wyvrnpm 2.14.1 → 2.16.0

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.14.0** — **New:** npm self-update notifier — when a newer `wyvrnpm` is on the npm registry, the next interactive run prints a one-line `[wyvrn] warn wyvrnpm <old> → <new> available` banner on stderr. The fetch happens in a detached background process so the user's command never blocks on the network; the banner appears on the *following* invocation after the cache (`%LOCALAPPDATA%\wyvrnpm\update-check.json`) is populated. Auto-suppressed in CI (`$CI`), non-TTY runs, and `--format json` mode; opt out via `WYVRNPM_NO_UPDATE_CHECK=1` or `--no-update-check`. Hand-rolled (~120 lines, zero new deps) instead of pulling in `update-notifier` and its 10+ transitives. Closes the gap where users on stale CLIs silently missed bug fixes (the `2.8.4` CloudFront cache-split fix and the `2.12.2` install-failure exit code, in particular). **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`.
7
+ > **README version: 2.15.0** — **New:** `buildDependencies` block + `whenOption` gating — closes EVALUATION.md **F3** (`tool_requires` + `test_requires`). Recipes can now declare author-private deps (the asio / websocketpp / boost-internals case — header-only libs that compile into a `StaticLib` and aren't exposed by its public API; the protoc / flex / bison case — executables the build invokes at configure time) in a sibling block to `dependencies`. Buildable at the author's root and at source-build of a transitive, but **never walked transitively when a downstream consumer pulls the prebuilt binary** — they just get the lean runtime closure. `whenOption: "websocket"` (or `"api=legacy"`) gates a dep on a recipe-declared option value, evaluated at the seam where the package's effective options are known (root install + source-build). The combination also subsumes Conan's `test_requires`: declare `options.tests` and list gtest in `buildDependencies` with `whenOption: "tests"` — default `tests=false` skips it, `-o MyPkg:tests=true` opts in for that build, consumers of the prebuilt binary never see it either way. Mirrors Conan 2.0's `requires` / `tool_requires` split. Backward compat is automatic — old wyvrnpm clients ignore the unknown top-level field. `wyvrnpm show` surfaces both blocks per-profile so a publisher can verify what shipped. (Cross-compilation case — tool requires resolved under a *build* profile distinct from the *host* profile — stays open as F4, deferred pending a concrete cross-compilation user.) **New:** npm self-update notifier — when a newer `wyvrnpm` is on the npm registry, the next interactive run prints a one-line `[wyvrn] warn wyvrnpm <old> → <new> available` banner on stderr. The fetch happens in a detached background process so the user's command never blocks on the network; the banner appears on the *following* invocation after the cache (`%LOCALAPPDATA%\wyvrnpm\update-check.json`) is populated. Auto-suppressed in CI (`$CI`), non-TTY runs, and `--format json` mode; opt out via `WYVRNPM_NO_UPDATE_CHECK=1` or `--no-update-check`. Hand-rolled (~120 lines, zero new deps) instead of pulling in `update-notifier` and its 10+ transitives. Closes the gap where users on stale CLIs silently missed bug fixes (the `2.8.4` CloudFront cache-split fix and the `2.12.2` install-failure exit code, in particular). **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
 
package/bin/wyvrnpm.js CHANGED
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env node
2
2
 
3
3
  'use strict';
4
4
 
@@ -506,9 +506,34 @@ yargs
506
506
  // ── clean ─────────────────────────────────────────────────────────────────
507
507
  .command(
508
508
  'clean',
509
- 'Remove downloaded packages and lock file. --build-cache also wipes the machine-wide source-build cache.',
509
+ 'Remove downloaded packages and lock file. --build-dir adds the active profile\'s CMake build dir; --all wipes the entire wyvrnpm workspace footprint.',
510
510
  (y) => {
511
511
  y
512
+ .option('profile', {
513
+ alias: 'p',
514
+ type: 'string',
515
+ description: 'Active profile name — determines which wyvrn-<profile>/ build dir --build-dir targets',
516
+ })
517
+ .option('build-dir', {
518
+ type: 'boolean',
519
+ default: false,
520
+ description:
521
+ 'Also remove the active profile\'s CMake build directory ' +
522
+ '(<binaryDirRoot>/wyvrn-<profile>/). Pairs well with CI cleanup. ' +
523
+ 'binaryDirRoot resolves from wyvrn.local.json; non-default roots ' +
524
+ 'set only via the install-time --build-dir <path> flag will not ' +
525
+ 'be found here.',
526
+ })
527
+ .option('all', {
528
+ type: 'boolean',
529
+ default: false,
530
+ description:
531
+ 'Wipe every wyvrnpm-managed file in the workspace: wyvrn_internal/, ' +
532
+ 'wyvrn.lock, every <binaryDirRoot>/wyvrn-*/ build dir, and wyvrn-* ' +
533
+ 'entries from CMakePresets.json / CMakeUserPresets.json. Does NOT ' +
534
+ 'touch the machine-wide build cache — combine with --build-cache ' +
535
+ 'for that.',
536
+ })
512
537
  .option('build-cache', {
513
538
  type: 'boolean',
514
539
  default: false,
@@ -521,12 +546,19 @@ yargs
521
546
  'Wipe only the local record of artefacts uploaded via --upload-built ' +
522
547
  '(the .uploaded-to.json sidecars in the build cache). Does not touch ' +
523
548
  'the registry, the artefact zips, or wyvrn_internal/.',
549
+ })
550
+ .option('dry-run', {
551
+ type: 'boolean',
552
+ default: false,
553
+ description: 'Print what would be removed without modifying anything.',
524
554
  });
525
555
  },
526
556
  (argv) => clean({
527
557
  ...argv,
558
+ buildDir: argv['build-dir'],
528
559
  buildCache: argv['build-cache'],
529
560
  uploadedBuilt: argv['uploaded-built'],
561
+ dryRun: argv['dry-run'],
530
562
  }),
531
563
  )
532
564
 
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wyvrnpm",
3
- "version": "2.14.1",
3
+ "version": "2.16.0",
4
4
  "description": "A simple, static-hosting-compatible C++ package manager",
5
5
  "keywords": [
6
6
  "c++",
@@ -9,7 +9,12 @@ const { normalizeRecipe, hasBuildRecipe } = require('./recipe');
9
9
  const { configureBuildInstall } = require('./cmake');
10
10
  const { getBuildPaths, hasValidArtefact } = require('./cache');
11
11
 
12
- const { normalizeDependencies } = require('../manifest');
12
+ const {
13
+ normalizeDependencies,
14
+ normalizeManifestDeps,
15
+ validateWhenOption,
16
+ evaluateWhenOption,
17
+ } = require('../manifest');
13
18
  const { resolveDependencies } = require('../resolve');
14
19
  const { hashProfile, mergeProfile, sha256Of } = require('../profile');
15
20
  const { generateToolchain } = require('../toolchain');
@@ -188,9 +193,51 @@ async function buildFromSource(args) {
188
193
  // eslint-disable-next-line global-require
189
194
  const { downloadDependencies } = require('../download');
190
195
 
191
- const rawPackageDeps = normalizeDependencies(
192
- clonedManifest.dependencies ?? clonedManifest.Dependencies,
193
- );
196
+ // Read both runtime and author-private buildDependencies from the cloned
197
+ // source. The source-build sandbox needs both to compile (e.g. asio +
198
+ // websocketpp headers when building WyvrnIPC); the fetched buildDeps
199
+ // stay scoped to this sandbox's `wyvrn_internal/` and never propagate
200
+ // up to the outer install's lockfile or wyvrn_deps.cmake.
201
+ let depsByKind;
202
+ try {
203
+ depsByKind = normalizeManifestDeps(clonedManifest);
204
+ } catch (err) {
205
+ throw new Error(
206
+ `${name}@${version}: ${err.message} (in cloned wyvrn.json at ${gitSha.slice(0, 12)})`,
207
+ );
208
+ }
209
+ const { runtime: runtimeDeps, build: buildDeps } = depsByKind;
210
+
211
+ // Validate + filter whenOption against the source-build's effective
212
+ // options (passed in from the consumer's resolution as `args.options`).
213
+ const declarationForFilter = clonedManifest.options
214
+ ? normalizeOptionsDeclaration(clonedManifest.options, `${name}@${version}`)
215
+ : null;
216
+ for (const [depName, d] of Object.entries(runtimeDeps)) {
217
+ if (d.whenOption) {
218
+ validateWhenOption(d.whenOption, declarationForFilter, `${name}.dependencies.${depName}`);
219
+ }
220
+ }
221
+ for (const [depName, d] of Object.entries(buildDeps)) {
222
+ if (d.whenOption) {
223
+ validateWhenOption(d.whenOption, declarationForFilter, `${name}.buildDependencies.${depName}`);
224
+ }
225
+ }
226
+ const filterByWhenOption = (deps) => {
227
+ const out = {};
228
+ for (const [depName, d] of Object.entries(deps)) {
229
+ if (!d.whenOption || evaluateWhenOption(d.whenOption, options)) {
230
+ out[depName] = d;
231
+ } else {
232
+ log.info(` ${depName}: skipped (whenOption "${d.whenOption}" not satisfied)`);
233
+ }
234
+ }
235
+ return out;
236
+ };
237
+ const rawPackageDeps = {
238
+ ...filterByWhenOption(runtimeDeps),
239
+ ...filterByWhenOption(buildDeps),
240
+ };
194
241
  const depsForResolution = Object.fromEntries(
195
242
  Object.entries(rawPackageDeps).map(([n, d]) => [n, d.version]),
196
243
  );
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- const fs = require('fs');
3
+ const fs = require('fs');
4
4
  const path = require('path');
5
5
 
6
6
  const {
@@ -8,72 +8,230 @@ const {
8
8
  getBuildCacheRoot,
9
9
  clearUploadSidecars,
10
10
  } = require('../build/cache');
11
+ const { readConfig } = require('../config');
12
+ const { readLocalOverlay } = require('../conf');
13
+ const { resolveBinaryDirRoot } = require('../binary-dir');
14
+ const { stripWyvrnPresets, safeReadJson } = require('../toolchain/presets');
11
15
  const log = require('../logger');
12
16
 
17
+ /**
18
+ * Resolve the active profile name using the same precedence install/build
19
+ * use, but without pulling the full buildContext (which would require a
20
+ * manifest, parse --conf, etc. — overkill for a cleanup command).
21
+ *
22
+ * Precedence: --profile > config.defaultProfile > "default".
23
+ */
24
+ function resolveProfileName(argv) {
25
+ if (typeof argv.profile === 'string' && argv.profile.length > 0) {
26
+ return argv.profile;
27
+ }
28
+ const config = readConfig();
29
+ if (typeof config.defaultProfile === 'string' && config.defaultProfile.length > 0) {
30
+ return config.defaultProfile;
31
+ }
32
+ return 'default';
33
+ }
34
+
35
+ /**
36
+ * Resolve binaryDirRoot the same way install/build do — overlay-only on
37
+ * clean (no `--build-dir <path>` setter; clean's `--build-dir` is a
38
+ * boolean selector). If the user installed against a non-default root
39
+ * via the install-time CLI flag, they need the overlay set for cleanup
40
+ * to find the same tree.
41
+ */
42
+ function resolveBinaryRoot(rootDir) {
43
+ const overlay = readLocalOverlay(rootDir);
44
+ return resolveBinaryDirRoot({
45
+ localOverlayBinaryDirRoot: overlay.binaryDirRoot,
46
+ });
47
+ }
48
+
49
+ /**
50
+ * List every `wyvrn-*` subdirectory directly under `<rootDir>/<binaryRoot>/`.
51
+ * Returns absolute paths. Empty list if the parent dir doesn't exist.
52
+ */
53
+ function listWyvrnBuildDirs(rootDir, binaryRoot) {
54
+ const parent = path.join(rootDir, binaryRoot);
55
+ if (!fs.existsSync(parent)) return [];
56
+ let entries;
57
+ try {
58
+ entries = fs.readdirSync(parent, { withFileTypes: true });
59
+ } catch {
60
+ return [];
61
+ }
62
+ return entries
63
+ .filter((e) => e.isDirectory() && e.name.startsWith('wyvrn-'))
64
+ .map((e) => path.join(parent, e.name));
65
+ }
66
+
67
+ /**
68
+ * Remove a path (file or dir). Honours dry-run by logging instead. Returns
69
+ * true if the path existed and we either removed it or would have.
70
+ */
71
+ function removePath(p, { dryRun, label }) {
72
+ if (!fs.existsSync(p)) return false;
73
+ if (dryRun) {
74
+ log.info(`Would remove ${label ?? p}`);
75
+ } else {
76
+ fs.rmSync(p, { recursive: true, force: true });
77
+ log.info(`Removed ${label ?? p}`);
78
+ }
79
+ return true;
80
+ }
81
+
82
+ /**
83
+ * Strip wyvrn-generated entries from `CMakePresets.json` /
84
+ * `CMakeUserPresets.json`. User-owned files (no wyvrnpm vendor marker)
85
+ * are left untouched. Files whose only meaningful content was wyvrnpm's
86
+ * are deleted; files with surviving foreign content are rewritten.
87
+ *
88
+ * Returns the number of files touched (incl. dry-run "would touch").
89
+ */
90
+ function stripPresetsFromWorkspace(rootDir, { dryRun }) {
91
+ const candidates = [
92
+ path.join(rootDir, 'CMakePresets.json'),
93
+ path.join(rootDir, 'CMakeUserPresets.json'),
94
+ ];
95
+ let touched = 0;
96
+ for (const filePath of candidates) {
97
+ if (!fs.existsSync(filePath)) continue;
98
+ const existing = safeReadJson(filePath);
99
+ if (!existing) continue;
100
+ // Only act on files we generated. A user-owned file (no wyvrnpm
101
+ // vendor marker AND no wyvrn-* presets) is left alone — defence in
102
+ // depth so we never strip presets from a hand-authored file even
103
+ // if a stray `wyvrn-x` name slipped in.
104
+ const hasOurVendor = !!(existing.vendor && existing.vendor['wyvrnpm/generated']);
105
+ const hasOurPresets =
106
+ (existing.configurePresets ?? []).some((p) => p && typeof p.name === 'string' && p.name.startsWith('wyvrn-')) ||
107
+ (existing.buildPresets ?? []).some((p) => p && typeof p.name === 'string' && p.name.startsWith('wyvrn-'));
108
+ if (!hasOurVendor && !hasOurPresets) continue;
109
+
110
+ const { result, removed } = stripWyvrnPresets(existing);
111
+ if (removed.length === 0 && hasOurVendor && !result) continue;
112
+
113
+ if (result === null) {
114
+ if (dryRun) {
115
+ log.info(`Would remove ${filePath} (only wyvrnpm content remained)`);
116
+ } else {
117
+ fs.unlinkSync(filePath);
118
+ log.info(`Removed ${filePath} (only wyvrnpm content remained)`);
119
+ }
120
+ } else {
121
+ if (dryRun) {
122
+ log.info(`Would strip ${removed.length} wyvrn-* preset(s) from ${filePath}`);
123
+ } else {
124
+ fs.writeFileSync(filePath, JSON.stringify(result, null, 2) + '\n', 'utf8');
125
+ log.info(`Stripped ${removed.length} wyvrn-* preset(s) from ${filePath}`);
126
+ }
127
+ }
128
+ touched += 1;
129
+ }
130
+ return touched;
131
+ }
132
+
13
133
  /**
14
134
  * Removes the project's `wyvrn_internal/` package cache and `wyvrn.lock` file.
15
- * With `--build-cache`, also clears the machine-wide source-build cache
16
- * under `%LOCALAPPDATA%\wyvrnpm\bc\` (shared across projects).
17
135
  *
18
- * `--uploaded-built` wipes just the consumer's local RECORD of what they
19
- * have uploaded via `wyvrnpm install --build=missing --upload-built` (the
20
- * `.uploaded-to.json` sidecars under the build cache). It does NOT touch
21
- * the registry, the artefact zips, or the source clones. Privacy / local
22
- * audit cleanup only.
136
+ * Flag matrix:
137
+ * - (default) wyvrn_internal/ + wyvrn.lock
138
+ * - --build-dir also the active profile's wyvrn-<profile> build dir
139
+ * - --all default set + every wyvrn-prefixed build dir + wyvrn-prefixed preset entries
140
+ * - --build-cache also the machine-wide source-build cache
141
+ * - --uploaded-built wipe just the upload sidecars (targeted-only when used
142
+ * alone — does not also do the default scrub)
143
+ * - --dry-run print what would be removed; write nothing
23
144
  *
24
145
  * @param {object} argv
25
146
  * @param {string} argv.root Project root directory.
26
147
  * @param {string} argv.manifest Path to the manifest file (used to locate wyvrn.lock).
148
+ * @param {string} [argv.profile] Active profile name (overrides config.defaultProfile).
149
+ * @param {boolean} [argv.buildDir] When true, also wipes <binaryDirRoot>/wyvrn-<profile>/.
150
+ * @param {boolean} [argv.all] When true, scorched-earth workspace cleanup.
27
151
  * @param {boolean} [argv.buildCache] When true, also nukes the source-build cache.
28
152
  * @param {boolean} [argv.uploadedBuilt] When true, wipes upload sidecars only.
153
+ * @param {boolean} [argv.dryRun] When true, log intentions but write nothing.
29
154
  * @returns {Promise<void>}
30
155
  */
31
156
  async function clean(argv) {
32
157
  const rootDir = path.resolve(argv.root);
33
158
  const razerDir = path.join(rootDir, 'wyvrn_internal');
34
159
  const lockPath = path.join(path.dirname(path.resolve(argv.manifest)), 'wyvrn.lock');
160
+ const dryRun = !!argv.dryRun;
35
161
 
36
162
  // --uploaded-built is a targeted cleanup mode. When passed on its own,
37
163
  // skip the default wyvrn_internal/ + wyvrn.lock scrub so a user can
38
164
  // "forget uploads" without also wiping their project-local install.
39
- const targetedOnly = argv.uploadedBuilt && !argv.buildCache;
165
+ // --all forces the default scrub regardless (scorched-earth wins).
166
+ const targetedOnly = argv.uploadedBuilt && !argv.buildCache && !argv.buildDir && !argv.all;
40
167
 
41
168
  let removedAnything = false;
42
169
 
43
170
  if (!targetedOnly) {
44
- if (fs.existsSync(razerDir)) {
45
- fs.rmSync(razerDir, { recursive: true, force: true });
46
- log.info(`Removed ${razerDir}`);
47
- removedAnything = true;
171
+ if (removePath(razerDir, { dryRun, label: razerDir })) removedAnything = true;
172
+ else log.info(`Nothing to clean at ${razerDir}`);
173
+
174
+ if (removePath(lockPath, { dryRun, label: lockPath })) removedAnything = true;
175
+ }
176
+
177
+ // --build-dir / --all: wipe CMake build trees.
178
+ if (argv.buildDir || argv.all) {
179
+ const binaryRoot = resolveBinaryRoot(rootDir);
180
+
181
+ if (argv.all) {
182
+ // Every wyvrn-* subdir under binaryDirRoot — covers all profiles
183
+ // any past install/build wrote to.
184
+ const dirs = listWyvrnBuildDirs(rootDir, binaryRoot);
185
+ if (dirs.length === 0) {
186
+ log.info(`No wyvrn-* build directories under ${path.join(rootDir, binaryRoot)}`);
187
+ }
188
+ for (const d of dirs) {
189
+ if (removePath(d, { dryRun, label: d })) removedAnything = true;
190
+ }
48
191
  } else {
49
- log.info(`Nothing to clean at ${razerDir}`);
192
+ // Active profile only — symmetric with `wyvrnpm build --clean`.
193
+ const profileName = resolveProfileName(argv);
194
+ const buildDir = path.join(rootDir, binaryRoot, `wyvrn-${profileName}`);
195
+ if (removePath(buildDir, { dryRun, label: buildDir })) removedAnything = true;
196
+ else log.info(`Nothing to clean at ${buildDir}`);
50
197
  }
198
+ }
51
199
 
52
- if (fs.existsSync(lockPath)) {
53
- fs.unlinkSync(lockPath);
54
- log.info(`Removed ${lockPath}`);
55
- removedAnything = true;
56
- }
200
+ // --all: also strip wyvrn-* entries from CMakePresets.json / CMakeUserPresets.json.
201
+ if (argv.all) {
202
+ const touched = stripPresetsFromWorkspace(rootDir, { dryRun });
203
+ if (touched > 0) removedAnything = true;
57
204
  }
58
205
 
59
206
  if (argv.buildCache) {
60
- const removed = clearBuildCache();
61
- if (removed) {
62
- log.info(`Removed source-build cache ${removed}`);
207
+ if (dryRun) {
208
+ log.info(`Would remove source-build cache ${getBuildCacheRoot()}`);
63
209
  removedAnything = true;
64
210
  } else {
65
- log.info(`No source-build cache at ${getBuildCacheRoot()}`);
211
+ const removed = clearBuildCache();
212
+ if (removed) {
213
+ log.info(`Removed source-build cache ${removed}`);
214
+ removedAnything = true;
215
+ } else {
216
+ log.info(`No source-build cache at ${getBuildCacheRoot()}`);
217
+ }
66
218
  }
67
219
  } else if (argv.uploadedBuilt) {
68
220
  // clearBuildCache() would have nuked the sidecars as a side-effect, so
69
221
  // only bother with the sidecar sweep when the cache itself is surviving.
70
- const removed = clearUploadSidecars();
71
- log.info(`Removed ${removed} upload record${removed === 1 ? '' : 's'} from the build cache`);
72
- if (removed > 0) removedAnything = true;
222
+ if (dryRun) {
223
+ log.info('Would remove upload sidecars from the build cache');
224
+ } else {
225
+ const removed = clearUploadSidecars();
226
+ log.info(`Removed ${removed} upload record${removed === 1 ? '' : 's'} from the build cache`);
227
+ if (removed > 0) removedAnything = true;
228
+ }
73
229
  }
74
230
 
75
231
  if (!removedAnything) {
76
232
  log.info('Nothing to clean.');
233
+ } else if (dryRun) {
234
+ log.info('Dry run — no files were modified.');
77
235
  }
78
236
  }
79
237
 
@@ -3,7 +3,12 @@
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
 
6
- const { normalizeDependencies } = require('../manifest');
6
+ const {
7
+ normalizeDependencies,
8
+ normalizeManifestDeps,
9
+ validateWhenOption,
10
+ evaluateWhenOption,
11
+ } = require('../manifest');
7
12
  const { resolveDependencies } = require('../resolve');
8
13
  const { downloadDependencies } = require('../download');
9
14
  const { resolveSigningContext } = require('../signing/resolve-context');
@@ -134,8 +139,76 @@ async function install(argv) {
134
139
  // and caches the result for the rest of this invocation.
135
140
  const manifest = ctx.manifest;
136
141
 
137
- // Normalize to { name: { version, settings } }
138
- const normalizedDeps = normalizeDependencies(manifest.dependencies ?? manifest.Dependencies);
142
+ // Pull both runtime `dependencies` and author-private `buildDependencies`.
143
+ // The latter feeds the root install (the author needs them to compile)
144
+ // and source-builds of this package as a transitive, but is never walked
145
+ // when a downstream consumer pulls the prebuilt binary — see resolve.js.
146
+ let depsByKind;
147
+ try {
148
+ depsByKind = normalizeManifestDeps(manifest);
149
+ } catch (err) {
150
+ log.error(err.message);
151
+ process.exit(1);
152
+ }
153
+ const { runtime: runtimeDeps, build: buildDeps } = depsByKind;
154
+
155
+ // Resolve the project's own effective options early. Needed for
156
+ // `whenOption` filtering of root deps, then reused below for the
157
+ // CMake preset's `${options.*}` substitution.
158
+ let projectDeclaration = null;
159
+ let projectEffectiveOptions = null;
160
+ try {
161
+ projectDeclaration = manifest.options
162
+ ? normalizeOptionsDeclaration(manifest.options, manifest.name)
163
+ : null;
164
+ projectEffectiveOptions = resolveEffectiveOptions(
165
+ projectDeclaration, {}, cliOptionsByPkg[manifest.name] ?? {}, manifest.name,
166
+ );
167
+ } catch (err) {
168
+ log.error(err.message);
169
+ process.exit(1);
170
+ }
171
+
172
+ // Validate every `whenOption` against the project's declared options
173
+ // before resolving anything — surface authoring errors fast.
174
+ try {
175
+ for (const [name, d] of Object.entries(runtimeDeps)) {
176
+ if (d.whenOption) {
177
+ validateWhenOption(d.whenOption, projectDeclaration, `${manifest.name}.dependencies.${name}`);
178
+ }
179
+ }
180
+ for (const [name, d] of Object.entries(buildDeps)) {
181
+ if (d.whenOption) {
182
+ validateWhenOption(d.whenOption, projectDeclaration, `${manifest.name}.buildDependencies.${name}`);
183
+ }
184
+ }
185
+ } catch (err) {
186
+ log.error(err.message);
187
+ process.exit(1);
188
+ }
189
+
190
+ // Filter both blocks by whenOption. A dep whose condition is false is
191
+ // omitted from the resolver's queue — the consumer never pays the
192
+ // download / find_package cost, and the entry stays out of wyvrn.lock.
193
+ const filterByWhenOption = (deps, label) => {
194
+ const out = {};
195
+ for (const [name, d] of Object.entries(deps)) {
196
+ if (!d.whenOption || evaluateWhenOption(d.whenOption, projectEffectiveOptions)) {
197
+ out[name] = d;
198
+ } else {
199
+ log.info(`${name}: ${label} skipped (whenOption "${d.whenOption}" not satisfied)`);
200
+ }
201
+ }
202
+ return out;
203
+ };
204
+ const activeRuntimeDeps = filterByWhenOption(runtimeDeps, 'runtime');
205
+ const activeBuildDeps = filterByWhenOption(buildDeps, 'buildDep');
206
+
207
+ // Combined view for resolver + downstream lookups. `buildDepNames`
208
+ // is the only side-channel — used to annotate lockfile entries with
209
+ // `kind: "build"` and to label log lines.
210
+ const normalizedDeps = { ...activeRuntimeDeps, ...activeBuildDeps };
211
+ const buildDepNames = new Set(Object.keys(activeBuildDeps));
139
212
 
140
213
  // For resolution, only pass name → version (settings don't affect resolution)
141
214
  const depsForResolution = Object.fromEntries(
@@ -484,7 +557,15 @@ async function install(argv) {
484
557
  // ── Write v2 lock file ────────────────────────────────────────────────────
485
558
  const sortedPackages = {};
486
559
  for (const key of [...resolvedDeps.keys()].sort()) {
487
- sortedPackages[key] = lockEntries?.get(key) ?? { version: resolvedDeps.get(key) };
560
+ const entry = lockEntries?.get(key) ?? { version: resolvedDeps.get(key) };
561
+ // Annotate root-level buildDependencies so `wyvrnpm show` / audits
562
+ // can distinguish runtime deps from author-private build deps.
563
+ // Runtime deps stay un-annotated so pre-feature lockfiles round-trip
564
+ // byte-identically.
565
+ if (buildDepNames.has(key) && entry && typeof entry === 'object' && !entry.kind) {
566
+ entry.kind = 'build';
567
+ }
568
+ sortedPackages[key] = entry;
488
569
  }
489
570
 
490
571
  const lockData = {
@@ -533,17 +614,11 @@ async function install(argv) {
533
614
  let presetConfigs = null;
534
615
  if (hasBuildRecipe(manifest)) {
535
616
  try {
536
- // Resolve the project's own options to expand ${options.*} in its
537
- // configure args. When the project declares no options, this is null
538
- // and the recipe's configure / buildArgs must not contain templates
539
- // (normalizeRecipe enforces that).
540
- const projectDeclaration = manifest.options
541
- ? normalizeOptionsDeclaration(manifest.options, manifest.name)
542
- : null;
543
- const projectCliOverrides = cliOptionsByPkg[manifest.name] ?? {};
544
- const projectEffectiveOptions = resolveEffectiveOptions(
545
- projectDeclaration, {}, projectCliOverrides, manifest.name,
546
- );
617
+ // `projectEffectiveOptions` was already resolved at the top of the
618
+ // function (needed for whenOption filtering). Reuse it here for
619
+ // `${options.*}` substitution in `build.configure` / `build.buildArgs`
620
+ // normalizeRecipe enforces that template references must match a
621
+ // declared option.
547
622
  const recipe = normalizeRecipe(manifest.build, projectEffectiveOptions, manifest);
548
623
 
549
624
  if (recipe.generators.length > 0) {
@@ -170,6 +170,17 @@ async function show(argv) {
170
170
  if (meta?.options && Object.keys(meta.options).length > 0) {
171
171
  console.log(` declared: ${formatDeclaredOptions(meta.options)}`);
172
172
  }
173
+ // Author-private buildDependencies (F-buildDeps). Surface both
174
+ // blocks so a publisher can confirm what shipped — runtime
175
+ // deps propagate to consumers, buildDeps don't. `whenOption`
176
+ // entries are annotated inline so it's obvious which deps are
177
+ // gated on an option value.
178
+ if (meta?.dependencies && Object.keys(meta.dependencies).length > 0) {
179
+ console.log(` deps : ${formatDeps(meta.dependencies)}`);
180
+ }
181
+ if (meta?.buildDependencies && Object.keys(meta.buildDependencies).length > 0) {
182
+ console.log(` buildDeps: ${formatDeps(meta.buildDependencies)}`);
183
+ }
173
184
  // PLAN-CONF.md §4.6 — surface publisher's publishedConf.
174
185
  // Informational only; absent for pre-2.6.0 author publishes.
175
186
  if (meta?.publishedConf && Object.keys(meta.publishedConf).length > 0) {
@@ -193,6 +204,8 @@ async function show(argv) {
193
204
  uploadedBy: meta?.uploadedBy ?? null,
194
205
  compatibility: meta?.compatibility ?? null,
195
206
  declaredOptions: meta?.options ?? null,
207
+ dependencies: meta?.dependencies ?? null,
208
+ buildDependencies: meta?.buildDependencies ?? null,
196
209
  publishedConf: meta?.publishedConf ?? null,
197
210
  signing,
198
211
  });
@@ -271,5 +284,18 @@ function formatDeclaredOptions(declared) {
271
284
  .join('; ');
272
285
  }
273
286
 
287
+ function formatDeps(deps) {
288
+ return Object.entries(deps)
289
+ .sort(([a], [b]) => a.localeCompare(b))
290
+ .map(([name, value]) => {
291
+ const ver = typeof value === 'string' ? value : (value?.version ?? '?');
292
+ const when = (typeof value === 'object' && value?.whenOption)
293
+ ? ` [when ${value.whenOption}]`
294
+ : '';
295
+ return `${name}@${ver}${when}`;
296
+ })
297
+ .join(', ');
298
+ }
299
+
274
300
  module.exports = show;
275
301
  module.exports.parseSpec = parseSpec;
package/src/manifest.js CHANGED
@@ -54,21 +54,24 @@ function defaultManifest(name) {
54
54
 
55
55
  /**
56
56
  * Normalise the raw `dependencies` field from a manifest into a consistent
57
- * `{ name → { version, settings, options, source? } }` map, regardless of
58
- * input format:
57
+ * `{ name → { version, settings, options, source?, whenOption? } }` map,
58
+ * regardless of input format:
59
59
  *
60
- * • String value : "1.0.0" → { version: "1.0.0", settings: {}, options: {} }
61
- * • Object value : { version, settings?, options?, source? } → kept (missing keys default to {})
62
- * • Array format : [{ Name, Version }] → converted (no settings/options)
60
+ * • String value : "1.0.0" → { version: "1.0.0", settings: {}, options: {} }
61
+ * • Object value : { version, settings?, options?, source?, whenOption? } → kept (missing keys default to {})
62
+ * • Array format : [{ Name, Version }] → converted (no settings/options)
63
63
  *
64
64
  * `settings` merges on top of the active profile for this dep only.
65
65
  * `options` maps to the recipe's declared options; validated against
66
66
  * the recipe's `allowed` list at resolve time (see src/options.js).
67
67
  * `source` (EVALUATION.md S4) pins the dep to a named install source
68
68
  * (e.g. `"primary-s3"`); absent = fan out across all configured sources.
69
+ * `whenOption` (`"name"` or `"name=value"`) gates the dep on a recipe-
70
+ * declared option — see `evaluateWhenOption`. Validated against the
71
+ * package's `options` block by `validateWhenOption` at install time.
69
72
  *
70
73
  * @param {object|Array|undefined} rawDeps
71
- * @returns {Record<string, { version: string, settings: object, options: object, source?: string }>}
74
+ * @returns {Record<string, { version: string, settings: object, options: object, source?: string, whenOption?: string }>}
72
75
  */
73
76
  function normalizeDependencies(rawDeps) {
74
77
  if (!rawDeps || typeof rawDeps !== 'object') return {};
@@ -98,12 +101,141 @@ function normalizeDependencies(rawDeps) {
98
101
  if (typeof value.source === 'string' && value.source.length > 0) {
99
102
  entry.source = value.source;
100
103
  }
104
+ // Option-gated dep (F-buildDeps). Preserved verbatim here; structural
105
+ // validation lives in `validateWhenOption` because that's where we
106
+ // also know the package's declared options.
107
+ if (typeof value.whenOption === 'string' && value.whenOption.length > 0) {
108
+ entry.whenOption = value.whenOption;
109
+ }
101
110
  result[n] = entry;
102
111
  }
103
112
  }
104
113
  return result;
105
114
  }
106
115
 
116
+ /**
117
+ * Pull both runtime `dependencies` and author-private `buildDependencies`
118
+ * out of a manifest, normalised through `normalizeDependencies`. A package
119
+ * name MUST NOT appear in both blocks — that's a hard authoring error
120
+ * (no sensible meaning for "both runtime and build-only").
121
+ *
122
+ * `buildDependencies` are deps the package needs to compile itself but
123
+ * that consumers of the prebuilt binary do not need transitively. The
124
+ * resolver walks them at the root (and at source-build of a transitive
125
+ * dep) but never when reading an upstream manifest's transitive graph
126
+ * — see [src/resolve.js](resolve.js). Backward compat with old wyvrnpm
127
+ * clients is automatic: they only read `dependencies`.
128
+ *
129
+ * @param {object} manifest - parsed wyvrn.json
130
+ * @returns {{
131
+ * runtime: Record<string, object>,
132
+ * build: Record<string, object>,
133
+ * }}
134
+ */
135
+ function normalizeManifestDeps(manifest) {
136
+ const runtime = normalizeDependencies(
137
+ manifest?.dependencies ?? manifest?.Dependencies,
138
+ );
139
+ const build = normalizeDependencies(manifest?.buildDependencies);
140
+ for (const name of Object.keys(build)) {
141
+ if (name in runtime) {
142
+ throw new Error(
143
+ `"${name}" appears in both \`dependencies\` and \`buildDependencies\` — ` +
144
+ `pick one. Runtime deps propagate to consumers; buildDependencies do not.`,
145
+ );
146
+ }
147
+ }
148
+ return { runtime, build };
149
+ }
150
+
151
+ /**
152
+ * Parse a `whenOption` spec into `{ name, value }`. Bare `"foo"` is
153
+ * sugar for `"foo=true"`. Throws on malformed input. Pure structural
154
+ * parser — does not validate against a package's option declaration;
155
+ * use `validateWhenOption` for that.
156
+ *
157
+ * @param {string} spec
158
+ * @returns {{ name: string, value: string|boolean }}
159
+ */
160
+ function parseWhenOption(spec) {
161
+ if (typeof spec !== 'string' || spec.length === 0) {
162
+ throw new Error('whenOption must be a non-empty string');
163
+ }
164
+ const eqIdx = spec.indexOf('=');
165
+ if (eqIdx === -1) {
166
+ if (!/^[a-z][a-z0-9_-]*$/.test(spec)) {
167
+ throw new Error(
168
+ `whenOption "${spec}" is malformed — option name must match /^[a-z][a-z0-9_-]*$/`,
169
+ );
170
+ }
171
+ return { name: spec, value: true };
172
+ }
173
+ const name = spec.slice(0, eqIdx);
174
+ const raw = spec.slice(eqIdx + 1);
175
+ if (!/^[a-z][a-z0-9_-]*$/.test(name)) {
176
+ throw new Error(
177
+ `whenOption "${spec}" is malformed — option name must match /^[a-z][a-z0-9_-]*$/`,
178
+ );
179
+ }
180
+ if (raw.length === 0) {
181
+ throw new Error(`whenOption "${spec}" has empty value after "="`);
182
+ }
183
+ const lc = raw.toLowerCase();
184
+ if (lc === 'true') return { name, value: true };
185
+ if (lc === 'false') return { name, value: false };
186
+ return { name, value: raw };
187
+ }
188
+
189
+ /**
190
+ * Validate a dep's `whenOption` against the owning package's normalised
191
+ * options declaration. Throws on:
192
+ * - structural error in the spec,
193
+ * - referenced option name not in the declaration,
194
+ * - compared value not in the option's `allowed` list.
195
+ *
196
+ * @param {string} spec - the whenOption string
197
+ * @param {object|null} optionsDecl - normalised options (from src/options.js)
198
+ * @param {string} where - context for error messages ("WyvrnIPC.dependencies.websocketpp")
199
+ * @returns {{ name: string, value: any }}
200
+ */
201
+ function validateWhenOption(spec, optionsDecl, where) {
202
+ const parsed = parseWhenOption(spec);
203
+ if (!optionsDecl || !(parsed.name in optionsDecl)) {
204
+ const declared = optionsDecl ? Object.keys(optionsDecl).join(', ') : '(none)';
205
+ throw new Error(
206
+ `whenOption on ${where} references option "${parsed.name}" which is not declared.\n` +
207
+ ` declared options: ${declared}`,
208
+ );
209
+ }
210
+ const allowed = optionsDecl[parsed.name].allowed;
211
+ if (!allowed.includes(parsed.value)) {
212
+ throw new Error(
213
+ `whenOption on ${where} compares ${parsed.name} = ${JSON.stringify(parsed.value)}, ` +
214
+ `which is not in allowed: ${JSON.stringify(allowed)}`,
215
+ );
216
+ }
217
+ return parsed;
218
+ }
219
+
220
+ /**
221
+ * Evaluate a `whenOption` spec against a resolved options map.
222
+ * Returns true (include the dep) when the option's resolved value
223
+ * matches the spec's compared value.
224
+ *
225
+ * Pre-validated: callers should run `validateWhenOption` once to
226
+ * surface authoring errors — `evaluateWhenOption` is the per-install
227
+ * runtime decision and assumes structurally valid input.
228
+ *
229
+ * @param {string} spec
230
+ * @param {Record<string, any>|null} effectiveOptions
231
+ * @returns {boolean}
232
+ */
233
+ function evaluateWhenOption(spec, effectiveOptions) {
234
+ const parsed = parseWhenOption(spec);
235
+ const actual = effectiveOptions?.[parsed.name];
236
+ return actual === parsed.value;
237
+ }
238
+
107
239
  /**
108
240
  * Reads and parses a wyvrn.json manifest from disk.
109
241
  *
@@ -160,6 +292,10 @@ module.exports = {
160
292
  VALID_KINDS,
161
293
  defaultManifest,
162
294
  normalizeDependencies,
295
+ normalizeManifestDeps,
296
+ parseWhenOption,
297
+ validateWhenOption,
298
+ evaluateWhenOption,
163
299
  readManifest,
164
300
  writeManifest,
165
301
  };
@@ -343,8 +343,86 @@ function generateCMakePresets({
343
343
  return { path: null, action: 'skipped', isUser: false };
344
344
  }
345
345
 
346
+ /**
347
+ * Strip every wyvrnpm-generated entry from a parsed presets object.
348
+ * Mirror of `mergeIntoExisting` — used by `wyvrnpm clean --all` to
349
+ * leave no trace of our injections in the consumer's CMakePresets.json.
350
+ *
351
+ * Both `configurePresets` and `buildPresets` are filtered for names
352
+ * matching `/^wyvrn-/`. The wyvrnpm `vendor` entry is always removed.
353
+ *
354
+ * Returns:
355
+ * - `result: null` → the file is now empty of meaningful content
356
+ * (no presets of either kind, no other vendor
357
+ * entries, no other top-level keys beyond the
358
+ * schema `version`). Caller should delete the
359
+ * file rather than write a husk.
360
+ * - `result: object` → the trimmed object the caller should write
361
+ * back. Foreign vendor blocks, user-named
362
+ * presets, and any other top-level keys are
363
+ * preserved.
364
+ * - `removed: string[]` → names of every preset (configure + build)
365
+ * that was stripped. Useful for dry-run
366
+ * reporting.
367
+ *
368
+ * @param {object|null} existing
369
+ * @returns {{ result: object|null, removed: string[] }}
370
+ */
371
+ function stripWyvrnPresets(existing) {
372
+ if (!existing || typeof existing !== 'object') {
373
+ return { result: null, removed: [] };
374
+ }
375
+
376
+ const isWyvrnName = (n) => typeof n === 'string' && n.startsWith('wyvrn-');
377
+
378
+ const removed = [];
379
+ const configurePresets = (existing.configurePresets ?? []).filter((p) => {
380
+ if (p && isWyvrnName(p.name)) { removed.push(p.name); return false; }
381
+ return true;
382
+ });
383
+ const buildPresets = (existing.buildPresets ?? []).filter((p) => {
384
+ if (p && (isWyvrnName(p.name) || isWyvrnName(p.configurePreset))) {
385
+ removed.push(p.name);
386
+ return false;
387
+ }
388
+ return true;
389
+ });
390
+
391
+ // Drop our vendor marker; preserve any foreign vendor entries.
392
+ let vendor = existing.vendor;
393
+ if (vendor && typeof vendor === 'object') {
394
+ const { [VENDOR_KEY]: _ours, ...rest } = vendor;
395
+ vendor = Object.keys(rest).length > 0 ? rest : undefined;
396
+ }
397
+
398
+ // Decide if anything meaningful survives. The schema `version` field
399
+ // is metadata, not content — a file with only `{ version: 3 }` is a
400
+ // husk we should delete.
401
+ const hasSurvivingPresets = configurePresets.length > 0 || buildPresets.length > 0;
402
+ const hasForeignVendor = vendor !== undefined;
403
+ const otherTopLevelKeys = Object.keys(existing).filter(
404
+ (k) => k !== 'version' && k !== 'vendor' && k !== 'configurePresets' && k !== 'buildPresets',
405
+ );
406
+
407
+ if (!hasSurvivingPresets && !hasForeignVendor && otherTopLevelKeys.length === 0) {
408
+ return { result: null, removed };
409
+ }
410
+
411
+ const result = { ...existing };
412
+ if (configurePresets.length > 0) result.configurePresets = configurePresets;
413
+ else delete result.configurePresets;
414
+ if (buildPresets.length > 0) result.buildPresets = buildPresets;
415
+ else delete result.buildPresets;
416
+ if (vendor !== undefined) result.vendor = vendor;
417
+ else delete result.vendor;
418
+
419
+ return { result, removed };
420
+ }
421
+
346
422
  module.exports = {
347
423
  generateCMakePresets,
424
+ stripWyvrnPresets,
425
+ safeReadJson,
348
426
  // Exported for tests
349
427
  isWyvrnGenerated,
350
428
  buildConfigurePreset,