wyvrnpm 2.11.0 → 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.
- package/README.md +1914 -1905
- package/bin/{wyvrn.js → wyvrnpm.js} +31 -0
- package/cmake/cpp.cmake +9 -9
- package/cmake/functions.cmake +224 -224
- package/cmake/macros.cmake +284 -284
- package/package.json +3 -2
- package/src/auth.js +66 -66
- package/src/binary-dir.js +95 -0
- package/src/bootstrap/cookbook.js +196 -196
- package/src/bootstrap/detect.js +150 -150
- package/src/bootstrap/index.js +220 -220
- package/src/bootstrap/version.js +72 -72
- package/src/build/cache.js +344 -344
- package/src/build/clone.js +170 -170
- package/src/build/cmake.js +342 -342
- package/src/build/index.js +299 -297
- package/src/build/msvc-env.js +260 -260
- package/src/build/recipe.js +188 -188
- package/src/commands/add.js +141 -141
- package/src/commands/bootstrap.js +96 -96
- package/src/commands/build.js +482 -452
- package/src/commands/cache.js +189 -189
- package/src/commands/clean.js +80 -80
- package/src/commands/configure.js +92 -92
- package/src/commands/init.js +70 -70
- package/src/commands/install-skill.js +115 -115
- package/src/commands/install.js +730 -674
- package/src/commands/link.js +320 -320
- package/src/commands/profile.js +237 -237
- package/src/commands/publish.js +584 -555
- package/src/commands/show.js +252 -252
- package/src/commands/version.js +187 -187
- package/src/compat.js +273 -273
- package/src/conf/index.js +415 -391
- package/src/conf/namespaces.js +94 -94
- package/src/context.js +230 -230
- package/src/http-fetch.js +53 -53
- package/src/ignore.js +118 -118
- package/src/logger.js +122 -122
- package/src/options.js +303 -303
- package/src/settings-overrides.js +152 -152
- package/src/toolchain/deps.js +164 -164
- package/src/toolchain/index.js +212 -212
- package/src/toolchain/presets.js +356 -340
- package/src/toolchain/template.cmake +77 -77
- package/src/upload-built.js +265 -265
- package/src/version-range.js +301 -301
- package/src/zip-safe.js +52 -52
- package/src/zip-stream.js +126 -0
package/src/options.js
CHANGED
|
@@ -1,303 +1,303 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Recipe-declared options: `wyvrn.json` gains a top-level `options` block
|
|
5
|
-
* that declares feature toggles the package exposes (shared/static,
|
|
6
|
-
* `minizip` on zlib, FIPS on OpenSSL, etc.). Consumers override per-dep
|
|
7
|
-
* via `dependencies.<name>.options` or on the CLI via `-o <name>:<key>=<value>`.
|
|
8
|
-
*
|
|
9
|
-
* Options fold into `profileHash` (via `hashProfile`), so every distinct
|
|
10
|
-
* option combination gets its own artefact on the registry.
|
|
11
|
-
*
|
|
12
|
-
* Scope:
|
|
13
|
-
* - Recipe declares options with `default` + `allowed` per name.
|
|
14
|
-
* - Consumer can override any declared option to any `allowed` value.
|
|
15
|
-
* - No transitive propagation for MVP (see PLAN-OPTIONS §2).
|
|
16
|
-
* - Values are `boolean` or string-enum; no integers/lists.
|
|
17
|
-
*
|
|
18
|
-
* See claude/PLAN-OPTIONS.md for the full design and
|
|
19
|
-
* claude/PLAN-OPTIONS-EXAMPLE.md for a worked zlib walkthrough.
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
// Option names are conservative — lower-case, start with a letter. Prevents
|
|
23
|
-
// collisions with future reserved keywords (`options.schemaVersion` etc.)
|
|
24
|
-
const OPTION_NAME_RE = /^[a-z][a-z0-9_-]*$/;
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Validate and normalise a recipe's `options` block. Returns a canonical
|
|
28
|
-
* form (a fresh object with the declared options preserved, `allowed`
|
|
29
|
-
* arrays duplicated defensively), or null if the recipe has no options.
|
|
30
|
-
*
|
|
31
|
-
* Throws with a precise, user-facing message on any structural error.
|
|
32
|
-
*
|
|
33
|
-
* @param {object|null|undefined} rawOptions - the `options` field from a recipe wyvrn.json
|
|
34
|
-
* @param {string} [pkgContext] - e.g. "zlib@1.3.0.0" — included in error messages
|
|
35
|
-
* @returns {Record<string, { default: any, allowed: any[] }>|null}
|
|
36
|
-
*/
|
|
37
|
-
function normalizeOptionsDeclaration(rawOptions, pkgContext = '') {
|
|
38
|
-
if (rawOptions === null || rawOptions === undefined) return null;
|
|
39
|
-
const where = pkgContext ? ` in ${pkgContext}` : '';
|
|
40
|
-
|
|
41
|
-
if (typeof rawOptions !== 'object' || Array.isArray(rawOptions)) {
|
|
42
|
-
throw new Error(`\`options\`${where} must be an object mapping option names to { default, allowed } specs`);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
const result = {};
|
|
46
|
-
for (const [name, spec] of Object.entries(rawOptions)) {
|
|
47
|
-
if (!OPTION_NAME_RE.test(name)) {
|
|
48
|
-
throw new Error(
|
|
49
|
-
`option name "${name}"${where} is invalid — must match /^[a-z][a-z0-9_-]*$/`,
|
|
50
|
-
);
|
|
51
|
-
}
|
|
52
|
-
if (!spec || typeof spec !== 'object' || Array.isArray(spec)) {
|
|
53
|
-
throw new Error(`options.${name}${where} must be an object of the form { default: <value>, allowed: [<value>, ...] }`);
|
|
54
|
-
}
|
|
55
|
-
if (!('default' in spec) || !('allowed' in spec)) {
|
|
56
|
-
throw new Error(
|
|
57
|
-
`options.${name}${where} is missing the \`default\` or \`allowed\` field\n` +
|
|
58
|
-
` shape expected: { "default": <value>, "allowed": [<value>, ...] }`,
|
|
59
|
-
);
|
|
60
|
-
}
|
|
61
|
-
if (!Array.isArray(spec.allowed) || spec.allowed.length === 0) {
|
|
62
|
-
throw new Error(`options.${name}.allowed${where} must be a non-empty array`);
|
|
63
|
-
}
|
|
64
|
-
for (const v of spec.allowed) {
|
|
65
|
-
const t = typeof v;
|
|
66
|
-
if (t !== 'boolean' && t !== 'string') {
|
|
67
|
-
throw new Error(
|
|
68
|
-
`options.${name}.allowed${where} values must be boolean or string (got ${t}: ${JSON.stringify(v)})`,
|
|
69
|
-
);
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
if (!spec.allowed.includes(spec.default)) {
|
|
73
|
-
throw new Error(
|
|
74
|
-
`options.${name}.default = ${JSON.stringify(spec.default)}${where} is not in allowed: ${JSON.stringify(spec.allowed)}`,
|
|
75
|
-
);
|
|
76
|
-
}
|
|
77
|
-
result[name] = { default: spec.default, allowed: spec.allowed.slice() };
|
|
78
|
-
}
|
|
79
|
-
return result;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* Compute the effective (resolved) options for a dep by starting from the
|
|
84
|
-
* recipe's declared defaults, overlaying the consumer's wyvrn.json
|
|
85
|
-
* overrides, then overlaying CLI `-o` overrides. Each override is
|
|
86
|
-
* validated against the recipe's `allowed` list.
|
|
87
|
-
*
|
|
88
|
-
* Returns null when the recipe has no declared options. Returning null
|
|
89
|
-
* (vs an empty object) is how `hashProfile` distinguishes "no options"
|
|
90
|
-
* from "options declared but all at defaults" — the former produces the
|
|
91
|
-
* pre-F1 hash for backward compatibility.
|
|
92
|
-
*
|
|
93
|
-
* @param {object|null} declaration — normalised recipe options (from `normalizeOptionsDeclaration`)
|
|
94
|
-
* @param {object} [consumerOverrides] — dependencies.<name>.options from consumer wyvrn.json
|
|
95
|
-
* @param {object} [cliOverrides] — scoped to this package, extracted from `-o` flags
|
|
96
|
-
* @param {string} [pkgContext] — "zlib@1.3.0.0" style, for error messages
|
|
97
|
-
* @returns {Record<string, any>|null}
|
|
98
|
-
*/
|
|
99
|
-
function resolveEffectiveOptions(declaration, consumerOverrides = {}, cliOverrides = {}, pkgContext = '') {
|
|
100
|
-
if (!declaration) {
|
|
101
|
-
// Recipe didn't declare options → consumer must not supply any
|
|
102
|
-
const extra = new Set([
|
|
103
|
-
...Object.keys(consumerOverrides ?? {}),
|
|
104
|
-
...Object.keys(cliOverrides ?? {}),
|
|
105
|
-
]);
|
|
106
|
-
if (extra.size > 0) {
|
|
107
|
-
throw new Error(
|
|
108
|
-
`${pkgContext || 'package'} has no declared options, but received overrides: ${[...extra].join(', ')}`,
|
|
109
|
-
);
|
|
110
|
-
}
|
|
111
|
-
return null;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
const declaredNames = Object.keys(declaration);
|
|
115
|
-
|
|
116
|
-
const validateSource = (overrides, sourceLabel) => {
|
|
117
|
-
for (const [key, value] of Object.entries(overrides ?? {})) {
|
|
118
|
-
if (!(key in declaration)) {
|
|
119
|
-
const suggestion = findClosest(key, declaredNames);
|
|
120
|
-
const hint = suggestion ? `\n Did you mean "${suggestion}"?` : '';
|
|
121
|
-
throw new Error(
|
|
122
|
-
`unknown option "${key}" for ${pkgContext || 'package'} (from ${sourceLabel})\n` +
|
|
123
|
-
` declared options: ${declaredNames.join(', ')}${hint}`,
|
|
124
|
-
);
|
|
125
|
-
}
|
|
126
|
-
if (!declaration[key].allowed.includes(value)) {
|
|
127
|
-
throw new Error(
|
|
128
|
-
`option ${pkgContext ? pkgContext + '.' : ''}${key} = ${JSON.stringify(value)} is not allowed (from ${sourceLabel})\n` +
|
|
129
|
-
` allowed values: ${declaration[key].allowed.map((v) => JSON.stringify(v)).join(', ')}`,
|
|
130
|
-
);
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
};
|
|
134
|
-
validateSource(consumerOverrides, 'wyvrn.json');
|
|
135
|
-
validateSource(cliOverrides, '--option CLI flag');
|
|
136
|
-
|
|
137
|
-
const effective = {};
|
|
138
|
-
for (const [name, spec] of Object.entries(declaration)) {
|
|
139
|
-
effective[name] = spec.default;
|
|
140
|
-
if (consumerOverrides && name in consumerOverrides) effective[name] = consumerOverrides[name];
|
|
141
|
-
if (cliOverrides && name in cliOverrides) effective[name] = cliOverrides[name];
|
|
142
|
-
}
|
|
143
|
-
return effective;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/**
|
|
147
|
-
* Parse the `--option`/`-o` CLI flag values into a nested map.
|
|
148
|
-
*
|
|
149
|
-
* ["zlib:minizip=false", "openssl:fips=true"]
|
|
150
|
-
* ↓
|
|
151
|
-
* { zlib: { minizip: false }, openssl: { fips: true } }
|
|
152
|
-
*
|
|
153
|
-
* Rules:
|
|
154
|
-
* - Split once on ":" (package name cannot contain ":", enforced by
|
|
155
|
-
* manifest validation elsewhere).
|
|
156
|
-
* - Split the right side once on "=" so string values may contain "=".
|
|
157
|
-
* - Coerce "true"/"false" (case-insensitive) to booleans; everything
|
|
158
|
-
* else stays a string. Downstream validation against the recipe's
|
|
159
|
-
* `allowed` list catches illegal values.
|
|
160
|
-
* - Empty package name, option name, or value → usage error.
|
|
161
|
-
*
|
|
162
|
-
* @param {string|string[]|undefined} rawFlags
|
|
163
|
-
* @returns {Record<string, Record<string, any>>}
|
|
164
|
-
*/
|
|
165
|
-
function parseCliOptions(rawFlags) {
|
|
166
|
-
if (!rawFlags) return {};
|
|
167
|
-
const arr = Array.isArray(rawFlags) ? rawFlags : [rawFlags];
|
|
168
|
-
const result = {};
|
|
169
|
-
for (const entry of arr) {
|
|
170
|
-
if (typeof entry !== 'string' || entry.length === 0) continue;
|
|
171
|
-
const colonIdx = entry.indexOf(':');
|
|
172
|
-
if (colonIdx <= 0) {
|
|
173
|
-
throw new Error(`--option "${entry}" is malformed — expected <pkg>:<name>=<value>`);
|
|
174
|
-
}
|
|
175
|
-
const pkg = entry.slice(0, colonIdx);
|
|
176
|
-
const rest = entry.slice(colonIdx + 1);
|
|
177
|
-
const eqIdx = rest.indexOf('=');
|
|
178
|
-
if (eqIdx <= 0 || eqIdx === rest.length - 1) {
|
|
179
|
-
throw new Error(`--option "${entry}" is malformed — expected <pkg>:<name>=<value>`);
|
|
180
|
-
}
|
|
181
|
-
const name = rest.slice(0, eqIdx);
|
|
182
|
-
const value = rest.slice(eqIdx + 1);
|
|
183
|
-
if (!result[pkg]) result[pkg] = {};
|
|
184
|
-
result[pkg][name] = coerceCliValue(value);
|
|
185
|
-
}
|
|
186
|
-
return result;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
function coerceCliValue(raw) {
|
|
190
|
-
const lc = raw.toLowerCase();
|
|
191
|
-
if (lc === 'true') return true;
|
|
192
|
-
if (lc === 'false') return false;
|
|
193
|
-
return raw;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
/**
|
|
197
|
-
* Template-expand `${options.<name>}` occurrences in a single string using
|
|
198
|
-
* the supplied `effective` options map. Bool values coerce to CMake's
|
|
199
|
-
* `ON`/`OFF`; string values expand verbatim.
|
|
200
|
-
*
|
|
201
|
-
* A missing variable (`${options.fips}` with no `fips` in effective) is
|
|
202
|
-
* a hard error — a typo in the recipe must fail fast at source-build
|
|
203
|
-
* configure time, not silently reach cmake as a literal unexpanded
|
|
204
|
-
* string. If the recipe declares no options at all, every template
|
|
205
|
-
* reference fails for the same reason.
|
|
206
|
-
*
|
|
207
|
-
* @param {string} arg a single argv element (e.g. `-DSHARED=${options.shared}`)
|
|
208
|
-
* @param {Record<string, any>|null} effective
|
|
209
|
-
* @param {string} [context] — for the error message (`build.configure`, `build.buildArgs`)
|
|
210
|
-
* @returns {string}
|
|
211
|
-
*/
|
|
212
|
-
function substituteTemplate(arg, effective, context = '') {
|
|
213
|
-
if (typeof arg !== 'string') return arg;
|
|
214
|
-
return arg.replace(/\$\{options\.([a-z][a-z0-9_-]*)\}/g, (_full, name) => {
|
|
215
|
-
if (!effective || !(name in effective)) {
|
|
216
|
-
const declared = effective ? Object.keys(effective) : [];
|
|
217
|
-
const declaredLine = declared.length > 0
|
|
218
|
-
? `\n declared options: ${declared.join(', ')}`
|
|
219
|
-
: '\n (no options declared on this recipe)';
|
|
220
|
-
throw new Error(
|
|
221
|
-
`${context ? context + ' ' : ''}references \${options.${name}} but no such option is declared${declaredLine}`,
|
|
222
|
-
);
|
|
223
|
-
}
|
|
224
|
-
const v = effective[name];
|
|
225
|
-
if (v === true) return 'ON';
|
|
226
|
-
if (v === false) return 'OFF';
|
|
227
|
-
return String(v);
|
|
228
|
-
});
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
/**
|
|
232
|
-
* Apply `substituteTemplate` across an array of argv elements.
|
|
233
|
-
*
|
|
234
|
-
* @param {string[]} args
|
|
235
|
-
* @param {Record<string, any>|null} effective
|
|
236
|
-
* @param {string} [context]
|
|
237
|
-
* @returns {string[]}
|
|
238
|
-
*/
|
|
239
|
-
function substituteTemplateAll(args, effective, context = '') {
|
|
240
|
-
if (!Array.isArray(args)) return args;
|
|
241
|
-
return args.map((a) => substituteTemplate(a, effective, context));
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
/**
|
|
245
|
-
* Return a new object with the same keys sorted alphabetically. Used as
|
|
246
|
-
* the canonical form for hashing.
|
|
247
|
-
*
|
|
248
|
-
* @param {object|null} options
|
|
249
|
-
* @returns {object|null}
|
|
250
|
-
*/
|
|
251
|
-
function sortAndNormaliseOptions(options) {
|
|
252
|
-
if (!options) return null;
|
|
253
|
-
return Object.fromEntries(
|
|
254
|
-
Object.entries(options).sort(([a], [b]) => a.localeCompare(b)),
|
|
255
|
-
);
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
// ---------------------------------------------------------------------------
|
|
259
|
-
// "Did you mean" suggestion helper — Levenshtein with a small-distance cap.
|
|
260
|
-
// ---------------------------------------------------------------------------
|
|
261
|
-
|
|
262
|
-
function findClosest(needle, haystack) {
|
|
263
|
-
if (!Array.isArray(haystack) || haystack.length === 0) return null;
|
|
264
|
-
const cap = Math.max(2, Math.floor(needle.length / 3));
|
|
265
|
-
let best = null;
|
|
266
|
-
let bestDist = Infinity;
|
|
267
|
-
for (const candidate of haystack) {
|
|
268
|
-
const d = levenshtein(needle.toLowerCase(), candidate.toLowerCase());
|
|
269
|
-
if (d < bestDist && d <= cap) {
|
|
270
|
-
best = candidate;
|
|
271
|
-
bestDist = d;
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
return best;
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
function levenshtein(a, b) {
|
|
278
|
-
const m = a.length;
|
|
279
|
-
const n = b.length;
|
|
280
|
-
if (m === 0) return n;
|
|
281
|
-
if (n === 0) return m;
|
|
282
|
-
let prev = new Array(n + 1);
|
|
283
|
-
for (let j = 0; j <= n; j++) prev[j] = j;
|
|
284
|
-
for (let i = 1; i <= m; i++) {
|
|
285
|
-
const curr = [i];
|
|
286
|
-
for (let j = 1; j <= n; j++) {
|
|
287
|
-
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
288
|
-
curr.push(Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost));
|
|
289
|
-
}
|
|
290
|
-
prev = curr;
|
|
291
|
-
}
|
|
292
|
-
return prev[n];
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
module.exports = {
|
|
296
|
-
normalizeOptionsDeclaration,
|
|
297
|
-
resolveEffectiveOptions,
|
|
298
|
-
parseCliOptions,
|
|
299
|
-
sortAndNormaliseOptions,
|
|
300
|
-
substituteTemplate,
|
|
301
|
-
substituteTemplateAll,
|
|
302
|
-
OPTION_NAME_RE,
|
|
303
|
-
};
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Recipe-declared options: `wyvrn.json` gains a top-level `options` block
|
|
5
|
+
* that declares feature toggles the package exposes (shared/static,
|
|
6
|
+
* `minizip` on zlib, FIPS on OpenSSL, etc.). Consumers override per-dep
|
|
7
|
+
* via `dependencies.<name>.options` or on the CLI via `-o <name>:<key>=<value>`.
|
|
8
|
+
*
|
|
9
|
+
* Options fold into `profileHash` (via `hashProfile`), so every distinct
|
|
10
|
+
* option combination gets its own artefact on the registry.
|
|
11
|
+
*
|
|
12
|
+
* Scope:
|
|
13
|
+
* - Recipe declares options with `default` + `allowed` per name.
|
|
14
|
+
* - Consumer can override any declared option to any `allowed` value.
|
|
15
|
+
* - No transitive propagation for MVP (see PLAN-OPTIONS §2).
|
|
16
|
+
* - Values are `boolean` or string-enum; no integers/lists.
|
|
17
|
+
*
|
|
18
|
+
* See claude/PLAN-OPTIONS.md for the full design and
|
|
19
|
+
* claude/PLAN-OPTIONS-EXAMPLE.md for a worked zlib walkthrough.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
// Option names are conservative — lower-case, start with a letter. Prevents
|
|
23
|
+
// collisions with future reserved keywords (`options.schemaVersion` etc.)
|
|
24
|
+
const OPTION_NAME_RE = /^[a-z][a-z0-9_-]*$/;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Validate and normalise a recipe's `options` block. Returns a canonical
|
|
28
|
+
* form (a fresh object with the declared options preserved, `allowed`
|
|
29
|
+
* arrays duplicated defensively), or null if the recipe has no options.
|
|
30
|
+
*
|
|
31
|
+
* Throws with a precise, user-facing message on any structural error.
|
|
32
|
+
*
|
|
33
|
+
* @param {object|null|undefined} rawOptions - the `options` field from a recipe wyvrn.json
|
|
34
|
+
* @param {string} [pkgContext] - e.g. "zlib@1.3.0.0" — included in error messages
|
|
35
|
+
* @returns {Record<string, { default: any, allowed: any[] }>|null}
|
|
36
|
+
*/
|
|
37
|
+
function normalizeOptionsDeclaration(rawOptions, pkgContext = '') {
|
|
38
|
+
if (rawOptions === null || rawOptions === undefined) return null;
|
|
39
|
+
const where = pkgContext ? ` in ${pkgContext}` : '';
|
|
40
|
+
|
|
41
|
+
if (typeof rawOptions !== 'object' || Array.isArray(rawOptions)) {
|
|
42
|
+
throw new Error(`\`options\`${where} must be an object mapping option names to { default, allowed } specs`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const result = {};
|
|
46
|
+
for (const [name, spec] of Object.entries(rawOptions)) {
|
|
47
|
+
if (!OPTION_NAME_RE.test(name)) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`option name "${name}"${where} is invalid — must match /^[a-z][a-z0-9_-]*$/`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
if (!spec || typeof spec !== 'object' || Array.isArray(spec)) {
|
|
53
|
+
throw new Error(`options.${name}${where} must be an object of the form { default: <value>, allowed: [<value>, ...] }`);
|
|
54
|
+
}
|
|
55
|
+
if (!('default' in spec) || !('allowed' in spec)) {
|
|
56
|
+
throw new Error(
|
|
57
|
+
`options.${name}${where} is missing the \`default\` or \`allowed\` field\n` +
|
|
58
|
+
` shape expected: { "default": <value>, "allowed": [<value>, ...] }`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
if (!Array.isArray(spec.allowed) || spec.allowed.length === 0) {
|
|
62
|
+
throw new Error(`options.${name}.allowed${where} must be a non-empty array`);
|
|
63
|
+
}
|
|
64
|
+
for (const v of spec.allowed) {
|
|
65
|
+
const t = typeof v;
|
|
66
|
+
if (t !== 'boolean' && t !== 'string') {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`options.${name}.allowed${where} values must be boolean or string (got ${t}: ${JSON.stringify(v)})`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (!spec.allowed.includes(spec.default)) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
`options.${name}.default = ${JSON.stringify(spec.default)}${where} is not in allowed: ${JSON.stringify(spec.allowed)}`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
result[name] = { default: spec.default, allowed: spec.allowed.slice() };
|
|
78
|
+
}
|
|
79
|
+
return result;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Compute the effective (resolved) options for a dep by starting from the
|
|
84
|
+
* recipe's declared defaults, overlaying the consumer's wyvrn.json
|
|
85
|
+
* overrides, then overlaying CLI `-o` overrides. Each override is
|
|
86
|
+
* validated against the recipe's `allowed` list.
|
|
87
|
+
*
|
|
88
|
+
* Returns null when the recipe has no declared options. Returning null
|
|
89
|
+
* (vs an empty object) is how `hashProfile` distinguishes "no options"
|
|
90
|
+
* from "options declared but all at defaults" — the former produces the
|
|
91
|
+
* pre-F1 hash for backward compatibility.
|
|
92
|
+
*
|
|
93
|
+
* @param {object|null} declaration — normalised recipe options (from `normalizeOptionsDeclaration`)
|
|
94
|
+
* @param {object} [consumerOverrides] — dependencies.<name>.options from consumer wyvrn.json
|
|
95
|
+
* @param {object} [cliOverrides] — scoped to this package, extracted from `-o` flags
|
|
96
|
+
* @param {string} [pkgContext] — "zlib@1.3.0.0" style, for error messages
|
|
97
|
+
* @returns {Record<string, any>|null}
|
|
98
|
+
*/
|
|
99
|
+
function resolveEffectiveOptions(declaration, consumerOverrides = {}, cliOverrides = {}, pkgContext = '') {
|
|
100
|
+
if (!declaration) {
|
|
101
|
+
// Recipe didn't declare options → consumer must not supply any
|
|
102
|
+
const extra = new Set([
|
|
103
|
+
...Object.keys(consumerOverrides ?? {}),
|
|
104
|
+
...Object.keys(cliOverrides ?? {}),
|
|
105
|
+
]);
|
|
106
|
+
if (extra.size > 0) {
|
|
107
|
+
throw new Error(
|
|
108
|
+
`${pkgContext || 'package'} has no declared options, but received overrides: ${[...extra].join(', ')}`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const declaredNames = Object.keys(declaration);
|
|
115
|
+
|
|
116
|
+
const validateSource = (overrides, sourceLabel) => {
|
|
117
|
+
for (const [key, value] of Object.entries(overrides ?? {})) {
|
|
118
|
+
if (!(key in declaration)) {
|
|
119
|
+
const suggestion = findClosest(key, declaredNames);
|
|
120
|
+
const hint = suggestion ? `\n Did you mean "${suggestion}"?` : '';
|
|
121
|
+
throw new Error(
|
|
122
|
+
`unknown option "${key}" for ${pkgContext || 'package'} (from ${sourceLabel})\n` +
|
|
123
|
+
` declared options: ${declaredNames.join(', ')}${hint}`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
if (!declaration[key].allowed.includes(value)) {
|
|
127
|
+
throw new Error(
|
|
128
|
+
`option ${pkgContext ? pkgContext + '.' : ''}${key} = ${JSON.stringify(value)} is not allowed (from ${sourceLabel})\n` +
|
|
129
|
+
` allowed values: ${declaration[key].allowed.map((v) => JSON.stringify(v)).join(', ')}`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
validateSource(consumerOverrides, 'wyvrn.json');
|
|
135
|
+
validateSource(cliOverrides, '--option CLI flag');
|
|
136
|
+
|
|
137
|
+
const effective = {};
|
|
138
|
+
for (const [name, spec] of Object.entries(declaration)) {
|
|
139
|
+
effective[name] = spec.default;
|
|
140
|
+
if (consumerOverrides && name in consumerOverrides) effective[name] = consumerOverrides[name];
|
|
141
|
+
if (cliOverrides && name in cliOverrides) effective[name] = cliOverrides[name];
|
|
142
|
+
}
|
|
143
|
+
return effective;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Parse the `--option`/`-o` CLI flag values into a nested map.
|
|
148
|
+
*
|
|
149
|
+
* ["zlib:minizip=false", "openssl:fips=true"]
|
|
150
|
+
* ↓
|
|
151
|
+
* { zlib: { minizip: false }, openssl: { fips: true } }
|
|
152
|
+
*
|
|
153
|
+
* Rules:
|
|
154
|
+
* - Split once on ":" (package name cannot contain ":", enforced by
|
|
155
|
+
* manifest validation elsewhere).
|
|
156
|
+
* - Split the right side once on "=" so string values may contain "=".
|
|
157
|
+
* - Coerce "true"/"false" (case-insensitive) to booleans; everything
|
|
158
|
+
* else stays a string. Downstream validation against the recipe's
|
|
159
|
+
* `allowed` list catches illegal values.
|
|
160
|
+
* - Empty package name, option name, or value → usage error.
|
|
161
|
+
*
|
|
162
|
+
* @param {string|string[]|undefined} rawFlags
|
|
163
|
+
* @returns {Record<string, Record<string, any>>}
|
|
164
|
+
*/
|
|
165
|
+
function parseCliOptions(rawFlags) {
|
|
166
|
+
if (!rawFlags) return {};
|
|
167
|
+
const arr = Array.isArray(rawFlags) ? rawFlags : [rawFlags];
|
|
168
|
+
const result = {};
|
|
169
|
+
for (const entry of arr) {
|
|
170
|
+
if (typeof entry !== 'string' || entry.length === 0) continue;
|
|
171
|
+
const colonIdx = entry.indexOf(':');
|
|
172
|
+
if (colonIdx <= 0) {
|
|
173
|
+
throw new Error(`--option "${entry}" is malformed — expected <pkg>:<name>=<value>`);
|
|
174
|
+
}
|
|
175
|
+
const pkg = entry.slice(0, colonIdx);
|
|
176
|
+
const rest = entry.slice(colonIdx + 1);
|
|
177
|
+
const eqIdx = rest.indexOf('=');
|
|
178
|
+
if (eqIdx <= 0 || eqIdx === rest.length - 1) {
|
|
179
|
+
throw new Error(`--option "${entry}" is malformed — expected <pkg>:<name>=<value>`);
|
|
180
|
+
}
|
|
181
|
+
const name = rest.slice(0, eqIdx);
|
|
182
|
+
const value = rest.slice(eqIdx + 1);
|
|
183
|
+
if (!result[pkg]) result[pkg] = {};
|
|
184
|
+
result[pkg][name] = coerceCliValue(value);
|
|
185
|
+
}
|
|
186
|
+
return result;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function coerceCliValue(raw) {
|
|
190
|
+
const lc = raw.toLowerCase();
|
|
191
|
+
if (lc === 'true') return true;
|
|
192
|
+
if (lc === 'false') return false;
|
|
193
|
+
return raw;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Template-expand `${options.<name>}` occurrences in a single string using
|
|
198
|
+
* the supplied `effective` options map. Bool values coerce to CMake's
|
|
199
|
+
* `ON`/`OFF`; string values expand verbatim.
|
|
200
|
+
*
|
|
201
|
+
* A missing variable (`${options.fips}` with no `fips` in effective) is
|
|
202
|
+
* a hard error — a typo in the recipe must fail fast at source-build
|
|
203
|
+
* configure time, not silently reach cmake as a literal unexpanded
|
|
204
|
+
* string. If the recipe declares no options at all, every template
|
|
205
|
+
* reference fails for the same reason.
|
|
206
|
+
*
|
|
207
|
+
* @param {string} arg a single argv element (e.g. `-DSHARED=${options.shared}`)
|
|
208
|
+
* @param {Record<string, any>|null} effective
|
|
209
|
+
* @param {string} [context] — for the error message (`build.configure`, `build.buildArgs`)
|
|
210
|
+
* @returns {string}
|
|
211
|
+
*/
|
|
212
|
+
function substituteTemplate(arg, effective, context = '') {
|
|
213
|
+
if (typeof arg !== 'string') return arg;
|
|
214
|
+
return arg.replace(/\$\{options\.([a-z][a-z0-9_-]*)\}/g, (_full, name) => {
|
|
215
|
+
if (!effective || !(name in effective)) {
|
|
216
|
+
const declared = effective ? Object.keys(effective) : [];
|
|
217
|
+
const declaredLine = declared.length > 0
|
|
218
|
+
? `\n declared options: ${declared.join(', ')}`
|
|
219
|
+
: '\n (no options declared on this recipe)';
|
|
220
|
+
throw new Error(
|
|
221
|
+
`${context ? context + ' ' : ''}references \${options.${name}} but no such option is declared${declaredLine}`,
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
const v = effective[name];
|
|
225
|
+
if (v === true) return 'ON';
|
|
226
|
+
if (v === false) return 'OFF';
|
|
227
|
+
return String(v);
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Apply `substituteTemplate` across an array of argv elements.
|
|
233
|
+
*
|
|
234
|
+
* @param {string[]} args
|
|
235
|
+
* @param {Record<string, any>|null} effective
|
|
236
|
+
* @param {string} [context]
|
|
237
|
+
* @returns {string[]}
|
|
238
|
+
*/
|
|
239
|
+
function substituteTemplateAll(args, effective, context = '') {
|
|
240
|
+
if (!Array.isArray(args)) return args;
|
|
241
|
+
return args.map((a) => substituteTemplate(a, effective, context));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Return a new object with the same keys sorted alphabetically. Used as
|
|
246
|
+
* the canonical form for hashing.
|
|
247
|
+
*
|
|
248
|
+
* @param {object|null} options
|
|
249
|
+
* @returns {object|null}
|
|
250
|
+
*/
|
|
251
|
+
function sortAndNormaliseOptions(options) {
|
|
252
|
+
if (!options) return null;
|
|
253
|
+
return Object.fromEntries(
|
|
254
|
+
Object.entries(options).sort(([a], [b]) => a.localeCompare(b)),
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ---------------------------------------------------------------------------
|
|
259
|
+
// "Did you mean" suggestion helper — Levenshtein with a small-distance cap.
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
|
|
262
|
+
function findClosest(needle, haystack) {
|
|
263
|
+
if (!Array.isArray(haystack) || haystack.length === 0) return null;
|
|
264
|
+
const cap = Math.max(2, Math.floor(needle.length / 3));
|
|
265
|
+
let best = null;
|
|
266
|
+
let bestDist = Infinity;
|
|
267
|
+
for (const candidate of haystack) {
|
|
268
|
+
const d = levenshtein(needle.toLowerCase(), candidate.toLowerCase());
|
|
269
|
+
if (d < bestDist && d <= cap) {
|
|
270
|
+
best = candidate;
|
|
271
|
+
bestDist = d;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return best;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function levenshtein(a, b) {
|
|
278
|
+
const m = a.length;
|
|
279
|
+
const n = b.length;
|
|
280
|
+
if (m === 0) return n;
|
|
281
|
+
if (n === 0) return m;
|
|
282
|
+
let prev = new Array(n + 1);
|
|
283
|
+
for (let j = 0; j <= n; j++) prev[j] = j;
|
|
284
|
+
for (let i = 1; i <= m; i++) {
|
|
285
|
+
const curr = [i];
|
|
286
|
+
for (let j = 1; j <= n; j++) {
|
|
287
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
288
|
+
curr.push(Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost));
|
|
289
|
+
}
|
|
290
|
+
prev = curr;
|
|
291
|
+
}
|
|
292
|
+
return prev[n];
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
module.exports = {
|
|
296
|
+
normalizeOptionsDeclaration,
|
|
297
|
+
resolveEffectiveOptions,
|
|
298
|
+
parseCliOptions,
|
|
299
|
+
sortAndNormaliseOptions,
|
|
300
|
+
substituteTemplate,
|
|
301
|
+
substituteTemplateAll,
|
|
302
|
+
OPTION_NAME_RE,
|
|
303
|
+
};
|