From 3ae221529def9ab43e03e005b8ea201ccf4104c0 Mon Sep 17 00:00:00 2001 From: Shreyakb301 Date: Thu, 1 Oct 2026 16:47:01 -0500 Subject: [PATCH] feat: older-than flag --- README.md | 7 ++ src/cli/cli.controller.ts | 57 ++++++++++-- src/cli/interfaces/config.interface.ts | 2 + src/constants/cli.constants.ts | 6 ++ src/constants/main.constants.ts | 1 + src/constants/messages.constants.ts | 4 + src/utils/is-older-than.ts | 22 +++++ tests/cli/cli.controller.test.ts | 120 +++++++++++++++++++++++++ tests/utils/utils.test.ts | 23 +++++ 9 files changed, 236 insertions(+), 6 deletions(-) create mode 100644 src/utils/is-older-than.ts diff --git a/README.md b/README.md index 314c5676..3c34b521 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,7 @@ After pressing V to enter range selection mode: | -h, --help, ? | Show help page | | -nu, --no-check-update | Don't check for updates on startup | | -s, --sort | Sort results by: `size`, `path` or `age` | +| --older-than | Only show results whose project has not been modified in more than the given number of days (see the `last_mod` column). Example: `--older-than 90`. | | -t, --targets | Disable profiles feature and specify the name of the directories you want to search for. You can define multiple targets separating with comma. Ej. `-t node_modules,.cache`. | | -x, --exclude-sensitive | Exclude sensitive directories. | | -y | Avoid displaying a warning when executing --delete-all. | @@ -177,6 +178,12 @@ npkill npkill -d 'projects' --exclude "progress, ignore-this" ``` +- Automatically delete all node_modules from projects you have not touched in the last 90 days: + +```bash +npkill -d ~/projects --older-than 90 --delete-all --targets node_modules +``` + - Automatically delete all node_modules that have sneaked into your backups: ```bash diff --git a/src/cli/cli.controller.ts b/src/cli/cli.controller.ts index 4822921a..d25f4054 100644 --- a/src/cli/cli.controller.ts +++ b/src/cli/cli.controller.ts @@ -13,7 +13,7 @@ import { DEFAULT_PROFILE } from '../core/constants/profiles.constants.js'; import { ERROR_MSG, INFO_MSGS } from '../constants/messages.constants.js'; import { IConfig, CliScanFoundFolder, IKeyPress } from './interfaces/index.js'; import { firstValueFrom, Subject, Subscription } from 'rxjs'; -import { mergeMap, tap } from 'rxjs/operators'; +import { filter, mergeMap, tap } from 'rxjs/operators'; import { COLORS } from '../constants/cli.constants.js'; import { FOLDER_SORT } from '../constants/sort.result.js'; @@ -43,6 +43,7 @@ import { LoggerService } from '../core/services/logger.service.js'; import { ScanStatus } from '../core/interfaces/search-status.model.js'; import { isSafeToDelete } from '../utils/is-safe-to-delete.js'; import { getFileContent } from '../utils/get-file-content.js'; +import { isOlderThan } from '../utils/is-older-than.js'; import { ResultDetailsUi } from './ui/components/result-details.ui.js'; import { ScanService } from './services/scan.service.js'; import { JsonOutputService } from './services/json-output.service.js'; @@ -552,6 +553,22 @@ export class CliController { this.config.disableAge = true; } + if (options.isTrue('older-than')) { + const olderThan = options.getString('older-than'); + if (!/^\d+$/.test(olderThan) || Number(olderThan) <= 0) { + this.uiService.print(ERROR_MSG.INVALID_OLDER_THAN); + this.logger.error(ERROR_MSG.INVALID_OLDER_THAN); + this.exitWithError(); + } + this.config.olderThan = Number(olderThan); + } + + if (this.config.olderThan > 0 && this.config.disableAge) { + this.uiService.print(ERROR_MSG.CANT_USE_OLDER_THAN_WITHOUT_AGE); + this.logger.error(ERROR_MSG.CANT_USE_OLDER_THAN_WITHOUT_AGE); + this.exitWithError(); + } + if (this.config.jsonStream && this.config.jsonSimple) { this.logger.error(ERROR_MSG.CANT_USE_BOTH_JSON_OPTIONS); this.exitWithError(); @@ -751,6 +768,7 @@ export class CliController { }), 10, // Limit to 10 concurrent stat calculations at a time ), + filter((folder) => this.passesAgeFilter(folder)), tap((folder) => this.jsonOutputService.processResult(folder)), ) .subscribe({ @@ -808,13 +826,14 @@ export class CliController { private processNodeFolderForUi(nodeFolder: CliScanFoundFolder): void { this.searchStatus.newResult(); - this.resultsService.addResult(nodeFolder); - if (this.config.sortBy === 'path') { - this.resultsService.sortResults(this.config.sortBy); - this.uiResults.clear(); + // The age is not known yet, so wait for the stats before showing it. + if (this.isFilteringByAge()) { + return; } + this.addResultToUi(nodeFolder); + if (this.config.disableSize && this.config.deleteAll) { this.deleteFolder(nodeFolder); } @@ -824,13 +843,39 @@ export class CliController { private processFolderStatsForUi(folder: CliScanFoundFolder): void { this.searchStatus.completeStatCalculation(); + + const passesAgeFilter = this.passesAgeFilter(folder); + if (this.isFilteringByAge() && passesAgeFilter) { + this.addResultToUi(folder); + } + this.finishFolderStats(); - if (this.config.deleteAll) { + if (this.config.deleteAll && passesAgeFilter) { this.deleteFolder(folder); } } + private addResultToUi(folder: CliScanFoundFolder): void { + this.resultsService.addResult(folder); + + if (this.config.sortBy === 'path') { + this.resultsService.sortResults(this.config.sortBy); + this.uiResults.clear(); + } + } + + private isFilteringByAge(): boolean { + return this.config.olderThan > 0; + } + + private passesAgeFilter(folder: CliScanFoundFolder): boolean { + return ( + !this.isFilteringByAge() || + isOlderThan(folder.modificationTime, this.config.olderThan) + ); + } + private finishFolderStats(): void { const needSort = this.config.sortBy === 'size' || this.config.sortBy === 'age'; diff --git a/src/cli/interfaces/config.interface.ts b/src/cli/interfaces/config.interface.ts index bfa19054..f3a5e13c 100644 --- a/src/cli/interfaces/config.interface.ts +++ b/src/cli/interfaces/config.interface.ts @@ -17,4 +17,6 @@ export interface IConfig { disableSize: boolean; disableAge: boolean; disableStats: boolean; + /** Only show results whose project is older than this many days. 0 = disabled. */ + olderThan: number; } diff --git a/src/constants/cli.constants.ts b/src/constants/cli.constants.ts index 4bbec549..b809ae4f 100644 --- a/src/constants/cli.constants.ts +++ b/src/constants/cli.constants.ts @@ -113,6 +113,12 @@ export const OPTIONS: ICliOptions[] = [ 'Skip both size and age calculation. Shortcut for --no-size --no-age.', name: 'disable-stats', }, + { + arg: ['--older-than'], + description: + 'Only show results whose project has not been modified in more than the given number of days (based on age). Example: `--older-than 90`.', + name: 'older-than', + }, { arg: ['-v', '--version'], description: 'Show version.', diff --git a/src/constants/main.constants.ts b/src/constants/main.constants.ts index 774a5a24..cacdf34a 100644 --- a/src/constants/main.constants.ts +++ b/src/constants/main.constants.ts @@ -27,6 +27,7 @@ export const DEFAULT_CONFIG: IConfig = { disableSize: false, disableAge: false, disableStats: false, + olderThan: 0, }; export const MARGINS = { diff --git a/src/constants/messages.constants.ts b/src/constants/messages.constants.ts index b23defb8..3f28b15f 100644 --- a/src/constants/messages.constants.ts +++ b/src/constants/messages.constants.ts @@ -38,4 +38,8 @@ export const ERROR_MSG = { CANT_GET_REMOTE_VERSION: 'Couldnt check for updates', CANT_USE_BOTH_JSON_OPTIONS: 'Cannot use both --json and --json-stream options simultaneously.', + INVALID_OLDER_THAN: + 'Invalid --older-than value. It must be a positive whole number of days (e.g. --older-than 90).', + CANT_USE_OLDER_THAN_WITHOUT_AGE: + '--older-than needs the age of each result, so it cannot be used with --no-age or --no-stats.', }; diff --git a/src/utils/is-older-than.ts b/src/utils/is-older-than.ts new file mode 100644 index 00000000..6f262d95 --- /dev/null +++ b/src/utils/is-older-than.ts @@ -0,0 +1,22 @@ +const SECONDS_PER_DAY = 86400; + +/** + * Checks if a result was last modified more than `days` days ago. + * + * @param modificationTime Unix timestamp in seconds. Values <= 1 mean the + * age is unknown (not calculated or failed), and are never considered old. + * @param days Minimum age in days. + * @param now Current time in milliseconds. + */ +export function isOlderThan( + modificationTime: number, + days: number, + now: number = Date.now(), +): boolean { + if (modificationTime <= 1) { + return false; + } + + const ageInDays = (now / 1000 - modificationTime) / SECONDS_PER_DAY; + return ageInDays > days; +} diff --git a/tests/cli/cli.controller.test.ts b/tests/cli/cli.controller.test.ts index d5008638..f798d75c 100644 --- a/tests/cli/cli.controller.test.ts +++ b/tests/cli/cli.controller.test.ts @@ -255,6 +255,9 @@ describe('CliController test', () => { DEFAULT_CONFIG.deleteAll = false; DEFAULT_CONFIG.dryRun = false; DEFAULT_CONFIG.sortBy = 'none'; + DEFAULT_CONFIG.olderThan = 0; + DEFAULT_CONFIG.disableAge = false; + DEFAULT_CONFIG.disableSize = false; }); it('#showHelp should called if --help flag is present and exit', () => { @@ -406,6 +409,123 @@ describe('CliController test', () => { }); }); + describe('--older-than', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('Should set olderThan in the config when a valid number is given', () => { + mockParameters({ 'older-than': '90' }); + cliController.init(); + expect(DEFAULT_CONFIG.olderThan).toBe(90); + expect(scanSpy).toHaveBeenCalledTimes(1); + }); + + it.each(['abc', '0', '-5', '1.5', '90d'])( + 'Should show error and exit when the value is %p', + (value) => { + mockParameters({ 'older-than': value }); + const exitWithErrorSpy = spyMethod('exitWithError'); + cliController.init(); + expect(loggerServiceMock.error).toHaveBeenCalledWith( + ERROR_MSG.INVALID_OLDER_THAN, + ); + expect(exitWithErrorSpy).toHaveBeenCalled(); + }, + ); + + it('Should show error and exit when no value is given', () => { + mockParameters({ 'older-than': true }); + const exitWithErrorSpy = spyMethod('exitWithError'); + cliController.init(); + expect(loggerServiceMock.error).toHaveBeenCalledWith( + ERROR_MSG.INVALID_OLDER_THAN, + ); + expect(exitWithErrorSpy).toHaveBeenCalled(); + }); + + it.each(['disable-age', 'disable-stats'])( + 'Should show error and exit when used with %p', + (option) => { + mockParameters({ 'older-than': '90', [option]: true }); + const exitWithErrorSpy = spyMethod('exitWithError'); + cliController.init(); + expect(loggerServiceMock.error).toHaveBeenCalledWith( + ERROR_MSG.CANT_USE_OLDER_THAN_WITHOUT_AGE, + ); + expect(exitWithErrorSpy).toHaveBeenCalled(); + }, + ); + }); + + describe('age filtering', () => { + const daysAgo = (days: number) => Date.now() / 1000 - days * 86400; + const folder = (modificationTime: number) => ({ + path: '/my/project/node_modules', + size: 1, + modificationTime, + status: 'live', + }); + + let addResultMock; + let deleteFolderSpy; + + beforeEach(() => { + addResultMock = jest.fn(); + cliController.resultsService = { + addResult: addResultMock, + sortResults: jest.fn(), + }; + cliController.searchStatus = { + newResult: jest.fn(), + completeStatCalculation: jest.fn(), + }; + cliController.uiResults = { render: jest.fn(), clear: jest.fn() }; + spyMethod('finishFolderStats'); + deleteFolderSpy = spyMethod('deleteFolder'); + }); + + it('Should add results as soon as they are found when not filtering', () => { + DEFAULT_CONFIG.olderThan = 0; + cliController.processNodeFolderForUi(folder(-1)); + expect(addResultMock).toHaveBeenCalledTimes(1); + }); + + it('Should wait for the stats before adding results when filtering', () => { + DEFAULT_CONFIG.olderThan = 90; + cliController.processNodeFolderForUi(folder(-1)); + expect(addResultMock).not.toHaveBeenCalled(); + }); + + it('Should only add and delete results older than the given days', () => { + DEFAULT_CONFIG.olderThan = 90; + DEFAULT_CONFIG.deleteAll = true; + + const oldFolder = folder(daysAgo(120)); + const recentFolder = folder(daysAgo(10)); + const unknownAgeFolder = folder(1); + + cliController.processFolderStatsForUi(oldFolder); + cliController.processFolderStatsForUi(recentFolder); + cliController.processFolderStatsForUi(unknownAgeFolder); + + expect(addResultMock).toHaveBeenCalledTimes(1); + expect(addResultMock).toHaveBeenCalledWith(oldFolder); + expect(deleteFolderSpy).toHaveBeenCalledTimes(1); + expect(deleteFolderSpy).toHaveBeenCalledWith(oldFolder); + }); + + it('Should not delete results before their age is known with --no-size', () => { + DEFAULT_CONFIG.olderThan = 90; + DEFAULT_CONFIG.deleteAll = true; + DEFAULT_CONFIG.disableSize = true; + + cliController.processNodeFolderForUi(folder(-1)); + + expect(deleteFolderSpy).not.toHaveBeenCalled(); + }); + }); + describe('TTY Handling', () => { it('Should run normally even if stdout is NOT TTY', () => { Object.defineProperty(process.stdout, 'isTTY', { diff --git a/tests/utils/utils.test.ts b/tests/utils/utils.test.ts index 2e232af3..d8f2704c 100644 --- a/tests/utils/utils.test.ts +++ b/tests/utils/utils.test.ts @@ -5,6 +5,7 @@ import { formatSize, } from '../../src/utils/unit-conversions.js'; import { isSafeToDelete } from '../../src/utils/is-safe-to-delete.js'; +import { isOlderThan } from '../../src/utils/is-older-than.js'; describe('unit-conversions', () => { it('#convertBytesToKB', () => { @@ -99,3 +100,25 @@ describe('is-safe-to-delete', () => { expect(isSafeToDelete('/home/user/dIst', [...target, 'dIst'])).toBeTruthy(); }); }); + +describe('is-older-than', () => { + const now = new Date('2026-10-01T00:00:00Z').getTime(); + const daysAgo = (days: number) => now / 1000 - days * 86400; + + it('should return true when the result is older than the given days', () => { + expect(isOlderThan(daysAgo(91), 90, now)).toBe(true); + expect(isOlderThan(daysAgo(365), 90, now)).toBe(true); + }); + + it('should return false when the result is not older than the given days', () => { + expect(isOlderThan(daysAgo(89), 90, now)).toBe(false); + expect(isOlderThan(daysAgo(90), 90, now)).toBe(false); + expect(isOlderThan(daysAgo(0), 90, now)).toBe(false); + }); + + it('should return false when the age is unknown', () => { + expect(isOlderThan(-1, 90, now)).toBe(false); + expect(isOlderThan(0, 90, now)).toBe(false); + expect(isOlderThan(1, 90, now)).toBe(false); + }); +});