diff --git a/AGENTS.md b/AGENTS.md index 5df5519c..091aa0e5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 ` 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: diff --git a/README.md b/README.md index fe5c7d9d..118a5c9f 100644 --- a/README.md +++ b/README.md @@ -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/),在线更新仅安装正式版;构建元数据不影响版本排序。 ## 接入与文档 diff --git a/eslint.config.ts b/eslint.config.ts index 57ef350b..1d74e914 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -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/**', diff --git a/packages/common/package.json b/packages/common/package.json index 8d34eaad..416cd672 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -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" diff --git a/packages/common/src/semver.ts b/packages/common/src/semver.ts new file mode 100644 index 00000000..07ec5ff5 --- /dev/null +++ b/packages/common/src/semver.ts @@ -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); +} diff --git a/packages/common/tests/semver.test.ts b/packages/common/tests/semver.test.ts new file mode 100644 index 00000000..c9840084 --- /dev/null +++ b/packages/common/tests/semver.test.ts @@ -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(); + }, + ); +}); diff --git a/packages/core/src/notifications/config.ts b/packages/core/src/notifications/config.ts index dfb32fcc..3cb3317b 100644 --- a/packages/core/src/notifications/config.ts +++ b/packages/core/src/notifications/config.ts @@ -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 { if (typeof template !== 'string') { return ''; @@ -124,7 +115,7 @@ export function renderTemplate(template: string, vars: Record): } const val = vars[key]; - return isJson ? val.replace(/\\/g, '\\\\').replace(/"/g, '\\"') : val; + return isJson ? JSON.stringify(val).slice(1, -1) : val; }); } diff --git a/packages/core/src/update/check.ts b/packages/core/src/update/check.ts index 5396ee01..6c1f59bf 100644 --- a/packages/core/src/update/check.ts +++ b/packages/core/src/update/check.ts @@ -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 { } 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, diff --git a/packages/core/src/update/files.ts b/packages/core/src/update/files.ts index e9a2938e..4a5ebae9 100644 --- a/packages/core/src/update/files.ts +++ b/packages/core/src/update/files.ts @@ -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 { 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) || diff --git a/packages/core/src/update/service.ts b/packages/core/src/update/service.ts index 1e854993..de71e8cd 100644 --- a/packages/core/src/update/service.ts +++ b/packages/core/src/update/service.ts @@ -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') { diff --git a/packages/core/tests/notifications-config.test.ts b/packages/core/tests/notifications-config.test.ts index 41593cde..322ab473 100644 --- a/packages/core/tests/notifications-config.test.ts +++ b/packages/core/tests/notifications-config.test.ts @@ -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', () => { diff --git a/packages/core/tests/update-files.test.ts b/packages/core/tests/update-files.test.ts index c4092161..742d76d8 100644 --- a/packages/core/tests/update-files.test.ts +++ b/packages/core/tests/update-files.test.ts @@ -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); diff --git a/packages/core/tests/update-service.test.ts b/packages/core/tests/update-service.test.ts index 93a0f3ca..f9924a16 100644 --- a/packages/core/tests/update-service.test.ts +++ b/packages/core/tests/update-service.test.ts @@ -71,18 +71,36 @@ 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) => { - const download = vi.fn(); - const service = await createUpdateService(root, '1.0.0', { - download, - isContainer: false, - }); - expect(() => service.start(version)).toThrow('正式版本'); - expect(download).not.toHaveBeenCalled(); - }, - ); + 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, + isContainer: false, + }); + 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'); diff --git a/packages/core/tests/webui-update-check.test.ts b/packages/core/tests/webui-update-check.test.ts index 4b7f8535..e6ae1c98 100644 --- a/packages/core/tests/webui-update-check.test.ts +++ b/packages/core/tests/webui-update-check.test.ts @@ -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'); diff --git a/packages/onebot/src/message-parser.ts b/packages/onebot/src/message-parser.ts index d3899456..cf2da89c 100644 --- a/packages/onebot/src/message-parser.ts +++ b/packages/onebot/src/message-parser.ts @@ -69,10 +69,10 @@ export function parseCQParams(raw: string): Record { if (eq > 0) { params[pair.substring(0, eq)] = pair .substring(eq + 1) - .replace(/[/g, '[') - .replace(/]/g, ']') - .replace(/,/g, ',') - .replace(/&/g, '&'); + .replaceAll('[', '[') + .replaceAll(']', ']') + .replaceAll(',', ',') + .replaceAll('&', '&'); } } diff --git a/packages/protocol/src/element-builder.ts b/packages/protocol/src/element-builder.ts index 71394d0d..e5d8d4c6 100644 --- a/packages/protocol/src/element-builder.ts +++ b/packages/protocol/src/element-builder.ts @@ -300,14 +300,14 @@ function makeMarketFaceElem(element: MessageElement): ProtoElem { function escapeXml(value: string): string { return value - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') - .replace(/\t/g, ' ') - .replace(/\n/g, ' ') - .replace(/\r/g, ' '); + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') + .replaceAll('\t', ' ') + .replaceAll('\n', ' ') + .replaceAll('\r', ' '); } function xmlPreviewText(value: string): string { diff --git a/packages/sdk/src/messages/cq-format.ts b/packages/sdk/src/messages/cq-format.ts index c68616c8..e15790bc 100644 --- a/packages/sdk/src/messages/cq-format.ts +++ b/packages/sdk/src/messages/cq-format.ts @@ -1,11 +1,11 @@ import type { AnyMessageSegment } from '../types/index'; export function escapeCqText(text: string): string { - return text.replace(/&/g, '&').replace(/\[/g, '[').replace(/\]/g, ']'); + return text.replaceAll('&', '&').replaceAll('[', '[').replaceAll(']', ']'); } export function escapeCqParam(text: string): string { - return escapeCqText(text).replace(/,/g, ','); + return escapeCqText(text).replaceAll(',', ','); } export function segmentsToCQString(segments: AnyMessageSegment[]): string { diff --git a/packages/sdk/src/messages/cq.ts b/packages/sdk/src/messages/cq.ts index c2bf9996..645b438f 100644 --- a/packages/sdk/src/messages/cq.ts +++ b/packages/sdk/src/messages/cq.ts @@ -59,12 +59,12 @@ function parseCQParams(raw: string): Record { } function cqUnescape(text: string): string { - return text.replace(/[/g, '[').replace(/]/g, ']').replace(/&/g, '&'); + return text.replaceAll('[', '[').replaceAll(']', ']').replaceAll('&', '&'); } function cqUnescapeParam(text: string): string { // Decode ampersands last so literal entity text is not decoded twice. - return cqUnescape(text.replace(/,/g, ',')); + return cqUnescape(text.replaceAll(',', ',')); } function segmentFromCq(type: string, data: Record): AnyMessageSegment { diff --git a/packages/webui/README.md b/packages/webui/README.md index af6baf26..877bc006 100644 --- a/packages/webui/README.md +++ b/packages/webui/README.md @@ -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 diff --git a/packages/webui/eslint.config.ts b/packages/webui/eslint.config.ts index 3d7998a9..a0e31cc8 100644 --- a/packages/webui/eslint.config.ts +++ b/packages/webui/eslint.config.ts @@ -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: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 191e955a..9f7903fe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/tools/bump-version.mjs b/tools/bump-version.mjs index f12bfcf9..03ebd6f8 100644 --- a/tools/bump-version.mjs +++ b/tools/bump-version.mjs @@ -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); } diff --git a/tools/release.mjs b/tools/release.mjs index c10b5917..606fb9e3 100644 --- a/tools/release.mjs +++ b/tools/release.mjs @@ -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); }