wyvrnpm 2.18.0 → 2.20.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/bin/wyvrnpm.js +19 -1
- package/package.json +1 -1
- package/src/build/cmake.js +27 -2
- package/src/build/default-generator.js +124 -0
- package/src/commands/build.js +12 -3
- package/src/commands/cache.js +265 -189
- package/src/commands/clean.js +17 -0
- package/src/commands/install.js +16 -0
- package/src/download.js +161 -0
- package/src/pkg-cache.js +296 -0
package/bin/wyvrnpm.js
CHANGED
|
@@ -543,7 +543,7 @@ yargs
|
|
|
543
543
|
// ── cache ─────────────────────────────────────────────────────────────────
|
|
544
544
|
.command(
|
|
545
545
|
'cache',
|
|
546
|
-
'Inspect and selectively prune the machine-wide
|
|
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
package/src/build/cmake.js
CHANGED
|
@@ -5,6 +5,8 @@ const path = require('path');
|
|
|
5
5
|
const { spawn } = require('child_process');
|
|
6
6
|
|
|
7
7
|
const { loadMsvcEnv } = require('./msvc-env');
|
|
8
|
+
const { archToVsPlatform, isVisualStudioGenerator } = require('../toolchain');
|
|
9
|
+
const { detectDefaultGenerator } = require('./default-generator');
|
|
8
10
|
const log = require('../logger');
|
|
9
11
|
|
|
10
12
|
/**
|
|
@@ -190,15 +192,21 @@ function isMultiConfigGenerator(generatorName) {
|
|
|
190
192
|
* configureExtra: string[],
|
|
191
193
|
* buildType?: string|null, // single-config only
|
|
192
194
|
* configurations?: string[]|null, // multi-config only, pins CMAKE_CONFIGURATION_TYPES
|
|
195
|
+
* vsPlatform?: string|null, // VS generators only — emits -A <platform>
|
|
193
196
|
* }} args
|
|
194
197
|
* @returns {string[]}
|
|
195
198
|
*/
|
|
196
199
|
function buildConfigureArgs({
|
|
197
200
|
sourceDir, buildDir, installDir, toolchainFile, generator, configureExtra,
|
|
198
|
-
buildType, configurations,
|
|
201
|
+
buildType, configurations, vsPlatform = null,
|
|
199
202
|
}) {
|
|
200
203
|
const args = ['-S', sourceDir, '-B', buildDir];
|
|
201
204
|
if (generator) args.push('-G', generator);
|
|
205
|
+
// Visual Studio generators default to host x64 unless the target platform is
|
|
206
|
+
// pinned with -A. Without this, an x86/ARM profile silently produces x64
|
|
207
|
+
// binaries (PLAN-VS-ARCH-PIN gate 3). Mapped from the profile arch by the
|
|
208
|
+
// caller; null for non-VS generators.
|
|
209
|
+
if (vsPlatform) args.push('-A', vsPlatform);
|
|
202
210
|
args.push(`-DCMAKE_TOOLCHAIN_FILE=${toolchainFile}`);
|
|
203
211
|
args.push(`-DCMAKE_INSTALL_PREFIX=${installDir}`);
|
|
204
212
|
// Single-config generators bake CMAKE_BUILD_TYPE into the cache.
|
|
@@ -257,7 +265,22 @@ async function configureBuildInstall({ recipe, srcRoot, buildPaths, toolchainFil
|
|
|
257
265
|
|
|
258
266
|
// Resolve generator from the fallback chain (probes each candidate's
|
|
259
267
|
// backing tool). `null` → omit `-G`, let CMake pick its default.
|
|
260
|
-
|
|
268
|
+
let generator = await pickGenerator(recipe.generators);
|
|
269
|
+
|
|
270
|
+
// No recipe generator → CMake's Windows default is the newest VS, which
|
|
271
|
+
// builds host x64 unless `-A` is pinned. Resolve it to a concrete VS
|
|
272
|
+
// generator so the -A below applies (PLAN-VS-ARCH-PIN gate 3). Non-Windows /
|
|
273
|
+
// non-MSVC leaves `generator` null → CMake default, no -A (target arch is
|
|
274
|
+
// the compiler's job there).
|
|
275
|
+
if (!generator && profile && profile.os === 'Windows' && profile.compiler === 'msvc') {
|
|
276
|
+
generator = await detectDefaultGenerator();
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// VS generators need -A <platform> to honour the profile arch — recipe-pinned
|
|
280
|
+
// VS generators get it too, not just the detected default.
|
|
281
|
+
const vsPlatform = isVisualStudioGenerator(generator)
|
|
282
|
+
? archToVsPlatform(profile && profile.arch)
|
|
283
|
+
: null;
|
|
261
284
|
|
|
262
285
|
// On Windows with an MSVC profile + non-VS generator (Ninja, Make, ...),
|
|
263
286
|
// auto-enter a Developer Command Prompt-equivalent env so `cl.exe` is on
|
|
@@ -281,6 +304,7 @@ async function configureBuildInstall({ recipe, srcRoot, buildPaths, toolchainFil
|
|
|
281
304
|
installDir: buildPaths.install,
|
|
282
305
|
toolchainFile,
|
|
283
306
|
generator,
|
|
307
|
+
vsPlatform,
|
|
284
308
|
configureExtra: recipe.configure,
|
|
285
309
|
buildType: null, // multi-config picks at build time
|
|
286
310
|
configurations: configList, // pin CMAKE_CONFIGURATION_TYPES so all 4 build-<Config>.ninja files get generated
|
|
@@ -313,6 +337,7 @@ async function configureBuildInstall({ recipe, srcRoot, buildPaths, toolchainFil
|
|
|
313
337
|
installDir: buildPaths.install,
|
|
314
338
|
toolchainFile,
|
|
315
339
|
generator,
|
|
340
|
+
vsPlatform,
|
|
316
341
|
configureExtra: recipe.configure,
|
|
317
342
|
buildType: cfg,
|
|
318
343
|
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { spawn } = require('child_process');
|
|
4
|
+
|
|
5
|
+
const { isVisualStudioGenerator } = require('../toolchain');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Parse `cmake --help` output for the platform's default generator. CMake
|
|
9
|
+
* marks the default with a leading `* ` in the "Generators" section, e.g.:
|
|
10
|
+
*
|
|
11
|
+
* The following generators are available on this platform (* marks default):
|
|
12
|
+
* * Visual Studio 17 2022 [arch] = Generates Visual Studio 2022 project files.
|
|
13
|
+
* Visual Studio 16 2019 [arch] = Generates Visual Studio 2019 project files.
|
|
14
|
+
* Ninja = Generates build.ninja files.
|
|
15
|
+
*
|
|
16
|
+
* Returns the default generator's name verbatim (with any trailing ` [arch]`
|
|
17
|
+
* annotation stripped), or null when no default marker is present. The name is
|
|
18
|
+
* returned as-is — deciding whether it's a Visual Studio generator is the
|
|
19
|
+
* caller's job.
|
|
20
|
+
*
|
|
21
|
+
* @param {string} helpText
|
|
22
|
+
* @returns {string|null}
|
|
23
|
+
*/
|
|
24
|
+
function parseDefaultGenerator(helpText) {
|
|
25
|
+
if (typeof helpText !== 'string') return null;
|
|
26
|
+
for (const line of helpText.split(/\r?\n/)) {
|
|
27
|
+
const m = line.match(/^\*\s+(.+?)\s*=/);
|
|
28
|
+
if (!m) continue;
|
|
29
|
+
return m[1].replace(/\s*\[arch\]\s*$/, '').trim();
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Spawn `cmake --help` and resolve its stdout. Resolves null (never rejects)
|
|
36
|
+
* when cmake is absent from PATH or exits non-zero — generator detection is
|
|
37
|
+
* best-effort, and an unavailable cmake simply means the caller falls back to
|
|
38
|
+
* leaving the generator unpinned, exactly as before this feature.
|
|
39
|
+
*
|
|
40
|
+
* @returns {Promise<string|null>}
|
|
41
|
+
*/
|
|
42
|
+
function runCmakeHelp() {
|
|
43
|
+
return new Promise((resolve) => {
|
|
44
|
+
let out = '';
|
|
45
|
+
const proc = spawn('cmake', ['--help'], {
|
|
46
|
+
shell: process.platform === 'win32',
|
|
47
|
+
});
|
|
48
|
+
proc.stdout.on('data', (chunk) => { out += chunk.toString(); });
|
|
49
|
+
// cmake not on PATH → detection unavailable; recoverable, fall back to null.
|
|
50
|
+
proc.on('error', () => resolve(null));
|
|
51
|
+
proc.on('close', (code) => resolve(code === 0 ? out : null));
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Per-process memo of the production (no-injection) probe result. The default
|
|
56
|
+
// generator can't change mid-run, so a single `cmake --help` spawn is enough
|
|
57
|
+
// for the whole process — important when one process drives many installs
|
|
58
|
+
// (e.g. a source-build that resolves several deps, or the test suite). Calls
|
|
59
|
+
// that inject `platform`/`runHelp` (tests) bypass this cache to stay hermetic.
|
|
60
|
+
let cachedDefault; // undefined = unprobed; string|null = probed result
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Detect the Visual Studio generator CMake would use by default on this
|
|
64
|
+
* machine. On Windows with VS installed, CMake's implicit default is the
|
|
65
|
+
* newest installed VS — which builds host x64 unless `-A`/architecture is
|
|
66
|
+
* pinned. Returns that VS generator name so callers can pin it (and the
|
|
67
|
+
* matching `-A`), or null when the default is not a VS generator
|
|
68
|
+
* (non-Windows, or VS not installed → today's behaviour, no pin).
|
|
69
|
+
*
|
|
70
|
+
* `platform` and `runHelp` are injectable for tests; the defaults probe the
|
|
71
|
+
* real machine and memoise the result per-process.
|
|
72
|
+
*
|
|
73
|
+
* @param {{ platform?: string, runHelp?: () => Promise<string|null> }} [opts]
|
|
74
|
+
* @returns {Promise<string|null>}
|
|
75
|
+
*/
|
|
76
|
+
async function detectDefaultGenerator({ platform, runHelp } = {}) {
|
|
77
|
+
const injected = platform !== undefined || runHelp !== undefined;
|
|
78
|
+
if (!injected && cachedDefault !== undefined) return cachedDefault;
|
|
79
|
+
|
|
80
|
+
const plat = platform ?? process.platform;
|
|
81
|
+
let result = null;
|
|
82
|
+
if (plat === 'win32') {
|
|
83
|
+
const helpText = await (runHelp ?? runCmakeHelp)();
|
|
84
|
+
const name = helpText ? parseDefaultGenerator(helpText) : null;
|
|
85
|
+
// Only VS generators need an arch pin; everything else (Ninja, NMake, …)
|
|
86
|
+
// already honours the active vcvarsall env for target arch.
|
|
87
|
+
result = isVisualStudioGenerator(name) ? name : null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (!injected) cachedDefault = result;
|
|
91
|
+
return result;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Resolve the generator a CMake invocation will actually use, given an
|
|
96
|
+
* optional explicit choice (CLI `--generator` or a recipe `build.generator`)
|
|
97
|
+
* and the active profile.
|
|
98
|
+
*
|
|
99
|
+
* - An explicit non-empty generator always wins, verbatim.
|
|
100
|
+
* - Otherwise, on a Windows/MSVC profile, fall back to the detected default
|
|
101
|
+
* VS generator so callers can pin `-A`/architecture against it.
|
|
102
|
+
* - Otherwise null — the caller leaves the generator unpinned (CMake picks
|
|
103
|
+
* its default), exactly as before this feature. On non-Windows toolchains
|
|
104
|
+
* target arch is the compiler's job, not the generator's.
|
|
105
|
+
*
|
|
106
|
+
* @param {{ explicit?: string|null,
|
|
107
|
+
* profile?: { os?: string, compiler?: string }|null,
|
|
108
|
+
* detect?: () => Promise<string|null> }} [args]
|
|
109
|
+
* @returns {Promise<string|null>}
|
|
110
|
+
*/
|
|
111
|
+
async function resolveEffectiveGenerator({ explicit = null, profile = null, detect = detectDefaultGenerator } = {}) {
|
|
112
|
+
if (typeof explicit === 'string' && explicit.trim()) return explicit;
|
|
113
|
+
if (profile && profile.os === 'Windows' && profile.compiler === 'msvc') {
|
|
114
|
+
return detect();
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
module.exports = {
|
|
120
|
+
resolveEffectiveGenerator,
|
|
121
|
+
detectDefaultGenerator,
|
|
122
|
+
// Exported for unit tests
|
|
123
|
+
parseDefaultGenerator,
|
|
124
|
+
};
|
package/src/commands/build.js
CHANGED
|
@@ -7,6 +7,7 @@ const { spawn } = require('child_process');
|
|
|
7
7
|
const { normalizeRecipe, hasBuildRecipe, VALID_CMAKE_CONFIGS, DEFAULT_RECIPE } = require('../build/recipe');
|
|
8
8
|
const { loadMsvcEnv } = require('../build/msvc-env');
|
|
9
9
|
const { archToVsPlatform, isVisualStudioGenerator } = require('../toolchain');
|
|
10
|
+
const { resolveEffectiveGenerator } = require('../build/default-generator');
|
|
10
11
|
const { LOCAL_OVERLAY_FILENAME, readLocalOverlay } = require('../conf');
|
|
11
12
|
const { resolveBinaryDirRoot, DEFAULT_BINARY_DIR_ROOT } = require('../binary-dir');
|
|
12
13
|
const { buildContext } = require('../context');
|
|
@@ -384,9 +385,17 @@ async function build(argv) {
|
|
|
384
385
|
if (recipe.generators.length > 0) [presetGenerator] = recipe.generators;
|
|
385
386
|
} catch { /* templates need options → skip env activation */ }
|
|
386
387
|
}
|
|
387
|
-
// CLI --generator wins
|
|
388
|
-
//
|
|
389
|
-
|
|
388
|
+
// CLI --generator wins, then the recipe's generator, then — on a
|
|
389
|
+
// Windows/MSVC profile with neither — CMake's detected default VS
|
|
390
|
+
// generator. Resolving the default here keeps the `-A` pin below and the
|
|
391
|
+
// vcvarsall-skip decision consistent with the generator `cmake --preset`
|
|
392
|
+
// will actually use (install pins the same generator into the preset).
|
|
393
|
+
// Non-Windows / non-MSVC → null, unchanged from before.
|
|
394
|
+
// See claude/PLAN-VS-ARCH-PIN.md.
|
|
395
|
+
effectiveGenerator = await resolveEffectiveGenerator({
|
|
396
|
+
explicit: effectiveGenerator ?? presetGenerator,
|
|
397
|
+
profile: activeProfile,
|
|
398
|
+
});
|
|
390
399
|
cmakeEnv = await loadMsvcEnv({
|
|
391
400
|
profile: activeProfile,
|
|
392
401
|
skipForGenerator: effectiveGenerator,
|
package/src/commands/cache.js
CHANGED
|
@@ -1,189 +1,265 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
// F12:
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
function
|
|
41
|
-
if (
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
if (
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
if (
|
|
138
|
-
log.
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
|
|
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 };
|
package/src/commands/clean.js
CHANGED
|
@@ -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/commands/install.js
CHANGED
|
@@ -18,6 +18,7 @@ const { resolveBinaryDirRoot } = require('../binary
|
|
|
18
18
|
const { hashProfile, formatProfile, mergeProfile } = require('../profile');
|
|
19
19
|
const { generateToolchain, generateCMakePresets } = require('../toolchain');
|
|
20
20
|
const { normalizeRecipe, hasBuildRecipe } = require('../build/recipe');
|
|
21
|
+
const { resolveEffectiveGenerator } = require('../build/default-generator');
|
|
21
22
|
const { buildContext } = require('../context');
|
|
22
23
|
const {
|
|
23
24
|
normalizeOptionsDeclaration,
|
|
@@ -661,6 +662,21 @@ async function install(argv) {
|
|
|
661
662
|
}
|
|
662
663
|
}
|
|
663
664
|
|
|
665
|
+
// When no generator was pinned by a recipe (the common recipe-less consumer
|
|
666
|
+
// case), CMake falls back to its platform default. On Windows that's the
|
|
667
|
+
// newest Visual Studio, which silently targets host x64 unless
|
|
668
|
+
// `-A`/architecture is pinned — so an x86 (or any non-host-arch) profile
|
|
669
|
+
// would produce mismatched binaries that find_package() later rejects with a
|
|
670
|
+
// pointer-size error. Resolve the default to a concrete VS generator so the
|
|
671
|
+
// preset carries both `generator` and `architecture` (buildConfigurePreset
|
|
672
|
+
// pins arch only when it knows the generator is VS). Non-Windows / non-MSVC
|
|
673
|
+
// → null, leaving the preset generator-less exactly as before. See
|
|
674
|
+
// claude/PLAN-VS-ARCH-PIN.md.
|
|
675
|
+
presetGenerator = await resolveEffectiveGenerator({
|
|
676
|
+
explicit: presetGenerator,
|
|
677
|
+
profile: baseProfile,
|
|
678
|
+
});
|
|
679
|
+
|
|
664
680
|
// PLAN-CONF.md §4.3 / §5 presets sink: fold effectiveConf's
|
|
665
681
|
// cmake.cache namespace into the preset's cacheVariables. Runs
|
|
666
682
|
// regardless of whether the project has its own build recipe —
|
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 });
|
package/src/pkg-cache.js
ADDED
|
@@ -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
|
+
};
|