wyvrnpm 2.1.0 → 2.3.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/src/download.js CHANGED
@@ -11,6 +11,7 @@ const { sha256Of } = require('./profile');
11
11
  const { getProvider } = require('./providers');
12
12
  const { evaluateCompat, pickBestCandidate, summarizeReasons } = require('./compat');
13
13
  const { buildFromSource } = require('./build');
14
+ const { uploadSourceBuiltArtefact } = require('./upload-built');
14
15
  const log = require('./logger');
15
16
 
16
17
  // ---------------------------------------------------------------------------
@@ -177,7 +178,7 @@ async function tryV2Exact(dep, packageSources, destZipPath, timeoutMs, authOptio
177
178
  * meta: object,
178
179
  * }|null>}
179
180
  */
180
- async function findCompatibleBuild({ name, version, consumerProfile, versionsIndex, source, authOptions = {} }) {
181
+ async function findCompatibleBuild({ name, version, consumerProfile, consumerOptions, versionsIndex, source, authOptions = {} }) {
181
182
  const { awsProfile, token } = authOptions;
182
183
  let provider;
183
184
  try { provider = getProvider(source); } catch { return null; }
@@ -185,6 +186,11 @@ async function findCompatibleBuild({ name, version, consumerProfile, versionsInd
185
186
  const versionEntry = versionsIndex?.versions?.[version];
186
187
  if (!versionEntry?.profiles) return null;
187
188
 
189
+ // Consumer side includes options so the compat evaluator can see them.
190
+ const consumerFull = consumerOptions
191
+ ? { ...consumerProfile, options: consumerOptions }
192
+ : consumerProfile;
193
+
188
194
  const candidates = [];
189
195
  for (const [profileHash, buildEntry] of Object.entries(versionEntry.profiles)) {
190
196
  const packageProfile = buildEntry.buildSettings ?? buildEntry.profile;
@@ -194,8 +200,16 @@ async function findCompatibleBuild({ name, version, consumerProfile, versionsInd
194
200
  const meta = await provider.v2GetMeta({ source, name, version, profileHash, awsProfile, token });
195
201
  if (!meta) continue;
196
202
 
203
+ // Package-side options may live on the nested buildSettings.options (new
204
+ // publish path) or as a sibling key when publish-time metadata diverges.
205
+ const packageFull = packageProfile.options
206
+ ? packageProfile
207
+ : (meta.buildSettings?.options
208
+ ? { ...packageProfile, options: meta.buildSettings.options }
209
+ : packageProfile);
210
+
197
211
  const rawCompat = meta.compatibility ?? null;
198
- const { compatible, reasons } = evaluateCompat(consumerProfile, packageProfile, rawCompat);
212
+ const { compatible, reasons } = evaluateCompat(consumerFull, packageFull, rawCompat);
199
213
  if (compatible) {
200
214
  candidates.push({ profileHash, packageProfile, reasons, buildEntry, meta });
201
215
  }
@@ -339,7 +353,14 @@ async function extractZip(destZipPath, extractDir) {
339
353
  */
340
354
  async function downloadDependencies(deps, packageSources, platform, razerDir, httpClient, timeout, options = {}) {
341
355
  const timeoutMs = timeout * 1000;
342
- const { awsProfile, token, buildMode = 'never', profileName = 'default' } = options;
356
+ const {
357
+ awsProfile, token, buildMode = 'never', profileName = 'default',
358
+ uploadBuilt = false, uploadSource, uploadAuth,
359
+ // Optional accumulator. When provided, upload outcomes are pushed here
360
+ // so the caller can print a single aggregated summary at the end of an
361
+ // install (see src/commands/install.js). Mutated in place.
362
+ uploadStats,
363
+ } = options;
343
364
 
344
365
  if (buildMode === 'always') {
345
366
  throw new Error(
@@ -363,6 +384,7 @@ async function downloadDependencies(deps, packageSources, platform, razerDir, ht
363
384
  const version = isLegacyEntry ? depInfoOrVersion : depInfoOrVersion.version;
364
385
  const profileHash = isLegacyEntry ? null : (depInfoOrVersion.profileHash ?? null);
365
386
  const profile = isLegacyEntry ? null : (depInfoOrVersion.profile ?? null);
387
+ const options = isLegacyEntry ? null : (depInfoOrVersion.options ?? null);
366
388
 
367
389
  const extractDir = path.join(razerDir, name);
368
390
  const versionFile = path.join(extractDir, '.wyvrn-version');
@@ -396,6 +418,7 @@ async function downloadDependencies(deps, packageSources, platform, razerDir, ht
396
418
  contentSha256: installedMeta?.contentSha256 ?? null,
397
419
  gitSha: installedMeta?.gitSha ?? null,
398
420
  resolvedFrom: installedMeta?.resolvedFrom ?? 'cached',
421
+ ...(options ? { options } : {}),
399
422
  });
400
423
  continue;
401
424
  }
@@ -438,6 +461,7 @@ async function downloadDependencies(deps, packageSources, platform, razerDir, ht
438
461
  const compatResult = await findCompatibleBuild({
439
462
  name, version,
440
463
  consumerProfile: profile,
464
+ consumerOptions: options,
441
465
  versionsIndex: v2.versionsIndex,
442
466
  source: v2.indexFoundInSource,
443
467
  authOptions: { awsProfile, token },
@@ -474,6 +498,7 @@ async function downloadDependencies(deps, packageSources, platform, razerDir, ht
474
498
  const built = await buildFromSource({
475
499
  name, version,
476
500
  profile, profileName, profileHash,
501
+ options,
477
502
  gitRepo: sourceInfo.gitRepo,
478
503
  gitSha: sourceInfo.gitSha,
479
504
  destZipPath,
@@ -489,6 +514,10 @@ async function downloadDependencies(deps, packageSources, platform, razerDir, ht
489
514
  gitRepo: built.gitRepo,
490
515
  profileHash: built.profileHash,
491
516
  source: built.source,
517
+ // Carried through so the post-extraction upload step can
518
+ // read the manifest and push the zip without re-doing work.
519
+ artefactPath: built.artefactPath,
520
+ clonedManifestPath: built.clonedManifestPath,
492
521
  };
493
522
  resolvedFrom = 'v2-source-build';
494
523
  } catch (err) {
@@ -602,7 +631,40 @@ async function downloadDependencies(deps, packageSources, platform, razerDir, ht
602
631
  profileHash: downloadResult?.profileHash ?? null,
603
632
  contentSha256: downloadResult?.contentSha256 ?? null,
604
633
  gitSha: downloadResult?.gitSha ?? null,
634
+ ...(options ? { options } : {}),
605
635
  });
636
+
637
+ // ── Upload freshly source-built artefact back to the registry ────────
638
+ // Opt-in via --upload-built. Runs after extraction succeeds so a
639
+ // bad artefact is never uploaded. Upload failures log but never
640
+ // break the install — the consumer already has a working install.
641
+ if (uploadBuilt && resolvedFrom === 'v2-source-build' && downloadResult?.artefactPath) {
642
+ try {
643
+ const outcome = await uploadSourceBuiltArtefact({
644
+ name, version,
645
+ profile, profileHash,
646
+ artefactPath: downloadResult.artefactPath,
647
+ clonedManifestPath: downloadResult.clonedManifestPath,
648
+ contentSha256: downloadResult.contentSha256,
649
+ gitRepo: downloadResult.gitRepo,
650
+ gitSha: downloadResult.gitSha,
651
+ options,
652
+ uploadSource,
653
+ uploadAuth: uploadAuth ?? { awsProfile, token },
654
+ });
655
+ if (uploadStats) {
656
+ const bucket = outcome.uploaded
657
+ ? 'uploaded'
658
+ : /already exists/.test(outcome.reason) ? 'skipped' : 'failed';
659
+ uploadStats[bucket].push({ name, version, profileHash, reason: outcome.reason });
660
+ }
661
+ } catch (err) {
662
+ log.warn(`upload-built: ${name}@${version} upload skipped — ${err.message}`);
663
+ if (uploadStats) {
664
+ uploadStats.failed.push({ name, version, profileHash, reason: err.message });
665
+ }
666
+ }
667
+ }
606
668
  } catch (err) {
607
669
  log.warn(`Failed to extract ${name}@${version}: ${err.message}`);
608
670
  lockEntries.set(name, { version, resolvedFrom: 'extract-failed' });
package/src/ignore.js ADDED
@@ -0,0 +1,118 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // .wyvrnignore matcher — follows .gitignore semantics as closely as is
7
+ // practical for a static-archive filter. Supported:
8
+ //
9
+ // - Blank lines and lines starting with `#` are ignored.
10
+ // - A leading `!` negates the match — re-includes a path a prior pattern
11
+ // excluded. Last matching pattern wins.
12
+ // - A trailing `/` makes the pattern directory-only: it matches directory
13
+ // entries only, never files. This is the single most common gitignore
14
+ // idiom (`build/`, `.git/`) so we honour it rather than treating the
15
+ // slash as a literal character.
16
+ // - A leading `/` anchors the pattern to the project root.
17
+ // - A `/` anywhere else in the pattern (excluding the trailing one) also
18
+ // anchors it to the root — same rule as gitignore.
19
+ // - Otherwise the pattern floats: it matches at any directory depth.
20
+ // - Globs: `*` is single-segment, `**` crosses segments, `?` is one char.
21
+ // - Literal leading `!` or `#` can be escaped with `\!` / `\#`.
22
+ //
23
+ // Not supported (unlike gitignore): pattern re-evaluation after a parent
24
+ // directory has been excluded (gitignore can't re-include a file whose
25
+ // parent directory was excluded — we don't recurse into excluded dirs
26
+ // either, so the behaviour lines up in practice).
27
+ // ---------------------------------------------------------------------------
28
+
29
+ function parsePattern(raw) {
30
+ let p = raw;
31
+ if (p == null) return null;
32
+ p = p.replace(/\s+$/, ''); // trim trailing whitespace (gitignore rule)
33
+ if (p.length === 0) return null;
34
+ if (p.startsWith('#')) return null;
35
+
36
+ let negate = false;
37
+ if (p.startsWith('!')) {
38
+ negate = true;
39
+ p = p.slice(1);
40
+ } else if (p.startsWith('\\!') || p.startsWith('\\#')) {
41
+ // escaped literal leading ! or #
42
+ p = p.slice(1);
43
+ }
44
+
45
+ let dirOnly = false;
46
+ if (p.length > 1 && p.endsWith('/')) {
47
+ dirOnly = true;
48
+ p = p.slice(0, -1);
49
+ }
50
+
51
+ const leadingSlash = p.startsWith('/');
52
+ if (leadingSlash) p = p.slice(1);
53
+
54
+ // A `/` anywhere in the (remaining) pattern anchors it to the root.
55
+ // Bare names like `build` or `*.log` float — they match at any depth.
56
+ const anchored = leadingSlash || p.includes('/');
57
+
58
+ const re = p
59
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
60
+ .replace(/\*\*/g, '\x00')
61
+ .replace(/\*/g, '[^/]*')
62
+ .replace(/\?/g, '[^/]')
63
+ .replace(/\x00/g, '.*');
64
+
65
+ const regex = anchored
66
+ ? new RegExp(`^${re}(/|$)`)
67
+ : new RegExp(`(^|/)${re}(/|$)`);
68
+
69
+ return { raw, regex, negate, dirOnly };
70
+ }
71
+
72
+ function loadIgnorePatterns(ignoreFile) {
73
+ if (!fs.existsSync(ignoreFile)) return [];
74
+ return fs
75
+ .readFileSync(ignoreFile, 'utf8')
76
+ .split(/\r?\n/)
77
+ .map(parsePattern)
78
+ .filter(Boolean);
79
+ }
80
+
81
+ /**
82
+ * Determine whether a path should be excluded.
83
+ * @param {string} relPath Forward-slash-separated path relative to the root.
84
+ * @param {boolean} isDir True if the path is a directory.
85
+ * @param {Array} patterns Parsed patterns (from loadIgnorePatterns).
86
+ * @returns {boolean}
87
+ *
88
+ * Semantics follow gitignore: a dir-only pattern (`build/`) matches the
89
+ * directory itself AND every descendant. We honour this by testing each
90
+ * ancestor-prefix of the path against dir-only patterns — matters for
91
+ * callers who probe arbitrary paths (tests, library users) rather than
92
+ * walking the tree top-down (where the walker naturally stops recursing
93
+ * once it sees the excluded parent).
94
+ */
95
+ function isIgnored(relPath, isDir, patterns) {
96
+ let ignored = false;
97
+ for (const { regex, negate, dirOnly } of patterns) {
98
+ let matched;
99
+ if (dirOnly) {
100
+ // Check the path itself (if it's a directory) and every ancestor dir.
101
+ matched = isDir && regex.test(relPath);
102
+ if (!matched) {
103
+ const parts = relPath.split('/');
104
+ let prefix = '';
105
+ for (let i = 0; i < parts.length - 1; i++) {
106
+ prefix = prefix ? `${prefix}/${parts[i]}` : parts[i];
107
+ if (regex.test(prefix)) { matched = true; break; }
108
+ }
109
+ }
110
+ } else {
111
+ matched = regex.test(relPath);
112
+ }
113
+ if (matched) ignored = !negate;
114
+ }
115
+ return ignored;
116
+ }
117
+
118
+ module.exports = { parsePattern, loadIgnorePatterns, isIgnored };
package/src/logger.js CHANGED
@@ -21,7 +21,22 @@ const ANSI = {
21
21
  cyan: '\x1b[36m',
22
22
  };
23
23
 
24
+ // When a command is emitting a JSON payload on stdout (F7 `--format=json`),
25
+ // stdout must be reserved exclusively for that payload. In that mode we
26
+ // route info/success to stderr too and drop colour so stderr stays
27
+ // log-ingestion-friendly.
28
+ let jsonMode = false;
29
+
30
+ function setJsonMode(enabled) {
31
+ jsonMode = Boolean(enabled);
32
+ }
33
+
34
+ function isJsonMode() {
35
+ return jsonMode;
36
+ }
37
+
24
38
  function colorEnabled(stream) {
39
+ if (jsonMode) return false;
25
40
  if (process.env.NO_COLOR) return false;
26
41
  if (process.env.FORCE_COLOR) return true;
27
42
  return !!(stream && stream.isTTY);
@@ -42,12 +57,10 @@ function emit(stream, consoleFn, coloredPrefix, message) {
42
57
  }
43
58
 
44
59
  function info(message) {
45
- emit(
46
- process.stdout,
47
- console.log,
48
- paint(PREFIX, ANSI.dim, process.stdout),
49
- message,
50
- );
60
+ // In JSON mode stdout is reserved for the final payload — route info to stderr.
61
+ const stream = jsonMode ? process.stderr : process.stdout;
62
+ const consoleFn = jsonMode ? console.error : console.log;
63
+ emit(stream, consoleFn, paint(PREFIX, ANSI.dim, stream), message);
51
64
  }
52
65
 
53
66
  function warn(message) {
@@ -63,9 +76,12 @@ function error(message) {
63
76
  }
64
77
 
65
78
  function success(message) {
66
- const p = paint(PREFIX, ANSI.green, process.stdout);
67
- const tag = paint('ok', ANSI.green + ANSI.bold, process.stdout);
68
- emit(process.stdout, console.log, `${p} ${tag}`, message);
79
+ // In JSON mode stdout is reserved for the final payload — route success to stderr.
80
+ const stream = jsonMode ? process.stderr : process.stdout;
81
+ const consoleFn = jsonMode ? console.error : console.log;
82
+ const p = paint(PREFIX, ANSI.green, stream);
83
+ const tag = paint('ok', ANSI.green + ANSI.bold, stream);
84
+ emit(stream, consoleFn, `${p} ${tag}`, message);
69
85
  }
70
86
 
71
87
  function debug(message) {
@@ -75,4 +91,4 @@ function debug(message) {
75
91
  emit(process.stderr, console.error, `${p} ${tag}`, message);
76
92
  }
77
93
 
78
- module.exports = { info, warn, error, success, debug };
94
+ module.exports = { info, warn, error, success, debug, setJsonMode, isJsonMode };
package/src/manifest.js CHANGED
@@ -52,14 +52,18 @@ function defaultManifest(name) {
52
52
 
53
53
  /**
54
54
  * Normalise the raw `dependencies` field from a manifest into a consistent
55
- * `{ name → { version, settings } }` map, regardless of the input format:
55
+ * `{ name → { version, settings, options } }` map, regardless of input format:
56
56
  *
57
- * • String value : "1.0.0" → { version: "1.0.0", settings: {} }
58
- * • Object value : { version, settings? } → kept (settings defaults to {})
59
- * • Array format : [{ Name, Version }] → converted
57
+ * • String value : "1.0.0" → { version: "1.0.0", settings: {}, options: {} }
58
+ * • Object value : { version, settings?, options? } → kept (missing keys default to {})
59
+ * • Array format : [{ Name, Version }] → converted (no settings/options)
60
+ *
61
+ * `settings` merges on top of the active profile for this dep only.
62
+ * `options` maps to the recipe's declared options; validated against
63
+ * the recipe's `allowed` list at resolve time (see src/options.js).
60
64
  *
61
65
  * @param {object|Array|undefined} rawDeps
62
- * @returns {Record<string, { version: string, settings: object }>}
66
+ * @returns {Record<string, { version: string, settings: object, options: object }>}
63
67
  */
64
68
  function normalizeDependencies(rawDeps) {
65
69
  if (!rawDeps || typeof rawDeps !== 'object') return {};
@@ -69,7 +73,7 @@ function normalizeDependencies(rawDeps) {
69
73
  for (const d of rawDeps) {
70
74
  const n = d.Name ?? d.name;
71
75
  const v = d.Version ?? d.version;
72
- if (n && v) result[n] = { version: v, settings: {} };
76
+ if (n && v) result[n] = { version: v, settings: {}, options: {} };
73
77
  }
74
78
  return result;
75
79
  }
@@ -77,11 +81,12 @@ function normalizeDependencies(rawDeps) {
77
81
  const result = {};
78
82
  for (const [n, value] of Object.entries(rawDeps)) {
79
83
  if (typeof value === 'string') {
80
- result[n] = { version: value, settings: {} };
84
+ result[n] = { version: value, settings: {}, options: {} };
81
85
  } else if (value && typeof value === 'object') {
82
86
  result[n] = {
83
87
  version: value.version ?? value.Version ?? '',
84
88
  settings: value.settings ?? {},
89
+ options: value.options ?? {},
85
90
  };
86
91
  }
87
92
  }
package/src/options.js ADDED
@@ -0,0 +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
+ };