node-modules-hunter 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +21 -1
  2. package/dist/cli.js +688 -206
  3. package/package.json +5 -1
package/dist/cli.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  // src/cli.ts
4
4
  import { cac } from "cac";
5
- import path24 from "path";
5
+ import path25 from "path";
6
6
 
7
7
  // src/utils/logger.ts
8
8
  import pc from "picocolors";
@@ -354,8 +354,13 @@ async function writeDeleteLog(payload2) {
354
354
  entries: payload2.results.map((row) => ({
355
355
  path: row.path,
356
356
  status: row.status,
357
- error: row.error ?? null
358
- }))
357
+ error: row.error ?? null,
358
+ // v1.1 additive: which trash took it (system | nmh) — trash rows only
359
+ ...row.backend !== void 0 ? { backend: row.backend } : {}
360
+ })),
361
+ // v1.1 additive: nmh-trash batch dir / fallback marker when applicable
362
+ ...payload2.trashBatch !== void 0 ? { trashBatch: payload2.trashBatch } : {},
363
+ ...payload2.trashFallback === true ? { trashFallback: true } : {}
359
364
  };
360
365
  await fs3.writeFile(file, JSON.stringify(body, null, 2));
361
366
  return file;
@@ -558,6 +563,11 @@ function formatBytes(bytes) {
558
563
  }
559
564
  return `${value.toFixed(1)} ${units[unitIndex]}`;
560
565
  }
566
+ function formatIdle(days) {
567
+ if (days === void 0) return "\u2014";
568
+ if (days === 0) return "now";
569
+ return `${days.toLocaleString("en-US")}d`;
570
+ }
561
571
  function formatAge(mtimeMs, now = Date.now()) {
562
572
  if (mtimeMs == null) return "\u2014";
563
573
  const diff = Math.max(0, now - mtimeMs);
@@ -692,9 +702,14 @@ function makeSearchMatcher(pattern) {
692
702
  // src/core/walker.ts
693
703
  import fs6 from "fs/promises";
694
704
  import path6 from "path";
705
+ var SYSTEM_SKIP_NAMES = /* @__PURE__ */ new Set([".Trash", ".Trashes", "Library"]);
706
+ function isSystemNoise(name) {
707
+ return SYSTEM_SKIP_NAMES.has(name) || name.startsWith(".Trash-") || name.endsWith(".app");
708
+ }
695
709
  async function scanForNodeModules(roots, options = {}) {
696
710
  const maxDepth = options.maxDepth ?? Number.POSITIVE_INFINITY;
697
711
  const recursive = options.recursive === true;
712
+ const skipSystem = options.skipSystem !== false;
698
713
  const isExcluded = options.excludes && options.excludes.length > 0 ? makeGlobMatcher(options.excludes) : null;
699
714
  const entries = [];
700
715
  const permissionIssues = [];
@@ -730,6 +745,7 @@ async function scanForNodeModules(roots, options = {}) {
730
745
  }
731
746
  for (const dirent of dirents) {
732
747
  if (dirent.name === ".git") continue;
748
+ if (skipSystem && isSystemNoise(dirent.name)) continue;
733
749
  const child = path6.join(job.dir, dirent.name);
734
750
  const st = await fs6.lstat(child).catch(() => null);
735
751
  if (st === null) continue;
@@ -1000,11 +1016,11 @@ async function measureEntry(entryPath, permIssues = []) {
1000
1016
  );
1001
1017
  }
1002
1018
  const packageCount = await countTopLevelPackages(entryPath);
1003
- const stat = await fs8.lstat(entryPath).then(
1019
+ const stat2 = await fs8.lstat(entryPath).then(
1004
1020
  (value) => value,
1005
1021
  () => null
1006
1022
  );
1007
- return { size, packageCount, mtimeMs: stat?.mtimeMs ?? null };
1023
+ return { size, packageCount, mtimeMs: stat2?.mtimeMs ?? null };
1008
1024
  }
1009
1025
  async function countTopLevelPackages(entryPath) {
1010
1026
  return (await listTopLevelPackages(entryPath)).length;
@@ -1031,8 +1047,105 @@ async function measureEntries(entryPaths, permIssues = [], options = {}) {
1031
1047
  return results;
1032
1048
  }
1033
1049
 
1034
- // src/core/pm-detect.ts
1050
+ // src/core/du-size.ts
1051
+ import { promisify } from "util";
1052
+ import { execFile } from "child_process";
1035
1053
  import fs9 from "fs/promises";
1054
+ var execFileP = promisify(execFile);
1055
+ async function measureEntryFast(entryPath) {
1056
+ try {
1057
+ const { stdout } = await execFileP("du", ["-sk", "--", entryPath], {
1058
+ maxBuffer: 8 * 1024,
1059
+ timeout: 3e4
1060
+ });
1061
+ const line = stdout.split("\n").find((value) => value.includes(" ")) ?? "";
1062
+ const tabIndex = line.lastIndexOf(" ");
1063
+ const kb = Number.parseInt(line.slice(0, tabIndex).trim(), 10);
1064
+ const echoed = line.slice(tabIndex + 1);
1065
+ if (!Number.isInteger(kb) || kb < 0 || echoed !== entryPath) return null;
1066
+ const [packageCount, stat2] = await Promise.all([
1067
+ countTopLevelPackages(entryPath),
1068
+ fs9.lstat(entryPath).catch(() => null)
1069
+ ]);
1070
+ return { size: kb * 1024, packageCount, mtimeMs: stat2?.mtimeMs ?? null };
1071
+ } catch {
1072
+ return null;
1073
+ }
1074
+ }
1075
+
1076
+ // src/core/activity.ts
1077
+ import { promisify as promisify2 } from "util";
1078
+ import { execFile as execFile2 } from "child_process";
1079
+ import { stat } from "fs/promises";
1080
+ import { dirname, join } from "path";
1081
+ var execFileP2 = promisify2(execFile2);
1082
+ var DAY_MS = 864e5;
1083
+ var GIT_TIMEOUT_MS = 4e3;
1084
+ async function hasGitDir(dir) {
1085
+ try {
1086
+ await stat(join(dir, ".git"));
1087
+ return true;
1088
+ } catch {
1089
+ return false;
1090
+ }
1091
+ }
1092
+ async function mtimeOf(dir, file) {
1093
+ try {
1094
+ return (await stat(join(dir, file))).mtimeMs;
1095
+ } catch {
1096
+ return null;
1097
+ }
1098
+ }
1099
+ async function findUp(start, test) {
1100
+ let dir = start;
1101
+ for (; ; ) {
1102
+ if (await test(dir)) return dir;
1103
+ const parent = dirname(dir);
1104
+ if (parent === dir) return null;
1105
+ dir = parent;
1106
+ }
1107
+ }
1108
+ async function lastCommitSeconds(gitDir) {
1109
+ try {
1110
+ const { stdout } = await execFileP2(
1111
+ "git",
1112
+ ["-C", gitDir, "log", "-1", "--format=%ct"],
1113
+ { maxBuffer: 64 * 1024, timeout: GIT_TIMEOUT_MS }
1114
+ );
1115
+ const seconds = Number.parseInt(stdout.trim(), 10);
1116
+ return Number.isNaN(seconds) ? null : seconds;
1117
+ } catch {
1118
+ return null;
1119
+ }
1120
+ }
1121
+ async function computeActivity(entryPath, now = Date.now()) {
1122
+ const startDir = dirname(entryPath);
1123
+ const gitDir = await findUp(startDir, hasGitDir);
1124
+ if (gitDir !== null) {
1125
+ const seconds = await lastCommitSeconds(gitDir);
1126
+ if (seconds !== null) {
1127
+ return { idleDays: idleFrom(seconds * 1e3, now), source: "git" };
1128
+ }
1129
+ }
1130
+ const manifestMtime = await findUp(startDir, async (dir) => await mtimeOf(dir, "package.json") !== null);
1131
+ if (manifestMtime !== null) {
1132
+ const mtime = await mtimeOf(manifestMtime, "package.json");
1133
+ if (mtime !== null) {
1134
+ return { idleDays: idleFrom(mtime, now), source: "manifest" };
1135
+ }
1136
+ }
1137
+ const folderMtime = await mtimeOf(startDir, "node_modules");
1138
+ if (folderMtime !== null) {
1139
+ return { idleDays: idleFrom(folderMtime, now), source: "mtime" };
1140
+ }
1141
+ return null;
1142
+ }
1143
+ function idleFrom(signalMs, now) {
1144
+ return Math.max(0, Math.floor((now - signalMs) / DAY_MS));
1145
+ }
1146
+
1147
+ // src/core/pm-detect.ts
1148
+ import fs10 from "fs/promises";
1036
1149
  import path9 from "path";
1037
1150
  var LOCKFILE_PRIORITY = [
1038
1151
  { family: "pnpm", files: ["pnpm-workspace.yaml", "pnpm-lock.yaml"] },
@@ -1113,7 +1226,7 @@ async function detectLockfilesAt(dir) {
1113
1226
  }
1114
1227
  async function fileExists(p) {
1115
1228
  try {
1116
- const st = await fs9.stat(p);
1229
+ const st = await fs10.stat(p);
1117
1230
  return st.isFile();
1118
1231
  } catch {
1119
1232
  return false;
@@ -1150,9 +1263,9 @@ function knownFamily(name) {
1150
1263
  // src/core/guards.ts
1151
1264
  import os2 from "os";
1152
1265
  import path10 from "path";
1153
- import fs10 from "fs/promises";
1266
+ import fs11 from "fs/promises";
1154
1267
  async function checkDeleteTarget(candidate) {
1155
- const st = await fs10.lstat(candidate).then(
1268
+ const st = await fs11.lstat(candidate).then(
1156
1269
  (value) => value,
1157
1270
  () => null
1158
1271
  );
@@ -1229,13 +1342,23 @@ function renderScanResult(result, options) {
1229
1342
  if (options.sizeMode === "total" || isSilent()) return renderScanTotals(result);
1230
1343
  return renderScanTable(result, options);
1231
1344
  }
1345
+ var BAR_CELLS = 12;
1346
+ function renderBar(size, max) {
1347
+ if (max <= 0 || size <= 0) return colors.dim("\xB7");
1348
+ const cells = Math.max(1, Math.round(size / max * BAR_CELLS));
1349
+ return colors.cyan("\u2588".repeat(Math.min(cells, BAR_CELLS)));
1350
+ }
1232
1351
  function renderScanTable(result, options) {
1352
+ const showIdle = result.entries.some((entry) => entry.idleDays !== void 0);
1353
+ const maxSize = result.entries.reduce((max, entry) => entry.nested === true ? max : Math.max(max, entry.size), 0);
1233
1354
  const table = new Table({
1234
1355
  head: [
1235
1356
  colors.dim("Path"),
1236
1357
  colors.dim("Size"),
1358
+ colors.dim("Share"),
1237
1359
  colors.dim("Pkgs"),
1238
1360
  colors.dim("PM"),
1361
+ ...showIdle ? [colors.dim("Idle")] : [],
1239
1362
  colors.dim("Modified")
1240
1363
  ],
1241
1364
  style: { head: [], border: colorEnabled() ? ["gray"] : [] }
@@ -1246,8 +1369,10 @@ function renderScanTable(result, options) {
1246
1369
  table.push([
1247
1370
  label,
1248
1371
  formatBytes(entry.size),
1372
+ nested ? colors.dim("\xB7") : renderBar(entry.size, maxSize),
1249
1373
  entry.packageCount > 0 ? String(entry.packageCount) : "0",
1250
1374
  pmLabel(entry.pm),
1375
+ ...showIdle ? [formatIdle(entry.idleDays)] : [],
1251
1376
  formatAge(entry.mtimeMs)
1252
1377
  ]);
1253
1378
  };
@@ -1291,6 +1416,8 @@ function renderScanJson(result) {
1291
1416
  pmProjectRoot: entry.pm.projectRoot,
1292
1417
  nested: entry.nested === true,
1293
1418
  ownerPath: entry.ownerPath ?? entry.path,
1419
+ // v1.1 additive — keys appear only when --stale/--sort idle ran (frozen-contract friendly)
1420
+ ...entry.idleDays !== void 0 ? { idleDays: entry.idleDays, activitySource: entry.activitySource } : {},
1294
1421
  modifiedAt: entry.mtimeMs == null ? null : new Date(entry.mtimeMs).toISOString()
1295
1422
  }))
1296
1423
  };
@@ -1348,8 +1475,13 @@ function renderDeleteJson(outcome, mode, dryRun) {
1348
1475
  status: row.status,
1349
1476
  size: row.size,
1350
1477
  sizeFormatted: formatBytes(row.size),
1351
- error: row.error ?? null
1352
- }))
1478
+ error: row.error ?? null,
1479
+ // v1.1 additive — trash backend per row, only when trash mode ran
1480
+ ...row.backend !== void 0 ? { backend: row.backend } : {}
1481
+ })),
1482
+ // v1.1 additive — nmh-internal trash batch dir / fallback flag, only when used
1483
+ ...outcome.trashBatch !== void 0 ? { trashBatch: outcome.trashBatch } : {},
1484
+ ...outcome.trashFallback === true ? { trashFallback: true } : {}
1353
1485
  };
1354
1486
  return JSON.stringify(payload2, null, 2);
1355
1487
  }
@@ -1989,7 +2121,8 @@ async function collectScan(request) {
1989
2121
  const walk = await scanForNodeModules(walkRoots, {
1990
2122
  maxDepth: request.maxDepth,
1991
2123
  recursive: request.recursive,
1992
- excludes: walkExcludes
2124
+ excludes: walkExcludes,
2125
+ skipSystem: request.skipSystem
1993
2126
  });
1994
2127
  const permIssues = [...walk.permissionIssues];
1995
2128
  collected.push(...walk.entries);
@@ -1999,6 +2132,7 @@ async function collectScan(request) {
1999
2132
  maxDepth: request.maxDepth,
2000
2133
  recursive: true,
2001
2134
  excludes: walkExcludes,
2135
+ skipSystem: request.skipSystem,
2002
2136
  initialOwner: direct
2003
2137
  });
2004
2138
  permIssues.push(...inner.permissionIssues);
@@ -2008,28 +2142,39 @@ async function collectScan(request) {
2008
2142
  const metaByPath = new Map(collected.map((entry) => [entry.path, entry]));
2009
2143
  const entryPaths = [...metaByPath.keys()];
2010
2144
  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
- });
2145
+ const measures = await collectMeasures(entryPaths, request.fastSize === true, permIssues);
2024
2146
  clearProgress();
2025
2147
  const detect = createPmDetector();
2026
2148
  const pmLimiter = createLimiter(8);
2027
2149
  const pmResults = await Promise.all(entryPaths.map((entryPath) => pmLimiter(() => detect(entryPath))));
2028
2150
  verboseLog(`scan finished in ${Date.now() - started} ms`);
2151
+ const activityByPath = /* @__PURE__ */ new Map();
2152
+ if (request.stale !== void 0 || request.sort === "idle") {
2153
+ const activityLimiter = createLimiter(8);
2154
+ const cache = /* @__PURE__ */ new Map();
2155
+ const activityFor = (entryPath) => {
2156
+ const startDir = path12.dirname(entryPath);
2157
+ let pending = cache.get(startDir);
2158
+ if (pending === void 0) {
2159
+ pending = computeActivity(entryPath);
2160
+ cache.set(startDir, pending);
2161
+ }
2162
+ return pending;
2163
+ };
2164
+ await Promise.all(
2165
+ entryPaths.map(
2166
+ (entryPath) => activityLimiter(async () => {
2167
+ const activity = await activityFor(entryPath);
2168
+ if (activity !== null) activityByPath.set(entryPath, activity);
2169
+ })
2170
+ )
2171
+ );
2172
+ }
2029
2173
  const cwd = request.cwd;
2030
2174
  let entries = entryPaths.map((entryPath, index) => {
2031
2175
  const measure = measures.get(entryPath) ?? { size: 0, packageCount: 0, mtimeMs: null };
2032
2176
  const meta = metaByPath.get(entryPath);
2177
+ const activity = activityByPath.get(entryPath);
2033
2178
  return {
2034
2179
  path: entryPath,
2035
2180
  displayPath: shortenPath(entryPath, cwd),
@@ -2037,11 +2182,19 @@ async function collectScan(request) {
2037
2182
  packageCount: measure.packageCount,
2038
2183
  pm: pmResults[index],
2039
2184
  mtimeMs: measure.mtimeMs,
2185
+ ...activity !== void 0 ? { idleDays: activity.idleDays, activitySource: activity.source } : {},
2040
2186
  nested: meta?.nested,
2041
2187
  ownerPath: meta?.ownerPath
2042
2188
  };
2043
2189
  });
2044
- entries.sort((a, b) => b.size - a.size);
2190
+ const cmpStr = (x, y) => x < y ? -1 : x > y ? 1 : 0;
2191
+ const comparators = {
2192
+ size: (a, b) => b.size - a.size || cmpStr(a.path, b.path),
2193
+ path: (a, b) => cmpStr(a.path, b.path) || b.size - a.size,
2194
+ idle: (a, b) => (b.idleDays ?? -1) - (a.idleDays ?? -1) || b.size - a.size,
2195
+ mtime: (a, b) => (b.mtimeMs ?? Number.NEGATIVE_INFINITY) - (a.mtimeMs ?? Number.NEGATIVE_INFINITY) || b.size - a.size
2196
+ };
2197
+ entries.sort(comparators[request.sort ?? "size"]);
2045
2198
  entries = await applyFilters(entries, request);
2046
2199
  const topEntries = entries.filter((entry) => !entry.nested);
2047
2200
  const totals = {
@@ -2065,6 +2218,59 @@ async function collectScan(request) {
2065
2218
  else if (permIssues.length > 0) code = 4;
2066
2219
  return { code, result };
2067
2220
  }
2221
+ async function collectMeasures(entryPaths, fastSize, permIssues) {
2222
+ const entryCount = entryPaths.length;
2223
+ let measuredCount = 0;
2224
+ let bytesSoFar = 0;
2225
+ const bump = () => {
2226
+ progressLine(`\u280B measuring ${measuredCount}/${entryCount} folders \xB7 ${formatBytes(bytesSoFar)}`);
2227
+ };
2228
+ if (!fastSize) {
2229
+ return measureEntries(entryPaths, permIssues, {
2230
+ concurrency: 4,
2231
+ onProgress: (progress) => {
2232
+ measuredCount = progress.measuredEntries;
2233
+ bytesSoFar = progress.bytesSoFar;
2234
+ bump();
2235
+ }
2236
+ });
2237
+ }
2238
+ const measures = /* @__PURE__ */ new Map();
2239
+ const duLimiter = createLimiter(8);
2240
+ const fallbackPaths = [];
2241
+ await Promise.all(
2242
+ entryPaths.map(
2243
+ (entryPath) => duLimiter(async () => {
2244
+ const fast = await measureEntryFast(entryPath);
2245
+ if (fast === null) {
2246
+ fallbackPaths.push(entryPath);
2247
+ return;
2248
+ }
2249
+ measures.set(entryPath, fast);
2250
+ measuredCount += 1;
2251
+ bytesSoFar += fast.size;
2252
+ bump();
2253
+ })
2254
+ )
2255
+ );
2256
+ verboseLog(
2257
+ `fast-size: ${measures.size} entr${measures.size === 1 ? "y" : "ies"} via du, ${fallbackPaths.length} via walker fallback`
2258
+ );
2259
+ if (fallbackPaths.length > 0) {
2260
+ const duBytes = bytesSoFar;
2261
+ const duCount = measures.size;
2262
+ const walked = await measureEntries(fallbackPaths, permIssues, {
2263
+ concurrency: 4,
2264
+ onProgress: (progress) => {
2265
+ progressLine(
2266
+ `\u280B measuring ${duCount + progress.measuredEntries}/${entryCount} folders \xB7 ${formatBytes(duBytes + progress.bytesSoFar)}`
2267
+ );
2268
+ }
2269
+ });
2270
+ for (const [entryPath, measure] of walked) measures.set(entryPath, measure);
2271
+ }
2272
+ return measures;
2273
+ }
2068
2274
  async function applyFilters(entries, request) {
2069
2275
  const wantsPm = request.pmFilter !== void 0 && request.pmFilter.length > 0;
2070
2276
  const wantedFamilies = new Set(request.pmFilter ?? []);
@@ -2073,12 +2279,15 @@ async function applyFilters(entries, request) {
2073
2279
  const excludeMatcher = request.excludes !== void 0 && request.excludes.length > 0 ? makeGlobMatcher(request.excludes) : null;
2074
2280
  const selfGuard = (request.excludes?.includes(SELF_TOKEN) ?? false) || (request.keep?.includes(SELF_TOKEN) ?? false);
2075
2281
  const guardRoot = selfGuard ? path12.resolve(request.cwd) : null;
2076
- if (!wantsPm && !keepMatcher && !excludeMatcher && !selfGuard) return entries;
2282
+ const wantsStale = request.stale !== void 0;
2283
+ const isStale = (entry) => wantsStale && entry.idleDays !== void 0 && entry.idleDays >= (request.stale ?? 0);
2284
+ if (!wantsPm && !keepMatcher && !excludeMatcher && !selfGuard && !wantsStale) return entries;
2077
2285
  const survivingTops = /* @__PURE__ */ new Set();
2078
2286
  for (const entry of entries) {
2079
2287
  if (entry.nested) continue;
2080
2288
  if (guardRoot !== null && insideRoot(entry.path, guardRoot)) continue;
2081
2289
  if (wantsPm && !matchPm(entry)) continue;
2290
+ if (wantsStale && !isStale(entry)) continue;
2082
2291
  if (excludeMatcher && excludeMatcher(entry.path)) continue;
2083
2292
  if (keepMatcher) {
2084
2293
  const name = await projectNameFor(entry.path);
@@ -2137,10 +2346,203 @@ function emptyResult(targets, unresolvable = []) {
2137
2346
  };
2138
2347
  }
2139
2348
 
2140
- // src/commands/config.ts
2141
- import fs11 from "fs/promises";
2349
+ // src/commands/update.ts
2350
+ import { promisify as promisify3 } from "util";
2351
+ import { execFile as execFile3 } from "child_process";
2352
+ import fs13 from "fs/promises";
2353
+
2354
+ // src/utils/update-check.ts
2355
+ import fs12 from "fs/promises";
2142
2356
  import path13 from "path";
2143
2357
  import { homedir as homedir6 } from "os";
2358
+ var UPDATE_CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
2359
+ function cacheFile() {
2360
+ return path13.join(homedir6(), ".nmh", "cache", "update-check.json");
2361
+ }
2362
+ function registryUrl() {
2363
+ return process.env.NMH_REGISTRY_JSON ?? "https://registry.npmjs.org/node-modules-hunter/latest";
2364
+ }
2365
+ function isNewer(current, latest) {
2366
+ const triple = (v) => {
2367
+ const match = /^(\d+)\.(\d+)\.(\d+)(.*)$/.exec(v.trim());
2368
+ return match === null ? null : [Number(match[1]), Number(match[2]), Number(match[3]), match[4] ?? ""];
2369
+ };
2370
+ const now = triple(current);
2371
+ const next = triple(latest);
2372
+ if (now === null || next === null) return false;
2373
+ for (let i = 0; i < 3; i += 1) {
2374
+ if (next[i] > now[i]) return true;
2375
+ if (next[i] < now[i]) return false;
2376
+ }
2377
+ return false;
2378
+ }
2379
+ function updateNote(latest, current) {
2380
+ return `update tersedia: ${current} \u2192 ${latest} \u2014 npm i -g node-modules-hunter (cek 24 jam sekali, opt-in via --update-check)`;
2381
+ }
2382
+ async function readFreshCache() {
2383
+ try {
2384
+ const parsed = JSON.parse(await fs12.readFile(cacheFile(), "utf8"));
2385
+ const age = Date.now() - new Date(parsed.checkedAt).getTime();
2386
+ if (Number.isFinite(age) && age >= 0 && age < UPDATE_CACHE_TTL_MS && typeof parsed.latest === "string") {
2387
+ return parsed.latest;
2388
+ }
2389
+ } catch {
2390
+ }
2391
+ return null;
2392
+ }
2393
+ async function writeCache(latest) {
2394
+ try {
2395
+ const entry = { checkedAt: (/* @__PURE__ */ new Date()).toISOString(), latest };
2396
+ await fs12.mkdir(path13.dirname(cacheFile()), { recursive: true });
2397
+ await fs12.writeFile(cacheFile(), `${JSON.stringify(entry, null, 2)}
2398
+ `, "utf8");
2399
+ } catch (err) {
2400
+ verboseLog(`update-cache write failed: ${err instanceof Error ? err.message : String(err)}`);
2401
+ }
2402
+ }
2403
+ function parseLatestVersion(payload2) {
2404
+ if (typeof payload2 !== "object" || payload2 === null) return null;
2405
+ const version = payload2.version;
2406
+ if (typeof version !== "string" || version.length === 0) return null;
2407
+ return tripleOf(version) ? version : null;
2408
+ }
2409
+ function tripleOf(version) {
2410
+ return /^\d+\.\d+\.\d+/.test(version.trim());
2411
+ }
2412
+ async function checkForUpdate() {
2413
+ try {
2414
+ const fresh = await readFreshCache();
2415
+ const latest = fresh ?? await fetchLatest();
2416
+ if (latest === null) return null;
2417
+ return isNewer(toolVersion(), latest) ? updateNote(latest, toolVersion()) : null;
2418
+ } catch (err) {
2419
+ verboseLog(`update check failed: ${err instanceof Error ? err.message : String(err)}`);
2420
+ return null;
2421
+ }
2422
+ }
2423
+ async function fetchLatest() {
2424
+ try {
2425
+ const response = await fetch(registryUrl(), { signal: AbortSignal.timeout(4e3) });
2426
+ if (!response.ok) {
2427
+ verboseLog(`update check: registry responded ${response.status}`);
2428
+ return null;
2429
+ }
2430
+ const latest = parseLatestVersion(await response.json());
2431
+ if (latest !== null) await writeCache(latest);
2432
+ return latest;
2433
+ } catch (err) {
2434
+ verboseLog(`update check: registry unreachable (${err instanceof Error ? err.message : String(err)})`);
2435
+ return null;
2436
+ }
2437
+ }
2438
+
2439
+ // src/commands/update.ts
2440
+ var execFileP3 = promisify3(execFile3);
2441
+ async function runNpm(args, timeoutMs) {
2442
+ const win32 = process.platform === "win32";
2443
+ try {
2444
+ const { stdout } = await execFileP3(win32 ? "npm.cmd" : "npm", args, {
2445
+ timeout: timeoutMs,
2446
+ maxBuffer: 1024 * 1024,
2447
+ shell: win32,
2448
+ windowsHide: true
2449
+ });
2450
+ return { code: 0, stdout, stderr: "" };
2451
+ } catch (err) {
2452
+ const failure = err;
2453
+ return { code: failure.code ?? 1, stdout: failure.stdout ?? "", stderr: failure.stderr ?? "" };
2454
+ }
2455
+ }
2456
+ var VERSION_SHAPE = /^\d+\.\d+\.\d+/;
2457
+ async function latestVersion() {
2458
+ const override = process.env.NMH_UPDATE_LATEST_JSON;
2459
+ if (typeof override === "string" && override.length > 0) {
2460
+ try {
2461
+ const parsed = JSON.parse(await fs13.readFile(override, "utf8"));
2462
+ return typeof parsed.version === "string" && VERSION_SHAPE.test(parsed.version) ? parsed.version : null;
2463
+ } catch (err) {
2464
+ verboseLog(`update: override file unreadable (${err instanceof Error ? err.message : String(err)})`);
2465
+ return null;
2466
+ }
2467
+ }
2468
+ const view = await runNpm(["view", "node-modules-hunter", "version"], 6e4);
2469
+ if (view.code !== 0) {
2470
+ verboseLog(`npm view failed (rc ${view.code}): ${view.stderr.trim().slice(0, 200)}`);
2471
+ return null;
2472
+ }
2473
+ const version = view.stdout.trim();
2474
+ return VERSION_SHAPE.test(version) ? version : null;
2475
+ }
2476
+ async function isNpmGlobal() {
2477
+ const forced = process.env.NMH_UPDATE_GLOBAL;
2478
+ if (forced === "1") return true;
2479
+ if (forced === "0") return false;
2480
+ const ls = await runNpm(["ls", "-g", "node-modules-hunter", "--depth=0"], 6e4);
2481
+ return ls.code === 0 && ls.stdout.includes("node-modules-hunter");
2482
+ }
2483
+ async function installLatest(version) {
2484
+ const installCommand = `npm install -g node-modules-hunter@${version}`;
2485
+ const fake = process.env.NMH_UPDATE_FAKE_INSTALL;
2486
+ if (typeof fake === "string" && fake.length > 0) {
2487
+ try {
2488
+ await fs13.writeFile(fake, `${installCommand}
2489
+ `, "utf8");
2490
+ return { ok: true, detail: installCommand };
2491
+ } catch (err) {
2492
+ return { ok: false, detail: err instanceof Error ? err.message : String(err) };
2493
+ }
2494
+ }
2495
+ progressLine("\u280B updating via npm\u2026");
2496
+ const install = await runNpm(["install", "-g", "node-modules-hunter@" + version], 3e5);
2497
+ clearProgress();
2498
+ if (install.code !== 0) {
2499
+ return { ok: false, detail: install.stderr.trim().slice(0, 300) || `npm exited ${install.code}` };
2500
+ }
2501
+ return { ok: true, detail: installCommand };
2502
+ }
2503
+ async function runUpdate(request) {
2504
+ const current = toolVersion();
2505
+ const latest = await latestVersion();
2506
+ if (latest === null) {
2507
+ const message = "cannot read the latest version \u2014 is the registry reachable? (npm view node-modules-hunter version)";
2508
+ if (request.json) errorLog(message);
2509
+ else errorLog(`\u26A0 ${message}`);
2510
+ return 1;
2511
+ }
2512
+ const global = await isNpmGlobal();
2513
+ const method = global ? "npm-global" : "manual";
2514
+ if (!isNewer(current, latest)) {
2515
+ const result2 = { current, latest, updated: false, method };
2516
+ if (request.json) payload(JSON.stringify(result2, null, 2));
2517
+ else log(`nmh ${current} sudah versi terbaru (registry: ${latest}) \u2014 tak ada yang diinstall`);
2518
+ return 0;
2519
+ }
2520
+ if (!global) {
2521
+ const result2 = { current, latest, updated: false, method };
2522
+ const advice = [
2523
+ `nmh ${current} terpasang di luar npm global \u2014 update manual:`,
2524
+ " npm i -g node-modules-hunter (atau: pnpm add -g / bunx / brew upgrade node-modules-hunter)"
2525
+ ];
2526
+ if (request.json) payload(JSON.stringify(result2, null, 2));
2527
+ else for (const line of advice) note(line);
2528
+ return 0;
2529
+ }
2530
+ const install = await installLatest(latest);
2531
+ if (!install.ok) {
2532
+ errorLog(`\u26A0 update gagal: ${install.detail}`);
2533
+ errorLog(` coba manual: npm install -g node-modules-hunter@${latest}`);
2534
+ return 1;
2535
+ }
2536
+ const result = { current, latest, updated: true, method: "npm-global" };
2537
+ if (request.json) payload(JSON.stringify(result, null, 2));
2538
+ else log(`\u2713 terupdate ${current} \u2192 ${latest} (npm install -g node-modules-hunter)`);
2539
+ return 0;
2540
+ }
2541
+
2542
+ // src/commands/config.ts
2543
+ import fs14 from "fs/promises";
2544
+ import path14 from "path";
2545
+ import { homedir as homedir7 } from "os";
2144
2546
  var VALID_MODES2 = /* @__PURE__ */ new Set(["hard", "soft", "trash"]);
2145
2547
  var KNOWN_KEYS = ["defaultMode", "depth", "exclude", "keep", "color", "animate", "updateCheck", "logDir"];
2146
2548
  var KEY_TO_FLAG = {
@@ -2218,9 +2620,9 @@ function parseBool(raw2, label) {
2218
2620
  function configWriteTarget(explicitFile, cwd) {
2219
2621
  if (explicitFile !== void 0 && explicitFile.length > 0) {
2220
2622
  const raw2 = expandTilde(explicitFile);
2221
- return path13.isAbsolute(raw2) ? raw2 : path13.resolve(cwd, raw2);
2623
+ return path14.isAbsolute(raw2) ? raw2 : path14.resolve(cwd, raw2);
2222
2624
  }
2223
- return path13.join(homedir6(), DEFAULT_TARGET);
2625
+ return path14.join(homedir7(), DEFAULT_TARGET);
2224
2626
  }
2225
2627
  function listMutation(op, flag, given) {
2226
2628
  const key = LIST_KEYS[flag];
@@ -2287,7 +2689,7 @@ function assemble(request) {
2287
2689
  async function readRawConfig(file) {
2288
2690
  let body;
2289
2691
  try {
2290
- body = await fs11.readFile(file, "utf8");
2692
+ body = await fs14.readFile(file, "utf8");
2291
2693
  } catch (err) {
2292
2694
  if (err.code === "ENOENT") return {};
2293
2695
  throw new ConfigError(`config unreadable: ${file} (${err.code ?? String(err)})`);
@@ -2334,8 +2736,8 @@ async function writeConfigMutation(file, mutation) {
2334
2736
  }
2335
2737
  const body = `${JSON.stringify(raw2, null, 2)}
2336
2738
  `;
2337
- await fs11.mkdir(path13.dirname(file), { recursive: true });
2338
- await fs11.writeFile(file, body, "utf8");
2739
+ await fs14.mkdir(path14.dirname(file), { recursive: true });
2740
+ await fs14.writeFile(file, body, "utf8");
2339
2741
  return applied;
2340
2742
  }
2341
2743
  var INFO_DEFAULTS = {
@@ -2426,7 +2828,7 @@ async function runConfigCommand(request) {
2426
2828
  }
2427
2829
 
2428
2830
  // src/commands/search.ts
2429
- import path14 from "path";
2831
+ import path15 from "path";
2430
2832
  async function collectSearch(request) {
2431
2833
  const scan = await collectScan({
2432
2834
  targets: request.targets,
@@ -2444,7 +2846,7 @@ async function collectSearch(request) {
2444
2846
  const outcomes = await Promise.all(
2445
2847
  scan.result.entries.map(
2446
2848
  (entry) => limiter(async () => {
2447
- const pathMatch = match(entry.path) || match(path14.relative(request.cwd, entry.path));
2849
+ const pathMatch = match(entry.path) || match(path15.relative(request.cwd, entry.path));
2448
2850
  const packages = await listTopLevelPackages(entry.path);
2449
2851
  const matched = packages.filter((pkg) => match(pkg.name));
2450
2852
  const matches = await Promise.all(
@@ -2497,11 +2899,11 @@ async function runSearch(request) {
2497
2899
  }
2498
2900
 
2499
2901
  // src/commands/why.ts
2500
- import path16 from "path";
2902
+ import path17 from "path";
2501
2903
 
2502
2904
  // src/core/lockfile.ts
2503
- import fs12 from "fs/promises";
2504
- import path15 from "path";
2905
+ import fs15 from "fs/promises";
2906
+ import path16 from "path";
2505
2907
  var ROOT = "\xABroot\xBB";
2506
2908
  var MAX_LOCKFILE_BYTES = 8 * 1024 * 1024;
2507
2909
  function nameOfPackageKey(key) {
@@ -2583,9 +2985,9 @@ function buildGraph(pm, file, parsed) {
2583
2985
  async function readNpmLockGraph(projectDir, file = "package-lock.json") {
2584
2986
  let text2;
2585
2987
  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");
2988
+ const stat2 = await fs15.stat(`${projectDir}/${file}`);
2989
+ if (!stat2.isFile() || stat2.size > MAX_LOCKFILE_BYTES) return null;
2990
+ text2 = await fs15.readFile(`${projectDir}/${file}`, "utf8");
2589
2991
  } catch {
2590
2992
  return null;
2591
2993
  }
@@ -2606,9 +3008,9 @@ async function readProjectLockGraph(projectDir) {
2606
3008
  async function readYarnLockGraph(projectDir) {
2607
3009
  let text2;
2608
3010
  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");
3011
+ const stat2 = await fs15.stat(path16.join(projectDir, "yarn.lock"));
3012
+ if (!stat2.isFile() || stat2.size > MAX_LOCKFILE_BYTES) return null;
3013
+ text2 = await fs15.readFile(path16.join(projectDir, "yarn.lock"), "utf8");
2612
3014
  } catch {
2613
3015
  return null;
2614
3016
  }
@@ -2616,7 +3018,7 @@ async function readYarnLockGraph(projectDir) {
2616
3018
  if (parsed === null) return null;
2617
3019
  let rootDirect = 0;
2618
3020
  try {
2619
- const manifest = JSON.parse(await fs12.readFile(path15.join(projectDir, "package.json"), "utf8"));
3021
+ const manifest = JSON.parse(await fs15.readFile(path16.join(projectDir, "package.json"), "utf8"));
2620
3022
  for (const field of ["dependencies", "devDependencies", "optionalDependencies"]) {
2621
3023
  const map = manifest[field];
2622
3024
  if (map === void 0 || typeof map !== "object") continue;
@@ -2751,9 +3153,9 @@ function nameOfSpecKey(key) {
2751
3153
  async function readPnpmLockGraph(projectDir) {
2752
3154
  let text2;
2753
3155
  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");
3156
+ const stat2 = await fs15.stat(path16.join(projectDir, "pnpm-lock.yaml"));
3157
+ if (!stat2.isFile() || stat2.size > MAX_LOCKFILE_BYTES) return null;
3158
+ text2 = await fs15.readFile(path16.join(projectDir, "pnpm-lock.yaml"), "utf8");
2757
3159
  } catch {
2758
3160
  return null;
2759
3161
  }
@@ -2810,9 +3212,9 @@ function collectPnpmNodes(section, edges, nodeNames) {
2810
3212
  async function readBunLockGraph(projectDir) {
2811
3213
  let text2;
2812
3214
  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");
3215
+ const stat2 = await fs15.stat(path16.join(projectDir, "bun.lock"));
3216
+ if (!stat2.isFile() || stat2.size > MAX_LOCKFILE_BYTES) return null;
3217
+ text2 = await fs15.readFile(path16.join(projectDir, "bun.lock"), "utf8");
2816
3218
  } catch {
2817
3219
  return null;
2818
3220
  }
@@ -2957,7 +3359,7 @@ async function runWhy(request) {
2957
3359
  if (target === void 0) return null;
2958
3360
  const declaredBy = await classify(entry.path, request.pkg);
2959
3361
  const targetVersion = await packageVersion(target.path);
2960
- const lock = await readProjectLockGraph(path16.dirname(entry.path));
3362
+ const lock = await readProjectLockGraph(path17.dirname(entry.path));
2961
3363
  let requiredBy;
2962
3364
  let requiredByGrade;
2963
3365
  if (lock !== null) {
@@ -3014,91 +3416,6 @@ async function runWhy(request) {
3014
3416
  return code;
3015
3417
  }
3016
3418
 
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
3419
  // src/commands/map.ts
3103
3420
  async function collectMap(request) {
3104
3421
  const scan = await collectScan({
@@ -3190,7 +3507,7 @@ async function runMap(request) {
3190
3507
  }
3191
3508
 
3192
3509
  // src/commands/treemap.ts
3193
- import fs14 from "fs/promises";
3510
+ import fs16 from "fs/promises";
3194
3511
  import path18 from "path";
3195
3512
 
3196
3513
  // src/core/treemap.ts
@@ -3421,8 +3738,8 @@ async function runTreemap(request) {
3421
3738
  }
3422
3739
  const outPath = path18.resolve(request.cwd, request.out);
3423
3740
  try {
3424
- await fs14.mkdir(path18.dirname(outPath), { recursive: true });
3425
- await fs14.writeFile(outPath, renderTreemapHtml(data), "utf8");
3741
+ await fs16.mkdir(path18.dirname(outPath), { recursive: true });
3742
+ await fs16.writeFile(outPath, renderTreemapHtml(data), "utf8");
3426
3743
  } catch (err) {
3427
3744
  errorLog(`\u{1F6D1} could not write the treemap file \u2192 ${outPath} \u2014 ${err instanceof Error ? err.message : String(err)}`);
3428
3745
  return 1;
@@ -3435,7 +3752,7 @@ async function runTreemap(request) {
3435
3752
  // src/core/deps-audit.ts
3436
3753
  import { glob as glob2 } from "tinyglobby";
3437
3754
  import path19 from "path";
3438
- import fs15 from "fs/promises";
3755
+ import fs17 from "fs/promises";
3439
3756
  var SOURCE_GLOB = "**/*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,svelte,astro}";
3440
3757
  var SOURCE_IGNORES = ["**/node_modules/**", "**/.git/**"];
3441
3758
  var MAX_FILES_PER_PROJECT = 2e3;
@@ -3503,9 +3820,9 @@ async function collectDepsAudit(projects, installedNames) {
3503
3820
  await limiter(async () => {
3504
3821
  let code;
3505
3822
  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");
3823
+ const stat2 = await fs17.stat(path19.join(project.dir, file));
3824
+ if (stat2.size > MAX_FILE_BYTES) return;
3825
+ code = await fs17.readFile(path19.join(project.dir, file), "utf8");
3509
3826
  } catch {
3510
3827
  return;
3511
3828
  }
@@ -3546,7 +3863,7 @@ async function collectDepsAudit(projects, installedNames) {
3546
3863
  import path21 from "path";
3547
3864
 
3548
3865
  // src/utils/registry-deprecated.ts
3549
- import fs16 from "fs/promises";
3866
+ import fs18 from "fs/promises";
3550
3867
  import path20 from "path";
3551
3868
  import os3 from "os";
3552
3869
  var MAX_REGISTRY_DEPRECATED_FETCHES = 25;
@@ -3562,7 +3879,7 @@ async function registryDeprecated(name, version, cacheDir, base = "https://regis
3562
3879
  if (name.length === 0 || version.length === 0) return null;
3563
3880
  const file = cachePath(cacheDir, name, version);
3564
3881
  try {
3565
- const cached2 = JSON.parse(await fs16.readFile(file, "utf8"));
3882
+ const cached2 = JSON.parse(await fs18.readFile(file, "utf8"));
3566
3883
  if (typeof cached2.checkedAt === "string") {
3567
3884
  const age = Date.now() - Date.parse(cached2.checkedAt);
3568
3885
  if (Number.isFinite(age) && age >= 0 && age < DEPRECATED_TTL_MS) {
@@ -3589,8 +3906,8 @@ async function registryDeprecated(name, version, cacheDir, base = "https://regis
3589
3906
  }
3590
3907
  async function writeDeprecatedCache(file, message) {
3591
3908
  try {
3592
- await fs16.mkdir(path20.dirname(file), { recursive: true });
3593
- await fs16.writeFile(file, JSON.stringify({ checkedAt: (/* @__PURE__ */ new Date()).toISOString(), message }), "utf8");
3909
+ await fs18.mkdir(path20.dirname(file), { recursive: true });
3910
+ await fs18.writeFile(file, JSON.stringify({ checkedAt: (/* @__PURE__ */ new Date()).toISOString(), message }), "utf8");
3594
3911
  } catch {
3595
3912
  }
3596
3913
  }
@@ -4094,13 +4411,20 @@ async function saveBaselineQuiet(name, snapshot) {
4094
4411
  import { confirm, select, isCancel } from "@clack/prompts";
4095
4412
 
4096
4413
  // src/core/deleter.ts
4097
- import fs17 from "fs/promises";
4414
+ import fs20 from "fs/promises";
4098
4415
 
4099
4416
  // src/utils/trash.ts
4100
- import { execFile } from "child_process";
4101
- import { promisify } from "util";
4102
- var execFileAsync = promisify(execFile);
4417
+ import { execFile as execFile4 } from "child_process";
4418
+ import { promisify as promisify4 } from "util";
4419
+ import fs19 from "fs/promises";
4420
+ import path22 from "path";
4421
+ import { homedir as homedir8 } from "os";
4422
+ var execFileAsync = promisify4(execFile4);
4103
4423
  async function trashBackend() {
4424
+ const forced = process.env.NMH_TRASH_BACKEND;
4425
+ if (forced === "windows-com" || forced === "macos-finder" || forced === "linux-gio" || forced === "linux-trash-cli" || forced === "none") {
4426
+ return forced;
4427
+ }
4104
4428
  switch (process.platform) {
4105
4429
  case "win32":
4106
4430
  return "windows-com";
@@ -4181,6 +4505,30 @@ async function commandExists(bin) {
4181
4505
  return false;
4182
4506
  }
4183
4507
  }
4508
+ async function createTrashBatch(home) {
4509
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
4510
+ const hex = Math.random().toString(16).slice(2, 5);
4511
+ const batchDir = path22.join(home ?? homedir8(), ".nmh", "trash", `trash-${stamp}-${hex}`);
4512
+ await fs19.mkdir(batchDir, { recursive: true });
4513
+ return batchDir;
4514
+ }
4515
+ async function nmhTrashMove(entryPath, batchDir) {
4516
+ const parent = path22.basename(path22.dirname(entryPath));
4517
+ const storedAs = `${parent}-node_modules-${Math.random().toString(16).slice(2, 5)}`;
4518
+ const destination = path22.join(batchDir, storedAs);
4519
+ try {
4520
+ await fs19.rename(entryPath, destination);
4521
+ } catch (err) {
4522
+ if (err?.code !== "EXDEV") throw err;
4523
+ await fs19.cp(entryPath, destination, { recursive: true, dereference: false, verbatimSymlinks: true });
4524
+ await fs19.rm(entryPath, { recursive: true, force: true });
4525
+ }
4526
+ return storedAs;
4527
+ }
4528
+ async function appendTrashManifest(batchDir, row) {
4529
+ await fs19.appendFile(path22.join(batchDir, "manifest.jsonl"), `${JSON.stringify(row)}
4530
+ `, "utf8");
4531
+ }
4184
4532
 
4185
4533
  // src/core/deleter.ts
4186
4534
  async function deleteEntries(entries, request) {
@@ -4188,39 +4536,76 @@ async function deleteEntries(entries, request) {
4188
4536
  const limiter = createLimiter(request.concurrency ?? 8);
4189
4537
  const rows = [];
4190
4538
  let done = 0;
4191
- const runOne = async (entry, attempt) => {
4539
+ const runOne = async (entry, attempt, backend) => {
4192
4540
  const failure = await attempt().catch(
4193
4541
  (err) => err instanceof Error ? err.message : String(err)
4194
4542
  );
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" };
4543
+ const row = failure ? { path: entry.path, size: entry.size, status: "failed", error: failure } : {
4544
+ path: entry.path,
4545
+ size: entry.size,
4546
+ status: request.mode === "hard" ? "deleted" : "trashed",
4547
+ ...backend !== void 0 ? { backend } : {}
4548
+ };
4196
4549
  rows.push(row);
4197
4550
  done += 1;
4198
4551
  request.onRow?.(row, done, total);
4199
4552
  };
4553
+ const outcome = { results: rows, freed: 0, deleted: 0, failed: 0, permissionDenied: false };
4200
4554
  if (request.mode === "hard") {
4201
4555
  await Promise.all(entries.map((entry) => limiter(() => runOne(entry, () => removeHard(entry.path)))));
4202
4556
  } 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
- );
4557
+ const trashMode = request.trashMode ?? "system";
4558
+ const systemBackend = trashMode === "system" ? await trashBackend() : null;
4559
+ const useNmhTrash = systemBackend === null || systemBackend === "none";
4560
+ if (useNmhTrash) {
4561
+ const via = request.trashMode === "nmh" ? "explicit" : "fallback";
4562
+ const batchDir = await createTrashBatch();
4563
+ outcome.trashBatch = batchDir;
4564
+ if (via === "fallback") outcome.trashFallback = true;
4565
+ await Promise.all(
4566
+ entries.map(
4567
+ (entry) => limiter(
4568
+ () => runOne(
4569
+ entry,
4570
+ async () => {
4571
+ const storedAs = await nmhTrashMove(entry.path, batchDir);
4572
+ await appendTrashManifest(batchDir, {
4573
+ originalPath: entry.path,
4574
+ storedAs,
4575
+ size: entry.size,
4576
+ trashedAt: (/* @__PURE__ */ new Date()).toISOString(),
4577
+ nmh: toolVersion(),
4578
+ via
4579
+ });
4580
+ return null;
4581
+ },
4582
+ "nmh"
4583
+ )
4584
+ )
4585
+ )
4586
+ );
4587
+ } else {
4588
+ const backend = systemBackend;
4589
+ await Promise.all(
4590
+ entries.map(
4591
+ (entry) => limiter(
4592
+ () => runOne(entry, () => moveToTrash([entry.path], backend).then(() => null), "system")
4593
+ )
4594
+ )
4595
+ );
4596
+ }
4210
4597
  }
4211
4598
  const completed = rows.filter((row) => row.status !== "failed");
4212
4599
  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
- };
4600
+ outcome.freed = completed.reduce((sum2, row) => sum2 + row.size, 0);
4601
+ outcome.deleted = completed.length;
4602
+ outcome.failed = failedRows.length;
4603
+ outcome.permissionDenied = failedRows.some((row) => /EACCES|EPERM/.test(row.error ?? ""));
4604
+ return outcome;
4220
4605
  }
4221
4606
  async function removeHard(target) {
4222
4607
  try {
4223
- await fs17.rm(target, { recursive: true, force: true, maxRetries: 3, retryDelay: 300 });
4608
+ await fs20.rm(target, { recursive: true, force: true, maxRetries: 3, retryDelay: 300 });
4224
4609
  return null;
4225
4610
  } catch (err) {
4226
4611
  const code = err?.code ?? "ERR";
@@ -4233,6 +4618,7 @@ async function removeHard(target) {
4233
4618
  async function executeAll(entries, request) {
4234
4619
  const outcome = await deleteEntries(entries, {
4235
4620
  mode: request.mode,
4621
+ trashMode: request.trashMode,
4236
4622
  onRow: (_row, done, total) => {
4237
4623
  progressLine(`\u{1F5D1} ${done}/${total} folders\u2026`);
4238
4624
  }
@@ -4242,13 +4628,22 @@ async function executeAll(entries, request) {
4242
4628
  else {
4243
4629
  const summary = renderDeleteSummary(outcome, request.mode);
4244
4630
  if (summary) log(summary);
4631
+ if (outcome.trashBatch !== void 0) {
4632
+ if (outcome.trashFallback === true) {
4633
+ note(`no OS trash backend (install gio or trash-cli) \u2014 fell back to the restorable nmh trash: ${outcome.trashBatch}`);
4634
+ } else {
4635
+ note(`restorable nmh trash: ${outcome.trashBatch} (manifest: manifest.jsonl \u2014 restore by moving folders back)`);
4636
+ }
4637
+ }
4245
4638
  }
4246
4639
  await writeDeleteLog({
4247
4640
  mode: request.mode,
4248
4641
  results: outcome.results,
4249
4642
  freed: outcome.freed,
4250
4643
  deleted: outcome.deleted,
4251
- failed: outcome.failed
4644
+ failed: outcome.failed,
4645
+ ...outcome.trashBatch !== void 0 ? { trashBatch: outcome.trashBatch } : {},
4646
+ ...outcome.trashFallback === true ? { trashFallback: true } : {}
4252
4647
  });
4253
4648
  const code = outcome.permissionDenied ? 4 : outcome.failed > 0 ? 1 : 0;
4254
4649
  return { code, outcome };
@@ -4264,7 +4659,9 @@ async function deleteFlow(request) {
4264
4659
  recursive: request.recursive,
4265
4660
  excludes: request.excludes,
4266
4661
  keep: request.keep,
4267
- pmFilter: request.pmFilter
4662
+ pmFilter: request.pmFilter,
4663
+ stale: request.stale,
4664
+ skipSystem: request.skipSystem
4268
4665
  });
4269
4666
  if (scan.result.unresolvable.length > 0) {
4270
4667
  for (const suspect of scan.result.unresolvable) {
@@ -4309,7 +4706,7 @@ async function deleteFlow(request) {
4309
4706
  return 1;
4310
4707
  }
4311
4708
  if (request.json) {
4312
- const { code: code2 } = await executeAll(safeEntries, { mode, json: true });
4709
+ const { code: code2 } = await executeAll(safeEntries, { mode, json: true, trashMode: request.trashMode });
4313
4710
  return code2;
4314
4711
  }
4315
4712
  if (!request.assumeYes) {
@@ -4324,7 +4721,7 @@ async function deleteFlow(request) {
4324
4721
  return 0;
4325
4722
  }
4326
4723
  }
4327
- const { code } = await executeAll(safeEntries, { mode, json: false });
4724
+ const { code } = await executeAll(safeEntries, { mode, json: false, trashMode: request.trashMode });
4328
4725
  return code;
4329
4726
  }
4330
4727
  async function resolveMode(requested, json, dryRun) {
@@ -4389,14 +4786,17 @@ async function runClean(request) {
4389
4786
  excludes: request.excludes,
4390
4787
  keep: request.keep,
4391
4788
  pmFilter: request.pmFilter,
4789
+ stale: request.stale,
4790
+ skipSystem: request.skipSystem,
4791
+ trashMode: request.trashMode,
4392
4792
  maxDepth: request.maxDepth,
4393
4793
  blockedMode: "filter"
4394
4794
  });
4395
4795
  }
4396
4796
 
4397
4797
  // src/commands/wizard.ts
4398
- import fs18 from "fs/promises";
4399
- import path22 from "path";
4798
+ import fs21 from "fs/promises";
4799
+ import path23 from "path";
4400
4800
  import { confirm as confirm2, isCancel as isCancel2, multiselect, outro, select as select2, text } from "@clack/prompts";
4401
4801
 
4402
4802
  // src/utils/banner.ts
@@ -4447,7 +4847,7 @@ var PROJECT_MANIFESTS = /* @__PURE__ */ new Set([
4447
4847
  ]);
4448
4848
  async function hasProjectManifest(dir) {
4449
4849
  try {
4450
- const names = await fs18.readdir(dir);
4850
+ const names = await fs21.readdir(dir);
4451
4851
  for (const name of names) {
4452
4852
  if (PROJECT_MANIFESTS.has(name)) return true;
4453
4853
  }
@@ -4459,13 +4859,13 @@ async function hasProjectManifest(dir) {
4459
4859
  async function resolveWizardScope(cwd) {
4460
4860
  const here = await scanForNodeModules([cwd], { maxDepth: WIZARD_DEPTH });
4461
4861
  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) {
4862
+ let dir = path23.dirname(cwd);
4863
+ for (let hop = 0; hop < WIZARD_MAX_CLIMB && dir !== path23.dirname(dir); hop += 1) {
4464
4864
  if (await hasProjectManifest(dir)) {
4465
4865
  const probe = await scanForNodeModules([dir], { maxDepth: WIZARD_DEPTH });
4466
4866
  if (probe.entries.length > 0) return { root: dir, from: cwd, climbed: true, scouted: true };
4467
4867
  }
4468
- dir = path22.dirname(dir);
4868
+ dir = path23.dirname(dir);
4469
4869
  }
4470
4870
  return { root: cwd, from: cwd, climbed: false, scouted: true };
4471
4871
  }
@@ -4651,11 +5051,11 @@ function sum(entries) {
4651
5051
  }
4652
5052
 
4653
5053
  // 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);
5054
+ import { execFile as execFile5 } from "child_process";
5055
+ import { promisify as promisify5 } from "util";
5056
+ import fs22 from "fs/promises";
5057
+ import path24 from "path";
5058
+ var execFileAsync2 = promisify5(execFile5);
4659
5059
  var PROBE_TIMEOUT = 8e3;
4660
5060
  async function probeViaCommand(cmd, argv) {
4661
5061
  try {
@@ -4678,7 +5078,7 @@ async function probeRoot(cmd, argv, shape, fallback) {
4678
5078
  }
4679
5079
  async function isDir(candidate) {
4680
5080
  try {
4681
- return (await fs19.stat(candidate)).isDirectory();
5081
+ return (await fs22.stat(candidate)).isDirectory();
4682
5082
  } catch {
4683
5083
  return false;
4684
5084
  }
@@ -4704,12 +5104,12 @@ async function inspectDeadBins(root, binDir, refs) {
4704
5104
  else if (binField && typeof binField === "object") binNames = Object.keys(binField);
4705
5105
  else continue;
4706
5106
  for (const binName of binNames) {
4707
- const link = path23.join(binDir, binName);
5107
+ const link = path24.join(binDir, binName);
4708
5108
  try {
4709
- const linkStat = await fs19.lstat(link);
5109
+ const linkStat = await fs22.lstat(link);
4710
5110
  if (!linkStat.isSymbolicLink()) continue;
4711
5111
  try {
4712
- await fs19.stat(link);
5112
+ await fs22.stat(link);
4713
5113
  } catch {
4714
5114
  issues.push({ name: ref.name, kind: "broken bin symlink", detail: binName });
4715
5115
  }
@@ -4722,40 +5122,40 @@ async function inspectDeadBins(root, binDir, refs) {
4722
5122
  }
4723
5123
  async function resolvePmRoots(pmFilter) {
4724
5124
  const npmFallback = {
4725
- root: path23.join(expandTilde("~"), "lib", "node_modules"),
4726
- binDir: path23.join(expandTilde("~"), "bin"),
5125
+ root: path24.join(expandTilde("~"), "lib", "node_modules"),
5126
+ binDir: path24.join(expandTilde("~"), "bin"),
4727
5127
  source: "fallback"
4728
5128
  };
4729
5129
  const pnpmFallback = await pnpmFallbackSpec();
4730
5130
  const yarnFallback = {
4731
- root: path23.join(expandTilde("~"), ".config", "yarn", "global", "node_modules"),
4732
- binDir: path23.join(expandTilde("~"), ".config", "yarn", "global", "bin"),
5131
+ root: path24.join(expandTilde("~"), ".config", "yarn", "global", "node_modules"),
5132
+ binDir: path24.join(expandTilde("~"), ".config", "yarn", "global", "bin"),
4733
5133
  source: "fallback"
4734
5134
  };
4735
5135
  const bunFallback = {
4736
- root: path23.join(expandTilde("~"), ".bun", "install", "global", "node_modules"),
4737
- binDir: path23.join(expandTilde("~"), ".bun", "bin"),
5136
+ root: path24.join(expandTilde("~"), ".bun", "install", "global", "node_modules"),
5137
+ binDir: path24.join(expandTilde("~"), ".bun", "bin"),
4738
5138
  source: "fallback"
4739
5139
  };
4740
5140
  const wanted = pmFilter ?? ["npm", "pnpm", "yarn", "bun"];
4741
5141
  const results = [];
4742
5142
  for (const pm of wanted) {
4743
5143
  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);
5144
+ 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
5145
  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);
5146
+ 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);
5147
+ 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
5148
  if (spec) results.push({ pm, spec });
4749
5149
  else verboseLog(`global: ${pm} \u2014 no global node_modules found`);
4750
5150
  }
4751
5151
  return results;
4752
5152
  }
4753
5153
  async function pnpmFallbackSpec() {
4754
- const base = path23.join(expandTilde("~"), ".local", "share", "pnpm", "global");
5154
+ const base = path24.join(expandTilde("~"), ".local", "share", "pnpm", "global");
4755
5155
  try {
4756
- const versions = (await fs19.readdir(base)).filter((name) => /^\d+$/.test(name)).sort((a, b) => Number(b) - Number(a));
5156
+ const versions = (await fs22.readdir(base)).filter((name) => /^\d+$/.test(name)).sort((a, b) => Number(b) - Number(a));
4757
5157
  for (const version of versions) {
4758
- const root = path23.join(base, version, "node_modules");
5158
+ const root = path24.join(base, version, "node_modules");
4759
5159
  if (await isDir(root)) return { root, binDir: null, source: "fallback" };
4760
5160
  }
4761
5161
  } catch {
@@ -4839,6 +5239,11 @@ cli.option("--dry-run", "Preview what would be deleted \u2014 nothing is touched
4839
5239
  cli.option("-y, --yes", "Answer yes to every prompt");
4840
5240
  cli.option("-f, --force", "Skip all confirmations (implies --yes)");
4841
5241
  cli.option("--size <mode>", "'total' (just aggregate) or 'each' (default); numeric top-N lands later");
5242
+ cli.option("--stale <days>", "v1.1: only projects idle >= <days> (git activity signal \u2014 last commit, else manifest, else folder mtime)");
5243
+ cli.option("--fast-size", "v1.1: measure with one `du -sk` per entry (KiB granularity) instead of walking every file");
5244
+ cli.option("--sort <key>", "v1.1: order results \u2014 size (default, desc) | path | idle (oldest project first) | mtime (newest first)");
5245
+ cli.option("--no-skip", "v1.1: audit everything \u2014 stop hiding .Trash*, Library/, and *.app dirs during discovery (default: hidden)");
5246
+ 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
5247
  cli.option("--json", "Machine-readable JSON output");
4843
5248
  cli.option("-q, --quiet", "Minimal output \u2014 pretty tables/notes mute, but the --json payload still prints");
4844
5249
  cli.option("--verbose", "Debug chatter on stderr");
@@ -4978,6 +5383,59 @@ async function main() {
4978
5383
  errorLog("--size given more than once \u2014 pass a single value (total | each)");
4979
5384
  return 1;
4980
5385
  }
5386
+ let stale;
5387
+ if (flags.stale !== void 0) {
5388
+ if (flags.stale === true) {
5389
+ errorLog("--stale needs a value \u2014 use --stale=<days> (e.g. --stale=90)");
5390
+ return 1;
5391
+ }
5392
+ if (Array.isArray(flags.stale)) {
5393
+ errorLog("--stale given more than once \u2014 pass a single value (days)");
5394
+ return 1;
5395
+ }
5396
+ const days = Number(flags.stale);
5397
+ if (!Number.isInteger(days) || days < 1 || days > 365e3) {
5398
+ errorLog(`invalid --stale: ${String(flags.stale)} (expected a positive whole number of days)`);
5399
+ return 1;
5400
+ }
5401
+ stale = days;
5402
+ }
5403
+ const fastSize = flags.fastSize === true || Array.isArray(flags.fastSize) && flags.fastSize.includes(true);
5404
+ let sort;
5405
+ if (flags.sort !== void 0) {
5406
+ if (flags.sort === true) {
5407
+ errorLog("--sort needs a value \u2014 use --sort=size | path | idle | mtime");
5408
+ return 1;
5409
+ }
5410
+ if (Array.isArray(flags.sort)) {
5411
+ errorLog("--sort given more than once \u2014 pass a single key");
5412
+ return 1;
5413
+ }
5414
+ const key = String(flags.sort);
5415
+ if (key !== "size" && key !== "path" && key !== "idle" && key !== "mtime") {
5416
+ errorLog(`invalid --sort: ${key} (expected size | path | idle | mtime)`);
5417
+ return 1;
5418
+ }
5419
+ sort = key;
5420
+ }
5421
+ const skipSystem = flags.skip !== false;
5422
+ let trashMode;
5423
+ if (flags.trash !== void 0) {
5424
+ if (flags.trash === true) {
5425
+ errorLog("--trash needs a value \u2014 use --trash=system or --trash=nmh");
5426
+ return 1;
5427
+ }
5428
+ if (Array.isArray(flags.trash)) {
5429
+ errorLog("--trash given more than once \u2014 pass a single mode");
5430
+ return 1;
5431
+ }
5432
+ const mode = String(flags.trash);
5433
+ if (mode !== "system" && mode !== "nmh") {
5434
+ errorLog(`invalid --trash: ${mode} (expected system | nmh)`);
5435
+ return 1;
5436
+ }
5437
+ trashMode = mode;
5438
+ }
4981
5439
  const deleted = normalizeDelete(flags.delete);
4982
5440
  if (deleted === "invalid") {
4983
5441
  errorLog(`invalid --delete: ${String(flags.delete)} (expected 'hard' | 'soft' | 'trash')`);
@@ -5023,7 +5481,7 @@ async function main() {
5023
5481
  }
5024
5482
  }
5025
5483
  if (reportPath !== null) {
5026
- const ext = path24.extname(reportPath).toLowerCase();
5484
+ const ext = path25.extname(reportPath).toLowerCase();
5027
5485
  if (!REPORT_EXTENSIONS.includes(ext)) {
5028
5486
  errorLog(`unknown report format: ${reportPath} (expected .json/.md/.txt)`);
5029
5487
  return 1;
@@ -5064,9 +5522,12 @@ async function main() {
5064
5522
  errorLog("--save-report rides along with scan \u2014 delete/clean runs are not scan reports");
5065
5523
  return 1;
5066
5524
  }
5525
+ if (trashMode !== void 0 && !deleted.given && subcommand !== "clean") {
5526
+ warn("--trash only matters for delete/clean runs \u2014 ignored for scans and read-only commands");
5527
+ }
5067
5528
  if (deleted.given || subcommand === "clean") {
5068
5529
  for (const candidate of targets.length > 0 ? targets : [""]) {
5069
- if (isRootPath(path24.resolve(cwd, candidate))) {
5530
+ if (isRootPath(path25.resolve(cwd, candidate))) {
5070
5531
  errorLog("refusing a delete op over the filesystem root \u2014 point --dir at the project directory you want cleaned");
5071
5532
  return 1;
5072
5533
  }
@@ -5084,12 +5545,23 @@ async function main() {
5084
5545
  pmFilter: pm.family !== null ? [pm.family] : void 0,
5085
5546
  listPm: pm.given && pm.family === null,
5086
5547
  expand,
5087
- saveReport: reportPath ?? void 0
5548
+ saveReport: reportPath ?? void 0,
5549
+ stale,
5550
+ fastSize,
5551
+ sort,
5552
+ skipSystem
5088
5553
  };
5089
5554
  if (subcommand === "version") {
5090
5555
  printVersion(json);
5091
5556
  return 0;
5092
5557
  }
5558
+ if (subcommand === "update") {
5559
+ if (deleted.given || rest.length > 0) {
5560
+ errorLog("update takes no arguments \u2014 plain `nmh update` (add --json for the payload)");
5561
+ return 1;
5562
+ }
5563
+ return runUpdate({ json });
5564
+ }
5093
5565
  if (subcommand === "config") {
5094
5566
  const ops = /* @__PURE__ */ new Set(["info", "set", "add", "remove", "unset"]);
5095
5567
  const opRaw = rest[0];
@@ -5355,10 +5827,17 @@ async function main() {
5355
5827
  excludes: excludes.length > 0 ? excludes : void 0,
5356
5828
  keep: keep.length > 0 ? keep : void 0,
5357
5829
  pmFilter: pm.family !== null ? [pm.family] : void 0,
5358
- maxDepth: effectiveDepth
5830
+ maxDepth: effectiveDepth,
5831
+ stale,
5832
+ skipSystem,
5833
+ trashMode
5359
5834
  });
5360
5835
  }
5361
5836
  if (deleted.given) {
5837
+ if (trashMode !== void 0 && deleted.mode === "hard") {
5838
+ errorLog("--trash applies to trash mode \u2014 hard delete is permanent by design, there is no trash to choose");
5839
+ return 1;
5840
+ }
5362
5841
  return runDelete({
5363
5842
  targets,
5364
5843
  cwd,
@@ -5370,7 +5849,10 @@ async function main() {
5370
5849
  excludes: excludes.length > 0 ? excludes : void 0,
5371
5850
  keep: keep.length > 0 ? keep : void 0,
5372
5851
  pmFilter: pm.family !== null ? [pm.family] : void 0,
5373
- maxDepth: effectiveDepth
5852
+ maxDepth: effectiveDepth,
5853
+ stale,
5854
+ skipSystem,
5855
+ trashMode
5374
5856
  });
5375
5857
  }
5376
5858
  if (positionals.length > 0 || dirTargets.length > 0) {