wyvrnpm 2.1.0 → 2.4.1

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.
@@ -25,6 +25,44 @@ macro(WYVRN_CONFIGURE_VERSION_HEADER)
25
25
  )
26
26
  endmacro()
27
27
 
28
+ # Apply WYVRN_ARCH_SUFFIX to a target's OUTPUT_NAME so the produced artefact
29
+ # carries an unambiguous arch marker. Opt-in — call it from your CMakeLists
30
+ # only if you want the suffixing:
31
+ #
32
+ # WYVRN_APPLY_ARCH_SUFFIX(String) # String64.lib / String32.lib
33
+ # WYVRN_APPLY_ARCH_SUFFIX(String BASE_NAME my-string) # my-string64.lib / my-string32.lib
34
+ #
35
+ # BASE_NAME defaults to the target name. The target linkage name (what
36
+ # consumers pass to target_link_libraries) is untouched — only the on-disk
37
+ # output file changes. Safe to call for any target type; does nothing if
38
+ # WYVRN_ARCH_SUFFIX is empty (unknown / unmapped arch).
39
+ function(WYVRN_APPLY_ARCH_SUFFIX _wyvrn_target)
40
+ set(options)
41
+ set(oneValueArgs BASE_NAME)
42
+ set(multiValueArgs)
43
+ cmake_parse_arguments(_WYVRN_ARG "${options}" "${oneValueArgs}" "${multiValueArgs}" ${ARGN})
44
+
45
+ if(NOT TARGET ${_wyvrn_target})
46
+ message(FATAL_ERROR "WYVRN_APPLY_ARCH_SUFFIX: '${_wyvrn_target}' is not a target")
47
+ endif()
48
+
49
+ if("${WYVRN_ARCH_SUFFIX}" STREQUAL "")
50
+ # Unknown arch — leave the output name alone rather than stamping a
51
+ # misleading value. The caller can still set OUTPUT_NAME manually.
52
+ return()
53
+ endif()
54
+
55
+ if(_WYVRN_ARG_BASE_NAME)
56
+ set(_wyvrn_base "${_WYVRN_ARG_BASE_NAME}")
57
+ else()
58
+ set(_wyvrn_base "${_wyvrn_target}")
59
+ endif()
60
+
61
+ set_target_properties(${_wyvrn_target} PROPERTIES
62
+ OUTPUT_NAME "${_wyvrn_base}${WYVRN_ARCH_SUFFIX}"
63
+ )
64
+ endfunction()
65
+
28
66
  function(WYVRN_INSTALL_PACKAGE_EXPORT)
29
67
  set(options)
30
68
  set(oneValueArgs
@@ -84,10 +122,23 @@ function(WYVRN_INSTALL_PACKAGE_EXPORT)
84
122
  INSTALL_DESTINATION "${WYVRN_EXPORT_INSTALL_CMAKEDIR}"
85
123
  )
86
124
 
125
+ # INTERFACE libraries (header-only) have no compiled binary, so the arch
126
+ # stamped by `write_basic_package_version_file()` by default makes CMake
127
+ # reject the package when the consumer's CMAKE_SIZEOF_VOID_P differs —
128
+ # even though the headers themselves are arch-portable. Pass
129
+ # ARCH_INDEPENDENT so a HeaderOnlyLib published from x86_64 is consumable
130
+ # from x86 (and vice versa). Binary kinds keep the arch check.
131
+ set(_wyvrn_version_args)
132
+ get_target_property(_wyvrn_target_type ${WYVRN_EXPORT_TARGET} TYPE)
133
+ if(_wyvrn_target_type STREQUAL "INTERFACE_LIBRARY")
134
+ list(APPEND _wyvrn_version_args ARCH_INDEPENDENT)
135
+ endif()
136
+
87
137
  write_basic_package_version_file(
88
138
  "${WYVRN_EXPORT_VERSION_OUTPUT}"
89
139
  VERSION "${WYVRN_EXPORT_PACKAGE_VERSION}"
90
140
  COMPATIBILITY SameMajorVersion
141
+ ${_wyvrn_version_args}
91
142
  )
92
143
 
93
144
  install(FILES
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wyvrnpm",
3
- "version": "2.1.0",
3
+ "version": "2.4.1",
4
4
  "description": "A simple, static-hosting-compatible C++ package manager",
5
5
  "keywords": [
6
6
  "c++",
@@ -20,6 +20,7 @@
20
20
  "bin/",
21
21
  "src/",
22
22
  "cmake/",
23
+ "claude/skills/wyvrnpm.skill",
23
24
  "README.md"
24
25
  ],
25
26
  "scripts": {
@@ -51,6 +51,7 @@ const LAYOUT = Object.freeze({
51
51
  buildDir: 'build',
52
52
  installSubdir: 'install', // relative to buildDir — can be overridden by recipe.installDir
53
53
  artefactZip: 'wyvrn.zip',
54
+ uploadSidecar: '.uploaded-to.json',
54
55
  });
55
56
 
56
57
  /**
@@ -91,11 +92,57 @@ function hasValidArtefact(args) {
91
92
  return fs.existsSync(artefact);
92
93
  }
93
94
 
95
+ /**
96
+ * Find every `.uploaded-to.json` sidecar under the build cache. Each file
97
+ * records the destinations that a cached artefact has been pushed to via
98
+ * `wyvrnpm install --build=missing --upload-built`.
99
+ *
100
+ * Used by `wyvrnpm clean --uploaded-built` and for audit tooling.
101
+ *
102
+ * @returns {Array<{ cacheDir: string, sidecarPath: string, uploads: Array<object> }>}
103
+ */
104
+ function listUploadSidecars() {
105
+ const root = getBuildCacheRoot();
106
+ if (!fs.existsSync(root)) return [];
107
+ const out = [];
108
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
109
+ if (!entry.isDirectory()) continue;
110
+ const cacheDir = path.join(root, entry.name);
111
+ const sidecarPath = path.join(cacheDir, LAYOUT.uploadSidecar);
112
+ if (!fs.existsSync(sidecarPath)) continue;
113
+ let uploads = [];
114
+ try {
115
+ const raw = JSON.parse(fs.readFileSync(sidecarPath, 'utf8'));
116
+ if (Array.isArray(raw?.uploads)) uploads = raw.uploads;
117
+ } catch { /* corrupt — surface as empty uploads */ }
118
+ out.push({ cacheDir, sidecarPath, uploads });
119
+ }
120
+ return out;
121
+ }
122
+
123
+ /**
124
+ * Remove every `.uploaded-to.json` sidecar under the build cache. Used by
125
+ * `wyvrnpm clean --uploaded-built`. Leaves all other cache content intact
126
+ * (the artefact zips, source clones, and build dirs stay put).
127
+ *
128
+ * @returns {number} count of sidecar files removed
129
+ */
130
+ function clearUploadSidecars() {
131
+ const entries = listUploadSidecars();
132
+ let removed = 0;
133
+ for (const { sidecarPath } of entries) {
134
+ try { fs.unlinkSync(sidecarPath); removed++; } catch { /* ignore */ }
135
+ }
136
+ return removed;
137
+ }
138
+
94
139
  module.exports = {
95
140
  getBuildCacheRoot,
96
141
  getPackageBuildDir,
97
142
  getBuildPaths,
98
143
  clearBuildCache,
99
144
  hasValidArtefact,
145
+ listUploadSidecars,
146
+ clearUploadSidecars,
100
147
  LAYOUT,
101
148
  };
@@ -13,6 +13,7 @@ const { normalizeDependencies } = require('../manifest');
13
13
  const { resolveDependencies } = require('../resolve');
14
14
  const { hashProfile, mergeProfile, sha256Of } = require('../profile');
15
15
  const { generateToolchain } = require('../toolchain');
16
+ const { normalizeOptionsDeclaration, resolveEffectiveOptions } = require('../options');
16
17
  const log = require('../logger');
17
18
 
18
19
  /**
@@ -62,6 +63,8 @@ function addFolder(zip, dir, base) {
62
63
  * @param {import('../profile').WyvrnProfile} args.profile
63
64
  * @param {string} args.profileName
64
65
  * @param {string} args.profileHash
66
+ * @param {Record<string, any>|null} [args.options] Effective options for this build.
67
+ * Used to expand `${options.*}` in the recipe.
65
68
  * @param {string} args.gitRepo
66
69
  * @param {string} args.gitSha
67
70
  * @param {string} args.destZipPath Where the caller expects the artefact zip.
@@ -73,13 +76,15 @@ function addFolder(zip, dir, base) {
73
76
  * all four CMake configurations).
74
77
  * @param {object} [args.authOptions]
75
78
  * @returns {Promise<{
76
- * found: true,
77
- * contentSha256: string,
78
- * profileHash: string,
79
- * gitSha: string,
80
- * gitRepo: string,
81
- * source: string, // build-cache path (for logging)
82
- * resolvedFrom: 'v2-source-build',
79
+ * found: true,
80
+ * contentSha256: string,
81
+ * profileHash: string,
82
+ * gitSha: string,
83
+ * gitRepo: string,
84
+ * source: string, // build-cache path (for logging)
85
+ * resolvedFrom: 'v2-source-build',
86
+ * artefactPath: string, // absolute path to the built zip in the cache
87
+ * clonedManifestPath: string, // absolute path to the cloned wyvrn.json (for upload-built)
83
88
  * }>}
84
89
  * @throws {Error} when the cloned manifest lacks a `build` section, when
85
90
  * CMake is missing, or when the compile itself fails.
@@ -88,6 +93,7 @@ async function buildFromSource(args) {
88
93
  const {
89
94
  name, version,
90
95
  profile, profileName, profileHash,
96
+ options = null,
91
97
  gitRepo, gitSha,
92
98
  destZipPath,
93
99
  packageSources,
@@ -120,6 +126,8 @@ async function buildFromSource(args) {
120
126
  gitRepo,
121
127
  source: paths.root,
122
128
  resolvedFrom: 'v2-source-build',
129
+ artefactPath: paths.artefact,
130
+ clonedManifestPath: path.join(paths.src, 'wyvrn.json'),
123
131
  };
124
132
  }
125
133
 
@@ -145,7 +153,10 @@ async function buildFromSource(args) {
145
153
  `Source-build is opt-in — the publisher must add a build recipe.`,
146
154
  );
147
155
  }
148
- const recipe = normalizeRecipe(clonedManifest.build);
156
+ // Pass effective options into the recipe so `${options.<name>}` tokens in
157
+ // `build.configure` / `build.buildArgs` expand to the concrete values that
158
+ // produced this build's profileHash.
159
+ const recipe = normalizeRecipe(clonedManifest.build, options);
149
160
 
150
161
  // ── 4. Recursively resolve + download this package's OWN deps ──────────
151
162
  // Lazy-require: downloadDependencies is where we were called from, so
@@ -161,20 +172,38 @@ async function buildFromSource(args) {
161
172
  );
162
173
 
163
174
  log.info(` resolving ${Object.keys(depsForResolution).length} transitive dep(s)`);
175
+ const transitiveManifests = new Map();
164
176
  const resolvedDeps = await resolveDependencies(
165
177
  depsForResolution, packageSources, platform, fetch, new Map(),
178
+ transitiveManifests,
166
179
  );
167
180
 
168
- // Build an enriched deps map (profileHash per dep based on overrides).
181
+ // Build an enriched deps map. Same logic as install.js: per-dep settings
182
+ // + recipe-declared options resolved against what the *cloned* source
183
+ // manifest asks for. No CLI overrides — transitive options come solely
184
+ // from the source package's `dependencies.<n>.options`.
169
185
  const enrichedDeps = new Map();
170
186
  for (const [depName, depVersion] of resolvedDeps) {
171
- const depOverrides = rawPackageDeps[depName]?.settings ?? {};
187
+ const depOverrides = rawPackageDeps[depName]?.settings ?? {};
172
188
  const effectiveProfile = mergeProfile(profile, depOverrides);
173
- const depProfileHash = hashProfile(effectiveProfile);
189
+
190
+ const transitiveManifest = transitiveManifests.get(depName);
191
+ const declaration = transitiveManifest?.options
192
+ ? normalizeOptionsDeclaration(transitiveManifest.options, `${depName}@${depVersion}`)
193
+ : null;
194
+ const effectiveOptions = resolveEffectiveOptions(
195
+ declaration,
196
+ rawPackageDeps[depName]?.options ?? {},
197
+ {},
198
+ `${depName}@${depVersion}`,
199
+ );
200
+
201
+ const depProfileHash = hashProfile(effectiveProfile, effectiveOptions);
174
202
  enrichedDeps.set(depName, {
175
203
  version: depVersion,
176
204
  profileHash: depProfileHash,
177
205
  profile: effectiveProfile,
206
+ options: effectiveOptions,
178
207
  });
179
208
  }
180
209
 
@@ -238,6 +267,8 @@ async function buildFromSource(args) {
238
267
  gitRepo,
239
268
  source: paths.root,
240
269
  resolvedFrom: 'v2-source-build',
270
+ artefactPath: paths.artefact,
271
+ clonedManifestPath: manifestPath,
241
272
  };
242
273
  }
243
274
 
@@ -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),
@@ -0,0 +1,140 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+
5
+ const { readManifest, writeManifest } = require('../manifest');
6
+ const { resolveLatestVersion } = require('../resolve');
7
+ const { readConfig } = require('../config');
8
+ const log = require('../logger');
9
+
10
+ /**
11
+ * Parse a `<name>[@<version>]` positional into its parts.
12
+ *
13
+ * `@` inside the version (e.g. tags like `latest`) is allowed — only the
14
+ * first `@` is treated as the separator.
15
+ *
16
+ * @param {string} spec
17
+ * @returns {{ name: string, version: string | null }}
18
+ */
19
+ function parseSpec(spec) {
20
+ const at = spec.indexOf('@');
21
+ if (at === -1) return { name: spec, version: null };
22
+ if (at === 0) throw new Error(`invalid package spec "${spec}" — missing name before "@"`);
23
+ const name = spec.slice(0, at);
24
+ const version = spec.slice(at + 1);
25
+ if (!version) throw new Error(`invalid package spec "${spec}" — missing version after "@"`);
26
+ return { name, version };
27
+ }
28
+
29
+ /**
30
+ * Add one or more dependencies to wyvrn.json.
31
+ *
32
+ * wyvrnpm add <name> → resolves latest, writes "^<latest>"
33
+ * wyvrnpm add <name>@<version> → writes "<version>" verbatim (exact or range)
34
+ *
35
+ * Existing entries are replaced. If the prior entry was an object
36
+ * (`{ version, settings, options }`), its settings/options are preserved —
37
+ * only `version` is updated.
38
+ *
39
+ * Does not run install. The user must `wyvrnpm install` afterwards.
40
+ *
41
+ * @param {object} argv
42
+ */
43
+ async function add(argv) {
44
+ const manifestPath = path.resolve(argv.manifest);
45
+ const specs = (argv.packages ?? []).map(parseSpec);
46
+
47
+ if (specs.length === 0) {
48
+ log.error('no packages given. Usage: wyvrnpm add <name>[@<version>] [...]');
49
+ process.exit(1);
50
+ }
51
+
52
+ // Install sources — only needed when at least one spec omits @version and
53
+ // we have to hit `latest.json`.
54
+ let packageSources;
55
+ if (argv.source && argv.source.length > 0) {
56
+ packageSources = argv.source;
57
+ } else {
58
+ const config = readConfig();
59
+ packageSources = config.installSources.map((s) => s.url);
60
+ }
61
+
62
+ const needsResolve = specs.some((s) => s.version === null);
63
+ if (needsResolve && packageSources.length === 0) {
64
+ log.error(
65
+ 'no install sources configured — cannot look up latest versions.\n' +
66
+ ' Pass --source <url>, pin versions explicitly (`name@1.2.3.4`), or run:\n' +
67
+ ' wyvrnpm configure add-source --kind install --name ... --url ...',
68
+ );
69
+ process.exit(1);
70
+ }
71
+
72
+ const manifest = readManifest(manifestPath);
73
+
74
+ // Preserve whichever dependencies key the manifest already uses. If neither
75
+ // exists, create lowercase `dependencies` (the v2 convention).
76
+ const depsKey =
77
+ Object.prototype.hasOwnProperty.call(manifest, 'dependencies') ? 'dependencies' :
78
+ Object.prototype.hasOwnProperty.call(manifest, 'Dependencies') ? 'Dependencies' :
79
+ 'dependencies';
80
+
81
+ let deps = manifest[depsKey];
82
+ // Legacy array form → object. Editing by name is the whole point of `add`,
83
+ // and round-tripping as an array would silently drop per-dep settings.
84
+ if (Array.isArray(deps)) {
85
+ const obj = {};
86
+ for (const d of deps) {
87
+ const n = d.Name ?? d.name;
88
+ const v = d.Version ?? d.version;
89
+ if (n && v) obj[n] = v;
90
+ }
91
+ deps = obj;
92
+ } else if (!deps || typeof deps !== 'object') {
93
+ deps = {};
94
+ }
95
+
96
+ for (const spec of specs) {
97
+ let versionToWrite;
98
+ if (spec.version) {
99
+ versionToWrite = spec.version;
100
+ log.info(`${spec.name}: writing "${versionToWrite}" (as given)`);
101
+ } else {
102
+ const latest = await resolveLatestVersion(spec.name, packageSources, argv.platform, fetch);
103
+ versionToWrite = `^${latest}`;
104
+ log.info(`${spec.name}: latest is ${latest} → writing "${versionToWrite}"`);
105
+ }
106
+
107
+ const prior = deps[spec.name];
108
+ if (prior !== undefined) {
109
+ const priorVer =
110
+ typeof prior === 'string' ? prior :
111
+ (prior && typeof prior === 'object') ? (prior.version ?? prior.Version ?? '(unknown)') :
112
+ '(unknown)';
113
+ log.info(` replacing existing entry: "${priorVer}"`);
114
+ }
115
+
116
+ // If the prior entry was an object with settings/options, keep those —
117
+ // only bump the version field. Otherwise write the short-form string.
118
+ if (prior && typeof prior === 'object' && !Array.isArray(prior)) {
119
+ const updated = { ...prior };
120
+ if ('Version' in updated && !('version' in updated)) {
121
+ updated.Version = versionToWrite;
122
+ } else {
123
+ updated.version = versionToWrite;
124
+ }
125
+ deps[spec.name] = updated;
126
+ } else {
127
+ deps[spec.name] = versionToWrite;
128
+ }
129
+ }
130
+
131
+ manifest[depsKey] = deps;
132
+ writeManifest(manifestPath, manifest);
133
+
134
+ const n = specs.length;
135
+ log.success(`Updated ${manifestPath} — ${n} package${n !== 1 ? 's' : ''}.`);
136
+ log.info('Run `wyvrnpm install` to download.');
137
+ }
138
+
139
+ module.exports = add;
140
+ module.exports.parseSpec = parseSpec;
@@ -4,9 +4,14 @@ 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 { archToVsPlatform, isVisualStudioGenerator } = require('../toolchain');
13
+ const install = require('./install');
14
+ const log = require('../logger');
10
15
 
11
16
  // ---------------------------------------------------------------------------
12
17
  // Pure helpers (unit-testable — no process spawning, no filesystem writes)
@@ -66,11 +71,29 @@ function resolveBinaryDir({ rootDir, presetName }) {
66
71
 
67
72
  /**
68
73
  * Args passed to `cmake` for the configure step.
69
- * @param {{ presetName: string }} args
74
+ *
75
+ * `generator`, when set, appends `-G <name>` after `--preset`. CMake 3.21+
76
+ * lets CLI args override a preset's declared fields, so this swaps the
77
+ * generator at configure time without touching CMakePresets.json. Note:
78
+ * CMake bakes the chosen generator into the cache, so switching generators
79
+ * for an existing build directory requires `--clean` (otherwise CMake
80
+ * errors with "CMAKE_GENERATOR has changed").
81
+ *
82
+ * `vsPlatform`, when set, appends `-A <platform>`. Only meaningful for
83
+ * Visual Studio generators — they default to x64 when unspecified, which
84
+ * silently produces a 64-bit build even for a 32-bit profile. The caller
85
+ * is responsible for deriving this from the profile arch only when the
86
+ * effective generator is VS (see `isVisualStudioGenerator` +
87
+ * `archToVsPlatform`).
88
+ *
89
+ * @param {{ presetName: string, generator?: string|null, vsPlatform?: string|null }} args
70
90
  * @returns {string[]}
71
91
  */
72
- function buildConfigureArgs({ presetName }) {
73
- return ['--preset', presetName];
92
+ function buildConfigureArgs({ presetName, generator = null, vsPlatform = null }) {
93
+ const args = ['--preset', presetName];
94
+ if (generator) args.push('-G', generator);
95
+ if (vsPlatform) args.push('-A', vsPlatform);
96
+ return args;
74
97
  }
75
98
 
76
99
  /**
@@ -117,12 +140,18 @@ function buildInstallArgs({ binaryDir, config, prefix }) {
117
140
  * Run `cmake` with the given args. Inherits stdio for real-time output.
118
141
  * Rejects if the process exits non-zero or fails to spawn.
119
142
  *
143
+ * When `env` is provided, it replaces the ambient environment (used to
144
+ * activate MSVC via vcvarsall for non-VS generators on Windows).
145
+ *
120
146
  * @param {string[]} args
147
+ * @param {NodeJS.ProcessEnv|null} [env]
121
148
  * @returns {Promise<void>}
122
149
  */
123
- function runCmake(args) {
150
+ function runCmake(args, env = null) {
124
151
  return new Promise((resolve, reject) => {
125
- const proc = spawn('cmake', args, { stdio: 'inherit' });
152
+ const opts = { stdio: 'inherit' };
153
+ if (env) opts.env = env;
154
+ const proc = spawn('cmake', args, opts);
126
155
  proc.on('error', (err) => {
127
156
  reject(new Error(
128
157
  `Failed to spawn cmake: ${err.message}\n` +
@@ -192,10 +221,65 @@ async function build(argv) {
192
221
  fs.rmSync(binaryDir, { recursive: true, force: true });
193
222
  }
194
223
 
224
+ // ── MSVC env activation (Windows + non-VS generator) ───────────────────
225
+ // When the preset's generator is Ninja / Ninja Multi-Config / Make / etc.
226
+ // and the active profile is MSVC, we need `vcvarsall.bat` to have been
227
+ // sourced — otherwise cmake's compiler detection fails with "CXX compiler
228
+ // identification is unknown". VS generators sidestep this because MSBuild
229
+ // finds MSVC via its own toolset resolution.
230
+ //
231
+ // Same mechanism the source-build path already uses (src/build/msvc-env.js).
232
+ let cmakeEnv = null;
233
+ let effectiveGenerator = argv.generator ?? null;
234
+ let effectiveProfile = null;
235
+ try {
236
+ const { profile: activeProfile } = loadProfile(profileName);
237
+ effectiveProfile = activeProfile;
238
+ // Which generator will the preset actually use? Read it from the
239
+ // project's own recipe if one exists. If not, default to null and let
240
+ // cmake/CMakePresets decide — in which case MSVC env activation is
241
+ // skipped (because VS is usually the default on Windows).
242
+ const manifest = readManifest(manifestPath);
243
+ let presetGenerator = null;
244
+ if (hasBuildRecipe(manifest)) {
245
+ try {
246
+ const recipe = normalizeRecipe(manifest.build, null);
247
+ if (recipe.generators.length > 0) [presetGenerator] = recipe.generators;
248
+ } catch { /* templates need options → skip env activation */ }
249
+ }
250
+ // CLI --generator wins — that is the generator CMake will actually use,
251
+ // so it is the one that determines whether vcvarsall must be activated.
252
+ if (!effectiveGenerator) effectiveGenerator = presetGenerator;
253
+ cmakeEnv = await loadMsvcEnv({
254
+ profile: activeProfile,
255
+ skipForGenerator: effectiveGenerator,
256
+ });
257
+ } catch (err) {
258
+ log.warn(`Could not resolve MSVC env activation: ${err.message}`);
259
+ }
260
+
195
261
  // ── Configure ─────────────────────────────────────────────────────────────
196
- const configureArgs = buildConfigureArgs({ presetName });
262
+ // Auto-emit `-A <platform>` for Visual Studio generators, mapped from the
263
+ // active profile's arch. VS otherwise defaults to x64 regardless of the
264
+ // profile, which breaks x86 builds: CMake produces 64-bit artefacts but
265
+ // find_package() rejects the 32-bit installed dependencies with "version
266
+ // not compatible" (actually a CMAKE_SIZEOF_VOID_P mismatch, not a
267
+ // version-number check).
268
+ let vsPlatform = null;
269
+ if (isVisualStudioGenerator(effectiveGenerator) && effectiveProfile) {
270
+ vsPlatform = archToVsPlatform(effectiveProfile.arch);
271
+ if (vsPlatform) {
272
+ log.info(`Visual Studio generator + arch=${effectiveProfile.arch} → passing -A ${vsPlatform}`);
273
+ }
274
+ }
275
+
276
+ const configureArgs = buildConfigureArgs({
277
+ presetName,
278
+ generator: argv.generator,
279
+ vsPlatform,
280
+ });
197
281
  log.info(`cmake ${configureArgs.join(' ')}`);
198
- await runCmake(configureArgs);
282
+ await runCmake(configureArgs, cmakeEnv);
199
283
 
200
284
  // ── Build ─────────────────────────────────────────────────────────────────
201
285
  const buildArgs = buildBuildArgs({
@@ -206,7 +290,7 @@ async function build(argv) {
206
290
  passthru: argv['--'] ?? [],
207
291
  });
208
292
  log.info(`cmake ${buildArgs.join(' ')}`);
209
- await runCmake(buildArgs);
293
+ await runCmake(buildArgs, cmakeEnv);
210
294
 
211
295
  // ── Install (optional) ────────────────────────────────────────────────────
212
296
  // `--install` runs `cmake --install <binaryDir>` after the build. Prefix
@@ -219,7 +303,7 @@ async function build(argv) {
219
303
  prefix: argv.installPrefix,
220
304
  });
221
305
  log.info(`cmake ${installArgs.join(' ')}`);
222
- await runCmake(installArgs);
306
+ await runCmake(installArgs, cmakeEnv);
223
307
  }
224
308
 
225
309
  const suffix = argv.install