This commit is contained in:
2026-10-21 14:42:59 +08:00
commit 954236d3f9
399 changed files with 85885 additions and 0 deletions

View File

@@ -0,0 +1,160 @@
#!/usr/bin/env node
// scripts/commit-msg-preprocess.cjs
//
// 把 commit message 转成 commitlint 能验证的 conventional 格式,绝不卡用户:
//
// [audit] phase 2 — ...
// → audit: phase 2 — ... (项目主流 [prefix] 风格;prefix 强制小写)
//
// feat add new button
// → feat: add new button (bare 首词命中 type-enum → 用作 type)
//
// feat:word (常见 typo —— 冒号后忘空格)
// → feat: word (顺带补空格)
//
// update xxx
// → chore: update xxx (bare 首词不在 enum → 默认 chore)
//
// update (单字 bare)
// → chore: update (默认 chore)
//
// feat: existing conventional
// → feat: existing conventional (已 conventional 不动)
//
// 只动第一行(header),body/footer 不动。type-enum 从 commitlint.config.cjs
// 同步读取 —— audit round 8 (2026-09-14) commitlint.config 现在顶层导出
// .types 数组作为单一真相,这里优先读 config.types,失败再退到
// config.rules['type-enum'][2](老格式),再不行才用本文件末尾 FALLBACK_TYPES。
//
// 调用方式(在 .husky/commit-msg 里):node scripts/commit-msg-preprocess.cjs "$1"
const fs = require('node:fs');
const path = require('node:path');
// FALLBACK_TYPES 现在是真正的兜底(双 require 失败 / config 损坏),
// 不再与 commitlint.config 双源同步。commitlint.config 是唯一真相,
// 这里 hardcode 是为了让 loadTypeEnum 在异常路径上仍能给一个非空集合。
const FALLBACK_TYPES = new Set([
'workbench', 'cleanup', 'ui', 'docs', 'spec', 'plan', 'test', 'audit',
'feat', 'fix', 'refactor', 'perf', 'style', 'build', 'ci', 'chore', 'revert',
]);
function loadTypeEnum() {
try {
const configPath = path.resolve(__dirname, '..', 'commitlint.config.cjs');
delete require.cache[require.resolve(configPath)];
const config = require(configPath);
// audit round 8 (2026-09-14):优先读 config.types —— 单一真相。
// 旧实现走 config.rules['type-enum'][2] 拿数组,需要先校验结构。
// 现在 commitlint.config.cjs 顶层直接导出 types 数组,这里 require
// 拿到即可,避免双重结构解码。失败时仍可走 type-enum 数组兜底
// (兼容老 commitlint.config 没升级 .types 字段的过渡期),再不行
// 才退回 FALLBACK_TYPES。
if (Array.isArray(config && config.types)) {
return new Set(config.types);
}
const enumRule = config && config.rules && config.rules['type-enum'];
if (Array.isArray(enumRule) && Array.isArray(enumRule[2])) {
return new Set(enumRule[2]);
}
} catch (_) {
/* fall through to fallback */
}
return FALLBACK_TYPES;
}
const CONVENTIONAL_HEADER = /^\w+(?:\([^)]+\))?!?:\s.+$/;
const BRACKET_HEADER = /^\[(\w+)\]\s+(.+)$/;
function preprocess(message, typeSet) {
const set = typeSet || loadTypeEnum();
const newline = message.includes('\r\n') ? '\r\n' : '\n';
const lines = message.split(/\r?\n/);
const header = lines[0] || '';
// 空 header —— 让 commitlint 自然报错,不要硬塞一个虚假 chore:
if (!header.trim()) {
return message;
}
// [prefix] subject → prefix: subject (prefix 强制小写,匹配 type-case 规则;
// 未注册的 prefix 直接落到 bare fallback,避免 "[WIP] stuff" 这种被静默转成
// "wip: stuff" 然后 commitlint 用 "type not in enum" 二次卡住用户)
const bracketMatch = header.match(BRACKET_HEADER);
if (bracketMatch) {
const [, type, subject] = bracketMatch;
const lower = type.toLowerCase();
if (set.has(lower)) {
lines[0] = `${lower}: ${subject}`;
return lines.join(newline);
}
}
// 已 conventional → 原样不动(commitlint 自家规则会校验)
if (CONVENTIONAL_HEADER.test(header)) {
return message;
}
// bare:首词是已知 type(忽略大小写)且后面有内容 → "<type>: <rest>"
// 同时吞掉紧跟首词的 ":"(常见 typo "feat:word"),保证输出干净
const firstWord = header.match(/^(\w+)\b/);
if (firstWord) {
const typeCandidate = firstWord[1].toLowerCase();
if (set.has(typeCandidate)) {
const rest = header
.slice(firstWord[0].length)
.replace(/^:\s*/, '')
.trimStart();
if (rest.length > 0) {
lines[0] = `${typeCandidate}: ${rest}`;
return lines.join(newline);
}
}
}
// bare fallback → "chore: <header>" —— 兜底,确保 commit 永远成功
lines[0] = `chore: ${header}`;
return lines.join(newline);
}
if (require.main === module) {
const file = process.argv[2];
if (!file) {
console.error(
'[commit-msg-preprocess] usage: commit-msg-preprocess.cjs <commit-msg-file>',
);
process.exit(2);
}
const original = fs.readFileSync(file, 'utf8');
const rewritten = preprocess(original);
if (rewritten !== original) {
// audit round 8 (2026-09-14):写 tmp + rename 替直接 writeFileSync ——
// 旧实现写中断(磁盘满 / OS kill / power loss)会让 commit-msg 文件
// 半空,git 直接 reject "no commit message",用户措手不及。改成
// tmp 写 + renameSync:POSIX rename 原子,Windows 上 NTFS MoveFileEx
// 同 target volume 时也是原子的;失败时 tmp 文件残留但原文件完好。
// tmp 用 file + '.tmp' 与 git 临时文件命名约定一致,git 重启可识别。
const tmp = `${file}.tmp`;
fs.writeFileSync(tmp, rewritten, 'utf8');
try {
fs.renameSync(tmp, file);
} catch (e) {
try {
fs.unlinkSync(tmp);
} catch (_) {
/* tmp 清理失败不阻塞 — 用户重试一次 commit 就会覆盖 */
}
console.error(`[commit-msg-preprocess] rename tmp -> file failed: ${e.message}`);
process.exit(1);
}
// 静默不输出 —— git commit 行为不要污染 stdout,提交消息自身就是反馈
}
process.exit(0);
}
module.exports = {
preprocess,
loadTypeEnum,
CONVENTIONAL_HEADER,
BRACKET_HEADER,
FALLBACK_TYPES,
};

203
scripts/rebuild-native.cjs Normal file
View File

@@ -0,0 +1,203 @@
#!/usr/bin/env node
// 重编 better-sqlite3 (项目唯一的运行时 native module) 匹配当前 Electron ABI。
//
// 调用方:
// - scripts/start.cjs 在检测到 .node mtime 早于 electron.exe 时自动调
// - 用户手动跑 `npm run rebuild:native`
//
// 关键:
// - ELECTRON_RUN_AS_NODE=1 让 child process 的 require('electron') 返回
// electron.exe 路径,而不是 electron 模块本身 —— 这样 electron-rebuild
// 才能正确读取 Electron 内嵌的 Node 版本并匹配 NODE_MODULE_VERSION
// - stdio: 'inherit' 让 electron-rebuild 的进度输出原样透传
// - 不捕获 stdout,失败时直接非零退出码交给 caller
const { spawnSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const projectRoot = path.resolve(__dirname, '..');
const electronRebuildCli = path.resolve(
projectRoot,
'node_modules',
'@electron',
'rebuild',
'lib',
'cli.js',
);
// node-gyp 失败时会把 build/Release/ 整个删掉 —— 包括原来那个**能用**的 .node。
// 在没有 MSVC 工具链、或 GitHub prebuild 下载超时的机器上(离线 / 网络受限),
// 这会把一个本来能跑的仓库变成跑不起来的仓库;更糟的是 start.cjs 检测到 .node
// 缺失会再次触发 rebuild,于是每次 npm run dev 都重复这个破坏。
// 所以:rebuild 前快照,失败就还原。
const nodeFile = path.join(
projectRoot,
'node_modules',
'better-sqlite3',
'build',
'Release',
'better_sqlite3.node',
);
const backupDir = path.join(projectRoot, 'node_modules', '.cache', 'rkms');
const backupFile = path.join(backupDir, 'better_sqlite3.node.bak');
// --no-snapshot:调用方已经知道当前 .node 是坏的(缺失 / ABI 不匹配),别把它
// 当成"上一个可用版本"存进快照 —— 那会把之前真正可用的快照覆盖掉,把安全网变成陷阱。
// start.cjs 的自动 rebuild 永远走这个分支(它只在探测失败时才调用本脚本);
// 人手跑 `npm run rebuild:native` 时默认做快照,这样 rebuild 失败还能退回去。
const noSnapshot = process.argv.includes('--no-snapshot');
/**
* 当前 build/Release/.node 是否真的能在 Electron 里 dlopen。
*
* 只用 existsSync 判断"值得快照"是不够的 —— 本仓库的 .node 在两个 ABI 之间来回换:
* `npm run test:run` 的 pretest 会把它换成系统 Node ABI(137),`npm run dev` 需要的
* 是 Electron ABI(123)。文件一直存在,但对 Electron 而言可能是坏的。若把这种
* 文件存成"上一个可用版本",rebuild 失败后还原回去,安全网就变成了陷阱:
* 真正可用的旧快照被覆盖,而且日志还会谎称"已还原可用的 .node"。
*
* 探测手段与 start.cjs 的 probeBetterSqliteInElectron 一致(ELECTRON_RUN_AS_NODE=1
* 跑 Electron 内嵌 Node,ABI 与主进程完全一致)。探测不了就保守返回 false —— 宁可
* 不快照,也不要存一个来路不明的二进制。
*/
function existingBindingLoadsInElectron() {
let electronExePath;
try {
electronExePath = require('electron');
} catch {
return false;
}
if (typeof electronExePath !== 'string' || !fs.existsSync(electronExePath)) {
return false;
}
const probe = [
"try {",
" const Db = require('better-sqlite3');",
" const db = new Db(':memory:');",
" db.close();",
" process.exit(0);",
"} catch (e) {",
" process.exit(1);",
"}",
].join('\n');
try {
const r = spawnSync(electronExePath, ['--no-deprecation', '-e', probe], {
cwd: projectRoot,
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
encoding: 'utf8',
timeout: 30000,
});
return r.status === 0;
} catch {
return false;
}
}
if (!noSnapshot && fs.existsSync(nodeFile)) {
if (existingBindingLoadsInElectron()) {
try {
fs.mkdirSync(backupDir, { recursive: true });
fs.copyFileSync(nodeFile, backupFile);
console.log(`[rebuild-native] Snapshotted existing binding -> ${backupFile}`);
} catch (e) {
console.warn('[rebuild-native] Snapshot failed (continuing):', e.message);
}
} else {
console.log(
'[rebuild-native] 跳过快照:现有 .node 在 Electron 里加载失败(ABI 不匹配或已损坏),',
);
console.log('[rebuild-native] 存它只会覆盖掉真正可用的旧快照。');
}
}
function restoreBackup() {
// tri-state: 'restored' 成功还原,'no-backup' 快照不存在,'kept-existing'
// rebuild 失败但原地留了个文件 —— 这是最危险的中间态(node-gyp 异常退出
// 时可能留个 0 字节 .node),以前被 if/else 漏掉,用户看不到任何提示。
if (!fs.existsSync(backupFile)) return 'no-backup';
if (fs.existsSync(nodeFile)) return 'kept-existing';
try {
fs.mkdirSync(path.dirname(nodeFile), { recursive: true });
fs.copyFileSync(backupFile, nodeFile);
return 'restored';
} catch (e) {
console.error('[rebuild-native] Restore failed:', e.message);
return 'kept-existing';
}
}
console.log('[rebuild-native] Rebuilding better-sqlite3 against Electron ABI...');
console.log('[rebuild-native] electron-rebuild CLI:', electronRebuildCli);
const result = spawnSync(
process.execPath,
[
electronRebuildCli,
'-f', // force:即使 mtime 一致也重新编译
'-w', 'better-sqlite3', // 只重编这一个模块(避免动到其它 native module)
'--module-dir', projectRoot,
'--only', // 不读 package.json 里的所有 deps,只处理 -w 指定的
],
{
stdio: 'inherit',
env: {
...process.env,
// 关键:让 spawn 出来的 node 进程"扮演"成 electron 内嵌 node,
// 这样 require('electron') 仍能拿到 electron 二进制路径供 electron-rebuild 推断 ABI
ELECTRON_RUN_AS_NODE: '1',
},
},
);
if (result.error) {
console.error('[rebuild-native] spawn failed:', result.error.message);
if (restoreBackup() === 'restored') {
console.error('[rebuild-native] 已还原之前可用的 better_sqlite3.node,仓库仍可运行。');
}
process.exit(1);
}
const status = result.status ?? 1;
if (status !== 0) {
// 失败诊断:这两个是实战里最常见的原因,直接点名比让用户去读 node-gyp 堆栈快得多。
console.error('');
console.error('=====================================================');
console.error('[rebuild-native] Rebuild 失败。常见原因:');
console.error(' 1) 没有 C++ 工具链 —— node-gyp 报 "Could not find any Visual Studio installation"。');
console.error(' 装 VS Build Tools(勾选 "Desktop development with C++"):');
console.error(' winget install Microsoft.VisualStudio.2022.BuildTools');
console.error(' 2) prebuild 下载超时 —— better-sqlite3 的预编译包在 GitHub Releases 上,');
console.error(' 网络受限时会 timeout。配好代理后重试:');
console.error(' set HTTPS_PROXY=http://127.0.0.1:<port>');
console.error(' npm run rebuild:native');
console.error('=====================================================');
const restoreOutcome = restoreBackup();
if (restoreOutcome === 'restored') {
console.error('');
console.error('[rebuild-native] 已还原 rebuild 之前那个可用的 better_sqlite3.node。');
console.error('[rebuild-native] 仓库仍可 npm run dev —— 只要 Electron 版本没变,ABI 依旧匹配。');
} else if (restoreOutcome === 'no-backup') {
console.error('');
console.error('[rebuild-native] 警告:build/Release/better_sqlite3.node 现在不存在,');
console.error('[rebuild-native] 且没有可还原的快照。修好上面的原因后再跑一次。');
} else if (restoreOutcome === 'kept-existing') {
console.error('');
console.error('[rebuild-native] 警告:rebuild 失败,但原地还留着一个 better_sqlite3.node。');
let size = -1;
try {
size = fs.statSync(nodeFile).size;
} catch {}
console.error(
`[rebuild-native] 该文件大小 = ${size} 字节 —— 很可能是 node-gyp 异常退出时留下的残骸。`,
);
console.error('[rebuild-native] 手动删掉后再重跑:`rm build/Release/better_sqlite3.node`');
console.error(
'[rebuild-native] 不删的话下次探测照样不过,start.cjs 会反复触发 rebuild 死循环。',
);
}
}
process.exit(status);

109
scripts/run-tests.cjs Normal file
View File

@@ -0,0 +1,109 @@
#!/usr/bin/env node
// 自适应 vitest 启动器 —— 解决 better-sqlite3 的「双 ABI 互相覆盖」问题。
//
// 背景(2026-10-19 实地复现):
// better-sqlite3 不是 N-API,一个 .node 只对应一个 ABI:
// - Electron 30.5(NODE_MODULE_VERSION 123)要 Electron 版 —— app 运行时用
// - 系统 Node 24(NODE_MODULE_VERSION 137)要 Node 版 —— vitest 用
// 两者共用同一个路径:
// node_modules/better-sqlite3/build/Release/better_sqlite3.node
//
// 旧做法(pretest / pretest:run / pretest:coverage 都是这一行):
// node scripts/verify-abi-node.cjs || npm rebuild better-sqlite3
// → 为了让测试能跑,**破坏性地**把 app 那份覆盖成 Node 版。
// Windows 上 .node 被运行中的 electron 进程锁住 → unlink 报
// EPERM/EBUSY → 重建失败 → pretest 非 0 退出 → npm 把整条
// `npm run test:run` 一起中止(哪怕测试本身全绿)。
// 反向也成立:测试跑完 .node 变成 Node 版,下一次 `npm run dev` 又要
// 重建回 Electron 版 —— 每次切换都可能被运行中的 app 锁死。
//
// 策略:不再为了跑测试而重建。改成二选一启动器,按「当前 .node 到底是谁的」
// 决定用谁来跑 vitest:
// 1. 系统 Node 能直接 require('better-sqlite3') → 用系统 Node 跑 vitest。
// (Linux/macOS 的 napi prebuilt 天然两用;Windows 上若已是 Node 版 .node
// 也走这条)
// 2. 否则(Windows + Electron 版 .node,也就是常态)→ 用 Electron 自带的
// Node 跑 vitest(ELECTRON_RUN_AS_NODE=1)。此时测试与 app 共用同一份
// 原生模块:零重建、零 EBUSY、也不会把 app 那份覆盖掉。
//
// 附带收益:Electron 30.5 内嵌 Node 是 v20.16.0,仍在 package.json 的
// engines(>=20.16.0 <25)范围内,所以这条路不引入新的版本约束。
//
// 退出码透传 vitest 的退出码;CI 语义不变。
'use strict';
const { spawnSync } = require('child_process');
const path = require('path');
const fs = require('fs');
const ROOT = path.resolve(__dirname, '..');
const VITEST_ENTRY = path.join(ROOT, 'node_modules', 'vitest', 'vitest.mjs');
const argv = process.argv.slice(2);
function log(msg) {
process.stderr.write(`[run-tests] ${msg}\n`);
}
/** 系统 Node 当前能否加载 better-sqlite3(= .node 是否是 Node ABI 版本)。 */
function nativeLoadable() {
try {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.close();
return true;
} catch {
return false;
}
}
/** electron npm 包在 Node 下导出的是二进制路径字符串;拿不到就退回 dist 猜路径。 */
function electronBinary() {
try {
const p = require('electron');
if (typeof p === 'string' && fs.existsSync(p)) return p;
} catch {
/* 落到下面的猜测 */
}
const exe = process.platform === 'win32' ? 'electron.exe' : 'electron';
const guess = path.join(ROOT, 'node_modules', 'electron', 'dist', exe);
return fs.existsSync(guess) ? guess : null;
}
function run(cmd, cmdArgs, env) {
const res = spawnSync(cmd, cmdArgs, { stdio: 'inherit', cwd: ROOT, env });
if (res.error) {
log(`启动失败: ${res.error.message}`);
return 1;
}
// 被信号杀死时 status 为 null
return typeof res.status === 'number' ? res.status : 1;
}
if (!fs.existsSync(VITEST_ENTRY)) {
log(`找不到 vitest 入口 ${VITEST_ENTRY} —— 先执行 npm install`);
process.exit(1);
}
let code;
if (nativeLoadable()) {
// 路线 1:系统 Node 直跑(app 那份 .node 已经是 Node 版,或 napi 通用版)
code = run(process.execPath, [VITEST_ENTRY, ...argv], process.env);
} else {
// 路线 2:用 Electron 自带 Node 跑 —— 与 app 共用同一份原生模块
const electron = electronBinary();
if (!electron) {
log('better-sqlite3 在系统 Node 下不可加载,且找不到 electron 可执行文件。');
log(' 手动修复(选一条):');
log(' npx electron-rebuild -f -w better-sqlite3 # 给 app 用');
log(' npm rebuild better-sqlite3 # 给系统 Node 用');
process.exit(1);
}
log('better-sqlite3 当前为 Electron ABI —— 改用 Electron 自带 Node 跑 vitest');
log(' (与 app 共用同一份原生模块:不重建、不覆盖,规避 EBUSY)');
code = run(electron, [VITEST_ENTRY, ...argv], {
...process.env,
ELECTRON_RUN_AS_NODE: '1',
});
}
process.exit(code);

72
scripts/shutdown.cjs Normal file
View File

@@ -0,0 +1,72 @@
// scripts/shutdown.cjs
//
// audit r40 (2026-10-15):抽出 start.cjs 的子进程树清理逻辑,便于 vitest
// 单元测试(start.cjs 自身是顶层脚本,直接 require 会副作用跑整个 dev
// 链路,不便测)。
//
// 设计目标:
// - 输入 child (Node ChildProcess-like,需要 .pid 字段) + 可选 signal
// - Windows 走 taskkill /F /T /PID;POSIX 走 process.kill(-pid, ...)
// - 1500ms 后 SIGKILL 兜底
// - 任意抛错 swallow(启动期容错,本 helper 不该再抛出)
//
// 注意:Windows 上 Node 不支持 process.kill(-pid) 的 process group 语义,
// POSIX 分支仅在非 win32 跑;Windows 必须走 taskkill,负 PID 在 win32 上
// 会 ESRCH,这是已知差异。
const { spawnSync } = require('child_process');
/**
* 递归杀进程树。
* @param {{ pid?: number }} child : 需要 .pid;可无(空对象 → no-op)
* @param {string} [signalName] : POSIX 下首次发的信号;默认 'SIGTERM'
* @param {object} [opts]
* @param {string} [opts.platform] : 默认 process.platform;测时 mock
* @param {(pid: number) => unknown} [opts.posixGroupKill] : POSIX 杀进程的 helper;默认 process.kill
* @param {() => void} [opts.posixEscalate] : 1500ms 升级到 SIGKILL 的 schedule;默认 setTimeout
* @returns {{ killed: boolean, method: 'taskkill'|'posix'|'noop' }}
*/
function shutdownTree(child, signalName, opts) {
const pid = child && child.pid;
// PID 0 是 kernel idle process,不可能是 user-space child;统一视为 noop
if (typeof pid !== 'number' || !Number.isFinite(pid) || pid <= 0) {
return { killed: false, method: 'noop' };
}
const platform = (opts && opts.platform) || process.platform;
const posixGroupKill = (opts && opts.posixGroupKill) || ((p) => process.kill(p));
const posixEscalate = (opts && opts.posixEscalate) || ((fn) => setTimeout(fn, 1500));
if (platform === 'win32') {
try {
// windowsHide: 子进程在父 console handle 内隐藏,不弹黑色 cmd 窗口
spawnSync('taskkill', ['/F', '/T', '/PID', String(pid)], {
stdio: 'ignore',
windowsHide: true,
});
return { killed: true, method: 'taskkill' };
} catch {
// taskkill 自身失败 —— child 大概率已死,fallthrough
return { killed: false, method: 'taskkill' };
}
}
// POSIX:负 PID = process group(shell:true 时 child.pid 是 cmd 的 PID,
// cmd 自己起的 electron + vite 子进程都在同一 group)
try {
posixGroupKill(-pid, signalName || 'SIGTERM');
posixEscalate(() => {
try {
posixGroupKill(-pid, 'SIGKILL');
} catch {
// already exited
}
});
return { killed: true, method: 'posix' };
} catch {
// ESRCH = group 已不存在
return { killed: false, method: 'posix' };
}
}
module.exports = { shutdownTree };

529
scripts/start.cjs Normal file
View File

@@ -0,0 +1,529 @@
#!/usr/bin/env node
// 启动包装器:
// 1) 取消 ELECTRON_RUN_AS_NODE(用户的 shell 里可能有)
// 2) Windows 路径兜底:如果 cwd 含 CJK 或超长(>240),在 %LOCALAPPDATA%\rkms-junctions\<hash>
// 建一个 ASCII junction,然后 chdir 进去再启动 electron-vite。
// 这能避免 better-sqlite3 dlopen 时 \\?\ 前缀把 CJK 路径解读成 mojibake。
// 3) Windows 终端 codepage 切到 65001,避免 electron-vite 输出中文时乱码。
//
// 逃生通道(给用户控制权,不让工具绑架项目路径):
// - RKMS_NO_JUNCTION=1 跳过 junction,直接用原始 cwd(现代 Win11 + Electron 通常能直接吃 CJK 路径)
// - RKMS_JUNCTION_DIR=... 自定义 ASCII junction 父目录(LOCALAPPDATA 被污染时用这个)
const { spawn, execFileSync, spawnSync } = require('child_process');
const fs = require('fs');
const os = require('os');
const path = require('path');
const crypto = require('crypto');
// audit r40 (2026-10-15):shutdownTree 抽到 scripts/shutdown.cjs 便于单测;
// 详见该文件头注。
const { shutdownTree } = require('./shutdown.cjs');
// Windows 终端默认 codepage 是 cp936(中文系统),会把后续 console.* 里的 UTF-8 字节
// 按 GBK 解读 → 中文路径变成 鎴... 这种乱码。这个乱码连 start.cjs 自己的 ABI 探针
// 失败回显都吃(实测:探针的 stderr 是 UTF-8,Node 用 utf8 解码后 console.warn 写到终端,
// 终端 codepage=cp936 时显示成 `The module '\\?\C:\data\æ£å¨å·¥ä½\æºè½ä½ç®¡çç`)。
//
// 必须发生在**任何** console.* 调用之前 —— 一旦第一段 UTF-8 中文进了终端缓冲,
// 就已经被 cp936 错误解码了,改 codepage 也救不回来。所以这条不能下移到 spawn 之前,
// 必须放在文件顶部。
//
// 实现:cmd /c chcp 65001 通过共享的 console handle 改 codepage,影响同一 console
// 上的所有进程(包括 node 自身的后续 stdout)。失败时(非交互 console / 已 UTF-8
// 终端)忽略 —— 顶多中文乱码,不影响功能。RKMS_SKIP_CHCP 给 CI / 自动化场景逃生。
if (process.platform === 'win32' && !process.env.RKMS_SKIP_CHCP) {
try {
// audit 2026-09-14:windowsHide: true —— 避免 cmd.exe 启动瞬间的 console
// 窗口闪烁。cmd 是 console-subsystem,默认 spawn 会弹一个黑色 cmd 窗口
// 即使 stdio: 'ignore' 也可见;windowsHide 让子进程在父 console handle
// 内隐藏运行,chcp 65001 改 codepage 仍生效(共享 console)。
spawnSync('cmd.exe', ['/c', 'chcp', '65001'], { stdio: 'ignore', windowsHide: true });
} catch {
// codepage 切换失败,忽略 —— 后续输出可能乱码但不影响功能
}
}
const command = process.argv[2];
if (!command) {
console.error('Usage: node scripts/start.cjs <dev|build|start>');
process.exit(1);
}
const commandMap = {
dev: ['electron-vite', ['dev']],
build: ['electron-vite', ['build']],
start: ['electron-vite', ['preview']],
};
// 轻量 verify 子命令:只检查 better_sqlite3.node 的 mtime vs electron.exe 的 mtime,
// 不启动 electron-vite。给 predev / CI / 用户手动诊断用。
// node scripts/start.cjs verify-abi
// exit 0 = OK 或已自动修复; exit 1 = 修复失败(需要人工介入)
if (command === 'verify-abi') {
const fixed = checkAndFixNativeAbi({ silent: true });
if (!fixed) {
console.error('[start.cjs] verify-abi: ABI mismatch detected and auto-rebuild failed.');
console.error('[start.cjs] 请手动执行: npx electron-rebuild -f -w better-sqlite3');
process.exit(1);
}
process.exit(0);
}
const [cmd, args] = commandMap[command];
if (!cmd) {
console.error(`Unknown command: ${command}. Use dev|build|start|verify-abi.`);
process.exit(1);
}
// 创建干净的环境
const env = { ...process.env };
// 关键:取消这两个变量
delete env.ELECTRON_RUN_AS_NODE;
delete env.ELECTRON_NO_ATTACH_CONSOLE;
if (process.platform === 'linux') {
// 在 Linux 上,sandbox 经常需要 disable
env.NO_SANDBOX = '1';
}
// 启动期 pre-check:better-sqlite3 的 NODE_MODULE_VERSION 是否匹配当前 Electron ABI。
// 早于所有 junction / RKMS_NO_JUNCTION / build-skip 分支,这样:
// 1) 错误信息里路径还指向原始 cwd(用户能一眼看出是项目目录,不是 LOCALAPPDATA junction)
// 2) 即使后面 junction / build-skip 改了 cwd,ABI 检查也已完成
// 3) 三个子命令 (dev/build/start) 统一受益
//
// mtime 嗅探不是 100% 准确(系统时钟回拨 / 同步工具篡改 mtime 会误判),
// 但 Electron 升级 + ABI 漂移是 mtime 漂移最常见的诱因,实战足够。
// 万一漏判,主进程 path-check.ts 还有第二道兜底 + 清晰错误提示。
//
// 拿到返回值:checkAndFixNativeAbi() 在探测+自动 rebuild 都失败时返回 false,
// 这种情况下继续 spawn electron-vite 会导致 Electron 主进程 dlopen better-sqlite3
// 抛 "Could not locate the bindings file",一路被 unhandledRejection 吞掉,最后
// 表现为"DB not initialized"—— 这正是这个 precheck 想避免的失败模式。
// dev/build/start 三个子命令都没有 pre* hook 走 verify-abi 子命令路径,所以必须
// 自己处理返回值。
if (checkAndFixNativeAbi() === false) {
console.error('[start.cjs] better-sqlite3 ABI 不匹配且自动重建失败,中止启动。');
console.error('[start.cjs] 请手动执行: npx electron-rebuild -f -w better-sqlite3');
console.error('[start.cjs] 或:`npm run rebuild:native`(离线 / 工具链缺失请先看脚本输出)。');
process.exit(1);
}
// 路径策略(2026-08-28 修订):ASCII junction 默认**关闭**,改为 RKMS_FORCE_JUNCTION=1 显式开启。
//
// 历史:D2-020 认为 Windows + CJK cwd 会让 better-sqlite3 dlopen 拿到 GBK 乱码路径,
// 于是在 %LOCALAPPDATA% 建 ASCII junction 再 chdir 进去。实测这个防御无效且有害:
//
// 1) 无效 —— Node 的模块解析默认走 realpath。从 junction 里 require('better-sqlite3')
// 仍然解析回真实 CJK 路径才 dlopen;junction 只改了 process.cwd(),没改最终传给
// LoadLibraryEx 的路径。证据:junction 模式下 bindings 报错列出的全是 CJK 路径。
//
// 2) 有害 —— cwd(junction)与 vite 的 cacheDir(realpath 后的 CJK 路径)不同源。
// vite 的 esbuildOutputFromId() 用 path.relative(process.cwd(), ...) 去匹配 esbuild
// metafile 的 key,跨源匹配不上就返回 undefined,而调用方直接解引用 output.imports:
// TypeError: Cannot read properties of undefined (reading 'imports')
// dev server 整个进程崩掉。
//
// 3) 不必要 —— Electron 30 能直接 dlopen CJK 路径。verify-abi 探针就是在真实 CJK cwd 下
// spawn electron.exe 跑 new Database(':memory:') 成功的。
//
// 万一某个 Windows / Electron 组合真的碰到 CJK dlopen 失败,用 RKMS_FORCE_JUNCTION=1 开回来
//(代价:vite optimizeDeps 可能重现上面的崩溃)。
let effectiveCwd = process.cwd();
if (process.platform === 'win32') {
const originalCwd = process.cwd();
if (process.env.RKMS_FORCE_JUNCTION === '1' && command === 'build') {
// build 永不走 junction:vite 5 的 build-html transform 在 junction 下会把
// renderer/index.html 解析回 CJK 绝对路径,抛 "The 'fileName' or 'name'
// properties of emitted chunks and assets must be strings that are neither
// absolute nor relative paths"。build 阶段也不 dlopen native module,没必要。
console.log('[start.cjs] build 忽略 RKMS_FORCE_JUNCTION(与 vite build-html 不兼容)');
} else if (process.env.RKMS_FORCE_JUNCTION === '1') {
const junctionPath = setupWindowsJunction(originalCwd);
if (!junctionPath) {
console.error('[start.cjs] RKMS_FORCE_JUNCTION=1 但 junction 创建失败,见上方原因。');
process.exit(1);
}
try {
process.chdir(junctionPath);
effectiveCwd = junctionPath;
console.log(`[start.cjs] cwd -> ${junctionPath} (junction of ${originalCwd})`);
} catch (e) {
console.error('[start.cjs] chdir to junction failed:', e.message);
process.exit(1);
}
} else if (originalCwd.length > 240) {
// audit round 8 (2026-09-14):硬 fallback —— 之前只 warn,继续在超长 cwd
// 里 spawn electron-vite,后面 native module dlopen / vite pre-transform
// 才报错,失败模式不直观。修:超长路径直接走 junction 兜底,与
// RKMS_FORCE_JUNCTION=1 同条 setupWindowsJunction 路径,只是无 opt-in。
// junction 创建失败时再降级到 warn(老行为),让用户有最后机会绕开。
const junctionPath = setupWindowsJunction(originalCwd);
if (junctionPath) {
try {
process.chdir(junctionPath);
effectiveCwd = junctionPath;
console.log(
`[start.cjs] cwd 长度 ${originalCwd.length} > 240,自动 junction 兜底: ${junctionPath}`,
);
} catch (e) {
console.error('[start.cjs] chdir to junction failed:', e.message);
process.exit(1);
}
} else {
console.warn(`[start.cjs] 警告:cwd 长度 ${originalCwd.length} > 240,junction 兜底创建失败。`);
console.warn('[start.cjs] 继续在原始 cwd 启动,可能遇到 native module 加载失败。');
}
}
}
// Windows 终端 codepage 已在文件顶部 chcp 65001 切到 UTF-8,这里再加一次是
// belt-and-suspenders:某些 spawn 场景下(罕见,比如 detached console)子进程
// 不一定继承父 codepage,在 spawn shell 里再切一次是 0 成本的保险。
let spawnCmd = cmd;
let spawnArgs = args;
if (process.platform === 'win32') {
spawnCmd = `chcp 65001 >nul && ${cmd} ${args.map((a) => (a.includes(' ') ? `"${a}"` : a)).join(' ')}`;
spawnArgs = [];
}
// 把 effectiveCwd (junction 或原始 ASCII cwd) 透传给子进程。
// electron.vite.config.ts 读 RKMS_JUNCTION_PATH 加入 vite server.fs.allow,
// 解决 Windows + CJK 项目路径下 dev server pre-transform 抛
// "Failed to load url /src/main.tsx ... Does the file exist?"。
if (process.platform === 'win32' && effectiveCwd && /[A-Za-z]:\\/.test(effectiveCwd)) {
env.RKMS_JUNCTION_PATH = effectiveCwd;
}
const child = spawn(spawnCmd, spawnArgs, {
stdio: 'inherit',
env,
shell: true,
cwd: effectiveCwd,
});
// audit r40 (2026-10-15):Ctrl+C / SIGTERM 时递归清子进程树。
// 之前只监听 child 'exit' / 'error',用户按 Ctrl+C 时 Node 收到 SIGINT,
// 但 shell:true 下 child.pid 是 cmd.exe 的 PID,Node 默认 SIGTERM 转发
// 只触达 cmd.exe —— cmd.exe 死后 electron.exe + 多个 vite + electron helper
// 进程**不一定**跟着挂(实测常留 5+ 个僵尸)。下次启动时:
// 1) electron Cache_Data 文件被旧实例锁 → "cache_util_win.cc: Unable
// to move the cache: 拒绝访问" + 多个 Gpu Cache Creation failed
// 2) 多份 vite dev server 占 5173 / 5174 → "Port ... in use, trying
// another one..." 落 5175
// 修:本进程收 SIGINT/SIGTERM/SIGHUP 时主动杀进程树。
// 详见 scripts/shutdown.cjs 头注。
// child.on('exit') 仍保留作"子进程自己死掉"的兜底(spawn 链路本身异常
// 退出,父 Node 也要退出),但 shutdown 在前;两者用 shutdownInFlight 防
// 重入(防止 taskkill 触发的 child exit 又走 child.on('exit') 二次退出)。
let shutdownInFlight = false;
['SIGINT', 'SIGTERM', 'SIGHUP'].forEach((sig) => {
process.on(sig, () => {
if (shutdownInFlight) return;
shutdownInFlight = true;
shutdownTree(child, sig);
// 立即给上层一个非 0 退出码,不等 child.on('exit') 兜底
process.exit(128 + (sig === 'SIGINT' ? 2 : sig === 'SIGTERM' ? 15 : 1));
});
});
// audit 2026-10-01:补 error 监听 + 修正信号退出码。
// 1) 只监听 exit 时,spawn 失败(EAGAIN/EMFILE、cmd.exe 被策略软件拦截、
// cwd 不存在)产生的 'error' 事件无人处理 —— Node 会打一段内部栈,
// 与本文件其它分支精心设计的 "[start.cjs] ..." 中文提示风格割裂。
// 2) `code ?? 0` 把「被信号杀死」(code === null、signal 有值)吞成退出码 0,
// 于是 Ctrl+C / 任务管理器结束进程后 npm 看到的是成功,predev 链路
// 判定通过。信号退出应返回非 0。
child.on('error', (err) => {
console.error('[start.cjs] 启动子进程失败:', err && err.message ? err.message : err);
process.exit(1);
});
child.on('exit', (code, signal) => {
if (shutdownInFlight) {
// shutdownTree 已写过 process.exit,这里不再二次调用,防状态错乱
return;
}
if (signal) {
// 被信号终止:signal 名(如 'SIGTERM')转成 128+n 是 shell 惯例
const names = { SIGHUP: 1, SIGINT: 2, SIGQUIT: 3, SIGKILL: 9, SIGTERM: 15 };
process.exit(128 + (names[signal] || 1));
}
process.exit(code ?? 1);
});
/**
* 在 %LOCALAPPDATA%\rkms-junctions\<hash16> 建一个指向 realCwd 的 junction。
* 复用规则:
* - 已存在且指向 realCwd → 直接返回
* - 已存在但指向别的目标 → 删除重建
* - 不存在 → 创建
* - 已存在但不是 junction(实目录) → 返回 null(让上层走错误分支)
* - 创建失败 → 返回 null
*
* 可通过环境变量 RKMS_JUNCTION_DIR 覆盖父目录位置(给 LOCALAPPDATA 被 CJK 污染的场景用)。
*/
function setupWindowsJunction(realCwd) {
// audit round 8 (2026-09-14):显式 win32 guard —— 函数名带 "Windows",且
// 内部拼的是 Windows 专属的 %LOCALAPPDATA% 路径。加这条防御让函数自描述
// + 防未来跨平台误用。
if (process.platform !== 'win32') {
console.error('[start.cjs] setupWindowsJunction called on non-win32 platform');
return null;
}
const customBase = process.env.RKMS_JUNCTION_DIR;
// audit 2026-10-01:兜底从硬编码的 'C:\Users\guan'(原作者本机用户名)换成
// os.homedir()。原值有两处问题:一是把个人路径固化进要分发的脚本;
// 二是 LOCALAPPDATA 与 USERPROFILE 都缺失时会去**别人的** profile 下
// 建 junction,报出的错完全指不到真因。os.homedir() 走的是同样的系统
// 解析(USERPROFILE → HOMEDRIVE+HOMEPATH → 兜底),不需要自己拼。
const localAppData =
process.env.LOCALAPPDATA ||
path.join(process.env.USERPROFILE || os.homedir(), 'AppData', 'Local');
// 用 sha256 前 16 位而非 8 位,降低多项目/重建路径时的碰撞概率
const hash = crypto.createHash('sha256').update(realCwd).digest('hex').slice(0, 16);
const baseDir = customBase || path.join(localAppData, 'rkms-junctions');
const junctionDir = path.join(baseDir, hash);
const parentDir = path.dirname(junctionDir);
// 防御:junction 父目录自身不能含 CJK,否则 junction 路径还是坏的
if (/[^\x00-\x7F]/.test(junctionDir)) {
console.error(`[start.cjs] Computed junction path contains non-ASCII: ${junctionDir}`);
console.error('[start.cjs] Check your LOCALAPPDATA / RKMS_JUNCTION_DIR environment variable.');
return null;
}
// 1. 检查是否已存在
try {
const lst = fs.lstatSync(junctionDir);
if (lst.isDirectory()) {
let target = null;
try {
target = fs.readlinkSync(junctionDir);
} catch {
// 路径是目录但不是 junction/link → 冲突,不让 start.cjs 误删用户数据
console.error(`[start.cjs] ${junctionDir} exists but is not a junction.`);
console.error(
'[start.cjs] Remove it manually, or set RKMS_JUNCTION_DIR to a fresh ASCII dir.',
);
return null;
}
if (path.resolve(target) === path.resolve(realCwd)) {
console.log(`[start.cjs] Reusing junction: ${junctionDir}`);
return junctionDir;
}
// 指向别的目标 → 删了重建
console.log(`[start.cjs] Removing stale junction ${junctionDir} (-> ${target})`);
try {
fs.rmSync(junctionDir, { recursive: true, force: true });
} catch (e) {
console.error(`[start.cjs] Failed to remove stale junction:`, e.message);
return null;
}
} else {
// 路径存在但不是目录(可能是文件) → 删掉后重建
console.log(`[start.cjs] Removing non-directory at ${junctionDir}`);
try {
fs.unlinkSync(junctionDir);
} catch (e) {
console.error(`[start.cjs] Failed to unlink non-directory:`, e.message);
return null;
}
}
} catch (e) {
if (e.code !== 'ENOENT') {
console.error(`[start.cjs] Junction probe failed:`, e.message);
return null;
}
// 不存在 → 继续创建
}
// 2. 确保父目录存在
try {
fs.mkdirSync(parentDir, { recursive: true });
} catch (e) {
console.error(`[start.cjs] Failed to create parent dir ${parentDir}:`, e.message);
return null;
}
// 3. 建 junction
try {
// mklink /J 是 cmd 内置,必须经 cmd.exe 调用
execFileSync('cmd.exe', ['/c', 'mklink', '/J', junctionDir, realCwd], { stdio: 'pipe' });
console.log(`[start.cjs] Created junction: ${junctionDir} -> ${realCwd}`);
// 4. 创建后立刻验证 — 极少数情况下 mklink 成功但 readlink 行为异常
try {
const verify = fs.readlinkSync(junctionDir);
if (path.resolve(verify) !== path.resolve(realCwd)) {
console.error(`[start.cjs] Junction created but verification failed:`);
console.error(` expected -> ${realCwd}`);
console.error(` got -> ${verify}`);
return null;
}
} catch (e) {
console.error(`[start.cjs] Junction verify readlink failed:`, e.message);
return null;
}
return junctionDir;
} catch (e) {
// execFileSync 失败时 e.stderr 是 cmd 输出的多行文本(GBK/UTF-8 都有)
const stderr = e.stderr ? e.stderr.toString().trim() : e.message;
console.error(`[start.cjs] mklink failed:`);
console.error(` ${stderr.replace(/\r?\n/g, '\n ')}`);
return null;
}
}
/**
* 在 Electron 子进程里 try-load better-sqlite3 —— 这是判断 ABI 是否匹配最可靠的信号。
* 之前用 .node mtime vs electron.exe mtime 的方案有致命缺陷:electron-rebuild 解包
* prebuilt 后 mtime 永远是 2025-05-08(打包时间),无论 ABI 是否对齐,导致每次启动
* 都误判为 mismatch 而陷入 rebuild 死循环。
*
* 思路:用 spawnSync 起一个 electron.exe,设 ELECTRON_RUN_AS_NODE=1 让它像 node 一样
* 执行 probe 脚本。脚本里 require('better-sqlite3') + new Database(':memory:'),成功就
* exit 0,失败就 exit 1。这跑的是 Electron 内嵌的 Node,ABI 与主进程完全一致 —— 与主
* 进程 dlopen 是同一个动作的子集。
*
* @returns {{ok: boolean, reason?: string}}
*/
function probeBetterSqliteInElectron() {
let electronExePath;
try {
// 普通 node 进程下 require('electron') 返回 electron.exe 的字符串绝对路径
electronExePath = require('electron');
} catch {
return { ok: true, reason: 'electron module not resolvable (skipped)' };
}
if (typeof electronExePath !== 'string' || !fs.existsSync(electronExePath)) {
return { ok: true, reason: 'electron binary not at expected path (likely packaged; skipped)' };
}
const probeScript = [
'try {',
" const Db = require('better-sqlite3');",
" const db = new Db(':memory:');",
' db.close();',
' process.exit(0);',
'} catch (e) {',
" process.stderr.write('ABI_PROBE_FAIL: ' + (e && e.message ? e.message : String(e)));",
' process.exit(1);',
'}',
].join('\n');
let result;
try {
result = spawnSync(electronExePath, ['--no-deprecation', '-e', probeScript], {
cwd: process.cwd(),
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
encoding: 'utf8',
timeout: 30000,
});
} catch (e) {
return { ok: false, reason: 'spawn failed: ' + (e.message || String(e)) };
}
if (result.error) {
return { ok: false, reason: 'spawn error: ' + result.error.message };
}
if (result.status === 0) {
return { ok: true };
}
const reason =
(result.stderr || result.stdout || '').toString().trim() || `exit ${result.status}`;
return { ok: false, reason };
}
/**
* 检测 better-sqlite3 的 .node 是否匹配当前 Electron 内嵌 Node 的 ABI。
* 不匹配时(典型场景:升级了 Electron 但没重跑 npm install,postinstall 跳过)自动 rebuild。
*
* 依赖 probeBetterSqliteInElectron() —— 真正加载一次来确认 ABI。
* mtime 比较不可靠(prebuilt mtime 永远是打包时间),所以这里不再做。
*
* @param {object} [opts]
* @param {boolean} [opts.silent=false] verify-abi 子命令用 — 不打警告只返回结果
* @returns {boolean} true = OK 或已修复;false = 检测到 mismatch 但修复失败
*/
function checkAndFixNativeAbi(opts = {}) {
const { silent = false } = opts;
// Linux/macOS 上 .node 由 better-sqlite3 的 prebuilt 直接发,napi v3 跨 Node 版本 ABI 一致;
// 只在 Windows 上有可能出现 postinstall 装错 .node 的历史问题。
if (process.platform !== 'win32') return true;
const cwd = process.cwd();
const sqlitePath = path.resolve(
cwd,
'node_modules',
'better-sqlite3',
'build',
'Release',
'better_sqlite3.node',
);
// .node 缺失时**不能**放行。
// 旧逻辑在这里 return true(理由是"第一次 install,交给 postinstall"),但 postinstall
// 只在 npm install 时跑。一旦 build/ 被清空、或 electron-builder install-app-deps 静默失败,
// predev 的 verify-abi 就一路绿灯,直到主进程 dlopen 才炸 "Could not locate the bindings
// file" —— 而且是在后台 DB init 里被吞成 unhandledRejection + 满屏 "DB not initialized",
// 根因离现场很远。所以:缺失 == 需要 rebuild,和 ABI 不匹配走同一条修复路径。
let reason;
if (!fs.existsSync(sqlitePath)) {
reason = `missing ${path.relative(cwd, sqlitePath)}`;
} else {
// 真正的 ABI 检查 —— 在 Electron 子进程里 try-load
const probe = probeBetterSqliteInElectron();
if (probe.ok) {
if (!silent && probe.reason && probe.reason.includes('skipped')) {
console.log(`[start.cjs] ABI check: ${probe.reason}`);
}
return true;
}
reason = probe.reason || 'unknown';
}
if (!silent) {
console.warn('');
console.warn('=====================================================');
console.warn('[start.cjs] better-sqlite3 native binding unusable:');
console.warn(` ${reason}`);
console.warn('[start.cjs] Rebuilding better-sqlite3 against current Electron ABI...');
console.warn('=====================================================');
}
try {
execFileSync(
process.execPath,
// --no-snapshot:走到这里说明当前 .node 已经确认不可用(缺失或 ABI 不匹配),
// 不能把它快照成"上一个可用版本",否则会覆盖掉真正可用的那份快照。
[path.resolve(__dirname, 'rebuild-native.cjs'), '--no-snapshot'],
{ stdio: silent ? 'pipe' : 'inherit' },
);
} catch (e) {
if (silent) {
// verify-abi 路径:失败时仍输出 stderr 让 caller 看到 rebuild-native 的报错
if (e.stderr) process.stderr.write(e.stderr);
if (e.stdout) process.stdout.write(e.stdout);
} else {
console.error('[start.cjs] Auto-rebuild failed.');
console.error(' 请手动执行: npx electron-rebuild -f -w better-sqlite3');
console.error(' 或: npm run rebuild:native');
}
return false;
}
// Rebuild 完再 probe 一次,确认真的修好了 (理论上 rebuild 不会失败,但保险)
const recheck = probeBetterSqliteInElectron();
if (recheck.ok) {
if (!silent) console.log('[start.cjs] Rebuild succeeded, ABI matches.');
return true;
}
if (!silent) {
console.error('[start.cjs] Rebuild completed but ABI still mismatches:');
console.error(` ${recheck.reason || 'unknown'}`);
console.error(' 建议手动执行: npm rebuild better-sqlite3');
}
return false;
}

View File

@@ -0,0 +1,21 @@
#!/usr/bin/env node
// 检查 better-sqlite3 在当前 Node (system Node, ABI=N) 下能否加载。
// 不可加载时 exit 1,由 npm pretest hook 触发 `npm rebuild better-sqlite3`。
//
// 与 scripts/start.cjs 的 verify-abi 互为镜像:
// - verify-abi → 检查 Electron 内嵌 Node 的 ABI(ELECTRON_RUN_AS_NODE 子进程)
// - verify-abi-node → 检查 system Node 的 ABI(直接 require)
//
// 两者都需要的原因:better-sqlite3 不是 N-API,每个 .node 只对应一个 ABI。
// Electron 30.5.1 用 ABI 123,系统 Node 20.15.x 用 ABI 115 —— 不能共用。
// dev 时调 verify-abi(配合 electron-rebuild),test 时调本脚本(配合 npm rebuild)。
try {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.close();
process.exit(0);
} catch (e) {
process.stderr.write(`[verify-abi-node] better-sqlite3 failed under system Node: ${e && e.message ? e.message : e}\n`);
process.exit(1);
}