node-modules-hunter 1.1.0 → 1.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 +4 -4
- package/dist/cli.js +271 -26
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -86,11 +86,11 @@ Search patterns: plain text is a case-insensitive substring (`nmh search lodash`
|
|
|
86
86
|
| `--save-report <file>` / `--report <file>` | `scan`, `map`, `search`, `stats`: **also** write the printed report to a file — format picked by extension `.json` / `.md` / `.txt` (write failure = warning, exit unchanged) |
|
|
87
87
|
| `--treemap [file]` / `--out <file>` | `nmh treemap <dirs>`: export one self-contained interactive HTML treemap (default `nmh-treemap.html`); `--json` prints the raw data instead |
|
|
88
88
|
| `--reinstall` | With `stats` (focused view): naive npm-ci-style estimate of how long re-installing everything takes (pkg × 60 ms + bytes / 50 MB/s + 3 s per native-family build); assumptions surfaced in the JSON, labeled naif |
|
|
89
|
-
| `--animate` / `--no-animate` |
|
|
89
|
+
| `--animate` / `--no-animate` | Spinning braille loader while measuring (time-driven — rotates on its own, v1.1.1): auto = real TTY only, `--animate` forces it even when piped; always suppressed under `--json`; `.nmhrc animate:false` mutes it |
|
|
90
90
|
| `--update-check` | Opt-in: peek npm for a newer release (non-blocking, 24 h cache, notice on stderr; also `updateCheck` in `.nmhrc`) |
|
|
91
91
|
| `--config <file>` | Use this config file instead of the `.nmhrc` lookup (cwd → `$HOME`) |
|
|
92
92
|
| `--json` | Machine-readable output (pipe into `jq`) |
|
|
93
|
-
| `-q/--quiet`, `--verbose`, `--no-color` | Output control |
|
|
93
|
+
| `-q/--quiet`, `--verbose`, `--no-color` | Output control — ANSI colors paint only on a real TTY, or with explicit opt-in (`--color` / `FORCE_COLOR`); pipes & CI get plain text by default, `--no-color`/`NO_COLOR` always win (v1.1.1) |
|
|
94
94
|
|
|
95
95
|
The `.nmhrc` lookup walks every ancestor from cwd up to `$HOME` — and it honors `logDir`, the directory where delete recovery logs are written. Only keep a `.nmhrc` in directories you control: in a shared checkout, a parent directory you don't own could steer your recovery logs and scan defaults; when in doubt, pass an explicit `--config`.
|
|
96
96
|
|
|
@@ -413,7 +413,7 @@ For settings you always repeat, drop a `.nmhrc` (or `.nmhrc.json`) next to your
|
|
|
413
413
|
}
|
|
414
414
|
```
|
|
415
415
|
|
|
416
|
-
- Nearest file wins: looks from the current dir up **to** `$HOME` (inclusive), not beyond.
|
|
416
|
+
- Nearest file wins: looks from the current dir up **to** `$HOME` (inclusive), not beyond. When your cwd lives **outside** the home tree, `$HOME/.nmhrc` is still honored as the final fallback (chain first, v1.1.1) — so `nmh config set` works everywhere.
|
|
417
417
|
- `--config <file>` replaces the lookup entirely.
|
|
418
418
|
- CLI flags always win; `exclude`/`keep` **merge** (config first, CLI appended); `defaultMode` pre-fills a bare `--delete` and the wizard's initial choice; unknown keys are warned about and ignored.
|
|
419
419
|
- `--config` isn't a delete-mode switch — deleting still asks permission unless `--yes`/`--force`.
|
|
@@ -448,7 +448,7 @@ nmh config unset --default-exclude --animate # keys out of the file
|
|
|
448
448
|
|
|
449
449
|
## Roadmap
|
|
450
450
|
|
|
451
|
-
v0.1 shipped the MVP (scan · size · delete · wizard · guards · PM detection). v0.2 added **search, map, `--depend`, `-r` recursive audit and the `--pm` / `--exclude` / `--keep` / `--depth` / `--expand` filters**. v0.3 added the **`stats` suite (dup factor, waste, top-N, per-package tree), `stats -g` global analysis across PMs, report export (`--save-report`/`--report`), `--animate` and the `.nmhrc` config file**. v0.4 added the **analytics deep-cut: baselines (`--save-baseline`/`--compare`/`--history`), dedup suggestions with per-copy declared ranges, `nmh why`, the real `--by type` breakdown, the `--phantom`/`--dead` best-effort import audit and the opt-in `--update-check`**. v0.5 added the **vision & hygiene extras: license scan (`--licenses`/`--by licenses` with the copyleft-in-prod ⚠), the experimental Bloat Score (`--score`) and CO2 estimate (`--co2`), the deprecated check (`--online`, offline manifests + lazy cached registry fallback), the `nmh treemap` single-file HTML export and `--save-report` for map/search/stats**. v0.6 added the **lockfile-grade graph: `--graph` parses npm/yarn/pnpm/bun lockfiles (zero new dependencies) into a real dependency graph with a BFS depth histogram, `nmh why` upgrades to lockfile-grade required-by (transitive requirers, no more false "orphans"), and `--reinstall` estimates the npm-ci-style reinstall time**. v0.7 added the **full PRD §7.5 wizard menu (delete all / pick / search / map / detailed report / settings, with loop-backs), a serious banner box (TTY) + compact one-liner (non-TTY, `--json`-clean), and a richer `nmh version` (node + abi + OS/arch/kernel + tty, `--json`)**. The v0.7.1 patch re-scheduled the folder walker to a slot-per-readdir work queue: the old recursive limiter could deadlock (circular wait) on wide bushy trees like a 12-package pnpm workspace — freezing the scan with **zero output and exit code 0 mid-walk**. Trees of that shape are covered by a regression test now. The same patch hardened measurement: children are classified with `fs.lstat` instead of dirent `d_type`, because some filesystems (Android FUSE/sdcardfs) misreport hardlinked pnpm-store files as symlinks — one `typescript/lib` dir had 112 of 125 dirents lying, which made a whole pnpm virtual store measure 650 KB while `du` saw 66 MB — and entry sizes now dedupe hardlinks per entry (`du -sb`-style), keeping walk numbers in line with `du` (91 → 63 MB on the same tree). v0.8 makes the no-arg wizard scope-aware: when the cwd has no node_modules within depth 3, it climbs once to the nearest manifest ancestor (package.json, any PM lockfile, or `pnpm-workspace.yaml`, ≤8 hops — empty manifest dirs are skipped so the monorepo root still wins) and the whole menu runs there; the scope is frozen for the session (rescans never re-climb), a TTY note reveals the lift, non-TTY gets a warn line, and `--json` stays a pure payload. The v0.9 minor introduces **`nmh config` (info · set · add · remove · unset)** — manage the `.nmhrc` from the CLI instead of hand-editing JSON (`--default-exclude`/`--default-keep` list discipline with dedupe and clear, loader-grade validation, unknown-key preservation on write, `$HOME/.nmhrc` default target) — and the **`self` token** for `exclude`/`keep`: a scan-level guard that drops every entry inside the folder nmh runs from, wholesale, whatever target you point at (your own project can never be hunted by accident). v0.9.1 is a **live-audit hardening patch**: a scripted fixture farm × ~140 invoked combination runs surfaced five defects that are now fixed — delete guards additionally refuse a `node_modules` sitting *directly inside* `/` or `$HOME` (previously a `--dir ~ --delete -y` could sweep it away), the wizard's non-TTY fallback scan now honors `exclude`/`keep` from config and flags, a bare `--depth` failure message no longer swallows the value into `true`, the `--depend` guard messages are reconciled as map-only with actionable hints, and a failed `treemap --out` write exits 1 with a friendly 🛑 instead of a raw errno string. Audited-and-confirmed-as-designed along the way: positional targets are never comma-split (only `--dir` is), `--save-report` write failures warn and keep the exit code, `nmh search` matches top-level package names per PRD §7.3, and `nmh why` takes pkg-first arguments. v0.9.2 is an **independent-review hardening patch**: it closes the biggest hole the v0.9.1 audit missed — a TTY `--json --delete` used to execute with **zero confirmation** (machine output has no interactive prompt slot, and the non-TTY guard didn't cover the TTY branch; a pseudo-TTY reproduction proved rc=0 with the victim gone) — now `--json --delete` requires `--yes`/`--force` on every shell, TTY included. The same pass fixed the config family under `--quiet --json` (its payload was the one survivor the sweep missed — printed empty with rc 0), turned a silent bare/multi `--size` into a loud usage error like its sibling guards, and cleaned the last 10 pre-existing `tsc` errors (a leftover `depth` sentinel union) so `npm run typecheck` is finally clean. v0.9.3 is a **no-behavior-change cleanliness patch** from the same review: six dead symbols removed (`measurePackageRefs`, `SizedPackageRef`, `readLicense`, `listHasSelf`, `isVerbose`, `formatCount`), the write-only `MeasureProgress.currentEntry` field dropped, the twin `DepsAudit` interfaces collapsed to one canonical definition (re-exported from `core/result.ts`), the `self` guard moved to its semantic home (`core/guards.ts`), `ConfigError` now carries a machine-readable `code` instead of message string-matching, scan's private report writer now shares `writeReportSnapshot` with map/search/stats, the `analyze`/`info` stats aliases are documented, and `npm run typecheck` stays at zero errors. v0.9.4 is a **hardening patch for the review findings that change data/behavior**: `stripJsonC` is now fully quote-aware (a trailing comma inside a string value is no longer eaten — silent corruption fixed), `makeGlobMatcher` caps pathological patterns (512 chars / 8 `**`) up front, a delete op aimed at `/` refuses before scanning, the deprecated `truncated` flag means "a 201st item was refused" instead of "exactly at the cap", recovery logs get a random suffix (no same-millisecond overwrites), deprecated notes are ANSI-stripped at the source, `nmh why` reads its manifests/locks with bounded concurrency of 4, the `$HOME` guard ignores letter-case on macOS, the `.nmhrc` ancestor trust is documented in the README, and the self-guard-vs-symlinked-cwd claim is verified as a non-issue (Node resolves `process.cwd()` physically). v1.0.0 is the **stable-API release**: every `--json` payload is a frozen contract guarded by the 245-test suite, the exit-code table (0/1/2/3/4) is locked, `npm run typecheck` and `npm audit` are both clean, and the package ships to npm with a `v1.0.0` git tag once the remote is configured. v1.1.0 is the **stale-hunting edition**: `--stale <days>` grades entries by newest-of git commit → manifest mtime → folder mtime and drops no-evidence stragglers, `--fast-size` measures with `du -sk` (exact-walker fallback), results gain an `Idle` column, a proportional Share bar and `--sort size|path|idle|mtime`, discovery hides system noise by default (`--no-skip` restores the bare audit), `nmh update` self-updates npm-global installs, and `--trash=system|nmh` picks the backend with a restorable `~/.nmh/trash` fallback — all with zero new dependencies and additive-only JSON keys, guarded by the 267-test suite.
|
|
451
|
+
v0.1 shipped the MVP (scan · size · delete · wizard · guards · PM detection). v0.2 added **search, map, `--depend`, `-r` recursive audit and the `--pm` / `--exclude` / `--keep` / `--depth` / `--expand` filters**. v0.3 added the **`stats` suite (dup factor, waste, top-N, per-package tree), `stats -g` global analysis across PMs, report export (`--save-report`/`--report`), `--animate` and the `.nmhrc` config file**. v0.4 added the **analytics deep-cut: baselines (`--save-baseline`/`--compare`/`--history`), dedup suggestions with per-copy declared ranges, `nmh why`, the real `--by type` breakdown, the `--phantom`/`--dead` best-effort import audit and the opt-in `--update-check`**. v0.5 added the **vision & hygiene extras: license scan (`--licenses`/`--by licenses` with the copyleft-in-prod ⚠), the experimental Bloat Score (`--score`) and CO2 estimate (`--co2`), the deprecated check (`--online`, offline manifests + lazy cached registry fallback), the `nmh treemap` single-file HTML export and `--save-report` for map/search/stats**. v0.6 added the **lockfile-grade graph: `--graph` parses npm/yarn/pnpm/bun lockfiles (zero new dependencies) into a real dependency graph with a BFS depth histogram, `nmh why` upgrades to lockfile-grade required-by (transitive requirers, no more false "orphans"), and `--reinstall` estimates the npm-ci-style reinstall time**. v0.7 added the **full PRD §7.5 wizard menu (delete all / pick / search / map / detailed report / settings, with loop-backs), a serious banner box (TTY) + compact one-liner (non-TTY, `--json`-clean), and a richer `nmh version` (node + abi + OS/arch/kernel + tty, `--json`)**. The v0.7.1 patch re-scheduled the folder walker to a slot-per-readdir work queue: the old recursive limiter could deadlock (circular wait) on wide bushy trees like a 12-package pnpm workspace — freezing the scan with **zero output and exit code 0 mid-walk**. Trees of that shape are covered by a regression test now. The same patch hardened measurement: children are classified with `fs.lstat` instead of dirent `d_type`, because some filesystems (Android FUSE/sdcardfs) misreport hardlinked pnpm-store files as symlinks — one `typescript/lib` dir had 112 of 125 dirents lying, which made a whole pnpm virtual store measure 650 KB while `du` saw 66 MB — and entry sizes now dedupe hardlinks per entry (`du -sb`-style), keeping walk numbers in line with `du` (91 → 63 MB on the same tree). v0.8 makes the no-arg wizard scope-aware: when the cwd has no node_modules within depth 3, it climbs once to the nearest manifest ancestor (package.json, any PM lockfile, or `pnpm-workspace.yaml`, ≤8 hops — empty manifest dirs are skipped so the monorepo root still wins) and the whole menu runs there; the scope is frozen for the session (rescans never re-climb), a TTY note reveals the lift, non-TTY gets a warn line, and `--json` stays a pure payload. The v0.9 minor introduces **`nmh config` (info · set · add · remove · unset)** — manage the `.nmhrc` from the CLI instead of hand-editing JSON (`--default-exclude`/`--default-keep` list discipline with dedupe and clear, loader-grade validation, unknown-key preservation on write, `$HOME/.nmhrc` default target) — and the **`self` token** for `exclude`/`keep`: a scan-level guard that drops every entry inside the folder nmh runs from, wholesale, whatever target you point at (your own project can never be hunted by accident). v0.9.1 is a **live-audit hardening patch**: a scripted fixture farm × ~140 invoked combination runs surfaced five defects that are now fixed — delete guards additionally refuse a `node_modules` sitting *directly inside* `/` or `$HOME` (previously a `--dir ~ --delete -y` could sweep it away), the wizard's non-TTY fallback scan now honors `exclude`/`keep` from config and flags, a bare `--depth` failure message no longer swallows the value into `true`, the `--depend` guard messages are reconciled as map-only with actionable hints, and a failed `treemap --out` write exits 1 with a friendly 🛑 instead of a raw errno string. Audited-and-confirmed-as-designed along the way: positional targets are never comma-split (only `--dir` is), `--save-report` write failures warn and keep the exit code, `nmh search` matches top-level package names per PRD §7.3, and `nmh why` takes pkg-first arguments. v0.9.2 is an **independent-review hardening patch**: it closes the biggest hole the v0.9.1 audit missed — a TTY `--json --delete` used to execute with **zero confirmation** (machine output has no interactive prompt slot, and the non-TTY guard didn't cover the TTY branch; a pseudo-TTY reproduction proved rc=0 with the victim gone) — now `--json --delete` requires `--yes`/`--force` on every shell, TTY included. The same pass fixed the config family under `--quiet --json` (its payload was the one survivor the sweep missed — printed empty with rc 0), turned a silent bare/multi `--size` into a loud usage error like its sibling guards, and cleaned the last 10 pre-existing `tsc` errors (a leftover `depth` sentinel union) so `npm run typecheck` is finally clean. v0.9.3 is a **no-behavior-change cleanliness patch** from the same review: six dead symbols removed (`measurePackageRefs`, `SizedPackageRef`, `readLicense`, `listHasSelf`, `isVerbose`, `formatCount`), the write-only `MeasureProgress.currentEntry` field dropped, the twin `DepsAudit` interfaces collapsed to one canonical definition (re-exported from `core/result.ts`), the `self` guard moved to its semantic home (`core/guards.ts`), `ConfigError` now carries a machine-readable `code` instead of message string-matching, scan's private report writer now shares `writeReportSnapshot` with map/search/stats, the `analyze`/`info` stats aliases are documented, and `npm run typecheck` stays at zero errors. v0.9.4 is a **hardening patch for the review findings that change data/behavior**: `stripJsonC` is now fully quote-aware (a trailing comma inside a string value is no longer eaten — silent corruption fixed), `makeGlobMatcher` caps pathological patterns (512 chars / 8 `**`) up front, a delete op aimed at `/` refuses before scanning, the deprecated `truncated` flag means "a 201st item was refused" instead of "exactly at the cap", recovery logs get a random suffix (no same-millisecond overwrites), deprecated notes are ANSI-stripped at the source, `nmh why` reads its manifests/locks with bounded concurrency of 4, the `$HOME` guard ignores letter-case on macOS, the `.nmhrc` ancestor trust is documented in the README, and the self-guard-vs-symlinked-cwd claim is verified as a non-issue (Node resolves `process.cwd()` physically). v1.0.0 is the **stable-API release**: every `--json` payload is a frozen contract guarded by the 245-test suite, the exit-code table (0/1/2/3/4) is locked, `npm run typecheck` and `npm audit` are both clean, and the package ships to npm with a `v1.0.0` git tag once the remote is configured. v1.1.0 is the **stale-hunting edition**: `--stale <days>` grades entries by newest-of git commit → manifest mtime → folder mtime and drops no-evidence stragglers, `--fast-size` measures with `du -sk` (exact-walker fallback), results gain an `Idle` column, a proportional Share bar and `--sort size|path|idle|mtime`, discovery hides system noise by default (`--no-skip` restores the bare audit), `nmh update` self-updates npm-global installs, and `--trash=system|nmh` picks the backend with a restorable `~/.nmh/trash` fallback — all with zero new dependencies and additive-only JSON keys, guarded by the 267-test suite. The v1.1.1 patch is a **polish-and-hardening pass driven by three user reports and a full live-exercise matrix** (26 sandbox fixtures × 223 real combinations): the measuring loader is a **time-driven spinner** (braille frames rotate on their own timer at 70 ms instead of per-update repaints, spinning on TTY, silent in pipes unless `--animate on`), `nmh help` renders a **complete manual** (own renderer — 14 commands + 5 stats subcommand views + grouped options + examples + the safety/exit footer, and the previously-missing `-h`/`--help`/`-v`/`--version` aliases finally work instead of slipping into the default scan), and ANSI colors are **TTY-only by design** (picocolors' `!!env.CI` guess had painted unknown escape bytes into piped GitHub Actions output and shredded substring assertions — CI is 9/9 green for the first time; local color comes from a real TTY or explicit `--color`/`FORCE_COLOR`). The same matrix surfaced two defects the suite had missed: an unknown first word (`nmh scanx`) used to fall through to a silent full-cwd scan with exit 0 — any non-path, non-command token is now a loud usage error (and a bare cwd-relative dir like `nmh node_modules` now correctly targets that folder); and `nmh config set` wrote `$HOME/.nmhrc` while scans walked cwd → `/` and never read it when the cwd lived outside the home tree — the home file is now the documented fallback (chain first). Meanwhile `os.homedir()` on Windows reads `USERPROFILE` (test helper pins both), `execFile('npm')` needs `shell: true` on win32 (`stats -g` exited 2 there), and system-trash-dependent flows run hermetically via `nmh trash` fallback. Suite: 44 files / 279 tests green.
|
|
452
452
|
|
|
453
453
|
## Development
|
|
454
454
|
|
package/dist/cli.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// src/cli.ts
|
|
4
4
|
import { cac } from "cac";
|
|
5
5
|
import path25 from "path";
|
|
6
|
+
import { existsSync } from "fs";
|
|
6
7
|
|
|
7
8
|
// src/utils/logger.ts
|
|
8
9
|
import pc from "picocolors";
|
|
@@ -82,11 +83,38 @@ function progressAllowed() {
|
|
|
82
83
|
if (state.progress === "on") return true;
|
|
83
84
|
return state.isTTY;
|
|
84
85
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
86
|
+
var SPIN_FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
|
|
87
|
+
var SPIN_MS = 70;
|
|
88
|
+
var spinTimer = null;
|
|
89
|
+
var spinFrame = 0;
|
|
90
|
+
var spinMsg = "";
|
|
91
|
+
function spinPaint() {
|
|
92
|
+
process.stdout.write(CLEAR_LINE + colors.dim(`${SPIN_FRAMES[spinFrame]} ${spinMsg}`));
|
|
93
|
+
}
|
|
94
|
+
function startSpin() {
|
|
95
|
+
spinFrame = 0;
|
|
96
|
+
spinPaint();
|
|
97
|
+
const timer = setInterval(() => {
|
|
98
|
+
spinFrame = (spinFrame + 1) % SPIN_FRAMES.length;
|
|
99
|
+
spinPaint();
|
|
100
|
+
}, SPIN_MS);
|
|
101
|
+
spinTimer = timer;
|
|
102
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
103
|
+
}
|
|
104
|
+
function spinUpdate(message) {
|
|
105
|
+
spinMsg = message;
|
|
106
|
+
if (state.quiet || !progressAllowed() || spinTimer !== null) return;
|
|
107
|
+
if (!state.isTTY) {
|
|
108
|
+
spinPaint();
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
startSpin();
|
|
88
112
|
}
|
|
89
|
-
function
|
|
113
|
+
function spinClear() {
|
|
114
|
+
if (spinTimer !== null) {
|
|
115
|
+
clearInterval(spinTimer);
|
|
116
|
+
spinTimer = null;
|
|
117
|
+
}
|
|
90
118
|
if (state.quiet || !progressAllowed()) return;
|
|
91
119
|
process.stdout.write(CLEAR_LINE);
|
|
92
120
|
}
|
|
@@ -216,6 +244,165 @@ function renderVersionDetail(detail) {
|
|
|
216
244
|
].join("\n");
|
|
217
245
|
}
|
|
218
246
|
|
|
247
|
+
// src/utils/help.ts
|
|
248
|
+
var COMMANDS = [
|
|
249
|
+
{ usage: "nmh", desc: "interactive wizard: arrow menu (delete all / pick / search / map / report / settings)" },
|
|
250
|
+
{ usage: "nmh <path> [path\u2026]", desc: "shortcut: scan given path(s); one-liner actions via flags (--delete, --search, \u2026)" },
|
|
251
|
+
{ usage: "nmh scan [dir]", desc: "full scan across targets (default: cwd)" },
|
|
252
|
+
{ usage: "nmh clean [dir]", desc: "scan + delete everything safe (trash by default)" },
|
|
253
|
+
{ usage: "nmh search <pattern> [dir\u2026]", desc: "find installed packages by name (+ versions & sizes) \u2014 read-only" },
|
|
254
|
+
{ usage: "nmh map [dir]", desc: "project \u2192 PM \u2192 size \u2192 top packages \u2192 declared deps \u2014 read-only" },
|
|
255
|
+
{ usage: "nmh stats [dir]", desc: "size analytics: dup factor, waste, top-N, per-package tree (aliases: analyze, info)" },
|
|
256
|
+
{ usage: "nmh why <pkg> [dir\u2026]", desc: "reverse depend: where is <pkg> and who requires it \u2014 read-only" },
|
|
257
|
+
{ usage: "nmh treemap [dir]", desc: "single-file interactive HTML treemap \u2192 nmh-treemap.html" },
|
|
258
|
+
{ usage: "nmh stats -g", desc: "global installs across npm/pnpm/yarn/bun \u2014 read-only" },
|
|
259
|
+
{ usage: "nmh config <op>", desc: "manage .nmhrc from the CLI: info \xB7 set \xB7 add \xB7 remove \xB7 unset" },
|
|
260
|
+
{ usage: "nmh update", desc: "self-update: compare installed vs npm latest, install if global" },
|
|
261
|
+
{ usage: "nmh version", desc: "banner: tool version, node+abi, OS/arch/kernel, tty (alias: -v / --version)" },
|
|
262
|
+
{ usage: "nmh help", desc: "this help (alias: -h / --help)" }
|
|
263
|
+
];
|
|
264
|
+
var STAT_SUBCOMMANDS = [
|
|
265
|
+
{ usage: "stats --by type [dir]", desc: "breakdown per dependency field (dep/dev/optional/peer/transitive)" },
|
|
266
|
+
{ usage: "stats --licenses [dir]", desc: "license buckets (+ \u26A0 copyleft inside production deps)" },
|
|
267
|
+
{ usage: "stats --score / --co2", desc: "experimental: composite bloat score 0-100 / rough CO2 estimate" },
|
|
268
|
+
{ usage: "stats --graph [dir]", desc: "dependency graph from the lockfile \u2014 npm/yarn/pnpm/bun, no extra deps" },
|
|
269
|
+
{ usage: "stats --reinstall [dir]", desc: 'naive npm-ci-style "how long would reinstalling everything take"' }
|
|
270
|
+
];
|
|
271
|
+
var GROUPS = [
|
|
272
|
+
{
|
|
273
|
+
title: "Actions",
|
|
274
|
+
flags: [
|
|
275
|
+
{ usage: "--delete [mode]", desc: "delete found folders: hard | trash (soft alias) \u2014 no value = pick interactively" },
|
|
276
|
+
{ usage: "--trash system|nmh", desc: "v1.1: whose trash takes victims \u2014 system backend (default) or restorable ~/.nmh/trash" },
|
|
277
|
+
{ usage: "--dry-run", desc: "preview what would be deleted \u2014 nothing is touched" },
|
|
278
|
+
{ usage: "-y, --yes", desc: "answer yes to every prompt (required for non-TTY deletes)" },
|
|
279
|
+
{ usage: "-f, --force", desc: "skip all confirmations (implies --yes)" },
|
|
280
|
+
{ usage: "--search <pattern>", desc: "search installed packages (substring, glob, or /regex/) \u2014 read-only" },
|
|
281
|
+
{ usage: "-m, --map", desc: "map projects: PM, size, top packages, declared deps \u2014 read-only" },
|
|
282
|
+
{ usage: "--depend [pkg]", desc: "map only: show declared deps; with a value, list who declares it" },
|
|
283
|
+
{ usage: "--tree", desc: "map/stats only: list per-project packages (tree view, with sizes)" },
|
|
284
|
+
{ usage: "--why <pkg>", desc: "read-only: explain an installed package \u2014 instances and requirers" },
|
|
285
|
+
{ usage: "--stats", desc: "stats report: overview + dup factor + waste + top sizes \u2014 read-only" },
|
|
286
|
+
{ usage: "--treemap [file]", desc: "treemap export: single-file HTML (default nmh-treemap.html; --out is an alias)" },
|
|
287
|
+
{ usage: "-d, --dir <paths>", desc: "target dir(s), comma separated or repeated; globs and ~ allowed" },
|
|
288
|
+
{ usage: "--size <mode>", desc: "output shape: 'total' (just aggregate) or 'each' (default: per-folder table)" },
|
|
289
|
+
{ usage: "--save-report <file>", desc: "also write the printed report to a file (.json/.md/.txt; --report <file> aliases it)" },
|
|
290
|
+
{ usage: "--update-check", desc: "peek npm for a newer release (non-blocking, 24 h cache; also settable in .nmhrc)" }
|
|
291
|
+
]
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
title: "Stale hunting & output shape (v1.1)",
|
|
295
|
+
flags: [
|
|
296
|
+
{ usage: "--stale <days>", desc: "keep only projects idle >= <days>; ladder = git commit \u2192 manifest \u2192 folder mtime; no evidence = dropped" },
|
|
297
|
+
{ usage: "--fast-size", desc: "measure with one `du -sk` per entry (KiB granularity) instead of walking every file" },
|
|
298
|
+
{ usage: "--sort <key>", desc: "order results: size (default, desc) | path | idle (oldest project first) | mtime (newest first)" },
|
|
299
|
+
{ usage: "--no-skip", desc: "stop hiding system noise (.Trash*, Library/, *.app) during discovery \u2014 default: hidden" },
|
|
300
|
+
{ usage: "--animate / --no-animate", desc: "live size counter while measuring: auto = real TTY only" },
|
|
301
|
+
{ usage: "--json", desc: "machine-readable payload (pipe into jq); additive-only keys \u2014 v1.0 keys stay frozen" },
|
|
302
|
+
{ usage: "-q, --quiet", desc: "minimal output \u2014 pretty tables/notes mute, the --json payload still prints" },
|
|
303
|
+
{ usage: "--verbose", desc: "debug chatter on stderr" },
|
|
304
|
+
{ usage: "--no-color", desc: "disable ANSI colors (auto: colors only on a real TTY or with --color / FORCE_COLOR)" },
|
|
305
|
+
{ usage: "--color", desc: "force ANSI colors even on a piped run (wins over a config color:false)" }
|
|
306
|
+
]
|
|
307
|
+
},
|
|
308
|
+
{
|
|
309
|
+
title: "Scoping & filters",
|
|
310
|
+
flags: [
|
|
311
|
+
{ usage: "-r, --recursive", desc: "also look INSIDE found node_modules (nested audit; totals stay top-level only)" },
|
|
312
|
+
{ usage: "--pm [family]", desc: "filter by detected PM: npm|pnpm|yarn|bun|vp \u2014 no value lists what was detected" },
|
|
313
|
+
{ usage: "--exclude <globs>", desc: "exclude paths from results (comma list of globs, e.g. **/e2e/**)" },
|
|
314
|
+
{ usage: "--keep <pkgs>", desc: 'refuse to delete node_modules of these projects (comma list, glob ok; "self" works)' },
|
|
315
|
+
{ usage: "--depth <n>", desc: "max scan depth in levels below the target" },
|
|
316
|
+
{ usage: "--expand", desc: "full absolute paths + extra detail in output" }
|
|
317
|
+
]
|
|
318
|
+
},
|
|
319
|
+
{
|
|
320
|
+
title: "Stats suite",
|
|
321
|
+
flags: [
|
|
322
|
+
{ usage: "--top <n>", desc: "rows in the focused size list (default 10)" },
|
|
323
|
+
{ usage: "--by <mode>", desc: "breakdown mode \u2014 'size' (alias: package), 'type', 'licenses'" },
|
|
324
|
+
{ usage: "-g, --global", desc: "analyze global installs (per-PM prefix scan; read-only)" },
|
|
325
|
+
{ usage: "--config <file>", desc: "use this config file instead of the .nmhrc lookup (cwd \u2192 home)" },
|
|
326
|
+
{ usage: "--save-baseline <name>", desc: "save a metadata snapshot of this run to ~/.nmh/baselines/<name>.json" },
|
|
327
|
+
{ usage: "--compare <name>", desc: "diff this run against a stored baseline (totals, per-folder, package movers)" },
|
|
328
|
+
{ usage: "--history", desc: "list stored baselines \u2014 no scan at all" },
|
|
329
|
+
{ usage: "--phantom", desc: "stats only: imports found in sources but declared nowhere (best-effort regex audit)" },
|
|
330
|
+
{ usage: "--dead", desc: "stats only: installed + declared but never imported (best-effort regex audit)" },
|
|
331
|
+
{ usage: "--licenses", desc: "stats only: bucket instances by license field; flags copyleft in production deps" },
|
|
332
|
+
{ usage: "--score", desc: "stats only: experimental composite bloat score 0-100" },
|
|
333
|
+
{ usage: "--co2", desc: "stats only: experimental carbon estimate (storage-year + one redownload)" },
|
|
334
|
+
{ usage: "--online", desc: "stats only: deprecated scan \u2014 offline manifests + lazy cached registry fallback" },
|
|
335
|
+
{ usage: "--graph", desc: "stats only: dependency graph from the lockfile (npm/yarn/pnpm/bun) with BFS depth" },
|
|
336
|
+
{ usage: "--reinstall", desc: "stats only: naive npm-ci-style estimate of the time to reinstall everything" },
|
|
337
|
+
{ usage: "--out <file>", desc: "treemap only: where to write the HTML file (same as --treemap <file>)" }
|
|
338
|
+
]
|
|
339
|
+
},
|
|
340
|
+
{
|
|
341
|
+
title: "Config family",
|
|
342
|
+
flags: [
|
|
343
|
+
{ usage: "--default-exclude <globs>", desc: 'manage the persisted exclude list (comma list; special value "self")' },
|
|
344
|
+
{ usage: "--default-keep <pkgs>", desc: 'manage the persisted keep list (comma list; special value "self")' },
|
|
345
|
+
{ usage: "--default-mode <mode>", desc: "set the persisted delete default (hard | soft | trash)" },
|
|
346
|
+
{ usage: "--log-dir <dir>", desc: "set the persisted recovery-log directory (logDir)" }
|
|
347
|
+
]
|
|
348
|
+
}
|
|
349
|
+
];
|
|
350
|
+
var EXAMPLES = [
|
|
351
|
+
["nmh", "wizard; the no-args menu auto-climbs to the nearest project root"],
|
|
352
|
+
["nmh --dir=./proj,~/code/*/node_modules", "multi-target scan"],
|
|
353
|
+
["nmh . --stale 90 --delete=trash -y", "bury the stale boneyard, restorable trash"],
|
|
354
|
+
["nmh why react --expand", "reverse-depend: who declares + who requires"],
|
|
355
|
+
["nmh stats --by type --json", "dep-field breakdown as a machine payload"],
|
|
356
|
+
["nmh stats -g --top 20", "the 20 biggest global installs across PMs"],
|
|
357
|
+
["nmh config set default-mode trash", "persist a default delete mode in .nmhrc"]
|
|
358
|
+
];
|
|
359
|
+
var flagWidth = (groups) => {
|
|
360
|
+
let width = 0;
|
|
361
|
+
for (const group of groups) for (const item of group.flags) width = Math.max(width, item.usage.length);
|
|
362
|
+
return width;
|
|
363
|
+
};
|
|
364
|
+
var commandWidth = (items) => {
|
|
365
|
+
let width = 0;
|
|
366
|
+
for (const item of items) width = Math.max(width, item.usage.length);
|
|
367
|
+
return width;
|
|
368
|
+
};
|
|
369
|
+
function renderSection(title, items, width) {
|
|
370
|
+
const lines = [colors.bold(title)];
|
|
371
|
+
for (const item of items) {
|
|
372
|
+
lines.push(colors.cyan(item.usage.padEnd(width + 2)) + colors.dim(item.desc));
|
|
373
|
+
}
|
|
374
|
+
return lines;
|
|
375
|
+
}
|
|
376
|
+
function renderHelp() {
|
|
377
|
+
const lines = [];
|
|
378
|
+
lines.push(colors.bold("nmh") + colors.dim(" \u2014 hunt bloated node_modules folders: scan, measure, clean safely (npm/pnpm/yarn/bun)"));
|
|
379
|
+
lines.push("");
|
|
380
|
+
lines.push(colors.bold("Usage"));
|
|
381
|
+
lines.push(` ${colors.cyan("nmh <command|targets> [options]")}`);
|
|
382
|
+
lines.push("");
|
|
383
|
+
lines.push(...renderSection("Commands", COMMANDS, commandWidth(COMMANDS)));
|
|
384
|
+
lines.push("");
|
|
385
|
+
lines.push(...renderSection("Stats subcommands", STAT_SUBCOMMANDS, Math.max(commandWidth(STAT_SUBCOMMANDS), commandWidth(COMMANDS))));
|
|
386
|
+
lines.push("");
|
|
387
|
+
const width = flagWidth(GROUPS);
|
|
388
|
+
for (const group of GROUPS) {
|
|
389
|
+
lines.push(...renderSection(`Options \u2014 ${group.title}`, group.flags, width));
|
|
390
|
+
lines.push("");
|
|
391
|
+
}
|
|
392
|
+
lines.push(colors.bold("Examples"));
|
|
393
|
+
for (const [cmd, note2] of EXAMPLES) {
|
|
394
|
+
lines.push(` ${colors.cyan(cmd.padEnd(40))}${colors.dim(note2)}`);
|
|
395
|
+
}
|
|
396
|
+
lines.push("");
|
|
397
|
+
lines.push(colors.dim("Safety first: guards refuse dangerous targets (exit 3), deletes confirm unless --yes/--force, symlinks never get followed."));
|
|
398
|
+
lines.push(colors.dim("Exit codes: 0 ok \xB7 1 usage error \xB7 2 nothing found \xB7 3 guard refused \xB7 4 permission denied."));
|
|
399
|
+
lines.push(colors.dim("Everything is documented in depth at: https://github.com/jhedev96/node-modules-hunter"));
|
|
400
|
+
return lines.join("\n") + "\n";
|
|
401
|
+
}
|
|
402
|
+
function printHelp() {
|
|
403
|
+
process.stdout.write(renderHelp());
|
|
404
|
+
}
|
|
405
|
+
|
|
219
406
|
// src/utils/future-flags.ts
|
|
220
407
|
var FUTURE_FLAGS = /* @__PURE__ */ new Set([
|
|
221
408
|
"empty"
|
|
@@ -329,6 +516,17 @@ async function loadConfig(explicitPath, cwd) {
|
|
|
329
516
|
if (current === home || current === path2.dirname(current)) break;
|
|
330
517
|
current = path2.dirname(current);
|
|
331
518
|
}
|
|
519
|
+
const homeChain = cwd === home || cwd.startsWith(home + path2.sep);
|
|
520
|
+
if (!homeChain) {
|
|
521
|
+
for (const name of FILE_NAMES) {
|
|
522
|
+
const candidate = path2.join(home, name);
|
|
523
|
+
const body = await readMaybe(candidate);
|
|
524
|
+
if (body !== null) {
|
|
525
|
+
verboseLog(`config: found ${candidate}`);
|
|
526
|
+
return { values: parseConfigBody(body, candidate), source: candidate };
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
332
530
|
return { values: {}, source: null };
|
|
333
531
|
}
|
|
334
532
|
|
|
@@ -2143,7 +2341,7 @@ async function collectScan(request) {
|
|
|
2143
2341
|
const entryPaths = [...metaByPath.keys()];
|
|
2144
2342
|
verboseLog(`found ${entryPaths.length} node_modules \u2014 measuring`);
|
|
2145
2343
|
const measures = await collectMeasures(entryPaths, request.fastSize === true, permIssues);
|
|
2146
|
-
|
|
2344
|
+
spinClear();
|
|
2147
2345
|
const detect = createPmDetector();
|
|
2148
2346
|
const pmLimiter = createLimiter(8);
|
|
2149
2347
|
const pmResults = await Promise.all(entryPaths.map((entryPath) => pmLimiter(() => detect(entryPath))));
|
|
@@ -2223,7 +2421,7 @@ async function collectMeasures(entryPaths, fastSize, permIssues) {
|
|
|
2223
2421
|
let measuredCount = 0;
|
|
2224
2422
|
let bytesSoFar = 0;
|
|
2225
2423
|
const bump = () => {
|
|
2226
|
-
|
|
2424
|
+
spinUpdate(`measuring ${measuredCount}/${entryCount} folders \xB7 ${formatBytes(bytesSoFar)}`);
|
|
2227
2425
|
};
|
|
2228
2426
|
if (!fastSize) {
|
|
2229
2427
|
return measureEntries(entryPaths, permIssues, {
|
|
@@ -2262,8 +2460,8 @@ async function collectMeasures(entryPaths, fastSize, permIssues) {
|
|
|
2262
2460
|
const walked = await measureEntries(fallbackPaths, permIssues, {
|
|
2263
2461
|
concurrency: 4,
|
|
2264
2462
|
onProgress: (progress) => {
|
|
2265
|
-
|
|
2266
|
-
|
|
2463
|
+
spinUpdate(
|
|
2464
|
+
`measuring ${duCount + progress.measuredEntries}/${entryCount} folders \xB7 ${formatBytes(duBytes + progress.bytesSoFar)}`
|
|
2267
2465
|
);
|
|
2268
2466
|
}
|
|
2269
2467
|
});
|
|
@@ -2492,9 +2690,9 @@ async function installLatest(version) {
|
|
|
2492
2690
|
return { ok: false, detail: err instanceof Error ? err.message : String(err) };
|
|
2493
2691
|
}
|
|
2494
2692
|
}
|
|
2495
|
-
|
|
2693
|
+
spinUpdate("updating via npm\u2026");
|
|
2496
2694
|
const install = await runNpm(["install", "-g", "node-modules-hunter@" + version], 3e5);
|
|
2497
|
-
|
|
2695
|
+
spinClear();
|
|
2498
2696
|
if (install.code !== 0) {
|
|
2499
2697
|
return { ok: false, detail: install.stderr.trim().slice(0, 300) || `npm exited ${install.code}` };
|
|
2500
2698
|
}
|
|
@@ -2867,12 +3065,12 @@ async function collectSearch(request) {
|
|
|
2867
3065
|
)
|
|
2868
3066
|
);
|
|
2869
3067
|
searched += 1;
|
|
2870
|
-
|
|
3068
|
+
spinUpdate(`searching ${searched}/${entryCount} node_modules\u2026`);
|
|
2871
3069
|
return { entry, pathMatch, matches };
|
|
2872
3070
|
})
|
|
2873
3071
|
)
|
|
2874
3072
|
);
|
|
2875
|
-
|
|
3073
|
+
spinClear();
|
|
2876
3074
|
const results = outcomes.filter((outcome) => outcome.pathMatch || outcome.matches.length > 0);
|
|
2877
3075
|
verboseLog(`search '${request.pattern}': ${results.length}/${entryCount} entries matched`);
|
|
2878
3076
|
let code = 0;
|
|
@@ -3456,12 +3654,12 @@ async function collectMap(request) {
|
|
|
3456
3654
|
)
|
|
3457
3655
|
);
|
|
3458
3656
|
mapped += 1;
|
|
3459
|
-
|
|
3657
|
+
spinUpdate(`mapping ${mapped}/${entryCount} projects\u2026`);
|
|
3460
3658
|
return { entry, project, topPackages, declared };
|
|
3461
3659
|
})
|
|
3462
3660
|
)
|
|
3463
3661
|
);
|
|
3464
|
-
|
|
3662
|
+
spinClear();
|
|
3465
3663
|
let code = 0;
|
|
3466
3664
|
if (projects.length === 0) code = 2;
|
|
3467
3665
|
else if (scan.result.permissionIssues.length > 0) code = 4;
|
|
@@ -3956,13 +4154,13 @@ async function collectStats(request) {
|
|
|
3956
4154
|
)
|
|
3957
4155
|
);
|
|
3958
4156
|
measuredEntries += 1;
|
|
3959
|
-
|
|
4157
|
+
spinUpdate(`analyzing ${measuredEntries}/${entryCount} node_modules\u2026`);
|
|
3960
4158
|
const graph2 = request.graph === true ? await readProjectLockGraph(declared?.projectDir ?? path21.dirname(entry.path)) : void 0;
|
|
3961
4159
|
return { entry, project, topPackages, declared, graph: graph2 };
|
|
3962
4160
|
})
|
|
3963
4161
|
)
|
|
3964
4162
|
);
|
|
3965
|
-
|
|
4163
|
+
spinClear();
|
|
3966
4164
|
const byName = /* @__PURE__ */ new Map();
|
|
3967
4165
|
const versionsByName = /* @__PURE__ */ new Map();
|
|
3968
4166
|
const members = /* @__PURE__ */ new Map();
|
|
@@ -4620,10 +4818,10 @@ async function executeAll(entries, request) {
|
|
|
4620
4818
|
mode: request.mode,
|
|
4621
4819
|
trashMode: request.trashMode,
|
|
4622
4820
|
onRow: (_row, done, total) => {
|
|
4623
|
-
|
|
4821
|
+
spinUpdate(`\u{1F5D1} ${done}/${total} folders\u2026`);
|
|
4624
4822
|
}
|
|
4625
4823
|
});
|
|
4626
|
-
|
|
4824
|
+
spinClear();
|
|
4627
4825
|
if (request.json) payload(renderDeleteJson(outcome, request.mode, false));
|
|
4628
4826
|
else {
|
|
4629
4827
|
const summary = renderDeleteSummary(outcome, request.mode);
|
|
@@ -5057,13 +5255,27 @@ import fs22 from "fs/promises";
|
|
|
5057
5255
|
import path24 from "path";
|
|
5058
5256
|
var execFileAsync2 = promisify5(execFile5);
|
|
5059
5257
|
var PROBE_TIMEOUT = 8e3;
|
|
5258
|
+
function lastLine(stdout) {
|
|
5259
|
+
const line = stdout.split("\n").map((row) => row.trim()).filter(Boolean).pop();
|
|
5260
|
+
return line && line.length > 0 ? line : null;
|
|
5261
|
+
}
|
|
5060
5262
|
async function probeViaCommand(cmd, argv) {
|
|
5061
5263
|
try {
|
|
5062
5264
|
const { stdout } = await execFileAsync2(cmd, argv, { timeout: PROBE_TIMEOUT });
|
|
5063
|
-
|
|
5064
|
-
return line && line.length > 0 ? line : null;
|
|
5265
|
+
return lastLine(stdout);
|
|
5065
5266
|
} catch (err) {
|
|
5066
5267
|
verboseLog(`global probe: ${cmd} ${argv.join(" ")} failed (${err instanceof Error ? err.message.split("\n")[0] : String(err)})`);
|
|
5268
|
+
}
|
|
5269
|
+
if (process.platform !== "win32") return null;
|
|
5270
|
+
try {
|
|
5271
|
+
const { stdout } = await execFileAsync2(cmd, argv, {
|
|
5272
|
+
timeout: PROBE_TIMEOUT,
|
|
5273
|
+
shell: true,
|
|
5274
|
+
windowsHide: true
|
|
5275
|
+
});
|
|
5276
|
+
return lastLine(stdout);
|
|
5277
|
+
} catch (err) {
|
|
5278
|
+
verboseLog(`global probe: ${cmd} ${argv.join(" ")} via shell failed (${err instanceof Error ? err.message.split("\n")[0] : String(err)})`);
|
|
5067
5279
|
return null;
|
|
5068
5280
|
}
|
|
5069
5281
|
}
|
|
@@ -5163,7 +5375,7 @@ async function pnpmFallbackSpec() {
|
|
|
5163
5375
|
return null;
|
|
5164
5376
|
}
|
|
5165
5377
|
async function runGlobal(request) {
|
|
5166
|
-
|
|
5378
|
+
spinUpdate("probing global prefixes\u2026");
|
|
5167
5379
|
const pmRoots = await resolvePmRoots(request.pmFilter);
|
|
5168
5380
|
const reports = [];
|
|
5169
5381
|
const permissionIssues = [];
|
|
@@ -5179,7 +5391,7 @@ async function runGlobal(request) {
|
|
|
5179
5391
|
ref.symlinked ? Promise.resolve(null) : measureEntry(ref.path)
|
|
5180
5392
|
]);
|
|
5181
5393
|
done += 1;
|
|
5182
|
-
|
|
5394
|
+
spinUpdate(`global ${pm} \u2014 ${done}/${refs.length} packages`);
|
|
5183
5395
|
return { name: ref.name, version, symlinked: ref.symlinked, size: measure === null ? null : measure.size };
|
|
5184
5396
|
})
|
|
5185
5397
|
)
|
|
@@ -5194,7 +5406,7 @@ async function runGlobal(request) {
|
|
|
5194
5406
|
dead
|
|
5195
5407
|
});
|
|
5196
5408
|
}
|
|
5197
|
-
|
|
5409
|
+
spinClear();
|
|
5198
5410
|
const byName = /* @__PURE__ */ new Map();
|
|
5199
5411
|
for (const report of reports) {
|
|
5200
5412
|
for (const pkg of report.packages) {
|
|
@@ -5233,6 +5445,8 @@ function finish(request, outcome) {
|
|
|
5233
5445
|
|
|
5234
5446
|
// src/cli.ts
|
|
5235
5447
|
var cli = cac("nmh");
|
|
5448
|
+
cli.option("-h, --help", "Show the full help screen (commands, options, examples)");
|
|
5449
|
+
cli.option("-v, --version", "Show the version banner (tool version, node+abi, OS/arch/kernel \u2014 --json for plain fields)");
|
|
5236
5450
|
cli.option("-d, --dir <paths>", "Target dir(s), comma separated or repeated; globs and ~ allowed");
|
|
5237
5451
|
cli.option("--delete [mode]", "Delete found folders: hard | soft | trash (soft = trash; no value = pick interactively)");
|
|
5238
5452
|
cli.option("--dry-run", "Preview what would be deleted \u2014 nothing is touched");
|
|
@@ -5286,9 +5500,33 @@ cli.option("--default-keep <pkgs>", 'config family: manage the persisted keep li
|
|
|
5286
5500
|
cli.option("--default-mode <mode>", "config family: set the persisted defaultMode (hard | soft | trash)");
|
|
5287
5501
|
cli.option("--log-dir <dir>", "config family: set the persisted recovery-log directory (logDir)");
|
|
5288
5502
|
var PM_FAMILIES = /* @__PURE__ */ new Set(["npm", "pnpm", "yarn", "bun", "vp"]);
|
|
5503
|
+
var KNOWN_SUBCOMMANDS = /* @__PURE__ */ new Set([
|
|
5504
|
+
"scan",
|
|
5505
|
+
"clean",
|
|
5506
|
+
"search",
|
|
5507
|
+
"map",
|
|
5508
|
+
"why",
|
|
5509
|
+
"treemap",
|
|
5510
|
+
"stats",
|
|
5511
|
+
"analyze",
|
|
5512
|
+
"info",
|
|
5513
|
+
"config",
|
|
5514
|
+
"update",
|
|
5515
|
+
"version",
|
|
5516
|
+
"help"
|
|
5517
|
+
]);
|
|
5289
5518
|
function looksLikePath(text2) {
|
|
5290
5519
|
return text2.includes("/") || text2.includes("\\") || text2.startsWith(".") || text2.startsWith("~") || /^[A-Za-z]:/.test(text2);
|
|
5291
5520
|
}
|
|
5521
|
+
function resolveSubcommand(positionals, cwd) {
|
|
5522
|
+
if (positionals.length === 0) return { subcommand: null, unknown: null };
|
|
5523
|
+
const first = positionals[0];
|
|
5524
|
+
if (KNOWN_SUBCOMMANDS.has(first)) return { subcommand: first, unknown: null };
|
|
5525
|
+
if (existsSync(path25.resolve(cwd, first)) || looksLikePath(first)) {
|
|
5526
|
+
return { subcommand: null, unknown: null };
|
|
5527
|
+
}
|
|
5528
|
+
return { subcommand: null, unknown: first };
|
|
5529
|
+
}
|
|
5292
5530
|
function normalizeDelete(value) {
|
|
5293
5531
|
if (value === void 0 || value === false) return { given: false, mode: null };
|
|
5294
5532
|
if (value === true) return { given: true, mode: null };
|
|
@@ -5320,7 +5558,7 @@ async function main() {
|
|
|
5320
5558
|
return 0;
|
|
5321
5559
|
}
|
|
5322
5560
|
if (flags.help === true || positionals[0] === "help") {
|
|
5323
|
-
|
|
5561
|
+
printHelp();
|
|
5324
5562
|
return 0;
|
|
5325
5563
|
}
|
|
5326
5564
|
const explicitConfig = typeof flags.config === "string" && flags.config.length > 0 ? flags.config : null;
|
|
@@ -5351,11 +5589,13 @@ async function main() {
|
|
|
5351
5589
|
const cliColorExplicit = process.argv.includes("--color") || process.argv.includes("--color=true");
|
|
5352
5590
|
const json = flags.json === true || Array.isArray(flags.json) && flags.json.includes(true);
|
|
5353
5591
|
const progress = normalizeAnimate(process.argv, config.animate);
|
|
5592
|
+
const isTTY = process.stdout.isTTY === true;
|
|
5593
|
+
const colorForced = cliColorExplicit || process.env.FORCE_COLOR !== void 0 && process.env.FORCE_COLOR !== "0" && process.env.FORCE_COLOR !== "false";
|
|
5354
5594
|
configureLogger({
|
|
5355
5595
|
verbose: flags.verbose === true,
|
|
5356
5596
|
quiet: flags.quiet === true,
|
|
5357
5597
|
noColor: flags.noColor === true || flags.color === false || // explicit CLI --no-color wins over config
|
|
5358
|
-
config.color === false && !cliColorExplicit,
|
|
5598
|
+
config.color === false && !cliColorExplicit || !isTTY && !colorForced,
|
|
5359
5599
|
// --json shares stdout with the live counter → counter must stay silent
|
|
5360
5600
|
progress: json ? "off" : progress
|
|
5361
5601
|
});
|
|
@@ -5510,7 +5750,12 @@ async function main() {
|
|
|
5510
5750
|
const cwd = process.cwd();
|
|
5511
5751
|
const dryRun = flags.dryRun === true;
|
|
5512
5752
|
const assumeYes = flags.yes === true || flags.force === true;
|
|
5513
|
-
const
|
|
5753
|
+
const resolvedSub = resolveSubcommand(positionals, cwd);
|
|
5754
|
+
if (resolvedSub.unknown !== null) {
|
|
5755
|
+
errorLog(`unknown command '${resolvedSub.unknown}' \u2014 run \`nmh help\` for the command list (paths are passed to \`nmh scan\` or \`--dir\`)`);
|
|
5756
|
+
return 1;
|
|
5757
|
+
}
|
|
5758
|
+
const subcommand = resolvedSub.subcommand;
|
|
5514
5759
|
const rest = subcommand ? positionals.slice(1) : positionals;
|
|
5515
5760
|
const dirTargets = splitTargets(flags.dir);
|
|
5516
5761
|
const targets = [...rest, ...dirTargets];
|
package/package.json
CHANGED