1interface ParsedSemver {
2 readonly major: number;
3 readonly minor: number;
4 readonly patch: number;
5 readonly prerelease: ReadonlyArray<string>;
6}
7
8const SEMVER_NUMBER_SEGMENT = /^\d+$/;
9
10export function normalizeSemverVersion(version: string): string {
11 const [main, prerelease] = version.trim().split("-", 2);
12 const segments: string[] = [];
13 for (const segment of (main ?? "").split(".")) {
14 const trimmed = segment.trim();
15 if (trimmed.length > 0) {
16 segments.push(trimmed);
17 }
18 }
19
20 // Pad shorthand versions ("20" or "20.1") up to three segments so major-only
21 // and minor-only inputs parse and compare numerically. This matches
22 // satisfiesSemverRange, which already treats a missing minor/patch as 0. The
23 // length > 0 guard keeps empty/garbage input empty (parseSemver still
24 // rejects it), and inputs with more than three segments are left untouched.
25 while (segments.length > 0 && segments.length < 3) {
26 segments.push("0");
27 }
28
29 return prerelease ? `${segments.join(".")}-${prerelease}` : segments.join(".");
30}
31
32export function parseSemver(value: string): ParsedSemver | null {
33 const normalized = normalizeSemverVersion(value).replace(/^v/, "");
34 const [main = "", prerelease] = normalized.split("-", 2);
35 const segments = main.split(".");
36 if (segments.length !== 3) {
37 return null;
38 }
39
40 const [majorSegment, minorSegment, patchSegment] = segments;
41 if (majorSegment === undefined || minorSegment === undefined || patchSegment === undefined) {
42 return null;
43 }
44 if (
45 !SEMVER_NUMBER_SEGMENT.test(majorSegment) ||
46 !SEMVER_NUMBER_SEGMENT.test(minorSegment) ||
47 !SEMVER_NUMBER_SEGMENT.test(patchSegment)
48 ) {
49 return null;
50 }
51
52 const major = Number.parseInt(majorSegment, 10);
53 const minor = Number.parseInt(minorSegment, 10);
54 const patch = Number.parseInt(patchSegment, 10);
55 if (![major, minor, patch].every(Number.isInteger)) {
56 return null;
57 }
58
59 return {
60 major,
61 minor,
62 patch,
63 prerelease: parsePrereleaseSegments(prerelease),
64 };
65}
66
67function parsePrereleaseSegments(prerelease: string | undefined): ReadonlyArray<string> {
68 if (prerelease === undefined) {
69 return [];
70 }
71 const segments: string[] = [];
72 for (const segment of prerelease.split(".")) {
73 const trimmed = segment.trim();
74 if (trimmed.length > 0) {
75 segments.push(trimmed);
76 }
77 }
78 return segments;
79}
80
81function comparePrereleaseIdentifier(left: string, right: string): number {
82 const leftNumeric = SEMVER_NUMBER_SEGMENT.test(left);
83 const rightNumeric = SEMVER_NUMBER_SEGMENT.test(right);
84
85 if (leftNumeric && rightNumeric) {
86 return Number.parseInt(left, 10) - Number.parseInt(right, 10);
87 }
88 if (leftNumeric) {
89 return -1;
90 }
91 if (rightNumeric) {
92 return 1;
93 }
94 return left.localeCompare(right);
95}
96
97export function compareSemverVersions(left: string, right: string): number {
98 const parsedLeft = parseSemver(left);
99 const parsedRight = parseSemver(right);
100 if (!parsedLeft || !parsedRight) {
101 return left.localeCompare(right);
102 }
103
104 if (parsedLeft.major !== parsedRight.major) {
105 return parsedLeft.major - parsedRight.major;
106 }
107 if (parsedLeft.minor !== parsedRight.minor) {
108 return parsedLeft.minor - parsedRight.minor;
109 }
110 if (parsedLeft.patch !== parsedRight.patch) {
111 return parsedLeft.patch - parsedRight.patch;
112 }
113
114 if (parsedLeft.prerelease.length === 0 && parsedRight.prerelease.length === 0) {
115 return 0;
116 }
117 if (parsedLeft.prerelease.length === 0) {
118 return 1;
119 }
120 if (parsedRight.prerelease.length === 0) {
121 return -1;
122 }
123
124 const length = Math.max(parsedLeft.prerelease.length, parsedRight.prerelease.length);
125 for (let index = 0; index < length; index += 1) {
126 const leftIdentifier = parsedLeft.prerelease[index];
127 const rightIdentifier = parsedRight.prerelease[index];
128 if (leftIdentifier === undefined) {
129 return -1;
130 }
131 if (rightIdentifier === undefined) {
132 return 1;
133 }
134 const comparison = comparePrereleaseIdentifier(leftIdentifier, rightIdentifier);
135 if (comparison !== 0) {
136 return comparison;
137 }
138 }
139
140 return 0;
141}
142
143/**
144 * Small semver range checker for CLI/runtime gates.
145 *
146 * Keep the function body valid plain JavaScript: SSH startup stringifies this
147 * function and runs it on remote Node versions before TypeScript support is known.
148 *
149 * @param rawVersion Version string, with or without a leading `v`.
150 * @param range Space-separated comparators, with `||` range groups.
151 * @returns Whether `rawVersion` satisfies the supported range syntax.
152 */
153export const satisfiesSemverRange: (rawVersion: string, range: string) => boolean =
154 function satisfiesSemverRange(rawVersion, range) {
155 const normalizedVersion = String(rawVersion).trim().replace(/^v/, "");
156 const versionMatch = normalizedVersion.match(
157 /^(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-[0-9A-Za-z.-]+)?$/,
158 );
159 if (!versionMatch) {
160 return false;
161 }
162
163 const version = {
164 major: Number(versionMatch[1]),
165 minor: Number(versionMatch[2] || 0),
166 patch: Number(versionMatch[3] || 0),
167 };
168
169 return range.split("||").some((group) => {
170 const comparators = group.trim().split(/\s+/).filter(Boolean);
171 if (comparators.length === 0) {
172 return false;
173 }
174 return comparators.every((comparator) => {
175 const match = comparator.trim().match(/^(\^|>=|>|<=|<|=)?\s*v?(\d+(?:\.\d+){0,2})$/);
176 if (!match) {
177 return false;
178 }
179 const targetVersion = match[2];
180 if (targetVersion === undefined) {
181 return false;
182 }
183 const targetMatch = targetVersion.match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?$/);
184 if (!targetMatch) {
185 return false;
186 }
187 const target = {
188 major: Number(targetMatch[1]),
189 minor: Number(targetMatch[2] || 0),
190 patch: Number(targetMatch[3] || 0),
191 };
192 const compared =
193 version.major !== target.major
194 ? version.major > target.major
195 ? 1
196 : -1
197 : version.minor !== target.minor
198 ? version.minor > target.minor
199 ? 1
200 : -1
201 : version.patch !== target.patch
202 ? version.patch > target.patch
203 ? 1
204 : -1
205 : 0;
206 const operator = match[1] || "=";
207 switch (operator) {
208 case "^":
209 if (compared < 0) {
210 return false;
211 }
212 if (target.major > 0) {
213 return version.major === target.major;
214 }
215 if (target.minor > 0) {
216 return version.major === 0 && version.minor === target.minor;
217 }
218 return version.major === 0 && version.minor === 0 && version.patch === target.patch;
219 case ">=":
220 return compared >= 0;
221 case ">":
222 return compared > 0;
223 case "<=":
224 return compared <= 0;
225 case "<":
226 return compared < 0;
227 case "=":
228 return compared === 0;
229 default:
230 return false;
231 }
232 });
233 });
234 };