@git.zone/tsdisk 1.1.0 → 1.2.1
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 +26 -0
- package/dist_ts/tsdisk.cleanup.js +128 -0
- package/dist_ts/tsdisk.cli.d.ts +31 -7
- package/dist_ts/tsdisk.cli.js +174 -145
- package/dist_ts/tsdisk.plugins.d.ts +2 -2
- package/dist_ts/tsdisk.plugins.js +3 -3
- package/dist_ts/tsdisk.scan.d.ts +19 -0
- package/dist_ts/tsdisk.scan.js +118 -0
- package/dist_ts/tsdisk.toolcaches.js +27 -9
- package/package.json +4 -4
- package/readme.md +38 -19
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/index.ts +5 -0
- package/ts/tsdisk.cleanup.ts +120 -0
- package/ts/tsdisk.cli.ts +149 -163
- package/ts/tsdisk.plugins.ts +2 -1
- package/ts/tsdisk.scan.ts +124 -0
- package/ts/tsdisk.toolcaches.ts +28 -8
package/ts/tsdisk.cli.ts
CHANGED
|
@@ -1,23 +1,25 @@
|
|
|
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
|
+
export type { IWorkspaceScanOptions } from './tsdisk.scan.js';
|
|
3
6
|
import {
|
|
4
7
|
classifyToolCachePath,
|
|
5
|
-
parseToolCacheMarker,
|
|
6
|
-
toolCacheMarkerFile,
|
|
7
8
|
type IToolCacheFinding,
|
|
8
|
-
type IToolCacheMarker,
|
|
9
9
|
} from './tsdisk.toolcaches.js';
|
|
10
10
|
|
|
11
11
|
interface ICommandResult {
|
|
12
12
|
code: number;
|
|
13
13
|
stdout: string;
|
|
14
14
|
stderr: string;
|
|
15
|
+
truncated?: boolean;
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
interface IRunOptions {
|
|
18
19
|
asRoot?: boolean;
|
|
19
20
|
asTargetUser?: boolean;
|
|
20
21
|
timeoutSeconds?: number;
|
|
22
|
+
cwd?: string;
|
|
21
23
|
}
|
|
22
24
|
|
|
23
25
|
export interface IRuntimeContext {
|
|
@@ -51,11 +53,6 @@ interface INcduParseResult {
|
|
|
51
53
|
insights: INcduInsight[];
|
|
52
54
|
}
|
|
53
55
|
|
|
54
|
-
export interface IWorkspaceScanOptions {
|
|
55
|
-
maxDepth: number;
|
|
56
|
-
timeoutMs: number;
|
|
57
|
-
maxEntries: number;
|
|
58
|
-
}
|
|
59
56
|
|
|
60
57
|
const decoder = new TextDecoder();
|
|
61
58
|
const maxCapturedOutputBytes = 64 * 1024 * 1024;
|
|
@@ -209,6 +206,7 @@ function commandResultFromCapture(code: number, stdout: ICaptureState, stderr: I
|
|
|
209
206
|
truncationMessages.push(`stderr truncated after ${formatBytes(maxCapturedOutputBytes)}`);
|
|
210
207
|
}
|
|
211
208
|
if (truncationMessages.length) {
|
|
209
|
+
result.truncated = true;
|
|
212
210
|
result.stderr = [result.stderr.trimEnd(), `[tsdisk] ${truncationMessages.join('; ')}.`]
|
|
213
211
|
.filter(Boolean)
|
|
214
212
|
.join('\n');
|
|
@@ -591,12 +589,13 @@ async function runCommand(
|
|
|
591
589
|
}
|
|
592
590
|
|
|
593
591
|
if (options.timeoutSeconds && options.timeoutSeconds > 0) {
|
|
594
|
-
finalArgs = [`${options.timeoutSeconds}s`, executable, ...finalArgs];
|
|
592
|
+
finalArgs = ['--kill-after=5s', `${options.timeoutSeconds}s`, executable, ...finalArgs];
|
|
595
593
|
executable = 'timeout';
|
|
596
594
|
}
|
|
597
595
|
|
|
598
596
|
return await new Promise<ICommandResult>((resolve) => {
|
|
599
597
|
const child = plugins.spawn(executable, finalArgs, {
|
|
598
|
+
cwd: options.cwd,
|
|
600
599
|
env: {
|
|
601
600
|
...process.env,
|
|
602
601
|
...env,
|
|
@@ -679,119 +678,8 @@ async function pathExists(path: string): Promise<boolean> {
|
|
|
679
678
|
}
|
|
680
679
|
}
|
|
681
680
|
|
|
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()];
|
|
681
|
+
export async function collectToolCachePathsFromRoot(root: string, options: IWorkspaceScanOptions): Promise<IToolCacheFinding[]> {
|
|
682
|
+
return (await scanWorkspace(root, options)).findings;
|
|
795
683
|
}
|
|
796
684
|
|
|
797
685
|
async function getWorkspaceRoots(): Promise<string[]> {
|
|
@@ -806,34 +694,34 @@ async function getWorkspaceRoots(): Promise<string[]> {
|
|
|
806
694
|
return [...roots];
|
|
807
695
|
}
|
|
808
696
|
|
|
809
|
-
export async function
|
|
810
|
-
const roots = await getWorkspaceRoots();
|
|
697
|
+
export async function collectProjectToolCacheReport(ctx: IRuntimeContext, roots?: string[]) {
|
|
811
698
|
const findings: IToolCacheFinding[] = [];
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
const
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
for (const [index, finding] of uniqueFindings.entries()) {
|
|
825
|
-
if (index >= toolCacheSizingLimit || Date.now() - sizingStartedAt > toolCacheSizingTimeoutMs) {
|
|
826
|
-
sizedFindings.push(finding);
|
|
699
|
+
const issues: IScanIssue[] = [];
|
|
700
|
+
const scanRoots = roots?.length ? roots : await getWorkspaceRoots();
|
|
701
|
+
for (const root of scanRoots) {
|
|
702
|
+
const result = await scanWorkspace(root, { maxDepth: 8, timeoutMs: 45_000, maxEntries: 50_000 });
|
|
703
|
+
findings.push(...result.findings);
|
|
704
|
+
issues.push(...result.issues);
|
|
705
|
+
}
|
|
706
|
+
const unique = [...new Map(findings.map((finding) => [plugins.path.resolve(finding.path), finding])).values()];
|
|
707
|
+
const deadline = Date.now() + toolCacheSizingTimeoutMs;
|
|
708
|
+
for (const [index, finding] of unique.entries()) {
|
|
709
|
+
if (index >= toolCacheSizingLimit || Date.now() >= deadline) {
|
|
710
|
+
issues.push({ path: finding.path, reason: 'size-unavailable', detail: 'Sizing budget exhausted.' });
|
|
827
711
|
continue;
|
|
828
712
|
}
|
|
829
|
-
const
|
|
830
|
-
|
|
831
|
-
...finding,
|
|
832
|
-
bytes: size?.bytes,
|
|
713
|
+
const result = await runCommand(ctx, 'du', ['-sx', '--block-size=1', '--', finding.path], {
|
|
714
|
+
asRoot: ctx.sudoAvailable, timeoutSeconds: Math.max(1, Math.min(ctx.duTimeoutSeconds, Math.floor((deadline - Date.now()) / 1000))),
|
|
833
715
|
});
|
|
716
|
+
const size = result.code === 0 ? parseDuOutput(result.stdout)[0] : undefined;
|
|
717
|
+
if (size) finding.bytes = size.bytes;
|
|
718
|
+
else issues.push({ path: finding.path, reason: 'size-unavailable', detail: result.stderr.trim() || 'du failed or returned no size.' });
|
|
834
719
|
}
|
|
720
|
+
return { roots: scanRoots, toolCaches: unique.sort((a, b) => (b.bytes ?? 0) - (a.bytes ?? 0)), issues, complete: issues.length === 0 };
|
|
721
|
+
}
|
|
835
722
|
|
|
836
|
-
|
|
723
|
+
export async function collectProjectToolCacheDiagnostics(ctx: IRuntimeContext): Promise<IToolCacheFinding[]> {
|
|
724
|
+
return (await collectProjectToolCacheReport(ctx)).toolCaches;
|
|
837
725
|
}
|
|
838
726
|
|
|
839
727
|
async function printProjectToolCacheDiagnostics(ctx: IRuntimeContext) {
|
|
@@ -844,14 +732,16 @@ async function printProjectToolCacheDiagnostics(ctx: IRuntimeContext) {
|
|
|
844
732
|
return;
|
|
845
733
|
}
|
|
846
734
|
progress(`Scanning workspace roots: ${roots.join(', ')}`);
|
|
847
|
-
const
|
|
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;
|
|
848
738
|
if (findings.length === 0) {
|
|
849
739
|
console.log('No known project-local tool caches found.');
|
|
850
740
|
return;
|
|
851
741
|
}
|
|
852
742
|
|
|
853
743
|
for (const finding of findings) {
|
|
854
|
-
|
|
744
|
+
console.log(`${finding.bytes === undefined ? "unknown" : formatBytes(finding.bytes)} ${finding.ownerGuess} ${finding.kind} [${finding.risk}] ${finding.path}`);
|
|
855
745
|
console.log(` suggested: ${finding.suggestedCleanupCommand}`);
|
|
856
746
|
}
|
|
857
747
|
}
|
|
@@ -1604,7 +1494,15 @@ function printHelp() {
|
|
|
1604
1494
|
console.log('');
|
|
1605
1495
|
console.log('Usage:');
|
|
1606
1496
|
console.log(' tsdisk Run the disk usage diagnostic scan');
|
|
1607
|
-
console.log(' tsdisk --
|
|
1497
|
+
console.log(' tsdisk scan [--root PATH] [--json] Bounded diagnostics; repeat --root for multiple workspaces');
|
|
1498
|
+
console.log(' tsdisk --json Full diagnostic report including toolCaches, sections, and scan issues');
|
|
1499
|
+
console.log(' tsdisk cleanup [--root PROJECT] [--only rust,docker,pnpm] [--days 14]');
|
|
1500
|
+
console.log(' Preview owning-tool cleanup; defaults to rust,docker in the current project');
|
|
1501
|
+
console.log(' tsdisk cleanup --apply [--yes] Execute after review; JSON/noninteractive execution requires --yes');
|
|
1502
|
+
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.');
|
|
1504
|
+
console.log(' --days applies only to Rust targets. Docker uses its owner checks; pnpm uses reference tracking.');
|
|
1505
|
+
console.log(' scan exit codes: 0 complete, 2 incomplete, 1 invalid input/failure.');
|
|
1608
1506
|
console.log(' tsdisk --help Show this help');
|
|
1609
1507
|
console.log(' tsdisk --version Show the version');
|
|
1610
1508
|
console.log('');
|
|
@@ -1687,30 +1585,118 @@ export async function main() {
|
|
|
1687
1585
|
printCleanupHints(ctx);
|
|
1688
1586
|
}
|
|
1689
1587
|
|
|
1690
|
-
export
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1588
|
+
export interface IDiagnosticSection {
|
|
1589
|
+
name: string;
|
|
1590
|
+
command: string;
|
|
1591
|
+
args: string[];
|
|
1592
|
+
result: ICommandResult;
|
|
1593
|
+
complete: boolean;
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
export async function collectDiskReport(ctx: IRuntimeContext, roots?: string[]) {
|
|
1597
|
+
const caches = await collectProjectToolCacheReport(ctx, roots);
|
|
1598
|
+
const sections: IDiagnosticSection[] = [];
|
|
1599
|
+
const deadline = Date.now() + 120_000;
|
|
1600
|
+
const inspect = async (name: string, command: string, args: string[]) => {
|
|
1601
|
+
const result = Date.now() >= deadline
|
|
1602
|
+
? { code: 124, stdout: '', stderr: 'Diagnostic command budget exhausted.' }
|
|
1603
|
+
: 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 });
|
|
1606
|
+
return result;
|
|
1607
|
+
};
|
|
1608
|
+
await inspect('filesystems', 'df', ['-hT']);
|
|
1609
|
+
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]);
|
|
1611
|
+
for (const path of ['/', ctx.targetHome, `${ctx.targetHome}/.cache`, `${ctx.targetHome}/.local/share`, '/var', '/var/lib']) {
|
|
1612
|
+
await inspect(`usage:${path}`, 'du', ['-x', '--max-depth=1', '--block-size=1', '--', path]);
|
|
1613
|
+
}
|
|
1614
|
+
await inspect('pnpm-store', 'pnpm', ['store', 'path']);
|
|
1615
|
+
await inspect('journal', 'journalctl', ['--disk-usage']);
|
|
1616
|
+
await inspect('deleted-open-files', 'lsof', ['-nP', '+L1']);
|
|
1617
|
+
const contexts = await inspect('docker-contexts', 'docker', ['context', 'ls', '--format', '{{.Name}}']);
|
|
1618
|
+
if (contexts.code === 0) {
|
|
1619
|
+
for (const context of contexts.stdout.trim().split(/\r?\n/).filter(Boolean)) {
|
|
1620
|
+
await inspect(`docker:${context}`, 'docker', ['--context', context, 'system', 'df', '-v']);
|
|
1621
|
+
}
|
|
1694
1622
|
}
|
|
1623
|
+
return { schemaVersion: 1, generatedAt: new Date().toISOString(), ...caches, sections, complete: caches.complete && sections.every((section) => section.complete) };
|
|
1624
|
+
}
|
|
1695
1625
|
|
|
1696
|
-
|
|
1697
|
-
|
|
1626
|
+
async function runCleanup(options: ICliOptions) {
|
|
1627
|
+
const plan = await createCleanupPlan(options);
|
|
1628
|
+
const ctx = await initContext();
|
|
1629
|
+
const previews: Array<{ action: number; result: ICommandResult }> = [];
|
|
1630
|
+
const results: Array<{ action: number; result: ICommandResult }> = [];
|
|
1631
|
+
const print = () => {
|
|
1632
|
+
if (options.json) console.log(JSON.stringify({ ...plan, previews, results }, null, 2));
|
|
1633
|
+
else {
|
|
1634
|
+
console.log(plan.dryRun ? 'Cleanup preview (no deletion)' : 'Cleanup plan');
|
|
1635
|
+
plan.actions.forEach((action, index) => {
|
|
1636
|
+
console.log(`[${index + 1}] ${action.category}: ${action.cwd}`);
|
|
1637
|
+
console.log(` ${action.scope}`);
|
|
1638
|
+
console.log(` command: ${JSON.stringify([action.command, ...action.applyArgs])}`);
|
|
1639
|
+
if (action.blocked) console.log(` blocked: ${action.blocked}`);
|
|
1640
|
+
});
|
|
1641
|
+
for (const item of [...previews, ...results]) {
|
|
1642
|
+
console.log(`[${item.action + 1}] exit ${item.result.code}`);
|
|
1643
|
+
if (item.result.stdout) console.log(item.result.stdout.trimEnd());
|
|
1644
|
+
if (item.result.stderr) console.error(item.result.stderr.trimEnd());
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
};
|
|
1648
|
+
if (options.apply && options.json && !options.yes) throw new Error('JSON cleanup requires --apply --yes to execute.');
|
|
1649
|
+
// Complete all previews before any apply command. Missing tools and failed previews block the batch.
|
|
1650
|
+
for (const [index, action] of plan.actions.entries()) {
|
|
1651
|
+
if (action.blocked) continue;
|
|
1652
|
+
const result = await runCommand(ctx, action.command, action.previewArgs ?? ['store', 'path'], { cwd: action.cwd, timeoutSeconds: 120 });
|
|
1653
|
+
previews.push({ action: index, result });
|
|
1654
|
+
}
|
|
1655
|
+
if (plan.actions.some((action) => action.blocked) || previews.some((preview) => preview.result.code !== 0 || preview.result.truncated)) {
|
|
1656
|
+
print();
|
|
1657
|
+
process.exitCode = 1;
|
|
1698
1658
|
return;
|
|
1699
1659
|
}
|
|
1700
|
-
|
|
1701
|
-
if (
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1660
|
+
if (!options.apply) { print(); return; }
|
|
1661
|
+
if (!options.json) print();
|
|
1662
|
+
if (!options.yes) {
|
|
1663
|
+
if (!process.stdin.isTTY) throw new Error('Noninteractive cleanup requires --apply --yes.');
|
|
1664
|
+
if (!await promptYesNo('Run the listed owning-tool cleanup commands?', false)) return;
|
|
1665
|
+
}
|
|
1666
|
+
for (const [index, action] of plan.actions.entries()) {
|
|
1667
|
+
const result = await runCommand(ctx, action.command, action.applyArgs, { cwd: action.cwd, timeoutSeconds: 240 });
|
|
1668
|
+
results.push({ action: index, result });
|
|
1669
|
+
if (result.code !== 0) { process.exitCode = 1; break; }
|
|
1670
|
+
}
|
|
1671
|
+
// JSON emits one complete document; text can show the plan before confirmation.
|
|
1672
|
+
if (options.json) print();
|
|
1673
|
+
else {
|
|
1674
|
+
for (const item of results) {
|
|
1675
|
+
console.log(`[${item.action + 1}] cleanup exit ${item.result.code}`);
|
|
1676
|
+
if (item.result.stdout) console.log(item.result.stdout.trimEnd());
|
|
1677
|
+
if (item.result.stderr) console.error(item.result.stderr.trimEnd());
|
|
1709
1678
|
}
|
|
1710
|
-
return;
|
|
1711
1679
|
}
|
|
1680
|
+
}
|
|
1712
1681
|
|
|
1682
|
+
export async function runCli(args = process.argv.slice(2)) {
|
|
1713
1683
|
try {
|
|
1684
|
+
const options = parseCliOptions(args);
|
|
1685
|
+
if (options.command === 'help') { printHelp(); return; }
|
|
1686
|
+
if (options.command === 'version') { console.log(commitinfo.version); return; }
|
|
1687
|
+
if (options.command === 'cleanup') { await runCleanup(options); return; }
|
|
1688
|
+
if (options.command === 'scan') {
|
|
1689
|
+
const report = await collectDiskReport(await initContext(), options.roots);
|
|
1690
|
+
if (options.json) console.log(JSON.stringify(report, null, 2));
|
|
1691
|
+
else {
|
|
1692
|
+
console.log(`Scan ${report.complete ? 'complete' : 'incomplete'}: ${report.toolCaches.length} tool caches`);
|
|
1693
|
+
for (const finding of report.toolCaches) console.log(`${finding.bytes === undefined ? 'unknown' : formatBytes(finding.bytes)} [${finding.risk}] ${finding.path}\n ${finding.suggestedCleanupCommand}`);
|
|
1694
|
+
for (const issue of report.issues) console.log(`${issue.reason}: ${issue.path}: ${issue.detail}`);
|
|
1695
|
+
for (const section of report.sections) console.log(`\n${section.name} [${section.complete ? 'complete' : 'incomplete'}]\n${section.result.stdout}${section.result.stderr}`);
|
|
1696
|
+
}
|
|
1697
|
+
if (!report.complete) process.exitCode = 2;
|
|
1698
|
+
return;
|
|
1699
|
+
}
|
|
1714
1700
|
await main();
|
|
1715
1701
|
} catch (error) {
|
|
1716
1702
|
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,124 @@
|
|
|
1
|
+
import * as plugins from './tsdisk.plugins.js';
|
|
2
|
+
import { classifyToolCachePath, parseToolCacheMarker, toolCacheMarkerFile, type IToolCacheFinding } from './tsdisk.toolcaches.js';
|
|
3
|
+
|
|
4
|
+
export interface IWorkspaceScanOptions {
|
|
5
|
+
maxDepth: number;
|
|
6
|
+
timeoutMs: number;
|
|
7
|
+
maxEntries: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface IScanIssue {
|
|
11
|
+
path: string;
|
|
12
|
+
reason: 'read-error' | 'invalid-marker' | 'depth-limit' | 'entry-limit' | 'timeout' | 'mount-boundary' | 'symlink' | 'size-unavailable';
|
|
13
|
+
detail: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface IWorkspaceScanResult {
|
|
17
|
+
root: string;
|
|
18
|
+
findings: IToolCacheFinding[];
|
|
19
|
+
issues: IScanIssue[];
|
|
20
|
+
complete: boolean;
|
|
21
|
+
visitedEntries: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const excludedDirectories = new Set(['.git', 'node_modules', 'dist_ts', 'dist_ts_web', 'coverage', '.pnpm-store']);
|
|
25
|
+
|
|
26
|
+
export async function scanWorkspace(root: string, options: IWorkspaceScanOptions): Promise<IWorkspaceScanResult> {
|
|
27
|
+
for (const name of ['maxDepth', 'timeoutMs', 'maxEntries'] as const) {
|
|
28
|
+
const value = options[name];
|
|
29
|
+
if (!Number.isSafeInteger(value) || value < (name === 'maxDepth' ? 0 : 1)) {
|
|
30
|
+
throw new Error(`Invalid scan option ${name}: ${value}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const result: IWorkspaceScanResult = { root: plugins.path.resolve(root), findings: [], issues: [], complete: true, visitedEntries: 0 };
|
|
34
|
+
const deadline = Date.now() + options.timeoutMs;
|
|
35
|
+
let stopped = false;
|
|
36
|
+
const issue = (path: string, reason: IScanIssue['reason'], detail: string) => {
|
|
37
|
+
result.complete = false;
|
|
38
|
+
result.issues.push({ path, reason, detail });
|
|
39
|
+
};
|
|
40
|
+
const limited = (path: string) => {
|
|
41
|
+
if (stopped) return true;
|
|
42
|
+
if (Date.now() >= deadline || result.visitedEntries >= options.maxEntries) {
|
|
43
|
+
issue(path, Date.now() >= deadline ? 'timeout' : 'entry-limit', 'Scan stopped before visiting all entries.');
|
|
44
|
+
stopped = true;
|
|
45
|
+
}
|
|
46
|
+
return stopped;
|
|
47
|
+
};
|
|
48
|
+
let rootDev: number;
|
|
49
|
+
try {
|
|
50
|
+
const stat = await plugins.fs.lstat(result.root);
|
|
51
|
+
if (stat.isSymbolicLink() || await plugins.fs.realpath(result.root) !== result.root) {
|
|
52
|
+
issue(result.root, 'symlink', 'Scan roots must not contain symbolic links.');
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
55
|
+
if (!stat.isDirectory()) throw new Error('Scan root is not a directory.');
|
|
56
|
+
rootDev = stat.dev;
|
|
57
|
+
} catch (error) {
|
|
58
|
+
issue(result.root, 'read-error', error instanceof Error ? error.message : String(error));
|
|
59
|
+
return result;
|
|
60
|
+
}
|
|
61
|
+
const visit = async (directory: string, depth: number): Promise<void> => {
|
|
62
|
+
if (limited(directory)) return;
|
|
63
|
+
result.visitedEntries++;
|
|
64
|
+
try {
|
|
65
|
+
const stat = await plugins.fs.lstat(directory);
|
|
66
|
+
if (stat.isSymbolicLink()) {
|
|
67
|
+
issue(directory, 'symlink', 'Symbolic link skipped.');
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (stat.dev !== rootDev) {
|
|
71
|
+
issue(directory, 'mount-boundary', 'Different filesystem skipped.');
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const entries = await plugins.fs.readdir(directory, { withFileTypes: true });
|
|
75
|
+
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);
|
|
79
|
+
if (!markerStat.isFile() || markerStat.size > 65536) {
|
|
80
|
+
issue(markerPath, 'invalid-marker', 'Marker must be a regular file of at most 64 KiB.');
|
|
81
|
+
} else {
|
|
82
|
+
marker = parseToolCacheMarker(await plugins.fs.readFile(markerPath, 'utf8'));
|
|
83
|
+
if (!marker) issue(markerPath, 'invalid-marker', 'Invalid cache ownership marker.');
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const heuristic = classifyToolCachePath(directory);
|
|
87
|
+
const classification = classifyToolCachePath(directory, marker);
|
|
88
|
+
if (classification) result.findings.push({ path: directory, ...classification });
|
|
89
|
+
// Cache contents are not workspaces. Registry session directories can carry their own markers.
|
|
90
|
+
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) {
|
|
97
|
+
const childPath = plugins.path.join(directory, child.name);
|
|
98
|
+
if (limited(childPath)) return;
|
|
99
|
+
if (!child.isDirectory() && !child.isSymbolicLink()) {
|
|
100
|
+
result.visitedEntries++;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (child.isSymbolicLink()) {
|
|
104
|
+
result.visitedEntries++;
|
|
105
|
+
issue(childPath, 'symlink', 'Symbolic link skipped.');
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (heuristic?.kind === 'docker-registry-cache') {
|
|
109
|
+
// Only direct sessions, never registry blob trees.
|
|
110
|
+
const sessionMarker = await plugins.fs.lstat(plugins.path.join(childPath, toolCacheMarkerFile)).catch((error: NodeJS.ErrnoException) => {
|
|
111
|
+
if (error.code === 'ENOENT') return undefined;
|
|
112
|
+
throw error;
|
|
113
|
+
});
|
|
114
|
+
if (!sessionMarker) { result.visitedEntries++; continue; }
|
|
115
|
+
}
|
|
116
|
+
await visit(childPath, depth + 1);
|
|
117
|
+
}
|
|
118
|
+
} catch (error) {
|
|
119
|
+
issue(directory, 'read-error', error instanceof Error ? error.message : String(error));
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
await visit(result.root, 0);
|
|
123
|
+
return result;
|
|
124
|
+
}
|
package/ts/tsdisk.toolcaches.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { path } from './tsdisk.plugins.js';
|
|
2
2
|
|
|
3
3
|
export const toolCacheMarkerFile = '.gitzone-tool-cache.json';
|
|
4
4
|
|
|
@@ -29,10 +29,10 @@ export function parseToolCacheMarker(rawArg: string): IToolCacheMarker | undefin
|
|
|
29
29
|
try {
|
|
30
30
|
const marker = JSON.parse(rawArg) as Partial<IToolCacheMarker>;
|
|
31
31
|
if (
|
|
32
|
-
typeof marker.owner === 'string' &&
|
|
33
|
-
typeof marker.kind === 'string' &&
|
|
34
|
-
marker.safeToPrune ===
|
|
35
|
-
typeof marker.createdAt === 'string' &&
|
|
32
|
+
typeof marker.owner === 'string' && marker.owner.length > 0 &&
|
|
33
|
+
typeof marker.kind === 'string' && marker.kind.length > 0 &&
|
|
34
|
+
typeof marker.safeToPrune === 'boolean' &&
|
|
35
|
+
typeof marker.createdAt === 'string' && Number.isFinite(Date.parse(marker.createdAt)) &&
|
|
36
36
|
marker.schemaVersion === 1
|
|
37
37
|
) {
|
|
38
38
|
return marker as IToolCacheMarker;
|
|
@@ -79,8 +79,10 @@ export function classifyToolCachePath(pathArg: string, markerArg?: IToolCacheMar
|
|
|
79
79
|
return {
|
|
80
80
|
ownerGuess: markerArg.owner,
|
|
81
81
|
kind: markerArg.kind,
|
|
82
|
-
risk:
|
|
83
|
-
suggestedCleanupCommand:
|
|
82
|
+
risk: 'review',
|
|
83
|
+
suggestedCleanupCommand: markerArg.safeToPrune
|
|
84
|
+
? `report-only: ownership marker is not proof of inactivity; use the owning tool to review ${quotePath(pathArg)}`
|
|
85
|
+
: `protected: owner explicitly disallows pruning ${quotePath(pathArg)}`,
|
|
84
86
|
marker: markerArg,
|
|
85
87
|
};
|
|
86
88
|
}
|
|
@@ -103,7 +105,7 @@ export function classifyToolCachePath(pathArg: string, markerArg?: IToolCacheMar
|
|
|
103
105
|
};
|
|
104
106
|
}
|
|
105
107
|
|
|
106
|
-
if (
|
|
108
|
+
if (/\/\.nogit\/tsbundle-temp(?:-[^/]+)?$/.test(normalized)) {
|
|
107
109
|
return {
|
|
108
110
|
ownerGuess: '@git.zone/tsbundle',
|
|
109
111
|
kind: 'bundle-temp-workspace',
|
|
@@ -112,6 +114,24 @@ export function classifyToolCachePath(pathArg: string, markerArg?: IToolCacheMar
|
|
|
112
114
|
};
|
|
113
115
|
}
|
|
114
116
|
|
|
117
|
+
if (/\/\.nogit\/tsrust-matrix(?:-archive|-probes)?$/.test(normalized)) {
|
|
118
|
+
return {
|
|
119
|
+
ownerGuess: '@git.zone/tsrust',
|
|
120
|
+
kind: 'rust-matrix-artifacts',
|
|
121
|
+
risk: 'report-only',
|
|
122
|
+
suggestedCleanupCommand: `report-only: retained matrix runs may be needed for recovery; inspect ${quotePath(pathArg)}`,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (/\/\.nogit\/(?:docker-registry\.[^/]+|docker-registry-v1-cache|dockerimagestore)$/.test(normalized)) {
|
|
127
|
+
return {
|
|
128
|
+
ownerGuess: '@git.zone/tsdocker',
|
|
129
|
+
kind: 'legacy-docker-registry-cache',
|
|
130
|
+
risk: 'report-only',
|
|
131
|
+
suggestedCleanupCommand: `report-only: verify release recovery requirements before removing ${quotePath(pathArg)}`,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
115
135
|
if (normalized.endsWith('/.nogit/docker-registry') || normalized.includes('/.nogit/docker-registry/')) {
|
|
116
136
|
return {
|
|
117
137
|
ownerGuess: '@git.zone/tsdocker',
|