wyvrnpm 2.4.1 → 2.9.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.
@@ -1,383 +1,435 @@
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 { readManifest } = require('../manifest');
9
- const { getProvider } = require('../providers');
10
- const { readConfig } = require('../config');
11
- const { loadProfile, hashProfile, sha256Of, getGitSha, getGitRepo, isShaOnRemote } = require('../profile');
12
- const { defaultCompatBlock } = require('../compat');
13
- const {
14
- normalizeOptionsDeclaration,
15
- resolveEffectiveOptions,
16
- parseCliOptions,
17
- } = require('../options');
18
- const { loadIgnorePatterns, isIgnored } = require('../ignore');
19
- const { hasBuildRecipe, normalizeRecipe } = require('../build/recipe');
20
- const log = require('../logger');
21
-
22
- /**
23
- * Walk `dir` recursively and add every file to `zip`, skipping anything that
24
- * matches `.wyvrnignore` patterns OR whose zip-relative path is already in
25
- * `reservedRelPaths`. The reserved set lets the install-tree overlay win
26
- * on path collisions (e.g. `include/foo.h` shipped by both source and
27
- * install trees).
28
- */
29
- function addFolderFiltered(zip, dir, base, patterns, reservedRelPaths = new Set()) {
30
- for (const entry of fs.readdirSync(dir)) {
31
- const fullPath = path.join(dir, entry);
32
- const relPath = path.relative(base, fullPath).replace(/\\/g, '/');
33
- const stat = fs.statSync(fullPath);
34
- const isDir = stat.isDirectory();
35
- if (isIgnored(relPath, isDir, patterns)) {
36
- log.info(` Ignoring: ${relPath}${isDir ? '/' : ''}`);
37
- continue;
38
- }
39
- if (isDir) {
40
- addFolderFiltered(zip, fullPath, base, patterns, reservedRelPaths);
41
- } else if (reservedRelPaths.has(relPath)) {
42
- // Install tree has a canonical version of this file; let that win.
43
- log.info(` Install-tree wins: ${relPath}`);
44
- } else {
45
- const zipDir = path.dirname(relPath).replace(/\\/g, '/');
46
- zip.addLocalFile(fullPath, zipDir === '.' ? '' : zipDir);
47
- }
48
- }
49
- }
50
-
51
- /**
52
- * Collect every file under `dir` as a `{ fullPath, relPath }` pair. `relPath`
53
- * is forward-slash-separated and relative to `dir` — so the install tree's
54
- * `install/cmake/FooConfig.cmake` lands as `cmake/FooConfig.cmake` at the
55
- * zip root, which is where `find_package(Foo CONFIG)` expects it on the
56
- * consumer side after extraction into `wyvrn_internal/<name>/`.
57
- */
58
- function collectInstallTreeFiles(dir) {
59
- const files = [];
60
- function walk(current) {
61
- for (const entry of fs.readdirSync(current)) {
62
- const full = path.join(current, entry);
63
- const stat = fs.statSync(full);
64
- if (stat.isDirectory()) {
65
- walk(full);
66
- } else {
67
- const rel = path.relative(dir, full).replace(/\\/g, '/');
68
- files.push({ fullPath: full, relPath: rel });
69
- }
70
- }
71
- }
72
- walk(dir);
73
- return files;
74
- }
75
-
76
- function addInstallTree(zip, installDir) {
77
- const files = collectInstallTreeFiles(installDir);
78
- const added = new Set();
79
- for (const { fullPath, relPath } of files) {
80
- const zipDir = path.dirname(relPath).replace(/\\/g, '/');
81
- zip.addLocalFile(fullPath, zipDir === '.' ? '' : zipDir);
82
- added.add(relPath);
83
- }
84
- return added;
85
- }
86
-
87
- // ---------------------------------------------------------------------------
88
- // Source resolution
89
- // ---------------------------------------------------------------------------
90
-
91
- function resolveSource(argv) {
92
- let { source, awsProfile, token } = argv;
93
- const config = readConfig();
94
-
95
- if (source) {
96
- const named = config.publishSources.find((s) => s.name === source);
97
- if (named) {
98
- log.info(`Using publish source "${named.name}" from config`);
99
- source = named.url;
100
- awsProfile = awsProfile ?? named.profile;
101
- token = token ?? named.token;
102
- }
103
- } else {
104
- if (config.publishSources.length === 0) {
105
- log.error(
106
- '--source is required (or configure one with: ' +
107
- 'wyvrnpm configure add-source --kind publish ...)',
108
- );
109
- process.exit(1);
110
- }
111
- const first = config.publishSources[0];
112
- log.info(`Using publish source "${first.name}" from config`);
113
- source = first.url;
114
- awsProfile = awsProfile ?? first.profile;
115
- token = token ?? first.token;
116
- }
117
-
118
- return { source, awsProfile, token };
119
- }
120
-
121
- // ---------------------------------------------------------------------------
122
- // Main publish command
123
- // ---------------------------------------------------------------------------
124
-
125
- async function publish(argv) {
126
- const { source, awsProfile, token } = resolveSource(argv);
127
- const {
128
- profile: profileName,
129
- path: publishPath,
130
- force,
131
- manifest: manifestArg,
132
- } = argv;
133
- const jsonOut = argv.format === 'json';
134
- if (jsonOut) log.setJsonMode(true);
135
-
136
- // ── Load manifest ─────────────────────────────────────────────────────────
137
- const manifestPath = path.resolve(manifestArg || './wyvrn.json');
138
- const manifest = await readManifest(manifestPath);
139
- if (!manifest) {
140
- log.error(`Could not read manifest at ${manifestPath}`);
141
- process.exit(1);
142
- }
143
-
144
- const { name, version } = manifest;
145
- if (!name || !version) {
146
- log.error('Manifest must have "name" and "version" fields');
147
- process.exit(1);
148
- }
149
-
150
- const srcDir = path.resolve(publishPath || '.');
151
- if (!fs.existsSync(srcDir)) {
152
- log.error(`Publish path does not exist: ${srcDir}`);
153
- process.exit(1);
154
- }
155
-
156
- // ── Resolve provider ──────────────────────────────────────────────────────
157
- let provider;
158
- try {
159
- provider = getProvider(source);
160
- } catch (err) {
161
- log.error(err.message);
162
- process.exit(1);
163
- }
164
-
165
- // ── Load build profile ────────────────────────────────────────────────────
166
- const config = readConfig();
167
- const activeProfileName = profileName ?? config.defaultProfile ?? 'default';
168
- const { profile: buildProfile, fromFile } = loadProfile(activeProfileName);
169
-
170
- // ── Resolve this package's own declared options ─────────────────────────
171
- // Publishing needs to commit to a specific value for every option the
172
- // recipe declares. Sources for overrides: CLI `-o <pkg>:...` flags
173
- // (scoped to the package being published), else the recipe's defaults.
174
- const ownDeclaration = manifest.options
175
- ? normalizeOptionsDeclaration(manifest.options, `${name}@${version}`)
176
- : null;
177
- const cliOptionsByPkg = parseCliOptions(argv.option);
178
- const effectiveOptions = resolveEffectiveOptions(
179
- ownDeclaration,
180
- {}, // consumer-side overrides don't apply to self-publish
181
- cliOptionsByPkg[name] ?? {},
182
- `${name}@${version}`,
183
- );
184
-
185
- const profileHash = hashProfile(buildProfile, effectiveOptions);
186
-
187
- log.info(
188
- `Publishing ${name}@${version}\n` +
189
- `Profile : "${activeProfileName}"${fromFile ? '' : ' (auto-detected — save with `wyvrnpm configure profile detect`)'}\n` +
190
- ` ${buildProfile.os}/${buildProfile.arch} ` +
191
- `${buildProfile.compiler} ${buildProfile['compiler.version']} ` +
192
- `C++${buildProfile['compiler.cppstd']}` +
193
- (buildProfile['compiler.runtime'] ? ` [${buildProfile['compiler.runtime']}]` : '') + '\n' +
194
- (effectiveOptions ? `Options : ${JSON.stringify(effectiveOptions)}\n` : '') +
195
- `Profile hash: ${profileHash}\n` +
196
- `Provider : ${provider.constructor.providerName}`,
197
- );
198
-
199
- // ── Check v2 existence ────────────────────────────────────────────────────
200
- const v2AlreadyExists = await provider.v2Exists({ source, name, version, profileHash, awsProfile, token });
201
- if (v2AlreadyExists) {
202
- if (force) {
203
- log.warn(`${name}@${version} [${profileHash}] already exists — overwriting (--force)`);
204
- } else {
205
- log.error(
206
- `${name}@${version} [${profileHash}] already exists.\n` +
207
- ' Bump the version, use a different profile, or pass --force to overwrite.',
208
- );
209
- process.exit(1);
210
- }
211
- }
212
-
213
- // ── Read locked dependency versions from wyvrn.lock ──────────────────────
214
- const lockPath = path.join(path.dirname(manifestPath), 'wyvrn.lock');
215
- const lockedDependencies = {};
216
- if (fs.existsSync(lockPath)) {
217
- try {
218
- const lock = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
219
- for (const [pkgName, entry] of Object.entries(lock.packages ?? {})) {
220
- const ver = typeof entry === 'string' ? entry : entry.version;
221
- if (ver) lockedDependencies[pkgName] = ver;
222
- }
223
- log.info(`Lock file : ${Object.keys(lockedDependencies).length} pinned package(s)`);
224
- } catch {
225
- log.warn('could not read wyvrn.lock — dependency versions will be published as-is from wyvrn.json');
226
- }
227
- } else {
228
- log.warn('no wyvrn.lock found — run `wyvrnpm install` before publishing to pin dependency versions');
229
- }
230
-
231
- // ── Git metadata ──────────────────────────────────────────────────────────
232
- const gitSha = getGitSha(srcDir);
233
- const gitRepo = getGitRepo(srcDir);
234
- if (gitSha) log.info(`Git commit : ${gitSha}`);
235
- if (gitRepo) log.info(`Git remote : ${gitRepo}`);
236
-
237
- // Warn (but don't block) if HEAD isn't reachable from origin. Downstream
238
- // `--build=missing` consumers will fail to fetch this commit the fix
239
- // is almost always `git push` before publishing.
240
- if (gitSha && gitRepo && !isShaOnRemote(gitSha, srcDir)) {
241
- log.warn(
242
- `commit ${gitSha.slice(0, 12)} is not reachable from any remote ` +
243
- `branch or tag on origin.\n` +
244
- ` Consumers building this package from source will fail to retrieve it.\n` +
245
- ` Fix: push your branch (or tag the commit) before publishing.`,
246
- );
247
- }
248
-
249
- // ── .wyvrnignore ──────────────────────────────────────────────────────────
250
- const ignoreFile = path.join(srcDir, '.wyvrnignore');
251
- const ignorePatterns = loadIgnorePatterns(ignoreFile);
252
- if (ignorePatterns.length > 0) {
253
- log.info(`Loaded ${ignorePatterns.length} pattern(s) from .wyvrnignore`);
254
- }
255
-
256
- // ── Build zip ─────────────────────────────────────────────────────────────
257
- const tmpZipPath = path.join(os.tmpdir(), `wyvrnpm-${name}-${version}-${Date.now()}.zip`);
258
-
259
- // ── Inject default `compatibility` block if the source manifest lacks one ──
260
- // Written to a temp file so the source wyvrn.json is never modified.
261
- // A published manifest must always carry a compat block so install-time
262
- // resolution has something to evaluate (default = all fields `exact`,
263
- // identical semantics to pre-phase-2 strict behaviour).
264
- const tmpManifestPath = path.join(
265
- os.tmpdir(),
266
- `wyvrnpm-manifest-${name}-${version}-${Date.now()}.json`,
267
- );
268
- const manifestForPublish = {
269
- ...manifest,
270
- compatibility: manifest.compatibility ?? defaultCompatBlock(),
271
- };
272
- fs.writeFileSync(tmpManifestPath, JSON.stringify(manifestForPublish, null, 2), 'utf8');
273
- if (!manifest.compatibility) {
274
- log.info('Injecting default `compatibility` block (all fields `exact`) — override by adding one to wyvrn.json');
275
- } else {
276
- log.info(`Using package-declared compatibility: ${JSON.stringify(manifest.compatibility)}`);
277
- }
278
-
279
- // ── Install-tree overlay ──────────────────────────────────────────────────
280
- // Publish zips the source tree (filtered by .wyvrnignore), but consumers
281
- // actually need the install tree — `lib/cmake/<Name>/<Name>Config.cmake`,
282
- // the installed headers, compiled libs — so find_package() works after
283
- // extraction. When a build recipe is declared, locate the per-profile
284
- // install dir (populated by `wyvrnpm build --config <cfg> --install`) and
285
- // overlay its contents on top of the source zip. Install-tree files win
286
- // on path collisions (e.g. `include/`).
287
- let installOverlayDir = null;
288
- let installOverlayPaths = new Set();
289
- if (hasBuildRecipe(manifest)) {
290
- try {
291
- const recipe = normalizeRecipe(manifest.build, effectiveOptions);
292
- const candidate = path.join(
293
- srcDir, 'build', `wyvrn-${activeProfileName}`, recipe.installDir,
294
- );
295
- if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) {
296
- installOverlayDir = candidate;
297
- } else {
298
- log.warn(
299
- `build recipe is declared but no install tree found at ${candidate}.\n` +
300
- ' Consumers\' find_package() will likely fail. Fix: run\n' +
301
- ' wyvrnpm build --config Debug --install\n' +
302
- ' wyvrnpm build --config Release --install\n' +
303
- ' wyvrnpm build --config RelWithDebInfo --install\n' +
304
- ' wyvrnpm build --config MinSizeRel --install\n' +
305
- ' then re-run publish.',
306
- );
307
- }
308
- } catch (err) {
309
- log.warn(`could not locate install tree: ${err.message} — publishing source tree only`);
310
- }
311
- }
312
-
313
- try {
314
- log.info(`Zipping ${srcDir} ...`);
315
- const zip = new AdmZip();
316
- if (installOverlayDir) {
317
- // Collect install-tree paths first so the source-tree walker can skip
318
- // anything the install tree will add canonically.
319
- const peek = collectInstallTreeFiles(installOverlayDir);
320
- installOverlayPaths = new Set(peek.map((f) => f.relPath));
321
- }
322
- addFolderFiltered(zip, srcDir, srcDir, ignorePatterns, installOverlayPaths);
323
- if (installOverlayDir) {
324
- log.info(`Overlaying install tree: ${installOverlayDir}`);
325
- const added = addInstallTree(zip, installOverlayDir);
326
- log.info(` Added ${added.size} file(s) from install tree`);
327
- }
328
- zip.writeZip(tmpZipPath);
329
-
330
- const contentSha256 = sha256Of(tmpZipPath);
331
- log.info(`Content SHA256 : ${contentSha256}`);
332
-
333
- // ── v2 publish ────────────────────────────────────────────────────────
334
- log.info('Publishing (v2) ...');
335
- await provider.v2Publish(
336
- { manifest: tmpManifestPath, zip: tmpZipPath },
337
- {
338
- source,
339
- name,
340
- version,
341
- profileHash,
342
- // buildSettings stored in metadata for debugging — NOT wyvrn.json
343
- buildSettings: effectiveOptions
344
- ? { ...buildProfile, options: effectiveOptions }
345
- : buildProfile,
346
- contentSha256,
347
- gitSha,
348
- gitRepo,
349
- lockedDependencies,
350
- awsProfile,
351
- token,
352
- },
353
- );
354
-
355
- log.success(`Successfully published ${name}@${version} [${profileHash}] to ${source}`);
356
-
357
- if (jsonOut) {
358
- const payload = {
359
- command: 'publish',
360
- wyvrnpmVersion: require('../../package.json').version,
361
- name,
362
- version,
363
- profileHash,
364
- source,
365
- contentSha256,
366
- options: effectiveOptions ?? null,
367
- gitSha: gitSha ?? null,
368
- gitRepo: gitRepo ?? null,
369
- publishedAt: new Date().toISOString(),
370
- };
371
- process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
372
- }
373
- } finally {
374
- if (fs.existsSync(tmpZipPath)) fs.unlinkSync(tmpZipPath);
375
- if (fs.existsSync(tmpManifestPath)) fs.unlinkSync(tmpManifestPath);
376
- }
377
- }
378
-
379
- module.exports = publish;
380
- // Exported for tests only
381
- module.exports.addFolderFiltered = addFolderFiltered;
382
- module.exports.addInstallTree = addInstallTree;
383
- module.exports.collectInstallTreeFiles = collectInstallTreeFiles;
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
+ * Walk `dir` recursively and add every file to `zip`, skipping anything that
27
+ * matches `.wyvrnignore` patterns OR whose zip-relative path is already in
28
+ * `reservedRelPaths`. The reserved set lets the install-tree overlay win
29
+ * on path collisions (e.g. `include/foo.h` shipped by both source and
30
+ * install trees).
31
+ */
32
+ function addFolderFiltered(zip, dir, base, patterns, reservedRelPaths = new Set()) {
33
+ for (const entry of fs.readdirSync(dir)) {
34
+ const fullPath = path.join(dir, entry);
35
+ const relPath = path.relative(base, fullPath).replace(/\\/g, '/');
36
+ const stat = fs.statSync(fullPath);
37
+ const isDir = stat.isDirectory();
38
+ if (isIgnored(relPath, isDir, patterns)) {
39
+ log.info(` Ignoring: ${relPath}${isDir ? '/' : ''}`);
40
+ continue;
41
+ }
42
+ if (isDir) {
43
+ addFolderFiltered(zip, fullPath, base, patterns, reservedRelPaths);
44
+ } else if (reservedRelPaths.has(relPath)) {
45
+ // Install tree has a canonical version of this file; let that win.
46
+ log.info(` Install-tree wins: ${relPath}`);
47
+ } else {
48
+ const zipDir = path.dirname(relPath).replace(/\\/g, '/');
49
+ zip.addLocalFile(fullPath, zipDir === '.' ? '' : zipDir);
50
+ }
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Collect every file under `dir` as a `{ fullPath, relPath }` pair. `relPath`
56
+ * is forward-slash-separated and relative to `dir` — so the install tree's
57
+ * `install/cmake/FooConfig.cmake` lands as `cmake/FooConfig.cmake` at the
58
+ * zip root, which is where `find_package(Foo CONFIG)` expects it on the
59
+ * consumer side after extraction into `wyvrn_internal/<name>/`.
60
+ */
61
+ function collectInstallTreeFiles(dir) {
62
+ const files = [];
63
+ function walk(current) {
64
+ for (const entry of fs.readdirSync(current)) {
65
+ const full = path.join(current, entry);
66
+ const stat = fs.statSync(full);
67
+ if (stat.isDirectory()) {
68
+ walk(full);
69
+ } else {
70
+ const rel = path.relative(dir, full).replace(/\\/g, '/');
71
+ files.push({ fullPath: full, relPath: rel });
72
+ }
73
+ }
74
+ }
75
+ walk(dir);
76
+ return files;
77
+ }
78
+
79
+ function addInstallTree(zip, installDir) {
80
+ const files = collectInstallTreeFiles(installDir);
81
+ const added = new Set();
82
+ for (const { fullPath, relPath } of files) {
83
+ const zipDir = path.dirname(relPath).replace(/\\/g, '/');
84
+ zip.addLocalFile(fullPath, zipDir === '.' ? '' : zipDir);
85
+ added.add(relPath);
86
+ }
87
+ return added;
88
+ }
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // Source resolution
92
+ // ---------------------------------------------------------------------------
93
+
94
+ function resolveSource(argv, ctx) {
95
+ let { source, awsProfile } = argv;
96
+ const { config } = ctx;
97
+ let sourceEntry = null;
98
+
99
+ if (source) {
100
+ const named = config.publishSources.find((s) => s.name === source);
101
+ if (named) {
102
+ log.info(`Using publish source "${named.name}" from config`);
103
+ source = named.url;
104
+ awsProfile = awsProfile ?? named.profile;
105
+ sourceEntry = named;
106
+ }
107
+ } else {
108
+ if (config.publishSources.length === 0) {
109
+ log.error(
110
+ '--source is required (or configure one with: ' +
111
+ 'wyvrnpm configure add-source --kind publish ...)',
112
+ );
113
+ process.exit(1);
114
+ }
115
+ const first = config.publishSources[0];
116
+ log.info(`Using publish source "${first.name}" from config`);
117
+ source = first.url;
118
+ awsProfile = awsProfile ?? first.profile;
119
+ sourceEntry = first;
120
+ }
121
+
122
+ // Auth flows through ctx.auth.for — same literal > --token-env >
123
+ // config.token > config.tokenEnv precedence, centralised once in
124
+ // buildContext.
125
+ const { token } = ctx.auth.for(sourceEntry);
126
+
127
+ return { source, awsProfile, token };
128
+ }
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // Main publish command
132
+ // ---------------------------------------------------------------------------
133
+
134
+ async function publish(argv) {
135
+ // Shared per-invocation state (config, profile, manifest lazy, auth,
136
+ // CLI options/conf parsing, JSON-mode toggle). See src/context.js +
137
+ // claude/PLAN-COMMAND-CONTEXT.md. Also throws on malformed --option /
138
+ // --conf so bad inputs surface before we hit the registry.
139
+ let ctx;
140
+ try {
141
+ ctx = buildContext(argv);
142
+ } catch (err) {
143
+ log.error(err.message);
144
+ process.exit(1);
145
+ }
146
+
147
+ const { source, awsProfile, token } = resolveSource(argv, ctx);
148
+ const { path: publishPath, force } = argv;
149
+ const jsonOut = ctx.flags.format === 'json';
150
+
151
+ // ── Load manifest ─────────────────────────────────────────────────────────
152
+ // ctx.manifest is a lazy getter — throws on first access if the file is
153
+ // missing or malformed, which is exactly what we want here.
154
+ const { manifestPath } = ctx;
155
+ let manifest;
156
+ try {
157
+ manifest = ctx.manifest;
158
+ } catch (err) {
159
+ log.error(err.message);
160
+ process.exit(1);
161
+ }
162
+
163
+ const { name, version } = manifest;
164
+ if (!name || !version) {
165
+ log.error('Manifest must have "name" and "version" fields');
166
+ process.exit(1);
167
+ }
168
+
169
+ const srcDir = path.resolve(publishPath || '.');
170
+ if (!fs.existsSync(srcDir)) {
171
+ log.error(`Publish path does not exist: ${srcDir}`);
172
+ process.exit(1);
173
+ }
174
+
175
+ // ── Resolve provider ──────────────────────────────────────────────────────
176
+ let provider;
177
+ try {
178
+ provider = getProvider(source);
179
+ } catch (err) {
180
+ log.error(err.message);
181
+ process.exit(1);
182
+ }
183
+
184
+ // ── Load build profile ────────────────────────────────────────────────────
185
+ // Resolved once in buildContext(). argv.profile → config.defaultProfile
186
+ // → "default" fallback is applied there. `--profile` to publish still
187
+ // works identically — it just flows through ctx.
188
+ const {
189
+ name: activeProfileName,
190
+ profile: buildProfile,
191
+ fromFile,
192
+ } = ctx.profiles.host;
193
+
194
+ // ── Resolve this package's own declared options ─────────────────────────
195
+ // Publishing needs to commit to a specific value for every option the
196
+ // recipe declares. Sources for overrides: CLI `-o <pkg>:...` flags
197
+ // (scoped to the package being published), else the recipe's defaults.
198
+ const ownDeclaration = manifest.options
199
+ ? normalizeOptionsDeclaration(manifest.options, `${name}@${version}`)
200
+ : null;
201
+ const cliOptionsByPkg = ctx.cliOptions;
202
+ const effectiveOptions = resolveEffectiveOptions(
203
+ ownDeclaration,
204
+ {}, // consumer-side overrides don't apply to self-publish
205
+ cliOptionsByPkg[name] ?? {},
206
+ `${name}@${version}`,
207
+ );
208
+
209
+ const profileHash = hashProfile(buildProfile, effectiveOptions);
210
+
211
+ log.info(
212
+ `Publishing ${name}@${version}\n` +
213
+ `Profile : "${activeProfileName}"${fromFile ? '' : ' (auto-detected save with `wyvrnpm configure profile detect`)'}\n` +
214
+ ` ${buildProfile.os}/${buildProfile.arch} ` +
215
+ `${buildProfile.compiler} ${buildProfile['compiler.version']} ` +
216
+ `C++${buildProfile['compiler.cppstd']}` +
217
+ (buildProfile['compiler.runtime'] ? ` [${buildProfile['compiler.runtime']}]` : '') + '\n' +
218
+ (effectiveOptions ? `Options : ${JSON.stringify(effectiveOptions)}\n` : '') +
219
+ `Profile hash: ${profileHash}\n` +
220
+ `Provider : ${provider.constructor.providerName}`,
221
+ );
222
+
223
+ // ── Check v2 existence ────────────────────────────────────────────────────
224
+ const v2AlreadyExists = await provider.v2Exists({ source, name, version, profileHash, awsProfile, token });
225
+ if (v2AlreadyExists) {
226
+ if (force) {
227
+ log.warn(`${name}@${version} [${profileHash}] already exists — overwriting (--force)`);
228
+ } else {
229
+ log.error(
230
+ `${name}@${version} [${profileHash}] already exists.\n` +
231
+ ' Bump the version, use a different profile, or pass --force to overwrite.',
232
+ );
233
+ process.exit(1);
234
+ }
235
+ }
236
+
237
+ // ── Read the author's lockfile snapshot for `publishedLock` audit ────────
238
+ // Dependencies in wyvrn.json are published verbatim (ranges stay as ranges).
239
+ // The exact versions from wyvrn.lock are attached as `publishedLock` metadata
240
+ // on the uploaded manifest so future forensics can tell what the author
241
+ // actually tested against — without constraining consumer resolution.
242
+ const lockPath = path.join(path.dirname(manifestPath), 'wyvrn.lock');
243
+ const publishedLock = {};
244
+ if (fs.existsSync(lockPath)) {
245
+ try {
246
+ const lock = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
247
+ for (const [pkgName, entry] of Object.entries(lock.packages ?? {})) {
248
+ const ver = typeof entry === 'string' ? entry : entry.version;
249
+ if (ver) publishedLock[pkgName] = ver;
250
+ }
251
+ log.info(`Lock snapshot: ${Object.keys(publishedLock).length} package(s) recorded in publishedLock`);
252
+ } catch {
253
+ log.warn('could not read wyvrn.lock publishedLock audit block will be empty');
254
+ }
255
+ } else {
256
+ log.warn('no wyvrn.lock found run `wyvrnpm install` before publishing so publishedLock records tested versions');
257
+ }
258
+
259
+ // ── Git metadata ──────────────────────────────────────────────────────────
260
+ const gitSha = getGitSha(srcDir);
261
+ const gitRepo = getGitRepo(srcDir);
262
+ if (gitSha) log.info(`Git commit : ${gitSha}`);
263
+ if (gitRepo) log.info(`Git remote : ${gitRepo}`);
264
+
265
+ // Warn (but don't block) if HEAD isn't reachable from origin. Downstream
266
+ // `--build=missing` consumers will fail to fetch this commit — the fix
267
+ // is almost always `git push` before publishing.
268
+ if (gitSha && gitRepo && !isShaOnRemote(gitSha, srcDir)) {
269
+ log.warn(
270
+ `commit ${gitSha.slice(0, 12)} is not reachable from any remote ` +
271
+ `branch or tag on origin.\n` +
272
+ ` Consumers building this package from source will fail to retrieve it.\n` +
273
+ ` Fix: push your branch (or tag the commit) before publishing.`,
274
+ );
275
+ }
276
+
277
+ // ── .wyvrnignore ──────────────────────────────────────────────────────────
278
+ const ignoreFile = path.join(srcDir, '.wyvrnignore');
279
+ const ignorePatterns = loadIgnorePatterns(ignoreFile);
280
+ if (ignorePatterns.length > 0) {
281
+ log.info(`Loaded ${ignorePatterns.length} pattern(s) from .wyvrnignore`);
282
+ }
283
+
284
+ // ── Build zip ─────────────────────────────────────────────────────────────
285
+ const tmpZipPath = path.join(os.tmpdir(), `wyvrnpm-${name}-${version}-${Date.now()}.zip`);
286
+
287
+ // ── Inject default `compatibility` block if the source manifest lacks one ──
288
+ // Written to a temp file so the source wyvrn.json is never modified.
289
+ // A published manifest must always carry a compat block so install-time
290
+ // resolution has something to evaluate (default = all fields `exact`,
291
+ // identical semantics to pre-phase-2 strict behaviour).
292
+ const tmpManifestPath = path.join(
293
+ os.tmpdir(),
294
+ `wyvrnpm-manifest-${name}-${version}-${Date.now()}.json`,
295
+ );
296
+ // ── publishedConf (PLAN-CONF.md §4.6) ────────────────────────────────────
297
+ // Publish reads ONLY the recipe conf + CLI --conf — wyvrn.local.json is
298
+ // intentionally ignored (plan §4.5 / §8.4) so dev-local overlays never
299
+ // leak into shared artefacts. Recorded on the published manifest as
300
+ // informational metadata: it tells maintainers what the publisher's
301
+ // CMake cache vars were at build time but does NOT drive resolution on
302
+ // the consumer side.
303
+ let publishedConf = {};
304
+ try {
305
+ const recipeConfFlat = readRecipeConf(manifest);
306
+ // ctx.cliConf is pre-parsed by buildContext. Malformed --conf would
307
+ // have thrown up there before we reached this block.
308
+ publishedConf = mergeConfLayers(recipeConfFlat, {}, ctx.cliConf);
309
+ } catch (err) {
310
+ log.error(`conf resolution failed: ${err.message}`);
311
+ process.exit(1);
312
+ }
313
+ if (Object.keys(publishedConf).length > 0) {
314
+ log.info(`Published conf: ${JSON.stringify(publishedConf)}`);
315
+ }
316
+
317
+ const manifestForPublish = {
318
+ ...manifest,
319
+ compatibility: manifest.compatibility ?? defaultCompatBlock(),
320
+ ...(Object.keys(publishedConf).length > 0
321
+ ? { publishedConf: unflattenConf(publishedConf) }
322
+ : {}),
323
+ };
324
+ fs.writeFileSync(tmpManifestPath, JSON.stringify(manifestForPublish, null, 2), 'utf8');
325
+ if (!manifest.compatibility) {
326
+ log.info('Injecting default `compatibility` block (all fields `exact`) override by adding one to wyvrn.json');
327
+ } else {
328
+ log.info(`Using package-declared compatibility: ${JSON.stringify(manifest.compatibility)}`);
329
+ }
330
+
331
+ // ── Install-tree overlay ──────────────────────────────────────────────────
332
+ // Publish zips the source tree (filtered by .wyvrnignore), but consumers
333
+ // actually need the install tree — `lib/cmake/<Name>/<Name>Config.cmake`,
334
+ // the installed headers, compiled libs — so find_package() works after
335
+ // extraction. When a build recipe is declared, locate the per-profile
336
+ // install dir (populated by `wyvrnpm build --config <cfg> --install`) and
337
+ // overlay its contents on top of the source zip. Install-tree files win
338
+ // on path collisions (e.g. `include/`).
339
+ let installOverlayDir = null;
340
+ let installOverlayPaths = new Set();
341
+ if (hasBuildRecipe(manifest)) {
342
+ try {
343
+ const recipe = normalizeRecipe(manifest.build, effectiveOptions, manifest);
344
+ const candidate = path.join(
345
+ srcDir, 'build', `wyvrn-${activeProfileName}`, recipe.installDir,
346
+ );
347
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) {
348
+ installOverlayDir = candidate;
349
+ } else {
350
+ log.warn(
351
+ `build recipe is declared but no install tree found at ${candidate}.\n` +
352
+ ' Consumers\' find_package() will likely fail. Fix: run\n' +
353
+ ' wyvrnpm build --config Debug --install\n' +
354
+ ' wyvrnpm build --config Release --install\n' +
355
+ ' wyvrnpm build --config RelWithDebInfo --install\n' +
356
+ ' wyvrnpm build --config MinSizeRel --install\n' +
357
+ ' then re-run publish.',
358
+ );
359
+ }
360
+ } catch (err) {
361
+ log.warn(`could not locate install tree: ${err.message} — publishing source tree only`);
362
+ }
363
+ }
364
+
365
+ try {
366
+ log.info(`Zipping ${srcDir} ...`);
367
+ const zip = new AdmZip();
368
+ if (installOverlayDir) {
369
+ // Collect install-tree paths first so the source-tree walker can skip
370
+ // anything the install tree will add canonically.
371
+ const peek = collectInstallTreeFiles(installOverlayDir);
372
+ installOverlayPaths = new Set(peek.map((f) => f.relPath));
373
+ }
374
+ addFolderFiltered(zip, srcDir, srcDir, ignorePatterns, installOverlayPaths);
375
+ if (installOverlayDir) {
376
+ log.info(`Overlaying install tree: ${installOverlayDir}`);
377
+ const added = addInstallTree(zip, installOverlayDir);
378
+ log.info(` Added ${added.size} file(s) from install tree`);
379
+ }
380
+ zip.writeZip(tmpZipPath);
381
+
382
+ const contentSha256 = sha256Of(tmpZipPath);
383
+ log.info(`Content SHA256 : ${contentSha256}`);
384
+
385
+ // ── v2 publish ────────────────────────────────────────────────────────
386
+ log.info('Publishing (v2) ...');
387
+ await provider.v2Publish(
388
+ { manifest: tmpManifestPath, zip: tmpZipPath },
389
+ {
390
+ source,
391
+ name,
392
+ version,
393
+ profileHash,
394
+ // buildSettings stored in metadata for debugging — NOT wyvrn.json
395
+ buildSettings: effectiveOptions
396
+ ? { ...buildProfile, options: effectiveOptions }
397
+ : buildProfile,
398
+ contentSha256,
399
+ gitSha,
400
+ gitRepo,
401
+ publishedLock,
402
+ awsProfile,
403
+ token,
404
+ },
405
+ );
406
+
407
+ log.success(`Successfully published ${name}@${version} [${profileHash}] to ${source}`);
408
+
409
+ if (jsonOut) {
410
+ const payload = {
411
+ command: 'publish',
412
+ wyvrnpmVersion: require('../../package.json').version,
413
+ name,
414
+ version,
415
+ profileHash,
416
+ source,
417
+ contentSha256,
418
+ options: effectiveOptions ?? null,
419
+ gitSha: gitSha ?? null,
420
+ gitRepo: gitRepo ?? null,
421
+ publishedAt: new Date().toISOString(),
422
+ };
423
+ process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
424
+ }
425
+ } finally {
426
+ if (fs.existsSync(tmpZipPath)) fs.unlinkSync(tmpZipPath);
427
+ if (fs.existsSync(tmpManifestPath)) fs.unlinkSync(tmpManifestPath);
428
+ }
429
+ }
430
+
431
+ module.exports = publish;
432
+ // Exported for tests only
433
+ module.exports.addFolderFiltered = addFolderFiltered;
434
+ module.exports.addInstallTree = addInstallTree;
435
+ module.exports.collectInstallTreeFiles = collectInstallTreeFiles;