wyvrnpm 2.10.2 → 2.12.2

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 (49) hide show
  1. package/README.md +1914 -1860
  2. package/bin/{wyvrn.js → wyvrnpm.js} +66 -0
  3. package/cmake/cpp.cmake +9 -9
  4. package/cmake/functions.cmake +224 -224
  5. package/cmake/macros.cmake +284 -284
  6. package/package.json +3 -2
  7. package/src/auth.js +66 -66
  8. package/src/binary-dir.js +95 -0
  9. package/src/bootstrap/cookbook.js +196 -196
  10. package/src/bootstrap/detect.js +150 -150
  11. package/src/bootstrap/index.js +220 -220
  12. package/src/bootstrap/version.js +72 -72
  13. package/src/build/cache.js +344 -344
  14. package/src/build/clone.js +170 -170
  15. package/src/build/cmake.js +342 -342
  16. package/src/build/index.js +299 -297
  17. package/src/build/msvc-env.js +260 -260
  18. package/src/build/recipe.js +188 -188
  19. package/src/commands/add.js +141 -141
  20. package/src/commands/bootstrap.js +96 -96
  21. package/src/commands/build.js +482 -452
  22. package/src/commands/cache.js +189 -189
  23. package/src/commands/clean.js +80 -80
  24. package/src/commands/configure.js +92 -92
  25. package/src/commands/init.js +70 -70
  26. package/src/commands/install-skill.js +115 -115
  27. package/src/commands/install.js +730 -674
  28. package/src/commands/link.js +320 -320
  29. package/src/commands/profile.js +237 -237
  30. package/src/commands/publish.js +584 -555
  31. package/src/commands/show.js +252 -252
  32. package/src/commands/version.js +187 -0
  33. package/src/compat.js +273 -273
  34. package/src/conf/index.js +415 -391
  35. package/src/conf/namespaces.js +94 -94
  36. package/src/context.js +230 -230
  37. package/src/http-fetch.js +53 -53
  38. package/src/ignore.js +118 -118
  39. package/src/logger.js +122 -122
  40. package/src/options.js +303 -303
  41. package/src/settings-overrides.js +152 -152
  42. package/src/toolchain/deps.js +164 -164
  43. package/src/toolchain/index.js +212 -212
  44. package/src/toolchain/presets.js +356 -340
  45. package/src/toolchain/template.cmake +77 -77
  46. package/src/upload-built.js +265 -265
  47. package/src/version-range.js +301 -301
  48. package/src/zip-safe.js +52 -52
  49. package/src/zip-stream.js +126 -0
package/src/conf/index.js CHANGED
@@ -1,391 +1,415 @@
1
- 'use strict';
2
-
3
- // Non-ABI build-time configuration — the `conf` system (PLAN-CONF.md).
4
- //
5
- // `conf` is the sibling of `options` with the opposite hashing
6
- // property: options fold into `profileHash` (ABI-affecting), conf
7
- // never does (build-time knobs). See plan §2 for the rationale.
8
- //
9
- // This module is the pure core. It parses the four layers
10
- // (CLI, `wyvrn.local.json`, named profile `[conf]`, recipe
11
- // `wyvrn.json`), merges them with per-leaf precedence, and produces
12
- // a flat `{ "namespace.leaf": value }` map ready for each sink to
13
- // consume. It does NOT know about CMake, filesystems beyond reading
14
- // a given JSON file, or any command — those live one level up in
15
- // src/commands/* and src/toolchain/*.
16
-
17
- const fs = require('fs');
18
- const path = require('path');
19
-
20
- const { validateFlatConfKeys } = require('./namespaces');
21
-
22
- const LOCAL_OVERLAY_FILENAME = 'wyvrn.local.json';
23
-
24
- // Per plan §4.5 "Strict top-level allow-list". Phase-1 accepts only
25
- // `{ conf }` at the top level of wyvrn.local.json. Adding
26
- // dependencies/options/etc. to this set is deliberate and plan-2
27
- // territory.
28
- const LOCAL_OVERLAY_ALLOWED_TOP_KEYS = new Set(['conf']);
29
-
30
- // ---------------------------------------------------------------------------
31
- // Value normalisation
32
- // ---------------------------------------------------------------------------
33
-
34
- /**
35
- * Normalise a single leaf VALUE into the string CMake (or whichever
36
- * sink consumes it) expects. JSON `true`/`false` become `"ON"`/`"OFF"`;
37
- * numbers are stringified; strings pass through. Arrays / objects /
38
- * null at a leaf position are a hard error those live at branch
39
- * positions, not leaves.
40
- *
41
- * CLI values arrive as strings and are passed through verbatim.
42
- *
43
- * @param {unknown} value
44
- * @param {string} keyForError
45
- * @returns {string}
46
- */
47
- function normalizeLeafValue(value, keyForError) {
48
- if (value === true) return 'ON';
49
- if (value === false) return 'OFF';
50
- if (typeof value === 'number' && Number.isFinite(value)) return String(value);
51
- if (typeof value === 'string') return value;
52
- throw new Error(
53
- `conf value for ${JSON.stringify(keyForError)} must be a string, ` +
54
- `number, or boolean got ${JSON.stringify(value)}`,
55
- );
56
- }
57
-
58
- /**
59
- * Flatten a nested conf object (recipe/local form) into a flat
60
- * `{ "ns.leaf": stringValue }` map. Keys are joined with `.`. The
61
- * recursion stops at any non-object (strings, numbers, booleans) —
62
- * those are leaves.
63
- *
64
- * Throws on:
65
- * - null at a leaf position
66
- * - arrays anywhere (arrays are not meaningful for cmake cache vars
67
- * and leaving them as "maybe later" invites footguns)
68
- * - objects whose keys contain `.` (would clash with our separator)
69
- *
70
- * @param {object} nested
71
- * @param {string} [prefix]
72
- * @param {Record<string, string>} [out]
73
- * @returns {Record<string, string>}
74
- */
75
- function flattenConf(nested, prefix = '', out = {}) {
76
- if (nested === null || typeof nested !== 'object') {
77
- throw new Error(
78
- `conf block must be an object got ${JSON.stringify(nested)} at ` +
79
- `${prefix || '<root>'}`,
80
- );
81
- }
82
- if (Array.isArray(nested)) {
83
- throw new Error(`conf block must be an object, not an array — at ${prefix || '<root>'}`);
84
- }
85
-
86
- for (const [rawKey, value] of Object.entries(nested)) {
87
- if (rawKey.includes('.')) {
88
- throw new Error(
89
- `conf key segment ${JSON.stringify(rawKey)} must not contain a period — ` +
90
- `use nested objects or the flat "namespace.leaf" form, not both`,
91
- );
92
- }
93
- const fullKey = prefix ? `${prefix}.${rawKey}` : rawKey;
94
-
95
- if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
96
- flattenConf(value, fullKey, out);
97
- } else {
98
- out[fullKey] = normalizeLeafValue(value, fullKey);
99
- }
100
- }
101
- return out;
102
- }
103
-
104
- /**
105
- * Re-expand a flat `{ "ns.leaf": value }` map back into nested form,
106
- * for emitting into JSON manifests / lockfiles / payloads in the same
107
- * shape authors author with. Values round-trip as strings (conf
108
- * values are always strings post-normalisation).
109
- *
110
- * @param {Record<string, string>} flat
111
- * @returns {object}
112
- */
113
- function unflattenConf(flat) {
114
- const root = {};
115
- for (const [key, value] of Object.entries(flat)) {
116
- const parts = key.split('.');
117
- let node = root;
118
- for (let i = 0; i < parts.length - 1; i++) {
119
- const seg = parts[i];
120
- if (node[seg] === undefined || typeof node[seg] !== 'object') {
121
- node[seg] = {};
122
- }
123
- node = node[seg];
124
- }
125
- node[parts[parts.length - 1]] = value;
126
- }
127
- return root;
128
- }
129
-
130
- // ---------------------------------------------------------------------------
131
- // CLI parsing
132
- // ---------------------------------------------------------------------------
133
-
134
- /**
135
- * Parse an array of `--conf KEY=VALUE` strings (yargs delivers
136
- * `argv.conf` when the option is registered as `array: true`) into a
137
- * flat map. See plan §4.4 for semantics:
138
- *
139
- * KEY=VALUE → set the key to VALUE verbatim (string).
140
- * KEY → alias for KEY=true (matches CMake's -D convention).
141
- * KEY= → unset (remove the key from lower layers on merge).
142
- *
143
- * Returns two maps set values and unset keys — so the merger can
144
- * treat the latter specially.
145
- *
146
- * @param {string[] | undefined} rawArgs
147
- * @returns {{ sets: Record<string,string>, unsets: Set<string> }}
148
- */
149
- function parseCliConf(rawArgs) {
150
- const sets = {};
151
- const unsets = new Set();
152
-
153
- if (!rawArgs || rawArgs.length === 0) {
154
- return { sets, unsets };
155
- }
156
-
157
- for (const arg of rawArgs) {
158
- if (typeof arg !== 'string') {
159
- throw new Error(`--conf expects KEY=VALUE string; got ${JSON.stringify(arg)}`);
160
- }
161
- const eqIdx = arg.indexOf('=');
162
- if (eqIdx < 0) {
163
- // Bare `KEY` → KEY=true (normalised to "ON").
164
- const key = arg.trim();
165
- if (key.length === 0) {
166
- throw new Error('--conf was given an empty key');
167
- }
168
- sets[key] = 'ON';
169
- } else {
170
- const key = arg.slice(0, eqIdx).trim();
171
- const value = arg.slice(eqIdx + 1);
172
- if (key.length === 0) {
173
- throw new Error(`--conf ${JSON.stringify(arg)} has an empty key`);
174
- }
175
- if (value.length === 0) {
176
- // KEY= unset. Distinguish from KEY="" which callers can
177
- // achieve by quoting the value; here the shell has already
178
- // stripped the quotes, so an empty string past the `=` means
179
- // "clear any inherited value for this key."
180
- unsets.add(key);
181
- } else {
182
- sets[key] = value;
183
- }
184
- }
185
- }
186
-
187
- return { sets, unsets };
188
- }
189
-
190
- // ---------------------------------------------------------------------------
191
- // Local-overlay file
192
- // ---------------------------------------------------------------------------
193
-
194
- /**
195
- * Read and validate `wyvrn.local.json` at the given project root.
196
- * Returns the parsed `conf` block as a flat map, or an empty map when
197
- * the file is absent.
198
- *
199
- * Enforces plan §4.5: the only top-level key this file may carry in
200
- * phase 1 is `conf`; anything else is a hard error (not a warning).
201
- *
202
- * @param {string} rootDir
203
- * @returns {{ flat: Record<string,string>, path: string|null }}
204
- */
205
- function readLocalOverlay(rootDir) {
206
- const p = path.join(rootDir, LOCAL_OVERLAY_FILENAME);
207
- if (!fs.existsSync(p)) return { flat: {}, path: null };
208
-
209
- let raw;
210
- try {
211
- raw = JSON.parse(fs.readFileSync(p, 'utf8'));
212
- } catch (err) {
213
- throw new Error(`cannot parse ${LOCAL_OVERLAY_FILENAME}: ${err.message}`);
214
- }
215
- if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
216
- throw new Error(`${LOCAL_OVERLAY_FILENAME} must contain a JSON object at the top level`);
217
- }
218
-
219
- for (const key of Object.keys(raw)) {
220
- if (!LOCAL_OVERLAY_ALLOWED_TOP_KEYS.has(key)) {
221
- const allowed = [...LOCAL_OVERLAY_ALLOWED_TOP_KEYS].join(', ');
222
- throw new Error(
223
- `${LOCAL_OVERLAY_FILENAME}: unknown top-level key ${JSON.stringify(key)} — ` +
224
- `phase-1 allow-list: [${allowed}]. ` +
225
- `If you meant to override dependencies or options, that is reserved ` +
226
- `for a future phase; see claude/PLAN-CONF.md §4.5.`,
227
- );
228
- }
229
- }
230
-
231
- const confBlock = raw.conf ?? {};
232
- const flat = flattenConf(confBlock);
233
- validateFlatConfKeys(flat);
234
- return { flat, path: p };
235
- }
236
-
237
- // ---------------------------------------------------------------------------
238
- // Recipe extraction
239
- // ---------------------------------------------------------------------------
240
-
241
- /**
242
- * Pull the `conf` block out of an already-loaded recipe manifest and
243
- * flatten it. A missing block is an empty map.
244
- *
245
- * Validation runs against the phase-1 allow-list; if a recipe declares
246
- * a namespace we don't support yet, the install fails with a clear
247
- * error rather than silently dropping the config.
248
- *
249
- * @param {object} manifest
250
- * @returns {Record<string,string>}
251
- */
252
- function readRecipeConf(manifest) {
253
- const block = manifest?.conf ?? null;
254
- if (block === null || block === undefined) return {};
255
- const flat = flattenConf(block);
256
- validateFlatConfKeys(flat);
257
- return flat;
258
- }
259
-
260
- // ---------------------------------------------------------------------------
261
- // Profile extraction
262
- // ---------------------------------------------------------------------------
263
-
264
- /**
265
- * Pull the optional `conf` block out of a loaded named profile JSON
266
- * (see src/profile.js) and flatten it. A missing block is an empty
267
- * map — profiles without `conf` round-trip byte-identically to
268
- * pre-phase-2 behaviour.
269
- *
270
- * Validation runs against the phase-1 allow-list; an unknown
271
- * namespace in a profile fails fast rather than silently dropping
272
- * the config (CLAUDE.md §2 principle 8).
273
- *
274
- * @param {object|null|undefined} profile
275
- * @returns {Record<string,string>}
276
- */
277
- function readProfileConf(profile) {
278
- const block = profile?.conf ?? null;
279
- if (block === null || block === undefined) return {};
280
- const flat = flattenConf(block);
281
- validateFlatConfKeys(flat);
282
- return flat;
283
- }
284
-
285
- // ---------------------------------------------------------------------------
286
- // Merge
287
- // ---------------------------------------------------------------------------
288
-
289
- /**
290
- * Merge the four layers with plan §4.2 precedence (post phase-2):
291
- *
292
- * CLI unsets — remove named keys from below.
293
- * CLI sets — highest wins.
294
- * local — overrides profile.
295
- * profile — overrides recipe (team-shared team defaults).
296
- * recipe — base defaults.
297
- *
298
- * Per-leaf override, not per-branch replace — so CLI setting only
299
- * cmake.cache.FOO does not disturb a recipe's cmake.cache.BAR.
300
- *
301
- * The `profileFlat` parameter is optional. Pre-phase-2 callers that
302
- * pass only three layers (recipe, local, cli) still work — profile
303
- * defaults to an empty map, preserving the three-layer semantics
304
- * exactly.
305
- *
306
- * @param {Record<string,string>} recipeFlat
307
- * @param {Record<string,string>} localFlat
308
- * @param {{ sets: Record<string,string>, unsets: Set<string> }} cli
309
- * @param {Record<string,string>} [profileFlat]
310
- * @returns {Record<string,string>}
311
- */
312
- function mergeConfLayers(recipeFlat, localFlat, cli, profileFlat = {}) {
313
- const out = { ...recipeFlat, ...profileFlat, ...localFlat, ...cli.sets };
314
- for (const k of cli.unsets) delete out[k];
315
- validateFlatConfKeys(out);
316
- return out;
317
- }
318
-
319
- /**
320
- * Convenience: do the whole four-layer resolution at once. Most
321
- * callers want this. Individual layer helpers are exported for tests.
322
- *
323
- * The `profile` argument is optional. Callers that don't have a
324
- * resolved profile to hand (e.g. the legacy three-layer call sites in
325
- * `publish`, which intentionally ignore the profile tier so published
326
- * artefacts aren't skewed by dev machines) pass nothing and get the
327
- * three-layer merge (CLI > local > recipe).
328
- *
329
- * @param {object} args
330
- * @param {object} [args.manifest]
331
- * @param {string} args.rootDir
332
- * @param {string[]} [args.cliConf]
333
- * @param {object} [args.profile] Resolved named profile JSON (may carry .conf).
334
- * @returns {{
335
- * flat: Record<string,string>,
336
- * recipeFlat: Record<string,string>,
337
- * profileFlat: Record<string,string>,
338
- * localFlat: Record<string,string>,
339
- * cli: { sets: Record<string,string>, unsets: Set<string> },
340
- * localOverlayPath: string | null,
341
- * }}
342
- */
343
- function resolveEffectiveConf({ manifest, rootDir, cliConf, profile }) {
344
- const recipeFlat = readRecipeConf(manifest);
345
- const profileFlat = readProfileConf(profile);
346
- const { flat: localFlat, path: localOverlayPath } = readLocalOverlay(rootDir);
347
- const cli = parseCliConf(cliConf);
348
- const flat = mergeConfLayers(recipeFlat, localFlat, cli, profileFlat);
349
- return { flat, recipeFlat, profileFlat, localFlat, cli, localOverlayPath };
350
- }
351
-
352
- /**
353
- * Extract only the `cmake.cache.*` portion of a flat conf map as
354
- * `{ VARNAME: stringValue }` — the shape
355
- * `src/toolchain/presets.js` wants for `cacheVariables`. Stable
356
- * alphabetical key order so preset diffs are deterministic.
357
- *
358
- * @param {Record<string,string>} flatConf
359
- * @returns {Record<string,string>}
360
- */
361
- function cmakeCacheVariables(flatConf) {
362
- const out = {};
363
- const prefix = 'cmake.cache.';
364
- const keys = Object.keys(flatConf).filter((k) => k.startsWith(prefix)).sort();
365
- for (const k of keys) {
366
- out[k.slice(prefix.length)] = flatConf[k];
367
- }
368
- return out;
369
- }
370
-
371
- module.exports = {
372
- // High-level
373
- resolveEffectiveConf,
374
-
375
- // Per-layer
376
- parseCliConf,
377
- readLocalOverlay,
378
- readRecipeConf,
379
- readProfileConf,
380
- mergeConfLayers,
381
-
382
- // Shape transforms
383
- flattenConf,
384
- unflattenConf,
385
- normalizeLeafValue,
386
- cmakeCacheVariables,
387
-
388
- // Constants (exported for tests + integration)
389
- LOCAL_OVERLAY_FILENAME,
390
- LOCAL_OVERLAY_ALLOWED_TOP_KEYS,
391
- };
1
+ 'use strict';
2
+
3
+ // Non-ABI build-time configuration — the `conf` system (PLAN-CONF.md).
4
+ //
5
+ // `conf` is the sibling of `options` with the opposite hashing
6
+ // property: options fold into `profileHash` (ABI-affecting), conf
7
+ // never does (build-time knobs). See plan §2 for the rationale.
8
+ //
9
+ // This module is the pure core. It parses the four layers
10
+ // (CLI, `wyvrn.local.json`, named profile `[conf]`, recipe
11
+ // `wyvrn.json`), merges them with per-leaf precedence, and produces
12
+ // a flat `{ "namespace.leaf": value }` map ready for each sink to
13
+ // consume. It does NOT know about CMake, filesystems beyond reading
14
+ // a given JSON file, or any command — those live one level up in
15
+ // src/commands/* and src/toolchain/*.
16
+
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+
20
+ const { validateFlatConfKeys } = require('./namespaces');
21
+
22
+ const LOCAL_OVERLAY_FILENAME = 'wyvrn.local.json';
23
+
24
+ // Per plan §4.5 "Strict top-level allow-list". Two top-level keys
25
+ // today:
26
+ // - `conf` — non-ABI build-time knobs (PLAN-CONF.md).
27
+ // - `binaryDirRoot` — dev-local override of the preset's build
28
+ // subdir (sibling channel to --build-dir on
29
+ // install/build). See src/binary-dir.js.
30
+ // Adding dependencies/options/etc. is plan-2 territory.
31
+ const LOCAL_OVERLAY_ALLOWED_TOP_KEYS = new Set(['conf', 'binaryDirRoot']);
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Value normalisation
35
+ // ---------------------------------------------------------------------------
36
+
37
+ /**
38
+ * Normalise a single leaf VALUE into the string CMake (or whichever
39
+ * sink consumes it) expects. JSON `true`/`false` become `"ON"`/`"OFF"`;
40
+ * numbers are stringified; strings pass through. Arrays / objects /
41
+ * null at a leaf position are a hard error — those live at branch
42
+ * positions, not leaves.
43
+ *
44
+ * CLI values arrive as strings and are passed through verbatim.
45
+ *
46
+ * @param {unknown} value
47
+ * @param {string} keyForError
48
+ * @returns {string}
49
+ */
50
+ function normalizeLeafValue(value, keyForError) {
51
+ if (value === true) return 'ON';
52
+ if (value === false) return 'OFF';
53
+ if (typeof value === 'number' && Number.isFinite(value)) return String(value);
54
+ if (typeof value === 'string') return value;
55
+ throw new Error(
56
+ `conf value for ${JSON.stringify(keyForError)} must be a string, ` +
57
+ `number, or boolean — got ${JSON.stringify(value)}`,
58
+ );
59
+ }
60
+
61
+ /**
62
+ * Flatten a nested conf object (recipe/local form) into a flat
63
+ * `{ "ns.leaf": stringValue }` map. Keys are joined with `.`. The
64
+ * recursion stops at any non-object (strings, numbers, booleans) —
65
+ * those are leaves.
66
+ *
67
+ * Throws on:
68
+ * - null at a leaf position
69
+ * - arrays anywhere (arrays are not meaningful for cmake cache vars
70
+ * and leaving them as "maybe later" invites footguns)
71
+ * - objects whose keys contain `.` (would clash with our separator)
72
+ *
73
+ * @param {object} nested
74
+ * @param {string} [prefix]
75
+ * @param {Record<string, string>} [out]
76
+ * @returns {Record<string, string>}
77
+ */
78
+ function flattenConf(nested, prefix = '', out = {}) {
79
+ if (nested === null || typeof nested !== 'object') {
80
+ throw new Error(
81
+ `conf block must be an object — got ${JSON.stringify(nested)} at ` +
82
+ `${prefix || '<root>'}`,
83
+ );
84
+ }
85
+ if (Array.isArray(nested)) {
86
+ throw new Error(`conf block must be an object, not an array — at ${prefix || '<root>'}`);
87
+ }
88
+
89
+ for (const [rawKey, value] of Object.entries(nested)) {
90
+ if (rawKey.includes('.')) {
91
+ throw new Error(
92
+ `conf key segment ${JSON.stringify(rawKey)} must not contain a period — ` +
93
+ `use nested objects or the flat "namespace.leaf" form, not both`,
94
+ );
95
+ }
96
+ const fullKey = prefix ? `${prefix}.${rawKey}` : rawKey;
97
+
98
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
99
+ flattenConf(value, fullKey, out);
100
+ } else {
101
+ out[fullKey] = normalizeLeafValue(value, fullKey);
102
+ }
103
+ }
104
+ return out;
105
+ }
106
+
107
+ /**
108
+ * Re-expand a flat `{ "ns.leaf": value }` map back into nested form,
109
+ * for emitting into JSON manifests / lockfiles / payloads in the same
110
+ * shape authors author with. Values round-trip as strings (conf
111
+ * values are always strings post-normalisation).
112
+ *
113
+ * @param {Record<string, string>} flat
114
+ * @returns {object}
115
+ */
116
+ function unflattenConf(flat) {
117
+ const root = {};
118
+ for (const [key, value] of Object.entries(flat)) {
119
+ const parts = key.split('.');
120
+ let node = root;
121
+ for (let i = 0; i < parts.length - 1; i++) {
122
+ const seg = parts[i];
123
+ if (node[seg] === undefined || typeof node[seg] !== 'object') {
124
+ node[seg] = {};
125
+ }
126
+ node = node[seg];
127
+ }
128
+ node[parts[parts.length - 1]] = value;
129
+ }
130
+ return root;
131
+ }
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // CLI parsing
135
+ // ---------------------------------------------------------------------------
136
+
137
+ /**
138
+ * Parse an array of `--conf KEY=VALUE` strings (yargs delivers
139
+ * `argv.conf` when the option is registered as `array: true`) into a
140
+ * flat map. See plan §4.4 for semantics:
141
+ *
142
+ * KEY=VALUE → set the key to VALUE verbatim (string).
143
+ * KEY → alias for KEY=true (matches CMake's -D convention).
144
+ * KEY= → unset (remove the key from lower layers on merge).
145
+ *
146
+ * Returns two maps set values and unset keys — so the merger can
147
+ * treat the latter specially.
148
+ *
149
+ * @param {string[] | undefined} rawArgs
150
+ * @returns {{ sets: Record<string,string>, unsets: Set<string> }}
151
+ */
152
+ function parseCliConf(rawArgs) {
153
+ const sets = {};
154
+ const unsets = new Set();
155
+
156
+ if (!rawArgs || rawArgs.length === 0) {
157
+ return { sets, unsets };
158
+ }
159
+
160
+ for (const arg of rawArgs) {
161
+ if (typeof arg !== 'string') {
162
+ throw new Error(`--conf expects KEY=VALUE string; got ${JSON.stringify(arg)}`);
163
+ }
164
+ const eqIdx = arg.indexOf('=');
165
+ if (eqIdx < 0) {
166
+ // Bare `KEY` KEY=true (normalised to "ON").
167
+ const key = arg.trim();
168
+ if (key.length === 0) {
169
+ throw new Error('--conf was given an empty key');
170
+ }
171
+ sets[key] = 'ON';
172
+ } else {
173
+ const key = arg.slice(0, eqIdx).trim();
174
+ const value = arg.slice(eqIdx + 1);
175
+ if (key.length === 0) {
176
+ throw new Error(`--conf ${JSON.stringify(arg)} has an empty key`);
177
+ }
178
+ if (value.length === 0) {
179
+ // KEY= unset. Distinguish from KEY="" which callers can
180
+ // achieve by quoting the value; here the shell has already
181
+ // stripped the quotes, so an empty string past the `=` means
182
+ // "clear any inherited value for this key."
183
+ unsets.add(key);
184
+ } else {
185
+ sets[key] = value;
186
+ }
187
+ }
188
+ }
189
+
190
+ return { sets, unsets };
191
+ }
192
+
193
+ // ---------------------------------------------------------------------------
194
+ // Local-overlay file
195
+ // ---------------------------------------------------------------------------
196
+
197
+ /**
198
+ * Read and validate `wyvrn.local.json` at the given project root.
199
+ * Returns the parsed `conf` block as a flat map, or an empty map when
200
+ * the file is absent.
201
+ *
202
+ * Enforces plan §4.5: the only top-level key this file may carry in
203
+ * phase 1 is `conf`; anything else is a hard error (not a warning).
204
+ *
205
+ * @param {string} rootDir
206
+ * @returns {{ flat: Record<string,string>, path: string|null, binaryDirRoot: string|null }}
207
+ */
208
+ function readLocalOverlay(rootDir) {
209
+ const p = path.join(rootDir, LOCAL_OVERLAY_FILENAME);
210
+ if (!fs.existsSync(p)) return { flat: {}, path: null, binaryDirRoot: null };
211
+
212
+ let raw;
213
+ try {
214
+ raw = JSON.parse(fs.readFileSync(p, 'utf8'));
215
+ } catch (err) {
216
+ throw new Error(`cannot parse ${LOCAL_OVERLAY_FILENAME}: ${err.message}`);
217
+ }
218
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
219
+ throw new Error(`${LOCAL_OVERLAY_FILENAME} must contain a JSON object at the top level`);
220
+ }
221
+
222
+ for (const key of Object.keys(raw)) {
223
+ if (!LOCAL_OVERLAY_ALLOWED_TOP_KEYS.has(key)) {
224
+ const allowed = [...LOCAL_OVERLAY_ALLOWED_TOP_KEYS].join(', ');
225
+ throw new Error(
226
+ `${LOCAL_OVERLAY_FILENAME}: unknown top-level key ${JSON.stringify(key)} — ` +
227
+ `allow-list: [${allowed}]. ` +
228
+ `If you meant to override dependencies or options, that is reserved ` +
229
+ `for a future phase; see claude/PLAN-CONF.md §4.5.`,
230
+ );
231
+ }
232
+ }
233
+
234
+ const confBlock = raw.conf ?? {};
235
+ const flat = flattenConf(confBlock);
236
+ validateFlatConfKeys(flat);
237
+
238
+ // `binaryDirRoot` is only validated for type/shape here; the actual
239
+ // path-syntax check (no abs, no `..`, etc.) lives in src/binary-dir.js
240
+ // which has the canonical ruleset. Returning the raw string keeps the
241
+ // two modules decoupled.
242
+ const binaryDirRoot = Object.prototype.hasOwnProperty.call(raw, 'binaryDirRoot')
243
+ ? raw.binaryDirRoot
244
+ : null;
245
+
246
+ return { flat, path: p, binaryDirRoot };
247
+ }
248
+
249
+ // ---------------------------------------------------------------------------
250
+ // Recipe extraction
251
+ // ---------------------------------------------------------------------------
252
+
253
+ /**
254
+ * Pull the `conf` block out of an already-loaded recipe manifest and
255
+ * flatten it. A missing block is an empty map.
256
+ *
257
+ * Validation runs against the phase-1 allow-list; if a recipe declares
258
+ * a namespace we don't support yet, the install fails with a clear
259
+ * error rather than silently dropping the config.
260
+ *
261
+ * @param {object} manifest
262
+ * @returns {Record<string,string>}
263
+ */
264
+ function readRecipeConf(manifest) {
265
+ const block = manifest?.conf ?? null;
266
+ if (block === null || block === undefined) return {};
267
+ const flat = flattenConf(block);
268
+ validateFlatConfKeys(flat);
269
+ return flat;
270
+ }
271
+
272
+ // ---------------------------------------------------------------------------
273
+ // Profile extraction
274
+ // ---------------------------------------------------------------------------
275
+
276
+ /**
277
+ * Pull the optional `conf` block out of a loaded named profile JSON
278
+ * (see src/profile.js) and flatten it. A missing block is an empty
279
+ * map profiles without `conf` round-trip byte-identically to
280
+ * pre-phase-2 behaviour.
281
+ *
282
+ * Validation runs against the phase-1 allow-list; an unknown
283
+ * namespace in a profile fails fast rather than silently dropping
284
+ * the config (CLAUDE.md §2 principle 8).
285
+ *
286
+ * @param {object|null|undefined} profile
287
+ * @returns {Record<string,string>}
288
+ */
289
+ function readProfileConf(profile) {
290
+ const block = profile?.conf ?? null;
291
+ if (block === null || block === undefined) return {};
292
+ const flat = flattenConf(block);
293
+ validateFlatConfKeys(flat);
294
+ return flat;
295
+ }
296
+
297
+ // ---------------------------------------------------------------------------
298
+ // Merge
299
+ // ---------------------------------------------------------------------------
300
+
301
+ /**
302
+ * Merge the four layers with plan §4.2 precedence (post phase-2):
303
+ *
304
+ * CLI unsets — remove named keys from below.
305
+ * CLI sets — highest wins.
306
+ * local — overrides profile.
307
+ * profile — overrides recipe (team-shared team defaults).
308
+ * recipe — base defaults.
309
+ *
310
+ * Per-leaf override, not per-branch replace — so CLI setting only
311
+ * cmake.cache.FOO does not disturb a recipe's cmake.cache.BAR.
312
+ *
313
+ * The `profileFlat` parameter is optional. Pre-phase-2 callers that
314
+ * pass only three layers (recipe, local, cli) still work — profile
315
+ * defaults to an empty map, preserving the three-layer semantics
316
+ * exactly.
317
+ *
318
+ * @param {Record<string,string>} recipeFlat
319
+ * @param {Record<string,string>} localFlat
320
+ * @param {{ sets: Record<string,string>, unsets: Set<string> }} cli
321
+ * @param {Record<string,string>} [profileFlat]
322
+ * @returns {Record<string,string>}
323
+ */
324
+ function mergeConfLayers(recipeFlat, localFlat, cli, profileFlat = {}) {
325
+ const out = { ...recipeFlat, ...profileFlat, ...localFlat, ...cli.sets };
326
+ for (const k of cli.unsets) delete out[k];
327
+ validateFlatConfKeys(out);
328
+ return out;
329
+ }
330
+
331
+ /**
332
+ * Convenience: do the whole four-layer resolution at once. Most
333
+ * callers want this. Individual layer helpers are exported for tests.
334
+ *
335
+ * The `profile` argument is optional. Callers that don't have a
336
+ * resolved profile to hand (e.g. the legacy three-layer call sites in
337
+ * `publish`, which intentionally ignore the profile tier so published
338
+ * artefacts aren't skewed by dev machines) pass nothing and get the
339
+ * three-layer merge (CLI > local > recipe).
340
+ *
341
+ * @param {object} args
342
+ * @param {object} [args.manifest]
343
+ * @param {string} args.rootDir
344
+ * @param {string[]} [args.cliConf]
345
+ * @param {object} [args.profile] Resolved named profile JSON (may carry .conf).
346
+ * @returns {{
347
+ * flat: Record<string,string>,
348
+ * recipeFlat: Record<string,string>,
349
+ * profileFlat: Record<string,string>,
350
+ * localFlat: Record<string,string>,
351
+ * cli: { sets: Record<string,string>, unsets: Set<string> },
352
+ * localOverlayPath: string | null,
353
+ * }}
354
+ */
355
+ function resolveEffectiveConf({ manifest, rootDir, cliConf, profile }) {
356
+ const recipeFlat = readRecipeConf(manifest);
357
+ const profileFlat = readProfileConf(profile);
358
+ const overlay = readLocalOverlay(rootDir);
359
+ const { flat: localFlat, path: localOverlayPath, binaryDirRoot: localBinaryDirRoot } = overlay;
360
+ const cli = parseCliConf(cliConf);
361
+ const flat = mergeConfLayers(recipeFlat, localFlat, cli, profileFlat);
362
+ return {
363
+ flat,
364
+ recipeFlat,
365
+ profileFlat,
366
+ localFlat,
367
+ cli,
368
+ localOverlayPath,
369
+ // Surfaced so install can route this into src/binary-dir.js without
370
+ // re-reading the overlay file. Validated downstream — this just
371
+ // forwards the raw value the user wrote.
372
+ localBinaryDirRoot,
373
+ };
374
+ }
375
+
376
+ /**
377
+ * Extract only the `cmake.cache.*` portion of a flat conf map as
378
+ * `{ VARNAME: stringValue }` — the shape
379
+ * `src/toolchain/presets.js` wants for `cacheVariables`. Stable
380
+ * alphabetical key order so preset diffs are deterministic.
381
+ *
382
+ * @param {Record<string,string>} flatConf
383
+ * @returns {Record<string,string>}
384
+ */
385
+ function cmakeCacheVariables(flatConf) {
386
+ const out = {};
387
+ const prefix = 'cmake.cache.';
388
+ const keys = Object.keys(flatConf).filter((k) => k.startsWith(prefix)).sort();
389
+ for (const k of keys) {
390
+ out[k.slice(prefix.length)] = flatConf[k];
391
+ }
392
+ return out;
393
+ }
394
+
395
+ module.exports = {
396
+ // High-level
397
+ resolveEffectiveConf,
398
+
399
+ // Per-layer
400
+ parseCliConf,
401
+ readLocalOverlay,
402
+ readRecipeConf,
403
+ readProfileConf,
404
+ mergeConfLayers,
405
+
406
+ // Shape transforms
407
+ flattenConf,
408
+ unflattenConf,
409
+ normalizeLeafValue,
410
+ cmakeCacheVariables,
411
+
412
+ // Constants (exported for tests + integration)
413
+ LOCAL_OVERLAY_FILENAME,
414
+ LOCAL_OVERLAY_ALLOWED_TOP_KEYS,
415
+ };