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/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
- // The v2 path is split in two:
63
- //
64
- // tryV2Exact — ONLY attempts the exact profile-hash match. Also returns
65
- // the versions index if the package exists in v2 (so the
66
- // caller can distinguish "v2 knows this package but not your
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, reusable by tryV2Compat
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, loose }) {
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=missing source-build was attempted but did not produce a usable binary. ` +
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
- await zip.extract(null, extractDir);
317
- await zip.close();
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
- * - Default: STRICT FAIL with a structured error. Does not fall back
332
- * to v1 (that would silently accept a build with different settings).
333
- * - `buildMode === 'missing'`: same strict fail for now; error hints
334
- * at PLAN-BUILD-MISSING phase 3.
335
- * - WYVRNPM_ALLOW_LOOSE_COMPAT=1 (deprecated escape hatch): try the
336
- * old OS+arch compat fallback with a deprecation warning.
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
- const installedVersion = fs.existsSync(versionFile)
404
- ? fs.readFileSync(versionFile, 'utf8').trim()
405
- : null;
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
- log.info(`Profile changed for ${name}@${version}, re-downloading`);
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
- const v2 = await tryV2Exact(
443
- { name, version, profileHash },
444
- packageSources,
445
- destZipPath,
446
- timeoutMs,
447
- { awsProfile, token },
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. Do NOT fall back
455
- // to v1 — that would silently accept a binary with different build
456
- // settings. Try declarative compatibility first (phase 2), then the
457
- // deprecated env-var escape hatch, then fail strictly.
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
- const compatResult = await findCompatibleBuild({
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` is active ─────────
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, packageSources, platform, httpClient, timeoutMs, destZipPath);
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 ? { 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 ────────