disk-space-saviour 0.20.0 → 0.22.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/src/env/local.js CHANGED
@@ -89,7 +89,7 @@ export function parsePsSearchPaths(output) {
89
89
  * host and inside containers (see shell.js).
90
90
  */
91
91
 
92
- import { promises as fsp } from 'node:fs';
92
+ import { constants as fsConstants, promises as fsp } from 'node:fs';
93
93
  import os from 'node:os';
94
94
  import path from 'node:path';
95
95
  import { URL, fileURLToPath } from 'node:url';
@@ -97,6 +97,7 @@ import { procIdentity, procOpenPaths } from './proc.js';
97
97
  import { CACHE_VARIABLES } from './cache-roots.js';
98
98
 
99
99
  import { hostExecutor } from '../exec.js';
100
+ import { parseBundleIdentifier } from '../app-owners.js';
100
101
  import { matchesGlob } from '../paths.js';
101
102
  import { mapLimit } from './parallel.js';
102
103
  import { directoryFingerprint, SIZE_MAX_AGE } from '../scan-state.js';
@@ -251,6 +252,36 @@ export class LocalEnv {
251
252
  );
252
253
  }
253
254
 
255
+ /**
256
+ * Access errors as report messages. Paths inside a directory this user
257
+ * cannot enter collapse into one line: nothing inside such a directory
258
+ * can be deleted by this user, so no candidate hides there.
259
+ * @returns {Promise<string[]>}
260
+ */
261
+ async accessErrorMessages() {
262
+ const messages = [];
263
+ const foreign = new Set();
264
+ for (const [target, message] of this.accessErrors ?? []) {
265
+ const parent = path.dirname(target);
266
+ const enterable = await fsp
267
+ .access(parent, fsConstants.X_OK)
268
+ .then(() => true)
269
+ .catch(() => false);
270
+ if (enterable) {
271
+ messages.push(message);
272
+ } else {
273
+ foreign.add(parent);
274
+ }
275
+ }
276
+ if (foreign.size > 0) {
277
+ const [example] = [...foreign].sort();
278
+ messages.push(
279
+ `skipped ${foreign.size} ${foreign.size === 1 ? 'directory' : 'directories'} this user cannot enter, such as ${example}; nothing inside can be deleted without their owner's rights`
280
+ );
281
+ }
282
+ return messages;
283
+ }
284
+
254
285
  async #lstat(target) {
255
286
  try {
256
287
  return await (this.options.fileFs ?? fsp).lstat(target);
@@ -333,6 +364,24 @@ export class LocalEnv {
333
364
  return heads;
334
365
  }
335
366
 
367
+ /** Another user's entry of a sticky shared directory such as /tmp is private by design. */
368
+ async #foreignTempEntry(dir) {
369
+ const io = this.options.fileFs ?? fsp;
370
+ try {
371
+ const [own, parent] = await Promise.all([
372
+ io.lstat(dir),
373
+ io.stat(path.dirname(dir)),
374
+ ]);
375
+ return (
376
+ typeof process.getuid === 'function' &&
377
+ own.uid !== process.getuid() &&
378
+ (parent.mode & 0o1000) !== 0
379
+ );
380
+ } catch {
381
+ return false;
382
+ }
383
+ }
384
+
336
385
  async list(dir, { discovery = false } = {}) {
337
386
  let names;
338
387
  try {
@@ -340,7 +389,9 @@ export class LocalEnv {
340
389
  withFileTypes: true,
341
390
  });
342
391
  } catch (error) {
343
- this.recordAccessError(dir, error);
392
+ if (!(await this.#foreignTempEntry(dir))) {
393
+ this.recordAccessError(dir, error);
394
+ }
344
395
  return [];
345
396
  }
346
397
  const entries = await mapLimit(names, 32, async (entry) => {
@@ -810,6 +861,8 @@ export class LocalEnv {
810
861
  .map((m) => ({
811
862
  pid: Number(m[1]),
812
863
  name: path.basename(m[2].trim()),
864
+ // macOS `comm` is the executable's full path.
865
+ executable: m[2].trim(),
813
866
  cwd: cwds.get(Number(m[1])) ?? null,
814
867
  command: commands.get(Number(m[1])) ?? '',
815
868
  searchPath: searchPaths.get(Number(m[1])) ?? [],
@@ -958,12 +1011,22 @@ export class LocalEnv {
958
1011
  if (result.code !== 0 && !result.stdout) {
959
1012
  return null;
960
1013
  }
961
- return new Set(
962
- result.stdout
963
- .split('\n')
964
- .filter((line) => line.startsWith('n/'))
965
- .map((line) => line.slice(1))
966
- );
1014
+ // `-F` always prints the pid (`p`) before the files of a process.
1015
+ const paths = new Set();
1016
+ const owners = new Map();
1017
+ let pid = null;
1018
+ for (const line of result.stdout.split('\n')) {
1019
+ if (line.startsWith('p')) {
1020
+ pid = Number(line.slice(1));
1021
+ } else if (line.startsWith('n/')) {
1022
+ paths.add(line.slice(1));
1023
+ if (!owners.has(line.slice(1))) {
1024
+ owners.set(line.slice(1), pid);
1025
+ }
1026
+ }
1027
+ }
1028
+ this.openPathPids = owners;
1029
+ return paths;
967
1030
  }
968
1031
  return null;
969
1032
  }
@@ -972,6 +1035,26 @@ export class LocalEnv {
972
1035
  return this.executor.run(argv, options);
973
1036
  }
974
1037
 
1038
+ /** `CFBundleIdentifier` of a macOS bundle, or null. */
1039
+ async bundleIdentifier(bundle) {
1040
+ if (this.platform !== 'darwin') {
1041
+ return null;
1042
+ }
1043
+ const plist = path.join(bundle, 'Contents', 'Info.plist');
1044
+ const text = await fsp.readFile(plist, 'utf8').catch(() => null);
1045
+ if (text === null) {
1046
+ return null;
1047
+ }
1048
+ if (!text.startsWith('bplist')) {
1049
+ return parseBundleIdentifier(text);
1050
+ }
1051
+ const result = await this.run(
1052
+ ['plutil', '-extract', 'CFBundleIdentifier', 'raw', '-o', '-', plist],
1053
+ { timeoutMs: 10000 }
1054
+ ).catch(() => null);
1055
+ return result?.code === 0 ? result.stdout.trim() || null : null;
1056
+ }
1057
+
975
1058
  async which(command) {
976
1059
  const dirs = (process.env.PATH ?? '').split(path.delimiter);
977
1060
  const extensions =
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Proof that a repository's commits still exist on a network remote, and
3
+ * the linked worktrees that share its object database.
4
+ *
5
+ * Remote URLs are read from the repository config but never used through
6
+ * it: `git ls-remote` runs outside any repository, with only the https and
7
+ * ssh transports allowed and no prompts, so a planted config cannot run
8
+ * commands or ask for credentials. Only advertised branches, tags and
9
+ * review refs (`refs/pull/*`, `refs/merge-requests/*`) count, and only the
10
+ * advertised commits that exist locally, so "on the remote" never relies
11
+ * on cached remote-tracking refs.
12
+ */
13
+ import { promises as fsp } from 'node:fs';
14
+ import os from 'node:os';
15
+ import path from 'node:path';
16
+
17
+ const LS_REMOTE_TIMEOUT_MS = 30 * 1000;
18
+ const ADVERTISED = [
19
+ 'refs/heads/*',
20
+ 'refs/tags/*',
21
+ 'refs/pull/*/head',
22
+ 'refs/merge-requests/*/head',
23
+ ];
24
+
25
+ /**
26
+ * A remote URL `git ls-remote` may contact: https/http or ssh, with a host
27
+ * that cannot be mistaken for an option. Local paths and `file://` remotes
28
+ * are not proof that the code exists elsewhere.
29
+ */
30
+ export function networkRemote(url) {
31
+ const value = url.trim();
32
+ if (/\s/.test(value) || value.startsWith('-')) {
33
+ return null;
34
+ }
35
+ const standard = /^(https?|ssh|git\+ssh):\/\/(?:[^@/]+@)?([^/:]+)/i.exec(
36
+ value
37
+ );
38
+ const scp = /^(?:[\w.-]+@)?([\w.-]+):(?!\/\/)[^\\]/.exec(value);
39
+ const host = standard?.[2] ?? scp?.[1];
40
+ // `C:/repo` is a Windows path, not the host `C`.
41
+ const drive = !standard && /^[a-z]$/i.test(host ?? '');
42
+ return host && !drive && /^[\w.[\]-]+$/.test(host) && !host.startsWith('-')
43
+ ? { url: value, host }
44
+ : null;
45
+ }
46
+
47
+ /** `remote.<name>.url` values from a Git config, as `{name, url}`. */
48
+ export function parseRemoteUrls(text) {
49
+ const remotes = [];
50
+ let remote = null;
51
+ for (const raw of text.split('\n')) {
52
+ const line = raw.trim();
53
+ const section = /^\[\s*remote\s+"([^"]+)"\s*\]$/i.exec(line);
54
+ if (section) {
55
+ remote = section[1];
56
+ continue;
57
+ }
58
+ if (/^\[/.test(line)) {
59
+ remote = null;
60
+ continue;
61
+ }
62
+ const url = /^url\s*=\s*(.+)$/i.exec(line);
63
+ if (remote && url) {
64
+ remotes.push({ name: remote, url: url[1].replace(/^"(.*)"$/, '$1') });
65
+ }
66
+ }
67
+ return remotes;
68
+ }
69
+
70
+ /** Commit IDs from `git ls-remote` output, peeled tags included. */
71
+ export function advertisedCommits(text) {
72
+ return [
73
+ ...new Set(
74
+ text
75
+ .split('\n')
76
+ .map((line) => /^([0-9a-f]{40,64})\t/.exec(line)?.[1])
77
+ .filter(Boolean)
78
+ ),
79
+ ];
80
+ }
81
+
82
+ /** Network remotes of the repository whose common Git directory is given. */
83
+ export async function networkRemotes(commonDir) {
84
+ const text = await fsp
85
+ .readFile(path.join(commonDir, 'config'), 'utf8')
86
+ .catch(() => '');
87
+ return parseRemoteUrls(text)
88
+ .map((remote) => ({ name: remote.name, ...networkRemote(remote.url) }))
89
+ .filter((remote) => remote.host);
90
+ }
91
+
92
+ /**
93
+ * Work trees attached to a common Git directory: the main one (unless the
94
+ * repository is bare) and every linked one that still exists, with the
95
+ * detached HEAD commits of linked worktrees.
96
+ */
97
+ export async function attachedWorktrees(commonDir) {
98
+ const trees = [];
99
+ const detached = [];
100
+ const config = await fsp
101
+ .readFile(path.join(commonDir, 'config'), 'utf8')
102
+ .catch(() => '');
103
+ if (
104
+ path.basename(commonDir) === '.git' &&
105
+ !/^\s*bare\s*=\s*true/m.test(config)
106
+ ) {
107
+ trees.push(path.dirname(commonDir));
108
+ }
109
+ const base = path.join(commonDir, 'worktrees');
110
+ for (const name of await fsp.readdir(base).catch(() => [])) {
111
+ const read = (file) =>
112
+ fsp.readFile(path.join(base, name, file), 'utf8').catch(() => '');
113
+ const gitdir = (await read('gitdir')).trim();
114
+ const head = (await read('HEAD')).trim();
115
+ if (/^[0-9a-f]{40,64}$/.test(head)) {
116
+ detached.push(head);
117
+ }
118
+ if (gitdir) {
119
+ const tree = path.dirname(path.resolve(base, name, gitdir));
120
+ if (await fsp.stat(tree).catch(() => null)) {
121
+ trees.push(tree);
122
+ }
123
+ }
124
+ }
125
+ return { trees, detached };
126
+ }
127
+
128
+ /**
129
+ * Runs `git ls-remote` for `url` outside every repository.
130
+ * @param {(argv: string[], options: object) => Promise<object>} run
131
+ */
132
+ export async function lsRemote(run, url) {
133
+ const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'dss-ls-remote-'));
134
+ try {
135
+ return await run(
136
+ [
137
+ 'git',
138
+ '-c',
139
+ 'protocol.allow=never',
140
+ '-c',
141
+ 'protocol.https.allow=always',
142
+ '-c',
143
+ 'protocol.http.allow=always',
144
+ '-c',
145
+ 'protocol.ssh.allow=always',
146
+ '-c',
147
+ 'core.hooksPath=/dev/null',
148
+ '-C',
149
+ dir,
150
+ 'ls-remote',
151
+ url,
152
+ ...ADVERTISED,
153
+ ],
154
+ {
155
+ timeoutMs: LS_REMOTE_TIMEOUT_MS,
156
+ env: {
157
+ GIT_TERMINAL_PROMPT: '0',
158
+ GIT_ASKPASS: '',
159
+ SSH_ASKPASS: '',
160
+ GIT_SSH_COMMAND: 'ssh -o BatchMode=yes -o ConnectTimeout=15',
161
+ GIT_CEILING_DIRECTORIES: path.dirname(dir),
162
+ },
163
+ }
164
+ );
165
+ } finally {
166
+ await fsp.rm(dir, { recursive: true, force: true });
167
+ }
168
+ }
package/src/git.js CHANGED
@@ -16,6 +16,12 @@ import path from 'node:path';
16
16
 
17
17
  import { trace } from './exec.js';
18
18
  import { preservedWork } from './git-preserved.js';
19
+ import {
20
+ advertisedCommits,
21
+ attachedWorktrees,
22
+ lsRemote,
23
+ networkRemotes,
24
+ } from './git-remote.js';
19
25
 
20
26
  const GIT_FLAGS = [
21
27
  '--no-optional-locks',
@@ -42,7 +48,7 @@ const SAFE_GIT_ENV = {
42
48
  * a linked worktree or a submodule has a `.git` file naming its directory,
43
49
  * and a linked worktree names the common directory in `commondir`.
44
50
  */
45
- async function gitDirectories(root) {
51
+ export async function gitDirectories(root) {
46
52
  const dotGit = path.join(root, '.git');
47
53
  const stat = await fsp.lstat(dotGit);
48
54
  let gitDir = dotGit;
@@ -176,10 +182,11 @@ export class GitInspector {
176
182
  this.options = options;
177
183
  this.states = new Map();
178
184
  this.roots = new Map();
185
+ this.remoteRefs = new Map();
179
186
  this.available = null;
180
187
  }
181
188
 
182
- async #git(root, args, safeDir = null) {
189
+ async #git(root, args, safeDir = null, input = undefined) {
183
190
  const result = await this.env.run(
184
191
  [
185
192
  'git',
@@ -189,7 +196,7 @@ export class GitInspector {
189
196
  ...(safeDir ? ['--git-dir', safeDir, '--work-tree', root] : []),
190
197
  ...args,
191
198
  ],
192
- { env: SAFE_GIT_ENV }
199
+ { env: SAFE_GIT_ENV, input }
193
200
  );
194
201
  trace('git', root, args.join(' '), '->', result.code);
195
202
  return result;
@@ -246,6 +253,7 @@ export class GitInspector {
246
253
  invalidate() {
247
254
  this.states.clear();
248
255
  this.roots.clear();
256
+ this.remoteRefs.clear();
249
257
  }
250
258
 
251
259
  async #loadState(root) {
@@ -322,8 +330,8 @@ export class GitInspector {
322
330
  return stateBlockers(root, await this.repoState(root));
323
331
  }
324
332
 
325
- /** Protect an entire temporary work directory, including nested clones. */
326
- async treeBlockers(target, { allowNoRepo = false } = {}) {
333
+ /** Repositories at or below `target`. */
334
+ async #repoRoots(target) {
327
335
  const dirs = await this.env.findDirs([target], {
328
336
  globs: ['.git'],
329
337
  maxDepth: 32,
@@ -334,13 +342,18 @@ export class GitInspector {
334
342
  maxDepth: 32,
335
343
  skipNames: [],
336
344
  });
337
- const roots = new Set([
345
+ return new Set([
338
346
  ...((await this.env.exists(this.env.path.join(target, '.git')))
339
347
  ? [target]
340
348
  : []),
341
349
  ...dirs.map((entry) => entry.parent),
342
350
  ...files.map((file) => this.env.path.dirname(file)),
343
351
  ]);
352
+ }
353
+
354
+ /** Protect an entire temporary work directory, including nested clones. */
355
+ async treeBlockers(target, { allowNoRepo = false } = {}) {
356
+ const roots = await this.#repoRoots(target);
344
357
  if (roots.size === 0) {
345
358
  if (allowNoRepo) {
346
359
  return [];
@@ -357,6 +370,134 @@ export class GitInspector {
357
370
  return blockers;
358
371
  }
359
372
 
373
+ /**
374
+ * Reasons a temporary clone must not be deleted. Every repository inside
375
+ * `target` must have no uncommitted, unpushed or stashed work in any of
376
+ * its worktrees, no linked worktree outside `target`, and every local
377
+ * commit must be advertised by one of its https or ssh remotes.
378
+ */
379
+ async cloneBlockers(target) {
380
+ if (this.env.kind !== 'host') {
381
+ return [`${target}: remote copies are only verified on the host`];
382
+ }
383
+ if (!(await this.isAvailable())) {
384
+ return [`${target}: git is not installed, cannot verify the repository`];
385
+ }
386
+ const roots = await this.#repoRoots(target);
387
+ if (roots.size === 0) {
388
+ return [`${target}: no Git repository found; cannot verify work`];
389
+ }
390
+ if (roots.size > 50) {
391
+ return [`${target}: too many repositories to verify safely`];
392
+ }
393
+ const blockers = [];
394
+ for (const root of roots) {
395
+ blockers.push(...(await this.#cloneRootBlockers(root, target)));
396
+ }
397
+ return blockers;
398
+ }
399
+
400
+ async #cloneRootBlockers(root, target) {
401
+ let commonDir;
402
+ try {
403
+ ({ commonDir } = await gitDirectories(root));
404
+ } catch (error) {
405
+ return [`${root}: ${error.message}`];
406
+ }
407
+ const inside = (dir) => {
408
+ const relative = path.relative(target, dir);
409
+ return (
410
+ relative === '' ||
411
+ (!relative.startsWith('..') && !path.isAbsolute(relative))
412
+ );
413
+ };
414
+ const { trees, detached } = await attachedWorktrees(commonDir);
415
+ // Deleting the repository strands every worktree that shares it.
416
+ const affected = inside(commonDir) ? [root, ...trees] : [root];
417
+ const blockers = [];
418
+ for (const tree of new Set(affected)) {
419
+ if (!inside(tree)) {
420
+ blockers.push(`linked worktree ${tree} uses the repository in ${root}`);
421
+ } else if (await this.env.exists(path.join(tree, '.git'))) {
422
+ blockers.push(...(await this.repoBlockers(tree)));
423
+ }
424
+ }
425
+ if (blockers.length > 0) {
426
+ return blockers;
427
+ }
428
+ return this.#remoteBlockers(root, commonDir, detached);
429
+ }
430
+
431
+ async #remoteBlockers(root, commonDir, detached) {
432
+ const remotes = await networkRemotes(commonDir);
433
+ if (remotes.length === 0) {
434
+ return [`Git repository ${root} has no https or ssh remote holding it`];
435
+ }
436
+ const advertised = [];
437
+ const unreachable = [];
438
+ for (const remote of remotes) {
439
+ if (!this.remoteRefs.has(remote.url)) {
440
+ this.remoteRefs.set(
441
+ remote.url,
442
+ lsRemote((argv, options) => this.env.run(argv, options), remote.url)
443
+ );
444
+ }
445
+ const result = await this.remoteRefs.get(remote.url);
446
+ if (result.code === 0) {
447
+ advertised.push(...advertisedCommits(result.stdout));
448
+ } else {
449
+ unreachable.push(`${remote.name} (${remote.host})`);
450
+ }
451
+ }
452
+ if (advertised.length === 0) {
453
+ return [
454
+ `cannot confirm Git repository ${root} on a remote: ${unreachable.length > 0 ? `${unreachable.join(', ')} unreachable` : 'its remotes advertise nothing'}`,
455
+ ];
456
+ }
457
+ try {
458
+ const missing = await safeGitDirectory(root, (safeDir) =>
459
+ this.#commitsMissingFrom(root, safeDir, advertised, detached)
460
+ );
461
+ if (missing === 0) {
462
+ return [];
463
+ }
464
+ const hosts = [...new Set(remotes.map((remote) => remote.host))];
465
+ return [
466
+ `Git repository ${root} has ${plural(missing, 'commit')} no branch, tag or pull request on ${hosts.join(', ')} contains`,
467
+ ];
468
+ } catch (error) {
469
+ return [`cannot compare ${root} with its remote: ${error.message}`];
470
+ }
471
+ }
472
+
473
+ /** Local commits reachable from HEAD, branches or `detached` but no advertised commit. */
474
+ async #commitsMissingFrom(root, safeDir, advertised, detached) {
475
+ const known = await this.#git(
476
+ root,
477
+ ['cat-file', '--batch-check=%(objectname) %(objecttype)'],
478
+ safeDir,
479
+ `${advertised.join('\n')}\n`
480
+ );
481
+ if (known.code !== 0) {
482
+ throw new Error(known.stderr.trim() || `exit ${known.code}`);
483
+ }
484
+ const commits = known.stdout
485
+ .split('\n')
486
+ .map((line) => /^([0-9a-f]+) commit$/.exec(line)?.[1])
487
+ .filter(Boolean);
488
+ const tips = ['HEAD', '--branches', ...detached];
489
+ const count = await this.#git(
490
+ root,
491
+ ['rev-list', '--count', '--stdin', ...tips],
492
+ safeDir,
493
+ commits.map((commit) => `^${commit}\n`).join('')
494
+ );
495
+ if (count.code !== 0) {
496
+ throw new Error(count.stderr.trim() || `exit ${count.code}`);
497
+ }
498
+ return Number(count.stdout.trim());
499
+ }
500
+
360
501
  /**
361
502
  * Number of tracked files inside `target` (a path within `root`).
362
503
  * Returns -1 when Git cannot answer.
package/src/index.d.ts CHANGED
@@ -172,9 +172,19 @@ export interface EnvironmentDescriptor {
172
172
  holders: Array<{
173
173
  pid: number;
174
174
  command: string;
175
+ /** Resident plus compressed and swapped memory. */
175
176
  memoryBytes: number;
176
- compressedBytes: number;
177
+ /** macOS only. */
178
+ compressedBytes?: number;
179
+ /** Linux only (`VmSwap`). */
180
+ swappedBytes?: number;
181
+ /** Change since the earliest scan of the last week listing this process. */
182
+ growth?: { since: number; memoryBytes: number };
177
183
  }>;
184
+ /** Change since the earliest scan of the last week. */
185
+ growth?: { since: number; usedBytes: number } | null;
186
+ /** Large, growing, or disk-threatening swap, naming the memory holders. */
187
+ warnings?: string[];
178
188
  attribution: string;
179
189
  advice: string;
180
190
  } | null;
@@ -209,6 +219,16 @@ export interface Report {
209
219
  options: Record<string, unknown>;
210
220
  environments: EnvironmentDescriptor[];
211
221
  items: Item[];
222
+ /**
223
+ * Host items that share every block with an installed app (APFS clones):
224
+ * never offered, listed so the space `du` counts for them is explained.
225
+ */
226
+ shared?: Array<{
227
+ rule: string;
228
+ path: string;
229
+ apparentBytes: number;
230
+ sharedWith: string;
231
+ }>;
212
232
  /** Tier totals exclude items that need explicit consent (`consent`). */
213
233
  totals: Record<Tier | 'blocked' | 'consent', TierTotal>;
214
234
  docker: {
@@ -290,6 +310,50 @@ export declare function clean(
290
310
  /** Escalates tiers until the free-space goal is met, then stops. */
291
311
  export declare function emergency(options: EmergencyOptions): Promise<AuditLog>;
292
312
 
313
+ export interface UsageOptions {
314
+ /** Directories to walk; default the macOS Data volume or `/`. */
315
+ roots?: string[] | null;
316
+ /** Smallest directory in the tree, in bytes; default 1% of a root. */
317
+ minSize?: number;
318
+ verbose?: boolean;
319
+ signal?: AbortSignal;
320
+ auditDir?: string;
321
+ onUsageProgress?: (progress: {
322
+ root: string;
323
+ elapsedMs: number;
324
+ entries: number;
325
+ at: string | null;
326
+ }) => void;
327
+ }
328
+
329
+ export interface UsageReport {
330
+ schema: 1;
331
+ createdAt: string;
332
+ durationMs: number;
333
+ platform: string;
334
+ scan: { file: string; createdAt: string | null } | null;
335
+ roots: Array<{
336
+ root: string;
337
+ path: string;
338
+ bytes: number;
339
+ entries: number;
340
+ tree: object;
341
+ files: Array<{ path: string; bytes: number }>;
342
+ [key: string]: unknown;
343
+ }>;
344
+ volumes: Array<{
345
+ name: string;
346
+ mount: string | null;
347
+ bytes: number;
348
+ role: string;
349
+ }>;
350
+ snapshots: number | null;
351
+ }
352
+
353
+ /** Shows where the space of whole volumes goes. Never deletes. */
354
+ export declare function usage(options?: UsageOptions): Promise<UsageReport>;
355
+ export declare function formatUsage(report: UsageReport): string;
356
+
293
357
  export declare function formatReport(
294
358
  report: Report,
295
359
  options?: { verbose?: boolean }
package/src/index.js CHANGED
@@ -23,6 +23,7 @@ export {
23
23
  writeAudit,
24
24
  } from './audit.js';
25
25
  export { formatReport, formatAudit, formatDisk } from './report.js';
26
+ export { usage, formatUsage } from './usage.js';
26
27
  export { runCli, parseCli, USAGE } from './cli.js';
27
28
  export { DEFAULTS, resolveOptions, resolveGoal } from './options.js';
28
29
  export {
package/src/items.js CHANGED
@@ -7,6 +7,8 @@
7
7
  * named flag (or an interactive yes) on top of their tier being selected.
8
8
  */
9
9
 
10
+ import { blockedConditions } from './conditions.js';
11
+
10
12
  export const TIERS = ['safe', 'moderate', 'aggressive'];
11
13
 
12
14
  /**
@@ -111,6 +113,7 @@ export function tierTotals(items) {
111
113
  unknownBytes: blocked
112
114
  .filter((item) => item.sizeUnknown)
113
115
  .reduce((sum, item) => sum + (item.totalBytes ?? item.bytes), 0),
116
+ ...blockedConditions(blocked),
114
117
  };
115
118
  return totals;
116
119
  }