@lexmanh/shed-cli 0.2.0-beta.9 → 0.3.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/cli.js +227 -172
  2. package/package.json +2 -2
package/dist/cli.js CHANGED
@@ -5,15 +5,50 @@ import { createRequire as createRequire2 } from "module";
5
5
  import { Command } from "commander";
6
6
 
7
7
  // src/commands/clean.ts
8
- import { resolve } from "path";
9
- import * as p from "@clack/prompts";
8
+ import * as p2 from "@clack/prompts";
10
9
  import {
11
10
  RiskTier,
12
11
  SafetyChecker,
13
12
  Scanner,
14
13
  defaultDetectors
15
14
  } from "@lexmanh/shed-core";
15
+ import pc2 from "picocolors";
16
+
17
+ // src/scope-resolver.ts
18
+ import { resolve } from "path";
19
+ import * as p from "@clack/prompts";
20
+ import { resolveScopeRoots } from "@lexmanh/shed-core";
16
21
  import pc from "picocolors";
22
+ async function resolveScanScope(opts = {}) {
23
+ const explicit = opts.path !== void 0 ? resolve(opts.path) : void 0;
24
+ const result = await resolveScopeRoots({ explicit });
25
+ if (result.ok) return { ok: true, roots: result.roots };
26
+ if (result.reason !== "no-default-roots" || opts.nonInteractive) {
27
+ return { ok: false, message: result.message };
28
+ }
29
+ const examples = result.suggestedExamples ?? [];
30
+ p.note(
31
+ [
32
+ "shed could not find a default dev directory under your home folder.",
33
+ "",
34
+ "Pass an explicit path to `shed scan` / `shed clean`, or create one of:",
35
+ ...examples.map((e) => ` ${pc.dim(e)}`)
36
+ ].join("\n"),
37
+ "No scan target"
38
+ );
39
+ const answer = await p.text({
40
+ message: "Path to scan (leave empty to cancel):",
41
+ placeholder: "~/projects"
42
+ });
43
+ if (p.isCancel(answer) || typeof answer !== "string" || answer.trim() === "") {
44
+ return { ok: false, message: "Cancelled \u2014 no path provided." };
45
+ }
46
+ const trimmed = answer.trim();
47
+ const expanded = trimmed.startsWith("~") ? trimmed.replace(/^~/, process.env.HOME ?? process.env.USERPROFILE ?? "") : trimmed;
48
+ const second = await resolveScopeRoots({ explicit: resolve(expanded) });
49
+ if (second.ok) return { ok: true, roots: second.roots };
50
+ return { ok: false, message: second.message };
51
+ }
17
52
 
18
53
  // src/verbose.ts
19
54
  var _verbose = false;
@@ -36,37 +71,44 @@ function formatBytes(bytes) {
36
71
  return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
37
72
  }
38
73
  var RISK_BADGE = {
39
- [RiskTier.Green]: pc.green("Green "),
40
- [RiskTier.Yellow]: pc.yellow("Yellow"),
41
- [RiskTier.Red]: pc.red("Red ")
74
+ [RiskTier.Green]: pc2.green("Green "),
75
+ [RiskTier.Yellow]: pc2.yellow("Yellow"),
76
+ [RiskTier.Red]: pc2.red("Red ")
42
77
  };
43
- async function cleanCommand(path = ".", options = {}) {
44
- const rootDir = resolve(path);
78
+ async function cleanCommand(path, options = {}) {
45
79
  const isDryRun = !options.execute;
46
- p.intro(pc.bgYellow(pc.black(" shed clean ")));
80
+ p2.intro(pc2.bgYellow(pc2.black(" shed clean ")));
81
+ const scope = await resolveScanScope({ path });
82
+ if (!scope.ok) {
83
+ p2.cancel(scope.message);
84
+ return;
85
+ }
47
86
  if (isDryRun) {
48
- p.note(
87
+ p2.note(
49
88
  "DRY-RUN mode \u2014 no files will be deleted.\nPass --execute to perform actual cleanup.",
50
89
  "Safe mode"
51
90
  );
52
91
  }
53
- const spinner4 = p.spinner();
54
- verbose(`clean root: ${rootDir}, dryRun=${isDryRun}, hardDelete=${options.hardDelete ?? false}`);
55
- spinner4.start(`Scanning ${rootDir} \u2026`);
92
+ const spinner4 = p2.spinner();
93
+ verbose(
94
+ `clean roots: ${scope.roots.join(", ")}, dryRun=${isDryRun}, hardDelete=${options.hardDelete ?? false}`
95
+ );
96
+ spinner4.start(
97
+ scope.roots.length === 1 ? `Scanning ${scope.roots[0]} \u2026` : `Scanning ${scope.roots.length} roots \u2026`
98
+ );
56
99
  const scanner = new Scanner(defaultDetectors());
57
- const ctx = { scanRoot: rootDir, maxDepth: 8 };
58
- const [projects, globalItems] = await Promise.all([
59
- scanner.scan(rootDir),
60
- scanner.scanGlobal(ctx)
61
- ]);
100
+ const projectsByRoot = await Promise.all(scope.roots.map((root) => scanner.scan(root)));
101
+ const projects = projectsByRoot.flat();
102
+ const ctx = { scanRoot: scope.roots[0] ?? "/", maxDepth: 8 };
103
+ const globalItems = await scanner.scanGlobal(ctx);
62
104
  const allItems = [
63
105
  ...projects.flatMap((proj) => proj.items),
64
106
  ...globalItems
65
107
  ].filter((i) => options.includeRed || i.risk !== RiskTier.Red);
66
108
  verbose(`scan complete: ${allItems.length} cleanable items`);
67
- spinner4.stop(`Found ${pc.bold(String(allItems.length))} cleanable items.`);
109
+ spinner4.stop(`Found ${pc2.bold(String(allItems.length))} cleanable items.`);
68
110
  if (allItems.length === 0) {
69
- p.outro(pc.dim("Nothing to clean."));
111
+ p2.outro(pc2.dim("Nothing to clean."));
70
112
  return;
71
113
  }
72
114
  const checker = new SafetyChecker();
@@ -80,18 +122,18 @@ async function cleanCommand(path = ".", options = {}) {
80
122
  return !(result2?.allowed ?? false);
81
123
  });
82
124
  if (blockedItems.length > 0) {
83
- p.note(
125
+ p2.note(
84
126
  blockedItems.map((item) => {
85
127
  const reasons = checkResults[allItems.indexOf(item)]?.reasons ?? [];
86
128
  const blockReason = reasons.find((r) => r.severity === "block");
87
- return `${pc.dim(item.path)}
88
- ${pc.red("\u2717")} ${blockReason?.message ?? "blocked"}`;
129
+ return `${pc2.dim(item.path)}
130
+ ${pc2.red("\u2717")} ${blockReason?.message ?? "blocked"}`;
89
131
  }).join("\n\n"),
90
132
  `${blockedItems.length} item(s) blocked by safety checks`
91
133
  );
92
134
  }
93
135
  if (eligibleItems.length === 0) {
94
- p.outro(pc.yellow("All items were blocked by safety checks."));
136
+ p2.outro(pc2.yellow("All items were blocked by safety checks."));
95
137
  return;
96
138
  }
97
139
  let selectedItems = eligibleItems;
@@ -102,31 +144,31 @@ async function cleanCommand(path = ".", options = {}) {
102
144
  const greenBytes = greenItems.reduce((s, i) => s + i.sizeBytes, 0);
103
145
  const yellowBytes = yellowItems.reduce((s, i) => s + i.sizeBytes, 0);
104
146
  const allBytes = eligibleItems.reduce((s, i) => s + i.sizeBytes, 0);
105
- const preset = await p.select({
147
+ const preset = await p2.select({
106
148
  message: "What would you like to clean?",
107
149
  options: [
108
150
  {
109
151
  value: "all",
110
- label: `All ${pc.dim(`${eligibleItems.length} items \xB7 ${formatBytes(allBytes)}`)}`
152
+ label: `All ${pc2.dim(`${eligibleItems.length} items \xB7 ${formatBytes(allBytes)}`)}`
111
153
  },
112
154
  ...greenItems.length > 0 ? [
113
155
  {
114
156
  value: "green",
115
- label: `${pc.green("Green only")} ${pc.dim(`${greenItems.length} items \xB7 ${formatBytes(greenBytes)} \xB7 safest`)}`
157
+ label: `${pc2.green("Green only")} ${pc2.dim(`${greenItems.length} items \xB7 ${formatBytes(greenBytes)} \xB7 safest`)}`
116
158
  }
117
159
  ] : [],
118
160
  ...yellowItems.length > 0 ? [
119
161
  {
120
162
  value: "yellow",
121
- label: `${pc.yellow("Yellow only")} ${pc.dim(`${yellowItems.length} items \xB7 ${formatBytes(yellowBytes)}`)}`
163
+ label: `${pc2.yellow("Yellow only")} ${pc2.dim(`${yellowItems.length} items \xB7 ${formatBytes(yellowBytes)}`)}`
122
164
  }
123
165
  ] : [],
124
166
  { value: "custom", label: "Custom (pick individual items)" },
125
- { value: "cancel", label: pc.dim("Cancel (do nothing, exit)") }
167
+ { value: "cancel", label: pc2.dim("Cancel (do nothing, exit)") }
126
168
  ]
127
169
  });
128
- if (p.isCancel(preset) || preset === "cancel") {
129
- p.cancel("Cleanup cancelled.");
170
+ if (p2.isCancel(preset) || preset === "cancel") {
171
+ p2.cancel("Cleanup cancelled.");
130
172
  return;
131
173
  }
132
174
  if (preset === "all") {
@@ -141,37 +183,37 @@ async function cleanCommand(path = ".", options = {}) {
141
183
  const warnings = checkResults[allItems.indexOf(item)]?.reasons.filter(
142
184
  (r) => r.severity === "warning"
143
185
  ) ?? [];
144
- const warnStr = warnings.length > 0 ? pc.yellow(` \u26A0 ${warnings.map((w) => w.message).join("; ")}`) : "";
186
+ const warnStr = warnings.length > 0 ? pc2.yellow(` \u26A0 ${warnings.map((w) => w.message).join("; ")}`) : "";
145
187
  return {
146
188
  value: item,
147
- label: `${RISK_BADGE[item.risk]} ${displayPath} ${pc.dim(formatBytes(item.sizeBytes))}${warnStr}`
189
+ label: `${RISK_BADGE[item.risk]} ${displayPath} ${pc2.dim(formatBytes(item.sizeBytes))}${warnStr}`
148
190
  };
149
191
  });
150
- const selection = await p.multiselect({
192
+ const selection = await p2.multiselect({
151
193
  message: "Select items to clean (space to toggle, enter to confirm):",
152
194
  options: choices,
153
195
  required: false
154
196
  });
155
- if (p.isCancel(selection)) {
156
- p.cancel("Cleanup cancelled.");
197
+ if (p2.isCancel(selection)) {
198
+ p2.cancel("Cleanup cancelled.");
157
199
  return;
158
200
  }
159
201
  selectedItems = selection;
160
202
  }
161
203
  }
162
204
  if (selectedItems.length === 0) {
163
- p.outro(pc.dim("Nothing selected."));
205
+ p2.outro(pc2.dim("Nothing selected."));
164
206
  return;
165
207
  }
166
208
  const totalBytes = selectedItems.reduce((s, i) => s + i.sizeBytes, 0);
167
209
  if (!isDryRun && !options.yes) {
168
- const action = options.hardDelete ? pc.red("PERMANENTLY DELETE") : "move to Trash";
169
- const confirmed = await p.confirm({
210
+ const action = options.hardDelete ? pc2.red("PERMANENTLY DELETE") : "move to Trash";
211
+ const confirmed = await p2.confirm({
170
212
  message: `${action} ${selectedItems.length} item(s) (${formatBytes(totalBytes)})?`,
171
213
  initialValue: false
172
214
  });
173
- if (p.isCancel(confirmed) || !confirmed) {
174
- p.cancel("Cleanup cancelled.");
215
+ if (p2.isCancel(confirmed) || !confirmed) {
216
+ p2.cancel("Cleanup cancelled.");
175
217
  return;
176
218
  }
177
219
  }
@@ -179,7 +221,7 @@ async function cleanCommand(path = ".", options = {}) {
179
221
  `executing ${selectedItems.length} items, dryRun=${isDryRun}, hardDelete=${options.hardDelete ?? false}`
180
222
  );
181
223
  for (const item of selectedItems) verbose(` \u2192 ${item.path}`);
182
- const execSpinner = p.spinner();
224
+ const execSpinner = p2.spinner();
183
225
  execSpinner.start(isDryRun ? "Simulating cleanup \u2026" : "Cleaning up \u2026");
184
226
  const result = await checker.execute(selectedItems, {
185
227
  dryRun: isDryRun,
@@ -191,26 +233,26 @@ async function cleanCommand(path = ".", options = {}) {
191
233
  const verb = isDryRun ? "Would free" : "Freed";
192
234
  console.log(
193
235
  `
194
- ${pc.green("\u2713")} ${verb} ${pc.bold(pc.green(formatBytes(result.totalBytesFreed)))} across ${result.succeeded.length} item(s).`
236
+ ${pc2.green("\u2713")} ${verb} ${pc2.bold(pc2.green(formatBytes(result.totalBytesFreed)))} across ${result.succeeded.length} item(s).`
195
237
  );
196
238
  }
197
239
  if (result.skipped.length > 0) {
198
- console.log(` ${pc.yellow("\u26A0")} ${result.skipped.length} item(s) skipped.`);
240
+ console.log(` ${pc2.yellow("\u26A0")} ${result.skipped.length} item(s) skipped.`);
199
241
  for (const s of result.skipped) {
200
242
  const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
201
243
  const displayPath = home ? s.item.path.replace(home, "~") : s.item.path;
202
- console.log(` ${pc.dim(displayPath)}: ${s.reason}`);
244
+ console.log(` ${pc2.dim(displayPath)}: ${s.reason}`);
203
245
  }
204
246
  }
205
247
  if (result.failed.length > 0) {
206
- console.log(` ${pc.red("\u2717")} ${result.failed.length} item(s) failed:`);
248
+ console.log(` ${pc2.red("\u2717")} ${result.failed.length} item(s) failed:`);
207
249
  for (const f of result.failed) {
208
- console.log(` ${pc.dim(f.item.path)}: ${f.error}`);
250
+ console.log(` ${pc2.dim(f.item.path)}: ${f.error}`);
209
251
  }
210
252
  }
211
253
  console.log();
212
- const outro7 = isDryRun ? `Dry-run complete. Run with ${pc.cyan("--execute")} to perform actual cleanup.` : result.failed.length > 0 ? `Completed with ${result.failed.length} failure(s).` : "All done!";
213
- p.outro(outro7);
254
+ const outro7 = isDryRun ? `Dry-run complete. Run with ${pc2.cyan("--execute")} to perform actual cleanup.` : result.failed.length > 0 ? `Completed with ${result.failed.length} failure(s).` : "All done!";
255
+ p2.outro(outro7);
214
256
  }
215
257
 
216
258
  // src/commands/completions.ts
@@ -330,9 +372,9 @@ function completionsCommand(shell) {
330
372
  }
331
373
 
332
374
  // src/commands/config.ts
333
- import * as p2 from "@clack/prompts";
375
+ import * as p3 from "@clack/prompts";
334
376
  import Conf from "conf";
335
- import pc2 from "picocolors";
377
+ import pc3 from "picocolors";
336
378
  var DEFAULTS = {
337
379
  scan: { maxDepth: 8, maxAgeDays: 30 },
338
380
  clean: { hardDelete: false },
@@ -394,7 +436,7 @@ function flatSet(store, key, raw) {
394
436
  return true;
395
437
  }
396
438
  async function configCommand(action, key, value) {
397
- p2.intro(pc2.bgBlue(pc2.black(" shed config ")));
439
+ p3.intro(pc3.bgBlue(pc3.black(" shed config ")));
398
440
  const store = getStore();
399
441
  switch (action) {
400
442
  case "list":
@@ -402,16 +444,16 @@ async function configCommand(action, key, value) {
402
444
  const lines = Object.entries(KEY_DEFS).map(([k, def]) => {
403
445
  const val = flatGet(store, k);
404
446
  const isDefault = String(val) === String(def.get(DEFAULTS));
405
- const valStr = isDefault ? pc2.dim(String(val)) : pc2.cyan(String(val));
406
- return ` ${k.padEnd(22)} ${valStr}${isDefault ? pc2.dim(" (default)") : ""}`;
447
+ const valStr = isDefault ? pc3.dim(String(val)) : pc3.cyan(String(val));
448
+ return ` ${k.padEnd(22)} ${valStr}${isDefault ? pc3.dim(" (default)") : ""}`;
407
449
  });
408
- p2.note(lines.join("\n"), "Current configuration");
409
- p2.note(pc2.dim(store.path), "Config file");
450
+ p3.note(lines.join("\n"), "Current configuration");
451
+ p3.note(pc3.dim(store.path), "Config file");
410
452
  break;
411
453
  }
412
454
  case "get": {
413
455
  if (!key || !(key in KEY_DEFS)) {
414
- p2.cancel(
456
+ p3.cancel(
415
457
  !key ? "Usage: shed config get <key>" : `Unknown key: ${key}
416
458
  Valid: ${Object.keys(KEY_DEFS).join(", ")}`
417
459
  );
@@ -422,59 +464,59 @@ Valid: ${Object.keys(KEY_DEFS).join(", ")}`
422
464
  }
423
465
  case "set": {
424
466
  if (!key || value === void 0) {
425
- p2.cancel("Usage: shed config set <key> <value>");
467
+ p3.cancel("Usage: shed config set <key> <value>");
426
468
  process.exit(1);
427
469
  }
428
470
  if (!(key in KEY_DEFS)) {
429
- p2.cancel(`Unknown key: ${key}
471
+ p3.cancel(`Unknown key: ${key}
430
472
  Valid: ${Object.keys(KEY_DEFS).join(", ")}`);
431
473
  process.exit(1);
432
474
  }
433
475
  if (!flatSet(store, key, value)) {
434
- p2.cancel(`Invalid value "${value}" for key "${key}"`);
476
+ p3.cancel(`Invalid value "${value}" for key "${key}"`);
435
477
  process.exit(1);
436
478
  }
437
- p2.outro(`${pc2.cyan(key)} = ${pc2.green(value)}`);
479
+ p3.outro(`${pc3.cyan(key)} = ${pc3.green(value)}`);
438
480
  return;
439
481
  }
440
482
  case "reset": {
441
483
  if (key) {
442
484
  if (!(key in KEY_DEFS)) {
443
- p2.cancel(`Unknown key: ${key}`);
485
+ p3.cancel(`Unknown key: ${key}`);
444
486
  process.exit(1);
445
487
  }
446
488
  const [section] = key.split(".");
447
489
  store.set(section, DEFAULTS[section]);
448
- p2.outro(`${pc2.cyan(key)} reset to default.`);
490
+ p3.outro(`${pc3.cyan(key)} reset to default.`);
449
491
  } else {
450
- const confirmed = await p2.confirm({
492
+ const confirmed = await p3.confirm({
451
493
  message: "Reset ALL settings to defaults?",
452
494
  initialValue: false
453
495
  });
454
- if (p2.isCancel(confirmed) || !confirmed) {
455
- p2.cancel("Cancelled.");
496
+ if (p3.isCancel(confirmed) || !confirmed) {
497
+ p3.cancel("Cancelled.");
456
498
  return;
457
499
  }
458
500
  store.clear();
459
- p2.outro("All settings reset to defaults.");
501
+ p3.outro("All settings reset to defaults.");
460
502
  }
461
503
  return;
462
504
  }
463
505
  default:
464
- p2.cancel(`Unknown action: ${action}
506
+ p3.cancel(`Unknown action: ${action}
465
507
  Usage: shed config [list|get|set|reset]`);
466
508
  process.exit(1);
467
509
  }
468
- p2.outro(pc2.dim(`Edit directly: ${store.path}`));
510
+ p3.outro(pc3.dim(`Edit directly: ${store.path}`));
469
511
  }
470
512
 
471
513
  // src/commands/doctor.ts
472
514
  import { arch, homedir, platform, release } from "os";
473
- import * as p3 from "@clack/prompts";
515
+ import * as p4 from "@clack/prompts";
474
516
  import { execa } from "execa";
475
- import pc3 from "picocolors";
517
+ import pc4 from "picocolors";
476
518
  async function doctorCommand() {
477
- p3.intro(pc3.bgGreen(pc3.black(" shed doctor ")));
519
+ p4.intro(pc4.bgGreen(pc4.black(" shed doctor ")));
478
520
  const checks = [];
479
521
  checks.push({ name: "OS", value: `${platform()} ${release()} (${arch()})` });
480
522
  checks.push({ name: "Home", value: homedir() });
@@ -485,25 +527,24 @@ async function doctorCommand() {
485
527
  const { stdout } = await execa(tool, ["--version"], { reject: false });
486
528
  checks.push({ name: tool, value: stdout.split("\n")[0] ?? "unknown" });
487
529
  } catch {
488
- checks.push({ name: tool, value: pc3.dim("not installed") });
530
+ checks.push({ name: tool, value: pc4.dim("not installed") });
489
531
  }
490
532
  }
491
- const body = checks.map((c) => ` ${pc3.cyan(c.name.padEnd(10))} ${c.value}`).join("\n");
492
- p3.note(body, "Environment");
493
- p3.outro(pc3.green("Environment check complete."));
533
+ const body = checks.map((c) => ` ${pc4.cyan(c.name.padEnd(10))} ${c.value}`).join("\n");
534
+ p4.note(body, "Environment");
535
+ p4.outro(pc4.green("Environment check complete."));
494
536
  }
495
537
 
496
538
  // src/commands/scan.ts
497
539
  import { createRequire } from "module";
498
540
  import { hostname } from "os";
499
- import { resolve as resolve2 } from "path";
500
- import * as p4 from "@clack/prompts";
541
+ import * as p5 from "@clack/prompts";
501
542
  import {
502
543
  RiskTier as RiskTier2,
503
544
  Scanner as Scanner2,
504
545
  defaultDetectors as defaultDetectors2
505
546
  } from "@lexmanh/shed-core";
506
- import pc4 from "picocolors";
547
+ import pc5 from "picocolors";
507
548
 
508
549
  // src/commands/scan-aggregate.ts
509
550
  import { dirname } from "path";
@@ -577,13 +618,13 @@ function selectTopGroups(groups, topN) {
577
618
  // src/commands/scan.ts
578
619
  var require2 = createRequire(import.meta.url);
579
620
  var { version: SHED_VERSION } = require2("../package.json");
580
- var JSON_SCHEMA_VERSION = 1;
621
+ var JSON_SCHEMA_VERSION = 2;
581
622
  var COMPACT_TOP_N = 15;
582
623
  var DETECTOR_BREAKDOWN_TOP_N = 6;
583
624
  var RISK_LABEL = {
584
- [RiskTier2.Green]: pc4.green("\u25CF Green"),
585
- [RiskTier2.Yellow]: pc4.yellow("\u25CF Yellow"),
586
- [RiskTier2.Red]: pc4.red("\u25CF Red")
625
+ [RiskTier2.Green]: pc5.green("\u25CF Green"),
626
+ [RiskTier2.Yellow]: pc5.yellow("\u25CF Yellow"),
627
+ [RiskTier2.Red]: pc5.red("\u25CF Red")
587
628
  };
588
629
  var RISK_ORDER = {
589
630
  [RiskTier2.Red]: 0,
@@ -597,21 +638,32 @@ function formatBytes2(bytes) {
597
638
  if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
598
639
  return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
599
640
  }
600
- async function scanCommand(path = ".", options = {}) {
601
- const rootDir = resolve2(path);
641
+ async function scanCommand(path, options = {}) {
602
642
  if (!options.json) {
603
- p4.intro(pc4.bgCyan(pc4.black(" shed scan ")));
643
+ p5.intro(pc5.bgCyan(pc5.black(" shed scan ")));
604
644
  }
605
- const spinner4 = options.json ? null : p4.spinner();
606
- verbose(`scan root: ${rootDir}`);
607
- spinner4?.start(`Scanning ${rootDir} \u2026`);
645
+ const scope = await resolveScanScope({ path, nonInteractive: options.json });
646
+ if (!scope.ok) {
647
+ if (options.json) {
648
+ console.log(
649
+ JSON.stringify({ schemaVersion: JSON_SCHEMA_VERSION, error: scope.message }, null, 2)
650
+ );
651
+ } else {
652
+ p5.cancel(scope.message);
653
+ }
654
+ return;
655
+ }
656
+ const spinner4 = options.json ? null : p5.spinner();
657
+ verbose(`scan roots: ${scope.roots.join(", ")}`);
658
+ spinner4?.start(
659
+ scope.roots.length === 1 ? `Scanning ${scope.roots[0]} \u2026` : `Scanning ${scope.roots.length} roots \u2026`
660
+ );
608
661
  const scanStartedAt = Date.now();
609
662
  const scanner = new Scanner2(defaultDetectors2());
610
- const ctx = { scanRoot: rootDir, maxDepth: 8 };
611
- const [projects, globalItems] = await Promise.all([
612
- scanner.scan(rootDir),
613
- scanner.scanGlobal(ctx)
614
- ]);
663
+ const projectsByRoot = await Promise.all(scope.roots.map((root) => scanner.scan(root)));
664
+ const projects = projectsByRoot.flat();
665
+ const ctx = { scanRoot: scope.roots[0] ?? "/", maxDepth: 8 };
666
+ const globalItems = await scanner.scanGlobal(ctx);
615
667
  const allItems = [
616
668
  ...projects.flatMap((proj) => proj.items),
617
669
  ...globalItems
@@ -623,7 +675,7 @@ async function scanCommand(path = ".", options = {}) {
623
675
  for (const item of allItems)
624
676
  verbose(` item: ${item.risk} ${item.path} (${item.sizeBytes} bytes)`);
625
677
  spinner4?.stop(
626
- `Found ${pc4.bold(String(allItems.length))} cleanable items across ${projects.length} project(s).`
678
+ `Found ${pc5.bold(String(allItems.length))} cleanable items across ${projects.length} project(s).`
627
679
  );
628
680
  if (options.json) {
629
681
  const byRisk = { green: 0, yellow: 0, red: 0 };
@@ -650,7 +702,7 @@ async function scanCommand(path = ".", options = {}) {
650
702
  arch: process.arch
651
703
  },
652
704
  scan: {
653
- root: rootDir,
705
+ roots: scope.roots,
654
706
  durationMs: Date.now() - scanStartedAt
655
707
  },
656
708
  summary: {
@@ -669,8 +721,8 @@ async function scanCommand(path = ".", options = {}) {
669
721
  return;
670
722
  }
671
723
  if (allItems.length === 0) {
672
- p4.note("Nothing found to clean in this directory.", "Result");
673
- p4.outro(pc4.dim("All clear!"));
724
+ p5.note("Nothing found to clean in this directory.", "Result");
725
+ p5.outro(pc5.dim("All clear!"));
674
726
  return;
675
727
  }
676
728
  if (options.all) {
@@ -679,8 +731,8 @@ async function scanCommand(path = ".", options = {}) {
679
731
  renderCompact(allItems);
680
732
  }
681
733
  console.log();
682
- p4.outro(
683
- `Total recoverable: ${pc4.bold(pc4.green(formatBytes2(totalBytes)))} \u2014 run ${pc4.cyan("shed clean")} to proceed.`
734
+ p5.outro(
735
+ `Total recoverable: ${pc5.bold(pc5.green(formatBytes2(totalBytes)))} \u2014 run ${pc5.cyan("shed clean")} to proceed.`
684
736
  );
685
737
  }
686
738
  function renderCompact(allItems) {
@@ -693,25 +745,25 @@ function renderCompact(allItems) {
693
745
  if (item.metadata?.detectOnly === true) detectOnly++;
694
746
  byDetector.set(item.detector, (byDetector.get(item.detector) ?? 0) + item.sizeBytes);
695
747
  }
696
- const riskLine = `${pc4.green(`\u25CF ${byRisk.green} Green`)} ${pc4.yellow(`\u25CF ${byRisk.yellow} Yellow`)} ${pc4.red(`\u25CF ${byRisk.red} Red`)}${detectOnly > 0 ? pc4.dim(` (${detectOnly} detect-only)`) : ""}`;
748
+ const riskLine = `${pc5.green(`\u25CF ${byRisk.green} Green`)} ${pc5.yellow(`\u25CF ${byRisk.yellow} Yellow`)} ${pc5.red(`\u25CF ${byRisk.red} Red`)}${detectOnly > 0 ? pc5.dim(` (${detectOnly} detect-only)`) : ""}`;
697
749
  const detectorLine = [...byDetector.entries()].sort(([, a], [, b]) => b - a).slice(0, DETECTOR_BREAKDOWN_TOP_N).map(([d, b]) => `${d} ${formatBytes2(b)}`).join(" \xB7 ");
698
750
  console.log();
699
751
  console.log(` By risk: ${riskLine}`);
700
- console.log(` By detector: ${pc4.dim(detectorLine)}`);
752
+ console.log(` By detector: ${pc5.dim(detectorLine)}`);
701
753
  const groups = aggregateForDisplay(allItems);
702
754
  const { shown, hidden } = selectTopGroups(groups, COMPACT_TOP_N);
703
755
  console.log();
704
- console.log(` ${pc4.bold(`Top ${shown.length} items:`)}`);
756
+ console.log(` ${pc5.bold(`Top ${shown.length} items:`)}`);
705
757
  for (const g of shown) {
706
758
  const path = home ? g.displayPath.replace(home, "~") : g.displayPath;
707
- const tag = g.type === "aggregate" ? pc4.dim(` (${g.itemCount} ${g.detector} items)`) : "";
708
- const size = g.totalBytes > 0 ? pc4.dim(` ${formatBytes2(g.totalBytes)}`) : "";
759
+ const tag = g.type === "aggregate" ? pc5.dim(` (${g.itemCount} ${g.detector} items)`) : "";
760
+ const size = g.totalBytes > 0 ? pc5.dim(` ${formatBytes2(g.totalBytes)}`) : "";
709
761
  console.log(` ${RISK_LABEL[g.risk]} ${path}${tag}${size}`);
710
762
  }
711
763
  if (hidden.groupCount > 0) {
712
764
  console.log();
713
765
  console.log(
714
- pc4.dim(
766
+ pc5.dim(
715
767
  ` \u2026 ${hidden.groupCount} more groups (${hidden.itemCount} items, ${formatBytes2(hidden.totalBytes)}) \u2014 use --all to see everything`
716
768
  )
717
769
  );
@@ -727,24 +779,24 @@ function renderFull(allItems) {
727
779
  byProject.set(key, group);
728
780
  }
729
781
  for (const [projectRoot, items] of byProject.entries()) {
730
- const projectLabel = projectRoot === "(global)" ? pc4.dim("global caches") : pc4.cyan(home ? projectRoot.replace(home, "~") : projectRoot);
782
+ const projectLabel = projectRoot === "(global)" ? pc5.dim("global caches") : pc5.cyan(home ? projectRoot.replace(home, "~") : projectRoot);
731
783
  const groupTotal = items.reduce((s, i) => s + i.sizeBytes, 0);
732
784
  console.log(`
733
- ${projectLabel} ${pc4.dim(formatBytes2(groupTotal))}`);
785
+ ${projectLabel} ${pc5.dim(formatBytes2(groupTotal))}`);
734
786
  for (const item of items) {
735
- const size = item.sizeBytes > 0 ? pc4.dim(` ${formatBytes2(item.sizeBytes)}`) : "";
787
+ const size = item.sizeBytes > 0 ? pc5.dim(` ${formatBytes2(item.sizeBytes)}`) : "";
736
788
  const displayPath = home ? item.path.replace(home, "~") : item.path;
737
789
  const shortPath = projectRoot !== "(global)" ? displayPath.replace(home ? projectRoot.replace(home, "~") : projectRoot, "").replace(/^\//, "") || displayPath : displayPath;
738
790
  console.log(` ${RISK_LABEL[item.risk]} ${shortPath}${size}`);
739
- console.log(` ${pc4.dim(` ${item.description}`)}`);
791
+ console.log(` ${pc5.dim(` ${item.description}`)}`);
740
792
  }
741
793
  }
742
794
  }
743
795
 
744
796
  // src/commands/undo.ts
745
797
  import { platform as platform2 } from "os";
746
- import * as p5 from "@clack/prompts";
747
- import pc5 from "picocolors";
798
+ import * as p6 from "@clack/prompts";
799
+ import pc6 from "picocolors";
748
800
  function trashPath() {
749
801
  switch (platform2()) {
750
802
  case "darwin":
@@ -766,27 +818,27 @@ function trashOpenHint() {
766
818
  }
767
819
  }
768
820
  async function undoCommand() {
769
- p5.intro(pc5.bgMagenta(pc5.black(" shed undo ")));
770
- p5.note(
821
+ p6.intro(pc6.bgMagenta(pc6.black(" shed undo ")));
822
+ p6.note(
771
823
  [
772
824
  "shed clean moves items to your OS Trash by default.",
773
825
  "",
774
- ` Trash location: ${pc5.cyan(trashPath())}`,
826
+ ` Trash location: ${pc6.cyan(trashPath())}`,
775
827
  "",
776
- ` To restore: ${pc5.dim(trashOpenHint())}`,
828
+ ` To restore: ${pc6.dim(trashOpenHint())}`,
777
829
  "",
778
- pc5.dim("Tip: shed clean --hard-delete bypasses Trash (no undo possible)."),
779
- pc5.dim(" shed clean --dry-run to preview before any real deletion.")
830
+ pc6.dim("Tip: shed clean --hard-delete bypasses Trash (no undo possible)."),
831
+ pc6.dim(" shed clean --dry-run to preview before any real deletion.")
780
832
  ].join("\n"),
781
833
  "How to undo a cleanup"
782
834
  );
783
- p5.outro(pc5.green("Nothing to do \u2014 restore items via your OS Trash."));
835
+ p6.outro(pc6.green("Nothing to do \u2014 restore items via your OS Trash."));
784
836
  }
785
837
 
786
838
  // src/commands/upgrade.ts
787
- import * as p6 from "@clack/prompts";
839
+ import * as p7 from "@clack/prompts";
788
840
  import { execa as execa2 } from "execa";
789
- import pc6 from "picocolors";
841
+ import pc7 from "picocolors";
790
842
 
791
843
  // src/update/detect-install.ts
792
844
  import { realpathSync } from "fs";
@@ -794,32 +846,32 @@ import { constants, access } from "fs/promises";
794
846
  import { dirname as dirname2 } from "path";
795
847
  var PACKAGE_NAME = "@lexmanh/shed-cli";
796
848
  function classifyInstall(resolvedPath) {
797
- const p7 = resolvedPath.replace(/\\/g, "/").toLowerCase();
798
- if (p7.includes("/_npx/") || p7.includes("/npx-cache/")) {
849
+ const p8 = resolvedPath.replace(/\\/g, "/").toLowerCase();
850
+ if (p8.includes("/_npx/") || p8.includes("/npx-cache/")) {
799
851
  return {
800
852
  kind: "npx",
801
853
  note: "Running via npx (ephemeral). Re-run with `npx @lexmanh/shed-cli@latest`."
802
854
  };
803
855
  }
804
- if (p7.includes("/bun/install/cache/") || p7.includes("/.bun/install/cache/")) {
856
+ if (p8.includes("/bun/install/cache/") || p8.includes("/.bun/install/cache/")) {
805
857
  return {
806
858
  kind: "bunx",
807
859
  note: "Running via bunx (ephemeral). Re-run with `bunx @lexmanh/shed-cli@latest`."
808
860
  };
809
861
  }
810
- if (p7.includes("/.volta/") || p7.includes("/volta/tools/")) {
862
+ if (p8.includes("/.volta/") || p8.includes("/volta/tools/")) {
811
863
  return { kind: "volta" };
812
864
  }
813
- if (p7.includes("/pnpm/global/") || p7.includes("/library/pnpm/") || p7.includes("/.local/share/pnpm/")) {
865
+ if (p8.includes("/pnpm/global/") || p8.includes("/library/pnpm/") || p8.includes("/.local/share/pnpm/")) {
814
866
  return { kind: "pnpm-global" };
815
867
  }
816
- if (p7.includes("/yarn/global/") || p7.includes("/.config/yarn/global/")) {
868
+ if (p8.includes("/yarn/global/") || p8.includes("/.config/yarn/global/")) {
817
869
  return { kind: "yarn-global" };
818
870
  }
819
- if (p7.includes("/.bun/install/global/")) {
871
+ if (p8.includes("/.bun/install/global/")) {
820
872
  return { kind: "bun-global" };
821
873
  }
822
- if (p7.includes("/node_modules/")) {
874
+ if (p8.includes("/node_modules/")) {
823
875
  return { kind: "npm-global" };
824
876
  }
825
877
  return { kind: "unknown" };
@@ -849,12 +901,12 @@ function detectInstall(binPath) {
849
901
  } catch {
850
902
  resolvedPath = binPath;
851
903
  }
852
- const { kind, note: note7 } = classifyInstall(resolvedPath);
904
+ const { kind, note: note8 } = classifyInstall(resolvedPath);
853
905
  return {
854
906
  kind,
855
907
  upgradeCommand: buildUpgradeCommand(kind),
856
908
  resolvedPath,
857
- note: note7
909
+ note: note8
858
910
  };
859
911
  }
860
912
  async function needsElevation(resolvedPath) {
@@ -945,93 +997,93 @@ function isNewer(latest, current) {
945
997
 
946
998
  // src/commands/upgrade.ts
947
999
  async function upgradeCommand(opts, currentVersion) {
948
- p6.intro(pc6.bgMagenta(pc6.black(" shed upgrade ")));
1000
+ p7.intro(pc7.bgMagenta(pc7.black(" shed upgrade ")));
949
1001
  const install = detectInstall(process.argv[1] ?? "");
950
- const spin = p6.spinner();
1002
+ const spin = p7.spinner();
951
1003
  spin.start("Checking npm registry\u2026");
952
1004
  const latest = await fetchLatestVersion({ force: true });
953
1005
  spin.stop(latest ? `Latest: v${latest}` : "Could not reach registry");
954
1006
  if (!latest) {
955
- p6.outro(pc6.yellow("No upgrade information available. Check your network and try again."));
1007
+ p7.outro(pc7.yellow("No upgrade information available. Check your network and try again."));
956
1008
  process.exit(1);
957
1009
  }
958
1010
  if (!isNewer(latest, currentVersion)) {
959
- p6.note(
960
- `Installed: ${pc6.cyan(`v${currentVersion}`)}
961
- Latest: ${pc6.cyan(`v${latest}`)}`,
1011
+ p7.note(
1012
+ `Installed: ${pc7.cyan(`v${currentVersion}`)}
1013
+ Latest: ${pc7.cyan(`v${latest}`)}`,
962
1014
  "Already up to date"
963
1015
  );
964
- p6.outro(pc6.green("Nothing to do."));
1016
+ p7.outro(pc7.green("Nothing to do."));
965
1017
  return;
966
1018
  }
967
- p6.note(
1019
+ p7.note(
968
1020
  [
969
- `Installed: ${pc6.dim(`v${currentVersion}`)}`,
970
- `Latest: ${pc6.green(`v${latest}`)}`,
971
- `Source: ${pc6.cyan(install.kind)}`,
972
- `Path: ${pc6.dim(install.resolvedPath)}`
1021
+ `Installed: ${pc7.dim(`v${currentVersion}`)}`,
1022
+ `Latest: ${pc7.green(`v${latest}`)}`,
1023
+ `Source: ${pc7.cyan(install.kind)}`,
1024
+ `Path: ${pc7.dim(install.resolvedPath)}`
973
1025
  ].join("\n"),
974
1026
  "Upgrade available"
975
1027
  );
976
1028
  if (!install.upgradeCommand) {
977
- p6.note(
1029
+ p7.note(
978
1030
  install.note ?? "Could not detect how shed was installed.",
979
- pc6.yellow("Cannot self-upgrade")
1031
+ pc7.yellow("Cannot self-upgrade")
980
1032
  );
981
- p6.outro(pc6.dim("Re-install manually using your preferred package manager."));
1033
+ p7.outro(pc7.dim("Re-install manually using your preferred package manager."));
982
1034
  return;
983
1035
  }
984
1036
  const elevate = await needsElevation(install.resolvedPath);
985
1037
  const finalCommand = elevate ? `sudo ${install.upgradeCommand}` : install.upgradeCommand;
986
1038
  if (opts.check) {
987
- p6.note(finalCommand, "Run this to upgrade");
988
- p6.outro(pc6.dim("(--check mode: nothing executed)"));
1039
+ p7.note(finalCommand, "Run this to upgrade");
1040
+ p7.outro(pc7.dim("(--check mode: nothing executed)"));
989
1041
  return;
990
1042
  }
991
1043
  if (elevate) {
992
- p6.note(finalCommand, pc6.yellow("Install dir is not writable \u2014 run this manually"));
993
- p6.outro(pc6.dim("Re-run `shed upgrade` after the install completes to verify."));
1044
+ p7.note(finalCommand, pc7.yellow("Install dir is not writable \u2014 run this manually"));
1045
+ p7.outro(pc7.dim("Re-run `shed upgrade` after the install completes to verify."));
994
1046
  return;
995
1047
  }
996
1048
  if (!opts.yes) {
997
- const ok = await p6.confirm({ message: `Run \`${finalCommand}\` now?`, initialValue: true });
998
- if (p6.isCancel(ok) || !ok) {
999
- p6.cancel("Upgrade cancelled.");
1049
+ const ok = await p7.confirm({ message: `Run \`${finalCommand}\` now?`, initialValue: true });
1050
+ if (p7.isCancel(ok) || !ok) {
1051
+ p7.cancel("Upgrade cancelled.");
1000
1052
  return;
1001
1053
  }
1002
1054
  }
1003
- const runSpin = p6.spinner();
1055
+ const runSpin = p7.spinner();
1004
1056
  runSpin.start(`Running ${finalCommand}\u2026`);
1005
1057
  try {
1006
1058
  const [bin, ...args] = finalCommand.split(" ");
1007
1059
  if (!bin) throw new Error("Empty upgrade command");
1008
1060
  await execa2(bin, args, { stdio: "pipe" });
1009
- runSpin.stop(pc6.green(`Upgraded to v${latest}.`));
1010
- p6.outro(pc6.green("Done. Re-run `shed --version` to confirm."));
1061
+ runSpin.stop(pc7.green(`Upgraded to v${latest}.`));
1062
+ p7.outro(pc7.green("Done. Re-run `shed --version` to confirm."));
1011
1063
  } catch (err) {
1012
- runSpin.stop(pc6.red("Upgrade failed."));
1064
+ runSpin.stop(pc7.red("Upgrade failed."));
1013
1065
  const message = err instanceof Error ? err.message : String(err);
1014
- p6.note(message, pc6.red("Error"));
1015
- p6.outro(pc6.dim(`You can retry manually: ${finalCommand}`));
1066
+ p7.note(message, pc7.red("Error"));
1067
+ p7.outro(pc7.dim(`You can retry manually: ${finalCommand}`));
1016
1068
  process.exit(1);
1017
1069
  }
1018
1070
  }
1019
1071
 
1020
1072
  // src/update/notifier.ts
1021
- import pc7 from "picocolors";
1073
+ import pc8 from "picocolors";
1022
1074
  function maybeNotifyOfUpdate(currentVersion) {
1023
1075
  const cached = readCachedLatest();
1024
1076
  if (!cached || !isNewer(cached, currentVersion)) return;
1025
1077
  const install = detectInstall(process.argv[1] ?? "");
1026
1078
  const cmd = install.upgradeCommand ?? "shed upgrade";
1027
1079
  const banner = [
1028
- pc7.yellow("\u25B2"),
1029
- pc7.dim(`shed v${currentVersion} \u2192`),
1030
- pc7.green(`v${cached}`),
1031
- pc7.dim("available."),
1032
- pc7.dim("Run"),
1033
- pc7.cyan("`shed upgrade`"),
1034
- pc7.dim(`(or \`${cmd}\`).`)
1080
+ pc8.yellow("\u25B2"),
1081
+ pc8.dim(`shed v${currentVersion} \u2192`),
1082
+ pc8.green(`v${cached}`),
1083
+ pc8.dim("available."),
1084
+ pc8.dim("Run"),
1085
+ pc8.cyan("`shed upgrade`"),
1086
+ pc8.dim(`(or \`${cmd}\`).`)
1035
1087
  ].join(" ");
1036
1088
  console.log(banner);
1037
1089
  }
@@ -1042,19 +1094,22 @@ function scheduleBackgroundRefresh() {
1042
1094
  }
1043
1095
 
1044
1096
  // src/logo.ts
1045
- import pc8 from "picocolors";
1046
- var ART = [
1097
+ import pc9 from "picocolors";
1098
+ var CABIN = [" \u2571\u2572 ", " \u2571\u2500\u2500\u2572 ", " \u2571\u2500\u2500\u2500\u2500\u2572 ", " \u2502 \u2588\u2588 \u2502 ", " \u2514\u2500\u2500\u2500\u2500\u2518 "];
1099
+ var WORDMARK = [
1047
1100
  " ____ _ _ ",
1048
1101
  " / ___|| |__ ___ __| |",
1049
1102
  " \\___ \\| '_ \\ / _ \\/ _` |",
1050
1103
  " ___) | | | | __/ (_| |",
1051
1104
  " |____/|_| |_|\\___|\\__,_|"
1052
- ].join("\n");
1105
+ ];
1053
1106
  function printLogo(version2) {
1054
- console.log(pc8.cyan(ART));
1055
- console.log(` ${pc8.dim(`v${version2} \xB7 safe disk cleanup \xB7 dev machines & servers`)}`);
1107
+ for (let i = 0; i < CABIN.length; i++) {
1108
+ console.log(pc9.yellow(CABIN[i]) + pc9.cyan(WORDMARK[i]));
1109
+ }
1110
+ console.log(` ${pc9.dim(`v${version2} \xB7 safe disk cleanup \xB7 dev machines & servers`)}`);
1056
1111
  console.log(
1057
- ` ${pc8.dim("by")} ${pc8.white("L\xEA Xu\xE2n M\u1EA1nh")} ${pc8.dim("\xB7 https://github.com/lexmanh/shed")}
1112
+ ` ${pc9.dim("by")} ${pc9.white("L\xEA Xu\xE2n M\u1EA1nh")} ${pc9.dim("\xB7 https://github.com/lexmanh/shed")}
1058
1113
  `
1059
1114
  );
1060
1115
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lexmanh/shed-cli",
3
- "version": "0.2.0-beta.9",
3
+ "version": "0.3.0-beta.2",
4
4
  "description": "Safe disk cleanup CLI for dev machines and Linux servers — git-aware, cross-stack, trash-by-default",
5
5
  "type": "module",
6
6
  "bin": {
@@ -23,7 +23,7 @@
23
23
  "conf": "^13.1.0",
24
24
  "execa": "^9.5.0",
25
25
  "picocolors": "^1.1.1",
26
- "@lexmanh/shed-core": "0.2.0-beta.9"
26
+ "@lexmanh/shed-core": "0.3.0-beta.2"
27
27
  },
28
28
  "devDependencies": {
29
29
  "@types/node": "^22.10.0",