wyvrnpm 2.4.1 → 2.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const { substituteTemplateAll } = require('../options');
4
+ const { readRecipeConf } = require('../conf');
4
5
 
5
6
  /**
6
7
  * The `build` section of a source-repo `wyvrn.json` tells wyvrnpm how to
@@ -60,15 +61,22 @@ const SUPPORTED_SYSTEMS = new Set(['cmake']);
60
61
  * no options), any `${options.*}` token in the recipe also fails fast —
61
62
  * recipes don't get to reference undeclared options.
62
63
  *
64
+ * When `manifestForConfCollisionCheck` is supplied, PLAN-CONF.md §4.7's
65
+ * collision detection runs: any `-DKEY=...` in `build.configure` whose
66
+ * KEY is ALSO declared in recipe-level `conf.cmake.cache` is a hard
67
+ * error. Recipes pick ONE route, avoiding silent duplication.
68
+ *
63
69
  * @param {object|null|undefined} rawBuild
64
70
  * @param {Record<string, any>|null} [effectiveOptions]
71
+ * @param {object|null} [manifestForConfCollisionCheck]
65
72
  * @returns {{ system: string, generators: string[], configure: string[],
66
73
  * buildArgs: string[], installDir: string, sourceSubdir: string,
67
74
  * requiredTools: string[] }}
68
- * @throws {Error} when `system` is set but not supported, or when a
69
- * `${options.<name>}` template references an undeclared option
75
+ * @throws {Error} when `system` is set but not supported, when a
76
+ * `${options.<name>}` template references an undeclared option,
77
+ * or when `build.configure` collides with `conf.cmake.cache`.
70
78
  */
71
- function normalizeRecipe(rawBuild, effectiveOptions = null) {
79
+ function normalizeRecipe(rawBuild, effectiveOptions = null, manifestForConfCollisionCheck = null) {
72
80
  const b = rawBuild ?? {};
73
81
  const system = b.system ?? DEFAULT_RECIPE.system;
74
82
  if (!SUPPORTED_SYSTEMS.has(system)) {
@@ -124,6 +132,31 @@ function normalizeRecipe(rawBuild, effectiveOptions = null) {
124
132
  'build.buildArgs',
125
133
  );
126
134
 
135
+ // PLAN-CONF.md §4.7: recipe-side collision between `build.configure`
136
+ // `-DKEY=...` args and declared `conf.cmake.cache.KEY` is a hard
137
+ // error. Forces the author to pick one route; avoids silent
138
+ // duplication where one route wins opaquely.
139
+ if (manifestForConfCollisionCheck) {
140
+ const recipeConfFlat = readRecipeConf(manifestForConfCollisionCheck);
141
+ const confCacheKeys = new Set(
142
+ Object.keys(recipeConfFlat)
143
+ .filter((k) => k.startsWith('cmake.cache.'))
144
+ .map((k) => k.slice('cmake.cache.'.length)),
145
+ );
146
+ if (confCacheKeys.size > 0) {
147
+ for (const arg of configure) {
148
+ const m = arg.match(/^-D([^=]+)=/);
149
+ if (m && confCacheKeys.has(m[1])) {
150
+ throw new Error(
151
+ `recipe collision: "${m[1]}" is set in both ` +
152
+ `build.configure (as ${JSON.stringify(arg)}) and ` +
153
+ `conf.cmake.cache. Pick one route — see claude/PLAN-CONF.md §4.7.`,
154
+ );
155
+ }
156
+ }
157
+ }
158
+ }
159
+
127
160
  return {
128
161
  system,
129
162
  generators,
@@ -10,6 +10,8 @@ const { readManifest } = require('../manifest');
10
10
  const { normalizeRecipe, hasBuildRecipe } = require('../build/recipe');
11
11
  const { loadMsvcEnv } = require('../build/msvc-env');
12
12
  const { archToVsPlatform, isVisualStudioGenerator } = require('../toolchain');
13
+ const { parseCliConf } = require('../conf');
14
+ const { LOCAL_OVERLAY_FILENAME } = require('../conf');
13
15
  const install = require('./install');
14
16
  const log = require('../logger');
15
17
 
@@ -29,8 +31,10 @@ const log = require('../logger');
29
31
  * @returns {{ stale: boolean, reason: string }}
30
32
  */
31
33
  function isToolchainStale({ rootDir, manifestPath }) {
32
- const lockPath = path.join(path.dirname(manifestPath), 'wyvrn.lock');
33
- const toolchainPath = path.join(rootDir, 'wyvrn_internal', 'wyvrn_toolchain.cmake');
34
+ const lockPath = path.join(path.dirname(manifestPath), 'wyvrn.lock');
35
+ const toolchainPath = path.join(rootDir, 'wyvrn_internal', 'wyvrn_toolchain.cmake');
36
+ const localOverlayPath = path.join(rootDir, LOCAL_OVERLAY_FILENAME);
37
+ const presetPath = path.join(rootDir, 'CMakePresets.json');
34
38
 
35
39
  if (!fs.existsSync(toolchainPath)) {
36
40
  return { stale: true, reason: 'toolchain-missing' };
@@ -44,6 +48,19 @@ function isToolchainStale({ rootDir, manifestPath }) {
44
48
  if (lockMtime > toolchainMtime) {
45
49
  return { stale: true, reason: 'lock-newer-than-toolchain' };
46
50
  }
51
+
52
+ // PLAN-CONF.md §7: if wyvrn.local.json was edited after the preset was
53
+ // written, the preset's cacheVariables no longer reflect the consumer's
54
+ // intent. Re-run install (which regenerates the preset) rather than
55
+ // silently running a stale configure.
56
+ if (fs.existsSync(localOverlayPath) && fs.existsSync(presetPath)) {
57
+ const overlayMtime = fs.statSync(localOverlayPath).mtimeMs;
58
+ const presetMtime = fs.statSync(presetPath).mtimeMs;
59
+ if (overlayMtime > presetMtime) {
60
+ return { stale: true, reason: 'local-overlay-newer-than-preset' };
61
+ }
62
+ }
63
+
47
64
  return { stale: false, reason: 'fresh' };
48
65
  }
49
66
 
@@ -89,10 +106,38 @@ function resolveBinaryDir({ rootDir, presetName }) {
89
106
  * @param {{ presetName: string, generator?: string|null, vsPlatform?: string|null }} args
90
107
  * @returns {string[]}
91
108
  */
92
- function buildConfigureArgs({ presetName, generator = null, vsPlatform = null }) {
109
+ function buildConfigureArgs({
110
+ presetName,
111
+ generator = null,
112
+ vsPlatform = null,
113
+ confSets = null,
114
+ confUnsets = null,
115
+ }) {
93
116
  const args = ['--preset', presetName];
94
117
  if (generator) args.push('-G', generator);
95
118
  if (vsPlatform) args.push('-A', vsPlatform);
119
+
120
+ // PLAN-CONF.md §7: CLI --conf at build time applies as -D / -U
121
+ // overrides on top of whatever's baked into the preset. We do NOT
122
+ // silently regenerate the preset — that's a separate install.
123
+ // Confined to the cmake.cache namespace; other namespaces have
124
+ // different sinks (phase 3) and don't apply to `cmake --preset`.
125
+ if (confSets) {
126
+ const prefix = 'cmake.cache.';
127
+ for (const key of Object.keys(confSets).sort()) {
128
+ if (!key.startsWith(prefix)) continue;
129
+ const cmakeKey = key.slice(prefix.length);
130
+ args.push('-D', `${cmakeKey}=${confSets[key]}`);
131
+ }
132
+ }
133
+ if (confUnsets) {
134
+ const prefix = 'cmake.cache.';
135
+ for (const key of [...confUnsets].sort()) {
136
+ if (!key.startsWith(prefix)) continue;
137
+ args.push('-U', key.slice(prefix.length));
138
+ }
139
+ }
140
+
96
141
  return args;
97
142
  }
98
143
 
@@ -273,10 +318,26 @@ async function build(argv) {
273
318
  }
274
319
  }
275
320
 
321
+ // CLI --conf → -D / -U passthrough (PLAN-CONF.md §7). The preset's
322
+ // baked-in cacheVariables (from the last install) already reflect the
323
+ // recipe + local-overlay layers; CLI conf layers on top for this
324
+ // invocation only.
325
+ let cliConfSets, cliConfUnsets;
326
+ try {
327
+ const cli = parseCliConf(argv.conf);
328
+ cliConfSets = cli.sets;
329
+ cliConfUnsets = cli.unsets;
330
+ } catch (err) {
331
+ log.error(`--conf parse error: ${err.message}`);
332
+ process.exit(1);
333
+ }
334
+
276
335
  const configureArgs = buildConfigureArgs({
277
336
  presetName,
278
337
  generator: argv.generator,
279
338
  vsPlatform,
339
+ confSets: cliConfSets,
340
+ confUnsets: cliConfUnsets,
280
341
  });
281
342
  log.info(`cmake ${configureArgs.join(' ')}`);
282
343
  await runCmake(configureArgs, cmakeEnv);
@@ -0,0 +1,189 @@
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 };
@@ -27,8 +27,9 @@ function printSection(title, sources) {
27
27
  }
28
28
 
29
29
  function authLabel(src) {
30
- if (src.profile) return `[profile: ${src.profile}]`;
31
- if (src.token) return '[token: ***]';
30
+ if (src.profile) return `[profile: ${src.profile}]`;
31
+ if (src.tokenEnv) return `[token-env: ${src.tokenEnv}]`;
32
+ if (src.token) return '[token: ***]';
32
33
  return '';
33
34
  }
34
35
 
@@ -38,12 +39,22 @@ function authLabel(src) {
38
39
  */
39
40
  function addSource(argv) {
40
41
  const { kind, name, url, profile, token } = argv;
42
+ // yargs delivers --token-env as argv['token-env']; accept both shapes
43
+ // so programmatic callers with camelCase also work.
44
+ const tokenEnv = argv.tokenEnv ?? argv['token-env'];
45
+
46
+ if (token && tokenEnv) {
47
+ log.error('Pass --token OR --token-env, not both. --token-env is preferred for CI and shared setups (EVALUATION.md S8).');
48
+ process.exit(1);
49
+ }
50
+
41
51
  const config = readConfig();
42
52
  const key = kind === 'install' ? 'installSources' : 'publishSources';
43
53
 
44
54
  const entry = { name, url };
45
- if (profile) entry.profile = profile;
46
- if (token) entry.token = token;
55
+ if (profile) entry.profile = profile;
56
+ if (token) entry.token = token;
57
+ if (tokenEnv) entry.tokenEnv = tokenEnv;
47
58
 
48
59
  const idx = config[key].findIndex((s) => s.name === name);
49
60
  if (idx !== -1) {
@@ -3,8 +3,35 @@
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
  const { defaultManifest, writeManifest } = require('../manifest');
6
+ const { LOCAL_OVERLAY_FILENAME } = require('../conf');
6
7
  const log = require('../logger');
7
8
 
9
+ // The wyvrn.local.json overlay must never be committed — it is by design
10
+ // per-developer state. init writes a gitignore entry; existing projects
11
+ // get a one-line nudge from install (see install.js) when the file is
12
+ // present but not ignored.
13
+ const GITIGNORE_MARKER_LINE = '# wyvrnpm — per-developer conf overlay (never commit)';
14
+
15
+ function ensureGitignoreEntry(rootDir) {
16
+ const gitignorePath = path.join(rootDir, '.gitignore');
17
+ const desiredLines = [GITIGNORE_MARKER_LINE, LOCAL_OVERLAY_FILENAME];
18
+
19
+ let existing = '';
20
+ if (fs.existsSync(gitignorePath)) {
21
+ existing = fs.readFileSync(gitignorePath, 'utf8');
22
+ const hasEntry = existing.split(/\r?\n/).some((l) => l.trim() === LOCAL_OVERLAY_FILENAME);
23
+ if (hasEntry) return { action: 'already-present', path: gitignorePath };
24
+ }
25
+
26
+ const needsLeadingNewline = existing.length > 0 && !existing.endsWith('\n');
27
+ const block = (needsLeadingNewline ? '\n' : '') + desiredLines.join('\n') + '\n';
28
+ fs.writeFileSync(gitignorePath, existing + block, 'utf8');
29
+ return {
30
+ action: existing.length > 0 ? 'appended' : 'created',
31
+ path: gitignorePath,
32
+ };
33
+ }
34
+
8
35
  /**
9
36
  * Initialises a new wyvrn.json manifest in the project root.
10
37
  * If a manifest already exists the command exits early without overwriting it.
@@ -28,7 +55,16 @@ async function init(argv) {
28
55
 
29
56
  writeManifest(manifestPath, manifest);
30
57
 
58
+ const gitignore = ensureGitignoreEntry(rootDir);
59
+ if (gitignore.action === 'created') {
60
+ log.info(`Created .gitignore at ${gitignore.path} with ${LOCAL_OVERLAY_FILENAME} exclusion`);
61
+ } else if (gitignore.action === 'appended') {
62
+ log.info(`Added ${LOCAL_OVERLAY_FILENAME} to ${gitignore.path}`);
63
+ }
64
+
31
65
  log.success(`Created manifest at ${manifestPath}`);
32
66
  }
33
67
 
34
68
  module.exports = init;
69
+ module.exports.ensureGitignoreEntry = ensureGitignoreEntry;
70
+ module.exports.GITIGNORE_MARKER_LINE = GITIGNORE_MARKER_LINE;
@@ -5,6 +5,7 @@ const os = require('os');
5
5
  const path = require('path');
6
6
  const AdmZip = require('adm-zip');
7
7
 
8
+ const { assertAllSafeZipEntryNames } = require('../zip-safe');
8
9
  const log = require('../logger');
9
10
 
10
11
  // ---------------------------------------------------------------------------
@@ -72,6 +73,13 @@ function installForClaude({ force, dryRun }) {
72
73
  rmrf(destZip);
73
74
 
74
75
  const zip = new AdmZip(zipPath);
76
+ // Zip Slip defense-in-depth (EVALUATION.md S2). The shipped .skill zip
77
+ // is trusted-by-provenance, but validating consistently keeps the
78
+ // attack surface flat across all extraction call sites.
79
+ assertAllSafeZipEntryNames(
80
+ zip.getEntries().map((e) => e.entryName),
81
+ `${SKILL_NAME}.skill`,
82
+ );
75
83
  zip.extractAllTo(skillsDir, /* overwrite */ true);
76
84
  fs.copyFileSync(zipPath, destZip);
77
85