fix: standardize version parsing and simplify string escaping

This commit is contained in:
手瓜一十雪
2026-10-18 13:00:39 +08:00
parent 4e2b6b1200
commit 38a7c3da63
23 changed files with 245 additions and 137 deletions
+1
View File
@@ -36,6 +36,7 @@ This is a pnpm workspace. Package ownership matters more than directory names:
[The entry point](packages/core/src/index.ts) applies pending updates before starting runtime services. [The updater](packages/core/src/update) owns staging and replacement; [Core's build configuration](packages/core/vite.config.ts) emits the release manifest from its build outputs and runtime assets. Keep Core in one bundle and native libraries lazy-loaded so startup can recover an interrupted update before opening native files.
Application versions come from the root `package.json` through `@snowluma/common/version`. Use `pnpm bump <version>` to update workspace manifests, or `pnpm bump` to sync them after editing the root version.
Use `@snowluma/common/semver` for application version validation and comparison. Versions must be canonical SemVer; strip GitHub's `v` tag prefix only at the API boundary.
Read the relevant owner before changing a path:
+1
View File
@@ -54,6 +54,7 @@ SnowLuma 是面向 QQ 客户端的 TypeScript 互操作运行时,将 QQ 原生
3. 打开 [`http://localhost:5099`](http://localhost:5099),使用启动日志中的初始密码登录 WebUI,按引导接入 QQ 进程并配置 OneBot 连接。
在「设置 → 关于 → 软件更新」下载新版本,再点「重启更新」。文件优先暂存于系统临时目录,不可用时使用安装目录的 `tmp/update`,下次启动时替换并清理,配置、日志与消息数据保留。更新沿用当前 Node.js;容器部署请更新镜像。
版本号遵循 [SemVer](https://semver.org/),在线更新仅安装正式版;构建元数据不影响版本排序。
## 接入与文档
+1 -4
View File
@@ -6,10 +6,7 @@ import { defineConfig, globalIgnores } from 'eslint/config';
import globals from 'globals';
import tseslint from 'typescript-eslint';
const tsconfigRootDir = decodeURIComponent(new URL('.', import.meta.url).pathname).replace(
/^\/(.:\/)/,
'$1',
);
const tsconfigRootDir = import.meta.dirname;
export const sourceIgnores = globalIgnores([
'**/dist/**',
+7 -1
View File
@@ -8,6 +8,10 @@
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
"./semver": {
"types": "./src/semver.ts",
"default": "./src/semver.ts"
},
"./version": {
"types": "./src/version.ts",
"default": "./src/version.ts"
@@ -95,13 +99,15 @@
},
"devDependencies": {
"@types/node": "catalog:",
"@types/semver": "7.7.1",
"typescript": "catalog:",
"vitest": "^4.1.4"
},
"dependencies": {
"@zxcvbn-ts/core": "^4.1.2",
"@zxcvbn-ts/language-common": "^4.1.3",
"@zxcvbn-ts/language-en": "^4.1.1"
"@zxcvbn-ts/language-en": "^4.1.1",
"semver": "7.7.4"
},
"engines": {
"node": ">=24"
+28
View File
@@ -0,0 +1,28 @@
import parse from 'semver/functions/parse.js';
/** Parse a canonical SemVer string; tag prefixes belong at the GitHub boundary. */
export function parseVersion(value: unknown) {
if (typeof value !== 'string') {
return null;
}
const parsed = parse(value);
if (!parsed) {
return null;
}
const canonical = parsed.version + (parsed.build.length ? `+${parsed.build.join('.')}` : '');
return value === canonical ? parsed : null;
}
export function compareVersions(a: string, b: string): number {
const left = parseVersion(a);
const right = parseVersion(b);
if (!left || !right) {
throw new TypeError('Invalid semantic version');
}
return left.compare(right);
}
+73
View File
@@ -0,0 +1,73 @@
import { describe, expect, it } from 'vitest';
import { compareVersions, parseVersion } from '../src/semver';
describe('parseVersion', () => {
it.each([
'0.0.0',
'1.2.3',
'1.2.3-rc.1',
'1.2.3-alpha-beta',
'1.2.3+001.build',
'1.2.3-rc.1+build.2',
])('accepts canonical SemVer and preserves its full value: %s', (version) => {
expect(parseVersion(version)?.raw).toBe(version);
});
it.each([
undefined,
null,
123,
{},
'',
'1.2',
'1.2.3.4',
'1.2.x',
'01.2.3',
'1.02.3',
'1.2.03',
'1.2.3-01',
'1.2.3-rc.01',
'1.2.3-rc..1',
'1.2.3-rc_1',
'1.2.3+build..1',
'1.2.3+build_1',
'v1.2.3',
'V1.2.3',
'=1.2.3',
' 1.2.3',
'1.2.3\n',
])('rejects noncanonical input without coercion: %j', (version) => {
expect(parseVersion(version)).toBeNull();
});
});
describe('compareVersions', () => {
it.each([
['1.2.3', '1.2.3', 0],
['1.2.4', '1.2.3', 1],
['1.2.3', '1.2.4', -1],
['1.10.0', '1.9.0', 1],
['2.0.0', '1.9.9', 1],
['3.0.0', '1.0.0', 1],
['1.0.0', '3.0.0', -1],
['1.2.3', '1.2.3-rc.1', 1],
['1.2.3-rc.1', '1.2.3', -1],
['1.2.3-rc.1', '1.2.3-rc.1', 0],
['1.2.3-rc.1', '1.2.3-rc.2', -1],
['1.2.3-rc.2', '1.2.3-rc.1', 1],
['1.2.3-rc.10', '1.2.3-rc.2', 1],
['1.2.3-rc.1+build.2', '1.2.3-rc.1+build.1', 0],
['1.2.3-alpha-beta', '1.2.3-alpha', 1],
['2.0.0-rc.1', '1.9.9', 1],
] as const)('compareVersions(%j, %j) === %s', (a, b, expected) => {
expect(compareVersions(a, b)).toBe(expected);
});
it.each(['1.2', '1.2.3.9', '1.2.x', '', 'v1.2.3', 'V1.2.3', '01.2.3', '1.2.3-rc.01'])(
'rejects invalid versions instead of guessing: %j',
(version) => {
expect(() => compareVersions(version, '1.2.3')).toThrow();
expect(() => compareVersions('1.2.3', version)).toThrow();
},
);
});
+2 -11
View File
@@ -94,16 +94,7 @@ export function defaultNotificationsConfig(): NotificationsConfig {
// ─── Template renderer ──────────────────────────────────────────────────────
/**
* Mechanical `{key}` substitution — no logic, no conditionals, no escaping.
* A key present in `vars` is replaced by its value; an unknown `{key}` is left
* untouched (原样) so a typo'd placeholder is visible rather than silently
* blanked. Pure + total (a non-string template yields '').
*
* **JSON safety**: When the template is valid JSON (parseable by JSON.parse),
* values are escaped to prevent breaking the JSON structure — backslashes
* become `\\` and double-quotes become `\"`.
*/
/** Preserve unknown placeholders; escape substituted strings in JSON templates. */
export function renderTemplate(template: string, vars: Record<string, string>): string {
if (typeof template !== 'string') {
return '';
@@ -124,7 +115,7 @@ export function renderTemplate(template: string, vars: Record<string, string>):
}
const val = vars[key];
return isJson ? val.replace(/\\/g, '\\\\').replace(/"/g, '\\"') : val;
return isJson ? JSON.stringify(val).slice(1, -1) : val;
});
}
+8 -45
View File
@@ -1,4 +1,5 @@
import { APP_VERSION } from '@snowluma/common/version';
import { compareVersions, parseVersion } from '@snowluma/common/semver';
import { createLogger } from '@snowluma/common/logger';
import { createSingleFlightCache } from '@snowluma/common/single-flight-cache';
@@ -40,49 +41,6 @@ function isEnabled(): boolean {
return v !== '0' && v !== 'false' && v !== 'off' && v !== 'no';
}
/**
* Compare two dotted versions. Returns >0 if `a` is newer than `b`, <0 if
* older, 0 if equal. The numeric core (major.minor.patch) compares
* numerically; a prerelease (`-rc.1`) ranks below its release. Good enough
* for "is the latest stable strictly newer than what we run" — and avoids
* pulling in a `semver` runtime dependency (the dist bundle ships none).
*/
export function compareVersions(a: string, b: string): number {
const parse = (v: string) => {
const [core = '', pre = ''] = v.replace(/^v/, '').split('-', 2);
const nums = core.split('.').map((n) => parseInt(n, 10) || 0);
while (nums.length < 3) {
nums.push(0);
}
return { nums, pre };
};
const pa = parse(a);
const pb = parse(b);
for (let i = 0; i < 3; i++) {
if (pa.nums[i] !== pb.nums[i]) {
return pa.nums[i] - pb.nums[i];
}
}
if (pa.pre === pb.pre) {
return 0;
}
if (!pa.pre) {
return 1;
} // release > prerelease
if (!pb.pre) {
return -1;
}
return pa.pre < pb.pre ? -1 : 1;
}
// TTL + single-flight: cache good results for CACHE_TTL_MS (well under GitHub's
// 60 req/hr limit) and collapse concurrent checks onto one fetch. Error results
// are intentionally not cached (shouldCache) so the next check retries.
@@ -130,13 +88,18 @@ async function fetchLatest(current: string): Promise<UpdateCheckResult> {
}
const rel = (await res.json()) as GithubRelease;
const tag = (rel.tag_name ?? '').trim();
const tag = typeof rel.tag_name === 'string' ? rel.tag_name.trim() : '';
if (!tag) {
return { ...base, error: 'no tag' };
}
const latest = tag.replace(/^v/, '');
const latest = tag.startsWith('v') ? tag.slice(1) : tag;
if (!parseVersion(latest)) {
return { ...base, error: 'invalid release version' };
}
return {
current,
latest,
+2 -1
View File
@@ -3,6 +3,7 @@ import path from 'node:path';
import { createHash } from 'node:crypto';
import { constants, existsSync } from 'node:fs';
import os from 'node:os';
import { parseVersion } from '@snowluma/common/semver';
export const MANIFEST_FILE = 'release.json';
@@ -86,7 +87,7 @@ export async function readManifest(root: string): Promise<ReleaseManifest> {
typeof value !== 'object' ||
!('version' in value) ||
typeof value.version !== 'string' ||
!/^\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?$/.test(value.version) ||
!parseVersion(value.version) ||
!('target' in value) ||
typeof value.target !== 'string' ||
!/^(win32-x64|linux-(x64|arm64))$/.test(value.target) ||
+7 -9
View File
@@ -2,7 +2,7 @@ import { existsSync } from 'node:fs';
import { mkdir, rename, rm } from 'node:fs/promises';
import path from 'node:path';
import { createLogger } from '@snowluma/common/logger';
import { compareVersions } from './check';
import { parseVersion } from '@snowluma/common/semver';
import { downloadUpdate } from './download';
import { exists, MANIFEST_FILE, readManifest, updateDirectory } from './files';
@@ -76,17 +76,15 @@ export async function createUpdateService(
);
}
if (
typeof version !== 'string' ||
!/^\d+\.\d+\.\d+$/.test(version) ||
compareVersions(version, current) <= 0
) {
const parsed = parseVersion(version);
if (!parsed || parsed.prerelease.length || parsed.compare(current) <= 0) {
throw new Error('请选择更新的正式版本');
}
state = { phase: 'downloading', version };
log.info('下载 v%s', version);
void prepare(version);
state = { phase: 'downloading', version: parsed.raw };
log.info('下载 v%s', parsed.raw);
void prepare(parsed.raw);
},
restart() {
if (state.phase !== 'ready') {
@@ -60,6 +60,12 @@ describe('renderTemplate — mechanical {key} substitution', () => {
it('does NOT escape when template is plain text', () => {
expect(renderTemplate('{val}', { val: 'a"b\\c' })).toBe('a"b\\c');
});
it('preserves control characters as JSON string escapes', () => {
const value = 'line\nnext\r\t\b\f\0"\\';
const rendered = renderTemplate('{"text":"{value}"}', { value });
expect(JSON.parse(rendered)).toEqual({ text: value });
});
});
describe('normalizeNotificationsConfig — total normalize', () => {
+25
View File
@@ -27,6 +27,31 @@ afterEach(async () => {
});
describe('staged updates', () => {
it.each(['02.0.0', '2.0.0-rc.01', '2.0.0-rc_1', '2.0.0+build..1'])(
'rejects invalid manifest versions before replacing files: %s',
async (version) => {
const payload = path.join(staging, 'pending/payload');
const manifest = await readManifest(payload);
await fs.writeFile(
path.join(payload, 'release.json'),
JSON.stringify({ ...manifest, version }),
);
await expect(applyPendingUpdate(root)).rejects.toThrow('清单无效');
expect((await readManifest(root)).version).toBe('1.0.0');
},
);
it('accepts prerelease identifiers and build metadata in manifests', async () => {
const payload = path.join(staging, 'pending/payload');
const manifest = await readManifest(payload);
const version = '2.0.0-rc.10+build.01';
await fs.writeFile(
path.join(payload, 'release.json'),
JSON.stringify({ ...manifest, version }),
);
expect((await readManifest(payload)).version).toBe(version);
});
it('stages outside the installation and only renames replacements on the destination filesystem', async () => {
expect(path.dirname(staging)).toBe(os.tmpdir());
expect(staging.startsWith(root + path.sep)).toBe(false);
+23 -5
View File
@@ -71,9 +71,16 @@ describe('update service', () => {
await vi.waitFor(() => expect(service.status().phase).toBe('error'));
});
it.each(['1.0.0', '0.9.0', '2.0.0-rc.1', '../../outside', null])(
'rejects invalid or older version %s',
async (version) => {
it.each([
'1.0.0',
'0.9.0',
'2.0.0-rc.1',
'02.0.0',
'2.0.0\n',
'1.0.0+build.2',
'../../outside',
null,
])('rejects invalid or older version %s', async (version) => {
const download = vi.fn();
const service = await createUpdateService(root, '1.0.0', {
download,
@@ -81,8 +88,19 @@ describe('update service', () => {
});
expect(() => service.start(version)).toThrow('正式版本');
expect(download).not.toHaveBeenCalled();
},
);
});
it('downloads stable versions with build metadata without dropping the tag suffix', async () => {
const download = vi.fn(async (directory: string, version: string, target: string) => {
await releaseFixture(path.join(directory, 'payload'), version, target);
await writeFile(path.join(directory, 'archive'), 'archive');
});
const service = await createUpdateService(root, '1.0.0', { download, isContainer: false });
service.start('2.0.0+build.01');
await vi.waitFor(() => expect(service.status().phase).toBe('ready'));
expect(download).toHaveBeenCalledWith(expect.any(String), '2.0.0+build.01', 'win32-x64');
expect(service.status()).toMatchObject({ version: '2.0.0+build.01' });
});
it('does not enable updates for a source checkout', async () => {
const service = await createUpdateService(undefined, '1.0.0');
+21 -30
View File
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { compareVersions, getUpdateInfo } from '../src/update/check';
import { getUpdateInfo } from '../src/update/check';
const LATEST_RELEASE_URL = 'https://api.github.com/repos/SnowLuma/SnowLuma/releases/latest';
const NOTES_MAX = 4_000;
@@ -46,35 +46,6 @@ afterEach(() => {
vi.mock('@snowluma/common/version', () => ({ APP_VERSION: '0.0.0' }));
describe('compareVersions', () => {
it.each([
['1.2.3', '1.2.3', 0],
['v1.2.3', '1.2.3', 0],
['1.2', '1.2.0', 0],
['1.2.3.9', '1.2.3.1', 0],
['1.2.x', '1.2.0', 0],
['', '0.0.0', 0],
// Only a leading lowercase `v` is stripped; `V1.2.3` parses as 0.2.3.
['V1.2.3', '0.0.0', 2],
['V1.2.3', '0.2.3', 0],
['1.2.4', '1.2.3', 1],
['1.2.3', '1.2.4', -1],
['1.10.0', '1.9.0', 1],
['2.0.0', '1.9.9', 1],
['3.0.0', '1.0.0', 2],
['1.0.0', '3.0.0', -2],
['1.2.3', '1.2.3-rc.1', 1],
['1.2.3-rc.1', '1.2.3', -1],
['1.2.3-rc.1', '1.2.3-rc.1', 0],
['1.2.3-rc.1', '1.2.3-rc.2', -1],
['1.2.3-rc.2', '1.2.3-rc.1', 1],
['1.2.3-rc.10', '1.2.3-rc.2', -1],
['2.0.0-rc.1', '1.9.9', 1],
] as const)('compareVersions(%j, %j) === %s', (a, b, expected) => {
expect(compareVersions(a, b)).toBe(expected);
});
});
describe('getUpdateInfo', () => {
it.each(['0', 'false', 'off', 'no', 'FALSE', ' Off ', 'No'] as const)(
'skips the GitHub request when SNOWLUMA_UPDATE_CHECK=%j',
@@ -234,6 +205,26 @@ describe('getUpdateInfo', () => {
},
);
it.each(['v01.2.3', 'v1.2.x', 'v1.2.3.4', 'v1.2.3-rc.01', 'v1.2.3+build..1'])(
'reports an invalid release tag without offering an update: %s',
async (tag) => {
mockFetch(async () => jsonResponse({ tag_name: tag }));
expect(await getUpdateInfo(true)).toMatchObject({
latest: null,
hasUpdate: false,
error: 'invalid release version',
});
},
);
it('preserves build metadata in release tags', async () => {
mockFetch(async () => jsonResponse({ tag_name: 'v1.2.3+build.01' }));
expect(await getUpdateInfo(true)).toMatchObject({
latest: '1.2.3+build.01',
hasUpdate: true,
});
});
it('uses the Error message when fetch throws an Error', async () => {
mockFetch(async () => {
throw new Error('getaddrinfo ENOTFOUND');
+4 -4
View File
@@ -69,10 +69,10 @@ export function parseCQParams(raw: string): Record<string, string> {
if (eq > 0) {
params[pair.substring(0, eq)] = pair
.substring(eq + 1)
.replace(/&#91;/g, '[')
.replace(/&#93;/g, ']')
.replace(/&#44;/g, ',')
.replace(/&amp;/g, '&');
.replaceAll('&#91;', '[')
.replaceAll('&#93;', ']')
.replaceAll('&#44;', ',')
.replaceAll('&amp;', '&');
}
}
+8 -8
View File
@@ -300,14 +300,14 @@ function makeMarketFaceElem(element: MessageElement): ProtoElem {
function escapeXml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;')
.replace(/\t/g, '&#x9;')
.replace(/\n/g, '&#xA;')
.replace(/\r/g, '&#xD;');
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&apos;')
.replaceAll('\t', '&#x9;')
.replaceAll('\n', '&#xA;')
.replaceAll('\r', '&#xD;');
}
function xmlPreviewText(value: string): string {
+2 -2
View File
@@ -1,11 +1,11 @@
import type { AnyMessageSegment } from '../types/index';
export function escapeCqText(text: string): string {
return text.replace(/&/g, '&amp;').replace(/\[/g, '&#91;').replace(/\]/g, '&#93;');
return text.replaceAll('&', '&amp;').replaceAll('[', '&#91;').replaceAll(']', '&#93;');
}
export function escapeCqParam(text: string): string {
return escapeCqText(text).replace(/,/g, '&#44;');
return escapeCqText(text).replaceAll(',', '&#44;');
}
export function segmentsToCQString(segments: AnyMessageSegment[]): string {
+2 -2
View File
@@ -59,12 +59,12 @@ function parseCQParams(raw: string): Record<string, string> {
}
function cqUnescape(text: string): string {
return text.replace(/&#91;/g, '[').replace(/&#93;/g, ']').replace(/&amp;/g, '&');
return text.replaceAll('&#91;', '[').replaceAll('&#93;', ']').replaceAll('&amp;', '&');
}
function cqUnescapeParam(text: string): string {
// Decode ampersands last so literal entity text is not decoded twice.
return cqUnescape(text.replace(/&#44;/g, ','));
return cqUnescape(text.replaceAll('&#44;', ','));
}
function segmentFromCq(type: string, data: Record<string, string>): AnyMessageSegment {
+1
View File
@@ -57,6 +57,7 @@ Webhook bodies and email subjects/bodies support `{uin}`, `{nickname}`, `{event}
(`offline` or `online`), `{time}`, `{status}` (Hook process state), `{pid}`, and
`{error}` (Hook process error). New webhook and email channels include process
status in their default body; existing saved templates are preserved.
JSON templates escape substituted strings, including quotes, line breaks and control characters.
Process facts are resolved when the notification is dispatched, after any offline
debounce. Each transition carries its process PID, including the final PID detached
+1 -6
View File
@@ -6,11 +6,6 @@ import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
import { defineConfig, globalIgnores } from 'eslint/config';
const tsconfigRootDir = decodeURIComponent(new URL('.', import.meta.url).pathname).replace(
/^\/(.:\/)/,
'$1',
);
export default defineConfig([
globalIgnores(['dist']),
{
@@ -25,7 +20,7 @@ export default defineConfig([
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
tsconfigRootDir,
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
+11
View File
@@ -109,10 +109,16 @@ importers:
'@zxcvbn-ts/language-en':
specifier: ^4.1.1
version: 4.1.1
semver:
specifier: 7.7.4
version: 7.7.4
devDependencies:
'@types/node':
specifier: 'catalog:'
version: 24.19.2
'@types/semver':
specifier: 7.7.1
version: 7.7.1
typescript:
specifier: 'catalog:'
version: 6.0.2
@@ -2007,6 +2013,9 @@ packages:
'@types/resolve@1.20.2':
resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==}
'@types/semver@7.7.1':
resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==}
'@typescript-eslint/eslint-plugin@8.58.2':
resolution: {integrity: sha512-aC2qc5thQahutKjP+cl8cgN9DWe3ZUqVko30CMSZHnFEHyhOYoZSzkGtAI2mcwZ38xeImDucI4dnqsHiOYuuCw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -4806,6 +4815,8 @@ snapshots:
'@types/resolve@1.20.2': {}
'@types/semver@7.7.1': {}
'@typescript-eslint/eslint-plugin@8.58.2(@typescript-eslint/parser@8.58.2(eslint@10.2.0(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.2))(eslint@10.2.0(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.2)':
dependencies:
'@eslint-community/regexpp': 4.12.2
+2 -1
View File
@@ -5,12 +5,13 @@ import { readFile, writeFile, readdir } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseVersion } from '../packages/common/src/semver.ts';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, '..');
const nextVersion = process.argv[2] ?? JSON.parse(await readFile(path.join(repoRoot, 'package.json'), 'utf8')).version;
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(nextVersion)) {
if (!parseVersion(nextVersion)) {
console.error(`Invalid semver: "${nextVersion}"`);
process.exit(1);
}
+2 -1
View File
@@ -32,6 +32,7 @@ import { execSync, spawnSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseVersion } from '../packages/common/src/semver.ts';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, '..');
@@ -51,7 +52,7 @@ if (!version) {
console.error('Example: pnpm release 1.8.0');
process.exit(1);
}
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(version)) {
if (!parseVersion(version)) {
console.error(`Invalid semver: "${version}"`);
process.exit(1);
}