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.
Files changed (44) hide show
  1. package/README.md +639 -5
  2. package/bin/wyvrn.js +220 -10
  3. package/claude/skills/wyvrnpm.skill +0 -0
  4. package/cmake/cpp.cmake +9 -0
  5. package/cmake/functions.cmake +224 -0
  6. package/cmake/macros.cmake +233 -0
  7. package/cmake/options.cmake +23 -0
  8. package/cmake/variables.cmake +171 -0
  9. package/package.json +3 -1
  10. package/src/build/cache.js +148 -0
  11. package/src/build/clone.js +170 -0
  12. package/src/build/cmake.js +342 -0
  13. package/src/build/index.js +275 -0
  14. package/src/build/msvc-env.js +217 -0
  15. package/src/build/recipe.js +155 -0
  16. package/src/commands/build.js +283 -0
  17. package/src/commands/clean.js +56 -16
  18. package/src/commands/configure.js +6 -5
  19. package/src/commands/init.js +3 -2
  20. package/src/commands/install-skill.js +107 -0
  21. package/src/commands/install.js +262 -19
  22. package/src/commands/link.js +18 -15
  23. package/src/commands/profile.js +15 -12
  24. package/src/commands/publish.js +216 -65
  25. package/src/commands/show.js +237 -0
  26. package/src/compat.js +261 -0
  27. package/src/config.js +3 -1
  28. package/src/download.js +431 -87
  29. package/src/ignore.js +118 -0
  30. package/src/logger.js +94 -0
  31. package/src/manifest.js +12 -7
  32. package/src/options.js +303 -0
  33. package/src/profile.js +56 -4
  34. package/src/providers/base.js +16 -1
  35. package/src/providers/file.js +12 -6
  36. package/src/providers/http.js +15 -10
  37. package/src/providers/s3.js +14 -9
  38. package/src/resolve.js +179 -19
  39. package/src/toolchain/deps.js +164 -0
  40. package/src/toolchain/index.js +141 -0
  41. package/src/toolchain/presets.js +263 -0
  42. package/src/toolchain/template.cmake +66 -0
  43. package/src/upload-built.js +256 -0
  44. package/src/version-range.js +301 -0
package/src/download.js CHANGED
@@ -9,6 +9,10 @@ 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 { uploadSourceBuiltArtefact } = require('./upload-built');
15
+ const log = require('./logger');
12
16
 
13
17
  // ---------------------------------------------------------------------------
14
18
  // v1 (legacy) download helpers
@@ -42,7 +46,7 @@ async function tryDownloadFromSources(name, version, packageSources, platform, h
42
46
  await downloadFile(url, destZipPath, httpClient, timeoutMs);
43
47
  return true;
44
48
  } catch (err) {
45
- console.warn(`[wyvrn] Could not download ${name}@${version} from ${url}: ${err.message}`);
49
+ log.warn(`Could not download ${name}@${version} from ${url}: ${err.message}`);
46
50
  }
47
51
  }
48
52
  }
@@ -52,101 +56,257 @@ async function tryDownloadFromSources(name, version, packageSources, platform, h
52
56
  // ---------------------------------------------------------------------------
53
57
  // v2 download helpers
54
58
  // ---------------------------------------------------------------------------
59
+ //
60
+ // PLAN-BUILD-MISSING phase 1: strict-by-default resolution.
61
+ //
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.
73
+ //
55
74
 
56
75
  /**
57
- * Try to download a v2 prebuilt binary for a package.
76
+ * Attempt an exact v2 profile-hash download across all configured sources.
58
77
  *
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)
78
+ * Semantics:
79
+ * - If ANY source has a byte-exact match, we download from the first one
80
+ * and return `found: true`.
81
+ * - Otherwise we return `found: false`, along with whether any source had
82
+ * a versions.json entry for (name, version). The caller uses that flag
83
+ * to decide whether to fall through to the v1 legacy path (only when
84
+ * the package is unknown in v2 entirely) or to fail strictly (when v2
85
+ * knows the package but not the requested profile).
63
86
  *
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? }
87
+ * @param {{ name: string, version: string, profileHash: string }} dep
88
+ * @param {string[]} packageSources
89
+ * @param {string} destZipPath
90
+ * @param {number} timeoutMs
91
+ * @param {object} authOptions { awsProfile?, token? }
74
92
  * @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
93
+ * found: boolean,
94
+ * result?: { contentSha256: string|null, gitSha: string|null, gitRepo: string|null, profileHash: string, source: string },
95
+ * indexFoundInSource: string|null, // which source returned a versions index (null = none did)
96
+ * availableProfiles: string[], // profile hashes the registry HAS, for the error message
97
+ * versionsIndex?: object, // pre-fetched index, reusable by tryV2Compat
98
+ * }>}
82
99
  */
83
- async function tryDownloadV2(dep, packageSources, destZipPath, timeoutMs, authOptions = {}) {
84
- const { name, version, profileHash, profile } = dep;
100
+ async function tryV2Exact(dep, packageSources, destZipPath, timeoutMs, authOptions = {}) {
101
+ const { name, version, profileHash } = dep;
85
102
  const { awsProfile, token } = authOptions;
86
103
 
104
+ let indexFoundInSource = null;
105
+ let versionsIndex = null;
106
+ let availableProfiles = [];
107
+
87
108
  for (const source of packageSources) {
88
109
  let provider;
89
110
  try { provider = getProvider(source); } catch { continue; }
90
111
 
91
- // 1) Exact profile hash
92
112
  const base = source.replace(/\/$/, '');
93
113
  const metaUrl = `${base}/v2/${name}/${version}/${profileHash}/wyvrn.json`;
94
- console.log(`[wyvrn] Trying v2: ${metaUrl}`);
114
+ log.info(`Trying v2: ${metaUrl}`);
95
115
  const exactMeta = await provider.v2GetMeta({ source, name, version, profileHash, awsProfile, token });
96
116
  if (exactMeta) {
97
117
  const ok = await provider.v2DownloadZip({ source, name, version, profileHash, awsProfile, token }, destZipPath, timeoutMs);
98
118
  if (ok) {
99
119
  return {
100
- found: true,
101
- contentSha256: exactMeta.contentSha256 ?? null,
102
- gitSha: exactMeta.gitSha ?? null,
103
- gitRepo: exactMeta.gitRepo ?? null,
104
- profileHash,
105
- source,
120
+ found: true,
121
+ result: {
122
+ contentSha256: exactMeta.contentSha256 ?? null,
123
+ gitSha: exactMeta.gitSha ?? null,
124
+ gitRepo: exactMeta.gitRepo ?? null,
125
+ profileHash,
126
+ source,
127
+ },
128
+ indexFoundInSource: source,
129
+ availableProfiles: [],
106
130
  };
107
131
  }
108
132
  } else {
109
- console.log(`[wyvrn] v2 miss (no exact profile match): ${metaUrl}`);
133
+ log.info(`v2 miss (no exact profile match): ${metaUrl}`);
110
134
  }
111
135
 
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;
136
+ // No exact match from this source. Check whether v2 knows the package
137
+ // at all — keep the first non-null index we find (for error messages
138
+ // and potential compat fallback).
139
+ if (!versionsIndex) {
140
+ const idx = await provider.v2GetVersionsIndex({ source, name, awsProfile, token });
141
+ if (idx?.versions?.[version]?.profiles) {
142
+ versionsIndex = idx;
143
+ indexFoundInSource = source;
144
+ availableProfiles = Object.keys(idx.versions[version].profiles);
145
+ }
119
146
  }
147
+ }
120
148
 
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
- }
149
+ return { found: false, indexFoundInSource, availableProfiles, versionsIndex };
150
+ }
151
+
152
+ /**
153
+ * Declarative per-package compatibility resolver (PLAN-BUILD-MISSING phase 2).
154
+ *
155
+ * When no exact profileHash match exists but the v2 registry knows the
156
+ * package, we evaluate each registry-side build's `compatibility` block
157
+ * (from the build's `wyvrn.json`) against the consumer's profile. If any
158
+ * candidates are compatible, the tiebreaker picks one.
159
+ *
160
+ * Cost: O(n) HTTP round-trips (one `v2GetMeta` per build) — acceptable at
161
+ * phase 2; phase 2.1 could fold the compat block into `versions.json` to
162
+ * make this O(1).
163
+ *
164
+ * @param {object} args
165
+ * @param {string} args.name
166
+ * @param {string} args.version
167
+ * @param {import('./profile').WyvrnProfile} args.consumerProfile
168
+ * @param {object} args.versionsIndex pre-fetched from tryV2Exact
169
+ * @param {string} args.source source URL that returned the index
170
+ * @param {object} args.authOptions
171
+ * @returns {Promise<{
172
+ * profileHash: string,
173
+ * contentSha256: string|null,
174
+ * gitSha: string|null,
175
+ * gitRepo: string|null,
176
+ * source: string,
177
+ * reasons: Array<object>,
178
+ * meta: object,
179
+ * }|null>}
180
+ */
181
+ async function findCompatibleBuild({ name, version, consumerProfile, consumerOptions, versionsIndex, source, authOptions = {} }) {
182
+ const { awsProfile, token } = authOptions;
183
+ let provider;
184
+ try { provider = getProvider(source); } catch { return null; }
185
+
186
+ const versionEntry = versionsIndex?.versions?.[version];
187
+ if (!versionEntry?.profiles) return null;
188
+
189
+ // Consumer side includes options so the compat evaluator can see them.
190
+ const consumerFull = consumerOptions
191
+ ? { ...consumerProfile, options: consumerOptions }
192
+ : consumerProfile;
193
+
194
+ const candidates = [];
195
+ for (const [profileHash, buildEntry] of Object.entries(versionEntry.profiles)) {
196
+ const packageProfile = buildEntry.buildSettings ?? buildEntry.profile;
197
+ if (!packageProfile) continue;
198
+
199
+ // Compatibility block lives on the per-build wyvrn.json. Fetch it.
200
+ const meta = await provider.v2GetMeta({ source, name, version, profileHash, awsProfile, token });
201
+ if (!meta) continue;
202
+
203
+ // Package-side options may live on the nested buildSettings.options (new
204
+ // publish path) or as a sibling key when publish-time metadata diverges.
205
+ const packageFull = packageProfile.options
206
+ ? packageProfile
207
+ : (meta.buildSettings?.options
208
+ ? { ...packageProfile, options: meta.buildSettings.options }
209
+ : packageProfile);
210
+
211
+ const rawCompat = meta.compatibility ?? null;
212
+ const { compatible, reasons } = evaluateCompat(consumerFull, packageFull, rawCompat);
213
+ if (compatible) {
214
+ candidates.push({ profileHash, packageProfile, reasons, buildEntry, meta });
144
215
  }
145
216
  }
146
217
 
218
+ const best = pickBestCandidate(candidates, consumerProfile);
219
+ if (!best) return null;
220
+
221
+ return {
222
+ profileHash: best.profileHash,
223
+ contentSha256: best.buildEntry.contentSha256 ?? best.meta.contentSha256 ?? null,
224
+ gitSha: versionEntry.source?.gitSha ?? best.meta.gitSha ?? null,
225
+ gitRepo: versionEntry.source?.gitRepo ?? best.meta.gitRepo ?? null,
226
+ source,
227
+ reasons: best.reasons,
228
+ meta: best.meta,
229
+ };
230
+ }
231
+
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
+ }
147
278
  return null;
148
279
  }
149
280
 
281
+ /**
282
+ * Build the structured error message shown when a package exists in the v2
283
+ * registry but no build matches the consumer's profileHash.
284
+ */
285
+ function buildStrictMissMessage({ name, version, profileHash, availableProfiles, buildMode, loose }) {
286
+ const lines = [
287
+ `no v2 build of ${name}@${version} matches profile [${profileHash}]`,
288
+ availableProfiles.length > 0
289
+ ? ` Available builds on the registry: ${availableProfiles.map((p) => `[${p}]`).join(' ')}`
290
+ : ` The registry lists no builds for this version.`,
291
+ ];
292
+ if (buildMode === 'missing') {
293
+ lines.push(
294
+ ` --build=missing source-build was attempted but did not produce a usable binary. ` +
295
+ `See the error above for details (missing git metadata, missing "build" section in source manifest, or compile failure).`,
296
+ );
297
+ } else {
298
+ lines.push(
299
+ ` Fix: publish a build for your profile, switch to a compatible one, or rerun with --build=missing to source-build.`,
300
+ );
301
+ }
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
+ return lines.join('\n');
308
+ }
309
+
150
310
  // ---------------------------------------------------------------------------
151
311
  // Extraction helper
152
312
  // ---------------------------------------------------------------------------
@@ -163,7 +323,21 @@ async function extractZip(destZipPath, extractDir) {
163
323
 
164
324
  /**
165
325
  * Downloads and extracts all resolved dependencies.
166
- * Tries v2 first (profile-aware + SHA256 verification), falls back to v1.
326
+ *
327
+ * Resolution order (v2 path, i.e. enriched deps with a profileHash):
328
+ * 1. Exact profile-hash match from any configured v2 source → use it.
329
+ * 2. Otherwise, if the package exists in v2 (versions.json returned an
330
+ * 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.
337
+ * 3. Otherwise (v2 doesn't know the package at all) → v1 legacy fallback.
338
+ *
339
+ * Legacy deps (plain `Map<name, version>`, no profile info) skip v2 entirely
340
+ * and use the v1 path, same as before.
167
341
  *
168
342
  * @param {Map<string, string | {version:string, profileHash:string|null, profile:import('./profile').WyvrnProfile|null}>} deps
169
343
  * Either a plain `Map<name, version>` (legacy callers / tests) or the enriched map
@@ -173,12 +347,29 @@ async function extractZip(destZipPath, extractDir) {
173
347
  * @param {string} razerDir
174
348
  * @param {Function} httpClient - fetch-compatible
175
349
  * @param {number} timeout - seconds
176
- * @param {object} [authOptions] - { awsProfile?, token? }
350
+ * @param {object} [options] - { awsProfile?, token?, buildMode? }
351
+ * buildMode: 'never' (default) | 'missing' | 'always'
177
352
  * @returns {Promise<Map<string, {version:string, profileHash?:string, contentSha256?:string, gitSha?:string, resolvedFrom:string}>>}
178
353
  */
179
- async function downloadDependencies(deps, packageSources, platform, razerDir, httpClient, timeout, authOptions = {}) {
354
+ async function downloadDependencies(deps, packageSources, platform, razerDir, httpClient, timeout, options = {}) {
180
355
  const timeoutMs = timeout * 1000;
181
- const { awsProfile, token } = authOptions;
356
+ const {
357
+ awsProfile, token, buildMode = 'never', profileName = 'default',
358
+ uploadBuilt = false, uploadSource, uploadAuth,
359
+ // Optional accumulator. When provided, upload outcomes are pushed here
360
+ // so the caller can print a single aggregated summary at the end of an
361
+ // install (see src/commands/install.js). Mutated in place.
362
+ uploadStats,
363
+ } = options;
364
+
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';
182
373
 
183
374
  if (!fs.existsSync(razerDir)) {
184
375
  fs.mkdirSync(razerDir, { recursive: true });
@@ -193,6 +384,7 @@ async function downloadDependencies(deps, packageSources, platform, razerDir, ht
193
384
  const version = isLegacyEntry ? depInfoOrVersion : depInfoOrVersion.version;
194
385
  const profileHash = isLegacyEntry ? null : (depInfoOrVersion.profileHash ?? null);
195
386
  const profile = isLegacyEntry ? null : (depInfoOrVersion.profile ?? null);
387
+ const options = isLegacyEntry ? null : (depInfoOrVersion.options ?? null);
196
388
 
197
389
  const extractDir = path.join(razerDir, name);
198
390
  const versionFile = path.join(extractDir, '.wyvrn-version');
@@ -201,7 +393,7 @@ async function downloadDependencies(deps, packageSources, platform, razerDir, ht
201
393
  // Skip linked packages
202
394
  if (isLink(extractDir)) {
203
395
  const linkTarget = getLinkTarget(extractDir);
204
- console.log(`[wyvrn] Linked: ${name} → ${linkTarget} (skipping download)`);
396
+ log.info(`Linked: ${name} → ${linkTarget} (skipping download)`);
205
397
  lockEntries.set(name, { version, resolvedFrom: 'link' });
206
398
  continue;
207
399
  }
@@ -219,47 +411,166 @@ async function downloadDependencies(deps, packageSources, platform, razerDir, ht
219
411
  const sameProfile = !profileHash || installedMeta?.profileHash === profileHash;
220
412
 
221
413
  if (sameVersion && sameProfile) {
222
- console.log(`[wyvrn] Already present: ${name}@${version}${profileHash ? ` [${installedMeta?.profileHash}]` : ''} — skipping`);
414
+ log.info(`Already present: ${name}@${version}${profileHash ? ` [${installedMeta?.profileHash}]` : ''} — skipping`);
223
415
  lockEntries.set(name, {
224
416
  version,
225
417
  profileHash: installedMeta?.profileHash ?? null,
226
418
  contentSha256: installedMeta?.contentSha256 ?? null,
227
419
  gitSha: installedMeta?.gitSha ?? null,
228
420
  resolvedFrom: installedMeta?.resolvedFrom ?? 'cached',
421
+ ...(options ? { options } : {}),
229
422
  });
230
423
  continue;
231
424
  }
232
425
  if (!sameVersion) {
233
- console.log(`[wyvrn] Version changed: ${name} ${installedVersion} → ${version}, re-downloading`);
426
+ log.info(`Version changed: ${name} ${installedVersion} → ${version}, re-downloading`);
234
427
  } else {
235
- console.log(`[wyvrn] Profile changed for ${name}@${version}, re-downloading`);
428
+ log.info(`Profile changed for ${name}@${version}, re-downloading`);
236
429
  }
237
430
  fs.rmSync(extractDir, { recursive: true, force: true });
238
431
  }
239
432
 
240
- console.log(`[wyvrn] Downloading: ${name}@${version}`);
433
+ log.info(`Downloading: ${name}@${version}`);
241
434
  const destZipPath = path.join(razerDir, `${name}.zip`);
242
435
 
243
436
  let downloadResult = null;
244
437
  let resolvedFrom = 'v1';
438
+ let skipV1Fallback = false;
245
439
 
246
- // ── Try v2 first ────────────────────────────────────────────────────────
440
+ // ── Try v2 exact ────────────────────────────────────────────────────────
247
441
  if (profileHash && profile) {
248
- downloadResult = await tryDownloadV2(
249
- { name, version, profileHash, profile },
442
+ const v2 = await tryV2Exact(
443
+ { name, version, profileHash },
250
444
  packageSources,
251
445
  destZipPath,
252
446
  timeoutMs,
253
447
  { awsProfile, token },
254
448
  );
255
- if (downloadResult) resolvedFrom = 'v2';
449
+
450
+ if (v2.found) {
451
+ downloadResult = v2.result;
452
+ resolvedFrom = 'v2';
453
+ } 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.
458
+ skipV1Fallback = true;
459
+
460
+ // ── Phase 2: declarative per-package compatibility ─────────────────
461
+ const compatResult = await findCompatibleBuild({
462
+ name, version,
463
+ consumerProfile: profile,
464
+ consumerOptions: options,
465
+ versionsIndex: v2.versionsIndex,
466
+ source: v2.indexFoundInSource,
467
+ authOptions: { awsProfile, token },
468
+ });
469
+ if (compatResult) {
470
+ // Download the chosen compatible build's zip.
471
+ const compatProvider = getProvider(compatResult.source);
472
+ const ok = await compatProvider.v2DownloadZip(
473
+ { source: compatResult.source, name, version, profileHash: compatResult.profileHash, awsProfile, token },
474
+ destZipPath,
475
+ timeoutMs,
476
+ );
477
+ if (ok) {
478
+ log.info(
479
+ `Compatibility match for ${name}@${version}: [${compatResult.profileHash}] — ` +
480
+ `${summarizeReasons(compatResult.reasons)}`,
481
+ );
482
+ downloadResult = {
483
+ contentSha256: compatResult.contentSha256,
484
+ gitSha: compatResult.gitSha,
485
+ gitRepo: compatResult.gitRepo,
486
+ profileHash: compatResult.profileHash,
487
+ source: compatResult.source,
488
+ };
489
+ resolvedFrom = 'v2-compat';
490
+ }
491
+ }
492
+
493
+ // ── Phase 3: source-build when `--build=missing` is active ─────────
494
+ if (!downloadResult && buildMode === 'missing') {
495
+ const sourceInfo = v2.versionsIndex?.versions?.[version]?.source;
496
+ if (sourceInfo?.gitRepo && sourceInfo?.gitSha) {
497
+ try {
498
+ const built = await buildFromSource({
499
+ name, version,
500
+ profile, profileName, profileHash,
501
+ options,
502
+ gitRepo: sourceInfo.gitRepo,
503
+ gitSha: sourceInfo.gitSha,
504
+ destZipPath,
505
+ packageSources,
506
+ platform,
507
+ timeoutMs,
508
+ buildMode,
509
+ authOptions: { awsProfile, token },
510
+ });
511
+ downloadResult = {
512
+ contentSha256: built.contentSha256,
513
+ gitSha: built.gitSha,
514
+ gitRepo: built.gitRepo,
515
+ profileHash: built.profileHash,
516
+ source: built.source,
517
+ // Carried through so the post-extraction upload step can
518
+ // read the manifest and push the zip without re-doing work.
519
+ artefactPath: built.artefactPath,
520
+ clonedManifestPath: built.clonedManifestPath,
521
+ };
522
+ resolvedFrom = 'v2-source-build';
523
+ } catch (err) {
524
+ log.error(`Source-build failed for ${name}@${version}: ${err.message}`);
525
+ }
526
+ } else {
527
+ log.error(
528
+ `Cannot source-build ${name}@${version}: no git metadata on registry ` +
529
+ `(gitRepo/gitSha). Publisher must re-publish with git info.`,
530
+ );
531
+ }
532
+ }
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
+ if (!downloadResult) {
551
+ log.error(buildStrictMissMessage({
552
+ name, version, profileHash,
553
+ availableProfiles: v2.availableProfiles,
554
+ buildMode,
555
+ loose: looseCompatAllowed,
556
+ }));
557
+ lockEntries.set(name, {
558
+ version,
559
+ profileHash,
560
+ resolvedFrom: buildMode === 'missing' ? 'source-build-failed' : 'no-exact-match',
561
+ availableProfiles: v2.availableProfiles,
562
+ });
563
+ continue;
564
+ }
565
+ }
566
+ // else: no v2 index anywhere → fall through to v1
256
567
  }
257
568
 
258
569
  // ── Fall back to v1 ─────────────────────────────────────────────────────
259
- if (!downloadResult) {
570
+ if (!downloadResult && !skipV1Fallback) {
260
571
  const ok = await tryDownloadFromSources(name, version, packageSources, platform, httpClient, timeoutMs, destZipPath);
261
572
  if (!ok) {
262
- console.warn(`[wyvrn] Warning: package ${name}@${version} was not found in any configured source`);
573
+ log.warn(`package ${name}@${version} was not found in any configured source`);
263
574
  lockEntries.set(name, { version, resolvedFrom: 'not-found' });
264
575
  continue;
265
576
  }
@@ -269,8 +580,8 @@ async function downloadDependencies(deps, packageSources, platform, razerDir, ht
269
580
  if (downloadResult?.contentSha256) {
270
581
  const actual = sha256Of(destZipPath);
271
582
  if (actual !== downloadResult.contentSha256) {
272
- console.error(
273
- `[wyvrn] Error: SHA256 mismatch for ${name}@${version}\n` +
583
+ log.error(
584
+ `SHA256 mismatch for ${name}@${version}\n` +
274
585
  ` expected: ${downloadResult.contentSha256}\n` +
275
586
  ` actual: ${actual}`,
276
587
  );
@@ -278,7 +589,7 @@ async function downloadDependencies(deps, packageSources, platform, razerDir, ht
278
589
  lockEntries.set(name, { version, resolvedFrom: 'sha256-mismatch' });
279
590
  continue;
280
591
  }
281
- console.log(`[wyvrn] SHA256 verified: ${actual.slice(0, 16)}...`);
592
+ log.info(`SHA256 verified: ${actual.slice(0, 16)}...`);
282
593
  }
283
594
 
284
595
  // ── Extract ─────────────────────────────────────────────────────────────
@@ -301,16 +612,16 @@ async function downloadDependencies(deps, packageSources, platform, razerDir, ht
301
612
  };
302
613
  fs.writeFileSync(metaFile, JSON.stringify(meta, null, 2) + '\n', 'utf8');
303
614
 
304
- console.log(
305
- `[wyvrn] Extracted: ${name}@${version}` +
615
+ log.info(
616
+ `Extracted: ${name}@${version}` +
306
617
  (downloadResult?.profileHash ? ` [${downloadResult.profileHash}]` : '') +
307
618
  ` (${resolvedFrom}) → ${extractDir}`,
308
619
  );
309
620
 
310
621
  if (downloadResult?.gitSha) {
311
- console.log(`[wyvrn] Source commit : ${downloadResult.gitSha}`);
622
+ log.info(` Source commit : ${downloadResult.gitSha}`);
312
623
  if (downloadResult?.gitRepo) {
313
- console.log(`[wyvrn] Source repo : ${downloadResult.gitRepo}`);
624
+ log.info(` Source repo : ${downloadResult.gitRepo}`);
314
625
  }
315
626
  }
316
627
 
@@ -320,9 +631,42 @@ async function downloadDependencies(deps, packageSources, platform, razerDir, ht
320
631
  profileHash: downloadResult?.profileHash ?? null,
321
632
  contentSha256: downloadResult?.contentSha256 ?? null,
322
633
  gitSha: downloadResult?.gitSha ?? null,
634
+ ...(options ? { options } : {}),
323
635
  });
636
+
637
+ // ── Upload freshly source-built artefact back to the registry ────────
638
+ // Opt-in via --upload-built. Runs after extraction succeeds so a
639
+ // bad artefact is never uploaded. Upload failures log but never
640
+ // break the install — the consumer already has a working install.
641
+ if (uploadBuilt && resolvedFrom === 'v2-source-build' && downloadResult?.artefactPath) {
642
+ try {
643
+ const outcome = await uploadSourceBuiltArtefact({
644
+ name, version,
645
+ profile, profileHash,
646
+ artefactPath: downloadResult.artefactPath,
647
+ clonedManifestPath: downloadResult.clonedManifestPath,
648
+ contentSha256: downloadResult.contentSha256,
649
+ gitRepo: downloadResult.gitRepo,
650
+ gitSha: downloadResult.gitSha,
651
+ options,
652
+ uploadSource,
653
+ uploadAuth: uploadAuth ?? { awsProfile, token },
654
+ });
655
+ if (uploadStats) {
656
+ const bucket = outcome.uploaded
657
+ ? 'uploaded'
658
+ : /already exists/.test(outcome.reason) ? 'skipped' : 'failed';
659
+ uploadStats[bucket].push({ name, version, profileHash, reason: outcome.reason });
660
+ }
661
+ } catch (err) {
662
+ log.warn(`upload-built: ${name}@${version} upload skipped — ${err.message}`);
663
+ if (uploadStats) {
664
+ uploadStats.failed.push({ name, version, profileHash, reason: err.message });
665
+ }
666
+ }
667
+ }
324
668
  } catch (err) {
325
- console.warn(`[wyvrn] Failed to extract ${name}@${version}: ${err.message}`);
669
+ log.warn(`Failed to extract ${name}@${version}: ${err.message}`);
326
670
  lockEntries.set(name, { version, resolvedFrom: 'extract-failed' });
327
671
  } finally {
328
672
  try { fs.unlinkSync(destZipPath); } catch { /* ignore */ }