@rightkit/release 0.2.37 → 0.2.39

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.
@@ -0,0 +1,96 @@
1
+ import assert from "node:assert/strict";
2
+ import { execFileSync } from "node:child_process";
3
+ import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import test from "node:test";
7
+
8
+ import { assertPrimaryReleaseCheckout, dirtyBuildInputs } from "./release-invocation.mjs";
9
+
10
+ function git(cwd, ...args) {
11
+ return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
12
+ }
13
+
14
+ function makeRepo() {
15
+ const root = mkdtempSync(path.join(os.tmpdir(), "right-release-invocation-"));
16
+ git(root, "init", "--initial-branch", "main");
17
+ git(root, "config", "user.email", "release-test@example.com");
18
+ git(root, "config", "user.name", "Release Test");
19
+ mkdirSync(path.join(root, "src"));
20
+ mkdirSync(path.join(root, "tests"));
21
+ mkdirSync(path.join(root, "docs"));
22
+ writeFileSync(path.join(root, "package.json"), "{}\n");
23
+ writeFileSync(path.join(root, "src", "app.js"), "export const app = true;\n");
24
+ writeFileSync(path.join(root, "tests", "app.test.js"), "// test\n");
25
+ writeFileSync(path.join(root, "docs", "notes.md"), "notes\n");
26
+ git(root, "add", ".");
27
+ git(root, "commit", "-m", "initial");
28
+ return root;
29
+ }
30
+
31
+ const buildInputs = {
32
+ include: ["package.json", "src/**"],
33
+ exclude: ["**/*.test.*", "**/tests/**"],
34
+ json: {
35
+ "package.json": [["dependencies"], ["scripts", "build"]],
36
+ },
37
+ };
38
+
39
+ test("rejects release invocation from a linked Git worktree", () => {
40
+ const primary = makeRepo();
41
+ const linked = path.join(path.dirname(primary), `${path.basename(primary)}-linked`);
42
+ git(primary, "worktree", "add", "--detach", linked);
43
+
44
+ assert.throws(
45
+ () => assertPrimaryReleaseCheckout(linked),
46
+ /must be invoked from the primary Git worktree/i,
47
+ );
48
+ });
49
+
50
+ test("allows release invocation from the primary Git checkout", () => {
51
+ const primary = makeRepo();
52
+
53
+ assert.doesNotThrow(() => assertPrimaryReleaseCheckout(primary));
54
+ });
55
+
56
+ test("reports dirty files that can change the packaged app", () => {
57
+ const primary = makeRepo();
58
+ writeFileSync(path.join(primary, "src", "app.js"), "export const app = false;\n");
59
+ writeFileSync(path.join(primary, "src", "new-runtime.js"), "export {};\n");
60
+
61
+ assert.deepEqual(dirtyBuildInputs(primary, buildInputs), [
62
+ "src/app.js",
63
+ "src/new-runtime.js",
64
+ ]);
65
+ });
66
+
67
+ test("ignores dirty files outside the packaged app input set", () => {
68
+ const primary = makeRepo();
69
+ writeFileSync(path.join(primary, "docs", "notes.md"), "changed notes\n");
70
+ writeFileSync(path.join(primary, "tests", "app.test.js"), "// changed test\n");
71
+ writeFileSync(path.join(primary, "scratch.txt"), "evaluation output\n");
72
+
73
+ assert.deepEqual(dirtyBuildInputs(primary, buildInputs), []);
74
+ });
75
+
76
+ test("checks only build-relevant fields in mixed-purpose JSON manifests", () => {
77
+ const primary = makeRepo();
78
+ writeFileSync(path.join(primary, "package.json"), `${JSON.stringify({ scripts: { bakeoff: "node eval.mjs" } })}\n`);
79
+ assert.deepEqual(dirtyBuildInputs(primary, buildInputs), []);
80
+
81
+ writeFileSync(path.join(primary, "package.json"), `${JSON.stringify({ dependencies: { tauri: "2.0.0" } })}\n`);
82
+ assert.deepEqual(dirtyBuildInputs(primary, buildInputs), ["package.json"]);
83
+ });
84
+
85
+ test("does not report a locally dirty file whose bytes already equal the release commit", () => {
86
+ const primary = makeRepo();
87
+ const localHead = git(primary, "rev-parse", "HEAD");
88
+ writeFileSync(path.join(primary, "src", "app.js"), "export const app = false;\n");
89
+ git(primary, "add", "src/app.js");
90
+ git(primary, "commit", "-m", "release change");
91
+ const releaseCommit = git(primary, "rev-parse", "HEAD");
92
+ git(primary, "checkout", localHead);
93
+ writeFileSync(path.join(primary, "src", "app.js"), "export const app = false;\n");
94
+
95
+ assert.deepEqual(dirtyBuildInputs(primary, buildInputs, releaseCommit), []);
96
+ });
package/build-release.mjs CHANGED
@@ -20,7 +20,9 @@ import {
20
20
  import path from "node:path";
21
21
  import { spawn, spawnSync } from "node:child_process";
22
22
  import { fileURLToPath, pathToFileURL } from "node:url";
23
- import { cacheFingerprint, commandOutputPortable, releaseEnvironment, resolveReleaseLayout, runBuildStateMachine, watchProgress } from "./release-state.mjs";
23
+ import { assertPrimaryReleaseCheckout, dirtyBuildInputs } from "./release-invocation.mjs";
24
+ import { commandOutputPortable, releaseEnvironment, resolveReleaseLayout, runBuildStateMachine, verifySealedRelease, watchProgress } from "./release-state.mjs";
25
+ import { acquireCacheLease, acquireSuiteBuildSlot, applyCachePrune, assertWriteVolumeFloors, inspectCache, inspectWriteVolumes, markCacheEntrySuccessful, planCachePrune, readCachePolicy, resolveCacheLayout, resolveSharedCacheIdentity, resolveSharedCacheRoot } from "./cache-policy.mjs";
24
26
 
25
27
  const TOOL_ROOT = path.dirname(fileURLToPath(import.meta.url));
26
28
  const WORKER = path.join(TOOL_ROOT, "release.mjs");
@@ -42,22 +44,27 @@ if (platform !== "win" && platform !== "mac") fail("--platform must be win or ma
42
44
 
43
45
  const invocationRoot = path.dirname(path.resolve(configName));
44
46
  const repoRoot = git(invocationRoot, ["rev-parse", "--show-toplevel"]);
47
+ assertPrimaryReleaseCheckout(repoRoot);
45
48
  const relativeConfig = path.relative(repoRoot, path.resolve(configName)).replaceAll("\\", "/");
46
49
  if (relativeConfig.startsWith("../")) fail("release config must live inside the Git repository");
47
50
  const vaultRoot = resolveReleaseLayout({ repoRoot, configPath: path.resolve(configName) }).vaultRoot;
48
51
  mkdirSync(path.join(vaultRoot, "locks"), { recursive: true });
49
52
  const lock = acquireLock(path.join(vaultRoot, "locks", `${platform}.lock.json`));
50
53
  let child = null;
54
+ let suiteSlot;
55
+ let cacheLease;
56
+ let interrupted;
51
57
 
52
58
  for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
53
59
  process.once(signal, () => {
54
60
  if (child?.pid) killTree(child.pid);
55
- lock.release();
56
- process.exit(signal === "SIGINT" ? 130 : 143);
61
+ interrupted = new Error(`release interrupted by ${signal}`);
62
+ process.exitCode = signal === "SIGINT" ? 130 : 143;
57
63
  });
58
64
  }
59
65
 
60
66
  try {
67
+ throwIfInterrupted();
61
68
  if (!dryRun) git(repoRoot, ["fetch", "origin", "main"]);
62
69
  const commit = git(repoRoot, ["rev-parse", dryRun ? "HEAD" : "origin/main"]);
63
70
  const shortCommit = commit.slice(0, 8);
@@ -80,24 +87,36 @@ try {
80
87
  }
81
88
  const target = config.targets?.[platform];
82
89
  if (!target?.package) fail(`${config.app} has no ${platform} package command`);
90
+ const dirtyInputs = dirtyBuildInputs(repoRoot, repoBuildInputs(repoRoot, invocationRoot, relativeConfig, target.buildInputs ?? config.buildInputs), commit);
91
+ if (dirtyInputs.length > 0) fail(`dirty files can change the packaged app; resolve them before release:\n${dirtyInputs.map((file) => `- ${file}`).join("\n")}`);
83
92
  const requiredInputs = inputPaths(appRoot, worktreeConfigPath, target);
84
93
  const toolVersions = collectToolVersions(config.packageManager ?? "pnpm");
85
94
  const inputHashes = hashInputs(requiredInputs);
86
95
  const cargoLock = requiredInputs.find((file) => /Cargo\.lock$/i.test(file));
87
96
  const cargoToml = requiredInputs.find((file) => /Cargo\.toml$/i.test(file));
88
- const cacheKey = cacheFingerprint({
89
- cargoLockSha256: cargoLock ? inputHashes[path.relative(worktree, cargoLock)] : "none",
90
- rustc: toolVersions.rustc,
91
- target: toolVersions.rustHost,
92
- features: cargoToml ? sqlCipherFeatures(readFileSync(cargoToml, "utf8")) : [],
93
- });
97
+ const cacheIdentity = resolveSharedCacheIdentity({ cargoLockPath: cargoLock, cargoTomlPath: cargoToml, rustcVerbose: toolVersions.rustc, targetTriple: target.cargoTarget ?? target.targetTriple ?? target.rustTarget ?? toolVersions.rustHost, profile: target.profile ?? "release" });
98
+ const cacheKey = cacheIdentity.fingerprint;
99
+ const cacheMode = process.env.RIGHT_RELEASE_CACHE_MODE ?? (platform === "mac" ? "shared" : "legacy");
100
+ if (cacheMode !== "legacy" && cacheMode !== "shared") fail("RIGHT_RELEASE_CACHE_MODE must be legacy or shared");
101
+ const sharedCacheRoot = cacheMode === "shared" ? resolveSharedCacheRoot({ platform, env: process.env }) : undefined;
102
+ const sharedLayout = cacheMode === "shared"
103
+ ? resolveCacheLayout({ cacheRoot: sharedCacheRoot, platform, architecture: cacheIdentity.architecture, app: config.app, fingerprint: cacheKey, kind: "release" })
104
+ : undefined;
105
+ if (cacheMode === "shared") assertSccacheVersion(toolVersions.sccache);
94
106
  const env = {
95
107
  ...process.env,
96
- ...releaseEnvironment({ root: vaultRoot, platform, cacheKey, kind: "release" }),
108
+ ...releaseEnvironment({ root: vaultRoot, cacheRoot: sharedCacheRoot, platform, architecture: cacheIdentity.architecture, app: config.app, cacheKey, kind: "release", appRoot, mode: cacheMode }),
97
109
  RIGHT_RELEASE_REPO_ROOT: layout.repoRoot,
98
110
  RIGHT_RELEASE_APP_ROOT: layout.appRoot,
99
111
  };
100
- if (!commandExists("sccache")) delete env.RUSTC_WRAPPER;
112
+ if (cacheMode === "legacy") delete env.RIGHT_RELEASE_CACHE_OWNER;
113
+ if (cacheMode === "shared" && env.RIGHT_RELEASE_CACHE_OWNER !== "rightkit-v2") fail("shared cache ownership token was not configured");
114
+ if (cacheMode === "legacy" && !commandExists("sccache")) delete env.RUSTC_WRAPPER;
115
+ if (cacheMode === "shared") {
116
+ suiteSlot = acquireSuiteBuildSlot({ layout: sharedLayout, pid: process.pid, argv: process.argv, waitMs: Number(process.env.BUILD_GUARD_WAIT_SECS ?? 900) * 1000 });
117
+ cacheLease = acquireCacheLease({ layout: sharedLayout, pid: process.pid, argv: process.argv });
118
+ }
119
+ throwIfInterrupted();
101
120
  const releaseId = `${config.app}-${config.version}-${shortCommit}`;
102
121
  const platformDir = platform === "win" ? "windows" : "mac";
103
122
  const buildRoot = path.join(vaultRoot, "build", config.app, config.version, shortCommit, platformDir);
@@ -124,7 +143,15 @@ try {
124
143
  requiredInputs,
125
144
  ops: {
126
145
  preflight: async () => {
127
- assertDiskSpace(repoRoot, target.preflight?.minFreeGb ?? 20);
146
+ throwIfInterrupted();
147
+ if (cacheMode === "shared") {
148
+ const policy = readCachePolicy(process.env);
149
+ const snapshot = inspectCache({ cacheRoot: sharedCacheRoot });
150
+ const plan = planCachePrune({ snapshot, policy, protectedEntryIds: new Set([sharedLayout.id]) });
151
+ applyCachePrune({ plan, cacheRoot: sharedCacheRoot, dryRun: false });
152
+ const volumes = inspectWriteVolumes({ repoRoot, vaultRoot, cacheRoot: sharedCacheRoot });
153
+ assertWriteVolumeFloors({ volumes, policy, configuredRepoMinimumBytes: Math.max(Number(target.preflight?.minFreeGb ?? 25), 25) * 1024 ** 3 });
154
+ } else assertDiskSpace(repoRoot, target.preflight?.minFreeGb ?? 20);
128
155
  assertExecutables(["git", config.packageManager ?? "pnpm", "cargo", "rustc", ...(target.preflight?.executables ?? [])]);
129
156
  for (const name of target.preflight?.env ?? []) if (!process.env[name]) fail(`missing required environment variable: ${name}`);
130
157
  for (const command of target.preflight?.commands ?? []) runChecked(command.cmd, command.args ?? [], path.resolve(appRoot, command.cwd ?? "."), env);
@@ -135,6 +162,7 @@ try {
135
162
  checkpoint(stateRoot, "preflight_complete");
136
163
  },
137
164
  prepare: async () => {
165
+ throwIfInterrupted();
138
166
  const cacheTarget = env.CARGO_TARGET_DIR;
139
167
  const targetLink = path.join(appRoot, "src-tauri", "target");
140
168
  mkdirSync(cacheTarget, { recursive: true });
@@ -145,6 +173,7 @@ try {
145
173
  await runProgress(config.packageManager ?? "pnpm", ["install", "--frozen-lockfile"], appRoot, env, cacheTarget);
146
174
  },
147
175
  build: async () => {
176
+ throwIfInterrupted();
148
177
  await runProgress(
149
178
  process.execPath,
150
179
  [WORKER, "--config", worktreeConfigPath, "--platform", platform, "--no-upload"],
@@ -158,13 +187,22 @@ try {
158
187
  },
159
188
  seal: async ({ sealedDir }) => {
160
189
  sealRelease({ configRoot: path.dirname(worktreeConfigPath), sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey });
190
+ verifySealedRelease(sealedDir);
191
+ if (cacheMode === "shared") markCacheEntrySuccessful({ layout: sharedLayout });
161
192
  checkpoint(stateRoot, "sealed");
162
193
  },
163
194
  },
164
195
  });
196
+ if (cacheMode === "shared") {
197
+ const snapshot = inspectCache({ cacheRoot: sharedCacheRoot });
198
+ const plan = planCachePrune({ snapshot, policy: readCachePolicy(process.env), protectedEntryIds: new Set([sharedLayout.id]) });
199
+ applyCachePrune({ plan, cacheRoot: sharedCacheRoot, dryRun: false });
200
+ }
165
201
  console.log(`right-release build: ${result.resumed ? "resumed" : "sealed"} ${result.releaseId}`);
166
202
  console.log(`sealed: ${result.sealedDir}`);
167
203
  } finally {
204
+ cacheLease?.release();
205
+ suiteSlot?.release();
168
206
  lock.release();
169
207
  }
170
208
 
@@ -182,6 +220,24 @@ function inputPaths(appRoot, configPath, target) {
182
220
  return required;
183
221
  }
184
222
 
223
+ function repoBuildInputs(repoRoot, appRoot, relativeConfig, configured) {
224
+ if (!Array.isArray(configured?.include) || configured.include.length === 0) {
225
+ fail("release config must declare non-empty buildInputs.include paths");
226
+ }
227
+ const appPrefix = path.relative(repoRoot, appRoot).replaceAll("\\", "/");
228
+ const qualify = (pattern) => [appPrefix, pattern.replaceAll("\\", "/").replace(/^\.\//, "")].filter(Boolean).join("/");
229
+ return {
230
+ include: [
231
+ relativeConfig,
232
+ qualify("package.json"),
233
+ qualify("pnpm-lock.yaml"),
234
+ ...configured.include.map(qualify),
235
+ ],
236
+ exclude: (configured.exclude ?? []).map(qualify),
237
+ json: Object.fromEntries(Object.entries(configured.json ?? {}).map(([file, fields]) => [qualify(file), fields])),
238
+ };
239
+ }
240
+
185
241
  function hashInputs(files) {
186
242
  return Object.fromEntries(files.map((file) => [path.relative(path.dirname(path.dirname(file)), file), hashFile(file)]));
187
243
  }
@@ -350,6 +406,11 @@ function commandExists(name) {
350
406
  return probe.status === 0;
351
407
  }
352
408
 
409
+ function assertSccacheVersion(version) {
410
+ const match = String(version ?? "").match(/sccache\s+(\d+)\.(\d+)\.(\d+)/i);
411
+ if (!match || Number(match[1]) === 0 && Number(match[2]) < 15) fail("shared cache requires sccache >= 0.15.0; install with: cargo install sccache --locked; then verify: sccache --version");
412
+ }
413
+
353
414
  function commandOutput(cmd, runArgs) {
354
415
  try {
355
416
  return commandOutputPortable(cmd, runArgs);
@@ -395,5 +456,7 @@ function usage(code) {
395
456
 
396
457
  function fail(message) {
397
458
  console.error(`right-release build: ${message}`);
398
- process.exit(1);
459
+ throw new Error(message);
399
460
  }
461
+
462
+ function throwIfInterrupted() { if (interrupted) throw interrupted; }
@@ -0,0 +1,24 @@
1
+ import assert from "node:assert/strict";
2
+ import { readFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ import test from "node:test";
5
+
6
+ const source = readFileSync(path.join(path.dirname(new URL(import.meta.url).pathname), "build-release.mjs"), "utf8");
7
+
8
+ test("macOS builds default to Cache V2 while explicit legacy mode remains available", () => {
9
+ assert.match(source, /RIGHT_RELEASE_CACHE_MODE/);
10
+ assert.match(source, /platform === "mac" \? "shared" : "legacy"/);
11
+ assert.match(source, /cacheMode !== "legacy" && cacheMode !== "shared"/);
12
+ assert.match(source, /resolveSharedCacheRoot/);
13
+ assert.match(source, /acquireSuiteBuildSlot/);
14
+ assert.match(source, /acquireCacheLease/);
15
+ assert.match(source, /markCacheEntrySuccessful/);
16
+ assert.match(source, /RIGHT_RELEASE_CACHE_OWNER/);
17
+ assert.match(source, /suiteSlot\?\.release\(\)/);
18
+ assert.match(source, /cacheLease\?\.release\(\)/);
19
+ assert.doesNotMatch(source, /process\.exit\(signal/);
20
+ assert.match(source, /throwIfInterrupted/);
21
+ assert.match(source, /delete env\.RIGHT_RELEASE_CACHE_OWNER/);
22
+ assert.match(source, /assertPrimaryReleaseCheckout\(repoRoot\)/);
23
+ assert.match(source, /dirtyBuildInputs\(repoRoot/);
24
+ });
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+ import {
6
+ applyCachePrune,
7
+ inspectCache,
8
+ migrateLegacyCache,
9
+ planCachePrune,
10
+ readCachePolicy,
11
+ resolveCacheLayout,
12
+ resolveSharedCacheIdentity,
13
+ resolveSharedCacheRoot,
14
+ } from "./cache-policy.mjs";
15
+ import { resolveReleaseLayout } from "./release-state.mjs";
16
+
17
+ export async function runCacheCommand(args = process.argv.slice(2), { env = process.env, stdout = console.log, stderr = console.error } = {}) {
18
+ const [command, ...rest] = args;
19
+ const json = rest.includes("--json");
20
+ const platform = platformFrom(env);
21
+ const cacheRoot = resolveSharedCacheRoot({ platform, env });
22
+ if (command === "status") {
23
+ rejectUnknown(rest, new Set(["--json"]));
24
+ const snapshot = inspectCache({ cacheRoot });
25
+ return output({ schema: 1, command, ...snapshot, policy: readCachePolicy(env) }, json, stdout);
26
+ }
27
+ if (command === "prune") {
28
+ rejectUnknown(rest, new Set(["--json", "--dry-run", "--apply"]));
29
+ if (rest.includes("--dry-run") && rest.includes("--apply")) throw new Error("cache prune accepts either --dry-run or --apply");
30
+ const dryRun = !rest.includes("--apply");
31
+ const snapshot = inspectCache({ cacheRoot });
32
+ const plan = planCachePrune({ snapshot, policy: readCachePolicy(env), protectedEntryIds: new Set(snapshot.entries.filter((entry) => entry.leased).map((entry) => entry.id)) });
33
+ const result = dryRun ? { candidateIds: plan.candidateIds, removedIds: [], reclaimedBytes: 0, dryRun: true } : applyCachePrune({ plan, cacheRoot, dryRun: false });
34
+ return output({ schema: 1, command, cacheRoot, dryRun, plan, ...result }, json, stdout);
35
+ }
36
+ if (command === "migrate") {
37
+ const configPath = option(rest, "--config");
38
+ if (!configPath) throw new Error("cache migrate requires --config <right-release.config.mjs>");
39
+ rejectUnknown(rest.filter((value) => value !== "--config" && value !== configPath), new Set(["--json", "--dry-run", "--apply"]));
40
+ if (rest.includes("--dry-run") && rest.includes("--apply")) throw new Error("cache migrate accepts either --dry-run or --apply");
41
+ const configFile = path.resolve(configPath);
42
+ const config = (await import(pathToFileURL(configFile).href)).default;
43
+ if (!config?.app) throw new Error("cache migrate config must expose app");
44
+ const appRoot = path.dirname(configFile);
45
+ const configuredTarget = config.targets?.[platform] ?? {};
46
+ const lockPath = path.join(appRoot, "src-tauri", "Cargo.lock");
47
+ const identity = resolveSharedCacheIdentity({ cargoLockPath: lockPath, cargoTomlPath: path.join(appRoot, "src-tauri", "Cargo.toml"), rustcVerbose: env.RIGHT_RELEASE_RUSTC_VERBOSE, targetTriple: configuredTarget.cargoTarget ?? configuredTarget.targetTriple ?? configuredTarget.rustTarget ?? env.TAURI_ENV_TARGET_TRIPLE, profile: configuredTarget.profile ?? "release" });
48
+ const fingerprint = env.RIGHT_RELEASE_CACHE_KEY || identity.fingerprint;
49
+ const layout = resolveCacheLayout({ cacheRoot, platform, architecture: identity.architecture, app: config.app, fingerprint, kind: "release" });
50
+ const dryRun = !rest.includes("--apply");
51
+ const legacy = resolveReleaseLayout({ repoRoot: findRepoRoot(appRoot), configPath: configFile });
52
+ const legacyTargetRoot = path.join(legacy.vaultRoot, "cache", "cargo-target", platform, fingerprint);
53
+ const result = migrateLegacyCache({ legacyRoot: path.join(appRoot, "src-tauri", "target"), legacyTargetRoot, legacyCargoHome: path.join(legacy.vaultRoot, "cache", "cargo-home"), legacyReleaseLockPath: path.join(legacy.vaultRoot, "locks", `${platform}.lock.json`), layout, app: config.app, dryRun });
54
+ return output({ schema: 1, command, cacheRoot, ...result }, json, stdout);
55
+ }
56
+ throw new Error("right-release cache: expected status, prune, or migrate");
57
+ }
58
+
59
+ function output(value, json, stdout) {
60
+ if (json) stdout(JSON.stringify(value));
61
+ else if (value.command === "status") stdout(`RightKit cache root: ${value.cacheRoot}\nTargets: ${value.targetBytes} bytes\nFree: ${(value.cacheFreeBytes / 1024 ** 3).toFixed(1)} GiB\nLeases: ${value.entries.filter((entry) => entry.leased).length}`);
62
+ else stdout(`RightKit cache ${value.command}: ${value.dryRun ? "dry-run" : "applied"}\nRoot: ${value.cacheRoot}\nCandidates: ${(value.candidateIds ?? value.plan?.candidateIds ?? []).join(", ") || "none"}\nReclaimed: ${value.reclaimedBytes ?? 0} bytes`);
63
+ return value;
64
+ }
65
+
66
+ function option(args, name) { const index = args.indexOf(name); return index >= 0 ? args[index + 1] : undefined; }
67
+ function rejectUnknown(args, allowed) { for (const arg of args) if (!allowed.has(arg)) throw new Error(`unknown cache argument: ${arg}`); }
68
+ function platformFrom(env) { return env.RIGHT_RELEASE_PLATFORM || (process.platform === "win32" ? "win" : process.platform === "darwin" ? "mac" : process.platform); }
69
+ function findRepoRoot(start) { let current = path.resolve(start); for (;;) { if (existsSync(path.join(current, ".git"))) return current; const parent = path.dirname(current); if (parent === current) throw new Error(`cache migrate could not find Git repository for ${start}`); current = parent; } }
70
+
71
+ if (import.meta.url === pathToFileURL(process.argv[1]).href) {
72
+ runCacheCommand().catch((error) => { console.error(error.message); process.exitCode = 2; });
73
+ }
@@ -0,0 +1,73 @@
1
+ import assert from "node:assert/strict";
2
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { spawnSync } from "node:child_process";
6
+ import test from "node:test";
7
+
8
+ const root = path.dirname(new URL(import.meta.url).pathname);
9
+ const cli = path.join(root, "cli", "right-release.mjs");
10
+
11
+ function run(args, cacheRoot, env = {}) {
12
+ return spawnSync(process.execPath, [cli, "cache", ...args], { encoding: "utf8", env: { ...process.env, RIGHT_RELEASE_CACHE_ROOT: cacheRoot, ...env } });
13
+ }
14
+
15
+ test("cache status has human and schema-stable JSON output", () => {
16
+ const cacheRoot = mkdtempSync(path.join(os.tmpdir(), "rightkit-cache-command-"));
17
+ const human = run(["status"], cacheRoot);
18
+ const json = run(["status", "--json"], cacheRoot);
19
+ assert.equal(human.status, 0, human.stderr);
20
+ assert.match(human.stdout, /RightKit cache root:/);
21
+ assert.equal(json.status, 0, json.stderr);
22
+ assert.equal(JSON.parse(json.stdout).schema, 1);
23
+ });
24
+
25
+ test("cache prune defaults to dry-run and only apply can mutate an admitted temporary entry", () => {
26
+ const cacheRoot = mkdtempSync(path.join(os.tmpdir(), "rightkit-cache-command-"));
27
+ const target = path.join(cacheRoot, "targets", "mac", "fixture", "1111111111111111");
28
+ mkdirSync(target, { recursive: true });
29
+ writeFileSync(path.join(target, "payload"), "payload");
30
+ writeFileSync(path.join(target, ".rightkit-cache-entry.json"), JSON.stringify({ schema: 1, id: "target:mac:fixture:1111111111111111", kind: "release", platform: "mac", architecture: "aarch64", app: "fixture", fingerprint: "1111111111111111", createdAt: "2020-01-01T00:00:00.000Z", lastUsedAt: "2020-01-01T00:00:00.000Z", lastSuccessfulBuildAt: "2020-01-01T00:00:00.000Z", toolchain: {} }));
31
+ const dry = run(["prune", "--dry-run", "--json"], cacheRoot);
32
+ assert.equal(dry.status, 0, dry.stderr);
33
+ assert.equal(JSON.parse(dry.stdout).dryRun, true);
34
+ assert.equal(run(["prune", "--apply", "--json"], cacheRoot).status, 0);
35
+ });
36
+
37
+ test("cache rejects unknown commands and migration requires a config", () => {
38
+ const cacheRoot = mkdtempSync(path.join(os.tmpdir(), "rightkit-cache-command-"));
39
+ assert.notEqual(run(["unknown"], cacheRoot).status, 0);
40
+ assert.notEqual(run(["migrate"], cacheRoot).status, 0);
41
+ });
42
+
43
+ test("status and prune dry-run do not create a cache root or lock file", () => {
44
+ const parent = mkdtempSync(path.join(os.tmpdir(), "rightkit-cache-command-"));
45
+ const cacheRoot = path.join(parent, "never-created");
46
+ assert.equal(run(["status", "--json"], cacheRoot).status, 0);
47
+ assert.equal(run(["prune", "--dry-run", "--json"], cacheRoot).status, 0);
48
+ assert.equal(existsSync(cacheRoot), false);
49
+ });
50
+
51
+ test("cache migrate uses the repository vault Cargo home, not an app-local lookalike", () => {
52
+ const sandbox = mkdtempSync(path.join(os.tmpdir(), "rightkit-cache-command-"));
53
+ const repo = path.join(sandbox, "repo"); const app = path.join(repo, "app"); const cacheRoot = path.join(sandbox, "shared-cache");
54
+ const config = path.join(app, "right-release.config.mjs"); const fingerprint = "abcdef0123456789";
55
+ const target = path.join(repo, ".right-release", "cache", "cargo-target", "mac", fingerprint);
56
+ const vaultCargoHome = path.join(repo, ".right-release", "cache", "cargo-home");
57
+ const appCargoHome = path.join(app, ".right-release", "cache", "cargo-home");
58
+ mkdirSync(path.join(repo, ".git"), { recursive: true });
59
+ mkdirSync(path.dirname(config), { recursive: true });
60
+ writeFileSync(config, "export default { app: 'fixture', targets: { mac: { targetTriple: 'aarch64-apple-darwin' } } };\n");
61
+ mkdirSync(path.join(app, "src-tauri"), { recursive: true });
62
+ writeFileSync(path.join(app, "src-tauri", "Cargo.lock"), "fixture-lock\n");
63
+ writeFileSync(path.join(app, "src-tauri", "Cargo.toml"), "[package]\nname = 'fixture'\n");
64
+ mkdirSync(target, { recursive: true }); writeFileSync(path.join(target, "artifact"), "target");
65
+ symlinkSync(target, path.join(app, "src-tauri", "target"));
66
+ mkdirSync(vaultCargoHome, { recursive: true }); writeFileSync(path.join(vaultCargoHome, "registry"), "vault");
67
+ mkdirSync(appCargoHome, { recursive: true }); writeFileSync(path.join(appCargoHome, "registry"), "app-local");
68
+ const result = run(["migrate", "--config", config, "--apply", "--json"], cacheRoot, { RIGHT_RELEASE_PLATFORM: "mac", RIGHT_RELEASE_RUSTC_VERBOSE: "rustc 1.91.0\nhost: aarch64-apple-darwin", RIGHT_RELEASE_CACHE_KEY: fingerprint });
69
+ assert.equal(result.status, 0, result.stderr);
70
+ assert.equal(JSON.parse(result.stdout).cargoHomeMoved, true);
71
+ assert.equal(readFileSync(path.join(cacheRoot, "cargo-home", "registry"), "utf8"), "vault");
72
+ assert.equal(readFileSync(path.join(appCargoHome, "registry"), "utf8"), "app-local");
73
+ });