node-modules-hunter 1.0.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.
Files changed (3) hide show
  1. package/README.md +24 -4
  2. package/dist/cli.js +954 -227
  3. package/package.json +5 -1
package/dist/cli.js CHANGED
@@ -2,7 +2,8 @@
2
2
 
3
3
  // src/cli.ts
4
4
  import { cac } from "cac";
5
- import path24 from "path";
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
- function progressLine(message) {
86
- if (state.quiet || !progressAllowed()) return;
87
- process.stdout.write(CLEAR_LINE + colors.dim(message));
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 clearProgress() {
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
 
@@ -354,8 +552,13 @@ async function writeDeleteLog(payload2) {
354
552
  entries: payload2.results.map((row) => ({
355
553
  path: row.path,
356
554
  status: row.status,
357
- error: row.error ?? null
358
- }))
555
+ error: row.error ?? null,
556
+ // v1.1 additive: which trash took it (system | nmh) — trash rows only
557
+ ...row.backend !== void 0 ? { backend: row.backend } : {}
558
+ })),
559
+ // v1.1 additive: nmh-trash batch dir / fallback marker when applicable
560
+ ...payload2.trashBatch !== void 0 ? { trashBatch: payload2.trashBatch } : {},
561
+ ...payload2.trashFallback === true ? { trashFallback: true } : {}
359
562
  };
360
563
  await fs3.writeFile(file, JSON.stringify(body, null, 2));
361
564
  return file;
@@ -558,6 +761,11 @@ function formatBytes(bytes) {
558
761
  }
559
762
  return `${value.toFixed(1)} ${units[unitIndex]}`;
560
763
  }
764
+ function formatIdle(days) {
765
+ if (days === void 0) return "\u2014";
766
+ if (days === 0) return "now";
767
+ return `${days.toLocaleString("en-US")}d`;
768
+ }
561
769
  function formatAge(mtimeMs, now = Date.now()) {
562
770
  if (mtimeMs == null) return "\u2014";
563
771
  const diff = Math.max(0, now - mtimeMs);
@@ -692,9 +900,14 @@ function makeSearchMatcher(pattern) {
692
900
  // src/core/walker.ts
693
901
  import fs6 from "fs/promises";
694
902
  import path6 from "path";
903
+ var SYSTEM_SKIP_NAMES = /* @__PURE__ */ new Set([".Trash", ".Trashes", "Library"]);
904
+ function isSystemNoise(name) {
905
+ return SYSTEM_SKIP_NAMES.has(name) || name.startsWith(".Trash-") || name.endsWith(".app");
906
+ }
695
907
  async function scanForNodeModules(roots, options = {}) {
696
908
  const maxDepth = options.maxDepth ?? Number.POSITIVE_INFINITY;
697
909
  const recursive = options.recursive === true;
910
+ const skipSystem = options.skipSystem !== false;
698
911
  const isExcluded = options.excludes && options.excludes.length > 0 ? makeGlobMatcher(options.excludes) : null;
699
912
  const entries = [];
700
913
  const permissionIssues = [];
@@ -730,6 +943,7 @@ async function scanForNodeModules(roots, options = {}) {
730
943
  }
731
944
  for (const dirent of dirents) {
732
945
  if (dirent.name === ".git") continue;
946
+ if (skipSystem && isSystemNoise(dirent.name)) continue;
733
947
  const child = path6.join(job.dir, dirent.name);
734
948
  const st = await fs6.lstat(child).catch(() => null);
735
949
  if (st === null) continue;
@@ -1000,11 +1214,11 @@ async function measureEntry(entryPath, permIssues = []) {
1000
1214
  );
1001
1215
  }
1002
1216
  const packageCount = await countTopLevelPackages(entryPath);
1003
- const stat = await fs8.lstat(entryPath).then(
1217
+ const stat2 = await fs8.lstat(entryPath).then(
1004
1218
  (value) => value,
1005
1219
  () => null
1006
1220
  );
1007
- return { size, packageCount, mtimeMs: stat?.mtimeMs ?? null };
1221
+ return { size, packageCount, mtimeMs: stat2?.mtimeMs ?? null };
1008
1222
  }
1009
1223
  async function countTopLevelPackages(entryPath) {
1010
1224
  return (await listTopLevelPackages(entryPath)).length;
@@ -1031,8 +1245,105 @@ async function measureEntries(entryPaths, permIssues = [], options = {}) {
1031
1245
  return results;
1032
1246
  }
1033
1247
 
1034
- // src/core/pm-detect.ts
1248
+ // src/core/du-size.ts
1249
+ import { promisify } from "util";
1250
+ import { execFile } from "child_process";
1035
1251
  import fs9 from "fs/promises";
1252
+ var execFileP = promisify(execFile);
1253
+ async function measureEntryFast(entryPath) {
1254
+ try {
1255
+ const { stdout } = await execFileP("du", ["-sk", "--", entryPath], {
1256
+ maxBuffer: 8 * 1024,
1257
+ timeout: 3e4
1258
+ });
1259
+ const line = stdout.split("\n").find((value) => value.includes(" ")) ?? "";
1260
+ const tabIndex = line.lastIndexOf(" ");
1261
+ const kb = Number.parseInt(line.slice(0, tabIndex).trim(), 10);
1262
+ const echoed = line.slice(tabIndex + 1);
1263
+ if (!Number.isInteger(kb) || kb < 0 || echoed !== entryPath) return null;
1264
+ const [packageCount, stat2] = await Promise.all([
1265
+ countTopLevelPackages(entryPath),
1266
+ fs9.lstat(entryPath).catch(() => null)
1267
+ ]);
1268
+ return { size: kb * 1024, packageCount, mtimeMs: stat2?.mtimeMs ?? null };
1269
+ } catch {
1270
+ return null;
1271
+ }
1272
+ }
1273
+
1274
+ // src/core/activity.ts
1275
+ import { promisify as promisify2 } from "util";
1276
+ import { execFile as execFile2 } from "child_process";
1277
+ import { stat } from "fs/promises";
1278
+ import { dirname, join } from "path";
1279
+ var execFileP2 = promisify2(execFile2);
1280
+ var DAY_MS = 864e5;
1281
+ var GIT_TIMEOUT_MS = 4e3;
1282
+ async function hasGitDir(dir) {
1283
+ try {
1284
+ await stat(join(dir, ".git"));
1285
+ return true;
1286
+ } catch {
1287
+ return false;
1288
+ }
1289
+ }
1290
+ async function mtimeOf(dir, file) {
1291
+ try {
1292
+ return (await stat(join(dir, file))).mtimeMs;
1293
+ } catch {
1294
+ return null;
1295
+ }
1296
+ }
1297
+ async function findUp(start, test) {
1298
+ let dir = start;
1299
+ for (; ; ) {
1300
+ if (await test(dir)) return dir;
1301
+ const parent = dirname(dir);
1302
+ if (parent === dir) return null;
1303
+ dir = parent;
1304
+ }
1305
+ }
1306
+ async function lastCommitSeconds(gitDir) {
1307
+ try {
1308
+ const { stdout } = await execFileP2(
1309
+ "git",
1310
+ ["-C", gitDir, "log", "-1", "--format=%ct"],
1311
+ { maxBuffer: 64 * 1024, timeout: GIT_TIMEOUT_MS }
1312
+ );
1313
+ const seconds = Number.parseInt(stdout.trim(), 10);
1314
+ return Number.isNaN(seconds) ? null : seconds;
1315
+ } catch {
1316
+ return null;
1317
+ }
1318
+ }
1319
+ async function computeActivity(entryPath, now = Date.now()) {
1320
+ const startDir = dirname(entryPath);
1321
+ const gitDir = await findUp(startDir, hasGitDir);
1322
+ if (gitDir !== null) {
1323
+ const seconds = await lastCommitSeconds(gitDir);
1324
+ if (seconds !== null) {
1325
+ return { idleDays: idleFrom(seconds * 1e3, now), source: "git" };
1326
+ }
1327
+ }
1328
+ const manifestMtime = await findUp(startDir, async (dir) => await mtimeOf(dir, "package.json") !== null);
1329
+ if (manifestMtime !== null) {
1330
+ const mtime = await mtimeOf(manifestMtime, "package.json");
1331
+ if (mtime !== null) {
1332
+ return { idleDays: idleFrom(mtime, now), source: "manifest" };
1333
+ }
1334
+ }
1335
+ const folderMtime = await mtimeOf(startDir, "node_modules");
1336
+ if (folderMtime !== null) {
1337
+ return { idleDays: idleFrom(folderMtime, now), source: "mtime" };
1338
+ }
1339
+ return null;
1340
+ }
1341
+ function idleFrom(signalMs, now) {
1342
+ return Math.max(0, Math.floor((now - signalMs) / DAY_MS));
1343
+ }
1344
+
1345
+ // src/core/pm-detect.ts
1346
+ import fs10 from "fs/promises";
1036
1347
  import path9 from "path";
1037
1348
  var LOCKFILE_PRIORITY = [
1038
1349
  { family: "pnpm", files: ["pnpm-workspace.yaml", "pnpm-lock.yaml"] },
@@ -1113,7 +1424,7 @@ async function detectLockfilesAt(dir) {
1113
1424
  }
1114
1425
  async function fileExists(p) {
1115
1426
  try {
1116
- const st = await fs9.stat(p);
1427
+ const st = await fs10.stat(p);
1117
1428
  return st.isFile();
1118
1429
  } catch {
1119
1430
  return false;
@@ -1150,9 +1461,9 @@ function knownFamily(name) {
1150
1461
  // src/core/guards.ts
1151
1462
  import os2 from "os";
1152
1463
  import path10 from "path";
1153
- import fs10 from "fs/promises";
1464
+ import fs11 from "fs/promises";
1154
1465
  async function checkDeleteTarget(candidate) {
1155
- const st = await fs10.lstat(candidate).then(
1466
+ const st = await fs11.lstat(candidate).then(
1156
1467
  (value) => value,
1157
1468
  () => null
1158
1469
  );
@@ -1229,13 +1540,23 @@ function renderScanResult(result, options) {
1229
1540
  if (options.sizeMode === "total" || isSilent()) return renderScanTotals(result);
1230
1541
  return renderScanTable(result, options);
1231
1542
  }
1543
+ var BAR_CELLS = 12;
1544
+ function renderBar(size, max) {
1545
+ if (max <= 0 || size <= 0) return colors.dim("\xB7");
1546
+ const cells = Math.max(1, Math.round(size / max * BAR_CELLS));
1547
+ return colors.cyan("\u2588".repeat(Math.min(cells, BAR_CELLS)));
1548
+ }
1232
1549
  function renderScanTable(result, options) {
1550
+ const showIdle = result.entries.some((entry) => entry.idleDays !== void 0);
1551
+ const maxSize = result.entries.reduce((max, entry) => entry.nested === true ? max : Math.max(max, entry.size), 0);
1233
1552
  const table = new Table({
1234
1553
  head: [
1235
1554
  colors.dim("Path"),
1236
1555
  colors.dim("Size"),
1556
+ colors.dim("Share"),
1237
1557
  colors.dim("Pkgs"),
1238
1558
  colors.dim("PM"),
1559
+ ...showIdle ? [colors.dim("Idle")] : [],
1239
1560
  colors.dim("Modified")
1240
1561
  ],
1241
1562
  style: { head: [], border: colorEnabled() ? ["gray"] : [] }
@@ -1246,8 +1567,10 @@ function renderScanTable(result, options) {
1246
1567
  table.push([
1247
1568
  label,
1248
1569
  formatBytes(entry.size),
1570
+ nested ? colors.dim("\xB7") : renderBar(entry.size, maxSize),
1249
1571
  entry.packageCount > 0 ? String(entry.packageCount) : "0",
1250
1572
  pmLabel(entry.pm),
1573
+ ...showIdle ? [formatIdle(entry.idleDays)] : [],
1251
1574
  formatAge(entry.mtimeMs)
1252
1575
  ]);
1253
1576
  };
@@ -1291,6 +1614,8 @@ function renderScanJson(result) {
1291
1614
  pmProjectRoot: entry.pm.projectRoot,
1292
1615
  nested: entry.nested === true,
1293
1616
  ownerPath: entry.ownerPath ?? entry.path,
1617
+ // v1.1 additive — keys appear only when --stale/--sort idle ran (frozen-contract friendly)
1618
+ ...entry.idleDays !== void 0 ? { idleDays: entry.idleDays, activitySource: entry.activitySource } : {},
1294
1619
  modifiedAt: entry.mtimeMs == null ? null : new Date(entry.mtimeMs).toISOString()
1295
1620
  }))
1296
1621
  };
@@ -1348,8 +1673,13 @@ function renderDeleteJson(outcome, mode, dryRun) {
1348
1673
  status: row.status,
1349
1674
  size: row.size,
1350
1675
  sizeFormatted: formatBytes(row.size),
1351
- error: row.error ?? null
1352
- }))
1676
+ error: row.error ?? null,
1677
+ // v1.1 additive — trash backend per row, only when trash mode ran
1678
+ ...row.backend !== void 0 ? { backend: row.backend } : {}
1679
+ })),
1680
+ // v1.1 additive — nmh-internal trash batch dir / fallback flag, only when used
1681
+ ...outcome.trashBatch !== void 0 ? { trashBatch: outcome.trashBatch } : {},
1682
+ ...outcome.trashFallback === true ? { trashFallback: true } : {}
1353
1683
  };
1354
1684
  return JSON.stringify(payload2, null, 2);
1355
1685
  }
@@ -1989,7 +2319,8 @@ async function collectScan(request) {
1989
2319
  const walk = await scanForNodeModules(walkRoots, {
1990
2320
  maxDepth: request.maxDepth,
1991
2321
  recursive: request.recursive,
1992
- excludes: walkExcludes
2322
+ excludes: walkExcludes,
2323
+ skipSystem: request.skipSystem
1993
2324
  });
1994
2325
  const permIssues = [...walk.permissionIssues];
1995
2326
  collected.push(...walk.entries);
@@ -1999,6 +2330,7 @@ async function collectScan(request) {
1999
2330
  maxDepth: request.maxDepth,
2000
2331
  recursive: true,
2001
2332
  excludes: walkExcludes,
2333
+ skipSystem: request.skipSystem,
2002
2334
  initialOwner: direct
2003
2335
  });
2004
2336
  permIssues.push(...inner.permissionIssues);
@@ -2008,28 +2340,39 @@ async function collectScan(request) {
2008
2340
  const metaByPath = new Map(collected.map((entry) => [entry.path, entry]));
2009
2341
  const entryPaths = [...metaByPath.keys()];
2010
2342
  verboseLog(`found ${entryPaths.length} node_modules \u2014 measuring`);
2011
- const entryCount = entryPaths.length;
2012
- let bytesSoFar = 0;
2013
- let measuredCount = 0;
2014
- const measures = await measureEntries(entryPaths, permIssues, {
2015
- concurrency: 4,
2016
- onProgress: (progress) => {
2017
- measuredCount = progress.measuredEntries;
2018
- bytesSoFar = progress.bytesSoFar;
2019
- progressLine(
2020
- `\u280B measuring ${measuredCount}/${entryCount} folders \xB7 ${formatBytes(bytesSoFar)}`
2021
- );
2022
- }
2023
- });
2024
- clearProgress();
2343
+ const measures = await collectMeasures(entryPaths, request.fastSize === true, permIssues);
2344
+ spinClear();
2025
2345
  const detect = createPmDetector();
2026
2346
  const pmLimiter = createLimiter(8);
2027
2347
  const pmResults = await Promise.all(entryPaths.map((entryPath) => pmLimiter(() => detect(entryPath))));
2028
2348
  verboseLog(`scan finished in ${Date.now() - started} ms`);
2349
+ const activityByPath = /* @__PURE__ */ new Map();
2350
+ if (request.stale !== void 0 || request.sort === "idle") {
2351
+ const activityLimiter = createLimiter(8);
2352
+ const cache = /* @__PURE__ */ new Map();
2353
+ const activityFor = (entryPath) => {
2354
+ const startDir = path12.dirname(entryPath);
2355
+ let pending = cache.get(startDir);
2356
+ if (pending === void 0) {
2357
+ pending = computeActivity(entryPath);
2358
+ cache.set(startDir, pending);
2359
+ }
2360
+ return pending;
2361
+ };
2362
+ await Promise.all(
2363
+ entryPaths.map(
2364
+ (entryPath) => activityLimiter(async () => {
2365
+ const activity = await activityFor(entryPath);
2366
+ if (activity !== null) activityByPath.set(entryPath, activity);
2367
+ })
2368
+ )
2369
+ );
2370
+ }
2029
2371
  const cwd = request.cwd;
2030
2372
  let entries = entryPaths.map((entryPath, index) => {
2031
2373
  const measure = measures.get(entryPath) ?? { size: 0, packageCount: 0, mtimeMs: null };
2032
2374
  const meta = metaByPath.get(entryPath);
2375
+ const activity = activityByPath.get(entryPath);
2033
2376
  return {
2034
2377
  path: entryPath,
2035
2378
  displayPath: shortenPath(entryPath, cwd),
@@ -2037,11 +2380,19 @@ async function collectScan(request) {
2037
2380
  packageCount: measure.packageCount,
2038
2381
  pm: pmResults[index],
2039
2382
  mtimeMs: measure.mtimeMs,
2383
+ ...activity !== void 0 ? { idleDays: activity.idleDays, activitySource: activity.source } : {},
2040
2384
  nested: meta?.nested,
2041
2385
  ownerPath: meta?.ownerPath
2042
2386
  };
2043
2387
  });
2044
- entries.sort((a, b) => b.size - a.size);
2388
+ const cmpStr = (x, y) => x < y ? -1 : x > y ? 1 : 0;
2389
+ const comparators = {
2390
+ size: (a, b) => b.size - a.size || cmpStr(a.path, b.path),
2391
+ path: (a, b) => cmpStr(a.path, b.path) || b.size - a.size,
2392
+ idle: (a, b) => (b.idleDays ?? -1) - (a.idleDays ?? -1) || b.size - a.size,
2393
+ mtime: (a, b) => (b.mtimeMs ?? Number.NEGATIVE_INFINITY) - (a.mtimeMs ?? Number.NEGATIVE_INFINITY) || b.size - a.size
2394
+ };
2395
+ entries.sort(comparators[request.sort ?? "size"]);
2045
2396
  entries = await applyFilters(entries, request);
2046
2397
  const topEntries = entries.filter((entry) => !entry.nested);
2047
2398
  const totals = {
@@ -2065,6 +2416,59 @@ async function collectScan(request) {
2065
2416
  else if (permIssues.length > 0) code = 4;
2066
2417
  return { code, result };
2067
2418
  }
2419
+ async function collectMeasures(entryPaths, fastSize, permIssues) {
2420
+ const entryCount = entryPaths.length;
2421
+ let measuredCount = 0;
2422
+ let bytesSoFar = 0;
2423
+ const bump = () => {
2424
+ spinUpdate(`measuring ${measuredCount}/${entryCount} folders \xB7 ${formatBytes(bytesSoFar)}`);
2425
+ };
2426
+ if (!fastSize) {
2427
+ return measureEntries(entryPaths, permIssues, {
2428
+ concurrency: 4,
2429
+ onProgress: (progress) => {
2430
+ measuredCount = progress.measuredEntries;
2431
+ bytesSoFar = progress.bytesSoFar;
2432
+ bump();
2433
+ }
2434
+ });
2435
+ }
2436
+ const measures = /* @__PURE__ */ new Map();
2437
+ const duLimiter = createLimiter(8);
2438
+ const fallbackPaths = [];
2439
+ await Promise.all(
2440
+ entryPaths.map(
2441
+ (entryPath) => duLimiter(async () => {
2442
+ const fast = await measureEntryFast(entryPath);
2443
+ if (fast === null) {
2444
+ fallbackPaths.push(entryPath);
2445
+ return;
2446
+ }
2447
+ measures.set(entryPath, fast);
2448
+ measuredCount += 1;
2449
+ bytesSoFar += fast.size;
2450
+ bump();
2451
+ })
2452
+ )
2453
+ );
2454
+ verboseLog(
2455
+ `fast-size: ${measures.size} entr${measures.size === 1 ? "y" : "ies"} via du, ${fallbackPaths.length} via walker fallback`
2456
+ );
2457
+ if (fallbackPaths.length > 0) {
2458
+ const duBytes = bytesSoFar;
2459
+ const duCount = measures.size;
2460
+ const walked = await measureEntries(fallbackPaths, permIssues, {
2461
+ concurrency: 4,
2462
+ onProgress: (progress) => {
2463
+ spinUpdate(
2464
+ `measuring ${duCount + progress.measuredEntries}/${entryCount} folders \xB7 ${formatBytes(duBytes + progress.bytesSoFar)}`
2465
+ );
2466
+ }
2467
+ });
2468
+ for (const [entryPath, measure] of walked) measures.set(entryPath, measure);
2469
+ }
2470
+ return measures;
2471
+ }
2068
2472
  async function applyFilters(entries, request) {
2069
2473
  const wantsPm = request.pmFilter !== void 0 && request.pmFilter.length > 0;
2070
2474
  const wantedFamilies = new Set(request.pmFilter ?? []);
@@ -2073,12 +2477,15 @@ async function applyFilters(entries, request) {
2073
2477
  const excludeMatcher = request.excludes !== void 0 && request.excludes.length > 0 ? makeGlobMatcher(request.excludes) : null;
2074
2478
  const selfGuard = (request.excludes?.includes(SELF_TOKEN) ?? false) || (request.keep?.includes(SELF_TOKEN) ?? false);
2075
2479
  const guardRoot = selfGuard ? path12.resolve(request.cwd) : null;
2076
- if (!wantsPm && !keepMatcher && !excludeMatcher && !selfGuard) return entries;
2480
+ const wantsStale = request.stale !== void 0;
2481
+ const isStale = (entry) => wantsStale && entry.idleDays !== void 0 && entry.idleDays >= (request.stale ?? 0);
2482
+ if (!wantsPm && !keepMatcher && !excludeMatcher && !selfGuard && !wantsStale) return entries;
2077
2483
  const survivingTops = /* @__PURE__ */ new Set();
2078
2484
  for (const entry of entries) {
2079
2485
  if (entry.nested) continue;
2080
2486
  if (guardRoot !== null && insideRoot(entry.path, guardRoot)) continue;
2081
2487
  if (wantsPm && !matchPm(entry)) continue;
2488
+ if (wantsStale && !isStale(entry)) continue;
2082
2489
  if (excludeMatcher && excludeMatcher(entry.path)) continue;
2083
2490
  if (keepMatcher) {
2084
2491
  const name = await projectNameFor(entry.path);
@@ -2137,10 +2544,203 @@ function emptyResult(targets, unresolvable = []) {
2137
2544
  };
2138
2545
  }
2139
2546
 
2140
- // src/commands/config.ts
2141
- import fs11 from "fs/promises";
2547
+ // src/commands/update.ts
2548
+ import { promisify as promisify3 } from "util";
2549
+ import { execFile as execFile3 } from "child_process";
2550
+ import fs13 from "fs/promises";
2551
+
2552
+ // src/utils/update-check.ts
2553
+ import fs12 from "fs/promises";
2142
2554
  import path13 from "path";
2143
2555
  import { homedir as homedir6 } from "os";
2556
+ var UPDATE_CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
2557
+ function cacheFile() {
2558
+ return path13.join(homedir6(), ".nmh", "cache", "update-check.json");
2559
+ }
2560
+ function registryUrl() {
2561
+ return process.env.NMH_REGISTRY_JSON ?? "https://registry.npmjs.org/node-modules-hunter/latest";
2562
+ }
2563
+ function isNewer(current, latest) {
2564
+ const triple = (v) => {
2565
+ const match = /^(\d+)\.(\d+)\.(\d+)(.*)$/.exec(v.trim());
2566
+ return match === null ? null : [Number(match[1]), Number(match[2]), Number(match[3]), match[4] ?? ""];
2567
+ };
2568
+ const now = triple(current);
2569
+ const next = triple(latest);
2570
+ if (now === null || next === null) return false;
2571
+ for (let i = 0; i < 3; i += 1) {
2572
+ if (next[i] > now[i]) return true;
2573
+ if (next[i] < now[i]) return false;
2574
+ }
2575
+ return false;
2576
+ }
2577
+ function updateNote(latest, current) {
2578
+ return `update tersedia: ${current} \u2192 ${latest} \u2014 npm i -g node-modules-hunter (cek 24 jam sekali, opt-in via --update-check)`;
2579
+ }
2580
+ async function readFreshCache() {
2581
+ try {
2582
+ const parsed = JSON.parse(await fs12.readFile(cacheFile(), "utf8"));
2583
+ const age = Date.now() - new Date(parsed.checkedAt).getTime();
2584
+ if (Number.isFinite(age) && age >= 0 && age < UPDATE_CACHE_TTL_MS && typeof parsed.latest === "string") {
2585
+ return parsed.latest;
2586
+ }
2587
+ } catch {
2588
+ }
2589
+ return null;
2590
+ }
2591
+ async function writeCache(latest) {
2592
+ try {
2593
+ const entry = { checkedAt: (/* @__PURE__ */ new Date()).toISOString(), latest };
2594
+ await fs12.mkdir(path13.dirname(cacheFile()), { recursive: true });
2595
+ await fs12.writeFile(cacheFile(), `${JSON.stringify(entry, null, 2)}
2596
+ `, "utf8");
2597
+ } catch (err) {
2598
+ verboseLog(`update-cache write failed: ${err instanceof Error ? err.message : String(err)}`);
2599
+ }
2600
+ }
2601
+ function parseLatestVersion(payload2) {
2602
+ if (typeof payload2 !== "object" || payload2 === null) return null;
2603
+ const version = payload2.version;
2604
+ if (typeof version !== "string" || version.length === 0) return null;
2605
+ return tripleOf(version) ? version : null;
2606
+ }
2607
+ function tripleOf(version) {
2608
+ return /^\d+\.\d+\.\d+/.test(version.trim());
2609
+ }
2610
+ async function checkForUpdate() {
2611
+ try {
2612
+ const fresh = await readFreshCache();
2613
+ const latest = fresh ?? await fetchLatest();
2614
+ if (latest === null) return null;
2615
+ return isNewer(toolVersion(), latest) ? updateNote(latest, toolVersion()) : null;
2616
+ } catch (err) {
2617
+ verboseLog(`update check failed: ${err instanceof Error ? err.message : String(err)}`);
2618
+ return null;
2619
+ }
2620
+ }
2621
+ async function fetchLatest() {
2622
+ try {
2623
+ const response = await fetch(registryUrl(), { signal: AbortSignal.timeout(4e3) });
2624
+ if (!response.ok) {
2625
+ verboseLog(`update check: registry responded ${response.status}`);
2626
+ return null;
2627
+ }
2628
+ const latest = parseLatestVersion(await response.json());
2629
+ if (latest !== null) await writeCache(latest);
2630
+ return latest;
2631
+ } catch (err) {
2632
+ verboseLog(`update check: registry unreachable (${err instanceof Error ? err.message : String(err)})`);
2633
+ return null;
2634
+ }
2635
+ }
2636
+
2637
+ // src/commands/update.ts
2638
+ var execFileP3 = promisify3(execFile3);
2639
+ async function runNpm(args, timeoutMs) {
2640
+ const win32 = process.platform === "win32";
2641
+ try {
2642
+ const { stdout } = await execFileP3(win32 ? "npm.cmd" : "npm", args, {
2643
+ timeout: timeoutMs,
2644
+ maxBuffer: 1024 * 1024,
2645
+ shell: win32,
2646
+ windowsHide: true
2647
+ });
2648
+ return { code: 0, stdout, stderr: "" };
2649
+ } catch (err) {
2650
+ const failure = err;
2651
+ return { code: failure.code ?? 1, stdout: failure.stdout ?? "", stderr: failure.stderr ?? "" };
2652
+ }
2653
+ }
2654
+ var VERSION_SHAPE = /^\d+\.\d+\.\d+/;
2655
+ async function latestVersion() {
2656
+ const override = process.env.NMH_UPDATE_LATEST_JSON;
2657
+ if (typeof override === "string" && override.length > 0) {
2658
+ try {
2659
+ const parsed = JSON.parse(await fs13.readFile(override, "utf8"));
2660
+ return typeof parsed.version === "string" && VERSION_SHAPE.test(parsed.version) ? parsed.version : null;
2661
+ } catch (err) {
2662
+ verboseLog(`update: override file unreadable (${err instanceof Error ? err.message : String(err)})`);
2663
+ return null;
2664
+ }
2665
+ }
2666
+ const view = await runNpm(["view", "node-modules-hunter", "version"], 6e4);
2667
+ if (view.code !== 0) {
2668
+ verboseLog(`npm view failed (rc ${view.code}): ${view.stderr.trim().slice(0, 200)}`);
2669
+ return null;
2670
+ }
2671
+ const version = view.stdout.trim();
2672
+ return VERSION_SHAPE.test(version) ? version : null;
2673
+ }
2674
+ async function isNpmGlobal() {
2675
+ const forced = process.env.NMH_UPDATE_GLOBAL;
2676
+ if (forced === "1") return true;
2677
+ if (forced === "0") return false;
2678
+ const ls = await runNpm(["ls", "-g", "node-modules-hunter", "--depth=0"], 6e4);
2679
+ return ls.code === 0 && ls.stdout.includes("node-modules-hunter");
2680
+ }
2681
+ async function installLatest(version) {
2682
+ const installCommand = `npm install -g node-modules-hunter@${version}`;
2683
+ const fake = process.env.NMH_UPDATE_FAKE_INSTALL;
2684
+ if (typeof fake === "string" && fake.length > 0) {
2685
+ try {
2686
+ await fs13.writeFile(fake, `${installCommand}
2687
+ `, "utf8");
2688
+ return { ok: true, detail: installCommand };
2689
+ } catch (err) {
2690
+ return { ok: false, detail: err instanceof Error ? err.message : String(err) };
2691
+ }
2692
+ }
2693
+ spinUpdate("updating via npm\u2026");
2694
+ const install = await runNpm(["install", "-g", "node-modules-hunter@" + version], 3e5);
2695
+ spinClear();
2696
+ if (install.code !== 0) {
2697
+ return { ok: false, detail: install.stderr.trim().slice(0, 300) || `npm exited ${install.code}` };
2698
+ }
2699
+ return { ok: true, detail: installCommand };
2700
+ }
2701
+ async function runUpdate(request) {
2702
+ const current = toolVersion();
2703
+ const latest = await latestVersion();
2704
+ if (latest === null) {
2705
+ const message = "cannot read the latest version \u2014 is the registry reachable? (npm view node-modules-hunter version)";
2706
+ if (request.json) errorLog(message);
2707
+ else errorLog(`\u26A0 ${message}`);
2708
+ return 1;
2709
+ }
2710
+ const global = await isNpmGlobal();
2711
+ const method = global ? "npm-global" : "manual";
2712
+ if (!isNewer(current, latest)) {
2713
+ const result2 = { current, latest, updated: false, method };
2714
+ if (request.json) payload(JSON.stringify(result2, null, 2));
2715
+ else log(`nmh ${current} sudah versi terbaru (registry: ${latest}) \u2014 tak ada yang diinstall`);
2716
+ return 0;
2717
+ }
2718
+ if (!global) {
2719
+ const result2 = { current, latest, updated: false, method };
2720
+ const advice = [
2721
+ `nmh ${current} terpasang di luar npm global \u2014 update manual:`,
2722
+ " npm i -g node-modules-hunter (atau: pnpm add -g / bunx / brew upgrade node-modules-hunter)"
2723
+ ];
2724
+ if (request.json) payload(JSON.stringify(result2, null, 2));
2725
+ else for (const line of advice) note(line);
2726
+ return 0;
2727
+ }
2728
+ const install = await installLatest(latest);
2729
+ if (!install.ok) {
2730
+ errorLog(`\u26A0 update gagal: ${install.detail}`);
2731
+ errorLog(` coba manual: npm install -g node-modules-hunter@${latest}`);
2732
+ return 1;
2733
+ }
2734
+ const result = { current, latest, updated: true, method: "npm-global" };
2735
+ if (request.json) payload(JSON.stringify(result, null, 2));
2736
+ else log(`\u2713 terupdate ${current} \u2192 ${latest} (npm install -g node-modules-hunter)`);
2737
+ return 0;
2738
+ }
2739
+
2740
+ // src/commands/config.ts
2741
+ import fs14 from "fs/promises";
2742
+ import path14 from "path";
2743
+ import { homedir as homedir7 } from "os";
2144
2744
  var VALID_MODES2 = /* @__PURE__ */ new Set(["hard", "soft", "trash"]);
2145
2745
  var KNOWN_KEYS = ["defaultMode", "depth", "exclude", "keep", "color", "animate", "updateCheck", "logDir"];
2146
2746
  var KEY_TO_FLAG = {
@@ -2218,9 +2818,9 @@ function parseBool(raw2, label) {
2218
2818
  function configWriteTarget(explicitFile, cwd) {
2219
2819
  if (explicitFile !== void 0 && explicitFile.length > 0) {
2220
2820
  const raw2 = expandTilde(explicitFile);
2221
- return path13.isAbsolute(raw2) ? raw2 : path13.resolve(cwd, raw2);
2821
+ return path14.isAbsolute(raw2) ? raw2 : path14.resolve(cwd, raw2);
2222
2822
  }
2223
- return path13.join(homedir6(), DEFAULT_TARGET);
2823
+ return path14.join(homedir7(), DEFAULT_TARGET);
2224
2824
  }
2225
2825
  function listMutation(op, flag, given) {
2226
2826
  const key = LIST_KEYS[flag];
@@ -2287,7 +2887,7 @@ function assemble(request) {
2287
2887
  async function readRawConfig(file) {
2288
2888
  let body;
2289
2889
  try {
2290
- body = await fs11.readFile(file, "utf8");
2890
+ body = await fs14.readFile(file, "utf8");
2291
2891
  } catch (err) {
2292
2892
  if (err.code === "ENOENT") return {};
2293
2893
  throw new ConfigError(`config unreadable: ${file} (${err.code ?? String(err)})`);
@@ -2334,8 +2934,8 @@ async function writeConfigMutation(file, mutation) {
2334
2934
  }
2335
2935
  const body = `${JSON.stringify(raw2, null, 2)}
2336
2936
  `;
2337
- await fs11.mkdir(path13.dirname(file), { recursive: true });
2338
- await fs11.writeFile(file, body, "utf8");
2937
+ await fs14.mkdir(path14.dirname(file), { recursive: true });
2938
+ await fs14.writeFile(file, body, "utf8");
2339
2939
  return applied;
2340
2940
  }
2341
2941
  var INFO_DEFAULTS = {
@@ -2426,7 +3026,7 @@ async function runConfigCommand(request) {
2426
3026
  }
2427
3027
 
2428
3028
  // src/commands/search.ts
2429
- import path14 from "path";
3029
+ import path15 from "path";
2430
3030
  async function collectSearch(request) {
2431
3031
  const scan = await collectScan({
2432
3032
  targets: request.targets,
@@ -2444,7 +3044,7 @@ async function collectSearch(request) {
2444
3044
  const outcomes = await Promise.all(
2445
3045
  scan.result.entries.map(
2446
3046
  (entry) => limiter(async () => {
2447
- const pathMatch = match(entry.path) || match(path14.relative(request.cwd, entry.path));
3047
+ const pathMatch = match(entry.path) || match(path15.relative(request.cwd, entry.path));
2448
3048
  const packages = await listTopLevelPackages(entry.path);
2449
3049
  const matched = packages.filter((pkg) => match(pkg.name));
2450
3050
  const matches = await Promise.all(
@@ -2465,12 +3065,12 @@ async function collectSearch(request) {
2465
3065
  )
2466
3066
  );
2467
3067
  searched += 1;
2468
- progressLine(`\u280B searching ${searched}/${entryCount} node_modules\u2026`);
3068
+ spinUpdate(`searching ${searched}/${entryCount} node_modules\u2026`);
2469
3069
  return { entry, pathMatch, matches };
2470
3070
  })
2471
3071
  )
2472
3072
  );
2473
- clearProgress();
3073
+ spinClear();
2474
3074
  const results = outcomes.filter((outcome) => outcome.pathMatch || outcome.matches.length > 0);
2475
3075
  verboseLog(`search '${request.pattern}': ${results.length}/${entryCount} entries matched`);
2476
3076
  let code = 0;
@@ -2497,11 +3097,11 @@ async function runSearch(request) {
2497
3097
  }
2498
3098
 
2499
3099
  // src/commands/why.ts
2500
- import path16 from "path";
3100
+ import path17 from "path";
2501
3101
 
2502
3102
  // src/core/lockfile.ts
2503
- import fs12 from "fs/promises";
2504
- import path15 from "path";
3103
+ import fs15 from "fs/promises";
3104
+ import path16 from "path";
2505
3105
  var ROOT = "\xABroot\xBB";
2506
3106
  var MAX_LOCKFILE_BYTES = 8 * 1024 * 1024;
2507
3107
  function nameOfPackageKey(key) {
@@ -2583,9 +3183,9 @@ function buildGraph(pm, file, parsed) {
2583
3183
  async function readNpmLockGraph(projectDir, file = "package-lock.json") {
2584
3184
  let text2;
2585
3185
  try {
2586
- const stat = await fs12.stat(`${projectDir}/${file}`);
2587
- if (!stat.isFile() || stat.size > MAX_LOCKFILE_BYTES) return null;
2588
- text2 = await fs12.readFile(`${projectDir}/${file}`, "utf8");
3186
+ const stat2 = await fs15.stat(`${projectDir}/${file}`);
3187
+ if (!stat2.isFile() || stat2.size > MAX_LOCKFILE_BYTES) return null;
3188
+ text2 = await fs15.readFile(`${projectDir}/${file}`, "utf8");
2589
3189
  } catch {
2590
3190
  return null;
2591
3191
  }
@@ -2606,9 +3206,9 @@ async function readProjectLockGraph(projectDir) {
2606
3206
  async function readYarnLockGraph(projectDir) {
2607
3207
  let text2;
2608
3208
  try {
2609
- const stat = await fs12.stat(path15.join(projectDir, "yarn.lock"));
2610
- if (!stat.isFile() || stat.size > MAX_LOCKFILE_BYTES) return null;
2611
- text2 = await fs12.readFile(path15.join(projectDir, "yarn.lock"), "utf8");
3209
+ const stat2 = await fs15.stat(path16.join(projectDir, "yarn.lock"));
3210
+ if (!stat2.isFile() || stat2.size > MAX_LOCKFILE_BYTES) return null;
3211
+ text2 = await fs15.readFile(path16.join(projectDir, "yarn.lock"), "utf8");
2612
3212
  } catch {
2613
3213
  return null;
2614
3214
  }
@@ -2616,7 +3216,7 @@ async function readYarnLockGraph(projectDir) {
2616
3216
  if (parsed === null) return null;
2617
3217
  let rootDirect = 0;
2618
3218
  try {
2619
- const manifest = JSON.parse(await fs12.readFile(path15.join(projectDir, "package.json"), "utf8"));
3219
+ const manifest = JSON.parse(await fs15.readFile(path16.join(projectDir, "package.json"), "utf8"));
2620
3220
  for (const field of ["dependencies", "devDependencies", "optionalDependencies"]) {
2621
3221
  const map = manifest[field];
2622
3222
  if (map === void 0 || typeof map !== "object") continue;
@@ -2751,9 +3351,9 @@ function nameOfSpecKey(key) {
2751
3351
  async function readPnpmLockGraph(projectDir) {
2752
3352
  let text2;
2753
3353
  try {
2754
- const stat = await fs12.stat(path15.join(projectDir, "pnpm-lock.yaml"));
2755
- if (!stat.isFile() || stat.size > MAX_LOCKFILE_BYTES) return null;
2756
- text2 = await fs12.readFile(path15.join(projectDir, "pnpm-lock.yaml"), "utf8");
3354
+ const stat2 = await fs15.stat(path16.join(projectDir, "pnpm-lock.yaml"));
3355
+ if (!stat2.isFile() || stat2.size > MAX_LOCKFILE_BYTES) return null;
3356
+ text2 = await fs15.readFile(path16.join(projectDir, "pnpm-lock.yaml"), "utf8");
2757
3357
  } catch {
2758
3358
  return null;
2759
3359
  }
@@ -2810,9 +3410,9 @@ function collectPnpmNodes(section, edges, nodeNames) {
2810
3410
  async function readBunLockGraph(projectDir) {
2811
3411
  let text2;
2812
3412
  try {
2813
- const stat = await fs12.stat(path15.join(projectDir, "bun.lock"));
2814
- if (!stat.isFile() || stat.size > MAX_LOCKFILE_BYTES) return null;
2815
- text2 = await fs12.readFile(path15.join(projectDir, "bun.lock"), "utf8");
3413
+ const stat2 = await fs15.stat(path16.join(projectDir, "bun.lock"));
3414
+ if (!stat2.isFile() || stat2.size > MAX_LOCKFILE_BYTES) return null;
3415
+ text2 = await fs15.readFile(path16.join(projectDir, "bun.lock"), "utf8");
2816
3416
  } catch {
2817
3417
  return null;
2818
3418
  }
@@ -2957,7 +3557,7 @@ async function runWhy(request) {
2957
3557
  if (target === void 0) return null;
2958
3558
  const declaredBy = await classify(entry.path, request.pkg);
2959
3559
  const targetVersion = await packageVersion(target.path);
2960
- const lock = await readProjectLockGraph(path16.dirname(entry.path));
3560
+ const lock = await readProjectLockGraph(path17.dirname(entry.path));
2961
3561
  let requiredBy;
2962
3562
  let requiredByGrade;
2963
3563
  if (lock !== null) {
@@ -3014,91 +3614,6 @@ async function runWhy(request) {
3014
3614
  return code;
3015
3615
  }
3016
3616
 
3017
- // src/utils/update-check.ts
3018
- import fs13 from "fs/promises";
3019
- import path17 from "path";
3020
- import { homedir as homedir7 } from "os";
3021
- var UPDATE_CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
3022
- function cacheFile() {
3023
- return path17.join(homedir7(), ".nmh", "cache", "update-check.json");
3024
- }
3025
- function registryUrl() {
3026
- return process.env.NMH_REGISTRY_JSON ?? "https://registry.npmjs.org/node-modules-hunter/latest";
3027
- }
3028
- function isNewer(current, latest) {
3029
- const triple = (v) => {
3030
- const match = /^(\d+)\.(\d+)\.(\d+)(.*)$/.exec(v.trim());
3031
- return match === null ? null : [Number(match[1]), Number(match[2]), Number(match[3]), match[4] ?? ""];
3032
- };
3033
- const now = triple(current);
3034
- const next = triple(latest);
3035
- if (now === null || next === null) return false;
3036
- for (let i = 0; i < 3; i += 1) {
3037
- if (next[i] > now[i]) return true;
3038
- if (next[i] < now[i]) return false;
3039
- }
3040
- return false;
3041
- }
3042
- function updateNote(latest, current) {
3043
- return `update tersedia: ${current} \u2192 ${latest} \u2014 npm i -g node-modules-hunter (cek 24 jam sekali, opt-in via --update-check)`;
3044
- }
3045
- async function readFreshCache() {
3046
- try {
3047
- const parsed = JSON.parse(await fs13.readFile(cacheFile(), "utf8"));
3048
- const age = Date.now() - new Date(parsed.checkedAt).getTime();
3049
- if (Number.isFinite(age) && age >= 0 && age < UPDATE_CACHE_TTL_MS && typeof parsed.latest === "string") {
3050
- return parsed.latest;
3051
- }
3052
- } catch {
3053
- }
3054
- return null;
3055
- }
3056
- async function writeCache(latest) {
3057
- try {
3058
- const entry = { checkedAt: (/* @__PURE__ */ new Date()).toISOString(), latest };
3059
- await fs13.mkdir(path17.dirname(cacheFile()), { recursive: true });
3060
- await fs13.writeFile(cacheFile(), `${JSON.stringify(entry, null, 2)}
3061
- `, "utf8");
3062
- } catch (err) {
3063
- verboseLog(`update-cache write failed: ${err instanceof Error ? err.message : String(err)}`);
3064
- }
3065
- }
3066
- function parseLatestVersion(payload2) {
3067
- if (typeof payload2 !== "object" || payload2 === null) return null;
3068
- const version = payload2.version;
3069
- if (typeof version !== "string" || version.length === 0) return null;
3070
- return tripleOf(version) ? version : null;
3071
- }
3072
- function tripleOf(version) {
3073
- return /^\d+\.\d+\.\d+/.test(version.trim());
3074
- }
3075
- async function checkForUpdate() {
3076
- try {
3077
- const fresh = await readFreshCache();
3078
- const latest = fresh ?? await fetchLatest();
3079
- if (latest === null) return null;
3080
- return isNewer(toolVersion(), latest) ? updateNote(latest, toolVersion()) : null;
3081
- } catch (err) {
3082
- verboseLog(`update check failed: ${err instanceof Error ? err.message : String(err)}`);
3083
- return null;
3084
- }
3085
- }
3086
- async function fetchLatest() {
3087
- try {
3088
- const response = await fetch(registryUrl(), { signal: AbortSignal.timeout(4e3) });
3089
- if (!response.ok) {
3090
- verboseLog(`update check: registry responded ${response.status}`);
3091
- return null;
3092
- }
3093
- const latest = parseLatestVersion(await response.json());
3094
- if (latest !== null) await writeCache(latest);
3095
- return latest;
3096
- } catch (err) {
3097
- verboseLog(`update check: registry unreachable (${err instanceof Error ? err.message : String(err)})`);
3098
- return null;
3099
- }
3100
- }
3101
-
3102
3617
  // src/commands/map.ts
3103
3618
  async function collectMap(request) {
3104
3619
  const scan = await collectScan({
@@ -3139,12 +3654,12 @@ async function collectMap(request) {
3139
3654
  )
3140
3655
  );
3141
3656
  mapped += 1;
3142
- progressLine(`\u280B mapping ${mapped}/${entryCount} projects\u2026`);
3657
+ spinUpdate(`mapping ${mapped}/${entryCount} projects\u2026`);
3143
3658
  return { entry, project, topPackages, declared };
3144
3659
  })
3145
3660
  )
3146
3661
  );
3147
- clearProgress();
3662
+ spinClear();
3148
3663
  let code = 0;
3149
3664
  if (projects.length === 0) code = 2;
3150
3665
  else if (scan.result.permissionIssues.length > 0) code = 4;
@@ -3190,7 +3705,7 @@ async function runMap(request) {
3190
3705
  }
3191
3706
 
3192
3707
  // src/commands/treemap.ts
3193
- import fs14 from "fs/promises";
3708
+ import fs16 from "fs/promises";
3194
3709
  import path18 from "path";
3195
3710
 
3196
3711
  // src/core/treemap.ts
@@ -3421,8 +3936,8 @@ async function runTreemap(request) {
3421
3936
  }
3422
3937
  const outPath = path18.resolve(request.cwd, request.out);
3423
3938
  try {
3424
- await fs14.mkdir(path18.dirname(outPath), { recursive: true });
3425
- await fs14.writeFile(outPath, renderTreemapHtml(data), "utf8");
3939
+ await fs16.mkdir(path18.dirname(outPath), { recursive: true });
3940
+ await fs16.writeFile(outPath, renderTreemapHtml(data), "utf8");
3426
3941
  } catch (err) {
3427
3942
  errorLog(`\u{1F6D1} could not write the treemap file \u2192 ${outPath} \u2014 ${err instanceof Error ? err.message : String(err)}`);
3428
3943
  return 1;
@@ -3435,7 +3950,7 @@ async function runTreemap(request) {
3435
3950
  // src/core/deps-audit.ts
3436
3951
  import { glob as glob2 } from "tinyglobby";
3437
3952
  import path19 from "path";
3438
- import fs15 from "fs/promises";
3953
+ import fs17 from "fs/promises";
3439
3954
  var SOURCE_GLOB = "**/*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,svelte,astro}";
3440
3955
  var SOURCE_IGNORES = ["**/node_modules/**", "**/.git/**"];
3441
3956
  var MAX_FILES_PER_PROJECT = 2e3;
@@ -3503,9 +4018,9 @@ async function collectDepsAudit(projects, installedNames) {
3503
4018
  await limiter(async () => {
3504
4019
  let code;
3505
4020
  try {
3506
- const stat = await fs15.stat(path19.join(project.dir, file));
3507
- if (stat.size > MAX_FILE_BYTES) return;
3508
- code = await fs15.readFile(path19.join(project.dir, file), "utf8");
4021
+ const stat2 = await fs17.stat(path19.join(project.dir, file));
4022
+ if (stat2.size > MAX_FILE_BYTES) return;
4023
+ code = await fs17.readFile(path19.join(project.dir, file), "utf8");
3509
4024
  } catch {
3510
4025
  return;
3511
4026
  }
@@ -3546,7 +4061,7 @@ async function collectDepsAudit(projects, installedNames) {
3546
4061
  import path21 from "path";
3547
4062
 
3548
4063
  // src/utils/registry-deprecated.ts
3549
- import fs16 from "fs/promises";
4064
+ import fs18 from "fs/promises";
3550
4065
  import path20 from "path";
3551
4066
  import os3 from "os";
3552
4067
  var MAX_REGISTRY_DEPRECATED_FETCHES = 25;
@@ -3562,7 +4077,7 @@ async function registryDeprecated(name, version, cacheDir, base = "https://regis
3562
4077
  if (name.length === 0 || version.length === 0) return null;
3563
4078
  const file = cachePath(cacheDir, name, version);
3564
4079
  try {
3565
- const cached2 = JSON.parse(await fs16.readFile(file, "utf8"));
4080
+ const cached2 = JSON.parse(await fs18.readFile(file, "utf8"));
3566
4081
  if (typeof cached2.checkedAt === "string") {
3567
4082
  const age = Date.now() - Date.parse(cached2.checkedAt);
3568
4083
  if (Number.isFinite(age) && age >= 0 && age < DEPRECATED_TTL_MS) {
@@ -3589,8 +4104,8 @@ async function registryDeprecated(name, version, cacheDir, base = "https://regis
3589
4104
  }
3590
4105
  async function writeDeprecatedCache(file, message) {
3591
4106
  try {
3592
- await fs16.mkdir(path20.dirname(file), { recursive: true });
3593
- await fs16.writeFile(file, JSON.stringify({ checkedAt: (/* @__PURE__ */ new Date()).toISOString(), message }), "utf8");
4107
+ await fs18.mkdir(path20.dirname(file), { recursive: true });
4108
+ await fs18.writeFile(file, JSON.stringify({ checkedAt: (/* @__PURE__ */ new Date()).toISOString(), message }), "utf8");
3594
4109
  } catch {
3595
4110
  }
3596
4111
  }
@@ -3639,13 +4154,13 @@ async function collectStats(request) {
3639
4154
  )
3640
4155
  );
3641
4156
  measuredEntries += 1;
3642
- progressLine(`\u280B analyzing ${measuredEntries}/${entryCount} node_modules\u2026`);
4157
+ spinUpdate(`analyzing ${measuredEntries}/${entryCount} node_modules\u2026`);
3643
4158
  const graph2 = request.graph === true ? await readProjectLockGraph(declared?.projectDir ?? path21.dirname(entry.path)) : void 0;
3644
4159
  return { entry, project, topPackages, declared, graph: graph2 };
3645
4160
  })
3646
4161
  )
3647
4162
  );
3648
- clearProgress();
4163
+ spinClear();
3649
4164
  const byName = /* @__PURE__ */ new Map();
3650
4165
  const versionsByName = /* @__PURE__ */ new Map();
3651
4166
  const members = /* @__PURE__ */ new Map();
@@ -4094,13 +4609,20 @@ async function saveBaselineQuiet(name, snapshot) {
4094
4609
  import { confirm, select, isCancel } from "@clack/prompts";
4095
4610
 
4096
4611
  // src/core/deleter.ts
4097
- import fs17 from "fs/promises";
4612
+ import fs20 from "fs/promises";
4098
4613
 
4099
4614
  // src/utils/trash.ts
4100
- import { execFile } from "child_process";
4101
- import { promisify } from "util";
4102
- var execFileAsync = promisify(execFile);
4615
+ import { execFile as execFile4 } from "child_process";
4616
+ import { promisify as promisify4 } from "util";
4617
+ import fs19 from "fs/promises";
4618
+ import path22 from "path";
4619
+ import { homedir as homedir8 } from "os";
4620
+ var execFileAsync = promisify4(execFile4);
4103
4621
  async function trashBackend() {
4622
+ const forced = process.env.NMH_TRASH_BACKEND;
4623
+ if (forced === "windows-com" || forced === "macos-finder" || forced === "linux-gio" || forced === "linux-trash-cli" || forced === "none") {
4624
+ return forced;
4625
+ }
4104
4626
  switch (process.platform) {
4105
4627
  case "win32":
4106
4628
  return "windows-com";
@@ -4181,6 +4703,30 @@ async function commandExists(bin) {
4181
4703
  return false;
4182
4704
  }
4183
4705
  }
4706
+ async function createTrashBatch(home) {
4707
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
4708
+ const hex = Math.random().toString(16).slice(2, 5);
4709
+ const batchDir = path22.join(home ?? homedir8(), ".nmh", "trash", `trash-${stamp}-${hex}`);
4710
+ await fs19.mkdir(batchDir, { recursive: true });
4711
+ return batchDir;
4712
+ }
4713
+ async function nmhTrashMove(entryPath, batchDir) {
4714
+ const parent = path22.basename(path22.dirname(entryPath));
4715
+ const storedAs = `${parent}-node_modules-${Math.random().toString(16).slice(2, 5)}`;
4716
+ const destination = path22.join(batchDir, storedAs);
4717
+ try {
4718
+ await fs19.rename(entryPath, destination);
4719
+ } catch (err) {
4720
+ if (err?.code !== "EXDEV") throw err;
4721
+ await fs19.cp(entryPath, destination, { recursive: true, dereference: false, verbatimSymlinks: true });
4722
+ await fs19.rm(entryPath, { recursive: true, force: true });
4723
+ }
4724
+ return storedAs;
4725
+ }
4726
+ async function appendTrashManifest(batchDir, row) {
4727
+ await fs19.appendFile(path22.join(batchDir, "manifest.jsonl"), `${JSON.stringify(row)}
4728
+ `, "utf8");
4729
+ }
4184
4730
 
4185
4731
  // src/core/deleter.ts
4186
4732
  async function deleteEntries(entries, request) {
@@ -4188,39 +4734,76 @@ async function deleteEntries(entries, request) {
4188
4734
  const limiter = createLimiter(request.concurrency ?? 8);
4189
4735
  const rows = [];
4190
4736
  let done = 0;
4191
- const runOne = async (entry, attempt) => {
4737
+ const runOne = async (entry, attempt, backend) => {
4192
4738
  const failure = await attempt().catch(
4193
4739
  (err) => err instanceof Error ? err.message : String(err)
4194
4740
  );
4195
- const row = failure ? { path: entry.path, size: entry.size, status: "failed", error: failure } : { path: entry.path, size: entry.size, status: request.mode === "hard" ? "deleted" : "trashed" };
4741
+ const row = failure ? { path: entry.path, size: entry.size, status: "failed", error: failure } : {
4742
+ path: entry.path,
4743
+ size: entry.size,
4744
+ status: request.mode === "hard" ? "deleted" : "trashed",
4745
+ ...backend !== void 0 ? { backend } : {}
4746
+ };
4196
4747
  rows.push(row);
4197
4748
  done += 1;
4198
4749
  request.onRow?.(row, done, total);
4199
4750
  };
4751
+ const outcome = { results: rows, freed: 0, deleted: 0, failed: 0, permissionDenied: false };
4200
4752
  if (request.mode === "hard") {
4201
4753
  await Promise.all(entries.map((entry) => limiter(() => runOne(entry, () => removeHard(entry.path)))));
4202
4754
  } else {
4203
- const backend = await trashBackend();
4204
- if (backend === "none") throw new TrashUnavailableError();
4205
- await Promise.all(
4206
- entries.map(
4207
- (entry) => limiter(() => runOne(entry, () => moveToTrash([entry.path], backend).then(() => null)))
4208
- )
4209
- );
4755
+ const trashMode = request.trashMode ?? "system";
4756
+ const systemBackend = trashMode === "system" ? await trashBackend() : null;
4757
+ const useNmhTrash = systemBackend === null || systemBackend === "none";
4758
+ if (useNmhTrash) {
4759
+ const via = request.trashMode === "nmh" ? "explicit" : "fallback";
4760
+ const batchDir = await createTrashBatch();
4761
+ outcome.trashBatch = batchDir;
4762
+ if (via === "fallback") outcome.trashFallback = true;
4763
+ await Promise.all(
4764
+ entries.map(
4765
+ (entry) => limiter(
4766
+ () => runOne(
4767
+ entry,
4768
+ async () => {
4769
+ const storedAs = await nmhTrashMove(entry.path, batchDir);
4770
+ await appendTrashManifest(batchDir, {
4771
+ originalPath: entry.path,
4772
+ storedAs,
4773
+ size: entry.size,
4774
+ trashedAt: (/* @__PURE__ */ new Date()).toISOString(),
4775
+ nmh: toolVersion(),
4776
+ via
4777
+ });
4778
+ return null;
4779
+ },
4780
+ "nmh"
4781
+ )
4782
+ )
4783
+ )
4784
+ );
4785
+ } else {
4786
+ const backend = systemBackend;
4787
+ await Promise.all(
4788
+ entries.map(
4789
+ (entry) => limiter(
4790
+ () => runOne(entry, () => moveToTrash([entry.path], backend).then(() => null), "system")
4791
+ )
4792
+ )
4793
+ );
4794
+ }
4210
4795
  }
4211
4796
  const completed = rows.filter((row) => row.status !== "failed");
4212
4797
  const failedRows = rows.filter((row) => row.status === "failed");
4213
- return {
4214
- results: rows,
4215
- freed: completed.reduce((sum2, row) => sum2 + row.size, 0),
4216
- deleted: completed.length,
4217
- failed: failedRows.length,
4218
- permissionDenied: failedRows.some((row) => /EACCES|EPERM/.test(row.error ?? ""))
4219
- };
4798
+ outcome.freed = completed.reduce((sum2, row) => sum2 + row.size, 0);
4799
+ outcome.deleted = completed.length;
4800
+ outcome.failed = failedRows.length;
4801
+ outcome.permissionDenied = failedRows.some((row) => /EACCES|EPERM/.test(row.error ?? ""));
4802
+ return outcome;
4220
4803
  }
4221
4804
  async function removeHard(target) {
4222
4805
  try {
4223
- await fs17.rm(target, { recursive: true, force: true, maxRetries: 3, retryDelay: 300 });
4806
+ await fs20.rm(target, { recursive: true, force: true, maxRetries: 3, retryDelay: 300 });
4224
4807
  return null;
4225
4808
  } catch (err) {
4226
4809
  const code = err?.code ?? "ERR";
@@ -4233,22 +4816,32 @@ async function removeHard(target) {
4233
4816
  async function executeAll(entries, request) {
4234
4817
  const outcome = await deleteEntries(entries, {
4235
4818
  mode: request.mode,
4819
+ trashMode: request.trashMode,
4236
4820
  onRow: (_row, done, total) => {
4237
- progressLine(`\u{1F5D1} ${done}/${total} folders\u2026`);
4821
+ spinUpdate(`\u{1F5D1} ${done}/${total} folders\u2026`);
4238
4822
  }
4239
4823
  });
4240
- clearProgress();
4824
+ spinClear();
4241
4825
  if (request.json) payload(renderDeleteJson(outcome, request.mode, false));
4242
4826
  else {
4243
4827
  const summary = renderDeleteSummary(outcome, request.mode);
4244
4828
  if (summary) log(summary);
4829
+ if (outcome.trashBatch !== void 0) {
4830
+ if (outcome.trashFallback === true) {
4831
+ note(`no OS trash backend (install gio or trash-cli) \u2014 fell back to the restorable nmh trash: ${outcome.trashBatch}`);
4832
+ } else {
4833
+ note(`restorable nmh trash: ${outcome.trashBatch} (manifest: manifest.jsonl \u2014 restore by moving folders back)`);
4834
+ }
4835
+ }
4245
4836
  }
4246
4837
  await writeDeleteLog({
4247
4838
  mode: request.mode,
4248
4839
  results: outcome.results,
4249
4840
  freed: outcome.freed,
4250
4841
  deleted: outcome.deleted,
4251
- failed: outcome.failed
4842
+ failed: outcome.failed,
4843
+ ...outcome.trashBatch !== void 0 ? { trashBatch: outcome.trashBatch } : {},
4844
+ ...outcome.trashFallback === true ? { trashFallback: true } : {}
4252
4845
  });
4253
4846
  const code = outcome.permissionDenied ? 4 : outcome.failed > 0 ? 1 : 0;
4254
4847
  return { code, outcome };
@@ -4264,7 +4857,9 @@ async function deleteFlow(request) {
4264
4857
  recursive: request.recursive,
4265
4858
  excludes: request.excludes,
4266
4859
  keep: request.keep,
4267
- pmFilter: request.pmFilter
4860
+ pmFilter: request.pmFilter,
4861
+ stale: request.stale,
4862
+ skipSystem: request.skipSystem
4268
4863
  });
4269
4864
  if (scan.result.unresolvable.length > 0) {
4270
4865
  for (const suspect of scan.result.unresolvable) {
@@ -4309,7 +4904,7 @@ async function deleteFlow(request) {
4309
4904
  return 1;
4310
4905
  }
4311
4906
  if (request.json) {
4312
- const { code: code2 } = await executeAll(safeEntries, { mode, json: true });
4907
+ const { code: code2 } = await executeAll(safeEntries, { mode, json: true, trashMode: request.trashMode });
4313
4908
  return code2;
4314
4909
  }
4315
4910
  if (!request.assumeYes) {
@@ -4324,7 +4919,7 @@ async function deleteFlow(request) {
4324
4919
  return 0;
4325
4920
  }
4326
4921
  }
4327
- const { code } = await executeAll(safeEntries, { mode, json: false });
4922
+ const { code } = await executeAll(safeEntries, { mode, json: false, trashMode: request.trashMode });
4328
4923
  return code;
4329
4924
  }
4330
4925
  async function resolveMode(requested, json, dryRun) {
@@ -4389,14 +4984,17 @@ async function runClean(request) {
4389
4984
  excludes: request.excludes,
4390
4985
  keep: request.keep,
4391
4986
  pmFilter: request.pmFilter,
4987
+ stale: request.stale,
4988
+ skipSystem: request.skipSystem,
4989
+ trashMode: request.trashMode,
4392
4990
  maxDepth: request.maxDepth,
4393
4991
  blockedMode: "filter"
4394
4992
  });
4395
4993
  }
4396
4994
 
4397
4995
  // src/commands/wizard.ts
4398
- import fs18 from "fs/promises";
4399
- import path22 from "path";
4996
+ import fs21 from "fs/promises";
4997
+ import path23 from "path";
4400
4998
  import { confirm as confirm2, isCancel as isCancel2, multiselect, outro, select as select2, text } from "@clack/prompts";
4401
4999
 
4402
5000
  // src/utils/banner.ts
@@ -4447,7 +5045,7 @@ var PROJECT_MANIFESTS = /* @__PURE__ */ new Set([
4447
5045
  ]);
4448
5046
  async function hasProjectManifest(dir) {
4449
5047
  try {
4450
- const names = await fs18.readdir(dir);
5048
+ const names = await fs21.readdir(dir);
4451
5049
  for (const name of names) {
4452
5050
  if (PROJECT_MANIFESTS.has(name)) return true;
4453
5051
  }
@@ -4459,13 +5057,13 @@ async function hasProjectManifest(dir) {
4459
5057
  async function resolveWizardScope(cwd) {
4460
5058
  const here = await scanForNodeModules([cwd], { maxDepth: WIZARD_DEPTH });
4461
5059
  if (here.entries.length > 0) return { root: cwd, from: cwd, climbed: false, scouted: false };
4462
- let dir = path22.dirname(cwd);
4463
- for (let hop = 0; hop < WIZARD_MAX_CLIMB && dir !== path22.dirname(dir); hop += 1) {
5060
+ let dir = path23.dirname(cwd);
5061
+ for (let hop = 0; hop < WIZARD_MAX_CLIMB && dir !== path23.dirname(dir); hop += 1) {
4464
5062
  if (await hasProjectManifest(dir)) {
4465
5063
  const probe = await scanForNodeModules([dir], { maxDepth: WIZARD_DEPTH });
4466
5064
  if (probe.entries.length > 0) return { root: dir, from: cwd, climbed: true, scouted: true };
4467
5065
  }
4468
- dir = path22.dirname(dir);
5066
+ dir = path23.dirname(dir);
4469
5067
  }
4470
5068
  return { root: cwd, from: cwd, climbed: false, scouted: true };
4471
5069
  }
@@ -4651,19 +5249,33 @@ function sum(entries) {
4651
5249
  }
4652
5250
 
4653
5251
  // src/commands/global.ts
4654
- import { execFile as execFile2 } from "child_process";
4655
- import { promisify as promisify2 } from "util";
4656
- import fs19 from "fs/promises";
4657
- import path23 from "path";
4658
- var execFileAsync2 = promisify2(execFile2);
5252
+ import { execFile as execFile5 } from "child_process";
5253
+ import { promisify as promisify5 } from "util";
5254
+ import fs22 from "fs/promises";
5255
+ import path24 from "path";
5256
+ var execFileAsync2 = promisify5(execFile5);
4659
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
+ }
4660
5262
  async function probeViaCommand(cmd, argv) {
4661
5263
  try {
4662
5264
  const { stdout } = await execFileAsync2(cmd, argv, { timeout: PROBE_TIMEOUT });
4663
- const line = stdout.split("\n").map((row) => row.trim()).filter(Boolean).pop();
4664
- return line && line.length > 0 ? line : null;
5265
+ return lastLine(stdout);
4665
5266
  } catch (err) {
4666
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)})`);
4667
5279
  return null;
4668
5280
  }
4669
5281
  }
@@ -4678,7 +5290,7 @@ async function probeRoot(cmd, argv, shape, fallback) {
4678
5290
  }
4679
5291
  async function isDir(candidate) {
4680
5292
  try {
4681
- return (await fs19.stat(candidate)).isDirectory();
5293
+ return (await fs22.stat(candidate)).isDirectory();
4682
5294
  } catch {
4683
5295
  return false;
4684
5296
  }
@@ -4704,12 +5316,12 @@ async function inspectDeadBins(root, binDir, refs) {
4704
5316
  else if (binField && typeof binField === "object") binNames = Object.keys(binField);
4705
5317
  else continue;
4706
5318
  for (const binName of binNames) {
4707
- const link = path23.join(binDir, binName);
5319
+ const link = path24.join(binDir, binName);
4708
5320
  try {
4709
- const linkStat = await fs19.lstat(link);
5321
+ const linkStat = await fs22.lstat(link);
4710
5322
  if (!linkStat.isSymbolicLink()) continue;
4711
5323
  try {
4712
- await fs19.stat(link);
5324
+ await fs22.stat(link);
4713
5325
  } catch {
4714
5326
  issues.push({ name: ref.name, kind: "broken bin symlink", detail: binName });
4715
5327
  }
@@ -4722,40 +5334,40 @@ async function inspectDeadBins(root, binDir, refs) {
4722
5334
  }
4723
5335
  async function resolvePmRoots(pmFilter) {
4724
5336
  const npmFallback = {
4725
- root: path23.join(expandTilde("~"), "lib", "node_modules"),
4726
- binDir: path23.join(expandTilde("~"), "bin"),
5337
+ root: path24.join(expandTilde("~"), "lib", "node_modules"),
5338
+ binDir: path24.join(expandTilde("~"), "bin"),
4727
5339
  source: "fallback"
4728
5340
  };
4729
5341
  const pnpmFallback = await pnpmFallbackSpec();
4730
5342
  const yarnFallback = {
4731
- root: path23.join(expandTilde("~"), ".config", "yarn", "global", "node_modules"),
4732
- binDir: path23.join(expandTilde("~"), ".config", "yarn", "global", "bin"),
5343
+ root: path24.join(expandTilde("~"), ".config", "yarn", "global", "node_modules"),
5344
+ binDir: path24.join(expandTilde("~"), ".config", "yarn", "global", "bin"),
4733
5345
  source: "fallback"
4734
5346
  };
4735
5347
  const bunFallback = {
4736
- root: path23.join(expandTilde("~"), ".bun", "install", "global", "node_modules"),
4737
- binDir: path23.join(expandTilde("~"), ".bun", "bin"),
5348
+ root: path24.join(expandTilde("~"), ".bun", "install", "global", "node_modules"),
5349
+ binDir: path24.join(expandTilde("~"), ".bun", "bin"),
4738
5350
  source: "fallback"
4739
5351
  };
4740
5352
  const wanted = pmFilter ?? ["npm", "pnpm", "yarn", "bun"];
4741
5353
  const results = [];
4742
5354
  for (const pm of wanted) {
4743
5355
  let spec = null;
4744
- if (pm === "npm") spec = await probeRoot("npm", ["root", "-g"], (dir) => ({ root: dir, binDir: path23.join(path23.dirname(path23.dirname(dir)), "bin"), source: "pm" }), npmFallback);
5356
+ if (pm === "npm") spec = await probeRoot("npm", ["root", "-g"], (dir) => ({ root: dir, binDir: path24.join(path24.dirname(path24.dirname(dir)), "bin"), source: "pm" }), npmFallback);
4745
5357
  else if (pm === "pnpm") spec = await probeRoot("pnpm", ["root", "-g"], (dir) => ({ root: dir, binDir: null, source: "pm" }), pnpmFallback);
4746
- else if (pm === "yarn") spec = await probeRoot("yarn", ["global", "dir"], (dir) => ({ root: path23.join(dir, "node_modules"), binDir: path23.join(dir, "bin"), source: "pm" }), yarnFallback);
4747
- else if (pm === "bun") spec = await probeRoot("bun", ["pm", "-g"], (dir) => ({ root: dir, binDir: path23.join(path23.dirname(path23.dirname(dir)), "bin"), source: "pm" }), bunFallback);
5358
+ else if (pm === "yarn") spec = await probeRoot("yarn", ["global", "dir"], (dir) => ({ root: path24.join(dir, "node_modules"), binDir: path24.join(dir, "bin"), source: "pm" }), yarnFallback);
5359
+ else if (pm === "bun") spec = await probeRoot("bun", ["pm", "-g"], (dir) => ({ root: dir, binDir: path24.join(path24.dirname(path24.dirname(dir)), "bin"), source: "pm" }), bunFallback);
4748
5360
  if (spec) results.push({ pm, spec });
4749
5361
  else verboseLog(`global: ${pm} \u2014 no global node_modules found`);
4750
5362
  }
4751
5363
  return results;
4752
5364
  }
4753
5365
  async function pnpmFallbackSpec() {
4754
- const base = path23.join(expandTilde("~"), ".local", "share", "pnpm", "global");
5366
+ const base = path24.join(expandTilde("~"), ".local", "share", "pnpm", "global");
4755
5367
  try {
4756
- const versions = (await fs19.readdir(base)).filter((name) => /^\d+$/.test(name)).sort((a, b) => Number(b) - Number(a));
5368
+ const versions = (await fs22.readdir(base)).filter((name) => /^\d+$/.test(name)).sort((a, b) => Number(b) - Number(a));
4757
5369
  for (const version of versions) {
4758
- const root = path23.join(base, version, "node_modules");
5370
+ const root = path24.join(base, version, "node_modules");
4759
5371
  if (await isDir(root)) return { root, binDir: null, source: "fallback" };
4760
5372
  }
4761
5373
  } catch {
@@ -4763,7 +5375,7 @@ async function pnpmFallbackSpec() {
4763
5375
  return null;
4764
5376
  }
4765
5377
  async function runGlobal(request) {
4766
- progressLine("\u280B probing global prefixes\u2026");
5378
+ spinUpdate("probing global prefixes\u2026");
4767
5379
  const pmRoots = await resolvePmRoots(request.pmFilter);
4768
5380
  const reports = [];
4769
5381
  const permissionIssues = [];
@@ -4779,7 +5391,7 @@ async function runGlobal(request) {
4779
5391
  ref.symlinked ? Promise.resolve(null) : measureEntry(ref.path)
4780
5392
  ]);
4781
5393
  done += 1;
4782
- progressLine(`\u280B global ${pm} \u2014 ${done}/${refs.length} packages`);
5394
+ spinUpdate(`global ${pm} \u2014 ${done}/${refs.length} packages`);
4783
5395
  return { name: ref.name, version, symlinked: ref.symlinked, size: measure === null ? null : measure.size };
4784
5396
  })
4785
5397
  )
@@ -4794,7 +5406,7 @@ async function runGlobal(request) {
4794
5406
  dead
4795
5407
  });
4796
5408
  }
4797
- clearProgress();
5409
+ spinClear();
4798
5410
  const byName = /* @__PURE__ */ new Map();
4799
5411
  for (const report of reports) {
4800
5412
  for (const pkg of report.packages) {
@@ -4833,12 +5445,19 @@ function finish(request, outcome) {
4833
5445
 
4834
5446
  // src/cli.ts
4835
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)");
4836
5450
  cli.option("-d, --dir <paths>", "Target dir(s), comma separated or repeated; globs and ~ allowed");
4837
5451
  cli.option("--delete [mode]", "Delete found folders: hard | soft | trash (soft = trash; no value = pick interactively)");
4838
5452
  cli.option("--dry-run", "Preview what would be deleted \u2014 nothing is touched");
4839
5453
  cli.option("-y, --yes", "Answer yes to every prompt");
4840
5454
  cli.option("-f, --force", "Skip all confirmations (implies --yes)");
4841
5455
  cli.option("--size <mode>", "'total' (just aggregate) or 'each' (default); numeric top-N lands later");
5456
+ cli.option("--stale <days>", "v1.1: only projects idle >= <days> (git activity signal \u2014 last commit, else manifest, else folder mtime)");
5457
+ cli.option("--fast-size", "v1.1: measure with one `du -sk` per entry (KiB granularity) instead of walking every file");
5458
+ cli.option("--sort <key>", "v1.1: order results \u2014 size (default, desc) | path | idle (oldest project first) | mtime (newest first)");
5459
+ cli.option("--no-skip", "v1.1: audit everything \u2014 stop hiding .Trash*, Library/, and *.app dirs during discovery (default: hidden)");
5460
+ cli.option("--trash <mode>", "v1.1: trash mode for delete ops \u2014 'system' (default: gio/Finder/Recycle Bin) or 'nmh' (restorable ~/.nmh/trash + manifest)");
4842
5461
  cli.option("--json", "Machine-readable JSON output");
4843
5462
  cli.option("-q, --quiet", "Minimal output \u2014 pretty tables/notes mute, but the --json payload still prints");
4844
5463
  cli.option("--verbose", "Debug chatter on stderr");
@@ -4881,9 +5500,33 @@ cli.option("--default-keep <pkgs>", 'config family: manage the persisted keep li
4881
5500
  cli.option("--default-mode <mode>", "config family: set the persisted defaultMode (hard | soft | trash)");
4882
5501
  cli.option("--log-dir <dir>", "config family: set the persisted recovery-log directory (logDir)");
4883
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
+ ]);
4884
5518
  function looksLikePath(text2) {
4885
5519
  return text2.includes("/") || text2.includes("\\") || text2.startsWith(".") || text2.startsWith("~") || /^[A-Za-z]:/.test(text2);
4886
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
+ }
4887
5530
  function normalizeDelete(value) {
4888
5531
  if (value === void 0 || value === false) return { given: false, mode: null };
4889
5532
  if (value === true) return { given: true, mode: null };
@@ -4915,7 +5558,7 @@ async function main() {
4915
5558
  return 0;
4916
5559
  }
4917
5560
  if (flags.help === true || positionals[0] === "help") {
4918
- cli.outputHelp();
5561
+ printHelp();
4919
5562
  return 0;
4920
5563
  }
4921
5564
  const explicitConfig = typeof flags.config === "string" && flags.config.length > 0 ? flags.config : null;
@@ -4946,11 +5589,13 @@ async function main() {
4946
5589
  const cliColorExplicit = process.argv.includes("--color") || process.argv.includes("--color=true");
4947
5590
  const json = flags.json === true || Array.isArray(flags.json) && flags.json.includes(true);
4948
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";
4949
5594
  configureLogger({
4950
5595
  verbose: flags.verbose === true,
4951
5596
  quiet: flags.quiet === true,
4952
5597
  noColor: flags.noColor === true || flags.color === false || // explicit CLI --no-color wins over config
4953
- config.color === false && !cliColorExplicit,
5598
+ config.color === false && !cliColorExplicit || !isTTY && !colorForced,
4954
5599
  // --json shares stdout with the live counter → counter must stay silent
4955
5600
  progress: json ? "off" : progress
4956
5601
  });
@@ -4978,6 +5623,59 @@ async function main() {
4978
5623
  errorLog("--size given more than once \u2014 pass a single value (total | each)");
4979
5624
  return 1;
4980
5625
  }
5626
+ let stale;
5627
+ if (flags.stale !== void 0) {
5628
+ if (flags.stale === true) {
5629
+ errorLog("--stale needs a value \u2014 use --stale=<days> (e.g. --stale=90)");
5630
+ return 1;
5631
+ }
5632
+ if (Array.isArray(flags.stale)) {
5633
+ errorLog("--stale given more than once \u2014 pass a single value (days)");
5634
+ return 1;
5635
+ }
5636
+ const days = Number(flags.stale);
5637
+ if (!Number.isInteger(days) || days < 1 || days > 365e3) {
5638
+ errorLog(`invalid --stale: ${String(flags.stale)} (expected a positive whole number of days)`);
5639
+ return 1;
5640
+ }
5641
+ stale = days;
5642
+ }
5643
+ const fastSize = flags.fastSize === true || Array.isArray(flags.fastSize) && flags.fastSize.includes(true);
5644
+ let sort;
5645
+ if (flags.sort !== void 0) {
5646
+ if (flags.sort === true) {
5647
+ errorLog("--sort needs a value \u2014 use --sort=size | path | idle | mtime");
5648
+ return 1;
5649
+ }
5650
+ if (Array.isArray(flags.sort)) {
5651
+ errorLog("--sort given more than once \u2014 pass a single key");
5652
+ return 1;
5653
+ }
5654
+ const key = String(flags.sort);
5655
+ if (key !== "size" && key !== "path" && key !== "idle" && key !== "mtime") {
5656
+ errorLog(`invalid --sort: ${key} (expected size | path | idle | mtime)`);
5657
+ return 1;
5658
+ }
5659
+ sort = key;
5660
+ }
5661
+ const skipSystem = flags.skip !== false;
5662
+ let trashMode;
5663
+ if (flags.trash !== void 0) {
5664
+ if (flags.trash === true) {
5665
+ errorLog("--trash needs a value \u2014 use --trash=system or --trash=nmh");
5666
+ return 1;
5667
+ }
5668
+ if (Array.isArray(flags.trash)) {
5669
+ errorLog("--trash given more than once \u2014 pass a single mode");
5670
+ return 1;
5671
+ }
5672
+ const mode = String(flags.trash);
5673
+ if (mode !== "system" && mode !== "nmh") {
5674
+ errorLog(`invalid --trash: ${mode} (expected system | nmh)`);
5675
+ return 1;
5676
+ }
5677
+ trashMode = mode;
5678
+ }
4981
5679
  const deleted = normalizeDelete(flags.delete);
4982
5680
  if (deleted === "invalid") {
4983
5681
  errorLog(`invalid --delete: ${String(flags.delete)} (expected 'hard' | 'soft' | 'trash')`);
@@ -5023,7 +5721,7 @@ async function main() {
5023
5721
  }
5024
5722
  }
5025
5723
  if (reportPath !== null) {
5026
- const ext = path24.extname(reportPath).toLowerCase();
5724
+ const ext = path25.extname(reportPath).toLowerCase();
5027
5725
  if (!REPORT_EXTENSIONS.includes(ext)) {
5028
5726
  errorLog(`unknown report format: ${reportPath} (expected .json/.md/.txt)`);
5029
5727
  return 1;
@@ -5052,7 +5750,12 @@ async function main() {
5052
5750
  const cwd = process.cwd();
5053
5751
  const dryRun = flags.dryRun === true;
5054
5752
  const assumeYes = flags.yes === true || flags.force === true;
5055
- const subcommand = positionals[0] === void 0 || looksLikePath(positionals[0]) ? null : positionals[0];
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;
5056
5759
  const rest = subcommand ? positionals.slice(1) : positionals;
5057
5760
  const dirTargets = splitTargets(flags.dir);
5058
5761
  const targets = [...rest, ...dirTargets];
@@ -5064,9 +5767,12 @@ async function main() {
5064
5767
  errorLog("--save-report rides along with scan \u2014 delete/clean runs are not scan reports");
5065
5768
  return 1;
5066
5769
  }
5770
+ if (trashMode !== void 0 && !deleted.given && subcommand !== "clean") {
5771
+ warn("--trash only matters for delete/clean runs \u2014 ignored for scans and read-only commands");
5772
+ }
5067
5773
  if (deleted.given || subcommand === "clean") {
5068
5774
  for (const candidate of targets.length > 0 ? targets : [""]) {
5069
- if (isRootPath(path24.resolve(cwd, candidate))) {
5775
+ if (isRootPath(path25.resolve(cwd, candidate))) {
5070
5776
  errorLog("refusing a delete op over the filesystem root \u2014 point --dir at the project directory you want cleaned");
5071
5777
  return 1;
5072
5778
  }
@@ -5084,12 +5790,23 @@ async function main() {
5084
5790
  pmFilter: pm.family !== null ? [pm.family] : void 0,
5085
5791
  listPm: pm.given && pm.family === null,
5086
5792
  expand,
5087
- saveReport: reportPath ?? void 0
5793
+ saveReport: reportPath ?? void 0,
5794
+ stale,
5795
+ fastSize,
5796
+ sort,
5797
+ skipSystem
5088
5798
  };
5089
5799
  if (subcommand === "version") {
5090
5800
  printVersion(json);
5091
5801
  return 0;
5092
5802
  }
5803
+ if (subcommand === "update") {
5804
+ if (deleted.given || rest.length > 0) {
5805
+ errorLog("update takes no arguments \u2014 plain `nmh update` (add --json for the payload)");
5806
+ return 1;
5807
+ }
5808
+ return runUpdate({ json });
5809
+ }
5093
5810
  if (subcommand === "config") {
5094
5811
  const ops = /* @__PURE__ */ new Set(["info", "set", "add", "remove", "unset"]);
5095
5812
  const opRaw = rest[0];
@@ -5355,10 +6072,17 @@ async function main() {
5355
6072
  excludes: excludes.length > 0 ? excludes : void 0,
5356
6073
  keep: keep.length > 0 ? keep : void 0,
5357
6074
  pmFilter: pm.family !== null ? [pm.family] : void 0,
5358
- maxDepth: effectiveDepth
6075
+ maxDepth: effectiveDepth,
6076
+ stale,
6077
+ skipSystem,
6078
+ trashMode
5359
6079
  });
5360
6080
  }
5361
6081
  if (deleted.given) {
6082
+ if (trashMode !== void 0 && deleted.mode === "hard") {
6083
+ errorLog("--trash applies to trash mode \u2014 hard delete is permanent by design, there is no trash to choose");
6084
+ return 1;
6085
+ }
5362
6086
  return runDelete({
5363
6087
  targets,
5364
6088
  cwd,
@@ -5370,7 +6094,10 @@ async function main() {
5370
6094
  excludes: excludes.length > 0 ? excludes : void 0,
5371
6095
  keep: keep.length > 0 ? keep : void 0,
5372
6096
  pmFilter: pm.family !== null ? [pm.family] : void 0,
5373
- maxDepth: effectiveDepth
6097
+ maxDepth: effectiveDepth,
6098
+ stale,
6099
+ skipSystem,
6100
+ trashMode
5374
6101
  });
5375
6102
  }
5376
6103
  if (positionals.length > 0 || dirTargets.length > 0) {