@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,220 @@
1
+ import { createHash, createPrivateKey, randomUUID, sign, X509Certificate } from "node:crypto";
2
+ import { constants, copyFileSync, existsSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+ import { spawnSync } from "./background-process.mjs";
6
+ import { notarytoolAuthArgs } from "./notary-auth.mjs";
7
+
8
+ const ASC = "https://api.appstoreconnect.apple.com";
9
+ export class AppleProfileRenewError extends Error {
10
+ constructor(code, message) { super(message); this.name = "AppleProfileRenewError"; this.code = code; }
11
+ }
12
+ const fail = (code, message) => { throw new AppleProfileRenewError(code, message); };
13
+ const sha1 = (bytes) => createHash("sha1").update(bytes).digest("hex").toUpperCase();
14
+ const serial = (value) => String(value ?? "").replace(/[^a-f0-9]/gi, "").replace(/^0+/, "").toUpperCase();
15
+ const query = (endpoint, params) => `${endpoint}?${new URLSearchParams(params)}`;
16
+
17
+ export function appleProfileAuth(env = process.env) {
18
+ if (!env.APPLE_API_ISSUER?.trim()) fail("APPLE_PROFILE_ISSUER_MISSING", "Set APPLE_API_ISSUER using existing notarization environment.");
19
+ if (!env.APPLE_API_KEY?.trim() || !env.APPLE_API_KEY_PATH?.trim()) fail("APPLE_PROFILE_KEY_MISSING", "Set APPLE_API_KEY & APPLE_API_KEY_PATH using existing notarization environment.");
20
+ const args = notarytoolAuthArgs({ env });
21
+ const auth = { keyPath: args[1], keyId: args[3], issuer: args[5] };
22
+ if (!existsSync(auth.keyPath) || !statSync(auth.keyPath).isFile()) fail("APPLE_PROFILE_KEY_MISSING", "Configured App Store Connect API key file is missing.");
23
+ return auth;
24
+ }
25
+
26
+ // Only this in-process signer reads key material. Never send it to a subprocess,
27
+ // include it in diagnostics, or expose it in returned renewal plans.
28
+ export function signAppleApiJwt({ keyPath, keyId, issuer }, now = new Date()) {
29
+ try {
30
+ const key = createPrivateKey(readFileSync(keyPath));
31
+ if (key.asymmetricKeyType !== "ec" || key.asymmetricKeyDetails?.namedCurve !== "prime256v1") throw new Error();
32
+ const iat = Math.floor(now.getTime() / 1000);
33
+ const encoded = (value) => Buffer.from(JSON.stringify(value)).toString("base64url");
34
+ const payload = `${encoded({ alg: "ES256", kid: keyId, typ: "JWT" })}.${encoded({ iss: issuer, iat, exp: iat + 600, aud: "appstoreconnect-v1" })}`;
35
+ return `${payload}.${sign("sha256", Buffer.from(payload), { key, dsaEncoding: "ieee-p1363" }).toString("base64url")}`;
36
+ } catch { fail("APPLE_PROFILE_KEY_INVALID", "Unable to sign API JWT with configured .p8 key."); }
37
+ }
38
+
39
+ function checked(run, command, args, options = {}) {
40
+ const result = run(command, args, { encoding: "utf8", stdio: "pipe", shell: false, windowsHide: true, ...options });
41
+ if (result.error || result.status !== 0) fail("APPLE_PROFILE_LOCAL_INSPECTION_FAILED", `${command} failed while inspecting profile or public signing certificates.`);
42
+ return result.stdout;
43
+ }
44
+
45
+ export function decodeAppleProfile(file, { run = spawnSync } = {}) {
46
+ const cms = checked(run, "security", ["cms", "-D", "-i", file]);
47
+ // plutil JSON does not support provisioning profiles' native date/data values.
48
+ const parser = "import base64,datetime,json,plistlib,sys\ndef encode(v):\n if isinstance(v,bytes): return base64.b64encode(v).decode('ascii')\n if isinstance(v,(datetime.date,datetime.datetime)): return v.isoformat()\n raise TypeError(type(v).__name__)\nprint(json.dumps(plistlib.loads(sys.stdin.buffer.read()),default=encode))";
49
+ try { return JSON.parse(checked(run, "python3", ["-c", parser], { input: cms })); }
50
+ catch (error) {
51
+ if (error instanceof AppleProfileRenewError) throw error;
52
+ fail("APPLE_PROFILE_INVALID", "Cannot decode macOS provisioning profile.");
53
+ }
54
+ }
55
+
56
+ export function describeAppleProfile(profile) {
57
+ if (!profile.Platform?.includes("OSX") || profile.TeamIdentifier?.length !== 1 || !profile.Name || !profile.Entitlements || !profile.DeveloperCertificates?.length) fail("APPLE_PROFILE_INVALID", "Expected named macOS profile with one team, entitlements & signing certificates.");
58
+ const team = profile.TeamIdentifier[0];
59
+ const appId = profile.Entitlements["com.apple.application-identifier"] ?? profile.Entitlements["application-identifier"];
60
+ const prefixes = profile.ApplicationIdentifierPrefix ?? [team];
61
+ const prefix = prefixes.find((value) => typeof appId === "string" && appId.startsWith(`${value}.`));
62
+ const bundleId = prefix && appId.slice(prefix.length + 1);
63
+ if (!bundleId || bundleId.includes("*")) fail("APPLE_PROFILE_INVALID", "Profile must authorize an explicit bundle identifier.");
64
+ const development = profile.Entitlements["get-task-allow"] ?? profile.Entitlements["com.apple.security.get-task-allow"];
65
+ const type = profile.ProvisionsAllDevices === true && development !== true ? "MAC_APP_DIRECT"
66
+ : development === true && Array.isArray(profile.ProvisionedDevices) && profile.ProvisionedDevices.length ? "MAC_APP_DEVELOPMENT" : null;
67
+ if (!type) fail("APPLE_PROFILE_TYPE_UNSUPPORTED", "Only MAC_APP_DIRECT & MAC_APP_DEVELOPMENT profiles can be renewed.");
68
+ return { team, bundleId, type, name: profile.Name, entitlements: profile.Entitlements };
69
+ }
70
+
71
+ export function findAppleSigningCertificate(info, { env = process.env, run = spawnSync, now = new Date() } = {}) {
72
+ const identities = checked(run, "security", ["find-identity", "-v", "-p", "codesigning"]);
73
+ const valid = new Set([...identities.matchAll(/([a-f0-9]{40})\s+"[^"\n]+"/gi)].map((match) => match[1].toUpperCase()));
74
+ const pem = checked(run, "security", ["find-certificate", "-a", "-p"]);
75
+ const prefix = info.type === "MAC_APP_DIRECT" ? "Developer ID Application:" : "Apple Development:";
76
+ const requested = env.APPLE_SIGNING_IDENTITY?.trim() || env.APPLE_DEVELOPER_ID?.trim();
77
+ const candidates = [...pem.matchAll(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g)].map((match) => {
78
+ const cert = new X509Certificate(match[0]);
79
+ return { sha1: sha1(cert.raw), serialNumber: cert.serialNumber, name: /^CN=(.+)$/m.exec(cert.subject)?.[1], team: /^OU=(.+)$/m.exec(cert.subject)?.[1], issued: Date.parse(cert.validFrom), expires: Date.parse(cert.validTo) };
80
+ }).filter((cert) => valid.has(cert.sha1) && cert.name?.startsWith(prefix) && cert.team === info.team && cert.issued <= now.getTime() && cert.expires > now.getTime()
81
+ && (!requested || cert.sha1 === requested.toUpperCase() || cert.name === requested))
82
+ .sort((a, b) => b.issued - a.issued || a.sha1.localeCompare(b.sha1));
83
+ if (!candidates.length) fail("APPLE_PROFILE_SIGNING_CERT_MISSING", `No valid ${prefix} certificate/private-key identity for team ${info.team} in Keychain.`);
84
+ return candidates[0];
85
+ }
86
+
87
+ export function entitlementsSuperset(newValue, oldValue) {
88
+ if (Array.isArray(oldValue)) return Array.isArray(newValue) && oldValue.every((oldItem) => newValue.some((newItem) => entitlementsSuperset(newItem, oldItem)));
89
+ if (oldValue && typeof oldValue === "object") return !!newValue && typeof newValue === "object" && !Array.isArray(newValue)
90
+ && Object.entries(oldValue).every(([key, value]) => Object.hasOwn(newValue, key) && entitlementsSuperset(newValue[key], value));
91
+ return newValue === oldValue;
92
+ }
93
+
94
+ export function verifyAppleProfile(profile, previous, certificate, now = new Date()) {
95
+ const before = describeAppleProfile(previous), after = describeAppleProfile(profile);
96
+ if (before.team !== after.team) fail("APPLE_PROFILE_TEAM_MISMATCH", "Renewed profile TeamIdentifier differs from current profile.");
97
+ if (before.bundleId !== after.bundleId || before.type !== after.type) fail("APPLE_PROFILE_IDENTITY_MISMATCH", "Renewed profile bundle identifier or type differs from current profile.");
98
+ if (!profile.DeveloperCertificates.some((data) => sha1(Buffer.from(data, "base64")) === certificate.sha1)) fail("APPLE_PROFILE_CERT_MISMATCH", "Renewed profile does not authorize selected Keychain certificate.");
99
+ if (!(Date.parse(profile.ExpirationDate) > now.getTime())) fail("APPLE_PROFILE_INVALID", "Renewed profile is expired or has no expiration date.");
100
+ if (!entitlementsSuperset(after.entitlements, before.entitlements)) fail("APPLE_PROFILE_ENTITLEMENTS_MISMATCH", "Renewed profile entitlements are not a superset of current profile.");
101
+ if (before.type === "MAC_APP_DEVELOPMENT" && !previous.ProvisionedDevices.every((udid) => profile.ProvisionedDevices?.includes(udid))) fail("APPLE_PROFILE_DEVICES_MISMATCH", "Renewed profile omits previously provisioned devices.");
102
+ }
103
+
104
+ export function configuredAppleProfile({ root, config = {}, profilePath }) {
105
+ if (profilePath) return path.resolve(root, profilePath);
106
+ const configured = config.targets?.mac?.sign?.provisioningProfile;
107
+ if (configured) return path.resolve(root, configured);
108
+ const manifest = config.development?.targets?.mac?.install?.manifestPath ?? "src-tauri/Cargo.toml";
109
+ const tauriRoot = path.dirname(path.resolve(root, manifest));
110
+ let mac = {};
111
+ for (const name of ["tauri.conf.json", "tauri.macos.conf.json"]) {
112
+ const file = path.join(tauriRoot, name);
113
+ if (existsSync(file)) mac = { ...mac, ...JSON.parse(readFileSync(file, "utf8")).bundle?.macOS };
114
+ }
115
+ const value = mac.provisioningProfile ?? mac.files?.["embedded.provisionprofile"] ?? mac.files?.["/embedded.provisionprofile"];
116
+ if (!value) fail("APPLE_PROFILE_PATH_MISSING", "Configure targets.mac.sign.provisioningProfile or Tauri macOS profile; use --profile for script-managed embedding.");
117
+ return path.resolve(tauriRoot, value);
118
+ }
119
+
120
+ export async function renewAppleProfile({ root, config = {}, profilePath, dryRun = false, env = process.env, run = spawnSync, fetch: fetchApi = globalThis.fetch, jwtSigner = signAppleApiJwt, now = new Date(), log = console.log }) {
121
+ const file = configuredAppleProfile({ root, config, profilePath });
122
+ if (!existsSync(file)) fail("APPLE_PROFILE_PATH_MISSING", "Configured provisioning profile file is missing.");
123
+ const original = readFileSync(file);
124
+ const previous = decodeAppleProfile(file, { run });
125
+ const info = describeAppleProfile(previous);
126
+ const certificate = findAppleSigningCertificate(info, { env, run, now });
127
+ const auth = appleProfileAuth(env);
128
+ const token = await jwtSigner(auth, now);
129
+ const calls = [];
130
+ const request = async (method, endpoint, body) => {
131
+ const url = new URL(endpoint, ASC);
132
+ if (url.origin !== ASC || !url.pathname.startsWith("/v1/")) fail("APPLE_PROFILE_API_INVALID", "ASC pagination URL is outside API origin.");
133
+ calls.push({ method, path: `${url.pathname}${url.search}` });
134
+ log(`${method} ${decodeURIComponent(url.pathname + url.search)}`);
135
+ if (dryRun && method !== "GET") fail("APPLE_PROFILE_DRY_RUN_MUTATION", "Dry-run cannot mutate ASC.");
136
+ let response;
137
+ try { response = await fetchApi(url.href, { method, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, ...(body ? { body: JSON.stringify(body) } : {}), redirect: "error", signal: AbortSignal.timeout(30_000) }); }
138
+ catch { fail("APPLE_PROFILE_API_FAILED", `ASC ${method} ${url.pathname} request failed.`); }
139
+ if (response.status === 403) fail("APPLE_PROFILE_INSUFFICIENT_ROLE", "ASC key lacks permission for Certificates, Identifiers & Profiles (403).");
140
+ if (!response.ok) fail("APPLE_PROFILE_API_FAILED", `ASC ${method} ${url.pathname} returned HTTP ${response.status}.`);
141
+ try { return await response.json(); }
142
+ catch { fail("APPLE_PROFILE_API_INVALID", "ASC returned invalid JSON."); }
143
+ };
144
+ const list = async (endpoint) => {
145
+ const items = [], visited = new Set();
146
+ while (endpoint) {
147
+ if (visited.has(endpoint) || visited.size >= 100) fail("APPLE_PROFILE_API_INVALID", "Invalid or excessive ASC pagination.");
148
+ visited.add(endpoint);
149
+ const result = await request("GET", endpoint);
150
+ if (!Array.isArray(result.data)) fail("APPLE_PROFILE_API_INVALID", "ASC list response has no resource array.");
151
+ items.push(...result.data); endpoint = result.links?.next;
152
+ }
153
+ return items;
154
+ };
155
+ const certificates = await list(query("/v1/certificates", { "filter[serialNumber]": certificate.serialNumber, limit: "200" }));
156
+ const ascCert = certificates.find((item) => serial(item.attributes?.serialNumber) === serial(certificate.serialNumber));
157
+ if (!ascCert?.id) fail("APPLE_PROFILE_CERT_NOT_IN_ASC", `Keychain certificate ${certificate.sha1} (serial ${certificate.serialNumber}) is absent from ASC.`);
158
+ const bundles = await list(query("/v1/bundleIds", { "filter[identifier]": info.bundleId, limit: "200" }));
159
+ const bundle = bundles.find((item) => item.attributes?.identifier === info.bundleId && ["MAC_OS", "UNIVERSAL"].includes(item.attributes?.platform));
160
+ if (!bundle?.id) fail("APPLE_PROFILE_BUNDLE_ID_MISSING", `No macOS bundleId resource for ${info.bundleId} in ASC.`);
161
+ const name = `${info.name.replace(/-\d{8}$/, "")}-${now.toISOString().slice(0, 10).replaceAll("-", "")}`;
162
+ const relationships = { bundleId: { data: { type: "bundleIds", id: bundle.id } }, certificates: { data: [{ type: "certificates", id: ascCert.id }] } };
163
+ if (info.type === "MAC_APP_DEVELOPMENT") {
164
+ const devices = await list(query("/v1/devices", { "filter[platform]": "MAC_OS", limit: "200" }));
165
+ relationships.devices = { data: previous.ProvisionedDevices.map((udid) => {
166
+ const device = devices.find((item) => item.attributes?.udid === udid && item.attributes?.status === "ENABLED");
167
+ if (!device?.id) fail("APPLE_PROFILE_DEVICE_MISSING", `Previously provisioned device ${udid} is absent or disabled in ASC.`);
168
+ return { type: "devices", id: device.id };
169
+ }) };
170
+ }
171
+ const body = { data: { type: "profiles", attributes: { name, profileType: info.type }, relationships } };
172
+ const plan = { dryRun, profilePath: file, profileType: info.type, teamIdentifier: info.team, name, certificate: { ...certificate, id: ascCert.id }, bundleId: { identifier: info.bundleId, id: bundle.id }, entitlements: info.entitlements, calls, body };
173
+ log(JSON.stringify({ profilePath: file, profileType: info.type, teamIdentifier: info.team, name, certificate: plan.certificate, bundleId: plan.bundleId, entitlements: plan.entitlements }, null, 2));
174
+ if (dryRun) {
175
+ calls.push({ method: "POST", path: "/v1/profiles", planned: true });
176
+ log(`PLANNED POST /v1/profiles ${JSON.stringify(body)}`);
177
+ log(`PLANNED download profileContent, verify & replace ${file} with adjacent backup`);
178
+ return plan;
179
+ }
180
+ let created = (await request("POST", "/v1/profiles", body)).data;
181
+ if (!created?.id) fail("APPLE_PROFILE_API_INVALID", "ASC did not return a created profile id.");
182
+ if (!created.attributes?.profileContent) created = (await request("GET", `/v1/profiles/${encodeURIComponent(created.id)}`)).data;
183
+ const content = created?.attributes?.profileContent;
184
+ if (typeof content !== "string" || !content || !/^[A-Za-z0-9+/]+={0,2}$/.test(content)) fail("APPLE_PROFILE_API_INVALID", "ASC profileContent is missing or invalid.");
185
+ const temporary = `${file}.renew-${randomUUID()}.tmp`;
186
+ const backup = `${file}.backup-${now.toISOString().replace(/[:.]/g, "-")}-${randomUUID()}`;
187
+ try {
188
+ writeFileSync(temporary, Buffer.from(content, "base64"), { flag: "wx", mode: statSync(file).mode & 0o777 });
189
+ verifyAppleProfile(decodeAppleProfile(temporary, { run }), previous, certificate, now);
190
+ if (!readFileSync(file).equals(original)) fail("APPLE_PROFILE_CHANGED", "Current profile changed during renewal; refusing replacement.");
191
+ copyFileSync(file, backup, constants.COPYFILE_EXCL);
192
+ renameSync(temporary, file);
193
+ } finally { rmSync(temporary, { force: true }); }
194
+ log(`Renewed ${file}; preserved previous profile at ${backup}`);
195
+ return { ...plan, backupPath: backup, profileId: created.id };
196
+ }
197
+
198
+ export async function main(args = process.argv.slice(2), options = {}) {
199
+ if (args.includes("--help") || args.includes("-h")) {
200
+ (options.log ?? console.log)("right-release apple profile renew [--config <file>] [--profile <file>] [--dry-run]\nUses APPLE_API_ISSUER, APPLE_API_KEY & APPLE_API_KEY_PATH. Dry-run performs GET discovery only; never POSTs or writes files.");
201
+ return;
202
+ }
203
+ if (args[0] !== "profile" || args[1] !== "renew") fail("APPLE_PROFILE_USAGE", "Expected: right-release apple profile renew");
204
+ let configPath = "right-release.config.mjs", profilePath, dryRun = false;
205
+ for (let i = 2; i < args.length; i++) {
206
+ if (args[i] === "--dry-run") dryRun = true;
207
+ else if (["--config", "--profile"].includes(args[i])) {
208
+ const flag = args[i], value = args[++i];
209
+ if (!value || value.startsWith("--")) fail("APPLE_PROFILE_USAGE", `Missing value for ${flag}`);
210
+ if (flag === "--config") configPath = value; else profilePath = value;
211
+ } else fail("APPLE_PROFILE_USAGE", `Unknown option: ${args[i]}`);
212
+ }
213
+ const absolute = path.resolve(configPath);
214
+ const loaded = await import(pathToFileURL(absolute));
215
+ return renewAppleProfile({ ...options, root: path.dirname(absolute), config: loaded.default ?? loaded.config ?? loaded, profilePath, dryRun });
216
+ }
217
+
218
+ if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
219
+ main().catch((error) => { console.error(`right-release apple profile renew: [${error.code ?? "APPLE_PROFILE_FAILED"}] ${error instanceof AppleProfileRenewError ? error.message : "Renewal failed; check app configuration & local file access."}`); process.exitCode = 1; });
220
+ }
@@ -0,0 +1,344 @@
1
+ import {
2
+ existsSync,
3
+ copyFileSync,
4
+ lstatSync,
5
+ mkdirSync,
6
+ readFileSync,
7
+ readdirSync,
8
+ renameSync,
9
+ rmSync,
10
+ statSync,
11
+ writeFileSync,
12
+ } from "node:fs";
13
+ import path from "node:path";
14
+
15
+ export const ARTIFACT_MARKER_SCHEMA = 1;
16
+ export const DEFAULT_MANAGED_ROOT = process.platform === "win32"
17
+ ? "D:\\.rightkit-managed"
18
+ : "/Volumes/D/.rightkit-managed";
19
+
20
+ const SAFE_COMPONENT = /^[A-Za-z0-9][A-Za-z0-9._+-]{0,159}$/;
21
+
22
+ export function resolveManagedRoot({ env = process.env } = {}) {
23
+ const value = env.RIGHTKIT_MANAGED_ROOT || DEFAULT_MANAGED_ROOT;
24
+ return path.resolve(value);
25
+ }
26
+
27
+ export function resolveArtifactRoot({ app, managedRoot } = {}) {
28
+ assertComponent(app, "app");
29
+ return path.join(path.resolve(managedRoot ?? resolveManagedRoot()), "artifacts", app);
30
+ }
31
+
32
+ export function resolveArtifactDir({ app, version, managedRoot } = {}) {
33
+ assertComponent(app, "app");
34
+ assertComponent(version, "version");
35
+ return path.join(resolveArtifactRoot({ app, managedRoot }), version);
36
+ }
37
+
38
+ /** Resolve one declared installer/updater path, allowing managed output. */
39
+ export function resolveArtifactInput({ root, app, version, declared, managedRoot } = {}) {
40
+ const local = path.resolve(root, declared);
41
+ if (existsSync(local)) return local;
42
+ const managed = path.join(resolveArtifactDir({ app, version, managedRoot }), path.basename(declared));
43
+ return existsSync(managed) ? managed : local;
44
+ }
45
+
46
+ export function markerPath({ app, version, managedRoot } = {}) {
47
+ return path.join(resolveArtifactDir({ app, version, managedRoot }), ".rightkit-artifact.json");
48
+ }
49
+
50
+ export function publishedPointerPath({ app, managedRoot } = {}) {
51
+ return path.join(resolveArtifactRoot({ app, managedRoot }), "published.json");
52
+ }
53
+
54
+ /**
55
+ * Create or refresh the lease marker before a build writes managed artifacts.
56
+ * Marker fields intentionally remain the small cross-process contract.
57
+ */
58
+ export function beginArtifactLease({ app, version, managedRoot, now = Date.now(), ownerPid = process.pid, leaseMs = 2 * 60 * 60 * 1000 } = {}) {
59
+ if (!Number.isFinite(now) || !Number.isSafeInteger(ownerPid) || ownerPid < 1 || !Number.isFinite(leaseMs) || leaseMs <= 0) {
60
+ throw new TypeError("artifact lease requires finite now/leaseMs and positive ownerPid");
61
+ }
62
+ const root = resolveArtifactRoot({ app, managedRoot });
63
+ ensureDirectory(root);
64
+ const lock = acquireRetentionLock(root, now);
65
+ if (!lock) throw new Error(`artifact retention lock is held; cannot lease ${app}@${version}`);
66
+ try {
67
+ const directory = resolveArtifactDir({ app, version, managedRoot });
68
+ ensureDirectory(directory);
69
+ const file = markerPath({ app, version, managedRoot });
70
+ assertSafeMutation(file);
71
+ if (existsSync(file)) {
72
+ const previous = readArtifactMarker(file);
73
+ if (isActiveLease(previous, now) && previous.ownerPid !== ownerPid) {
74
+ throw new Error(`artifact lease is held by PID ${previous.ownerPid}: ${app}@${version}`);
75
+ }
76
+ }
77
+ const marker = { schemaVersion: ARTIFACT_MARKER_SCHEMA, app, version, createdAt: now, ownerPid, leaseUntil: now + leaseMs };
78
+ writeJson(file, marker);
79
+ return marker;
80
+ } finally {
81
+ lock.release();
82
+ }
83
+ }
84
+
85
+ export function completeArtifactLease({ app, version, managedRoot, completedAt = Date.now() } = {}) {
86
+ if (!Number.isFinite(completedAt)) throw new TypeError("completedAt must be finite");
87
+ const root = resolveArtifactRoot({ app, managedRoot });
88
+ const lock = acquireRetentionLock(root, completedAt);
89
+ if (!lock) throw new Error(`artifact retention lock is held; cannot complete ${app}@${version}`);
90
+ try {
91
+ const file = markerPath({ app, version, managedRoot });
92
+ if (!existsSync(file)) throw new Error(`artifact marker missing: ${file}`);
93
+ const marker = readArtifactMarker(file);
94
+ if (isActiveLease(marker, completedAt) && marker.ownerPid !== process.pid) throw new Error(`artifact lease is owned by PID ${marker.ownerPid}: ${app}@${version}`);
95
+ const completed = { schemaVersion: ARTIFACT_MARKER_SCHEMA, app, version, createdAt: marker.createdAt, ownerPid: 0, leaseUntil: 0, completedAt };
96
+ writeJson(file, completed);
97
+ return completed;
98
+ } finally {
99
+ lock.release();
100
+ }
101
+ }
102
+
103
+ export function readArtifactMarker(file) {
104
+ const marker = JSON.parse(readFileSync(file, "utf8"));
105
+ if (marker?.schemaVersion !== ARTIFACT_MARKER_SCHEMA) throw new Error(`unsupported artifact marker schema: ${file}`);
106
+ for (const key of ["app", "version"]) assertComponent(marker[key], key);
107
+ for (const key of ["createdAt", "ownerPid", "leaseUntil"]) {
108
+ if (!Number.isFinite(marker[key])) throw new Error(`artifact marker ${key} must be numeric: ${file}`);
109
+ }
110
+ if (marker.completedAt != null && !Number.isFinite(marker.completedAt)) throw new Error(`artifact marker completedAt must be numeric: ${file}`);
111
+ return marker;
112
+ }
113
+
114
+ /**
115
+ * Read publication state. Missing or malformed state is unknown and must keep
116
+ * every artifact. An explicit empty versions array means known never-published.
117
+ */
118
+ export function readPublishedVersions({ app, managedRoot } = {}) {
119
+ const file = publishedPointerPath({ app, managedRoot });
120
+ if (!existsSync(file)) return { state: "unknown", versions: [], path: file };
121
+ try {
122
+ const pointer = JSON.parse(readFileSync(file, "utf8"));
123
+ if (pointer.platforms != null && (!pointer.platforms || typeof pointer.platforms !== "object" || Array.isArray(pointer.platforms))) {
124
+ return { state: "unknown", versions: [], platforms: {}, path: file, reason: "published pointer has invalid platforms" };
125
+ }
126
+ if (!Array.isArray(pointer.versions) || pointer.versions.some((version) => !SAFE_COMPONENT.test(String(version)))) {
127
+ return { state: "unknown", versions: [], path: file, reason: "published pointer has invalid versions" };
128
+ }
129
+ const platforms = Object.fromEntries(Object.entries(pointer.platforms ?? {}).map(([platform, version]) => {
130
+ if (!SAFE_COMPONENT.test(platform)) throw new Error("published pointer has invalid platform");
131
+ return [platform, String(version)];
132
+ }));
133
+ if (Object.values(platforms).some((version) => !SAFE_COMPONENT.test(version))) return { state: "unknown", versions: [], platforms: {}, path: file, reason: "published pointer has invalid platform version" };
134
+ return { state: "known", versions: [...new Set(pointer.versions.map(String))], platforms, path: file };
135
+ } catch (error) {
136
+ return { state: "unknown", versions: [], path: file, reason: error.message };
137
+ }
138
+ }
139
+
140
+ /** Record only a publication that completed remote verification. */
141
+ export function recordPublishedVersion({ app, version, platform = "default", managedRoot, now = Date.now() } = {}) {
142
+ assertComponent(version, "version");
143
+ const root = resolveArtifactRoot({ app, managedRoot });
144
+ ensureDirectory(root);
145
+ const lock = acquireRetentionLock(root, now);
146
+ if (!lock) throw new Error("retention lock is held by a live owner; publication state cannot be updated");
147
+ try {
148
+ const current = readPublishedVersions({ app, managedRoot });
149
+ if (current.state === "unknown" && existsSync(current.path)) throw new Error(`published state is unknown; refusing to overwrite ${current.path}`);
150
+ if (!SAFE_COMPONENT.test(String(platform))) throw new Error("platform must be a safe release component");
151
+ const platforms = { ...current.platforms, [platform]: version };
152
+ const versions = [...new Set(Object.values(platforms))];
153
+ // Pointer contract is deliberately tiny; publication time is already
154
+ // evidenced by upload receipt and marker files.
155
+ writeJson(current.path, { versions, platforms });
156
+ return { state: "known", versions, platforms, path: current.path };
157
+ } finally {
158
+ lock.release();
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Keep three newest completed artifact versions plus every published version.
164
+ * Unknown publication state never deletes data. Invalid/incomplete markers are
165
+ * also retained because they may belong to an interrupted build.
166
+ */
167
+ export function retainArtifacts({ app, managedRoot, now = Date.now(), keep = 3, remove = true } = {}) {
168
+ if (!Number.isInteger(keep) || keep < 0) throw new TypeError("keep must be a non-negative integer");
169
+ const root = resolveArtifactRoot({ app, managedRoot });
170
+ assertSafeMutation(root);
171
+ if (!existsSync(root)) return { state: "known", removed: [], retained: [] };
172
+ const lock = acquireRetentionLock(root, now);
173
+ if (!lock) return { state: "unknown", removed: [], retained: [], reason: "retention lock is held by a live owner" };
174
+
175
+ try {
176
+ const publication = readPublishedVersions({ app, managedRoot });
177
+ if (publication.state !== "known") return { state: "unknown", removed: [], retained: [], reason: publication.reason ?? "publication state is unknown" };
178
+ const candidates = [];
179
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
180
+ if (!entry.isDirectory() || !SAFE_COMPONENT.test(entry.name)) continue;
181
+ const directory = path.join(root, entry.name);
182
+ const markerFile = path.join(directory, ".rightkit-artifact.json");
183
+ if (!existsSync(markerFile)) continue;
184
+ try {
185
+ const marker = readArtifactMarker(markerFile);
186
+ if (marker.app !== app || marker.version !== entry.name || marker.completedAt == null) continue;
187
+ if (isActiveLease(marker, now)) continue;
188
+ candidates.push({ version: entry.name, directory, markerFile, completedAt: marker.completedAt });
189
+ } catch {
190
+ // Preserve malformed state. A future doctor can repair it explicitly.
191
+ }
192
+ }
193
+ candidates.sort((left, right) => right.completedAt - left.completedAt || right.version.localeCompare(left.version));
194
+ const retainedVersions = new Set([
195
+ ...candidates.slice(0, keep).map((candidate) => candidate.version),
196
+ ...publication.versions,
197
+ ]);
198
+ const removed = [];
199
+ for (const candidate of candidates) {
200
+ if (retainedVersions.has(candidate.version)) continue;
201
+ // Re-read under the retention lock immediately before deletion. A build
202
+ // may have renewed this lease after the first directory scan.
203
+ if (!existsSync(candidate.markerFile)) continue;
204
+ const current = readArtifactMarker(candidate.markerFile);
205
+ if (isActiveLease(current, now) || current.completedAt == null) continue;
206
+ assertSafeMutation(candidate.directory);
207
+ if (remove) rmSync(candidate.directory, { recursive: true, force: false });
208
+ removed.push(candidate.version);
209
+ }
210
+ return { state: "known", removed, retained: [...retainedVersions].sort(), published: publication.versions };
211
+ } finally {
212
+ lock.release();
213
+ }
214
+ }
215
+
216
+ /** Atomically copy sealed/source files into their managed version directory. */
217
+ export function materializeArtifacts({ app, version, files, managedRoot, now = Date.now(), leaseMs } = {}) {
218
+ if (!Array.isArray(files) || files.length === 0) throw new TypeError("artifact files are required");
219
+ const root = resolveArtifactRoot({ app, managedRoot });
220
+ ensureDirectory(root);
221
+ const lock = acquireRetentionLock(root, now);
222
+ if (!lock) throw new Error(`artifact retention lock is held; cannot materialize ${app}@${version}`);
223
+ const directory = resolveArtifactDir({ app, version, managedRoot });
224
+ const markerFile = markerPath({ app, version, managedRoot });
225
+ try {
226
+ if (existsSync(markerFile)) {
227
+ const marker = readArtifactMarker(markerFile);
228
+ if (isActiveLease(marker, now) && marker.ownerPid !== process.pid) {
229
+ throw new Error(`artifact lease is owned by PID ${marker.ownerPid}: ${app}@${version}`);
230
+ }
231
+ }
232
+ } catch (error) {
233
+ lock.release();
234
+ throw error;
235
+ }
236
+ const temp = `${directory}.tmp-${process.pid}`;
237
+ try {
238
+ assertSafeMutation(directory);
239
+ assertSafeMutation(temp);
240
+ rmSync(temp, { recursive: true, force: true });
241
+ ensureDirectory(temp);
242
+ for (const entry of files) {
243
+ const source = path.resolve(entry.source ?? entry.file ?? "");
244
+ const name = path.basename(entry.name ?? source);
245
+ if (!source || !existsSync(source) || !statSync(source).isFile()) throw new Error(`artifact source missing: ${source}`);
246
+ assertSafeMutation(source);
247
+ if (!SAFE_COMPONENT.test(name)) throw new Error(`unsafe artifact filename: ${name}`);
248
+ copyFileSync(source, path.join(temp, name));
249
+ }
250
+ if (existsSync(directory)) {
251
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
252
+ if (entry.name === ".rightkit-artifact.json" || !entry.isFile()) continue;
253
+ const existing = path.join(directory, entry.name);
254
+ assertSafeMutation(existing);
255
+ if (!existsSync(path.join(temp, entry.name))) copyFileSync(existing, path.join(temp, entry.name));
256
+ }
257
+ }
258
+ mkdirSync(directory, { recursive: true });
259
+ for (const entry of readdirSync(temp, { withFileTypes: true })) {
260
+ if (entry.isFile()) {
261
+ const destination = path.join(directory, entry.name);
262
+ assertSafeMutation(destination);
263
+ rmSync(destination, { force: true });
264
+ renameSync(path.join(temp, entry.name), destination);
265
+ }
266
+ }
267
+ } catch (error) {
268
+ safeRemove(temp);
269
+ throw error;
270
+ } finally {
271
+ lock.release();
272
+ }
273
+ return directory;
274
+ }
275
+
276
+ function writeJson(file, value) {
277
+ ensureDirectory(path.dirname(file));
278
+ assertSafeMutation(file);
279
+ const temp = `${file}.tmp-${process.pid}`;
280
+ assertSafeMutation(temp);
281
+ writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`);
282
+ renameSync(temp, file);
283
+ }
284
+
285
+ function assertComponent(value, label) {
286
+ if (!SAFE_COMPONENT.test(String(value ?? ""))) throw new Error(`${label} must be a safe release component`);
287
+ }
288
+
289
+ function ensureDirectory(directory) {
290
+ assertSafeMutation(directory);
291
+ mkdirSync(directory, { recursive: true });
292
+ }
293
+
294
+ function assertSafeMutation(target) {
295
+ const absolute = path.resolve(target);
296
+ const root = path.parse(absolute).root;
297
+ const relative = path.relative(root, absolute);
298
+ let current = root;
299
+ for (const segment of relative ? relative.split(path.sep) : []) {
300
+ current = path.join(current, segment);
301
+ try {
302
+ if (lstatSync(current).isSymbolicLink()) throw new Error(`refusing filesystem mutation through symlink: ${current}`);
303
+ } catch (error) {
304
+ if (error.code === "ENOENT") continue;
305
+ throw error;
306
+ }
307
+ }
308
+ }
309
+
310
+ function safeRemove(target) {
311
+ assertSafeMutation(target);
312
+ rmSync(target, { recursive: true, force: true });
313
+ }
314
+
315
+ function isActiveLease(marker, now) {
316
+ if (Number(marker.ownerPid) > 0 && processAlive(marker.ownerPid)) return true;
317
+ return Number(marker.leaseUntil) > Number(now);
318
+ }
319
+
320
+ function acquireRetentionLock(root, now) {
321
+ ensureDirectory(root);
322
+ const directory = path.join(root, ".retention-lock");
323
+ assertSafeMutation(directory);
324
+ try {
325
+ mkdirSync(directory);
326
+ } catch (error) {
327
+ if (error.code !== "EEXIST") throw error;
328
+ const ownerFile = path.join(directory, "owner.json");
329
+ let owner;
330
+ try { owner = JSON.parse(readFileSync(ownerFile, "utf8")); } catch { return null; }
331
+ if (!Number.isSafeInteger(owner?.pid) || owner.pid < 1) return null;
332
+ if (processAlive(owner.pid)) return null;
333
+ safeRemove(directory);
334
+ mkdirSync(directory);
335
+ }
336
+ const ownerFile = path.join(directory, "owner.json");
337
+ writeJson(ownerFile, { pid: process.pid, createdAt: now });
338
+ return { release: () => safeRemove(directory) };
339
+ }
340
+
341
+ function processAlive(pid) {
342
+ if (!Number.isInteger(Number(pid)) || Number(pid) <= 0) return false;
343
+ try { process.kill(Number(pid), 0); return true; } catch (error) { return error.code !== "ESRCH"; }
344
+ }