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.
@@ -4,6 +4,21 @@ const fs = require('fs');
4
4
  const path = require('path');
5
5
 
6
6
  const VENDOR_KEY = 'wyvrnpm/generated';
7
+
8
+ // Local copy of the arch→VS-platform mapping. Duplicated here rather than
9
+ // imported from ./index so preset generation can run standalone (keeps
10
+ // the dependency graph simple — presets.js doesn't otherwise need the
11
+ // toolchain generator). Keep in sync with `archToVsPlatform` in index.js.
12
+ const ARCH_VS_PLATFORM = {
13
+ x86_64: 'x64',
14
+ x86: 'Win32',
15
+ armv8: 'ARM64',
16
+ armv7: 'ARM',
17
+ };
18
+
19
+ function isVsGenerator(name) {
20
+ return typeof name === 'string' && /^Visual Studio \d/.test(name);
21
+ }
7
22
  const SCHEMA_VERSION = 3; // requires CMake ≥ 3.21
8
23
 
9
24
  /**
@@ -33,22 +48,83 @@ function isWyvrnGenerated(presets) {
33
48
  /**
34
49
  * Build the configure preset object for the active profile.
35
50
  *
51
+ * When the project has its own `build` recipe (i.e. it's a publishable
52
+ * library with `build.generator` / `build.configure` declared), those are
53
+ * propagated into the generated preset so `wyvrnpm build` locally honours
54
+ * the same knobs the source-build + publish paths use. Without this, a
55
+ * publisher's recipe is effectively advisory at install-time (F15 fix).
56
+ *
36
57
  * @param {object} args
37
58
  * @param {string} args.profileName
38
59
  * @param {string} args.profileHash
39
- * @param {string} args.toolchainRelPath Relative from project root, CMake-style
40
- * forward slashes.
60
+ * @param {string} args.toolchainRelPath Relative from project root, CMake-style
61
+ * forward slashes.
62
+ * @param {string|null} [args.generator] Single generator name to pin on the
63
+ * preset (e.g. "Ninja Multi-Config").
64
+ * Null / undefined → let CMake pick.
65
+ * @param {Record<string,string>|null} [args.extraCacheVariables]
66
+ * Merged into cacheVariables alongside
67
+ * `CMAKE_TOOLCHAIN_FILE`. Usually
68
+ * derived from the project's own
69
+ * `build.configure` -D args.
70
+ * @param {string|null} [args.profileArch] Active profile arch — used to pin
71
+ * `architecture` for VS generators
72
+ * so `cmake --preset` produces
73
+ * binaries matching the profile
74
+ * (VS otherwise defaults to x64).
41
75
  */
42
- function buildConfigurePreset({ profileName, profileHash, toolchainRelPath }) {
43
- return {
76
+ function buildConfigurePreset({
77
+ profileName,
78
+ profileHash,
79
+ toolchainRelPath,
80
+ generator = null,
81
+ extraCacheVariables = null,
82
+ installDir = null,
83
+ profileArch = null,
84
+ }) {
85
+ const binaryDir = `\${sourceDir}/build/wyvrn-${profileName}`;
86
+ // Spread `extraCacheVariables` FIRST, then the wyvrn-owned entries. This
87
+ // means the recipe cannot redirect `CMAKE_TOOLCHAIN_FILE` — our dep
88
+ // injection is always the authoritative toolchain pointer. A malicious
89
+ // or sloppy recipe trying to override it is silently ignored, not
90
+ // quietly obeyed. Same protection applies to `CMAKE_INSTALL_PREFIX`:
91
+ // if a recipe declares `build.installDir`, we resolve it under the
92
+ // binary dir; any cacheVariables override is overwritten below.
93
+ const cacheVariables = {
94
+ ...(extraCacheVariables ?? {}),
95
+ CMAKE_TOOLCHAIN_FILE: `\${sourceDir}/${toolchainRelPath}`,
96
+ };
97
+ if (installDir) {
98
+ // `installDir` in wyvrn.json is defined as relative to the binary dir
99
+ // (same semantics as the source-build path, which passes
100
+ // -DCMAKE_INSTALL_PREFIX=<buildDir>/<installDir>). Without this, a
101
+ // `wyvrnpm build --install` falls back to CMake's system default —
102
+ // on Windows that's `C:/Program Files (x86)/<Project>`, which needs
103
+ // admin rights and almost always fails.
104
+ cacheVariables.CMAKE_INSTALL_PREFIX = `${binaryDir}/${installDir}`;
105
+ }
106
+ const preset = {
44
107
  name: `wyvrn-${profileName}`,
45
108
  displayName: `wyvrnpm (${profileName})`,
46
109
  description: `Generated by wyvrnpm install — profile hash ${profileHash}`,
47
- binaryDir: `\${sourceDir}/build/wyvrn-${profileName}`,
48
- cacheVariables: {
49
- CMAKE_TOOLCHAIN_FILE: `\${sourceDir}/${toolchainRelPath}`,
50
- },
110
+ binaryDir,
111
+ cacheVariables,
51
112
  };
113
+ if (generator) preset.generator = generator;
114
+
115
+ // Visual Studio generators default to x64 when `architecture` is
116
+ // unspecified — which silently overrides the profile arch and makes
117
+ // find_package() reject 32-bit installed packages with a pointer-size
118
+ // mismatch. Pin `architecture` from the profile so `cmake --preset`
119
+ // produces the right artefacts end-to-end.
120
+ if (isVsGenerator(generator) && profileArch && ARCH_VS_PLATFORM[profileArch]) {
121
+ preset.architecture = {
122
+ value: ARCH_VS_PLATFORM[profileArch],
123
+ strategy: 'set',
124
+ };
125
+ }
126
+
127
+ return preset;
52
128
  }
53
129
 
54
130
  /**
@@ -78,7 +154,15 @@ function buildBuildPresets(profileName) {
78
154
  /**
79
155
  * Build a fresh presets file from scratch (no prior content to merge).
80
156
  */
81
- function buildFreshPresetsFile({ profileName, profileHash, toolchainRelPath }) {
157
+ function buildFreshPresetsFile({
158
+ profileName,
159
+ profileHash,
160
+ toolchainRelPath,
161
+ generator = null,
162
+ extraCacheVariables = null,
163
+ installDir = null,
164
+ profileArch = null,
165
+ }) {
82
166
  return {
83
167
  version: SCHEMA_VERSION,
84
168
  vendor: {
@@ -88,8 +172,10 @@ function buildFreshPresetsFile({ profileName, profileHash, toolchainRelPath }) {
88
172
  generatedAt: new Date().toISOString(),
89
173
  },
90
174
  },
91
- configurePresets: [buildConfigurePreset({ profileName, profileHash, toolchainRelPath })],
92
- buildPresets: buildBuildPresets(profileName),
175
+ configurePresets: [buildConfigurePreset({
176
+ profileName, profileHash, toolchainRelPath, generator, extraCacheVariables, installDir, profileArch,
177
+ })],
178
+ buildPresets: buildBuildPresets(profileName),
93
179
  };
94
180
  }
95
181
 
@@ -103,8 +189,18 @@ function buildFreshPresetsFile({ profileName, profileHash, toolchainRelPath }) {
103
189
  * @param {object} args forwarded to the preset builders
104
190
  * @returns {object}
105
191
  */
106
- function mergeIntoExisting(existing, { profileName, profileHash, toolchainRelPath }) {
107
- const newConfigure = buildConfigurePreset({ profileName, profileHash, toolchainRelPath });
192
+ function mergeIntoExisting(existing, {
193
+ profileName,
194
+ profileHash,
195
+ toolchainRelPath,
196
+ generator = null,
197
+ extraCacheVariables = null,
198
+ installDir = null,
199
+ profileArch = null,
200
+ }) {
201
+ const newConfigure = buildConfigurePreset({
202
+ profileName, profileHash, toolchainRelPath, generator, extraCacheVariables, installDir, profileArch,
203
+ });
108
204
  const newBuilds = buildBuildPresets(profileName);
109
205
  const newConfigureNames = new Set([newConfigure.name]);
110
206
  const newBuildNames = new Set(newBuilds.map((b) => b.name));
@@ -150,9 +246,22 @@ function mergeIntoExisting(existing, { profileName, profileHash, toolchainRelPat
150
246
  * @param {string} args.profileName
151
247
  * @param {string} args.profileHash
152
248
  * @param {string} args.toolchainRelPath CMake-style forward slashes, relative to projectRoot.
249
+ * @param {string|null} [args.generator] Propagated onto the configure preset's `generator` field.
250
+ * @param {Record<string,string>|null} [args.extraCacheVariables]
251
+ * Merged into the configure preset's `cacheVariables`.
153
252
  * @returns {{ path: string|null, action: 'created'|'updated'|'skipped', isUser: boolean }}
154
253
  */
155
- function generateCMakePresets({ projectRoot, profileName, profileHash, toolchainRelPath }) {
254
+ function generateCMakePresets({
255
+ projectRoot,
256
+ profileName,
257
+ profileHash,
258
+ toolchainRelPath,
259
+ generator = null,
260
+ extraCacheVariables = null,
261
+ installDir = null,
262
+ profileArch = null,
263
+ }) {
264
+ const args = { profileName, profileHash, toolchainRelPath, generator, extraCacheVariables, installDir, profileArch };
156
265
  const candidates = [
157
266
  { filePath: path.join(projectRoot, 'CMakePresets.json'), isUser: false },
158
267
  { filePath: path.join(projectRoot, 'CMakeUserPresets.json'), isUser: true },
@@ -160,14 +269,14 @@ function generateCMakePresets({ projectRoot, profileName, profileHash, toolchain
160
269
 
161
270
  for (const { filePath, isUser } of candidates) {
162
271
  if (!fs.existsSync(filePath)) {
163
- const content = buildFreshPresetsFile({ profileName, profileHash, toolchainRelPath });
272
+ const content = buildFreshPresetsFile(args);
164
273
  fs.writeFileSync(filePath, JSON.stringify(content, null, 2) + '\n', 'utf8');
165
274
  return { path: filePath, action: 'created', isUser };
166
275
  }
167
276
 
168
277
  const existing = safeReadJson(filePath);
169
278
  if (isWyvrnGenerated(existing)) {
170
- const merged = mergeIntoExisting(existing, { profileName, profileHash, toolchainRelPath });
279
+ const merged = mergeIntoExisting(existing, args);
171
280
  fs.writeFileSync(filePath, JSON.stringify(merged, null, 2) + '\n', 'utf8');
172
281
  return { path: filePath, action: 'updated', isUser };
173
282
  }
@@ -17,6 +17,17 @@ set(WYVRN_PROFILE_NAME "{{PROFILE_NAME}}")
17
17
  set(WYVRN_PROFILE_HASH "{{PROFILE_HASH}}")
18
18
  set(WYVRN_PROFILE_OS "{{PROFILE_OS}}")
19
19
  set(WYVRN_PROFILE_ARCH "{{PROFILE_ARCH}}")
20
+
21
+ # "64" for 64-bit arches (x86_64, armv8, …), "32" for 32-bit (x86, armv7, …),
22
+ # empty for arches outside the known mapping. Derived at generation time from
23
+ # the profile so it is valid BEFORE `project()` — unlike CMAKE_SIZEOF_VOID_P,
24
+ # which is only set after the first project() call. Intended for suffixing
25
+ # artefact names (e.g. "String${WYVRN_ARCH_SUFFIX}" → String64 / String32) so
26
+ # x64 and x86 builds can coexist on disk without overwriting each other.
27
+ # Call WYVRN_APPLY_ARCH_SUFFIX(<target>) from macros.cmake for the common
28
+ # per-target shape, or reference the variable directly for other uses.
29
+ set(WYVRN_ARCH_SUFFIX "{{PROFILE_ARCH_SUFFIX}}")
30
+
20
31
  set(WYVRN_PROFILE_COMPILER "{{PROFILE_COMPILER}}")
21
32
  set(WYVRN_PROFILE_COMPILER_VERSION "{{PROFILE_COMPILER_VERSION}}")
22
33
  set(WYVRN_PROFILE_CPPSTD "{{PROFILE_CPPSTD}}")
@@ -0,0 +1,256 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+
7
+ const { getProvider } = require('./providers');
8
+ const { defaultCompatBlock } = require('./compat');
9
+ const { getBuildPaths } = require('./build/cache');
10
+ const log = require('./logger');
11
+
12
+ const WYVRNPM_VERSION = require('../package.json').version;
13
+
14
+ const UPLOAD_SIDECAR = '.uploaded-to.json';
15
+
16
+ /**
17
+ * Record a successful upload in a sidecar file alongside the build-cache
18
+ * entry. Append-only so repeated uploads to different destinations all
19
+ * leave a trail. Purely informational — nothing in the install/build flow
20
+ * branches on these files. Wiped by `wyvrnpm clean --uploaded-built`.
21
+ *
22
+ * @param {{ name: string, version: string, profileHash: string, source: string, contentSha256: string }} args
23
+ */
24
+ function recordUploadSidecar({ name, version, profileHash, source, contentSha256 }) {
25
+ try {
26
+ const paths = getBuildPaths({ name, version, profileHash });
27
+ if (!fs.existsSync(paths.root)) return;
28
+ const sidecarPath = path.join(paths.root, UPLOAD_SIDECAR);
29
+ let doc = { uploads: [] };
30
+ if (fs.existsSync(sidecarPath)) {
31
+ try {
32
+ const raw = JSON.parse(fs.readFileSync(sidecarPath, 'utf8'));
33
+ if (Array.isArray(raw?.uploads)) doc = raw;
34
+ } catch { /* corrupt sidecar — overwrite with fresh doc */ }
35
+ }
36
+ doc.uploads.push({
37
+ uploadedAt: new Date().toISOString(),
38
+ source,
39
+ profileHash,
40
+ contentSha256,
41
+ });
42
+ fs.writeFileSync(sidecarPath, JSON.stringify(doc, null, 2) + '\n', 'utf8');
43
+ } catch {
44
+ // Sidecar bookkeeping is best-effort — never break an install over it.
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Upload a freshly source-built artefact back to the v2 registry under the
50
+ * consumer's profileHash, so subsequent consumers with the same profile get
51
+ * a direct download instead of re-cloning and re-compiling.
52
+ *
53
+ * See PLAN-UPLOAD-BUILT.md for the full design. Key properties:
54
+ *
55
+ * - Opt-in. Only called when the user passes `--upload-built` at install.
56
+ * - Skips silently if the registry already has this (name, version,
57
+ * profileHash) — the common case once the first teammate has uploaded.
58
+ * - Never overwrites. A consumer with write credentials cannot clobber an
59
+ * existing artefact; that still requires `publish --force` from the
60
+ * source repo.
61
+ * - Leaves `latest.json` and the `latest` pointer in `versions.json`
62
+ * alone. A consumer upload is a new profileHash of an existing version,
63
+ * not a new version release. Honoured via `updateLatest: false`.
64
+ * - Records provenance in the uploaded manifest's `uploadedBy` block.
65
+ * Absence of this block implies author-publish by convention.
66
+ *
67
+ * @param {object} args
68
+ * @param {string} args.name
69
+ * @param {string} args.version
70
+ * @param {import('./profile').WyvrnProfile} args.profile
71
+ * @param {string} args.profileHash
72
+ * @param {string} args.artefactPath Absolute path to the built zip in %LOCALAPPDATA%\wyvrnpm\bc\.
73
+ * @param {string} args.clonedManifestPath Absolute path to the cloned-source wyvrn.json.
74
+ * @param {string} args.contentSha256 SHA256 of the built zip.
75
+ * @param {string} args.gitRepo Source repo URL recorded at publish.
76
+ * @param {string} args.gitSha Source commit recorded at publish.
77
+ * @param {string} args.uploadSource Destination URL (resolved from --upload-source or config).
78
+ * @param {object} [args.uploadAuth] { awsProfile?, token? }
79
+ * @param {Record<string, any>|null} [args.options] Effective options for this build (fed through to buildSettings.options).
80
+ * @param {string} [args.toolchainHash] Optional SHA of the generated wyvrn_toolchain.cmake.
81
+ * @returns {Promise<{ uploaded: boolean, reason: string }>}
82
+ */
83
+ async function uploadSourceBuiltArtefact(args) {
84
+ const {
85
+ name, version, profile, profileHash,
86
+ artefactPath, clonedManifestPath,
87
+ contentSha256, gitRepo, gitSha,
88
+ uploadSource, uploadAuth = {},
89
+ options = null,
90
+ toolchainHash,
91
+ } = args;
92
+
93
+ if (!uploadSource) {
94
+ return { uploaded: false, reason: 'no upload source resolved' };
95
+ }
96
+ if (!fs.existsSync(artefactPath)) {
97
+ return { uploaded: false, reason: `artefact zip missing at ${artefactPath}` };
98
+ }
99
+ if (!fs.existsSync(clonedManifestPath)) {
100
+ // Cache-hit path on a previously-trimmed cache, or the source dir was
101
+ // manually deleted. We have the binary but not the manifest we would
102
+ // have uploaded alongside it. Surface it — nothing silent.
103
+ return { uploaded: false, reason: `cloned manifest missing at ${clonedManifestPath}` };
104
+ }
105
+
106
+ let provider;
107
+ try {
108
+ provider = getProvider(uploadSource);
109
+ } catch (err) {
110
+ return { uploaded: false, reason: `no provider for ${uploadSource}: ${err.message}` };
111
+ }
112
+
113
+ const { awsProfile, token } = uploadAuth;
114
+
115
+ // ── Skip-if-exists ────────────────────────────────────────────────────────
116
+ const exists = await provider.v2Exists({
117
+ source: uploadSource, name, version, profileHash, awsProfile, token,
118
+ });
119
+ if (exists) {
120
+ log.info(`upload-built: ${name}@${version} [${profileHash}] already on ${uploadSource} — skipping`);
121
+ return { uploaded: false, reason: 'already exists on registry' };
122
+ }
123
+
124
+ // ── Prepare manifest: cloned wyvrn.json + compat block + uploadedBy ──────
125
+ const rawManifest = JSON.parse(fs.readFileSync(clonedManifestPath, 'utf8'));
126
+ const manifestForUpload = {
127
+ ...rawManifest,
128
+ compatibility: rawManifest.compatibility ?? defaultCompatBlock(),
129
+ uploadedBy: {
130
+ kind: 'source-build',
131
+ profileHash,
132
+ builtFromGit: gitRepo ?? null,
133
+ builtFromSha: gitSha ?? null,
134
+ ...(toolchainHash ? { toolchainHash } : {}),
135
+ uploadedAt: new Date().toISOString(),
136
+ wyvrnpmVersion: WYVRNPM_VERSION,
137
+ },
138
+ };
139
+
140
+ const tmpManifestPath = path.join(
141
+ os.tmpdir(),
142
+ `wyvrnpm-upload-${name}-${version}-${profileHash}-${Date.now()}.json`,
143
+ );
144
+ fs.writeFileSync(tmpManifestPath, JSON.stringify(manifestForUpload, null, 2), 'utf8');
145
+
146
+ try {
147
+ log.info(`upload-built: ${name}@${version} [${profileHash}] → ${uploadSource}`);
148
+ await provider.v2Publish(
149
+ { manifest: tmpManifestPath, zip: artefactPath },
150
+ {
151
+ source: uploadSource,
152
+ name, version, profileHash,
153
+ buildSettings: options ? { ...profile, options } : profile,
154
+ contentSha256,
155
+ gitSha: gitSha ?? null,
156
+ gitRepo: gitRepo ?? null,
157
+ // Consumer uploads must not reshape the "latest" pointer — see
158
+ // PLAN-UPLOAD-BUILT §3.3.
159
+ updateLatest: false,
160
+ uploadedBy: manifestForUpload.uploadedBy,
161
+ awsProfile,
162
+ token,
163
+ },
164
+ );
165
+ log.success(`upload-built: ${name}@${version} [${profileHash}] uploaded`);
166
+ recordUploadSidecar({ name, version, profileHash, source: uploadSource, contentSha256 });
167
+ return { uploaded: true, reason: 'ok' };
168
+ } catch (err) {
169
+ // The caller wraps us in try/catch too, but returning {uploaded:false}
170
+ // here keeps the happy path type-stable.
171
+ return { uploaded: false, reason: `upload failed: ${err.message}` };
172
+ } finally {
173
+ try { fs.unlinkSync(tmpManifestPath); } catch { /* ignore */ }
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Resolve `--upload-source` (URL or configured-publish-source name) + auth
179
+ * into an object the orchestrator understands. Mirrors the resolution
180
+ * pattern in src/commands/publish.js so the two flows behave identically.
181
+ *
182
+ * Returns null if no destination can be resolved — caller decides whether
183
+ * that is fatal (it is, if --upload-built was passed) or just skip-worthy.
184
+ *
185
+ * @param {object} argv
186
+ * @param {string|undefined} argv.uploadSource
187
+ * @param {string|undefined} argv.awsProfile
188
+ * @param {string|undefined} argv.token
189
+ * @param {{ publishSources: Array<{name:string, url:string, profile?:string, token?:string}> }} config
190
+ * @returns {{ uploadSource: string, uploadAuth: { awsProfile?: string, token?: string } } | null}
191
+ */
192
+ function resolveUploadDestination(argv, config) {
193
+ let src = argv.uploadSource;
194
+ let awsProfile = argv.awsProfile;
195
+ let token = argv.token;
196
+
197
+ const publishSources = config.publishSources ?? [];
198
+
199
+ if (src) {
200
+ // Check if it matches a configured publish source by name.
201
+ const named = publishSources.find((s) => s.name === src);
202
+ if (named) {
203
+ src = named.url;
204
+ awsProfile = awsProfile ?? named.profile;
205
+ token = token ?? named.token;
206
+ log.info(`upload-built: using publish source "${named.name}" from config`);
207
+ }
208
+ } else if (publishSources.length > 0) {
209
+ const first = publishSources[0];
210
+ src = first.url;
211
+ awsProfile = awsProfile ?? first.profile;
212
+ token = token ?? first.token;
213
+ log.info(`upload-built: using first configured publish source "${first.name}"`);
214
+ }
215
+
216
+ if (!src) return null;
217
+ return { uploadSource: src, uploadAuth: { awsProfile, token } };
218
+ }
219
+
220
+ /**
221
+ * Create an empty accumulator for aggregated upload outcomes. Pass the
222
+ * returned object as `options.uploadStats` to `downloadDependencies`, then
223
+ * feed it to `formatUploadSummary()` at the end of the install.
224
+ *
225
+ * @returns {{ uploaded: Array<object>, skipped: Array<object>, failed: Array<object> }}
226
+ */
227
+ function createUploadStats() {
228
+ return { uploaded: [], skipped: [], failed: [] };
229
+ }
230
+
231
+ /**
232
+ * Produce the one-line aggregated summary shown at the end of an install
233
+ * when `--upload-built` was passed. Returns null when nothing was attempted
234
+ * (so the caller can skip printing).
235
+ *
236
+ * @param {{ uploaded: Array, skipped: Array, failed: Array }} stats
237
+ * @returns {string|null}
238
+ */
239
+ function formatUploadSummary(stats) {
240
+ if (!stats) return null;
241
+ const total = stats.uploaded.length + stats.skipped.length + stats.failed.length;
242
+ if (total === 0) return null;
243
+ const n = (arr, singular) => `${arr.length} ${singular}${arr.length === 1 ? '' : 's'}`;
244
+ return (
245
+ `upload-built summary: ${n(stats.uploaded, 'artefact')} uploaded, ` +
246
+ `${stats.skipped.length} skipped (already existed), ` +
247
+ `${stats.failed.length} failed`
248
+ );
249
+ }
250
+
251
+ module.exports = {
252
+ uploadSourceBuiltArtefact,
253
+ resolveUploadDestination,
254
+ createUploadStats,
255
+ formatUploadSummary,
256
+ };