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.
@@ -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
- return `${holder.pid} ${holder.command}: ${details.join(', ')}`;
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.file) {
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');
@@ -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
  ];
@@ -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
@@ -98,6 +98,7 @@ export async function authenticatedReport(report, directory, now = Date.now()) {
98
98
  export function scopeKey(options) {
99
99
  const names = [
100
100
  'roots',
101
+ 'containerRoots',
101
102
  'scanners',
102
103
  'host',
103
104
  'docker',
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 { existingPaths, isWithin, matchesExcluded } from './paths.js';
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
- /** Common project locations inside containers, scanned when present. */
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: overrides.roots ?? options.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 = overrides.roots ?? (await defaultRoots(env, homes, tmpDirs));
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
- (inner) => scanEnvironment(inner, options, { errors })
595
+ containerScanner(options, errors)
586
596
  );
587
597
  errors.push(...result.errors);
588
598
  if (options.docker === true && result.daemons.length === 0) {
@@ -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
- bytes: usage.bytes,
131
+ ...itemSize([[state.entry.path, usage]]),
131
132
  newestMtimeMs: usage.newestMtimeMs,
132
133
  worktree: state.worktree,
133
134
  ...verdict,
@@ -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
- bytes: group.reduce((sum, entry) => sum + entry.usage.bytes, 0),
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
+ }