@zhushanwen/pi-cw-tool 0.2.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.
@@ -0,0 +1,183 @@
1
+ /**
2
+ * detectRepoWorkspace 真实 git 测试 + executeCwAction 集成(真实 cwd)。
3
+ *
4
+ * 与 cw-tool.test.ts 分开:该文件 mock 了 node:child_process(spawnSync 被替换),
5
+ * 而本文件需要真实 git 探测,故不 mock,直接对临时 git repo 验证。
6
+ *
7
+ * 测试框架:vitest(从 vitest 导入 describe/it/expect/vi)。
8
+ */
9
+ import { execSync } from "node:child_process";
10
+ import { mkdirSync, mkdtempSync, realpathSync, rmSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import * as path from "node:path";
13
+
14
+ import { afterEach, describe, expect, it, vi } from "vitest";
15
+
16
+ import {
17
+ buildCwArgs,
18
+ detectRepoWorkspace,
19
+ executeCwAction,
20
+ } from "../cw-runner.ts";
21
+ import { type CwSpawner } from "../cw-spawn.ts";
22
+ import { DEV_ALLOWED } from "../index.ts";
23
+
24
+ // ── 临时目录管理 ────────────────────────────────────────────────
25
+
26
+ const tmpDirs: string[] = [];
27
+
28
+ /** 建一个独立临时目录(afterEach 统一清理)。 */
29
+ function makeTempDir(prefix: string): string {
30
+ const dir = mkdtempSync(path.join(tmpdir(), prefix));
31
+ tmpDirs.push(dir);
32
+ return dir;
33
+ }
34
+
35
+ /** 在 dir 下初始化一个含一次空 commit 的 git repo,返回 realpath 规范化后的 repo 根。 */
36
+ function createGitRepo(parentDir: string, name: string): string {
37
+ const repo = path.join(parentDir, name);
38
+ mkdirSync(repo);
39
+ execSync("git init -q", { cwd: repo });
40
+ execSync("git -c user.name=test -c user.email=test@test.local commit -q --allow-empty -m init", {
41
+ cwd: repo,
42
+ });
43
+ // macOS 上 /tmp → /private/tmp:git rev-parse 输出 realpath,与 mkdtempSync 返回路径不一致,
44
+ // 统一以 realpath 为准(--workspace 最终传的也是 git 输出的规范化路径)。
45
+ return realpathSync(repo);
46
+ }
47
+
48
+ afterEach(() => {
49
+ for (const dir of tmpDirs.splice(0)) {
50
+ rmSync(dir, { recursive: true, force: true });
51
+ }
52
+ });
53
+
54
+ // ── detectRepoWorkspace(真实 git)─────────────────────────────
55
+
56
+ describe("detectRepoWorkspace(真实 git)", () => {
57
+ it("git repo 根目录 → 返回 repo 根(等于 --show-toplevel)", () => {
58
+ const base = makeTempDir("cw-detect-");
59
+ const repo = createGitRepo(base, "repo");
60
+ const toplevel = execSync("git rev-parse --show-toplevel", { cwd: repo })
61
+ .toString()
62
+ .trim();
63
+ expect(detectRepoWorkspace(repo)).toBe(toplevel);
64
+ });
65
+
66
+ it("repo 子目录(cwd 不在根)→ 仍返回 repo 根", () => {
67
+ const base = makeTempDir("cw-detect-");
68
+ const repo = createGitRepo(base, "repo");
69
+ const sub = path.join(repo, "src", "deep");
70
+ mkdirSync(sub, { recursive: true });
71
+ const toplevel = execSync("git rev-parse --show-toplevel", { cwd: sub })
72
+ .toString()
73
+ .trim();
74
+ expect(detectRepoWorkspace(sub)).toBe(toplevel);
75
+ });
76
+
77
+ it("同一 repo 的所有 worktree 返回相同值(repo 级统一,MF-1 核心证据)", () => {
78
+ const base = makeTempDir("cw-detect-");
79
+ const repo = createGitRepo(base, "repo");
80
+ const wtDir = path.join(base, "wt1");
81
+ execSync(`git worktree add -q ${wtDir}`, { cwd: repo });
82
+
83
+ const mainWs = detectRepoWorkspace(repo);
84
+ const wtWs = detectRepoWorkspace(wtDir);
85
+ expect(mainWs).toBe(repo);
86
+ expect(wtWs).toBe(repo);
87
+ expect(wtWs).toBe(mainWs);
88
+ });
89
+
90
+ it("非 git 目录 → undefined", () => {
91
+ const plain = makeTempDir("cw-detect-plain-");
92
+ expect(detectRepoWorkspace(plain)).toBeUndefined();
93
+ });
94
+
95
+ it("不存在的路径 → undefined(不抛)", () => {
96
+ const base = makeTempDir("cw-detect-");
97
+ expect(detectRepoWorkspace(path.join(base, "does-not-exist"))).toBeUndefined();
98
+ });
99
+ });
100
+
101
+ // ── executeCwAction 集成(真实 cwd,fake spawner 记录 args)─────
102
+
103
+ describe("executeCwAction 集成(真实 git cwd)", () => {
104
+ /** 记录 args 的 fake spawner(不真调 cw)。 */
105
+ function recordingSpawner(): { spawner: CwSpawner; calls: Array<{ args: string[] }> } {
106
+ const calls: Array<{ args: string[] }> = [];
107
+ const spawner: CwSpawner = vi.fn(async (args: string[]) => {
108
+ calls.push({ args });
109
+ return { stdout: "{}", stderr: "", exitCode: 0 };
110
+ }) as unknown as CwSpawner;
111
+ return { spawner, calls };
112
+ }
113
+
114
+ it("cwd 在 git repo 内 → write action args 含 --workspace <repo 根>", async () => {
115
+ const base = makeTempDir("cw-int-");
116
+ const repo = createGitRepo(base, "repo");
117
+ const { spawner, calls } = recordingSpawner();
118
+ await executeCwAction("execute", DEV_ALLOWED, "cw_dev", "u1", {}, spawner, repo);
119
+ expect(calls[0].args).toContain("--workspace");
120
+ expect(calls[0].args[calls[0].args.indexOf("--workspace") + 1]).toBe(repo);
121
+ });
122
+
123
+ it("cwd 在 linked worktree 内 → write action --workspace 指向 repo 主目录", async () => {
124
+ const base = makeTempDir("cw-int-");
125
+ const repo = createGitRepo(base, "repo");
126
+ const wtDir = path.join(base, "wt1");
127
+ execSync(`git worktree add -q ${wtDir}`, { cwd: repo });
128
+ const { spawner, calls } = recordingSpawner();
129
+ await executeCwAction("execute", DEV_ALLOWED, "cw_dev", "u1", {}, spawner, wtDir);
130
+ expect(calls[0].args).toContain("--workspace");
131
+ expect(calls[0].args[calls[0].args.indexOf("--workspace") + 1]).toBe(repo);
132
+ });
133
+
134
+ it("read-only action(status)即使在 git repo 内也不附加 --workspace(S-3)", async () => {
135
+ const base = makeTempDir("cw-int-");
136
+ const repo = createGitRepo(base, "repo");
137
+ const { spawner, calls } = recordingSpawner();
138
+ await executeCwAction("status", DEV_ALLOWED, "cw_dev", "u1", {}, spawner, repo);
139
+ expect(calls[0].args).not.toContain("--workspace");
140
+ });
141
+
142
+ it("cwd 为非 git 目录 → write action 不含 --workspace", async () => {
143
+ const plain = makeTempDir("cw-int-plain-");
144
+ const { spawner, calls } = recordingSpawner();
145
+ await executeCwAction("execute", DEV_ALLOWED, "cw_dev", "u1", {}, spawner, plain);
146
+ expect(calls[0].args).not.toContain("--workspace");
147
+ });
148
+
149
+ it("cwd 不存在 → write action 不含 --workspace(探测失败不抛)", async () => {
150
+ const base = makeTempDir("cw-int-");
151
+ const { spawner, calls } = recordingSpawner();
152
+ await executeCwAction(
153
+ "execute",
154
+ DEV_ALLOWED,
155
+ "cw_dev",
156
+ "u1",
157
+ {},
158
+ spawner,
159
+ path.join(base, "missing"),
160
+ );
161
+ expect(calls[0].args).not.toContain("--workspace");
162
+ });
163
+ });
164
+
165
+ // ── buildCwArgs 纯函数(workspace 参数)─────────────────────────
166
+
167
+ describe("buildCwArgs(workspace 参数)", () => {
168
+ it("workspace + commitHash → --workspace 位于 --commitHash 之后", () => {
169
+ expect(buildCwArgs("execute", "u1", { commitHash: "abc" }, "/repo/root")).toEqual([
170
+ "execute",
171
+ "--unitId",
172
+ "u1",
173
+ "--commitHash",
174
+ "abc",
175
+ "--workspace",
176
+ "/repo/root",
177
+ ]);
178
+ });
179
+
180
+ it("workspace 为空字符串 → 不追加", () => {
181
+ expect(buildCwArgs("status", "u1", {}, "")).toEqual(["status", "--unitId", "u1"]);
182
+ });
183
+ });
@@ -0,0 +1,275 @@
1
+ /**
2
+ * cw action 执行核心:白名单校验 + 参数构造 + spawn + 输出解析。
3
+ *
4
+ * 与 Pi SDK 解耦(不 import pi 类型),纯逻辑 + 可注入 spawner,便于单测。
5
+ * 所有错误路径返回 `{ ok: false, error }`,不抛异常(由调用方映射为 tool 返回)。
6
+ */
7
+ import { spawnSync } from "node:child_process";
8
+ import * as path from "node:path";
9
+
10
+ import type { CwSpawner } from "./cw-spawn.ts";
11
+
12
+ /** cw 全部 action 名(E1 后:clarify 已删、plan→design)。透传 cw,与 cw-cli ALL_ACTIONS 对齐。 */
13
+ export const CW_ACTIONS = [
14
+ "create",
15
+ "design",
16
+ "design-review",
17
+ "execute",
18
+ "test",
19
+ "exec-review",
20
+ "retrospect",
21
+ "closeout",
22
+ "replan",
23
+ "abort",
24
+ "list",
25
+ "tree",
26
+ "status",
27
+ "handoff",
28
+ "frontier",
29
+ ] as const;
30
+ export type CwAction = (typeof CW_ACTIONS)[number];
31
+
32
+ /** 只读 action(不推进状态机,query only)。 */
33
+ export const READONLY_ACTIONS = ["list", "tree", "status", "handoff", "frontier"] as const;
34
+
35
+ /**
36
+ * 判断 action 是否为只读(属于 {@link READONLY_ACTIONS})。
37
+ *
38
+ * 只读 action 的两个边界复用此判定(S-3/S-5):
39
+ * - 不附加 `--workspace`(保守避免 cw 子命令拒收未知选项导致 readonly 查询失败,S-3);
40
+ * - 不强制 unitId(list/tree/frontier 等全局查询不需要具体 unit,S-5)。
41
+ *
42
+ * 用 `.some(===)` 而非 `.includes()` 以保持 string 入参的类型安全(readonly tuple 的
43
+ * `.includes()` 要求字面量联合类型,传 string 会报错,无需 `as` 宽化)。
44
+ */
45
+ export function isReadonlyAction(action: string): boolean {
46
+ return READONLY_ACTIONS.some((a) => a === action);
47
+ }
48
+
49
+ /** 透传给 cw 的可选参数(flags)。 */
50
+ export interface CwToolOptions {
51
+ /** JSON 内容字符串,经 stdin 传给 cw(`cw --input -`)。与 inputFile 互斥。 */
52
+ input?: string;
53
+ /** input 文件路径,直接传 `--input <path>`。与 input 互斥。 */
54
+ inputFile?: string;
55
+ /** execute 关联的 commit(wave 层),传 `--commitHash`。 */
56
+ commitHash?: string;
57
+ }
58
+
59
+ /** 工具返回的 details 结构(结构化成功/失败,调用方按 `ok` 区分)。 */
60
+ export type CwDetails =
61
+ | { ok: true; action: string; unitId: string | undefined; stdout: string; parsed: true; data: unknown }
62
+ | { ok: true; action: string; unitId: string | undefined; stdout: string; parsed: false }
63
+ | { ok: false; action: string; unitId: string | undefined; error: string };
64
+
65
+ /**
66
+ * 白名单校验。返回错误消息(string)或 undefined(放行)。
67
+ *
68
+ * 设计为 string-based,独立于 schema 枚举——schema 是 LLM 输入的第一道约束,
69
+ * 此函数是 execute 内的防御性第二道(直接/程序化调用、宽松 provider 兜底),
70
+ * 同时是单测的核心契约(直接调 executeCwAction 传任意 action 验证拦截)。
71
+ */
72
+ export function rejectDisallowedAction(
73
+ action: string,
74
+ allowed: readonly string[],
75
+ toolName: string,
76
+ ): string | undefined {
77
+ if (!allowed.includes(action)) {
78
+ return `action "${action}" 不在 ${toolName} 白名单。允许的 action: ${allowed.join(", ")}`;
79
+ }
80
+ return undefined;
81
+ }
82
+
83
+ /** git 探测超时(ms):git 卡死时避免阻塞 agent turn。 */
84
+ const GIT_PROBE_TIMEOUT_MS = 5000;
85
+
86
+ /**
87
+ * 探测 cwd 所属 repo 的主目录(repo 级 workspace)。
88
+ *
89
+ * 用 `git rev-parse --path-format=absolute --git-common-dir` 取 git common dir:
90
+ * 同一 repo 的所有 worktree 返回相同路径,dirname 即 repo 主目录。cw store 键控
91
+ * 从 per-cwd 升级为 repo 级(ADR-0045)后,spawn cw 时附带 --workspace 让 cw
92
+ * 在 repo 主目录解析/共享状态,避免同一 repo 的 worktree 间状态各自为政。
93
+ *
94
+ * 任何失败(非 git 目录、git 不在 PATH、路径不存在、超时)→ undefined(不抛)。
95
+ */
96
+ export function detectRepoWorkspace(cwd: string): string | undefined {
97
+ try {
98
+ const result = spawnSync(
99
+ "git",
100
+ ["-C", cwd, "rev-parse", "--path-format=absolute", "--git-common-dir"],
101
+ { encoding: "utf8", timeout: GIT_PROBE_TIMEOUT_MS },
102
+ );
103
+ if (result.status !== 0) return undefined;
104
+ const gitCommonDir = result.stdout.trim();
105
+ if (gitCommonDir.length === 0) return undefined;
106
+ return path.dirname(gitCommonDir);
107
+ } catch {
108
+ return undefined;
109
+ }
110
+ }
111
+
112
+ /** input / inputFile 互斥校验。 */
113
+ export function rejectConflictingInput(opts: CwToolOptions): string | undefined {
114
+ if (opts.input !== undefined && opts.inputFile !== undefined) {
115
+ return "'input' 和 'inputFile' 互斥,只能传其中一个。";
116
+ }
117
+ return undefined;
118
+ }
119
+
120
+ /**
121
+ * unitId 缺失校验(S-5)。写 action 缺 unitId → 返回错误消息;只读 action 或已传 unitId → undefined(放行)。
122
+ *
123
+ * schema 已把 unitId 改为 Optional(只读 action 不需要),此函数是写 action 的运行时第二道约束
124
+ * (直接/程序化调用、宽松 provider 兜底),与 rejectDisallowedAction / rejectConflictingInput 同族。
125
+ */
126
+ export function rejectMissingUnitId(action: string, unitId: string | undefined): string | undefined {
127
+ if (unitId === undefined && !isReadonlyAction(action)) {
128
+ return `action "${action}" 需要 unitId(只读 action ${READONLY_ACTIONS.join("/")} 可省略)`;
129
+ }
130
+ return undefined;
131
+ }
132
+
133
+ /**
134
+ * 构建 cw 命令行参数(action 后接 flags)。
135
+ *
136
+ * - unitId(若提供)→ `--unitId <id>`;undefined 则省略(只读 action 不需要,S-5)
137
+ * - input 内容 → `--input -`(经 stdin,见 executeCwAction)
138
+ * - inputFile 路径 → `--input <path>`
139
+ * - commitHash → `--commitHash <sha>`
140
+ * - workspace(repo 主目录,由调用方经 detectRepoWorkspace 探测)→ `--workspace <path>`,位于 --commitHash 之后
141
+ */
142
+ export function buildCwArgs(
143
+ action: string,
144
+ unitId: string | undefined,
145
+ opts: CwToolOptions,
146
+ workspace?: string,
147
+ ): string[] {
148
+ const args: string[] = [action];
149
+ if (unitId !== undefined) {
150
+ args.push("--unitId", unitId);
151
+ }
152
+
153
+ if (opts.inputFile) {
154
+ args.push("--input", opts.inputFile);
155
+ } else if (opts.input !== undefined) {
156
+ args.push("--input", "-");
157
+ }
158
+
159
+ if (opts.commitHash) {
160
+ args.push("--commitHash", opts.commitHash);
161
+ }
162
+
163
+ if (workspace) {
164
+ args.push("--workspace", workspace);
165
+ }
166
+
167
+ return args;
168
+ }
169
+
170
+ /** 尝试把 stdout 解析为 JSON。成功返回解析值,失败返回 undefined。 */
171
+ function tryParseJson(text: string): unknown | undefined {
172
+ const trimmed = text.trim();
173
+ if (trimmed.length === 0) return undefined;
174
+ try {
175
+ return JSON.parse(trimmed);
176
+ } catch {
177
+ return undefined;
178
+ }
179
+ }
180
+
181
+ /** cw spawn 默认超时(5 分钟)。cw 卡死时避免永久挂起 agent turn。 */
182
+ const DEFAULT_CW_TIMEOUT_MS = 300_000;
183
+
184
+ /**
185
+ * 执行 cw action 的核心逻辑:白名单校验 → 参数冲突校验 → spawn → 解析。
186
+ *
187
+ * 失败判定:非零退出码(含被信号终止的 null)→ ok:false(stderr 折进错误消息;S-2 按 exitCode 判定)。
188
+ * 成功后 stdout 尝试 JSON.parse:成功 → parsed:true + data;失败 → parsed:false + 原样 stdout。
189
+ *
190
+ * @param action 调用方请求的 action(运行时再校验白名单)。
191
+ * @param allowed 该工具允许的 action 白名单。
192
+ * @param toolName 工具名(错误消息归属用)。
193
+ * @param unitId cw unit id(写 action 必传;只读 action 可省略,见 rejectMissingUnitId)。
194
+ * @param opts 可选 flags。
195
+ * @param spawner spawn 实现(默认走真实 cw,测试注入 fake)。
196
+ * @param cwd 子进程工作目录。
197
+ * @param signal 可选 SDK abort signal;与超时合并后透传给 spawner,abort 时 spawner kill 子进程。
198
+ * @param timeoutMs spawn 超时(ms),默认 5 分钟;0 表示不限时。超时返回 ok:false "cw 超时"。
199
+ */
200
+ export async function executeCwAction(
201
+ action: string,
202
+ allowed: readonly string[],
203
+ toolName: string,
204
+ unitId: string | undefined,
205
+ opts: CwToolOptions,
206
+ spawner: CwSpawner,
207
+ cwd: string,
208
+ signal?: AbortSignal,
209
+ timeoutMs: number = DEFAULT_CW_TIMEOUT_MS,
210
+ ): Promise<CwDetails> {
211
+ const base = { action, unitId };
212
+
213
+ const actionErr = rejectDisallowedAction(action, allowed, toolName);
214
+ if (actionErr) return { ok: false, ...base, error: actionErr };
215
+
216
+ const inputErr = rejectConflictingInput(opts);
217
+ if (inputErr) return { ok: false, ...base, error: inputErr };
218
+
219
+ // unitId 运行时校验(S-5):写 action 缺 unitId → 清晰错误(schema 已把 unitId 改 Optional)。
220
+ const unitIdErr = rejectMissingUnitId(action, unitId);
221
+ if (unitIdErr) return { ok: false, ...base, error: unitIdErr };
222
+
223
+ // repo 级 workspace(ADR-0045):仅写 action 附加(S-3:只读 action 保守不加,避免 cw 子命令
224
+ // 拒收未知选项导致 readonly 查询失败);cwd 在 git repo 内才探测,探测失败静默跳过。
225
+ const workspace = isReadonlyAction(action) ? undefined : detectRepoWorkspace(cwd);
226
+ const args = buildCwArgs(action, unitId, opts, workspace);
227
+ const stdinPayload = opts.input !== undefined ? opts.input : undefined;
228
+
229
+ // 合并 SDK abort signal 与超时为单个 signal 传给 spawner:spawner(默认实现)在 abort
230
+ // 时 kill 子进程,避免 abort/超时后僵尸 cw 继续推进状态机。
231
+ const combined = new AbortController();
232
+ let timedOut = false;
233
+ const onSdkAbort = (): void => combined.abort();
234
+ if (signal) {
235
+ if (signal.aborted) combined.abort();
236
+ else signal.addEventListener("abort", onSdkAbort, { once: true });
237
+ }
238
+ const timer =
239
+ timeoutMs > 0
240
+ ? setTimeout(() => {
241
+ timedOut = true;
242
+ combined.abort();
243
+ }, timeoutMs)
244
+ : undefined;
245
+
246
+ let result;
247
+ try {
248
+ result = await spawner(args, stdinPayload, cwd, combined.signal);
249
+ } catch (err) {
250
+ const msg = err instanceof Error ? err.message : String(err);
251
+ return { ok: false, ...base, error: `cw spawn 失败: ${msg}` };
252
+ } finally {
253
+ if (timer) clearTimeout(timer);
254
+ if (signal) signal.removeEventListener("abort", onSdkAbort);
255
+ }
256
+
257
+ // 超时优先于结果判定(spawner 被 kill 后 resolve,exitCode 通常为 null)。
258
+ if (timedOut) return { ok: false, ...base, error: "cw 超时" };
259
+
260
+ const { stdout, stderr, exitCode } = result;
261
+
262
+ // S-2:按 exitCode 判定失败(防御性,不依赖 cw 是否向 stderr 写非错误诊断信息)。
263
+ // 非零退出码(含 null=被信号终止)→ 失败;stderr 折进错误消息(成功时 stderr 不导致失败)。
264
+ if (exitCode !== 0) {
265
+ const parts: string[] = [`exit code ${exitCode ?? "null"}`];
266
+ if (stderr.trim()) parts.push(stderr.trim());
267
+ return { ok: false, ...base, error: parts.join(" | ") };
268
+ }
269
+
270
+ const data = tryParseJson(stdout);
271
+ if (data !== undefined) {
272
+ return { ok: true, ...base, stdout, parsed: true, data };
273
+ }
274
+ return { ok: true, ...base, stdout, parsed: false };
275
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * cw 子进程执行抽象。
3
+ *
4
+ * 设计为可注入(CwSpawner),使核心逻辑(cw-runner.ts)可在测试中用 fake
5
+ * spawner 替换,避免真调 cw。默认实现 {@link defaultCwSpawner} 走 child_process。
6
+ *
7
+ * cw 路径解析:spawn 裸命令名 `cw`,由 OS execvp 语义在 `process.env.PATH` 中
8
+ * 查找(架构约定 #16:禁止写死绝对路径)。env 继承自 process.env,确保 PATH 可用。
9
+ */
10
+ import { spawn } from "node:child_process";
11
+
12
+ /** cw 子进程执行结果。 */
13
+ export interface CwSpawnResult {
14
+ /** stdout 内容(cw action 通常把结果 JSON 输出到 stdout)。 */
15
+ stdout: string;
16
+ /** stderr 内容(cw 的错误/诊断信息)。 */
17
+ stderr: string;
18
+ /** 进程退出码;null 表示被信号终止未产出退出码(视为异常)。 */
19
+ exitCode: number | null;
20
+ }
21
+
22
+ /**
23
+ * spawn cw 的可注入抽象。
24
+ *
25
+ * @param args 传给 cw 的参数(不含 `cw` 本身,由实现补上)。
26
+ * @param input 要写入子进程 stdin 的内容;undefined 表示不写(cw 不读 stdin)。
27
+ * @param cwd 子进程工作目录。
28
+ * @param signal 可选 abort signal;实现应在 abort 时 kill 子进程(见 defaultCwSpawner),
29
+ * 避免 abort 后僵尸 cw 子进程继续推进状态机(executeCwAction 把 SDK signal +
30
+ * 超时合并为此 signal 传入)。
31
+ */
32
+ export type CwSpawner = (
33
+ args: string[],
34
+ input: string | undefined,
35
+ cwd: string,
36
+ signal?: AbortSignal,
37
+ ) => Promise<CwSpawnResult>;
38
+
39
+ /**
40
+ * 默认 cw spawner:用 child_process.spawn 执行 PATH 中的 `cw`。
41
+ *
42
+ * - stdout/stderr 设 utf8 编码后全量捕获(data 回调收 string,无需 Buffer 处理)。
43
+ * - input(若提供)写入 stdin 后关闭;未提供则直接 end(cw 不阻塞等待 stdin)。
44
+ * - spawn 自身失败(如 cw 不在 PATH)走 'error' 事件,拼进 stderr、exitCode=-1 标记异常。
45
+ * - signal abort 时 kill 子进程(SIGTERM),避免 abort 后僵尸 cw 继续推进状态机;
46
+ * signal 进入时已 aborted 则立即 kill。listener 在 settle 时移除防泄漏。
47
+ */
48
+ export const defaultCwSpawner: CwSpawner = (args, input, cwd, signal) =>
49
+ new Promise<CwSpawnResult>((resolve) => {
50
+ const child = spawn("cw", args, {
51
+ cwd,
52
+ env: process.env,
53
+ stdio: ["pipe", "pipe", "pipe"],
54
+ });
55
+
56
+ let stdout = "";
57
+ let stderr = "";
58
+
59
+ child.stdout.setEncoding("utf8");
60
+ child.stderr.setEncoding("utf8");
61
+ child.stdout.on("data", (chunk: string) => {
62
+ stdout += chunk;
63
+ });
64
+ child.stderr.on("data", (chunk: string) => {
65
+ stderr += chunk;
66
+ });
67
+
68
+ // abort → kill 子进程,防止 cw 状态机被已 abort 的僵尸子进程推进。
69
+ const onAbort = (): void => {
70
+ child.kill("SIGTERM");
71
+ };
72
+ if (signal) {
73
+ if (signal.aborted) {
74
+ child.kill("SIGTERM");
75
+ } else {
76
+ signal.addEventListener("abort", onAbort, { once: true });
77
+ }
78
+ }
79
+
80
+ if (input !== undefined) {
81
+ child.stdin.write(input, "utf8");
82
+ }
83
+ child.stdin.end();
84
+
85
+ // error 与 close 可能先后触发;用 settled 守卫保证只 resolve 一次并清理 listener。
86
+ let settled = false;
87
+ const finish = (result: CwSpawnResult): void => {
88
+ if (settled) return;
89
+ settled = true;
90
+ signal?.removeEventListener("abort", onAbort);
91
+ resolve(result);
92
+ };
93
+
94
+ child.on("error", (err: NodeJS.ErrnoException) => {
95
+ // spawn 失败(cw 不在 PATH / 无执行权限等)。exitCode=-1 区分于正常退出码。
96
+ finish({ stdout, stderr: `${stderr}\n[spawn error] ${err.message}`, exitCode: -1 });
97
+ });
98
+ child.on("close", (code: number | null) => {
99
+ finish({ stdout, stderr, exitCode: code });
100
+ });
101
+ });