@rightkit/release 0.2.174 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +200 -2
- package/addon-command.mjs +3 -4
- package/android-play-upload.mjs +2 -1
- package/apple-profile-renew.mjs +11 -3
- package/artifact-storage.mjs +35 -2
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/darwin/rightkit-swap.sign.json +1 -1
- package/build-release.mjs +138 -47
- package/cli/right-release.mjs +40 -5
- package/codesign-environment.mjs +2 -2
- package/command-spec.mjs +80 -0
- package/content-digest.mjs +209 -0
- package/deps-cargo.mjs +55 -0
- package/deps.mjs +7 -0
- package/development.mjs +17 -6
- package/direct-bootstrap.mjs +5 -6
- package/disk-command.mjs +1 -0
- package/disk-policy.json +6 -2
- package/disk-stable-root.mjs +13 -9
- package/dist-scan.mjs +102 -0
- package/dmg-build.mjs +53 -4
- package/github-updater-release.mjs +70 -7
- package/icon-pack.mjs +117 -0
- package/in-place-install.mjs +17 -14
- package/init-command.mjs +116 -0
- package/install-app-windows.mjs +24 -7
- package/install-app.mjs +56 -42
- package/ios-asc-credentials.mjs +163 -0
- package/ios-build-context.mjs +129 -0
- package/ios-build-upload.mjs +282 -0
- package/ios-credentials-command.mjs +70 -0
- package/ios-dev.mjs +248 -0
- package/ios-host.mjs +76 -0
- package/ios-lock.mjs +75 -0
- package/ios-package-recipe.mjs +707 -0
- package/ios-testflight.mjs +266 -0
- package/ios-upload.mjs +313 -0
- package/local-publish.mjs +390 -47
- package/macho-approval-hash.mjs +181 -0
- package/managed-cargo.mjs +57 -1
- package/media-sidecar.mjs +20 -4
- package/mirror-root-artifact.mjs +74 -22
- package/native-cargo-layout.mjs +47 -4
- package/npm-license-notices.mjs +159 -0
- package/ownership-command.mjs +2 -1
- package/ownership-dependency-allowlist.json +2 -1
- package/ownership-evidence.mjs +30 -18
- package/ownership-key-command.mjs +69 -2
- package/ownership-mandatory-set.mjs +66 -16
- package/ownership-repositories.json +1 -1
- package/ownership-scope.mjs +94 -0
- package/ownership.mjs +86 -11
- package/package-recipe-targets.mjs +56 -2
- package/package.json +1 -1
- package/pe-content.mjs +133 -0
- package/preflight.mjs +14 -7
- package/project-type.mjs +3 -1
- package/protected-credentials.mjs +28 -1
- package/publication-artifacts.mjs +30 -5
- package/publication-gate.mjs +83 -5
- package/publish-cargo.mjs +124 -18
- package/publish-update.mjs +15 -2
- package/qa-build-features.mjs +16 -13
- package/qa-contract.mjs +30 -2
- package/qa-control-scan.mjs +4 -0
- package/release-dependencies-command.mjs +45 -0
- package/release-dependencies.mjs +60 -0
- package/release-invocation.mjs +118 -8
- package/release-switch-registry.json +126 -27
- package/release-switch-scan.mjs +62 -24
- package/release.mjs +144 -21
- package/rightkit-ownership.json +46 -2
- package/rightkit-versions.json +380 -189
- package/runtime-budgets.mjs +134 -23
- package/sign-updater.mjs +56 -59
- package/source-gate.mjs +8 -1
- package/stage-sidecar.mjs +634 -69
- package/target-distribution.mjs +5 -0
- package/test-command.mjs +190 -0
- package/toolchain-swift.mjs +239 -0
- package/upload-release.mjs +7 -2
- package/web-build.mjs +67 -0
package/dist-scan.mjs
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Fails when a built web bundle (vite `dist/`) carries dev, mock or fixture code (replaces per-app
|
|
3
|
+
// checkReleaseBundle.mjs copies). Scans text files for forbidden marker strings and flags chunk file
|
|
4
|
+
// names that match a forbidden name pattern. Read-only: it never writes, builds or follows a symlink.
|
|
5
|
+
// Usage: right-release dist-scan --dist <dir> --forbid <string>... [--forbid-file <path>]
|
|
6
|
+
// [--forbid-name <regex>]... [--ext js,mjs,css,html,json,map]
|
|
7
|
+
// A --forbid-file is JSON (`["a","b"]` or `{ "forbid": [...], "forbidNames": [...] }`) or plain text with
|
|
8
|
+
// one marker per line (blank lines and `#` comments ignored).
|
|
9
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
10
|
+
import { basename, join, relative, resolve } from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
|
|
13
|
+
export const DEFAULT_EXTENSIONS = Object.freeze(["js", "mjs", "css", "html", "json", "map"]);
|
|
14
|
+
export const DEFAULT_FORBIDDEN_NAMES = Object.freeze([/^(devSource|mockDataSource|fixtures)[-.]/]);
|
|
15
|
+
|
|
16
|
+
export function parseForbidFile(text, name = "forbid file") {
|
|
17
|
+
const trimmed = text.trim();
|
|
18
|
+
if (trimmed.startsWith("[") || trimmed.startsWith("{")) {
|
|
19
|
+
let data;
|
|
20
|
+
try { data = JSON.parse(trimmed); } catch (error) { throw new Error(`${name}: invalid JSON (${error.message})`); }
|
|
21
|
+
const forbid = Array.isArray(data) ? data : data.forbid ?? [];
|
|
22
|
+
const forbidNames = Array.isArray(data) ? [] : data.forbidNames ?? [];
|
|
23
|
+
if (!Array.isArray(forbid) || !Array.isArray(forbidNames) || ![...forbid, ...forbidNames].every((s) => typeof s === "string" && s)) throw new Error(`${name}: entries must be non-empty strings`);
|
|
24
|
+
return { forbid, forbidNames };
|
|
25
|
+
}
|
|
26
|
+
return { forbid: trimmed.split(/\r?\n/).map((l) => l.trim()).filter((l) => l && !l.startsWith("#")), forbidNames: [] };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Scan `dist`. Returns { dist, files, scanned, markers, offenders: [{file, reason}] }. */
|
|
30
|
+
export function scanDist(dist, { forbid = [], forbidNames = [], extensions = DEFAULT_EXTENSIONS } = {}) {
|
|
31
|
+
const root = resolve(dist);
|
|
32
|
+
if (!existsSync(root) || !statSync(root).isDirectory()) throw new Error(`No ${root}: build first.`);
|
|
33
|
+
const nameRes = [...DEFAULT_FORBIDDEN_NAMES, ...forbidNames.map((pattern) => {
|
|
34
|
+
try { return new RegExp(pattern); } catch (error) { throw new Error(`--forbid-name ${JSON.stringify(pattern)} is not a regular expression (${error.message})`); }
|
|
35
|
+
})];
|
|
36
|
+
const extRe = new RegExp(`\\.(${extensions.map((e) => e.replace(/[^\w]/g, "")).filter(Boolean).join("|")})$`);
|
|
37
|
+
const offenders = [];
|
|
38
|
+
let files = 0;
|
|
39
|
+
let scanned = 0;
|
|
40
|
+
const walk = (dir) => {
|
|
41
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
42
|
+
const path = join(dir, entry.name);
|
|
43
|
+
if (entry.isSymbolicLink()) continue;
|
|
44
|
+
if (entry.isDirectory()) { walk(path); continue; }
|
|
45
|
+
files += 1;
|
|
46
|
+
const rel = relative(root, path);
|
|
47
|
+
if (nameRes.some((re) => re.test(basename(path)))) offenders.push({ file: rel, reason: "forbidden chunk name" });
|
|
48
|
+
if (!extRe.test(path)) continue;
|
|
49
|
+
scanned += 1;
|
|
50
|
+
const text = readFileSync(path, "utf8");
|
|
51
|
+
for (const marker of forbid) if (text.includes(marker)) offenders.push({ file: rel, reason: `marker: ${marker}` });
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
walk(root);
|
|
55
|
+
return { dist: root, files, scanned, markers: forbid.length, offenders };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function distScanFailed(result) {
|
|
59
|
+
return result.offenders.length > 0 || result.files === 0 || result.markers === 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const USAGE = "usage: right-release dist-scan --dist <dir> --forbid <string>... [--forbid-file <path>] [--forbid-name <regex>]... [--ext js,css,...]";
|
|
63
|
+
|
|
64
|
+
export function parseArgs(argv) {
|
|
65
|
+
const out = { dist: null, forbid: [], forbidNames: [], extensions: undefined };
|
|
66
|
+
for (let i = 0; i < argv.length; i++) {
|
|
67
|
+
const a = argv[i];
|
|
68
|
+
const value = () => { const v = argv[++i]; if (v === undefined || v.startsWith("--")) throw new Error(`${a} needs a value`); return v; };
|
|
69
|
+
if (a === "--dist") out.dist = value();
|
|
70
|
+
else if (a === "--forbid") out.forbid.push(value());
|
|
71
|
+
else if (a === "--forbid-name") out.forbidNames.push(value());
|
|
72
|
+
else if (a === "--ext") out.extensions = value().split(",").filter(Boolean);
|
|
73
|
+
else if (a === "--forbid-file") {
|
|
74
|
+
const file = value();
|
|
75
|
+
const parsed = parseForbidFile(readFileSync(file, "utf8"), file);
|
|
76
|
+
out.forbid.push(...parsed.forbid);
|
|
77
|
+
out.forbidNames.push(...parsed.forbidNames);
|
|
78
|
+
} else throw new Error(`unknown argument ${a}`);
|
|
79
|
+
}
|
|
80
|
+
if (!out.dist) throw new Error("--dist is required");
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function main(argv) {
|
|
85
|
+
let result;
|
|
86
|
+
try {
|
|
87
|
+
const args = parseArgs(argv);
|
|
88
|
+
if (args.forbid.length === 0) throw new Error("no --forbid markers given");
|
|
89
|
+
result = scanDist(args.dist, args);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
console.error(`right-release dist-scan: ${error.message}\n${USAGE}`);
|
|
92
|
+
return 2;
|
|
93
|
+
}
|
|
94
|
+
if (distScanFailed(result)) {
|
|
95
|
+
console.error(`Release bundle contains forbidden code:\n${result.offenders.map((o) => `${o.file}: ${o.reason}`).join("\n") || `no files in ${result.dist}`}`);
|
|
96
|
+
return 1;
|
|
97
|
+
}
|
|
98
|
+
console.log(`Release bundle clean: ${result.markers} markers absent from ${result.dist} (${result.scanned} files scanned)`);
|
|
99
|
+
return 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) process.exit(main(process.argv.slice(2)));
|
package/dmg-build.mjs
CHANGED
|
@@ -7,6 +7,10 @@ import { readDmgSpec } from "./dmg-spec.mjs";
|
|
|
7
7
|
import { detachWithRetrySync } from "./hdiutil-detach.mjs";
|
|
8
8
|
import { assertReleaseExecutionAllowed } from "./execution-policy.mjs";
|
|
9
9
|
import { assertNoQaControl } from "./qa-control-scan.mjs";
|
|
10
|
+
import { signMacosEntries, validateCodesignIdentifier } from "./mac-sign-core.mjs";
|
|
11
|
+
import { MACOS_PORTABLE_SIGNING_CONTRACT } from "./macos-signing-contract.mjs";
|
|
12
|
+
import { resolveMacosDeveloperIdIdentity } from "./macos-signing-identity.mjs";
|
|
13
|
+
import { runMacCodesignCommands } from "./macos-signing-session.mjs";
|
|
10
14
|
|
|
11
15
|
const fail = (code, message) => Object.assign(new Error(message), { code });
|
|
12
16
|
const pair = (value, label, positive = false) => {
|
|
@@ -25,12 +29,44 @@ export function resolveDmgOptions(input = {}) {
|
|
|
25
29
|
return spec ? { ...readDmgSpec(spec, { app: explicit.app }), ...definedOnly(explicit) } : explicit;
|
|
26
30
|
}
|
|
27
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Developer ID sign a finished disk image (#646): secure timestamp, no hardened runtime (a disk image has none), an
|
|
34
|
+
* optional stable `--identifier`, strict verification, and an optional signing receipt (the sign-macos.mjs shape) so a
|
|
35
|
+
* later notarize step can bind to it. It never notarizes or uploads. Developer ID only: no ad hoc or Apple Development fallback.
|
|
36
|
+
*/
|
|
37
|
+
export function signBuiltDmg({ file, identifier, receipt }) {
|
|
38
|
+
const identity = resolveMacosDeveloperIdIdentity();
|
|
39
|
+
const files = signMacosEntries([{ file, hardenedRuntime: false, options: [], ...(identifier ? { identifier } : {}) }], {
|
|
40
|
+
identity,
|
|
41
|
+
signOne: (codesignArgs) => runMacCodesignCommands([codesignArgs])[0],
|
|
42
|
+
inspect: (tool, args) => {
|
|
43
|
+
const result = spawnSync(tool, args, { encoding: "utf8" });
|
|
44
|
+
const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`;
|
|
45
|
+
if (result.status !== 0) throw new Error(`${tool} ${args.join(" ")} failed: ${output.trim()}`);
|
|
46
|
+
return output;
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
if (receipt) {
|
|
50
|
+
mkdirSync(path.dirname(receipt), { recursive: true });
|
|
51
|
+
writeFileSync(receipt, `${JSON.stringify({ schema: 1, signingContract: MACOS_PORTABLE_SIGNING_CONTRACT, identity, files }, null, 2)}\n`);
|
|
52
|
+
}
|
|
53
|
+
return files;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Build the branded DMG; with `sign: true` (CLI `--sign`) it is then Developer ID signed (`identifier`, `signReceipt`
|
|
58
|
+
* optional) and a signing failure removes the unsigned image so nothing downstream can pick it up. `signDmg` is injectable.
|
|
59
|
+
*/
|
|
28
60
|
export function buildDmg(input = {},
|
|
29
61
|
{ platform = process.platform, run = spawnSync, makeAlias = backgroundAlias,
|
|
30
|
-
log = (event) => console.error(JSON.stringify(event)), sleep } = {}) {
|
|
62
|
+
log = (event) => console.error(JSON.stringify(event)), sleep, signDmg = signBuiltDmg } = {}) {
|
|
31
63
|
if (platform !== "darwin") throw fail("DMG_NATIVE_HOST_REQUIRED", "DMG creation requires macOS");
|
|
32
64
|
let { app, out, volname, background, iconSize = 128, appPosition = [158, 231],
|
|
33
|
-
applicationsPosition = [492, 231], window = [660, 400], windowPosition = [100, 100], volumeIcon
|
|
65
|
+
applicationsPosition = [492, 231], window = [660, 400], windowPosition = [100, 100], volumeIcon,
|
|
66
|
+
sign = false, identifier, signReceipt } = resolveDmgOptions(input);
|
|
67
|
+
if (identifier !== undefined && !sign) throw fail("DMG_INVALID_OPTIONS", "--identifier needs --sign");
|
|
68
|
+
if (signReceipt !== undefined && !sign) throw fail("DMG_INVALID_OPTIONS", "--receipt needs --sign");
|
|
69
|
+
if (identifier !== undefined) { try { validateCodesignIdentifier(identifier, "--identifier"); } catch (error) { throw fail("DMG_INVALID_OPTIONS", error.message); } }
|
|
34
70
|
if (!app || !out || !volname) throw fail("DMG_INVALID_OPTIONS", "--app, --out & --volname are required");
|
|
35
71
|
app = path.resolve(app); out = path.resolve(out);
|
|
36
72
|
if (!app.endsWith(".app") || !statSync(app).isDirectory() || !existsSync(path.join(app, "Contents", "Info.plist"))) {
|
|
@@ -103,8 +139,18 @@ export function buildDmg(input = {},
|
|
|
103
139
|
command("/usr/bin/hdiutil", ["convert", rw, "-format", "UDZO", "-imagekey", "zlib-level=9", "-o", compressed]);
|
|
104
140
|
command("/usr/bin/hdiutil", ["verify", compressed]);
|
|
105
141
|
renameSync(compressed, out);
|
|
106
|
-
|
|
107
|
-
|
|
142
|
+
let signed;
|
|
143
|
+
if (sign) {
|
|
144
|
+
// Developer ID sign the finished image. A failure removes the unsigned image: nothing downstream may pick it up.
|
|
145
|
+
try { signed = signDmg({ file: out, ...(identifier ? { identifier } : {}), ...(signReceipt ? { receipt: path.resolve(signReceipt) } : {}) }); }
|
|
146
|
+
catch (error) {
|
|
147
|
+
rmSync(out, { force: true });
|
|
148
|
+
throw fail("DMG_SIGN_FAILED", `signing ${out} failed (the unsigned image was removed): ${error.message}`);
|
|
149
|
+
}
|
|
150
|
+
log({ event: "dmg-signed", out, ...(identifier ? { identifier } : {}) });
|
|
151
|
+
}
|
|
152
|
+
log({ event: "dmg-build-completed", out, format: "UDZO", signed: Boolean(sign) });
|
|
153
|
+
return { out, format: "UDZO", ...(sign ? { signed: true, signing: signed } : {}) };
|
|
108
154
|
} catch (error) {
|
|
109
155
|
log({ event: "dmg-build-failed", out, code: error.code ?? "DMG_BUILD_FAILED", message: error.message });
|
|
110
156
|
throw error;
|
|
@@ -122,8 +168,11 @@ export function parseDmgArguments(args) {
|
|
|
122
168
|
if (args[0] !== "build") throw fail("DMG_INVALID_OPTIONS", "expected dmg build");
|
|
123
169
|
const flags = new Map([["--app", "app"], ["--out", "out"], ["--volname", "volname"], ["--background", "background"],
|
|
124
170
|
["--icon-size", "iconSize"], ["--app-position", "appPosition"], ["--applications-position", "applicationsPosition"], ["--window", "window"], ["--window-position", "windowPosition"], ["--volume-icon", "volumeIcon"], ["--spec", "spec"]]);
|
|
171
|
+
flags.set("--identifier", "identifier"); flags.set("--receipt", "signReceipt");
|
|
125
172
|
const options = {};
|
|
126
173
|
for (let i = 1; i < args.length; i += 2) {
|
|
174
|
+
// `--sign` is the one value-less flag: Developer ID sign the finished image (#646).
|
|
175
|
+
if (args[i] === "--sign") { if (options.sign !== undefined) throw fail("DMG_INVALID_OPTIONS", "invalid option: --sign"); options.sign = true; i -= 1; continue; }
|
|
127
176
|
const key = flags.get(args[i]);
|
|
128
177
|
if (!key || options[key] !== undefined || !args[i + 1] || args[i + 1].startsWith("--")) throw fail("DMG_INVALID_OPTIONS", `invalid option: ${args[i]}`);
|
|
129
178
|
options[key] = args[i + 1];
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
// GitHub Releases lane of `right-release publish-update` (Orthic-Labs/rightkit#237).
|
|
2
2
|
//
|
|
3
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
|
|
5
|
-
//
|
|
4
|
+
// signed once by `sign-updater.mjs` with the suite updater key (loaded from the OS credential store, never a
|
|
5
|
+
// key file), sealed, and re-verified by `verifyPublicationRelease`. This module only turns the same
|
|
6
6
|
// `platforms` map the r2 lane builds ({ <platform>: { signature, url } }) into a Tauri v2 static `latest.json`
|
|
7
7
|
// and uploads it, with the bundles and their `.sig` files, as assets of the GitHub release `v<version>`, so
|
|
8
8
|
// `https://github.com/<owner>/<repo>/releases/latest/download/latest.json` resolves. It adds no key and no trust root.
|
|
9
|
+
import { createHash, createPublicKey, verify as cryptoVerify } from "node:crypto";
|
|
9
10
|
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
10
11
|
import path from "node:path";
|
|
11
12
|
import { PublicationError, digestBytes, readVerifiedArtifact, verifyArtifactFile } from "./publication-artifacts.mjs";
|
|
@@ -58,13 +59,75 @@ export function githubLatestJsonUrl(repo) {
|
|
|
58
59
|
* [2-byte algorithm][8-byte key id, little endian][64-byte signature].
|
|
59
60
|
*/
|
|
60
61
|
export function updaterSignatureKeyId(signatureText) {
|
|
62
|
+
return parseMinisignSignature(signatureText)?.keyId ?? null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
|
|
66
|
+
|
|
67
|
+
function parseMinisignSignature(signatureText) {
|
|
68
|
+
try {
|
|
69
|
+
const decode = (text) => {
|
|
70
|
+
if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(text)) throw new Error("invalid base64");
|
|
71
|
+
const bytes = Buffer.from(text, "base64");
|
|
72
|
+
if (bytes.toString("base64") !== text) throw new Error("invalid base64");
|
|
73
|
+
return bytes;
|
|
74
|
+
};
|
|
75
|
+
const envelope = decode(String(signatureText).trim());
|
|
76
|
+
const text = new TextDecoder("utf-8", { fatal: true }).decode(envelope);
|
|
77
|
+
const lines = text.split(/\r?\n/);
|
|
78
|
+
if (lines.at(-1) === "") lines.pop();
|
|
79
|
+
if (lines.length !== 4 || !lines[2].startsWith("trusted comment: ")) return null;
|
|
80
|
+
const raw = decode(lines[1]);
|
|
81
|
+
const globalSignature = decode(lines[3]);
|
|
82
|
+
if (globalSignature.length !== 64) return null;
|
|
83
|
+
if (raw.length !== 74) return null;
|
|
84
|
+
const algorithm = raw.subarray(0, 2).toString("latin1");
|
|
85
|
+
if (algorithm !== "Ed" && algorithm !== "ED") return null;
|
|
86
|
+
return { algorithm, keyId: Buffer.from(raw.subarray(2, 10)).reverse().toString("hex").toUpperCase(), signature: raw.subarray(10), globalSignature, trustedComment: Buffer.from(lines[2].slice(17), "utf8") };
|
|
87
|
+
} catch { return null; }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function parseMinisignPublicKey(publicKeyText) {
|
|
91
|
+
const text = String(publicKeyText ?? "").trim();
|
|
61
92
|
try {
|
|
62
|
-
const lines = Buffer.from(
|
|
93
|
+
const lines = Buffer.from(text, "base64").toString("utf8").split(/\r?\n/);
|
|
63
94
|
const raw = Buffer.from(lines[1] ?? "", "base64");
|
|
64
|
-
if (raw.length
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
95
|
+
if (raw.length === 42 && ["Ed", "ED"].includes(raw.subarray(0, 2).toString("latin1"))) {
|
|
96
|
+
return { keyId: Buffer.from(raw.subarray(2, 10)).reverse().toString("hex").toUpperCase(), key: raw.subarray(10) };
|
|
97
|
+
}
|
|
98
|
+
} catch { /* fall through to raw forms */ }
|
|
99
|
+
try {
|
|
100
|
+
const raw = Buffer.from(text, "base64");
|
|
101
|
+
if (raw.length === 42 && ["Ed", "ED"].includes(raw.subarray(0, 2).toString("latin1"))) return { keyId: Buffer.from(raw.subarray(2, 10)).reverse().toString("hex").toUpperCase(), key: raw.subarray(10) };
|
|
102
|
+
if (raw.length === 32) return { keyId: null, key: raw };
|
|
103
|
+
} catch { /* invalid below */ }
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Cryptographic minisign (Ed25519) verification over the EXACT artifact bytes. `Ed` signatures
|
|
109
|
+
* cover the bytes directly; `ED` signatures cover their BLAKE2b-512 prehash. Returns false (never
|
|
110
|
+
* throws) for any malformed signature/key, key-id mismatch, or bad signature.
|
|
111
|
+
*/
|
|
112
|
+
export function verifyMinisignSignature(bytes, signatureText, publicKeyText) {
|
|
113
|
+
const sig = parseMinisignSignature(signatureText);
|
|
114
|
+
const pub = parseMinisignPublicKey(publicKeyText);
|
|
115
|
+
if (!sig || !pub || (pub.keyId && pub.keyId !== sig.keyId)) return false;
|
|
116
|
+
try {
|
|
117
|
+
const data = sig.algorithm === "ED" ? createHash("blake2b512").update(bytes).digest() : Buffer.from(bytes);
|
|
118
|
+
const key = createPublicKey({ key: Buffer.concat([ED25519_SPKI_PREFIX, pub.key]), format: "der", type: "spki" });
|
|
119
|
+
return cryptoVerify(null, data, key, sig.signature)
|
|
120
|
+
&& cryptoVerify(null, Buffer.concat([sig.signature, sig.trustedComment]), key, sig.globalSignature);
|
|
121
|
+
} catch { return false; }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Require a cryptographically valid minisign signature over `bytes` under the declared public key.
|
|
126
|
+
* The key-id check (`assertUpdaterKeyId`) is supplementary only; this is the seal/upload gate.
|
|
127
|
+
*/
|
|
128
|
+
export function assertUpdaterSignature(bytes, signatureText, publicKeyText, label = "updater signature") {
|
|
129
|
+
if (!verifyMinisignSignature(bytes, signatureText, publicKeyText)) {
|
|
130
|
+
throw new PublicationError("PUBLICATION_SIGNATURE_INVALID", `${label} does not cryptographically verify over the exact artifact bytes under the declared updater public key`);
|
|
68
131
|
}
|
|
69
132
|
}
|
|
70
133
|
|
package/icon-pack.mjs
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// App icon packers (bogusyogi/rightkit#675): assemble a Windows ICO or a macOS ICNS from per-size PNG frames, so
|
|
3
|
+
// hand-tuned small frames (16/24/32/48) are kept exactly as drawn instead of being down-scaled from one master.
|
|
4
|
+
// right-release icons ico --out <file.ico> --frame <PNG>... [--dir <folder of <size>.png>]
|
|
5
|
+
// right-release icons icns --out <file.icns> --frame <PNG>... [--dir <folder of <size>.png>]
|
|
6
|
+
// Every frame must be a square PNG; its size comes from its IHDR, never from the file name. ICO frames are stored
|
|
7
|
+
// as PNG (Vista+), sizes 1..256. ICNS accepts 16, 32, 64, 128, 256, 512 and 1024. Pure Buffer work, any host.
|
|
8
|
+
// Rendering a master image to PNG frames and DMG background art stay with the app's design source
|
|
9
|
+
// (dmg background: generate-dmg-background.py).
|
|
10
|
+
import { readFileSync, readdirSync, writeFileSync, mkdirSync } from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
|
|
13
|
+
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
14
|
+
export const ICNS_TYPES = Object.freeze({ 16: "icp4", 32: "icp5", 64: "icp6", 128: "ic07", 256: "ic08", 512: "ic09", 1024: "ic10" });
|
|
15
|
+
function fail(message, code = "ICON_INVALID") { throw Object.assign(new Error(message), { code }); }
|
|
16
|
+
|
|
17
|
+
/** Read the pixel size of a square PNG from its IHDR. */
|
|
18
|
+
export function pngSize(bytes, label = "frame") {
|
|
19
|
+
const buf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes);
|
|
20
|
+
if (buf.length < 33 || !buf.subarray(0, 8).equals(PNG_MAGIC) || buf.toString("latin1", 12, 16) !== "IHDR") fail(`${label} is not a PNG`);
|
|
21
|
+
const width = buf.readUInt32BE(16), height = buf.readUInt32BE(20);
|
|
22
|
+
if (width !== height) fail(`${label} must be square, got ${width}x${height}`);
|
|
23
|
+
// Walk the chunk list: IHDR first (13 bytes), at least one IDAT, IEND last with nothing after it.
|
|
24
|
+
if (buf.readUInt32BE(8) !== 13) fail(`${label} has a malformed IHDR chunk`);
|
|
25
|
+
let at = 8, idat = false, iend = false, first = true;
|
|
26
|
+
while (at < buf.length) {
|
|
27
|
+
if (at + 12 > buf.length) fail(`${label} is a truncated PNG`);
|
|
28
|
+
const length = buf.readUInt32BE(at), type = buf.toString("latin1", at + 4, at + 8);
|
|
29
|
+
if (at + 12 + length > buf.length) fail(`${label} is a truncated PNG`);
|
|
30
|
+
if (iend) fail(`${label} has data after IEND`);
|
|
31
|
+
if (first && type !== "IHDR") fail(`${label} is not a PNG`);
|
|
32
|
+
first = false;
|
|
33
|
+
if (type === "IDAT") idat = true;
|
|
34
|
+
if (type === "IEND") iend = true;
|
|
35
|
+
at += 12 + length;
|
|
36
|
+
}
|
|
37
|
+
if (!idat) fail(`${label} has no image data (IDAT)`);
|
|
38
|
+
if (!iend) fail(`${label} is missing IEND`);
|
|
39
|
+
const depth = buf[24], colorType = buf[25];
|
|
40
|
+
if (depth !== 8 || colorType !== 6) fail(`${label} must be 8-bit RGBA (color type 6, bit depth 8), got bit depth ${depth}, color type ${colorType}`);
|
|
41
|
+
return width;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function normalizeFrames(frames, { min = 1, max = 1024, only = null } = {}) {
|
|
45
|
+
if (!Array.isArray(frames) || frames.length === 0) fail("at least one PNG frame is required", "ICON_USAGE");
|
|
46
|
+
const bySize = new Map();
|
|
47
|
+
for (const frame of frames) {
|
|
48
|
+
const bytes = Buffer.isBuffer(frame) ? frame : Buffer.from(frame);
|
|
49
|
+
const size = pngSize(bytes);
|
|
50
|
+
if (size < min || size > max || (only && !only.includes(size))) fail(`unsupported frame size ${size}${only ? ` (allowed: ${only.join(", ")})` : ` (allowed ${min}..${max})`}`);
|
|
51
|
+
if (bySize.has(size)) fail(`duplicate ${size}x${size} frame`);
|
|
52
|
+
bySize.set(size, bytes);
|
|
53
|
+
}
|
|
54
|
+
return [...bySize.entries()].sort((a, b) => a[0] - b[0]);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** @param {Buffer[]} frames PNG buffers, one per size (1..256). @returns {Buffer} ICO file. */
|
|
58
|
+
export function packIco(frames) {
|
|
59
|
+
const sorted = normalizeFrames(frames, { max: 256 });
|
|
60
|
+
const header = Buffer.alloc(6 + 16 * sorted.length);
|
|
61
|
+
header.writeUInt16LE(0, 0); header.writeUInt16LE(1, 2); header.writeUInt16LE(sorted.length, 4);
|
|
62
|
+
let offset = header.length;
|
|
63
|
+
sorted.forEach(([size, png], i) => {
|
|
64
|
+
const at = 6 + i * 16;
|
|
65
|
+
header[at] = size === 256 ? 0 : size; header[at + 1] = size === 256 ? 0 : size;
|
|
66
|
+
header.writeUInt16LE(1, at + 4); header.writeUInt16LE(32, at + 6);
|
|
67
|
+
header.writeUInt32LE(png.length, at + 8); header.writeUInt32LE(offset, at + 12);
|
|
68
|
+
offset += png.length;
|
|
69
|
+
});
|
|
70
|
+
return Buffer.concat([header, ...sorted.map(([, png]) => png)]);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** @param {Buffer[]} frames PNG buffers (16, 32, 64, 128, 256, 512, 1024). @returns {Buffer} ICNS file. */
|
|
74
|
+
export function packIcns(frames) {
|
|
75
|
+
const sorted = normalizeFrames(frames, { only: Object.keys(ICNS_TYPES).map(Number) });
|
|
76
|
+
const chunks = sorted.map(([size, png]) => {
|
|
77
|
+
const head = Buffer.alloc(8);
|
|
78
|
+
head.write(ICNS_TYPES[size], 0, "latin1"); head.writeUInt32BE(png.length + 8, 4);
|
|
79
|
+
return Buffer.concat([head, png]);
|
|
80
|
+
});
|
|
81
|
+
const total = 8 + chunks.reduce((n, c) => n + c.length, 0);
|
|
82
|
+
const head = Buffer.alloc(8);
|
|
83
|
+
head.write("icns", 0, "latin1"); head.writeUInt32BE(total, 4);
|
|
84
|
+
return Buffer.concat([head, ...chunks]);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function parseArgs(argv) {
|
|
88
|
+
const [kind, ...rest] = argv;
|
|
89
|
+
const opts = { kind, out: null, frames: [], dirs: [] };
|
|
90
|
+
for (let i = 0; i < rest.length; i++) {
|
|
91
|
+
const arg = rest[i], value = rest[i + 1];
|
|
92
|
+
if (!["--out", "--frame", "--dir"].includes(arg)) fail(`unknown icons argument: ${arg}`, "ICON_USAGE");
|
|
93
|
+
if (value === undefined || value.startsWith("--")) fail(`${arg} needs a value`, "ICON_USAGE");
|
|
94
|
+
i += 1;
|
|
95
|
+
if (arg === "--out") opts.out = value; else if (arg === "--frame") opts.frames.push(value); else opts.dirs.push(value);
|
|
96
|
+
}
|
|
97
|
+
if (!["ico", "icns"].includes(kind) || !opts.out) fail("usage: right-release icons ico|icns --out <file> --frame <PNG>... [--dir <folder>]", "ICON_USAGE");
|
|
98
|
+
for (const dir of opts.dirs) for (const name of readdirSync(dir).filter((n) => n.toLowerCase().endsWith(".png")).sort()) opts.frames.push(path.join(dir, name));
|
|
99
|
+
return opts;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function main(argv = process.argv.slice(2), out = console) {
|
|
103
|
+
try {
|
|
104
|
+
const opts = parseArgs(argv);
|
|
105
|
+
const frames = opts.frames.map((file) => readFileSync(file));
|
|
106
|
+
const bytes = opts.kind === "ico" ? packIco(frames) : packIcns(frames);
|
|
107
|
+
mkdirSync(path.dirname(path.resolve(opts.out)), { recursive: true });
|
|
108
|
+
writeFileSync(opts.out, bytes);
|
|
109
|
+
out.log(JSON.stringify({ out: opts.out, kind: opts.kind, frames: opts.frames.length, bytes: bytes.length }));
|
|
110
|
+
return 0;
|
|
111
|
+
} catch (error) {
|
|
112
|
+
out.error(error.message);
|
|
113
|
+
return error.code === "ICON_USAGE" ? 2 : 1;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href) process.exit(main());
|
package/in-place-install.mjs
CHANGED
|
@@ -4,10 +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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* copy at ONE fixed path. NO install keeps a retained `<name>.previous` copy (D01/#771): a `previous` path passed
|
|
8
|
+
* to `swapInPlace` is only a TRANSIENT park for the old copy while the new one is verified; it is deleted on
|
|
9
|
+
* success and, on failure, the parked copy is renamed back and the park path is gone. Nothing ever runs from
|
|
10
|
+
* `.previous` or from the staging copy. A stale `<destination>.previous` left by an older RightKit is removed.
|
|
11
11
|
*/
|
|
12
12
|
export const PREVIOUS_SUFFIX = ".previous";
|
|
13
13
|
export const STAGING_SUFFIX = ".staging";
|
|
@@ -24,16 +24,16 @@ const exists = (fs, file) => {
|
|
|
24
24
|
/**
|
|
25
25
|
* Swap `staging` into `destination`, then `verify(destination)` runs.
|
|
26
26
|
*
|
|
27
|
-
* With a `previous` path
|
|
28
|
-
* failure removes the new copy and renames
|
|
29
|
-
*
|
|
27
|
+
* With a `previous` path: the old destination is parked there ONLY until verification passes, then deleted
|
|
28
|
+
* (never retained as a rollback copy). Any failure removes the new copy and renames the parked copy back,
|
|
29
|
+
* so the fixed path is never left empty and no `.previous` remains either way. Renames only.
|
|
30
|
+
* @returns {{destination: string, previous: null}} previous is always null: no copy is retained.
|
|
30
31
|
*
|
|
31
|
-
* With `previous: null` (
|
|
32
|
-
* over the fixed path
|
|
33
|
-
*
|
|
34
|
-
* from an older build. `previous` is always null in the result.
|
|
32
|
+
* With `previous: null` (NO park of any kind): the old destination is removed and `staging` is renamed
|
|
33
|
+
* over the fixed path. Any failure removes the new (absent or partial) copy and the staging copy and
|
|
34
|
+
* rethrows; the old copy is NOT restored, the user reinstalls from an older build.
|
|
35
35
|
*/
|
|
36
|
-
export function swapInPlace({ staging, destination, previous =
|
|
36
|
+
export function swapInPlace({ staging, destination, previous = null, verify = () => {}, fs = nodeFs }) {
|
|
37
37
|
if (previous === null) return replaceWithoutBackup({ staging, destination, verify, fs });
|
|
38
38
|
if (previous === destination || staging === destination || staging === previous) throw new Error("swapInPlace needs three distinct paths");
|
|
39
39
|
let movedAside = false;
|
|
@@ -51,7 +51,10 @@ export function swapInPlace({ staging, destination, previous = previousPathOf(de
|
|
|
51
51
|
try { fs.rmSync(staging, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
52
52
|
throw error;
|
|
53
53
|
}
|
|
54
|
-
|
|
54
|
+
// Verified: the transient park (and any stale retained copy) is deleted, never kept.
|
|
55
|
+
try { if (movedAside) fs.rmSync(previous, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
56
|
+
try { fs.rmSync(previousPathOf(destination), { recursive: true, force: true }); } catch { /* best effort */ }
|
|
57
|
+
return { destination, previous: null };
|
|
55
58
|
}
|
|
56
59
|
|
|
57
60
|
function replaceWithoutBackup({ staging, destination, verify, fs }) {
|
|
@@ -65,7 +68,7 @@ function replaceWithoutBackup({ staging, destination, verify, fs }) {
|
|
|
65
68
|
try { fs.rmSync(staging, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
66
69
|
throw error;
|
|
67
70
|
}
|
|
68
|
-
// No old copy may survive
|
|
71
|
+
// No old copy may survive an install, including one an older RightKit left behind.
|
|
69
72
|
try { fs.rmSync(previousPathOf(destination), { recursive: true, force: true }); } catch { /* best effort */ }
|
|
70
73
|
return { destination, previous: null };
|
|
71
74
|
}
|
package/init-command.mjs
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// `right-release init --name <App Name> --id <reverse.dns.bundle.id> [--slug <lowercase>] [--dir <root>] [--dry-run]`
|
|
3
|
+
// (bogusyogi/rightkit#385): renames the desktop app template into a real app, in place. Ports PostRight's
|
|
4
|
+
// crates/tools/src/init.rs. The template identity (`RightApp`, `rightapp`, `RIGHTAPP`, `app.rightsuite.rightapp`)
|
|
5
|
+
// is rewritten in text files; nothing else is touched and no gate or build is run.
|
|
6
|
+
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
|
|
10
|
+
export const SKIP_DIRS = new Set(["node_modules", "target", ".git", "dist", "gen"]);
|
|
11
|
+
export const TEXT_EXT = new Set(["json", "toml", "mjs", "ts", "tsx", "rs", "md", "html", "css", "yaml", "yml", "sh"]);
|
|
12
|
+
// Assembled so this file is not rewritten into the new identity when the template is renamed.
|
|
13
|
+
const TITLE = ["Right", "App"].join("");
|
|
14
|
+
const LOWER = ["right", "app"].join("");
|
|
15
|
+
const UPPER = ["RIGHT", "APP"].join("");
|
|
16
|
+
const TEMPLATE_ID = `app.rightsuite.${LOWER}`;
|
|
17
|
+
const SELF = path.basename(fileURLToPath(import.meta.url));
|
|
18
|
+
|
|
19
|
+
export const INIT_USAGE = "usage: right-release init --name <Product Name> --id <reverse.dns.bundle.id> [--slug <lowercase>] [--dir <root>] [--dry-run]";
|
|
20
|
+
|
|
21
|
+
export function parseInitArgs(argv) {
|
|
22
|
+
const opts = { name: undefined, id: undefined, slug: undefined, dir: ".", dryRun: false };
|
|
23
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
24
|
+
const arg = argv[i];
|
|
25
|
+
if (["--name", "--id", "--slug", "--dir"].includes(arg)) {
|
|
26
|
+
const value = argv[i + 1];
|
|
27
|
+
if (value === undefined || value.startsWith("--")) throw new Error(`${arg} needs a value\n${INIT_USAGE}`);
|
|
28
|
+
opts[arg.slice(2)] = value;
|
|
29
|
+
i += 1;
|
|
30
|
+
} else if (arg === "--dry-run") opts.dryRun = true;
|
|
31
|
+
else throw new Error(`unknown init argument: ${arg}\n${INIT_USAGE}`);
|
|
32
|
+
}
|
|
33
|
+
if (!opts.name || !opts.id) throw new Error(INIT_USAGE);
|
|
34
|
+
return opts;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Validate and derive the identity. `slug` defaults to the lowercased name without spaces. */
|
|
38
|
+
export function resolveIdentity({ name, id, slug }) {
|
|
39
|
+
const resolvedSlug = slug ?? name.replaceAll(" ", "").toLowerCase();
|
|
40
|
+
if (!/^[A-Za-z][A-Za-z0-9 ]*$/.test(name)) throw new Error(`name must be letters, digits and spaces starting with a letter: ${name}\n${INIT_USAGE}`);
|
|
41
|
+
if (!/^[a-z][a-z0-9]*$/.test(resolvedSlug)) throw new Error(`slug must be lowercase letters and digits starting with a letter: ${resolvedSlug}\n${INIT_USAGE}`);
|
|
42
|
+
if (!/^[A-Za-z][A-Za-z0-9-]*(\.[A-Za-z][A-Za-z0-9-]*)+$/.test(id)) throw new Error(`id must be a reverse-DNS bundle id with at least two segments: ${id}\n${INIT_USAGE}`);
|
|
43
|
+
const warnings = [];
|
|
44
|
+
if (name.replaceAll(" ", "").toLowerCase() !== resolvedSlug) {
|
|
45
|
+
warnings.push(`--name "${name}" lowercased is not slug "${resolvedSlug}"; check productName and installer names.`);
|
|
46
|
+
}
|
|
47
|
+
if (!id.toLowerCase().endsWith(`.${resolvedSlug}`)) {
|
|
48
|
+
warnings.push(`--id "${id}" does not end in .${resolvedSlug}; check the bundle id.`);
|
|
49
|
+
}
|
|
50
|
+
return { name, id, slug: resolvedSlug, warnings };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function walkTextFiles(dir, out = []) {
|
|
54
|
+
let entries;
|
|
55
|
+
try {
|
|
56
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
57
|
+
} catch {
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
for (const entry of entries) {
|
|
61
|
+
const full = path.join(dir, entry.name);
|
|
62
|
+
if (entry.isDirectory()) {
|
|
63
|
+
if (!SKIP_DIRS.has(entry.name)) walkTextFiles(full, out);
|
|
64
|
+
} else if (entry.isFile() && TEXT_EXT.has(path.extname(entry.name).slice(1))) {
|
|
65
|
+
out.push(full);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Rewrite the template identity in one string; the bundle id first, then the three spellings. */
|
|
72
|
+
export function renameText(text, { name, id, slug }) {
|
|
73
|
+
return text
|
|
74
|
+
.replaceAll(TEMPLATE_ID, id)
|
|
75
|
+
.replaceAll(UPPER, slug.toUpperCase())
|
|
76
|
+
.replaceAll(TITLE, name)
|
|
77
|
+
.replaceAll(LOWER, slug);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Rename the template under `root`. Returns the identity and the changed file paths (relative, sorted). */
|
|
81
|
+
export function initTemplate(root, options) {
|
|
82
|
+
const identity = resolveIdentity(options);
|
|
83
|
+
const changed = [];
|
|
84
|
+
for (const file of walkTextFiles(root)) {
|
|
85
|
+
if (path.basename(file) === SELF) continue;
|
|
86
|
+
let before;
|
|
87
|
+
try {
|
|
88
|
+
before = readFileSync(file, "utf8");
|
|
89
|
+
} catch {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const after = renameText(before, identity);
|
|
93
|
+
if (after === before) continue;
|
|
94
|
+
if (!options.dryRun) writeFileSync(file, after);
|
|
95
|
+
changed.push(path.relative(root, file).split(path.sep).join("/"));
|
|
96
|
+
}
|
|
97
|
+
return { ...identity, changed: changed.sort() };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function runInitCommand(argv, io = { stdout: console.log, stderr: console.error }) {
|
|
101
|
+
const opts = parseInitArgs(argv);
|
|
102
|
+
const result = initTemplate(path.resolve(opts.dir), opts);
|
|
103
|
+
for (const warning of result.warnings) io.stderr(`warning: ${warning}`);
|
|
104
|
+
io.stdout(`right-release init${opts.dryRun ? " --dry-run" : ""}: ${result.changed.length} file(s) ${opts.dryRun ? "would change" : "changed"}`);
|
|
105
|
+
for (const file of result.changed) io.stdout(` ${file}`);
|
|
106
|
+
return result;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
110
|
+
try {
|
|
111
|
+
runInitCommand(process.argv.slice(2));
|
|
112
|
+
} catch (error) {
|
|
113
|
+
console.error(`right-release init: ${error.message}`);
|
|
114
|
+
process.exitCode = 1;
|
|
115
|
+
}
|
|
116
|
+
}
|
package/install-app-windows.mjs
CHANGED
|
@@ -18,9 +18,11 @@ import { previousPathOf } from "./in-place-install.mjs";
|
|
|
18
18
|
* plus the `--exe` file; entries are relative regular files inside `--dest`, no directories, wildcards, `..` or drive paths;
|
|
19
19
|
* - with NO declared payload the allowlist is just the `--exe` file (always backed up and restored; nothing else is);
|
|
20
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
|
|
21
|
+
* - the TRANSIENT backup is `<dest>.previous`, holding only the allowlisted files that existed before; it is
|
|
22
|
+
* deleted after a successful install and after a failure restore, so no copy is ever retained (D01);
|
|
22
23
|
* - 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
|
+
* it never touches any other file in `--dest`; a stale `<dest>.previous` is cleaned allowlist-scope only,
|
|
25
|
+
* never as a whole install directory.
|
|
24
26
|
* Dev installs do not pass through this module: they keep no backup at all (see development.mjs, install-app.mjs --dev).
|
|
25
27
|
*/
|
|
26
28
|
export class InstallAppWindowsError extends Error {
|
|
@@ -114,8 +116,20 @@ export function installAppWindows(opts, { platform = process.platform, fs = node
|
|
|
114
116
|
fs.copyFileSync(inDest(relative), inPrevious(relative));
|
|
115
117
|
}
|
|
116
118
|
backupMade = true;
|
|
117
|
-
event("previous-
|
|
119
|
+
event("previous-parked", { previous, files: backedUp.length, payloadOnly: true, transient: true });
|
|
118
120
|
}
|
|
121
|
+
// Delete the transient park. Ours (backupMade) is exactly the allowlisted scope we just created; a stale
|
|
122
|
+
// park from an older RightKit is cleaned allowlist-entry by allowlist-entry, never as a whole directory.
|
|
123
|
+
const cleanupPrevious = () => {
|
|
124
|
+
try {
|
|
125
|
+
if (backupMade) { fs.rmSync(previous, { recursive: true, force: true }); return; }
|
|
126
|
+
for (const relative of allowlist) {
|
|
127
|
+
const file = inPrevious(relative);
|
|
128
|
+
if (fs.existsSync(file) && isFile(file)) fs.rmSync(file, { force: true });
|
|
129
|
+
}
|
|
130
|
+
try { fs.rmdirSync(previous); } catch { /* non-empty or absent: leave it */ }
|
|
131
|
+
} catch { /* best effort; never mask the install result */ }
|
|
132
|
+
};
|
|
119
133
|
// Roll back the payload and nothing else: restore allowlisted files that existed, remove allowlisted files this run newly wrote.
|
|
120
134
|
// Each file is restored independently; failures are collected and reported together, never masking the installer error.
|
|
121
135
|
const restoreFailures = [];
|
|
@@ -142,13 +156,16 @@ export function installAppWindows(opts, { platform = process.platform, fs = node
|
|
|
142
156
|
const result = run(installer, windowsInstallArguments(dest), { stdio: "inherit", windowsHide: true, shell: false });
|
|
143
157
|
if (result.status !== 0) {
|
|
144
158
|
const restored = restore();
|
|
145
|
-
|
|
159
|
+
cleanupPrevious();
|
|
160
|
+
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 the transient backup (now deleted); no other file was touched` : noRollback}${failureNote()}; reinstall from the release installer if the app does not launch`);
|
|
146
161
|
}
|
|
147
162
|
// The installer must have updated the fixed folder: the executable is at the same path as before, not in a new folder.
|
|
148
163
|
if (!fs.existsSync(installed) || fs.statSync(installed).size === 0) {
|
|
149
164
|
const restored = restore();
|
|
150
|
-
|
|
165
|
+
cleanupPrevious();
|
|
166
|
+
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 the transient backup (now deleted); no other file was touched` : noRollback}${failureNote()}; reinstall from the release installer if the app does not launch`);
|
|
151
167
|
}
|
|
152
|
-
|
|
153
|
-
|
|
168
|
+
cleanupPrevious();
|
|
169
|
+
event("replaced", { installed, previous: null, sha256: sha256(fs, installed) });
|
|
170
|
+
return { ok: true, installed, previous: null };
|
|
154
171
|
}
|