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.
- package/README.md +257 -1
- package/bin/wyvrn.js +131 -14
- package/claude/skills/wyvrnpm.skill +0 -0
- package/package.json +2 -1
- package/src/build/cache.js +47 -0
- package/src/build/index.js +42 -11
- package/src/build/recipe.js +30 -4
- package/src/commands/build.js +49 -8
- package/src/commands/clean.js +39 -15
- package/src/commands/install-skill.js +107 -0
- package/src/commands/install.js +201 -5
- package/src/commands/publish.js +150 -35
- package/src/commands/show.js +237 -0
- package/src/compat.js +32 -3
- package/src/download.js +65 -3
- package/src/ignore.js +118 -0
- package/src/logger.js +26 -10
- package/src/manifest.js +12 -7
- package/src/options.js +303 -0
- package/src/profile.js +28 -4
- package/src/providers/base.js +16 -1
- package/src/providers/file.js +8 -3
- package/src/providers/http.js +12 -8
- package/src/providers/s3.js +11 -7
- package/src/resolve.js +171 -13
- package/src/toolchain/presets.js +88 -16
- package/src/upload-built.js +256 -0
- package/src/version-range.js +301 -0
|
@@ -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
|
+
};
|