@rightkit/release 0.2.173 → 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 +188 -5
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/darwin/rightkit-swap.sign.json +1 -1
- package/cli/right-release.mjs +34 -6
- package/command-spec.mjs +80 -0
- package/deps-cargo.mjs +55 -0
- package/deps.mjs +7 -0
- package/development-signing.mjs +135 -4
- package/development.mjs +12 -11
- package/dist-scan.mjs +102 -0
- package/dmg-build.mjs +53 -4
- package/icon-pack.mjs +117 -0
- package/in-place-install.mjs +32 -7
- package/init-command.mjs +116 -0
- package/install-app-windows.mjs +108 -17
- package/install-app.mjs +68 -11
- package/install-tauri-devid.mjs +106 -47
- package/local-publish.mjs +317 -29
- package/mac-bundle-signing.mjs +143 -1
- package/mac-signing-matrix.mjs +73 -6
- package/macho-approval-hash.mjs +181 -0
- package/macos-signing-session.mjs +12 -2
- 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 +180 -16
- package/package.json +1 -1
- package/pe-content.mjs +133 -0
- package/portable-development.mjs +9 -9
- package/publication-gate.mjs +70 -3
- package/publish-cargo.mjs +26 -7
- package/qa-build-features.mjs +7 -1
- package/recipe-build.mjs +346 -0
- package/release-dependencies-command.mjs +45 -0
- package/release-dependencies.mjs +60 -0
- package/release-switch-scan.mjs +17 -7
- package/release.mjs +98 -12
- package/rightkit-ownership.json +46 -2
- package/rightkit-signing-agent.mjs +10 -4
- package/rightkit-versions.json +11 -6
- package/runtime-budgets.mjs +28 -12
- package/stage-binary.mjs +1 -1
- package/stage-sidecar-command.mjs +36 -4
- package/stage-sidecar.mjs +926 -74
- package/test-command.mjs +190 -0
- package/toolchain-swift.mjs +239 -0
- package/web-build.mjs +67 -0
package/in-place-install.mjs
CHANGED
|
@@ -4,8 +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
|
-
*
|
|
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.
|
|
9
11
|
*/
|
|
10
12
|
export const PREVIOUS_SUFFIX = ".previous";
|
|
11
13
|
export const STAGING_SUFFIX = ".staging";
|
|
@@ -20,12 +22,19 @@ const exists = (fs, file) => {
|
|
|
20
22
|
};
|
|
21
23
|
|
|
22
24
|
/**
|
|
23
|
-
* Swap `staging` into `destination
|
|
24
|
-
*
|
|
25
|
-
* path
|
|
25
|
+
* Swap `staging` into `destination`, then `verify(destination)` runs.
|
|
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.
|
|
26
29
|
* @returns {{destination: string, previous: string|null}} previous is null when nothing was installed before.
|
|
30
|
+
*
|
|
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.
|
|
27
35
|
*/
|
|
28
36
|
export function swapInPlace({ staging, destination, previous = previousPathOf(destination), verify = () => {}, fs = nodeFs }) {
|
|
37
|
+
if (previous === null) return replaceWithoutBackup({ staging, destination, verify, fs });
|
|
29
38
|
if (previous === destination || staging === destination || staging === previous) throw new Error("swapInPlace needs three distinct paths");
|
|
30
39
|
let movedAside = false;
|
|
31
40
|
try {
|
|
@@ -45,12 +54,28 @@ export function swapInPlace({ staging, destination, previous = previousPathOf(de
|
|
|
45
54
|
return { destination, previous: movedAside ? previous : null };
|
|
46
55
|
}
|
|
47
56
|
|
|
48
|
-
|
|
57
|
+
function replaceWithoutBackup({ staging, destination, verify, fs }) {
|
|
58
|
+
if (staging === destination) throw new Error("swapInPlace needs distinct staging and destination paths");
|
|
59
|
+
try {
|
|
60
|
+
if (exists(fs, destination)) fs.rmSync(destination, { recursive: true, force: true });
|
|
61
|
+
fs.renameSync(staging, destination);
|
|
62
|
+
verify(destination);
|
|
63
|
+
} catch (error) {
|
|
64
|
+
try { fs.rmSync(destination, { recursive: true, force: true }); } catch { /* keep the original error */ }
|
|
65
|
+
try { fs.rmSync(staging, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
// No old copy may survive a dev install, including one an older RightKit left behind.
|
|
69
|
+
try { fs.rmSync(previousPathOf(destination), { recursive: true, force: true }); } catch { /* best effort */ }
|
|
70
|
+
return { destination, previous: null };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Copy one dev or QA file to its fixed path in place: temp sibling, then rename. NO backup of the old file. Returns the path. */
|
|
49
74
|
export function replaceFileInPlace(from, destination, { fs = nodeFs } = {}) {
|
|
50
75
|
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
51
76
|
const staging = stagingPathOf(destination);
|
|
52
77
|
fs.rmSync(staging, { force: true });
|
|
53
78
|
fs.copyFileSync(from, staging);
|
|
54
|
-
swapInPlace({ staging, destination });
|
|
79
|
+
swapInPlace({ staging, destination, previous: null, fs });
|
|
55
80
|
return destination;
|
|
56
81
|
}
|
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
|
@@ -5,15 +5,23 @@ import { spawnSync } from "./background-process.mjs";
|
|
|
5
5
|
import { previousPathOf } from "./in-place-install.mjs";
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* `right-release install-app --installer <Setup.exe> --dest <install folder> --exe <App.exe
|
|
8
|
+
* `right-release install-app --installer <Setup.exe> --dest <install folder> --exe <App.exe> [--payload <file>]... [--payload-manifest <json>]` on Windows (Orthic-Labs/rightkit#258).
|
|
9
9
|
*
|
|
10
10
|
* The same rule as the macOS install: the app is installed and updated IN PLACE at one fixed folder (`--dest`, the
|
|
11
|
-
* folder the NSIS installer already owns, for example `%LOCALAPPDATA%\Programs\Pulse`).
|
|
12
|
-
*
|
|
13
|
-
* versioned, digest or per-run folder. The silent installer is pinned to the fixed folder with `/D=` so it cannot choose another one.
|
|
11
|
+
* folder the NSIS installer already owns, for example `%LOCALAPPDATA%\Programs\Pulse`). The silent installer is pinned to the
|
|
12
|
+
* fixed folder with `/D=` so it cannot choose another one.
|
|
14
13
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
14
|
+
* DATA SAFETY (Orthic-Labs/rightkit#348): the install folder can also hold USER DATA (CodeRight keeps coderight.db, its WAL,
|
|
15
|
+
* state and models in `%LOCALAPPDATA%\CodeRight`). So this module NEVER copies, restores, removes or overwrites the whole
|
|
16
|
+
* install folder. Rollback is PAYLOAD-ONLY, from an explicit allowlist of the files the installer itself writes:
|
|
17
|
+
* - the allowlist is `--payload <relative file>` (repeatable) and/or `--payload-manifest <json file>` (`{ "files": [...] }` or a bare array)
|
|
18
|
+
* plus the `--exe` file; entries are relative regular files inside `--dest`, no directories, wildcards, `..` or drive paths;
|
|
19
|
+
* - with NO declared payload the allowlist is just the `--exe` file (always backed up and restored; nothing else is);
|
|
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);
|
|
22
|
+
* - 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
|
+
* Dev installs do not pass through this module: they keep no backup at all (see development.mjs, install-app.mjs --dev).
|
|
17
25
|
*/
|
|
18
26
|
export class InstallAppWindowsError extends Error {
|
|
19
27
|
constructor(code, message) { super(message); this.name = "InstallAppWindowsError"; this.code = code; }
|
|
@@ -26,6 +34,40 @@ export function windowsInstallArguments(dest) {
|
|
|
26
34
|
|
|
27
35
|
const sha256 = (fs, file) => createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
|
28
36
|
|
|
37
|
+
const SEGMENT_BAD = /[<>:"|?*\0]/;
|
|
38
|
+
|
|
39
|
+
/** One allowlist entry as a normalized `a/b/c` relative file path, or a usage error. */
|
|
40
|
+
function normalizePayloadEntry(entry) {
|
|
41
|
+
if (typeof entry !== "string" || !entry.trim()) throw new InstallAppWindowsError("usage", "a payload entry must be a non-empty relative file path");
|
|
42
|
+
if (path.win32.isAbsolute(entry) || path.posix.isAbsolute(entry) || /^[A-Za-z]:/.test(entry)) throw new InstallAppWindowsError("usage", `payload entry ${JSON.stringify(entry)} must be relative to --dest, not absolute`);
|
|
43
|
+
const segments = entry.split(/[\\/]/);
|
|
44
|
+
if (segments.some((segment) => segment === "" || segment === "." || segment === ".." || SEGMENT_BAD.test(segment))) {
|
|
45
|
+
throw new InstallAppWindowsError("usage", `payload entry ${JSON.stringify(entry)} must be a plain relative file path (no directories, wildcards, "..", or empty segments)`);
|
|
46
|
+
}
|
|
47
|
+
return segments.join("/");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The payload allowlist: `--exe`, `--payload` entries and a `--payload-manifest` file, normalized and de-duplicated. Empty when nothing is declared. */
|
|
51
|
+
export function resolvePayloadAllowlist({ exe, payload = [], payloadManifest = null }, { fs = nodeFs } = {}) {
|
|
52
|
+
const declared = [...(Array.isArray(payload) ? payload : [payload])];
|
|
53
|
+
if (payloadManifest) {
|
|
54
|
+
let parsed;
|
|
55
|
+
try { parsed = JSON.parse(String(fs.readFileSync(payloadManifest, "utf8"))); }
|
|
56
|
+
catch (error) { throw new InstallAppWindowsError("usage", `--payload-manifest ${payloadManifest} cannot be read as JSON: ${error.message}`); }
|
|
57
|
+
const files = Array.isArray(parsed) ? parsed : parsed?.files;
|
|
58
|
+
if (!Array.isArray(files)) throw new InstallAppWindowsError("usage", `--payload-manifest ${payloadManifest} must be a JSON array of relative files, or { "files": [...] }`);
|
|
59
|
+
declared.push(...files);
|
|
60
|
+
}
|
|
61
|
+
// `--exe` alone is the minimal allowlist: the executable is always backed up and restored; data files never are.
|
|
62
|
+
const normalized = [...new Set([exe, ...declared].map(normalizePayloadEntry))];
|
|
63
|
+
const lower = new Set();
|
|
64
|
+
for (const entry of normalized) {
|
|
65
|
+
if (lower.has(entry.toLowerCase())) throw new InstallAppWindowsError("usage", `payload entry ${JSON.stringify(entry)} is listed twice (Windows paths are case-insensitive)`);
|
|
66
|
+
lower.add(entry.toLowerCase());
|
|
67
|
+
}
|
|
68
|
+
return normalized.sort();
|
|
69
|
+
}
|
|
70
|
+
|
|
29
71
|
export function installAppWindows(opts, { platform = process.platform, fs = nodeFs, run = spawnSync, event = () => {} } = {}) {
|
|
30
72
|
if (platform !== "win32") throw new InstallAppWindowsError("unsupported", `the Windows installer mode runs on win32, not ${platform}`);
|
|
31
73
|
const { installer, dest, exe } = opts;
|
|
@@ -33,31 +75,80 @@ export function installAppWindows(opts, { platform = process.platform, fs = node
|
|
|
33
75
|
if (!dest || !path.win32.isAbsolute(dest) || path.win32.parse(dest).root === dest) throw new InstallAppWindowsError("usage", "--dest must be the fixed absolute install folder, not a drive root");
|
|
34
76
|
if (!exe || exe !== path.win32.basename(exe)) throw new InstallAppWindowsError("usage", "--exe must be the bare executable name inside --dest");
|
|
35
77
|
if (!fs.existsSync(installer)) throw new InstallAppWindowsError("installer-missing", `installer not found: ${installer}`);
|
|
78
|
+
const allowlist = resolvePayloadAllowlist({ exe, payload: opts.payload, payloadManifest: opts.payloadManifest }, { fs });
|
|
36
79
|
const previous = previousPathOf(dest);
|
|
37
80
|
const installed = path.win32.join(dest, exe);
|
|
38
81
|
const hadInstall = fs.existsSync(dest);
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
82
|
+
const inDest = (relative) => path.win32.join(dest, ...relative.split("/"));
|
|
83
|
+
const inPrevious = (relative) => path.win32.join(previous, ...relative.split("/"));
|
|
84
|
+
const isFile = (file) => { try { return (fs.lstatSync ?? fs.statSync).call(fs, file).isFile(); } catch { return false; } };
|
|
85
|
+
// lstat every segment from dest down to the file; a symlink or junction anywhere in the chain is refused. A missing segment ends the walk.
|
|
86
|
+
const assertNoLinks = (relative) => {
|
|
87
|
+
const lstat = fs.lstatSync ?? fs.statSync;
|
|
88
|
+
let current = dest;
|
|
89
|
+
for (const segment of [null, ...relative.split("/")]) {
|
|
90
|
+
if (segment !== null) current = path.win32.join(current, segment);
|
|
91
|
+
let stat;
|
|
92
|
+
try { stat = lstat.call(fs, current); } catch { return; }
|
|
93
|
+
if (stat.isSymbolicLink?.() || stat.isReparsePoint?.()) throw new InstallAppWindowsError("usage", `${current} is a symlink or junction; refusing to back up, restore or remove payload entry ${relative} through it`);
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
event("start", { installer, dest, replacing: hadInstall, payloadFiles: allowlist.length });
|
|
97
|
+
|
|
98
|
+
// Backup: ONLY the allowlisted files that exist now, into `<dest>.previous`. The rest of --dest (user data) is never copied.
|
|
99
|
+
const backedUp = [];
|
|
100
|
+
const absentBefore = [];
|
|
101
|
+
let backupMade = false;
|
|
102
|
+
if (hadInstall && allowlist.length > 0) {
|
|
103
|
+
for (const relative of allowlist) {
|
|
104
|
+
const file = inDest(relative);
|
|
105
|
+
assertNoLinks(relative);
|
|
106
|
+
if (!fs.existsSync(file)) { absentBefore.push(relative); continue; }
|
|
107
|
+
if (!isFile(file)) throw new InstallAppWindowsError("usage", `payload entry ${relative} is not a regular file in ${dest}; refusing to back it up or roll it back`);
|
|
108
|
+
backedUp.push(relative);
|
|
109
|
+
}
|
|
110
|
+
// One payload-only rollback copy, replaced on every install (this also clears a whole-folder copy an older RightKit left).
|
|
42
111
|
fs.rmSync(previous, { recursive: true, force: true });
|
|
43
|
-
|
|
44
|
-
|
|
112
|
+
for (const relative of backedUp) {
|
|
113
|
+
fs.mkdirSync(path.win32.dirname(inPrevious(relative)), { recursive: true });
|
|
114
|
+
fs.copyFileSync(inDest(relative), inPrevious(relative));
|
|
115
|
+
}
|
|
116
|
+
backupMade = true;
|
|
117
|
+
event("previous-kept", { previous, files: backedUp.length, payloadOnly: true });
|
|
45
118
|
}
|
|
119
|
+
// Roll back the payload and nothing else: restore allowlisted files that existed, remove allowlisted files this run newly wrote.
|
|
120
|
+
// Each file is restored independently; failures are collected and reported together, never masking the installer error.
|
|
121
|
+
const restoreFailures = [];
|
|
46
122
|
const restore = () => {
|
|
47
|
-
if (!
|
|
48
|
-
|
|
123
|
+
if (!backupMade) return false;
|
|
124
|
+
for (const relative of backedUp) {
|
|
125
|
+
try {
|
|
126
|
+
assertNoLinks(relative);
|
|
127
|
+
fs.mkdirSync(path.win32.dirname(inDest(relative)), { recursive: true });
|
|
128
|
+
fs.copyFileSync(inPrevious(relative), inDest(relative));
|
|
129
|
+
} catch (error) { restoreFailures.push(`${relative}: ${error.message}`); }
|
|
130
|
+
}
|
|
131
|
+
for (const relative of absentBefore) {
|
|
132
|
+
try {
|
|
133
|
+
assertNoLinks(relative);
|
|
134
|
+
const file = inDest(relative);
|
|
135
|
+
if (fs.existsSync(file) && isFile(file)) fs.rmSync(file, { force: true });
|
|
136
|
+
} catch (error) { restoreFailures.push(`${relative}: ${error.message}`); }
|
|
137
|
+
}
|
|
49
138
|
return true;
|
|
50
139
|
};
|
|
140
|
+
const failureNote = () => (restoreFailures.length ? `; ROLLBACK INCOMPLETE, ${restoreFailures.length} file(s) failed: ${restoreFailures.join(" | ")}` : "");
|
|
141
|
+
const noRollback = "nothing was installed before, so nothing was rolled back and no other file in the install folder was touched";
|
|
51
142
|
const result = run(installer, windowsInstallArguments(dest), { stdio: "inherit", windowsHide: true, shell: false });
|
|
52
143
|
if (result.status !== 0) {
|
|
53
144
|
const restored = restore();
|
|
54
|
-
throw new InstallAppWindowsError("installer-failed", `installer exited ${result.status ?? "abnormally"}${result.error ? `: ${result.error.message}` : ""}; ${restored ? `restored ${dest} from ${previous}
|
|
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()}`);
|
|
55
146
|
}
|
|
56
147
|
// The installer must have updated the fixed folder: the executable is at the same path as before, not in a new folder.
|
|
57
148
|
if (!fs.existsSync(installed) || fs.statSync(installed).size === 0) {
|
|
58
149
|
const restored = restore();
|
|
59
|
-
throw new InstallAppWindowsError("path-changed", `the installer did not place ${exe} at the fixed path ${installed}; ${restored ? `restored from ${previous}
|
|
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()}`);
|
|
60
151
|
}
|
|
61
|
-
event("replaced", { installed, previous:
|
|
62
|
-
return { ok: true, installed, previous:
|
|
152
|
+
event("replaced", { installed, previous: backupMade ? previous : null, sha256: sha256(fs, installed) });
|
|
153
|
+
return { ok: true, installed, previous: backupMade ? previous : null };
|
|
63
154
|
}
|
package/install-app.mjs
CHANGED
|
@@ -22,11 +22,11 @@ export class InstallAppError extends Error {
|
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
const VALUE_FLAGS = new Set(["--dmg", "--zip", "--app", "--app-name", "--bundle-id", "--dest", "--installer", "--exe"]);
|
|
25
|
+
const VALUE_FLAGS = new Set(["--dmg", "--zip", "--app", "--app-name", "--bundle-id", "--dest", "--installer", "--exe", "--payload", "--payload-manifest"]);
|
|
26
26
|
const BOOL_FLAGS = new Set(["--relaunch", "--no-relaunch", "--json", "--dev", "--recover"]);
|
|
27
27
|
|
|
28
28
|
export function parseArgs(argv) {
|
|
29
|
-
const opts = { dmg: null, zip: null, app: null, installer: null, exe: null, dev: false, appName: null, bundleId: null, dest: "/Applications", relaunch: null, json: false, recover: false };
|
|
29
|
+
const opts = { dmg: null, zip: null, app: null, installer: null, exe: null, payload: [], payloadManifest: null, dev: false, appName: null, bundleId: null, dest: "/Applications", relaunch: null, json: false, recover: false };
|
|
30
30
|
for (let i = 0; i < argv.length; i += 1) {
|
|
31
31
|
const flag = argv[i];
|
|
32
32
|
if (VALUE_FLAGS.has(flag)) {
|
|
@@ -40,6 +40,8 @@ export function parseArgs(argv) {
|
|
|
40
40
|
else if (flag === "--bundle-id") opts.bundleId = value;
|
|
41
41
|
else if (flag === "--installer") opts.installer = value;
|
|
42
42
|
else if (flag === "--exe") opts.exe = value;
|
|
43
|
+
else if (flag === "--payload") opts.payload.push(value);
|
|
44
|
+
else if (flag === "--payload-manifest") opts.payloadManifest = value;
|
|
43
45
|
else opts.dest = value;
|
|
44
46
|
} else if (BOOL_FLAGS.has(flag)) {
|
|
45
47
|
if (flag === "--json") opts.json = true;
|
|
@@ -51,11 +53,13 @@ export function parseArgs(argv) {
|
|
|
51
53
|
}
|
|
52
54
|
}
|
|
53
55
|
if (opts.installer !== null) {
|
|
54
|
-
// Windows: run the sealed NSIS installer in place at one fixed folder,
|
|
56
|
+
// Windows: run the sealed NSIS installer in place at one fixed folder; rollback is payload-only (--payload / --payload-manifest), never the whole folder.
|
|
55
57
|
if (opts.dmg !== null || opts.zip !== null || opts.app !== null || opts.recover || opts.dev) throw new InstallAppError("usage", "--installer cannot be combined with --dmg, --zip, --app, --dev or --recover");
|
|
56
58
|
if (!argv.includes("--dest") || opts.exe === null) throw new InstallAppError("usage", "--installer requires --dest <fixed install folder> and --exe <App.exe>");
|
|
59
|
+
if (opts.payloadManifest !== null) opts.payloadManifest = path.resolve(opts.payloadManifest);
|
|
57
60
|
return opts;
|
|
58
61
|
}
|
|
62
|
+
if (opts.payload.length > 0 || opts.payloadManifest !== null) throw new InstallAppError("usage", "--payload and --payload-manifest only apply to --installer (the Windows install)");
|
|
59
63
|
if (argv.includes("--relaunch") && argv.includes("--no-relaunch")) throw new InstallAppError("usage", "--relaunch and --no-relaunch are mutually exclusive");
|
|
60
64
|
const inputs = [opts.dmg, opts.zip, opts.app].filter((v) => v !== null).length;
|
|
61
65
|
if (opts.recover) {
|
|
@@ -278,6 +282,31 @@ async function keepPreviousBundle(run, fsImpl, bundle, installed) {
|
|
|
278
282
|
return previous;
|
|
279
283
|
}
|
|
280
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
|
+
|
|
281
310
|
// Deterministic roll-forward/back of any interrupted install journal in `dest`. Idempotent.
|
|
282
311
|
export async function recoverInstalls({ dest, appName = null, run, fsImpl, home, now, event, sleep, isAlive = defaultIsAlive, selfPid = process.pid, swapPaths = createSwapPaths() }) {
|
|
283
312
|
let names = [];
|
|
@@ -311,7 +340,8 @@ export async function recoverInstalls({ dest, appName = null, run, fsImpl, home,
|
|
|
311
340
|
let ok = j.phase === "verified";
|
|
312
341
|
if (!ok) { try { await verifyBundle(run, j.installed, { assess: !j.dev, staple: false }); ok = true; } catch { ok = false; } }
|
|
313
342
|
if (ok) {
|
|
314
|
-
|
|
343
|
+
// A dev journal (from an older RightKit) never keeps the old bundle: the staged path is cleared below.
|
|
344
|
+
try { if (!j.dev) await keepPreviousBundle(run, fsImpl, j.staged, j.installed); action = "rolled-forward"; }
|
|
315
345
|
catch { action = "rolled-forward-previous-pending"; keepJournal = true; keepStaged = true; }
|
|
316
346
|
} else {
|
|
317
347
|
swapBack(swapPaths, fsImpl, j.staged, j.installed); // staged now holds the bad new bundle
|
|
@@ -325,7 +355,7 @@ export async function recoverInstalls({ dest, appName = null, run, fsImpl, home,
|
|
|
325
355
|
let ok = j.phase === "verified";
|
|
326
356
|
if (!ok) { try { await verifyBundle(run, j.installed, { assess: !j.dev, staple: false }); ok = true; } catch { ok = false; } }
|
|
327
357
|
if (ok) {
|
|
328
|
-
try { await keepPreviousBundle(run, fsImpl, j.backup, j.installed); action = "rolled-forward"; }
|
|
358
|
+
try { if (j.dev) rm(j.backup); else await keepPreviousBundle(run, fsImpl, j.backup, j.installed); action = "rolled-forward"; }
|
|
329
359
|
catch { action = "rolled-forward-previous-pending"; keepJournal = true; }
|
|
330
360
|
} else {
|
|
331
361
|
rm(j.installed);
|
|
@@ -352,7 +382,10 @@ export async function recoverInstalls({ dest, appName = null, run, fsImpl, home,
|
|
|
352
382
|
export async function recoverApp(opts, deps = {}) {
|
|
353
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;
|
|
354
384
|
if (platform !== "darwin") throw new InstallAppError("unsupported", `install-app is not implemented on ${platform}`);
|
|
355
|
-
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(); }
|
|
356
389
|
emit({ event: "recover-done", dest: opts.dest, recovered: results.length });
|
|
357
390
|
return { ok: true, recovered: results };
|
|
358
391
|
}
|
|
@@ -384,6 +417,8 @@ export async function installApp(opts, deps = {}) {
|
|
|
384
417
|
let mountPoint = null;
|
|
385
418
|
let failedStage = "start";
|
|
386
419
|
let swap = null; // set once the journal exists; tracks which renames happened
|
|
420
|
+
let releaseLock = () => {};
|
|
421
|
+
let devOldRemoved = false; // a dev install removed the old bundle; nothing can put it back
|
|
387
422
|
|
|
388
423
|
const removeQuiet = (p) => { if (p) { try { fsImpl.rmSync(p, { recursive: true, force: true }); } catch { /* best effort */ } } };
|
|
389
424
|
// Undo an unfinished swap using renames only. Sync on purpose so a signal handler can run it.
|
|
@@ -422,6 +457,7 @@ export async function installApp(opts, deps = {}) {
|
|
|
422
457
|
await detach();
|
|
423
458
|
cleanupExtract();
|
|
424
459
|
} catch { /* exiting anyway */ }
|
|
460
|
+
releaseLock();
|
|
425
461
|
exit(128 + (os.constants.signals[sig] || 0));
|
|
426
462
|
})();
|
|
427
463
|
};
|
|
@@ -449,6 +485,7 @@ export async function installApp(opts, deps = {}) {
|
|
|
449
485
|
if (!fsImpl.existsSync(inputPath)) throw new InstallAppError(`${inputKind}-missing`, `${inputKind.toUpperCase()} not found: ${inputPath}`);
|
|
450
486
|
if (!fsImpl.existsSync(opts.dest)) throw new InstallAppError("dest-missing", `destination not found: ${opts.dest}`);
|
|
451
487
|
|
|
488
|
+
releaseLock = acquireInstallLock({ dest: opts.dest, fsImpl, pid, isAlive });
|
|
452
489
|
await recoverInstalls({ dest: opts.dest, appName: opts.appName, run, fsImpl, home, now, event, sleep, isAlive, selfPid: pid, swapPaths });
|
|
453
490
|
|
|
454
491
|
let sourceApp = null;
|
|
@@ -505,9 +542,11 @@ export async function installApp(opts, deps = {}) {
|
|
|
505
542
|
// Stage a verified copy on the destination volume so the swap is rename(2) only.
|
|
506
543
|
failedStage = "stage";
|
|
507
544
|
const base = appName.replace(/\.app$/, "");
|
|
508
|
-
// Staging is never run from: it is verified, then renamed into place.
|
|
509
|
-
|
|
510
|
-
const
|
|
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`);
|
|
548
|
+
// Dev installs keep NO old copy of any kind (Orthic-Labs/rightkit#348): the old bundle is removed, never moved to `.previous`.
|
|
549
|
+
const backup = hadInstalled && !dev ? previousPathOf(installed) : null;
|
|
511
550
|
const journal = journalPathFor(opts.dest, appName);
|
|
512
551
|
const journalData = { schema: 1, appName, pid, phase: "staging", dev, installed, backup, staged, mount: mountPoint, mountRoot };
|
|
513
552
|
removeQuiet(staged);
|
|
@@ -543,6 +582,17 @@ export async function installApp(opts, deps = {}) {
|
|
|
543
582
|
writeJournal(fsImpl, journal, { ...jd, phase: "swapping", swap: swapMode });
|
|
544
583
|
fsImpl.renameSync(staged, installed);
|
|
545
584
|
swap.stagedIn = true;
|
|
585
|
+
} else if (dev) {
|
|
586
|
+
// Dev: remove the old bundle, then rename the verified staged copy over the fixed path. No backup, no exchange (an exchange
|
|
587
|
+
// would park the old bundle at the staged path). A failure from here leaves the path absent or partial: reinstall from an older DMG.
|
|
588
|
+
swapMode = "replace";
|
|
589
|
+
swap.mode = "two-rename";
|
|
590
|
+
jd = { ...journalData, swap: "two-rename", backup: null };
|
|
591
|
+
writeJournal(fsImpl, journal, { ...jd, phase: "swapping" });
|
|
592
|
+
devOldRemoved = true;
|
|
593
|
+
fsImpl.rmSync(installed, { recursive: true, force: true });
|
|
594
|
+
fsImpl.renameSync(staged, installed);
|
|
595
|
+
swap.stagedIn = true;
|
|
546
596
|
} else {
|
|
547
597
|
jd = { ...journalData, swap: "atomic", backup: null, stagedIno: fsImpl.statSync(staged).ino };
|
|
548
598
|
writeJournal(fsImpl, journal, { ...jd, phase: "swapping" });
|
|
@@ -586,6 +636,8 @@ export async function installApp(opts, deps = {}) {
|
|
|
586
636
|
}
|
|
587
637
|
if (!oldBundle || kept !== previousPathOf(installed)) { if (!oldBundle) removeQuiet(journal); }
|
|
588
638
|
else removeQuiet(journal);
|
|
639
|
+
// A stale `.previous` from an older RightKit must not survive a dev install either.
|
|
640
|
+
if (dev) removeQuiet(previousPathOf(installed));
|
|
589
641
|
swap = null;
|
|
590
642
|
event("replaced", { installed, previous: kept, swap: swapMode });
|
|
591
643
|
|
|
@@ -601,7 +653,11 @@ export async function installApp(opts, deps = {}) {
|
|
|
601
653
|
return result;
|
|
602
654
|
} catch (error) {
|
|
603
655
|
rollbackSwap();
|
|
604
|
-
|
|
656
|
+
let reason = error instanceof InstallAppError ? error.message : errText(error);
|
|
657
|
+
if (dev && devOldRemoved) {
|
|
658
|
+
reason = `${reason}; the dev install keeps no old copy, so the failed install was removed: reinstall from an older DMG`;
|
|
659
|
+
if (error instanceof InstallAppError) error.message = reason;
|
|
660
|
+
}
|
|
605
661
|
event("failed", { stage: failedStage, code: error.code || "error", reason });
|
|
606
662
|
if (!(error instanceof InstallAppError)) throw new InstallAppError("error", reason);
|
|
607
663
|
throw error;
|
|
@@ -610,6 +666,7 @@ export async function installApp(opts, deps = {}) {
|
|
|
610
666
|
rollbackSwap();
|
|
611
667
|
await detach();
|
|
612
668
|
cleanupExtract();
|
|
669
|
+
releaseLock();
|
|
613
670
|
}
|
|
614
671
|
}
|
|
615
672
|
|
|
@@ -708,7 +765,7 @@ export async function main(argv = process.argv.slice(2), io = {}) {
|
|
|
708
765
|
stderr(`right-release install-app: ${error.message}`);
|
|
709
766
|
stderr("usage: right-release install-app (--dmg <path.dmg> | --dev (--zip <path.zip> | --app <path.app> | --dmg <path.dmg>)) [--app-name Pulse.app] [--bundle-id dev.orthic.pulse] [--dest /Applications] [--relaunch|--no-relaunch] [--json]");
|
|
710
767
|
stderr(" right-release install-app --recover [--dest /Applications] [--app-name Pulse.app] [--json]");
|
|
711
|
-
stderr(" right-release install-app --installer <Setup.exe> --dest <fixed install folder> --exe <App.exe> [--json] (Windows: in place,
|
|
768
|
+
stderr(" right-release install-app --installer <Setup.exe> --dest <fixed install folder> --exe <App.exe> [--payload <file>]... [--payload-manifest <json>] [--json] (Windows: in place; payload-only rollback, never the whole folder)");
|
|
712
769
|
stderr(" --dev installs a Developer ID-signed, NOT notarized build (skips spctl/stapler, clears quarantine on the new bundle).");
|
|
713
770
|
return 2;
|
|
714
771
|
}
|