@rightkit/release 0.2.155 → 0.2.158

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.
@@ -78,6 +78,8 @@ if (first === "--version" || first === "-v") {
78
78
  );
79
79
  } else if (first === "cache") {
80
80
  run("cache-command.mjs", args.slice(1));
81
+ } else if (first === "disk") {
82
+ run("disk-command.mjs", args.slice(1));
81
83
  } else if (first === "install-app") {
82
84
  run("install-app.mjs", args.slice(1));
83
85
  } else if (first === "dmg") {
@@ -242,6 +244,11 @@ Commands:
242
244
  cache prune [--dry-run|--apply] [--json] Plan or apply safe shared-target pruning
243
245
  cache migrate --config <file> [--dry-run|--apply] [--json]
244
246
  Move a same-volume legacy target without copying
247
+ disk check [--json] Per-writer sizes vs caps and free-space floor (exit 3 on warning)
248
+ disk sweep [--dry-run] [--days N] [--delete] [--json]
249
+ Move stale idle heavy data to the heavy volume (delete only with --delete)
250
+ disk run check, dry-run sweep, move sweep, warn (the scheduled job)
251
+ disk schedule install|uninstall|status Daily launchd job (Mac) / hidden Task Scheduler entry (Windows)
245
252
  deps --check|--audit|--update Shared dependency lane
246
253
  hardening <artifact...> Run the Right Suite hardening scan
247
254
  lsclean <AppName.app> Clear macOS LaunchServices duplicates
@@ -0,0 +1,204 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, mkdirSync, realpathSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { fileURLToPath, pathToFileURL } from "node:url";
6
+ import { spawnSync } from "./background-process.mjs";
7
+ import {
8
+ DISK_EXIT_WARNING, applySweep, checkDisk, formatBytes, listOpenPaths, logEvent, planSweep, platformKey, readDiskPolicy,
9
+ } from "./disk-policy.mjs";
10
+
11
+ export const LAUNCHD_LABEL = "dev.rightkit.disk-hygiene";
12
+ export const WINDOWS_TASK = "RightKit Disk Hygiene";
13
+
14
+ export function renderCheck(result) {
15
+ const lines = [`${result.floor.label}: ${formatBytes(result.floor.freeBytes)} free of ${formatBytes(result.floor.totalBytes)} (floor ${formatBytes(result.floor.thresholdBytes)})${result.floor.warn ? " WARN" : ""}`];
16
+ for (const w of result.writers) {
17
+ const size = w.error ? `error: ${w.error}` : w.matches === 0 ? "absent" : `${formatBytes(w.bytes)} / cap ${formatBytes(w.capBytes)}`;
18
+ lines.push(` ${w.id.padEnd(28)} ${w.action.padEnd(6)} ${size}${w.over ? " OVER CAP" : ""}`);
19
+ }
20
+ for (const w of result.warnings) lines.push(`WARNING: ${w}`);
21
+ return lines.join("\n");
22
+ }
23
+
24
+ export function renderSweep(plan, applied) {
25
+ const lines = [`${applied ? "Swept" : "Dry run: would move"} ${plan.candidates.length} item(s), ${formatBytes(plan.totalBytes)} (stale > ${plan.staleDays} days)`];
26
+ const rows = applied ? applied.results : plan.candidates;
27
+ const byWriter = new Map();
28
+ for (const r of rows) byWriter.set(r.writer, [...(byWriter.get(r.writer) ?? []), r]);
29
+ for (const [writer, items] of byWriter) {
30
+ const total = items.reduce((s, r) => s + r.bytes, 0);
31
+ lines.push(` ${writer.padEnd(26)} ${String(items.length).padStart(4)} item(s) ${formatBytes(total).padStart(9)}`);
32
+ for (const r of [...items].sort((a, b) => b.bytes - a.bytes).slice(0, 5)) lines.push(` ${formatBytes(r.bytes).padStart(9)} ${r.path}${r.destination ? ` -> ${r.destination}` : ""}${r.ok === false ? ` FAILED: ${r.error}` : ""}`);
33
+ if (items.length > 5) lines.push(` ... ${items.length - 5} more`);
34
+ }
35
+ const counts = {};
36
+ for (const s of plan.skipped) counts[s.reason] = (counts[s.reason] ?? 0) + 1;
37
+ if (plan.skipped.length) lines.push(` skipped: ${Object.entries(counts).map(([k, v]) => `${v} ${k}`).join(", ")}`);
38
+ return lines.join("\n");
39
+ }
40
+
41
+ function parse(rest) {
42
+ const known = new Set(["--json", "--dry-run", "--delete", "--days", "--policy"]);
43
+ const opts = { json: false, dryRun: false, deleteMode: false, days: undefined, policy: undefined };
44
+ for (let i = 0; i < rest.length; i += 1) {
45
+ const a = rest[i];
46
+ if (!known.has(a)) throw new Error(`disk: unknown option ${a}`);
47
+ if (a === "--json") opts.json = true;
48
+ else if (a === "--dry-run") opts.dryRun = true;
49
+ else if (a === "--delete") opts.deleteMode = true;
50
+ else { const v = rest[++i]; if (!v) throw new Error(`disk: ${a} needs a value`); if (a === "--days") opts.days = Number(v); else opts.policy = v; }
51
+ }
52
+ if (opts.days !== undefined && !(opts.days >= 0)) throw new Error("disk: --days must be a non-negative number");
53
+ if (opts.dryRun && opts.deleteMode) throw new Error("disk sweep accepts either --dry-run or --delete");
54
+ return opts;
55
+ }
56
+
57
+ const CHECKOUT_FILE = fileURLToPath(import.meta.url);
58
+
59
+ // A path inside any git checkout (a `.git` entry on an ancestor) is development code, never an installed package.
60
+ export function insideGitCheckout(file, exists = existsSync) {
61
+ let dir = path.dirname(file);
62
+ for (;;) {
63
+ if (exists(path.join(dir, ".git"))) return true;
64
+ const up = path.dirname(dir);
65
+ if (up === dir) return false;
66
+ dir = up;
67
+ }
68
+ }
69
+
70
+ /** Installed @rightkit/release CLI for the Windows task: explicit --cli / RIGHT_RELEASE_INSTALLED_CLI, else the global
71
+ * node_modules (`npm root -g`, `pnpm root -g`). A checkout, including the one running install, is refused. */
72
+ export function resolveInstalledCli({ explicit, env = process.env, globalRoots = defaultGlobalRoots, exists = existsSync, checkoutFile = CHECKOUT_FILE } = {}) {
73
+ const rel = path.join("@rightkit", "release", "cli", "right-release.mjs");
74
+ const candidates = explicit || env.RIGHT_RELEASE_INSTALLED_CLI
75
+ ? [explicit || env.RIGHT_RELEASE_INSTALLED_CLI]
76
+ : globalRoots({ env }).map((root) => path.join(root, rel));
77
+ const checkoutCli = path.join(path.dirname(checkoutFile), "cli", "right-release.mjs");
78
+ for (const candidate of candidates) {
79
+ if (!exists(candidate)) continue;
80
+ let real = candidate;
81
+ try { real = realpathSync(candidate); } catch { /* keep lexical */ }
82
+ if (real === checkoutCli || insideGitCheckout(real, exists)) continue;
83
+ return candidate;
84
+ }
85
+ throw new Error("disk schedule install needs the installed @rightkit/release (not a checkout): install it globally or pass --cli <path to the installed cli/right-release.mjs>");
86
+ }
87
+
88
+ function defaultGlobalRoots() {
89
+ const roots = [];
90
+ for (const tool of ["npm", "pnpm"]) {
91
+ const r = spawnSync(tool, ["root", "-g"], { encoding: "utf8", shell: process.platform === "win32" });
92
+ const line = String(r.stdout ?? "").trim().split(/\r?\n/).pop();
93
+ if (r.status === 0 && line) roots.push(line);
94
+ }
95
+ return roots;
96
+ }
97
+
98
+ 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 } = {}) {
99
+ if (platform === "darwin") {
100
+ const logDir = "/Volumes/D/claude/tmp/swept";
101
+ const file = path.join(home, "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
102
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
103
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
104
+ <plist version="1.0"><dict>
105
+ <key>Label</key><string>${LAUNCHD_LABEL}</string>
106
+ <key>ProgramArguments</key><array><string>${nodePath}</string><string>${cli}</string><string>disk</string><string>run</string></array>
107
+ <key>StartCalendarInterval</key><dict><key>Hour</key><integer>${hour}</integer><key>Minute</key><integer>${minute}</integer></dict>
108
+ <key>StandardOutPath</key><string>${logDir}/launchd.out.log</string>
109
+ <key>StandardErrorPath</key><string>${logDir}/launchd.err.log</string>
110
+ <key>ProcessType</key><string>Background</string><key>LowPriorityIO</key><true/><key>Nice</key><integer>10</integer>
111
+ </dict></plist>
112
+ `;
113
+ return { platform: "mac", file, plist };
114
+ }
115
+ const script = `$a = New-ScheduledTaskAction -Execute '${nodePath}' -Argument '"${cli}" disk run'
116
+ $t = New-ScheduledTaskTrigger -Daily -At ${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}
117
+ $s = New-ScheduledTaskSettingsSet -Hidden -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Hours 2)
118
+ $p = New-ScheduledTaskPrincipal -UserId "$env:USERDOMAIN\\$env:USERNAME" -LogonType Interactive -RunLevel Limited
119
+ Register-ScheduledTask -TaskName '${WINDOWS_TASK}' -Action $a -Trigger $t -Settings $s -Principal $p -Force | Out-Null`;
120
+ return { platform: "win", script };
121
+ }
122
+
123
+ function schedule(sub, { stdout, cli }) {
124
+ const platform = process.platform;
125
+ const spec = scheduleSpec(platform === "darwin" || sub !== "install" ? { platform } : { platform, cli: resolveInstalledCli({ explicit: cli }) });
126
+ if (platform === "darwin") {
127
+ const uid = String(process.getuid());
128
+ if (sub === "install") {
129
+ mkdirSync(path.dirname(spec.file), { recursive: true });
130
+ mkdirSync("/Volumes/D/claude/tmp/swept", { recursive: true });
131
+ writeFileSync(spec.file, spec.plist);
132
+ spawnSync("launchctl", ["bootout", `gui/${uid}/${LAUNCHD_LABEL}`], { encoding: "utf8" });
133
+ const r = spawnSync("launchctl", ["bootstrap", `gui/${uid}`, spec.file], { encoding: "utf8" });
134
+ if (r.status !== 0) throw new Error(`launchctl bootstrap failed: ${r.stderr}`);
135
+ stdout(`installed ${spec.file} (daily 03:30)`);
136
+ } else if (sub === "uninstall") {
137
+ spawnSync("launchctl", ["bootout", `gui/${uid}/${LAUNCHD_LABEL}`], { encoding: "utf8" });
138
+ if (existsSync(spec.file)) unlinkSync(spec.file);
139
+ stdout("uninstalled");
140
+ } else if (sub === "status") {
141
+ const r = spawnSync("launchctl", ["print", `gui/${uid}/${LAUNCHD_LABEL}`], { encoding: "utf8" });
142
+ stdout(r.status === 0 ? `installed (${spec.file})` : "not installed");
143
+ } else throw new Error("disk schedule install|uninstall|status");
144
+ return;
145
+ }
146
+ if (sub === "install") {
147
+ const r = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-Command", spec.script], { encoding: "utf8" });
148
+ if (r.status !== 0) throw new Error(`Register-ScheduledTask failed: ${r.stderr || r.stdout}`);
149
+ stdout(`installed scheduled task "${WINDOWS_TASK}" (daily 03:30, hidden)`);
150
+ } else if (sub === "uninstall") {
151
+ spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", `Unregister-ScheduledTask -TaskName '${WINDOWS_TASK}' -Confirm:$false`], { encoding: "utf8" });
152
+ stdout("uninstalled");
153
+ } else if (sub === "status") {
154
+ const r = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", `Get-ScheduledTask -TaskName '${WINDOWS_TASK}' | Select-Object -ExpandProperty State`], { encoding: "utf8" });
155
+ stdout(r.status === 0 ? `installed (${String(r.stdout).trim()})` : "not installed");
156
+ } else throw new Error("disk schedule install|uninstall|status");
157
+ }
158
+
159
+ export async function runDiskCommand(args = process.argv.slice(2), { env = process.env, stdout = console.log, platform = process.platform, deps = {} } = {}) {
160
+ const [command, ...rest] = args;
161
+ if (command === "schedule") {
162
+ const at = rest.indexOf("--cli");
163
+ schedule(rest[0], { stdout, cli: at === -1 ? undefined : rest[at + 1] });
164
+ return { exitCode: 0 };
165
+ }
166
+ const opts = parse(rest);
167
+ const policy = readDiskPolicy(opts.policy);
168
+ const key = platformKey(platform);
169
+ const logFile = path.join(policy.heavyVolume[key], "disk-hygiene.jsonl");
170
+ const out = (value, text) => stdout(opts.json ? JSON.stringify(value) : text);
171
+ const check = () => checkDisk({ policy, platform, env, ...deps.check });
172
+ const plan = () => planSweep({ policy, platform, env, staleDays: opts.days, openPaths: platform === "win32" ? null : (deps.openPaths ?? listOpenPaths({ platform })), ...deps.plan });
173
+
174
+ if (command === "check") {
175
+ const result = check();
176
+ out(result, renderCheck(result));
177
+ return { exitCode: result.warn ? DISK_EXIT_WARNING : 0, result };
178
+ }
179
+ if (command === "sweep") {
180
+ const p = plan();
181
+ if (opts.dryRun) { out(p, renderSweep(p)); return { exitCode: 0, plan: p }; }
182
+ const applied = applySweep({ plan: p, policy, platform, deleteMode: opts.deleteMode, ...deps.apply });
183
+ out({ ...p, applied }, renderSweep(p, applied));
184
+ return { exitCode: applied.results.some((r) => !r.ok) ? 1 : 0, plan: p, applied };
185
+ }
186
+ if (command === "run") {
187
+ const before = check();
188
+ stdout(renderCheck(before));
189
+ const p = plan();
190
+ stdout(renderSweep(p));
191
+ const applied = applySweep({ plan: p, policy, platform, ...deps.apply });
192
+ stdout(`moved ${formatBytes(applied.movedBytes)} (${applied.results.filter((r) => r.ok).length}/${applied.results.length})`);
193
+ const after = check();
194
+ const warn = after.warn;
195
+ logEvent(logFile, { event: warn ? "disk.floor.warn" : "disk.run.ok", warnings: after.warnings, freeBytes: after.floor.freeBytes, movedBytes: applied.movedBytes });
196
+ if (warn) stdout(`WARNING: ${after.warnings.join("; ")}`);
197
+ return { exitCode: warn ? DISK_EXIT_WARNING : 0, before, after, applied };
198
+ }
199
+ throw new Error("disk usage: check|sweep [--dry-run|--delete] [--days N]|run|schedule install|uninstall|status [--json]");
200
+ }
201
+
202
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
203
+ runDiskCommand().then((r) => process.exit(r.exitCode)).catch((e) => { console.error(e.message); process.exit(2); });
204
+ }
@@ -0,0 +1,48 @@
1
+ {
2
+ "schema": 1,
3
+ "staleDays": 7,
4
+ "floors": {
5
+ "mac": {"path": "/System/Volumes/Data", "label": "Mac internal", "warnFreeBytes": 42949672960},
6
+ "win": {"path": "D:\\", "label": "Dell D:", "warnFreeBytes": 429496729600, "warnFreePercent": 10}
7
+ },
8
+ "heavyVolume": {
9
+ "mac": "/Volumes/D/claude/tmp/swept",
10
+ "win": "D:\\claude\\tmp\\swept"
11
+ },
12
+ "writers": [
13
+ {"id": "claude-session-dirs", "platform": "mac", "path": "/private/tmp/claude-501/*/*", "capBytes": 2147483648, "action": "sweep", "unit": "self"},
14
+ {"id": "rightkit-qa-tmp", "platform": "mac", "path": "/private/tmp/rightkit-qa", "capBytes": 2147483648, "action": "sweep", "unit": "children"},
15
+ {"id": "cache-e2e", "platform": "mac", "path": "~/Library/Caches/e2e_*", "capBytes": 1073741824, "action": "sweep", "unit": "self"},
16
+ {"id": "cache-parakeet", "platform": "mac", "path": "~/Library/Caches/parakeet_*", "capBytes": 1073741824, "action": "sweep", "unit": "self"},
17
+ {"id": "cache-swiftright-factory", "platform": "mac", "path": "~/Library/Caches/SwiftRightFactory", "capBytes": 10737418240, "action": "sweep", "unit": "children"},
18
+ {"id": "cache-cutright-engine", "platform": "mac", "path": "~/Library/Caches/cutright-engine", "capBytes": 2147483648, "action": "report"},
19
+ {"id": "cache-heardright-engine", "platform": "mac", "path": "~/Library/Caches/heardright-engine", "capBytes": 2147483648, "action": "report"},
20
+ {"id": "cache-pnpm", "platform": "mac", "path": "~/Library/Caches/pnpm", "capBytes": 3221225472, "action": "report"},
21
+ {"id": "cache-python", "platform": "mac", "path": "~/Library/Caches/python", "capBytes": 3221225472, "action": "report"},
22
+ {"id": "xcode-deriveddata", "platform": "mac", "path": "~/Library/Developer/Xcode/DerivedData", "capBytes": 10737418240, "action": "sweep", "unit": "children"},
23
+ {"id": "coresimulator", "platform": "mac", "path": "~/Library/Developer/CoreSimulator", "capBytes": 26843545600, "action": "report"},
24
+ {"id": "documents-scratch", "platform": "mac", "path": "~/Documents", "capBytes": 21474836480, "action": "report"},
25
+ {"id": "shared-models", "platform": "mac", "path": "/Volumes/D/Orthic Labs/shared/models", "capBytes": 107374182400, "action": "report"},
26
+ {"id": "stale-release-tarballs", "platform": "mac", "path": "/Volumes/D/claude/rightkit/packages/release/rightkit-release-*.tgz", "capBytes": 1073741824, "action": "sweep", "unit": "self"},
27
+ {"id": "win-temp", "platform": "win", "path": "${TEMP}", "capBytes": 10737418240, "action": "sweep", "unit": "children"},
28
+ {"id": "win-pnpm", "platform": "win", "path": "${LOCALAPPDATA}\\pnpm", "capBytes": 21474836480, "action": "report"},
29
+ {"id": "win-sccache", "platform": "win", "path": "D:\\.rightkit-managed\\rightkit-build-control\\global", "capBytes": 34359738368, "action": "report"},
30
+ {"id": "win-claude-workspace", "platform": "win", "path": "D:\\claude", "capBytes": 805306368000, "action": "report"},
31
+ {"id": "win-claude-tmp", "platform": "win", "path": "D:\\claude\\tmp", "capBytes": 107374182400, "action": "report"},
32
+ {"id": "win-shared-models", "platform": "win", "path": "${LOCALAPPDATA}\\Orthic Labs\\shared\\models", "capBytes": 107374182400, "action": "report"},
33
+ {"id": "stale-release-tarballs", "platform": "win", "path": "D:\\claude\\rightkit\\packages\\release\\rightkit-release-*.tgz", "capBytes": 1073741824, "action": "sweep", "unit": "self"},
34
+ {"id": "win-remote-inputs", "platform": "win", "path": "D:\\.rightkit-managed\\remote-inputs", "capBytes": 21474836480, "action": "report"},
35
+ {"id": "leftover-delete-me", "platform": "win", "path": "D:\\claude\\*-DELETE-ME", "capBytes": 1073741824, "action": "sweep", "unit": "self"},
36
+ {"id": "leftover-node-modules-medium-rightkit", "platform": "win", "path": "D:\\claude\\rightkit\\node_modules.medium-*", "capBytes": 1073741824, "action": "sweep", "unit": "self"},
37
+ {"id": "leftover-node-modules-medium-packages", "platform": "win", "path": "D:\\claude\\rightkit\\packages\\*\\node_modules.medium-*", "capBytes": 1073741824, "action": "sweep", "unit": "self"},
38
+ {"id": "leftover-legion-snap", "platform": "win", "path": "D:\\claude\\legion-snap-*", "capBytes": 1073741824, "action": "sweep", "unit": "self"},
39
+ {"id": "leftover-legion-final", "platform": "win", "path": "D:\\claude\\legion-final-*", "capBytes": 1073741824, "action": "sweep", "unit": "self"},
40
+ {"id": "leftover-delete-me", "platform": "mac", "path": "/Volumes/D/claude/*-DELETE-ME", "capBytes": 1073741824, "action": "sweep", "unit": "self"},
41
+ {"id": "leftover-node-modules-medium-rightkit", "platform": "mac", "path": "/Volumes/D/claude/rightkit/node_modules.medium-*", "capBytes": 1073741824, "action": "sweep", "unit": "self"},
42
+ {"id": "leftover-node-modules-medium-packages", "platform": "mac", "path": "/Volumes/D/claude/rightkit/packages/*/node_modules.medium-*", "capBytes": 1073741824, "action": "sweep", "unit": "self"},
43
+ {"id": "leftover-legion-snap", "platform": "mac", "path": "/Volumes/D/claude/legion-snap-*", "capBytes": 1073741824, "action": "sweep", "unit": "self"},
44
+ {"id": "leftover-legion-final", "platform": "mac", "path": "/Volumes/D/claude/legion-final-*", "capBytes": 1073741824, "action": "sweep", "unit": "self"},
45
+ {"id": "leftover-pulse-bak", "platform": "win", "path": "${LOCALAPPDATA}\\Programs\\Pulse.bak-*", "capBytes": 1073741824, "action": "sweep", "unit": "self"}
46
+ ],
47
+ "protectedSegments": ["Application Support", "Keychains", "Keychain", "HeardRightNext", "Credentials", ".ssh", ".rightkit-managed"]
48
+ }
@@ -0,0 +1,270 @@
1
+ import { closeSync, cpSync, existsSync, lstatSync, mkdirSync, opendirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, statfsSync, appendFileSync } from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { spawnSync } from "./background-process.mjs";
6
+
7
+ export const DISK_EXIT_WARNING = 3;
8
+ export const DEFAULT_POLICY_FILE = path.join(path.dirname(fileURLToPath(import.meta.url)), "disk-policy.json");
9
+
10
+ export function readDiskPolicy(file = DEFAULT_POLICY_FILE) {
11
+ const policy = JSON.parse(readFileSync(file, "utf8"));
12
+ if (policy.schema !== 1 || !Array.isArray(policy.writers)) throw new Error("disk policy: unsupported schema");
13
+ return policy;
14
+ }
15
+
16
+ export function platformKey(platform = process.platform) {
17
+ return platform === "win32" || platform === "win" ? "win" : "mac";
18
+ }
19
+
20
+ export function expandPolicyPath(value, { env = process.env, home = os.homedir() } = {}) {
21
+ let out = value.replace(/^~(?=$|[\\/])/, home);
22
+ out = out.replace(/\$\{(\w+)\}/g, (_, name) => {
23
+ if (!env[name]) throw new Error(`disk policy: ${name} is not set`);
24
+ return env[name];
25
+ });
26
+ return out;
27
+ }
28
+
29
+ // Expand a policy path whose segments may contain `*` wildcards into existing paths.
30
+ export function expandGlob(pattern, sep = path.sep) {
31
+ const parts = pattern.split(/[\\/]/);
32
+ let bases = [parts[0] === "" ? sep : `${parts[0]}${sep}`];
33
+ for (const part of parts.slice(1)) {
34
+ if (part === "") continue;
35
+ const next = [];
36
+ for (const base of bases) {
37
+ if (!part.includes("*")) {
38
+ const candidate = path.join(base, part);
39
+ if (existsSync(candidate)) next.push(candidate);
40
+ continue;
41
+ }
42
+ const rx = new RegExp(`^${part.split("*").map((s) => s.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join(".*")}$`);
43
+ let names = [];
44
+ try { names = readdirSync(base); } catch { continue; }
45
+ for (const name of names) if (rx.test(name)) next.push(path.join(base, name));
46
+ }
47
+ bases = next;
48
+ }
49
+ return bases;
50
+ }
51
+
52
+ export function walkSize(root) {
53
+ let total = 0;
54
+ const stack = [root];
55
+ while (stack.length) {
56
+ const dir = stack.pop();
57
+ let st;
58
+ try { st = lstatSync(dir); } catch { continue; }
59
+ total += st.blocks !== undefined ? st.blocks * 512 : st.size;
60
+ if (!st.isDirectory()) continue;
61
+ let handle;
62
+ try { handle = opendirSync(dir); } catch { continue; }
63
+ try {
64
+ for (let e = handle.readSync(); e; e = handle.readSync()) stack.push(path.join(dir, e.name));
65
+ } finally { try { handle.closeSync(); } catch { /* ignore */ } }
66
+ }
67
+ return total;
68
+ }
69
+
70
+ export function pathSize(target, { platform = process.platform } = {}) {
71
+ if (platform === "darwin") {
72
+ const r = spawnSync("du", ["-sk", target], { encoding: "utf8" });
73
+ const kb = Number.parseInt(String(r.stdout).split(/\s+/)[0], 10);
74
+ if (Number.isFinite(kb)) return kb * 1024;
75
+ }
76
+ return walkSize(target);
77
+ }
78
+
79
+ export function freeSpace(volumePath) {
80
+ const s = statfsSync(volumePath);
81
+ return { freeBytes: Number(s.bavail) * Number(s.bsize), totalBytes: Number(s.blocks) * Number(s.bsize) };
82
+ }
83
+
84
+ export function floorThreshold(floor, totalBytes) {
85
+ const pct = floor.warnFreePercent ? Math.floor((totalBytes * floor.warnFreePercent) / 100) : 0;
86
+ return Math.max(floor.warnFreeBytes ?? 0, pct);
87
+ }
88
+
89
+ export function evaluateFloor(floor, space) {
90
+ const thresholdBytes = floorThreshold(floor, space.totalBytes);
91
+ return { label: floor.label, path: floor.path, ...space, thresholdBytes, warn: space.freeBytes < thresholdBytes };
92
+ }
93
+
94
+ // Literal directory prefix of a policy path: everything before the first wildcard segment
95
+ // (for a wildcard-free path, its parent). A sweep unit must resolve inside it.
96
+ export function literalRoot(expanded) {
97
+ const parts = expanded.split(/[\\/]/);
98
+ const firstWild = parts.findIndex((p) => p.includes("*"));
99
+ const keep = firstWild === -1 ? parts.slice(0, -1) : parts.slice(0, firstWild);
100
+ const joined = keep.join(path.sep);
101
+ return joined === "" || /^[A-Za-z]:$/.test(joined) ? `${joined}${path.sep}` : joined;
102
+ }
103
+
104
+ export function isInsideRoot(candidate, root) {
105
+ try {
106
+ const realRoot = realpathSync(root);
107
+ const realCandidate = realpathSync(candidate);
108
+ const prefix = realRoot.endsWith(path.sep) ? realRoot : realRoot + path.sep;
109
+ return realCandidate !== realRoot && realCandidate.startsWith(prefix);
110
+ } catch { return false; }
111
+ }
112
+
113
+ export function writerMatches(writer, ctx) {
114
+ const pattern = expandPolicyPath(writer.path, ctx);
115
+ return expandGlob(pattern);
116
+ }
117
+
118
+ export function checkDisk({ policy, platform = process.platform, env = process.env, home = os.homedir(), size = (p) => pathSize(p, { platform }), space = freeSpace } = {}) {
119
+ const key = platformKey(platform);
120
+ const floorPolicy = policy.floors[key];
121
+ const floor = evaluateFloor(floorPolicy, space(floorPolicy.path));
122
+ const writers = [];
123
+ for (const writer of policy.writers.filter((w) => w.platform === key)) {
124
+ let matches;
125
+ try { matches = writerMatches(writer, { env, home }); } catch (error) {
126
+ writers.push({ id: writer.id, action: writer.action, error: error.message, bytes: 0, capBytes: writer.capBytes, over: false, matches: 0 });
127
+ continue;
128
+ }
129
+ const bytes = matches.reduce((sum, m) => sum + size(m), 0);
130
+ writers.push({ id: writer.id, action: writer.action, path: writer.path, matches: matches.length, bytes, capBytes: writer.capBytes, over: bytes > writer.capBytes });
131
+ }
132
+ const warnings = [];
133
+ if (floor.warn) warnings.push(`${floor.label} free ${formatBytes(floor.freeBytes)} is below the ${formatBytes(floor.thresholdBytes)} floor`);
134
+ for (const w of writers) if (w.over) warnings.push(`${w.id} holds ${formatBytes(w.bytes)} over its ${formatBytes(w.capBytes)} cap`);
135
+ return { schema: 1, platform: key, floor, writers, warnings, warn: warnings.length > 0 };
136
+ }
137
+
138
+ export function formatBytes(n) {
139
+ if (n >= 1024 ** 3) return `${(n / 1024 ** 3).toFixed(1)} GB`;
140
+ if (n >= 1024 ** 2) return `${(n / 1024 ** 2).toFixed(1)} MB`;
141
+ return `${Math.round(n / 1024)} KB`;
142
+ }
143
+
144
+ // True when anything under `root` was modified after `cutoffMs` (early exit).
145
+ export function hasFreshContent(root, cutoffMs) {
146
+ const stack = [root];
147
+ while (stack.length) {
148
+ const entry = stack.pop();
149
+ let st;
150
+ try { st = lstatSync(entry); } catch { continue; }
151
+ if (st.mtimeMs > cutoffMs) return true;
152
+ if (!st.isDirectory()) continue;
153
+ let handle;
154
+ try { handle = opendirSync(entry); } catch { continue; }
155
+ try {
156
+ for (let e = handle.readSync(); e; e = handle.readSync()) stack.push(path.join(entry, e.name));
157
+ } finally { try { handle.closeSync(); } catch { /* ignore */ } }
158
+ }
159
+ return false;
160
+ }
161
+
162
+ export function isProtectedPath(candidate, policy) {
163
+ const segments = candidate.split(/[\\/]/);
164
+ return (policy.protectedSegments ?? []).some((p) => segments.includes(p));
165
+ }
166
+
167
+ // Open-file listing for the whole machine on macOS; paths a live process holds.
168
+ export function listOpenPaths({ platform = process.platform } = {}) {
169
+ if (platform === "win32") return null;
170
+ const r = spawnSync("lsof", ["-n", "-P", "-w", "-F", "n"], { encoding: "utf8", maxBuffer: 1 << 28 });
171
+ if (r.error || (r.status !== 0 && !r.stdout)) return undefined;
172
+ return String(r.stdout).split("\n").filter((l) => l.startsWith("n/")).map((l) => l.slice(1));
173
+ }
174
+
175
+ export function inUseByOpenPaths(candidate, openPaths) {
176
+ if (openPaths === undefined) return true; // cannot prove idle: treat as in use
177
+ if (openPaths === null) return false;
178
+ const prefix = candidate.endsWith(path.sep) ? candidate : candidate + path.sep;
179
+ return openPaths.some((p) => p === candidate || p.startsWith(prefix));
180
+ }
181
+
182
+ // Windows has no lsof; a locked directory cannot be renamed. Probe by renaming away and back.
183
+ export function windowsLockProbe(candidate) {
184
+ const probe = `${candidate}.rk-probe`;
185
+ try { renameSync(candidate, probe); } catch { return true; }
186
+ try { renameSync(probe, candidate); } catch { return true; }
187
+ return false;
188
+ }
189
+
190
+ export function planSweep({ policy, platform = process.platform, env = process.env, home = os.homedir(), now = Date.now(), staleDays = policy.staleDays, openPaths, inUse, size = (p) => pathSize(p, { platform }) } = {}) {
191
+ const key = platformKey(platform);
192
+ const cutoff = now - staleDays * 86400_000;
193
+ const candidates = [];
194
+ const skipped = [];
195
+ const seen = new Set();
196
+ const lockCheck = inUse ?? (key === "win" ? windowsLockProbe : (p) => inUseByOpenPaths(p, openPaths));
197
+ for (const writer of policy.writers.filter((w) => w.platform === key && w.action === "sweep")) {
198
+ let roots; let literal;
199
+ try { roots = writerMatches(writer, { env, home }); literal = literalRoot(expandPolicyPath(writer.path, { env, home })); } catch { continue; }
200
+ const units = [];
201
+ for (const r of roots) {
202
+ if (writer.unit !== "children") { units.push({ unit: r, root: literal }); continue; }
203
+ let rst;
204
+ try { rst = lstatSync(r); } catch { continue; }
205
+ if (rst.isSymbolicLink()) { skipped.push({ writer: writer.id, path: r, reason: "symlink-root" }); continue; }
206
+ let names = [];
207
+ try { names = readdirSync(r); } catch { continue; }
208
+ for (const n of names) units.push({ unit: path.join(r, n), root: r });
209
+ }
210
+ for (const { unit, root } of units) {
211
+ if (seen.has(unit)) continue;
212
+ seen.add(unit);
213
+ const base = { writer: writer.id, path: unit };
214
+ let st;
215
+ try { st = lstatSync(unit); } catch { continue; }
216
+ if (st.isSymbolicLink()) { skipped.push({ ...base, reason: "symlink" }); continue; }
217
+ if (!isInsideRoot(unit, root)) { skipped.push({ ...base, reason: "outside-root" }); continue; }
218
+ if (isProtectedPath(unit, policy)) { skipped.push({ ...base, reason: "protected" }); continue; }
219
+ if (hasFreshContent(unit, cutoff)) { skipped.push({ ...base, reason: "fresh" }); continue; }
220
+ if (lockCheck(unit)) { skipped.push({ ...base, reason: "in-use" }); continue; }
221
+ candidates.push({ ...base, root, bytes: size(unit), mtime: new Date(st.mtimeMs).toISOString() });
222
+ }
223
+ }
224
+ return { schema: 1, platform: key, staleDays, candidates, skipped, totalBytes: candidates.reduce((s, c) => s + c.bytes, 0) };
225
+ }
226
+
227
+ export function sweptDestination({ policy, platform = process.platform, date = new Date() }) {
228
+ const key = platformKey(platform);
229
+ return path.join(policy.heavyVolume[key], date.toISOString().slice(0, 10));
230
+ }
231
+
232
+ export function moveEntry(source, destination) {
233
+ mkdirSync(path.dirname(destination), { recursive: true });
234
+ try { renameSync(source, destination); return "rename"; } catch (error) {
235
+ if (error.code !== "EXDEV") throw error;
236
+ }
237
+ const before = walkSize(source);
238
+ cpSync(source, destination, { recursive: true, preserveTimestamps: true, verbatimSymlinks: true, errorOnExist: true, force: false });
239
+ if (walkSize(destination) < before) throw new Error(`copy verification failed for ${source}; source kept`);
240
+ rmSync(source, { recursive: true, force: true }); // completes the move after verified copy
241
+ return "copy-verify-remove";
242
+ }
243
+
244
+ export function logEvent(logFile, event) {
245
+ try {
246
+ mkdirSync(path.dirname(logFile), { recursive: true });
247
+ appendFileSync(logFile, `${JSON.stringify({ ts: new Date().toISOString(), ...event })}\n`);
248
+ } catch { /* logging must never fail the sweep */ }
249
+ }
250
+
251
+ export function applySweep({ plan, policy, platform = process.platform, deleteMode = false, date = new Date(), move = moveEntry, remove = (p) => rmSync(p, { recursive: true, force: true }) }) {
252
+ const destRoot = sweptDestination({ policy, platform, date });
253
+ const logFile = path.join(policy.heavyVolume[platformKey(platform)], "disk-hygiene.jsonl");
254
+ const results = [];
255
+ for (const c of plan.candidates) {
256
+ const rel = path.join(c.writer, path.basename(c.path));
257
+ let destination = path.join(destRoot, rel);
258
+ if (existsSync(destination)) destination = `${destination}.${date.getTime()}`;
259
+ try {
260
+ // Re-verify at apply time: still the planned unit, not a symlink, still inside its root.
261
+ if (!c.root || lstatSync(c.path).isSymbolicLink() || !isInsideRoot(c.path, c.root)) throw new Error("refused: path is not a sweepable unit inside its target root");
262
+ if (deleteMode) { remove(c.path); results.push({ ...c, action: "delete", ok: true }); logEvent(logFile, { event: "disk.sweep.delete", ...c }); }
263
+ else { const how = move(c.path, destination); results.push({ ...c, action: "move", destination, how, ok: true }); logEvent(logFile, { event: "disk.sweep.move", ...c, destination, how }); }
264
+ } catch (error) {
265
+ results.push({ ...c, action: deleteMode ? "delete" : "move", ok: false, error: error.message });
266
+ logEvent(logFile, { event: "disk.sweep.error", ...c, error: error.message });
267
+ }
268
+ }
269
+ return { logFile, results, movedBytes: results.filter((r) => r.ok).reduce((s, r) => s + r.bytes, 0) };
270
+ }
package/local-publish.mjs CHANGED
@@ -30,18 +30,20 @@ const refuse = (message) => { throw new LocalPublishError(message); };
30
30
 
31
31
  export function parseLocalPublishArgs(kind, argv, cwd = process.cwd()) {
32
32
  const flag = kind === "npm" ? "--package" : "--crate";
33
- let unit; let workspace; let local = false;
33
+ let unit; let workspace; let local = false; let allowIgnored = false;
34
34
  for (let i = 0; i < argv.length; i += 1) {
35
35
  const arg = argv[i];
36
36
  if (arg === "--local") local = true;
37
37
  else if (arg === flag) unit = argv[++i];
38
38
  else if (arg === "--workspace") workspace = argv[++i];
39
- else refuse(`unknown publish ${kind} --local option: ${arg} (accepted: ${flag} <name>, --workspace <dir>, --local)`);
39
+ // Same meaning as publish.yml's allow_ignored: this unit may have ignored tests; the count is recorded.
40
+ else if (arg === "--allow-ignored") allowIgnored = true;
41
+ else refuse(`unknown publish ${kind} --local option: ${arg} (accepted: ${flag} <name>, --workspace <dir>, --allow-ignored, --local)`);
40
42
  }
41
43
  if (!local) refuse("--local is required");
42
44
  if (kind === "npm" && !/^@rightkit\/[a-z0-9-]+$/.test(unit ?? "")) refuse("publish npm --local requires --package @rightkit/<name>");
43
45
  if (kind === "cargo" && !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(unit ?? "")) refuse("publish cargo --local requires --crate <name>");
44
- return { kind, unit, workspaceRoot: path.resolve(workspace ?? cwd) };
46
+ return { kind, unit, allowIgnored, workspaceRoot: path.resolve(workspace ?? cwd) };
45
47
  }
46
48
 
47
49
  function defaultGit(args, cwd) {
@@ -107,7 +109,7 @@ const tail = (text, lines = 40) => String(text ?? "").split(/\r?\n/).slice(-line
107
109
 
108
110
  export async function runLocalPublish(kind, argv, deps = {}) {
109
111
  const log = deps.log ?? ((line) => process.stdout.write(`${line}\n`));
110
- const { unit, workspaceRoot } = parseLocalPublishArgs(kind, argv, deps.cwd);
112
+ const { unit, allowIgnored, workspaceRoot } = parseLocalPublishArgs(kind, argv, deps.cwd);
111
113
  const platform = deps.platform ?? process.platform;
112
114
  const git = deps.git ?? defaultGit;
113
115
  const readJson = deps.readJson ?? defaultReadJson;
@@ -170,7 +172,7 @@ export async function runLocalPublish(kind, argv, deps = {}) {
170
172
  }
171
173
  steps.push(row);
172
174
  }
173
- const verdict = judgeUnit({ name: unit, steps: steps.map((s) => ({ ...s, unit: plan.id })), allowIgnored: [], allowMissingAssets: isAssetGated ? [unit] : [] });
175
+ const verdict = judgeUnit({ name: unit, steps: steps.map((s) => ({ ...s, unit: plan.id })), allowIgnored: allowIgnored ? [unit] : [], allowMissingAssets: isAssetGated ? [unit] : [] });
174
176
  if (verdict.status === "fail" || steps.some((s) => s.failed > 0)) refuse(`${plan.id} not qualified: ${verdict.problems.join("; ") || "failed tests reported"}`);
175
177
  if (!(verdict.executed > 0)) refuse(`${plan.id} executed no test`);
176
178
  log(`[right-release] ${plan.id}: ${verdict.executed} passed, 0 failed, ${verdict.skipped} skipped, ${verdict.ignored} ignored${isAssetGated ? " (asset-gated: RIGHTKIT_REQUIRE_ASSETS=0, skips recorded)" : ""}`);
@@ -212,7 +214,7 @@ export async function runLocalPublish(kind, argv, deps = {}) {
212
214
  runs: [{
213
215
  host: { os: "darwin", arch: deps.arch ?? process.arch }, commit: head, features: plan.featureLabels, artifactSha256,
214
216
  command: commands.join(" && "), operator: LOCAL_OPERATOR, timestamp,
215
- passed: verdict.executed, failed: 0, skipped: verdict.skipped, ignored: verdict.ignored,
217
+ passed: verdict.executed, failed: 0, skipped: verdict.skipped, ignored: verdict.ignored, allowIgnored,
216
218
  requireAssets: env.RIGHTKIT_REQUIRE_ASSETS, assetGated: isAssetGated,
217
219
  steps: steps.map(({ command, ok, executed, failed, ignored, skipped, binaries }) => ({ command, ok, ...(executed === undefined ? {} : { executed, failed, ignored, skipped, binaries }) })),
218
220
  }],