wyvrnpm 2.10.2 → 2.12.2

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.
Files changed (49) hide show
  1. package/README.md +1914 -1860
  2. package/bin/{wyvrn.js → wyvrnpm.js} +66 -0
  3. package/cmake/cpp.cmake +9 -9
  4. package/cmake/functions.cmake +224 -224
  5. package/cmake/macros.cmake +284 -284
  6. package/package.json +3 -2
  7. package/src/auth.js +66 -66
  8. package/src/binary-dir.js +95 -0
  9. package/src/bootstrap/cookbook.js +196 -196
  10. package/src/bootstrap/detect.js +150 -150
  11. package/src/bootstrap/index.js +220 -220
  12. package/src/bootstrap/version.js +72 -72
  13. package/src/build/cache.js +344 -344
  14. package/src/build/clone.js +170 -170
  15. package/src/build/cmake.js +342 -342
  16. package/src/build/index.js +299 -297
  17. package/src/build/msvc-env.js +260 -260
  18. package/src/build/recipe.js +188 -188
  19. package/src/commands/add.js +141 -141
  20. package/src/commands/bootstrap.js +96 -96
  21. package/src/commands/build.js +482 -452
  22. package/src/commands/cache.js +189 -189
  23. package/src/commands/clean.js +80 -80
  24. package/src/commands/configure.js +92 -92
  25. package/src/commands/init.js +70 -70
  26. package/src/commands/install-skill.js +115 -115
  27. package/src/commands/install.js +730 -674
  28. package/src/commands/link.js +320 -320
  29. package/src/commands/profile.js +237 -237
  30. package/src/commands/publish.js +584 -555
  31. package/src/commands/show.js +252 -252
  32. package/src/commands/version.js +187 -0
  33. package/src/compat.js +273 -273
  34. package/src/conf/index.js +415 -391
  35. package/src/conf/namespaces.js +94 -94
  36. package/src/context.js +230 -230
  37. package/src/http-fetch.js +53 -53
  38. package/src/ignore.js +118 -118
  39. package/src/logger.js +122 -122
  40. package/src/options.js +303 -303
  41. package/src/settings-overrides.js +152 -152
  42. package/src/toolchain/deps.js +164 -164
  43. package/src/toolchain/index.js +212 -212
  44. package/src/toolchain/presets.js +356 -340
  45. package/src/toolchain/template.cmake +77 -77
  46. package/src/upload-built.js +265 -265
  47. package/src/version-range.js +301 -301
  48. package/src/zip-safe.js +52 -52
  49. package/src/zip-stream.js +126 -0
@@ -1,344 +1,344 @@
1
- 'use strict';
2
-
3
- const fs = require('fs');
4
- const path = require('path');
5
- const os = require('os');
6
-
7
- /**
8
- * Root directory for the wyvrnpm source-build cache.
9
- * Windows: %LOCALAPPDATA%\wyvrnpm\bc\ (short name — Windows 260-char path limits bite nested CMake builds)
10
- * Unix : ~/.cache/wyvrnpm/bc/
11
- *
12
- * @returns {string}
13
- */
14
- function getBuildCacheRoot() {
15
- const base = process.env.LOCALAPPDATA
16
- ? path.join(process.env.LOCALAPPDATA, 'wyvrnpm')
17
- : path.join(process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache'), 'wyvrnpm');
18
- return path.join(base, 'bc');
19
- }
20
-
21
- /**
22
- * Per-build cache directory, keyed by `(name, version, profileHash)`. Each
23
- * consumer profile gets its own cache line because the toolchain's output
24
- * differs per profile.
25
- *
26
- * Layout inside this dir:
27
- * src/ git clone
28
- * wyvrn_internal/ package's own deps + generated toolchain
29
- * build/ cmake -B
30
- * build/install/ cmake install prefix → zipped as the artefact
31
- * wyvrn.zip final artefact (treated as if downloaded)
32
- *
33
- * @param {{ name: string, version: string, profileHash: string }} args
34
- * @returns {string}
35
- */
36
- function getPackageBuildDir({ name, version, profileHash }) {
37
- // Sanitise — names should be filesystem-safe already, but guard against
38
- // accidental path traversal.
39
- const safeName = name.replace(/[^\w.-]/g, '_');
40
- const safeVersion = version.replace(/[^\w.-]/g, '_');
41
- const safeHash = profileHash.replace(/[^\w.-]/g, '_');
42
- return path.join(getBuildCacheRoot(), `${safeName}-${safeVersion}-${safeHash}`);
43
- }
44
-
45
- /**
46
- * Names of the artefacts inside a package build dir.
47
- */
48
- const LAYOUT = Object.freeze({
49
- srcDir: 'src',
50
- internalDir: 'wyvrn_internal',
51
- buildDir: 'build',
52
- installSubdir: 'install', // relative to buildDir — can be overridden by recipe.installDir
53
- artefactZip: 'wyvrn.zip',
54
- uploadSidecar: '.uploaded-to.json',
55
- });
56
-
57
- /**
58
- * Resolve the standard sub-paths within a package build dir.
59
- */
60
- function getBuildPaths({ name, version, profileHash }) {
61
- const root = getPackageBuildDir({ name, version, profileHash });
62
- return {
63
- root,
64
- src: path.join(root, LAYOUT.srcDir),
65
- internal: path.join(root, LAYOUT.internalDir),
66
- build: path.join(root, LAYOUT.buildDir),
67
- install: path.join(root, LAYOUT.buildDir, LAYOUT.installSubdir),
68
- artefact: path.join(root, LAYOUT.artefactZip),
69
- };
70
- }
71
-
72
- /**
73
- * Remove the entire build cache root. Used by `wyvrnpm clean --build-cache`.
74
- * @returns {string|null} the path removed, or null if nothing was there.
75
- */
76
- function clearBuildCache() {
77
- const root = getBuildCacheRoot();
78
- if (!fs.existsSync(root)) return null;
79
- fs.rmSync(root, { recursive: true, force: true });
80
- return root;
81
- }
82
-
83
- /**
84
- * Check whether a valid cached artefact exists for this `(name, version, profileHash)`
85
- * — used to short-circuit re-builds on re-run.
86
- *
87
- * @param {{ name, version, profileHash }} args
88
- * @returns {boolean}
89
- */
90
- function hasValidArtefact(args) {
91
- const { artefact } = getBuildPaths(args);
92
- return fs.existsSync(artefact);
93
- }
94
-
95
- /**
96
- * Find every `.uploaded-to.json` sidecar under the build cache. Each file
97
- * records the destinations that a cached artefact has been pushed to via
98
- * `wyvrnpm install --build=missing --upload-built`.
99
- *
100
- * Used by `wyvrnpm clean --uploaded-built` and for audit tooling.
101
- *
102
- * @returns {Array<{ cacheDir: string, sidecarPath: string, uploads: Array<object> }>}
103
- */
104
- function listUploadSidecars() {
105
- const root = getBuildCacheRoot();
106
- if (!fs.existsSync(root)) return [];
107
- const out = [];
108
- for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
109
- if (!entry.isDirectory()) continue;
110
- const cacheDir = path.join(root, entry.name);
111
- const sidecarPath = path.join(cacheDir, LAYOUT.uploadSidecar);
112
- if (!fs.existsSync(sidecarPath)) continue;
113
- let uploads = [];
114
- try {
115
- const raw = JSON.parse(fs.readFileSync(sidecarPath, 'utf8'));
116
- if (Array.isArray(raw?.uploads)) uploads = raw.uploads;
117
- } catch { /* corrupt — surface as empty uploads */ }
118
- out.push({ cacheDir, sidecarPath, uploads });
119
- }
120
- return out;
121
- }
122
-
123
- /**
124
- * Remove every `.uploaded-to.json` sidecar under the build cache. Used by
125
- * `wyvrnpm clean --uploaded-built`. Leaves all other cache content intact
126
- * (the artefact zips, source clones, and build dirs stay put).
127
- *
128
- * @returns {number} count of sidecar files removed
129
- */
130
- function clearUploadSidecars() {
131
- const entries = listUploadSidecars();
132
- let removed = 0;
133
- for (const { sidecarPath } of entries) {
134
- try { fs.unlinkSync(sidecarPath); removed++; } catch { /* ignore */ }
135
- }
136
- return removed;
137
- }
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
-
329
- module.exports = {
330
- getBuildCacheRoot,
331
- getPackageBuildDir,
332
- getBuildPaths,
333
- clearBuildCache,
334
- hasValidArtefact,
335
- listUploadSidecars,
336
- clearUploadSidecars,
337
- LAYOUT,
338
- // Cache inspection (F12)
339
- listCacheEntries,
340
- computePruneSet,
341
- removeCacheEntries,
342
- parseDurationToCutoff,
343
- CACHE_DIR_RE,
344
- };
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+
7
+ /**
8
+ * Root directory for the wyvrnpm source-build cache.
9
+ * Windows: %LOCALAPPDATA%\wyvrnpm\bc\ (short name — Windows 260-char path limits bite nested CMake builds)
10
+ * Unix : ~/.cache/wyvrnpm/bc/
11
+ *
12
+ * @returns {string}
13
+ */
14
+ function getBuildCacheRoot() {
15
+ const base = process.env.LOCALAPPDATA
16
+ ? path.join(process.env.LOCALAPPDATA, 'wyvrnpm')
17
+ : path.join(process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache'), 'wyvrnpm');
18
+ return path.join(base, 'bc');
19
+ }
20
+
21
+ /**
22
+ * Per-build cache directory, keyed by `(name, version, profileHash)`. Each
23
+ * consumer profile gets its own cache line because the toolchain's output
24
+ * differs per profile.
25
+ *
26
+ * Layout inside this dir:
27
+ * src/ git clone
28
+ * wyvrn_internal/ package's own deps + generated toolchain
29
+ * build/ cmake -B
30
+ * build/install/ cmake install prefix → zipped as the artefact
31
+ * wyvrn.zip final artefact (treated as if downloaded)
32
+ *
33
+ * @param {{ name: string, version: string, profileHash: string }} args
34
+ * @returns {string}
35
+ */
36
+ function getPackageBuildDir({ name, version, profileHash }) {
37
+ // Sanitise — names should be filesystem-safe already, but guard against
38
+ // accidental path traversal.
39
+ const safeName = name.replace(/[^\w.-]/g, '_');
40
+ const safeVersion = version.replace(/[^\w.-]/g, '_');
41
+ const safeHash = profileHash.replace(/[^\w.-]/g, '_');
42
+ return path.join(getBuildCacheRoot(), `${safeName}-${safeVersion}-${safeHash}`);
43
+ }
44
+
45
+ /**
46
+ * Names of the artefacts inside a package build dir.
47
+ */
48
+ const LAYOUT = Object.freeze({
49
+ srcDir: 'src',
50
+ internalDir: 'wyvrn_internal',
51
+ buildDir: 'build',
52
+ installSubdir: 'install', // relative to buildDir — can be overridden by recipe.installDir
53
+ artefactZip: 'wyvrn.zip',
54
+ uploadSidecar: '.uploaded-to.json',
55
+ });
56
+
57
+ /**
58
+ * Resolve the standard sub-paths within a package build dir.
59
+ */
60
+ function getBuildPaths({ name, version, profileHash }) {
61
+ const root = getPackageBuildDir({ name, version, profileHash });
62
+ return {
63
+ root,
64
+ src: path.join(root, LAYOUT.srcDir),
65
+ internal: path.join(root, LAYOUT.internalDir),
66
+ build: path.join(root, LAYOUT.buildDir),
67
+ install: path.join(root, LAYOUT.buildDir, LAYOUT.installSubdir),
68
+ artefact: path.join(root, LAYOUT.artefactZip),
69
+ };
70
+ }
71
+
72
+ /**
73
+ * Remove the entire build cache root. Used by `wyvrnpm clean --build-cache`.
74
+ * @returns {string|null} the path removed, or null if nothing was there.
75
+ */
76
+ function clearBuildCache() {
77
+ const root = getBuildCacheRoot();
78
+ if (!fs.existsSync(root)) return null;
79
+ fs.rmSync(root, { recursive: true, force: true });
80
+ return root;
81
+ }
82
+
83
+ /**
84
+ * Check whether a valid cached artefact exists for this `(name, version, profileHash)`
85
+ * — used to short-circuit re-builds on re-run.
86
+ *
87
+ * @param {{ name, version, profileHash }} args
88
+ * @returns {boolean}
89
+ */
90
+ function hasValidArtefact(args) {
91
+ const { artefact } = getBuildPaths(args);
92
+ return fs.existsSync(artefact);
93
+ }
94
+
95
+ /**
96
+ * Find every `.uploaded-to.json` sidecar under the build cache. Each file
97
+ * records the destinations that a cached artefact has been pushed to via
98
+ * `wyvrnpm install --build=missing --upload-built`.
99
+ *
100
+ * Used by `wyvrnpm clean --uploaded-built` and for audit tooling.
101
+ *
102
+ * @returns {Array<{ cacheDir: string, sidecarPath: string, uploads: Array<object> }>}
103
+ */
104
+ function listUploadSidecars() {
105
+ const root = getBuildCacheRoot();
106
+ if (!fs.existsSync(root)) return [];
107
+ const out = [];
108
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
109
+ if (!entry.isDirectory()) continue;
110
+ const cacheDir = path.join(root, entry.name);
111
+ const sidecarPath = path.join(cacheDir, LAYOUT.uploadSidecar);
112
+ if (!fs.existsSync(sidecarPath)) continue;
113
+ let uploads = [];
114
+ try {
115
+ const raw = JSON.parse(fs.readFileSync(sidecarPath, 'utf8'));
116
+ if (Array.isArray(raw?.uploads)) uploads = raw.uploads;
117
+ } catch { /* corrupt — surface as empty uploads */ }
118
+ out.push({ cacheDir, sidecarPath, uploads });
119
+ }
120
+ return out;
121
+ }
122
+
123
+ /**
124
+ * Remove every `.uploaded-to.json` sidecar under the build cache. Used by
125
+ * `wyvrnpm clean --uploaded-built`. Leaves all other cache content intact
126
+ * (the artefact zips, source clones, and build dirs stay put).
127
+ *
128
+ * @returns {number} count of sidecar files removed
129
+ */
130
+ function clearUploadSidecars() {
131
+ const entries = listUploadSidecars();
132
+ let removed = 0;
133
+ for (const { sidecarPath } of entries) {
134
+ try { fs.unlinkSync(sidecarPath); removed++; } catch { /* ignore */ }
135
+ }
136
+ return removed;
137
+ }
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
+
329
+ module.exports = {
330
+ getBuildCacheRoot,
331
+ getPackageBuildDir,
332
+ getBuildPaths,
333
+ clearBuildCache,
334
+ hasValidArtefact,
335
+ listUploadSidecars,
336
+ clearUploadSidecars,
337
+ LAYOUT,
338
+ // Cache inspection (F12)
339
+ listCacheEntries,
340
+ computePruneSet,
341
+ removeCacheEntries,
342
+ parseDurationToCutoff,
343
+ CACHE_DIR_RE,
344
+ };