@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.
Files changed (82) hide show
  1. package/README.md +200 -2
  2. package/addon-command.mjs +3 -4
  3. package/android-play-upload.mjs +2 -1
  4. package/apple-profile-renew.mjs +11 -3
  5. package/artifact-storage.mjs +35 -2
  6. package/bin/darwin/rightkit-swap +0 -0
  7. package/bin/darwin/rightkit-swap.sign.json +1 -1
  8. package/build-release.mjs +138 -47
  9. package/cli/right-release.mjs +40 -5
  10. package/codesign-environment.mjs +2 -2
  11. package/command-spec.mjs +80 -0
  12. package/content-digest.mjs +209 -0
  13. package/deps-cargo.mjs +55 -0
  14. package/deps.mjs +7 -0
  15. package/development.mjs +17 -6
  16. package/direct-bootstrap.mjs +5 -6
  17. package/disk-command.mjs +1 -0
  18. package/disk-policy.json +6 -2
  19. package/disk-stable-root.mjs +13 -9
  20. package/dist-scan.mjs +102 -0
  21. package/dmg-build.mjs +53 -4
  22. package/github-updater-release.mjs +70 -7
  23. package/icon-pack.mjs +117 -0
  24. package/in-place-install.mjs +17 -14
  25. package/init-command.mjs +116 -0
  26. package/install-app-windows.mjs +24 -7
  27. package/install-app.mjs +56 -42
  28. package/ios-asc-credentials.mjs +163 -0
  29. package/ios-build-context.mjs +129 -0
  30. package/ios-build-upload.mjs +282 -0
  31. package/ios-credentials-command.mjs +70 -0
  32. package/ios-dev.mjs +248 -0
  33. package/ios-host.mjs +76 -0
  34. package/ios-lock.mjs +75 -0
  35. package/ios-package-recipe.mjs +707 -0
  36. package/ios-testflight.mjs +266 -0
  37. package/ios-upload.mjs +313 -0
  38. package/local-publish.mjs +390 -47
  39. package/macho-approval-hash.mjs +181 -0
  40. package/managed-cargo.mjs +57 -1
  41. package/media-sidecar.mjs +20 -4
  42. package/mirror-root-artifact.mjs +74 -22
  43. package/native-cargo-layout.mjs +47 -4
  44. package/npm-license-notices.mjs +159 -0
  45. package/ownership-command.mjs +2 -1
  46. package/ownership-dependency-allowlist.json +2 -1
  47. package/ownership-evidence.mjs +30 -18
  48. package/ownership-key-command.mjs +69 -2
  49. package/ownership-mandatory-set.mjs +66 -16
  50. package/ownership-repositories.json +1 -1
  51. package/ownership-scope.mjs +94 -0
  52. package/ownership.mjs +86 -11
  53. package/package-recipe-targets.mjs +56 -2
  54. package/package.json +1 -1
  55. package/pe-content.mjs +133 -0
  56. package/preflight.mjs +14 -7
  57. package/project-type.mjs +3 -1
  58. package/protected-credentials.mjs +28 -1
  59. package/publication-artifacts.mjs +30 -5
  60. package/publication-gate.mjs +83 -5
  61. package/publish-cargo.mjs +124 -18
  62. package/publish-update.mjs +15 -2
  63. package/qa-build-features.mjs +16 -13
  64. package/qa-contract.mjs +30 -2
  65. package/qa-control-scan.mjs +4 -0
  66. package/release-dependencies-command.mjs +45 -0
  67. package/release-dependencies.mjs +60 -0
  68. package/release-invocation.mjs +118 -8
  69. package/release-switch-registry.json +126 -27
  70. package/release-switch-scan.mjs +62 -24
  71. package/release.mjs +144 -21
  72. package/rightkit-ownership.json +46 -2
  73. package/rightkit-versions.json +380 -189
  74. package/runtime-budgets.mjs +134 -23
  75. package/sign-updater.mjs +56 -59
  76. package/source-gate.mjs +8 -1
  77. package/stage-sidecar.mjs +634 -69
  78. package/target-distribution.mjs +5 -0
  79. package/test-command.mjs +190 -0
  80. package/toolchain-swift.mjs +239 -0
  81. package/upload-release.mjs +7 -2
  82. 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 } = resolveDmgOptions(input);
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
- log({ event: "dmg-build-completed", out, format: "UDZO" });
107
- return { out, format: "UDZO" };
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 same TAURI_SIGNING_PRIVATE_KEY / key file as
5
- // every other app), sealed, and re-verified by `verifyPublicationRelease`. This module only turns the same
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(String(signatureText).trim(), "base64").toString("utf8").split(/\r?\n/);
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 !== 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;
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());
@@ -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. Release installs may keep a persistent `<name>.previous` beside it (rename it back).
8
- * Dev and QA installs NEVER keep an old copy (Orthic-Labs/rightkit#348: "when installing dev, old copy must never be
9
- * backedup"): they pass `previous: null` to `swapInPlace`, or use `replaceFileInPlace`, which has no backup at all.
10
- * Nothing ever runs from `.previous` or from the staging copy.
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 (release): the old destination becomes `previous` (replacing any older `.previous`). Any
28
- * failure removes the new copy and renames `previous` back, so the fixed path is never left empty. Renames only.
29
- * @returns {{destination: string, previous: string|null}} previous is null when nothing was installed before.
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` (dev and QA, NO backup of any kind): the old destination is removed and `staging` is renamed
32
- * over the fixed path; a stale `<destination>.previous` left by an older RightKit is removed too. Any failure removes
33
- * the new (absent or partial) copy and the staging copy and rethrows; the old copy is NOT restored, the user reinstalls
34
- * from an older build. `previous` is always null in the result.
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 = previousPathOf(destination), verify = () => {}, fs = nodeFs }) {
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
- return { destination, previous: movedAside ? previous : null };
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 a dev install, including one an older RightKit left behind.
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
  }
@@ -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
+ }
@@ -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 (replaced on each install; nothing runs from it);
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-kept", { previous, files: backedUp.length, payloadOnly: true });
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
- throw new InstallAppWindowsError("installer-failed", `installer exited ${result.status ?? "abnormally"}${result.error ? `: ${result.error.message}` : ""}; ${restored ? `restored the ${backedUp.length} payload file(s) of ${dest} from ${previous}; no other file was touched` : noRollback}${failureNote()}`);
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
- throw new InstallAppWindowsError("path-changed", `the installer did not place ${exe} at the fixed path ${installed}; ${restored ? `restored the ${backedUp.length} payload file(s) from ${previous}; no other file was touched` : noRollback}${failureNote()}`);
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
- event("replaced", { installed, previous: backupMade ? previous : null, sha256: sha256(fs, installed) });
153
- return { ok: true, installed, previous: backupMade ? previous : null };
168
+ cleanupPrevious();
169
+ event("replaced", { installed, previous: null, sha256: sha256(fs, installed) });
170
+ return { ok: true, installed, previous: null };
154
171
  }