@rightkit/release 0.2.165 → 0.2.173
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/README.md +121 -4
- package/addon-command.mjs +6 -13
- package/android-aab-scan.mjs +265 -0
- package/android-dev.mjs +111 -0
- package/android-ndk.mjs +77 -0
- package/android-play-upload.mjs +262 -0
- package/android-release.mjs +344 -0
- package/android-test-fixtures.mjs +79 -0
- package/apple-profile-create.mjs +363 -0
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/darwin/rightkit-swap.sign.json +6 -0
- package/build-release.mjs +73 -13
- package/cargo-guard.mjs +2 -0
- package/cargo-profile-contract.mjs +183 -0
- package/cargo-target.mjs +8 -0
- package/cli/right-release.mjs +16 -4
- package/committed-source.mjs +1 -1
- package/dev-artifact-refresh.mjs +176 -0
- package/dev-artifact-support.mjs +61 -0
- package/development-signing.mjs +71 -4
- package/development.mjs +93 -47
- package/direct-bootstrap.mjs +26 -41
- package/disk-command.mjs +298 -5
- package/disk-policy.json +7 -5
- package/disk-policy.mjs +105 -16
- package/disk-stable-root.mjs +19 -16
- package/engine-locator.mjs +116 -0
- package/external-bin.mjs +133 -0
- package/github-release.mjs +2 -2
- package/github-updater-release.mjs +154 -0
- package/hardeningscan.mjs +1 -1
- package/in-place-install.mjs +56 -0
- package/install-app-windows.mjs +63 -0
- package/install-app.mjs +53 -23
- package/install-tauri-devid.mjs +172 -0
- package/inventory-check.mjs +1 -1
- package/local-publish.mjs +11 -1
- package/mac-package-recipe.mjs +304 -0
- package/mac-signing-matrix.mjs +23 -11
- package/managed-cargo.mjs +6 -0
- package/media-sidecar-catalog.mjs +45 -0
- package/media-sidecar.mjs +306 -0
- package/ownership-approval-command.mjs +10 -44
- package/ownership-approval.mjs +3 -42
- package/ownership-catalog-config.json +2 -1
- package/ownership-catalog.mjs +4 -2
- package/ownership-command.mjs +7 -9
- package/ownership-dependency-allowlist.json +25 -1
- package/ownership-evidence.mjs +8 -5
- package/ownership-git-inventory.mjs +41 -7
- package/ownership-lockfiles.mjs +91 -8
- package/ownership-mandatory-set.json +36 -16
- package/ownership-report.mjs +5 -6
- package/ownership-scan-worker.mjs +2 -2
- package/ownership-verdict.mjs +10 -7
- package/ownership.mjs +47 -29
- package/package-recipe-targets.mjs +95 -0
- package/package.json +7 -4
- package/portable-development.mjs +32 -115
- package/preflight.mjs +4 -6
- package/project-type.mjs +1 -1
- package/publication-artifacts.mjs +34 -0
- package/publish-update.mjs +72 -11
- package/qa-adoption-exemptions.json +5 -0
- package/qa-build-features.mjs +7 -0
- package/qa-contract.mjs +182 -0
- package/qa-control-scan.mjs +3 -3
- package/release-qa-command.mjs +38 -12
- package/release-qa-installed.mjs +136 -0
- package/release-qa-receipt.mjs +23 -2
- package/release.mjs +285 -14
- package/rightkit-ownership.json +537 -5
- package/rightkit-sync.mjs +27 -1
- package/rightkit-versions.json +111 -63
- package/runtime-budget-classes.json +28 -0
- package/runtime-budgets.mjs +4 -1
- package/sign-candidate.mjs +161 -0
- package/sign-dev-artifact.mjs +198 -0
- package/stage-binary.mjs +60 -0
- package/stage-sidecar-command.mjs +41 -0
- package/stage-sidecar.mjs +424 -0
- package/swap-helper-bundle.mjs +105 -0
- package/swift-mac-dmg.mjs +2 -1
- package/templates/cargo-profiles.toml +28 -0
- package/upload-release.mjs +11 -1
- package/win-package-recipe.mjs +321 -0
package/disk-command.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
@@ -150,7 +150,8 @@ function defaultGlobalRoots() {
|
|
|
150
150
|
|
|
151
151
|
export function scheduleSpec({ platform = process.platform, nodePath = process.execPath, cli = path.join(path.dirname(fileURLToPath(import.meta.url)), "cli", "right-release.mjs"), home = os.homedir(), hour = 3, minute = 30 } = {}) {
|
|
152
152
|
if (platform === "darwin") {
|
|
153
|
-
|
|
153
|
+
// launchd cannot open log files on an external volume for a background job (spawn fails with EX_CONFIG);
|
|
154
|
+
// `disk run` writes its own structured log to disk-hygiene.jsonl on the heavy volume.
|
|
154
155
|
const file = path.join(home, "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
|
|
155
156
|
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
156
157
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
@@ -158,8 +159,8 @@ export function scheduleSpec({ platform = process.platform, nodePath = process.e
|
|
|
158
159
|
<key>Label</key><string>${LAUNCHD_LABEL}</string>
|
|
159
160
|
<key>ProgramArguments</key><array><string>${nodePath}</string><string>${cli}</string><string>disk</string><string>run</string></array>
|
|
160
161
|
<key>StartCalendarInterval</key><dict><key>Hour</key><integer>${hour}</integer><key>Minute</key><integer>${minute}</integer></dict>
|
|
161
|
-
<key>StandardOutPath</key><string
|
|
162
|
-
<key>StandardErrorPath</key><string
|
|
162
|
+
<key>StandardOutPath</key><string>/dev/null</string>
|
|
163
|
+
<key>StandardErrorPath</key><string>/dev/null</string>
|
|
163
164
|
<key>ProcessType</key><string>Background</string><key>LowPriorityIO</key><true/><key>Nice</key><integer>10</integer>
|
|
164
165
|
</dict></plist>
|
|
165
166
|
`;
|
|
@@ -242,8 +243,300 @@ function schedule(sub, { stdout, cli, platform = process.platform, env = process
|
|
|
242
243
|
return 0;
|
|
243
244
|
}
|
|
244
245
|
|
|
246
|
+
// ---- agent-env and probe: keep every agent and child-tool write on the external root (Adrian, 2026-10-17) -------------------
|
|
247
|
+
|
|
248
|
+
const defaultAgentFs = { existsSync, statSync, mkdirSync, writeFileSync, unlinkSync, realpathSync };
|
|
249
|
+
|
|
250
|
+
const pathFor = (platform) => (platform === "win32" ? path.win32 : path.posix);
|
|
251
|
+
|
|
252
|
+
/** External root used when --root is absent: D:\ on Windows, /Volumes/D on macOS. No other platform is supported. */
|
|
253
|
+
export function defaultExternalRoot(platform = process.platform) {
|
|
254
|
+
if (platform === "win32") return "D:\\";
|
|
255
|
+
if (platform === "darwin") return "/Volumes/D";
|
|
256
|
+
throw new Error(`disk agent-env: unsupported platform ${platform}; pass --root`);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function envValue(env, name, platform) {
|
|
260
|
+
if (platform !== "win32") return env[name];
|
|
261
|
+
const key = Object.keys(env).find((k) => k.toLowerCase() === name.toLowerCase());
|
|
262
|
+
return key === undefined ? undefined : env[key];
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** The one table of agent-environment paths, relative to the external root, identical on Windows and macOS (these are the D:
|
|
266
|
+
* paths RustRight already applied to the user environment). RustRight's broker imports this. `agentHome` rows are emitted only
|
|
267
|
+
* with --include-agent-homes. CARGO_TARGET_DIR is deliberately absent: RustRight owns cargo targets. */
|
|
268
|
+
export const AGENT_ENV_TABLE = Object.freeze([
|
|
269
|
+
{ name: "TEMP", segments: ["agent-cache", "tmp"] },
|
|
270
|
+
{ name: "TMP", segments: ["agent-cache", "tmp"] },
|
|
271
|
+
{ name: "TMPDIR", segments: ["agent-cache", "tmp"] },
|
|
272
|
+
{ name: "CLAUDE_CODE_TMPDIR", segments: ["agent-cache", "tmp"] },
|
|
273
|
+
{ name: "XDG_CACHE_HOME", segments: ["agent-cache", "xdg"] },
|
|
274
|
+
{ name: "XDG_DATA_HOME", segments: ["agent-cache", "xdg-data"] },
|
|
275
|
+
{ name: "XDG_STATE_HOME", segments: ["agent-cache", "xdg-state"] },
|
|
276
|
+
{ name: "XDG_CONFIG_HOME", segments: ["agent-cache", "xdg-config"] },
|
|
277
|
+
{ name: "npm_config_cache", segments: [".dev-cache", "npm-cache"] },
|
|
278
|
+
{ name: "PNPM_STORE_DIR", segments: ["agent-cache", "pnpm-store"] },
|
|
279
|
+
{ name: "YARN_CACHE_FOLDER", segments: ["agent-cache", "yarn"] },
|
|
280
|
+
{ name: "PIP_CACHE_DIR", segments: [".dev-cache", "pip"] },
|
|
281
|
+
{ name: "UV_CACHE_DIR", segments: [".dev-cache", "uv"] },
|
|
282
|
+
{ name: "HF_HOME", segments: [".dev-cache", "huggingface"] },
|
|
283
|
+
{ name: "TORCH_HOME", segments: ["agent-cache", "torch"] },
|
|
284
|
+
{ name: "TRANSFORMERS_CACHE", segments: ["agent-cache", "transformers"] },
|
|
285
|
+
{ name: "PLAYWRIGHT_BROWSERS_PATH", segments: ["agent-cache", "playwright"] },
|
|
286
|
+
{ name: "PUPPETEER_CACHE_DIR", segments: ["agent-cache", "puppeteer"] },
|
|
287
|
+
{ name: "NUGET_PACKAGES", segments: ["agent-cache", "nuget"] },
|
|
288
|
+
{ name: "GOCACHE", segments: ["agent-cache", "go-build"] },
|
|
289
|
+
{ name: "GOMODCACHE", segments: ["agent-cache", "go-mod"] },
|
|
290
|
+
{ name: "DOTNET_CLI_HOME", segments: ["agent-cache", "dotnet"] },
|
|
291
|
+
{ name: "ELECTRON_CACHE", segments: ["agent-cache", "electron"] },
|
|
292
|
+
{ name: "DENO_DIR", segments: ["agent-cache", "deno"] },
|
|
293
|
+
{ name: "CODEX_HOME", segments: ["agent-home", "codex"], agentHome: true },
|
|
294
|
+
{ name: "CLAUDE_CONFIG_DIR", segments: ["agent-home", "claude"], agentHome: true },
|
|
295
|
+
]);
|
|
296
|
+
|
|
297
|
+
/** Ordered variable map for one external root from AGENT_ENV_TABLE. Pure. */
|
|
298
|
+
export function agentEnvMap({ root, platform = process.platform, includeAgentHomes = false } = {}) {
|
|
299
|
+
if (!root) throw new Error("agentEnvMap needs a root");
|
|
300
|
+
const p = pathFor(platform);
|
|
301
|
+
return AGENT_ENV_TABLE.filter((e) => includeAgentHomes || !e.agentHome).map((e) => ({ name: e.name, value: p.join(root, ...e.segments) }));
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export const AGENT_ENV_NOTES = [
|
|
305
|
+
"CARGO_TARGET_DIR is intentionally NOT set: RustRight owns cargo targets.",
|
|
306
|
+
"CODEX_HOME and CLAUDE_CONFIG_DIR are only emitted with --include-agent-homes (moving them requires restarting every chat).",
|
|
307
|
+
];
|
|
308
|
+
|
|
309
|
+
const shQuote = (v) => `'${String(v).replace(/'/g, `'\\''`)}'`;
|
|
310
|
+
const psQuote = (v) => `'${String(v).replace(/'/g, "''")}'`;
|
|
311
|
+
|
|
312
|
+
/** Render the map as a shell script, a PowerShell script, a cmd batch or JSON. Pure. */
|
|
313
|
+
export function renderAgentEnv(map, { format, root, notes = AGENT_ENV_NOTES } = {}) {
|
|
314
|
+
if (format === "json") return JSON.stringify({ root, env: Object.fromEntries(map.map((m) => [m.name, m.value])), notes }, null, 2);
|
|
315
|
+
if (format === "sh") return [...notes.map((n) => `# ${n}`), ...map.map((m) => `export ${m.name}=${shQuote(m.value)}`)].join("\n");
|
|
316
|
+
if (format === "ps1") return [...notes.map((n) => `# ${n}`), ...map.map((m) => `$env:${m.name} = ${psQuote(m.value)}`)].join("\n");
|
|
317
|
+
if (format === "cmd") return [...notes.map((n) => `rem ${n}`), ...map.map((m) => `set "${m.name}=${m.value}"`)].join("\r\n");
|
|
318
|
+
throw new Error(`disk agent-env: unknown --format ${format} (sh|ps1|cmd|json)`);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function parseAgentEnv(rest, platform) {
|
|
322
|
+
const opts = { root: undefined, format: platform === "win32" ? "ps1" : "sh", check: false, includeAgentHomes: false };
|
|
323
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
324
|
+
const a = rest[i];
|
|
325
|
+
if (a === "--check") opts.check = true;
|
|
326
|
+
else if (a === "--include-agent-homes") opts.includeAgentHomes = true;
|
|
327
|
+
else if (a === "--root" || a === "--format") {
|
|
328
|
+
const v = rest[++i];
|
|
329
|
+
if (!v) throw new Error(`disk agent-env: ${a} needs a value`);
|
|
330
|
+
if (a === "--root") opts.root = v; else opts.format = v;
|
|
331
|
+
} else throw new Error(`disk agent-env: unknown option ${a}`);
|
|
332
|
+
}
|
|
333
|
+
if (!["sh", "ps1", "cmd", "json"].includes(opts.format)) throw new Error(`disk agent-env: unknown --format ${opts.format} (sh|ps1|cmd|json)`);
|
|
334
|
+
return opts;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const LOCAL_UNC_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]", "."]);
|
|
338
|
+
|
|
339
|
+
/** Realpath of `value`, resolving its nearest existing ancestor when the path itself does not exist yet. Null when nothing resolves. */
|
|
340
|
+
function realpathOfExisting(value, realpath) {
|
|
341
|
+
let current = value;
|
|
342
|
+
let rest = "";
|
|
343
|
+
for (;;) {
|
|
344
|
+
try { return rest ? path.posix.join(realpath(current), rest) : realpath(current); } catch { /* try the parent */ }
|
|
345
|
+
const up = path.posix.dirname(current);
|
|
346
|
+
if (up === current) return null;
|
|
347
|
+
rest = rest ? `${path.posix.basename(current)}/${rest}` : path.posix.basename(current);
|
|
348
|
+
current = up;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** True when `value` lies on the internal drive, or cannot be proven external. Windows: a path on the system drive, including the
|
|
353
|
+
* `\\?\C:\`, `\\.\C:\` and `\\?\UNC\localhost\C$\` / `\\localhost\C$\` spellings; any other path on this machine's own host name or
|
|
354
|
+
* loopback, a relative path and any device or volume-GUID path are unprovable and count as internal. macOS: any path outside
|
|
355
|
+
* /Volumes, and a /Volumes entry that resolves outside it (`/Volumes/Macintosh HD` is a symlink to /). Pure apart from the
|
|
356
|
+
* injectable `realpath` used on macOS. */
|
|
357
|
+
export function pointsAtInternalDrive(value, { platform = process.platform, env = {}, realpath = realpathSync } = {}) {
|
|
358
|
+
const v = String(value ?? "");
|
|
359
|
+
if (platform === "win32") {
|
|
360
|
+
const system = String(envValue(env, "SystemDrive", platform) ?? "C:").slice(0, 1).toUpperCase();
|
|
361
|
+
let p = v.replace(/\//g, "\\");
|
|
362
|
+
let unc = null;
|
|
363
|
+
const extended = /^\\\\[?.]\\(.*)$/.exec(p);
|
|
364
|
+
if (extended) {
|
|
365
|
+
const tail = extended[1];
|
|
366
|
+
if (/^UNC\\/i.test(tail)) unc = tail.slice(4);
|
|
367
|
+
else if (/^[A-Za-z]:/.test(tail)) p = tail;
|
|
368
|
+
else return true;
|
|
369
|
+
} else if (/^\\\\[^\\]/.test(p)) unc = p.slice(2);
|
|
370
|
+
if (unc !== null) {
|
|
371
|
+
const [host = "", share = ""] = unc.split("\\");
|
|
372
|
+
const hostName = host.toLowerCase();
|
|
373
|
+
const own = String(envValue(env, "COMPUTERNAME", platform) ?? "").toLowerCase();
|
|
374
|
+
if (!host) return true;
|
|
375
|
+
if (!LOCAL_UNC_HOSTS.has(hostName) && !(own && hostName === own)) return false;
|
|
376
|
+
const admin = /^([A-Za-z])\$$/.exec(share);
|
|
377
|
+
return admin ? admin[1].toUpperCase() === system : true;
|
|
378
|
+
}
|
|
379
|
+
const m = /^([A-Za-z]):/.exec(p);
|
|
380
|
+
if (!m) return true;
|
|
381
|
+
return m[1].toUpperCase() === system;
|
|
382
|
+
}
|
|
383
|
+
if (!v.startsWith("/Volumes/")) return true;
|
|
384
|
+
if (platform === "darwin") {
|
|
385
|
+
const real = realpathOfExisting(v, realpath);
|
|
386
|
+
if (real !== null && !real.startsWith("/Volumes/")) return true;
|
|
387
|
+
}
|
|
388
|
+
return false;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function isUnder(value, root, platform) {
|
|
392
|
+
const p = pathFor(platform);
|
|
393
|
+
const norm = (s) => { const n = p.resolve(s); return platform === "win32" ? n.toLowerCase() : n; };
|
|
394
|
+
const r = norm(root);
|
|
395
|
+
const v = norm(value);
|
|
396
|
+
return v === r || v.startsWith(r.endsWith(p.sep) ? r : r + p.sep);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function uniqueProbeName(nonce) {
|
|
400
|
+
return `.rk-disk-probe-${process.pid}-${nonce ?? `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`}`;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** Create then immediately remove one 0-byte uniquely named file in `dir`. Writes nothing else. Pure with injected fs. */
|
|
404
|
+
const BLOCKED_CODES = new Set(["EACCES", "EPERM", "EROFS"]);
|
|
405
|
+
|
|
406
|
+
/** Outcome: `allowed` (created and removed), `blocked` (EACCES/EPERM/EROFS), or `error` (missing directory or any other
|
|
407
|
+
* failure, with its code). A failed removal is `allowed` with code `UNLINK_<code>` and `leftover` set. */
|
|
408
|
+
export function probeDirectory(dir, { fs = defaultAgentFs, nonce, platform = process.platform } = {}) {
|
|
409
|
+
let file;
|
|
410
|
+
try {
|
|
411
|
+
if (!dir) return { outcome: "error", code: "ENODIR" };
|
|
412
|
+
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) return { outcome: "error", code: "ENOENT" };
|
|
413
|
+
file = pathFor(platform).join(dir, uniqueProbeName(nonce));
|
|
414
|
+
fs.writeFileSync(file, "", { flag: "wx" });
|
|
415
|
+
} catch (error) {
|
|
416
|
+
const code = error.code ?? "EUNKNOWN";
|
|
417
|
+
return { outcome: BLOCKED_CODES.has(code) ? "blocked" : "error", code };
|
|
418
|
+
}
|
|
419
|
+
try {
|
|
420
|
+
fs.unlinkSync(file);
|
|
421
|
+
} catch (error) {
|
|
422
|
+
return { outcome: "allowed", code: `UNLINK_${error.code ?? "EUNKNOWN"}`, leftover: true, file };
|
|
423
|
+
}
|
|
424
|
+
return { outcome: "allowed", code: null };
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** Locations that must be blocked for agents: user profile root, local temp (AppData\Local\Temp / default OS temp), OS temp, and
|
|
428
|
+
* the external root. */
|
|
429
|
+
export function probeLocations({ platform = process.platform, env = process.env, home = os.homedir(), tmpdir = os.tmpdir(), root } = {}) {
|
|
430
|
+
const p = pathFor(platform);
|
|
431
|
+
const localTemp = platform === "win32"
|
|
432
|
+
? p.join(envValue(env, "LOCALAPPDATA", platform) ?? p.join(home, "AppData", "Local"), "Temp")
|
|
433
|
+
: (envValue(env, "TMPDIR", platform) ?? tmpdir);
|
|
434
|
+
return [
|
|
435
|
+
{ location: "profile", dir: home },
|
|
436
|
+
{ location: "local-temp", dir: localTemp },
|
|
437
|
+
{ location: "os-temp", dir: tmpdir },
|
|
438
|
+
{ location: "external-root", dir: root ?? defaultExternalRoot(platform) },
|
|
439
|
+
];
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
export const PROBE_SCHEMA = "rightkit.disk.probe.v1";
|
|
443
|
+
|
|
444
|
+
/** Probe all four locations; the report is the exact `rightkit.disk.probe.v1` shape. `internalWritable` is true when any
|
|
445
|
+
* internal location (profile, local-temp, os-temp) accepted the probe file. */
|
|
446
|
+
export function runProbe({ platform = process.platform, env = process.env, home, tmpdir, root, fs = defaultAgentFs, nonce, host = os.hostname() } = {}) {
|
|
447
|
+
const locations = probeLocations({ platform, env, ...(home ? { home } : {}), ...(tmpdir ? { tmpdir } : {}), root });
|
|
448
|
+
const probed = locations.map((l) => ({ l, r: probeDirectory(l.dir, { fs, nonce, platform }) }));
|
|
449
|
+
const external = locations.find((l) => l.location === "external-root").dir;
|
|
450
|
+
// Fail closed. An internal location is sealed only when the write was refused (EACCES, EPERM, EROFS) or the directory does not
|
|
451
|
+
// exist (ENOENT); ENOSPC, EBUSY and every other error prove nothing, so they count against the probe. The external root must
|
|
452
|
+
// accept the probe file, or agent writes have nowhere to go.
|
|
453
|
+
const problems = [];
|
|
454
|
+
for (const { l, r } of probed) {
|
|
455
|
+
if (l.location === "external-root") {
|
|
456
|
+
if (r.outcome !== "allowed") problems.push(`${l.location} ${l.dir} is not writable (${r.code})`);
|
|
457
|
+
} else if (r.outcome === "allowed") problems.push(`${l.location} ${l.dir} is writable`);
|
|
458
|
+
else if (r.outcome === "error" && r.code !== "ENOENT") problems.push(`${l.location} ${l.dir} could not be proven blocked (${r.code})`);
|
|
459
|
+
}
|
|
460
|
+
return {
|
|
461
|
+
problems,
|
|
462
|
+
report: {
|
|
463
|
+
schema: PROBE_SCHEMA, host, root: external,
|
|
464
|
+
results: probed.map(({ l, r }) => ({ location: l.location, path: l.dir, outcome: r.outcome, code: r.code })),
|
|
465
|
+
internalWritable: probed.some(({ l, r }) => l.location !== "external-root" && r.outcome === "allowed"),
|
|
466
|
+
},
|
|
467
|
+
leftovers: probed.filter(({ r }) => r.leftover).map(({ r }) => r.file),
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
export function renderProbe(report) {
|
|
472
|
+
return [...report.results.map((r) => `${r.outcome.toUpperCase().padEnd(8)} ${r.location.padEnd(14)} ${r.path}${r.code ? ` (${r.code})` : ""}`), report.internalWritable ? "INTERNAL DRIVE IS WRITABLE" : "internal drive is not writable"].join("\n");
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** Compare the live environment with the map. Each row: ok (under root), elsewhere (external, not under root), internal (set on
|
|
476
|
+
* the internal drive), unset (tool default lands on the internal drive). */
|
|
477
|
+
export function checkAgentEnv(map, { env = process.env, platform = process.platform, root } = {}) {
|
|
478
|
+
return map.map(({ name, value }) => {
|
|
479
|
+
const current = envValue(env, name, platform);
|
|
480
|
+
let status;
|
|
481
|
+
if (current === undefined || current === "") status = "unset";
|
|
482
|
+
else if (pointsAtInternalDrive(current, { platform, env })) status = "internal";
|
|
483
|
+
else if (isUnder(current, root, platform)) status = "ok";
|
|
484
|
+
else status = "elsewhere";
|
|
485
|
+
return { name, expected: value, current: current ?? null, status };
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function agentEnvCommand(rest, { env, stdout, platform, deps }) {
|
|
490
|
+
const stderr = deps.stderr ?? console.error;
|
|
491
|
+
const fs = deps.fs ?? defaultAgentFs;
|
|
492
|
+
const opts = parseAgentEnv(rest, platform);
|
|
493
|
+
const root = opts.root ?? defaultExternalRoot(platform);
|
|
494
|
+
const fail = (message) => { stderr(`disk agent-env: ${message}; refusing (no fallback to the internal drive)`); return { exitCode: 1 }; };
|
|
495
|
+
if (pointsAtInternalDrive(root, { platform, env, realpath: (p) => fs.realpathSync(p) })) return fail(`root ${root} is on the internal drive`);
|
|
496
|
+
if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) return fail(`root ${root} is missing`);
|
|
497
|
+
if (platform === "darwin") {
|
|
498
|
+
let real = root;
|
|
499
|
+
try { real = fs.realpathSync(root); } catch { /* keep lexical */ }
|
|
500
|
+
if (!real.startsWith("/Volumes/")) return fail(`root ${root} resolves to ${real}, not an external volume`);
|
|
501
|
+
}
|
|
502
|
+
const writable = probeDirectory(root, { fs, nonce: deps.nonce, platform });
|
|
503
|
+
if (writable.outcome !== "allowed" || writable.leftover) return fail(`root ${root} is not writable (${writable.code})`);
|
|
504
|
+
const map = agentEnvMap({ root, platform, includeAgentHomes: opts.includeAgentHomes });
|
|
505
|
+
if (opts.check) {
|
|
506
|
+
const rows = checkAgentEnv(map, { env, platform, root });
|
|
507
|
+
const bad = rows.filter((r) => r.status === "internal" || r.status === "unset");
|
|
508
|
+
if (opts.format === "json") stdout(JSON.stringify({ root, ok: bad.length === 0, variables: rows }, null, 2));
|
|
509
|
+
else {
|
|
510
|
+
for (const r of rows) stdout(`${r.status.toUpperCase().padEnd(9)} ${r.name.padEnd(26)} ${r.current ?? "(unset)"}${r.status === "ok" ? "" : ` -> ${r.expected}`}`);
|
|
511
|
+
stdout(bad.length ? `${bad.length} variable(s) point at the internal drive or are unset` : "all variables point at the external root");
|
|
512
|
+
}
|
|
513
|
+
return { exitCode: bad.length ? 1 : 0, rows };
|
|
514
|
+
}
|
|
515
|
+
for (const m of map) fs.mkdirSync(m.value, { recursive: true });
|
|
516
|
+
stdout(renderAgentEnv(map, { format: opts.format, root }));
|
|
517
|
+
return { exitCode: 0, map };
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function probeCommand(rest, { env, stdout, platform, deps }) {
|
|
521
|
+
let json = false;
|
|
522
|
+
let root;
|
|
523
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
524
|
+
if (rest[i] === "--json") json = true;
|
|
525
|
+
else if (rest[i] === "--root") { root = rest[++i]; if (!root) throw new Error("disk probe: --root needs a value"); }
|
|
526
|
+
else throw new Error(`disk probe: unknown option ${rest[i]}`);
|
|
527
|
+
}
|
|
528
|
+
const { report, leftovers, problems } = runProbe({ platform, env, root, ...(deps.probe ?? {}), ...(deps.fs ? { fs: deps.fs } : {}), nonce: deps.nonce });
|
|
529
|
+
stdout(json ? JSON.stringify(report) : renderProbe(report));
|
|
530
|
+
const stderr = deps.stderr ?? console.error;
|
|
531
|
+
for (const problem of problems) stderr(`disk probe: ${problem}`);
|
|
532
|
+
for (const file of leftovers) stderr(`disk probe: could not remove ${file}`);
|
|
533
|
+
return { exitCode: report.internalWritable || leftovers.length || problems.length ? 1 : 0, report, problems };
|
|
534
|
+
}
|
|
535
|
+
|
|
245
536
|
export async function runDiskCommand(args = process.argv.slice(2), { env = process.env, stdout = console.log, platform = process.platform, deps = {} } = {}) {
|
|
246
537
|
const [command, ...rest] = args;
|
|
538
|
+
if (command === "agent-env") return agentEnvCommand(rest, { env, stdout, platform, deps });
|
|
539
|
+
if (command === "probe") return probeCommand(rest, { env, stdout, platform, deps });
|
|
247
540
|
if (command === "schedule") {
|
|
248
541
|
const at = rest.indexOf("--cli");
|
|
249
542
|
const exitCode = schedule(rest[0], { stdout, cli: at === -1 ? undefined : rest[at + 1], platform, env });
|
|
@@ -303,7 +596,7 @@ export async function runDiskCommand(args = process.argv.slice(2), { env = proce
|
|
|
303
596
|
if (warn) stdout(`WARNING: ${after.warnings.join("; ")}`);
|
|
304
597
|
return { exitCode: !self.ok ? 2 : warn ? DISK_EXIT_WARNING : 0, before, after, applied, purged, selfCheck: self };
|
|
305
598
|
}
|
|
306
|
-
throw new Error("disk usage: check|sweep [--dry-run|--delete] [--all]|allow-workspace-sweep [--off] [--days N]|purge [--dry-run] [--days N]|run|schedule install|uninstall|status [--json]");
|
|
599
|
+
throw new Error("disk usage: check|sweep [--dry-run|--delete] [--all]|allow-workspace-sweep [--off] [--days N]|purge [--dry-run] [--days N]|run|schedule install|uninstall|status|agent-env [--root dir] [--format sh|ps1|cmd|json] [--check] [--include-agent-homes]|probe [--json]");
|
|
307
600
|
}
|
|
308
601
|
|
|
309
602
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
package/disk-policy.json
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
},
|
|
8
8
|
"holdingDays": 3,
|
|
9
9
|
"holding": {
|
|
10
|
-
"mac": [{"prefix": "/", "path": "~/.rightkit-swept"}],
|
|
10
|
+
"mac": [{"prefix": "/", "path": "/Volumes/D/.rightkit-swept", "mount": "/Volumes/D"}, {"prefix": "/", "path": "~/.rightkit-swept"}],
|
|
11
11
|
"win": [{"prefix": "C:\\", "path": "${LOCALAPPDATA}\\Orthic Labs\\.rightkit-swept"}]
|
|
12
12
|
},
|
|
13
13
|
"heavyVolume": {
|
|
@@ -27,13 +27,15 @@
|
|
|
27
27
|
"writers": [
|
|
28
28
|
{"id": "claude-session-dirs", "platform": "mac", "path": "/private/tmp/claude-501/*/*", "capBytes": 2147483648, "action": "sweep", "unit": "self"},
|
|
29
29
|
{"id": "rightkit-qa-tmp", "platform": "mac", "path": "/private/tmp/rightkit-qa", "capBytes": 2147483648, "action": "sweep", "unit": "children"},
|
|
30
|
+
{"id": "cache-coreml-e5rt-tmp", "platform": "mac", "path": "~/Library/Caches/*/com.apple.e5rt.e5bundlecache/*/*/*.tmp.*", "capBytes": 4294967296, "action": "sweep", "unit": "self", "staleDays": 1},
|
|
31
|
+
{"id": "cache-coreml-e5rt", "platform": "mac", "path": "~/Library/Caches/*/com.apple.e5rt.e5bundlecache/*/*", "capBytes": 4294967296, "action": "sweep", "unit": "children", "staleDays": 2},
|
|
30
32
|
{"id": "cache-e2e", "platform": "mac", "path": "~/Library/Caches/e2e_*", "capBytes": 1073741824, "action": "sweep", "unit": "self"},
|
|
31
33
|
{"id": "cache-parakeet", "platform": "mac", "path": "~/Library/Caches/parakeet_*", "capBytes": 1073741824, "action": "sweep", "unit": "self"},
|
|
32
34
|
{"id": "cache-swiftright-factory", "platform": "mac", "path": "~/Library/Caches/SwiftRightFactory", "capBytes": 10737418240, "action": "sweep", "unit": "children"},
|
|
33
|
-
{"id": "cache-cutright-engine", "platform": "mac", "path": "~/Library/Caches/cutright-engine", "capBytes": 2147483648, "action": "report"},
|
|
34
|
-
{"id": "cache-heardright-engine", "platform": "mac", "path": "~/Library/Caches/heardright-engine", "capBytes": 2147483648, "action": "report"},
|
|
35
|
-
{"id": "cache-pnpm", "platform": "mac", "path": "~/Library/Caches/pnpm", "capBytes": 3221225472, "action": "report"},
|
|
36
|
-
{"id": "cache-python", "platform": "mac", "path": "~/Library/Caches/python", "capBytes": 3221225472, "action": "report"},
|
|
35
|
+
{"id": "cache-cutright-engine", "platform": "mac", "path": "~/Library/Caches/cutright-engine", "capBytes": 2147483648, "action": "report", "unit": "children", "escalate": {"factor": 2, "staleDays": 7}},
|
|
36
|
+
{"id": "cache-heardright-engine", "platform": "mac", "path": "~/Library/Caches/heardright-engine", "capBytes": 2147483648, "action": "report", "unit": "children", "escalate": {"factor": 2, "staleDays": 7}},
|
|
37
|
+
{"id": "cache-pnpm", "platform": "mac", "path": "~/Library/Caches/pnpm", "capBytes": 3221225472, "action": "report", "unit": "children", "escalate": {"factor": 2, "staleDays": 7}},
|
|
38
|
+
{"id": "cache-python", "platform": "mac", "path": "~/Library/Caches/python", "capBytes": 3221225472, "action": "report", "unit": "children", "escalate": {"factor": 2, "staleDays": 7}},
|
|
37
39
|
{"id": "xcode-deriveddata", "platform": "mac", "path": "~/Library/Developer/Xcode/DerivedData", "capBytes": 10737418240, "action": "sweep", "unit": "children"},
|
|
38
40
|
{"id": "coresimulator", "platform": "mac", "path": "~/Library/Developer/CoreSimulator", "capBytes": 26843545600, "action": "report"},
|
|
39
41
|
{"id": "documents-scratch", "platform": "mac", "path": "~/Documents", "capBytes": 21474836480, "action": "report"},
|
package/disk-policy.mjs
CHANGED
|
@@ -10,6 +10,12 @@ export const DEFAULT_POLICY_FILE = path.join(path.dirname(fileURLToPath(import.m
|
|
|
10
10
|
export function readDiskPolicy(file = DEFAULT_POLICY_FILE) {
|
|
11
11
|
const policy = JSON.parse(readFileSync(file, "utf8"));
|
|
12
12
|
if (policy.schema !== 1 || !Array.isArray(policy.writers)) throw new Error("disk policy: unsupported schema");
|
|
13
|
+
for (const w of policy.writers) {
|
|
14
|
+
// `escalate` lets a report-only cache sweep its idle children once it is over cap by `factor` (see planSweep).
|
|
15
|
+
if (!w.escalate) continue;
|
|
16
|
+
const e = w.escalate;
|
|
17
|
+
if (w.action !== "report" || w.unit !== "children" || !(e.factor >= 1) || !(e.staleDays >= 0)) throw new Error(`disk policy: ${w.id} escalate needs action report, unit children, factor >= 1, staleDays >= 0`);
|
|
18
|
+
}
|
|
13
19
|
return policy;
|
|
14
20
|
}
|
|
15
21
|
|
|
@@ -112,6 +118,20 @@ export function isInsideRoot(candidate, root) {
|
|
|
112
118
|
} catch { return false; }
|
|
113
119
|
}
|
|
114
120
|
|
|
121
|
+
// True when a directory strictly between `literal` (the writer's literal root) and `target` is a symlink: a cache reached
|
|
122
|
+
// through a symlinked cache dir (some Mac caches now live on /Volumes/D) is never swept.
|
|
123
|
+
export function symlinkAncestor(target, literal) {
|
|
124
|
+
const rel = path.relative(literal, target);
|
|
125
|
+
if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) return false;
|
|
126
|
+
const parts = rel.split(path.sep).slice(0, -1);
|
|
127
|
+
let cur = literal;
|
|
128
|
+
for (const part of parts) {
|
|
129
|
+
cur = path.join(cur, part);
|
|
130
|
+
try { if (lstatSync(cur).isSymbolicLink()) return true; } catch { return false; }
|
|
131
|
+
}
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
|
|
115
135
|
export function writerMatches(writer, ctx) {
|
|
116
136
|
const pattern = expandPolicyPath(writer.path, ctx);
|
|
117
137
|
return expandGlob(pattern);
|
|
@@ -305,11 +325,18 @@ export function planSweep({ policy, platform = process.platform, env = process.e
|
|
|
305
325
|
const skipped = [];
|
|
306
326
|
const seen = new Set();
|
|
307
327
|
const lockCheck = inUse ?? (key === "win" ? windowsLockProbe : (p) => inUseByOpenPaths(p, openPaths));
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
const cutoff = now - (staleDays ?? writer.staleDays ?? policy.staleDays) * 86400_000;
|
|
328
|
+
// Sweep writers, plus report writers with `escalate` when they are over cap by its factor (pure caches: idle children go).
|
|
329
|
+
for (const writer of policy.writers.filter((w) => w.platform === key && (w.action === "sweep" || w.escalate))) {
|
|
311
330
|
let roots; let literal;
|
|
312
331
|
try { roots = writerMatches(writer, { env, home }); literal = literalRoot(expandPolicyPath(writer.path, { env, home })); } catch { continue; }
|
|
332
|
+
// `--days N` overrides every writer; otherwise a writer may carry its own window (QA evidence: 3 days, caches: 2).
|
|
333
|
+
let window = staleDays ?? writer.staleDays ?? policy.staleDays;
|
|
334
|
+
if (writer.action !== "sweep") {
|
|
335
|
+
const total = roots.reduce((sum, r) => sum + size(r), 0);
|
|
336
|
+
if (!(total > writer.capBytes * writer.escalate.factor)) continue;
|
|
337
|
+
window = staleDays ?? writer.escalate.staleDays;
|
|
338
|
+
}
|
|
339
|
+
const cutoff = now - window * 86400_000;
|
|
313
340
|
const units = [];
|
|
314
341
|
for (const r of roots) {
|
|
315
342
|
if (writer.unit !== "children") { units.push({ unit: r, root: literal }); continue; }
|
|
@@ -328,6 +355,7 @@ export function planSweep({ policy, platform = process.platform, env = process.e
|
|
|
328
355
|
try { st = lstatSync(unit); } catch { continue; }
|
|
329
356
|
if (st.isSymbolicLink()) { skipped.push({ ...base, reason: "symlink" }); continue; }
|
|
330
357
|
if (!isInsideRoot(unit, root)) { skipped.push({ ...base, reason: "outside-root" }); continue; }
|
|
358
|
+
if (symlinkAncestor(unit, literal)) { skipped.push({ ...base, reason: "symlink-ancestor" }); continue; }
|
|
331
359
|
if (isProtectedPath(unit, policy)) { skipped.push({ ...base, reason: "protected" }); continue; }
|
|
332
360
|
if (writer.skipTracked && tracked(unit)) { skipped.push({ ...base, reason: "tracked" }); continue; }
|
|
333
361
|
if (hasFreshContent(unit, cutoff)) { skipped.push({ ...base, reason: "fresh" }); continue; }
|
|
@@ -335,6 +363,10 @@ export function planSweep({ policy, platform = process.platform, env = process.e
|
|
|
335
363
|
candidates.push({ ...base, root, bytes: size(unit), mtime: new Date(st.mtimeMs).toISOString() });
|
|
336
364
|
}
|
|
337
365
|
}
|
|
366
|
+
// Overlapping writers (a whole cache dir and one of its children): the outer unit wins, the nested one is not moved twice.
|
|
367
|
+
const nested = candidates.filter((c) => candidates.some((o) => o !== c && c.path.startsWith(o.path + path.sep)));
|
|
368
|
+
for (const c of nested) skipped.push({ writer: c.writer, path: c.path, reason: "nested" });
|
|
369
|
+
if (nested.length) candidates.splice(0, candidates.length, ...candidates.filter((c) => !nested.includes(c)));
|
|
338
370
|
// Allowlisted workspace roots: unknown top-level entries older than 3 days and idle.
|
|
339
371
|
const scanned = scan({ policy, platform, env, home });
|
|
340
372
|
skipped.push(...scanned.skipped);
|
|
@@ -403,26 +435,52 @@ const isUnder = (candidate, prefix, win) => {
|
|
|
403
435
|
return c === p || c.startsWith(withSep);
|
|
404
436
|
};
|
|
405
437
|
|
|
438
|
+
// True when a volume is mounted at `mount` and is not the item's own volume (`itemDev`).
|
|
439
|
+
export function defaultMounted(mount, itemDev) {
|
|
440
|
+
try {
|
|
441
|
+
const dev = deviceOf(mount);
|
|
442
|
+
return existsSync(mount) && dev !== null && dev !== itemDev;
|
|
443
|
+
} catch { return false; }
|
|
444
|
+
}
|
|
445
|
+
|
|
406
446
|
// Candidate holding bases for an item, best first: policy per-volume rules (longest prefix), then the item's own volume root.
|
|
447
|
+
// A rule with `mount` is a cross-volume base: it applies only to items on the policy floor's volume (the Mac internal drive),
|
|
448
|
+
// and only while that mount is present (checked at pick time). Entries are `{ path, mount? }`.
|
|
407
449
|
export function holdingBases(candidate, { policy, platform = process.platform, env = process.env, home = os.homedir() } = {}) {
|
|
408
450
|
const key = platformKey(platform);
|
|
409
451
|
const out = [];
|
|
452
|
+
let floorDev;
|
|
410
453
|
const rules = (policy.holding?.[key] ?? []).flatMap((r) => {
|
|
411
|
-
try {
|
|
454
|
+
try {
|
|
455
|
+
return [{ prefix: expandPolicyPath(r.prefix, { env, home }), path: expandPolicyPath(r.path, { env, home }), mount: r.mount ? expandPolicyPath(r.mount, { env, home }) : undefined }];
|
|
456
|
+
} catch { return []; }
|
|
412
457
|
}).filter((r) => isUnder(candidate, r.prefix, key === "win")).sort((a, b) => b.prefix.length - a.prefix.length);
|
|
413
|
-
for (const r of rules)
|
|
414
|
-
|
|
458
|
+
for (const r of rules) {
|
|
459
|
+
if (r.mount) {
|
|
460
|
+
if (floorDev === undefined) {
|
|
461
|
+
const floorPath = policy.floors?.[key]?.path;
|
|
462
|
+
floorDev = floorPath ? deviceOf(expandPolicyPath(floorPath, { env, home })) : null;
|
|
463
|
+
}
|
|
464
|
+
if (floorDev === null || deviceOf(path.dirname(candidate)) !== floorDev) continue;
|
|
465
|
+
}
|
|
466
|
+
out.push({ path: r.path, ...(r.mount ? { mount: r.mount } : {}) });
|
|
467
|
+
}
|
|
468
|
+
out.push({ path: path.join(volumeRoot(path.dirname(candidate)), HOLDING_DIR) });
|
|
415
469
|
return out;
|
|
416
470
|
}
|
|
417
471
|
|
|
418
|
-
// Create (and mark) today's holding dir
|
|
419
|
-
|
|
472
|
+
// Create (and mark) today's holding dir; null when no holding dir is possible. A same-volume base must sit on the item's
|
|
473
|
+
// own volume; a mount base must sit on a mounted volume other than the item's (`isMounted`, injectable for tests).
|
|
474
|
+
export function pickHolding(candidate, { policy, platform = process.platform, env = process.env, home = os.homedir(), date = new Date(), bases, isMounted = defaultMounted } = {}) {
|
|
420
475
|
const dev = deviceOf(path.dirname(candidate));
|
|
421
|
-
for (const
|
|
476
|
+
for (const entry of bases ?? holdingBases(candidate, { policy, platform, env, home })) {
|
|
477
|
+
const base = typeof entry === "string" ? entry : entry.path;
|
|
478
|
+
const mount = typeof entry === "string" ? undefined : entry.mount;
|
|
422
479
|
try {
|
|
423
|
-
|
|
480
|
+
// checked before creating anything: a same-volume base on another volume would free nothing
|
|
481
|
+
if (mount ? !isMounted(mount, dev) : deviceOf(base) !== dev) continue;
|
|
424
482
|
mkdirSync(base, { recursive: true });
|
|
425
|
-
if (
|
|
483
|
+
if (lstatSync(base).isSymbolicLink() || (!mount && deviceOf(base) !== dev)) continue;
|
|
426
484
|
const marker = path.join(base, HOLDING_MARKER);
|
|
427
485
|
if (!existsSync(marker)) writeFileSync(marker, "created by right-release disk; date-named children are purged after the retention window\n");
|
|
428
486
|
const dir = path.join(base, date.toISOString().slice(0, 10));
|
|
@@ -433,14 +491,45 @@ export function pickHolding(candidate, { policy, platform = process.platform, en
|
|
|
433
491
|
return null;
|
|
434
492
|
}
|
|
435
493
|
|
|
494
|
+
// Entries (lstat-visited, symlinks not followed) and allocated bytes under `root`.
|
|
495
|
+
export function treeStats(root) {
|
|
496
|
+
let entries = 0;
|
|
497
|
+
let bytes = 0;
|
|
498
|
+
const stack = [root];
|
|
499
|
+
while (stack.length) {
|
|
500
|
+
const dir = stack.pop();
|
|
501
|
+
let st;
|
|
502
|
+
try { st = lstatSync(dir); } catch { continue; }
|
|
503
|
+
entries += 1;
|
|
504
|
+
bytes += st.blocks ? st.blocks * 512 : st.size;
|
|
505
|
+
if (!st.isDirectory()) continue;
|
|
506
|
+
let names = [];
|
|
507
|
+
try { names = readdirSync(dir); } catch { continue; }
|
|
508
|
+
for (const name of names) stack.push(path.join(dir, name));
|
|
509
|
+
}
|
|
510
|
+
return { entries, bytes };
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const pathExists = (p) => { try { lstatSync(p); return true; } catch { return false; } };
|
|
514
|
+
|
|
436
515
|
export function moveEntry(source, destination) {
|
|
437
516
|
mkdirSync(path.dirname(destination), { recursive: true });
|
|
438
517
|
try { renameSync(source, destination); return "rename"; } catch (error) {
|
|
439
518
|
if (error.code !== "EXDEV") throw error;
|
|
440
519
|
}
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
520
|
+
// Cross-volume: copy, verify the copy against the source, and only then remove the source. Never overwrite.
|
|
521
|
+
if (pathExists(destination)) throw new Error(`destination exists: ${destination}; source kept`);
|
|
522
|
+
const before = treeStats(source);
|
|
523
|
+
try {
|
|
524
|
+
cpSync(source, destination, { recursive: true, preserveTimestamps: true, verbatimSymlinks: true, errorOnExist: true, force: false });
|
|
525
|
+
const after = treeStats(destination);
|
|
526
|
+
if (after.entries !== before.entries || after.bytes < before.bytes) {
|
|
527
|
+
throw new Error(`copy verification failed for ${source} (entries ${before.entries} -> ${after.entries}, bytes ${before.bytes} -> ${after.bytes}); source kept`);
|
|
528
|
+
}
|
|
529
|
+
} catch (error) {
|
|
530
|
+
rmSync(destination, { recursive: true, force: true }); // our partial copy only: the source is untouched
|
|
531
|
+
throw error;
|
|
532
|
+
}
|
|
444
533
|
rmSync(source, { recursive: true, force: true }); // completes the move after verified copy
|
|
445
534
|
return "copy-verify-remove";
|
|
446
535
|
}
|
|
@@ -452,7 +541,7 @@ export function logEvent(logFile, event) {
|
|
|
452
541
|
} catch { /* logging must never fail the sweep */ }
|
|
453
542
|
}
|
|
454
543
|
|
|
455
|
-
export function applySweep({ plan, policy, platform = process.platform, deleteMode = false, date = new Date(), env = process.env, home = os.homedir(), move = moveEntry, pick = pickHolding, remove = (p) => rmSync(p, { recursive: true, force: true }) }) {
|
|
544
|
+
export function applySweep({ plan, policy, platform = process.platform, deleteMode = false, date = new Date(), env = process.env, home = os.homedir(), move = moveEntry, pick = pickHolding, isMounted, remove = (p) => rmSync(p, { recursive: true, force: true }) }) {
|
|
456
545
|
const logFile = path.join(policy.heavyVolume[platformKey(platform)], "disk-hygiene.jsonl");
|
|
457
546
|
const results = [];
|
|
458
547
|
for (const c of plan.candidates) {
|
|
@@ -462,7 +551,7 @@ export function applySweep({ plan, policy, platform = process.platform, deleteMo
|
|
|
462
551
|
if (c.writer === "workspace-unknown" && PROJECT_MARKERS.some((m) => existsSync(path.join(c.path, m)))) throw new Error("refused: a nested git checkout or project directory is never swept");
|
|
463
552
|
if (deleteMode) { remove(c.path); results.push({ ...c, action: "delete", ok: true }); logEvent(logFile, { event: "disk.sweep.delete", ...c }); continue; }
|
|
464
553
|
// An item stays on its own volume: moving it elsewhere frees nothing on the volume that is short of space.
|
|
465
|
-
const holding = pick(c.path, { policy, platform, env, home, date });
|
|
554
|
+
const holding = pick(c.path, { policy, platform, env, home, date, isMounted });
|
|
466
555
|
if (!holding) {
|
|
467
556
|
remove(c.path);
|
|
468
557
|
results.push({ ...c, action: "delete", how: "no-holding-on-volume", ok: true });
|