wyvrnpm 2.1.0 → 2.3.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.
@@ -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
+ };
@@ -0,0 +1,301 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Version range parsing and matching for 4-part `major.minor.patch.build`
5
+ * versions.
6
+ *
7
+ * Accepts three range syntaxes in addition to the existing exact and
8
+ * `"latest"` strings:
9
+ *
10
+ * "^1.2.3.4" major-pinned : >= 1.2.3.4 < 2.0.0.0
11
+ * "~1.2.3.4" minor-pinned : >= 1.2.3.4 < 1.3.0.0
12
+ * ">=1.2.3.4 <2.0" explicit : one or more space-separated comparators
13
+ * among >=, >, <=, <, =
14
+ *
15
+ * `parseRange(s)` classifies the input; `matchesRange(v, parsed)`
16
+ * evaluates a candidate version; `pickHighestInRange(versions, parsed)`
17
+ * selects the best match; `intersectRanges(a, b)` combines two ranges
18
+ * (returns null when the intersection is empty).
19
+ *
20
+ * See claude/PLAN-VERSION-RANGES.md for the full design.
21
+ */
22
+
23
+ const VERSION_RE = /^\d+\.\d+\.\d+\.\d+$/;
24
+
25
+ /**
26
+ * Compare two 4-part versions numerically. Missing trailing components
27
+ * default to 0 so `compareVersions("1.2", "1.2.0.0") === 0`, though the
28
+ * parser elsewhere always emits fully-qualified 4-part strings.
29
+ *
30
+ * @param {string} a
31
+ * @param {string} b
32
+ * @returns {-1|0|1}
33
+ */
34
+ function compareVersions(a, b) {
35
+ const partsOf = (s) => s.split('.').map((n) => parseInt(n, 10) || 0);
36
+ const pa = partsOf(a);
37
+ const pb = partsOf(b);
38
+ const len = Math.max(pa.length, pb.length);
39
+ for (let i = 0; i < len; i++) {
40
+ const x = pa[i] ?? 0;
41
+ const y = pb[i] ?? 0;
42
+ if (x < y) return -1;
43
+ if (x > y) return 1;
44
+ }
45
+ return 0;
46
+ }
47
+
48
+ function bumpMajor(v) {
49
+ const [maj] = v.split('.').map((n) => parseInt(n, 10) || 0);
50
+ return `${maj + 1}.0.0.0`;
51
+ }
52
+
53
+ function bumpMinor(v) {
54
+ const [maj, min] = v.split('.').map((n) => parseInt(n, 10) || 0);
55
+ return `${maj}.${min + 1}.0.0`;
56
+ }
57
+
58
+ /**
59
+ * Parse a dependency version string into a structured spec. Exact versions
60
+ * and `"latest"` pass through unchanged; `^` / `~` / comparator strings
61
+ * become `{ kind: 'range', min, max }` specs.
62
+ *
63
+ * `min` and `max` are each `{ op: '>=' | '>' | null, v: string } | null`
64
+ * (null means unbounded on that side — an open upper bound, etc.).
65
+ *
66
+ * Throws on malformed input with a user-friendly multi-line error that
67
+ * lists the supported syntaxes.
68
+ *
69
+ * @param {string} spec
70
+ * @returns {{ kind: 'exact', version: string }
71
+ * | { kind: 'tag', tag: 'latest' }
72
+ * | { kind: 'range', raw: string,
73
+ * min: { op: '>='|'>', v: string } | null,
74
+ * max: { op: '<='|'<', v: string } | null,
75
+ * exact?: string }}
76
+ */
77
+ function parseRange(spec) {
78
+ if (typeof spec !== 'string' || spec.length === 0) {
79
+ throw new Error(malformedRangeMessage(spec, 'version spec must be a non-empty string'));
80
+ }
81
+ const s = spec.trim();
82
+
83
+ if (s === 'latest') return { kind: 'tag', tag: 'latest' };
84
+
85
+ // `linked:` is handled by resolve.js directly — not our concern.
86
+ if (s.startsWith('linked:')) return { kind: 'linked', raw: s };
87
+
88
+ if (VERSION_RE.test(s)) return { kind: 'exact', version: s };
89
+
90
+ // Caret / tilde — require a full 4-part version as the operand.
91
+ if (s[0] === '^' || s[0] === '~') {
92
+ const op = s[0];
93
+ const v = s.slice(1).trim();
94
+ if (!VERSION_RE.test(v)) {
95
+ throw new Error(malformedRangeMessage(spec, `${op} prefix requires a 4-part version`));
96
+ }
97
+ return {
98
+ kind: 'range',
99
+ raw: s,
100
+ min: { op: '>=', v },
101
+ max: { op: '<', v: op === '^' ? bumpMajor(v) : bumpMinor(v) },
102
+ };
103
+ }
104
+
105
+ // Explicit comparator form: one or more of {>=, >, <=, <, =} each followed
106
+ // by a 4-part version, space-separated.
107
+ if (/^[<>=]/.test(s)) {
108
+ const tokens = s.split(/\s+/);
109
+ let min = null;
110
+ let max = null;
111
+ let exact = null;
112
+ for (const tok of tokens) {
113
+ const m = tok.match(/^(>=|<=|>|<|=)(\d+\.\d+\.\d+\.\d+)$/);
114
+ if (!m) {
115
+ throw new Error(malformedRangeMessage(spec, `unrecognised comparator "${tok}"`));
116
+ }
117
+ const [, op, v] = m;
118
+ if (op === '=') {
119
+ if (exact && exact !== v) {
120
+ throw new Error(malformedRangeMessage(spec, `conflicting = comparators (${exact} vs ${v})`));
121
+ }
122
+ exact = v;
123
+ } else if (op === '>=' || op === '>') {
124
+ if (!min || compareVersions(v, min.v) > 0 || (compareVersions(v, min.v) === 0 && op === '>')) {
125
+ min = { op, v };
126
+ }
127
+ } else {
128
+ if (!max || compareVersions(v, max.v) < 0 || (compareVersions(v, max.v) === 0 && op === '<')) {
129
+ max = { op, v };
130
+ }
131
+ }
132
+ }
133
+ if (exact) return { kind: 'exact', version: exact };
134
+ if (!min && !max) {
135
+ throw new Error(malformedRangeMessage(spec, 'no comparators parsed'));
136
+ }
137
+ return { kind: 'range', raw: s, min, max };
138
+ }
139
+
140
+ throw new Error(malformedRangeMessage(spec, 'unknown version syntax'));
141
+ }
142
+
143
+ function malformedRangeMessage(spec, why) {
144
+ return (
145
+ `cannot parse dependency version ${JSON.stringify(spec)}: ${why}\n` +
146
+ ` supported syntaxes:\n` +
147
+ ` "1.2.3.4" exact version\n` +
148
+ ` "latest" latest-published tag\n` +
149
+ ` "^1.2.3.4" major-pinned range (>= 1.2.3.4 < 2.0.0.0)\n` +
150
+ ` "~1.2.3.4" minor-pinned range (>= 1.2.3.4 < 1.3.0.0)\n` +
151
+ ` ">=1.2.3.4 <2.0.0.0" explicit range (>=, >, <=, <, = separated by spaces)`
152
+ );
153
+ }
154
+
155
+ /**
156
+ * Returns true if `version` satisfies every bound in `parsed`. Exact-kind
157
+ * specs require byte-equal; tag/linked specs always return false here
158
+ * because they are handled upstream.
159
+ *
160
+ * @param {string} version
161
+ * @param {ReturnType<typeof parseRange>} parsed
162
+ * @returns {boolean}
163
+ */
164
+ function matchesRange(version, parsed) {
165
+ if (!parsed) return false;
166
+ if (parsed.kind === 'exact') return version === parsed.version;
167
+ if (parsed.kind !== 'range') return false;
168
+ if (parsed.min) {
169
+ const cmp = compareVersions(version, parsed.min.v);
170
+ if (parsed.min.op === '>=' && cmp < 0) return false;
171
+ if (parsed.min.op === '>' && cmp <= 0) return false;
172
+ }
173
+ if (parsed.max) {
174
+ const cmp = compareVersions(version, parsed.max.v);
175
+ if (parsed.max.op === '<=' && cmp > 0) return false;
176
+ if (parsed.max.op === '<' && cmp >= 0) return false;
177
+ }
178
+ return true;
179
+ }
180
+
181
+ /**
182
+ * Pick the highest version from `publishedVersions` that satisfies
183
+ * `parsed`. Returns null when no version matches. Input is not mutated.
184
+ *
185
+ * @param {string[]} publishedVersions
186
+ * @param {ReturnType<typeof parseRange>} parsed
187
+ * @returns {string|null}
188
+ */
189
+ function pickHighestInRange(publishedVersions, parsed) {
190
+ if (!Array.isArray(publishedVersions) || publishedVersions.length === 0) return null;
191
+ const matches = publishedVersions.filter((v) => matchesRange(v, parsed));
192
+ if (matches.length === 0) return null;
193
+ matches.sort(compareVersions);
194
+ return matches[matches.length - 1];
195
+ }
196
+
197
+ /**
198
+ * Intersect two parsed range specs. Exact specs are treated as the degenerate
199
+ * range `[v, v]`. Returns a new range spec or null when the intersection is
200
+ * empty.
201
+ *
202
+ * `tag` and `linked` kinds cannot participate in intersection — the caller
203
+ * must resolve them to exact first. We throw rather than silently ignore.
204
+ *
205
+ * @param {ReturnType<typeof parseRange>} a
206
+ * @param {ReturnType<typeof parseRange>} b
207
+ * @returns {{ kind: 'range'|'exact', ... }|null}
208
+ */
209
+ function intersectRanges(a, b) {
210
+ const liftExact = (spec) => spec.kind === 'exact'
211
+ ? { kind: 'range', raw: spec.version, min: { op: '>=', v: spec.version }, max: { op: '<=', v: spec.version } }
212
+ : spec;
213
+
214
+ if (!a || !b) throw new Error('intersectRanges: null spec');
215
+ if (a.kind === 'tag' || a.kind === 'linked' || b.kind === 'tag' || b.kind === 'linked') {
216
+ throw new Error('intersectRanges: tag/linked must be resolved before intersection');
217
+ }
218
+
219
+ const ra = liftExact(a);
220
+ const rb = liftExact(b);
221
+
222
+ // Tighter min (higher value; strictly-greater beats equal under gte).
223
+ const tighterMin = pickTighter(ra.min, rb.min, 'min');
224
+ // Tighter max (lower value; strictly-less beats equal under lte).
225
+ const tighterMax = pickTighter(ra.max, rb.max, 'max');
226
+
227
+ if (tighterMin && tighterMax) {
228
+ const cmp = compareVersions(tighterMin.v, tighterMax.v);
229
+ if (cmp > 0) return null;
230
+ if (cmp === 0) {
231
+ // Both bounds reference the same version. Feasible only if both are
232
+ // inclusive; otherwise the point is excluded.
233
+ if (tighterMin.op !== '>=' || tighterMax.op !== '<=') return null;
234
+ return { kind: 'exact', version: tighterMin.v };
235
+ }
236
+ }
237
+
238
+ return {
239
+ kind: 'range',
240
+ raw: `${ra.raw ?? 'range'} ∩ ${rb.raw ?? 'range'}`,
241
+ min: tighterMin,
242
+ max: tighterMax,
243
+ };
244
+ }
245
+
246
+ function pickTighter(a, b, kind) {
247
+ if (!a) return b;
248
+ if (!b) return a;
249
+ const cmp = compareVersions(a.v, b.v);
250
+ if (kind === 'min') {
251
+ if (cmp > 0) return a;
252
+ if (cmp < 0) return b;
253
+ // equal: strictly-greater is tighter than gte
254
+ return a.op === '>' ? a : b;
255
+ } else {
256
+ if (cmp < 0) return a;
257
+ if (cmp > 0) return b;
258
+ return a.op === '<' ? a : b;
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Human-readable rendering for error messages. Exact and tag kinds pass
264
+ * through unchanged; ranges render as the original raw string when
265
+ * available, else as a comparator reconstruction.
266
+ *
267
+ * @param {ReturnType<typeof parseRange>} parsed
268
+ * @returns {string}
269
+ */
270
+ function rangeToString(parsed) {
271
+ if (!parsed) return '(unparsed)';
272
+ if (parsed.kind === 'exact') return parsed.version;
273
+ if (parsed.kind === 'tag') return parsed.tag;
274
+ if (parsed.kind === 'linked') return parsed.raw;
275
+ if (parsed.raw && !parsed.raw.includes('∩')) return parsed.raw;
276
+ const parts = [];
277
+ if (parsed.min) parts.push(`${parsed.min.op}${parsed.min.v}`);
278
+ if (parsed.max) parts.push(`${parsed.max.op}${parsed.max.v}`);
279
+ return parts.join(' ');
280
+ }
281
+
282
+ /**
283
+ * Convenience predicate used by the resolver: does this spec require
284
+ * range-based resolution (as opposed to exact / latest / linked)?
285
+ */
286
+ function isRangeSpec(spec) {
287
+ try {
288
+ const parsed = parseRange(spec);
289
+ return parsed.kind === 'range';
290
+ } catch { return false; }
291
+ }
292
+
293
+ module.exports = {
294
+ parseRange,
295
+ matchesRange,
296
+ pickHighestInRange,
297
+ intersectRanges,
298
+ rangeToString,
299
+ isRangeSpec,
300
+ compareVersions,
301
+ };