wyvrnpm 2.10.2 → 2.12.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +1914 -1860
  2. package/bin/{wyvrn.js → wyvrnpm.js} +66 -0
  3. package/cmake/cpp.cmake +9 -9
  4. package/cmake/functions.cmake +224 -224
  5. package/cmake/macros.cmake +284 -284
  6. package/package.json +3 -2
  7. package/src/auth.js +66 -66
  8. package/src/binary-dir.js +95 -0
  9. package/src/bootstrap/cookbook.js +196 -196
  10. package/src/bootstrap/detect.js +150 -150
  11. package/src/bootstrap/index.js +220 -220
  12. package/src/bootstrap/version.js +72 -72
  13. package/src/build/cache.js +344 -344
  14. package/src/build/clone.js +170 -170
  15. package/src/build/cmake.js +342 -342
  16. package/src/build/index.js +299 -297
  17. package/src/build/msvc-env.js +260 -260
  18. package/src/build/recipe.js +188 -188
  19. package/src/commands/add.js +141 -141
  20. package/src/commands/bootstrap.js +96 -96
  21. package/src/commands/build.js +482 -452
  22. package/src/commands/cache.js +189 -189
  23. package/src/commands/clean.js +80 -80
  24. package/src/commands/configure.js +92 -92
  25. package/src/commands/init.js +70 -70
  26. package/src/commands/install-skill.js +115 -115
  27. package/src/commands/install.js +730 -674
  28. package/src/commands/link.js +320 -320
  29. package/src/commands/profile.js +237 -237
  30. package/src/commands/publish.js +584 -555
  31. package/src/commands/show.js +252 -252
  32. package/src/commands/version.js +187 -0
  33. package/src/compat.js +273 -273
  34. package/src/conf/index.js +415 -391
  35. package/src/conf/namespaces.js +94 -94
  36. package/src/context.js +230 -230
  37. package/src/http-fetch.js +53 -53
  38. package/src/ignore.js +118 -118
  39. package/src/logger.js +122 -122
  40. package/src/options.js +303 -303
  41. package/src/settings-overrides.js +152 -152
  42. package/src/toolchain/deps.js +164 -164
  43. package/src/toolchain/index.js +212 -212
  44. package/src/toolchain/presets.js +356 -340
  45. package/src/toolchain/template.cmake +77 -77
  46. package/src/upload-built.js +265 -265
  47. package/src/version-range.js +301 -301
  48. package/src/zip-safe.js +52 -52
  49. package/src/zip-stream.js +126 -0
@@ -1,452 +1,482 @@
1
- 'use strict';
2
-
3
- const fs = require('fs');
4
- const path = require('path');
5
- const { spawn } = require('child_process');
6
-
7
- const { normalizeRecipe, hasBuildRecipe, VALID_CMAKE_CONFIGS, DEFAULT_RECIPE } = require('../build/recipe');
8
- const { loadMsvcEnv } = require('../build/msvc-env');
9
- const { archToVsPlatform, isVisualStudioGenerator } = require('../toolchain');
10
- const { LOCAL_OVERLAY_FILENAME } = require('../conf');
11
- const { buildContext } = require('../context');
12
- const install = require('./install');
13
- const log = require('../logger');
14
-
15
- // ---------------------------------------------------------------------------
16
- // Pure helpers (unit-testable — no process spawning, no filesystem writes)
17
- // ---------------------------------------------------------------------------
18
-
19
- /**
20
- * Decide whether `wyvrn install` must run before build.
21
- *
22
- * - toolchain-missing : wyvrn_toolchain.cmake does not exist → definitely stale.
23
- * - lock-newer : wyvrn.lock was modified after the toolchain was written,
24
- * so the toolchain's package list is out of date.
25
- * - fresh : no action needed.
26
- *
27
- * @param {{ rootDir: string, manifestPath: string }} args
28
- * @returns {{ stale: boolean, reason: string }}
29
- */
30
- function isToolchainStale({ rootDir, manifestPath }) {
31
- const lockPath = path.join(path.dirname(manifestPath), 'wyvrn.lock');
32
- const toolchainPath = path.join(rootDir, 'wyvrn_internal', 'wyvrn_toolchain.cmake');
33
- const localOverlayPath = path.join(rootDir, LOCAL_OVERLAY_FILENAME);
34
- const presetPath = path.join(rootDir, 'CMakePresets.json');
35
-
36
- if (!fs.existsSync(toolchainPath)) {
37
- return { stale: true, reason: 'toolchain-missing' };
38
- }
39
- if (!fs.existsSync(lockPath)) {
40
- return { stale: false, reason: 'no-lock' };
41
- }
42
-
43
- const lockMtime = fs.statSync(lockPath).mtimeMs;
44
- const toolchainMtime = fs.statSync(toolchainPath).mtimeMs;
45
- if (lockMtime > toolchainMtime) {
46
- return { stale: true, reason: 'lock-newer-than-toolchain' };
47
- }
48
-
49
- // PLAN-CONF.md §7: if wyvrn.local.json was edited after the preset was
50
- // written, the preset's cacheVariables no longer reflect the consumer's
51
- // intent. Re-run install (which regenerates the preset) rather than
52
- // silently running a stale configure.
53
- if (fs.existsSync(localOverlayPath) && fs.existsSync(presetPath)) {
54
- const overlayMtime = fs.statSync(localOverlayPath).mtimeMs;
55
- const presetMtime = fs.statSync(presetPath).mtimeMs;
56
- if (overlayMtime > presetMtime) {
57
- return { stale: true, reason: 'local-overlay-newer-than-preset' };
58
- }
59
- }
60
-
61
- return { stale: false, reason: 'fresh' };
62
- }
63
-
64
- /**
65
- * Resolve the configure preset name from CLI override or profile convention.
66
- *
67
- * @param {{ presetOverride?: string, profileName: string }} args
68
- * @returns {string}
69
- */
70
- function resolvePresetName({ presetOverride, profileName }) {
71
- if (presetOverride) return presetOverride;
72
- return `wyvrn-${profileName}`;
73
- }
74
-
75
- /**
76
- * Compute the build directory for a preset, matching the pattern emitted
77
- * by [src/toolchain/presets.js](../toolchain/presets.js) `${sourceDir}/build/<preset>`.
78
- *
79
- * @param {{ rootDir: string, presetName: string }} args
80
- * @returns {string}
81
- */
82
- function resolveBinaryDir({ rootDir, presetName }) {
83
- return path.join(rootDir, 'build', presetName);
84
- }
85
-
86
- /**
87
- * Args passed to `cmake` for the configure step.
88
- *
89
- * `generator`, when set, appends `-G <name>` after `--preset`. CMake 3.21+
90
- * lets CLI args override a preset's declared fields, so this swaps the
91
- * generator at configure time without touching CMakePresets.json. Note:
92
- * CMake bakes the chosen generator into the cache, so switching generators
93
- * for an existing build directory requires `--clean` (otherwise CMake
94
- * errors with "CMAKE_GENERATOR has changed").
95
- *
96
- * `vsPlatform`, when set, appends `-A <platform>`. Only meaningful for
97
- * Visual Studio generators they default to x64 when unspecified, which
98
- * silently produces a 64-bit build even for a 32-bit profile. The caller
99
- * is responsible for deriving this from the profile arch only when the
100
- * effective generator is VS (see `isVisualStudioGenerator` +
101
- * `archToVsPlatform`).
102
- *
103
- * @param {{ presetName: string, generator?: string|null, vsPlatform?: string|null }} args
104
- * @returns {string[]}
105
- */
106
- function buildConfigureArgs({
107
- presetName,
108
- generator = null,
109
- vsPlatform = null,
110
- confSets = null,
111
- confUnsets = null,
112
- }) {
113
- const args = ['--preset', presetName];
114
- if (generator) args.push('-G', generator);
115
- if (vsPlatform) args.push('-A', vsPlatform);
116
-
117
- // PLAN-CONF.md §7: CLI --conf at build time applies as -D / -U
118
- // overrides on top of whatever's baked into the preset. We do NOT
119
- // silently regenerate the preset — that's a separate install.
120
- // Confined to the cmake.cache namespace; other namespaces have
121
- // different sinks (phase 3) and don't apply to `cmake --preset`.
122
- if (confSets) {
123
- const prefix = 'cmake.cache.';
124
- for (const key of Object.keys(confSets).sort()) {
125
- if (!key.startsWith(prefix)) continue;
126
- const cmakeKey = key.slice(prefix.length);
127
- args.push('-D', `${cmakeKey}=${confSets[key]}`);
128
- }
129
- }
130
- if (confUnsets) {
131
- const prefix = 'cmake.cache.';
132
- for (const key of [...confUnsets].sort()) {
133
- if (!key.startsWith(prefix)) continue;
134
- args.push('-U', key.slice(prefix.length));
135
- }
136
- }
137
-
138
- return args;
139
- }
140
-
141
- /**
142
- * Resolve the list of CMake configs to build (and optionally install).
143
- *
144
- * Precedence:
145
- * 1. `--all-configs` → recipe.configs (falls back to all four canonical
146
- * configs when no recipe declares any).
147
- * 2. `--config` comma-separated list, validated against
148
- * VALID_CMAKE_CONFIGS. Order preserved. Duplicates removed.
149
- * 3. default → `['Release']`.
150
- *
151
- * Configure runs once per `wyvrnpm build` invocation regardless — multi-
152
- * config generators (VS, Xcode, Ninja Multi-Config) produce all configs
153
- * from one configure step. Single-config generators (plain Ninja, Make)
154
- * bake CMAKE_BUILD_TYPE into the cache, so `cmake --build --config X`
155
- * effectively ignores X for them; building two configs against a single-
156
- * config generator rebuilds the same binary twice. That's wasteful but
157
- * correct; fixing it properly requires per-config build dirs, which is
158
- * out of scope for this helper.
159
- *
160
- * @param {{ argv: object, recipeConfigs?: string[] }} args
161
- * @returns {string[]}
162
- */
163
- function resolveConfigList({ argv, recipeConfigs = null }) {
164
- if (argv.allConfigs) {
165
- const fromRecipe = (recipeConfigs ?? []).filter((c) => VALID_CMAKE_CONFIGS.has(c));
166
- return fromRecipe.length > 0 ? [...fromRecipe] : [...DEFAULT_RECIPE.configs];
167
- }
168
-
169
- const raw = typeof argv.config === 'string' && argv.config.trim() ? argv.config : 'Release';
170
- const list = raw.split(',').map((s) => s.trim()).filter(Boolean);
171
-
172
- const invalid = list.filter((c) => !VALID_CMAKE_CONFIGS.has(c));
173
- if (invalid.length > 0) {
174
- const valid = [...VALID_CMAKE_CONFIGS].join(' | ');
175
- throw new Error(`invalid --config "${invalid.join(', ')}" expected ${valid}`);
176
- }
177
-
178
- const seen = new Set();
179
- const deduped = [];
180
- for (const c of list) {
181
- if (!seen.has(c)) { seen.add(c); deduped.push(c); }
182
- }
183
- return deduped.length > 0 ? deduped : ['Release'];
184
- }
185
-
186
- /**
187
- * Args passed to `cmake` for the build step.
188
- *
189
- * @param {{
190
- * binaryDir: string,
191
- * config: string,
192
- * target?: string,
193
- * verbose?: boolean,
194
- * passthru?: string[],
195
- * }} args
196
- * @returns {string[]}
197
- */
198
- function buildBuildArgs({ binaryDir, config, target, verbose, passthru = [] }) {
199
- const args = ['--build', binaryDir, '--config', config];
200
- if (verbose) args.push('--verbose');
201
- if (target) args.push('--target', target);
202
- if (passthru.length > 0) args.push('--', ...passthru);
203
- return args;
204
- }
205
-
206
- /**
207
- * Args passed to `cmake --install` for the install step.
208
- *
209
- * @param {{
210
- * binaryDir: string,
211
- * config: string,
212
- * prefix?: string, // override CMAKE_INSTALL_PREFIX at install time
213
- * }} args
214
- * @returns {string[]}
215
- */
216
- function buildInstallArgs({ binaryDir, config, prefix }) {
217
- const args = ['--install', binaryDir, '--config', config];
218
- if (prefix) args.push('--prefix', prefix);
219
- return args;
220
- }
221
-
222
- // ---------------------------------------------------------------------------
223
- // Spawn wrapper
224
- // ---------------------------------------------------------------------------
225
-
226
- /**
227
- * Run `cmake` with the given args. Inherits stdio for real-time output.
228
- * Rejects if the process exits non-zero or fails to spawn.
229
- *
230
- * When `env` is provided, it replaces the ambient environment (used to
231
- * activate MSVC via vcvarsall for non-VS generators on Windows).
232
- *
233
- * @param {string[]} args
234
- * @param {NodeJS.ProcessEnv|null} [env]
235
- * @returns {Promise<void>}
236
- */
237
- function runCmake(args, env = null) {
238
- return new Promise((resolve, reject) => {
239
- const opts = { stdio: 'inherit' };
240
- if (env) opts.env = env;
241
- const proc = spawn('cmake', args, opts);
242
- proc.on('error', (err) => {
243
- reject(new Error(
244
- `Failed to spawn cmake: ${err.message}\n` +
245
- ' Is CMake installed and on PATH?',
246
- ));
247
- });
248
- proc.on('close', (code) => {
249
- if (code === 0) resolve();
250
- else reject(new Error(`cmake ${args.join(' ')} exited with code ${code}`));
251
- });
252
- });
253
- }
254
-
255
- // ---------------------------------------------------------------------------
256
- // Command entry
257
- // ---------------------------------------------------------------------------
258
-
259
- /**
260
- * `wyvrnpm build` — configure + build via the generated CMake preset.
261
- *
262
- * Defaults:
263
- * - preset : `wyvrn-<profileName>` (from --profile or config.defaultProfile)
264
- * - config : Release
265
- * - auto-install when toolchain is missing / older than wyvrn.lock
266
- *
267
- * @param {object} argv
268
- */
269
- async function build(argv) {
270
- // Shared per-invocation state. See src/context.js +
271
- // claude/PLAN-COMMAND-CONTEXT.md. Parses --conf up front so a malformed
272
- // flag fails here instead of after cmake's already configured.
273
- let ctx;
274
- try {
275
- ctx = buildContext(argv);
276
- } catch (err) {
277
- log.error(err.message);
278
- process.exit(1);
279
- }
280
-
281
- const { rootDir, manifestPath } = ctx;
282
- const { name: profileName, profile: activeProfile } = ctx.profiles.host;
283
- const presetName = resolvePresetName({ presetOverride: argv.preset, profileName });
284
-
285
- // Resolve the list of configs up front so a malformed --config fails
286
- // before auto-install kicks off. `--all-configs` consults the recipe
287
- // (if any); otherwise comma-separated --config, defaulting to Release.
288
- let configList;
289
- try {
290
- const recipe = hasBuildRecipe(ctx.manifest)
291
- ? (() => { try { return normalizeRecipe(ctx.manifest.build, null); } catch { return null; } })()
292
- : null;
293
- configList = resolveConfigList({ argv, recipeConfigs: recipe?.configs ?? null });
294
- } catch (err) {
295
- log.error(err.message);
296
- process.exit(1);
297
- }
298
-
299
- // ── Stale check / auto-install ────────────────────────────────────────────
300
- // `--auto-install` gates the "run `wyvrnpm install` first when the
301
- // toolchain is missing or older than wyvrn.lock" behaviour. The separate
302
- // `--install` flag below runs `cmake --install` AFTER build; the two are
303
- // deliberately distinct concepts.
304
- const staleness = isToolchainStale({ rootDir, manifestPath });
305
- if (staleness.stale) {
306
- if (argv.autoInstall === false) {
307
- log.error(
308
- `toolchain is stale (${staleness.reason}) and --no-auto-install was passed.\n` +
309
- ' Run: wyvrnpm install',
310
- );
311
- process.exit(1);
312
- }
313
- log.info(`Toolchain ${staleness.reason} — running install first`);
314
- await install({
315
- manifest: argv.manifest,
316
- root: argv.root,
317
- profile: argv.profile,
318
- source: argv.source,
319
- platform: argv.platform ?? 'win_x64',
320
- timeout: argv.timeout ?? 300,
321
- });
322
- }
323
-
324
- const binaryDir = resolveBinaryDir({ rootDir, presetName });
325
-
326
- // ── Clean ─────────────────────────────────────────────────────────────────
327
- if (argv.clean && fs.existsSync(binaryDir)) {
328
- log.info(`Removing ${binaryDir}`);
329
- fs.rmSync(binaryDir, { recursive: true, force: true });
330
- }
331
-
332
- // ── MSVC env activation (Windows + non-VS generator) ───────────────────
333
- // When the preset's generator is Ninja / Ninja Multi-Config / Make / etc.
334
- // and the active profile is MSVC, we need `vcvarsall.bat` to have been
335
- // sourced — otherwise cmake's compiler detection fails with "CXX compiler
336
- // identification is unknown". VS generators sidestep this because MSBuild
337
- // finds MSVC via its own toolset resolution.
338
- //
339
- // Same mechanism the source-build path already uses (src/build/msvc-env.js).
340
- let cmakeEnv = null;
341
- let effectiveGenerator = argv.generator ?? null;
342
- let effectiveProfile = null;
343
- try {
344
- effectiveProfile = activeProfile;
345
- // Which generator will the preset actually use? Read it from the
346
- // project's own recipe if one exists. If not, default to null and let
347
- // cmake/CMakePresets decide in which case MSVC env activation is
348
- // skipped (because VS is usually the default on Windows).
349
- const manifest = ctx.manifest;
350
- let presetGenerator = null;
351
- if (hasBuildRecipe(manifest)) {
352
- try {
353
- const recipe = normalizeRecipe(manifest.build, null);
354
- if (recipe.generators.length > 0) [presetGenerator] = recipe.generators;
355
- } catch { /* templates need options → skip env activation */ }
356
- }
357
- // CLI --generator wins — that is the generator CMake will actually use,
358
- // so it is the one that determines whether vcvarsall must be activated.
359
- if (!effectiveGenerator) effectiveGenerator = presetGenerator;
360
- cmakeEnv = await loadMsvcEnv({
361
- profile: activeProfile,
362
- skipForGenerator: effectiveGenerator,
363
- });
364
- } catch (err) {
365
- log.warn(`Could not resolve MSVC env activation: ${err.message}`);
366
- }
367
-
368
- // ── Configure ─────────────────────────────────────────────────────────────
369
- // Auto-emit `-A <platform>` for Visual Studio generators, mapped from the
370
- // active profile's arch. VS otherwise defaults to x64 regardless of the
371
- // profile, which breaks x86 builds: CMake produces 64-bit artefacts but
372
- // find_package() rejects the 32-bit installed dependencies with "version
373
- // not compatible" (actually a CMAKE_SIZEOF_VOID_P mismatch, not a
374
- // version-number check).
375
- let vsPlatform = null;
376
- if (isVisualStudioGenerator(effectiveGenerator) && effectiveProfile) {
377
- vsPlatform = archToVsPlatform(effectiveProfile.arch);
378
- if (vsPlatform) {
379
- log.info(`Visual Studio generator + arch=${effectiveProfile.arch} → passing -A ${vsPlatform}`);
380
- }
381
- }
382
-
383
- // CLI --conf → -D / -U passthrough (PLAN-CONF.md §7). The preset's
384
- // baked-in cacheVariables (from the last install) already reflect the
385
- // recipe + local-overlay layers; CLI conf layers on top for this
386
- // invocation only. ctx.cliConf is pre-parsed by buildContext; malformed
387
- // values already threw above.
388
- const cliConfSets = ctx.cliConf.sets;
389
- const cliConfUnsets = ctx.cliConf.unsets;
390
-
391
- const configureArgs = buildConfigureArgs({
392
- presetName,
393
- generator: argv.generator,
394
- vsPlatform,
395
- confSets: cliConfSets,
396
- confUnsets: cliConfUnsets,
397
- });
398
- log.info(`cmake ${configureArgs.join(' ')}`);
399
- await runCmake(configureArgs, cmakeEnv);
400
-
401
- // ── Build (+ optional install) per config ─────────────────────────────────
402
- // Configure ran once above. Multi-config generators (VS, Xcode, Ninja
403
- // Multi-Config) produce every config from that one configure step; for
404
- // single-config generators each iteration simply re-`cmake --build`s the
405
- // same baked CMAKE_BUILD_TYPE (harmless duplication). `--install` runs
406
- // per config too — the usual reason to want multi-config is so that a
407
- // downstream `find_package()` can pick whichever it needs.
408
- if (configList.length > 1) {
409
- log.info(`Building ${configList.length} configs: ${configList.join(', ')}`);
410
- }
411
-
412
- for (const config of configList) {
413
- const buildArgs = buildBuildArgs({
414
- binaryDir,
415
- config,
416
- target: argv.target,
417
- verbose: argv.verbose,
418
- passthru: argv['--'] ?? [],
419
- });
420
- log.info(`cmake ${buildArgs.join(' ')}`);
421
- await runCmake(buildArgs, cmakeEnv);
422
-
423
- if (argv.install) {
424
- const installArgs = buildInstallArgs({
425
- binaryDir,
426
- config,
427
- prefix: argv.installPrefix,
428
- });
429
- log.info(`cmake ${installArgs.join(' ')}`);
430
- await runCmake(installArgs, cmakeEnv);
431
- }
432
- }
433
-
434
- const suffix = argv.install
435
- ? ` + installed${argv.installPrefix ? ` to ${argv.installPrefix}` : ''}`
436
- : '';
437
- log.success(`Build complete: ${binaryDir} (${configList.join(', ')})${suffix}`);
438
- }
439
-
440
- module.exports = build;
441
- module.exports.default = build;
442
-
443
- // Exported for unit tests
444
- module.exports._helpers = {
445
- isToolchainStale,
446
- resolvePresetName,
447
- resolveBinaryDir,
448
- resolveConfigList,
449
- buildConfigureArgs,
450
- buildBuildArgs,
451
- buildInstallArgs,
452
- };
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { spawn } = require('child_process');
6
+
7
+ const { normalizeRecipe, hasBuildRecipe, VALID_CMAKE_CONFIGS, DEFAULT_RECIPE } = require('../build/recipe');
8
+ const { loadMsvcEnv } = require('../build/msvc-env');
9
+ const { archToVsPlatform, isVisualStudioGenerator } = require('../toolchain');
10
+ const { LOCAL_OVERLAY_FILENAME, readLocalOverlay } = require('../conf');
11
+ const { resolveBinaryDirRoot, DEFAULT_BINARY_DIR_ROOT } = require('../binary-dir');
12
+ const { buildContext } = require('../context');
13
+ const install = require('./install');
14
+ const log = require('../logger');
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Pure helpers (unit-testable — no process spawning, no filesystem writes)
18
+ // ---------------------------------------------------------------------------
19
+
20
+ /**
21
+ * Decide whether `wyvrn install` must run before build.
22
+ *
23
+ * - toolchain-missing : wyvrn_toolchain.cmake does not exist definitely stale.
24
+ * - lock-newer : wyvrn.lock was modified after the toolchain was written,
25
+ * so the toolchain's package list is out of date.
26
+ * - fresh : no action needed.
27
+ *
28
+ * @param {{ rootDir: string, manifestPath: string }} args
29
+ * @returns {{ stale: boolean, reason: string }}
30
+ */
31
+ function isToolchainStale({ rootDir, manifestPath }) {
32
+ const lockPath = path.join(path.dirname(manifestPath), 'wyvrn.lock');
33
+ const toolchainPath = path.join(rootDir, 'wyvrn_internal', 'wyvrn_toolchain.cmake');
34
+ const localOverlayPath = path.join(rootDir, LOCAL_OVERLAY_FILENAME);
35
+ const presetPath = path.join(rootDir, 'CMakePresets.json');
36
+
37
+ if (!fs.existsSync(toolchainPath)) {
38
+ return { stale: true, reason: 'toolchain-missing' };
39
+ }
40
+ if (!fs.existsSync(lockPath)) {
41
+ return { stale: false, reason: 'no-lock' };
42
+ }
43
+
44
+ const lockMtime = fs.statSync(lockPath).mtimeMs;
45
+ const toolchainMtime = fs.statSync(toolchainPath).mtimeMs;
46
+ if (lockMtime > toolchainMtime) {
47
+ return { stale: true, reason: 'lock-newer-than-toolchain' };
48
+ }
49
+
50
+ // PLAN-CONF.md §7: if wyvrn.local.json was edited after the preset was
51
+ // written, the preset's cacheVariables no longer reflect the consumer's
52
+ // intent. Re-run install (which regenerates the preset) rather than
53
+ // silently running a stale configure.
54
+ if (fs.existsSync(localOverlayPath) && fs.existsSync(presetPath)) {
55
+ const overlayMtime = fs.statSync(localOverlayPath).mtimeMs;
56
+ const presetMtime = fs.statSync(presetPath).mtimeMs;
57
+ if (overlayMtime > presetMtime) {
58
+ return { stale: true, reason: 'local-overlay-newer-than-preset' };
59
+ }
60
+ }
61
+
62
+ return { stale: false, reason: 'fresh' };
63
+ }
64
+
65
+ /**
66
+ * Resolve the configure preset name from CLI override or profile convention.
67
+ *
68
+ * @param {{ presetOverride?: string, profileName: string }} args
69
+ * @returns {string}
70
+ */
71
+ function resolvePresetName({ presetOverride, profileName }) {
72
+ if (presetOverride) return presetOverride;
73
+ return `wyvrn-${profileName}`;
74
+ }
75
+
76
+ /**
77
+ * Compute the build directory for a preset, matching the pattern emitted
78
+ * by [src/toolchain/presets.js](../toolchain/presets.js) —
79
+ * `${sourceDir}/<binaryDirRoot>/<preset>`. `binaryDirRoot` defaults to
80
+ * `"build"`; override via `--build-dir` or
81
+ * `wyvrn.local.json:binaryDirRoot`.
82
+ *
83
+ * @param {{ rootDir: string, presetName: string, binaryDirRoot?: string }} args
84
+ * @returns {string}
85
+ */
86
+ function resolveBinaryDir({ rootDir, presetName, binaryDirRoot = DEFAULT_BINARY_DIR_ROOT }) {
87
+ const root = binaryDirRoot || DEFAULT_BINARY_DIR_ROOT;
88
+ return path.join(rootDir, root, presetName);
89
+ }
90
+
91
+ /**
92
+ * Args passed to `cmake` for the configure step.
93
+ *
94
+ * `generator`, when set, appends `-G <name>` after `--preset`. CMake 3.21+
95
+ * lets CLI args override a preset's declared fields, so this swaps the
96
+ * generator at configure time without touching CMakePresets.json. Note:
97
+ * CMake bakes the chosen generator into the cache, so switching generators
98
+ * for an existing build directory requires `--clean` (otherwise CMake
99
+ * errors with "CMAKE_GENERATOR has changed").
100
+ *
101
+ * `vsPlatform`, when set, appends `-A <platform>`. Only meaningful for
102
+ * Visual Studio generators — they default to x64 when unspecified, which
103
+ * silently produces a 64-bit build even for a 32-bit profile. The caller
104
+ * is responsible for deriving this from the profile arch only when the
105
+ * effective generator is VS (see `isVisualStudioGenerator` +
106
+ * `archToVsPlatform`).
107
+ *
108
+ * @param {{ presetName: string, generator?: string|null, vsPlatform?: string|null }} args
109
+ * @returns {string[]}
110
+ */
111
+ function buildConfigureArgs({
112
+ presetName,
113
+ generator = null,
114
+ vsPlatform = null,
115
+ confSets = null,
116
+ confUnsets = null,
117
+ }) {
118
+ const args = ['--preset', presetName];
119
+ if (generator) args.push('-G', generator);
120
+ if (vsPlatform) args.push('-A', vsPlatform);
121
+
122
+ // PLAN-CONF.md §7: CLI --conf at build time applies as -D / -U
123
+ // overrides on top of whatever's baked into the preset. We do NOT
124
+ // silently regenerate the preset — that's a separate install.
125
+ // Confined to the cmake.cache namespace; other namespaces have
126
+ // different sinks (phase 3) and don't apply to `cmake --preset`.
127
+ if (confSets) {
128
+ const prefix = 'cmake.cache.';
129
+ for (const key of Object.keys(confSets).sort()) {
130
+ if (!key.startsWith(prefix)) continue;
131
+ const cmakeKey = key.slice(prefix.length);
132
+ args.push('-D', `${cmakeKey}=${confSets[key]}`);
133
+ }
134
+ }
135
+ if (confUnsets) {
136
+ const prefix = 'cmake.cache.';
137
+ for (const key of [...confUnsets].sort()) {
138
+ if (!key.startsWith(prefix)) continue;
139
+ args.push('-U', key.slice(prefix.length));
140
+ }
141
+ }
142
+
143
+ return args;
144
+ }
145
+
146
+ /**
147
+ * Resolve the list of CMake configs to build (and optionally install).
148
+ *
149
+ * Precedence:
150
+ * 1. `--all-configs` → recipe.configs (falls back to all four canonical
151
+ * configs when no recipe declares any).
152
+ * 2. `--config` comma-separated list, validated against
153
+ * VALID_CMAKE_CONFIGS. Order preserved. Duplicates removed.
154
+ * 3. default `['Release']`.
155
+ *
156
+ * Configure runs once per `wyvrnpm build` invocation regardless multi-
157
+ * config generators (VS, Xcode, Ninja Multi-Config) produce all configs
158
+ * from one configure step. Single-config generators (plain Ninja, Make)
159
+ * bake CMAKE_BUILD_TYPE into the cache, so `cmake --build --config X`
160
+ * effectively ignores X for them; building two configs against a single-
161
+ * config generator rebuilds the same binary twice. That's wasteful but
162
+ * correct; fixing it properly requires per-config build dirs, which is
163
+ * out of scope for this helper.
164
+ *
165
+ * @param {{ argv: object, recipeConfigs?: string[] }} args
166
+ * @returns {string[]}
167
+ */
168
+ function resolveConfigList({ argv, recipeConfigs = null }) {
169
+ if (argv.allConfigs) {
170
+ const fromRecipe = (recipeConfigs ?? []).filter((c) => VALID_CMAKE_CONFIGS.has(c));
171
+ return fromRecipe.length > 0 ? [...fromRecipe] : [...DEFAULT_RECIPE.configs];
172
+ }
173
+
174
+ const raw = typeof argv.config === 'string' && argv.config.trim() ? argv.config : 'Release';
175
+ const list = raw.split(',').map((s) => s.trim()).filter(Boolean);
176
+
177
+ const invalid = list.filter((c) => !VALID_CMAKE_CONFIGS.has(c));
178
+ if (invalid.length > 0) {
179
+ const valid = [...VALID_CMAKE_CONFIGS].join(' | ');
180
+ throw new Error(`invalid --config "${invalid.join(', ')}" — expected ${valid}`);
181
+ }
182
+
183
+ const seen = new Set();
184
+ const deduped = [];
185
+ for (const c of list) {
186
+ if (!seen.has(c)) { seen.add(c); deduped.push(c); }
187
+ }
188
+ return deduped.length > 0 ? deduped : ['Release'];
189
+ }
190
+
191
+ /**
192
+ * Args passed to `cmake` for the build step.
193
+ *
194
+ * @param {{
195
+ * binaryDir: string,
196
+ * config: string,
197
+ * target?: string,
198
+ * verbose?: boolean,
199
+ * passthru?: string[],
200
+ * }} args
201
+ * @returns {string[]}
202
+ */
203
+ function buildBuildArgs({ binaryDir, config, target, verbose, passthru = [] }) {
204
+ const args = ['--build', binaryDir, '--config', config];
205
+ if (verbose) args.push('--verbose');
206
+ if (target) args.push('--target', target);
207
+ if (passthru.length > 0) args.push('--', ...passthru);
208
+ return args;
209
+ }
210
+
211
+ /**
212
+ * Args passed to `cmake --install` for the install step.
213
+ *
214
+ * @param {{
215
+ * binaryDir: string,
216
+ * config: string,
217
+ * prefix?: string, // override CMAKE_INSTALL_PREFIX at install time
218
+ * }} args
219
+ * @returns {string[]}
220
+ */
221
+ function buildInstallArgs({ binaryDir, config, prefix }) {
222
+ const args = ['--install', binaryDir, '--config', config];
223
+ if (prefix) args.push('--prefix', prefix);
224
+ return args;
225
+ }
226
+
227
+ // ---------------------------------------------------------------------------
228
+ // Spawn wrapper
229
+ // ---------------------------------------------------------------------------
230
+
231
+ /**
232
+ * Run `cmake` with the given args. Inherits stdio for real-time output.
233
+ * Rejects if the process exits non-zero or fails to spawn.
234
+ *
235
+ * When `env` is provided, it replaces the ambient environment (used to
236
+ * activate MSVC via vcvarsall for non-VS generators on Windows).
237
+ *
238
+ * @param {string[]} args
239
+ * @param {NodeJS.ProcessEnv|null} [env]
240
+ * @returns {Promise<void>}
241
+ */
242
+ function runCmake(args, env = null) {
243
+ return new Promise((resolve, reject) => {
244
+ const opts = { stdio: 'inherit' };
245
+ if (env) opts.env = env;
246
+ const proc = spawn('cmake', args, opts);
247
+ proc.on('error', (err) => {
248
+ reject(new Error(
249
+ `Failed to spawn cmake: ${err.message}\n` +
250
+ ' Is CMake installed and on PATH?',
251
+ ));
252
+ });
253
+ proc.on('close', (code) => {
254
+ if (code === 0) resolve();
255
+ else reject(new Error(`cmake ${args.join(' ')} exited with code ${code}`));
256
+ });
257
+ });
258
+ }
259
+
260
+ // ---------------------------------------------------------------------------
261
+ // Command entry
262
+ // ---------------------------------------------------------------------------
263
+
264
+ /**
265
+ * `wyvrnpm build` configure + build via the generated CMake preset.
266
+ *
267
+ * Defaults:
268
+ * - preset : `wyvrn-<profileName>` (from --profile or config.defaultProfile)
269
+ * - config : Release
270
+ * - auto-install when toolchain is missing / older than wyvrn.lock
271
+ *
272
+ * @param {object} argv
273
+ */
274
+ async function build(argv) {
275
+ // Shared per-invocation state. See src/context.js +
276
+ // claude/PLAN-COMMAND-CONTEXT.md. Parses --conf up front so a malformed
277
+ // flag fails here instead of after cmake's already configured.
278
+ let ctx;
279
+ try {
280
+ ctx = buildContext(argv);
281
+ } catch (err) {
282
+ log.error(err.message);
283
+ process.exit(1);
284
+ }
285
+
286
+ const { rootDir, manifestPath } = ctx;
287
+ const { name: profileName, profile: activeProfile } = ctx.profiles.host;
288
+ const presetName = resolvePresetName({ presetOverride: argv.preset, profileName });
289
+
290
+ // Resolve binaryDirRoot the same way install does (CLI > overlay >
291
+ // default) so `cmake --build <binaryDir>` lands at the same path the
292
+ // baked preset configured into. A mismatch (e.g. install ran with
293
+ // --build-dir foo but build did not) surfaces as a "no CMakeCache.txt"
294
+ // error from cmake — same failure mode as a --generator mismatch
295
+ // today. Persisting the value via wyvrn.local.json sidesteps the
296
+ // need to repeat the flag.
297
+ let binaryDirRoot;
298
+ try {
299
+ const overlay = readLocalOverlay(rootDir);
300
+ binaryDirRoot = resolveBinaryDirRoot({
301
+ cliBuildDir: argv.buildDir,
302
+ localOverlayBinaryDirRoot: overlay.binaryDirRoot,
303
+ });
304
+ } catch (err) {
305
+ log.error(err.message);
306
+ process.exit(1);
307
+ }
308
+
309
+ // Resolve the list of configs up front so a malformed --config fails
310
+ // before auto-install kicks off. `--all-configs` consults the recipe
311
+ // (if any); otherwise comma-separated --config, defaulting to Release.
312
+ let configList;
313
+ try {
314
+ const recipe = hasBuildRecipe(ctx.manifest)
315
+ ? (() => { try { return normalizeRecipe(ctx.manifest.build, null); } catch { return null; } })()
316
+ : null;
317
+ configList = resolveConfigList({ argv, recipeConfigs: recipe?.configs ?? null });
318
+ } catch (err) {
319
+ log.error(err.message);
320
+ process.exit(1);
321
+ }
322
+
323
+ // ── Stale check / auto-install ────────────────────────────────────────────
324
+ // `--auto-install` gates the "run `wyvrnpm install` first when the
325
+ // toolchain is missing or older than wyvrn.lock" behaviour. The separate
326
+ // `--install` flag below runs `cmake --install` AFTER build; the two are
327
+ // deliberately distinct concepts.
328
+ const staleness = isToolchainStale({ rootDir, manifestPath });
329
+ if (staleness.stale) {
330
+ if (argv.autoInstall === false) {
331
+ log.error(
332
+ `toolchain is stale (${staleness.reason}) and --no-auto-install was passed.\n` +
333
+ ' Run: wyvrnpm install',
334
+ );
335
+ process.exit(1);
336
+ }
337
+ log.info(`Toolchain ${staleness.reason} running install first`);
338
+ await install({
339
+ manifest: argv.manifest,
340
+ root: argv.root,
341
+ profile: argv.profile,
342
+ source: argv.source,
343
+ platform: argv.platform ?? 'win_x64',
344
+ timeout: argv.timeout ?? 300,
345
+ // Forward --build-dir so the auto-install bakes the same
346
+ // binaryDir into the regenerated preset. Without this, `wyvrnpm
347
+ // build --build-dir foo` would auto-install with the default
348
+ // "build" root and then promptly fail when this command tries
349
+ // to read CMakeCache.txt out of `foo/wyvrn-<profile>`.
350
+ buildDir: argv.buildDir,
351
+ });
352
+ }
353
+
354
+ const binaryDir = resolveBinaryDir({ rootDir, presetName, binaryDirRoot });
355
+
356
+ // ── Clean ─────────────────────────────────────────────────────────────────
357
+ if (argv.clean && fs.existsSync(binaryDir)) {
358
+ log.info(`Removing ${binaryDir}`);
359
+ fs.rmSync(binaryDir, { recursive: true, force: true });
360
+ }
361
+
362
+ // ── MSVC env activation (Windows + non-VS generator) ───────────────────
363
+ // When the preset's generator is Ninja / Ninja Multi-Config / Make / etc.
364
+ // and the active profile is MSVC, we need `vcvarsall.bat` to have been
365
+ // sourced otherwise cmake's compiler detection fails with "CXX compiler
366
+ // identification is unknown". VS generators sidestep this because MSBuild
367
+ // finds MSVC via its own toolset resolution.
368
+ //
369
+ // Same mechanism the source-build path already uses (src/build/msvc-env.js).
370
+ let cmakeEnv = null;
371
+ let effectiveGenerator = argv.generator ?? null;
372
+ let effectiveProfile = null;
373
+ try {
374
+ effectiveProfile = activeProfile;
375
+ // Which generator will the preset actually use? Read it from the
376
+ // project's own recipe if one exists. If not, default to null and let
377
+ // cmake/CMakePresets decide — in which case MSVC env activation is
378
+ // skipped (because VS is usually the default on Windows).
379
+ const manifest = ctx.manifest;
380
+ let presetGenerator = null;
381
+ if (hasBuildRecipe(manifest)) {
382
+ try {
383
+ const recipe = normalizeRecipe(manifest.build, null);
384
+ if (recipe.generators.length > 0) [presetGenerator] = recipe.generators;
385
+ } catch { /* templates need options skip env activation */ }
386
+ }
387
+ // CLI --generator wins — that is the generator CMake will actually use,
388
+ // so it is the one that determines whether vcvarsall must be activated.
389
+ if (!effectiveGenerator) effectiveGenerator = presetGenerator;
390
+ cmakeEnv = await loadMsvcEnv({
391
+ profile: activeProfile,
392
+ skipForGenerator: effectiveGenerator,
393
+ });
394
+ } catch (err) {
395
+ log.warn(`Could not resolve MSVC env activation: ${err.message}`);
396
+ }
397
+
398
+ // ── Configure ─────────────────────────────────────────────────────────────
399
+ // Auto-emit `-A <platform>` for Visual Studio generators, mapped from the
400
+ // active profile's arch. VS otherwise defaults to x64 regardless of the
401
+ // profile, which breaks x86 builds: CMake produces 64-bit artefacts but
402
+ // find_package() rejects the 32-bit installed dependencies with "version
403
+ // not compatible" (actually a CMAKE_SIZEOF_VOID_P mismatch, not a
404
+ // version-number check).
405
+ let vsPlatform = null;
406
+ if (isVisualStudioGenerator(effectiveGenerator) && effectiveProfile) {
407
+ vsPlatform = archToVsPlatform(effectiveProfile.arch);
408
+ if (vsPlatform) {
409
+ log.info(`Visual Studio generator + arch=${effectiveProfile.arch} passing -A ${vsPlatform}`);
410
+ }
411
+ }
412
+
413
+ // CLI --conf → -D / -U passthrough (PLAN-CONF.md §7). The preset's
414
+ // baked-in cacheVariables (from the last install) already reflect the
415
+ // recipe + local-overlay layers; CLI conf layers on top for this
416
+ // invocation only. ctx.cliConf is pre-parsed by buildContext; malformed
417
+ // values already threw above.
418
+ const cliConfSets = ctx.cliConf.sets;
419
+ const cliConfUnsets = ctx.cliConf.unsets;
420
+
421
+ const configureArgs = buildConfigureArgs({
422
+ presetName,
423
+ generator: argv.generator,
424
+ vsPlatform,
425
+ confSets: cliConfSets,
426
+ confUnsets: cliConfUnsets,
427
+ });
428
+ log.info(`cmake ${configureArgs.join(' ')}`);
429
+ await runCmake(configureArgs, cmakeEnv);
430
+
431
+ // ── Build (+ optional install) per config ─────────────────────────────────
432
+ // Configure ran once above. Multi-config generators (VS, Xcode, Ninja
433
+ // Multi-Config) produce every config from that one configure step; for
434
+ // single-config generators each iteration simply re-`cmake --build`s the
435
+ // same baked CMAKE_BUILD_TYPE (harmless duplication). `--install` runs
436
+ // per config too — the usual reason to want multi-config is so that a
437
+ // downstream `find_package()` can pick whichever it needs.
438
+ if (configList.length > 1) {
439
+ log.info(`Building ${configList.length} configs: ${configList.join(', ')}`);
440
+ }
441
+
442
+ for (const config of configList) {
443
+ const buildArgs = buildBuildArgs({
444
+ binaryDir,
445
+ config,
446
+ target: argv.target,
447
+ verbose: argv.verbose,
448
+ passthru: argv['--'] ?? [],
449
+ });
450
+ log.info(`cmake ${buildArgs.join(' ')}`);
451
+ await runCmake(buildArgs, cmakeEnv);
452
+
453
+ if (argv.install) {
454
+ const installArgs = buildInstallArgs({
455
+ binaryDir,
456
+ config,
457
+ prefix: argv.installPrefix,
458
+ });
459
+ log.info(`cmake ${installArgs.join(' ')}`);
460
+ await runCmake(installArgs, cmakeEnv);
461
+ }
462
+ }
463
+
464
+ const suffix = argv.install
465
+ ? ` + installed${argv.installPrefix ? ` to ${argv.installPrefix}` : ''}`
466
+ : '';
467
+ log.success(`Build complete: ${binaryDir} (${configList.join(', ')})${suffix}`);
468
+ }
469
+
470
+ module.exports = build;
471
+ module.exports.default = build;
472
+
473
+ // Exported for unit tests
474
+ module.exports._helpers = {
475
+ isToolchainStale,
476
+ resolvePresetName,
477
+ resolveBinaryDir,
478
+ resolveConfigList,
479
+ buildConfigureArgs,
480
+ buildBuildArgs,
481
+ buildInstallArgs,
482
+ };