wyvrnpm 2.8.1 → 2.9.0

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.
@@ -1,665 +1,674 @@
1
- 'use strict';
2
-
3
- const fs = require('fs');
4
- const path = require('path');
5
-
6
- const { readManifest, normalizeDependencies } = require('../manifest');
7
- const { resolveDependencies } = require('../resolve');
8
- const { downloadDependencies } = require('../download');
9
- const { readConfig } = require('../config');
10
- const { resolveSourceAuth } = require('../auth');
11
- const { resolveEffectiveConf, cmakeCacheVariables, unflattenConf } = require('../conf');
12
- const { loadProfile, hashProfile, formatProfile, mergeProfile } = require('../profile');
13
- const { generateToolchain, generateCMakePresets } = require('../toolchain');
14
- const { normalizeRecipe, hasBuildRecipe } = require('../build/recipe');
15
- const {
16
- normalizeOptionsDeclaration,
17
- resolveEffectiveOptions,
18
- parseCliOptions,
19
- } = require('../options');
20
- const {
21
- normalizeSettingsOverrides,
22
- resolveOverrideSettings,
23
- } = require('../settings-overrides');
24
- const {
25
- resolveUploadDestination,
26
- createUploadStats,
27
- formatUploadSummary,
28
- } = require('../upload-built');
29
- const log = require('../logger');
30
-
31
- /**
32
- * Resolves and downloads all dependencies declared in the manifest, then writes
33
- * a wyvrn.lock file recording exactly what was installed.
34
- *
35
- * v2 behaviour:
36
- * - Loads the named build profile (--profile flag config.defaultProfile "default")
37
- * - Each dependency can override individual profile settings via its `settings` field
38
- * - Lock file records the full profile + per-package SHA256, profileHash, and git metadata
39
- *
40
- * @param {object} argv
41
- */
42
- async function install(argv) {
43
- const manifestPath = path.resolve(argv.manifest);
44
- const rootDir = path.resolve(argv.root);
45
- const buildMode = argv.build ?? 'never';
46
- const uploadBuilt = argv.uploadBuilt === true;
47
- const jsonOut = argv.format === 'json';
48
-
49
- // Under --format=json, stdout is reserved for the final JSON payload.
50
- // All info/success lines route to stderr via the logger.
51
- if (jsonOut) log.setJsonMode(true);
52
-
53
- // --upload-built implies a build step happened. `--build=never` +
54
- // `--upload-built` is a usage error — fail fast rather than silently
55
- // accept something that cannot upload anything.
56
- if (uploadBuilt && buildMode === 'never') {
57
- log.error(
58
- '--upload-built requires --build=missing (or --build=always).\n' +
59
- ' Nothing will be built, so there is nothing to upload.',
60
- );
61
- process.exit(1);
62
- }
63
-
64
- if (buildMode === 'always') {
65
- log.error(
66
- '--build=always is not implemented (requires PLAN-BUILD-MISSING phase 3).\n' +
67
- ' Use --build=never (default) or --build=missing.',
68
- );
69
- process.exit(1);
70
- }
71
-
72
- // ── Auth / sources ────────────────────────────────────────────────────────
73
- let packageSources;
74
- if (argv.source && argv.source.length > 0) {
75
- packageSources = argv.source;
76
- } else {
77
- const config = readConfig();
78
- packageSources = config.installSources.map((s) => s.url);
79
- if (packageSources.length > 0) {
80
- log.info(`Using ${packageSources.length} source(s) from config`);
81
- }
82
- }
83
-
84
- if (packageSources.length === 0) {
85
- log.warn(
86
- 'no sources configured. ' +
87
- 'Pass --source <url> or run: wyvrnpm configure add-source --kind install ...',
88
- );
89
- }
90
-
91
- // ── Active build profile ──────────────────────────────────────────────────
92
- const config = readConfig();
93
- const profileName = argv.profile ?? config.defaultProfile ?? 'default';
94
- const { profile: baseProfile, fromFile } = loadProfile(profileName);
95
-
96
- log.info(`Profile: "${profileName}"${fromFile ? '' : ' (auto-detected)'}`);
97
- console.log(formatProfile(baseProfile).split('\n').map((l) => ` ${l}`).join('\n'));
98
- console.log(` Profile hash : ${hashProfile(baseProfile)}`);
99
-
100
- // ── Lock file ─────────────────────────────────────────────────────────────
101
- const lockPath = path.join(path.dirname(manifestPath), 'wyvrn.lock');
102
- const lockedVersions = new Map();
103
-
104
- if (fs.existsSync(lockPath)) {
105
- try {
106
- const lock = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
107
- for (const [name, entry] of Object.entries(lock.packages ?? {})) {
108
- const ver = typeof entry === 'string' ? entry : entry.version;
109
- if (ver) lockedVersions.set(name, ver);
110
- }
111
- log.info(`Lock file found ${lockedVersions.size} pinned package(s)`);
112
- } catch {
113
- log.warn('could not read wyvrn.lock, resolving from scratch');
114
- }
115
- }
116
-
117
- // ── Read manifest ─────────────────────────────────────────────────────────
118
- const manifest = readManifest(manifestPath);
119
-
120
- // Normalize to { name: { version, settings } }
121
- const normalizedDeps = normalizeDependencies(manifest.dependencies ?? manifest.Dependencies);
122
-
123
- // For resolution, only pass name → version (settings don't affect resolution)
124
- const depsForResolution = Object.fromEntries(
125
- Object.entries(normalizedDeps).map(([name, d]) => [name, d.version]),
126
- );
127
-
128
- // Per-package source pinning (EVALUATION.md S4). Deps declaring
129
- // `"source": "primary-s3"` restrict to that named source's URL from
130
- // config typosquat / mirror-shadow defense. Unknown name → fail
131
- // fast (silent fallback to fan-out would defeat the point).
132
- //
133
- // `argv.source` overrides config entirely; if the user passes
134
- // `--source <url>` but a dep pins a name, we can't resolve the name
135
- // warn and fall back to fan-out across the CLI-supplied URLs.
136
- //
137
- // Transitive deps continue to fan out — a pinned top-level dep's
138
- // recipe can reference whatever it wants.
139
- const sourcesByName = new Map();
140
- if (!argv.source || argv.source.length === 0) {
141
- for (const s of config.installSources ?? []) {
142
- if (s.name && s.url) sourcesByName.set(s.name, s.url);
143
- }
144
- }
145
- const pinnedSources = new Map();
146
- for (const [name, d] of Object.entries(normalizedDeps)) {
147
- if (!d.source) continue;
148
- if (argv.source && argv.source.length > 0) {
149
- log.warn(
150
- `"${name}" pins source "${d.source}" but --source was passed on CLI — ` +
151
- `pin ignored for this run`,
152
- );
153
- continue;
154
- }
155
- const url = sourcesByName.get(d.source);
156
- if (!url) {
157
- log.error(
158
- `"${name}" pins source "${d.source}" but no install source by that name is configured. ` +
159
- `Run "wyvrnpm configure list" to see configured sources.`,
160
- );
161
- process.exit(1);
162
- }
163
- pinnedSources.set(name, url);
164
- log.info(`${name}: pinned to source "${d.source}"`);
165
- }
166
-
167
- log.info(
168
- `Installing dependencies for "${manifest.name}" (platform: ${argv.platform})`,
169
- );
170
-
171
- // Manifests collected during resolution — reused below to read each dep's
172
- // recipe-declared `options` block without a second HTTP round trip.
173
- const depManifests = new Map();
174
-
175
- const resolvedDeps = await resolveDependencies(
176
- depsForResolution,
177
- packageSources,
178
- argv.platform,
179
- fetch,
180
- lockedVersions,
181
- depManifests,
182
- // F13: prefer the consumer's own profileHash when picking which build
183
- // entry to read wyvrn.json from. Today every published build for a
184
- // given (name, version) ships the same manifest, so this is harmless;
185
- // it future-proofs against per-profile manifest variance.
186
- hashProfile(baseProfile),
187
- pinnedSources,
188
- );
189
-
190
- // CLI `-o <pkg>:<name>=<value>` overrides, parsed into { pkg → {name: value} }.
191
- const cliOptionsByPkg = parseCliOptions(argv.option);
192
-
193
- // F11: pattern-based settings overrides. `manifest.settingsOverrides`
194
- // is { "<glob>": { <setting>: <value> }, ... }. Validated + sorted by
195
- // specificity once here; `resolveOverrideSettings(depName, ...)` then
196
- // returns the merged-from-patterns settings for each dep. Literal
197
- // per-dep `settings` still override these (authors pinning a specific
198
- // dep win over a team-wide pattern).
199
- let settingsOverrides;
200
- try {
201
- settingsOverrides = normalizeSettingsOverrides(manifest.settingsOverrides);
202
- } catch (err) {
203
- log.error(err.message);
204
- process.exit(1);
205
- }
206
-
207
- // ── Build per-dependency enriched map ─────────────────────────────────────
208
- // Each dep gets:
209
- // - an effective profile (base + per-dep settings overrides)
210
- // - effective options (recipe defaults + consumer overrides + CLI overrides)
211
- // - profileHash computed from both (options only hashed in when declared)
212
- const enrichedDeps = new Map();
213
- for (const [name, version] of resolvedDeps) {
214
- // F11: layer pattern overrides first (less-specific applied first
215
- // inside resolveOverrideSettings), then the literal per-dep
216
- // settings on top. Literal wins over any pattern.
217
- const patternOverrides = resolveOverrideSettings(name, settingsOverrides);
218
- const literalOverrides = normalizedDeps[name]?.settings ?? {};
219
- const depOverrides = { ...patternOverrides, ...literalOverrides };
220
- const effectiveProfile = mergeProfile(baseProfile, depOverrides);
221
-
222
- const depManifest = depManifests.get(name);
223
- const declaration = depManifest?.options
224
- ? normalizeOptionsDeclaration(depManifest.options, `${name}@${version}`)
225
- : null;
226
- const effectiveOptions = resolveEffectiveOptions(
227
- declaration,
228
- normalizedDeps[name]?.options ?? {},
229
- cliOptionsByPkg[name] ?? {},
230
- `${name}@${version}`,
231
- );
232
-
233
- const profileHash = hashProfile(effectiveProfile, effectiveOptions);
234
-
235
- if (Object.keys(depOverrides).length > 0) {
236
- log.info(
237
- `${name}: settings override → profileHash ${profileHash}` +
238
- ` (${JSON.stringify(depOverrides)})`,
239
- );
240
- }
241
- if (effectiveOptions) {
242
- log.info(`${name}: options → ${JSON.stringify(effectiveOptions)}`);
243
- }
244
-
245
- enrichedDeps.set(name, {
246
- version,
247
- profileHash,
248
- profile: effectiveProfile,
249
- options: effectiveOptions,
250
- // S4: pinned install source (name + URL). Only set for direct
251
- // deps that declared `"source": "..."`; transitive deps stay
252
- // undefined so they fan out across configured sources.
253
- pinnedSource: pinnedSources.has(name)
254
- ? { name: normalizedDeps[name].source, url: pinnedSources.get(name) }
255
- : undefined,
256
- });
257
- }
258
-
259
- // ── Resolve effective conf (PLAN-CONF.md §4) ──────────────────────────────
260
- // Four layers (phase 2): CLI > wyvrn.local.json > profile > recipe.
261
- // Non-ABI; does not fold into profileHash. Feeds into CMakePresets.json
262
- // cacheVariables below. `baseProfile` may carry its own `conf` block —
263
- // an auto-detected profile (no on-disk file) does not.
264
- let effectiveConfResult;
265
- try {
266
- effectiveConfResult = resolveEffectiveConf({
267
- manifest,
268
- rootDir,
269
- cliConf: argv.conf, // yargs array: string[]
270
- profile: baseProfile, // named profile may carry a `conf` block (phase 2)
271
- });
272
- } catch (err) {
273
- log.error(`conf resolution failed: ${err.message}`);
274
- process.exit(1);
275
- }
276
- const effectiveConf = effectiveConfResult.flat;
277
- if (effectiveConfResult.localOverlayPath) {
278
- log.info(`Local overlay: ${effectiveConfResult.localOverlayPath}`);
279
- // One-line nudge when wyvrn.local.json exists but isn't gitignored —
280
- // catching the existing-project case init.js's scaffold can't cover.
281
- // Non-fatal; we never rewrite the user's .gitignore at install time.
282
- const gitignorePath = path.join(rootDir, '.gitignore');
283
- if (fs.existsSync(gitignorePath)) {
284
- const gi = fs.readFileSync(gitignorePath, 'utf8');
285
- const ignored = gi.split(/\r?\n/).some((l) => l.trim() === 'wyvrn.local.json');
286
- if (!ignored) {
287
- log.warn(
288
- `wyvrn.local.json is present but not in ${gitignorePath}` +
289
- `add a line "wyvrn.local.json" to keep dev-local overrides out of version control.`,
290
- );
291
- }
292
- }
293
- }
294
- if (Object.keys(effectiveConf).length > 0) {
295
- log.info(`Effective conf: ${JSON.stringify(effectiveConf)}`);
296
- }
297
-
298
- // ── --dry-run: print the resolved plan and exit (EVALUATION.md F6) ────────
299
- // Runs the resolver + profile/options math end-to-end, then reports what
300
- // WOULD be installed no download, no wyvrn.lock, no wyvrn_internal/, no
301
- // toolchain. Preserves --format=json for CI ingestion.
302
- if (argv.dryRun) {
303
- emitDryRunPlan({
304
- enrichedDeps,
305
- resolvedDeps,
306
- baseProfile,
307
- profileName,
308
- buildMode,
309
- packageSources,
310
- effectiveConf,
311
- jsonOut,
312
- });
313
- return;
314
- }
315
-
316
- const razerDir = path.join(rootDir, 'wyvrn_internal');
317
-
318
- // Extract auth from the first install source (if any). Prefer
319
- // `tokenEnv` over a literal `token` stored in config — closes
320
- // EVALUATION.md S8 (no plaintext secret persisted, value is looked up
321
- // fresh each run).
322
- const firstInstallSrc = config.installSources?.[0];
323
- const downloadOptions = {
324
- awsProfile: firstInstallSrc?.profile ?? undefined,
325
- token: resolveSourceAuth(
326
- { token: argv.token, tokenEnv: argv.tokenEnv },
327
- firstInstallSrc,
328
- ).token,
329
- buildMode,
330
- profileName,
331
- };
332
-
333
- // ── Resolve upload destination up-front when --upload-built is set ──────
334
- // Failing here (no publish source configured) avoids spending minutes on
335
- // a source-build whose output we can't deliver.
336
- if (uploadBuilt) {
337
- const resolved = resolveUploadDestination(
338
- {
339
- uploadSource: argv.uploadSource,
340
- awsProfile: argv.awsProfile ?? firstInstallSrc?.profile,
341
- token: resolveSourceAuth(
342
- { token: argv.token, tokenEnv: argv.tokenEnv },
343
- firstInstallSrc,
344
- ).token,
345
- },
346
- config,
347
- );
348
- if (!resolved) {
349
- log.error(
350
- '--upload-built was passed but no upload destination could be resolved.\n' +
351
- ' Pass --upload-source <url-or-name> or configure a publish source:\n' +
352
- ' wyvrnpm configure add-source --kind publish --name ... --url ...',
353
- );
354
- process.exit(1);
355
- }
356
- downloadOptions.uploadBuilt = true;
357
- downloadOptions.uploadSource = resolved.uploadSource;
358
- downloadOptions.uploadAuth = resolved.uploadAuth;
359
- downloadOptions.uploadStats = createUploadStats();
360
-
361
- if (resolved.uploadSource !== (packageSources[0] ?? '')) {
362
- log.info(
363
- `upload-built: install source(s) and upload source differ —\n` +
364
- ` install: ${packageSources.join(', ') || '(none)'}\n` +
365
- ` upload : ${resolved.uploadSource}`,
366
- );
367
- }
368
- }
369
-
370
- const lockEntries = await downloadDependencies(
371
- enrichedDeps,
372
- packageSources,
373
- argv.platform,
374
- razerDir,
375
- fetch,
376
- argv.timeout,
377
- downloadOptions,
378
- );
379
-
380
- // ── Write v2 lock file ────────────────────────────────────────────────────
381
- const sortedPackages = {};
382
- for (const key of [...resolvedDeps.keys()].sort()) {
383
- sortedPackages[key] = lockEntries?.get(key) ?? { version: resolvedDeps.get(key) };
384
- }
385
-
386
- const lockData = {
387
- wyvrnVersion: 2,
388
- generatedAt: new Date().toISOString(),
389
- platform: argv.platform,
390
- profileName,
391
- profile: baseProfile,
392
- profileHash: hashProfile(baseProfile),
393
- packages: sortedPackages,
394
- // PLAN-CONF.md §4.8.1: informational only, not consulted during
395
- // resolution. Omitted entirely when empty so pre-conf locks
396
- // remain byte-identical to today's output.
397
- ...(Object.keys(effectiveConf).length > 0
398
- ? { effectiveConf: unflattenConf(effectiveConf) }
399
- : {}),
400
- };
401
-
402
- fs.writeFileSync(lockPath, JSON.stringify(lockData, null, 2) + '\n', 'utf8');
403
- log.info(`Lock file written to ${lockPath}`);
404
-
405
- // ── CMake toolchain generation ────────────────────────────────────────────
406
- const packageNames = [...resolvedDeps.keys()].sort();
407
- const { toolchain } = generateToolchain({
408
- destDir: razerDir,
409
- profile: baseProfile,
410
- profileName,
411
- profileHash: hashProfile(baseProfile),
412
- packageNames,
413
- });
414
- log.info(`CMake toolchain written to ${toolchain}`);
415
-
416
- // ── CMakePresets.json emission ────────────────────────────────────────────
417
- const toolchainRelPath = path
418
- .relative(rootDir, toolchain)
419
- .replace(/\\/g, '/');
420
-
421
- // Derive generator + cacheVariables from the project's own `build` recipe
422
- // so `wyvrnpm build` locally honours the same knobs the source-build /
423
- // publish paths use. Without this, `build.configure` is advisory metadata
424
- // and CMake falls back to its default generator (VS on Windows) — which
425
- // breaks publishers whose recipe disables example/test subprojects (F15).
426
- let presetGenerator = null;
427
- let presetCacheVariables = null;
428
- let presetInstallDir = null;
429
- let presetConfigs = null;
430
- if (hasBuildRecipe(manifest)) {
431
- try {
432
- // Resolve the project's own options to expand ${options.*} in its
433
- // configure args. When the project declares no options, this is null
434
- // and the recipe's configure / buildArgs must not contain templates
435
- // (normalizeRecipe enforces that).
436
- const projectDeclaration = manifest.options
437
- ? normalizeOptionsDeclaration(manifest.options, manifest.name)
438
- : null;
439
- const projectCliOverrides = cliOptionsByPkg[manifest.name] ?? {};
440
- const projectEffectiveOptions = resolveEffectiveOptions(
441
- projectDeclaration, {}, projectCliOverrides, manifest.name,
442
- );
443
- const recipe = normalizeRecipe(manifest.build, projectEffectiveOptions, manifest);
444
-
445
- if (recipe.generators.length > 0) {
446
- // First entry in the generator fallback chain. Users whose system
447
- // lacks the first pick can override via CMakeUserPresets.json
448
- // (which CMake merges in automatically).
449
- [presetGenerator] = recipe.generators;
450
- }
451
-
452
- // Fold -DKEY=VALUE from recipe.configure into cacheVariables. Other
453
- // configure args (anything not matching `-DKEY=VALUE`) can't be
454
- // expressed in a CMake preset and are silently skipped here — the
455
- // source-build path still sees them verbatim.
456
- const cacheVars = {};
457
- for (const arg of recipe.configure) {
458
- const match = arg.match(/^-D([^=]+)=(.*)$/);
459
- if (match) cacheVars[match[1]] = match[2];
460
- }
461
- if (Object.keys(cacheVars).length > 0) presetCacheVariables = cacheVars;
462
-
463
- // Propagate `build.installDir` so that `wyvrnpm build --install` lands
464
- // under the project's build tree instead of CMake's system default
465
- // (C:/Program Files on Windows, /usr/local on Unix). Same semantics as
466
- // the source-build path which passes -DCMAKE_INSTALL_PREFIX explicitly.
467
- presetInstallDir = recipe.installDir;
468
-
469
- // Propagate `build.configs` so multi-config generators (Ninja
470
- // Multi-Config, Visual Studio) generate build files for every
471
- // config the recipe declares. Pre-2.8.1 the preset only carried
472
- // Debug/Release, so a recipe listing MinSizeRel would fail with
473
- // `ninja: error: loading 'build-MinSizeRel.ninja'`. CLAUDE.md §8
474
- // documented that sharp edge; this closes it.
475
- presetConfigs = recipe.configs;
476
- } catch (err) {
477
- log.warn(
478
- `could not derive CMake preset config from the project's build recipe: ${err.message}\n` +
479
- ` the preset will be emitted without a generator or recipe cacheVariables. ` +
480
- `Fix the recipe or override via CMakeUserPresets.json.`,
481
- );
482
- }
483
- }
484
-
485
- // PLAN-CONF.md §4.3 / §5 presets sink: fold effectiveConf's
486
- // cmake.cache namespace into the preset's cacheVariables. Runs
487
- // regardless of whether the project has its own build recipe
488
- // a consumer with no recipe but a wyvrn.local.json conf still gets
489
- // their overrides into the generated preset.
490
- //
491
- // Conf wins on collisions with build.configure-derived vars. The
492
- // recipe-normaliser blocks author-side collisions (§4.7); a
493
- // collision here can only come from a consumer's CLI --conf or
494
- // wyvrn.local.json overriding a value the author also sets via
495
- // build.configure. Warn so the override is visible.
496
- const confCacheVars = cmakeCacheVariables(effectiveConf);
497
- if (Object.keys(confCacheVars).length > 0) {
498
- presetCacheVariables = presetCacheVariables ?? {};
499
- for (const [k, v] of Object.entries(confCacheVars)) {
500
- if (k in presetCacheVariables && presetCacheVariables[k] !== v) {
501
- log.warn(
502
- `conf override: cmake.cache.${k} = ${JSON.stringify(v)} ` +
503
- `supersedes build.configure's ${JSON.stringify(presetCacheVariables[k])}`,
504
- );
505
- }
506
- presetCacheVariables[k] = v;
507
- }
508
- }
509
-
510
- const presetResult = generateCMakePresets({
511
- projectRoot: rootDir,
512
- profileName,
513
- profileHash: hashProfile(baseProfile),
514
- toolchainRelPath,
515
- generator: presetGenerator,
516
- extraCacheVariables: presetCacheVariables,
517
- installDir: presetInstallDir,
518
- profileArch: baseProfile.arch ?? null,
519
- configs: presetConfigs,
520
- });
521
- if (presetResult.action === 'skipped') {
522
- log.warn(
523
- 'could not write presets — both CMakePresets.json and ' +
524
- 'CMakeUserPresets.json exist and are user-owned. Add this to your presets:\n' +
525
- ` { "name": "wyvrn-${profileName}", "cacheVariables": { "CMAKE_TOOLCHAIN_FILE": "\${sourceDir}/${toolchainRelPath}" } }`,
526
- );
527
- } else {
528
- log.info(
529
- `CMake presets ${presetResult.action}: ${presetResult.path}` +
530
- (presetResult.isUser ? ' (user presets — CMakePresets.json is user-owned)' : ''),
531
- );
532
- }
533
-
534
- const count = resolvedDeps.size;
535
- log.success(`Done — ${count} package${count !== 1 ? 's' : ''} installed.`);
536
-
537
- // ── upload-built summary ────────────────────────────────────────────────
538
- const uploadSummary = formatUploadSummary(downloadOptions.uploadStats);
539
- if (uploadSummary) log.info(uploadSummary);
540
-
541
- // ── JSON payload (--format=json) ────────────────────────────────────────
542
- // All log.info/success above have gone to stderr thanks to setJsonMode.
543
- // stdout gets exactly one JSON object, no trailing text.
544
- if (jsonOut) {
545
- const packages = [...resolvedDeps.keys()].sort().map((name) => {
546
- const entry = lockEntries?.get(name) ?? {};
547
- const consumerSpec = normalizedDeps[name]?.version ?? null;
548
- const resolvedVer = entry.version ?? resolvedDeps.get(name);
549
- const versionRange = (consumerSpec && consumerSpec !== resolvedVer) ? consumerSpec : null;
550
- return {
551
- name,
552
- version: resolvedVer,
553
- versionRange,
554
- profileHash: entry.profileHash ?? null,
555
- options: entry.options ?? null,
556
- contentSha256: entry.contentSha256 ?? null,
557
- gitSha: entry.gitSha ?? null,
558
- resolvedFrom: entry.resolvedFrom ?? null,
559
- };
560
- });
561
-
562
- const stats = downloadOptions.uploadStats;
563
- const uploadSummaryJson = stats ? {
564
- uploaded: stats.uploaded.length,
565
- skipped: stats.skipped.length,
566
- failed: stats.failed.length,
567
- } : null;
568
-
569
- const payload = {
570
- command: 'install',
571
- wyvrnpmVersion: require('../../package.json').version,
572
- profile: baseProfile,
573
- profileName,
574
- profileHash: hashProfile(baseProfile),
575
- packages,
576
- lockFile: lockPath,
577
- uploadSummary: uploadSummaryJson,
578
- };
579
- process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
580
- }
581
- }
582
-
583
- // ---------------------------------------------------------------------------
584
- // --dry-run plan emitter (EVALUATION.md F6)
585
- // ---------------------------------------------------------------------------
586
-
587
- /**
588
- * Render the resolved install plan WITHOUT touching disk. Called from
589
- * `install()` when `--dry-run` is passed; exits the install flow before
590
- * download, lockfile write, or toolchain generation.
591
- *
592
- * Text mode: human-readable per-dep summary.
593
- * JSON mode (`--format=json`): structured payload on stdout with
594
- * `command: "install-dry-run"` so log consumers can disambiguate it
595
- * from a real install.
596
- */
597
- function emitDryRunPlan({
598
- enrichedDeps, resolvedDeps, baseProfile, profileName, buildMode,
599
- packageSources, effectiveConf = {}, jsonOut,
600
- }) {
601
- const baseHash = hashProfile(baseProfile);
602
- const packages = [...resolvedDeps.keys()].sort().map((name) => {
603
- const entry = enrichedDeps.get(name) ?? {};
604
- return {
605
- name,
606
- version: resolvedDeps.get(name),
607
- profileHash: entry.profileHash ?? null,
608
- options: entry.options ?? null,
609
- // Whether the dep's effective profile differs from the consumer's
610
- // base (i.e. per-dep settings override produced a distinct hash).
611
- overridesBaseProfile: entry.profileHash && entry.profileHash !== baseHash,
612
- };
613
- });
614
-
615
- if (jsonOut) {
616
- const payload = {
617
- command: 'install-dry-run',
618
- wyvrnpmVersion: require('../../package.json').version,
619
- profile: baseProfile,
620
- profileName,
621
- profileHash: baseHash,
622
- buildMode,
623
- sources: packageSources,
624
- effectiveConf: Object.keys(effectiveConf).length > 0
625
- ? unflattenConf(effectiveConf)
626
- : {},
627
- packages,
628
- };
629
- process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
630
- return;
631
- }
632
-
633
- log.info('── install plan (--dry-run) ─────────────────────────────────────');
634
- log.info(`Profile : "${profileName}" [${baseHash}]`);
635
- log.info(`Build : ${buildMode}`);
636
- log.info(`Sources : ${packageSources.length > 0 ? packageSources.join(', ') : '(none)'}`);
637
- const confKeys = Object.keys(effectiveConf).sort();
638
- if (confKeys.length > 0) {
639
- log.info(`Conf : ${confKeys.map((k) => `${k}=${effectiveConf[k]}`).join(', ')}`);
640
- }
641
- if (packages.length === 0) {
642
- log.info('(no dependencies)');
643
- return;
644
- }
645
-
646
- const nameW = Math.max(4, ...packages.map((p) => p.name.length));
647
- const versionW = Math.max(7, ...packages.map((p) => p.version.length));
648
- const header = ` ${'name'.padEnd(nameW)} ${'version'.padEnd(versionW)} profileHash options`;
649
- log.info(header);
650
- log.info(` ${'-'.repeat(nameW)} ${'-'.repeat(versionW)} ---------------- --------------------`);
651
- for (const p of packages) {
652
- const override = p.overridesBaseProfile ? ' *' : ' ';
653
- const optStr = p.options ? JSON.stringify(p.options) : '-';
654
- log.info(
655
- ` ${p.name.padEnd(nameW)} ${p.version.padEnd(versionW)} ${p.profileHash ?? '(none) '}${override} ${optStr}`,
656
- );
657
- }
658
- log.info('');
659
- log.info('(nothing was downloaded; wyvrn.lock / wyvrn_internal / toolchain untouched)');
660
- if (packages.some((p) => p.overridesBaseProfile)) {
661
- log.info(' * = per-dep settings override produces a profileHash distinct from the base.');
662
- }
663
- }
664
-
665
- module.exports = install;
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ const { normalizeDependencies } = require('../manifest');
7
+ const { resolveDependencies } = require('../resolve');
8
+ const { downloadDependencies } = require('../download');
9
+ const { wyvrnFetch } = require('../http-fetch');
10
+ const { resolveEffectiveConf, cmakeCacheVariables, unflattenConf } = require('../conf');
11
+ const { hashProfile, formatProfile, mergeProfile } = require('../profile');
12
+ const { generateToolchain, generateCMakePresets } = require('../toolchain');
13
+ const { normalizeRecipe, hasBuildRecipe } = require('../build/recipe');
14
+ const { buildContext } = require('../context');
15
+ const {
16
+ normalizeOptionsDeclaration,
17
+ resolveEffectiveOptions,
18
+ } = require('../options');
19
+ const {
20
+ normalizeSettingsOverrides,
21
+ resolveOverrideSettings,
22
+ } = require('../settings-overrides');
23
+ const {
24
+ resolveUploadDestination,
25
+ createUploadStats,
26
+ formatUploadSummary,
27
+ } = require('../upload-built');
28
+ const log = require('../logger');
29
+
30
+ /**
31
+ * Resolves and downloads all dependencies declared in the manifest, then writes
32
+ * a wyvrn.lock file recording exactly what was installed.
33
+ *
34
+ * v2 behaviour:
35
+ * - Loads the named build profile (--profile flag → config.defaultProfile → "default")
36
+ * - Each dependency can override individual profile settings via its `settings` field
37
+ * - Lock file records the full profile + per-package SHA256, profileHash, and git metadata
38
+ *
39
+ * @param {object} argv
40
+ */
41
+ async function install(argv) {
42
+ // Shared per-invocation state (config, profile, manifest lazy, auth,
43
+ // CLI options/conf parsing, JSON-mode toggle). See src/context.js +
44
+ // claude/PLAN-COMMAND-CONTEXT.md. buildContext throws on input errors
45
+ // so they surface here with a single log+exit rather than deep in the
46
+ // command.
47
+ let ctx;
48
+ try {
49
+ ctx = buildContext(argv);
50
+ } catch (err) {
51
+ log.error(err.message);
52
+ process.exit(1);
53
+ }
54
+
55
+ const {
56
+ rootDir,
57
+ manifestPath,
58
+ config,
59
+ cliOptions: cliOptionsByPkg,
60
+ flags,
61
+ } = ctx;
62
+ const { name: profileName, profile: baseProfile, fromFile } = ctx.profiles.host;
63
+ const { build: buildMode, uploadBuilt } = flags;
64
+ const jsonOut = flags.format === 'json';
65
+
66
+ // --upload-built implies a build step happened. `--build=never` +
67
+ // `--upload-built` is a usage error — fail fast rather than silently
68
+ // accept something that cannot upload anything.
69
+ if (uploadBuilt && buildMode === 'never') {
70
+ log.error(
71
+ '--upload-built requires --build=missing (or --build=always).\n' +
72
+ ' Nothing will be built, so there is nothing to upload.',
73
+ );
74
+ process.exit(1);
75
+ }
76
+
77
+ if (buildMode === 'always') {
78
+ log.error(
79
+ '--build=always is not implemented (requires PLAN-BUILD-MISSING phase 3).\n' +
80
+ ' Use --build=never (default) or --build=missing.',
81
+ );
82
+ process.exit(1);
83
+ }
84
+
85
+ // ── Auth / sources ────────────────────────────────────────────────────────
86
+ // Source filtering stays per-command (install takes URL array + pin
87
+ // resolution; publish takes name-or-URL; show takes URL array). The
88
+ // ctx-level config read above means we do NOT call readConfig() again.
89
+ let packageSources;
90
+ if (argv.source && argv.source.length > 0) {
91
+ packageSources = argv.source;
92
+ } else {
93
+ packageSources = config.installSources.map((s) => s.url);
94
+ if (packageSources.length > 0) {
95
+ log.info(`Using ${packageSources.length} source(s) from config`);
96
+ }
97
+ }
98
+
99
+ if (packageSources.length === 0) {
100
+ log.warn(
101
+ 'no sources configured. ' +
102
+ 'Pass --source <url> or run: wyvrnpm configure add-source --kind install ...',
103
+ );
104
+ }
105
+
106
+ // ── Active build profile ──────────────────────────────────────────────────
107
+ // Resolved once during buildContext(); destructured at the top of this
108
+ // function. `profileName`, `baseProfile`, `fromFile` are already in scope.
109
+ log.info(`Profile: "${profileName}"${fromFile ? '' : ' (auto-detected)'}`);
110
+ console.log(formatProfile(baseProfile).split('\n').map((l) => ` ${l}`).join('\n'));
111
+ console.log(` Profile hash : ${hashProfile(baseProfile)}`);
112
+
113
+ // ── Lock file ─────────────────────────────────────────────────────────────
114
+ const lockPath = path.join(path.dirname(manifestPath), 'wyvrn.lock');
115
+ const lockedVersions = new Map();
116
+
117
+ if (fs.existsSync(lockPath)) {
118
+ try {
119
+ const lock = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
120
+ for (const [name, entry] of Object.entries(lock.packages ?? {})) {
121
+ const ver = typeof entry === 'string' ? entry : entry.version;
122
+ if (ver) lockedVersions.set(name, ver);
123
+ }
124
+ log.info(`Lock file found — ${lockedVersions.size} pinned package(s)`);
125
+ } catch {
126
+ log.warn('could not read wyvrn.lock, resolving from scratch');
127
+ }
128
+ }
129
+
130
+ // ── Read manifest ─────────────────────────────────────────────────────────
131
+ // ctx.manifest is a lazy getter: first access triggers readManifest()
132
+ // and caches the result for the rest of this invocation.
133
+ const manifest = ctx.manifest;
134
+
135
+ // Normalize to { name: { version, settings } }
136
+ const normalizedDeps = normalizeDependencies(manifest.dependencies ?? manifest.Dependencies);
137
+
138
+ // For resolution, only pass name → version (settings don't affect resolution)
139
+ const depsForResolution = Object.fromEntries(
140
+ Object.entries(normalizedDeps).map(([name, d]) => [name, d.version]),
141
+ );
142
+
143
+ // Per-package source pinning (EVALUATION.md S4). Deps declaring
144
+ // `"source": "primary-s3"` restrict to that named source's URL from
145
+ // config typosquat / mirror-shadow defense. Unknown name → fail
146
+ // fast (silent fallback to fan-out would defeat the point).
147
+ //
148
+ // `argv.source` overrides config entirely; if the user passes
149
+ // `--source <url>` but a dep pins a name, we can't resolve the name
150
+ // warn and fall back to fan-out across the CLI-supplied URLs.
151
+ //
152
+ // Transitive deps continue to fan out — a pinned top-level dep's
153
+ // recipe can reference whatever it wants.
154
+ const sourcesByName = new Map();
155
+ if (!argv.source || argv.source.length === 0) {
156
+ for (const s of config.installSources ?? []) {
157
+ if (s.name && s.url) sourcesByName.set(s.name, s.url);
158
+ }
159
+ }
160
+ const pinnedSources = new Map();
161
+ for (const [name, d] of Object.entries(normalizedDeps)) {
162
+ if (!d.source) continue;
163
+ if (argv.source && argv.source.length > 0) {
164
+ log.warn(
165
+ `"${name}" pins source "${d.source}" but --source was passed on CLI — ` +
166
+ `pin ignored for this run`,
167
+ );
168
+ continue;
169
+ }
170
+ const url = sourcesByName.get(d.source);
171
+ if (!url) {
172
+ log.error(
173
+ `"${name}" pins source "${d.source}" but no install source by that name is configured. ` +
174
+ `Run "wyvrnpm configure list" to see configured sources.`,
175
+ );
176
+ process.exit(1);
177
+ }
178
+ pinnedSources.set(name, url);
179
+ log.info(`${name}: pinned to source "${d.source}"`);
180
+ }
181
+
182
+ log.info(
183
+ `Installing dependencies for "${manifest.name}" (platform: ${argv.platform})`,
184
+ );
185
+
186
+ // Manifests collected during resolution — reused below to read each dep's
187
+ // recipe-declared `options` block without a second HTTP round trip.
188
+ const depManifests = new Map();
189
+
190
+ const resolvedDeps = await resolveDependencies(
191
+ depsForResolution,
192
+ packageSources,
193
+ argv.platform,
194
+ wyvrnFetch,
195
+ lockedVersions,
196
+ depManifests,
197
+ // F13: prefer the consumer's own profileHash when picking which build
198
+ // entry to read wyvrn.json from. Today every published build for a
199
+ // given (name, version) ships the same manifest, so this is harmless;
200
+ // it future-proofs against per-profile manifest variance.
201
+ hashProfile(baseProfile),
202
+ pinnedSources,
203
+ );
204
+
205
+ // CLI `-o <pkg>:<name>=<value>` overrides come pre-parsed from
206
+ // buildContext as `cliOptionsByPkg` (shape: { pkg: { name: value } }).
207
+
208
+ // F11: pattern-based settings overrides. `manifest.settingsOverrides`
209
+ // is { "<glob>": { <setting>: <value> }, ... }. Validated + sorted by
210
+ // specificity once here; `resolveOverrideSettings(depName, ...)` then
211
+ // returns the merged-from-patterns settings for each dep. Literal
212
+ // per-dep `settings` still override these (authors pinning a specific
213
+ // dep win over a team-wide pattern).
214
+ let settingsOverrides;
215
+ try {
216
+ settingsOverrides = normalizeSettingsOverrides(manifest.settingsOverrides);
217
+ } catch (err) {
218
+ log.error(err.message);
219
+ process.exit(1);
220
+ }
221
+
222
+ // ── Build per-dependency enriched map ─────────────────────────────────────
223
+ // Each dep gets:
224
+ // - an effective profile (base + per-dep settings overrides)
225
+ // - effective options (recipe defaults + consumer overrides + CLI overrides)
226
+ // - profileHash computed from both (options only hashed in when declared)
227
+ const enrichedDeps = new Map();
228
+ for (const [name, version] of resolvedDeps) {
229
+ // F11: layer pattern overrides first (less-specific applied first
230
+ // inside resolveOverrideSettings), then the literal per-dep
231
+ // settings on top. Literal wins over any pattern.
232
+ const patternOverrides = resolveOverrideSettings(name, settingsOverrides);
233
+ const literalOverrides = normalizedDeps[name]?.settings ?? {};
234
+ const depOverrides = { ...patternOverrides, ...literalOverrides };
235
+ const effectiveProfile = mergeProfile(baseProfile, depOverrides);
236
+
237
+ const depManifest = depManifests.get(name);
238
+ const declaration = depManifest?.options
239
+ ? normalizeOptionsDeclaration(depManifest.options, `${name}@${version}`)
240
+ : null;
241
+ const effectiveOptions = resolveEffectiveOptions(
242
+ declaration,
243
+ normalizedDeps[name]?.options ?? {},
244
+ cliOptionsByPkg[name] ?? {},
245
+ `${name}@${version}`,
246
+ );
247
+
248
+ const profileHash = hashProfile(effectiveProfile, effectiveOptions);
249
+
250
+ if (Object.keys(depOverrides).length > 0) {
251
+ log.info(
252
+ `${name}: settings override profileHash ${profileHash}` +
253
+ ` (${JSON.stringify(depOverrides)})`,
254
+ );
255
+ }
256
+ if (effectiveOptions) {
257
+ log.info(`${name}: options → ${JSON.stringify(effectiveOptions)}`);
258
+ }
259
+
260
+ enrichedDeps.set(name, {
261
+ version,
262
+ profileHash,
263
+ profile: effectiveProfile,
264
+ options: effectiveOptions,
265
+ // S4: pinned install source (name + URL). Only set for direct
266
+ // deps that declared `"source": "..."`; transitive deps stay
267
+ // undefined so they fan out across configured sources.
268
+ pinnedSource: pinnedSources.has(name)
269
+ ? { name: normalizedDeps[name].source, url: pinnedSources.get(name) }
270
+ : undefined,
271
+ });
272
+ }
273
+
274
+ // ── Resolve effective conf (PLAN-CONF.md §4) ──────────────────────────────
275
+ // Four layers (phase 2): CLI > wyvrn.local.json > profile > recipe.
276
+ // Non-ABI; does not fold into profileHash. Feeds into CMakePresets.json
277
+ // cacheVariables below. `baseProfile` may carry its own `conf` block —
278
+ // an auto-detected profile (no on-disk file) does not.
279
+ let effectiveConfResult;
280
+ try {
281
+ effectiveConfResult = resolveEffectiveConf({
282
+ manifest,
283
+ rootDir,
284
+ cliConf: argv.conf, // yargs array: string[]
285
+ profile: baseProfile, // named profile may carry a `conf` block (phase 2)
286
+ });
287
+ } catch (err) {
288
+ log.error(`conf resolution failed: ${err.message}`);
289
+ process.exit(1);
290
+ }
291
+ const effectiveConf = effectiveConfResult.flat;
292
+ if (effectiveConfResult.localOverlayPath) {
293
+ log.info(`Local overlay: ${effectiveConfResult.localOverlayPath}`);
294
+ // One-line nudge when wyvrn.local.json exists but isn't gitignored —
295
+ // catching the existing-project case init.js's scaffold can't cover.
296
+ // Non-fatal; we never rewrite the user's .gitignore at install time.
297
+ const gitignorePath = path.join(rootDir, '.gitignore');
298
+ if (fs.existsSync(gitignorePath)) {
299
+ const gi = fs.readFileSync(gitignorePath, 'utf8');
300
+ const ignored = gi.split(/\r?\n/).some((l) => l.trim() === 'wyvrn.local.json');
301
+ if (!ignored) {
302
+ log.warn(
303
+ `wyvrn.local.json is present but not in ${gitignorePath} — ` +
304
+ `add a line "wyvrn.local.json" to keep dev-local overrides out of version control.`,
305
+ );
306
+ }
307
+ }
308
+ }
309
+ if (Object.keys(effectiveConf).length > 0) {
310
+ log.info(`Effective conf: ${JSON.stringify(effectiveConf)}`);
311
+ }
312
+
313
+ // ── --dry-run: print the resolved plan and exit (EVALUATION.md F6) ────────
314
+ // Runs the resolver + profile/options math end-to-end, then reports what
315
+ // WOULD be installed — no download, no wyvrn.lock, no wyvrn_internal/, no
316
+ // toolchain. Preserves --format=json for CI ingestion.
317
+ if (argv.dryRun) {
318
+ emitDryRunPlan({
319
+ enrichedDeps,
320
+ resolvedDeps,
321
+ baseProfile,
322
+ profileName,
323
+ buildMode,
324
+ packageSources,
325
+ effectiveConf,
326
+ jsonOut,
327
+ });
328
+ return;
329
+ }
330
+
331
+ const razerDir = path.join(rootDir, 'wyvrn_internal');
332
+
333
+ // Extract auth from the first install source (if any). Prefer
334
+ // `tokenEnv` over a literal `token` stored in config closes
335
+ // EVALUATION.md S8 (no plaintext secret persisted, value is looked up
336
+ // fresh each run).
337
+ const firstInstallSrc = config.installSources?.[0];
338
+ const downloadOptions = {
339
+ awsProfile: firstInstallSrc?.profile ?? undefined,
340
+ token: ctx.auth.for(firstInstallSrc).token,
341
+ buildMode,
342
+ profileName,
343
+ };
344
+
345
+ // ── Resolve upload destination up-front when --upload-built is set ──────
346
+ // Failing here (no publish source configured) avoids spending minutes on
347
+ // a source-build whose output we can't deliver.
348
+ if (uploadBuilt) {
349
+ const resolved = resolveUploadDestination(
350
+ {
351
+ uploadSource: argv.uploadSource,
352
+ awsProfile: argv.awsProfile ?? firstInstallSrc?.profile,
353
+ token: ctx.auth.for(firstInstallSrc).token,
354
+ },
355
+ config,
356
+ );
357
+ if (!resolved) {
358
+ log.error(
359
+ '--upload-built was passed but no upload destination could be resolved.\n' +
360
+ ' Pass --upload-source <url-or-name> or configure a publish source:\n' +
361
+ ' wyvrnpm configure add-source --kind publish --name ... --url ...',
362
+ );
363
+ process.exit(1);
364
+ }
365
+ downloadOptions.uploadBuilt = true;
366
+ downloadOptions.uploadSource = resolved.uploadSource;
367
+ downloadOptions.uploadAuth = resolved.uploadAuth;
368
+ downloadOptions.uploadStats = createUploadStats();
369
+
370
+ if (resolved.uploadSource !== (packageSources[0] ?? '')) {
371
+ log.info(
372
+ `upload-built: install source(s) and upload source differ —\n` +
373
+ ` install: ${packageSources.join(', ') || '(none)'}\n` +
374
+ ` upload : ${resolved.uploadSource}`,
375
+ );
376
+ }
377
+ }
378
+
379
+ const lockEntries = await downloadDependencies(
380
+ enrichedDeps,
381
+ packageSources,
382
+ argv.platform,
383
+ razerDir,
384
+ wyvrnFetch,
385
+ argv.timeout,
386
+ downloadOptions,
387
+ );
388
+
389
+ // ── Write v2 lock file ────────────────────────────────────────────────────
390
+ const sortedPackages = {};
391
+ for (const key of [...resolvedDeps.keys()].sort()) {
392
+ sortedPackages[key] = lockEntries?.get(key) ?? { version: resolvedDeps.get(key) };
393
+ }
394
+
395
+ const lockData = {
396
+ wyvrnVersion: 2,
397
+ generatedAt: new Date().toISOString(),
398
+ platform: argv.platform,
399
+ profileName,
400
+ profile: baseProfile,
401
+ profileHash: hashProfile(baseProfile),
402
+ packages: sortedPackages,
403
+ // PLAN-CONF.md §4.8.1: informational only, not consulted during
404
+ // resolution. Omitted entirely when empty so pre-conf locks
405
+ // remain byte-identical to today's output.
406
+ ...(Object.keys(effectiveConf).length > 0
407
+ ? { effectiveConf: unflattenConf(effectiveConf) }
408
+ : {}),
409
+ };
410
+
411
+ fs.writeFileSync(lockPath, JSON.stringify(lockData, null, 2) + '\n', 'utf8');
412
+ log.info(`Lock file written to ${lockPath}`);
413
+
414
+ // ── CMake toolchain generation ────────────────────────────────────────────
415
+ const packageNames = [...resolvedDeps.keys()].sort();
416
+ const { toolchain } = generateToolchain({
417
+ destDir: razerDir,
418
+ profile: baseProfile,
419
+ profileName,
420
+ profileHash: hashProfile(baseProfile),
421
+ packageNames,
422
+ });
423
+ log.info(`CMake toolchain written to ${toolchain}`);
424
+
425
+ // ── CMakePresets.json emission ────────────────────────────────────────────
426
+ const toolchainRelPath = path
427
+ .relative(rootDir, toolchain)
428
+ .replace(/\\/g, '/');
429
+
430
+ // Derive generator + cacheVariables from the project's own `build` recipe
431
+ // so `wyvrnpm build` locally honours the same knobs the source-build /
432
+ // publish paths use. Without this, `build.configure` is advisory metadata
433
+ // and CMake falls back to its default generator (VS on Windows) — which
434
+ // breaks publishers whose recipe disables example/test subprojects (F15).
435
+ let presetGenerator = null;
436
+ let presetCacheVariables = null;
437
+ let presetInstallDir = null;
438
+ let presetConfigs = null;
439
+ if (hasBuildRecipe(manifest)) {
440
+ try {
441
+ // Resolve the project's own options to expand ${options.*} in its
442
+ // configure args. When the project declares no options, this is null
443
+ // and the recipe's configure / buildArgs must not contain templates
444
+ // (normalizeRecipe enforces that).
445
+ const projectDeclaration = manifest.options
446
+ ? normalizeOptionsDeclaration(manifest.options, manifest.name)
447
+ : null;
448
+ const projectCliOverrides = cliOptionsByPkg[manifest.name] ?? {};
449
+ const projectEffectiveOptions = resolveEffectiveOptions(
450
+ projectDeclaration, {}, projectCliOverrides, manifest.name,
451
+ );
452
+ const recipe = normalizeRecipe(manifest.build, projectEffectiveOptions, manifest);
453
+
454
+ if (recipe.generators.length > 0) {
455
+ // First entry in the generator fallback chain. Users whose system
456
+ // lacks the first pick can override via CMakeUserPresets.json
457
+ // (which CMake merges in automatically).
458
+ [presetGenerator] = recipe.generators;
459
+ }
460
+
461
+ // Fold -DKEY=VALUE from recipe.configure into cacheVariables. Other
462
+ // configure args (anything not matching `-DKEY=VALUE`) can't be
463
+ // expressed in a CMake preset and are silently skipped here — the
464
+ // source-build path still sees them verbatim.
465
+ const cacheVars = {};
466
+ for (const arg of recipe.configure) {
467
+ const match = arg.match(/^-D([^=]+)=(.*)$/);
468
+ if (match) cacheVars[match[1]] = match[2];
469
+ }
470
+ if (Object.keys(cacheVars).length > 0) presetCacheVariables = cacheVars;
471
+
472
+ // Propagate `build.installDir` so that `wyvrnpm build --install` lands
473
+ // under the project's build tree instead of CMake's system default
474
+ // (C:/Program Files on Windows, /usr/local on Unix). Same semantics as
475
+ // the source-build path which passes -DCMAKE_INSTALL_PREFIX explicitly.
476
+ presetInstallDir = recipe.installDir;
477
+
478
+ // Propagate `build.configs` so multi-config generators (Ninja
479
+ // Multi-Config, Visual Studio) generate build files for every
480
+ // config the recipe declares. Pre-2.8.1 the preset only carried
481
+ // Debug/Release, so a recipe listing MinSizeRel would fail with
482
+ // `ninja: error: loading 'build-MinSizeRel.ninja'`. CLAUDE.md §8
483
+ // documented that sharp edge; this closes it.
484
+ presetConfigs = recipe.configs;
485
+ } catch (err) {
486
+ log.warn(
487
+ `could not derive CMake preset config from the project's build recipe: ${err.message}\n` +
488
+ ` the preset will be emitted without a generator or recipe cacheVariables. ` +
489
+ `Fix the recipe or override via CMakeUserPresets.json.`,
490
+ );
491
+ }
492
+ }
493
+
494
+ // PLAN-CONF.md §4.3 / §5 presets sink: fold effectiveConf's
495
+ // cmake.cache namespace into the preset's cacheVariables. Runs
496
+ // regardless of whether the project has its own build recipe —
497
+ // a consumer with no recipe but a wyvrn.local.json conf still gets
498
+ // their overrides into the generated preset.
499
+ //
500
+ // Conf wins on collisions with build.configure-derived vars. The
501
+ // recipe-normaliser blocks author-side collisions (§4.7); a
502
+ // collision here can only come from a consumer's CLI --conf or
503
+ // wyvrn.local.json overriding a value the author also sets via
504
+ // build.configure. Warn so the override is visible.
505
+ const confCacheVars = cmakeCacheVariables(effectiveConf);
506
+ if (Object.keys(confCacheVars).length > 0) {
507
+ presetCacheVariables = presetCacheVariables ?? {};
508
+ for (const [k, v] of Object.entries(confCacheVars)) {
509
+ if (k in presetCacheVariables && presetCacheVariables[k] !== v) {
510
+ log.warn(
511
+ `conf override: cmake.cache.${k} = ${JSON.stringify(v)} ` +
512
+ `supersedes build.configure's ${JSON.stringify(presetCacheVariables[k])}`,
513
+ );
514
+ }
515
+ presetCacheVariables[k] = v;
516
+ }
517
+ }
518
+
519
+ const presetResult = generateCMakePresets({
520
+ projectRoot: rootDir,
521
+ profileName,
522
+ profileHash: hashProfile(baseProfile),
523
+ toolchainRelPath,
524
+ generator: presetGenerator,
525
+ extraCacheVariables: presetCacheVariables,
526
+ installDir: presetInstallDir,
527
+ profileArch: baseProfile.arch ?? null,
528
+ configs: presetConfigs,
529
+ });
530
+ if (presetResult.action === 'skipped') {
531
+ log.warn(
532
+ 'could not write presets — both CMakePresets.json and ' +
533
+ 'CMakeUserPresets.json exist and are user-owned. Add this to your presets:\n' +
534
+ ` { "name": "wyvrn-${profileName}", "cacheVariables": { "CMAKE_TOOLCHAIN_FILE": "\${sourceDir}/${toolchainRelPath}" } }`,
535
+ );
536
+ } else {
537
+ log.info(
538
+ `CMake presets ${presetResult.action}: ${presetResult.path}` +
539
+ (presetResult.isUser ? ' (user presets — CMakePresets.json is user-owned)' : ''),
540
+ );
541
+ }
542
+
543
+ const count = resolvedDeps.size;
544
+ log.success(`Done — ${count} package${count !== 1 ? 's' : ''} installed.`);
545
+
546
+ // ── upload-built summary ────────────────────────────────────────────────
547
+ const uploadSummary = formatUploadSummary(downloadOptions.uploadStats);
548
+ if (uploadSummary) log.info(uploadSummary);
549
+
550
+ // ── JSON payload (--format=json) ────────────────────────────────────────
551
+ // All log.info/success above have gone to stderr thanks to setJsonMode.
552
+ // stdout gets exactly one JSON object, no trailing text.
553
+ if (jsonOut) {
554
+ const packages = [...resolvedDeps.keys()].sort().map((name) => {
555
+ const entry = lockEntries?.get(name) ?? {};
556
+ const consumerSpec = normalizedDeps[name]?.version ?? null;
557
+ const resolvedVer = entry.version ?? resolvedDeps.get(name);
558
+ const versionRange = (consumerSpec && consumerSpec !== resolvedVer) ? consumerSpec : null;
559
+ return {
560
+ name,
561
+ version: resolvedVer,
562
+ versionRange,
563
+ profileHash: entry.profileHash ?? null,
564
+ options: entry.options ?? null,
565
+ contentSha256: entry.contentSha256 ?? null,
566
+ gitSha: entry.gitSha ?? null,
567
+ resolvedFrom: entry.resolvedFrom ?? null,
568
+ };
569
+ });
570
+
571
+ const stats = downloadOptions.uploadStats;
572
+ const uploadSummaryJson = stats ? {
573
+ uploaded: stats.uploaded.length,
574
+ skipped: stats.skipped.length,
575
+ failed: stats.failed.length,
576
+ } : null;
577
+
578
+ const payload = {
579
+ command: 'install',
580
+ wyvrnpmVersion: require('../../package.json').version,
581
+ profile: baseProfile,
582
+ profileName,
583
+ profileHash: hashProfile(baseProfile),
584
+ packages,
585
+ lockFile: lockPath,
586
+ uploadSummary: uploadSummaryJson,
587
+ };
588
+ process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
589
+ }
590
+ }
591
+
592
+ // ---------------------------------------------------------------------------
593
+ // --dry-run plan emitter (EVALUATION.md F6)
594
+ // ---------------------------------------------------------------------------
595
+
596
+ /**
597
+ * Render the resolved install plan WITHOUT touching disk. Called from
598
+ * `install()` when `--dry-run` is passed; exits the install flow before
599
+ * download, lockfile write, or toolchain generation.
600
+ *
601
+ * Text mode: human-readable per-dep summary.
602
+ * JSON mode (`--format=json`): structured payload on stdout with
603
+ * `command: "install-dry-run"` so log consumers can disambiguate it
604
+ * from a real install.
605
+ */
606
+ function emitDryRunPlan({
607
+ enrichedDeps, resolvedDeps, baseProfile, profileName, buildMode,
608
+ packageSources, effectiveConf = {}, jsonOut,
609
+ }) {
610
+ const baseHash = hashProfile(baseProfile);
611
+ const packages = [...resolvedDeps.keys()].sort().map((name) => {
612
+ const entry = enrichedDeps.get(name) ?? {};
613
+ return {
614
+ name,
615
+ version: resolvedDeps.get(name),
616
+ profileHash: entry.profileHash ?? null,
617
+ options: entry.options ?? null,
618
+ // Whether the dep's effective profile differs from the consumer's
619
+ // base (i.e. per-dep settings override produced a distinct hash).
620
+ overridesBaseProfile: entry.profileHash && entry.profileHash !== baseHash,
621
+ };
622
+ });
623
+
624
+ if (jsonOut) {
625
+ const payload = {
626
+ command: 'install-dry-run',
627
+ wyvrnpmVersion: require('../../package.json').version,
628
+ profile: baseProfile,
629
+ profileName,
630
+ profileHash: baseHash,
631
+ buildMode,
632
+ sources: packageSources,
633
+ effectiveConf: Object.keys(effectiveConf).length > 0
634
+ ? unflattenConf(effectiveConf)
635
+ : {},
636
+ packages,
637
+ };
638
+ process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
639
+ return;
640
+ }
641
+
642
+ log.info('── install plan (--dry-run) ─────────────────────────────────────');
643
+ log.info(`Profile : "${profileName}" [${baseHash}]`);
644
+ log.info(`Build : ${buildMode}`);
645
+ log.info(`Sources : ${packageSources.length > 0 ? packageSources.join(', ') : '(none)'}`);
646
+ const confKeys = Object.keys(effectiveConf).sort();
647
+ if (confKeys.length > 0) {
648
+ log.info(`Conf : ${confKeys.map((k) => `${k}=${effectiveConf[k]}`).join(', ')}`);
649
+ }
650
+ if (packages.length === 0) {
651
+ log.info('(no dependencies)');
652
+ return;
653
+ }
654
+
655
+ const nameW = Math.max(4, ...packages.map((p) => p.name.length));
656
+ const versionW = Math.max(7, ...packages.map((p) => p.version.length));
657
+ const header = ` ${'name'.padEnd(nameW)} ${'version'.padEnd(versionW)} profileHash options`;
658
+ log.info(header);
659
+ log.info(` ${'-'.repeat(nameW)} ${'-'.repeat(versionW)} ---------------- --------------------`);
660
+ for (const p of packages) {
661
+ const override = p.overridesBaseProfile ? ' *' : ' ';
662
+ const optStr = p.options ? JSON.stringify(p.options) : '-';
663
+ log.info(
664
+ ` ${p.name.padEnd(nameW)} ${p.version.padEnd(versionW)} ${p.profileHash ?? '(none) '}${override} ${optStr}`,
665
+ );
666
+ }
667
+ log.info('');
668
+ log.info('(nothing was downloaded; wyvrn.lock / wyvrn_internal / toolchain untouched)');
669
+ if (packages.some((p) => p.overridesBaseProfile)) {
670
+ log.info(' * = per-dep settings override produces a profileHash distinct from the base.');
671
+ }
672
+ }
673
+
674
+ module.exports = install;