@git.zone/tsdisk 1.2.1 → 1.4.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/index.ts CHANGED
@@ -3,6 +3,7 @@ export {
3
3
  formatBytes,
4
4
  collectDiskReport,
5
5
  collectProjectToolCacheReport,
6
+ diagnosticCommandComplete,
6
7
  main,
7
8
  parseDuOutput,
8
9
  runCli,
@@ -10,4 +11,7 @@ export {
10
11
  export * from './tsdisk.toolcaches.js';
11
12
  export * from './tsdisk.scan.js';
12
13
  export * from './tsdisk.cleanup.js';
14
+ export * from './tsdisk.usage.js';
15
+ export { DiskProgressReporter, estimateReclaimableSpace } from './tsdisk.progress.js';
16
+ export type { IDiskProgress, IReclaimEstimate } from './tsdisk.progress.js';
13
17
  export type { IRuntimeContext, IDiagnosticSection } from './tsdisk.cli.js';
@@ -10,6 +10,8 @@ export interface ICleanupAction {
10
10
  applyArgs: string[];
11
11
  scope: string;
12
12
  blocked?: string;
13
+ sudoArgs?: string[];
14
+ requiresSudo?: boolean;
13
15
  }
14
16
 
15
17
  export interface ICleanupPlan {
@@ -25,10 +27,11 @@ export interface ICliOptions {
25
27
  apply: boolean;
26
28
  yes: boolean;
27
29
  days: number;
30
+ sudo?: 'auto' | 'allow' | 'never';
28
31
  }
29
32
 
30
33
  export function parseCliOptions(args: string[]): ICliOptions {
31
- const options: ICliOptions = { command: 'diagnose', roots: [], categories: ['rust', 'docker'], json: false, apply: false, yes: false, days: 14 };
34
+ const options: ICliOptions = { command: 'diagnose', roots: [], categories: ['rust', 'docker'], json: false, apply: false, yes: false, days: 14, sudo: 'auto' };
32
35
  let commandSeen = false;
33
36
  let dryRun = false;
34
37
  let cleanupOption = false;
@@ -44,6 +47,12 @@ export function parseCliOptions(args: string[]): ICliOptions {
44
47
  else if (arg === '--apply') { options.apply = true; cleanupOption = true; }
45
48
  else if (arg === '--yes' || arg === '-y') { options.yes = true; cleanupOption = true; }
46
49
  else if (arg === '--dry-run' || arg === '-n') { dryRun = true; cleanupOption = true; }
50
+ else if (arg === '--sudo' || arg === '--no-sudo') {
51
+ const value = arg === '--sudo' ? 'allow' : 'never';
52
+ if (options.sudo !== 'auto' && options.sudo !== value) throw new Error('--sudo and --no-sudo cannot be combined.');
53
+ options.sudo = value;
54
+ cleanupOption = true;
55
+ }
47
56
  else if (['--root', '--only', '--days'].some((flag) => arg === flag || arg.startsWith(`${flag}=`))) {
48
57
  const separator = arg.indexOf('=');
49
58
  const name = separator < 0 ? arg : arg.slice(0, separator);
@@ -110,6 +119,12 @@ export async function createCleanupPlan(options: ICliOptions): Promise<ICleanupP
110
119
  if (!version || (different !== undefined && different !== -1 && version[different] < minimum[different])) {
111
120
  action.blocked = `@git.zone/${binary} ${minimum.join('.')} or later is required for the documented prune contract.`;
112
121
  }
122
+ if (category === 'docker' && version && (version[0] > 3 || (version[0] === 3 && version[1] >= 7))) {
123
+ action.previewArgs = ['prune', '--json'];
124
+ action.sudoArgs = [...action.applyArgs, '--sudo'];
125
+ } else if (category === 'docker' && options.sudo === 'allow') {
126
+ action.blocked = '@git.zone/tsdocker 3.7.0 or later is required for sudo cleanup. Upgrade the project tool first.';
127
+ }
113
128
  } catch {
114
129
  action.blocked = `Install a current @git.zone/${binary} in this project before cleanup. No tool is downloaded automatically.`;
115
130
  }
@@ -118,3 +133,13 @@ export async function createCleanupPlan(options: ICliOptions): Promise<ICleanupP
118
133
  }
119
134
  return plan;
120
135
  }
136
+
137
+ /** A versioned owner preview determines whether authentication is needed. */
138
+ export function dockerPreviewRequiresSudo(stdout: string): boolean {
139
+ const plan = JSON.parse(stdout) as { schemaVersion?: unknown; kind?: unknown; cacheDirs?: Array<{ removal?: { requiresSudo?: unknown } }> };
140
+ if (plan.schemaVersion !== 1 || plan.kind !== 'tsdocker-prune-plan' || !Array.isArray(plan.cacheDirs)
141
+ || plan.cacheDirs.some((cache) => typeof cache?.removal?.requiresSudo !== 'boolean')) {
142
+ throw new Error('Invalid Docker permission preview; refusing cleanup.');
143
+ }
144
+ return plan.cacheDirs.some((cache) => cache.removal!.requiresSudo === true);
145
+ }
package/ts/tsdisk.cli.ts CHANGED
@@ -1,7 +1,10 @@
1
1
  import * as plugins from './tsdisk.plugins.js';
2
2
  import { commitinfo } from './00_commitinfo_data.js';
3
3
  import { scanWorkspace, type IWorkspaceScanOptions, type IScanIssue } from './tsdisk.scan.js';
4
- import { parseCliOptions, createCleanupPlan, type ICliOptions } from './tsdisk.cleanup.js';
4
+ import { parseCliOptions, createCleanupPlan, dockerPreviewRequiresSudo, 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';
5
8
  export type { IWorkspaceScanOptions } from './tsdisk.scan.js';
6
9
  import {
7
10
  classifyToolCachePath,
@@ -23,6 +26,7 @@ interface IRunOptions {
23
26
  }
24
27
 
25
28
  export interface IRuntimeContext {
29
+ onProgress?: (progress: Partial<IDiskProgress>) => void;
26
30
  runningAsRoot: boolean;
27
31
  sudoAvailable: boolean;
28
32
  sudoChecked: boolean;
@@ -89,24 +93,6 @@ function firstLines(text: string, maxLines = 8): string {
89
93
  return text.trim().split(/\r?\n/).slice(0, maxLines).join('\n');
90
94
  }
91
95
 
92
- export function formatBytes(bytes: number): string {
93
- if (!Number.isFinite(bytes) || bytes < 0) {
94
- return 'n/a';
95
- }
96
-
97
- const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
98
- let value = bytes;
99
- let unitIndex = 0;
100
-
101
- while (value >= 1024 && unitIndex < units.length - 1) {
102
- value = value / 1024;
103
- unitIndex++;
104
- }
105
-
106
- const decimals = unitIndex === 0 || value >= 100 ? 0 : value >= 10 ? 1 : 2;
107
- return `${value.toFixed(decimals)} ${units[unitIndex]}`;
108
- }
109
-
110
96
  function printSizeLine(label: string, bytes: number) {
111
97
  console.log(`${formatBytes(bytes).padStart(10)} ${label}`);
112
98
  }
@@ -523,6 +509,7 @@ async function runRootInteractive(
523
509
  args: string[],
524
510
  timeoutSeconds?: number,
525
511
  ): Promise<number> {
512
+ ctx.onProgress?.({ stage: `${command} ${args.slice(0, 3).join(' ')}` });
526
513
  let executable = command;
527
514
  let finalArgs = args;
528
515
 
@@ -561,6 +548,7 @@ async function runCommand(
561
548
  args: string[],
562
549
  options: IRunOptions = {},
563
550
  ): Promise<ICommandResult> {
551
+ ctx.onProgress?.({ stage: `${command} ${args.slice(0, 3).join(' ')}` });
564
552
  let executable = command;
565
553
  let finalArgs = [...args];
566
554
  const env: NodeJS.ProcessEnv = {};
@@ -691,17 +679,23 @@ async function getWorkspaceRoots(): Promise<string[]> {
691
679
  if (cwd && cwd !== '/') {
692
680
  roots.add(cwd);
693
681
  }
694
- return [...roots];
682
+ return await normalizeScanRoots([...roots]);
695
683
  }
696
684
 
697
685
  export async function collectProjectToolCacheReport(ctx: IRuntimeContext, roots?: string[]) {
698
686
  const findings: IToolCacheFinding[] = [];
699
687
  const issues: IScanIssue[] = [];
700
- const scanRoots = roots?.length ? roots : await getWorkspaceRoots();
688
+ const issueCounts: Partial<Record<IScanIssue['reason'], number>> = {};
689
+ const usagePriorityPaths: string[] = [];
690
+ const scanRoots = await normalizeScanRoots(roots?.length ? roots : await getWorkspaceRoots());
701
691
  for (const root of scanRoots) {
702
- const result = await scanWorkspace(root, { maxDepth: 8, timeoutMs: 45_000, maxEntries: 50_000 });
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]) }),
694
+ });
703
695
  findings.push(...result.findings);
696
+ usagePriorityPaths.push(...result.usagePriorityPaths);
704
697
  issues.push(...result.issues);
698
+ for (const [reason, count] of Object.entries(result.issueCounts)) issueCounts[reason] = (issueCounts[reason] ?? 0) + count;
705
699
  }
706
700
  const unique = [...new Map(findings.map((finding) => [plugins.path.resolve(finding.path), finding])).values()];
707
701
  const deadline = Date.now() + toolCacheSizingTimeoutMs;
@@ -716,36 +710,17 @@ export async function collectProjectToolCacheReport(ctx: IRuntimeContext, roots?
716
710
  const size = result.code === 0 ? parseDuOutput(result.stdout)[0] : undefined;
717
711
  if (size) finding.bytes = size.bytes;
718
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) });
719
714
  }
720
- return { roots: scanRoots, toolCaches: unique.sort((a, b) => (b.bytes ?? 0) - (a.bytes ?? 0)), issues, complete: issues.length === 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 };
721
718
  }
722
719
 
723
720
  export async function collectProjectToolCacheDiagnostics(ctx: IRuntimeContext): Promise<IToolCacheFinding[]> {
724
721
  return (await collectProjectToolCacheReport(ctx)).toolCaches;
725
722
  }
726
723
 
727
- async function printProjectToolCacheDiagnostics(ctx: IRuntimeContext) {
728
- section('Project Tool Cache Diagnostics');
729
- const roots = await getWorkspaceRoots();
730
- if (roots.length === 0) {
731
- console.log('No workspace roots found.');
732
- return;
733
- }
734
- progress(`Scanning workspace roots: ${roots.join(', ')}`);
735
- const report = await collectProjectToolCacheReport(ctx);
736
- for (const issue of report.issues) console.log(`Incomplete scan: ${issue.reason}: ${issue.path}: ${issue.detail}`);
737
- const findings = report.toolCaches;
738
- if (findings.length === 0) {
739
- console.log('No known project-local tool caches found.');
740
- return;
741
- }
742
-
743
- for (const finding of findings) {
744
- console.log(`${finding.bytes === undefined ? "unknown" : formatBytes(finding.bytes)} ${finding.ownerGuess} ${finding.kind} [${finding.risk}] ${finding.path}`);
745
- console.log(` suggested: ${finding.suggestedCleanupCommand}`);
746
- }
747
- }
748
-
749
724
  async function pathExistsForDu(ctx: IRuntimeContext, path: string, asRoot: boolean): Promise<boolean> {
750
725
  if (!asRoot) {
751
726
  return await pathExists(path);
@@ -1500,9 +1475,13 @@ function printHelp() {
1500
1475
  console.log(' Preview owning-tool cleanup; defaults to rust,docker in the current project');
1501
1476
  console.log(' tsdisk cleanup --apply [--yes] Execute after review; JSON/noninteractive execution requires --yes');
1502
1477
  console.log(' --dry-run Explicit cleanup preview; cannot be combined with --apply');
1503
- console.log(' Cleanup uses project-installed tsrust/tsdocker and pnpm store prune, without sudo or raw cache deletion.');
1478
+ console.log(' Cleanup uses project-installed tsrust/tsdocker and pnpm store prune.');
1479
+ console.log(' Docker cleanup asks for sudo when needed (tsdocker >=3.7.0); only approved cache removal is elevated.');
1480
+ console.log(' --sudo Allow an existing sudo authorization without a terminal; never accepts a password argument');
1481
+ console.log(' --no-sudo Refuse cleanup requiring elevated filesystem access');
1504
1482
  console.log(' --days applies only to Rust targets. Docker uses its owner checks; pnpm uses reference tracking.');
1505
1483
  console.log(' scan exit codes: 0 complete, 2 incomplete, 1 invalid input/failure.');
1484
+ console.log(' Live progress updates every 5 seconds (stderr with --json). Potential cleanup requires owner review.');
1506
1485
  console.log(' tsdisk --help Show this help');
1507
1486
  console.log(' tsdisk --version Show the version');
1508
1487
  console.log('');
@@ -1517,8 +1496,19 @@ function printHelp() {
1517
1496
 
1518
1497
  export async function main() {
1519
1498
  const ctx = await initContext();
1499
+ const reporter = new DiskProgressReporter((line) => console.log(line));
1500
+ ctx.onProgress = (update) => reporter.update(update);
1501
+ reporter.start();
1502
+ try { await runHumanReport(ctx); }
1503
+ finally { reporter.stop(); }
1504
+ }
1520
1505
 
1506
+ async function runHumanReport(ctx: IRuntimeContext) {
1521
1507
  await printDiskOverview(ctx);
1508
+ const caches = await collectProjectToolCacheReport(ctx);
1509
+ const usage = await collectWorkspaceUsage(ctx, caches);
1510
+ printWorkspaceUsage(usage);
1511
+ console.log(`Potential cleanup: ${formatBytes(estimateReclaimableSpace(caches.toolCaches).candidateBytes)} of cache candidates; owner review required. Confirmed reclaimable: unknown.`);
1522
1512
  await ensureSudo(ctx);
1523
1513
  await printNcduInsights(ctx);
1524
1514
 
@@ -1536,7 +1526,9 @@ export async function main() {
1536
1526
  await printDuTree(ctx, 'Target Local Share Breakdown', `${ctx.targetHome}/.local/share`, true);
1537
1527
  await printDuTree(ctx, 'Target Cache Breakdown', `${ctx.targetHome}/.cache`, true);
1538
1528
  await printConfiguredToolPaths(ctx);
1539
- await printProjectToolCacheDiagnostics(ctx);
1529
+ section('Project Tool Cache Diagnostics');
1530
+ for (const cache of caches.toolCaches) console.log(`${cache.bytes === undefined ? 'unknown' : formatBytes(cache.bytes)} ${JSON.stringify(cache.path)}\n ${cache.suggestedCleanupCommand}`);
1531
+ if (!caches.complete) console.log(`Discovery incomplete: ${JSON.stringify(caches.issueCounts)}`);
1540
1532
  await printKnownSizes(ctx, 'Known User Caches', [
1541
1533
  { label: 'rootless Docker data', path: `${ctx.targetHome}/.local/share/docker` },
1542
1534
  { label: 'pnpm store', path: `${ctx.targetHome}/.local/share/pnpm` },
@@ -1587,27 +1579,61 @@ export async function main() {
1587
1579
 
1588
1580
  export interface IDiagnosticSection {
1589
1581
  name: string;
1582
+ kind?: 'command' | 'derived';
1583
+ derivedFrom?: 'usage.entries';
1590
1584
  command: string;
1591
1585
  args: string[];
1592
1586
  result: ICommandResult;
1593
1587
  complete: boolean;
1594
1588
  }
1595
1589
 
1590
+ export function diagnosticCommandComplete(command: string, result: ICommandResult): boolean {
1591
+ return !result.truncated && !result.stderr.trim() && (result.code === 0 || (command === 'lsof' && result.code === 1));
1592
+ }
1593
+
1594
+ async function collectWorkspaceUsage(ctx: IRuntimeContext, caches: Awaited<ReturnType<typeof collectProjectToolCacheReport>>) {
1595
+ ctx.onProgress?.({ stage: 'measuring workspace files and directories' });
1596
+ const usage = await measureDiskUsage(caches.roots, {
1597
+ timeoutSeconds: ctx.duTimeoutSeconds,
1598
+ trackedPaths: caches.toolCaches.map((finding) => finding.path),
1599
+ priorityPaths: caches.usagePriorityPaths,
1600
+ onProgress: (partial) => ctx.onProgress?.({
1601
+ stage: 'measuring workspace files and directories', measuredBytes: partial.measuredBytes,
1602
+ largest: partial.snapshot().filter((entry) => !caches.roots.includes(entry.path)).slice(0, 3),
1603
+ }),
1604
+ });
1605
+ return usage;
1606
+ }
1607
+
1608
+ function printWorkspaceUsage(usage: IUsageReport) {
1609
+ section(`Workspace Disk Usage (${usage.complete ? 'complete' : 'partial; + means a lower bound'})`);
1610
+ for (const entry of usage.entries.slice(0, 30)) console.log(`${formatBytes(entry.bytes)}${entry.complete ? '' : '+'} ${JSON.stringify(entry.path)}`);
1611
+ if (usage.stderr) console.log(firstLines(usage.stderr));
1612
+ }
1613
+
1596
1614
  export async function collectDiskReport(ctx: IRuntimeContext, roots?: string[]) {
1597
1615
  const caches = await collectProjectToolCacheReport(ctx, roots);
1616
+ const usage = await collectWorkspaceUsage(ctx, caches);
1617
+ const reclaim = estimateReclaimableSpace(caches.toolCaches);
1598
1618
  const sections: IDiagnosticSection[] = [];
1599
1619
  const deadline = Date.now() + 120_000;
1600
1620
  const inspect = async (name: string, command: string, args: string[]) => {
1601
1621
  const result = Date.now() >= deadline
1602
1622
  ? { code: 124, stdout: '', stderr: 'Diagnostic command budget exhausted.' }
1603
1623
  : await runCommand(ctx, command, args, { timeoutSeconds: Math.max(1, Math.min(15, Math.floor((deadline - Date.now()) / 1000))) });
1604
- const complete = !result.truncated && (result.code === 0 || (command === 'lsof' && result.code === 1 && !result.stderr.trim()));
1605
- sections.push({ name, command, args, result, complete });
1624
+ const complete = diagnosticCommandComplete(command, result);
1625
+ sections.push({ name, kind: 'command', command, args, result, complete });
1606
1626
  return result;
1607
1627
  };
1608
1628
  await inspect('filesystems', 'df', ['-hT']);
1609
1629
  await inspect('mounts', 'findmnt', ['--json']);
1610
- for (const root of caches.roots) await inspect(`workspace:${root}`, 'du', ['-x', '--max-depth=1', '--block-size=1', '--', root]);
1630
+ for (const root of caches.roots) {
1631
+ const entries = usage.entries.filter((entry) => entry.path === root || entry.path.startsWith(root === '/' ? '/' : `${root}/`));
1632
+ const truncated = entries.length > 100;
1633
+ sections.push({ name: `workspace:${root}`, kind: 'derived', derivedFrom: 'usage.entries', command: 'usage-summary', args: [], complete: usage.complete && !truncated,
1634
+ result: { code: usage.code, stderr: usage.stderr, truncated, stdout: entries.slice(0, 100).map((entry) => `${entry.bytes}\t${entry.path}\n`).join('') },
1635
+ });
1636
+ }
1611
1637
  for (const path of ['/', ctx.targetHome, `${ctx.targetHome}/.cache`, `${ctx.targetHome}/.local/share`, '/var', '/var/lib']) {
1612
1638
  await inspect(`usage:${path}`, 'du', ['-x', '--max-depth=1', '--block-size=1', '--', path]);
1613
1639
  }
@@ -1620,7 +1646,7 @@ export async function collectDiskReport(ctx: IRuntimeContext, roots?: string[])
1620
1646
  await inspect(`docker:${context}`, 'docker', ['--context', context, 'system', 'df', '-v']);
1621
1647
  }
1622
1648
  }
1623
- return { schemaVersion: 1, generatedAt: new Date().toISOString(), ...caches, sections, complete: caches.complete && sections.every((section) => section.complete) };
1649
+ return { schemaVersion: 1, generatedAt: new Date().toISOString(), ...caches, usage, reclaim, sections, complete: caches.complete && usage.complete && sections.every((section) => section.complete) };
1624
1650
  }
1625
1651
 
1626
1652
  async function runCleanup(options: ICliOptions) {
@@ -1637,6 +1663,7 @@ async function runCleanup(options: ICliOptions) {
1637
1663
  console.log(` ${action.scope}`);
1638
1664
  console.log(` command: ${JSON.stringify([action.command, ...action.applyArgs])}`);
1639
1665
  if (action.blocked) console.log(` blocked: ${action.blocked}`);
1666
+ if (action.requiresSudo) console.log(' requires sudo for cache file removal');
1640
1667
  });
1641
1668
  for (const item of [...previews, ...results]) {
1642
1669
  console.log(`[${item.action + 1}] exit ${item.result.code}`);
@@ -1651,6 +1678,15 @@ async function runCleanup(options: ICliOptions) {
1651
1678
  if (action.blocked) continue;
1652
1679
  const result = await runCommand(ctx, action.command, action.previewArgs ?? ['store', 'path'], { cwd: action.cwd, timeoutSeconds: 120 });
1653
1680
  previews.push({ action: index, result });
1681
+ if (action.sudoArgs && result.code === 0 && !result.truncated) {
1682
+ try { action.requiresSudo = dockerPreviewRequiresSudo(result.stdout); }
1683
+ catch (error) { action.blocked = error instanceof Error ? error.message : String(error); }
1684
+ if (options.apply && action.requiresSudo && (options.sudo === 'never' || (!process.stdin.isTTY && options.sudo !== 'allow'))) {
1685
+ action.blocked = options.sudo === 'never'
1686
+ ? 'This cache requires sudo, which --no-sudo disables.'
1687
+ : 'This cache requires sudo. Run cleanup in a terminal for a password prompt, or use --sudo with existing sudo authorization.';
1688
+ }
1689
+ }
1654
1690
  }
1655
1691
  if (plan.actions.some((action) => action.blocked) || previews.some((preview) => preview.result.code !== 0 || preview.result.truncated)) {
1656
1692
  print();
@@ -1663,8 +1699,21 @@ async function runCleanup(options: ICliOptions) {
1663
1699
  if (!process.stdin.isTTY) throw new Error('Noninteractive cleanup requires --apply --yes.');
1664
1700
  if (!await promptYesNo('Run the listed owning-tool cleanup commands?', false)) return;
1665
1701
  }
1702
+ if (plan.actions.some((action) => action.requiresSudo)) {
1703
+ // sudo owns password input and terminal echo; credentials never enter this process.
1704
+ console.error('Approved registry caches require elevated file removal. Authenticating with sudo.');
1705
+ const code = process.stdin.isTTY
1706
+ ? await runInteractive('/usr/bin/sudo', ['-v'], 60)
1707
+ : (await runCommand(ctx, '/usr/bin/sudo', ['-n', '-v'], { timeoutSeconds: 60 })).code;
1708
+ if (code !== 0) {
1709
+ for (const action of plan.actions) if (action.requiresSudo) action.blocked = 'Sudo authentication failed or was cancelled; no cleanup commands were applied.';
1710
+ print();
1711
+ process.exitCode = 1;
1712
+ return;
1713
+ }
1714
+ }
1666
1715
  for (const [index, action] of plan.actions.entries()) {
1667
- const result = await runCommand(ctx, action.command, action.applyArgs, { cwd: action.cwd, timeoutSeconds: 240 });
1716
+ const result = await runCommand(ctx, action.command, action.requiresSudo ? action.sudoArgs! : action.applyArgs, { cwd: action.cwd, timeoutSeconds: 240 });
1668
1717
  results.push({ action: index, result });
1669
1718
  if (result.code !== 0) { process.exitCode = 1; break; }
1670
1719
  }
@@ -1686,10 +1735,18 @@ export async function runCli(args = process.argv.slice(2)) {
1686
1735
  if (options.command === 'version') { console.log(commitinfo.version); return; }
1687
1736
  if (options.command === 'cleanup') { await runCleanup(options); return; }
1688
1737
  if (options.command === 'scan') {
1689
- const report = await collectDiskReport(await initContext(), options.roots);
1738
+ const reporter = new DiskProgressReporter((line) => options.json ? process.stderr.write(`${line}\n`) : console.log(line));
1739
+ const ctx = await initContext();
1740
+ ctx.onProgress = (update) => reporter.update(update);
1741
+ reporter.start();
1742
+ let report: Awaited<ReturnType<typeof collectDiskReport>>;
1743
+ try { report = await collectDiskReport(ctx, options.roots); }
1744
+ finally { reporter.stop(); }
1690
1745
  if (options.json) console.log(JSON.stringify(report, null, 2));
1691
1746
  else {
1692
1747
  console.log(`Scan ${report.complete ? 'complete' : 'incomplete'}: ${report.toolCaches.length} tool caches`);
1748
+ printWorkspaceUsage(report.usage);
1749
+ console.log(`Potential cleanup: ${formatBytes(report.reclaim.candidateBytes)} (${report.reclaim.unknownSizeCount} caches unsized); owner review required. Confirmed reclaimable: unknown.`);
1693
1750
  for (const finding of report.toolCaches) console.log(`${finding.bytes === undefined ? 'unknown' : formatBytes(finding.bytes)} [${finding.risk}] ${finding.path}\n ${finding.suggestedCleanupCommand}`);
1694
1751
  for (const issue of report.issues) console.log(`${issue.reason}: ${issue.path}: ${issue.detail}`);
1695
1752
  for (const section of report.sections) console.log(`\n${section.name} [${section.complete ? 'complete' : 'incomplete'}]\n${section.result.stdout}${section.result.stderr}`);
@@ -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
+ }
package/ts/tsdisk.scan.ts CHANGED
@@ -5,6 +5,7 @@ export interface IWorkspaceScanOptions {
5
5
  maxDepth: number;
6
6
  timeoutMs: number;
7
7
  maxEntries: number;
8
+ onProgress?: (result: IWorkspaceScanResult) => void;
8
9
  }
9
10
 
10
11
  export interface IScanIssue {
@@ -19,6 +20,8 @@ export interface IWorkspaceScanResult {
19
20
  issues: IScanIssue[];
20
21
  complete: boolean;
21
22
  visitedEntries: number;
23
+ issueCounts: Partial<Record<IScanIssue['reason'], number>>;
24
+ usagePriorityPaths: string[];
22
25
  }
23
26
 
24
27
  const excludedDirectories = new Set(['.git', 'node_modules', 'dist_ts', 'dist_ts_web', 'coverage', '.pnpm-store']);
@@ -30,12 +33,13 @@ export async function scanWorkspace(root: string, options: IWorkspaceScanOptions
30
33
  throw new Error(`Invalid scan option ${name}: ${value}`);
31
34
  }
32
35
  }
33
- const result: IWorkspaceScanResult = { root: plugins.path.resolve(root), findings: [], issues: [], complete: true, visitedEntries: 0 };
36
+ const result: IWorkspaceScanResult = { root: plugins.path.resolve(root), findings: [], issues: [], issueCounts: {}, usagePriorityPaths: [], complete: true, visitedEntries: 0 };
34
37
  const deadline = Date.now() + options.timeoutMs;
35
38
  let stopped = false;
36
39
  const issue = (path: string, reason: IScanIssue['reason'], detail: string) => {
37
40
  result.complete = false;
38
- result.issues.push({ path, reason, detail });
41
+ result.issueCounts[reason] = (result.issueCounts[reason] ?? 0) + 1;
42
+ if (result.issues.length < 200) result.issues.push({ path, reason, detail });
39
43
  };
40
44
  const limited = (path: string) => {
41
45
  if (stopped) return true;
@@ -58,6 +62,8 @@ export async function scanWorkspace(root: string, options: IWorkspaceScanOptions
58
62
  issue(result.root, 'read-error', error instanceof Error ? error.message : String(error));
59
63
  return result;
60
64
  }
65
+ const queue: Array<{ directory: string; depth: number }> = [{ directory: result.root, depth: 0 }];
66
+ let queueLimited = false;
61
67
  const visit = async (directory: string, depth: number): Promise<void> => {
62
68
  if (limited(directory)) return;
63
69
  result.visitedEntries++;
@@ -71,11 +77,14 @@ export async function scanWorkspace(root: string, options: IWorkspaceScanOptions
71
77
  issue(directory, 'mount-boundary', 'Different filesystem skipped.');
72
78
  return;
73
79
  }
74
- const entries = await plugins.fs.readdir(directory, { withFileTypes: true });
80
+ if (plugins.path.basename(directory) === '.nogit') result.usagePriorityPaths.push(directory);
75
81
  let marker;
76
- if (entries.some((entry) => entry.name === toolCacheMarkerFile)) {
77
- const markerPath = plugins.path.join(directory, toolCacheMarkerFile);
78
- const markerStat = await plugins.fs.lstat(markerPath);
82
+ const markerPath = plugins.path.join(directory, toolCacheMarkerFile);
83
+ const markerStat = await plugins.fs.lstat(markerPath).catch((error: NodeJS.ErrnoException) => {
84
+ if (error.code === 'ENOENT') return undefined;
85
+ throw error;
86
+ });
87
+ if (markerStat) {
79
88
  if (!markerStat.isFile() || markerStat.size > 65536) {
80
89
  issue(markerPath, 'invalid-marker', 'Marker must be a regular file of at most 64 KiB.');
81
90
  } else {
@@ -88,18 +97,22 @@ export async function scanWorkspace(root: string, options: IWorkspaceScanOptions
88
97
  if (classification) result.findings.push({ path: directory, ...classification });
89
98
  // Cache contents are not workspaces. Registry session directories can carry their own markers.
90
99
  if (classification && heuristic?.kind !== 'docker-registry-cache') return;
91
- const children = entries.filter((entry) => !excludedDirectories.has(entry.name));
92
- if (depth >= options.maxDepth) {
93
- if (children.some((entry) => entry.isDirectory() || entry.isSymbolicLink())) issue(directory, 'depth-limit', `Maximum scan depth ${options.maxDepth} reached.`);
94
- return;
95
- }
96
- for (const child of children) {
100
+ let enumerated = 0;
101
+ const children = await plugins.fs.opendir(directory);
102
+ for await (const child of children) {
103
+ // A huge flat directory must not consume the budget of sibling projects.
104
+ if (++enumerated > 4096) {
105
+ issue(directory, 'entry-limit', 'Directory listing stopped at 4096 entries; sibling directories are still scanned.');
106
+ break;
107
+ }
108
+ if (excludedDirectories.has(child.name)) continue;
109
+ if (!child.isDirectory() && !child.isSymbolicLink()) continue;
110
+ if (depth >= options.maxDepth) {
111
+ issue(directory, 'depth-limit', `Maximum scan depth ${options.maxDepth} reached.`);
112
+ break;
113
+ }
97
114
  const childPath = plugins.path.join(directory, child.name);
98
115
  if (limited(childPath)) return;
99
- if (!child.isDirectory() && !child.isSymbolicLink()) {
100
- result.visitedEntries++;
101
- continue;
102
- }
103
116
  if (child.isSymbolicLink()) {
104
117
  result.visitedEntries++;
105
118
  issue(childPath, 'symlink', 'Symbolic link skipped.');
@@ -113,12 +126,26 @@ export async function scanWorkspace(root: string, options: IWorkspaceScanOptions
113
126
  });
114
127
  if (!sessionMarker) { result.visitedEntries++; continue; }
115
128
  }
116
- await visit(childPath, depth + 1);
129
+ if (queue.length >= options.maxEntries) {
130
+ if (!queueLimited) issue(directory, 'entry-limit', 'Directory queue budget exhausted; already queued siblings are still scanned.');
131
+ queueLimited = true;
132
+ continue;
133
+ }
134
+ queue.push({ directory: childPath, depth: depth + 1 });
117
135
  }
118
136
  } catch (error) {
119
137
  issue(directory, 'read-error', error instanceof Error ? error.message : String(error));
120
138
  }
121
139
  };
122
- await visit(result.root, 0);
140
+ // Breadth first: canonical projects precede deeply nested retained worktrees.
141
+ let lastProgress = 0;
142
+ for (let index = 0; index < queue.length && !stopped; index++) {
143
+ await visit(queue[index].directory, queue[index].depth);
144
+ if (Date.now() - lastProgress >= 250) {
145
+ options.onProgress?.(result);
146
+ lastProgress = Date.now();
147
+ }
148
+ }
149
+ options.onProgress?.(result);
123
150
  return result;
124
151
  }