disk-space-saviour 0.16.0 → 0.17.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/CHANGELOG.md +6 -0
- package/README.md +40 -1
- package/package.json +1 -1
- package/src/clean.js +29 -5
- package/src/cli.js +79 -13
- package/src/emergency.js +37 -6
- package/src/env/local.js +245 -64
- package/src/env/parallel.js +23 -0
- package/src/env/platform-index.js +87 -0
- package/src/env/shell.js +41 -1
- package/src/fast-clean.js +39 -0
- package/src/health.js +3 -3
- package/src/index.d.ts +27 -0
- package/src/options.js +3 -1
- package/src/report.js +18 -2
- package/src/scan-state.js +239 -0
- package/src/scan.js +135 -19
- package/src/scanners/global.js +19 -2
- package/src/scanners/projects.js +89 -20
- package/src/scanners/versions.js +7 -4
- package/src/swap.js +69 -0
package/src/env/local.js
CHANGED
|
@@ -64,6 +64,9 @@ import { CACHE_VARIABLES } from './cache-roots.js';
|
|
|
64
64
|
|
|
65
65
|
import { hostExecutor } from '../exec.js';
|
|
66
66
|
import { matchesGlob } from '../paths.js';
|
|
67
|
+
import { mapLimit } from './parallel.js';
|
|
68
|
+
import { directoryFingerprint, SIZE_MAX_AGE } from '../scan-state.js';
|
|
69
|
+
import { indexedDirectories } from './platform-index.js';
|
|
67
70
|
|
|
68
71
|
// Directories read concurrently while measuring usage.
|
|
69
72
|
const WALK_BATCH = 64;
|
|
@@ -252,6 +255,7 @@ export class LocalEnv {
|
|
|
252
255
|
size: stats.size,
|
|
253
256
|
bytes: diskBytes(stats),
|
|
254
257
|
mtimeMs: stats.mtimeMs,
|
|
258
|
+
ctimeMs: stats.ctimeMs,
|
|
255
259
|
inode: `${stats.dev}:${stats.ino}`,
|
|
256
260
|
uid: stats.uid,
|
|
257
261
|
gid: stats.gid,
|
|
@@ -295,31 +299,36 @@ export class LocalEnv {
|
|
|
295
299
|
return heads;
|
|
296
300
|
}
|
|
297
301
|
|
|
298
|
-
async list(dir) {
|
|
302
|
+
async list(dir, { discovery = false } = {}) {
|
|
299
303
|
let names;
|
|
300
304
|
try {
|
|
301
|
-
names = await (this.options.fileFs ?? fsp).readdir(dir
|
|
305
|
+
names = await (this.options.fileFs ?? fsp).readdir(dir, {
|
|
306
|
+
withFileTypes: true,
|
|
307
|
+
});
|
|
302
308
|
} catch (error) {
|
|
303
309
|
this.recordAccessError(dir, error);
|
|
304
310
|
return [];
|
|
305
311
|
}
|
|
306
|
-
const entries = await
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
312
|
+
const entries = await mapLimit(names, 32, async (entry) => {
|
|
313
|
+
const name = typeof entry === 'string' ? entry : entry.name;
|
|
314
|
+
const full = path.join(dir, name);
|
|
315
|
+
if (discovery && typeof entry !== 'string' && !entry.isDirectory()) {
|
|
316
|
+
return { name, path: full, type: entryType(entry), mtimeMs: 0 };
|
|
317
|
+
}
|
|
318
|
+
const stats = await this.#lstat(full);
|
|
319
|
+
if (!stats) {
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
return {
|
|
323
|
+
name,
|
|
324
|
+
path: full,
|
|
325
|
+
type: entryType(stats),
|
|
326
|
+
size: stats.size,
|
|
327
|
+
bytes: diskBytes(stats),
|
|
328
|
+
mtimeMs: stats.mtimeMs,
|
|
329
|
+
device: stats.dev,
|
|
330
|
+
};
|
|
331
|
+
});
|
|
323
332
|
return entries.filter(Boolean);
|
|
324
333
|
}
|
|
325
334
|
|
|
@@ -328,14 +337,15 @@ export class LocalEnv {
|
|
|
328
337
|
* counted a single time, symlinks not followed. Also reports the newest
|
|
329
338
|
* file mtime.
|
|
330
339
|
*/
|
|
331
|
-
async usage(target) {
|
|
332
|
-
return (await this.usageMany([target])).get(target) ?? null;
|
|
340
|
+
async usage(target, options) {
|
|
341
|
+
return (await this.usageMany([target], options)).get(target) ?? null;
|
|
333
342
|
}
|
|
334
343
|
|
|
335
344
|
async statMany(targets) {
|
|
336
|
-
const entries = await
|
|
337
|
-
|
|
338
|
-
|
|
345
|
+
const entries = await mapLimit(targets, 32, async (target) => [
|
|
346
|
+
target,
|
|
347
|
+
await this.stat(target),
|
|
348
|
+
]);
|
|
339
349
|
return new Map(entries.filter(([, stat]) => stat));
|
|
340
350
|
}
|
|
341
351
|
|
|
@@ -362,6 +372,9 @@ export class LocalEnv {
|
|
|
362
372
|
apparentBytes += stats.size;
|
|
363
373
|
if (!stats.isDirectory()) {
|
|
364
374
|
files++;
|
|
375
|
+
if (this.scanMetrics) {
|
|
376
|
+
this.scanMetrics.filesSized++;
|
|
377
|
+
}
|
|
365
378
|
newestMtimeMs = Math.max(newestMtimeMs, stats.mtimeMs);
|
|
366
379
|
}
|
|
367
380
|
};
|
|
@@ -375,12 +388,10 @@ export class LocalEnv {
|
|
|
375
388
|
errors++;
|
|
376
389
|
return [];
|
|
377
390
|
}
|
|
378
|
-
const children = await
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
})
|
|
383
|
-
);
|
|
391
|
+
const children = await mapLimit(names, 32, async (name) => {
|
|
392
|
+
const full = path.join(dir, name);
|
|
393
|
+
return [full, await this.#lstat(full)];
|
|
394
|
+
});
|
|
384
395
|
const subdirs = [];
|
|
385
396
|
for (const [full, stats] of children) {
|
|
386
397
|
if (!stats) {
|
|
@@ -453,6 +464,10 @@ export class LocalEnv {
|
|
|
453
464
|
return results;
|
|
454
465
|
}
|
|
455
466
|
|
|
467
|
+
indexedDirs(roots, globs, maxDepth) {
|
|
468
|
+
return indexedDirectories(this, roots, globs, maxDepth);
|
|
469
|
+
}
|
|
470
|
+
|
|
456
471
|
/**
|
|
457
472
|
* Finds regular files whose basename matches one of `names`.
|
|
458
473
|
* @returns {Promise<string[]>}
|
|
@@ -468,7 +483,14 @@ export class LocalEnv {
|
|
|
468
483
|
results.push(entry.path);
|
|
469
484
|
}
|
|
470
485
|
}
|
|
471
|
-
return
|
|
486
|
+
return new Set([
|
|
487
|
+
'node_modules',
|
|
488
|
+
'target',
|
|
489
|
+
'vendor',
|
|
490
|
+
'.venv',
|
|
491
|
+
'venv',
|
|
492
|
+
...this.skipNames,
|
|
493
|
+
]);
|
|
472
494
|
});
|
|
473
495
|
return results;
|
|
474
496
|
}
|
|
@@ -480,55 +502,214 @@ export class LocalEnv {
|
|
|
480
502
|
async #walk(roots, maxDepth, skipNames, visit) {
|
|
481
503
|
const skip = skipNames ? new Set(skipNames) : this.skipNames;
|
|
482
504
|
const visited = new Set();
|
|
483
|
-
|
|
505
|
+
let queue = await Promise.all(
|
|
484
506
|
roots.map(async (root) => [root, 0, await this.deviceId(root)])
|
|
485
507
|
);
|
|
486
508
|
while (queue.length > 0) {
|
|
487
509
|
checkSignal(this.signal);
|
|
488
|
-
const
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
510
|
+
const current = queue;
|
|
511
|
+
queue = [];
|
|
512
|
+
const children = await mapLimit(
|
|
513
|
+
current,
|
|
514
|
+
8,
|
|
515
|
+
async ([dir, depth, device]) => {
|
|
516
|
+
if (visited.has(dir)) {
|
|
517
|
+
return [];
|
|
518
|
+
}
|
|
519
|
+
visited.add(dir);
|
|
520
|
+
const entries = [];
|
|
521
|
+
for (const entry of await this.discoveryList(dir)) {
|
|
522
|
+
if (
|
|
523
|
+
entry.type !== 'dir' ||
|
|
524
|
+
(entry.device ?? (await this.deviceId(entry.path))) === device
|
|
525
|
+
) {
|
|
526
|
+
entries.push(entry);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
const pruned = visit(dir, entries) ?? new Set();
|
|
530
|
+
if (depth + 1 >= maxDepth) {
|
|
531
|
+
return [];
|
|
532
|
+
}
|
|
533
|
+
const next = [];
|
|
534
|
+
for (const entry of entries) {
|
|
535
|
+
if (
|
|
536
|
+
entry.type === 'dir' &&
|
|
537
|
+
!pruned.has(entry.name) &&
|
|
538
|
+
!this.skipDiscoveryDirectory(dir, entry.name, skip) &&
|
|
539
|
+
(entry.device ?? (await this.deviceId(entry.path))) === device
|
|
540
|
+
) {
|
|
541
|
+
next.push([entry.path, depth + 1, device]);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
return next;
|
|
500
545
|
}
|
|
546
|
+
);
|
|
547
|
+
queue = children.flat();
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
skipDiscoveryDirectory(dir, name, skip = this.skipNames) {
|
|
552
|
+
if (skip.has(name) || /\.photoslibrary$/i.test(name)) {
|
|
553
|
+
return true;
|
|
554
|
+
}
|
|
555
|
+
const homes = this.options.homes ?? [this.currentHome];
|
|
556
|
+
return (
|
|
557
|
+
(['Music', 'Pictures', 'Movies'].includes(name) && homes.includes(dir)) ||
|
|
558
|
+
(name === 'Volumes' && dir === this.path.parse(dir).root)
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
async discoveryStat(dir) {
|
|
563
|
+
let stats = await this.stat(dir);
|
|
564
|
+
// Explicit roots may be aliases (for example /tmp on macOS). Child links
|
|
565
|
+
// are never queued by the walker, so only an explicitly supplied root follows.
|
|
566
|
+
if (stats?.type === 'symlink') {
|
|
567
|
+
const resolved = await this.realPath(dir);
|
|
568
|
+
stats = resolved ? await this.stat(resolved) : null;
|
|
569
|
+
}
|
|
570
|
+
return stats;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
async discoveryList(dir) {
|
|
574
|
+
checkSignal(this.signal);
|
|
575
|
+
const stats = await this.discoveryStat(dir);
|
|
576
|
+
if (!stats || stats.type !== 'dir') {
|
|
577
|
+
return [];
|
|
578
|
+
}
|
|
579
|
+
const key = `list:${dir}`;
|
|
580
|
+
const fingerprint = `${directoryFingerprint(stats)}:${JSON.stringify([this.scanPolicy?.exclude, this.scanPolicy?.pruned])}`;
|
|
581
|
+
let entries = this.scanIndex?.get(key, fingerprint);
|
|
582
|
+
if (entries) {
|
|
583
|
+
this.scanMetrics.indexHits++;
|
|
584
|
+
} else {
|
|
585
|
+
const errors = this.accessErrors?.size ?? 0;
|
|
586
|
+
entries = await this.list(dir, { discovery: true });
|
|
587
|
+
if ((this.accessErrors?.size ?? 0) === errors) {
|
|
588
|
+
this.scanIndex?.set(key, fingerprint, entries);
|
|
501
589
|
}
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
590
|
+
}
|
|
591
|
+
// Stored metadata is a discovery hint; never accept external paths from it.
|
|
592
|
+
const listing = entries
|
|
593
|
+
.filter(
|
|
594
|
+
(entry) => entry.name && this.path.basename(entry.name) === entry.name
|
|
595
|
+
)
|
|
596
|
+
.map((entry) => ({ ...entry, path: this.path.join(dir, entry.name) }))
|
|
597
|
+
.filter(
|
|
598
|
+
(entry) => !this.scanPolicy || this.scanPolicy.allows(entry.path)
|
|
599
|
+
);
|
|
600
|
+
// Mounts and link replacements need not change the parent's mtime.
|
|
601
|
+
return (
|
|
602
|
+
await mapLimit(listing, 32, async (entry) => {
|
|
603
|
+
if (entry.type !== 'dir') {
|
|
604
|
+
return entry;
|
|
514
605
|
}
|
|
515
|
-
|
|
606
|
+
const fresh = await this.stat(entry.path);
|
|
607
|
+
return fresh ? { ...entry, ...fresh } : null;
|
|
608
|
+
})
|
|
609
|
+
).filter(Boolean);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/** Source activity excludes generated trees, and stops as soon as recent work is seen. */
|
|
613
|
+
async projectActivity(project, globs, since) {
|
|
614
|
+
let newest = 0;
|
|
615
|
+
let frontier = [project];
|
|
616
|
+
const device = await this.deviceId(project);
|
|
617
|
+
while (frontier.length && newest < since) {
|
|
618
|
+
const children = await mapLimit(frontier, 8, async (dir) => {
|
|
619
|
+
checkSignal(this.signal);
|
|
620
|
+
const next = [];
|
|
621
|
+
for (const entry of await this.list(dir)) {
|
|
622
|
+
if (entry.type === 'dir') {
|
|
623
|
+
if (
|
|
624
|
+
!this.skipDiscoveryDirectory(dir, entry.name) &&
|
|
625
|
+
!globs.some((glob) => matchesGlob(entry.name, glob)) &&
|
|
626
|
+
entry.device === device
|
|
627
|
+
) {
|
|
628
|
+
next.push(entry.path);
|
|
629
|
+
}
|
|
630
|
+
} else if (entry.type === 'file') {
|
|
631
|
+
newest = Math.max(newest, entry.mtimeMs);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
return next;
|
|
635
|
+
});
|
|
636
|
+
frontier = children.flat();
|
|
516
637
|
}
|
|
638
|
+
return newest;
|
|
517
639
|
}
|
|
518
640
|
|
|
519
|
-
async usageMany(targets) {
|
|
520
|
-
const usages = await this.rawUsageMany(targets);
|
|
641
|
+
async usageMany(targets, options) {
|
|
642
|
+
const usages = await this.rawUsageMany(targets, options);
|
|
521
643
|
return this.writableLayer
|
|
522
644
|
? this.writableLayer.measure(this, usages)
|
|
523
645
|
: usages;
|
|
524
646
|
}
|
|
525
647
|
|
|
526
|
-
async rawUsageMany(targets) {
|
|
527
|
-
const
|
|
528
|
-
|
|
529
|
-
|
|
648
|
+
async rawUsageMany(targets, { mtime = true, cache = false } = {}) {
|
|
649
|
+
const entries = await mapLimit([...new Set(targets)], 4, async (target) => [
|
|
650
|
+
target,
|
|
651
|
+
!this.writableLayer && cache
|
|
652
|
+
? await this.cachedUsage(target, mtime)
|
|
653
|
+
: !mtime && !this.writableLayer
|
|
654
|
+
? await this.quickUsage(target)
|
|
655
|
+
: await this.rawUsage(target),
|
|
656
|
+
]);
|
|
657
|
+
return new Map(entries);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
async cachedUsage(target, mtime) {
|
|
661
|
+
const stats = await this.stat(target);
|
|
662
|
+
if (!stats) {
|
|
663
|
+
return null;
|
|
664
|
+
}
|
|
665
|
+
const key = `usage:${mtime}:${target}`;
|
|
666
|
+
const fingerprint = `${directoryFingerprint(stats)}:${JSON.stringify([this.scanPolicy?.exclude, this.scanPolicy?.pruned])}`;
|
|
667
|
+
const cached = this.scanIndex?.get(key, fingerprint, SIZE_MAX_AGE);
|
|
668
|
+
if (cached) {
|
|
669
|
+
this.scanMetrics.sizeCacheHits++;
|
|
670
|
+
return { ...cached, estimated: true };
|
|
671
|
+
}
|
|
672
|
+
const usage = mtime
|
|
673
|
+
? await this.rawUsage(target)
|
|
674
|
+
: await this.quickUsage(target);
|
|
675
|
+
if (usage && !usage.errors) {
|
|
676
|
+
this.scanIndex?.set(key, fingerprint, usage);
|
|
677
|
+
}
|
|
678
|
+
return usage;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
async quickUsage(target) {
|
|
682
|
+
const stats = await this.stat(target);
|
|
683
|
+
if (!stats) {
|
|
684
|
+
return null;
|
|
685
|
+
}
|
|
686
|
+
// du cannot enforce path-glob exclusions or detect a nested mount beneath a candidate.
|
|
687
|
+
if (
|
|
688
|
+
this.platform === 'win32' ||
|
|
689
|
+
this.scanPolicy?.exclude.length ||
|
|
690
|
+
this.scanPolicy?.mounts.some(
|
|
691
|
+
(mount) =>
|
|
692
|
+
mount.path !== '/' && mount.path.startsWith(`${target}${path.sep}`)
|
|
693
|
+
)
|
|
694
|
+
) {
|
|
695
|
+
return this.rawUsage(target);
|
|
696
|
+
}
|
|
697
|
+
const result = await this.run(['du', '-skx', target]);
|
|
698
|
+
const match = /^\s*(\d+)\s/.exec(result.stdout);
|
|
699
|
+
if (result.code !== 0 || !match) {
|
|
700
|
+
return this.rawUsage(target);
|
|
701
|
+
}
|
|
702
|
+
const usage = {
|
|
703
|
+
bytes: Number(match[1]) * 1024,
|
|
704
|
+
apparentBytes: Number(match[1]) * 1024,
|
|
705
|
+
newestMtimeMs: 0,
|
|
706
|
+
files: 0,
|
|
707
|
+
errors: 0,
|
|
708
|
+
};
|
|
709
|
+
if (this.scanMetrics) {
|
|
710
|
+
this.scanMetrics.directoriesSized++;
|
|
530
711
|
}
|
|
531
|
-
return
|
|
712
|
+
return usage;
|
|
532
713
|
}
|
|
533
714
|
|
|
534
715
|
async readLink(target) {
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Finite worker pool; settle all in-flight work before propagating failure. */
|
|
2
|
+
export async function mapLimit(values, limit, operation) {
|
|
3
|
+
const results = new Array(values.length);
|
|
4
|
+
let cursor = 0;
|
|
5
|
+
let failure;
|
|
6
|
+
const worker = async () => {
|
|
7
|
+
while (!failure && cursor < values.length) {
|
|
8
|
+
const index = cursor++;
|
|
9
|
+
try {
|
|
10
|
+
results[index] = await operation(values[index], index);
|
|
11
|
+
} catch (error) {
|
|
12
|
+
failure ??= error;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
await Promise.all(
|
|
17
|
+
Array.from({ length: Math.min(limit, values.length) }, worker)
|
|
18
|
+
);
|
|
19
|
+
if (failure) {
|
|
20
|
+
throw failure;
|
|
21
|
+
}
|
|
22
|
+
return results;
|
|
23
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/** OS index hits are hints; stale/missing indexes always fall back to discovery. */
|
|
2
|
+
import { isWithin, matchesGlob } from '../paths.js';
|
|
3
|
+
import { mapLimit } from './parallel.js';
|
|
4
|
+
|
|
5
|
+
export async function indexedDirectories(env, roots, globs, maxDepth) {
|
|
6
|
+
if (!env.scanIndex?.directory || env.platformIndex === false) {
|
|
7
|
+
return [];
|
|
8
|
+
}
|
|
9
|
+
let hits = [];
|
|
10
|
+
try {
|
|
11
|
+
if (env.platform === 'darwin' && (await env.which('mdfind'))) {
|
|
12
|
+
const query = globs
|
|
13
|
+
.map((glob) => `kMDItemFSName == "${glob.replaceAll('"', '\\"')}"`)
|
|
14
|
+
.join(' || ');
|
|
15
|
+
for (const root of roots) {
|
|
16
|
+
const result = await env.run(['mdfind', '-0', '-onlyin', root, query]);
|
|
17
|
+
if (result.code === 0) {
|
|
18
|
+
hits.push(...result.stdout.split('\0').filter(Boolean));
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
} else if (env.platform === 'linux') {
|
|
22
|
+
const command = (await env.which('plocate'))
|
|
23
|
+
? 'plocate'
|
|
24
|
+
: (await env.which('locate'))
|
|
25
|
+
? 'locate'
|
|
26
|
+
: null;
|
|
27
|
+
if (command) {
|
|
28
|
+
const result = await env.run([command, '-0', '-e', '-b', ...globs]);
|
|
29
|
+
if (result.code === 0) {
|
|
30
|
+
hits = result.stdout.split('\0').filter(Boolean);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
} catch {
|
|
35
|
+
// Budget/cancellation will be observed by the fallback walk too.
|
|
36
|
+
}
|
|
37
|
+
hits = [...new Set(hits)]
|
|
38
|
+
.filter((target) => {
|
|
39
|
+
const root = roots.find((candidate) =>
|
|
40
|
+
isWithin(target, candidate, env.path)
|
|
41
|
+
);
|
|
42
|
+
if (
|
|
43
|
+
!root ||
|
|
44
|
+
!env.path.isAbsolute(target) ||
|
|
45
|
+
!globs.some((glob) => matchesGlob(env.path.basename(target), glob))
|
|
46
|
+
) {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
const parts = env.path.relative(root, target).split(/[\\/]/);
|
|
50
|
+
return (
|
|
51
|
+
parts.length <= maxDepth &&
|
|
52
|
+
!parts
|
|
53
|
+
.slice(0, -1)
|
|
54
|
+
.some(
|
|
55
|
+
(name, index) =>
|
|
56
|
+
env.skipDiscoveryDirectory(
|
|
57
|
+
env.path.join(root, ...parts.slice(0, index)),
|
|
58
|
+
name
|
|
59
|
+
) || globs.some((glob) => matchesGlob(name, glob))
|
|
60
|
+
)
|
|
61
|
+
);
|
|
62
|
+
})
|
|
63
|
+
.slice(0, 20000);
|
|
64
|
+
const candidates = await mapLimit(hits, 8, async (target) => {
|
|
65
|
+
const stat = await env.stat(target);
|
|
66
|
+
const root = roots.find((candidate) =>
|
|
67
|
+
isWithin(target, candidate, env.path)
|
|
68
|
+
);
|
|
69
|
+
if (stat?.type !== 'dir' || stat.device !== (await env.deviceId(root))) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
let parent = env.path.dirname(target);
|
|
73
|
+
while (parent !== root && isWithin(parent, root, env.path)) {
|
|
74
|
+
const ancestor = await env.stat(parent);
|
|
75
|
+
if (ancestor?.type !== 'dir' || ancestor.device !== stat.device) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
parent = env.path.dirname(parent);
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
path: target,
|
|
82
|
+
name: env.path.basename(target),
|
|
83
|
+
parent: env.path.dirname(target),
|
|
84
|
+
};
|
|
85
|
+
});
|
|
86
|
+
return candidates.filter(Boolean);
|
|
87
|
+
}
|
package/src/env/shell.js
CHANGED
|
@@ -405,7 +405,47 @@ export class ShellEnv {
|
|
|
405
405
|
}
|
|
406
406
|
|
|
407
407
|
findFiles(roots, { names, maxDepth = 6, skipNames } = {}) {
|
|
408
|
-
return this.#find(roots, 'f', names, maxDepth,
|
|
408
|
+
return this.#find(roots, 'f', names, maxDepth, [
|
|
409
|
+
...new Set([
|
|
410
|
+
...(skipNames ?? this.skipNames),
|
|
411
|
+
'node_modules',
|
|
412
|
+
'target',
|
|
413
|
+
'vendor',
|
|
414
|
+
'.venv',
|
|
415
|
+
'venv',
|
|
416
|
+
]),
|
|
417
|
+
]);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async projectActivity(project, globs) {
|
|
421
|
+
const prune = [
|
|
422
|
+
'-type',
|
|
423
|
+
'd',
|
|
424
|
+
'(',
|
|
425
|
+
...findNameExpression([...new Set([...this.skipNames, ...globs])]),
|
|
426
|
+
')',
|
|
427
|
+
'-prune',
|
|
428
|
+
'-o',
|
|
429
|
+
];
|
|
430
|
+
const argv = [
|
|
431
|
+
'find',
|
|
432
|
+
project,
|
|
433
|
+
'-xdev',
|
|
434
|
+
...this.#prunePaths(),
|
|
435
|
+
...prune,
|
|
436
|
+
'-type',
|
|
437
|
+
'f',
|
|
438
|
+
'-exec',
|
|
439
|
+
'stat',
|
|
440
|
+
'-c',
|
|
441
|
+
'%Y',
|
|
442
|
+
'{}',
|
|
443
|
+
'+',
|
|
444
|
+
];
|
|
445
|
+
const result = await this.sh(
|
|
446
|
+
`${argv.map(shellQuote).join(' ')} | awk 'BEGIN{m=0} {if ($1>m) m=$1} END{print m}'`
|
|
447
|
+
);
|
|
448
|
+
return Number(result.stdout.trim()) * 1000 || 0;
|
|
409
449
|
}
|
|
410
450
|
|
|
411
451
|
/**
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** Clean completed safe host findings while discovery continues. */
|
|
2
|
+
import { clean } from './clean.js';
|
|
3
|
+
import { scan } from './scan.js';
|
|
4
|
+
|
|
5
|
+
export async function cleanDuringScan(options) {
|
|
6
|
+
const entries = [];
|
|
7
|
+
const health = [];
|
|
8
|
+
let failure;
|
|
9
|
+
const report = await scan({
|
|
10
|
+
...options,
|
|
11
|
+
onScanPhase: async (phase) => {
|
|
12
|
+
try {
|
|
13
|
+
const audit = await clean(phase, {
|
|
14
|
+
...options,
|
|
15
|
+
tier: 'safe',
|
|
16
|
+
priorEntries: entries,
|
|
17
|
+
priorHealth: health,
|
|
18
|
+
});
|
|
19
|
+
entries.splice(0, entries.length, ...audit.entries);
|
|
20
|
+
health.splice(0, health.length, ...audit.health);
|
|
21
|
+
} catch (error) {
|
|
22
|
+
failure = error;
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
if (failure) {
|
|
28
|
+
throw failure;
|
|
29
|
+
}
|
|
30
|
+
if (report.aborted) {
|
|
31
|
+
return { report, audit: null };
|
|
32
|
+
}
|
|
33
|
+
const audit = await clean(report, {
|
|
34
|
+
...options,
|
|
35
|
+
priorEntries: entries,
|
|
36
|
+
priorHealth: health,
|
|
37
|
+
});
|
|
38
|
+
return { report, audit };
|
|
39
|
+
}
|
package/src/health.js
CHANGED
|
@@ -107,9 +107,9 @@ function describe(lost) {
|
|
|
107
107
|
* Per-environment snapshots and re-checks. `records` is the audit view.
|
|
108
108
|
*/
|
|
109
109
|
export class HealthWatch {
|
|
110
|
-
constructor() {
|
|
111
|
-
this.records = [];
|
|
112
|
-
this.byEnv = new Map();
|
|
110
|
+
constructor(records = []) {
|
|
111
|
+
this.records = [...records];
|
|
112
|
+
this.byEnv = new Map(records.map((record) => [record.env, record]));
|
|
113
113
|
}
|
|
114
114
|
|
|
115
115
|
/**
|
package/src/index.d.ts
CHANGED
|
@@ -13,12 +13,21 @@ export interface ScanOptions {
|
|
|
13
13
|
maxDepth?: number;
|
|
14
14
|
/** Time budget per filesystem environment (`2m`); incomplete findings are kept. */
|
|
15
15
|
scanBudget?: Duration;
|
|
16
|
+
/** Persistent scan/index directory. Library default is false; CLI uses XDG state. */
|
|
17
|
+
cacheDir?: string | false;
|
|
18
|
+
/** Use verified Spotlight/locate hints before the discovery walk. */
|
|
19
|
+
platformIndex?: boolean;
|
|
16
20
|
signal?: AbortSignal;
|
|
17
21
|
onScanProgress?: (progress: {
|
|
18
22
|
env: string;
|
|
19
23
|
scanner: string;
|
|
20
24
|
elapsedMs: number;
|
|
21
25
|
items: number;
|
|
26
|
+
candidates: number;
|
|
27
|
+
filesSized: number;
|
|
28
|
+
directoriesSized: number;
|
|
29
|
+
indexHits: number;
|
|
30
|
+
sizeCacheHits: number;
|
|
22
31
|
status: string;
|
|
23
32
|
}) => void;
|
|
24
33
|
/** Activity window: newer files are kept (`1h`). */
|
|
@@ -109,6 +118,8 @@ export interface Item {
|
|
|
109
118
|
path: string | null;
|
|
110
119
|
paths: string[];
|
|
111
120
|
bytes: number;
|
|
121
|
+
/** Cached size is advisory; cleanup always measures again. */
|
|
122
|
+
sizeEstimated?: boolean;
|
|
112
123
|
/** Merged filesystem bytes, including image data, when measured in Docker. */
|
|
113
124
|
totalBytes?: number;
|
|
114
125
|
/** Immutable image bytes excluded from reclaimable totals. */
|
|
@@ -154,9 +165,23 @@ export interface EnvironmentDescriptor {
|
|
|
154
165
|
chain: Array<{ containerId: string; name: string }>;
|
|
155
166
|
hint?: string | null;
|
|
156
167
|
disk?: Disk | null;
|
|
168
|
+
swap?: {
|
|
169
|
+
total: number;
|
|
170
|
+
used: number;
|
|
171
|
+
free: number;
|
|
172
|
+
holders: Array<{
|
|
173
|
+
pid: number;
|
|
174
|
+
command: string;
|
|
175
|
+
memoryBytes: number;
|
|
176
|
+
compressedBytes: number;
|
|
177
|
+
}>;
|
|
178
|
+
attribution: string;
|
|
179
|
+
advice: string;
|
|
180
|
+
} | null;
|
|
157
181
|
scanStatus?:
|
|
158
182
|
| 'scanning'
|
|
159
183
|
| 'complete'
|
|
184
|
+
| 'goal-reached'
|
|
160
185
|
| 'aborted'
|
|
161
186
|
| 'budget-exceeded'
|
|
162
187
|
| 'error'
|
|
@@ -178,6 +203,7 @@ export interface Report {
|
|
|
178
203
|
createdAt: string;
|
|
179
204
|
durationMs: number;
|
|
180
205
|
aborted?: boolean;
|
|
206
|
+
authentication?: { hostname: string; uid: number | string; digest: string };
|
|
181
207
|
audit?: { file: string | null; error: string; code?: string };
|
|
182
208
|
host: { env: string; label: string; platform: string };
|
|
183
209
|
options: Record<string, unknown>;
|
|
@@ -235,6 +261,7 @@ export interface AuditLog {
|
|
|
235
261
|
startedAt: string;
|
|
236
262
|
finishedAt: string | null;
|
|
237
263
|
aborted?: boolean;
|
|
264
|
+
report?: Partial<Report>;
|
|
238
265
|
error?: string;
|
|
239
266
|
entries: AuditEntry[];
|
|
240
267
|
freedBytes: number;
|