@git.zone/tsdisk 1.1.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.
@@ -0,0 +1,1719 @@
1
+ import * as plugins from './tsdisk.plugins.js';
2
+ import { commitinfo } from './00_commitinfo_data.js';
3
+ import {
4
+ classifyToolCachePath,
5
+ parseToolCacheMarker,
6
+ toolCacheMarkerFile,
7
+ type IToolCacheFinding,
8
+ type IToolCacheMarker,
9
+ } from './tsdisk.toolcaches.js';
10
+
11
+ interface ICommandResult {
12
+ code: number;
13
+ stdout: string;
14
+ stderr: string;
15
+ }
16
+
17
+ interface IRunOptions {
18
+ asRoot?: boolean;
19
+ asTargetUser?: boolean;
20
+ timeoutSeconds?: number;
21
+ }
22
+
23
+ export interface IRuntimeContext {
24
+ runningAsRoot: boolean;
25
+ sudoAvailable: boolean;
26
+ sudoChecked: boolean;
27
+ targetUser: string;
28
+ targetUid: string;
29
+ targetHome: string;
30
+ targetRuntimeDir: string;
31
+ dockerTimeoutSeconds: number;
32
+ duTimeoutSeconds: number;
33
+ ncduTimeoutSeconds: number;
34
+ }
35
+
36
+ interface IDuEntry {
37
+ bytes: number;
38
+ path: string;
39
+ }
40
+
41
+ interface INcduInsight {
42
+ bytes: number;
43
+ path: string;
44
+ type: 'directory' | 'file';
45
+ readError: boolean;
46
+ excluded?: string;
47
+ }
48
+
49
+ interface INcduParseResult {
50
+ rootBytes: number;
51
+ insights: INcduInsight[];
52
+ }
53
+
54
+ export interface IWorkspaceScanOptions {
55
+ maxDepth: number;
56
+ timeoutMs: number;
57
+ maxEntries: number;
58
+ }
59
+
60
+ const decoder = new TextDecoder();
61
+ const maxCapturedOutputBytes = 64 * 1024 * 1024;
62
+ const ncduInsightLimit = 30;
63
+ const toolCacheSizingLimit = 500;
64
+ const toolCacheSizingTimeoutMs = 45_000;
65
+
66
+ function section(title: string) {
67
+ console.log(`\n=== ${title} ===`);
68
+ }
69
+
70
+ function subsection(title: string) {
71
+ console.log(`\n--- ${title} ---`);
72
+ }
73
+
74
+ function progress(message: string) {
75
+ console.log(`[tsdisk] ${message}`);
76
+ }
77
+
78
+ function parsePositiveInteger(value: string | undefined, fallback: number): number {
79
+ if (!value) {
80
+ return fallback;
81
+ }
82
+
83
+ const parsed = Number(value);
84
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
85
+ }
86
+
87
+ function decodeOutput(output: Uint8Array): string {
88
+ return decoder.decode(output);
89
+ }
90
+
91
+ function firstLines(text: string, maxLines = 8): string {
92
+ return text.trim().split(/\r?\n/).slice(0, maxLines).join('\n');
93
+ }
94
+
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
+ function printSizeLine(label: string, bytes: number) {
114
+ console.log(`${formatBytes(bytes).padStart(10)} ${label}`);
115
+ }
116
+
117
+ function isTruthy(value: string | undefined): boolean {
118
+ const normalized = value?.toLowerCase();
119
+ return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'y';
120
+ }
121
+
122
+ async function promptYesNo(question: string, defaultYes = false): Promise<boolean> {
123
+ const suffix = defaultYes ? '[Y/n]' : '[y/N]';
124
+ const readline = plugins.createInterface({
125
+ input: process.stdin,
126
+ output: process.stdout,
127
+ });
128
+
129
+ try {
130
+ const answer = (await readline.question(`${question} ${suffix} `)).trim().toLowerCase();
131
+ if (!answer) {
132
+ return defaultYes;
133
+ }
134
+
135
+ return answer === 'y' || answer === 'yes';
136
+ } catch {
137
+ return defaultYes;
138
+ } finally {
139
+ readline.close();
140
+ }
141
+ }
142
+
143
+ export function parseDuOutput(output: string): IDuEntry[] {
144
+ const entries: IDuEntry[] = [];
145
+
146
+ for (const line of output.trim().split(/\r?\n/)) {
147
+ const match = line.match(/^(\d+)\s+(.+)$/);
148
+ if (!match) {
149
+ continue;
150
+ }
151
+
152
+ entries.push({
153
+ bytes: Number(match[1]),
154
+ path: match[2],
155
+ });
156
+ }
157
+
158
+ return entries;
159
+ }
160
+
161
+ interface ICaptureState {
162
+ bytes: number;
163
+ chunks: Uint8Array[];
164
+ truncated: boolean;
165
+ }
166
+
167
+ function createCaptureState(): ICaptureState {
168
+ return {
169
+ bytes: 0,
170
+ chunks: [],
171
+ truncated: false,
172
+ };
173
+ }
174
+
175
+ function captureOutputChunk(state: ICaptureState, chunk: Uint8Array) {
176
+ if (state.bytes >= maxCapturedOutputBytes) {
177
+ state.truncated = true;
178
+ return;
179
+ }
180
+
181
+ const remainingBytes = maxCapturedOutputBytes - state.bytes;
182
+ if (chunk.byteLength > remainingBytes) {
183
+ state.chunks.push(chunk.subarray(0, remainingBytes));
184
+ state.bytes += remainingBytes;
185
+ state.truncated = true;
186
+ return;
187
+ }
188
+
189
+ state.chunks.push(chunk);
190
+ state.bytes += chunk.byteLength;
191
+ }
192
+
193
+ function capturedOutput(state: ICaptureState): string {
194
+ return decodeOutput(Buffer.concat(state.chunks));
195
+ }
196
+
197
+ function commandResultFromCapture(code: number, stdout: ICaptureState, stderr: ICaptureState): ICommandResult {
198
+ const result: ICommandResult = {
199
+ code,
200
+ stdout: capturedOutput(stdout),
201
+ stderr: capturedOutput(stderr),
202
+ };
203
+
204
+ const truncationMessages: string[] = [];
205
+ if (stdout.truncated) {
206
+ truncationMessages.push(`stdout truncated after ${formatBytes(maxCapturedOutputBytes)}`);
207
+ }
208
+ if (stderr.truncated) {
209
+ truncationMessages.push(`stderr truncated after ${formatBytes(maxCapturedOutputBytes)}`);
210
+ }
211
+ if (truncationMessages.length) {
212
+ result.stderr = [result.stderr.trimEnd(), `[tsdisk] ${truncationMessages.join('; ')}.`]
213
+ .filter(Boolean)
214
+ .join('\n');
215
+ }
216
+
217
+ return result;
218
+ }
219
+
220
+ class JsonStreamReader {
221
+ private buffer = '';
222
+ private bytesRead = 0;
223
+ private chunkDecoder = new TextDecoder();
224
+ private done = false;
225
+ private index = 0;
226
+ private iterator?: AsyncIterator<Buffer>;
227
+ private lastProgressBytes = 0;
228
+ private stream?: plugins.ReadStream;
229
+
230
+ constructor(
231
+ private filePath: string,
232
+ private totalBytes: number,
233
+ ) {}
234
+
235
+ async open() {
236
+ this.stream = plugins.createReadStream(this.filePath);
237
+ this.iterator = this.stream[Symbol.asyncIterator]() as AsyncIterator<Buffer>;
238
+ }
239
+
240
+ close() {
241
+ this.stream?.destroy();
242
+ }
243
+
244
+ private reportProgress() {
245
+ const interval = 512 * 1024 * 1024;
246
+ if (this.bytesRead - this.lastProgressBytes < interval) {
247
+ return;
248
+ }
249
+
250
+ this.lastProgressBytes = this.bytesRead;
251
+ progress(`Parsed ${formatBytes(this.bytesRead)} of ${formatBytes(this.totalBytes)} from ncdu export.`);
252
+ }
253
+
254
+ private async fillBuffer(): Promise<boolean> {
255
+ while (this.index >= this.buffer.length) {
256
+ if (this.done) {
257
+ return false;
258
+ }
259
+
260
+ if (!this.iterator) {
261
+ throw new Error('JSON stream reader is not open.');
262
+ }
263
+
264
+ const chunk = await this.iterator.next();
265
+ if (chunk.done) {
266
+ this.done = true;
267
+ this.buffer = this.chunkDecoder.decode();
268
+ this.index = 0;
269
+ return this.buffer.length > 0;
270
+ }
271
+
272
+ this.bytesRead += chunk.value.byteLength;
273
+ this.reportProgress();
274
+ this.buffer = this.chunkDecoder.decode(chunk.value, { stream: true });
275
+ this.index = 0;
276
+ }
277
+
278
+ return true;
279
+ }
280
+
281
+ async peek(): Promise<string | undefined> {
282
+ if (!(await this.fillBuffer())) {
283
+ return undefined;
284
+ }
285
+
286
+ return this.buffer[this.index];
287
+ }
288
+
289
+ async next(): Promise<string | undefined> {
290
+ const char = await this.peek();
291
+ if (char !== undefined) {
292
+ this.index++;
293
+ }
294
+
295
+ return char;
296
+ }
297
+
298
+ async skipWhitespace() {
299
+ while (true) {
300
+ const char = await this.peek();
301
+ if (char === undefined || !/\s/.test(char)) {
302
+ return;
303
+ }
304
+ this.index++;
305
+ }
306
+ }
307
+
308
+ async expect(expected: string) {
309
+ await this.skipWhitespace();
310
+ const actual = await this.next();
311
+ if (actual !== expected) {
312
+ throw new Error(`Expected JSON token ${expected}, got ${actual ?? 'EOF'}.`);
313
+ }
314
+ }
315
+
316
+ async readString(): Promise<string> {
317
+ await this.skipWhitespace();
318
+ const first = await this.next();
319
+ if (first !== '"') {
320
+ throw new Error(`Expected JSON string, got ${first ?? 'EOF'}.`);
321
+ }
322
+
323
+ let raw = '"';
324
+ let escaped = false;
325
+ while (true) {
326
+ const char = await this.next();
327
+ if (char === undefined) {
328
+ throw new Error('Unexpected EOF inside JSON string.');
329
+ }
330
+
331
+ raw += char;
332
+ if (escaped) {
333
+ escaped = false;
334
+ } else if (char === '\\') {
335
+ escaped = true;
336
+ } else if (char === '"') {
337
+ return JSON.parse(raw) as string;
338
+ }
339
+ }
340
+ }
341
+
342
+ async readNumber(): Promise<number> {
343
+ await this.skipWhitespace();
344
+ let raw = '';
345
+
346
+ while (true) {
347
+ const char = await this.peek();
348
+ if (char === undefined || !/[0-9eE+.-]/.test(char)) {
349
+ break;
350
+ }
351
+
352
+ raw += char;
353
+ this.index++;
354
+ }
355
+
356
+ const parsed = Number(raw);
357
+ if (!raw || !Number.isFinite(parsed)) {
358
+ throw new Error(`Expected JSON number, got ${raw || 'EOF'}.`);
359
+ }
360
+
361
+ return parsed;
362
+ }
363
+
364
+ async readLiteral(expected: string) {
365
+ await this.skipWhitespace();
366
+ for (const expectedChar of expected) {
367
+ const actualChar = await this.next();
368
+ if (actualChar !== expectedChar) {
369
+ throw new Error(`Expected JSON literal ${expected}.`);
370
+ }
371
+ }
372
+ }
373
+
374
+ async skipValue() {
375
+ await this.skipWhitespace();
376
+ const char = await this.peek();
377
+
378
+ if (char === '"') {
379
+ await this.readString();
380
+ return;
381
+ }
382
+
383
+ if (char === '{') {
384
+ await this.expect('{');
385
+ await this.skipWhitespace();
386
+ if ((await this.peek()) === '}') {
387
+ await this.next();
388
+ return;
389
+ }
390
+
391
+ while (true) {
392
+ await this.readString();
393
+ await this.expect(':');
394
+ await this.skipValue();
395
+ await this.skipWhitespace();
396
+ const nextToken = await this.next();
397
+ if (nextToken === '}') {
398
+ return;
399
+ }
400
+ if (nextToken !== ',') {
401
+ throw new Error(`Expected JSON object separator, got ${nextToken ?? 'EOF'}.`);
402
+ }
403
+ }
404
+ }
405
+
406
+ if (char === '[') {
407
+ await this.expect('[');
408
+ await this.skipWhitespace();
409
+ if ((await this.peek()) === ']') {
410
+ await this.next();
411
+ return;
412
+ }
413
+
414
+ while (true) {
415
+ await this.skipValue();
416
+ await this.skipWhitespace();
417
+ const nextToken = await this.next();
418
+ if (nextToken === ']') {
419
+ return;
420
+ }
421
+ if (nextToken !== ',') {
422
+ throw new Error(`Expected JSON array separator, got ${nextToken ?? 'EOF'}.`);
423
+ }
424
+ }
425
+ }
426
+
427
+ if (char === 't') {
428
+ await this.readLiteral('true');
429
+ return;
430
+ }
431
+
432
+ if (char === 'f') {
433
+ await this.readLiteral('false');
434
+ return;
435
+ }
436
+
437
+ if (char === 'n') {
438
+ await this.readLiteral('null');
439
+ return;
440
+ }
441
+
442
+ await this.readNumber();
443
+ }
444
+ }
445
+
446
+ async function runBasic(command: string, args: string[]): Promise<ICommandResult> {
447
+ return await new Promise<ICommandResult>((resolve) => {
448
+ const child = plugins.spawn(command, args, {
449
+ stdio: ['ignore', 'pipe', 'pipe'],
450
+ });
451
+ const stdout = createCaptureState();
452
+ const stderr = createCaptureState();
453
+
454
+ child.stdout.on('data', (chunk: Uint8Array) => captureOutputChunk(stdout, chunk));
455
+ child.stderr.on('data', (chunk: Uint8Array) => captureOutputChunk(stderr, chunk));
456
+ child.on('error', (error) => {
457
+ resolve({
458
+ code: 127,
459
+ stdout: '',
460
+ stderr: error instanceof Error ? error.message : String(error),
461
+ });
462
+ });
463
+ child.on('close', (code) => {
464
+ resolve(commandResultFromCapture(code ?? 1, stdout, stderr));
465
+ });
466
+ });
467
+ }
468
+
469
+ async function runInteractive(command: string, args: string[], timeoutSeconds?: number): Promise<number> {
470
+ return await new Promise<number>((resolve) => {
471
+ const child = plugins.spawn(command, args, {
472
+ stdio: 'inherit',
473
+ });
474
+ let didTimeout = false;
475
+ let killTimeout: NodeJS.Timeout | undefined;
476
+ const timeout = timeoutSeconds && timeoutSeconds > 0
477
+ ? setTimeout(() => {
478
+ didTimeout = true;
479
+ child.kill('SIGTERM');
480
+ killTimeout = setTimeout(() => child.kill('SIGKILL'), 5000);
481
+ killTimeout.unref();
482
+ }, timeoutSeconds * 1000)
483
+ : undefined;
484
+ timeout?.unref();
485
+
486
+ child.on('error', (error) => {
487
+ if (timeout) {
488
+ clearTimeout(timeout);
489
+ }
490
+ if (killTimeout) {
491
+ clearTimeout(killTimeout);
492
+ }
493
+ console.error(error instanceof Error ? error.message : String(error));
494
+ resolve(127);
495
+ });
496
+ child.on('close', (code, signal) => {
497
+ if (timeout) {
498
+ clearTimeout(timeout);
499
+ }
500
+ if (killTimeout) {
501
+ clearTimeout(killTimeout);
502
+ }
503
+ if (didTimeout) {
504
+ resolve(124);
505
+ return;
506
+ }
507
+ if (code !== null) {
508
+ resolve(code);
509
+ return;
510
+ }
511
+
512
+ resolve(signal === 'SIGINT' ? 130 : 1);
513
+ });
514
+ });
515
+ }
516
+
517
+ async function commandExists(command: string): Promise<boolean> {
518
+ const result = await runBasic('which', [command]);
519
+ return result.code === 0 && result.stdout.trim().length > 0;
520
+ }
521
+
522
+ async function runRootInteractive(
523
+ ctx: IRuntimeContext,
524
+ command: string,
525
+ args: string[],
526
+ timeoutSeconds?: number,
527
+ ): Promise<number> {
528
+ let executable = command;
529
+ let finalArgs = args;
530
+
531
+ if (timeoutSeconds && timeoutSeconds > 0) {
532
+ finalArgs = [`${timeoutSeconds}s`, command, ...args];
533
+ executable = 'timeout';
534
+ }
535
+ const processTimeoutSeconds = timeoutSeconds && timeoutSeconds > 0 ? timeoutSeconds + 10 : undefined;
536
+
537
+ if (ctx.runningAsRoot) {
538
+ return await runInteractive(executable, finalArgs, processTimeoutSeconds);
539
+ }
540
+
541
+ if (!(await ensureSudo(ctx))) {
542
+ return 1;
543
+ }
544
+
545
+ return await runInteractive('sudo', [executable, ...finalArgs], processTimeoutSeconds);
546
+ }
547
+
548
+ function buildTargetUserArgs(ctx: IRuntimeContext, command: string, args: string[]): string[] {
549
+ return [
550
+ '-u',
551
+ ctx.targetUser,
552
+ 'env',
553
+ `HOME=${ctx.targetHome}`,
554
+ `XDG_RUNTIME_DIR=${ctx.targetRuntimeDir}`,
555
+ command,
556
+ ...args,
557
+ ];
558
+ }
559
+
560
+ async function runCommand(
561
+ ctx: IRuntimeContext,
562
+ command: string,
563
+ args: string[],
564
+ options: IRunOptions = {},
565
+ ): Promise<ICommandResult> {
566
+ let executable = command;
567
+ let finalArgs = [...args];
568
+ const env: NodeJS.ProcessEnv = {};
569
+
570
+ if (options.asRoot && !ctx.runningAsRoot) {
571
+ if (!ctx.sudoAvailable) {
572
+ return {
573
+ code: 1,
574
+ stdout: '',
575
+ stderr: 'sudo is unavailable; root-only check skipped',
576
+ };
577
+ }
578
+
579
+ executable = 'sudo';
580
+ finalArgs = ['-n', command, ...finalArgs];
581
+ }
582
+
583
+ if (options.asTargetUser) {
584
+ if (ctx.runningAsRoot && ctx.targetUser !== 'root') {
585
+ executable = 'sudo';
586
+ finalArgs = buildTargetUserArgs(ctx, command, finalArgs);
587
+ } else {
588
+ env.HOME = ctx.targetHome;
589
+ env.XDG_RUNTIME_DIR = ctx.targetRuntimeDir;
590
+ }
591
+ }
592
+
593
+ if (options.timeoutSeconds && options.timeoutSeconds > 0) {
594
+ finalArgs = [`${options.timeoutSeconds}s`, executable, ...finalArgs];
595
+ executable = 'timeout';
596
+ }
597
+
598
+ return await new Promise<ICommandResult>((resolve) => {
599
+ const child = plugins.spawn(executable, finalArgs, {
600
+ env: {
601
+ ...process.env,
602
+ ...env,
603
+ },
604
+ stdio: ['ignore', 'pipe', 'pipe'],
605
+ });
606
+ const stdout = createCaptureState();
607
+ const stderr = createCaptureState();
608
+
609
+ child.stdout.on('data', (chunk: Uint8Array) => captureOutputChunk(stdout, chunk));
610
+ child.stderr.on('data', (chunk: Uint8Array) => captureOutputChunk(stderr, chunk));
611
+ child.on('error', (error) => {
612
+ resolve({
613
+ code: 127,
614
+ stdout: '',
615
+ stderr: error instanceof Error ? error.message : String(error),
616
+ });
617
+ });
618
+ child.on('close', (code) => {
619
+ resolve(commandResultFromCapture(code ?? 1, stdout, stderr));
620
+ });
621
+ });
622
+ }
623
+
624
+ async function initContext(): Promise<IRuntimeContext> {
625
+ const currentUid = (await runBasic('id', ['-u'])).stdout.trim();
626
+ const currentUser = (await runBasic('id', ['-un'])).stdout.trim() || process.env.USER || 'unknown';
627
+ const sudoUser = process.env.SUDO_USER;
628
+ const targetUser = sudoUser && sudoUser !== 'root' ? sudoUser : currentUser;
629
+ const targetUid = (await runBasic('id', ['-u', targetUser])).stdout.trim() || currentUid;
630
+ const passwdLine = (await runBasic('getent', ['passwd', targetUser])).stdout.trim();
631
+ const passwdHome = passwdLine.split(':')[5];
632
+ const targetHome = passwdHome || process.env.HOME || `/home/${targetUser}`;
633
+ const targetRuntimeDir = process.env.XDG_RUNTIME_DIR || `/run/user/${targetUid}`;
634
+
635
+ return {
636
+ runningAsRoot: currentUid === '0',
637
+ sudoAvailable: currentUid === '0',
638
+ sudoChecked: currentUid === '0',
639
+ targetUser,
640
+ targetUid,
641
+ targetHome,
642
+ targetRuntimeDir,
643
+ dockerTimeoutSeconds: parsePositiveInteger(process.env.DOCKER_TIMEOUT, 60),
644
+ duTimeoutSeconds: parsePositiveInteger(process.env.DU_TIMEOUT, 300),
645
+ ncduTimeoutSeconds: parsePositiveInteger(process.env.NCDU_TIMEOUT, 900),
646
+ };
647
+ }
648
+
649
+ async function ensureSudo(ctx: IRuntimeContext): Promise<boolean> {
650
+ if (ctx.runningAsRoot) {
651
+ ctx.sudoAvailable = true;
652
+ ctx.sudoChecked = true;
653
+ return true;
654
+ }
655
+
656
+ if (ctx.sudoChecked) {
657
+ return ctx.sudoAvailable;
658
+ }
659
+
660
+ section('Privilege Check');
661
+ console.log('Root filesystem checks require sudo. Requesting sudo credentials now.');
662
+ const code = await runInteractive('sudo', ['-v'], 60);
663
+ ctx.sudoChecked = true;
664
+ ctx.sudoAvailable = code === 0;
665
+
666
+ if (!ctx.sudoAvailable) {
667
+ console.log('sudo authentication failed or was cancelled; root-only checks will be skipped.');
668
+ }
669
+
670
+ return ctx.sudoAvailable;
671
+ }
672
+
673
+ async function pathExists(path: string): Promise<boolean> {
674
+ try {
675
+ await plugins.fs.stat(path);
676
+ return true;
677
+ } catch {
678
+ return false;
679
+ }
680
+ }
681
+
682
+ async function readMarkerInDirectory(pathArg: string): Promise<IToolCacheMarker | undefined> {
683
+ try {
684
+ const markerRaw = await plugins.fs.readFile(plugins.path.join(pathArg, toolCacheMarkerFile), 'utf8');
685
+ return parseToolCacheMarker(markerRaw);
686
+ } catch {
687
+ return undefined;
688
+ }
689
+ }
690
+
691
+ function shouldSkipWorkspaceDir(nameArg: string): boolean {
692
+ return nameArg === '.git'
693
+ || nameArg === 'node_modules'
694
+ || nameArg === 'dist_ts'
695
+ || nameArg === 'dist_ts_web'
696
+ || nameArg === 'coverage'
697
+ || nameArg === '.pnpm-store';
698
+ }
699
+
700
+ export async function collectToolCachePathsFromRoot(
701
+ rootArg: string,
702
+ optionsArg: IWorkspaceScanOptions,
703
+ ): Promise<IToolCacheFinding[]> {
704
+ const findings = new Map<string, IToolCacheFinding>();
705
+ const startedAt = Date.now();
706
+ let visitedEntries = 0;
707
+ let rootDev: number | undefined;
708
+
709
+ try {
710
+ rootDev = (await plugins.fs.stat(rootArg)).dev;
711
+ } catch {
712
+ return [];
713
+ }
714
+
715
+ const visit = async (dirArg: string, depthArg: number): Promise<void> => {
716
+ if (Date.now() - startedAt > optionsArg.timeoutMs || visitedEntries > optionsArg.maxEntries) {
717
+ return;
718
+ }
719
+
720
+ let entries;
721
+ try {
722
+ entries = await plugins.fs.readdir(dirArg, { withFileTypes: true });
723
+ } catch {
724
+ return;
725
+ }
726
+
727
+ const marker = await readMarkerInDirectory(dirArg);
728
+ const markerClassification = classifyToolCachePath(dirArg, marker);
729
+ if (markerClassification) {
730
+ findings.set(plugins.path.resolve(dirArg), {
731
+ path: dirArg,
732
+ ...markerClassification,
733
+ });
734
+ }
735
+
736
+ const heuristicClassification = classifyToolCachePath(dirArg);
737
+ if (heuristicClassification) {
738
+ if (!markerClassification) {
739
+ findings.set(plugins.path.resolve(dirArg), {
740
+ path: dirArg,
741
+ ...heuristicClassification,
742
+ });
743
+ }
744
+ if (heuristicClassification.kind === 'docker-registry-cache') {
745
+ for (const entry of entries) {
746
+ if (Date.now() - startedAt > optionsArg.timeoutMs || visitedEntries > optionsArg.maxEntries) {
747
+ return;
748
+ }
749
+ visitedEntries++;
750
+ if (!entry.isDirectory()) {
751
+ continue;
752
+ }
753
+ const entryPath = plugins.path.join(dirArg, entry.name);
754
+ const entryMarker = await readMarkerInDirectory(entryPath);
755
+ const entryMarkerClassification = classifyToolCachePath(entryPath, entryMarker);
756
+ if (entryMarkerClassification) {
757
+ findings.set(plugins.path.resolve(entryPath), {
758
+ path: entryPath,
759
+ ...entryMarkerClassification,
760
+ });
761
+ }
762
+ }
763
+ }
764
+ return;
765
+ }
766
+
767
+ if (depthArg >= optionsArg.maxDepth) {
768
+ return;
769
+ }
770
+
771
+ for (const entry of entries) {
772
+ if (Date.now() - startedAt > optionsArg.timeoutMs || visitedEntries > optionsArg.maxEntries) {
773
+ return;
774
+ }
775
+ visitedEntries++;
776
+ if (!entry.isDirectory() || shouldSkipWorkspaceDir(entry.name)) {
777
+ continue;
778
+ }
779
+ const entryPath = plugins.path.join(dirArg, entry.name);
780
+ let stat;
781
+ try {
782
+ stat = await plugins.fs.stat(entryPath);
783
+ } catch {
784
+ continue;
785
+ }
786
+ if (rootDev !== undefined && stat.dev !== rootDev) {
787
+ continue;
788
+ }
789
+ await visit(entryPath, depthArg + 1);
790
+ }
791
+ };
792
+
793
+ await visit(rootArg, 0);
794
+ return [...findings.values()];
795
+ }
796
+
797
+ async function getWorkspaceRoots(): Promise<string[]> {
798
+ const roots = new Set<string>();
799
+ if (await pathExists('/mnt/data')) {
800
+ roots.add('/mnt/data');
801
+ }
802
+ const cwd = process.cwd();
803
+ if (cwd && cwd !== '/') {
804
+ roots.add(cwd);
805
+ }
806
+ return [...roots];
807
+ }
808
+
809
+ export async function collectProjectToolCacheDiagnostics(ctx: IRuntimeContext): Promise<IToolCacheFinding[]> {
810
+ const roots = await getWorkspaceRoots();
811
+ const findings: IToolCacheFinding[] = [];
812
+ for (const root of roots) {
813
+ const rootFindings = await collectToolCachePathsFromRoot(root, {
814
+ maxDepth: 8,
815
+ timeoutMs: 45_000,
816
+ maxEntries: 50_000,
817
+ });
818
+ findings.push(...rootFindings);
819
+ }
820
+
821
+ const uniqueFindings = [...new Map(findings.map((finding) => [plugins.path.resolve(finding.path), finding])).values()];
822
+ const sizedFindings: IToolCacheFinding[] = [];
823
+ const sizingStartedAt = Date.now();
824
+ for (const [index, finding] of uniqueFindings.entries()) {
825
+ if (index >= toolCacheSizingLimit || Date.now() - sizingStartedAt > toolCacheSizingTimeoutMs) {
826
+ sizedFindings.push(finding);
827
+ continue;
828
+ }
829
+ const size = await readDuTotal(ctx, finding.path, ctx.sudoAvailable);
830
+ sizedFindings.push({
831
+ ...finding,
832
+ bytes: size?.bytes,
833
+ });
834
+ }
835
+
836
+ return sizedFindings.sort((a, b) => (b.bytes ?? 0) - (a.bytes ?? 0));
837
+ }
838
+
839
+ async function printProjectToolCacheDiagnostics(ctx: IRuntimeContext) {
840
+ section('Project Tool Cache Diagnostics');
841
+ const roots = await getWorkspaceRoots();
842
+ if (roots.length === 0) {
843
+ console.log('No workspace roots found.');
844
+ return;
845
+ }
846
+ progress(`Scanning workspace roots: ${roots.join(', ')}`);
847
+ const findings = await collectProjectToolCacheDiagnostics(ctx);
848
+ if (findings.length === 0) {
849
+ console.log('No known project-local tool caches found.');
850
+ return;
851
+ }
852
+
853
+ for (const finding of findings) {
854
+ printSizeLine(`${finding.ownerGuess} ${finding.kind} [${finding.risk}] ${finding.path}`, finding.bytes ?? 0);
855
+ console.log(` suggested: ${finding.suggestedCleanupCommand}`);
856
+ }
857
+ }
858
+
859
+ async function pathExistsForDu(ctx: IRuntimeContext, path: string, asRoot: boolean): Promise<boolean> {
860
+ if (!asRoot) {
861
+ return await pathExists(path);
862
+ }
863
+
864
+ const result = await runCommand(ctx, 'test', ['-e', path], {
865
+ asRoot: true,
866
+ timeoutSeconds: 30,
867
+ });
868
+
869
+ return result.code === 0;
870
+ }
871
+
872
+ async function printDiskOverview(ctx: IRuntimeContext) {
873
+ section('Disk Usage Overview');
874
+ progress('Reading filesystem usage with df.');
875
+ const paths = ['/'];
876
+
877
+ if (await pathExists('/mnt/data')) {
878
+ paths.push('/mnt/data');
879
+ }
880
+
881
+ const result = await runCommand(ctx, 'df', ['-B1', '-T', ...paths], { timeoutSeconds: 30 });
882
+ if (result.stdout.trim()) {
883
+ const lines = result.stdout.trim().split(/\r?\n/).slice(1);
884
+ for (const line of lines) {
885
+ const parts = line.trim().split(/\s+/);
886
+ if (parts.length < 7) {
887
+ continue;
888
+ }
889
+
890
+ const [filesystem, type, size, used, available, usePercent, mount] = parts;
891
+ console.log(
892
+ `${mount.padEnd(12)} ${formatBytes(Number(used))} used / ${formatBytes(Number(size))} (${usePercent} full), ${formatBytes(Number(available))} available, ${type}, ${filesystem}`,
893
+ );
894
+ }
895
+ } else {
896
+ console.log(result.stderr.trim() || 'df output unavailable');
897
+ }
898
+
899
+ console.log(`Inspecting user: ${ctx.targetUser}`);
900
+ console.log(`Home: ${ctx.targetHome}`);
901
+ console.log(`Docker timeout: ${ctx.dockerTimeoutSeconds}s`);
902
+ console.log(`du timeout: ${ctx.duTimeoutSeconds}s`);
903
+ console.log(`ncdu timeout: ${ctx.ncduTimeoutSeconds}s`);
904
+ }
905
+
906
+ async function readDuTree(
907
+ ctx: IRuntimeContext,
908
+ path: string,
909
+ depth: number,
910
+ asRoot: boolean,
911
+ ): Promise<{ entries: IDuEntry[]; result: ICommandResult }> {
912
+ const result = await runCommand(ctx, 'du', ['-xB1', '-d', String(depth), path], {
913
+ asRoot,
914
+ timeoutSeconds: ctx.duTimeoutSeconds,
915
+ });
916
+
917
+ return {
918
+ entries: parseDuOutput(result.stdout),
919
+ result,
920
+ };
921
+ }
922
+
923
+ async function readDuTotal(ctx: IRuntimeContext, path: string, asRoot: boolean): Promise<IDuEntry | undefined> {
924
+ const result = await runCommand(ctx, 'du', ['-sxB1', path], {
925
+ asRoot,
926
+ timeoutSeconds: ctx.duTimeoutSeconds,
927
+ });
928
+
929
+ return parseDuOutput(result.stdout)[0];
930
+ }
931
+
932
+ async function printDuTree(ctx: IRuntimeContext, title: string, path: string, asRoot = true) {
933
+ section(title);
934
+ progress(`Scanning ${path} with du.`);
935
+ const { entries, result } = await readDuTree(ctx, path, 1, asRoot);
936
+
937
+ if (!entries.length) {
938
+ console.log(result.stderr.trim() || `${path} unavailable`);
939
+ return;
940
+ }
941
+
942
+ const total = entries.find((entry) => entry.path === path);
943
+ const children = entries.filter((entry) => entry.path !== path).sort((a, b) => b.bytes - a.bytes);
944
+
945
+ if (total) {
946
+ printSizeLine(`${path} total`, total.bytes);
947
+ }
948
+
949
+ for (const entry of children) {
950
+ printSizeLine(entry.path, entry.bytes);
951
+ }
952
+
953
+ if (result.code === 124) {
954
+ console.log(`Timed out after ${ctx.duTimeoutSeconds}s; results may be incomplete.`);
955
+ } else if (result.code !== 0 && result.stderr.trim()) {
956
+ console.log('Partial du warnings:');
957
+ console.log(firstLines(result.stderr));
958
+ }
959
+ }
960
+
961
+ async function printKnownSizes(
962
+ ctx: IRuntimeContext,
963
+ title: string,
964
+ rows: Array<{ label: string; path: string; asRoot?: boolean }>,
965
+ ) {
966
+ section(title);
967
+ progress(`Checking ${rows.length} known paths.`);
968
+ const sizes: Array<{ label: string; path: string; asRoot?: boolean; bytes?: number }> = [];
969
+ for (const row of rows) {
970
+ const entry = await readDuTotal(ctx, row.path, row.asRoot ?? true);
971
+ sizes.push({ ...row, bytes: entry?.bytes });
972
+ }
973
+
974
+ const visibleSizes = sizes.filter((row) => typeof row.bytes === 'number').sort((a, b) => {
975
+ return (b.bytes ?? 0) - (a.bytes ?? 0);
976
+ });
977
+
978
+ if (!visibleSizes.length) {
979
+ console.log('No matching paths found.');
980
+ return;
981
+ }
982
+
983
+ for (const row of visibleSizes) {
984
+ printSizeLine(`${row.label} (${row.path})`, row.bytes ?? 0);
985
+ }
986
+ }
987
+
988
+ async function printConfiguredToolPaths(ctx: IRuntimeContext) {
989
+ section('Configured Package Cache Paths');
990
+ progress('Querying package manager cache configuration.');
991
+
992
+ const commands = [
993
+ { label: 'pnpm store path', command: 'pnpm', args: ['store', 'path'] },
994
+ { label: 'npm cache path', command: 'npm', args: ['config', 'get', 'cache'] },
995
+ { label: 'yarn cache path', command: 'yarn', args: ['cache', 'dir'] },
996
+ ];
997
+
998
+ let printed = false;
999
+ for (const item of commands) {
1000
+ const result = await runCommand(ctx, item.command, item.args, {
1001
+ asTargetUser: true,
1002
+ timeoutSeconds: 30,
1003
+ });
1004
+ const configuredPath = result.stdout.trim().split(/\r?\n/).at(-1)?.trim();
1005
+
1006
+ if (!configuredPath || result.code !== 0) {
1007
+ continue;
1008
+ }
1009
+
1010
+ const entry = await readDuTotal(ctx, configuredPath, ctx.sudoAvailable);
1011
+ if (entry) {
1012
+ printSizeLine(`${item.label} (${configuredPath})`, entry.bytes);
1013
+ } else {
1014
+ console.log(`${item.label}: ${configuredPath}`);
1015
+ }
1016
+ printed = true;
1017
+ }
1018
+
1019
+ if (!printed) {
1020
+ console.log('No pnpm/npm/yarn cache paths could be resolved from installed commands.');
1021
+ }
1022
+ }
1023
+
1024
+ async function printDeletedOpenFiles(ctx: IRuntimeContext) {
1025
+ section('Deleted Open Files');
1026
+ progress('Checking deleted-but-open files with lsof.');
1027
+ const result = await runCommand(ctx, 'lsof', ['-nP', '+L1'], {
1028
+ asRoot: true,
1029
+ timeoutSeconds: 60,
1030
+ });
1031
+
1032
+ if (result.code === 127) {
1033
+ console.log('lsof is not installed. This check finds deleted files that still consume disk space.');
1034
+ return;
1035
+ }
1036
+
1037
+ if (!result.stdout.trim()) {
1038
+ console.log(result.stderr.trim() || 'No deleted open files reported.');
1039
+ return;
1040
+ }
1041
+
1042
+ const lines = result.stdout.trimEnd().split(/\r?\n/);
1043
+ const maxLines = 40;
1044
+ console.log(lines.slice(0, maxLines).join('\n'));
1045
+ if (lines.length > maxLines) {
1046
+ console.log(`... ${lines.length - maxLines} more lines omitted. Run: sudo lsof -nP +L1`);
1047
+ }
1048
+ }
1049
+
1050
+ async function printJournalUsage(ctx: IRuntimeContext) {
1051
+ section('Journal Usage');
1052
+ progress('Checking systemd journal disk usage.');
1053
+ const result = await runCommand(ctx, 'journalctl', ['--disk-usage'], {
1054
+ asRoot: true,
1055
+ timeoutSeconds: 30,
1056
+ });
1057
+
1058
+ if (result.code === 127) {
1059
+ console.log('journalctl is not installed.');
1060
+ return;
1061
+ }
1062
+
1063
+ console.log((result.stdout.trim() || result.stderr.trim() || 'journalctl returned no output').trim());
1064
+ }
1065
+
1066
+ function ncduPath(parentPath: string, name: string): string {
1067
+ if (name.startsWith('/')) {
1068
+ return name;
1069
+ }
1070
+
1071
+ if (!parentPath || parentPath === '/') {
1072
+ return `/${name}`;
1073
+ }
1074
+
1075
+ return `${parentPath}/${name}`;
1076
+ }
1077
+
1078
+ function addNcduInsight(insights: INcduInsight[], insight: INcduInsight) {
1079
+ if (insight.path === '/' || insight.bytes <= 0) {
1080
+ return;
1081
+ }
1082
+
1083
+ insights.push(insight);
1084
+ insights.sort((a, b) => b.bytes - a.bytes);
1085
+ if (insights.length > ncduInsightLimit) {
1086
+ insights.length = ncduInsightLimit;
1087
+ }
1088
+ }
1089
+
1090
+ async function parseNcduInfo(reader: JsonStreamReader): Promise<{
1091
+ dsize: number;
1092
+ excluded?: string;
1093
+ name: string;
1094
+ readError: boolean;
1095
+ }> {
1096
+ let dsize = 0;
1097
+ let excluded: string | undefined;
1098
+ let name = 'unknown';
1099
+ let readError = false;
1100
+
1101
+ await reader.expect('{');
1102
+ await reader.skipWhitespace();
1103
+ if ((await reader.peek()) === '}') {
1104
+ await reader.next();
1105
+ return { dsize, excluded, name, readError };
1106
+ }
1107
+
1108
+ while (true) {
1109
+ const key = await reader.readString();
1110
+ await reader.expect(':');
1111
+ await reader.skipWhitespace();
1112
+
1113
+ if (key === 'name') {
1114
+ name = await reader.readString();
1115
+ } else if (key === 'dsize') {
1116
+ dsize = await reader.readNumber();
1117
+ } else if (key === 'read_error') {
1118
+ const nextToken = await reader.peek();
1119
+ if (nextToken === 't') {
1120
+ await reader.readLiteral('true');
1121
+ readError = true;
1122
+ } else if (nextToken === 'f') {
1123
+ await reader.readLiteral('false');
1124
+ readError = false;
1125
+ } else {
1126
+ await reader.skipValue();
1127
+ }
1128
+ } else if (key === 'excluded') {
1129
+ excluded = await reader.readString();
1130
+ } else {
1131
+ await reader.skipValue();
1132
+ }
1133
+
1134
+ await reader.skipWhitespace();
1135
+ const nextToken = await reader.next();
1136
+ if (nextToken === '}') {
1137
+ return { dsize, excluded, name, readError };
1138
+ }
1139
+ if (nextToken !== ',') {
1140
+ throw new Error(`Expected JSON object separator, got ${nextToken ?? 'EOF'}.`);
1141
+ }
1142
+ }
1143
+ }
1144
+
1145
+ async function parseNcduNode(
1146
+ reader: JsonStreamReader,
1147
+ parentPath: string,
1148
+ insights: INcduInsight[],
1149
+ ): Promise<number> {
1150
+ await reader.skipWhitespace();
1151
+ const nextToken = await reader.peek();
1152
+
1153
+ if (nextToken === '[') {
1154
+ await reader.expect('[');
1155
+ const info = await parseNcduInfo(reader);
1156
+ const path = ncduPath(parentPath, info.name);
1157
+ let bytes = info.dsize;
1158
+
1159
+ await reader.skipWhitespace();
1160
+ while ((await reader.peek()) === ',') {
1161
+ await reader.next();
1162
+ bytes += await parseNcduNode(reader, path, insights);
1163
+ await reader.skipWhitespace();
1164
+ }
1165
+
1166
+ await reader.expect(']');
1167
+ addNcduInsight(insights, {
1168
+ bytes,
1169
+ path,
1170
+ type: 'directory',
1171
+ readError: info.readError,
1172
+ excluded: info.excluded,
1173
+ });
1174
+
1175
+ return bytes;
1176
+ }
1177
+
1178
+ if (nextToken === '{') {
1179
+ const info = await parseNcduInfo(reader);
1180
+ const path = ncduPath(parentPath, info.name);
1181
+ addNcduInsight(insights, {
1182
+ bytes: info.dsize,
1183
+ path,
1184
+ type: 'file',
1185
+ readError: info.readError,
1186
+ excluded: info.excluded,
1187
+ });
1188
+
1189
+ return info.dsize;
1190
+ }
1191
+
1192
+ throw new Error(`Expected ncdu node, got ${nextToken ?? 'EOF'}.`);
1193
+ }
1194
+
1195
+ async function parseNcduExportFile(exportPath: string): Promise<INcduParseResult> {
1196
+ const stat = await plugins.fs.stat(exportPath);
1197
+ const reader = new JsonStreamReader(exportPath, stat.size);
1198
+ await reader.open();
1199
+
1200
+ try {
1201
+ const insights: INcduInsight[] = [];
1202
+ await reader.expect('[');
1203
+ await reader.skipValue();
1204
+ await reader.expect(',');
1205
+ await reader.skipValue();
1206
+ await reader.expect(',');
1207
+ await reader.skipValue();
1208
+ await reader.expect(',');
1209
+ const rootBytes = await parseNcduNode(reader, '', insights);
1210
+
1211
+ return {
1212
+ rootBytes,
1213
+ insights: insights.sort((a, b) => b.bytes - a.bytes),
1214
+ };
1215
+ } finally {
1216
+ reader.close();
1217
+ }
1218
+ }
1219
+
1220
+ async function cleanupNcduExport(ctx: IRuntimeContext, exportPath: string) {
1221
+ if (isTruthy(process.env.TSDISK_KEEP_NCDU_EXPORT)) {
1222
+ await runCommand(ctx, 'chown', [ctx.targetUser, exportPath], {
1223
+ asRoot: true,
1224
+ timeoutSeconds: 30,
1225
+ });
1226
+ console.log(`ncdu export file kept: ${exportPath}`);
1227
+ console.log(`Browse it later with: ncdu -f ${exportPath}`);
1228
+ return;
1229
+ }
1230
+
1231
+ progress(`Removing temporary ncdu export ${exportPath}.`);
1232
+ const result = await runCommand(ctx, 'rm', ['-f', exportPath], {
1233
+ asRoot: true,
1234
+ timeoutSeconds: 30,
1235
+ });
1236
+
1237
+ if (result.code !== 0) {
1238
+ console.log(result.stderr.trim() || `Could not remove ${exportPath}`);
1239
+ }
1240
+ }
1241
+
1242
+ async function ensureNcduAvailable(ctx: IRuntimeContext): Promise<boolean> {
1243
+ if (await commandExists('ncdu')) {
1244
+ return true;
1245
+ }
1246
+
1247
+ console.log('ncdu is not installed.');
1248
+ if (!(await commandExists('apt-get'))) {
1249
+ console.log('apt-get is not available, so this script cannot install ncdu automatically.');
1250
+ return false;
1251
+ }
1252
+
1253
+ const envAnswer = process.env.TSDISK_INSTALL_NCDU?.toLowerCase();
1254
+ const installRequested = isTruthy(envAnswer)
1255
+ ? true
1256
+ : await promptYesNo('Install ncdu now using sudo apt-get?', false);
1257
+
1258
+ if (!installRequested) {
1259
+ return false;
1260
+ }
1261
+
1262
+ progress('Updating apt package lists before installing ncdu.');
1263
+ const updateCode = await runRootInteractive(ctx, 'apt-get', ['update'], ctx.ncduTimeoutSeconds);
1264
+ if (updateCode !== 0) {
1265
+ console.log(`apt-get update failed with exit code ${updateCode}.`);
1266
+ return false;
1267
+ }
1268
+
1269
+ progress('Installing ncdu with apt-get.');
1270
+ const installCode = await runRootInteractive(ctx, 'apt-get', ['install', '-y', 'ncdu'], ctx.ncduTimeoutSeconds);
1271
+ if (installCode !== 0) {
1272
+ console.log(`apt-get install ncdu failed with exit code ${installCode}.`);
1273
+ return false;
1274
+ }
1275
+
1276
+ return await commandExists('ncdu');
1277
+ }
1278
+
1279
+ async function runNcduExport(ctx: IRuntimeContext, exportPath: string, useIgnoreConfig: boolean): Promise<number> {
1280
+ const args = useIgnoreConfig
1281
+ ? ['--ignore-config', '-1', '-x', '-o', exportPath, '/']
1282
+ : ['-1', '-x', '-o', exportPath, '/'];
1283
+
1284
+ return await runRootInteractive(ctx, 'ncdu', args, ctx.ncduTimeoutSeconds);
1285
+ }
1286
+
1287
+ async function printNcduInsights(ctx: IRuntimeContext) {
1288
+ section('ncdu Insights');
1289
+ if (!(await ensureNcduAvailable(ctx))) {
1290
+ console.log('Skipping ncdu export scan.');
1291
+ return;
1292
+ }
1293
+
1294
+ const exportPath = process.env.TSDISK_NCDU_EXPORT || `/tmp/tsdisk-ncdu-root-${Date.now()}.json`;
1295
+ progress(`Running ncdu export scan of / into ${exportPath}.`);
1296
+ let exitCode = await runNcduExport(ctx, exportPath, true);
1297
+
1298
+ if (exitCode !== 0 && !(await pathExists(exportPath))) {
1299
+ progress('Retrying ncdu export without --ignore-config for older ncdu versions.');
1300
+ exitCode = await runNcduExport(ctx, exportPath, false);
1301
+ }
1302
+
1303
+ if (exitCode !== 0 && !(await pathExists(exportPath))) {
1304
+ console.log(`ncdu export failed with exit code ${exitCode}.`);
1305
+ return;
1306
+ }
1307
+
1308
+ progress('Parsing ncdu export for largest paths.');
1309
+ let parseResult: INcduParseResult;
1310
+ try {
1311
+ parseResult = await parseNcduExportFile(exportPath);
1312
+ } catch (error) {
1313
+ console.log(error instanceof Error ? error.message : String(error));
1314
+ await cleanupNcduExport(ctx, exportPath);
1315
+ return;
1316
+ }
1317
+
1318
+ printSizeLine('ncdu scanned root total', parseResult.rootBytes);
1319
+
1320
+ const largest = parseResult.insights.filter((entry) => entry.path !== '/' && entry.bytes > 0).slice(0, ncduInsightLimit);
1321
+ if (!largest.length) {
1322
+ console.log('No non-root ncdu entries found.');
1323
+ } else {
1324
+ console.log('Largest paths from ncdu export:');
1325
+ for (const entry of largest) {
1326
+ const suffix = [entry.type, entry.readError ? 'read_error' : '', entry.excluded ? `excluded:${entry.excluded}` : '']
1327
+ .filter(Boolean)
1328
+ .join(', ');
1329
+ printSizeLine(`${entry.path}${suffix ? ` (${suffix})` : ''}`, entry.bytes);
1330
+ }
1331
+ }
1332
+
1333
+ await cleanupNcduExport(ctx, exportPath);
1334
+ }
1335
+
1336
+ async function printSpecializedToolHints() {
1337
+ section('Specialized Disk Tools');
1338
+ const tools = ['ncdu', 'gdu', 'dua', 'dust'];
1339
+ const available: string[] = [];
1340
+
1341
+ for (const tool of tools) {
1342
+ if (await commandExists(tool)) {
1343
+ available.push(tool);
1344
+ }
1345
+ }
1346
+
1347
+ if (available.length) {
1348
+ console.log(`Installed scanner tools: ${available.join(', ')}`);
1349
+ } else {
1350
+ console.log('No ncdu/gdu/dua/dust scanner is currently installed.');
1351
+ }
1352
+
1353
+ console.log('Best interactive scanner: sudo ncdu -x /');
1354
+ console.log('Exportable scan: sudo ncdu -x -o /tmp/root-ncdu.json / && ncdu -f /tmp/root-ncdu.json');
1355
+ console.log('df/du mismatch check: sudo lsof -nP +L1');
1356
+ }
1357
+
1358
+ async function runDocker(ctx: IRuntimeContext, args: string[], timeoutSeconds = ctx.dockerTimeoutSeconds) {
1359
+ return await runCommand(ctx, 'docker', args, {
1360
+ asTargetUser: true,
1361
+ timeoutSeconds,
1362
+ });
1363
+ }
1364
+
1365
+ function parseDockerInspect(stdoutArg: string): any[] {
1366
+ if (!stdoutArg.trim()) {
1367
+ return [];
1368
+ }
1369
+ try {
1370
+ const parsed = JSON.parse(stdoutArg);
1371
+ return Array.isArray(parsed) ? parsed : [];
1372
+ } catch {
1373
+ return [];
1374
+ }
1375
+ }
1376
+
1377
+ async function inspectDockerObjects(ctx: IRuntimeContext, dockerContext: string, idsArg: string[]): Promise<any[]> {
1378
+ if (idsArg.length === 0) {
1379
+ return [];
1380
+ }
1381
+ const result = await runDocker(ctx, ['--context', dockerContext, 'inspect', ...idsArg], 30);
1382
+ return parseDockerInspect(result.stdout);
1383
+ }
1384
+
1385
+ async function printDockerTsdockerRegistryContainers(ctx: IRuntimeContext, dockerContext: string) {
1386
+ const result = await runDocker(ctx, [
1387
+ '--context',
1388
+ dockerContext,
1389
+ 'ps',
1390
+ '-a',
1391
+ '--filter',
1392
+ 'name=tsdocker-registry-',
1393
+ '--format',
1394
+ '{{.ID}}',
1395
+ ], 30);
1396
+ const ids = result.stdout.trim().split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
1397
+ const containers = await inspectDockerObjects(ctx, dockerContext, ids);
1398
+ const stopped = containers.filter((container) => container.State?.Status !== 'running');
1399
+ if (stopped.length === 0) {
1400
+ return;
1401
+ }
1402
+
1403
+ subsection(`Stopped tsdocker registry containers: ${dockerContext}`);
1404
+ for (const container of stopped) {
1405
+ const name = String(container.Name || '').replace(/^\//, '');
1406
+ const labels = container.Config?.Labels || {};
1407
+ const cachePath = labels['git.zone.cache-path'] || 'unlabeled cache path';
1408
+ const mounts = (container.Mounts || []).map((mount: any) => mount.Source).filter(Boolean).join(', ');
1409
+ console.log(`${container.Id.slice(0, 12)} ${name} ${container.State?.Status} cache=${cachePath} mounts=${mounts || 'none'}`);
1410
+ console.log(' suggested: tsdocker prune --apply (labeled containers only)');
1411
+ }
1412
+ }
1413
+
1414
+ async function printDockerBuildxZeroLinkVolumes(ctx: IRuntimeContext, dockerContext: string) {
1415
+ const volumeList = await runDocker(ctx, ['--context', dockerContext, 'volume', 'ls', '--format', '{{.Name}}'], 30);
1416
+ const names = volumeList.stdout.trim().split(/\r?\n/).map((line) => line.trim()).filter((name) => {
1417
+ return /^buildx_buildkit_.+_state$/.test(name) || name.includes('buildkit');
1418
+ });
1419
+ if (names.length === 0) {
1420
+ return;
1421
+ }
1422
+ const volumes = await inspectDockerObjects(ctx, dockerContext, names);
1423
+ const zeroLinkVolumes = volumes.filter((volume) => volume.UsageData?.RefCount === 0);
1424
+ if (zeroLinkVolumes.length === 0) {
1425
+ return;
1426
+ }
1427
+
1428
+ subsection(`Docker buildx zero-link volumes: ${dockerContext}`);
1429
+ for (const volume of zeroLinkVolumes) {
1430
+ console.log(`${volume.Name} mountpoint=${volume.Mountpoint || 'unknown'} refCount=0`);
1431
+ console.log(' suggested: report-only; review builder state before docker volume rm');
1432
+ }
1433
+ }
1434
+
1435
+ async function getDockerContexts(ctx: IRuntimeContext): Promise<string[]> {
1436
+ const result = await runDocker(ctx, ['context', 'ls', '--format', '{{.Name}}'], 30);
1437
+ return result.stdout.trim().split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
1438
+ }
1439
+
1440
+ async function getDockerEndpoint(ctx: IRuntimeContext, dockerContext: string): Promise<string> {
1441
+ const jsonResult = await runDocker(ctx, [
1442
+ 'context',
1443
+ 'inspect',
1444
+ dockerContext,
1445
+ '--format',
1446
+ '{{json .Endpoints.docker.Host}}',
1447
+ ], 30);
1448
+ const raw = jsonResult.stdout.trim();
1449
+
1450
+ if (raw) {
1451
+ try {
1452
+ const parsed = JSON.parse(raw);
1453
+ if (typeof parsed === 'string') {
1454
+ return parsed;
1455
+ }
1456
+ } catch {
1457
+ return raw;
1458
+ }
1459
+ }
1460
+
1461
+ const plainResult = await runDocker(ctx, [
1462
+ 'context',
1463
+ 'inspect',
1464
+ dockerContext,
1465
+ '--format',
1466
+ '{{.Endpoints.docker.Host}}',
1467
+ ], 30);
1468
+ return plainResult.stdout.trim();
1469
+ }
1470
+
1471
+ async function printDockerContextDetails(ctx: IRuntimeContext, dockerContext: string) {
1472
+ subsection(`Docker context: ${dockerContext}`);
1473
+ progress(`Inspecting Docker context ${dockerContext}.`);
1474
+
1475
+ const endpoint = await getDockerEndpoint(ctx, dockerContext);
1476
+ if (endpoint) {
1477
+ console.log(`Endpoint: ${endpoint}`);
1478
+ }
1479
+
1480
+ const info = await runDocker(ctx, ['--context', dockerContext, 'info', '--format', '{{.DockerRootDir}}']);
1481
+ const dockerRoot = info.stdout.trim();
1482
+
1483
+ if (!dockerRoot) {
1484
+ console.log(info.stderr.trim() || `DockerRootDir unavailable or timed out after ${ctx.dockerTimeoutSeconds}s`);
1485
+ return;
1486
+ }
1487
+
1488
+ console.log(`DockerRootDir: ${dockerRoot}`);
1489
+
1490
+ const systemDf = await runDocker(ctx, ['--context', dockerContext, 'system', 'df']);
1491
+ if (systemDf.stdout.trim()) {
1492
+ console.log(systemDf.stdout.trimEnd());
1493
+ } else {
1494
+ console.log(systemDf.stderr.trim() || `docker system df unavailable or timed out after ${ctx.dockerTimeoutSeconds}s`);
1495
+ }
1496
+
1497
+ await printDockerTsdockerRegistryContainers(ctx, dockerContext);
1498
+ await printDockerBuildxZeroLinkVolumes(ctx, dockerContext);
1499
+
1500
+ const isLocalEndpoint = !endpoint || endpoint.startsWith('unix://');
1501
+ if (!isLocalEndpoint) {
1502
+ console.log('Skipping local du for this context because the Docker endpoint is not local.');
1503
+ return;
1504
+ }
1505
+
1506
+ const duAsRoot = ctx.sudoAvailable;
1507
+
1508
+ if (!(await pathExistsForDu(ctx, dockerRoot, duAsRoot))) {
1509
+ console.log('Skipping local du because DockerRootDir does not exist on this filesystem.');
1510
+ return;
1511
+ }
1512
+
1513
+ const dockerRootTotal = await readDuTotal(ctx, dockerRoot, duAsRoot);
1514
+ if (dockerRootTotal) {
1515
+ printSizeLine('Docker root local size', dockerRootTotal.bytes);
1516
+ }
1517
+
1518
+ const { entries, result } = await readDuTree(ctx, dockerRoot, 1, duAsRoot);
1519
+ const children = entries.filter((entry) => entry.path !== dockerRoot).sort((a, b) => b.bytes - a.bytes);
1520
+
1521
+ if (children.length) {
1522
+ subsection(`Docker root breakdown: ${dockerContext}`);
1523
+ for (const entry of children) {
1524
+ printSizeLine(entry.path, entry.bytes);
1525
+ }
1526
+ }
1527
+
1528
+ if (result.code !== 0 && result.stderr.trim()) {
1529
+ console.log('Partial Docker root du warnings:');
1530
+ console.log(firstLines(result.stderr));
1531
+ }
1532
+
1533
+ const volumePath = `${dockerRoot}/volumes`;
1534
+ if (await pathExistsForDu(ctx, volumePath, duAsRoot)) {
1535
+ const volumeTree = await readDuTree(ctx, volumePath, 1, duAsRoot);
1536
+ const volumes = volumeTree.entries.filter((entry) => entry.path !== volumePath).sort((a, b) => b.bytes - a.bytes);
1537
+
1538
+ if (volumes.length) {
1539
+ subsection(`Docker volume sizes: ${dockerContext}`);
1540
+ for (const volume of volumes) {
1541
+ printSizeLine(volume.path, volume.bytes);
1542
+ }
1543
+ }
1544
+ }
1545
+
1546
+ const buildkitPath = `${dockerRoot}/buildkit`;
1547
+ if (await pathExistsForDu(ctx, buildkitPath, duAsRoot)) {
1548
+ const buildkitTree = await readDuTree(ctx, buildkitPath, 1, duAsRoot);
1549
+ const buildkitEntries = buildkitTree.entries.filter((entry) => entry.path !== buildkitPath).sort((a, b) => b.bytes - a.bytes);
1550
+
1551
+ if (buildkitEntries.length) {
1552
+ subsection(`Docker buildkit sizes: ${dockerContext}`);
1553
+ for (const entry of buildkitEntries) {
1554
+ printSizeLine(entry.path, entry.bytes);
1555
+ }
1556
+ }
1557
+ }
1558
+ }
1559
+
1560
+ async function printDockerContexts(ctx: IRuntimeContext) {
1561
+ section('Docker Contexts');
1562
+ progress('Listing Docker contexts and daemon usage.');
1563
+ const version = await runDocker(ctx, ['--version'], 15);
1564
+ if (version.code === 127) {
1565
+ console.log('docker command not found');
1566
+ return;
1567
+ }
1568
+
1569
+ const contextList = await runDocker(ctx, ['context', 'ls'], 30);
1570
+ if (contextList.stdout.trim()) {
1571
+ console.log(contextList.stdout.trimEnd());
1572
+ } else if (contextList.stderr.trim()) {
1573
+ console.log(contextList.stderr.trim());
1574
+ }
1575
+
1576
+ const contexts = await getDockerContexts(ctx);
1577
+ if (!contexts.length) {
1578
+ console.log(`No Docker contexts found for ${ctx.targetUser}.`);
1579
+ return;
1580
+ }
1581
+
1582
+ for (const dockerContext of contexts) {
1583
+ await printDockerContextDetails(ctx, dockerContext);
1584
+ }
1585
+ }
1586
+
1587
+ function printCleanupHints(ctx: IRuntimeContext) {
1588
+ section('Cleanup Commands To Consider');
1589
+ console.log('Review first. Docker volume prune can delete database or build data stored in volumes.');
1590
+ console.log('docker --context rootless system prune -a');
1591
+ console.log('docker --context rootless builder prune -a');
1592
+ console.log('docker --context rootless volume prune');
1593
+ console.log('docker --context default system prune -a');
1594
+ console.log('docker --context default builder prune -a');
1595
+ console.log('docker --context default volume prune');
1596
+ console.log(`rm -rf "${ctx.targetHome}/.cache/puppeteer" "${ctx.targetHome}/.cache/uv" "${ctx.targetHome}/.cache/deno" "${ctx.targetHome}/.cache/ms-playwright" "${ctx.targetHome}/.cache/yarn" "${ctx.targetHome}/.cache/pnpm" "${ctx.targetHome}/.cache/Cypress" "${ctx.targetHome}/.cache/mongodb-binaries"`);
1597
+ console.log('pnpm store prune');
1598
+ console.log('npm cache clean --force');
1599
+ console.log(`# Review before deleting VS Code data: "${ctx.targetHome}/.vscode-server" "${ctx.targetHome}/.vscode" "${ctx.targetHome}/.config/Code"`);
1600
+ }
1601
+
1602
+ function printHelp() {
1603
+ console.log(`@git.zone/tsdisk v${commitinfo.version}`);
1604
+ console.log('');
1605
+ console.log('Usage:');
1606
+ console.log(' tsdisk Run the disk usage diagnostic scan');
1607
+ console.log(' tsdisk --json Print project tool-cache diagnostics as JSON');
1608
+ console.log(' tsdisk --help Show this help');
1609
+ console.log(' tsdisk --version Show the version');
1610
+ console.log('');
1611
+ console.log('Environment:');
1612
+ console.log(' DOCKER_TIMEOUT=<s> Docker command timeout, default 60');
1613
+ console.log(' DU_TIMEOUT=<s> du command timeout, default 300');
1614
+ console.log(' NCDU_TIMEOUT=<s> ncdu export timeout, default 900');
1615
+ console.log(' TSDISK_NCDU_EXPORT=<path> ncdu export path, default /tmp/tsdisk-ncdu-root-*.json');
1616
+ console.log(' TSDISK_KEEP_NCDU_EXPORT=true Keep the ncdu export after parsing');
1617
+ console.log(' TSDISK_INSTALL_NCDU=true Install ncdu with apt-get if missing');
1618
+ }
1619
+
1620
+ export async function main() {
1621
+ const ctx = await initContext();
1622
+
1623
+ await printDiskOverview(ctx);
1624
+ await ensureSudo(ctx);
1625
+ await printNcduInsights(ctx);
1626
+
1627
+ await printDuTree(ctx, 'Top-Level Root Breakdown', '/', true);
1628
+ await printKnownSizes(ctx, 'Known Root Areas', [
1629
+ { label: 'home', path: '/home' },
1630
+ { label: 'var', path: '/var' },
1631
+ { label: 'usr', path: '/usr' },
1632
+ { label: 'opt', path: '/opt' },
1633
+ { label: 'root', path: '/root' },
1634
+ { label: 'target home', path: ctx.targetHome },
1635
+ ]);
1636
+
1637
+ await printDuTree(ctx, 'Target Home Breakdown', ctx.targetHome, true);
1638
+ await printDuTree(ctx, 'Target Local Share Breakdown', `${ctx.targetHome}/.local/share`, true);
1639
+ await printDuTree(ctx, 'Target Cache Breakdown', `${ctx.targetHome}/.cache`, true);
1640
+ await printConfiguredToolPaths(ctx);
1641
+ await printProjectToolCacheDiagnostics(ctx);
1642
+ await printKnownSizes(ctx, 'Known User Caches', [
1643
+ { label: 'rootless Docker data', path: `${ctx.targetHome}/.local/share/docker` },
1644
+ { label: 'pnpm store', path: `${ctx.targetHome}/.local/share/pnpm` },
1645
+ { label: 'pnpm cache', path: `${ctx.targetHome}/.cache/pnpm` },
1646
+ { label: 'legacy pnpm store', path: `${ctx.targetHome}/.pnpm-store` },
1647
+ { label: 'npm directory', path: `${ctx.targetHome}/.npm` },
1648
+ { label: 'npm content cache', path: `${ctx.targetHome}/.npm/_cacache` },
1649
+ { label: 'node-gyp cache', path: `${ctx.targetHome}/.cache/node-gyp` },
1650
+ { label: 'corepack cache', path: `${ctx.targetHome}/.cache/node/corepack` },
1651
+ { label: 'Puppeteer Chrome', path: `${ctx.targetHome}/.cache/puppeteer` },
1652
+ { label: 'Playwright browsers', path: `${ctx.targetHome}/.cache/ms-playwright` },
1653
+ { label: 'Deno cache', path: `${ctx.targetHome}/.cache/deno` },
1654
+ { label: 'uv Python cache', path: `${ctx.targetHome}/.cache/uv` },
1655
+ { label: 'Yarn cache', path: `${ctx.targetHome}/.cache/yarn` },
1656
+ { label: 'Cypress', path: `${ctx.targetHome}/.cache/Cypress` },
1657
+ { label: 'MongoDB binaries', path: `${ctx.targetHome}/.cache/mongodb-binaries` },
1658
+ { label: 'VS Code Server', path: `${ctx.targetHome}/.vscode-server` },
1659
+ { label: 'VS Code Server extensions', path: `${ctx.targetHome}/.vscode-server/extensions` },
1660
+ { label: 'VS Code Server binaries', path: `${ctx.targetHome}/.vscode-server/bin` },
1661
+ { label: 'VS Code user data', path: `${ctx.targetHome}/.vscode` },
1662
+ { label: 'VS Code local extensions', path: `${ctx.targetHome}/.vscode/extensions` },
1663
+ { label: 'VS Code config', path: `${ctx.targetHome}/.config/Code` },
1664
+ { label: 'VS Code workspace storage', path: `${ctx.targetHome}/.config/Code/User/workspaceStorage` },
1665
+ { label: 'VS Code cached data', path: `${ctx.targetHome}/.config/Code/CachedData` },
1666
+ { label: 'VS Code extension VSIX cache', path: `${ctx.targetHome}/.config/Code/CachedExtensionVSIXs` },
1667
+ { label: 'VS Code service worker cache', path: `${ctx.targetHome}/.config/Code/Service Worker/CacheStorage` },
1668
+ { label: 'Rust toolchains', path: `${ctx.targetHome}/.rustup` },
1669
+ ]);
1670
+
1671
+ await printDuTree(ctx, 'Var Breakdown', '/var', true);
1672
+ await printDuTree(ctx, 'Var Lib Breakdown', '/var/lib', true);
1673
+ await printKnownSizes(ctx, 'Known System Data', [
1674
+ { label: 'root Docker data', path: '/var/lib/docker' },
1675
+ { label: 'containerd', path: '/var/lib/containerd' },
1676
+ { label: 'onebox', path: '/var/lib/onebox' },
1677
+ { label: 'ClickHouse data', path: '/var/lib/onebox/clickhouse' },
1678
+ { label: 'libvirt', path: '/var/lib/libvirt' },
1679
+ { label: 'snapd', path: '/var/lib/snapd' },
1680
+ { label: 'system logs', path: '/var/log' },
1681
+ ]);
1682
+
1683
+ await printJournalUsage(ctx);
1684
+ await printDeletedOpenFiles(ctx);
1685
+ await printDockerContexts(ctx);
1686
+ await printSpecializedToolHints();
1687
+ printCleanupHints(ctx);
1688
+ }
1689
+
1690
+ export async function runCli(args = process.argv.slice(2)) {
1691
+ if (args.includes('--help') || args.includes('-h')) {
1692
+ printHelp();
1693
+ return;
1694
+ }
1695
+
1696
+ if (args.includes('--version') || args.includes('-v')) {
1697
+ console.log(commitinfo.version);
1698
+ return;
1699
+ }
1700
+
1701
+ if (args.includes('--json')) {
1702
+ try {
1703
+ const ctx = await initContext();
1704
+ const findings = await collectProjectToolCacheDiagnostics(ctx);
1705
+ console.log(JSON.stringify({ toolCaches: findings }, null, 2));
1706
+ } catch (error) {
1707
+ console.error(error instanceof Error ? error.message : String(error));
1708
+ process.exitCode = 1;
1709
+ }
1710
+ return;
1711
+ }
1712
+
1713
+ try {
1714
+ await main();
1715
+ } catch (error) {
1716
+ console.error(error instanceof Error ? error.message : String(error));
1717
+ process.exitCode = 1;
1718
+ }
1719
+ }