@rightkit/release 0.2.56 → 0.2.58

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,199 @@
1
+ #!/usr/bin/env node
2
+ import { createHash } from "node:crypto";
3
+ import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { spawnSync } from "node:child_process";
7
+ import { fileURLToPath, pathToFileURL } from "node:url";
8
+ import { addonManifestSha256, addonRoutes, canonicalAddonManifest, createAddonManifest, downloadImmutableAddOn, validateAddonConfig, validateAddonManifest } from "./addon-contract.mjs";
9
+ import { assertPrimaryReleaseCheckout } from "./release-invocation.mjs";
10
+ import { assertCleanSource } from "./source-gate.mjs";
11
+
12
+ const ROOT = path.dirname(fileURLToPath(import.meta.url));
13
+ const SIGN_WINDOWS = path.join(ROOT, "sign-windows.mjs");
14
+ const HARDEN = path.join(ROOT, "hardeningscan.mjs");
15
+ const [operation, ...args] = process.argv.slice(2);
16
+ const options = parse(args);
17
+ if (!new Set(["doctor", "build", "upload", "adopt"]).has(operation)) usage(2);
18
+ if (!new Set(["mac", "win"]).has(options.platform)) fail("--platform must be mac or win");
19
+
20
+ if (operation === "adopt") await adopt(options);
21
+ else {
22
+ if (!options.config) fail("--config is required");
23
+ const configPath = path.resolve(options.config);
24
+ const root = path.dirname(configPath);
25
+ const repoRoot = git(root, ["rev-parse", "--show-toplevel"]);
26
+ assertPrimaryReleaseCheckout(repoRoot);
27
+ const config = (await import(`${pathToFileURL(configPath).href}?addon=${Date.now()}`)).default;
28
+ if (operation === "doctor") doctor({ config, configPath, root, repoRoot, options });
29
+ else if (operation === "build") await build({ config, configPath, root, repoRoot, options });
30
+ else await upload({ config, root, repoRoot, options });
31
+ }
32
+
33
+ function doctor({ config, configPath, root, repoRoot, options }) {
34
+ validateAddonConfig(config, { root, platform: options.platform, allowMissingExecutables: true });
35
+ console.log(`right-release addon doctor: ${config.addon} ${config.version} ${options.platform}`);
36
+ console.log(`config: ${configPath}`);
37
+ console.log(`repo: ${repoRoot}`);
38
+ }
39
+
40
+ async function build({ config, configPath, root, repoRoot, options }) {
41
+ const commit = git(repoRoot, ["rev-parse", "HEAD"]);
42
+ const { target } = validateAddonConfig(config, { root, platform: options.platform, allowMissingExecutables: true });
43
+ if (options.dryRun) {
44
+ console.log(`right-release addon build dry-run: ${config.addon} ${config.version} ${options.platform}`);
45
+ console.log(`config: ${configPath}`);
46
+ console.log(`commit: ${commit}`);
47
+ console.log(`would run: ${target.build.cmd} ${target.build.args.join(" ")}`);
48
+ return;
49
+ }
50
+ assertCleanSource({ status: git(repoRoot, ["status", "--porcelain=v1", "-z", "--untracked-files=all"]), commandId: "right-release addon build" });
51
+ run(target.build.cmd, target.build.args, root);
52
+ const signing = signExecutableRoles(config, root, options.platform);
53
+ const manifest = createAddonManifest({ config, root, platform: options.platform, commit, signing });
54
+ const digest = addonManifestSha256(manifest);
55
+ const sealed = path.join(repoRoot, ".right-release", "addons", config.addon, config.version, commit.slice(0, 8), options.platform);
56
+ if (existsSync(sealed)) fail(`add-on seal already exists: ${sealed}`);
57
+ const temp = `${sealed}.tmp-${process.pid}`;
58
+ mkdirSync(temp, { recursive: true });
59
+ for (const file of config.targets[options.platform].files ?? config.files) copyFileSync(path.resolve(root, file.source), path.join(temp, file.name));
60
+ writeFileSync(path.join(temp, "addon-manifest.json"), canonicalAddonManifest(manifest));
61
+ run(process.execPath, [HARDEN, ...manifest.files.map((file) => path.join(temp, file.name))], root);
62
+ mkdirSync(path.dirname(sealed), { recursive: true });
63
+ renameSync(temp, sealed);
64
+ console.log(`right-release addon build: sealed ${sealed}`);
65
+ console.log(`manifestSha256: ${digest}`);
66
+ }
67
+
68
+ function signExecutableRoles(config, root, platform) {
69
+ const files = config.targets[platform].files ?? config.files;
70
+ const executable = files.filter((file) => file.executable === true);
71
+ if (platform === "win") run(process.execPath, [SIGN_WINDOWS, ...executable.map((file) => path.resolve(root, file.source))], root);
72
+ else for (const file of executable) {
73
+ const absolute = path.resolve(root, file.source);
74
+ run("codesign", ["--force", "--options", "runtime", "--sign", "Developer ID Application", absolute], root);
75
+ const output = command("codesign", ["-dv", "--verbose=4", absolute], root);
76
+ if (!new RegExp(`TeamIdentifier=${config.targets.mac.signing.teamId}`).test(output)) fail(`codesign team mismatch: ${file.name}`);
77
+ run("codesign", ["--verify", "--strict", "--verbose=2", absolute], root);
78
+ }
79
+ return Object.fromEntries(executable.map((file) => [file.role, { contract: config.targets[platform].signing.contract, status: "verified" }]));
80
+ }
81
+
82
+ async function upload({ config, root, repoRoot, options }) {
83
+ if (!new Set(["patch", "update"]).has(options.tier)) fail("add-on upload requires --tier patch|update");
84
+ const commit = git(repoRoot, ["rev-parse", "HEAD"]);
85
+ const sealed = path.join(repoRoot, ".right-release", "addons", config.addon, config.version, commit.slice(0, 8), options.platform);
86
+ const manifest = JSON.parse(readFileSync(path.join(sealed, "addon-manifest.json"), "utf8"));
87
+ validateAddonManifest(manifest);
88
+ const routes = addonRoutes(manifest);
89
+ if (options.dryRun) {
90
+ for (const route of routes) console.log(`would upload ${route.key}${route.manifest ? " (last)" : ""}`);
91
+ return;
92
+ }
93
+ if (!process.env.CLOUDFLARE_API_TOKEN) fail("CLOUDFLARE_API_TOKEN is required before R2 mutation");
94
+ for (const route of routes) {
95
+ const source = route.manifest ? path.join(sealed, "addon-manifest.json") : path.join(sealed, route.name);
96
+ await putImmutable({ source, route, repoRoot });
97
+ }
98
+ console.log(`right-release addon upload: verified ${config.addon} ${options.platform} tier=${options.tier}`);
99
+ }
100
+
101
+ async function putImmutable({ source, route, repoRoot }) {
102
+ const target = `rightapps-downloads/${route.key}`;
103
+ const temp = path.join(os.tmpdir(), `right-addon-r2-${process.pid}-${createHash("sha256").update(route.key).digest("hex").slice(0, 12)}`);
104
+ const existing = wrangler(["r2", "object", "get", target, "--file", temp, "--remote"], repoRoot);
105
+ if (existing.status === 0) {
106
+ const actual = sha256(temp); rmSync(temp, { force: true });
107
+ if (actual !== route.sha256) fail(`immutable object exists with different bytes: ${route.key}`);
108
+ return;
109
+ }
110
+ if (!/not found|does not exist|404/i.test(`${existing.stdout}\n${existing.stderr}`)) fail(`cannot inspect immutable object: ${route.key}`);
111
+ const uploaded = wrangler(["r2", "object", "put", target, "--file", source, "--remote"], repoRoot);
112
+ if (uploaded.status !== 0) fail(`immutable upload failed: ${route.key}`);
113
+ }
114
+
115
+ async function adopt(options) {
116
+ if (!options.lock || !options.output) fail("adopt requires --lock and --output");
117
+ const lockPath = path.resolve(options.lock);
118
+ const lock = JSON.parse(readFileSync(lockPath, "utf8"));
119
+ const entry = lock.targets?.[options.platform] ?? lock[options.platform];
120
+ if (!entry?.manifestSha256 || !entry?.manifestUrl) fail("add-on lock needs platform manifestUrl and manifestSha256");
121
+ const temporary = options.source ? null : mkdtempSync(path.join(os.tmpdir(), "right-addon-adopt-"));
122
+ const source = options.source ? path.resolve(options.source) : temporary;
123
+ if (!options.source) await downloadImmutableAddOn(entry.manifestUrl, source);
124
+ const manifest = JSON.parse(readFileSync(path.join(source, "addon-manifest.json"), "utf8"));
125
+ validateAddonManifest(manifest);
126
+ if (addonManifestSha256(manifest) !== entry.manifestSha256) fail("add-on lock manifest SHA mismatch");
127
+ const immutableSuffix = `/${manifest.addon}/addons/${manifest.platform}/sha256/${entry.manifestSha256}/addon-manifest.json`;
128
+ if (!entry.manifestUrl.endsWith(immutableSuffix) || /[?#]/.test(entry.manifestUrl)) fail("add-on lock manifestUrl is not immutable");
129
+ const output = path.resolve(options.output);
130
+ const destination = path.join(output, manifest.addon);
131
+ const stage = `${destination}.tmp-${process.pid}`;
132
+ const adoptedFiles = [];
133
+ rmSync(stage, { recursive: true, force: true }); mkdirSync(stage, { recursive: true });
134
+ for (const file of manifest.files) {
135
+ const sourceFile = path.join(source, file.name);
136
+ if (!existsSync(sourceFile) || statSync(sourceFile).size !== file.sizeBytes || sha256(sourceFile) !== file.sha256) fail(`add-on file verification failed: ${file.name}`);
137
+ verifyAdoptedSignature({ file, sourceFile, platform: manifest.platform, sourceFixture: Boolean(options.source) });
138
+ const subdir = file.role === "command" || file.role === "service" ? "bin" : file.role === "icon" || LEGAL(file.role) ? "resources" : "resources";
139
+ mkdirSync(path.join(stage, subdir), { recursive: true });
140
+ const installed = path.join(stage, subdir, file.name);
141
+ copyFileSync(sourceFile, installed);
142
+ // Tauri resolves externalBin artifacts by Rust target triple. Keep the
143
+ // portable role name too, then provide the required target-qualified alias.
144
+ if (file.role === "command" || file.role === "service") {
145
+ const base = file.name.replace(/\.exe$/i, "");
146
+ const suffix = manifest.platform === "win" ? `${base}-${manifest.targetTriple}.exe` : `${base}-${manifest.targetTriple}`;
147
+ const alias = path.join(stage, "bin", suffix);
148
+ copyFileSync(sourceFile, alias);
149
+ const adoptedSha256 = sha256(alias);
150
+ if (adoptedSha256 !== file.sha256) fail(`adopted alias hash mismatch: ${file.name}`);
151
+ adoptedFiles.push({ role: file.role, name: file.name, sealedSha256: file.sha256, adoptedName: `bin/${suffix}`, adoptedSha256 });
152
+ }
153
+ }
154
+ copyFileSync(path.join(source, "addon-manifest.json"), path.join(stage, "addon-manifest.json"));
155
+ // Directory rename is atomic on one filesystem. Move any previous add-on
156
+ // aside first, restore it if replacement fails, then clean it only after the
157
+ // new verified tree is in place.
158
+ const previous = `${destination}.previous-${process.pid}`;
159
+ mkdirSync(output, { recursive: true });
160
+ if (existsSync(destination)) renameSync(destination, previous);
161
+ try {
162
+ renameSync(stage, destination);
163
+ } catch (error) {
164
+ if (existsSync(previous)) renameSync(previous, destination);
165
+ throw error;
166
+ }
167
+ rmSync(previous, { recursive: true, force: true });
168
+ if (temporary) rmSync(temporary, { recursive: true, force: true });
169
+ const repoRoot = git(path.dirname(lockPath), ["rev-parse", "--show-toplevel"]);
170
+ const receipt = path.join(repoRoot, ".right-release", "addons", "adoptions", `${manifest.addon}-${manifest.platform}.json`);
171
+ mkdirSync(path.dirname(receipt), { recursive: true });
172
+ writeFileSync(receipt, `${JSON.stringify({ schema: 1, addon: manifest.addon, platform: manifest.platform, manifestSha256: entry.manifestSha256, manifestUrl: entry.manifestUrl, output: destination, files: adoptedFiles, adoptedAt: new Date().toISOString() }, null, 2)}\n`);
173
+ console.log(`right-release addon adopt: ${destination}`);
174
+ console.log(`receipt: ${receipt}`);
175
+ }
176
+
177
+ function verifyAdoptedSignature({ file, sourceFile, platform, sourceFixture }) {
178
+ if (!file.executable) return;
179
+ if (file.signing.contract === "test-fixture-v1" && sourceFixture && process.env.RIGHT_RELEASE_TEST_FIXTURE === "1") return;
180
+ if (platform === "mac") {
181
+ if (file.signing.contract !== "apple-developer-id-executable-v1") fail(`unexpected mac signing contract: ${file.name}`);
182
+ const output = command("codesign", ["-dv", "--verbose=4", sourceFile], path.dirname(sourceFile));
183
+ if (!/TeamIdentifier=6KLGD3LLKF/.test(output)) fail(`codesign team mismatch: ${file.name}`);
184
+ run("codesign", ["--verify", "--strict", "--verbose=2", sourceFile], path.dirname(sourceFile));
185
+ } else {
186
+ if (file.signing.contract !== "azure-artifact-signing-v1") fail(`unexpected Windows signing contract: ${file.name}`);
187
+ run(process.execPath, [SIGN_WINDOWS, "--verify-only", sourceFile], path.dirname(sourceFile));
188
+ }
189
+ }
190
+
191
+ function LEGAL(role) { return new Set(["license", "eula", "privacy", "third-party-notices"]).has(role); }
192
+ function parse(args) { const value = { platform: process.platform === "win32" ? "win" : "mac", dryRun: false }; for (let i = 0; i < args.length; i += 1) { const key = args[i]; if (key === "--dry-run") value.dryRun = true; else if (["--config", "--platform", "--tier", "--lock", "--output", "--source"].includes(key)) value[key.slice(2).replace(/-([a-z])/g, (_, c) => c.toUpperCase())] = args[++i]; else fail(`unknown argument: ${key}`); } return value; }
193
+ function git(cwd, args) { return command("git", args, cwd).trim(); }
194
+ function command(cmd, args, cwd) { const result = spawnSync(cmd, args, { cwd, encoding: "utf8", windowsHide: true }); if (result.status !== 0) throw new Error(`${cmd} ${args.join(" ")} failed: ${result.stderr || result.stdout}`); return `${result.stdout ?? ""}${result.stderr ?? ""}`; }
195
+ function run(cmd, args, cwd) { command(cmd, args, cwd); }
196
+ function wrangler(args, cwd) { return spawnSync("pnpm", ["dlx", "wrangler@4", ...args], { cwd, encoding: "utf8", windowsHide: true }); }
197
+ function sha256(file) { return createHash("sha256").update(readFileSync(file)).digest("hex"); }
198
+ function usage(code) { console.log("usage: right-release addon doctor|build|upload|adopt --platform mac|win ..."); process.exit(code); }
199
+ function fail(message) { console.error(`right-release addon: ${message}`); process.exit(1); }
@@ -0,0 +1,53 @@
1
+ import assert from "node:assert/strict";
2
+ import { existsSync, mkdtempSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { execFileSync, spawnSync } from "node:child_process";
6
+ import { fileURLToPath, pathToFileURL } from "node:url";
7
+ import test from "node:test";
8
+ import { createAddonManifest, addonManifestSha256, canonicalAddonManifest, downloadImmutableAddOn } from "./addon-contract.mjs";
9
+
10
+ const command = fileURLToPath(new URL("./addon-command.mjs", import.meta.url));
11
+ function fixture() {
12
+ const root = mkdtempSync(path.join(os.tmpdir(), "right-addon-command-"));
13
+ mkdirSync(path.join(root, "legal")); mkdirSync(path.join(root, "assets"));
14
+ for (const name of ["LICENSE", "EULA.txt", "PRIVACY.md", "THIRD-PARTY-NOTICES.txt"]) writeFileSync(path.join(root, "legal", name), name);
15
+ writeFileSync(path.join(root, "assets", "tab.png"), "icon");
16
+ const files = [["command", "membrane", "out/membrane", true], ["service", "crypt-service", "out/crypt-service", true], ["icon", "membrane-tab-icon.png", "assets/tab.png", false], ["license", "LICENSE", "legal/LICENSE", false], ["eula", "EULA.txt", "legal/EULA.txt", false], ["privacy", "PRIVACY.md", "legal/PRIVACY.md", false], ["third-party-notices", "THIRD-PARTY-NOTICES.txt", "legal/THIRD-PARTY-NOTICES.txt", false]].map(([role, name, source, executable]) => ({ role, name, source, executable }));
17
+ writeFileSync(path.join(root, "right-addon.config.mjs"), `export default ${JSON.stringify({ schema: 1, kind: "headless-addon", addon: "membrane", version: "0.1.0", packageManager: "pnpm", checks: [], buildInputs: { include: ["right-addon.config.mjs"] }, consumer: { contract: "orthic-product-v1" }, targets: { mac: { targetTriple: "aarch64-apple-darwin", build: { cmd: "false", args: [] }, signing: { contract: "apple-developer-id-executable-v1", teamId: "6KLGD3LLKF" }, files }, win: { targetTriple: "x86_64-pc-windows-msvc", build: { cmd: "false", args: [] }, signing: { contract: "azure-artifact-signing-v1" }, files } } }, null, 2)};\n`);
18
+ execFileSync("git", ["init", "--initial-branch", "main"], { cwd: root }); execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: root }); execFileSync("git", ["config", "user.name", "Test"], { cwd: root }); execFileSync("git", ["add", "."], { cwd: root }); execFileSync("git", ["commit", "-m", "init"], { cwd: root });
19
+ return root;
20
+ }
21
+ test("add-on dry-run creates no release state and never executes build", () => {
22
+ const root = fixture(); const result = spawnSync(process.execPath, [command, "build", "--config", "right-addon.config.mjs", "--platform", "mac", "--dry-run"], { cwd: root, encoding: "utf8" });
23
+ assert.equal(result.status, 0, result.stderr); assert.match(result.stdout, /would run: false/); assert.equal(existsSync(path.join(root, ".right-release")), false);
24
+ });
25
+
26
+ test("local adoption verifies lock and stages portable plus Tauri-qualified binaries", async () => {
27
+ const root = fixture(); const config = (await import(`${pathToFileURL(path.join(root, "right-addon.config.mjs")).href}?test=${Date.now()}`)).default;
28
+ mkdirSync(path.join(root, "out")); writeFileSync(path.join(root, "out", "membrane"), "command"); writeFileSync(path.join(root, "out", "crypt-service"), "service");
29
+ const manifest = createAddonManifest({ config, root, platform: "mac", commit: "d".repeat(40), signing: { command: { contract: "test-fixture-v1", status: "verified" }, service: { contract: "test-fixture-v1", status: "verified" } } });
30
+ const sealed = path.join(root, "sealed"); mkdirSync(sealed);
31
+ for (const file of manifest.files) writeFileSync(path.join(sealed, file.name), readFileSync(path.join(root, config.targets.mac.files.find((entry) => entry.role === file.role).source)));
32
+ writeFileSync(path.join(sealed, "addon-manifest.json"), canonicalAddonManifest(manifest));
33
+ const digest = addonManifestSha256(manifest); const lock = path.join(root, "membrane.lock.json"); writeFileSync(lock, JSON.stringify({ targets: { mac: { manifestUrl: `https://example.test/membrane/addons/mac/sha256/${digest}/addon-manifest.json`, manifestSha256: digest } } }));
34
+ const output = path.join(root, "addons"); const unsigned = spawnSync(process.execPath, [command, "adopt", "--platform", "mac", "--lock", lock, "--output", `${output}-unsigned`, "--source", sealed], { cwd: root, encoding: "utf8", env: { ...process.env, RIGHT_RELEASE_TEST_FIXTURE: "" } });
35
+ assert.notEqual(unsigned.status, 0); assert.match(unsigned.stderr, /unexpected mac signing contract/);
36
+ const result = spawnSync(process.execPath, [command, "adopt", "--platform", "mac", "--lock", lock, "--output", output, "--source", sealed], { cwd: root, encoding: "utf8", env: { ...process.env, RIGHT_RELEASE_TEST_FIXTURE: "1" } });
37
+ assert.equal(result.status, 0, result.stderr);
38
+ assert.equal(readFileSync(path.join(output, "membrane", "bin", "membrane-aarch64-apple-darwin"), "utf8"), "command");
39
+ assert.equal(readFileSync(path.join(output, "membrane", "resources", "LICENSE"), "utf8"), "LICENSE");
40
+ const receipt = JSON.parse(readFileSync(path.join(root, ".right-release", "addons", "adoptions", "membrane-mac.json"), "utf8"));
41
+ assert.equal(receipt.manifestSha256, digest);
42
+ assert.deepEqual(receipt.files.map((file) => [file.role, file.sealedSha256, file.adoptedSha256]), [["command", manifest.files.find((file) => file.role === "command").sha256, manifest.files.find((file) => file.role === "command").sha256], ["service", manifest.files.find((file) => file.role === "service").sha256, manifest.files.find((file) => file.role === "service").sha256]]);
43
+ });
44
+
45
+ test("remote downloader accepts only immutable manifest plus file routes", async () => {
46
+ const root = fixture(); const config = (await import(`${pathToFileURL(path.join(root, "right-addon.config.mjs")).href}?remote=${Date.now()}`)).default;
47
+ mkdirSync(path.join(root, "out")); writeFileSync(path.join(root, "out", "membrane"), "command"); writeFileSync(path.join(root, "out", "crypt-service"), "service");
48
+ const manifest = createAddonManifest({ config, root, platform: "mac", commit: "e".repeat(40), signing: { command: { contract: "test-fixture-v1", status: "verified" }, service: { contract: "test-fixture-v1", status: "verified" } } });
49
+ const digest = addonManifestSha256(manifest); const base = `https://example.test/membrane/addons/mac/sha256/${digest}/`; const bytes = new Map([[`${base}addon-manifest.json`, Buffer.from(canonicalAddonManifest(manifest))], ...manifest.files.map((file) => [base + encodeURIComponent(file.name), readFileSync(path.join(root, config.targets.mac.files.find((entry) => entry.role === file.role).source))])]);
50
+ const destination = path.join(root, "remote"); mkdirSync(destination);
51
+ await downloadImmutableAddOn(`${base}addon-manifest.json`, destination, { fetchImpl: async (url) => ({ ok: bytes.has(url), status: bytes.has(url) ? 200 : 404, arrayBuffer: async () => bytes.get(url) }) });
52
+ assert.equal(readFileSync(path.join(destination, "crypt-service"), "utf8"), "service");
53
+ });
@@ -0,0 +1,100 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
3
+ import path from "node:path";
4
+
5
+ const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
6
+ const ROLES = new Set(["command", "service", "icon", "license", "eula", "privacy", "third-party-notices"]);
7
+ const LEGAL_ROLES = new Set(["license", "eula", "privacy", "third-party-notices"]);
8
+ const PLATFORM = new Set(["mac", "win"]);
9
+
10
+ export function validateAddonConfig(config, { root, platform, allowMissingExecutables = false }) {
11
+ if (!PLATFORM.has(platform)) throw new Error("add-on platform must be mac or win");
12
+ if (!config || config.schema !== 1 || config.kind !== "headless-addon") throw new Error("add-on config must use schema 1 headless-addon");
13
+ for (const key of ["addon", "version", "packageManager"]) if (typeof config[key] !== "string" || !SAFE_NAME.test(config[key])) throw new Error(`unsafe add-on ${key}`);
14
+ if (!Array.isArray(config.checks)) throw new Error("add-on checks must be an array");
15
+ validateBuildInputs(config.buildInputs);
16
+ const target = config.targets?.[platform];
17
+ if (!target?.targetTriple || !target?.build?.cmd || !Array.isArray(target.build.args)) throw new Error(`add-on ${platform} target requires targetTriple and build command`);
18
+ if (!target.signing?.contract) throw new Error(`add-on ${platform} target requires signing contract`);
19
+ if (platform === "mac" && target.signing.contract === "apple-developer-id-executable-v1" && !/^[A-Z0-9]{10}$/.test(target.signing.teamId ?? "")) throw new Error("mac add-on requires Developer ID teamId");
20
+ const files = resolveAddonFiles(config, root, platform);
21
+ const seenRoles = new Set();
22
+ const seenNames = new Set();
23
+ for (const file of files) {
24
+ if (!ROLES.has(file.role) || seenRoles.has(file.role)) throw new Error(`duplicate or unsafe add-on role: ${file.role}`);
25
+ if (!SAFE_NAME.test(file.name) || file.name.includes("..")) throw new Error(`unsafe add-on file name: ${file.name}`);
26
+ if (path.isAbsolute(file.source) || file.source.split(/[\\/]/).includes("..")) throw new Error(`unsafe add-on source: ${file.source}`);
27
+ if (seenNames.has(file.name)) throw new Error(`duplicate add-on file name: ${file.name}`);
28
+ if (!existsSync(path.resolve(root, file.source)) && !(allowMissingExecutables && file.executable === true)) throw new Error(`missing add-on file: ${file.source}`);
29
+ seenRoles.add(file.role); seenNames.add(file.name);
30
+ }
31
+ for (const role of ["command", "service", "icon", ...LEGAL_ROLES]) if (!seenRoles.has(role)) throw new Error(`missing required add-on role: ${role}`);
32
+ return { target, files };
33
+ }
34
+
35
+ export function resolveAddonFiles(config, root, platform) {
36
+ const configured = config.targets?.[platform]?.files ?? config.files;
37
+ if (!Array.isArray(configured)) throw new Error("add-on config requires files with role, name, and source");
38
+ return configured.map((file) => ({ ...file, executable: file.executable === true, source: String(file.source ?? "") }));
39
+ }
40
+
41
+ export function createAddonManifest({ config, root, platform, commit, signedAt = new Date().toISOString(), signing = {} }) {
42
+ const { target, files } = validateAddonConfig(config, { root, platform });
43
+ if (!/^[0-9a-f]{40}$/i.test(commit)) throw new Error("add-on commit must be a full Git SHA");
44
+ const manifestFiles = files.map((file) => {
45
+ const source = path.resolve(root, file.source);
46
+ const executable = file.executable === true;
47
+ if ((file.role === "command" || file.role === "service") && !executable) throw new Error(`executable role must declare executable: ${file.role}`);
48
+ const signature = signing[file.role] ?? (executable ? { contract: target.signing.contract, status: "verified" } : null);
49
+ if (executable && (!signature || signature.status !== "verified")) throw new Error(`unsigned executable role: ${file.role}`);
50
+ return { role: file.role, name: file.name, sha256: sha256(source), sizeBytes: statSync(source).size, executable, signing: signature };
51
+ });
52
+ const manifest = { schema: 1, kind: "headless-addon", addon: config.addon, version: config.version, commit, platform, targetTriple: target.targetTriple, consumer: config.consumer, files: manifestFiles, signedAt };
53
+ validateAddonManifest(manifest);
54
+ return manifest;
55
+ }
56
+
57
+ export function validateAddonManifest(manifest) {
58
+ const expected = ["schema", "kind", "addon", "version", "commit", "platform", "targetTriple", "consumer", "files", "signedAt"];
59
+ assertExactKeys(manifest, expected, "add-on manifest");
60
+ if (manifest.schema !== 1 || manifest.kind !== "headless-addon" || !SAFE_NAME.test(manifest.addon) || !SAFE_NAME.test(manifest.version)) throw new Error("invalid add-on manifest identity");
61
+ if (!PLATFORM.has(manifest.platform) || !/^[0-9a-f]{40}$/i.test(manifest.commit) || !Array.isArray(manifest.files)) throw new Error("invalid add-on manifest platform or files");
62
+ const roles = new Set(); const names = new Set();
63
+ for (const file of manifest.files) {
64
+ assertExactKeys(file, ["role", "name", "sha256", "sizeBytes", "executable", "signing"], "add-on file");
65
+ if (!ROLES.has(file.role) || roles.has(file.role) || !SAFE_NAME.test(file.name) || names.has(file.name)) throw new Error("duplicate or unsafe add-on manifest file");
66
+ if (!/^[0-9a-f]{64}$/i.test(file.sha256) || !Number.isSafeInteger(file.sizeBytes) || file.sizeBytes < 0 || typeof file.executable !== "boolean") throw new Error("invalid add-on file integrity");
67
+ if (file.executable && (!file.signing || file.signing.status !== "verified")) throw new Error(`unsigned executable role: ${file.role}`);
68
+ roles.add(file.role); names.add(file.name);
69
+ }
70
+ for (const role of ["command", "service", "icon", ...LEGAL_ROLES]) if (!roles.has(role)) throw new Error(`missing required add-on role: ${role}`);
71
+ return manifest;
72
+ }
73
+
74
+ export function canonicalAddonManifest(manifest) { validateAddonManifest(manifest); return `${JSON.stringify(manifest)}\n`; }
75
+ export function addonManifestSha256(manifest) { return createHash("sha256").update(canonicalAddonManifest(manifest)).digest("hex"); }
76
+ export function addonRoutes(manifest) {
77
+ const digest = addonManifestSha256(manifest);
78
+ const root = `${manifest.addon}/addons/${manifest.platform}/sha256/${digest}`;
79
+ return [...manifest.files.map((file) => ({ name: file.name, key: `${root}/${file.name}`, sha256: file.sha256, manifest: false })), { name: "addon-manifest.json", key: `${root}/addon-manifest.json`, sha256: digest, manifest: true }];
80
+ }
81
+
82
+ export async function downloadImmutableAddOn(manifestUrl, destination, { fetchImpl = fetch } = {}) {
83
+ if (!/^https:\/\//.test(manifestUrl) || !manifestUrl.endsWith("/addon-manifest.json") || /[?#]/.test(manifestUrl)) throw new Error("add-on lock manifestUrl must be an immutable HTTPS manifest URL");
84
+ const download = async (url, file) => {
85
+ const response = await fetchImpl(url, { cache: "no-store" });
86
+ if (!response.ok) throw new Error(`immutable add-on download failed: ${response.status} ${url}`);
87
+ writeFileSync(file, Buffer.from(await response.arrayBuffer()));
88
+ };
89
+ await download(manifestUrl, path.join(destination, "addon-manifest.json"));
90
+ const manifest = JSON.parse(readFileSync(path.join(destination, "addon-manifest.json"), "utf8"));
91
+ validateAddonManifest(manifest);
92
+ const base = manifestUrl.slice(0, -"addon-manifest.json".length);
93
+ for (const file of manifest.files) await download(`${base}${encodeURIComponent(file.name)}`, path.join(destination, file.name));
94
+ }
95
+
96
+ function validateBuildInputs(inputs) {
97
+ if (!Array.isArray(inputs?.include) || !inputs.include.length || inputs.include.some((entry) => typeof entry !== "string" || path.isAbsolute(entry) || entry.split(/[\\/]/).includes(".."))) throw new Error("add-on buildInputs.include must be safe and non-empty");
98
+ }
99
+ function sha256(file) { return createHash("sha256").update(readFileSync(file)).digest("hex"); }
100
+ function assertExactKeys(value, keys, label) { if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).sort().join("\0") !== [...keys].sort().join("\0")) throw new Error(`${label} has unsupported fields`); }
@@ -0,0 +1,48 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import test from "node:test";
6
+ import { addonManifestSha256, addonRoutes, createAddonManifest, validateAddonConfig, validateAddonManifest } from "./addon-contract.mjs";
7
+
8
+ function fixture() {
9
+ const root = mkdtempSync(path.join(os.tmpdir(), "right-addon-contract-"));
10
+ mkdirSync(path.join(root, "bin")); mkdirSync(path.join(root, "assets")); mkdirSync(path.join(root, "legal"));
11
+ for (const [file, bytes] of [["bin/membrane", "membrane"], ["bin/crypt-service", "service"], ["assets/tab.png", "icon"], ["legal/LICENSE", "license"], ["legal/EULA.txt", "eula"], ["legal/PRIVACY.md", "privacy"], ["legal/THIRD-PARTY-NOTICES.txt", "notice"]]) writeFileSync(path.join(root, file), bytes);
12
+ const files = [
13
+ ["command", "membrane", "bin/membrane", true], ["service", "crypt-service", "bin/crypt-service", true], ["icon", "membrane-tab-icon.png", "assets/tab.png", false],
14
+ ["license", "LICENSE", "legal/LICENSE", false], ["eula", "EULA.txt", "legal/EULA.txt", false], ["privacy", "PRIVACY.md", "legal/PRIVACY.md", false], ["third-party-notices", "THIRD-PARTY-NOTICES.txt", "legal/THIRD-PARTY-NOTICES.txt", false],
15
+ ].map(([role, name, source, executable]) => ({ role, name, source, executable }));
16
+ return { root, config: { schema: 1, kind: "headless-addon", addon: "membrane", version: "0.1.0", packageManager: "pnpm", checks: ["test"], buildInputs: { include: ["right-addon.config.mjs"] }, consumer: { contract: "orthic-product-v1" }, targets: { mac: { targetTriple: "aarch64-apple-darwin", build: { cmd: "cargo", args: ["build"] }, signing: { contract: "apple-developer-id-executable-v1", teamId: "6KLGD3LLKF" }, files }, win: { targetTriple: "x86_64-pc-windows-msvc", build: { cmd: "cargo", args: ["build"] }, signing: { contract: "azure-artifact-signing-v1" }, files } } } };
17
+ }
18
+
19
+ test("add-on manifest has portable immutable identity and routes", () => {
20
+ const { root, config } = fixture();
21
+ const signing = { command: { contract: "test", status: "verified" }, service: { contract: "test", status: "verified" } };
22
+ const manifest = createAddonManifest({ config, root, platform: "mac", commit: "a".repeat(40), signedAt: "2026-08-11T00:00:00.000Z", signing });
23
+ assert.deepEqual(Object.keys(manifest), ["schema", "kind", "addon", "version", "commit", "platform", "targetTriple", "consumer", "files", "signedAt"]);
24
+ const digest = addonManifestSha256(manifest); const routes = addonRoutes(manifest);
25
+ assert.equal(routes.at(-1).manifest, true);
26
+ assert.match(routes[0].key, new RegExp(`^membrane/addons/mac/sha256/${digest}/`));
27
+ assert.ok(routes.slice(0, -1).every((route) => !route.manifest));
28
+ });
29
+
30
+ test("add-on contract rejects unsafe, incomplete, duplicate, and unsigned inputs", () => {
31
+ const { root, config } = fixture();
32
+ const broken = structuredClone(config); broken.targets.mac.files[0].source = "../escape";
33
+ assert.throws(() => validateAddonConfig(broken, { root, platform: "mac" }), /unsafe add-on source/);
34
+ const duplicate = structuredClone(config); duplicate.targets.mac.files[1].role = "command";
35
+ assert.throws(() => validateAddonConfig(duplicate, { root, platform: "mac" }), /duplicate/);
36
+ const signing = { command: { contract: "test", status: "verified" }, service: { contract: "test", status: "verified" } };
37
+ const manifest = createAddonManifest({ config, root, platform: "mac", commit: "b".repeat(40), signing });
38
+ manifest.files.find((file) => file.role === "command").signing = null;
39
+ assert.throws(() => validateAddonManifest(manifest), /unsigned executable/);
40
+ });
41
+
42
+ test("manifest excludes source and mutable transport fields", () => {
43
+ const { root, config } = fixture();
44
+ const manifest = createAddonManifest({ config, root, platform: "win", commit: "c".repeat(40), signing: { command: { contract: "test", status: "verified" }, service: { contract: "test", status: "verified" } } });
45
+ const text = JSON.stringify(manifest);
46
+ assert.doesNotMatch(text, /source|path|url|token/i);
47
+ assert.equal(readFileSync(path.join(root, "bin", "membrane"), "utf8"), "membrane");
48
+ });
package/build-release.mjs CHANGED
@@ -29,6 +29,17 @@ import { buildPathPrefix, collectPreflight, formatPreflight, preflightFailures }
29
29
  import { assertCleanSource } from "./source-gate.mjs";
30
30
  import { acquireHeavyWorkSlot, heavyCommandEnvironment, terminateProcessTree } from "./heavy-command.mjs";
31
31
 
32
+ // Fingerprint of the pipeline code that can change the bytes we ship or the way
33
+ // they are signed — deliberately NOT the package version, which moves for docs
34
+ // and test-only edits and would force a cold Rust rebuild of every app each time.
35
+ // These four files are the ones whose behaviour the cached target directory can
36
+ // outlive.
37
+ const PIPELINE_FINGERPRINT_SOURCES = ["release.mjs", "sign-windows.mjs", "tauri-bundle-marker.mjs", "nsis-payload.mjs"];
38
+ const PIPELINE_FINGERPRINT = createHash("sha256")
39
+ .update(PIPELINE_FINGERPRINT_SOURCES.map((file) => `${file}:${createHash("sha256").update(readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), file))).digest("hex")}`).join("\n"))
40
+ .digest("hex")
41
+ .slice(0, 16);
42
+
32
43
  /** Assemble preflight inputs from the app's own files (mirrors release.mjs). */
33
44
  function buildPreflight({ config, configPath, appRoot, repoRoot, platform }) {
34
45
  let version;
@@ -88,6 +99,23 @@ let cacheLease;
88
99
  let heavySlot;
89
100
  let interrupted;
90
101
 
102
+ // A dry-run is an inspection contract, not a partial build. It must finish
103
+ // before locks, cache directories, receipts, dependency installation, or any
104
+ // child build/sign process can exist.
105
+ if (dryRun) {
106
+ const commit = git(invocationRoot, ["rev-parse", "HEAD"]);
107
+ const dirtyConfig = dirtyBuildInputs(repoRoot, { include: [relativeConfig], required: [relativeConfig] }, commit);
108
+ if (dirtyConfig.length > 0) fail(`dirty release config cannot be executed:\n${dirtyConfig.map((file) => `- ${file}`).join("\n")}`);
109
+ const config = (await import(`${pathToFileURL(configPath).href}?dry-run=${commit}`)).default;
110
+ if (!config?.app || !config?.version) fail("release config must expose app and version");
111
+ const target = config.targets?.[platform];
112
+ if (!target?.package) fail(`${config.app} has no ${platform} package command`);
113
+ console.log(`right-release build dry-run: ${config.app} ${config.version} ${platform}`);
114
+ console.log(`config: ${configPath}`);
115
+ console.log(`commit: ${commit}`);
116
+ process.exit(0);
117
+ }
118
+
91
119
  for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
92
120
  process.once(signal, () => {
93
121
  if (child?.pid) killTree(child.pid);
@@ -124,8 +152,13 @@ try {
124
152
  const receiptRoot = path.resolve(process.env.RIGHT_RELEASE_STATE_ROOT ?? path.join(appRoot, ".right-release", "receipts"));
125
153
  const signingIdentity = platform === "win" ? {
126
154
  contract: target.signingContract ?? "<missing>",
155
+ // Pipeline code participates in cache identity. Without it, a fix to the
156
+ // signing or packaging logic leaves the previous logic's target directory
157
+ // eligible for reuse — which is how outputs from a known-bad pipeline
158
+ // outlived the change that was supposed to retire them.
159
+ pipeline: PIPELINE_FINGERPRINT,
127
160
  configSha256: hashFile(configPath),
128
- receiptInputs: ["raw-exe", "installer"].map((phase) => path.join(receiptRoot, `windows-${phase}.json`)),
161
+ receiptInputs: ["raw-exe", "installer", "embedding"].map((phase) => path.join(receiptRoot, `windows-${phase}.json`)),
129
162
  } : null;
130
163
  if (signingIdentity) inputHashes[".right-release/signing-identity.json"] = hashFileText(JSON.stringify(signingIdentity));
131
164
  const cargoLock = requiredInputs.find((file) => /Cargo\.lock$/i.test(file));
@@ -1,10 +1,24 @@
1
1
  import assert from "node:assert/strict";
2
- import { readFileSync } from "node:fs";
2
+ import { mkdtempSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
3
+ import os from "node:os";
3
4
  import path from "node:path";
5
+ import { execFileSync, spawnSync } from "node:child_process";
4
6
  import test from "node:test";
5
7
  import { fileURLToPath } from "node:url";
6
8
 
7
9
  const source = readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "build-release.mjs"), "utf8");
10
+ const build = path.join(path.dirname(fileURLToPath(import.meta.url)), "build-release.mjs");
11
+
12
+ test("general build dry-run is filesystem-pure and never starts its package command", () => {
13
+ const root = mkdtempSync(path.join(os.tmpdir(), "right-build-dry-run-"));
14
+ writeFileSync(path.join(root, "right-release.config.mjs"), "export default { app: 'fixture', version: '1.0.0', targets: { mac: { package: { cmd: 'false' } } } };\n");
15
+ for (const args of [["init", "--initial-branch", "main"], ["config", "user.email", "test@example.com"], ["config", "user.name", "Test"], ["add", "."], ["commit", "-m", "init"]]) execFileSync("git", args, { cwd: root });
16
+ const before = readdirSync(root).sort();
17
+ const result = spawnSync(process.execPath, [build, "--config", "right-release.config.mjs", "--platform", "mac", "--dry-run"], { cwd: root, encoding: "utf8" });
18
+ assert.equal(result.status, 0, result.stderr);
19
+ assert.match(result.stdout, /build dry-run/);
20
+ assert.deepEqual(readdirSync(root).sort(), before);
21
+ });
8
22
 
9
23
  test("macOS and Windows builds default to Cache V2 while explicit legacy mode remains available", () => {
10
24
  assert.match(source, /assertCleanSource/);
@@ -85,6 +85,15 @@ function readCargoManifestDependencies(manifestPath, cargoHome, label) {
85
85
  windowsHide: true,
86
86
  },
87
87
  );
88
+ if (result.error) {
89
+ // spawnSync failed to launch the process at all (no exit code, no stderr) -
90
+ // a missing/unspawnable binary, not cargo rejecting the manifest. Reporting
91
+ // this as a version-contract violation sent a real diagnosis (a Windows
92
+ // rustup-proxy binary that can't be spawned from a non-console session)
93
+ // down the wrong path: it read as "fix your Cargo.toml" instead of "cargo
94
+ // could not be launched here".
95
+ throw new Error(`${label} could not launch ${cargoExecutable()} to read Cargo metadata: ${result.error.message}`);
96
+ }
88
97
  if (result.status !== 0) {
89
98
  throw new Error(`${label} Cargo metadata rejected manifest; RightKit dependencies must use exact crates.io versions: ${String(result.stderr ?? "").trim()}`);
90
99
  }
@@ -61,6 +61,8 @@ if (first === "--version" || first === "-v") {
61
61
  process.exit(2);
62
62
  }
63
63
  run("model-promote.mjs", rest.slice(1));
64
+ } else if (first === "addon") {
65
+ run("addon-command.mjs", args.slice(1));
64
66
  } else if (first === "lsclean") {
65
67
  runBinary("bash", [path.join(packageRoot, "lsclean.sh"), ...args.slice(1)]);
66
68
  } else if (first === "generate-dmg-background") {
@@ -133,6 +135,7 @@ Commands:
133
135
  publish swift [--scope <s>] [--url <u>] [--dry-run] Test, archive, scan, and publish RightKitSwift to a Swift registry
134
136
  model promote --authority heardright --config <file> [--dry-run]
135
137
  Sign and promote one runtime/model artifact
138
+ addon doctor|build|upload|adopt <options> Manage a signed headless add-on
136
139
  doctor [--platform mac|win] Inspect one app's release config
137
140
  doctor --all Verify all Right Suite app release contracts
138
141
  suite-doctor Verify all local Right Suite repositories