@rightkit/release 0.2.173 → 0.2.174
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 +10 -4
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/darwin/rightkit-swap.sign.json +1 -1
- package/cli/right-release.mjs +5 -3
- package/development-signing.mjs +135 -4
- package/development.mjs +12 -11
- package/in-place-install.mjs +32 -7
- package/install-app-windows.mjs +108 -17
- package/install-app.mjs +32 -8
- package/install-tauri-devid.mjs +106 -47
- package/mac-bundle-signing.mjs +143 -1
- package/mac-signing-matrix.mjs +73 -6
- package/macos-signing-session.mjs +12 -2
- package/package-recipe-targets.mjs +166 -16
- package/package.json +1 -1
- package/portable-development.mjs +9 -9
- package/qa-build-features.mjs +7 -1
- package/recipe-build.mjs +346 -0
- package/release.mjs +23 -8
- package/rightkit-signing-agent.mjs +10 -4
- package/rightkit-versions.json +9 -5
- package/runtime-budgets.mjs +14 -5
- package/stage-binary.mjs +1 -1
- package/stage-sidecar-command.mjs +36 -4
- package/stage-sidecar.mjs +463 -48
package/README.md
CHANGED
|
@@ -100,13 +100,13 @@ The shipped `bin/darwin/rightkit-swap` is Developer ID signed. It is added by `r
|
|
|
100
100
|
|
|
101
101
|
Development builds (from `@rightkit/ci` `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.
|
|
102
102
|
|
|
103
|
-
## One fixed path, one identity
|
|
103
|
+
## One fixed path, one identity; release keeps one `.previous`, dev keeps none (every app)
|
|
104
104
|
|
|
105
|
-
macOS and Windows tie privacy grants (mic, screen, Files and Folders, Removable Volumes, Full Disk Access) to the path an app runs from and to its code identity. So every app, helper and dev or QA binary installs and updates IN PLACE at ONE fixed path under ONE identity; there is no per-version, per-digest, per-pid or per-build install folder or bundle id.
|
|
105
|
+
macOS and Windows tie privacy grants (mic, screen, Files and Folders, Removable Volumes, Full Disk Access) to the path an app runs from and to its code identity. So every app, helper and dev or QA binary installs and updates IN PLACE at ONE fixed path under ONE identity; there is no per-version, per-digest, per-pid or per-build install folder or bundle id. A RELEASE install may keep one rollback `<name>.previous` beside the fixed path (rename it back). A DEV or QA install keeps NO old copy of any kind (Adrian, bogusyogi/rightkit#348: "when installing dev, old copy must never be backedup"; reinstall from an older DMG): it replaces the path in place through a short-lived staging name and removes any stale `.previous`, and a failed dev install leaves the path absent or partial with a clear error. Nothing ever runs from `.previous` or from a staging copy.
|
|
106
106
|
|
|
107
|
-
- `install-app` (macOS) renames the replaced app to `<App>.app.previous` (never the Trash); `install-app --installer <Setup.exe> --dest <folder> --exe <App.exe
|
|
107
|
+
- `install-app` (macOS) renames the replaced app to `<App>.app.previous` (never the Trash) for a release install only; `install-app --dev` removes the old app and keeps nothing. `install-app --installer <Setup.exe> --dest <folder> --exe <App.exe> [--payload <file>]... [--payload-manifest <json>]` runs the NSIS installer in place. It NEVER copies or restores the whole install folder, because that folder can hold user data (CodeRight keeps `coderight.db`, its WAL, state and models there). Its backup and rollback are payload-only, from an explicit allowlist: the `--exe` file plus `--payload` entries and a `--payload-manifest` JSON of relative file paths (files, never directories); with no `--payload` it backs up and rolls back nothing. `right-release dev` (mac) installs to `install.destination` (required) with no `.previous` and fails if its designated requirement differs from the installed copy; Windows tauri dev runs the silent installer without copying `<exe>.previous`; a portable dev payload installs as `<installRoot>/current` with no `current.previous` (data beside `current` is never touched); the Windows direct installer installs to `current` with `current.previous`.
|
|
108
108
|
- `right-release dev` (mac) signs with the same Developer ID Application identity as release (an app that needs a provisioning profile uses a Developer ID profile); Apple Development certificates are refused with `DEV_MAC_SIGNING_DEVELOPER_ID_REQUIRED`.
|
|
109
|
-
- **Cargo target folders are not stable paths.** The broker's target tree is `<managed>/rightkit-build-control/ws/<hash>/g/<generation>/target/...` and the generation changes; `cargo-guard` uses a hashed `dev-targets/<project>-<hash>` folder. They are build output only, so nothing may run from them. Dev and QA binaries run from a fixed staged path: list them in `development.targets.<platform>.stage` (`[{ source } | { manifestPath, bin, targetTriple?, profile?, name? }]`) and `right-release dev` copies each in place to `<app root>/.rightkit-dev-bin/<triple-or-host>/<name>` (
|
|
109
|
+
- **Cargo target folders are not stable paths.** The broker's target tree is `<managed>/rightkit-build-control/ws/<hash>/g/<generation>/target/...` and the generation changes; `cargo-guard` uses a hashed `dev-targets/<project>-<hash>` folder. They are build output only, so nothing may run from them. Dev and QA binaries run from a fixed staged path: list them in `development.targets.<platform>.stage` (`[{ source } | { manifestPath, bin, targetTriple?, profile?, name? }]`) and `right-release dev` copies each in place to `<app root>/.rightkit-dev-bin/<triple-or-host>/<name>` (no backup of the old copy); `rightkit-qa` launches from its `.rightkit-qa-bin` stash on every path.
|
|
110
110
|
|
|
111
111
|
## Runtime budget gate (crates)
|
|
112
112
|
|
|
@@ -320,3 +320,9 @@ No agent may write to the internal drive (C: on Windows, the internal disk on ma
|
|
|
320
320
|
- `smol-toml` is pinned exactly. A consumer that depends on a different `smol-toml` version gets a second copy.
|
|
321
321
|
- Apps pin this package at different versions; `docs/ADOPTION.md` lists each pin and marks the stale ones.
|
|
322
322
|
- Prepublish runs the test suite, the Windows signer integration test and the registry parity check.
|
|
323
|
+
|
|
324
|
+
## Post-sign hook chains
|
|
325
|
+
|
|
326
|
+
`target.postSign` accepts either one command object or an array of command objects. An array runs in order, for example `create-mac-updater` followed by `lsclean`, and stops at the first failing command, so the release fails closed. Every command in the array is scanned for forbidden build features and release switches, exactly like a single command. A single object behaves as before.
|
|
327
|
+
|
|
328
|
+
Each command may set `timeoutMs`. When it expires the release kills the whole process tree (`taskkill /T /F` on Windows, the process group elsewhere) and exits non-zero, so a hung child such as Tauri leaves no orphan.
|
package/bin/darwin/rightkit-swap
CHANGED
|
Binary file
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema": 1,
|
|
3
|
-
"sha256": "
|
|
3
|
+
"sha256": "f52367947184283bf188d04e414743879c842ac359ed0a97acacecf23383b1ce",
|
|
4
4
|
"identity": "Developer ID Application: Adrian D'souza (6KLGD3LLKF)",
|
|
5
5
|
"codesign": "strict-valid"
|
|
6
6
|
}
|
package/cli/right-release.mjs
CHANGED
|
@@ -22,6 +22,7 @@ const commands = new Map([
|
|
|
22
22
|
["sign-candidate", "sign-candidate.mjs"],
|
|
23
23
|
["engine-locate", "engine-locator.mjs"],
|
|
24
24
|
["stage-sidecar", "stage-sidecar-command.mjs"],
|
|
25
|
+
["recipe-build", "recipe-build.mjs"],
|
|
25
26
|
["signing-agent", "signing-agent-command.mjs"],
|
|
26
27
|
["create-mac-updater", "create-mac-updater.mjs"],
|
|
27
28
|
["mirror-root-artifact", "mirror-root-artifact.mjs"],
|
|
@@ -183,15 +184,16 @@ function printHelp() {
|
|
|
183
184
|
|
|
184
185
|
Commands:
|
|
185
186
|
stage-sidecar [--config <file>] [--target <triple>] [--json] Stage declarative sidecars into src-tauri/binaries (Tauri externalBin naming)
|
|
187
|
+
recipe-build --plan <json> | --plan-file <path> [--app-root <dir>] Run a package recipe: prepare steps, the Tauri build, postBuild steps (argv, no shell, fail closed)
|
|
186
188
|
dmg build --app <.app> --out <dmg> --volname <name> [--background <png>] [--spec <appdmg.json>]
|
|
187
189
|
[--icon-size N] [--app-position x,y] [--applications-position x,y] [--window w,h] [--window-position x,y]
|
|
188
190
|
Build branded UDZO image without Finder
|
|
189
191
|
install-app --dmg <path.dmg> [--app-name Pulse.app] [--bundle-id <id>] [--dest /Applications]
|
|
190
192
|
[--relaunch|--no-relaunch] [--json]
|
|
191
193
|
Verify a signed DMG, quit, replace & relaunch hidden; no UI, no osascript (macOS)
|
|
192
|
-
|
|
193
|
-
install-app --installer <Setup.exe> --dest <fixed install folder> --exe <App.exe> [--json]
|
|
194
|
-
Windows: run the installer in place at the fixed folder,
|
|
194
|
+
A release install keeps the replaced app beside the fixed path as <App>.app.previous; --dev keeps NO old copy
|
|
195
|
+
install-app --installer <Setup.exe> --dest <fixed install folder> --exe <App.exe> [--payload <file>]... [--payload-manifest <json>] [--json]
|
|
196
|
+
Windows: run the installer in place at the fixed folder; payload-only rollback (never the whole folder, user data beside the app is never copied)
|
|
195
197
|
build [--platform mac|win] [--skip-checks] [--resume-from <phase>]
|
|
196
198
|
Resume macOS at prepackage, sign, package, post-sign, or harden
|
|
197
199
|
dev --platform win|mac [--config <path>] [--dry-run] [--build-only]
|
package/development-signing.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import os from "node:os";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { spawnSync } from "./background-process.mjs";
|
|
6
6
|
import { runMacCodesignCommands } from "./macos-signing-session.mjs";
|
|
7
|
+
import { bundleRelativePath, compileBundlePathMap, globToRegExp, isGlobPattern, lookupBundlePath, resolveBundleIdentifier, validateBundleRelativeKey, validateIdentifierTemplate, validateSigningIdentifier } from "./mac-bundle-signing.mjs";
|
|
7
8
|
|
|
8
9
|
export class MacosDevelopmentSigningError extends Error {
|
|
9
10
|
constructor(code, message, details = {}) {
|
|
@@ -100,7 +101,8 @@ export function resolveMacosDevelopmentSigning({ root, bundle, descriptor = {},
|
|
|
100
101
|
if (existsSync(file)) mac = { ...mac, ...JSON.parse(readFileSync(file, "utf8")).bundle?.macOS };
|
|
101
102
|
}
|
|
102
103
|
const sign = config.targets?.mac?.sign ?? {};
|
|
103
|
-
|
|
104
|
+
// `sign.entitlements` is the app-level file when a string; an object is the per-path map (readDevSigningSettings).
|
|
105
|
+
const entitlements = typeof sign.entitlements === "string" && sign.entitlements ? path.resolve(root, sign.entitlements) : mac.entitlements ? path.resolve(tauriRoot, mac.entitlements) : null;
|
|
104
106
|
if (entitlements && !existsSync(entitlements)) throw new MacosDevelopmentSigningError("DEV_MAC_SIGNING_CONFIG_INVALID", `Configured entitlements file is missing: ${entitlements}`);
|
|
105
107
|
// CI-only ad hoc: no Keychain, no provisioning profile (a profile authorizes restricted entitlements that an ad hoc signature cannot carry).
|
|
106
108
|
if (ciAdhoc) return { identity: "-", certificateSha1: "-", teamIdentifier: null, entitlements, provisioningProfile: null, mode: CI_ADHOC_SIGNING, adhoc: true };
|
|
@@ -158,9 +160,128 @@ function macho(file) {
|
|
|
158
160
|
} finally { closeSync(fd); }
|
|
159
161
|
}
|
|
160
162
|
|
|
163
|
+
const invalidConfig = (message) => { throw new MacosDevelopmentSigningError("DEV_MAC_SIGNING_CONFIG_INVALID", message); };
|
|
164
|
+
const ASSERT_KEYS = new Set(["path", "identifier", "teamIdentifier", "authority"]);
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Declarative per-binary signing settings of a dev install. Everything is optional; with none declared the result is
|
|
168
|
+
* empty and signing is exactly what it was before these keys existed.
|
|
169
|
+
* install.identifiers / sign.identifiers { path-or-glob: "--identifier value" } ("." is the bundle)
|
|
170
|
+
* install.identifierTemplate / sign.identifierTemplate default identifier for remaining plain Mach-O files
|
|
171
|
+
* install.entitlements / sign.entitlements (object) { path-or-glob: plist path } (a string sign.entitlements is the app file)
|
|
172
|
+
* install.timestamp true adds --timestamp (a secure timestamp contacts Apple's server)
|
|
173
|
+
* install.assert [{ path-or-glob, identifier?, teamIdentifier?, authority? }]
|
|
174
|
+
* install.assertTeamSignedNested true: every Mach-O in the bundle carries the app's TeamIdentifier
|
|
175
|
+
* install.* entries win over sign.* entries (so one declaration in `sign` keeps dev and release identifiers identical).
|
|
176
|
+
* `root` resolves entitlement plist paths and checks they exist; without it only the shape is validated.
|
|
177
|
+
*/
|
|
178
|
+
export function readDevSigningSettings(descriptor = {}, config = {}, { root, exists = existsSync } = {}) {
|
|
179
|
+
const sign = config?.targets?.mac?.sign ?? {};
|
|
180
|
+
const identifier = (raw, label) => validateSigningIdentifier(raw, label, invalidConfig);
|
|
181
|
+
const identifiers = [...compileBundlePathMap(descriptor.identifiers, "install.identifiers", { reject: invalidConfig, value: identifier }),
|
|
182
|
+
...compileBundlePathMap(sign.identifiers, "sign.identifiers", { reject: invalidConfig, value: identifier })];
|
|
183
|
+
const templateSource = descriptor.identifierTemplate !== undefined ? ["install.identifierTemplate", descriptor.identifierTemplate] : sign.identifierTemplate !== undefined ? ["sign.identifierTemplate", sign.identifierTemplate] : null;
|
|
184
|
+
const identifierTemplate = templateSource ? validateIdentifierTemplate(templateSource[1], templateSource[0], invalidConfig) : undefined;
|
|
185
|
+
const plistPath = (raw, label) => {
|
|
186
|
+
if (typeof raw !== "string" || !raw.trim()) invalidConfig(`${label} must be an entitlements plist path`);
|
|
187
|
+
if (!root) return raw;
|
|
188
|
+
const file = path.resolve(root, raw);
|
|
189
|
+
if (!exists(file)) invalidConfig(`${label} entitlements file not found: ${file}`);
|
|
190
|
+
return file;
|
|
191
|
+
};
|
|
192
|
+
const signMap = typeof sign.entitlements === "object" && sign.entitlements !== null && !Array.isArray(sign.entitlements) ? sign.entitlements : undefined;
|
|
193
|
+
const entitlements = [...compileBundlePathMap(descriptor.entitlements, "install.entitlements", { reject: invalidConfig, value: plistPath }),
|
|
194
|
+
...compileBundlePathMap(signMap, "sign.entitlements", { reject: invalidConfig, value: plistPath })];
|
|
195
|
+
if (descriptor.timestamp !== undefined && typeof descriptor.timestamp !== "boolean") invalidConfig("install.timestamp must be true or false");
|
|
196
|
+
if (descriptor.assertTeamSignedNested !== undefined && typeof descriptor.assertTeamSignedNested !== "boolean") invalidConfig("install.assertTeamSignedNested must be true or false");
|
|
197
|
+
const rawAssertions = descriptor.assert ?? [];
|
|
198
|
+
if (!Array.isArray(rawAssertions)) invalidConfig("install.assert must be an array of { path, identifier?, teamIdentifier?, authority? }");
|
|
199
|
+
const assertions = rawAssertions.map((item, index) => {
|
|
200
|
+
const label = `install.assert[${index}]`;
|
|
201
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) invalidConfig(`${label} must be an object`);
|
|
202
|
+
for (const key of Object.keys(item)) if (!ASSERT_KEYS.has(key)) invalidConfig(`${label}.${key} is not a known key (path, identifier, teamIdentifier, authority)`);
|
|
203
|
+
validateBundleRelativeKey(item.path, `${label}.path`, invalidConfig);
|
|
204
|
+
const checks = ["identifier", "teamIdentifier", "authority"].filter((key) => item[key] !== undefined);
|
|
205
|
+
if (!checks.length) invalidConfig(`${label} needs at least one of identifier, teamIdentifier, authority`);
|
|
206
|
+
for (const key of checks) if (typeof item[key] !== "string" || !item[key].trim()) invalidConfig(`${label}.${key} must be a non-empty string`);
|
|
207
|
+
return { path: item.path, ...Object.fromEntries(checks.map((key) => [key, item[key]])) };
|
|
208
|
+
});
|
|
209
|
+
return { identifiers, identifierTemplate, entitlements, timestamp: descriptor.timestamp === true, assertions, assertTeamSignedNested: descriptor.assertTeamSignedNested === true };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function listBundleFiles(bundle) {
|
|
213
|
+
const out = [];
|
|
214
|
+
const walk = (directory) => {
|
|
215
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
216
|
+
const file = path.join(directory, entry.name);
|
|
217
|
+
if (entry.isSymbolicLink()) continue;
|
|
218
|
+
if (entry.isDirectory()) walk(file);
|
|
219
|
+
else if (entry.isFile()) out.push(bundleRelativePath(bundle, file));
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
walk(bundle);
|
|
223
|
+
return out;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Post-sign identity assertions, read from `codesign -dvv` (stdout and stderr: codesign reports on stderr). Fails closed with
|
|
228
|
+
* DEV_MAC_SIGNING_ASSERT_FAILED listing every mismatch. A glob that matches no file is a failure, so a typo cannot pass.
|
|
229
|
+
* `assertTeamSignedNested` additionally requires every Mach-O in the bundle (dylibs included) to be signed by
|
|
230
|
+
* `teamIdentifier`, not ad hoc. Used on the signed staging copy and again on the installed copy (a failure there rolls back).
|
|
231
|
+
* Injectable: run, listFiles(bundle) -> bundle-relative file paths, isMachO(file), join.
|
|
232
|
+
*/
|
|
233
|
+
export function assertSignedIdentities({ root, bundle, settings, teamIdentifier, identity, env = process.env, run = spawnSync, listFiles = listBundleFiles, isMachO = macho, join = path.join, label = "signed bundle" }) {
|
|
234
|
+
const { assertions = [], assertTeamSignedNested = false } = settings ?? {};
|
|
235
|
+
if (!assertions.length && !assertTeamSignedNested) return { checked: 0 };
|
|
236
|
+
const reports = new Map();
|
|
237
|
+
const report = (file) => {
|
|
238
|
+
if (!reports.has(file)) {
|
|
239
|
+
const result = run("codesign", ["-dvv", file], captureOptions(root, env));
|
|
240
|
+
if (result.error || result.status !== 0) {
|
|
241
|
+
throw new MacosDevelopmentSigningError("DEV_MAC_SIGNING_ASSERT_FAILED", `${label}: cannot inspect the signing identity of ${file}: ${result.error?.message || output(result) || `exit ${result.status ?? "unavailable"}`}`, { identity });
|
|
242
|
+
}
|
|
243
|
+
const text = output(result);
|
|
244
|
+
reports.set(file, { identifier: /^Identifier=(.+)$/m.exec(text)?.[1]?.trim(), team: /^TeamIdentifier=(.+)$/m.exec(text)?.[1]?.trim(), authorities: [...text.matchAll(/^Authority=(.+)$/gm)].map((match) => match[1].trim()), adhoc: /^Signature=adhoc$/m.test(text) });
|
|
245
|
+
}
|
|
246
|
+
return reports.get(file);
|
|
247
|
+
};
|
|
248
|
+
const absolute = (relative) => (relative === "." ? bundle : join(bundle, ...relative.split("/")));
|
|
249
|
+
let files;
|
|
250
|
+
const listed = () => (files ??= listFiles(bundle));
|
|
251
|
+
const problems = [];
|
|
252
|
+
let checked = 0;
|
|
253
|
+
for (const item of assertions) {
|
|
254
|
+
const targets = isGlobPattern(item.path) ? listed().filter((relative) => globToRegExp(item.path).test(relative)) : [item.path];
|
|
255
|
+
if (!targets.length) { problems.push(`${item.path}: matches no file in the bundle`); continue; }
|
|
256
|
+
for (const relative of targets) {
|
|
257
|
+
const actual = report(absolute(relative));
|
|
258
|
+
checked += 1;
|
|
259
|
+
if (item.identifier !== undefined && actual.identifier !== item.identifier) problems.push(`${relative}: Identifier=${actual.identifier ?? "missing"}, expected ${item.identifier}`);
|
|
260
|
+
if (item.teamIdentifier !== undefined && actual.team !== item.teamIdentifier) problems.push(`${relative}: TeamIdentifier=${actual.team ?? "missing"}, expected ${item.teamIdentifier}`);
|
|
261
|
+
if (item.authority !== undefined && !actual.authorities.includes(item.authority)) problems.push(`${relative}: Authority does not include ${JSON.stringify(item.authority)} (found ${actual.authorities.map((name) => JSON.stringify(name)).join(", ") || "none"})`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
if (assertTeamSignedNested) {
|
|
265
|
+
if (!teamIdentifier) problems.push("assertTeamSignedNested: the expected TeamIdentifier is unknown (ad hoc signing has none)");
|
|
266
|
+
else {
|
|
267
|
+
for (const relative of [".", ...listed().filter((entry) => isMachO(absolute(entry)))]) {
|
|
268
|
+
const actual = report(absolute(relative));
|
|
269
|
+
checked += 1;
|
|
270
|
+
if (actual.adhoc || actual.team !== teamIdentifier) problems.push(`${relative}: ${actual.adhoc ? "ad hoc signed" : `TeamIdentifier=${actual.team ?? "missing"}`}, expected TeamIdentifier=${teamIdentifier}`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (problems.length) {
|
|
275
|
+
throw new MacosDevelopmentSigningError("DEV_MAC_SIGNING_ASSERT_FAILED", `${label}: signing identity assertions failed (${problems.length}): ${problems.slice(0, 20).join("; ")}${problems.length > 20 ? "; ..." : ""}. Refusing dev installation.`, { identity, problems });
|
|
276
|
+
}
|
|
277
|
+
return { checked };
|
|
278
|
+
}
|
|
279
|
+
|
|
161
280
|
export function signMacosDevelopmentBundle(options) {
|
|
162
281
|
const { root, bundle, env = process.env, run = spawnSync } = options;
|
|
163
282
|
const context = { root, env, run };
|
|
283
|
+
// Declared per-binary settings are validated before any Keychain or codesign call.
|
|
284
|
+
const settings = readDevSigningSettings(options.descriptor, options.config, { root });
|
|
164
285
|
const signing = resolveMacosDevelopmentSigning({ ...options, env, run });
|
|
165
286
|
const { identity, certificateSha1, teamIdentifier, entitlements, provisioningProfile } = signing;
|
|
166
287
|
const embedded = path.join(bundle, "Contents", "embedded.provisionprofile");
|
|
@@ -218,12 +339,20 @@ export function signMacosDevelopmentBundle(options) {
|
|
|
218
339
|
const commands = [];
|
|
219
340
|
for (const target of ordered) {
|
|
220
341
|
// Bind the exact profile-authorized certificate even when names collide in Keychain.
|
|
221
|
-
const
|
|
342
|
+
const relative = bundleRelativePath(bundle, target.file);
|
|
343
|
+
let identifier;
|
|
344
|
+
try {
|
|
345
|
+
identifier = resolveBundleIdentifier({ relative, identifiers: settings.identifiers, template: settings.identifierTemplate, bundleId: options.descriptor?.bundleId ?? appInfo.CFBundleIdentifier, plain: !target.info });
|
|
346
|
+
} catch (cause) { throw new MacosDevelopmentSigningError("DEV_MAC_SIGNING_CONFIG_INVALID", cause.message, { cause }); }
|
|
347
|
+
// --timestamp only when declared (install.timestamp): it needs Apple's timestamp server, and dev builds otherwise sign offline.
|
|
348
|
+
const args = ["--force", "--options", "runtime", ...(settings.timestamp ? ["--timestamp"] : []), ...(identifier ? ["--identifier", identifier] : []), "--sign", certificateSha1];
|
|
222
349
|
const parent = target.file === bundle;
|
|
350
|
+
const mappedEntitlements = lookupBundlePath(settings.entitlements, relative || ".");
|
|
223
351
|
const profiledHelper = target.info && existsSync(path.join(target.file, "Contents/embedded.provisionprofile")) && target.info.CFBundleIdentifier === appInfo.CFBundleIdentifier;
|
|
224
352
|
if (profiledHelper && !parent && provisioningProfile) copyFileSync(embedded, path.join(target.file, "Contents/embedded.provisionprofile"));
|
|
225
353
|
const variant = !parent && entitlementVariants.find((item) => path.basename(target.file).replace(/\.(app|xpc|appex|bundle)$/, "").toLowerCase().endsWith(item.suffix));
|
|
226
|
-
if (
|
|
354
|
+
if (mappedEntitlements) args.push("--entitlements", entitlementFile(mappedEntitlements));
|
|
355
|
+
else if (variant) args.push("--entitlements", entitlementFile(variant.file));
|
|
227
356
|
else if (entitlements && (parent || profiledHelper)) args.push("--entitlements", entitlementFile(entitlements));
|
|
228
357
|
else {
|
|
229
358
|
const existing = run("codesign", ["--display", "--entitlements", ":-", target.file], captureOptions(root, env));
|
|
@@ -248,7 +377,9 @@ export function signMacosDevelopmentBundle(options) {
|
|
|
248
377
|
const requirement = output(checked("codesign", ["--display", "-r-", bundle], context, "DEV_MAC_SIGNATURE_INVALID", identity));
|
|
249
378
|
const designated = /^designated => (.+)$/m.exec(requirement)?.[1]?.trim();
|
|
250
379
|
if (!designated || /\bcdhash\b/.test(designated)) throw new MacosDevelopmentSigningError("DEV_MAC_SIGNING_UNSTABLE_REQUIREMENT", `Signing identity ${JSON.stringify(identity)}: designated requirement ${designated ? JSON.stringify(designated) : "is missing"} is not stable across rebuilds (cdhash-bound). Refusing dev installation.`, { identity, designatedRequirement: designated });
|
|
251
|
-
|
|
380
|
+
// Declared identity assertions (install.assert, install.assertTeamSignedNested) on the signed staging copy, before any install.
|
|
381
|
+
const asserted = assertSignedIdentities({ root, bundle, settings, teamIdentifier, identity, env, run, label: "signed staging copy", ...(options.listFiles ? { listFiles: options.listFiles } : {}), ...(options.isMachO ? { isMachO: options.isMachO } : {}) });
|
|
382
|
+
return { ...signing, provisioningProfile: provisioningProfile ? embedded : null, verified: "strict-deep", designatedRequirement: designated, signedTargets: ordered.length, ...(asserted.checked ? { assertedIdentities: asserted.checked } : {}) };
|
|
252
383
|
} finally { rmSync(scratch, { recursive: true, force: true }); }
|
|
253
384
|
}
|
|
254
385
|
|
package/development.mjs
CHANGED
|
@@ -105,7 +105,7 @@ export function runDevelopment({ root, platform, config, buildOnly = false, dryR
|
|
|
105
105
|
// Android: cargo-ndk (Cargo dev profile) fills the generated jniLibs, then the Gradle debug build packages it.
|
|
106
106
|
if (target.ndk) { rmSync(target.ndk.jniLibs, { recursive: true, force: true }); execute(androidDevelopmentNdkCommand(target.ndk), { root, env: childEnv, dryRun, run, label: "native build" }); }
|
|
107
107
|
for (const command of target.build) execute(command, { root, env: childEnv, dryRun, run, label: "build" });
|
|
108
|
-
// Dev and QA binaries run from one stable staged path, copied in place (
|
|
108
|
+
// Dev and QA binaries run from one stable staged path, copied in place (no backup of the old copy), never from the
|
|
109
109
|
// broker's per-generation target folder or a hashed dev-targets folder.
|
|
110
110
|
const staged = target.stage?.length ? stageDevelopmentBinaries(target.stage, { root, platform: platform === "win" ? "win32" : process.platform }) : [];
|
|
111
111
|
for (const file of staged) console.log(`right-release dev: staged ${file}`);
|
|
@@ -114,7 +114,7 @@ export function runDevelopment({ root, platform, config, buildOnly = false, dryR
|
|
|
114
114
|
// installTauriDevId is async: the lease is held until it settles, so a broker cutover cannot land mid-install.
|
|
115
115
|
const built = resolveTauriArtifact(target.install, { root, platform, env: childEnv });
|
|
116
116
|
releaseLease = false;
|
|
117
|
-
return installTauriDevId({ appRoot: root, config: target.install, built, env: childEnv })
|
|
117
|
+
return installTauriDevId({ appRoot: root, config: target.install, releaseConfig: config, built, env: childEnv })
|
|
118
118
|
.then((outcome) => ({ built: true, installed: true, ...(staged.length ? { staged } : {}), devid: outcome }))
|
|
119
119
|
.finally(() => lease.release());
|
|
120
120
|
}
|
|
@@ -140,9 +140,10 @@ function install(descriptor, { root, platform, config, env, run, sinceMs = null,
|
|
|
140
140
|
// was running, pass /R so the installer relaunches it as the user (RunAsUser, outside this process tree).
|
|
141
141
|
const running = isWindowsProcessRunning(path.basename(descriptor.expectedInstalledPath), { run });
|
|
142
142
|
if (running) console.log(`right-release dev: ${descriptor.productName} is running; the installer will close it and relaunch it`);
|
|
143
|
-
// The silent installer updates the fixed path in place.
|
|
143
|
+
// The silent installer updates the fixed path in place. NO backup of the old copy (Orthic-Labs/rightkit#348): a stale
|
|
144
|
+
// <exe>.previous left by an older RightKit is removed, nothing is copied, and a failed install is reinstalled from an older build.
|
|
144
145
|
const installedBefore = path.resolve(root, descriptor.expectedInstalledPath);
|
|
145
|
-
|
|
146
|
+
rmSync(previousPathOf(installedBefore), { recursive: true, force: true });
|
|
146
147
|
const result = run(app, windowsInstallerArguments({ running }), { cwd: root, env, stdio: "inherit", windowsHide: true, shell: false });
|
|
147
148
|
if (result.status !== 0) throw new Error(`development installer failed (${result.status}): ${app}`);
|
|
148
149
|
const installed = path.resolve(root, descriptor.expectedInstalledPath);
|
|
@@ -156,7 +157,6 @@ function install(descriptor, { root, platform, config, env, run, sinceMs = null,
|
|
|
156
157
|
mkdirSync(path.dirname(destination), { recursive: true });
|
|
157
158
|
// Fixed staging name (not per-pid): the copy is signed and verified here, then renamed into place. Nothing runs from it.
|
|
158
159
|
const staging = stagingPathOf(destination);
|
|
159
|
-
const previous = previousPathOf(destination);
|
|
160
160
|
rmSync(staging, { recursive: true, force: true });
|
|
161
161
|
cpSync(app, staging, { recursive: true, verbatimSymlinks: true });
|
|
162
162
|
let signing;
|
|
@@ -167,7 +167,7 @@ function install(descriptor, { root, platform, config, env, run, sinceMs = null,
|
|
|
167
167
|
if (!signing.adhoc && existsSync(destination)) assertStableDesignatedRequirement({ root, installed: destination, expected: signing.designatedRequirement, env, run });
|
|
168
168
|
executableName = readBundleExecutable(staging, { root, env, run });
|
|
169
169
|
} catch (error) { rmSync(staging, { recursive: true, force: true }); throw error; }
|
|
170
|
-
swapInstalledMacosBundle({ staging, destination,
|
|
170
|
+
swapInstalledMacosBundle({ staging, destination, executableName });
|
|
171
171
|
if (signing.provisioningProfile) signing.provisioningProfile = path.join(destination, "Contents", "embedded.provisionprofile");
|
|
172
172
|
if (signing.adhoc) console.log(`right-release dev: installed ${destination}; signing=ci-adhoc (ad hoc signed on a CI runner; NOT a release artifact); signature=adhoc; TeamIdentifier=none`);
|
|
173
173
|
else console.log(`right-release dev: installed ${destination}; identity=${signing.identity}; TeamIdentifier=${signing.teamIdentifier}; profile=${signing.provisioningProfile ?? "none"}; designated=${signing.designatedRequirement}`);
|
|
@@ -181,12 +181,13 @@ export class DevelopmentInstallError extends Error {
|
|
|
181
181
|
const NODE_FS = { existsSync, lstatSync, mkdirSync, copyFileSync, readFileSync, renameSync, rmSync, statSync };
|
|
182
182
|
|
|
183
183
|
/**
|
|
184
|
-
* Move the signed staging bundle into place
|
|
184
|
+
* Move the signed staging bundle into place IN PLACE with NO backup (Orthic-Labs/rightkit#348: the old app is removed,
|
|
185
|
+
* never kept as `<destination>.previous`; a stale `.previous` is removed too), and verify it: Info.plist exists and
|
|
185
186
|
* the installed main executable (`executableName`, the staged CFBundleExecutable read through plutil by the caller) exists with the same SHA-256 as the staged one. Any failure removes the
|
|
186
|
-
* new install,
|
|
187
|
+
* new (absent or partial) install, does NOT restore the old app (reinstall from an older DMG), and throws a DevelopmentInstallError (code DEV_INSTALL_VERIFICATION_FAILED,
|
|
187
188
|
* DEV_INSTALL_EXECUTABLE_MISSING or DEV_INSTALL_EXECUTABLE_MISMATCH). `fs` is the file-system seam (tests inject it).
|
|
188
189
|
*/
|
|
189
|
-
export function swapInstalledMacosBundle({ staging, destination,
|
|
190
|
+
export function swapInstalledMacosBundle({ staging, destination, executableName, fs = NODE_FS }) {
|
|
190
191
|
if (typeof executableName !== "string" || !executableName) throw new DevelopmentInstallError("DEV_INSTALL_VERIFICATION_FAILED", "swapInstalledMacosBundle requires the staged CFBundleExecutable name");
|
|
191
192
|
const executable = path.join("Contents", "MacOS", executableName);
|
|
192
193
|
const digest = (file) => createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
|
@@ -196,9 +197,9 @@ export function swapInstalledMacosBundle({ staging, destination, previous = prev
|
|
|
196
197
|
fs.rmSync(staging, { recursive: true, force: true });
|
|
197
198
|
throw new DevelopmentInstallError("DEV_INSTALL_EXECUTABLE_MISSING", `staged app has no main executable ${executable}: ${staging} (${error.message})`);
|
|
198
199
|
}
|
|
199
|
-
// The old app
|
|
200
|
+
// The old app is removed and the staged one renamed over it; no old copy survives, even on failure.
|
|
200
201
|
swapInPlace({
|
|
201
|
-
staging, destination, previous, fs,
|
|
202
|
+
staging, destination, previous: null, fs,
|
|
202
203
|
verify: () => {
|
|
203
204
|
if (!fs.existsSync(path.join(destination, "Contents", "Info.plist"))) throw new DevelopmentInstallError("DEV_INSTALL_VERIFICATION_FAILED", `installed app verification failed: ${destination}`);
|
|
204
205
|
const installed = path.join(destination, executable);
|
package/in-place-install.mjs
CHANGED
|
@@ -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.
|
|
8
|
-
*
|
|
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
|
|
24
|
-
*
|
|
25
|
-
* 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
|
-
|
|
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
|
}
|
package/install-app-windows.mjs
CHANGED
|
@@ -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
|
|
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`).
|
|
12
|
-
*
|
|
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
|
-
*
|
|
16
|
-
*
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
44
|
-
|
|
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 (!
|
|
48
|
-
|
|
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}
|
|
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}
|
|
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:
|
|
62
|
-
return { ok: true, installed, previous:
|
|
152
|
+
event("replaced", { installed, previous: backupMade ? previous : null, sha256: sha256(fs, installed) });
|
|
153
|
+
return { ok: true, installed, previous: backupMade ? previous : null };
|
|
63
154
|
}
|