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.
- package/README.md +1914 -1860
- package/bin/{wyvrn.js → wyvrnpm.js} +66 -0
- package/cmake/cpp.cmake +9 -9
- package/cmake/functions.cmake +224 -224
- package/cmake/macros.cmake +284 -284
- package/package.json +3 -2
- package/src/auth.js +66 -66
- package/src/binary-dir.js +95 -0
- package/src/bootstrap/cookbook.js +196 -196
- package/src/bootstrap/detect.js +150 -150
- package/src/bootstrap/index.js +220 -220
- package/src/bootstrap/version.js +72 -72
- package/src/build/cache.js +344 -344
- package/src/build/clone.js +170 -170
- package/src/build/cmake.js +342 -342
- package/src/build/index.js +299 -297
- package/src/build/msvc-env.js +260 -260
- package/src/build/recipe.js +188 -188
- package/src/commands/add.js +141 -141
- package/src/commands/bootstrap.js +96 -96
- package/src/commands/build.js +482 -452
- package/src/commands/cache.js +189 -189
- package/src/commands/clean.js +80 -80
- package/src/commands/configure.js +92 -92
- package/src/commands/init.js +70 -70
- package/src/commands/install-skill.js +115 -115
- package/src/commands/install.js +730 -674
- package/src/commands/link.js +320 -320
- package/src/commands/profile.js +237 -237
- package/src/commands/publish.js +584 -555
- package/src/commands/show.js +252 -252
- package/src/commands/version.js +187 -0
- package/src/compat.js +273 -273
- package/src/conf/index.js +415 -391
- package/src/conf/namespaces.js +94 -94
- package/src/context.js +230 -230
- package/src/http-fetch.js +53 -53
- package/src/ignore.js +118 -118
- package/src/logger.js +122 -122
- package/src/options.js +303 -303
- package/src/settings-overrides.js +152 -152
- package/src/toolchain/deps.js +164 -164
- package/src/toolchain/index.js +212 -212
- package/src/toolchain/presets.js +356 -340
- package/src/toolchain/template.cmake +77 -77
- package/src/upload-built.js +265 -265
- package/src/version-range.js +301 -301
- package/src/zip-safe.js +52 -52
- package/src/zip-stream.js +126 -0
package/src/build/cache.js
CHANGED
|
@@ -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
|
+
};
|