wyvrnpm 2.3.2 → 2.8.1

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
@@ -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;
@@ -209,7 +238,7 @@ async function findCompatibleBuild({ name, version, consumerProfile, consumerOpt
209
238
  : packageProfile);
210
239
 
211
240
  const rawCompat = meta.compatibility ?? null;
212
- const { compatible, reasons } = evaluateCompat(consumerFull, packageFull, rawCompat);
241
+ const { compatible, reasons } = evaluateCompat(consumerFull, packageFull, rawCompat, meta.kind);
213
242
  if (compatible) {
214
243
  candidates.push({ profileHash, packageProfile, reasons, buildEntry, meta });
215
244
  }
@@ -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 ────────
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
  /**