@rightkit/release 0.2.131 → 0.2.133

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,120 @@
1
+ import { existsSync, lstatSync, readFileSync, readdirSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { execFileSync } from "./background-process.mjs";
4
+ import { resolveManagedRoot } from "./artifact-storage.mjs";
5
+
6
+ const COPY_COMMAND = /\b(?:cp|copy|copy-item|rsync)\b/i;
7
+ const MIRROR_COMMAND = /\bmirror-root-artifact\b/i;
8
+ const INSTALLER_SOURCE = /(?:\$\{?[A-Z_]*(?:INSTALLER|SETUP|DMG|PKG|MSI|EXE|APP_ZIP|PACKAGE)[A-Z0-9_]*\}?|\b[^\s"']+\.(?:dmg|pkg|msi|exe|zip)\b)/i;
9
+ const REPO_DESTINATION = /(?:--package-root\s+|(?:^|\s))(?:["']?(?:\$\{?(?:REPO_ROOT|ROOT|REPOSITORY_ROOT)\}?)(?:\/[^"'\s]*)?|\.\.?\/?)["']?(?:\s|$)/i;
10
+ const MANAGED_DESTINATION = /(?:RIGHT_RELEASE_ARTIFACT_DIR|RIGHTKIT_MANAGED_ROOT|\.rightkit-managed|managedRoot|artifact(?:s)?\/)/i;
11
+ export const DEFAULT_STORAGE_THRESHOLD_BYTES = 5_000_000_000;
12
+
13
+ /** Report-only check for product scripts that copy installers into a checkout. */
14
+ export function findInstallerCopyScripts({ appRoot, workspaceRoot = "/Volumes/D/claude", knownPaths = ["scraperight/package.sh"], managedRoot = resolveManagedRoot() } = {}) {
15
+ const findings = [];
16
+ const seen = new Set();
17
+ const add = (file, lineNumber, source, reason) => {
18
+ const resolved = path.resolve(file);
19
+ const key = `${resolved}:${lineNumber}`;
20
+ if (seen.has(key)) return;
21
+ seen.add(key);
22
+ findings.push({ path: resolved, line: lineNumber, source: source.trim(), destination: destinationFor(source), reason });
23
+ };
24
+ const inspect = (file, label) => {
25
+ let text;
26
+ try { text = readFileSync(file, "utf8"); } catch { return; }
27
+ const lines = text.split(/\r?\n/);
28
+ for (let index = 0; index < lines.length; index += 1) {
29
+ const source = lines[index].trim();
30
+ if (!source || source.startsWith("#")) continue;
31
+ if (isManagedDestination(source, managedRoot)) continue;
32
+ if (isRepoInstallerCopy(source)) add(file, index + 1, source, `${label} copies installer into repository output path`);
33
+ }
34
+ };
35
+ const packageFile = path.join(path.resolve(appRoot), "package.json");
36
+ if (existsSync(packageFile)) {
37
+ try {
38
+ const packageJson = JSON.parse(readFileSync(packageFile, "utf8"));
39
+ for (const [name, command] of Object.entries(packageJson.scripts ?? {})) {
40
+ if (typeof command !== "string") continue;
41
+ for (const [index, segment] of command.split(/\r?\n|&&|;/).entries()) {
42
+ if (!isManagedDestination(segment, managedRoot) && isRepoInstallerCopy(segment)) add(packageFile, index + 1, segment, `script ${name} copies installer into repository output path`);
43
+ }
44
+ }
45
+ } catch { /* malformed package.json is reported by existing doctor checks */ }
46
+ }
47
+ const root = path.resolve(appRoot);
48
+ for (const entry of safeEntries(root)) {
49
+ if (!entry.isFile() || !/\.(?:sh|bash|cmd|bat|ps1)$/i.test(entry.name)) continue;
50
+ inspect(path.join(root, entry.name), "script");
51
+ }
52
+ for (const relative of knownPaths) {
53
+ const file = path.join(path.resolve(workspaceRoot), relative);
54
+ const resolved = path.resolve(file);
55
+ if (existsSync(resolved) && (resolved === root || resolved.startsWith(`${root}${path.sep}`))) inspect(resolved, "known script");
56
+ }
57
+ return findings;
58
+ }
59
+
60
+ function isRepoInstallerCopy(line) {
61
+ if (MIRROR_COMMAND.test(line)) return /--package-root\s+["']?(?:\$\{?(?:REPO_ROOT|ROOT|REPOSITORY_ROOT)\}?|\.\.?)/i.test(line);
62
+ return COPY_COMMAND.test(line) && INSTALLER_SOURCE.test(line) && REPO_DESTINATION.test(line);
63
+ }
64
+
65
+ function isManagedDestination(line, managedRoot) {
66
+ return MANAGED_DESTINATION.test(line) || (managedRoot && line.includes(path.resolve(managedRoot)));
67
+ }
68
+
69
+ function destinationFor(line) {
70
+ const match = line.match(/(?:--package-root\s+|\s)(["']?(?:\$\{?[A-Z_][A-Z0-9_]*\}?|\.\.?)(?:\/[^"'\s]*)?\/?["']?)\s*$/i);
71
+ return match?.[1] ?? null;
72
+ }
73
+
74
+ /** Find repository subdirectories above storage threshold outside managed root. */
75
+ export function findLargeRepositorySubdirectories({ appRoot, managedRoot = resolveManagedRoot(), thresholdBytes = DEFAULT_STORAGE_THRESHOLD_BYTES, ignored = gitIgnored } = {}) {
76
+ if (!Number.isFinite(thresholdBytes) || thresholdBytes < 1) throw new TypeError("thresholdBytes must be positive");
77
+ const root = path.resolve(appRoot);
78
+ const ignoredRoot = managedRoot ? path.resolve(managedRoot) : null;
79
+ const findings = [];
80
+ const inside = (candidate, parent) => candidate === parent || candidate.startsWith(`${parent}${path.sep}`);
81
+ const managedIgnored = ignoredRoot && inside(ignoredRoot, root) ? Boolean(ignored(ignoredRoot, root)) : false;
82
+ const walk = (directory) => {
83
+ let total = 0;
84
+ let entries;
85
+ try { entries = readdirSync(directory, { withFileTypes: true }); } catch { return 0; }
86
+ for (const entry of entries) {
87
+ if (entry.name === ".git") continue;
88
+ const file = path.join(directory, entry.name);
89
+ let stat;
90
+ try { stat = lstatSync(file); } catch { continue; }
91
+ if (stat.isSymbolicLink()) continue;
92
+ if (stat.isDirectory()) {
93
+ if (managedIgnored && inside(file, ignoredRoot)) continue;
94
+ total += walk(file);
95
+ } else if (stat.isFile()) total += stat.size;
96
+ }
97
+ if (total > thresholdBytes) findings.push({ path: directory, bytes: total, reason: "repository-directory-exceeds-5GB-outside-ignored-managed-path" });
98
+ return total;
99
+ };
100
+ for (const entry of safeEntries(root)) {
101
+ if (entry.name === ".git" || !entry.isDirectory() || entry.isSymbolicLink()) continue;
102
+ const directory = path.join(root, entry.name);
103
+ if (managedIgnored && inside(directory, ignoredRoot)) continue;
104
+ walk(directory);
105
+ }
106
+ return findings;
107
+ }
108
+
109
+ function gitIgnored(candidate, root) {
110
+ try {
111
+ execFileSync("git", ["-C", root, "check-ignore", "--no-index", "-q", "--", path.relative(root, candidate)], { stdio: "ignore" });
112
+ return true;
113
+ } catch {
114
+ return false;
115
+ }
116
+ }
117
+
118
+ function safeEntries(directory) {
119
+ try { return readdirSync(directory, { withFileTypes: true }); } catch { return []; }
120
+ }
@@ -0,0 +1,72 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { spawnSync } from "./background-process.mjs";
5
+ import { MACOS_PORTABLE_SIGNING_CONTRACT } from "./macos-signing-contract.mjs";
6
+
7
+ function checkedRun(args, run) {
8
+ const result = run("xcrun", args, { encoding: "utf8" });
9
+ if (result.error || result.status !== 0) throw new Error(`xcrun ${args.slice(0, 2).join(" ")} failed: ${result.error?.message ?? result.stderr ?? result.stdout ?? result.status}`);
10
+ return result.stdout ?? "";
11
+ }
12
+
13
+ export function stapleMacosArtifact(file, { run = spawnSync } = {}) {
14
+ file = path.resolve(file);
15
+ if (!/\.(dmg|pkg|app)$/i.test(file)) throw new Error(`not a stapleable macOS artifact: ${file}`);
16
+ const probe = run("xcrun", ["stapler", "validate", file], { encoding: "utf8" });
17
+ if (probe.error) throw new Error(`cannot validate macOS staple: ${probe.error.message}`);
18
+ const alreadyStapled = probe.status === 0;
19
+ if (!alreadyStapled) checkedRun(["stapler", "staple", file], run);
20
+ checkedRun(["stapler", "validate", file], run);
21
+ return { file, alreadyStapled, validated: true };
22
+ }
23
+
24
+ function fileEvidence(file) {
25
+ return { sha256: createHash("sha256").update(readFileSync(file)).digest("hex"), sizeBytes: statSync(file).size };
26
+ }
27
+
28
+ // DMG/PKG receive their own ticket. For a separately submitted app ZIP,
29
+ // target.notarize.app (CLI --app) identifies the bundle to staple before packaging.
30
+ export function notarizeMacosArtifact({ archive, receiptPath, authArgs, app, run = spawnSync }) {
31
+ archive = path.resolve(archive);
32
+ const stapleTargets = /\.(dmg|pkg)$/i.test(archive) ? [archive] : [];
33
+ if (app) {
34
+ const bundle = path.resolve(app);
35
+ if (!/\.app$/i.test(bundle)) throw new Error(`notarize.app must name an app bundle: ${bundle}`);
36
+ stapleTargets.push(bundle);
37
+ }
38
+ if (!stapleTargets.length) throw new Error("notarization archives require notarize.app (or --app) for stapling their app bundle");
39
+ let result;
40
+ try { result = JSON.parse(checkedRun(["notarytool", "submit", archive, "--wait", "--output-format", "json", ...authArgs], run)); }
41
+ catch (error) { throw new Error(`macOS notarization failed for ${archive}: ${error.message}`); }
42
+ if (result.status !== "Accepted") throw new Error(`notarization was not accepted for ${archive}: ${result.status ?? "missing status"}`);
43
+ const stapling = [...new Set(stapleTargets)].map((file) => stapleMacosArtifact(file, { run }));
44
+ // Stapling changes container bytes. Record final bytes, never the submission digest.
45
+ const receipt = {
46
+ schema: 1,
47
+ signingContract: MACOS_PORTABLE_SIGNING_CONTRACT,
48
+ archive: { file: archive, ...fileEvidence(archive) },
49
+ notarization: { status: result.status, id: result.id ?? result.submissionId ?? null },
50
+ stapling,
51
+ };
52
+ mkdirSync(path.dirname(path.resolve(receiptPath)), { recursive: true });
53
+ writeFileSync(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`);
54
+ return receipt;
55
+ }
56
+
57
+ export function validateNotarizationReceipt({ receiptPath, archive, run = spawnSync }) {
58
+ let receipt;
59
+ try { receipt = JSON.parse(readFileSync(receiptPath, "utf8")); }
60
+ catch { throw new Error(`missing or invalid macOS notarization receipt: ${receiptPath}`); }
61
+ archive = path.resolve(archive);
62
+ if (receipt.schema !== 1 || receipt.signingContract !== MACOS_PORTABLE_SIGNING_CONTRACT
63
+ || path.resolve(receipt.archive?.file ?? "") !== archive || receipt.archive.sha256 !== fileEvidence(archive).sha256
64
+ || receipt.notarization?.status !== "Accepted") throw new Error(`invalid macOS notarization receipt evidence: ${receiptPath}`);
65
+ const stapling = receipt.stapling;
66
+ if (!Array.isArray(stapling) || !stapling.length || stapling.some((item) => item.validated !== true || !/\.(dmg|pkg|app)$/i.test(item.file ?? ""))
67
+ || (/\.(dmg|pkg|app)$/i.test(archive) && !stapling.some((item) => path.resolve(item.file) === archive))) {
68
+ throw new Error(`missing macOS stapling evidence: ${receiptPath}; rerun notarization before sealing`);
69
+ }
70
+ for (const item of stapling) checkedRun(["stapler", "validate", path.resolve(item.file)], run);
71
+ return receipt;
72
+ }
@@ -0,0 +1,150 @@
1
+ import * as fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { spawnSync } from "./background-process.mjs";
5
+
6
+ import { requestSigningAgentSync } from "./signing-agent-client.mjs";
7
+ import { installSigningAgent, requireMacGuiSession } from "./signing-agent-install.mjs";
8
+ import { signingAgentPaths, defaultSigningRoots, ensurePrivateDirectory, privatePath, within } from "./rightkit-signing-agent.mjs";
9
+
10
+ export class MacSigningSessionError extends Error {
11
+ constructor(code, message) {
12
+ super(message);
13
+ this.name = "MacSigningSessionError";
14
+ this.code = code;
15
+ }
16
+ }
17
+
18
+ const capture = { encoding: "utf8", stdio: "pipe", timeout: 5000, shell: false };
19
+ const interactionDenied = (r) => r.status === 36 || /errSecInteractionNotAllowed|User interaction is not allowed/i.test(`${r.stderr ?? ""}`);
20
+
21
+ // Metadata access alone does not prove private-key access. SSH always needs the
22
+ // GUI route; launchctl identifies other non-Aqua bootstrap/security sessions.
23
+ export function detectMacSigningSession({ env = process.env, run = spawnSync } = {}) {
24
+ if (env.SSH_CONNECTION || env.SSH_TTY || env.SSH_CLIENT) return { guiRunner: true, reason: "SSH security session" };
25
+ const manager = run("/bin/launchctl", ["managername"], capture);
26
+ if (manager.error || manager.status !== 0 || !String(manager.stdout ?? "").trim()) {
27
+ throw new MacSigningSessionError("MAC_SIGNING_SESSION_UNKNOWN", "Cannot determine launchctl security session before macOS signing");
28
+ }
29
+ const name = String(manager.stdout).trim();
30
+ if (name !== "Aqua") return { guiRunner: true, reason: `launchctl manager ${name}` };
31
+ const probe = run("/usr/bin/security", ["show-keychain-info", path.join(os.userInfo().homedir, "Library/Keychains/login.keychain-db")], capture);
32
+ if (interactionDenied(probe)) return { guiRunner: true, reason: "login keychain interaction denied (exit 36)" };
33
+ if (probe.error || probe.status !== 0) throw new MacSigningSessionError("MAC_SIGNING_KEYCHAIN_PROBE_FAILED", `Login keychain metadata probe failed (exit ${probe.status ?? "unavailable"})`);
34
+ return { guiRunner: false, reason: "Aqua security session" };
35
+ }
36
+
37
+ // Parse legacy callers' signing argv into a closed, typed protocol. The agent
38
+ // reconstructs argv itself & never accepts executable names or environment.
39
+ export function codesignAgentItems(commands) {
40
+ if (!Array.isArray(commands) || !commands.length) throw new TypeError("Codesign commands required");
41
+ return commands.map((args) => {
42
+ if (!Array.isArray(args) || args.some((arg) => typeof arg !== "string")) throw new TypeError("Invalid codesign command");
43
+ const item = {};
44
+ for (let i = 0; i < args.length - 1; i++) {
45
+ const option = args[i];
46
+ if (option === "--force" && item.force === undefined) item.force = true;
47
+ else if (option === "--timestamp" && item.timestamp === undefined) item.timestamp = true;
48
+ else if (option === "--options" && args[i + 1] === "runtime" && item.runtime === undefined) { item.runtime = true; i++; }
49
+ else if (option === "--sign" && item.identity === undefined) item.identity = args[++i];
50
+ else if (option === "--entitlements" && item.entitlements === undefined) item.entitlements = args[++i];
51
+ else if (option === "--preserve-metadata=entitlements" && item.preserveEntitlements === undefined) item.preserveEntitlements = true;
52
+ else throw new TypeError(`Unsupported agent codesign option: ${option}`);
53
+ }
54
+ item.path = args.at(-1);
55
+ if (!item.identity || item.identity === "-" || !path.isAbsolute(item.path ?? "")
56
+ || (item.entitlements !== undefined && !path.isAbsolute(item.entitlements))) throw new TypeError("Agent signing requires real identity & absolute paths");
57
+ return item;
58
+ });
59
+ }
60
+ export function runMacGuiSigning({ kind, commands, files, cwd = process.cwd(), env = process.env,
61
+ run = spawnSync, io = fs, timeoutMs = 180000, home = os.userInfo().homedir,
62
+ uid = process.getuid?.(), username = os.userInfo().username, allowedRoots,
63
+ request = requestSigningAgentSync, install = installSigningAgent } = {}) {
64
+ if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 180000) throw new TypeError("Signing timeout must be 1..180000ms");
65
+ if (!["codesign", "updater"].includes(kind)) throw new TypeError("Only typed codesign & updater requests supported");
66
+ const paths = signingAgentPaths(home);
67
+ let roots = allowedRoots ?? defaultSigningRoots(home, env.RIGHTKIT_MANAGED_ROOT);
68
+ if (!allowedRoots && io.existsSync(paths.config)) {
69
+ privatePath(paths.config, 0o600, { io, uid });
70
+ roots = JSON.parse(io.readFileSync(paths.config, "utf8")).roots;
71
+ }
72
+ roots = roots.filter((root) => io.existsSync(root));
73
+ const plan = { schema: 1, type: kind, allowedRoots: roots, cwd: path.resolve(cwd), timeoutMs };
74
+ let staging;
75
+ try {
76
+ if (kind === "codesign") {
77
+ plan.items = codesignAgentItems(commands);
78
+ // Development signing generates entitlements in TMPDIR. Copy only those
79
+ // plist bytes into managed private staging, never broaden signing roots.
80
+ for (const item of plan.items) {
81
+ if (!item.entitlements || roots.some((root) => within(root, item.entitlements) && within(io.realpathSync(root), io.realpathSync(item.entitlements)))) continue;
82
+ if (!staging) {
83
+ ensurePrivateDirectory(paths.directory, { io, uid });
84
+ ensurePrivateDirectory(path.join(paths.directory, "requests"), { io, uid });
85
+ staging = io.mkdtempSync(path.join(paths.directory, "requests/request-"));
86
+ io.chmodSync(staging, 0o700);
87
+ plan.allowedRoots = [...roots, staging];
88
+ }
89
+ const target = path.join(staging, `${plan.items.indexOf(item)}.plist`);
90
+ io.writeFileSync(target, io.readFileSync(item.entitlements), { mode: 0o600, flag: "wx" });
91
+ item.entitlements = target;
92
+ }
93
+ } else {
94
+ if (!Array.isArray(files) || !files.length || files.some((file) => typeof file !== "string" || !path.isAbsolute(file))) throw new TypeError("Updater artifacts must be absolute paths");
95
+ plan.files = files;
96
+ }
97
+ const clientOptions = { home, timeoutMs: timeoutMs + 5000 };
98
+ let report;
99
+ try { report = request(plan, clientOptions); }
100
+ catch (error) {
101
+ if (error.code !== "MAC_SIGNING_AGENT_UNAVAILABLE") throw error;
102
+ requireMacGuiSession({ run, uid });
103
+ install({ home, username, uid, run, io, managedRoot: env.RIGHTKIT_MANAGED_ROOT });
104
+ report = request(plan, clientOptions); // One install attempt, one retry.
105
+ }
106
+ if (report.schema !== 1 || report.ok !== true || !Array.isArray(report.results)
107
+ || report.results.length !== (plan.items?.length ?? plan.files.length)
108
+ || report.results.some((r) => r.status !== 0 || typeof r.stdout !== "string" || typeof r.stderr !== "string")
109
+ || (kind === "updater" && report.results.some((r) => r.stdout || r.stderr))) {
110
+ throw new MacSigningSessionError("MAC_SIGNING_AGENT_PROTOCOL", "Signing agent returned invalid results");
111
+ }
112
+ return report.results;
113
+ } finally { if (staging) io.rmSync(staging, { recursive: true, force: true }); }
114
+ }
115
+
116
+ export function runMacCodesignCommands(commands, options = {}) {
117
+ const { cwd = process.cwd(), env = process.env, run = spawnSync } = options;
118
+ const session = detectMacSigningSession({ env, run });
119
+ if (session.guiRunner) return runMacGuiSigning({ ...options, kind: "codesign", commands });
120
+ const results = [];
121
+ for (const args of commands) {
122
+ const r = run("codesign", args, { cwd, env, encoding: "utf8", stdio: ["inherit", "pipe", "pipe"], shell: false });
123
+ results.push(r);
124
+ if (r.error || r.status !== 0) break;
125
+ }
126
+ return results;
127
+ }
128
+
129
+ export function signMacUpdaterFiles(files, options = {}) {
130
+ const { cwd = process.cwd(), env = process.env, run = spawnSync, username = os.userInfo().username } = options;
131
+ const commands = files.map((file) => ["exec", "tauri", "signer", "sign", file]);
132
+ const signingEnv = { ...env, TAURI_SIGNING_PRIVATE_KEY_PASSWORD: env.TAURI_SIGNING_PRIVATE_KEY_PASSWORD ?? "" };
133
+ if (env.SSH_CONNECTION || env.SSH_TTY || env.SSH_CLIENT || (!signingEnv.TAURI_SIGNING_PRIVATE_KEY && detectMacSigningSession({ env, run }).guiRunner)) {
134
+ return runMacGuiSigning({ ...options, kind: "updater", files });
135
+ }
136
+ if (!signingEnv.TAURI_SIGNING_PRIVATE_KEY) {
137
+ const key = run("security", ["find-generic-password", "-a", username, "-s", "rightsuite-updater-key", "-w"], capture);
138
+ if (key.error || key.status !== 0 || !String(key.stdout ?? "").trim()) {
139
+ throw new MacSigningSessionError("MAC_SIGNING_UPDATER_KEY_UNAVAILABLE", "Shared updater key unavailable in Keychain service rightsuite-updater-key");
140
+ }
141
+ signingEnv.TAURI_SIGNING_PRIVATE_KEY = String(key.stdout).trim();
142
+ }
143
+ const results = [];
144
+ for (const args of commands) {
145
+ const r = run("pnpm", args, { cwd, env: signingEnv, encoding: "utf8", stdio: "pipe", shell: false });
146
+ if (r.error || r.status !== 0) throw new MacSigningSessionError("MAC_SIGNING_UPDATER_FAILED", `Updater signing failed (exit ${r.status ?? "unavailable"})`);
147
+ results.push({ status: 0, stdout: "", stderr: "" });
148
+ }
149
+ return results;
150
+ }
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { copyFileSync, mkdirSync, realpathSync, renameSync, rmSync } from "node:fs";
2
+ import { copyFileSync, lstatSync, mkdirSync, realpathSync, renameSync, rmSync, statSync } from "node:fs";
3
3
  import { execFileSync } from "./background-process.mjs";
4
4
  import path from "node:path";
5
5
 
@@ -8,43 +8,78 @@ if (!args.file || !args.packageRoot) {
8
8
  console.error("Usage: right-release mirror-root-artifact --file <artifact> --package-root <dir> [--name <filename>]");
9
9
  process.exit(2);
10
10
  }
11
+ if (!process.env.RIGHT_RELEASE_ARTIFACT_DIR) {
12
+ console.error("right-release mirror-root-artifact: RIGHT_RELEASE_ARTIFACT_DIR is required; refusing repository-root output");
13
+ process.exit(2);
14
+ }
11
15
 
12
16
  const packageRoot = realpathSync(path.resolve(args.packageRoot));
13
17
  const source = path.resolve(packageRoot, args.file);
14
18
  const name = args.name || path.basename(source);
15
- const repoRoot = git(packageRoot, "rev-parse", "--show-toplevel");
16
- const commonDir = git(packageRoot, "rev-parse", "--path-format=absolute", "--git-common-dir");
17
- const canonicalRepoRoot = path.basename(commonDir) === ".git" ? path.dirname(commonDir) : repoRoot;
18
- const relativePackageRoot = path.relative(repoRoot, packageRoot);
19
- const destinations = new Set([
20
- path.join(packageRoot, name),
21
- path.join(canonicalRepoRoot, relativePackageRoot, name),
22
- ]);
19
+ const managedOutput = path.resolve(process.env.RIGHT_RELEASE_ARTIFACT_DIR);
20
+ const repoRoot = path.resolve(git(packageRoot, "rev-parse", "--show-toplevel"));
21
+ if (inside(managedOutput, repoRoot)) {
22
+ console.error(`right-release mirror-root-artifact: managed output must be outside repository: ${managedOutput}`);
23
+ process.exit(2);
24
+ }
25
+ if (name !== path.basename(name) || !/^[A-Za-z0-9][A-Za-z0-9._+-]{0,159}$/.test(name)) {
26
+ console.error(`right-release mirror-root-artifact: unsafe artifact name: ${name}`);
27
+ process.exit(2);
28
+ }
29
+ assertSafePath(source);
30
+ if (!statSync(source).isFile()) {
31
+ console.error(`right-release mirror-root-artifact: source is not a regular file: ${source}`);
32
+ process.exit(2);
33
+ }
34
+ const destinations = new Set([path.join(managedOutput, name)]);
23
35
 
24
36
  for (const destination of destinations) {
25
37
  if (path.resolve(destination) === source) continue;
26
38
  atomicCopy(source, destination);
27
39
  }
28
40
 
29
- const canonicalArtifact = path.join(canonicalRepoRoot, relativePackageRoot, name);
30
- console.log(`right-release: repository-root artifact -> ${canonicalArtifact}`);
41
+ console.log(`right-release: managed artifact -> ${[...destinations][0]}`);
31
42
 
32
43
  function git(cwd, ...commandArgs) {
33
44
  return execFileSync("git", commandArgs, { cwd, encoding: "utf8" }).trim();
34
45
  }
35
46
 
36
47
  function atomicCopy(from, to) {
48
+ assertSafePath(to);
37
49
  mkdirSync(path.dirname(to), { recursive: true });
50
+ assertSafePath(to);
38
51
  const temporary = `${to}.tmp-${process.pid}`;
52
+ assertSafePath(temporary);
39
53
  rmSync(temporary, { force: true });
40
54
  try {
41
55
  copyFileSync(from, temporary);
56
+ assertSafePath(temporary);
57
+ assertSafePath(to);
42
58
  renameSync(temporary, to);
43
59
  } finally {
60
+ assertSafePath(temporary);
44
61
  rmSync(temporary, { force: true });
45
62
  }
46
63
  }
47
64
 
65
+ function assertSafePath(target) {
66
+ const absolute = path.resolve(target);
67
+ const root = path.parse(absolute).root;
68
+ let cursor = root;
69
+ for (const segment of path.relative(root, absolute).split(path.sep).filter(Boolean)) {
70
+ cursor = path.join(cursor, segment);
71
+ try {
72
+ if (lstatSync(cursor).isSymbolicLink()) throw new Error(`refusing filesystem mutation through symlink: ${cursor}`);
73
+ } catch (error) {
74
+ if (error.code !== "ENOENT") throw error;
75
+ }
76
+ }
77
+ }
78
+
79
+ function inside(candidate, parent) {
80
+ return candidate === parent || candidate.startsWith(`${parent}${path.sep}`);
81
+ }
82
+
48
83
  function parseArgs(values) {
49
84
  const parsed = {};
50
85
  for (let i = 0; i < values.length; i += 1) {
package/model-promote.mjs CHANGED
@@ -8,6 +8,7 @@ import { spawn, spawnSync } from "./background-process.mjs";
8
8
  import { fileURLToPath, pathToFileURL } from "node:url";
9
9
 
10
10
  import {
11
+ RIGHT_SUITE_APP_KEYS,
11
12
  buildRuntimeArtifactManifest,
12
13
  signRuntimeArtifactManifest,
13
14
  } from "./runtime-artifact-manifest.mjs";
@@ -19,15 +20,34 @@ export const RUNTIME_ARTIFACT_TRUSTED_KEY_ID = "rightkit-runtime-artifacts-2026-
19
20
  const TOOL_ROOT = path.dirname(fileURLToPath(import.meta.url));
20
21
  const UPLOAD = path.join(TOOL_ROOT, "upload-large.mjs");
21
22
 
23
+ export class RuntimeArtifactPromotionError extends Error {
24
+ constructor(code, message) {
25
+ super(message);
26
+ this.name = "RuntimeArtifactPromotionError";
27
+ this.code = code;
28
+ }
29
+ }
30
+
31
+ function assertPromotionAuthority(authority, appKey) {
32
+ for (const [field, key] of [["authority", authority], ["entitlement.appKey", appKey]]) {
33
+ if (!RIGHT_SUITE_APP_KEYS.includes(key)) {
34
+ throw new RuntimeArtifactPromotionError("RUNTIME_ARTIFACT_UNKNOWN_APP", `${field} is not a registered Right Suite app: ${key}`);
35
+ }
36
+ }
37
+ if (authority !== appKey) {
38
+ throw new RuntimeArtifactPromotionError("RUNTIME_ARTIFACT_AUTHORITY_MISMATCH", "model promotion authority must equal manifest.entitlement.appKey");
39
+ }
40
+ }
41
+
22
42
  export async function promoteRuntimeArtifact(input, deps = {}) {
23
43
  const metadata = deps.metadata ?? fileMetadata;
24
44
  const upload = deps.upload;
25
45
  const register = deps.register;
26
46
  const { config } = input;
27
47
  assertPromotionConfig(config);
48
+ assertPromotionAuthority(input.authority, config.manifest.entitlement?.appKey);
28
49
  if (!input.verification) throw new Error("release source identity is required");
29
50
  assertCleanSource({ status: input.verification.status, commandId: "right-release model promote" });
30
- if (input.authority !== "heardright") throw new Error("model promotion authority must be heardright");
31
51
  if (input.keyId !== RUNTIME_ARTIFACT_TRUSTED_KEY_ID) throw new Error(`untrusted runtime artifact key id: ${input.keyId}`);
32
52
  if (!input.privateKey) throw new Error("runtime artifact signing key is required");
33
53
 
@@ -122,6 +142,8 @@ async function main() {
122
142
  evidence: { sha256: raw.evidence.sha256 },
123
143
  manifest: raw.manifest,
124
144
  };
145
+ assertPromotionConfig(config);
146
+ assertPromotionAuthority(args.authority, config.manifest.entitlement?.appKey);
125
147
  const privateKey = await loadRuntimeArtifactSigningKey({ explicitFile: args.signingKeyFile });
126
148
  const releaseToken = args.dryRun ? null : await loadReleaseToken();
127
149
  const runtime = {
@@ -150,7 +172,10 @@ async function main() {
150
172
  }
151
173
 
152
174
  function printHelp() {
153
- console.log(`right-release model promote --authority heardright --config <promotion.json> [options]
175
+ console.log(`right-release model promote --authority <app-key> --config <promotion.json> [options]
176
+
177
+ Authority must equal manifest.entitlement.appKey. Registered Right Suite apps:
178
+ ${RIGHT_SUITE_APP_KEYS.join(", ")}.
154
179
 
155
180
  The JSON config has exactly:
156
181
  artifact: { file, filename, sha256, sizeBytes }
@@ -171,7 +196,9 @@ exact commit.
171
196
  Signing key inputs (never printed): RIGHTKIT_RUNTIME_ARTIFACT_PRIVATE_KEY or
172
197
  RIGHTKIT_RUNTIME_ARTIFACT_PRIVATE_KEY_FILE. Windows defaults to
173
198
  %APPDATA%/RightKit/runtime-artifact-signing-key.pem. Live promotion also uses
174
- the durable RIGHTAPPS_RELEASE_TOKEN boundary.`);
199
+ the durable RIGHTAPPS_RELEASE_TOKEN boundary. Run live promotion on Windows,
200
+ or set RIGHTKIT_RUNTIME_ARTIFACT_PRIVATE_KEY_FILE to the protected PEM.
201
+ See packages/release/MODEL-PROMOTION.md for config and evidence examples.`);
175
202
  }
176
203
 
177
204
  function parseArgs(args) {
@@ -190,7 +217,7 @@ function parseArgs(args) {
190
217
  else throw new Error(`unknown model promote argument: ${arg}`);
191
218
  }
192
219
  if (!config) throw new Error("model promote requires --config <path>");
193
- if (!authority) throw new Error("model promote requires --authority heardright");
220
+ if (!authority) throw new Error("model promote requires --authority <app-key>");
194
221
  return { authority, config, signingKeyFile, keyId, dryRun };
195
222
  }
196
223
 
@@ -276,7 +303,7 @@ function assertExactKeys(value, expected, label) {
276
303
 
277
304
  if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url) {
278
305
  main().catch((error) => {
279
- console.error(`right-release model promote: ${error.message}`);
306
+ console.error(`right-release model promote: ${error.code ? `[${error.code}] ` : ""}${error.message}`);
280
307
  process.exitCode = 1;
281
308
  });
282
309
  }
@@ -1,20 +1,21 @@
1
1
  #!/usr/bin/env node
2
- import { createHash } from "node:crypto";
3
- import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
+ import { existsSync } from "node:fs";
4
3
  import path from "node:path";
5
- import { spawnSync } from "./background-process.mjs";
6
4
  import { notarytoolAuthArgs } from "./notary-auth.mjs";
7
- import { MACOS_PORTABLE_SIGNING_CONTRACT } from "./macos-signing-contract.mjs";
5
+ import { notarizeMacosArtifact } from "./macos-notarization.mjs";
8
6
 
9
7
  const args = process.argv.slice(2);
10
8
  const dryRun = args.includes("--dry-run");
11
9
  const receiptIndex = args.indexOf("--receipt");
12
10
  const profileIndex = args.indexOf("--profile");
11
+ const appIndex = args.indexOf("--app");
12
+ const app = appIndex >= 0 ? args[appIndex + 1] : undefined;
13
13
  const receiptPath = receiptIndex >= 0 ? args[receiptIndex + 1] : null;
14
14
  const profile = profileIndex >= 0 ? args[profileIndex + 1] : undefined;
15
- const archiveArgs = args.filter((arg, index) => arg !== "--dry-run" && arg !== "--receipt" && arg !== "--profile" && (receiptIndex < 0 || index !== receiptIndex + 1) && (profileIndex < 0 || index !== profileIndex + 1));
16
- if (archiveArgs.length !== 1) fail("usage: node notarize-macos.mjs [--dry-run] --receipt <path> [--profile <name>] <archive>");
17
- if (!receiptPath) fail("--receipt requires a path");
15
+ const archiveArgs = args.filter((arg, index) => arg !== "--dry-run" && arg !== "--receipt" && arg !== "--profile" && arg !== "--app" && (appIndex < 0 || index !== appIndex + 1) && (receiptIndex < 0 || index !== receiptIndex + 1) && (profileIndex < 0 || index !== profileIndex + 1));
16
+ if (archiveArgs.length !== 1) fail("usage: node notarize-macos.mjs [--dry-run] --receipt <path> [--profile <name>] [--app <bundle>] <archive>");
17
+ if (!receiptPath || receiptPath.startsWith("--")) fail("--receipt requires a path");
18
+ if (appIndex >= 0 && (!app || app.startsWith("--"))) fail("--app requires an app bundle path");
18
19
  const archive = path.resolve(archiveArgs[0]);
19
20
  if (dryRun) {
20
21
  console.log(`dry-run: notarize ${archive}`);
@@ -25,31 +26,8 @@ if (!existsSync(archive)) fail(`missing archive: ${archive}`);
25
26
 
26
27
  let authArgs;
27
28
  try { authArgs = notarytoolAuthArgs({ profile }); } catch (error) { fail(error.message); }
28
- const output = run("xcrun", ["notarytool", "submit", archive, "--wait", "--output-format", "json", ...authArgs]);
29
- let result;
30
- try { result = JSON.parse(output); } catch { fail(`notarytool did not return JSON for ${archive}`); }
31
- if (result.status !== "Accepted") fail(`notarization was not accepted for ${archive}: ${result.status ?? "missing status"}`);
32
-
33
- const absoluteReceipt = path.resolve(receiptPath);
34
- mkdirSync(path.dirname(absoluteReceipt), { recursive: true });
35
- writeFileSync(absoluteReceipt, `${JSON.stringify({
36
- schema: 1,
37
- signingContract: MACOS_PORTABLE_SIGNING_CONTRACT,
38
- archive: { file: archive, ...fileEvidence(archive) },
39
- notarization: { status: result.status, id: result.id ?? result.submissionId ?? null },
40
- }, null, 2)}\n`);
41
-
42
- function fileEvidence(file) {
43
- const bytes = readFileSync(file);
44
- return { sha256: createHash("sha256").update(bytes).digest("hex"), sizeBytes: statSync(file).size };
45
- }
46
-
47
- function run(cmd, runArgs) {
48
- const result = spawnSync(cmd, runArgs, { encoding: "utf8" });
49
- const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim();
50
- if (result.status !== 0) fail(`${cmd} ${runArgs.join(" ")} failed: ${output}`);
51
- return output;
52
- }
29
+ try { notarizeMacosArtifact({ archive, receiptPath, authArgs, app }); }
30
+ catch (error) { fail(error.message); }
53
31
 
54
32
  function fail(message) {
55
33
  console.error(`right-notarize-macos: ${message}`);
@@ -237,7 +237,9 @@ function helperFor(run) {
237
237
  }
238
238
  function invokeHelper(run, helper, args) {
239
239
  const r = run(helper, args);
240
- if (r.status !== 0) throw new Error(String(r.stderr || "native ownership operation cancelled or failed").trim());
240
+ if (r.status !== 0 || String(r.stderr ?? "").includes("RightKit ownership keychain preflight:")) {
241
+ throw new Error(String(r.stderr || "native ownership operation cancelled or failed").trim());
242
+ }
241
243
  return String(r.stdout ?? "");
242
244
  }
243
245
  function helperFile(run, helper, args, outPath, what) {