@lexmanh/shed-cli 0.3.0-beta.1 → 0.4.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/cli.js +406 -202
  2. package/package.json +2 -2
package/dist/cli.js CHANGED
@@ -5,15 +5,51 @@ 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 {
10
+ OpLog,
11
11
  RiskTier,
12
12
  SafetyChecker,
13
13
  Scanner,
14
14
  defaultDetectors
15
15
  } from "@lexmanh/shed-core";
16
+ import pc2 from "picocolors";
17
+
18
+ // src/scope-resolver.ts
19
+ import { resolve } from "path";
20
+ import * as p from "@clack/prompts";
21
+ import { resolveScopeRoots } from "@lexmanh/shed-core";
16
22
  import pc from "picocolors";
23
+ async function resolveScanScope(opts = {}) {
24
+ const explicit = opts.path !== void 0 ? resolve(opts.path) : void 0;
25
+ const result = await resolveScopeRoots({ explicit });
26
+ if (result.ok) return { ok: true, roots: result.roots };
27
+ if (result.reason !== "no-default-roots" || opts.nonInteractive) {
28
+ return { ok: false, message: result.message };
29
+ }
30
+ const examples = result.suggestedExamples ?? [];
31
+ p.note(
32
+ [
33
+ "shed could not find a default dev directory under your home folder.",
34
+ "",
35
+ "Pass an explicit path to `shed scan` / `shed clean`, or create one of:",
36
+ ...examples.map((e) => ` ${pc.dim(e)}`)
37
+ ].join("\n"),
38
+ "No scan target"
39
+ );
40
+ const answer = await p.text({
41
+ message: "Path to scan (leave empty to cancel):",
42
+ placeholder: "~/projects"
43
+ });
44
+ if (p.isCancel(answer) || typeof answer !== "string" || answer.trim() === "") {
45
+ return { ok: false, message: "Cancelled \u2014 no path provided." };
46
+ }
47
+ const trimmed = answer.trim();
48
+ const expanded = trimmed.startsWith("~") ? trimmed.replace(/^~/, process.env.HOME ?? process.env.USERPROFILE ?? "") : trimmed;
49
+ const second = await resolveScopeRoots({ explicit: resolve(expanded) });
50
+ if (second.ok) return { ok: true, roots: second.roots };
51
+ return { ok: false, message: second.message };
52
+ }
17
53
 
18
54
  // src/verbose.ts
19
55
  var _verbose = false;
@@ -36,40 +72,47 @@ function formatBytes(bytes) {
36
72
  return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
37
73
  }
38
74
  var RISK_BADGE = {
39
- [RiskTier.Green]: pc.green("Green "),
40
- [RiskTier.Yellow]: pc.yellow("Yellow"),
41
- [RiskTier.Red]: pc.red("Red ")
75
+ [RiskTier.Green]: pc2.green("Green "),
76
+ [RiskTier.Yellow]: pc2.yellow("Yellow"),
77
+ [RiskTier.Red]: pc2.red("Red ")
42
78
  };
43
- async function cleanCommand(path = ".", options = {}) {
44
- const rootDir = resolve(path);
79
+ async function cleanCommand(path, options = {}) {
45
80
  const isDryRun = !options.execute;
46
- p.intro(pc.bgYellow(pc.black(" shed clean ")));
81
+ p2.intro(pc2.bgYellow(pc2.black(" shed clean ")));
82
+ const scope = await resolveScanScope({ path });
83
+ if (!scope.ok) {
84
+ p2.cancel(scope.message);
85
+ return;
86
+ }
47
87
  if (isDryRun) {
48
- p.note(
88
+ p2.note(
49
89
  "DRY-RUN mode \u2014 no files will be deleted.\nPass --execute to perform actual cleanup.",
50
90
  "Safe mode"
51
91
  );
52
92
  }
53
- const spinner4 = p.spinner();
54
- verbose(`clean root: ${rootDir}, dryRun=${isDryRun}, hardDelete=${options.hardDelete ?? false}`);
55
- spinner4.start(`Scanning ${rootDir} \u2026`);
93
+ const spinner4 = p2.spinner();
94
+ verbose(
95
+ `clean roots: ${scope.roots.join(", ")}, dryRun=${isDryRun}, hardDelete=${options.hardDelete ?? false}`
96
+ );
97
+ spinner4.start(
98
+ scope.roots.length === 1 ? `Scanning ${scope.roots[0]} \u2026` : `Scanning ${scope.roots.length} roots \u2026`
99
+ );
56
100
  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
- ]);
101
+ const projectsByRoot = await Promise.all(scope.roots.map((root) => scanner.scan(root)));
102
+ const projects = projectsByRoot.flat();
103
+ const ctx = { scanRoot: scope.roots[0] ?? "/", maxDepth: 8 };
104
+ const globalItems = await scanner.scanGlobal(ctx);
62
105
  const allItems = [
63
106
  ...projects.flatMap((proj) => proj.items),
64
107
  ...globalItems
65
108
  ].filter((i) => options.includeRed || i.risk !== RiskTier.Red);
66
109
  verbose(`scan complete: ${allItems.length} cleanable items`);
67
- spinner4.stop(`Found ${pc.bold(String(allItems.length))} cleanable items.`);
110
+ spinner4.stop(`Found ${pc2.bold(String(allItems.length))} cleanable items.`);
68
111
  if (allItems.length === 0) {
69
- p.outro(pc.dim("Nothing to clean."));
112
+ p2.outro(pc2.dim("Nothing to clean."));
70
113
  return;
71
114
  }
72
- const checker = new SafetyChecker();
115
+ const checker = new SafetyChecker({ oplog: new OpLog() });
73
116
  const checkResults = await Promise.all(allItems.map((item) => checker.check(item)));
74
117
  const eligibleItems = allItems.filter((_, i) => {
75
118
  const result2 = checkResults[i];
@@ -80,18 +123,18 @@ async function cleanCommand(path = ".", options = {}) {
80
123
  return !(result2?.allowed ?? false);
81
124
  });
82
125
  if (blockedItems.length > 0) {
83
- p.note(
126
+ p2.note(
84
127
  blockedItems.map((item) => {
85
128
  const reasons = checkResults[allItems.indexOf(item)]?.reasons ?? [];
86
129
  const blockReason = reasons.find((r) => r.severity === "block");
87
- return `${pc.dim(item.path)}
88
- ${pc.red("\u2717")} ${blockReason?.message ?? "blocked"}`;
130
+ return `${pc2.dim(item.path)}
131
+ ${pc2.red("\u2717")} ${blockReason?.message ?? "blocked"}`;
89
132
  }).join("\n\n"),
90
133
  `${blockedItems.length} item(s) blocked by safety checks`
91
134
  );
92
135
  }
93
136
  if (eligibleItems.length === 0) {
94
- p.outro(pc.yellow("All items were blocked by safety checks."));
137
+ p2.outro(pc2.yellow("All items were blocked by safety checks."));
95
138
  return;
96
139
  }
97
140
  let selectedItems = eligibleItems;
@@ -102,31 +145,31 @@ async function cleanCommand(path = ".", options = {}) {
102
145
  const greenBytes = greenItems.reduce((s, i) => s + i.sizeBytes, 0);
103
146
  const yellowBytes = yellowItems.reduce((s, i) => s + i.sizeBytes, 0);
104
147
  const allBytes = eligibleItems.reduce((s, i) => s + i.sizeBytes, 0);
105
- const preset = await p.select({
148
+ const preset = await p2.select({
106
149
  message: "What would you like to clean?",
107
150
  options: [
108
151
  {
109
152
  value: "all",
110
- label: `All ${pc.dim(`${eligibleItems.length} items \xB7 ${formatBytes(allBytes)}`)}`
153
+ label: `All ${pc2.dim(`${eligibleItems.length} items \xB7 ${formatBytes(allBytes)}`)}`
111
154
  },
112
155
  ...greenItems.length > 0 ? [
113
156
  {
114
157
  value: "green",
115
- label: `${pc.green("Green only")} ${pc.dim(`${greenItems.length} items \xB7 ${formatBytes(greenBytes)} \xB7 safest`)}`
158
+ label: `${pc2.green("Green only")} ${pc2.dim(`${greenItems.length} items \xB7 ${formatBytes(greenBytes)} \xB7 safest`)}`
116
159
  }
117
160
  ] : [],
118
161
  ...yellowItems.length > 0 ? [
119
162
  {
120
163
  value: "yellow",
121
- label: `${pc.yellow("Yellow only")} ${pc.dim(`${yellowItems.length} items \xB7 ${formatBytes(yellowBytes)}`)}`
164
+ label: `${pc2.yellow("Yellow only")} ${pc2.dim(`${yellowItems.length} items \xB7 ${formatBytes(yellowBytes)}`)}`
122
165
  }
123
166
  ] : [],
124
167
  { value: "custom", label: "Custom (pick individual items)" },
125
- { value: "cancel", label: pc.dim("Cancel (do nothing, exit)") }
168
+ { value: "cancel", label: pc2.dim("Cancel (do nothing, exit)") }
126
169
  ]
127
170
  });
128
- if (p.isCancel(preset) || preset === "cancel") {
129
- p.cancel("Cleanup cancelled.");
171
+ if (p2.isCancel(preset) || preset === "cancel") {
172
+ p2.cancel("Cleanup cancelled.");
130
173
  return;
131
174
  }
132
175
  if (preset === "all") {
@@ -141,37 +184,37 @@ async function cleanCommand(path = ".", options = {}) {
141
184
  const warnings = checkResults[allItems.indexOf(item)]?.reasons.filter(
142
185
  (r) => r.severity === "warning"
143
186
  ) ?? [];
144
- const warnStr = warnings.length > 0 ? pc.yellow(` \u26A0 ${warnings.map((w) => w.message).join("; ")}`) : "";
187
+ const warnStr = warnings.length > 0 ? pc2.yellow(` \u26A0 ${warnings.map((w) => w.message).join("; ")}`) : "";
145
188
  return {
146
189
  value: item,
147
- label: `${RISK_BADGE[item.risk]} ${displayPath} ${pc.dim(formatBytes(item.sizeBytes))}${warnStr}`
190
+ label: `${RISK_BADGE[item.risk]} ${displayPath} ${pc2.dim(formatBytes(item.sizeBytes))}${warnStr}`
148
191
  };
149
192
  });
150
- const selection = await p.multiselect({
193
+ const selection = await p2.multiselect({
151
194
  message: "Select items to clean (space to toggle, enter to confirm):",
152
195
  options: choices,
153
196
  required: false
154
197
  });
155
- if (p.isCancel(selection)) {
156
- p.cancel("Cleanup cancelled.");
198
+ if (p2.isCancel(selection)) {
199
+ p2.cancel("Cleanup cancelled.");
157
200
  return;
158
201
  }
159
202
  selectedItems = selection;
160
203
  }
161
204
  }
162
205
  if (selectedItems.length === 0) {
163
- p.outro(pc.dim("Nothing selected."));
206
+ p2.outro(pc2.dim("Nothing selected."));
164
207
  return;
165
208
  }
166
209
  const totalBytes = selectedItems.reduce((s, i) => s + i.sizeBytes, 0);
167
210
  if (!isDryRun && !options.yes) {
168
- const action = options.hardDelete ? pc.red("PERMANENTLY DELETE") : "move to Trash";
169
- const confirmed = await p.confirm({
211
+ const action = options.hardDelete ? pc2.red("PERMANENTLY DELETE") : "move to Trash";
212
+ const confirmed = await p2.confirm({
170
213
  message: `${action} ${selectedItems.length} item(s) (${formatBytes(totalBytes)})?`,
171
214
  initialValue: false
172
215
  });
173
- if (p.isCancel(confirmed) || !confirmed) {
174
- p.cancel("Cleanup cancelled.");
216
+ if (p2.isCancel(confirmed) || !confirmed) {
217
+ p2.cancel("Cleanup cancelled.");
175
218
  return;
176
219
  }
177
220
  }
@@ -179,7 +222,7 @@ async function cleanCommand(path = ".", options = {}) {
179
222
  `executing ${selectedItems.length} items, dryRun=${isDryRun}, hardDelete=${options.hardDelete ?? false}`
180
223
  );
181
224
  for (const item of selectedItems) verbose(` \u2192 ${item.path}`);
182
- const execSpinner = p.spinner();
225
+ const execSpinner = p2.spinner();
183
226
  execSpinner.start(isDryRun ? "Simulating cleanup \u2026" : "Cleaning up \u2026");
184
227
  const result = await checker.execute(selectedItems, {
185
228
  dryRun: isDryRun,
@@ -191,26 +234,31 @@ async function cleanCommand(path = ".", options = {}) {
191
234
  const verb = isDryRun ? "Would free" : "Freed";
192
235
  console.log(
193
236
  `
194
- ${pc.green("\u2713")} ${verb} ${pc.bold(pc.green(formatBytes(result.totalBytesFreed)))} across ${result.succeeded.length} item(s).`
237
+ ${pc2.green("\u2713")} ${verb} ${pc2.bold(pc2.green(formatBytes(result.totalBytesFreed)))} across ${result.succeeded.length} item(s).`
195
238
  );
196
239
  }
197
240
  if (result.skipped.length > 0) {
198
- console.log(` ${pc.yellow("\u26A0")} ${result.skipped.length} item(s) skipped.`);
241
+ console.log(` ${pc2.yellow("\u26A0")} ${result.skipped.length} item(s) skipped.`);
199
242
  for (const s of result.skipped) {
200
243
  const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
201
244
  const displayPath = home ? s.item.path.replace(home, "~") : s.item.path;
202
- console.log(` ${pc.dim(displayPath)}: ${s.reason}`);
245
+ console.log(` ${pc2.dim(displayPath)}: ${s.reason}`);
203
246
  }
204
247
  }
205
248
  if (result.failed.length > 0) {
206
- console.log(` ${pc.red("\u2717")} ${result.failed.length} item(s) failed:`);
249
+ console.log(` ${pc2.red("\u2717")} ${result.failed.length} item(s) failed:`);
207
250
  for (const f of result.failed) {
208
- console.log(` ${pc.dim(f.item.path)}: ${f.error}`);
251
+ console.log(` ${pc2.dim(f.item.path)}: ${f.error}`);
209
252
  }
210
253
  }
254
+ if (!isDryRun && !options.hardDelete && result.succeeded.length > 0) {
255
+ console.log();
256
+ console.log(` ${pc2.dim("To restore: ")}${pc2.cyan(`shed undo --session ${result.sessionId}`)}`);
257
+ console.log(` ${pc2.dim("History: ")}${pc2.cyan("shed history")}`);
258
+ }
211
259
  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);
260
+ const outro8 = 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!";
261
+ p2.outro(outro8);
214
262
  }
215
263
 
216
264
  // src/commands/completions.ts
@@ -330,9 +378,9 @@ function completionsCommand(shell) {
330
378
  }
331
379
 
332
380
  // src/commands/config.ts
333
- import * as p2 from "@clack/prompts";
381
+ import * as p3 from "@clack/prompts";
334
382
  import Conf from "conf";
335
- import pc2 from "picocolors";
383
+ import pc3 from "picocolors";
336
384
  var DEFAULTS = {
337
385
  scan: { maxDepth: 8, maxAgeDays: 30 },
338
386
  clean: { hardDelete: false },
@@ -394,7 +442,7 @@ function flatSet(store, key, raw) {
394
442
  return true;
395
443
  }
396
444
  async function configCommand(action, key, value) {
397
- p2.intro(pc2.bgBlue(pc2.black(" shed config ")));
445
+ p3.intro(pc3.bgBlue(pc3.black(" shed config ")));
398
446
  const store = getStore();
399
447
  switch (action) {
400
448
  case "list":
@@ -402,16 +450,16 @@ async function configCommand(action, key, value) {
402
450
  const lines = Object.entries(KEY_DEFS).map(([k, def]) => {
403
451
  const val = flatGet(store, k);
404
452
  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)") : ""}`;
453
+ const valStr = isDefault ? pc3.dim(String(val)) : pc3.cyan(String(val));
454
+ return ` ${k.padEnd(22)} ${valStr}${isDefault ? pc3.dim(" (default)") : ""}`;
407
455
  });
408
- p2.note(lines.join("\n"), "Current configuration");
409
- p2.note(pc2.dim(store.path), "Config file");
456
+ p3.note(lines.join("\n"), "Current configuration");
457
+ p3.note(pc3.dim(store.path), "Config file");
410
458
  break;
411
459
  }
412
460
  case "get": {
413
461
  if (!key || !(key in KEY_DEFS)) {
414
- p2.cancel(
462
+ p3.cancel(
415
463
  !key ? "Usage: shed config get <key>" : `Unknown key: ${key}
416
464
  Valid: ${Object.keys(KEY_DEFS).join(", ")}`
417
465
  );
@@ -422,59 +470,59 @@ Valid: ${Object.keys(KEY_DEFS).join(", ")}`
422
470
  }
423
471
  case "set": {
424
472
  if (!key || value === void 0) {
425
- p2.cancel("Usage: shed config set <key> <value>");
473
+ p3.cancel("Usage: shed config set <key> <value>");
426
474
  process.exit(1);
427
475
  }
428
476
  if (!(key in KEY_DEFS)) {
429
- p2.cancel(`Unknown key: ${key}
477
+ p3.cancel(`Unknown key: ${key}
430
478
  Valid: ${Object.keys(KEY_DEFS).join(", ")}`);
431
479
  process.exit(1);
432
480
  }
433
481
  if (!flatSet(store, key, value)) {
434
- p2.cancel(`Invalid value "${value}" for key "${key}"`);
482
+ p3.cancel(`Invalid value "${value}" for key "${key}"`);
435
483
  process.exit(1);
436
484
  }
437
- p2.outro(`${pc2.cyan(key)} = ${pc2.green(value)}`);
485
+ p3.outro(`${pc3.cyan(key)} = ${pc3.green(value)}`);
438
486
  return;
439
487
  }
440
488
  case "reset": {
441
489
  if (key) {
442
490
  if (!(key in KEY_DEFS)) {
443
- p2.cancel(`Unknown key: ${key}`);
491
+ p3.cancel(`Unknown key: ${key}`);
444
492
  process.exit(1);
445
493
  }
446
494
  const [section] = key.split(".");
447
495
  store.set(section, DEFAULTS[section]);
448
- p2.outro(`${pc2.cyan(key)} reset to default.`);
496
+ p3.outro(`${pc3.cyan(key)} reset to default.`);
449
497
  } else {
450
- const confirmed = await p2.confirm({
498
+ const confirmed = await p3.confirm({
451
499
  message: "Reset ALL settings to defaults?",
452
500
  initialValue: false
453
501
  });
454
- if (p2.isCancel(confirmed) || !confirmed) {
455
- p2.cancel("Cancelled.");
502
+ if (p3.isCancel(confirmed) || !confirmed) {
503
+ p3.cancel("Cancelled.");
456
504
  return;
457
505
  }
458
506
  store.clear();
459
- p2.outro("All settings reset to defaults.");
507
+ p3.outro("All settings reset to defaults.");
460
508
  }
461
509
  return;
462
510
  }
463
511
  default:
464
- p2.cancel(`Unknown action: ${action}
512
+ p3.cancel(`Unknown action: ${action}
465
513
  Usage: shed config [list|get|set|reset]`);
466
514
  process.exit(1);
467
515
  }
468
- p2.outro(pc2.dim(`Edit directly: ${store.path}`));
516
+ p3.outro(pc3.dim(`Edit directly: ${store.path}`));
469
517
  }
470
518
 
471
519
  // src/commands/doctor.ts
472
520
  import { arch, homedir, platform, release } from "os";
473
- import * as p3 from "@clack/prompts";
521
+ import * as p4 from "@clack/prompts";
474
522
  import { execa } from "execa";
475
- import pc3 from "picocolors";
523
+ import pc4 from "picocolors";
476
524
  async function doctorCommand() {
477
- p3.intro(pc3.bgGreen(pc3.black(" shed doctor ")));
525
+ p4.intro(pc4.bgGreen(pc4.black(" shed doctor ")));
478
526
  const checks = [];
479
527
  checks.push({ name: "OS", value: `${platform()} ${release()} (${arch()})` });
480
528
  checks.push({ name: "Home", value: homedir() });
@@ -485,25 +533,97 @@ async function doctorCommand() {
485
533
  const { stdout } = await execa(tool, ["--version"], { reject: false });
486
534
  checks.push({ name: tool, value: stdout.split("\n")[0] ?? "unknown" });
487
535
  } catch {
488
- checks.push({ name: tool, value: pc3.dim("not installed") });
536
+ checks.push({ name: tool, value: pc4.dim("not installed") });
489
537
  }
490
538
  }
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."));
539
+ const body = checks.map((c) => ` ${pc4.cyan(c.name.padEnd(10))} ${c.value}`).join("\n");
540
+ p4.note(body, "Environment");
541
+ p4.outro(pc4.green("Environment check complete."));
542
+ }
543
+
544
+ // src/commands/history.ts
545
+ import * as p5 from "@clack/prompts";
546
+ import { OpLog as OpLog2 } from "@lexmanh/shed-core";
547
+ import pc5 from "picocolors";
548
+ function formatBytes2(bytes) {
549
+ if (bytes === 0) return "0 B";
550
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
551
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
552
+ return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
553
+ }
554
+ function formatRelative(ts) {
555
+ const ms = Date.now() - new Date(ts).getTime();
556
+ if (Number.isNaN(ms)) return ts;
557
+ const sec = Math.floor(ms / 1e3);
558
+ if (sec < 60) return `${sec}s ago`;
559
+ const min = Math.floor(sec / 60);
560
+ if (min < 60) return `${min}m ago`;
561
+ const hr = Math.floor(min / 60);
562
+ if (hr < 24) return `${hr}h ago`;
563
+ const day = Math.floor(hr / 24);
564
+ return `${day}d ago`;
565
+ }
566
+ var ACTION_BADGE = {
567
+ trash: pc5.cyan("TRASH "),
568
+ delete: pc5.red("DELETE"),
569
+ revert: pc5.green("REVERT")
570
+ };
571
+ var RISK_DOT = {
572
+ green: pc5.green("\u25CF"),
573
+ yellow: pc5.yellow("\u25CF"),
574
+ red: pc5.red("\u25CF")
575
+ };
576
+ async function historyCommand(options = {}) {
577
+ const limit = options.limit ? Math.max(1, Number.parseInt(options.limit, 10) || 20) : 20;
578
+ const oplog = new OpLog2();
579
+ const entries = await oplog.read({ limit, session: options.session });
580
+ if (options.json) {
581
+ console.log(JSON.stringify({ entries }, null, 2));
582
+ return;
583
+ }
584
+ p5.intro(pc5.bgMagenta(pc5.black(" shed history ")));
585
+ if (entries.length === 0) {
586
+ p5.note(
587
+ [
588
+ "No cleanup operations have been recorded yet.",
589
+ "",
590
+ `Run ${pc5.cyan("shed clean --execute")} to perform a real cleanup.`,
591
+ "",
592
+ pc5.dim("Operations are logged to ~/.shed/ops.jsonl when shed actually deletes files.")
593
+ ].join("\n"),
594
+ "Empty oplog"
595
+ );
596
+ p5.outro(pc5.dim("Nothing to show."));
597
+ return;
598
+ }
599
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
600
+ const lines = entries.map((e) => {
601
+ const path = home ? e.path.replace(home, "~") : e.path;
602
+ const reverted = e.reverted ? pc5.dim(" [reverted]") : "";
603
+ const tokenShort = e.token.slice(-8);
604
+ const when = formatRelative(e.ts);
605
+ return ` ${ACTION_BADGE[e.action]} ${RISK_DOT[e.risk]} ${pc5.dim(when.padEnd(7))} ${pc5.bold(tokenShort)} ${path} ${pc5.dim(formatBytes2(e.sizeBytes))}${reverted}`;
606
+ });
607
+ console.log();
608
+ console.log(` ${pc5.bold(`Last ${entries.length} operation(s):`)}`);
609
+ console.log();
610
+ for (const line of lines) console.log(line);
611
+ console.log();
612
+ p5.outro(
613
+ `Restore: ${pc5.cyan("shed undo <token>")} \xB7 All in session: ${pc5.cyan("shed undo --session <id>")}`
614
+ );
494
615
  }
495
616
 
496
617
  // src/commands/scan.ts
497
618
  import { createRequire } from "module";
498
619
  import { hostname } from "os";
499
- import { resolve as resolve2 } from "path";
500
- import * as p4 from "@clack/prompts";
620
+ import * as p6 from "@clack/prompts";
501
621
  import {
502
622
  RiskTier as RiskTier2,
503
623
  Scanner as Scanner2,
504
624
  defaultDetectors as defaultDetectors2
505
625
  } from "@lexmanh/shed-core";
506
- import pc4 from "picocolors";
626
+ import pc6 from "picocolors";
507
627
 
508
628
  // src/commands/scan-aggregate.ts
509
629
  import { dirname } from "path";
@@ -577,41 +697,52 @@ function selectTopGroups(groups, topN) {
577
697
  // src/commands/scan.ts
578
698
  var require2 = createRequire(import.meta.url);
579
699
  var { version: SHED_VERSION } = require2("../package.json");
580
- var JSON_SCHEMA_VERSION = 1;
700
+ var JSON_SCHEMA_VERSION = 2;
581
701
  var COMPACT_TOP_N = 15;
582
702
  var DETECTOR_BREAKDOWN_TOP_N = 6;
583
703
  var RISK_LABEL = {
584
- [RiskTier2.Green]: pc4.green("\u25CF Green"),
585
- [RiskTier2.Yellow]: pc4.yellow("\u25CF Yellow"),
586
- [RiskTier2.Red]: pc4.red("\u25CF Red")
704
+ [RiskTier2.Green]: pc6.green("\u25CF Green"),
705
+ [RiskTier2.Yellow]: pc6.yellow("\u25CF Yellow"),
706
+ [RiskTier2.Red]: pc6.red("\u25CF Red")
587
707
  };
588
708
  var RISK_ORDER = {
589
709
  [RiskTier2.Red]: 0,
590
710
  [RiskTier2.Yellow]: 1,
591
711
  [RiskTier2.Green]: 2
592
712
  };
593
- function formatBytes2(bytes) {
713
+ function formatBytes3(bytes) {
594
714
  if (bytes === 0) return "0 B";
595
715
  if (bytes < 1024) return `${bytes} B`;
596
716
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
597
717
  if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
598
718
  return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
599
719
  }
600
- async function scanCommand(path = ".", options = {}) {
601
- const rootDir = resolve2(path);
720
+ async function scanCommand(path, options = {}) {
602
721
  if (!options.json) {
603
- p4.intro(pc4.bgCyan(pc4.black(" shed scan ")));
722
+ p6.intro(pc6.bgCyan(pc6.black(" shed scan ")));
723
+ }
724
+ const scope = await resolveScanScope({ path, nonInteractive: options.json });
725
+ if (!scope.ok) {
726
+ if (options.json) {
727
+ console.log(
728
+ JSON.stringify({ schemaVersion: JSON_SCHEMA_VERSION, error: scope.message }, null, 2)
729
+ );
730
+ } else {
731
+ p6.cancel(scope.message);
732
+ }
733
+ return;
604
734
  }
605
- const spinner4 = options.json ? null : p4.spinner();
606
- verbose(`scan root: ${rootDir}`);
607
- spinner4?.start(`Scanning ${rootDir} \u2026`);
735
+ const spinner4 = options.json ? null : p6.spinner();
736
+ verbose(`scan roots: ${scope.roots.join(", ")}`);
737
+ spinner4?.start(
738
+ scope.roots.length === 1 ? `Scanning ${scope.roots[0]} \u2026` : `Scanning ${scope.roots.length} roots \u2026`
739
+ );
608
740
  const scanStartedAt = Date.now();
609
741
  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
- ]);
742
+ const projectsByRoot = await Promise.all(scope.roots.map((root) => scanner.scan(root)));
743
+ const projects = projectsByRoot.flat();
744
+ const ctx = { scanRoot: scope.roots[0] ?? "/", maxDepth: 8 };
745
+ const globalItems = await scanner.scanGlobal(ctx);
615
746
  const allItems = [
616
747
  ...projects.flatMap((proj) => proj.items),
617
748
  ...globalItems
@@ -623,7 +754,7 @@ async function scanCommand(path = ".", options = {}) {
623
754
  for (const item of allItems)
624
755
  verbose(` item: ${item.risk} ${item.path} (${item.sizeBytes} bytes)`);
625
756
  spinner4?.stop(
626
- `Found ${pc4.bold(String(allItems.length))} cleanable items across ${projects.length} project(s).`
757
+ `Found ${pc6.bold(String(allItems.length))} cleanable items across ${projects.length} project(s).`
627
758
  );
628
759
  if (options.json) {
629
760
  const byRisk = { green: 0, yellow: 0, red: 0 };
@@ -650,7 +781,7 @@ async function scanCommand(path = ".", options = {}) {
650
781
  arch: process.arch
651
782
  },
652
783
  scan: {
653
- root: rootDir,
784
+ roots: scope.roots,
654
785
  durationMs: Date.now() - scanStartedAt
655
786
  },
656
787
  summary: {
@@ -669,8 +800,8 @@ async function scanCommand(path = ".", options = {}) {
669
800
  return;
670
801
  }
671
802
  if (allItems.length === 0) {
672
- p4.note("Nothing found to clean in this directory.", "Result");
673
- p4.outro(pc4.dim("All clear!"));
803
+ p6.note("Nothing found to clean in this directory.", "Result");
804
+ p6.outro(pc6.dim("All clear!"));
674
805
  return;
675
806
  }
676
807
  if (options.all) {
@@ -679,8 +810,8 @@ async function scanCommand(path = ".", options = {}) {
679
810
  renderCompact(allItems);
680
811
  }
681
812
  console.log();
682
- p4.outro(
683
- `Total recoverable: ${pc4.bold(pc4.green(formatBytes2(totalBytes)))} \u2014 run ${pc4.cyan("shed clean")} to proceed.`
813
+ p6.outro(
814
+ `Total recoverable: ${pc6.bold(pc6.green(formatBytes3(totalBytes)))} \u2014 run ${pc6.cyan("shed clean")} to proceed.`
684
815
  );
685
816
  }
686
817
  function renderCompact(allItems) {
@@ -693,26 +824,26 @@ function renderCompact(allItems) {
693
824
  if (item.metadata?.detectOnly === true) detectOnly++;
694
825
  byDetector.set(item.detector, (byDetector.get(item.detector) ?? 0) + item.sizeBytes);
695
826
  }
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)`) : ""}`;
697
- const detectorLine = [...byDetector.entries()].sort(([, a], [, b]) => b - a).slice(0, DETECTOR_BREAKDOWN_TOP_N).map(([d, b]) => `${d} ${formatBytes2(b)}`).join(" \xB7 ");
827
+ const riskLine = `${pc6.green(`\u25CF ${byRisk.green} Green`)} ${pc6.yellow(`\u25CF ${byRisk.yellow} Yellow`)} ${pc6.red(`\u25CF ${byRisk.red} Red`)}${detectOnly > 0 ? pc6.dim(` (${detectOnly} detect-only)`) : ""}`;
828
+ const detectorLine = [...byDetector.entries()].sort(([, a], [, b]) => b - a).slice(0, DETECTOR_BREAKDOWN_TOP_N).map(([d, b]) => `${d} ${formatBytes3(b)}`).join(" \xB7 ");
698
829
  console.log();
699
830
  console.log(` By risk: ${riskLine}`);
700
- console.log(` By detector: ${pc4.dim(detectorLine)}`);
831
+ console.log(` By detector: ${pc6.dim(detectorLine)}`);
701
832
  const groups = aggregateForDisplay(allItems);
702
833
  const { shown, hidden } = selectTopGroups(groups, COMPACT_TOP_N);
703
834
  console.log();
704
- console.log(` ${pc4.bold(`Top ${shown.length} items:`)}`);
835
+ console.log(` ${pc6.bold(`Top ${shown.length} items:`)}`);
705
836
  for (const g of shown) {
706
837
  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)}`) : "";
838
+ const tag = g.type === "aggregate" ? pc6.dim(` (${g.itemCount} ${g.detector} items)`) : "";
839
+ const size = g.totalBytes > 0 ? pc6.dim(` ${formatBytes3(g.totalBytes)}`) : "";
709
840
  console.log(` ${RISK_LABEL[g.risk]} ${path}${tag}${size}`);
710
841
  }
711
842
  if (hidden.groupCount > 0) {
712
843
  console.log();
713
844
  console.log(
714
- pc4.dim(
715
- ` \u2026 ${hidden.groupCount} more groups (${hidden.itemCount} items, ${formatBytes2(hidden.totalBytes)}) \u2014 use --all to see everything`
845
+ pc6.dim(
846
+ ` \u2026 ${hidden.groupCount} more groups (${hidden.itemCount} items, ${formatBytes3(hidden.totalBytes)}) \u2014 use --all to see everything`
716
847
  )
717
848
  );
718
849
  }
@@ -727,66 +858,138 @@ function renderFull(allItems) {
727
858
  byProject.set(key, group);
728
859
  }
729
860
  for (const [projectRoot, items] of byProject.entries()) {
730
- const projectLabel = projectRoot === "(global)" ? pc4.dim("global caches") : pc4.cyan(home ? projectRoot.replace(home, "~") : projectRoot);
861
+ const projectLabel = projectRoot === "(global)" ? pc6.dim("global caches") : pc6.cyan(home ? projectRoot.replace(home, "~") : projectRoot);
731
862
  const groupTotal = items.reduce((s, i) => s + i.sizeBytes, 0);
732
863
  console.log(`
733
- ${projectLabel} ${pc4.dim(formatBytes2(groupTotal))}`);
864
+ ${projectLabel} ${pc6.dim(formatBytes3(groupTotal))}`);
734
865
  for (const item of items) {
735
- const size = item.sizeBytes > 0 ? pc4.dim(` ${formatBytes2(item.sizeBytes)}`) : "";
866
+ const size = item.sizeBytes > 0 ? pc6.dim(` ${formatBytes3(item.sizeBytes)}`) : "";
736
867
  const displayPath = home ? item.path.replace(home, "~") : item.path;
737
868
  const shortPath = projectRoot !== "(global)" ? displayPath.replace(home ? projectRoot.replace(home, "~") : projectRoot, "").replace(/^\//, "") || displayPath : displayPath;
738
869
  console.log(` ${RISK_LABEL[item.risk]} ${shortPath}${size}`);
739
- console.log(` ${pc4.dim(` ${item.description}`)}`);
870
+ console.log(` ${pc6.dim(` ${item.description}`)}`);
740
871
  }
741
872
  }
742
873
  }
743
874
 
744
875
  // src/commands/undo.ts
745
- import { platform as platform2 } from "os";
746
- import * as p5 from "@clack/prompts";
747
- import pc5 from "picocolors";
748
- function trashPath() {
749
- switch (platform2()) {
750
- case "darwin":
751
- return "~/.Trash";
752
- case "win32":
753
- return "Recycle Bin";
754
- default:
755
- return "~/.local/share/Trash";
756
- }
876
+ import * as p7 from "@clack/prompts";
877
+ import { OpLog as OpLog3, restoreFromTrash } from "@lexmanh/shed-core";
878
+ import pc7 from "picocolors";
879
+ function formatBytes4(bytes) {
880
+ if (bytes === 0) return "0 B";
881
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
882
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
883
+ return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
757
884
  }
758
- function trashOpenHint() {
759
- switch (platform2()) {
760
- case "darwin":
761
- return "open ~/.Trash (or click Trash in Dock \u2192 right-click \u2192 Put Back)";
762
- case "win32":
763
- return "Open Recycle Bin on desktop \u2192 right-click item \u2192 Restore";
764
- default:
765
- return "nautilus trash:/// or gio trash --list / gio trash --restore";
885
+ async function undoCommand(token, options = {}) {
886
+ p7.intro(pc7.bgMagenta(pc7.black(" shed undo ")));
887
+ const oplog = new OpLog3();
888
+ let targets;
889
+ if (token) {
890
+ const found = await oplog.findByToken(token);
891
+ if (!found) {
892
+ p7.cancel(`No operation found with token: ${token}`);
893
+ return;
894
+ }
895
+ targets = [found];
896
+ } else if (options.last) {
897
+ const recent = await oplog.read({ limit: 1 });
898
+ if (recent.length === 0) {
899
+ p7.cancel("No operations recorded yet.");
900
+ return;
901
+ }
902
+ targets = [...recent];
903
+ } else if (options.session) {
904
+ targets = [...await oplog.read({ session: options.session })];
905
+ if (targets.length === 0) {
906
+ p7.cancel(`No operations found for session: ${options.session}`);
907
+ return;
908
+ }
909
+ } else {
910
+ targets = await pickInteractively(oplog);
911
+ if (targets.length === 0) return;
912
+ }
913
+ const restorable = targets.filter((t) => t.action === "trash" && !t.reverted);
914
+ const skipped = targets.length - restorable.length;
915
+ if (restorable.length === 0) {
916
+ if (skipped > 0) {
917
+ p7.cancel(
918
+ `Nothing to restore. ${skipped} op(s) were either hard-deleted or already reverted.`
919
+ );
920
+ } else {
921
+ p7.cancel("Nothing to restore.");
922
+ }
923
+ return;
924
+ }
925
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
926
+ let restored = 0;
927
+ let failed = 0;
928
+ for (const op of restorable) {
929
+ const display = home ? op.path.replace(home, "~") : op.path;
930
+ const result = await restoreFromTrash(op.path);
931
+ if (result.ok) {
932
+ await oplog.markReverted(op.token);
933
+ console.log(
934
+ ` ${pc7.green("\u2713")} restored ${pc7.bold(display)} ${pc7.dim(formatBytes4(op.sizeBytes))}`
935
+ );
936
+ restored++;
937
+ } else {
938
+ console.log(` ${pc7.red("\u2717")} ${display}: ${pc7.dim(result.message)}`);
939
+ failed++;
940
+ }
941
+ }
942
+ console.log();
943
+ if (failed === 0) {
944
+ p7.outro(pc7.green(`Restored ${restored} item(s).`));
945
+ } else if (restored === 0) {
946
+ p7.outro(pc7.red(`Failed to restore any of ${failed} item(s).`));
947
+ } else {
948
+ p7.outro(`Restored ${restored} of ${restored + failed} item(s).`);
766
949
  }
767
950
  }
768
- async function undoCommand() {
769
- p5.intro(pc5.bgMagenta(pc5.black(" shed undo ")));
770
- p5.note(
771
- [
772
- "shed clean moves items to your OS Trash by default.",
773
- "",
774
- ` Trash location: ${pc5.cyan(trashPath())}`,
775
- "",
776
- ` To restore: ${pc5.dim(trashOpenHint())}`,
777
- "",
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.")
780
- ].join("\n"),
781
- "How to undo a cleanup"
782
- );
783
- p5.outro(pc5.green("Nothing to do \u2014 restore items via your OS Trash."));
951
+ async function pickInteractively(oplog) {
952
+ const recent = await oplog.read({ limit: 20 });
953
+ const restorable = recent.filter((e) => e.action === "trash" && !e.reverted);
954
+ if (restorable.length === 0) {
955
+ p7.note(
956
+ [
957
+ "No restorable operations in your oplog.",
958
+ "",
959
+ `Run ${pc7.cyan("shed history")} to see all recorded operations,`,
960
+ `or ${pc7.cyan("shed clean --execute")} to perform a real cleanup.`,
961
+ "",
962
+ pc7.dim("Note: hard-deletes (--hard-delete) cannot be undone.")
963
+ ].join("\n"),
964
+ "Nothing to undo"
965
+ );
966
+ p7.outro(pc7.dim("Empty."));
967
+ return [];
968
+ }
969
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
970
+ const choice = await p7.select({
971
+ message: "Pick an operation to restore:",
972
+ options: restorable.map((e) => {
973
+ const display = home ? e.path.replace(home, "~") : e.path;
974
+ const tokenShort = e.token.slice(-8);
975
+ return {
976
+ value: e.token,
977
+ label: `${pc7.bold(tokenShort)} ${display} ${pc7.dim(formatBytes4(e.sizeBytes))}`
978
+ };
979
+ })
980
+ });
981
+ if (p7.isCancel(choice) || typeof choice !== "string") {
982
+ p7.cancel("Cancelled.");
983
+ return [];
984
+ }
985
+ const picked = restorable.find((e) => e.token === choice);
986
+ return picked ? [picked] : [];
784
987
  }
785
988
 
786
989
  // src/commands/upgrade.ts
787
- import * as p6 from "@clack/prompts";
990
+ import * as p8 from "@clack/prompts";
788
991
  import { execa as execa2 } from "execa";
789
- import pc6 from "picocolors";
992
+ import pc8 from "picocolors";
790
993
 
791
994
  // src/update/detect-install.ts
792
995
  import { realpathSync } from "fs";
@@ -794,32 +997,32 @@ import { constants, access } from "fs/promises";
794
997
  import { dirname as dirname2 } from "path";
795
998
  var PACKAGE_NAME = "@lexmanh/shed-cli";
796
999
  function classifyInstall(resolvedPath) {
797
- const p7 = resolvedPath.replace(/\\/g, "/").toLowerCase();
798
- if (p7.includes("/_npx/") || p7.includes("/npx-cache/")) {
1000
+ const p9 = resolvedPath.replace(/\\/g, "/").toLowerCase();
1001
+ if (p9.includes("/_npx/") || p9.includes("/npx-cache/")) {
799
1002
  return {
800
1003
  kind: "npx",
801
1004
  note: "Running via npx (ephemeral). Re-run with `npx @lexmanh/shed-cli@latest`."
802
1005
  };
803
1006
  }
804
- if (p7.includes("/bun/install/cache/") || p7.includes("/.bun/install/cache/")) {
1007
+ if (p9.includes("/bun/install/cache/") || p9.includes("/.bun/install/cache/")) {
805
1008
  return {
806
1009
  kind: "bunx",
807
1010
  note: "Running via bunx (ephemeral). Re-run with `bunx @lexmanh/shed-cli@latest`."
808
1011
  };
809
1012
  }
810
- if (p7.includes("/.volta/") || p7.includes("/volta/tools/")) {
1013
+ if (p9.includes("/.volta/") || p9.includes("/volta/tools/")) {
811
1014
  return { kind: "volta" };
812
1015
  }
813
- if (p7.includes("/pnpm/global/") || p7.includes("/library/pnpm/") || p7.includes("/.local/share/pnpm/")) {
1016
+ if (p9.includes("/pnpm/global/") || p9.includes("/library/pnpm/") || p9.includes("/.local/share/pnpm/")) {
814
1017
  return { kind: "pnpm-global" };
815
1018
  }
816
- if (p7.includes("/yarn/global/") || p7.includes("/.config/yarn/global/")) {
1019
+ if (p9.includes("/yarn/global/") || p9.includes("/.config/yarn/global/")) {
817
1020
  return { kind: "yarn-global" };
818
1021
  }
819
- if (p7.includes("/.bun/install/global/")) {
1022
+ if (p9.includes("/.bun/install/global/")) {
820
1023
  return { kind: "bun-global" };
821
1024
  }
822
- if (p7.includes("/node_modules/")) {
1025
+ if (p9.includes("/node_modules/")) {
823
1026
  return { kind: "npm-global" };
824
1027
  }
825
1028
  return { kind: "unknown" };
@@ -849,12 +1052,12 @@ function detectInstall(binPath) {
849
1052
  } catch {
850
1053
  resolvedPath = binPath;
851
1054
  }
852
- const { kind, note: note7 } = classifyInstall(resolvedPath);
1055
+ const { kind, note: note9 } = classifyInstall(resolvedPath);
853
1056
  return {
854
1057
  kind,
855
1058
  upgradeCommand: buildUpgradeCommand(kind),
856
1059
  resolvedPath,
857
- note: note7
1060
+ note: note9
858
1061
  };
859
1062
  }
860
1063
  async function needsElevation(resolvedPath) {
@@ -945,93 +1148,93 @@ function isNewer(latest, current) {
945
1148
 
946
1149
  // src/commands/upgrade.ts
947
1150
  async function upgradeCommand(opts, currentVersion) {
948
- p6.intro(pc6.bgMagenta(pc6.black(" shed upgrade ")));
1151
+ p8.intro(pc8.bgMagenta(pc8.black(" shed upgrade ")));
949
1152
  const install = detectInstall(process.argv[1] ?? "");
950
- const spin = p6.spinner();
1153
+ const spin = p8.spinner();
951
1154
  spin.start("Checking npm registry\u2026");
952
1155
  const latest = await fetchLatestVersion({ force: true });
953
1156
  spin.stop(latest ? `Latest: v${latest}` : "Could not reach registry");
954
1157
  if (!latest) {
955
- p6.outro(pc6.yellow("No upgrade information available. Check your network and try again."));
1158
+ p8.outro(pc8.yellow("No upgrade information available. Check your network and try again."));
956
1159
  process.exit(1);
957
1160
  }
958
1161
  if (!isNewer(latest, currentVersion)) {
959
- p6.note(
960
- `Installed: ${pc6.cyan(`v${currentVersion}`)}
961
- Latest: ${pc6.cyan(`v${latest}`)}`,
1162
+ p8.note(
1163
+ `Installed: ${pc8.cyan(`v${currentVersion}`)}
1164
+ Latest: ${pc8.cyan(`v${latest}`)}`,
962
1165
  "Already up to date"
963
1166
  );
964
- p6.outro(pc6.green("Nothing to do."));
1167
+ p8.outro(pc8.green("Nothing to do."));
965
1168
  return;
966
1169
  }
967
- p6.note(
1170
+ p8.note(
968
1171
  [
969
- `Installed: ${pc6.dim(`v${currentVersion}`)}`,
970
- `Latest: ${pc6.green(`v${latest}`)}`,
971
- `Source: ${pc6.cyan(install.kind)}`,
972
- `Path: ${pc6.dim(install.resolvedPath)}`
1172
+ `Installed: ${pc8.dim(`v${currentVersion}`)}`,
1173
+ `Latest: ${pc8.green(`v${latest}`)}`,
1174
+ `Source: ${pc8.cyan(install.kind)}`,
1175
+ `Path: ${pc8.dim(install.resolvedPath)}`
973
1176
  ].join("\n"),
974
1177
  "Upgrade available"
975
1178
  );
976
1179
  if (!install.upgradeCommand) {
977
- p6.note(
1180
+ p8.note(
978
1181
  install.note ?? "Could not detect how shed was installed.",
979
- pc6.yellow("Cannot self-upgrade")
1182
+ pc8.yellow("Cannot self-upgrade")
980
1183
  );
981
- p6.outro(pc6.dim("Re-install manually using your preferred package manager."));
1184
+ p8.outro(pc8.dim("Re-install manually using your preferred package manager."));
982
1185
  return;
983
1186
  }
984
1187
  const elevate = await needsElevation(install.resolvedPath);
985
1188
  const finalCommand = elevate ? `sudo ${install.upgradeCommand}` : install.upgradeCommand;
986
1189
  if (opts.check) {
987
- p6.note(finalCommand, "Run this to upgrade");
988
- p6.outro(pc6.dim("(--check mode: nothing executed)"));
1190
+ p8.note(finalCommand, "Run this to upgrade");
1191
+ p8.outro(pc8.dim("(--check mode: nothing executed)"));
989
1192
  return;
990
1193
  }
991
1194
  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."));
1195
+ p8.note(finalCommand, pc8.yellow("Install dir is not writable \u2014 run this manually"));
1196
+ p8.outro(pc8.dim("Re-run `shed upgrade` after the install completes to verify."));
994
1197
  return;
995
1198
  }
996
1199
  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.");
1200
+ const ok = await p8.confirm({ message: `Run \`${finalCommand}\` now?`, initialValue: true });
1201
+ if (p8.isCancel(ok) || !ok) {
1202
+ p8.cancel("Upgrade cancelled.");
1000
1203
  return;
1001
1204
  }
1002
1205
  }
1003
- const runSpin = p6.spinner();
1206
+ const runSpin = p8.spinner();
1004
1207
  runSpin.start(`Running ${finalCommand}\u2026`);
1005
1208
  try {
1006
1209
  const [bin, ...args] = finalCommand.split(" ");
1007
1210
  if (!bin) throw new Error("Empty upgrade command");
1008
1211
  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."));
1212
+ runSpin.stop(pc8.green(`Upgraded to v${latest}.`));
1213
+ p8.outro(pc8.green("Done. Re-run `shed --version` to confirm."));
1011
1214
  } catch (err) {
1012
- runSpin.stop(pc6.red("Upgrade failed."));
1215
+ runSpin.stop(pc8.red("Upgrade failed."));
1013
1216
  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}`));
1217
+ p8.note(message, pc8.red("Error"));
1218
+ p8.outro(pc8.dim(`You can retry manually: ${finalCommand}`));
1016
1219
  process.exit(1);
1017
1220
  }
1018
1221
  }
1019
1222
 
1020
1223
  // src/update/notifier.ts
1021
- import pc7 from "picocolors";
1224
+ import pc9 from "picocolors";
1022
1225
  function maybeNotifyOfUpdate(currentVersion) {
1023
1226
  const cached = readCachedLatest();
1024
1227
  if (!cached || !isNewer(cached, currentVersion)) return;
1025
1228
  const install = detectInstall(process.argv[1] ?? "");
1026
1229
  const cmd = install.upgradeCommand ?? "shed upgrade";
1027
1230
  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}\`).`)
1231
+ pc9.yellow("\u25B2"),
1232
+ pc9.dim(`shed v${currentVersion} \u2192`),
1233
+ pc9.green(`v${cached}`),
1234
+ pc9.dim("available."),
1235
+ pc9.dim("Run"),
1236
+ pc9.cyan("`shed upgrade`"),
1237
+ pc9.dim(`(or \`${cmd}\`).`)
1035
1238
  ].join(" ");
1036
1239
  console.log(banner);
1037
1240
  }
@@ -1042,7 +1245,7 @@ function scheduleBackgroundRefresh() {
1042
1245
  }
1043
1246
 
1044
1247
  // src/logo.ts
1045
- import pc8 from "picocolors";
1248
+ import pc10 from "picocolors";
1046
1249
  var CABIN = [" \u2571\u2572 ", " \u2571\u2500\u2500\u2572 ", " \u2571\u2500\u2500\u2500\u2500\u2572 ", " \u2502 \u2588\u2588 \u2502 ", " \u2514\u2500\u2500\u2500\u2500\u2518 "];
1047
1250
  var WORDMARK = [
1048
1251
  " ____ _ _ ",
@@ -1053,11 +1256,11 @@ var WORDMARK = [
1053
1256
  ];
1054
1257
  function printLogo(version2) {
1055
1258
  for (let i = 0; i < CABIN.length; i++) {
1056
- console.log(pc8.yellow(CABIN[i]) + pc8.cyan(WORDMARK[i]));
1259
+ console.log(pc10.yellow(CABIN[i]) + pc10.cyan(WORDMARK[i]));
1057
1260
  }
1058
- console.log(` ${pc8.dim(`v${version2} \xB7 safe disk cleanup \xB7 dev machines & servers`)}`);
1261
+ console.log(` ${pc10.dim(`v${version2} \xB7 safe disk cleanup \xB7 dev machines & servers`)}`);
1059
1262
  console.log(
1060
- ` ${pc8.dim("by")} ${pc8.white("L\xEA Xu\xE2n M\u1EA1nh")} ${pc8.dim("\xB7 https://github.com/lexmanh/shed")}
1263
+ ` ${pc10.dim("by")} ${pc10.white("L\xEA Xu\xE2n M\u1EA1nh")} ${pc10.dim("\xB7 https://github.com/lexmanh/shed")}
1061
1264
  `
1062
1265
  );
1063
1266
  }
@@ -1069,7 +1272,8 @@ var program = new Command();
1069
1272
  program.name("shed").description("Safe disk cleanup for dev machines and Linux servers").version(version).option("-v, --verbose", "Enable verbose logging");
1070
1273
  program.command("scan [path]").description("Scan for cleanable items without modifying anything").option("--json", "Output machine-readable JSON").option("--max-age <days>", "Only include items older than N days", "30").option("--all", "Show every item (default: compact summary with top 15)").action(scanCommand);
1071
1274
  program.command("clean [path]").description("Interactive cleanup of detected items").option("--dry-run", "Preview operations without executing", true).option("--execute", "Actually perform the cleanup (overrides --dry-run)").option("--hard-delete", "Skip Trash, delete permanently").option("--include-red", "Include Red-tier (high-risk) items").option("--yes", "Skip interactive confirmations (CI mode)").action(cleanCommand);
1072
- program.command("undo").description("List and restore items from previous cleanups").action(undoCommand);
1275
+ program.command("undo [token]").description("Restore a previously cleaned item from Trash").option("--last", "Restore the most recent operation").option("--session <id>", "Restore every operation from a session").action(undoCommand);
1276
+ program.command("history").description("List recent cleanup operations from the oplog").option("--limit <n>", "Show at most N entries", "20").option("--session <id>", "Filter to a single session ULID").option("--json", "Output machine-readable JSON").action(historyCommand);
1073
1277
  program.command("doctor").description("Check environment and configuration").action(doctorCommand);
1074
1278
  program.command("config").description("Manage user preferences").argument("[action]", "get | set | list | reset").argument("[key]", "Configuration key").argument("[value]", "Configuration value (for set)").action(configCommand);
1075
1279
  program.command("completions").description("Print shell completion script").argument("<shell>", "bash | zsh | fish").action(completionsCommand);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lexmanh/shed-cli",
3
- "version": "0.3.0-beta.1",
3
+ "version": "0.4.0-beta.1",
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.3.0-beta.1"
26
+ "@lexmanh/shed-core": "0.4.0-beta.1"
27
27
  },
28
28
  "devDependencies": {
29
29
  "@types/node": "^22.10.0",