@luckychen1993/pm-mcp 0.1.4 → 0.3.0
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/README.md +119 -14
- package/dist/agents-md.js +51 -0
- package/dist/audit.js +25 -21
- package/dist/budget.js +36 -3
- package/dist/cli.js +32 -0
- package/dist/dashboard.js +6 -4
- package/dist/doctor.js +148 -0
- package/dist/git-state.js +75 -0
- package/dist/governance-tools.js +12 -2
- package/dist/index-db.js +40 -0
- package/dist/index-store.js +35 -44
- package/dist/index.js +29 -9
- package/dist/init.js +3 -0
- package/dist/knowledge-tools.js +6 -15
- package/dist/language-adapters.js +11 -10
- package/dist/license.js +28 -2
- package/dist/paths.js +1 -1
- package/dist/probe.js +141 -0
- package/dist/project-fingerprint.js +9 -5
- package/dist/project-tools.js +35 -3
- package/dist/quality-evidence.js +56 -0
- package/dist/quality-store.js +5 -25
- package/dist/registry.js +28 -2
- package/dist/scan-policy.js +34 -0
- package/dist/scan.js +5 -25
- package/dist/search.js +5 -1
- package/dist/security.js +66 -5
- package/dist/semantic-evidence-store.js +22 -0
- package/dist/semantic-parsers.js +18 -0
- package/dist/session-log.js +14 -0
- package/dist/setup-project.js +146 -0
- package/dist/setup.js +136 -18
- package/dist/task-evidence.js +110 -0
- package/dist/task-pagination.js +64 -0
- package/dist/task-tools.js +47 -37
- package/dist/tool-base.js +70 -9
- package/dist/types.js +5 -0
- package/dist/usage-log.js +278 -0
- package/dist/version.js +10 -0
- package/package.json +7 -4
package/dist/scan.js
CHANGED
|
@@ -2,33 +2,13 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { globToRegExp, normSep } from "./budget.js";
|
|
4
4
|
import { aggregates, getIndex, walkRefresh } from "./index-store.js";
|
|
5
|
+
import { SCAN_IGNORE_DIRS, ignoreMatcher, projectScanIgnores } from "./scan-policy.js";
|
|
5
6
|
/**
|
|
6
7
|
* 代码树扫描:低层走查(生成器,内存恒定)+ scanProject 聚合入口。
|
|
7
8
|
* 内容级结果存 SQLite 索引(index-store),未变更文件永不重读。
|
|
8
9
|
* 超大项目保护:>10 万文件不物化 files 数组(聚合与点名走 SQL)。
|
|
9
10
|
*/
|
|
10
|
-
export const DEFAULT_IGNORE_DIRS =
|
|
11
|
-
"node_modules",
|
|
12
|
-
".git",
|
|
13
|
-
"dist",
|
|
14
|
-
"build",
|
|
15
|
-
"out",
|
|
16
|
-
".next",
|
|
17
|
-
".nuxt",
|
|
18
|
-
".output",
|
|
19
|
-
".turbo",
|
|
20
|
-
"coverage",
|
|
21
|
-
"__pycache__",
|
|
22
|
-
".venv",
|
|
23
|
-
"venv",
|
|
24
|
-
"env",
|
|
25
|
-
".pm",
|
|
26
|
-
".idea",
|
|
27
|
-
".vscode",
|
|
28
|
-
"target",
|
|
29
|
-
".cache",
|
|
30
|
-
".gradle",
|
|
31
|
-
]);
|
|
11
|
+
export const DEFAULT_IGNORE_DIRS = SCAN_IGNORE_DIRS;
|
|
32
12
|
/** 真实目录树不太可能超过 64 层;超限的计数上报,绝不静默丢失 */
|
|
33
13
|
export const DEPTH_LIMIT = 64;
|
|
34
14
|
export const BINARY_EXTS = new Set([
|
|
@@ -112,7 +92,7 @@ export function needsSkipCount(rel, isTest) {
|
|
|
112
92
|
* onSkippedDeep 在超深路径被跳过时回调(计数上报用)。
|
|
113
93
|
*/
|
|
114
94
|
export function* walkStatEntries(root, opts = {}, onSkippedDeep) {
|
|
115
|
-
const
|
|
95
|
+
const excluded = ignoreMatcher([...projectScanIgnores(root), ...(opts.extraIgnores ?? [])]);
|
|
116
96
|
const includeRe = opts.include?.map(globToRegExp);
|
|
117
97
|
const maxBytes = opts.maxFileBytes ?? 2 * 1024 * 1024;
|
|
118
98
|
void maxBytes;
|
|
@@ -134,12 +114,12 @@ export function* walkStatEntries(root, opts = {}, onSkippedDeep) {
|
|
|
134
114
|
if (entry.isDirectory()) {
|
|
135
115
|
if (DEFAULT_IGNORE_DIRS.has(entry.name))
|
|
136
116
|
continue;
|
|
137
|
-
if (
|
|
117
|
+
if (excluded(rel, true))
|
|
138
118
|
continue;
|
|
139
119
|
yield* walk(abs, depth + 1);
|
|
140
120
|
}
|
|
141
121
|
else if (entry.isFile()) {
|
|
142
|
-
if (
|
|
122
|
+
if (excluded(rel))
|
|
143
123
|
continue;
|
|
144
124
|
if (includeRe && !includeRe.some((re) => re.test(rel)))
|
|
145
125
|
continue;
|
package/dist/search.js
CHANGED
|
@@ -6,6 +6,7 @@ import { decisionsDir } from "./paths.js";
|
|
|
6
6
|
import { loadGovernance } from "./governance-model.js";
|
|
7
7
|
import { listFiles, DEFAULT_IGNORE_DIRS } from "./scan.js";
|
|
8
8
|
import { foldLines, normSep } from "./budget.js";
|
|
9
|
+
import { projectScanIgnores } from "./scan-policy.js";
|
|
9
10
|
/**
|
|
10
11
|
* 求证与检索:search_code(锚定真实代码)+ search_knowledge(历史结论)。
|
|
11
12
|
* token 经济的一部分:返回 file:line 片段而非整文件。
|
|
@@ -36,14 +37,17 @@ export function rgSearch(root, query, glob, maxResults, regex) {
|
|
|
36
37
|
args.push("-g", "!.pm/**");
|
|
37
38
|
else
|
|
38
39
|
args.push("-g", `!${dir}/**`);
|
|
40
|
+
args.push("-g", `!**/${dir}/**`);
|
|
39
41
|
}
|
|
40
42
|
args.push("--max-count", String(maxResults * 3));
|
|
41
43
|
if (regex)
|
|
42
44
|
args.push("-e", query);
|
|
43
45
|
else
|
|
44
|
-
args.push("-F", query);
|
|
46
|
+
args.push("-F", "-e", query);
|
|
45
47
|
if (glob)
|
|
46
48
|
args.push("--glob", glob);
|
|
49
|
+
for (const excluded of projectScanIgnores(root))
|
|
50
|
+
args.push("--glob", `!${excluded}`);
|
|
47
51
|
args.push(".");
|
|
48
52
|
const r = spawnSync("rg", args, { cwd: root, encoding: "utf8", timeout: 120_000, maxBuffer: 64 * 1024 * 1024 });
|
|
49
53
|
if (process.env.PM_RG_DEBUG)
|
package/dist/security.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
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";
|
|
@@ -5,6 +7,7 @@ import { readDirectDeps } from "./scan.js";
|
|
|
5
7
|
import { ensureFresh, getIndex, iterateFileRows, walkRefresh } from "./index-store.js";
|
|
6
8
|
import { readText } from "./search.js";
|
|
7
9
|
import { now } from "./types.js";
|
|
10
|
+
import { scanExclusionNote } from "./scan-policy.js";
|
|
8
11
|
const SECRET_PATTERNS = [
|
|
9
12
|
{ id: "secret.aws-access-key", severity: "high", pattern: /AKIA[0-9A-Z]{16}/, message: "疑似 AWS Access Key" },
|
|
10
13
|
{ id: "secret.github-token", severity: "high", pattern: /gh[pousr]_[A-Za-z0-9]{20,}/, message: "疑似 GitHub Token" },
|
|
@@ -69,8 +72,37 @@ function findSecretRule(value) {
|
|
|
69
72
|
function fingerprint(ruleId, rel, matched) {
|
|
70
73
|
return createHash("sha1").update(ruleId + "|" + rel + "|" + matched).digest("hex").slice(0, 16);
|
|
71
74
|
}
|
|
75
|
+
/** 只有根内普通路径的 ENOENT 才证明删除;权限错误、目录和链接均不证明修复。 */
|
|
76
|
+
function findingFileState(root, rel) {
|
|
77
|
+
const parts = rel.replace(/\\/g, "/").split("/");
|
|
78
|
+
if (path.isAbsolute(rel) || parts.some((part) => !part || part === "." || part === ".."))
|
|
79
|
+
return "unknown";
|
|
80
|
+
let current = root;
|
|
81
|
+
let stat;
|
|
82
|
+
for (const part of parts) {
|
|
83
|
+
current = path.join(current, part);
|
|
84
|
+
try {
|
|
85
|
+
stat = fs.lstatSync(current);
|
|
86
|
+
if (stat.isSymbolicLink())
|
|
87
|
+
return "unknown";
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
return error.code === "ENOENT" ? "missing" : "unknown";
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return stat?.isFile() ? "file" : "unknown";
|
|
94
|
+
}
|
|
72
95
|
function scanDetections(root, forceContent = false, indexPrepared = false) {
|
|
73
96
|
const out = [];
|
|
97
|
+
// 只跟踪已有 open 发现,不为百万文件仓库额外物化整棵扫描覆盖表。
|
|
98
|
+
const pending = loadSecurity(root).findings.filter((finding) => finding.status === "open");
|
|
99
|
+
const byFile = new Map();
|
|
100
|
+
for (const finding of pending) {
|
|
101
|
+
const entries = byFile.get(finding.file) ?? [];
|
|
102
|
+
entries.push(finding);
|
|
103
|
+
byFile.set(finding.file, entries);
|
|
104
|
+
}
|
|
105
|
+
const checkedFindings = new Map();
|
|
74
106
|
const rules = [...SECRET_PATTERNS, ...DANGEROUS_PATTERNS];
|
|
75
107
|
// 用户自定义规则
|
|
76
108
|
for (const r of loadExtraRules(root).rules) {
|
|
@@ -113,6 +145,7 @@ function scanDetections(root, forceContent = false, indexPrepared = false) {
|
|
|
113
145
|
const content = readText(root, rel, forceContent);
|
|
114
146
|
if (content === null)
|
|
115
147
|
continue;
|
|
148
|
+
const contentDigest = byFile.has(rel) ? createHash("sha256").update(content).digest("hex") : undefined;
|
|
116
149
|
const lines = content.split("\n");
|
|
117
150
|
for (const rule of rules) {
|
|
118
151
|
if (rule.glob && !rule.glob.test(rel))
|
|
@@ -131,9 +164,18 @@ function scanDetections(root, forceContent = false, indexPrepared = false) {
|
|
|
131
164
|
message: rule.message,
|
|
132
165
|
});
|
|
133
166
|
}
|
|
167
|
+
for (const finding of byFile.get(rel) ?? []) {
|
|
168
|
+
if (finding.rule === rule.id)
|
|
169
|
+
checkedFindings.set(finding.fingerprint, contentDigest);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
for (const finding of pending) {
|
|
174
|
+
if (!checkedFindings.has(finding.fingerprint) && findingFileState(root, finding.file) === "missing") {
|
|
175
|
+
checkedFindings.set(finding.fingerprint, null);
|
|
134
176
|
}
|
|
135
177
|
}
|
|
136
|
-
return out;
|
|
178
|
+
return { detections: out, checkedFindings };
|
|
137
179
|
}
|
|
138
180
|
/** 昂贵的全仓扫描阶段;调用方可在账本锁外完成,再把脱敏发现提交到账本。 */
|
|
139
181
|
export function prepareSecurityAudit(root, options = {}) {
|
|
@@ -141,13 +183,14 @@ export function prepareSecurityAudit(root, options = {}) {
|
|
|
141
183
|
const forceContent = options.forceContent ?? true;
|
|
142
184
|
if (forceIndex)
|
|
143
185
|
walkRefresh(root, { forceContent: true });
|
|
144
|
-
return
|
|
186
|
+
return scanDetections(root, forceContent, forceIndex || options.indexPrepared === true);
|
|
145
187
|
}
|
|
146
188
|
/** 扫描并更新台账(幂等:重复扫描不产生重复条目) */
|
|
147
189
|
export function auditSecurity(root, prepared) {
|
|
148
190
|
const project = loadProject(root);
|
|
149
191
|
const stamp = now();
|
|
150
|
-
const
|
|
192
|
+
const scan = prepared ?? scanDetections(root);
|
|
193
|
+
const detections = scan.detections;
|
|
151
194
|
const security = loadSecurity(root);
|
|
152
195
|
const existing = new Map(security.findings.map((f) => [f.fingerprint, f]));
|
|
153
196
|
const acceptedRefound = [];
|
|
@@ -186,10 +229,25 @@ export function auditSecurity(root, prepared) {
|
|
|
186
229
|
});
|
|
187
230
|
}
|
|
188
231
|
}
|
|
189
|
-
//
|
|
232
|
+
// 没有命中不等于已修复:必须证明该旧发现的文件/规则已检查,或文件确已删除。
|
|
190
233
|
let autoFixed = 0;
|
|
234
|
+
let unverified = 0;
|
|
235
|
+
const currentDigests = new Map();
|
|
191
236
|
for (const f of security.findings) {
|
|
192
237
|
if (f.status === "open" && !seen.has(f.fingerprint)) {
|
|
238
|
+
const expected = scan.checkedFindings?.get(f.fingerprint);
|
|
239
|
+
let confirmed = expected === null && findingFileState(root, f.file) === "missing";
|
|
240
|
+
if (typeof expected === "string") {
|
|
241
|
+
if (!currentDigests.has(f.file)) {
|
|
242
|
+
const content = findingFileState(root, f.file) === "file" ? readText(root, f.file, true) : null;
|
|
243
|
+
currentDigests.set(f.file, content === null ? null : createHash("sha256").update(content).digest("hex"));
|
|
244
|
+
}
|
|
245
|
+
confirmed = currentDigests.get(f.file) === expected;
|
|
246
|
+
}
|
|
247
|
+
if (!confirmed) {
|
|
248
|
+
unverified += 1;
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
193
251
|
f.status = "fixed";
|
|
194
252
|
f.note += "(自动:最近一次扫描未再检出)";
|
|
195
253
|
autoFixed += 1;
|
|
@@ -208,6 +266,7 @@ export function auditSecurity(root, prepared) {
|
|
|
208
266
|
const high = open.filter((f) => f.severity === "high");
|
|
209
267
|
const L = [];
|
|
210
268
|
L.push(`## 安全体检报告(exposure=${project.exposure})`);
|
|
269
|
+
L.push(...scanExclusionNote(root));
|
|
211
270
|
// exposure 兑现:不同暴露面使用不同处置口径
|
|
212
271
|
if (project.exposure === "public") {
|
|
213
272
|
const mustFix = open.filter((f) => f.severity === "high" || f.severity === "medium");
|
|
@@ -236,7 +295,9 @@ export function auditSecurity(root, prepared) {
|
|
|
236
295
|
}
|
|
237
296
|
}
|
|
238
297
|
if (autoFixed > 0)
|
|
239
|
-
L.push(`自动关闭 ${autoFixed}
|
|
298
|
+
L.push(`自动关闭 ${autoFixed} 个(已成功重扫未命中或确认文件删除)。`);
|
|
299
|
+
if (unverified > 0)
|
|
300
|
+
L.push(`⚠️ 扫描覆盖不足:${unverified} 个旧发现未获得完整检查证据,保持未解决状态(检查文件大小、可读性、扫描范围和规则)。`);
|
|
240
301
|
if (newDeps.length > 0)
|
|
241
302
|
L.push(`⚠️ 上次快照后新增依赖: ${newDeps.join(", ")}(AI 引入的依赖请审查来源与必要性)`);
|
|
242
303
|
if (riskyDeps.length > 0)
|
|
@@ -102,6 +102,28 @@ export function saveSemanticEvidence(root, id, input) {
|
|
|
102
102
|
return parsed.data;
|
|
103
103
|
});
|
|
104
104
|
}
|
|
105
|
+
/** Atomically replace the evidence set for one repository-relative source file under the ledger lock. */
|
|
106
|
+
export function replaceSemanticEvidenceForFile(root, id, input) {
|
|
107
|
+
const safeId = validateSemanticEvidenceId(id);
|
|
108
|
+
const parsed = SemanticEvidenceDocumentSchema.safeParse(input);
|
|
109
|
+
if (!parsed.success) {
|
|
110
|
+
throw new Error(`语义证据 schema 校验失败: ${parsed.error.issues.map((issue) => `${issue.path.join(".") || "document"}: ${issue.message}`).join("; ")}`);
|
|
111
|
+
}
|
|
112
|
+
const absoluteRoot = path.resolve(root);
|
|
113
|
+
const directory = semanticEvidenceDir(absoluteRoot);
|
|
114
|
+
assertRealDirectoryWithinRoot(absoluteRoot, directory, true);
|
|
115
|
+
return withLedgerLock(absoluteRoot, () => {
|
|
116
|
+
assertRealDirectoryWithinRoot(absoluteRoot, directory, false);
|
|
117
|
+
const oldIds = evidenceIdsUnlocked(absoluteRoot)
|
|
118
|
+
.filter((candidate) => candidate !== safeId)
|
|
119
|
+
.filter((candidate) => loadUnlocked(absoluteRoot, candidate).file === parsed.data.file);
|
|
120
|
+
atomicWrite(semanticEvidencePath(absoluteRoot, safeId), `${JSON.stringify(parsed.data, null, 2)}
|
|
121
|
+
`);
|
|
122
|
+
for (const oldId of oldIds)
|
|
123
|
+
fs.rmSync(semanticEvidencePath(absoluteRoot, oldId), { force: false });
|
|
124
|
+
return parsed.data;
|
|
125
|
+
});
|
|
126
|
+
}
|
|
105
127
|
export function loadSemanticEvidence(root, id) {
|
|
106
128
|
return loadUnlocked(path.resolve(root), validateSemanticEvidenceId(id));
|
|
107
129
|
}
|
package/dist/semantic-parsers.js
CHANGED
|
@@ -298,6 +298,24 @@ export function candidates(from, specifier, language, ownerRoots = []) {
|
|
|
298
298
|
if (base === ".." || base.startsWith("../"))
|
|
299
299
|
continue;
|
|
300
300
|
out.add(base);
|
|
301
|
+
// TypeScript ESM convention: source specifiers ending in .js/.jsx/.mjs/.cjs often
|
|
302
|
+
// refer to .ts/.tsx/.mts/.cts files on disk (tsc rewriteRelativeImportExtensions
|
|
303
|
+
// and Node type stripping both map them this way). Candidates are appended after
|
|
304
|
+
// the literal path, so an existing .js file still wins resolution.
|
|
305
|
+
const specExt = path.posix.extname(base);
|
|
306
|
+
if (specExt === ".js" || specExt === ".jsx" || specExt === ".mjs" || specExt === ".cjs") {
|
|
307
|
+
const stem = base.slice(0, base.length - specExt.length);
|
|
308
|
+
if (specExt === ".js") {
|
|
309
|
+
out.add(`${stem}.ts`);
|
|
310
|
+
out.add(`${stem}.tsx`);
|
|
311
|
+
}
|
|
312
|
+
else if (specExt === ".jsx")
|
|
313
|
+
out.add(`${stem}.tsx`);
|
|
314
|
+
else if (specExt === ".mjs")
|
|
315
|
+
out.add(`${stem}.mts`);
|
|
316
|
+
else
|
|
317
|
+
out.add(`${stem}.cts`);
|
|
318
|
+
}
|
|
301
319
|
if (!path.posix.extname(base)) {
|
|
302
320
|
for (const ext of RESOLVE_EXTS)
|
|
303
321
|
out.add(base + ext);
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { loadSessions, nextId, saveSessions } from "./store.js";
|
|
2
|
+
import { now } from "./types.js";
|
|
3
|
+
import { captureFileHashes, projectFilePath } from "./git-state.js";
|
|
4
|
+
/** 调用方持有账本锁;共享显式会话与任务自动会话的同一记录方式。 */
|
|
5
|
+
export function appendSession(root, input, hashes) {
|
|
6
|
+
const files = (input.files ?? []).map(projectFilePath);
|
|
7
|
+
const fileHashes = hashes ?? captureFileHashes(root, files);
|
|
8
|
+
const data = loadSessions(root);
|
|
9
|
+
const id = nextId("S", data.seq, 4);
|
|
10
|
+
data.seq += 1;
|
|
11
|
+
data.sessions.push({ id, date: now(), author: input.author ?? "", summary: input.summary, files, next_steps: input.next_steps ?? [], file_hashes: fileHashes });
|
|
12
|
+
saveSessions(root, data);
|
|
13
|
+
return id;
|
|
14
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
+
import { atomicWrite } from "./store.js";
|
|
5
|
+
import { PACKAGE_SPEC, SERVER_NAME } from "./version.js";
|
|
6
|
+
export function canonicalProjectRoot(root) {
|
|
7
|
+
const absolute = path.resolve(root);
|
|
8
|
+
let real;
|
|
9
|
+
try {
|
|
10
|
+
real = fs.realpathSync.native(absolute);
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
if (error.code !== "ENOENT")
|
|
14
|
+
throw error;
|
|
15
|
+
real = absolute;
|
|
16
|
+
}
|
|
17
|
+
return process.platform === "win32" ? real.toLowerCase() : real;
|
|
18
|
+
}
|
|
19
|
+
export function codexProjectServerKey(root) {
|
|
20
|
+
const canonical = canonicalProjectRoot(root);
|
|
21
|
+
const slug = path.basename(canonical).toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "") || "project";
|
|
22
|
+
return `${SERVER_NAME}-${slug}-${createHash("sha256").update(canonical).digest("hex").slice(0, 12)}`;
|
|
23
|
+
}
|
|
24
|
+
function stringValue(value) {
|
|
25
|
+
return value.startsWith("'") ? value.slice(1, -1) : JSON.parse(value);
|
|
26
|
+
}
|
|
27
|
+
/** 仅处理本安装器的单行参数形式;复杂 TOML 明确拒绝,不猜测或输出配置原文。 */
|
|
28
|
+
export function findCodexProjectEntry(text, root) {
|
|
29
|
+
if (text.includes('"""') || text.includes("'''"))
|
|
30
|
+
throw new Error("配置包含多行字符串,自动升级无法安全确认段落边界;未修改配置。");
|
|
31
|
+
const canonical = canonicalProjectRoot(root), expected = codexProjectServerKey(root);
|
|
32
|
+
const matches = [];
|
|
33
|
+
const headers = [...text.matchAll(/^(?:\uFEFF)?[ \t]*\[mcp_servers\.(?:([A-Za-z0-9_-]+)|"([^"\r\n]+)"|'([^'\r\n]+)')\][ \t]*(?:#.*)?\r?$/gm)];
|
|
34
|
+
for (const header of headers) {
|
|
35
|
+
const name = header[1] ?? header[2] ?? header[3];
|
|
36
|
+
if (name !== SERVER_NAME && !name.startsWith(SERVER_NAME + "-"))
|
|
37
|
+
continue;
|
|
38
|
+
const start = header.index + header[0].length;
|
|
39
|
+
const next = text.slice(start).search(/^[ \t]*\[/m);
|
|
40
|
+
const end = next < 0 ? text.length : start + next;
|
|
41
|
+
const section = text.slice(start, end);
|
|
42
|
+
const commands = [...section.matchAll(/^[ \t]*command[ \t]*=[ \t]*("[^\r\n]*"|'[^\r\n]*')[ \t]*(?:#.*)?\r?$/gm)];
|
|
43
|
+
const arrays = [...section.matchAll(/^[ \t]*args[ \t]*=[ \t]*(\[[^\r\n]*\])[ \t]*(?:#.*)?\r?$/gm)];
|
|
44
|
+
let entry;
|
|
45
|
+
try {
|
|
46
|
+
if (commands.length !== 1 || arrays.length !== 1)
|
|
47
|
+
throw new Error();
|
|
48
|
+
const command = stringValue(commands[0][1]), args = JSON.parse(arrays[0][1]);
|
|
49
|
+
if (typeof command !== "string" || !Array.isArray(args) || !args.every((value) => typeof value === "string"))
|
|
50
|
+
throw new Error();
|
|
51
|
+
const positions = args.flatMap((value, index) => value === "--root" ? [index] : []);
|
|
52
|
+
if (positions.length !== 1 || !path.isAbsolute(args[positions[0] + 1] ?? ""))
|
|
53
|
+
throw new Error();
|
|
54
|
+
const filters = [...section.matchAll(/^[ \t]*(enabled_tools|disabled_tools)[ \t]*=[ \t]*(\[[^\r\n]*\])/gm)];
|
|
55
|
+
entry = { name, command, args, root: canonicalProjectRoot(args[positions[0] + 1]), start, end,
|
|
56
|
+
enabled: !/^[ \t]*enabled[ \t]*=[ \t]*false\b/m.test(section),
|
|
57
|
+
toolFilters: [...section.matchAll(/^[ \t]*(?:enabled_tools|disabled_tools)[ \t]*=/gm)].length !== filters.length || filters.some((filter) => filter[1] === "enabled_tools" || JSON.parse(filter[2]).length > 0) };
|
|
58
|
+
}
|
|
59
|
+
catch { /* 无法识别的条目不会被覆盖 */ }
|
|
60
|
+
if (name === expected && entry?.root !== canonical)
|
|
61
|
+
throw new Error(`已有 [mcp_servers.${name}] 指向不同项目或无法确认 --root;未修改配置。`);
|
|
62
|
+
if (!entry && (name === SERVER_NAME || name === expected.slice(0, -13)))
|
|
63
|
+
throw new Error("旧 pm-mcp 条目的项目根无法确认,拒绝创建可能重复的启动项。");
|
|
64
|
+
if (entry?.root === canonical)
|
|
65
|
+
matches.push(entry);
|
|
66
|
+
}
|
|
67
|
+
if (matches.length > 1)
|
|
68
|
+
throw new Error("多个 pm-mcp 条目指向同一项目,需先明确保留哪个;未修改配置。");
|
|
69
|
+
return matches[0];
|
|
70
|
+
}
|
|
71
|
+
export function readCodexConfig(file) {
|
|
72
|
+
if (!fs.existsSync(file))
|
|
73
|
+
return "";
|
|
74
|
+
const stat = fs.lstatSync(file);
|
|
75
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 2 * 1024 * 1024)
|
|
76
|
+
throw new Error("Codex 配置必须为有界普通文件,禁止链接。");
|
|
77
|
+
return fs.readFileSync(file, "utf8");
|
|
78
|
+
}
|
|
79
|
+
export function isPackageLaunch(entry) {
|
|
80
|
+
return /^npx(?:\.(?:cmd|exe))?$/i.test(path.basename(entry.command)) && entry.args.length === 4 && entry.args[0] === "-y" &&
|
|
81
|
+
/^@luckychen1993\/pm-mcp(?:@[^\s]+)?$/.test(entry.args[1]) && entry.args[2] === "--root";
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* 校验启动项指向本包已构建的本地入口(`dist/index.js` 或 `src/index.ts`)。
|
|
85
|
+
*
|
|
86
|
+
* 与 `isLocalLaunch` 的区别:**不要求 `--root`**。WorkBuddy 等多项目宿主
|
|
87
|
+
* 以会话工作区作为子进程 cwd,服务端按 cwd 解析项目根,此时参数里没有 `--root`。
|
|
88
|
+
*/
|
|
89
|
+
export function isLocalServerEntry(entry) {
|
|
90
|
+
if (!/^node(?:\.exe)?$/i.test(path.basename(entry.command)) || entry.args.length < 1 || !path.isAbsolute(entry.args[0]))
|
|
91
|
+
return false;
|
|
92
|
+
const file = path.resolve(entry.args[0]);
|
|
93
|
+
if (!/^(?:index|cli)\.(?:js|ts)$/.test(path.basename(file)))
|
|
94
|
+
return false;
|
|
95
|
+
try {
|
|
96
|
+
const stat = fs.lstatSync(file);
|
|
97
|
+
return stat.isFile() && !stat.isSymbolicLink() && JSON.parse(fs.readFileSync(path.join(path.dirname(file), "..", "package.json"), "utf8")).name === "@luckychen1993/pm-mcp";
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
export function isLocalLaunch(entry) {
|
|
104
|
+
return entry.args.length === 3 && entry.args[1] === "--root" && isLocalServerEntry(entry);
|
|
105
|
+
}
|
|
106
|
+
export function appendCodexProjectConfig(file, root, force, dryRun, log, local) {
|
|
107
|
+
const absolute = path.resolve(file), existing = readCodexConfig(absolute);
|
|
108
|
+
const entry = findCodexProjectEntry(existing, root);
|
|
109
|
+
const desired = local ?? { command: "npx", args: ["-y", PACKAGE_SPEC, "--root", path.resolve(root).replace(/\\/g, "/")] };
|
|
110
|
+
let updated;
|
|
111
|
+
if (entry) {
|
|
112
|
+
if (!force) {
|
|
113
|
+
log(`[skip] Codex already has [mcp_servers.${entry.name}] for this project; --force 可升级,现有配置保留。`);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (!isPackageLaunch(entry) && !isLocalLaunch(entry))
|
|
117
|
+
throw new Error("当前启动项不是可识别的 pm-mcp npx/node 形式;未修改配置。");
|
|
118
|
+
if (entry.command === desired.command && JSON.stringify(entry.args) === JSON.stringify(desired.args)) {
|
|
119
|
+
log("[skip] 启动配置已是目标版本;仍需通过 doctor 核验连接。");
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const section = existing.slice(entry.start, entry.end);
|
|
123
|
+
const replace = (name, value, input) => input.replace(new RegExp(`^([ \\t]*${name}[ \\t]*=[ \\t]*)(?:\\[[^\\r\\n]*\\]|"[^\\r\\n]*"|'[^\\r\\n]*')([ \\t]*(?:#.*)?)(\\r?)$`, "m"), (_all, prefix, suffix, cr) => prefix + value + suffix + cr);
|
|
124
|
+
const patched = replace("args", JSON.stringify(desired.args), replace("command", JSON.stringify(desired.command), section));
|
|
125
|
+
updated = existing.slice(0, entry.start) + patched + existing.slice(entry.end);
|
|
126
|
+
const checked = findCodexProjectEntry(updated, root);
|
|
127
|
+
if (!checked || checked.command !== desired.command || JSON.stringify(checked.args) !== JSON.stringify(desired.args))
|
|
128
|
+
throw new Error("启动配置替换验证失败,未写入。");
|
|
129
|
+
log(`[${dryRun ? "plan" : "upgrade"}] Codex [mcp_servers.${entry.name}];仅替换 command/args,保留其余设置。`);
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
const separator = existing.length === 0 || existing.endsWith("\n") ? "" : "\n";
|
|
133
|
+
const name = codexProjectServerKey(root);
|
|
134
|
+
updated = existing + `${separator}\n[mcp_servers.${name}]\ncommand = ${JSON.stringify(desired.command)}\nargs = ${JSON.stringify(desired.args)}\n`;
|
|
135
|
+
log(`[${dryRun ? "plan" : "write"}] Codex: ${absolute}`);
|
|
136
|
+
log(`[entry] [mcp_servers.${name}] → --root ${path.resolve(root)}`);
|
|
137
|
+
}
|
|
138
|
+
if (dryRun)
|
|
139
|
+
return;
|
|
140
|
+
fs.mkdirSync(path.dirname(absolute), { recursive: true });
|
|
141
|
+
if (readCodexConfig(absolute) !== existing)
|
|
142
|
+
throw new Error("配置在准备期间发生变化;未写入,请重新预览。");
|
|
143
|
+
if (fs.existsSync(absolute))
|
|
144
|
+
fs.copyFileSync(absolute, `${absolute}.backup-${Date.now()}-${randomUUID()}`, fs.constants.COPYFILE_EXCL);
|
|
145
|
+
atomicWrite(absolute, updated);
|
|
146
|
+
}
|