@rightkit/release 0.2.174 → 0.3.0

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/dmg-build.mjs CHANGED
@@ -7,6 +7,10 @@ import { readDmgSpec } from "./dmg-spec.mjs";
7
7
  import { detachWithRetrySync } from "./hdiutil-detach.mjs";
8
8
  import { assertReleaseExecutionAllowed } from "./execution-policy.mjs";
9
9
  import { assertNoQaControl } from "./qa-control-scan.mjs";
10
+ import { signMacosEntries, validateCodesignIdentifier } from "./mac-sign-core.mjs";
11
+ import { MACOS_PORTABLE_SIGNING_CONTRACT } from "./macos-signing-contract.mjs";
12
+ import { resolveMacosDeveloperIdIdentity } from "./macos-signing-identity.mjs";
13
+ import { runMacCodesignCommands } from "./macos-signing-session.mjs";
10
14
 
11
15
  const fail = (code, message) => Object.assign(new Error(message), { code });
12
16
  const pair = (value, label, positive = false) => {
@@ -25,12 +29,44 @@ export function resolveDmgOptions(input = {}) {
25
29
  return spec ? { ...readDmgSpec(spec, { app: explicit.app }), ...definedOnly(explicit) } : explicit;
26
30
  }
27
31
 
32
+ /**
33
+ * Developer ID sign a finished disk image (#646): secure timestamp, no hardened runtime (a disk image has none), an
34
+ * optional stable `--identifier`, strict verification, and an optional signing receipt (the sign-macos.mjs shape) so a
35
+ * later notarize step can bind to it. It never notarizes or uploads. Developer ID only: no ad hoc or Apple Development fallback.
36
+ */
37
+ export function signBuiltDmg({ file, identifier, receipt }) {
38
+ const identity = resolveMacosDeveloperIdIdentity();
39
+ const files = signMacosEntries([{ file, hardenedRuntime: false, options: [], ...(identifier ? { identifier } : {}) }], {
40
+ identity,
41
+ signOne: (codesignArgs) => runMacCodesignCommands([codesignArgs])[0],
42
+ inspect: (tool, args) => {
43
+ const result = spawnSync(tool, args, { encoding: "utf8" });
44
+ const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`;
45
+ if (result.status !== 0) throw new Error(`${tool} ${args.join(" ")} failed: ${output.trim()}`);
46
+ return output;
47
+ },
48
+ });
49
+ if (receipt) {
50
+ mkdirSync(path.dirname(receipt), { recursive: true });
51
+ writeFileSync(receipt, `${JSON.stringify({ schema: 1, signingContract: MACOS_PORTABLE_SIGNING_CONTRACT, identity, files }, null, 2)}\n`);
52
+ }
53
+ return files;
54
+ }
55
+
56
+ /**
57
+ * Build the branded DMG; with `sign: true` (CLI `--sign`) it is then Developer ID signed (`identifier`, `signReceipt`
58
+ * optional) and a signing failure removes the unsigned image so nothing downstream can pick it up. `signDmg` is injectable.
59
+ */
28
60
  export function buildDmg(input = {},
29
61
  { platform = process.platform, run = spawnSync, makeAlias = backgroundAlias,
30
- log = (event) => console.error(JSON.stringify(event)), sleep } = {}) {
62
+ log = (event) => console.error(JSON.stringify(event)), sleep, signDmg = signBuiltDmg } = {}) {
31
63
  if (platform !== "darwin") throw fail("DMG_NATIVE_HOST_REQUIRED", "DMG creation requires macOS");
32
64
  let { app, out, volname, background, iconSize = 128, appPosition = [158, 231],
33
- applicationsPosition = [492, 231], window = [660, 400], windowPosition = [100, 100], volumeIcon } = resolveDmgOptions(input);
65
+ applicationsPosition = [492, 231], window = [660, 400], windowPosition = [100, 100], volumeIcon,
66
+ sign = false, identifier, signReceipt } = resolveDmgOptions(input);
67
+ if (identifier !== undefined && !sign) throw fail("DMG_INVALID_OPTIONS", "--identifier needs --sign");
68
+ if (signReceipt !== undefined && !sign) throw fail("DMG_INVALID_OPTIONS", "--receipt needs --sign");
69
+ if (identifier !== undefined) { try { validateCodesignIdentifier(identifier, "--identifier"); } catch (error) { throw fail("DMG_INVALID_OPTIONS", error.message); } }
34
70
  if (!app || !out || !volname) throw fail("DMG_INVALID_OPTIONS", "--app, --out & --volname are required");
35
71
  app = path.resolve(app); out = path.resolve(out);
36
72
  if (!app.endsWith(".app") || !statSync(app).isDirectory() || !existsSync(path.join(app, "Contents", "Info.plist"))) {
@@ -103,8 +139,18 @@ export function buildDmg(input = {},
103
139
  command("/usr/bin/hdiutil", ["convert", rw, "-format", "UDZO", "-imagekey", "zlib-level=9", "-o", compressed]);
104
140
  command("/usr/bin/hdiutil", ["verify", compressed]);
105
141
  renameSync(compressed, out);
106
- log({ event: "dmg-build-completed", out, format: "UDZO" });
107
- return { out, format: "UDZO" };
142
+ let signed;
143
+ if (sign) {
144
+ // Developer ID sign the finished image. A failure removes the unsigned image: nothing downstream may pick it up.
145
+ try { signed = signDmg({ file: out, ...(identifier ? { identifier } : {}), ...(signReceipt ? { receipt: path.resolve(signReceipt) } : {}) }); }
146
+ catch (error) {
147
+ rmSync(out, { force: true });
148
+ throw fail("DMG_SIGN_FAILED", `signing ${out} failed (the unsigned image was removed): ${error.message}`);
149
+ }
150
+ log({ event: "dmg-signed", out, ...(identifier ? { identifier } : {}) });
151
+ }
152
+ log({ event: "dmg-build-completed", out, format: "UDZO", signed: Boolean(sign) });
153
+ return { out, format: "UDZO", ...(sign ? { signed: true, signing: signed } : {}) };
108
154
  } catch (error) {
109
155
  log({ event: "dmg-build-failed", out, code: error.code ?? "DMG_BUILD_FAILED", message: error.message });
110
156
  throw error;
@@ -122,8 +168,11 @@ export function parseDmgArguments(args) {
122
168
  if (args[0] !== "build") throw fail("DMG_INVALID_OPTIONS", "expected dmg build");
123
169
  const flags = new Map([["--app", "app"], ["--out", "out"], ["--volname", "volname"], ["--background", "background"],
124
170
  ["--icon-size", "iconSize"], ["--app-position", "appPosition"], ["--applications-position", "applicationsPosition"], ["--window", "window"], ["--window-position", "windowPosition"], ["--volume-icon", "volumeIcon"], ["--spec", "spec"]]);
171
+ flags.set("--identifier", "identifier"); flags.set("--receipt", "signReceipt");
125
172
  const options = {};
126
173
  for (let i = 1; i < args.length; i += 2) {
174
+ // `--sign` is the one value-less flag: Developer ID sign the finished image (#646).
175
+ if (args[i] === "--sign") { if (options.sign !== undefined) throw fail("DMG_INVALID_OPTIONS", "invalid option: --sign"); options.sign = true; i -= 1; continue; }
127
176
  const key = flags.get(args[i]);
128
177
  if (!key || options[key] !== undefined || !args[i + 1] || args[i + 1].startsWith("--")) throw fail("DMG_INVALID_OPTIONS", `invalid option: ${args[i]}`);
129
178
  options[key] = args[i + 1];
package/icon-pack.mjs ADDED
@@ -0,0 +1,117 @@
1
+ #!/usr/bin/env node
2
+ // App icon packers (bogusyogi/rightkit#675): assemble a Windows ICO or a macOS ICNS from per-size PNG frames, so
3
+ // hand-tuned small frames (16/24/32/48) are kept exactly as drawn instead of being down-scaled from one master.
4
+ // right-release icons ico --out <file.ico> --frame <PNG>... [--dir <folder of <size>.png>]
5
+ // right-release icons icns --out <file.icns> --frame <PNG>... [--dir <folder of <size>.png>]
6
+ // Every frame must be a square PNG; its size comes from its IHDR, never from the file name. ICO frames are stored
7
+ // as PNG (Vista+), sizes 1..256. ICNS accepts 16, 32, 64, 128, 256, 512 and 1024. Pure Buffer work, any host.
8
+ // Rendering a master image to PNG frames and DMG background art stay with the app's design source
9
+ // (dmg background: generate-dmg-background.py).
10
+ import { readFileSync, readdirSync, writeFileSync, mkdirSync } from "node:fs";
11
+ import path from "node:path";
12
+
13
+ const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
14
+ export const ICNS_TYPES = Object.freeze({ 16: "icp4", 32: "icp5", 64: "icp6", 128: "ic07", 256: "ic08", 512: "ic09", 1024: "ic10" });
15
+ function fail(message, code = "ICON_INVALID") { throw Object.assign(new Error(message), { code }); }
16
+
17
+ /** Read the pixel size of a square PNG from its IHDR. */
18
+ export function pngSize(bytes, label = "frame") {
19
+ const buf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes);
20
+ if (buf.length < 33 || !buf.subarray(0, 8).equals(PNG_MAGIC) || buf.toString("latin1", 12, 16) !== "IHDR") fail(`${label} is not a PNG`);
21
+ const width = buf.readUInt32BE(16), height = buf.readUInt32BE(20);
22
+ if (width !== height) fail(`${label} must be square, got ${width}x${height}`);
23
+ // Walk the chunk list: IHDR first (13 bytes), at least one IDAT, IEND last with nothing after it.
24
+ if (buf.readUInt32BE(8) !== 13) fail(`${label} has a malformed IHDR chunk`);
25
+ let at = 8, idat = false, iend = false, first = true;
26
+ while (at < buf.length) {
27
+ if (at + 12 > buf.length) fail(`${label} is a truncated PNG`);
28
+ const length = buf.readUInt32BE(at), type = buf.toString("latin1", at + 4, at + 8);
29
+ if (at + 12 + length > buf.length) fail(`${label} is a truncated PNG`);
30
+ if (iend) fail(`${label} has data after IEND`);
31
+ if (first && type !== "IHDR") fail(`${label} is not a PNG`);
32
+ first = false;
33
+ if (type === "IDAT") idat = true;
34
+ if (type === "IEND") iend = true;
35
+ at += 12 + length;
36
+ }
37
+ if (!idat) fail(`${label} has no image data (IDAT)`);
38
+ if (!iend) fail(`${label} is missing IEND`);
39
+ const depth = buf[24], colorType = buf[25];
40
+ if (depth !== 8 || colorType !== 6) fail(`${label} must be 8-bit RGBA (color type 6, bit depth 8), got bit depth ${depth}, color type ${colorType}`);
41
+ return width;
42
+ }
43
+
44
+ function normalizeFrames(frames, { min = 1, max = 1024, only = null } = {}) {
45
+ if (!Array.isArray(frames) || frames.length === 0) fail("at least one PNG frame is required", "ICON_USAGE");
46
+ const bySize = new Map();
47
+ for (const frame of frames) {
48
+ const bytes = Buffer.isBuffer(frame) ? frame : Buffer.from(frame);
49
+ const size = pngSize(bytes);
50
+ if (size < min || size > max || (only && !only.includes(size))) fail(`unsupported frame size ${size}${only ? ` (allowed: ${only.join(", ")})` : ` (allowed ${min}..${max})`}`);
51
+ if (bySize.has(size)) fail(`duplicate ${size}x${size} frame`);
52
+ bySize.set(size, bytes);
53
+ }
54
+ return [...bySize.entries()].sort((a, b) => a[0] - b[0]);
55
+ }
56
+
57
+ /** @param {Buffer[]} frames PNG buffers, one per size (1..256). @returns {Buffer} ICO file. */
58
+ export function packIco(frames) {
59
+ const sorted = normalizeFrames(frames, { max: 256 });
60
+ const header = Buffer.alloc(6 + 16 * sorted.length);
61
+ header.writeUInt16LE(0, 0); header.writeUInt16LE(1, 2); header.writeUInt16LE(sorted.length, 4);
62
+ let offset = header.length;
63
+ sorted.forEach(([size, png], i) => {
64
+ const at = 6 + i * 16;
65
+ header[at] = size === 256 ? 0 : size; header[at + 1] = size === 256 ? 0 : size;
66
+ header.writeUInt16LE(1, at + 4); header.writeUInt16LE(32, at + 6);
67
+ header.writeUInt32LE(png.length, at + 8); header.writeUInt32LE(offset, at + 12);
68
+ offset += png.length;
69
+ });
70
+ return Buffer.concat([header, ...sorted.map(([, png]) => png)]);
71
+ }
72
+
73
+ /** @param {Buffer[]} frames PNG buffers (16, 32, 64, 128, 256, 512, 1024). @returns {Buffer} ICNS file. */
74
+ export function packIcns(frames) {
75
+ const sorted = normalizeFrames(frames, { only: Object.keys(ICNS_TYPES).map(Number) });
76
+ const chunks = sorted.map(([size, png]) => {
77
+ const head = Buffer.alloc(8);
78
+ head.write(ICNS_TYPES[size], 0, "latin1"); head.writeUInt32BE(png.length + 8, 4);
79
+ return Buffer.concat([head, png]);
80
+ });
81
+ const total = 8 + chunks.reduce((n, c) => n + c.length, 0);
82
+ const head = Buffer.alloc(8);
83
+ head.write("icns", 0, "latin1"); head.writeUInt32BE(total, 4);
84
+ return Buffer.concat([head, ...chunks]);
85
+ }
86
+
87
+ function parseArgs(argv) {
88
+ const [kind, ...rest] = argv;
89
+ const opts = { kind, out: null, frames: [], dirs: [] };
90
+ for (let i = 0; i < rest.length; i++) {
91
+ const arg = rest[i], value = rest[i + 1];
92
+ if (!["--out", "--frame", "--dir"].includes(arg)) fail(`unknown icons argument: ${arg}`, "ICON_USAGE");
93
+ if (value === undefined || value.startsWith("--")) fail(`${arg} needs a value`, "ICON_USAGE");
94
+ i += 1;
95
+ if (arg === "--out") opts.out = value; else if (arg === "--frame") opts.frames.push(value); else opts.dirs.push(value);
96
+ }
97
+ if (!["ico", "icns"].includes(kind) || !opts.out) fail("usage: right-release icons ico|icns --out <file> --frame <PNG>... [--dir <folder>]", "ICON_USAGE");
98
+ for (const dir of opts.dirs) for (const name of readdirSync(dir).filter((n) => n.toLowerCase().endsWith(".png")).sort()) opts.frames.push(path.join(dir, name));
99
+ return opts;
100
+ }
101
+
102
+ export function main(argv = process.argv.slice(2), out = console) {
103
+ try {
104
+ const opts = parseArgs(argv);
105
+ const frames = opts.frames.map((file) => readFileSync(file));
106
+ const bytes = opts.kind === "ico" ? packIco(frames) : packIcns(frames);
107
+ mkdirSync(path.dirname(path.resolve(opts.out)), { recursive: true });
108
+ writeFileSync(opts.out, bytes);
109
+ out.log(JSON.stringify({ out: opts.out, kind: opts.kind, frames: opts.frames.length, bytes: bytes.length }));
110
+ return 0;
111
+ } catch (error) {
112
+ out.error(error.message);
113
+ return error.code === "ICON_USAGE" ? 2 : 1;
114
+ }
115
+ }
116
+
117
+ if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href) process.exit(main());
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env node
2
+ // `right-release init --name <App Name> --id <reverse.dns.bundle.id> [--slug <lowercase>] [--dir <root>] [--dry-run]`
3
+ // (bogusyogi/rightkit#385): renames the desktop app template into a real app, in place. Ports PostRight's
4
+ // crates/tools/src/init.rs. The template identity (`RightApp`, `rightapp`, `RIGHTAPP`, `app.rightsuite.rightapp`)
5
+ // is rewritten in text files; nothing else is touched and no gate or build is run.
6
+ import { readdirSync, readFileSync, writeFileSync } from "node:fs";
7
+ import path from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+
10
+ export const SKIP_DIRS = new Set(["node_modules", "target", ".git", "dist", "gen"]);
11
+ export const TEXT_EXT = new Set(["json", "toml", "mjs", "ts", "tsx", "rs", "md", "html", "css", "yaml", "yml", "sh"]);
12
+ // Assembled so this file is not rewritten into the new identity when the template is renamed.
13
+ const TITLE = ["Right", "App"].join("");
14
+ const LOWER = ["right", "app"].join("");
15
+ const UPPER = ["RIGHT", "APP"].join("");
16
+ const TEMPLATE_ID = `app.rightsuite.${LOWER}`;
17
+ const SELF = path.basename(fileURLToPath(import.meta.url));
18
+
19
+ export const INIT_USAGE = "usage: right-release init --name <Product Name> --id <reverse.dns.bundle.id> [--slug <lowercase>] [--dir <root>] [--dry-run]";
20
+
21
+ export function parseInitArgs(argv) {
22
+ const opts = { name: undefined, id: undefined, slug: undefined, dir: ".", dryRun: false };
23
+ for (let i = 0; i < argv.length; i += 1) {
24
+ const arg = argv[i];
25
+ if (["--name", "--id", "--slug", "--dir"].includes(arg)) {
26
+ const value = argv[i + 1];
27
+ if (value === undefined || value.startsWith("--")) throw new Error(`${arg} needs a value\n${INIT_USAGE}`);
28
+ opts[arg.slice(2)] = value;
29
+ i += 1;
30
+ } else if (arg === "--dry-run") opts.dryRun = true;
31
+ else throw new Error(`unknown init argument: ${arg}\n${INIT_USAGE}`);
32
+ }
33
+ if (!opts.name || !opts.id) throw new Error(INIT_USAGE);
34
+ return opts;
35
+ }
36
+
37
+ /** Validate and derive the identity. `slug` defaults to the lowercased name without spaces. */
38
+ export function resolveIdentity({ name, id, slug }) {
39
+ const resolvedSlug = slug ?? name.replaceAll(" ", "").toLowerCase();
40
+ if (!/^[A-Za-z][A-Za-z0-9 ]*$/.test(name)) throw new Error(`name must be letters, digits and spaces starting with a letter: ${name}\n${INIT_USAGE}`);
41
+ if (!/^[a-z][a-z0-9]*$/.test(resolvedSlug)) throw new Error(`slug must be lowercase letters and digits starting with a letter: ${resolvedSlug}\n${INIT_USAGE}`);
42
+ if (!/^[A-Za-z][A-Za-z0-9-]*(\.[A-Za-z][A-Za-z0-9-]*)+$/.test(id)) throw new Error(`id must be a reverse-DNS bundle id with at least two segments: ${id}\n${INIT_USAGE}`);
43
+ const warnings = [];
44
+ if (name.replaceAll(" ", "").toLowerCase() !== resolvedSlug) {
45
+ warnings.push(`--name "${name}" lowercased is not slug "${resolvedSlug}"; check productName and installer names.`);
46
+ }
47
+ if (!id.toLowerCase().endsWith(`.${resolvedSlug}`)) {
48
+ warnings.push(`--id "${id}" does not end in .${resolvedSlug}; check the bundle id.`);
49
+ }
50
+ return { name, id, slug: resolvedSlug, warnings };
51
+ }
52
+
53
+ export function walkTextFiles(dir, out = []) {
54
+ let entries;
55
+ try {
56
+ entries = readdirSync(dir, { withFileTypes: true });
57
+ } catch {
58
+ return out;
59
+ }
60
+ for (const entry of entries) {
61
+ const full = path.join(dir, entry.name);
62
+ if (entry.isDirectory()) {
63
+ if (!SKIP_DIRS.has(entry.name)) walkTextFiles(full, out);
64
+ } else if (entry.isFile() && TEXT_EXT.has(path.extname(entry.name).slice(1))) {
65
+ out.push(full);
66
+ }
67
+ }
68
+ return out;
69
+ }
70
+
71
+ /** Rewrite the template identity in one string; the bundle id first, then the three spellings. */
72
+ export function renameText(text, { name, id, slug }) {
73
+ return text
74
+ .replaceAll(TEMPLATE_ID, id)
75
+ .replaceAll(UPPER, slug.toUpperCase())
76
+ .replaceAll(TITLE, name)
77
+ .replaceAll(LOWER, slug);
78
+ }
79
+
80
+ /** Rename the template under `root`. Returns the identity and the changed file paths (relative, sorted). */
81
+ export function initTemplate(root, options) {
82
+ const identity = resolveIdentity(options);
83
+ const changed = [];
84
+ for (const file of walkTextFiles(root)) {
85
+ if (path.basename(file) === SELF) continue;
86
+ let before;
87
+ try {
88
+ before = readFileSync(file, "utf8");
89
+ } catch {
90
+ continue;
91
+ }
92
+ const after = renameText(before, identity);
93
+ if (after === before) continue;
94
+ if (!options.dryRun) writeFileSync(file, after);
95
+ changed.push(path.relative(root, file).split(path.sep).join("/"));
96
+ }
97
+ return { ...identity, changed: changed.sort() };
98
+ }
99
+
100
+ export function runInitCommand(argv, io = { stdout: console.log, stderr: console.error }) {
101
+ const opts = parseInitArgs(argv);
102
+ const result = initTemplate(path.resolve(opts.dir), opts);
103
+ for (const warning of result.warnings) io.stderr(`warning: ${warning}`);
104
+ io.stdout(`right-release init${opts.dryRun ? " --dry-run" : ""}: ${result.changed.length} file(s) ${opts.dryRun ? "would change" : "changed"}`);
105
+ for (const file of result.changed) io.stdout(` ${file}`);
106
+ return result;
107
+ }
108
+
109
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
110
+ try {
111
+ runInitCommand(process.argv.slice(2));
112
+ } catch (error) {
113
+ console.error(`right-release init: ${error.message}`);
114
+ process.exitCode = 1;
115
+ }
116
+ }
package/install-app.mjs CHANGED
@@ -282,6 +282,31 @@ async function keepPreviousBundle(run, fsImpl, bundle, installed) {
282
282
  return previous;
283
283
  }
284
284
 
285
+ // Exclusive per-destination install lock: a lock file beside the installed apps, created O_EXCL. A lock whose pid is dead is
286
+ // stale and taken over once. Held across recovery, staging, journal mutation and cleanup so two installers never share the
287
+ // fixed staging/journal paths.
288
+ const LOCK_NAME = ".rightkit-install.lock";
289
+ // Locks this process currently holds. A lock naming our own pid that is not in this set was leaked (a simulated or
290
+ // aborted install in this process), so it is stale exactly like a dead pid's.
291
+ const heldLocks = new Set();
292
+ function acquireInstallLock({ dest, fsImpl, pid, isAlive }) {
293
+ const lock = path.join(dest, LOCK_NAME);
294
+ for (let attempt = 0; attempt < 2; attempt += 1) {
295
+ try {
296
+ fsImpl.writeFileSync(lock, JSON.stringify({ pid }), { flag: "wx" });
297
+ heldLocks.add(lock);
298
+ return () => { heldLocks.delete(lock); try { fsImpl.rmSync(lock, { force: true }); } catch { /* best effort */ } };
299
+ } catch (error) {
300
+ if (error?.code !== "EEXIST") throw new InstallAppError("lock-failed", `cannot create install lock ${lock}: ${errText(error)}`);
301
+ let holder = null;
302
+ try { holder = JSON.parse(fsImpl.readFileSync(lock, "utf8")).pid; } catch { holder = null; }
303
+ if (Number.isInteger(holder) && isAlive(holder) && !(holder === pid && !heldLocks.has(lock))) throw new InstallAppError("install-locked", `another install holds ${lock} (pid ${holder})`);
304
+ try { fsImpl.rmSync(lock, { force: true }); } catch { /* retry decides */ }
305
+ }
306
+ }
307
+ throw new InstallAppError("install-locked", `could not take install lock ${lock}`);
308
+ }
309
+
285
310
  // Deterministic roll-forward/back of any interrupted install journal in `dest`. Idempotent.
286
311
  export async function recoverInstalls({ dest, appName = null, run, fsImpl, home, now, event, sleep, isAlive = defaultIsAlive, selfPid = process.pid, swapPaths = createSwapPaths() }) {
287
312
  let names = [];
@@ -357,7 +382,10 @@ export async function recoverInstalls({ dest, appName = null, run, fsImpl, home,
357
382
  export async function recoverApp(opts, deps = {}) {
358
383
  const { execFile = realExecFile, fsImpl = fs, home = os.homedir(), now = () => new Date(), sleep = (ms) => new Promise((r) => setTimeout(r, ms)), emit = () => {}, isAlive, platform = process.platform, env = process.env, swapPaths = createSwapPaths({ env }) } = deps;
359
384
  if (platform !== "darwin") throw new InstallAppError("unsupported", `install-app is not implemented on ${platform}`);
360
- const results = await recoverInstalls({ dest: opts.dest, appName: opts.appName, run: (c, a) => execFile(c, a), fsImpl, home, now, event: (name, data = {}) => emit({ event: name, ...data }), sleep, isAlive, swapPaths });
385
+ const release = acquireInstallLock({ dest: opts.dest, fsImpl, pid: process.pid, isAlive: isAlive ?? defaultIsAlive });
386
+ let results;
387
+ try { results = await recoverInstalls({ dest: opts.dest, appName: opts.appName, run: (c, a) => execFile(c, a), fsImpl, home, now, event: (name, data = {}) => emit({ event: name, ...data }), sleep, isAlive, swapPaths }); }
388
+ finally { release(); }
361
389
  emit({ event: "recover-done", dest: opts.dest, recovered: results.length });
362
390
  return { ok: true, recovered: results };
363
391
  }
@@ -389,6 +417,7 @@ export async function installApp(opts, deps = {}) {
389
417
  let mountPoint = null;
390
418
  let failedStage = "start";
391
419
  let swap = null; // set once the journal exists; tracks which renames happened
420
+ let releaseLock = () => {};
392
421
  let devOldRemoved = false; // a dev install removed the old bundle; nothing can put it back
393
422
 
394
423
  const removeQuiet = (p) => { if (p) { try { fsImpl.rmSync(p, { recursive: true, force: true }); } catch { /* best effort */ } } };
@@ -428,6 +457,7 @@ export async function installApp(opts, deps = {}) {
428
457
  await detach();
429
458
  cleanupExtract();
430
459
  } catch { /* exiting anyway */ }
460
+ releaseLock();
431
461
  exit(128 + (os.constants.signals[sig] || 0));
432
462
  })();
433
463
  };
@@ -455,6 +485,7 @@ export async function installApp(opts, deps = {}) {
455
485
  if (!fsImpl.existsSync(inputPath)) throw new InstallAppError(`${inputKind}-missing`, `${inputKind.toUpperCase()} not found: ${inputPath}`);
456
486
  if (!fsImpl.existsSync(opts.dest)) throw new InstallAppError("dest-missing", `destination not found: ${opts.dest}`);
457
487
 
488
+ releaseLock = acquireInstallLock({ dest: opts.dest, fsImpl, pid, isAlive });
458
489
  await recoverInstalls({ dest: opts.dest, appName: opts.appName, run, fsImpl, home, now, event, sleep, isAlive, selfPid: pid, swapPaths });
459
490
 
460
491
  let sourceApp = null;
@@ -511,8 +542,9 @@ export async function installApp(opts, deps = {}) {
511
542
  // Stage a verified copy on the destination volume so the swap is rename(2) only.
512
543
  failedStage = "stage";
513
544
  const base = appName.replace(/\.app$/, "");
514
- // Staging is never run from: it is verified, then renamed into place. The rollback copy is the fixed `<installed>.previous`.
515
- const staged = path.join(opts.dest, `.${base}.rightkit-install-${pid}.app`);
545
+ // Staging is never run from: it is verified, then renamed into place. Its name is fixed per app (no per-pid sibling), like the
546
+ // journal beside it; the rollback copy is the fixed `<installed>.previous`.
547
+ const staged = path.join(opts.dest, `.${base}.rightkit-install.app`);
516
548
  // Dev installs keep NO old copy of any kind (Orthic-Labs/rightkit#348): the old bundle is removed, never moved to `.previous`.
517
549
  const backup = hadInstalled && !dev ? previousPathOf(installed) : null;
518
550
  const journal = journalPathFor(opts.dest, appName);
@@ -634,6 +666,7 @@ export async function installApp(opts, deps = {}) {
634
666
  rollbackSwap();
635
667
  await detach();
636
668
  cleanupExtract();
669
+ releaseLock();
637
670
  }
638
671
  }
639
672