wyvrnpm 2.0.4 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/download.js CHANGED
@@ -9,6 +9,9 @@ const StreamZip = require('node-stream-zip');
9
9
  const { isLink, getLinkTarget } = require('./link-utils');
10
10
  const { sha256Of } = require('./profile');
11
11
  const { getProvider } = require('./providers');
12
+ const { evaluateCompat, pickBestCandidate, summarizeReasons } = require('./compat');
13
+ const { buildFromSource } = require('./build');
14
+ const log = require('./logger');
12
15
 
13
16
  // ---------------------------------------------------------------------------
14
17
  // v1 (legacy) download helpers
@@ -42,7 +45,7 @@ async function tryDownloadFromSources(name, version, packageSources, platform, h
42
45
  await downloadFile(url, destZipPath, httpClient, timeoutMs);
43
46
  return true;
44
47
  } catch (err) {
45
- console.warn(`[wyvrn] Could not download ${name}@${version} from ${url}: ${err.message}`);
48
+ log.warn(`Could not download ${name}@${version} from ${url}: ${err.message}`);
46
49
  }
47
50
  }
48
51
  }
@@ -52,101 +55,244 @@ async function tryDownloadFromSources(name, version, packageSources, platform, h
52
55
  // ---------------------------------------------------------------------------
53
56
  // v2 download helpers
54
57
  // ---------------------------------------------------------------------------
58
+ //
59
+ // PLAN-BUILD-MISSING phase 1: strict-by-default resolution.
60
+ //
61
+ // The v2 path is split in two:
62
+ //
63
+ // tryV2Exact — ONLY attempts the exact profile-hash match. Also returns
64
+ // the versions index if the package exists in v2 (so the
65
+ // caller can distinguish "v2 knows this package but not your
66
+ // profile" from "v2 doesn't know this package at all").
67
+ //
68
+ // tryV2Compat — the old OS+arch compatibility fallback. Now dead code on
69
+ // the default path; only reachable via the escape-hatch env
70
+ // var WYVRNPM_ALLOW_LOOSE_COMPAT=1 (deprecated — prints a
71
+ // warning when used). Will be deleted in a future release.
72
+ //
55
73
 
56
74
  /**
57
- * Try to download a v2 prebuilt binary for a package.
75
+ * Attempt an exact v2 profile-hash download across all configured sources.
58
76
  *
59
- * Resolution order:
60
- * 1. Exact profile hash match
61
- * 2. Compatible build from versions.json (same OS + arch, any compiler)
62
- * 3. Return null (caller should fall back to v1)
77
+ * Semantics:
78
+ * - If ANY source has a byte-exact match, we download from the first one
79
+ * and return `found: true`.
80
+ * - Otherwise we return `found: false`, along with whether any source had
81
+ * a versions.json entry for (name, version). The caller uses that flag
82
+ * to decide whether to fall through to the v1 legacy path (only when
83
+ * the package is unknown in v2 entirely) or to fail strictly (when v2
84
+ * knows the package but not the requested profile).
63
85
  *
64
- * @param {{
65
- * name: string,
66
- * version: string,
67
- * profileHash: string,
68
- * profile: import('./profile').WyvrnProfile,
69
- * }} dep
70
- * @param {string[]} packageSources
71
- * @param {string} destZipPath
72
- * @param {number} timeoutMs
73
- * @param {object} authOptions - { awsProfile?, token? }
86
+ * @param {{ name: string, version: string, profileHash: string }} dep
87
+ * @param {string[]} packageSources
88
+ * @param {string} destZipPath
89
+ * @param {number} timeoutMs
90
+ * @param {object} authOptions { awsProfile?, token? }
74
91
  * @returns {Promise<{
75
- * found: boolean,
76
- * contentSha256: string|null,
77
- * gitSha: string|null,
78
- * gitRepo: string|null,
79
- * profileHash: string,
80
- * source: string, // which source URL was used
81
- * }|null>} null = not found in v2
92
+ * found: boolean,
93
+ * result?: { contentSha256: string|null, gitSha: string|null, gitRepo: string|null, profileHash: string, source: string },
94
+ * indexFoundInSource: string|null, // which source returned a versions index (null = none did)
95
+ * availableProfiles: string[], // profile hashes the registry HAS, for the error message
96
+ * versionsIndex?: object, // pre-fetched index, reusable by tryV2Compat
97
+ * }>}
82
98
  */
83
- async function tryDownloadV2(dep, packageSources, destZipPath, timeoutMs, authOptions = {}) {
84
- const { name, version, profileHash, profile } = dep;
99
+ async function tryV2Exact(dep, packageSources, destZipPath, timeoutMs, authOptions = {}) {
100
+ const { name, version, profileHash } = dep;
85
101
  const { awsProfile, token } = authOptions;
86
102
 
103
+ let indexFoundInSource = null;
104
+ let versionsIndex = null;
105
+ let availableProfiles = [];
106
+
87
107
  for (const source of packageSources) {
88
108
  let provider;
89
109
  try { provider = getProvider(source); } catch { continue; }
90
110
 
91
- // 1) Exact profile hash
92
111
  const base = source.replace(/\/$/, '');
93
112
  const metaUrl = `${base}/v2/${name}/${version}/${profileHash}/wyvrn.json`;
94
- console.log(`[wyvrn] Trying v2: ${metaUrl}`);
113
+ log.info(`Trying v2: ${metaUrl}`);
95
114
  const exactMeta = await provider.v2GetMeta({ source, name, version, profileHash, awsProfile, token });
96
115
  if (exactMeta) {
97
116
  const ok = await provider.v2DownloadZip({ source, name, version, profileHash, awsProfile, token }, destZipPath, timeoutMs);
98
117
  if (ok) {
99
118
  return {
100
- found: true,
101
- contentSha256: exactMeta.contentSha256 ?? null,
102
- gitSha: exactMeta.gitSha ?? null,
103
- gitRepo: exactMeta.gitRepo ?? null,
104
- profileHash,
105
- source,
119
+ found: true,
120
+ result: {
121
+ contentSha256: exactMeta.contentSha256 ?? null,
122
+ gitSha: exactMeta.gitSha ?? null,
123
+ gitRepo: exactMeta.gitRepo ?? null,
124
+ profileHash,
125
+ source,
126
+ },
127
+ indexFoundInSource: source,
128
+ availableProfiles: [],
106
129
  };
107
130
  }
108
131
  } else {
109
- console.log(`[wyvrn] v2 miss (no exact profile match): ${metaUrl}`);
132
+ log.info(`v2 miss (no exact profile match): ${metaUrl}`);
110
133
  }
111
134
 
112
- // 2) Fallback: scan versions.json for a compatible build (same OS + arch)
113
- const versionsUrl = `${base}/v2/${name}/versions.json`;
114
- console.log(`[wyvrn] Trying v2 versions index: ${versionsUrl}`);
115
- const idx = await provider.v2GetVersionsIndex({ source, name, awsProfile, token });
116
- if (!idx?.versions?.[version]?.profiles) {
117
- console.log(`[wyvrn] v2 miss (no versions index or no entry for ${version}): ${versionsUrl}`);
118
- continue;
135
+ // No exact match from this source. Check whether v2 knows the package
136
+ // at all — keep the first non-null index we find (for error messages
137
+ // and potential compat fallback).
138
+ if (!versionsIndex) {
139
+ const idx = await provider.v2GetVersionsIndex({ source, name, awsProfile, token });
140
+ if (idx?.versions?.[version]?.profiles) {
141
+ versionsIndex = idx;
142
+ indexFoundInSource = source;
143
+ availableProfiles = Object.keys(idx.versions[version].profiles);
144
+ }
119
145
  }
146
+ }
120
147
 
121
- const versionEntry = idx.versions[version];
122
- for (const [ph, buildEntry] of Object.entries(versionEntry.profiles)) {
123
- // buildSettings is the copy of the profile stored for debugging at publish time
124
- const bp = buildEntry.buildSettings ?? buildEntry.profile;
125
- if (!bp) continue;
126
- if (bp.os !== profile.os) continue;
127
- if (bp.arch !== profile.arch) continue;
128
- // Same OS + arch accept as a compatible fallback
129
- console.warn(
130
- `[wyvrn] No exact profile match for ${name}@${version}; using compatible build [${ph}]` +
131
- ` (${bp.compiler} ${bp['compiler.version']})`,
132
- );
133
- const ok = await provider.v2DownloadZip({ source, name, version, profileHash: ph, awsProfile, token }, destZipPath, timeoutMs);
134
- if (ok) {
135
- return {
136
- found: true,
137
- contentSha256: buildEntry.contentSha256 ?? null,
138
- gitSha: versionEntry.source?.gitSha ?? null,
139
- gitRepo: versionEntry.source?.gitRepo ?? null,
140
- profileHash: ph,
141
- source,
142
- };
143
- }
148
+ return { found: false, indexFoundInSource, availableProfiles, versionsIndex };
149
+ }
150
+
151
+ /**
152
+ * Declarative per-package compatibility resolver (PLAN-BUILD-MISSING phase 2).
153
+ *
154
+ * When no exact profileHash match exists but the v2 registry knows the
155
+ * package, we evaluate each registry-side build's `compatibility` block
156
+ * (from the build's `wyvrn.json`) against the consumer's profile. If any
157
+ * candidates are compatible, the tiebreaker picks one.
158
+ *
159
+ * Cost: O(n) HTTP round-trips (one `v2GetMeta` per build) — acceptable at
160
+ * phase 2; phase 2.1 could fold the compat block into `versions.json` to
161
+ * make this O(1).
162
+ *
163
+ * @param {object} args
164
+ * @param {string} args.name
165
+ * @param {string} args.version
166
+ * @param {import('./profile').WyvrnProfile} args.consumerProfile
167
+ * @param {object} args.versionsIndex pre-fetched from tryV2Exact
168
+ * @param {string} args.source source URL that returned the index
169
+ * @param {object} args.authOptions
170
+ * @returns {Promise<{
171
+ * profileHash: string,
172
+ * contentSha256: string|null,
173
+ * gitSha: string|null,
174
+ * gitRepo: string|null,
175
+ * source: string,
176
+ * reasons: Array<object>,
177
+ * meta: object,
178
+ * }|null>}
179
+ */
180
+ async function findCompatibleBuild({ name, version, consumerProfile, versionsIndex, source, authOptions = {} }) {
181
+ const { awsProfile, token } = authOptions;
182
+ let provider;
183
+ try { provider = getProvider(source); } catch { return null; }
184
+
185
+ const versionEntry = versionsIndex?.versions?.[version];
186
+ if (!versionEntry?.profiles) return null;
187
+
188
+ const candidates = [];
189
+ for (const [profileHash, buildEntry] of Object.entries(versionEntry.profiles)) {
190
+ const packageProfile = buildEntry.buildSettings ?? buildEntry.profile;
191
+ if (!packageProfile) continue;
192
+
193
+ // Compatibility block lives on the per-build wyvrn.json. Fetch it.
194
+ const meta = await provider.v2GetMeta({ source, name, version, profileHash, awsProfile, token });
195
+ if (!meta) continue;
196
+
197
+ const rawCompat = meta.compatibility ?? null;
198
+ const { compatible, reasons } = evaluateCompat(consumerProfile, packageProfile, rawCompat);
199
+ if (compatible) {
200
+ candidates.push({ profileHash, packageProfile, reasons, buildEntry, meta });
144
201
  }
145
202
  }
146
203
 
204
+ const best = pickBestCandidate(candidates, consumerProfile);
205
+ if (!best) return null;
206
+
207
+ return {
208
+ profileHash: best.profileHash,
209
+ contentSha256: best.buildEntry.contentSha256 ?? best.meta.contentSha256 ?? null,
210
+ gitSha: versionEntry.source?.gitSha ?? best.meta.gitSha ?? null,
211
+ gitRepo: versionEntry.source?.gitRepo ?? best.meta.gitRepo ?? null,
212
+ source,
213
+ reasons: best.reasons,
214
+ meta: best.meta,
215
+ };
216
+ }
217
+
218
+ /**
219
+ * LEGACY compat fallback — scan versions.json for a build with the same OS +
220
+ * arch as the consumer's profile and use it. Reachable only via
221
+ * WYVRNPM_ALLOW_LOOSE_COMPAT=1. Prints a deprecation warning whenever it
222
+ * succeeds. Will be removed after one release cycle.
223
+ *
224
+ * @param {{ name: string, version: string, profile: import('./profile').WyvrnProfile }} dep
225
+ * @param {object} versionsIndex pre-fetched from tryV2Exact
226
+ * @param {string} source source URL that returned the index
227
+ * @param {string} destZipPath
228
+ * @param {number} timeoutMs
229
+ * @param {object} authOptions
230
+ * @returns {Promise<object|null>} same shape as tryV2Exact.result, or null
231
+ */
232
+ async function tryV2Compat(dep, versionsIndex, source, destZipPath, timeoutMs, authOptions = {}) {
233
+ const { name, version, profile } = dep;
234
+ const { awsProfile, token } = authOptions;
235
+
236
+ let provider;
237
+ try { provider = getProvider(source); } catch { return null; }
238
+
239
+ const versionEntry = versionsIndex?.versions?.[version];
240
+ if (!versionEntry?.profiles) return null;
241
+
242
+ for (const [ph, buildEntry] of Object.entries(versionEntry.profiles)) {
243
+ const bp = buildEntry.buildSettings ?? buildEntry.profile;
244
+ if (!bp) continue;
245
+ if (bp.os !== profile.os) continue;
246
+ if (bp.arch !== profile.arch) continue;
247
+
248
+ log.warn(
249
+ `⚠ DEPRECATED loose-compat fallback (WYVRNPM_ALLOW_LOOSE_COMPAT=1):\n` +
250
+ ` ${name}@${version} — using build [${ph}] (${bp.compiler} ${bp['compiler.version']})\n` +
251
+ ` cppstd/runtime may differ from your profile. This fallback will be removed in the next release.`,
252
+ );
253
+ const ok = await provider.v2DownloadZip({ source, name, version, profileHash: ph, awsProfile, token }, destZipPath, timeoutMs);
254
+ if (ok) {
255
+ return {
256
+ contentSha256: buildEntry.contentSha256 ?? null,
257
+ gitSha: versionEntry.source?.gitSha ?? null,
258
+ gitRepo: versionEntry.source?.gitRepo ?? null,
259
+ profileHash: ph,
260
+ source,
261
+ };
262
+ }
263
+ }
147
264
  return null;
148
265
  }
149
266
 
267
+ /**
268
+ * Build the structured error message shown when a package exists in the v2
269
+ * registry but no build matches the consumer's profileHash.
270
+ */
271
+ function buildStrictMissMessage({ name, version, profileHash, availableProfiles, buildMode, loose }) {
272
+ const lines = [
273
+ `no v2 build of ${name}@${version} matches profile [${profileHash}]`,
274
+ availableProfiles.length > 0
275
+ ? ` Available builds on the registry: ${availableProfiles.map((p) => `[${p}]`).join(' ')}`
276
+ : ` The registry lists no builds for this version.`,
277
+ ];
278
+ if (buildMode === 'missing') {
279
+ lines.push(
280
+ ` --build=missing source-build was attempted but did not produce a usable binary. ` +
281
+ `See the error above for details (missing git metadata, missing "build" section in source manifest, or compile failure).`,
282
+ );
283
+ } else {
284
+ lines.push(
285
+ ` Fix: publish a build for your profile, switch to a compatible one, or rerun with --build=missing to source-build.`,
286
+ );
287
+ }
288
+ if (!loose) {
289
+ lines.push(
290
+ ` Escape hatch: WYVRNPM_ALLOW_LOOSE_COMPAT=1 (deprecated — accepts same OS+arch with possibly-different cppstd/runtime).`,
291
+ );
292
+ }
293
+ return lines.join('\n');
294
+ }
295
+
150
296
  // ---------------------------------------------------------------------------
151
297
  // Extraction helper
152
298
  // ---------------------------------------------------------------------------
@@ -163,7 +309,21 @@ async function extractZip(destZipPath, extractDir) {
163
309
 
164
310
  /**
165
311
  * Downloads and extracts all resolved dependencies.
166
- * Tries v2 first (profile-aware + SHA256 verification), falls back to v1.
312
+ *
313
+ * Resolution order (v2 path, i.e. enriched deps with a profileHash):
314
+ * 1. Exact profile-hash match from any configured v2 source → use it.
315
+ * 2. Otherwise, if the package exists in v2 (versions.json returned an
316
+ * entry for this version) but nothing matches our profile:
317
+ * - Default: STRICT FAIL with a structured error. Does not fall back
318
+ * to v1 (that would silently accept a build with different settings).
319
+ * - `buildMode === 'missing'`: same strict fail for now; error hints
320
+ * at PLAN-BUILD-MISSING phase 3.
321
+ * - WYVRNPM_ALLOW_LOOSE_COMPAT=1 (deprecated escape hatch): try the
322
+ * old OS+arch compat fallback with a deprecation warning.
323
+ * 3. Otherwise (v2 doesn't know the package at all) → v1 legacy fallback.
324
+ *
325
+ * Legacy deps (plain `Map<name, version>`, no profile info) skip v2 entirely
326
+ * and use the v1 path, same as before.
167
327
  *
168
328
  * @param {Map<string, string | {version:string, profileHash:string|null, profile:import('./profile').WyvrnProfile|null}>} deps
169
329
  * Either a plain `Map<name, version>` (legacy callers / tests) or the enriched map
@@ -173,12 +333,22 @@ async function extractZip(destZipPath, extractDir) {
173
333
  * @param {string} razerDir
174
334
  * @param {Function} httpClient - fetch-compatible
175
335
  * @param {number} timeout - seconds
176
- * @param {object} [authOptions] - { awsProfile?, token? }
336
+ * @param {object} [options] - { awsProfile?, token?, buildMode? }
337
+ * buildMode: 'never' (default) | 'missing' | 'always'
177
338
  * @returns {Promise<Map<string, {version:string, profileHash?:string, contentSha256?:string, gitSha?:string, resolvedFrom:string}>>}
178
339
  */
179
- async function downloadDependencies(deps, packageSources, platform, razerDir, httpClient, timeout, authOptions = {}) {
340
+ async function downloadDependencies(deps, packageSources, platform, razerDir, httpClient, timeout, options = {}) {
180
341
  const timeoutMs = timeout * 1000;
181
- const { awsProfile, token } = authOptions;
342
+ const { awsProfile, token, buildMode = 'never', profileName = 'default' } = options;
343
+
344
+ if (buildMode === 'always') {
345
+ throw new Error(
346
+ '--build=always is not implemented (requires PLAN-BUILD-MISSING phase 3). ' +
347
+ 'Use --build=never (default) or --build=missing.',
348
+ );
349
+ }
350
+
351
+ const looseCompatAllowed = process.env.WYVRNPM_ALLOW_LOOSE_COMPAT === '1';
182
352
 
183
353
  if (!fs.existsSync(razerDir)) {
184
354
  fs.mkdirSync(razerDir, { recursive: true });
@@ -201,7 +371,7 @@ async function downloadDependencies(deps, packageSources, platform, razerDir, ht
201
371
  // Skip linked packages
202
372
  if (isLink(extractDir)) {
203
373
  const linkTarget = getLinkTarget(extractDir);
204
- console.log(`[wyvrn] Linked: ${name} → ${linkTarget} (skipping download)`);
374
+ log.info(`Linked: ${name} → ${linkTarget} (skipping download)`);
205
375
  lockEntries.set(name, { version, resolvedFrom: 'link' });
206
376
  continue;
207
377
  }
@@ -219,7 +389,7 @@ async function downloadDependencies(deps, packageSources, platform, razerDir, ht
219
389
  const sameProfile = !profileHash || installedMeta?.profileHash === profileHash;
220
390
 
221
391
  if (sameVersion && sameProfile) {
222
- console.log(`[wyvrn] Already present: ${name}@${version}${profileHash ? ` [${installedMeta?.profileHash}]` : ''} — skipping`);
392
+ log.info(`Already present: ${name}@${version}${profileHash ? ` [${installedMeta?.profileHash}]` : ''} — skipping`);
223
393
  lockEntries.set(name, {
224
394
  version,
225
395
  profileHash: installedMeta?.profileHash ?? null,
@@ -230,36 +400,148 @@ async function downloadDependencies(deps, packageSources, platform, razerDir, ht
230
400
  continue;
231
401
  }
232
402
  if (!sameVersion) {
233
- console.log(`[wyvrn] Version changed: ${name} ${installedVersion} → ${version}, re-downloading`);
403
+ log.info(`Version changed: ${name} ${installedVersion} → ${version}, re-downloading`);
234
404
  } else {
235
- console.log(`[wyvrn] Profile changed for ${name}@${version}, re-downloading`);
405
+ log.info(`Profile changed for ${name}@${version}, re-downloading`);
236
406
  }
237
407
  fs.rmSync(extractDir, { recursive: true, force: true });
238
408
  }
239
409
 
240
- console.log(`[wyvrn] Downloading: ${name}@${version}`);
410
+ log.info(`Downloading: ${name}@${version}`);
241
411
  const destZipPath = path.join(razerDir, `${name}.zip`);
242
412
 
243
413
  let downloadResult = null;
244
414
  let resolvedFrom = 'v1';
415
+ let skipV1Fallback = false;
245
416
 
246
- // ── Try v2 first ────────────────────────────────────────────────────────
417
+ // ── Try v2 exact ────────────────────────────────────────────────────────
247
418
  if (profileHash && profile) {
248
- downloadResult = await tryDownloadV2(
249
- { name, version, profileHash, profile },
419
+ const v2 = await tryV2Exact(
420
+ { name, version, profileHash },
250
421
  packageSources,
251
422
  destZipPath,
252
423
  timeoutMs,
253
424
  { awsProfile, token },
254
425
  );
255
- if (downloadResult) resolvedFrom = 'v2';
426
+
427
+ if (v2.found) {
428
+ downloadResult = v2.result;
429
+ resolvedFrom = 'v2';
430
+ } else if (v2.indexFoundInSource) {
431
+ // v2 knows this package but no exact profile match. Do NOT fall back
432
+ // to v1 — that would silently accept a binary with different build
433
+ // settings. Try declarative compatibility first (phase 2), then the
434
+ // deprecated env-var escape hatch, then fail strictly.
435
+ skipV1Fallback = true;
436
+
437
+ // ── Phase 2: declarative per-package compatibility ─────────────────
438
+ const compatResult = await findCompatibleBuild({
439
+ name, version,
440
+ consumerProfile: profile,
441
+ versionsIndex: v2.versionsIndex,
442
+ source: v2.indexFoundInSource,
443
+ authOptions: { awsProfile, token },
444
+ });
445
+ if (compatResult) {
446
+ // Download the chosen compatible build's zip.
447
+ const compatProvider = getProvider(compatResult.source);
448
+ const ok = await compatProvider.v2DownloadZip(
449
+ { source: compatResult.source, name, version, profileHash: compatResult.profileHash, awsProfile, token },
450
+ destZipPath,
451
+ timeoutMs,
452
+ );
453
+ if (ok) {
454
+ log.info(
455
+ `Compatibility match for ${name}@${version}: [${compatResult.profileHash}] — ` +
456
+ `${summarizeReasons(compatResult.reasons)}`,
457
+ );
458
+ downloadResult = {
459
+ contentSha256: compatResult.contentSha256,
460
+ gitSha: compatResult.gitSha,
461
+ gitRepo: compatResult.gitRepo,
462
+ profileHash: compatResult.profileHash,
463
+ source: compatResult.source,
464
+ };
465
+ resolvedFrom = 'v2-compat';
466
+ }
467
+ }
468
+
469
+ // ── Phase 3: source-build when `--build=missing` is active ─────────
470
+ if (!downloadResult && buildMode === 'missing') {
471
+ const sourceInfo = v2.versionsIndex?.versions?.[version]?.source;
472
+ if (sourceInfo?.gitRepo && sourceInfo?.gitSha) {
473
+ try {
474
+ const built = await buildFromSource({
475
+ name, version,
476
+ profile, profileName, profileHash,
477
+ gitRepo: sourceInfo.gitRepo,
478
+ gitSha: sourceInfo.gitSha,
479
+ destZipPath,
480
+ packageSources,
481
+ platform,
482
+ timeoutMs,
483
+ buildMode,
484
+ authOptions: { awsProfile, token },
485
+ });
486
+ downloadResult = {
487
+ contentSha256: built.contentSha256,
488
+ gitSha: built.gitSha,
489
+ gitRepo: built.gitRepo,
490
+ profileHash: built.profileHash,
491
+ source: built.source,
492
+ };
493
+ resolvedFrom = 'v2-source-build';
494
+ } catch (err) {
495
+ log.error(`Source-build failed for ${name}@${version}: ${err.message}`);
496
+ }
497
+ } else {
498
+ log.error(
499
+ `Cannot source-build ${name}@${version}: no git metadata on registry ` +
500
+ `(gitRepo/gitSha). Publisher must re-publish with git info.`,
501
+ );
502
+ }
503
+ }
504
+
505
+ // ── Deprecated escape hatch: WYVRNPM_ALLOW_LOOSE_COMPAT=1 ──────────
506
+ if (!downloadResult && looseCompatAllowed) {
507
+ const compat = await tryV2Compat(
508
+ { name, version, profile },
509
+ v2.versionsIndex,
510
+ v2.indexFoundInSource,
511
+ destZipPath,
512
+ timeoutMs,
513
+ { awsProfile, token },
514
+ );
515
+ if (compat) {
516
+ downloadResult = compat;
517
+ resolvedFrom = 'v2-compat-deprecated';
518
+ }
519
+ }
520
+
521
+ if (!downloadResult) {
522
+ log.error(buildStrictMissMessage({
523
+ name, version, profileHash,
524
+ availableProfiles: v2.availableProfiles,
525
+ buildMode,
526
+ loose: looseCompatAllowed,
527
+ }));
528
+ lockEntries.set(name, {
529
+ version,
530
+ profileHash,
531
+ resolvedFrom: buildMode === 'missing' ? 'source-build-failed' : 'no-exact-match',
532
+ availableProfiles: v2.availableProfiles,
533
+ });
534
+ continue;
535
+ }
536
+ }
537
+ // else: no v2 index anywhere → fall through to v1
256
538
  }
257
539
 
258
540
  // ── Fall back to v1 ─────────────────────────────────────────────────────
259
- if (!downloadResult) {
541
+ if (!downloadResult && !skipV1Fallback) {
260
542
  const ok = await tryDownloadFromSources(name, version, packageSources, platform, httpClient, timeoutMs, destZipPath);
261
543
  if (!ok) {
262
- console.warn(`[wyvrn] Warning: package ${name}@${version} was not found in any configured source`);
544
+ log.warn(`package ${name}@${version} was not found in any configured source`);
263
545
  lockEntries.set(name, { version, resolvedFrom: 'not-found' });
264
546
  continue;
265
547
  }
@@ -269,8 +551,8 @@ async function downloadDependencies(deps, packageSources, platform, razerDir, ht
269
551
  if (downloadResult?.contentSha256) {
270
552
  const actual = sha256Of(destZipPath);
271
553
  if (actual !== downloadResult.contentSha256) {
272
- console.error(
273
- `[wyvrn] Error: SHA256 mismatch for ${name}@${version}\n` +
554
+ log.error(
555
+ `SHA256 mismatch for ${name}@${version}\n` +
274
556
  ` expected: ${downloadResult.contentSha256}\n` +
275
557
  ` actual: ${actual}`,
276
558
  );
@@ -278,7 +560,7 @@ async function downloadDependencies(deps, packageSources, platform, razerDir, ht
278
560
  lockEntries.set(name, { version, resolvedFrom: 'sha256-mismatch' });
279
561
  continue;
280
562
  }
281
- console.log(`[wyvrn] SHA256 verified: ${actual.slice(0, 16)}...`);
563
+ log.info(`SHA256 verified: ${actual.slice(0, 16)}...`);
282
564
  }
283
565
 
284
566
  // ── Extract ─────────────────────────────────────────────────────────────
@@ -301,16 +583,16 @@ async function downloadDependencies(deps, packageSources, platform, razerDir, ht
301
583
  };
302
584
  fs.writeFileSync(metaFile, JSON.stringify(meta, null, 2) + '\n', 'utf8');
303
585
 
304
- console.log(
305
- `[wyvrn] Extracted: ${name}@${version}` +
586
+ log.info(
587
+ `Extracted: ${name}@${version}` +
306
588
  (downloadResult?.profileHash ? ` [${downloadResult.profileHash}]` : '') +
307
589
  ` (${resolvedFrom}) → ${extractDir}`,
308
590
  );
309
591
 
310
592
  if (downloadResult?.gitSha) {
311
- console.log(`[wyvrn] Source commit : ${downloadResult.gitSha}`);
593
+ log.info(` Source commit : ${downloadResult.gitSha}`);
312
594
  if (downloadResult?.gitRepo) {
313
- console.log(`[wyvrn] Source repo : ${downloadResult.gitRepo}`);
595
+ log.info(` Source repo : ${downloadResult.gitRepo}`);
314
596
  }
315
597
  }
316
598
 
@@ -322,7 +604,7 @@ async function downloadDependencies(deps, packageSources, platform, razerDir, ht
322
604
  gitSha: downloadResult?.gitSha ?? null,
323
605
  });
324
606
  } catch (err) {
325
- console.warn(`[wyvrn] Failed to extract ${name}@${version}: ${err.message}`);
607
+ log.warn(`Failed to extract ${name}@${version}: ${err.message}`);
326
608
  lockEntries.set(name, { version, resolvedFrom: 'extract-failed' });
327
609
  } finally {
328
610
  try { fs.unlinkSync(destZipPath); } catch { /* ignore */ }