disk-space-saviour 0.22.1 → 0.23.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 +12 -0
- package/README.md +65 -0
- package/package.json +1 -1
- package/src/clean.js +25 -7
- package/src/cli.js +11 -0
- package/src/docker/scan.js +1 -0
- package/src/docker/writable.js +7 -1
- package/src/env/hardlinks.js +139 -0
- package/src/env/local.js +53 -11
- package/src/env/roots.js +138 -0
- package/src/env/shell.js +14 -2
- package/src/index.d.ts +42 -0
- package/src/mac-apps.js +286 -0
- package/src/options.js +3 -0
- package/src/report.js +13 -0
- package/src/scan-state.js +1 -0
- package/src/scan.js +32 -23
- package/src/scanners/agents.js +2 -1
- package/src/scanners/global.js +2 -1
- package/src/scanners/projects.js +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.23.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- Recursive container scans honor requested roots: `--container-root PATH` (`containerRoots`) sets project roots inside every scanned container at any Docker depth, and host roots that exist inside a container join its defaults. The same roots reach the traversal policy and project discovery. Every environment now reports its effective `scope` (roots with source and status, max depth, exclusions, pruned mounts) and the text report names requested roots that were absent, inaccessible, pruned, excluded or not searched.
|
|
8
|
+
|
|
9
|
+
Hard-link aware sizes: items report physically reclaimable bytes, with `allocatedBytes` and `sharedBytes` when links outside the item keep blocks allocated. Each inode is counted once across a selected deletion set, and the audit records `sharedBytes` and marks link-aware estimated `freedBytes`.
|
|
10
|
+
|
|
11
|
+
Add explicit macOS app bundle removal through native Trash with process checks,
|
|
12
|
+
dry runs and audits. Report possible app leftovers separately from user data,
|
|
13
|
+
without automatically deleting either category.
|
|
14
|
+
|
|
3
15
|
## 0.22.1
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -411,6 +411,41 @@ is scanned as a new environment, down to `--depth` levels (default 3). The
|
|
|
411
411
|
CI job `Docker-in-Docker Integration` builds a host → l1 → l2 chain and runs
|
|
412
412
|
`tests/integration/dind.mjs` against it (`npm run test:dind`).
|
|
413
413
|
|
|
414
|
+
### Scan roots
|
|
415
|
+
|
|
416
|
+
Project discovery walks a list of roots per environment; the traversal
|
|
417
|
+
policy (which mount points are crossed) uses the same requested roots, so
|
|
418
|
+
both always agree:
|
|
419
|
+
|
|
420
|
+
- host: the positional `paths` (`roots` in the API), otherwise the homes and
|
|
421
|
+
temporary directories;
|
|
422
|
+
- every scanned container, at every Docker depth: `--container-root PATH`
|
|
423
|
+
(`containerRoots`, repeatable) replaces the defaults; without it, the
|
|
424
|
+
container's homes, temporary directories, existing common workspace paths
|
|
425
|
+
(`/workspace`, `/workspaces`, `/app`, `/src`, `/code`, `/project`,
|
|
426
|
+
`/usr/src/app`) and every host root that also exists inside the container.
|
|
427
|
+
Host and container paths are different namespaces, so no path is remapped.
|
|
428
|
+
|
|
429
|
+
Each JSON environment carries `scope`: its roots with `source` (`roots`,
|
|
430
|
+
`container-roots`, `home`, `tmp`, `container-default`) and `status`
|
|
431
|
+
(`searched`, `partial`, `absent`, `inaccessible`, `pruned`, `excluded`,
|
|
432
|
+
`not-searched`), plus `maxDepth`, `exclude` and `prunedMounts`. The text
|
|
433
|
+
report names requested roots that were not searched. `scanStatus: complete`
|
|
434
|
+
means complete within that scope; exclusions, mount boundaries and liveness
|
|
435
|
+
checks still apply.
|
|
436
|
+
|
|
437
|
+
### Hard links
|
|
438
|
+
|
|
439
|
+
Sizes are physical reclaimable bytes. A file's blocks count toward an item
|
|
440
|
+
only when every hard link to it lies inside the deletion set: a package
|
|
441
|
+
cache or `node_modules` hard-linked to a shared store (pnpm, Nix, uv) reports
|
|
442
|
+
those blocks as `sharedBytes`, next to `allocatedBytes` (what `du` prints).
|
|
443
|
+
Each `dev:inode` is counted once across the whole selection, so files linked
|
|
444
|
+
between two selected paths are reclaimable once. In the audit log,
|
|
445
|
+
`sharedBytes` lists blocks that stayed allocated; `freedBytes` is a measured
|
|
446
|
+
before/after value for Docker writable layers and otherwise the link-aware
|
|
447
|
+
estimate, marked with `freedEstimated` and `freedAccounting`.
|
|
448
|
+
|
|
414
449
|
## Emergency mode
|
|
415
450
|
|
|
416
451
|
`dss emergency --free 20G` (at least 20 GiB available) or
|
|
@@ -776,3 +811,33 @@ Quick steps:
|
|
|
776
811
|
## License
|
|
777
812
|
|
|
778
813
|
[Unlicense](LICENSE) - Public Domain
|
|
814
|
+
|
|
815
|
+
### Explicit macOS app removal and leftovers
|
|
816
|
+
|
|
817
|
+
`dss apps remove "/Users/me/Applications/Example.app"` previews removal;
|
|
818
|
+
`--yes` moves only that bundle to the macOS Trash. It never empties Trash or
|
|
819
|
+
removes support data. App identity and process activity are checked before
|
|
820
|
+
removal; symlinked paths, nested app components and App Store receipts are
|
|
821
|
+
refused. Use a vendor uninstaller first when one exists.
|
|
822
|
+
|
|
823
|
+
Chrome web apps should first be uninstalled using `chrome://apps`.
|
|
824
|
+
`--launcher-only` explicitly allows moving their launcher bundles to Trash;
|
|
825
|
+
it preserves Chrome's app registration, cookies, profiles and website data,
|
|
826
|
+
so Chrome may recreate the launcher. The command is separate from automatic
|
|
827
|
+
cache cleanup. Removed URL handlers may be recreated by their owning tool.
|
|
828
|
+
|
|
829
|
+
`dss apps leftovers` reports possible leftover Library files, including logs.
|
|
830
|
+
An absent bundle in standard application folders is only a clue: apps can live
|
|
831
|
+
elsewhere, and helpers or other apps can share data. Caches and logs are marked
|
|
832
|
+
`disposable-after-activity-check`; settings, application support, containers,
|
|
833
|
+
and saved state require a user decision. Every candidate remains read-only,
|
|
834
|
+
including disposable candidates. No leftover is safe to delete unconditionally;
|
|
835
|
+
logs may be needed for investigation. Inventory errors appear in the report.
|
|
836
|
+
|
|
837
|
+
### Platform-specific competitor comparison
|
|
838
|
+
|
|
839
|
+
See the [feature comparison tables](docs/research/platform-feature-comparison.md)
|
|
840
|
+
for dss and 30 competitors across Linux, macOS, FreeBSD, OpenBSD, NetBSD,
|
|
841
|
+
illumos and AIX. Capability, interface and safety-backend tables distinguish
|
|
842
|
+
implemented features, vendor documentation, partial coverage and unknowns.
|
|
843
|
+
The comparison is a sourced inventory, not a performance or superiority claim.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "disk-space-saviour",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
4
4
|
"description": "Safely reclaims disk space: caches and build outputs of 20+ ecosystems, forgotten projects, and Docker (running, stopped and nested containers), with an emergency mode",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
package/src/clean.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* log.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
import { selectionBytes } from './env/hardlinks.js';
|
|
15
16
|
import { once } from 'node:events';
|
|
16
17
|
import { createWriteStream, promises as fsp } from 'node:fs';
|
|
17
18
|
import path from 'node:path';
|
|
@@ -79,6 +80,7 @@ export function cleanOrder(a, b) {
|
|
|
79
80
|
);
|
|
80
81
|
}
|
|
81
82
|
|
|
83
|
+
/** Plain per-path sum: used for before/after measurements of one set. */
|
|
82
84
|
function sumBytes(usages, paths = [...usages.keys()]) {
|
|
83
85
|
let total = 0;
|
|
84
86
|
for (const target of paths) {
|
|
@@ -334,7 +336,7 @@ export class Cleaner {
|
|
|
334
336
|
const fresh = {
|
|
335
337
|
...item,
|
|
336
338
|
paths,
|
|
337
|
-
bytes:
|
|
339
|
+
bytes: selectionBytes(usages, paths).bytes,
|
|
338
340
|
newestMtimeMs: newest || item.newestMtimeMs,
|
|
339
341
|
action: type === 'remove' ? { ...item.action, paths } : item.action,
|
|
340
342
|
};
|
|
@@ -559,7 +561,12 @@ export class Cleaner {
|
|
|
559
561
|
entry.reason = boundary;
|
|
560
562
|
return;
|
|
561
563
|
}
|
|
562
|
-
await this.#deletePaths(
|
|
564
|
+
await this.#deletePaths(
|
|
565
|
+
ctx,
|
|
566
|
+
entry,
|
|
567
|
+
[target],
|
|
568
|
+
selectionBytes(new Map([[target, usage]]), [target])
|
|
569
|
+
);
|
|
563
570
|
}
|
|
564
571
|
if (entry.deletedPaths.length === 0 && entry.skippedPaths.length > 0) {
|
|
565
572
|
entry.reason = 'every path was in use or recently written';
|
|
@@ -599,7 +606,12 @@ export class Cleaner {
|
|
|
599
606
|
if (!selected.length) {
|
|
600
607
|
return true;
|
|
601
608
|
}
|
|
602
|
-
await this.#deletePaths(
|
|
609
|
+
await this.#deletePaths(
|
|
610
|
+
ctx,
|
|
611
|
+
entry,
|
|
612
|
+
selected,
|
|
613
|
+
selectionBytes(usages, selected)
|
|
614
|
+
);
|
|
603
615
|
const lost = await this.health.check(ctx.env, item);
|
|
604
616
|
if (lost) {
|
|
605
617
|
entry.reason = lost;
|
|
@@ -638,7 +650,12 @@ export class Cleaner {
|
|
|
638
650
|
return usage.bytes === 0 ? 'no writable bytes remain' : null;
|
|
639
651
|
}
|
|
640
652
|
|
|
641
|
-
|
|
653
|
+
/** `accounted`: hard-link aware {bytes, sharedBytes} of `selected`. */
|
|
654
|
+
async #deletePaths(ctx, entry, selected, accounted) {
|
|
655
|
+
const estimate = accounted.bytes;
|
|
656
|
+
if (accounted.sharedBytes > 0) {
|
|
657
|
+
entry.sharedBytes = (entry.sharedBytes ?? 0) + accounted.sharedBytes;
|
|
658
|
+
}
|
|
642
659
|
const before = await ctx.env.writableLayer?.allocatedBytes?.(
|
|
643
660
|
selected,
|
|
644
661
|
ctx.env.path
|
|
@@ -661,9 +678,10 @@ export class Cleaner {
|
|
|
661
678
|
if (typeof before === 'number' && typeof after === 'number') {
|
|
662
679
|
return Math.max(0, before - after);
|
|
663
680
|
}
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
681
|
+
// Without a block measurement the hard-link aware selection estimate
|
|
682
|
+
// is reported, and marked as an estimate.
|
|
683
|
+
entry.freedEstimated = true;
|
|
684
|
+
entry.freedAccounting = 'hardlink-aware-estimate';
|
|
667
685
|
return fallback;
|
|
668
686
|
}
|
|
669
687
|
|
package/src/cli.js
CHANGED
|
@@ -13,6 +13,7 @@ import { parseArgs } from 'node:util';
|
|
|
13
13
|
|
|
14
14
|
import { startAudit, writeAudit } from './audit.js';
|
|
15
15
|
import { clean } from './clean.js';
|
|
16
|
+
import { runApps } from './mac-apps.js';
|
|
16
17
|
import { exitedBlockerPids, pidAlive } from './conditions.js';
|
|
17
18
|
import { emergency } from './emergency.js';
|
|
18
19
|
import { setTrace } from './exec.js';
|
|
@@ -34,6 +35,8 @@ Commands:
|
|
|
34
35
|
largest directories and files, what dss can
|
|
35
36
|
free inside them, APFS volumes (never deletes)
|
|
36
37
|
docker scan|clean Only Docker: daemons, containers, nested daemons
|
|
38
|
+
apps remove APP.app Move explicitly selected macOS apps to Trash
|
|
39
|
+
apps leftovers Report possible app leftovers (read-only)
|
|
37
40
|
help, version
|
|
38
41
|
|
|
39
42
|
Scope:
|
|
@@ -43,6 +46,9 @@ Scope:
|
|
|
43
46
|
--depth N Docker nesting depth to recurse into (default 3)
|
|
44
47
|
--recursive docker: recurse into nested daemons
|
|
45
48
|
--container ID|NAME limit Docker work to these containers (repeat)
|
|
49
|
+
--container-root PATH project root inside every scanned container,
|
|
50
|
+
at any Docker depth; replaces the container
|
|
51
|
+
defaults (repeat)
|
|
46
52
|
--max-depth N directory depth for project search (default 6)
|
|
47
53
|
--scan-budget AGE time budget per filesystem environment (2m)
|
|
48
54
|
--only NAME|ID|PATH only these ecosystems, rules, kinds, item ids,
|
|
@@ -91,6 +97,7 @@ const OPTIONS = {
|
|
|
91
97
|
depth: { type: 'string' },
|
|
92
98
|
recursive: { type: 'boolean' },
|
|
93
99
|
container: { type: 'string', multiple: true },
|
|
100
|
+
'container-root': { type: 'string', multiple: true },
|
|
94
101
|
'max-depth': { type: 'string' },
|
|
95
102
|
'scan-budget': { type: 'string' },
|
|
96
103
|
only: { type: 'string', multiple: true },
|
|
@@ -170,6 +177,7 @@ function tierOf(values) {
|
|
|
170
177
|
export function toOptions(values, paths = []) {
|
|
171
178
|
return {
|
|
172
179
|
roots: paths.length > 0 ? paths : null,
|
|
180
|
+
containerRoots: values['container-root'],
|
|
173
181
|
docker: dockerMode(values),
|
|
174
182
|
dockerDepth: integer(values.depth, '--depth'),
|
|
175
183
|
containers: values.container,
|
|
@@ -539,6 +547,9 @@ function dispatch(cli, command, options, values) {
|
|
|
539
547
|
export async function runCli(argv, deps = {}) {
|
|
540
548
|
const io = deps.io ?? processIo();
|
|
541
549
|
try {
|
|
550
|
+
if (argv[0] === 'apps') {
|
|
551
|
+
return await runApps(argv.slice(1), io, deps.signal);
|
|
552
|
+
}
|
|
542
553
|
const { command, paths, values } = parseCli(argv);
|
|
543
554
|
if (command === 'help') {
|
|
544
555
|
io.stdout(USAGE);
|
package/src/docker/scan.js
CHANGED
|
@@ -632,6 +632,7 @@ class DockerScan {
|
|
|
632
632
|
this.result.items.push(...(await this.scanEnvironment(env)));
|
|
633
633
|
Object.assign(descriptor, {
|
|
634
634
|
scanStatus: env.scanStatus,
|
|
635
|
+
scope: env.scanScope ?? null,
|
|
635
636
|
hint: env.probeHint ?? null,
|
|
636
637
|
layerSource: env.writableLayer?.source ?? null,
|
|
637
638
|
timings: env.timings?.toJSON() ?? [],
|
package/src/docker/writable.js
CHANGED
|
@@ -213,9 +213,15 @@ export class WritableLayer {
|
|
|
213
213
|
)
|
|
214
214
|
)
|
|
215
215
|
: 0;
|
|
216
|
+
// Upper-layer blocks are measured directly: merged-view link data
|
|
217
|
+
// does not describe them, so the writable bytes are final.
|
|
218
|
+
const plain = { ...usage };
|
|
219
|
+
delete plain.links;
|
|
220
|
+
delete plain.sharedBytes;
|
|
216
221
|
result.set(target, {
|
|
217
|
-
...
|
|
222
|
+
...plain,
|
|
218
223
|
bytes,
|
|
224
|
+
allocatedBytes: bytes,
|
|
219
225
|
totalBytes: usage.bytes,
|
|
220
226
|
imageBytes: known ? Math.max(0, usage.bytes - bytes) : 0,
|
|
221
227
|
sizeUnknown: !known,
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hard-link aware accounting of reclaimable blocks.
|
|
3
|
+
*
|
|
4
|
+
* `du` (and every per-path walk) counts each inode once per measured path,
|
|
5
|
+
* including inodes that also have links outside that path. Deleting such a
|
|
6
|
+
* path removes a directory entry but releases no file blocks while another
|
|
7
|
+
* link remains. A file's blocks are reclaimable only when every one of its
|
|
8
|
+
* links is inside the selected deletion set; several links that are all
|
|
9
|
+
* inside the set are reclaimable once.
|
|
10
|
+
*
|
|
11
|
+
* Usage objects carry:
|
|
12
|
+
* - `allocatedBytes`: blocks reachable from the path (what `du` prints);
|
|
13
|
+
* - `sharedBytes`: blocks of multi-link files with a link outside the path;
|
|
14
|
+
* - `bytes`: `allocatedBytes - sharedBytes`, the physically reclaimable part;
|
|
15
|
+
* - `links`: the multi-link non-directory entries below the path
|
|
16
|
+
* (`{path, id, nlink, bytes}`), used to account a whole selection.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** POSIX shell fragment listing multi-link files below "$t" (GNU/busybox or BSD stat). */
|
|
20
|
+
export const LINKS_SCRIPT = `if stat -c %h / >/dev/null 2>&1; then
|
|
21
|
+
find "$t" -xdev ! -type d -links +1 -exec stat -c '%d:%i %h %b %B %n' -- {} + 2>/dev/null
|
|
22
|
+
else
|
|
23
|
+
find "$t" -xdev ! -type d -links +1 -exec stat -f '%d:%i %l %b 512 %N' {} + 2>/dev/null
|
|
24
|
+
fi`;
|
|
25
|
+
|
|
26
|
+
/** Parses LINKS_SCRIPT output lines (`dev:ino nlink blocks blocksize path`). */
|
|
27
|
+
export function parseLinks(text) {
|
|
28
|
+
const links = [];
|
|
29
|
+
for (const line of (text ?? '').split('\n')) {
|
|
30
|
+
const match = /^(\d+:\d+) (\d+) (\d+) (\d+) (.+)$/.exec(line);
|
|
31
|
+
if (match) {
|
|
32
|
+
links.push({
|
|
33
|
+
id: match[1],
|
|
34
|
+
nlink: Number(match[2]),
|
|
35
|
+
bytes: Number(match[3]) * Number(match[4]),
|
|
36
|
+
path: match[5],
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return links;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function groupLinks(groups) {
|
|
44
|
+
const inodes = new Map();
|
|
45
|
+
for (const [target, links] of groups) {
|
|
46
|
+
for (const link of links ?? []) {
|
|
47
|
+
if (!inodes.has(link.id)) {
|
|
48
|
+
inodes.set(link.id, {
|
|
49
|
+
nlink: link.nlink,
|
|
50
|
+
bytes: link.bytes,
|
|
51
|
+
paths: new Set(),
|
|
52
|
+
targets: new Set(),
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
const inode = inodes.get(link.id);
|
|
56
|
+
inode.nlink = Math.max(inode.nlink, link.nlink);
|
|
57
|
+
inode.paths.add(link.path);
|
|
58
|
+
inode.targets.add(target);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return inodes;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Accounts a deletion set measured per path.
|
|
66
|
+
* @param {Array<[string, {allocated: number, links?: object[]}]>} measured
|
|
67
|
+
* one entry per selected path: its per-path allocated bytes (each inode
|
|
68
|
+
* counted once within that path) and its multi-link entries
|
|
69
|
+
* @returns {{allocatedBytes: number, sharedBytes: number, bytes: number}}
|
|
70
|
+
* blocks reachable from the set (each inode once), blocks retained by
|
|
71
|
+
* links outside the set, and the physically reclaimable remainder
|
|
72
|
+
*/
|
|
73
|
+
export function accountLinks(measured) {
|
|
74
|
+
let allocated = measured.reduce((sum, [, entry]) => sum + entry.allocated, 0);
|
|
75
|
+
let shared = 0;
|
|
76
|
+
const inodes = groupLinks(
|
|
77
|
+
measured.map(([target, entry]) => [target, entry.links])
|
|
78
|
+
);
|
|
79
|
+
for (const inode of inodes.values()) {
|
|
80
|
+
// Per-path walks counted this inode once in every path containing it.
|
|
81
|
+
allocated -= (inode.targets.size - 1) * inode.bytes;
|
|
82
|
+
if (inode.paths.size < inode.nlink) {
|
|
83
|
+
shared += inode.bytes;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
allocated = Math.max(0, allocated);
|
|
87
|
+
shared = Math.min(shared, allocated);
|
|
88
|
+
return {
|
|
89
|
+
allocatedBytes: allocated,
|
|
90
|
+
sharedBytes: shared,
|
|
91
|
+
bytes: allocated - shared,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Adds allocated/shared/reclaimable fields to one path's usage. */
|
|
96
|
+
export function withLinkAccounting(usage, links) {
|
|
97
|
+
if (!usage) {
|
|
98
|
+
return usage;
|
|
99
|
+
}
|
|
100
|
+
const allocated = usage.allocatedBytes ?? usage.bytes;
|
|
101
|
+
const accounted = accountLinks([['', { allocated, links }]]);
|
|
102
|
+
return {
|
|
103
|
+
...usage,
|
|
104
|
+
bytes: accounted.bytes,
|
|
105
|
+
allocatedBytes: accounted.allocatedBytes,
|
|
106
|
+
sharedBytes: accounted.sharedBytes,
|
|
107
|
+
...(links?.length ? { links } : {}),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Reclaimable bytes of several selected paths of one environment, from
|
|
113
|
+
* their usages. Links between selected paths are reclaimable; links
|
|
114
|
+
* outside the selection are not.
|
|
115
|
+
*/
|
|
116
|
+
export function selectionBytes(usages, selected) {
|
|
117
|
+
const measured = selected
|
|
118
|
+
.map((target) => [target, usages.get(target)])
|
|
119
|
+
.filter(([, usage]) => usage)
|
|
120
|
+
.map(([target, usage]) => [
|
|
121
|
+
target,
|
|
122
|
+
{
|
|
123
|
+
allocated: usage.allocatedBytes ?? usage.bytes,
|
|
124
|
+
links: usage.links,
|
|
125
|
+
},
|
|
126
|
+
]);
|
|
127
|
+
return accountLinks(measured);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Item size fields of the usages of one item's paths: `bytes` is the
|
|
132
|
+
* reclaimable total; `allocatedBytes`/`sharedBytes` are added only when
|
|
133
|
+
* hard links outside the item retain blocks.
|
|
134
|
+
*/
|
|
135
|
+
export function itemSize(entries) {
|
|
136
|
+
const usages = new Map(entries.filter(([, usage]) => usage));
|
|
137
|
+
const accounted = selectionBytes(usages, [...usages.keys()]);
|
|
138
|
+
return accounted.sharedBytes > 0 ? accounted : { bytes: accounted.bytes };
|
|
139
|
+
}
|
package/src/env/local.js
CHANGED
|
@@ -100,6 +100,7 @@ import { hostExecutor } from '../exec.js';
|
|
|
100
100
|
import { parseBundleIdentifier } from '../app-owners.js';
|
|
101
101
|
import { matchesGlob } from '../paths.js';
|
|
102
102
|
import { mapLimit } from './parallel.js';
|
|
103
|
+
import { withLinkAccounting } from './hardlinks.js';
|
|
103
104
|
import { directoryFingerprint, SIZE_MAX_AGE } from '../scan-state.js';
|
|
104
105
|
import { indexedDirectories } from './platform-index.js';
|
|
105
106
|
|
|
@@ -213,6 +214,15 @@ async function makeWritable(target) {
|
|
|
213
214
|
}
|
|
214
215
|
}
|
|
215
216
|
|
|
217
|
+
function linkOf(stats, file) {
|
|
218
|
+
return {
|
|
219
|
+
path: file,
|
|
220
|
+
id: `${stats.dev}:${stats.ino}`,
|
|
221
|
+
nlink: stats.nlink,
|
|
222
|
+
bytes: diskBytes(stats),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
216
226
|
export class LocalEnv {
|
|
217
227
|
/**
|
|
218
228
|
* @param {{homes?: string[], tmpDirs?: string[], executor?: object,
|
|
@@ -322,6 +332,7 @@ export class LocalEnv {
|
|
|
322
332
|
mtimeMs: stats.mtimeMs,
|
|
323
333
|
ctimeMs: stats.ctimeMs,
|
|
324
334
|
inode: `${stats.dev}:${stats.ino}`,
|
|
335
|
+
nlink: stats.nlink,
|
|
325
336
|
uid: stats.uid,
|
|
326
337
|
gid: stats.gid,
|
|
327
338
|
mode: stats.mode,
|
|
@@ -440,14 +451,16 @@ export class LocalEnv {
|
|
|
440
451
|
return null;
|
|
441
452
|
}
|
|
442
453
|
const seen = new Set();
|
|
454
|
+
const links = [];
|
|
443
455
|
let bytes = 0;
|
|
444
456
|
let apparentBytes = 0;
|
|
445
457
|
let files = 0;
|
|
446
458
|
let errors = 0;
|
|
447
459
|
let newestMtimeMs = 0;
|
|
448
|
-
const account = (stats) => {
|
|
460
|
+
const account = (stats, full) => {
|
|
449
461
|
if (stats.nlink > 1 && !stats.isDirectory()) {
|
|
450
462
|
const key = `${stats.dev}:${stats.ino}`;
|
|
463
|
+
links.push(linkOf(stats, full));
|
|
451
464
|
if (seen.has(key)) {
|
|
452
465
|
return;
|
|
453
466
|
}
|
|
@@ -463,7 +476,7 @@ export class LocalEnv {
|
|
|
463
476
|
newestMtimeMs = Math.max(newestMtimeMs, stats.mtimeMs);
|
|
464
477
|
}
|
|
465
478
|
};
|
|
466
|
-
account(root);
|
|
479
|
+
account(root, target);
|
|
467
480
|
const visit = async (dir) => {
|
|
468
481
|
let names;
|
|
469
482
|
try {
|
|
@@ -489,7 +502,7 @@ export class LocalEnv {
|
|
|
489
502
|
) {
|
|
490
503
|
continue;
|
|
491
504
|
}
|
|
492
|
-
account(stats);
|
|
505
|
+
account(stats, full);
|
|
493
506
|
if (stats.isDirectory()) {
|
|
494
507
|
subdirs.push(full);
|
|
495
508
|
}
|
|
@@ -511,7 +524,10 @@ export class LocalEnv {
|
|
|
511
524
|
if (files === 0) {
|
|
512
525
|
newestMtimeMs = root.mtimeMs;
|
|
513
526
|
}
|
|
514
|
-
return
|
|
527
|
+
return withLinkAccounting(
|
|
528
|
+
{ bytes, apparentBytes, files, errors, newestMtimeMs },
|
|
529
|
+
links
|
|
530
|
+
);
|
|
515
531
|
}
|
|
516
532
|
|
|
517
533
|
/**
|
|
@@ -734,6 +750,29 @@ export class LocalEnv {
|
|
|
734
750
|
return usage;
|
|
735
751
|
}
|
|
736
752
|
|
|
753
|
+
/** Multi-link files below `target`, which `du` cannot attribute. */
|
|
754
|
+
async #multiLinks(target) {
|
|
755
|
+
const result = await this.run([
|
|
756
|
+
'find',
|
|
757
|
+
target,
|
|
758
|
+
'-xdev',
|
|
759
|
+
'!',
|
|
760
|
+
'-type',
|
|
761
|
+
'd',
|
|
762
|
+
'-links',
|
|
763
|
+
'+1',
|
|
764
|
+
'-print0',
|
|
765
|
+
]);
|
|
766
|
+
const files = result.stdout.split('\0').filter(Boolean);
|
|
767
|
+
const stats = await mapLimit(files, 32, async (file) => [
|
|
768
|
+
file,
|
|
769
|
+
await this.#lstat(file),
|
|
770
|
+
]);
|
|
771
|
+
return stats
|
|
772
|
+
.filter(([, entry]) => entry)
|
|
773
|
+
.map(([file, entry]) => linkOf(entry, file));
|
|
774
|
+
}
|
|
775
|
+
|
|
737
776
|
async quickUsage(target) {
|
|
738
777
|
const stats = await this.stat(target);
|
|
739
778
|
if (!stats) {
|
|
@@ -755,13 +794,16 @@ export class LocalEnv {
|
|
|
755
794
|
if (result.code !== 0 || !match) {
|
|
756
795
|
return this.rawUsage(target);
|
|
757
796
|
}
|
|
758
|
-
const usage =
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
797
|
+
const usage = withLinkAccounting(
|
|
798
|
+
{
|
|
799
|
+
bytes: Number(match[1]) * 1024,
|
|
800
|
+
apparentBytes: Number(match[1]) * 1024,
|
|
801
|
+
newestMtimeMs: 0,
|
|
802
|
+
files: 0,
|
|
803
|
+
errors: 0,
|
|
804
|
+
},
|
|
805
|
+
await this.#multiLinks(target)
|
|
806
|
+
);
|
|
765
807
|
if (this.scanMetrics) {
|
|
766
808
|
this.scanMetrics.directoriesSized++;
|
|
767
809
|
}
|
package/src/env/roots.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Effective scan roots of one environment and the scope report that makes
|
|
3
|
+
* them visible.
|
|
4
|
+
*
|
|
5
|
+
* Root semantics (also documented in README "Scan roots"):
|
|
6
|
+
* - host: `roots` (positional paths) or, when none, the homes and temporary
|
|
7
|
+
* directories;
|
|
8
|
+
* - container (every Docker level): `containerRoots` replaces the defaults
|
|
9
|
+
* when given; otherwise the defaults (homes, temporary directories and
|
|
10
|
+
* the common workspace paths in CONTAINER_ROOTS that exist) plus every
|
|
11
|
+
* host `roots` path that also exists inside that container. Host and
|
|
12
|
+
* container namespaces differ, so a host root absent from a container is
|
|
13
|
+
* reported as `absent`; paths are never guessed or remapped.
|
|
14
|
+
* The same requested roots reach the traversal policy (mount selection)
|
|
15
|
+
* and project discovery, so both always agree.
|
|
16
|
+
*/
|
|
17
|
+
import { existingPaths, matchesExcluded } from '../paths.js';
|
|
18
|
+
|
|
19
|
+
/** Common project locations inside containers, scanned when present. */
|
|
20
|
+
export const CONTAINER_ROOTS = [
|
|
21
|
+
'/workspace',
|
|
22
|
+
'/workspaces',
|
|
23
|
+
'/app',
|
|
24
|
+
'/src',
|
|
25
|
+
'/code',
|
|
26
|
+
'/project',
|
|
27
|
+
'/usr/src/app',
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
/** Roots explicitly requested for `env` (null when only defaults apply). */
|
|
31
|
+
export function requestedRoots(env, options, overrides = {}) {
|
|
32
|
+
if (overrides.roots) {
|
|
33
|
+
return overrides.roots;
|
|
34
|
+
}
|
|
35
|
+
if (env.kind === 'container') {
|
|
36
|
+
return options.containerRoots ?? options.roots ?? null;
|
|
37
|
+
}
|
|
38
|
+
return options.roots ?? null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const unique = (list) => [...new Set(list)];
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Resolves the roots project discovery walks plus one scope entry per
|
|
45
|
+
* considered root.
|
|
46
|
+
* @returns {Promise<{roots: string[], entries: object[]}>}
|
|
47
|
+
*/
|
|
48
|
+
export async function resolveRoots(env, options, overrides, homes, tmpDirs) {
|
|
49
|
+
const entries = [];
|
|
50
|
+
const add = (path, source) => {
|
|
51
|
+
if (!entries.some((entry) => entry.path === path)) {
|
|
52
|
+
entries.push({ path, source });
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
if (overrides.roots) {
|
|
56
|
+
overrides.roots.forEach((root) => add(root, 'roots'));
|
|
57
|
+
} else if (env.kind === 'container' && options.containerRoots) {
|
|
58
|
+
options.containerRoots.forEach((root) => add(root, 'container-roots'));
|
|
59
|
+
} else if (env.kind !== 'container' && options.roots) {
|
|
60
|
+
options.roots.forEach((root) => add(root, 'roots'));
|
|
61
|
+
} else {
|
|
62
|
+
homes.forEach((root) => add(root, 'home'));
|
|
63
|
+
tmpDirs.forEach((root) => add(root, 'tmp'));
|
|
64
|
+
if (env.kind === 'container') {
|
|
65
|
+
const present = await existingPaths(env, CONTAINER_ROOTS);
|
|
66
|
+
CONTAINER_ROOTS.filter((root) => present.has(root)).forEach((root) =>
|
|
67
|
+
add(root, 'container-default')
|
|
68
|
+
);
|
|
69
|
+
(options.roots ?? []).forEach((root) => add(root, 'roots'));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
await classify(env, options, entries);
|
|
73
|
+
return {
|
|
74
|
+
roots: unique(
|
|
75
|
+
entries.filter((entry) => entry.status === 'pending').map((e) => e.path)
|
|
76
|
+
),
|
|
77
|
+
entries,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function classify(env, options, entries) {
|
|
82
|
+
const policy = env.scanPolicy;
|
|
83
|
+
const allowed = entries.filter((entry) => {
|
|
84
|
+
if (matchesExcluded(entry.path, options.exclude ?? [], env.path)) {
|
|
85
|
+
entry.status = 'excluded';
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
if (policy && !policy.allows(entry.path)) {
|
|
89
|
+
entry.status = 'pruned';
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
return true;
|
|
93
|
+
});
|
|
94
|
+
const present = allowed.length
|
|
95
|
+
? await existingPaths(
|
|
96
|
+
env,
|
|
97
|
+
allowed.map((entry) => entry.path)
|
|
98
|
+
)
|
|
99
|
+
: new Set();
|
|
100
|
+
for (const entry of allowed) {
|
|
101
|
+
entry.status = present.has(entry.path) ? 'pending' : 'absent';
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Scope descriptor stored on the environment and copied into reports. */
|
|
106
|
+
export function scanScope(env, options, entries) {
|
|
107
|
+
return {
|
|
108
|
+
roots: entries.map((entry) => ({ ...entry })),
|
|
109
|
+
maxDepth: options.maxDepth,
|
|
110
|
+
exclude: [...(options.exclude ?? [])],
|
|
111
|
+
prunedMounts: [...(env.scanPolicy?.pruned ?? [])],
|
|
112
|
+
note:
|
|
113
|
+
'scanStatus "complete" means complete within these roots, maxDepth, ' +
|
|
114
|
+
'exclusions and mount boundaries; fixed cache, toolchain and agent ' +
|
|
115
|
+
'locations are checked independently of roots',
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Final per-root status when the scanners of the environment finish. */
|
|
120
|
+
export function finishScope(env, ranProjects) {
|
|
121
|
+
const scope = env.scanScope;
|
|
122
|
+
if (!scope) {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const denied = new Set(env.accessErrors?.keys() ?? []);
|
|
126
|
+
for (const root of scope.roots) {
|
|
127
|
+
if (root.status !== 'pending') {
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (denied.has(root.path)) {
|
|
131
|
+
root.status = 'inaccessible';
|
|
132
|
+
} else if (!ranProjects) {
|
|
133
|
+
root.status = 'not-searched';
|
|
134
|
+
} else {
|
|
135
|
+
root.status = env.scanStatus === 'complete' ? 'searched' : 'partial';
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
package/src/env/shell.js
CHANGED
|
@@ -20,6 +20,7 @@ import { sharedWritableLayer } from '../docker/writable.js';
|
|
|
20
20
|
import { containerExecutor, shellQuote, trace } from '../exec.js';
|
|
21
21
|
import { DEFAULT_SKIP_NAMES, parseDf, processAliases } from './local.js';
|
|
22
22
|
import { batches } from './batches.js';
|
|
23
|
+
import { LINKS_SCRIPT, parseLinks, withLinkAccounting } from './hardlinks.js';
|
|
23
24
|
import { signalExecutor } from './scope.js';
|
|
24
25
|
|
|
25
26
|
const STAT_FORMAT = '%F|%s|%b|%B|%Y|%n';
|
|
@@ -44,6 +45,8 @@ const USAGE_MANY_SCRIPT = `for t in "$@"; do
|
|
|
44
45
|
du -skx -- "$t" 2>/dev/null | cut -f1
|
|
45
46
|
find "$t" -xdev ! -type d -exec stat -c %Y {} + 2>/dev/null | awk 'BEGIN{m=0;n=0}{n++; if ($1>m) m=$1} END{print n, m}'
|
|
46
47
|
stat -c %Y -- "$t"
|
|
48
|
+
echo links
|
|
49
|
+
${LINKS_SCRIPT}
|
|
47
50
|
done
|
|
48
51
|
true`;
|
|
49
52
|
|
|
@@ -120,10 +123,18 @@ function splitRecords(output) {
|
|
|
120
123
|
}
|
|
121
124
|
|
|
122
125
|
function parseUsage(body) {
|
|
123
|
-
const [
|
|
126
|
+
const [head, links = ''] = body.split('\nlinks\n');
|
|
127
|
+
const [kib, counts = '', rootMtime] = head.trim().split('\n');
|
|
124
128
|
const [files, newest] = counts.trim().split(/\s+/).map(Number);
|
|
125
129
|
const bytes = Number(kib) * 1024;
|
|
126
130
|
const newestSeconds = files > 0 ? newest : Number(rootMtime);
|
|
131
|
+
return withLinkAccounting(
|
|
132
|
+
usageFields(bytes, files, newestSeconds),
|
|
133
|
+
parseLinks(links)
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function usageFields(bytes, files, newestSeconds) {
|
|
127
138
|
return {
|
|
128
139
|
bytes: Number.isFinite(bytes) ? bytes : 0,
|
|
129
140
|
apparentBytes: Number.isFinite(bytes) ? bytes : 0,
|
|
@@ -292,7 +303,7 @@ export class ShellEnv {
|
|
|
292
303
|
const result = await this.executor.run([
|
|
293
304
|
'stat',
|
|
294
305
|
'-c',
|
|
295
|
-
`${STAT_FORMAT}|%d:%i:%u:%g:%f`,
|
|
306
|
+
`${STAT_FORMAT}|%d:%i:%u:%g:%f:%h`,
|
|
296
307
|
'--',
|
|
297
308
|
...batch,
|
|
298
309
|
]);
|
|
@@ -310,6 +321,7 @@ export class ShellEnv {
|
|
|
310
321
|
identity.length > 4
|
|
311
322
|
? Number.parseInt(identity[4], 16)
|
|
312
323
|
: undefined,
|
|
324
|
+
nlink: identity.length > 5 ? Number(identity[5]) : undefined,
|
|
313
325
|
});
|
|
314
326
|
}
|
|
315
327
|
}
|
package/src/index.d.ts
CHANGED
|
@@ -9,6 +9,13 @@ export type Duration = number | string;
|
|
|
9
9
|
export interface ScanOptions {
|
|
10
10
|
/** Directories to search for projects (default: homes and tmp dirs). */
|
|
11
11
|
roots?: string[] | string | null;
|
|
12
|
+
/**
|
|
13
|
+
* Project roots inside every scanned container (all Docker depths).
|
|
14
|
+
* Replaces the container defaults; without it containers search their
|
|
15
|
+
* homes, temporary directories, common workspace paths and every `roots`
|
|
16
|
+
* path that exists inside them.
|
|
17
|
+
*/
|
|
18
|
+
containerRoots?: string[] | string | null;
|
|
12
19
|
/** Directory depth for project search. */
|
|
13
20
|
maxDepth?: number;
|
|
14
21
|
/** Time budget per filesystem environment (`2m`); incomplete findings are kept. */
|
|
@@ -117,7 +124,15 @@ export interface Item {
|
|
|
117
124
|
description: string;
|
|
118
125
|
path: string | null;
|
|
119
126
|
paths: string[];
|
|
127
|
+
/**
|
|
128
|
+
* Physically reclaimable bytes: blocks of hard-linked files with a link
|
|
129
|
+
* outside the item are not counted.
|
|
130
|
+
*/
|
|
120
131
|
bytes: number;
|
|
132
|
+
/** Blocks reachable from the item (each inode once); set with sharedBytes. */
|
|
133
|
+
allocatedBytes?: number;
|
|
134
|
+
/** Blocks retained by hard links outside the item; present when non-zero. */
|
|
135
|
+
sharedBytes?: number;
|
|
121
136
|
/** Cached size is advisory; cleanup always measures again. */
|
|
122
137
|
sizeEstimated?: boolean;
|
|
123
138
|
/** Merged filesystem bytes, including image data, when measured in Docker. */
|
|
@@ -198,9 +213,32 @@ export interface EnvironmentDescriptor {
|
|
|
198
213
|
| 'budget-exceeded'
|
|
199
214
|
| 'error'
|
|
200
215
|
| null;
|
|
216
|
+
/** What `scanStatus: complete` covers in this environment. */
|
|
217
|
+
scope?: ScanScope | null;
|
|
201
218
|
layerSource?: string | null;
|
|
202
219
|
}
|
|
203
220
|
|
|
221
|
+
export interface ScanScope {
|
|
222
|
+
roots: Array<{
|
|
223
|
+
path: string;
|
|
224
|
+
source: 'roots' | 'container-roots' | 'home' | 'tmp' | 'container-default';
|
|
225
|
+
status:
|
|
226
|
+
| 'searched'
|
|
227
|
+
| 'partial'
|
|
228
|
+
| 'absent'
|
|
229
|
+
| 'inaccessible'
|
|
230
|
+
| 'pruned'
|
|
231
|
+
| 'excluded'
|
|
232
|
+
| 'not-searched'
|
|
233
|
+
| 'pending';
|
|
234
|
+
}>;
|
|
235
|
+
maxDepth: number;
|
|
236
|
+
exclude: string[];
|
|
237
|
+
/** Mount points never traversed (unselected, read-only or overlay). */
|
|
238
|
+
prunedMounts: string[];
|
|
239
|
+
note: string;
|
|
240
|
+
}
|
|
241
|
+
|
|
204
242
|
export interface TierTotal {
|
|
205
243
|
items: number;
|
|
206
244
|
bytes: number;
|
|
@@ -260,6 +298,10 @@ export interface AuditEntry {
|
|
|
260
298
|
freedBytes: number;
|
|
261
299
|
/** True when authoritative before/after allocation could not be measured. */
|
|
262
300
|
freedEstimated?: boolean;
|
|
301
|
+
/** `'hardlink-aware-estimate'`: freedBytes is the link-aware selection estimate. */
|
|
302
|
+
freedAccounting?: 'hardlink-aware-estimate';
|
|
303
|
+
/** Blocks of deleted files that stay allocated through outside hard links. */
|
|
304
|
+
sharedBytes?: number;
|
|
263
305
|
durationMs: number;
|
|
264
306
|
backup?: string;
|
|
265
307
|
[key: string]: unknown;
|
package/src/mac-apps.js
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/** Explicit macOS application removal and conservative leftover inventory. */
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { parseArgs } from 'node:util';
|
|
6
|
+
import { LocalEnv } from './env/local.js';
|
|
7
|
+
import { LivenessProbe } from './liveness.js';
|
|
8
|
+
import { parseBundleIdentifier } from './app-owners.js';
|
|
9
|
+
import { runProcess } from './exec.js';
|
|
10
|
+
import { startAudit, writeAudit } from './audit.js';
|
|
11
|
+
|
|
12
|
+
const TRASH = `ObjC.import('Foundation');
|
|
13
|
+
function run(args) {
|
|
14
|
+
const url = $.NSURL.fileURLWithPath(args[0]);
|
|
15
|
+
if (!$.NSFileManager.defaultManager.trashItemAtURLResultingItemURLError(url, null, null))
|
|
16
|
+
throw new Error('macOS refused the Trash operation');
|
|
17
|
+
return 'In Trash';
|
|
18
|
+
}`;
|
|
19
|
+
|
|
20
|
+
export async function appIdentity(target, home = os.homedir()) {
|
|
21
|
+
const full = path.resolve(target);
|
|
22
|
+
const allowed = ['/Applications', path.join(home, 'Applications')].map(
|
|
23
|
+
(root) => path.resolve(root)
|
|
24
|
+
);
|
|
25
|
+
if (
|
|
26
|
+
!allowed.some((root) => full.startsWith(`${root}${path.sep}`)) ||
|
|
27
|
+
!full.endsWith('.app')
|
|
28
|
+
) {
|
|
29
|
+
throw new Error(
|
|
30
|
+
'Select an explicit .app inside /Applications or ~/Applications'
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
if ((await fs.realpath(full)) !== full) {
|
|
34
|
+
throw new Error('Symlinked app paths are not supported');
|
|
35
|
+
}
|
|
36
|
+
if (
|
|
37
|
+
full
|
|
38
|
+
.split(path.sep)
|
|
39
|
+
.slice(0, -1)
|
|
40
|
+
.some((part) => part.endsWith('.app'))
|
|
41
|
+
) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
'Nested application components cannot be removed separately'
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
const stats = await fs.lstat(full);
|
|
47
|
+
if (!stats.isDirectory()) {
|
|
48
|
+
throw new Error('Application must be a directory');
|
|
49
|
+
}
|
|
50
|
+
const plist = await runProcess([
|
|
51
|
+
'/usr/bin/plutil',
|
|
52
|
+
'-convert',
|
|
53
|
+
'xml1',
|
|
54
|
+
'-o',
|
|
55
|
+
'-',
|
|
56
|
+
path.join(full, 'Contents/Info.plist'),
|
|
57
|
+
]);
|
|
58
|
+
const id = parseBundleIdentifier(plist.stdout);
|
|
59
|
+
if (plist.code || !id) {
|
|
60
|
+
throw new Error('Application identity cannot be verified');
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
path: full,
|
|
64
|
+
id,
|
|
65
|
+
device: stats.dev,
|
|
66
|
+
inode: stats.ino,
|
|
67
|
+
webApp: plist.stdout.includes('CrAppModeShortcutID'),
|
|
68
|
+
hasReceipt: await fs
|
|
69
|
+
.access(path.join(full, 'Contents/_MASReceipt/receipt'))
|
|
70
|
+
.then(
|
|
71
|
+
() => true,
|
|
72
|
+
() => false
|
|
73
|
+
),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function leftoverCategory(kind) {
|
|
78
|
+
return ['Caches', 'Logs'].includes(kind)
|
|
79
|
+
? 'disposable-after-activity-check'
|
|
80
|
+
: 'user-decision';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function installedIds(root, ids, errors, depth = 0) {
|
|
84
|
+
if (depth > 4) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const entries = await fs.readdir(root, { withFileTypes: true });
|
|
88
|
+
for (const entry of entries) {
|
|
89
|
+
if (!entry.isDirectory()) {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const full = path.join(root, entry.name);
|
|
93
|
+
if (entry.name.endsWith('.app')) {
|
|
94
|
+
try {
|
|
95
|
+
const result = await runProcess([
|
|
96
|
+
'/usr/bin/plutil',
|
|
97
|
+
'-convert',
|
|
98
|
+
'xml1',
|
|
99
|
+
'-o',
|
|
100
|
+
'-',
|
|
101
|
+
path.join(full, 'Contents/Info.plist'),
|
|
102
|
+
]);
|
|
103
|
+
const id = parseBundleIdentifier(result.stdout);
|
|
104
|
+
if (!id) {
|
|
105
|
+
throw new Error('Unreadable app identity');
|
|
106
|
+
}
|
|
107
|
+
ids.add(id);
|
|
108
|
+
} catch {
|
|
109
|
+
errors.push(`Cannot inventory ${full}`);
|
|
110
|
+
}
|
|
111
|
+
} else {
|
|
112
|
+
await installedIds(full, ids, errors, depth + 1);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function appLeftovers(home = os.homedir()) {
|
|
118
|
+
const ids = new Set();
|
|
119
|
+
const errors = [];
|
|
120
|
+
for (const root of [
|
|
121
|
+
'/Applications',
|
|
122
|
+
'/System/Applications',
|
|
123
|
+
path.join(home, 'Applications'),
|
|
124
|
+
]) {
|
|
125
|
+
try {
|
|
126
|
+
await installedIds(root, ids, errors);
|
|
127
|
+
} catch (error) {
|
|
128
|
+
errors.push(`${root}: ${error.message}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const items = [];
|
|
132
|
+
for (const kind of [
|
|
133
|
+
'Caches',
|
|
134
|
+
'Logs',
|
|
135
|
+
'Application Support',
|
|
136
|
+
'Containers',
|
|
137
|
+
'Group Containers',
|
|
138
|
+
'Preferences',
|
|
139
|
+
'Saved Application State',
|
|
140
|
+
]) {
|
|
141
|
+
const root = path.join(home, 'Library', kind);
|
|
142
|
+
const entries = await fs.readdir(root).catch(() => []);
|
|
143
|
+
for (const name of entries) {
|
|
144
|
+
const id = name.replace(/\.(plist|savedState)$/, '');
|
|
145
|
+
if (
|
|
146
|
+
(kind !== 'Logs' &&
|
|
147
|
+
!/^(?:com|org|net|io|dev|ai|app|at|ru|us|notion)\.[a-zA-Z0-9_.-]+$/.test(
|
|
148
|
+
id
|
|
149
|
+
)) ||
|
|
150
|
+
id.startsWith('com.apple.')
|
|
151
|
+
) {
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (
|
|
155
|
+
[...ids].some(
|
|
156
|
+
(installed) =>
|
|
157
|
+
installed === id ||
|
|
158
|
+
installed.startsWith(`${id}.`) ||
|
|
159
|
+
id.startsWith(`${installed}.`)
|
|
160
|
+
)
|
|
161
|
+
) {
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
items.push({
|
|
165
|
+
path: path.join(root, name),
|
|
166
|
+
category: leftoverCategory(kind),
|
|
167
|
+
status: 'possible-leftover',
|
|
168
|
+
reason:
|
|
169
|
+
'No matching bundle in standard application folders; app may be elsewhere or ownership shared',
|
|
170
|
+
automaticDeletion: false,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
items,
|
|
176
|
+
errors,
|
|
177
|
+
note: 'Read-only candidates, not proof of uninstall. Logs can contain diagnostic history. No support data is automatically removed.',
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function trashApp(identity) {
|
|
182
|
+
const fresh = await appIdentity(identity.path);
|
|
183
|
+
if (
|
|
184
|
+
fresh.inode !== identity.inode ||
|
|
185
|
+
fresh.device !== identity.device ||
|
|
186
|
+
fresh.id !== identity.id
|
|
187
|
+
) {
|
|
188
|
+
throw new Error('Application changed during removal');
|
|
189
|
+
}
|
|
190
|
+
const env = new LocalEnv();
|
|
191
|
+
const probe = new LivenessProbe(env, { staleAgeMs: 0 });
|
|
192
|
+
await probe.refresh(true);
|
|
193
|
+
await probe.resolve([identity.path]);
|
|
194
|
+
const blocker = probe.busyReason({
|
|
195
|
+
paths: [identity.path],
|
|
196
|
+
checks: { mtime: false },
|
|
197
|
+
});
|
|
198
|
+
if (blocker) {
|
|
199
|
+
throw new Error(blocker);
|
|
200
|
+
}
|
|
201
|
+
const result = await runProcess([
|
|
202
|
+
'/usr/bin/osascript',
|
|
203
|
+
'-l',
|
|
204
|
+
'JavaScript',
|
|
205
|
+
'-e',
|
|
206
|
+
TRASH,
|
|
207
|
+
identity.path,
|
|
208
|
+
]);
|
|
209
|
+
if (result.code) {
|
|
210
|
+
throw new Error(result.stderr || 'macOS Trash operation failed');
|
|
211
|
+
}
|
|
212
|
+
if (
|
|
213
|
+
await fs.lstat(identity.path).then(
|
|
214
|
+
() => true,
|
|
215
|
+
() => false
|
|
216
|
+
)
|
|
217
|
+
) {
|
|
218
|
+
throw new Error('Trash operation did not remove the original path');
|
|
219
|
+
}
|
|
220
|
+
return result.stdout.trim();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function removalDryRun(values) {
|
|
224
|
+
return !values.yes || Boolean(values['dry-run']);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export async function runApps(argv, io, signal) {
|
|
228
|
+
if (process.platform !== 'darwin') {
|
|
229
|
+
throw new Error('apps is currently macOS-only');
|
|
230
|
+
}
|
|
231
|
+
const { positionals, values } = parseArgs({
|
|
232
|
+
args: argv,
|
|
233
|
+
allowPositionals: true,
|
|
234
|
+
options: {
|
|
235
|
+
yes: { type: 'boolean' },
|
|
236
|
+
'dry-run': { type: 'boolean' },
|
|
237
|
+
'launcher-only': { type: 'boolean' },
|
|
238
|
+
json: { type: 'boolean' },
|
|
239
|
+
'audit-dir': { type: 'string' },
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
const [command, ...targets] = positionals;
|
|
243
|
+
if (command === 'leftovers') {
|
|
244
|
+
io.stdout(JSON.stringify(await appLeftovers(), null, 2));
|
|
245
|
+
return 0;
|
|
246
|
+
}
|
|
247
|
+
if (command !== 'remove' || !targets.length) {
|
|
248
|
+
throw new Error(
|
|
249
|
+
'Use apps remove /absolute/App.app [--yes] [--launcher-only], or apps leftovers'
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
const audit = startAudit('apps-remove', {
|
|
253
|
+
dryRun: removalDryRun(values),
|
|
254
|
+
});
|
|
255
|
+
for (const target of targets) {
|
|
256
|
+
if (signal?.aborted) {
|
|
257
|
+
throw new Error('Aborted');
|
|
258
|
+
}
|
|
259
|
+
try {
|
|
260
|
+
const identity = await appIdentity(target);
|
|
261
|
+
if (identity.hasReceipt) {
|
|
262
|
+
throw new Error('App Store app: use the macOS uninstall flow');
|
|
263
|
+
}
|
|
264
|
+
if (identity.webApp && !values['launcher-only']) {
|
|
265
|
+
throw new Error(
|
|
266
|
+
'Use Chrome > chrome://apps > Uninstall first; --launcher-only explicitly permits the Trash fallback, preserving browser data and registration'
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
const destination = audit.dryRun ? null : await trashApp(identity);
|
|
270
|
+
audit.entries.push({
|
|
271
|
+
...identity,
|
|
272
|
+
status: audit.dryRun ? 'would-trash' : 'trashed',
|
|
273
|
+
destination,
|
|
274
|
+
});
|
|
275
|
+
} catch (error) {
|
|
276
|
+
audit.entries.push({
|
|
277
|
+
path: target,
|
|
278
|
+
status: 'blocked',
|
|
279
|
+
reason: error.message,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
const file = await writeAudit(audit, { auditDir: values['audit-dir'] });
|
|
284
|
+
io.stdout(JSON.stringify({ ...audit, auditFile: file }, null, 2));
|
|
285
|
+
return audit.entries.some((entry) => entry.status === 'blocked') ? 1 : 0;
|
|
286
|
+
}
|
package/src/options.js
CHANGED
|
@@ -8,6 +8,7 @@ import { parseDuration, parsePercent, parseSize } from './units.js';
|
|
|
8
8
|
|
|
9
9
|
export const DEFAULTS = {
|
|
10
10
|
roots: null,
|
|
11
|
+
containerRoots: null,
|
|
11
12
|
maxDepth: 6,
|
|
12
13
|
staleAge: '1h',
|
|
13
14
|
inactive: '30d',
|
|
@@ -78,9 +79,11 @@ export function resolveOptions(input = {}) {
|
|
|
78
79
|
throw new Error('scanBudget must be a positive finite duration');
|
|
79
80
|
}
|
|
80
81
|
const roots = toList(merged.roots);
|
|
82
|
+
const containerRoots = toList(merged.containerRoots);
|
|
81
83
|
return {
|
|
82
84
|
...merged,
|
|
83
85
|
roots: roots.length > 0 ? roots : null,
|
|
86
|
+
containerRoots: containerRoots.length > 0 ? containerRoots : null,
|
|
84
87
|
maxDepth: Number(merged.maxDepth),
|
|
85
88
|
dockerDepth: Number(merged.dockerDepth),
|
|
86
89
|
staleAgeMs: toMs(staleAge),
|
package/src/report.js
CHANGED
|
@@ -455,6 +455,18 @@ function swapLines(swap) {
|
|
|
455
455
|
];
|
|
456
456
|
}
|
|
457
457
|
|
|
458
|
+
/** Requested roots that were not searched, so coverage gaps are visible. */
|
|
459
|
+
function scopeLines(env) {
|
|
460
|
+
const skipped = (env.scope?.roots ?? []).filter(
|
|
461
|
+
(root) =>
|
|
462
|
+
['roots', 'container-roots'].includes(root.source) &&
|
|
463
|
+
!['searched', 'pending'].includes(root.status)
|
|
464
|
+
);
|
|
465
|
+
return skipped.map(
|
|
466
|
+
(root) => ` ${env.label}: root ${root.path} ${root.status}`
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
|
|
458
470
|
function environmentStatusLines(env) {
|
|
459
471
|
const lines = [];
|
|
460
472
|
if (env.layerSource) {
|
|
@@ -470,6 +482,7 @@ function environmentStatusLines(env) {
|
|
|
470
482
|
if (env.hint) {
|
|
471
483
|
lines.push(` ${env.label}: ${env.hint}`);
|
|
472
484
|
}
|
|
485
|
+
lines.push(...scopeLines(env));
|
|
473
486
|
return lines;
|
|
474
487
|
}
|
|
475
488
|
|
package/src/scan-state.js
CHANGED
package/src/scan.js
CHANGED
|
@@ -13,13 +13,20 @@ import { scanDocker } from './docker/scan.js';
|
|
|
13
13
|
import { LocalEnv } from './env/local.js';
|
|
14
14
|
import { configureScanPolicy } from './env/policy.js';
|
|
15
15
|
import { configureCacheRoots } from './env/cache-roots.js';
|
|
16
|
+
import {
|
|
17
|
+
CONTAINER_ROOTS,
|
|
18
|
+
finishScope,
|
|
19
|
+
requestedRoots,
|
|
20
|
+
resolveRoots,
|
|
21
|
+
scanScope,
|
|
22
|
+
} from './env/roots.js';
|
|
16
23
|
import { checkSignal, environmentScope } from './env/scope.js';
|
|
17
24
|
import { block, tierTotals } from './items.js';
|
|
18
25
|
import { hostExecutor, trace } from './exec.js';
|
|
19
26
|
import { GitInspector } from './git.js';
|
|
20
27
|
import { LivenessProbe } from './liveness.js';
|
|
21
28
|
import { resolveOptions } from './options.js';
|
|
22
|
-
import {
|
|
29
|
+
import { isWithin, matchesExcluded } from './paths.js';
|
|
23
30
|
import { scanAgents } from './scanners/agents.js';
|
|
24
31
|
import { GLOBAL_RULES, scanGlobal, targetedRules } from './scanners/global.js';
|
|
25
32
|
import { scanProjects } from './scanners/projects.js';
|
|
@@ -50,6 +57,7 @@ const reportEnvironments = new WeakMap();
|
|
|
50
57
|
export const scannedEnvironment = (report) => reportEnvironments.get(report);
|
|
51
58
|
const RESCAN_OPTIONS = new Set([
|
|
52
59
|
'roots',
|
|
60
|
+
'containerRoots',
|
|
53
61
|
'scanners',
|
|
54
62
|
'containers',
|
|
55
63
|
'host',
|
|
@@ -120,6 +128,7 @@ export async function revalidateReport(saved, input = {}) {
|
|
|
120
128
|
const scope = Object.fromEntries(
|
|
121
129
|
[
|
|
122
130
|
'roots',
|
|
131
|
+
'containerRoots',
|
|
123
132
|
'scanners',
|
|
124
133
|
'containers',
|
|
125
134
|
'host',
|
|
@@ -140,25 +149,7 @@ export async function revalidateReport(saved, input = {}) {
|
|
|
140
149
|
return sealReport(current);
|
|
141
150
|
}
|
|
142
151
|
|
|
143
|
-
|
|
144
|
-
export const CONTAINER_ROOTS = [
|
|
145
|
-
'/workspace',
|
|
146
|
-
'/workspaces',
|
|
147
|
-
'/app',
|
|
148
|
-
'/src',
|
|
149
|
-
'/code',
|
|
150
|
-
'/project',
|
|
151
|
-
'/usr/src/app',
|
|
152
|
-
];
|
|
153
|
-
|
|
154
|
-
async function defaultRoots(env, homes, tmpDirs) {
|
|
155
|
-
const roots = [...homes, ...tmpDirs];
|
|
156
|
-
if (env.kind === 'container') {
|
|
157
|
-
const present = await existingPaths(env, CONTAINER_ROOTS);
|
|
158
|
-
roots.push(...CONTAINER_ROOTS.filter((root) => present.has(root)));
|
|
159
|
-
}
|
|
160
|
-
return [...new Set(roots)];
|
|
161
|
-
}
|
|
152
|
+
export { CONTAINER_ROOTS };
|
|
162
153
|
|
|
163
154
|
/**
|
|
164
155
|
* Builds the scanner context of one environment.
|
|
@@ -169,7 +160,7 @@ async function defaultRoots(env, homes, tmpDirs) {
|
|
|
169
160
|
export async function environmentContext(env, options, overrides = {}) {
|
|
170
161
|
await configureScanPolicy(env, {
|
|
171
162
|
...options,
|
|
172
|
-
roots:
|
|
163
|
+
roots: requestedRoots(env, options, overrides),
|
|
173
164
|
});
|
|
174
165
|
const liveness = new LivenessProbe(env, {
|
|
175
166
|
staleAgeMs: options.staleAgeMs,
|
|
@@ -180,7 +171,14 @@ export async function environmentContext(env, options, overrides = {}) {
|
|
|
180
171
|
const homes = await env.homeDirs();
|
|
181
172
|
await configureCacheRoots(env, homes);
|
|
182
173
|
const tmpDirs = await env.tmpDirs();
|
|
183
|
-
const roots
|
|
174
|
+
const { roots, entries } = await resolveRoots(
|
|
175
|
+
env,
|
|
176
|
+
options,
|
|
177
|
+
overrides,
|
|
178
|
+
homes,
|
|
179
|
+
tmpDirs
|
|
180
|
+
);
|
|
181
|
+
env.scanScope = scanScope(env, options, entries);
|
|
184
182
|
return {
|
|
185
183
|
env,
|
|
186
184
|
git: new GitInspector(env),
|
|
@@ -274,6 +272,7 @@ export async function scanEnvironment(env, options, extra = {}) {
|
|
|
274
272
|
}
|
|
275
273
|
return items;
|
|
276
274
|
} finally {
|
|
275
|
+
finishScope(env, selectedScanners(options).includes('projects'));
|
|
277
276
|
clearTimeout(timer);
|
|
278
277
|
clearInterval(interval);
|
|
279
278
|
options.signal?.removeEventListener('abort', abort);
|
|
@@ -551,6 +550,7 @@ function envDescriptor(env, depth, chain) {
|
|
|
551
550
|
chain,
|
|
552
551
|
hint: env.probeHint ?? null,
|
|
553
552
|
scanStatus: env.scanStatus ?? null,
|
|
553
|
+
scope: env.scanScope ?? null,
|
|
554
554
|
layerSource: env.writableLayer?.source ?? null,
|
|
555
555
|
timings: env.timings?.toJSON() ?? [],
|
|
556
556
|
};
|
|
@@ -570,6 +570,15 @@ async function withDisk(env, descriptor, options) {
|
|
|
570
570
|
};
|
|
571
571
|
}
|
|
572
572
|
|
|
573
|
+
/**
|
|
574
|
+
* Scans one running container found by the Docker walk (any depth). Roots
|
|
575
|
+
* come from `containerRoots`/`roots` via environmentContext, never from the
|
|
576
|
+
* host's resolved defaults.
|
|
577
|
+
*/
|
|
578
|
+
export function containerScanner(options, errors) {
|
|
579
|
+
return (inner) => scanEnvironment(inner, options, { errors });
|
|
580
|
+
}
|
|
581
|
+
|
|
573
582
|
async function dockerSection(env, options, errors) {
|
|
574
583
|
if (options.docker === false) {
|
|
575
584
|
return null;
|
|
@@ -583,7 +592,7 @@ async function dockerSection(env, options, errors) {
|
|
|
583
592
|
const result = await scanDocker(
|
|
584
593
|
hostRecord,
|
|
585
594
|
{ ...options, selfContainerIds: await selfContainerIds(env) },
|
|
586
|
-
(
|
|
595
|
+
containerScanner(options, errors)
|
|
587
596
|
);
|
|
588
597
|
errors.push(...result.errors);
|
|
589
598
|
if (options.docker === true && result.daemons.length === 0) {
|
package/src/scanners/agents.js
CHANGED
|
@@ -20,6 +20,7 @@ import { scanItems } from '../env/scope.js';
|
|
|
20
20
|
* (the same rule the agent's own `Project.prune()` uses).
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
+
import { itemSize } from '../env/hardlinks.js';
|
|
23
24
|
import { block, makeItem } from '../items.js';
|
|
24
25
|
import { listMany, olderThan, scanTimeBusy } from './common.js';
|
|
25
26
|
|
|
@@ -127,7 +128,7 @@ async function appItems(context, app, root) {
|
|
|
127
128
|
ecosystem: 'agents',
|
|
128
129
|
description: `${app.name} snapshot store ${state.entry.name}`,
|
|
129
130
|
path: state.entry.path,
|
|
130
|
-
|
|
131
|
+
...itemSize([[state.entry.path, usage]]),
|
|
131
132
|
newestMtimeMs: usage.newestMtimeMs,
|
|
132
133
|
worktree: state.worktree,
|
|
133
134
|
...verdict,
|
package/src/scanners/global.js
CHANGED
|
@@ -6,6 +6,7 @@ import { checkSignal, scanItems } from '../env/scope.js';
|
|
|
6
6
|
* ../rules/downloads.js.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { itemSize } from '../env/hardlinks.js';
|
|
9
10
|
import { CACHE_RULES } from '../rules/ecosystems.js';
|
|
10
11
|
import { OTHER_RULES, TEMP_CLONE_RULES } from '../rules/other.js';
|
|
11
12
|
import { APP_RULES, expandChromiumCaches } from '../rules/apps.js';
|
|
@@ -199,7 +200,7 @@ async function buildItem(context, rule, base, group, homes) {
|
|
|
199
200
|
description: rule.description,
|
|
200
201
|
path: paths[0],
|
|
201
202
|
paths,
|
|
202
|
-
|
|
203
|
+
...itemSize(group.map((entry) => [entry.path, entry.usage])),
|
|
203
204
|
sizeEstimated: group.some((entry) => entry.usage.estimated),
|
|
204
205
|
newestMtimeMs: Math.max(...group.map((e) => e.usage.newestMtimeMs ?? 0)),
|
|
205
206
|
tier: rule.tier ?? 'safe',
|
package/src/scanners/projects.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { performance } from 'node:perf_hooks';
|
|
2
2
|
|
|
3
|
+
import { itemSize } from '../env/hardlinks.js';
|
|
3
4
|
import { scanItems } from '../env/scope.js';
|
|
4
5
|
/**
|
|
5
6
|
* Per-project dependency, build and cache directories (node_modules, target,
|
|
@@ -241,7 +242,7 @@ async function projectItem(context, { candidate, rule }, usage) {
|
|
|
241
242
|
ecosystem: rule.ecosystem,
|
|
242
243
|
description: `${ECOSYSTEM_NAMES.get(rule.ecosystem)} ${rule.description}`,
|
|
243
244
|
path: candidate.path,
|
|
244
|
-
|
|
245
|
+
...itemSize([[candidate.path, usage]]),
|
|
245
246
|
sizeEstimated: Boolean(usage?.estimated),
|
|
246
247
|
newestMtimeMs: usage?.newestMtimeMs ?? 0,
|
|
247
248
|
project,
|