wyvrnpm 2.4.1 → 2.8.1

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
 
@@ -220,6 +246,16 @@ yargs
220
246
  .option('timeout', {
221
247
  type: 'number',
222
248
  default: 300,
249
+ })
250
+ .option('conf', {
251
+ type: 'string',
252
+ array: true,
253
+ description:
254
+ 'Non-ABI build-time override forwarded to cmake --preset as -D / -U. ' +
255
+ 'Repeatable. Format: <namespace.leaf>=<value> — e.g. ' +
256
+ '--conf cmake.cache.CHROMA_ENABLE_TEST=ON. CLI --conf does NOT ' +
257
+ 'regenerate CMakePresets.json; edit wyvrn.local.json and re-run ' +
258
+ '`wyvrnpm install` for persistent overrides. See claude/PLAN-CONF.md.',
223
259
  });
224
260
  },
225
261
  (argv) => build({
@@ -229,6 +265,74 @@ yargs
229
265
  }),
230
266
  )
231
267
 
268
+ // ── cache ─────────────────────────────────────────────────────────────────
269
+ .command(
270
+ 'cache',
271
+ 'Inspect and selectively prune the machine-wide source-build cache (F12). Use `clean --build-cache` to wipe everything.',
272
+ (y) => {
273
+ y
274
+ .command(
275
+ 'list [pattern]',
276
+ 'List cache entries. Optional glob pattern filters by package name.',
277
+ (y2) => {
278
+ y2
279
+ .positional('pattern', {
280
+ type: 'string',
281
+ description: 'Glob over package names, e.g. "boost*" (alias for --pattern)',
282
+ })
283
+ .option('pattern', {
284
+ type: 'string',
285
+ description: 'Glob over package names; overrides the positional',
286
+ })
287
+ .option('format', {
288
+ type: 'string',
289
+ choices: ['text', 'json'],
290
+ default: 'text',
291
+ description: 'Output format',
292
+ });
293
+ },
294
+ (argv) => cache.list(argv),
295
+ )
296
+ .command(
297
+ 'prune',
298
+ 'Remove cache entries per policy. Requires at least one of --keep-last / --older-than.',
299
+ (y2) => {
300
+ y2
301
+ .option('keep-last', {
302
+ type: 'number',
303
+ description:
304
+ 'Retain the N most recently-touched entries per (name, version). ' +
305
+ 'Older variants beyond N are pruned.',
306
+ })
307
+ .option('older-than', {
308
+ type: 'string',
309
+ description:
310
+ 'Remove entries whose mtime is older than this threshold. ' +
311
+ 'Format: <number><unit>, unit ∈ s/m/h/d (e.g. 30d, 72h).',
312
+ })
313
+ .option('pattern', {
314
+ type: 'string',
315
+ description: 'Limit scope to package names matching this glob (e.g. "boost*")',
316
+ })
317
+ .option('dry-run', {
318
+ type: 'boolean',
319
+ default: false,
320
+ description: 'Print what would be removed without actually deleting',
321
+ });
322
+ },
323
+ (argv) => cache.prune({
324
+ ...argv,
325
+ keepLast: argv['keep-last'],
326
+ olderThan: argv['older-than'],
327
+ dryRun: argv['dry-run'],
328
+ }),
329
+ )
330
+ .demandCommand(1)
331
+ .help();
332
+ },
333
+ () => {},
334
+ )
335
+
232
336
  // ── clean ─────────────────────────────────────────────────────────────────
233
337
  .command(
234
338
  'clean',
@@ -278,7 +382,11 @@ yargs
278
382
  })
279
383
  .option('token', {
280
384
  type: 'string',
281
- description: 'Bearer token for HTTP sources',
385
+ description: 'Bearer token for HTTP sources. Prefer --token-env in CI so the literal value never lands in argv.',
386
+ })
387
+ .option('token-env', {
388
+ type: 'string',
389
+ description: 'Name of an env var whose value is the bearer token for HTTP sources.',
282
390
  })
283
391
  .option('format', {
284
392
  type: 'string',
@@ -287,7 +395,7 @@ yargs
287
395
  description: 'Output format. "json" emits a single JSON object on stdout; log lines go to stderr.',
288
396
  });
289
397
  },
290
- (argv) => show({ ...argv, awsProfile: argv['aws-profile'] }),
398
+ (argv) => show({ ...argv, awsProfile: argv['aws-profile'], tokenEnv: argv['token-env'] }),
291
399
  )
292
400
 
293
401
  // ── publish ───────────────────────────────────────────────────────────────
@@ -318,7 +426,11 @@ yargs
318
426
  })
319
427
  .option('token', {
320
428
  type: 'string',
321
- description: 'Bearer token for HTTP server authentication',
429
+ description: 'Bearer token for HTTP server authentication. Prefer --token-env in CI so the literal value never lands in argv.',
430
+ })
431
+ .option('token-env', {
432
+ type: 'string',
433
+ description: 'Name of an env var whose value is the bearer token for HTTP authentication.',
322
434
  })
323
435
  .option('force', {
324
436
  alias: 'f',
@@ -334,6 +446,15 @@ yargs
334
446
  'Override an option value at publish time. Repeatable. ' +
335
447
  'Format: <pkg>:<name>=<value> (pkg must match the package being published).',
336
448
  })
449
+ .option('conf', {
450
+ type: 'string',
451
+ array: true,
452
+ description:
453
+ 'Non-ABI build-time override recorded as `publishedConf` on the ' +
454
+ 'uploaded manifest (informational — does not affect profileHash). ' +
455
+ 'Reads recipe conf + CLI only; wyvrn.local.json is intentionally ' +
456
+ 'ignored for publish.',
457
+ })
337
458
  .option('format', {
338
459
  type: 'string',
339
460
  choices: ['text', 'json'],
@@ -342,7 +463,7 @@ yargs
342
463
  })
343
464
  ;
344
465
  },
345
- (argv) => publish({ ...argv, awsProfile: argv['aws-profile'] }),
466
+ (argv) => publish({ ...argv, awsProfile: argv['aws-profile'], tokenEnv: argv['token-env'] }),
346
467
  )
347
468
 
348
469
  // ── configure ─────────────────────────────────────────────────────────────
@@ -364,7 +485,8 @@ yargs
364
485
  .option('name', { type: 'string', demandOption: true })
365
486
  .option('url', { type: 'string', demandOption: true })
366
487
  .option('profile', { type: 'string', description: 'AWS SSO profile (S3 only)' })
367
- .option('token', { type: 'string', description: 'Bearer token (HTTP only)' });
488
+ .option('token', { type: 'string', description: 'Bearer token (HTTP only). Persists in plaintext in config.json — prefer --token-env for CI/shared setups.' })
489
+ .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
490
  },
369
491
  (argv) => configure.addSource(argv),
370
492
  )
@@ -438,7 +560,15 @@ yargs
438
560
  .option('compiler', { type: 'string', description: 'Compiler (msvc | gcc | clang | apple-clang)' })
439
561
  .option('compiler-version', { type: 'string', description: 'Compiler version (193 | 11 | 14 | ...)' })
440
562
  .option('cppstd', { type: 'string', description: 'C++ standard (14 | 17 | 20 | 23)' })
441
- .option('runtime', { type: 'string', description: 'Runtime linkage (static | dynamic)' });
563
+ .option('runtime', { type: 'string', description: 'Runtime linkage (static | dynamic)' })
564
+ .option('conf', {
565
+ type: 'string',
566
+ array: true,
567
+ description:
568
+ 'Profile-level non-ABI build knob — <namespace.leaf>=<value> ' +
569
+ '(repeatable). `KEY` alone means `KEY=ON`; `KEY=` removes the ' +
570
+ 'leaf. Phase 1 allow-list: cmake.cache.*. See PLAN-CONF.md §4.',
571
+ });
442
572
  },
443
573
  (argv) => profile.set({ ...argv, compilerVersion: argv['compiler-version'] }),
444
574
  )
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.8.1",
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,7 @@ 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');
17
18
  const log = require('../logger');
18
19
 
19
20
  /**
@@ -155,8 +156,28 @@ async function buildFromSource(args) {
155
156
  }
156
157
  // Pass effective options into the recipe so `${options.<name>}` tokens in
157
158
  // `build.configure` / `build.buildArgs` expand to the concrete values that
158
- // produced this build's profileHash.
159
- const recipe = normalizeRecipe(clonedManifest.build, options);
159
+ // produced this build's profileHash. Pass the cloned manifest so PLAN-CONF.md
160
+ // §4.7 collision detection catches a publisher who declared the same KEY in
161
+ // both build.configure and conf.cmake.cache.
162
+ const recipe = normalizeRecipe(clonedManifest.build, options, clonedManifest);
163
+
164
+ // PLAN-CONF.md §4.8.2: source-build of a dep consumes ONLY the dep's own
165
+ // recipe conf — never the root project's merged conf. Translating the
166
+ // dep's cmake.cache namespace into -D args here keeps the dep's source-
167
+ // build CMake invocation aligned with its author's declared defaults
168
+ // while preserving cross-consumer reproducibility. The root's CLI
169
+ // --conf / wyvrn.local.json overlay is invisible at this seam.
170
+ const clonedConfFlat = readRecipeConf(clonedManifest);
171
+ const clonedCacheVars = cmakeCacheVariables(clonedConfFlat);
172
+ if (Object.keys(clonedCacheVars).length > 0) {
173
+ const confDerivedArgs = Object.entries(clonedCacheVars)
174
+ .map(([k, v]) => `-D${k}=${v}`);
175
+ recipe.configure = [...recipe.configure, ...confDerivedArgs];
176
+ log.info(
177
+ ` conf.cmake.cache → ${confDerivedArgs.length} source-build ` +
178
+ `configure arg(s) from the dep's own recipe`,
179
+ );
180
+ }
160
181
 
161
182
  // ── 4. Recursively resolve + download this package's OWN deps ──────────
162
183
  // Lazy-require: downloadDependencies is where we were called from, so
@@ -75,17 +75,35 @@ function findVcvarsall(vsInstallPath) {
75
75
  return fs.existsSync(p) ? p : null;
76
76
  }
77
77
 
78
+ /**
79
+ * The complete set of arch arguments `vcvarsall.bat` accepts AND that we
80
+ * are willing to splice into a shell command. Explicit allow-list — any
81
+ * value outside this set is rejected (EVALUATION.md S5).
82
+ */
83
+ const VCVARSALL_ARCH_ALLOWLIST = new Set(['x64', 'x86', 'arm64', 'arm']);
84
+
85
+ const ARCH_MAP = {
86
+ x86_64: 'x64',
87
+ x86: 'x86',
88
+ armv8: 'arm64',
89
+ armv7: 'arm',
90
+ };
91
+
78
92
  /**
79
93
  * Map a wyvrnpm profile arch to the argument `vcvarsall.bat` expects.
94
+ * Throws on an unknown profile arch — silently defaulting to `x64` would
95
+ * (a) produce a build for the wrong target, and (b) hand any shell-unsafe
96
+ * input to cmd.exe through captureVcvarsEnv's template.
80
97
  */
81
98
  function mapArch(profileArch) {
82
- switch (profileArch) {
83
- case 'x86_64': return 'x64';
84
- case 'x86': return 'x86';
85
- case 'armv8': return 'arm64';
86
- case 'armv7': return 'arm';
87
- default: return 'x64';
99
+ const arg = ARCH_MAP[profileArch];
100
+ if (!arg || !VCVARSALL_ARCH_ALLOWLIST.has(arg)) {
101
+ throw new Error(
102
+ `unsupported MSVC profile arch ${JSON.stringify(profileArch)}; ` +
103
+ `expected one of ${Object.keys(ARCH_MAP).join(', ')}`,
104
+ );
88
105
  }
106
+ return arg;
89
107
  }
90
108
 
91
109
  /**
@@ -105,6 +123,18 @@ function mapArch(profileArch) {
105
123
  * @returns {Promise<object|null>}
106
124
  */
107
125
  async function captureVcvarsEnv(vcvarsallPath, archArg) {
126
+ // Defense-in-depth at the shell boundary (EVALUATION.md S5). mapArch
127
+ // already restricts archArg to the allow-list; re-assert here so this
128
+ // function is safe to call directly (e.g. from a future caller that
129
+ // skips mapArch). Likewise refuse a vcvarsall path containing a
130
+ // double-quote — it would break out of the quoted command template.
131
+ if (!VCVARSALL_ARCH_ALLOWLIST.has(archArg)) {
132
+ throw new Error(`refusing to spawn cmd.exe with unsafe arch arg ${JSON.stringify(archArg)}`);
133
+ }
134
+ if (vcvarsallPath.includes('"')) {
135
+ throw new Error(`refusing to spawn cmd.exe with quote in vcvarsall path ${JSON.stringify(vcvarsallPath)}`);
136
+ }
137
+
108
138
  const MARKER = '___WYVRN_ENV_MARKER___';
109
139
  // `call` is required so the batch file returns control instead of
110
140
  // `exit`-ing the cmd shell; otherwise the `echo` + `set` after it
@@ -197,7 +227,18 @@ async function loadMsvcEnv({ profile, skipForGenerator }) {
197
227
  return null;
198
228
  }
199
229
 
200
- const archArg = mapArch(profile.arch);
230
+ let archArg;
231
+ try {
232
+ archArg = mapArch(profile.arch);
233
+ } catch (err) {
234
+ // Unknown arch for an MSVC profile is genuinely a configuration bug
235
+ // (MSVC only ships toolsets for x64/x86/arm64/arm). Surface clearly
236
+ // and fall through to the ambient env — CMake will then error with
237
+ // a usable compiler-detection message instead of us silently
238
+ // cross-targeting x64.
239
+ log.warn(` ${err.message}`);
240
+ return null;
241
+ }
201
242
  log.info(` activating MSVC build env: vcvarsall.bat ${archArg} (${vsPath})`);
202
243
  const env = await captureVcvarsEnv(vcvarsall, archArg);
203
244
  if (!env) {
@@ -214,4 +255,6 @@ module.exports = {
214
255
  findVsInstall,
215
256
  findVcvarsall,
216
257
  mapArch,
258
+ captureVcvarsEnv,
259
+ VCVARSALL_ARCH_ALLOWLIST,
217
260
  };