wyvrnpm 2.12.3 → 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 +162 -1
- package/bin/wyvrnpm.js +167 -11
- 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/upload-built.js +88 -14
- package/src/v2-meta.js +98 -0
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ A simple, private C++ package manager that works with any static file hosting pr
|
|
|
4
4
|
|
|
5
5
|
There is no central registry. You control where packages are hosted.
|
|
6
6
|
|
|
7
|
-
> **README version: 2.
|
|
7
|
+
> **README version: 2.13.0** — **New:** S1 artefact signing — Ed25519 detached signatures over a canonical attestation, verified against a per-registry `.trust/keys.json`; `wyvrnpm key gen / show-pub`, `wyvrnpm trust list / refresh`, `wyvrnpm configure signing set-default / show / unset`; `--require-signatures` on install, `--no-sign` / `--signing-key-env` / `--signing-key-file` on publish; pure-Node `crypto` (no external tooling, identical on Windows / Linux / macOS). Closes S1 + S3. Highlights since 2.8.3: profile's `compiler.cppstd` is now authoritative for the toolchain — `CMAKE_CXX_STANDARD` is set unconditionally before `project()` and `cpp.cmake` no longer touches it post-`project()`, so a `-DCMAKE_CXX_STANDARD=23` on the cmake CLI cannot defeat a `cppstd=20` profile; `wyvrnpm install` exits non-zero on partial failure (sha256-mismatch, extract-failed, no-exact-match, source-build-failed, not-found) and prints a per-dep failure summary instead of misreporting "Done — N installed"; streaming publish via `archiver` (handles >2 GiB artefacts that `adm-zip` choked on — e.g. gRPC's `RelWithDebInfo` static libs); `--build-dir <path>` + `wyvrn.local.json:binaryDirRoot` to sidestep `build/` case-insensitive-FS collisions on repos with a Bazel/Buck `BUILD` file at the root (gRPC, …); `wyvrnpm version` to read / set / partially update the top-level `version` field from CI pipelines (`wyvrnpm version --build "$BUILD_ID"`); `wyvrnpm bootstrap <git-url>` to scaffold a first-draft `wyvrn.json` for OSS C++ libraries (fmt, spdlog, zlib, …) with cookbook-driven defaults; `wyvrnpm publish --dry-run` (npm-style preview of the upload plan, plus `wouldOverwrite` flag in `--format json`); `wyvrnpm build --config Debug,Release` / `--all-configs` to build multiple configurations from a single configure; resolver hardened against the CloudFront brotli-vs-identity cache split (resolved a class of bugs where `show` listed a freshly-published version that `install` claimed didn't exist). Full list in `claude/EVALUATION.md`.
|
|
8
8
|
|
|
9
9
|
---
|
|
10
10
|
|
|
@@ -335,6 +335,7 @@ wyvrnpm install --dry-run --format=json
|
|
|
335
335
|
| `--dry-run` | `false` | Run the resolver + profile/options math, print the install plan, and exit. No download, no `wyvrn.lock`, no `wyvrn_internal/`, no toolchain. Combine with `--format=json` for CI ingestion (payload carries `command: "install-dry-run"`). |
|
|
336
336
|
| `--conf` | — | Non-ABI build-time override. Repeatable. Format: `<namespace.leaf>=<value>` — e.g. `--conf cmake.cache.CHROMA_ENABLE_TEST=ON`. Layered: CLI > `wyvrn.local.json` > named-profile `conf` > recipe. Does **not** fold into `profileHash`. See [Build-time configuration (`conf`)](#build-time-configuration-conf). |
|
|
337
337
|
| `--build-dir` | `build` | Project-relative subdir for the generated preset's `binaryDir` (`${sourceDir}/<root>/wyvrn-<profile>`). Use when the repo has a `BUILD` Bazel/Buck file at root that collides with `build/` on case-insensitive filesystems (gRPC, …). Persistent override: set `binaryDirRoot` in `wyvrn.local.json`. Validated — must be relative, no `..` segments. |
|
|
338
|
+
| `--require-signatures` | `false` | Force S1 signature verification on every dep regardless of the registry's `mode.json`. Equivalent to `mode=require` for this run — useful for hardened CI. See [Signing & verification](#signing--verification-s1-2130). |
|
|
338
339
|
|
|
339
340
|
Packages are extracted to `wyvrn_internal/{name}/`. A `wyvrn.lock` file is written alongside the manifest recording the exact resolved versions, profile, and per-package SHA256. On subsequent installs the lock file pins all previously resolved versions — only newly added dependencies are resolved fresh.
|
|
340
341
|
|
|
@@ -497,11 +498,15 @@ wyvrnpm publish --dry-run --format json | jq '.wouldOverwrite, .plan'
|
|
|
497
498
|
| `--manifest` | `./wyvrn.json` | Path to the manifest file. |
|
|
498
499
|
| `--force` / `-f` | `false` | Overwrite an existing published version (same version + profile hash). |
|
|
499
500
|
| `--dry-run` | `false` | Run every step (profile, options, zip, SHA256, `v2Exists` check) and print the upload plan, but skip the provider write. An existing published version is reported as a warning (not an error) so the full plan still prints. Pair with `--format json` to gate CI on `wouldOverwrite`. |
|
|
501
|
+
| `--no-sign` | `false` | Publish unsigned even when `defaultSigning` is configured. Logs a warning. Useful for local testing — never recommended in CI. See `## Signing & verification`. |
|
|
502
|
+
| `--signing-key-env` | *(config)* | Override the env var holding the publisher private key for this run. Inherits `keyId` from `defaultSigning` / per-source `signing`. |
|
|
503
|
+
| `--signing-key-file` | *(config)* | Override the file holding the publisher private key for this run. Inherits `keyId` from `defaultSigning` / per-source `signing`. |
|
|
500
504
|
|
|
501
505
|
During publish the tool also:
|
|
502
506
|
- reads `wyvrn.lock` to pin locked dependency versions into the uploaded manifest
|
|
503
507
|
- captures the current git commit SHA and remote URL as metadata
|
|
504
508
|
- computes a SHA256 of the zip and stores it in the upload metadata
|
|
509
|
+
- when signing is configured, signs an Ed25519 attestation over `(name, version, profileHash, contentSha256, manifestSha256, publisherKeyId, publishedAt)` and uploads `wyvrn.att.json` + `wyvrn.sig` alongside the zip. See `## Signing & verification`.
|
|
505
510
|
|
|
506
511
|
---
|
|
507
512
|
|
|
@@ -871,6 +876,61 @@ wyvrnpm configure profile delete msvc_release
|
|
|
871
876
|
| `--cppstd` | C++ standard (`14` \| `17` \| `20` \| `23`). |
|
|
872
877
|
| `--runtime` | Runtime linkage (`static` \| `dynamic`). |
|
|
873
878
|
|
|
879
|
+
#### `wyvrnpm configure signing` (S1, 2.13.0+)
|
|
880
|
+
|
|
881
|
+
Manages the publisher signing context for `wyvrnpm publish`. Wyvrnpm never stores the private key — only the retrieval path (env-var name or file path).
|
|
882
|
+
|
|
883
|
+
```bash
|
|
884
|
+
# Persist the keyId + retrieval path. CI form:
|
|
885
|
+
wyvrnpm configure signing set-default --key-id razer-2026 --key-env WYVRNPM_SIGNING_KEY
|
|
886
|
+
|
|
887
|
+
# Local-dev form:
|
|
888
|
+
wyvrnpm configure signing set-default --key-id razer-2026 --key-file ~/.wyvrnpm/keys/razer-2026.priv.pem
|
|
889
|
+
|
|
890
|
+
wyvrnpm configure signing show
|
|
891
|
+
wyvrnpm configure signing unset
|
|
892
|
+
```
|
|
893
|
+
|
|
894
|
+
See [Signing & verification](#signing--verification-s1-2130) for the full design.
|
|
895
|
+
|
|
896
|
+
---
|
|
897
|
+
|
|
898
|
+
### `wyvrnpm key` (S1, 2.13.0+)
|
|
899
|
+
|
|
900
|
+
Generate / inspect publisher signing keys. Pure-Node Ed25519, no external tooling required.
|
|
901
|
+
|
|
902
|
+
```bash
|
|
903
|
+
# Generate a fresh keypair. Refuses to overwrite existing files.
|
|
904
|
+
wyvrnpm key gen --out ./keys --id razer-2026
|
|
905
|
+
# → ./keys/razer-2026.priv.pem (PKCS#8 PEM, mode 0600 on POSIX)
|
|
906
|
+
# → ./keys/razer-2026.pub.pem (SPKI PEM, paste into trust anchor)
|
|
907
|
+
# → ./keys/razer-2026.pub.b64 (base64 SPKI DER, the literal value for keys.json)
|
|
908
|
+
|
|
909
|
+
# Re-derive the public key from a private key file (read-only sanity check).
|
|
910
|
+
wyvrnpm key show-pub --in ./keys/razer-2026.priv.pem
|
|
911
|
+
```
|
|
912
|
+
|
|
913
|
+
Pair with `wyvrnpm configure signing set-default` to persist the keyId + retrieval path locally, then add the printed `keys.json` block to the registry's `.trust/keys.json` to authorise the publisher.
|
|
914
|
+
|
|
915
|
+
---
|
|
916
|
+
|
|
917
|
+
### `wyvrnpm trust` (S1, 2.13.0+)
|
|
918
|
+
|
|
919
|
+
Inspect or refresh the locally-cached per-registry trust anchor (`.trust/keys.json` + `.trust/mode.json`).
|
|
920
|
+
|
|
921
|
+
```bash
|
|
922
|
+
# Show what's cached locally (read-only — no fetch).
|
|
923
|
+
wyvrnpm trust list --source corp-s3
|
|
924
|
+
|
|
925
|
+
# Fetch fresh from the registry. Pure additions accepted silently;
|
|
926
|
+
# removals or modifications of an existing keyId fail closed unless
|
|
927
|
+
# --accept-breaking is passed (TOFU phase 1).
|
|
928
|
+
wyvrnpm trust refresh --source corp-s3
|
|
929
|
+
wyvrnpm trust refresh --source corp-s3 --accept-breaking
|
|
930
|
+
```
|
|
931
|
+
|
|
932
|
+
`--source` accepts a configured-source name or a literal URL.
|
|
933
|
+
|
|
874
934
|
---
|
|
875
935
|
|
|
876
936
|
## JSON output (`--format json`)
|
|
@@ -1016,6 +1076,107 @@ The `linkedPackages` field stores globally registered packages for `wyvrnpm link
|
|
|
1016
1076
|
|
|
1017
1077
|
---
|
|
1018
1078
|
|
|
1079
|
+
## Signing & verification (S1, 2.13.0+)
|
|
1080
|
+
|
|
1081
|
+
wyvrnpm signs published artefacts with **Ed25519** detached signatures and verifies them at install time against a per-registry trust anchor. The signing layer is pure-Node (`crypto.sign` / `crypto.verify` from the built-in module) — no external tooling, no shell-outs, identical behaviour on Windows / Linux / macOS.
|
|
1082
|
+
|
|
1083
|
+
> **Why this matters.** Without signing, `contentSha256` proves transit integrity *against itself* — a stolen S3 credential can republish a trojanised zip and update the SHA in `versions.json` to match. Signing pins the publisher's identity to a registry-side trust anchor (`.trust/keys.json`) that an attacker with bucket-write access cannot rewrite without also holding the publisher's private key.
|
|
1084
|
+
|
|
1085
|
+
### Threat model
|
|
1086
|
+
|
|
1087
|
+
| Protects against | Does *not* protect against |
|
|
1088
|
+
|---|---|
|
|
1089
|
+
| Stolen registry credentials (S3 / HTTP token) | A trusted signer publishing maliciously |
|
|
1090
|
+
| Mirror impersonation / MitM | Compromise of the publisher's signing key (use rotation) |
|
|
1091
|
+
| Consumer-upload masquerade (`--upload-built` attribution) | First-contact TOFU bootstrapping (phase 1; closed by phase 2 root-pinning) |
|
|
1092
|
+
| URL-swap attacks (a sig moved between profiles) | Malicious code that's correctly signed by an authorised publisher |
|
|
1093
|
+
|
|
1094
|
+
### Quick start (publisher)
|
|
1095
|
+
|
|
1096
|
+
```bash
|
|
1097
|
+
# 1. Generate an Ed25519 keypair
|
|
1098
|
+
wyvrnpm key gen --out ./keys --id razer-2026
|
|
1099
|
+
|
|
1100
|
+
# 2. Persist the retrieval path. Wyvrnpm never stores the private key
|
|
1101
|
+
# itself — only the env-var name (preferred for CI) or the file path.
|
|
1102
|
+
export WYVRNPM_SIGNING_KEY=$(cat ./keys/razer-2026.priv.pem)
|
|
1103
|
+
wyvrnpm configure signing set-default --key-id razer-2026 --key-env WYVRNPM_SIGNING_KEY
|
|
1104
|
+
|
|
1105
|
+
# 3. Add the public half to the registry's trust anchor — exactly once,
|
|
1106
|
+
# out-of-band. The block printed by `key gen` is the literal value to
|
|
1107
|
+
# drop into {source}/v2/.trust/keys.json. Set mode to "warn" while
|
|
1108
|
+
# rolling out signing across publishers; flip to "require" once every
|
|
1109
|
+
# publisher is on board.
|
|
1110
|
+
|
|
1111
|
+
# 4. Publish — signing happens automatically when defaultSigning is set.
|
|
1112
|
+
wyvrnpm publish --source corp-s3
|
|
1113
|
+
# → signs wyvrn.att.json + wyvrn.sig and uploads them alongside the zip
|
|
1114
|
+
```
|
|
1115
|
+
|
|
1116
|
+
### Quick start (consumer)
|
|
1117
|
+
|
|
1118
|
+
Consumers don't have to do anything when the registry is in `mode: warn` — installs proceed and signed artefacts log `signed by <keyId> (<identityHint>)`. To check what's cached locally:
|
|
1119
|
+
|
|
1120
|
+
```bash
|
|
1121
|
+
wyvrnpm trust list --source corp-s3
|
|
1122
|
+
wyvrnpm trust refresh --source corp-s3 # pull latest .trust/keys.json
|
|
1123
|
+
```
|
|
1124
|
+
|
|
1125
|
+
To force strict verification regardless of registry mode (e.g., hardened CI):
|
|
1126
|
+
|
|
1127
|
+
```bash
|
|
1128
|
+
wyvrnpm install --require-signatures
|
|
1129
|
+
```
|
|
1130
|
+
|
|
1131
|
+
### Modes (`{source}/v2/.trust/mode.json`)
|
|
1132
|
+
|
|
1133
|
+
| `mode` | missing sig | invalid sig |
|
|
1134
|
+
|---|---|---|
|
|
1135
|
+
| `off` (default — no `.trust/` dir) | accept silently | n/a |
|
|
1136
|
+
| `warn` (phase-1 default) | accept with one-line warning | **abort** (bad sig is stricter than missing) |
|
|
1137
|
+
| `require` (phase 2+) | abort | abort |
|
|
1138
|
+
|
|
1139
|
+
### Trust-anchor rotation (TOFU)
|
|
1140
|
+
|
|
1141
|
+
Phase 1 uses trust-on-first-use — the first `wyvrn install` against a registry caches `keys.json` locally at `%LOCALAPPDATA%\wyvrnpm\trust\<source-hash>\`. Subsequent fetches diff against the cache:
|
|
1142
|
+
|
|
1143
|
+
- **Pure additions** → accepted silently (the common rotation event: bring on a new publisher).
|
|
1144
|
+
- **Removals or modifications** of an existing key → fail closed; install aborts and prompts for `wyvrnpm trust refresh --accept-breaking`.
|
|
1145
|
+
|
|
1146
|
+
Phase 2 will allow pinning a root key in `installSources[].trustRootPub` so the registry's `keys.json` itself carries a root signature — closes the TOFU window.
|
|
1147
|
+
|
|
1148
|
+
### What the attestation binds
|
|
1149
|
+
|
|
1150
|
+
Each signed artefact gets a `wyvrn.att.json` body:
|
|
1151
|
+
|
|
1152
|
+
```json
|
|
1153
|
+
{
|
|
1154
|
+
"wyvrnAttestationVersion": 1,
|
|
1155
|
+
"name": "demo",
|
|
1156
|
+
"version": "1.0.0.0",
|
|
1157
|
+
"profileHash": "a1b2c3d4e5f60718",
|
|
1158
|
+
"contentSha256": "<sha256 of wyvrn.zip>",
|
|
1159
|
+
"manifestSha256": "<sha256 of canonical wyvrn.json>",
|
|
1160
|
+
"publishedAt": "2026-04-29T12:34:56.789Z",
|
|
1161
|
+
"publisherKeyId": "razer-2026",
|
|
1162
|
+
"uploadedBy": null
|
|
1163
|
+
}
|
|
1164
|
+
```
|
|
1165
|
+
|
|
1166
|
+
Plus a sidecar `wyvrn.sig`:
|
|
1167
|
+
|
|
1168
|
+
```json
|
|
1169
|
+
{ "alg": "ed25519", "keyId": "razer-2026", "sig": "<base64 64-byte signature>" }
|
|
1170
|
+
```
|
|
1171
|
+
|
|
1172
|
+
The verifier checks the cryptographic signature, then asserts `(name, version, profileHash, contentSha256, manifestSha256)` match what the consumer downloaded. Dropping a valid attestation for `pkg-A` into `pkg-B`'s URL fails the bound-identity check; swapping the zip after publish fails the bound-`contentSha256` check; rewriting the manifest fails the bound-`manifestSha256` check.
|
|
1173
|
+
|
|
1174
|
+
### Backward compatibility
|
|
1175
|
+
|
|
1176
|
+
A registry without a `.trust/` directory behaves as `mode: off` — pre-S1 clients ignore the new files entirely, S1 clients install identically to today. Pre-S1 buckets need *zero* migration to keep working.
|
|
1177
|
+
|
|
1178
|
+
---
|
|
1179
|
+
|
|
1019
1180
|
## Publish Providers
|
|
1020
1181
|
|
|
1021
1182
|
wyvrnpm uses a provider architecture to support different destination types. The correct provider is selected automatically based on the source URL format.
|
package/bin/wyvrnpm.js
CHANGED
|
@@ -16,6 +16,8 @@ const show = require('../src/commands/show');
|
|
|
16
16
|
const installSkill = require('../src/commands/install-skill');
|
|
17
17
|
const cache = require('../src/commands/cache');
|
|
18
18
|
const bootstrap = require('../src/commands/bootstrap');
|
|
19
|
+
const keyCmd = require('../src/commands/key');
|
|
20
|
+
const trustCmd = require('../src/commands/trust');
|
|
19
21
|
const { link, unlink } = require('../src/commands/link');
|
|
20
22
|
|
|
21
23
|
yargs
|
|
@@ -281,16 +283,34 @@ yargs
|
|
|
281
283
|
choices: ['text', 'json'],
|
|
282
284
|
default: 'text',
|
|
283
285
|
description: 'Output format. "json" emits a single JSON object on stdout; log lines go to stderr.',
|
|
286
|
+
})
|
|
287
|
+
.option('require-signatures', {
|
|
288
|
+
type: 'boolean',
|
|
289
|
+
default: false,
|
|
290
|
+
description:
|
|
291
|
+
'Force signature verification on every dep regardless of the ' +
|
|
292
|
+
'registry\'s mode.json. Equivalent to mode=require for this run. ' +
|
|
293
|
+
'Useful for hardened CI where unsigned artefacts must always block.',
|
|
294
|
+
})
|
|
295
|
+
.option('request-configs', {
|
|
296
|
+
type: 'string',
|
|
297
|
+
description:
|
|
298
|
+
'Comma-separated CMake configs to fetch from per-config-published deps ' +
|
|
299
|
+
'(e.g. "Release" or "Release,Debug"). Highest precedence; overrides ' +
|
|
300
|
+
'wyvrn.local.json:requestConfigs and the consumer\'s build.configs. ' +
|
|
301
|
+
'Ignored for deps published as a fat zip (today\'s default).',
|
|
284
302
|
});
|
|
285
303
|
},
|
|
286
304
|
(argv) => install({
|
|
287
305
|
...argv,
|
|
288
|
-
uploadBuilt:
|
|
289
|
-
uploadSource:
|
|
290
|
-
awsProfile:
|
|
291
|
-
tokenEnv:
|
|
292
|
-
dryRun:
|
|
293
|
-
buildDir:
|
|
306
|
+
uploadBuilt: argv['upload-built'],
|
|
307
|
+
uploadSource: argv['upload-source'],
|
|
308
|
+
awsProfile: argv['aws-profile'],
|
|
309
|
+
tokenEnv: argv['token-env'],
|
|
310
|
+
dryRun: argv['dry-run'],
|
|
311
|
+
buildDir: argv['build-dir'],
|
|
312
|
+
requireSignatures: argv['require-signatures'],
|
|
313
|
+
requestConfigs: argv['request-configs'],
|
|
294
314
|
}),
|
|
295
315
|
)
|
|
296
316
|
|
|
@@ -593,7 +613,19 @@ yargs
|
|
|
593
613
|
'but skip the provider write. An existing published version is ' +
|
|
594
614
|
'reported as a warning instead of failing. Pair with --format json ' +
|
|
595
615
|
'to script bulk publish validation (plan + wouldOverwrite flag ' +
|
|
596
|
-
'land in stdout).'
|
|
616
|
+
'land in stdout). On --dry-run, the freshly-built artefacts ' +
|
|
617
|
+
'(wyvrn[-<Config>].zip, wyvrn.json, wyvrn.att.json, wyvrn.sig) ' +
|
|
618
|
+
'are also copied to a local inspection folder (default ' +
|
|
619
|
+
'`<srcDir>/<binaryDirRoot>/wyvrn-<profile>/dry-run-publish/`; ' +
|
|
620
|
+
'override with --dry-run-out).',
|
|
621
|
+
})
|
|
622
|
+
.option('dry-run-out', {
|
|
623
|
+
type: 'string',
|
|
624
|
+
description:
|
|
625
|
+
'Override the directory where --dry-run materialises the would-' +
|
|
626
|
+
'be-uploaded artefacts. Relative paths resolve against the project ' +
|
|
627
|
+
'root. Existing contents are wiped and replaced. No-op without ' +
|
|
628
|
+
'--dry-run.',
|
|
597
629
|
})
|
|
598
630
|
.option('option', {
|
|
599
631
|
alias: 'o',
|
|
@@ -628,14 +660,38 @@ yargs
|
|
|
628
660
|
default: 'text',
|
|
629
661
|
description: 'Output format. "json" emits a single JSON object on stdout; log lines go to stderr.',
|
|
630
662
|
})
|
|
663
|
+
// ── S1 signing ────────────────────────────────────────────────
|
|
664
|
+
.option('no-sign', {
|
|
665
|
+
type: 'boolean',
|
|
666
|
+
default: false,
|
|
667
|
+
description:
|
|
668
|
+
'Publish unsigned even when defaultSigning is configured. Logs a ' +
|
|
669
|
+
'warning. Useful for local testing — never recommended in CI.',
|
|
670
|
+
})
|
|
671
|
+
.option('signing-key-env', {
|
|
672
|
+
type: 'string',
|
|
673
|
+
description:
|
|
674
|
+
'Override the env var holding the publisher private key (PKCS#8 PEM) ' +
|
|
675
|
+
'for this run. Inherits keyId from defaultSigning / per-source signing.',
|
|
676
|
+
})
|
|
677
|
+
.option('signing-key-file', {
|
|
678
|
+
type: 'string',
|
|
679
|
+
description:
|
|
680
|
+
'Override the file holding the publisher private key (PKCS#8 PEM) ' +
|
|
681
|
+
'for this run. Inherits keyId from defaultSigning / per-source signing.',
|
|
682
|
+
})
|
|
631
683
|
;
|
|
632
684
|
},
|
|
633
685
|
(argv) => publish({
|
|
634
686
|
...argv,
|
|
635
|
-
awsProfile:
|
|
636
|
-
tokenEnv:
|
|
637
|
-
dryRun:
|
|
638
|
-
|
|
687
|
+
awsProfile: argv['aws-profile'],
|
|
688
|
+
tokenEnv: argv['token-env'],
|
|
689
|
+
dryRun: argv['dry-run'],
|
|
690
|
+
dryRunOut: argv['dry-run-out'],
|
|
691
|
+
buildDir: argv['build-dir'],
|
|
692
|
+
noSign: argv['no-sign'],
|
|
693
|
+
signingKeyEnv: argv['signing-key-env'],
|
|
694
|
+
signingKeyFile: argv['signing-key-file'],
|
|
639
695
|
}),
|
|
640
696
|
)
|
|
641
697
|
|
|
@@ -769,6 +825,106 @@ yargs
|
|
|
769
825
|
() => {},
|
|
770
826
|
)
|
|
771
827
|
|
|
828
|
+
// --- signing (S1) ---
|
|
829
|
+
.command(
|
|
830
|
+
'signing',
|
|
831
|
+
'Configure publisher signing context for `wyvrnpm publish`',
|
|
832
|
+
(y2) => {
|
|
833
|
+
y2
|
|
834
|
+
.command(
|
|
835
|
+
'set-default',
|
|
836
|
+
'Persist defaultSigning (alg + keyId + privateKey retrieval path)',
|
|
837
|
+
(y3) => {
|
|
838
|
+
y3
|
|
839
|
+
.option('key-id', { type: 'string', demandOption: true,
|
|
840
|
+
description: 'Publisher keyId (must match an entry in the registry\'s .trust/keys.json)' })
|
|
841
|
+
.option('key-env', { type: 'string',
|
|
842
|
+
description: 'Env var name holding the PKCS#8 PEM private key' })
|
|
843
|
+
.option('key-file', { type: 'string',
|
|
844
|
+
description: 'Filesystem path to a PKCS#8 PEM private key file' });
|
|
845
|
+
},
|
|
846
|
+
(argv) => configure.signingSetDefault(argv),
|
|
847
|
+
)
|
|
848
|
+
.command('show', 'Show the current defaultSigning entry', () => {}, () => configure.signingShow())
|
|
849
|
+
.command('unset', 'Remove the defaultSigning entry from config.json', () => {}, () => configure.signingUnset())
|
|
850
|
+
.demandCommand(1)
|
|
851
|
+
.help();
|
|
852
|
+
},
|
|
853
|
+
() => {},
|
|
854
|
+
)
|
|
855
|
+
|
|
856
|
+
.demandCommand(1)
|
|
857
|
+
.help();
|
|
858
|
+
},
|
|
859
|
+
() => {},
|
|
860
|
+
)
|
|
861
|
+
|
|
862
|
+
// ── key (S1) ──────────────────────────────────────────────────────────────
|
|
863
|
+
.command(
|
|
864
|
+
'key',
|
|
865
|
+
'Generate / inspect publisher signing keys (Ed25519). Used for S1 artefact signing.',
|
|
866
|
+
(y) => {
|
|
867
|
+
y
|
|
868
|
+
.command(
|
|
869
|
+
'gen',
|
|
870
|
+
'Generate a fresh Ed25519 keypair (PKCS#8 PEM private + SPKI PEM public + base64 SPKI DER)',
|
|
871
|
+
(y2) => {
|
|
872
|
+
y2
|
|
873
|
+
.option('out', { type: 'string', demandOption: true, description: 'Output directory' })
|
|
874
|
+
.option('id', { type: 'string', default: 'wyvrnpm', description: 'Publisher keyId — also used as filename prefix' })
|
|
875
|
+
.option('format', { type: 'string', choices: ['text', 'json'], default: 'text' });
|
|
876
|
+
},
|
|
877
|
+
(argv) => keyCmd.gen(argv),
|
|
878
|
+
)
|
|
879
|
+
.command(
|
|
880
|
+
'show-pub',
|
|
881
|
+
'Re-derive the public key from a private key file (read-only)',
|
|
882
|
+
(y2) => {
|
|
883
|
+
y2
|
|
884
|
+
.option('in', { type: 'string', demandOption: true, description: 'Path to PKCS#8 PEM private key' })
|
|
885
|
+
.option('format', { type: 'string', choices: ['text', 'json'], default: 'text' });
|
|
886
|
+
},
|
|
887
|
+
(argv) => keyCmd.showPub(argv),
|
|
888
|
+
)
|
|
889
|
+
.demandCommand(1)
|
|
890
|
+
.help();
|
|
891
|
+
},
|
|
892
|
+
() => {},
|
|
893
|
+
)
|
|
894
|
+
|
|
895
|
+
// ── trust (S1) ────────────────────────────────────────────────────────────
|
|
896
|
+
.command(
|
|
897
|
+
'trust',
|
|
898
|
+
'Inspect / refresh the per-registry trust anchor (.trust/keys.json + .trust/mode.json)',
|
|
899
|
+
(y) => {
|
|
900
|
+
y
|
|
901
|
+
.command(
|
|
902
|
+
'list',
|
|
903
|
+
'List the locally-cached trust anchor for a registry',
|
|
904
|
+
(y2) => {
|
|
905
|
+
y2
|
|
906
|
+
.option('source', { alias: 's', type: 'string', description: 'Source URL or configured-source name' })
|
|
907
|
+
.option('format', { type: 'string', choices: ['text', 'json'], default: 'text' });
|
|
908
|
+
},
|
|
909
|
+
(argv) => trustCmd.list(argv),
|
|
910
|
+
)
|
|
911
|
+
.command(
|
|
912
|
+
'refresh',
|
|
913
|
+
'Fetch + cache the registry trust anchor; refuse non-additive diffs unless --accept-breaking',
|
|
914
|
+
(y2) => {
|
|
915
|
+
y2
|
|
916
|
+
.option('source', { alias: 's', type: 'string', description: 'Source URL or configured-source name' })
|
|
917
|
+
.option('aws-profile', { type: 'string', description: 'AWS SSO profile (S3 only)' })
|
|
918
|
+
.option('accept-breaking', { type: 'boolean', default: false,
|
|
919
|
+
description: 'Accept removal/modification of existing key entries' })
|
|
920
|
+
.option('format', { type: 'string', choices: ['text', 'json'], default: 'text' });
|
|
921
|
+
},
|
|
922
|
+
(argv) => trustCmd.refresh({
|
|
923
|
+
...argv,
|
|
924
|
+
awsProfile: argv['aws-profile'],
|
|
925
|
+
acceptBreaking: argv['accept-breaking'],
|
|
926
|
+
}),
|
|
927
|
+
)
|
|
772
928
|
.demandCommand(1)
|
|
773
929
|
.help();
|
|
774
930
|
},
|
package/package.json
CHANGED
package/src/build/recipe.js
CHANGED
|
@@ -39,6 +39,12 @@ const DEFAULT_RECIPE = Object.freeze({
|
|
|
39
39
|
installDir: 'install', // relative to binary dir; becomes CMAKE_INSTALL_PREFIX
|
|
40
40
|
sourceSubdir: '.', // where CMakeLists.txt lives relative to the clone root
|
|
41
41
|
requiredTools: Object.freeze([]),
|
|
42
|
+
// When true, `wyvrnpm publish` slices the unified install tree into one
|
|
43
|
+
// zip per config (Release-only consumers download one slice, not the
|
|
44
|
+
// 4-config fat zip). Default false — small libraries don't benefit and
|
|
45
|
+
// pay 4× the per-publish round-trips. Big packages (gRPC, Boost) opt in.
|
|
46
|
+
// See claude/PLAN-PER-CONFIG-ARTEFACTS in plan history.
|
|
47
|
+
publishPerConfig: false,
|
|
42
48
|
});
|
|
43
49
|
|
|
44
50
|
const VALID_CMAKE_CONFIGS = new Set(['Debug', 'Release', 'RelWithDebInfo', 'MinSizeRel']);
|
|
@@ -157,15 +163,31 @@ function normalizeRecipe(rawBuild, effectiveOptions = null, manifestForConfColli
|
|
|
157
163
|
}
|
|
158
164
|
}
|
|
159
165
|
|
|
166
|
+
// `publishPerConfig` opts the package into per-config slicing at
|
|
167
|
+
// publish time. Strict boolean — undefined / null / missing all become
|
|
168
|
+
// the default (false); any other type is a hard error so a typo
|
|
169
|
+
// (e.g. `"true"` as a string) doesn't silently default to false and
|
|
170
|
+
// produce a fat zip the author didn't intend.
|
|
171
|
+
let publishPerConfig = DEFAULT_RECIPE.publishPerConfig;
|
|
172
|
+
if (b.publishPerConfig !== undefined && b.publishPerConfig !== null) {
|
|
173
|
+
if (typeof b.publishPerConfig !== 'boolean') {
|
|
174
|
+
throw new Error(
|
|
175
|
+
`build.publishPerConfig must be a boolean — got ${JSON.stringify(b.publishPerConfig)}`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
publishPerConfig = b.publishPerConfig;
|
|
179
|
+
}
|
|
180
|
+
|
|
160
181
|
return {
|
|
161
182
|
system,
|
|
162
183
|
generators,
|
|
163
184
|
configs,
|
|
164
185
|
configure,
|
|
165
186
|
buildArgs,
|
|
166
|
-
installDir:
|
|
167
|
-
sourceSubdir:
|
|
168
|
-
requiredTools:
|
|
187
|
+
installDir: b.installDir ?? DEFAULT_RECIPE.installDir,
|
|
188
|
+
sourceSubdir: b.sourceSubdir ?? DEFAULT_RECIPE.sourceSubdir,
|
|
189
|
+
requiredTools: cleanArray(b.requiredTools),
|
|
190
|
+
publishPerConfig,
|
|
169
191
|
};
|
|
170
192
|
}
|
|
171
193
|
|
|
@@ -89,4 +89,64 @@ function removeSource(argv) {
|
|
|
89
89
|
log.info(`Removed ${kind} source "${name}"`);
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
|
|
92
|
+
/**
|
|
93
|
+
* wyvrnpm configure signing set-default --key-id <id>
|
|
94
|
+
* [--key-env <VAR> | --key-file <path>]
|
|
95
|
+
*
|
|
96
|
+
* Persists the publisher signing context to `defaultSigning` in
|
|
97
|
+
* config.json. Wyvrnpm never stores the secret itself — only the
|
|
98
|
+
* retrieval path (env var name or file path), so a `cat config.json`
|
|
99
|
+
* never leaks key material.
|
|
100
|
+
*/
|
|
101
|
+
function signingSetDefault(argv) {
|
|
102
|
+
const keyId = argv.keyId ?? argv['key-id'];
|
|
103
|
+
const keyEnv = argv.keyEnv ?? argv['key-env'];
|
|
104
|
+
const keyFile = argv.keyFile ?? argv['key-file'];
|
|
105
|
+
|
|
106
|
+
if (!keyId) { log.error('--key-id is required'); process.exit(1); }
|
|
107
|
+
if (!!keyEnv === !!keyFile) {
|
|
108
|
+
log.error('Pass exactly one of --key-env or --key-file');
|
|
109
|
+
process.exit(1);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const config = readConfig();
|
|
113
|
+
config.defaultSigning = {
|
|
114
|
+
alg: 'ed25519',
|
|
115
|
+
keyId,
|
|
116
|
+
privateKey: keyEnv ? { env: keyEnv } : { file: keyFile },
|
|
117
|
+
};
|
|
118
|
+
writeConfig(config);
|
|
119
|
+
log.success(`set defaultSigning.keyId="${keyId}" (${keyEnv ? `env:${keyEnv}` : `file:${keyFile}`})`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** wyvrnpm configure signing show */
|
|
123
|
+
function signingShow() {
|
|
124
|
+
const config = readConfig();
|
|
125
|
+
if (!config.defaultSigning) {
|
|
126
|
+
console.log('No defaultSigning configured. Set with `wyvrnpm configure signing set-default ...`');
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const s = config.defaultSigning;
|
|
130
|
+
console.log('defaultSigning:');
|
|
131
|
+
console.log(` alg : ${s.alg}`);
|
|
132
|
+
console.log(` keyId : ${s.keyId}`);
|
|
133
|
+
if (s.privateKey?.env) console.log(` privKey : env:${s.privateKey.env}`);
|
|
134
|
+
if (s.privateKey?.file) console.log(` privKey : file:${s.privateKey.file}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** wyvrnpm configure signing unset */
|
|
138
|
+
function signingUnset() {
|
|
139
|
+
const config = readConfig();
|
|
140
|
+
if (!config.defaultSigning) {
|
|
141
|
+
log.info('defaultSigning was not set — nothing to do');
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
delete config.defaultSigning;
|
|
145
|
+
writeConfig(config);
|
|
146
|
+
log.success('cleared defaultSigning');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
module.exports = {
|
|
150
|
+
list, addSource, removeSource,
|
|
151
|
+
signingSetDefault, signingShow, signingUnset,
|
|
152
|
+
};
|
package/src/commands/install.js
CHANGED
|
@@ -6,6 +6,7 @@ const path = require('path');
|
|
|
6
6
|
const { normalizeDependencies } = require('../manifest');
|
|
7
7
|
const { resolveDependencies } = require('../resolve');
|
|
8
8
|
const { downloadDependencies } = require('../download');
|
|
9
|
+
const { resolveSigningContext } = require('../signing/resolve-context');
|
|
9
10
|
const { wyvrnFetch } = require('../http-fetch');
|
|
10
11
|
const { resolveEffectiveConf, cmakeCacheVariables, unflattenConf } = require('../conf');
|
|
11
12
|
const { resolveBinaryDirRoot } = require('../binary-dir');
|
|
@@ -350,6 +351,47 @@ async function install(argv) {
|
|
|
350
351
|
|
|
351
352
|
const razerDir = path.join(rootDir, 'wyvrn_internal');
|
|
352
353
|
|
|
354
|
+
// ── Resolve requestConfigs for per-config dep slicing ─────────────────────
|
|
355
|
+
// Precedence: CLI > wyvrn.local.json > consumer's recipe build.configs.
|
|
356
|
+
// When all three are absent, falls through to null which means "download
|
|
357
|
+
// every config the dep author published" (preserves pre-feature semantics
|
|
358
|
+
// for fat-zip deps; for sliced deps, equivalent to "no override, fetch all").
|
|
359
|
+
let requestConfigs = null;
|
|
360
|
+
const cliRaw = typeof argv.requestConfigs === 'string' ? argv.requestConfigs.trim() : null;
|
|
361
|
+
if (cliRaw) {
|
|
362
|
+
requestConfigs = cliRaw.split(',').map((s) => s.trim()).filter(Boolean);
|
|
363
|
+
if (requestConfigs.length === 0) {
|
|
364
|
+
log.error('--request-configs was empty after parsing');
|
|
365
|
+
process.exit(1);
|
|
366
|
+
}
|
|
367
|
+
} else if (effectiveConfResult.localRequestConfigs) {
|
|
368
|
+
requestConfigs = effectiveConfResult.localRequestConfigs.slice();
|
|
369
|
+
} else if (manifest && manifest.build && (Array.isArray(manifest.build.configs) || typeof manifest.build.configs === 'string')) {
|
|
370
|
+
// Read configs directly from the consumer's manifest. Avoid normalizeRecipe
|
|
371
|
+
// here because it can throw on `${options.X}` references that this caller
|
|
372
|
+
// hasn't resolved yet — request-configs only depends on the configs field.
|
|
373
|
+
const raw = manifest.build.configs;
|
|
374
|
+
requestConfigs = (typeof raw === 'string' ? [raw] : raw)
|
|
375
|
+
.filter((s) => typeof s === 'string' && s.trim())
|
|
376
|
+
.slice();
|
|
377
|
+
if (requestConfigs.length === 0) requestConfigs = null;
|
|
378
|
+
}
|
|
379
|
+
if (requestConfigs) {
|
|
380
|
+
log.info(`Request configs: ${requestConfigs.join(', ')}`);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Per-dep override map (wyvrn.local.json: depRequestConfigs). When a
|
|
384
|
+
// dep is named in this map, its slice selection is the listed configs;
|
|
385
|
+
// otherwise it inherits the global `requestConfigs`. Use case: "fetch
|
|
386
|
+
// all four configs of fmt + zlib, but only MinSizeRel of grpc."
|
|
387
|
+
const depRequestConfigs = effectiveConfResult.localDepRequestConfigs ?? null;
|
|
388
|
+
if (depRequestConfigs && Object.keys(depRequestConfigs).length > 0) {
|
|
389
|
+
const summary = Object.entries(depRequestConfigs)
|
|
390
|
+
.map(([n, c]) => `${n}=[${c.join(',')}]`)
|
|
391
|
+
.join(' ');
|
|
392
|
+
log.info(`Per-dep request configs: ${summary}`);
|
|
393
|
+
}
|
|
394
|
+
|
|
353
395
|
// Extract auth from the first install source (if any). Prefer
|
|
354
396
|
// `tokenEnv` over a literal `token` stored in config — closes
|
|
355
397
|
// EVALUATION.md S8 (no plaintext secret persisted, value is looked up
|
|
@@ -360,6 +402,12 @@ async function install(argv) {
|
|
|
360
402
|
token: ctx.auth.for(firstInstallSrc).token,
|
|
361
403
|
buildMode,
|
|
362
404
|
profileName,
|
|
405
|
+
// S1: forwarded straight through to download.js's verify tail.
|
|
406
|
+
requireSignatures: Boolean(argv.requireSignatures),
|
|
407
|
+
// Per-config slice selection. null = "no override, fetch all available".
|
|
408
|
+
requestConfigs,
|
|
409
|
+
// Per-dep override; null when the overlay omits it.
|
|
410
|
+
depRequestConfigs,
|
|
363
411
|
};
|
|
364
412
|
|
|
365
413
|
// ── Resolve upload destination up-front when --upload-built is set ──────
|
|
@@ -387,6 +435,33 @@ async function install(argv) {
|
|
|
387
435
|
downloadOptions.uploadAuth = resolved.uploadAuth;
|
|
388
436
|
downloadOptions.uploadStats = createUploadStats();
|
|
389
437
|
|
|
438
|
+
// S1: resolve signing context for the consumer-upload tail. Same
|
|
439
|
+
// precedence as `wyvrnpm publish` — per-source signing on the
|
|
440
|
+
// resolved upload entry, then defaultSigning, then CLI overrides.
|
|
441
|
+
// When null, the upload is unsigned and downstream consumers warn /
|
|
442
|
+
// abort per the destination registry's mode.json.
|
|
443
|
+
const uploadSourceEntry = (config.publishSources ?? [])
|
|
444
|
+
.find((s) => s.url === resolved.uploadSource || s.name === argv.uploadSource) ?? null;
|
|
445
|
+
let uploadSigningContext = null;
|
|
446
|
+
try {
|
|
447
|
+
const ctxOrSkip = resolveSigningContext(
|
|
448
|
+
{
|
|
449
|
+
noSign: argv.noSign,
|
|
450
|
+
signingKeyEnv: argv.signingKeyEnv,
|
|
451
|
+
signingKeyFile: argv.signingKeyFile,
|
|
452
|
+
},
|
|
453
|
+
config,
|
|
454
|
+
uploadSourceEntry,
|
|
455
|
+
);
|
|
456
|
+
// `skip: true` (--no-sign) is treated identically to "no context"
|
|
457
|
+
// here — the consumer just doesn't sign.
|
|
458
|
+
uploadSigningContext = (ctxOrSkip && !ctxOrSkip.skip) ? ctxOrSkip : null;
|
|
459
|
+
} catch (err) {
|
|
460
|
+
log.error(`upload-built signing config invalid: ${err.message}`);
|
|
461
|
+
process.exit(1);
|
|
462
|
+
}
|
|
463
|
+
downloadOptions.uploadSigningContext = uploadSigningContext;
|
|
464
|
+
|
|
390
465
|
if (resolved.uploadSource !== (packageSources[0] ?? '')) {
|
|
391
466
|
log.info(
|
|
392
467
|
`upload-built: install source(s) and upload source differ —\n` +
|
|
@@ -569,7 +644,7 @@ async function install(argv) {
|
|
|
569
644
|
// here so CI doesn't take "Done — N installed" at face value when one of
|
|
570
645
|
// the deps actually failed.
|
|
571
646
|
const SUCCESS_RESOLVED = new Set([
|
|
572
|
-
'v1', 'v2', 'v2-compat', 'v2-source-build', 'cached', 'link',
|
|
647
|
+
'v1', 'v2', 'v2-slice', 'v2-compat', 'v2-source-build', 'cached', 'link',
|
|
573
648
|
]);
|
|
574
649
|
const failed = [];
|
|
575
650
|
for (const [name, entry] of lockEntries ?? []) {
|