wyvrnpm 2.1.0 → 2.4.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
@@ -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
  };
@@ -17,6 +17,73 @@ function getBundledCmakeDir() {
17
17
  return dir.replace(/\\/g, '/');
18
18
  }
19
19
 
20
+ // Arch bit-width mapping. Kept exhaustive against `ARCH_MAP` in profile.js
21
+ // so any arch a profile can hold has a deterministic "32"/"64" suffix.
22
+ // Unknown arch strings fall back to an empty suffix — the consumer's
23
+ // CMakeLists should then use another discriminator (e.g. profile hash).
24
+ const ARCH_BIT_SUFFIX = {
25
+ x86_64: '64',
26
+ x86: '32',
27
+ armv8: '64',
28
+ armv7: '32',
29
+ ppc64le: '64',
30
+ s390x: '64',
31
+ mips: '32',
32
+ mipsel: '32',
33
+ };
34
+
35
+ /**
36
+ * Map a profile `arch` value to the canonical "64" / "32" suffix that
37
+ * `WYVRN_ARCH_SUFFIX` exposes to consumer CMakeLists. Empty string for
38
+ * unknown arches — callers can pattern-match on that to fall back.
39
+ *
40
+ * @param {string|null|undefined} arch
41
+ * @returns {string}
42
+ */
43
+ function archToBitSuffix(arch) {
44
+ if (!arch) return '';
45
+ return ARCH_BIT_SUFFIX[arch] ?? '';
46
+ }
47
+
48
+ // Visual Studio generator `-A <platform>` mapping. VS defaults to x64
49
+ // when `-A` is unspecified — which silently wins against a 32-bit profile
50
+ // and causes consumer-side `find_package()` to reject 32-bit artefacts
51
+ // with "The version found is not compatible" (a CMake pointer-size check,
52
+ // not a version-number check). Auto-emitting `-A` keeps VS + x86 honest.
53
+ const ARCH_VS_PLATFORM = {
54
+ x86_64: 'x64',
55
+ x86: 'Win32',
56
+ armv8: 'ARM64',
57
+ armv7: 'ARM',
58
+ };
59
+
60
+ /**
61
+ * Map a profile arch to the Visual Studio generator's `-A <platform>` value.
62
+ * Returns null for arches that don't map to a known VS platform — callers
63
+ * should skip `-A` in that case and let the user pass one explicitly.
64
+ *
65
+ * @param {string|null|undefined} arch
66
+ * @returns {string|null}
67
+ */
68
+ function archToVsPlatform(arch) {
69
+ if (!arch) return null;
70
+ return ARCH_VS_PLATFORM[arch] ?? null;
71
+ }
72
+
73
+ /**
74
+ * True when the generator name identifies a Visual Studio generator (e.g.
75
+ * "Visual Studio 17 2022"). VS generators require `-A <platform>` /
76
+ * `architecture` in the preset; everyone else (Ninja, Make, Xcode, …)
77
+ * ignores it.
78
+ *
79
+ * @param {string|null|undefined} name
80
+ * @returns {boolean}
81
+ */
82
+ function isVisualStudioGenerator(name) {
83
+ if (!name) return false;
84
+ return /^Visual Studio \d/.test(name);
85
+ }
86
+
20
87
  /**
21
88
  * Substitute `{{KEY}}` placeholders in a template string.
22
89
  *
@@ -54,6 +121,7 @@ function buildPlaceholders({ profile, profileName, profileHash, packageNames })
54
121
  PROFILE_HASH: profileHash,
55
122
  PROFILE_OS: profile.os ?? '',
56
123
  PROFILE_ARCH: profile.arch ?? '',
124
+ PROFILE_ARCH_SUFFIX: archToBitSuffix(profile.arch),
57
125
  PROFILE_COMPILER: profile.compiler ?? '',
58
126
  PROFILE_COMPILER_VERSION: profile['compiler.version'] ?? '',
59
127
  PROFILE_CPPSTD: profile['compiler.cppstd'] ?? '20',
@@ -135,7 +203,10 @@ module.exports = {
135
203
  generateToolchain,
136
204
  generateCMakePresets,
137
205
  getBundledCmakeDir,
206
+ archToVsPlatform,
207
+ isVisualStudioGenerator,
138
208
  // Exported for tests
209
+ archToBitSuffix,
139
210
  buildPlaceholders,
140
211
  renderTemplate,
141
212
  };