wyvrnpm 2.3.2 → 2.8.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.
- package/README.md +465 -19
- package/bin/wyvrn.js +175 -9
- package/claude/skills/wyvrnpm.skill +0 -0
- package/cmake/macros.cmake +51 -0
- package/cmake/variables.cmake +13 -3
- package/package.json +1 -1
- package/src/auth.js +66 -0
- package/src/build/cache.js +196 -0
- package/src/build/index.js +23 -2
- package/src/build/msvc-env.js +50 -7
- package/src/build/recipe.js +36 -3
- package/src/commands/add.js +140 -0
- package/src/commands/build.js +111 -7
- package/src/commands/cache.js +189 -0
- package/src/commands/configure.js +15 -4
- package/src/commands/init.js +36 -0
- package/src/commands/install-skill.js +8 -0
- package/src/commands/install.js +271 -5
- package/src/commands/profile.js +70 -7
- package/src/commands/publish.js +41 -4
- package/src/commands/show.js +14 -4
- package/src/compat.js +21 -9
- package/src/conf/index.js +391 -0
- package/src/conf/namespaces.js +94 -0
- package/src/download.js +136 -148
- package/src/logger.js +30 -2
- package/src/manifest.js +34 -7
- package/src/profile.js +148 -12
- package/src/resolve.js +47 -7
- package/src/settings-overrides.js +152 -0
- package/src/toolchain/index.js +71 -0
- package/src/toolchain/presets.js +108 -31
- package/src/toolchain/template.cmake +11 -0
- package/src/upload-built.js +11 -2
- package/src/zip-safe.js +52 -0
|
@@ -0,0 +1,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". 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
|
+
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Allow-list of recognised `conf` namespaces (PLAN-CONF.md §4.1).
|
|
4
|
+
//
|
|
5
|
+
// Adding a new namespace is an intentional act — the plan commits to
|
|
6
|
+
// expanding this file only when a concrete use case lands, not
|
|
7
|
+
// speculatively. Phase 1 ships `cmake.cache.*` only; every other
|
|
8
|
+
// namespace (env.*, build.*, tool.*) is reserved but not yet valid.
|
|
9
|
+
//
|
|
10
|
+
// Each entry describes how leaf names under that namespace are
|
|
11
|
+
// validated. A namespace with `leaf: true` accepts any non-empty leaf
|
|
12
|
+
// name; a namespace with `leaf: /regex/` enforces the regex at load
|
|
13
|
+
// time. The regex only validates the leaf name — leaf VALUES are
|
|
14
|
+
// normalised separately in ../conf/index.js.
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @typedef {Object} NamespaceSpec
|
|
18
|
+
* @property {RegExp} leaf
|
|
19
|
+
* Pattern that every leaf name under this namespace must satisfy.
|
|
20
|
+
* @property {string} description
|
|
21
|
+
* Human-readable one-liner shown in error messages.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** @type {Record<string, NamespaceSpec>} */
|
|
25
|
+
const NAMESPACES = {
|
|
26
|
+
'cmake.cache': {
|
|
27
|
+
// CMake variable names: C/C++ identifier rules, no hyphens.
|
|
28
|
+
leaf: /^[A-Za-z_][A-Za-z0-9_]*$/,
|
|
29
|
+
description: 'CMake cache variables — baked into CMakePresets.json cacheVariables.',
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Parse a full dotted conf key (e.g. `cmake.cache.CHROMA_ENABLE_TEST`)
|
|
35
|
+
* into its namespace prefix and leaf name. The namespace is the
|
|
36
|
+
* longest matching prefix registered in NAMESPACES; anything after is
|
|
37
|
+
* the leaf.
|
|
38
|
+
*
|
|
39
|
+
* Returns `{ namespace, leaf }` on success, `null` when the key does
|
|
40
|
+
* not belong to any registered namespace or has no leaf component.
|
|
41
|
+
*
|
|
42
|
+
* @param {string} key
|
|
43
|
+
* @returns {{ namespace: string, leaf: string } | null}
|
|
44
|
+
*/
|
|
45
|
+
function splitKey(key) {
|
|
46
|
+
// Try longer namespaces first so "a.b.c" prefers namespace "a.b"
|
|
47
|
+
// over a hypothetical namespace "a".
|
|
48
|
+
const known = Object.keys(NAMESPACES).sort((a, b) => b.length - a.length);
|
|
49
|
+
for (const ns of known) {
|
|
50
|
+
const prefix = `${ns}.`;
|
|
51
|
+
if (key.startsWith(prefix)) {
|
|
52
|
+
const leaf = key.slice(prefix.length);
|
|
53
|
+
if (leaf.length === 0) return null;
|
|
54
|
+
return { namespace: ns, leaf };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Validate a flat `{key: value}` map against the namespace allow-list.
|
|
62
|
+
* Throws on the first offender (fail-fast, matches the Zip Slip
|
|
63
|
+
* validator's style). Leaf VALUES are not type-checked here — values
|
|
64
|
+
* arrive as strings from the CLI, normalised from JSON elsewhere; type
|
|
65
|
+
* enforcement happens in ../conf/index.js at merge time.
|
|
66
|
+
*
|
|
67
|
+
* @param {Record<string, string>} flatConf
|
|
68
|
+
* @throws {Error} if any key's namespace or leaf is rejected.
|
|
69
|
+
*/
|
|
70
|
+
function validateFlatConfKeys(flatConf) {
|
|
71
|
+
const knownList = Object.keys(NAMESPACES).sort().join(', ');
|
|
72
|
+
for (const key of Object.keys(flatConf)) {
|
|
73
|
+
const parts = splitKey(key);
|
|
74
|
+
if (!parts) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
`unknown conf namespace in ${JSON.stringify(key)} — ` +
|
|
77
|
+
`phase-1 allow-list: [${knownList}]`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
const spec = NAMESPACES[parts.namespace];
|
|
81
|
+
if (!spec.leaf.test(parts.leaf)) {
|
|
82
|
+
throw new Error(
|
|
83
|
+
`invalid leaf name ${JSON.stringify(parts.leaf)} for conf namespace ` +
|
|
84
|
+
`"${parts.namespace}" — ${spec.description}`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
module.exports = {
|
|
91
|
+
NAMESPACES,
|
|
92
|
+
splitKey,
|
|
93
|
+
validateFlatConfKeys,
|
|
94
|
+
};
|