wyvrnpm 2.1.0 → 2.3.2

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
@@ -518,6 +518,101 @@ wyvrnpm configure profile delete msvc_release
518
518
 
519
519
  ---
520
520
 
521
+ ## JSON output (`--format json`)
522
+
523
+ `install`, `publish`, and `show` accept `--format json` to emit a single machine-readable JSON object on stdout. Log lines (`[wyvrn]` prefix) move to **stderr** in this mode, so stdout is reserved exclusively for the JSON payload — no scraping required.
524
+
525
+ ```bash
526
+ wyvrnpm install --format json > install-result.json
527
+ wyvrnpm publish --format json 2>/dev/null # swallow logs, keep JSON
528
+ wyvrnpm show zlib@1.3.0.0 --format json | jq '.versions["1.3.0.0"].profiles[].origin'
529
+ ```
530
+
531
+ ### Shape — `install`
532
+
533
+ ```json
534
+ {
535
+ "command": "install",
536
+ "wyvrnpmVersion": "2.2.1",
537
+ "profile": { "os": "Windows", "arch": "x86_64", "compiler": "msvc", "compiler.version": "193", "compiler.cppstd": "23", "compiler.runtime": "dynamic" },
538
+ "profileName": "default",
539
+ "profileHash": "bf341c08af0aee2d",
540
+ "packages": [
541
+ {
542
+ "name": "zlib",
543
+ "version": "1.3.0.0",
544
+ "versionRange": "^1.3.0.0",
545
+ "profileHash": "a1b2c3d4e5f60718",
546
+ "options": { "shared": false, "minizip": true },
547
+ "contentSha256": "…",
548
+ "gitSha": "…",
549
+ "resolvedFrom": "v2"
550
+ }
551
+ ],
552
+ "lockFile": "/abs/path/to/wyvrn.lock",
553
+ "uploadSummary": { "uploaded": 0, "skipped": 0, "failed": 0 }
554
+ }
555
+ ```
556
+
557
+ - `versionRange` is `null` when the user pinned an exact version.
558
+ - `options` is `null` for packages that declare no options.
559
+ - `uploadSummary` is `null` unless `--upload-built` was passed.
560
+ - `packages` is sorted by name.
561
+
562
+ ### Shape — `publish`
563
+
564
+ ```json
565
+ {
566
+ "command": "publish",
567
+ "wyvrnpmVersion": "2.2.1",
568
+ "name": "zlib",
569
+ "version": "1.3.0.0",
570
+ "profileHash": "a1b2c3d4e5f60718",
571
+ "source": "s3://team-pkgs",
572
+ "contentSha256": "…",
573
+ "options": { "shared": false, "minizip": true },
574
+ "gitSha": "…",
575
+ "gitRepo": "…",
576
+ "publishedAt": "2026-04-21T12:34:56.789Z"
577
+ }
578
+ ```
579
+
580
+ ### Shape — `show`
581
+
582
+ ```json
583
+ {
584
+ "command": "show",
585
+ "wyvrnpmVersion": "2.2.1",
586
+ "source": "s3://team-pkgs",
587
+ "package": "zlib",
588
+ "latest": "1.3.0.0",
589
+ "versions": {
590
+ "1.3.0.0": {
591
+ "source": { "gitRepo": "…", "gitSha": "…" },
592
+ "profiles": [
593
+ {
594
+ "profileHash": "a1b2c3d4e5f60718",
595
+ "contentSha256": "…",
596
+ "publishedAt": "…",
597
+ "buildSettings": { "os": "Windows", "...": "...", "options": { "minizip": true } },
598
+ "origin": "author-publish",
599
+ "uploadedBy": null,
600
+ "compatibility": { "compiler.cppstd": "lte" },
601
+ "declaredOptions": { "minizip": { "default": true, "allowed": [true, false] } }
602
+ }
603
+ ]
604
+ }
605
+ }
606
+ }
607
+ ```
608
+
609
+ - `origin` is `"author-publish"` | `"consumer-source-build"` | `null` (the last when the summary view can't tell without a deep fetch).
610
+ - Bare-name queries (`wyvrnpm show zlib --format json`) skip the per-profile deep fetch; `origin`, `uploadedBy`, `compatibility`, `declaredOptions` are `null` for each profile.
611
+
612
+ Exit codes and error behaviour are identical to text mode. Fatal errors leave stdout empty rather than emitting partial JSON.
613
+
614
+ ---
615
+
521
616
  ## Configuration File
522
617
 
523
618
  wyvrnpm stores persistent configuration in a `config.json` file:
@@ -733,7 +828,97 @@ Fields use PascalCase. `Dependencies` is an array — transitive dependencies ar
733
828
 
734
829
  **`kind` values:** `ConsoleApp`, `StaticLib`, `DynamicLib`, `HeaderOnlyLib`, `WebApp`, `Utility`, `Service`, `TestProject`
735
830
 
736
- **`dependencies`** can be either a plain version string (`"major.minor.patch.build"`) or an object with `version` and an optional `settings` map. The `settings` map overrides individual build profile fields for that specific dependency — useful when a dependency must be consumed with a different runtime linkage or C++ standard than your default profile.
831
+ **`dependencies`** can be either a plain version string (`"major.minor.patch.build"`) or an object with `version`, an optional `settings` map, and an optional `options` map. The `settings` map overrides individual build profile fields for that specific dependency — useful when a dependency must be consumed with a different runtime linkage or C++ standard than your default profile. The `options` map is covered in the next section.
832
+
833
+ ### Version ranges
834
+
835
+ The `version` field accepts ranges in addition to exact versions and the `"latest"` tag:
836
+
837
+ | Spec | Meaning |
838
+ |---|---|
839
+ | `"1.2.3.4"` | exact — must match byte-identically |
840
+ | `"latest"` | resolves to whatever `latest.json` points at |
841
+ | `"^1.2.3.4"` | major-pinned — `>= 1.2.3.4` and `< 2.0.0.0` |
842
+ | `"~1.2.3.4"` | minor-pinned — `>= 1.2.3.4` and `< 1.3.0.0` |
843
+ | `">=1.2.3.4 <2.0.0.0"` | explicit comparators — space-separated, supports `>=`, `>`, `<=`, `<`, `=` |
844
+
845
+ Ranges resolve at install time against each source's `versions.json`. wyvrnpm picks the **highest version** that satisfies the range. The resolved exact version is pinned into `wyvrn.lock` alongside the original range string, so re-installs are byte-reproducible — ranges are only re-evaluated when the lock file's entry for that dependency is removed.
846
+
847
+ **Error handling:**
848
+
849
+ - **No version satisfies the range** → strict failure with every published version listed so you can see what's available.
850
+ - **Conflicting ranges across the graph** (direct consumer wants `^1.x` but a transitive wants `^2.x`) → precise error naming both contributors and their ranges.
851
+ - **Malformed range string** → fail fast with a cheat-sheet of supported syntaxes.
852
+
853
+ Example:
854
+
855
+ ```json
856
+ {
857
+ "dependencies": {
858
+ "OpenSSL": "^3.0.0.0",
859
+ "zlib": "~1.3.0.0",
860
+ "boost": ">=1.80.0.0 <2.0.0.0"
861
+ }
862
+ }
863
+ ```
864
+
865
+ ### Package options (`options`)
866
+
867
+ A library author can declare **options** — compile-time feature toggles that change the produced binary's ABI. Examples: `shared` (shared vs static library), `minizip` (zlib's minizip support), `fips` (OpenSSL's FIPS mode). Each option produces a distinct `profileHash`, so every option combination gets its own artefact on the registry.
868
+
869
+ **Author side — declare in the recipe's `wyvrn.json`:**
870
+
871
+ ```json
872
+ {
873
+ "name": "zlib",
874
+ "version": "1.3.0.0",
875
+ "options": {
876
+ "shared": { "default": false, "allowed": [true, false] },
877
+ "minizip": { "default": true, "allowed": [true, false] },
878
+ "api": { "default": "default", "allowed": ["default", "legacy"] }
879
+ },
880
+ "build": {
881
+ "configure": [
882
+ "-DBUILD_SHARED_LIBS=${options.shared}",
883
+ "-DZLIB_BUILD_MINIZIP=${options.minizip}",
884
+ "-DZLIB_API_MODE=${options.api}"
885
+ ]
886
+ }
887
+ }
888
+ ```
889
+
890
+ Rules for the declaration:
891
+
892
+ - Option names must match `/^[a-z][a-z0-9_-]*$/`.
893
+ - Each option needs `default` + `allowed`. `default` must be a member of `allowed`.
894
+ - `allowed` values are `boolean` or `string`. No integers, no lists.
895
+ - `${options.<name>}` tokens in `build.configure` / `build.buildArgs` are expanded at source-build time. Booleans expand to `ON`/`OFF`; strings expand verbatim. A typo (`${options.fips}` with no `fips` declared) is a hard error — it will not silently reach CMake.
896
+
897
+ **Consumer side — override per dependency:**
898
+
899
+ ```json
900
+ {
901
+ "dependencies": {
902
+ "zlib": {
903
+ "version": "1.3.0.0",
904
+ "options": { "minizip": false }
905
+ }
906
+ }
907
+ }
908
+ ```
909
+
910
+ **Consumer side — override via CLI (precedence: CLI > wyvrn.json > recipe defaults):**
911
+
912
+ ```bash
913
+ wyvrnpm install -o zlib:minizip=false -o zlib:shared=true
914
+ wyvrnpm publish -o zlib:minizip=false # publishing a specific variant
915
+ ```
916
+
917
+ Option overrides are validated against the recipe's `allowed` list at resolve time; unknown option names surface a "did you mean?" suggestion.
918
+
919
+ **What happens on the registry.** Options fold into `profileHash`, so `zlib@1.3.0.0` with `minizip=true` and `zlib@1.3.0.0` with `minizip=false` are two distinct artefacts at two different URLs — same version, different hashes. `wyvrnpm show zlib@1.3.0.0` lists all published variants and prints each one's resolved options.
920
+
921
+ **Backward compatibility.** Packages that do not declare an `options` block hash byte-identically to how they did before options existed. No registry migration required.
737
922
 
738
923
  ### Source-build recipe (`build`)
739
924
 
@@ -932,6 +1117,77 @@ wyvrnpm clean --build-cache
932
1117
 
933
1118
  wyvrnpm uses your ambient git configuration (SSH keys, credential helpers, Windows Credential Manager). It does not manage git credentials itself. If `git clone <gitRepo>` works from your shell, source-build works.
934
1119
 
1120
+ ### Upload back to the registry (`--upload-built`)
1121
+
1122
+ `--build=missing` warms *your* cache. `--build=missing --upload-built` warms the *team's* cache: after a successful source-build, the freshly-zipped artefact is pushed back to the registry under the consumer's `profileHash`, so the next teammate on the same profile gets a direct v2 download.
1123
+
1124
+ ```bash
1125
+ # Build missing artefacts locally AND publish them back under your profile.
1126
+ wyvrnpm install --build=missing --upload-built
1127
+
1128
+ # Override the upload destination (URL or configured publish-source name).
1129
+ wyvrnpm install --build=missing --upload-built --upload-source team-s3
1130
+
1131
+ # Upload auth is resolved in order: CLI flag → named-source config → first configured publish source.
1132
+ wyvrnpm install --build=missing --upload-built --aws-profile my-sso
1133
+ wyvrnpm install --build=missing --upload-built --token mytoken
1134
+ ```
1135
+
1136
+ Semantics:
1137
+
1138
+ - **Opt-in.** Nothing uploads unless `--upload-built` is passed. Requires `--build=missing`.
1139
+ - **Skip if exists.** If `(name, version, profileHash)` already lives on the upload source, the upload is a silent no-op. A consumer cannot overwrite an existing artefact; use `wyvrnpm publish --force` from the source repo for that.
1140
+ - **`latest.json` is untouched.** A consumer upload is a new `profileHash` of an existing version, not a new version release.
1141
+ - **Upload failures never break the install.** The consumer already has a working `wyvrn_internal/` on disk — a broken upload is logged as a warning and the install continues.
1142
+ - **Provenance.** Uploaded manifests carry a small `uploadedBy` block so maintainers can tell consumer builds apart from author publishes:
1143
+
1144
+ ```json
1145
+ {
1146
+ "uploadedBy": {
1147
+ "kind": "source-build",
1148
+ "profileHash": "a1b2c3d4e5f60718",
1149
+ "builtFromGit": "https://github.com/madler/zlib",
1150
+ "builtFromSha": "abcdef01...",
1151
+ "uploadedAt": "2026-04-21T12:34:56.789Z",
1152
+ "wyvrnpmVersion": "2.1.0"
1153
+ }
1154
+ }
1155
+ ```
1156
+
1157
+ Absence of `uploadedBy` means the artefact was published by the author via `wyvrnpm publish`.
1158
+
1159
+ At the end of an install with `--upload-built`, a one-line summary is printed:
1160
+
1161
+ ```
1162
+ [wyvrn] upload-built summary: 2 artefacts uploaded, 1 skipped (already existed), 0 failed
1163
+ ```
1164
+
1165
+ **Trust model.** Registry write auth is the gate — only consumers with credentials for the upload destination can upload at all. The same `v2Publish` path is used as `wyvrnpm publish`, so the resulting artefact is byte-identical in layout. SHA256 verification on the download side protects against in-transit tampering. Uploading deliberately opts into "your machine's build of this package is trusted by everyone else on this profile" — use CI with clean toolchains rather than developer workstations if you care about reproducibility.
1166
+
1167
+ #### Auditing consumer uploads (`wyvrnpm show`)
1168
+
1169
+ Maintainers can list every build of a package and tell author-published artefacts apart from consumer uploads:
1170
+
1171
+ ```bash
1172
+ wyvrnpm show zlib # all versions + their profile hashes
1173
+ wyvrnpm show zlib@1.3.0.0 # every profile for that version, with origin + builtFromSha
1174
+ wyvrnpm show zlib@1.3.0.0@a1b2c3d4... # one specific build, full metadata
1175
+ ```
1176
+
1177
+ With a version specified, `show` fetches each build's `wyvrn.json` and reports `author publish` or `consumer upload (source-build)` alongside the relevant `uploadedBy` fields. Sources default to the configured install sources; override with `--source <url>`.
1178
+
1179
+ #### Forgetting uploads (`clean --uploaded-built`)
1180
+
1181
+ Each successful upload writes a `.uploaded-to.json` sidecar alongside the cached artefact under `%LOCALAPPDATA%\wyvrnpm\bc\{name}-{version}-{profileHash}\`. It records destination, timestamp, and SHA — purely informational, nothing reads it during install.
1182
+
1183
+ To wipe just those local records (no effect on the registry, the artefact zips, or `wyvrn_internal/`):
1184
+
1185
+ ```bash
1186
+ wyvrnpm clean --uploaded-built
1187
+ ```
1188
+
1189
+ This is a privacy / local-audit cleanup knob — `clean --build-cache` already removes sidecars as a side effect of nuking the cache.
1190
+
935
1191
  ---
936
1192
 
937
1193
  ## Lock File (wyvrn.lock)
package/bin/wyvrn.js CHANGED
@@ -3,13 +3,15 @@
3
3
  'use strict';
4
4
 
5
5
  const yargs = require('yargs');
6
- const init = require('../src/commands/init');
7
- const install = require('../src/commands/install');
8
- const clean = require('../src/commands/clean');
9
- const publish = require('../src/commands/publish');
10
- const configure = require('../src/commands/configure');
11
- const profile = require('../src/commands/profile');
12
- const build = require('../src/commands/build');
6
+ const init = require('../src/commands/init');
7
+ const install = require('../src/commands/install');
8
+ const clean = require('../src/commands/clean');
9
+ const publish = require('../src/commands/publish');
10
+ const configure = require('../src/commands/configure');
11
+ const profile = require('../src/commands/profile');
12
+ const build = require('../src/commands/build');
13
+ const show = require('../src/commands/show');
14
+ const installSkill = require('../src/commands/install-skill');
13
15
  const { link, unlink } = require('../src/commands/link');
14
16
 
15
17
  yargs
@@ -58,6 +60,36 @@ yargs
58
60
  'Resolution mode when no exact profile match exists. ' +
59
61
  'never=fail; missing=build from source (pending phase 3); always=rebuild (pending phase 3)',
60
62
  })
63
+ .option('option', {
64
+ alias: 'o',
65
+ type: 'string',
66
+ array: true,
67
+ description:
68
+ 'Override a package option. Repeatable. Format: <pkg>:<name>=<value>. ' +
69
+ 'Example: -o zlib:minizip=false -o OpenSSL:fips=true',
70
+ })
71
+ .option('upload-built', {
72
+ type: 'boolean',
73
+ default: false,
74
+ description:
75
+ 'After successful --build=missing, upload each source-built artefact ' +
76
+ 'back to the registry under the active profileHash so the next consumer ' +
77
+ 'gets a direct download. Requires write credentials for the upload source.',
78
+ })
79
+ .option('upload-source', {
80
+ type: 'string',
81
+ description:
82
+ 'Destination for --upload-built (URL or configured publish-source name). ' +
83
+ 'Defaults to the first configured publish source.',
84
+ })
85
+ .option('aws-profile', {
86
+ type: 'string',
87
+ description: 'AWS SSO profile for --upload-built when uploading to S3',
88
+ })
89
+ .option('token', {
90
+ type: 'string',
91
+ description: 'Bearer token for --upload-built when uploading to HTTP',
92
+ })
61
93
  .option('platform', {
62
94
  type: 'string',
63
95
  description: 'v1 legacy platform path (used as fallback if v2 not found)',
@@ -68,9 +100,20 @@ yargs
68
100
  type: 'number',
69
101
  description: 'HTTP timeout in seconds',
70
102
  default: 300,
103
+ })
104
+ .option('format', {
105
+ type: 'string',
106
+ choices: ['text', 'json'],
107
+ default: 'text',
108
+ description: 'Output format. "json" emits a single JSON object on stdout; log lines go to stderr.',
71
109
  });
72
110
  },
73
- (argv) => install(argv),
111
+ (argv) => install({
112
+ ...argv,
113
+ uploadBuilt: argv['upload-built'],
114
+ uploadSource: argv['upload-source'],
115
+ awsProfile: argv['aws-profile'],
116
+ }),
74
117
  )
75
118
 
76
119
  // ── build ─────────────────────────────────────────────────────────────────
@@ -155,13 +198,60 @@ yargs
155
198
  'clean',
156
199
  'Remove downloaded packages and lock file. --build-cache also wipes the machine-wide source-build cache.',
157
200
  (y) => {
158
- y.option('build-cache', {
159
- type: 'boolean',
160
- default: false,
161
- description: 'Also remove the source-build cache under %LOCALAPPDATA%\\wyvrnpm\\bc\\',
162
- });
201
+ y
202
+ .option('build-cache', {
203
+ type: 'boolean',
204
+ default: false,
205
+ description: 'Also remove the source-build cache under %LOCALAPPDATA%\\wyvrnpm\\bc\\',
206
+ })
207
+ .option('uploaded-built', {
208
+ type: 'boolean',
209
+ default: false,
210
+ description:
211
+ 'Wipe only the local record of artefacts uploaded via --upload-built ' +
212
+ '(the .uploaded-to.json sidecars in the build cache). Does not touch ' +
213
+ 'the registry, the artefact zips, or wyvrn_internal/.',
214
+ });
215
+ },
216
+ (argv) => clean({
217
+ ...argv,
218
+ buildCache: argv['build-cache'],
219
+ uploadedBuilt: argv['uploaded-built'],
220
+ }),
221
+ )
222
+
223
+ // ── show ──────────────────────────────────────────────────────────────────
224
+ .command(
225
+ 'show <pkg>',
226
+ 'Display registry metadata for a package. Surfaces `uploadedBy` so consumer uploads can be told apart from author publishes.',
227
+ (y) => {
228
+ y
229
+ .positional('pkg', {
230
+ type: 'string',
231
+ description: 'Spec: <name>[@<version>[@<profileHash>]]',
232
+ })
233
+ .option('source', {
234
+ alias: 's',
235
+ type: 'string',
236
+ array: true,
237
+ description: 'Source URL(s) to query; defaults to configured install sources',
238
+ })
239
+ .option('aws-profile', {
240
+ type: 'string',
241
+ description: 'AWS SSO profile for S3 sources',
242
+ })
243
+ .option('token', {
244
+ type: 'string',
245
+ description: 'Bearer token for HTTP sources',
246
+ })
247
+ .option('format', {
248
+ type: 'string',
249
+ choices: ['text', 'json'],
250
+ default: 'text',
251
+ description: 'Output format. "json" emits a single JSON object on stdout; log lines go to stderr.',
252
+ });
163
253
  },
164
- (argv) => clean({ ...argv, buildCache: argv['build-cache'] }),
254
+ (argv) => show({ ...argv, awsProfile: argv['aws-profile'] }),
165
255
  )
166
256
 
167
257
  // ── publish ───────────────────────────────────────────────────────────────
@@ -200,6 +290,20 @@ yargs
200
290
  description: 'Overwrite an existing published version',
201
291
  default: false,
202
292
  })
293
+ .option('option', {
294
+ alias: 'o',
295
+ type: 'string',
296
+ array: true,
297
+ description:
298
+ 'Override an option value at publish time. Repeatable. ' +
299
+ 'Format: <pkg>:<name>=<value> (pkg must match the package being published).',
300
+ })
301
+ .option('format', {
302
+ type: 'string',
303
+ choices: ['text', 'json'],
304
+ default: 'text',
305
+ description: 'Output format. "json" emits a single JSON object on stdout; log lines go to stderr.',
306
+ })
203
307
  ;
204
308
  },
205
309
  (argv) => publish({ ...argv, awsProfile: argv['aws-profile'] }),
@@ -365,6 +469,19 @@ yargs
365
469
  (argv) => unlink(argv),
366
470
  )
367
471
 
472
+ // ── install-skill ─────────────────────────────────────────────────────────
473
+ .command(
474
+ 'install-skill',
475
+ 'Install the bundled wyvrnpm Agent Skill into Claude Code (~/.claude/skills/wyvrnpm/)',
476
+ (y) => {
477
+ y
478
+ .option('claude', { type: 'boolean', description: 'Install for Claude Code', default: false })
479
+ .option('force', { type: 'boolean', description: 'Overwrite an existing installation', default: false })
480
+ .option('dry-run', { type: 'boolean', description: 'Report actions without writing', default: false });
481
+ },
482
+ (argv) => installSkill(argv),
483
+ )
484
+
368
485
  .demandCommand(1)
369
486
  .strict()
370
487
  .help()
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wyvrnpm",
3
- "version": "2.1.0",
3
+ "version": "2.3.2",
4
4
  "description": "A simple, static-hosting-compatible C++ package manager",
5
5
  "keywords": [
6
6
  "c++",
@@ -20,6 +20,7 @@
20
20
  "bin/",
21
21
  "src/",
22
22
  "cmake/",
23
+ "claude/skills/wyvrnpm.skill",
23
24
  "README.md"
24
25
  ],
25
26
  "scripts": {
@@ -51,6 +51,7 @@ const LAYOUT = Object.freeze({
51
51
  buildDir: 'build',
52
52
  installSubdir: 'install', // relative to buildDir — can be overridden by recipe.installDir
53
53
  artefactZip: 'wyvrn.zip',
54
+ uploadSidecar: '.uploaded-to.json',
54
55
  });
55
56
 
56
57
  /**
@@ -91,11 +92,57 @@ function hasValidArtefact(args) {
91
92
  return fs.existsSync(artefact);
92
93
  }
93
94
 
95
+ /**
96
+ * Find every `.uploaded-to.json` sidecar under the build cache. Each file
97
+ * records the destinations that a cached artefact has been pushed to via
98
+ * `wyvrnpm install --build=missing --upload-built`.
99
+ *
100
+ * Used by `wyvrnpm clean --uploaded-built` and for audit tooling.
101
+ *
102
+ * @returns {Array<{ cacheDir: string, sidecarPath: string, uploads: Array<object> }>}
103
+ */
104
+ function listUploadSidecars() {
105
+ const root = getBuildCacheRoot();
106
+ if (!fs.existsSync(root)) return [];
107
+ const out = [];
108
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
109
+ if (!entry.isDirectory()) continue;
110
+ const cacheDir = path.join(root, entry.name);
111
+ const sidecarPath = path.join(cacheDir, LAYOUT.uploadSidecar);
112
+ if (!fs.existsSync(sidecarPath)) continue;
113
+ let uploads = [];
114
+ try {
115
+ const raw = JSON.parse(fs.readFileSync(sidecarPath, 'utf8'));
116
+ if (Array.isArray(raw?.uploads)) uploads = raw.uploads;
117
+ } catch { /* corrupt — surface as empty uploads */ }
118
+ out.push({ cacheDir, sidecarPath, uploads });
119
+ }
120
+ return out;
121
+ }
122
+
123
+ /**
124
+ * Remove every `.uploaded-to.json` sidecar under the build cache. Used by
125
+ * `wyvrnpm clean --uploaded-built`. Leaves all other cache content intact
126
+ * (the artefact zips, source clones, and build dirs stay put).
127
+ *
128
+ * @returns {number} count of sidecar files removed
129
+ */
130
+ function clearUploadSidecars() {
131
+ const entries = listUploadSidecars();
132
+ let removed = 0;
133
+ for (const { sidecarPath } of entries) {
134
+ try { fs.unlinkSync(sidecarPath); removed++; } catch { /* ignore */ }
135
+ }
136
+ return removed;
137
+ }
138
+
94
139
  module.exports = {
95
140
  getBuildCacheRoot,
96
141
  getPackageBuildDir,
97
142
  getBuildPaths,
98
143
  clearBuildCache,
99
144
  hasValidArtefact,
145
+ listUploadSidecars,
146
+ clearUploadSidecars,
100
147
  LAYOUT,
101
148
  };
@@ -13,6 +13,7 @@ const { normalizeDependencies } = require('../manifest');
13
13
  const { resolveDependencies } = require('../resolve');
14
14
  const { hashProfile, mergeProfile, sha256Of } = require('../profile');
15
15
  const { generateToolchain } = require('../toolchain');
16
+ const { normalizeOptionsDeclaration, resolveEffectiveOptions } = require('../options');
16
17
  const log = require('../logger');
17
18
 
18
19
  /**
@@ -62,6 +63,8 @@ function addFolder(zip, dir, base) {
62
63
  * @param {import('../profile').WyvrnProfile} args.profile
63
64
  * @param {string} args.profileName
64
65
  * @param {string} args.profileHash
66
+ * @param {Record<string, any>|null} [args.options] Effective options for this build.
67
+ * Used to expand `${options.*}` in the recipe.
65
68
  * @param {string} args.gitRepo
66
69
  * @param {string} args.gitSha
67
70
  * @param {string} args.destZipPath Where the caller expects the artefact zip.
@@ -73,13 +76,15 @@ function addFolder(zip, dir, base) {
73
76
  * all four CMake configurations).
74
77
  * @param {object} [args.authOptions]
75
78
  * @returns {Promise<{
76
- * found: true,
77
- * contentSha256: string,
78
- * profileHash: string,
79
- * gitSha: string,
80
- * gitRepo: string,
81
- * source: string, // build-cache path (for logging)
82
- * resolvedFrom: 'v2-source-build',
79
+ * found: true,
80
+ * contentSha256: string,
81
+ * profileHash: string,
82
+ * gitSha: string,
83
+ * gitRepo: string,
84
+ * source: string, // build-cache path (for logging)
85
+ * resolvedFrom: 'v2-source-build',
86
+ * artefactPath: string, // absolute path to the built zip in the cache
87
+ * clonedManifestPath: string, // absolute path to the cloned wyvrn.json (for upload-built)
83
88
  * }>}
84
89
  * @throws {Error} when the cloned manifest lacks a `build` section, when
85
90
  * CMake is missing, or when the compile itself fails.
@@ -88,6 +93,7 @@ async function buildFromSource(args) {
88
93
  const {
89
94
  name, version,
90
95
  profile, profileName, profileHash,
96
+ options = null,
91
97
  gitRepo, gitSha,
92
98
  destZipPath,
93
99
  packageSources,
@@ -120,6 +126,8 @@ async function buildFromSource(args) {
120
126
  gitRepo,
121
127
  source: paths.root,
122
128
  resolvedFrom: 'v2-source-build',
129
+ artefactPath: paths.artefact,
130
+ clonedManifestPath: path.join(paths.src, 'wyvrn.json'),
123
131
  };
124
132
  }
125
133
 
@@ -145,7 +153,10 @@ async function buildFromSource(args) {
145
153
  `Source-build is opt-in — the publisher must add a build recipe.`,
146
154
  );
147
155
  }
148
- const recipe = normalizeRecipe(clonedManifest.build);
156
+ // Pass effective options into the recipe so `${options.<name>}` tokens in
157
+ // `build.configure` / `build.buildArgs` expand to the concrete values that
158
+ // produced this build's profileHash.
159
+ const recipe = normalizeRecipe(clonedManifest.build, options);
149
160
 
150
161
  // ── 4. Recursively resolve + download this package's OWN deps ──────────
151
162
  // Lazy-require: downloadDependencies is where we were called from, so
@@ -161,20 +172,38 @@ async function buildFromSource(args) {
161
172
  );
162
173
 
163
174
  log.info(` resolving ${Object.keys(depsForResolution).length} transitive dep(s)`);
175
+ const transitiveManifests = new Map();
164
176
  const resolvedDeps = await resolveDependencies(
165
177
  depsForResolution, packageSources, platform, fetch, new Map(),
178
+ transitiveManifests,
166
179
  );
167
180
 
168
- // Build an enriched deps map (profileHash per dep based on overrides).
181
+ // Build an enriched deps map. Same logic as install.js: per-dep settings
182
+ // + recipe-declared options resolved against what the *cloned* source
183
+ // manifest asks for. No CLI overrides — transitive options come solely
184
+ // from the source package's `dependencies.<n>.options`.
169
185
  const enrichedDeps = new Map();
170
186
  for (const [depName, depVersion] of resolvedDeps) {
171
- const depOverrides = rawPackageDeps[depName]?.settings ?? {};
187
+ const depOverrides = rawPackageDeps[depName]?.settings ?? {};
172
188
  const effectiveProfile = mergeProfile(profile, depOverrides);
173
- const depProfileHash = hashProfile(effectiveProfile);
189
+
190
+ const transitiveManifest = transitiveManifests.get(depName);
191
+ const declaration = transitiveManifest?.options
192
+ ? normalizeOptionsDeclaration(transitiveManifest.options, `${depName}@${depVersion}`)
193
+ : null;
194
+ const effectiveOptions = resolveEffectiveOptions(
195
+ declaration,
196
+ rawPackageDeps[depName]?.options ?? {},
197
+ {},
198
+ `${depName}@${depVersion}`,
199
+ );
200
+
201
+ const depProfileHash = hashProfile(effectiveProfile, effectiveOptions);
174
202
  enrichedDeps.set(depName, {
175
203
  version: depVersion,
176
204
  profileHash: depProfileHash,
177
205
  profile: effectiveProfile,
206
+ options: effectiveOptions,
178
207
  });
179
208
  }
180
209
 
@@ -238,6 +267,8 @@ async function buildFromSource(args) {
238
267
  gitRepo,
239
268
  source: paths.root,
240
269
  resolvedFrom: 'v2-source-build',
270
+ artefactPath: paths.artefact,
271
+ clonedManifestPath: manifestPath,
241
272
  };
242
273
  }
243
274