@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
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// Signing-independent approval hashes for staged prebuilt binaries (Orthic-Labs/rightkit#490).
|
|
2
|
+
//
|
|
3
|
+
// `raw-sha256` the sha256 of the file bytes (Windows and Linux binaries).
|
|
4
|
+
// `macho-codesign-strip-linkedit-v1` the sha256 of a thin little-endian 64-bit Mach-O after its code signature is removed:
|
|
5
|
+
// LC_CODE_SIGNATURE and the trailing signature blob are gone, the __LINKEDIT filesize is
|
|
6
|
+
// whatever `codesign --remove-signature` leaves, and the __LINKEDIT vmsize is normalised to
|
|
7
|
+
// the page-rounded filesize, so a signed and an unsigned copy of the same build hash alike.
|
|
8
|
+
//
|
|
9
|
+
// This is the algorithm GenRight's `local-image-approval-hash.mjs` recorded as `approval_hash_algorithm` in its runtime
|
|
10
|
+
// manifest, moved here so apps stop carrying it. Every layout surprise (a fat or big-endian file, a duplicate or non-terminal
|
|
11
|
+
// signature, a load command that changed, padding that is not zero) is a typed refusal, never a guess. The signature is removed
|
|
12
|
+
// from a private copy with `/usr/bin/codesign --remove-signature`; the input file is never modified. macOS only for Mach-O.
|
|
13
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
14
|
+
import { chmodSync, lstatSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
15
|
+
import os from "node:os";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { spawnSync } from "./background-process.mjs";
|
|
18
|
+
|
|
19
|
+
export const APPROVAL_RAW_SHA256 = "raw-sha256";
|
|
20
|
+
export const APPROVAL_MACHO_STRIP_LINKEDIT_V1 = "macho-codesign-strip-linkedit-v1";
|
|
21
|
+
export const APPROVAL_ALGORITHMS = Object.freeze([APPROVAL_RAW_SHA256, APPROVAL_MACHO_STRIP_LINKEDIT_V1]);
|
|
22
|
+
|
|
23
|
+
const LC_CODE_SIGNATURE = 0x1d;
|
|
24
|
+
const LC_SEGMENT_64 = 0x19;
|
|
25
|
+
const MH_MAGIC_64 = 0xfeedfacf;
|
|
26
|
+
const CPU_TYPE_X86_64 = 0x01000007;
|
|
27
|
+
const CPU_TYPE_ARM64 = 0x0100000c;
|
|
28
|
+
|
|
29
|
+
export class ApprovalHashError extends Error {
|
|
30
|
+
constructor(code, message) { super(message); this.name = "ApprovalHashError"; this.code = code; }
|
|
31
|
+
}
|
|
32
|
+
const fail = (message, code = "APPROVAL_HASH_INVALID_MACHO") => { throw new ApprovalHashError(code, `backend integrity: ${message}`); };
|
|
33
|
+
|
|
34
|
+
/** Parse a thin 64-bit little-endian Mach-O. Exported for tests. */
|
|
35
|
+
export function parseMachO(bytes) {
|
|
36
|
+
if (bytes.length < 32 || bytes.readUInt32LE(0) !== MH_MAGIC_64) fail("only little-endian 64-bit thin Mach-O is supported");
|
|
37
|
+
const cpuType = bytes.readUInt32LE(4);
|
|
38
|
+
if (![CPU_TYPE_X86_64, CPU_TYPE_ARM64].includes(cpuType)) fail("unsupported Mach-O CPU type");
|
|
39
|
+
const ncmds = bytes.readUInt32LE(16);
|
|
40
|
+
const sizeofcmds = bytes.readUInt32LE(20);
|
|
41
|
+
const commandEnd = 32 + sizeofcmds;
|
|
42
|
+
if (ncmds > 65_536 || commandEnd > bytes.length || sizeofcmds > bytes.length / 2) fail("invalid Mach-O load command table");
|
|
43
|
+
let cursor = 32;
|
|
44
|
+
let signature = null;
|
|
45
|
+
let linkedit = null;
|
|
46
|
+
const normalizedCommands = [];
|
|
47
|
+
for (let index = 0; index < ncmds; index += 1) {
|
|
48
|
+
if (cursor + 8 > commandEnd) fail("truncated Mach-O load command");
|
|
49
|
+
const command = bytes.readUInt32LE(cursor);
|
|
50
|
+
const commandSize = bytes.readUInt32LE(cursor + 4);
|
|
51
|
+
if (commandSize < 8 || commandSize % 4 !== 0 || cursor + commandSize > commandEnd) fail("invalid Mach-O load command size");
|
|
52
|
+
const commandBytes = Buffer.from(bytes.subarray(cursor, cursor + commandSize));
|
|
53
|
+
if (command === LC_CODE_SIGNATURE) {
|
|
54
|
+
if (commandSize !== 16 || signature) fail("invalid duplicate code signature command");
|
|
55
|
+
const offset = bytes.readUInt32LE(cursor + 8);
|
|
56
|
+
const size = bytes.readUInt32LE(cursor + 12);
|
|
57
|
+
if (!size || offset + size > bytes.length) fail("invalid code signature extent");
|
|
58
|
+
signature = { offset, size };
|
|
59
|
+
} else if (command === LC_SEGMENT_64) {
|
|
60
|
+
if (commandSize < 72) fail("truncated Mach-O segment command");
|
|
61
|
+
const sectionCount = commandBytes.readUInt32LE(64);
|
|
62
|
+
if (sectionCount > 65_536 || 72 + sectionCount * 80 > commandSize) fail("invalid Mach-O segment sections");
|
|
63
|
+
const segmentName = commandBytes.subarray(8, 24).toString("utf8").replace(/\0.*$/s, "");
|
|
64
|
+
if (segmentName === "__LINKEDIT") {
|
|
65
|
+
if (linkedit) fail("duplicate __LINKEDIT segment");
|
|
66
|
+
const vmsize = commandBytes.readBigUInt64LE(32);
|
|
67
|
+
const fileoff = commandBytes.readBigUInt64LE(40);
|
|
68
|
+
const filesize = commandBytes.readBigUInt64LE(48);
|
|
69
|
+
if (fileoff + filesize > BigInt(bytes.length) || vmsize < filesize) fail("invalid __LINKEDIT extent");
|
|
70
|
+
// vmsize and filesize legitimately differ after the signature is removed: they are normalised, never compared raw.
|
|
71
|
+
commandBytes.fill(0, 32, 40);
|
|
72
|
+
commandBytes.fill(0, 48, 56);
|
|
73
|
+
linkedit = { fileoff, filesize, vmsize, vmsizeOffset: cursor + 32 };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (command !== LC_CODE_SIGNATURE) normalizedCommands.push(commandBytes);
|
|
77
|
+
cursor += commandSize;
|
|
78
|
+
}
|
|
79
|
+
if (cursor !== commandEnd) fail("load commands do not fill declared table");
|
|
80
|
+
if (!linkedit) fail("missing __LINKEDIT segment");
|
|
81
|
+
if (signature && (BigInt(signature.offset) < linkedit.fileoff
|
|
82
|
+
|| signature.offset + signature.size !== bytes.length
|
|
83
|
+
|| linkedit.fileoff + linkedit.filesize !== BigInt(bytes.length))) {
|
|
84
|
+
fail("signature is not terminal __LINKEDIT data");
|
|
85
|
+
}
|
|
86
|
+
return { bytes, cpuType, ncmds, sizeofcmds, signature, linkedit, normalizedCommands };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Every byte outside the explicitly permitted ranges must be identical between the captured original and the stripped copy.
|
|
91
|
+
* Permitted: header ncmds/sizeofcmds (checked by the caller), the load-command table (compared command by command, with only
|
|
92
|
+
* the signature command removed and __LINKEDIT vmsize/filesize normalised), the zeroed tail the shorter table leaves, and the
|
|
93
|
+
* signature blob plus its alignment padding at the end of __LINKEDIT. Code, data and all __LINKEDIT content before the
|
|
94
|
+
* signature are compared byte for byte.
|
|
95
|
+
*/
|
|
96
|
+
function assertOnlySignatureDiffers(before, after) {
|
|
97
|
+
const a = before.bytes;
|
|
98
|
+
const b = after.bytes;
|
|
99
|
+
if (!before.signature) {
|
|
100
|
+
if (a.length !== b.length || !a.equals(b)) fail("unsigned Mach-O changed during normalization");
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const same = (from, to, label) => {
|
|
104
|
+
if (from === to) return;
|
|
105
|
+
if (!a.subarray(from, to).equals(b.subarray(from, to))) fail(`signature removal changed ${label}`);
|
|
106
|
+
};
|
|
107
|
+
const beforeTable = 32 + before.sizeofcmds;
|
|
108
|
+
const afterTable = 32 + after.sizeofcmds;
|
|
109
|
+
same(0, 16, "Mach-O header");
|
|
110
|
+
same(24, 32, "Mach-O header");
|
|
111
|
+
if (b.subarray(afterTable, beforeTable).some((byte) => byte !== 0)) fail("signature removal left nonzero bytes after the load commands");
|
|
112
|
+
const keptEnd = Number(after.linkedit.fileoff + after.linkedit.filesize);
|
|
113
|
+
if (b.length !== keptEnd) fail("stripped Mach-O does not end at __LINKEDIT");
|
|
114
|
+
same(beforeTable, keptEnd, "executable or data bytes");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The approval digest of an already-unsigned copy and its signed original: verifies that signature removal changed
|
|
119
|
+
* nothing but the signature, then hashes the unsigned bytes with the normalised __LINKEDIT vmsize. Exported for tests.
|
|
120
|
+
*/
|
|
121
|
+
export function machoApprovalDigest(beforeBytes, afterBytes) {
|
|
122
|
+
const before = parseMachO(beforeBytes);
|
|
123
|
+
const after = parseMachO(Buffer.from(afterBytes));
|
|
124
|
+
if (after.signature) fail("signature remains after normalization");
|
|
125
|
+
if (before.signature && (before.ncmds !== after.ncmds + 1 || before.sizeofcmds !== after.sizeofcmds + 16)) fail("signature removal changed Mach-O command layout");
|
|
126
|
+
if (before.signature) {
|
|
127
|
+
const beforeLinkeditEnd = before.linkedit.fileoff + before.linkedit.filesize;
|
|
128
|
+
const afterLinkeditEnd = after.linkedit.fileoff + after.linkedit.filesize;
|
|
129
|
+
const removedPadding = BigInt(before.signature.offset) - afterLinkeditEnd;
|
|
130
|
+
if (beforeLinkeditEnd !== BigInt(before.signature.offset + before.signature.size)
|
|
131
|
+
|| after.linkedit.fileoff !== before.linkedit.fileoff
|
|
132
|
+
|| afterLinkeditEnd > BigInt(before.signature.offset)
|
|
133
|
+
|| removedPadding < 0n || removedPadding > 15n
|
|
134
|
+
|| BigInt(after.bytes.length) !== afterLinkeditEnd) {
|
|
135
|
+
fail("code signature is outside trailing __LINKEDIT data");
|
|
136
|
+
}
|
|
137
|
+
const start = Number(afterLinkeditEnd);
|
|
138
|
+
const end = before.signature.offset;
|
|
139
|
+
if (before.bytes.subarray(start, end).some((byte) => byte !== 0)) fail("nonzero alignment padding before code signature");
|
|
140
|
+
}
|
|
141
|
+
assertOnlySignatureDiffers(before, after);
|
|
142
|
+
if (before.normalizedCommands.length !== after.normalizedCommands.length
|
|
143
|
+
|| before.normalizedCommands.some((command, index) => !command.equals(after.normalizedCommands[index]))) {
|
|
144
|
+
fail("signature removal changed non-signature load commands");
|
|
145
|
+
}
|
|
146
|
+
const pageSize = before.cpuType === CPU_TYPE_ARM64 ? 16n * 1024n : 4096n;
|
|
147
|
+
const roundedSize = ((after.linkedit.filesize + pageSize - 1n) / pageSize) * pageSize;
|
|
148
|
+
if (after.linkedit.vmsize < roundedSize) fail("invalid __LINKEDIT virtual size");
|
|
149
|
+
after.bytes.writeBigUInt64LE(roundedSize, after.linkedit.vmsizeOffset);
|
|
150
|
+
return createHash("sha256").update(after.bytes).digest("hex");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Default signature removal: `/usr/bin/codesign --remove-signature <copy>` (macOS only). */
|
|
154
|
+
function removeSignatureWithCodesign(copy) {
|
|
155
|
+
const result = spawnSync("/usr/bin/codesign", ["--remove-signature", copy], { stdio: "ignore", timeout: 15_000, windowsHide: true });
|
|
156
|
+
if (result.error || result.status !== 0) throw new ApprovalHashError("APPROVAL_HASH_CODESIGN_FAILED", `backend integrity: codesign normalization failed (${result.error?.message ?? result.status})`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The approval digest of `file` under `algorithm`. `removeSignature(copyPath)` is injectable for tests and for hosts that
|
|
161
|
+
* strip signatures another way; the default needs macOS. The input file is never modified.
|
|
162
|
+
*/
|
|
163
|
+
export function approvalDigest(file, algorithm, { platform = process.platform, removeSignature = removeSignatureWithCodesign, tmpdir = os.tmpdir() } = {}) {
|
|
164
|
+
if (algorithm === APPROVAL_RAW_SHA256) return createHash("sha256").update(readFileSync(file)).digest("hex");
|
|
165
|
+
if (algorithm !== APPROVAL_MACHO_STRIP_LINKEDIT_V1) throw new ApprovalHashError("APPROVAL_HASH_ALGORITHM_UNKNOWN", `unknown approval hash algorithm ${JSON.stringify(algorithm)} (supported: ${APPROVAL_ALGORITHMS.join(", ")})`);
|
|
166
|
+
if (platform !== "darwin") throw new ApprovalHashError("APPROVAL_HASH_HOST_UNSUPPORTED", `${APPROVAL_MACHO_STRIP_LINKEDIT_V1} needs macOS (codesign); this host is ${platform}`);
|
|
167
|
+
if (!lstatSync(file).isFile()) fail("expected regular Mach-O file");
|
|
168
|
+
const original = readFileSync(file);
|
|
169
|
+
const before = parseMachO(original);
|
|
170
|
+
const directory = mkdtempSync(path.join(tmpdir, `rightkit-macho-${process.pid}-${randomBytes(8).toString("hex")}-`), { mode: 0o700 });
|
|
171
|
+
const copy = path.join(directory, "binary");
|
|
172
|
+
try {
|
|
173
|
+
// The copy is written from the captured bytes, never re-read from the path, so the validated snapshot is the one stripped.
|
|
174
|
+
writeFileSync(copy, original, { mode: 0o700 });
|
|
175
|
+
chmodSync(copy, 0o700);
|
|
176
|
+
if (before.signature) removeSignature(copy);
|
|
177
|
+
return machoApprovalDigest(original, readFileSync(copy));
|
|
178
|
+
} finally {
|
|
179
|
+
rmSync(directory, { recursive: true, force: true });
|
|
180
|
+
}
|
|
181
|
+
}
|
package/managed-cargo.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto";
|
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
5
5
|
import path from "node:path";
|
|
6
|
-
import { spawnSync } from "./background-process.mjs";
|
|
6
|
+
import { spawn as spawnProcess, spawnSync } from "./background-process.mjs";
|
|
7
7
|
|
|
8
8
|
export function managedRuntimeClient(env = process.env, platform = process.platform, exists = existsSync) {
|
|
9
9
|
const roots = managedControlCandidates({ env, platform });
|
|
@@ -72,3 +72,51 @@ export function runCargoSync(args, options = {}, { platform = process.platform,
|
|
|
72
72
|
function unavailable(message) {
|
|
73
73
|
return { status: null, stdout: "", stderr: message, error: Object.assign(new Error(message), { code: 'RK_BROKER_UNAVAILABLE' }) };
|
|
74
74
|
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Async `spawnSync` look-alike for the batch publisher: runs the command through the release spawn boundary
|
|
78
|
+
* (background-process.mjs), collects stdout and stderr, and resolves `{ status, signal, stdout, stderr, error }`.
|
|
79
|
+
* It never rejects: a spawn failure, a timeout or an exceeded `maxBuffer` resolves with `error` set, which is
|
|
80
|
+
* exactly how callers already read a failed `spawnSync`.
|
|
81
|
+
*/
|
|
82
|
+
export function spawnCollect(command, args, options = {}, { spawnAsync = spawnProcess } = {}) {
|
|
83
|
+
const { timeout, maxBuffer = 512 * 1024 * 1024, encoding: _encoding, stdio: _stdio, ...rest } = options;
|
|
84
|
+
return new Promise((resolve) => {
|
|
85
|
+
let child;
|
|
86
|
+
try { child = spawnAsync(command, args, { ...rest, stdio: ["ignore", "pipe", "pipe"] }); } catch (error) { resolve({ status: null, signal: null, stdout: "", stderr: "", error }); return; }
|
|
87
|
+
const out = []; const err = [];
|
|
88
|
+
let size = 0; let settled = false; let timer; let failure;
|
|
89
|
+
const finish = (result) => { if (settled) return; settled = true; clearTimeout(timer); resolve(result); };
|
|
90
|
+
const take = (chunks) => (chunk) => {
|
|
91
|
+
size += chunk.length;
|
|
92
|
+
if (size > maxBuffer) { failure ??= new Error(`output exceeded maxBuffer (${maxBuffer} bytes)`); child.kill("SIGKILL"); return; }
|
|
93
|
+
chunks.push(chunk);
|
|
94
|
+
};
|
|
95
|
+
child.stdout.on("data", take(out));
|
|
96
|
+
child.stderr.on("data", take(err));
|
|
97
|
+
child.on("error", (error) => finish({ status: null, signal: null, stdout: "", stderr: "", error }));
|
|
98
|
+
child.on("close", (status, signal) => finish({
|
|
99
|
+
status, signal, stdout: Buffer.concat(out).toString("utf8"), stderr: Buffer.concat(err).toString("utf8"), error: failure,
|
|
100
|
+
}));
|
|
101
|
+
if (timeout) timer = setTimeout(() => { failure ??= new Error(`timed out after ${timeout}ms`); child.kill("SIGKILL"); }, timeout);
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Async twin of `runCargoSync`: the same client, broker-socket and darwin-never-direct selection (it asks
|
|
107
|
+
* `runCargoSync` to choose, with a spawn that only records the invocation), then runs the chosen invocation
|
|
108
|
+
* without blocking the event loop so independent crates can publish concurrently. The broker still
|
|
109
|
+
* serializes the builds themselves.
|
|
110
|
+
*/
|
|
111
|
+
export async function runCargoAsync(args, options = {}, deps = {}) {
|
|
112
|
+
const { spawnAsync = spawnCollect, ...syncDeps } = deps;
|
|
113
|
+
let chosen;
|
|
114
|
+
const probe = runCargoSync(args, options, { ...syncDeps, spawn: (command, commandArgs, commandOptions) => { chosen = { command, args: commandArgs, options: commandOptions }; return { status: 0, stdout: "", stderr: "" }; } });
|
|
115
|
+
if (!chosen) return probe;
|
|
116
|
+
const result = await spawnAsync(chosen.command, chosen.args, chosen.options);
|
|
117
|
+
const platform = syncDeps.platform ?? process.platform;
|
|
118
|
+
if (platform === 'darwin' && result.status !== 0 && /RK_BROKER_UNAVAILABLE|RightKit broker (?:unavailable|endpoint is not configured)/i.test(String(result.stderr ?? ''))) {
|
|
119
|
+
return { ...result, error: unavailable(String(result.stderr).trim()).error };
|
|
120
|
+
}
|
|
121
|
+
return result;
|
|
122
|
+
}
|
package/media-sidecar.mjs
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// targets.mac.mediaSidecar = {
|
|
4
4
|
// arch: "aarch64", // catalog arch (default: build host); one arch per build
|
|
5
5
|
// variant: "decode", // catalog variant (default "decode")
|
|
6
|
+
// variants: { ffmpeg: "export-ass" }, // optional per-tool variant override (a tool not named uses `variant`)
|
|
6
7
|
// tools: ["ffmpeg", "ffprobe"], // default both
|
|
7
8
|
// stageDir: "src-tauri/binaries", // Tauri externalBin directory (default)
|
|
8
9
|
// bundleDir: "Contents/MacOS", // mac only: where Tauri puts externalBin (or "Contents/Resources")
|
|
@@ -26,7 +27,7 @@ export const MEDIA_SIDECAR_TOOLS = Object.freeze(["ffmpeg", "ffprobe"]);
|
|
|
26
27
|
const PLATFORM_OS = Object.freeze({ mac: "macos", win: "windows", linux: "linux" });
|
|
27
28
|
const ARCHES = new Set(["aarch64", "x86_64"]);
|
|
28
29
|
const SHA256 = /^[0-9a-f]{64}$/;
|
|
29
|
-
const CONFIG_KEYS = new Set(["arch", "variant", "tools", "stageDir", "bundleDir", "notices", "catalog", "stage"]);
|
|
30
|
+
const CONFIG_KEYS = new Set(["arch", "variant", "variants", "tools", "stageDir", "bundleDir", "notices", "catalog", "stage"]);
|
|
30
31
|
const BUNDLE_DIRS = new Set(["Contents/MacOS", "Contents/Resources"]);
|
|
31
32
|
|
|
32
33
|
const invalid = (code, message) => Object.assign(new Error(message), { code });
|
|
@@ -87,6 +88,18 @@ export function validateMediaSidecarConfig(raw, { platform, hostArch = hostCatal
|
|
|
87
88
|
if (!Array.isArray(tools) || !tools.length || new Set(tools).size !== tools.length || tools.some((tool) => !MEDIA_SIDECAR_TOOLS.includes(tool))) {
|
|
88
89
|
errors.push(`tools must be a non-empty list without duplicates drawn from ${MEDIA_SIDECAR_TOOLS.join(", ")}`);
|
|
89
90
|
}
|
|
91
|
+
// One entry may mix variants (for example the export-ass ffmpeg with the decode ffprobe): `variants` maps a tool to its variant.
|
|
92
|
+
let variants;
|
|
93
|
+
if (raw.variants !== undefined) {
|
|
94
|
+
if (!isObject(raw.variants) || !Object.keys(raw.variants).length) errors.push("variants must be an object mapping a tool to a catalog variant slug");
|
|
95
|
+
else {
|
|
96
|
+
for (const [tool, slug] of Object.entries(raw.variants)) {
|
|
97
|
+
if (!MEDIA_SIDECAR_TOOLS.includes(tool) || (Array.isArray(tools) && !tools.includes(tool))) errors.push(`variants.${tool}: not one of the configured tools`);
|
|
98
|
+
if (typeof slug !== "string" || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) errors.push(`variants.${tool} must be a catalog variant slug: ${JSON.stringify(slug)}`);
|
|
99
|
+
}
|
|
100
|
+
variants = { ...raw.variants };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
90
103
|
const stageDir = relativePath(raw.stageDir ?? "src-tauri/binaries", "stageDir");
|
|
91
104
|
let bundleDir;
|
|
92
105
|
if (os === "macos") {
|
|
@@ -109,7 +122,7 @@ export function validateMediaSidecarConfig(raw, { platform, hostArch = hostCatal
|
|
|
109
122
|
else stage = raw.stage;
|
|
110
123
|
}
|
|
111
124
|
if (errors.length) throw invalid("MEDIA_SIDECAR_CONFIG_INVALID", `invalid mediaSidecar configuration:\n${errors.map((line) => `- ${line}`).join("\n")}`);
|
|
112
|
-
return { os, arch, variant, tools: [...tools], stageDir, ...(bundleDir ? { bundleDir } : {}), notices, ...(catalog ? { catalog } : {}), ...(stage ? { stage } : {}) };
|
|
125
|
+
return { os, arch, variant, ...(variants ? { variants } : {}), tools: [...tools], stageDir, ...(bundleDir ? { bundleDir } : {}), notices, ...(catalog ? { catalog } : {}), ...(stage ? { stage } : {}) };
|
|
113
126
|
}
|
|
114
127
|
|
|
115
128
|
/** `sign.nestedCode` entries for the macOS bundle: the sidecar executables, hardened runtime, no entitlements. */
|
|
@@ -126,9 +139,10 @@ export function mediaSidecarNestedCode(config) {
|
|
|
126
139
|
export function selectSidecarRows(catalog, config) {
|
|
127
140
|
if (!isObject(catalog) || !Array.isArray(catalog.artifacts)) throw invalid("MEDIA_SIDECAR_CATALOG_INVALID", "catalog has no artifacts array");
|
|
128
141
|
return config.tools.map((tool) => {
|
|
129
|
-
const
|
|
142
|
+
const variant = config.variants?.[tool] ?? config.variant;
|
|
143
|
+
const matches = catalog.artifacts.filter((row) => row.tool === tool && (row.variant ?? "decode") === variant
|
|
130
144
|
&& String(row.os).toLowerCase() === config.os && String(row.arch).toLowerCase() === config.arch);
|
|
131
|
-
const where = `${tool} ${
|
|
145
|
+
const where = `${tool} ${variant} ${config.os}/${config.arch}`;
|
|
132
146
|
if (matches.length === 0) throw invalid("MEDIA_SIDECAR_NO_ROW", `the rightkit-ffmpeg catalog has no ${where} row`);
|
|
133
147
|
if (matches.length > 1) throw invalid("MEDIA_SIDECAR_AMBIGUOUS_ROW", `the rightkit-ffmpeg catalog has ${matches.length} ${where} rows`);
|
|
134
148
|
const [row] = matches;
|
|
@@ -152,6 +166,7 @@ export function planMediaSidecar({ config, catalog, crateDir, crateVersion, cata
|
|
|
152
166
|
artifactId: row.id,
|
|
153
167
|
version: row.version,
|
|
154
168
|
contentSha256: row.contentSha256,
|
|
169
|
+
...(config.variants ? { variant: row.variant ?? "decode" } : {}),
|
|
155
170
|
stagedPath: path.resolve(root, config.stageDir, stagedFileName(row.tool, config.os, config.arch)),
|
|
156
171
|
bundledName: bundledFileName(row.tool, config.os),
|
|
157
172
|
...(config.bundleDir ? { bundlePath: `${config.bundleDir}/${bundledFileName(row.tool, config.os)}` } : {}),
|
|
@@ -242,6 +257,7 @@ export function buildMediaSidecarReceipt(plan, preSign, signed, { notices = [],
|
|
|
242
257
|
tool: file.tool,
|
|
243
258
|
artifactId: file.artifactId,
|
|
244
259
|
version: file.version,
|
|
260
|
+
...(file.variant ? { variant: file.variant } : {}),
|
|
245
261
|
catalogContentSha256: file.contentSha256,
|
|
246
262
|
preSignSha256: file.preSignSha256,
|
|
247
263
|
signedSha256: after.sha256,
|
package/mirror-root-artifact.mjs
CHANGED
|
@@ -1,11 +1,24 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
2
|
+
// Two forms:
|
|
3
|
+
// right-release mirror-root-artifact --file <artifact> --package-root <dir> [--name <filename>]
|
|
4
|
+
// right-release mirror-root-artifact --config <right-release.config.mjs> --platform mac|win [--package-root <dir>]
|
|
5
|
+
// The config form mirrors every entry of `targets.<platform>.mirror`, a list of `"<app-relative file>"` or
|
|
6
|
+
// `{ file, name?, pruneSuffix? }`. `pruneSuffix` (for example "-setup.exe") then removes the other files with that suffix
|
|
7
|
+
// from the managed directory, so it holds exactly the current artifact. Run it as `targets.<platform>.postSign`.
|
|
8
|
+
// Output goes only to RIGHT_RELEASE_ARTIFACT_DIR (outside the repository); it never writes into the repository root.
|
|
9
|
+
import { copyFileSync, lstatSync, mkdirSync, readdirSync, realpathSync, renameSync, rmSync, statSync } from "node:fs";
|
|
3
10
|
import { execFileSync } from "./background-process.mjs";
|
|
4
11
|
import path from "node:path";
|
|
12
|
+
import { pathToFileURL } from "node:url";
|
|
5
13
|
|
|
6
14
|
const args = parseArgs(process.argv.slice(2));
|
|
7
|
-
|
|
8
|
-
|
|
15
|
+
const usage = "Usage: right-release mirror-root-artifact (--file <artifact> --package-root <dir> [--name <filename>] | --config <file> --platform mac|win [--package-root <dir>])";
|
|
16
|
+
if (!args.config && (!args.file || !args.packageRoot)) {
|
|
17
|
+
console.error(usage);
|
|
18
|
+
process.exit(2);
|
|
19
|
+
}
|
|
20
|
+
if (args.config && (!args.platform || args.file)) {
|
|
21
|
+
console.error(usage);
|
|
9
22
|
process.exit(2);
|
|
10
23
|
}
|
|
11
24
|
if (!process.env.RIGHT_RELEASE_ARTIFACT_DIR) {
|
|
@@ -13,33 +26,68 @@ if (!process.env.RIGHT_RELEASE_ARTIFACT_DIR) {
|
|
|
13
26
|
process.exit(2);
|
|
14
27
|
}
|
|
15
28
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
29
|
+
let entries;
|
|
30
|
+
let rootArg = args.packageRoot;
|
|
31
|
+
if (args.config) {
|
|
32
|
+
const configPath = path.resolve(args.config);
|
|
33
|
+
const loaded = await import(pathToFileURL(configPath).href);
|
|
34
|
+
const config = loaded.default ?? loaded.config ?? loaded;
|
|
35
|
+
const declared = config?.targets?.[args.platform]?.mirror;
|
|
36
|
+
if (!Array.isArray(declared) || declared.length === 0) {
|
|
37
|
+
console.error(`right-release mirror-root-artifact: targets.${args.platform}.mirror declares no files in ${args.config}`);
|
|
38
|
+
process.exit(2);
|
|
39
|
+
}
|
|
40
|
+
entries = declared.map((item) => (typeof item === "string" ? { file: item } : item));
|
|
41
|
+
for (const entry of entries) {
|
|
42
|
+
const unknown = entry && typeof entry === "object" ? Object.keys(entry).filter((key) => !["file", "name", "pruneSuffix"].includes(key)) : ["(not an object)"];
|
|
43
|
+
if (typeof entry?.file !== "string" || !entry.file.trim() || unknown.length || path.isAbsolute(entry.file) || entry.file.replaceAll("\\", "/").split("/").includes("..")) {
|
|
44
|
+
console.error(`right-release mirror-root-artifact: each mirror entry is an app-relative file or { file, name?, pruneSuffix? }: ${JSON.stringify(entry)}`);
|
|
45
|
+
process.exit(2);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
rootArg ??= path.dirname(configPath);
|
|
49
|
+
} else {
|
|
50
|
+
entries = [{ file: args.file, name: args.name }];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const packageRoot = realpathSync(path.resolve(rootArg));
|
|
19
54
|
const managedOutput = path.resolve(process.env.RIGHT_RELEASE_ARTIFACT_DIR);
|
|
20
55
|
const repoRoot = path.resolve(git(packageRoot, "rev-parse", "--show-toplevel"));
|
|
21
56
|
if (inside(managedOutput, repoRoot)) {
|
|
22
57
|
console.error(`right-release mirror-root-artifact: managed output must be outside repository: ${managedOutput}`);
|
|
23
58
|
process.exit(2);
|
|
24
59
|
}
|
|
25
|
-
|
|
26
|
-
console.error(`right-release mirror-root-artifact: unsafe artifact name: ${name}`);
|
|
27
|
-
process.exit(2);
|
|
28
|
-
}
|
|
29
|
-
assertSafePath(source);
|
|
30
|
-
if (!statSync(source).isFile()) {
|
|
31
|
-
console.error(`right-release mirror-root-artifact: source is not a regular file: ${source}`);
|
|
32
|
-
process.exit(2);
|
|
33
|
-
}
|
|
34
|
-
const destinations = new Set([path.join(managedOutput, name)]);
|
|
60
|
+
const safeName = (name) => name === path.basename(name) && /^[A-Za-z0-9][A-Za-z0-9._+-]{0,159}$/.test(name);
|
|
35
61
|
|
|
36
|
-
for (const
|
|
37
|
-
|
|
38
|
-
|
|
62
|
+
for (const entry of entries) {
|
|
63
|
+
const source = path.resolve(packageRoot, entry.file);
|
|
64
|
+
const name = entry.name || path.basename(source);
|
|
65
|
+
if (!safeName(name)) {
|
|
66
|
+
console.error(`right-release mirror-root-artifact: unsafe artifact name: ${name}`);
|
|
67
|
+
process.exit(2);
|
|
68
|
+
}
|
|
69
|
+
if (entry.pruneSuffix !== undefined && (typeof entry.pruneSuffix !== "string" || !/^[A-Za-z0-9._+-]{1,40}$/.test(entry.pruneSuffix) || !name.endsWith(entry.pruneSuffix))) {
|
|
70
|
+
console.error(`right-release mirror-root-artifact: pruneSuffix must be a plain suffix of the artifact name: ${entry.pruneSuffix}`);
|
|
71
|
+
process.exit(2);
|
|
72
|
+
}
|
|
73
|
+
assertSafePath(source);
|
|
74
|
+
if (!statSync(source).isFile()) {
|
|
75
|
+
console.error(`right-release mirror-root-artifact: source is not a regular file: ${source}`);
|
|
76
|
+
process.exit(2);
|
|
77
|
+
}
|
|
78
|
+
const destination = path.join(managedOutput, name);
|
|
79
|
+
if (path.resolve(destination) !== source) atomicCopy(source, destination);
|
|
80
|
+
if (entry.pruneSuffix) {
|
|
81
|
+
for (const stale of readdirSync(managedOutput)) {
|
|
82
|
+
if (stale !== name && stale.endsWith(entry.pruneSuffix) && safeName(stale)) {
|
|
83
|
+
assertSafePath(path.join(managedOutput, stale));
|
|
84
|
+
rmSync(path.join(managedOutput, stale), { force: true });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
console.log(`right-release: managed artifact -> ${destination}`);
|
|
39
89
|
}
|
|
40
90
|
|
|
41
|
-
console.log(`right-release: managed artifact -> ${[...destinations][0]}`);
|
|
42
|
-
|
|
43
91
|
function git(cwd, ...commandArgs) {
|
|
44
92
|
return execFileSync("git", commandArgs, { cwd, encoding: "utf8" }).trim();
|
|
45
93
|
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// `right-release license-notices npm [--config <file>] [--output <file>] [--product-name <name>] [--check]`: writes the
|
|
3
|
+
// third-party npm license appendix (one HTML file) for an app's production dependency closure, from
|
|
4
|
+
// `pnpm licenses list --prod --json`, the installed packages' own LICENSE / COPYING / NOTICE files, and the lockfile hash.
|
|
5
|
+
//
|
|
6
|
+
// legal: { npmNotices: { output: "legal/THIRD_PARTY_NPM_LICENSES.html", productName: "ViewRight", exclude: ["@rightkit/"] } }
|
|
7
|
+
//
|
|
8
|
+
// `output` and `productName` may also come from `--output` / `--product-name`. `exclude` is a list of package-name prefixes left
|
|
9
|
+
// out (default `["@rightkit/"]`). A package that ships no license file gets the standard published text for its declared SPDX
|
|
10
|
+
// license (MIT or ISC), labelled as a fallback; any other license without a file is an error, never invented text.
|
|
11
|
+
// The page records the app version, the sha256 of `pnpm-lock.yaml` and `generated-by=right-release license-notices npm`, so the
|
|
12
|
+
// same inputs always produce the same bytes. `--check` writes nothing and exits 1 when the file on disk differs.
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { pathToFileURL } from "node:url";
|
|
17
|
+
import { spawnSync } from "./background-process.mjs";
|
|
18
|
+
import { CommandSpecError, loadAppConfig, relativeInside } from "./command-spec.mjs";
|
|
19
|
+
|
|
20
|
+
const fail = (message) => { throw new CommandSpecError(message); };
|
|
21
|
+
|
|
22
|
+
// Generic published SPDX text, used ONLY when an installed package ships no license file of its own.
|
|
23
|
+
export const SPDX_FALLBACK_TEXT = {
|
|
24
|
+
MIT: `Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
25
|
+
|
|
26
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
27
|
+
|
|
28
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.`,
|
|
29
|
+
ISC: `Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.
|
|
30
|
+
|
|
31
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.`,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const escapeHtml = (value) => String(value)
|
|
35
|
+
.replaceAll("&", "&")
|
|
36
|
+
.replaceAll("<", "<")
|
|
37
|
+
.replaceAll(">", ">")
|
|
38
|
+
.replaceAll('"', """);
|
|
39
|
+
|
|
40
|
+
/** Flatten `pnpm licenses list --json` (license -> entries) into a sorted list, minus excluded name prefixes. */
|
|
41
|
+
export function collectNpmPackages(groups, exclude = ["@rightkit/"]) {
|
|
42
|
+
return Object.entries(groups)
|
|
43
|
+
.flatMap(([license, entries]) => entries.map((entry) => ({ ...entry, license })))
|
|
44
|
+
.filter((entry) => !exclude.some((prefix) => entry.name.startsWith(prefix)))
|
|
45
|
+
.sort((a, b) => `${a.name}@${a.versions.join(",")}`.localeCompare(`${b.name}@${b.versions.join(",")}`));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Render the appendix. `readNames(dir)` lists a package directory and `readText(file)` reads one file (both injectable).
|
|
50
|
+
* Returns `{ html, packageCount, fallbackCount }`.
|
|
51
|
+
*/
|
|
52
|
+
export function renderNpmNotices({ productName, version, lockHash, packages, readNames = (dir) => readdirSync(dir), readText = (file) => readFileSync(file, "utf8") }) {
|
|
53
|
+
let fallbackCount = 0;
|
|
54
|
+
const sections = packages.map((entry) => {
|
|
55
|
+
const packageRoot = entry.paths[0];
|
|
56
|
+
const files = readNames(packageRoot).filter((name) => /^(?:license|copying|notice)(?:[._-]|$)/i.test(name)).sort();
|
|
57
|
+
let texts;
|
|
58
|
+
if (files.length) {
|
|
59
|
+
texts = files.map((name) => `<h3>${escapeHtml(name)}</h3><pre>${escapeHtml(readText(path.join(packageRoot, name)))}</pre>`).join("\n");
|
|
60
|
+
} else {
|
|
61
|
+
const fallback = SPDX_FALLBACK_TEXT[entry.license];
|
|
62
|
+
if (!fallback) {
|
|
63
|
+
fail(`${entry.name} ${entry.versions.join(", ")} has no packaged license/notice file and no SPDX fallback text for declared license "${entry.license}"; bundle the file or extend SPDX_FALLBACK_TEXT`);
|
|
64
|
+
}
|
|
65
|
+
fallbackCount += 1;
|
|
66
|
+
texts = `<h3>No packaged license file found</h3><p><em>This package's installed npm distribution does not include a LICENSE/COPYING/NOTICE file. The text below is the standard published license text for its declared SPDX license (${escapeHtml(entry.license)}), not package-specific content.</em></p><pre>${escapeHtml(fallback)}</pre>`;
|
|
67
|
+
}
|
|
68
|
+
return `<section><h2>${escapeHtml(entry.name)} ${escapeHtml(entry.versions.join(", "))}</h2><p>Declared license: ${escapeHtml(entry.license)}</p>${texts}</section>`;
|
|
69
|
+
});
|
|
70
|
+
const html = `<!doctype html>
|
|
71
|
+
<html lang="en"><head><meta charset="utf-8"><title>${escapeHtml(productName)} npm third-party licenses</title></head><body>
|
|
72
|
+
<h1>${escapeHtml(productName)} npm third-party licenses</h1>
|
|
73
|
+
<p>Build provenance: ${escapeHtml(productName)} ${escapeHtml(version)}; inputs: pnpm-lock.yaml sha256=${lockHash}; generated-by=right-release license-notices npm</p>
|
|
74
|
+
${sections.join("\n")}
|
|
75
|
+
</body></html>\n`;
|
|
76
|
+
return { html, packageCount: packages.length, fallbackCount };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Resolve options from config + flags. */
|
|
80
|
+
export function resolveNoticeOptions(config, flags = {}) {
|
|
81
|
+
const declared = config?.legal?.npmNotices ?? {};
|
|
82
|
+
if (typeof declared !== "object" || declared === null || Array.isArray(declared)) fail("legal.npmNotices must be an object");
|
|
83
|
+
const unknown = Object.keys(declared).filter((key) => !["output", "productName", "exclude"].includes(key));
|
|
84
|
+
if (unknown.length) fail(`legal.npmNotices has unknown key(s): ${unknown.join(", ")} (allowed: output, productName, exclude)`);
|
|
85
|
+
const output = flags.output ?? declared.output;
|
|
86
|
+
const productName = flags.productName ?? declared.productName;
|
|
87
|
+
const exclude = declared.exclude ?? ["@rightkit/"];
|
|
88
|
+
if (output === undefined) fail("no output file: set legal.npmNotices.output or pass --output");
|
|
89
|
+
relativeInside(output, "legal.npmNotices.output");
|
|
90
|
+
if (typeof productName !== "string" || !productName.trim()) fail("no product name: set legal.npmNotices.productName or pass --product-name");
|
|
91
|
+
if (!Array.isArray(exclude) || exclude.some((prefix) => typeof prefix !== "string")) fail("legal.npmNotices.exclude must be a list of name prefixes");
|
|
92
|
+
if ((config?.packageManager ?? "pnpm") !== "pnpm") fail(`license-notices npm supports pnpm apps only (config.packageManager is ${config.packageManager})`);
|
|
93
|
+
return { output, productName, exclude };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Generate (or check) the appendix for the app at `appRoot`. `spawnSyncFn` is injectable. */
|
|
97
|
+
export function generateNpmNotices({ appRoot, config, flags = {}, check = false, spawnSyncFn = spawnSync }) {
|
|
98
|
+
const { output, productName, exclude } = resolveNoticeOptions(config, flags);
|
|
99
|
+
const version = JSON.parse(readFileSync(path.join(appRoot, "package.json"), "utf8")).version;
|
|
100
|
+
const result = spawnSyncFn("pnpm", ["licenses", "list", "--prod", "--json"], {
|
|
101
|
+
cwd: appRoot,
|
|
102
|
+
encoding: "utf8",
|
|
103
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
104
|
+
shell: process.platform === "win32",
|
|
105
|
+
});
|
|
106
|
+
if (result.error) throw result.error;
|
|
107
|
+
if (result.status !== 0) fail(result.stderr || "pnpm licenses list failed");
|
|
108
|
+
const packages = collectNpmPackages(JSON.parse(result.stdout), exclude);
|
|
109
|
+
const lockHash = createHash("sha256").update(readFileSync(path.join(appRoot, "pnpm-lock.yaml"))).digest("hex");
|
|
110
|
+
const { html, packageCount, fallbackCount } = renderNpmNotices({ productName, version, lockHash, packages });
|
|
111
|
+
const target = path.join(appRoot, output);
|
|
112
|
+
if (check) {
|
|
113
|
+
const current = existsSync(target) ? readFileSync(target, "utf8") : null;
|
|
114
|
+
return { target, packageCount, fallbackCount, upToDate: current === html };
|
|
115
|
+
}
|
|
116
|
+
mkdirSync(path.dirname(target), { recursive: true });
|
|
117
|
+
writeFileSync(target, html);
|
|
118
|
+
return { target, packageCount, fallbackCount, upToDate: true };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function parseNoticeArgs(argv) {
|
|
122
|
+
const opts = { config: "right-release.config.mjs", output: undefined, productName: undefined, check: false };
|
|
123
|
+
const rest = argv[0] === "npm" ? argv.slice(1) : argv;
|
|
124
|
+
if (argv[0] !== "npm") throw new Error("expected: license-notices npm");
|
|
125
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
126
|
+
const arg = rest[i];
|
|
127
|
+
if (arg === "--config" || arg === "--output" || arg === "--product-name") {
|
|
128
|
+
const value = rest[i + 1];
|
|
129
|
+
if (!value || value.startsWith("--")) throw new Error(`${arg} needs a value`);
|
|
130
|
+
opts[arg === "--product-name" ? "productName" : arg.slice(2)] = value;
|
|
131
|
+
i += 1;
|
|
132
|
+
} else if (arg === "--check") opts.check = true;
|
|
133
|
+
else throw new Error(`unknown license-notices argument: ${arg}`);
|
|
134
|
+
}
|
|
135
|
+
return opts;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function main(argv = process.argv.slice(2)) {
|
|
139
|
+
const opts = parseNoticeArgs(argv);
|
|
140
|
+
const { config, appRoot } = await loadAppConfig(opts.config);
|
|
141
|
+
const outcome = generateNpmNotices({ appRoot, config, flags: { output: opts.output, productName: opts.productName }, check: opts.check });
|
|
142
|
+
if (opts.check) {
|
|
143
|
+
if (!outcome.upToDate) {
|
|
144
|
+
console.error(`right-release license-notices: ${outcome.target} is out of date; run right-release license-notices npm`);
|
|
145
|
+
return 1;
|
|
146
|
+
}
|
|
147
|
+
console.log(`${outcome.target} is up to date (${outcome.packageCount} package notices)`);
|
|
148
|
+
return 0;
|
|
149
|
+
}
|
|
150
|
+
console.log(`wrote ${outcome.packageCount} package notices (${outcome.fallbackCount} used an SPDX fallback text)`);
|
|
151
|
+
return 0;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
|
155
|
+
main().then((code) => { process.exitCode = code; }, (error) => {
|
|
156
|
+
console.error(`right-release license-notices: ${error.code ? `[${error.code}] ` : ""}${error.message}`);
|
|
157
|
+
process.exitCode = 1;
|
|
158
|
+
});
|
|
159
|
+
}
|