@rightkit/release 0.6.2 → 0.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +37 -5
  2. package/bin/darwin/rightkit-swap +0 -0
  3. package/bin/rightkit-gate.mjs +4 -4
  4. package/build-release.mjs +2 -2
  5. package/cli/right-release.mjs +3 -2
  6. package/deterministic-scans.mjs +21 -10
  7. package/development.mjs +3 -2
  8. package/hosted-workflow-policy.mjs +111 -0
  9. package/ios-dev.mjs +2 -2
  10. package/ios-host.mjs +7 -7
  11. package/ios-package-recipe.mjs +2 -2
  12. package/local-publish.mjs +1 -1
  13. package/local-qualify.mjs +3 -3
  14. package/local-runtime.mjs +1 -1
  15. package/ownership-scope.mjs +9 -0
  16. package/package.json +1 -1
  17. package/pipeline-admission.mjs +5 -4
  18. package/pipeline-receipts.mjs +302 -302
  19. package/pipeline-scan.mjs +3 -3
  20. package/pipeline-tools.mjs +175 -171
  21. package/prebuild-gates.mjs +187 -0
  22. package/preflight.mjs +5 -1
  23. package/progress-control.mjs +1 -1
  24. package/publish-update.mjs +4 -3
  25. package/qa-admission-policy.json +5 -0
  26. package/release-config.mjs +353 -330
  27. package/release-qa-audit.mjs +37 -11
  28. package/release-qa-command.mjs +46 -5
  29. package/release-qa-fixtures.mjs +127 -0
  30. package/release-qa-floor.json +6 -1
  31. package/release-qa-hardening.mjs +173 -0
  32. package/release-qa-policy.mjs +168 -0
  33. package/release-qa-receipt.mjs +21 -1
  34. package/release-switch-scan.mjs +137 -6
  35. package/release.mjs +256 -198
  36. package/rightapps-register.mjs +3 -1
  37. package/rightkit-drift.mjs +1 -1
  38. package/rightkit-ownership.json +15 -5
  39. package/rightkit-versions.json +52 -38
  40. package/stage-sidecar.mjs +17 -15
  41. package/suite-api-literal.mjs +109 -0
  42. package/suite-api.generated.mjs +78 -0
  43. package/swap-helper-bundle.mjs +2 -2
  44. package/win-package-recipe.mjs +7 -4
@@ -1,330 +1,353 @@
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 };
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
+ const fieldProblems = releaseConfigFieldProblems(config);
258
+ if (fieldProblems.length) fail(fieldProblems[0].code, fieldProblems.map((problem) => `${file}: ${problem.detail}`).join("\n"), { file, pointers: fieldProblems.map((problem) => problem.pointer) });
259
+ return config;
260
+ }
261
+
262
+ /**
263
+ * Field-level refusals that need no other part of the config, so a caller (the loader, the doctor) can list them next to every other
264
+ * finding: `[{ name, code, pointer, detail }]`. Today: `targets.<t>.updater.keyFile`. Updater minisign keys come from the OS credential
265
+ * provider (`protected-credentials.mjs`, secret "updater-signing-key", namespace `rightsuite-updater-key`; `sign-updater.mjs` refuses
266
+ * `--key-file`), so a config that names a key file could only fail mid-signing. It is refused up front instead, and never forwarded.
267
+ */
268
+ export function releaseConfigFieldProblems(config) {
269
+ const problems = [];
270
+ if (!isObject(config) || !isObject(config.targets)) return problems;
271
+ for (const [name, target] of Object.entries(config.targets)) {
272
+ if (!isObject(target) || !isObject(target.updater) || !Object.prototype.hasOwnProperty.call(target.updater, "keyFile")) continue;
273
+ problems.push({
274
+ name: "updater-key-file",
275
+ code: "RELEASE_CONFIG_UPDATER_KEY_FILE",
276
+ pointer: `/targets/${pointerKey(name)}/updater/keyFile`,
277
+ detail: `targets.${name}.updater.keyFile is not supported: updater signing keys come from the OS credential provider (rightkit-credentials, namespace rightsuite-updater-key; see protected-credentials.mjs and sign-updater.mjs), never from a key file. Remove updater.keyFile from the release config.`,
278
+ });
279
+ }
280
+ return problems;
281
+ }
282
+
283
+ function packageScripts(appRoot, readText) {
284
+ try {
285
+ const parsed = JSON.parse(readText(path.join(appRoot, "package.json")));
286
+ return isObject(parsed?.scripts) ? parsed.scripts : undefined;
287
+ } catch { return undefined; }
288
+ }
289
+
290
+ /**
291
+ * Load, validate and expand a release config. Returns `{ config, configPath, appRoot, sha256, inputs, lineOf }`. Throws ReleaseConfigError
292
+ * (RELEASE_CONFIG_LEGACY_EXECUTABLE | _NOT_JSON | _NOT_FOUND | _INVALID_JSON | _SCHEMA | _KEY | _UPDATER_KEY_FILE | _TEMPLATE | _M24). `validatePipeline: false`
293
+ * skips only the M24 command admission (the pipeline scanner reports those findings itself); nothing else is optional.
294
+ * File access, the generator registry, the environment and the home directory are injectable for tests.
295
+ */
296
+ export function loadReleaseConfigSync(file, {
297
+ validatePipeline = true, registry, containment, env = process.env, homedir = () => os.homedir(),
298
+ readText = (target) => readFileSync(target, "utf8"), readBytes = (target) => readFileSync(target), lstat = lstatSync,
299
+ } = {}) {
300
+ assertReleaseConfigFileName(file);
301
+ const configPath = path.resolve(file);
302
+ const appRoot = path.dirname(configPath);
303
+ let text;
304
+ try { text = readText(configPath); } catch { fail("RELEASE_CONFIG_NOT_FOUND", missingConfigMessage(configPath), { file: configPath }); }
305
+ if (Buffer.byteLength(text) > MAX_CONFIG_BYTES) fail("RELEASE_CONFIG_INVALID_JSON", `${configPath} is larger than ${MAX_CONFIG_BYTES} bytes`, { file: configPath });
306
+ if (text.charCodeAt(0) === 0xfeff) text = text.slice(1);
307
+ let parsed;
308
+ try { parsed = parseStrictJson(text); } catch (error) {
309
+ let line = 1;
310
+ for (let i = 0; i < (error.offset ?? 0) && i < text.length; i += 1) if (text[i] === "\n") line += 1;
311
+ fail("RELEASE_CONFIG_INVALID_JSON", `${configPath}:${line} is not valid strict JSON (${error.message}; no comments, trailing commas or duplicate keys)`, { file: configPath, line });
312
+ }
313
+ const raw = parsed.value;
314
+ validateReleaseConfigShape(raw, { file: configPath, phase: "raw" });
315
+ const inputs = new Map();
316
+ const base = { appRoot, inputs, readBytes, lstat, values: { home: homedir(), localAppData: env.LOCALAPPDATA ?? "", app: "", version: "" } };
317
+ base.values.app = expand(raw.app, { ...base }, "/app");
318
+ if (raw.version !== undefined) base.values.version = expand(raw.version, { ...base, values: { ...base.values, version: "" } }, "/version");
319
+ const config = expand(raw, base, "");
320
+ validateReleaseConfigShape(config, { file: configPath });
321
+ const sha256 = createHash("sha256").update(Buffer.from(text)).digest("hex");
322
+ if (validatePipeline) {
323
+ const findings = validateConfigPipeline(config, {
324
+ scripts: packageScripts(appRoot, readText),
325
+ registry: registry ?? loadGeneratorRegistry(),
326
+ appRoot,
327
+ containment,
328
+ readBytes,
329
+ lineOf: (pointer) => lineOfPointer(text, parsed.offsets, pointer),
330
+ });
331
+ if (findings.length) {
332
+ 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 });
333
+ }
334
+ }
335
+ return {
336
+ config, configPath, appRoot, sha256,
337
+ inputs: [...inputs].map(([file, digest]) => ({ file, sha256: digest })),
338
+ lineOf: (pointer) => lineOfPointer(text, parsed.offsets, pointer),
339
+ };
340
+ }
341
+
342
+ /** The config object only (what `(await import(configPath)).default` used to be). */
343
+ export async function loadReleaseConfig(file, options) {
344
+ return loadReleaseConfigSync(file, options).config;
345
+ }
346
+
347
+ /** `{ config, appRoot, configPath }` for the commands that need the app root beside the config. */
348
+ export async function loadAppConfigFile(file = RELEASE_CONFIG_FILE, options) {
349
+ const loaded = loadReleaseConfigSync(file, options);
350
+ return { config: loaded.config, appRoot: loaded.appRoot, configPath: loaded.configPath };
351
+ }
352
+
353
+ export { collectConfigCommands };