@rightkit/release 0.2.174 → 0.3.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 +178 -1
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/darwin/rightkit-swap.sign.json +1 -1
- package/cli/right-release.mjs +29 -3
- package/command-spec.mjs +80 -0
- package/deps-cargo.mjs +55 -0
- package/deps.mjs +7 -0
- package/dist-scan.mjs +102 -0
- package/dmg-build.mjs +53 -4
- package/icon-pack.mjs +117 -0
- package/init-command.mjs +116 -0
- package/install-app.mjs +36 -3
- package/local-publish.mjs +317 -29
- package/macho-approval-hash.mjs +181 -0
- package/managed-cargo.mjs +49 -1
- package/media-sidecar.mjs +20 -4
- package/mirror-root-artifact.mjs +69 -21
- package/npm-license-notices.mjs +159 -0
- package/ownership-dependency-allowlist.json +1 -0
- package/ownership.mjs +37 -4
- package/package-recipe-targets.mjs +15 -1
- package/package.json +1 -1
- package/pe-content.mjs +133 -0
- package/publication-gate.mjs +70 -3
- package/publish-cargo.mjs +26 -7
- package/release-dependencies-command.mjs +45 -0
- package/release-dependencies.mjs +60 -0
- package/release-switch-scan.mjs +17 -7
- package/release.mjs +75 -4
- package/rightkit-ownership.json +46 -2
- package/rightkit-versions.json +5 -4
- package/runtime-budgets.mjs +14 -7
- package/stage-sidecar.mjs +505 -68
- package/test-command.mjs +190 -0
- package/toolchain-swift.mjs +239 -0
- package/web-build.mjs +67 -0
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];
|
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/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.mjs
CHANGED
|
@@ -282,6 +282,31 @@ async function keepPreviousBundle(run, fsImpl, bundle, installed) {
|
|
|
282
282
|
return previous;
|
|
283
283
|
}
|
|
284
284
|
|
|
285
|
+
// Exclusive per-destination install lock: a lock file beside the installed apps, created O_EXCL. A lock whose pid is dead is
|
|
286
|
+
// stale and taken over once. Held across recovery, staging, journal mutation and cleanup so two installers never share the
|
|
287
|
+
// fixed staging/journal paths.
|
|
288
|
+
const LOCK_NAME = ".rightkit-install.lock";
|
|
289
|
+
// Locks this process currently holds. A lock naming our own pid that is not in this set was leaked (a simulated or
|
|
290
|
+
// aborted install in this process), so it is stale exactly like a dead pid's.
|
|
291
|
+
const heldLocks = new Set();
|
|
292
|
+
function acquireInstallLock({ dest, fsImpl, pid, isAlive }) {
|
|
293
|
+
const lock = path.join(dest, LOCK_NAME);
|
|
294
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
295
|
+
try {
|
|
296
|
+
fsImpl.writeFileSync(lock, JSON.stringify({ pid }), { flag: "wx" });
|
|
297
|
+
heldLocks.add(lock);
|
|
298
|
+
return () => { heldLocks.delete(lock); try { fsImpl.rmSync(lock, { force: true }); } catch { /* best effort */ } };
|
|
299
|
+
} catch (error) {
|
|
300
|
+
if (error?.code !== "EEXIST") throw new InstallAppError("lock-failed", `cannot create install lock ${lock}: ${errText(error)}`);
|
|
301
|
+
let holder = null;
|
|
302
|
+
try { holder = JSON.parse(fsImpl.readFileSync(lock, "utf8")).pid; } catch { holder = null; }
|
|
303
|
+
if (Number.isInteger(holder) && isAlive(holder) && !(holder === pid && !heldLocks.has(lock))) throw new InstallAppError("install-locked", `another install holds ${lock} (pid ${holder})`);
|
|
304
|
+
try { fsImpl.rmSync(lock, { force: true }); } catch { /* retry decides */ }
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
throw new InstallAppError("install-locked", `could not take install lock ${lock}`);
|
|
308
|
+
}
|
|
309
|
+
|
|
285
310
|
// Deterministic roll-forward/back of any interrupted install journal in `dest`. Idempotent.
|
|
286
311
|
export async function recoverInstalls({ dest, appName = null, run, fsImpl, home, now, event, sleep, isAlive = defaultIsAlive, selfPid = process.pid, swapPaths = createSwapPaths() }) {
|
|
287
312
|
let names = [];
|
|
@@ -357,7 +382,10 @@ export async function recoverInstalls({ dest, appName = null, run, fsImpl, home,
|
|
|
357
382
|
export async function recoverApp(opts, deps = {}) {
|
|
358
383
|
const { execFile = realExecFile, fsImpl = fs, home = os.homedir(), now = () => new Date(), sleep = (ms) => new Promise((r) => setTimeout(r, ms)), emit = () => {}, isAlive, platform = process.platform, env = process.env, swapPaths = createSwapPaths({ env }) } = deps;
|
|
359
384
|
if (platform !== "darwin") throw new InstallAppError("unsupported", `install-app is not implemented on ${platform}`);
|
|
360
|
-
const
|
|
385
|
+
const release = acquireInstallLock({ dest: opts.dest, fsImpl, pid: process.pid, isAlive: isAlive ?? defaultIsAlive });
|
|
386
|
+
let results;
|
|
387
|
+
try { results = await recoverInstalls({ dest: opts.dest, appName: opts.appName, run: (c, a) => execFile(c, a), fsImpl, home, now, event: (name, data = {}) => emit({ event: name, ...data }), sleep, isAlive, swapPaths }); }
|
|
388
|
+
finally { release(); }
|
|
361
389
|
emit({ event: "recover-done", dest: opts.dest, recovered: results.length });
|
|
362
390
|
return { ok: true, recovered: results };
|
|
363
391
|
}
|
|
@@ -389,6 +417,7 @@ export async function installApp(opts, deps = {}) {
|
|
|
389
417
|
let mountPoint = null;
|
|
390
418
|
let failedStage = "start";
|
|
391
419
|
let swap = null; // set once the journal exists; tracks which renames happened
|
|
420
|
+
let releaseLock = () => {};
|
|
392
421
|
let devOldRemoved = false; // a dev install removed the old bundle; nothing can put it back
|
|
393
422
|
|
|
394
423
|
const removeQuiet = (p) => { if (p) { try { fsImpl.rmSync(p, { recursive: true, force: true }); } catch { /* best effort */ } } };
|
|
@@ -428,6 +457,7 @@ export async function installApp(opts, deps = {}) {
|
|
|
428
457
|
await detach();
|
|
429
458
|
cleanupExtract();
|
|
430
459
|
} catch { /* exiting anyway */ }
|
|
460
|
+
releaseLock();
|
|
431
461
|
exit(128 + (os.constants.signals[sig] || 0));
|
|
432
462
|
})();
|
|
433
463
|
};
|
|
@@ -455,6 +485,7 @@ export async function installApp(opts, deps = {}) {
|
|
|
455
485
|
if (!fsImpl.existsSync(inputPath)) throw new InstallAppError(`${inputKind}-missing`, `${inputKind.toUpperCase()} not found: ${inputPath}`);
|
|
456
486
|
if (!fsImpl.existsSync(opts.dest)) throw new InstallAppError("dest-missing", `destination not found: ${opts.dest}`);
|
|
457
487
|
|
|
488
|
+
releaseLock = acquireInstallLock({ dest: opts.dest, fsImpl, pid, isAlive });
|
|
458
489
|
await recoverInstalls({ dest: opts.dest, appName: opts.appName, run, fsImpl, home, now, event, sleep, isAlive, selfPid: pid, swapPaths });
|
|
459
490
|
|
|
460
491
|
let sourceApp = null;
|
|
@@ -511,8 +542,9 @@ export async function installApp(opts, deps = {}) {
|
|
|
511
542
|
// Stage a verified copy on the destination volume so the swap is rename(2) only.
|
|
512
543
|
failedStage = "stage";
|
|
513
544
|
const base = appName.replace(/\.app$/, "");
|
|
514
|
-
// Staging is never run from: it is verified, then renamed into place.
|
|
515
|
-
|
|
545
|
+
// Staging is never run from: it is verified, then renamed into place. Its name is fixed per app (no per-pid sibling), like the
|
|
546
|
+
// journal beside it; the rollback copy is the fixed `<installed>.previous`.
|
|
547
|
+
const staged = path.join(opts.dest, `.${base}.rightkit-install.app`);
|
|
516
548
|
// Dev installs keep NO old copy of any kind (Orthic-Labs/rightkit#348): the old bundle is removed, never moved to `.previous`.
|
|
517
549
|
const backup = hadInstalled && !dev ? previousPathOf(installed) : null;
|
|
518
550
|
const journal = journalPathFor(opts.dest, appName);
|
|
@@ -634,6 +666,7 @@ export async function installApp(opts, deps = {}) {
|
|
|
634
666
|
rollbackSwap();
|
|
635
667
|
await detach();
|
|
636
668
|
cleanupExtract();
|
|
669
|
+
releaseLock();
|
|
637
670
|
}
|
|
638
671
|
}
|
|
639
672
|
|