@rightkit/release 0.2.173 → 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.
Files changed (50) hide show
  1. package/README.md +188 -5
  2. package/bin/darwin/rightkit-swap +0 -0
  3. package/bin/darwin/rightkit-swap.sign.json +1 -1
  4. package/cli/right-release.mjs +34 -6
  5. package/command-spec.mjs +80 -0
  6. package/deps-cargo.mjs +55 -0
  7. package/deps.mjs +7 -0
  8. package/development-signing.mjs +135 -4
  9. package/development.mjs +12 -11
  10. package/dist-scan.mjs +102 -0
  11. package/dmg-build.mjs +53 -4
  12. package/icon-pack.mjs +117 -0
  13. package/in-place-install.mjs +32 -7
  14. package/init-command.mjs +116 -0
  15. package/install-app-windows.mjs +108 -17
  16. package/install-app.mjs +68 -11
  17. package/install-tauri-devid.mjs +106 -47
  18. package/local-publish.mjs +317 -29
  19. package/mac-bundle-signing.mjs +143 -1
  20. package/mac-signing-matrix.mjs +73 -6
  21. package/macho-approval-hash.mjs +181 -0
  22. package/macos-signing-session.mjs +12 -2
  23. package/managed-cargo.mjs +49 -1
  24. package/media-sidecar.mjs +20 -4
  25. package/mirror-root-artifact.mjs +69 -21
  26. package/npm-license-notices.mjs +159 -0
  27. package/ownership-dependency-allowlist.json +1 -0
  28. package/ownership.mjs +37 -4
  29. package/package-recipe-targets.mjs +180 -16
  30. package/package.json +1 -1
  31. package/pe-content.mjs +133 -0
  32. package/portable-development.mjs +9 -9
  33. package/publication-gate.mjs +70 -3
  34. package/publish-cargo.mjs +26 -7
  35. package/qa-build-features.mjs +7 -1
  36. package/recipe-build.mjs +346 -0
  37. package/release-dependencies-command.mjs +45 -0
  38. package/release-dependencies.mjs +60 -0
  39. package/release-switch-scan.mjs +17 -7
  40. package/release.mjs +98 -12
  41. package/rightkit-ownership.json +46 -2
  42. package/rightkit-signing-agent.mjs +10 -4
  43. package/rightkit-versions.json +11 -6
  44. package/runtime-budgets.mjs +28 -12
  45. package/stage-binary.mjs +1 -1
  46. package/stage-sidecar-command.mjs +36 -4
  47. package/stage-sidecar.mjs +926 -74
  48. package/test-command.mjs +190 -0
  49. package/toolchain-swift.mjs +239 -0
  50. package/web-build.mjs +67 -0
@@ -4,8 +4,10 @@ import path from "node:path";
4
4
  /**
5
5
  * The one install rule for every app, helper and dev or QA binary (Adrian, 2026-10-17: "No new id, no new folder"):
6
6
  * macOS and Windows tie privacy grants to the path and the code identity, so an install or update replaces the
7
- * copy at ONE fixed path. Rollback is a persistent `<name>.previous` beside it (rename it back). Nothing ever runs
8
- * from `.previous` or from the staging copy.
7
+ * copy at ONE fixed path. Release installs may keep a persistent `<name>.previous` beside it (rename it back).
8
+ * Dev and QA installs NEVER keep an old copy (Orthic-Labs/rightkit#348: "when installing dev, old copy must never be
9
+ * backedup"): they pass `previous: null` to `swapInPlace`, or use `replaceFileInPlace`, which has no backup at all.
10
+ * Nothing ever runs from `.previous` or from the staging copy.
9
11
  */
10
12
  export const PREVIOUS_SUFFIX = ".previous";
11
13
  export const STAGING_SUFFIX = ".staging";
@@ -20,12 +22,19 @@ const exists = (fs, file) => {
20
22
  };
21
23
 
22
24
  /**
23
- * Swap `staging` into `destination`: the old destination becomes `previous` (replacing any older `.previous`),
24
- * then `verify(destination)` runs. Any failure removes the new copy and renames `previous` back, so the fixed
25
- * path is never left empty. Renames only: no copy, no new path.
25
+ * Swap `staging` into `destination`, then `verify(destination)` runs.
26
+ *
27
+ * With a `previous` path (release): the old destination becomes `previous` (replacing any older `.previous`). Any
28
+ * failure removes the new copy and renames `previous` back, so the fixed path is never left empty. Renames only.
26
29
  * @returns {{destination: string, previous: string|null}} previous is null when nothing was installed before.
30
+ *
31
+ * With `previous: null` (dev and QA, NO backup of any kind): the old destination is removed and `staging` is renamed
32
+ * over the fixed path; a stale `<destination>.previous` left by an older RightKit is removed too. Any failure removes
33
+ * the new (absent or partial) copy and the staging copy and rethrows; the old copy is NOT restored, the user reinstalls
34
+ * from an older build. `previous` is always null in the result.
27
35
  */
28
36
  export function swapInPlace({ staging, destination, previous = previousPathOf(destination), verify = () => {}, fs = nodeFs }) {
37
+ if (previous === null) return replaceWithoutBackup({ staging, destination, verify, fs });
29
38
  if (previous === destination || staging === destination || staging === previous) throw new Error("swapInPlace needs three distinct paths");
30
39
  let movedAside = false;
31
40
  try {
@@ -45,12 +54,28 @@ export function swapInPlace({ staging, destination, previous = previousPathOf(de
45
54
  return { destination, previous: movedAside ? previous : null };
46
55
  }
47
56
 
48
- /** Copy one file to its fixed path in place: temp sibling, then rename, keeping `<file>.previous`. Returns the path. */
57
+ function replaceWithoutBackup({ staging, destination, verify, fs }) {
58
+ if (staging === destination) throw new Error("swapInPlace needs distinct staging and destination paths");
59
+ try {
60
+ if (exists(fs, destination)) fs.rmSync(destination, { recursive: true, force: true });
61
+ fs.renameSync(staging, destination);
62
+ verify(destination);
63
+ } catch (error) {
64
+ try { fs.rmSync(destination, { recursive: true, force: true }); } catch { /* keep the original error */ }
65
+ try { fs.rmSync(staging, { recursive: true, force: true }); } catch { /* best effort */ }
66
+ throw error;
67
+ }
68
+ // No old copy may survive a dev install, including one an older RightKit left behind.
69
+ try { fs.rmSync(previousPathOf(destination), { recursive: true, force: true }); } catch { /* best effort */ }
70
+ return { destination, previous: null };
71
+ }
72
+
73
+ /** Copy one dev or QA file to its fixed path in place: temp sibling, then rename. NO backup of the old file. Returns the path. */
49
74
  export function replaceFileInPlace(from, destination, { fs = nodeFs } = {}) {
50
75
  fs.mkdirSync(path.dirname(destination), { recursive: true });
51
76
  const staging = stagingPathOf(destination);
52
77
  fs.rmSync(staging, { force: true });
53
78
  fs.copyFileSync(from, staging);
54
- swapInPlace({ staging, destination });
79
+ swapInPlace({ staging, destination, previous: null, fs });
55
80
  return destination;
56
81
  }
@@ -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
+ }
@@ -5,15 +5,23 @@ import { spawnSync } from "./background-process.mjs";
5
5
  import { previousPathOf } from "./in-place-install.mjs";
6
6
 
7
7
  /**
8
- * `right-release install-app --installer <Setup.exe> --dest <install folder> --exe <App.exe>` on Windows (Orthic-Labs/rightkit#258).
8
+ * `right-release install-app --installer <Setup.exe> --dest <install folder> --exe <App.exe> [--payload <file>]... [--payload-manifest <json>]` on Windows (Orthic-Labs/rightkit#258).
9
9
  *
10
10
  * The same rule as the macOS install: the app is installed and updated IN PLACE at one fixed folder (`--dest`, the
11
- * folder the NSIS installer already owns, for example `%LOCALAPPDATA%\Programs\Pulse`). Rollback is one
12
- * `<dest>.previous` beside it (a copy of the folder as it was; replaced on each install; nothing runs from it), never a
13
- * versioned, digest or per-run folder. The silent installer is pinned to the fixed folder with `/D=` so it cannot choose another one.
11
+ * folder the NSIS installer already owns, for example `%LOCALAPPDATA%\Programs\Pulse`). The silent installer is pinned to the
12
+ * fixed folder with `/D=` so it cannot choose another one.
14
13
  *
15
- * Why a copy and not a rename: a running app holds its folder open on Windows, so a rename fails; the installer closes
16
- * the app and updates the same path in place instead. A failed install copies `<dest>.previous` back over `<dest>`.
14
+ * DATA SAFETY (Orthic-Labs/rightkit#348): the install folder can also hold USER DATA (CodeRight keeps coderight.db, its WAL,
15
+ * state and models in `%LOCALAPPDATA%\CodeRight`). So this module NEVER copies, restores, removes or overwrites the whole
16
+ * install folder. Rollback is PAYLOAD-ONLY, from an explicit allowlist of the files the installer itself writes:
17
+ * - the allowlist is `--payload <relative file>` (repeatable) and/or `--payload-manifest <json file>` (`{ "files": [...] }` or a bare array)
18
+ * plus the `--exe` file; entries are relative regular files inside `--dest`, no directories, wildcards, `..` or drive paths;
19
+ * - with NO declared payload the allowlist is just the `--exe` file (always backed up and restored; nothing else is);
20
+ * - no path segment from `--dest` down to an allowlisted file may be a symlink or junction (reparse point); one is refused before backup and before restore or removal;
21
+ * - the backup is `<dest>.previous`, holding only the allowlisted files that existed before (replaced on each install; nothing runs from it);
22
+ * - a failed install restores only allowlisted files that existed before and removes only allowlisted files the failed run newly wrote;
23
+ * it never touches any other file in `--dest`.
24
+ * Dev installs do not pass through this module: they keep no backup at all (see development.mjs, install-app.mjs --dev).
17
25
  */
18
26
  export class InstallAppWindowsError extends Error {
19
27
  constructor(code, message) { super(message); this.name = "InstallAppWindowsError"; this.code = code; }
@@ -26,6 +34,40 @@ export function windowsInstallArguments(dest) {
26
34
 
27
35
  const sha256 = (fs, file) => createHash("sha256").update(fs.readFileSync(file)).digest("hex");
28
36
 
37
+ const SEGMENT_BAD = /[<>:"|?*\0]/;
38
+
39
+ /** One allowlist entry as a normalized `a/b/c` relative file path, or a usage error. */
40
+ function normalizePayloadEntry(entry) {
41
+ if (typeof entry !== "string" || !entry.trim()) throw new InstallAppWindowsError("usage", "a payload entry must be a non-empty relative file path");
42
+ if (path.win32.isAbsolute(entry) || path.posix.isAbsolute(entry) || /^[A-Za-z]:/.test(entry)) throw new InstallAppWindowsError("usage", `payload entry ${JSON.stringify(entry)} must be relative to --dest, not absolute`);
43
+ const segments = entry.split(/[\\/]/);
44
+ if (segments.some((segment) => segment === "" || segment === "." || segment === ".." || SEGMENT_BAD.test(segment))) {
45
+ throw new InstallAppWindowsError("usage", `payload entry ${JSON.stringify(entry)} must be a plain relative file path (no directories, wildcards, "..", or empty segments)`);
46
+ }
47
+ return segments.join("/");
48
+ }
49
+
50
+ /** The payload allowlist: `--exe`, `--payload` entries and a `--payload-manifest` file, normalized and de-duplicated. Empty when nothing is declared. */
51
+ export function resolvePayloadAllowlist({ exe, payload = [], payloadManifest = null }, { fs = nodeFs } = {}) {
52
+ const declared = [...(Array.isArray(payload) ? payload : [payload])];
53
+ if (payloadManifest) {
54
+ let parsed;
55
+ try { parsed = JSON.parse(String(fs.readFileSync(payloadManifest, "utf8"))); }
56
+ catch (error) { throw new InstallAppWindowsError("usage", `--payload-manifest ${payloadManifest} cannot be read as JSON: ${error.message}`); }
57
+ const files = Array.isArray(parsed) ? parsed : parsed?.files;
58
+ if (!Array.isArray(files)) throw new InstallAppWindowsError("usage", `--payload-manifest ${payloadManifest} must be a JSON array of relative files, or { "files": [...] }`);
59
+ declared.push(...files);
60
+ }
61
+ // `--exe` alone is the minimal allowlist: the executable is always backed up and restored; data files never are.
62
+ const normalized = [...new Set([exe, ...declared].map(normalizePayloadEntry))];
63
+ const lower = new Set();
64
+ for (const entry of normalized) {
65
+ if (lower.has(entry.toLowerCase())) throw new InstallAppWindowsError("usage", `payload entry ${JSON.stringify(entry)} is listed twice (Windows paths are case-insensitive)`);
66
+ lower.add(entry.toLowerCase());
67
+ }
68
+ return normalized.sort();
69
+ }
70
+
29
71
  export function installAppWindows(opts, { platform = process.platform, fs = nodeFs, run = spawnSync, event = () => {} } = {}) {
30
72
  if (platform !== "win32") throw new InstallAppWindowsError("unsupported", `the Windows installer mode runs on win32, not ${platform}`);
31
73
  const { installer, dest, exe } = opts;
@@ -33,31 +75,80 @@ export function installAppWindows(opts, { platform = process.platform, fs = node
33
75
  if (!dest || !path.win32.isAbsolute(dest) || path.win32.parse(dest).root === dest) throw new InstallAppWindowsError("usage", "--dest must be the fixed absolute install folder, not a drive root");
34
76
  if (!exe || exe !== path.win32.basename(exe)) throw new InstallAppWindowsError("usage", "--exe must be the bare executable name inside --dest");
35
77
  if (!fs.existsSync(installer)) throw new InstallAppWindowsError("installer-missing", `installer not found: ${installer}`);
78
+ const allowlist = resolvePayloadAllowlist({ exe, payload: opts.payload, payloadManifest: opts.payloadManifest }, { fs });
36
79
  const previous = previousPathOf(dest);
37
80
  const installed = path.win32.join(dest, exe);
38
81
  const hadInstall = fs.existsSync(dest);
39
- event("start", { installer, dest, replacing: hadInstall });
40
- if (hadInstall) {
41
- // One rollback copy, replaced on every install.
82
+ const inDest = (relative) => path.win32.join(dest, ...relative.split("/"));
83
+ const inPrevious = (relative) => path.win32.join(previous, ...relative.split("/"));
84
+ const isFile = (file) => { try { return (fs.lstatSync ?? fs.statSync).call(fs, file).isFile(); } catch { return false; } };
85
+ // lstat every segment from dest down to the file; a symlink or junction anywhere in the chain is refused. A missing segment ends the walk.
86
+ const assertNoLinks = (relative) => {
87
+ const lstat = fs.lstatSync ?? fs.statSync;
88
+ let current = dest;
89
+ for (const segment of [null, ...relative.split("/")]) {
90
+ if (segment !== null) current = path.win32.join(current, segment);
91
+ let stat;
92
+ try { stat = lstat.call(fs, current); } catch { return; }
93
+ if (stat.isSymbolicLink?.() || stat.isReparsePoint?.()) throw new InstallAppWindowsError("usage", `${current} is a symlink or junction; refusing to back up, restore or remove payload entry ${relative} through it`);
94
+ }
95
+ };
96
+ event("start", { installer, dest, replacing: hadInstall, payloadFiles: allowlist.length });
97
+
98
+ // Backup: ONLY the allowlisted files that exist now, into `<dest>.previous`. The rest of --dest (user data) is never copied.
99
+ const backedUp = [];
100
+ const absentBefore = [];
101
+ let backupMade = false;
102
+ if (hadInstall && allowlist.length > 0) {
103
+ for (const relative of allowlist) {
104
+ const file = inDest(relative);
105
+ assertNoLinks(relative);
106
+ if (!fs.existsSync(file)) { absentBefore.push(relative); continue; }
107
+ if (!isFile(file)) throw new InstallAppWindowsError("usage", `payload entry ${relative} is not a regular file in ${dest}; refusing to back it up or roll it back`);
108
+ backedUp.push(relative);
109
+ }
110
+ // One payload-only rollback copy, replaced on every install (this also clears a whole-folder copy an older RightKit left).
42
111
  fs.rmSync(previous, { recursive: true, force: true });
43
- fs.cpSync(dest, previous, { recursive: true });
44
- event("previous-kept", { previous });
112
+ for (const relative of backedUp) {
113
+ fs.mkdirSync(path.win32.dirname(inPrevious(relative)), { recursive: true });
114
+ fs.copyFileSync(inDest(relative), inPrevious(relative));
115
+ }
116
+ backupMade = true;
117
+ event("previous-kept", { previous, files: backedUp.length, payloadOnly: true });
45
118
  }
119
+ // Roll back the payload and nothing else: restore allowlisted files that existed, remove allowlisted files this run newly wrote.
120
+ // Each file is restored independently; failures are collected and reported together, never masking the installer error.
121
+ const restoreFailures = [];
46
122
  const restore = () => {
47
- if (!hadInstall || !fs.existsSync(previous)) return false;
48
- fs.cpSync(previous, dest, { recursive: true, force: true });
123
+ if (!backupMade) return false;
124
+ for (const relative of backedUp) {
125
+ try {
126
+ assertNoLinks(relative);
127
+ fs.mkdirSync(path.win32.dirname(inDest(relative)), { recursive: true });
128
+ fs.copyFileSync(inPrevious(relative), inDest(relative));
129
+ } catch (error) { restoreFailures.push(`${relative}: ${error.message}`); }
130
+ }
131
+ for (const relative of absentBefore) {
132
+ try {
133
+ assertNoLinks(relative);
134
+ const file = inDest(relative);
135
+ if (fs.existsSync(file) && isFile(file)) fs.rmSync(file, { force: true });
136
+ } catch (error) { restoreFailures.push(`${relative}: ${error.message}`); }
137
+ }
49
138
  return true;
50
139
  };
140
+ const failureNote = () => (restoreFailures.length ? `; ROLLBACK INCOMPLETE, ${restoreFailures.length} file(s) failed: ${restoreFailures.join(" | ")}` : "");
141
+ const noRollback = "nothing was installed before, so nothing was rolled back and no other file in the install folder was touched";
51
142
  const result = run(installer, windowsInstallArguments(dest), { stdio: "inherit", windowsHide: true, shell: false });
52
143
  if (result.status !== 0) {
53
144
  const restored = restore();
54
- throw new InstallAppWindowsError("installer-failed", `installer exited ${result.status ?? "abnormally"}${result.error ? `: ${result.error.message}` : ""}; ${restored ? `restored ${dest} from ${previous}` : "nothing was installed before"}`);
145
+ throw new InstallAppWindowsError("installer-failed", `installer exited ${result.status ?? "abnormally"}${result.error ? `: ${result.error.message}` : ""}; ${restored ? `restored the ${backedUp.length} payload file(s) of ${dest} from ${previous}; no other file was touched` : noRollback}${failureNote()}`);
55
146
  }
56
147
  // The installer must have updated the fixed folder: the executable is at the same path as before, not in a new folder.
57
148
  if (!fs.existsSync(installed) || fs.statSync(installed).size === 0) {
58
149
  const restored = restore();
59
- throw new InstallAppWindowsError("path-changed", `the installer did not place ${exe} at the fixed path ${installed}; ${restored ? `restored from ${previous}` : "nothing was installed before"}`);
150
+ throw new InstallAppWindowsError("path-changed", `the installer did not place ${exe} at the fixed path ${installed}; ${restored ? `restored the ${backedUp.length} payload file(s) from ${previous}; no other file was touched` : noRollback}${failureNote()}`);
60
151
  }
61
- event("replaced", { installed, previous: hadInstall ? previous : null, sha256: sha256(fs, installed) });
62
- return { ok: true, installed, previous: hadInstall ? previous : null };
152
+ event("replaced", { installed, previous: backupMade ? previous : null, sha256: sha256(fs, installed) });
153
+ return { ok: true, installed, previous: backupMade ? previous : null };
63
154
  }
package/install-app.mjs CHANGED
@@ -22,11 +22,11 @@ export class InstallAppError extends Error {
22
22
  }
23
23
  }
24
24
 
25
- const VALUE_FLAGS = new Set(["--dmg", "--zip", "--app", "--app-name", "--bundle-id", "--dest", "--installer", "--exe"]);
25
+ const VALUE_FLAGS = new Set(["--dmg", "--zip", "--app", "--app-name", "--bundle-id", "--dest", "--installer", "--exe", "--payload", "--payload-manifest"]);
26
26
  const BOOL_FLAGS = new Set(["--relaunch", "--no-relaunch", "--json", "--dev", "--recover"]);
27
27
 
28
28
  export function parseArgs(argv) {
29
- const opts = { dmg: null, zip: null, app: null, installer: null, exe: null, dev: false, appName: null, bundleId: null, dest: "/Applications", relaunch: null, json: false, recover: false };
29
+ const opts = { dmg: null, zip: null, app: null, installer: null, exe: null, payload: [], payloadManifest: null, dev: false, appName: null, bundleId: null, dest: "/Applications", relaunch: null, json: false, recover: false };
30
30
  for (let i = 0; i < argv.length; i += 1) {
31
31
  const flag = argv[i];
32
32
  if (VALUE_FLAGS.has(flag)) {
@@ -40,6 +40,8 @@ export function parseArgs(argv) {
40
40
  else if (flag === "--bundle-id") opts.bundleId = value;
41
41
  else if (flag === "--installer") opts.installer = value;
42
42
  else if (flag === "--exe") opts.exe = value;
43
+ else if (flag === "--payload") opts.payload.push(value);
44
+ else if (flag === "--payload-manifest") opts.payloadManifest = value;
43
45
  else opts.dest = value;
44
46
  } else if (BOOL_FLAGS.has(flag)) {
45
47
  if (flag === "--json") opts.json = true;
@@ -51,11 +53,13 @@ export function parseArgs(argv) {
51
53
  }
52
54
  }
53
55
  if (opts.installer !== null) {
54
- // Windows: run the sealed NSIS installer in place at one fixed folder, keeping <dest>.previous.
56
+ // Windows: run the sealed NSIS installer in place at one fixed folder; rollback is payload-only (--payload / --payload-manifest), never the whole folder.
55
57
  if (opts.dmg !== null || opts.zip !== null || opts.app !== null || opts.recover || opts.dev) throw new InstallAppError("usage", "--installer cannot be combined with --dmg, --zip, --app, --dev or --recover");
56
58
  if (!argv.includes("--dest") || opts.exe === null) throw new InstallAppError("usage", "--installer requires --dest <fixed install folder> and --exe <App.exe>");
59
+ if (opts.payloadManifest !== null) opts.payloadManifest = path.resolve(opts.payloadManifest);
57
60
  return opts;
58
61
  }
62
+ if (opts.payload.length > 0 || opts.payloadManifest !== null) throw new InstallAppError("usage", "--payload and --payload-manifest only apply to --installer (the Windows install)");
59
63
  if (argv.includes("--relaunch") && argv.includes("--no-relaunch")) throw new InstallAppError("usage", "--relaunch and --no-relaunch are mutually exclusive");
60
64
  const inputs = [opts.dmg, opts.zip, opts.app].filter((v) => v !== null).length;
61
65
  if (opts.recover) {
@@ -278,6 +282,31 @@ async function keepPreviousBundle(run, fsImpl, bundle, installed) {
278
282
  return previous;
279
283
  }
280
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
+
281
310
  // Deterministic roll-forward/back of any interrupted install journal in `dest`. Idempotent.
282
311
  export async function recoverInstalls({ dest, appName = null, run, fsImpl, home, now, event, sleep, isAlive = defaultIsAlive, selfPid = process.pid, swapPaths = createSwapPaths() }) {
283
312
  let names = [];
@@ -311,7 +340,8 @@ export async function recoverInstalls({ dest, appName = null, run, fsImpl, home,
311
340
  let ok = j.phase === "verified";
312
341
  if (!ok) { try { await verifyBundle(run, j.installed, { assess: !j.dev, staple: false }); ok = true; } catch { ok = false; } }
313
342
  if (ok) {
314
- try { await keepPreviousBundle(run, fsImpl, j.staged, j.installed); action = "rolled-forward"; }
343
+ // A dev journal (from an older RightKit) never keeps the old bundle: the staged path is cleared below.
344
+ try { if (!j.dev) await keepPreviousBundle(run, fsImpl, j.staged, j.installed); action = "rolled-forward"; }
315
345
  catch { action = "rolled-forward-previous-pending"; keepJournal = true; keepStaged = true; }
316
346
  } else {
317
347
  swapBack(swapPaths, fsImpl, j.staged, j.installed); // staged now holds the bad new bundle
@@ -325,7 +355,7 @@ export async function recoverInstalls({ dest, appName = null, run, fsImpl, home,
325
355
  let ok = j.phase === "verified";
326
356
  if (!ok) { try { await verifyBundle(run, j.installed, { assess: !j.dev, staple: false }); ok = true; } catch { ok = false; } }
327
357
  if (ok) {
328
- try { await keepPreviousBundle(run, fsImpl, j.backup, j.installed); action = "rolled-forward"; }
358
+ try { if (j.dev) rm(j.backup); else await keepPreviousBundle(run, fsImpl, j.backup, j.installed); action = "rolled-forward"; }
329
359
  catch { action = "rolled-forward-previous-pending"; keepJournal = true; }
330
360
  } else {
331
361
  rm(j.installed);
@@ -352,7 +382,10 @@ export async function recoverInstalls({ dest, appName = null, run, fsImpl, home,
352
382
  export async function recoverApp(opts, deps = {}) {
353
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;
354
384
  if (platform !== "darwin") throw new InstallAppError("unsupported", `install-app is not implemented on ${platform}`);
355
- 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(); }
356
389
  emit({ event: "recover-done", dest: opts.dest, recovered: results.length });
357
390
  return { ok: true, recovered: results };
358
391
  }
@@ -384,6 +417,8 @@ export async function installApp(opts, deps = {}) {
384
417
  let mountPoint = null;
385
418
  let failedStage = "start";
386
419
  let swap = null; // set once the journal exists; tracks which renames happened
420
+ let releaseLock = () => {};
421
+ let devOldRemoved = false; // a dev install removed the old bundle; nothing can put it back
387
422
 
388
423
  const removeQuiet = (p) => { if (p) { try { fsImpl.rmSync(p, { recursive: true, force: true }); } catch { /* best effort */ } } };
389
424
  // Undo an unfinished swap using renames only. Sync on purpose so a signal handler can run it.
@@ -422,6 +457,7 @@ export async function installApp(opts, deps = {}) {
422
457
  await detach();
423
458
  cleanupExtract();
424
459
  } catch { /* exiting anyway */ }
460
+ releaseLock();
425
461
  exit(128 + (os.constants.signals[sig] || 0));
426
462
  })();
427
463
  };
@@ -449,6 +485,7 @@ export async function installApp(opts, deps = {}) {
449
485
  if (!fsImpl.existsSync(inputPath)) throw new InstallAppError(`${inputKind}-missing`, `${inputKind.toUpperCase()} not found: ${inputPath}`);
450
486
  if (!fsImpl.existsSync(opts.dest)) throw new InstallAppError("dest-missing", `destination not found: ${opts.dest}`);
451
487
 
488
+ releaseLock = acquireInstallLock({ dest: opts.dest, fsImpl, pid, isAlive });
452
489
  await recoverInstalls({ dest: opts.dest, appName: opts.appName, run, fsImpl, home, now, event, sleep, isAlive, selfPid: pid, swapPaths });
453
490
 
454
491
  let sourceApp = null;
@@ -505,9 +542,11 @@ export async function installApp(opts, deps = {}) {
505
542
  // Stage a verified copy on the destination volume so the swap is rename(2) only.
506
543
  failedStage = "stage";
507
544
  const base = appName.replace(/\.app$/, "");
508
- // Staging is never run from: it is verified, then renamed into place. The rollback copy is the fixed `<installed>.previous`.
509
- const staged = path.join(opts.dest, `.${base}.rightkit-install-${pid}.app`);
510
- const backup = hadInstalled ? previousPathOf(installed) : null;
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`);
548
+ // Dev installs keep NO old copy of any kind (Orthic-Labs/rightkit#348): the old bundle is removed, never moved to `.previous`.
549
+ const backup = hadInstalled && !dev ? previousPathOf(installed) : null;
511
550
  const journal = journalPathFor(opts.dest, appName);
512
551
  const journalData = { schema: 1, appName, pid, phase: "staging", dev, installed, backup, staged, mount: mountPoint, mountRoot };
513
552
  removeQuiet(staged);
@@ -543,6 +582,17 @@ export async function installApp(opts, deps = {}) {
543
582
  writeJournal(fsImpl, journal, { ...jd, phase: "swapping", swap: swapMode });
544
583
  fsImpl.renameSync(staged, installed);
545
584
  swap.stagedIn = true;
585
+ } else if (dev) {
586
+ // Dev: remove the old bundle, then rename the verified staged copy over the fixed path. No backup, no exchange (an exchange
587
+ // would park the old bundle at the staged path). A failure from here leaves the path absent or partial: reinstall from an older DMG.
588
+ swapMode = "replace";
589
+ swap.mode = "two-rename";
590
+ jd = { ...journalData, swap: "two-rename", backup: null };
591
+ writeJournal(fsImpl, journal, { ...jd, phase: "swapping" });
592
+ devOldRemoved = true;
593
+ fsImpl.rmSync(installed, { recursive: true, force: true });
594
+ fsImpl.renameSync(staged, installed);
595
+ swap.stagedIn = true;
546
596
  } else {
547
597
  jd = { ...journalData, swap: "atomic", backup: null, stagedIno: fsImpl.statSync(staged).ino };
548
598
  writeJournal(fsImpl, journal, { ...jd, phase: "swapping" });
@@ -586,6 +636,8 @@ export async function installApp(opts, deps = {}) {
586
636
  }
587
637
  if (!oldBundle || kept !== previousPathOf(installed)) { if (!oldBundle) removeQuiet(journal); }
588
638
  else removeQuiet(journal);
639
+ // A stale `.previous` from an older RightKit must not survive a dev install either.
640
+ if (dev) removeQuiet(previousPathOf(installed));
589
641
  swap = null;
590
642
  event("replaced", { installed, previous: kept, swap: swapMode });
591
643
 
@@ -601,7 +653,11 @@ export async function installApp(opts, deps = {}) {
601
653
  return result;
602
654
  } catch (error) {
603
655
  rollbackSwap();
604
- const reason = error instanceof InstallAppError ? error.message : errText(error);
656
+ let reason = error instanceof InstallAppError ? error.message : errText(error);
657
+ if (dev && devOldRemoved) {
658
+ reason = `${reason}; the dev install keeps no old copy, so the failed install was removed: reinstall from an older DMG`;
659
+ if (error instanceof InstallAppError) error.message = reason;
660
+ }
605
661
  event("failed", { stage: failedStage, code: error.code || "error", reason });
606
662
  if (!(error instanceof InstallAppError)) throw new InstallAppError("error", reason);
607
663
  throw error;
@@ -610,6 +666,7 @@ export async function installApp(opts, deps = {}) {
610
666
  rollbackSwap();
611
667
  await detach();
612
668
  cleanupExtract();
669
+ releaseLock();
613
670
  }
614
671
  }
615
672
 
@@ -708,7 +765,7 @@ export async function main(argv = process.argv.slice(2), io = {}) {
708
765
  stderr(`right-release install-app: ${error.message}`);
709
766
  stderr("usage: right-release install-app (--dmg <path.dmg> | --dev (--zip <path.zip> | --app <path.app> | --dmg <path.dmg>)) [--app-name Pulse.app] [--bundle-id dev.orthic.pulse] [--dest /Applications] [--relaunch|--no-relaunch] [--json]");
710
767
  stderr(" right-release install-app --recover [--dest /Applications] [--app-name Pulse.app] [--json]");
711
- stderr(" right-release install-app --installer <Setup.exe> --dest <fixed install folder> --exe <App.exe> [--json] (Windows: in place, keeps <dest>.previous)");
768
+ stderr(" right-release install-app --installer <Setup.exe> --dest <fixed install folder> --exe <App.exe> [--payload <file>]... [--payload-manifest <json>] [--json] (Windows: in place; payload-only rollback, never the whole folder)");
712
769
  stderr(" --dev installs a Developer ID-signed, NOT notarized build (skips spctl/stapler, clears quarantine on the new bundle).");
713
770
  return 2;
714
771
  }