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.
@@ -14,16 +14,78 @@ const {
14
14
  } = require('../conf');
15
15
  const { resolveBinaryDirRoot } = require('../binary-dir');
16
16
  const { hashProfile, sha256Of, getGitSha, getGitRepo, isShaOnRemote } = require('../profile');
17
+ const { sha256OfCanonical } = require('../signing/canonical');
18
+ const {
19
+ signAttestation,
20
+ serializeSigEnvelope,
21
+ } = require('../signing');
22
+ const { resolveSigningContext } = require('../signing/resolve-context');
17
23
  const { defaultCompatBlock } = require('../compat');
18
24
  const { buildContext } = require('../context');
25
+ const { buildV2Meta } = require('../v2-meta');
19
26
  const {
20
27
  normalizeOptionsDeclaration,
21
28
  resolveEffectiveOptions,
22
29
  } = require('../options');
23
30
  const { loadIgnorePatterns, isIgnored } = require('../ignore');
24
31
  const { hasBuildRecipe, normalizeRecipe } = require('../build/recipe');
32
+ const {
33
+ planSlice,
34
+ auditSlicePlan,
35
+ sha256AndSize,
36
+ } = require('../publish/slice');
25
37
  const log = require('../logger');
26
38
 
39
+ /**
40
+ * Materialise the freshly-built dry-run artefacts (manifest, slice or
41
+ * fat zip(s), optional att+sig) into a local inspection directory.
42
+ * Does NOT touch any provider — pure local copy. The same files the
43
+ * provider would have uploaded land here under their canonical
44
+ * registry filenames so the user can `unzip`, sha256, or diff them
45
+ * before committing to a real publish.
46
+ *
47
+ * Existing contents of `outDir` are wiped first — dry-run-publish is
48
+ * a fresh-rendering operation; stale leftovers from a previous run
49
+ * would just confuse inspection.
50
+ *
51
+ * @param {object} args
52
+ * @param {string} args.outDir
53
+ * @param {string} args.manifestPath tmp wyvrn.json
54
+ * @param {string|null} [args.zipPath] tmp fat-zip path (fat-zip publishes)
55
+ * @param {Record<string,string>|null} [args.artefactZips] config → tmp slice path
56
+ * @param {string|null} [args.attPath]
57
+ * @param {string|null} [args.sigPath]
58
+ * @returns {{ outDir: string, files: Array<{ name: string, sizeBytes: number }> }}
59
+ */
60
+ function writeDryRunArtefacts({ outDir, manifestPath, zipPath, artefactZips, attPath, sigPath }) {
61
+ // Wipe + recreate. Recursive rm on an absent dir is a no-op.
62
+ fs.rmSync(outDir, { recursive: true, force: true });
63
+ fs.mkdirSync(outDir, { recursive: true });
64
+
65
+ const files = [];
66
+ function copyTo(srcPath, name) {
67
+ const dest = path.join(outDir, name);
68
+ fs.copyFileSync(srcPath, dest);
69
+ files.push({ name, sizeBytes: fs.statSync(dest).size });
70
+ }
71
+
72
+ // Order mirrors the registry write order (zips → manifest → att → sig)
73
+ // so the inspection folder reads top-to-bottom in the same shape the
74
+ // provider would write.
75
+ if (artefactZips && Object.keys(artefactZips).length > 0) {
76
+ for (const cfg of Object.keys(artefactZips).sort()) {
77
+ copyTo(artefactZips[cfg], `wyvrn-${cfg}.zip`);
78
+ }
79
+ } else if (zipPath) {
80
+ copyTo(zipPath, 'wyvrn.zip');
81
+ }
82
+ copyTo(manifestPath, 'wyvrn.json');
83
+ if (attPath) copyTo(attPath, 'wyvrn.att.json');
84
+ if (sigPath) copyTo(sigPath, 'wyvrn.sig');
85
+
86
+ return { outDir, files };
87
+ }
88
+
27
89
  /**
28
90
  * Human-friendly byte formatter for the dry-run summary.
29
91
  *
@@ -44,29 +106,49 @@ function formatBytes(n) {
44
106
  * providers (§4.5 of CLAUDE.md), so dry-run can surface the exact shape
45
107
  * of the upload without actually performing it.
46
108
  *
109
+ * `sliceConfigs` triggers the per-config branch: instead of a single
110
+ * `wyvrn.zip`, the plan lists one `wyvrn-<Config>.zip` per declared
111
+ * config (alphabetical). Pass `null` for fat-zip publishes (today's
112
+ * default).
113
+ *
47
114
  * @param {{
48
- * source: string,
49
- * name: string,
50
- * version: string,
51
- * profileHash: string,
52
- * gitSha?: string | null,
53
- * gitRepo?: string | null,
115
+ * source: string,
116
+ * name: string,
117
+ * version: string,
118
+ * profileHash: string,
119
+ * gitSha?: string | null,
120
+ * gitRepo?: string | null,
54
121
  * updateLatest?: boolean,
122
+ * signed?: boolean,
123
+ * sliceConfigs?: string[] | null,
55
124
  * }} opts
56
- * @returns {Array<{ path: string, kind: 'wyvrn.zip'|'wyvrn.json'|'source.json'|'versions.json'|'latest.json' }>}
125
+ * @returns {Array<{ path: string, kind: string }>}
57
126
  */
58
127
  function planPublishUrls({
59
128
  source, name, version, profileHash,
60
- gitSha = null, gitRepo = null, updateLatest = true,
129
+ gitSha = null, gitRepo = null, updateLatest = true, signed = false,
130
+ sliceConfigs = null,
61
131
  }) {
62
132
  const base = source.replace(/\/$/, '');
63
133
  const v2root = `${base}/v2/${name}`;
64
134
  const buildRoot = `${v2root}/${version}/${profileHash}`;
65
135
 
66
- const plan = [
67
- { path: `${buildRoot}/wyvrn.zip`, kind: 'wyvrn.zip' },
68
- { path: `${buildRoot}/wyvrn.json`, kind: 'wyvrn.json' },
69
- ];
136
+ const plan = [];
137
+ if (Array.isArray(sliceConfigs) && sliceConfigs.length > 0) {
138
+ for (const cfg of sliceConfigs.slice().sort()) {
139
+ plan.push({ path: `${buildRoot}/wyvrn-${cfg}.zip`, kind: `wyvrn-${cfg}.zip` });
140
+ }
141
+ } else {
142
+ plan.push({ path: `${buildRoot}/wyvrn.zip`, kind: 'wyvrn.zip' });
143
+ }
144
+ plan.push({ path: `${buildRoot}/wyvrn.json`, kind: 'wyvrn.json' });
145
+ if (signed) {
146
+ // Write order matters: att+sig land between the manifest and
147
+ // versions.json so a partial-failure crash never leaves a sig-less
148
+ // entry visible to consumers (S1 plan §3 + Risks §write-order).
149
+ plan.push({ path: `${buildRoot}/wyvrn.att.json`, kind: 'wyvrn.att.json' });
150
+ plan.push({ path: `${buildRoot}/wyvrn.sig`, kind: 'wyvrn.sig' });
151
+ }
70
152
  if (gitSha || gitRepo) {
71
153
  plan.push({ path: `${v2root}/${version}/source.json`, kind: 'source.json' });
72
154
  }
@@ -77,6 +159,9 @@ function planPublishUrls({
77
159
  return plan;
78
160
  }
79
161
 
162
+ // resolveSigningContext lives in src/signing/resolve-context.js so the
163
+ // install.js `--upload-built` path uses identical precedence rules.
164
+
80
165
  /**
81
166
  * Walk `dir` recursively and add every file to `zip`, skipping anything that
82
167
  * matches `.wyvrnignore` patterns OR whose zip-relative path is already in
@@ -179,7 +264,7 @@ function resolveSource(argv, ctx) {
179
264
  // buildContext.
180
265
  const { token } = ctx.auth.for(sourceEntry);
181
266
 
182
- return { source, awsProfile, token };
267
+ return { source, awsProfile, token, sourceEntry };
183
268
  }
184
269
 
185
270
  // ---------------------------------------------------------------------------
@@ -199,7 +284,7 @@ async function publish(argv) {
199
284
  process.exit(1);
200
285
  }
201
286
 
202
- const { source, awsProfile, token } = resolveSource(argv, ctx);
287
+ const { source, awsProfile, token, sourceEntry } = resolveSource(argv, ctx);
203
288
  const { path: publishPath, force } = argv;
204
289
  const dryRun = Boolean(argv.dryRun);
205
290
  const jsonOut = ctx.flags.format === 'json';
@@ -283,8 +368,33 @@ async function publish(argv) {
283
368
  // Dry-run softens the overwrite-without-force error to a warning so the
284
369
  // full plan still prints — CI callers should parse `--format json` and
285
370
  // gate on `wouldOverwrite` rather than on the exit code.
286
- const v2AlreadyExists = await provider.v2Exists({ source, name, version, profileHash, awsProfile, token });
287
- if (v2AlreadyExists) {
371
+ //
372
+ // The existence check is the only outbound network hit in publish()
373
+ // before the dry-run early-return. Under --dry-run we tolerate
374
+ // network/credential failures: the user's intent is "show me what
375
+ // WOULD happen, locally" — they shouldn't need a live AWS SSO token
376
+ // to inspect the slice plan + materialise artefacts. Degrade to
377
+ // wouldOverwrite=null and continue. Real publishes still propagate.
378
+ let v2AlreadyExists = false;
379
+ try {
380
+ v2AlreadyExists = await provider.v2Exists({ source, name, version, profileHash, awsProfile, token });
381
+ } catch (err) {
382
+ if (dryRun) {
383
+ log.warn(
384
+ `could not check existence of ${name}@${version} [${profileHash}] on ${source}: ` +
385
+ `${err.message}. ` +
386
+ `Continuing dry-run; "wouldOverwrite" reported as unknown.`,
387
+ );
388
+ v2AlreadyExists = null; // sentinel — surfaces in JSON payload
389
+ } else {
390
+ log.error(
391
+ `existence check failed for ${name}@${version} [${profileHash}] on ${source}: ` +
392
+ `${err.message}`,
393
+ );
394
+ process.exit(1);
395
+ }
396
+ }
397
+ if (v2AlreadyExists === true) {
288
398
  if (force) {
289
399
  log.warn(`${name}@${version} [${profileHash}] already exists — overwriting (--force)`);
290
400
  } else if (dryRun) {
@@ -350,6 +460,13 @@ async function publish(argv) {
350
460
 
351
461
  // ── Build zip ─────────────────────────────────────────────────────────────
352
462
  const tmpZipPath = path.join(os.tmpdir(), `wyvrnpm-${name}-${version}-${Date.now()}.zip`);
463
+ // Declared at function scope so the cleanup `finally` can `unlink` them
464
+ // regardless of which step in the try block populated them.
465
+ let attestationFilePath = null;
466
+ let signatureFilePath = null;
467
+ // Per-config slicing fans out to N tmp zips; tracked here so the
468
+ // cleanup `finally` unlinks every one even on partial failures.
469
+ const sliceTmpPaths = [];
353
470
 
354
471
  // ── Inject default `compatibility` block if the source manifest lacks one ──
355
472
  // Written to a temp file so the source wyvrn.json is never modified.
@@ -388,7 +505,10 @@ async function publish(argv) {
388
505
  ? { publishedConf: unflattenConf(publishedConf) }
389
506
  : {}),
390
507
  };
391
- fs.writeFileSync(tmpManifestPath, JSON.stringify(manifestForPublish, null, 2), 'utf8');
508
+ // The full v2Meta is built BEFORE provider write so the publisher's
509
+ // attestation can sign over the exact bytes the provider will upload.
510
+ // Providers no longer mutate the manifest; they read tmpManifestPath
511
+ // verbatim. See src/v2-meta.js for the canonical shape.
392
512
  if (!manifest.compatibility) {
393
513
  log.info('Injecting default `compatibility` block (all fields `exact`) — override by adding one to wyvrn.json');
394
514
  } else {
@@ -424,9 +544,10 @@ async function publish(argv) {
424
544
  // on path collisions (e.g. `include/`).
425
545
  let installOverlayDir = null;
426
546
  let installOverlayPaths = new Set();
547
+ let recipe = null;
427
548
  if (hasBuildRecipe(manifest)) {
428
549
  try {
429
- const recipe = normalizeRecipe(manifest.build, effectiveOptions, manifest);
550
+ recipe = normalizeRecipe(manifest.build, effectiveOptions, manifest);
430
551
  const candidate = path.join(
431
552
  srcDir, publishBinaryDirRoot, `wyvrn-${activeProfileName}`, recipe.installDir,
432
553
  );
@@ -445,11 +566,27 @@ async function publish(argv) {
445
566
  }
446
567
  } catch (err) {
447
568
  log.warn(`could not locate install tree: ${err.message} — publishing source tree only`);
569
+ recipe = null;
448
570
  }
449
571
  }
450
572
 
573
+ // Per-config slice publish (recipe `build.publishPerConfig: true`)
574
+ // requires an install tree to slice — fail fast if the recipe opted
575
+ // in but the install tree is missing.
576
+ const usePerConfig = recipe?.publishPerConfig === true;
577
+ if (usePerConfig && !installOverlayDir) {
578
+ log.error(
579
+ 'build.publishPerConfig is true but no install tree found. ' +
580
+ 'Per-config slicing has nothing to slice — run `wyvrnpm build --all-configs --install` ' +
581
+ 'and retry, or set publishPerConfig: false to publish the source tree alone.',
582
+ );
583
+ process.exit(1);
584
+ }
585
+ if (usePerConfig) {
586
+ log.info(`Per-config slicing: enabled — configs ${recipe.configs.join(', ')}`);
587
+ }
588
+
451
589
  try {
452
- log.info(`Zipping ${srcDir} ...`);
453
590
  // StreamingZipWriter — replaces adm-zip on the write path. archiver
454
591
  // streams each file in via fs.createReadStream rather than the
455
592
  // sync-readFileSync adm-zip used, so multi-GB artefacts (gRPC's
@@ -458,43 +595,222 @@ async function publish(argv) {
458
595
  // auto-promotes to ZIP64 when total size or entry count crosses
459
596
  // the 4 GB / 65535 thresholds; node-stream-zip on the consumer
460
597
  // side reads ZIP64 transparently.
461
- const zip = new StreamingZipWriter(tmpZipPath);
462
- if (installOverlayDir) {
463
- // Collect install-tree paths first so the source-tree walker can skip
464
- // anything the install tree will add canonically.
465
- const peek = collectInstallTreeFiles(installOverlayDir);
466
- installOverlayPaths = new Set(peek.map((f) => f.relPath));
598
+ let contentSha256 = null; // fat-zip mode
599
+ let zipBytes = 0;
600
+ let artefactConfigs = null; // slice mode: { config { sha256, sizeBytes } }
601
+ let artefactZips = null; // slice mode: { config tmpPath } (passed to v2Publish)
602
+
603
+ if (usePerConfig) {
604
+ // ── Per-config slice publish ────────────────────────────────────────
605
+ log.info(`Slicing install tree per config (${recipe.configs.join(', ')}) ...`);
606
+ const installPlan = planSlice(installOverlayDir);
607
+ try {
608
+ auditSlicePlan(installPlan, recipe.configs);
609
+ } catch (err) {
610
+ log.error(err.message);
611
+ process.exit(1);
612
+ }
613
+
614
+ // installOverlayPaths covers EVERY install-tree file (shared +
615
+ // per-config[*]) so the source-tree walker skips them all — install
616
+ // tree wins on collisions for any config slice.
617
+ installOverlayPaths = new Set([
618
+ ...installPlan.sharedFiles.map((f) => f.relPath),
619
+ ...Object.values(installPlan.perConfigFiles).flat().map((f) => f.relPath),
620
+ ]);
621
+
622
+ artefactZips = {};
623
+ artefactConfigs = {};
624
+
625
+ for (const config of recipe.configs.slice().sort()) {
626
+ const slicePath = path.join(
627
+ os.tmpdir(),
628
+ `wyvrnpm-slice-${name}-${version}-${config}-${Date.now()}.zip`,
629
+ );
630
+ sliceTmpPaths.push(slicePath);
631
+
632
+ log.info(` Building slice ${config} ...`);
633
+ const sliceZip = new StreamingZipWriter(slicePath);
634
+ // Source tree (filtered by .wyvrnignore, install-tree paths skipped).
635
+ addFolderFiltered(sliceZip, srcDir, srcDir, ignorePatterns, installOverlayPaths);
636
+ // Install-tree shared files — same in every slice so the
637
+ // multi-config-merge invariant holds when consumers extract two
638
+ // slices into one wyvrn_internal/<name>/.
639
+ for (const { fullPath, relPath } of installPlan.sharedFiles) {
640
+ const zipDir = path.dirname(relPath).replace(/\\/g, '/');
641
+ sliceZip.addLocalFile(fullPath, zipDir === '.' ? '' : zipDir);
642
+ }
643
+ // Install-tree per-config[<config>] files — exclusive to this slice.
644
+ for (const { fullPath, relPath } of installPlan.perConfigFiles[config]) {
645
+ const zipDir = path.dirname(relPath).replace(/\\/g, '/');
646
+ sliceZip.addLocalFile(fullPath, zipDir === '.' ? '' : zipDir);
647
+ }
648
+ await sliceZip.finalize();
649
+
650
+ const { sha256, sizeBytes } = await sha256AndSize(slicePath);
651
+ artefactZips[config] = slicePath;
652
+ artefactConfigs[config] = { sha256, sizeBytes };
653
+ zipBytes += sizeBytes;
654
+ log.info(` ${formatBytes(sizeBytes).padStart(10)} sha=${sha256.slice(0, 16)}...`);
655
+ }
656
+ log.info(`Total slice bytes: ${formatBytes(zipBytes)} (${Object.keys(artefactConfigs).length} slice(s))`);
657
+ } else {
658
+ // ── Fat-zip publish (today's default) ───────────────────────────────
659
+ log.info(`Zipping ${srcDir} ...`);
660
+ const zip = new StreamingZipWriter(tmpZipPath);
661
+ if (installOverlayDir) {
662
+ // Collect install-tree paths first so the source-tree walker can skip
663
+ // anything the install tree will add canonically.
664
+ const peek = collectInstallTreeFiles(installOverlayDir);
665
+ installOverlayPaths = new Set(peek.map((f) => f.relPath));
666
+ }
667
+ addFolderFiltered(zip, srcDir, srcDir, ignorePatterns, installOverlayPaths);
668
+ if (installOverlayDir) {
669
+ log.info(`Overlaying install tree: ${installOverlayDir}`);
670
+ const added = addInstallTree(zip, installOverlayDir);
671
+ log.info(` Added ${added.size} file(s) from install tree`);
672
+ }
673
+ await zip.finalize();
674
+
675
+ contentSha256 = sha256Of(tmpZipPath);
676
+ zipBytes = fs.statSync(tmpZipPath).size;
677
+ log.info(`Content SHA256 : ${contentSha256}`);
678
+ log.info(`Zip size : ${formatBytes(zipBytes)} (${zipBytes} bytes)`);
467
679
  }
468
- addFolderFiltered(zip, srcDir, srcDir, ignorePatterns, installOverlayPaths);
469
- if (installOverlayDir) {
470
- log.info(`Overlaying install tree: ${installOverlayDir}`);
471
- const added = addInstallTree(zip, installOverlayDir);
472
- log.info(` Added ${added.size} file(s) from install tree`);
680
+
681
+ // ── Build v2Meta + write tmpManifestPath ─────────────────────────────
682
+ // Single source of truth (src/v2-meta.js) so the publisher's signed
683
+ // manifestSha256 matches what consumers compute from v2GetMeta.
684
+ // Providers MUST NOT mutate the manifest after this point.
685
+ //
686
+ // Slice publishes carry `artefactConfigs` (per-config { sha256, sizeBytes });
687
+ // fat-zip publishes carry `contentSha256`. Mutually exclusive.
688
+ const publishedAt = new Date().toISOString();
689
+ const v2Meta = buildV2Meta(manifestForPublish, {
690
+ profileHash,
691
+ buildSettings: effectiveOptions
692
+ ? { ...buildProfile, options: effectiveOptions }
693
+ : buildProfile,
694
+ ...(usePerConfig ? { artefactConfigs } : { contentSha256 }),
695
+ gitSha,
696
+ gitRepo,
697
+ publishedAt,
698
+ publishedLock,
699
+ });
700
+ fs.writeFileSync(tmpManifestPath, JSON.stringify(v2Meta, null, 2), 'utf8');
701
+ const manifestBytes = fs.statSync(tmpManifestPath).size;
702
+ const manifestSha256 = sha256OfCanonical(v2Meta);
703
+
704
+ let signingCtx;
705
+ try {
706
+ signingCtx = resolveSigningContext(argv, ctx.config, sourceEntry);
707
+ } catch (err) {
708
+ log.error(err.message);
709
+ process.exit(1);
473
710
  }
474
- await zip.finalize();
475
711
 
476
- const contentSha256 = sha256Of(tmpZipPath);
477
- const zipBytes = fs.statSync(tmpZipPath).size;
478
- const manifestBytes = fs.statSync(tmpManifestPath).size;
479
- log.info(`Content SHA256 : ${contentSha256}`);
480
- log.info(`Zip size : ${formatBytes(zipBytes)} (${zipBytes} bytes)`);
712
+ let signed = false;
713
+ let signerKeyId = null;
714
+ if (signingCtx === null) {
715
+ log.warn(
716
+ 'publishing unsigned — no signing config found. Configure ' +
717
+ 'defaultSigning in config.json to enable S1 attestations.',
718
+ );
719
+ } else if (signingCtx.skip) {
720
+ log.warn(`publishing unsigned — ${signingCtx.reason}`);
721
+ } else {
722
+ try {
723
+ const att = {
724
+ name, version, profileHash,
725
+ ...(usePerConfig
726
+ ? {
727
+ artefactSha256: Object.fromEntries(
728
+ Object.entries(artefactConfigs).map(([cfg, v]) => [cfg, v.sha256]),
729
+ ),
730
+ }
731
+ : { contentSha256 }),
732
+ manifestSha256,
733
+ publishedAt: new Date().toISOString(),
734
+ publisherKeyId: signingCtx.keyId,
735
+ uploadedBy: null,
736
+ };
737
+ const result = signAttestation(att, signingCtx);
738
+ attestationFilePath = path.join(
739
+ os.tmpdir(),
740
+ `wyvrnpm-att-${name}-${version}-${Date.now()}.json`,
741
+ );
742
+ signatureFilePath = path.join(
743
+ os.tmpdir(),
744
+ `wyvrnpm-sig-${name}-${version}-${Date.now()}.json`,
745
+ );
746
+ fs.writeFileSync(attestationFilePath, result.attBytes);
747
+ fs.writeFileSync(signatureFilePath, serializeSigEnvelope(result.sigEnvelope));
748
+ signed = true;
749
+ signerKeyId = signingCtx.keyId;
750
+ log.info(`Signed by : ${signingCtx.keyId} (ed25519)`);
751
+ } catch (err) {
752
+ log.error(`signing failed: ${err.message}`);
753
+ process.exit(1);
754
+ }
755
+ }
481
756
 
482
757
  // ── Dry run: print the plan, skip the actual upload ──────────────────
483
758
  // Mirrors `npm publish --dry-run` — every step above (profile, options,
484
- // zip, hash, v2Exists) runs; only the provider write is skipped.
759
+ // zip, hash, v2Exists, sign) runs; only the provider write is skipped.
485
760
  if (dryRun) {
486
761
  const plan = planPublishUrls({
487
- source, name, version, profileHash, gitSha, gitRepo, updateLatest: true,
762
+ source, name, version, profileHash, gitSha, gitRepo, updateLatest: true, signed,
763
+ sliceConfigs: usePerConfig ? Object.keys(artefactConfigs) : null,
488
764
  });
489
765
 
766
+ // ── Materialise artefacts to a local inspection folder ─────────────
767
+ // CLI override (`--dry-run-out`) wins; otherwise default to a
768
+ // sibling of the install tree so it lives under the existing
769
+ // gitignored build dir and is wiped by `wyvrnpm clean`.
770
+ const dryRunOutDir = (() => {
771
+ if (typeof argv.dryRunOut === 'string' && argv.dryRunOut.length > 0) {
772
+ return path.isAbsolute(argv.dryRunOut)
773
+ ? argv.dryRunOut
774
+ : path.join(srcDir, argv.dryRunOut);
775
+ }
776
+ return path.join(srcDir, publishBinaryDirRoot, `wyvrn-${activeProfileName}`, 'dry-run-publish');
777
+ })();
778
+ let dryRunWritten = null;
779
+ try {
780
+ dryRunWritten = writeDryRunArtefacts({
781
+ outDir: dryRunOutDir,
782
+ manifestPath: tmpManifestPath,
783
+ zipPath: usePerConfig ? null : tmpZipPath,
784
+ artefactZips: usePerConfig ? artefactZips : null,
785
+ attPath: attestationFilePath,
786
+ sigPath: signatureFilePath,
787
+ });
788
+ } catch (err) {
789
+ log.error(`could not materialise dry-run artefacts to ${dryRunOutDir}: ${err.message}`);
790
+ process.exit(1);
791
+ }
792
+
490
793
  if (!jsonOut) {
491
794
  console.log();
492
795
  console.log(`Would publish ${name}@${version} [${profileHash}] to ${source}`);
493
- console.log(` contentSha256 : ${contentSha256}`);
494
- console.log(` zip size : ${formatBytes(zipBytes)}`);
796
+ if (usePerConfig) {
797
+ console.log(` per-config slices:`);
798
+ for (const cfg of Object.keys(artefactConfigs).sort()) {
799
+ const { sha256, sizeBytes } = artefactConfigs[cfg];
800
+ console.log(` ${cfg.padEnd(15)} ${formatBytes(sizeBytes).padStart(10)} sha=${sha256.slice(0, 16)}...`);
801
+ }
802
+ console.log(` total bytes : ${formatBytes(zipBytes)}`);
803
+ } else {
804
+ console.log(` contentSha256 : ${contentSha256}`);
805
+ console.log(` zip size : ${formatBytes(zipBytes)}`);
806
+ }
807
+ console.log(` manifestSha256: ${manifestSha256}`);
495
808
  console.log(` manifest size : ${formatBytes(manifestBytes)}`);
496
- if (v2AlreadyExists) {
809
+ console.log(` signing : ${signed ? `signed (${signerKeyId}, ed25519)` : 'unsigned'}`);
810
+ if (v2AlreadyExists === true) {
497
811
  console.log(` overwrite : YES — ${force ? '--force set, would proceed' : 'NOT SET, real publish would refuse'}`);
812
+ } else if (v2AlreadyExists === null) {
813
+ console.log(` overwrite : UNKNOWN — existence check skipped (no credentials)`);
498
814
  }
499
815
  console.log();
500
816
  console.log('Registry writes:');
@@ -502,6 +818,11 @@ async function publish(argv) {
502
818
  console.log(` → ${p} (${kind})`);
503
819
  }
504
820
  console.log();
821
+ console.log(`Artefacts written for inspection: ${dryRunWritten.outDir}`);
822
+ for (const { name: f, sizeBytes } of dryRunWritten.files) {
823
+ console.log(` ${f.padEnd(28)} ${formatBytes(sizeBytes).padStart(10)}`);
824
+ }
825
+ console.log();
505
826
  log.success('Dry run OK — nothing uploaded.');
506
827
  } else {
507
828
  const payload = {
@@ -512,17 +833,27 @@ async function publish(argv) {
512
833
  version,
513
834
  profileHash,
514
835
  source,
515
- contentSha256,
836
+ contentSha256: usePerConfig ? null : contentSha256,
837
+ artefactConfigs: usePerConfig ? artefactConfigs : null,
838
+ manifestSha256,
516
839
  zipBytes,
517
840
  manifestBytes,
518
841
  options: effectiveOptions ?? null,
519
842
  gitSha: gitSha ?? null,
520
843
  gitRepo: gitRepo ?? null,
521
- wouldOverwrite: Boolean(v2AlreadyExists),
844
+ // null when the existence check failed (e.g. expired SSO token
845
+ // under --dry-run); CI scripts must distinguish "would overwrite"
846
+ // from "couldn't tell" to avoid false-greens.
847
+ wouldOverwrite: v2AlreadyExists === null ? null : Boolean(v2AlreadyExists),
522
848
  forceSet: Boolean(force),
523
849
  publishedLock,
524
850
  publishedConf: Object.keys(publishedConf).length > 0 ? unflattenConf(publishedConf) : null,
851
+ signing: signed
852
+ ? { signed: true, keyId: signerKeyId, alg: 'ed25519' }
853
+ : { signed: false, keyId: null, alg: null },
525
854
  plan,
855
+ dryRunOut: dryRunWritten.outDir,
856
+ dryRunFiles: dryRunWritten.files,
526
857
  };
527
858
  process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
528
859
  }
@@ -532,7 +863,15 @@ async function publish(argv) {
532
863
  // ── v2 publish ────────────────────────────────────────────────────────
533
864
  log.info('Publishing (v2) ...');
534
865
  await provider.v2Publish(
535
- { manifest: tmpManifestPath, zip: tmpZipPath },
866
+ {
867
+ manifest: tmpManifestPath,
868
+ // Slice publishes pass artefactZips; fat-zip publishes pass `zip`.
869
+ ...(usePerConfig ? { artefactZips } : { zip: tmpZipPath }),
870
+ // S1 — both undefined when publishing unsigned; provider skips
871
+ // the writes entirely so the v2 layout is byte-identical to today.
872
+ ...(attestationFilePath ? { attPath: attestationFilePath } : {}),
873
+ ...(signatureFilePath ? { sigPath: signatureFilePath } : {}),
874
+ },
536
875
  {
537
876
  source,
538
877
  name,
@@ -542,7 +881,7 @@ async function publish(argv) {
542
881
  buildSettings: effectiveOptions
543
882
  ? { ...buildProfile, options: effectiveOptions }
544
883
  : buildProfile,
545
- contentSha256,
884
+ ...(usePerConfig ? { artefactConfigs } : { contentSha256 }),
546
885
  gitSha,
547
886
  gitRepo,
548
887
  publishedLock,
@@ -551,7 +890,10 @@ async function publish(argv) {
551
890
  },
552
891
  );
553
892
 
554
- log.success(`Successfully published ${name}@${version} [${profileHash}] to ${source}`);
893
+ log.success(
894
+ `Successfully published ${name}@${version} [${profileHash}] to ${source}` +
895
+ (signed ? ` (signed by ${signerKeyId})` : ''),
896
+ );
555
897
 
556
898
  if (jsonOut) {
557
899
  const payload = {
@@ -561,17 +903,27 @@ async function publish(argv) {
561
903
  version,
562
904
  profileHash,
563
905
  source,
564
- contentSha256,
906
+ contentSha256: usePerConfig ? null : contentSha256,
907
+ artefactConfigs: usePerConfig ? artefactConfigs : null,
908
+ manifestSha256,
565
909
  options: effectiveOptions ?? null,
566
910
  gitSha: gitSha ?? null,
567
911
  gitRepo: gitRepo ?? null,
568
912
  publishedAt: new Date().toISOString(),
913
+ signing: signed
914
+ ? { signed: true, keyId: signerKeyId, alg: 'ed25519' }
915
+ : { signed: false, keyId: null, alg: null },
569
916
  };
570
917
  process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
571
918
  }
572
919
  } finally {
573
- if (fs.existsSync(tmpZipPath)) fs.unlinkSync(tmpZipPath);
574
- if (fs.existsSync(tmpManifestPath)) fs.unlinkSync(tmpManifestPath);
920
+ if (fs.existsSync(tmpZipPath)) fs.unlinkSync(tmpZipPath);
921
+ if (fs.existsSync(tmpManifestPath)) fs.unlinkSync(tmpManifestPath);
922
+ if (attestationFilePath && fs.existsSync(attestationFilePath)) fs.unlinkSync(attestationFilePath);
923
+ if (signatureFilePath && fs.existsSync(signatureFilePath)) fs.unlinkSync(signatureFilePath);
924
+ for (const slicePath of sliceTmpPaths) {
925
+ if (fs.existsSync(slicePath)) fs.unlinkSync(slicePath);
926
+ }
575
927
  }
576
928
  }
577
929
 
@@ -581,4 +933,5 @@ module.exports.addFolderFiltered = addFolderFiltered;
581
933
  module.exports.addInstallTree = addInstallTree;
582
934
  module.exports.collectInstallTreeFiles = collectInstallTreeFiles;
583
935
  module.exports.planPublishUrls = planPublishUrls;
936
+ module.exports.writeDryRunArtefacts = writeDryRunArtefacts;
584
937
  module.exports.formatBytes = formatBytes;
@@ -130,10 +130,26 @@ async function show(argv) {
130
130
  }
131
131
 
132
132
  let meta = null;
133
+ let signing = { signed: false, keyId: null, alg: null };
133
134
  if (deepFetch) {
134
135
  meta = await provider.v2GetMeta({
135
136
  source, name, version: v, profileHash: hash, awsProfile, token,
136
137
  });
138
+ // S1: surface signer identity per profile when an attestation
139
+ // exists. We don't fully VERIFY here (`wyvrnpm show` is a
140
+ // read-only registry query, not a download path) — just parse
141
+ // the envelope so the user knows whose signature shipped.
142
+ // Verification still runs on `wyvrnpm install` per mode.json.
143
+ try {
144
+ const sigBytes = await provider.v2GetSignature({
145
+ source, name, version: v, profileHash: hash, awsProfile, token,
146
+ });
147
+ if (sigBytes) {
148
+ const env = JSON.parse(sigBytes.toString('utf8'));
149
+ signing = { signed: true, keyId: env.keyId ?? null, alg: env.alg ?? null };
150
+ }
151
+ } catch { /* malformed sig is non-fatal here — install will catch */ }
152
+
137
153
  if (!jsonOut) {
138
154
  if (meta?.uploadedBy) {
139
155
  console.log(` origin : consumer upload (${meta.uploadedBy.kind})`);
@@ -159,6 +175,12 @@ async function show(argv) {
159
175
  if (meta?.publishedConf && Object.keys(meta.publishedConf).length > 0) {
160
176
  console.log(` conf : ${JSON.stringify(meta.publishedConf)}`);
161
177
  }
178
+ // S1 — surface signer (informational; install still verifies).
179
+ if (signing.signed) {
180
+ console.log(` signed : ${signing.keyId} [${signing.alg}]`);
181
+ } else if (deepFetch) {
182
+ console.log(` signed : (unsigned)`);
183
+ }
162
184
  }
163
185
  }
164
186
 
@@ -172,6 +194,7 @@ async function show(argv) {
172
194
  compatibility: meta?.compatibility ?? null,
173
195
  declaredOptions: meta?.options ?? null,
174
196
  publishedConf: meta?.publishedConf ?? null,
197
+ signing,
175
198
  });
176
199
  }
177
200