@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/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/index.d.ts +4 -1
- package/dist_ts/index.js +4 -2
- package/dist_ts/tsdisk.cleanup.d.ts +5 -0
- package/dist_ts/tsdisk.cleanup.js +25 -2
- package/dist_ts/tsdisk.cli.d.ts +13 -1
- package/dist_ts/tsdisk.cli.js +125 -50
- package/dist_ts/tsdisk.progress.d.ts +31 -0
- package/dist_ts/tsdisk.progress.js +64 -0
- package/dist_ts/tsdisk.scan.d.ts +3 -0
- package/dist_ts/tsdisk.scan.js +48 -19
- package/dist_ts/tsdisk.usage.d.ts +50 -0
- package/dist_ts/tsdisk.usage.js +194 -0
- package/package.json +1 -1
- package/readme.md +26 -5
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/index.ts +4 -0
- package/ts/tsdisk.cleanup.ts +26 -1
- package/ts/tsdisk.cli.ts +110 -53
- package/ts/tsdisk.progress.ts +78 -0
- package/ts/tsdisk.scan.ts +45 -18
- package/ts/tsdisk.usage.ts +199 -0
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';
|
package/ts/tsdisk.cleanup.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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)
|
|
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
|
|
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.
|
|
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
|
-
|
|
80
|
+
if (plugins.path.basename(directory) === '.nogit') result.usagePriorityPaths.push(directory);
|
|
75
81
|
let marker;
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|