@rightkit/release 0.2.139 → 0.2.141
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/README.md +6 -0
- package/committed-source.mjs +2 -1
- package/dmg-build.mjs +10 -3
- package/hdiutil-detach.mjs +42 -0
- package/install-app.mjs +295 -54
- package/macos-notarization.mjs +21 -1
- package/ownership.mjs +24 -0
- package/package.json +1 -1
- package/release.mjs +25 -4
- package/rightkit-versions.json +18 -12
- package/source-gate.mjs +21 -0
package/README.md
CHANGED
|
@@ -35,6 +35,8 @@ right-release install-app --dmg <path.dmg> [--app-name Pulse.app] [--bundle-id d
|
|
|
35
35
|
|
|
36
36
|
It mounts the image read-only and hidden (`-nobrowse`), verifies the app (`codesign --verify --deep --strict`, `spctl --assess`, `stapler validate`, bundle id, and Team ID against the installed copy; a Team ID change is refused) before touching the installed copy, quits the running instance with SIGTERM (15 s, then SIGKILL), moves the old bundle to `~/.Trash/<App> <timestamp>.app`, copies the new one with `ditto` (restoring the old bundle on failure), re-verifies, and relaunches only if it was running or `--relaunch` is given, using `open -g -j -b` (background, hidden, never activated). `--json` emits one lifecycle event per line (`start`, `verified`, `quit`, `replaced`, `relaunched`, `done`/`failed`). Win32 exits with "not implemented on win32". Exit codes: 0 ok, 1 failed, 2 usage, 3 unsupported platform.
|
|
37
37
|
|
|
38
|
+
Development builds (from `@rightkit/git` `devArtifact`, Developer ID-signed but not notarized) install with `--dev` and exactly one of `--dmg`, `--zip <path.zip>` (extracted with `ditto -x -k`), or `--app <path.app>`: `right-release install-app --dev --zip dev-mac.zip`. `--dev` still requires `codesign --verify --deep --strict`, a Team ID, no Team ID change, and a bundle-id match; it skips only `spctl` and `stapler`, removes `com.apple.quarantine` from the new bundle only, prints `DEV BUILD (not notarized)`, and sets `dev: true` on every JSON event.
|
|
39
|
+
|
|
38
40
|
## Entry points
|
|
39
41
|
|
|
40
42
|
Bin: `right-release` (`cli/right-release.mjs`). The package has no `exports` map. The CLI is the supported surface.
|
|
@@ -66,6 +68,10 @@ Swift (SwiftPM) apps are a first-class project type. `projectType: "swiftpm"` (c
|
|
|
66
68
|
|
|
67
69
|
macOS Swift apps use `right-release swift-package mac-app` (Info.plist from config, resources, frameworks, entitlements on the bundle only; nested code signed inside-out before the app).
|
|
68
70
|
|
|
71
|
+
Build a SwiftPM app with `right-release build --platform mac|win` (add `--config <path>` when the config is not `right-release.config.mjs`). Build is tier-neutral and never uploads, so it rejects `--tier` and takes no `--upload`/`--no-upload`; the old `release --tier patch --no-upload` form no longer works. Pick the tier only at upload: `right-release upload --release <sealed-id> --platform mac|win --tier patch|update`.
|
|
72
|
+
|
|
73
|
+
Set `targets.mac.notarize.appBeforePackage: true` (with `notarize.app` naming the `.app`) to notarize and staple the app itself right after signing and before the package step copies it into the DMG, so an app copied out of the DMG passes `stapler validate` offline. The DMG named by `notarize.file` is still notarized and stapled afterward; the app ticket receipt is `macos-app-notarization.json`. Without the flag, behavior is unchanged.
|
|
74
|
+
|
|
69
75
|
## Known issues
|
|
70
76
|
|
|
71
77
|
- `smol-toml` is pinned exactly. A consumer that adds another `smol-toml` gets a second copy (npm audit 2026-10-08, release finding).
|
package/committed-source.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { stripOwnLockRecords } from "./source-gate.mjs";
|
|
1
2
|
import { createHash } from "node:crypto";
|
|
2
3
|
import os from "node:os";
|
|
3
4
|
import path from "node:path";
|
|
@@ -193,7 +194,7 @@ export function assertCommittedCheckoutClean(root, { expectedCommit, expectedTre
|
|
|
193
194
|
const output = runGit(path.resolve(root), ["status", "--porcelain=v1", "-z", "--untracked-files=all"], {
|
|
194
195
|
encoding: "buffer", maxBuffer: MAX_TREE_BYTES, timeout: COMMAND_TIMEOUT_MS, runner, env: gitEnv(),
|
|
195
196
|
});
|
|
196
|
-
if (output.length) throw new CommittedSourceIncompleteError("committed ownership: protected release checkout has tracked or untracked changes");
|
|
197
|
+
if (stripOwnLockRecords(Buffer.from(output).toString("utf8")).length) throw new CommittedSourceIncompleteError("committed ownership: protected release checkout has tracked or untracked changes");
|
|
197
198
|
return true;
|
|
198
199
|
}
|
|
199
200
|
|
package/dmg-build.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
import { spawnSync } from "./background-process.mjs";
|
|
5
5
|
import { backgroundAlias, dmgDSStore } from "./dmg-layout.mjs";
|
|
6
|
+
import { detachWithRetrySync } from "./hdiutil-detach.mjs";
|
|
6
7
|
import { assertReleaseExecutionAllowed } from "./execution-policy.mjs";
|
|
7
8
|
|
|
8
9
|
const fail = (code, message) => Object.assign(new Error(message), { code });
|
|
@@ -17,7 +18,7 @@ const pair = (value, label, positive = false) => {
|
|
|
17
18
|
export function buildDmg({ app, out, volname, background, iconSize = 128, appPosition = [158, 231],
|
|
18
19
|
applicationsPosition = [492, 231], window = [660, 400], volumeIcon } = {},
|
|
19
20
|
{ platform = process.platform, run = spawnSync, makeAlias = backgroundAlias,
|
|
20
|
-
log = (event) => console.error(JSON.stringify(event)) } = {}) {
|
|
21
|
+
log = (event) => console.error(JSON.stringify(event)), sleep } = {}) {
|
|
21
22
|
if (platform !== "darwin") throw fail("DMG_NATIVE_HOST_REQUIRED", "DMG creation requires macOS");
|
|
22
23
|
if (!app || !out || !volname) throw fail("DMG_INVALID_OPTIONS", "--app, --out & --volname are required");
|
|
23
24
|
app = path.resolve(app); out = path.resolve(out);
|
|
@@ -55,6 +56,12 @@ export function buildDmg({ app, out, volname, background, iconSize = 128, appPos
|
|
|
55
56
|
if (result.error || result.status !== 0) throw fail("DMG_COMMAND_FAILED", `${tool} ${args[0]} failed: ${result.error?.message ?? result.stderr ?? result.status}`);
|
|
56
57
|
return result;
|
|
57
58
|
};
|
|
59
|
+
// Detach flakes with "Resource busy": bounded retry with backoff, then -force as the last resort.
|
|
60
|
+
const detach = () => {
|
|
61
|
+
const outcome = detachWithRetrySync({ mount, log, ...(sleep ? { sleep } : {}),
|
|
62
|
+
detach: (args) => run("/usr/bin/hdiutil", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], shell: false, timeout: 300000 }) });
|
|
63
|
+
if (!outcome.ok) throw fail("DMG_COMMAND_FAILED", `hdiutil detach failed after ${outcome.attempts} tries and -force: ${outcome.result?.error?.message ?? outcome.result?.stderr ?? outcome.result?.status}`);
|
|
64
|
+
};
|
|
58
65
|
log({ event: "dmg-build-started", app, out, volname });
|
|
59
66
|
try {
|
|
60
67
|
mkdirSync(stage); mkdirSync(mount);
|
|
@@ -78,7 +85,7 @@ export function buildDmg({ app, out, volname, background, iconSize = 128, appPos
|
|
|
78
85
|
const alias = relative ? makeAlias({ mount, volname, relative }) : undefined;
|
|
79
86
|
writeFileSync(path.join(mount, ".DS_Store"), dmgDSStore({ appName: path.basename(app), iconSize,
|
|
80
87
|
appPosition, applicationsPosition, window, alias }));
|
|
81
|
-
|
|
88
|
+
detach(); attached = false;
|
|
82
89
|
command("/usr/bin/hdiutil", ["convert", rw, "-format", "UDZO", "-imagekey", "zlib-level=9", "-o", compressed]);
|
|
83
90
|
command("/usr/bin/hdiutil", ["verify", compressed]);
|
|
84
91
|
renameSync(compressed, out);
|
|
@@ -89,7 +96,7 @@ export function buildDmg({ app, out, volname, background, iconSize = 128, appPos
|
|
|
89
96
|
throw error;
|
|
90
97
|
} finally {
|
|
91
98
|
if (attached || attaching) {
|
|
92
|
-
try {
|
|
99
|
+
try { detach(); attached = false; attaching = false; }
|
|
93
100
|
catch (error) { log({ event: "dmg-cleanup-failed", mount, work, message: error.message }); }
|
|
94
101
|
}
|
|
95
102
|
// Never recurse through a mount that failed to detach.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Bounded detach for hdiutil mounts. Spotlight, Finder, and quick-look helpers can hold a freshly
|
|
2
|
+
// written volume for a moment, so a first `hdiutil detach` regularly fails with "Resource busy".
|
|
3
|
+
// Retry a fixed number of times with exponential backoff, then fall back to `-force` once.
|
|
4
|
+
|
|
5
|
+
export const DETACH_TRIES = 5;
|
|
6
|
+
export const DETACH_BACKOFF_MS = Object.freeze([500, 1000, 2000, 4000]);
|
|
7
|
+
|
|
8
|
+
const blockingSleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
9
|
+
const describe = (result) => String(result?.error?.message ?? result?.stderr ?? result?.status ?? "failed").trim();
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Synchronous. `detach(args)` runs `hdiutil <args>` and returns `{ status, stderr?, error? }`.
|
|
13
|
+
* Returns `{ ok, attempts, forced }`; throws only through the caller when `ok` is false.
|
|
14
|
+
*/
|
|
15
|
+
export function detachWithRetrySync({ detach, mount, sleep = blockingSleep, log = () => {}, tries = DETACH_TRIES, backoffMs = DETACH_BACKOFF_MS }) {
|
|
16
|
+
let last;
|
|
17
|
+
for (let attempt = 1; attempt <= tries; attempt += 1) {
|
|
18
|
+
last = detach(["detach", mount]);
|
|
19
|
+
if (!last?.error && last?.status === 0) return { ok: true, attempts: attempt, forced: false, result: last };
|
|
20
|
+
const wait = backoffMs[Math.min(attempt - 1, backoffMs.length - 1)];
|
|
21
|
+
log({ event: "hdiutil-detach-retry", mount, attempt, of: tries, retryInMs: attempt < tries ? wait : 0, message: describe(last) });
|
|
22
|
+
if (attempt < tries) sleep(wait);
|
|
23
|
+
}
|
|
24
|
+
log({ event: "hdiutil-detach-force", mount, message: describe(last) });
|
|
25
|
+
const forced = detach(["detach", "-force", mount]);
|
|
26
|
+
return { ok: !forced?.error && forced?.status === 0, attempts: tries, forced: true, result: forced };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Async twin for promise-based runners: `detach(args)` resolves on success and rejects on failure. */
|
|
30
|
+
export async function detachWithRetry({ detach, mount, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), log = () => {}, tries = DETACH_TRIES, backoffMs = DETACH_BACKOFF_MS }) {
|
|
31
|
+
let last;
|
|
32
|
+
for (let attempt = 1; attempt <= tries; attempt += 1) {
|
|
33
|
+
try { await detach(["detach", mount]); return { ok: true, attempts: attempt, forced: false }; }
|
|
34
|
+
catch (error) { last = error; }
|
|
35
|
+
const wait = backoffMs[Math.min(attempt - 1, backoffMs.length - 1)];
|
|
36
|
+
log({ event: "hdiutil-detach-retry", mount, attempt, of: tries, retryInMs: attempt < tries ? wait : 0, message: String(last?.message ?? last) });
|
|
37
|
+
if (attempt < tries) await sleep(wait);
|
|
38
|
+
}
|
|
39
|
+
log({ event: "hdiutil-detach-force", mount, message: String(last?.message ?? last) });
|
|
40
|
+
try { await detach(["detach", "-force", mount]); return { ok: true, attempts: tries, forced: true }; }
|
|
41
|
+
catch (error) { return { ok: false, attempts: tries, forced: true, error }; }
|
|
42
|
+
}
|
package/install-app.mjs
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// Sanctioned, non-intrusive local app install from a signed DMG (macOS)
|
|
2
|
+
// Sanctioned, non-intrusive local app install from a signed DMG (macOS); --dev also accepts a
|
|
3
|
+
// Developer ID-signed, not-notarized zip or .app.
|
|
3
4
|
// Never uses a shell, osascript, AppleEvents, or foreground activation.
|
|
4
5
|
import { execFile as nodeExecFile } from "./background-process.mjs";
|
|
6
|
+
import { detachWithRetry } from "./hdiutil-detach.mjs";
|
|
5
7
|
import fs from "node:fs";
|
|
6
8
|
import os from "node:os";
|
|
7
9
|
import path from "node:path";
|
|
@@ -17,11 +19,11 @@ export class InstallAppError extends Error {
|
|
|
17
19
|
}
|
|
18
20
|
}
|
|
19
21
|
|
|
20
|
-
const VALUE_FLAGS = new Set(["--dmg", "--app-name", "--bundle-id", "--dest"]);
|
|
21
|
-
const BOOL_FLAGS = new Set(["--relaunch", "--no-relaunch", "--json"]);
|
|
22
|
+
const VALUE_FLAGS = new Set(["--dmg", "--zip", "--app", "--app-name", "--bundle-id", "--dest"]);
|
|
23
|
+
const BOOL_FLAGS = new Set(["--relaunch", "--no-relaunch", "--json", "--dev", "--recover"]);
|
|
22
24
|
|
|
23
25
|
export function parseArgs(argv) {
|
|
24
|
-
const opts = { dmg: null, appName: null, bundleId: null, dest: "/Applications", relaunch: null, json: false };
|
|
26
|
+
const opts = { dmg: null, zip: null, app: null, dev: false, appName: null, bundleId: null, dest: "/Applications", relaunch: null, json: false, recover: false };
|
|
25
27
|
for (let i = 0; i < argv.length; i += 1) {
|
|
26
28
|
const flag = argv[i];
|
|
27
29
|
if (VALUE_FLAGS.has(flag)) {
|
|
@@ -29,25 +31,44 @@ export function parseArgs(argv) {
|
|
|
29
31
|
if (value === undefined || value.startsWith("--")) throw new InstallAppError("usage", `${flag} requires a value`);
|
|
30
32
|
i += 1;
|
|
31
33
|
if (flag === "--dmg") opts.dmg = value;
|
|
34
|
+
else if (flag === "--zip") opts.zip = value;
|
|
35
|
+
else if (flag === "--app") opts.app = value;
|
|
32
36
|
else if (flag === "--app-name") opts.appName = value;
|
|
33
37
|
else if (flag === "--bundle-id") opts.bundleId = value;
|
|
34
38
|
else opts.dest = value;
|
|
35
39
|
} else if (BOOL_FLAGS.has(flag)) {
|
|
36
40
|
if (flag === "--json") opts.json = true;
|
|
41
|
+
else if (flag === "--dev") opts.dev = true;
|
|
42
|
+
else if (flag === "--recover") opts.recover = true;
|
|
37
43
|
else opts.relaunch = flag === "--relaunch";
|
|
38
44
|
} else {
|
|
39
45
|
throw new InstallAppError("usage", `unknown argument: ${flag}`);
|
|
40
46
|
}
|
|
41
47
|
}
|
|
42
48
|
if (argv.includes("--relaunch") && argv.includes("--no-relaunch")) throw new InstallAppError("usage", "--relaunch and --no-relaunch are mutually exclusive");
|
|
43
|
-
|
|
44
|
-
if (
|
|
49
|
+
const inputs = [opts.dmg, opts.zip, opts.app].filter((v) => v !== null).length;
|
|
50
|
+
if (opts.recover) {
|
|
51
|
+
if (inputs > 0 || opts.dev || opts.relaunch !== null || opts.bundleId !== null) throw new InstallAppError("usage", "--recover only accepts --dest, --app-name, and --json");
|
|
52
|
+
if (opts.appName !== null && (!/^[^/\0]+\.app$/.test(opts.appName) || opts.appName === ".app" || opts.appName.includes(".."))) {
|
|
53
|
+
throw new InstallAppError("usage", "--app-name must be a bare bundle name ending in .app");
|
|
54
|
+
}
|
|
55
|
+
if (!path.isAbsolute(opts.dest)) opts.dest = path.resolve(opts.dest);
|
|
56
|
+
return opts;
|
|
57
|
+
}
|
|
58
|
+
if (inputs === 0) throw new InstallAppError("usage", opts.dev ? "one of --dmg <path.dmg>, --zip <path.zip>, or --app <path.app> is required" : "--dmg <path.dmg> is required");
|
|
59
|
+
if (inputs > 1) throw new InstallAppError("usage", "--dmg, --zip, and --app are mutually exclusive");
|
|
60
|
+
if ((opts.zip !== null || opts.app !== null) && !opts.dev) throw new InstallAppError("usage", "--zip and --app require --dev (only DMGs are installable as release builds)");
|
|
61
|
+
if (opts.dmg !== null && !opts.dmg.toLowerCase().endsWith(".dmg")) throw new InstallAppError("usage", "--dmg must name a .dmg file");
|
|
62
|
+
if (opts.zip !== null && !opts.zip.toLowerCase().endsWith(".zip")) throw new InstallAppError("usage", "--zip must name a .zip file");
|
|
63
|
+
if (opts.app !== null && !opts.app.replace(/\/+$/, "").toLowerCase().endsWith(".app")) throw new InstallAppError("usage", "--app must name a .app bundle");
|
|
45
64
|
if (opts.appName !== null && (!/^[^/\0]+\.app$/.test(opts.appName) || opts.appName === ".app" || opts.appName.includes(".."))) {
|
|
46
65
|
throw new InstallAppError("usage", "--app-name must be a bare bundle name ending in .app");
|
|
47
66
|
}
|
|
48
67
|
if (opts.bundleId !== null && !/^[A-Za-z0-9][A-Za-z0-9.-]*$/.test(opts.bundleId)) throw new InstallAppError("usage", "--bundle-id is not a valid bundle identifier");
|
|
49
68
|
if (!path.isAbsolute(opts.dest)) opts.dest = path.resolve(opts.dest);
|
|
50
|
-
opts.dmg = path.resolve(opts.dmg);
|
|
69
|
+
if (opts.dmg !== null) opts.dmg = path.resolve(opts.dmg);
|
|
70
|
+
if (opts.zip !== null) opts.zip = path.resolve(opts.zip);
|
|
71
|
+
if (opts.app !== null) opts.app = path.resolve(opts.app);
|
|
51
72
|
return opts;
|
|
52
73
|
}
|
|
53
74
|
|
|
@@ -152,6 +173,98 @@ function errText(error) {
|
|
|
152
173
|
return `${error?.stderr || ""}${error?.stdout || ""}`.trim() || error?.message || String(error);
|
|
153
174
|
}
|
|
154
175
|
|
|
176
|
+
// --- crash-safe swap: journal + recovery -----------------------------------
|
|
177
|
+
|
|
178
|
+
const JOURNAL_RE = /^\.(.+)\.rightkit-install\.json$/;
|
|
179
|
+
|
|
180
|
+
export function journalPathFor(dest, appName) {
|
|
181
|
+
return path.join(dest, `.${appName.replace(/\.app$/, "")}.rightkit-install.json`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Atomic write: temp file, fsync, rename, then a best-effort fsync of the directory.
|
|
185
|
+
export function writeJournal(fsImpl, file, data) {
|
|
186
|
+
const tmp = `${file}.tmp`;
|
|
187
|
+
const fd = fsImpl.openSync(tmp, "w");
|
|
188
|
+
try { fsImpl.writeSync(fd, `${JSON.stringify(data)}\n`); fsImpl.fsyncSync(fd); }
|
|
189
|
+
finally { fsImpl.closeSync(fd); }
|
|
190
|
+
fsImpl.renameSync(tmp, file);
|
|
191
|
+
try {
|
|
192
|
+
const dfd = fsImpl.openSync(path.dirname(file), "r");
|
|
193
|
+
try { fsImpl.fsyncSync(dfd); } finally { fsImpl.closeSync(dfd); }
|
|
194
|
+
} catch { /* directory fsync is best effort */ }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const defaultIsAlive = (pid) => {
|
|
198
|
+
try { process.kill(pid, 0); return true; } catch (e) { return e.code === "EPERM"; }
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
async function trashBundle(run, fsImpl, home, now, bundle, appName) {
|
|
202
|
+
const trashDir = path.join(home, ".Trash");
|
|
203
|
+
fsImpl.mkdirSync(trashDir, { recursive: true });
|
|
204
|
+
const trashed = path.join(trashDir, trashName(appName, now()));
|
|
205
|
+
await moveAside(run, fsImpl, bundle, trashed);
|
|
206
|
+
return trashed;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Deterministic roll-forward/back of any interrupted install journal in `dest`. Idempotent.
|
|
210
|
+
export async function recoverInstalls({ dest, appName = null, run, fsImpl, home, now, event, sleep, isAlive = defaultIsAlive, selfPid = process.pid }) {
|
|
211
|
+
let names = [];
|
|
212
|
+
try { names = fsImpl.readdirSync(dest).filter((n) => JOURNAL_RE.test(n)); } catch { return []; }
|
|
213
|
+
if (appName) names = names.filter((n) => n === path.basename(journalPathFor(dest, appName)));
|
|
214
|
+
const results = [];
|
|
215
|
+
for (const name of names.sort()) {
|
|
216
|
+
const file = path.join(dest, name);
|
|
217
|
+
let j;
|
|
218
|
+
try { j = JSON.parse(fsImpl.readFileSync(file, "utf8")); } catch {
|
|
219
|
+
event("recovery-journal-unreadable", { journal: file });
|
|
220
|
+
try { fsImpl.rmSync(file, { force: true }); } catch { /* best effort */ }
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (j.pid && j.pid !== selfPid && isAlive(j.pid)) { event("recovery-skipped", { journal: file, pid: j.pid, reason: "install still running" }); continue; }
|
|
224
|
+
const has = (p) => Boolean(p) && fsImpl.existsSync(p);
|
|
225
|
+
const rm = (p) => { if (p) { try { fsImpl.rmSync(p, { recursive: true, force: true }); } catch { /* best effort */ } } };
|
|
226
|
+
let action;
|
|
227
|
+
let keepJournal = false;
|
|
228
|
+
try {
|
|
229
|
+
if (!has(j.installed)) {
|
|
230
|
+
if (has(j.backup)) { fsImpl.renameSync(j.backup, j.installed); action = "restored-backup"; }
|
|
231
|
+
else action = "cleared";
|
|
232
|
+
} else if (has(j.backup)) {
|
|
233
|
+
let ok = j.phase === "verified";
|
|
234
|
+
if (!ok) { try { await verifyBundle(run, j.installed, { assess: !j.dev, staple: false }); ok = true; } catch { ok = false; } }
|
|
235
|
+
if (ok) {
|
|
236
|
+
try { await trashBundle(run, fsImpl, home, now, j.backup, path.basename(j.installed)); action = "rolled-forward"; }
|
|
237
|
+
catch { action = "rolled-forward-trash-pending"; keepJournal = true; }
|
|
238
|
+
} else {
|
|
239
|
+
rm(j.installed);
|
|
240
|
+
fsImpl.renameSync(j.backup, j.installed);
|
|
241
|
+
action = "rolled-back";
|
|
242
|
+
}
|
|
243
|
+
} else action = "cleared";
|
|
244
|
+
} catch (error) {
|
|
245
|
+
event("recovery-failed", { journal: file, reason: errText(error) });
|
|
246
|
+
continue; // leave the journal for the next run
|
|
247
|
+
}
|
|
248
|
+
rm(j.staged);
|
|
249
|
+
if (j.mount && has(j.mount)) {
|
|
250
|
+
const out = await detachWithRetry({ mount: j.mount, log: (e) => event(e.event, e), sleep, detach: (args) => run("/usr/bin/hdiutil", args) });
|
|
251
|
+
if (out.ok) rm(j.mountRoot);
|
|
252
|
+
} else rm(j.mountRoot);
|
|
253
|
+
if (!keepJournal) rm(file);
|
|
254
|
+
event("recovered", { journal: file, action, installed: j.installed });
|
|
255
|
+
results.push({ journal: file, action, installed: j.installed });
|
|
256
|
+
}
|
|
257
|
+
return results;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export async function recoverApp(opts, deps = {}) {
|
|
261
|
+
const { execFile = realExecFile, fsImpl = fs, home = os.homedir(), now = () => new Date(), sleep = (ms) => new Promise((r) => setTimeout(r, ms)), emit = () => {}, isAlive, platform = process.platform } = deps;
|
|
262
|
+
if (platform !== "darwin") throw new InstallAppError("unsupported", `install-app is not implemented on ${platform}`);
|
|
263
|
+
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 });
|
|
264
|
+
emit({ event: "recover-done", dest: opts.dest, recovered: results.length });
|
|
265
|
+
return { ok: true, recovered: results };
|
|
266
|
+
}
|
|
267
|
+
|
|
155
268
|
export async function installApp(opts, deps = {}) {
|
|
156
269
|
const {
|
|
157
270
|
execFile = realExecFile,
|
|
@@ -163,62 +276,149 @@ export async function installApp(opts, deps = {}) {
|
|
|
163
276
|
kill = (pid, sig) => process.kill(pid, sig),
|
|
164
277
|
emit = () => {},
|
|
165
278
|
tmpdir = os.tmpdir(),
|
|
279
|
+
pid = process.pid,
|
|
280
|
+
isAlive = defaultIsAlive,
|
|
281
|
+
signalTarget = process,
|
|
282
|
+
exit = (code) => process.exit(code),
|
|
166
283
|
} = deps;
|
|
167
|
-
const
|
|
284
|
+
const dev = opts.dev === true;
|
|
285
|
+
const event = (name, data = {}) => emit({ event: name, ...(dev ? { dev: true } : {}), ...data });
|
|
168
286
|
const run = (cmd, args) => execFile(cmd, args);
|
|
169
287
|
let mountRoot = null;
|
|
288
|
+
let extractRoot = null;
|
|
170
289
|
let mounted = false;
|
|
171
290
|
let mountPoint = null;
|
|
172
291
|
let failedStage = "start";
|
|
292
|
+
let swap = null; // set once the journal exists; tracks which renames happened
|
|
293
|
+
|
|
294
|
+
const removeQuiet = (p) => { if (p) { try { fsImpl.rmSync(p, { recursive: true, force: true }); } catch { /* best effort */ } } };
|
|
295
|
+
// Undo an unfinished swap using renames only. Sync on purpose so a signal handler can run it.
|
|
296
|
+
const rollbackSwap = () => {
|
|
297
|
+
if (!swap) return;
|
|
298
|
+
const s = swap;
|
|
299
|
+
swap = null;
|
|
300
|
+
try {
|
|
301
|
+
if (!s.final) {
|
|
302
|
+
if (s.stagedIn) removeQuiet(s.installed);
|
|
303
|
+
if (s.backedUp && !fsImpl.existsSync(s.installed)) fsImpl.renameSync(s.backup, s.installed);
|
|
304
|
+
removeQuiet(s.staged);
|
|
305
|
+
removeQuiet(s.journal);
|
|
306
|
+
} else {
|
|
307
|
+
removeQuiet(s.staged);
|
|
308
|
+
}
|
|
309
|
+
} catch { /* recovery on the next run finishes the job */ }
|
|
310
|
+
};
|
|
311
|
+
let signaled = false;
|
|
312
|
+
const onSignal = (sig) => {
|
|
313
|
+
if (signaled) return;
|
|
314
|
+
signaled = true;
|
|
315
|
+
(async () => {
|
|
316
|
+
try {
|
|
317
|
+
rollbackSwap();
|
|
318
|
+
event("interrupted", { signal: sig });
|
|
319
|
+
await detach();
|
|
320
|
+
cleanupExtract();
|
|
321
|
+
} catch { /* exiting anyway */ }
|
|
322
|
+
exit(128 + (os.constants.signals[sig] || 0));
|
|
323
|
+
})();
|
|
324
|
+
};
|
|
325
|
+
const handlers = { SIGINT: () => onSignal("SIGINT"), SIGTERM: () => onSignal("SIGTERM") };
|
|
173
326
|
|
|
174
327
|
const detach = async () => {
|
|
175
328
|
if (!mounted) return;
|
|
176
329
|
mounted = false;
|
|
177
|
-
|
|
178
|
-
catch { try { await run("/usr/bin/hdiutil", ["detach", "-force", mountPoint]); } catch { /* best effort */ } }
|
|
330
|
+
await detachWithRetry({ mount: mountPoint, log: (e) => event(e.event, e), sleep, detach: (args) => run("/usr/bin/hdiutil", args) });
|
|
179
331
|
try { fsImpl.rmSync(mountRoot, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
180
332
|
};
|
|
333
|
+
const cleanupExtract = () => {
|
|
334
|
+
if (!extractRoot) return;
|
|
335
|
+
try { fsImpl.rmSync(extractRoot, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
336
|
+
extractRoot = null;
|
|
337
|
+
};
|
|
181
338
|
|
|
182
339
|
try {
|
|
183
340
|
if (platform !== "darwin") throw new InstallAppError("unsupported", `install-app is not implemented on ${platform}`);
|
|
184
|
-
|
|
185
|
-
|
|
341
|
+
for (const [sig, fn] of Object.entries(handlers)) signalTarget.on(sig, fn);
|
|
342
|
+
const inputPath = opts.dmg ?? opts.zip ?? opts.app;
|
|
343
|
+
const inputKind = opts.dmg ? "dmg" : opts.zip ? "zip" : "app";
|
|
344
|
+
event("start", { [inputKind]: inputPath, dest: opts.dest });
|
|
345
|
+
if (dev) event("dev-build", { note: "DEV BUILD (not notarized)" });
|
|
346
|
+
if (!fsImpl.existsSync(inputPath)) throw new InstallAppError(`${inputKind}-missing`, `${inputKind.toUpperCase()} not found: ${inputPath}`);
|
|
186
347
|
if (!fsImpl.existsSync(opts.dest)) throw new InstallAppError("dest-missing", `destination not found: ${opts.dest}`);
|
|
187
348
|
|
|
188
|
-
|
|
189
|
-
mountRoot = fsImpl.mkdtempSync(path.join(tmpdir, "right-install-app-"));
|
|
190
|
-
const attached = await run("/usr/bin/hdiutil", ["attach", "-nobrowse", "-readonly", "-noautoopen", "-mountrandom", mountRoot, "-plist", opts.dmg]);
|
|
191
|
-
const mounts = mountPointsFromAttach(attached.stdout);
|
|
192
|
-
if (mounts.length === 0) throw new InstallAppError("attach", "DMG attached but exposed no mount point");
|
|
193
|
-
mountPoint = mounts[0];
|
|
194
|
-
mounted = true;
|
|
349
|
+
await recoverInstalls({ dest: opts.dest, appName: opts.appName, run, fsImpl, home, now, event, sleep, isAlive, selfPid: pid });
|
|
195
350
|
|
|
196
351
|
let sourceApp = null;
|
|
197
|
-
|
|
198
|
-
const apps = fsImpl.readdirSync(
|
|
199
|
-
if (opts.appName)
|
|
200
|
-
|
|
201
|
-
|
|
352
|
+
const pickApp = (dir, label) => {
|
|
353
|
+
const apps = fsImpl.readdirSync(dir).filter((n) => n.endsWith(".app"));
|
|
354
|
+
if (opts.appName) return apps.includes(opts.appName) ? path.join(dir, opts.appName) : null;
|
|
355
|
+
if (apps.length === 1) return path.join(dir, apps[0]);
|
|
356
|
+
if (apps.length > 1) throw new InstallAppError("ambiguous-app", `${label} contains multiple apps (${apps.join(", ")}); pass --app-name`);
|
|
357
|
+
return null;
|
|
358
|
+
};
|
|
359
|
+
if (opts.dmg) {
|
|
360
|
+
failedStage = "attach";
|
|
361
|
+
mountRoot = fsImpl.mkdtempSync(path.join(tmpdir, "right-install-app-"));
|
|
362
|
+
const attached = await run("/usr/bin/hdiutil", ["attach", "-nobrowse", "-readonly", "-noautoopen", "-mountrandom", mountRoot, "-plist", opts.dmg]);
|
|
363
|
+
const mounts = mountPointsFromAttach(attached.stdout);
|
|
364
|
+
if (mounts.length === 0) throw new InstallAppError("attach", "DMG attached but exposed no mount point");
|
|
365
|
+
mountPoint = mounts[0];
|
|
366
|
+
mounted = true;
|
|
367
|
+
for (const mp of mounts) {
|
|
368
|
+
sourceApp = pickApp(mp, "DMG");
|
|
369
|
+
if (sourceApp) break;
|
|
370
|
+
}
|
|
371
|
+
if (!sourceApp) throw new InstallAppError("app-not-found", opts.appName ? `${opts.appName} not found in DMG` : "no .app found in DMG");
|
|
372
|
+
} else if (opts.zip) {
|
|
373
|
+
failedStage = "extract";
|
|
374
|
+
extractRoot = fsImpl.mkdtempSync(path.join(tmpdir, "right-install-app-zip-"));
|
|
375
|
+
try { await run("/usr/bin/ditto", ["-x", "-k", opts.zip, extractRoot]); }
|
|
376
|
+
catch (error) { throw new InstallAppError("zip-extract", `ditto -x -k failed for ${opts.zip}: ${errText(error)}`); }
|
|
377
|
+
sourceApp = pickApp(extractRoot, "zip");
|
|
378
|
+
if (!sourceApp) throw new InstallAppError("app-not-found", opts.appName ? `${opts.appName} not found in zip` : "no .app found in zip");
|
|
379
|
+
} else {
|
|
380
|
+
sourceApp = opts.app.replace(/\/+$/, "");
|
|
381
|
+
if (opts.appName && path.basename(sourceApp) !== opts.appName) throw new InstallAppError("app-not-found", `--app is ${path.basename(sourceApp)}, not ${opts.appName}`);
|
|
202
382
|
}
|
|
203
|
-
if (!sourceApp) throw new InstallAppError("app-not-found", opts.appName ? `${opts.appName} not found in DMG` : "no .app found in DMG");
|
|
204
383
|
const appName = path.basename(sourceApp);
|
|
205
384
|
const installed = path.join(opts.dest, appName);
|
|
206
385
|
const hadInstalled = fsImpl.existsSync(installed);
|
|
207
386
|
|
|
208
387
|
// Verify the mounted app before touching anything installed.
|
|
209
388
|
failedStage = "verify";
|
|
210
|
-
const newTeam = await verifyBundle(run, sourceApp, { assess:
|
|
389
|
+
const { team: newTeam, ticket } = await verifyBundle(run, sourceApp, { assess: !dev, staple: !dev, dmg: opts.dmg });
|
|
211
390
|
const newBundleId = await readBundleId(run, sourceApp);
|
|
212
|
-
if (opts.bundleId && newBundleId !== opts.bundleId) throw new InstallAppError("bundle-id-mismatch",
|
|
391
|
+
if (opts.bundleId && newBundleId !== opts.bundleId) throw new InstallAppError("bundle-id-mismatch", `${inputKind.toUpperCase()} app bundle id ${newBundleId} != --bundle-id ${opts.bundleId}`);
|
|
213
392
|
let installedTeam = null;
|
|
214
393
|
if (hadInstalled) {
|
|
215
394
|
const installedBundleId = await readBundleId(run, installed);
|
|
216
|
-
if (installedBundleId !== newBundleId) throw new InstallAppError("bundle-id-mismatch",
|
|
395
|
+
if (installedBundleId !== newBundleId) throw new InstallAppError("bundle-id-mismatch", `${inputKind.toUpperCase()} app bundle id ${newBundleId} != installed bundle id ${installedBundleId}`);
|
|
217
396
|
try { installedTeam = await teamIdOf(run, installed); } catch { installedTeam = null; }
|
|
218
|
-
if (installedTeam && installedTeam !== newTeam) throw new InstallAppError("team-id-changed", `refusing team change: installed ${installedTeam},
|
|
397
|
+
if (installedTeam && installedTeam !== newTeam) throw new InstallAppError("team-id-changed", `refusing team change: installed ${installedTeam}, ${inputKind.toUpperCase()} ${newTeam}`);
|
|
219
398
|
}
|
|
220
399
|
const bundleId = opts.bundleId || newBundleId;
|
|
221
|
-
event("verified", { app: appName, bundleId, teamId: newTeam, replacing: hadInstalled });
|
|
400
|
+
event("verified", { app: appName, bundleId, teamId: newTeam, replacing: hadInstalled, ...(ticket ? { ticket } : {}) });
|
|
401
|
+
|
|
402
|
+
// Stage a verified copy on the destination volume so the swap is rename(2) only.
|
|
403
|
+
failedStage = "stage";
|
|
404
|
+
const base = appName.replace(/\.app$/, "");
|
|
405
|
+
const staged = path.join(opts.dest, `.${base}.rightkit-install-${pid}.app`);
|
|
406
|
+
const backup = hadInstalled ? path.join(opts.dest, `.${base}.rightkit-backup-${pid}.app`) : null;
|
|
407
|
+
const journal = journalPathFor(opts.dest, appName);
|
|
408
|
+
const journalData = { schema: 1, appName, pid, phase: "staging", dev, installed, backup, staged, mount: mountPoint, mountRoot };
|
|
409
|
+
removeQuiet(staged);
|
|
410
|
+
writeJournal(fsImpl, journal, journalData);
|
|
411
|
+
swap = { installed, backup, staged, journal, backedUp: false, stagedIn: false, final: false };
|
|
412
|
+
try { await run("/usr/bin/ditto", [sourceApp, staged]); }
|
|
413
|
+
catch (error) {
|
|
414
|
+
rollbackSwap();
|
|
415
|
+
throw new InstallAppError("ditto-failed", `ditto failed (${errText(error)}); previous app untouched`);
|
|
416
|
+
}
|
|
417
|
+
{
|
|
418
|
+
const { team: stagedTeam } = await verifyBundle(run, staged, { assess: !dev, staple: false });
|
|
419
|
+
const stagedId = await readBundleId(run, staged);
|
|
420
|
+
if (stagedTeam !== newTeam || stagedId !== newBundleId) throw new InstallAppError("staged-mismatch", `staged copy ${staged} does not match the verified source (team ${stagedTeam}, bundle id ${stagedId})`);
|
|
421
|
+
}
|
|
222
422
|
|
|
223
423
|
// Quit running instance without UI.
|
|
224
424
|
failedStage = "quit";
|
|
@@ -226,29 +426,35 @@ export async function installApp(opts, deps = {}) {
|
|
|
226
426
|
const quit = hadInstalled ? await quitApp(run, kill, sleep, installed) : { pids: [], killed: [] };
|
|
227
427
|
event("quit", { wasRunning, pids: quit.pids, forced: quit.killed });
|
|
228
428
|
|
|
229
|
-
//
|
|
429
|
+
// Swap: old -> backup, staged -> installed. The old bundle is never trashed before the new one is verified.
|
|
230
430
|
failedStage = "replace";
|
|
231
|
-
|
|
232
|
-
if (hadInstalled) {
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
await run("/usr/bin/ditto", [sourceApp, installed]);
|
|
240
|
-
} catch (error) {
|
|
241
|
-
try { fsImpl.rmSync(installed, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
242
|
-
let restored = false;
|
|
243
|
-
if (trashed) { try { await moveAside(run, fsImpl, trashed, installed); restored = true; } catch { /* reported below */ } }
|
|
244
|
-
throw new InstallAppError("ditto-failed", `ditto failed (${errText(error)}); ${trashed ? (restored ? "previous app restored" : `RESTORE FAILED, previous app is at ${trashed}`) : "no previous app to restore"}`);
|
|
431
|
+
writeJournal(fsImpl, journal, { ...journalData, phase: "swapping" });
|
|
432
|
+
if (hadInstalled) { fsImpl.renameSync(installed, backup); swap.backedUp = true; }
|
|
433
|
+
fsImpl.renameSync(staged, installed);
|
|
434
|
+
swap.stagedIn = true;
|
|
435
|
+
// Dev builds are not notarized: clear quarantine on the NEW bundle only so Gatekeeper does not block it.
|
|
436
|
+
if (dev) {
|
|
437
|
+
try { await run("/usr/bin/xattr", ["-dr", "com.apple.quarantine", installed]); }
|
|
438
|
+
catch (error) { event("quarantine-clear-failed", { installed, reason: errText(error) }); }
|
|
245
439
|
}
|
|
246
|
-
event("replaced", { installed, previous: trashed });
|
|
247
440
|
await detach();
|
|
441
|
+
cleanupExtract();
|
|
248
442
|
|
|
249
|
-
// Re-verify installed copy.
|
|
443
|
+
// Re-verify installed copy; on failure the catch below rolls back to the backup.
|
|
250
444
|
failedStage = "reverify";
|
|
251
|
-
await verifyBundle(run, installed, { assess:
|
|
445
|
+
await verifyBundle(run, installed, { assess: !dev, staple: false });
|
|
446
|
+
writeJournal(fsImpl, journal, { ...journalData, phase: "verified", mount: null, mountRoot: null });
|
|
447
|
+
swap.final = true;
|
|
448
|
+
|
|
449
|
+
// Only now does the old bundle go to the Trash.
|
|
450
|
+
let trashed = null;
|
|
451
|
+
if (backup) {
|
|
452
|
+
try { trashed = await trashBundle(run, fsImpl, home, now, backup, appName); }
|
|
453
|
+
catch (error) { event("backup-trash-failed", { backup, reason: errText(error) }); trashed = backup; }
|
|
454
|
+
}
|
|
455
|
+
if (!backup || trashed !== backup) removeQuiet(journal);
|
|
456
|
+
swap = null;
|
|
457
|
+
event("replaced", { installed, previous: trashed });
|
|
252
458
|
|
|
253
459
|
// Relaunch hidden and in the background only.
|
|
254
460
|
const relaunch = opts.relaunch === null ? wasRunning : opts.relaunch;
|
|
@@ -257,20 +463,24 @@ export async function installApp(opts, deps = {}) {
|
|
|
257
463
|
await run("/usr/bin/open", ["-g", "-j", "-b", bundleId]);
|
|
258
464
|
event("relaunched", { bundleId });
|
|
259
465
|
}
|
|
260
|
-
const result = { ok: true, app: appName, installed, bundleId, teamId: newTeam, previous: trashed, relaunched: relaunch };
|
|
466
|
+
const result = { ok: true, app: appName, installed, bundleId, teamId: newTeam, previous: trashed, relaunched: relaunch, ...(dev ? { dev: true } : {}) };
|
|
261
467
|
event("done", result);
|
|
262
468
|
return result;
|
|
263
469
|
} catch (error) {
|
|
470
|
+
rollbackSwap();
|
|
264
471
|
const reason = error instanceof InstallAppError ? error.message : errText(error);
|
|
265
472
|
event("failed", { stage: failedStage, code: error.code || "error", reason });
|
|
266
473
|
if (!(error instanceof InstallAppError)) throw new InstallAppError("error", reason);
|
|
267
474
|
throw error;
|
|
268
475
|
} finally {
|
|
476
|
+
for (const [sig, fn] of Object.entries(handlers)) signalTarget.off(sig, fn);
|
|
477
|
+
rollbackSwap();
|
|
269
478
|
await detach();
|
|
479
|
+
cleanupExtract();
|
|
270
480
|
}
|
|
271
481
|
}
|
|
272
482
|
|
|
273
|
-
async function verifyBundle(run, app, { assess, staple }) {
|
|
483
|
+
async function verifyBundle(run, app, { assess, staple, dmg = null }) {
|
|
274
484
|
const step = async (code, cmd, args) => {
|
|
275
485
|
try { return await run(cmd, args); }
|
|
276
486
|
catch (error) { throw new InstallAppError(code, `${code} failed for ${app}: ${errText(error)}`); }
|
|
@@ -280,8 +490,35 @@ async function verifyBundle(run, app, { assess, staple }) {
|
|
|
280
490
|
if (!team) throw new InstallAppError("team-id-missing", `${app} has no Team ID (ad-hoc or unsigned)`);
|
|
281
491
|
await step("codesign-requirement", "/usr/bin/codesign", ["-dr", "-", app]);
|
|
282
492
|
if (assess) await step("spctl-assess", "/usr/sbin/spctl", ["--assess", "--type", "execute", app]);
|
|
283
|
-
|
|
284
|
-
|
|
493
|
+
let ticket = null;
|
|
494
|
+
let haveStapler = true;
|
|
495
|
+
if (staple) {
|
|
496
|
+
// Clean macOS hosts have no Xcode/CLT, so `xcrun stapler` can be missing.
|
|
497
|
+
try { await run("/usr/bin/xcrun", ["--find", "stapler"]); } catch { haveStapler = false; }
|
|
498
|
+
}
|
|
499
|
+
if (staple && !haveStapler) {
|
|
500
|
+
if (dmg) await step("spctl-dmg", "/usr/sbin/spctl", ["-a", "-t", "open", "--context", "context:primary-signature", dmg]);
|
|
501
|
+
const assessed = await step("spctl-notarized", "/usr/sbin/spctl", ["-a", "-t", "exec", "-vv", app]);
|
|
502
|
+
if (!/Notarized Developer ID/.test(`${assessed?.stderr || ""}\n${assessed?.stdout || ""}`)) {
|
|
503
|
+
throw new InstallAppError("spctl-notarized", `spctl-notarized failed for ${app}: not accepted as Notarized Developer ID`);
|
|
504
|
+
}
|
|
505
|
+
ticket = "spctl-only";
|
|
506
|
+
} else if (staple) {
|
|
507
|
+
try { await run("/usr/bin/xcrun", ["stapler", "validate", app]); ticket = "app-ticket"; }
|
|
508
|
+
catch (error) {
|
|
509
|
+
if (!dmg) throw new InstallAppError("stapler-validate", `stapler-validate failed for ${app}: ${errText(error)}`);
|
|
510
|
+
// Standard Tauri flow: the DMG carries the notarization ticket, not the app. Accept only when the
|
|
511
|
+
// stapled DMG validates, its primary signature is accepted, and Gatekeeper reports the app Notarized Developer ID.
|
|
512
|
+
await step("stapler-validate", "/usr/bin/xcrun", ["stapler", "validate", dmg]);
|
|
513
|
+
await step("spctl-dmg", "/usr/sbin/spctl", ["-a", "-t", "open", "--context", "context:primary-signature", dmg]);
|
|
514
|
+
const assessed = await step("spctl-notarized", "/usr/sbin/spctl", ["-a", "-t", "exec", "-vv", app]);
|
|
515
|
+
if (!/Notarized Developer ID/.test(`${assessed?.stderr || ""}\n${assessed?.stdout || ""}`)) {
|
|
516
|
+
throw new InstallAppError("spctl-notarized", `spctl-notarized failed for ${app}: not accepted as Notarized Developer ID`);
|
|
517
|
+
}
|
|
518
|
+
ticket = "dmg-ticket";
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
return { team, ticket };
|
|
285
522
|
}
|
|
286
523
|
|
|
287
524
|
async function teamIdOf(run, app) {
|
|
@@ -336,17 +573,21 @@ export async function main(argv = process.argv.slice(2), io = {}) {
|
|
|
336
573
|
try { opts = parseArgs(argv); }
|
|
337
574
|
catch (error) {
|
|
338
575
|
stderr(`right-release install-app: ${error.message}`);
|
|
339
|
-
stderr("usage: right-release install-app --dmg <path.dmg> [--app-name Pulse.app] [--bundle-id dev.orthic.pulse] [--dest /Applications] [--relaunch|--no-relaunch] [--json]");
|
|
576
|
+
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]");
|
|
577
|
+
stderr(" right-release install-app --recover [--dest /Applications] [--app-name Pulse.app] [--json]");
|
|
578
|
+
stderr(" --dev installs a Developer ID-signed, NOT notarized build (skips spctl/stapler, clears quarantine on the new bundle).");
|
|
340
579
|
return 2;
|
|
341
580
|
}
|
|
342
581
|
const emit = (e) => {
|
|
343
582
|
if (opts.json) { stdout(JSON.stringify({ schema: 1, ts: new Date().toISOString(), ...e })); return; }
|
|
344
583
|
const { event, ...rest } = e;
|
|
345
584
|
if (event === "failed") stderr(`install-app: failed at ${rest.stage}: ${rest.reason}`);
|
|
585
|
+
else if (event === "dev-build") stdout(`install-app: ${rest.note}`);
|
|
346
586
|
else stdout(`install-app: ${event}${Object.keys(rest).length ? ` ${JSON.stringify(rest)}` : ""}`);
|
|
347
587
|
};
|
|
348
588
|
try {
|
|
349
|
-
await
|
|
589
|
+
if (opts.recover) await recoverApp(opts, { ...io.deps, emit });
|
|
590
|
+
else await installApp(opts, { ...io.deps, emit });
|
|
350
591
|
return 0;
|
|
351
592
|
} catch (error) {
|
|
352
593
|
return error.code === "unsupported" ? 3 : 1;
|
package/macos-notarization.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { spawnSync } from "./background-process.mjs";
|
|
5
5
|
import { MACOS_PORTABLE_SIGNING_CONTRACT } from "./macos-signing-contract.mjs";
|
|
@@ -70,6 +70,26 @@ export function notarizeMacosArtifact({ archive, receiptPath, authArgs, profile,
|
|
|
70
70
|
return receipt;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Notarize and staple a signed .app BEFORE it is packaged into a DMG, so a copy dragged out of the
|
|
75
|
+
* DMG passes `stapler validate` offline. The app is zipped with ditto (notarytool cannot take a bare
|
|
76
|
+
* bundle), submitted, and its own ticket stapled; the DMG is then notarized and stapled as usual.
|
|
77
|
+
* The caller removes the zip after validating the receipt, which hashes it. The receipt records the submitted zip; its stapling evidence names the app bundle.
|
|
78
|
+
*/
|
|
79
|
+
export function notarizeAppBundle({ app, receiptPath, profile, authArgs, zip, run = spawnSync, env = process.env, ...agentOptions }) {
|
|
80
|
+
app = path.resolve(app);
|
|
81
|
+
if (!/\.app$/i.test(app)) throw new Error(`notarize.app must name an app bundle: ${app}`);
|
|
82
|
+
zip = path.resolve(zip ?? `${app}.zip`);
|
|
83
|
+
rmSync(zip, { force: true });
|
|
84
|
+
checkedDitto(["-c", "-k", "--keepParent", app, zip], run);
|
|
85
|
+
return notarizeMacosArtifact({ archive: zip, receiptPath, profile, authArgs, app, run, env, ...agentOptions });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function checkedDitto(args, run) {
|
|
89
|
+
const result = run("/usr/bin/ditto", args, { encoding: "utf8" });
|
|
90
|
+
if (result.error || result.status !== 0) throw new Error(`ditto failed: ${result.error?.message ?? result.stderr ?? result.stdout ?? result.status}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
73
93
|
export function validateNotarizationReceipt({ receiptPath, archive, run = spawnSync }) {
|
|
74
94
|
let receipt;
|
|
75
95
|
try { receipt = JSON.parse(readFileSync(receiptPath, "utf8")); }
|
package/ownership.mjs
CHANGED
|
@@ -234,7 +234,31 @@ function committedFileInventory(appRoot, trust, pathPrefix, committed) {
|
|
|
234
234
|
return { files, coverage, committed: true, pathPrefix, repositoryFiles, snapshotFiles: new Map(repositoryFiles.map((file) => [file.rel, file])) };
|
|
235
235
|
}
|
|
236
236
|
|
|
237
|
+
// Blank out Swift `//` and nested `/* */` comments outside string literals, keeping offsets.
|
|
238
|
+
export function stripSwiftComments(text) {
|
|
239
|
+
const out = text.split("");
|
|
240
|
+
let quote = false, depth = 0;
|
|
241
|
+
for (let i = 0; i < text.length; i++) {
|
|
242
|
+
if (depth) {
|
|
243
|
+
if (text.startsWith("/*", i)) { depth++; out[i] = out[i + 1] = " "; i++; continue; }
|
|
244
|
+
if (text.startsWith("*/", i)) { depth--; out[i] = out[i + 1] = " "; i++; continue; }
|
|
245
|
+
if (text[i] !== "\n") out[i] = " ";
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
if (quote) {
|
|
249
|
+
if (text[i] === "\\") { i++; continue; }
|
|
250
|
+
if (text[i] === '"' || text[i] === "\n") quote = false;
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
if (text[i] === '"') { quote = true; continue; }
|
|
254
|
+
if (text.startsWith("//", i)) { for (; i < text.length && text[i] !== "\n"; i++) out[i] = " "; continue; }
|
|
255
|
+
if (text.startsWith("/*", i)) { depth = 1; out[i] = out[i + 1] = " "; i++; }
|
|
256
|
+
}
|
|
257
|
+
return out.join("");
|
|
258
|
+
}
|
|
259
|
+
|
|
237
260
|
function swiftPackageArguments(text) {
|
|
261
|
+
text = stripSwiftComments(text);
|
|
238
262
|
const args = [];
|
|
239
263
|
for (const match of text.matchAll(/\.package\s*\(/g)) {
|
|
240
264
|
let at = match.index + match[0].length, start = at, depth = 1, quote = false;
|
package/package.json
CHANGED
package/release.mjs
CHANGED
|
@@ -27,7 +27,7 @@ import { verifyNsisEmbeddedBinary } from "./nsis-payload.mjs";
|
|
|
27
27
|
import { terminateProcessTree } from "./heavy-command.mjs";
|
|
28
28
|
import { materializeHardeningEvidence } from "./hardening-evidence.mjs";
|
|
29
29
|
import { signingReceiptPath } from "./signing-receipts.mjs";
|
|
30
|
-
import { validateNotarizationReceipt } from "./macos-notarization.mjs";
|
|
30
|
+
import { notarizeAppBundle, validateNotarizationReceipt } from "./macos-notarization.mjs";
|
|
31
31
|
import { MACOS_PORTABLE_SIGNING_CONTRACT } from "./macos-signing-contract.mjs";
|
|
32
32
|
import { runConfiguredChecks } from "./release-checks.mjs";
|
|
33
33
|
import { resolveMacosDeveloperIdIdentity } from "./macos-signing-identity.mjs";
|
|
@@ -156,6 +156,7 @@ if (opts.platform === "mac") {
|
|
|
156
156
|
if (!target.prePackage?.cmd) fail(`${config.app ?? "app"} mac must declare prePackage to materialize Developer ID signing files`);
|
|
157
157
|
if (!target.sign?.prePackageFiles?.length) fail(`${config.app ?? "app"} mac must declare at least one sign.prePackageFiles`);
|
|
158
158
|
if (!target.notarize?.file) fail(`${config.app ?? "app"} mac must declare notarize.file`);
|
|
159
|
+
if (target.notarize.appBeforePackage && !target.notarize.app) fail(`${config.app ?? "app"} mac notarize.appBeforePackage requires notarize.app`);
|
|
159
160
|
}
|
|
160
161
|
if (opts.upload && target.publishBlocked) fail(`${config.app ?? "app"} ${opts.platform} publish blocked: ${target.publishBlocked}`);
|
|
161
162
|
/**
|
|
@@ -310,7 +311,11 @@ if (opts.platform === "win" && target.signingContract === MSIX_SIGNING_CONTRACT)
|
|
|
310
311
|
// Resolve after prePackage: with storage governance the artifact may live only in RIGHT_RELEASE_ARTIFACT_DIR.
|
|
311
312
|
const signingFiles = target.sign.prePackageFiles.map((p) => resolveReleaseArtifactPath(root, p));
|
|
312
313
|
for (const file of signingFiles) await mustExist(file, `missing macOS Developer ID signing artifact: ${file}`);
|
|
313
|
-
if (runMacPhase("sign"))
|
|
314
|
+
if (runMacPhase("sign")) {
|
|
315
|
+
await signMacos(signingFiles, root);
|
|
316
|
+
// Opt-in (notarize.appBeforePackage): staple the .app itself before the package step copies it into the DMG.
|
|
317
|
+
if (target.notarize.appBeforePackage) await notarizeAppBeforePackage(root);
|
|
318
|
+
}
|
|
314
319
|
} else if (nativeAssembly) {
|
|
315
320
|
if (nativeAssembly.packageHook) await runCommand(nativeAssembly.packageHook, root);
|
|
316
321
|
if (nativeFinalizer) {
|
|
@@ -397,7 +402,7 @@ releaseLock.release();
|
|
|
397
402
|
|
|
398
403
|
function usage(code, message) {
|
|
399
404
|
if (message) console.error(`right-release: ${message}\n`);
|
|
400
|
-
console.error("usage:
|
|
405
|
+
console.error("usage: right-release build --platform mac|win [--config right-release.config.mjs] (build is tier-neutral; tier applies only to: right-release upload --release <id> --platform mac|win --tier patch|update)");
|
|
401
406
|
process.exit(code);
|
|
402
407
|
}
|
|
403
408
|
|
|
@@ -870,7 +875,8 @@ async function notarizeMacos(archive, root) {
|
|
|
870
875
|
if (opts.dryRun) args.push("--dry-run");
|
|
871
876
|
args.push("--receipt", receipt);
|
|
872
877
|
if (target.notarize.profile) args.push("--profile", target.notarize.profile);
|
|
873
|
-
|
|
878
|
+
// With appBeforePackage the app already carries its own stapled ticket inside the packaged DMG.
|
|
879
|
+
if (target.notarize.app && !target.notarize.appBeforePackage) args.push("--app", resolveReleaseArtifactPath(root, target.notarize.app));
|
|
874
880
|
args.push(archive);
|
|
875
881
|
if (!opts.dryRun && existsSync(receipt)) unlinkSync(receipt);
|
|
876
882
|
console.log("right-release: notarizing macOS archive");
|
|
@@ -878,6 +884,21 @@ async function notarizeMacos(archive, root) {
|
|
|
878
884
|
if (!opts.dryRun) verifyMacosNotarizationReceipt(receipt, archive);
|
|
879
885
|
}
|
|
880
886
|
|
|
887
|
+
async function notarizeAppBeforePackage(root) {
|
|
888
|
+
const app = resolveReleaseArtifactPath(root, target.notarize.app);
|
|
889
|
+
const receipt = macosReceiptPath(root, "app-notarization");
|
|
890
|
+
const zip = `${app}.zip`;
|
|
891
|
+
console.log("right-release: notarizing and stapling macOS app before packaging");
|
|
892
|
+
if (opts.dryRun) { console.log(`dry-run: notarize app ${app} (zip ${zip}) receipt ${receipt}`); return; }
|
|
893
|
+
await mustExist(app, `missing macOS app bundle for notarize.app: ${app}`);
|
|
894
|
+
if (existsSync(receipt)) unlinkSync(receipt);
|
|
895
|
+
try {
|
|
896
|
+
notarizeAppBundle({ app, zip, receiptPath: receipt, profile: target.notarize.profile });
|
|
897
|
+
validateNotarizationReceipt({ receiptPath: receipt, archive: zip });
|
|
898
|
+
} catch (error) { fail(`app notarization failed: ${error.message}`); }
|
|
899
|
+
finally { rmSync(zip, { force: true }); }
|
|
900
|
+
}
|
|
901
|
+
|
|
881
902
|
function macosReceiptPath(root, phase) {
|
|
882
903
|
return signingReceiptPath({ appRoot: root, target, platform: "mac", phase, stateRoot: process.env.RIGHT_RELEASE_STATE_ROOT });
|
|
883
904
|
}
|
package/rightkit-versions.json
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"@rightkit/app-shell": "0.3.0",
|
|
17
17
|
"@rightkit/ax": "0.2.4",
|
|
18
18
|
"@rightkit/doc-render": "0.1.4",
|
|
19
|
-
"@rightkit/git": "0.2.
|
|
19
|
+
"@rightkit/git": "0.2.32",
|
|
20
20
|
"@rightkit/hooks": "0.1.2",
|
|
21
21
|
"@rightkit/i18n": "0.1.0",
|
|
22
22
|
"@rightkit/legal": "0.3.1",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"@rightkit/platform-ui": "0.2.0",
|
|
30
30
|
"@rightkit/qa": "0.4.0",
|
|
31
31
|
"@rightkit/qa-chrome": "0.1.3",
|
|
32
|
-
"@rightkit/release": "0.2.
|
|
32
|
+
"@rightkit/release": "0.2.141",
|
|
33
33
|
"@rightkit/settings-ui": "0.3.0",
|
|
34
34
|
"@rightkit/shell": "0.1.0",
|
|
35
35
|
"@rightkit/tauri": "0.2.0",
|
|
@@ -72,7 +72,8 @@
|
|
|
72
72
|
"0.2.27",
|
|
73
73
|
"0.2.28",
|
|
74
74
|
"0.2.29",
|
|
75
|
-
"0.2.30"
|
|
75
|
+
"0.2.30",
|
|
76
|
+
"0.2.31"
|
|
76
77
|
],
|
|
77
78
|
"@rightkit/legal-ui": [
|
|
78
79
|
"0.1.0",
|
|
@@ -169,7 +170,9 @@
|
|
|
169
170
|
"0.2.135",
|
|
170
171
|
"0.2.136",
|
|
171
172
|
"0.2.137",
|
|
172
|
-
"0.2.138"
|
|
173
|
+
"0.2.138",
|
|
174
|
+
"0.2.139",
|
|
175
|
+
"0.2.140"
|
|
173
176
|
],
|
|
174
177
|
"@rightkit/qa": [
|
|
175
178
|
"0.1.0",
|
|
@@ -276,11 +279,11 @@
|
|
|
276
279
|
"rightkit-framed-sidecar": "0.1.0",
|
|
277
280
|
"rightkit-fs": "0.3.2",
|
|
278
281
|
"rightkit-fsindex": "0.1.0",
|
|
279
|
-
"rightkit-http": "0.2.
|
|
282
|
+
"rightkit-http": "0.2.4",
|
|
280
283
|
"rightkit-jobs": "0.2.6",
|
|
281
284
|
"rightkit-license": "0.1.3",
|
|
282
285
|
"rightkit-logs": "0.2.0",
|
|
283
|
-
"rightkit-mcp": "0.5.
|
|
286
|
+
"rightkit-mcp": "0.5.2",
|
|
284
287
|
"rightkit-media": "0.2.4",
|
|
285
288
|
"rightkit-models": "0.2.7",
|
|
286
289
|
"rightkit-oauth": "0.3.0",
|
|
@@ -333,11 +336,11 @@
|
|
|
333
336
|
"rightkit-framed-sidecar": "0.1.0",
|
|
334
337
|
"rightkit-fs": "0.3.2",
|
|
335
338
|
"rightkit-fsindex": "0.1.0",
|
|
336
|
-
"rightkit-http": "0.2.
|
|
339
|
+
"rightkit-http": "0.2.4",
|
|
337
340
|
"rightkit-jobs": "0.2.6",
|
|
338
341
|
"rightkit-license": "0.1.3",
|
|
339
342
|
"rightkit-logs": "0.2.0",
|
|
340
|
-
"rightkit-mcp": "0.5.
|
|
343
|
+
"rightkit-mcp": "0.5.2",
|
|
341
344
|
"rightkit-media": "0.2.4",
|
|
342
345
|
"rightkit-models": "0.2.7",
|
|
343
346
|
"rightkit-oauth": "0.3.0",
|
|
@@ -368,7 +371,7 @@
|
|
|
368
371
|
"@rightkit/app-shell": "0.3.0",
|
|
369
372
|
"@rightkit/ax": "0.2.4",
|
|
370
373
|
"@rightkit/doc-render": "0.1.4",
|
|
371
|
-
"@rightkit/git": "0.2.
|
|
374
|
+
"@rightkit/git": "0.2.32",
|
|
372
375
|
"@rightkit/hooks": "0.1.2",
|
|
373
376
|
"@rightkit/i18n": "0.1.0",
|
|
374
377
|
"@rightkit/legal": "0.3.1",
|
|
@@ -381,7 +384,7 @@
|
|
|
381
384
|
"@rightkit/platform-ui": "0.2.0",
|
|
382
385
|
"@rightkit/qa": "0.4.0",
|
|
383
386
|
"@rightkit/qa-chrome": "0.1.3",
|
|
384
|
-
"@rightkit/release": "0.2.
|
|
387
|
+
"@rightkit/release": "0.2.141",
|
|
385
388
|
"@rightkit/settings-ui": "0.3.0",
|
|
386
389
|
"@rightkit/shell": "0.1.0",
|
|
387
390
|
"@rightkit/tauri": "0.2.0",
|
|
@@ -433,7 +436,8 @@
|
|
|
433
436
|
"0.3.0",
|
|
434
437
|
"0.3.1",
|
|
435
438
|
"0.4.0",
|
|
436
|
-
"0.5.0"
|
|
439
|
+
"0.5.0",
|
|
440
|
+
"0.5.1"
|
|
437
441
|
],
|
|
438
442
|
"rightkit-media": [
|
|
439
443
|
"0.2.2",
|
|
@@ -505,7 +509,9 @@
|
|
|
505
509
|
"rightkit-http": [
|
|
506
510
|
"0.1.0",
|
|
507
511
|
"0.2.0",
|
|
508
|
-
"0.2.1"
|
|
512
|
+
"0.2.1",
|
|
513
|
+
"0.2.2",
|
|
514
|
+
"0.2.3"
|
|
509
515
|
],
|
|
510
516
|
"rightkit-fs": [
|
|
511
517
|
"0.1.0",
|
package/source-gate.mjs
CHANGED
|
@@ -10,8 +10,29 @@
|
|
|
10
10
|
// real risk is shipping stale, dirty, or untested source. Distribution trust still
|
|
11
11
|
// comes from OS code signing, notarization, and the Tauri updater key — untouched.
|
|
12
12
|
|
|
13
|
+
// right-release's own per-platform run lock (`.right-release/locks/*`, every platform) is
|
|
14
|
+
// written before the later clean-tree preflights run. Only untracked files directly in that lock directory are
|
|
15
|
+
// exempt, so repos that do not ignore `.right-release/` still pass, and nothing else in
|
|
16
|
+
// the vault (sealed output, receipts, state) or elsewhere is ever excused.
|
|
17
|
+
const OWN_LOCK_RECORD = /^\?\? (?:[^\0]*\/)?\.right-release\/locks\/[^\0/]+$/;
|
|
18
|
+
|
|
19
|
+
/** Remove right-release's own untracked lock-file records from NUL-separated porcelain v1 output. */
|
|
20
|
+
export function stripOwnLockRecords(status) {
|
|
21
|
+
const records = status.split("\0");
|
|
22
|
+
const kept = [];
|
|
23
|
+
for (let index = 0; index < records.length; index += 1) {
|
|
24
|
+
const record = records[index];
|
|
25
|
+
if (!record) continue;
|
|
26
|
+
if (OWN_LOCK_RECORD.test(record)) continue;
|
|
27
|
+
kept.push(record);
|
|
28
|
+
if (/^[RC]/.test(record) || /^.[RC]/.test(record)) kept.push(records[++index] ?? "");
|
|
29
|
+
}
|
|
30
|
+
return kept.length ? `${kept.join("\0")}\0` : "";
|
|
31
|
+
}
|
|
32
|
+
|
|
13
33
|
export function assertCleanSource({ status, commandId = "release" }) {
|
|
14
34
|
if (typeof status !== "string") throw new Error("release source status is required");
|
|
35
|
+
status = stripOwnLockRecords(status);
|
|
15
36
|
if (status.length !== 0) {
|
|
16
37
|
const files = status.split("\0").filter(Boolean).map((entry) => entry.slice(3)).slice(0, 10);
|
|
17
38
|
throw new Error(`${commandId} requires a clean working tree; commit or stash first:\n${files.map((file) => `- ${file}`).join("\n")}`);
|