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.
@@ -0,0 +1,53 @@
1
+ 'use strict';
2
+
3
+ // Shared `fetch` wrapper used by every resolver / downloader call site.
4
+ //
5
+ // 2.8.3 shipped with a split between the HTTP provider (which uses
6
+ // node:https directly and sends `User-Agent: wyvrnpm/<ver>` + no
7
+ // `Accept-Encoding`) and the resolver (which used the bare global
8
+ // `fetch` — undici — defaulting to `Accept-Encoding: gzip, deflate,
9
+ // br`). CloudFront caches a **separate entry per response encoding**,
10
+ // so a `versions.json` freshly updated at origin could be stale in the
11
+ // Brotli cache bucket for up to the full TTL while the identity bucket
12
+ // served fresh bytes. The provider calls (show) happened to land in
13
+ // the identity bucket (fresh); the resolver calls (install, add)
14
+ // landed in the Brotli bucket (stale).
15
+ //
16
+ // Mitigation: force `Accept-Encoding: identity`. Manifest JSONs are
17
+ // small (KB to tens of KB) so the lost compression is negligible, and
18
+ // one encoding means one cache bucket — no split-brain. User-Agent +
19
+ // `Accept: application/json` are included for good measure (CloudFront
20
+ // + some WAFs 403 UA-less requests).
21
+ //
22
+ // Defence-in-depth: src/providers/s3.js caps every manifest-JSON PUT
23
+ // at `CacheControl: public, max-age=300` (2.8.4), so the worst-case
24
+ // staleness for any future publish is 5 min regardless of which
25
+ // cache bucket a client lands in.
26
+ //
27
+ // See claude/PLAN-RESOLVER-HTTP-UNIFY.md for the follow-up plan to
28
+ // route all resolver HTTP through the provider API and retire this
29
+ // wrapper.
30
+
31
+ const USER_AGENT = `wyvrnpm/${require('../package.json').version}`;
32
+
33
+ /**
34
+ * Drop-in replacement for the global `fetch`. Injects wyvrnpm's
35
+ * `User-Agent`, an `Accept: application/json` hint, and forces
36
+ * `Accept-Encoding: identity` to avoid CloudFront's per-encoding
37
+ * cache-key split. Caller-supplied headers win on collision.
38
+ *
39
+ * @param {string | URL} url
40
+ * @param {RequestInit} [init]
41
+ * @returns {Promise<Response>}
42
+ */
43
+ function wyvrnFetch(url, init = {}) {
44
+ const headers = {
45
+ 'User-Agent': USER_AGENT,
46
+ Accept: 'application/json',
47
+ 'Accept-Encoding': 'identity',
48
+ ...(init.headers || {}),
49
+ };
50
+ return fetch(url, { ...init, headers });
51
+ }
52
+
53
+ module.exports = { wyvrnFetch, USER_AGENT };
package/src/logger.js CHANGED
@@ -11,6 +11,32 @@
11
11
 
12
12
  const PREFIX = '[wyvrn]';
13
13
 
14
+ // ── Sensitive-value redaction (EVALUATION.md S8) ──────────────────────────────
15
+ // Defense-in-depth against a token leaking into logs or error traces. Nothing
16
+ // in wyvrnpm deliberately logs tokens today, but callers sometimes paste full
17
+ // commands + error output into bug reports, and some future error path might
18
+ // stringify argv. Two narrow patterns cover every format we handle:
19
+ //
20
+ // `--token <value>` — CLI leak
21
+ // `Authorization: Bearer <value>` — HTTP header leak
22
+ // `Bearer <value>` (standalone) — header fragment leak
23
+ //
24
+ // Redaction is a last-line mitigation — the primary fix is `--token-env` and
25
+ // per-source `tokenEnv` so the literal value never lands in argv to begin with.
26
+ const TOKEN_PATTERNS = [
27
+ [/(--token(?:-env)?[= ]+)\S+/g, '$1***'],
28
+ [/(Authorization\s*:\s*Bearer\s+)\S+/gi, '$1***'],
29
+ [/(\bBearer\s+)[A-Za-z0-9._\-+/=]{16,}/g, '$1***'],
30
+ ];
31
+
32
+ function redactSensitive(text) {
33
+ let out = String(text);
34
+ for (const [pattern, replacement] of TOKEN_PATTERNS) {
35
+ out = out.replace(pattern, replacement);
36
+ }
37
+ return out;
38
+ }
39
+
14
40
  const ANSI = {
15
41
  reset: '\x1b[0m',
16
42
  bold: '\x1b[1m',
@@ -49,7 +75,9 @@ function paint(text, code, stream) {
49
75
  function emit(stream, consoleFn, coloredPrefix, message) {
50
76
  // Every line in a multi-line message gets the prefix — so multi-line errors
51
77
  // line up visually without the caller having to repeat `[wyvrn]` per line.
52
- const out = String(message)
78
+ // Redaction runs before the prefix/colour is applied so patterns like
79
+ // `--token xyz` match regardless of how the caller formatted the line.
80
+ const out = redactSensitive(message)
53
81
  .split('\n')
54
82
  .map((line) => `${coloredPrefix} ${line}`)
55
83
  .join('\n');
@@ -91,4 +119,4 @@ function debug(message) {
91
119
  emit(process.stderr, console.error, `${p} ${tag}`, message);
92
120
  }
93
121
 
94
- module.exports = { info, warn, error, success, debug, setJsonMode, isJsonMode };
122
+ module.exports = { info, warn, error, success, debug, setJsonMode, isJsonMode, redactSensitive };
package/src/manifest.js CHANGED
@@ -3,6 +3,8 @@
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
 
6
+ const { readRecipeConf } = require('./conf');
7
+
6
8
  /** Current manifest schema version emitted by v2 tooling. */
7
9
  const SCHEMA_VERSION = 2;
8
10
 
@@ -52,18 +54,21 @@ function defaultManifest(name) {
52
54
 
53
55
  /**
54
56
  * Normalise the raw `dependencies` field from a manifest into a consistent
55
- * `{ name → { version, settings, options } }` map, regardless of input format:
57
+ * `{ name → { version, settings, options, source? } }` map, regardless of
58
+ * input format:
56
59
  *
57
- * • String value : "1.0.0" → { version: "1.0.0", settings: {}, options: {} }
58
- * • Object value : { version, settings?, options? } → kept (missing keys default to {})
59
- * • Array format : [{ Name, Version }] → converted (no settings/options)
60
+ * • String value : "1.0.0" → { version: "1.0.0", settings: {}, options: {} }
61
+ * • Object value : { version, settings?, options?, source? } → kept (missing keys default to {})
62
+ * • Array format : [{ Name, Version }] → converted (no settings/options)
60
63
  *
61
64
  * `settings` merges on top of the active profile for this dep only.
62
65
  * `options` maps to the recipe's declared options; validated against
63
66
  * the recipe's `allowed` list at resolve time (see src/options.js).
67
+ * `source` (EVALUATION.md S4) pins the dep to a named install source
68
+ * (e.g. `"primary-s3"`); absent = fan out across all configured sources.
64
69
  *
65
70
  * @param {object|Array|undefined} rawDeps
66
- * @returns {Record<string, { version: string, settings: object, options: object }>}
71
+ * @returns {Record<string, { version: string, settings: object, options: object, source?: string }>}
67
72
  */
68
73
  function normalizeDependencies(rawDeps) {
69
74
  if (!rawDeps || typeof rawDeps !== 'object') return {};
@@ -83,11 +88,17 @@ function normalizeDependencies(rawDeps) {
83
88
  if (typeof value === 'string') {
84
89
  result[n] = { version: value, settings: {}, options: {} };
85
90
  } else if (value && typeof value === 'object') {
86
- result[n] = {
91
+ const entry = {
87
92
  version: value.version ?? value.Version ?? '',
88
93
  settings: value.settings ?? {},
89
94
  options: value.options ?? {},
90
95
  };
96
+ // Per-package source pin (S4). Only keep it if it's a non-empty
97
+ // string so {} / null / 0 don't silently pin to a nonsense value.
98
+ if (typeof value.source === 'string' && value.source.length > 0) {
99
+ entry.source = value.source;
100
+ }
101
+ result[n] = entry;
91
102
  }
92
103
  }
93
104
  return result;
@@ -106,11 +117,27 @@ function readManifest(manifestPath) {
106
117
  throw new Error(`Manifest not found: ${resolved}`);
107
118
  }
108
119
  const raw = fs.readFileSync(resolved, 'utf8');
120
+ let parsed;
109
121
  try {
110
- return JSON.parse(raw);
122
+ parsed = JSON.parse(raw);
111
123
  } catch (err) {
112
124
  throw new Error(`Failed to parse manifest at ${resolved}: ${err.message}`);
113
125
  }
126
+
127
+ // Validate the `conf` block (PLAN-CONF.md phase 1.2). Only runs for
128
+ // local manifests — registry-side manifests fetched via resolve.js /
129
+ // download.js go through fetch().json() and deliberately skip this so
130
+ // a consumer with an older wyvrnpm doesn't fail on a publisher's
131
+ // forward-compatible conf keys.
132
+ if (parsed && typeof parsed === 'object' && parsed.conf !== undefined) {
133
+ try {
134
+ readRecipeConf(parsed);
135
+ } catch (err) {
136
+ throw new Error(`Invalid \`conf\` block in ${resolved}: ${err.message}`);
137
+ }
138
+ }
139
+
140
+ return parsed;
114
141
  }
115
142
 
116
143
  /**
package/src/profile.js CHANGED
@@ -162,6 +162,13 @@ function detectProfile() {
162
162
  };
163
163
  }
164
164
 
165
+ // Profile fields that participate in profileHash — mirror Conan's
166
+ // `[settings]` strictly. Anything else at the top level (e.g. `conf`)
167
+ // is non-ABI and must never fold into the hash (PLAN-CONF.md constitution).
168
+ const HASHED_PROFILE_FIELDS = new Set([
169
+ 'os', 'arch', 'compiler', 'compiler.version', 'compiler.cppstd', 'compiler.runtime',
170
+ ]);
171
+
165
172
  /**
166
173
  * Compute a stable 16-char SHA256 prefix identifying a unique build.
167
174
  * Inputs:
@@ -169,14 +176,19 @@ function detectProfile() {
169
176
  * - `options`: (optional) recipe-declared feature toggles resolved to
170
177
  * their effective values for this build.
171
178
  *
172
- * Backward-compat contract: when `options` is null / undefined / empty,
173
- * the hash is byte-identical to what pre-F1 wyvrnpm produced. Packages
174
- * without an `options` block keep their existing registry addresses.
175
- * The caller therefore passes `null` (not `{}`) to mean "this recipe
176
- * has no options declared" consistent with what
177
- * `resolveEffectiveOptions` returns in that case.
179
+ * Backward-compat contract:
180
+ * - When `options` is null / undefined / empty, the hash is
181
+ * byte-identical to what pre-F1 wyvrnpm produced. Packages
182
+ * without an `options` block keep their existing registry
183
+ * addresses. The caller passes `null` (not `{}`) to mean "this
184
+ * recipe has no options declared" — consistent with what
185
+ * `resolveEffectiveOptions` returns in that case.
186
+ * - When a profile carries a `conf` block (phase 2 — PLAN-CONF.md),
187
+ * the hash is byte-identical to the same profile WITHOUT `conf`.
188
+ * Build-time knobs are non-ABI by design (constitution).
178
189
  *
179
- * See claude/PLAN-OPTIONS.md §3.2 for the full design.
190
+ * See claude/PLAN-OPTIONS.md §3.2 and claude/PLAN-CONF.md for the
191
+ * full design.
180
192
  *
181
193
  * @param {WyvrnProfile} profile
182
194
  * @param {Record<string, any>|null} [options] effective options (after defaults + overrides)
@@ -185,7 +197,10 @@ function detectProfile() {
185
197
  function hashProfile(profile, options = null) {
186
198
  const significant = Object.fromEntries(
187
199
  Object.entries(profile)
188
- .filter(([, v]) => v !== null && v !== undefined && v !== '*' && v !== '')
200
+ .filter(([k, v]) =>
201
+ HASHED_PROFILE_FIELDS.has(k) &&
202
+ v !== null && v !== undefined && v !== '*' && v !== '',
203
+ )
189
204
  .sort(([a], [b]) => a.localeCompare(b)),
190
205
  );
191
206
 
@@ -267,6 +282,8 @@ function isShaOnRemote(sha, cwd) {
267
282
 
268
283
  /**
269
284
  * Format a profile for human-readable display (Conan-style).
285
+ * Emits a `[conf]` block with flat dotted keys when the profile
286
+ * carries a non-empty `conf` block (PLAN-CONF.md phase 2).
270
287
  *
271
288
  * @param {WyvrnProfile} profile
272
289
  * @returns {string}
@@ -282,6 +299,23 @@ function formatProfile(profile) {
282
299
  lines.push(`${key}=${val}`);
283
300
  }
284
301
  }
302
+
303
+ // [conf] block, Conan-style. Rendered flat so it's clear these are
304
+ // dotted namespaced keys, not nested JSON. Alphabetical for stable diffs.
305
+ const confBlock = profile.conf;
306
+ if (confBlock && typeof confBlock === 'object' && !Array.isArray(confBlock)) {
307
+ // Lazy-require to avoid circular dep (src/conf/* → src/manifest → src/profile).
308
+ const { flattenConf } = require('./conf');
309
+ let flat;
310
+ try { flat = flattenConf(confBlock); } catch { flat = null; }
311
+ if (flat && Object.keys(flat).length > 0) {
312
+ lines.push('[conf]');
313
+ for (const key of Object.keys(flat).sort()) {
314
+ lines.push(`${key}=${flat[key]}`);
315
+ }
316
+ }
317
+ }
318
+
285
319
  return lines.join('\n');
286
320
  }
287
321
 
@@ -386,15 +420,116 @@ function listProfiles() {
386
420
  }
387
421
 
388
422
  /**
389
- * Load the active build profile by name. Falls back to auto-detection if the
390
- * named profile file does not exist yet.
423
+ * Resolve a profile-inheritance chain (F8). A profile may declare
424
+ *
425
+ * { "extends": "base-profile", ... }
426
+ * { "extends": ["base1", "base2"], ... } // later wins over earlier
427
+ *
428
+ * `extends` is resolved recursively; the child's fields overlay the
429
+ * merged parent(s). Cycle detection throws a clear error. Missing
430
+ * parents throw — we don't silently drop `extends`, that would hide
431
+ * typos.
432
+ *
433
+ * Merge rules:
434
+ * - `[settings]` fields: child overrides parent, per-key.
435
+ * - `conf` block: parent + child flattened with child wins per-leaf
436
+ * (same pattern as PLAN-CONF.md §4.2 within the single profile).
437
+ * - `extends` itself is stripped from the resolved output — the
438
+ * returned object carries no inheritance metadata, so existing
439
+ * consumers (formatProfile, hashProfile, readProfileConf) work
440
+ * unchanged.
441
+ *
442
+ * @param {string} name
443
+ * @param {string[]} [stack] visited names — for cycle detection
444
+ * @returns {WyvrnProfile|null} Fully-resolved profile, or null if `name`
445
+ * does not exist on disk at the root of the chain.
446
+ */
447
+ function resolveProfileChain(name, stack = []) {
448
+ if (stack.includes(name)) {
449
+ throw new Error(
450
+ `profile inheritance cycle: ${[...stack, name].join(' → ')}`,
451
+ );
452
+ }
453
+
454
+ const raw = readProfile(name);
455
+ if (!raw) return null;
456
+
457
+ const rawExtends = raw.extends;
458
+ if (rawExtends === undefined || rawExtends === null) {
459
+ // Flat profile — just strip any accidental `extends` and return.
460
+ const { extends: _, ...rest } = raw;
461
+ return rest;
462
+ }
463
+
464
+ const parents = Array.isArray(rawExtends) ? rawExtends : [rawExtends];
465
+ for (const p of parents) {
466
+ if (typeof p !== 'string' || p.length === 0) {
467
+ throw new Error(
468
+ `profile "${name}": extends entries must be non-empty strings, got ${JSON.stringify(p)}`,
469
+ );
470
+ }
471
+ }
472
+
473
+ // Merge parents left-to-right, then overlay the child.
474
+ let merged = {};
475
+ for (const parentName of parents) {
476
+ const parent = resolveProfileChain(parentName, [...stack, name]);
477
+ if (!parent) {
478
+ throw new Error(
479
+ `profile "${name}": extends parent "${parentName}" not found ` +
480
+ `at ${getProfilesDir()}`,
481
+ );
482
+ }
483
+ merged = mergeResolvedProfiles(merged, parent);
484
+ }
485
+
486
+ const { extends: _, ...child } = raw;
487
+ return mergeResolvedProfiles(merged, child);
488
+ }
489
+
490
+ /**
491
+ * Merge two already-resolved profile objects. Top-level `[settings]`
492
+ * fields: right wins. `conf` block: per-leaf merge, right wins.
493
+ * @param {object} base
494
+ * @param {object} overlay
495
+ * @returns {object}
496
+ */
497
+ function mergeResolvedProfiles(base, overlay) {
498
+ const out = { ...base, ...overlay };
499
+ // Nullable override semantics: `null` on the overlay explicitly
500
+ // unsets a field from the base (matches how Conan profiles
501
+ // handle `compiler.runtime=None`).
502
+ for (const k of Object.keys(overlay)) {
503
+ if (overlay[k] === null) delete out[k];
504
+ }
505
+ // Conf block is merged per-leaf, not per-branch.
506
+ if (base.conf || overlay.conf) {
507
+ const { flattenConf, unflattenConf } = require('./conf');
508
+ let baseFlat = {};
509
+ let overlayFlat = {};
510
+ try { if (base.conf) baseFlat = flattenConf(base.conf); } catch { /* leave empty */ }
511
+ try { if (overlay.conf) overlayFlat = flattenConf(overlay.conf); } catch { /* leave empty */ }
512
+ const merged = { ...baseFlat, ...overlayFlat };
513
+ if (Object.keys(merged).length > 0) {
514
+ out.conf = unflattenConf(merged);
515
+ } else {
516
+ delete out.conf;
517
+ }
518
+ }
519
+ return out;
520
+ }
521
+
522
+ /**
523
+ * Load the active build profile by name, resolving any `extends` chain.
524
+ * Falls back to auto-detection if the named profile file does not
525
+ * exist yet.
391
526
  *
392
527
  * @param {string} [name='default']
393
528
  * @returns {{ profile: WyvrnProfile, fromFile: boolean }}
394
529
  */
395
530
  function loadProfile(name = 'default') {
396
- const stored = readProfile(name);
397
- if (stored) return { profile: stored, fromFile: true };
531
+ const resolved = resolveProfileChain(name);
532
+ if (resolved) return { profile: resolved, fromFile: true };
398
533
  return { profile: detectProfile(), fromFile: false };
399
534
  }
400
535
 
@@ -415,6 +550,7 @@ module.exports = {
415
550
  deleteProfile,
416
551
  listProfiles,
417
552
  loadProfile,
553
+ resolveProfileChain,
418
554
  };
419
555
 
420
556
  /**
@@ -16,21 +16,6 @@ const log = require('../logger');
16
16
  * //server/share/path (SMB UNC — forward slash)
17
17
  * file:///path (file URI)
18
18
  */
19
- function pinDependencies(rawDeps, lockedDeps) {
20
- if (!rawDeps || !lockedDeps || Object.keys(lockedDeps).length === 0) return rawDeps;
21
- const result = {};
22
- for (const [pkgName, value] of Object.entries(rawDeps)) {
23
- const locked = lockedDeps[pkgName];
24
- if (locked) {
25
- result[pkgName] = typeof value === 'object' && value !== null
26
- ? { ...value, version: locked }
27
- : locked;
28
- } else {
29
- result[pkgName] = value;
30
- }
31
- }
32
- return result;
33
- }
34
19
 
35
20
  class FileProvider extends BaseProvider {
36
21
  static canHandle(source) {
@@ -106,7 +91,7 @@ class FileProvider extends BaseProvider {
106
91
  async v2Publish(files, options) {
107
92
  const {
108
93
  source, name, version, profileHash, buildSettings,
109
- contentSha256, gitSha, gitRepo, lockedDependencies,
94
+ contentSha256, gitSha, gitRepo, publishedLock,
110
95
  updateLatest = true, uploadedBy,
111
96
  } = options;
112
97
 
@@ -118,12 +103,12 @@ class FileProvider extends BaseProvider {
118
103
  this._copy(files.zip, path.join(buildDir, 'wyvrn.zip'));
119
104
 
120
105
  // 2) enriched wyvrn.json (buildSettings stored for debugging only)
121
- // Dependencies are replaced with locked versions so the server never sees "latest".
106
+ // Dependencies are published verbatim from wyvrn.json ranges stay as
107
+ // ranges so consumers can intersect them with their own. The author's
108
+ // lockfile snapshot is preserved in `publishedLock` for audit.
122
109
  const rawManifest = JSON.parse(fs.readFileSync(files.manifest, 'utf8'));
123
- const pinnedDeps = pinDependencies(rawManifest.dependencies, lockedDependencies);
124
110
  const v2Meta = {
125
111
  ...rawManifest,
126
- ...(pinnedDeps !== undefined ? { dependencies: pinnedDeps } : {}),
127
112
  schemaVersion: 2,
128
113
  profileHash,
129
114
  buildSettings,
@@ -131,6 +116,7 @@ class FileProvider extends BaseProvider {
131
116
  gitSha: gitSha ?? null,
132
117
  gitRepo: gitRepo ?? null,
133
118
  publishedAt: new Date().toISOString(),
119
+ ...(publishedLock && Object.keys(publishedLock).length > 0 ? { publishedLock } : {}),
134
120
  ...(uploadedBy ? { uploadedBy } : {}),
135
121
  };
136
122
  this._writeJson(path.join(buildDir, 'wyvrn.json'), v2Meta);
@@ -8,21 +8,11 @@ const { Readable } = require('stream');
8
8
  const BaseProvider = require('./base');
9
9
  const log = require('../logger');
10
10
 
11
- function pinDependencies(rawDeps, lockedDeps) {
12
- if (!rawDeps || !lockedDeps || Object.keys(lockedDeps).length === 0) return rawDeps;
13
- const result = {};
14
- for (const [pkgName, value] of Object.entries(rawDeps)) {
15
- const locked = lockedDeps[pkgName];
16
- if (locked) {
17
- result[pkgName] = typeof value === 'object' && value !== null
18
- ? { ...value, version: locked }
19
- : locked;
20
- } else {
21
- result[pkgName] = value;
22
- }
23
- }
24
- return result;
25
- }
11
+ // CloudFront / AWS WAF (and some other fronts) 403 requests that lack a
12
+ // User-Agent header, so we MUST send one on every outbound call. Failing to
13
+ // do so means `_getJson` swallows the 403 as `null` and the compat path
14
+ // silently treats the registry as empty.
15
+ const USER_AGENT = `wyvrnpm/${require('../../package.json').version}`;
26
16
 
27
17
  class HttpProvider extends BaseProvider {
28
18
  static canHandle(source) {
@@ -44,7 +34,7 @@ class HttpProvider extends BaseProvider {
44
34
  return new Promise((resolve, reject) => {
45
35
  const body = fs.readFileSync(filePath);
46
36
  const parsed = new URL(url);
47
- const headers = { 'Content-Type': contentType, 'Content-Length': body.length };
37
+ const headers = { 'User-Agent': USER_AGENT, 'Content-Type': contentType, 'Content-Length': body.length };
48
38
  if (token) headers['Authorization'] = `Bearer ${token}`;
49
39
  log.info(` → PUT ${url}`);
50
40
  const req = this._lib(url).request(
@@ -65,7 +55,7 @@ class HttpProvider extends BaseProvider {
65
55
  _putBuffer(url, buffer, contentType, token) {
66
56
  return new Promise((resolve, reject) => {
67
57
  const parsed = new URL(url);
68
- const headers = { 'Content-Type': contentType, 'Content-Length': buffer.length };
58
+ const headers = { 'User-Agent': USER_AGENT, 'Content-Type': contentType, 'Content-Length': buffer.length };
69
59
  if (token) headers['Authorization'] = `Bearer ${token}`;
70
60
  log.info(` → PUT ${url}`);
71
61
  const req = this._lib(url).request(
@@ -86,7 +76,7 @@ class HttpProvider extends BaseProvider {
86
76
  _head(url, token) {
87
77
  return new Promise((resolve, reject) => {
88
78
  const parsed = new URL(url);
89
- const headers = {};
79
+ const headers = { 'User-Agent': USER_AGENT };
90
80
  if (token) headers['Authorization'] = `Bearer ${token}`;
91
81
  const req = this._lib(url).request(
92
82
  { hostname: parsed.hostname, port: parsed.port || undefined, path: parsed.pathname, method: 'HEAD', headers },
@@ -104,13 +94,22 @@ class HttpProvider extends BaseProvider {
104
94
  _getJson(url, token) {
105
95
  return new Promise((resolve) => {
106
96
  const parsed = new URL(url);
107
- const headers = { Accept: 'application/json' };
97
+ const headers = { 'User-Agent': USER_AGENT, Accept: 'application/json' };
108
98
  if (token) headers['Authorization'] = `Bearer ${token}`;
109
99
  const req = this._lib(url).request(
110
100
  { hostname: parsed.hostname, port: parsed.port || undefined, path: parsed.pathname, method: 'GET', headers },
111
101
  (res) => {
112
102
  if (res.statusCode === 404) { res.resume(); resolve(null); return; }
113
- if (res.statusCode < 200 || res.statusCode >= 300) { res.resume(); resolve(null); return; }
103
+ if (res.statusCode < 200 || res.statusCode >= 300) {
104
+ // A 404 is "not found" — expected. Anything else (403 from a WAF,
105
+ // 5xx, etc.) is surprising and should NOT be swallowed silently,
106
+ // or the compat path will treat the registry as empty. See the
107
+ // CloudFront UA-block case that motivated the default User-Agent.
108
+ log.warn(`HTTP GET ${url} → ${res.statusCode}; treating as missing`);
109
+ res.resume();
110
+ resolve(null);
111
+ return;
112
+ }
114
113
  const chunks = [];
115
114
  res.on('data', (c) => chunks.push(c));
116
115
  res.on('end', () => {
@@ -129,7 +128,7 @@ class HttpProvider extends BaseProvider {
129
128
  const controller = new AbortController();
130
129
  const timer = setTimeout(() => controller.abort(), timeoutMs ?? 300_000);
131
130
  try {
132
- const headers = {};
131
+ const headers = { 'User-Agent': USER_AGENT };
133
132
  if (token) headers['Authorization'] = `Bearer ${token}`;
134
133
  const response = await fetch(url, { signal: controller.signal, headers });
135
134
  if (!response.ok) return false;
@@ -175,7 +174,7 @@ class HttpProvider extends BaseProvider {
175
174
  async v2Publish(files, options) {
176
175
  const {
177
176
  source, name, version, profileHash, buildSettings,
178
- contentSha256, gitSha, gitRepo, lockedDependencies, token,
177
+ contentSha256, gitSha, gitRepo, publishedLock, token,
179
178
  updateLatest = true, uploadedBy,
180
179
  } = options;
181
180
  const base = source.replace(/\/$/, '');
@@ -185,14 +184,14 @@ class HttpProvider extends BaseProvider {
185
184
  // 1) Upload the binary zip
186
185
  await this._put(`${buildRoot}/wyvrn.zip`, files.zip, 'application/zip', token);
187
186
 
188
- // 2) Upload enriched wyvrn.json (original manifest + v2 metadata)
187
+ // 2) Upload enriched wyvrn.json (original manifest + v2 metadata).
189
188
  // buildSettings is stored for debugging purposes only — not for configuration.
190
- // Dependencies are replaced with locked versions so the server never sees "latest".
189
+ // Dependencies are published verbatim from wyvrn.json ranges stay as
190
+ // ranges so consumers can intersect them with their own. The author's
191
+ // lockfile snapshot is preserved in `publishedLock` for audit.
191
192
  const rawManifest = JSON.parse(fs.readFileSync(files.manifest, 'utf8'));
192
- const pinnedDeps = pinDependencies(rawManifest.dependencies, lockedDependencies);
193
193
  const v2Meta = {
194
194
  ...rawManifest,
195
- ...(pinnedDeps !== undefined ? { dependencies: pinnedDeps } : {}),
196
195
  schemaVersion: 2,
197
196
  profileHash,
198
197
  buildSettings, // copy of the profile used — for debugging
@@ -200,6 +199,7 @@ class HttpProvider extends BaseProvider {
200
199
  gitSha: gitSha ?? null,
201
200
  gitRepo: gitRepo ?? null,
202
201
  publishedAt: new Date().toISOString(),
202
+ ...(publishedLock && Object.keys(publishedLock).length > 0 ? { publishedLock } : {}),
203
203
  ...(uploadedBy ? { uploadedBy } : {}),
204
204
  };
205
205
  await this._putBuffer(