fix: standardize version parsing and simplify string escaping
This commit is contained in:
@@ -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.
|
[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.
|
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:
|
Read the relevant owner before changing a path:
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ SnowLuma 是面向 QQ 客户端的 TypeScript 互操作运行时,将 QQ 原生
|
|||||||
3. 打开 [`http://localhost:5099`](http://localhost:5099),使用启动日志中的初始密码登录 WebUI,按引导接入 QQ 进程并配置 OneBot 连接。
|
3. 打开 [`http://localhost:5099`](http://localhost:5099),使用启动日志中的初始密码登录 WebUI,按引导接入 QQ 进程并配置 OneBot 连接。
|
||||||
|
|
||||||
在「设置 → 关于 → 软件更新」下载新版本,再点「重启更新」。文件优先暂存于系统临时目录,不可用时使用安装目录的 `tmp/update`,下次启动时替换并清理,配置、日志与消息数据保留。更新沿用当前 Node.js;容器部署请更新镜像。
|
在「设置 → 关于 → 软件更新」下载新版本,再点「重启更新」。文件优先暂存于系统临时目录,不可用时使用安装目录的 `tmp/update`,下次启动时替换并清理,配置、日志与消息数据保留。更新沿用当前 Node.js;容器部署请更新镜像。
|
||||||
|
版本号遵循 [SemVer](https://semver.org/),在线更新仅安装正式版;构建元数据不影响版本排序。
|
||||||
|
|
||||||
## 接入与文档
|
## 接入与文档
|
||||||
|
|
||||||
|
|||||||
+1
-4
@@ -6,10 +6,7 @@ import { defineConfig, globalIgnores } from 'eslint/config';
|
|||||||
import globals from 'globals';
|
import globals from 'globals';
|
||||||
import tseslint from 'typescript-eslint';
|
import tseslint from 'typescript-eslint';
|
||||||
|
|
||||||
const tsconfigRootDir = decodeURIComponent(new URL('.', import.meta.url).pathname).replace(
|
const tsconfigRootDir = import.meta.dirname;
|
||||||
/^\/(.:\/)/,
|
|
||||||
'$1',
|
|
||||||
);
|
|
||||||
|
|
||||||
export const sourceIgnores = globalIgnores([
|
export const sourceIgnores = globalIgnores([
|
||||||
'**/dist/**',
|
'**/dist/**',
|
||||||
|
|||||||
@@ -8,6 +8,10 @@
|
|||||||
"main": "./src/index.ts",
|
"main": "./src/index.ts",
|
||||||
"types": "./src/index.ts",
|
"types": "./src/index.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
"./semver": {
|
||||||
|
"types": "./src/semver.ts",
|
||||||
|
"default": "./src/semver.ts"
|
||||||
|
},
|
||||||
"./version": {
|
"./version": {
|
||||||
"types": "./src/version.ts",
|
"types": "./src/version.ts",
|
||||||
"default": "./src/version.ts"
|
"default": "./src/version.ts"
|
||||||
@@ -95,13 +99,15 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
|
"@types/semver": "7.7.1",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"vitest": "^4.1.4"
|
"vitest": "^4.1.4"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@zxcvbn-ts/core": "^4.1.2",
|
"@zxcvbn-ts/core": "^4.1.2",
|
||||||
"@zxcvbn-ts/language-common": "^4.1.3",
|
"@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": {
|
"engines": {
|
||||||
"node": ">=24"
|
"node": ">=24"
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -94,16 +94,7 @@ export function defaultNotificationsConfig(): NotificationsConfig {
|
|||||||
|
|
||||||
// ─── Template renderer ──────────────────────────────────────────────────────
|
// ─── Template renderer ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/** Preserve unknown placeholders; escape substituted strings in JSON templates. */
|
||||||
* 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 `\"`.
|
|
||||||
*/
|
|
||||||
export function renderTemplate(template: string, vars: Record<string, string>): string {
|
export function renderTemplate(template: string, vars: Record<string, string>): string {
|
||||||
if (typeof template !== 'string') {
|
if (typeof template !== 'string') {
|
||||||
return '';
|
return '';
|
||||||
@@ -124,7 +115,7 @@ export function renderTemplate(template: string, vars: Record<string, string>):
|
|||||||
}
|
}
|
||||||
|
|
||||||
const val = vars[key];
|
const val = vars[key];
|
||||||
return isJson ? val.replace(/\\/g, '\\\\').replace(/"/g, '\\"') : val;
|
return isJson ? JSON.stringify(val).slice(1, -1) : val;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { APP_VERSION } from '@snowluma/common/version';
|
import { APP_VERSION } from '@snowluma/common/version';
|
||||||
|
import { compareVersions, parseVersion } from '@snowluma/common/semver';
|
||||||
import { createLogger } from '@snowluma/common/logger';
|
import { createLogger } from '@snowluma/common/logger';
|
||||||
import { createSingleFlightCache } from '@snowluma/common/single-flight-cache';
|
import { createSingleFlightCache } from '@snowluma/common/single-flight-cache';
|
||||||
|
|
||||||
@@ -40,49 +41,6 @@ function isEnabled(): boolean {
|
|||||||
return v !== '0' && v !== 'false' && v !== 'off' && v !== 'no';
|
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
|
// 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
|
// 60 req/hr limit) and collapse concurrent checks onto one fetch. Error results
|
||||||
// are intentionally not cached (shouldCache) so the next check retries.
|
// 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 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) {
|
if (!tag) {
|
||||||
return { ...base, error: 'no 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 {
|
return {
|
||||||
current,
|
current,
|
||||||
latest,
|
latest,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import path from 'node:path';
|
|||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
import { constants, existsSync } from 'node:fs';
|
import { constants, existsSync } from 'node:fs';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
|
import { parseVersion } from '@snowluma/common/semver';
|
||||||
|
|
||||||
export const MANIFEST_FILE = 'release.json';
|
export const MANIFEST_FILE = 'release.json';
|
||||||
|
|
||||||
@@ -86,7 +87,7 @@ export async function readManifest(root: string): Promise<ReleaseManifest> {
|
|||||||
typeof value !== 'object' ||
|
typeof value !== 'object' ||
|
||||||
!('version' in value) ||
|
!('version' in value) ||
|
||||||
typeof value.version !== 'string' ||
|
typeof value.version !== 'string' ||
|
||||||
!/^\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?$/.test(value.version) ||
|
!parseVersion(value.version) ||
|
||||||
!('target' in value) ||
|
!('target' in value) ||
|
||||||
typeof value.target !== 'string' ||
|
typeof value.target !== 'string' ||
|
||||||
!/^(win32-x64|linux-(x64|arm64))$/.test(value.target) ||
|
!/^(win32-x64|linux-(x64|arm64))$/.test(value.target) ||
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { existsSync } from 'node:fs';
|
|||||||
import { mkdir, rename, rm } from 'node:fs/promises';
|
import { mkdir, rename, rm } from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { createLogger } from '@snowluma/common/logger';
|
import { createLogger } from '@snowluma/common/logger';
|
||||||
import { compareVersions } from './check';
|
import { parseVersion } from '@snowluma/common/semver';
|
||||||
import { downloadUpdate } from './download';
|
import { downloadUpdate } from './download';
|
||||||
import { exists, MANIFEST_FILE, readManifest, updateDirectory } from './files';
|
import { exists, MANIFEST_FILE, readManifest, updateDirectory } from './files';
|
||||||
|
|
||||||
@@ -76,17 +76,15 @@ export async function createUpdateService(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
const parsed = parseVersion(version);
|
||||||
typeof version !== 'string' ||
|
|
||||||
!/^\d+\.\d+\.\d+$/.test(version) ||
|
if (!parsed || parsed.prerelease.length || parsed.compare(current) <= 0) {
|
||||||
compareVersions(version, current) <= 0
|
|
||||||
) {
|
|
||||||
throw new Error('请选择更新的正式版本');
|
throw new Error('请选择更新的正式版本');
|
||||||
}
|
}
|
||||||
|
|
||||||
state = { phase: 'downloading', version };
|
state = { phase: 'downloading', version: parsed.raw };
|
||||||
log.info('下载 v%s', version);
|
log.info('下载 v%s', parsed.raw);
|
||||||
void prepare(version);
|
void prepare(parsed.raw);
|
||||||
},
|
},
|
||||||
restart() {
|
restart() {
|
||||||
if (state.phase !== 'ready') {
|
if (state.phase !== 'ready') {
|
||||||
|
|||||||
@@ -60,6 +60,12 @@ describe('renderTemplate — mechanical {key} substitution', () => {
|
|||||||
it('does NOT escape when template is plain text', () => {
|
it('does NOT escape when template is plain text', () => {
|
||||||
expect(renderTemplate('{val}', { val: 'a"b\\c' })).toBe('a"b\\c');
|
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', () => {
|
describe('normalizeNotificationsConfig — total normalize', () => {
|
||||||
|
|||||||
@@ -27,6 +27,31 @@ afterEach(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('staged updates', () => {
|
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 () => {
|
it('stages outside the installation and only renames replacements on the destination filesystem', async () => {
|
||||||
expect(path.dirname(staging)).toBe(os.tmpdir());
|
expect(path.dirname(staging)).toBe(os.tmpdir());
|
||||||
expect(staging.startsWith(root + path.sep)).toBe(false);
|
expect(staging.startsWith(root + path.sep)).toBe(false);
|
||||||
|
|||||||
@@ -71,18 +71,36 @@ describe('update service', () => {
|
|||||||
await vi.waitFor(() => expect(service.status().phase).toBe('error'));
|
await vi.waitFor(() => expect(service.status().phase).toBe('error'));
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each(['1.0.0', '0.9.0', '2.0.0-rc.1', '../../outside', null])(
|
it.each([
|
||||||
'rejects invalid or older version %s',
|
'1.0.0',
|
||||||
async (version) => {
|
'0.9.0',
|
||||||
const download = vi.fn();
|
'2.0.0-rc.1',
|
||||||
const service = await createUpdateService(root, '1.0.0', {
|
'02.0.0',
|
||||||
download,
|
'2.0.0\n',
|
||||||
isContainer: false,
|
'1.0.0+build.2',
|
||||||
});
|
'../../outside',
|
||||||
expect(() => service.start(version)).toThrow('正式版本');
|
null,
|
||||||
expect(download).not.toHaveBeenCalled();
|
])('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 () => {
|
it('does not enable updates for a source checkout', async () => {
|
||||||
const service = await createUpdateService(undefined, '1.0.0');
|
const service = await createUpdateService(undefined, '1.0.0');
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
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 LATEST_RELEASE_URL = 'https://api.github.com/repos/SnowLuma/SnowLuma/releases/latest';
|
||||||
const NOTES_MAX = 4_000;
|
const NOTES_MAX = 4_000;
|
||||||
@@ -46,35 +46,6 @@ afterEach(() => {
|
|||||||
|
|
||||||
vi.mock('@snowluma/common/version', () => ({ APP_VERSION: '0.0.0' }));
|
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', () => {
|
describe('getUpdateInfo', () => {
|
||||||
it.each(['0', 'false', 'off', 'no', 'FALSE', ' Off ', 'No'] as const)(
|
it.each(['0', 'false', 'off', 'no', 'FALSE', ' Off ', 'No'] as const)(
|
||||||
'skips the GitHub request when SNOWLUMA_UPDATE_CHECK=%j',
|
'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 () => {
|
it('uses the Error message when fetch throws an Error', async () => {
|
||||||
mockFetch(async () => {
|
mockFetch(async () => {
|
||||||
throw new Error('getaddrinfo ENOTFOUND');
|
throw new Error('getaddrinfo ENOTFOUND');
|
||||||
|
|||||||
@@ -69,10 +69,10 @@ export function parseCQParams(raw: string): Record<string, string> {
|
|||||||
if (eq > 0) {
|
if (eq > 0) {
|
||||||
params[pair.substring(0, eq)] = pair
|
params[pair.substring(0, eq)] = pair
|
||||||
.substring(eq + 1)
|
.substring(eq + 1)
|
||||||
.replace(/[/g, '[')
|
.replaceAll('[', '[')
|
||||||
.replace(/]/g, ']')
|
.replaceAll(']', ']')
|
||||||
.replace(/,/g, ',')
|
.replaceAll(',', ',')
|
||||||
.replace(/&/g, '&');
|
.replaceAll('&', '&');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -300,14 +300,14 @@ function makeMarketFaceElem(element: MessageElement): ProtoElem {
|
|||||||
|
|
||||||
function escapeXml(value: string): string {
|
function escapeXml(value: string): string {
|
||||||
return value
|
return value
|
||||||
.replace(/&/g, '&')
|
.replaceAll('&', '&')
|
||||||
.replace(/</g, '<')
|
.replaceAll('<', '<')
|
||||||
.replace(/>/g, '>')
|
.replaceAll('>', '>')
|
||||||
.replace(/"/g, '"')
|
.replaceAll('"', '"')
|
||||||
.replace(/'/g, ''')
|
.replaceAll("'", ''')
|
||||||
.replace(/\t/g, '	')
|
.replaceAll('\t', '	')
|
||||||
.replace(/\n/g, '
')
|
.replaceAll('\n', '
')
|
||||||
.replace(/\r/g, '
');
|
.replaceAll('\r', '
');
|
||||||
}
|
}
|
||||||
|
|
||||||
function xmlPreviewText(value: string): string {
|
function xmlPreviewText(value: string): string {
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import type { AnyMessageSegment } from '../types/index';
|
import type { AnyMessageSegment } from '../types/index';
|
||||||
|
|
||||||
export function escapeCqText(text: string): string {
|
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 {
|
export function escapeCqParam(text: string): string {
|
||||||
return escapeCqText(text).replace(/,/g, ',');
|
return escapeCqText(text).replaceAll(',', ',');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function segmentsToCQString(segments: AnyMessageSegment[]): string {
|
export function segmentsToCQString(segments: AnyMessageSegment[]): string {
|
||||||
|
|||||||
@@ -59,12 +59,12 @@ function parseCQParams(raw: string): Record<string, string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function cqUnescape(text: string): string {
|
function cqUnescape(text: string): string {
|
||||||
return text.replace(/[/g, '[').replace(/]/g, ']').replace(/&/g, '&');
|
return text.replaceAll('[', '[').replaceAll(']', ']').replaceAll('&', '&');
|
||||||
}
|
}
|
||||||
|
|
||||||
function cqUnescapeParam(text: string): string {
|
function cqUnescapeParam(text: string): string {
|
||||||
// Decode ampersands last so literal entity text is not decoded twice.
|
// 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<string, string>): AnyMessageSegment {
|
function segmentFromCq(type: string, data: Record<string, string>): AnyMessageSegment {
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ Webhook bodies and email subjects/bodies support `{uin}`, `{nickname}`, `{event}
|
|||||||
(`offline` or `online`), `{time}`, `{status}` (Hook process state), `{pid}`, and
|
(`offline` or `online`), `{time}`, `{status}` (Hook process state), `{pid}`, and
|
||||||
`{error}` (Hook process error). New webhook and email channels include process
|
`{error}` (Hook process error). New webhook and email channels include process
|
||||||
status in their default body; existing saved templates are preserved.
|
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
|
Process facts are resolved when the notification is dispatched, after any offline
|
||||||
debounce. Each transition carries its process PID, including the final PID detached
|
debounce. Each transition carries its process PID, including the final PID detached
|
||||||
|
|||||||
@@ -6,11 +6,6 @@ import reactRefresh from 'eslint-plugin-react-refresh';
|
|||||||
import tseslint from 'typescript-eslint';
|
import tseslint from 'typescript-eslint';
|
||||||
import { defineConfig, globalIgnores } from 'eslint/config';
|
import { defineConfig, globalIgnores } from 'eslint/config';
|
||||||
|
|
||||||
const tsconfigRootDir = decodeURIComponent(new URL('.', import.meta.url).pathname).replace(
|
|
||||||
/^\/(.:\/)/,
|
|
||||||
'$1',
|
|
||||||
);
|
|
||||||
|
|
||||||
export default defineConfig([
|
export default defineConfig([
|
||||||
globalIgnores(['dist']),
|
globalIgnores(['dist']),
|
||||||
{
|
{
|
||||||
@@ -25,7 +20,7 @@ export default defineConfig([
|
|||||||
ecmaVersion: 2020,
|
ecmaVersion: 2020,
|
||||||
globals: globals.browser,
|
globals: globals.browser,
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
tsconfigRootDir,
|
tsconfigRootDir: import.meta.dirname,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
|
|||||||
Generated
+11
@@ -109,10 +109,16 @@ importers:
|
|||||||
'@zxcvbn-ts/language-en':
|
'@zxcvbn-ts/language-en':
|
||||||
specifier: ^4.1.1
|
specifier: ^4.1.1
|
||||||
version: 4.1.1
|
version: 4.1.1
|
||||||
|
semver:
|
||||||
|
specifier: 7.7.4
|
||||||
|
version: 7.7.4
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 24.19.2
|
version: 24.19.2
|
||||||
|
'@types/semver':
|
||||||
|
specifier: 7.7.1
|
||||||
|
version: 7.7.1
|
||||||
typescript:
|
typescript:
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 6.0.2
|
version: 6.0.2
|
||||||
@@ -2007,6 +2013,9 @@ packages:
|
|||||||
'@types/resolve@1.20.2':
|
'@types/resolve@1.20.2':
|
||||||
resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==}
|
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':
|
'@typescript-eslint/eslint-plugin@8.58.2':
|
||||||
resolution: {integrity: sha512-aC2qc5thQahutKjP+cl8cgN9DWe3ZUqVko30CMSZHnFEHyhOYoZSzkGtAI2mcwZ38xeImDucI4dnqsHiOYuuCw==}
|
resolution: {integrity: sha512-aC2qc5thQahutKjP+cl8cgN9DWe3ZUqVko30CMSZHnFEHyhOYoZSzkGtAI2mcwZ38xeImDucI4dnqsHiOYuuCw==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
@@ -4806,6 +4815,8 @@ snapshots:
|
|||||||
|
|
||||||
'@types/resolve@1.20.2': {}
|
'@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)':
|
'@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:
|
dependencies:
|
||||||
'@eslint-community/regexpp': 4.12.2
|
'@eslint-community/regexpp': 4.12.2
|
||||||
|
|||||||
@@ -5,12 +5,13 @@ import { readFile, writeFile, readdir } from 'node:fs/promises';
|
|||||||
import { existsSync } from 'node:fs';
|
import { existsSync } from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { parseVersion } from '../packages/common/src/semver.ts';
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const repoRoot = path.resolve(__dirname, '..');
|
const repoRoot = path.resolve(__dirname, '..');
|
||||||
|
|
||||||
const nextVersion = process.argv[2] ?? JSON.parse(await readFile(path.join(repoRoot, 'package.json'), 'utf8')).version;
|
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}"`);
|
console.error(`Invalid semver: "${nextVersion}"`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -32,6 +32,7 @@ import { execSync, spawnSync } from 'node:child_process';
|
|||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { parseVersion } from '../packages/common/src/semver.ts';
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const repoRoot = path.resolve(__dirname, '..');
|
const repoRoot = path.resolve(__dirname, '..');
|
||||||
@@ -51,7 +52,7 @@ if (!version) {
|
|||||||
console.error('Example: pnpm release 1.8.0');
|
console.error('Example: pnpm release 1.8.0');
|
||||||
process.exit(1);
|
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}"`);
|
console.error(`Invalid semver: "${version}"`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user