Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ After pressing <kbd>V</kbd> 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. |
Expand Down Expand Up @@ -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
Expand Down
57 changes: 51 additions & 6 deletions src/cli/cli.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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);
}
Expand All @@ -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';
Expand Down
2 changes: 2 additions & 0 deletions src/cli/interfaces/config.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
6 changes: 6 additions & 0 deletions src/constants/cli.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down
1 change: 1 addition & 0 deletions src/constants/main.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export const DEFAULT_CONFIG: IConfig = {
disableSize: false,
disableAge: false,
disableStats: false,
olderThan: 0,
};

export const MARGINS = {
Expand Down
4 changes: 4 additions & 0 deletions src/constants/messages.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
};
22 changes: 22 additions & 0 deletions src/utils/is-older-than.ts
Original file line number Diff line number Diff line change
@@ -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;
}
120 changes: 120 additions & 0 deletions tests/cli/cli.controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', {
Expand Down
23 changes: 23 additions & 0 deletions tests/utils/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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);
});
});