wyvrnpm 2.10.2 → 2.12.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +1914 -1860
  2. package/bin/{wyvrn.js → wyvrnpm.js} +66 -0
  3. package/cmake/cpp.cmake +9 -9
  4. package/cmake/functions.cmake +224 -224
  5. package/cmake/macros.cmake +284 -284
  6. package/package.json +3 -2
  7. package/src/auth.js +66 -66
  8. package/src/binary-dir.js +95 -0
  9. package/src/bootstrap/cookbook.js +196 -196
  10. package/src/bootstrap/detect.js +150 -150
  11. package/src/bootstrap/index.js +220 -220
  12. package/src/bootstrap/version.js +72 -72
  13. package/src/build/cache.js +344 -344
  14. package/src/build/clone.js +170 -170
  15. package/src/build/cmake.js +342 -342
  16. package/src/build/index.js +299 -297
  17. package/src/build/msvc-env.js +260 -260
  18. package/src/build/recipe.js +188 -188
  19. package/src/commands/add.js +141 -141
  20. package/src/commands/bootstrap.js +96 -96
  21. package/src/commands/build.js +482 -452
  22. package/src/commands/cache.js +189 -189
  23. package/src/commands/clean.js +80 -80
  24. package/src/commands/configure.js +92 -92
  25. package/src/commands/init.js +70 -70
  26. package/src/commands/install-skill.js +115 -115
  27. package/src/commands/install.js +730 -674
  28. package/src/commands/link.js +320 -320
  29. package/src/commands/profile.js +237 -237
  30. package/src/commands/publish.js +584 -555
  31. package/src/commands/show.js +252 -252
  32. package/src/commands/version.js +187 -0
  33. package/src/compat.js +273 -273
  34. package/src/conf/index.js +415 -391
  35. package/src/conf/namespaces.js +94 -94
  36. package/src/context.js +230 -230
  37. package/src/http-fetch.js +53 -53
  38. package/src/ignore.js +118 -118
  39. package/src/logger.js +122 -122
  40. package/src/options.js +303 -303
  41. package/src/settings-overrides.js +152 -152
  42. package/src/toolchain/deps.js +164 -164
  43. package/src/toolchain/index.js +212 -212
  44. package/src/toolchain/presets.js +356 -340
  45. package/src/toolchain/template.cmake +77 -77
  46. package/src/upload-built.js +265 -265
  47. package/src/version-range.js +301 -301
  48. package/src/zip-safe.js +52 -52
  49. package/src/zip-stream.js +126 -0
@@ -1,555 +1,584 @@
1
- 'use strict';
2
-
3
- const fs = require('fs');
4
- const path = require('path');
5
- const os = require('os');
6
- const AdmZip = require('adm-zip');
7
-
8
- const { getProvider } = require('../providers');
9
- const {
10
- readRecipeConf,
11
- mergeConfLayers,
12
- unflattenConf,
13
- } = require('../conf');
14
- const { hashProfile, sha256Of, getGitSha, getGitRepo, isShaOnRemote } = require('../profile');
15
- const { defaultCompatBlock } = require('../compat');
16
- const { buildContext } = require('../context');
17
- const {
18
- normalizeOptionsDeclaration,
19
- resolveEffectiveOptions,
20
- } = require('../options');
21
- const { loadIgnorePatterns, isIgnored } = require('../ignore');
22
- const { hasBuildRecipe, normalizeRecipe } = require('../build/recipe');
23
- const log = require('../logger');
24
-
25
- /**
26
- * Human-friendly byte formatter for the dry-run summary.
27
- *
28
- * @param {number} n
29
- * @returns {string}
30
- */
31
- function formatBytes(n) {
32
- if (!Number.isFinite(n) || n < 0) return `${n}`;
33
- if (n < 1024) return `${n} B`;
34
- if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
35
- if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(2)} MB`;
36
- return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`;
37
- }
38
-
39
- /**
40
- * Compute the registry URLs/paths that `v2Publish` would write to. Pure —
41
- * no network, no filesystem. Mirrors the keys produced by S3/HTTP/File
42
- * providers (§4.5 of CLAUDE.md), so dry-run can surface the exact shape
43
- * of the upload without actually performing it.
44
- *
45
- * @param {{
46
- * source: string,
47
- * name: string,
48
- * version: string,
49
- * profileHash: string,
50
- * gitSha?: string | null,
51
- * gitRepo?: string | null,
52
- * updateLatest?: boolean,
53
- * }} opts
54
- * @returns {Array<{ path: string, kind: 'wyvrn.zip'|'wyvrn.json'|'source.json'|'versions.json'|'latest.json' }>}
55
- */
56
- function planPublishUrls({
57
- source, name, version, profileHash,
58
- gitSha = null, gitRepo = null, updateLatest = true,
59
- }) {
60
- const base = source.replace(/\/$/, '');
61
- const v2root = `${base}/v2/${name}`;
62
- const buildRoot = `${v2root}/${version}/${profileHash}`;
63
-
64
- const plan = [
65
- { path: `${buildRoot}/wyvrn.zip`, kind: 'wyvrn.zip' },
66
- { path: `${buildRoot}/wyvrn.json`, kind: 'wyvrn.json' },
67
- ];
68
- if (gitSha || gitRepo) {
69
- plan.push({ path: `${v2root}/${version}/source.json`, kind: 'source.json' });
70
- }
71
- plan.push({ path: `${v2root}/versions.json`, kind: 'versions.json' });
72
- if (updateLatest) {
73
- plan.push({ path: `${v2root}/latest.json`, kind: 'latest.json' });
74
- }
75
- return plan;
76
- }
77
-
78
- /**
79
- * Walk `dir` recursively and add every file to `zip`, skipping anything that
80
- * matches `.wyvrnignore` patterns OR whose zip-relative path is already in
81
- * `reservedRelPaths`. The reserved set lets the install-tree overlay win
82
- * on path collisions (e.g. `include/foo.h` shipped by both source and
83
- * install trees).
84
- */
85
- function addFolderFiltered(zip, dir, base, patterns, reservedRelPaths = new Set()) {
86
- for (const entry of fs.readdirSync(dir)) {
87
- const fullPath = path.join(dir, entry);
88
- const relPath = path.relative(base, fullPath).replace(/\\/g, '/');
89
- const stat = fs.statSync(fullPath);
90
- const isDir = stat.isDirectory();
91
- if (isIgnored(relPath, isDir, patterns)) {
92
- log.info(` Ignoring: ${relPath}${isDir ? '/' : ''}`);
93
- continue;
94
- }
95
- if (isDir) {
96
- addFolderFiltered(zip, fullPath, base, patterns, reservedRelPaths);
97
- } else if (reservedRelPaths.has(relPath)) {
98
- // Install tree has a canonical version of this file; let that win.
99
- log.info(` Install-tree wins: ${relPath}`);
100
- } else {
101
- const zipDir = path.dirname(relPath).replace(/\\/g, '/');
102
- zip.addLocalFile(fullPath, zipDir === '.' ? '' : zipDir);
103
- }
104
- }
105
- }
106
-
107
- /**
108
- * Collect every file under `dir` as a `{ fullPath, relPath }` pair. `relPath`
109
- * is forward-slash-separated and relative to `dir` — so the install tree's
110
- * `install/cmake/FooConfig.cmake` lands as `cmake/FooConfig.cmake` at the
111
- * zip root, which is where `find_package(Foo CONFIG)` expects it on the
112
- * consumer side after extraction into `wyvrn_internal/<name>/`.
113
- */
114
- function collectInstallTreeFiles(dir) {
115
- const files = [];
116
- function walk(current) {
117
- for (const entry of fs.readdirSync(current)) {
118
- const full = path.join(current, entry);
119
- const stat = fs.statSync(full);
120
- if (stat.isDirectory()) {
121
- walk(full);
122
- } else {
123
- const rel = path.relative(dir, full).replace(/\\/g, '/');
124
- files.push({ fullPath: full, relPath: rel });
125
- }
126
- }
127
- }
128
- walk(dir);
129
- return files;
130
- }
131
-
132
- function addInstallTree(zip, installDir) {
133
- const files = collectInstallTreeFiles(installDir);
134
- const added = new Set();
135
- for (const { fullPath, relPath } of files) {
136
- const zipDir = path.dirname(relPath).replace(/\\/g, '/');
137
- zip.addLocalFile(fullPath, zipDir === '.' ? '' : zipDir);
138
- added.add(relPath);
139
- }
140
- return added;
141
- }
142
-
143
- // ---------------------------------------------------------------------------
144
- // Source resolution
145
- // ---------------------------------------------------------------------------
146
-
147
- function resolveSource(argv, ctx) {
148
- let { source, awsProfile } = argv;
149
- const { config } = ctx;
150
- let sourceEntry = null;
151
-
152
- if (source) {
153
- const named = config.publishSources.find((s) => s.name === source);
154
- if (named) {
155
- log.info(`Using publish source "${named.name}" from config`);
156
- source = named.url;
157
- awsProfile = awsProfile ?? named.profile;
158
- sourceEntry = named;
159
- }
160
- } else {
161
- if (config.publishSources.length === 0) {
162
- log.error(
163
- '--source is required (or configure one with: ' +
164
- 'wyvrnpm configure add-source --kind publish ...)',
165
- );
166
- process.exit(1);
167
- }
168
- const first = config.publishSources[0];
169
- log.info(`Using publish source "${first.name}" from config`);
170
- source = first.url;
171
- awsProfile = awsProfile ?? first.profile;
172
- sourceEntry = first;
173
- }
174
-
175
- // Auth flows through ctx.auth.for — same literal > --token-env >
176
- // config.token > config.tokenEnv precedence, centralised once in
177
- // buildContext.
178
- const { token } = ctx.auth.for(sourceEntry);
179
-
180
- return { source, awsProfile, token };
181
- }
182
-
183
- // ---------------------------------------------------------------------------
184
- // Main publish command
185
- // ---------------------------------------------------------------------------
186
-
187
- async function publish(argv) {
188
- // Shared per-invocation state (config, profile, manifest lazy, auth,
189
- // CLI options/conf parsing, JSON-mode toggle). See src/context.js +
190
- // claude/PLAN-COMMAND-CONTEXT.md. Also throws on malformed --option /
191
- // --conf so bad inputs surface before we hit the registry.
192
- let ctx;
193
- try {
194
- ctx = buildContext(argv);
195
- } catch (err) {
196
- log.error(err.message);
197
- process.exit(1);
198
- }
199
-
200
- const { source, awsProfile, token } = resolveSource(argv, ctx);
201
- const { path: publishPath, force } = argv;
202
- const dryRun = Boolean(argv.dryRun);
203
- const jsonOut = ctx.flags.format === 'json';
204
- if (dryRun) {
205
- log.info('DRY RUN no upload will occur. Previewing publish plan only.');
206
- }
207
-
208
- // ── Load manifest ─────────────────────────────────────────────────────────
209
- // ctx.manifest is a lazy getter — throws on first access if the file is
210
- // missing or malformed, which is exactly what we want here.
211
- const { manifestPath } = ctx;
212
- let manifest;
213
- try {
214
- manifest = ctx.manifest;
215
- } catch (err) {
216
- log.error(err.message);
217
- process.exit(1);
218
- }
219
-
220
- const { name, version } = manifest;
221
- if (!name || !version) {
222
- log.error('Manifest must have "name" and "version" fields');
223
- process.exit(1);
224
- }
225
-
226
- const srcDir = path.resolve(publishPath || '.');
227
- if (!fs.existsSync(srcDir)) {
228
- log.error(`Publish path does not exist: ${srcDir}`);
229
- process.exit(1);
230
- }
231
-
232
- // ── Resolve provider ──────────────────────────────────────────────────────
233
- let provider;
234
- try {
235
- provider = getProvider(source);
236
- } catch (err) {
237
- log.error(err.message);
238
- process.exit(1);
239
- }
240
-
241
- // ── Load build profile ────────────────────────────────────────────────────
242
- // Resolved once in buildContext(). argv.profile → config.defaultProfile
243
- // "default" fallback is applied there. `--profile` to publish still
244
- // works identically it just flows through ctx.
245
- const {
246
- name: activeProfileName,
247
- profile: buildProfile,
248
- fromFile,
249
- } = ctx.profiles.host;
250
-
251
- // ── Resolve this package's own declared options ─────────────────────────
252
- // Publishing needs to commit to a specific value for every option the
253
- // recipe declares. Sources for overrides: CLI `-o <pkg>:...` flags
254
- // (scoped to the package being published), else the recipe's defaults.
255
- const ownDeclaration = manifest.options
256
- ? normalizeOptionsDeclaration(manifest.options, `${name}@${version}`)
257
- : null;
258
- const cliOptionsByPkg = ctx.cliOptions;
259
- const effectiveOptions = resolveEffectiveOptions(
260
- ownDeclaration,
261
- {}, // consumer-side overrides don't apply to self-publish
262
- cliOptionsByPkg[name] ?? {},
263
- `${name}@${version}`,
264
- );
265
-
266
- const profileHash = hashProfile(buildProfile, effectiveOptions);
267
-
268
- log.info(
269
- `Publishing ${name}@${version}\n` +
270
- `Profile : "${activeProfileName}"${fromFile ? '' : ' (auto-detected — save with `wyvrnpm configure profile detect`)'}\n` +
271
- ` ${buildProfile.os}/${buildProfile.arch} ` +
272
- `${buildProfile.compiler} ${buildProfile['compiler.version']} ` +
273
- `C++${buildProfile['compiler.cppstd']}` +
274
- (buildProfile['compiler.runtime'] ? ` [${buildProfile['compiler.runtime']}]` : '') + '\n' +
275
- (effectiveOptions ? `Options : ${JSON.stringify(effectiveOptions)}\n` : '') +
276
- `Profile hash: ${profileHash}\n` +
277
- `Provider : ${provider.constructor.providerName}`,
278
- );
279
-
280
- // ── Check v2 existence ────────────────────────────────────────────────────
281
- // Dry-run softens the overwrite-without-force error to a warning so the
282
- // full plan still prints — CI callers should parse `--format json` and
283
- // gate on `wouldOverwrite` rather than on the exit code.
284
- const v2AlreadyExists = await provider.v2Exists({ source, name, version, profileHash, awsProfile, token });
285
- if (v2AlreadyExists) {
286
- if (force) {
287
- log.warn(`${name}@${version} [${profileHash}] already exists — overwriting (--force)`);
288
- } else if (dryRun) {
289
- log.warn(
290
- `${name}@${version} [${profileHash}] already exists on the registry. ` +
291
- `A real publish would refuse without --force.`,
292
- );
293
- } else {
294
- log.error(
295
- `${name}@${version} [${profileHash}] already exists.\n` +
296
- ' Bump the version, use a different profile, or pass --force to overwrite.',
297
- );
298
- process.exit(1);
299
- }
300
- }
301
-
302
- // ── Read the author's lockfile snapshot for `publishedLock` audit ────────
303
- // Dependencies in wyvrn.json are published verbatim (ranges stay as ranges).
304
- // The exact versions from wyvrn.lock are attached as `publishedLock` metadata
305
- // on the uploaded manifest so future forensics can tell what the author
306
- // actually tested against without constraining consumer resolution.
307
- const lockPath = path.join(path.dirname(manifestPath), 'wyvrn.lock');
308
- const publishedLock = {};
309
- if (fs.existsSync(lockPath)) {
310
- try {
311
- const lock = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
312
- for (const [pkgName, entry] of Object.entries(lock.packages ?? {})) {
313
- const ver = typeof entry === 'string' ? entry : entry.version;
314
- if (ver) publishedLock[pkgName] = ver;
315
- }
316
- log.info(`Lock snapshot: ${Object.keys(publishedLock).length} package(s) recorded in publishedLock`);
317
- } catch {
318
- log.warn('could not read wyvrn.lock — publishedLock audit block will be empty');
319
- }
320
- } else {
321
- log.warn('no wyvrn.lock found — run `wyvrnpm install` before publishing so publishedLock records tested versions');
322
- }
323
-
324
- // ── Git metadata ──────────────────────────────────────────────────────────
325
- const gitSha = getGitSha(srcDir);
326
- const gitRepo = getGitRepo(srcDir);
327
- if (gitSha) log.info(`Git commit : ${gitSha}`);
328
- if (gitRepo) log.info(`Git remote : ${gitRepo}`);
329
-
330
- // Warn (but don't block) if HEAD isn't reachable from origin. Downstream
331
- // `--build=missing` consumers will fail to fetch this commit — the fix
332
- // is almost always `git push` before publishing.
333
- if (gitSha && gitRepo && !isShaOnRemote(gitSha, srcDir)) {
334
- log.warn(
335
- `commit ${gitSha.slice(0, 12)} is not reachable from any remote ` +
336
- `branch or tag on origin.\n` +
337
- ` Consumers building this package from source will fail to retrieve it.\n` +
338
- ` Fix: push your branch (or tag the commit) before publishing.`,
339
- );
340
- }
341
-
342
- // ── .wyvrnignore ──────────────────────────────────────────────────────────
343
- const ignoreFile = path.join(srcDir, '.wyvrnignore');
344
- const ignorePatterns = loadIgnorePatterns(ignoreFile);
345
- if (ignorePatterns.length > 0) {
346
- log.info(`Loaded ${ignorePatterns.length} pattern(s) from .wyvrnignore`);
347
- }
348
-
349
- // ── Build zip ─────────────────────────────────────────────────────────────
350
- const tmpZipPath = path.join(os.tmpdir(), `wyvrnpm-${name}-${version}-${Date.now()}.zip`);
351
-
352
- // ── Inject default `compatibility` block if the source manifest lacks one ──
353
- // Written to a temp file so the source wyvrn.json is never modified.
354
- // A published manifest must always carry a compat block so install-time
355
- // resolution has something to evaluate (default = all fields `exact`,
356
- // identical semantics to pre-phase-2 strict behaviour).
357
- const tmpManifestPath = path.join(
358
- os.tmpdir(),
359
- `wyvrnpm-manifest-${name}-${version}-${Date.now()}.json`,
360
- );
361
- // ── publishedConf (PLAN-CONF.md §4.6) ────────────────────────────────────
362
- // Publish reads ONLY the recipe conf + CLI --conf — wyvrn.local.json is
363
- // intentionally ignored (plan §4.5 / §8.4) so dev-local overlays never
364
- // leak into shared artefacts. Recorded on the published manifest as
365
- // informational metadata: it tells maintainers what the publisher's
366
- // CMake cache vars were at build time but does NOT drive resolution on
367
- // the consumer side.
368
- let publishedConf = {};
369
- try {
370
- const recipeConfFlat = readRecipeConf(manifest);
371
- // ctx.cliConf is pre-parsed by buildContext. Malformed --conf would
372
- // have thrown up there before we reached this block.
373
- publishedConf = mergeConfLayers(recipeConfFlat, {}, ctx.cliConf);
374
- } catch (err) {
375
- log.error(`conf resolution failed: ${err.message}`);
376
- process.exit(1);
377
- }
378
- if (Object.keys(publishedConf).length > 0) {
379
- log.info(`Published conf: ${JSON.stringify(publishedConf)}`);
380
- }
381
-
382
- const manifestForPublish = {
383
- ...manifest,
384
- compatibility: manifest.compatibility ?? defaultCompatBlock(),
385
- ...(Object.keys(publishedConf).length > 0
386
- ? { publishedConf: unflattenConf(publishedConf) }
387
- : {}),
388
- };
389
- fs.writeFileSync(tmpManifestPath, JSON.stringify(manifestForPublish, null, 2), 'utf8');
390
- if (!manifest.compatibility) {
391
- log.info('Injecting default `compatibility` block (all fields `exact`) — override by adding one to wyvrn.json');
392
- } else {
393
- log.info(`Using package-declared compatibility: ${JSON.stringify(manifest.compatibility)}`);
394
- }
395
-
396
- // ── Install-tree overlay ──────────────────────────────────────────────────
397
- // Publish zips the source tree (filtered by .wyvrnignore), but consumers
398
- // actually need the install tree `lib/cmake/<Name>/<Name>Config.cmake`,
399
- // the installed headers, compiled libs so find_package() works after
400
- // extraction. When a build recipe is declared, locate the per-profile
401
- // install dir (populated by `wyvrnpm build --config <cfg> --install`) and
402
- // overlay its contents on top of the source zip. Install-tree files win
403
- // on path collisions (e.g. `include/`).
404
- let installOverlayDir = null;
405
- let installOverlayPaths = new Set();
406
- if (hasBuildRecipe(manifest)) {
407
- try {
408
- const recipe = normalizeRecipe(manifest.build, effectiveOptions, manifest);
409
- const candidate = path.join(
410
- srcDir, 'build', `wyvrn-${activeProfileName}`, recipe.installDir,
411
- );
412
- if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) {
413
- installOverlayDir = candidate;
414
- } else {
415
- log.warn(
416
- `build recipe is declared but no install tree found at ${candidate}.\n` +
417
- ' Consumers\' find_package() will likely fail. Fix: run\n' +
418
- ' wyvrnpm build --config Debug --install\n' +
419
- ' wyvrnpm build --config Release --install\n' +
420
- ' wyvrnpm build --config RelWithDebInfo --install\n' +
421
- ' wyvrnpm build --config MinSizeRel --install\n' +
422
- ' then re-run publish.',
423
- );
424
- }
425
- } catch (err) {
426
- log.warn(`could not locate install tree: ${err.message} — publishing source tree only`);
427
- }
428
- }
429
-
430
- try {
431
- log.info(`Zipping ${srcDir} ...`);
432
- const zip = new AdmZip();
433
- if (installOverlayDir) {
434
- // Collect install-tree paths first so the source-tree walker can skip
435
- // anything the install tree will add canonically.
436
- const peek = collectInstallTreeFiles(installOverlayDir);
437
- installOverlayPaths = new Set(peek.map((f) => f.relPath));
438
- }
439
- addFolderFiltered(zip, srcDir, srcDir, ignorePatterns, installOverlayPaths);
440
- if (installOverlayDir) {
441
- log.info(`Overlaying install tree: ${installOverlayDir}`);
442
- const added = addInstallTree(zip, installOverlayDir);
443
- log.info(` Added ${added.size} file(s) from install tree`);
444
- }
445
- zip.writeZip(tmpZipPath);
446
-
447
- const contentSha256 = sha256Of(tmpZipPath);
448
- const zipBytes = fs.statSync(tmpZipPath).size;
449
- const manifestBytes = fs.statSync(tmpManifestPath).size;
450
- log.info(`Content SHA256 : ${contentSha256}`);
451
- log.info(`Zip size : ${formatBytes(zipBytes)} (${zipBytes} bytes)`);
452
-
453
- // ── Dry run: print the plan, skip the actual upload ──────────────────
454
- // Mirrors `npm publish --dry-run` every step above (profile, options,
455
- // zip, hash, v2Exists) runs; only the provider write is skipped.
456
- if (dryRun) {
457
- const plan = planPublishUrls({
458
- source, name, version, profileHash, gitSha, gitRepo, updateLatest: true,
459
- });
460
-
461
- if (!jsonOut) {
462
- console.log();
463
- console.log(`Would publish ${name}@${version} [${profileHash}] to ${source}`);
464
- console.log(` contentSha256 : ${contentSha256}`);
465
- console.log(` zip size : ${formatBytes(zipBytes)}`);
466
- console.log(` manifest size : ${formatBytes(manifestBytes)}`);
467
- if (v2AlreadyExists) {
468
- console.log(` overwrite : YES — ${force ? '--force set, would proceed' : 'NOT SET, real publish would refuse'}`);
469
- }
470
- console.log();
471
- console.log('Registry writes:');
472
- for (const { path: p, kind } of plan) {
473
- console.log(` → ${p} (${kind})`);
474
- }
475
- console.log();
476
- log.success('Dry run OK — nothing uploaded.');
477
- } else {
478
- const payload = {
479
- command: 'publish',
480
- dryRun: true,
481
- wyvrnpmVersion: require('../../package.json').version,
482
- name,
483
- version,
484
- profileHash,
485
- source,
486
- contentSha256,
487
- zipBytes,
488
- manifestBytes,
489
- options: effectiveOptions ?? null,
490
- gitSha: gitSha ?? null,
491
- gitRepo: gitRepo ?? null,
492
- wouldOverwrite: Boolean(v2AlreadyExists),
493
- forceSet: Boolean(force),
494
- publishedLock,
495
- publishedConf: Object.keys(publishedConf).length > 0 ? unflattenConf(publishedConf) : null,
496
- plan,
497
- };
498
- process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
499
- }
500
- return;
501
- }
502
-
503
- // ── v2 publish ────────────────────────────────────────────────────────
504
- log.info('Publishing (v2) ...');
505
- await provider.v2Publish(
506
- { manifest: tmpManifestPath, zip: tmpZipPath },
507
- {
508
- source,
509
- name,
510
- version,
511
- profileHash,
512
- // buildSettings stored in metadata for debugging — NOT wyvrn.json
513
- buildSettings: effectiveOptions
514
- ? { ...buildProfile, options: effectiveOptions }
515
- : buildProfile,
516
- contentSha256,
517
- gitSha,
518
- gitRepo,
519
- publishedLock,
520
- awsProfile,
521
- token,
522
- },
523
- );
524
-
525
- log.success(`Successfully published ${name}@${version} [${profileHash}] to ${source}`);
526
-
527
- if (jsonOut) {
528
- const payload = {
529
- command: 'publish',
530
- wyvrnpmVersion: require('../../package.json').version,
531
- name,
532
- version,
533
- profileHash,
534
- source,
535
- contentSha256,
536
- options: effectiveOptions ?? null,
537
- gitSha: gitSha ?? null,
538
- gitRepo: gitRepo ?? null,
539
- publishedAt: new Date().toISOString(),
540
- };
541
- process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
542
- }
543
- } finally {
544
- if (fs.existsSync(tmpZipPath)) fs.unlinkSync(tmpZipPath);
545
- if (fs.existsSync(tmpManifestPath)) fs.unlinkSync(tmpManifestPath);
546
- }
547
- }
548
-
549
- module.exports = publish;
550
- // Exported for tests only
551
- module.exports.addFolderFiltered = addFolderFiltered;
552
- module.exports.addInstallTree = addInstallTree;
553
- module.exports.collectInstallTreeFiles = collectInstallTreeFiles;
554
- module.exports.planPublishUrls = planPublishUrls;
555
- module.exports.formatBytes = formatBytes;
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+
7
+ const { StreamingZipWriter } = require('../zip-stream');
8
+ const { getProvider } = require('../providers');
9
+ const {
10
+ readRecipeConf,
11
+ mergeConfLayers,
12
+ unflattenConf,
13
+ readLocalOverlay,
14
+ } = require('../conf');
15
+ const { resolveBinaryDirRoot } = require('../binary-dir');
16
+ const { hashProfile, sha256Of, getGitSha, getGitRepo, isShaOnRemote } = require('../profile');
17
+ const { defaultCompatBlock } = require('../compat');
18
+ const { buildContext } = require('../context');
19
+ const {
20
+ normalizeOptionsDeclaration,
21
+ resolveEffectiveOptions,
22
+ } = require('../options');
23
+ const { loadIgnorePatterns, isIgnored } = require('../ignore');
24
+ const { hasBuildRecipe, normalizeRecipe } = require('../build/recipe');
25
+ const log = require('../logger');
26
+
27
+ /**
28
+ * Human-friendly byte formatter for the dry-run summary.
29
+ *
30
+ * @param {number} n
31
+ * @returns {string}
32
+ */
33
+ function formatBytes(n) {
34
+ if (!Number.isFinite(n) || n < 0) return `${n}`;
35
+ if (n < 1024) return `${n} B`;
36
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
37
+ if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(2)} MB`;
38
+ return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`;
39
+ }
40
+
41
+ /**
42
+ * Compute the registry URLs/paths that `v2Publish` would write to. Pure
43
+ * no network, no filesystem. Mirrors the keys produced by S3/HTTP/File
44
+ * providers (§4.5 of CLAUDE.md), so dry-run can surface the exact shape
45
+ * of the upload without actually performing it.
46
+ *
47
+ * @param {{
48
+ * source: string,
49
+ * name: string,
50
+ * version: string,
51
+ * profileHash: string,
52
+ * gitSha?: string | null,
53
+ * gitRepo?: string | null,
54
+ * updateLatest?: boolean,
55
+ * }} opts
56
+ * @returns {Array<{ path: string, kind: 'wyvrn.zip'|'wyvrn.json'|'source.json'|'versions.json'|'latest.json' }>}
57
+ */
58
+ function planPublishUrls({
59
+ source, name, version, profileHash,
60
+ gitSha = null, gitRepo = null, updateLatest = true,
61
+ }) {
62
+ const base = source.replace(/\/$/, '');
63
+ const v2root = `${base}/v2/${name}`;
64
+ const buildRoot = `${v2root}/${version}/${profileHash}`;
65
+
66
+ const plan = [
67
+ { path: `${buildRoot}/wyvrn.zip`, kind: 'wyvrn.zip' },
68
+ { path: `${buildRoot}/wyvrn.json`, kind: 'wyvrn.json' },
69
+ ];
70
+ if (gitSha || gitRepo) {
71
+ plan.push({ path: `${v2root}/${version}/source.json`, kind: 'source.json' });
72
+ }
73
+ plan.push({ path: `${v2root}/versions.json`, kind: 'versions.json' });
74
+ if (updateLatest) {
75
+ plan.push({ path: `${v2root}/latest.json`, kind: 'latest.json' });
76
+ }
77
+ return plan;
78
+ }
79
+
80
+ /**
81
+ * Walk `dir` recursively and add every file to `zip`, skipping anything that
82
+ * matches `.wyvrnignore` patterns OR whose zip-relative path is already in
83
+ * `reservedRelPaths`. The reserved set lets the install-tree overlay win
84
+ * on path collisions (e.g. `include/foo.h` shipped by both source and
85
+ * install trees).
86
+ */
87
+ function addFolderFiltered(zip, dir, base, patterns, reservedRelPaths = new Set()) {
88
+ for (const entry of fs.readdirSync(dir)) {
89
+ const fullPath = path.join(dir, entry);
90
+ const relPath = path.relative(base, fullPath).replace(/\\/g, '/');
91
+ const stat = fs.statSync(fullPath);
92
+ const isDir = stat.isDirectory();
93
+ if (isIgnored(relPath, isDir, patterns)) {
94
+ log.info(` Ignoring: ${relPath}${isDir ? '/' : ''}`);
95
+ continue;
96
+ }
97
+ if (isDir) {
98
+ addFolderFiltered(zip, fullPath, base, patterns, reservedRelPaths);
99
+ } else if (reservedRelPaths.has(relPath)) {
100
+ // Install tree has a canonical version of this file; let that win.
101
+ log.info(` Install-tree wins: ${relPath}`);
102
+ } else {
103
+ const zipDir = path.dirname(relPath).replace(/\\/g, '/');
104
+ zip.addLocalFile(fullPath, zipDir === '.' ? '' : zipDir);
105
+ }
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Collect every file under `dir` as a `{ fullPath, relPath }` pair. `relPath`
111
+ * is forward-slash-separated and relative to `dir` so the install tree's
112
+ * `install/cmake/FooConfig.cmake` lands as `cmake/FooConfig.cmake` at the
113
+ * zip root, which is where `find_package(Foo CONFIG)` expects it on the
114
+ * consumer side after extraction into `wyvrn_internal/<name>/`.
115
+ */
116
+ function collectInstallTreeFiles(dir) {
117
+ const files = [];
118
+ function walk(current) {
119
+ for (const entry of fs.readdirSync(current)) {
120
+ const full = path.join(current, entry);
121
+ const stat = fs.statSync(full);
122
+ if (stat.isDirectory()) {
123
+ walk(full);
124
+ } else {
125
+ const rel = path.relative(dir, full).replace(/\\/g, '/');
126
+ files.push({ fullPath: full, relPath: rel });
127
+ }
128
+ }
129
+ }
130
+ walk(dir);
131
+ return files;
132
+ }
133
+
134
+ function addInstallTree(zip, installDir) {
135
+ const files = collectInstallTreeFiles(installDir);
136
+ const added = new Set();
137
+ for (const { fullPath, relPath } of files) {
138
+ const zipDir = path.dirname(relPath).replace(/\\/g, '/');
139
+ zip.addLocalFile(fullPath, zipDir === '.' ? '' : zipDir);
140
+ added.add(relPath);
141
+ }
142
+ return added;
143
+ }
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // Source resolution
147
+ // ---------------------------------------------------------------------------
148
+
149
+ function resolveSource(argv, ctx) {
150
+ let { source, awsProfile } = argv;
151
+ const { config } = ctx;
152
+ let sourceEntry = null;
153
+
154
+ if (source) {
155
+ const named = config.publishSources.find((s) => s.name === source);
156
+ if (named) {
157
+ log.info(`Using publish source "${named.name}" from config`);
158
+ source = named.url;
159
+ awsProfile = awsProfile ?? named.profile;
160
+ sourceEntry = named;
161
+ }
162
+ } else {
163
+ if (config.publishSources.length === 0) {
164
+ log.error(
165
+ '--source is required (or configure one with: ' +
166
+ 'wyvrnpm configure add-source --kind publish ...)',
167
+ );
168
+ process.exit(1);
169
+ }
170
+ const first = config.publishSources[0];
171
+ log.info(`Using publish source "${first.name}" from config`);
172
+ source = first.url;
173
+ awsProfile = awsProfile ?? first.profile;
174
+ sourceEntry = first;
175
+ }
176
+
177
+ // Auth flows through ctx.auth.for — same literal > --token-env >
178
+ // config.token > config.tokenEnv precedence, centralised once in
179
+ // buildContext.
180
+ const { token } = ctx.auth.for(sourceEntry);
181
+
182
+ return { source, awsProfile, token };
183
+ }
184
+
185
+ // ---------------------------------------------------------------------------
186
+ // Main publish command
187
+ // ---------------------------------------------------------------------------
188
+
189
+ async function publish(argv) {
190
+ // Shared per-invocation state (config, profile, manifest lazy, auth,
191
+ // CLI options/conf parsing, JSON-mode toggle). See src/context.js +
192
+ // claude/PLAN-COMMAND-CONTEXT.md. Also throws on malformed --option /
193
+ // --conf so bad inputs surface before we hit the registry.
194
+ let ctx;
195
+ try {
196
+ ctx = buildContext(argv);
197
+ } catch (err) {
198
+ log.error(err.message);
199
+ process.exit(1);
200
+ }
201
+
202
+ const { source, awsProfile, token } = resolveSource(argv, ctx);
203
+ const { path: publishPath, force } = argv;
204
+ const dryRun = Boolean(argv.dryRun);
205
+ const jsonOut = ctx.flags.format === 'json';
206
+ if (dryRun) {
207
+ log.info('DRY RUN — no upload will occur. Previewing publish plan only.');
208
+ }
209
+
210
+ // ── Load manifest ─────────────────────────────────────────────────────────
211
+ // ctx.manifest is a lazy getter — throws on first access if the file is
212
+ // missing or malformed, which is exactly what we want here.
213
+ const { manifestPath } = ctx;
214
+ let manifest;
215
+ try {
216
+ manifest = ctx.manifest;
217
+ } catch (err) {
218
+ log.error(err.message);
219
+ process.exit(1);
220
+ }
221
+
222
+ const { name, version } = manifest;
223
+ if (!name || !version) {
224
+ log.error('Manifest must have "name" and "version" fields');
225
+ process.exit(1);
226
+ }
227
+
228
+ const srcDir = path.resolve(publishPath || '.');
229
+ if (!fs.existsSync(srcDir)) {
230
+ log.error(`Publish path does not exist: ${srcDir}`);
231
+ process.exit(1);
232
+ }
233
+
234
+ // ── Resolve provider ──────────────────────────────────────────────────────
235
+ let provider;
236
+ try {
237
+ provider = getProvider(source);
238
+ } catch (err) {
239
+ log.error(err.message);
240
+ process.exit(1);
241
+ }
242
+
243
+ // ── Load build profile ────────────────────────────────────────────────────
244
+ // Resolved once in buildContext(). argv.profile config.defaultProfile
245
+ // → "default" fallback is applied there. `--profile` to publish still
246
+ // works identically — it just flows through ctx.
247
+ const {
248
+ name: activeProfileName,
249
+ profile: buildProfile,
250
+ fromFile,
251
+ } = ctx.profiles.host;
252
+
253
+ // ── Resolve this package's own declared options ─────────────────────────
254
+ // Publishing needs to commit to a specific value for every option the
255
+ // recipe declares. Sources for overrides: CLI `-o <pkg>:...` flags
256
+ // (scoped to the package being published), else the recipe's defaults.
257
+ const ownDeclaration = manifest.options
258
+ ? normalizeOptionsDeclaration(manifest.options, `${name}@${version}`)
259
+ : null;
260
+ const cliOptionsByPkg = ctx.cliOptions;
261
+ const effectiveOptions = resolveEffectiveOptions(
262
+ ownDeclaration,
263
+ {}, // consumer-side overrides don't apply to self-publish
264
+ cliOptionsByPkg[name] ?? {},
265
+ `${name}@${version}`,
266
+ );
267
+
268
+ const profileHash = hashProfile(buildProfile, effectiveOptions);
269
+
270
+ log.info(
271
+ `Publishing ${name}@${version}\n` +
272
+ `Profile : "${activeProfileName}"${fromFile ? '' : ' (auto-detected — save with `wyvrnpm configure profile detect`)'}\n` +
273
+ ` ${buildProfile.os}/${buildProfile.arch} ` +
274
+ `${buildProfile.compiler} ${buildProfile['compiler.version']} ` +
275
+ `C++${buildProfile['compiler.cppstd']}` +
276
+ (buildProfile['compiler.runtime'] ? ` [${buildProfile['compiler.runtime']}]` : '') + '\n' +
277
+ (effectiveOptions ? `Options : ${JSON.stringify(effectiveOptions)}\n` : '') +
278
+ `Profile hash: ${profileHash}\n` +
279
+ `Provider : ${provider.constructor.providerName}`,
280
+ );
281
+
282
+ // ── Check v2 existence ────────────────────────────────────────────────────
283
+ // Dry-run softens the overwrite-without-force error to a warning so the
284
+ // full plan still prints CI callers should parse `--format json` and
285
+ // gate on `wouldOverwrite` rather than on the exit code.
286
+ const v2AlreadyExists = await provider.v2Exists({ source, name, version, profileHash, awsProfile, token });
287
+ if (v2AlreadyExists) {
288
+ if (force) {
289
+ log.warn(`${name}@${version} [${profileHash}] already exists — overwriting (--force)`);
290
+ } else if (dryRun) {
291
+ log.warn(
292
+ `${name}@${version} [${profileHash}] already exists on the registry. ` +
293
+ `A real publish would refuse without --force.`,
294
+ );
295
+ } else {
296
+ log.error(
297
+ `${name}@${version} [${profileHash}] already exists.\n` +
298
+ ' Bump the version, use a different profile, or pass --force to overwrite.',
299
+ );
300
+ process.exit(1);
301
+ }
302
+ }
303
+
304
+ // ── Read the author's lockfile snapshot for `publishedLock` audit ────────
305
+ // Dependencies in wyvrn.json are published verbatim (ranges stay as ranges).
306
+ // The exact versions from wyvrn.lock are attached as `publishedLock` metadata
307
+ // on the uploaded manifest so future forensics can tell what the author
308
+ // actually tested against — without constraining consumer resolution.
309
+ const lockPath = path.join(path.dirname(manifestPath), 'wyvrn.lock');
310
+ const publishedLock = {};
311
+ if (fs.existsSync(lockPath)) {
312
+ try {
313
+ const lock = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
314
+ for (const [pkgName, entry] of Object.entries(lock.packages ?? {})) {
315
+ const ver = typeof entry === 'string' ? entry : entry.version;
316
+ if (ver) publishedLock[pkgName] = ver;
317
+ }
318
+ log.info(`Lock snapshot: ${Object.keys(publishedLock).length} package(s) recorded in publishedLock`);
319
+ } catch {
320
+ log.warn('could not read wyvrn.lock — publishedLock audit block will be empty');
321
+ }
322
+ } else {
323
+ log.warn('no wyvrn.lock found — run `wyvrnpm install` before publishing so publishedLock records tested versions');
324
+ }
325
+
326
+ // ── Git metadata ──────────────────────────────────────────────────────────
327
+ const gitSha = getGitSha(srcDir);
328
+ const gitRepo = getGitRepo(srcDir);
329
+ if (gitSha) log.info(`Git commit : ${gitSha}`);
330
+ if (gitRepo) log.info(`Git remote : ${gitRepo}`);
331
+
332
+ // Warn (but don't block) if HEAD isn't reachable from origin. Downstream
333
+ // `--build=missing` consumers will fail to fetch this commit — the fix
334
+ // is almost always `git push` before publishing.
335
+ if (gitSha && gitRepo && !isShaOnRemote(gitSha, srcDir)) {
336
+ log.warn(
337
+ `commit ${gitSha.slice(0, 12)} is not reachable from any remote ` +
338
+ `branch or tag on origin.\n` +
339
+ ` Consumers building this package from source will fail to retrieve it.\n` +
340
+ ` Fix: push your branch (or tag the commit) before publishing.`,
341
+ );
342
+ }
343
+
344
+ // ── .wyvrnignore ──────────────────────────────────────────────────────────
345
+ const ignoreFile = path.join(srcDir, '.wyvrnignore');
346
+ const ignorePatterns = loadIgnorePatterns(ignoreFile);
347
+ if (ignorePatterns.length > 0) {
348
+ log.info(`Loaded ${ignorePatterns.length} pattern(s) from .wyvrnignore`);
349
+ }
350
+
351
+ // ── Build zip ─────────────────────────────────────────────────────────────
352
+ const tmpZipPath = path.join(os.tmpdir(), `wyvrnpm-${name}-${version}-${Date.now()}.zip`);
353
+
354
+ // ── Inject default `compatibility` block if the source manifest lacks one ──
355
+ // Written to a temp file so the source wyvrn.json is never modified.
356
+ // A published manifest must always carry a compat block so install-time
357
+ // resolution has something to evaluate (default = all fields `exact`,
358
+ // identical semantics to pre-phase-2 strict behaviour).
359
+ const tmpManifestPath = path.join(
360
+ os.tmpdir(),
361
+ `wyvrnpm-manifest-${name}-${version}-${Date.now()}.json`,
362
+ );
363
+ // ── publishedConf (PLAN-CONF.md §4.6) ────────────────────────────────────
364
+ // Publish reads ONLY the recipe conf + CLI --conf — wyvrn.local.json is
365
+ // intentionally ignored (plan §4.5 / §8.4) so dev-local overlays never
366
+ // leak into shared artefacts. Recorded on the published manifest as
367
+ // informational metadata: it tells maintainers what the publisher's
368
+ // CMake cache vars were at build time but does NOT drive resolution on
369
+ // the consumer side.
370
+ let publishedConf = {};
371
+ try {
372
+ const recipeConfFlat = readRecipeConf(manifest);
373
+ // ctx.cliConf is pre-parsed by buildContext. Malformed --conf would
374
+ // have thrown up there before we reached this block.
375
+ publishedConf = mergeConfLayers(recipeConfFlat, {}, ctx.cliConf);
376
+ } catch (err) {
377
+ log.error(`conf resolution failed: ${err.message}`);
378
+ process.exit(1);
379
+ }
380
+ if (Object.keys(publishedConf).length > 0) {
381
+ log.info(`Published conf: ${JSON.stringify(publishedConf)}`);
382
+ }
383
+
384
+ const manifestForPublish = {
385
+ ...manifest,
386
+ compatibility: manifest.compatibility ?? defaultCompatBlock(),
387
+ ...(Object.keys(publishedConf).length > 0
388
+ ? { publishedConf: unflattenConf(publishedConf) }
389
+ : {}),
390
+ };
391
+ fs.writeFileSync(tmpManifestPath, JSON.stringify(manifestForPublish, null, 2), 'utf8');
392
+ if (!manifest.compatibility) {
393
+ log.info('Injecting default `compatibility` block (all fields `exact`) — override by adding one to wyvrn.json');
394
+ } else {
395
+ log.info(`Using package-declared compatibility: ${JSON.stringify(manifest.compatibility)}`);
396
+ }
397
+
398
+ // ── Resolve binaryDirRoot for install-tree lookup ────────────────────────
399
+ // Publish reads the overlay only to LOCATE the install tree on disk —
400
+ // not to fold any of its `conf` data into the published artefact (that
401
+ // remains explicitly excluded per CLAUDE.md §4.7 "Publish reads recipe
402
+ // + CLI only"). The build-dir path is a local filesystem detail, not
403
+ // ABI/config that ships with the zip, so reading `binaryDirRoot` here
404
+ // doesn't leak dev-machine state into the registry.
405
+ let publishBinaryDirRoot;
406
+ try {
407
+ const overlay = readLocalOverlay(srcDir);
408
+ publishBinaryDirRoot = resolveBinaryDirRoot({
409
+ cliBuildDir: argv.buildDir,
410
+ localOverlayBinaryDirRoot: overlay.binaryDirRoot,
411
+ });
412
+ } catch (err) {
413
+ log.error(err.message);
414
+ process.exit(1);
415
+ }
416
+
417
+ // ── Install-tree overlay ──────────────────────────────────────────────────
418
+ // Publish zips the source tree (filtered by .wyvrnignore), but consumers
419
+ // actually need the install tree — `lib/cmake/<Name>/<Name>Config.cmake`,
420
+ // the installed headers, compiled libs — so find_package() works after
421
+ // extraction. When a build recipe is declared, locate the per-profile
422
+ // install dir (populated by `wyvrnpm build --config <cfg> --install`) and
423
+ // overlay its contents on top of the source zip. Install-tree files win
424
+ // on path collisions (e.g. `include/`).
425
+ let installOverlayDir = null;
426
+ let installOverlayPaths = new Set();
427
+ if (hasBuildRecipe(manifest)) {
428
+ try {
429
+ const recipe = normalizeRecipe(manifest.build, effectiveOptions, manifest);
430
+ const candidate = path.join(
431
+ srcDir, publishBinaryDirRoot, `wyvrn-${activeProfileName}`, recipe.installDir,
432
+ );
433
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) {
434
+ installOverlayDir = candidate;
435
+ } else {
436
+ log.warn(
437
+ `build recipe is declared but no install tree found at ${candidate}.\n` +
438
+ ' Consumers\' find_package() will likely fail. Fix: run\n' +
439
+ ' wyvrnpm build --config Debug --install\n' +
440
+ ' wyvrnpm build --config Release --install\n' +
441
+ ' wyvrnpm build --config RelWithDebInfo --install\n' +
442
+ ' wyvrnpm build --config MinSizeRel --install\n' +
443
+ ' then re-run publish.',
444
+ );
445
+ }
446
+ } catch (err) {
447
+ log.warn(`could not locate install tree: ${err.message} — publishing source tree only`);
448
+ }
449
+ }
450
+
451
+ try {
452
+ log.info(`Zipping ${srcDir} ...`);
453
+ // StreamingZipWriter replaces adm-zip on the write path. archiver
454
+ // streams each file in via fs.createReadStream rather than the
455
+ // sync-readFileSync adm-zip used, so multi-GB artefacts (gRPC's
456
+ // static libs hit ~2.7 GB at RelWithDebInfo, the bug that prompted
457
+ // the swap) no longer trip Node's ~2-GiB Buffer cap. archiver
458
+ // auto-promotes to ZIP64 when total size or entry count crosses
459
+ // the 4 GB / 65535 thresholds; node-stream-zip on the consumer
460
+ // side reads ZIP64 transparently.
461
+ const zip = new StreamingZipWriter(tmpZipPath);
462
+ if (installOverlayDir) {
463
+ // Collect install-tree paths first so the source-tree walker can skip
464
+ // anything the install tree will add canonically.
465
+ const peek = collectInstallTreeFiles(installOverlayDir);
466
+ installOverlayPaths = new Set(peek.map((f) => f.relPath));
467
+ }
468
+ addFolderFiltered(zip, srcDir, srcDir, ignorePatterns, installOverlayPaths);
469
+ if (installOverlayDir) {
470
+ log.info(`Overlaying install tree: ${installOverlayDir}`);
471
+ const added = addInstallTree(zip, installOverlayDir);
472
+ log.info(` Added ${added.size} file(s) from install tree`);
473
+ }
474
+ await zip.finalize();
475
+
476
+ const contentSha256 = sha256Of(tmpZipPath);
477
+ const zipBytes = fs.statSync(tmpZipPath).size;
478
+ const manifestBytes = fs.statSync(tmpManifestPath).size;
479
+ log.info(`Content SHA256 : ${contentSha256}`);
480
+ log.info(`Zip size : ${formatBytes(zipBytes)} (${zipBytes} bytes)`);
481
+
482
+ // ── Dry run: print the plan, skip the actual upload ──────────────────
483
+ // Mirrors `npm publish --dry-run` — every step above (profile, options,
484
+ // zip, hash, v2Exists) runs; only the provider write is skipped.
485
+ if (dryRun) {
486
+ const plan = planPublishUrls({
487
+ source, name, version, profileHash, gitSha, gitRepo, updateLatest: true,
488
+ });
489
+
490
+ if (!jsonOut) {
491
+ console.log();
492
+ console.log(`Would publish ${name}@${version} [${profileHash}] to ${source}`);
493
+ console.log(` contentSha256 : ${contentSha256}`);
494
+ console.log(` zip size : ${formatBytes(zipBytes)}`);
495
+ console.log(` manifest size : ${formatBytes(manifestBytes)}`);
496
+ if (v2AlreadyExists) {
497
+ console.log(` overwrite : YES — ${force ? '--force set, would proceed' : 'NOT SET, real publish would refuse'}`);
498
+ }
499
+ console.log();
500
+ console.log('Registry writes:');
501
+ for (const { path: p, kind } of plan) {
502
+ console.log(` → ${p} (${kind})`);
503
+ }
504
+ console.log();
505
+ log.success('Dry run OK — nothing uploaded.');
506
+ } else {
507
+ const payload = {
508
+ command: 'publish',
509
+ dryRun: true,
510
+ wyvrnpmVersion: require('../../package.json').version,
511
+ name,
512
+ version,
513
+ profileHash,
514
+ source,
515
+ contentSha256,
516
+ zipBytes,
517
+ manifestBytes,
518
+ options: effectiveOptions ?? null,
519
+ gitSha: gitSha ?? null,
520
+ gitRepo: gitRepo ?? null,
521
+ wouldOverwrite: Boolean(v2AlreadyExists),
522
+ forceSet: Boolean(force),
523
+ publishedLock,
524
+ publishedConf: Object.keys(publishedConf).length > 0 ? unflattenConf(publishedConf) : null,
525
+ plan,
526
+ };
527
+ process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
528
+ }
529
+ return;
530
+ }
531
+
532
+ // ── v2 publish ────────────────────────────────────────────────────────
533
+ log.info('Publishing (v2) ...');
534
+ await provider.v2Publish(
535
+ { manifest: tmpManifestPath, zip: tmpZipPath },
536
+ {
537
+ source,
538
+ name,
539
+ version,
540
+ profileHash,
541
+ // buildSettings stored in metadata for debugging — NOT wyvrn.json
542
+ buildSettings: effectiveOptions
543
+ ? { ...buildProfile, options: effectiveOptions }
544
+ : buildProfile,
545
+ contentSha256,
546
+ gitSha,
547
+ gitRepo,
548
+ publishedLock,
549
+ awsProfile,
550
+ token,
551
+ },
552
+ );
553
+
554
+ log.success(`Successfully published ${name}@${version} [${profileHash}] to ${source}`);
555
+
556
+ if (jsonOut) {
557
+ const payload = {
558
+ command: 'publish',
559
+ wyvrnpmVersion: require('../../package.json').version,
560
+ name,
561
+ version,
562
+ profileHash,
563
+ source,
564
+ contentSha256,
565
+ options: effectiveOptions ?? null,
566
+ gitSha: gitSha ?? null,
567
+ gitRepo: gitRepo ?? null,
568
+ publishedAt: new Date().toISOString(),
569
+ };
570
+ process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
571
+ }
572
+ } finally {
573
+ if (fs.existsSync(tmpZipPath)) fs.unlinkSync(tmpZipPath);
574
+ if (fs.existsSync(tmpManifestPath)) fs.unlinkSync(tmpManifestPath);
575
+ }
576
+ }
577
+
578
+ module.exports = publish;
579
+ // Exported for tests only
580
+ module.exports.addFolderFiltered = addFolderFiltered;
581
+ module.exports.addInstallTree = addInstallTree;
582
+ module.exports.collectInstallTreeFiles = collectInstallTreeFiles;
583
+ module.exports.planPublishUrls = planPublishUrls;
584
+ module.exports.formatBytes = formatBytes;