@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.
- package/apple-profile-renew.mjs +220 -0
- package/artifact-storage.mjs +344 -0
- package/build-release.mjs +39 -18
- package/cli/right-release.mjs +9 -3
- package/create-mac-updater.mjs +8 -15
- package/development-signing.mjs +193 -0
- package/development.mjs +15 -13
- package/installer-storage-doctor.mjs +120 -0
- package/macos-notarization.mjs +72 -0
- package/macos-signing-session.mjs +150 -0
- package/mirror-root-artifact.mjs +46 -11
- package/model-promote.mjs +32 -5
- package/notarize-macos.mjs +10 -32
- package/ownership-key-command.mjs +3 -1
- package/ownership-report.mjs +15 -5
- package/package.json +1 -1
- package/publish-update.mjs +14 -6
- package/release.mjs +42 -18
- package/rightkit-signing-agent.mjs +253 -0
- package/rightkit-versions.json +35 -24
- package/runtime-artifact-manifest.mjs +5 -1
- package/sign-macos.mjs +32 -4
- package/sign-updater.mjs +8 -13
- package/signing-agent-client.mjs +85 -0
- package/signing-agent-command.mjs +21 -0
- package/signing-agent-install.mjs +100 -0
- package/signing-receipts.mjs +10 -0
- package/upload-release.mjs +4 -0
|
@@ -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
|
+
}
|
package/mirror-root-artifact.mjs
CHANGED
|
@@ -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
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
}
|
package/notarize-macos.mjs
CHANGED
|
@@ -1,20 +1,21 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
29
|
-
|
|
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
|
|
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) {
|