node-modules-hunter 1.1.0 → 1.2.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/README.md +12 -6
- package/dist/cli.js +481 -68
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -28,6 +28,8 @@ Node.js ≥ 18 required.
|
|
|
28
28
|
|
|
29
29
|
```
|
|
30
30
|
nmh # interactive wizard: arrow menu (delete all / pick / search / map / report / settings)
|
|
31
|
+
nmh wizard --dir=./a,./b # v1.2: the wizard on explicit targets (skips the auto-climb)
|
|
32
|
+
nmh wizard -- stats --dir=./a # v1.2: open a read-only view first, then the wizard menu
|
|
31
33
|
nmh <path> [path…] # shortcut: scan given path(s)
|
|
32
34
|
nmh scan [dir] # full scan (default: cwd)
|
|
33
35
|
nmh clean [dir] # scan + delete everything safe (trash by default)
|
|
@@ -44,7 +46,9 @@ nmh stats -g # global installs across npm/pnpm/yarn/bun
|
|
|
44
46
|
# aliases: `analyze` and `info` behave exactly like `stats` (recognition from PRD-era names)
|
|
45
47
|
nmh why <pkg> [dir…] # reverse depend: where is <pkg> and what requires it — read-only
|
|
46
48
|
nmh treemap [dir] # single-file interactive HTML treemap → nmh-treemap.html
|
|
47
|
-
nmh update
|
|
49
|
+
nmh update --check # v1.2: compare installed vs registry — nothing installs
|
|
50
|
+
nmh update --info # v1.2: full registry detail (npm view style)
|
|
51
|
+
nmh update -y # install the newer version without asking (-n declines, --force skips the ask)
|
|
48
52
|
|
|
49
53
|
nmh --dir=A,B,glob/*/node_modules # targets: comma list, globs, ~ — combine with flags below
|
|
50
54
|
nmh version | --version | -v # banner: tool version, node+abi, OS/arch/kernel distro, tty — --json for plain fields
|
|
@@ -86,11 +90,11 @@ Search patterns: plain text is a case-insensitive substring (`nmh search lodash`
|
|
|
86
90
|
| `--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
91
|
| `--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
92
|
| `--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` |
|
|
93
|
+
| `--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
94
|
| `--update-check` | Opt-in: peek npm for a newer release (non-blocking, 24 h cache, notice on stderr; also `updateCheck` in `.nmhrc`) |
|
|
91
95
|
| `--config <file>` | Use this config file instead of the `.nmhrc` lookup (cwd → `$HOME`) |
|
|
92
96
|
| `--json` | Machine-readable output (pipe into `jq`) |
|
|
93
|
-
| `-q/--quiet`, `--verbose`, `--no-color` | Output control |
|
|
97
|
+
| `-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
98
|
|
|
95
99
|
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
100
|
|
|
@@ -322,6 +326,8 @@ pasang ulang semuanya sekali : ~6.7s
|
|
|
322
326
|
|
|
323
327
|
Read-only items return to the menu; a delete run ends with the freed-space summary and the rescue-log pointer. Non-TTY (piped/CI): no menu — a compact one-line banner + a read-only scan, and `--json` suppresses even that for clean machine output. The v0.8 auto-scope climbs to the nearest project root (package.json / any PM lockfile / `pnpm-workspace.yaml`, ≤8 hops of ancestors — empty manifest dirs are skipped so the monorepo root still wins) and sticks for the whole session; the TTY shows a dim note, non-TTY a warn line, `--json` stays a pure payload.
|
|
324
328
|
|
|
329
|
+
**Explicit targeting (v1.2)**: `nmh wizard` is a registered command now (same menu as the bare invocation). Give it `--dir=./repo1,./repo2` and the menu runs on those targets as-is — the auto-climb is skipped for explicit scopes. `nmh --wizard` is the flag form. The **`--` router** opens a read-only view before the menu: `nmh wizard -- stats --dir=./repo1` runs the full stats report first (the view segment is parsed by the same dispatcher — all its flags/guards apply, and flags before `--` belong to the wizard), then drops you into the menu. Views: `stats` (aliases `analyze`, `info`), `map`, `search`, `why`, `treemap`. All explicit wizard forms need a TTY — a piped run refuses with exit `1` and directions (the bare `nmh` fallback scan is untouched).
|
|
330
|
+
|
|
325
331
|
## Safety first (the whole point)
|
|
326
332
|
|
|
327
333
|
- **Guards refuse to delete** (exit code `3`): the filesystem root, `$HOME` itself, a `node_modules` sitting **directly inside** `/` or `$HOME`, anything not named exactly `node_modules`, and targets with unresolvable paths (symlink loops).
|
|
@@ -351,7 +357,7 @@ The classic question — *which of these `node_modules` can I actually kill?*
|
|
|
351
357
|
|
|
352
358
|
- **`--fast-size`** swaps the byte-exact walker for one `du -sk` per entry (KiB granularity — sizes are always multiples of 1024); anything `du` can't measure falls back to the exact walker silently. Scan-only knob: delete runs always measure exactly so the freed-bytes number stays honest. Windows has no `du`, so it always walks.
|
|
353
359
|
- **Default skip-list flip**: discovery now hides name-based system noise (`.Trash`, `.Trashes`, `.Trash-<uid>`, `Library`, `*.app`) wherever it sits in the tree; pass **`--no-skip`** to restore the bare full-tree v1.0 audit. `.git` stays skipped always (even with `--no-skip`), and direct targets are never filtered — the skip only shapes what discovery finds.
|
|
354
|
-
- **`nmh update
|
|
360
|
+
- **`nmh update` is consented (v1.2)**: the plain run shows the facts and asks before installing (`@clack` confirm on a TTY; a piped/`--json` run needs `-y` to install or `-n` to decline — without an answer it refuses with exit `1`, the same protocol as deletes). `--check` is the compact compare for scripts/CI (exit 0 whether or not an update exists, never installs), `--info` is the full `npm view`-style read-out (description, tarball + size + file count, engines, license, publish time), and the two are mutually exclusive. `--force` skips the ask when an update exists — an already-latest install is checked and reported, never blindly reinstalled. Non-npm-global installs keep the honest how-to note. `--json` payload: `{ current, latest, updated, method }` frozen + additive `{ updateAvailable, detail? }`.
|
|
355
361
|
- **`--json` stays additive**: new optional keys `idleDays`, `activitySource`, `backend`, `trashBatch`, `trashFallback` appear only when a v1.1 feature is active; every v1.0 payload key is unchanged (the frozen v1.0 contract, extended minor-version-safely).
|
|
356
362
|
|
|
357
363
|
## Exit codes
|
|
@@ -413,7 +419,7 @@ For settings you always repeat, drop a `.nmhrc` (or `.nmhrc.json`) next to your
|
|
|
413
419
|
}
|
|
414
420
|
```
|
|
415
421
|
|
|
416
|
-
- Nearest file wins: looks from the current dir up **to** `$HOME` (inclusive), not beyond.
|
|
422
|
+
- 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
423
|
- `--config <file>` replaces the lookup entirely.
|
|
418
424
|
- 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
425
|
- `--config` isn't a delete-mode switch — deleting still asks permission unless `--yes`/`--force`.
|
|
@@ -448,7 +454,7 @@ nmh config unset --default-exclude --animate # keys out of the file
|
|
|
448
454
|
|
|
449
455
|
## Roadmap
|
|
450
456
|
|
|
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.
|
|
457
|
+
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. v1.2.0 is the **consent-and-targeting edition**: `nmh update` stops installing on faith — the plain run shows the facts and asks (`-y`/`-n` on pipes, `--force` to skip the ask, and an already-latest install is never reinstalled), while `--check` and `--info` are read-only registry views (compact compare vs full `npm view` detail, mutually exclusive); and the wizard becomes a first-class command — `nmh wizard --dir=./a,./b` scopes the menu to explicit targets (no auto-climb), `nmh --wizard` is the flag form, and the `--` router (`nmh wizard -- stats --dir=./a`) opens a read-only view through the full dispatcher before the menu; pipes refuse every explicit wizard form with exit 1 and directions while the bare `nmh` fallback scan stays untouched. The update messages were also unified to Indonesian (the smoke matrix caught `--check` speaking English next to an Indonesian default). Suite: 46 files / 295 tests green.
|
|
452
458
|
|
|
453
459
|
## Development
|
|
454
460
|
|
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,171 @@ 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 wizard [--dir \u2026]", desc: "v1.2: the wizard on explicit targets (--dir=./a,./b skips the auto-climb); -- <view> opens a view first" },
|
|
252
|
+
{ usage: "nmh scan [dir]", desc: "full scan across targets (default: cwd)" },
|
|
253
|
+
{ usage: "nmh clean [dir]", desc: "scan + delete everything safe (trash by default)" },
|
|
254
|
+
{ usage: "nmh search <pattern> [dir\u2026]", desc: "find installed packages by name (+ versions & sizes) \u2014 read-only" },
|
|
255
|
+
{ usage: "nmh map [dir]", desc: "project \u2192 PM \u2192 size \u2192 top packages \u2192 declared deps \u2014 read-only" },
|
|
256
|
+
{ usage: "nmh stats [dir]", desc: "size analytics: dup factor, waste, top-N, per-package tree (aliases: analyze, info)" },
|
|
257
|
+
{ usage: "nmh why <pkg> [dir\u2026]", desc: "reverse depend: where is <pkg> and who requires it \u2014 read-only" },
|
|
258
|
+
{ usage: "nmh treemap [dir]", desc: "single-file interactive HTML treemap \u2192 nmh-treemap.html" },
|
|
259
|
+
{ usage: "nmh stats -g", desc: "global installs across npm/pnpm/yarn/bun \u2014 read-only" },
|
|
260
|
+
{ usage: "nmh config <op>", desc: "manage .nmhrc from the CLI: info \xB7 set \xB7 add \xB7 remove \xB7 unset" },
|
|
261
|
+
{ usage: "nmh update", desc: "v1.2: shows the facts and asks before installing \u2014 --check / --info compare only; -y installs, -n declines, --force skips the ask" },
|
|
262
|
+
{ usage: "nmh version", desc: "banner: tool version, node+abi, OS/arch/kernel, tty (alias: -v / --version)" },
|
|
263
|
+
{ usage: "nmh help", desc: "this help (alias: -h / --help)" }
|
|
264
|
+
];
|
|
265
|
+
var STAT_SUBCOMMANDS = [
|
|
266
|
+
{ usage: "stats --by type [dir]", desc: "breakdown per dependency field (dep/dev/optional/peer/transitive)" },
|
|
267
|
+
{ usage: "stats --licenses [dir]", desc: "license buckets (+ \u26A0 copyleft inside production deps)" },
|
|
268
|
+
{ usage: "stats --score / --co2", desc: "experimental: composite bloat score 0-100 / rough CO2 estimate" },
|
|
269
|
+
{ usage: "stats --graph [dir]", desc: "dependency graph from the lockfile \u2014 npm/yarn/pnpm/bun, no extra deps" },
|
|
270
|
+
{ usage: "stats --reinstall [dir]", desc: 'naive npm-ci-style "how long would reinstalling everything take"' }
|
|
271
|
+
];
|
|
272
|
+
var GROUPS = [
|
|
273
|
+
{
|
|
274
|
+
title: "Actions",
|
|
275
|
+
flags: [
|
|
276
|
+
{ usage: "--delete [mode]", desc: "delete found folders: hard | trash (soft alias) \u2014 no value = pick interactively" },
|
|
277
|
+
{ usage: "--trash system|nmh", desc: "v1.1: whose trash takes victims \u2014 system backend (default) or restorable ~/.nmh/trash" },
|
|
278
|
+
{ usage: "--dry-run", desc: "preview what would be deleted \u2014 nothing is touched" },
|
|
279
|
+
{ usage: "-y, --yes", desc: "answer yes to every prompt (required for non-TTY deletes)" },
|
|
280
|
+
{ usage: "-f, --force", desc: "skip all confirmations (implies --yes)" },
|
|
281
|
+
{ usage: "--search <pattern>", desc: "search installed packages (substring, glob, or /regex/) \u2014 read-only" },
|
|
282
|
+
{ usage: "-m, --map", desc: "map projects: PM, size, top packages, declared deps \u2014 read-only" },
|
|
283
|
+
{ usage: "--depend [pkg]", desc: "map only: show declared deps; with a value, list who declares it" },
|
|
284
|
+
{ usage: "--tree", desc: "map/stats only: list per-project packages (tree view, with sizes)" },
|
|
285
|
+
{ usage: "--why <pkg>", desc: "read-only: explain an installed package \u2014 instances and requirers" },
|
|
286
|
+
{ usage: "--stats", desc: "stats report: overview + dup factor + waste + top sizes \u2014 read-only" },
|
|
287
|
+
{ usage: "--treemap [file]", desc: "treemap export: single-file HTML (default nmh-treemap.html; --out is an alias)" },
|
|
288
|
+
{ usage: "-d, --dir <paths>", desc: "target dir(s), comma separated or repeated; globs and ~ allowed" },
|
|
289
|
+
{ usage: "--size <mode>", desc: "output shape: 'total' (just aggregate) or 'each' (default: per-folder table)" },
|
|
290
|
+
{ usage: "--save-report <file>", desc: "also write the printed report to a file (.json/.md/.txt; --report <file> aliases it)" },
|
|
291
|
+
{ usage: "--wizard", desc: "v1.2: open the wizard explicitly \u2014 same as bare `nmh`, but a pipe refuses with directions instead of a scan" },
|
|
292
|
+
{ usage: "update: --check \xB7 --info \xB7 -y \xB7 -n \xB7 -f", desc: "v1.2: read-only views vs consented install \u2014 see `nmh update` row above" },
|
|
293
|
+
{ usage: "--update-check", desc: "peek npm for a newer release (non-blocking, 24 h cache; also settable in .nmhrc)" }
|
|
294
|
+
]
|
|
295
|
+
},
|
|
296
|
+
{
|
|
297
|
+
title: "Stale hunting & output shape (v1.1)",
|
|
298
|
+
flags: [
|
|
299
|
+
{ usage: "--stale <days>", desc: "keep only projects idle >= <days>; ladder = git commit \u2192 manifest \u2192 folder mtime; no evidence = dropped" },
|
|
300
|
+
{ usage: "--fast-size", desc: "measure with one `du -sk` per entry (KiB granularity) instead of walking every file" },
|
|
301
|
+
{ usage: "--sort <key>", desc: "order results: size (default, desc) | path | idle (oldest project first) | mtime (newest first)" },
|
|
302
|
+
{ usage: "--no-skip", desc: "stop hiding system noise (.Trash*, Library/, *.app) during discovery \u2014 default: hidden" },
|
|
303
|
+
{ usage: "--animate / --no-animate", desc: "live size counter while measuring: auto = real TTY only" },
|
|
304
|
+
{ usage: "--json", desc: "machine-readable payload (pipe into jq); additive-only keys \u2014 v1.0 keys stay frozen" },
|
|
305
|
+
{ usage: "-q, --quiet", desc: "minimal output \u2014 pretty tables/notes mute, the --json payload still prints" },
|
|
306
|
+
{ usage: "--verbose", desc: "debug chatter on stderr" },
|
|
307
|
+
{ usage: "--no-color", desc: "disable ANSI colors (auto: colors only on a real TTY or with --color / FORCE_COLOR)" },
|
|
308
|
+
{ usage: "--color", desc: "force ANSI colors even on a piped run (wins over a config color:false)" }
|
|
309
|
+
]
|
|
310
|
+
},
|
|
311
|
+
{
|
|
312
|
+
title: "Scoping & filters",
|
|
313
|
+
flags: [
|
|
314
|
+
{ usage: "-r, --recursive", desc: "also look INSIDE found node_modules (nested audit; totals stay top-level only)" },
|
|
315
|
+
{ usage: "--pm [family]", desc: "filter by detected PM: npm|pnpm|yarn|bun|vp \u2014 no value lists what was detected" },
|
|
316
|
+
{ usage: "--exclude <globs>", desc: "exclude paths from results (comma list of globs, e.g. **/e2e/**)" },
|
|
317
|
+
{ usage: "--keep <pkgs>", desc: 'refuse to delete node_modules of these projects (comma list, glob ok; "self" works)' },
|
|
318
|
+
{ usage: "--depth <n>", desc: "max scan depth in levels below the target" },
|
|
319
|
+
{ usage: "--expand", desc: "full absolute paths + extra detail in output" }
|
|
320
|
+
]
|
|
321
|
+
},
|
|
322
|
+
{
|
|
323
|
+
title: "Stats suite",
|
|
324
|
+
flags: [
|
|
325
|
+
{ usage: "--top <n>", desc: "rows in the focused size list (default 10)" },
|
|
326
|
+
{ usage: "--by <mode>", desc: "breakdown mode \u2014 'size' (alias: package), 'type', 'licenses'" },
|
|
327
|
+
{ usage: "-g, --global", desc: "analyze global installs (per-PM prefix scan; read-only)" },
|
|
328
|
+
{ usage: "--config <file>", desc: "use this config file instead of the .nmhrc lookup (cwd \u2192 home)" },
|
|
329
|
+
{ usage: "--save-baseline <name>", desc: "save a metadata snapshot of this run to ~/.nmh/baselines/<name>.json" },
|
|
330
|
+
{ usage: "--compare <name>", desc: "diff this run against a stored baseline (totals, per-folder, package movers)" },
|
|
331
|
+
{ usage: "--history", desc: "list stored baselines \u2014 no scan at all" },
|
|
332
|
+
{ usage: "--phantom", desc: "stats only: imports found in sources but declared nowhere (best-effort regex audit)" },
|
|
333
|
+
{ usage: "--dead", desc: "stats only: installed + declared but never imported (best-effort regex audit)" },
|
|
334
|
+
{ usage: "--licenses", desc: "stats only: bucket instances by license field; flags copyleft in production deps" },
|
|
335
|
+
{ usage: "--score", desc: "stats only: experimental composite bloat score 0-100" },
|
|
336
|
+
{ usage: "--co2", desc: "stats only: experimental carbon estimate (storage-year + one redownload)" },
|
|
337
|
+
{ usage: "--online", desc: "stats only: deprecated scan \u2014 offline manifests + lazy cached registry fallback" },
|
|
338
|
+
{ usage: "--graph", desc: "stats only: dependency graph from the lockfile (npm/yarn/pnpm/bun) with BFS depth" },
|
|
339
|
+
{ usage: "--reinstall", desc: "stats only: naive npm-ci-style estimate of the time to reinstall everything" },
|
|
340
|
+
{ usage: "--out <file>", desc: "treemap only: where to write the HTML file (same as --treemap <file>)" }
|
|
341
|
+
]
|
|
342
|
+
},
|
|
343
|
+
{
|
|
344
|
+
title: "Config family",
|
|
345
|
+
flags: [
|
|
346
|
+
{ usage: "--default-exclude <globs>", desc: 'manage the persisted exclude list (comma list; special value "self")' },
|
|
347
|
+
{ usage: "--default-keep <pkgs>", desc: 'manage the persisted keep list (comma list; special value "self")' },
|
|
348
|
+
{ usage: "--default-mode <mode>", desc: "set the persisted delete default (hard | soft | trash)" },
|
|
349
|
+
{ usage: "--log-dir <dir>", desc: "set the persisted recovery-log directory (logDir)" }
|
|
350
|
+
]
|
|
351
|
+
}
|
|
352
|
+
];
|
|
353
|
+
var EXAMPLES = [
|
|
354
|
+
["nmh", "wizard; the no-args menu auto-climbs to the nearest project root"],
|
|
355
|
+
["nmh wizard --dir=./repo1,./repo2", "wizard scoped to explicit targets \u2014 no auto-climb"],
|
|
356
|
+
["nmh wizard -- stats --dir=./repo1", "v1.2 router: stats view first, then the wizard menu"],
|
|
357
|
+
["nmh --dir=./proj,~/code/*/node_modules", "multi-target scan"],
|
|
358
|
+
["nmh . --stale 90 --delete=trash -y", "bury the stale boneyard, restorable trash"],
|
|
359
|
+
["nmh why react --expand", "reverse-depend: who declares + who requires"],
|
|
360
|
+
["nmh stats --by type --json", "dep-field breakdown as a machine payload"],
|
|
361
|
+
["nmh stats -g --top 20", "the 20 biggest global installs across PMs"],
|
|
362
|
+
["nmh update --check", "compare installed vs registry \u2014 nothing installs"],
|
|
363
|
+
["nmh config set default-mode trash", "persist a default delete mode in .nmhrc"]
|
|
364
|
+
];
|
|
365
|
+
var flagWidth = (groups) => {
|
|
366
|
+
let width = 0;
|
|
367
|
+
for (const group of groups) for (const item of group.flags) width = Math.max(width, item.usage.length);
|
|
368
|
+
return width;
|
|
369
|
+
};
|
|
370
|
+
var commandWidth = (items) => {
|
|
371
|
+
let width = 0;
|
|
372
|
+
for (const item of items) width = Math.max(width, item.usage.length);
|
|
373
|
+
return width;
|
|
374
|
+
};
|
|
375
|
+
function renderSection(title, items, width) {
|
|
376
|
+
const lines = [colors.bold(title)];
|
|
377
|
+
for (const item of items) {
|
|
378
|
+
lines.push(colors.cyan(item.usage.padEnd(width + 2)) + colors.dim(item.desc));
|
|
379
|
+
}
|
|
380
|
+
return lines;
|
|
381
|
+
}
|
|
382
|
+
function renderHelp() {
|
|
383
|
+
const lines = [];
|
|
384
|
+
lines.push(colors.bold("nmh") + colors.dim(" \u2014 hunt bloated node_modules folders: scan, measure, clean safely (npm/pnpm/yarn/bun)"));
|
|
385
|
+
lines.push("");
|
|
386
|
+
lines.push(colors.bold("Usage"));
|
|
387
|
+
lines.push(` ${colors.cyan("nmh <command|targets> [options]")}`);
|
|
388
|
+
lines.push("");
|
|
389
|
+
lines.push(...renderSection("Commands", COMMANDS, commandWidth(COMMANDS)));
|
|
390
|
+
lines.push("");
|
|
391
|
+
lines.push(...renderSection("Stats subcommands", STAT_SUBCOMMANDS, Math.max(commandWidth(STAT_SUBCOMMANDS), commandWidth(COMMANDS))));
|
|
392
|
+
lines.push("");
|
|
393
|
+
const width = flagWidth(GROUPS);
|
|
394
|
+
for (const group of GROUPS) {
|
|
395
|
+
lines.push(...renderSection(`Options \u2014 ${group.title}`, group.flags, width));
|
|
396
|
+
lines.push("");
|
|
397
|
+
}
|
|
398
|
+
lines.push(colors.bold("Examples"));
|
|
399
|
+
for (const [cmd, note2] of EXAMPLES) {
|
|
400
|
+
lines.push(` ${colors.cyan(cmd.padEnd(40))}${colors.dim(note2)}`);
|
|
401
|
+
}
|
|
402
|
+
lines.push("");
|
|
403
|
+
lines.push(colors.dim("Safety first: guards refuse dangerous targets (exit 3), deletes confirm unless --yes/--force, symlinks never get followed."));
|
|
404
|
+
lines.push(colors.dim("Exit codes: 0 ok \xB7 1 usage error \xB7 2 nothing found \xB7 3 guard refused \xB7 4 permission denied."));
|
|
405
|
+
lines.push(colors.dim("Everything is documented in depth at: https://github.com/jhedev96/node-modules-hunter"));
|
|
406
|
+
return lines.join("\n") + "\n";
|
|
407
|
+
}
|
|
408
|
+
function printHelp() {
|
|
409
|
+
process.stdout.write(renderHelp());
|
|
410
|
+
}
|
|
411
|
+
|
|
219
412
|
// src/utils/future-flags.ts
|
|
220
413
|
var FUTURE_FLAGS = /* @__PURE__ */ new Set([
|
|
221
414
|
"empty"
|
|
@@ -329,6 +522,17 @@ async function loadConfig(explicitPath, cwd) {
|
|
|
329
522
|
if (current === home || current === path2.dirname(current)) break;
|
|
330
523
|
current = path2.dirname(current);
|
|
331
524
|
}
|
|
525
|
+
const homeChain = cwd === home || cwd.startsWith(home + path2.sep);
|
|
526
|
+
if (!homeChain) {
|
|
527
|
+
for (const name of FILE_NAMES) {
|
|
528
|
+
const candidate = path2.join(home, name);
|
|
529
|
+
const body = await readMaybe(candidate);
|
|
530
|
+
if (body !== null) {
|
|
531
|
+
verboseLog(`config: found ${candidate}`);
|
|
532
|
+
return { values: parseConfigBody(body, candidate), source: candidate };
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
332
536
|
return { values: {}, source: null };
|
|
333
537
|
}
|
|
334
538
|
|
|
@@ -2143,7 +2347,7 @@ async function collectScan(request) {
|
|
|
2143
2347
|
const entryPaths = [...metaByPath.keys()];
|
|
2144
2348
|
verboseLog(`found ${entryPaths.length} node_modules \u2014 measuring`);
|
|
2145
2349
|
const measures = await collectMeasures(entryPaths, request.fastSize === true, permIssues);
|
|
2146
|
-
|
|
2350
|
+
spinClear();
|
|
2147
2351
|
const detect = createPmDetector();
|
|
2148
2352
|
const pmLimiter = createLimiter(8);
|
|
2149
2353
|
const pmResults = await Promise.all(entryPaths.map((entryPath) => pmLimiter(() => detect(entryPath))));
|
|
@@ -2223,7 +2427,7 @@ async function collectMeasures(entryPaths, fastSize, permIssues) {
|
|
|
2223
2427
|
let measuredCount = 0;
|
|
2224
2428
|
let bytesSoFar = 0;
|
|
2225
2429
|
const bump = () => {
|
|
2226
|
-
|
|
2430
|
+
spinUpdate(`measuring ${measuredCount}/${entryCount} folders \xB7 ${formatBytes(bytesSoFar)}`);
|
|
2227
2431
|
};
|
|
2228
2432
|
if (!fastSize) {
|
|
2229
2433
|
return measureEntries(entryPaths, permIssues, {
|
|
@@ -2262,8 +2466,8 @@ async function collectMeasures(entryPaths, fastSize, permIssues) {
|
|
|
2262
2466
|
const walked = await measureEntries(fallbackPaths, permIssues, {
|
|
2263
2467
|
concurrency: 4,
|
|
2264
2468
|
onProgress: (progress) => {
|
|
2265
|
-
|
|
2266
|
-
|
|
2469
|
+
spinUpdate(
|
|
2470
|
+
`measuring ${duCount + progress.measuredEntries}/${entryCount} folders \xB7 ${formatBytes(duBytes + progress.bytesSoFar)}`
|
|
2267
2471
|
);
|
|
2268
2472
|
}
|
|
2269
2473
|
});
|
|
@@ -2350,6 +2554,7 @@ function emptyResult(targets, unresolvable = []) {
|
|
|
2350
2554
|
import { promisify as promisify3 } from "util";
|
|
2351
2555
|
import { execFile as execFile3 } from "child_process";
|
|
2352
2556
|
import fs13 from "fs/promises";
|
|
2557
|
+
import { confirm } from "@clack/prompts";
|
|
2353
2558
|
|
|
2354
2559
|
// src/utils/update-check.ts
|
|
2355
2560
|
import fs12 from "fs/promises";
|
|
@@ -2454,24 +2659,62 @@ async function runNpm(args, timeoutMs) {
|
|
|
2454
2659
|
}
|
|
2455
2660
|
}
|
|
2456
2661
|
var VERSION_SHAPE = /^\d+\.\d+\.\d+/;
|
|
2457
|
-
|
|
2662
|
+
function manifestDetailFrom(parsed) {
|
|
2663
|
+
const detail = {};
|
|
2664
|
+
if (typeof parsed.description === "string") detail.description = parsed.description;
|
|
2665
|
+
if (typeof parsed.license === "string") detail.license = parsed.license;
|
|
2666
|
+
const dist = parsed.dist;
|
|
2667
|
+
if (dist !== null && typeof dist === "object") {
|
|
2668
|
+
const d = dist;
|
|
2669
|
+
if (typeof d.tarball === "string") detail.tarball = d.tarball;
|
|
2670
|
+
if (typeof d.unpackedSize === "number") detail.unpackedSize = formatBytes(d.unpackedSize);
|
|
2671
|
+
if (typeof d.fileCount === "number") detail.fileCount = d.fileCount;
|
|
2672
|
+
}
|
|
2673
|
+
const engines = parsed.engines;
|
|
2674
|
+
if (engines !== null && typeof engines === "object") {
|
|
2675
|
+
const e = engines;
|
|
2676
|
+
if (typeof e.node === "string") detail.engines = e.node;
|
|
2677
|
+
}
|
|
2678
|
+
return detail;
|
|
2679
|
+
}
|
|
2680
|
+
async function latestManifest() {
|
|
2458
2681
|
const override = process.env.NMH_UPDATE_LATEST_JSON;
|
|
2459
2682
|
if (typeof override === "string" && override.length > 0) {
|
|
2460
2683
|
try {
|
|
2461
2684
|
const parsed = JSON.parse(await fs13.readFile(override, "utf8"));
|
|
2462
|
-
|
|
2685
|
+
const version = parsed.version;
|
|
2686
|
+
if (typeof version !== "string" || !VERSION_SHAPE.test(version)) return null;
|
|
2687
|
+
return { version, detail: manifestDetailFrom(parsed) };
|
|
2463
2688
|
} catch (err) {
|
|
2464
2689
|
verboseLog(`update: override file unreadable (${err instanceof Error ? err.message : String(err)})`);
|
|
2465
2690
|
return null;
|
|
2466
2691
|
}
|
|
2467
2692
|
}
|
|
2468
|
-
const view = await runNpm(["view", "node-modules-hunter", "
|
|
2693
|
+
const view = await runNpm(["view", "node-modules-hunter", "--json"], 6e4);
|
|
2469
2694
|
if (view.code !== 0) {
|
|
2470
2695
|
verboseLog(`npm view failed (rc ${view.code}): ${view.stderr.trim().slice(0, 200)}`);
|
|
2471
2696
|
return null;
|
|
2472
2697
|
}
|
|
2473
|
-
|
|
2474
|
-
|
|
2698
|
+
try {
|
|
2699
|
+
const parsed = JSON.parse(view.stdout);
|
|
2700
|
+
const version = parsed.version;
|
|
2701
|
+
if (typeof version !== "string" || !VERSION_SHAPE.test(version)) return null;
|
|
2702
|
+
const detail = manifestDetailFrom(parsed);
|
|
2703
|
+
detail.publishedAt = await publishedTime(version);
|
|
2704
|
+
return { version, detail };
|
|
2705
|
+
} catch {
|
|
2706
|
+
return null;
|
|
2707
|
+
}
|
|
2708
|
+
}
|
|
2709
|
+
async function publishedTime(version) {
|
|
2710
|
+
const times = await runNpm(["view", "node-modules-hunter", "time", "--json"], 3e4);
|
|
2711
|
+
if (times.code !== 0) return void 0;
|
|
2712
|
+
try {
|
|
2713
|
+
const parsed = JSON.parse(times.stdout);
|
|
2714
|
+
return typeof parsed[version] === "string" ? parsed[version] : void 0;
|
|
2715
|
+
} catch {
|
|
2716
|
+
return void 0;
|
|
2717
|
+
}
|
|
2475
2718
|
}
|
|
2476
2719
|
async function isNpmGlobal() {
|
|
2477
2720
|
const forced = process.env.NMH_UPDATE_GLOBAL;
|
|
@@ -2492,49 +2735,94 @@ async function installLatest(version) {
|
|
|
2492
2735
|
return { ok: false, detail: err instanceof Error ? err.message : String(err) };
|
|
2493
2736
|
}
|
|
2494
2737
|
}
|
|
2495
|
-
|
|
2738
|
+
spinUpdate("updating via npm\u2026");
|
|
2496
2739
|
const install = await runNpm(["install", "-g", "node-modules-hunter@" + version], 3e5);
|
|
2497
|
-
|
|
2740
|
+
spinClear();
|
|
2498
2741
|
if (install.code !== 0) {
|
|
2499
2742
|
return { ok: false, detail: install.stderr.trim().slice(0, 300) || `npm exited ${install.code}` };
|
|
2500
2743
|
}
|
|
2501
2744
|
return { ok: true, detail: installCommand };
|
|
2502
2745
|
}
|
|
2746
|
+
function renderInfo(current, manifest, updateAvailable, global) {
|
|
2747
|
+
const d = manifest.detail;
|
|
2748
|
+
const rows = [
|
|
2749
|
+
`installed ${current}`,
|
|
2750
|
+
`latest ${manifest.version}`,
|
|
2751
|
+
`update ${updateAvailable ? `tersedia \u2014 jalankan \`nmh update\`${global ? "" : " (atau: npm i -g node-modules-hunter)"}` : "tidak ada \u2014 ini versi terbaru"}`
|
|
2752
|
+
];
|
|
2753
|
+
if (d.description !== void 0) rows.push(`summary ${d.description}`);
|
|
2754
|
+
if (d.tarball !== void 0) {
|
|
2755
|
+
const size = d.unpackedSize === void 0 ? "" : ` (${d.unpackedSize}${d.fileCount === void 0 ? "" : `, ${d.fileCount} files`})`;
|
|
2756
|
+
rows.push(`tarball ${d.tarball}${size}`);
|
|
2757
|
+
}
|
|
2758
|
+
if (d.engines !== void 0) rows.push(`engines node ${d.engines}`);
|
|
2759
|
+
if (d.license !== void 0) rows.push(`license ${d.license}`);
|
|
2760
|
+
if (d.publishedAt !== void 0) rows.push(`published ${d.publishedAt}`);
|
|
2761
|
+
return rows;
|
|
2762
|
+
}
|
|
2503
2763
|
async function runUpdate(request) {
|
|
2504
2764
|
const current = toolVersion();
|
|
2505
|
-
const
|
|
2506
|
-
if (
|
|
2765
|
+
const manifest = await latestManifest();
|
|
2766
|
+
if (manifest === null) {
|
|
2507
2767
|
const message = "cannot read the latest version \u2014 is the registry reachable? (npm view node-modules-hunter version)";
|
|
2508
2768
|
if (request.json) errorLog(message);
|
|
2509
2769
|
else errorLog(`\u26A0 ${message}`);
|
|
2510
2770
|
return 1;
|
|
2511
2771
|
}
|
|
2772
|
+
const latest = manifest.version;
|
|
2512
2773
|
const global = await isNpmGlobal();
|
|
2513
2774
|
const method = global ? "npm-global" : "manual";
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2775
|
+
const updateAvailable = isNewer(current, latest);
|
|
2776
|
+
const result = (updated) => ({ current, latest, updated, method, updateAvailable });
|
|
2777
|
+
if (request.mode === "info") {
|
|
2778
|
+
if (request.json) payload(JSON.stringify({ ...result(false), detail: manifest.detail }, null, 2));
|
|
2779
|
+
else log(["nmh update \u2014 info registry (npm view node-modules-hunter)", ...renderInfo(current, manifest, updateAvailable, global)].join("\n"));
|
|
2780
|
+
return 0;
|
|
2781
|
+
}
|
|
2782
|
+
if (request.mode === "check") {
|
|
2783
|
+
if (request.json) payload(JSON.stringify(result(false), null, 2));
|
|
2784
|
+
else if (updateAvailable) log(`\u2191 update tersedia: ${current} \u2192 ${latest} \u2014 jalankan \`nmh update\` untuk menginstall`);
|
|
2785
|
+
else log(`\u2713 nmh ${current} sudah versi terbaru (registry: ${latest})`);
|
|
2786
|
+
return 0;
|
|
2787
|
+
}
|
|
2788
|
+
if (!updateAvailable) {
|
|
2789
|
+
if (request.json) payload(JSON.stringify(result(false), null, 2));
|
|
2790
|
+
else log(`\u2713 nmh ${current} sudah versi terbaru (registry: ${latest}) \u2014 tak ada yang diinstall`);
|
|
2518
2791
|
return 0;
|
|
2519
2792
|
}
|
|
2520
2793
|
if (!global) {
|
|
2521
|
-
const result2 = { current, latest, updated: false, method };
|
|
2522
2794
|
const advice = [
|
|
2523
2795
|
`nmh ${current} terpasang di luar npm global \u2014 update manual:`,
|
|
2524
2796
|
" npm i -g node-modules-hunter (atau: pnpm add -g / bunx / brew upgrade node-modules-hunter)"
|
|
2525
2797
|
];
|
|
2526
|
-
if (request.json) payload(JSON.stringify(
|
|
2798
|
+
if (request.json) payload(JSON.stringify(result(false), null, 2));
|
|
2527
2799
|
else for (const line of advice) note(line);
|
|
2528
2800
|
return 0;
|
|
2529
2801
|
}
|
|
2530
|
-
|
|
2531
|
-
if (!install.
|
|
2532
|
-
|
|
2802
|
+
let install = request.force || request.answer === "yes";
|
|
2803
|
+
if (!install && request.answer === "none") {
|
|
2804
|
+
if (request.json || !isTTYOut()) {
|
|
2805
|
+
errorLog("update butuh jawaban eksplisit \u2014 -y untuk menginstall, -n untuk menolak (--check/--info untuk lihat saja)");
|
|
2806
|
+
return 1;
|
|
2807
|
+
}
|
|
2808
|
+
const admitted = await confirm({
|
|
2809
|
+
message: `Update nmh ${current} \u2192 ${latest} via npm install -g?`,
|
|
2810
|
+
initialValue: true
|
|
2811
|
+
});
|
|
2812
|
+
install = admitted === true;
|
|
2813
|
+
}
|
|
2814
|
+
if (!install) {
|
|
2815
|
+
if (request.json) payload(JSON.stringify(result(false), null, 2));
|
|
2816
|
+
else log(`\u2715 tetap nmh ${current} \u2014 update ke ${latest} tidak diinstall`);
|
|
2817
|
+
return 0;
|
|
2818
|
+
}
|
|
2819
|
+
const installation = await installLatest(latest);
|
|
2820
|
+
if (!installation.ok) {
|
|
2821
|
+
errorLog(`\u26A0 update gagal: ${installation.detail}`);
|
|
2533
2822
|
errorLog(` coba manual: npm install -g node-modules-hunter@${latest}`);
|
|
2534
2823
|
return 1;
|
|
2535
2824
|
}
|
|
2536
|
-
|
|
2537
|
-
if (request.json) payload(JSON.stringify(result, null, 2));
|
|
2825
|
+
if (request.json) payload(JSON.stringify(result(true), null, 2));
|
|
2538
2826
|
else log(`\u2713 terupdate ${current} \u2192 ${latest} (npm install -g node-modules-hunter)`);
|
|
2539
2827
|
return 0;
|
|
2540
2828
|
}
|
|
@@ -2750,7 +3038,7 @@ var INFO_DEFAULTS = {
|
|
|
2750
3038
|
updateCheck: "off",
|
|
2751
3039
|
logDir: "~/.nmh"
|
|
2752
3040
|
};
|
|
2753
|
-
function
|
|
3041
|
+
function renderInfo2(source, values) {
|
|
2754
3042
|
const row = (key, value) => ` ${colors.dim(key.padEnd(12, " "))}${value}`;
|
|
2755
3043
|
const lines = [
|
|
2756
3044
|
"",
|
|
@@ -2787,7 +3075,7 @@ async function runConfigCommand(request) {
|
|
|
2787
3075
|
);
|
|
2788
3076
|
return 0;
|
|
2789
3077
|
}
|
|
2790
|
-
log(
|
|
3078
|
+
log(renderInfo2(loaded.source, loaded.values));
|
|
2791
3079
|
return 0;
|
|
2792
3080
|
}
|
|
2793
3081
|
const file = configWriteTarget(request.explicitFile, request.cwd);
|
|
@@ -2867,12 +3155,12 @@ async function collectSearch(request) {
|
|
|
2867
3155
|
)
|
|
2868
3156
|
);
|
|
2869
3157
|
searched += 1;
|
|
2870
|
-
|
|
3158
|
+
spinUpdate(`searching ${searched}/${entryCount} node_modules\u2026`);
|
|
2871
3159
|
return { entry, pathMatch, matches };
|
|
2872
3160
|
})
|
|
2873
3161
|
)
|
|
2874
3162
|
);
|
|
2875
|
-
|
|
3163
|
+
spinClear();
|
|
2876
3164
|
const results = outcomes.filter((outcome) => outcome.pathMatch || outcome.matches.length > 0);
|
|
2877
3165
|
verboseLog(`search '${request.pattern}': ${results.length}/${entryCount} entries matched`);
|
|
2878
3166
|
let code = 0;
|
|
@@ -3456,12 +3744,12 @@ async function collectMap(request) {
|
|
|
3456
3744
|
)
|
|
3457
3745
|
);
|
|
3458
3746
|
mapped += 1;
|
|
3459
|
-
|
|
3747
|
+
spinUpdate(`mapping ${mapped}/${entryCount} projects\u2026`);
|
|
3460
3748
|
return { entry, project, topPackages, declared };
|
|
3461
3749
|
})
|
|
3462
3750
|
)
|
|
3463
3751
|
);
|
|
3464
|
-
|
|
3752
|
+
spinClear();
|
|
3465
3753
|
let code = 0;
|
|
3466
3754
|
if (projects.length === 0) code = 2;
|
|
3467
3755
|
else if (scan.result.permissionIssues.length > 0) code = 4;
|
|
@@ -3956,13 +4244,13 @@ async function collectStats(request) {
|
|
|
3956
4244
|
)
|
|
3957
4245
|
);
|
|
3958
4246
|
measuredEntries += 1;
|
|
3959
|
-
|
|
4247
|
+
spinUpdate(`analyzing ${measuredEntries}/${entryCount} node_modules\u2026`);
|
|
3960
4248
|
const graph2 = request.graph === true ? await readProjectLockGraph(declared?.projectDir ?? path21.dirname(entry.path)) : void 0;
|
|
3961
4249
|
return { entry, project, topPackages, declared, graph: graph2 };
|
|
3962
4250
|
})
|
|
3963
4251
|
)
|
|
3964
4252
|
);
|
|
3965
|
-
|
|
4253
|
+
spinClear();
|
|
3966
4254
|
const byName = /* @__PURE__ */ new Map();
|
|
3967
4255
|
const versionsByName = /* @__PURE__ */ new Map();
|
|
3968
4256
|
const members = /* @__PURE__ */ new Map();
|
|
@@ -4408,7 +4696,7 @@ async function saveBaselineQuiet(name, snapshot) {
|
|
|
4408
4696
|
}
|
|
4409
4697
|
|
|
4410
4698
|
// src/commands/delete.ts
|
|
4411
|
-
import { confirm, select, isCancel } from "@clack/prompts";
|
|
4699
|
+
import { confirm as confirm2, select, isCancel as isCancel2 } from "@clack/prompts";
|
|
4412
4700
|
|
|
4413
4701
|
// src/core/deleter.ts
|
|
4414
4702
|
import fs20 from "fs/promises";
|
|
@@ -4620,10 +4908,10 @@ async function executeAll(entries, request) {
|
|
|
4620
4908
|
mode: request.mode,
|
|
4621
4909
|
trashMode: request.trashMode,
|
|
4622
4910
|
onRow: (_row, done, total) => {
|
|
4623
|
-
|
|
4911
|
+
spinUpdate(`\u{1F5D1} ${done}/${total} folders\u2026`);
|
|
4624
4912
|
}
|
|
4625
4913
|
});
|
|
4626
|
-
|
|
4914
|
+
spinClear();
|
|
4627
4915
|
if (request.json) payload(renderDeleteJson(outcome, request.mode, false));
|
|
4628
4916
|
else {
|
|
4629
4917
|
const summary = renderDeleteSummary(outcome, request.mode);
|
|
@@ -4740,13 +5028,13 @@ async function resolveMode(requested, json, dryRun) {
|
|
|
4740
5028
|
],
|
|
4741
5029
|
initialValue: "trash"
|
|
4742
5030
|
});
|
|
4743
|
-
if (
|
|
5031
|
+
if (isCancel2(choice)) return "cancelled";
|
|
4744
5032
|
return choice;
|
|
4745
5033
|
}
|
|
4746
5034
|
return "trash";
|
|
4747
5035
|
}
|
|
4748
5036
|
async function confirmDeletion(count, size, mode) {
|
|
4749
|
-
const answer = await
|
|
5037
|
+
const answer = await confirm2({
|
|
4750
5038
|
message: mode === "hard" ? `Delete ${count} folder(s) \u2014 ${formatBytes(size)} \u2014 PERMANENTLY (no undo)?` : `Move ${count} folder(s) \u2014 ${formatBytes(size)} \u2014 to the OS trash?`,
|
|
4751
5039
|
initialValue: mode !== "hard"
|
|
4752
5040
|
});
|
|
@@ -4797,7 +5085,7 @@ async function runClean(request) {
|
|
|
4797
5085
|
// src/commands/wizard.ts
|
|
4798
5086
|
import fs21 from "fs/promises";
|
|
4799
5087
|
import path23 from "path";
|
|
4800
|
-
import { confirm as
|
|
5088
|
+
import { confirm as confirm3, isCancel as isCancel3, multiselect, outro, select as select2, text } from "@clack/prompts";
|
|
4801
5089
|
|
|
4802
5090
|
// src/utils/banner.ts
|
|
4803
5091
|
function widthOf(plain) {
|
|
@@ -4833,6 +5121,9 @@ async function runWizard(cwd, options = {}) {
|
|
|
4833
5121
|
if (!isTTYOut()) return runFallbackScan(cwd, options);
|
|
4834
5122
|
return runInteractiveWizard(cwd, options);
|
|
4835
5123
|
}
|
|
5124
|
+
function wizardTargets(options) {
|
|
5125
|
+
return options.targets !== void 0 && options.targets.length > 0 ? options.targets : ["."];
|
|
5126
|
+
}
|
|
4836
5127
|
var WIZARD_DEPTH = 3;
|
|
4837
5128
|
var WIZARD_MAX_CLIMB = 8;
|
|
4838
5129
|
var PROJECT_MANIFESTS = /* @__PURE__ */ new Set([
|
|
@@ -4871,12 +5162,13 @@ async function resolveWizardScope(cwd) {
|
|
|
4871
5162
|
}
|
|
4872
5163
|
async function runFallbackScan(cwd, options) {
|
|
4873
5164
|
if (!options.json) log(renderCompactBanner());
|
|
4874
|
-
const
|
|
5165
|
+
const explicit = options.targets !== void 0 && options.targets.length > 0;
|
|
5166
|
+
const scope = explicit ? { root: cwd, from: cwd, climbed: false, scouted: false } : await resolveWizardScope(cwd);
|
|
4875
5167
|
if (!options.json && scope.climbed) {
|
|
4876
5168
|
warn(`no node_modules within 3 levels of cwd \u2014 scoped to the project root above: ${scope.root}`);
|
|
4877
5169
|
}
|
|
4878
5170
|
const outcome = await collectScan({
|
|
4879
|
-
targets:
|
|
5171
|
+
targets: wizardTargets(options),
|
|
4880
5172
|
cwd: scope.root,
|
|
4881
5173
|
maxDepth: WIZARD_DEPTH,
|
|
4882
5174
|
excludes: options.excludes,
|
|
@@ -4895,14 +5187,15 @@ async function runFallbackScan(cwd, options) {
|
|
|
4895
5187
|
return outcome.code;
|
|
4896
5188
|
}
|
|
4897
5189
|
async function runInteractiveWizard(cwd, options) {
|
|
4898
|
-
|
|
4899
|
-
|
|
5190
|
+
const explicit = options.targets !== void 0 && options.targets.length > 0;
|
|
5191
|
+
if (options.banner !== false) log(renderFullBanner());
|
|
5192
|
+
const scope = explicit ? { root: cwd, from: cwd, climbed: false, scouted: false } : await resolveWizardScope(cwd);
|
|
4900
5193
|
if (scope.climbed) {
|
|
4901
5194
|
log(colors.dim(`cwd has no node_modules in 3 levels \u2014 scoped up to the project root ${scope.root}`));
|
|
4902
5195
|
}
|
|
4903
5196
|
for (; ; ) {
|
|
4904
5197
|
const scan = await collectScan({
|
|
4905
|
-
targets:
|
|
5198
|
+
targets: wizardTargets(options),
|
|
4906
5199
|
cwd: scope.root,
|
|
4907
5200
|
maxDepth: WIZARD_DEPTH,
|
|
4908
5201
|
excludes: options.excludes,
|
|
@@ -4926,7 +5219,7 @@ async function runInteractiveWizard(cwd, options) {
|
|
|
4926
5219
|
{ value: "quit", label: "\u23F9 Exit nmh" }
|
|
4927
5220
|
]
|
|
4928
5221
|
});
|
|
4929
|
-
if (
|
|
5222
|
+
if (isCancel3(choice) || choice === "quit") {
|
|
4930
5223
|
outro("bye \u{1F44B} \u2014 nothing deleted");
|
|
4931
5224
|
return 0;
|
|
4932
5225
|
}
|
|
@@ -4941,18 +5234,18 @@ async function runInteractiveWizard(cwd, options) {
|
|
|
4941
5234
|
placeholder: "e.g. lodash, react-dom, es*build, /react(-dom)?/i",
|
|
4942
5235
|
validate: (value) => value.trim().length === 0 ? "give a pattern \u2014 substring, glob, or /regex/" : void 0
|
|
4943
5236
|
});
|
|
4944
|
-
if (
|
|
5237
|
+
if (isCancel3(pattern)) {
|
|
4945
5238
|
warn("search skipped \u2014 back to the menu");
|
|
4946
5239
|
continue;
|
|
4947
5240
|
}
|
|
4948
|
-
await runSearch({ pattern: String(pattern).trim(), targets:
|
|
5241
|
+
await runSearch({ pattern: String(pattern).trim(), targets: wizardTargets(options), cwd: scope.root, maxDepth: WIZARD_DEPTH });
|
|
4949
5242
|
break;
|
|
4950
5243
|
}
|
|
4951
5244
|
case "map":
|
|
4952
|
-
await runMap({ targets:
|
|
5245
|
+
await runMap({ targets: wizardTargets(options), cwd: scope.root, maxDepth: WIZARD_DEPTH });
|
|
4953
5246
|
break;
|
|
4954
5247
|
case "stats":
|
|
4955
|
-
await runStats({ targets:
|
|
5248
|
+
await runStats({ targets: wizardTargets(options), cwd: scope.root, maxDepth: WIZARD_DEPTH });
|
|
4956
5249
|
break;
|
|
4957
5250
|
case "settings":
|
|
4958
5251
|
log(renderSettings(options.settings, scope));
|
|
@@ -4984,7 +5277,7 @@ async function deleteFlow2(kind, entries, options) {
|
|
|
4984
5277
|
})),
|
|
4985
5278
|
required: true
|
|
4986
5279
|
});
|
|
4987
|
-
if (
|
|
5280
|
+
if (isCancel3(picked)) {
|
|
4988
5281
|
outro("cancelled \u2014 nothing deleted");
|
|
4989
5282
|
return 0;
|
|
4990
5283
|
}
|
|
@@ -4999,11 +5292,11 @@ async function deleteFlow2(kind, entries, options) {
|
|
|
4999
5292
|
outro("cancelled \u2014 nothing deleted");
|
|
5000
5293
|
return 0;
|
|
5001
5294
|
}
|
|
5002
|
-
const admitted = await
|
|
5295
|
+
const admitted = await confirm3({
|
|
5003
5296
|
message: mode === "hard" ? `Delete ${selected.length} folder(s) \u2014 ${formatBytes(sum(selected))} \u2014 PERMANENTLY (no undo)?` : `Move ${selected.length} folder(s) \u2014 ${formatBytes(sum(selected))} \u2014 to the OS trash?`,
|
|
5004
5297
|
initialValue: mode !== "hard"
|
|
5005
5298
|
});
|
|
5006
|
-
if (
|
|
5299
|
+
if (isCancel3(admitted) || admitted === false) {
|
|
5007
5300
|
outro("cancelled \u2014 nothing deleted");
|
|
5008
5301
|
return 0;
|
|
5009
5302
|
}
|
|
@@ -5022,7 +5315,7 @@ async function pickDeleteMode(defaultMode) {
|
|
|
5022
5315
|
],
|
|
5023
5316
|
initialValue: defaultMode ?? "trash"
|
|
5024
5317
|
});
|
|
5025
|
-
return
|
|
5318
|
+
return isCancel3(modeChoice) ? null : modeChoice;
|
|
5026
5319
|
}
|
|
5027
5320
|
function renderSettings(settings, scope) {
|
|
5028
5321
|
const values = settings?.values ?? {};
|
|
@@ -5057,13 +5350,27 @@ import fs22 from "fs/promises";
|
|
|
5057
5350
|
import path24 from "path";
|
|
5058
5351
|
var execFileAsync2 = promisify5(execFile5);
|
|
5059
5352
|
var PROBE_TIMEOUT = 8e3;
|
|
5353
|
+
function lastLine(stdout) {
|
|
5354
|
+
const line = stdout.split("\n").map((row) => row.trim()).filter(Boolean).pop();
|
|
5355
|
+
return line && line.length > 0 ? line : null;
|
|
5356
|
+
}
|
|
5060
5357
|
async function probeViaCommand(cmd, argv) {
|
|
5061
5358
|
try {
|
|
5062
5359
|
const { stdout } = await execFileAsync2(cmd, argv, { timeout: PROBE_TIMEOUT });
|
|
5063
|
-
|
|
5064
|
-
return line && line.length > 0 ? line : null;
|
|
5360
|
+
return lastLine(stdout);
|
|
5065
5361
|
} catch (err) {
|
|
5066
5362
|
verboseLog(`global probe: ${cmd} ${argv.join(" ")} failed (${err instanceof Error ? err.message.split("\n")[0] : String(err)})`);
|
|
5363
|
+
}
|
|
5364
|
+
if (process.platform !== "win32") return null;
|
|
5365
|
+
try {
|
|
5366
|
+
const { stdout } = await execFileAsync2(cmd, argv, {
|
|
5367
|
+
timeout: PROBE_TIMEOUT,
|
|
5368
|
+
shell: true,
|
|
5369
|
+
windowsHide: true
|
|
5370
|
+
});
|
|
5371
|
+
return lastLine(stdout);
|
|
5372
|
+
} catch (err) {
|
|
5373
|
+
verboseLog(`global probe: ${cmd} ${argv.join(" ")} via shell failed (${err instanceof Error ? err.message.split("\n")[0] : String(err)})`);
|
|
5067
5374
|
return null;
|
|
5068
5375
|
}
|
|
5069
5376
|
}
|
|
@@ -5163,7 +5470,7 @@ async function pnpmFallbackSpec() {
|
|
|
5163
5470
|
return null;
|
|
5164
5471
|
}
|
|
5165
5472
|
async function runGlobal(request) {
|
|
5166
|
-
|
|
5473
|
+
spinUpdate("probing global prefixes\u2026");
|
|
5167
5474
|
const pmRoots = await resolvePmRoots(request.pmFilter);
|
|
5168
5475
|
const reports = [];
|
|
5169
5476
|
const permissionIssues = [];
|
|
@@ -5179,7 +5486,7 @@ async function runGlobal(request) {
|
|
|
5179
5486
|
ref.symlinked ? Promise.resolve(null) : measureEntry(ref.path)
|
|
5180
5487
|
]);
|
|
5181
5488
|
done += 1;
|
|
5182
|
-
|
|
5489
|
+
spinUpdate(`global ${pm} \u2014 ${done}/${refs.length} packages`);
|
|
5183
5490
|
return { name: ref.name, version, symlinked: ref.symlinked, size: measure === null ? null : measure.size };
|
|
5184
5491
|
})
|
|
5185
5492
|
)
|
|
@@ -5194,7 +5501,7 @@ async function runGlobal(request) {
|
|
|
5194
5501
|
dead
|
|
5195
5502
|
});
|
|
5196
5503
|
}
|
|
5197
|
-
|
|
5504
|
+
spinClear();
|
|
5198
5505
|
const byName = /* @__PURE__ */ new Map();
|
|
5199
5506
|
for (const report of reports) {
|
|
5200
5507
|
for (const pkg of report.packages) {
|
|
@@ -5233,6 +5540,8 @@ function finish(request, outcome) {
|
|
|
5233
5540
|
|
|
5234
5541
|
// src/cli.ts
|
|
5235
5542
|
var cli = cac("nmh");
|
|
5543
|
+
cli.option("-h, --help", "Show the full help screen (commands, options, examples)");
|
|
5544
|
+
cli.option("-v, --version", "Show the version banner (tool version, node+abi, OS/arch/kernel \u2014 --json for plain fields)");
|
|
5236
5545
|
cli.option("-d, --dir <paths>", "Target dir(s), comma separated or repeated; globs and ~ allowed");
|
|
5237
5546
|
cli.option("--delete [mode]", "Delete found folders: hard | soft | trash (soft = trash; no value = pick interactively)");
|
|
5238
5547
|
cli.option("--dry-run", "Preview what would be deleted \u2014 nothing is touched");
|
|
@@ -5281,14 +5590,44 @@ cli.option("--reinstall", "stats only: naive npm-ci-style estimate of the time t
|
|
|
5281
5590
|
cli.option("--treemap [file]", "treemap export: single-file HTML (vanilla JS, no deps); default nmh-treemap.html in cwd");
|
|
5282
5591
|
cli.option("--out <file>", "treemap only: where to write the HTML file (same as --treemap <file>)");
|
|
5283
5592
|
cli.option("--update-check", "opt-in: peek npm for a newer release (non-blocking, 24 h cache; also settable in .nmhrc)");
|
|
5593
|
+
cli.option("--check", "update only: compare installed vs registry and exit \u2014 never installs");
|
|
5594
|
+
cli.option("--info", "update only: full registry detail (npm view style) \u2014 never installs");
|
|
5595
|
+
cli.option("-n, --no", "update only: decline the update on the command line (nothing installs)");
|
|
5596
|
+
cli.option("--wizard", "open the interactive wizard explicitly (same as the bare `nmh`); needs a TTY");
|
|
5284
5597
|
cli.option("--default-exclude <globs>", 'config family: manage the persisted exclude list (comma list; special value "self")');
|
|
5285
5598
|
cli.option("--default-keep <pkgs>", 'config family: manage the persisted keep list (comma list; special value "self")');
|
|
5286
5599
|
cli.option("--default-mode <mode>", "config family: set the persisted defaultMode (hard | soft | trash)");
|
|
5287
5600
|
cli.option("--log-dir <dir>", "config family: set the persisted recovery-log directory (logDir)");
|
|
5288
5601
|
var PM_FAMILIES = /* @__PURE__ */ new Set(["npm", "pnpm", "yarn", "bun", "vp"]);
|
|
5602
|
+
var KNOWN_SUBCOMMANDS = /* @__PURE__ */ new Set([
|
|
5603
|
+
"scan",
|
|
5604
|
+
"clean",
|
|
5605
|
+
"search",
|
|
5606
|
+
"map",
|
|
5607
|
+
"why",
|
|
5608
|
+
"treemap",
|
|
5609
|
+
"stats",
|
|
5610
|
+
"analyze",
|
|
5611
|
+
"info",
|
|
5612
|
+
"config",
|
|
5613
|
+
"update",
|
|
5614
|
+
"version",
|
|
5615
|
+
"help",
|
|
5616
|
+
"wizard"
|
|
5617
|
+
]);
|
|
5618
|
+
var WIZARD_VIEWS = /* @__PURE__ */ new Set(["stats", "analyze", "info", "map", "search", "why", "treemap"]);
|
|
5289
5619
|
function looksLikePath(text2) {
|
|
5290
5620
|
return text2.includes("/") || text2.includes("\\") || text2.startsWith(".") || text2.startsWith("~") || /^[A-Za-z]:/.test(text2);
|
|
5291
5621
|
}
|
|
5622
|
+
function resolveSubcommand(positionals, cwd) {
|
|
5623
|
+
if (positionals.length === 0) return { subcommand: null, unknown: null };
|
|
5624
|
+
const first = positionals[0];
|
|
5625
|
+
if (KNOWN_SUBCOMMANDS.has(first)) return { subcommand: first, unknown: null };
|
|
5626
|
+
if (existsSync(path25.resolve(cwd, first)) || looksLikePath(first)) {
|
|
5627
|
+
return { subcommand: null, unknown: null };
|
|
5628
|
+
}
|
|
5629
|
+
return { subcommand: null, unknown: first };
|
|
5630
|
+
}
|
|
5292
5631
|
function normalizeDelete(value) {
|
|
5293
5632
|
if (value === void 0 || value === false) return { given: false, mode: null };
|
|
5294
5633
|
if (value === true) return { given: true, mode: null };
|
|
@@ -5311,8 +5650,12 @@ function normalizeDepth(value) {
|
|
|
5311
5650
|
if (typeof value === "number" && Number.isInteger(value) && value >= 0) return value;
|
|
5312
5651
|
return "invalid";
|
|
5313
5652
|
}
|
|
5314
|
-
|
|
5315
|
-
|
|
5653
|
+
var updateNoticeShown = false;
|
|
5654
|
+
async function runCli(userArgs) {
|
|
5655
|
+
const dd = userArgs.indexOf("--");
|
|
5656
|
+
const routerTokens = dd >= 0 ? userArgs.slice(dd + 1) : null;
|
|
5657
|
+
const headArgs = dd >= 0 ? userArgs.slice(0, dd) : userArgs;
|
|
5658
|
+
const parsed = cli.parse(["node", "nmh", ...headArgs], { run: false });
|
|
5316
5659
|
const flags = parsed.options;
|
|
5317
5660
|
const positionals = (parsed.args ?? []).filter((token) => typeof token === "string");
|
|
5318
5661
|
if (flags.version === true) {
|
|
@@ -5320,7 +5663,7 @@ async function main() {
|
|
|
5320
5663
|
return 0;
|
|
5321
5664
|
}
|
|
5322
5665
|
if (flags.help === true || positionals[0] === "help") {
|
|
5323
|
-
|
|
5666
|
+
printHelp();
|
|
5324
5667
|
return 0;
|
|
5325
5668
|
}
|
|
5326
5669
|
const explicitConfig = typeof flags.config === "string" && flags.config.length > 0 ? flags.config : null;
|
|
@@ -5351,15 +5694,18 @@ async function main() {
|
|
|
5351
5694
|
const cliColorExplicit = process.argv.includes("--color") || process.argv.includes("--color=true");
|
|
5352
5695
|
const json = flags.json === true || Array.isArray(flags.json) && flags.json.includes(true);
|
|
5353
5696
|
const progress = normalizeAnimate(process.argv, config.animate);
|
|
5697
|
+
const isTTY = process.stdout.isTTY === true;
|
|
5698
|
+
const colorForced = cliColorExplicit || process.env.FORCE_COLOR !== void 0 && process.env.FORCE_COLOR !== "0" && process.env.FORCE_COLOR !== "false";
|
|
5354
5699
|
configureLogger({
|
|
5355
5700
|
verbose: flags.verbose === true,
|
|
5356
5701
|
quiet: flags.quiet === true,
|
|
5357
5702
|
noColor: flags.noColor === true || flags.color === false || // explicit CLI --no-color wins over config
|
|
5358
|
-
config.color === false && !cliColorExplicit,
|
|
5703
|
+
config.color === false && !cliColorExplicit || !isTTY && !colorForced,
|
|
5359
5704
|
// --json shares stdout with the live counter → counter must stay silent
|
|
5360
5705
|
progress: json ? "off" : progress
|
|
5361
5706
|
});
|
|
5362
|
-
if (flags.updateCheck === true || config.updateCheck === true) {
|
|
5707
|
+
if (!updateNoticeShown && (flags.updateCheck === true || config.updateCheck === true)) {
|
|
5708
|
+
updateNoticeShown = true;
|
|
5363
5709
|
const notice = await checkForUpdate().catch(() => null);
|
|
5364
5710
|
if (notice !== null) note(notice);
|
|
5365
5711
|
}
|
|
@@ -5510,7 +5856,12 @@ async function main() {
|
|
|
5510
5856
|
const cwd = process.cwd();
|
|
5511
5857
|
const dryRun = flags.dryRun === true;
|
|
5512
5858
|
const assumeYes = flags.yes === true || flags.force === true;
|
|
5513
|
-
const
|
|
5859
|
+
const resolvedSub = resolveSubcommand(positionals, cwd);
|
|
5860
|
+
if (resolvedSub.unknown !== null) {
|
|
5861
|
+
errorLog(`unknown command '${resolvedSub.unknown}' \u2014 run \`nmh help\` for the command list (paths are passed to \`nmh scan\` or \`--dir\`)`);
|
|
5862
|
+
return 1;
|
|
5863
|
+
}
|
|
5864
|
+
const subcommand = resolvedSub.subcommand;
|
|
5514
5865
|
const rest = subcommand ? positionals.slice(1) : positionals;
|
|
5515
5866
|
const dirTargets = splitTargets(flags.dir);
|
|
5516
5867
|
const targets = [...rest, ...dirTargets];
|
|
@@ -5555,12 +5906,71 @@ async function main() {
|
|
|
5555
5906
|
printVersion(json);
|
|
5556
5907
|
return 0;
|
|
5557
5908
|
}
|
|
5909
|
+
if (flags.check !== void 0 && flags.check !== false && subcommand !== "update") {
|
|
5910
|
+
errorLog("--check only works with the update command \u2014 nmh update --check");
|
|
5911
|
+
return 1;
|
|
5912
|
+
}
|
|
5913
|
+
if (flags.info !== void 0 && flags.info !== false && subcommand !== "update") {
|
|
5914
|
+
errorLog("--info only works with the update command \u2014 nmh update --info");
|
|
5915
|
+
return 1;
|
|
5916
|
+
}
|
|
5558
5917
|
if (subcommand === "update") {
|
|
5559
5918
|
if (deleted.given || rest.length > 0) {
|
|
5560
5919
|
errorLog("update takes no arguments \u2014 plain `nmh update` (add --json for the payload)");
|
|
5561
5920
|
return 1;
|
|
5562
5921
|
}
|
|
5563
|
-
|
|
5922
|
+
if (flags.check === true && flags.info === true) {
|
|
5923
|
+
errorLog("--check and --info are two different read-only views \u2014 pick one (nmh update --check | --info)");
|
|
5924
|
+
return 1;
|
|
5925
|
+
}
|
|
5926
|
+
return runUpdate({
|
|
5927
|
+
json,
|
|
5928
|
+
mode: flags.check === true ? "check" : flags.info === true ? "info" : "default",
|
|
5929
|
+
answer: flags.yes === true ? "yes" : flags.no === true ? "no" : "none",
|
|
5930
|
+
force: flags.force === true
|
|
5931
|
+
});
|
|
5932
|
+
}
|
|
5933
|
+
const wizardIntent = subcommand === "wizard" || flags.wizard === true;
|
|
5934
|
+
if (routerTokens !== null && !wizardIntent) {
|
|
5935
|
+
errorLog("the `--` router belongs to the wizard \u2014 nmh wizard -- stats|map|search|why|treemap \u2026");
|
|
5936
|
+
return 1;
|
|
5937
|
+
}
|
|
5938
|
+
let routerView = null;
|
|
5939
|
+
if (routerTokens !== null) {
|
|
5940
|
+
const view = routerTokens[0];
|
|
5941
|
+
if (typeof view !== "string" || !WIZARD_VIEWS.has(view)) {
|
|
5942
|
+
errorLog(`unknown wizard view: ${String(view ?? "(empty)")} \u2014 the router opens a read-only view: stats (analyze, info) | map | search | why | treemap`);
|
|
5943
|
+
return 1;
|
|
5944
|
+
}
|
|
5945
|
+
routerView = routerTokens;
|
|
5946
|
+
}
|
|
5947
|
+
if (wizardIntent) {
|
|
5948
|
+
if (rest.length > 0) {
|
|
5949
|
+
errorLog("wizard takes no positional targets \u2014 scope it with --dir=./repo1,./repo2 (or `nmh wizard -- <view> \u2026`)");
|
|
5950
|
+
return 1;
|
|
5951
|
+
}
|
|
5952
|
+
if (deleted.given) {
|
|
5953
|
+
errorLog("--delete does not belong to the wizard \u2014 the menu asks for the mode itself");
|
|
5954
|
+
return 1;
|
|
5955
|
+
}
|
|
5956
|
+
if (!isTTYOut()) {
|
|
5957
|
+
errorLog("the wizard needs a TTY (it is an arrow-key menu) \u2014 use nmh scan / --dir / the plain commands instead; bare `nmh` in a pipe stays a read-only scan");
|
|
5958
|
+
return 1;
|
|
5959
|
+
}
|
|
5960
|
+
if (routerView !== null) {
|
|
5961
|
+
log(renderFullBanner());
|
|
5962
|
+
const viewCode = await runCli(routerView);
|
|
5963
|
+
if (viewCode === 1) return 1;
|
|
5964
|
+
}
|
|
5965
|
+
return runWizard(cwd, {
|
|
5966
|
+
defaultMode: config.defaultMode,
|
|
5967
|
+
json,
|
|
5968
|
+
settings: { source: configSource, values: config },
|
|
5969
|
+
excludes: excludes.length > 0 ? excludes : void 0,
|
|
5970
|
+
keep: keep.length > 0 ? keep : void 0,
|
|
5971
|
+
targets: dirTargets.length > 0 ? dirTargets : void 0,
|
|
5972
|
+
banner: routerView === null
|
|
5973
|
+
});
|
|
5564
5974
|
}
|
|
5565
5975
|
if (subcommand === "config") {
|
|
5566
5976
|
const ops = /* @__PURE__ */ new Set(["info", "set", "add", "remove", "unset"]);
|
|
@@ -5883,6 +6293,9 @@ function normalizeAnimate(argv, configAnimate) {
|
|
|
5883
6293
|
if (argv.includes("--animate") || argv.includes("--animate=true")) return "on";
|
|
5884
6294
|
return configAnimate === false ? "off" : "auto";
|
|
5885
6295
|
}
|
|
6296
|
+
function main() {
|
|
6297
|
+
return runCli(process.argv.slice(2));
|
|
6298
|
+
}
|
|
5886
6299
|
main().then((code) => {
|
|
5887
6300
|
if (code !== 0) process.exitCode = code;
|
|
5888
6301
|
}).catch((err) => {
|
package/package.json
CHANGED