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