@rightkit/release 0.2.172 → 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.
@@ -0,0 +1,321 @@
1
+ // Declarative default for the Windows two-phase NSIS package step (bogusyogi/rightkit#345).
2
+ //
3
+ // A target that declares `winPackage: { installer: "nsis", productName, updater, ... }` and no `package`
4
+ // script is packaged by this recipe instead of an app-local build-windows-nsis.mjs. The phases are the ones
5
+ // the eight app scripts share, in the only order that keeps Authenticode valid:
6
+ //
7
+ // build tauri build --no-bundle (raw EXE; no installer yet)
8
+ // marker patch the Tauri bundle marker to NSS (BEFORE signing: Tauri rewrites it while bundling)
9
+ // sign-raw sign every executable the installer wraps (sidecars first, then the raw EXE)
10
+ // bundle tauri bundle --bundles nsis (wraps the signed bytes; never recompiles)
11
+ // verify raw EXE unchanged on disk + installer embeds exactly the signed EXE
12
+ // sign-installer Authenticode-sign the installer
13
+ // updater sign-updater over the SIGNED installer (a signature made earlier is stale: signing changes bytes)
14
+ //
15
+ // Every spawn, sign and file-system effect goes through `deps`, so the recipe is testable with fakes only.
16
+
17
+ import { createHash } from "node:crypto";
18
+ import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
19
+ import path from "node:path";
20
+ import { fileURLToPath } from "node:url";
21
+ import { spawnSync } from "./background-process.mjs";
22
+ import { verifyNsisEmbeddedBinary } from "./nsis-payload.mjs";
23
+ import { signingReceiptPath } from "./signing-receipts.mjs";
24
+ import { patchTauriBundleType } from "./tauri-bundle-marker.mjs";
25
+
26
+ const TOOL_ROOT = path.dirname(fileURLToPath(import.meta.url));
27
+ const SIGN_WINDOWS = path.join(TOOL_ROOT, "sign-windows.mjs");
28
+ const SIGN_UPDATER = path.join(TOOL_ROOT, "sign-updater.mjs");
29
+
30
+ export const WIN_PACKAGE_PHASES = Object.freeze(["build", "marker", "sign-raw", "bundle", "verify", "sign-installer", "updater"]);
31
+ export const WIN_PACKAGE_DEFAULT_TRIPLE = "x86_64-pc-windows-msvc";
32
+ export const WIN_PACKAGE_CONFIG_ERROR = "RK_WIN_PACKAGE_CONFIG";
33
+ export const WIN_PACKAGE_PHASE_ERROR = "RK_WIN_PACKAGE_PHASE";
34
+
35
+ const ALLOWED_KEYS = new Set(["installer", "productName", "sidecars", "updater", "perMachine", "rawExe", "installerFile", "tauriCwd", "triple"]);
36
+
37
+ /** Invalid `targets.win.winPackage` configuration. `issues` lists every problem found, not just the first. */
38
+ export class WinPackageConfigError extends Error {
39
+ constructor(issues) {
40
+ super(`invalid winPackage configuration:\n${issues.map((issue) => `- ${issue}`).join("\n")}`);
41
+ this.name = "WinPackageConfigError";
42
+ this.code = WIN_PACKAGE_CONFIG_ERROR;
43
+ this.issues = issues;
44
+ }
45
+ }
46
+
47
+ /** A phase of a valid recipe failed. `phase` names it; `cause` carries the underlying error when there is one. */
48
+ export class WinPackageRecipeError extends Error {
49
+ constructor(phase, message, cause) {
50
+ super(`winPackage ${phase} failed: ${message}`);
51
+ this.name = "WinPackageRecipeError";
52
+ this.code = WIN_PACKAGE_PHASE_ERROR;
53
+ this.phase = phase;
54
+ if (cause) this.cause = cause;
55
+ }
56
+ }
57
+
58
+ function isPlainObject(value) {
59
+ return value !== null && typeof value === "object" && !Array.isArray(value);
60
+ }
61
+
62
+ function relativeInside(appRoot, value, label, issues) {
63
+ if (typeof value !== "string" || value.trim() === "" || value !== value.trim()) {
64
+ issues.push(`${label} must be a non-empty path string without surrounding whitespace`);
65
+ return null;
66
+ }
67
+ if (value.includes("\0") || path.win32.isAbsolute(value) || path.posix.isAbsolute(value)) {
68
+ issues.push(`${label} must be relative to the app root, received ${JSON.stringify(value)}`);
69
+ return null;
70
+ }
71
+ const resolved = path.resolve(appRoot, value);
72
+ const rel = path.relative(path.resolve(appRoot), resolved);
73
+ if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel)) {
74
+ issues.push(`${label} must stay inside the app root, received ${JSON.stringify(value)}`);
75
+ return null;
76
+ }
77
+ return resolved;
78
+ }
79
+
80
+ /**
81
+ * Validate `target.winPackage` strictly and return the normalised plan. Throws WinPackageConfigError listing
82
+ * every problem. Unknown keys are refused so a typo cannot silently drop a sidecar or the updater step.
83
+ */
84
+ export function validateWinPackage({ appRoot, config = {}, target } = {}) {
85
+ const issues = [];
86
+ if (typeof appRoot !== "string" || appRoot === "") issues.push("appRoot must be a non-empty string");
87
+ if (!isPlainObject(target)) throw new WinPackageConfigError([...issues, "target must be an object"]);
88
+ const spec = target.winPackage;
89
+ if (!isPlainObject(spec)) throw new WinPackageConfigError([...issues, "target.winPackage must be an object"]);
90
+ if (target.package !== undefined) issues.push("target.winPackage and target.package are mutually exclusive; remove the package script");
91
+ for (const key of Object.keys(spec)) if (!ALLOWED_KEYS.has(key)) issues.push(`unknown winPackage key: ${key}`);
92
+
93
+ if (spec.installer !== "nsis") issues.push(`winPackage.installer must be "nsis", received ${JSON.stringify(spec.installer)}`);
94
+ if (typeof spec.productName !== "string" || !/^[^\\/:*?"<>|\0-\x1f]{1,64}$/.test(spec.productName) || spec.productName !== spec.productName.trim()) {
95
+ issues.push("winPackage.productName must be 1-64 characters with no path separators, reserved file-name characters or surrounding whitespace");
96
+ }
97
+ if (typeof spec.updater !== "boolean") issues.push("winPackage.updater must be true or false (no default: an updater app must not lose its signature silently)");
98
+ if (spec.perMachine !== undefined && typeof spec.perMachine !== "boolean") issues.push("winPackage.perMachine must be a boolean when present");
99
+ const triple = spec.triple ?? WIN_PACKAGE_DEFAULT_TRIPLE;
100
+ if (typeof triple !== "string" || !/^[A-Za-z0-9_.-]+$/.test(triple) || !/windows/.test(triple)) issues.push(`winPackage.triple must be a Windows target triple, received ${JSON.stringify(spec.triple)}`);
101
+ const packageManager = config.packageManager ?? "pnpm";
102
+ if (packageManager !== "pnpm") issues.push(`winPackage runs Tauri through pnpm; config.packageManager is ${JSON.stringify(packageManager)}`);
103
+
104
+ const rawDeclared = spec.rawExe ?? (Array.isArray(target.sign?.prePackageFiles) && target.sign.prePackageFiles.length === 1 ? target.sign.prePackageFiles[0] : undefined);
105
+ const installerDeclared = spec.installerFile ?? (Array.isArray(target.sign?.files) && target.sign.files.length === 1 ? target.sign.files[0] : undefined);
106
+ if (rawDeclared === undefined) issues.push("the raw EXE path is unknown: set winPackage.rawExe or exactly one target.sign.prePackageFiles entry");
107
+ if (installerDeclared === undefined) issues.push("the installer path is unknown: set winPackage.installerFile or exactly one target.sign.files entry");
108
+ const rawExe = rawDeclared === undefined ? null : relativeInside(appRoot, rawDeclared, "rawExe", issues);
109
+ const installerFile = installerDeclared === undefined ? null : relativeInside(appRoot, installerDeclared, "installerFile", issues);
110
+ if (rawExe && !/\.exe$/i.test(rawExe)) issues.push(`rawExe must be an .exe, received ${rawDeclared}`);
111
+ if (installerFile && !/\.exe$/i.test(installerFile)) issues.push(`installerFile must be an .exe, received ${installerDeclared}`);
112
+ if (rawExe && installerFile && rawExe.toLowerCase() === installerFile.toLowerCase()) issues.push("rawExe and installerFile must be different files");
113
+ const tauriCwd = spec.tauriCwd === undefined ? path.resolve(appRoot) : relativeInside(appRoot, spec.tauriCwd, "tauriCwd", issues);
114
+
115
+ const sidecars = [];
116
+ if (spec.sidecars !== undefined) {
117
+ if (!Array.isArray(spec.sidecars)) issues.push("winPackage.sidecars must be an array of relative executable paths");
118
+ else {
119
+ const seen = new Set();
120
+ spec.sidecars.forEach((entry, index) => {
121
+ const resolved = relativeInside(appRoot, entry, `sidecars[${index}]`, issues);
122
+ if (!resolved) return;
123
+ if (!/\.(exe|dll)$/i.test(resolved)) issues.push(`sidecars[${index}] must be an .exe or .dll, received ${entry}`);
124
+ const key = resolved.toLowerCase();
125
+ if (seen.has(key)) issues.push(`sidecars[${index}] duplicates an earlier entry: ${entry}`);
126
+ else if (rawExe && key === rawExe.toLowerCase()) issues.push(`sidecars[${index}] is the raw EXE; it is signed anyway`);
127
+ seen.add(key);
128
+ sidecars.push(resolved);
129
+ });
130
+ }
131
+ }
132
+ if (spec.updater === true && target.updater !== undefined && !isPlainObject(target.updater)) issues.push("target.updater must be an object when winPackage.updater is true");
133
+
134
+ if (issues.length) throw new WinPackageConfigError(issues);
135
+ return Object.freeze({
136
+ appRoot: path.resolve(appRoot),
137
+ productName: spec.productName,
138
+ updater: spec.updater,
139
+ perMachine: spec.perMachine ?? false,
140
+ triple,
141
+ rawExe,
142
+ installerFile,
143
+ sidecars: Object.freeze(sidecars),
144
+ tauriCwd,
145
+ updaterKeyFile: typeof target.updater?.keyFile === "string" ? target.updater.keyFile : null,
146
+ });
147
+ }
148
+
149
+ function sha256File(file, read) {
150
+ return createHash("sha256").update(read(file)).digest("hex");
151
+ }
152
+
153
+ /**
154
+ * Real effects. Every one is replaceable through `deps`; tests replace all of them.
155
+ * `signWindows(files, { phase })` must throw on any failure and return `[{ file, sha256 }]` of the signed bytes.
156
+ */
157
+ export function defaultWinPackageDeps({ appRoot, target, env }) {
158
+ const runSync = (command, args, options) => spawnSync(command, args, options);
159
+ const spawn = (command, args, options) => {
160
+ const shell = process.platform === "win32" && !/\.(?:exe|com)$/i.test(command);
161
+ return runSync(command, args, { ...options, shell, stdio: "inherit" });
162
+ };
163
+ const receiptFor = (phase) => signingReceiptPath({ appRoot, target, platform: "win", phase, stateRoot: env.RIGHT_RELEASE_STATE_ROOT });
164
+ return {
165
+ env,
166
+ platform: process.platform,
167
+ dryRun: false,
168
+ log: (line) => console.log(line),
169
+ spawn,
170
+ signWindows(files, { phase, dryRun = false }) {
171
+ if (dryRun) {
172
+ const dry = spawn("node", [SIGN_WINDOWS, "--dry-run", ...files], { cwd: appRoot, env });
173
+ if (dry.error || dry.status !== 0) throw new Error(`sign-windows --dry-run exited ${dry.status ?? dry.error?.message}`);
174
+ return [];
175
+ }
176
+ const receipt = receiptFor(phase);
177
+ rmSync(receipt, { force: true });
178
+ mkdirSync(path.dirname(receipt), { recursive: true });
179
+ const result = spawn("node", [SIGN_WINDOWS, "--receipt", receipt, ...files], { cwd: appRoot, env });
180
+ if (result.error || result.status !== 0) throw new Error(`sign-windows exited ${result.status ?? result.error?.message}`);
181
+ const evidence = JSON.parse(readFileSync(receipt, "utf8"));
182
+ return evidence.files.map((item) => ({ file: path.resolve(item.file), sha256: item.after?.sha256 }));
183
+ },
184
+ signUpdater(file, { keyFile, dryRun = false }) {
185
+ const childEnv = { ...env };
186
+ delete childEnv.TAURI_SIGNING_PRIVATE_KEY;
187
+ delete childEnv.TAURI_SIGNING_PRIVATE_KEY_PASSWORD;
188
+ const result = spawn("node", [SIGN_UPDATER, "--file", file, ...(dryRun ? ["--dry-run"] : []), ...(keyFile ? ["--key-file", keyFile] : [])], { cwd: appRoot, env: childEnv });
189
+ if (result.error || result.status !== 0) throw new Error(`sign-updater exited ${result.status ?? result.error?.message}`);
190
+ },
191
+ patchMarker: (file, options) => patchTauriBundleType(file, options),
192
+ verifyEmbedded: (options) => verifyNsisEmbeddedBinary(options),
193
+ fs: { existsSync, readFileSync, rmSync, mkdirSync },
194
+ };
195
+ }
196
+
197
+ /** Where a produced artifact really is: the declared path, else the managed artifact directory (same rule as release.mjs). */
198
+ function locate(declared, { env, fs }) {
199
+ if (fs.existsSync(declared)) return declared;
200
+ const managed = env.RIGHT_RELEASE_ARTIFACT_DIR;
201
+ if (managed) {
202
+ const candidate = path.join(path.resolve(managed), path.basename(declared));
203
+ if (fs.existsSync(candidate)) return candidate;
204
+ }
205
+ return declared;
206
+ }
207
+
208
+ /**
209
+ * Run the whole recipe for one win target. Returns a receipt; throws WinPackageConfigError before any effect
210
+ * when the configuration is invalid, and WinPackageRecipeError (with `.phase`) when a phase fails. A failed
211
+ * phase stops the recipe: nothing later runs, so a failed signature can never be wrapped into an installer.
212
+ */
213
+ export function runWinPackageRecipe({ appRoot, config = {}, target, deps = {} } = {}) {
214
+ const plan = validateWinPackage({ appRoot, config, target });
215
+ const env = deps.env ?? process.env;
216
+ const base = defaultWinPackageDeps({ appRoot: plan.appRoot, target, env });
217
+ const d = { ...base, ...deps, env, fs: { ...base.fs, ...(deps.fs ?? {}) } };
218
+ const { fs } = d;
219
+ const dry = d.dryRun === true;
220
+ const phases = [];
221
+ const phase = (name, work) => {
222
+ d.log(`win-package: ${name}`);
223
+ try {
224
+ const value = work();
225
+ phases.push(name);
226
+ return value;
227
+ } catch (error) {
228
+ if (error instanceof WinPackageRecipeError) throw error;
229
+ throw new WinPackageRecipeError(name, error.message, error);
230
+ }
231
+ };
232
+ const tauri = (args, extraEnv) => {
233
+ if (dry) { d.log(`dry-run: pnpm exec tauri ${args.join(" ")}`); return; }
234
+ const result = d.spawn("pnpm", ["exec", "tauri", ...args], { cwd: plan.tauriCwd, env: { ...env, ...extraEnv } });
235
+ if (result?.error) throw new Error(`could not start tauri: ${result.error.message}`);
236
+ if (result?.status !== 0) throw new Error(`tauri ${args[0]} exited ${result?.status ?? "without a status"}`);
237
+ };
238
+ // `tauri bundle` never signs the updater artifact itself: createUpdaterArtifacts is forced off and sign-updater signs the
239
+ // Authenticode-signed installer. An empty key and password stop Tauri from ever prompting.
240
+ const quiet = { TAURI_SIGNING_PRIVATE_KEY: "", TAURI_SIGNING_PRIVATE_KEY_PASSWORD: env.TAURI_SIGNING_PRIVATE_KEY_PASSWORD ?? "" };
241
+
242
+ if (d.platform !== "win32" && !dry) throw new WinPackageRecipeError("build", `the Windows package recipe must run on Windows, not ${d.platform}`);
243
+
244
+ let raw = plan.rawExe;
245
+ const sidecars = [...plan.sidecars];
246
+
247
+ phase("build", () => {
248
+ if (!dry) fs.rmSync(plan.rawExe, { force: true });
249
+ tauri(["build", "--no-bundle", "--target", plan.triple], quiet);
250
+ if (dry) return;
251
+ raw = locate(plan.rawExe, { env, fs });
252
+ if (!fs.existsSync(raw)) throw new Error(`tauri build did not produce the raw EXE: ${plan.rawExe}`);
253
+ for (const sidecar of sidecars) if (!fs.existsSync(sidecar)) throw new Error(`declared sidecar does not exist: ${sidecar}`);
254
+ });
255
+
256
+ const marker = phase("marker", () => (dry ? null : d.patchMarker(raw, { bundle: "nsis" })));
257
+
258
+ const signedRaw = phase("sign-raw", () => {
259
+ // Sidecars first, raw EXE last, in one signing call: nothing the installer wraps is left unsigned.
260
+ const files = [...sidecars, raw];
261
+ const evidence = d.signWindows(files, { phase: "raw-exe", dryRun: dry });
262
+ if (dry) return null;
263
+ const byFile = new Map((evidence ?? []).map((item) => [path.resolve(item.file).toLowerCase(), item.sha256]));
264
+ for (const file of files) {
265
+ if (!/^[0-9a-f]{64}$/i.test(byFile.get(path.resolve(file).toLowerCase()) ?? "")) throw new Error(`no signed digest recorded for ${file}`);
266
+ }
267
+ return { sha256: byFile.get(path.resolve(raw).toLowerCase()), sidecars: Object.fromEntries(sidecars.map((file) => [file, byFile.get(path.resolve(file).toLowerCase())])) };
268
+ });
269
+
270
+ const installerBase = plan.installerFile;
271
+ let installer = installerBase;
272
+ phase("bundle", () => {
273
+ if (!dry) for (const stale of [installerBase, `${installerBase}.sig`, `${installerBase}.zip`, `${installerBase}.zip.sig`]) fs.rmSync(stale, { force: true });
274
+ const override = { bundle: { createUpdaterArtifacts: false, windows: { nsis: { installMode: plan.perMachine ? "perMachine" : "currentUser" } } } };
275
+ tauri(["bundle", "--bundles", "nsis", "--target", plan.triple, "--config", JSON.stringify(override)], quiet);
276
+ if (dry) return;
277
+ installer = locate(installerBase, { env, fs });
278
+ if (!fs.existsSync(installer)) throw new Error(`tauri bundle did not produce the installer: ${installerBase}`);
279
+ });
280
+
281
+ const embedded = phase("verify", () => {
282
+ if (dry) return null;
283
+ const current = sha256File(raw, fs.readFileSync);
284
+ if (current !== signedRaw.sha256) {
285
+ throw new Error(`bundling rewrote the signed raw EXE: signed sha256 ${signedRaw.sha256}, on-disk sha256 ${current}; apply every byte patch before signing`);
286
+ }
287
+ return d.verifyEmbedded({ installer, entryName: path.basename(raw), expectedSha256: signedRaw.sha256 });
288
+ });
289
+
290
+ const signedInstaller = phase("sign-installer", () => {
291
+ const evidence = d.signWindows([installer], { phase: "installer", dryRun: dry });
292
+ if (dry) return null;
293
+ const digest = (evidence ?? []).find((item) => path.resolve(item.file).toLowerCase() === path.resolve(installer).toLowerCase())?.sha256;
294
+ if (!/^[0-9a-f]{64}$/i.test(digest ?? "")) throw new Error(`no signed digest recorded for ${installer}`);
295
+ return { sha256: digest };
296
+ });
297
+
298
+ let updaterSignature = null;
299
+ if (plan.updater) {
300
+ phase("updater", () => {
301
+ if (!dry) fs.rmSync(`${installer}.sig`, { force: true });
302
+ d.signUpdater(installer, { keyFile: plan.updaterKeyFile, dryRun: dry });
303
+ if (dry) return;
304
+ if (!fs.existsSync(`${installer}.sig`)) throw new Error(`sign-updater did not write ${installer}.sig`);
305
+ updaterSignature = `${installer}.sig`;
306
+ });
307
+ }
308
+
309
+ return {
310
+ schema: 1,
311
+ productName: plan.productName,
312
+ dryRun: dry,
313
+ phases,
314
+ rawExe: { file: raw, signedSha256: signedRaw?.sha256 ?? null },
315
+ sidecars: signedRaw?.sidecars ?? {},
316
+ marker,
317
+ embedded,
318
+ installer: { file: installer, signedSha256: signedInstaller?.sha256 ?? null },
319
+ updaterSignature,
320
+ };
321
+ }