disk-space-saviour 0.21.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/CHANGELOG.md +6 -0
- package/README.md +35 -0
- package/package.json +1 -1
- package/src/app-owners.js +87 -0
- package/src/clean.js +11 -0
- package/src/cli.js +32 -3
- package/src/conditions.js +145 -0
- package/src/env/local.js +70 -7
- package/src/index.d.ts +54 -0
- package/src/index.js +1 -0
- package/src/items.js +3 -0
- package/src/liveness.js +67 -4
- package/src/report.js +118 -28
- package/src/scan.js +30 -1
- package/src/scanners/global.js +4 -0
- package/src/scanners/projects.js +18 -2
- package/src/usage.js +601 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.22.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- a63f8ca: Add `dss usage`, which shows where the space of a whole volume goes (largest directories and files, what dss can free or has blocked inside each, APFS volumes, snapshots and unread space), list APFS clones that share their blocks with an installed app under `SHARED WITH INSTALLED APPS`, search Git worktree folders at full depth, keep per-app macOS Library folders while a process of the owning app runs, name the process and app holding a busy path, group blocked space by the condition that frees it in a `WARNING:` line and a `FREEABLE ON CONDITIONS` section, scan again when a process that blocked items has exited, warn when a clean plan rests on an incomplete scan, and collapse access errors inside directories this user cannot enter into one line.
|
|
8
|
+
|
|
3
9
|
## 0.21.0
|
|
4
10
|
|
|
5
11
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -57,6 +57,7 @@ dss scan [paths...] [--docker] [--depth N] [--json] # report only
|
|
|
57
57
|
dss clean [paths...] --tier safe|moderate [--yes] [--older-than 1h]
|
|
58
58
|
dss emergency --free 20G | --until 80% [--path /] [--yes]
|
|
59
59
|
dss docker scan|clean [--container ID] [--recursive]
|
|
60
|
+
dss usage [paths...] [--min-size 1G] [--json] # where space goes
|
|
60
61
|
```
|
|
61
62
|
|
|
62
63
|
| Exit code | Meaning |
|
|
@@ -85,6 +86,22 @@ Run `dss --help` for every option. The most useful ones:
|
|
|
85
86
|
Recent reports authenticated by this installation are reused; older, unsigned
|
|
86
87
|
or edited reports require discovery again. Changing discovery or action options
|
|
87
88
|
also rebuilds the plan. Every deletion still gets fresh checks.
|
|
89
|
+
- `dss usage` shows where the space of a whole volume goes, including data
|
|
90
|
+
no rule covers. One read-only `du -axk` pass walks the macOS Data volume
|
|
91
|
+
(`/Users`, `/Applications`, `/private`, `/opt`, ...) or `/` on Linux, or
|
|
92
|
+
the given paths, staying on their filesystem. It prints a tree of the
|
|
93
|
+
largest directories (1% of the walk and up, or `--min-size`), the largest
|
|
94
|
+
files, and for each directory what the latest dss scan or clean found
|
|
95
|
+
inside it (`[dss: 1.2 GiB freeable, 3.4 GiB blocked]`). On macOS it adds
|
|
96
|
+
the APFS volumes a walk cannot see (system, VM swap, Preboot, Recovery),
|
|
97
|
+
local snapshots and the part of the Data volume left unread. It needs
|
|
98
|
+
`du`, so it runs on macOS and Linux; a full walk takes minutes.
|
|
99
|
+
- APFS clones that share every block with an installed app, such as Chrome's
|
|
100
|
+
code-sign clones, free nothing when deleted, so they are never offered. The
|
|
101
|
+
scan lists them under `SHARED WITH INSTALLED APPS` and `dss usage` marks
|
|
102
|
+
them, because `du` and Finder count them at full size.
|
|
103
|
+
- Git worktree folders (`.worktrees`, `worktrees`) restart the project search
|
|
104
|
+
depth, so dependencies of nested worktrees are found below `--max-depth`.
|
|
88
105
|
- `clean` automatically reuses a matching scan from the last 30 minutes.
|
|
89
106
|
`--fresh` bypasses this plan and the discovery/size index; `--cache-dir DIR`
|
|
90
107
|
chooses the state location (default `$XDG_STATE_HOME/disk-space-saviour`, or
|
|
@@ -149,6 +166,24 @@ is needed. Agent scratch cleanup requires inactivity and Git safety checks.
|
|
|
149
166
|
cache, while `bun install`, `bunx` or an auto-installing script does.
|
|
150
167
|
A directory on the `PATH` of a running process is kept too: the process
|
|
151
168
|
may still start programs from it.
|
|
169
|
+
- **Per-app macOS folders stay while their app runs.** Caches, containers
|
|
170
|
+
and other folders under `~/Library/<kind>/<bundle-id>` belong to the app
|
|
171
|
+
with that bundle identifier, read from the `Info.plist` of every bundle a
|
|
172
|
+
running process starts from. Such a folder is blocked while that app, a
|
|
173
|
+
helper or an updater of it runs (for example the WebKit cache of Music,
|
|
174
|
+
Telegram's group container), even when no rule names the app's process.
|
|
175
|
+
A path held open names its holder: `in use: <path> (pid N, App)`.
|
|
176
|
+
- **Blocked space is grouped by what frees it.** A `WARNING:` line and the
|
|
177
|
+
`FREEABLE ON CONDITIONS` section total blocked space by the condition that
|
|
178
|
+
clears every blocker of an item: quit an app, a process or Claude Code
|
|
179
|
+
session ending, files closing, the activity window passing, a container
|
|
180
|
+
stopping or a later scan finishing what this one ran out of time for.
|
|
181
|
+
Items kept for Git state, protected installs or unverifiable records are
|
|
182
|
+
counted apart. `clean` repeats the summary after the plan. Rerun dss once
|
|
183
|
+
a condition is met; it rechecks everything before deleting. `clean` scans
|
|
184
|
+
again instead of reusing a saved scan when a process named by its blockers
|
|
185
|
+
has exited, and warns when the scan behind its plan stopped early (for
|
|
186
|
+
example at `--scan-budget`), since items it never reached are not planned.
|
|
152
187
|
- **Toolchain versions are kept while anything may need them.** The
|
|
153
188
|
manager's default and aliases, versions pinned by a project (`.nvmrc`,
|
|
154
189
|
`rust-toolchain.toml`, ...) and the newest install stay. A partial nvm pin
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "disk-space-saviour",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.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",
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* macOS apps that own per-app Library folders.
|
|
3
|
+
*
|
|
4
|
+
* Caches, containers and other per-app data under `~/Library/<kind>/` are
|
|
5
|
+
* named after the owning app's bundle identifier
|
|
6
|
+
* (`~/Library/Caches/com.apple.Music/WebKit`, group containers carry a
|
|
7
|
+
* team prefix: `6N38VWS5BX.ru.keepcoder.Telegram`). A running app may read
|
|
8
|
+
* or write such a folder at any moment without holding a file open, so a
|
|
9
|
+
* folder stays while a process runs from a bundle of its owner. This
|
|
10
|
+
* covers apps a rule's process names do not list, such as every app that
|
|
11
|
+
* embeds WebKit.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const OWNED =
|
|
15
|
+
/\/Library\/(Caches|Containers|Group Containers|HTTPStorages|WebKit|Saved Application State|Application Scripts|Application Support|Logs|Cookies)\/([^/]+)/;
|
|
16
|
+
const BUNDLE_ID = /^[A-Za-z0-9-]+(?:\.[A-Za-z0-9_-]+)+$/;
|
|
17
|
+
|
|
18
|
+
/** Bundle identifier owning `target`, or null outside per-app folders. */
|
|
19
|
+
export function ownerBundleId(target) {
|
|
20
|
+
const match = OWNED.exec(target);
|
|
21
|
+
if (!match) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
let id = match[2].replace(/\.(savedState|binarycookies)$/, '');
|
|
25
|
+
if (match[1] === 'Group Containers') {
|
|
26
|
+
id = id.replace(/^(?:[A-Z0-9]{10}\.|group\.)/, '');
|
|
27
|
+
}
|
|
28
|
+
return BUNDLE_ID.test(id) ? id : null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Bundles (`.app`, `.appex`, `.xpc`) an executable runs from, outermost first. */
|
|
32
|
+
export function bundleRoots(executable) {
|
|
33
|
+
const roots = [];
|
|
34
|
+
const parts = (executable ?? '').split('/');
|
|
35
|
+
for (let index = 1; index < parts.length - 1; index++) {
|
|
36
|
+
if (/\.(app|appex|xpc)$/.test(parts[index])) {
|
|
37
|
+
roots.push(parts.slice(0, index + 1).join('/'));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return roots;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** `CFBundleIdentifier` of an XML property list, or null. */
|
|
44
|
+
export function parseBundleIdentifier(plist) {
|
|
45
|
+
return (
|
|
46
|
+
/<key>CFBundleIdentifier<\/key>\s*<string>([^<]+)<\/string>/
|
|
47
|
+
.exec(plist ?? '')?.[1]
|
|
48
|
+
?.trim() || null
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Whether a running bundle belongs to the owner of a folder: the same
|
|
54
|
+
* identifier, or one nested under the other (helpers such as
|
|
55
|
+
* `com.google.Chrome.helper`, updaters such as `….ShipIt`).
|
|
56
|
+
*/
|
|
57
|
+
export function ownsFolder(running, owner) {
|
|
58
|
+
const a = running.toLowerCase();
|
|
59
|
+
const b = owner.toLowerCase();
|
|
60
|
+
return a === b || a.startsWith(`${b}.`) || b.startsWith(`${a}.`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Identifiers a process runs under. A bundle without a readable identifier
|
|
65
|
+
* falls back to its name, which still matches an owner ending in it
|
|
66
|
+
* (`Telegram.app` and `ru.keepcoder.Telegram`).
|
|
67
|
+
* @param {string[]} roots bundle paths of the process
|
|
68
|
+
* @param {Map<string, string|null>} ids bundle path → identifier
|
|
69
|
+
*/
|
|
70
|
+
export function processBundles(roots, ids) {
|
|
71
|
+
return roots.map((root) => ({
|
|
72
|
+
id: ids.get(root) ?? null,
|
|
73
|
+
name: root
|
|
74
|
+
.split('/')
|
|
75
|
+
.at(-1)
|
|
76
|
+
.replace(/\.(app|appex|xpc)$/, ''),
|
|
77
|
+
}));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Whether one of `bundles` owns the folder of bundle identifier `owner`. */
|
|
81
|
+
export function bundlesOwn(bundles, owner) {
|
|
82
|
+
return bundles.some((bundle) =>
|
|
83
|
+
bundle.id
|
|
84
|
+
? ownsFolder(bundle.id, owner)
|
|
85
|
+
: owner.toLowerCase().split('.').at(-1) === bundle.name.toLowerCase()
|
|
86
|
+
);
|
|
87
|
+
}
|
package/src/clean.js
CHANGED
|
@@ -1013,6 +1013,17 @@ export async function finishAudit(audit, report, options) {
|
|
|
1013
1013
|
.filter((e) => e.status === 'planned')
|
|
1014
1014
|
.reduce((sum, e) => sum + e.plannedBytes, 0);
|
|
1015
1015
|
audit.environments = environmentTotals(report, audit.entries);
|
|
1016
|
+
// What the run had to leave, grouped by the condition that frees it.
|
|
1017
|
+
audit.blocked = report.totals?.blocked ?? null;
|
|
1018
|
+
// Scanners an incomplete scan never reached contribute no items at all.
|
|
1019
|
+
audit.incomplete = (report.environments ?? [])
|
|
1020
|
+
.filter(
|
|
1021
|
+
(env) =>
|
|
1022
|
+
env.scanStatus &&
|
|
1023
|
+
env.scanStatus !== 'complete' &&
|
|
1024
|
+
env.scanStatus !== 'goal-reached'
|
|
1025
|
+
)
|
|
1026
|
+
.map((env) => ({ label: env.label, status: env.scanStatus }));
|
|
1016
1027
|
if (options.audit !== false) {
|
|
1017
1028
|
await writeAudit(audit, options);
|
|
1018
1029
|
}
|
package/src/cli.js
CHANGED
|
@@ -13,14 +13,16 @@ import { parseArgs } from 'node:util';
|
|
|
13
13
|
|
|
14
14
|
import { startAudit, writeAudit } from './audit.js';
|
|
15
15
|
import { clean } from './clean.js';
|
|
16
|
+
import { exitedBlockerPids, pidAlive } from './conditions.js';
|
|
16
17
|
import { emergency } from './emergency.js';
|
|
17
18
|
import { setTrace } from './exec.js';
|
|
18
19
|
import { resolveGoal, resolveOptions } from './options.js';
|
|
19
20
|
import { formatAudit, formatReport } from './report.js';
|
|
20
21
|
import { scan } from './scan.js';
|
|
22
|
+
import { formatUsage, usage } from './usage.js';
|
|
21
23
|
import { cleanDuringScan } from './fast-clean.js';
|
|
22
24
|
import { defaultStateDirectory, loadScan } from './scan-state.js';
|
|
23
|
-
import { formatBytes } from './units.js';
|
|
25
|
+
import { formatBytes, parseSize } from './units.js';
|
|
24
26
|
|
|
25
27
|
export const USAGE = `Usage: dss <command> [paths...] [options]
|
|
26
28
|
|
|
@@ -28,6 +30,9 @@ Commands:
|
|
|
28
30
|
scan [paths...] Report reclaimable space (never deletes)
|
|
29
31
|
clean [paths...] Delete what the chosen tier allows
|
|
30
32
|
emergency Escalate tiers until a free-space goal is met
|
|
33
|
+
usage [paths...] Show where space goes on whole volumes: the
|
|
34
|
+
largest directories and files, what dss can
|
|
35
|
+
free inside them, APFS volumes (never deletes)
|
|
31
36
|
docker scan|clean Only Docker: daemons, containers, nested daemons
|
|
32
37
|
help, version
|
|
33
38
|
|
|
@@ -288,7 +293,7 @@ export function processIo() {
|
|
|
288
293
|
};
|
|
289
294
|
}
|
|
290
295
|
|
|
291
|
-
const API = { scan, clean, emergency, cleanDuringScan };
|
|
296
|
+
const API = { scan, clean, emergency, cleanDuringScan, usage };
|
|
292
297
|
|
|
293
298
|
class Cli {
|
|
294
299
|
constructor(io, api) {
|
|
@@ -305,6 +310,23 @@ class Cli {
|
|
|
305
310
|
return this.printScan(report, options, values);
|
|
306
311
|
}
|
|
307
312
|
|
|
313
|
+
async usage(options, values) {
|
|
314
|
+
const report = await this.api.usage({
|
|
315
|
+
roots: options.roots,
|
|
316
|
+
// Without --min-size the tree shows directories of 1% of a root.
|
|
317
|
+
minSize: values['min-size'] ? parseSize(values['min-size']) : undefined,
|
|
318
|
+
verbose: values.verbose,
|
|
319
|
+
signal: options.signal,
|
|
320
|
+
auditDir: options.auditDir,
|
|
321
|
+
onUsageProgress: (progress) =>
|
|
322
|
+
this.io.stderr(
|
|
323
|
+
`[usage] ${progress.root}: ${Math.round(progress.elapsedMs / 1000)}s, ${progress.entries.toLocaleString('en-US')} entries, at ${progress.at ?? progress.root}`
|
|
324
|
+
),
|
|
325
|
+
});
|
|
326
|
+
this.print(values, report, () => formatUsage(report));
|
|
327
|
+
return 0;
|
|
328
|
+
}
|
|
329
|
+
|
|
308
330
|
async printScan(report, options, values) {
|
|
309
331
|
const audit = scanAudit(report);
|
|
310
332
|
if (options.audit !== false) {
|
|
@@ -329,7 +351,12 @@ class Cli {
|
|
|
329
351
|
const file = values.from ?? values.report;
|
|
330
352
|
if (!file) {
|
|
331
353
|
const recent = await loadScan(resolveOptions(options));
|
|
332
|
-
|
|
354
|
+
const exited = recent ? exitedBlockerPids(recent, pidAlive) : [];
|
|
355
|
+
if (exited.length > 0) {
|
|
356
|
+
this.io.stderr(
|
|
357
|
+
`Scanning again: processes that blocked items in the scan from ${recent.createdAt} exited (pid ${exited.slice(0, 5).join(', ')}${exited.length > 5 ? ', …' : ''}).`
|
|
358
|
+
);
|
|
359
|
+
} else if (recent) {
|
|
333
360
|
this.io.stderr(
|
|
334
361
|
`Reusing scan from ${recent.createdAt}; planned items will be checked before deletion.`
|
|
335
362
|
);
|
|
@@ -491,6 +518,8 @@ function dispatch(cli, command, options, values) {
|
|
|
491
518
|
return cli.clean(options, values);
|
|
492
519
|
case 'emergency':
|
|
493
520
|
return cli.emergency(options, values);
|
|
521
|
+
case 'usage':
|
|
522
|
+
return cli.usage(options, values);
|
|
494
523
|
case 'docker-scan':
|
|
495
524
|
return cli.scan(dockerOnly(options, values), values);
|
|
496
525
|
case 'docker-clean':
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Space that frees when a condition is met: blocked items grouped by what
|
|
3
|
+
* keeps them, so a report says "quit Telegram: 1.9 GiB" in one line for
|
|
4
|
+
* hundreds of busy items. An item frees only when every one of
|
|
5
|
+
* its blockers clears; items kept for reasons no running program explains
|
|
6
|
+
* (Git state, protected installs, unreadable records) are counted apart.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const CONDITIONS = [
|
|
10
|
+
[
|
|
11
|
+
/^(?:busy: )?(.+?) is running in (.+?) \(pid (\d+)\)$/,
|
|
12
|
+
(m) => ({
|
|
13
|
+
key: `exit:${m[3]}`,
|
|
14
|
+
action: `${m[1]} (pid ${m[3]}) in ${m[2]} exits`,
|
|
15
|
+
}),
|
|
16
|
+
],
|
|
17
|
+
[
|
|
18
|
+
/^(?:busy: )?(.+?) is running \(pid \d+(?:, working directory unknown)?\)(?: and owns \S+)?$/,
|
|
19
|
+
(m) => ({ key: `quit:${m[1]}`, action: `quit ${m[1]}` }),
|
|
20
|
+
],
|
|
21
|
+
[
|
|
22
|
+
/^locked by running Claude Code \(pid (\d+)/,
|
|
23
|
+
(m) => ({
|
|
24
|
+
key: `exit:${m[1]}`,
|
|
25
|
+
action: `the Claude Code session (pid ${m[1]}) ends`,
|
|
26
|
+
}),
|
|
27
|
+
],
|
|
28
|
+
[
|
|
29
|
+
/^(?:busy: )?on the PATH of (.+?) \(pid (\d+)\)/,
|
|
30
|
+
(m) => ({ key: `exit:${m[2]}`, action: `${m[1]} (pid ${m[2]}) exits` }),
|
|
31
|
+
],
|
|
32
|
+
[
|
|
33
|
+
/^(?:busy: )?in use: .+ \(pid (\d+)(?:, (.+))?\)$/,
|
|
34
|
+
(m) => ({
|
|
35
|
+
key: `exit:${m[1]}`,
|
|
36
|
+
action: `${m[2] ?? 'process'} (pid ${m[1]}) exits`,
|
|
37
|
+
}),
|
|
38
|
+
],
|
|
39
|
+
[
|
|
40
|
+
/^(?:busy: )?(?:in use: |every path is in use)/,
|
|
41
|
+
() => ({ key: 'open', action: 'the programs holding the files open exit' }),
|
|
42
|
+
],
|
|
43
|
+
[
|
|
44
|
+
/^(?:busy: )?modified .+ ago, inside the (.+) activity window$/,
|
|
45
|
+
(m) => ({
|
|
46
|
+
key: 'idle',
|
|
47
|
+
action: `they stay untouched for the ${m[1]} activity window`,
|
|
48
|
+
}),
|
|
49
|
+
],
|
|
50
|
+
[
|
|
51
|
+
/^scan incomplete: (.+)$/,
|
|
52
|
+
(m) => ({
|
|
53
|
+
key: 'rescan',
|
|
54
|
+
action: `a later scan finishes measuring them (${m[1]})`,
|
|
55
|
+
}),
|
|
56
|
+
],
|
|
57
|
+
[
|
|
58
|
+
/^bind-mounted by running container (\S+)/,
|
|
59
|
+
(m) => ({ key: `container:${m[1]}`, action: `container ${m[1]} stops` }),
|
|
60
|
+
],
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
/** What must happen for `blocker` to clear, or null when nothing running explains it. */
|
|
64
|
+
export function blockerCondition(blocker) {
|
|
65
|
+
for (const [pattern, condition] of CONDITIONS) {
|
|
66
|
+
const match = pattern.exec(blocker);
|
|
67
|
+
if (match) {
|
|
68
|
+
return condition(match);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Blocked space grouped by the conditions that free it, largest first.
|
|
76
|
+
* @param {object[]} items blocked items, nested ones already dropped
|
|
77
|
+
* @returns {{conditions: object[], other: {items: number, bytes: number}}}
|
|
78
|
+
*/
|
|
79
|
+
export function blockedConditions(items) {
|
|
80
|
+
const groups = new Map();
|
|
81
|
+
const other = { items: 0, bytes: 0 };
|
|
82
|
+
for (const item of items) {
|
|
83
|
+
const found = item.blockers.map(blockerCondition);
|
|
84
|
+
if (found.length === 0 || found.includes(null)) {
|
|
85
|
+
other.items += 1;
|
|
86
|
+
other.bytes += item.bytes;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
// The first blocker names a process most specifically (a Claude Code
|
|
90
|
+
// lock before the files it holds open).
|
|
91
|
+
const unique = found.filter(
|
|
92
|
+
(entry, index) => found.findIndex((e) => e.key === entry.key) === index
|
|
93
|
+
);
|
|
94
|
+
const key = unique
|
|
95
|
+
.map((entry) => entry.key)
|
|
96
|
+
.sort()
|
|
97
|
+
.join('+');
|
|
98
|
+
const group = groups.get(key) ?? {
|
|
99
|
+
action: unique.map((entry) => entry.action).join(' and '),
|
|
100
|
+
items: 0,
|
|
101
|
+
bytes: 0,
|
|
102
|
+
tiers: {},
|
|
103
|
+
};
|
|
104
|
+
group.items += 1;
|
|
105
|
+
group.bytes += item.bytes;
|
|
106
|
+
group.tiers[item.tier] = (group.tiers[item.tier] ?? 0) + item.bytes;
|
|
107
|
+
groups.set(key, group);
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
conditions: [...groups.values()].sort((a, b) => b.bytes - a.bytes),
|
|
111
|
+
other,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Host processes named by blockers of a saved scan that no longer run, so
|
|
117
|
+
* the items they blocked may be free now and the scan is stale.
|
|
118
|
+
* @param {object} report saved scan
|
|
119
|
+
* @param {(pid: number) => boolean} alive
|
|
120
|
+
* @returns {number[]}
|
|
121
|
+
*/
|
|
122
|
+
export function exitedBlockerPids(report, alive) {
|
|
123
|
+
const pids = new Set();
|
|
124
|
+
for (const item of report.items ?? []) {
|
|
125
|
+
if (item.env !== 'host') {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
for (const blocker of item.blockers ?? []) {
|
|
129
|
+
for (const match of blocker.matchAll(/\(pid (\d+)/g)) {
|
|
130
|
+
pids.add(Number(match[1]));
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return [...pids].filter((pid) => !alive(pid));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Whether a process with `pid` exists, including other users' processes. */
|
|
138
|
+
export function pidAlive(pid) {
|
|
139
|
+
try {
|
|
140
|
+
process.kill(pid, 0);
|
|
141
|
+
return true;
|
|
142
|
+
} catch (error) {
|
|
143
|
+
return error.code === 'EPERM';
|
|
144
|
+
}
|
|
145
|
+
}
|
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);
|
|
@@ -830,6 +861,8 @@ export class LocalEnv {
|
|
|
830
861
|
.map((m) => ({
|
|
831
862
|
pid: Number(m[1]),
|
|
832
863
|
name: path.basename(m[2].trim()),
|
|
864
|
+
// macOS `comm` is the executable's full path.
|
|
865
|
+
executable: m[2].trim(),
|
|
833
866
|
cwd: cwds.get(Number(m[1])) ?? null,
|
|
834
867
|
command: commands.get(Number(m[1])) ?? '',
|
|
835
868
|
searchPath: searchPaths.get(Number(m[1])) ?? [],
|
|
@@ -978,12 +1011,22 @@ export class LocalEnv {
|
|
|
978
1011
|
if (result.code !== 0 && !result.stdout) {
|
|
979
1012
|
return null;
|
|
980
1013
|
}
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
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;
|
|
987
1030
|
}
|
|
988
1031
|
return null;
|
|
989
1032
|
}
|
|
@@ -992,6 +1035,26 @@ export class LocalEnv {
|
|
|
992
1035
|
return this.executor.run(argv, options);
|
|
993
1036
|
}
|
|
994
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
|
+
|
|
995
1058
|
async which(command) {
|
|
996
1059
|
const dirs = (process.env.PATH ?? '').split(path.delimiter);
|
|
997
1060
|
const extensions =
|
package/src/index.d.ts
CHANGED
|
@@ -219,6 +219,16 @@ export interface Report {
|
|
|
219
219
|
options: Record<string, unknown>;
|
|
220
220
|
environments: EnvironmentDescriptor[];
|
|
221
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
|
+
}>;
|
|
222
232
|
/** Tier totals exclude items that need explicit consent (`consent`). */
|
|
223
233
|
totals: Record<Tier | 'blocked' | 'consent', TierTotal>;
|
|
224
234
|
docker: {
|
|
@@ -300,6 +310,50 @@ export declare function clean(
|
|
|
300
310
|
/** Escalates tiers until the free-space goal is met, then stops. */
|
|
301
311
|
export declare function emergency(options: EmergencyOptions): Promise<AuditLog>;
|
|
302
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
|
+
|
|
303
357
|
export declare function formatReport(
|
|
304
358
|
report: Report,
|
|
305
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
|
}
|