@luckychen1993/pm-mcp 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/security.js CHANGED
@@ -1,8 +1,10 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
1
3
  import { createHash } from "node:crypto";
2
4
  import { loadExtraRules, loadProject, loadSecurity, saveSecurity } from "./store.js";
3
5
  import { latestSnapshot } from "./audit.js";
4
6
  import { readDirectDeps } from "./scan.js";
5
- import { ensureFresh, getIndex, iterateFileRows } from "./index-store.js";
7
+ import { ensureFresh, getIndex, iterateFileRows, walkRefresh } from "./index-store.js";
6
8
  import { readText } from "./search.js";
7
9
  import { now } from "./types.js";
8
10
  const SECRET_PATTERNS = [
@@ -69,8 +71,37 @@ function findSecretRule(value) {
69
71
  function fingerprint(ruleId, rel, matched) {
70
72
  return createHash("sha1").update(ruleId + "|" + rel + "|" + matched).digest("hex").slice(0, 16);
71
73
  }
72
- function scanDetections(root) {
74
+ /** 只有根内普通路径的 ENOENT 才证明删除;权限错误、目录和链接均不证明修复。 */
75
+ function findingFileState(root, rel) {
76
+ const parts = rel.replace(/\\/g, "/").split("/");
77
+ if (path.isAbsolute(rel) || parts.some((part) => !part || part === "." || part === ".."))
78
+ return "unknown";
79
+ let current = root;
80
+ let stat;
81
+ for (const part of parts) {
82
+ current = path.join(current, part);
83
+ try {
84
+ stat = fs.lstatSync(current);
85
+ if (stat.isSymbolicLink())
86
+ return "unknown";
87
+ }
88
+ catch (error) {
89
+ return error.code === "ENOENT" ? "missing" : "unknown";
90
+ }
91
+ }
92
+ return stat?.isFile() ? "file" : "unknown";
93
+ }
94
+ function scanDetections(root, forceContent = false, indexPrepared = false) {
73
95
  const out = [];
96
+ // 只跟踪已有 open 发现,不为百万文件仓库额外物化整棵扫描覆盖表。
97
+ const pending = loadSecurity(root).findings.filter((finding) => finding.status === "open");
98
+ const byFile = new Map();
99
+ for (const finding of pending) {
100
+ const entries = byFile.get(finding.file) ?? [];
101
+ entries.push(finding);
102
+ byFile.set(finding.file, entries);
103
+ }
104
+ const checkedFindings = new Map();
74
105
  const rules = [...SECRET_PATTERNS, ...DANGEROUS_PATTERNS];
75
106
  // 用户自定义规则
76
107
  for (const r of loadExtraRules(root).rules) {
@@ -87,7 +118,8 @@ function scanDetections(root) {
87
118
  /* 非法自定义规则跳过 */
88
119
  }
89
120
  }
90
- ensureFresh(root);
121
+ if (!indexPrepared)
122
+ ensureFresh(root);
91
123
  const db = getIndex(root);
92
124
  // .env 类文件存在性检查(是否被 git 提交需人工确认)
93
125
  for (const row of iterateFileRows(db, { excludeOversize: false })) {
@@ -109,9 +141,10 @@ function scanDetections(root) {
109
141
  continue;
110
142
  if (rel.startsWith(".pm/"))
111
143
  continue;
112
- const content = readText(root, rel);
144
+ const content = readText(root, rel, forceContent);
113
145
  if (content === null)
114
146
  continue;
147
+ const contentDigest = byFile.has(rel) ? createHash("sha256").update(content).digest("hex") : undefined;
115
148
  const lines = content.split("\n");
116
149
  for (const rule of rules) {
117
150
  if (rule.glob && !rule.glob.test(rel))
@@ -130,15 +163,33 @@ function scanDetections(root) {
130
163
  message: rule.message,
131
164
  });
132
165
  }
166
+ for (const finding of byFile.get(rel) ?? []) {
167
+ if (finding.rule === rule.id)
168
+ checkedFindings.set(finding.fingerprint, contentDigest);
169
+ }
133
170
  }
134
171
  }
135
- return out;
172
+ for (const finding of pending) {
173
+ if (!checkedFindings.has(finding.fingerprint) && findingFileState(root, finding.file) === "missing") {
174
+ checkedFindings.set(finding.fingerprint, null);
175
+ }
176
+ }
177
+ return { detections: out, checkedFindings };
178
+ }
179
+ /** 昂贵的全仓扫描阶段;调用方可在账本锁外完成,再把脱敏发现提交到账本。 */
180
+ export function prepareSecurityAudit(root, options = {}) {
181
+ const forceIndex = options.forceIndex ?? true;
182
+ const forceContent = options.forceContent ?? true;
183
+ if (forceIndex)
184
+ walkRefresh(root, { forceContent: true });
185
+ return scanDetections(root, forceContent, forceIndex || options.indexPrepared === true);
136
186
  }
137
187
  /** 扫描并更新台账(幂等:重复扫描不产生重复条目) */
138
- export function auditSecurity(root) {
188
+ export function auditSecurity(root, prepared) {
139
189
  const project = loadProject(root);
140
190
  const stamp = now();
141
- const detections = scanDetections(root);
191
+ const scan = prepared ?? scanDetections(root);
192
+ const detections = scan.detections;
142
193
  const security = loadSecurity(root);
143
194
  const existing = new Map(security.findings.map((f) => [f.fingerprint, f]));
144
195
  const acceptedRefound = [];
@@ -177,10 +228,25 @@ export function auditSecurity(root) {
177
228
  });
178
229
  }
179
230
  }
180
- // 不再检出且仍 open 的 → 自动转 fixed(附注说明)
231
+ // 没有命中不等于已修复:必须证明该旧发现的文件/规则已检查,或文件确已删除。
181
232
  let autoFixed = 0;
233
+ let unverified = 0;
234
+ const currentDigests = new Map();
182
235
  for (const f of security.findings) {
183
236
  if (f.status === "open" && !seen.has(f.fingerprint)) {
237
+ const expected = scan.checkedFindings?.get(f.fingerprint);
238
+ let confirmed = expected === null && findingFileState(root, f.file) === "missing";
239
+ if (typeof expected === "string") {
240
+ if (!currentDigests.has(f.file)) {
241
+ const content = findingFileState(root, f.file) === "file" ? readText(root, f.file, true) : null;
242
+ currentDigests.set(f.file, content === null ? null : createHash("sha256").update(content).digest("hex"));
243
+ }
244
+ confirmed = currentDigests.get(f.file) === expected;
245
+ }
246
+ if (!confirmed) {
247
+ unverified += 1;
248
+ continue;
249
+ }
184
250
  f.status = "fixed";
185
251
  f.note += "(自动:最近一次扫描未再检出)";
186
252
  autoFixed += 1;
@@ -227,7 +293,9 @@ export function auditSecurity(root) {
227
293
  }
228
294
  }
229
295
  if (autoFixed > 0)
230
- L.push(`自动关闭 ${autoFixed} 个(代码中已不再出现)。`);
296
+ L.push(`自动关闭 ${autoFixed} 个(已成功重扫未命中或确认文件删除)。`);
297
+ if (unverified > 0)
298
+ L.push(`⚠️ 扫描覆盖不足:${unverified} 个旧发现未获得完整检查证据,保持未解决状态(检查文件大小、可读性、扫描范围和规则)。`);
231
299
  if (newDeps.length > 0)
232
300
  L.push(`⚠️ 上次快照后新增依赖: ${newDeps.join(", ")}(AI 引入的依赖请审查来源与必要性)`);
233
301
  if (riskyDeps.length > 0)
package/dist/setup.js CHANGED
@@ -2,11 +2,15 @@ import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { spawnSync } from "node:child_process";
5
- export const PACKAGE_SPEC = "@luckychen1993/pm-mcp@0.1.3";
5
+ import { createHash, randomUUID } from "node:crypto";
6
+ import { initProject } from "./init.js";
7
+ import { isInitialized } from "./paths.js";
8
+ import { refreshDerived } from "./dashboard.js";
9
+ export const PACKAGE_SPEC = "@luckychen1993/pm-mcp@0.1.5";
6
10
  export const SERVER_NAME = "pm-mcp";
7
11
  const CLIENTS = ["codex", "claude", "zcode", "cursor", "vscode"];
8
12
  export function parseSetupArgs(argv) {
9
- const options = { client: "auto", force: false, dryRun: false, help: false };
13
+ const options = { client: "auto", force: false, dryRun: false, help: false, project: false };
10
14
  for (let index = 0; index < argv.length; index += 1) {
11
15
  const arg = argv[index];
12
16
  if (arg === "--client") {
@@ -17,6 +21,14 @@ export function parseSetupArgs(argv) {
17
21
  options.client = value;
18
22
  index += 1;
19
23
  }
24
+ else if (arg === "--project") {
25
+ options.project = true;
26
+ const value = argv[index + 1];
27
+ if (value && !value.startsWith("--")) {
28
+ options.projectDir = value;
29
+ index += 1;
30
+ }
31
+ }
20
32
  else if (arg === "--force") {
21
33
  options.force = true;
22
34
  }
@@ -82,7 +94,7 @@ export function mergeJsonServer(document, shape) {
82
94
  }
83
95
  function backupName(file) {
84
96
  const stamp = new Date().toISOString().replace(/[-:.TZ]/g, "");
85
- return `${file}.backup-${stamp}-${process.pid}`;
97
+ return `${file}.backup-${stamp}-${process.pid}-${randomUUID()}`;
86
98
  }
87
99
  export function writeJsonClientConfig(file, shape, dryRun = false) {
88
100
  const absolute = path.resolve(file);
@@ -146,6 +158,86 @@ function appendCodexConfig(file, dryRun, log) {
146
158
  const block = `${separator}\n[mcp_servers.${SERVER_NAME}]\ncommand = "npx"\nargs = ["-y", "${PACKAGE_SPEC}"]\n`;
147
159
  fs.appendFileSync(absolute, block, "utf8");
148
160
  }
161
+ function canonicalProjectRoot(projectRoot) {
162
+ const absolute = path.resolve(projectRoot);
163
+ let resolved;
164
+ try {
165
+ resolved = fs.realpathSync.native(absolute);
166
+ }
167
+ catch (error) {
168
+ if (error.code !== "ENOENT")
169
+ throw error;
170
+ resolved = absolute;
171
+ }
172
+ return process.platform === "win32" ? resolved.toLowerCase() : resolved;
173
+ }
174
+ function legacyProjectServerKey(projectRoot) {
175
+ const base = path.basename(projectRoot);
176
+ const safe = base.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
177
+ return `${SERVER_NAME}-${safe || "project"}`;
178
+ }
179
+ /** 完整规范路径参与命名,区分中文目录和位于不同父目录下的同名项目。 */
180
+ export function codexProjectServerKey(projectRoot) {
181
+ const canonical = canonicalProjectRoot(projectRoot);
182
+ return `${legacyProjectServerKey(canonical)}-${createHash("sha256").update(canonical).digest("hex").slice(0, 12)}`;
183
+ }
184
+ /** 只解析本安装器生成的单行 JSON 兼容 args;手写复杂 TOML 不猜测其绑定。 */
185
+ function projectEntry(text, server) {
186
+ const header = new RegExp(`^\\s*\\[mcp_servers\\.(?:${server}|"${server}"|'${server}')\\]\\s*(?:#.*)?$`, "m").exec(text);
187
+ if (!header)
188
+ return undefined;
189
+ const tail = text.slice(header.index + header[0].length);
190
+ const nextHeader = tail.search(/^\s*\[/m);
191
+ const section = nextHeader < 0 ? tail : tail.slice(0, nextHeader);
192
+ const argsLine = /^\s*args\s*=\s*(\[[^\r\n]*\])\s*(?:#.*)?$/m.exec(section);
193
+ try {
194
+ const args = argsLine ? JSON.parse(argsLine[1]) : undefined;
195
+ if (!Array.isArray(args) || !args.every((value) => typeof value === "string"))
196
+ return {};
197
+ const positions = args.flatMap((value, index) => value === "--root" ? [index] : []);
198
+ if (positions.length !== 1 || !path.isAbsolute(args[positions[0] + 1] ?? ""))
199
+ return {};
200
+ return { root: canonicalProjectRoot(args[positions[0] + 1]) };
201
+ }
202
+ catch {
203
+ return {};
204
+ }
205
+ }
206
+ /**
207
+ * --project 模式:把指定目录作为钉定项目写进 Codex config.toml(npx + --root)。
208
+ * 桌面版 Codex 起 MCP 服务时 cwd 不在工作区,必须逐项目钉根;同名条目已存在则跳过(fail-closed,不产生重复 TOML 表头)。
209
+ */
210
+ export function appendCodexProjectConfig(file, projectRoot, force, dryRun, log) {
211
+ const absolute = path.resolve(file);
212
+ const server = codexProjectServerKey(projectRoot);
213
+ const canonical = canonicalProjectRoot(projectRoot);
214
+ const existing = fs.existsSync(absolute) ? fs.readFileSync(absolute, "utf8") : "";
215
+ const entry = projectEntry(existing, server);
216
+ const legacy = legacyProjectServerKey(canonical);
217
+ const legacyEntry = projectEntry(existing, legacy);
218
+ if (entry && entry.root !== canonical) {
219
+ throw new Error(`已有 [mcp_servers.${server}] 指向不同项目或无法确认 --root;未修改配置。`);
220
+ }
221
+ if (entry || legacyEntry?.root === canonical) {
222
+ const reused = entry ? server : legacy;
223
+ if (!force)
224
+ log(`[skip] Codex already has [mcp_servers.${reused}] for this project; nothing to change.`);
225
+ else
226
+ throw new Error(`Codex already has [mcp_servers.${reused}]; 请先手工删除该段再 --force 重加。`);
227
+ return;
228
+ }
229
+ log(`[${dryRun ? "plan" : "write"}] Codex: ${absolute}`);
230
+ if (dryRun)
231
+ return;
232
+ fs.mkdirSync(path.dirname(absolute), { recursive: true });
233
+ if (fs.existsSync(absolute))
234
+ fs.copyFileSync(absolute, backupName(absolute), fs.constants.COPYFILE_EXCL);
235
+ const tomlRoot = path.resolve(projectRoot).replace(/\\/g, "/");
236
+ const separator = existing.length === 0 || existing.endsWith("\n") ? "" : "\n";
237
+ const block = `${separator}\n[mcp_servers.${server}]\ncommand = "npx"\nargs = ["-y", ${JSON.stringify(PACKAGE_SPEC)}, "--root", ${JSON.stringify(tomlRoot)}]\n`;
238
+ fs.appendFileSync(absolute, block, "utf8");
239
+ log(`[entry] [mcp_servers.${server}] → --root ${tomlRoot}`);
240
+ }
149
241
  function run(command, args, visible) {
150
242
  const result = spawnSync(command, args, {
151
243
  shell: false,
@@ -177,8 +269,12 @@ function configureCliClient(client, options, available, log) {
177
269
  throw new Error(`${client} could not add ${SERVER_NAME}`);
178
270
  return true;
179
271
  }
180
- function configureClient(client, options, context, log) {
272
+ function configureClient(client, options, context, log, projectRoot) {
181
273
  if (client === "codex") {
274
+ if (options.project && projectRoot) {
275
+ appendCodexProjectConfig(codexConfigPath(context.env, context.home), projectRoot, options.force, options.dryRun, log);
276
+ return;
277
+ }
182
278
  if (configureCliClient("codex", options, context.commandExists("codex"), log))
183
279
  return;
184
280
  appendCodexConfig(codexConfigPath(context.env, context.home), options.dryRun, log);
@@ -207,8 +303,9 @@ function configureClient(client, options, context, log) {
207
303
  function printHelp(log) {
208
304
  log("pm-mcp setup — configure local AI coding clients");
209
305
  log("");
210
- log(`Usage: npx -y ${PACKAGE_SPEC} setup [--client auto|all|codex|claude|zcode|cursor|vscode|print] [--force] [--dry-run]`);
306
+ log(`Usage: npx -y ${PACKAGE_SPEC} setup [--client auto|all|codex|claude|zcode|cursor|vscode|print] [--project [dir]] [--force] [--dry-run]`);
211
307
  log("Default auto mode configures every detected supported client.");
308
+ log("--project [dir]: 在 Codex 把该目录注册为钉定项目并初始化 .pm(新项目一条命令上手;默认当前目录)");
212
309
  }
213
310
  export function runSetup(argv, overrides = {}) {
214
311
  const options = parseSetupArgs(argv);
@@ -226,24 +323,42 @@ export function runSetup(argv, overrides = {}) {
226
323
  const context = {
227
324
  env,
228
325
  home,
326
+ cwd: overrides.cwd,
229
327
  commandExists: overrides.commandExists ?? ((command) => commandExists(command, env)),
230
328
  exists: overrides.exists ?? fs.existsSync,
231
329
  };
330
+ const projectRoot = options.project ? path.resolve(options.projectDir?.trim() || context.cwd || process.cwd()) : undefined;
232
331
  const selected = options.client === "all"
233
332
  ? CLIENTS
234
333
  : options.client === "auto"
235
334
  ? detectClients(context)
236
335
  : [options.client];
237
- if (selected.length === 0) {
336
+ if (selected.length === 0 && !options.project) {
238
337
  log("No supported client was detected. Use --client <name>, or --client print for generic JSON.");
239
338
  return 1;
240
339
  }
241
340
  log(`pm-mcp setup ${PACKAGE_SPEC}`);
242
- log(`clients: ${selected.join(", ")}`);
341
+ if (selected.length > 0)
342
+ log(`clients: ${selected.join(", ")}`);
343
+ if (options.project && projectRoot) {
344
+ const name = path.basename(projectRoot) || "project";
345
+ log(`project: ${projectRoot}`);
346
+ if (options.dryRun) {
347
+ log(`[plan] init_project ${name}(.pm/ + PROJECT.md + AGENTS.md 工作规矩)`);
348
+ }
349
+ else if (isInitialized(projectRoot)) {
350
+ log("[skip] 项目已初始化(.pm/project.json 已存在)。");
351
+ }
352
+ else {
353
+ initProject(projectRoot, { name });
354
+ refreshDerived(projectRoot);
355
+ log(`[init] ✅ ${name} 已初始化:.pm/ 状态目录 + PROJECT.md 仪表盘 + AGENTS.md 工作规矩`);
356
+ }
357
+ }
243
358
  let failures = 0;
244
359
  for (const client of selected) {
245
360
  try {
246
- configureClient(client, options, context, log);
361
+ configureClient(client, options, context, log, projectRoot);
247
362
  }
248
363
  catch (error) {
249
364
  failures += 1;
@@ -252,6 +367,11 @@ export function runSetup(argv, overrides = {}) {
252
367
  }
253
368
  if (failures > 0)
254
369
  return 1;
255
- log("Done. Restart the configured client and ask it to call pm-mcp get_status.");
370
+ if (options.project && selected.includes("codex")) {
371
+ log("完成。请完全退出并重启 Codex,开新会话即可使用(工具名前缀 pm-mcp-<项目名>)。");
372
+ }
373
+ else {
374
+ log("Done. Restart the configured client and ask it to call pm-mcp get_status.");
375
+ }
256
376
  return 0;
257
377
  }
package/dist/store.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { randomUUID } from "node:crypto";
4
+ import { DatabaseSync } from "node:sqlite";
4
5
  import { DebugLogFileSchema, FeaturesFileSchema, FileNotesFileSchema, now, ProjectSchema, RoadmapFileSchema, SecurityFileSchema, SessionsFileSchema, TasksFileSchema, } from "./types.js";
5
6
  import { DEBUGLOG_JSON, EXTRA_RULES_FILE, FEATURES_JSON, FILE_NOTES_JSON, PROJECT_JSON, ROADMAP_JSON, SECURITY_JSON, SESSIONS_JSON, TASKS_JSON, pmPath, } from "./paths.js";
6
7
  import { ExtraRulesFileSchema } from "./types.js";
@@ -38,92 +39,208 @@ export function atomicWrite(file, content) {
38
39
  export function saveJson(file, data) {
39
40
  atomicWrite(file, JSON.stringify(data, null, 2) + "\n");
40
41
  }
42
+ const FILE_LOCK_TIMEOUT_MS = 3_000;
43
+ const FILE_LOCK_STALE_MS = 10_000;
44
+ const FILE_LOCK_POLL_MS = 20;
45
+ const FILE_LOCK_PROTOCOL = "sqlite-guard-v1";
46
+ const FILE_LOCK_WAIT = new Int32Array(new SharedArrayBuffer(4));
47
+ function readFileLockOwner(lockFile) {
48
+ let raw;
49
+ try {
50
+ raw = fs.readFileSync(lockFile, "utf8");
51
+ }
52
+ catch (error) {
53
+ if (error.code === "ENOENT")
54
+ return null;
55
+ throw error;
56
+ }
57
+ try {
58
+ const parsed = JSON.parse(raw);
59
+ if (!Number.isSafeInteger(parsed.pid) || parsed.pid <= 0)
60
+ return null;
61
+ return {
62
+ pid: parsed.pid,
63
+ ...(typeof parsed.token === "string" && parsed.token.length > 0 ? { token: parsed.token } : {}),
64
+ ...(typeof parsed.created_at === "string" && parsed.created_at.length > 0 ? { created_at: parsed.created_at } : {}),
65
+ ...(typeof parsed.protocol === "string" && parsed.protocol.length > 0 ? { protocol: parsed.protocol } : {}),
66
+ };
67
+ }
68
+ catch {
69
+ const pid = Number(raw.trim());
70
+ return Number.isSafeInteger(pid) && pid > 0 ? { pid } : null;
71
+ }
72
+ }
73
+ function fileLockOwnerAlive(pid) {
74
+ try {
75
+ process.kill(pid, 0);
76
+ return true;
77
+ }
78
+ catch (error) {
79
+ // EPERM proves that the process exists even when signalling it is forbidden.
80
+ return error.code === "EPERM";
81
+ }
82
+ }
83
+ function sqliteBusy(error) {
84
+ const candidate = error;
85
+ return candidate.errcode === 5 ||
86
+ (typeof candidate.message === "string" && /database is (?:locked|busy)/i.test(candidate.message));
87
+ }
88
+ function rollbackRecoveryGuard(db) {
89
+ if (!db.isTransaction)
90
+ return;
91
+ try {
92
+ db.exec("ROLLBACK");
93
+ }
94
+ catch {
95
+ // close() below is the final fail-safe and also rolls an open transaction back.
96
+ }
97
+ }
41
98
  /**
42
- * 账本互斥锁:把「读账本 → 改 → 写」整个事务串行化,多客户端(如 ZCode+Codex 并行)
43
- * 不再互相覆盖丢数据。锁文件 .pm/.lock(O_EXCL 创建);超过 10s 且持锁进程已死亡
44
- * 才视为残留并接管。活着的长任务不会被误抢锁;释放时也只删除自己的 token。
45
- * 等待上限 3s,超时明确报错而非静默丢写。
99
+ * 通用跨进程文件锁。SQLite 写事务覆盖 .lock 的检查、接管、创建、调用方临界区和
100
+ * 释放,从而消除 check-then-rm ABA,并在进程崩溃时由操作系统释放真实所有权。
101
+ * 成功取得同一 guard 后,带当前 protocol 的残留 .lock 可立即核销,即使 PID 已复用;
102
+ * 无 protocol 的旧版锁仍须「超过 staleMs 且 PID 已死」才接管,以兼容滚动升级。
103
+ * 释放必须完整匹配 protocol、PID、token 与 created_at,否则保留并 fail-closed。
104
+ * 共享同一 guardFile 的不同 lockFile 会串行,调用方应只在短元数据事务中共享 guard。
46
105
  */
47
- export function withLedgerLock(root, fn) {
48
- const lockFile = pmPath(root, ".lock");
49
- fs.mkdirSync(pmPath(root), { recursive: true });
106
+ export function withFileRecoveryLock(lockFile, fn, options = {}) {
107
+ const timeoutMs = options.timeoutMs ?? FILE_LOCK_TIMEOUT_MS;
108
+ const staleMs = options.staleMs ?? FILE_LOCK_STALE_MS;
109
+ const guardFile = options.guardFile ?? path.join(path.dirname(lockFile), ".file-recovery-guard.db");
110
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || !Number.isFinite(staleMs) || staleMs < 0) {
111
+ throw new RangeError("文件锁 timeoutMs 必须大于 0,staleMs 不得小于 0。");
112
+ }
113
+ fs.mkdirSync(path.dirname(lockFile), { recursive: true });
114
+ fs.mkdirSync(path.dirname(guardFile), { recursive: true });
50
115
  const token = randomUUID();
51
- const payload = JSON.stringify({ pid: process.pid, token, created_at: new Date().toISOString() });
52
- const owner = () => {
116
+ const createdAt = new Date().toISOString();
117
+ const payload = JSON.stringify({ pid: process.pid, token, created_at: createdAt, protocol: FILE_LOCK_PROTOCOL });
118
+ const deadline = Date.now() + timeoutMs;
119
+ let fileLockHeld = false;
120
+ const acquireFileLock = () => {
53
121
  try {
54
- const raw = fs.readFileSync(lockFile, "utf8");
55
- try {
56
- const parsed = JSON.parse(raw);
57
- return typeof parsed.pid === "number"
58
- ? { pid: parsed.pid, ...(typeof parsed.token === "string" ? { token: parsed.token } : {}) }
59
- : null;
60
- }
61
- catch {
62
- const pid = Number(raw.trim());
63
- return Number.isInteger(pid) && pid > 0 ? { pid } : null;
64
- }
122
+ fs.writeFileSync(lockFile, payload, { flag: "wx" });
123
+ return true;
65
124
  }
66
- catch {
67
- return null;
125
+ catch (error) {
126
+ if (error.code !== "EEXIST")
127
+ throw error;
68
128
  }
69
- };
70
- const alive = (pid) => {
129
+ let stat;
71
130
  try {
72
- process.kill(pid, 0);
73
- return true;
131
+ stat = fs.statSync(lockFile);
74
132
  }
75
133
  catch (error) {
76
- return error.code === "EPERM";
134
+ if (error.code === "ENOENT")
135
+ return false;
136
+ throw error;
137
+ }
138
+ const current = readFileLockOwner(lockFile);
139
+ const guardedResidual = current?.protocol === FILE_LOCK_PROTOCOL;
140
+ if (!guardedResidual && (Date.now() - stat.mtimeMs <= staleMs || (current && fileLockOwnerAlive(current.pid)))) {
141
+ return false;
142
+ }
143
+ // Every current-version acquirer and releaser holds the SQLite write transaction
144
+ // here, so no compliant peer can replace .lock between this removal and creation.
145
+ try {
146
+ fs.rmSync(lockFile);
147
+ }
148
+ catch (error) {
149
+ if (error.code === "ENOENT")
150
+ return false;
151
+ throw error;
77
152
  }
78
- };
79
- const acquire = () => {
80
153
  try {
81
154
  fs.writeFileSync(lockFile, payload, { flag: "wx" });
82
155
  return true;
83
156
  }
84
- catch {
157
+ catch (error) {
158
+ if (error.code === "EEXIST")
159
+ return false;
160
+ throw error;
161
+ }
162
+ };
163
+ const beginGuard = (db, until) => {
164
+ while (Date.now() < until) {
165
+ const remaining = until - Date.now();
166
+ if (remaining <= 0)
167
+ return false;
168
+ db.exec(`PRAGMA busy_timeout=${Math.max(1, Math.min(100, remaining))}`);
85
169
  try {
86
- const st = fs.statSync(lockFile);
87
- const current = owner();
88
- if (Date.now() - st.mtimeMs > 10_000 && (!current || !alive(current.pid))) {
89
- fs.rmSync(lockFile, { force: true });
90
- try {
91
- fs.writeFileSync(lockFile, payload, { flag: "wx" });
92
- return true;
93
- }
94
- catch {
95
- return false;
96
- }
97
- }
170
+ db.exec("BEGIN IMMEDIATE");
171
+ return true;
98
172
  }
99
- catch {
100
- /* 锁文件刚好消失,下轮重试 */
173
+ catch (error) {
174
+ if (!sqliteBusy(error))
175
+ throw error;
176
+ const pause = Math.min(FILE_LOCK_POLL_MS, Math.max(0, until - Date.now()));
177
+ if (pause > 0)
178
+ Atomics.wait(FILE_LOCK_WAIT, 0, 0, pause);
101
179
  }
102
- return false;
103
180
  }
181
+ return false;
104
182
  };
105
- let held = false;
106
- for (let i = 0; i < 150 && !held; i++) {
107
- held = acquire();
108
- if (!held)
109
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20);
110
- }
111
- if (!held) {
112
- throw new Error("账本锁获取超时(3s):另一进程正在写入本项目,请稍后重试。");
113
- }
183
+ const acquisitionGuard = new DatabaseSync(guardFile);
114
184
  try {
115
- return fn();
185
+ while (!fileLockHeld && Date.now() < deadline) {
186
+ if (!beginGuard(acquisitionGuard, deadline))
187
+ break;
188
+ try {
189
+ fileLockHeld = acquireFileLock();
190
+ }
191
+ catch (error) {
192
+ rollbackRecoveryGuard(acquisitionGuard);
193
+ throw error;
194
+ }
195
+ if (!fileLockHeld) {
196
+ rollbackRecoveryGuard(acquisitionGuard);
197
+ const pause = Math.min(FILE_LOCK_POLL_MS, Math.max(0, deadline - Date.now()));
198
+ if (pause > 0)
199
+ Atomics.wait(FILE_LOCK_WAIT, 0, 0, pause);
200
+ }
201
+ }
202
+ if (!fileLockHeld) {
203
+ throw new Error(options.timeoutMessage ?? "文件锁获取超时(3s),请稍后重试。");
204
+ }
205
+ try {
206
+ return fn();
207
+ }
208
+ finally {
209
+ try {
210
+ const current = readFileLockOwner(lockFile);
211
+ if (current?.protocol === FILE_LOCK_PROTOCOL &&
212
+ current.pid === process.pid &&
213
+ current.token === token &&
214
+ current.created_at === createdAt) {
215
+ fs.rmSync(lockFile);
216
+ }
217
+ }
218
+ catch {
219
+ // Ownership cannot be proven: leave the file for guarded recovery.
220
+ }
221
+ }
116
222
  }
117
223
  finally {
224
+ rollbackRecoveryGuard(acquisitionGuard);
118
225
  try {
119
- if (owner()?.token === token)
120
- fs.rmSync(lockFile, { force: true });
226
+ acquisitionGuard.close();
121
227
  }
122
228
  catch {
123
- /* 尽力释放 */
229
+ /* close after rollback is best effort */
124
230
  }
125
231
  }
126
232
  }
233
+ /**
234
+ * 账本专用互斥锁:把「读账本 → 改 → 写」整个事务串行化。活着的长任务不会被误抢,
235
+ * 等待上限 3s,超时明确报错而非静默丢写。
236
+ */
237
+ export function withLedgerLock(root, fn) {
238
+ const runtimeDirectory = pmPath(root, ".runtime");
239
+ return withFileRecoveryLock(pmPath(root, ".lock"), fn, {
240
+ guardFile: path.join(runtimeDirectory, "ledger-lock.db"),
241
+ timeoutMessage: "账本锁获取超时(3s):另一进程正在写入本项目,请稍后重试。",
242
+ });
243
+ }
127
244
  export function loadJson(file, schema, fallback) {
128
245
  if (!fs.existsSync(file))
129
246
  return fallback;