@rightkit/release 0.2.165 → 0.2.173
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 +121 -4
- package/addon-command.mjs +6 -13
- package/android-aab-scan.mjs +265 -0
- package/android-dev.mjs +111 -0
- package/android-ndk.mjs +77 -0
- package/android-play-upload.mjs +262 -0
- package/android-release.mjs +344 -0
- package/android-test-fixtures.mjs +79 -0
- package/apple-profile-create.mjs +363 -0
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/darwin/rightkit-swap.sign.json +6 -0
- package/build-release.mjs +73 -13
- package/cargo-guard.mjs +2 -0
- package/cargo-profile-contract.mjs +183 -0
- package/cargo-target.mjs +8 -0
- package/cli/right-release.mjs +16 -4
- package/committed-source.mjs +1 -1
- package/dev-artifact-refresh.mjs +176 -0
- package/dev-artifact-support.mjs +61 -0
- package/development-signing.mjs +71 -4
- package/development.mjs +93 -47
- package/direct-bootstrap.mjs +26 -41
- package/disk-command.mjs +298 -5
- package/disk-policy.json +7 -5
- package/disk-policy.mjs +105 -16
- package/disk-stable-root.mjs +19 -16
- package/engine-locator.mjs +116 -0
- package/external-bin.mjs +133 -0
- package/github-release.mjs +2 -2
- package/github-updater-release.mjs +154 -0
- package/hardeningscan.mjs +1 -1
- package/in-place-install.mjs +56 -0
- package/install-app-windows.mjs +63 -0
- package/install-app.mjs +53 -23
- package/install-tauri-devid.mjs +172 -0
- package/inventory-check.mjs +1 -1
- package/local-publish.mjs +11 -1
- package/mac-package-recipe.mjs +304 -0
- package/mac-signing-matrix.mjs +23 -11
- package/managed-cargo.mjs +6 -0
- package/media-sidecar-catalog.mjs +45 -0
- package/media-sidecar.mjs +306 -0
- package/ownership-approval-command.mjs +10 -44
- package/ownership-approval.mjs +3 -42
- package/ownership-catalog-config.json +2 -1
- package/ownership-catalog.mjs +4 -2
- package/ownership-command.mjs +7 -9
- package/ownership-dependency-allowlist.json +25 -1
- package/ownership-evidence.mjs +8 -5
- package/ownership-git-inventory.mjs +41 -7
- package/ownership-lockfiles.mjs +91 -8
- package/ownership-mandatory-set.json +36 -16
- package/ownership-report.mjs +5 -6
- package/ownership-scan-worker.mjs +2 -2
- package/ownership-verdict.mjs +10 -7
- package/ownership.mjs +47 -29
- package/package-recipe-targets.mjs +95 -0
- package/package.json +7 -4
- package/portable-development.mjs +32 -115
- package/preflight.mjs +4 -6
- package/project-type.mjs +1 -1
- package/publication-artifacts.mjs +34 -0
- package/publish-update.mjs +72 -11
- package/qa-adoption-exemptions.json +5 -0
- package/qa-build-features.mjs +7 -0
- package/qa-contract.mjs +182 -0
- package/qa-control-scan.mjs +3 -3
- package/release-qa-command.mjs +38 -12
- package/release-qa-installed.mjs +136 -0
- package/release-qa-receipt.mjs +23 -2
- package/release.mjs +285 -14
- package/rightkit-ownership.json +537 -5
- package/rightkit-sync.mjs +27 -1
- package/rightkit-versions.json +111 -63
- package/runtime-budget-classes.json +28 -0
- package/runtime-budgets.mjs +4 -1
- package/sign-candidate.mjs +161 -0
- package/sign-dev-artifact.mjs +198 -0
- package/stage-binary.mjs +60 -0
- package/stage-sidecar-command.mjs +41 -0
- package/stage-sidecar.mjs +424 -0
- package/swap-helper-bundle.mjs +105 -0
- package/swift-mac-dmg.mjs +2 -1
- package/templates/cargo-profiles.toml +28 -0
- package/upload-release.mjs +11 -1
- package/win-package-recipe.mjs +321 -0
package/disk-stable-root.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { cpSync, existsSync,
|
|
1
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { spawnSync } from "./background-process.mjs";
|
|
5
|
+
import { previousPathOf, stagingPathOf, swapInPlace } from "./in-place-install.mjs";
|
|
5
6
|
|
|
6
7
|
// The scheduled disk job must never bind to a pnpm store, a pnpm global hash directory or a checkout: those move or vanish
|
|
7
8
|
// on upgrade. `disk schedule install` copies the installed package into this per-user root and binds the job to it.
|
|
@@ -64,8 +65,8 @@ export function collectDependencies(pkgRoot) {
|
|
|
64
65
|
}
|
|
65
66
|
|
|
66
67
|
/** Copy the installed @rightkit/release package (and its dependencies) into the stable root. The new tree is built and
|
|
67
|
-
*
|
|
68
|
-
* `current
|
|
68
|
+
* built in the fixed `current.staging`, swapped in by rename (the old copy stays as `current.previous`), then smoke-tested at
|
|
69
|
+
* `current`; a failed swap or smoke run renames `current.previous` back. Returns `{ stable, cli, version, replaced, unchanged }`. */
|
|
69
70
|
export function installStableRoot({ cli, platform = process.platform, env = process.env, home = os.homedir(), now = Date.now(), nodePath = process.execPath, smoke = true } = {}) {
|
|
70
71
|
const stable = stableRootPath({ platform, env, home });
|
|
71
72
|
const stableCli = stableCliPath(stable, platform);
|
|
@@ -81,10 +82,13 @@ export function installStableRoot({ cli, platform = process.platform, env = proc
|
|
|
81
82
|
|
|
82
83
|
const parent = path.dirname(stable);
|
|
83
84
|
mkdirSync(parent, { recursive: true });
|
|
85
|
+
// Legacy per-pid names from earlier versions; the fixed names are `current.staging` and `current.previous`.
|
|
84
86
|
for (const name of readdirSync(parent)) {
|
|
85
87
|
if (/^current\.(staging|prev)-/.test(name)) try { rmSync(path.join(parent, name), { recursive: true, force: true }); } catch { /* in use: leave it */ }
|
|
86
88
|
}
|
|
87
|
-
const staging =
|
|
89
|
+
const staging = stagingPathOf(stable);
|
|
90
|
+
const previous = previousPathOf(stable);
|
|
91
|
+
rmSync(staging, { recursive: true, force: true });
|
|
88
92
|
const copy = (from, to, filter) => cpSync(from, to, { recursive: true, dereference: true, force: true, filter });
|
|
89
93
|
try {
|
|
90
94
|
const target = path.join(staging, "node_modules", "@rightkit", "release");
|
|
@@ -98,30 +102,29 @@ export function installStableRoot({ cli, platform = process.platform, env = proc
|
|
|
98
102
|
writeFileSync(path.join(staging, "install.json"), `${JSON.stringify({ package: pkg.name, version: pkg.version, source: pkgRoot, installedAt: new Date(now).toISOString(), node: nodePath }, null, 2)}\n`);
|
|
99
103
|
const stagedCli = stableCliPath(staging, platform);
|
|
100
104
|
if (!existsSync(stagedCli)) throw new Error("disk: the copied package has no cli/right-release.mjs");
|
|
101
|
-
if (smoke) {
|
|
102
|
-
const r = spawnSync(nodePath, [stagedCli, "--version"], { encoding: "utf8" });
|
|
103
|
-
if (r.status !== 0) throw new Error(`disk: the staged copy does not run: ${r.stderr || r.stdout || r.error?.message}`);
|
|
104
|
-
}
|
|
105
105
|
} catch (error) {
|
|
106
106
|
rmSync(staging, { recursive: true, force: true });
|
|
107
107
|
throw error;
|
|
108
108
|
}
|
|
109
|
+
// Swap first, then smoke-run at the fixed `current` path (never from the staging path). A failed run renames
|
|
110
|
+
// `current.previous` back. Exactly one `.previous` is kept.
|
|
109
111
|
let replaced = false;
|
|
110
|
-
let prev;
|
|
111
112
|
try {
|
|
112
|
-
|
|
113
|
-
|
|
113
|
+
const result = swapInPlace({
|
|
114
|
+
staging, destination: stable, previous,
|
|
115
|
+
verify: () => {
|
|
116
|
+
if (!smoke) return;
|
|
117
|
+
const r = spawnSync(nodePath, [stableCli, "--version"], { encoding: "utf8" });
|
|
118
|
+
if (r.status !== 0) throw new Error(`the installed copy does not run: ${r.stderr || r.stdout || r.error?.message}`);
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
replaced = result.previous !== null;
|
|
114
122
|
} catch (error) {
|
|
115
|
-
if (prev && !existsSync(stable)) try { renameSync(prev, stable); } catch { /* reported below */ }
|
|
116
|
-
rmSync(staging, { recursive: true, force: true });
|
|
117
123
|
throw new Error(`disk: could not replace ${stable}: ${error.message}`);
|
|
118
124
|
}
|
|
119
|
-
if (prev) try { rmSync(prev, { recursive: true, force: true }); } catch { /* a later install removes it */ }
|
|
120
125
|
return { stable, cli: stableCli, version: pkg.version, replaced, unchanged: false };
|
|
121
126
|
}
|
|
122
127
|
|
|
123
|
-
function isLink(p) { try { return lstatSync(p).isSymbolicLink(); } catch { return false; } }
|
|
124
|
-
|
|
125
128
|
/** Registered schedule target: the CLI path the launchd job or Windows task runs, or null when none is registered. */
|
|
126
129
|
export function readScheduleBinding({ platform = process.platform, home = os.homedir(), file, run = spawnSync, label = "RightKit Disk Hygiene", plistFile } = {}) {
|
|
127
130
|
if (platform === "darwin") {
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// right-release engine-locate: the verified route to the CutRight and GenRight engines (Orthic-Labs/rightkit#316).
|
|
3
|
+
// Callers such as PostRight get the engine path ONLY from here. The route is fixed per platform (the installed app's stable
|
|
4
|
+
// `current` folder, see in-place-install.mjs) and every candidate is verified before its path is returned: a regular file (no
|
|
5
|
+
// symlink or junction), signed by the Orthic Labs release identity (macOS: Developer ID, hardened runtime, pinned Team ID and
|
|
6
|
+
// Identifier, not ad hoc; Windows: valid Authenticode, pinned signer organisation), and, when the caller pins one, an exact sha256.
|
|
7
|
+
// There is no environment override, no path argument and no PATH search: a path the user or a build supplies is never trusted.
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
import { existsSync, lstatSync, readFileSync } from "node:fs";
|
|
10
|
+
import os from "node:os";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { pathToFileURL } from "node:url";
|
|
13
|
+
import { spawnSync } from "./background-process.mjs";
|
|
14
|
+
import { assertPackagedIdentity, parseCodesignDisplay } from "./packaged-app-identity.mjs";
|
|
15
|
+
|
|
16
|
+
export const ENGINE_TEAM_IDENTIFIER = "6KLGD3LLKF";
|
|
17
|
+
export const ENGINE_SIGNER_ORGANIZATION = "Orthic Labs";
|
|
18
|
+
|
|
19
|
+
/** engine id -> installed app folder, executable name (no extension) and macOS codesign Identifier. */
|
|
20
|
+
export const ENGINES = Object.freeze({
|
|
21
|
+
cutright: Object.freeze({ app: "CutRight", exe: "cutright", identifier: "com.orthiclabs.cutright.engine" }),
|
|
22
|
+
genright: Object.freeze({ app: "GenRight", exe: "genright", identifier: "com.orthiclabs.genright.engine" }),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export class EngineLocateError extends Error {
|
|
26
|
+
constructor(code, message) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.name = "EngineLocateError";
|
|
29
|
+
this.code = code;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const fail = (code, message) => { throw new EngineLocateError(code, message); };
|
|
34
|
+
|
|
35
|
+
/** The single fixed path for an engine on a platform. Reads no override variable: `localAppData` / `home` only name the user's profile roots. */
|
|
36
|
+
export function engineRoute(engine, { platform = process.platform, home = os.homedir(), localAppData = process.env.LOCALAPPDATA } = {}) {
|
|
37
|
+
const spec = ENGINES[engine];
|
|
38
|
+
if (!spec) fail("ENGINE_UNKNOWN", `unknown engine ${JSON.stringify(engine)} (cutright or genright)`);
|
|
39
|
+
if (platform === "win32") {
|
|
40
|
+
if (!localAppData) fail("ENGINE_ROUTE_UNAVAILABLE", "LOCALAPPDATA is not set, so the Windows engine route cannot be formed");
|
|
41
|
+
return path.win32.join(localAppData, "Orthic Labs", spec.app, "current", "bin", `${spec.exe}.exe`);
|
|
42
|
+
}
|
|
43
|
+
if (platform === "darwin") return path.posix.join(home, "Library", "Application Support", "Orthic Labs", spec.app, "current", "bin", spec.exe);
|
|
44
|
+
return fail("ENGINE_PLATFORM_UNSUPPORTED", `no engine route for platform ${platform}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const capture = (command, args, run = spawnSync) => {
|
|
48
|
+
const result = run(command, args, { encoding: "utf8", windowsHide: true, shell: false });
|
|
49
|
+
return { status: result.status ?? 1, output: `${result.stdout ?? ""}\n${result.stderr ?? ""}` };
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const defaultVerifyMac = (file, spec, run) => {
|
|
53
|
+
const strict = capture("codesign", ["--verify", "--strict", file], run);
|
|
54
|
+
if (strict.status !== 0) fail("ENGINE_SIGNATURE_INVALID", `codesign --verify --strict failed for ${file}: ${strict.output.trim()}`);
|
|
55
|
+
const display = capture("codesign", ["-dv", "--verbose=4", file], run);
|
|
56
|
+
if (display.status !== 0) fail("ENGINE_SIGNATURE_INVALID", `codesign could not read ${file}: ${display.output.trim()}`);
|
|
57
|
+
const failures = assertPackagedIdentity(parseCodesignDisplay(display.output), [], { identifier: spec.identifier, team: ENGINE_TEAM_IDENTIFIER });
|
|
58
|
+
if (failures.length) fail("ENGINE_IDENTITY_MISMATCH", `${file}: ${failures.join("; ")}`);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const defaultVerifyWindows = (file, _spec, run) => {
|
|
62
|
+
const script = "$s = Get-AuthenticodeSignature -LiteralPath $args[0]; [pscustomobject]@{ status = [string]$s.Status; subject = if ($s.SignerCertificate) { $s.SignerCertificate.Subject } else { '' } } | ConvertTo-Json -Compress";
|
|
63
|
+
const result = capture("powershell", ["-NoProfile", "-NonInteractive", "-Command", script, file], run);
|
|
64
|
+
let parsed;
|
|
65
|
+
try { parsed = JSON.parse(result.output.trim().split(/\r?\n/).filter(Boolean).at(-1)); } catch { fail("ENGINE_SIGNATURE_INVALID", `Authenticode could not be read for ${file}: ${result.output.trim()}`); }
|
|
66
|
+
if (parsed.status !== "Valid") fail("ENGINE_SIGNATURE_INVALID", `${file}: Authenticode status is ${parsed.status}, not Valid`);
|
|
67
|
+
if (!new RegExp(`(^|,\\s*)O=${ENGINE_SIGNER_ORGANIZATION}(,|$)`).test(parsed.subject)) fail("ENGINE_IDENTITY_MISMATCH", `${file}: signer ${JSON.stringify(parsed.subject)} is not ${ENGINE_SIGNER_ORGANIZATION}`);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Locate and verify an engine. Returns { engine, path, sha256, platform }. Throws EngineLocateError (ENGINE_NOT_INSTALLED,
|
|
72
|
+
* ENGINE_NOT_REGULAR_FILE, ENGINE_SIGNATURE_INVALID, ENGINE_IDENTITY_MISMATCH, ENGINE_HASH_MISMATCH, ...). Injectable for tests:
|
|
73
|
+
* `route`, `verifySignature`, `lstat`, `readBytes`. `expectedSha256` is an optional exact pin from the caller's own release record.
|
|
74
|
+
*/
|
|
75
|
+
export function locateEngine(engine, {
|
|
76
|
+
platform = process.platform, home, localAppData, expectedSha256, run = spawnSync,
|
|
77
|
+
route = engineRoute(engine, { platform, ...(home ? { home } : {}), ...(localAppData ? { localAppData } : {}) }),
|
|
78
|
+
verifySignature, lstat = lstatSync, readBytes = readFileSync, exists = existsSync,
|
|
79
|
+
} = {}) {
|
|
80
|
+
const spec = ENGINES[engine];
|
|
81
|
+
if (!spec) fail("ENGINE_UNKNOWN", `unknown engine ${JSON.stringify(engine)} (cutright or genright)`);
|
|
82
|
+
if (!exists(route)) fail("ENGINE_NOT_INSTALLED", `${spec.app} engine is not installed at ${route}; install ${spec.app} from RightApps (there is no path override)`);
|
|
83
|
+
const info = lstat(route);
|
|
84
|
+
if (info.isSymbolicLink() || !info.isFile()) fail("ENGINE_NOT_REGULAR_FILE", `${route} must be a regular file, not a symlink, junction or directory`);
|
|
85
|
+
const verify = verifySignature ?? (platform === "win32" ? defaultVerifyWindows : platform === "darwin" ? defaultVerifyMac : () => fail("ENGINE_PLATFORM_UNSUPPORTED", `no engine verification for platform ${platform}`));
|
|
86
|
+
verify(route, spec, run);
|
|
87
|
+
const sha256 = createHash("sha256").update(readBytes(route)).digest("hex");
|
|
88
|
+
if (expectedSha256 !== undefined) {
|
|
89
|
+
if (!/^[0-9a-f]{64}$/.test(expectedSha256)) fail("ENGINE_HASH_MISMATCH", `expectedSha256 must be a lowercase SHA-256: ${JSON.stringify(expectedSha256)}`);
|
|
90
|
+
if (sha256 !== expectedSha256) fail("ENGINE_HASH_MISMATCH", `${route}: sha256 ${sha256} is not the pinned ${expectedSha256}`);
|
|
91
|
+
}
|
|
92
|
+
return { engine, path: route, sha256, platform };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function main(args = process.argv.slice(2), options = {}) {
|
|
96
|
+
try {
|
|
97
|
+
const known = new Set(["--engine", "--sha256", "--json"]);
|
|
98
|
+
for (const arg of args) if (arg.startsWith("--") && !known.has(arg)) fail("ENGINE_USAGE", `unknown option ${arg} (there is deliberately no path option)`);
|
|
99
|
+
const take = (flag) => {
|
|
100
|
+
const index = args.indexOf(flag);
|
|
101
|
+
if (index < 0) return undefined;
|
|
102
|
+
const value = args[index + 1];
|
|
103
|
+
if (!value || value.startsWith("--")) fail("ENGINE_USAGE", `${flag} requires a value`);
|
|
104
|
+
return value;
|
|
105
|
+
};
|
|
106
|
+
const engine = take("--engine");
|
|
107
|
+
if (!engine) fail("ENGINE_USAGE", "usage: right-release engine-locate --engine cutright|genright [--sha256 <hex>] [--json]");
|
|
108
|
+
const result = locateEngine(engine, { expectedSha256: take("--sha256"), ...options });
|
|
109
|
+
console.log(args.includes("--json") ? JSON.stringify(result) : result.path);
|
|
110
|
+
} catch (error) {
|
|
111
|
+
console.error(`right-release engine-locate: ${error.code ? `[${error.code}] ` : ""}${error.message}`);
|
|
112
|
+
process.exitCode = 1;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) main();
|
package/external-bin.mjs
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Stage, sign and receipt any number of Tauri externalBin sidecars (Orthic-Labs/rightkit#270).
|
|
2
|
+
//
|
|
3
|
+
// targets.mac.externalBin = {
|
|
4
|
+
// names: ["mailright-engine", "mailright-core"], // bare names, as in tauri.conf externalBin "binaries/<name>"
|
|
5
|
+
// arch: "aarch64", // default: build host; one arch per build
|
|
6
|
+
// stageDir: "src-tauri/binaries", // default; files are <name>-<target triple>[.exe]
|
|
7
|
+
// bundleDir: "Contents/MacOS", // mac only (or "Contents/Resources")
|
|
8
|
+
// stage: { cmd: "node", args: ["scripts/build-engine.mjs"] }, // optional: the app's own build of the files
|
|
9
|
+
// }
|
|
10
|
+
//
|
|
11
|
+
// This is the generic form of the ffmpeg sidecar from #246 and reuses its naming, hashing, staged-file checks and signing
|
|
12
|
+
// evidence checks (media-sidecar.mjs); it adds no catalog, notices or release-manifest seal. It never signs: release.mjs
|
|
13
|
+
// signs the staged files with the app (Windows raw-EXE step, macOS nested code) and writes external-bin.json.
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import {
|
|
16
|
+
bundledFileName, hostCatalogArch, sidecarTargetTriple, stagedFileName, verifyStagedSidecars,
|
|
17
|
+
} from "./media-sidecar.mjs";
|
|
18
|
+
|
|
19
|
+
export const EXTERNAL_BIN_CONTRACT = "rightkit-external-bin-v1";
|
|
20
|
+
export const EXTERNAL_BIN_RECEIPT_NAME = "external-bin.json";
|
|
21
|
+
|
|
22
|
+
const PLATFORM_OS = Object.freeze({ mac: "macos", win: "windows", linux: "linux" });
|
|
23
|
+
const ARCHES = new Set(["aarch64", "x86_64"]);
|
|
24
|
+
const CONFIG_KEYS = new Set(["names", "arch", "stageDir", "bundleDir", "stage"]);
|
|
25
|
+
const BUNDLE_DIRS = new Set(["Contents/MacOS", "Contents/Resources"]);
|
|
26
|
+
const SHA256 = /^[0-9a-f]{64}$/;
|
|
27
|
+
|
|
28
|
+
const invalid = (code, message) => Object.assign(new Error(message), { code });
|
|
29
|
+
const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
30
|
+
|
|
31
|
+
/** Validate and normalise `targets.<platform>.externalBin`. Every problem is reported at once. */
|
|
32
|
+
export function validateExternalBinConfig(raw, { platform, hostArch = hostCatalogArch(), reservedNames = [] } = {}) {
|
|
33
|
+
const errors = [];
|
|
34
|
+
const os = PLATFORM_OS[platform];
|
|
35
|
+
if (!os) throw invalid("EXTERNAL_BIN_CONFIG_INVALID", `externalBin is not supported on platform ${JSON.stringify(platform)} (mac, win or linux)`);
|
|
36
|
+
if (!isObject(raw)) throw invalid("EXTERNAL_BIN_CONFIG_INVALID", "externalBin must be an object");
|
|
37
|
+
for (const key of Object.keys(raw)) if (!CONFIG_KEYS.has(key)) errors.push(`unknown key ${JSON.stringify(key)} (allowed: ${[...CONFIG_KEYS].join(", ")})`);
|
|
38
|
+
const names = raw.names;
|
|
39
|
+
if (!Array.isArray(names) || !names.length || new Set(names).size !== names.length
|
|
40
|
+
|| names.some((name) => typeof name !== "string" || !/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(name) || name !== path.basename(name) || /\.exe$/i.test(name))) {
|
|
41
|
+
errors.push("names must be a non-empty list of unique bare sidecar names (no path, no .exe, no target triple)");
|
|
42
|
+
} else {
|
|
43
|
+
for (const name of names) if (reservedNames.includes(name)) errors.push(`${name} is already staged by mediaSidecar`);
|
|
44
|
+
}
|
|
45
|
+
const arch = raw.arch ?? hostArch;
|
|
46
|
+
if (!ARCHES.has(arch)) errors.push(`arch must be one of ${[...ARCHES].join(", ")} (one architecture per build): ${JSON.stringify(arch)}`);
|
|
47
|
+
else {
|
|
48
|
+
try { sidecarTargetTriple(os, arch); } catch (error) { errors.push(error.message); }
|
|
49
|
+
}
|
|
50
|
+
const stageDirRaw = raw.stageDir ?? "src-tauri/binaries";
|
|
51
|
+
let stageDir;
|
|
52
|
+
if (typeof stageDirRaw !== "string" || !stageDirRaw.trim()) errors.push("stageDir must be a non-empty relative path");
|
|
53
|
+
else {
|
|
54
|
+
const normalized = stageDirRaw.replaceAll("\\", "/");
|
|
55
|
+
if (path.isAbsolute(stageDirRaw) || /^[A-Za-z]:/.test(stageDirRaw) || normalized.split("/").includes("..")) errors.push(`stageDir must be relative and stay inside the app: ${stageDirRaw}`);
|
|
56
|
+
else stageDir = normalized.replace(/\/+$/, "");
|
|
57
|
+
}
|
|
58
|
+
let bundleDir;
|
|
59
|
+
if (os === "macos") {
|
|
60
|
+
bundleDir = raw.bundleDir ?? "Contents/MacOS";
|
|
61
|
+
if (!BUNDLE_DIRS.has(bundleDir)) errors.push(`bundleDir must be ${[...BUNDLE_DIRS].join(" or ")}`);
|
|
62
|
+
} else if (raw.bundleDir !== undefined) errors.push("bundleDir applies to macOS only");
|
|
63
|
+
let stage;
|
|
64
|
+
if (raw.stage !== undefined) {
|
|
65
|
+
if (!isObject(raw.stage) || typeof raw.stage.cmd !== "string" || !raw.stage.cmd || (raw.stage.args !== undefined && !Array.isArray(raw.stage.args))) errors.push("stage must be { cmd, args? }");
|
|
66
|
+
else stage = raw.stage;
|
|
67
|
+
}
|
|
68
|
+
if (errors.length) throw invalid("EXTERNAL_BIN_CONFIG_INVALID", `invalid externalBin configuration:\n${errors.map((line) => `- ${line}`).join("\n")}`);
|
|
69
|
+
return { os, arch, names: [...names], stageDir, ...(bundleDir ? { bundleDir } : {}), ...(stage ? { stage } : {}) };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The staged file (and, on macOS, bundle path) for every configured sidecar. Same shape `verifyStagedSidecars` reads. */
|
|
73
|
+
export function planExternalBin({ config, root }) {
|
|
74
|
+
return {
|
|
75
|
+
config,
|
|
76
|
+
files: config.names.map((name) => ({
|
|
77
|
+
tool: name,
|
|
78
|
+
stagedPath: path.resolve(root, config.stageDir, stagedFileName(name, config.os, config.arch)),
|
|
79
|
+
bundledName: bundledFileName(name, config.os),
|
|
80
|
+
...(config.bundleDir ? { bundlePath: `${config.bundleDir}/${bundledFileName(name, config.os)}` } : {}),
|
|
81
|
+
})),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** `sign.nestedCode` entries for the macOS bundle: the sidecar executables, hardened runtime, no entitlements. */
|
|
86
|
+
export function externalBinNestedCode(config) {
|
|
87
|
+
if (config.os !== "macos") return {};
|
|
88
|
+
return Object.fromEntries(config.names.map((name) => [`${config.bundleDir}/${bundledFileName(name, "macos")}`, { hardenedRuntime: true }]));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Before signing: every staged file exists, is non-empty, and its hash is recorded (reuses the ffmpeg check). */
|
|
92
|
+
export function verifyStagedExternalBins(plan, deps) {
|
|
93
|
+
return verifyStagedSidecars(plan, deps).map((file) => ({ ...file }));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** After signing: `signed` maps name to { sha256, sizeBytes }. */
|
|
97
|
+
export function buildExternalBinReceipt(plan, preSign, signed) {
|
|
98
|
+
const { config } = plan;
|
|
99
|
+
return {
|
|
100
|
+
schema: 1,
|
|
101
|
+
contract: EXTERNAL_BIN_CONTRACT,
|
|
102
|
+
os: config.os,
|
|
103
|
+
arch: config.arch,
|
|
104
|
+
signing: config.os === "macos" ? "developer-id-hardened-runtime" : config.os === "windows" ? "authenticode" : "none",
|
|
105
|
+
files: preSign.map((file) => {
|
|
106
|
+
const after = signed[file.tool];
|
|
107
|
+
if (!after || !SHA256.test(after.sha256 ?? "")) throw invalid("EXTERNAL_BIN_RECEIPT_INVALID", `no signed digest for ${file.tool}`);
|
|
108
|
+
return {
|
|
109
|
+
name: file.tool,
|
|
110
|
+
preSignSha256: file.preSignSha256,
|
|
111
|
+
signedSha256: after.sha256,
|
|
112
|
+
sizeBytes: after.sizeBytes,
|
|
113
|
+
...(file.bundlePath ? { bundlePath: file.bundlePath } : { bundledName: file.bundledName }),
|
|
114
|
+
};
|
|
115
|
+
}),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function validateExternalBinReceipt(receipt, expected = {}) {
|
|
120
|
+
const fail = (message) => { throw invalid("EXTERNAL_BIN_RECEIPT_INVALID", `external bin receipt: ${message}`); };
|
|
121
|
+
if (!isObject(receipt) || receipt.schema !== 1 || receipt.contract !== EXTERNAL_BIN_CONTRACT) fail("unrecognized contract");
|
|
122
|
+
if (!Array.isArray(receipt.files) || !receipt.files.length) fail("no files");
|
|
123
|
+
for (const file of receipt.files) {
|
|
124
|
+
if (typeof file.name !== "string" || !file.name) fail("file without a name");
|
|
125
|
+
for (const key of ["preSignSha256", "signedSha256"]) if (!SHA256.test(file[key] ?? "")) fail(`${file.name}.${key} must be a SHA-256`);
|
|
126
|
+
if (!Number.isSafeInteger(file.sizeBytes) || file.sizeBytes <= 0) fail(`${file.name}.sizeBytes must be positive`);
|
|
127
|
+
if (file.preSignSha256 === file.signedSha256) fail(`${file.name} was not changed by signing`);
|
|
128
|
+
}
|
|
129
|
+
if (new Set(receipt.files.map((file) => file.name)).size !== receipt.files.length) fail("duplicate name");
|
|
130
|
+
if (expected.os && receipt.os !== expected.os) fail(`os ${receipt.os} is not ${expected.os}`);
|
|
131
|
+
if (expected.names && JSON.stringify([...expected.names].sort()) !== JSON.stringify(receipt.files.map((file) => file.name).sort())) fail("names differ from the configuration");
|
|
132
|
+
return receipt;
|
|
133
|
+
}
|
package/github-release.mjs
CHANGED
|
@@ -329,7 +329,7 @@ function verifyRemoteAsset(tag, repo, asset, run, evidence) {
|
|
|
329
329
|
if (inspectRemoteAsset(tag, repo, asset, run, evidence) !== "match") throw new Error(`GitHub release asset missing; inspect remote asset ${repo}/${tag}/${path.basename(asset)}`);
|
|
330
330
|
}
|
|
331
331
|
|
|
332
|
-
function inspectRemoteAsset(tag, repo, asset, run, evidence) {
|
|
332
|
+
export function inspectRemoteAsset(tag, repo, asset, run, evidence) {
|
|
333
333
|
const verifyDir = `${path.dirname(asset)}/verify-${path.basename(asset)}`;
|
|
334
334
|
rmSync(verifyDir, { recursive: true, force: true });
|
|
335
335
|
mkdirSync(verifyDir, { recursive: true });
|
|
@@ -350,7 +350,7 @@ function sha256(file) {
|
|
|
350
350
|
return createHash("sha256").update(readFileSync(file)).digest("hex");
|
|
351
351
|
}
|
|
352
352
|
|
|
353
|
-
function runCommand(command, args, { allowFailure = false, cwd } = {}) {
|
|
353
|
+
export function runCommand(command, args, { allowFailure = false, cwd } = {}) {
|
|
354
354
|
const result = spawnSync(command, args, { cwd, encoding: "utf8", windowsHide: true });
|
|
355
355
|
const output = result.stdout?.trim() ?? "";
|
|
356
356
|
if (result.status !== 0) {
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// GitHub Releases lane of `right-release publish-update` (Orthic-Labs/rightkit#237).
|
|
2
|
+
//
|
|
3
|
+
// The updater artifacts and their minisign signatures are the ones the r2-rightapps lane already uses: they are
|
|
4
|
+
// signed once by `sign-updater.mjs` with the suite updater key (the same TAURI_SIGNING_PRIVATE_KEY / key file as
|
|
5
|
+
// every other app), sealed, and re-verified by `verifyPublicationRelease`. This module only turns the same
|
|
6
|
+
// `platforms` map the r2 lane builds ({ <platform>: { signature, url } }) into a Tauri v2 static `latest.json`
|
|
7
|
+
// and uploads it, with the bundles and their `.sig` files, as assets of the GitHub release `v<version>`, so
|
|
8
|
+
// `https://github.com/<owner>/<repo>/releases/latest/download/latest.json` resolves. It adds no key and no trust root.
|
|
9
|
+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { PublicationError, digestBytes, readVerifiedArtifact, verifyArtifactFile } from "./publication-artifacts.mjs";
|
|
12
|
+
import { inspectRemoteAsset, runCommand } from "./github-release.mjs";
|
|
13
|
+
|
|
14
|
+
const REPO_RE = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
|
|
15
|
+
const SEMVER_RE = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?$/;
|
|
16
|
+
// GitHub rewrites other characters in an asset name, which would make the manifest url differ from the asset.
|
|
17
|
+
const ASSET_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._+-]{0,199}$/;
|
|
18
|
+
// Tauri v2 updater platform keys: <os>-<arch>, optionally <os>-<arch>-<installer>.
|
|
19
|
+
const PLATFORM_RE = /^(?:windows|darwin|linux)-(?:x86_64|aarch64|i686|armv7)(?:-(?:nsis|msi|app|appimage|deb|rpm))?$/;
|
|
20
|
+
const KEY_ID_RE = /^[0-9A-F]{16}$/;
|
|
21
|
+
|
|
22
|
+
export const LATEST_JSON_NAME = "latest.json";
|
|
23
|
+
|
|
24
|
+
export function assertGitHubRepository(repo) {
|
|
25
|
+
if (!REPO_RE.test(repo ?? "")) throw new PublicationError("PUBLICATION_GITHUB_REPOSITORY_INVALID", "release config distribution.repository must be owner/repo for github-releases");
|
|
26
|
+
return repo;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function assertUpdaterPlatform(platform) {
|
|
30
|
+
if (!PLATFORM_RE.test(platform ?? "")) throw new PublicationError("PUBLICATION_UPDATER_PLATFORM_INVALID", `updater platform must be a Tauri target such as windows-x86_64 or darwin-aarch64, got: ${platform}`);
|
|
31
|
+
return platform;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function assertAssetName(name) {
|
|
35
|
+
if (!ASSET_NAME_RE.test(name ?? "")) throw new PublicationError("PUBLICATION_ASSET_NAME_INVALID", `GitHub release asset names must match ${ASSET_NAME_RE}, got: ${name}`);
|
|
36
|
+
return name;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function assertUpdaterVersion(version) {
|
|
40
|
+
if (!SEMVER_RE.test(version ?? "")) throw new PublicationError("PUBLICATION_VERSION_INVALID", `updater version must be SemVer, got: ${version}`);
|
|
41
|
+
return version;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const githubTag = (version) => `v${assertUpdaterVersion(version)}`;
|
|
45
|
+
|
|
46
|
+
export function githubAssetUrl(repo, tag, name) {
|
|
47
|
+
return `https://github.com/${assertGitHubRepository(repo)}/releases/download/${tag}/${assertAssetName(name)}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The stable URL an app configures as its updater endpoint (resolves to the newest non-draft, non-prerelease release). */
|
|
51
|
+
export function githubLatestJsonUrl(repo) {
|
|
52
|
+
return `https://github.com/${assertGitHubRepository(repo)}/releases/latest/download/${LATEST_JSON_NAME}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Key id (16 upper-case hex digits, as minisign prints it) of the key that made a Tauri `.sig` file's content.
|
|
57
|
+
* The `.sig` text is base64 of a minisign signature file; its second line is base64 of
|
|
58
|
+
* [2-byte algorithm][8-byte key id, little endian][64-byte signature].
|
|
59
|
+
*/
|
|
60
|
+
export function updaterSignatureKeyId(signatureText) {
|
|
61
|
+
try {
|
|
62
|
+
const lines = Buffer.from(String(signatureText).trim(), "base64").toString("utf8").split(/\r?\n/);
|
|
63
|
+
const raw = Buffer.from(lines[1] ?? "", "base64");
|
|
64
|
+
if (raw.length !== 74 || raw[0] !== 0x45 || (raw[1] !== 0x64 && raw[1] !== 0x44)) return null;
|
|
65
|
+
return Buffer.from(raw.subarray(2, 10)).reverse().toString("hex").toUpperCase();
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Refuses a signature made by any key other than the configured suite key id (distribution.updaterKeyId). */
|
|
72
|
+
export function assertUpdaterKeyId(signatureText, expectedKeyId, label = "updater signature") {
|
|
73
|
+
if (expectedKeyId === undefined) return;
|
|
74
|
+
if (!KEY_ID_RE.test(expectedKeyId)) throw new PublicationError("PUBLICATION_SIGNATURE_KEY_MISMATCH", "distribution.updaterKeyId must be 16 upper-case hex digits");
|
|
75
|
+
const actual = updaterSignatureKeyId(signatureText);
|
|
76
|
+
if (actual !== expectedKeyId) throw new PublicationError("PUBLICATION_SIGNATURE_KEY_MISMATCH", `${label} was not made by updater key ${expectedKeyId} (found ${actual ?? "unreadable"})`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Tauri v2 static manifest. `platforms` is { <target>: { signature, url } }, exactly what the r2 lane builds. */
|
|
80
|
+
export function buildLatestJson({ version, notes, pubDate, platforms }) {
|
|
81
|
+
assertUpdaterVersion(version);
|
|
82
|
+
const entries = Object.entries(platforms ?? {});
|
|
83
|
+
if (!entries.length) throw new PublicationError("PUBLICATION_ROUTE_INVALID", "latest.json needs at least one platform");
|
|
84
|
+
const sorted = {};
|
|
85
|
+
for (const [name, entry] of entries.sort(([a], [b]) => a.localeCompare(b))) {
|
|
86
|
+
assertUpdaterPlatform(name);
|
|
87
|
+
if (!entry?.signature || !/^https:\/\//.test(entry.url ?? "")) throw new PublicationError("PUBLICATION_SIGNATURE_MISSING", `latest.json platform ${name} needs a signature and an https url`);
|
|
88
|
+
sorted[name] = { signature: entry.signature, url: entry.url };
|
|
89
|
+
}
|
|
90
|
+
return { version, notes: notes ?? "", pub_date: pubDate, platforms: sorted };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The win and mac runs publish separately to one latest.json: keep the other platform's entries for the same version. */
|
|
94
|
+
export function mergeLatestJson(existing, next) {
|
|
95
|
+
if (!existing) return next;
|
|
96
|
+
if (existing.version !== next.version) throw new PublicationError("PUBLICATION_MANIFEST_CONFLICT", `remote latest.json is for ${existing.version}, not ${next.version}; refusing to overwrite it`);
|
|
97
|
+
return buildLatestJson({ version: next.version, notes: next.notes, pubDate: next.pub_date, platforms: { ...(existing.platforms ?? {}), ...next.platforms } });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Uploads the sealed updater bundles, their signatures and latest.json to release `tag` of `repo`, creating the
|
|
102
|
+
* (public, non-draft) release when it does not exist. Every sealed asset is byte-verified after upload; latest.json
|
|
103
|
+
* is read back and compared. `assets` is [{ file, evidence }] with the sealed artifact as evidence.
|
|
104
|
+
*/
|
|
105
|
+
export function publishGitHubUpdaterRelease({ repo, tag, title, notes, assets, latest, stateDir, dryRun = false, run = runCommand }) {
|
|
106
|
+
assertGitHubRepository(repo);
|
|
107
|
+
const byName = new Map();
|
|
108
|
+
for (const asset of assets) {
|
|
109
|
+
const name = assertAssetName(path.basename(asset.file));
|
|
110
|
+
if (byName.has(name) && byName.get(name).file !== asset.file) throw new PublicationError("PUBLICATION_ASSET_NAME_INVALID", `duplicate updater asset name: ${name}`);
|
|
111
|
+
byName.set(name, asset);
|
|
112
|
+
}
|
|
113
|
+
// A dry run makes no network call: it only reports the assets it would publish.
|
|
114
|
+
if (dryRun) return { status: "would-publish", tag, assets: [...byName.keys(), LATEST_JSON_NAME] };
|
|
115
|
+
const existing = run("gh", ["release", "view", tag, "--repo", repo, "--json", "tagName"], { allowFailure: true });
|
|
116
|
+
|
|
117
|
+
mkdirSync(stateDir, { recursive: true });
|
|
118
|
+
if (!existing.ok) {
|
|
119
|
+
const notesFile = path.join(stateDir, "release-notes.md");
|
|
120
|
+
writeFileSync(notesFile, `${notes}\n`);
|
|
121
|
+
try { run("gh", ["release", "create", tag, "--repo", repo, "--title", title, "--notes-file", notesFile]); }
|
|
122
|
+
finally { rmSync(notesFile, { force: true }); }
|
|
123
|
+
}
|
|
124
|
+
const missing = [...byName.values()].filter((asset) => inspectRemoteAsset(tag, repo, asset.file, run, asset.evidence) === "missing");
|
|
125
|
+
if (missing.length) run("gh", ["release", "upload", tag, ...missing.map((asset) => asset.file), "--repo", repo]);
|
|
126
|
+
for (const asset of byName.values()) {
|
|
127
|
+
if (inspectRemoteAsset(tag, repo, asset.file, run, asset.evidence) !== "match") throw new PublicationError("PUBLICATION_REMOTE_VERIFY_FAILED", `GitHub release asset missing after upload: ${repo}/${tag}/${path.basename(asset.file)}`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Merge with the other platform's entries, then upload latest.json last so it never points at a missing asset.
|
|
131
|
+
const downloadDir = path.join(stateDir, "remote-latest");
|
|
132
|
+
rmSync(downloadDir, { recursive: true, force: true });
|
|
133
|
+
mkdirSync(downloadDir, { recursive: true });
|
|
134
|
+
let remote = null;
|
|
135
|
+
const fetched = run("gh", ["release", "download", tag, "--repo", repo, "--pattern", LATEST_JSON_NAME, "--dir", downloadDir], { allowFailure: true });
|
|
136
|
+
if (fetched.ok) remote = JSON.parse(readFileSync(path.join(downloadDir, LATEST_JSON_NAME), "utf8"));
|
|
137
|
+
else if (!/no assets match|not found/i.test(`${fetched.output ?? ""}\n${fetched.error ?? ""}`)) throw new PublicationError("PUBLICATION_REMOTE_VERIFY_FAILED", `cannot read remote latest.json: ${fetched.error || fetched.output}`);
|
|
138
|
+
rmSync(downloadDir, { recursive: true, force: true });
|
|
139
|
+
const merged = mergeLatestJson(remote, latest);
|
|
140
|
+
const bytes = Buffer.from(`${JSON.stringify(merged, null, 2)}\n`);
|
|
141
|
+
const manifestFile = path.join(stateDir, LATEST_JSON_NAME);
|
|
142
|
+
writeFileSync(manifestFile, bytes);
|
|
143
|
+
run("gh", ["release", "upload", tag, manifestFile, "--repo", repo, "--clobber"]);
|
|
144
|
+
const verifyDir = path.join(stateDir, "verify-latest");
|
|
145
|
+
rmSync(verifyDir, { recursive: true, force: true });
|
|
146
|
+
mkdirSync(verifyDir, { recursive: true });
|
|
147
|
+
try {
|
|
148
|
+
run("gh", ["release", "download", tag, "--repo", repo, "--pattern", LATEST_JSON_NAME, "--dir", verifyDir]);
|
|
149
|
+
verifyArtifactFile(path.join(verifyDir, LATEST_JSON_NAME), readVerifiedArtifact(manifestFile, { sha256: digestBytes(bytes) }));
|
|
150
|
+
} finally {
|
|
151
|
+
rmSync(verifyDir, { recursive: true, force: true });
|
|
152
|
+
}
|
|
153
|
+
return { status: existing.ok ? "updated" : "created", tag, assets: [...byName.keys(), LATEST_JSON_NAME], latestJsonUrl: githubLatestJsonUrl(repo), manifest: merged };
|
|
154
|
+
}
|
package/hardeningscan.mjs
CHANGED
|
@@ -70,7 +70,7 @@ const contentRules = [
|
|
|
70
70
|
["gpl-flag", /--enable-gpl|--enable-nonfree/gi, "GPL/nonfree ffmpeg flag leaked"],
|
|
71
71
|
["stale-model", /tdt_w15_p6/gi, "stale TDT p6 string leaked"],
|
|
72
72
|
// Not allowable through hardening evidence: a shipped artifact never contains the QA control server.
|
|
73
|
-
["qa-control-marker", new RegExp(QA_CONTROL_MARKER, "g"), "QA control server shipped (rightkit-qa-driver qa-control)"],
|
|
73
|
+
["qa-control-marker", new RegExp(QA_CONTROL_MARKER, "g"), "QA control server shipped (rightkit-qa control, or rightkit-qa-driver qa-control)"],
|
|
74
74
|
["experiment-model", /Canary-Qwen|Nemotron|dqlstm|bias-experiments/gi, "experiment/model-history marker leaked"],
|
|
75
75
|
];
|
|
76
76
|
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import nodeFs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
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
|
+
* macOS and Windows tie privacy grants to the path and the code identity, so an install or update replaces the
|
|
7
|
+
* copy at ONE fixed path. Rollback is a persistent `<name>.previous` beside it (rename it back). Nothing ever runs
|
|
8
|
+
* from `.previous` or from the staging copy.
|
|
9
|
+
*/
|
|
10
|
+
export const PREVIOUS_SUFFIX = ".previous";
|
|
11
|
+
export const STAGING_SUFFIX = ".staging";
|
|
12
|
+
|
|
13
|
+
export const previousPathOf = (destination) => `${destination}${PREVIOUS_SUFFIX}`;
|
|
14
|
+
/** Fixed (not per-pid) staging sibling. Nothing runs from it: it is fully written and verified, then renamed. */
|
|
15
|
+
export const stagingPathOf = (destination) => `${destination}${STAGING_SUFFIX}`;
|
|
16
|
+
|
|
17
|
+
const exists = (fs, file) => {
|
|
18
|
+
if (typeof fs.lstatSync !== "function") return fs.existsSync(file);
|
|
19
|
+
try { fs.lstatSync(file); return true; } catch (error) { if (error?.code === "ENOENT") return false; throw error; }
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Swap `staging` into `destination`: the old destination becomes `previous` (replacing any older `.previous`),
|
|
24
|
+
* then `verify(destination)` runs. Any failure removes the new copy and renames `previous` back, so the fixed
|
|
25
|
+
* path is never left empty. Renames only: no copy, no new path.
|
|
26
|
+
* @returns {{destination: string, previous: string|null}} previous is null when nothing was installed before.
|
|
27
|
+
*/
|
|
28
|
+
export function swapInPlace({ staging, destination, previous = previousPathOf(destination), verify = () => {}, fs = nodeFs }) {
|
|
29
|
+
if (previous === destination || staging === destination || staging === previous) throw new Error("swapInPlace needs three distinct paths");
|
|
30
|
+
let movedAside = false;
|
|
31
|
+
try {
|
|
32
|
+
if (exists(fs, destination)) {
|
|
33
|
+
if (exists(fs, previous)) fs.rmSync(previous, { recursive: true, force: true });
|
|
34
|
+
fs.renameSync(destination, previous);
|
|
35
|
+
movedAside = true;
|
|
36
|
+
}
|
|
37
|
+
fs.renameSync(staging, destination);
|
|
38
|
+
verify(destination);
|
|
39
|
+
} catch (error) {
|
|
40
|
+
try { fs.rmSync(destination, { recursive: true, force: true }); } catch { /* keep the original error */ }
|
|
41
|
+
if (movedAside && exists(fs, previous)) fs.renameSync(previous, destination);
|
|
42
|
+
try { fs.rmSync(staging, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
return { destination, previous: movedAside ? previous : null };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Copy one file to its fixed path in place: temp sibling, then rename, keeping `<file>.previous`. Returns the path. */
|
|
49
|
+
export function replaceFileInPlace(from, destination, { fs = nodeFs } = {}) {
|
|
50
|
+
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
51
|
+
const staging = stagingPathOf(destination);
|
|
52
|
+
fs.rmSync(staging, { force: true });
|
|
53
|
+
fs.copyFileSync(from, staging);
|
|
54
|
+
swapInPlace({ staging, destination });
|
|
55
|
+
return destination;
|
|
56
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import nodeFs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { spawnSync } from "./background-process.mjs";
|
|
5
|
+
import { previousPathOf } from "./in-place-install.mjs";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `right-release install-app --installer <Setup.exe> --dest <install folder> --exe <App.exe>` on Windows (Orthic-Labs/rightkit#258).
|
|
9
|
+
*
|
|
10
|
+
* The same rule as the macOS install: the app is installed and updated IN PLACE at one fixed folder (`--dest`, the
|
|
11
|
+
* folder the NSIS installer already owns, for example `%LOCALAPPDATA%\Programs\Pulse`). Rollback is one
|
|
12
|
+
* `<dest>.previous` beside it (a copy of the folder as it was; replaced on each install; nothing runs from it), never a
|
|
13
|
+
* versioned, digest or per-run folder. The silent installer is pinned to the fixed folder with `/D=` so it cannot choose another one.
|
|
14
|
+
*
|
|
15
|
+
* Why a copy and not a rename: a running app holds its folder open on Windows, so a rename fails; the installer closes
|
|
16
|
+
* the app and updates the same path in place instead. A failed install copies `<dest>.previous` back over `<dest>`.
|
|
17
|
+
*/
|
|
18
|
+
export class InstallAppWindowsError extends Error {
|
|
19
|
+
constructor(code, message) { super(message); this.name = "InstallAppWindowsError"; this.code = code; }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function windowsInstallArguments(dest) {
|
|
23
|
+
// NSIS: /D must be the last argument, unquoted, and names the install folder.
|
|
24
|
+
return ["/S", `/D=${dest}`];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const sha256 = (fs, file) => createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
|
28
|
+
|
|
29
|
+
export function installAppWindows(opts, { platform = process.platform, fs = nodeFs, run = spawnSync, event = () => {} } = {}) {
|
|
30
|
+
if (platform !== "win32") throw new InstallAppWindowsError("unsupported", `the Windows installer mode runs on win32, not ${platform}`);
|
|
31
|
+
const { installer, dest, exe } = opts;
|
|
32
|
+
if (!installer || !path.win32.isAbsolute(installer)) throw new InstallAppWindowsError("usage", "--installer must be an absolute path to the NSIS setup executable");
|
|
33
|
+
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
|
+
if (!exe || exe !== path.win32.basename(exe)) throw new InstallAppWindowsError("usage", "--exe must be the bare executable name inside --dest");
|
|
35
|
+
if (!fs.existsSync(installer)) throw new InstallAppWindowsError("installer-missing", `installer not found: ${installer}`);
|
|
36
|
+
const previous = previousPathOf(dest);
|
|
37
|
+
const installed = path.win32.join(dest, exe);
|
|
38
|
+
const hadInstall = fs.existsSync(dest);
|
|
39
|
+
event("start", { installer, dest, replacing: hadInstall });
|
|
40
|
+
if (hadInstall) {
|
|
41
|
+
// One rollback copy, replaced on every install.
|
|
42
|
+
fs.rmSync(previous, { recursive: true, force: true });
|
|
43
|
+
fs.cpSync(dest, previous, { recursive: true });
|
|
44
|
+
event("previous-kept", { previous });
|
|
45
|
+
}
|
|
46
|
+
const restore = () => {
|
|
47
|
+
if (!hadInstall || !fs.existsSync(previous)) return false;
|
|
48
|
+
fs.cpSync(previous, dest, { recursive: true, force: true });
|
|
49
|
+
return true;
|
|
50
|
+
};
|
|
51
|
+
const result = run(installer, windowsInstallArguments(dest), { stdio: "inherit", windowsHide: true, shell: false });
|
|
52
|
+
if (result.status !== 0) {
|
|
53
|
+
const restored = restore();
|
|
54
|
+
throw new InstallAppWindowsError("installer-failed", `installer exited ${result.status ?? "abnormally"}${result.error ? `: ${result.error.message}` : ""}; ${restored ? `restored ${dest} from ${previous}` : "nothing was installed before"}`);
|
|
55
|
+
}
|
|
56
|
+
// The installer must have updated the fixed folder: the executable is at the same path as before, not in a new folder.
|
|
57
|
+
if (!fs.existsSync(installed) || fs.statSync(installed).size === 0) {
|
|
58
|
+
const restored = restore();
|
|
59
|
+
throw new InstallAppWindowsError("path-changed", `the installer did not place ${exe} at the fixed path ${installed}; ${restored ? `restored from ${previous}` : "nothing was installed before"}`);
|
|
60
|
+
}
|
|
61
|
+
event("replaced", { installed, previous: hadInstall ? previous : null, sha256: sha256(fs, installed) });
|
|
62
|
+
return { ok: true, installed, previous: hadInstall ? previous : null };
|
|
63
|
+
}
|