@rightkit/release 0.4.0 → 0.4.1

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.
@@ -8,7 +8,7 @@ import fs from "node:fs";
8
8
  import os from "node:os";
9
9
  import path from "node:path";
10
10
  import { spawnSync } from "./background-process.mjs";
11
- import { recordPublished } from "./rightkit-sync.mjs";
11
+ import { recordPublished, workspaceFeedOptions } from "./rightkit-sync.mjs";
12
12
  import { fileURLToPath } from "node:url";
13
13
  import { digestBytes, readVerifiedArtifact } from "./publication-artifacts.mjs";
14
14
 
@@ -93,6 +93,27 @@ export function batchReceiptProblems(batch, evidence, tree) {
93
93
  return problems;
94
94
  }
95
95
 
96
+ /**
97
+ * Feature sets of this crate that a batch receipt did NOT lint (0.4.1 review A2). Batch mode lints each crate's tested
98
+ * feature sets in shared `-p a -p b` runs, so a receipt from before every set was linted (0.4.0: first set only)
99
+ * still satisfies `batchReceiptProblems`, which accepts any ONE clippy row covering the crate. Replay therefore
100
+ * needs one passing row per feature set: bound to the evidence commit and the batch tree, naming the crate (`-p <crate>`
101
+ * and `crates`), and ending `--no-deps --all-targets <features> -- -D warnings`. `featureSets` is the crate's
102
+ * `ci/gate.config.json` list (`[[]]` when unset). Returns the missing sets as argument arrays.
103
+ */
104
+ export function batchClippyMissingFeatureSets(batch, evidence, featureSets) {
105
+ const rows = (Array.isArray(batch?.checks) ? batch.checks : []).filter((row) => row?.name === "clippy" && row.ok === true
106
+ && row.commit === evidence.commit && row.tree === batch.tree && Array.isArray(row.crates) && row.crates.includes(evidence.name) && text(row.command));
107
+ const sets = (Array.isArray(featureSets) && featureSets.length ? featureSets : [[]]).map((set) => (Array.isArray(set) ? set : []));
108
+ return sets.filter((features) => {
109
+ const tail = ` --no-deps --all-targets${features.length ? ` ${features.join(" ")}` : ""} -- -D warnings`;
110
+ return !rows.some((row) => {
111
+ const words = row.command.split(" ");
112
+ return row.command.endsWith(tail) && words.some((word, index) => word === "-p" && words[index + 1] === evidence.name);
113
+ });
114
+ });
115
+ }
116
+
96
117
  /**
97
118
  * Which per-crate publication steps the evidence ALREADY proves for this exact commit, so the publisher may
98
119
  * skip repeating them: `tests` (a passing `cargo test -p <name>` step that executed tests), `clippy` (a passing
@@ -137,40 +158,44 @@ function invalidRunFields(run, evidence, tree) {
137
158
  return bad;
138
159
  }
139
160
 
140
- // Returns { ok, missing[] }. Pure apart from the injected git/readFile.
141
- export function checkPublicationGate({ kind, name, version, packagePath, evidencePath, artifactBytes, git = defaultGit, readFile = (p) => fs.readFileSync(p, "utf8") }) {
161
+ function gateResult(missing, evidence) {
162
+ const result = { ok: missing.length === 0, missing };
163
+ Object.defineProperty(result, "evidence", { value: evidence });
164
+ return Object.freeze(result);
165
+ }
166
+
167
+ /** Validate supplied evidence against caller-authenticated source identity, without reading Git or files.
168
+ * The caller owns authentication of expected.commit/tree and requiredHosts (for example, committed CI policy).
169
+ * Evidence cannot relax host requirements: only an explicit, nonempty subset supplied by the caller can.
170
+ * This proves receipt contents only; it makes no claim about working-tree cleanliness or receipt provenance. */
171
+ export function validatePublicationEvidence({ expected = {}, evidence, artifactBytes }) {
172
+ const { kind, name, version, commit, tree } = expected;
142
173
  const missing = [];
143
- const head = git(["rev-parse", "HEAD"], packagePath);
144
- if (!head || !SHA.test(head)) missing.push(`cannot read current commit SHA for ${packagePath}`);
145
- const dirty = git(["status", "--porcelain", "--", "."], packagePath);
146
- if (dirty === null) missing.push(`cannot read git status for ${packagePath}`);
147
- else if (dirty !== "") missing.push(`tree is not clean for ${packagePath}`);
148
- let evidence = null;
149
- try { evidence = JSON.parse(readFile(evidencePath)); } catch {
150
- missing.push(`gate evidence file missing or unreadable: ${evidencePath}`);
174
+ if (!["npm", "cargo", "swift"].includes(kind)) missing.push("expected kind must be npm, cargo or swift");
175
+ if (!text(name)) missing.push("expected name must be nonempty");
176
+ if (!SHA.test(String(commit ?? ""))) missing.push("expected commit is not a full 40-character SHA");
177
+ const defaults = requiredHosts(kind);
178
+ let hosts = defaults;
179
+ if (expected.requiredHosts !== undefined) {
180
+ const requested = expected.requiredHosts;
181
+ if (!(Array.isArray(requested) && requested.length > 0 && new Set(requested).size === requested.length
182
+ && Array.from(requested).every((host) => defaults.includes(host)))) {
183
+ missing.push(`expected requiredHosts must be a nonempty unique subset of ${defaults.join(", ")}`);
184
+ } else hosts = requested;
151
185
  }
152
- if (evidence) {
186
+ if (!evidence || typeof evidence !== "object" || Array.isArray(evidence)) {
187
+ missing.push("gate evidence must be an object");
188
+ } else {
153
189
  if (evidence.schema !== GATE_SCHEMA) missing.push(`evidence schema must be ${GATE_SCHEMA}`);
154
190
  if (evidence.kind !== kind || evidence.name !== name) missing.push(`evidence is for ${evidence.kind}:${evidence.name}, expected ${kind}:${name}`);
155
191
  // A missing publishing version must refuse: a skipped comparison would qualify any evidence version.
156
192
  if (typeof version !== "string" || version.trim() === "") missing.push("publishing version is unknown (manifest has no literal version), cannot bind the receipt to it");
157
193
  else if (evidence.version !== version) missing.push(`evidence version ${evidence.version} != ${version}`);
158
194
  if (!SHA.test(String(evidence.commit ?? ""))) missing.push("evidence commit is not a full 40-character SHA");
159
- else if (head && evidence.commit !== head) {
160
- missing.push(`evidence commit ${evidence.commit} != current HEAD ${head} (${describeCommitMismatch(evidence.commit, head, packagePath, git)})`);
161
- }
195
+ else if (SHA.test(String(commit ?? "")) && evidence.commit !== commit) missing.push(`evidence commit ${evidence.commit} != expected commit ${commit}`);
162
196
  const runs = Array.isArray(evidence.runs) ? evidence.runs : [];
163
- // A batch receipt is bound to the HEAD tree as well as the commit; read it only when a run carries one.
164
- // `tree ?? null` below makes an unreadable tree fail the comparison (null never equals a 40-hex tree).
165
- const tree = runs.some((run) => run?.batch !== undefined) ? git(["rev-parse", "HEAD^{tree}"], packagePath) : undefined;
166
- if (tree === null) missing.push(`cannot read the HEAD tree for ${packagePath}`);
167
- // npm packages and crates may ship on macOS evidence first when Adrian decides so; the decision must be
168
- // recorded in the evidence (hostPolicy "darwin-only" + a non-empty hostPolicyReason).
169
- // Crates may ship Mac-first too (Adrian 2026-10-07): the Windows run follows; the reason is recorded.
170
- // A crate published from the Windows host under the local-build grant records the symmetric
171
- // hostPolicy "win32-only" + reason, and then the one required receipt is the Windows run.
172
- const singleHost = (kind === "npm" || kind === "cargo") ? singleHostOfPolicy(evidence) : null;
173
- for (const host of singleHost ? [singleHost] : requiredHosts(kind)) {
197
+ if (runs.some((run) => run?.batch !== undefined) && !SHA.test(String(tree ?? ""))) missing.push("expected tree is not a full 40-character SHA for batch evidence");
198
+ for (const host of hosts) {
174
199
  const label = host === "darwin" ? "macOS" : "Windows";
175
200
  const matching = runs.filter((run) => run?.host?.os === host);
176
201
  if (!matching.length) { missing.push(`no ${label} host run receipt`); continue; }
@@ -183,9 +208,34 @@ export function checkPublicationGate({ kind, name, version, packagePath, evidenc
183
208
  if (!/^[0-9a-f]{64}$/.test(String(evidence.artifactSha256 ?? ""))) missing.push("evidence artifactSha256 must be a 64-hex hash");
184
209
  }
185
210
  if (artifactBytes !== undefined && evidence && digestBytes(artifactBytes) !== evidence.artifactSha256) missing.push("artifact sha256 does not match evidence artifactSha256");
186
- const result = { ok: missing.length === 0, missing };
187
- Object.defineProperty(result, "evidence", { value: evidence });
188
- return Object.freeze(result);
211
+ return gateResult(missing, evidence);
212
+ }
213
+
214
+ // Acquire local source identity and cleanliness, then use the same content validator as CI.
215
+ export function checkPublicationGate({ kind, name, version, packagePath, evidencePath, artifactBytes, git = defaultGit, readFile = (p) => fs.readFileSync(p, "utf8") }) {
216
+ const missing = [];
217
+ const head = git(["rev-parse", "HEAD"], packagePath);
218
+ if (!head || !SHA.test(head)) missing.push(`cannot read current commit SHA for ${packagePath}`);
219
+ const dirty = git(["status", "--porcelain", "--", "."], packagePath);
220
+ if (dirty === null) missing.push(`cannot read git status for ${packagePath}`);
221
+ else if (dirty !== "") missing.push(`tree is not clean for ${packagePath}`);
222
+ let evidence = null;
223
+ try { evidence = JSON.parse(readFile(evidencePath)); } catch {
224
+ missing.push(`gate evidence file missing or unreadable: ${evidencePath}`);
225
+ }
226
+ const runs = Array.isArray(evidence?.runs) ? evidence.runs : [];
227
+ const tree = runs.some((run) => run?.batch !== undefined) ? git(["rev-parse", "HEAD^{tree}"], packagePath) : undefined;
228
+ if (tree === null) missing.push(`cannot read the HEAD tree for ${packagePath}`);
229
+ // Local publication retains its recorded single-host grant. CI callers own their policy separately.
230
+ const singleHost = (kind === "npm" || kind === "cargo") ? singleHostOfPolicy(evidence) : null;
231
+ const result = validatePublicationEvidence({ expected: { kind, name, version, commit: head, tree,
232
+ ...(singleHost ? { requiredHosts: [singleHost] } : {}) }, evidence, artifactBytes });
233
+ for (const problem of result.missing) {
234
+ if (problem === `evidence commit ${evidence?.commit} != expected commit ${head}`) {
235
+ missing.push(`evidence commit ${evidence.commit} != current HEAD ${head} (${describeCommitMismatch(evidence.commit, head, packagePath, git)})`);
236
+ } else missing.push(problem);
237
+ }
238
+ return gateResult(missing, evidence);
189
239
  }
190
240
 
191
241
  // Enforce: throws on missing evidence unless dryRun (then reports and continues).
@@ -253,10 +303,10 @@ export function runNpmPreflight(argv, workspaceRoot = process.cwd(), deps = {})
253
303
  if (dryRun) throw new Error("--publish and --dry-run are mutually exclusive");
254
304
  if (!tarball) throw new Error("--publish requires --tarball <gated .tgz>");
255
305
  enforcePublicationGate(options);
256
- publishGatedTarball({ name: pkg, version: manifest.version, tarball, evidencePath }, deps);
306
+ publishGatedTarball({ name: pkg, version: manifest.version, tarball, evidencePath, workspaceRoot }, deps);
257
307
  }
258
308
 
259
- export function publishGatedTarball({ name, version, tarball, evidencePath }, deps = {}) {
309
+ export function publishGatedTarball({ name, version, tarball, evidencePath, workspaceRoot }, deps = {}) {
260
310
  const run = deps.spawnSync ?? spawnSync;
261
311
  const bytes = fs.readFileSync(tarball);
262
312
  const evidence = JSON.parse(fs.readFileSync(evidencePath, "utf8"));
@@ -278,7 +328,8 @@ export function publishGatedTarball({ name, version, tarball, evidencePath }, de
278
328
  }
279
329
  const sha1 = crypto.createHash("sha1").update(bytes).digest("hex");
280
330
  verifyNpmRegistryBytes({ name, version, sha1 }, deps);
281
- (deps.recordPublished ?? recordPublished)({ registry: "npm", name, version });
331
+ // Feed and consumer copies belong to the requested workspace, never to this module's own (possibly installed) location.
332
+ (deps.recordPublished ?? recordPublished)({ registry: "npm", name, version }, workspaceRoot ? workspaceFeedOptions(workspaceRoot) : undefined);
282
333
  }
283
334
 
284
335
  // CI and the authenticated helper share mandatory, fail-closed registry equality.
package/publish-cargo.mjs CHANGED
@@ -2,12 +2,12 @@
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
- import { enforcePublicationGate, defaultEvidencePath, provenGateSteps, singleHostOfPolicy } from "./publication-gate.mjs";
5
+ import { enforcePublicationGate, defaultEvidencePath, provenGateSteps, singleHostOfPolicy, batchClippyMissingFeatureSets } from "./publication-gate.mjs";
6
6
  import { runCargoSync } from "./managed-cargo.mjs";
7
7
  import { spawnSync } from "./background-process.mjs";
8
8
  import { readVerifiedArtifact, assertArtifactUnchanged } from "./publication-artifacts.mjs";
9
9
  import { verifyCargoRegistry } from "./cargo-registry-readback.mjs";
10
- import { recordPublished } from "./rightkit-sync.mjs";
10
+ import { recordPublished, workspaceFeedOptions } from "./rightkit-sync.mjs";
11
11
  import { readCargoPackage } from "./crate-graph.mjs";
12
12
  import { runtimeBudgetCheck, RUNTIME_BUDGET_CODE } from "./runtime-budgets.mjs";
13
13
  import { formatPreflight, PREFLIGHT_FAIL } from "./preflight.mjs";
@@ -147,7 +147,6 @@ export function scanCrateForSecrets(crateDir) {
147
147
  continue;
148
148
  }
149
149
  const bytes = fs.readFileSync(file);
150
- if (bytes.includes(0)) continue;
151
150
  const text = bytes.toString("utf8");
152
151
  if (SECRET_PATTERNS.some((pattern) => pattern.test(text))) violations.push(relative);
153
152
  }
@@ -176,8 +175,9 @@ export async function runCargoPublish(options, execute = (_command, args, settin
176
175
  const proven = (reuseEvidence || options.trustEvidenceSteps === true) && !options.dryRun ? provenGateSteps(gate.evidence) : {};
177
176
  if (reuseEvidence && (proven.tests !== true || proven.clippy !== true)) throw new Error("--from-evidence requires gate-proven tests and clippy for this crate and commit");
178
177
  if (reuseEvidence) {
179
- const status = (deps.workspaceStatus ?? ((workspaceRoot) => spawnSync("git", ["status", "--porcelain", "--", "Cargo.toml", "Cargo.lock"], { cwd: workspaceRoot, encoding: "utf8", windowsHide: true })))(options.workspaceRoot);
180
- if (status.error || status.status !== 0 || String(status.stdout ?? "").trim()) throw new Error("--from-evidence requires clean workspace Cargo.toml and Cargo.lock");
178
+ const status = (deps.workspaceStatus ?? ((workspaceRoot) => spawnSync("git", ["status", "--porcelain", "--", "Cargo.toml", "Cargo.lock", "ci/gate.config.json", "ci/asset-gated.json"], { cwd: workspaceRoot, encoding: "utf8", windowsHide: true })))(options.workspaceRoot);
179
+ // The gate config decides the steps replayed below, so an uncommitted edit would change what is demanded (0.4.1 review A3).
180
+ if (status.error || status.status !== 0 || String(status.stdout ?? "").trim()) throw new Error("--from-evidence requires clean workspace Cargo.toml, Cargo.lock and ci gate config (ci/gate.config.json, ci/asset-gated.json)");
181
181
  const proofHost = singleHostOfPolicy(gate.evidence) ?? "darwin";
182
182
  const runs = gate.evidence.runs.filter((run) => run?.host?.os === proofHost);
183
183
  const command = `cargo package --locked -p ${options.crate}`;
@@ -201,6 +201,11 @@ export async function runCargoPublish(options, execute = (_command, args, settin
201
201
  throw new Error(`--from-evidence missing configured qualification step: ${text}`);
202
202
  }
203
203
  }
204
+ // A batch run's clippy lives in its batch receipt, which `expected` leaves out: demand one row per feature set (0.4.1 review A2).
205
+ if (run.batch) {
206
+ const missing = batchClippyMissingFeatureSets(run.batch, gate.evidence, config?.rust?.[options.crate]?.featureSets ?? [[]]);
207
+ if (missing.length) throw new Error(`--from-evidence missing batch clippy for feature set${missing.length === 1 ? "" : "s"}: ${missing.map((features) => features.join(" ") || "default").join("; ")}`);
208
+ }
204
209
  }
205
210
  }
206
211
  const plan = buildCargoPublishPlan({ ...options, crateDir, proven,
@@ -316,7 +321,7 @@ async function main() {
316
321
  const result = await runCargoPublish(options);
317
322
  process.stdout.write(`[right-release] ${options.dryRun ? "validated" : "published"} ${options.crate}\n`);
318
323
  if (!options.dryRun) {
319
- recordPublished({ registry: "cargo", name: result.name, version: result.version });
324
+ recordPublished({ registry: "cargo", name: result.name, version: result.version }, workspaceFeedOptions(options.workspaceRoot));
320
325
  }
321
326
  }
322
327
 
package/publish-swift.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  // Publish RightKitSwift by the ONE Swift distribution contract: a plain semver git tag on this
2
- // repository (github.com/Orthic-Labs/rightkit), resolved by apps through the root Package.swift
2
+ // repository (the checkout's own origin, github.com/bogusyogi/rightkit), resolved by apps through the root Package.swift
3
3
  // (docs/RIGHTKIT-SHARED-PACKAGES.md, Swift section). There is no mirror and no second repository.
4
4
  //
5
5
  // `right-release publish swift [--dry-run] [--evidence <file>] [--workspace <dir>]` verifies, in order:
@@ -19,7 +19,14 @@ import { spawnSync } from "./background-process.mjs";
19
19
  import { digestBytes } from "./publication-artifacts.mjs";
20
20
  import { enforcePublicationGate, defaultEvidencePath } from "./publication-gate.mjs";
21
21
 
22
- export const SWIFT_DISTRIBUTION_URL = "https://github.com/Orthic-Labs/rightkit";
22
+ // The distribution URL is whatever repository this checkout's origin names (GITHUB_REPOSITORY when origin does not
23
+ // parse); it is never a constant, so a repository move cannot leave a stale name in the success message.
24
+ export function swiftDistributionUrl(origin, environment = process.env) {
25
+ const fromOrigin = /github\.com[:/]([\w.-]+\/[\w.-]+?)(?:\.git)?\/?$/i.exec(String(origin ?? "").trim())?.[1];
26
+ const fromEnvironment = /^[\w.-]+\/[\w.-]+$/.test(environment?.GITHUB_REPOSITORY ?? "") ? environment.GITHUB_REPOSITORY : null;
27
+ const repository = fromOrigin ?? fromEnvironment;
28
+ return repository ? `https://github.com/${repository}` : String(origin ?? "").trim();
29
+ }
23
30
  const DRIFT_TEST = path.join("scripts", "swift-root-manifest.test.mjs");
24
31
 
25
32
  export function parseSwiftPublishArgs(argv) {
@@ -125,7 +132,9 @@ export function runSwiftPublish(options, execute = spawnSync) {
125
132
  catch (error) { git(["tag", "-d", version], toplevel, execute); throw error; }
126
133
  const remote = git(["ls-remote", "--tags", "origin", `refs/tags/${version}`], toplevel, execute);
127
134
  if (!remote.startsWith(sha)) throw new Error(`origin does not show tag ${version} at ${sha}`);
128
- out(`tagged ${version} at ${sha} on ${SWIFT_DISTRIBUTION_URL}`);
135
+ let origin = "";
136
+ try { origin = git(["config", "--get", "remote.origin.url"], toplevel, execute); } catch { /* the tag is already verified on origin; the name is cosmetic */ }
137
+ out(`tagged ${version} at ${sha} on ${swiftDistributionUrl(origin, options.environment)}`);
129
138
  return { name, version, commit: sha, published: true };
130
139
  }
131
140
 
@@ -391,6 +391,10 @@ export const WEB_MOCK_MARKERS = Object.freeze([
391
391
  { id: "isBrowserQaMode", pattern: /isBrowserQaMode/ },
392
392
  { id: "MockDataSource", pattern: /MockDataSource/ },
393
393
  { id: "qa-build-flag", pattern: /\bVITE_[A-Z0-9_]*_QA\b/ },
394
+
395
+ // @rightkit/license/testing (M12): its sentinel value, and the subpath a bundle that kept the import still names.
396
+ { id: "license-testing-sentinel", pattern: /\bRIGHTKIT_LICENSE_TESTING_DO_NOT_SHIP\b/ },
397
+ { id: "license-testing-entry", pattern: /@rightkit\/license\/testing/ },
394
398
  ]);
395
399
  const WEB_EXTENSIONS = /\.(?:m?js|cjs|html?|css|map)$/i;
396
400
 
@@ -0,0 +1,330 @@
1
+ // The release config contract (M24): `right-release.config.json`, a finite, versioned JSON schema read by dev, build, doctor, install
2
+ // and upload alike. It is data. RightKit never imports, evaluates or converts an executable (`.mjs`, `.js`, `.cjs`, `.ts`) config: the
3
+ // file name is judged BEFORE anything is opened or imported, including for `--dry-run`, so a legacy config cannot run a single line.
4
+ //
5
+ // Load order (build, dev, upload): resolve the primary checkout and the config path first, then `loadReleaseConfigSync` parses the JSON
6
+ // strictly (no comments, no trailing commas, no duplicate keys), checks `schema: 1` and the finite top-level key set, expands the
7
+ // typed templates below, and runs the M24 pipeline validation (`pipeline-config.mjs`) so a command that is not an admitted RightKit
8
+ // invocation stops the run before the #340 preflight and before any recipe executes. A hash alone is never trusted: each effect
9
+ // boundary (build, sign, upload) loads and validates again.
10
+ //
11
+ // Typed templates (the only computation a config may express):
12
+ // { "$fromJson": { "file": "src-tauri/tauri.conf.json", "pointer": "/version" } } a string read from an app-relative JSON file
13
+ // { "$fromToml": { "file": "src-tauri/Cargo.toml", "path": "package.version" } } a string read from an app-relative TOML file
14
+ // { "$json": <value> } JSON.stringify(<value>), for a JSON argument
15
+ // "{{app}}" "{{version}}" "{{home}}" "{{localAppData}}" replaced inside any string value
16
+ // Anything else with braces is left alone, and an unknown {{token}} is an error.
17
+ import { createHash } from "node:crypto";
18
+ import { existsSync, lstatSync, readFileSync } from "node:fs";
19
+ import os from "node:os";
20
+ import path from "node:path";
21
+ import { parse as parseToml } from "smol-toml";
22
+ import { collectConfigCommands, formatFindings, validateConfigPipeline } from "./pipeline-config.mjs";
23
+ import { loadGeneratorRegistry } from "./pipeline-generators.mjs";
24
+
25
+ export const RELEASE_CONFIG_FILE = "right-release.config.json";
26
+ export const LEGACY_RELEASE_CONFIG_FILE = "right-release.config.mjs";
27
+ export const RELEASE_CONFIG_SCHEMA = 1;
28
+ export const MAX_CONFIG_BYTES = 1024 * 1024;
29
+ export const LEGACY_EXECUTABLE_EXTENSIONS = Object.freeze([".mjs", ".js", ".cjs", ".ts", ".mts", ".cts", ".jsx", ".tsx"]);
30
+
31
+ /** The complete set of top-level keys. A key outside it is refused: a new capability is a RightKit change, not an app extension. */
32
+ export const TOP_LEVEL_KEYS = Object.freeze([
33
+ "$schema", "$comment", "schema", "app", "version", "channel", "distribution", "packageManager", "workdir", "targets", "development",
34
+ "deps", "checks", "buildInputs", "hostedWorkflows", "devtools", "releaseSwitches", "qa", "nativeAssembly", "legal", "web", "tests",
35
+ "sidecars", "releaseDependencies", "appStoreConnect", "storage", "cargoProfileExempt", "runtimeArtifacts",
36
+ ]);
37
+ export const TEMPLATE_TOKENS = Object.freeze(["app", "version", "home", "localAppData"]);
38
+
39
+ export class ReleaseConfigError extends Error {
40
+ constructor(code, message, details = {}) {
41
+ super(message);
42
+ this.name = "ReleaseConfigError";
43
+ this.code = code;
44
+ Object.assign(this, details);
45
+ }
46
+ }
47
+ const fail = (code, message, details) => { throw new ReleaseConfigError(code, message, details); };
48
+ const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
49
+
50
+ /**
51
+ * Judge a config file NAME. Throws for an executable config and for anything that is not `.json`. It touches no file: call it before
52
+ * opening, importing or even resolving a repository, so a legacy config is refused on every route (dry runs included).
53
+ */
54
+ export function assertReleaseConfigFileName(file) {
55
+ if (typeof file !== "string" || !file.trim()) fail("RELEASE_CONFIG_NOT_JSON", "a release config path is required (right-release.config.json)");
56
+ const extension = path.extname(path.basename(file)).toLowerCase();
57
+ if (LEGACY_EXECUTABLE_EXTENSIONS.includes(extension)) {
58
+ fail("RELEASE_CONFIG_LEGACY_EXECUTABLE", `${file} is an executable release config (${extension}). RightKit never imports, evaluates or converts one; write ${RELEASE_CONFIG_FILE} (finite JSON schema, see the README section "Release config (JSON)") and delete the module.`, { file });
59
+ }
60
+ if (extension !== ".json") fail("RELEASE_CONFIG_NOT_JSON", `${file} is not a JSON release config; the only config file is ${RELEASE_CONFIG_FILE}`, { file });
61
+ return file;
62
+ }
63
+
64
+ /** A hint for a missing config: a legacy sibling is named (never opened). */
65
+ function missingConfigMessage(file) {
66
+ const legacy = path.join(path.dirname(file), LEGACY_RELEASE_CONFIG_FILE);
67
+ return existsSync(legacy)
68
+ ? `release config not found: ${file}. A legacy executable config exists at ${legacy}; it is never imported or converted. Write ${RELEASE_CONFIG_FILE}.`
69
+ : `release config not found: ${file}`;
70
+ }
71
+
72
+ // ---- strict JSON ----------------------------------------------------------------------------------------------------------------
73
+ const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]);
74
+ const NUMBER = /-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/y;
75
+ const pointerKey = (key) => key.replaceAll("~", "~0").replaceAll("/", "~1");
76
+
77
+ /** Parse JSON strictly and remember where every value starts: `{ value, offsets }` (offsets: JSON pointer -> character offset). */
78
+ export function parseStrictJson(text) {
79
+ const offsets = new Map();
80
+ let pos = 0;
81
+ const error = (message) => { throw Object.assign(new SyntaxError(message), { offset: pos }); };
82
+ const skip = () => { while (pos < text.length && (text[pos] === " " || text[pos] === "\t" || text[pos] === "\n" || text[pos] === "\r")) pos += 1; };
83
+ const string = () => {
84
+ const start = pos;
85
+ pos += 1;
86
+ while (pos < text.length && text[pos] !== '"') {
87
+ if (text.charCodeAt(pos) < 0x20) error("a control character inside a string");
88
+ pos += text[pos] === "\\" ? 2 : 1;
89
+ }
90
+ if (text[pos] !== '"') error("an unterminated string");
91
+ pos += 1;
92
+ try { return JSON.parse(text.slice(start, pos)); } catch { pos = start; return error("an invalid string escape"); }
93
+ };
94
+ const literal = (word, result) => { if (!text.startsWith(word, pos)) error(`unexpected token`); pos += word.length; return result; };
95
+ const value = (pointer, depth) => {
96
+ if (depth > 64) error("nesting deeper than 64 levels");
97
+ skip();
98
+ offsets.set(pointer, pos);
99
+ const ch = text[pos];
100
+ if (ch === "{") return object(pointer, depth);
101
+ if (ch === "[") return array(pointer, depth);
102
+ if (ch === '"') return string();
103
+ if (ch === "t") return literal("true", true);
104
+ if (ch === "f") return literal("false", false);
105
+ if (ch === "n") return literal("null", null);
106
+ NUMBER.lastIndex = pos;
107
+ const match = NUMBER.exec(text);
108
+ if (!match || match.index !== pos) error("unexpected token");
109
+ pos += match[0].length;
110
+ return Number(match[0]);
111
+ };
112
+ const object = (pointer, depth) => {
113
+ const out = {};
114
+ pos += 1;
115
+ skip();
116
+ if (text[pos] === "}") { pos += 1; return out; }
117
+ for (;;) {
118
+ skip();
119
+ if (text[pos] !== '"') error("expected a string key");
120
+ const keyStart = pos;
121
+ const key = string();
122
+ if (FORBIDDEN_KEYS.has(key)) { pos = keyStart; error(`the key "${key}" is not allowed`); }
123
+ if (Object.prototype.hasOwnProperty.call(out, key)) { pos = keyStart; error(`duplicate key "${key}"`); }
124
+ skip();
125
+ if (text[pos] !== ":") error("expected ':'");
126
+ pos += 1;
127
+ out[key] = value(`${pointer}/${pointerKey(key)}`, depth + 1);
128
+ skip();
129
+ if (text[pos] === ",") { pos += 1; continue; }
130
+ if (text[pos] === "}") { pos += 1; return out; }
131
+ error("expected ',' or '}'");
132
+ }
133
+ };
134
+ const array = (pointer, depth) => {
135
+ const out = [];
136
+ pos += 1;
137
+ skip();
138
+ if (text[pos] === "]") { pos += 1; return out; }
139
+ for (;;) {
140
+ out.push(value(`${pointer}/${out.length}`, depth + 1));
141
+ skip();
142
+ if (text[pos] === ",") { pos += 1; continue; }
143
+ if (text[pos] === "]") { pos += 1; return out; }
144
+ error("expected ',' or ']'");
145
+ }
146
+ };
147
+ const result = value("", 0);
148
+ skip();
149
+ if (pos !== text.length) error("unexpected content after the JSON value");
150
+ return { value: result, offsets };
151
+ }
152
+
153
+ /** 1-based line of a JSON pointer in `text` (the nearest recorded ancestor when the exact pointer is not a source position). */
154
+ export function lineOfPointer(text, offsets, pointer) {
155
+ let current = pointer;
156
+ while (current !== "" && !offsets.has(current)) current = current.slice(0, current.lastIndexOf("/"));
157
+ const offset = offsets.get(current) ?? 0;
158
+ let line = 1;
159
+ for (let i = 0; i < offset && i < text.length; i += 1) if (text[i] === "\n") line += 1;
160
+ return line;
161
+ }
162
+
163
+ // ---- templates ------------------------------------------------------------------------------------------------------------------
164
+ function readInside(appRoot, relative, label, { readBytes, lstat }) {
165
+ if (typeof relative !== "string" || !relative.trim() || path.isAbsolute(relative) || path.win32.isAbsolute(relative) || /^[A-Za-z]:/.test(relative) || relative.replaceAll("\\", "/").split("/").includes("..")) {
166
+ fail("RELEASE_CONFIG_TEMPLATE", `${label}: file must be a relative path inside the app: ${JSON.stringify(relative)}`);
167
+ }
168
+ const absolute = path.resolve(appRoot, relative);
169
+ let info;
170
+ try { info = lstat(absolute); } catch { fail("RELEASE_CONFIG_TEMPLATE", `${label}: ${relative} does not exist`); }
171
+ if (info.isSymbolicLink() || !info.isFile()) fail("RELEASE_CONFIG_TEMPLATE", `${label}: ${relative} must be a regular file (no symlink)`);
172
+ if (info.size > MAX_CONFIG_BYTES) fail("RELEASE_CONFIG_TEMPLATE", `${label}: ${relative} is larger than ${MAX_CONFIG_BYTES} bytes`);
173
+ const bytes = readBytes(absolute);
174
+ return { bytes, sha256: createHash("sha256").update(bytes).digest("hex"), relative: relative.replaceAll("\\", "/") };
175
+ }
176
+
177
+ function jsonPointerGet(document, pointer, label) {
178
+ if (pointer === "") return document;
179
+ if (typeof pointer !== "string" || !pointer.startsWith("/")) fail("RELEASE_CONFIG_TEMPLATE", `${label}: pointer must be a JSON pointer such as /version`);
180
+ let current = document;
181
+ for (const raw of pointer.slice(1).split("/")) {
182
+ const key = raw.replaceAll("~1", "/").replaceAll("~0", "~");
183
+ if (!(isObject(current) || Array.isArray(current)) || !Object.prototype.hasOwnProperty.call(current, key)) fail("RELEASE_CONFIG_TEMPLATE", `${label}: ${pointer} is not in the file`);
184
+ current = current[key];
185
+ }
186
+ return current;
187
+ }
188
+
189
+ function scalarString(value, label) {
190
+ if (typeof value === "string") return value;
191
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
192
+ return fail("RELEASE_CONFIG_TEMPLATE", `${label}: the referenced value must be a string or a number`);
193
+ }
194
+
195
+ /** Replace the typed templates in `node`. `context` carries the template values, the app root and the injectable file access. */
196
+ function expand(node, context, label) {
197
+ if (typeof node === "string") {
198
+ return node.replace(/\{\{([A-Za-z][A-Za-z0-9]*)\}\}/g, (_, token) => {
199
+ if (!TEMPLATE_TOKENS.includes(token)) fail("RELEASE_CONFIG_TEMPLATE", `${label}: unknown template token {{${token}}} (allowed: ${TEMPLATE_TOKENS.map((t) => `{{${t}}}`).join(", ")})`);
200
+ const resolved = context.values[token];
201
+ if (typeof resolved !== "string" || resolved === "") fail("RELEASE_CONFIG_TEMPLATE", `${label}: {{${token}}} is not available${token === "localAppData" ? " (LOCALAPPDATA is not set)" : token === "version" ? " here (version cannot refer to itself)" : ""}`);
202
+ return resolved;
203
+ });
204
+ }
205
+ if (Array.isArray(node)) return node.map((item, index) => expand(item, context, `${label}/${index}`));
206
+ if (!isObject(node)) return node;
207
+ const keys = Object.keys(node);
208
+ if (keys.length === 1 && keys[0] === "$fromJson") {
209
+ const spec = node.$fromJson;
210
+ if (!isObject(spec) || Object.keys(spec).some((key) => key !== "file" && key !== "pointer") || typeof spec.file !== "string") fail("RELEASE_CONFIG_TEMPLATE", `${label}: $fromJson must be { file, pointer }`);
211
+ const read = readInside(context.appRoot, spec.file, `${label} $fromJson`, context);
212
+ let document;
213
+ try { document = JSON.parse(Buffer.from(read.bytes).toString("utf8")); } catch { fail("RELEASE_CONFIG_TEMPLATE", `${label}: ${spec.file} is not valid JSON`); }
214
+ context.inputs.set(read.relative, read.sha256);
215
+ return scalarString(jsonPointerGet(document, spec.pointer ?? "", `${label} $fromJson`), label);
216
+ }
217
+ if (keys.length === 1 && keys[0] === "$fromToml") {
218
+ const spec = node.$fromToml;
219
+ if (!isObject(spec) || Object.keys(spec).some((key) => key !== "file" && key !== "path") || typeof spec.file !== "string" || typeof spec.path !== "string") fail("RELEASE_CONFIG_TEMPLATE", `${label}: $fromToml must be { file, path }`);
220
+ const read = readInside(context.appRoot, spec.file, `${label} $fromToml`, context);
221
+ let document;
222
+ try { document = parseToml(Buffer.from(read.bytes).toString("utf8")); } catch { fail("RELEASE_CONFIG_TEMPLATE", `${label}: ${spec.file} is not valid TOML`); }
223
+ context.inputs.set(read.relative, read.sha256);
224
+ let current = document;
225
+ for (const key of spec.path.split(".")) {
226
+ if (!isObject(current) || !Object.prototype.hasOwnProperty.call(current, key)) fail("RELEASE_CONFIG_TEMPLATE", `${label}: ${spec.path} is not in ${spec.file}`);
227
+ current = current[key];
228
+ }
229
+ return scalarString(current, label);
230
+ }
231
+ if (keys.length === 1 && keys[0] === "$json") return JSON.stringify(expand(node.$json, context, `${label}/$json`));
232
+ const out = {};
233
+ for (const key of keys) {
234
+ if (key.startsWith("$") && !TOP_LEVEL_KEYS.includes(key)) fail("RELEASE_CONFIG_TEMPLATE", `${label}/${key}: "$" keys other than $fromJson, $fromToml and $json are reserved`);
235
+ out[key] = expand(node[key], context, `${label}/${pointerKey(key)}`);
236
+ }
237
+ return out;
238
+ }
239
+
240
+ // ---- shape ----------------------------------------------------------------------------------------------------------------------
241
+ const SAFE_APP = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
242
+ const SAFE_VERSION = /^[A-Za-z0-9][A-Za-z0-9._+-]*$/;
243
+
244
+ /** Schema, finite key set and the identity fields. Throws RELEASE_CONFIG_SCHEMA / RELEASE_CONFIG_KEY. */
245
+ export function validateReleaseConfigShape(config, { file = RELEASE_CONFIG_FILE, phase = "final" } = {}) {
246
+ if (!isObject(config)) fail("RELEASE_CONFIG_SCHEMA", `${file} must be a JSON object`, { file });
247
+ if (config.schema !== RELEASE_CONFIG_SCHEMA) fail("RELEASE_CONFIG_SCHEMA", `${file}: unsupported config schema ${JSON.stringify(config.schema ?? null)} (expected ${RELEASE_CONFIG_SCHEMA})`, { file });
248
+ const unknown = Object.keys(config).filter((key) => !TOP_LEVEL_KEYS.includes(key));
249
+ if (unknown.length) fail("RELEASE_CONFIG_KEY", `${file}: unknown top-level key(s): ${unknown.join(", ")}. The config schema is finite (allowed: ${TOP_LEVEL_KEYS.filter((key) => !key.startsWith("$")).join(", ")}); a new capability is a RightKit change.`, { file, unknown });
250
+ if (typeof config.app !== "string" || !SAFE_APP.test(config.app)) fail("RELEASE_CONFIG_SCHEMA", `${file}: app must be a safe name (letters, digits, . _ -)`, { file });
251
+ // Before template expansion a version may still be a { $fromJson | $fromToml } node or carry a {{token}}; the final check is strict.
252
+ const versionIsTemplate = phase === "raw" && (isObject(config.version) || (typeof config.version === "string" && config.version.includes("{{")));
253
+ if (config.version !== undefined && !versionIsTemplate && (typeof config.version !== "string" || !SAFE_VERSION.test(config.version))) fail("RELEASE_CONFIG_SCHEMA", `${file}: version must be a safe version string`, { file });
254
+ for (const key of ["targets", "development", "buildInputs", "distribution"]) {
255
+ if (config[key] !== undefined && !isObject(config[key])) fail("RELEASE_CONFIG_SCHEMA", `${file}: ${key} must be an object`, { file });
256
+ }
257
+ return config;
258
+ }
259
+
260
+ function packageScripts(appRoot, readText) {
261
+ try {
262
+ const parsed = JSON.parse(readText(path.join(appRoot, "package.json")));
263
+ return isObject(parsed?.scripts) ? parsed.scripts : undefined;
264
+ } catch { return undefined; }
265
+ }
266
+
267
+ /**
268
+ * Load, validate and expand a release config. Returns `{ config, configPath, appRoot, sha256, inputs, lineOf }`. Throws ReleaseConfigError
269
+ * (RELEASE_CONFIG_LEGACY_EXECUTABLE | _NOT_JSON | _NOT_FOUND | _INVALID_JSON | _SCHEMA | _KEY | _TEMPLATE | _M24). `validatePipeline: false`
270
+ * skips only the M24 command admission (the pipeline scanner reports those findings itself); nothing else is optional.
271
+ * File access, the generator registry, the environment and the home directory are injectable for tests.
272
+ */
273
+ export function loadReleaseConfigSync(file, {
274
+ validatePipeline = true, registry, containment, env = process.env, homedir = () => os.homedir(),
275
+ readText = (target) => readFileSync(target, "utf8"), readBytes = (target) => readFileSync(target), lstat = lstatSync,
276
+ } = {}) {
277
+ assertReleaseConfigFileName(file);
278
+ const configPath = path.resolve(file);
279
+ const appRoot = path.dirname(configPath);
280
+ let text;
281
+ try { text = readText(configPath); } catch { fail("RELEASE_CONFIG_NOT_FOUND", missingConfigMessage(configPath), { file: configPath }); }
282
+ if (Buffer.byteLength(text) > MAX_CONFIG_BYTES) fail("RELEASE_CONFIG_INVALID_JSON", `${configPath} is larger than ${MAX_CONFIG_BYTES} bytes`, { file: configPath });
283
+ if (text.charCodeAt(0) === 0xfeff) text = text.slice(1);
284
+ let parsed;
285
+ try { parsed = parseStrictJson(text); } catch (error) {
286
+ let line = 1;
287
+ for (let i = 0; i < (error.offset ?? 0) && i < text.length; i += 1) if (text[i] === "\n") line += 1;
288
+ fail("RELEASE_CONFIG_INVALID_JSON", `${configPath}:${line} is not valid strict JSON (${error.message}; no comments, trailing commas or duplicate keys)`, { file: configPath, line });
289
+ }
290
+ const raw = parsed.value;
291
+ validateReleaseConfigShape(raw, { file: configPath, phase: "raw" });
292
+ const inputs = new Map();
293
+ const base = { appRoot, inputs, readBytes, lstat, values: { home: homedir(), localAppData: env.LOCALAPPDATA ?? "", app: "", version: "" } };
294
+ base.values.app = expand(raw.app, { ...base }, "/app");
295
+ if (raw.version !== undefined) base.values.version = expand(raw.version, { ...base, values: { ...base.values, version: "" } }, "/version");
296
+ const config = expand(raw, base, "");
297
+ validateReleaseConfigShape(config, { file: configPath });
298
+ const sha256 = createHash("sha256").update(Buffer.from(text)).digest("hex");
299
+ if (validatePipeline) {
300
+ const findings = validateConfigPipeline(config, {
301
+ scripts: packageScripts(appRoot, readText),
302
+ registry: registry ?? loadGeneratorRegistry(),
303
+ appRoot,
304
+ containment,
305
+ readBytes,
306
+ lineOf: (pointer) => lineOfPointer(text, parsed.offsets, pointer),
307
+ });
308
+ if (findings.length) {
309
+ fail("RELEASE_CONFIG_M24", `${configPath} has ${findings.length} M24 pipeline finding(s); every command must be an admitted RightKit invocation:\n${formatFindings(findings, configPath).map((line) => `- ${line}`).join("\n")}`, { file: configPath, findings });
310
+ }
311
+ }
312
+ return {
313
+ config, configPath, appRoot, sha256,
314
+ inputs: [...inputs].map(([file, digest]) => ({ file, sha256: digest })),
315
+ lineOf: (pointer) => lineOfPointer(text, parsed.offsets, pointer),
316
+ };
317
+ }
318
+
319
+ /** The config object only (what `(await import(configPath)).default` used to be). */
320
+ export async function loadReleaseConfig(file, options) {
321
+ return loadReleaseConfigSync(file, options).config;
322
+ }
323
+
324
+ /** `{ config, appRoot, configPath }` for the commands that need the app root beside the config. */
325
+ export async function loadAppConfigFile(file = RELEASE_CONFIG_FILE, options) {
326
+ const loaded = loadReleaseConfigSync(file, options);
327
+ return { config: loaded.config, appRoot: loaded.appRoot, configPath: loaded.configPath };
328
+ }
329
+
330
+ export { collectConfigCommands };