@luckychen1993/pm-mcp 0.1.5 → 0.4.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/LICENSE +21 -21
- package/README.md +114 -11
- package/dist/agents-md.js +3 -3
- package/dist/audit-tools.js +17 -0
- package/dist/audit.js +25 -21
- package/dist/cli.js +54 -0
- package/dist/dashboard.js +6 -4
- package/dist/doctor.js +148 -0
- package/dist/duplicates.js +228 -0
- package/dist/git-state.js +75 -0
- package/dist/governance-audit.js +98 -1
- package/dist/governance-tools.js +21 -3
- package/dist/index-db.js +40 -0
- package/dist/index-store.js +35 -44
- package/dist/index.js +15 -8
- package/dist/knowledge-tools.js +6 -15
- package/dist/language-adapters.js +11 -10
- package/dist/license.js +28 -2
- package/dist/osv.js +139 -0
- package/dist/probe.js +141 -0
- package/dist/project-fingerprint.js +9 -5
- package/dist/project-tools.js +13 -2
- 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 +4 -0
- package/dist/security.js +2 -0
- package/dist/semantic-evidence-store.js +22 -0
- package/dist/semantic-graph.js +2 -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 +92 -94
- 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 +7 -5
- package/dist/types.js +5 -0
- package/dist/ui.js +191 -0
- package/dist/version.js +10 -0
- package/package.json +10 -4
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
]);
|
|
8
|
-
function hashRegularFile(file) {
|
|
4
|
+
import { SCAN_IGNORE_DIRS, ignoreMatcher, projectScanIgnores } from "./scan-policy.js";
|
|
5
|
+
const DEFAULT_IGNORED_DIRECTORIES = SCAN_IGNORE_DIRS;
|
|
6
|
+
export function hashRegularFile(file) {
|
|
9
7
|
const handle = fs.openSync(file, "r");
|
|
10
8
|
const digest = createHash("sha256");
|
|
11
9
|
const buffer = Buffer.allocUnsafe(1024 * 1024);
|
|
@@ -43,6 +41,10 @@ export function fingerprintProject(root) {
|
|
|
43
41
|
throw new Error(`项目根必须是普通目录: ${absolute}`);
|
|
44
42
|
const rootReal = fs.realpathSync.native(absolute);
|
|
45
43
|
const tree = createHash("sha256");
|
|
44
|
+
const ignores = projectScanIgnores(root);
|
|
45
|
+
const excluded = ignoreMatcher(ignores);
|
|
46
|
+
if (ignores.length)
|
|
47
|
+
tree.update(`scan-ignore\0${JSON.stringify(ignores)}\0`);
|
|
46
48
|
let files = 0;
|
|
47
49
|
let bytes = 0;
|
|
48
50
|
const visit = (directory, relativeDirectory) => {
|
|
@@ -52,6 +54,8 @@ export function fingerprintProject(root) {
|
|
|
52
54
|
continue;
|
|
53
55
|
const absoluteEntry = path.join(directory, entry.name);
|
|
54
56
|
const relative = path.posix.join(relativeDirectory.replace(/\\/g, "/"), entry.name);
|
|
57
|
+
if (excluded(relative, entry.isDirectory()))
|
|
58
|
+
continue;
|
|
55
59
|
if (relative === "PROJECT.md")
|
|
56
60
|
continue; // generated dashboard; authoritative inputs live in .pm and source files
|
|
57
61
|
const stat = fs.lstatSync(absoluteEntry);
|
package/dist/project-tools.js
CHANGED
|
@@ -9,6 +9,9 @@ import { loadProject, loadRoadmap, loadSecurity, loadSessions, loadTasks, savePr
|
|
|
9
9
|
import { now } from "./types.js";
|
|
10
10
|
import { budgetLines, toolI, toolR, toolW } from "./tool-base.js";
|
|
11
11
|
import { renderRuntimeLogLines, renderUsageLogLines } from "./usage-log.js";
|
|
12
|
+
import { ScanIgnoreSchema } from "./scan-policy.js";
|
|
13
|
+
import { VERSION, RUNTIME_CONTRACT } from "./version.js";
|
|
14
|
+
import { CompletionEvidenceSchema } from "./quality-evidence.js";
|
|
12
15
|
function taskLine(t, withNext = false) {
|
|
13
16
|
const parts = [`[${t.status}]`, t.id, t.title, `(${t.type}${t.milestone ? `,${t.milestone}` : ""})`];
|
|
14
17
|
if (withNext && t.checkpoint) {
|
|
@@ -46,6 +49,7 @@ export function registerProjectTools(server, root) {
|
|
|
46
49
|
const security = loadSecurity(root);
|
|
47
50
|
const L = [];
|
|
48
51
|
L.push(`# ${project.name}${project.phase ? ` — ${project.phase}` : ""}`);
|
|
52
|
+
L.push(`运行版本: pm-mcp ${VERSION} · 项目根: ${root}`);
|
|
49
53
|
if (project.description)
|
|
50
54
|
L.push(project.description);
|
|
51
55
|
L.push("");
|
|
@@ -96,8 +100,9 @@ export function registerProjectTools(server, root) {
|
|
|
96
100
|
}
|
|
97
101
|
L.push("");
|
|
98
102
|
L.push("> 三段式定位:本状态 → search_code 精确到行 → 才读具体文件。别全量读代码。");
|
|
99
|
-
return foldLines(L, { maxLines: budgetLines(root) });
|
|
100
|
-
})
|
|
103
|
+
return JSON.stringify({ text: foldLines(L, { maxLines: budgetLines(root) }), runtime: { version: VERSION, root, contract: RUNTIME_CONTRACT } });
|
|
104
|
+
}, { cacheVersion: `runtime:${VERSION}`, outputSchema: { runtime: z.object({ version: z.string(), root: z.string(), contract: z.string() }) },
|
|
105
|
+
decode: (value) => { const result = JSON.parse(value); return { text: result.text, data: { runtime: result.runtime } }; } });
|
|
101
106
|
toolR(server, root, "get_usage_log", "使用日志:pm-mcp 被用了哪些工具、成败、耗时,以及输出 token 估算与「折叠省下的 token」——回答“用它到底省不省 token”。只记工具名与计量,不记参数内容。日志滚动保留约 2MB。", {
|
|
102
107
|
last: z.number().int().min(1).max(200).optional().describe("返回最近 N 条明细(默认 20;汇总始终覆盖全部保留日志)"),
|
|
103
108
|
tool: z.string().optional().describe("只看某工具,如 get_status"),
|
|
@@ -124,6 +129,8 @@ export function registerProjectTools(server, root) {
|
|
|
124
129
|
license: z.string().optional(),
|
|
125
130
|
exposure: z.enum(["local", "network", "public"]).optional(),
|
|
126
131
|
modules: z.array(z.string()).optional().describe("模块登记(用于未登记目录检测)"),
|
|
132
|
+
scan_ignore: ScanIgnoreSchema.optional().describe("本项目产物/缓存的相对排除 glob,例如 release/**;传 [] 清除;不应排除真实源码"),
|
|
133
|
+
completion_evidence: CompletionEvidenceSchema.optional().describe("默认要求tests_observed;兼容无计数运行器时显式选择execution_only并记录原因"),
|
|
127
134
|
max_file_loc: z.number().int().min(10).optional().describe("单文件行数预算"),
|
|
128
135
|
refactor_quota_pct: z.number().min(0).max(100).optional().describe("里程碑重构类任务最低占比%"),
|
|
129
136
|
session_blast_radius: z.number().int().min(1).optional().describe("单会话波及文件阈值"),
|
|
@@ -145,6 +152,10 @@ export function registerProjectTools(server, root) {
|
|
|
145
152
|
project.exposure = args.exposure;
|
|
146
153
|
if (args.modules !== undefined)
|
|
147
154
|
project.modules = args.modules;
|
|
155
|
+
if (args.scan_ignore !== undefined)
|
|
156
|
+
project.scan_ignore = args.scan_ignore;
|
|
157
|
+
if (args.completion_evidence !== undefined)
|
|
158
|
+
project.completion_evidence = CompletionEvidenceSchema.parse(args.completion_evidence);
|
|
148
159
|
if (args.max_file_loc !== undefined)
|
|
149
160
|
project.budgets.maxFileLoc = args.max_file_loc;
|
|
150
161
|
if (args.refactor_quota_pct !== undefined)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
export const CompletionEvidenceSchema = z.object({
|
|
4
|
+
minimum: z.enum(["tests_observed", "execution_only"]),
|
|
5
|
+
reason: z.string().trim().min(8).max(1000),
|
|
6
|
+
});
|
|
7
|
+
const ranks = { unverified: 0, execution_only: 1, tests_observed: 2, coverage_observed: 3 };
|
|
8
|
+
/** 等级描述观测强度,不代表测试充分、结果通过或来源不可伪造。 */
|
|
9
|
+
export function evidenceLevel(kind, summary, truncated = false) {
|
|
10
|
+
if (truncated || !["test", "coverage"].includes(kind))
|
|
11
|
+
return "execution_only";
|
|
12
|
+
const counters = [summary.failed, summary.skipped, summary.cancelled, summary.todo];
|
|
13
|
+
if (summary.tests === null || !Number.isSafeInteger(summary.tests) || summary.tests <= 0 ||
|
|
14
|
+
counters.some((n) => n === null || !Number.isSafeInteger(n) || n < 0) ||
|
|
15
|
+
counters.reduce((total, n) => total + (n ?? 0), 0) > summary.tests)
|
|
16
|
+
return "execution_only";
|
|
17
|
+
if (summary.passed != null && summary.passed + counters.reduce((total, n) => total + (n ?? 0), 0) !== summary.tests)
|
|
18
|
+
return "execution_only";
|
|
19
|
+
const coverage = z.object({ lines_pct: z.number().min(0).max(100), branches_pct: z.number().min(0).max(100), functions_pct: z.number().min(0).max(100) }).safeParse(summary.coverage);
|
|
20
|
+
return coverage.success ? "coverage_observed" : "tests_observed";
|
|
21
|
+
}
|
|
22
|
+
export function weakestEvidence(levels) {
|
|
23
|
+
return levels.length ? levels.reduce((a, b) => ranks[a] < ranks[b] ? a : b) : "execution_only";
|
|
24
|
+
}
|
|
25
|
+
export function evidenceLabel(level) {
|
|
26
|
+
return { unverified: "无可用验证证据", execution_only: "仅执行记录,未确认测试数量", tests_observed: "已观察测试结果", coverage_observed: "已观察测试与覆盖率" }[level ?? ""] ?? "旧报告未标等级";
|
|
27
|
+
}
|
|
28
|
+
export function qualityResultSummary(item) {
|
|
29
|
+
const summary = qualityOutputSummary(item.command.kind, item.stdout, item.stderr, item.truncated);
|
|
30
|
+
return { ...summary, evidence_level: item.exitCode === null ? "unverified" : summary.evidence_level };
|
|
31
|
+
}
|
|
32
|
+
export function qualityOutputSummary(kind, stdout, stderr, truncated = false) {
|
|
33
|
+
const output = `${stdout}\n${stderr}`.replace(/\u001b\[[0-9;]*m/g, "");
|
|
34
|
+
const diagnostic = (label) => {
|
|
35
|
+
const matches = [...output.matchAll(new RegExp(`^[ \\t]*(?:ℹ|#)?[ \\t]*${label}[ \\t]+(\\d+)[ \\t]*\\r?$`, "gmi"))];
|
|
36
|
+
return matches.length ? Number(matches.at(-1)[1]) : null;
|
|
37
|
+
};
|
|
38
|
+
let counts = { tests: diagnostic("tests"), passed: diagnostic("pass"), failed: diagnostic("fail"), cancelled: diagnostic("cancelled"), skipped: diagnostic("skipped"), todo: diagnostic("todo") };
|
|
39
|
+
let counterSource = counts.tests === null ? "unknown" : "node_test_summary";
|
|
40
|
+
const jest = [...output.matchAll(/^\s*Tests:\s*(.+?)(\d+)\s+total\s*$/gm)].at(-1);
|
|
41
|
+
const vitest = [...output.matchAll(/^\s*Tests\s+(.+?)\s*\((\d+)\)\s*$/gm)].at(-1);
|
|
42
|
+
const pytest = [...output.matchAll(/^=+\s+((?:\d+\s+(?:passed|failed|skipped|xfailed|xpassed|deselected|errors?|warnings?)[,\s]*)+)\s+in\s+[\d.]+s.*?=+\s*$/gm)].at(-1);
|
|
43
|
+
if (counts.tests === null && (jest || vitest || pytest)) {
|
|
44
|
+
const text = (jest ?? vitest ?? pytest)[1];
|
|
45
|
+
const number = (label) => Number(new RegExp(`(\\d+)\\s+${label}\\b`).exec(text)?.[1] ?? 0);
|
|
46
|
+
const passed = number("passed"), failed = number("failed") + number("errors?") + number("xpassed");
|
|
47
|
+
const skipped = number("skipped") + number("xfailed") + number("deselected"), todo = number("todo");
|
|
48
|
+
counts = { tests: jest ? Number(jest[2]) : vitest ? Number(vitest[2]) : passed + failed + skipped + todo, passed, failed, skipped, todo, cancelled: 0 };
|
|
49
|
+
counterSource = jest ? "jest_summary" : vitest ? "vitest_summary" : "pytest_summary";
|
|
50
|
+
}
|
|
51
|
+
const coverage = output.match(/(?:^|\n)(?:ℹ|#)?\s*all files\s*\|\s*([\d.]+)\s*\|\s*([\d.]+)\s*\|\s*([\d.]+)(?:\s*\|\s*([\d.]+))?/i);
|
|
52
|
+
counts.coverage = coverage ? { lines_pct: Number(coverage[4] ?? coverage[1]), branches_pct: Number(coverage[2]), functions_pct: Number(coverage[3]) } : null;
|
|
53
|
+
return { stdout_bytes: Buffer.byteLength(stdout), stderr_bytes: Buffer.byteLength(stderr),
|
|
54
|
+
output_sha256: createHash("sha256").update(stdout).update("\0").update(stderr).digest("hex"),
|
|
55
|
+
...counts, counter_source: counterSource, evidence_level: evidenceLevel(kind, counts, truncated) };
|
|
56
|
+
}
|
package/dist/quality-store.js
CHANGED
|
@@ -1,48 +1,28 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { createHash } from "node:crypto";
|
|
4
3
|
import { atomicWrite, withLedgerLock } from "./store.js";
|
|
5
4
|
import { refreshDerived } from "./dashboard.js";
|
|
6
|
-
|
|
7
|
-
const match = output.match(new RegExp(`(?:^|\\n)(?:ℹ|#)?\\s*${label}\\s+(\\d+)(?:\\s|$)`, "i"));
|
|
8
|
-
return match ? Number(match[1]) : null;
|
|
9
|
-
}
|
|
10
|
-
function outputSummary(stdout, stderr) {
|
|
11
|
-
const combined = `${stdout}\n${stderr}`;
|
|
12
|
-
// Node's test reporter exposes line|branch|function, while Istanbul/c8 uses
|
|
13
|
-
// statement|branch|function|line. Preserve one normalized three-metric shape.
|
|
14
|
-
const coverage = combined.match(/(?:^|\n)(?:ℹ|#)?\s*all files\s*\|\s*([\d.]+)\s*\|\s*([\d.]+)\s*\|\s*([\d.]+)(?:\s*\|\s*([\d.]+))?/i);
|
|
15
|
-
return {
|
|
16
|
-
stdout_bytes: Buffer.byteLength(stdout),
|
|
17
|
-
stderr_bytes: Buffer.byteLength(stderr),
|
|
18
|
-
output_sha256: createHash("sha256").update(stdout, "utf8").update("\0", "utf8").update(stderr, "utf8").digest("hex"),
|
|
19
|
-
tests: numericDiagnostic(combined, "tests"),
|
|
20
|
-
passed: numericDiagnostic(combined, "pass"),
|
|
21
|
-
failed: numericDiagnostic(combined, "fail"),
|
|
22
|
-
cancelled: numericDiagnostic(combined, "cancelled"),
|
|
23
|
-
skipped: numericDiagnostic(combined, "skipped"),
|
|
24
|
-
todo: numericDiagnostic(combined, "todo"),
|
|
25
|
-
coverage: coverage ? { lines_pct: Number(coverage[4] ?? coverage[1]), branches_pct: Number(coverage[2]), functions_pct: Number(coverage[3]) } : null,
|
|
26
|
-
};
|
|
27
|
-
}
|
|
5
|
+
import { qualityResultSummary, weakestEvidence } from "./quality-evidence.js";
|
|
28
6
|
/** Persist a secret-minimised quality summary; raw stdout/stderr never enter the ledger. */
|
|
29
7
|
export function saveQualityRun(root, result, provenance) {
|
|
30
8
|
const runAt = new Date();
|
|
31
9
|
const stamp = runAt.toISOString().replace(/[-:]/g, "").replace("T", "-").replace(".", "-").replace(/Z$/, "");
|
|
32
10
|
const effectiveOk = result.ok && result.execute && result.results.length > 0 && result.results.every((item) => item.status === "passed" && item.exitCode === 0 && item.signal === null);
|
|
11
|
+
const summaries = result.results.map(qualityResultSummary);
|
|
33
12
|
const payload = JSON.stringify({
|
|
34
13
|
schema_version: 2,
|
|
35
14
|
run_at: runAt.toISOString(),
|
|
36
15
|
ok: effectiveOk,
|
|
37
16
|
reported_ok: result.ok,
|
|
38
17
|
execute: result.execute,
|
|
18
|
+
evidence_level: result.execute && result.results.some((item) => item.exitCode !== null) ? weakestEvidence(summaries.filter((_item, i) => ["test", "coverage"].includes(result.results[i].command.kind)).map((item) => item.evidence_level)) : "unverified",
|
|
39
19
|
environment: { platform: process.platform, arch: process.arch, node: process.version },
|
|
40
20
|
source: provenance ? {
|
|
41
21
|
before: provenance.source_before,
|
|
42
22
|
after: provenance.source_after,
|
|
43
23
|
stable: provenance.source_before.sha256 === provenance.source_after.sha256,
|
|
44
24
|
} : null,
|
|
45
|
-
results: result.results.map((item) => ({
|
|
25
|
+
results: result.results.map((item, index) => ({
|
|
46
26
|
unit_root: item.command.cwd,
|
|
47
27
|
kind: item.command.kind,
|
|
48
28
|
command: item.command.command,
|
|
@@ -52,7 +32,7 @@ export function saveQualityRun(root, result, provenance) {
|
|
|
52
32
|
signal: item.signal,
|
|
53
33
|
duration_ms: item.durationMs,
|
|
54
34
|
output_truncated: item.truncated,
|
|
55
|
-
output_summary:
|
|
35
|
+
output_summary: summaries[index],
|
|
56
36
|
error: item.error ?? "",
|
|
57
37
|
})),
|
|
58
38
|
}, null, 2) + "\n";
|
package/dist/registry.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import { RegistrySchema, now } from "./types.js";
|
|
4
5
|
import { registryFile } from "./paths.js";
|
|
@@ -23,9 +24,18 @@ export function saveRegistry(data) {
|
|
|
23
24
|
}
|
|
24
25
|
/** 登记/刷新一个项目(init 与每次写操作都会 touch) */
|
|
25
26
|
export function touchRegistry(root, name) {
|
|
26
|
-
const data = loadRegistry();
|
|
27
27
|
const abs = path.resolve(root);
|
|
28
|
-
|
|
28
|
+
// 防污染(T-034 根因):临时目录里的"项目"从不进入真实注册表——
|
|
29
|
+
// 测试若未设 PM_MCP_HOME 沙箱,写入会被这里拦下而不是污染 ~/.pm-mcp。
|
|
30
|
+
// 沙箱化测试(显式设了 PM_MCP_HOME)不受影响,仍写沙箱注册表。
|
|
31
|
+
if (!process.env.PM_MCP_HOME?.trim() && isUnderTemp(abs)) {
|
|
32
|
+
console.error(`[pm-mcp] 跳过注册临时目录项目(如需管理请移出临时目录): ${abs}`);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const data = loadRegistry();
|
|
36
|
+
// Windows 路径大小写不敏感:按小写比较去重(e:/E: 重复注册缺陷)
|
|
37
|
+
const key = abs.toLowerCase();
|
|
38
|
+
const idx = data.projects.findIndex((p) => path.resolve(p.root).toLowerCase() === key);
|
|
29
39
|
const entry = { name, root: abs, last_seen: now() };
|
|
30
40
|
if (idx >= 0)
|
|
31
41
|
data.projects[idx] = entry;
|
|
@@ -33,6 +43,22 @@ export function touchRegistry(root, name) {
|
|
|
33
43
|
data.projects.push(entry);
|
|
34
44
|
saveRegistry(data);
|
|
35
45
|
}
|
|
46
|
+
function isUnderTemp(abs) {
|
|
47
|
+
const normalized = abs.toLowerCase();
|
|
48
|
+
// tmpdir 可能以 8.3 短名返回(ADMINI~1),与注册表里的长名不一致——两种形态都比对
|
|
49
|
+
let tmpLong = os.tmpdir().toLowerCase();
|
|
50
|
+
try {
|
|
51
|
+
tmpLong = fs.realpathSync.native(os.tmpdir()).toLowerCase();
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
/* keep default */
|
|
55
|
+
}
|
|
56
|
+
for (const tmp of new Set([tmpLong, os.tmpdir().toLowerCase()])) {
|
|
57
|
+
if (normalized === tmp || normalized.startsWith(tmp.replace(/\$/, "") + path.sep))
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
36
62
|
export function listRegistry() {
|
|
37
63
|
return loadRegistry().projects.sort((a, b) => b.last_seen.localeCompare(a.last_seen));
|
|
38
64
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { globToRegExp, normSep } from "./budget.js";
|
|
5
|
+
export const SCAN_IGNORE_DIRS = new Set([
|
|
6
|
+
"node_modules", ".git", "dist", "build", "out", ".next", ".nuxt", ".output", ".turbo", "coverage",
|
|
7
|
+
"__pycache__", ".venv", "venv", "env", ".pm", ".idea", ".vscode", "target", ".cache", ".gradle",
|
|
8
|
+
".zcode", ".runtime", "EBWebView",
|
|
9
|
+
// 包管理器缓存 / 运行时数据 / 临时与上下文目录:属于环境而非项目源码(T-045)
|
|
10
|
+
".pnpm-cache", ".pnpm-store", ".npm-cache", ".yarn-cache", ".data", ".tmp", ".temp",
|
|
11
|
+
".codex-context", "userdata", "logs",
|
|
12
|
+
]);
|
|
13
|
+
export const ScanIgnoreSchema = z.array(z.string().trim().min(1).max(512).transform(normSep).refine((glob) => !glob.startsWith("/") && !glob.startsWith("!") && !/^[a-z]:/i.test(glob) && !glob.split("/").includes(".."), "扫描排除必须是项目内的正向相对 glob")).max(100);
|
|
14
|
+
export function projectScanIgnores(root) {
|
|
15
|
+
const file = path.join(root, ".pm", "project.json");
|
|
16
|
+
let raw;
|
|
17
|
+
try {
|
|
18
|
+
raw = fs.readFileSync(file, "utf8");
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
if (error.code === "ENOENT")
|
|
22
|
+
return [];
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
return [...new Set(ScanIgnoreSchema.parse(JSON.parse(raw).scan_ignore ?? []))].sort();
|
|
26
|
+
}
|
|
27
|
+
export function ignoreMatcher(globs) {
|
|
28
|
+
const patterns = globs.map(globToRegExp);
|
|
29
|
+
return (rel, directory = false) => patterns.some((pattern) => pattern.test(rel) || (directory && pattern.test(`${rel}/`)));
|
|
30
|
+
}
|
|
31
|
+
export function scanExclusionNote(root) {
|
|
32
|
+
const ignores = projectScanIgnores(root);
|
|
33
|
+
return ignores.length ? [`扫描范围排除:${ignores.join(", ")};排除内容不属于本次检查结论。`] : [];
|
|
34
|
+
}
|
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,6 +37,7 @@ 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)
|
|
@@ -44,6 +46,8 @@ export function rgSearch(root, query, glob, maxResults, regex) {
|
|
|
44
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
|
@@ -7,6 +7,7 @@ import { readDirectDeps } from "./scan.js";
|
|
|
7
7
|
import { ensureFresh, getIndex, iterateFileRows, walkRefresh } from "./index-store.js";
|
|
8
8
|
import { readText } from "./search.js";
|
|
9
9
|
import { now } from "./types.js";
|
|
10
|
+
import { scanExclusionNote } from "./scan-policy.js";
|
|
10
11
|
const SECRET_PATTERNS = [
|
|
11
12
|
{ id: "secret.aws-access-key", severity: "high", pattern: /AKIA[0-9A-Z]{16}/, message: "疑似 AWS Access Key" },
|
|
12
13
|
{ id: "secret.github-token", severity: "high", pattern: /gh[pousr]_[A-Za-z0-9]{20,}/, message: "疑似 GitHub Token" },
|
|
@@ -265,6 +266,7 @@ export function auditSecurity(root, prepared) {
|
|
|
265
266
|
const high = open.filter((f) => f.severity === "high");
|
|
266
267
|
const L = [];
|
|
267
268
|
L.push(`## 安全体检报告(exposure=${project.exposure})`);
|
|
269
|
+
L.push(...scanExclusionNote(root));
|
|
268
270
|
// exposure 兑现:不同暴露面使用不同处置口径
|
|
269
271
|
if (project.exposure === "public") {
|
|
270
272
|
const mustFix = open.filter((f) => f.severity === "high" || f.severity === "medium");
|
|
@@ -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-graph.js
CHANGED
|
@@ -363,6 +363,8 @@ export function buildSemanticGraph(root, governance, options = {}) {
|
|
|
363
363
|
limitations: [...analysisLimitations],
|
|
364
364
|
},
|
|
365
365
|
files, fileEdges, moduleEdges, exports: exportedSymbols, contracts, unresolved, cycles: findModuleCycles(moduleEdges), violations,
|
|
366
|
+
// Tarjan 对 {from,to} 边同构,文件内边与模块边共用同一实现。
|
|
367
|
+
fileCycles: findModuleCycles(fileEdges.flatMap((edge) => (edge.to !== null && edge.resolution === "file" ? [{ from: edge.from, to: edge.to }] : []))),
|
|
366
368
|
coverage: {
|
|
367
369
|
totalFiles: files.length,
|
|
368
370
|
sourceCandidateFiles,
|
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
|
+
}
|