wyvrnpm 2.0.4 → 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.
Files changed (44) hide show
  1. package/README.md +639 -5
  2. package/bin/wyvrn.js +220 -10
  3. package/claude/skills/wyvrnpm.skill +0 -0
  4. package/cmake/cpp.cmake +9 -0
  5. package/cmake/functions.cmake +224 -0
  6. package/cmake/macros.cmake +233 -0
  7. package/cmake/options.cmake +23 -0
  8. package/cmake/variables.cmake +171 -0
  9. package/package.json +3 -1
  10. package/src/build/cache.js +148 -0
  11. package/src/build/clone.js +170 -0
  12. package/src/build/cmake.js +342 -0
  13. package/src/build/index.js +275 -0
  14. package/src/build/msvc-env.js +217 -0
  15. package/src/build/recipe.js +155 -0
  16. package/src/commands/build.js +283 -0
  17. package/src/commands/clean.js +56 -16
  18. package/src/commands/configure.js +6 -5
  19. package/src/commands/init.js +3 -2
  20. package/src/commands/install-skill.js +107 -0
  21. package/src/commands/install.js +262 -19
  22. package/src/commands/link.js +18 -15
  23. package/src/commands/profile.js +15 -12
  24. package/src/commands/publish.js +216 -65
  25. package/src/commands/show.js +237 -0
  26. package/src/compat.js +261 -0
  27. package/src/config.js +3 -1
  28. package/src/download.js +431 -87
  29. package/src/ignore.js +118 -0
  30. package/src/logger.js +94 -0
  31. package/src/manifest.js +12 -7
  32. package/src/options.js +303 -0
  33. package/src/profile.js +56 -4
  34. package/src/providers/base.js +16 -1
  35. package/src/providers/file.js +12 -6
  36. package/src/providers/http.js +15 -10
  37. package/src/providers/s3.js +14 -9
  38. package/src/resolve.js +179 -19
  39. package/src/toolchain/deps.js +164 -0
  40. package/src/toolchain/index.js +141 -0
  41. package/src/toolchain/presets.js +263 -0
  42. package/src/toolchain/template.cmake +66 -0
  43. package/src/upload-built.js +256 -0
  44. package/src/version-range.js +301 -0
@@ -0,0 +1,275 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const AdmZip = require('adm-zip');
6
+
7
+ const { cloneAtSha } = require('./clone');
8
+ const { normalizeRecipe, hasBuildRecipe } = require('./recipe');
9
+ const { configureBuildInstall } = require('./cmake');
10
+ const { getBuildPaths, hasValidArtefact } = require('./cache');
11
+
12
+ const { normalizeDependencies } = require('../manifest');
13
+ const { resolveDependencies } = require('../resolve');
14
+ const { hashProfile, mergeProfile, sha256Of } = require('../profile');
15
+ const { generateToolchain } = require('../toolchain');
16
+ const { normalizeOptionsDeclaration, resolveEffectiveOptions } = require('../options');
17
+ const log = require('../logger');
18
+
19
+ /**
20
+ * Recursively walk a directory and add every file into an AdmZip at
21
+ * paths relative to `base`. Matches the structure emitted by `publish` —
22
+ * zip contents are the DIRECT SUBTREE of `base`, not `base` itself.
23
+ */
24
+ function addFolder(zip, dir, base) {
25
+ for (const entry of fs.readdirSync(dir)) {
26
+ const full = path.join(dir, entry);
27
+ const rel = path.relative(base, full).replace(/\\/g, '/');
28
+ const stat = fs.statSync(full);
29
+ if (stat.isDirectory()) {
30
+ addFolder(zip, full, base);
31
+ } else {
32
+ const zipDir = path.dirname(rel);
33
+ zip.addLocalFile(full, zipDir === '.' ? '' : zipDir);
34
+ }
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Build a package from source and produce an artefact zip indistinguishable
40
+ * from a pre-built download.
41
+ *
42
+ * ───────────────────────────────────────────────────────────────────────────
43
+ * Flow (PLAN-BUILD-MISSING phase 3):
44
+ * 1. Check the build cache for a ready artefact → copy and return.
45
+ * 2. Shallow-clone `{gitRepo}` at `{gitSha}` into the cache.
46
+ * 3. Read the cloned `wyvrn.json`; require a `build` section.
47
+ * 4. Recursively resolve + download the package's OWN dependencies into a
48
+ * scratch `wyvrn_internal/` co-located with the source. Source-build
49
+ * propagates via `buildMode: "missing"`, so transitive source-only deps
50
+ * Just Work.
51
+ * 5. Generate the CMake toolchain via `generateToolchain()` — the shared
52
+ * entry point documented in PLAN-CMAKE-TOOLCHAIN Phase F. Never hand-
53
+ * roll profile→flag translation here.
54
+ * 6. Configure + build + install via `configureBuildInstall()`.
55
+ * 7. Zip the install dir → `wyvrn.zip`, compute `contentSha256`.
56
+ * 8. Copy the zip to `destZipPath` so `downloadDependencies` can extract
57
+ * it like any other v2 artefact.
58
+ * ───────────────────────────────────────────────────────────────────────────
59
+ *
60
+ * @param {object} args
61
+ * @param {string} args.name
62
+ * @param {string} args.version
63
+ * @param {import('../profile').WyvrnProfile} args.profile
64
+ * @param {string} args.profileName
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.
68
+ * @param {string} args.gitRepo
69
+ * @param {string} args.gitSha
70
+ * @param {string} args.destZipPath Where the caller expects the artefact zip.
71
+ * @param {string[]} args.packageSources Used to resolve this package's own deps.
72
+ * @param {string} args.platform v1 fallback platform for transitive deps.
73
+ * @param {number} args.timeoutMs
74
+ * @param {string} [args.buildMode] Propagated into transitive downloads. Default 'missing'.
75
+ * @param {string[]} [args.configs] Override recipe.configs (which defaults to
76
+ * all four CMake configurations).
77
+ * @param {object} [args.authOptions]
78
+ * @returns {Promise<{
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)
88
+ * }>}
89
+ * @throws {Error} when the cloned manifest lacks a `build` section, when
90
+ * CMake is missing, or when the compile itself fails.
91
+ */
92
+ async function buildFromSource(args) {
93
+ const {
94
+ name, version,
95
+ profile, profileName, profileHash,
96
+ options = null,
97
+ gitRepo, gitSha,
98
+ destZipPath,
99
+ packageSources,
100
+ platform,
101
+ timeoutMs,
102
+ buildMode = 'missing',
103
+ configs,
104
+ authOptions = {},
105
+ } = args;
106
+
107
+ if (!gitRepo || !gitSha) {
108
+ throw new Error(
109
+ `Cannot source-build ${name}@${version}: the registry has no git metadata ` +
110
+ `(gitRepo/gitSha) for this version. Publisher must re-publish with git info.`,
111
+ );
112
+ }
113
+
114
+ const paths = getBuildPaths({ name, version, profileHash });
115
+
116
+ // ── 1. Cache hit? Short-circuit to copy and return ──────────────────────
117
+ if (hasValidArtefact({ name, version, profileHash })) {
118
+ log.info(` source-build cache hit: ${paths.artefact}`);
119
+ fs.copyFileSync(paths.artefact, destZipPath);
120
+ const contentSha256 = sha256Of(paths.artefact);
121
+ return {
122
+ found: true,
123
+ contentSha256,
124
+ profileHash,
125
+ gitSha,
126
+ gitRepo,
127
+ source: paths.root,
128
+ resolvedFrom: 'v2-source-build',
129
+ artefactPath: paths.artefact,
130
+ clonedManifestPath: path.join(paths.src, 'wyvrn.json'),
131
+ };
132
+ }
133
+
134
+ log.info(` source-build starting: ${name}@${version} [${profileHash}]`);
135
+ log.info(` cache dir: ${paths.root}`);
136
+
137
+ // ── 2. Clone source ─────────────────────────────────────────────────────
138
+ fs.mkdirSync(paths.root, { recursive: true });
139
+ await cloneAtSha({ gitRepo, gitSha, destDir: paths.src });
140
+
141
+ // ── 3. Read cloned manifest + recipe ────────────────────────────────────
142
+ const manifestPath = path.join(paths.src, 'wyvrn.json');
143
+ if (!fs.existsSync(manifestPath)) {
144
+ throw new Error(
145
+ `Cloned source does not contain wyvrn.json at ${manifestPath}. ` +
146
+ `${name}@${version} cannot be source-built.`,
147
+ );
148
+ }
149
+ const clonedManifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
150
+ if (!hasBuildRecipe(clonedManifest)) {
151
+ throw new Error(
152
+ `${name}@${version} has no "build" section in its wyvrn.json at ${gitSha.slice(0, 12)}. ` +
153
+ `Source-build is opt-in — the publisher must add a build recipe.`,
154
+ );
155
+ }
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);
160
+
161
+ // ── 4. Recursively resolve + download this package's OWN deps ──────────
162
+ // Lazy-require: downloadDependencies is where we were called from, so
163
+ // pulling it in at module-load would be a circular import.
164
+ // eslint-disable-next-line global-require
165
+ const { downloadDependencies } = require('../download');
166
+
167
+ const rawPackageDeps = normalizeDependencies(
168
+ clonedManifest.dependencies ?? clonedManifest.Dependencies,
169
+ );
170
+ const depsForResolution = Object.fromEntries(
171
+ Object.entries(rawPackageDeps).map(([n, d]) => [n, d.version]),
172
+ );
173
+
174
+ log.info(` resolving ${Object.keys(depsForResolution).length} transitive dep(s)`);
175
+ const transitiveManifests = new Map();
176
+ const resolvedDeps = await resolveDependencies(
177
+ depsForResolution, packageSources, platform, fetch, new Map(),
178
+ transitiveManifests,
179
+ );
180
+
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`.
185
+ const enrichedDeps = new Map();
186
+ for (const [depName, depVersion] of resolvedDeps) {
187
+ const depOverrides = rawPackageDeps[depName]?.settings ?? {};
188
+ const effectiveProfile = mergeProfile(profile, depOverrides);
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);
202
+ enrichedDeps.set(depName, {
203
+ version: depVersion,
204
+ profileHash: depProfileHash,
205
+ profile: effectiveProfile,
206
+ options: effectiveOptions,
207
+ });
208
+ }
209
+
210
+ if (enrichedDeps.size > 0) {
211
+ await downloadDependencies(
212
+ enrichedDeps, packageSources, platform, paths.internal,
213
+ fetch, Math.max(30, Math.round(timeoutMs / 1000)),
214
+ { ...authOptions, buildMode },
215
+ );
216
+ } else {
217
+ fs.mkdirSync(paths.internal, { recursive: true });
218
+ }
219
+
220
+ // ── 5. Generate the CMake toolchain into wyvrn_internal/ ─────────────────
221
+ const { toolchain } = generateToolchain({
222
+ destDir: paths.internal,
223
+ profile,
224
+ profileName,
225
+ profileHash,
226
+ packageNames: [...resolvedDeps.keys()].sort(),
227
+ });
228
+
229
+ // ── 6. Configure + build + install ──────────────────────────────────────
230
+ // Caller can override recipe.configs (e.g. for CI that only wants Release);
231
+ // otherwise the recipe's own default of all four CMake configurations is
232
+ // respected.
233
+ const effectiveConfigs = (configs && configs.length > 0) ? configs : recipe.configs;
234
+ log.info(` building ${name}@${version} (configs: ${effectiveConfigs.join(', ')})`);
235
+ await configureBuildInstall({
236
+ recipe,
237
+ srcRoot: paths.src,
238
+ buildPaths: { build: paths.build, install: paths.install },
239
+ toolchainFile: toolchain,
240
+ configs: effectiveConfigs,
241
+ profile,
242
+ });
243
+
244
+ // ── 7. Zip install dir → wyvrn.zip ──────────────────────────────────────
245
+ if (!fs.existsSync(paths.install) || fs.readdirSync(paths.install).length === 0) {
246
+ throw new Error(
247
+ `CMake install produced no files at ${paths.install}. ` +
248
+ `Check the package's install() rules in its CMakeLists.txt.`,
249
+ );
250
+ }
251
+ log.info(` zipping install dir → ${paths.artefact}`);
252
+ const zip = new AdmZip();
253
+ addFolder(zip, paths.install, paths.install);
254
+ zip.writeZip(paths.artefact);
255
+
256
+ const contentSha256 = sha256Of(paths.artefact);
257
+ log.info(` source-build SHA256: ${contentSha256.slice(0, 16)}...`);
258
+
259
+ // ── 8. Hand off to the caller's extraction flow ─────────────────────────
260
+ fs.copyFileSync(paths.artefact, destZipPath);
261
+
262
+ return {
263
+ found: true,
264
+ contentSha256,
265
+ profileHash,
266
+ gitSha,
267
+ gitRepo,
268
+ source: paths.root,
269
+ resolvedFrom: 'v2-source-build',
270
+ artefactPath: paths.artefact,
271
+ clonedManifestPath: manifestPath,
272
+ };
273
+ }
274
+
275
+ module.exports = { buildFromSource };
@@ -0,0 +1,217 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { spawn } = require('child_process');
6
+
7
+ const log = require('../logger');
8
+
9
+ /**
10
+ * Auto-activate the MSVC build environment (the moral equivalent of a
11
+ * "Developer PowerShell for VS 2022") when a source-build on Windows picks
12
+ * a non-VS generator and `cl.exe` isn't on PATH.
13
+ *
14
+ * Without this, Ninja/Make/etc. fail at CMake's compiler-detection step
15
+ * because MSVC's `cl.exe` lives under the VS install tree and is only put
16
+ * on PATH by `vcvarsall.bat`. VS generators sidestep this because MSBuild
17
+ * locates MSVC via VS's own toolset resolution.
18
+ *
19
+ * Strategy:
20
+ * 1. Check if cl.exe is already on PATH → return null (nothing to do).
21
+ * 2. Locate the VS installation via `vswhere.exe -latest`.
22
+ * 3. Run `vcvarsall.bat <arch>` in `cmd /s /c` and capture `set` output.
23
+ * 4. Parse KEY=VALUE lines, return them as a shallow-merged env object
24
+ * suitable for `child_process.spawn({ env })`.
25
+ *
26
+ * Returns `null` when nothing needs to be done OR when VS isn't found —
27
+ * callers fall back to the ambient environment and CMake produces its own
28
+ * error if that turns out to be missing a compiler.
29
+ */
30
+
31
+ const VSWHERE_DEFAULT = path.join(
32
+ process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)',
33
+ 'Microsoft Visual Studio', 'Installer', 'vswhere.exe',
34
+ );
35
+
36
+ function tryCapture(cmd, args, opts = {}) {
37
+ return new Promise((resolve) => {
38
+ const proc = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'ignore'], ...opts });
39
+ let out = '';
40
+ proc.stdout.on('data', (c) => { out += c.toString(); });
41
+ proc.on('error', () => resolve(null));
42
+ proc.on('close', (code) => resolve(code === 0 ? out : null));
43
+ });
44
+ }
45
+
46
+ /**
47
+ * @returns {Promise<boolean>}
48
+ */
49
+ async function isClOnPath() {
50
+ if (process.platform !== 'win32') return false;
51
+ // `where cl` is the Windows-native PATH probe.
52
+ return new Promise((resolve) => {
53
+ const proc = spawn('where', ['cl'], { stdio: 'ignore' });
54
+ proc.on('error', () => resolve(false));
55
+ proc.on('close', (code) => resolve(code === 0));
56
+ });
57
+ }
58
+
59
+ /**
60
+ * Return the latest VS installation path via vswhere, or null if not found.
61
+ * @returns {Promise<string|null>}
62
+ */
63
+ async function findVsInstall() {
64
+ if (!fs.existsSync(VSWHERE_DEFAULT)) return null;
65
+ const out = await tryCapture(VSWHERE_DEFAULT, [
66
+ '-latest',
67
+ '-requires', 'Microsoft.VisualStudio.Component.VC.Tools.x86.x64',
68
+ '-property', 'installationPath',
69
+ ]);
70
+ return out ? out.trim() : null;
71
+ }
72
+
73
+ function findVcvarsall(vsInstallPath) {
74
+ const p = path.join(vsInstallPath, 'VC', 'Auxiliary', 'Build', 'vcvarsall.bat');
75
+ return fs.existsSync(p) ? p : null;
76
+ }
77
+
78
+ /**
79
+ * Map a wyvrnpm profile arch to the argument `vcvarsall.bat` expects.
80
+ */
81
+ function mapArch(profileArch) {
82
+ switch (profileArch) {
83
+ case 'x86_64': return 'x64';
84
+ case 'x86': return 'x86';
85
+ case 'armv8': return 'arm64';
86
+ case 'armv7': return 'arm';
87
+ default: return 'x64';
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Capture the environment produced by running vcvarsall.bat.
93
+ *
94
+ * Strategy: shell out to `cmd`, run `vcvarsall.bat <arch>`, then print a
95
+ * marker followed by the post-vcvars environment (`set` dumps KEY=VALUE
96
+ * lines for every env var). Everything before the marker is vcvarsall's
97
+ * own output; everything after is the env we want.
98
+ *
99
+ * Uses `shell: true` so Node hands the full command line to `cmd.exe`
100
+ * verbatim — avoids the usual quoting pitfalls when the VS install path
101
+ * contains spaces.
102
+ *
103
+ * @param {string} vcvarsallPath
104
+ * @param {string} archArg
105
+ * @returns {Promise<object|null>}
106
+ */
107
+ async function captureVcvarsEnv(vcvarsallPath, archArg) {
108
+ const MARKER = '___WYVRN_ENV_MARKER___';
109
+ // `call` is required so the batch file returns control instead of
110
+ // `exit`-ing the cmd shell; otherwise the `echo` + `set` after it
111
+ // never run.
112
+ const cmdLine =
113
+ `call "${vcvarsallPath}" ${archArg} ` +
114
+ `&& echo ${MARKER} ` +
115
+ `&& set`;
116
+
117
+ return new Promise((resolve) => {
118
+ const proc = spawn(cmdLine, {
119
+ shell: true,
120
+ stdio: ['ignore', 'pipe', 'pipe'],
121
+ });
122
+ let stdout = '';
123
+ let stderr = '';
124
+ proc.stdout.on('data', (c) => { stdout += c.toString(); });
125
+ proc.stderr.on('data', (c) => { stderr += c.toString(); });
126
+ proc.on('error', (err) => {
127
+ log.warn(` vcvarsall spawn error: ${err.message}`);
128
+ resolve(null);
129
+ });
130
+ proc.on('close', (code) => {
131
+ if (code !== 0) {
132
+ const tail = (stderr || stdout).trim().split(/\r?\n/).slice(-5).join(' | ');
133
+ log.warn(` vcvarsall exited ${code}; last lines: ${tail}`);
134
+ resolve(null);
135
+ return;
136
+ }
137
+ const markerIdx = stdout.indexOf(MARKER);
138
+ if (markerIdx === -1) {
139
+ const tail = stdout.trim().split(/\r?\n/).slice(-5).join(' | ');
140
+ log.warn(` vcvarsall marker not found; last stdout lines: ${tail}`);
141
+ resolve(null);
142
+ return;
143
+ }
144
+ // Everything after the marker line is the env dump.
145
+ const envDump = stdout.slice(markerIdx + MARKER.length);
146
+ const env = { ...process.env };
147
+ let matched = 0;
148
+ for (const line of envDump.split(/\r?\n/)) {
149
+ const m = line.match(/^([A-Za-z_][A-Za-z0-9_()]*)=(.*)$/);
150
+ if (m) { env[m[1]] = m[2]; matched += 1; }
151
+ }
152
+ if (matched < 5 || !env.VSINSTALLDIR) {
153
+ log.warn(
154
+ ` vcvarsall env dump looks incomplete ` +
155
+ `(${matched} vars, VSINSTALLDIR=${env.VSINSTALLDIR ? 'set' : 'MISSING'})`,
156
+ );
157
+ resolve(null);
158
+ return;
159
+ }
160
+ resolve(env);
161
+ });
162
+ });
163
+ }
164
+
165
+ /**
166
+ * Compute a build-environment override for the given profile, or return
167
+ * null to indicate "use the ambient environment". Callers pass the result
168
+ * as `spawn(..., { env })`.
169
+ *
170
+ * Only activates when:
171
+ * - Running on Windows
172
+ * - Profile says compiler=msvc
173
+ * - `cl.exe` is NOT already on PATH (so we're outside a Dev shell)
174
+ * - `skipForGenerator` does not start with "Visual Studio" (VS generators
175
+ * don't need this — MSBuild resolves MSVC itself)
176
+ *
177
+ * @param {{
178
+ * profile: { compiler?: string, arch?: string },
179
+ * skipForGenerator?: string|null,
180
+ * }} args
181
+ * @returns {Promise<object|null>}
182
+ */
183
+ async function loadMsvcEnv({ profile, skipForGenerator }) {
184
+ if (process.platform !== 'win32') return null;
185
+ if (!profile || profile.compiler !== 'msvc') return null;
186
+ if (skipForGenerator && skipForGenerator.startsWith('Visual Studio')) return null;
187
+ if (await isClOnPath()) return null;
188
+
189
+ const vsPath = await findVsInstall();
190
+ if (!vsPath) {
191
+ log.warn(' MSVC profile requested but vswhere/VS not found — CMake will error if cl.exe is missing');
192
+ return null;
193
+ }
194
+ const vcvarsall = findVcvarsall(vsPath);
195
+ if (!vcvarsall) {
196
+ log.warn(` MSVC profile requested but vcvarsall.bat not found under ${vsPath}`);
197
+ return null;
198
+ }
199
+
200
+ const archArg = mapArch(profile.arch);
201
+ log.info(` activating MSVC build env: vcvarsall.bat ${archArg} (${vsPath})`);
202
+ const env = await captureVcvarsEnv(vcvarsall, archArg);
203
+ if (!env) {
204
+ log.warn(` vcvarsall.bat ${archArg} failed — CMake will error if cl.exe is missing`);
205
+ return null;
206
+ }
207
+ return env;
208
+ }
209
+
210
+ module.exports = {
211
+ loadMsvcEnv,
212
+ // Exported for tests
213
+ isClOnPath,
214
+ findVsInstall,
215
+ findVcvarsall,
216
+ mapArch,
217
+ };
@@ -0,0 +1,155 @@
1
+ 'use strict';
2
+
3
+ const { substituteTemplateAll } = require('../options');
4
+
5
+ /**
6
+ * The `build` section of a source-repo `wyvrn.json` tells wyvrnpm how to
7
+ * build the package from source when `--build=missing` fires. Only
8
+ * `system: "cmake"` is supported in phase 3.
9
+ *
10
+ * Example — single generator (strict; CMake errors if Ninja isn't installed):
11
+ * "build": {
12
+ * "system": "cmake",
13
+ * "generator": "Ninja",
14
+ * "configure": ["-DZLIB_BUILD_EXAMPLES=OFF"],
15
+ * "buildArgs": ["--parallel"],
16
+ * "installDir": "install",
17
+ * "sourceSubdir": "."
18
+ * }
19
+ *
20
+ * Example — generator fallback chain (tried in order, first available wins;
21
+ * falls through to CMake's default if none are present):
22
+ * "build": {
23
+ * "generator": ["Ninja", "Visual Studio 17 2022", "Visual Studio 16 2019"]
24
+ * }
25
+ */
26
+
27
+ const DEFAULT_RECIPE = Object.freeze({
28
+ system: 'cmake',
29
+ // Null / empty array → let CMake pick its default
30
+ // (Visual Studio on Windows with VS installed, Unix Makefiles on Linux/macOS).
31
+ generators: Object.freeze([]),
32
+ // All four CMake configurations are built and installed by default so
33
+ // downstream consumers can link against whichever they need. Restrict via
34
+ // the recipe (e.g. `"configs": ["Release"]`) to cut source-build time.
35
+ configs: Object.freeze(['Debug', 'Release', 'RelWithDebInfo', 'MinSizeRel']),
36
+ configure: Object.freeze([]),
37
+ buildArgs: Object.freeze([]),
38
+ installDir: 'install', // relative to binary dir; becomes CMAKE_INSTALL_PREFIX
39
+ sourceSubdir: '.', // where CMakeLists.txt lives relative to the clone root
40
+ requiredTools: Object.freeze([]),
41
+ });
42
+
43
+ const VALID_CMAKE_CONFIGS = new Set(['Debug', 'Release', 'RelWithDebInfo', 'MinSizeRel']);
44
+
45
+ const SUPPORTED_SYSTEMS = new Set(['cmake']);
46
+
47
+ /**
48
+ * Normalise the `build` section of a manifest into a concrete, complete
49
+ * recipe. Missing fields fall back to `DEFAULT_RECIPE`.
50
+ *
51
+ * `generator` accepts either a string (strict — single generator) or an
52
+ * array of strings (fallback chain — tried in order; first available wins).
53
+ * Both forms produce a `generators: string[]` field in the normalised recipe.
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
+ *
63
+ * @param {object|null|undefined} rawBuild
64
+ * @param {Record<string, any>|null} [effectiveOptions]
65
+ * @returns {{ system: string, generators: string[], configure: string[],
66
+ * buildArgs: string[], installDir: string, sourceSubdir: string,
67
+ * requiredTools: string[] }}
68
+ * @throws {Error} when `system` is set but not supported, or when a
69
+ * `${options.<name>}` template references an undeclared option
70
+ */
71
+ function normalizeRecipe(rawBuild, effectiveOptions = null) {
72
+ const b = rawBuild ?? {};
73
+ const system = b.system ?? DEFAULT_RECIPE.system;
74
+ if (!SUPPORTED_SYSTEMS.has(system)) {
75
+ throw new Error(
76
+ `build.system "${system}" is not supported. ` +
77
+ `Supported: ${[...SUPPORTED_SYSTEMS].join(', ')}.`,
78
+ );
79
+ }
80
+
81
+ // Accept generator as string | string[] | null for user convenience;
82
+ // canonicalise to an array.
83
+ let generators;
84
+ if (typeof b.generator === 'string') {
85
+ generators = b.generator.trim() ? [b.generator] : [];
86
+ } else if (Array.isArray(b.generator)) {
87
+ generators = b.generator.filter((g) => typeof g === 'string' && g.trim());
88
+ } else {
89
+ generators = [];
90
+ }
91
+
92
+ // Drop empty-string entries so a stray `""` in the user's JSON doesn't
93
+ // become an empty-string argument to CMake / the build tool.
94
+ const cleanArray = (arr) =>
95
+ Array.isArray(arr)
96
+ ? arr.filter((s) => typeof s === 'string' && s.trim().length > 0)
97
+ : [];
98
+
99
+ // Accept `configs` as string | string[]. Default to all four canonical
100
+ // CMake configurations. Unknown entries are silently dropped so a typo
101
+ // doesn't blow up the build loop.
102
+ let configs;
103
+ if (typeof b.configs === 'string') {
104
+ configs = [b.configs];
105
+ } else if (Array.isArray(b.configs)) {
106
+ configs = b.configs.filter((c) => typeof c === 'string' && c.trim());
107
+ } else {
108
+ configs = DEFAULT_RECIPE.configs.slice();
109
+ }
110
+ configs = configs.filter((c) => VALID_CMAKE_CONFIGS.has(c));
111
+ if (configs.length === 0) configs = DEFAULT_RECIPE.configs.slice();
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
+
127
+ return {
128
+ system,
129
+ generators,
130
+ configs,
131
+ configure,
132
+ buildArgs,
133
+ installDir: b.installDir ?? DEFAULT_RECIPE.installDir,
134
+ sourceSubdir: b.sourceSubdir ?? DEFAULT_RECIPE.sourceSubdir,
135
+ requiredTools: cleanArray(b.requiredTools),
136
+ };
137
+ }
138
+
139
+ /**
140
+ * Returns true if a manifest has enough info for source-build — i.e. the
141
+ * `build` section is present (even empty — defaults cover the rest).
142
+ * @param {object} manifest
143
+ * @returns {boolean}
144
+ */
145
+ function hasBuildRecipe(manifest) {
146
+ return !!manifest && typeof manifest.build === 'object' && manifest.build !== null;
147
+ }
148
+
149
+ module.exports = {
150
+ normalizeRecipe,
151
+ hasBuildRecipe,
152
+ DEFAULT_RECIPE,
153
+ SUPPORTED_SYSTEMS,
154
+ VALID_CMAKE_CONFIGS,
155
+ };