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.
- package/README.md +639 -5
- package/bin/wyvrn.js +220 -10
- package/claude/skills/wyvrnpm.skill +0 -0
- package/cmake/cpp.cmake +9 -0
- package/cmake/functions.cmake +224 -0
- package/cmake/macros.cmake +233 -0
- package/cmake/options.cmake +23 -0
- package/cmake/variables.cmake +171 -0
- package/package.json +3 -1
- package/src/build/cache.js +148 -0
- package/src/build/clone.js +170 -0
- package/src/build/cmake.js +342 -0
- package/src/build/index.js +275 -0
- package/src/build/msvc-env.js +217 -0
- package/src/build/recipe.js +155 -0
- package/src/commands/build.js +283 -0
- package/src/commands/clean.js +56 -16
- package/src/commands/configure.js +6 -5
- package/src/commands/init.js +3 -2
- package/src/commands/install-skill.js +107 -0
- package/src/commands/install.js +262 -19
- package/src/commands/link.js +18 -15
- package/src/commands/profile.js +15 -12
- package/src/commands/publish.js +216 -65
- package/src/commands/show.js +237 -0
- package/src/compat.js +261 -0
- package/src/config.js +3 -1
- package/src/download.js +431 -87
- package/src/ignore.js +118 -0
- package/src/logger.js +94 -0
- package/src/manifest.js +12 -7
- package/src/options.js +303 -0
- package/src/profile.js +56 -4
- package/src/providers/base.js +16 -1
- package/src/providers/file.js +12 -6
- package/src/providers/http.js +15 -10
- package/src/providers/s3.js +14 -9
- package/src/resolve.js +179 -19
- package/src/toolchain/deps.js +164 -0
- package/src/toolchain/index.js +141 -0
- package/src/toolchain/presets.js +263 -0
- package/src/toolchain/template.cmake +66 -0
- package/src/upload-built.js +256 -0
- 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
|
+
};
|