@indigoai-us/hq-cli 5.345.147 → 5.345.149
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 +65 -0
- package/README.md +2 -0
- package/assets/bot-workers/setup/worker.yaml +11 -0
- package/dist/command-catalog.generated.d.ts +341 -11
- package/dist/command-catalog.generated.js +421 -11
- package/dist/command-help.js +2 -1
- package/dist/commands/access.d.ts +45 -2
- package/dist/commands/access.js +537 -223
- package/dist/commands/agent-inbox.js +2 -1
- package/dist/commands/billing-usage.d.ts +33 -2
- package/dist/commands/billing-usage.js +75 -4
- package/dist/commands/bot.js +2 -2
- package/dist/commands/channels.d.ts +27 -0
- package/dist/commands/channels.js +108 -4
- package/dist/commands/cloud.js +31 -1
- package/dist/commands/core-refresh-vault-access-native.d.ts +33 -0
- package/dist/commands/core-refresh-vault-access-native.js +356 -28
- package/dist/commands/core-work-mesh-bind-trusted-native.d.ts +9 -0
- package/dist/commands/core-work-mesh-bind-trusted-native.js +26 -19
- package/dist/commands/core.js +9 -0
- package/dist/commands/daemon.d.ts +7 -0
- package/dist/commands/daemon.js +37 -3
- package/dist/commands/dm.js +18 -3
- package/dist/commands/doctor.d.ts +11 -1
- package/dist/commands/doctor.js +56 -1
- package/dist/commands/install-global.d.ts +8 -8
- package/dist/commands/install-global.js +139 -61
- package/dist/commands/lanes-shared.d.ts +3 -1
- package/dist/commands/lanes.d.ts +4 -4
- package/dist/commands/lanes.js +380 -25
- package/dist/commands/library-add.d.ts +31 -1
- package/dist/commands/library-add.js +69 -2
- package/dist/commands/library.js +37 -3
- package/dist/commands/pack-install.d.ts +2 -2
- package/dist/commands/pack-install.js +133 -35
- package/dist/commands/reindex.js +19 -0
- package/dist/commands/sync-manifest.d.ts +2 -9
- package/dist/commands/sync-manifest.js +4 -15
- package/dist/hook-engine-path.d.ts +4 -0
- package/dist/hook-engine-path.js +43 -0
- package/dist/hq-cloud-sync-runner/bin/sync-runner.js +2783 -2354
- package/dist/hq-cloud-sync-runner/package.json +1 -1
- package/dist/index.d.ts +10 -0
- package/dist/index.js +76 -5
- package/dist/lazy-commands.js +2 -0
- package/dist/lib/agent-kit/inbox-redact.d.ts +44 -0
- package/dist/lib/agent-kit/inbox-redact.js +361 -0
- package/dist/lib/agent-kit/inbox-state.js +11 -2
- package/dist/lib/agent-kit/mcp/tools.js +2 -1
- package/dist/lib/agent-kit/run/inbox.js +36 -7
- package/dist/lib/anywhere-runtime-flag-key.d.ts +3 -0
- package/dist/lib/anywhere-runtime-flag-key.js +9 -0
- package/dist/lib/billing/usage-cost.d.ts +31 -1
- package/dist/lib/billing/usage-cost.js +46 -0
- package/dist/lib/company-skill-wrapper.js +6 -4
- package/dist/lib/daemon/install.d.ts +47 -1
- package/dist/lib/daemon/install.js +181 -15
- package/dist/lib/daemon/lock.d.ts +32 -0
- package/dist/lib/daemon/lock.js +55 -2
- package/dist/lib/db/local.d.ts +2 -2
- package/dist/lib/db/local.js +2 -2
- package/dist/lib/db/sql.js +2 -2
- package/dist/lib/db/sqlite-binding.d.ts +22 -0
- package/dist/lib/db/sqlite-binding.js +168 -0
- package/dist/lib/doctor/__testing__/run-doctor.js +4 -0
- package/dist/lib/doctor/checks/anywhere.d.ts +15 -0
- package/dist/lib/doctor/checks/anywhere.js +77 -11
- package/dist/lib/doctor/checks/cli-path.d.ts +12 -0
- package/dist/lib/doctor/checks/cli-path.js +46 -0
- package/dist/lib/doctor/checks/hook-engine.d.ts +4 -0
- package/dist/lib/doctor/checks/hook-engine.js +17 -0
- package/dist/lib/doctor/checks/sync-health.d.ts +7 -0
- package/dist/lib/doctor/checks/sync-health.js +38 -2
- package/dist/lib/doctor/checks/temp-homes.d.ts +19 -0
- package/dist/lib/doctor/checks/temp-homes.js +54 -0
- package/dist/lib/doctor/fix/apply.d.ts +90 -1
- package/dist/lib/doctor/fix/apply.js +176 -10
- package/dist/lib/doctor/fix/json-output.d.ts +4 -1
- package/dist/lib/doctor/fix/json-output.js +6 -0
- package/dist/lib/doctor/fix/remediation.d.ts +7 -1
- package/dist/lib/doctor/fix/remediation.js +23 -1
- package/dist/lib/doctor/registry.js +10 -0
- package/dist/lib/doctor/temp-home-sweeper.d.ts +149 -0
- package/dist/lib/doctor/temp-home-sweeper.js +418 -0
- package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
- package/dist/lib/flag-registry-worker-source.generated.js +1 -1
- package/dist/lib/flag-registry.js +9 -1
- package/dist/lib/flag-snapshot-file.js +8 -3
- package/dist/lib/hook-engine/reconcile.d.ts +9 -0
- package/dist/lib/hook-engine/reconcile.js +134 -0
- package/dist/lib/hook-engine/registration.d.ts +17 -0
- package/dist/lib/hook-engine/registration.js +50 -0
- package/dist/lib/hook-engine/resolve.d.ts +31 -0
- package/dist/lib/hook-engine/resolve.js +134 -0
- package/dist/lib/install/claude-targets.d.ts +2 -1
- package/dist/lib/install/claude-targets.js +8 -3
- package/dist/lib/install/codex-targets.d.ts +3 -0
- package/dist/lib/install/codex-targets.js +15 -4
- package/dist/lib/install/hook-target.d.ts +9 -0
- package/dist/lib/install/hook-target.js +39 -0
- package/dist/lib/lanes/admission-host.d.ts +1 -0
- package/dist/lib/lanes/admission-host.js +8 -1
- package/dist/lib/lanes/admission.d.ts +4 -0
- package/dist/lib/lanes/admission.js +6 -2
- package/dist/lib/lanes/envelope-repair-dispatch.d.ts +5 -0
- package/dist/lib/lanes/envelope-repair-dispatch.js +12 -1
- package/dist/lib/lanes/index.d.ts +2 -2
- package/dist/lib/lanes/index.js +1 -1
- package/dist/lib/lanes/keep-alive.js +4 -1
- package/dist/lib/lanes/local-host-identity.d.ts +40 -0
- package/dist/lib/lanes/local-host-identity.js +314 -0
- package/dist/lib/lanes/reaper.d.ts +1 -1
- package/dist/lib/lanes/reaper.js +7 -0
- package/dist/lib/lanes/reconcile.d.ts +4 -0
- package/dist/lib/lanes/reconcile.js +16 -8
- package/dist/lib/lanes/spawn.d.ts +20 -0
- package/dist/lib/lanes/spawn.js +48 -2
- package/dist/lib/lanes/status-fields.js +6 -1
- package/dist/lib/lanes/store.js +8 -1
- package/dist/lib/lanes/throttle.d.ts +49 -6
- package/dist/lib/lanes/throttle.js +154 -17
- package/dist/lib/lanes/types.d.ts +22 -1
- package/dist/lib/lanes/types.js +27 -3
- package/dist/lib/lanes/watcher.js +27 -2
- package/dist/lib/lanes/worker-continuity.d.ts +2 -0
- package/dist/lib/lanes/worker-continuity.js +18 -11
- package/dist/lib/manifest-disabled.d.ts +13 -0
- package/dist/lib/manifest-disabled.js +19 -0
- package/dist/lib/service-manager/launchd.d.ts +1 -0
- package/dist/lib/service-manager/launchd.js +1 -1
- package/dist/lib/windows/console-encoding.d.ts +24 -0
- package/dist/lib/windows/console-encoding.js +83 -0
- package/dist/lib/windows/install-followup.d.ts +35 -0
- package/dist/lib/windows/install-followup.js +142 -0
- package/dist/lib/windows/user-path.d.ts +125 -0
- package/dist/lib/windows/user-path.js +275 -0
- package/dist/lib/work-context/auto-bind.d.ts +1 -1
- package/dist/lib/work-context/auto-bind.js +22 -3
- package/dist/lib/work-context/lane-bind.d.ts +31 -0
- package/dist/lib/work-context/lane-bind.js +101 -0
- package/dist/lib/work-context/organize.js +1 -0
- package/dist/lib/work-context/prd-sync.js +7 -0
- package/dist/lib/work-context/project.d.ts +36 -10
- package/dist/lib/work-context/project.js +130 -41
- package/dist/lib/work-context/reconcile.d.ts +6 -0
- package/dist/lib/work-context/reconcile.js +25 -5
- package/dist/lib/work-context/state.d.ts +6 -0
- package/dist/lib/work-context/state.js +29 -1
- package/dist/main.js +2 -1
- package/dist/unknown-command.d.ts +18 -0
- package/dist/unknown-command.js +88 -0
- package/dist/utils/cognito-session.d.ts +8 -0
- package/dist/utils/cognito-session.js +14 -2
- package/dist/utils/copy-tree.d.ts +61 -0
- package/dist/utils/copy-tree.js +249 -0
- package/dist/utils/environmental-error.js +7 -3
- package/dist/utils/hook-trust.d.ts +9 -0
- package/dist/utils/hook-trust.js +30 -2
- package/dist/utils/npm-invocation.d.ts +27 -0
- package/dist/utils/npm-invocation.js +54 -0
- package/dist/utils/qmd-native-binding-error.js +3 -1
- package/dist/utils/resolve-executable.d.ts +2 -0
- package/dist/utils/resolve-executable.js +6 -2
- package/dist/utils/safe-link.d.ts +50 -0
- package/dist/utils/safe-link.js +300 -0
- package/package.json +17 -5
- package/scripts/hook-engine-manifest.mjs +22 -0
- package/scripts/install-hook-engine.mjs +10 -0
|
@@ -29,6 +29,12 @@
|
|
|
29
29
|
* keep`, no hook-file rewrite. Leftover shards
|
|
30
30
|
* without a local tree stay unfixed (the check reports
|
|
31
31
|
* NA).
|
|
32
|
+
* - `refresh-manifest` — re-run `hq sync manifest --scope <slug|personal>`
|
|
33
|
+
* for a manifest scope whose last upload is past the
|
|
34
|
+
* stale threshold. Upload-only metadata (file paths
|
|
35
|
+
* and hashes); it never writes the HQ tree. A company
|
|
36
|
+
* scope whose slug did not resolve locally stays
|
|
37
|
+
* manual-only.
|
|
32
38
|
*
|
|
33
39
|
* EVERYTHING else — content drift between platform copies, a missing script, a
|
|
34
40
|
* missing Codex counterpart, an unquoted `$CLAUDE_PROJECT_DIR`, a stale
|
|
@@ -38,7 +44,7 @@
|
|
|
38
44
|
* content-drift finding is manual-only, because that boundary is load-bearing:
|
|
39
45
|
* deciding which of two diverged copies is correct needs human judgement.
|
|
40
46
|
*/
|
|
41
|
-
import { isCompanyJournalSlug, isPersonalJournalSlug, journalSlugFromCheckId, } from "../checks/sync-health.js";
|
|
47
|
+
import { isCompanyJournalSlug, isSafeCompanySlug, isPersonalJournalSlug, journalSlugFromCheckId, } from "../checks/sync-health.js";
|
|
42
48
|
/** Matches an exec-bit remediation command, quoted or not: `chmod +x <path>`. */
|
|
43
49
|
const CHMOD_EXEC = /^chmod\s+\+x\s+(.+)$/;
|
|
44
50
|
/**
|
|
@@ -171,6 +177,22 @@ export function deriveRemediation(result) {
|
|
|
171
177
|
command: `hq sync pull --company ${journalSlug} --on-conflict keep`,
|
|
172
178
|
};
|
|
173
179
|
}
|
|
180
|
+
// refresh-manifest — a stale manifest upload. The check sets `target` to the
|
|
181
|
+
// `--scope` value only when it resolved one it can name safely.
|
|
182
|
+
if (checkId.startsWith("sync.manifest.") &&
|
|
183
|
+
result.status === "WARN" &&
|
|
184
|
+
result.reasonCode === "stale-threshold" &&
|
|
185
|
+
target &&
|
|
186
|
+
(target === "personal" || isSafeCompanySlug(target))) {
|
|
187
|
+
const scopeLabel = target === "personal" ? "the personal vault" : `company '${target}'`;
|
|
188
|
+
return {
|
|
189
|
+
autoFixable: true,
|
|
190
|
+
fixClass: "refresh-manifest",
|
|
191
|
+
fixTarget: target,
|
|
192
|
+
action: `Upload a fresh sync manifest for ${scopeLabel}.`,
|
|
193
|
+
command: `hq sync manifest --scope ${target}`,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
174
196
|
// Manual-only: content drift, missing scripts/counterparts, unquoted
|
|
175
197
|
// expansions, stale allowed-divergence entries, invalid settings, etc. `--fix`
|
|
176
198
|
// never touches these.
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* later stories add per-item results to this family without touching the engine.
|
|
15
15
|
*/
|
|
16
16
|
import * as fs from "node:fs";
|
|
17
|
+
import { hookEngineFamily } from './checks/hook-engine.js';
|
|
17
18
|
import * as path from "node:path";
|
|
18
19
|
import { checkCodexWiring } from "./checks/codex-wiring.js";
|
|
19
20
|
import { checkGrokWiring } from "./checks/grok-wiring.js";
|
|
@@ -27,6 +28,8 @@ import { anywhereFamily } from "./checks/anywhere.js";
|
|
|
27
28
|
import { scaffoldCliFloorFamily } from "./checks/scaffold-cli-floor.js";
|
|
28
29
|
import { strandedLanesFamily } from "./checks/stranded-lanes.js";
|
|
29
30
|
import { releaseLinksFamily } from "./checks/release-links.js";
|
|
31
|
+
import { cliPathFamily } from "./checks/cli-path.js";
|
|
32
|
+
import { tempHomesFamily } from "./checks/temp-homes.js";
|
|
30
33
|
import { fixtureCoverageFamily } from "./fixtures/discover.js";
|
|
31
34
|
import { checkClaudeWiring } from "./checks/claude-wiring.js";
|
|
32
35
|
/**
|
|
@@ -240,6 +243,7 @@ function hasCommandHook(settings, event) {
|
|
|
240
243
|
export function createDefaultRegistry() {
|
|
241
244
|
const registry = new DoctorRegistry();
|
|
242
245
|
registry.register(hooksFamily);
|
|
246
|
+
registry.register(hookEngineFamily);
|
|
243
247
|
registry.register(scaffoldCliFloorFamily);
|
|
244
248
|
// Fixture coverage (US-007): reports UNTESTED for every registered hook with
|
|
245
249
|
// no fixture and prints the tested/total coverage line. A separate family so
|
|
@@ -273,6 +277,12 @@ export function createDefaultRegistry() {
|
|
|
273
277
|
registry.register(strandedLanesFamily);
|
|
274
278
|
// Release symlinks left pointing at a deleted update staging folder.
|
|
275
279
|
registry.register(releaseLinksFamily);
|
|
280
|
+
// Windows: the npm hq.cmd shim folder must be on PATH for a new terminal to
|
|
281
|
+
// find hq. Registry read only; prints the fix, never edits PATH.
|
|
282
|
+
registry.register(cliPathFamily);
|
|
283
|
+
// Idle tmp.* throwaway homes holding a Rust toolchain download. Dry-run
|
|
284
|
+
// scan only; `hq doctor --sweep-temp-homes` is the delete path.
|
|
285
|
+
registry.register(tempHomesFamily);
|
|
276
286
|
return registry;
|
|
277
287
|
}
|
|
278
288
|
//# sourceMappingURL=registry.js.map
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sweeper for abandoned throwaway `$HOME` folders.
|
|
3
|
+
*
|
|
4
|
+
* Commands run as `HOME=$(mktemp -d) <cmd>` leave a `tmp.XXXXXXXX` folder in
|
|
5
|
+
* the temp dir. When the command touched Rust or pnpm tooling, that folder
|
|
6
|
+
* holds a full `.rustup` / `.cargo` download (1-2 GB) and nothing deletes it.
|
|
7
|
+
* On 2026-10-18 about 45 GB refilled within hours of a cleanup this way.
|
|
8
|
+
* `core/scripts/with-temp-home.sh` makes `hq-temp-home.XXXXXXXX` folders and
|
|
9
|
+
* removes them on exit, but a wrapper killed with SIGKILL leaves its folder.
|
|
10
|
+
*
|
|
11
|
+
* The sweep is deliberately narrow. A folder is removed only when ALL hold:
|
|
12
|
+
*
|
|
13
|
+
* - it is a direct child of a temp root (TMPDIR, os.tmpdir(), /tmp,
|
|
14
|
+
* /private/tmp, /var/folders/{a}/{b}/T) and its name starts with `tmp.` or
|
|
15
|
+
* `hq-temp-home.`;
|
|
16
|
+
* - `lstat` says it is a real directory (never a symlink) owned by this user;
|
|
17
|
+
* - a `tmp.*` folder holds a `.rustup` or `.cargo` directory (again by
|
|
18
|
+
* `lstat`). An `hq-temp-home.*` folder needs no marker: the name shows the
|
|
19
|
+
* wrapper made it;
|
|
20
|
+
* - nothing inside it, the folder itself included, was modified in the last
|
|
21
|
+
* 90 minutes;
|
|
22
|
+
* - no live process uses it: the wrapper's `.hq-temp-home.lock` does not name
|
|
23
|
+
* a live pid, and no process of this user has its cwd or an open file
|
|
24
|
+
* there (on Linux, or its `HOME` there either).
|
|
25
|
+
*
|
|
26
|
+
* The walk uses `lstat` throughout and never descends into a symlink, so a
|
|
27
|
+
* link inside a temp home that points elsewhere is neither measured nor
|
|
28
|
+
* followed. `fs.rmSync` unlinks symlinks rather than following them. A folder
|
|
29
|
+
* that cannot be fully read is kept, because an incomplete scan cannot prove
|
|
30
|
+
* the folder is idle. When the in-use probe fails, every candidate is kept.
|
|
31
|
+
*
|
|
32
|
+
* `shallow` mode is for plain `hq doctor`, which must stay fast. It checks
|
|
33
|
+
* only the folder and its direct children for recent changes, skips the size
|
|
34
|
+
* walk and the process probe, and reports no byte counts.
|
|
35
|
+
*/
|
|
36
|
+
import * as fs from "node:fs";
|
|
37
|
+
/** Folders younger than this (by newest mtime anywhere inside) are kept. */
|
|
38
|
+
export declare const TEMP_HOME_MIN_IDLE_MS: number;
|
|
39
|
+
/** Names that mark a `tmp.*` temp home as holding a toolchain download. */
|
|
40
|
+
export declare const TEMP_HOME_TOOLCHAIN_MARKERS: readonly [".rustup", ".cargo"];
|
|
41
|
+
/** Folders made by `HOME=$(mktemp -d)`; swept only with a toolchain marker. */
|
|
42
|
+
export declare const TEMP_HOME_MKTEMP_PREFIX = "tmp.";
|
|
43
|
+
/** Folders made by `core/scripts/with-temp-home.sh`. */
|
|
44
|
+
export declare const TEMP_HOME_WRAPPER_PREFIX = "hq-temp-home.";
|
|
45
|
+
/** Lock file the wrapper writes into its temp home; holds the wrapper pid. */
|
|
46
|
+
export declare const TEMP_HOME_LOCK_FILE = ".hq-temp-home.lock";
|
|
47
|
+
export type TempHomeAction = "removed" | "would-remove" | "kept" | "failed";
|
|
48
|
+
export type TempHomeKeptReason = "symlink" | "not-directory" | "not-owned" | "no-toolchain-marker" | "recent" | "in-use" | "in-use-unknown" | "unreadable" | "changed-before-delete";
|
|
49
|
+
export interface TempHomeEntry {
|
|
50
|
+
path: string;
|
|
51
|
+
action: TempHomeAction;
|
|
52
|
+
/** On-disk bytes. Present when the folder was fully measured. */
|
|
53
|
+
bytes?: number;
|
|
54
|
+
/** Why the folder was kept. Present only for `kept`. */
|
|
55
|
+
reason?: TempHomeKeptReason;
|
|
56
|
+
/** Error message. Present for `failed`, and for some `kept` entries. */
|
|
57
|
+
error?: string;
|
|
58
|
+
}
|
|
59
|
+
export interface TempHomeSweepResult {
|
|
60
|
+
dryRun: boolean;
|
|
61
|
+
/** True when the sweep ran in shallow mode (no sizes, no process probe). */
|
|
62
|
+
shallow: boolean;
|
|
63
|
+
/** Real paths of the temp roots that were scanned, deduplicated. */
|
|
64
|
+
roots: string[];
|
|
65
|
+
/** Every matching child that was examined, in scan order. */
|
|
66
|
+
entries: TempHomeEntry[];
|
|
67
|
+
/** Bytes removed, or bytes that would be removed in a dry run. 0 when shallow. */
|
|
68
|
+
bytesFreed: number;
|
|
69
|
+
/** `bytesFreed` in GB (10^9 bytes), rounded to two decimals. */
|
|
70
|
+
gbFreed: number;
|
|
71
|
+
}
|
|
72
|
+
/** File system calls the sweeper uses; a seam for tests. */
|
|
73
|
+
export interface TempHomeSweepFs {
|
|
74
|
+
lstatSync(target: string): fs.Stats;
|
|
75
|
+
readdirSync(target: string): string[];
|
|
76
|
+
realpathSync(target: string): string;
|
|
77
|
+
readFileSync(target: string, encoding: "utf8"): string;
|
|
78
|
+
rmSync(target: string, options: {
|
|
79
|
+
recursive: true;
|
|
80
|
+
force: false;
|
|
81
|
+
}): void;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Returns the subset of `homes` that a live process uses: its cwd or an open
|
|
85
|
+
* file is inside the home (and on Linux, its `HOME` is the home). Throws when
|
|
86
|
+
* it cannot tell; the sweep then keeps every candidate.
|
|
87
|
+
*/
|
|
88
|
+
export type TempHomeInUseProbe = (homes: readonly string[]) => Set<string>;
|
|
89
|
+
export interface TempHomeSweepOptions {
|
|
90
|
+
/** Temp roots to scan. Defaults to {@link defaultTempRoots}. */
|
|
91
|
+
roots?: readonly string[];
|
|
92
|
+
/** Report what would be removed without deleting anything. */
|
|
93
|
+
dryRun?: boolean;
|
|
94
|
+
/**
|
|
95
|
+
* Cheap scan for plain `hq doctor`: no size walk, no process probe, only
|
|
96
|
+
* the folder and its direct children checked for recent changes. Implies
|
|
97
|
+
* `dryRun`.
|
|
98
|
+
*/
|
|
99
|
+
shallow?: boolean;
|
|
100
|
+
/** Current time in ms. Defaults to `Date.now()`. */
|
|
101
|
+
nowMs?: number;
|
|
102
|
+
/** Minimum idle time in ms. Defaults to {@link TEMP_HOME_MIN_IDLE_MS}. */
|
|
103
|
+
minIdleMs?: number;
|
|
104
|
+
/** Owner uid to require. Defaults to `process.getuid()` when it exists. */
|
|
105
|
+
uid?: number;
|
|
106
|
+
fs?: TempHomeSweepFs;
|
|
107
|
+
/** Live-process probe. Defaults to {@link defaultInUseProbe}. */
|
|
108
|
+
inUse?: TempHomeInUseProbe;
|
|
109
|
+
/** Whether a pid is alive. Defaults to `process.kill(pid, 0)`. */
|
|
110
|
+
isPidAlive?: (pid: number) => boolean;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* The temp roots a throwaway home can land in: `$TMPDIR`, `os.tmpdir()`,
|
|
114
|
+
* `/tmp`, `/private/tmp`, and every per-user macOS temp dir
|
|
115
|
+
* `/var/folders/{a}/{b}/T`. Missing roots are dropped later by the sweep.
|
|
116
|
+
*/
|
|
117
|
+
export declare function defaultTempRoots(env?: NodeJS.ProcessEnv, fsImpl?: Pick<TempHomeSweepFs, "readdirSync">): string[];
|
|
118
|
+
/** `process.kill(pid, 0)`: alive unless the pid does not exist (ESRCH). */
|
|
119
|
+
export declare function defaultIsPidAlive(pid: number): boolean;
|
|
120
|
+
/**
|
|
121
|
+
* True when `value` (a cwd or open-file path, or `HOME`) is `home` or a path
|
|
122
|
+
* inside it. Matched by folder name as a path segment, because the same temp
|
|
123
|
+
* dir is reachable as `/var/folders/...` and `/private/var/folders/...`; the
|
|
124
|
+
* random `mktemp` suffix makes the name unique. Over-matching only keeps more.
|
|
125
|
+
*/
|
|
126
|
+
export declare function pathUsesHome(value: string, home: string): boolean;
|
|
127
|
+
/**
|
|
128
|
+
* Default in-use probe. Only processes of `uid` are checked; other users
|
|
129
|
+
* cannot own these folders' contents in a way the sweep would delete.
|
|
130
|
+
*
|
|
131
|
+
* - Linux: `/proc/{pid}/cwd`, every `/proc/{pid}/fd/*` link, and `HOME` from
|
|
132
|
+
* `/proc/{pid}/environ`. Environment values are matched in memory and never
|
|
133
|
+
* logged.
|
|
134
|
+
* - macOS: `lsof -u uid` for every file each process has open, its cwd and
|
|
135
|
+
* its executable included. macOS does not show other processes'
|
|
136
|
+
* environments (`ps -E` leaves them out), so `HOME` itself is not checked
|
|
137
|
+
* there. A process that has HOME set to a temp home but holds nothing open
|
|
138
|
+
* inside it is not seen; the wrapper lock and the 90-minute idle check
|
|
139
|
+
* cover that case.
|
|
140
|
+
*/
|
|
141
|
+
export declare function defaultInUseProbe(uid: number | undefined): TempHomeInUseProbe;
|
|
142
|
+
/**
|
|
143
|
+
* Find and (unless `dryRun`) remove idle, unused temp homes. See the module
|
|
144
|
+
* comment for the exact rules.
|
|
145
|
+
*/
|
|
146
|
+
export declare function sweepTempHomes(options?: TempHomeSweepOptions): TempHomeSweepResult;
|
|
147
|
+
/** Bytes to GB (10^9 bytes), rounded to two decimals. */
|
|
148
|
+
export declare function toGb(bytes: number): number;
|
|
149
|
+
//# sourceMappingURL=temp-home-sweeper.d.ts.map
|
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sweeper for abandoned throwaway `$HOME` folders.
|
|
3
|
+
*
|
|
4
|
+
* Commands run as `HOME=$(mktemp -d) <cmd>` leave a `tmp.XXXXXXXX` folder in
|
|
5
|
+
* the temp dir. When the command touched Rust or pnpm tooling, that folder
|
|
6
|
+
* holds a full `.rustup` / `.cargo` download (1-2 GB) and nothing deletes it.
|
|
7
|
+
* On 2026-10-18 about 45 GB refilled within hours of a cleanup this way.
|
|
8
|
+
* `core/scripts/with-temp-home.sh` makes `hq-temp-home.XXXXXXXX` folders and
|
|
9
|
+
* removes them on exit, but a wrapper killed with SIGKILL leaves its folder.
|
|
10
|
+
*
|
|
11
|
+
* The sweep is deliberately narrow. A folder is removed only when ALL hold:
|
|
12
|
+
*
|
|
13
|
+
* - it is a direct child of a temp root (TMPDIR, os.tmpdir(), /tmp,
|
|
14
|
+
* /private/tmp, /var/folders/{a}/{b}/T) and its name starts with `tmp.` or
|
|
15
|
+
* `hq-temp-home.`;
|
|
16
|
+
* - `lstat` says it is a real directory (never a symlink) owned by this user;
|
|
17
|
+
* - a `tmp.*` folder holds a `.rustup` or `.cargo` directory (again by
|
|
18
|
+
* `lstat`). An `hq-temp-home.*` folder needs no marker: the name shows the
|
|
19
|
+
* wrapper made it;
|
|
20
|
+
* - nothing inside it, the folder itself included, was modified in the last
|
|
21
|
+
* 90 minutes;
|
|
22
|
+
* - no live process uses it: the wrapper's `.hq-temp-home.lock` does not name
|
|
23
|
+
* a live pid, and no process of this user has its cwd or an open file
|
|
24
|
+
* there (on Linux, or its `HOME` there either).
|
|
25
|
+
*
|
|
26
|
+
* The walk uses `lstat` throughout and never descends into a symlink, so a
|
|
27
|
+
* link inside a temp home that points elsewhere is neither measured nor
|
|
28
|
+
* followed. `fs.rmSync` unlinks symlinks rather than following them. A folder
|
|
29
|
+
* that cannot be fully read is kept, because an incomplete scan cannot prove
|
|
30
|
+
* the folder is idle. When the in-use probe fails, every candidate is kept.
|
|
31
|
+
*
|
|
32
|
+
* `shallow` mode is for plain `hq doctor`, which must stay fast. It checks
|
|
33
|
+
* only the folder and its direct children for recent changes, skips the size
|
|
34
|
+
* walk and the process probe, and reports no byte counts.
|
|
35
|
+
*/
|
|
36
|
+
import { spawnSync } from "node:child_process";
|
|
37
|
+
import * as fs from "node:fs";
|
|
38
|
+
import * as os from "node:os";
|
|
39
|
+
import * as path from "node:path";
|
|
40
|
+
/** Folders younger than this (by newest mtime anywhere inside) are kept. */
|
|
41
|
+
export const TEMP_HOME_MIN_IDLE_MS = 90 * 60 * 1000;
|
|
42
|
+
/** Names that mark a `tmp.*` temp home as holding a toolchain download. */
|
|
43
|
+
export const TEMP_HOME_TOOLCHAIN_MARKERS = [".rustup", ".cargo"];
|
|
44
|
+
/** Folders made by `HOME=$(mktemp -d)`; swept only with a toolchain marker. */
|
|
45
|
+
export const TEMP_HOME_MKTEMP_PREFIX = "tmp.";
|
|
46
|
+
/** Folders made by `core/scripts/with-temp-home.sh`. */
|
|
47
|
+
export const TEMP_HOME_WRAPPER_PREFIX = "hq-temp-home.";
|
|
48
|
+
/** Lock file the wrapper writes into its temp home; holds the wrapper pid. */
|
|
49
|
+
export const TEMP_HOME_LOCK_FILE = ".hq-temp-home.lock";
|
|
50
|
+
const BYTES_PER_GB = 1_000_000_000;
|
|
51
|
+
const nodeFs = {
|
|
52
|
+
lstatSync: (target) => fs.lstatSync(target),
|
|
53
|
+
readdirSync: (target) => fs.readdirSync(target),
|
|
54
|
+
realpathSync: (target) => fs.realpathSync(target),
|
|
55
|
+
readFileSync: (target, encoding) => fs.readFileSync(target, encoding),
|
|
56
|
+
rmSync: (target, options) => fs.rmSync(target, options),
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* The temp roots a throwaway home can land in: `$TMPDIR`, `os.tmpdir()`,
|
|
60
|
+
* `/tmp`, `/private/tmp`, and every per-user macOS temp dir
|
|
61
|
+
* `/var/folders/{a}/{b}/T`. Missing roots are dropped later by the sweep.
|
|
62
|
+
*/
|
|
63
|
+
export function defaultTempRoots(env = process.env, fsImpl = nodeFs) {
|
|
64
|
+
const roots = [];
|
|
65
|
+
if (env.TMPDIR)
|
|
66
|
+
roots.push(env.TMPDIR);
|
|
67
|
+
roots.push(os.tmpdir(), "/tmp", "/private/tmp");
|
|
68
|
+
for (const a of safeReaddir(fsImpl, "/var/folders")) {
|
|
69
|
+
for (const b of safeReaddir(fsImpl, path.join("/var/folders", a))) {
|
|
70
|
+
roots.push(path.join("/var/folders", a, b, "T"));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return roots;
|
|
74
|
+
}
|
|
75
|
+
function safeReaddir(fsImpl, target) {
|
|
76
|
+
try {
|
|
77
|
+
return fsImpl.readdirSync(target);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function errorMessage(error) {
|
|
84
|
+
return error instanceof Error ? error.message : String(error);
|
|
85
|
+
}
|
|
86
|
+
/** `process.kill(pid, 0)`: alive unless the pid does not exist (ESRCH). */
|
|
87
|
+
export function defaultIsPidAlive(pid) {
|
|
88
|
+
try {
|
|
89
|
+
process.kill(pid, 0);
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
return error.code === "EPERM";
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* True when `value` (a cwd or open-file path, or `HOME`) is `home` or a path
|
|
98
|
+
* inside it. Matched by folder name as a path segment, because the same temp
|
|
99
|
+
* dir is reachable as `/var/folders/...` and `/private/var/folders/...`; the
|
|
100
|
+
* random `mktemp` suffix makes the name unique. Over-matching only keeps more.
|
|
101
|
+
*/
|
|
102
|
+
export function pathUsesHome(value, home) {
|
|
103
|
+
const name = path.basename(home);
|
|
104
|
+
if (value === name)
|
|
105
|
+
return true;
|
|
106
|
+
const marker = `/${name}`;
|
|
107
|
+
let index = value.indexOf(marker);
|
|
108
|
+
while (index !== -1) {
|
|
109
|
+
const next = value[index + marker.length];
|
|
110
|
+
if (next === undefined || next === "/")
|
|
111
|
+
return true;
|
|
112
|
+
index = value.indexOf(marker, index + 1);
|
|
113
|
+
}
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
function runProbe(command, args) {
|
|
117
|
+
const result = spawnSync(command, args, { encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
|
|
118
|
+
if (result.error)
|
|
119
|
+
throw result.error;
|
|
120
|
+
if (result.status !== 0 && !result.stdout) {
|
|
121
|
+
throw new Error(`${command} exited ${result.status ?? result.signal}`);
|
|
122
|
+
}
|
|
123
|
+
return result.stdout;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Default in-use probe. Only processes of `uid` are checked; other users
|
|
127
|
+
* cannot own these folders' contents in a way the sweep would delete.
|
|
128
|
+
*
|
|
129
|
+
* - Linux: `/proc/{pid}/cwd`, every `/proc/{pid}/fd/*` link, and `HOME` from
|
|
130
|
+
* `/proc/{pid}/environ`. Environment values are matched in memory and never
|
|
131
|
+
* logged.
|
|
132
|
+
* - macOS: `lsof -u uid` for every file each process has open, its cwd and
|
|
133
|
+
* its executable included. macOS does not show other processes'
|
|
134
|
+
* environments (`ps -E` leaves them out), so `HOME` itself is not checked
|
|
135
|
+
* there. A process that has HOME set to a temp home but holds nothing open
|
|
136
|
+
* inside it is not seen; the wrapper lock and the 90-minute idle check
|
|
137
|
+
* cover that case.
|
|
138
|
+
*/
|
|
139
|
+
export function defaultInUseProbe(uid) {
|
|
140
|
+
return (homes) => {
|
|
141
|
+
const used = new Set();
|
|
142
|
+
if (homes.length === 0)
|
|
143
|
+
return used;
|
|
144
|
+
const values = [];
|
|
145
|
+
if (process.platform === "linux") {
|
|
146
|
+
for (const pid of fs.readdirSync("/proc").filter((name) => /^\d+$/.test(name))) {
|
|
147
|
+
try {
|
|
148
|
+
if (uid !== undefined && fs.statSync(`/proc/${pid}`).uid !== uid)
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
try {
|
|
155
|
+
values.push(fs.readlinkSync(`/proc/${pid}/cwd`));
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
// The process exited or the link is unreadable.
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
for (const fd of fs.readdirSync(`/proc/${pid}/fd`)) {
|
|
162
|
+
try {
|
|
163
|
+
values.push(fs.readlinkSync(`/proc/${pid}/fd/${fd}`));
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
// The descriptor closed.
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
// Same.
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
for (const entry of fs.readFileSync(`/proc/${pid}/environ`, "utf8").split("\0")) {
|
|
175
|
+
if (entry.startsWith("HOME="))
|
|
176
|
+
values.push(entry.slice(5));
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
// Same.
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
const userArgs = uid !== undefined ? ["-u", String(uid)] : [];
|
|
186
|
+
// -w: no warnings; -n and -P: no host or port name lookups.
|
|
187
|
+
const lsof = runProbe("lsof", ["-w", "-n", "-P", "-Fn", ...userArgs]);
|
|
188
|
+
for (const line of lsof.split("\n")) {
|
|
189
|
+
if (line.startsWith("n"))
|
|
190
|
+
values.push(line.slice(1));
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
for (const home of homes) {
|
|
194
|
+
if (values.some((value) => pathUsesHome(value, home)))
|
|
195
|
+
used.add(home);
|
|
196
|
+
}
|
|
197
|
+
return used;
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
/** On-disk size of one lstat result: allocated blocks when known, else size. */
|
|
201
|
+
function diskBytes(stat) {
|
|
202
|
+
return typeof stat.blocks === "number" && stat.blocks >= 0 ? stat.blocks * 512 : stat.size;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Measure a folder with `lstat`, never descending into symlinks. Stops as soon
|
|
206
|
+
* as it sees an entry modified after `cutoffMs`, since such a folder is kept
|
|
207
|
+
* and its size is not needed. With `maxDepth` 1 it reads only direct children.
|
|
208
|
+
*/
|
|
209
|
+
function walk(fsImpl, dir, dirStat, cutoffMs, maxDepth = Number.POSITIVE_INFINITY) {
|
|
210
|
+
if (dirStat.mtimeMs > cutoffMs)
|
|
211
|
+
return { complete: true, recent: true, bytes: 0 };
|
|
212
|
+
let bytes = diskBytes(dirStat);
|
|
213
|
+
const stack = [{ dir, depth: 0 }];
|
|
214
|
+
while (stack.length > 0) {
|
|
215
|
+
const current = stack.pop();
|
|
216
|
+
let names;
|
|
217
|
+
try {
|
|
218
|
+
names = fsImpl.readdirSync(current.dir);
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
return { complete: false, recent: false, bytes };
|
|
222
|
+
}
|
|
223
|
+
for (const name of names) {
|
|
224
|
+
const child = path.join(current.dir, name);
|
|
225
|
+
let stat;
|
|
226
|
+
try {
|
|
227
|
+
stat = fsImpl.lstatSync(child);
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
return { complete: false, recent: false, bytes };
|
|
231
|
+
}
|
|
232
|
+
if (stat.mtimeMs > cutoffMs)
|
|
233
|
+
return { complete: true, recent: true, bytes };
|
|
234
|
+
bytes += diskBytes(stat);
|
|
235
|
+
if (stat.isDirectory() && !stat.isSymbolicLink() && current.depth + 1 < maxDepth) {
|
|
236
|
+
stack.push({ dir: child, depth: current.depth + 1 });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return { complete: true, recent: false, bytes };
|
|
241
|
+
}
|
|
242
|
+
function hasToolchainMarker(fsImpl, dir) {
|
|
243
|
+
return TEMP_HOME_TOOLCHAIN_MARKERS.some((marker) => {
|
|
244
|
+
try {
|
|
245
|
+
const stat = fsImpl.lstatSync(path.join(dir, marker));
|
|
246
|
+
return stat.isDirectory() && !stat.isSymbolicLink();
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
/** True when the wrapper's lock file in `dir` names a live pid. */
|
|
254
|
+
function lockHeldByLiveProcess(fsImpl, dir, isPidAlive) {
|
|
255
|
+
let text;
|
|
256
|
+
try {
|
|
257
|
+
const lock = path.join(dir, TEMP_HOME_LOCK_FILE);
|
|
258
|
+
if (!fsImpl.lstatSync(lock).isFile())
|
|
259
|
+
return false;
|
|
260
|
+
text = fsImpl.readFileSync(lock, "utf8");
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
265
|
+
const pid = Number.parseInt(text.trim(), 10);
|
|
266
|
+
return Number.isInteger(pid) && pid > 0 && isPidAlive(pid);
|
|
267
|
+
}
|
|
268
|
+
function resolveRoots(fsImpl, roots) {
|
|
269
|
+
const seen = new Set();
|
|
270
|
+
const resolved = [];
|
|
271
|
+
for (const root of roots) {
|
|
272
|
+
let real;
|
|
273
|
+
try {
|
|
274
|
+
real = fsImpl.realpathSync(root);
|
|
275
|
+
if (!fsImpl.lstatSync(real).isDirectory())
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
if (seen.has(real))
|
|
282
|
+
continue;
|
|
283
|
+
seen.add(real);
|
|
284
|
+
resolved.push(real);
|
|
285
|
+
}
|
|
286
|
+
return resolved;
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Find and (unless `dryRun`) remove idle, unused temp homes. See the module
|
|
290
|
+
* comment for the exact rules.
|
|
291
|
+
*/
|
|
292
|
+
export function sweepTempHomes(options = {}) {
|
|
293
|
+
const fsImpl = options.fs ?? nodeFs;
|
|
294
|
+
const shallow = options.shallow === true;
|
|
295
|
+
const dryRun = shallow || options.dryRun === true;
|
|
296
|
+
const nowMs = options.nowMs ?? Date.now();
|
|
297
|
+
const cutoffMs = nowMs - (options.minIdleMs ?? TEMP_HOME_MIN_IDLE_MS);
|
|
298
|
+
const uid = options.uid ?? (typeof process.getuid === "function" ? process.getuid() : undefined);
|
|
299
|
+
const isPidAlive = options.isPidAlive ?? defaultIsPidAlive;
|
|
300
|
+
const roots = resolveRoots(fsImpl, options.roots ?? defaultTempRoots());
|
|
301
|
+
// Pass 1: decide per folder from the file system alone.
|
|
302
|
+
const entries = [];
|
|
303
|
+
const candidates = [];
|
|
304
|
+
for (const root of roots) {
|
|
305
|
+
const names = safeReaddir(fsImpl, root)
|
|
306
|
+
.filter((name) => name.startsWith(TEMP_HOME_MKTEMP_PREFIX) || name.startsWith(TEMP_HOME_WRAPPER_PREFIX))
|
|
307
|
+
.sort();
|
|
308
|
+
for (const name of names) {
|
|
309
|
+
const candidate = path.join(root, name);
|
|
310
|
+
let stat;
|
|
311
|
+
try {
|
|
312
|
+
stat = fsImpl.lstatSync(candidate);
|
|
313
|
+
}
|
|
314
|
+
catch (error) {
|
|
315
|
+
entries.push({ path: candidate, action: "kept", reason: "unreadable", error: errorMessage(error) });
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
if (stat.isSymbolicLink()) {
|
|
319
|
+
entries.push({ path: candidate, action: "kept", reason: "symlink" });
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
if (!stat.isDirectory()) {
|
|
323
|
+
entries.push({ path: candidate, action: "kept", reason: "not-directory" });
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
if (uid !== undefined && stat.uid !== uid) {
|
|
327
|
+
entries.push({ path: candidate, action: "kept", reason: "not-owned" });
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
if (name.startsWith(TEMP_HOME_MKTEMP_PREFIX) && !hasToolchainMarker(fsImpl, candidate)) {
|
|
331
|
+
entries.push({ path: candidate, action: "kept", reason: "no-toolchain-marker" });
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
const measured = walk(fsImpl, candidate, stat, cutoffMs, shallow ? 1 : undefined);
|
|
335
|
+
if (measured.recent) {
|
|
336
|
+
entries.push({ path: candidate, action: "kept", reason: "recent" });
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
if (!measured.complete) {
|
|
340
|
+
entries.push({ path: candidate, action: "kept", reason: "unreadable" });
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
if (lockHeldByLiveProcess(fsImpl, candidate, isPidAlive)) {
|
|
344
|
+
entries.push({ path: candidate, action: "kept", reason: "in-use" });
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
candidates.push({ path: candidate, stat, bytes: shallow ? undefined : measured.bytes });
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
// Pass 2: one process probe for every remaining candidate. Shallow mode
|
|
351
|
+
// only reports, so it skips the probe; the full sweep runs it.
|
|
352
|
+
let inUse = new Set();
|
|
353
|
+
if (!shallow && candidates.length > 0) {
|
|
354
|
+
const probe = options.inUse ?? defaultInUseProbe(uid);
|
|
355
|
+
try {
|
|
356
|
+
inUse = probe(candidates.map((candidate) => candidate.path));
|
|
357
|
+
}
|
|
358
|
+
catch (error) {
|
|
359
|
+
const message = errorMessage(error);
|
|
360
|
+
for (const candidate of candidates) {
|
|
361
|
+
entries.push({ path: candidate.path, action: "kept", reason: "in-use-unknown", error: message });
|
|
362
|
+
}
|
|
363
|
+
return finish(entries, roots, dryRun, shallow);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
// Pass 3: remove, or report what would be removed.
|
|
367
|
+
for (const candidate of candidates) {
|
|
368
|
+
if (inUse.has(candidate.path)) {
|
|
369
|
+
entries.push({ path: candidate.path, action: "kept", reason: "in-use" });
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
if (dryRun) {
|
|
373
|
+
entries.push(withBytes({ path: candidate.path, action: "would-remove" }, candidate.bytes));
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
// Re-check right before deleting: the path must still be the same real
|
|
377
|
+
// directory, untouched since it was measured, and not newly locked.
|
|
378
|
+
let again;
|
|
379
|
+
try {
|
|
380
|
+
again = fsImpl.lstatSync(candidate.path);
|
|
381
|
+
}
|
|
382
|
+
catch (error) {
|
|
383
|
+
entries.push({ path: candidate.path, action: "kept", reason: "changed-before-delete", error: errorMessage(error) });
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
if (again.isSymbolicLink() ||
|
|
387
|
+
!again.isDirectory() ||
|
|
388
|
+
again.ino !== candidate.stat.ino ||
|
|
389
|
+
again.dev !== candidate.stat.dev ||
|
|
390
|
+
again.mtimeMs > cutoffMs ||
|
|
391
|
+
lockHeldByLiveProcess(fsImpl, candidate.path, isPidAlive)) {
|
|
392
|
+
entries.push({ path: candidate.path, action: "kept", reason: "changed-before-delete" });
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
try {
|
|
396
|
+
fsImpl.rmSync(candidate.path, { recursive: true, force: false });
|
|
397
|
+
entries.push(withBytes({ path: candidate.path, action: "removed" }, candidate.bytes));
|
|
398
|
+
}
|
|
399
|
+
catch (error) {
|
|
400
|
+
entries.push(withBytes({ path: candidate.path, action: "failed", error: errorMessage(error) }, candidate.bytes));
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return finish(entries, roots, dryRun, shallow);
|
|
404
|
+
}
|
|
405
|
+
function withBytes(entry, bytes) {
|
|
406
|
+
return bytes === undefined ? entry : { ...entry, bytes };
|
|
407
|
+
}
|
|
408
|
+
function finish(entries, roots, dryRun, shallow) {
|
|
409
|
+
const bytesFreed = entries
|
|
410
|
+
.filter((entry) => entry.action === "removed" || entry.action === "would-remove")
|
|
411
|
+
.reduce((sum, entry) => sum + (entry.bytes ?? 0), 0);
|
|
412
|
+
return { dryRun, shallow, roots, entries, bytesFreed, gbFreed: toGb(bytesFreed) };
|
|
413
|
+
}
|
|
414
|
+
/** Bytes to GB (10^9 bytes), rounded to two decimals. */
|
|
415
|
+
export function toGb(bytes) {
|
|
416
|
+
return Math.round((bytes / BYTES_PER_GB) * 100) / 100;
|
|
417
|
+
}
|
|
418
|
+
//# sourceMappingURL=temp-home-sweeper.js.map
|