wyvrnpm 2.12.3 → 2.13.12

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 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.12.3** — 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`.
7
+ > **README version: 2.13.12** — **New:** Resolver caches `@latest` lookups within a single `wyvrnpm install` — when a package is referenced as `@latest` from multiple parents in the dep graph (or already resolved earlier via a range), the registry round-trip is reused instead of re-probing `latest.json` per encounter. Eliminates ~12 redundant HTTPS round-trips on a typical 16-dep graph; visible win on resolution wall-time when the registry sits behind CloudFront. Closes the parallel gap to `manifestCache` / `publishedCache` — all three S3-backed helpers in `resolve.js` now memoise per-name within one install. **Per-config artefact slicing.** Recipes opt in via `build.publishPerConfig: true`; `wyvrnpm publish` then emits one `wyvrn-<Config>.zip` per declared CMake config under one `profileHash` URL instead of a single fat zip. Consumers fetch only the configs they need: `wyvrnpm install --request-configs Release` on a CI runner pulls 1.93 GB of gRPC instead of 14.96 GB across all four configs (~87% saving). Driven by CMake's own `*Targets-<config>.cmake` exports (`IMPORTED_LOCATION_<CONFIG>`) so both the MSVC convention (`lib/<Config>/foo.lib`) and the postfix convention (`lib/foo.lib` Release vs `lib/food.lib` Debug, via `CMAKE_DEBUG_POSTFIX`) classify correctly. `wyvrn.local.json` gains `requestConfigs` (global) + `depRequestConfigs` (per-dep override "all four configs of fmt, but MinSizeRel of grpc"). Attestation v2 binds an `artefactSha256: { config → sha }` map; one signature covers every slice. `wyvrnpm publish --dry-run` materialises the would-be-uploaded artefacts to `<build>/dry-run-publish/` for inspection (override with `--dry-run-out`), and tolerates expired AWS credentials (the existence check degrades to `wouldOverwrite: null` instead of crashing). Highlights since 2.8.3: 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. 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: argv['upload-built'],
289
- uploadSource: argv['upload-source'],
290
- awsProfile: argv['aws-profile'],
291
- tokenEnv: argv['token-env'],
292
- dryRun: argv['dry-run'],
293
- buildDir: argv['build-dir'],
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: argv['aws-profile'],
636
- tokenEnv: argv['token-env'],
637
- dryRun: argv['dry-run'],
638
- buildDir: argv['build-dir'],
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wyvrnpm",
3
- "version": "2.12.3",
3
+ "version": "2.13.12",
4
4
  "description": "A simple, static-hosting-compatible C++ package manager",
5
5
  "keywords": [
6
6
  "c++",
@@ -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: b.installDir ?? DEFAULT_RECIPE.installDir,
167
- sourceSubdir: b.sourceSubdir ?? DEFAULT_RECIPE.sourceSubdir,
168
- requiredTools: cleanArray(b.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
- module.exports = { list, addSource, removeSource };
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
+ };
@@ -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 ?? []) {