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/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.23.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- Recursive container scans honor requested roots: `--container-root PATH` (`containerRoots`) sets project roots inside every scanned container at any Docker depth, and host roots that exist inside a container join its defaults. The same roots reach the traversal policy and project discovery. Every environment now reports its effective `scope` (roots with source and status, max depth, exclusions, pruned mounts) and the text report names requested roots that were absent, inaccessible, pruned, excluded or not searched.
|
|
8
|
+
|
|
9
|
+
Hard-link aware sizes: items report physically reclaimable bytes, with `allocatedBytes` and `sharedBytes` when links outside the item keep blocks allocated. Each inode is counted once across a selected deletion set, and the audit records `sharedBytes` and marks link-aware estimated `freedBytes`.
|
|
10
|
+
|
|
11
|
+
Add explicit macOS app bundle removal through native Trash with process checks,
|
|
12
|
+
dry runs and audits. Report possible app leftovers separately from user data,
|
|
13
|
+
without automatically deleting either category.
|
|
14
|
+
|
|
15
|
+
## 0.22.1
|
|
16
|
+
|
|
17
|
+
### Patch Changes
|
|
18
|
+
|
|
19
|
+
- 9479acd: Keep a `node_modules` folder that holds data no package manager installs, naming each such entry and its size, at scan time and right before deletion.
|
|
20
|
+
|
|
21
|
+
Let `clean` and `emergency` delete on a full disk: an audit log that does not fit is reported as not written and written as soon as deletions free space.
|
|
22
|
+
|
|
23
|
+
Keep everything inside a live Claude Code session scratchpad until the session and its transcript stay idle for the inactive window, at scan time and right before deletion.
|
|
24
|
+
|
|
25
|
+
Show the dss run among swap holders as `(this dss run)` and never name it as a process to quit.
|
|
26
|
+
|
|
27
|
+
Name what fills a directory of many small entries in `dss usage` by name pattern, with their count and total size.
|
|
28
|
+
|
|
29
|
+
Offer rotated app logs in `~/Library/Logs`, Crashpad reports left unsent for 30 days and the GitHub CLI run-log cache in the moderate tier.
|
|
30
|
+
|
|
3
31
|
## 0.22.0
|
|
4
32
|
|
|
5
33
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -94,12 +94,19 @@ Run `dss --help` for every option. The most useful ones:
|
|
|
94
94
|
files, and for each directory what the latest dss scan or clean found
|
|
95
95
|
inside it (`[dss: 1.2 GiB freeable, 3.4 GiB blocked]`). On macOS it adds
|
|
96
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.
|
|
98
|
-
|
|
97
|
+
local snapshots and the part of the Data volume left unread. A directory
|
|
98
|
+
filled by many small entries names what fills it by pattern
|
|
99
|
+
(`T/ms-* (68523 entries, each under 64 MiB)`). It needs `du`, so it runs
|
|
100
|
+
on macOS and Linux; a full walk takes minutes.
|
|
99
101
|
- APFS clones that share every block with an installed app, such as Chrome's
|
|
100
102
|
code-sign clones, free nothing when deleted, so they are never offered. The
|
|
101
103
|
scan lists them under `SHARED WITH INSTALLED APPS` and `dss usage` marks
|
|
102
104
|
them, because `du` and Finder count them at full size.
|
|
105
|
+
- `node_modules` counts as regenerable only while it holds what package
|
|
106
|
+
managers install: packages, `@scope` folders, `.bin`, lockfile copies and
|
|
107
|
+
tool caches such as `.cache` or `.vite`. Any other data kept there
|
|
108
|
+
(verification runs, captured evidence) blocks the whole directory, and the
|
|
109
|
+
check runs again right before deleting.
|
|
103
110
|
- Git worktree folders (`.worktrees`, `worktrees`) restart the project search
|
|
104
111
|
depth, so dependencies of nested worktrees are found below `--max-depth`.
|
|
105
112
|
- `clean` automatically reuses a matching scan from the last 30 minutes.
|
|
@@ -139,7 +146,9 @@ their blocks with the installed app), Chromium on-device AI models
|
|
|
139
146
|
Electron and electron-builder archives, the Bun transpiler cache, Docker
|
|
140
147
|
Desktop and Warp staged updates (kept while the app runs), Zed language
|
|
141
148
|
servers, tree-sitter toolchains, Codex plugin staging, the Clang module cache
|
|
142
|
-
|
|
149
|
+
Crashpad reports (processed ones, and unsent ones after 30 days), rotated app
|
|
150
|
+
logs in `~/Library/Logs` (`warp.log.old.2`) and the GitHub CLI run-log cache.
|
|
151
|
+
Telegram pure caches, Safari/WebKit,
|
|
143
152
|
Chromium code/GPU/Dawn/service-worker caches and marker-validated Electron
|
|
144
153
|
caches are included. Old Playwright browser revisions are kept while a
|
|
145
154
|
Playwright package recorded in the registry's `.links` still launches them or
|
|
@@ -148,6 +157,10 @@ directories are removed once untouched. Telegram caches are removed only
|
|
|
148
157
|
while Telegram is not running. Telegram databases, browser profiles and
|
|
149
158
|
Playwright MCP profiles are preserved. macOS access failures explain when Full Disk Access
|
|
150
159
|
is needed. Agent scratch cleanup requires inactivity and Git safety checks.
|
|
160
|
+
Anything inside a Claude Code session scratchpad
|
|
161
|
+
(`{TMP}/claude-<uid>/<project>/<session>`) is kept until the session and its
|
|
162
|
+
transcript in `~/.claude/projects` stay untouched for the inactive window
|
|
163
|
+
(30 days), checked again right before deletion.
|
|
151
164
|
|
|
152
165
|
## Safety
|
|
153
166
|
|
|
@@ -233,7 +246,10 @@ is needed. Agent scratch cleanup requires inactivity and Git safety checks.
|
|
|
233
246
|
include every candidate and its blockers; blocked candidates do not
|
|
234
247
|
contribute to reclaimable tier totals.
|
|
235
248
|
If a read-only scan cannot save its audit, it warns and still prints the
|
|
236
|
-
report.
|
|
249
|
+
report. When the disk is full, `clean` and `emergency` delete anyway and
|
|
250
|
+
write their audit as soon as deletions free room; if it never fits, the
|
|
251
|
+
summary says `Audit log: not written (ENOSPC ...)`. Other audit failures,
|
|
252
|
+
such as an unusable `--audit-dir`, still stop them before deleting. Root scans with another user's `HOME` use a private temporary audit
|
|
237
253
|
directory. Set `--audit-dir` to choose a persistent writable location.
|
|
238
254
|
|
|
239
255
|
Long-lived `npm exec`/`npx` servers allow idle npm download and node-gyp
|
|
@@ -290,7 +306,8 @@ Each scan records swap usage and its largest holders in the state directory, so
|
|
|
290
306
|
the next scan shows how much both grew during the last week. A `WARNING:` line
|
|
291
307
|
appears when swap grew by 1 GiB or more, is at least 75% full, or exceeds the free
|
|
292
308
|
disk space it can grow into, naming the processes holding the memory and their
|
|
293
|
-
growth.
|
|
309
|
+
growth. The dss run itself is listed as `(this dss run)` and never named as a
|
|
310
|
+
process to quit. Long-running AI agent sessions keep their history in memory: quitting and
|
|
294
311
|
resuming them (`codex resume <session-id>`, `claude --resume`) releases it. When
|
|
295
312
|
Docker Desktop consumes memory, saving work and restarting it or lowering its VM
|
|
296
313
|
memory limit can help. dss never deletes swap files.
|
|
@@ -394,6 +411,41 @@ is scanned as a new environment, down to `--depth` levels (default 3). The
|
|
|
394
411
|
CI job `Docker-in-Docker Integration` builds a host → l1 → l2 chain and runs
|
|
395
412
|
`tests/integration/dind.mjs` against it (`npm run test:dind`).
|
|
396
413
|
|
|
414
|
+
### Scan roots
|
|
415
|
+
|
|
416
|
+
Project discovery walks a list of roots per environment; the traversal
|
|
417
|
+
policy (which mount points are crossed) uses the same requested roots, so
|
|
418
|
+
both always agree:
|
|
419
|
+
|
|
420
|
+
- host: the positional `paths` (`roots` in the API), otherwise the homes and
|
|
421
|
+
temporary directories;
|
|
422
|
+
- every scanned container, at every Docker depth: `--container-root PATH`
|
|
423
|
+
(`containerRoots`, repeatable) replaces the defaults; without it, the
|
|
424
|
+
container's homes, temporary directories, existing common workspace paths
|
|
425
|
+
(`/workspace`, `/workspaces`, `/app`, `/src`, `/code`, `/project`,
|
|
426
|
+
`/usr/src/app`) and every host root that also exists inside the container.
|
|
427
|
+
Host and container paths are different namespaces, so no path is remapped.
|
|
428
|
+
|
|
429
|
+
Each JSON environment carries `scope`: its roots with `source` (`roots`,
|
|
430
|
+
`container-roots`, `home`, `tmp`, `container-default`) and `status`
|
|
431
|
+
(`searched`, `partial`, `absent`, `inaccessible`, `pruned`, `excluded`,
|
|
432
|
+
`not-searched`), plus `maxDepth`, `exclude` and `prunedMounts`. The text
|
|
433
|
+
report names requested roots that were not searched. `scanStatus: complete`
|
|
434
|
+
means complete within that scope; exclusions, mount boundaries and liveness
|
|
435
|
+
checks still apply.
|
|
436
|
+
|
|
437
|
+
### Hard links
|
|
438
|
+
|
|
439
|
+
Sizes are physical reclaimable bytes. A file's blocks count toward an item
|
|
440
|
+
only when every hard link to it lies inside the deletion set: a package
|
|
441
|
+
cache or `node_modules` hard-linked to a shared store (pnpm, Nix, uv) reports
|
|
442
|
+
those blocks as `sharedBytes`, next to `allocatedBytes` (what `du` prints).
|
|
443
|
+
Each `dev:inode` is counted once across the whole selection, so files linked
|
|
444
|
+
between two selected paths are reclaimable once. In the audit log,
|
|
445
|
+
`sharedBytes` lists blocks that stayed allocated; `freedBytes` is a measured
|
|
446
|
+
before/after value for Docker writable layers and otherwise the link-aware
|
|
447
|
+
estimate, marked with `freedEstimated` and `freedAccounting`.
|
|
448
|
+
|
|
397
449
|
## Emergency mode
|
|
398
450
|
|
|
399
451
|
`dss emergency --free 20G` (at least 20 GiB available) or
|
|
@@ -759,3 +811,33 @@ Quick steps:
|
|
|
759
811
|
## License
|
|
760
812
|
|
|
761
813
|
[Unlicense](LICENSE) - Public Domain
|
|
814
|
+
|
|
815
|
+
### Explicit macOS app removal and leftovers
|
|
816
|
+
|
|
817
|
+
`dss apps remove "/Users/me/Applications/Example.app"` previews removal;
|
|
818
|
+
`--yes` moves only that bundle to the macOS Trash. It never empties Trash or
|
|
819
|
+
removes support data. App identity and process activity are checked before
|
|
820
|
+
removal; symlinked paths, nested app components and App Store receipts are
|
|
821
|
+
refused. Use a vendor uninstaller first when one exists.
|
|
822
|
+
|
|
823
|
+
Chrome web apps should first be uninstalled using `chrome://apps`.
|
|
824
|
+
`--launcher-only` explicitly allows moving their launcher bundles to Trash;
|
|
825
|
+
it preserves Chrome's app registration, cookies, profiles and website data,
|
|
826
|
+
so Chrome may recreate the launcher. The command is separate from automatic
|
|
827
|
+
cache cleanup. Removed URL handlers may be recreated by their owning tool.
|
|
828
|
+
|
|
829
|
+
`dss apps leftovers` reports possible leftover Library files, including logs.
|
|
830
|
+
An absent bundle in standard application folders is only a clue: apps can live
|
|
831
|
+
elsewhere, and helpers or other apps can share data. Caches and logs are marked
|
|
832
|
+
`disposable-after-activity-check`; settings, application support, containers,
|
|
833
|
+
and saved state require a user decision. Every candidate remains read-only,
|
|
834
|
+
including disposable candidates. No leftover is safe to delete unconditionally;
|
|
835
|
+
logs may be needed for investigation. Inventory errors appear in the report.
|
|
836
|
+
|
|
837
|
+
### Platform-specific competitor comparison
|
|
838
|
+
|
|
839
|
+
See the [feature comparison tables](docs/research/platform-feature-comparison.md)
|
|
840
|
+
for dss and 30 competitors across Linux, macOS, FreeBSD, OpenBSD, NetBSD,
|
|
841
|
+
illumos and AIX. Capability, interface and safety-backend tables distinguish
|
|
842
|
+
implemented features, vendor documentation, partial coverage and unknowns.
|
|
843
|
+
The comparison is a sourced inventory, not a performance or superiority claim.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "disk-space-saviour",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
4
4
|
"description": "Safely reclaims disk space: caches and build outputs of 20+ ecosystems, forgotten projects, and Docker (running, stopped and nested containers), with an emergency mode",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
package/src/audit.js
CHANGED
|
@@ -101,7 +101,38 @@ export async function writeAudit(audit, options = {}) {
|
|
|
101
101
|
dir,
|
|
102
102
|
`dss-${audit.command}-${stamp}-${audit.pid}.json`
|
|
103
103
|
);
|
|
104
|
-
|
|
104
|
+
// A write that fails half way (a full disk) leaves the previous record.
|
|
105
|
+
const partial = `${file}.${process.pid}.tmp`;
|
|
106
|
+
try {
|
|
107
|
+
await fsp.writeFile(partial, `${JSON.stringify(audit, null, 2)}\n`);
|
|
108
|
+
await fsp.rename(partial, file);
|
|
109
|
+
} catch (error) {
|
|
110
|
+
await fsp.rm(partial, { force: true }).catch(() => {});
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
105
113
|
audit.file = file;
|
|
106
114
|
return file;
|
|
107
115
|
}
|
|
116
|
+
|
|
117
|
+
/** Errors meaning the disk is full, which is what cleaning fixes. */
|
|
118
|
+
const NO_SPACE = new Set(['ENOSPC', 'EDQUOT']);
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Writes the record of a run that deletes. A full disk does not stop the
|
|
122
|
+
* run: the failure is kept in `audit.auditError`, and the next write, after
|
|
123
|
+
* deletions freed space, tries again.
|
|
124
|
+
* @returns {Promise<string|null>} the file, or null when the disk is full
|
|
125
|
+
*/
|
|
126
|
+
export async function recordAudit(audit, options = {}) {
|
|
127
|
+
try {
|
|
128
|
+
const file = await writeAudit(audit, options);
|
|
129
|
+
delete audit.auditError;
|
|
130
|
+
return file;
|
|
131
|
+
} catch (error) {
|
|
132
|
+
if (!NO_SPACE.has(error.code)) {
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
135
|
+
audit.auditError = { message: error.message, code: error.code };
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
}
|
package/src/clean.js
CHANGED
|
@@ -12,12 +12,13 @@
|
|
|
12
12
|
* log.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
import { selectionBytes } from './env/hardlinks.js';
|
|
15
16
|
import { once } from 'node:events';
|
|
16
17
|
import { createWriteStream, promises as fsp } from 'node:fs';
|
|
17
18
|
import path from 'node:path';
|
|
18
19
|
import { finished } from 'node:stream/promises';
|
|
19
20
|
|
|
20
|
-
import { backupDirectory,
|
|
21
|
+
import { backupDirectory, recordAudit, startAudit } from './audit.js';
|
|
21
22
|
import { PhaseTimer } from './timings.js';
|
|
22
23
|
import { configureContainerHost } from './docker/writable.js';
|
|
23
24
|
import { DockerCli, assertAllowed, parseDockerSize } from './docker/cli.js';
|
|
@@ -45,6 +46,7 @@ import { isWithin } from './paths.js';
|
|
|
45
46
|
import { OTHER_RULES } from './rules/other.js';
|
|
46
47
|
import { compareVersions, VERSION_RULES } from './rules/versions.js';
|
|
47
48
|
import { scanVersions } from './scanners/versions.js';
|
|
49
|
+
import { foreignDataBlocker } from './scanners/node-modules.js';
|
|
48
50
|
import { analyzeRustProfile } from './scanners/rust.js';
|
|
49
51
|
import {
|
|
50
52
|
filterItems,
|
|
@@ -78,6 +80,7 @@ export function cleanOrder(a, b) {
|
|
|
78
80
|
);
|
|
79
81
|
}
|
|
80
82
|
|
|
83
|
+
/** Plain per-path sum: used for before/after measurements of one set. */
|
|
81
84
|
function sumBytes(usages, paths = [...usages.keys()]) {
|
|
82
85
|
let total = 0;
|
|
83
86
|
for (const target of paths) {
|
|
@@ -192,6 +195,7 @@ export class Cleaner {
|
|
|
192
195
|
git: new GitInspector(env),
|
|
193
196
|
liveness: new LivenessProbe(env, {
|
|
194
197
|
staleAgeMs: this.options.staleAgeMs,
|
|
198
|
+
inactiveMs: this.options.inactiveMs,
|
|
195
199
|
now: this.options.now,
|
|
196
200
|
}),
|
|
197
201
|
});
|
|
@@ -332,7 +336,7 @@ export class Cleaner {
|
|
|
332
336
|
const fresh = {
|
|
333
337
|
...item,
|
|
334
338
|
paths,
|
|
335
|
-
bytes:
|
|
339
|
+
bytes: selectionBytes(usages, paths).bytes,
|
|
336
340
|
newestMtimeMs: newest || item.newestMtimeMs,
|
|
337
341
|
action: type === 'remove' ? { ...item.action, paths } : item.action,
|
|
338
342
|
};
|
|
@@ -447,6 +451,12 @@ export class Cleaner {
|
|
|
447
451
|
if (mount) {
|
|
448
452
|
return mount;
|
|
449
453
|
}
|
|
454
|
+
if (item.rule === 'node-modules' && type === 'remove') {
|
|
455
|
+
const foreign = await foreignDataBlocker(ctx.env, item.path);
|
|
456
|
+
if (foreign) {
|
|
457
|
+
return foreign;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
450
460
|
return this.#gitBlocker(ctx, item);
|
|
451
461
|
}
|
|
452
462
|
|
|
@@ -551,7 +561,12 @@ export class Cleaner {
|
|
|
551
561
|
entry.reason = boundary;
|
|
552
562
|
return;
|
|
553
563
|
}
|
|
554
|
-
await this.#deletePaths(
|
|
564
|
+
await this.#deletePaths(
|
|
565
|
+
ctx,
|
|
566
|
+
entry,
|
|
567
|
+
[target],
|
|
568
|
+
selectionBytes(new Map([[target, usage]]), [target])
|
|
569
|
+
);
|
|
555
570
|
}
|
|
556
571
|
if (entry.deletedPaths.length === 0 && entry.skippedPaths.length > 0) {
|
|
557
572
|
entry.reason = 'every path was in use or recently written';
|
|
@@ -591,7 +606,12 @@ export class Cleaner {
|
|
|
591
606
|
if (!selected.length) {
|
|
592
607
|
return true;
|
|
593
608
|
}
|
|
594
|
-
await this.#deletePaths(
|
|
609
|
+
await this.#deletePaths(
|
|
610
|
+
ctx,
|
|
611
|
+
entry,
|
|
612
|
+
selected,
|
|
613
|
+
selectionBytes(usages, selected)
|
|
614
|
+
);
|
|
595
615
|
const lost = await this.health.check(ctx.env, item);
|
|
596
616
|
if (lost) {
|
|
597
617
|
entry.reason = lost;
|
|
@@ -630,7 +650,12 @@ export class Cleaner {
|
|
|
630
650
|
return usage.bytes === 0 ? 'no writable bytes remain' : null;
|
|
631
651
|
}
|
|
632
652
|
|
|
633
|
-
|
|
653
|
+
/** `accounted`: hard-link aware {bytes, sharedBytes} of `selected`. */
|
|
654
|
+
async #deletePaths(ctx, entry, selected, accounted) {
|
|
655
|
+
const estimate = accounted.bytes;
|
|
656
|
+
if (accounted.sharedBytes > 0) {
|
|
657
|
+
entry.sharedBytes = (entry.sharedBytes ?? 0) + accounted.sharedBytes;
|
|
658
|
+
}
|
|
634
659
|
const before = await ctx.env.writableLayer?.allocatedBytes?.(
|
|
635
660
|
selected,
|
|
636
661
|
ctx.env.path
|
|
@@ -653,9 +678,10 @@ export class Cleaner {
|
|
|
653
678
|
if (typeof before === 'number' && typeof after === 'number') {
|
|
654
679
|
return Math.max(0, before - after);
|
|
655
680
|
}
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
681
|
+
// Without a block measurement the hard-link aware selection estimate
|
|
682
|
+
// is reported, and marked as an estimate.
|
|
683
|
+
entry.freedEstimated = true;
|
|
684
|
+
entry.freedAccounting = 'hardlink-aware-estimate';
|
|
659
685
|
return fallback;
|
|
660
686
|
}
|
|
661
687
|
|
|
@@ -959,7 +985,7 @@ export async function clean(report, input = {}) {
|
|
|
959
985
|
}
|
|
960
986
|
if (options.audit !== false) {
|
|
961
987
|
await timings.measure('audit-write', () =>
|
|
962
|
-
|
|
988
|
+
recordAudit(audit, { ...options, inProgress: true })
|
|
963
989
|
);
|
|
964
990
|
}
|
|
965
991
|
};
|
|
@@ -971,7 +997,7 @@ export async function clean(report, input = {}) {
|
|
|
971
997
|
audit.health = cleaner.health.records;
|
|
972
998
|
audit.timings = timings;
|
|
973
999
|
if (options.audit !== false) {
|
|
974
|
-
await
|
|
1000
|
+
await recordAudit(audit, { ...options, inProgress: true });
|
|
975
1001
|
}
|
|
976
1002
|
try {
|
|
977
1003
|
const done = new Set(audit.entries.map((entry) => entry.id));
|
|
@@ -988,7 +1014,7 @@ export async function clean(report, input = {}) {
|
|
|
988
1014
|
options.onEntry?.(entry, item);
|
|
989
1015
|
if (options.audit !== false) {
|
|
990
1016
|
await timings.measure('audit-write', () =>
|
|
991
|
-
|
|
1017
|
+
recordAudit(audit, { ...options, inProgress: true })
|
|
992
1018
|
);
|
|
993
1019
|
}
|
|
994
1020
|
}
|
|
@@ -1025,7 +1051,7 @@ export async function finishAudit(audit, report, options) {
|
|
|
1025
1051
|
)
|
|
1026
1052
|
.map((env) => ({ label: env.label, status: env.scanStatus }));
|
|
1027
1053
|
if (options.audit !== false) {
|
|
1028
|
-
await
|
|
1054
|
+
await recordAudit(audit, options);
|
|
1029
1055
|
}
|
|
1030
1056
|
return audit;
|
|
1031
1057
|
}
|
package/src/cli.js
CHANGED
|
@@ -13,6 +13,7 @@ import { parseArgs } from 'node:util';
|
|
|
13
13
|
|
|
14
14
|
import { startAudit, writeAudit } from './audit.js';
|
|
15
15
|
import { clean } from './clean.js';
|
|
16
|
+
import { runApps } from './mac-apps.js';
|
|
16
17
|
import { exitedBlockerPids, pidAlive } from './conditions.js';
|
|
17
18
|
import { emergency } from './emergency.js';
|
|
18
19
|
import { setTrace } from './exec.js';
|
|
@@ -34,6 +35,8 @@ Commands:
|
|
|
34
35
|
largest directories and files, what dss can
|
|
35
36
|
free inside them, APFS volumes (never deletes)
|
|
36
37
|
docker scan|clean Only Docker: daemons, containers, nested daemons
|
|
38
|
+
apps remove APP.app Move explicitly selected macOS apps to Trash
|
|
39
|
+
apps leftovers Report possible app leftovers (read-only)
|
|
37
40
|
help, version
|
|
38
41
|
|
|
39
42
|
Scope:
|
|
@@ -43,6 +46,9 @@ Scope:
|
|
|
43
46
|
--depth N Docker nesting depth to recurse into (default 3)
|
|
44
47
|
--recursive docker: recurse into nested daemons
|
|
45
48
|
--container ID|NAME limit Docker work to these containers (repeat)
|
|
49
|
+
--container-root PATH project root inside every scanned container,
|
|
50
|
+
at any Docker depth; replaces the container
|
|
51
|
+
defaults (repeat)
|
|
46
52
|
--max-depth N directory depth for project search (default 6)
|
|
47
53
|
--scan-budget AGE time budget per filesystem environment (2m)
|
|
48
54
|
--only NAME|ID|PATH only these ecosystems, rules, kinds, item ids,
|
|
@@ -91,6 +97,7 @@ const OPTIONS = {
|
|
|
91
97
|
depth: { type: 'string' },
|
|
92
98
|
recursive: { type: 'boolean' },
|
|
93
99
|
container: { type: 'string', multiple: true },
|
|
100
|
+
'container-root': { type: 'string', multiple: true },
|
|
94
101
|
'max-depth': { type: 'string' },
|
|
95
102
|
'scan-budget': { type: 'string' },
|
|
96
103
|
only: { type: 'string', multiple: true },
|
|
@@ -170,6 +177,7 @@ function tierOf(values) {
|
|
|
170
177
|
export function toOptions(values, paths = []) {
|
|
171
178
|
return {
|
|
172
179
|
roots: paths.length > 0 ? paths : null,
|
|
180
|
+
containerRoots: values['container-root'],
|
|
173
181
|
docker: dockerMode(values),
|
|
174
182
|
dockerDepth: integer(values.depth, '--depth'),
|
|
175
183
|
containers: values.container,
|
|
@@ -539,6 +547,9 @@ function dispatch(cli, command, options, values) {
|
|
|
539
547
|
export async function runCli(argv, deps = {}) {
|
|
540
548
|
const io = deps.io ?? processIo();
|
|
541
549
|
try {
|
|
550
|
+
if (argv[0] === 'apps') {
|
|
551
|
+
return await runApps(argv.slice(1), io, deps.signal);
|
|
552
|
+
}
|
|
542
553
|
const { command, paths, values } = parseCli(argv);
|
|
543
554
|
if (command === 'help') {
|
|
544
555
|
io.stdout(USAGE);
|
package/src/conditions.js
CHANGED
|
@@ -47,6 +47,13 @@ const CONDITIONS = [
|
|
|
47
47
|
action: `they stay untouched for the ${m[1]} activity window`,
|
|
48
48
|
}),
|
|
49
49
|
],
|
|
50
|
+
[
|
|
51
|
+
/^(?:busy: )?inside the scratchpad of Claude Code session (\S+) .*; kept until it is idle for (.+)$/,
|
|
52
|
+
(m) => ({
|
|
53
|
+
key: `session:${m[1]}`,
|
|
54
|
+
action: `the Claude Code session ${m[1]} stays idle for ${m[2]}`,
|
|
55
|
+
}),
|
|
56
|
+
],
|
|
50
57
|
[
|
|
51
58
|
/^scan incomplete: (.+)$/,
|
|
52
59
|
(m) => ({
|
package/src/docker/scan.js
CHANGED
|
@@ -632,6 +632,7 @@ class DockerScan {
|
|
|
632
632
|
this.result.items.push(...(await this.scanEnvironment(env)));
|
|
633
633
|
Object.assign(descriptor, {
|
|
634
634
|
scanStatus: env.scanStatus,
|
|
635
|
+
scope: env.scanScope ?? null,
|
|
635
636
|
hint: env.probeHint ?? null,
|
|
636
637
|
layerSource: env.writableLayer?.source ?? null,
|
|
637
638
|
timings: env.timings?.toJSON() ?? [],
|
package/src/docker/writable.js
CHANGED
|
@@ -213,9 +213,15 @@ export class WritableLayer {
|
|
|
213
213
|
)
|
|
214
214
|
)
|
|
215
215
|
: 0;
|
|
216
|
+
// Upper-layer blocks are measured directly: merged-view link data
|
|
217
|
+
// does not describe them, so the writable bytes are final.
|
|
218
|
+
const plain = { ...usage };
|
|
219
|
+
delete plain.links;
|
|
220
|
+
delete plain.sharedBytes;
|
|
216
221
|
result.set(target, {
|
|
217
|
-
...
|
|
222
|
+
...plain,
|
|
218
223
|
bytes,
|
|
224
|
+
allocatedBytes: bytes,
|
|
219
225
|
totalBytes: usage.bytes,
|
|
220
226
|
imageBytes: known ? Math.max(0, usage.bytes - bytes) : 0,
|
|
221
227
|
sizeUnknown: !known,
|
package/src/emergency.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* kept for investigation needs its own `removeContainers` entry.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import {
|
|
15
|
+
import { recordAudit, startAudit, throwIfAborted } from './audit.js';
|
|
16
16
|
import { Cleaner, cleanOrder, finishAudit, wantedItems } from './clean.js';
|
|
17
17
|
import { LocalEnv } from './env/local.js';
|
|
18
18
|
import { TIERS, selectByTier, tierRank } from './items.js';
|
|
@@ -120,7 +120,7 @@ export async function emergency(input = {}) {
|
|
|
120
120
|
});
|
|
121
121
|
audit.report = report;
|
|
122
122
|
if (options.audit !== false) {
|
|
123
|
-
await
|
|
123
|
+
await recordAudit(audit, { ...options, inProgress: true });
|
|
124
124
|
}
|
|
125
125
|
let disk = audit.diskAfter ?? diskBefore;
|
|
126
126
|
if (report.aborted) {
|
|
@@ -145,7 +145,7 @@ async function escalate({ report, options, env, target, goal, audit }) {
|
|
|
145
145
|
audit.entries.push(entry);
|
|
146
146
|
}
|
|
147
147
|
if (options.audit !== false) {
|
|
148
|
-
await
|
|
148
|
+
await recordAudit(audit, { ...options, inProgress: true });
|
|
149
149
|
}
|
|
150
150
|
};
|
|
151
151
|
const cleaner = new Cleaner(report, {
|
|
@@ -195,7 +195,7 @@ async function escalate({ report, options, env, target, goal, audit }) {
|
|
|
195
195
|
}
|
|
196
196
|
options.onEntry?.(entry, item);
|
|
197
197
|
if (options.audit !== false) {
|
|
198
|
-
await
|
|
198
|
+
await recordAudit(audit, { ...options, inProgress: true });
|
|
199
199
|
}
|
|
200
200
|
if (entry.status !== 'removed' && entry.status !== 'planned') {
|
|
201
201
|
continue;
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hard-link aware accounting of reclaimable blocks.
|
|
3
|
+
*
|
|
4
|
+
* `du` (and every per-path walk) counts each inode once per measured path,
|
|
5
|
+
* including inodes that also have links outside that path. Deleting such a
|
|
6
|
+
* path removes a directory entry but releases no file blocks while another
|
|
7
|
+
* link remains. A file's blocks are reclaimable only when every one of its
|
|
8
|
+
* links is inside the selected deletion set; several links that are all
|
|
9
|
+
* inside the set are reclaimable once.
|
|
10
|
+
*
|
|
11
|
+
* Usage objects carry:
|
|
12
|
+
* - `allocatedBytes`: blocks reachable from the path (what `du` prints);
|
|
13
|
+
* - `sharedBytes`: blocks of multi-link files with a link outside the path;
|
|
14
|
+
* - `bytes`: `allocatedBytes - sharedBytes`, the physically reclaimable part;
|
|
15
|
+
* - `links`: the multi-link non-directory entries below the path
|
|
16
|
+
* (`{path, id, nlink, bytes}`), used to account a whole selection.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** POSIX shell fragment listing multi-link files below "$t" (GNU/busybox or BSD stat). */
|
|
20
|
+
export const LINKS_SCRIPT = `if stat -c %h / >/dev/null 2>&1; then
|
|
21
|
+
find "$t" -xdev ! -type d -links +1 -exec stat -c '%d:%i %h %b %B %n' -- {} + 2>/dev/null
|
|
22
|
+
else
|
|
23
|
+
find "$t" -xdev ! -type d -links +1 -exec stat -f '%d:%i %l %b 512 %N' {} + 2>/dev/null
|
|
24
|
+
fi`;
|
|
25
|
+
|
|
26
|
+
/** Parses LINKS_SCRIPT output lines (`dev:ino nlink blocks blocksize path`). */
|
|
27
|
+
export function parseLinks(text) {
|
|
28
|
+
const links = [];
|
|
29
|
+
for (const line of (text ?? '').split('\n')) {
|
|
30
|
+
const match = /^(\d+:\d+) (\d+) (\d+) (\d+) (.+)$/.exec(line);
|
|
31
|
+
if (match) {
|
|
32
|
+
links.push({
|
|
33
|
+
id: match[1],
|
|
34
|
+
nlink: Number(match[2]),
|
|
35
|
+
bytes: Number(match[3]) * Number(match[4]),
|
|
36
|
+
path: match[5],
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return links;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function groupLinks(groups) {
|
|
44
|
+
const inodes = new Map();
|
|
45
|
+
for (const [target, links] of groups) {
|
|
46
|
+
for (const link of links ?? []) {
|
|
47
|
+
if (!inodes.has(link.id)) {
|
|
48
|
+
inodes.set(link.id, {
|
|
49
|
+
nlink: link.nlink,
|
|
50
|
+
bytes: link.bytes,
|
|
51
|
+
paths: new Set(),
|
|
52
|
+
targets: new Set(),
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
const inode = inodes.get(link.id);
|
|
56
|
+
inode.nlink = Math.max(inode.nlink, link.nlink);
|
|
57
|
+
inode.paths.add(link.path);
|
|
58
|
+
inode.targets.add(target);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return inodes;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Accounts a deletion set measured per path.
|
|
66
|
+
* @param {Array<[string, {allocated: number, links?: object[]}]>} measured
|
|
67
|
+
* one entry per selected path: its per-path allocated bytes (each inode
|
|
68
|
+
* counted once within that path) and its multi-link entries
|
|
69
|
+
* @returns {{allocatedBytes: number, sharedBytes: number, bytes: number}}
|
|
70
|
+
* blocks reachable from the set (each inode once), blocks retained by
|
|
71
|
+
* links outside the set, and the physically reclaimable remainder
|
|
72
|
+
*/
|
|
73
|
+
export function accountLinks(measured) {
|
|
74
|
+
let allocated = measured.reduce((sum, [, entry]) => sum + entry.allocated, 0);
|
|
75
|
+
let shared = 0;
|
|
76
|
+
const inodes = groupLinks(
|
|
77
|
+
measured.map(([target, entry]) => [target, entry.links])
|
|
78
|
+
);
|
|
79
|
+
for (const inode of inodes.values()) {
|
|
80
|
+
// Per-path walks counted this inode once in every path containing it.
|
|
81
|
+
allocated -= (inode.targets.size - 1) * inode.bytes;
|
|
82
|
+
if (inode.paths.size < inode.nlink) {
|
|
83
|
+
shared += inode.bytes;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
allocated = Math.max(0, allocated);
|
|
87
|
+
shared = Math.min(shared, allocated);
|
|
88
|
+
return {
|
|
89
|
+
allocatedBytes: allocated,
|
|
90
|
+
sharedBytes: shared,
|
|
91
|
+
bytes: allocated - shared,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Adds allocated/shared/reclaimable fields to one path's usage. */
|
|
96
|
+
export function withLinkAccounting(usage, links) {
|
|
97
|
+
if (!usage) {
|
|
98
|
+
return usage;
|
|
99
|
+
}
|
|
100
|
+
const allocated = usage.allocatedBytes ?? usage.bytes;
|
|
101
|
+
const accounted = accountLinks([['', { allocated, links }]]);
|
|
102
|
+
return {
|
|
103
|
+
...usage,
|
|
104
|
+
bytes: accounted.bytes,
|
|
105
|
+
allocatedBytes: accounted.allocatedBytes,
|
|
106
|
+
sharedBytes: accounted.sharedBytes,
|
|
107
|
+
...(links?.length ? { links } : {}),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Reclaimable bytes of several selected paths of one environment, from
|
|
113
|
+
* their usages. Links between selected paths are reclaimable; links
|
|
114
|
+
* outside the selection are not.
|
|
115
|
+
*/
|
|
116
|
+
export function selectionBytes(usages, selected) {
|
|
117
|
+
const measured = selected
|
|
118
|
+
.map((target) => [target, usages.get(target)])
|
|
119
|
+
.filter(([, usage]) => usage)
|
|
120
|
+
.map(([target, usage]) => [
|
|
121
|
+
target,
|
|
122
|
+
{
|
|
123
|
+
allocated: usage.allocatedBytes ?? usage.bytes,
|
|
124
|
+
links: usage.links,
|
|
125
|
+
},
|
|
126
|
+
]);
|
|
127
|
+
return accountLinks(measured);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Item size fields of the usages of one item's paths: `bytes` is the
|
|
132
|
+
* reclaimable total; `allocatedBytes`/`sharedBytes` are added only when
|
|
133
|
+
* hard links outside the item retain blocks.
|
|
134
|
+
*/
|
|
135
|
+
export function itemSize(entries) {
|
|
136
|
+
const usages = new Map(entries.filter(([, usage]) => usage));
|
|
137
|
+
const accounted = selectionBytes(usages, [...usages.keys()]);
|
|
138
|
+
return accounted.sharedBytes > 0 ? accounted : { bytes: accounted.bytes };
|
|
139
|
+
}
|