@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.
Files changed (86) hide show
  1. package/README.md +121 -4
  2. package/addon-command.mjs +6 -13
  3. package/android-aab-scan.mjs +265 -0
  4. package/android-dev.mjs +111 -0
  5. package/android-ndk.mjs +77 -0
  6. package/android-play-upload.mjs +262 -0
  7. package/android-release.mjs +344 -0
  8. package/android-test-fixtures.mjs +79 -0
  9. package/apple-profile-create.mjs +363 -0
  10. package/bin/darwin/rightkit-swap +0 -0
  11. package/bin/darwin/rightkit-swap.sign.json +6 -0
  12. package/build-release.mjs +73 -13
  13. package/cargo-guard.mjs +2 -0
  14. package/cargo-profile-contract.mjs +183 -0
  15. package/cargo-target.mjs +8 -0
  16. package/cli/right-release.mjs +16 -4
  17. package/committed-source.mjs +1 -1
  18. package/dev-artifact-refresh.mjs +176 -0
  19. package/dev-artifact-support.mjs +61 -0
  20. package/development-signing.mjs +71 -4
  21. package/development.mjs +93 -47
  22. package/direct-bootstrap.mjs +26 -41
  23. package/disk-command.mjs +298 -5
  24. package/disk-policy.json +7 -5
  25. package/disk-policy.mjs +105 -16
  26. package/disk-stable-root.mjs +19 -16
  27. package/engine-locator.mjs +116 -0
  28. package/external-bin.mjs +133 -0
  29. package/github-release.mjs +2 -2
  30. package/github-updater-release.mjs +154 -0
  31. package/hardeningscan.mjs +1 -1
  32. package/in-place-install.mjs +56 -0
  33. package/install-app-windows.mjs +63 -0
  34. package/install-app.mjs +53 -23
  35. package/install-tauri-devid.mjs +172 -0
  36. package/inventory-check.mjs +1 -1
  37. package/local-publish.mjs +11 -1
  38. package/mac-package-recipe.mjs +304 -0
  39. package/mac-signing-matrix.mjs +23 -11
  40. package/managed-cargo.mjs +6 -0
  41. package/media-sidecar-catalog.mjs +45 -0
  42. package/media-sidecar.mjs +306 -0
  43. package/ownership-approval-command.mjs +10 -44
  44. package/ownership-approval.mjs +3 -42
  45. package/ownership-catalog-config.json +2 -1
  46. package/ownership-catalog.mjs +4 -2
  47. package/ownership-command.mjs +7 -9
  48. package/ownership-dependency-allowlist.json +25 -1
  49. package/ownership-evidence.mjs +8 -5
  50. package/ownership-git-inventory.mjs +41 -7
  51. package/ownership-lockfiles.mjs +91 -8
  52. package/ownership-mandatory-set.json +36 -16
  53. package/ownership-report.mjs +5 -6
  54. package/ownership-scan-worker.mjs +2 -2
  55. package/ownership-verdict.mjs +10 -7
  56. package/ownership.mjs +47 -29
  57. package/package-recipe-targets.mjs +95 -0
  58. package/package.json +7 -4
  59. package/portable-development.mjs +32 -115
  60. package/preflight.mjs +4 -6
  61. package/project-type.mjs +1 -1
  62. package/publication-artifacts.mjs +34 -0
  63. package/publish-update.mjs +72 -11
  64. package/qa-adoption-exemptions.json +5 -0
  65. package/qa-build-features.mjs +7 -0
  66. package/qa-contract.mjs +182 -0
  67. package/qa-control-scan.mjs +3 -3
  68. package/release-qa-command.mjs +38 -12
  69. package/release-qa-installed.mjs +136 -0
  70. package/release-qa-receipt.mjs +23 -2
  71. package/release.mjs +285 -14
  72. package/rightkit-ownership.json +537 -5
  73. package/rightkit-sync.mjs +27 -1
  74. package/rightkit-versions.json +111 -63
  75. package/runtime-budget-classes.json +28 -0
  76. package/runtime-budgets.mjs +4 -1
  77. package/sign-candidate.mjs +161 -0
  78. package/sign-dev-artifact.mjs +198 -0
  79. package/stage-binary.mjs +60 -0
  80. package/stage-sidecar-command.mjs +41 -0
  81. package/stage-sidecar.mjs +424 -0
  82. package/swap-helper-bundle.mjs +105 -0
  83. package/swift-mac-dmg.mjs +2 -1
  84. package/templates/cargo-profiles.toml +28 -0
  85. package/upload-release.mjs +11 -1
  86. 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
- const logDir = "/Volumes/D/claude/tmp/swept";
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>${logDir}/launchd.out.log</string>
162
- <key>StandardErrorPath</key><string>${logDir}/launchd.err.log</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
- for (const writer of policy.writers.filter((w) => w.platform === key && w.action === "sweep")) {
309
- // `--days N` overrides every writer; otherwise a writer may carry its own window (QA evidence: 3 days).
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 { return [{ prefix: expandPolicyPath(r.prefix, { env, home }), path: expandPolicyPath(r.path, { env, home }) }]; } catch { return []; }
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) out.push(r.path);
414
- out.push(path.join(volumeRoot(path.dirname(candidate)), HOLDING_DIR));
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 on the item's own volume; null when no holding dir on that volume is possible.
419
- export function pickHolding(candidate, { policy, platform = process.platform, env = process.env, home = os.homedir(), date = new Date(), bases } = {}) {
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 base of bases ?? holdingBases(candidate, { policy, platform, env, home })) {
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
- if (deviceOf(base) !== dev) continue; // checked before creating anything: a base on another volume would free nothing
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 (deviceOf(base) !== dev || lstatSync(base).isSymbolicLink()) continue;
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
- const before = walkSize(source);
442
- cpSync(source, destination, { recursive: true, preserveTimestamps: true, verbatimSymlinks: true, errorOnExist: true, force: false });
443
- if (walkSize(destination) < before) throw new Error(`copy verification failed for ${source}; source kept`);
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 });