@hunchoweb/nclean 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -0
- package/dist/cleanup/remove.js +3 -0
- package/dist/cli.js +1 -1
- package/dist/scanner/activity.js +3 -0
- package/dist/scanner/discover.js +5 -2
- package/dist/utils/paths.js +2 -0
- package/dist/utils/project-scope.d.ts +2 -0
- package/dist/utils/project-scope.js +26 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -135,6 +135,21 @@ The current working directory is the scan root. Run `nclean` inside one project
|
|
|
135
135
|
|
|
136
136
|
Explicit paths can be absolute, relative, or home-relative (`~`). Overlapping roots are deduplicated. Filesystem roots and paths inside `node_modules` are rejected. Symlinked scan roots are rejected.
|
|
137
137
|
|
|
138
|
+
### Projects, not installed tools
|
|
139
|
+
|
|
140
|
+
The exclusions below are included in **0.1.1 and newer**. Users of 0.1.0 should update before scanning broad directories:
|
|
141
|
+
|
|
142
|
+
```sh
|
|
143
|
+
npm install --global @hunchoweb/nclean@latest
|
|
144
|
+
nclean --version
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
nclean excludes hidden directories (including `.vscode`, `.cursor`, `.npm`, `.nvm`, `.cranq`, and generated `.next` folders), application bundles, operating-system application/data directories such as `Library` and `AppData`, and generated trees such as `dist`, `build`, `coverage`, and `vendor`. These exclusions apply to the full directory path, including ancestors of an explicitly selected scan root, and are checked again before deletion. `--yes` does not override them.
|
|
148
|
+
|
|
149
|
+
A source project for a VS Code extension can still be cleaned when it lives in an ordinary project directory. An installed copy beneath `.vscode/extensions` cannot. Projects stored in hidden or excluded directories are intentionally ineligible; move source projects to an ordinary project directory to use nclean.
|
|
150
|
+
|
|
151
|
+
A `package.json` identifies a Node.js package, but cannot prove it is a project you personally worked on. These exclusions protect known tool locations; custom installations in ordinary folders may still look like projects. Review the selection and scope scans to your source directories, such as `~/Code` or `~/Projects`.
|
|
152
|
+
|
|
138
153
|
### Partial scans
|
|
139
154
|
|
|
140
155
|
Protected or unreadable folders are skipped, with a compact notice:
|
|
@@ -177,6 +192,7 @@ For a workspace, install at the workspace root when your package manager require
|
|
|
177
192
|
**Cleanup removes only discovered, real `project/node_modules` directories.** Project folders, source, manifests, lockfiles, and `.git` are preserved.
|
|
178
193
|
|
|
179
194
|
- Interactive cleanup always requires final confirmation, defaulting to No.
|
|
195
|
+
- Hidden folders, installed-tool locations, app bundles, and generated trees are excluded, including when selected directly or with `--yes`.
|
|
180
196
|
- `scan` and `--dry-run` never delete anything.
|
|
181
197
|
- Scanning and sizing never follow symlinks or Windows directory junctions.
|
|
182
198
|
- Root containment, parent directories, manifest, and directory identity are checked again before deletion.
|
package/dist/cleanup/remove.js
CHANGED
|
@@ -2,9 +2,11 @@ import { lstat, rm } from 'node:fs/promises';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { assertRealPath } from '../utils/paths.js';
|
|
4
4
|
import { directorySize } from '../scanner/size.js';
|
|
5
|
+
import { assertProjectScope } from '../utils/project-scope.js';
|
|
5
6
|
export async function removeCandidate(entry, dryRun = false) {
|
|
6
7
|
if (path.basename(entry.path) !== 'node_modules' || path.dirname(entry.path) !== entry.project)
|
|
7
8
|
throw new Error('Refusing to remove anything except project/node_modules.');
|
|
9
|
+
assertProjectScope(entry.project);
|
|
8
10
|
if (entry.bytes === null || entry.error || entry.lastActive === null)
|
|
9
11
|
throw new Error('Size or activity is unknown; refusing cleanup.');
|
|
10
12
|
await assertRealPath(entry.root, entry.path);
|
|
@@ -21,6 +23,7 @@ export async function removeCandidate(entry, dryRun = false) {
|
|
|
21
23
|
const latest = await lstat(entry.path);
|
|
22
24
|
if (latest.dev !== entry.device || latest.ino !== entry.inode)
|
|
23
25
|
throw new Error('node_modules changed before cleanup.');
|
|
26
|
+
assertProjectScope(entry.project);
|
|
24
27
|
await rm(entry.path, { recursive: true, force: false, maxRetries: 2 });
|
|
25
28
|
return measured;
|
|
26
29
|
}
|
package/dist/cli.js
CHANGED
|
@@ -11,7 +11,7 @@ function days(value) {
|
|
|
11
11
|
}
|
|
12
12
|
const program = new Command();
|
|
13
13
|
program.configureHelp({ showGlobalOptions: true });
|
|
14
|
-
program.name('nclean').description('Clean up forgotten Node.js dependencies.').version('0.1.
|
|
14
|
+
program.name('nclean').description('Clean up forgotten Node.js dependencies.').version('0.1.1')
|
|
15
15
|
.argument('[paths...]', 'Directories to scan (default: current directory)')
|
|
16
16
|
.option('--older-than <days>', 'Only include projects inactive for at least this many days', days)
|
|
17
17
|
.option('--dry-run', 'Preview candidates; never delete anything')
|
package/dist/scanner/activity.js
CHANGED
|
@@ -2,6 +2,7 @@ import { opendir, lstat } from 'node:fs/promises';
|
|
|
2
2
|
import { execFile } from 'node:child_process';
|
|
3
3
|
import { promisify } from 'node:util';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
+
import { projectScopeReason } from '../utils/project-scope.js';
|
|
5
6
|
const exec = promisify(execFile);
|
|
6
7
|
export const ignored = new Set(['node_modules', '.git', '.next', '.nuxt', '.cache', '.turbo', 'dist', 'build', 'coverage', 'vendor']);
|
|
7
8
|
const relevant = /\.(?:[cm]?[jt]sx?|vue|svelte|astro|json|ya?ml|toml|html|css|scss|md|graphql|prisma)$/i;
|
|
@@ -22,6 +23,8 @@ export async function lastActivity(project) {
|
|
|
22
23
|
continue;
|
|
23
24
|
const target = path.join(folder, entry.name);
|
|
24
25
|
if (entry.isDirectory()) {
|
|
26
|
+
if (projectScopeReason(target))
|
|
27
|
+
continue;
|
|
25
28
|
// Nested projects have their own activity signals.
|
|
26
29
|
try {
|
|
27
30
|
if ((await lstat(path.join(target, 'package.json'))).isFile())
|
package/dist/scanner/discover.js
CHANGED
|
@@ -4,6 +4,7 @@ import { detectManager } from '../utils/package-manager.js';
|
|
|
4
4
|
import { errorMessage } from '../utils/format.js';
|
|
5
5
|
import { ignored, lastActivity } from './activity.js';
|
|
6
6
|
import { directorySize } from './size.js';
|
|
7
|
+
import { assertProjectScope, projectScopeReason } from '../utils/project-scope.js';
|
|
7
8
|
export async function discover(roots, progress = () => { }) {
|
|
8
9
|
const result = { projects: 0, entries: [], warnings: [] };
|
|
9
10
|
async function walk(folder, root) {
|
|
@@ -33,7 +34,7 @@ export async function discover(roots, progress = () => { }) {
|
|
|
33
34
|
}
|
|
34
35
|
progress(`Discovering projects · ${result.projects} found`, { phase: 'discovery', projects: result.projects, folders: result.entries.length, completed: 0, total: null });
|
|
35
36
|
for await (const entry of await opendir(folder)) {
|
|
36
|
-
if (entry.isDirectory() && !entry.isSymbolicLink() && !ignored.has(entry.name))
|
|
37
|
+
if (entry.isDirectory() && !entry.isSymbolicLink() && !ignored.has(entry.name) && !projectScopeReason(path.join(folder, entry.name)))
|
|
37
38
|
await walk(path.join(folder, entry.name), root);
|
|
38
39
|
}
|
|
39
40
|
}
|
|
@@ -41,8 +42,10 @@ export async function discover(roots, progress = () => { }) {
|
|
|
41
42
|
result.warnings.push(`${folder}: ${errorMessage(error)}`);
|
|
42
43
|
}
|
|
43
44
|
}
|
|
44
|
-
for (const root of roots)
|
|
45
|
+
for (const root of roots) {
|
|
46
|
+
assertProjectScope(root);
|
|
45
47
|
await walk(root, root);
|
|
48
|
+
}
|
|
46
49
|
let next = 0, complete = 0;
|
|
47
50
|
progress('Measuring dependencies', { phase: 'measurement', projects: result.projects, folders: result.entries.length, completed: 0, total: result.entries.length });
|
|
48
51
|
async function worker() {
|
package/dist/utils/paths.js
CHANGED
|
@@ -2,6 +2,7 @@ import path from 'node:path';
|
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import { lstat, realpath } from 'node:fs/promises';
|
|
4
4
|
import { terminalText } from './format.js';
|
|
5
|
+
import { assertProjectScope } from './project-scope.js';
|
|
5
6
|
export function expandPath(input) {
|
|
6
7
|
if (input === '~')
|
|
7
8
|
return os.homedir();
|
|
@@ -27,6 +28,7 @@ export async function scanRoots(inputs) {
|
|
|
27
28
|
throw new Error('Scanning the filesystem root is not supported. Choose a project directory.');
|
|
28
29
|
if (canonical.split(path.sep).includes('node_modules'))
|
|
29
30
|
throw new Error('Choose a project directory, not a directory inside node_modules.');
|
|
31
|
+
assertProjectScope(canonical);
|
|
30
32
|
roots.push(canonical);
|
|
31
33
|
}
|
|
32
34
|
return [...new Set(roots)].filter(root => !roots.some(other => other !== root && within(other, root)));
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
// Apply to the full path so choosing a protected subdirectory explicitly
|
|
3
|
+
// cannot bypass the same exclusions used during recursive discovery.
|
|
4
|
+
const generated = new Set(['node_modules', 'dist', 'build', 'coverage', 'vendor']);
|
|
5
|
+
const managed = new Set(['library', 'applications', 'appdata', 'program files', 'program files (x86)', 'programdata', 'windows']);
|
|
6
|
+
const systemRoots = ['/usr', '/opt', '/System', '/Applications', '/Library'];
|
|
7
|
+
export function projectScopeReason(folder) {
|
|
8
|
+
const absolute = path.resolve(folder);
|
|
9
|
+
if (process.platform !== 'win32' && systemRoots.some(root => absolute === root || absolute.startsWith(`${root}/`))) {
|
|
10
|
+
return 'system or installed-software directory';
|
|
11
|
+
}
|
|
12
|
+
for (const part of absolute.split(path.sep).filter(Boolean)) {
|
|
13
|
+
if (part.startsWith('.'))
|
|
14
|
+
return 'hidden or tool-managed directory';
|
|
15
|
+
if (generated.has(part.toLowerCase()))
|
|
16
|
+
return 'dependency or generated directory';
|
|
17
|
+
if (managed.has(part.toLowerCase()) || /\.(?:app|framework|bundle)$/i.test(part))
|
|
18
|
+
return 'application or tool-managed directory';
|
|
19
|
+
}
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
export function assertProjectScope(folder) {
|
|
23
|
+
const reason = projectScopeReason(folder);
|
|
24
|
+
if (reason)
|
|
25
|
+
throw new Error(`Refusing project cleanup in a ${reason}. Choose a source project directory.`);
|
|
26
|
+
}
|