wyvrnpm 2.17.0 → 2.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -473,6 +473,62 @@ Everything after `--` is forwarded verbatim to `cmake --build` (e.g. `-j 8`).
473
473
 
474
474
  ---
475
475
 
476
+ ### `wyvrnpm test` (2.18.0+)
477
+
478
+ Runs ctest against the wyvrnpm-generated test preset (`wyvrn-<profile>-<config>`). Auto-runs `wyvrnpm build` first if the toolchain or build outputs are stale — disable with `--no-build` for a pure ctest pass.
479
+
480
+ ```bash
481
+ wyvrnpm test # Release config, auto-builds if stale
482
+ wyvrnpm test --all-configs # Loop over every recipe config
483
+ wyvrnpm test --config Debug,Release # Comma-separated config list
484
+ wyvrnpm test -R BasicSpatialGrid # Filter by test-name regex
485
+ wyvrnpm test -L fast # Filter by label
486
+ wyvrnpm test --format json # JSON summary on stdout for CI
487
+ wyvrnpm test --no-build # Skip auto-build, just run ctest
488
+ wyvrnpm test -- --output-on-failure -j 8 # Passthru to ctest
489
+ ```
490
+
491
+ | Flag | Default | Description |
492
+ |---|---|---|
493
+ | `--preset` / `-P` | `wyvrn-<profile>-<config>` | Override the test preset name |
494
+ | `--profile` / `-p` | *(config)* | Build profile — selects the preset family |
495
+ | `--config` / `-c` | `Release` | Single or comma-separated config list |
496
+ | `--all-configs` | `false` | Loop over every config the recipe declares (or all four canonical configs) |
497
+ | `--verbose` / `-v` | `false` | Forwards `-V` to ctest (per-test detail) |
498
+ | `--tests-regex` / `-R` | — | Run only tests whose name matches the regex |
499
+ | `--label-regex` / `-L` | — | Run only tests whose label matches the regex |
500
+ | `--auto-build` | `true` | Auto-run `wyvrnpm build` when toolchain is stale. `--no-build` opts out. |
501
+ | `--source` / `-s` | *(config)* | Forwarded to auto-build when triggered |
502
+ | `--build-dir` | `build` | Must match the value used at install time. Persist via `wyvrn.local.json:binaryDirRoot`. |
503
+ | `--format` | `text` | `text` (default) streams ctest verbatim; `json` emits a structured summary on stdout, logs to stderr |
504
+
505
+ Everything after `--` is forwarded verbatim to `ctest` (e.g. `--repeat until-pass:3`, `--output-junit results.xml`).
506
+
507
+ **`--format json` summary**:
508
+
509
+ ```json
510
+ {
511
+ "command": "test",
512
+ "wyvrnpmVersion": "2.18.0",
513
+ "profile": "default",
514
+ "configs": [
515
+ { "config": "Release", "preset": "wyvrn-default-release",
516
+ "total": 42, "passed": 40, "failed": 2,
517
+ "durationMs": 1234, "exitCode": 8 }
518
+ ],
519
+ "totalConfigs": 1,
520
+ "passedConfigs": 0,
521
+ "failedConfigs": 1,
522
+ "ok": false
523
+ }
524
+ ```
525
+
526
+ Exit code is `0` iff every config passes; `1` otherwise. CI scripts should gate on the `ok` field or the exit code, not on the text output.
527
+
528
+ **Test-only deps pattern.** For projects that need a test framework like Catch2 / GoogleTest only during the build, the canonical recipe is to declare an `options.tests` toggle and list the framework in `buildDependencies` with `whenOption: "tests"` (see §4.1 of [CLAUDE.md](CLAUDE.md)). Consumers pulling the prebuilt artefact never see the test deps; running `wyvrnpm install -o <Pkg>:tests=true && wyvrnpm test` from the project root fetches them and runs the suite.
529
+
530
+ ---
531
+
476
532
  ### `wyvrnpm publish`
477
533
 
478
534
  Zips the project directory and uploads `wyvrn.json` + `wyvrn.zip` to the destination source, tagged with the active build profile.
package/bin/wyvrnpm.js CHANGED
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env node
2
2
 
3
3
  'use strict';
4
4
 
@@ -543,7 +543,7 @@ yargs
543
543
  // ── cache ─────────────────────────────────────────────────────────────────
544
544
  .command(
545
545
  'cache',
546
- 'Inspect and selectively prune the machine-wide source-build cache (F12). Use `clean --build-cache` to wipe everything.',
546
+ 'Inspect and selectively prune the machine-wide caches. Use `clean --build-cache` / `clean --pkg-cache` to wipe everything.',
547
547
  (y) => {
548
548
  y
549
549
  .command(
@@ -559,6 +559,12 @@ yargs
559
559
  type: 'string',
560
560
  description: 'Glob over package names; overrides the positional',
561
561
  })
562
+ .option('type', {
563
+ type: 'string',
564
+ choices: ['src', 'pkg', 'all'],
565
+ default: 'all',
566
+ description: 'Which cache to inspect: src (source-build), pkg (binary downloads), or all',
567
+ })
562
568
  .option('format', {
563
569
  type: 'string',
564
570
  choices: ['text', 'json'],
@@ -573,6 +579,12 @@ yargs
573
579
  'Remove cache entries per policy. Requires at least one of --keep-last / --older-than.',
574
580
  (y2) => {
575
581
  y2
582
+ .option('type', {
583
+ type: 'string',
584
+ choices: ['src', 'pkg', 'all'],
585
+ default: 'all',
586
+ description: 'Which cache to prune: src (source-build), pkg (binary downloads), or all',
587
+ })
576
588
  .option('keep-last', {
577
589
  type: 'number',
578
590
  description:
@@ -644,6 +656,11 @@ yargs
644
656
  default: false,
645
657
  description: 'Also remove the source-build cache under %LOCALAPPDATA%\\wyvrnpm\\bc\\',
646
658
  })
659
+ .option('pkg-cache', {
660
+ type: 'boolean',
661
+ default: false,
662
+ description: 'Also remove the binary package cache under %LOCALAPPDATA%\\wyvrnpm\\pkg\\ (14-day TTL download cache)',
663
+ })
647
664
  .option('uploaded-built', {
648
665
  type: 'boolean',
649
666
  default: false,
@@ -662,6 +679,7 @@ yargs
662
679
  ...argv,
663
680
  buildDir: argv['build-dir'],
664
681
  buildCache: argv['build-cache'],
682
+ pkgCache: argv['pkg-cache'],
665
683
  uploadedBuilt: argv['uploaded-built'],
666
684
  dryRun: argv['dry-run'],
667
685
  }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wyvrnpm",
3
- "version": "2.17.0",
3
+ "version": "2.19.0",
4
4
  "description": "A simple, static-hosting-compatible C++ package manager",
5
5
  "keywords": [
6
6
  "c++",
@@ -1,189 +1,265 @@
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 };
1
+ 'use strict';
2
+
3
+ // F12: cache inspection + targeted pruning for both cache types.
4
+ //
5
+ // Two machine-wide caches exist:
6
+ // src — source-build cache at %LOCALAPPDATA%\wyvrnpm\bc\
7
+ // (entries built from source via wyvrnpm install --build=missing)
8
+ // pkg — binary package cache at %LOCALAPPDATA%\wyvrnpm\pkg\
9
+ // (downloaded prebuilt zips; TTL 14 days)
10
+ //
11
+ // `wyvrnpm cache list [pattern] [--type src|pkg|all]`
12
+ // Prints a human-readable table (or JSON with --format json) of entries
13
+ // from the selected cache type(s).
14
+ //
15
+ // `wyvrnpm cache prune [--type src|pkg|all] [--keep-last N] [--older-than 30d] ...`
16
+ // Removes entries per a policy; --type restricts which cache(s) are pruned.
17
+ //
18
+ // `wyvrnpm clean --build-cache` / `wyvrnpm clean --pkg-cache` still nuke
19
+ // each cache root entirely; these commands are the targeted alternative.
20
+
21
+ const {
22
+ listCacheEntries,
23
+ computePruneSet,
24
+ removeCacheEntries,
25
+ parseDurationToCutoff,
26
+ getBuildCacheRoot,
27
+ } = require('../build/cache');
28
+ const {
29
+ listPkgCacheEntries,
30
+ computePkgPruneSet,
31
+ removePkgCacheEntries,
32
+ getPkgCacheRoot,
33
+ } = require('../pkg-cache');
34
+ const log = require('../logger');
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Helpers
38
+ // ---------------------------------------------------------------------------
39
+
40
+ function formatBytes(n) {
41
+ if (n < 1024) return `${n} B`;
42
+ const units = ['KB', 'MB', 'GB', 'TB'];
43
+ let size = n / 1024, i = 0;
44
+ while (size >= 1024 && i < units.length - 1) { size /= 1024; i++; }
45
+ return `${size.toFixed(size >= 100 ? 0 : 1)} ${units[i]}`;
46
+ }
47
+
48
+ function formatMtime(ms) {
49
+ if (!ms) return '<unknown>';
50
+ return new Date(ms).toISOString().replace('T', ' ').slice(0, 16);
51
+ }
52
+
53
+ function globToRegExp(glob) {
54
+ const escaped = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&');
55
+ return new RegExp(`^${escaped.replace(/\*/g, '.*').replace(/\?/g, '.')}$`);
56
+ }
57
+
58
+ function patternFor(argPattern, positional) {
59
+ const src = argPattern ?? positional ?? null;
60
+ return src ? globToRegExp(src) : null;
61
+ }
62
+
63
+ /**
64
+ * Resolve --type to the set of cache types to act on.
65
+ * @param {string|undefined} typeArg
66
+ * @returns {{ includeSrc: boolean, includePkg: boolean }}
67
+ */
68
+ function resolveType(typeArg) {
69
+ const t = (typeArg ?? 'all').toLowerCase();
70
+ if (t === 'src') return { includeSrc: true, includePkg: false };
71
+ if (t === 'pkg') return { includeSrc: false, includePkg: true };
72
+ if (t === 'all') return { includeSrc: true, includePkg: true };
73
+ throw new Error(`--type must be src, pkg, or all (got ${JSON.stringify(typeArg)})`);
74
+ }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // list
78
+ // ---------------------------------------------------------------------------
79
+
80
+ /**
81
+ * wyvrnpm cache list [pattern] [--type src|pkg|all] [--format json|text]
82
+ *
83
+ * @param {object} argv
84
+ */
85
+ function list(argv) {
86
+ let typeOpts;
87
+ try { typeOpts = resolveType(argv.type); }
88
+ catch (err) { log.error(err.message); process.exit(1); }
89
+
90
+ const { includeSrc, includePkg } = typeOpts;
91
+ const regex = patternFor(argv.pattern, argv._?.[1]);
92
+
93
+ // Collect entries from each selected cache, tagged with type.
94
+ const allEntries = [];
95
+ if (includeSrc) {
96
+ for (const e of listCacheEntries()) {
97
+ allEntries.push({ ...e, cacheType: 'src' });
98
+ }
99
+ }
100
+ if (includePkg) {
101
+ for (const e of listPkgCacheEntries()) {
102
+ allEntries.push({ ...e, cacheType: 'pkg' });
103
+ }
104
+ }
105
+
106
+ const filtered = regex ? allEntries.filter((e) => regex.test(e.name)) : allEntries;
107
+ filtered.sort((a, b) => b.mtimeMs - a.mtimeMs);
108
+
109
+ if (argv.format === 'json') {
110
+ process.stdout.write(JSON.stringify({
111
+ command: 'cache-list',
112
+ cacheRoots: {
113
+ ...(includeSrc ? { src: getBuildCacheRoot() } : {}),
114
+ ...(includePkg ? { pkg: getPkgCacheRoot() } : {}),
115
+ },
116
+ totalEntries: filtered.length,
117
+ totalBytes: filtered.reduce((s, e) => s + e.sizeBytes, 0),
118
+ entries: filtered.map((e) => ({
119
+ type: e.cacheType,
120
+ name: e.name,
121
+ version: e.version,
122
+ profileHash: e.profileHash,
123
+ sizeBytes: e.sizeBytes,
124
+ mtime: new Date(e.mtimeMs).toISOString(),
125
+ ...(e.cacheType === 'src'
126
+ ? { hasArtefact: e.hasArtefact, uploadCount: e.uploads?.length ?? 0 }
127
+ : { downloadedAt: e.downloadedAt }),
128
+ })),
129
+ }, null, 2) + '\n');
130
+ return;
131
+ }
132
+
133
+ // Text output — show each selected cache root.
134
+ if (includeSrc) log.info(`Source-build cache : ${getBuildCacheRoot()}`);
135
+ if (includePkg) log.info(`Binary pkg cache : ${getPkgCacheRoot()}`);
136
+
137
+ if (filtered.length === 0) {
138
+ log.info(' (empty)');
139
+ return;
140
+ }
141
+
142
+ const showType = includeSrc && includePkg;
143
+ const rows = filtered.map((e) => [
144
+ ...(showType ? [e.cacheType] : []),
145
+ e.name,
146
+ e.version,
147
+ e.profileHash,
148
+ formatBytes(e.sizeBytes),
149
+ formatMtime(e.mtimeMs),
150
+ e.cacheType === 'src'
151
+ ? (e.uploads?.length > 0 ? `${e.uploads.length}` : '-')
152
+ : (e.downloadedAt ? e.downloadedAt.slice(0, 10) : '-'),
153
+ ]);
154
+ const header = [
155
+ ...(showType ? ['TYPE'] : []),
156
+ 'NAME', 'VERSION', 'PROFILE HASH', 'SIZE', 'MTIME',
157
+ showType ? 'UPLOADS/DL' : (includeSrc ? 'UPLOADS' : 'DOWNLOADED'),
158
+ ];
159
+ const widths = header.map((h, i) =>
160
+ Math.max(h.length, ...rows.map((r) => String(r[i]).length)),
161
+ );
162
+ const format = (cells) => cells.map((c, i) => String(c).padEnd(widths[i])).join(' ');
163
+ console.log('');
164
+ console.log(' ' + format(header));
165
+ console.log(' ' + widths.map((w) => '-'.repeat(w)).join(' '));
166
+ for (const r of rows) console.log(' ' + format(r));
167
+ console.log('');
168
+
169
+ const totalBytes = filtered.reduce((s, e) => s + e.sizeBytes, 0);
170
+ log.info(`${filtered.length} entries, ${formatBytes(totalBytes)} total`);
171
+ }
172
+
173
+ // ---------------------------------------------------------------------------
174
+ // prune
175
+ // ---------------------------------------------------------------------------
176
+
177
+ /**
178
+ * wyvrnpm cache prune [--type src|pkg|all] [--keep-last N] [--older-than 30d] [--pattern X] [--dry-run]
179
+ *
180
+ * @param {object} argv
181
+ */
182
+ function prune(argv) {
183
+ let typeOpts;
184
+ try { typeOpts = resolveType(argv.type); }
185
+ catch (err) { log.error(err.message); process.exit(1); }
186
+
187
+ const { includeSrc, includePkg } = typeOpts;
188
+ const hasKeep = argv.keepLast !== undefined && argv.keepLast !== null;
189
+ const hasOlder = typeof argv.olderThan === 'string' && argv.olderThan.length > 0;
190
+
191
+ if (!hasKeep && !hasOlder) {
192
+ const clearCmd = includeSrc && includePkg
193
+ ? '`wyvrnpm clean --build-cache` / `wyvrnpm clean --pkg-cache`'
194
+ : includeSrc ? '`wyvrnpm clean --build-cache`' : '`wyvrnpm clean --pkg-cache`';
195
+ log.error(
196
+ `cache prune: specify --keep-last <N> and/or --older-than <duration>.\n` +
197
+ ` To wipe the entire cache, use ${clearCmd}.`,
198
+ );
199
+ process.exit(1);
200
+ }
201
+
202
+ const policy = {};
203
+ if (hasKeep) {
204
+ const n = Number(argv.keepLast);
205
+ if (!Number.isInteger(n) || n < 0) {
206
+ log.error(`--keep-last must be a non-negative integer, got ${argv.keepLast}`);
207
+ process.exit(1);
208
+ }
209
+ policy.keepLast = n;
210
+ }
211
+ if (hasOlder) {
212
+ try { policy.olderThanMs = parseDurationToCutoff(argv.olderThan); }
213
+ catch (err) { log.error(err.message); process.exit(1); }
214
+ }
215
+ if (argv.pattern) {
216
+ policy.patternRegex = globToRegExp(argv.pattern);
217
+ }
218
+
219
+ // Collect prune sets from selected caches, tagged with type.
220
+ const pruneSet = [];
221
+ if (includeSrc) {
222
+ for (const e of computePruneSet(listCacheEntries(), policy)) {
223
+ pruneSet.push({ ...e, cacheType: 'src' });
224
+ }
225
+ }
226
+ if (includePkg) {
227
+ for (const e of computePkgPruneSet(listPkgCacheEntries(), policy)) {
228
+ pruneSet.push({ ...e, cacheType: 'pkg' });
229
+ }
230
+ }
231
+ pruneSet.sort((a, b) => b.mtimeMs - a.mtimeMs);
232
+
233
+ if (pruneSet.length === 0) {
234
+ log.info('Nothing to prune under that policy.');
235
+ return;
236
+ }
237
+
238
+ const totalBytes = pruneSet.reduce((s, e) => s + e.sizeBytes, 0);
239
+ const verb = argv.dryRun ? 'Would remove' : 'Removing';
240
+ const showType = includeSrc && includePkg;
241
+ log.info(`${verb} ${pruneSet.length} cache entr${pruneSet.length === 1 ? 'y' : 'ies'}, ${formatBytes(totalBytes)}:`);
242
+ for (const e of pruneSet) {
243
+ const typeTag = showType ? `[${e.cacheType}] ` : '';
244
+ console.log(` - ${typeTag}${e.name}@${e.version} [${e.profileHash}] ${formatBytes(e.sizeBytes)} ${formatMtime(e.mtimeMs)}`);
245
+ }
246
+
247
+ if (argv.dryRun) {
248
+ log.info('(dry-run — nothing deleted)');
249
+ return;
250
+ }
251
+
252
+ // Partition and remove per cache type.
253
+ const srcEntries = pruneSet.filter((e) => e.cacheType === 'src');
254
+ const pkgEntries = pruneSet.filter((e) => e.cacheType === 'pkg');
255
+ const removedSrc = srcEntries.length > 0 ? removeCacheEntries(srcEntries) : [];
256
+ const removedPkg = pkgEntries.length > 0 ? removePkgCacheEntries(pkgEntries) : [];
257
+ const totalRemoved = removedSrc.length + removedPkg.length;
258
+
259
+ log.info(`Removed ${totalRemoved} of ${pruneSet.length} cache entr${pruneSet.length === 1 ? 'y' : 'ies'}`);
260
+ if (totalRemoved < pruneSet.length) {
261
+ log.warn(`${pruneSet.length - totalRemoved} entr${pruneSet.length - totalRemoved === 1 ? 'y' : 'ies'} could not be removed (permissions or concurrent access)`);
262
+ }
263
+ }
264
+
265
+ module.exports = { list, prune };
@@ -8,6 +8,7 @@ const {
8
8
  getBuildCacheRoot,
9
9
  clearUploadSidecars,
10
10
  } = require('../build/cache');
11
+ const { clearPkgCache, getPkgCacheRoot } = require('../pkg-cache');
11
12
  const { readConfig } = require('../config');
12
13
  const { readLocalOverlay } = require('../conf');
13
14
  const { resolveBinaryDirRoot } = require('../binary-dir');
@@ -149,6 +150,7 @@ function stripPresetsFromWorkspace(rootDir, { dryRun }) {
149
150
  * @param {boolean} [argv.buildDir] When true, also wipes <binaryDirRoot>/wyvrn-<profile>/.
150
151
  * @param {boolean} [argv.all] When true, scorched-earth workspace cleanup.
151
152
  * @param {boolean} [argv.buildCache] When true, also nukes the source-build cache.
153
+ * @param {boolean} [argv.pkgCache] When true, also nukes the binary pkg cache.
152
154
  * @param {boolean} [argv.uploadedBuilt] When true, wipes upload sidecars only.
153
155
  * @param {boolean} [argv.dryRun] When true, log intentions but write nothing.
154
156
  * @returns {Promise<void>}
@@ -240,6 +242,21 @@ async function clean(argv) {
240
242
  }
241
243
  }
242
244
 
245
+ if (argv.pkgCache) {
246
+ if (dryRun) {
247
+ log.info(`Would remove binary package cache ${getPkgCacheRoot()}`);
248
+ removedAnything = true;
249
+ } else {
250
+ const removed = clearPkgCache();
251
+ if (removed) {
252
+ log.info(`Removed binary package cache ${removed}`);
253
+ removedAnything = true;
254
+ } else {
255
+ log.info(`No binary package cache at ${getPkgCacheRoot()}`);
256
+ }
257
+ }
258
+ }
259
+
243
260
  if (!removedAnything) {
244
261
  log.info('Nothing to clean.');
245
262
  } else if (dryRun) {
package/src/download.js CHANGED
@@ -8,6 +8,12 @@ const StreamZip = require('node-stream-zip');
8
8
 
9
9
  const { isLink, getLinkTarget } = require('./link-utils');
10
10
  const { sha256Of } = require('./profile');
11
+ const {
12
+ getPkgCacheDir,
13
+ readPkgCacheMeta,
14
+ writePkgCacheMeta,
15
+ isCacheEntryFresh,
16
+ } = require('./pkg-cache');
11
17
  const { getProvider } = require('./providers');
12
18
  const { evaluateCompat, pickBestCandidate, summarizeReasons } = require('./compat');
13
19
  const { buildFromSource } = require('./build');
@@ -18,6 +24,82 @@ const { verifyAttestation, parseSigEnvelope } = require('./signing');
18
24
  const { assertBoundHashes } = require('./signing/attestation');
19
25
  const log = require('./logger');
20
26
 
27
+ // ---------------------------------------------------------------------------
28
+ // Machine-wide binary package cache helpers
29
+ // ---------------------------------------------------------------------------
30
+
31
+ /**
32
+ * Check the machine-wide pkg cache for a fresh entry. Returns a hit descriptor
33
+ * or null on miss. Does NOT SHA-verify the cached zip — we trust local disk
34
+ * integrity the same way Conan does; corrupt zips surface as extract errors
35
+ * and fall through to re-download.
36
+ *
37
+ * @param {{ name, version, profileHash, requestConfigs: string[]|null }} args
38
+ * @returns {{ kind: 'fat-zip'|'slice', zipPath?: string, contentSha256?: string|null,
39
+ * slices?: Array<{config,zipPath,sha256}>, configs?: string[], meta: object }|null}
40
+ */
41
+ function checkPkgCacheHit({ name, version, profileHash, requestConfigs }) {
42
+ const cacheDir = getPkgCacheDir({ name, version, profileHash });
43
+ const meta = readPkgCacheMeta(cacheDir);
44
+ if (!meta || !isCacheEntryFresh(meta)) return null;
45
+
46
+ if (!meta.configs) {
47
+ // Fat-zip entry
48
+ const zipPath = path.join(cacheDir, 'wyvrn.zip');
49
+ if (!fs.existsSync(zipPath)) return null;
50
+ return { kind: 'fat-zip', zipPath, contentSha256: meta.contentSha256 ?? null, meta };
51
+ }
52
+
53
+ // Slice entry — serve requested configs that are cached; full miss if any are absent
54
+ const available = Object.keys(meta.configs);
55
+ const requested = (Array.isArray(requestConfigs) && requestConfigs.length > 0)
56
+ ? requestConfigs
57
+ : available;
58
+ const toServe = requested.filter((c) => available.includes(c));
59
+ if (toServe.length === 0) return null;
60
+
61
+ const slices = [];
62
+ for (const config of toServe) {
63
+ const zipPath = path.join(cacheDir, `wyvrn-${config}.zip`);
64
+ if (!fs.existsSync(zipPath)) return null; // partial cache — re-download everything
65
+ slices.push({ config, zipPath, sha256: meta.configs[config] ?? null });
66
+ }
67
+ return { kind: 'slice', slices, configs: toServe.slice().sort(), meta };
68
+ }
69
+
70
+ /**
71
+ * Save a successfully-downloaded v2 artefact to the pkg cache. Non-fatal:
72
+ * caller wraps in try/catch and logs a warning on failure.
73
+ *
74
+ * @param {{ name, version, profileHash, downloadResult: object, destZipPath: string }} args
75
+ */
76
+ function saveToPkgCache({ name, version, profileHash, downloadResult, destZipPath }) {
77
+ const cacheDir = getPkgCacheDir({ name, version, profileHash });
78
+ fs.mkdirSync(cacheDir, { recursive: true });
79
+
80
+ if (downloadResult.kind === 'slice') {
81
+ for (const slice of downloadResult.slices ?? []) {
82
+ fs.copyFileSync(slice.zipPath, path.join(cacheDir, `wyvrn-${slice.config}.zip`));
83
+ }
84
+ writePkgCacheMeta(cacheDir, {
85
+ name, version, profileHash,
86
+ downloadedAt: new Date().toISOString(),
87
+ configs: Object.fromEntries((downloadResult.slices ?? []).map((s) => [s.config, s.sha256])),
88
+ gitSha: downloadResult.gitSha ?? null,
89
+ gitRepo: downloadResult.gitRepo ?? null,
90
+ });
91
+ } else {
92
+ fs.copyFileSync(destZipPath, path.join(cacheDir, 'wyvrn.zip'));
93
+ writePkgCacheMeta(cacheDir, {
94
+ name, version, profileHash,
95
+ downloadedAt: new Date().toISOString(),
96
+ contentSha256: downloadResult.contentSha256 ?? null,
97
+ gitSha: downloadResult.gitSha ?? null,
98
+ gitRepo: downloadResult.gitRepo ?? null,
99
+ });
100
+ }
101
+ }
102
+
21
103
  // ---------------------------------------------------------------------------
22
104
  // v1 (legacy) download helpers
23
105
  // ---------------------------------------------------------------------------
@@ -722,6 +804,68 @@ async function downloadDependencies(deps, packageSources, platform, razerDir, ht
722
804
  }
723
805
  }
724
806
 
807
+ // ── Check machine-wide binary package cache ────────────────────────────
808
+ // Skip under --build=always (F9 forces a fresh source-build, so serving
809
+ // from cache defeats the purpose) and for legacy deps without a profile.
810
+ if (profileHash && profile && buildMode !== 'always') {
811
+ const pkgHit = checkPkgCacheHit({
812
+ name, version, profileHash,
813
+ requestConfigs: depEffectiveRequestConfigs,
814
+ });
815
+ if (pkgHit) {
816
+ try {
817
+ fs.mkdirSync(extractDir, { recursive: true });
818
+ if (pkgHit.kind === 'slice') {
819
+ for (const slice of pkgHit.slices) {
820
+ await extractZip(slice.zipPath, extractDir);
821
+ }
822
+ } else {
823
+ await extractZip(pkgHit.zipPath, extractDir);
824
+ }
825
+ fs.writeFileSync(versionFile, version, 'utf8');
826
+ const restoredMeta = {
827
+ version,
828
+ resolvedFrom: 'cached',
829
+ profileHash,
830
+ ...(pkgHit.kind === 'slice'
831
+ ? {
832
+ configs: pkgHit.configs,
833
+ perConfigSha256: Object.fromEntries(pkgHit.slices.map((s) => [s.config, s.sha256])),
834
+ }
835
+ : { contentSha256: pkgHit.contentSha256 }),
836
+ gitSha: pkgHit.meta.gitSha ?? null,
837
+ gitRepo: pkgHit.meta.gitRepo ?? null,
838
+ installedAt: new Date().toISOString(),
839
+ signer: pkgHit.meta.signer ?? null,
840
+ };
841
+ fs.writeFileSync(metaFile, JSON.stringify(restoredMeta, null, 2) + '\n', 'utf8');
842
+ log.info(
843
+ `Restored from pkg cache: ${name}@${version} [${profileHash}]` +
844
+ (pkgHit.kind === 'slice' ? ` configs=[${pkgHit.configs.join(', ')}]` : ''),
845
+ );
846
+ lockEntries.set(name, {
847
+ version,
848
+ resolvedFrom: 'cached',
849
+ profileHash,
850
+ ...(pkgHit.kind === 'slice'
851
+ ? {
852
+ configs: pkgHit.configs,
853
+ perConfigSha256: Object.fromEntries(pkgHit.slices.map((s) => [s.config, s.sha256])),
854
+ }
855
+ : { contentSha256: pkgHit.contentSha256 }),
856
+ gitSha: pkgHit.meta.gitSha ?? null,
857
+ ...(options ? { options } : {}),
858
+ ...(pinnedSource ? { source: pinnedSource.name } : {}),
859
+ });
860
+ continue;
861
+ } catch (err) {
862
+ log.warn(`pkg-cache restore failed for ${name}@${version} — ${err.message}; re-downloading`);
863
+ // Clean up partial extract and fall through to normal download.
864
+ try { fs.rmSync(extractDir, { recursive: true, force: true }); } catch { /* ignore */ }
865
+ }
866
+ }
867
+ }
868
+
725
869
  log.info(`Downloading: ${name}@${version}`);
726
870
  const destZipPath = path.join(razerDir, `${name}.zip`);
727
871
 
@@ -934,6 +1078,23 @@ async function downloadDependencies(deps, packageSources, platform, razerDir, ht
934
1078
  }
935
1079
  }
936
1080
 
1081
+ // ── Save to machine-wide binary package cache ──────────────────────────
1082
+ // Only for exact v2 hits (resolvedFrom v2 or v2-slice) where the
1083
+ // downloaded profileHash matches the consumer's requested hash.
1084
+ // Compat and source-build have different trust/reuse stories so we
1085
+ // leave them out of the pkg cache in phase 1.
1086
+ if (
1087
+ profileHash &&
1088
+ (resolvedFrom === 'v2' || resolvedFrom === 'v2-slice') &&
1089
+ downloadResult?.profileHash === profileHash
1090
+ ) {
1091
+ try {
1092
+ saveToPkgCache({ name, version, profileHash, downloadResult, destZipPath });
1093
+ } catch (err) {
1094
+ log.warn(`pkg-cache: failed to save ${name}@${version} — ${err.message}`);
1095
+ }
1096
+ }
1097
+
937
1098
  // ── Extract ─────────────────────────────────────────────────────────────
938
1099
  try {
939
1100
  fs.mkdirSync(extractDir, { recursive: true });
@@ -0,0 +1,296 @@
1
+ 'use strict';
2
+
3
+ // Machine-wide binary package cache.
4
+ //
5
+ // Downloaded prebuilt zips are stored here so that a `wyvrnpm clean` or a
6
+ // fresh CI agent can restore from local disk instead of hitting S3 again.
7
+ //
8
+ // Cache root:
9
+ // Windows: %LOCALAPPDATA%\wyvrnpm\pkg\
10
+ // Unix : ~/.cache/wyvrnpm/pkg/
11
+ //
12
+ // Entry layout per (name, version, profileHash):
13
+ // <root>/<name>-<version>-<profileHash>/
14
+ // wyvrn.zip (fat-zip publish)
15
+ // wyvrn-<Config>.zip (per-config slice publish, one per config)
16
+ // .pkg-meta.json sidecar — see schema below
17
+ //
18
+ // .pkg-meta.json schema:
19
+ // { name, version, profileHash,
20
+ // downloadedAt: ISO8601, <- TTL anchor
21
+ // contentSha256?: string, <- fat-zip SHA (absent for slices)
22
+ // configs?: { Config: sha256 }, <- slice SHAs (absent for fat-zip)
23
+ // gitSha?: string,
24
+ // gitRepo?: string }
25
+ //
26
+ // TTL: 14 days from downloadedAt (strict — stale entries are re-downloaded).
27
+ // Cache writes are non-fatal: a failure logs a warning and install continues.
28
+ //
29
+ // Prune / clear surface:
30
+ // wyvrnpm clean --pkg-cache wipe the entire pkg\ root
31
+ // wyvrnpm cache list --type pkg list pkg entries
32
+ // wyvrnpm cache prune --type pkg policy-based prune
33
+
34
+ const fs = require('fs');
35
+ const path = require('path');
36
+ const os = require('os');
37
+
38
+ const { parseDurationToCutoff, CACHE_DIR_RE } = require('./build/cache');
39
+
40
+ const DEFAULT_PKG_CACHE_TTL_MS = 14 * 24 * 60 * 60 * 1000; // 14 days
41
+
42
+ const PKG_META_FILE = '.pkg-meta.json';
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Path helpers
46
+ // ---------------------------------------------------------------------------
47
+
48
+ /**
49
+ * Root directory for the binary package cache.
50
+ * Windows: %LOCALAPPDATA%\wyvrnpm\pkg\
51
+ * Unix : ~/.cache/wyvrnpm/pkg/
52
+ *
53
+ * @returns {string}
54
+ */
55
+ function getPkgCacheRoot() {
56
+ const base = process.env.LOCALAPPDATA
57
+ ? path.join(process.env.LOCALAPPDATA, 'wyvrnpm')
58
+ : path.join(process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache'), 'wyvrnpm');
59
+ return path.join(base, 'pkg');
60
+ }
61
+
62
+ /**
63
+ * Per-artefact cache directory, keyed by `(name, version, profileHash)`.
64
+ *
65
+ * @param {{ name: string, version: string, profileHash: string }} args
66
+ * @returns {string}
67
+ */
68
+ function getPkgCacheDir({ name, version, profileHash }) {
69
+ const safeName = name.replace(/[^\w.-]/g, '_');
70
+ const safeVersion = version.replace(/[^\w.-]/g, '_');
71
+ const safeHash = profileHash.replace(/[^\w.-]/g, '_');
72
+ return path.join(getPkgCacheRoot(), `${safeName}-${safeVersion}-${safeHash}`);
73
+ }
74
+
75
+ /**
76
+ * Standard paths within a pkg cache entry.
77
+ *
78
+ * @param {{ name: string, version: string, profileHash: string }} args
79
+ * @returns {{ dir: string, metaFile: string, zipFile: string, sliceZip: (config: string) => string }}
80
+ */
81
+ function getPkgCachePaths({ name, version, profileHash }) {
82
+ const dir = getPkgCacheDir({ name, version, profileHash });
83
+ return {
84
+ dir,
85
+ metaFile: path.join(dir, PKG_META_FILE),
86
+ zipFile: path.join(dir, 'wyvrn.zip'),
87
+ sliceZip: (config) => path.join(dir, `wyvrn-${config}.zip`),
88
+ };
89
+ }
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // Meta sidecar
93
+ // ---------------------------------------------------------------------------
94
+
95
+ /**
96
+ * Read `.pkg-meta.json` from a cache entry dir. Returns null when absent or corrupt.
97
+ *
98
+ * @param {string} dir
99
+ * @returns {object|null}
100
+ */
101
+ function readPkgCacheMeta(dir) {
102
+ try {
103
+ const raw = JSON.parse(fs.readFileSync(path.join(dir, PKG_META_FILE), 'utf8'));
104
+ if (!raw || typeof raw !== 'object') return null;
105
+ return raw;
106
+ } catch {
107
+ return null;
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Write `.pkg-meta.json` to a cache entry dir (creates the dir if needed).
113
+ *
114
+ * @param {string} dir
115
+ * @param {object} meta
116
+ */
117
+ function writePkgCacheMeta(dir, meta) {
118
+ fs.mkdirSync(dir, { recursive: true });
119
+ fs.writeFileSync(
120
+ path.join(dir, PKG_META_FILE),
121
+ JSON.stringify(meta, null, 2) + '\n',
122
+ 'utf8',
123
+ );
124
+ }
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // TTL
128
+ // ---------------------------------------------------------------------------
129
+
130
+ /**
131
+ * Check whether a pkg cache meta entry is still within its TTL.
132
+ * Uses `downloadedAt` (ISO8601 string) as the anchor; defaults to 14 days.
133
+ *
134
+ * @param {object|null} meta
135
+ * @param {number} [ttlMs]
136
+ * @returns {boolean}
137
+ */
138
+ function isCacheEntryFresh(meta, ttlMs = DEFAULT_PKG_CACHE_TTL_MS) {
139
+ if (!meta || typeof meta.downloadedAt !== 'string') return false;
140
+ const parsed = Date.parse(meta.downloadedAt);
141
+ if (Number.isNaN(parsed)) return false;
142
+ const age = Date.now() - parsed;
143
+ return age >= 0 && age < ttlMs;
144
+ }
145
+
146
+ // ---------------------------------------------------------------------------
147
+ // Clear
148
+ // ---------------------------------------------------------------------------
149
+
150
+ /**
151
+ * Wipe the entire pkg cache root. Used by `wyvrnpm clean --pkg-cache`.
152
+ * @returns {string|null} the path removed, or null if nothing was there.
153
+ */
154
+ function clearPkgCache() {
155
+ const root = getPkgCacheRoot();
156
+ if (!fs.existsSync(root)) return null;
157
+ fs.rmSync(root, { recursive: true, force: true });
158
+ return root;
159
+ }
160
+
161
+ // ---------------------------------------------------------------------------
162
+ // Cache inspection
163
+ // ---------------------------------------------------------------------------
164
+
165
+ function dirSizeBytes(dir) {
166
+ let total = 0;
167
+ let entries;
168
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
169
+ catch { return 0; }
170
+ for (const e of entries) {
171
+ if (e.isSymbolicLink()) continue;
172
+ const p = path.join(dir, e.name);
173
+ try {
174
+ if (e.isDirectory()) total += dirSizeBytes(p);
175
+ else if (e.isFile()) total += fs.statSync(p).size;
176
+ } catch { /* concurrent deletion */ }
177
+ }
178
+ return total;
179
+ }
180
+
181
+ /**
182
+ * List every entry in the pkg cache, enriched with metadata.
183
+ * Entries whose directory name can't be parsed are silently skipped.
184
+ *
185
+ * @returns {Array<{
186
+ * name: string,
187
+ * version: string,
188
+ * profileHash: string,
189
+ * cacheDir: string,
190
+ * sizeBytes: number,
191
+ * mtimeMs: number, <- derived from downloadedAt for reliable sorting
192
+ * downloadedAt: string|null,
193
+ * contentSha256: string|null,
194
+ * configs: object|null, <- { Config: sha256 } for slices
195
+ * }>}
196
+ */
197
+ function listPkgCacheEntries() {
198
+ const root = getPkgCacheRoot();
199
+ if (!fs.existsSync(root)) return [];
200
+
201
+ const out = [];
202
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
203
+ if (!entry.isDirectory()) continue;
204
+ const m = entry.name.match(CACHE_DIR_RE);
205
+ if (!m) continue;
206
+ const [, name, version, profileHash] = m;
207
+
208
+ const cacheDir = path.join(root, entry.name);
209
+ const meta = readPkgCacheMeta(cacheDir);
210
+
211
+ const downloadedAt = meta?.downloadedAt ?? null;
212
+ const mtimeMs = downloadedAt
213
+ ? (Date.parse(downloadedAt) || 0)
214
+ : (() => { try { return fs.statSync(cacheDir).mtimeMs; } catch { return 0; } })();
215
+
216
+ out.push({
217
+ name, version, profileHash, cacheDir,
218
+ sizeBytes: dirSizeBytes(cacheDir),
219
+ mtimeMs,
220
+ downloadedAt,
221
+ contentSha256: meta?.contentSha256 ?? null,
222
+ configs: meta?.configs ?? null,
223
+ });
224
+ }
225
+ return out;
226
+ }
227
+
228
+ /**
229
+ * Compute which pkg cache entries would be pruned under the given policy.
230
+ * Pure — does no I/O. Same semantics as `computePruneSet` in build/cache.js.
231
+ *
232
+ * @param {ReturnType<typeof listPkgCacheEntries>} entries
233
+ * @param {{ keepLast?: number, olderThanMs?: number, patternRegex?: RegExp }} policy
234
+ * @returns {ReturnType<typeof listPkgCacheEntries>}
235
+ */
236
+ function computePkgPruneSet(entries, { keepLast, olderThanMs, patternRegex } = {}) {
237
+ let candidates = entries;
238
+ if (patternRegex) candidates = candidates.filter((e) => patternRegex.test(e.name));
239
+
240
+ const keep = new Set();
241
+ if (typeof keepLast === 'number' && keepLast >= 0) {
242
+ const groups = new Map();
243
+ for (const e of candidates) {
244
+ const k = `${e.name}\x1f${e.version}`;
245
+ if (!groups.has(k)) groups.set(k, []);
246
+ groups.get(k).push(e);
247
+ }
248
+ for (const group of groups.values()) {
249
+ group.sort((a, b) => b.mtimeMs - a.mtimeMs);
250
+ for (let i = 0; i < Math.min(keepLast, group.length); i++) {
251
+ keep.add(group[i].cacheDir);
252
+ }
253
+ }
254
+ }
255
+
256
+ const toPrune = candidates.filter((e) => !keep.has(e.cacheDir));
257
+ const filtered = typeof olderThanMs === 'number'
258
+ ? toPrune.filter((e) => e.mtimeMs < olderThanMs)
259
+ : toPrune;
260
+
261
+ filtered.sort((a, b) => b.mtimeMs - a.mtimeMs);
262
+ return filtered;
263
+ }
264
+
265
+ /**
266
+ * Delete a list of pkg cache entries. Returns the subset actually removed.
267
+ *
268
+ * @param {ReturnType<typeof listPkgCacheEntries>} entries
269
+ * @returns {ReturnType<typeof listPkgCacheEntries>}
270
+ */
271
+ function removePkgCacheEntries(entries) {
272
+ const removed = [];
273
+ for (const e of entries) {
274
+ try {
275
+ fs.rmSync(e.cacheDir, { recursive: true, force: true });
276
+ removed.push(e);
277
+ } catch { /* skip — caller reports the delta */ }
278
+ }
279
+ return removed;
280
+ }
281
+
282
+ module.exports = {
283
+ DEFAULT_PKG_CACHE_TTL_MS,
284
+ getPkgCacheRoot,
285
+ getPkgCacheDir,
286
+ getPkgCachePaths,
287
+ readPkgCacheMeta,
288
+ writePkgCacheMeta,
289
+ isCacheEntryFresh,
290
+ clearPkgCache,
291
+ listPkgCacheEntries,
292
+ computePkgPruneSet,
293
+ removePkgCacheEntries,
294
+ // Re-export for convenience in tests
295
+ parseDurationToCutoff,
296
+ };