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.
- package/README.md +24 -4
- package/dist/cli.js +954 -227
- 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
|
|
5
|
+
import path25 from "path";
|
|
6
|
+
import { existsSync } from "fs";
|
|
6
7
|
|
|
7
8
|
// src/utils/logger.ts
|
|
8
9
|
import pc from "picocolors";
|
|
@@ -82,11 +83,38 @@ function progressAllowed() {
|
|
|
82
83
|
if (state.progress === "on") return true;
|
|
83
84
|
return state.isTTY;
|
|
84
85
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
86
|
+
var SPIN_FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
|
|
87
|
+
var SPIN_MS = 70;
|
|
88
|
+
var spinTimer = null;
|
|
89
|
+
var spinFrame = 0;
|
|
90
|
+
var spinMsg = "";
|
|
91
|
+
function spinPaint() {
|
|
92
|
+
process.stdout.write(CLEAR_LINE + colors.dim(`${SPIN_FRAMES[spinFrame]} ${spinMsg}`));
|
|
93
|
+
}
|
|
94
|
+
function startSpin() {
|
|
95
|
+
spinFrame = 0;
|
|
96
|
+
spinPaint();
|
|
97
|
+
const timer = setInterval(() => {
|
|
98
|
+
spinFrame = (spinFrame + 1) % SPIN_FRAMES.length;
|
|
99
|
+
spinPaint();
|
|
100
|
+
}, SPIN_MS);
|
|
101
|
+
spinTimer = timer;
|
|
102
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
103
|
+
}
|
|
104
|
+
function spinUpdate(message) {
|
|
105
|
+
spinMsg = message;
|
|
106
|
+
if (state.quiet || !progressAllowed() || spinTimer !== null) return;
|
|
107
|
+
if (!state.isTTY) {
|
|
108
|
+
spinPaint();
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
startSpin();
|
|
88
112
|
}
|
|
89
|
-
function
|
|
113
|
+
function spinClear() {
|
|
114
|
+
if (spinTimer !== null) {
|
|
115
|
+
clearInterval(spinTimer);
|
|
116
|
+
spinTimer = null;
|
|
117
|
+
}
|
|
90
118
|
if (state.quiet || !progressAllowed()) return;
|
|
91
119
|
process.stdout.write(CLEAR_LINE);
|
|
92
120
|
}
|
|
@@ -216,6 +244,165 @@ function renderVersionDetail(detail) {
|
|
|
216
244
|
].join("\n");
|
|
217
245
|
}
|
|
218
246
|
|
|
247
|
+
// src/utils/help.ts
|
|
248
|
+
var COMMANDS = [
|
|
249
|
+
{ usage: "nmh", desc: "interactive wizard: arrow menu (delete all / pick / search / map / report / settings)" },
|
|
250
|
+
{ usage: "nmh <path> [path\u2026]", desc: "shortcut: scan given path(s); one-liner actions via flags (--delete, --search, \u2026)" },
|
|
251
|
+
{ usage: "nmh scan [dir]", desc: "full scan across targets (default: cwd)" },
|
|
252
|
+
{ usage: "nmh clean [dir]", desc: "scan + delete everything safe (trash by default)" },
|
|
253
|
+
{ usage: "nmh search <pattern> [dir\u2026]", desc: "find installed packages by name (+ versions & sizes) \u2014 read-only" },
|
|
254
|
+
{ usage: "nmh map [dir]", desc: "project \u2192 PM \u2192 size \u2192 top packages \u2192 declared deps \u2014 read-only" },
|
|
255
|
+
{ usage: "nmh stats [dir]", desc: "size analytics: dup factor, waste, top-N, per-package tree (aliases: analyze, info)" },
|
|
256
|
+
{ usage: "nmh why <pkg> [dir\u2026]", desc: "reverse depend: where is <pkg> and who requires it \u2014 read-only" },
|
|
257
|
+
{ usage: "nmh treemap [dir]", desc: "single-file interactive HTML treemap \u2192 nmh-treemap.html" },
|
|
258
|
+
{ usage: "nmh stats -g", desc: "global installs across npm/pnpm/yarn/bun \u2014 read-only" },
|
|
259
|
+
{ usage: "nmh config <op>", desc: "manage .nmhrc from the CLI: info \xB7 set \xB7 add \xB7 remove \xB7 unset" },
|
|
260
|
+
{ usage: "nmh update", desc: "self-update: compare installed vs npm latest, install if global" },
|
|
261
|
+
{ usage: "nmh version", desc: "banner: tool version, node+abi, OS/arch/kernel, tty (alias: -v / --version)" },
|
|
262
|
+
{ usage: "nmh help", desc: "this help (alias: -h / --help)" }
|
|
263
|
+
];
|
|
264
|
+
var STAT_SUBCOMMANDS = [
|
|
265
|
+
{ usage: "stats --by type [dir]", desc: "breakdown per dependency field (dep/dev/optional/peer/transitive)" },
|
|
266
|
+
{ usage: "stats --licenses [dir]", desc: "license buckets (+ \u26A0 copyleft inside production deps)" },
|
|
267
|
+
{ usage: "stats --score / --co2", desc: "experimental: composite bloat score 0-100 / rough CO2 estimate" },
|
|
268
|
+
{ usage: "stats --graph [dir]", desc: "dependency graph from the lockfile \u2014 npm/yarn/pnpm/bun, no extra deps" },
|
|
269
|
+
{ usage: "stats --reinstall [dir]", desc: 'naive npm-ci-style "how long would reinstalling everything take"' }
|
|
270
|
+
];
|
|
271
|
+
var GROUPS = [
|
|
272
|
+
{
|
|
273
|
+
title: "Actions",
|
|
274
|
+
flags: [
|
|
275
|
+
{ usage: "--delete [mode]", desc: "delete found folders: hard | trash (soft alias) \u2014 no value = pick interactively" },
|
|
276
|
+
{ usage: "--trash system|nmh", desc: "v1.1: whose trash takes victims \u2014 system backend (default) or restorable ~/.nmh/trash" },
|
|
277
|
+
{ usage: "--dry-run", desc: "preview what would be deleted \u2014 nothing is touched" },
|
|
278
|
+
{ usage: "-y, --yes", desc: "answer yes to every prompt (required for non-TTY deletes)" },
|
|
279
|
+
{ usage: "-f, --force", desc: "skip all confirmations (implies --yes)" },
|
|
280
|
+
{ usage: "--search <pattern>", desc: "search installed packages (substring, glob, or /regex/) \u2014 read-only" },
|
|
281
|
+
{ usage: "-m, --map", desc: "map projects: PM, size, top packages, declared deps \u2014 read-only" },
|
|
282
|
+
{ usage: "--depend [pkg]", desc: "map only: show declared deps; with a value, list who declares it" },
|
|
283
|
+
{ usage: "--tree", desc: "map/stats only: list per-project packages (tree view, with sizes)" },
|
|
284
|
+
{ usage: "--why <pkg>", desc: "read-only: explain an installed package \u2014 instances and requirers" },
|
|
285
|
+
{ usage: "--stats", desc: "stats report: overview + dup factor + waste + top sizes \u2014 read-only" },
|
|
286
|
+
{ usage: "--treemap [file]", desc: "treemap export: single-file HTML (default nmh-treemap.html; --out is an alias)" },
|
|
287
|
+
{ usage: "-d, --dir <paths>", desc: "target dir(s), comma separated or repeated; globs and ~ allowed" },
|
|
288
|
+
{ usage: "--size <mode>", desc: "output shape: 'total' (just aggregate) or 'each' (default: per-folder table)" },
|
|
289
|
+
{ usage: "--save-report <file>", desc: "also write the printed report to a file (.json/.md/.txt; --report <file> aliases it)" },
|
|
290
|
+
{ usage: "--update-check", desc: "peek npm for a newer release (non-blocking, 24 h cache; also settable in .nmhrc)" }
|
|
291
|
+
]
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
title: "Stale hunting & output shape (v1.1)",
|
|
295
|
+
flags: [
|
|
296
|
+
{ usage: "--stale <days>", desc: "keep only projects idle >= <days>; ladder = git commit \u2192 manifest \u2192 folder mtime; no evidence = dropped" },
|
|
297
|
+
{ usage: "--fast-size", desc: "measure with one `du -sk` per entry (KiB granularity) instead of walking every file" },
|
|
298
|
+
{ usage: "--sort <key>", desc: "order results: size (default, desc) | path | idle (oldest project first) | mtime (newest first)" },
|
|
299
|
+
{ usage: "--no-skip", desc: "stop hiding system noise (.Trash*, Library/, *.app) during discovery \u2014 default: hidden" },
|
|
300
|
+
{ usage: "--animate / --no-animate", desc: "live size counter while measuring: auto = real TTY only" },
|
|
301
|
+
{ usage: "--json", desc: "machine-readable payload (pipe into jq); additive-only keys \u2014 v1.0 keys stay frozen" },
|
|
302
|
+
{ usage: "-q, --quiet", desc: "minimal output \u2014 pretty tables/notes mute, the --json payload still prints" },
|
|
303
|
+
{ usage: "--verbose", desc: "debug chatter on stderr" },
|
|
304
|
+
{ usage: "--no-color", desc: "disable ANSI colors (auto: colors only on a real TTY or with --color / FORCE_COLOR)" },
|
|
305
|
+
{ usage: "--color", desc: "force ANSI colors even on a piped run (wins over a config color:false)" }
|
|
306
|
+
]
|
|
307
|
+
},
|
|
308
|
+
{
|
|
309
|
+
title: "Scoping & filters",
|
|
310
|
+
flags: [
|
|
311
|
+
{ usage: "-r, --recursive", desc: "also look INSIDE found node_modules (nested audit; totals stay top-level only)" },
|
|
312
|
+
{ usage: "--pm [family]", desc: "filter by detected PM: npm|pnpm|yarn|bun|vp \u2014 no value lists what was detected" },
|
|
313
|
+
{ usage: "--exclude <globs>", desc: "exclude paths from results (comma list of globs, e.g. **/e2e/**)" },
|
|
314
|
+
{ usage: "--keep <pkgs>", desc: 'refuse to delete node_modules of these projects (comma list, glob ok; "self" works)' },
|
|
315
|
+
{ usage: "--depth <n>", desc: "max scan depth in levels below the target" },
|
|
316
|
+
{ usage: "--expand", desc: "full absolute paths + extra detail in output" }
|
|
317
|
+
]
|
|
318
|
+
},
|
|
319
|
+
{
|
|
320
|
+
title: "Stats suite",
|
|
321
|
+
flags: [
|
|
322
|
+
{ usage: "--top <n>", desc: "rows in the focused size list (default 10)" },
|
|
323
|
+
{ usage: "--by <mode>", desc: "breakdown mode \u2014 'size' (alias: package), 'type', 'licenses'" },
|
|
324
|
+
{ usage: "-g, --global", desc: "analyze global installs (per-PM prefix scan; read-only)" },
|
|
325
|
+
{ usage: "--config <file>", desc: "use this config file instead of the .nmhrc lookup (cwd \u2192 home)" },
|
|
326
|
+
{ usage: "--save-baseline <name>", desc: "save a metadata snapshot of this run to ~/.nmh/baselines/<name>.json" },
|
|
327
|
+
{ usage: "--compare <name>", desc: "diff this run against a stored baseline (totals, per-folder, package movers)" },
|
|
328
|
+
{ usage: "--history", desc: "list stored baselines \u2014 no scan at all" },
|
|
329
|
+
{ usage: "--phantom", desc: "stats only: imports found in sources but declared nowhere (best-effort regex audit)" },
|
|
330
|
+
{ usage: "--dead", desc: "stats only: installed + declared but never imported (best-effort regex audit)" },
|
|
331
|
+
{ usage: "--licenses", desc: "stats only: bucket instances by license field; flags copyleft in production deps" },
|
|
332
|
+
{ usage: "--score", desc: "stats only: experimental composite bloat score 0-100" },
|
|
333
|
+
{ usage: "--co2", desc: "stats only: experimental carbon estimate (storage-year + one redownload)" },
|
|
334
|
+
{ usage: "--online", desc: "stats only: deprecated scan \u2014 offline manifests + lazy cached registry fallback" },
|
|
335
|
+
{ usage: "--graph", desc: "stats only: dependency graph from the lockfile (npm/yarn/pnpm/bun) with BFS depth" },
|
|
336
|
+
{ usage: "--reinstall", desc: "stats only: naive npm-ci-style estimate of the time to reinstall everything" },
|
|
337
|
+
{ usage: "--out <file>", desc: "treemap only: where to write the HTML file (same as --treemap <file>)" }
|
|
338
|
+
]
|
|
339
|
+
},
|
|
340
|
+
{
|
|
341
|
+
title: "Config family",
|
|
342
|
+
flags: [
|
|
343
|
+
{ usage: "--default-exclude <globs>", desc: 'manage the persisted exclude list (comma list; special value "self")' },
|
|
344
|
+
{ usage: "--default-keep <pkgs>", desc: 'manage the persisted keep list (comma list; special value "self")' },
|
|
345
|
+
{ usage: "--default-mode <mode>", desc: "set the persisted delete default (hard | soft | trash)" },
|
|
346
|
+
{ usage: "--log-dir <dir>", desc: "set the persisted recovery-log directory (logDir)" }
|
|
347
|
+
]
|
|
348
|
+
}
|
|
349
|
+
];
|
|
350
|
+
var EXAMPLES = [
|
|
351
|
+
["nmh", "wizard; the no-args menu auto-climbs to the nearest project root"],
|
|
352
|
+
["nmh --dir=./proj,~/code/*/node_modules", "multi-target scan"],
|
|
353
|
+
["nmh . --stale 90 --delete=trash -y", "bury the stale boneyard, restorable trash"],
|
|
354
|
+
["nmh why react --expand", "reverse-depend: who declares + who requires"],
|
|
355
|
+
["nmh stats --by type --json", "dep-field breakdown as a machine payload"],
|
|
356
|
+
["nmh stats -g --top 20", "the 20 biggest global installs across PMs"],
|
|
357
|
+
["nmh config set default-mode trash", "persist a default delete mode in .nmhrc"]
|
|
358
|
+
];
|
|
359
|
+
var flagWidth = (groups) => {
|
|
360
|
+
let width = 0;
|
|
361
|
+
for (const group of groups) for (const item of group.flags) width = Math.max(width, item.usage.length);
|
|
362
|
+
return width;
|
|
363
|
+
};
|
|
364
|
+
var commandWidth = (items) => {
|
|
365
|
+
let width = 0;
|
|
366
|
+
for (const item of items) width = Math.max(width, item.usage.length);
|
|
367
|
+
return width;
|
|
368
|
+
};
|
|
369
|
+
function renderSection(title, items, width) {
|
|
370
|
+
const lines = [colors.bold(title)];
|
|
371
|
+
for (const item of items) {
|
|
372
|
+
lines.push(colors.cyan(item.usage.padEnd(width + 2)) + colors.dim(item.desc));
|
|
373
|
+
}
|
|
374
|
+
return lines;
|
|
375
|
+
}
|
|
376
|
+
function renderHelp() {
|
|
377
|
+
const lines = [];
|
|
378
|
+
lines.push(colors.bold("nmh") + colors.dim(" \u2014 hunt bloated node_modules folders: scan, measure, clean safely (npm/pnpm/yarn/bun)"));
|
|
379
|
+
lines.push("");
|
|
380
|
+
lines.push(colors.bold("Usage"));
|
|
381
|
+
lines.push(` ${colors.cyan("nmh <command|targets> [options]")}`);
|
|
382
|
+
lines.push("");
|
|
383
|
+
lines.push(...renderSection("Commands", COMMANDS, commandWidth(COMMANDS)));
|
|
384
|
+
lines.push("");
|
|
385
|
+
lines.push(...renderSection("Stats subcommands", STAT_SUBCOMMANDS, Math.max(commandWidth(STAT_SUBCOMMANDS), commandWidth(COMMANDS))));
|
|
386
|
+
lines.push("");
|
|
387
|
+
const width = flagWidth(GROUPS);
|
|
388
|
+
for (const group of GROUPS) {
|
|
389
|
+
lines.push(...renderSection(`Options \u2014 ${group.title}`, group.flags, width));
|
|
390
|
+
lines.push("");
|
|
391
|
+
}
|
|
392
|
+
lines.push(colors.bold("Examples"));
|
|
393
|
+
for (const [cmd, note2] of EXAMPLES) {
|
|
394
|
+
lines.push(` ${colors.cyan(cmd.padEnd(40))}${colors.dim(note2)}`);
|
|
395
|
+
}
|
|
396
|
+
lines.push("");
|
|
397
|
+
lines.push(colors.dim("Safety first: guards refuse dangerous targets (exit 3), deletes confirm unless --yes/--force, symlinks never get followed."));
|
|
398
|
+
lines.push(colors.dim("Exit codes: 0 ok \xB7 1 usage error \xB7 2 nothing found \xB7 3 guard refused \xB7 4 permission denied."));
|
|
399
|
+
lines.push(colors.dim("Everything is documented in depth at: https://github.com/jhedev96/node-modules-hunter"));
|
|
400
|
+
return lines.join("\n") + "\n";
|
|
401
|
+
}
|
|
402
|
+
function printHelp() {
|
|
403
|
+
process.stdout.write(renderHelp());
|
|
404
|
+
}
|
|
405
|
+
|
|
219
406
|
// src/utils/future-flags.ts
|
|
220
407
|
var FUTURE_FLAGS = /* @__PURE__ */ new Set([
|
|
221
408
|
"empty"
|
|
@@ -329,6 +516,17 @@ async function loadConfig(explicitPath, cwd) {
|
|
|
329
516
|
if (current === home || current === path2.dirname(current)) break;
|
|
330
517
|
current = path2.dirname(current);
|
|
331
518
|
}
|
|
519
|
+
const homeChain = cwd === home || cwd.startsWith(home + path2.sep);
|
|
520
|
+
if (!homeChain) {
|
|
521
|
+
for (const name of FILE_NAMES) {
|
|
522
|
+
const candidate = path2.join(home, name);
|
|
523
|
+
const body = await readMaybe(candidate);
|
|
524
|
+
if (body !== null) {
|
|
525
|
+
verboseLog(`config: found ${candidate}`);
|
|
526
|
+
return { values: parseConfigBody(body, candidate), source: candidate };
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
332
530
|
return { values: {}, source: null };
|
|
333
531
|
}
|
|
334
532
|
|
|
@@ -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
|
|
1217
|
+
const stat2 = await fs8.lstat(entryPath).then(
|
|
1004
1218
|
(value) => value,
|
|
1005
1219
|
() => null
|
|
1006
1220
|
);
|
|
1007
|
-
return { size, packageCount, mtimeMs:
|
|
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/
|
|
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
|
|
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
|
|
1464
|
+
import fs11 from "fs/promises";
|
|
1154
1465
|
async function checkDeleteTarget(candidate) {
|
|
1155
|
-
const st = await
|
|
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
|
|
2012
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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/
|
|
2141
|
-
import
|
|
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
|
|
2821
|
+
return path14.isAbsolute(raw2) ? raw2 : path14.resolve(cwd, raw2);
|
|
2222
2822
|
}
|
|
2223
|
-
return
|
|
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
|
|
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
|
|
2338
|
-
await
|
|
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
|
|
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(
|
|
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
|
-
|
|
3068
|
+
spinUpdate(`searching ${searched}/${entryCount} node_modules\u2026`);
|
|
2469
3069
|
return { entry, pathMatch, matches };
|
|
2470
3070
|
})
|
|
2471
3071
|
)
|
|
2472
3072
|
);
|
|
2473
|
-
|
|
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
|
|
3100
|
+
import path17 from "path";
|
|
2501
3101
|
|
|
2502
3102
|
// src/core/lockfile.ts
|
|
2503
|
-
import
|
|
2504
|
-
import
|
|
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
|
|
2587
|
-
if (!
|
|
2588
|
-
text2 = await
|
|
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
|
|
2610
|
-
if (!
|
|
2611
|
-
text2 = await
|
|
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
|
|
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
|
|
2755
|
-
if (!
|
|
2756
|
-
text2 = await
|
|
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
|
|
2814
|
-
if (!
|
|
2815
|
-
text2 = await
|
|
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(
|
|
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
|
-
|
|
3657
|
+
spinUpdate(`mapping ${mapped}/${entryCount} projects\u2026`);
|
|
3143
3658
|
return { entry, project, topPackages, declared };
|
|
3144
3659
|
})
|
|
3145
3660
|
)
|
|
3146
3661
|
);
|
|
3147
|
-
|
|
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
|
|
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
|
|
3425
|
-
await
|
|
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
|
|
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
|
|
3507
|
-
if (
|
|
3508
|
-
code = await
|
|
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
|
|
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
|
|
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
|
|
3593
|
-
await
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 } : {
|
|
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
|
|
4204
|
-
|
|
4205
|
-
|
|
4206
|
-
|
|
4207
|
-
|
|
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
|
-
|
|
4214
|
-
|
|
4215
|
-
|
|
4216
|
-
|
|
4217
|
-
|
|
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
|
|
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
|
-
|
|
4821
|
+
spinUpdate(`\u{1F5D1} ${done}/${total} folders\u2026`);
|
|
4238
4822
|
}
|
|
4239
4823
|
});
|
|
4240
|
-
|
|
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
|
|
4399
|
-
import
|
|
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
|
|
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 =
|
|
4463
|
-
for (let hop = 0; hop < WIZARD_MAX_CLIMB && dir !==
|
|
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 =
|
|
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
|
|
4655
|
-
import { promisify as
|
|
4656
|
-
import
|
|
4657
|
-
import
|
|
4658
|
-
var execFileAsync2 =
|
|
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
|
-
|
|
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
|
|
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 =
|
|
5319
|
+
const link = path24.join(binDir, binName);
|
|
4708
5320
|
try {
|
|
4709
|
-
const linkStat = await
|
|
5321
|
+
const linkStat = await fs22.lstat(link);
|
|
4710
5322
|
if (!linkStat.isSymbolicLink()) continue;
|
|
4711
5323
|
try {
|
|
4712
|
-
await
|
|
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:
|
|
4726
|
-
binDir:
|
|
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:
|
|
4732
|
-
binDir:
|
|
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:
|
|
4737
|
-
binDir:
|
|
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:
|
|
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:
|
|
4747
|
-
else if (pm === "bun") spec = await probeRoot("bun", ["pm", "-g"], (dir) => ({ root: dir, binDir:
|
|
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 =
|
|
5366
|
+
const base = path24.join(expandTilde("~"), ".local", "share", "pnpm", "global");
|
|
4755
5367
|
try {
|
|
4756
|
-
const versions = (await
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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(
|
|
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) {
|