@hunchoweb/nclean 0.0.0-stage → 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 nclean contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,245 @@
1
- # Temporary Holding Version
1
+ <div align="center">
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ # nclean
4
+
5
+ **Node modules cleanup, without the drama.**
6
+
7
+ Find forgotten dependencies. Reclaim their space. Keep your projects.
8
+
9
+ [Quick start](#quick-start) · [Commands](#commands) · [Safety](#safety) · [Development](#development)
10
+
11
+ </div>
12
+
13
+ ```text
14
+ __
15
+ ____ _____ / / ____ ____ ____
16
+ / __ \ / ___/ / / / __ \ / __ | / __ \
17
+ / / / / / /__ / /__ / ____/ / /_/ / / / / /
18
+ /_/ /_/ \____/ \___/ \___/ \__,_/ /_/ /_/
19
+ ```
20
+
21
+ Old side projects, finished client work, abandoned experiments: each can leave thousands of dependency files behind. **nclean finds `node_modules`, shows what they cost, and lets you choose what to remove.** Your source, manifests, lockfiles, and Git history stay in place.
22
+
23
+ Built with TypeScript for Node.js 22+ · macOS / Linux / Windows · MIT
24
+
25
+ ## Quick start
26
+
27
+ Install with npm (Node.js 22 or newer):
28
+
29
+ ```sh
30
+ npm install --global @hunchoweb/nclean
31
+ ```
32
+
33
+ Then go to a project or the directory that holds your projects:
34
+
35
+ ```sh
36
+ cd ~/Code
37
+ nclean
38
+ ```
39
+
40
+ **No path required.** nclean scans the directory you ran it from, including nested projects. A path overrides that default:
41
+
42
+ ```sh
43
+ nclean ~/Projects
44
+ nclean ~/Code ~/Work
45
+ ```
46
+
47
+ Update to the latest release:
48
+
49
+ ```sh
50
+ npm install --global @hunchoweb/nclean
51
+ ```
52
+
53
+ To build from source or contribute, follow [Development](#development).
54
+
55
+ ## The experience
56
+
57
+ 1. Discover projects in the current directory.
58
+ 2. Measure dependencies with a live progress bar.
59
+ 3. Select folders in a bordered, keyboard-driven list.
60
+ 4. Review the selection and confirm with **Yes**.
61
+ 5. See the measured space reclaimed and commands to restore dependencies.
62
+
63
+ Illustrative terminal excerpt; counts and sizes depend on your projects:
64
+
65
+ ```text
66
+ $ nclean --older-than 30
67
+
68
+ Scanning ~/Code...
69
+
70
+ █████████████████████░░░░░░░░░░░ 66%
71
+
72
+ 4/6 dependency folders measured
73
+
74
+ 7 projects scanned
75
+ 6 dependency folders found
76
+ 4 inactive projects
77
+
78
+ 4.0 MB reclaimable
79
+
80
+ Select dependencies to remove
81
+
82
+ ┌───────────────────────────────────────────────────────────────┐
83
+ │ Project Size Inactive PM │
84
+ ├───────────────────────────────────────────────────────────────┤
85
+ │ › ● monorepo/apps/web 1.0 MB 70d npm │
86
+ │ ● shelf 1.0 MB 47d pnpm │
87
+ │ ○ old-client 1.0 MB 3mo npm │
88
+ │ ○ design-system 1.0 MB 60d yarn │
89
+ └───────────────────────────────────────────────────────────────┘
90
+
91
+ 2 selected · 2.0 MB
92
+
93
+ [space] select [a] all [enter] clean [esc] cancel
94
+ ```
95
+
96
+ Use ↑/↓ to navigate. **Nothing is preselected.** Enter opens the final review; it does not immediately delete your selection. Confirmation defaults to **No**. Choose `y`, then press Enter to approve, or press Esc to cancel.
97
+
98
+ During discovery, the bar moves without a percentage because the total is still unknown. Once discovery finishes, the percentage reflects completed dependency-folder measurements. Narrow terminals shorten paths and hide secondary columns.
99
+
100
+ ## Commands
101
+
102
+ ```sh
103
+ nclean # Select dependencies in the current directory
104
+ nclean scan # Report sizes and activity; never delete
105
+ nclean --dry-run # Preview eligible folders without prompts
106
+ nclean --older-than 30 # Show projects inactive for at least 30 days
107
+ nclean clean --older-than 30 # Review and confirm cleanup of all matches
108
+ nclean clean --older-than 30 --yes # Explicitly authorize unattended cleanup
109
+ nclean --verbose # Include detailed scan warnings
110
+ nclean --help # Show usage and examples
111
+ nclean --version # Show the installed version
112
+ ```
113
+
114
+ Every scan or cleanup command accepts optional paths:
115
+
116
+ ```sh
117
+ nclean scan ~/Projects
118
+ nclean ~/Code ~/Work --older-than 60
119
+ nclean clean ./archive --older-than 90 --dry-run
120
+ nclean clean ./archive --older-than 90 --yes
121
+ ```
122
+
123
+ | Option | Behavior |
124
+ | --- | --- |
125
+ | `--older-than <days>` | Include projects inactive for at least that many whole days. |
126
+ | `--dry-run` | Preview only. No deletion, even with `--yes`. |
127
+ | `--verbose` | Show full scan diagnostics and unreadable paths. |
128
+ | `-y, --yes` | Skip confirmation on the explicit `clean` command only. |
129
+
130
+ Options work before or after a subcommand. With no age filter, all known ages are eligible. In a noninteractive shell, the default command prints a report and deletes nothing; `clean` requires explicit `--yes` or a terminal confirmation.
131
+
132
+ ### Choose your scan scope
133
+
134
+ The current working directory is the scan root. Run `nclean` inside one project to inspect that project and its workspaces; run it from `~/Code` to inspect projects beneath that directory. Running it from your home directory scans your home directory.
135
+
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
+
138
+ ### Partial scans
139
+
140
+ Protected or unreadable folders are skipped, with a compact notice:
141
+
142
+ ```text
143
+ Partial scan · 12 folders skipped (12 access denied).
144
+ Use --verbose for details. Accessible projects are still shown.
145
+ ```
146
+
147
+ Use `nclean scan --verbose` for the detailed paths. A partial scan does not claim your whole directory is clean. Unknown activity or unreadable size excludes a dependency folder from cleanup.
148
+
149
+ ## How inactivity works
150
+
151
+ A project contains a regular `package.json`. nclean estimates its last activity using the **newest** of:
152
+
153
+ - The latest Git commit affecting the project, when Git is available.
154
+ - Manifest and lockfile modification times.
155
+ - Relevant source and configuration modification times, including JS, TS, Vue, Svelte, Astro, styles, Markdown, JSON, YAML, and Prisma files.
156
+
157
+ Dependency timestamps do not determine inactivity. Generated directories such as `dist`, `build`, `.next`, and `coverage`, along with `.git` internals, are excluded. Nested workspace projects are inspected separately; a parent's Git history can conservatively keep that parent active.
158
+
159
+ This is an estimate. A fresh checkout may look active, while simply opening or reading a project does not update its activity. When activity cannot be read reliably, nclean marks it **unknown** and skips cleanup.
160
+
161
+ ## Restore dependencies
162
+
163
+ Run the detected package manager's install command in the project directory. nclean groups the restore instructions after cleanup.
164
+
165
+ | Lockfile | Restore command |
166
+ | --- | --- |
167
+ | `package-lock.json` / `npm-shrinkwrap.json` | `npm install` |
168
+ | `pnpm-lock.yaml` | `pnpm install` |
169
+ | `yarn.lock` | `yarn install` |
170
+ | `bun.lock` / `bun.lockb` | `bun install` |
171
+ | No lockfile | `npm install` |
172
+
173
+ For a workspace, install at the workspace root when your package manager requires it. If multiple lockfiles exist, detection prefers pnpm, yarn, bun, then npm.
174
+
175
+ ## Safety
176
+
177
+ **Cleanup removes only discovered, real `project/node_modules` directories.** Project folders, source, manifests, lockfiles, and `.git` are preserved.
178
+
179
+ - Interactive cleanup always requires final confirmation, defaulting to No.
180
+ - `scan` and `--dry-run` never delete anything.
181
+ - Scanning and sizing never follow symlinks or Windows directory junctions.
182
+ - Root containment, parent directories, manifest, and directory identity are checked again before deletion.
183
+ - Unreadable or unknown entries are excluded. Removal failures are reported while other selected entries continue.
184
+ - Dependencies inside an already discovered `node_modules` are counted within that tree rather than reported again.
185
+
186
+ A normal project can reinstall dependencies from its manifest and lockfile. **Local edits or manually placed files inside `node_modules` may not be recoverable.** Without a lockfile, reinstalling may resolve different versions.
187
+
188
+ Sizes are logical file bytes. Symlinks are skipped and hard links count once per dependency directory. Successful removals are measured again before deletion; failed or partial removals do not contribute to the success total. Physical free space can differ because of shared hard links, compression, allocation, sparse files, and snapshots.
189
+
190
+ Avoid installing dependencies or replacing directories during cleanup. Portable Node APIs cannot make path checks and recursive removal atomic against concurrent directory replacement.
191
+
192
+ Exit code `1` indicates scan warnings, invalid input, or cleanup failures. Normal reports, empty results, dry runs, and declined confirmation return `0`.
193
+
194
+ ## Development
195
+
196
+ ```sh
197
+ npm install
198
+ npm run dev -- --dry-run
199
+ npm run build
200
+ npm link
201
+ npm test
202
+ npm run lint
203
+ ```
204
+
205
+ The source is strict TypeScript and ESM. Directory listings are streamed; up to four projects are measured concurrently. Git checks have a timeout. `lint` performs TypeScript checking; tests use isolated temporary directories to exercise discovery, activity, sizing, paths, symlinks, cleanup, prompts, progress, and diagnostics.
206
+
207
+ ### Record a demo
208
+
209
+ ```sh
210
+ npm run build
211
+ npm run demo:setup
212
+ ```
213
+
214
+ The script creates fake projects in a fresh temporary directory, with npm, pnpm, yarn, bun, a nested project, and different activity ages. Each fake dependency folder contains 1 MB. Copy the printed directory, then:
215
+
216
+ ```sh
217
+ cd <printed-directory>
218
+ nclean --older-than 30
219
+ ```
220
+
221
+ If you have not run `npm link`, use the absolute path to `dist/cli.js`. Run `npm run demo:setup` again for a fresh fixture after cleanup.
222
+
223
+ ### Contribute
224
+
225
+ Keep changes focused on Node.js dependency cleanup. For bugs, include a reproduction or temporary-directory test. Run the build, tests, and type check before opening a pull request. Cross-platform testing reports are welcome.
226
+
227
+ ### Publish
228
+
229
+ Maintainers publish `@hunchoweb/nclean` as a public package to the npm registry. The executable remains `nclean`. Sign in with an npm account that has publishing access:
230
+
231
+ ```sh
232
+ npm login --registry=https://registry.npmjs.org/
233
+ npm whoami
234
+ npm view @hunchoweb/nclean name version
235
+ npm pack --dry-run
236
+ npm publish --access public
237
+ ```
238
+
239
+ An `E404` from the name lookup means no public package was found; npm decides whether the name can be claimed when publishing. Publishing runs the tests and type check, and packing builds automatically. Complete npm's authentication and two-factor prompts when requested.
240
+
241
+ The package includes compiled `dist/` files, the README, the MIT license, and package metadata. Runtime dependencies are installed by npm; users do not need TypeScript or a repository checkout. Test the packed tarball in an isolated install before releasing. For subsequent releases, bump the package version and CLI version together; npm does not allow reusing a published version.
242
+
243
+ ## License
244
+
245
+ [MIT](LICENSE) © nclean contributors
@@ -0,0 +1,2 @@
1
+ import type { Candidate } from '../types.js';
2
+ export declare function removeCandidate(entry: Candidate, dryRun?: boolean): Promise<number>;
@@ -0,0 +1,26 @@
1
+ import { lstat, rm } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { assertRealPath } from '../utils/paths.js';
4
+ import { directorySize } from '../scanner/size.js';
5
+ export async function removeCandidate(entry, dryRun = false) {
6
+ if (path.basename(entry.path) !== 'node_modules' || path.dirname(entry.path) !== entry.project)
7
+ throw new Error('Refusing to remove anything except project/node_modules.');
8
+ if (entry.bytes === null || entry.error || entry.lastActive === null)
9
+ throw new Error('Size or activity is unknown; refusing cleanup.');
10
+ await assertRealPath(entry.root, entry.path);
11
+ const manifest = await lstat(path.join(entry.project, 'package.json'));
12
+ if (!manifest.isFile() || manifest.isSymbolicLink())
13
+ throw new Error('Project manifest is missing or changed.');
14
+ const stat = await lstat(entry.path);
15
+ if (stat.dev !== entry.device || stat.ino !== entry.inode)
16
+ throw new Error('node_modules changed since scanning; scan again.');
17
+ if (dryRun)
18
+ return 0;
19
+ const measured = await directorySize(entry.path);
20
+ await assertRealPath(entry.root, entry.path);
21
+ const latest = await lstat(entry.path);
22
+ if (latest.dev !== entry.device || latest.ino !== entry.inode)
23
+ throw new Error('node_modules changed before cleanup.');
24
+ await rm(entry.path, { recursive: true, force: false, maxRetries: 2 });
25
+ return measured;
26
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env node
2
+ import { Command, InvalidArgumentError } from 'commander';
3
+ import { scan } from './commands/scan.js';
4
+ import { clean } from './commands/clean.js';
5
+ import { banner, table, message } from './ui/output.js';
6
+ import { errorMessage, terminalText } from './utils/format.js';
7
+ function days(value) {
8
+ if (!/^\d+$/.test(value) || !Number.isSafeInteger(Number(value)))
9
+ throw new InvalidArgumentError('Use a non-negative whole number of days, e.g. 30.');
10
+ return Number(value);
11
+ }
12
+ const program = new Command();
13
+ program.configureHelp({ showGlobalOptions: true });
14
+ program.name('nclean').description('Clean up forgotten Node.js dependencies.').version('0.1.0')
15
+ .argument('[paths...]', 'Directories to scan (default: current directory)')
16
+ .option('--older-than <days>', 'Only include projects inactive for at least this many days', days)
17
+ .option('--dry-run', 'Preview candidates; never delete anything')
18
+ .option('--verbose', 'Show detailed scan warnings and unreadable paths')
19
+ .addHelpText('after', `\nExamples:\n nclean Select dependencies in the current directory\n nclean ~/Projects ~/Code Scan specific directories\n nclean --dry-run --older-than 30 Preview old dependencies\n nclean scan ~/Projects Report only\n nclean clean ~/Projects --older-than 30\n nclean clean ~/Projects --older-than 30 --yes\n\nWithout paths, scans the current directory and nested projects.\nIncludes all ages unless --older-than is set; nothing is preselected.\nCleanup always requires confirmation unless clean --yes is explicit.\nSizes are file bytes, not a guarantee of physical free disk space.\n`)
20
+ .action(async (paths, options) => {
21
+ banner();
22
+ const result = await scan(paths, options);
23
+ if (result.entries.length)
24
+ await clean(result.entries, options, true);
25
+ });
26
+ program.command('scan').description('Find dependencies and report their sizes without deleting')
27
+ .argument('[paths...]', 'Directories to scan (default: current directory)')
28
+ .action(async (paths, _options, command) => {
29
+ banner();
30
+ const result = await scan(paths, command.optsWithGlobals());
31
+ if (result.entries.length)
32
+ table(result.entries);
33
+ });
34
+ program.command('clean').description('Print matching directories and confirm bulk deletion')
35
+ .argument('[paths...]', 'Directories to scan (default: current directory)')
36
+ .option('-y, --yes', 'Explicitly authorize deletion without a prompt')
37
+ .action(async (paths, _options, command) => {
38
+ banner();
39
+ const options = command.optsWithGlobals();
40
+ const result = await scan(paths, options);
41
+ if (result.entries.length)
42
+ await clean(result.entries, options, false);
43
+ });
44
+ try {
45
+ await program.parseAsync();
46
+ }
47
+ catch (error) {
48
+ const name = error instanceof Error ? error.name : '';
49
+ if (['ExitPromptError', 'AbortPromptError', 'AbortError'].includes(name))
50
+ message('CANCELLED', 'Nothing further was deleted.');
51
+ else {
52
+ console.error(`Error: ${terminalText(errorMessage(error))}`);
53
+ process.exitCode = 1;
54
+ }
55
+ }
@@ -0,0 +1,2 @@
1
+ import type { Candidate, Options } from '../types.js';
2
+ export declare function clean(entries: Candidate[], options: Options, interactiveSelection: boolean): Promise<void>;
@@ -0,0 +1,67 @@
1
+ import { removeCandidate } from '../cleanup/remove.js';
2
+ import { plan, restores, table, message, note, cleanupComplete, deletionFailure } from '../ui/output.js';
3
+ import { selectEntries, confirmCleanup, isInteractive } from '../ui/interactive.js';
4
+ import { errorMessage } from '../utils/format.js';
5
+ import { startCleanupProgress } from '../ui/progress.js';
6
+ export async function clean(entries, options, interactiveSelection) {
7
+ const eligible = entries.filter(entry => entry.bytes !== null && entry.lastActive !== null && !entry.error);
8
+ const skipped = entries.length - eligible.length;
9
+ if (skipped)
10
+ note(`Skipping ${skipped} directories with unknown activity or unreadable size.`);
11
+ if (options.dryRun) {
12
+ table(entries);
13
+ message('DRY RUN', `${eligible.length} eligible ${eligible.length === 1 ? 'directory' : 'directories'}. Nothing was deleted.`);
14
+ return;
15
+ }
16
+ if (!eligible.length) {
17
+ message('NOTHING TO CLEAN', 'No directories eligible for cleanup.');
18
+ return;
19
+ }
20
+ if (interactiveSelection && !isInteractive()) {
21
+ table(entries);
22
+ note('Selection requires an interactive terminal. Nothing was deleted.');
23
+ note('For scripts: nclean clean <path> --older-than 30 --yes');
24
+ console.log();
25
+ return;
26
+ }
27
+ const selected = interactiveSelection ? await selectEntries(eligible) : eligible;
28
+ if (!selected.length) {
29
+ message('CANCELLED', 'Nothing selected. Nothing was deleted.');
30
+ return;
31
+ }
32
+ plan(selected);
33
+ // --yes is only honored by the explicit clean command.
34
+ if (!options.yes || interactiveSelection) {
35
+ if (!isInteractive())
36
+ throw new Error('Confirmation requires an interactive terminal. Review with --dry-run, or explicitly use clean --yes. Nothing was deleted.');
37
+ if (!await confirmCleanup()) {
38
+ message('CANCELLED', 'Nothing was deleted.');
39
+ return;
40
+ }
41
+ }
42
+ const removed = [];
43
+ const failures = [];
44
+ let bytes = 0;
45
+ const progress = startCleanupProgress(selected.length);
46
+ try {
47
+ for (const entry of selected) {
48
+ try {
49
+ bytes += await removeCandidate(entry);
50
+ removed.push(entry);
51
+ }
52
+ catch (error) {
53
+ process.exitCode = 1;
54
+ failures.push({ entry, reason: errorMessage(error) });
55
+ }
56
+ progress.update(removed.length + failures.length, failures.length);
57
+ }
58
+ }
59
+ finally {
60
+ progress.stop();
61
+ }
62
+ cleanupComplete(removed, bytes, failures.length);
63
+ for (const failure of failures)
64
+ deletionFailure(failure.entry, selected, failure.reason);
65
+ if (removed.length)
66
+ restores(removed);
67
+ }
@@ -0,0 +1,2 @@
1
+ import type { Options, ScanResult } from '../types.js';
2
+ export declare function scan(inputs: string[], options: Options): Promise<ScanResult>;
@@ -0,0 +1,44 @@
1
+ import { scanRoots } from '../utils/paths.js';
2
+ import { discover } from '../scanner/discover.js';
3
+ import { oldEnough } from '../utils/format.js';
4
+ import { scanning, scanComplete, message, note } from '../ui/output.js';
5
+ import { startScanProgress } from '../ui/progress.js';
6
+ import { scanDiagnostics } from '../ui/diagnostics.js';
7
+ export async function scan(inputs, options) {
8
+ const roots = await scanRoots(inputs);
9
+ scanning(roots);
10
+ const progress = startScanProgress();
11
+ let result;
12
+ try {
13
+ result = await discover(roots, (_message, state) => progress.update(state));
14
+ }
15
+ finally {
16
+ progress.stop();
17
+ }
18
+ const folderCount = result.entries.length;
19
+ const diagnostics = scanDiagnostics(result.warnings, result.entries, options.verbose);
20
+ if (diagnostics) {
21
+ console.error(diagnostics);
22
+ process.exitCode = 1;
23
+ }
24
+ if (options.olderThan !== undefined) {
25
+ const unknown = result.entries.filter(entry => entry.lastActive === null).length;
26
+ result.entries = result.entries.filter(entry => oldEnough(entry.lastActive, options.olderThan));
27
+ if (unknown)
28
+ note(`Skipped ${unknown} ${unknown === 1 ? 'directory' : 'directories'} with unknown activity.`);
29
+ }
30
+ scanComplete(result.projects, folderCount, result.entries, options.olderThan !== undefined);
31
+ if (!result.projects && result.warnings.length)
32
+ message('SCAN INCOMPLETE', 'No Node.js projects found in the accessible folders.', 'Try a specific project directory: nclean ~/Projects');
33
+ else if (!result.projects)
34
+ message('NO NODE PROJECTS FOUND', "We couldn't find any package.json files here.", 'Try: nclean ~/Projects');
35
+ else if (!folderCount && result.warnings.length)
36
+ message('SCAN INCOMPLETE', 'No dependency folders found in the accessible projects.', 'Skipped folders could not be checked.');
37
+ else if (!folderCount)
38
+ message('ALL CLEAN', 'No node_modules directories found.\nNothing to remove.');
39
+ else if (!result.entries.length && result.warnings.length)
40
+ message('NO MATCHES IN SCANNED FOLDERS', 'No inactive node_modules found in the accessible projects.', 'Skipped folders could not be checked.');
41
+ else if (!result.entries.length)
42
+ message('ALL CLEAN', 'No inactive node_modules found.\nNothing to remove.', 'Tip: try --older-than 7 to find recently inactive projects.');
43
+ return result;
44
+ }
@@ -0,0 +1,2 @@
1
+ export declare const ignored: Set<string>;
2
+ export declare function lastActivity(project: string): Promise<number | null>;
@@ -0,0 +1,51 @@
1
+ import { opendir, lstat } from 'node:fs/promises';
2
+ import { execFile } from 'node:child_process';
3
+ import { promisify } from 'node:util';
4
+ import path from 'node:path';
5
+ const exec = promisify(execFile);
6
+ export const ignored = new Set(['node_modules', '.git', '.next', '.nuxt', '.cache', '.turbo', 'dist', 'build', 'coverage', 'vendor']);
7
+ const relevant = /\.(?:[cm]?[jt]sx?|vue|svelte|astro|json|ya?ml|toml|html|css|scss|md|graphql|prisma)$/i;
8
+ export async function lastActivity(project) {
9
+ let newest = null;
10
+ const record = (stamp) => { newest = Math.max(newest ?? 0, stamp); };
11
+ try {
12
+ const { stdout } = await exec('git', ['-C', project, 'log', '-1', '--format=%ct', '--', '.'], { timeout: 3000, maxBuffer: 4096, windowsHide: true });
13
+ const stamp = Number(stdout.trim()) * 1000;
14
+ if (stdout.trim() && Number.isFinite(stamp) && stamp > 0)
15
+ record(stamp);
16
+ }
17
+ catch { /* Git is optional; filesystem signals remain usable. */ }
18
+ let reliable = true;
19
+ async function walk(folder) {
20
+ for await (const entry of await opendir(folder)) {
21
+ if (entry.isSymbolicLink() || ignored.has(entry.name))
22
+ continue;
23
+ const target = path.join(folder, entry.name);
24
+ if (entry.isDirectory()) {
25
+ // Nested projects have their own activity signals.
26
+ try {
27
+ if ((await lstat(path.join(target, 'package.json'))).isFile())
28
+ continue;
29
+ }
30
+ catch (error) {
31
+ if (error.code !== 'ENOENT')
32
+ throw error;
33
+ }
34
+ await walk(target);
35
+ }
36
+ else if (entry.isFile() && (relevant.test(entry.name) || /^(?:yarn\.lock|bun\.lockb?|npm-shrinkwrap\.json)$/.test(entry.name))) {
37
+ const stat = await lstat(target);
38
+ if (stat.isFile())
39
+ record(stat.mtimeMs);
40
+ }
41
+ }
42
+ }
43
+ try {
44
+ await walk(project);
45
+ }
46
+ catch {
47
+ reliable = false;
48
+ }
49
+ // Incomplete signals must never make a project appear safely inactive.
50
+ return reliable ? newest : null;
51
+ }
@@ -0,0 +1,2 @@
1
+ import type { ScanResult, ScanProgress } from '../types.js';
2
+ export declare function discover(roots: string[], progress?: (message: string, state: ScanProgress) => void): Promise<ScanResult>;
@@ -0,0 +1,65 @@
1
+ import { opendir, lstat } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { detectManager } from '../utils/package-manager.js';
4
+ import { errorMessage } from '../utils/format.js';
5
+ import { ignored, lastActivity } from './activity.js';
6
+ import { directorySize } from './size.js';
7
+ export async function discover(roots, progress = () => { }) {
8
+ const result = { projects: 0, entries: [], warnings: [] };
9
+ async function walk(folder, root) {
10
+ try {
11
+ let isProject = false;
12
+ try {
13
+ isProject = (await lstat(path.join(folder, 'package.json'))).isFile();
14
+ }
15
+ catch (error) {
16
+ if (error.code !== 'ENOENT')
17
+ throw error;
18
+ }
19
+ if (isProject) {
20
+ result.projects++;
21
+ try {
22
+ const modules = path.join(folder, 'node_modules');
23
+ const stat = await lstat(modules);
24
+ if (stat.isSymbolicLink())
25
+ result.warnings.push(`Skipped symlink: ${modules}`);
26
+ else if (stat.isDirectory())
27
+ result.entries.push({ project: folder, path: modules, root, manager: 'npm', lastActive: null, bytes: null, device: stat.dev, inode: stat.ino, error: null });
28
+ }
29
+ catch (error) {
30
+ if (error.code !== 'ENOENT')
31
+ throw error;
32
+ }
33
+ }
34
+ progress(`Discovering projects · ${result.projects} found`, { phase: 'discovery', projects: result.projects, folders: result.entries.length, completed: 0, total: null });
35
+ for await (const entry of await opendir(folder)) {
36
+ if (entry.isDirectory() && !entry.isSymbolicLink() && !ignored.has(entry.name))
37
+ await walk(path.join(folder, entry.name), root);
38
+ }
39
+ }
40
+ catch (error) {
41
+ result.warnings.push(`${folder}: ${errorMessage(error)}`);
42
+ }
43
+ }
44
+ for (const root of roots)
45
+ await walk(root, root);
46
+ let next = 0, complete = 0;
47
+ progress('Measuring dependencies', { phase: 'measurement', projects: result.projects, folders: result.entries.length, completed: 0, total: result.entries.length });
48
+ async function worker() {
49
+ while (next < result.entries.length) {
50
+ const entry = result.entries[next++];
51
+ try {
52
+ entry.manager = await detectManager(entry.project);
53
+ entry.lastActive = await lastActivity(entry.project);
54
+ entry.bytes = await directorySize(entry.path);
55
+ }
56
+ catch (error) {
57
+ entry.error = errorMessage(error);
58
+ }
59
+ progress(`Measuring dependencies · ${++complete}/${result.entries.length}`, { phase: 'measurement', projects: result.projects, folders: result.entries.length, completed: complete, total: result.entries.length });
60
+ }
61
+ }
62
+ await Promise.all(Array.from({ length: Math.min(4, result.entries.length) }, worker));
63
+ result.entries.sort((a, b) => (b.bytes ?? -1) - (a.bytes ?? -1));
64
+ return result;
65
+ }
@@ -0,0 +1 @@
1
+ export declare function directorySize(directory: string): Promise<number>;
@@ -0,0 +1,29 @@
1
+ import { opendir, lstat } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ // One streamed walk per worker. Links are measured as links, never followed.
4
+ export async function directorySize(directory) {
5
+ let bytes = 0;
6
+ const seen = new Set();
7
+ async function walk(folder) {
8
+ for await (const entry of await opendir(folder)) {
9
+ const target = path.join(folder, entry.name);
10
+ const stat = await lstat(target);
11
+ if (stat.isSymbolicLink())
12
+ continue;
13
+ if (stat.isDirectory())
14
+ await walk(target);
15
+ else if (stat.isFile()) {
16
+ const key = `${stat.dev}:${stat.ino}`;
17
+ if (stat.ino !== 0 && seen.has(key))
18
+ continue;
19
+ seen.add(key);
20
+ bytes += stat.size;
21
+ }
22
+ }
23
+ }
24
+ const stat = await lstat(directory);
25
+ if (!stat.isDirectory() || stat.isSymbolicLink())
26
+ throw new Error('Expected a real directory.');
27
+ await walk(directory);
28
+ return bytes;
29
+ }