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.
@@ -9,21 +9,13 @@ const RE_S3_URI = /^s3:\/\//;
9
9
  const RE_S3_VIRTUAL_HOSTED = /^https?:\/\/([^.]+)\.s3[.-][^/]*\.amazonaws\.com/;
10
10
  const RE_S3_PATH_STYLE = /^https?:\/\/s3[.-][^/]*\.amazonaws\.com\/([^/]+)/;
11
11
 
12
- function pinDependencies(rawDeps, lockedDeps) {
13
- if (!rawDeps || !lockedDeps || Object.keys(lockedDeps).length === 0) return rawDeps;
14
- const result = {};
15
- for (const [pkgName, value] of Object.entries(rawDeps)) {
16
- const locked = lockedDeps[pkgName];
17
- if (locked) {
18
- result[pkgName] = typeof value === 'object' && value !== null
19
- ? { ...value, version: locked }
20
- : locked;
21
- } else {
22
- result[pkgName] = value;
23
- }
24
- }
25
- return result;
26
- }
12
+ // CloudFront TTL cap for mutable manifest JSON (versions.json, latest.json)
13
+ // and the per-profile wyvrn.json / source.json. 5 min is short enough that
14
+ // a publish is visible to `install` / `add` well within a coffee break, but
15
+ // long enough that `install` at scale doesn't hammer the origin. Applied
16
+ // on every JSON PUT — consumers of the ZIPs get default (long) TTLs since
17
+ // a (name, version, profileHash) artefact is immutable by construction.
18
+ const JSON_CACHE_CONTROL = 'public, max-age=300';
27
19
 
28
20
  class S3Provider extends BaseProvider {
29
21
  static canHandle(source) {
@@ -76,25 +68,36 @@ class S3Provider extends BaseProvider {
76
68
 
77
69
  _makeClient(awsProfile) {
78
70
  const { S3Client, fromSSO } = this._loadSdk();
79
- return new S3Client(awsProfile ? { credentials: fromSSO({ profile: awsProfile }) } : {});
71
+ if (!awsProfile) return new S3Client({});
72
+
73
+ // fromSSO supplies credentials only. The SDK's region resolver is
74
+ // independent and consults AWS_REGION / AWS_DEFAULT_REGION / the profile
75
+ // named by AWS_PROFILE — NOT the profile handed to fromSSO. Propagate
76
+ // --aws-profile so its `region = …` line in ~/.aws/config is the fallback.
77
+ process.env.AWS_PROFILE = awsProfile;
78
+ return new S3Client({ credentials: fromSSO({ profile: awsProfile }) });
80
79
  }
81
80
 
82
81
  async _putFile(client, bucket, key, filePath, contentType, PutObjectCommand) {
83
82
  log.info(` → s3://${bucket}/${key}`);
84
- await client.send(new PutObjectCommand({
83
+ const params = {
85
84
  Bucket: bucket, Key: key,
86
85
  Body: fs.readFileSync(filePath),
87
86
  ContentType: contentType,
88
- }));
87
+ };
88
+ if (contentType === 'application/json') params.CacheControl = JSON_CACHE_CONTROL;
89
+ await client.send(new PutObjectCommand(params));
89
90
  }
90
91
 
91
92
  async _putBuffer(client, bucket, key, content, contentType, PutObjectCommand) {
92
93
  log.info(` → s3://${bucket}/${key}`);
93
- await client.send(new PutObjectCommand({
94
+ const params = {
94
95
  Bucket: bucket, Key: key,
95
96
  Body: content,
96
97
  ContentType: contentType,
97
- }));
98
+ };
99
+ if (contentType === 'application/json') params.CacheControl = JSON_CACHE_CONTROL;
100
+ await client.send(new PutObjectCommand(params));
98
101
  }
99
102
 
100
103
  async _getJson(client, bucket, key, GetObjectCommand) {
@@ -177,7 +180,7 @@ class S3Provider extends BaseProvider {
177
180
  async v2Publish(files, options) {
178
181
  const {
179
182
  source, name, version, profileHash, buildSettings,
180
- contentSha256, gitSha, gitRepo, lockedDependencies, awsProfile,
183
+ contentSha256, gitSha, gitRepo, publishedLock, awsProfile,
181
184
  updateLatest = true, uploadedBy,
182
185
  } = options;
183
186
  const { PutObjectCommand, GetObjectCommand } = this._loadSdk();
@@ -190,14 +193,14 @@ class S3Provider extends BaseProvider {
190
193
  // 1) Upload zip
191
194
  await this._putFile(client, bucket, `${buildRoot}/wyvrn.zip`, files.zip, 'application/zip', PutObjectCommand);
192
195
 
193
- // 2) Upload enriched wyvrn.json
196
+ // 2) Upload enriched wyvrn.json.
194
197
  // buildSettings is stored for debugging purposes only.
195
- // Dependencies are replaced with locked versions so the server never sees "latest".
198
+ // Dependencies are published verbatim from wyvrn.json ranges stay as
199
+ // ranges so consumers can intersect them with their own. The author's
200
+ // lockfile snapshot is preserved in `publishedLock` for audit.
196
201
  const rawManifest = JSON.parse(fs.readFileSync(files.manifest, 'utf8'));
197
- const pinnedDeps = pinDependencies(rawManifest.dependencies, lockedDependencies);
198
202
  const v2Meta = {
199
203
  ...rawManifest,
200
- ...(pinnedDeps !== undefined ? { dependencies: pinnedDeps } : {}),
201
204
  schemaVersion: 2,
202
205
  profileHash,
203
206
  buildSettings,
@@ -205,6 +208,7 @@ class S3Provider extends BaseProvider {
205
208
  gitSha: gitSha ?? null,
206
209
  gitRepo: gitRepo ?? null,
207
210
  publishedAt: new Date().toISOString(),
211
+ ...(publishedLock && Object.keys(publishedLock).length > 0 ? { publishedLock } : {}),
208
212
  ...(uploadedBy ? { uploadedBy } : {}),
209
213
  };
210
214
  await this._putBuffer(
package/src/resolve.js CHANGED
@@ -141,15 +141,27 @@ async function resolveLatestVersion(name, packageSources, platform, httpClient)
141
141
  /**
142
142
  * Fetches razer.json for a single package from the first source that responds.
143
143
  *
144
+ * When `preferredProfileHash` is provided (EVALUATION.md F13 — closes the
145
+ * "picks arbitrary profileHash" latent bug), the v2 lookup tries that hash
146
+ * first and only falls back to the first-available profile if the preferred
147
+ * hash isn't on the registry. Dependency declarations are invariant across
148
+ * profiles today, so picking any hash gives the same answer — but the
149
+ * consumer's own hash is stable across re-resolves and future-proofs
150
+ * against any schema that starts varying wyvrn.json per profile.
151
+ *
144
152
  * @param {string} name
145
153
  * @param {string} version
146
154
  * @param {string[]} packageSources
147
155
  * @param {string} platform
148
156
  * @param {Function} httpClient
149
157
  * @param {Map<string, object>} cache - In-flight / completed fetch cache.
158
+ * @param {string|null} [preferredProfileHash] - try this hash first.
150
159
  * @returns {Promise<object|null>} Parsed razer.json or null if not found anywhere.
151
160
  */
152
- async function fetchPackageManifest(name, version, packageSources, platform, httpClient, cache) {
161
+ async function fetchPackageManifest(
162
+ name, version, packageSources, platform, httpClient, cache,
163
+ preferredProfileHash = null,
164
+ ) {
153
165
  const cacheKey = `${name}@${version}`;
154
166
  if (cache.has(cacheKey)) {
155
167
  return cache.get(cacheKey);
@@ -158,14 +170,19 @@ async function fetchPackageManifest(name, version, packageSources, platform, htt
158
170
  for (const source of packageSources) {
159
171
  const base = source.replace(/\/$/, '');
160
172
 
161
- // v2: look up any available profile hash from versions.json, then fetch that manifest
173
+ // v2: prefer the consumer's own profileHash; fall back to the first
174
+ // available if that hash isn't published.
162
175
  try {
163
176
  const idxResp = await httpClient(`${base}/v2/${name}/versions.json`);
164
177
  if (idxResp.ok) {
165
178
  const idx = await idxResp.json();
166
179
  const profiles = idx?.versions?.[version]?.profiles;
167
180
  if (profiles) {
168
- const profileHash = Object.keys(profiles)[0];
181
+ const available = Object.keys(profiles);
182
+ const profileHash =
183
+ (preferredProfileHash && available.includes(preferredProfileHash))
184
+ ? preferredProfileHash
185
+ : available[0];
169
186
  if (profileHash) {
170
187
  const mResp = await httpClient(`${base}/v2/${name}/${version}/${profileHash}/wyvrn.json`);
171
188
  if (mResp.ok) {
@@ -215,15 +232,37 @@ async function fetchPackageManifest(name, version, packageSources, platform, htt
215
232
  * PLAN-OPTIONS) use this to avoid a second round of HTTP round-trips.
216
233
  * Passing `null` / omitting it keeps the legacy behaviour.
217
234
  *
235
+ * Per-package source pinning (EVALUATION.md S4): when `pinnedSources` is
236
+ * provided, any entry `name → url` restricts that package's manifest +
237
+ * latest + versions lookups to just that URL — typo-squatting and mirror
238
+ * shadowing are closed for pinned deps. Pins propagate to direct deps
239
+ * only; transitive deps continue to fan out across all sources (a pinned
240
+ * dep's own recipe is authoritative for its own transitives).
241
+ *
218
242
  * @param {Record<string, string>} rootDeps - Direct dependencies { name: version }.
219
243
  * @param {string[]} packageSources - Ordered list of base URLs to try.
220
244
  * @param {string} platform - Target platform string (e.g. "win_x64").
221
245
  * @param {Function} httpClient - fetch-compatible function.
222
246
  * @param {Map<string, string>} [lockedVersions]
223
247
  * @param {Map<string, object>} [manifestsOut] - populated in-place when provided
248
+ * @param {string|null} [preferredProfileHash] - consumer's own profileHash;
249
+ * threaded to fetchPackageManifest so v2 manifest lookups prefer the
250
+ * consumer's own build entry over an arbitrary one (EVALUATION.md F13).
251
+ * @param {Map<string,string>|null} [pinnedSources] - per-package source URL pin (S4).
224
252
  * @returns {Promise<Map<string, string>>} Resolved map of name -> version.
225
253
  */
226
- async function resolveDependencies(rootDeps, packageSources, platform, httpClient, lockedVersions = new Map(), manifestsOut = null) {
254
+ async function resolveDependencies(
255
+ rootDeps, packageSources, platform, httpClient,
256
+ lockedVersions = new Map(), manifestsOut = null,
257
+ preferredProfileHash = null,
258
+ pinnedSources = null,
259
+ ) {
260
+ // For pinned deps, restrict the source list to the single pinned URL.
261
+ // `null` / no pin → fall back to the full caller-supplied list.
262
+ const sourcesFor = (name) => {
263
+ const pin = pinnedSources?.get?.(name);
264
+ return pin ? [pin] : packageSources;
265
+ };
227
266
  /** @type {Map<string, string>} Final resolved versions. */
228
267
  const resolved = new Map();
229
268
 
@@ -254,7 +293,7 @@ async function resolveDependencies(rootDeps, packageSources, platform, httpClien
254
293
  const publishedCache = new Map();
255
294
  const fetchPublished = async (name) => {
256
295
  if (publishedCache.has(name)) return publishedCache.get(name);
257
- const list = await fetchPublishedVersions(name, packageSources, httpClient);
296
+ const list = await fetchPublishedVersions(name, sourcesFor(name), httpClient);
258
297
  publishedCache.set(name, list);
259
298
  return list;
260
299
  };
@@ -280,7 +319,7 @@ async function resolveDependencies(rootDeps, packageSources, platform, httpClien
280
319
  // Skip this for linked packages which have "linked:" prefix.
281
320
  if (version === 'latest') {
282
321
  try {
283
- version = await resolveLatestVersion(name, packageSources, platform, httpClient);
322
+ version = await resolveLatestVersion(name, sourcesFor(name), platform, httpClient);
284
323
  log.info(`Resolved "${name}@latest" → ${version}`);
285
324
  } catch (err) {
286
325
  log.warn(err.message);
@@ -405,10 +444,11 @@ async function resolveDependencies(rootDeps, packageSources, platform, httpClien
405
444
  manifest = await fetchPackageManifest(
406
445
  name,
407
446
  version,
408
- packageSources,
447
+ sourcesFor(name),
409
448
  platform,
410
449
  httpClient,
411
450
  manifestCache,
451
+ preferredProfileHash,
412
452
  );
413
453
  }
414
454
 
@@ -0,0 +1,152 @@
1
+ 'use strict';
2
+
3
+ // Pattern-based settings overrides (EVALUATION.md F11).
4
+ //
5
+ // `wyvrn.json` may declare a top-level `settingsOverrides` block whose
6
+ // keys are glob patterns over dependency names. Every matching pattern
7
+ // contributes to the resolved dep's `settings` merge; the per-dep
8
+ // literal settings (inside `dependencies`) still win over patterns.
9
+ //
10
+ // Conan users know this feature as profile-side `boost*:compiler.cppstd=17`.
11
+ // Ours lives in the manifest because that's where literal settings
12
+ // overrides already live — one fewer concept for recipe authors.
13
+ //
14
+ // Pure, no I/O. Exported helpers:
15
+ // - normalizeSettingsOverrides: validate + sort by specificity
16
+ // - resolveOverrideSettings: compute the merged settings for a dep name
17
+
18
+ /**
19
+ * Valid profile field keys that settings overrides may target.
20
+ * Mirrors `HASHED_PROFILE_FIELDS` in src/profile.js, but as the allow-
21
+ * list for OVERRIDE values (os/arch/compiler/etc.). If a pattern
22
+ * override tries to set something else, reject at parse time rather
23
+ * than silently letting it through into mergeProfile.
24
+ */
25
+ const ALLOWED_SETTINGS_KEYS = new Set([
26
+ 'os', 'arch', 'compiler', 'compiler.version', 'compiler.cppstd', 'compiler.runtime',
27
+ ]);
28
+
29
+ /**
30
+ * Convert a glob pattern to a RegExp. Supports `*` (any chars),
31
+ * `?` (single char), literal everything else. No brace expansion,
32
+ * no negation — keep the mental model small.
33
+ *
34
+ * @param {string} glob
35
+ * @returns {RegExp}
36
+ */
37
+ function globToRegExp(glob) {
38
+ const escaped = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&');
39
+ const pattern = escaped.replace(/\*/g, '.*').replace(/\?/g, '.');
40
+ return new RegExp(`^${pattern}$`);
41
+ }
42
+
43
+ /**
44
+ * Specificity heuristic: fewer wildcards = more specific. Ties broken
45
+ * by longer literal prefix, then lexical order for determinism. More
46
+ * specific patterns get applied LAST so they override less-specific
47
+ * ones in the merge.
48
+ *
49
+ * @param {string} glob
50
+ * @returns {[number, number, string]} sort key — lower = less specific
51
+ */
52
+ function specificityKey(glob) {
53
+ const wildcards = (glob.match(/[*?]/g) ?? []).length;
54
+ const literalPrefix = glob.match(/^[^*?]*/)?.[0].length ?? 0;
55
+ // Invert sort order: more wildcards → lower score; more prefix → higher score.
56
+ return [-wildcards, literalPrefix, glob];
57
+ }
58
+
59
+ /**
60
+ * Validate + sort a raw `settingsOverrides` block. Returns an array of
61
+ * `{ pattern, regex, settings }` in application order (least-specific
62
+ * first). A string pattern alone (literal `"openssl"`) works too — it
63
+ * matches only that exact name.
64
+ *
65
+ * Throws on:
66
+ * - non-object block
67
+ * - non-string keys (JSON can't produce these but defensive)
68
+ * - non-object values
69
+ * - empty pattern strings
70
+ * - unknown settings keys in a value
71
+ * - non-string value leaves (settings values must be strings like
72
+ * the profile itself: "dynamic", "17", "gcc")
73
+ *
74
+ * @param {unknown} block
75
+ * @returns {Array<{ pattern: string, regex: RegExp, settings: Record<string, string> }>}
76
+ */
77
+ function normalizeSettingsOverrides(block) {
78
+ if (block === undefined || block === null) return [];
79
+ if (typeof block !== 'object' || Array.isArray(block)) {
80
+ throw new Error('settingsOverrides must be an object mapping globs to settings');
81
+ }
82
+
83
+ const entries = [];
84
+ for (const [rawPattern, rawValue] of Object.entries(block)) {
85
+ if (typeof rawPattern !== 'string' || rawPattern.length === 0) {
86
+ throw new Error(`settingsOverrides: pattern must be a non-empty string, got ${JSON.stringify(rawPattern)}`);
87
+ }
88
+ if (!rawValue || typeof rawValue !== 'object' || Array.isArray(rawValue)) {
89
+ throw new Error(`settingsOverrides["${rawPattern}"]: value must be an object of settings`);
90
+ }
91
+
92
+ const settings = {};
93
+ for (const [k, v] of Object.entries(rawValue)) {
94
+ if (!ALLOWED_SETTINGS_KEYS.has(k)) {
95
+ throw new Error(
96
+ `settingsOverrides["${rawPattern}"]: unknown key "${k}" — ` +
97
+ `allowed: ${[...ALLOWED_SETTINGS_KEYS].join(', ')}`,
98
+ );
99
+ }
100
+ if (typeof v !== 'string' || v.length === 0) {
101
+ throw new Error(
102
+ `settingsOverrides["${rawPattern}"].${k}: value must be a non-empty string, ` +
103
+ `got ${JSON.stringify(v)}`,
104
+ );
105
+ }
106
+ settings[k] = v;
107
+ }
108
+
109
+ entries.push({ pattern: rawPattern, regex: globToRegExp(rawPattern), settings });
110
+ }
111
+
112
+ // Sort by specificity ascending so more-specific patterns apply LAST
113
+ // (their settings therefore win on per-key merge).
114
+ entries.sort((a, b) => {
115
+ const ka = specificityKey(a.pattern);
116
+ const kb = specificityKey(b.pattern);
117
+ for (let i = 0; i < ka.length; i++) {
118
+ if (ka[i] < kb[i]) return -1;
119
+ if (ka[i] > kb[i]) return 1;
120
+ }
121
+ return 0;
122
+ });
123
+
124
+ return entries;
125
+ }
126
+
127
+ /**
128
+ * Merge all matching pattern overrides for `depName` into a flat
129
+ * settings object. Less-specific patterns apply first; more-specific
130
+ * patterns overlay on top. Does NOT include the literal per-dep
131
+ * `settings` (caller layers that on after — literal always wins).
132
+ *
133
+ * @param {string} depName
134
+ * @param {ReturnType<typeof normalizeSettingsOverrides>} normalized
135
+ * @returns {Record<string, string>}
136
+ */
137
+ function resolveOverrideSettings(depName, normalized) {
138
+ const out = {};
139
+ for (const entry of normalized) {
140
+ if (entry.regex.test(depName)) {
141
+ Object.assign(out, entry.settings);
142
+ }
143
+ }
144
+ return out;
145
+ }
146
+
147
+ module.exports = {
148
+ ALLOWED_SETTINGS_KEYS,
149
+ globToRegExp,
150
+ normalizeSettingsOverrides,
151
+ resolveOverrideSettings,
152
+ };
@@ -72,6 +72,20 @@ function isWyvrnGenerated(presets) {
72
72
  * so `cmake --preset` produces
73
73
  * binaries matching the profile
74
74
  * (VS otherwise defaults to x64).
75
+ * @param {string[]|null} [args.configs] Build recipe's `configs` list
76
+ * (e.g. ['Debug','Release','MinSizeRel']).
77
+ * When provided, drives
78
+ * `CMAKE_CONFIGURATION_TYPES` so
79
+ * multi-config generators (Ninja
80
+ * Multi-Config, VS) actually
81
+ * produce every config the recipe
82
+ * declares. Null/empty → omitted
83
+ * (let CMake use its generator
84
+ * default). `extraCacheVariables`
85
+ * wins on explicit collision so
86
+ * authors that already bake the
87
+ * list into `build.configure`
88
+ * aren't overridden.
75
89
  */
76
90
  function buildConfigurePreset({
77
91
  profileName,
@@ -81,16 +95,20 @@ function buildConfigurePreset({
81
95
  extraCacheVariables = null,
82
96
  installDir = null,
83
97
  profileArch = null,
98
+ configs = null,
84
99
  }) {
85
100
  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.
101
+ // CMAKE_CONFIGURATION_TYPES is a defensible default from the recipe's
102
+ // declared `configs` list. Applied FIRST (lowest precedence) so an
103
+ // explicit `-DCMAKE_CONFIGURATION_TYPES=...` in `build.configure`
104
+ // still wins authors who already pin the list shouldn't silently
105
+ // lose their override. The wyvrn-owned entries (toolchain, install
106
+ // prefix) still win over both below.
107
+ const configsDefault = (Array.isArray(configs) && configs.length > 0)
108
+ ? { CMAKE_CONFIGURATION_TYPES: configs.join(';') }
109
+ : {};
93
110
  const cacheVariables = {
111
+ ...configsDefault,
94
112
  ...(extraCacheVariables ?? {}),
95
113
  CMAKE_TOOLCHAIN_FILE: `\${sourceDir}/${toolchainRelPath}`,
96
114
  };
@@ -128,27 +146,32 @@ function buildConfigurePreset({
128
146
  }
129
147
 
130
148
  /**
131
- * Two build presets per configure preset: Debug and Release.
149
+ * One build preset per configuration the recipe declares (or the
150
+ * legacy default pair `Debug` + `Release` when no list is provided).
151
+ *
152
+ * The preset name suffix is the configuration name lower-cased — matches
153
+ * the previous Debug/Release naming for those two, and extends to
154
+ * `wyvrn-<profile>-relwithdebinfo` / `-minsizerel` for the multi-config
155
+ * cases. Authors whose recipes include `MinSizeRel` in `build.configs`
156
+ * can now `cmake --build --preset wyvrn-<profile>-minsizerel` directly.
132
157
  *
133
158
  * @param {string} profileName
159
+ * @param {string[]|null} [configs] Recipe's declared configurations. When
160
+ * null/empty, emits the pre-2.8.1 `Debug` + `Release` pair so existing
161
+ * CMakePresets.json files remain byte-identical.
134
162
  * @returns {Array<object>}
135
163
  */
136
- function buildBuildPresets(profileName) {
137
- const config = `wyvrn-${profileName}`;
138
- return [
139
- {
140
- name: `${config}-debug`,
141
- displayName: `wyvrn ${profileName} (Debug)`,
142
- configurePreset: config,
143
- configuration: 'Debug',
144
- },
145
- {
146
- name: `${config}-release`,
147
- displayName: `wyvrn ${profileName} (Release)`,
148
- configurePreset: config,
149
- configuration: 'Release',
150
- },
151
- ];
164
+ function buildBuildPresets(profileName, configs = null) {
165
+ const base = `wyvrn-${profileName}`;
166
+ const list = (Array.isArray(configs) && configs.length > 0)
167
+ ? configs
168
+ : ['Debug', 'Release'];
169
+ return list.map((cfg) => ({
170
+ name: `${base}-${cfg.toLowerCase()}`,
171
+ displayName: `wyvrn ${profileName} (${cfg})`,
172
+ configurePreset: base,
173
+ configuration: cfg,
174
+ }));
152
175
  }
153
176
 
154
177
  /**
@@ -162,6 +185,7 @@ function buildFreshPresetsFile({
162
185
  extraCacheVariables = null,
163
186
  installDir = null,
164
187
  profileArch = null,
188
+ configs = null,
165
189
  }) {
166
190
  return {
167
191
  version: SCHEMA_VERSION,
@@ -173,9 +197,9 @@ function buildFreshPresetsFile({
173
197
  },
174
198
  },
175
199
  configurePresets: [buildConfigurePreset({
176
- profileName, profileHash, toolchainRelPath, generator, extraCacheVariables, installDir, profileArch,
200
+ profileName, profileHash, toolchainRelPath, generator, extraCacheVariables, installDir, profileArch, configs,
177
201
  })],
178
- buildPresets: buildBuildPresets(profileName),
202
+ buildPresets: buildBuildPresets(profileName, configs),
179
203
  };
180
204
  }
181
205
 
@@ -197,18 +221,33 @@ function mergeIntoExisting(existing, {
197
221
  extraCacheVariables = null,
198
222
  installDir = null,
199
223
  profileArch = null,
224
+ configs = null,
200
225
  }) {
201
226
  const newConfigure = buildConfigurePreset({
202
- profileName, profileHash, toolchainRelPath, generator, extraCacheVariables, installDir, profileArch,
227
+ profileName, profileHash, toolchainRelPath, generator, extraCacheVariables, installDir, profileArch, configs,
203
228
  });
204
- const newBuilds = buildBuildPresets(profileName);
229
+ const newBuilds = buildBuildPresets(profileName, configs);
205
230
  const newConfigureNames = new Set([newConfigure.name]);
206
- const newBuildNames = new Set(newBuilds.map((b) => b.name));
231
+ // When replacing THIS profile's build presets, drop every pre-existing
232
+ // one that targets the same configurePreset — the fresh `newBuilds`
233
+ // list fully replaces them. Scoping to configurePreset avoids stepping
234
+ // on presets for OTHER profiles in the same file, which is the whole
235
+ // point of the merge path.
236
+ //
237
+ // Without this, editing `build.configs` from 4 configs down to 2 would
238
+ // leave orphan build presets pointing at configurations CMake won't
239
+ // generate.
240
+ const myBase = `wyvrn-${profileName}`;
241
+ const isMyBuildPreset = (p) => p && p.configurePreset === myBase;
207
242
 
208
243
  const configurePresets = (existing.configurePresets ?? []).filter((p) => !newConfigureNames.has(p.name));
209
244
  configurePresets.push(newConfigure);
210
245
 
211
- const buildPresets = (existing.buildPresets ?? []).filter((p) => !newBuildNames.has(p.name));
246
+ // Drop every pre-existing build preset that targets THIS profile's
247
+ // configurePreset — the fresh `newBuilds` list (one per current
248
+ // `configs` entry) fully replaces them. Presets for OTHER profiles
249
+ // stay untouched so the multi-profile merge story is preserved.
250
+ const buildPresets = (existing.buildPresets ?? []).filter((p) => !isMyBuildPreset(p));
212
251
  buildPresets.push(...newBuilds);
213
252
 
214
253
  return {
@@ -260,8 +299,9 @@ function generateCMakePresets({
260
299
  extraCacheVariables = null,
261
300
  installDir = null,
262
301
  profileArch = null,
302
+ configs = null,
263
303
  }) {
264
- const args = { profileName, profileHash, toolchainRelPath, generator, extraCacheVariables, installDir, profileArch };
304
+ const args = { profileName, profileHash, toolchainRelPath, generator, extraCacheVariables, installDir, profileArch, configs };
265
305
  const candidates = [
266
306
  { filePath: path.join(projectRoot, 'CMakePresets.json'), isUser: false },
267
307
  { filePath: path.join(projectRoot, 'CMakeUserPresets.json'), isUser: true },
@@ -7,6 +7,7 @@ const path = require('path');
7
7
  const { getProvider } = require('./providers');
8
8
  const { defaultCompatBlock } = require('./compat');
9
9
  const { getBuildPaths } = require('./build/cache');
10
+ const { resolveSourceAuth } = require('./auth');
10
11
  const log = require('./logger');
11
12
 
12
13
  const WYVRNPM_VERSION = require('../package.json').version;
@@ -193,6 +194,7 @@ function resolveUploadDestination(argv, config) {
193
194
  let src = argv.uploadSource;
194
195
  let awsProfile = argv.awsProfile;
195
196
  let token = argv.token;
197
+ let sourceEntry = null;
196
198
 
197
199
  const publishSources = config.publishSources ?? [];
198
200
 
@@ -202,17 +204,24 @@ function resolveUploadDestination(argv, config) {
202
204
  if (named) {
203
205
  src = named.url;
204
206
  awsProfile = awsProfile ?? named.profile;
205
- token = token ?? named.token;
207
+ sourceEntry = named;
206
208
  log.info(`upload-built: using publish source "${named.name}" from config`);
207
209
  }
208
210
  } else if (publishSources.length > 0) {
209
211
  const first = publishSources[0];
210
212
  src = first.url;
211
213
  awsProfile = awsProfile ?? first.profile;
212
- token = token ?? first.token;
214
+ sourceEntry = first;
213
215
  log.info(`upload-built: using first configured publish source "${first.name}"`);
214
216
  }
215
217
 
218
+ // Fall back to the matched source entry's own token / tokenEnv only if
219
+ // the caller didn't pass one already (argv.token wins). resolveSourceAuth
220
+ // handles the literal-vs-env-backed precedence per side.
221
+ if (token === undefined || token === null) {
222
+ token = resolveSourceAuth({}, sourceEntry).token;
223
+ }
224
+
216
225
  if (!src) return null;
217
226
  return { uploadSource: src, uploadAuth: { awsProfile, token } };
218
227
  }
@@ -0,0 +1,52 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Defense-in-depth against Zip Slip (CVE-2018-1002200 family). Both
5
+ * node-stream-zip and adm-zip claim to handle this upstream, but we
6
+ * don't want to take the library's word for it on a supply-chain-
7
+ * critical code path. Pre-scan zip entry names before extraction and
8
+ * reject anything that could write outside the target directory.
9
+ *
10
+ * The ZIP spec uses forward slashes only, but Windows-produced archives
11
+ * sometimes leak backslashes — normalise both.
12
+ *
13
+ * @param {string} entryName zip-internal path as reported by the zip library
14
+ * @param {string} [context] optional archive identifier for the error message
15
+ * @throws {Error} if the entry name is absolute, contains a path-traversal
16
+ * segment, or starts with a Windows drive-letter prefix.
17
+ */
18
+ function assertSafeZipEntryName(entryName, context) {
19
+ const where = context ? ` in ${context}` : '';
20
+
21
+ if (/^[A-Za-z]:[\\/]/.test(entryName)) {
22
+ throw new Error(`unsafe zip entry${where}: drive-letter absolute path ${JSON.stringify(entryName)}`);
23
+ }
24
+ if (entryName.startsWith('/') || entryName.startsWith('\\')) {
25
+ throw new Error(`unsafe zip entry${where}: absolute path ${JSON.stringify(entryName)}`);
26
+ }
27
+
28
+ const segments = entryName.split(/[\\/]+/);
29
+ for (const seg of segments) {
30
+ if (seg === '..') {
31
+ throw new Error(`unsafe zip entry${where}: path-traversal segment in ${JSON.stringify(entryName)}`);
32
+ }
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Convenience: validate every name in an iterable. Throws on the first
38
+ * offender (fail-fast) rather than collecting all violations.
39
+ *
40
+ * @param {Iterable<string>} names
41
+ * @param {string} [context]
42
+ */
43
+ function assertAllSafeZipEntryNames(names, context) {
44
+ for (const name of names) {
45
+ assertSafeZipEntryName(name, context);
46
+ }
47
+ }
48
+
49
+ module.exports = {
50
+ assertSafeZipEntryName,
51
+ assertAllSafeZipEntryNames,
52
+ };