disk-space-saviour 0.22.0 → 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 +28 -0
- package/README.md +87 -5
- package/package.json +1 -1
- package/src/audit.js +32 -1
- package/src/clean.js +38 -12
- package/src/cli.js +11 -0
- package/src/conditions.js +7 -0
- package/src/docker/scan.js +1 -0
- package/src/docker/writable.js +7 -1
- package/src/emergency.js +4 -4
- 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 +46 -0
- package/src/liveness.js +116 -8
- package/src/mac-apps.js +286 -0
- package/src/options.js +3 -0
- package/src/report.js +18 -2
- package/src/rules/downloads.js +27 -0
- package/src/rules/other.js +9 -0
- package/src/scan-state.js +1 -0
- package/src/scan.js +33 -23
- package/src/scanners/agents.js +2 -1
- package/src/scanners/global.js +2 -1
- package/src/scanners/node-modules.js +89 -0
- package/src/scanners/projects.js +11 -1
- package/src/swap.js +9 -2
- package/src/usage.js +86 -0
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
|
@@ -438,7 +438,8 @@ function holderLine(holder) {
|
|
|
438
438
|
`${change > 0 ? '+' : '-'}${formatBytes(Math.abs(change))} since ${new Date(holder.growth.since).toISOString()}`
|
|
439
439
|
);
|
|
440
440
|
}
|
|
441
|
-
|
|
441
|
+
const self = holder.self ? ' (this dss run)' : '';
|
|
442
|
+
return `${holder.pid} ${holder.command}${self}: ${details.join(', ')}`;
|
|
442
443
|
}
|
|
443
444
|
|
|
444
445
|
function swapLines(swap) {
|
|
@@ -454,6 +455,18 @@ function swapLines(swap) {
|
|
|
454
455
|
];
|
|
455
456
|
}
|
|
456
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
|
+
|
|
457
470
|
function environmentStatusLines(env) {
|
|
458
471
|
const lines = [];
|
|
459
472
|
if (env.layerSource) {
|
|
@@ -469,6 +482,7 @@ function environmentStatusLines(env) {
|
|
|
469
482
|
if (env.hint) {
|
|
470
483
|
lines.push(` ${env.label}: ${env.hint}`);
|
|
471
484
|
}
|
|
485
|
+
lines.push(...scopeLines(env));
|
|
472
486
|
return lines;
|
|
473
487
|
}
|
|
474
488
|
|
|
@@ -617,7 +631,9 @@ export function formatAudit(audit, options = {}) {
|
|
|
617
631
|
...conditionsSection(audit.blocked, options).slice(2)
|
|
618
632
|
);
|
|
619
633
|
}
|
|
620
|
-
if (audit.
|
|
634
|
+
if (audit.auditError) {
|
|
635
|
+
lines.push(`Audit log: not written (${audit.auditError.message})`);
|
|
636
|
+
} else if (audit.file) {
|
|
621
637
|
lines.push(`Audit log: ${audit.file}`);
|
|
622
638
|
}
|
|
623
639
|
return lines.join('\n');
|
package/src/rules/downloads.js
CHANGED
|
@@ -184,4 +184,31 @@ export const DOWNLOAD_RULES = [
|
|
|
184
184
|
],
|
|
185
185
|
minAge: 'stale',
|
|
186
186
|
},
|
|
187
|
+
{
|
|
188
|
+
id: 'crashpad-pending-reports',
|
|
189
|
+
ecosystem: 'system',
|
|
190
|
+
description:
|
|
191
|
+
'Chromium and Electron app crash reports never sent: after a month no app uploads them',
|
|
192
|
+
tier: 'moderate',
|
|
193
|
+
aggregate: true,
|
|
194
|
+
paths: [
|
|
195
|
+
'~/Library/Application Support/*/Crashpad/pending/*',
|
|
196
|
+
...['Google', 'Yandex', 'BraveSoftware'].map(
|
|
197
|
+
(vendor) =>
|
|
198
|
+
`~/Library/Application Support/${vendor}/*/Crashpad/pending/*`
|
|
199
|
+
),
|
|
200
|
+
'~/.config/*/Crashpad/pending/*',
|
|
201
|
+
],
|
|
202
|
+
minAge: 'inactive',
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
id: 'gh-run-log-cache',
|
|
206
|
+
ecosystem: 'system',
|
|
207
|
+
description:
|
|
208
|
+
'GitHub CLI cached workflow run logs: gh downloads them again while GitHub keeps them',
|
|
209
|
+
tier: 'moderate',
|
|
210
|
+
aggregate: true,
|
|
211
|
+
paths: ['~/.cache/gh/run-log-*.zip'],
|
|
212
|
+
minAge: 'stale',
|
|
213
|
+
},
|
|
187
214
|
];
|
package/src/rules/other.js
CHANGED
|
@@ -414,6 +414,15 @@ export const OTHER_RULES = [
|
|
|
414
414
|
aggregate: true,
|
|
415
415
|
paths: ['~/Library/Logs/DiagnosticReports/*'],
|
|
416
416
|
},
|
|
417
|
+
{
|
|
418
|
+
id: 'rotated-app-logs',
|
|
419
|
+
ecosystem: 'system',
|
|
420
|
+
description:
|
|
421
|
+
'rotated app logs (warp.log.old.2, app.log.1): apps write only the current log',
|
|
422
|
+
tier: 'moderate',
|
|
423
|
+
paths: ['~/Library/Logs/*.log.*', '~/Library/Logs/*/*.log.*'],
|
|
424
|
+
minAge: 'stale',
|
|
425
|
+
},
|
|
417
426
|
{
|
|
418
427
|
id: 'apt-archives',
|
|
419
428
|
ecosystem: 'system',
|
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,17 +160,25 @@ 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,
|
|
167
|
+
inactiveMs: options.inactiveMs,
|
|
176
168
|
now: options.now,
|
|
177
169
|
});
|
|
178
170
|
await liveness.refresh(true);
|
|
179
171
|
const homes = await env.homeDirs();
|
|
180
172
|
await configureCacheRoots(env, homes);
|
|
181
173
|
const tmpDirs = await env.tmpDirs();
|
|
182
|
-
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);
|
|
183
182
|
return {
|
|
184
183
|
env,
|
|
185
184
|
git: new GitInspector(env),
|
|
@@ -273,6 +272,7 @@ export async function scanEnvironment(env, options, extra = {}) {
|
|
|
273
272
|
}
|
|
274
273
|
return items;
|
|
275
274
|
} finally {
|
|
275
|
+
finishScope(env, selectedScanners(options).includes('projects'));
|
|
276
276
|
clearTimeout(timer);
|
|
277
277
|
clearInterval(interval);
|
|
278
278
|
options.signal?.removeEventListener('abort', abort);
|
|
@@ -550,6 +550,7 @@ function envDescriptor(env, depth, chain) {
|
|
|
550
550
|
chain,
|
|
551
551
|
hint: env.probeHint ?? null,
|
|
552
552
|
scanStatus: env.scanStatus ?? null,
|
|
553
|
+
scope: env.scanScope ?? null,
|
|
553
554
|
layerSource: env.writableLayer?.source ?? null,
|
|
554
555
|
timings: env.timings?.toJSON() ?? [],
|
|
555
556
|
};
|
|
@@ -569,6 +570,15 @@ async function withDisk(env, descriptor, options) {
|
|
|
569
570
|
};
|
|
570
571
|
}
|
|
571
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
|
+
|
|
572
582
|
async function dockerSection(env, options, errors) {
|
|
573
583
|
if (options.docker === false) {
|
|
574
584
|
return null;
|
|
@@ -582,7 +592,7 @@ async function dockerSection(env, options, errors) {
|
|
|
582
592
|
const result = await scanDocker(
|
|
583
593
|
hostRecord,
|
|
584
594
|
{ ...options, selfContainerIds: await selfContainerIds(env) },
|
|
585
|
-
(
|
|
595
|
+
containerScanner(options, errors)
|
|
586
596
|
);
|
|
587
597
|
errors.push(...result.errors);
|
|
588
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',
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A node_modules directory is regenerable only as far as package managers
|
|
3
|
+
* fill it. Other data kept there (verification runs, evidence, hand-made
|
|
4
|
+
* fixtures) survives no reinstall, so it keeps the whole directory.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { formatBytes } from '../units.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Top-level entries package managers and the tools they run create:
|
|
11
|
+
* links, lockfile copies, store and workspace state, and caches that
|
|
12
|
+
* re-create themselves.
|
|
13
|
+
*/
|
|
14
|
+
const MANAGED_ENTRIES = new Set([
|
|
15
|
+
'.bin',
|
|
16
|
+
'.package-lock.json',
|
|
17
|
+
'.modules.yaml',
|
|
18
|
+
'.pnpm',
|
|
19
|
+
'.pnpm-workspace-state.json',
|
|
20
|
+
'.pnpm-workspace-state-v1.json',
|
|
21
|
+
'.yarn-state.yml',
|
|
22
|
+
'.yarn-integrity',
|
|
23
|
+
'.yarn-metadata.json',
|
|
24
|
+
'.store',
|
|
25
|
+
'.staging',
|
|
26
|
+
'.ignored',
|
|
27
|
+
'.cache',
|
|
28
|
+
'.vite',
|
|
29
|
+
'.vite-temp',
|
|
30
|
+
'.vitest',
|
|
31
|
+
'.vitest-cache',
|
|
32
|
+
'.deno',
|
|
33
|
+
'.prisma',
|
|
34
|
+
'.astro',
|
|
35
|
+
'.mf',
|
|
36
|
+
'.tmp',
|
|
37
|
+
'.DS_Store',
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Entries of `dir` no package manager installs, with their size; empty
|
|
42
|
+
* leftovers are left out.
|
|
43
|
+
* @returns {Promise<Array<{name: string, bytes: number}>>}
|
|
44
|
+
*/
|
|
45
|
+
export async function foreignEntries(env, dir) {
|
|
46
|
+
const candidates = [];
|
|
47
|
+
for (const entry of await env.list(dir)) {
|
|
48
|
+
if (
|
|
49
|
+
MANAGED_ENTRIES.has(entry.name) ||
|
|
50
|
+
entry.name.startsWith('@') ||
|
|
51
|
+
entry.type === 'symlink'
|
|
52
|
+
) {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (
|
|
56
|
+
entry.type === 'dir' &&
|
|
57
|
+
(await env.stat(env.path.join(entry.path, 'package.json')))
|
|
58
|
+
) {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
candidates.push(entry);
|
|
62
|
+
}
|
|
63
|
+
if (candidates.length === 0) {
|
|
64
|
+
return [];
|
|
65
|
+
}
|
|
66
|
+
const usages = await env.usageMany(
|
|
67
|
+
candidates.map((entry) => entry.path),
|
|
68
|
+
{ cache: true }
|
|
69
|
+
);
|
|
70
|
+
return candidates
|
|
71
|
+
.map((entry) => ({ name: entry.name, usage: usages.get(entry.path) }))
|
|
72
|
+
.filter(({ usage }) => usage && (usage.files ?? 1) > 0)
|
|
73
|
+
.map(({ name, usage }) => ({ name, bytes: usage.bytes }))
|
|
74
|
+
.sort((a, b) => b.bytes - a.bytes);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Blocker naming the foreign data inside `dir`, or null. */
|
|
78
|
+
export async function foreignDataBlocker(env, dir) {
|
|
79
|
+
const foreign = await foreignEntries(env, dir);
|
|
80
|
+
if (foreign.length === 0) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
const names = foreign
|
|
84
|
+
.slice(0, 3)
|
|
85
|
+
.map((entry) => `${entry.name} (${formatBytes(entry.bytes)})`)
|
|
86
|
+
.join(', ');
|
|
87
|
+
const more = foreign.length > 3 ? ` and ${foreign.length - 3} more` : '';
|
|
88
|
+
return `holds data no package manager installs: ${names}${more}; a reinstall does not re-create it, so move it out of node_modules first`;
|
|
89
|
+
}
|