@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/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/index.d.ts +7 -1
- package/dist_ts/index.js +6 -2
- package/dist_ts/tsdisk.cleanup.d.ts +26 -0
- package/dist_ts/tsdisk.cleanup.js +128 -0
- package/dist_ts/tsdisk.cli.d.ts +44 -8
- package/dist_ts/tsdisk.cli.js +247 -176
- package/dist_ts/tsdisk.plugins.d.ts +2 -2
- package/dist_ts/tsdisk.plugins.js +3 -3
- package/dist_ts/tsdisk.progress.d.ts +31 -0
- package/dist_ts/tsdisk.progress.js +64 -0
- package/dist_ts/tsdisk.scan.d.ts +22 -0
- package/dist_ts/tsdisk.scan.js +147 -0
- package/dist_ts/tsdisk.toolcaches.js +27 -9
- package/dist_ts/tsdisk.usage.d.ts +50 -0
- package/dist_ts/tsdisk.usage.js +194 -0
- package/package.json +4 -4
- package/readme.md +45 -20
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/index.ts +9 -0
- package/ts/tsdisk.cleanup.ts +120 -0
- package/ts/tsdisk.cli.ts +216 -199
- package/ts/tsdisk.plugins.ts +2 -1
- package/ts/tsdisk.progress.ts +78 -0
- package/ts/tsdisk.scan.ts +151 -0
- package/ts/tsdisk.toolcaches.ts +28 -8
- package/ts/tsdisk.usage.ts +199 -0
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
|
|
683
|
-
|
|
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
|
|
810
|
-
const roots = await getWorkspaceRoots();
|
|
685
|
+
export async function collectProjectToolCacheReport(ctx: IRuntimeContext, roots?: string[]) {
|
|
811
686
|
const findings: IToolCacheFinding[] = [];
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
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(...
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
const
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
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
|
|
830
|
-
|
|
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
|
-
|
|
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
|
|
840
|
-
|
|
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 --
|
|
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
|
-
|
|
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
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
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
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
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
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
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));
|
package/ts/tsdisk.plugins.ts
CHANGED
|
@@ -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
|
+
}
|