wyvrnpm 2.3.2 → 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
@@ -4,6 +4,7 @@
4
4
 
5
5
  const yargs = require('yargs');
6
6
  const init = require('../src/commands/init');
7
+ const add = require('../src/commands/add');
7
8
  const install = require('../src/commands/install');
8
9
  const clean = require('../src/commands/clean');
9
10
  const publish = require('../src/commands/publish');
@@ -12,6 +13,7 @@ const profile = require('../src/commands/profile');
12
13
  const build = require('../src/commands/build');
13
14
  const show = require('../src/commands/show');
14
15
  const installSkill = require('../src/commands/install-skill');
16
+ const cache = require('../src/commands/cache');
15
17
  const { link, unlink } = require('../src/commands/link');
16
18
 
17
19
  yargs
@@ -35,6 +37,34 @@ yargs
35
37
  (argv) => init(argv),
36
38
  )
37
39
 
40
+ // ── add ───────────────────────────────────────────────────────────────────
41
+ .command(
42
+ 'add <packages...>',
43
+ 'Add dependencies to wyvrn.json. Without @version, resolves latest and writes "^<latest>"; ' +
44
+ 'with @version, writes the given string verbatim. Does not run install.',
45
+ (y) => {
46
+ y
47
+ .positional('packages', {
48
+ type: 'string',
49
+ array: true,
50
+ describe: 'One or more <name>[@<version>] specs',
51
+ })
52
+ .option('source', {
53
+ alias: 's',
54
+ type: 'string',
55
+ description: 'Base URL of a package source for `latest` lookup (can be repeated)',
56
+ array: true,
57
+ })
58
+ .option('platform', {
59
+ type: 'string',
60
+ description: 'v1 legacy platform path (used as fallback when looking up latest)',
61
+ choices: ['win_x64', 'win_x86', 'linux_x64', 'linux_x86', 'osx_x64', 'osx_arm64'],
62
+ default: 'win_x64',
63
+ });
64
+ },
65
+ (argv) => add(argv),
66
+ )
67
+
38
68
  // ── install ───────────────────────────────────────────────────────────────
39
69
  .command(
40
70
  'install',
@@ -57,8 +87,8 @@ yargs
57
87
  choices: ['never', 'missing', 'always'],
58
88
  default: 'never',
59
89
  description:
60
- 'Resolution mode when no exact profile match exists. ' +
61
- '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).',
62
92
  })
63
93
  .option('option', {
64
94
  alias: 'o',
@@ -68,6 +98,25 @@ yargs
68
98
  'Override a package option. Repeatable. Format: <pkg>:<name>=<value>. ' +
69
99
  'Example: -o zlib:minizip=false -o OpenSSL:fips=true',
70
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
+ })
71
120
  .option('upload-built', {
72
121
  type: 'boolean',
73
122
  default: false,
@@ -88,7 +137,11 @@ yargs
88
137
  })
89
138
  .option('token', {
90
139
  type: 'string',
91
- 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.',
92
145
  })
93
146
  .option('platform', {
94
147
  type: 'string',
@@ -113,6 +166,8 @@ yargs
113
166
  uploadBuilt: argv['upload-built'],
114
167
  uploadSource: argv['upload-source'],
115
168
  awsProfile: argv['aws-profile'],
169
+ tokenEnv: argv['token-env'],
170
+ dryRun: argv['dry-run'],
116
171
  }),
117
172
  )
118
173
 
@@ -156,6 +211,13 @@ yargs
156
211
  default: false,
157
212
  description: 'Remove the build directory before configuring',
158
213
  })
214
+ .option('generator', {
215
+ alias: 'G',
216
+ type: 'string',
217
+ description:
218
+ 'Override the preset\'s CMake generator (e.g. "Visual Studio 17 2022", "Ninja", "Ninja Multi-Config"). ' +
219
+ 'CMake locks the generator into the build-dir cache, so pair this with --clean when switching generators.',
220
+ })
159
221
  .option('install', {
160
222
  type: 'boolean',
161
223
  default: false,
@@ -184,6 +246,16 @@ yargs
184
246
  .option('timeout', {
185
247
  type: 'number',
186
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.',
187
259
  });
188
260
  },
189
261
  (argv) => build({
@@ -193,6 +265,74 @@ yargs
193
265
  }),
194
266
  )
195
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
+
196
336
  // ── clean ─────────────────────────────────────────────────────────────────
197
337
  .command(
198
338
  'clean',
@@ -242,7 +382,11 @@ yargs
242
382
  })
243
383
  .option('token', {
244
384
  type: 'string',
245
- 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.',
246
390
  })
247
391
  .option('format', {
248
392
  type: 'string',
@@ -251,7 +395,7 @@ yargs
251
395
  description: 'Output format. "json" emits a single JSON object on stdout; log lines go to stderr.',
252
396
  });
253
397
  },
254
- (argv) => show({ ...argv, awsProfile: argv['aws-profile'] }),
398
+ (argv) => show({ ...argv, awsProfile: argv['aws-profile'], tokenEnv: argv['token-env'] }),
255
399
  )
256
400
 
257
401
  // ── publish ───────────────────────────────────────────────────────────────
@@ -282,7 +426,11 @@ yargs
282
426
  })
283
427
  .option('token', {
284
428
  type: 'string',
285
- 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.',
286
434
  })
287
435
  .option('force', {
288
436
  alias: 'f',
@@ -298,6 +446,15 @@ yargs
298
446
  'Override an option value at publish time. Repeatable. ' +
299
447
  'Format: <pkg>:<name>=<value> (pkg must match the package being published).',
300
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
+ })
301
458
  .option('format', {
302
459
  type: 'string',
303
460
  choices: ['text', 'json'],
@@ -306,7 +463,7 @@ yargs
306
463
  })
307
464
  ;
308
465
  },
309
- (argv) => publish({ ...argv, awsProfile: argv['aws-profile'] }),
466
+ (argv) => publish({ ...argv, awsProfile: argv['aws-profile'], tokenEnv: argv['token-env'] }),
310
467
  )
311
468
 
312
469
  // ── configure ─────────────────────────────────────────────────────────────
@@ -328,7 +485,8 @@ yargs
328
485
  .option('name', { type: 'string', demandOption: true })
329
486
  .option('url', { type: 'string', demandOption: true })
330
487
  .option('profile', { type: 'string', description: 'AWS SSO profile (S3 only)' })
331
- .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.' });
332
490
  },
333
491
  (argv) => configure.addSource(argv),
334
492
  )
@@ -402,7 +560,15 @@ yargs
402
560
  .option('compiler', { type: 'string', description: 'Compiler (msvc | gcc | clang | apple-clang)' })
403
561
  .option('compiler-version', { type: 'string', description: 'Compiler version (193 | 11 | 14 | ...)' })
404
562
  .option('cppstd', { type: 'string', description: 'C++ standard (14 | 17 | 20 | 23)' })
405
- .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
+ });
406
572
  },
407
573
  (argv) => profile.set({ ...argv, compilerVersion: argv['compiler-version'] }),
408
574
  )
Binary file
@@ -25,6 +25,44 @@ macro(WYVRN_CONFIGURE_VERSION_HEADER)
25
25
  )
26
26
  endmacro()
27
27
 
28
+ # Apply WYVRN_ARCH_SUFFIX to a target's OUTPUT_NAME so the produced artefact
29
+ # carries an unambiguous arch marker. Opt-in — call it from your CMakeLists
30
+ # only if you want the suffixing:
31
+ #
32
+ # WYVRN_APPLY_ARCH_SUFFIX(String) # String64.lib / String32.lib
33
+ # WYVRN_APPLY_ARCH_SUFFIX(String BASE_NAME my-string) # my-string64.lib / my-string32.lib
34
+ #
35
+ # BASE_NAME defaults to the target name. The target linkage name (what
36
+ # consumers pass to target_link_libraries) is untouched — only the on-disk
37
+ # output file changes. Safe to call for any target type; does nothing if
38
+ # WYVRN_ARCH_SUFFIX is empty (unknown / unmapped arch).
39
+ function(WYVRN_APPLY_ARCH_SUFFIX _wyvrn_target)
40
+ set(options)
41
+ set(oneValueArgs BASE_NAME)
42
+ set(multiValueArgs)
43
+ cmake_parse_arguments(_WYVRN_ARG "${options}" "${oneValueArgs}" "${multiValueArgs}" ${ARGN})
44
+
45
+ if(NOT TARGET ${_wyvrn_target})
46
+ message(FATAL_ERROR "WYVRN_APPLY_ARCH_SUFFIX: '${_wyvrn_target}' is not a target")
47
+ endif()
48
+
49
+ if("${WYVRN_ARCH_SUFFIX}" STREQUAL "")
50
+ # Unknown arch — leave the output name alone rather than stamping a
51
+ # misleading value. The caller can still set OUTPUT_NAME manually.
52
+ return()
53
+ endif()
54
+
55
+ if(_WYVRN_ARG_BASE_NAME)
56
+ set(_wyvrn_base "${_WYVRN_ARG_BASE_NAME}")
57
+ else()
58
+ set(_wyvrn_base "${_wyvrn_target}")
59
+ endif()
60
+
61
+ set_target_properties(${_wyvrn_target} PROPERTIES
62
+ OUTPUT_NAME "${_wyvrn_base}${WYVRN_ARCH_SUFFIX}"
63
+ )
64
+ endfunction()
65
+
28
66
  function(WYVRN_INSTALL_PACKAGE_EXPORT)
29
67
  set(options)
30
68
  set(oneValueArgs
@@ -84,10 +122,23 @@ function(WYVRN_INSTALL_PACKAGE_EXPORT)
84
122
  INSTALL_DESTINATION "${WYVRN_EXPORT_INSTALL_CMAKEDIR}"
85
123
  )
86
124
 
125
+ # INTERFACE libraries (header-only) have no compiled binary, so the arch
126
+ # stamped by `write_basic_package_version_file()` by default makes CMake
127
+ # reject the package when the consumer's CMAKE_SIZEOF_VOID_P differs —
128
+ # even though the headers themselves are arch-portable. Pass
129
+ # ARCH_INDEPENDENT so a HeaderOnlyLib published from x86_64 is consumable
130
+ # from x86 (and vice versa). Binary kinds keep the arch check.
131
+ set(_wyvrn_version_args)
132
+ get_target_property(_wyvrn_target_type ${WYVRN_EXPORT_TARGET} TYPE)
133
+ if(_wyvrn_target_type STREQUAL "INTERFACE_LIBRARY")
134
+ list(APPEND _wyvrn_version_args ARCH_INDEPENDENT)
135
+ endif()
136
+
87
137
  write_basic_package_version_file(
88
138
  "${WYVRN_EXPORT_VERSION_OUTPUT}"
89
139
  VERSION "${WYVRN_EXPORT_PACKAGE_VERSION}"
90
140
  COMPATIBILITY SameMajorVersion
141
+ ${_wyvrn_version_args}
91
142
  )
92
143
 
93
144
  install(FILES
@@ -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.3.2",
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
  };