wyvrnpm 2.1.0 → 2.4.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.
@@ -3,7 +3,11 @@
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
 
6
- const { clearBuildCache, getBuildCacheRoot } = require('../build/cache');
6
+ const {
7
+ clearBuildCache,
8
+ getBuildCacheRoot,
9
+ clearUploadSidecars,
10
+ } = require('../build/cache');
7
11
  const log = require('../logger');
8
12
 
9
13
  /**
@@ -11,10 +15,17 @@ const log = require('../logger');
11
15
  * With `--build-cache`, also clears the machine-wide source-build cache
12
16
  * under `%LOCALAPPDATA%\wyvrnpm\bc\` (shared across projects).
13
17
  *
18
+ * `--uploaded-built` wipes just the consumer's local RECORD of what they
19
+ * have uploaded via `wyvrnpm install --build=missing --upload-built` (the
20
+ * `.uploaded-to.json` sidecars under the build cache). It does NOT touch
21
+ * the registry, the artefact zips, or the source clones. Privacy / local
22
+ * audit cleanup only.
23
+ *
14
24
  * @param {object} argv
15
- * @param {string} argv.root Project root directory.
16
- * @param {string} argv.manifest Path to the manifest file (used to locate wyvrn.lock).
17
- * @param {boolean} [argv.buildCache] When true, also nukes the source-build cache.
25
+ * @param {string} argv.root Project root directory.
26
+ * @param {string} argv.manifest Path to the manifest file (used to locate wyvrn.lock).
27
+ * @param {boolean} [argv.buildCache] When true, also nukes the source-build cache.
28
+ * @param {boolean} [argv.uploadedBuilt] When true, wipes upload sidecars only.
18
29
  * @returns {Promise<void>}
19
30
  */
20
31
  async function clean(argv) {
@@ -22,20 +33,27 @@ async function clean(argv) {
22
33
  const razerDir = path.join(rootDir, 'wyvrn_internal');
23
34
  const lockPath = path.join(path.dirname(path.resolve(argv.manifest)), 'wyvrn.lock');
24
35
 
36
+ // --uploaded-built is a targeted cleanup mode. When passed on its own,
37
+ // skip the default wyvrn_internal/ + wyvrn.lock scrub so a user can
38
+ // "forget uploads" without also wiping their project-local install.
39
+ const targetedOnly = argv.uploadedBuilt && !argv.buildCache;
40
+
25
41
  let removedAnything = false;
26
42
 
27
- if (fs.existsSync(razerDir)) {
28
- fs.rmSync(razerDir, { recursive: true, force: true });
29
- log.info(`Removed ${razerDir}`);
30
- removedAnything = true;
31
- } else {
32
- log.info(`Nothing to clean at ${razerDir}`);
33
- }
43
+ if (!targetedOnly) {
44
+ if (fs.existsSync(razerDir)) {
45
+ fs.rmSync(razerDir, { recursive: true, force: true });
46
+ log.info(`Removed ${razerDir}`);
47
+ removedAnything = true;
48
+ } else {
49
+ log.info(`Nothing to clean at ${razerDir}`);
50
+ }
34
51
 
35
- if (fs.existsSync(lockPath)) {
36
- fs.unlinkSync(lockPath);
37
- log.info(`Removed ${lockPath}`);
38
- removedAnything = true;
52
+ if (fs.existsSync(lockPath)) {
53
+ fs.unlinkSync(lockPath);
54
+ log.info(`Removed ${lockPath}`);
55
+ removedAnything = true;
56
+ }
39
57
  }
40
58
 
41
59
  if (argv.buildCache) {
@@ -46,6 +64,12 @@ async function clean(argv) {
46
64
  } else {
47
65
  log.info(`No source-build cache at ${getBuildCacheRoot()}`);
48
66
  }
67
+ } else if (argv.uploadedBuilt) {
68
+ // clearBuildCache() would have nuked the sidecars as a side-effect, so
69
+ // only bother with the sidecar sweep when the cache itself is surviving.
70
+ const removed = clearUploadSidecars();
71
+ log.info(`Removed ${removed} upload record${removed === 1 ? '' : 's'} from the build cache`);
72
+ if (removed > 0) removedAnything = true;
49
73
  }
50
74
 
51
75
  if (!removedAnything) {
@@ -0,0 +1,107 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+ const AdmZip = require('adm-zip');
7
+
8
+ const log = require('../logger');
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // `wyvrnpm install-skill --claude`
12
+ //
13
+ // Copies the bundled wyvrnpm Agent Skill (claude/skills/wyvrnpm.skill) into
14
+ // the user's Claude Code skills directory so Claude picks it up automatically.
15
+ //
16
+ // Works for both install shapes:
17
+ // - `npm i -g wyvrnpm` → skill zip ships under <pkg>/claude/skills/
18
+ // - `git clone` of this repo → same path, just not from node_modules
19
+ //
20
+ // Destination, per target:
21
+ // --claude → %USERPROFILE%\.claude\skills\wyvrnpm\ (Windows)
22
+ // ~/.claude/skills/wyvrnpm/ (macOS / Linux)
23
+ // ---------------------------------------------------------------------------
24
+
25
+ const SKILL_NAME = 'wyvrnpm';
26
+
27
+ function bundledSkillPath() {
28
+ // __dirname = <pkg>/src/commands; the .skill zip lives at
29
+ // <pkg>/claude/skills/wyvrnpm.skill.
30
+ return path.resolve(__dirname, '..', '..', 'claude', 'skills', `${SKILL_NAME}.skill`);
31
+ }
32
+
33
+ function claudeSkillsDir() {
34
+ const home = os.homedir();
35
+ if (!home) throw new Error('cannot resolve user home directory');
36
+ return path.join(home, '.claude', 'skills');
37
+ }
38
+
39
+ function rmrf(target) {
40
+ if (!fs.existsSync(target)) return;
41
+ fs.rmSync(target, { recursive: true, force: true });
42
+ }
43
+
44
+ function installForClaude({ force, dryRun }) {
45
+ const zipPath = bundledSkillPath();
46
+ if (!fs.existsSync(zipPath)) {
47
+ log.error(`bundled skill not found at ${zipPath}`);
48
+ log.error('this is a packaging bug — please file an issue');
49
+ process.exit(1);
50
+ }
51
+
52
+ const skillsDir = claudeSkillsDir();
53
+ const destDir = path.join(skillsDir, SKILL_NAME);
54
+ const destZip = path.join(skillsDir, `${SKILL_NAME}.skill`);
55
+
56
+ log.info(`Source : ${zipPath}`);
57
+ log.info(`Destination : ${destDir}`);
58
+
59
+ if (fs.existsSync(destDir) && !force) {
60
+ log.warn(`${destDir} already exists`);
61
+ log.warn('re-run with --force to overwrite');
62
+ process.exit(1);
63
+ }
64
+
65
+ if (dryRun) {
66
+ log.info('(--dry-run) would remove existing skill dir and zip, then extract fresh copy');
67
+ return;
68
+ }
69
+
70
+ fs.mkdirSync(skillsDir, { recursive: true });
71
+ rmrf(destDir);
72
+ rmrf(destZip);
73
+
74
+ const zip = new AdmZip(zipPath);
75
+ zip.extractAllTo(skillsDir, /* overwrite */ true);
76
+ fs.copyFileSync(zipPath, destZip);
77
+
78
+ if (!fs.existsSync(path.join(destDir, 'SKILL.md'))) {
79
+ log.error(`extraction produced no SKILL.md under ${destDir}`);
80
+ log.error('the bundled .skill zip may be malformed');
81
+ process.exit(1);
82
+ }
83
+
84
+ log.success(`Installed ${SKILL_NAME} skill to ${destDir}`);
85
+ log.info('Claude Code will pick it up on next session start.');
86
+ }
87
+
88
+ async function installSkill(argv) {
89
+ const targets = [];
90
+ if (argv.claude) targets.push('claude');
91
+
92
+ if (targets.length === 0) {
93
+ log.error('no target specified — pass --claude');
94
+ log.error('(more targets may be added in future releases)');
95
+ process.exit(1);
96
+ }
97
+
98
+ for (const target of targets) {
99
+ if (target === 'claude') {
100
+ installForClaude({ force: !!argv.force, dryRun: !!argv['dry-run'] });
101
+ }
102
+ }
103
+ }
104
+
105
+ module.exports = installSkill;
106
+ module.exports.bundledSkillPath = bundledSkillPath;
107
+ module.exports.claudeSkillsDir = claudeSkillsDir;
@@ -9,6 +9,17 @@ const { downloadDependencies } = require('../downlo
9
9
  const { readConfig } = require('../config');
10
10
  const { loadProfile, hashProfile, formatProfile, mergeProfile } = require('../profile');
11
11
  const { generateToolchain, generateCMakePresets } = require('../toolchain');
12
+ const { normalizeRecipe, hasBuildRecipe } = require('../build/recipe');
13
+ const {
14
+ normalizeOptionsDeclaration,
15
+ resolveEffectiveOptions,
16
+ parseCliOptions,
17
+ } = require('../options');
18
+ const {
19
+ resolveUploadDestination,
20
+ createUploadStats,
21
+ formatUploadSummary,
22
+ } = require('../upload-built');
12
23
  const log = require('../logger');
13
24
 
14
25
  /**
@@ -26,6 +37,23 @@ async function install(argv) {
26
37
  const manifestPath = path.resolve(argv.manifest);
27
38
  const rootDir = path.resolve(argv.root);
28
39
  const buildMode = argv.build ?? 'never';
40
+ const uploadBuilt = argv.uploadBuilt === true;
41
+ const jsonOut = argv.format === 'json';
42
+
43
+ // Under --format=json, stdout is reserved for the final JSON payload.
44
+ // All info/success lines route to stderr via the logger.
45
+ if (jsonOut) log.setJsonMode(true);
46
+
47
+ // --upload-built implies a build step happened. `--build=never` +
48
+ // `--upload-built` is a usage error — fail fast rather than silently
49
+ // accept something that cannot upload anything.
50
+ if (uploadBuilt && buildMode === 'never') {
51
+ log.error(
52
+ '--upload-built requires --build=missing (or --build=always).\n' +
53
+ ' Nothing will be built, so there is nothing to upload.',
54
+ );
55
+ process.exit(1);
56
+ }
29
57
 
30
58
  if (buildMode === 'always') {
31
59
  log.error(
@@ -95,22 +123,44 @@ async function install(argv) {
95
123
  `Installing dependencies for "${manifest.name}" (platform: ${argv.platform})`,
96
124
  );
97
125
 
126
+ // Manifests collected during resolution — reused below to read each dep's
127
+ // recipe-declared `options` block without a second HTTP round trip.
128
+ const depManifests = new Map();
129
+
98
130
  const resolvedDeps = await resolveDependencies(
99
131
  depsForResolution,
100
132
  packageSources,
101
133
  argv.platform,
102
134
  fetch,
103
135
  lockedVersions,
136
+ depManifests,
104
137
  );
105
138
 
139
+ // CLI `-o <pkg>:<name>=<value>` overrides, parsed into { pkg → {name: value} }.
140
+ const cliOptionsByPkg = parseCliOptions(argv.option);
141
+
106
142
  // ── Build per-dependency enriched map ─────────────────────────────────────
107
- // Each dep gets a profileHash based on base profile + any settings overrides
108
- // declared in wyvrn.json under that dependency's "settings" key.
143
+ // Each dep gets:
144
+ // - an effective profile (base + per-dep settings overrides)
145
+ // - effective options (recipe defaults + consumer overrides + CLI overrides)
146
+ // - profileHash computed from both (options only hashed in when declared)
109
147
  const enrichedDeps = new Map();
110
148
  for (const [name, version] of resolvedDeps) {
111
- const depOverrides = normalizedDeps[name]?.settings ?? {};
149
+ const depOverrides = normalizedDeps[name]?.settings ?? {};
112
150
  const effectiveProfile = mergeProfile(baseProfile, depOverrides);
113
- const profileHash = hashProfile(effectiveProfile);
151
+
152
+ const depManifest = depManifests.get(name);
153
+ const declaration = depManifest?.options
154
+ ? normalizeOptionsDeclaration(depManifest.options, `${name}@${version}`)
155
+ : null;
156
+ const effectiveOptions = resolveEffectiveOptions(
157
+ declaration,
158
+ normalizedDeps[name]?.options ?? {},
159
+ cliOptionsByPkg[name] ?? {},
160
+ `${name}@${version}`,
161
+ );
162
+
163
+ const profileHash = hashProfile(effectiveProfile, effectiveOptions);
114
164
 
115
165
  if (Object.keys(depOverrides).length > 0) {
116
166
  log.info(
@@ -118,8 +168,16 @@ async function install(argv) {
118
168
  ` (${JSON.stringify(depOverrides)})`,
119
169
  );
120
170
  }
171
+ if (effectiveOptions) {
172
+ log.info(`${name}: options → ${JSON.stringify(effectiveOptions)}`);
173
+ }
121
174
 
122
- enrichedDeps.set(name, { version, profileHash, profile: effectiveProfile });
175
+ enrichedDeps.set(name, {
176
+ version,
177
+ profileHash,
178
+ profile: effectiveProfile,
179
+ options: effectiveOptions,
180
+ });
123
181
  }
124
182
 
125
183
  const razerDir = path.join(rootDir, 'wyvrn_internal');
@@ -133,6 +191,40 @@ async function install(argv) {
133
191
  profileName,
134
192
  };
135
193
 
194
+ // ── Resolve upload destination up-front when --upload-built is set ──────
195
+ // Failing here (no publish source configured) avoids spending minutes on
196
+ // a source-build whose output we can't deliver.
197
+ if (uploadBuilt) {
198
+ const resolved = resolveUploadDestination(
199
+ {
200
+ uploadSource: argv.uploadSource,
201
+ awsProfile: argv.awsProfile ?? firstInstallSrc?.profile,
202
+ token: argv.token ?? firstInstallSrc?.token,
203
+ },
204
+ config,
205
+ );
206
+ if (!resolved) {
207
+ log.error(
208
+ '--upload-built was passed but no upload destination could be resolved.\n' +
209
+ ' Pass --upload-source <url-or-name> or configure a publish source:\n' +
210
+ ' wyvrnpm configure add-source --kind publish --name ... --url ...',
211
+ );
212
+ process.exit(1);
213
+ }
214
+ downloadOptions.uploadBuilt = true;
215
+ downloadOptions.uploadSource = resolved.uploadSource;
216
+ downloadOptions.uploadAuth = resolved.uploadAuth;
217
+ downloadOptions.uploadStats = createUploadStats();
218
+
219
+ if (resolved.uploadSource !== (packageSources[0] ?? '')) {
220
+ log.info(
221
+ `upload-built: install source(s) and upload source differ —\n` +
222
+ ` install: ${packageSources.join(', ') || '(none)'}\n` +
223
+ ` upload : ${resolved.uploadSource}`,
224
+ );
225
+ }
226
+ }
227
+
136
228
  const lockEntries = await downloadDependencies(
137
229
  enrichedDeps,
138
230
  packageSources,
@@ -177,11 +269,71 @@ async function install(argv) {
177
269
  const toolchainRelPath = path
178
270
  .relative(rootDir, toolchain)
179
271
  .replace(/\\/g, '/');
272
+
273
+ // Derive generator + cacheVariables from the project's own `build` recipe
274
+ // so `wyvrnpm build` locally honours the same knobs the source-build /
275
+ // publish paths use. Without this, `build.configure` is advisory metadata
276
+ // and CMake falls back to its default generator (VS on Windows) — which
277
+ // breaks publishers whose recipe disables example/test subprojects (F15).
278
+ let presetGenerator = null;
279
+ let presetCacheVariables = null;
280
+ let presetInstallDir = null;
281
+ if (hasBuildRecipe(manifest)) {
282
+ try {
283
+ // Resolve the project's own options to expand ${options.*} in its
284
+ // configure args. When the project declares no options, this is null
285
+ // and the recipe's configure / buildArgs must not contain templates
286
+ // (normalizeRecipe enforces that).
287
+ const projectDeclaration = manifest.options
288
+ ? normalizeOptionsDeclaration(manifest.options, manifest.name)
289
+ : null;
290
+ const projectCliOverrides = cliOptionsByPkg[manifest.name] ?? {};
291
+ const projectEffectiveOptions = resolveEffectiveOptions(
292
+ projectDeclaration, {}, projectCliOverrides, manifest.name,
293
+ );
294
+ const recipe = normalizeRecipe(manifest.build, projectEffectiveOptions);
295
+
296
+ if (recipe.generators.length > 0) {
297
+ // First entry in the generator fallback chain. Users whose system
298
+ // lacks the first pick can override via CMakeUserPresets.json
299
+ // (which CMake merges in automatically).
300
+ [presetGenerator] = recipe.generators;
301
+ }
302
+
303
+ // Fold -DKEY=VALUE from recipe.configure into cacheVariables. Other
304
+ // configure args (anything not matching `-DKEY=VALUE`) can't be
305
+ // expressed in a CMake preset and are silently skipped here — the
306
+ // source-build path still sees them verbatim.
307
+ const cacheVars = {};
308
+ for (const arg of recipe.configure) {
309
+ const match = arg.match(/^-D([^=]+)=(.*)$/);
310
+ if (match) cacheVars[match[1]] = match[2];
311
+ }
312
+ if (Object.keys(cacheVars).length > 0) presetCacheVariables = cacheVars;
313
+
314
+ // Propagate `build.installDir` so that `wyvrnpm build --install` lands
315
+ // under the project's build tree instead of CMake's system default
316
+ // (C:/Program Files on Windows, /usr/local on Unix). Same semantics as
317
+ // the source-build path which passes -DCMAKE_INSTALL_PREFIX explicitly.
318
+ presetInstallDir = recipe.installDir;
319
+ } catch (err) {
320
+ log.warn(
321
+ `could not derive CMake preset config from the project's build recipe: ${err.message}\n` +
322
+ ` the preset will be emitted without a generator or recipe cacheVariables. ` +
323
+ `Fix the recipe or override via CMakeUserPresets.json.`,
324
+ );
325
+ }
326
+ }
327
+
180
328
  const presetResult = generateCMakePresets({
181
329
  projectRoot: rootDir,
182
330
  profileName,
183
331
  profileHash: hashProfile(baseProfile),
184
332
  toolchainRelPath,
333
+ generator: presetGenerator,
334
+ extraCacheVariables: presetCacheVariables,
335
+ installDir: presetInstallDir,
336
+ profileArch: baseProfile.arch ?? null,
185
337
  });
186
338
  if (presetResult.action === 'skipped') {
187
339
  log.warn(
@@ -198,6 +350,51 @@ async function install(argv) {
198
350
 
199
351
  const count = resolvedDeps.size;
200
352
  log.success(`Done — ${count} package${count !== 1 ? 's' : ''} installed.`);
353
+
354
+ // ── upload-built summary ────────────────────────────────────────────────
355
+ const uploadSummary = formatUploadSummary(downloadOptions.uploadStats);
356
+ if (uploadSummary) log.info(uploadSummary);
357
+
358
+ // ── JSON payload (--format=json) ────────────────────────────────────────
359
+ // All log.info/success above have gone to stderr thanks to setJsonMode.
360
+ // stdout gets exactly one JSON object, no trailing text.
361
+ if (jsonOut) {
362
+ const packages = [...resolvedDeps.keys()].sort().map((name) => {
363
+ const entry = lockEntries?.get(name) ?? {};
364
+ const consumerSpec = normalizedDeps[name]?.version ?? null;
365
+ const resolvedVer = entry.version ?? resolvedDeps.get(name);
366
+ const versionRange = (consumerSpec && consumerSpec !== resolvedVer) ? consumerSpec : null;
367
+ return {
368
+ name,
369
+ version: resolvedVer,
370
+ versionRange,
371
+ profileHash: entry.profileHash ?? null,
372
+ options: entry.options ?? null,
373
+ contentSha256: entry.contentSha256 ?? null,
374
+ gitSha: entry.gitSha ?? null,
375
+ resolvedFrom: entry.resolvedFrom ?? null,
376
+ };
377
+ });
378
+
379
+ const stats = downloadOptions.uploadStats;
380
+ const uploadSummaryJson = stats ? {
381
+ uploaded: stats.uploaded.length,
382
+ skipped: stats.skipped.length,
383
+ failed: stats.failed.length,
384
+ } : null;
385
+
386
+ const payload = {
387
+ command: 'install',
388
+ wyvrnpmVersion: require('../../package.json').version,
389
+ profile: baseProfile,
390
+ profileName,
391
+ profileHash: hashProfile(baseProfile),
392
+ packages,
393
+ lockFile: lockPath,
394
+ uploadSummary: uploadSummaryJson,
395
+ };
396
+ process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
397
+ }
201
398
  }
202
399
 
203
400
  module.exports = install;