@git.zone/tsdisk 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ts/tsdisk.cli.ts CHANGED
@@ -1,26 +1,32 @@
1
1
  import * as plugins from './tsdisk.plugins.js';
2
2
  import { commitinfo } from './00_commitinfo_data.js';
3
+ import { scanWorkspace, type IWorkspaceScanOptions, type IScanIssue } from './tsdisk.scan.js';
4
+ import { parseCliOptions, createCleanupPlan, type ICliOptions } from './tsdisk.cleanup.js';
5
+ import { measureDiskUsage, normalizeScanRoots, type IUsageReport } from './tsdisk.usage.js';
6
+ import { DiskProgressReporter, estimateReclaimableSpace, formatBytes, type IDiskProgress } from './tsdisk.progress.js';
7
+ export { formatBytes } from './tsdisk.progress.js';
8
+ export type { IWorkspaceScanOptions } from './tsdisk.scan.js';
3
9
  import {
4
10
  classifyToolCachePath,
5
- parseToolCacheMarker,
6
- toolCacheMarkerFile,
7
11
  type IToolCacheFinding,
8
- type IToolCacheMarker,
9
12
  } from './tsdisk.toolcaches.js';
10
13
 
11
14
  interface ICommandResult {
12
15
  code: number;
13
16
  stdout: string;
14
17
  stderr: string;
18
+ truncated?: boolean;
15
19
  }
16
20
 
17
21
  interface IRunOptions {
18
22
  asRoot?: boolean;
19
23
  asTargetUser?: boolean;
20
24
  timeoutSeconds?: number;
25
+ cwd?: string;
21
26
  }
22
27
 
23
28
  export interface IRuntimeContext {
29
+ onProgress?: (progress: Partial<IDiskProgress>) => void;
24
30
  runningAsRoot: boolean;
25
31
  sudoAvailable: boolean;
26
32
  sudoChecked: boolean;
@@ -51,11 +57,6 @@ interface INcduParseResult {
51
57
  insights: INcduInsight[];
52
58
  }
53
59
 
54
- export interface IWorkspaceScanOptions {
55
- maxDepth: number;
56
- timeoutMs: number;
57
- maxEntries: number;
58
- }
59
60
 
60
61
  const decoder = new TextDecoder();
61
62
  const maxCapturedOutputBytes = 64 * 1024 * 1024;
@@ -92,24 +93,6 @@ function firstLines(text: string, maxLines = 8): string {
92
93
  return text.trim().split(/\r?\n/).slice(0, maxLines).join('\n');
93
94
  }
94
95
 
95
- export function formatBytes(bytes: number): string {
96
- if (!Number.isFinite(bytes) || bytes < 0) {
97
- return 'n/a';
98
- }
99
-
100
- const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
101
- let value = bytes;
102
- let unitIndex = 0;
103
-
104
- while (value >= 1024 && unitIndex < units.length - 1) {
105
- value = value / 1024;
106
- unitIndex++;
107
- }
108
-
109
- const decimals = unitIndex === 0 || value >= 100 ? 0 : value >= 10 ? 1 : 2;
110
- return `${value.toFixed(decimals)} ${units[unitIndex]}`;
111
- }
112
-
113
96
  function printSizeLine(label: string, bytes: number) {
114
97
  console.log(`${formatBytes(bytes).padStart(10)} ${label}`);
115
98
  }
@@ -209,6 +192,7 @@ function commandResultFromCapture(code: number, stdout: ICaptureState, stderr: I
209
192
  truncationMessages.push(`stderr truncated after ${formatBytes(maxCapturedOutputBytes)}`);
210
193
  }
211
194
  if (truncationMessages.length) {
195
+ result.truncated = true;
212
196
  result.stderr = [result.stderr.trimEnd(), `[tsdisk] ${truncationMessages.join('; ')}.`]
213
197
  .filter(Boolean)
214
198
  .join('\n');
@@ -525,6 +509,7 @@ async function runRootInteractive(
525
509
  args: string[],
526
510
  timeoutSeconds?: number,
527
511
  ): Promise<number> {
512
+ ctx.onProgress?.({ stage: `${command} ${args.slice(0, 3).join(' ')}` });
528
513
  let executable = command;
529
514
  let finalArgs = args;
530
515
 
@@ -563,6 +548,7 @@ async function runCommand(
563
548
  args: string[],
564
549
  options: IRunOptions = {},
565
550
  ): Promise<ICommandResult> {
551
+ ctx.onProgress?.({ stage: `${command} ${args.slice(0, 3).join(' ')}` });
566
552
  let executable = command;
567
553
  let finalArgs = [...args];
568
554
  const env: NodeJS.ProcessEnv = {};
@@ -591,12 +577,13 @@ async function runCommand(
591
577
  }
592
578
 
593
579
  if (options.timeoutSeconds && options.timeoutSeconds > 0) {
594
- finalArgs = [`${options.timeoutSeconds}s`, executable, ...finalArgs];
580
+ finalArgs = ['--kill-after=5s', `${options.timeoutSeconds}s`, executable, ...finalArgs];
595
581
  executable = 'timeout';
596
582
  }
597
583
 
598
584
  return await new Promise<ICommandResult>((resolve) => {
599
585
  const child = plugins.spawn(executable, finalArgs, {
586
+ cwd: options.cwd,
600
587
  env: {
601
588
  ...process.env,
602
589
  ...env,
@@ -679,119 +666,8 @@ async function pathExists(path: string): Promise<boolean> {
679
666
  }
680
667
  }
681
668
 
682
- async function readMarkerInDirectory(pathArg: string): Promise<IToolCacheMarker | undefined> {
683
- try {
684
- const markerRaw = await plugins.fs.readFile(plugins.path.join(pathArg, toolCacheMarkerFile), 'utf8');
685
- return parseToolCacheMarker(markerRaw);
686
- } catch {
687
- return undefined;
688
- }
689
- }
690
-
691
- function shouldSkipWorkspaceDir(nameArg: string): boolean {
692
- return nameArg === '.git'
693
- || nameArg === 'node_modules'
694
- || nameArg === 'dist_ts'
695
- || nameArg === 'dist_ts_web'
696
- || nameArg === 'coverage'
697
- || nameArg === '.pnpm-store';
698
- }
699
-
700
- export async function collectToolCachePathsFromRoot(
701
- rootArg: string,
702
- optionsArg: IWorkspaceScanOptions,
703
- ): Promise<IToolCacheFinding[]> {
704
- const findings = new Map<string, IToolCacheFinding>();
705
- const startedAt = Date.now();
706
- let visitedEntries = 0;
707
- let rootDev: number | undefined;
708
-
709
- try {
710
- rootDev = (await plugins.fs.stat(rootArg)).dev;
711
- } catch {
712
- return [];
713
- }
714
-
715
- const visit = async (dirArg: string, depthArg: number): Promise<void> => {
716
- if (Date.now() - startedAt > optionsArg.timeoutMs || visitedEntries > optionsArg.maxEntries) {
717
- return;
718
- }
719
-
720
- let entries;
721
- try {
722
- entries = await plugins.fs.readdir(dirArg, { withFileTypes: true });
723
- } catch {
724
- return;
725
- }
726
-
727
- const marker = await readMarkerInDirectory(dirArg);
728
- const markerClassification = classifyToolCachePath(dirArg, marker);
729
- if (markerClassification) {
730
- findings.set(plugins.path.resolve(dirArg), {
731
- path: dirArg,
732
- ...markerClassification,
733
- });
734
- }
735
-
736
- const heuristicClassification = classifyToolCachePath(dirArg);
737
- if (heuristicClassification) {
738
- if (!markerClassification) {
739
- findings.set(plugins.path.resolve(dirArg), {
740
- path: dirArg,
741
- ...heuristicClassification,
742
- });
743
- }
744
- if (heuristicClassification.kind === 'docker-registry-cache') {
745
- for (const entry of entries) {
746
- if (Date.now() - startedAt > optionsArg.timeoutMs || visitedEntries > optionsArg.maxEntries) {
747
- return;
748
- }
749
- visitedEntries++;
750
- if (!entry.isDirectory()) {
751
- continue;
752
- }
753
- const entryPath = plugins.path.join(dirArg, entry.name);
754
- const entryMarker = await readMarkerInDirectory(entryPath);
755
- const entryMarkerClassification = classifyToolCachePath(entryPath, entryMarker);
756
- if (entryMarkerClassification) {
757
- findings.set(plugins.path.resolve(entryPath), {
758
- path: entryPath,
759
- ...entryMarkerClassification,
760
- });
761
- }
762
- }
763
- }
764
- return;
765
- }
766
-
767
- if (depthArg >= optionsArg.maxDepth) {
768
- return;
769
- }
770
-
771
- for (const entry of entries) {
772
- if (Date.now() - startedAt > optionsArg.timeoutMs || visitedEntries > optionsArg.maxEntries) {
773
- return;
774
- }
775
- visitedEntries++;
776
- if (!entry.isDirectory() || shouldSkipWorkspaceDir(entry.name)) {
777
- continue;
778
- }
779
- const entryPath = plugins.path.join(dirArg, entry.name);
780
- let stat;
781
- try {
782
- stat = await plugins.fs.stat(entryPath);
783
- } catch {
784
- continue;
785
- }
786
- if (rootDev !== undefined && stat.dev !== rootDev) {
787
- continue;
788
- }
789
- await visit(entryPath, depthArg + 1);
790
- }
791
- };
792
-
793
- await visit(rootArg, 0);
794
- return [...findings.values()];
669
+ export async function collectToolCachePathsFromRoot(root: string, options: IWorkspaceScanOptions): Promise<IToolCacheFinding[]> {
670
+ return (await scanWorkspace(root, options)).findings;
795
671
  }
796
672
 
797
673
  async function getWorkspaceRoots(): Promise<string[]> {
@@ -803,57 +679,46 @@ async function getWorkspaceRoots(): Promise<string[]> {
803
679
  if (cwd && cwd !== '/') {
804
680
  roots.add(cwd);
805
681
  }
806
- return [...roots];
682
+ return await normalizeScanRoots([...roots]);
807
683
  }
808
684
 
809
- export async function collectProjectToolCacheDiagnostics(ctx: IRuntimeContext): Promise<IToolCacheFinding[]> {
810
- const roots = await getWorkspaceRoots();
685
+ export async function collectProjectToolCacheReport(ctx: IRuntimeContext, roots?: string[]) {
811
686
  const findings: IToolCacheFinding[] = [];
812
- for (const root of roots) {
813
- const rootFindings = await collectToolCachePathsFromRoot(root, {
814
- maxDepth: 8,
815
- timeoutMs: 45_000,
816
- maxEntries: 50_000,
687
+ const issues: IScanIssue[] = [];
688
+ const issueCounts: Partial<Record<IScanIssue['reason'], number>> = {};
689
+ const usagePriorityPaths: string[] = [];
690
+ const scanRoots = await normalizeScanRoots(roots?.length ? roots : await getWorkspaceRoots());
691
+ for (const root of scanRoots) {
692
+ const result = await scanWorkspace(root, { maxDepth: 8, timeoutMs: 45_000, maxEntries: 50_000,
693
+ onProgress: (partial) => ctx.onProgress?.({ stage: `discovering ${root}`, cacheCount: findings.length + partial.findings.length, reclaim: estimateReclaimableSpace([...findings, ...partial.findings]) }),
817
694
  });
818
- findings.push(...rootFindings);
819
- }
820
-
821
- const uniqueFindings = [...new Map(findings.map((finding) => [plugins.path.resolve(finding.path), finding])).values()];
822
- const sizedFindings: IToolCacheFinding[] = [];
823
- const sizingStartedAt = Date.now();
824
- for (const [index, finding] of uniqueFindings.entries()) {
825
- if (index >= toolCacheSizingLimit || Date.now() - sizingStartedAt > toolCacheSizingTimeoutMs) {
826
- sizedFindings.push(finding);
695
+ findings.push(...result.findings);
696
+ usagePriorityPaths.push(...result.usagePriorityPaths);
697
+ issues.push(...result.issues);
698
+ for (const [reason, count] of Object.entries(result.issueCounts)) issueCounts[reason] = (issueCounts[reason] ?? 0) + count;
699
+ }
700
+ const unique = [...new Map(findings.map((finding) => [plugins.path.resolve(finding.path), finding])).values()];
701
+ const deadline = Date.now() + toolCacheSizingTimeoutMs;
702
+ for (const [index, finding] of unique.entries()) {
703
+ if (index >= toolCacheSizingLimit || Date.now() >= deadline) {
704
+ issues.push({ path: finding.path, reason: 'size-unavailable', detail: 'Sizing budget exhausted.' });
827
705
  continue;
828
706
  }
829
- const size = await readDuTotal(ctx, finding.path, ctx.sudoAvailable);
830
- sizedFindings.push({
831
- ...finding,
832
- bytes: size?.bytes,
707
+ const result = await runCommand(ctx, 'du', ['-sx', '--block-size=1', '--', finding.path], {
708
+ asRoot: ctx.sudoAvailable, timeoutSeconds: Math.max(1, Math.min(ctx.duTimeoutSeconds, Math.floor((deadline - Date.now()) / 1000))),
833
709
  });
710
+ const size = result.code === 0 ? parseDuOutput(result.stdout)[0] : undefined;
711
+ if (size) finding.bytes = size.bytes;
712
+ else issues.push({ path: finding.path, reason: 'size-unavailable', detail: result.stderr.trim() || 'du failed or returned no size.' });
713
+ ctx.onProgress?.({ cacheCount: unique.length, reclaim: estimateReclaimableSpace(unique) });
834
714
  }
835
-
836
- return sizedFindings.sort((a, b) => (b.bytes ?? 0) - (a.bytes ?? 0));
715
+ issueCounts['size-unavailable'] = issues.filter((issue) => issue.reason === 'size-unavailable').length;
716
+ ctx.onProgress?.({ cacheCount: unique.length, reclaim: estimateReclaimableSpace(unique) });
717
+ return { roots: scanRoots, toolCaches: unique.sort((a, b) => (b.bytes ?? 0) - (a.bytes ?? 0)), usagePriorityPaths, issues, issueCounts, complete: issues.length === 0 };
837
718
  }
838
719
 
839
- async function printProjectToolCacheDiagnostics(ctx: IRuntimeContext) {
840
- section('Project Tool Cache Diagnostics');
841
- const roots = await getWorkspaceRoots();
842
- if (roots.length === 0) {
843
- console.log('No workspace roots found.');
844
- return;
845
- }
846
- progress(`Scanning workspace roots: ${roots.join(', ')}`);
847
- const findings = await collectProjectToolCacheDiagnostics(ctx);
848
- if (findings.length === 0) {
849
- console.log('No known project-local tool caches found.');
850
- return;
851
- }
852
-
853
- for (const finding of findings) {
854
- printSizeLine(`${finding.ownerGuess} ${finding.kind} [${finding.risk}] ${finding.path}`, finding.bytes ?? 0);
855
- console.log(` suggested: ${finding.suggestedCleanupCommand}`);
856
- }
720
+ export async function collectProjectToolCacheDiagnostics(ctx: IRuntimeContext): Promise<IToolCacheFinding[]> {
721
+ return (await collectProjectToolCacheReport(ctx)).toolCaches;
857
722
  }
858
723
 
859
724
  async function pathExistsForDu(ctx: IRuntimeContext, path: string, asRoot: boolean): Promise<boolean> {
@@ -1604,7 +1469,16 @@ function printHelp() {
1604
1469
  console.log('');
1605
1470
  console.log('Usage:');
1606
1471
  console.log(' tsdisk Run the disk usage diagnostic scan');
1607
- console.log(' tsdisk --json Print project tool-cache diagnostics as JSON');
1472
+ console.log(' tsdisk scan [--root PATH] [--json] Bounded diagnostics; repeat --root for multiple workspaces');
1473
+ console.log(' tsdisk --json Full diagnostic report including toolCaches, sections, and scan issues');
1474
+ console.log(' tsdisk cleanup [--root PROJECT] [--only rust,docker,pnpm] [--days 14]');
1475
+ console.log(' Preview owning-tool cleanup; defaults to rust,docker in the current project');
1476
+ console.log(' tsdisk cleanup --apply [--yes] Execute after review; JSON/noninteractive execution requires --yes');
1477
+ console.log(' --dry-run Explicit cleanup preview; cannot be combined with --apply');
1478
+ console.log(' Cleanup uses project-installed tsrust/tsdocker and pnpm store prune, without sudo or raw cache deletion.');
1479
+ console.log(' --days applies only to Rust targets. Docker uses its owner checks; pnpm uses reference tracking.');
1480
+ console.log(' scan exit codes: 0 complete, 2 incomplete, 1 invalid input/failure.');
1481
+ console.log(' Live progress updates every 5 seconds (stderr with --json). Potential cleanup requires owner review.');
1608
1482
  console.log(' tsdisk --help Show this help');
1609
1483
  console.log(' tsdisk --version Show the version');
1610
1484
  console.log('');
@@ -1619,8 +1493,19 @@ function printHelp() {
1619
1493
 
1620
1494
  export async function main() {
1621
1495
  const ctx = await initContext();
1496
+ const reporter = new DiskProgressReporter((line) => console.log(line));
1497
+ ctx.onProgress = (update) => reporter.update(update);
1498
+ reporter.start();
1499
+ try { await runHumanReport(ctx); }
1500
+ finally { reporter.stop(); }
1501
+ }
1622
1502
 
1503
+ async function runHumanReport(ctx: IRuntimeContext) {
1623
1504
  await printDiskOverview(ctx);
1505
+ const caches = await collectProjectToolCacheReport(ctx);
1506
+ const usage = await collectWorkspaceUsage(ctx, caches);
1507
+ printWorkspaceUsage(usage);
1508
+ console.log(`Potential cleanup: ${formatBytes(estimateReclaimableSpace(caches.toolCaches).candidateBytes)} of cache candidates; owner review required. Confirmed reclaimable: unknown.`);
1624
1509
  await ensureSudo(ctx);
1625
1510
  await printNcduInsights(ctx);
1626
1511
 
@@ -1638,7 +1523,9 @@ export async function main() {
1638
1523
  await printDuTree(ctx, 'Target Local Share Breakdown', `${ctx.targetHome}/.local/share`, true);
1639
1524
  await printDuTree(ctx, 'Target Cache Breakdown', `${ctx.targetHome}/.cache`, true);
1640
1525
  await printConfiguredToolPaths(ctx);
1641
- await printProjectToolCacheDiagnostics(ctx);
1526
+ section('Project Tool Cache Diagnostics');
1527
+ for (const cache of caches.toolCaches) console.log(`${cache.bytes === undefined ? 'unknown' : formatBytes(cache.bytes)} ${JSON.stringify(cache.path)}\n ${cache.suggestedCleanupCommand}`);
1528
+ if (!caches.complete) console.log(`Discovery incomplete: ${JSON.stringify(caches.issueCounts)}`);
1642
1529
  await printKnownSizes(ctx, 'Known User Caches', [
1643
1530
  { label: 'rootless Docker data', path: `${ctx.targetHome}/.local/share/docker` },
1644
1531
  { label: 'pnpm store', path: `${ctx.targetHome}/.local/share/pnpm` },
@@ -1687,30 +1574,160 @@ export async function main() {
1687
1574
  printCleanupHints(ctx);
1688
1575
  }
1689
1576
 
1690
- export async function runCli(args = process.argv.slice(2)) {
1691
- if (args.includes('--help') || args.includes('-h')) {
1692
- printHelp();
1693
- return;
1694
- }
1577
+ export interface IDiagnosticSection {
1578
+ name: string;
1579
+ kind?: 'command' | 'derived';
1580
+ derivedFrom?: 'usage.entries';
1581
+ command: string;
1582
+ args: string[];
1583
+ result: ICommandResult;
1584
+ complete: boolean;
1585
+ }
1695
1586
 
1696
- if (args.includes('--version') || args.includes('-v')) {
1697
- console.log(commitinfo.version);
1698
- return;
1587
+ export function diagnosticCommandComplete(command: string, result: ICommandResult): boolean {
1588
+ return !result.truncated && !result.stderr.trim() && (result.code === 0 || (command === 'lsof' && result.code === 1));
1589
+ }
1590
+
1591
+ async function collectWorkspaceUsage(ctx: IRuntimeContext, caches: Awaited<ReturnType<typeof collectProjectToolCacheReport>>) {
1592
+ ctx.onProgress?.({ stage: 'measuring workspace files and directories' });
1593
+ const usage = await measureDiskUsage(caches.roots, {
1594
+ timeoutSeconds: ctx.duTimeoutSeconds,
1595
+ trackedPaths: caches.toolCaches.map((finding) => finding.path),
1596
+ priorityPaths: caches.usagePriorityPaths,
1597
+ onProgress: (partial) => ctx.onProgress?.({
1598
+ stage: 'measuring workspace files and directories', measuredBytes: partial.measuredBytes,
1599
+ largest: partial.snapshot().filter((entry) => !caches.roots.includes(entry.path)).slice(0, 3),
1600
+ }),
1601
+ });
1602
+ return usage;
1603
+ }
1604
+
1605
+ function printWorkspaceUsage(usage: IUsageReport) {
1606
+ section(`Workspace Disk Usage (${usage.complete ? 'complete' : 'partial; + means a lower bound'})`);
1607
+ for (const entry of usage.entries.slice(0, 30)) console.log(`${formatBytes(entry.bytes)}${entry.complete ? '' : '+'} ${JSON.stringify(entry.path)}`);
1608
+ if (usage.stderr) console.log(firstLines(usage.stderr));
1609
+ }
1610
+
1611
+ export async function collectDiskReport(ctx: IRuntimeContext, roots?: string[]) {
1612
+ const caches = await collectProjectToolCacheReport(ctx, roots);
1613
+ const usage = await collectWorkspaceUsage(ctx, caches);
1614
+ const reclaim = estimateReclaimableSpace(caches.toolCaches);
1615
+ const sections: IDiagnosticSection[] = [];
1616
+ const deadline = Date.now() + 120_000;
1617
+ const inspect = async (name: string, command: string, args: string[]) => {
1618
+ const result = Date.now() >= deadline
1619
+ ? { code: 124, stdout: '', stderr: 'Diagnostic command budget exhausted.' }
1620
+ : await runCommand(ctx, command, args, { timeoutSeconds: Math.max(1, Math.min(15, Math.floor((deadline - Date.now()) / 1000))) });
1621
+ const complete = diagnosticCommandComplete(command, result);
1622
+ sections.push({ name, kind: 'command', command, args, result, complete });
1623
+ return result;
1624
+ };
1625
+ await inspect('filesystems', 'df', ['-hT']);
1626
+ await inspect('mounts', 'findmnt', ['--json']);
1627
+ for (const root of caches.roots) {
1628
+ const entries = usage.entries.filter((entry) => entry.path === root || entry.path.startsWith(root === '/' ? '/' : `${root}/`));
1629
+ const truncated = entries.length > 100;
1630
+ sections.push({ name: `workspace:${root}`, kind: 'derived', derivedFrom: 'usage.entries', command: 'usage-summary', args: [], complete: usage.complete && !truncated,
1631
+ result: { code: usage.code, stderr: usage.stderr, truncated, stdout: entries.slice(0, 100).map((entry) => `${entry.bytes}\t${entry.path}\n`).join('') },
1632
+ });
1633
+ }
1634
+ for (const path of ['/', ctx.targetHome, `${ctx.targetHome}/.cache`, `${ctx.targetHome}/.local/share`, '/var', '/var/lib']) {
1635
+ await inspect(`usage:${path}`, 'du', ['-x', '--max-depth=1', '--block-size=1', '--', path]);
1636
+ }
1637
+ await inspect('pnpm-store', 'pnpm', ['store', 'path']);
1638
+ await inspect('journal', 'journalctl', ['--disk-usage']);
1639
+ await inspect('deleted-open-files', 'lsof', ['-nP', '+L1']);
1640
+ const contexts = await inspect('docker-contexts', 'docker', ['context', 'ls', '--format', '{{.Name}}']);
1641
+ if (contexts.code === 0) {
1642
+ for (const context of contexts.stdout.trim().split(/\r?\n/).filter(Boolean)) {
1643
+ await inspect(`docker:${context}`, 'docker', ['--context', context, 'system', 'df', '-v']);
1644
+ }
1699
1645
  }
1646
+ return { schemaVersion: 1, generatedAt: new Date().toISOString(), ...caches, usage, reclaim, sections, complete: caches.complete && usage.complete && sections.every((section) => section.complete) };
1647
+ }
1700
1648
 
1701
- if (args.includes('--json')) {
1702
- try {
1703
- const ctx = await initContext();
1704
- const findings = await collectProjectToolCacheDiagnostics(ctx);
1705
- console.log(JSON.stringify({ toolCaches: findings }, null, 2));
1706
- } catch (error) {
1707
- console.error(error instanceof Error ? error.message : String(error));
1708
- process.exitCode = 1;
1649
+ async function runCleanup(options: ICliOptions) {
1650
+ const plan = await createCleanupPlan(options);
1651
+ const ctx = await initContext();
1652
+ const previews: Array<{ action: number; result: ICommandResult }> = [];
1653
+ const results: Array<{ action: number; result: ICommandResult }> = [];
1654
+ const print = () => {
1655
+ if (options.json) console.log(JSON.stringify({ ...plan, previews, results }, null, 2));
1656
+ else {
1657
+ console.log(plan.dryRun ? 'Cleanup preview (no deletion)' : 'Cleanup plan');
1658
+ plan.actions.forEach((action, index) => {
1659
+ console.log(`[${index + 1}] ${action.category}: ${action.cwd}`);
1660
+ console.log(` ${action.scope}`);
1661
+ console.log(` command: ${JSON.stringify([action.command, ...action.applyArgs])}`);
1662
+ if (action.blocked) console.log(` blocked: ${action.blocked}`);
1663
+ });
1664
+ for (const item of [...previews, ...results]) {
1665
+ console.log(`[${item.action + 1}] exit ${item.result.code}`);
1666
+ if (item.result.stdout) console.log(item.result.stdout.trimEnd());
1667
+ if (item.result.stderr) console.error(item.result.stderr.trimEnd());
1668
+ }
1709
1669
  }
1670
+ };
1671
+ if (options.apply && options.json && !options.yes) throw new Error('JSON cleanup requires --apply --yes to execute.');
1672
+ // Complete all previews before any apply command. Missing tools and failed previews block the batch.
1673
+ for (const [index, action] of plan.actions.entries()) {
1674
+ if (action.blocked) continue;
1675
+ const result = await runCommand(ctx, action.command, action.previewArgs ?? ['store', 'path'], { cwd: action.cwd, timeoutSeconds: 120 });
1676
+ previews.push({ action: index, result });
1677
+ }
1678
+ if (plan.actions.some((action) => action.blocked) || previews.some((preview) => preview.result.code !== 0 || preview.result.truncated)) {
1679
+ print();
1680
+ process.exitCode = 1;
1710
1681
  return;
1711
1682
  }
1683
+ if (!options.apply) { print(); return; }
1684
+ if (!options.json) print();
1685
+ if (!options.yes) {
1686
+ if (!process.stdin.isTTY) throw new Error('Noninteractive cleanup requires --apply --yes.');
1687
+ if (!await promptYesNo('Run the listed owning-tool cleanup commands?', false)) return;
1688
+ }
1689
+ for (const [index, action] of plan.actions.entries()) {
1690
+ const result = await runCommand(ctx, action.command, action.applyArgs, { cwd: action.cwd, timeoutSeconds: 240 });
1691
+ results.push({ action: index, result });
1692
+ if (result.code !== 0) { process.exitCode = 1; break; }
1693
+ }
1694
+ // JSON emits one complete document; text can show the plan before confirmation.
1695
+ if (options.json) print();
1696
+ else {
1697
+ for (const item of results) {
1698
+ console.log(`[${item.action + 1}] cleanup exit ${item.result.code}`);
1699
+ if (item.result.stdout) console.log(item.result.stdout.trimEnd());
1700
+ if (item.result.stderr) console.error(item.result.stderr.trimEnd());
1701
+ }
1702
+ }
1703
+ }
1712
1704
 
1705
+ export async function runCli(args = process.argv.slice(2)) {
1713
1706
  try {
1707
+ const options = parseCliOptions(args);
1708
+ if (options.command === 'help') { printHelp(); return; }
1709
+ if (options.command === 'version') { console.log(commitinfo.version); return; }
1710
+ if (options.command === 'cleanup') { await runCleanup(options); return; }
1711
+ if (options.command === 'scan') {
1712
+ const reporter = new DiskProgressReporter((line) => options.json ? process.stderr.write(`${line}\n`) : console.log(line));
1713
+ const ctx = await initContext();
1714
+ ctx.onProgress = (update) => reporter.update(update);
1715
+ reporter.start();
1716
+ let report: Awaited<ReturnType<typeof collectDiskReport>>;
1717
+ try { report = await collectDiskReport(ctx, options.roots); }
1718
+ finally { reporter.stop(); }
1719
+ if (options.json) console.log(JSON.stringify(report, null, 2));
1720
+ else {
1721
+ console.log(`Scan ${report.complete ? 'complete' : 'incomplete'}: ${report.toolCaches.length} tool caches`);
1722
+ printWorkspaceUsage(report.usage);
1723
+ console.log(`Potential cleanup: ${formatBytes(report.reclaim.candidateBytes)} (${report.reclaim.unknownSizeCount} caches unsized); owner review required. Confirmed reclaimable: unknown.`);
1724
+ for (const finding of report.toolCaches) console.log(`${finding.bytes === undefined ? 'unknown' : formatBytes(finding.bytes)} [${finding.risk}] ${finding.path}\n ${finding.suggestedCleanupCommand}`);
1725
+ for (const issue of report.issues) console.log(`${issue.reason}: ${issue.path}: ${issue.detail}`);
1726
+ for (const section of report.sections) console.log(`\n${section.name} [${section.complete ? 'complete' : 'incomplete'}]\n${section.result.stdout}${section.result.stderr}`);
1727
+ }
1728
+ if (!report.complete) process.exitCode = 2;
1729
+ return;
1730
+ }
1714
1731
  await main();
1715
1732
  } catch (error) {
1716
1733
  console.error(error instanceof Error ? error.message : String(error));
@@ -1,11 +1,12 @@
1
1
  // node native
2
2
  import { spawn } from 'node:child_process';
3
- import { createReadStream, type ReadStream } from 'node:fs';
3
+ import { constants, createReadStream, type ReadStream } from 'node:fs';
4
4
  import * as fs from 'node:fs/promises';
5
5
  import * as path from 'node:path';
6
6
  import { createInterface } from 'node:readline/promises';
7
7
 
8
8
  export {
9
+ constants,
9
10
  createInterface,
10
11
  createReadStream,
11
12
  fs,
@@ -0,0 +1,78 @@
1
+ import { containsPath, type IUsageEntry } from './tsdisk.usage.js';
2
+ import type { IToolCacheFinding } from './tsdisk.toolcaches.js';
3
+
4
+ export interface IReclaimEstimate {
5
+ candidateBytes: number;
6
+ unknownSizeCount: number;
7
+ protectedCount: number;
8
+ confirmedReclaimableBytes: null;
9
+ status: 'owner-review-required';
10
+ }
11
+
12
+ /** Do not sum parents and children, or treat a protected descendant as removable. */
13
+ export function estimateReclaimableSpace(findings: IToolCacheFinding[]): IReclaimEstimate {
14
+ const protectedPaths = findings.filter((finding) => finding.marker?.safeToPrune === false).map((finding) => finding.path);
15
+ const candidates = findings.filter((finding) => !protectedPaths.some((path) => containsPath(path, finding.path) || containsPath(finding.path, path)));
16
+ const selected: IToolCacheFinding[] = [];
17
+ for (const finding of [...candidates].sort((a, b) => a.path.length - b.path.length)) {
18
+ if (finding.bytes === undefined || selected.some((parent) => containsPath(parent.path, finding.path))) continue;
19
+ selected.push(finding);
20
+ }
21
+ return {
22
+ candidateBytes: selected.reduce((sum, finding) => sum + (finding.bytes ?? 0), 0),
23
+ unknownSizeCount: candidates.filter((finding) => finding.bytes === undefined).length,
24
+ protectedCount: protectedPaths.length,
25
+ confirmedReclaimableBytes: null,
26
+ status: 'owner-review-required',
27
+ };
28
+ }
29
+
30
+ export interface IDiskProgress {
31
+ stage: string;
32
+ cacheCount: number;
33
+ measuredBytes: number;
34
+ reclaim: IReclaimEstimate;
35
+ largest: IUsageEntry[];
36
+ }
37
+
38
+ export function formatBytes(bytes: number): string {
39
+ if (!Number.isFinite(bytes) || bytes < 0) return 'n/a';
40
+ const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
41
+ let value = bytes;
42
+ let index = 0;
43
+ while (value >= 1024 && index < units.length - 1) { value /= 1024; index++; }
44
+ return `${value.toFixed(index === 0 || value >= 100 ? 0 : value >= 10 ? 1 : 2)} ${units[index]}`;
45
+ }
46
+
47
+ /** Timer ownership is explicit; callers stop it in finally even on failure. */
48
+ export class DiskProgressReporter {
49
+ private timer?: ReturnType<typeof setInterval>;
50
+ private startedAt = Date.now();
51
+ private state: IDiskProgress = { stage: 'starting', cacheCount: 0, measuredBytes: 0, reclaim: estimateReclaimableSpace([]), largest: [] };
52
+
53
+ constructor(private write: (line: string) => void) {}
54
+
55
+ public update(update: Partial<IDiskProgress>) { Object.assign(this.state, update); }
56
+
57
+ public start() {
58
+ if (this.timer) return;
59
+ this.startedAt = Date.now();
60
+ this.print();
61
+ this.timer = setInterval(() => this.print(), 5000);
62
+ this.timer.unref();
63
+ }
64
+
65
+ private print() {
66
+ const state = this.state;
67
+ const top = state.largest.slice(0, 3).map((entry) => `${formatBytes(entry.bytes)}${entry.complete ? '' : '+'} ${JSON.stringify(entry.path)}`).join('; ');
68
+ this.write(`[tsdisk ${Math.floor((Date.now() - this.startedAt) / 1000)}s] ${state.stage} | measured ${formatBytes(state.measuredBytes)} | ${state.cacheCount} caches | potential cleanup ${formatBytes(state.reclaim.candidateBytes)} (owner review; ${state.reclaim.unknownSizeCount} unsized) | confirmed reclaimable: unknown${top ? ` | largest: ${top}` : ''}`);
69
+ }
70
+
71
+ public stop(stage = 'finished') {
72
+ if (!this.timer) return;
73
+ clearInterval(this.timer);
74
+ this.timer = undefined;
75
+ this.state.stage = stage;
76
+ this.print();
77
+ }
78
+ }