wyvrnpm 2.4.1 → 2.9.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.
- package/README.md +414 -16
- package/bin/wyvrn.js +154 -11
- package/claude/skills/wyvrnpm.skill +0 -0
- package/cmake/variables.cmake +13 -3
- package/package.json +1 -1
- package/src/auth.js +66 -0
- package/src/build/cache.js +196 -0
- package/src/build/index.js +26 -4
- package/src/build/msvc-env.js +50 -7
- package/src/build/recipe.js +36 -3
- package/src/commands/add.js +2 -1
- package/src/commands/build.js +162 -36
- package/src/commands/cache.js +189 -0
- package/src/commands/configure.js +15 -4
- package/src/commands/init.js +36 -0
- package/src/commands/install-skill.js +8 -0
- package/src/commands/install.js +674 -400
- package/src/commands/link.js +320 -319
- package/src/commands/profile.js +237 -174
- package/src/commands/publish.js +435 -383
- package/src/commands/show.js +24 -9
- package/src/conf/index.js +391 -0
- package/src/conf/namespaces.js +94 -0
- package/src/context.js +230 -0
- package/src/download.js +135 -147
- package/src/http-fetch.js +53 -0
- package/src/logger.js +30 -2
- package/src/manifest.js +34 -7
- package/src/profile.js +148 -12
- package/src/providers/file.js +5 -19
- package/src/providers/http.js +26 -26
- package/src/providers/s3.js +29 -25
- package/src/resolve.js +47 -7
- package/src/settings-overrides.js +152 -0
- package/src/toolchain/presets.js +71 -31
- package/src/upload-built.js +11 -2
- package/src/zip-safe.js +52 -0
package/src/context.js
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* CommandContext — the shared per-invocation state every command consumes.
|
|
5
|
+
*
|
|
6
|
+
* Commands used to each re-read the manifest, re-resolve the profile name,
|
|
7
|
+
* re-read config, re-resolve auth, and each toggle JSON mode on the logger.
|
|
8
|
+
* The divergence was cheap at 11 commands; it starts costing real bugs
|
|
9
|
+
* once F3 (`tool_requires`) and F4 (build/host profile split) double the
|
|
10
|
+
* flag surface. See [claude/PLAN-COMMAND-CONTEXT.md](../claude/PLAN-COMMAND-CONTEXT.md).
|
|
11
|
+
*
|
|
12
|
+
* `buildContext(argv)` runs once at the top of each command. It:
|
|
13
|
+
* - resolves filesystem anchors (rootDir, manifestPath)
|
|
14
|
+
* - reads config once
|
|
15
|
+
* - resolves the profile-name fallback chain + loads the profile
|
|
16
|
+
* - parses CLI options + conf (format-level only; namespace allow-
|
|
17
|
+
* listing happens later in the sinks that need it)
|
|
18
|
+
* - sets JSON mode on the logger if --format=json
|
|
19
|
+
* - exposes an auth helper pre-curried with CLI --token / --token-env
|
|
20
|
+
* - exposes a lazy manifest getter (show never reads the manifest)
|
|
21
|
+
*
|
|
22
|
+
* `buildContext` does NOT do source filtering — each command has subtly
|
|
23
|
+
* different CLI shape there (publish takes name-or-URL, install/show
|
|
24
|
+
* take URL arrays, pin-resolution only matters for install). That
|
|
25
|
+
* stays per-command until a second feature shows the same duplication.
|
|
26
|
+
*
|
|
27
|
+
* Errors: `buildContext` THROWS on all input-validation failures. Each
|
|
28
|
+
* command wraps the call in one try/catch that does `log.error + exit`.
|
|
29
|
+
* This keeps buildContext unit-testable; behaviour from the user's
|
|
30
|
+
* point of view is unchanged.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const fs = require('fs');
|
|
34
|
+
const path = require('path');
|
|
35
|
+
|
|
36
|
+
const { readConfig } = require('./config');
|
|
37
|
+
const { loadProfile } = require('./profile');
|
|
38
|
+
const { readManifest } = require('./manifest');
|
|
39
|
+
const { resolveSourceAuth } = require('./auth');
|
|
40
|
+
const { parseCliOptions } = require('./options');
|
|
41
|
+
const { parseCliConf } = require('./conf');
|
|
42
|
+
const log = require('./logger');
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Internal helpers
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Resolve the active profile name per the long-standing precedence:
|
|
50
|
+
* --profile > config.defaultProfile > 'default'
|
|
51
|
+
*/
|
|
52
|
+
function resolveProfileName(argv, config) {
|
|
53
|
+
if (typeof argv.profile === 'string' && argv.profile.length > 0) {
|
|
54
|
+
return argv.profile;
|
|
55
|
+
}
|
|
56
|
+
if (typeof config.defaultProfile === 'string' && config.defaultProfile.length > 0) {
|
|
57
|
+
return config.defaultProfile;
|
|
58
|
+
}
|
|
59
|
+
return 'default';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Normalise CLI flags into the stable `ctx.flags` shape. Centralising
|
|
64
|
+
* defaults here means individual commands don't each re-apply
|
|
65
|
+
* `?? 'never'` / `?? 'Release'` / etc.
|
|
66
|
+
*
|
|
67
|
+
* Flags that are command-specific (argv.preset, argv.pkg, argv.clean,
|
|
68
|
+
* argv.verbose, argv.generator, argv.install, argv.installPrefix,
|
|
69
|
+
* argv.uploadSource, argv.autoInstall, argv.noGpgSign, ...) stay on
|
|
70
|
+
* argv and commands read them there. `ctx.argv` is the escape hatch.
|
|
71
|
+
*/
|
|
72
|
+
function normaliseFlags(argv) {
|
|
73
|
+
return {
|
|
74
|
+
format: argv.format === 'json' ? 'json' : 'text',
|
|
75
|
+
dryRun: argv.dryRun === true,
|
|
76
|
+
build: argv.build ?? 'never',
|
|
77
|
+
uploadBuilt: argv.uploadBuilt === true,
|
|
78
|
+
force: argv.force === true,
|
|
79
|
+
platform: argv.platform ?? defaultPlatform(),
|
|
80
|
+
timeout: typeof argv.timeout === 'number' ? argv.timeout : 300,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Default platform string when --platform is absent. Mirrors what
|
|
86
|
+
* bin/wyvrn.js passes when yargs applies its own default — keeping
|
|
87
|
+
* it here makes context.js testable without exercising the CLI layer.
|
|
88
|
+
*/
|
|
89
|
+
function defaultPlatform() {
|
|
90
|
+
if (process.platform === 'win32') {
|
|
91
|
+
return process.arch === 'ia32' ? 'win_x86' : 'win_x64';
|
|
92
|
+
}
|
|
93
|
+
if (process.platform === 'darwin') return 'macos';
|
|
94
|
+
return 'linux';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Produce a frozen `{ host, build }` profile pair. Today both point at
|
|
99
|
+
* the same resolved object — when F4 (build/host profile split) lands,
|
|
100
|
+
* only this function changes; every command reading `ctx.profiles.host`
|
|
101
|
+
* or `ctx.profiles.build` continues to work.
|
|
102
|
+
*
|
|
103
|
+
* The two sub-objects share a reference today. A regression test in
|
|
104
|
+
* tests/context.test.js asserts `ctx.profiles.host === ctx.profiles.build`
|
|
105
|
+
* — that test is expected to be removed (not fixed) when F4 makes them
|
|
106
|
+
* legitimately distinct.
|
|
107
|
+
*/
|
|
108
|
+
function resolveProfiles(argv, config) {
|
|
109
|
+
const name = resolveProfileName(argv, config);
|
|
110
|
+
const { profile, fromFile } = loadProfile(name);
|
|
111
|
+
const entry = Object.freeze({ name, profile, fromFile });
|
|
112
|
+
return Object.freeze({ host: entry, build: entry });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// buildContext
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Build the per-invocation CommandContext.
|
|
121
|
+
*
|
|
122
|
+
* @param {object} argv - yargs-parsed argument map. Fields read:
|
|
123
|
+
* root, manifest, profile, format, dryRun, build, uploadBuilt,
|
|
124
|
+
* force, platform, timeout, token, tokenEnv, option, conf.
|
|
125
|
+
* Command-specific fields pass through on ctx.argv unchanged.
|
|
126
|
+
*
|
|
127
|
+
* @returns {CommandContext}
|
|
128
|
+
*
|
|
129
|
+
* @throws on:
|
|
130
|
+
* - parseCliOptions: malformed `-o pkg:name=value`
|
|
131
|
+
* - parseCliConf: malformed `--conf KEY=VALUE`
|
|
132
|
+
* - loadProfile: malformed profile JSON, cycle in `extends`, etc.
|
|
133
|
+
*
|
|
134
|
+
* Does NOT throw on:
|
|
135
|
+
* - missing manifest (lazy — throws at first ctx.manifest access)
|
|
136
|
+
* - missing config (readConfig returns empty defaults)
|
|
137
|
+
* - missing profile (loadProfile falls back to detectProfile())
|
|
138
|
+
* - missing --token-env value (resolveSourceAuth throws at ctx.auth.for time)
|
|
139
|
+
*/
|
|
140
|
+
function buildContext(argv = {}) {
|
|
141
|
+
// 1. Filesystem anchors — pure path math, no I/O.
|
|
142
|
+
const rootDir = path.resolve(argv.root ?? '.');
|
|
143
|
+
const manifestPath = path.resolve(
|
|
144
|
+
argv.manifest ?? path.join(rootDir, 'wyvrn.json'),
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
// 2. Config — sync, small file, every command needs at least .defaultProfile.
|
|
148
|
+
const config = readConfig();
|
|
149
|
+
|
|
150
|
+
// 3. Profile (F4-ready shape).
|
|
151
|
+
const profiles = resolveProfiles(argv, config);
|
|
152
|
+
|
|
153
|
+
// 4. CLI auth, kept as the raw pair. Actual token lookup happens
|
|
154
|
+
// through ctx.auth.for(sourceEntry), which runs resolveSourceAuth
|
|
155
|
+
// each time so --token-env misses surface at use (same as today).
|
|
156
|
+
const authCli = Object.freeze({
|
|
157
|
+
token: typeof argv.token === 'string' ? argv.token : undefined,
|
|
158
|
+
tokenEnv: typeof argv.tokenEnv === 'string' ? argv.tokenEnv : undefined,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// 5. CLI options + conf — format-level parsing only. Namespace
|
|
162
|
+
// allow-listing for conf happens later in resolveEffectiveConf;
|
|
163
|
+
// options validation against the recipe's declared `allowed` set
|
|
164
|
+
// happens at resolveEffectiveOptions time.
|
|
165
|
+
// Both can throw — that's the contract: buildContext surfaces
|
|
166
|
+
// input-level errors up front.
|
|
167
|
+
const cliOptions = parseCliOptions(argv.option);
|
|
168
|
+
const cliConf = parseCliConf(argv.conf);
|
|
169
|
+
|
|
170
|
+
// 6. Flags.
|
|
171
|
+
const flags = normaliseFlags(argv);
|
|
172
|
+
|
|
173
|
+
// 7. JSON mode — side effect that must happen before the command
|
|
174
|
+
// starts logging. Flipping the logger here means every command
|
|
175
|
+
// that constructs a ctx gets JSON mode right automatically.
|
|
176
|
+
if (flags.format === 'json') log.setJsonMode(true);
|
|
177
|
+
|
|
178
|
+
// 8. Assemble.
|
|
179
|
+
const ctx = {
|
|
180
|
+
rootDir,
|
|
181
|
+
manifestPath,
|
|
182
|
+
cwd: process.cwd(),
|
|
183
|
+
profiles,
|
|
184
|
+
config,
|
|
185
|
+
auth: Object.freeze({
|
|
186
|
+
cli: authCli,
|
|
187
|
+
for: (sourceEntry) => resolveSourceAuth(authCli, sourceEntry ?? null),
|
|
188
|
+
}),
|
|
189
|
+
cliOptions,
|
|
190
|
+
cliConf,
|
|
191
|
+
flags,
|
|
192
|
+
argv,
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
// 9. Lazy manifest getter. Most commands need it; `show` does not.
|
|
196
|
+
// Defining as a getter means:
|
|
197
|
+
// - show pays nothing.
|
|
198
|
+
// - other commands pay once, cached.
|
|
199
|
+
// - an invalid manifest surfaces at first use, not at ctx
|
|
200
|
+
// construction — so `show` never trips over it.
|
|
201
|
+
let manifestCache = null;
|
|
202
|
+
let manifestLoaded = false;
|
|
203
|
+
Object.defineProperty(ctx, 'manifest', {
|
|
204
|
+
configurable: false,
|
|
205
|
+
enumerable: true,
|
|
206
|
+
get() {
|
|
207
|
+
if (!manifestLoaded) {
|
|
208
|
+
if (!fs.existsSync(manifestPath)) {
|
|
209
|
+
throw new Error(`Manifest not found: ${manifestPath}`);
|
|
210
|
+
}
|
|
211
|
+
manifestCache = readManifest(manifestPath);
|
|
212
|
+
manifestLoaded = true;
|
|
213
|
+
}
|
|
214
|
+
return manifestCache;
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
return ctx;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
module.exports = {
|
|
222
|
+
buildContext,
|
|
223
|
+
// Exported for unit tests — kept out of the default import surface.
|
|
224
|
+
_helpers: {
|
|
225
|
+
resolveProfileName,
|
|
226
|
+
normaliseFlags,
|
|
227
|
+
defaultPlatform,
|
|
228
|
+
resolveProfiles,
|
|
229
|
+
},
|
|
230
|
+
};
|
package/src/download.js
CHANGED
|
@@ -12,6 +12,7 @@ const { getProvider } = require('./providers');
|
|
|
12
12
|
const { evaluateCompat, pickBestCandidate, summarizeReasons } = require('./compat');
|
|
13
13
|
const { buildFromSource } = require('./build');
|
|
14
14
|
const { uploadSourceBuiltArtefact } = require('./upload-built');
|
|
15
|
+
const { assertAllSafeZipEntryNames } = require('./zip-safe');
|
|
15
16
|
const log = require('./logger');
|
|
16
17
|
|
|
17
18
|
// ---------------------------------------------------------------------------
|
|
@@ -59,17 +60,11 @@ async function tryDownloadFromSources(name, version, packageSources, platform, h
|
|
|
59
60
|
//
|
|
60
61
|
// PLAN-BUILD-MISSING phase 1: strict-by-default resolution.
|
|
61
62
|
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
// profile" from "v2 doesn't know this package at all").
|
|
68
|
-
//
|
|
69
|
-
// tryV2Compat — the old OS+arch compatibility fallback. Now dead code on
|
|
70
|
-
// the default path; only reachable via the escape-hatch env
|
|
71
|
-
// var WYVRNPM_ALLOW_LOOSE_COMPAT=1 (deprecated — prints a
|
|
72
|
-
// warning when used). Will be deleted in a future release.
|
|
63
|
+
// tryV2Exact attempts the exact profile-hash match and, when it misses,
|
|
64
|
+
// returns the versions index so the caller can distinguish "v2 knows this
|
|
65
|
+
// package but not your profile" from "v2 doesn't know this package at all".
|
|
66
|
+
// The declarative compat path (findCompatibleBuild) and the source-build
|
|
67
|
+
// path consume that index.
|
|
73
68
|
//
|
|
74
69
|
|
|
75
70
|
/**
|
|
@@ -94,9 +89,43 @@ async function tryDownloadFromSources(name, version, packageSources, platform, h
|
|
|
94
89
|
* result?: { contentSha256: string|null, gitSha: string|null, gitRepo: string|null, profileHash: string, source: string },
|
|
95
90
|
* indexFoundInSource: string|null, // which source returned a versions index (null = none did)
|
|
96
91
|
* availableProfiles: string[], // profile hashes the registry HAS, for the error message
|
|
97
|
-
* versionsIndex?: object, // pre-fetched index,
|
|
92
|
+
* versionsIndex?: object, // pre-fetched index, reused by findCompatibleBuild + source-build
|
|
98
93
|
* }>}
|
|
99
94
|
*/
|
|
95
|
+
/**
|
|
96
|
+
* Locate a v2 versions.json index for (name, version) across the configured
|
|
97
|
+
* sources WITHOUT attempting to download the exact-match artefact. Used by
|
|
98
|
+
* the `--build=always` path (F9) where the registry index is needed purely
|
|
99
|
+
* to look up `gitRepo` / `gitSha` for source-build.
|
|
100
|
+
*
|
|
101
|
+
* Returns the same shape as tryV2Exact's miss case.
|
|
102
|
+
*
|
|
103
|
+
* @param {{ name: string, version: string }} dep
|
|
104
|
+
* @param {string[]} packageSources
|
|
105
|
+
* @param {object} authOptions { awsProfile?, token? }
|
|
106
|
+
*/
|
|
107
|
+
async function findV2Index(dep, packageSources, authOptions = {}) {
|
|
108
|
+
const { name, version } = dep;
|
|
109
|
+
const { awsProfile, token } = authOptions;
|
|
110
|
+
|
|
111
|
+
for (const source of packageSources) {
|
|
112
|
+
let provider;
|
|
113
|
+
try { provider = getProvider(source); } catch { continue; }
|
|
114
|
+
|
|
115
|
+
const idx = await provider.v2GetVersionsIndex({ source, name, awsProfile, token });
|
|
116
|
+
if (idx?.versions?.[version]?.profiles) {
|
|
117
|
+
return {
|
|
118
|
+
found: false,
|
|
119
|
+
indexFoundInSource: source,
|
|
120
|
+
versionsIndex: idx,
|
|
121
|
+
availableProfiles: Object.keys(idx.versions[version].profiles),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return { found: false, indexFoundInSource: null, versionsIndex: null, availableProfiles: [] };
|
|
127
|
+
}
|
|
128
|
+
|
|
100
129
|
async function tryV2Exact(dep, packageSources, destZipPath, timeoutMs, authOptions = {}) {
|
|
101
130
|
const { name, version, profileHash } = dep;
|
|
102
131
|
const { awsProfile, token } = authOptions;
|
|
@@ -229,69 +258,20 @@ async function findCompatibleBuild({ name, version, consumerProfile, consumerOpt
|
|
|
229
258
|
};
|
|
230
259
|
}
|
|
231
260
|
|
|
232
|
-
/**
|
|
233
|
-
* LEGACY compat fallback — scan versions.json for a build with the same OS +
|
|
234
|
-
* arch as the consumer's profile and use it. Reachable only via
|
|
235
|
-
* WYVRNPM_ALLOW_LOOSE_COMPAT=1. Prints a deprecation warning whenever it
|
|
236
|
-
* succeeds. Will be removed after one release cycle.
|
|
237
|
-
*
|
|
238
|
-
* @param {{ name: string, version: string, profile: import('./profile').WyvrnProfile }} dep
|
|
239
|
-
* @param {object} versionsIndex pre-fetched from tryV2Exact
|
|
240
|
-
* @param {string} source source URL that returned the index
|
|
241
|
-
* @param {string} destZipPath
|
|
242
|
-
* @param {number} timeoutMs
|
|
243
|
-
* @param {object} authOptions
|
|
244
|
-
* @returns {Promise<object|null>} same shape as tryV2Exact.result, or null
|
|
245
|
-
*/
|
|
246
|
-
async function tryV2Compat(dep, versionsIndex, source, destZipPath, timeoutMs, authOptions = {}) {
|
|
247
|
-
const { name, version, profile } = dep;
|
|
248
|
-
const { awsProfile, token } = authOptions;
|
|
249
|
-
|
|
250
|
-
let provider;
|
|
251
|
-
try { provider = getProvider(source); } catch { return null; }
|
|
252
|
-
|
|
253
|
-
const versionEntry = versionsIndex?.versions?.[version];
|
|
254
|
-
if (!versionEntry?.profiles) return null;
|
|
255
|
-
|
|
256
|
-
for (const [ph, buildEntry] of Object.entries(versionEntry.profiles)) {
|
|
257
|
-
const bp = buildEntry.buildSettings ?? buildEntry.profile;
|
|
258
|
-
if (!bp) continue;
|
|
259
|
-
if (bp.os !== profile.os) continue;
|
|
260
|
-
if (bp.arch !== profile.arch) continue;
|
|
261
|
-
|
|
262
|
-
log.warn(
|
|
263
|
-
`⚠ DEPRECATED loose-compat fallback (WYVRNPM_ALLOW_LOOSE_COMPAT=1):\n` +
|
|
264
|
-
` ${name}@${version} — using build [${ph}] (${bp.compiler} ${bp['compiler.version']})\n` +
|
|
265
|
-
` cppstd/runtime may differ from your profile. This fallback will be removed in the next release.`,
|
|
266
|
-
);
|
|
267
|
-
const ok = await provider.v2DownloadZip({ source, name, version, profileHash: ph, awsProfile, token }, destZipPath, timeoutMs);
|
|
268
|
-
if (ok) {
|
|
269
|
-
return {
|
|
270
|
-
contentSha256: buildEntry.contentSha256 ?? null,
|
|
271
|
-
gitSha: versionEntry.source?.gitSha ?? null,
|
|
272
|
-
gitRepo: versionEntry.source?.gitRepo ?? null,
|
|
273
|
-
profileHash: ph,
|
|
274
|
-
source,
|
|
275
|
-
};
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
return null;
|
|
279
|
-
}
|
|
280
|
-
|
|
281
261
|
/**
|
|
282
262
|
* Build the structured error message shown when a package exists in the v2
|
|
283
263
|
* registry but no build matches the consumer's profileHash.
|
|
284
264
|
*/
|
|
285
|
-
function buildStrictMissMessage({ name, version, profileHash, availableProfiles, buildMode
|
|
265
|
+
function buildStrictMissMessage({ name, version, profileHash, availableProfiles, buildMode }) {
|
|
286
266
|
const lines = [
|
|
287
267
|
`no v2 build of ${name}@${version} matches profile [${profileHash}]`,
|
|
288
268
|
availableProfiles.length > 0
|
|
289
269
|
? ` Available builds on the registry: ${availableProfiles.map((p) => `[${p}]`).join(' ')}`
|
|
290
270
|
: ` The registry lists no builds for this version.`,
|
|
291
271
|
];
|
|
292
|
-
if (buildMode === 'missing') {
|
|
272
|
+
if (buildMode === 'missing' || buildMode === 'always') {
|
|
293
273
|
lines.push(
|
|
294
|
-
` --build
|
|
274
|
+
` --build=${buildMode} source-build was attempted but did not produce a usable binary. ` +
|
|
295
275
|
`See the error above for details (missing git metadata, missing "build" section in source manifest, or compile failure).`,
|
|
296
276
|
);
|
|
297
277
|
} else {
|
|
@@ -299,11 +279,6 @@ function buildStrictMissMessage({ name, version, profileHash, availableProfiles,
|
|
|
299
279
|
` Fix: publish a build for your profile, switch to a compatible one, or rerun with --build=missing to source-build.`,
|
|
300
280
|
);
|
|
301
281
|
}
|
|
302
|
-
if (!loose) {
|
|
303
|
-
lines.push(
|
|
304
|
-
` Escape hatch: WYVRNPM_ALLOW_LOOSE_COMPAT=1 (deprecated — accepts same OS+arch with possibly-different cppstd/runtime).`,
|
|
305
|
-
);
|
|
306
|
-
}
|
|
307
282
|
return lines.join('\n');
|
|
308
283
|
}
|
|
309
284
|
|
|
@@ -313,8 +288,17 @@ function buildStrictMissMessage({ name, version, profileHash, availableProfiles,
|
|
|
313
288
|
|
|
314
289
|
async function extractZip(destZipPath, extractDir) {
|
|
315
290
|
const zip = new StreamZip.async({ file: destZipPath });
|
|
316
|
-
|
|
317
|
-
|
|
291
|
+
try {
|
|
292
|
+
// Zip Slip defense-in-depth (EVALUATION.md S2): pre-scan entry names
|
|
293
|
+
// before handing them to node-stream-zip. Library is currently not
|
|
294
|
+
// known-vulnerable, but registry zips are the single most untrusted
|
|
295
|
+
// input on this path.
|
|
296
|
+
const entries = await zip.entries();
|
|
297
|
+
assertAllSafeZipEntryNames(Object.keys(entries), path.basename(destZipPath));
|
|
298
|
+
await zip.extract(null, extractDir);
|
|
299
|
+
} finally {
|
|
300
|
+
await zip.close();
|
|
301
|
+
}
|
|
318
302
|
}
|
|
319
303
|
|
|
320
304
|
// ---------------------------------------------------------------------------
|
|
@@ -328,12 +312,13 @@ async function extractZip(destZipPath, extractDir) {
|
|
|
328
312
|
* 1. Exact profile-hash match from any configured v2 source → use it.
|
|
329
313
|
* 2. Otherwise, if the package exists in v2 (versions.json returned an
|
|
330
314
|
* entry for this version) but nothing matches our profile:
|
|
331
|
-
* -
|
|
332
|
-
*
|
|
333
|
-
* - `buildMode === 'missing'
|
|
334
|
-
* at
|
|
335
|
-
* -
|
|
336
|
-
*
|
|
315
|
+
* - Try declarative per-package compatibility (PLAN-BUILD-MISSING
|
|
316
|
+
* phase 2); use the best compatible build if any.
|
|
317
|
+
* - With `buildMode === 'missing'` and nothing compatible: clone the
|
|
318
|
+
* source at gitRepo@gitSha and build it locally (phase 3).
|
|
319
|
+
* - Otherwise: STRICT FAIL with a structured error. Does not fall
|
|
320
|
+
* back to v1 (that would silently accept a build with different
|
|
321
|
+
* settings).
|
|
337
322
|
* 3. Otherwise (v2 doesn't know the package at all) → v1 legacy fallback.
|
|
338
323
|
*
|
|
339
324
|
* Legacy deps (plain `Map<name, version>`, no profile info) skip v2 entirely
|
|
@@ -362,15 +347,6 @@ async function downloadDependencies(deps, packageSources, platform, razerDir, ht
|
|
|
362
347
|
uploadStats,
|
|
363
348
|
} = options;
|
|
364
349
|
|
|
365
|
-
if (buildMode === 'always') {
|
|
366
|
-
throw new Error(
|
|
367
|
-
'--build=always is not implemented (requires PLAN-BUILD-MISSING phase 3). ' +
|
|
368
|
-
'Use --build=never (default) or --build=missing.',
|
|
369
|
-
);
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
const looseCompatAllowed = process.env.WYVRNPM_ALLOW_LOOSE_COMPAT === '1';
|
|
373
|
-
|
|
374
350
|
if (!fs.existsSync(razerDir)) {
|
|
375
351
|
fs.mkdirSync(razerDir, { recursive: true });
|
|
376
352
|
}
|
|
@@ -385,6 +361,11 @@ async function downloadDependencies(deps, packageSources, platform, razerDir, ht
|
|
|
385
361
|
const profileHash = isLegacyEntry ? null : (depInfoOrVersion.profileHash ?? null);
|
|
386
362
|
const profile = isLegacyEntry ? null : (depInfoOrVersion.profile ?? null);
|
|
387
363
|
const options = isLegacyEntry ? null : (depInfoOrVersion.options ?? null);
|
|
364
|
+
const pinnedSource = isLegacyEntry ? null : (depInfoOrVersion.pinnedSource ?? null);
|
|
365
|
+
// Per-dep source pin (EVALUATION.md S4). When set, restrict every
|
|
366
|
+
// lookup for this package to the pinned URL; no fan-out across
|
|
367
|
+
// configured sources.
|
|
368
|
+
const depSources = pinnedSource ? [pinnedSource.url] : packageSources;
|
|
388
369
|
|
|
389
370
|
const extractDir = path.join(razerDir, name);
|
|
390
371
|
const versionFile = path.join(extractDir, '.wyvrn-version');
|
|
@@ -398,36 +379,43 @@ async function downloadDependencies(deps, packageSources, platform, razerDir, ht
|
|
|
398
379
|
continue;
|
|
399
380
|
}
|
|
400
381
|
|
|
401
|
-
// Skip if already at correct version + profile hash
|
|
382
|
+
// Skip if already at correct version + profile hash. `--build=always`
|
|
383
|
+
// (F9) bypasses this cache so the forced rebuild actually fires.
|
|
402
384
|
if (fs.existsSync(extractDir)) {
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
:
|
|
406
|
-
const installedMeta = fs.existsSync(metaFile)
|
|
407
|
-
? JSON.parse(fs.readFileSync(metaFile, 'utf8'))
|
|
408
|
-
: null;
|
|
409
|
-
|
|
410
|
-
const sameVersion = installedVersion === version;
|
|
411
|
-
const sameProfile = !profileHash || installedMeta?.profileHash === profileHash;
|
|
412
|
-
|
|
413
|
-
if (sameVersion && sameProfile) {
|
|
414
|
-
log.info(`Already present: ${name}@${version}${profileHash ? ` [${installedMeta?.profileHash}]` : ''} — skipping`);
|
|
415
|
-
lockEntries.set(name, {
|
|
416
|
-
version,
|
|
417
|
-
profileHash: installedMeta?.profileHash ?? null,
|
|
418
|
-
contentSha256: installedMeta?.contentSha256 ?? null,
|
|
419
|
-
gitSha: installedMeta?.gitSha ?? null,
|
|
420
|
-
resolvedFrom: installedMeta?.resolvedFrom ?? 'cached',
|
|
421
|
-
...(options ? { options } : {}),
|
|
422
|
-
});
|
|
423
|
-
continue;
|
|
424
|
-
}
|
|
425
|
-
if (!sameVersion) {
|
|
426
|
-
log.info(`Version changed: ${name} ${installedVersion} → ${version}, re-downloading`);
|
|
385
|
+
if (buildMode === 'always') {
|
|
386
|
+
log.info(`--build=always: removing existing ${name}@${version} to force source-build`);
|
|
387
|
+
fs.rmSync(extractDir, { recursive: true, force: true });
|
|
427
388
|
} else {
|
|
428
|
-
|
|
389
|
+
const installedVersion = fs.existsSync(versionFile)
|
|
390
|
+
? fs.readFileSync(versionFile, 'utf8').trim()
|
|
391
|
+
: null;
|
|
392
|
+
const installedMeta = fs.existsSync(metaFile)
|
|
393
|
+
? JSON.parse(fs.readFileSync(metaFile, 'utf8'))
|
|
394
|
+
: null;
|
|
395
|
+
|
|
396
|
+
const sameVersion = installedVersion === version;
|
|
397
|
+
const sameProfile = !profileHash || installedMeta?.profileHash === profileHash;
|
|
398
|
+
|
|
399
|
+
if (sameVersion && sameProfile) {
|
|
400
|
+
log.info(`Already present: ${name}@${version}${profileHash ? ` [${installedMeta?.profileHash}]` : ''} — skipping`);
|
|
401
|
+
lockEntries.set(name, {
|
|
402
|
+
version,
|
|
403
|
+
profileHash: installedMeta?.profileHash ?? null,
|
|
404
|
+
contentSha256: installedMeta?.contentSha256 ?? null,
|
|
405
|
+
gitSha: installedMeta?.gitSha ?? null,
|
|
406
|
+
resolvedFrom: installedMeta?.resolvedFrom ?? 'cached',
|
|
407
|
+
...(options ? { options } : {}),
|
|
408
|
+
...(pinnedSource ? { source: pinnedSource.name } : {}),
|
|
409
|
+
});
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
if (!sameVersion) {
|
|
413
|
+
log.info(`Version changed: ${name} ${installedVersion} → ${version}, re-downloading`);
|
|
414
|
+
} else {
|
|
415
|
+
log.info(`Profile changed for ${name}@${version}, re-downloading`);
|
|
416
|
+
}
|
|
417
|
+
fs.rmSync(extractDir, { recursive: true, force: true });
|
|
429
418
|
}
|
|
430
|
-
fs.rmSync(extractDir, { recursive: true, force: true });
|
|
431
419
|
}
|
|
432
420
|
|
|
433
421
|
log.info(`Downloading: ${name}@${version}`);
|
|
@@ -439,26 +427,38 @@ async function downloadDependencies(deps, packageSources, platform, razerDir, ht
|
|
|
439
427
|
|
|
440
428
|
// ── Try v2 exact ────────────────────────────────────────────────────────
|
|
441
429
|
if (profileHash && profile) {
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
430
|
+
// --build=always skips every registry-binary path (exact match, compat
|
|
431
|
+
// match) and goes straight to source-build. Fetch just the versions
|
|
432
|
+
// index so source-build can find gitRepo/gitSha. EVALUATION.md F9.
|
|
433
|
+
const v2 = (buildMode === 'always')
|
|
434
|
+
? await findV2Index(
|
|
435
|
+
{ name, version },
|
|
436
|
+
depSources,
|
|
437
|
+
{ awsProfile, token },
|
|
438
|
+
)
|
|
439
|
+
: await tryV2Exact(
|
|
440
|
+
{ name, version, profileHash },
|
|
441
|
+
depSources,
|
|
442
|
+
destZipPath,
|
|
443
|
+
timeoutMs,
|
|
444
|
+
{ awsProfile, token },
|
|
445
|
+
);
|
|
449
446
|
|
|
450
447
|
if (v2.found) {
|
|
451
448
|
downloadResult = v2.result;
|
|
452
449
|
resolvedFrom = 'v2';
|
|
453
450
|
} else if (v2.indexFoundInSource) {
|
|
454
|
-
// v2 knows this package but no exact profile match
|
|
455
|
-
// to v1 — that would silently
|
|
456
|
-
//
|
|
457
|
-
//
|
|
451
|
+
// v2 knows this package but no exact profile match (or --build=always
|
|
452
|
+
// forced the path here). Do NOT fall back to v1 — that would silently
|
|
453
|
+
// accept a binary with different build settings. Try declarative
|
|
454
|
+
// compatibility first (phase 2) unless always-mode, then source-build
|
|
455
|
+
// when --build=missing|always, then fail strictly.
|
|
458
456
|
skipV1Fallback = true;
|
|
459
457
|
|
|
460
458
|
// ── Phase 2: declarative per-package compatibility ─────────────────
|
|
461
|
-
|
|
459
|
+
// Skip under --build=always: the whole point of that flag is to
|
|
460
|
+
// produce a fresh source-build, not to accept a registry binary.
|
|
461
|
+
const compatResult = (buildMode === 'always') ? null : await findCompatibleBuild({
|
|
462
462
|
name, version,
|
|
463
463
|
consumerProfile: profile,
|
|
464
464
|
consumerOptions: options,
|
|
@@ -490,8 +490,8 @@ async function downloadDependencies(deps, packageSources, platform, razerDir, ht
|
|
|
490
490
|
}
|
|
491
491
|
}
|
|
492
492
|
|
|
493
|
-
// ── Phase 3: source-build when `--build=missing`
|
|
494
|
-
if (!downloadResult && buildMode === 'missing') {
|
|
493
|
+
// ── Phase 3: source-build when `--build=missing` or `--build=always` ──
|
|
494
|
+
if (!downloadResult && (buildMode === 'missing' || buildMode === 'always')) {
|
|
495
495
|
const sourceInfo = v2.versionsIndex?.versions?.[version]?.source;
|
|
496
496
|
if (sourceInfo?.gitRepo && sourceInfo?.gitSha) {
|
|
497
497
|
try {
|
|
@@ -531,33 +531,16 @@ async function downloadDependencies(deps, packageSources, platform, razerDir, ht
|
|
|
531
531
|
}
|
|
532
532
|
}
|
|
533
533
|
|
|
534
|
-
// ── Deprecated escape hatch: WYVRNPM_ALLOW_LOOSE_COMPAT=1 ──────────
|
|
535
|
-
if (!downloadResult && looseCompatAllowed) {
|
|
536
|
-
const compat = await tryV2Compat(
|
|
537
|
-
{ name, version, profile },
|
|
538
|
-
v2.versionsIndex,
|
|
539
|
-
v2.indexFoundInSource,
|
|
540
|
-
destZipPath,
|
|
541
|
-
timeoutMs,
|
|
542
|
-
{ awsProfile, token },
|
|
543
|
-
);
|
|
544
|
-
if (compat) {
|
|
545
|
-
downloadResult = compat;
|
|
546
|
-
resolvedFrom = 'v2-compat-deprecated';
|
|
547
|
-
}
|
|
548
|
-
}
|
|
549
|
-
|
|
550
534
|
if (!downloadResult) {
|
|
551
535
|
log.error(buildStrictMissMessage({
|
|
552
536
|
name, version, profileHash,
|
|
553
537
|
availableProfiles: v2.availableProfiles,
|
|
554
538
|
buildMode,
|
|
555
|
-
loose: looseCompatAllowed,
|
|
556
539
|
}));
|
|
557
540
|
lockEntries.set(name, {
|
|
558
541
|
version,
|
|
559
542
|
profileHash,
|
|
560
|
-
resolvedFrom: buildMode === 'missing' ? 'source-build-failed' : 'no-exact-match',
|
|
543
|
+
resolvedFrom: (buildMode === 'missing' || buildMode === 'always') ? 'source-build-failed' : 'no-exact-match',
|
|
561
544
|
availableProfiles: v2.availableProfiles,
|
|
562
545
|
});
|
|
563
546
|
continue;
|
|
@@ -568,7 +551,7 @@ async function downloadDependencies(deps, packageSources, platform, razerDir, ht
|
|
|
568
551
|
|
|
569
552
|
// ── Fall back to v1 ─────────────────────────────────────────────────────
|
|
570
553
|
if (!downloadResult && !skipV1Fallback) {
|
|
571
|
-
const ok = await tryDownloadFromSources(name, version,
|
|
554
|
+
const ok = await tryDownloadFromSources(name, version, depSources, platform, httpClient, timeoutMs, destZipPath);
|
|
572
555
|
if (!ok) {
|
|
573
556
|
log.warn(`package ${name}@${version} was not found in any configured source`);
|
|
574
557
|
lockEntries.set(name, { version, resolvedFrom: 'not-found' });
|
|
@@ -631,7 +614,12 @@ async function downloadDependencies(deps, packageSources, platform, razerDir, ht
|
|
|
631
614
|
profileHash: downloadResult?.profileHash ?? null,
|
|
632
615
|
contentSha256: downloadResult?.contentSha256 ?? null,
|
|
633
616
|
gitSha: downloadResult?.gitSha ?? null,
|
|
634
|
-
...(options
|
|
617
|
+
...(options ? { options } : {}),
|
|
618
|
+
// S4: record the pinned source name on the lock entry so a
|
|
619
|
+
// reviewer reading wyvrn.lock can confirm the artefact came
|
|
620
|
+
// from the declared source. Absent on non-pinned deps so
|
|
621
|
+
// pre-S4 lockfiles remain byte-identical.
|
|
622
|
+
...(pinnedSource ? { source: pinnedSource.name } : {}),
|
|
635
623
|
});
|
|
636
624
|
|
|
637
625
|
// ── Upload freshly source-built artefact back to the registry ────────
|