wyvrnpm 2.0.2 → 2.1.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,244 @@
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 log = require('../logger');
17
+
18
+ /**
19
+ * Recursively walk a directory and add every file into an AdmZip at
20
+ * paths relative to `base`. Matches the structure emitted by `publish` —
21
+ * zip contents are the DIRECT SUBTREE of `base`, not `base` itself.
22
+ */
23
+ function addFolder(zip, dir, base) {
24
+ for (const entry of fs.readdirSync(dir)) {
25
+ const full = path.join(dir, entry);
26
+ const rel = path.relative(base, full).replace(/\\/g, '/');
27
+ const stat = fs.statSync(full);
28
+ if (stat.isDirectory()) {
29
+ addFolder(zip, full, base);
30
+ } else {
31
+ const zipDir = path.dirname(rel);
32
+ zip.addLocalFile(full, zipDir === '.' ? '' : zipDir);
33
+ }
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Build a package from source and produce an artefact zip indistinguishable
39
+ * from a pre-built download.
40
+ *
41
+ * ───────────────────────────────────────────────────────────────────────────
42
+ * Flow (PLAN-BUILD-MISSING phase 3):
43
+ * 1. Check the build cache for a ready artefact → copy and return.
44
+ * 2. Shallow-clone `{gitRepo}` at `{gitSha}` into the cache.
45
+ * 3. Read the cloned `wyvrn.json`; require a `build` section.
46
+ * 4. Recursively resolve + download the package's OWN dependencies into a
47
+ * scratch `wyvrn_internal/` co-located with the source. Source-build
48
+ * propagates via `buildMode: "missing"`, so transitive source-only deps
49
+ * Just Work.
50
+ * 5. Generate the CMake toolchain via `generateToolchain()` — the shared
51
+ * entry point documented in PLAN-CMAKE-TOOLCHAIN Phase F. Never hand-
52
+ * roll profile→flag translation here.
53
+ * 6. Configure + build + install via `configureBuildInstall()`.
54
+ * 7. Zip the install dir → `wyvrn.zip`, compute `contentSha256`.
55
+ * 8. Copy the zip to `destZipPath` so `downloadDependencies` can extract
56
+ * it like any other v2 artefact.
57
+ * ───────────────────────────────────────────────────────────────────────────
58
+ *
59
+ * @param {object} args
60
+ * @param {string} args.name
61
+ * @param {string} args.version
62
+ * @param {import('../profile').WyvrnProfile} args.profile
63
+ * @param {string} args.profileName
64
+ * @param {string} args.profileHash
65
+ * @param {string} args.gitRepo
66
+ * @param {string} args.gitSha
67
+ * @param {string} args.destZipPath Where the caller expects the artefact zip.
68
+ * @param {string[]} args.packageSources Used to resolve this package's own deps.
69
+ * @param {string} args.platform v1 fallback platform for transitive deps.
70
+ * @param {number} args.timeoutMs
71
+ * @param {string} [args.buildMode] Propagated into transitive downloads. Default 'missing'.
72
+ * @param {string[]} [args.configs] Override recipe.configs (which defaults to
73
+ * all four CMake configurations).
74
+ * @param {object} [args.authOptions]
75
+ * @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',
83
+ * }>}
84
+ * @throws {Error} when the cloned manifest lacks a `build` section, when
85
+ * CMake is missing, or when the compile itself fails.
86
+ */
87
+ async function buildFromSource(args) {
88
+ const {
89
+ name, version,
90
+ profile, profileName, profileHash,
91
+ gitRepo, gitSha,
92
+ destZipPath,
93
+ packageSources,
94
+ platform,
95
+ timeoutMs,
96
+ buildMode = 'missing',
97
+ configs,
98
+ authOptions = {},
99
+ } = args;
100
+
101
+ if (!gitRepo || !gitSha) {
102
+ throw new Error(
103
+ `Cannot source-build ${name}@${version}: the registry has no git metadata ` +
104
+ `(gitRepo/gitSha) for this version. Publisher must re-publish with git info.`,
105
+ );
106
+ }
107
+
108
+ const paths = getBuildPaths({ name, version, profileHash });
109
+
110
+ // ── 1. Cache hit? Short-circuit to copy and return ──────────────────────
111
+ if (hasValidArtefact({ name, version, profileHash })) {
112
+ log.info(` source-build cache hit: ${paths.artefact}`);
113
+ fs.copyFileSync(paths.artefact, destZipPath);
114
+ const contentSha256 = sha256Of(paths.artefact);
115
+ return {
116
+ found: true,
117
+ contentSha256,
118
+ profileHash,
119
+ gitSha,
120
+ gitRepo,
121
+ source: paths.root,
122
+ resolvedFrom: 'v2-source-build',
123
+ };
124
+ }
125
+
126
+ log.info(` source-build starting: ${name}@${version} [${profileHash}]`);
127
+ log.info(` cache dir: ${paths.root}`);
128
+
129
+ // ── 2. Clone source ─────────────────────────────────────────────────────
130
+ fs.mkdirSync(paths.root, { recursive: true });
131
+ await cloneAtSha({ gitRepo, gitSha, destDir: paths.src });
132
+
133
+ // ── 3. Read cloned manifest + recipe ────────────────────────────────────
134
+ const manifestPath = path.join(paths.src, 'wyvrn.json');
135
+ if (!fs.existsSync(manifestPath)) {
136
+ throw new Error(
137
+ `Cloned source does not contain wyvrn.json at ${manifestPath}. ` +
138
+ `${name}@${version} cannot be source-built.`,
139
+ );
140
+ }
141
+ const clonedManifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
142
+ if (!hasBuildRecipe(clonedManifest)) {
143
+ throw new Error(
144
+ `${name}@${version} has no "build" section in its wyvrn.json at ${gitSha.slice(0, 12)}. ` +
145
+ `Source-build is opt-in — the publisher must add a build recipe.`,
146
+ );
147
+ }
148
+ const recipe = normalizeRecipe(clonedManifest.build);
149
+
150
+ // ── 4. Recursively resolve + download this package's OWN deps ──────────
151
+ // Lazy-require: downloadDependencies is where we were called from, so
152
+ // pulling it in at module-load would be a circular import.
153
+ // eslint-disable-next-line global-require
154
+ const { downloadDependencies } = require('../download');
155
+
156
+ const rawPackageDeps = normalizeDependencies(
157
+ clonedManifest.dependencies ?? clonedManifest.Dependencies,
158
+ );
159
+ const depsForResolution = Object.fromEntries(
160
+ Object.entries(rawPackageDeps).map(([n, d]) => [n, d.version]),
161
+ );
162
+
163
+ log.info(` resolving ${Object.keys(depsForResolution).length} transitive dep(s)`);
164
+ const resolvedDeps = await resolveDependencies(
165
+ depsForResolution, packageSources, platform, fetch, new Map(),
166
+ );
167
+
168
+ // Build an enriched deps map (profileHash per dep based on overrides).
169
+ const enrichedDeps = new Map();
170
+ for (const [depName, depVersion] of resolvedDeps) {
171
+ const depOverrides = rawPackageDeps[depName]?.settings ?? {};
172
+ const effectiveProfile = mergeProfile(profile, depOverrides);
173
+ const depProfileHash = hashProfile(effectiveProfile);
174
+ enrichedDeps.set(depName, {
175
+ version: depVersion,
176
+ profileHash: depProfileHash,
177
+ profile: effectiveProfile,
178
+ });
179
+ }
180
+
181
+ if (enrichedDeps.size > 0) {
182
+ await downloadDependencies(
183
+ enrichedDeps, packageSources, platform, paths.internal,
184
+ fetch, Math.max(30, Math.round(timeoutMs / 1000)),
185
+ { ...authOptions, buildMode },
186
+ );
187
+ } else {
188
+ fs.mkdirSync(paths.internal, { recursive: true });
189
+ }
190
+
191
+ // ── 5. Generate the CMake toolchain into wyvrn_internal/ ─────────────────
192
+ const { toolchain } = generateToolchain({
193
+ destDir: paths.internal,
194
+ profile,
195
+ profileName,
196
+ profileHash,
197
+ packageNames: [...resolvedDeps.keys()].sort(),
198
+ });
199
+
200
+ // ── 6. Configure + build + install ──────────────────────────────────────
201
+ // Caller can override recipe.configs (e.g. for CI that only wants Release);
202
+ // otherwise the recipe's own default of all four CMake configurations is
203
+ // respected.
204
+ const effectiveConfigs = (configs && configs.length > 0) ? configs : recipe.configs;
205
+ log.info(` building ${name}@${version} (configs: ${effectiveConfigs.join(', ')})`);
206
+ await configureBuildInstall({
207
+ recipe,
208
+ srcRoot: paths.src,
209
+ buildPaths: { build: paths.build, install: paths.install },
210
+ toolchainFile: toolchain,
211
+ configs: effectiveConfigs,
212
+ profile,
213
+ });
214
+
215
+ // ── 7. Zip install dir → wyvrn.zip ──────────────────────────────────────
216
+ if (!fs.existsSync(paths.install) || fs.readdirSync(paths.install).length === 0) {
217
+ throw new Error(
218
+ `CMake install produced no files at ${paths.install}. ` +
219
+ `Check the package's install() rules in its CMakeLists.txt.`,
220
+ );
221
+ }
222
+ log.info(` zipping install dir → ${paths.artefact}`);
223
+ const zip = new AdmZip();
224
+ addFolder(zip, paths.install, paths.install);
225
+ zip.writeZip(paths.artefact);
226
+
227
+ const contentSha256 = sha256Of(paths.artefact);
228
+ log.info(` source-build SHA256: ${contentSha256.slice(0, 16)}...`);
229
+
230
+ // ── 8. Hand off to the caller's extraction flow ─────────────────────────
231
+ fs.copyFileSync(paths.artefact, destZipPath);
232
+
233
+ return {
234
+ found: true,
235
+ contentSha256,
236
+ profileHash,
237
+ gitSha,
238
+ gitRepo,
239
+ source: paths.root,
240
+ resolvedFrom: 'v2-source-build',
241
+ };
242
+ }
243
+
244
+ 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,129 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The `build` section of a source-repo `wyvrn.json` tells wyvrnpm how to
5
+ * build the package from source when `--build=missing` fires. Only
6
+ * `system: "cmake"` is supported in phase 3.
7
+ *
8
+ * Example — single generator (strict; CMake errors if Ninja isn't installed):
9
+ * "build": {
10
+ * "system": "cmake",
11
+ * "generator": "Ninja",
12
+ * "configure": ["-DZLIB_BUILD_EXAMPLES=OFF"],
13
+ * "buildArgs": ["--parallel"],
14
+ * "installDir": "install",
15
+ * "sourceSubdir": "."
16
+ * }
17
+ *
18
+ * Example — generator fallback chain (tried in order, first available wins;
19
+ * falls through to CMake's default if none are present):
20
+ * "build": {
21
+ * "generator": ["Ninja", "Visual Studio 17 2022", "Visual Studio 16 2019"]
22
+ * }
23
+ */
24
+
25
+ const DEFAULT_RECIPE = Object.freeze({
26
+ system: 'cmake',
27
+ // Null / empty array → let CMake pick its default
28
+ // (Visual Studio on Windows with VS installed, Unix Makefiles on Linux/macOS).
29
+ generators: Object.freeze([]),
30
+ // All four CMake configurations are built and installed by default so
31
+ // downstream consumers can link against whichever they need. Restrict via
32
+ // the recipe (e.g. `"configs": ["Release"]`) to cut source-build time.
33
+ configs: Object.freeze(['Debug', 'Release', 'RelWithDebInfo', 'MinSizeRel']),
34
+ configure: Object.freeze([]),
35
+ buildArgs: Object.freeze([]),
36
+ installDir: 'install', // relative to binary dir; becomes CMAKE_INSTALL_PREFIX
37
+ sourceSubdir: '.', // where CMakeLists.txt lives relative to the clone root
38
+ requiredTools: Object.freeze([]),
39
+ });
40
+
41
+ const VALID_CMAKE_CONFIGS = new Set(['Debug', 'Release', 'RelWithDebInfo', 'MinSizeRel']);
42
+
43
+ const SUPPORTED_SYSTEMS = new Set(['cmake']);
44
+
45
+ /**
46
+ * Normalise the `build` section of a manifest into a concrete, complete
47
+ * recipe. Missing fields fall back to `DEFAULT_RECIPE`.
48
+ *
49
+ * `generator` accepts either a string (strict — single generator) or an
50
+ * array of strings (fallback chain — tried in order; first available wins).
51
+ * Both forms produce a `generators: string[]` field in the normalised recipe.
52
+ *
53
+ * @param {object|null|undefined} rawBuild
54
+ * @returns {{ system: string, generators: string[], configure: string[],
55
+ * buildArgs: string[], installDir: string, sourceSubdir: string,
56
+ * requiredTools: string[] }}
57
+ * @throws {Error} when `system` is set but not supported
58
+ */
59
+ function normalizeRecipe(rawBuild) {
60
+ const b = rawBuild ?? {};
61
+ const system = b.system ?? DEFAULT_RECIPE.system;
62
+ if (!SUPPORTED_SYSTEMS.has(system)) {
63
+ throw new Error(
64
+ `build.system "${system}" is not supported. ` +
65
+ `Supported: ${[...SUPPORTED_SYSTEMS].join(', ')}.`,
66
+ );
67
+ }
68
+
69
+ // Accept generator as string | string[] | null for user convenience;
70
+ // canonicalise to an array.
71
+ let generators;
72
+ if (typeof b.generator === 'string') {
73
+ generators = b.generator.trim() ? [b.generator] : [];
74
+ } else if (Array.isArray(b.generator)) {
75
+ generators = b.generator.filter((g) => typeof g === 'string' && g.trim());
76
+ } else {
77
+ generators = [];
78
+ }
79
+
80
+ // Drop empty-string entries so a stray `""` in the user's JSON doesn't
81
+ // become an empty-string argument to CMake / the build tool.
82
+ const cleanArray = (arr) =>
83
+ Array.isArray(arr)
84
+ ? arr.filter((s) => typeof s === 'string' && s.trim().length > 0)
85
+ : [];
86
+
87
+ // Accept `configs` as string | string[]. Default to all four canonical
88
+ // CMake configurations. Unknown entries are silently dropped so a typo
89
+ // doesn't blow up the build loop.
90
+ let configs;
91
+ if (typeof b.configs === 'string') {
92
+ configs = [b.configs];
93
+ } else if (Array.isArray(b.configs)) {
94
+ configs = b.configs.filter((c) => typeof c === 'string' && c.trim());
95
+ } else {
96
+ configs = DEFAULT_RECIPE.configs.slice();
97
+ }
98
+ configs = configs.filter((c) => VALID_CMAKE_CONFIGS.has(c));
99
+ if (configs.length === 0) configs = DEFAULT_RECIPE.configs.slice();
100
+
101
+ return {
102
+ system,
103
+ generators,
104
+ configs,
105
+ configure: cleanArray(b.configure),
106
+ buildArgs: cleanArray(b.buildArgs),
107
+ installDir: b.installDir ?? DEFAULT_RECIPE.installDir,
108
+ sourceSubdir: b.sourceSubdir ?? DEFAULT_RECIPE.sourceSubdir,
109
+ requiredTools: cleanArray(b.requiredTools),
110
+ };
111
+ }
112
+
113
+ /**
114
+ * Returns true if a manifest has enough info for source-build — i.e. the
115
+ * `build` section is present (even empty — defaults cover the rest).
116
+ * @param {object} manifest
117
+ * @returns {boolean}
118
+ */
119
+ function hasBuildRecipe(manifest) {
120
+ return !!manifest && typeof manifest.build === 'object' && manifest.build !== null;
121
+ }
122
+
123
+ module.exports = {
124
+ normalizeRecipe,
125
+ hasBuildRecipe,
126
+ DEFAULT_RECIPE,
127
+ SUPPORTED_SYSTEMS,
128
+ VALID_CMAKE_CONFIGS,
129
+ };