update
This commit is contained in:
160
scripts/commit-msg-preprocess.cjs
Normal file
160
scripts/commit-msg-preprocess.cjs
Normal 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
203
scripts/rebuild-native.cjs
Normal 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
109
scripts/run-tests.cjs
Normal 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
72
scripts/shutdown.cjs
Normal 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
529
scripts/start.cjs
Normal 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;
|
||||
}
|
||||
21
scripts/verify-abi-node.cjs
Normal file
21
scripts/verify-abi-node.cjs
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user