wyvrnpm 2.4.1 → 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/src/profile.js CHANGED
@@ -162,6 +162,13 @@ function detectProfile() {
162
162
  };
163
163
  }
164
164
 
165
+ // Profile fields that participate in profileHash — mirror Conan's
166
+ // `[settings]` strictly. Anything else at the top level (e.g. `conf`)
167
+ // is non-ABI and must never fold into the hash (PLAN-CONF.md constitution).
168
+ const HASHED_PROFILE_FIELDS = new Set([
169
+ 'os', 'arch', 'compiler', 'compiler.version', 'compiler.cppstd', 'compiler.runtime',
170
+ ]);
171
+
165
172
  /**
166
173
  * Compute a stable 16-char SHA256 prefix identifying a unique build.
167
174
  * Inputs:
@@ -169,14 +176,19 @@ function detectProfile() {
169
176
  * - `options`: (optional) recipe-declared feature toggles resolved to
170
177
  * their effective values for this build.
171
178
  *
172
- * Backward-compat contract: when `options` is null / undefined / empty,
173
- * the hash is byte-identical to what pre-F1 wyvrnpm produced. Packages
174
- * without an `options` block keep their existing registry addresses.
175
- * The caller therefore passes `null` (not `{}`) to mean "this recipe
176
- * has no options declared" consistent with what
177
- * `resolveEffectiveOptions` returns in that case.
179
+ * Backward-compat contract:
180
+ * - When `options` is null / undefined / empty, the hash is
181
+ * byte-identical to what pre-F1 wyvrnpm produced. Packages
182
+ * without an `options` block keep their existing registry
183
+ * addresses. The caller passes `null` (not `{}`) to mean "this
184
+ * recipe has no options declared" — consistent with what
185
+ * `resolveEffectiveOptions` returns in that case.
186
+ * - When a profile carries a `conf` block (phase 2 — PLAN-CONF.md),
187
+ * the hash is byte-identical to the same profile WITHOUT `conf`.
188
+ * Build-time knobs are non-ABI by design (constitution).
178
189
  *
179
- * See claude/PLAN-OPTIONS.md §3.2 for the full design.
190
+ * See claude/PLAN-OPTIONS.md §3.2 and claude/PLAN-CONF.md for the
191
+ * full design.
180
192
  *
181
193
  * @param {WyvrnProfile} profile
182
194
  * @param {Record<string, any>|null} [options] effective options (after defaults + overrides)
@@ -185,7 +197,10 @@ function detectProfile() {
185
197
  function hashProfile(profile, options = null) {
186
198
  const significant = Object.fromEntries(
187
199
  Object.entries(profile)
188
- .filter(([, v]) => v !== null && v !== undefined && v !== '*' && v !== '')
200
+ .filter(([k, v]) =>
201
+ HASHED_PROFILE_FIELDS.has(k) &&
202
+ v !== null && v !== undefined && v !== '*' && v !== '',
203
+ )
189
204
  .sort(([a], [b]) => a.localeCompare(b)),
190
205
  );
191
206
 
@@ -267,6 +282,8 @@ function isShaOnRemote(sha, cwd) {
267
282
 
268
283
  /**
269
284
  * Format a profile for human-readable display (Conan-style).
285
+ * Emits a `[conf]` block with flat dotted keys when the profile
286
+ * carries a non-empty `conf` block (PLAN-CONF.md phase 2).
270
287
  *
271
288
  * @param {WyvrnProfile} profile
272
289
  * @returns {string}
@@ -282,6 +299,23 @@ function formatProfile(profile) {
282
299
  lines.push(`${key}=${val}`);
283
300
  }
284
301
  }
302
+
303
+ // [conf] block, Conan-style. Rendered flat so it's clear these are
304
+ // dotted namespaced keys, not nested JSON. Alphabetical for stable diffs.
305
+ const confBlock = profile.conf;
306
+ if (confBlock && typeof confBlock === 'object' && !Array.isArray(confBlock)) {
307
+ // Lazy-require to avoid circular dep (src/conf/* → src/manifest → src/profile).
308
+ const { flattenConf } = require('./conf');
309
+ let flat;
310
+ try { flat = flattenConf(confBlock); } catch { flat = null; }
311
+ if (flat && Object.keys(flat).length > 0) {
312
+ lines.push('[conf]');
313
+ for (const key of Object.keys(flat).sort()) {
314
+ lines.push(`${key}=${flat[key]}`);
315
+ }
316
+ }
317
+ }
318
+
285
319
  return lines.join('\n');
286
320
  }
287
321
 
@@ -386,15 +420,116 @@ function listProfiles() {
386
420
  }
387
421
 
388
422
  /**
389
- * Load the active build profile by name. Falls back to auto-detection if the
390
- * named profile file does not exist yet.
423
+ * Resolve a profile-inheritance chain (F8). A profile may declare
424
+ *
425
+ * { "extends": "base-profile", ... }
426
+ * { "extends": ["base1", "base2"], ... } // later wins over earlier
427
+ *
428
+ * `extends` is resolved recursively; the child's fields overlay the
429
+ * merged parent(s). Cycle detection throws a clear error. Missing
430
+ * parents throw — we don't silently drop `extends`, that would hide
431
+ * typos.
432
+ *
433
+ * Merge rules:
434
+ * - `[settings]` fields: child overrides parent, per-key.
435
+ * - `conf` block: parent + child flattened with child wins per-leaf
436
+ * (same pattern as PLAN-CONF.md §4.2 within the single profile).
437
+ * - `extends` itself is stripped from the resolved output — the
438
+ * returned object carries no inheritance metadata, so existing
439
+ * consumers (formatProfile, hashProfile, readProfileConf) work
440
+ * unchanged.
441
+ *
442
+ * @param {string} name
443
+ * @param {string[]} [stack] visited names — for cycle detection
444
+ * @returns {WyvrnProfile|null} Fully-resolved profile, or null if `name`
445
+ * does not exist on disk at the root of the chain.
446
+ */
447
+ function resolveProfileChain(name, stack = []) {
448
+ if (stack.includes(name)) {
449
+ throw new Error(
450
+ `profile inheritance cycle: ${[...stack, name].join(' → ')}`,
451
+ );
452
+ }
453
+
454
+ const raw = readProfile(name);
455
+ if (!raw) return null;
456
+
457
+ const rawExtends = raw.extends;
458
+ if (rawExtends === undefined || rawExtends === null) {
459
+ // Flat profile — just strip any accidental `extends` and return.
460
+ const { extends: _, ...rest } = raw;
461
+ return rest;
462
+ }
463
+
464
+ const parents = Array.isArray(rawExtends) ? rawExtends : [rawExtends];
465
+ for (const p of parents) {
466
+ if (typeof p !== 'string' || p.length === 0) {
467
+ throw new Error(
468
+ `profile "${name}": extends entries must be non-empty strings, got ${JSON.stringify(p)}`,
469
+ );
470
+ }
471
+ }
472
+
473
+ // Merge parents left-to-right, then overlay the child.
474
+ let merged = {};
475
+ for (const parentName of parents) {
476
+ const parent = resolveProfileChain(parentName, [...stack, name]);
477
+ if (!parent) {
478
+ throw new Error(
479
+ `profile "${name}": extends parent "${parentName}" not found ` +
480
+ `at ${getProfilesDir()}`,
481
+ );
482
+ }
483
+ merged = mergeResolvedProfiles(merged, parent);
484
+ }
485
+
486
+ const { extends: _, ...child } = raw;
487
+ return mergeResolvedProfiles(merged, child);
488
+ }
489
+
490
+ /**
491
+ * Merge two already-resolved profile objects. Top-level `[settings]`
492
+ * fields: right wins. `conf` block: per-leaf merge, right wins.
493
+ * @param {object} base
494
+ * @param {object} overlay
495
+ * @returns {object}
496
+ */
497
+ function mergeResolvedProfiles(base, overlay) {
498
+ const out = { ...base, ...overlay };
499
+ // Nullable override semantics: `null` on the overlay explicitly
500
+ // unsets a field from the base (matches how Conan profiles
501
+ // handle `compiler.runtime=None`).
502
+ for (const k of Object.keys(overlay)) {
503
+ if (overlay[k] === null) delete out[k];
504
+ }
505
+ // Conf block is merged per-leaf, not per-branch.
506
+ if (base.conf || overlay.conf) {
507
+ const { flattenConf, unflattenConf } = require('./conf');
508
+ let baseFlat = {};
509
+ let overlayFlat = {};
510
+ try { if (base.conf) baseFlat = flattenConf(base.conf); } catch { /* leave empty */ }
511
+ try { if (overlay.conf) overlayFlat = flattenConf(overlay.conf); } catch { /* leave empty */ }
512
+ const merged = { ...baseFlat, ...overlayFlat };
513
+ if (Object.keys(merged).length > 0) {
514
+ out.conf = unflattenConf(merged);
515
+ } else {
516
+ delete out.conf;
517
+ }
518
+ }
519
+ return out;
520
+ }
521
+
522
+ /**
523
+ * Load the active build profile by name, resolving any `extends` chain.
524
+ * Falls back to auto-detection if the named profile file does not
525
+ * exist yet.
391
526
  *
392
527
  * @param {string} [name='default']
393
528
  * @returns {{ profile: WyvrnProfile, fromFile: boolean }}
394
529
  */
395
530
  function loadProfile(name = 'default') {
396
- const stored = readProfile(name);
397
- if (stored) return { profile: stored, fromFile: true };
531
+ const resolved = resolveProfileChain(name);
532
+ if (resolved) return { profile: resolved, fromFile: true };
398
533
  return { profile: detectProfile(), fromFile: false };
399
534
  }
400
535
 
@@ -415,6 +550,7 @@ module.exports = {
415
550
  deleteProfile,
416
551
  listProfiles,
417
552
  loadProfile,
553
+ resolveProfileChain,
418
554
  };
419
555
 
420
556
  /**
package/src/resolve.js CHANGED
@@ -141,15 +141,27 @@ async function resolveLatestVersion(name, packageSources, platform, httpClient)
141
141
  /**
142
142
  * Fetches razer.json for a single package from the first source that responds.
143
143
  *
144
+ * When `preferredProfileHash` is provided (EVALUATION.md F13 — closes the
145
+ * "picks arbitrary profileHash" latent bug), the v2 lookup tries that hash
146
+ * first and only falls back to the first-available profile if the preferred
147
+ * hash isn't on the registry. Dependency declarations are invariant across
148
+ * profiles today, so picking any hash gives the same answer — but the
149
+ * consumer's own hash is stable across re-resolves and future-proofs
150
+ * against any schema that starts varying wyvrn.json per profile.
151
+ *
144
152
  * @param {string} name
145
153
  * @param {string} version
146
154
  * @param {string[]} packageSources
147
155
  * @param {string} platform
148
156
  * @param {Function} httpClient
149
157
  * @param {Map<string, object>} cache - In-flight / completed fetch cache.
158
+ * @param {string|null} [preferredProfileHash] - try this hash first.
150
159
  * @returns {Promise<object|null>} Parsed razer.json or null if not found anywhere.
151
160
  */
152
- async function fetchPackageManifest(name, version, packageSources, platform, httpClient, cache) {
161
+ async function fetchPackageManifest(
162
+ name, version, packageSources, platform, httpClient, cache,
163
+ preferredProfileHash = null,
164
+ ) {
153
165
  const cacheKey = `${name}@${version}`;
154
166
  if (cache.has(cacheKey)) {
155
167
  return cache.get(cacheKey);
@@ -158,14 +170,19 @@ async function fetchPackageManifest(name, version, packageSources, platform, htt
158
170
  for (const source of packageSources) {
159
171
  const base = source.replace(/\/$/, '');
160
172
 
161
- // v2: look up any available profile hash from versions.json, then fetch that manifest
173
+ // v2: prefer the consumer's own profileHash; fall back to the first
174
+ // available if that hash isn't published.
162
175
  try {
163
176
  const idxResp = await httpClient(`${base}/v2/${name}/versions.json`);
164
177
  if (idxResp.ok) {
165
178
  const idx = await idxResp.json();
166
179
  const profiles = idx?.versions?.[version]?.profiles;
167
180
  if (profiles) {
168
- const profileHash = Object.keys(profiles)[0];
181
+ const available = Object.keys(profiles);
182
+ const profileHash =
183
+ (preferredProfileHash && available.includes(preferredProfileHash))
184
+ ? preferredProfileHash
185
+ : available[0];
169
186
  if (profileHash) {
170
187
  const mResp = await httpClient(`${base}/v2/${name}/${version}/${profileHash}/wyvrn.json`);
171
188
  if (mResp.ok) {
@@ -215,15 +232,37 @@ async function fetchPackageManifest(name, version, packageSources, platform, htt
215
232
  * PLAN-OPTIONS) use this to avoid a second round of HTTP round-trips.
216
233
  * Passing `null` / omitting it keeps the legacy behaviour.
217
234
  *
235
+ * Per-package source pinning (EVALUATION.md S4): when `pinnedSources` is
236
+ * provided, any entry `name → url` restricts that package's manifest +
237
+ * latest + versions lookups to just that URL — typo-squatting and mirror
238
+ * shadowing are closed for pinned deps. Pins propagate to direct deps
239
+ * only; transitive deps continue to fan out across all sources (a pinned
240
+ * dep's own recipe is authoritative for its own transitives).
241
+ *
218
242
  * @param {Record<string, string>} rootDeps - Direct dependencies { name: version }.
219
243
  * @param {string[]} packageSources - Ordered list of base URLs to try.
220
244
  * @param {string} platform - Target platform string (e.g. "win_x64").
221
245
  * @param {Function} httpClient - fetch-compatible function.
222
246
  * @param {Map<string, string>} [lockedVersions]
223
247
  * @param {Map<string, object>} [manifestsOut] - populated in-place when provided
248
+ * @param {string|null} [preferredProfileHash] - consumer's own profileHash;
249
+ * threaded to fetchPackageManifest so v2 manifest lookups prefer the
250
+ * consumer's own build entry over an arbitrary one (EVALUATION.md F13).
251
+ * @param {Map<string,string>|null} [pinnedSources] - per-package source URL pin (S4).
224
252
  * @returns {Promise<Map<string, string>>} Resolved map of name -> version.
225
253
  */
226
- async function resolveDependencies(rootDeps, packageSources, platform, httpClient, lockedVersions = new Map(), manifestsOut = null) {
254
+ async function resolveDependencies(
255
+ rootDeps, packageSources, platform, httpClient,
256
+ lockedVersions = new Map(), manifestsOut = null,
257
+ preferredProfileHash = null,
258
+ pinnedSources = null,
259
+ ) {
260
+ // For pinned deps, restrict the source list to the single pinned URL.
261
+ // `null` / no pin → fall back to the full caller-supplied list.
262
+ const sourcesFor = (name) => {
263
+ const pin = pinnedSources?.get?.(name);
264
+ return pin ? [pin] : packageSources;
265
+ };
227
266
  /** @type {Map<string, string>} Final resolved versions. */
228
267
  const resolved = new Map();
229
268
 
@@ -254,7 +293,7 @@ async function resolveDependencies(rootDeps, packageSources, platform, httpClien
254
293
  const publishedCache = new Map();
255
294
  const fetchPublished = async (name) => {
256
295
  if (publishedCache.has(name)) return publishedCache.get(name);
257
- const list = await fetchPublishedVersions(name, packageSources, httpClient);
296
+ const list = await fetchPublishedVersions(name, sourcesFor(name), httpClient);
258
297
  publishedCache.set(name, list);
259
298
  return list;
260
299
  };
@@ -280,7 +319,7 @@ async function resolveDependencies(rootDeps, packageSources, platform, httpClien
280
319
  // Skip this for linked packages which have "linked:" prefix.
281
320
  if (version === 'latest') {
282
321
  try {
283
- version = await resolveLatestVersion(name, packageSources, platform, httpClient);
322
+ version = await resolveLatestVersion(name, sourcesFor(name), platform, httpClient);
284
323
  log.info(`Resolved "${name}@latest" → ${version}`);
285
324
  } catch (err) {
286
325
  log.warn(err.message);
@@ -405,10 +444,11 @@ async function resolveDependencies(rootDeps, packageSources, platform, httpClien
405
444
  manifest = await fetchPackageManifest(
406
445
  name,
407
446
  version,
408
- packageSources,
447
+ sourcesFor(name),
409
448
  platform,
410
449
  httpClient,
411
450
  manifestCache,
451
+ preferredProfileHash,
412
452
  );
413
453
  }
414
454
 
@@ -0,0 +1,152 @@
1
+ 'use strict';
2
+
3
+ // Pattern-based settings overrides (EVALUATION.md F11).
4
+ //
5
+ // `wyvrn.json` may declare a top-level `settingsOverrides` block whose
6
+ // keys are glob patterns over dependency names. Every matching pattern
7
+ // contributes to the resolved dep's `settings` merge; the per-dep
8
+ // literal settings (inside `dependencies`) still win over patterns.
9
+ //
10
+ // Conan users know this feature as profile-side `boost*:compiler.cppstd=17`.
11
+ // Ours lives in the manifest because that's where literal settings
12
+ // overrides already live — one fewer concept for recipe authors.
13
+ //
14
+ // Pure, no I/O. Exported helpers:
15
+ // - normalizeSettingsOverrides: validate + sort by specificity
16
+ // - resolveOverrideSettings: compute the merged settings for a dep name
17
+
18
+ /**
19
+ * Valid profile field keys that settings overrides may target.
20
+ * Mirrors `HASHED_PROFILE_FIELDS` in src/profile.js, but as the allow-
21
+ * list for OVERRIDE values (os/arch/compiler/etc.). If a pattern
22
+ * override tries to set something else, reject at parse time rather
23
+ * than silently letting it through into mergeProfile.
24
+ */
25
+ const ALLOWED_SETTINGS_KEYS = new Set([
26
+ 'os', 'arch', 'compiler', 'compiler.version', 'compiler.cppstd', 'compiler.runtime',
27
+ ]);
28
+
29
+ /**
30
+ * Convert a glob pattern to a RegExp. Supports `*` (any chars),
31
+ * `?` (single char), literal everything else. No brace expansion,
32
+ * no negation — keep the mental model small.
33
+ *
34
+ * @param {string} glob
35
+ * @returns {RegExp}
36
+ */
37
+ function globToRegExp(glob) {
38
+ const escaped = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&');
39
+ const pattern = escaped.replace(/\*/g, '.*').replace(/\?/g, '.');
40
+ return new RegExp(`^${pattern}$`);
41
+ }
42
+
43
+ /**
44
+ * Specificity heuristic: fewer wildcards = more specific. Ties broken
45
+ * by longer literal prefix, then lexical order for determinism. More
46
+ * specific patterns get applied LAST so they override less-specific
47
+ * ones in the merge.
48
+ *
49
+ * @param {string} glob
50
+ * @returns {[number, number, string]} sort key — lower = less specific
51
+ */
52
+ function specificityKey(glob) {
53
+ const wildcards = (glob.match(/[*?]/g) ?? []).length;
54
+ const literalPrefix = glob.match(/^[^*?]*/)?.[0].length ?? 0;
55
+ // Invert sort order: more wildcards → lower score; more prefix → higher score.
56
+ return [-wildcards, literalPrefix, glob];
57
+ }
58
+
59
+ /**
60
+ * Validate + sort a raw `settingsOverrides` block. Returns an array of
61
+ * `{ pattern, regex, settings }` in application order (least-specific
62
+ * first). A string pattern alone (literal `"openssl"`) works too — it
63
+ * matches only that exact name.
64
+ *
65
+ * Throws on:
66
+ * - non-object block
67
+ * - non-string keys (JSON can't produce these but defensive)
68
+ * - non-object values
69
+ * - empty pattern strings
70
+ * - unknown settings keys in a value
71
+ * - non-string value leaves (settings values must be strings like
72
+ * the profile itself: "dynamic", "17", "gcc")
73
+ *
74
+ * @param {unknown} block
75
+ * @returns {Array<{ pattern: string, regex: RegExp, settings: Record<string, string> }>}
76
+ */
77
+ function normalizeSettingsOverrides(block) {
78
+ if (block === undefined || block === null) return [];
79
+ if (typeof block !== 'object' || Array.isArray(block)) {
80
+ throw new Error('settingsOverrides must be an object mapping globs to settings');
81
+ }
82
+
83
+ const entries = [];
84
+ for (const [rawPattern, rawValue] of Object.entries(block)) {
85
+ if (typeof rawPattern !== 'string' || rawPattern.length === 0) {
86
+ throw new Error(`settingsOverrides: pattern must be a non-empty string, got ${JSON.stringify(rawPattern)}`);
87
+ }
88
+ if (!rawValue || typeof rawValue !== 'object' || Array.isArray(rawValue)) {
89
+ throw new Error(`settingsOverrides["${rawPattern}"]: value must be an object of settings`);
90
+ }
91
+
92
+ const settings = {};
93
+ for (const [k, v] of Object.entries(rawValue)) {
94
+ if (!ALLOWED_SETTINGS_KEYS.has(k)) {
95
+ throw new Error(
96
+ `settingsOverrides["${rawPattern}"]: unknown key "${k}" — ` +
97
+ `allowed: ${[...ALLOWED_SETTINGS_KEYS].join(', ')}`,
98
+ );
99
+ }
100
+ if (typeof v !== 'string' || v.length === 0) {
101
+ throw new Error(
102
+ `settingsOverrides["${rawPattern}"].${k}: value must be a non-empty string, ` +
103
+ `got ${JSON.stringify(v)}`,
104
+ );
105
+ }
106
+ settings[k] = v;
107
+ }
108
+
109
+ entries.push({ pattern: rawPattern, regex: globToRegExp(rawPattern), settings });
110
+ }
111
+
112
+ // Sort by specificity ascending so more-specific patterns apply LAST
113
+ // (their settings therefore win on per-key merge).
114
+ entries.sort((a, b) => {
115
+ const ka = specificityKey(a.pattern);
116
+ const kb = specificityKey(b.pattern);
117
+ for (let i = 0; i < ka.length; i++) {
118
+ if (ka[i] < kb[i]) return -1;
119
+ if (ka[i] > kb[i]) return 1;
120
+ }
121
+ return 0;
122
+ });
123
+
124
+ return entries;
125
+ }
126
+
127
+ /**
128
+ * Merge all matching pattern overrides for `depName` into a flat
129
+ * settings object. Less-specific patterns apply first; more-specific
130
+ * patterns overlay on top. Does NOT include the literal per-dep
131
+ * `settings` (caller layers that on after — literal always wins).
132
+ *
133
+ * @param {string} depName
134
+ * @param {ReturnType<typeof normalizeSettingsOverrides>} normalized
135
+ * @returns {Record<string, string>}
136
+ */
137
+ function resolveOverrideSettings(depName, normalized) {
138
+ const out = {};
139
+ for (const entry of normalized) {
140
+ if (entry.regex.test(depName)) {
141
+ Object.assign(out, entry.settings);
142
+ }
143
+ }
144
+ return out;
145
+ }
146
+
147
+ module.exports = {
148
+ ALLOWED_SETTINGS_KEYS,
149
+ globToRegExp,
150
+ normalizeSettingsOverrides,
151
+ resolveOverrideSettings,
152
+ };
@@ -72,6 +72,20 @@ function isWyvrnGenerated(presets) {
72
72
  * so `cmake --preset` produces
73
73
  * binaries matching the profile
74
74
  * (VS otherwise defaults to x64).
75
+ * @param {string[]|null} [args.configs] Build recipe's `configs` list
76
+ * (e.g. ['Debug','Release','MinSizeRel']).
77
+ * When provided, drives
78
+ * `CMAKE_CONFIGURATION_TYPES` so
79
+ * multi-config generators (Ninja
80
+ * Multi-Config, VS) actually
81
+ * produce every config the recipe
82
+ * declares. Null/empty → omitted
83
+ * (let CMake use its generator
84
+ * default). `extraCacheVariables`
85
+ * wins on explicit collision so
86
+ * authors that already bake the
87
+ * list into `build.configure`
88
+ * aren't overridden.
75
89
  */
76
90
  function buildConfigurePreset({
77
91
  profileName,
@@ -81,16 +95,20 @@ function buildConfigurePreset({
81
95
  extraCacheVariables = null,
82
96
  installDir = null,
83
97
  profileArch = null,
98
+ configs = null,
84
99
  }) {
85
100
  const binaryDir = `\${sourceDir}/build/wyvrn-${profileName}`;
86
- // Spread `extraCacheVariables` FIRST, then the wyvrn-owned entries. This
87
- // means the recipe cannot redirect `CMAKE_TOOLCHAIN_FILE` our dep
88
- // injection is always the authoritative toolchain pointer. A malicious
89
- // or sloppy recipe trying to override it is silently ignored, not
90
- // quietly obeyed. Same protection applies to `CMAKE_INSTALL_PREFIX`:
91
- // if a recipe declares `build.installDir`, we resolve it under the
92
- // binary dir; any cacheVariables override is overwritten below.
101
+ // CMAKE_CONFIGURATION_TYPES is a defensible default from the recipe's
102
+ // declared `configs` list. Applied FIRST (lowest precedence) so an
103
+ // explicit `-DCMAKE_CONFIGURATION_TYPES=...` in `build.configure`
104
+ // still wins authors who already pin the list shouldn't silently
105
+ // lose their override. The wyvrn-owned entries (toolchain, install
106
+ // prefix) still win over both below.
107
+ const configsDefault = (Array.isArray(configs) && configs.length > 0)
108
+ ? { CMAKE_CONFIGURATION_TYPES: configs.join(';') }
109
+ : {};
93
110
  const cacheVariables = {
111
+ ...configsDefault,
94
112
  ...(extraCacheVariables ?? {}),
95
113
  CMAKE_TOOLCHAIN_FILE: `\${sourceDir}/${toolchainRelPath}`,
96
114
  };
@@ -128,27 +146,32 @@ function buildConfigurePreset({
128
146
  }
129
147
 
130
148
  /**
131
- * Two build presets per configure preset: Debug and Release.
149
+ * One build preset per configuration the recipe declares (or the
150
+ * legacy default pair `Debug` + `Release` when no list is provided).
151
+ *
152
+ * The preset name suffix is the configuration name lower-cased — matches
153
+ * the previous Debug/Release naming for those two, and extends to
154
+ * `wyvrn-<profile>-relwithdebinfo` / `-minsizerel` for the multi-config
155
+ * cases. Authors whose recipes include `MinSizeRel` in `build.configs`
156
+ * can now `cmake --build --preset wyvrn-<profile>-minsizerel` directly.
132
157
  *
133
158
  * @param {string} profileName
159
+ * @param {string[]|null} [configs] Recipe's declared configurations. When
160
+ * null/empty, emits the pre-2.8.1 `Debug` + `Release` pair so existing
161
+ * CMakePresets.json files remain byte-identical.
134
162
  * @returns {Array<object>}
135
163
  */
136
- function buildBuildPresets(profileName) {
137
- const config = `wyvrn-${profileName}`;
138
- return [
139
- {
140
- name: `${config}-debug`,
141
- displayName: `wyvrn ${profileName} (Debug)`,
142
- configurePreset: config,
143
- configuration: 'Debug',
144
- },
145
- {
146
- name: `${config}-release`,
147
- displayName: `wyvrn ${profileName} (Release)`,
148
- configurePreset: config,
149
- configuration: 'Release',
150
- },
151
- ];
164
+ function buildBuildPresets(profileName, configs = null) {
165
+ const base = `wyvrn-${profileName}`;
166
+ const list = (Array.isArray(configs) && configs.length > 0)
167
+ ? configs
168
+ : ['Debug', 'Release'];
169
+ return list.map((cfg) => ({
170
+ name: `${base}-${cfg.toLowerCase()}`,
171
+ displayName: `wyvrn ${profileName} (${cfg})`,
172
+ configurePreset: base,
173
+ configuration: cfg,
174
+ }));
152
175
  }
153
176
 
154
177
  /**
@@ -162,6 +185,7 @@ function buildFreshPresetsFile({
162
185
  extraCacheVariables = null,
163
186
  installDir = null,
164
187
  profileArch = null,
188
+ configs = null,
165
189
  }) {
166
190
  return {
167
191
  version: SCHEMA_VERSION,
@@ -173,9 +197,9 @@ function buildFreshPresetsFile({
173
197
  },
174
198
  },
175
199
  configurePresets: [buildConfigurePreset({
176
- profileName, profileHash, toolchainRelPath, generator, extraCacheVariables, installDir, profileArch,
200
+ profileName, profileHash, toolchainRelPath, generator, extraCacheVariables, installDir, profileArch, configs,
177
201
  })],
178
- buildPresets: buildBuildPresets(profileName),
202
+ buildPresets: buildBuildPresets(profileName, configs),
179
203
  };
180
204
  }
181
205
 
@@ -197,18 +221,33 @@ function mergeIntoExisting(existing, {
197
221
  extraCacheVariables = null,
198
222
  installDir = null,
199
223
  profileArch = null,
224
+ configs = null,
200
225
  }) {
201
226
  const newConfigure = buildConfigurePreset({
202
- profileName, profileHash, toolchainRelPath, generator, extraCacheVariables, installDir, profileArch,
227
+ profileName, profileHash, toolchainRelPath, generator, extraCacheVariables, installDir, profileArch, configs,
203
228
  });
204
- const newBuilds = buildBuildPresets(profileName);
229
+ const newBuilds = buildBuildPresets(profileName, configs);
205
230
  const newConfigureNames = new Set([newConfigure.name]);
206
- const newBuildNames = new Set(newBuilds.map((b) => b.name));
231
+ // When replacing THIS profile's build presets, drop every pre-existing
232
+ // one that targets the same configurePreset — the fresh `newBuilds`
233
+ // list fully replaces them. Scoping to configurePreset avoids stepping
234
+ // on presets for OTHER profiles in the same file, which is the whole
235
+ // point of the merge path.
236
+ //
237
+ // Without this, editing `build.configs` from 4 configs down to 2 would
238
+ // leave orphan build presets pointing at configurations CMake won't
239
+ // generate.
240
+ const myBase = `wyvrn-${profileName}`;
241
+ const isMyBuildPreset = (p) => p && p.configurePreset === myBase;
207
242
 
208
243
  const configurePresets = (existing.configurePresets ?? []).filter((p) => !newConfigureNames.has(p.name));
209
244
  configurePresets.push(newConfigure);
210
245
 
211
- const buildPresets = (existing.buildPresets ?? []).filter((p) => !newBuildNames.has(p.name));
246
+ // Drop every pre-existing build preset that targets THIS profile's
247
+ // configurePreset — the fresh `newBuilds` list (one per current
248
+ // `configs` entry) fully replaces them. Presets for OTHER profiles
249
+ // stay untouched so the multi-profile merge story is preserved.
250
+ const buildPresets = (existing.buildPresets ?? []).filter((p) => !isMyBuildPreset(p));
212
251
  buildPresets.push(...newBuilds);
213
252
 
214
253
  return {
@@ -260,8 +299,9 @@ function generateCMakePresets({
260
299
  extraCacheVariables = null,
261
300
  installDir = null,
262
301
  profileArch = null,
302
+ configs = null,
263
303
  }) {
264
- const args = { profileName, profileHash, toolchainRelPath, generator, extraCacheVariables, installDir, profileArch };
304
+ const args = { profileName, profileHash, toolchainRelPath, generator, extraCacheVariables, installDir, profileArch, configs };
265
305
  const candidates = [
266
306
  { filePath: path.join(projectRoot, 'CMakePresets.json'), isUser: false },
267
307
  { filePath: path.join(projectRoot, 'CMakeUserPresets.json'), isUser: true },