@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/LICENSE +21 -21
- package/README.md +23 -8
- package/dist/agents-md.js +51 -0
- package/dist/audit-tools.js +8 -7
- package/dist/audit.js +10 -5
- package/dist/budget.js +36 -3
- package/dist/idempotency.js +201 -67
- package/dist/index-store.js +163 -94
- package/dist/index.js +35 -17
- package/dist/init.js +3 -0
- package/dist/license.js +7 -4
- package/dist/paths.js +35 -0
- package/dist/project-tools.js +23 -2
- package/dist/search.js +3 -3
- package/dist/security.js +77 -9
- package/dist/setup.js +129 -9
- package/dist/store.js +175 -58
- package/dist/tool-base.js +66 -7
- package/dist/usage-log.js +278 -0
- package/dist/watcher-coordinator.js +182 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
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
|
|
48
|
-
const
|
|
49
|
-
|
|
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
|
|
52
|
-
const
|
|
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
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
|
|
73
|
-
return true;
|
|
131
|
+
stat = fs.statSync(lockFile);
|
|
74
132
|
}
|
|
75
133
|
catch (error) {
|
|
76
|
-
|
|
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
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|