wyvrnpm 2.1.0 → 2.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,7 @@
1
1
  'use strict';
2
2
 
3
+ const { substituteTemplateAll } = require('../options');
4
+
3
5
  /**
4
6
  * The `build` section of a source-repo `wyvrn.json` tells wyvrnpm how to
5
7
  * build the package from source when `--build=missing` fires. Only
@@ -50,13 +52,23 @@ const SUPPORTED_SYSTEMS = new Set(['cmake']);
50
52
  * array of strings (fallback chain — tried in order; first available wins).
51
53
  * Both forms produce a `generators: string[]` field in the normalised recipe.
52
54
  *
55
+ * When `effectiveOptions` is supplied, `${options.<name>}` tokens inside
56
+ * `configure` and `buildArgs` are expanded using those values — bool → ON/OFF,
57
+ * strings verbatim. An unknown option in a template is a hard error (fail
58
+ * fast so the bad arg never reaches cmake). See src/options.js
59
+ * `substituteTemplate`. When `effectiveOptions` is null (recipe declares
60
+ * no options), any `${options.*}` token in the recipe also fails fast —
61
+ * recipes don't get to reference undeclared options.
62
+ *
53
63
  * @param {object|null|undefined} rawBuild
64
+ * @param {Record<string, any>|null} [effectiveOptions]
54
65
  * @returns {{ system: string, generators: string[], configure: string[],
55
66
  * buildArgs: string[], installDir: string, sourceSubdir: string,
56
67
  * requiredTools: string[] }}
57
- * @throws {Error} when `system` is set but not supported
68
+ * @throws {Error} when `system` is set but not supported, or when a
69
+ * `${options.<name>}` template references an undeclared option
58
70
  */
59
- function normalizeRecipe(rawBuild) {
71
+ function normalizeRecipe(rawBuild, effectiveOptions = null) {
60
72
  const b = rawBuild ?? {};
61
73
  const system = b.system ?? DEFAULT_RECIPE.system;
62
74
  if (!SUPPORTED_SYSTEMS.has(system)) {
@@ -98,12 +110,26 @@ function normalizeRecipe(rawBuild) {
98
110
  configs = configs.filter((c) => VALID_CMAKE_CONFIGS.has(c));
99
111
  if (configs.length === 0) configs = DEFAULT_RECIPE.configs.slice();
100
112
 
113
+ // Expand ${options.<name>} tokens in args the recipe passes to cmake.
114
+ // Only `configure` and `buildArgs` are user-authored arg lists; the
115
+ // other fields are literals.
116
+ const configure = substituteTemplateAll(
117
+ cleanArray(b.configure),
118
+ effectiveOptions,
119
+ 'build.configure',
120
+ );
121
+ const buildArgs = substituteTemplateAll(
122
+ cleanArray(b.buildArgs),
123
+ effectiveOptions,
124
+ 'build.buildArgs',
125
+ );
126
+
101
127
  return {
102
128
  system,
103
129
  generators,
104
130
  configs,
105
- configure: cleanArray(b.configure),
106
- buildArgs: cleanArray(b.buildArgs),
131
+ configure,
132
+ buildArgs,
107
133
  installDir: b.installDir ?? DEFAULT_RECIPE.installDir,
108
134
  sourceSubdir: b.sourceSubdir ?? DEFAULT_RECIPE.sourceSubdir,
109
135
  requiredTools: cleanArray(b.requiredTools),
@@ -4,9 +4,13 @@ const fs = require('fs');
4
4
  const path = require('path');
5
5
  const { spawn } = require('child_process');
6
6
 
7
- const { readConfig } = require('../config');
8
- const install = require('./install');
9
- const log = require('../logger');
7
+ const { readConfig } = require('../config');
8
+ const { loadProfile } = require('../profile');
9
+ const { readManifest } = require('../manifest');
10
+ const { normalizeRecipe, hasBuildRecipe } = require('../build/recipe');
11
+ const { loadMsvcEnv } = require('../build/msvc-env');
12
+ const install = require('./install');
13
+ const log = require('../logger');
10
14
 
11
15
  // ---------------------------------------------------------------------------
12
16
  // Pure helpers (unit-testable — no process spawning, no filesystem writes)
@@ -117,12 +121,18 @@ function buildInstallArgs({ binaryDir, config, prefix }) {
117
121
  * Run `cmake` with the given args. Inherits stdio for real-time output.
118
122
  * Rejects if the process exits non-zero or fails to spawn.
119
123
  *
124
+ * When `env` is provided, it replaces the ambient environment (used to
125
+ * activate MSVC via vcvarsall for non-VS generators on Windows).
126
+ *
120
127
  * @param {string[]} args
128
+ * @param {NodeJS.ProcessEnv|null} [env]
121
129
  * @returns {Promise<void>}
122
130
  */
123
- function runCmake(args) {
131
+ function runCmake(args, env = null) {
124
132
  return new Promise((resolve, reject) => {
125
- const proc = spawn('cmake', args, { stdio: 'inherit' });
133
+ const opts = { stdio: 'inherit' };
134
+ if (env) opts.env = env;
135
+ const proc = spawn('cmake', args, opts);
126
136
  proc.on('error', (err) => {
127
137
  reject(new Error(
128
138
  `Failed to spawn cmake: ${err.message}\n` +
@@ -192,10 +202,41 @@ async function build(argv) {
192
202
  fs.rmSync(binaryDir, { recursive: true, force: true });
193
203
  }
194
204
 
205
+ // ── MSVC env activation (Windows + non-VS generator) ───────────────────
206
+ // When the preset's generator is Ninja / Ninja Multi-Config / Make / etc.
207
+ // and the active profile is MSVC, we need `vcvarsall.bat` to have been
208
+ // sourced — otherwise cmake's compiler detection fails with "CXX compiler
209
+ // identification is unknown". VS generators sidestep this because MSBuild
210
+ // finds MSVC via its own toolset resolution.
211
+ //
212
+ // Same mechanism the source-build path already uses (src/build/msvc-env.js).
213
+ let cmakeEnv = null;
214
+ try {
215
+ const { profile: activeProfile } = loadProfile(profileName);
216
+ // Which generator will the preset actually use? Read it from the
217
+ // project's own recipe if one exists. If not, default to null and let
218
+ // cmake/CMakePresets decide — in which case MSVC env activation is
219
+ // skipped (because VS is usually the default on Windows).
220
+ const manifest = readManifest(manifestPath);
221
+ let presetGenerator = null;
222
+ if (hasBuildRecipe(manifest)) {
223
+ try {
224
+ const recipe = normalizeRecipe(manifest.build, null);
225
+ if (recipe.generators.length > 0) [presetGenerator] = recipe.generators;
226
+ } catch { /* templates need options → skip env activation */ }
227
+ }
228
+ cmakeEnv = await loadMsvcEnv({
229
+ profile: activeProfile,
230
+ skipForGenerator: presetGenerator,
231
+ });
232
+ } catch (err) {
233
+ log.warn(`Could not resolve MSVC env activation: ${err.message}`);
234
+ }
235
+
195
236
  // ── Configure ─────────────────────────────────────────────────────────────
196
237
  const configureArgs = buildConfigureArgs({ presetName });
197
238
  log.info(`cmake ${configureArgs.join(' ')}`);
198
- await runCmake(configureArgs);
239
+ await runCmake(configureArgs, cmakeEnv);
199
240
 
200
241
  // ── Build ─────────────────────────────────────────────────────────────────
201
242
  const buildArgs = buildBuildArgs({
@@ -206,7 +247,7 @@ async function build(argv) {
206
247
  passthru: argv['--'] ?? [],
207
248
  });
208
249
  log.info(`cmake ${buildArgs.join(' ')}`);
209
- await runCmake(buildArgs);
250
+ await runCmake(buildArgs, cmakeEnv);
210
251
 
211
252
  // ── Install (optional) ────────────────────────────────────────────────────
212
253
  // `--install` runs `cmake --install <binaryDir>` after the build. Prefix
@@ -219,7 +260,7 @@ async function build(argv) {
219
260
  prefix: argv.installPrefix,
220
261
  });
221
262
  log.info(`cmake ${installArgs.join(' ')}`);
222
- await runCmake(installArgs);
263
+ await runCmake(installArgs, cmakeEnv);
223
264
  }
224
265
 
225
266
  const suffix = argv.install
@@ -3,7 +3,11 @@
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
 
6
- const { clearBuildCache, getBuildCacheRoot } = require('../build/cache');
6
+ const {
7
+ clearBuildCache,
8
+ getBuildCacheRoot,
9
+ clearUploadSidecars,
10
+ } = require('../build/cache');
7
11
  const log = require('../logger');
8
12
 
9
13
  /**
@@ -11,10 +15,17 @@ const log = require('../logger');
11
15
  * With `--build-cache`, also clears the machine-wide source-build cache
12
16
  * under `%LOCALAPPDATA%\wyvrnpm\bc\` (shared across projects).
13
17
  *
18
+ * `--uploaded-built` wipes just the consumer's local RECORD of what they
19
+ * have uploaded via `wyvrnpm install --build=missing --upload-built` (the
20
+ * `.uploaded-to.json` sidecars under the build cache). It does NOT touch
21
+ * the registry, the artefact zips, or the source clones. Privacy / local
22
+ * audit cleanup only.
23
+ *
14
24
  * @param {object} argv
15
- * @param {string} argv.root Project root directory.
16
- * @param {string} argv.manifest Path to the manifest file (used to locate wyvrn.lock).
17
- * @param {boolean} [argv.buildCache] When true, also nukes the source-build cache.
25
+ * @param {string} argv.root Project root directory.
26
+ * @param {string} argv.manifest Path to the manifest file (used to locate wyvrn.lock).
27
+ * @param {boolean} [argv.buildCache] When true, also nukes the source-build cache.
28
+ * @param {boolean} [argv.uploadedBuilt] When true, wipes upload sidecars only.
18
29
  * @returns {Promise<void>}
19
30
  */
20
31
  async function clean(argv) {
@@ -22,20 +33,27 @@ async function clean(argv) {
22
33
  const razerDir = path.join(rootDir, 'wyvrn_internal');
23
34
  const lockPath = path.join(path.dirname(path.resolve(argv.manifest)), 'wyvrn.lock');
24
35
 
36
+ // --uploaded-built is a targeted cleanup mode. When passed on its own,
37
+ // skip the default wyvrn_internal/ + wyvrn.lock scrub so a user can
38
+ // "forget uploads" without also wiping their project-local install.
39
+ const targetedOnly = argv.uploadedBuilt && !argv.buildCache;
40
+
25
41
  let removedAnything = false;
26
42
 
27
- if (fs.existsSync(razerDir)) {
28
- fs.rmSync(razerDir, { recursive: true, force: true });
29
- log.info(`Removed ${razerDir}`);
30
- removedAnything = true;
31
- } else {
32
- log.info(`Nothing to clean at ${razerDir}`);
33
- }
43
+ if (!targetedOnly) {
44
+ if (fs.existsSync(razerDir)) {
45
+ fs.rmSync(razerDir, { recursive: true, force: true });
46
+ log.info(`Removed ${razerDir}`);
47
+ removedAnything = true;
48
+ } else {
49
+ log.info(`Nothing to clean at ${razerDir}`);
50
+ }
34
51
 
35
- if (fs.existsSync(lockPath)) {
36
- fs.unlinkSync(lockPath);
37
- log.info(`Removed ${lockPath}`);
38
- removedAnything = true;
52
+ if (fs.existsSync(lockPath)) {
53
+ fs.unlinkSync(lockPath);
54
+ log.info(`Removed ${lockPath}`);
55
+ removedAnything = true;
56
+ }
39
57
  }
40
58
 
41
59
  if (argv.buildCache) {
@@ -46,6 +64,12 @@ async function clean(argv) {
46
64
  } else {
47
65
  log.info(`No source-build cache at ${getBuildCacheRoot()}`);
48
66
  }
67
+ } else if (argv.uploadedBuilt) {
68
+ // clearBuildCache() would have nuked the sidecars as a side-effect, so
69
+ // only bother with the sidecar sweep when the cache itself is surviving.
70
+ const removed = clearUploadSidecars();
71
+ log.info(`Removed ${removed} upload record${removed === 1 ? '' : 's'} from the build cache`);
72
+ if (removed > 0) removedAnything = true;
49
73
  }
50
74
 
51
75
  if (!removedAnything) {
@@ -0,0 +1,107 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+ const AdmZip = require('adm-zip');
7
+
8
+ const log = require('../logger');
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // `wyvrnpm install-skill --claude`
12
+ //
13
+ // Copies the bundled wyvrnpm Agent Skill (claude/skills/wyvrnpm.skill) into
14
+ // the user's Claude Code skills directory so Claude picks it up automatically.
15
+ //
16
+ // Works for both install shapes:
17
+ // - `npm i -g wyvrnpm` → skill zip ships under <pkg>/claude/skills/
18
+ // - `git clone` of this repo → same path, just not from node_modules
19
+ //
20
+ // Destination, per target:
21
+ // --claude → %USERPROFILE%\.claude\skills\wyvrnpm\ (Windows)
22
+ // ~/.claude/skills/wyvrnpm/ (macOS / Linux)
23
+ // ---------------------------------------------------------------------------
24
+
25
+ const SKILL_NAME = 'wyvrnpm';
26
+
27
+ function bundledSkillPath() {
28
+ // __dirname = <pkg>/src/commands; the .skill zip lives at
29
+ // <pkg>/claude/skills/wyvrnpm.skill.
30
+ return path.resolve(__dirname, '..', '..', 'claude', 'skills', `${SKILL_NAME}.skill`);
31
+ }
32
+
33
+ function claudeSkillsDir() {
34
+ const home = os.homedir();
35
+ if (!home) throw new Error('cannot resolve user home directory');
36
+ return path.join(home, '.claude', 'skills');
37
+ }
38
+
39
+ function rmrf(target) {
40
+ if (!fs.existsSync(target)) return;
41
+ fs.rmSync(target, { recursive: true, force: true });
42
+ }
43
+
44
+ function installForClaude({ force, dryRun }) {
45
+ const zipPath = bundledSkillPath();
46
+ if (!fs.existsSync(zipPath)) {
47
+ log.error(`bundled skill not found at ${zipPath}`);
48
+ log.error('this is a packaging bug — please file an issue');
49
+ process.exit(1);
50
+ }
51
+
52
+ const skillsDir = claudeSkillsDir();
53
+ const destDir = path.join(skillsDir, SKILL_NAME);
54
+ const destZip = path.join(skillsDir, `${SKILL_NAME}.skill`);
55
+
56
+ log.info(`Source : ${zipPath}`);
57
+ log.info(`Destination : ${destDir}`);
58
+
59
+ if (fs.existsSync(destDir) && !force) {
60
+ log.warn(`${destDir} already exists`);
61
+ log.warn('re-run with --force to overwrite');
62
+ process.exit(1);
63
+ }
64
+
65
+ if (dryRun) {
66
+ log.info('(--dry-run) would remove existing skill dir and zip, then extract fresh copy');
67
+ return;
68
+ }
69
+
70
+ fs.mkdirSync(skillsDir, { recursive: true });
71
+ rmrf(destDir);
72
+ rmrf(destZip);
73
+
74
+ const zip = new AdmZip(zipPath);
75
+ zip.extractAllTo(skillsDir, /* overwrite */ true);
76
+ fs.copyFileSync(zipPath, destZip);
77
+
78
+ if (!fs.existsSync(path.join(destDir, 'SKILL.md'))) {
79
+ log.error(`extraction produced no SKILL.md under ${destDir}`);
80
+ log.error('the bundled .skill zip may be malformed');
81
+ process.exit(1);
82
+ }
83
+
84
+ log.success(`Installed ${SKILL_NAME} skill to ${destDir}`);
85
+ log.info('Claude Code will pick it up on next session start.');
86
+ }
87
+
88
+ async function installSkill(argv) {
89
+ const targets = [];
90
+ if (argv.claude) targets.push('claude');
91
+
92
+ if (targets.length === 0) {
93
+ log.error('no target specified — pass --claude');
94
+ log.error('(more targets may be added in future releases)');
95
+ process.exit(1);
96
+ }
97
+
98
+ for (const target of targets) {
99
+ if (target === 'claude') {
100
+ installForClaude({ force: !!argv.force, dryRun: !!argv['dry-run'] });
101
+ }
102
+ }
103
+ }
104
+
105
+ module.exports = installSkill;
106
+ module.exports.bundledSkillPath = bundledSkillPath;
107
+ module.exports.claudeSkillsDir = claudeSkillsDir;
@@ -9,6 +9,17 @@ const { downloadDependencies } = require('../downlo
9
9
  const { readConfig } = require('../config');
10
10
  const { loadProfile, hashProfile, formatProfile, mergeProfile } = require('../profile');
11
11
  const { generateToolchain, generateCMakePresets } = require('../toolchain');
12
+ const { normalizeRecipe, hasBuildRecipe } = require('../build/recipe');
13
+ const {
14
+ normalizeOptionsDeclaration,
15
+ resolveEffectiveOptions,
16
+ parseCliOptions,
17
+ } = require('../options');
18
+ const {
19
+ resolveUploadDestination,
20
+ createUploadStats,
21
+ formatUploadSummary,
22
+ } = require('../upload-built');
12
23
  const log = require('../logger');
13
24
 
14
25
  /**
@@ -26,6 +37,23 @@ async function install(argv) {
26
37
  const manifestPath = path.resolve(argv.manifest);
27
38
  const rootDir = path.resolve(argv.root);
28
39
  const buildMode = argv.build ?? 'never';
40
+ const uploadBuilt = argv.uploadBuilt === true;
41
+ const jsonOut = argv.format === 'json';
42
+
43
+ // Under --format=json, stdout is reserved for the final JSON payload.
44
+ // All info/success lines route to stderr via the logger.
45
+ if (jsonOut) log.setJsonMode(true);
46
+
47
+ // --upload-built implies a build step happened. `--build=never` +
48
+ // `--upload-built` is a usage error — fail fast rather than silently
49
+ // accept something that cannot upload anything.
50
+ if (uploadBuilt && buildMode === 'never') {
51
+ log.error(
52
+ '--upload-built requires --build=missing (or --build=always).\n' +
53
+ ' Nothing will be built, so there is nothing to upload.',
54
+ );
55
+ process.exit(1);
56
+ }
29
57
 
30
58
  if (buildMode === 'always') {
31
59
  log.error(
@@ -95,22 +123,44 @@ async function install(argv) {
95
123
  `Installing dependencies for "${manifest.name}" (platform: ${argv.platform})`,
96
124
  );
97
125
 
126
+ // Manifests collected during resolution — reused below to read each dep's
127
+ // recipe-declared `options` block without a second HTTP round trip.
128
+ const depManifests = new Map();
129
+
98
130
  const resolvedDeps = await resolveDependencies(
99
131
  depsForResolution,
100
132
  packageSources,
101
133
  argv.platform,
102
134
  fetch,
103
135
  lockedVersions,
136
+ depManifests,
104
137
  );
105
138
 
139
+ // CLI `-o <pkg>:<name>=<value>` overrides, parsed into { pkg → {name: value} }.
140
+ const cliOptionsByPkg = parseCliOptions(argv.option);
141
+
106
142
  // ── Build per-dependency enriched map ─────────────────────────────────────
107
- // Each dep gets a profileHash based on base profile + any settings overrides
108
- // declared in wyvrn.json under that dependency's "settings" key.
143
+ // Each dep gets:
144
+ // - an effective profile (base + per-dep settings overrides)
145
+ // - effective options (recipe defaults + consumer overrides + CLI overrides)
146
+ // - profileHash computed from both (options only hashed in when declared)
109
147
  const enrichedDeps = new Map();
110
148
  for (const [name, version] of resolvedDeps) {
111
- const depOverrides = normalizedDeps[name]?.settings ?? {};
149
+ const depOverrides = normalizedDeps[name]?.settings ?? {};
112
150
  const effectiveProfile = mergeProfile(baseProfile, depOverrides);
113
- const profileHash = hashProfile(effectiveProfile);
151
+
152
+ const depManifest = depManifests.get(name);
153
+ const declaration = depManifest?.options
154
+ ? normalizeOptionsDeclaration(depManifest.options, `${name}@${version}`)
155
+ : null;
156
+ const effectiveOptions = resolveEffectiveOptions(
157
+ declaration,
158
+ normalizedDeps[name]?.options ?? {},
159
+ cliOptionsByPkg[name] ?? {},
160
+ `${name}@${version}`,
161
+ );
162
+
163
+ const profileHash = hashProfile(effectiveProfile, effectiveOptions);
114
164
 
115
165
  if (Object.keys(depOverrides).length > 0) {
116
166
  log.info(
@@ -118,8 +168,16 @@ async function install(argv) {
118
168
  ` (${JSON.stringify(depOverrides)})`,
119
169
  );
120
170
  }
171
+ if (effectiveOptions) {
172
+ log.info(`${name}: options → ${JSON.stringify(effectiveOptions)}`);
173
+ }
121
174
 
122
- enrichedDeps.set(name, { version, profileHash, profile: effectiveProfile });
175
+ enrichedDeps.set(name, {
176
+ version,
177
+ profileHash,
178
+ profile: effectiveProfile,
179
+ options: effectiveOptions,
180
+ });
123
181
  }
124
182
 
125
183
  const razerDir = path.join(rootDir, 'wyvrn_internal');
@@ -133,6 +191,40 @@ async function install(argv) {
133
191
  profileName,
134
192
  };
135
193
 
194
+ // ── Resolve upload destination up-front when --upload-built is set ──────
195
+ // Failing here (no publish source configured) avoids spending minutes on
196
+ // a source-build whose output we can't deliver.
197
+ if (uploadBuilt) {
198
+ const resolved = resolveUploadDestination(
199
+ {
200
+ uploadSource: argv.uploadSource,
201
+ awsProfile: argv.awsProfile ?? firstInstallSrc?.profile,
202
+ token: argv.token ?? firstInstallSrc?.token,
203
+ },
204
+ config,
205
+ );
206
+ if (!resolved) {
207
+ log.error(
208
+ '--upload-built was passed but no upload destination could be resolved.\n' +
209
+ ' Pass --upload-source <url-or-name> or configure a publish source:\n' +
210
+ ' wyvrnpm configure add-source --kind publish --name ... --url ...',
211
+ );
212
+ process.exit(1);
213
+ }
214
+ downloadOptions.uploadBuilt = true;
215
+ downloadOptions.uploadSource = resolved.uploadSource;
216
+ downloadOptions.uploadAuth = resolved.uploadAuth;
217
+ downloadOptions.uploadStats = createUploadStats();
218
+
219
+ if (resolved.uploadSource !== (packageSources[0] ?? '')) {
220
+ log.info(
221
+ `upload-built: install source(s) and upload source differ —\n` +
222
+ ` install: ${packageSources.join(', ') || '(none)'}\n` +
223
+ ` upload : ${resolved.uploadSource}`,
224
+ );
225
+ }
226
+ }
227
+
136
228
  const lockEntries = await downloadDependencies(
137
229
  enrichedDeps,
138
230
  packageSources,
@@ -177,11 +269,70 @@ async function install(argv) {
177
269
  const toolchainRelPath = path
178
270
  .relative(rootDir, toolchain)
179
271
  .replace(/\\/g, '/');
272
+
273
+ // Derive generator + cacheVariables from the project's own `build` recipe
274
+ // so `wyvrnpm build` locally honours the same knobs the source-build /
275
+ // publish paths use. Without this, `build.configure` is advisory metadata
276
+ // and CMake falls back to its default generator (VS on Windows) — which
277
+ // breaks publishers whose recipe disables example/test subprojects (F15).
278
+ let presetGenerator = null;
279
+ let presetCacheVariables = null;
280
+ let presetInstallDir = null;
281
+ if (hasBuildRecipe(manifest)) {
282
+ try {
283
+ // Resolve the project's own options to expand ${options.*} in its
284
+ // configure args. When the project declares no options, this is null
285
+ // and the recipe's configure / buildArgs must not contain templates
286
+ // (normalizeRecipe enforces that).
287
+ const projectDeclaration = manifest.options
288
+ ? normalizeOptionsDeclaration(manifest.options, manifest.name)
289
+ : null;
290
+ const projectCliOverrides = cliOptionsByPkg[manifest.name] ?? {};
291
+ const projectEffectiveOptions = resolveEffectiveOptions(
292
+ projectDeclaration, {}, projectCliOverrides, manifest.name,
293
+ );
294
+ const recipe = normalizeRecipe(manifest.build, projectEffectiveOptions);
295
+
296
+ if (recipe.generators.length > 0) {
297
+ // First entry in the generator fallback chain. Users whose system
298
+ // lacks the first pick can override via CMakeUserPresets.json
299
+ // (which CMake merges in automatically).
300
+ [presetGenerator] = recipe.generators;
301
+ }
302
+
303
+ // Fold -DKEY=VALUE from recipe.configure into cacheVariables. Other
304
+ // configure args (anything not matching `-DKEY=VALUE`) can't be
305
+ // expressed in a CMake preset and are silently skipped here — the
306
+ // source-build path still sees them verbatim.
307
+ const cacheVars = {};
308
+ for (const arg of recipe.configure) {
309
+ const match = arg.match(/^-D([^=]+)=(.*)$/);
310
+ if (match) cacheVars[match[1]] = match[2];
311
+ }
312
+ if (Object.keys(cacheVars).length > 0) presetCacheVariables = cacheVars;
313
+
314
+ // Propagate `build.installDir` so that `wyvrnpm build --install` lands
315
+ // under the project's build tree instead of CMake's system default
316
+ // (C:/Program Files on Windows, /usr/local on Unix). Same semantics as
317
+ // the source-build path which passes -DCMAKE_INSTALL_PREFIX explicitly.
318
+ presetInstallDir = recipe.installDir;
319
+ } catch (err) {
320
+ log.warn(
321
+ `could not derive CMake preset config from the project's build recipe: ${err.message}\n` +
322
+ ` the preset will be emitted without a generator or recipe cacheVariables. ` +
323
+ `Fix the recipe or override via CMakeUserPresets.json.`,
324
+ );
325
+ }
326
+ }
327
+
180
328
  const presetResult = generateCMakePresets({
181
329
  projectRoot: rootDir,
182
330
  profileName,
183
331
  profileHash: hashProfile(baseProfile),
184
332
  toolchainRelPath,
333
+ generator: presetGenerator,
334
+ extraCacheVariables: presetCacheVariables,
335
+ installDir: presetInstallDir,
185
336
  });
186
337
  if (presetResult.action === 'skipped') {
187
338
  log.warn(
@@ -198,6 +349,51 @@ async function install(argv) {
198
349
 
199
350
  const count = resolvedDeps.size;
200
351
  log.success(`Done — ${count} package${count !== 1 ? 's' : ''} installed.`);
352
+
353
+ // ── upload-built summary ────────────────────────────────────────────────
354
+ const uploadSummary = formatUploadSummary(downloadOptions.uploadStats);
355
+ if (uploadSummary) log.info(uploadSummary);
356
+
357
+ // ── JSON payload (--format=json) ────────────────────────────────────────
358
+ // All log.info/success above have gone to stderr thanks to setJsonMode.
359
+ // stdout gets exactly one JSON object, no trailing text.
360
+ if (jsonOut) {
361
+ const packages = [...resolvedDeps.keys()].sort().map((name) => {
362
+ const entry = lockEntries?.get(name) ?? {};
363
+ const consumerSpec = normalizedDeps[name]?.version ?? null;
364
+ const resolvedVer = entry.version ?? resolvedDeps.get(name);
365
+ const versionRange = (consumerSpec && consumerSpec !== resolvedVer) ? consumerSpec : null;
366
+ return {
367
+ name,
368
+ version: resolvedVer,
369
+ versionRange,
370
+ profileHash: entry.profileHash ?? null,
371
+ options: entry.options ?? null,
372
+ contentSha256: entry.contentSha256 ?? null,
373
+ gitSha: entry.gitSha ?? null,
374
+ resolvedFrom: entry.resolvedFrom ?? null,
375
+ };
376
+ });
377
+
378
+ const stats = downloadOptions.uploadStats;
379
+ const uploadSummaryJson = stats ? {
380
+ uploaded: stats.uploaded.length,
381
+ skipped: stats.skipped.length,
382
+ failed: stats.failed.length,
383
+ } : null;
384
+
385
+ const payload = {
386
+ command: 'install',
387
+ wyvrnpmVersion: require('../../package.json').version,
388
+ profile: baseProfile,
389
+ profileName,
390
+ profileHash: hashProfile(baseProfile),
391
+ packages,
392
+ lockFile: lockPath,
393
+ uploadSummary: uploadSummaryJson,
394
+ };
395
+ process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
396
+ }
201
397
  }
202
398
 
203
399
  module.exports = install;