@lexmanh/shed-cli 0.2.0-beta.1 → 0.2.0-beta.10

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 +570 -87
  2. package/package.json +7 -3
package/dist/cli.js CHANGED
@@ -1,25 +1,17 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import { createRequire } from "module";
4
+ import { createRequire as createRequire2 } from "module";
5
5
  import { Command } from "commander";
6
6
 
7
7
  // src/commands/clean.ts
8
8
  import { resolve } from "path";
9
9
  import * as p from "@clack/prompts";
10
10
  import {
11
- AndroidDetector,
12
- CocoaPodsDetector,
13
- DockerDetector,
14
- FlutterDetector,
15
- IdeDetector,
16
- NodeDetector,
17
- PythonDetector,
18
11
  RiskTier,
19
- RustDetector,
20
12
  SafetyChecker,
21
13
  Scanner,
22
- XcodeDetector
14
+ defaultDetectors
23
15
  } from "@lexmanh/shed-core";
24
16
  import pc from "picocolors";
25
17
 
@@ -58,20 +50,10 @@ async function cleanCommand(path = ".", options = {}) {
58
50
  "Safe mode"
59
51
  );
60
52
  }
61
- const spinner3 = p.spinner();
53
+ const spinner4 = p.spinner();
62
54
  verbose(`clean root: ${rootDir}, dryRun=${isDryRun}, hardDelete=${options.hardDelete ?? false}`);
63
- spinner3.start(`Scanning ${rootDir} \u2026`);
64
- const scanner = new Scanner([
65
- new NodeDetector(),
66
- new PythonDetector(),
67
- new RustDetector(),
68
- new DockerDetector(),
69
- new XcodeDetector(),
70
- new FlutterDetector(),
71
- new AndroidDetector(),
72
- new CocoaPodsDetector(),
73
- new IdeDetector()
74
- ]);
55
+ spinner4.start(`Scanning ${rootDir} \u2026`);
56
+ const scanner = new Scanner(defaultDetectors());
75
57
  const ctx = { scanRoot: rootDir, maxDepth: 8 };
76
58
  const [projects, globalItems] = await Promise.all([
77
59
  scanner.scan(rootDir),
@@ -82,7 +64,7 @@ async function cleanCommand(path = ".", options = {}) {
82
64
  ...globalItems
83
65
  ].filter((i) => options.includeRed || i.risk !== RiskTier.Red);
84
66
  verbose(`scan complete: ${allItems.length} cleanable items`);
85
- spinner3.stop(`Found ${pc.bold(String(allItems.length))} cleanable items.`);
67
+ spinner4.stop(`Found ${pc.bold(String(allItems.length))} cleanable items.`);
86
68
  if (allItems.length === 0) {
87
69
  p.outro(pc.dim("Nothing to clean."));
88
70
  return;
@@ -139,10 +121,11 @@ async function cleanCommand(path = ".", options = {}) {
139
121
  label: `${pc.yellow("Yellow only")} ${pc.dim(`${yellowItems.length} items \xB7 ${formatBytes(yellowBytes)}`)}`
140
122
  }
141
123
  ] : [],
142
- { value: "custom", label: "Custom (pick individual items)" }
124
+ { value: "custom", label: "Custom (pick individual items)" },
125
+ { value: "cancel", label: pc.dim("Cancel (do nothing, exit)") }
143
126
  ]
144
127
  });
145
- if (p.isCancel(preset)) {
128
+ if (p.isCancel(preset) || preset === "cancel") {
146
129
  p.cancel("Cleanup cancelled.");
147
130
  return;
148
131
  }
@@ -226,8 +209,124 @@ async function cleanCommand(path = ".", options = {}) {
226
209
  }
227
210
  }
228
211
  console.log();
229
- const outro6 = 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!";
230
- p.outro(outro6);
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);
214
+ }
215
+
216
+ // src/commands/completions.ts
217
+ var BASH = `# shed bash completion
218
+ _shed_completions() {
219
+ local cur prev cmds
220
+ COMPREPLY=()
221
+ cur="\${COMP_WORDS[COMP_CWORD]}"
222
+ prev="\${COMP_WORDS[COMP_CWORD-1]}"
223
+ cmds="scan clean undo doctor config completions"
224
+
225
+ case "\${prev}" in
226
+ scan)
227
+ COMPREPLY=( $(compgen -W "--json --max-age --all" -- "\${cur}") )
228
+ return 0
229
+ ;;
230
+ clean)
231
+ COMPREPLY=( $(compgen -W "--dry-run --execute --hard-delete --include-red --yes" -- "\${cur}") )
232
+ return 0
233
+ ;;
234
+ config)
235
+ COMPREPLY=( $(compgen -W "get set list reset" -- "\${cur}") )
236
+ return 0
237
+ ;;
238
+ completions)
239
+ COMPREPLY=( $(compgen -W "bash zsh fish" -- "\${cur}") )
240
+ return 0
241
+ ;;
242
+ esac
243
+
244
+ if [ "\${COMP_CWORD}" -eq 1 ]; then
245
+ COMPREPLY=( $(compgen -W "\${cmds} --version --help --verbose" -- "\${cur}") )
246
+ return 0
247
+ fi
248
+ }
249
+ complete -F _shed_completions shed
250
+ `;
251
+ var ZSH = `#compdef shed
252
+ # shed zsh completion
253
+
254
+ _shed() {
255
+ local -a commands
256
+ commands=(
257
+ 'scan:Scan for cleanable items without modifying anything'
258
+ 'clean:Interactive cleanup of detected items'
259
+ 'undo:List and restore items from previous cleanups'
260
+ 'doctor:Check environment and configuration'
261
+ 'config:Manage user preferences'
262
+ 'completions:Print shell completion script (bash | zsh | fish)'
263
+ )
264
+
265
+ if (( CURRENT == 2 )); then
266
+ _describe -t commands 'shed command' commands
267
+ return
268
+ fi
269
+
270
+ case "\${words[2]}" in
271
+ scan)
272
+ _arguments \\
273
+ '--json[Output machine-readable JSON]' \\
274
+ '--max-age[Only include items older than N days]:days' \\
275
+ '--all[Show every item (default: compact summary)]'
276
+ ;;
277
+ clean)
278
+ _arguments \\
279
+ '--dry-run[Preview operations without executing]' \\
280
+ '--execute[Actually perform the cleanup]' \\
281
+ '--hard-delete[Skip Trash, delete permanently]' \\
282
+ '--include-red[Include Red-tier (high-risk) items]' \\
283
+ '--yes[Skip interactive confirmations (CI mode)]'
284
+ ;;
285
+ config)
286
+ _values 'config action' get set list reset
287
+ ;;
288
+ completions)
289
+ _values 'shell' bash zsh fish
290
+ ;;
291
+ esac
292
+ }
293
+
294
+ _shed "$@"
295
+ `;
296
+ var FISH = `# shed fish completion
297
+ complete -c shed -f
298
+
299
+ # subcommands
300
+ complete -c shed -n '__fish_use_subcommand' -a 'scan' -d 'Scan for cleanable items'
301
+ complete -c shed -n '__fish_use_subcommand' -a 'clean' -d 'Interactive cleanup'
302
+ complete -c shed -n '__fish_use_subcommand' -a 'undo' -d 'Restore from previous cleanups'
303
+ complete -c shed -n '__fish_use_subcommand' -a 'doctor' -d 'Check environment'
304
+ complete -c shed -n '__fish_use_subcommand' -a 'config' -d 'Manage preferences'
305
+ complete -c shed -n '__fish_use_subcommand' -a 'completions' -d 'Print shell completion script'
306
+
307
+ # scan flags
308
+ complete -c shed -n '__fish_seen_subcommand_from scan' -l json -d 'Output JSON'
309
+ complete -c shed -n '__fish_seen_subcommand_from scan' -l max-age -d 'Min age in days'
310
+ complete -c shed -n '__fish_seen_subcommand_from scan' -l all -d 'Show every item'
311
+
312
+ # clean flags
313
+ complete -c shed -n '__fish_seen_subcommand_from clean' -l dry-run -d 'Preview only'
314
+ complete -c shed -n '__fish_seen_subcommand_from clean' -l execute -d 'Actually delete'
315
+ complete -c shed -n '__fish_seen_subcommand_from clean' -l hard-delete -d 'Skip Trash'
316
+ complete -c shed -n '__fish_seen_subcommand_from clean' -l include-red -d 'Include Red tier'
317
+ complete -c shed -n '__fish_seen_subcommand_from clean' -l yes -d 'Skip confirmations'
318
+
319
+ # config + completions argument values
320
+ complete -c shed -n '__fish_seen_subcommand_from config' -a 'get set list reset'
321
+ complete -c shed -n '__fish_seen_subcommand_from completions' -a 'bash zsh fish'
322
+ `;
323
+ var SCRIPTS = { bash: BASH, zsh: ZSH, fish: FISH };
324
+ function completionsCommand(shell) {
325
+ if (shell !== "bash" && shell !== "zsh" && shell !== "fish") {
326
+ console.error("shed completions: shell must be one of: bash, zsh, fish");
327
+ process.exit(1);
328
+ }
329
+ process.stdout.write(SCRIPTS[shell]);
231
330
  }
232
331
 
233
332
  // src/commands/config.ts
@@ -395,30 +494,92 @@ async function doctorCommand() {
395
494
  }
396
495
 
397
496
  // src/commands/scan.ts
497
+ import { createRequire } from "module";
498
+ import { hostname } from "os";
398
499
  import { resolve as resolve2 } from "path";
399
500
  import * as p4 from "@clack/prompts";
400
501
  import {
401
- AndroidDetector as AndroidDetector2,
402
- CocoaPodsDetector as CocoaPodsDetector2,
403
- DatabaseDetector,
404
- DockerDetector as DockerDetector2,
405
- DotnetDetector,
406
- FlutterDetector as FlutterDetector2,
407
- GoDetector,
408
- IdeDetector as IdeDetector2,
409
- JavaGradleDetector,
410
- JavaMavenDetector,
411
- NodeDetector as NodeDetector2,
412
- PythonDetector as PythonDetector2,
413
502
  RiskTier as RiskTier2,
414
- RubyDetector,
415
- RustDetector as RustDetector2,
416
503
  Scanner as Scanner2,
417
- SystemDetector,
418
- WebserverDetector,
419
- XcodeDetector as XcodeDetector2
504
+ defaultDetectors as defaultDetectors2
420
505
  } from "@lexmanh/shed-core";
421
506
  import pc4 from "picocolors";
507
+
508
+ // src/commands/scan-aggregate.ts
509
+ import { dirname } from "path";
510
+ var AGGREGATE_THRESHOLD = 3;
511
+ function aggregateForDisplay(items) {
512
+ const buckets = /* @__PURE__ */ new Map();
513
+ const singles = [];
514
+ for (const item of items) {
515
+ const kind = item.metadata?.kind ?? null;
516
+ if (!kind) {
517
+ singles.push(item);
518
+ continue;
519
+ }
520
+ const key = `${dirname(item.path)}::${item.detector}::${kind}`;
521
+ const arr = buckets.get(key) ?? [];
522
+ arr.push(item);
523
+ buckets.set(key, arr);
524
+ }
525
+ const result = [];
526
+ for (const arr of buckets.values()) {
527
+ if (arr.length >= AGGREGATE_THRESHOLD) {
528
+ const first = arr[0];
529
+ if (!first) continue;
530
+ const totalBytes = arr.reduce((s, i) => s + i.sizeBytes, 0);
531
+ const kind = first.metadata?.kind;
532
+ const parentDir = dirname(first.path);
533
+ const displayPath = parentDir === "." ? kind : parentDir;
534
+ result.push({
535
+ type: "aggregate",
536
+ risk: first.risk,
537
+ displayPath,
538
+ description: `${arr.length} ${kind} files`,
539
+ totalBytes,
540
+ detector: first.detector,
541
+ itemCount: arr.length,
542
+ items: arr
543
+ });
544
+ } else {
545
+ for (const item of arr) result.push(toSingle(item));
546
+ }
547
+ }
548
+ for (const item of singles) result.push(toSingle(item));
549
+ return result;
550
+ }
551
+ function toSingle(item) {
552
+ return {
553
+ type: "single",
554
+ risk: item.risk,
555
+ displayPath: item.path,
556
+ description: item.description,
557
+ totalBytes: item.sizeBytes,
558
+ detector: item.detector,
559
+ itemCount: 1,
560
+ items: [item]
561
+ };
562
+ }
563
+ function selectTopGroups(groups, topN) {
564
+ const sorted = [...groups].sort((a, b) => b.totalBytes - a.totalBytes);
565
+ const shown = sorted.slice(0, topN);
566
+ const rest = sorted.slice(topN);
567
+ return {
568
+ shown,
569
+ hidden: {
570
+ groupCount: rest.length,
571
+ itemCount: rest.reduce((s, g) => s + g.itemCount, 0),
572
+ totalBytes: rest.reduce((s, g) => s + g.totalBytes, 0)
573
+ }
574
+ };
575
+ }
576
+
577
+ // src/commands/scan.ts
578
+ var require2 = createRequire(import.meta.url);
579
+ var { version: SHED_VERSION } = require2("../package.json");
580
+ var JSON_SCHEMA_VERSION = 1;
581
+ var COMPACT_TOP_N = 15;
582
+ var DETECTOR_BREAKDOWN_TOP_N = 6;
422
583
  var RISK_LABEL = {
423
584
  [RiskTier2.Green]: pc4.green("\u25CF Green"),
424
585
  [RiskTier2.Yellow]: pc4.yellow("\u25CF Yellow"),
@@ -441,28 +602,11 @@ async function scanCommand(path = ".", options = {}) {
441
602
  if (!options.json) {
442
603
  p4.intro(pc4.bgCyan(pc4.black(" shed scan ")));
443
604
  }
444
- const spinner3 = options.json ? null : p4.spinner();
605
+ const spinner4 = options.json ? null : p4.spinner();
445
606
  verbose(`scan root: ${rootDir}`);
446
- spinner3?.start(`Scanning ${rootDir} \u2026`);
447
- const scanner = new Scanner2([
448
- new NodeDetector2(),
449
- new PythonDetector2(),
450
- new RustDetector2(),
451
- new GoDetector(),
452
- new JavaMavenDetector(),
453
- new JavaGradleDetector(),
454
- new RubyDetector(),
455
- new DotnetDetector(),
456
- new DockerDetector2(),
457
- new XcodeDetector2(),
458
- new FlutterDetector2(),
459
- new AndroidDetector2(),
460
- new CocoaPodsDetector2(),
461
- new IdeDetector2(),
462
- new SystemDetector(),
463
- new WebserverDetector(),
464
- new DatabaseDetector()
465
- ]);
607
+ spinner4?.start(`Scanning ${rootDir} \u2026`);
608
+ const scanStartedAt = Date.now();
609
+ const scanner = new Scanner2(defaultDetectors2());
466
610
  const ctx = { scanRoot: rootDir, maxDepth: 8 };
467
611
  const [projects, globalItems] = await Promise.all([
468
612
  scanner.scan(rootDir),
@@ -478,17 +622,45 @@ async function scanCommand(path = ".", options = {}) {
478
622
  );
479
623
  for (const item of allItems)
480
624
  verbose(` item: ${item.risk} ${item.path} (${item.sizeBytes} bytes)`);
481
- spinner3?.stop(
625
+ spinner4?.stop(
482
626
  `Found ${pc4.bold(String(allItems.length))} cleanable items across ${projects.length} project(s).`
483
627
  );
484
628
  if (options.json) {
629
+ const byRisk = { green: 0, yellow: 0, red: 0 };
630
+ let detectOnly = 0;
631
+ for (const item of allItems) {
632
+ byRisk[item.risk]++;
633
+ if (item.metadata?.detectOnly === true) detectOnly++;
634
+ }
635
+ const projectsOut = projects.map((proj) => ({
636
+ root: proj.root,
637
+ detectors: [...new Set(proj.items.map((i) => i.detector))],
638
+ itemCount: proj.items.length,
639
+ totalBytes: proj.items.reduce((s, i) => s + i.sizeBytes, 0)
640
+ }));
485
641
  console.log(
486
642
  JSON.stringify(
487
643
  {
488
- root: rootDir,
489
- projects: projects.length,
490
- items: allItems,
491
- totalBytes
644
+ schemaVersion: JSON_SCHEMA_VERSION,
645
+ shedVersion: SHED_VERSION,
646
+ timestamp: new Date(scanStartedAt).toISOString(),
647
+ host: {
648
+ hostname: hostname(),
649
+ platform: process.platform,
650
+ arch: process.arch
651
+ },
652
+ scan: {
653
+ root: rootDir,
654
+ durationMs: Date.now() - scanStartedAt
655
+ },
656
+ summary: {
657
+ totalBytes,
658
+ totalItems: allItems.length,
659
+ byRisk,
660
+ detectOnly
661
+ },
662
+ projects: projectsOut,
663
+ items: allItems
492
664
  },
493
665
  null,
494
666
  2
@@ -501,6 +673,52 @@ async function scanCommand(path = ".", options = {}) {
501
673
  p4.outro(pc4.dim("All clear!"));
502
674
  return;
503
675
  }
676
+ if (options.all) {
677
+ renderFull(allItems);
678
+ } else {
679
+ renderCompact(allItems);
680
+ }
681
+ console.log();
682
+ p4.outro(
683
+ `Total recoverable: ${pc4.bold(pc4.green(formatBytes2(totalBytes)))} \u2014 run ${pc4.cyan("shed clean")} to proceed.`
684
+ );
685
+ }
686
+ function renderCompact(allItems) {
687
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
688
+ const byRisk = { green: 0, yellow: 0, red: 0 };
689
+ let detectOnly = 0;
690
+ const byDetector = /* @__PURE__ */ new Map();
691
+ for (const item of allItems) {
692
+ byRisk[item.risk]++;
693
+ if (item.metadata?.detectOnly === true) detectOnly++;
694
+ byDetector.set(item.detector, (byDetector.get(item.detector) ?? 0) + item.sizeBytes);
695
+ }
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 ");
698
+ console.log();
699
+ console.log(` By risk: ${riskLine}`);
700
+ console.log(` By detector: ${pc4.dim(detectorLine)}`);
701
+ const groups = aggregateForDisplay(allItems);
702
+ const { shown, hidden } = selectTopGroups(groups, COMPACT_TOP_N);
703
+ console.log();
704
+ console.log(` ${pc4.bold(`Top ${shown.length} items:`)}`);
705
+ for (const g of shown) {
706
+ 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)}`) : "";
709
+ console.log(` ${RISK_LABEL[g.risk]} ${path}${tag}${size}`);
710
+ }
711
+ if (hidden.groupCount > 0) {
712
+ console.log();
713
+ console.log(
714
+ pc4.dim(
715
+ ` \u2026 ${hidden.groupCount} more groups (${hidden.itemCount} items, ${formatBytes2(hidden.totalBytes)}) \u2014 use --all to see everything`
716
+ )
717
+ );
718
+ }
719
+ }
720
+ function renderFull(allItems) {
721
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
504
722
  const byProject = /* @__PURE__ */ new Map();
505
723
  for (const item of allItems) {
506
724
  const key = item.projectRoot ?? "(global)";
@@ -509,7 +727,6 @@ async function scanCommand(path = ".", options = {}) {
509
727
  byProject.set(key, group);
510
728
  }
511
729
  for (const [projectRoot, items] of byProject.entries()) {
512
- const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
513
730
  const projectLabel = projectRoot === "(global)" ? pc4.dim("global caches") : pc4.cyan(home ? projectRoot.replace(home, "~") : projectRoot);
514
731
  const groupTotal = items.reduce((s, i) => s + i.sizeBytes, 0);
515
732
  console.log(`
@@ -522,10 +739,6 @@ async function scanCommand(path = ".", options = {}) {
522
739
  console.log(` ${pc4.dim(` ${item.description}`)}`);
523
740
  }
524
741
  }
525
- console.log();
526
- p4.outro(
527
- `Total recoverable: ${pc4.bold(pc4.green(formatBytes2(totalBytes)))} \u2014 run ${pc4.cyan("shed clean")} to proceed.`
528
- );
529
742
  }
530
743
 
531
744
  // src/commands/undo.ts
@@ -570,39 +783,309 @@ async function undoCommand() {
570
783
  p5.outro(pc5.green("Nothing to do \u2014 restore items via your OS Trash."));
571
784
  }
572
785
 
573
- // src/logo.ts
786
+ // src/commands/upgrade.ts
787
+ import * as p6 from "@clack/prompts";
788
+ import { execa as execa2 } from "execa";
574
789
  import pc6 from "picocolors";
575
- var ART = [
790
+
791
+ // src/update/detect-install.ts
792
+ import { realpathSync } from "fs";
793
+ import { constants, access } from "fs/promises";
794
+ import { dirname as dirname2 } from "path";
795
+ var PACKAGE_NAME = "@lexmanh/shed-cli";
796
+ function classifyInstall(resolvedPath) {
797
+ const p7 = resolvedPath.replace(/\\/g, "/").toLowerCase();
798
+ if (p7.includes("/_npx/") || p7.includes("/npx-cache/")) {
799
+ return {
800
+ kind: "npx",
801
+ note: "Running via npx (ephemeral). Re-run with `npx @lexmanh/shed-cli@latest`."
802
+ };
803
+ }
804
+ if (p7.includes("/bun/install/cache/") || p7.includes("/.bun/install/cache/")) {
805
+ return {
806
+ kind: "bunx",
807
+ note: "Running via bunx (ephemeral). Re-run with `bunx @lexmanh/shed-cli@latest`."
808
+ };
809
+ }
810
+ if (p7.includes("/.volta/") || p7.includes("/volta/tools/")) {
811
+ return { kind: "volta" };
812
+ }
813
+ if (p7.includes("/pnpm/global/") || p7.includes("/library/pnpm/") || p7.includes("/.local/share/pnpm/")) {
814
+ return { kind: "pnpm-global" };
815
+ }
816
+ if (p7.includes("/yarn/global/") || p7.includes("/.config/yarn/global/")) {
817
+ return { kind: "yarn-global" };
818
+ }
819
+ if (p7.includes("/.bun/install/global/")) {
820
+ return { kind: "bun-global" };
821
+ }
822
+ if (p7.includes("/node_modules/")) {
823
+ return { kind: "npm-global" };
824
+ }
825
+ return { kind: "unknown" };
826
+ }
827
+ function buildUpgradeCommand(kind, pkg = PACKAGE_NAME) {
828
+ switch (kind) {
829
+ case "npm-global":
830
+ return `npm i -g ${pkg}@latest`;
831
+ case "pnpm-global":
832
+ return `pnpm add -g ${pkg}@latest`;
833
+ case "yarn-global":
834
+ return `yarn global add ${pkg}@latest`;
835
+ case "bun-global":
836
+ return `bun add -g ${pkg}@latest`;
837
+ case "volta":
838
+ return `volta install ${pkg}@latest`;
839
+ case "npx":
840
+ case "bunx":
841
+ case "unknown":
842
+ return null;
843
+ }
844
+ }
845
+ function detectInstall(binPath) {
846
+ let resolvedPath;
847
+ try {
848
+ resolvedPath = realpathSync(binPath);
849
+ } catch {
850
+ resolvedPath = binPath;
851
+ }
852
+ const { kind, note: note7 } = classifyInstall(resolvedPath);
853
+ return {
854
+ kind,
855
+ upgradeCommand: buildUpgradeCommand(kind),
856
+ resolvedPath,
857
+ note: note7
858
+ };
859
+ }
860
+ async function needsElevation(resolvedPath) {
861
+ if (process.platform === "win32") return false;
862
+ try {
863
+ await access(dirname2(resolvedPath), constants.W_OK);
864
+ return false;
865
+ } catch {
866
+ return true;
867
+ }
868
+ }
869
+
870
+ // src/update/registry.ts
871
+ import Conf2 from "conf";
872
+ var REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
873
+ var CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
874
+ var DEFAULT_TIMEOUT_MS = 3e3;
875
+ function getCache() {
876
+ return new Conf2({ projectName: "shed", configName: "update-cache" });
877
+ }
878
+ function readCachedLatest() {
879
+ const last = getCache().get("lastCheck");
880
+ if (!last) return null;
881
+ if (Date.now() - last.checkedAt >= CACHE_TTL_MS) return null;
882
+ return last.version;
883
+ }
884
+ async function fetchLatestVersion(opts = {}) {
885
+ if (!opts.force) {
886
+ const cached = readCachedLatest();
887
+ if (cached) return cached;
888
+ }
889
+ try {
890
+ const ctrl = new AbortController();
891
+ const t = setTimeout(() => ctrl.abort(), opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
892
+ const res = await fetch(REGISTRY_URL, {
893
+ signal: ctrl.signal,
894
+ headers: { Accept: "application/json" }
895
+ });
896
+ clearTimeout(t);
897
+ if (!res.ok) return null;
898
+ const json = await res.json();
899
+ if (!json.version) return null;
900
+ getCache().set("lastCheck", { version: json.version, checkedAt: Date.now() });
901
+ return json.version;
902
+ } catch {
903
+ return null;
904
+ }
905
+ }
906
+ function compareSemver(a, b) {
907
+ const [aMain, aPre = ""] = a.split("-");
908
+ const [bMain, bPre = ""] = b.split("-");
909
+ const aParts = (aMain ?? "").split(".").map((n2) => Number(n2) || 0);
910
+ const bParts = (bMain ?? "").split(".").map((n2) => Number(n2) || 0);
911
+ for (let i = 0; i < 3; i++) {
912
+ const x = aParts[i] ?? 0;
913
+ const y = bParts[i] ?? 0;
914
+ if (x !== y) return x - y;
915
+ }
916
+ if (aPre === "" && bPre === "") return 0;
917
+ if (aPre === "") return 1;
918
+ if (bPre === "") return -1;
919
+ const aP = aPre.split(".");
920
+ const bP = bPre.split(".");
921
+ const n = Math.max(aP.length, bP.length);
922
+ for (let i = 0; i < n; i++) {
923
+ const x = aP[i];
924
+ const y = bP[i];
925
+ if (x === void 0) return -1;
926
+ if (y === void 0) return 1;
927
+ const xIsNum = /^\d+$/.test(x);
928
+ const yIsNum = /^\d+$/.test(y);
929
+ if (xIsNum && yIsNum) {
930
+ const diff = Number(x) - Number(y);
931
+ if (diff !== 0) return diff;
932
+ } else if (xIsNum) {
933
+ return -1;
934
+ } else if (yIsNum) {
935
+ return 1;
936
+ } else if (x !== y) {
937
+ return x > y ? 1 : -1;
938
+ }
939
+ }
940
+ return 0;
941
+ }
942
+ function isNewer(latest, current) {
943
+ return compareSemver(latest, current) > 0;
944
+ }
945
+
946
+ // src/commands/upgrade.ts
947
+ async function upgradeCommand(opts, currentVersion) {
948
+ p6.intro(pc6.bgMagenta(pc6.black(" shed upgrade ")));
949
+ const install = detectInstall(process.argv[1] ?? "");
950
+ const spin = p6.spinner();
951
+ spin.start("Checking npm registry\u2026");
952
+ const latest = await fetchLatestVersion({ force: true });
953
+ spin.stop(latest ? `Latest: v${latest}` : "Could not reach registry");
954
+ if (!latest) {
955
+ p6.outro(pc6.yellow("No upgrade information available. Check your network and try again."));
956
+ process.exit(1);
957
+ }
958
+ if (!isNewer(latest, currentVersion)) {
959
+ p6.note(
960
+ `Installed: ${pc6.cyan(`v${currentVersion}`)}
961
+ Latest: ${pc6.cyan(`v${latest}`)}`,
962
+ "Already up to date"
963
+ );
964
+ p6.outro(pc6.green("Nothing to do."));
965
+ return;
966
+ }
967
+ p6.note(
968
+ [
969
+ `Installed: ${pc6.dim(`v${currentVersion}`)}`,
970
+ `Latest: ${pc6.green(`v${latest}`)}`,
971
+ `Source: ${pc6.cyan(install.kind)}`,
972
+ `Path: ${pc6.dim(install.resolvedPath)}`
973
+ ].join("\n"),
974
+ "Upgrade available"
975
+ );
976
+ if (!install.upgradeCommand) {
977
+ p6.note(
978
+ install.note ?? "Could not detect how shed was installed.",
979
+ pc6.yellow("Cannot self-upgrade")
980
+ );
981
+ p6.outro(pc6.dim("Re-install manually using your preferred package manager."));
982
+ return;
983
+ }
984
+ const elevate = await needsElevation(install.resolvedPath);
985
+ const finalCommand = elevate ? `sudo ${install.upgradeCommand}` : install.upgradeCommand;
986
+ if (opts.check) {
987
+ p6.note(finalCommand, "Run this to upgrade");
988
+ p6.outro(pc6.dim("(--check mode: nothing executed)"));
989
+ return;
990
+ }
991
+ 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."));
994
+ return;
995
+ }
996
+ 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.");
1000
+ return;
1001
+ }
1002
+ }
1003
+ const runSpin = p6.spinner();
1004
+ runSpin.start(`Running ${finalCommand}\u2026`);
1005
+ try {
1006
+ const [bin, ...args] = finalCommand.split(" ");
1007
+ if (!bin) throw new Error("Empty upgrade command");
1008
+ 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."));
1011
+ } catch (err) {
1012
+ runSpin.stop(pc6.red("Upgrade failed."));
1013
+ 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}`));
1016
+ process.exit(1);
1017
+ }
1018
+ }
1019
+
1020
+ // src/update/notifier.ts
1021
+ import pc7 from "picocolors";
1022
+ function maybeNotifyOfUpdate(currentVersion) {
1023
+ const cached = readCachedLatest();
1024
+ if (!cached || !isNewer(cached, currentVersion)) return;
1025
+ const install = detectInstall(process.argv[1] ?? "");
1026
+ const cmd = install.upgradeCommand ?? "shed upgrade";
1027
+ 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}\`).`)
1035
+ ].join(" ");
1036
+ console.log(banner);
1037
+ }
1038
+ function scheduleBackgroundRefresh() {
1039
+ if (readCachedLatest() !== null) return;
1040
+ void fetchLatestVersion({ force: false, timeoutMs: 1500 }).catch(() => {
1041
+ });
1042
+ }
1043
+
1044
+ // src/logo.ts
1045
+ import pc8 from "picocolors";
1046
+ 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
+ var WORDMARK = [
576
1048
  " ____ _ _ ",
577
1049
  " / ___|| |__ ___ __| |",
578
1050
  " \\___ \\| '_ \\ / _ \\/ _` |",
579
1051
  " ___) | | | | __/ (_| |",
580
1052
  " |____/|_| |_|\\___|\\__,_|"
581
- ].join("\n");
1053
+ ];
582
1054
  function printLogo(version2) {
583
- console.log(pc6.cyan(ART));
584
- console.log(` ${pc6.dim(`v${version2} \xB7 safe disk cleanup for developers`)}`);
1055
+ for (let i = 0; i < CABIN.length; i++) {
1056
+ console.log(pc8.yellow(CABIN[i]) + pc8.cyan(WORDMARK[i]));
1057
+ }
1058
+ console.log(` ${pc8.dim(`v${version2} \xB7 safe disk cleanup \xB7 dev machines & servers`)}`);
585
1059
  console.log(
586
- ` ${pc6.dim("by")} ${pc6.white("L\xEA Xu\xE2n M\u1EA1nh")} ${pc6.dim("\xB7 https://github.com/lexmanh/shed")}
1060
+ ` ${pc8.dim("by")} ${pc8.white("L\xEA Xu\xE2n M\u1EA1nh")} ${pc8.dim("\xB7 https://github.com/lexmanh/shed")}
587
1061
  `
588
1062
  );
589
1063
  }
590
1064
 
591
1065
  // src/cli.ts
592
- var require2 = createRequire(import.meta.url);
593
- var { version } = require2("../package.json");
1066
+ var require3 = createRequire2(import.meta.url);
1067
+ var { version } = require3("../package.json");
594
1068
  var program = new Command();
595
- program.name("shed").description("Safe, cross-platform disk cleanup for developers").version(version).option("-v, --verbose", "Enable verbose logging");
596
- 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").action(scanCommand);
1069
+ program.name("shed").description("Safe disk cleanup for dev machines and Linux servers").version(version).option("-v, --verbose", "Enable verbose logging");
1070
+ 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);
597
1071
  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);
598
1072
  program.command("undo").description("List and restore items from previous cleanups").action(undoCommand);
599
1073
  program.command("doctor").description("Check environment and configuration").action(doctorCommand);
600
1074
  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
+ program.command("completions").description("Print shell completion script").argument("<shell>", "bash | zsh | fish").action(completionsCommand);
1076
+ program.command("upgrade").alias("update").description("Check for and install the latest version of shed").option("--check", "Only check; print the upgrade command without running it").option("--yes", "Skip the confirmation prompt").action((opts) => upgradeCommand(opts, version));
601
1077
  program.hook("preAction", (_thisCommand, actionCommand) => {
602
1078
  const opts = program.opts();
603
1079
  setVerbose(opts.verbose ?? false);
604
1080
  const cmdOpts = actionCommand.opts();
605
- if (!cmdOpts.json) printLogo(version);
1081
+ const cmdName = actionCommand.name();
1082
+ const isCompletions = cmdName === "completions";
1083
+ const isUpgrade = cmdName === "upgrade";
1084
+ if (!cmdOpts.json && !isCompletions) printLogo(version);
1085
+ if (!cmdOpts.json && !isCompletions && !isUpgrade) {
1086
+ maybeNotifyOfUpdate(version);
1087
+ scheduleBackgroundRefresh();
1088
+ }
606
1089
  });
607
1090
  program.parseAsync(process.argv).catch((err) => {
608
1091
  console.error("shed: fatal error:", err instanceof Error ? err.message : err);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lexmanh/shed-cli",
3
- "version": "0.2.0-beta.1",
4
- "description": "Safe, cross-platform disk cleanup CLI for developers reclaim space from dev caches without breaking active work",
3
+ "version": "0.2.0-beta.10",
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": {
7
7
  "shed": "./dist/cli.js"
@@ -10,6 +10,10 @@
10
10
  "dist",
11
11
  "package.json"
12
12
  ],
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "https://github.com/lexmanh/shed.git"
16
+ },
13
17
  "publishConfig": {
14
18
  "access": "public"
15
19
  },
@@ -19,7 +23,7 @@
19
23
  "conf": "^13.1.0",
20
24
  "execa": "^9.5.0",
21
25
  "picocolors": "^1.1.1",
22
- "@lexmanh/shed-core": "0.2.0-beta.1"
26
+ "@lexmanh/shed-core": "0.2.0-beta.10"
23
27
  },
24
28
  "devDependencies": {
25
29
  "@types/node": "^22.10.0",