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/profile.js CHANGED
@@ -163,25 +163,49 @@ function detectProfile() {
163
163
  }
164
164
 
165
165
  /**
166
- * Compute a stable 16-char SHA256 prefix that identifies a unique build profile.
167
- * Only non-null, non-wildcard fields contribute to the hash.
166
+ * Compute a stable 16-char SHA256 prefix identifying a unique build.
167
+ * Inputs:
168
+ * - `profile`: the toolchain/ABI fields (os, arch, compiler, etc.).
169
+ * - `options`: (optional) recipe-declared feature toggles resolved to
170
+ * their effective values for this build.
171
+ *
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.
178
+ *
179
+ * See claude/PLAN-OPTIONS.md §3.2 for the full design.
168
180
  *
169
181
  * @param {WyvrnProfile} profile
182
+ * @param {Record<string, any>|null} [options] effective options (after defaults + overrides)
170
183
  * @returns {string} 16-character lowercase hex string
171
184
  */
172
- function hashProfile(profile) {
185
+ function hashProfile(profile, options = null) {
173
186
  const significant = Object.fromEntries(
174
187
  Object.entries(profile)
175
188
  .filter(([, v]) => v !== null && v !== undefined && v !== '*' && v !== '')
176
189
  .sort(([a], [b]) => a.localeCompare(b)),
177
190
  );
191
+
192
+ const payload = (options && Object.keys(options).length > 0)
193
+ ? { ...significant, options: sortOptionsForHashing(options) }
194
+ : significant;
195
+
178
196
  return crypto
179
197
  .createHash('sha256')
180
- .update(JSON.stringify(significant))
198
+ .update(JSON.stringify(payload))
181
199
  .digest('hex')
182
200
  .slice(0, 16);
183
201
  }
184
202
 
203
+ function sortOptionsForHashing(options) {
204
+ return Object.fromEntries(
205
+ Object.entries(options).sort(([a], [b]) => a.localeCompare(b)),
206
+ );
207
+ }
208
+
185
209
  /**
186
210
  * Compute the SHA256 hash of a file or Buffer.
187
211
  *
@@ -69,7 +69,20 @@ class BaseProvider {
69
69
 
70
70
  /**
71
71
  * Publish to the v2 path.
72
- * Writes wyvrn.json + wyvrn.zip under profileHash, updates versions.json and latest.json.
72
+ * Writes wyvrn.json + wyvrn.zip under profileHash, updates versions.json and
73
+ * (when `updateLatest !== false`) latest.json.
74
+ *
75
+ * `updateLatest: false` is used by the `upload-built` flow (a consumer
76
+ * returning a source-built artefact — see PLAN-UPLOAD-BUILT.md). A consumer
77
+ * upload is NOT introducing a new package version, so `latest.json` must
78
+ * not be clobbered. For the same reason the `latest` pointer inside
79
+ * `versions.json` is also left alone. Defaults to `true` to preserve
80
+ * existing `publish` semantics.
81
+ *
82
+ * The optional `uploadedBy` field in `options` is written verbatim into
83
+ * the per-build `wyvrn.json` as provenance metadata. Absent by default
84
+ * (author publish); populated by the upload-built flow. Readers ignore
85
+ * the field — it is informational only.
73
86
  *
74
87
  * @param {{ manifest: string, zip: string }} files - local file paths
75
88
  * @param {{
@@ -81,6 +94,8 @@ class BaseProvider {
81
94
  * contentSha256: string,
82
95
  * gitSha: string|null,
83
96
  * gitRepo: string|null,
97
+ * updateLatest?: boolean, // default true; false for upload-built
98
+ * uploadedBy?: object, // optional provenance block, written into wyvrn.json
84
99
  * awsProfile?: string,
85
100
  * token?: string,
86
101
  * }} options
@@ -107,6 +107,7 @@ class FileProvider extends BaseProvider {
107
107
  const {
108
108
  source, name, version, profileHash, buildSettings,
109
109
  contentSha256, gitSha, gitRepo, lockedDependencies,
110
+ updateLatest = true, uploadedBy,
110
111
  } = options;
111
112
 
112
113
  const v2root = this._v2Root(source, name);
@@ -130,6 +131,7 @@ class FileProvider extends BaseProvider {
130
131
  gitSha: gitSha ?? null,
131
132
  gitRepo: gitRepo ?? null,
132
133
  publishedAt: new Date().toISOString(),
134
+ ...(uploadedBy ? { uploadedBy } : {}),
133
135
  };
134
136
  this._writeJson(path.join(buildDir, 'wyvrn.json'), v2Meta);
135
137
 
@@ -155,11 +157,14 @@ class FileProvider extends BaseProvider {
155
157
  if (gitSha || gitRepo) {
156
158
  versionsIdx.versions[version].source = { gitRepo: gitRepo ?? null, gitSha: gitSha ?? null };
157
159
  }
158
- versionsIdx.latest = version;
160
+ if (updateLatest) versionsIdx.latest = version;
159
161
  this._writeJson(versionsPath, versionsIdx);
160
162
 
161
- // 5) v2 latest.json
162
- this._writeJson(path.join(v2root, 'latest.json'), { version, profileHash, contentSha256 });
163
+ // 5) v2 latest.json — skipped for consumer uploads (upload-built) so a
164
+ // historical-version rebuild cannot clobber the real "latest" pointer.
165
+ if (updateLatest) {
166
+ this._writeJson(path.join(v2root, 'latest.json'), { version, profileHash, contentSha256 });
167
+ }
163
168
  }
164
169
 
165
170
  async v2GetMeta({ source, name, version, profileHash }) {
@@ -176,6 +176,7 @@ class HttpProvider extends BaseProvider {
176
176
  const {
177
177
  source, name, version, profileHash, buildSettings,
178
178
  contentSha256, gitSha, gitRepo, lockedDependencies, token,
179
+ updateLatest = true, uploadedBy,
179
180
  } = options;
180
181
  const base = source.replace(/\/$/, '');
181
182
  const v2root = `${base}/v2/${name}`;
@@ -199,6 +200,7 @@ class HttpProvider extends BaseProvider {
199
200
  gitSha: gitSha ?? null,
200
201
  gitRepo: gitRepo ?? null,
201
202
  publishedAt: new Date().toISOString(),
203
+ ...(uploadedBy ? { uploadedBy } : {}),
202
204
  };
203
205
  await this._putBuffer(
204
206
  `${buildRoot}/wyvrn.json`,
@@ -232,7 +234,7 @@ class HttpProvider extends BaseProvider {
232
234
  if (gitSha || gitRepo) {
233
235
  versionsIdx.versions[version].source = { gitRepo: gitRepo ?? null, gitSha: gitSha ?? null };
234
236
  }
235
- versionsIdx.latest = version;
237
+ if (updateLatest) versionsIdx.latest = version;
236
238
  await this._putBuffer(
237
239
  `${v2root}/versions.json`,
238
240
  Buffer.from(JSON.stringify(versionsIdx, null, 2)),
@@ -240,13 +242,15 @@ class HttpProvider extends BaseProvider {
240
242
  token,
241
243
  );
242
244
 
243
- // 5) Update v2 latest.json
244
- await this._putBuffer(
245
- `${v2root}/latest.json`,
246
- Buffer.from(JSON.stringify({ version, profileHash, contentSha256 })),
247
- 'application/json',
248
- token,
249
- );
245
+ // 5) v2 latest.json — skipped for consumer uploads (see FileProvider note).
246
+ if (updateLatest) {
247
+ await this._putBuffer(
248
+ `${v2root}/latest.json`,
249
+ Buffer.from(JSON.stringify({ version, profileHash, contentSha256 })),
250
+ 'application/json',
251
+ token,
252
+ );
253
+ }
250
254
  }
251
255
 
252
256
  async v2GetMeta({ source, name, version, profileHash, token }) {
@@ -178,6 +178,7 @@ class S3Provider extends BaseProvider {
178
178
  const {
179
179
  source, name, version, profileHash, buildSettings,
180
180
  contentSha256, gitSha, gitRepo, lockedDependencies, awsProfile,
181
+ updateLatest = true, uploadedBy,
181
182
  } = options;
182
183
  const { PutObjectCommand, GetObjectCommand } = this._loadSdk();
183
184
  const client = this._makeClient(awsProfile);
@@ -204,6 +205,7 @@ class S3Provider extends BaseProvider {
204
205
  gitSha: gitSha ?? null,
205
206
  gitRepo: gitRepo ?? null,
206
207
  publishedAt: new Date().toISOString(),
208
+ ...(uploadedBy ? { uploadedBy } : {}),
207
209
  };
208
210
  await this._putBuffer(
209
211
  client, bucket, `${buildRoot}/wyvrn.json`,
@@ -232,18 +234,20 @@ class S3Provider extends BaseProvider {
232
234
  if (gitSha || gitRepo) {
233
235
  versionsIdx.versions[version].source = { gitRepo: gitRepo ?? null, gitSha: gitSha ?? null };
234
236
  }
235
- versionsIdx.latest = version;
237
+ if (updateLatest) versionsIdx.latest = version;
236
238
  await this._putBuffer(
237
239
  client, bucket, `${v2root}/versions.json`,
238
240
  Buffer.from(JSON.stringify(versionsIdx, null, 2)), 'application/json', PutObjectCommand,
239
241
  );
240
242
 
241
- // 5) latest.json
242
- await this._putBuffer(
243
- client, bucket, `${v2root}/latest.json`,
244
- Buffer.from(JSON.stringify({ version, profileHash, contentSha256 })),
245
- 'application/json', PutObjectCommand,
246
- );
243
+ // 5) latest.json — skipped for consumer uploads (see FileProvider note).
244
+ if (updateLatest) {
245
+ await this._putBuffer(
246
+ client, bucket, `${v2root}/latest.json`,
247
+ Buffer.from(JSON.stringify({ version, profileHash, contentSha256 })),
248
+ 'application/json', PutObjectCommand,
249
+ );
250
+ }
247
251
  }
248
252
 
249
253
  async v2GetMeta({ source, name, version, profileHash, awsProfile }) {
package/src/resolve.js CHANGED
@@ -4,6 +4,13 @@ const fs = require('fs');
4
4
  const path = require('path');
5
5
 
6
6
  const log = require('./logger');
7
+ const {
8
+ parseRange,
9
+ matchesRange,
10
+ pickHighestInRange,
11
+ intersectRanges,
12
+ rangeToString,
13
+ } = require('./version-range');
7
14
 
8
15
  /**
9
16
  * Checks if a version string represents a linked package.
@@ -65,6 +72,33 @@ function compareVersions(a, b) {
65
72
  return 0;
66
73
  }
67
74
 
75
+ /**
76
+ * Fetch every published version of `name` across the configured sources
77
+ * via each source's `versions.json` (v2 layout). Returns the union of
78
+ * version keys sorted ascending. Used by the range resolver to pick the
79
+ * highest version satisfying `^/~/>=` style specs.
80
+ *
81
+ * @param {string} name
82
+ * @param {string[]} packageSources
83
+ * @param {Function} httpClient
84
+ * @returns {Promise<string[]>}
85
+ */
86
+ async function fetchPublishedVersions(name, packageSources, httpClient) {
87
+ const seen = new Set();
88
+ for (const source of packageSources) {
89
+ const base = source.replace(/\/$/, '');
90
+ try {
91
+ const resp = await httpClient(`${base}/v2/${name}/versions.json`);
92
+ if (!resp.ok) continue;
93
+ const idx = await resp.json();
94
+ for (const v of Object.keys(idx?.versions ?? {})) seen.add(v);
95
+ } catch {
96
+ // Try next source
97
+ }
98
+ }
99
+ return [...seen].sort(compareVersions);
100
+ }
101
+
68
102
  /**
69
103
  * Resolves the "latest" tag for a package by fetching latest.json from the registry.
70
104
  *
@@ -175,13 +209,21 @@ async function fetchPackageManifest(name, version, packageSources, platform, htt
175
209
  * Resolves the full, flattened dependency graph starting from rootDeps.
176
210
  * When the same package is required at multiple versions the highest version wins.
177
211
  *
212
+ * Side-effect parameter: when `manifestsOut` is provided (a Map instance),
213
+ * the resolver populates it with `{name → recipe manifest}` for every
214
+ * fetched dependency. Callers that need recipe-declared `options` (see
215
+ * PLAN-OPTIONS) use this to avoid a second round of HTTP round-trips.
216
+ * Passing `null` / omitting it keeps the legacy behaviour.
217
+ *
178
218
  * @param {Record<string, string>} rootDeps - Direct dependencies { name: version }.
179
219
  * @param {string[]} packageSources - Ordered list of base URLs to try.
180
220
  * @param {string} platform - Target platform string (e.g. "win_x64").
181
221
  * @param {Function} httpClient - fetch-compatible function.
222
+ * @param {Map<string, string>} [lockedVersions]
223
+ * @param {Map<string, object>} [manifestsOut] - populated in-place when provided
182
224
  * @returns {Promise<Map<string, string>>} Resolved map of name -> version.
183
225
  */
184
- async function resolveDependencies(rootDeps, packageSources, platform, httpClient, lockedVersions = new Map()) {
226
+ async function resolveDependencies(rootDeps, packageSources, platform, httpClient, lockedVersions = new Map(), manifestsOut = null) {
185
227
  /** @type {Map<string, string>} Final resolved versions. */
186
228
  const resolved = new Map();
187
229
 
@@ -189,13 +231,45 @@ async function resolveDependencies(rootDeps, packageSources, platform, httpClien
189
231
  const manifestCache = new Map();
190
232
 
191
233
  /**
192
- * Pending work queue: each entry is { name, version }.
234
+ * Per-package effective range. For a package that only ever appears with
235
+ * exact versions, this stores `{ kind: 'exact', version }` — which still
236
+ * intersects cleanly with later range requests. For packages with range
237
+ * specs, we intersect contributors so conflicting ranges fail fast.
238
+ * @type {Map<string, ReturnType<typeof parseRange>>}
239
+ */
240
+ const effectiveRange = new Map();
241
+
242
+ /**
243
+ * Who contributed which raw spec for which package — used only for the
244
+ * conflict error message so the user knows whose declaration to fix.
245
+ * @type {Map<string, Array<{ by: string, spec: string }>>}
246
+ */
247
+ const rangeContributors = new Map();
248
+
249
+ /**
250
+ * Cache the published-versions list per package — one HTTP round trip
251
+ * per name regardless of how often it appears in the graph.
252
+ * @type {Map<string, string[]>}
253
+ */
254
+ const publishedCache = new Map();
255
+ const fetchPublished = async (name) => {
256
+ if (publishedCache.has(name)) return publishedCache.get(name);
257
+ const list = await fetchPublishedVersions(name, packageSources, httpClient);
258
+ publishedCache.set(name, list);
259
+ return list;
260
+ };
261
+
262
+ /**
263
+ * Pending work queue: each entry is { name, version, by? }.
264
+ * `by` is the contributor label for conflict diagnostics.
193
265
  * We process iteratively to avoid deep recursion on large dependency trees.
194
266
  */
195
- const queue = Object.entries(rootDeps).map(([name, version]) => ({ name, version }));
267
+ const queue = Object.entries(rootDeps).map(
268
+ ([name, version]) => ({ name, version, by: 'wyvrn.json' }),
269
+ );
196
270
 
197
271
  while (queue.length > 0) {
198
- let { name, version } = queue.shift();
272
+ let { name, version, by } = queue.shift();
199
273
 
200
274
  // Lock file always wins — use pinned version regardless of what was requested.
201
275
  if (lockedVersions.has(name)) {
@@ -214,29 +288,108 @@ async function resolveDependencies(rootDeps, packageSources, platform, httpClien
214
288
  }
215
289
  }
216
290
 
291
+ // Parse the version spec. Ranges (`^`, `~`, explicit comparators) resolve
292
+ // to an exact version via `fetchPublishedVersions` + highest-in-range;
293
+ // intersected against any previously-seen range for the same package.
294
+ let parsedSpec;
295
+ try {
296
+ parsedSpec = parseRange(version);
297
+ } catch (err) {
298
+ log.error(`Dependency "${name}": ${err.message}`);
299
+ continue;
300
+ }
301
+
302
+ if (parsedSpec.kind === 'linked') {
303
+ // Linked packages bypass range resolution entirely.
304
+ parsedSpec = { kind: 'exact', version };
305
+ }
306
+
307
+ // Intersect with any previously-seen range for this package.
308
+ //
309
+ // Backward-compat nuance: when both the previous and current spec are
310
+ // exact but differ, we preserve the legacy "pick higher with warning"
311
+ // behaviour rather than erroring. Intersection-strict conflict only
312
+ // applies when at least one side is an actual range — ranges are
313
+ // opt-in new territory where a declared range that cannot be satisfied
314
+ // IS a bug, not a soft conflict.
315
+ let effective = parsedSpec;
316
+ const prev = effectiveRange.get(name);
317
+ if (prev) {
318
+ const bothExact = prev.kind === 'exact' && parsedSpec.kind === 'exact';
319
+ if (bothExact) {
320
+ // Legacy behaviour: keep the HIGHER of the two exact versions.
321
+ // The warning is emitted in the "already resolved" branch below.
322
+ effective = compareVersions(parsedSpec.version, prev.version) > 0
323
+ ? parsedSpec
324
+ : prev;
325
+ } else {
326
+ const combined = intersectRanges(prev, parsedSpec);
327
+ if (!combined) {
328
+ const sources = rangeContributors.get(name) ?? [];
329
+ const lines = sources.map((c) => ` required by ${c.by}: ${c.spec}`);
330
+ lines.push(` required by ${by}: ${version}`);
331
+ log.error(
332
+ `cannot satisfy conflicting version ranges for "${name}":\n` +
333
+ lines.join('\n') + '\n' +
334
+ ` no version satisfies all ranges`,
335
+ );
336
+ continue;
337
+ }
338
+ effective = combined;
339
+ }
340
+ }
341
+ effectiveRange.set(name, effective);
342
+ if (!rangeContributors.has(name)) rangeContributors.set(name, []);
343
+ rangeContributors.get(name).push({ by, spec: version });
344
+
345
+ // Resolve the (possibly intersected) spec down to an exact version.
346
+ let exactVersion;
347
+ if (effective.kind === 'exact') {
348
+ exactVersion = effective.version;
349
+ } else {
350
+ const published = await fetchPublished(name);
351
+ const picked = pickHighestInRange(published, effective);
352
+ if (!picked) {
353
+ log.error(
354
+ `no published version of "${name}" satisfies ${rangeToString(effective)}\n` +
355
+ (published.length > 0
356
+ ? ` published versions: ${published.join(', ')}`
357
+ : ` no versions published on the configured sources`),
358
+ );
359
+ continue;
360
+ }
361
+ exactVersion = picked;
362
+ if (!prev) log.info(`Resolved "${name}@${version}" → ${exactVersion}`);
363
+ }
364
+
217
365
  if (resolved.has(name)) {
218
366
  const existing = resolved.get(name);
219
- if (existing === version) {
367
+ if (existing === exactVersion) {
220
368
  continue;
221
369
  }
222
370
  // Locked packages never lose to a conflict.
223
371
  if (lockedVersions.has(name)) {
224
372
  continue;
225
373
  }
226
- // Conflict: keep the latest version.
227
- const winner = compareVersions(version, existing) > 0 ? version : existing;
228
- if (winner !== existing) {
374
+ // After range-intersection resolution, we prefer the
375
+ // intersected-range's highest over a naive version bump the
376
+ // intersection already enforces "both contributors agree", so
377
+ // trust it.
378
+ if (exactVersion !== existing) {
229
379
  log.warn(
230
- `Version conflict for "${name}": ${existing} vs ${version} — using ${winner}`,
380
+ `Version conflict for "${name}": ${existing} vs ${exactVersion} — using ${exactVersion}`,
231
381
  );
232
- resolved.set(name, winner);
382
+ resolved.set(name, exactVersion);
233
383
  // Re-fetch the winning version's manifest to pull in its transitive deps.
234
- queue.push({ name, version: winner });
384
+ queue.push({ name, version: exactVersion, by });
235
385
  }
236
386
  continue;
237
387
  }
238
388
 
239
- resolved.set(name, version);
389
+ resolved.set(name, exactVersion);
390
+ // Shadow `version` with the resolved exact for the rest of this block
391
+ // — linked handling, manifest fetch, etc. all expect an exact string.
392
+ version = exactVersion;
240
393
 
241
394
  // Handle linked packages: read local manifest instead of fetching from remote
242
395
  let manifest;
@@ -263,6 +416,10 @@ async function resolveDependencies(rootDeps, packageSources, platform, httpClien
263
416
  continue;
264
417
  }
265
418
 
419
+ if (manifestsOut instanceof Map) {
420
+ manifestsOut.set(name, manifest);
421
+ }
422
+
266
423
  // Support both formats:
267
424
  // PascalCase array : { Dependencies: [ { Name, Version }, ... ] }
268
425
  // string object : { dependencies: { name: "version", ... } }
@@ -281,7 +438,7 @@ async function resolveDependencies(rootDeps, packageSources, platform, httpClien
281
438
  }));
282
439
  }
283
440
  for (const dep of deps) {
284
- if (dep.version) queue.push({ name: dep.name, version: dep.version });
441
+ if (dep.version) queue.push({ name: dep.name, version: dep.version, by: `${name}@${version}` });
285
442
  }
286
443
  }
287
444
 
@@ -292,6 +449,7 @@ module.exports = {
292
449
  compareVersions,
293
450
  resolveLatestVersion,
294
451
  resolveDependencies,
452
+ fetchPublishedVersions,
295
453
  isLinkedVersion,
296
454
  getLinkedPath,
297
455
  };
@@ -33,22 +33,64 @@ function isWyvrnGenerated(presets) {
33
33
  /**
34
34
  * Build the configure preset object for the active profile.
35
35
  *
36
+ * When the project has its own `build` recipe (i.e. it's a publishable
37
+ * library with `build.generator` / `build.configure` declared), those are
38
+ * propagated into the generated preset so `wyvrnpm build` locally honours
39
+ * the same knobs the source-build + publish paths use. Without this, a
40
+ * publisher's recipe is effectively advisory at install-time (F15 fix).
41
+ *
36
42
  * @param {object} args
37
43
  * @param {string} args.profileName
38
44
  * @param {string} args.profileHash
39
- * @param {string} args.toolchainRelPath Relative from project root, CMake-style
40
- * forward slashes.
45
+ * @param {string} args.toolchainRelPath Relative from project root, CMake-style
46
+ * forward slashes.
47
+ * @param {string|null} [args.generator] Single generator name to pin on the
48
+ * preset (e.g. "Ninja Multi-Config").
49
+ * Null / undefined → let CMake pick.
50
+ * @param {Record<string,string>|null} [args.extraCacheVariables]
51
+ * Merged into cacheVariables alongside
52
+ * `CMAKE_TOOLCHAIN_FILE`. Usually
53
+ * derived from the project's own
54
+ * `build.configure` -D args.
41
55
  */
42
- function buildConfigurePreset({ profileName, profileHash, toolchainRelPath }) {
43
- return {
56
+ function buildConfigurePreset({
57
+ profileName,
58
+ profileHash,
59
+ toolchainRelPath,
60
+ generator = null,
61
+ extraCacheVariables = null,
62
+ installDir = null,
63
+ }) {
64
+ const binaryDir = `\${sourceDir}/build/wyvrn-${profileName}`;
65
+ // Spread `extraCacheVariables` FIRST, then the wyvrn-owned entries. This
66
+ // means the recipe cannot redirect `CMAKE_TOOLCHAIN_FILE` — our dep
67
+ // injection is always the authoritative toolchain pointer. A malicious
68
+ // or sloppy recipe trying to override it is silently ignored, not
69
+ // quietly obeyed. Same protection applies to `CMAKE_INSTALL_PREFIX`:
70
+ // if a recipe declares `build.installDir`, we resolve it under the
71
+ // binary dir; any cacheVariables override is overwritten below.
72
+ const cacheVariables = {
73
+ ...(extraCacheVariables ?? {}),
74
+ CMAKE_TOOLCHAIN_FILE: `\${sourceDir}/${toolchainRelPath}`,
75
+ };
76
+ if (installDir) {
77
+ // `installDir` in wyvrn.json is defined as relative to the binary dir
78
+ // (same semantics as the source-build path, which passes
79
+ // -DCMAKE_INSTALL_PREFIX=<buildDir>/<installDir>). Without this, a
80
+ // `wyvrnpm build --install` falls back to CMake's system default —
81
+ // on Windows that's `C:/Program Files (x86)/<Project>`, which needs
82
+ // admin rights and almost always fails.
83
+ cacheVariables.CMAKE_INSTALL_PREFIX = `${binaryDir}/${installDir}`;
84
+ }
85
+ const preset = {
44
86
  name: `wyvrn-${profileName}`,
45
87
  displayName: `wyvrnpm (${profileName})`,
46
88
  description: `Generated by wyvrnpm install — profile hash ${profileHash}`,
47
- binaryDir: `\${sourceDir}/build/wyvrn-${profileName}`,
48
- cacheVariables: {
49
- CMAKE_TOOLCHAIN_FILE: `\${sourceDir}/${toolchainRelPath}`,
50
- },
89
+ binaryDir,
90
+ cacheVariables,
51
91
  };
92
+ if (generator) preset.generator = generator;
93
+ return preset;
52
94
  }
53
95
 
54
96
  /**
@@ -78,7 +120,14 @@ function buildBuildPresets(profileName) {
78
120
  /**
79
121
  * Build a fresh presets file from scratch (no prior content to merge).
80
122
  */
81
- function buildFreshPresetsFile({ profileName, profileHash, toolchainRelPath }) {
123
+ function buildFreshPresetsFile({
124
+ profileName,
125
+ profileHash,
126
+ toolchainRelPath,
127
+ generator = null,
128
+ extraCacheVariables = null,
129
+ installDir = null,
130
+ }) {
82
131
  return {
83
132
  version: SCHEMA_VERSION,
84
133
  vendor: {
@@ -88,8 +137,10 @@ function buildFreshPresetsFile({ profileName, profileHash, toolchainRelPath }) {
88
137
  generatedAt: new Date().toISOString(),
89
138
  },
90
139
  },
91
- configurePresets: [buildConfigurePreset({ profileName, profileHash, toolchainRelPath })],
92
- buildPresets: buildBuildPresets(profileName),
140
+ configurePresets: [buildConfigurePreset({
141
+ profileName, profileHash, toolchainRelPath, generator, extraCacheVariables, installDir,
142
+ })],
143
+ buildPresets: buildBuildPresets(profileName),
93
144
  };
94
145
  }
95
146
 
@@ -103,8 +154,17 @@ function buildFreshPresetsFile({ profileName, profileHash, toolchainRelPath }) {
103
154
  * @param {object} args forwarded to the preset builders
104
155
  * @returns {object}
105
156
  */
106
- function mergeIntoExisting(existing, { profileName, profileHash, toolchainRelPath }) {
107
- const newConfigure = buildConfigurePreset({ profileName, profileHash, toolchainRelPath });
157
+ function mergeIntoExisting(existing, {
158
+ profileName,
159
+ profileHash,
160
+ toolchainRelPath,
161
+ generator = null,
162
+ extraCacheVariables = null,
163
+ installDir = null,
164
+ }) {
165
+ const newConfigure = buildConfigurePreset({
166
+ profileName, profileHash, toolchainRelPath, generator, extraCacheVariables, installDir,
167
+ });
108
168
  const newBuilds = buildBuildPresets(profileName);
109
169
  const newConfigureNames = new Set([newConfigure.name]);
110
170
  const newBuildNames = new Set(newBuilds.map((b) => b.name));
@@ -150,9 +210,21 @@ function mergeIntoExisting(existing, { profileName, profileHash, toolchainRelPat
150
210
  * @param {string} args.profileName
151
211
  * @param {string} args.profileHash
152
212
  * @param {string} args.toolchainRelPath CMake-style forward slashes, relative to projectRoot.
213
+ * @param {string|null} [args.generator] Propagated onto the configure preset's `generator` field.
214
+ * @param {Record<string,string>|null} [args.extraCacheVariables]
215
+ * Merged into the configure preset's `cacheVariables`.
153
216
  * @returns {{ path: string|null, action: 'created'|'updated'|'skipped', isUser: boolean }}
154
217
  */
155
- function generateCMakePresets({ projectRoot, profileName, profileHash, toolchainRelPath }) {
218
+ function generateCMakePresets({
219
+ projectRoot,
220
+ profileName,
221
+ profileHash,
222
+ toolchainRelPath,
223
+ generator = null,
224
+ extraCacheVariables = null,
225
+ installDir = null,
226
+ }) {
227
+ const args = { profileName, profileHash, toolchainRelPath, generator, extraCacheVariables, installDir };
156
228
  const candidates = [
157
229
  { filePath: path.join(projectRoot, 'CMakePresets.json'), isUser: false },
158
230
  { filePath: path.join(projectRoot, 'CMakeUserPresets.json'), isUser: true },
@@ -160,14 +232,14 @@ function generateCMakePresets({ projectRoot, profileName, profileHash, toolchain
160
232
 
161
233
  for (const { filePath, isUser } of candidates) {
162
234
  if (!fs.existsSync(filePath)) {
163
- const content = buildFreshPresetsFile({ profileName, profileHash, toolchainRelPath });
235
+ const content = buildFreshPresetsFile(args);
164
236
  fs.writeFileSync(filePath, JSON.stringify(content, null, 2) + '\n', 'utf8');
165
237
  return { path: filePath, action: 'created', isUser };
166
238
  }
167
239
 
168
240
  const existing = safeReadJson(filePath);
169
241
  if (isWyvrnGenerated(existing)) {
170
- const merged = mergeIntoExisting(existing, { profileName, profileHash, toolchainRelPath });
242
+ const merged = mergeIntoExisting(existing, args);
171
243
  fs.writeFileSync(filePath, JSON.stringify(merged, null, 2) + '\n', 'utf8');
172
244
  return { path: filePath, action: 'updated', isUser };
173
245
  }