wyvrnpm 2.4.1 → 2.9.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/bin/wyvrn.js CHANGED
@@ -13,6 +13,7 @@ const profile = require('../src/commands/profile');
13
13
  const build = require('../src/commands/build');
14
14
  const show = require('../src/commands/show');
15
15
  const installSkill = require('../src/commands/install-skill');
16
+ const cache = require('../src/commands/cache');
16
17
  const { link, unlink } = require('../src/commands/link');
17
18
 
18
19
  yargs
@@ -86,8 +87,8 @@ yargs
86
87
  choices: ['never', 'missing', 'always'],
87
88
  default: 'never',
88
89
  description:
89
- 'Resolution mode when no exact profile match exists. ' +
90
- 'never=fail; missing=build from source (pending phase 3); always=rebuild (pending phase 3)',
90
+ 'Resolution strategy. never=fail when no exact match; ' +
91
+ 'missing=source-build only when no match; always=ignore registry binaries and rebuild every dep from source (debug/verification).',
91
92
  })
92
93
  .option('option', {
93
94
  alias: 'o',
@@ -97,6 +98,25 @@ yargs
97
98
  'Override a package option. Repeatable. Format: <pkg>:<name>=<value>. ' +
98
99
  'Example: -o zlib:minizip=false -o OpenSSL:fips=true',
99
100
  })
101
+ .option('dry-run', {
102
+ type: 'boolean',
103
+ default: false,
104
+ description:
105
+ 'Run the resolver + profile-hash math and print the install plan (every ' +
106
+ 'dep, its pinned version, effective profileHash, per-dep option overrides) ' +
107
+ 'without downloading, extracting, writing wyvrn.lock, or emitting the ' +
108
+ 'toolchain. Combine with --format=json for CI-friendly output.',
109
+ })
110
+ .option('conf', {
111
+ type: 'string',
112
+ array: true,
113
+ description:
114
+ 'Non-ABI build-time override. Repeatable. Format: ' +
115
+ '<namespace.leaf>=<value> — e.g. --conf cmake.cache.CHROMA_ENABLE_TEST=ON. ' +
116
+ 'Bare KEY is sugar for KEY=ON (CMake -D convention); KEY= (empty RHS) ' +
117
+ 'clears an inherited value. Does NOT fold into profileHash — see ' +
118
+ 'claude/PLAN-CONF.md for layering rules.',
119
+ })
100
120
  .option('upload-built', {
101
121
  type: 'boolean',
102
122
  default: false,
@@ -117,7 +137,11 @@ yargs
117
137
  })
118
138
  .option('token', {
119
139
  type: 'string',
120
- description: 'Bearer token for --upload-built when uploading to HTTP',
140
+ description: 'Bearer token for --upload-built when uploading to HTTP. Prefer --token-env in CI so the literal value never lands in argv.',
141
+ })
142
+ .option('token-env', {
143
+ type: 'string',
144
+ description: 'Name of an env var whose value is the bearer token for --upload-built. Preferred over --token in CI.',
121
145
  })
122
146
  .option('platform', {
123
147
  type: 'string',
@@ -142,6 +166,8 @@ yargs
142
166
  uploadBuilt: argv['upload-built'],
143
167
  uploadSource: argv['upload-source'],
144
168
  awsProfile: argv['aws-profile'],
169
+ tokenEnv: argv['token-env'],
170
+ dryRun: argv['dry-run'],
145
171
  }),
146
172
  )
147
173
 
@@ -165,9 +191,21 @@ yargs
165
191
  .option('config', {
166
192
  alias: 'c',
167
193
  type: 'string',
168
- choices: ['Debug', 'Release', 'RelWithDebInfo', 'MinSizeRel'],
169
194
  default: 'Release',
170
- description: 'Build configuration',
195
+ description:
196
+ 'Build configuration. Accepts a single value (Release) or a ' +
197
+ 'comma-separated list (Debug,Release) to build multiple configs ' +
198
+ 'in one invocation — configure runs once, build (+ --install) ' +
199
+ 'loops per config. Valid: Debug | Release | RelWithDebInfo | MinSizeRel.',
200
+ })
201
+ .option('all-configs', {
202
+ type: 'boolean',
203
+ default: false,
204
+ description:
205
+ 'Build every config declared in the recipe\'s build.configs ' +
206
+ '(falls back to all four canonical configs when no recipe is ' +
207
+ 'present). Equivalent to spelling out --config Debug,Release,... ' +
208
+ 'by hand. Pairs naturally with --install.',
171
209
  })
172
210
  .option('target', {
173
211
  alias: 't',
@@ -220,15 +258,94 @@ yargs
220
258
  .option('timeout', {
221
259
  type: 'number',
222
260
  default: 300,
261
+ })
262
+ .option('conf', {
263
+ type: 'string',
264
+ array: true,
265
+ description:
266
+ 'Non-ABI build-time override forwarded to cmake --preset as -D / -U. ' +
267
+ 'Repeatable. Format: <namespace.leaf>=<value> — e.g. ' +
268
+ '--conf cmake.cache.CHROMA_ENABLE_TEST=ON. CLI --conf does NOT ' +
269
+ 'regenerate CMakePresets.json; edit wyvrn.local.json and re-run ' +
270
+ '`wyvrnpm install` for persistent overrides. See claude/PLAN-CONF.md.',
223
271
  });
224
272
  },
225
273
  (argv) => build({
226
274
  ...argv,
227
275
  autoInstall: argv['auto-install'],
228
276
  installPrefix: argv['install-prefix'],
277
+ allConfigs: argv['all-configs'],
229
278
  }),
230
279
  )
231
280
 
281
+ // ── cache ─────────────────────────────────────────────────────────────────
282
+ .command(
283
+ 'cache',
284
+ 'Inspect and selectively prune the machine-wide source-build cache (F12). Use `clean --build-cache` to wipe everything.',
285
+ (y) => {
286
+ y
287
+ .command(
288
+ 'list [pattern]',
289
+ 'List cache entries. Optional glob pattern filters by package name.',
290
+ (y2) => {
291
+ y2
292
+ .positional('pattern', {
293
+ type: 'string',
294
+ description: 'Glob over package names, e.g. "boost*" (alias for --pattern)',
295
+ })
296
+ .option('pattern', {
297
+ type: 'string',
298
+ description: 'Glob over package names; overrides the positional',
299
+ })
300
+ .option('format', {
301
+ type: 'string',
302
+ choices: ['text', 'json'],
303
+ default: 'text',
304
+ description: 'Output format',
305
+ });
306
+ },
307
+ (argv) => cache.list(argv),
308
+ )
309
+ .command(
310
+ 'prune',
311
+ 'Remove cache entries per policy. Requires at least one of --keep-last / --older-than.',
312
+ (y2) => {
313
+ y2
314
+ .option('keep-last', {
315
+ type: 'number',
316
+ description:
317
+ 'Retain the N most recently-touched entries per (name, version). ' +
318
+ 'Older variants beyond N are pruned.',
319
+ })
320
+ .option('older-than', {
321
+ type: 'string',
322
+ description:
323
+ 'Remove entries whose mtime is older than this threshold. ' +
324
+ 'Format: <number><unit>, unit ∈ s/m/h/d (e.g. 30d, 72h).',
325
+ })
326
+ .option('pattern', {
327
+ type: 'string',
328
+ description: 'Limit scope to package names matching this glob (e.g. "boost*")',
329
+ })
330
+ .option('dry-run', {
331
+ type: 'boolean',
332
+ default: false,
333
+ description: 'Print what would be removed without actually deleting',
334
+ });
335
+ },
336
+ (argv) => cache.prune({
337
+ ...argv,
338
+ keepLast: argv['keep-last'],
339
+ olderThan: argv['older-than'],
340
+ dryRun: argv['dry-run'],
341
+ }),
342
+ )
343
+ .demandCommand(1)
344
+ .help();
345
+ },
346
+ () => {},
347
+ )
348
+
232
349
  // ── clean ─────────────────────────────────────────────────────────────────
233
350
  .command(
234
351
  'clean',
@@ -278,7 +395,11 @@ yargs
278
395
  })
279
396
  .option('token', {
280
397
  type: 'string',
281
- description: 'Bearer token for HTTP sources',
398
+ description: 'Bearer token for HTTP sources. Prefer --token-env in CI so the literal value never lands in argv.',
399
+ })
400
+ .option('token-env', {
401
+ type: 'string',
402
+ description: 'Name of an env var whose value is the bearer token for HTTP sources.',
282
403
  })
283
404
  .option('format', {
284
405
  type: 'string',
@@ -287,7 +408,7 @@ yargs
287
408
  description: 'Output format. "json" emits a single JSON object on stdout; log lines go to stderr.',
288
409
  });
289
410
  },
290
- (argv) => show({ ...argv, awsProfile: argv['aws-profile'] }),
411
+ (argv) => show({ ...argv, awsProfile: argv['aws-profile'], tokenEnv: argv['token-env'] }),
291
412
  )
292
413
 
293
414
  // ── publish ───────────────────────────────────────────────────────────────
@@ -318,7 +439,11 @@ yargs
318
439
  })
319
440
  .option('token', {
320
441
  type: 'string',
321
- description: 'Bearer token for HTTP server authentication',
442
+ description: 'Bearer token for HTTP server authentication. Prefer --token-env in CI so the literal value never lands in argv.',
443
+ })
444
+ .option('token-env', {
445
+ type: 'string',
446
+ description: 'Name of an env var whose value is the bearer token for HTTP authentication.',
322
447
  })
323
448
  .option('force', {
324
449
  alias: 'f',
@@ -334,6 +459,15 @@ yargs
334
459
  'Override an option value at publish time. Repeatable. ' +
335
460
  'Format: <pkg>:<name>=<value> (pkg must match the package being published).',
336
461
  })
462
+ .option('conf', {
463
+ type: 'string',
464
+ array: true,
465
+ description:
466
+ 'Non-ABI build-time override recorded as `publishedConf` on the ' +
467
+ 'uploaded manifest (informational — does not affect profileHash). ' +
468
+ 'Reads recipe conf + CLI only; wyvrn.local.json is intentionally ' +
469
+ 'ignored for publish.',
470
+ })
337
471
  .option('format', {
338
472
  type: 'string',
339
473
  choices: ['text', 'json'],
@@ -342,7 +476,7 @@ yargs
342
476
  })
343
477
  ;
344
478
  },
345
- (argv) => publish({ ...argv, awsProfile: argv['aws-profile'] }),
479
+ (argv) => publish({ ...argv, awsProfile: argv['aws-profile'], tokenEnv: argv['token-env'] }),
346
480
  )
347
481
 
348
482
  // ── configure ─────────────────────────────────────────────────────────────
@@ -364,7 +498,8 @@ yargs
364
498
  .option('name', { type: 'string', demandOption: true })
365
499
  .option('url', { type: 'string', demandOption: true })
366
500
  .option('profile', { type: 'string', description: 'AWS SSO profile (S3 only)' })
367
- .option('token', { type: 'string', description: 'Bearer token (HTTP only)' });
501
+ .option('token', { type: 'string', description: 'Bearer token (HTTP only). Persists in plaintext in config.json — prefer --token-env for CI/shared setups.' })
502
+ .option('token-env', { type: 'string', description: 'Name of an env var whose value is the bearer token (HTTP only). Only the name is persisted; the value is resolved at runtime.' });
368
503
  },
369
504
  (argv) => configure.addSource(argv),
370
505
  )
@@ -438,7 +573,15 @@ yargs
438
573
  .option('compiler', { type: 'string', description: 'Compiler (msvc | gcc | clang | apple-clang)' })
439
574
  .option('compiler-version', { type: 'string', description: 'Compiler version (193 | 11 | 14 | ...)' })
440
575
  .option('cppstd', { type: 'string', description: 'C++ standard (14 | 17 | 20 | 23)' })
441
- .option('runtime', { type: 'string', description: 'Runtime linkage (static | dynamic)' });
576
+ .option('runtime', { type: 'string', description: 'Runtime linkage (static | dynamic)' })
577
+ .option('conf', {
578
+ type: 'string',
579
+ array: true,
580
+ description:
581
+ 'Profile-level non-ABI build knob — <namespace.leaf>=<value> ' +
582
+ '(repeatable). `KEY` alone means `KEY=ON`; `KEY=` removes the ' +
583
+ 'leaf. Phase 1 allow-list: cmake.cache.*. See PLAN-CONF.md §4.',
584
+ });
442
585
  },
443
586
  (argv) => profile.set({ ...argv, compilerVersion: argv['compiler-version'] }),
444
587
  )
Binary file
@@ -161,9 +161,19 @@ else()
161
161
  message(STATUS "Unknown architecture")
162
162
  endif()
163
163
 
164
- set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY "${CMAKE_SOURCE_DIR}/output/lib/")
165
- set(CMAKE_LIBRARY_OUTPUT_DIRECTORY "${CMAKE_SOURCE_DIR}/output/lib/")
166
- set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${CMAKE_SOURCE_DIR}/output/bin")
164
+ # Output dirs are suffixed with WYVRN_ARCH_SUFFIX ("64" / "32" / "") so
165
+ # multi-arch builds from the same source tree don't clobber each other
166
+ # (e.g. building x64 then x86 used to silently overwrite the x64 DLLs
167
+ # in output/bin/<CONFIG>/). Single-arch consumers see no path change on
168
+ # x86 or unmapped arches — the suffix is empty there. Set defensively:
169
+ # if wyvrn_toolchain.cmake hasn't been included (standalone cmake runs),
170
+ # WYVRN_ARCH_SUFFIX is undefined and we fall back to the legacy layout.
171
+ if(NOT DEFINED WYVRN_ARCH_SUFFIX)
172
+ set(WYVRN_ARCH_SUFFIX "")
173
+ endif()
174
+ set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY "${CMAKE_SOURCE_DIR}/output/lib${WYVRN_ARCH_SUFFIX}/")
175
+ set(CMAKE_LIBRARY_OUTPUT_DIRECTORY "${CMAKE_SOURCE_DIR}/output/lib${WYVRN_ARCH_SUFFIX}/")
176
+ set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${CMAKE_SOURCE_DIR}/output/bin${WYVRN_ARCH_SUFFIX}")
167
177
 
168
178
  add_compile_definitions(
169
179
  $<$<CONFIG:Debug>:WYVRN_DEBUG>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wyvrnpm",
3
- "version": "2.4.1",
3
+ "version": "2.9.0",
4
4
  "description": "A simple, static-hosting-compatible C++ package manager",
5
5
  "keywords": [
6
6
  "c++",
package/src/auth.js ADDED
@@ -0,0 +1,66 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Resolve an authentication token for HTTP-source reads/writes.
5
+ *
6
+ * Closes EVALUATION.md S8 — passing `--token <value>` on the command line
7
+ * leaks the token into any shell history, process table, or CI job log
8
+ * that captures argv. `--token-env <VAR>` and the config equivalent
9
+ * `tokenEnv` let users keep the literal value in env only.
10
+ *
11
+ * Resolution order (first match wins):
12
+ * 1. `token` — literal string from CLI or config (explicit beats env).
13
+ * 2. `tokenEnv` — name of an env var; its value becomes the token.
14
+ *
15
+ * Returns `undefined` when nothing is configured (valid — not every source
16
+ * needs auth). Throws when `tokenEnv` names a var that is unset or empty.
17
+ *
18
+ * @param {{ token?: string, tokenEnv?: string }} opts
19
+ * @returns {string|undefined}
20
+ */
21
+ function resolveToken(opts = {}) {
22
+ const { token, tokenEnv } = opts;
23
+
24
+ if (typeof token === 'string' && token.length > 0) {
25
+ return token;
26
+ }
27
+
28
+ if (typeof tokenEnv === 'string' && tokenEnv.length > 0) {
29
+ const value = process.env[tokenEnv];
30
+ if (typeof value !== 'string' || value.length === 0) {
31
+ throw new Error(
32
+ `--token-env ${tokenEnv} (or config tokenEnv=${tokenEnv}) is set, ` +
33
+ `but the environment variable is unset or empty.`,
34
+ );
35
+ }
36
+ return value;
37
+ }
38
+
39
+ return undefined;
40
+ }
41
+
42
+ /**
43
+ * Merge CLI auth args + a source-config entry into a final `{ token }`
44
+ * object ready for passing to a provider. Centralises the "CLI wins,
45
+ * then env-backed CLI, then config literal, then config env-backed"
46
+ * precedence so call sites don't each re-implement it.
47
+ *
48
+ * @param {{ token?: string, tokenEnv?: string }} cli
49
+ * @param {{ token?: string, tokenEnv?: string } | null | undefined} source
50
+ * @returns {{ token: string|undefined }}
51
+ */
52
+ function resolveSourceAuth(cli, source) {
53
+ // Try CLI first; if it produced nothing, fall back to the source's
54
+ // own stored auth. Each call to resolveToken handles its own
55
+ // precedence (literal > env) and throws on invalid tokenEnv.
56
+ const fromCli = resolveToken(cli);
57
+ if (fromCli !== undefined) return { token: fromCli };
58
+
59
+ const fromSource = source ? resolveToken(source) : undefined;
60
+ return { token: fromSource };
61
+ }
62
+
63
+ module.exports = {
64
+ resolveToken,
65
+ resolveSourceAuth,
66
+ };
@@ -136,6 +136,196 @@ function clearUploadSidecars() {
136
136
  return removed;
137
137
  }
138
138
 
139
+ // ---------------------------------------------------------------------------
140
+ // Cache inspection (F12)
141
+ // ---------------------------------------------------------------------------
142
+
143
+ /**
144
+ * Dotted-version + 16+ hex-hash suffix. The hash tail is unambiguous
145
+ * (always hex, always 16 chars per src/profile.js) so parsing from the
146
+ * end avoids ambiguity when package names contain dashes
147
+ * (`boost-filesystem-1.80.0.0-abc1234567890def`).
148
+ */
149
+ const CACHE_DIR_RE = /^(.+)-(\d+(?:\.\d+)+)-([a-f0-9]{16,})$/;
150
+
151
+ /**
152
+ * Recursively compute total size of a directory in bytes. Skips symlinks
153
+ * to avoid accidental cycles through user-linked packages.
154
+ *
155
+ * @param {string} dir
156
+ * @returns {number}
157
+ */
158
+ function dirSizeBytes(dir) {
159
+ let total = 0;
160
+ let entries;
161
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
162
+ catch { return 0; }
163
+ for (const e of entries) {
164
+ if (e.isSymbolicLink()) continue;
165
+ const p = path.join(dir, e.name);
166
+ try {
167
+ if (e.isDirectory()) {
168
+ total += dirSizeBytes(p);
169
+ } else if (e.isFile()) {
170
+ total += fs.statSync(p).size;
171
+ }
172
+ } catch { /* concurrent deletion — ignore */ }
173
+ }
174
+ return total;
175
+ }
176
+
177
+ /**
178
+ * List every entry in the source-build cache, enriched with metadata.
179
+ * Unparseable directory names are skipped (corrupt / manual cruft).
180
+ *
181
+ * @returns {Array<{
182
+ * name: string,
183
+ * version: string,
184
+ * profileHash: string,
185
+ * cacheDir: string,
186
+ * sizeBytes: number,
187
+ * mtimeMs: number,
188
+ * hasArtefact: boolean,
189
+ * uploads: Array<object>,
190
+ * }>}
191
+ */
192
+ function listCacheEntries() {
193
+ const root = getBuildCacheRoot();
194
+ if (!fs.existsSync(root)) return [];
195
+
196
+ const out = [];
197
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
198
+ if (!entry.isDirectory()) continue;
199
+
200
+ const m = entry.name.match(CACHE_DIR_RE);
201
+ if (!m) continue; // corrupt / unknown — skip silently
202
+ const [, name, version, profileHash] = m;
203
+
204
+ const cacheDir = path.join(root, entry.name);
205
+ const artefact = path.join(cacheDir, LAYOUT.artefactZip);
206
+ const sidecarPath = path.join(cacheDir, LAYOUT.uploadSidecar);
207
+
208
+ let mtimeMs = 0;
209
+ try { mtimeMs = fs.statSync(cacheDir).mtimeMs; } catch { /* skip */ }
210
+
211
+ let uploads = [];
212
+ if (fs.existsSync(sidecarPath)) {
213
+ try {
214
+ const raw = JSON.parse(fs.readFileSync(sidecarPath, 'utf8'));
215
+ if (Array.isArray(raw?.uploads)) uploads = raw.uploads;
216
+ } catch { /* corrupt sidecar — surface empty */ }
217
+ }
218
+
219
+ out.push({
220
+ name, version, profileHash, cacheDir,
221
+ sizeBytes: dirSizeBytes(cacheDir),
222
+ mtimeMs,
223
+ hasArtefact: fs.existsSync(artefact),
224
+ uploads,
225
+ });
226
+ }
227
+ return out;
228
+ }
229
+
230
+ /**
231
+ * Parse `--older-than` duration strings (`7d`, `24h`, `30m`, `3600s`).
232
+ * Returns the cutoff timestamp in ms-since-epoch: entries with
233
+ * `mtimeMs < cutoff` are older than the threshold.
234
+ *
235
+ * @param {string} spec
236
+ * @param {number} [now=Date.now()]
237
+ * @returns {number}
238
+ */
239
+ function parseDurationToCutoff(spec, now = Date.now()) {
240
+ if (typeof spec !== 'string') {
241
+ throw new Error(`--older-than expects a string like "30d", got ${JSON.stringify(spec)}`);
242
+ }
243
+ const m = spec.trim().match(/^(\d+)\s*([smhd])$/i);
244
+ if (!m) {
245
+ throw new Error(
246
+ `--older-than ${JSON.stringify(spec)} must be <number><unit> where unit is s/m/h/d`,
247
+ );
248
+ }
249
+ const n = parseInt(m[1], 10);
250
+ const unit = m[2].toLowerCase();
251
+ const ms = { s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000 }[unit];
252
+ return now - n * ms;
253
+ }
254
+
255
+ /**
256
+ * Compute which cache entries would be pruned under the given policy.
257
+ * Pure — does no I/O of its own. Caller passes in `entries` (from
258
+ * `listCacheEntries()`) so tests can synthesise them.
259
+ *
260
+ * Policy semantics:
261
+ * - `keepLast` is **per (name, version) group** — preserves the N most
262
+ * recent profileHash variants for each version so multi-profile
263
+ * dev rigs don't lose every profile on a single prune.
264
+ * - `olderThanMs` applies after `keepLast`; it only widens the set
265
+ * of eligible-for-pruning entries; never adds entries back.
266
+ * - `patternRegex` filters entries by name at the very start.
267
+ *
268
+ * Returns the prune set in descending mtime order (newest-of-the-
269
+ * doomed first) — handy for human-friendly "about to remove" output.
270
+ *
271
+ * @param {ReturnType<typeof listCacheEntries>} entries
272
+ * @param {{ keepLast?: number, olderThanMs?: number, patternRegex?: RegExp }} policy
273
+ * @returns {ReturnType<typeof listCacheEntries>}
274
+ */
275
+ function computePruneSet(entries, { keepLast, olderThanMs, patternRegex } = {}) {
276
+ let candidates = entries;
277
+ if (patternRegex) candidates = candidates.filter((e) => patternRegex.test(e.name));
278
+
279
+ // Group by (name, version); within each group, sort by mtime desc,
280
+ // mark the top N as keepers when keepLast is set.
281
+ const keep = new Set();
282
+ if (typeof keepLast === 'number' && keepLast >= 0) {
283
+ const groups = new Map();
284
+ for (const e of candidates) {
285
+ const k = `${e.name}\x1f${e.version}`;
286
+ if (!groups.has(k)) groups.set(k, []);
287
+ groups.get(k).push(e);
288
+ }
289
+ for (const group of groups.values()) {
290
+ group.sort((a, b) => b.mtimeMs - a.mtimeMs);
291
+ for (let i = 0; i < Math.min(keepLast, group.length); i++) {
292
+ keep.add(group[i].cacheDir);
293
+ }
294
+ }
295
+ } else {
296
+ // No keepLast → every candidate is eligible for pruning subject to
297
+ // the other filters.
298
+ }
299
+
300
+ const toPrune = candidates.filter((e) => !keep.has(e.cacheDir));
301
+
302
+ const filtered = typeof olderThanMs === 'number'
303
+ ? toPrune.filter((e) => e.mtimeMs < olderThanMs)
304
+ : toPrune;
305
+
306
+ filtered.sort((a, b) => b.mtimeMs - a.mtimeMs);
307
+ return filtered;
308
+ }
309
+
310
+ /**
311
+ * Delete a list of cache entries. Returns the subset that was actually
312
+ * removed (permissions failures surface as misses). Caller should have
313
+ * already computed the set via `computePruneSet`.
314
+ *
315
+ * @param {ReturnType<typeof listCacheEntries>} entries
316
+ * @returns {ReturnType<typeof listCacheEntries>}
317
+ */
318
+ function removeCacheEntries(entries) {
319
+ const removed = [];
320
+ for (const e of entries) {
321
+ try {
322
+ fs.rmSync(e.cacheDir, { recursive: true, force: true });
323
+ removed.push(e);
324
+ } catch { /* skip failures — caller reports the delta */ }
325
+ }
326
+ return removed;
327
+ }
328
+
139
329
  module.exports = {
140
330
  getBuildCacheRoot,
141
331
  getPackageBuildDir,
@@ -145,4 +335,10 @@ module.exports = {
145
335
  listUploadSidecars,
146
336
  clearUploadSidecars,
147
337
  LAYOUT,
338
+ // Cache inspection (F12)
339
+ listCacheEntries,
340
+ computePruneSet,
341
+ removeCacheEntries,
342
+ parseDurationToCutoff,
343
+ CACHE_DIR_RE,
148
344
  };
@@ -14,6 +14,8 @@ const { resolveDependencies } = require('../resolve');
14
14
  const { hashProfile, mergeProfile, sha256Of } = require('../profile');
15
15
  const { generateToolchain } = require('../toolchain');
16
16
  const { normalizeOptionsDeclaration, resolveEffectiveOptions } = require('../options');
17
+ const { readRecipeConf, cmakeCacheVariables } = require('../conf');
18
+ const { wyvrnFetch } = require('../http-fetch');
17
19
  const log = require('../logger');
18
20
 
19
21
  /**
@@ -155,8 +157,28 @@ async function buildFromSource(args) {
155
157
  }
156
158
  // Pass effective options into the recipe so `${options.<name>}` tokens in
157
159
  // `build.configure` / `build.buildArgs` expand to the concrete values that
158
- // produced this build's profileHash.
159
- const recipe = normalizeRecipe(clonedManifest.build, options);
160
+ // produced this build's profileHash. Pass the cloned manifest so PLAN-CONF.md
161
+ // §4.7 collision detection catches a publisher who declared the same KEY in
162
+ // both build.configure and conf.cmake.cache.
163
+ const recipe = normalizeRecipe(clonedManifest.build, options, clonedManifest);
164
+
165
+ // PLAN-CONF.md §4.8.2: source-build of a dep consumes ONLY the dep's own
166
+ // recipe conf — never the root project's merged conf. Translating the
167
+ // dep's cmake.cache namespace into -D args here keeps the dep's source-
168
+ // build CMake invocation aligned with its author's declared defaults
169
+ // while preserving cross-consumer reproducibility. The root's CLI
170
+ // --conf / wyvrn.local.json overlay is invisible at this seam.
171
+ const clonedConfFlat = readRecipeConf(clonedManifest);
172
+ const clonedCacheVars = cmakeCacheVariables(clonedConfFlat);
173
+ if (Object.keys(clonedCacheVars).length > 0) {
174
+ const confDerivedArgs = Object.entries(clonedCacheVars)
175
+ .map(([k, v]) => `-D${k}=${v}`);
176
+ recipe.configure = [...recipe.configure, ...confDerivedArgs];
177
+ log.info(
178
+ ` conf.cmake.cache → ${confDerivedArgs.length} source-build ` +
179
+ `configure arg(s) from the dep's own recipe`,
180
+ );
181
+ }
160
182
 
161
183
  // ── 4. Recursively resolve + download this package's OWN deps ──────────
162
184
  // Lazy-require: downloadDependencies is where we were called from, so
@@ -174,7 +196,7 @@ async function buildFromSource(args) {
174
196
  log.info(` resolving ${Object.keys(depsForResolution).length} transitive dep(s)`);
175
197
  const transitiveManifests = new Map();
176
198
  const resolvedDeps = await resolveDependencies(
177
- depsForResolution, packageSources, platform, fetch, new Map(),
199
+ depsForResolution, packageSources, platform, wyvrnFetch, new Map(),
178
200
  transitiveManifests,
179
201
  );
180
202
 
@@ -210,7 +232,7 @@ async function buildFromSource(args) {
210
232
  if (enrichedDeps.size > 0) {
211
233
  await downloadDependencies(
212
234
  enrichedDeps, packageSources, platform, paths.internal,
213
- fetch, Math.max(30, Math.round(timeoutMs / 1000)),
235
+ wyvrnFetch, Math.max(30, Math.round(timeoutMs / 1000)),
214
236
  { ...authOptions, buildMode },
215
237
  );
216
238
  } else {