wyvrnpm 2.12.2 → 2.13.11

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,136 @@
1
+ 'use strict';
2
+
3
+ const { getProvider } = require('../providers');
4
+ const { readConfig } = require('../config');
5
+ const { resolveSourceAuth } = require('../auth');
6
+ const trust = require('../signing/trust');
7
+ const log = require('../logger');
8
+
9
+ /**
10
+ * Resolve a `--source` argument (URL or configured-source name) against
11
+ * `installSources` first then `publishSources`. Trust anchors live with
12
+ * the registry, not the consumer's view of it; both side's source lists
13
+ * point at the same buckets, so accepting either is the right UX.
14
+ */
15
+ function resolveTrustSource(argv) {
16
+ const config = readConfig();
17
+ const all = [...(config.installSources ?? []), ...(config.publishSources ?? [])];
18
+ let arg = argv.source;
19
+ if (!arg && all.length === 1) arg = all[0].name;
20
+ if (!arg) {
21
+ log.error(
22
+ 'specify a source: `wyvrnpm trust <cmd> --source <name|url>` ' +
23
+ '(or configure exactly one installSources entry)',
24
+ );
25
+ process.exit(1);
26
+ }
27
+ const named = all.find((s) => s.name === arg);
28
+ if (named) return { source: named.url, sourceEntry: named, awsProfile: argv.awsProfile ?? named.profile };
29
+ return { source: arg, sourceEntry: null, awsProfile: argv.awsProfile };
30
+ }
31
+
32
+ /**
33
+ * `wyvrnpm trust list [--source <name|url>] [--format json]`
34
+ *
35
+ * Prints the locally-cached trust anchor for a registry. Read-only —
36
+ * does NOT fetch. Pair with `trust refresh` when stale.
37
+ */
38
+ async function list(argv) {
39
+ const { source } = resolveTrustSource(argv);
40
+ const keys = trust.readCachedKeys(source);
41
+ const mode = trust.readCachedMode(source);
42
+ const ageMs = trust.getCacheAgeMs(source);
43
+
44
+ if (argv.format === 'json') {
45
+ process.stdout.write(JSON.stringify({
46
+ command: 'trust list',
47
+ wyvrnpmVersion: require('../../package.json').version,
48
+ source,
49
+ keys, mode,
50
+ cacheAgeMs: Number.isFinite(ageMs) ? ageMs : null,
51
+ fresh: trust.isCacheFresh(source),
52
+ }, null, 2) + '\n');
53
+ return;
54
+ }
55
+
56
+ if (!keys && !mode) {
57
+ log.warn(`no cached trust anchor for ${source}. Run \`wyvrnpm trust refresh --source ${argv.source ?? source}\` to fetch.`);
58
+ return;
59
+ }
60
+
61
+ console.log(`Trust anchor for ${source}`);
62
+ console.log(` cached : ${Number.isFinite(ageMs) ? `${Math.round(ageMs / 1000)}s ago` : 'unknown'}`);
63
+ console.log(` mode : ${mode?.mode ?? '(none — defaults to off)'}`);
64
+ console.log(` keys : ${keys?.keys?.length ?? 0}`);
65
+ for (const k of keys?.keys ?? []) {
66
+ const status = k.retiredAt ? `retired ${k.retiredAt}` : 'active';
67
+ console.log(` - ${k.id} [${k.alg}] ${status} ${k.identityHint ? `(${k.identityHint})` : ''}`);
68
+ }
69
+ }
70
+
71
+ /**
72
+ * `wyvrnpm trust refresh [--source <name|url>] [--format json]`
73
+ *
74
+ * Fetch `.trust/keys.json` and `.trust/mode.json` from the registry.
75
+ * TOFU diff classification:
76
+ * - `first-fetch` / `unchanged` / `additive` → cache + accept silently
77
+ * (additive logs the new keyIds).
78
+ * - `breaking` → DO NOT cache; print diff
79
+ * and exit 1. The user must re-run with `--accept-breaking` to pin
80
+ * the new state. (Phase 2 will require a root-key signature instead.)
81
+ */
82
+ async function refresh(argv) {
83
+ const { source, sourceEntry, awsProfile } = resolveTrustSource(argv);
84
+ const { token } = resolveSourceAuth({}, sourceEntry);
85
+
86
+ let provider;
87
+ try { provider = getProvider(source); }
88
+ catch (err) { log.error(`no provider for ${source}: ${err.message}`); process.exit(1); }
89
+
90
+ const fetched = await provider.v2GetTrustAnchor({ source, awsProfile, token });
91
+ if (!fetched) {
92
+ log.warn(`no .trust/ directory found at ${source} — registry has not adopted S1 (mode=off)`);
93
+ if (argv.format === 'json') {
94
+ process.stdout.write(JSON.stringify({ command: 'trust refresh', source, found: false }, null, 2) + '\n');
95
+ }
96
+ return;
97
+ }
98
+
99
+ const cached = trust.readCachedKeys(source);
100
+ const diff = fetched.keys ? trust.diffKeys(cached, fetched.keys) : { kind: 'unchanged' };
101
+
102
+ if (diff.kind === 'breaking' && !argv.acceptBreaking) {
103
+ log.error(
104
+ `trust anchor at ${source} changed in a non-additive way:\n` +
105
+ ` removed: ${diff.removedIds.join(', ') || '(none)'}\n` +
106
+ ` modified: ${diff.modifiedIds.join(', ') || '(none)'}\n` +
107
+ ` added: ${diff.addedIds.join(', ') || '(none)'}\n` +
108
+ 'Re-run with --accept-breaking to overwrite the cache. Phase 2 will require a root-key signature instead.',
109
+ );
110
+ process.exit(1);
111
+ }
112
+
113
+ trust.writeCache(source, { keys: fetched.keys ?? undefined, mode: fetched.mode ?? undefined });
114
+
115
+ if (argv.format === 'json') {
116
+ process.stdout.write(JSON.stringify({
117
+ command: 'trust refresh',
118
+ wyvrnpmVersion: require('../../package.json').version,
119
+ source,
120
+ diff,
121
+ keys: fetched.keys,
122
+ mode: fetched.mode,
123
+ }, null, 2) + '\n');
124
+ return;
125
+ }
126
+
127
+ log.success(`refreshed trust anchor for ${source} (${diff.kind})`);
128
+ if (diff.kind === 'additive') {
129
+ log.info(` added keys : ${diff.addedIds.join(', ')}`);
130
+ } else if (diff.kind === 'breaking') {
131
+ log.warn(` breaking diff accepted: -${diff.removedIds.length} ~${diff.modifiedIds.length} +${diff.addedIds.length}`);
132
+ }
133
+ log.info(` mode : ${fetched.mode?.mode ?? '(none — defaults to off)'}`);
134
+ }
135
+
136
+ module.exports = { list, refresh, resolveTrustSource };
package/src/conf/index.js CHANGED
@@ -21,14 +21,29 @@ const { validateFlatConfKeys } = require('./namespaces');
21
21
 
22
22
  const LOCAL_OVERLAY_FILENAME = 'wyvrn.local.json';
23
23
 
24
- // Per plan §4.5 "Strict top-level allow-list". Two top-level keys
25
- // today:
26
- // - `conf` non-ABI build-time knobs (PLAN-CONF.md).
27
- // - `binaryDirRoot` — dev-local override of the preset's build
28
- // subdir (sibling channel to --build-dir on
29
- // install/build). See src/binary-dir.js.
24
+ // Per plan §4.5 "Strict top-level allow-list". Top-level keys today:
25
+ // - `conf` — non-ABI build-time knobs (PLAN-CONF.md).
26
+ // - `binaryDirRoot` dev-local override of the preset's build
27
+ // subdir (sibling channel to --build-dir on
28
+ // install/build). See src/binary-dir.js.
29
+ // - `requestConfigs` — global per-config artefact selection;
30
+ // dev-local / runner-local default. Array of
31
+ // CMake config names; intersected with the
32
+ // author's `artefactConfigs` at install time.
33
+ // - `depRequestConfigs` — per-dep override of `requestConfigs`. Map
34
+ // of `{ <depName>: [<config>, ...] }`. When a
35
+ // dep is named here, the listed configs apply
36
+ // to it ONLY; everything else inherits the
37
+ // global `requestConfigs`. Use case: "fetch
38
+ // all four configs of fmt + zlib, but only
39
+ // MinSizeRel of the multi-GB grpc."
30
40
  // Adding dependencies/options/etc. is plan-2 territory.
31
- const LOCAL_OVERLAY_ALLOWED_TOP_KEYS = new Set(['conf', 'binaryDirRoot']);
41
+ const LOCAL_OVERLAY_ALLOWED_TOP_KEYS = new Set([
42
+ 'conf',
43
+ 'binaryDirRoot',
44
+ 'requestConfigs',
45
+ 'depRequestConfigs',
46
+ ]);
32
47
 
33
48
  // ---------------------------------------------------------------------------
34
49
  // Value normalisation
@@ -203,11 +218,24 @@ function parseCliConf(rawArgs) {
203
218
  * phase 1 is `conf`; anything else is a hard error (not a warning).
204
219
  *
205
220
  * @param {string} rootDir
206
- * @returns {{ flat: Record<string,string>, path: string|null, binaryDirRoot: string|null }}
221
+ * @returns {{
222
+ * flat: Record<string,string>,
223
+ * path: string|null,
224
+ * binaryDirRoot: string|null,
225
+ * requestConfigs: string[]|null,
226
+ * depRequestConfigs: Record<string, string[]>|null,
227
+ * }}
207
228
  */
208
229
  function readLocalOverlay(rootDir) {
209
230
  const p = path.join(rootDir, LOCAL_OVERLAY_FILENAME);
210
- if (!fs.existsSync(p)) return { flat: {}, path: null, binaryDirRoot: null };
231
+ if (!fs.existsSync(p)) {
232
+ return {
233
+ flat: {}, path: null,
234
+ binaryDirRoot: null,
235
+ requestConfigs: null,
236
+ depRequestConfigs: null,
237
+ };
238
+ }
211
239
 
212
240
  let raw;
213
241
  try {
@@ -243,7 +271,84 @@ function readLocalOverlay(rootDir) {
243
271
  ? raw.binaryDirRoot
244
272
  : null;
245
273
 
246
- return { flat, path: p, binaryDirRoot };
274
+ // `requestConfigs` shape-validates here; intersection with the
275
+ // author's `artefactConfigs` happens in download.js. Custom CMake
276
+ // configs (e.g. "Profile") are intentionally permitted — the
277
+ // intersection will reject unknown names with a clear diagnostic
278
+ // listing what the author actually published.
279
+ let requestConfigs = null;
280
+ if (Object.prototype.hasOwnProperty.call(raw, 'requestConfigs')) {
281
+ const v = raw.requestConfigs;
282
+ if (!Array.isArray(v)) {
283
+ throw new Error(
284
+ `${LOCAL_OVERLAY_FILENAME}: requestConfigs must be an array of CMake config names — ` +
285
+ `got ${JSON.stringify(v)}`,
286
+ );
287
+ }
288
+ if (v.length === 0) {
289
+ throw new Error(
290
+ `${LOCAL_OVERLAY_FILENAME}: requestConfigs must not be empty — ` +
291
+ `omit the key to inherit the recipe's build.configs`,
292
+ );
293
+ }
294
+ for (const entry of v) {
295
+ if (typeof entry !== 'string' || entry.trim() !== entry || entry.length === 0) {
296
+ throw new Error(
297
+ `${LOCAL_OVERLAY_FILENAME}: requestConfigs entries must be non-empty trimmed strings — ` +
298
+ `got ${JSON.stringify(entry)}`,
299
+ );
300
+ }
301
+ }
302
+ requestConfigs = v.slice();
303
+ }
304
+
305
+ // `depRequestConfigs` shape-validates here. Each entry is the same
306
+ // array-of-config-names contract as `requestConfigs`; the dep-name
307
+ // key is intentionally NOT validated against any registered package
308
+ // list — names are resolved at install time and a typo just means
309
+ // "no override applies" (with the global `requestConfigs` taking over).
310
+ let depRequestConfigs = null;
311
+ if (Object.prototype.hasOwnProperty.call(raw, 'depRequestConfigs')) {
312
+ const map = raw.depRequestConfigs;
313
+ if (map === null || typeof map !== 'object' || Array.isArray(map)) {
314
+ throw new Error(
315
+ `${LOCAL_OVERLAY_FILENAME}: depRequestConfigs must be an object keyed by dep name — ` +
316
+ `got ${JSON.stringify(map)}`,
317
+ );
318
+ }
319
+ const out = {};
320
+ for (const [depName, val] of Object.entries(map)) {
321
+ if (typeof depName !== 'string' || depName.trim() !== depName || depName.length === 0) {
322
+ throw new Error(
323
+ `${LOCAL_OVERLAY_FILENAME}: depRequestConfigs key ${JSON.stringify(depName)} must be a non-empty trimmed string`,
324
+ );
325
+ }
326
+ if (!Array.isArray(val)) {
327
+ throw new Error(
328
+ `${LOCAL_OVERLAY_FILENAME}: depRequestConfigs[${JSON.stringify(depName)}] must be an array of CMake config names — ` +
329
+ `got ${JSON.stringify(val)}`,
330
+ );
331
+ }
332
+ if (val.length === 0) {
333
+ throw new Error(
334
+ `${LOCAL_OVERLAY_FILENAME}: depRequestConfigs[${JSON.stringify(depName)}] must not be empty — ` +
335
+ `omit the entry to inherit the global requestConfigs`,
336
+ );
337
+ }
338
+ for (const entry of val) {
339
+ if (typeof entry !== 'string' || entry.trim() !== entry || entry.length === 0) {
340
+ throw new Error(
341
+ `${LOCAL_OVERLAY_FILENAME}: depRequestConfigs[${JSON.stringify(depName)}] entries must be non-empty trimmed strings — ` +
342
+ `got ${JSON.stringify(entry)}`,
343
+ );
344
+ }
345
+ }
346
+ out[depName] = val.slice();
347
+ }
348
+ depRequestConfigs = out;
349
+ }
350
+
351
+ return { flat, path: p, binaryDirRoot, requestConfigs, depRequestConfigs };
247
352
  }
248
353
 
249
354
  // ---------------------------------------------------------------------------
@@ -356,7 +461,13 @@ function resolveEffectiveConf({ manifest, rootDir, cliConf, profile }) {
356
461
  const recipeFlat = readRecipeConf(manifest);
357
462
  const profileFlat = readProfileConf(profile);
358
463
  const overlay = readLocalOverlay(rootDir);
359
- const { flat: localFlat, path: localOverlayPath, binaryDirRoot: localBinaryDirRoot } = overlay;
464
+ const {
465
+ flat: localFlat,
466
+ path: localOverlayPath,
467
+ binaryDirRoot: localBinaryDirRoot,
468
+ requestConfigs: localRequestConfigs,
469
+ depRequestConfigs: localDepRequestConfigs,
470
+ } = overlay;
360
471
  const cli = parseCliConf(cliConf);
361
472
  const flat = mergeConfLayers(recipeFlat, localFlat, cli, profileFlat);
362
473
  return {
@@ -370,6 +481,15 @@ function resolveEffectiveConf({ manifest, rootDir, cliConf, profile }) {
370
481
  // re-reading the overlay file. Validated downstream — this just
371
482
  // forwards the raw value the user wrote.
372
483
  localBinaryDirRoot,
484
+ // Forwarded for the per-config slice resolver in install.js. Null
485
+ // when the overlay omits it; the resolver falls through to recipe
486
+ // build.configs in that case.
487
+ localRequestConfigs,
488
+ // Per-dep override of the slice resolver. Map of `{ depName: [...] }`.
489
+ // When a dep is named here, those configs apply to it specifically;
490
+ // otherwise the global `localRequestConfigs` (or its fallback chain)
491
+ // takes over.
492
+ localDepRequestConfigs,
373
493
  };
374
494
  }
375
495
 
package/src/config.js CHANGED
@@ -26,12 +26,21 @@ function getConfigPath() {
26
26
  /**
27
27
  * Read and parse config.json.
28
28
  * Returns a default empty config if the file doesn't exist yet.
29
- * @returns {{ installSources: SourceEntry[], publishSources: SourceEntry[], linkedPackages: Object<string, string>, defaultProfile: string }}
29
+ * @returns {{
30
+ * installSources: SourceEntry[],
31
+ * publishSources: SourceEntry[],
32
+ * linkedPackages: Object<string, string>,
33
+ * defaultProfile: string,
34
+ * defaultSigning: SigningConfig|null,
35
+ * }}
30
36
  */
31
37
  function readConfig() {
32
38
  const configPath = getConfigPath();
33
39
  if (!fs.existsSync(configPath)) {
34
- return { installSources: [], publishSources: [], linkedPackages: {}, defaultProfile: 'default' };
40
+ return {
41
+ installSources: [], publishSources: [], linkedPackages: {},
42
+ defaultProfile: 'default', defaultSigning: null,
43
+ };
35
44
  }
36
45
  try {
37
46
  const raw = JSON.parse(fs.readFileSync(configPath, 'utf8'));
@@ -41,10 +50,19 @@ function readConfig() {
41
50
  linkedPackages: raw.linkedPackages ?? {},
42
51
  // Migrate: if old config had an inline `profile` object, drop it; use file-based profiles.
43
52
  defaultProfile: raw.defaultProfile ?? 'default',
53
+ // S1: optional signing config, never auto-populated. Each
54
+ // installSources / publishSources entry MAY also carry `signing`
55
+ // (publish-side) and `trustRootPub` (install-side, phase 2)
56
+ // overrides; consumers consult the per-source value first and
57
+ // fall back to defaultSigning.
58
+ defaultSigning: raw.defaultSigning ?? null,
44
59
  };
45
60
  } catch {
46
61
  log.warn('could not parse config.json — using defaults');
47
- return { installSources: [], publishSources: [], linkedPackages: {}, defaultProfile: 'default' };
62
+ return {
63
+ installSources: [], publishSources: [], linkedPackages: {},
64
+ defaultProfile: 'default', defaultSigning: null,
65
+ };
48
66
  }
49
67
  }
50
68
 
@@ -61,5 +79,18 @@ function writeConfig(config) {
61
79
  module.exports = { getConfigDir, getConfigPath, readConfig, writeConfig };
62
80
 
63
81
  /**
64
- * @typedef {{ name: string, url: string, profile?: string, token?: string }} SourceEntry
82
+ * @typedef {{ name: string, url: string, profile?: string, token?: string, signing?: SigningConfig, trustRootPub?: string }} SourceEntry
83
+ *
84
+ * @typedef {{
85
+ * alg: 'ed25519',
86
+ * keyId: string,
87
+ * privateKey: { env: string } | { file: string },
88
+ * }} SigningConfig
89
+ *
90
+ * `signing` on a publishSources entry overrides `defaultSigning` for that
91
+ * specific destination; useful when one team-shared registry uses a
92
+ * different keyId from another. `trustRootPub` on an installSources entry
93
+ * (phase 2) carries an SPKI-PEM root key pinned by the consumer; the
94
+ * registry's keys.json will then need to be signed by that root for the
95
+ * install to accept rotations.
65
96
  */