wyvrnpm 2.8.1 → 2.10.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.
@@ -0,0 +1,220 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { execFileSync } = require('child_process');
6
+
7
+ const { tagToVersion, nameFromUrl } = require('./version');
8
+ const { detectFromRepo } = require('./detect');
9
+ const { lookupCookbook } = require('./cookbook');
10
+ const log = require('../logger');
11
+
12
+ /**
13
+ * Clone `gitUrl` into `destDir` so bootstrap can read the tree.
14
+ * Defaults to a full clone — bootstrap's target audience is "I forked
15
+ * these repos to maintain them", so they want the history. `--shallow`
16
+ * opts into `--depth 1` (faster, but `git describe --tags` will miss
17
+ * tags that aren't reachable from the single fetched commit, so the
18
+ * version detection degrades to HEAD SHA placeholder + TODO).
19
+ *
20
+ * Uses the user's ambient git auth (SSH keys, credential helper). We do
21
+ * NOT manage credentials here.
22
+ *
23
+ * @param {{ gitUrl: string, destDir: string, ref?: string | null, shallow?: boolean }} opts
24
+ */
25
+ function cloneRepo({ gitUrl, destDir, ref = null, shallow = false }) {
26
+ const args = ['clone'];
27
+ if (shallow) args.push('--depth', '1');
28
+ if (ref) args.push('--branch', ref);
29
+ args.push('--', gitUrl, destDir);
30
+ log.info(` git ${args.join(' ')}`);
31
+ execFileSync('git', args, { stdio: 'inherit' });
32
+ }
33
+
34
+ /**
35
+ * Read HEAD SHA + nearest tag out of an existing clone. Pure
36
+ * read-side — never writes. Returns nulls on failure so the caller
37
+ * can surface a TODO rather than abort.
38
+ *
39
+ * @param {string} repoDir
40
+ * @returns {{ sha: string | null, tag: string | null }}
41
+ */
42
+ function readGitState(repoDir) {
43
+ const safe = (args) => {
44
+ try {
45
+ return execFileSync('git', args, { cwd: repoDir, stdio: ['ignore', 'pipe', 'ignore'] })
46
+ .toString('utf8').trim() || null;
47
+ } catch { return null; }
48
+ };
49
+ return {
50
+ sha: safe(['rev-parse', 'HEAD']),
51
+ // `--abbrev=0` gives the nearest tag without the "-<N>-g<sha>" suffix.
52
+ tag: safe(['describe', '--tags', '--abbrev=0']),
53
+ };
54
+ }
55
+
56
+ /**
57
+ * Compose a first-draft `wyvrn.json` from detection + cookbook data.
58
+ * Pure: no filesystem writes, no network. `compose` is the unit-testable
59
+ * seam between orchestration (`bootstrap`) and persistence.
60
+ *
61
+ * Precedence for each field (highest wins):
62
+ * CLI override → cookbook hint → filesystem detection → safe default.
63
+ *
64
+ * @param {{
65
+ * url: string,
66
+ * repoDir: string,
67
+ * overrides?: { name?: string, version?: string, kind?: string, description?: string },
68
+ * detect?: ReturnType<typeof detectFromRepo>,
69
+ * gitState?: { sha: string | null, tag: string | null },
70
+ * }} args
71
+ * @returns {{ manifest: object, todos: string[] }}
72
+ */
73
+ function compose({ url, repoDir, overrides = {}, detect = null, gitState = null }) {
74
+ const todos = [];
75
+ const detection = detect ?? detectFromRepo(repoDir);
76
+ const { sha, tag } = gitState ?? { sha: null, tag: null };
77
+
78
+ const repoTail = nameFromUrl(url);
79
+ const cookbook = lookupCookbook(repoTail) ?? {};
80
+
81
+ const name = overrides.name || repoTail;
82
+ if (!name) todos.push('name could not be derived from the URL — set "name" manually.');
83
+
84
+ let version = overrides.version || null;
85
+ if (!version && tag) {
86
+ version = tagToVersion(tag);
87
+ if (!version) {
88
+ todos.push(`nearest tag "${tag}" doesn't match a clean numeric version — set "version" manually (used HEAD SHA as a placeholder).`);
89
+ }
90
+ }
91
+ if (!version) {
92
+ version = '0.0.0.0';
93
+ todos.push('no usable git tag found — "version" defaulted to 0.0.0.0. Pin to a real release before publish.');
94
+ }
95
+
96
+ const kind = overrides.kind || cookbook.kind || detection.kind;
97
+ if (kind === 'unknown') {
98
+ todos.push('kind could not be detected — set "kind" to ConsoleApp | StaticLib | DynamicLib | HeaderOnlyLib | WebApp | Utility | Service | TestProject.');
99
+ }
100
+ for (const note of detection.notes) todos.push(`detect: ${note}`);
101
+ for (const note of (cookbook.notes ?? [])) todos.push(`cookbook: ${note}`);
102
+
103
+ // Safe default recipe for any CMake project. Header-only still goes
104
+ // through cmake --install so the INTERFACE target + install() rules
105
+ // land in the published zip.
106
+ const build = {
107
+ system: 'cmake',
108
+ configs: ['Debug', 'Release', 'RelWithDebInfo', 'MinSizeRel'],
109
+ configure: cookbook.configure ? [...cookbook.configure] : [],
110
+ installDir: 'install',
111
+ };
112
+
113
+ const manifest = {
114
+ name,
115
+ version,
116
+ schemaVersion: 2,
117
+ kind: kind === 'unknown' ? 'StaticLib' : kind,
118
+ description: overrides.description || `TODO: one-line description of ${name}`,
119
+ dependencies: cookbook.dependencies ? { ...cookbook.dependencies } : {},
120
+ build,
121
+ };
122
+
123
+ if (cookbook.dependencies) {
124
+ todos.push(
125
+ `dependencies seeded from cookbook: ${Object.keys(cookbook.dependencies).join(', ')}. ` +
126
+ `Verify versions and pin ranges before publish.`,
127
+ );
128
+ }
129
+
130
+ // `gitRepo` / `gitSha` are normally captured by `wyvrnpm publish` at
131
+ // publish time — we leave them out of the manifest (not an author
132
+ // field) but surface them in the TODO report so the user can
133
+ // double-check the clone landed at the ref they expected.
134
+ if (sha) todos.push(`git HEAD at bootstrap: ${sha}`);
135
+ if (tag) todos.push(`nearest git tag: ${tag}`);
136
+
137
+ return { manifest, todos };
138
+ }
139
+
140
+ /**
141
+ * Full bootstrap flow: optionally clone, read git state, compose manifest,
142
+ * write it to disk. Returns the result so callers (CLI, tests) can decide
143
+ * what to print.
144
+ *
145
+ * @param {{
146
+ * url: string,
147
+ * dest: string,
148
+ * ref?: string | null,
149
+ * name?: string,
150
+ * version?: string,
151
+ * kind?: string,
152
+ * description?: string,
153
+ * noClone?: boolean,
154
+ * dryRun?: boolean,
155
+ * force?: boolean,
156
+ * }} opts
157
+ * @returns {{ manifest: object, todos: string[], manifestPath: string, wrote: boolean }}
158
+ */
159
+ function bootstrap(opts) {
160
+ const {
161
+ url, dest, ref = null,
162
+ name, version, kind, description,
163
+ noClone = false, dryRun = false, force = false, shallow = false,
164
+ } = opts;
165
+
166
+ if (!url && !noClone) throw new Error('bootstrap: --url (or a positional git URL) is required unless --no-clone');
167
+ if (!dest) throw new Error('bootstrap: --dest (or positional) is required');
168
+
169
+ const repoDir = path.resolve(dest);
170
+
171
+ if (!noClone) {
172
+ if (fs.existsSync(repoDir) && !force) {
173
+ throw new Error(
174
+ `bootstrap: destination already exists: ${repoDir}\n` +
175
+ ' Pass --force to reuse it, or --no-clone to operate on it in place.',
176
+ );
177
+ }
178
+ if (fs.existsSync(repoDir) && force) {
179
+ fs.rmSync(repoDir, { recursive: true, force: true });
180
+ }
181
+ cloneRepo({ gitUrl: url, destDir: repoDir, ref, shallow });
182
+ } else if (!fs.existsSync(repoDir)) {
183
+ throw new Error(`bootstrap: --no-clone but directory does not exist: ${repoDir}`);
184
+ }
185
+
186
+ const detect = detectFromRepo(repoDir);
187
+ const gitState = readGitState(repoDir);
188
+
189
+ const { manifest, todos } = compose({
190
+ url: url || gitRemoteUrl(repoDir) || '',
191
+ repoDir,
192
+ overrides: { name, version, kind, description },
193
+ detect,
194
+ gitState,
195
+ });
196
+
197
+ const manifestPath = path.join(repoDir, 'wyvrn.json');
198
+ let wrote = false;
199
+ if (!dryRun) {
200
+ if (fs.existsSync(manifestPath) && !force) {
201
+ throw new Error(
202
+ `bootstrap: ${manifestPath} already exists. Pass --force to overwrite, or --dry-run to preview only.`,
203
+ );
204
+ }
205
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf8');
206
+ wrote = true;
207
+ }
208
+
209
+ return { manifest, todos, manifestPath, wrote };
210
+ }
211
+
212
+ function gitRemoteUrl(repoDir) {
213
+ try {
214
+ return execFileSync('git', ['remote', 'get-url', 'origin'], {
215
+ cwd: repoDir, stdio: ['ignore', 'pipe', 'ignore'],
216
+ }).toString('utf8').trim() || null;
217
+ } catch { return null; }
218
+ }
219
+
220
+ module.exports = { bootstrap, compose, readGitState, cloneRepo };
@@ -0,0 +1,72 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Parse an upstream git tag into wyvrnpm's 4-part version scheme.
5
+ *
6
+ * Most C++ OSS projects publish 3-part semver tags (`v1.2.3`, `11.0.2`,
7
+ * `1.3.12`). wyvrnpm uses a 4-part `major.minor.patch.build` scheme
8
+ * throughout (see CLAUDE.md §4.2). We pad the upstream tag with `.0` for
9
+ * the build component so published manifests look like `1.3.12.0`.
10
+ *
11
+ * Accepted tag shapes:
12
+ * v1.2.3 → "1.2.3.0"
13
+ * 1.2.3 → "1.2.3.0"
14
+ * v1.2.3.4 → "1.2.3.4"
15
+ * v11.0 → "11.0.0.0" (2-part — padded)
16
+ * v11 → "11.0.0.0" (1-part — padded)
17
+ * release-1.2 → "1.2.0.0" (strips a leading "release-" / "ver-")
18
+ *
19
+ * Rejected (returns null):
20
+ * empty / non-string
21
+ * tags with suffixes ("1.2.3-rc1", "1.2.3+git", "1.2.3a") — callers
22
+ * fall back to the HEAD SHA and flag a TODO
23
+ * tags with 5+ numeric components
24
+ *
25
+ * @param {string | null} tag
26
+ * @returns {string | null} 4-part version, or null when the tag can't be
27
+ * cleanly promoted.
28
+ */
29
+ function tagToVersion(tag) {
30
+ if (typeof tag !== 'string') return null;
31
+ let t = tag.trim();
32
+ if (!t) return null;
33
+
34
+ // Strip the most common prefixes authors use on git tags. Longest
35
+ // alternatives first so `version-2.0` matches the whole word, not
36
+ // just the leading `v`. We keep the allowlist tight — we'd rather
37
+ // fall through than silently misparse `release-candidate-1.2.3`.
38
+ t = t.replace(/^(version|release|ver|v)[-_.]?/i, '');
39
+
40
+ // Must be bare numeric segments separated by dots, nothing else.
41
+ if (!/^\d+(\.\d+){0,3}$/.test(t)) return null;
42
+
43
+ const parts = t.split('.').map((n) => parseInt(n, 10));
44
+ if (parts.some((n) => !Number.isFinite(n) || n < 0)) return null;
45
+
46
+ while (parts.length < 4) parts.push(0);
47
+ return parts.join('.');
48
+ }
49
+
50
+ /**
51
+ * Derive a wyvrnpm package name from a git URL.
52
+ *
53
+ * https://github.com/fmtlib/fmt.git → "fmt"
54
+ * git@github.com:fmtlib/fmt.git → "fmt"
55
+ * https://gitlab.com/bzip2/bzip2.git → "bzip2"
56
+ * https://github.com/Tencent/rapidjson.git → "rapidjson"
57
+ * https://github.com/nlohmann/json.git → "json" (caller may want "nlohmann-json")
58
+ *
59
+ * Pure: does not hit the network.
60
+ *
61
+ * @param {string} url
62
+ * @returns {string}
63
+ */
64
+ function nameFromUrl(url) {
65
+ if (typeof url !== 'string' || !url.trim()) return '';
66
+ const trimmed = url.trim().replace(/\/$/, '');
67
+ // Split on the last slash or colon (SSH form).
68
+ const tail = trimmed.split(/[/:]/).pop() || '';
69
+ return tail.replace(/\.git$/i, '');
70
+ }
71
+
72
+ module.exports = { tagToVersion, nameFromUrl };
@@ -15,6 +15,7 @@ const { hashProfile, mergeProfile, sha256Of } = require('../profile');
15
15
  const { generateToolchain } = require('../toolchain');
16
16
  const { normalizeOptionsDeclaration, resolveEffectiveOptions } = require('../options');
17
17
  const { readRecipeConf, cmakeCacheVariables } = require('../conf');
18
+ const { wyvrnFetch } = require('../http-fetch');
18
19
  const log = require('../logger');
19
20
 
20
21
  /**
@@ -195,7 +196,7 @@ async function buildFromSource(args) {
195
196
  log.info(` resolving ${Object.keys(depsForResolution).length} transitive dep(s)`);
196
197
  const transitiveManifests = new Map();
197
198
  const resolvedDeps = await resolveDependencies(
198
- depsForResolution, packageSources, platform, fetch, new Map(),
199
+ depsForResolution, packageSources, platform, wyvrnFetch, new Map(),
199
200
  transitiveManifests,
200
201
  );
201
202
 
@@ -231,7 +232,7 @@ async function buildFromSource(args) {
231
232
  if (enrichedDeps.size > 0) {
232
233
  await downloadDependencies(
233
234
  enrichedDeps, packageSources, platform, paths.internal,
234
- fetch, Math.max(30, Math.round(timeoutMs / 1000)),
235
+ wyvrnFetch, Math.max(30, Math.round(timeoutMs / 1000)),
235
236
  { ...authOptions, buildMode },
236
237
  );
237
238
  } else {
@@ -5,6 +5,7 @@ const path = require('path');
5
5
  const { readManifest, writeManifest } = require('../manifest');
6
6
  const { resolveLatestVersion } = require('../resolve');
7
7
  const { readConfig } = require('../config');
8
+ const { wyvrnFetch } = require('../http-fetch');
8
9
  const log = require('../logger');
9
10
 
10
11
  /**
@@ -99,7 +100,7 @@ async function add(argv) {
99
100
  versionToWrite = spec.version;
100
101
  log.info(`${spec.name}: writing "${versionToWrite}" (as given)`);
101
102
  } else {
102
- const latest = await resolveLatestVersion(spec.name, packageSources, argv.platform, fetch);
103
+ const latest = await resolveLatestVersion(spec.name, packageSources, argv.platform, wyvrnFetch);
103
104
  versionToWrite = `^${latest}`;
104
105
  log.info(`${spec.name}: latest is ${latest} → writing "${versionToWrite}"`);
105
106
  }
@@ -0,0 +1,96 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+
5
+ const { bootstrap } = require('../bootstrap');
6
+ const { nameFromUrl } = require('../bootstrap/version');
7
+ const log = require('../logger');
8
+
9
+ /**
10
+ * `wyvrnpm bootstrap <git-url>` — scaffold a first-draft `wyvrn.json`
11
+ * inside a cloned OSS repo so it can be built + published as a wyvrnpm
12
+ * package. See CLAUDE.md §5.9 and
13
+ * claude/skills/wyvrnpm/references/oss-conversion-patterns.md.
14
+ *
15
+ * Typical use — batch-convert a list of OSS libraries into wyvrnpm:
16
+ *
17
+ * while read url; do
18
+ * wyvrnpm bootstrap "$url" --dest "./pkgs/$(basename "$url" .git)"
19
+ * done < urls.txt
20
+ *
21
+ * The output is always a *first draft* — bootstrap prints a TODO report
22
+ * flagging gotchas that need human review (install gates, test-skip
23
+ * flags upstream-specific, missing CMakeLists.txt on header-drop repos
24
+ * like stb/imgui). Always review before `wyvrnpm publish`.
25
+ *
26
+ * @param {object} argv
27
+ */
28
+ async function bootstrapCmd(argv) {
29
+ const url = argv.url ?? argv._?.[1] ?? null;
30
+ if (!url && !argv.noClone) {
31
+ log.error('bootstrap: missing git URL.\n Usage: wyvrnpm bootstrap <git-url> [--dest <dir>]');
32
+ process.exit(1);
33
+ }
34
+
35
+ const dest = argv.dest || path.resolve(nameFromUrl(url ?? '') || 'package');
36
+
37
+ let result;
38
+ try {
39
+ result = bootstrap({
40
+ url,
41
+ dest,
42
+ ref: argv.ref,
43
+ name: argv.name,
44
+ version: argv.version,
45
+ kind: argv.kind,
46
+ description: argv.description,
47
+ noClone: Boolean(argv.noClone),
48
+ dryRun: Boolean(argv.dryRun),
49
+ force: Boolean(argv.force),
50
+ });
51
+ } catch (err) {
52
+ log.error(err.message);
53
+ process.exit(1);
54
+ }
55
+
56
+ const { manifest, todos, manifestPath, wrote } = result;
57
+
58
+ if (argv.format === 'json') {
59
+ process.stdout.write(JSON.stringify({
60
+ command: 'bootstrap',
61
+ wyvrnpmVersion: require('../../package.json').version,
62
+ dest,
63
+ manifestPath,
64
+ wrote,
65
+ manifest,
66
+ todos,
67
+ }, null, 2) + '\n');
68
+ return;
69
+ }
70
+
71
+ console.log();
72
+ console.log(`─── ${manifest.name}@${manifest.version} (${manifest.kind}) ───`);
73
+ console.log(JSON.stringify(manifest, null, 2));
74
+ console.log();
75
+
76
+ if (wrote) log.success(`Wrote ${manifestPath}`);
77
+ else log.info(`Dry run — no manifest written.`);
78
+
79
+ if (todos.length > 0) {
80
+ console.log();
81
+ console.log('TODOs — review before `wyvrnpm publish`:');
82
+ for (const t of todos) console.log(` • ${t}`);
83
+ }
84
+
85
+ if (wrote) {
86
+ console.log();
87
+ console.log('Next steps:');
88
+ console.log(` cd ${path.relative(process.cwd(), path.resolve(dest)) || '.'}`);
89
+ console.log(' wyvrnpm install');
90
+ console.log(' wyvrnpm build --install --all-configs');
91
+ console.log(' # review the install tree, then:');
92
+ console.log(' wyvrnpm publish --source <your-s3-publish-source>');
93
+ }
94
+ }
95
+
96
+ module.exports = bootstrapCmd;
@@ -4,14 +4,11 @@ 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 { loadProfile } = require('../profile');
9
- const { readManifest } = require('../manifest');
10
- const { normalizeRecipe, hasBuildRecipe } = require('../build/recipe');
7
+ const { normalizeRecipe, hasBuildRecipe, VALID_CMAKE_CONFIGS, DEFAULT_RECIPE } = require('../build/recipe');
11
8
  const { loadMsvcEnv } = require('../build/msvc-env');
12
9
  const { archToVsPlatform, isVisualStudioGenerator } = require('../toolchain');
13
- const { parseCliConf } = require('../conf');
14
10
  const { LOCAL_OVERLAY_FILENAME } = require('../conf');
11
+ const { buildContext } = require('../context');
15
12
  const install = require('./install');
16
13
  const log = require('../logger');
17
14
 
@@ -141,6 +138,51 @@ function buildConfigureArgs({
141
138
  return args;
142
139
  }
143
140
 
141
+ /**
142
+ * Resolve the list of CMake configs to build (and optionally install).
143
+ *
144
+ * Precedence:
145
+ * 1. `--all-configs` → recipe.configs (falls back to all four canonical
146
+ * configs when no recipe declares any).
147
+ * 2. `--config` → comma-separated list, validated against
148
+ * VALID_CMAKE_CONFIGS. Order preserved. Duplicates removed.
149
+ * 3. default → `['Release']`.
150
+ *
151
+ * Configure runs once per `wyvrnpm build` invocation regardless — multi-
152
+ * config generators (VS, Xcode, Ninja Multi-Config) produce all configs
153
+ * from one configure step. Single-config generators (plain Ninja, Make)
154
+ * bake CMAKE_BUILD_TYPE into the cache, so `cmake --build --config X`
155
+ * effectively ignores X for them; building two configs against a single-
156
+ * config generator rebuilds the same binary twice. That's wasteful but
157
+ * correct; fixing it properly requires per-config build dirs, which is
158
+ * out of scope for this helper.
159
+ *
160
+ * @param {{ argv: object, recipeConfigs?: string[] }} args
161
+ * @returns {string[]}
162
+ */
163
+ function resolveConfigList({ argv, recipeConfigs = null }) {
164
+ if (argv.allConfigs) {
165
+ const fromRecipe = (recipeConfigs ?? []).filter((c) => VALID_CMAKE_CONFIGS.has(c));
166
+ return fromRecipe.length > 0 ? [...fromRecipe] : [...DEFAULT_RECIPE.configs];
167
+ }
168
+
169
+ const raw = typeof argv.config === 'string' && argv.config.trim() ? argv.config : 'Release';
170
+ const list = raw.split(',').map((s) => s.trim()).filter(Boolean);
171
+
172
+ const invalid = list.filter((c) => !VALID_CMAKE_CONFIGS.has(c));
173
+ if (invalid.length > 0) {
174
+ const valid = [...VALID_CMAKE_CONFIGS].join(' | ');
175
+ throw new Error(`invalid --config "${invalid.join(', ')}" — expected ${valid}`);
176
+ }
177
+
178
+ const seen = new Set();
179
+ const deduped = [];
180
+ for (const c of list) {
181
+ if (!seen.has(c)) { seen.add(c); deduped.push(c); }
182
+ }
183
+ return deduped.length > 0 ? deduped : ['Release'];
184
+ }
185
+
144
186
  /**
145
187
  * Args passed to `cmake` for the build step.
146
188
  *
@@ -225,13 +267,34 @@ function runCmake(args, env = null) {
225
267
  * @param {object} argv
226
268
  */
227
269
  async function build(argv) {
228
- const rootDir = path.resolve(argv.root);
229
- const manifestPath = path.resolve(argv.manifest);
270
+ // Shared per-invocation state. See src/context.js +
271
+ // claude/PLAN-COMMAND-CONTEXT.md. Parses --conf up front so a malformed
272
+ // flag fails here instead of after cmake's already configured.
273
+ let ctx;
274
+ try {
275
+ ctx = buildContext(argv);
276
+ } catch (err) {
277
+ log.error(err.message);
278
+ process.exit(1);
279
+ }
230
280
 
231
- const config = readConfig();
232
- const profileName = argv.profile ?? config.defaultProfile ?? 'default';
281
+ const { rootDir, manifestPath } = ctx;
282
+ const { name: profileName, profile: activeProfile } = ctx.profiles.host;
233
283
  const presetName = resolvePresetName({ presetOverride: argv.preset, profileName });
234
- const buildConfig = argv.config ?? 'Release';
284
+
285
+ // Resolve the list of configs up front so a malformed --config fails
286
+ // before auto-install kicks off. `--all-configs` consults the recipe
287
+ // (if any); otherwise comma-separated --config, defaulting to Release.
288
+ let configList;
289
+ try {
290
+ const recipe = hasBuildRecipe(ctx.manifest)
291
+ ? (() => { try { return normalizeRecipe(ctx.manifest.build, null); } catch { return null; } })()
292
+ : null;
293
+ configList = resolveConfigList({ argv, recipeConfigs: recipe?.configs ?? null });
294
+ } catch (err) {
295
+ log.error(err.message);
296
+ process.exit(1);
297
+ }
235
298
 
236
299
  // ── Stale check / auto-install ────────────────────────────────────────────
237
300
  // `--auto-install` gates the "run `wyvrnpm install` first when the
@@ -278,13 +341,12 @@ async function build(argv) {
278
341
  let effectiveGenerator = argv.generator ?? null;
279
342
  let effectiveProfile = null;
280
343
  try {
281
- const { profile: activeProfile } = loadProfile(profileName);
282
344
  effectiveProfile = activeProfile;
283
345
  // Which generator will the preset actually use? Read it from the
284
346
  // project's own recipe if one exists. If not, default to null and let
285
347
  // cmake/CMakePresets decide — in which case MSVC env activation is
286
348
  // skipped (because VS is usually the default on Windows).
287
- const manifest = readManifest(manifestPath);
349
+ const manifest = ctx.manifest;
288
350
  let presetGenerator = null;
289
351
  if (hasBuildRecipe(manifest)) {
290
352
  try {
@@ -321,16 +383,10 @@ async function build(argv) {
321
383
  // CLI --conf → -D / -U passthrough (PLAN-CONF.md §7). The preset's
322
384
  // baked-in cacheVariables (from the last install) already reflect the
323
385
  // recipe + local-overlay layers; CLI conf layers on top for this
324
- // invocation only.
325
- let cliConfSets, cliConfUnsets;
326
- try {
327
- const cli = parseCliConf(argv.conf);
328
- cliConfSets = cli.sets;
329
- cliConfUnsets = cli.unsets;
330
- } catch (err) {
331
- log.error(`--conf parse error: ${err.message}`);
332
- process.exit(1);
333
- }
386
+ // invocation only. ctx.cliConf is pre-parsed by buildContext; malformed
387
+ // values already threw above.
388
+ const cliConfSets = ctx.cliConf.sets;
389
+ const cliConfUnsets = ctx.cliConf.unsets;
334
390
 
335
391
  const configureArgs = buildConfigureArgs({
336
392
  presetName,
@@ -342,35 +398,43 @@ async function build(argv) {
342
398
  log.info(`cmake ${configureArgs.join(' ')}`);
343
399
  await runCmake(configureArgs, cmakeEnv);
344
400
 
345
- // ── Build ─────────────────────────────────────────────────────────────────
346
- const buildArgs = buildBuildArgs({
347
- binaryDir,
348
- config: buildConfig,
349
- target: argv.target,
350
- verbose: argv.verbose,
351
- passthru: argv['--'] ?? [],
352
- });
353
- log.info(`cmake ${buildArgs.join(' ')}`);
354
- await runCmake(buildArgs, cmakeEnv);
355
-
356
- // ── Install (optional) ────────────────────────────────────────────────────
357
- // `--install` runs `cmake --install <binaryDir>` after the build. Prefix
358
- // defaults to whatever was baked into the cache at configure time; pass
359
- // `--install-prefix` to override without reconfiguring.
360
- if (argv.install) {
361
- const installArgs = buildInstallArgs({
401
+ // ── Build (+ optional install) per config ─────────────────────────────────
402
+ // Configure ran once above. Multi-config generators (VS, Xcode, Ninja
403
+ // Multi-Config) produce every config from that one configure step; for
404
+ // single-config generators each iteration simply re-`cmake --build`s the
405
+ // same baked CMAKE_BUILD_TYPE (harmless duplication). `--install` runs
406
+ // per config too — the usual reason to want multi-config is so that a
407
+ // downstream `find_package()` can pick whichever it needs.
408
+ if (configList.length > 1) {
409
+ log.info(`Building ${configList.length} configs: ${configList.join(', ')}`);
410
+ }
411
+
412
+ for (const config of configList) {
413
+ const buildArgs = buildBuildArgs({
362
414
  binaryDir,
363
- config: buildConfig,
364
- prefix: argv.installPrefix,
415
+ config,
416
+ target: argv.target,
417
+ verbose: argv.verbose,
418
+ passthru: argv['--'] ?? [],
365
419
  });
366
- log.info(`cmake ${installArgs.join(' ')}`);
367
- await runCmake(installArgs, cmakeEnv);
420
+ log.info(`cmake ${buildArgs.join(' ')}`);
421
+ await runCmake(buildArgs, cmakeEnv);
422
+
423
+ if (argv.install) {
424
+ const installArgs = buildInstallArgs({
425
+ binaryDir,
426
+ config,
427
+ prefix: argv.installPrefix,
428
+ });
429
+ log.info(`cmake ${installArgs.join(' ')}`);
430
+ await runCmake(installArgs, cmakeEnv);
431
+ }
368
432
  }
369
433
 
370
434
  const suffix = argv.install
371
435
  ? ` + installed${argv.installPrefix ? ` to ${argv.installPrefix}` : ''}`
372
436
  : '';
373
- log.success(`Build complete: ${binaryDir} (${buildConfig})${suffix}`);
437
+ log.success(`Build complete: ${binaryDir} (${configList.join(', ')})${suffix}`);
374
438
  }
375
439
 
376
440
  module.exports = build;
@@ -381,6 +445,7 @@ module.exports._helpers = {
381
445
  isToolchainStale,
382
446
  resolvePresetName,
383
447
  resolveBinaryDir,
448
+ resolveConfigList,
384
449
  buildConfigureArgs,
385
450
  buildBuildArgs,
386
451
  buildInstallArgs,