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.
@@ -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
  };
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const { substituteTemplateAll } = require('../options');
4
+ const { readRecipeConf } = require('../conf');
4
5
 
5
6
  /**
6
7
  * The `build` section of a source-repo `wyvrn.json` tells wyvrnpm how to
@@ -60,15 +61,22 @@ const SUPPORTED_SYSTEMS = new Set(['cmake']);
60
61
  * no options), any `${options.*}` token in the recipe also fails fast —
61
62
  * recipes don't get to reference undeclared options.
62
63
  *
64
+ * When `manifestForConfCollisionCheck` is supplied, PLAN-CONF.md §4.7's
65
+ * collision detection runs: any `-DKEY=...` in `build.configure` whose
66
+ * KEY is ALSO declared in recipe-level `conf.cmake.cache` is a hard
67
+ * error. Recipes pick ONE route, avoiding silent duplication.
68
+ *
63
69
  * @param {object|null|undefined} rawBuild
64
70
  * @param {Record<string, any>|null} [effectiveOptions]
71
+ * @param {object|null} [manifestForConfCollisionCheck]
65
72
  * @returns {{ system: string, generators: string[], configure: string[],
66
73
  * buildArgs: string[], installDir: string, sourceSubdir: string,
67
74
  * requiredTools: string[] }}
68
- * @throws {Error} when `system` is set but not supported, or when a
69
- * `${options.<name>}` template references an undeclared option
75
+ * @throws {Error} when `system` is set but not supported, when a
76
+ * `${options.<name>}` template references an undeclared option,
77
+ * or when `build.configure` collides with `conf.cmake.cache`.
70
78
  */
71
- function normalizeRecipe(rawBuild, effectiveOptions = null) {
79
+ function normalizeRecipe(rawBuild, effectiveOptions = null, manifestForConfCollisionCheck = null) {
72
80
  const b = rawBuild ?? {};
73
81
  const system = b.system ?? DEFAULT_RECIPE.system;
74
82
  if (!SUPPORTED_SYSTEMS.has(system)) {
@@ -124,6 +132,31 @@ function normalizeRecipe(rawBuild, effectiveOptions = null) {
124
132
  'build.buildArgs',
125
133
  );
126
134
 
135
+ // PLAN-CONF.md §4.7: recipe-side collision between `build.configure`
136
+ // `-DKEY=...` args and declared `conf.cmake.cache.KEY` is a hard
137
+ // error. Forces the author to pick one route; avoids silent
138
+ // duplication where one route wins opaquely.
139
+ if (manifestForConfCollisionCheck) {
140
+ const recipeConfFlat = readRecipeConf(manifestForConfCollisionCheck);
141
+ const confCacheKeys = new Set(
142
+ Object.keys(recipeConfFlat)
143
+ .filter((k) => k.startsWith('cmake.cache.'))
144
+ .map((k) => k.slice('cmake.cache.'.length)),
145
+ );
146
+ if (confCacheKeys.size > 0) {
147
+ for (const arg of configure) {
148
+ const m = arg.match(/^-D([^=]+)=/);
149
+ if (m && confCacheKeys.has(m[1])) {
150
+ throw new Error(
151
+ `recipe collision: "${m[1]}" is set in both ` +
152
+ `build.configure (as ${JSON.stringify(arg)}) and ` +
153
+ `conf.cmake.cache. Pick one route — see claude/PLAN-CONF.md §4.7.`,
154
+ );
155
+ }
156
+ }
157
+ }
158
+ }
159
+
127
160
  return {
128
161
  system,
129
162
  generators,
@@ -5,6 +5,7 @@ const path = require('path');
5
5
  const { readManifest, writeManifest } = require('../manifest');
6
6
  const { resolveLatestVersion } = require('../resolve');
7
7
  const { readConfig } = require('../config');
8
+ const { wyvrnFetch } = require('../http-fetch');
8
9
  const log = require('../logger');
9
10
 
10
11
  /**
@@ -99,7 +100,7 @@ async function add(argv) {
99
100
  versionToWrite = spec.version;
100
101
  log.info(`${spec.name}: writing "${versionToWrite}" (as given)`);
101
102
  } else {
102
- const latest = await resolveLatestVersion(spec.name, packageSources, argv.platform, fetch);
103
+ const latest = await resolveLatestVersion(spec.name, packageSources, argv.platform, wyvrnFetch);
103
104
  versionToWrite = `^${latest}`;
104
105
  log.info(`${spec.name}: latest is ${latest} → writing "${versionToWrite}"`);
105
106
  }
@@ -4,12 +4,11 @@ const fs = require('fs');
4
4
  const path = require('path');
5
5
  const { spawn } = require('child_process');
6
6
 
7
- const { readConfig } = require('../config');
8
- const { loadProfile } = require('../profile');
9
- const { readManifest } = require('../manifest');
10
- const { normalizeRecipe, hasBuildRecipe } = require('../build/recipe');
7
+ const { normalizeRecipe, hasBuildRecipe, VALID_CMAKE_CONFIGS, DEFAULT_RECIPE } = require('../build/recipe');
11
8
  const { loadMsvcEnv } = require('../build/msvc-env');
12
9
  const { archToVsPlatform, isVisualStudioGenerator } = require('../toolchain');
10
+ const { LOCAL_OVERLAY_FILENAME } = require('../conf');
11
+ const { buildContext } = require('../context');
13
12
  const install = require('./install');
14
13
  const log = require('../logger');
15
14
 
@@ -29,8 +28,10 @@ const log = require('../logger');
29
28
  * @returns {{ stale: boolean, reason: string }}
30
29
  */
31
30
  function isToolchainStale({ rootDir, manifestPath }) {
32
- const lockPath = path.join(path.dirname(manifestPath), 'wyvrn.lock');
33
- const toolchainPath = path.join(rootDir, 'wyvrn_internal', 'wyvrn_toolchain.cmake');
31
+ const lockPath = path.join(path.dirname(manifestPath), 'wyvrn.lock');
32
+ const toolchainPath = path.join(rootDir, 'wyvrn_internal', 'wyvrn_toolchain.cmake');
33
+ const localOverlayPath = path.join(rootDir, LOCAL_OVERLAY_FILENAME);
34
+ const presetPath = path.join(rootDir, 'CMakePresets.json');
34
35
 
35
36
  if (!fs.existsSync(toolchainPath)) {
36
37
  return { stale: true, reason: 'toolchain-missing' };
@@ -44,6 +45,19 @@ function isToolchainStale({ rootDir, manifestPath }) {
44
45
  if (lockMtime > toolchainMtime) {
45
46
  return { stale: true, reason: 'lock-newer-than-toolchain' };
46
47
  }
48
+
49
+ // PLAN-CONF.md §7: if wyvrn.local.json was edited after the preset was
50
+ // written, the preset's cacheVariables no longer reflect the consumer's
51
+ // intent. Re-run install (which regenerates the preset) rather than
52
+ // silently running a stale configure.
53
+ if (fs.existsSync(localOverlayPath) && fs.existsSync(presetPath)) {
54
+ const overlayMtime = fs.statSync(localOverlayPath).mtimeMs;
55
+ const presetMtime = fs.statSync(presetPath).mtimeMs;
56
+ if (overlayMtime > presetMtime) {
57
+ return { stale: true, reason: 'local-overlay-newer-than-preset' };
58
+ }
59
+ }
60
+
47
61
  return { stale: false, reason: 'fresh' };
48
62
  }
49
63
 
@@ -89,13 +103,86 @@ function resolveBinaryDir({ rootDir, presetName }) {
89
103
  * @param {{ presetName: string, generator?: string|null, vsPlatform?: string|null }} args
90
104
  * @returns {string[]}
91
105
  */
92
- function buildConfigureArgs({ presetName, generator = null, vsPlatform = null }) {
106
+ function buildConfigureArgs({
107
+ presetName,
108
+ generator = null,
109
+ vsPlatform = null,
110
+ confSets = null,
111
+ confUnsets = null,
112
+ }) {
93
113
  const args = ['--preset', presetName];
94
114
  if (generator) args.push('-G', generator);
95
115
  if (vsPlatform) args.push('-A', vsPlatform);
116
+
117
+ // PLAN-CONF.md §7: CLI --conf at build time applies as -D / -U
118
+ // overrides on top of whatever's baked into the preset. We do NOT
119
+ // silently regenerate the preset — that's a separate install.
120
+ // Confined to the cmake.cache namespace; other namespaces have
121
+ // different sinks (phase 3) and don't apply to `cmake --preset`.
122
+ if (confSets) {
123
+ const prefix = 'cmake.cache.';
124
+ for (const key of Object.keys(confSets).sort()) {
125
+ if (!key.startsWith(prefix)) continue;
126
+ const cmakeKey = key.slice(prefix.length);
127
+ args.push('-D', `${cmakeKey}=${confSets[key]}`);
128
+ }
129
+ }
130
+ if (confUnsets) {
131
+ const prefix = 'cmake.cache.';
132
+ for (const key of [...confUnsets].sort()) {
133
+ if (!key.startsWith(prefix)) continue;
134
+ args.push('-U', key.slice(prefix.length));
135
+ }
136
+ }
137
+
96
138
  return args;
97
139
  }
98
140
 
141
+ /**
142
+ * Resolve the list of CMake configs to build (and optionally install).
143
+ *
144
+ * Precedence:
145
+ * 1. `--all-configs` → recipe.configs (falls back to all four canonical
146
+ * configs when no recipe declares any).
147
+ * 2. `--config` → comma-separated list, validated against
148
+ * VALID_CMAKE_CONFIGS. Order preserved. Duplicates removed.
149
+ * 3. default → `['Release']`.
150
+ *
151
+ * Configure runs once per `wyvrnpm build` invocation regardless — multi-
152
+ * config generators (VS, Xcode, Ninja Multi-Config) produce all configs
153
+ * from one configure step. Single-config generators (plain Ninja, Make)
154
+ * bake CMAKE_BUILD_TYPE into the cache, so `cmake --build --config X`
155
+ * effectively ignores X for them; building two configs against a single-
156
+ * config generator rebuilds the same binary twice. That's wasteful but
157
+ * correct; fixing it properly requires per-config build dirs, which is
158
+ * out of scope for this helper.
159
+ *
160
+ * @param {{ argv: object, recipeConfigs?: string[] }} args
161
+ * @returns {string[]}
162
+ */
163
+ function resolveConfigList({ argv, recipeConfigs = null }) {
164
+ if (argv.allConfigs) {
165
+ const fromRecipe = (recipeConfigs ?? []).filter((c) => VALID_CMAKE_CONFIGS.has(c));
166
+ return fromRecipe.length > 0 ? [...fromRecipe] : [...DEFAULT_RECIPE.configs];
167
+ }
168
+
169
+ const raw = typeof argv.config === 'string' && argv.config.trim() ? argv.config : 'Release';
170
+ const list = raw.split(',').map((s) => s.trim()).filter(Boolean);
171
+
172
+ const invalid = list.filter((c) => !VALID_CMAKE_CONFIGS.has(c));
173
+ if (invalid.length > 0) {
174
+ const valid = [...VALID_CMAKE_CONFIGS].join(' | ');
175
+ throw new Error(`invalid --config "${invalid.join(', ')}" — expected ${valid}`);
176
+ }
177
+
178
+ const seen = new Set();
179
+ const deduped = [];
180
+ for (const c of list) {
181
+ if (!seen.has(c)) { seen.add(c); deduped.push(c); }
182
+ }
183
+ return deduped.length > 0 ? deduped : ['Release'];
184
+ }
185
+
99
186
  /**
100
187
  * Args passed to `cmake` for the build step.
101
188
  *
@@ -180,13 +267,34 @@ function runCmake(args, env = null) {
180
267
  * @param {object} argv
181
268
  */
182
269
  async function build(argv) {
183
- const rootDir = path.resolve(argv.root);
184
- const manifestPath = path.resolve(argv.manifest);
270
+ // Shared per-invocation state. See src/context.js +
271
+ // claude/PLAN-COMMAND-CONTEXT.md. Parses --conf up front so a malformed
272
+ // flag fails here instead of after cmake's already configured.
273
+ let ctx;
274
+ try {
275
+ ctx = buildContext(argv);
276
+ } catch (err) {
277
+ log.error(err.message);
278
+ process.exit(1);
279
+ }
185
280
 
186
- const config = readConfig();
187
- const profileName = argv.profile ?? config.defaultProfile ?? 'default';
281
+ const { rootDir, manifestPath } = ctx;
282
+ const { name: profileName, profile: activeProfile } = ctx.profiles.host;
188
283
  const presetName = resolvePresetName({ presetOverride: argv.preset, profileName });
189
- const buildConfig = argv.config ?? 'Release';
284
+
285
+ // Resolve the list of configs up front so a malformed --config fails
286
+ // before auto-install kicks off. `--all-configs` consults the recipe
287
+ // (if any); otherwise comma-separated --config, defaulting to Release.
288
+ let configList;
289
+ try {
290
+ const recipe = hasBuildRecipe(ctx.manifest)
291
+ ? (() => { try { return normalizeRecipe(ctx.manifest.build, null); } catch { return null; } })()
292
+ : null;
293
+ configList = resolveConfigList({ argv, recipeConfigs: recipe?.configs ?? null });
294
+ } catch (err) {
295
+ log.error(err.message);
296
+ process.exit(1);
297
+ }
190
298
 
191
299
  // ── Stale check / auto-install ────────────────────────────────────────────
192
300
  // `--auto-install` gates the "run `wyvrnpm install` first when the
@@ -233,13 +341,12 @@ async function build(argv) {
233
341
  let effectiveGenerator = argv.generator ?? null;
234
342
  let effectiveProfile = null;
235
343
  try {
236
- const { profile: activeProfile } = loadProfile(profileName);
237
344
  effectiveProfile = activeProfile;
238
345
  // Which generator will the preset actually use? Read it from the
239
346
  // project's own recipe if one exists. If not, default to null and let
240
347
  // cmake/CMakePresets decide — in which case MSVC env activation is
241
348
  // skipped (because VS is usually the default on Windows).
242
- const manifest = readManifest(manifestPath);
349
+ const manifest = ctx.manifest;
243
350
  let presetGenerator = null;
244
351
  if (hasBuildRecipe(manifest)) {
245
352
  try {
@@ -273,43 +380,61 @@ async function build(argv) {
273
380
  }
274
381
  }
275
382
 
383
+ // CLI --conf → -D / -U passthrough (PLAN-CONF.md §7). The preset's
384
+ // baked-in cacheVariables (from the last install) already reflect the
385
+ // recipe + local-overlay layers; CLI conf layers on top for this
386
+ // invocation only. ctx.cliConf is pre-parsed by buildContext; malformed
387
+ // values already threw above.
388
+ const cliConfSets = ctx.cliConf.sets;
389
+ const cliConfUnsets = ctx.cliConf.unsets;
390
+
276
391
  const configureArgs = buildConfigureArgs({
277
392
  presetName,
278
393
  generator: argv.generator,
279
394
  vsPlatform,
395
+ confSets: cliConfSets,
396
+ confUnsets: cliConfUnsets,
280
397
  });
281
398
  log.info(`cmake ${configureArgs.join(' ')}`);
282
399
  await runCmake(configureArgs, cmakeEnv);
283
400
 
284
- // ── Build ─────────────────────────────────────────────────────────────────
285
- const buildArgs = buildBuildArgs({
286
- binaryDir,
287
- config: buildConfig,
288
- target: argv.target,
289
- verbose: argv.verbose,
290
- passthru: argv['--'] ?? [],
291
- });
292
- log.info(`cmake ${buildArgs.join(' ')}`);
293
- await runCmake(buildArgs, cmakeEnv);
294
-
295
- // ── Install (optional) ────────────────────────────────────────────────────
296
- // `--install` runs `cmake --install <binaryDir>` after the build. Prefix
297
- // defaults to whatever was baked into the cache at configure time; pass
298
- // `--install-prefix` to override without reconfiguring.
299
- if (argv.install) {
300
- const installArgs = buildInstallArgs({
401
+ // ── Build (+ optional install) per config ─────────────────────────────────
402
+ // Configure ran once above. Multi-config generators (VS, Xcode, Ninja
403
+ // Multi-Config) produce every config from that one configure step; for
404
+ // single-config generators each iteration simply re-`cmake --build`s the
405
+ // same baked CMAKE_BUILD_TYPE (harmless duplication). `--install` runs
406
+ // per config too — the usual reason to want multi-config is so that a
407
+ // downstream `find_package()` can pick whichever it needs.
408
+ if (configList.length > 1) {
409
+ log.info(`Building ${configList.length} configs: ${configList.join(', ')}`);
410
+ }
411
+
412
+ for (const config of configList) {
413
+ const buildArgs = buildBuildArgs({
301
414
  binaryDir,
302
- config: buildConfig,
303
- prefix: argv.installPrefix,
415
+ config,
416
+ target: argv.target,
417
+ verbose: argv.verbose,
418
+ passthru: argv['--'] ?? [],
304
419
  });
305
- log.info(`cmake ${installArgs.join(' ')}`);
306
- await runCmake(installArgs, cmakeEnv);
420
+ log.info(`cmake ${buildArgs.join(' ')}`);
421
+ await runCmake(buildArgs, cmakeEnv);
422
+
423
+ if (argv.install) {
424
+ const installArgs = buildInstallArgs({
425
+ binaryDir,
426
+ config,
427
+ prefix: argv.installPrefix,
428
+ });
429
+ log.info(`cmake ${installArgs.join(' ')}`);
430
+ await runCmake(installArgs, cmakeEnv);
431
+ }
307
432
  }
308
433
 
309
434
  const suffix = argv.install
310
435
  ? ` + installed${argv.installPrefix ? ` to ${argv.installPrefix}` : ''}`
311
436
  : '';
312
- log.success(`Build complete: ${binaryDir} (${buildConfig})${suffix}`);
437
+ log.success(`Build complete: ${binaryDir} (${configList.join(', ')})${suffix}`);
313
438
  }
314
439
 
315
440
  module.exports = build;
@@ -320,6 +445,7 @@ module.exports._helpers = {
320
445
  isToolchainStale,
321
446
  resolvePresetName,
322
447
  resolveBinaryDir,
448
+ resolveConfigList,
323
449
  buildConfigureArgs,
324
450
  buildBuildArgs,
325
451
  buildInstallArgs,
@@ -0,0 +1,189 @@
1
+ 'use strict';
2
+
3
+ // F12: source-build cache inspection + targeted pruning.
4
+ //
5
+ // `wyvrnpm cache list [pattern]` prints a human-readable table (or JSON
6
+ // with --format json) of everything in the machine-wide source-build
7
+ // cache at `%LOCALAPPDATA%\wyvrnpm\bc\`.
8
+ //
9
+ // `wyvrnpm cache prune` removes cache entries per a policy:
10
+ // --keep-last N retain N most-recently-touched per (name,version)
11
+ // --older-than 30d also remove entries older than the threshold
12
+ // --pattern <glob> restrict to matching package names
13
+ // --dry-run print what would go, remove nothing
14
+ //
15
+ // The existing `wyvrnpm clean --build-cache` nukes everything; this is
16
+ // the targeted alternative for teams whose `--upload-built` cache has
17
+ // ballooned.
18
+
19
+ const {
20
+ listCacheEntries,
21
+ computePruneSet,
22
+ removeCacheEntries,
23
+ parseDurationToCutoff,
24
+ getBuildCacheRoot,
25
+ } = require('../build/cache');
26
+ const log = require('../logger');
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Helpers
30
+ // ---------------------------------------------------------------------------
31
+
32
+ function formatBytes(n) {
33
+ if (n < 1024) return `${n} B`;
34
+ const units = ['KB', 'MB', 'GB', 'TB'];
35
+ let size = n / 1024, i = 0;
36
+ while (size >= 1024 && i < units.length - 1) { size /= 1024; i++; }
37
+ return `${size.toFixed(size >= 100 ? 0 : 1)} ${units[i]}`;
38
+ }
39
+
40
+ function formatMtime(ms) {
41
+ if (!ms) return '<unknown>';
42
+ return new Date(ms).toISOString().replace('T', ' ').slice(0, 16);
43
+ }
44
+
45
+ function globToRegExp(glob) {
46
+ const escaped = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&');
47
+ return new RegExp(`^${escaped.replace(/\*/g, '.*').replace(/\?/g, '.')}$`);
48
+ }
49
+
50
+ function patternFor(argPattern, positional) {
51
+ const src = argPattern ?? positional ?? null;
52
+ return src ? globToRegExp(src) : null;
53
+ }
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // list
57
+ // ---------------------------------------------------------------------------
58
+
59
+ /**
60
+ * wyvrnpm cache list [pattern] [--format json|text]
61
+ *
62
+ * @param {object} argv
63
+ */
64
+ function list(argv) {
65
+ const entries = listCacheEntries();
66
+ const regex = patternFor(argv.pattern, argv._?.[1]);
67
+ const filtered = regex ? entries.filter((e) => regex.test(e.name)) : entries;
68
+
69
+ // Newest first — matches what `ls -lt` users expect.
70
+ filtered.sort((a, b) => b.mtimeMs - a.mtimeMs);
71
+
72
+ if (argv.format === 'json') {
73
+ process.stdout.write(JSON.stringify({
74
+ command: 'cache-list',
75
+ cacheRoot: getBuildCacheRoot(),
76
+ totalEntries: filtered.length,
77
+ totalBytes: filtered.reduce((s, e) => s + e.sizeBytes, 0),
78
+ entries: filtered.map((e) => ({
79
+ name: e.name,
80
+ version: e.version,
81
+ profileHash: e.profileHash,
82
+ sizeBytes: e.sizeBytes,
83
+ mtime: new Date(e.mtimeMs).toISOString(),
84
+ hasArtefact: e.hasArtefact,
85
+ uploadCount: e.uploads.length,
86
+ })),
87
+ }, null, 2) + '\n');
88
+ return;
89
+ }
90
+
91
+ log.info(`Cache root : ${getBuildCacheRoot()}`);
92
+ if (filtered.length === 0) {
93
+ log.info(' (empty)');
94
+ return;
95
+ }
96
+
97
+ const rows = filtered.map((e) => [
98
+ e.name,
99
+ e.version,
100
+ e.profileHash,
101
+ formatBytes(e.sizeBytes),
102
+ formatMtime(e.mtimeMs),
103
+ e.uploads.length > 0 ? `${e.uploads.length}` : '-',
104
+ ]);
105
+ const header = ['NAME', 'VERSION', 'PROFILE HASH', 'SIZE', 'MTIME', 'UPLOADS'];
106
+ const widths = header.map((h, i) =>
107
+ Math.max(h.length, ...rows.map((r) => String(r[i]).length)),
108
+ );
109
+
110
+ const format = (cells) => cells.map((c, i) => String(c).padEnd(widths[i])).join(' ');
111
+ console.log('');
112
+ console.log(' ' + format(header));
113
+ console.log(' ' + widths.map((w) => '-'.repeat(w)).join(' '));
114
+ for (const r of rows) console.log(' ' + format(r));
115
+ console.log('');
116
+
117
+ const totalBytes = filtered.reduce((s, e) => s + e.sizeBytes, 0);
118
+ log.info(`${filtered.length} entries, ${formatBytes(totalBytes)} total`);
119
+ }
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // prune
123
+ // ---------------------------------------------------------------------------
124
+
125
+ /**
126
+ * wyvrnpm cache prune [--keep-last N] [--older-than 30d] [--pattern X] [--dry-run]
127
+ *
128
+ * Requires at least one of --keep-last / --older-than to avoid footgun
129
+ * (use `wyvrnpm clean --build-cache` to wipe everything).
130
+ *
131
+ * @param {object} argv
132
+ */
133
+ function prune(argv) {
134
+ const hasKeep = argv.keepLast !== undefined && argv.keepLast !== null;
135
+ const hasOlder = typeof argv.olderThan === 'string' && argv.olderThan.length > 0;
136
+
137
+ if (!hasKeep && !hasOlder) {
138
+ log.error(
139
+ 'cache prune: specify --keep-last <N> and/or --older-than <duration>.\n' +
140
+ ' To wipe the entire build cache, use `wyvrnpm clean --build-cache`.',
141
+ );
142
+ process.exit(1);
143
+ }
144
+
145
+ const policy = {};
146
+ if (hasKeep) {
147
+ const n = Number(argv.keepLast);
148
+ if (!Number.isInteger(n) || n < 0) {
149
+ log.error(`--keep-last must be a non-negative integer, got ${argv.keepLast}`);
150
+ process.exit(1);
151
+ }
152
+ policy.keepLast = n;
153
+ }
154
+ if (hasOlder) {
155
+ try { policy.olderThanMs = parseDurationToCutoff(argv.olderThan); }
156
+ catch (err) { log.error(err.message); process.exit(1); }
157
+ }
158
+ if (argv.pattern) {
159
+ policy.patternRegex = globToRegExp(argv.pattern);
160
+ }
161
+
162
+ const entries = listCacheEntries();
163
+ const pruneSet = computePruneSet(entries, policy);
164
+
165
+ if (pruneSet.length === 0) {
166
+ log.info('Nothing to prune under that policy.');
167
+ return;
168
+ }
169
+
170
+ const totalBytes = pruneSet.reduce((s, e) => s + e.sizeBytes, 0);
171
+ const verb = argv.dryRun ? 'Would remove' : 'Removing';
172
+ log.info(`${verb} ${pruneSet.length} cache entr${pruneSet.length === 1 ? 'y' : 'ies'}, ${formatBytes(totalBytes)}:`);
173
+ for (const e of pruneSet) {
174
+ console.log(` - ${e.name}@${e.version} [${e.profileHash}] ${formatBytes(e.sizeBytes)} ${formatMtime(e.mtimeMs)}`);
175
+ }
176
+
177
+ if (argv.dryRun) {
178
+ log.info('(dry-run — nothing deleted)');
179
+ return;
180
+ }
181
+
182
+ const removed = removeCacheEntries(pruneSet);
183
+ log.info(`Removed ${removed.length} of ${pruneSet.length} cache entr${pruneSet.length === 1 ? 'y' : 'ies'}`);
184
+ if (removed.length < pruneSet.length) {
185
+ log.warn(`${pruneSet.length - removed.length} entr${pruneSet.length - removed.length === 1 ? 'y' : 'ies'} could not be removed (permissions or concurrent access)`);
186
+ }
187
+ }
188
+
189
+ module.exports = { list, prune };
@@ -27,8 +27,9 @@ function printSection(title, sources) {
27
27
  }
28
28
 
29
29
  function authLabel(src) {
30
- if (src.profile) return `[profile: ${src.profile}]`;
31
- if (src.token) return '[token: ***]';
30
+ if (src.profile) return `[profile: ${src.profile}]`;
31
+ if (src.tokenEnv) return `[token-env: ${src.tokenEnv}]`;
32
+ if (src.token) return '[token: ***]';
32
33
  return '';
33
34
  }
34
35
 
@@ -38,12 +39,22 @@ function authLabel(src) {
38
39
  */
39
40
  function addSource(argv) {
40
41
  const { kind, name, url, profile, token } = argv;
42
+ // yargs delivers --token-env as argv['token-env']; accept both shapes
43
+ // so programmatic callers with camelCase also work.
44
+ const tokenEnv = argv.tokenEnv ?? argv['token-env'];
45
+
46
+ if (token && tokenEnv) {
47
+ log.error('Pass --token OR --token-env, not both. --token-env is preferred for CI and shared setups (EVALUATION.md S8).');
48
+ process.exit(1);
49
+ }
50
+
41
51
  const config = readConfig();
42
52
  const key = kind === 'install' ? 'installSources' : 'publishSources';
43
53
 
44
54
  const entry = { name, url };
45
- if (profile) entry.profile = profile;
46
- if (token) entry.token = token;
55
+ if (profile) entry.profile = profile;
56
+ if (token) entry.token = token;
57
+ if (tokenEnv) entry.tokenEnv = tokenEnv;
47
58
 
48
59
  const idx = config[key].findIndex((s) => s.name === name);
49
60
  if (idx !== -1) {