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.
- package/README.md +163 -2
- package/bin/wyvrnpm.js +167 -11
- package/cmake/cpp.cmake +6 -8
- package/package.json +1 -1
- package/src/build/recipe.js +25 -3
- package/src/commands/configure.js +61 -1
- package/src/commands/install.js +76 -1
- package/src/commands/key.js +112 -0
- package/src/commands/publish.js +402 -49
- package/src/commands/show.js +23 -0
- package/src/commands/trust.js +136 -0
- package/src/conf/index.js +131 -11
- package/src/config.js +35 -4
- package/src/download.js +408 -11
- package/src/providers/base.js +110 -15
- package/src/providers/file.js +90 -29
- package/src/providers/http.js +109 -32
- package/src/providers/s3.js +103 -31
- package/src/publish/slice.js +414 -0
- package/src/signing/attestation.js +301 -0
- package/src/signing/canonical.js +80 -0
- package/src/signing/ed25519.js +123 -0
- package/src/signing/index.js +201 -0
- package/src/signing/resolve-context.js +73 -0
- package/src/signing/trust.js +215 -0
- package/src/toolchain/template.cmake +7 -5
- package/src/upload-built.js +88 -14
- package/src/v2-meta.js +98 -0
package/src/commands/publish.js
CHANGED
|
@@ -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:
|
|
49
|
-
* name:
|
|
50
|
-
* version:
|
|
51
|
-
* profileHash:
|
|
52
|
-
* gitSha?:
|
|
53
|
-
* gitRepo?:
|
|
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:
|
|
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
|
-
|
|
68
|
-
|
|
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
|
-
|
|
287
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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
|
-
|
|
494
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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(
|
|
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))
|
|
574
|
-
if (fs.existsSync(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;
|
package/src/commands/show.js
CHANGED
|
@@ -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
|
|