hamster-wheel-cli 0.1.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.
Files changed (55) hide show
  1. package/.github/ISSUE_TEMPLATE/bug_report.yml +107 -0
  2. package/.github/ISSUE_TEMPLATE/config.yml +15 -0
  3. package/.github/ISSUE_TEMPLATE/feature_request.yml +56 -0
  4. package/.github/workflows/ci-pr.yml +50 -0
  5. package/.github/workflows/publish.yml +121 -0
  6. package/.github/workflows/sync-master-to-dev.yml +100 -0
  7. package/AGENTS.md +20 -0
  8. package/CHANGELOG.md +12 -0
  9. package/LICENSE +21 -0
  10. package/README.md +90 -0
  11. package/dist/cli.d.ts +6 -0
  12. package/dist/cli.js +2678 -0
  13. package/dist/cli.js.map +1 -0
  14. package/dist/index.d.ts +80 -0
  15. package/dist/index.js +2682 -0
  16. package/dist/index.js.map +1 -0
  17. package/docs/ai-workflow.md +58 -0
  18. package/package.json +44 -0
  19. package/src/ai.ts +173 -0
  20. package/src/cli.ts +189 -0
  21. package/src/config.ts +134 -0
  22. package/src/deps.ts +210 -0
  23. package/src/gh.ts +228 -0
  24. package/src/git.ts +285 -0
  25. package/src/global-config.ts +296 -0
  26. package/src/index.ts +3 -0
  27. package/src/logger.ts +122 -0
  28. package/src/logs-viewer.ts +420 -0
  29. package/src/logs.ts +132 -0
  30. package/src/loop.ts +422 -0
  31. package/src/monitor.ts +291 -0
  32. package/src/runtime-tracker.ts +65 -0
  33. package/src/summary.ts +255 -0
  34. package/src/types.ts +176 -0
  35. package/src/utils.ts +179 -0
  36. package/src/webhook.ts +107 -0
  37. package/tests/deps.test.ts +72 -0
  38. package/tests/e2e/cli.e2e.test.ts +77 -0
  39. package/tests/e2e/gh-pr-create.e2e.test.ts +55 -0
  40. package/tests/e2e/gh-run-list.e2e.test.ts +47 -0
  41. package/tests/gh-pr-create.test.ts +55 -0
  42. package/tests/gh-run-list.test.ts +35 -0
  43. package/tests/global-config.test.ts +52 -0
  44. package/tests/logger-file.test.ts +56 -0
  45. package/tests/logger.test.ts +72 -0
  46. package/tests/logs-viewer.test.ts +57 -0
  47. package/tests/logs.test.ts +33 -0
  48. package/tests/prompt.test.ts +20 -0
  49. package/tests/run-command-stream.test.ts +60 -0
  50. package/tests/summary.test.ts +58 -0
  51. package/tests/token-usage.test.ts +33 -0
  52. package/tests/utils.test.ts +8 -0
  53. package/tests/webhook.test.ts +89 -0
  54. package/tsconfig.json +18 -0
  55. package/tsup.config.ts +18 -0
@@ -0,0 +1,65 @@
1
+ import { Logger } from './logger';
2
+ import { RunMetadata, removeCurrentRegistry, upsertCurrentRegistry, writeRunMetadata } from './logs';
3
+
4
+ export interface RunTracker {
5
+ readonly update: (round: number, tokenUsed: number) => Promise<void>;
6
+ readonly finalize: () => Promise<void>;
7
+ }
8
+
9
+ interface RunTrackerOptions {
10
+ readonly logFile?: string;
11
+ readonly command: string;
12
+ readonly path: string;
13
+ readonly logger?: Logger;
14
+ }
15
+
16
+ async function safeWrite(logFile: string, metadata: RunMetadata, logger?: Logger): Promise<void> {
17
+ try {
18
+ await writeRunMetadata(logFile, metadata);
19
+ } catch (error) {
20
+ const message = error instanceof Error ? error.message : String(error);
21
+ logger?.warn(`写入运行元信息失败: ${message}`);
22
+ }
23
+ try {
24
+ await upsertCurrentRegistry(logFile, metadata);
25
+ } catch (error) {
26
+ const message = error instanceof Error ? error.message : String(error);
27
+ logger?.warn(`更新 current.json 失败: ${message}`);
28
+ }
29
+ }
30
+
31
+ async function safeRemove(logFile: string, logger?: Logger): Promise<void> {
32
+ try {
33
+ await removeCurrentRegistry(logFile);
34
+ } catch (error) {
35
+ const message = error instanceof Error ? error.message : String(error);
36
+ logger?.warn(`清理 current.json 失败: ${message}`);
37
+ }
38
+ }
39
+
40
+ /**
41
+ * 创建运行中任务的追踪器(写入 JSON 元数据)。
42
+ */
43
+ export async function createRunTracker(options: RunTrackerOptions): Promise<RunTracker | null> {
44
+ const { logFile, command, path, logger } = options;
45
+ if (!logFile) return null;
46
+
47
+ const update = async (round: number, tokenUsed: number): Promise<void> => {
48
+ const metadata: RunMetadata = {
49
+ command,
50
+ round,
51
+ tokenUsed,
52
+ path
53
+ };
54
+ await safeWrite(logFile, metadata, logger);
55
+ };
56
+
57
+ await update(0, 0);
58
+
59
+ return {
60
+ update,
61
+ finalize: async (): Promise<void> => {
62
+ await safeRemove(logFile, logger);
63
+ }
64
+ };
65
+ }
package/src/summary.ts ADDED
@@ -0,0 +1,255 @@
1
+ import { DeliverySummary, TestRunResult } from './types';
2
+
3
+ /**
4
+ * 生成交付摘要提示的输入。
5
+ */
6
+ export interface SummaryPromptInput {
7
+ readonly task: string;
8
+ readonly plan: string;
9
+ readonly notes: string;
10
+ readonly lastAiOutput: string;
11
+ readonly testResults: TestRunResult[] | null;
12
+ readonly gitStatus: string;
13
+ readonly diffStat: string;
14
+ readonly branchName?: string;
15
+ }
16
+
17
+ /**
18
+ * 兜底摘要输入。
19
+ */
20
+ export interface SummaryFallbackInput {
21
+ readonly task: string;
22
+ readonly testResults: TestRunResult[] | null;
23
+ }
24
+
25
+ /**
26
+ * PR 文案兜底输入。
27
+ */
28
+ export interface PrBodyFallbackInput {
29
+ readonly commitTitle: string;
30
+ readonly commitBody?: string;
31
+ readonly testResults: TestRunResult[] | null;
32
+ }
33
+
34
+ const REQUIRED_SECTIONS = ['# 变更摘要', '# 测试结果', '# 风险与回滚'] as const;
35
+
36
+ function normalizeText(text: string): string {
37
+ return text.replace(/\r\n?/g, '\n');
38
+ }
39
+
40
+ function compactLine(text: string): string {
41
+ return text.replace(/\s+/g, ' ').trim();
42
+ }
43
+
44
+ function trimTail(text: string, limit: number, emptyFallback: string): string {
45
+ const normalized = normalizeText(text).trim();
46
+ if (!normalized) return emptyFallback;
47
+ if (normalized.length <= limit) return normalized;
48
+ return `(内容过长,保留最后 ${limit} 字符)\n${normalized.slice(-limit)}`;
49
+ }
50
+
51
+ function formatTestResultLines(testResults: TestRunResult[] | null): string[] {
52
+ if (!testResults || testResults.length === 0) {
53
+ return ['- 未运行(本次未执行测试)'];
54
+ }
55
+ return testResults.map(result => {
56
+ const label = result.kind === 'unit' ? '单元测试' : 'e2e 测试';
57
+ const status = result.success ? '通过' : `失败(退出码 ${result.exitCode})`;
58
+ const command = result.command ? `|命令: ${result.command}` : '';
59
+ return `- ${label}: ${status} ${command}`.trim();
60
+ });
61
+ }
62
+
63
+ function formatTestResultsForPrompt(testResults: TestRunResult[] | null): string {
64
+ return formatTestResultLines(testResults).join('\n');
65
+ }
66
+
67
+ function buildSummaryLinesFromCommit(commitTitle: string, commitBody?: string): string[] {
68
+ const bulletLines = extractBulletLines(commitBody);
69
+ if (bulletLines.length > 0) return bulletLines;
70
+ const summary = stripCommitType(commitTitle);
71
+ return [`- ${summary}`];
72
+ }
73
+
74
+ function stripCommitType(title: string): string {
75
+ const trimmed = compactLine(title);
76
+ if (!trimmed) return '更新迭代产出';
77
+ const match = trimmed.match(/^[^:]+:\s*(.+)$/);
78
+ return match?.[1]?.trim() || trimmed;
79
+ }
80
+
81
+ function buildPrBody(summaryLines: string[], testLines: string[]): string {
82
+ const riskLines = ['- 风险:待评估', '- 回滚:git revert 对应提交或关闭 PR'];
83
+ return [
84
+ '# 变更摘要',
85
+ summaryLines.join('\n'),
86
+ '',
87
+ '# 测试结果',
88
+ testLines.join('\n'),
89
+ '',
90
+ '# 风险与回滚',
91
+ riskLines.join('\n')
92
+ ].join('\n');
93
+ }
94
+
95
+ /**
96
+ * 构建交付摘要提示词。
97
+ */
98
+ export function buildSummaryPrompt(input: SummaryPromptInput): string {
99
+ const planSnippet = trimTail(input.plan, 2000, '(计划为空)');
100
+ const notesSnippet = trimTail(input.notes, 4000, '(notes 为空)');
101
+ const aiSnippet = trimTail(input.lastAiOutput, 3000, '(本轮无 AI 输出)');
102
+ const statusSnippet = trimTail(input.gitStatus, 1000, '(git status 为空)');
103
+ const diffSnippet = trimTail(input.diffStat, 1000, '(diff 统计为空)');
104
+ const testSummary = formatTestResultsForPrompt(input.testResults);
105
+
106
+ return [
107
+ '# 角色',
108
+ '你是资深工程师,需要为本次迭代生成提交信息与 PR 文案。',
109
+ '# 任务',
110
+ '基于输入信息输出严格 JSON(不要 markdown、不要代码块、不要多余文字)。',
111
+ '要求:',
112
+ '- 全部中文。',
113
+ '- commitTitle / prTitle 使用 Conventional Commits 格式:<type>: <概要>,简洁具体,不要出现“自动迭代提交/自动 PR”等字样。',
114
+ '- commitBody 为多行要点列表(可为空字符串)。',
115
+ '- prBody 为 Markdown,必须包含标题:# 变更摘要、# 测试结果、# 风险与回滚,并在变更摘要中体现工作总结。',
116
+ '- 不确定处可基于现有信息合理推断,但不要编造测试结果。',
117
+ '# 输出 JSON',
118
+ '{"commitTitle":"...","commitBody":"...","prTitle":"...","prBody":"..."}',
119
+ '# 输入信息',
120
+ `任务: ${compactLine(input.task) || '(空)'}`,
121
+ `分支: ${input.branchName ?? '(未知)'}`,
122
+ '计划(节选):',
123
+ planSnippet,
124
+ 'notes(节选):',
125
+ notesSnippet,
126
+ '最近一次 AI 输出(节选):',
127
+ aiSnippet,
128
+ '测试结果:',
129
+ testSummary,
130
+ 'git status --short:',
131
+ statusSnippet,
132
+ 'git diff --stat:',
133
+ diffSnippet
134
+ ].join('\n\n');
135
+ }
136
+
137
+ function pickString(record: Record<string, unknown>, keys: string[]): string | null {
138
+ for (const key of keys) {
139
+ const value = record[key];
140
+ if (typeof value === 'string' && value.trim().length > 0) {
141
+ return value.trim();
142
+ }
143
+ }
144
+ return null;
145
+ }
146
+
147
+ function extractJson(text: string): string | null {
148
+ const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
149
+ if (fenced?.[1]) return fenced[1].trim();
150
+ const start = text.indexOf('{');
151
+ const end = text.lastIndexOf('}');
152
+ if (start >= 0 && end > start) {
153
+ return text.slice(start, end + 1).trim();
154
+ }
155
+ return null;
156
+ }
157
+
158
+ function normalizeTitle(title: string): string {
159
+ return compactLine(title);
160
+ }
161
+
162
+ function normalizeBody(body?: string | null): string | undefined {
163
+ if (!body) return undefined;
164
+ const normalized = normalizeText(body).trim();
165
+ return normalized.length > 0 ? normalized : undefined;
166
+ }
167
+
168
+ function extractBulletLines(text?: string | null): string[] {
169
+ if (!text) return [];
170
+ const lines = normalizeText(text)
171
+ .split('\n')
172
+ .map(line => line.trim())
173
+ .filter(Boolean);
174
+ const bullets = lines.filter(line => line.startsWith('- ') || line.startsWith('* '));
175
+ return bullets.map(line => (line.startsWith('* ') ? `- ${line.slice(2).trim()}` : line));
176
+ }
177
+
178
+ /**
179
+ * 解析 AI 输出的交付摘要 JSON。
180
+ */
181
+ export function parseDeliverySummary(output: string): DeliverySummary | null {
182
+ const jsonText = extractJson(output);
183
+ if (!jsonText) return null;
184
+ try {
185
+ const parsed = JSON.parse(jsonText) as Record<string, unknown>;
186
+ let commitTitle = pickString(parsed, ['commitTitle', 'commit_message', 'commitMessage', 'commit_title']);
187
+ let commitBody = pickString(parsed, ['commitBody', 'commit_body']);
188
+ let prTitle = pickString(parsed, ['prTitle', 'pr_title']);
189
+ let prBody = pickString(parsed, ['prBody', 'pr_body']);
190
+
191
+ const commitObj = parsed.commit;
192
+ if ((!commitTitle || !commitBody) && typeof commitObj === 'object' && commitObj !== null) {
193
+ const commitRecord = commitObj as Record<string, unknown>;
194
+ commitTitle = commitTitle ?? pickString(commitRecord, ['title', 'commitTitle']);
195
+ commitBody = commitBody ?? pickString(commitRecord, ['body', 'commitBody']);
196
+ }
197
+
198
+ const prObj = parsed.pr;
199
+ if ((!prTitle || !prBody) && typeof prObj === 'object' && prObj !== null) {
200
+ const prRecord = prObj as Record<string, unknown>;
201
+ prTitle = prTitle ?? pickString(prRecord, ['title', 'prTitle']);
202
+ prBody = prBody ?? pickString(prRecord, ['body', 'prBody']);
203
+ }
204
+
205
+ if (!commitTitle || !prTitle || !prBody) return null;
206
+
207
+ const normalizedCommitTitle = normalizeTitle(commitTitle);
208
+ const normalizedPrTitle = normalizeTitle(prTitle);
209
+ const normalizedCommitBody = normalizeBody(commitBody);
210
+ const normalizedPrBody = normalizeText(prBody).trim();
211
+
212
+ if (!normalizedCommitTitle || !normalizedPrTitle || !normalizedPrBody) return null;
213
+
214
+ return {
215
+ commitTitle: normalizedCommitTitle,
216
+ commitBody: normalizedCommitBody,
217
+ prTitle: normalizedPrTitle,
218
+ prBody: normalizedPrBody
219
+ };
220
+ } catch {
221
+ return null;
222
+ }
223
+ }
224
+
225
+ /**
226
+ * 构建兜底交付摘要。
227
+ */
228
+ export function buildFallbackSummary(input: SummaryFallbackInput): DeliverySummary {
229
+ const taskLine = compactLine(input.task);
230
+ const shortTask = taskLine.length > 50 ? `${taskLine.slice(0, 50)}...` : taskLine;
231
+ const baseTitle = shortTask || '更新迭代产出';
232
+ const title = `chore: ${baseTitle}`;
233
+ const summaryLines = [`- ${baseTitle}`];
234
+ const testLines = formatTestResultLines(input.testResults);
235
+ const prBody = buildPrBody(summaryLines, testLines);
236
+ return {
237
+ commitTitle: title,
238
+ commitBody: summaryLines.join('\n'),
239
+ prTitle: title,
240
+ prBody
241
+ };
242
+ }
243
+
244
+ /**
245
+ * 确保 PR 文案包含必要章节。
246
+ */
247
+ export function ensurePrBodySections(prBody: string, fallback: PrBodyFallbackInput): string {
248
+ const normalized = normalizeText(prBody).trim();
249
+ const hasAll = REQUIRED_SECTIONS.every(section => normalized.includes(section));
250
+ if (hasAll) return normalized;
251
+
252
+ const summaryLines = buildSummaryLinesFromCommit(fallback.commitTitle, fallback.commitBody);
253
+ const testLines = formatTestResultLines(fallback.testResults);
254
+ return buildPrBody(summaryLines, testLines);
255
+ }
package/src/types.ts ADDED
@@ -0,0 +1,176 @@
1
+ import type { Logger } from './logger';
2
+
3
+ /**
4
+ * AI CLI 运行配置。
5
+ */
6
+ export interface AiCliConfig {
7
+ readonly command: string;
8
+ readonly args: string[];
9
+ readonly promptArg?: string;
10
+ }
11
+
12
+ /**
13
+ * Token 使用统计。
14
+ */
15
+ export interface TokenUsage {
16
+ readonly inputTokens?: number;
17
+ readonly outputTokens?: number;
18
+ readonly totalTokens: number;
19
+ }
20
+
21
+ /**
22
+ * AI 调用结果。
23
+ */
24
+ export interface AiResult {
25
+ readonly output: string;
26
+ readonly usage: TokenUsage | null;
27
+ }
28
+
29
+ /**
30
+ * 提交信息结构。
31
+ */
32
+ export interface CommitMessage {
33
+ readonly title: string;
34
+ readonly body?: string;
35
+ }
36
+
37
+ /**
38
+ * 交付内容摘要(提交 + PR)。
39
+ */
40
+ export interface DeliverySummary {
41
+ readonly commitTitle: string;
42
+ readonly commitBody?: string;
43
+ readonly prTitle: string;
44
+ readonly prBody: string;
45
+ }
46
+
47
+ /**
48
+ * worktree 相关配置。
49
+ */
50
+ export interface WorktreeConfig {
51
+ readonly useWorktree: boolean;
52
+ readonly branchName?: string;
53
+ readonly worktreePath?: string;
54
+ readonly baseBranch: string;
55
+ }
56
+
57
+ /**
58
+ * worktree 创建结果。
59
+ */
60
+ export interface WorktreeResult {
61
+ readonly path: string;
62
+ readonly created: boolean;
63
+ }
64
+
65
+ /**
66
+ * 测试命令配置。
67
+ */
68
+ export interface TestConfig {
69
+ readonly unitCommand?: string;
70
+ readonly e2eCommand?: string;
71
+ }
72
+
73
+ /**
74
+ * 测试执行结果。
75
+ */
76
+ export interface TestRunResult {
77
+ readonly kind: 'unit' | 'e2e';
78
+ readonly command: string;
79
+ readonly success: boolean;
80
+ readonly exitCode: number;
81
+ readonly stdout: string;
82
+ readonly stderr: string;
83
+ }
84
+
85
+ /**
86
+ * PR 配置。
87
+ */
88
+ export interface PrConfig {
89
+ readonly enable: boolean;
90
+ readonly title?: string;
91
+ readonly bodyPath?: string;
92
+ readonly draft?: boolean;
93
+ readonly reviewers?: string[];
94
+ }
95
+
96
+ /**
97
+ * webhook 配置。
98
+ */
99
+ export interface WebhookConfig {
100
+ readonly urls: string[];
101
+ readonly timeoutMs?: number;
102
+ }
103
+
104
+ /**
105
+ * 工作流文件路径。
106
+ */
107
+ export interface WorkflowFiles {
108
+ readonly workflowDoc: string;
109
+ readonly notesFile: string;
110
+ readonly planFile: string;
111
+ }
112
+
113
+ /**
114
+ * 主循环配置。
115
+ */
116
+ export interface LoopConfig {
117
+ readonly task: string;
118
+ readonly iterations: number;
119
+ readonly stopSignal: string;
120
+ readonly ai: AiCliConfig;
121
+ readonly workflowFiles: WorkflowFiles;
122
+ readonly git: WorktreeConfig;
123
+ readonly tests: TestConfig;
124
+ readonly pr: PrConfig;
125
+ readonly webhooks?: WebhookConfig;
126
+ readonly cwd: string;
127
+ readonly logFile?: string;
128
+ readonly verbose: boolean;
129
+ readonly runTests: boolean;
130
+ readonly runE2e: boolean;
131
+ readonly autoCommit: boolean;
132
+ readonly autoPush: boolean;
133
+ readonly skipInstall: boolean;
134
+ }
135
+
136
+ /**
137
+ * 命令执行配置。
138
+ */
139
+ export interface CommandOptions {
140
+ readonly cwd?: string;
141
+ readonly env?: Record<string, string>;
142
+ readonly input?: string;
143
+ readonly logger?: Logger;
144
+ readonly verboseLabel?: string;
145
+ readonly verboseCommand?: string;
146
+ readonly stream?: StreamOptions;
147
+ }
148
+
149
+ /**
150
+ * 命令执行结果。
151
+ */
152
+ export interface CommandResult {
153
+ readonly stdout: string;
154
+ readonly stderr: string;
155
+ readonly exitCode: number;
156
+ }
157
+
158
+ /**
159
+ * 命令执行输出流配置。
160
+ */
161
+ export interface StreamOptions {
162
+ readonly enabled: boolean;
163
+ readonly stdoutPrefix?: string;
164
+ readonly stderrPrefix?: string;
165
+ }
166
+
167
+ /**
168
+ * 迭代记录。
169
+ */
170
+ export interface IterationRecord {
171
+ readonly iteration: number;
172
+ readonly prompt: string;
173
+ readonly aiOutput: string;
174
+ readonly timestamp: string;
175
+ readonly testResults?: TestRunResult[];
176
+ }
package/src/utils.ts ADDED
@@ -0,0 +1,179 @@
1
+ import path from 'node:path';
2
+ import fs from 'fs-extra';
3
+ import { CommandOptions, CommandResult } from './types';
4
+
5
+ type ExecaModule = typeof import('execa');
6
+ type ExecaError = import('execa').ExecaError;
7
+
8
+ const importExeca = async (): Promise<ExecaModule> => {
9
+ // 通过运行时动态 import 兼容 CommonJS 下加载 ESM 包
10
+ const importer = new Function('specifier', 'return import(specifier)') as (specifier: string) => Promise<ExecaModule>;
11
+ return importer('execa');
12
+ };
13
+
14
+ /**
15
+ * 执行外部命令,支持日志与流式输出。
16
+ */
17
+ export async function runCommand(command: string, args: string[], options: CommandOptions = {}): Promise<CommandResult> {
18
+ const label = options.verboseLabel ?? 'cmd';
19
+ const displayCmd = options.verboseCommand ?? [command, ...args].join(' ');
20
+ const cwd = options.cwd ?? process.cwd();
21
+ options.logger?.debug(`[${label}] ${displayCmd} (cwd: ${cwd})`);
22
+
23
+ const logger = options.logger;
24
+ const streamEnabled = Boolean(options.stream?.enabled && logger);
25
+ const stdoutPrefix = options.stream?.stdoutPrefix ?? `[${label}] `;
26
+ const stderrPrefix = options.stream?.stderrPrefix ?? `[${label} stderr] `;
27
+
28
+ const createLineStreamer = (prefix: string) => {
29
+ let buffer = '';
30
+ const emit = (line: string): void => {
31
+ logger?.info(`${prefix}${line}`);
32
+ };
33
+ const push = (chunk: string | Buffer): void => {
34
+ const text = typeof chunk === 'string' ? chunk : chunk.toString('utf8');
35
+ buffer += text.replace(/\r/g, '\n');
36
+ const parts = buffer.split('\n');
37
+ buffer = parts.pop() ?? '';
38
+ parts.forEach(emit);
39
+ };
40
+ const flush = (): void => {
41
+ if (buffer.length === 0) return;
42
+ emit(buffer);
43
+ buffer = '';
44
+ };
45
+ return { push, flush };
46
+ };
47
+
48
+ const attachStream = (stream: NodeJS.ReadableStream | null | undefined, streamer: ReturnType<typeof createLineStreamer>): void => {
49
+ if (!stream) return;
50
+ if (typeof stream.setEncoding === 'function') {
51
+ stream.setEncoding('utf8');
52
+ }
53
+ stream.on('data', streamer.push);
54
+ stream.on('end', streamer.flush);
55
+ };
56
+
57
+ const stdoutStreamer = streamEnabled ? createLineStreamer(stdoutPrefix) : null;
58
+ const stderrStreamer = streamEnabled ? createLineStreamer(stderrPrefix) : null;
59
+
60
+ try {
61
+ const { execa } = await importExeca();
62
+ const subprocess = execa(command, args, {
63
+ cwd: options.cwd,
64
+ env: options.env,
65
+ input: options.input,
66
+ all: false
67
+ });
68
+ if (stdoutStreamer) {
69
+ attachStream(subprocess.stdout, stdoutStreamer);
70
+ }
71
+ if (stderrStreamer) {
72
+ attachStream(subprocess.stderr, stderrStreamer);
73
+ }
74
+ const result = await subprocess;
75
+ stdoutStreamer?.flush();
76
+ stderrStreamer?.flush();
77
+ const commandResult: CommandResult = {
78
+ stdout: String(result.stdout ?? ''),
79
+ stderr: String(result.stderr ?? ''),
80
+ exitCode: result.exitCode ?? 0
81
+ };
82
+ if (logger) {
83
+ const stdout = commandResult.stdout.trim();
84
+ const stderr = commandResult.stderr.trim();
85
+ if (stdout.length > 0) {
86
+ logger.debug(`[${label}] stdout: ${stdout}`);
87
+ }
88
+ if (stderr.length > 0) {
89
+ logger.debug(`[${label}] stderr: ${stderr}`);
90
+ }
91
+ logger.debug(`[${label}] exit ${commandResult.exitCode}`);
92
+ }
93
+ return commandResult;
94
+ } catch (error) {
95
+ const execaError = error as ExecaError;
96
+ stdoutStreamer?.flush();
97
+ stderrStreamer?.flush();
98
+ const commandResult: CommandResult = {
99
+ stdout: String(execaError.stdout ?? ''),
100
+ stderr: String(execaError.stderr ?? String(error)),
101
+ exitCode: execaError.exitCode ?? 1
102
+ };
103
+ if (logger) {
104
+ const stdout = commandResult.stdout.trim();
105
+ const stderr = commandResult.stderr.trim();
106
+ if (stdout.length > 0) {
107
+ logger.debug(`[${label}] stdout: ${stdout}`);
108
+ }
109
+ if (stderr.length > 0) {
110
+ logger.debug(`[${label}] stderr: ${stderr}`);
111
+ }
112
+ logger.debug(`[${label}] exit ${commandResult.exitCode}`);
113
+ }
114
+ return commandResult;
115
+ }
116
+ }
117
+
118
+ /**
119
+ * 返回 ISO 格式时间戳。
120
+ */
121
+ export function isoNow(): string {
122
+ return new Date().toISOString();
123
+ }
124
+
125
+ /**
126
+ * 基于 cwd 解析相对路径。
127
+ */
128
+ export function resolvePath(cwd: string, target: string): string {
129
+ return path.isAbsolute(target) ? target : path.join(cwd, target);
130
+ }
131
+
132
+ /**
133
+ * 确保目录存在。
134
+ */
135
+ export async function ensureDir(dirPath: string): Promise<void> {
136
+ await fs.mkdirp(dirPath);
137
+ }
138
+
139
+ /**
140
+ * 确保文件存在(必要时创建)。
141
+ */
142
+ export async function ensureFile(filePath: string, initialContent = ''): Promise<void> {
143
+ await ensureDir(path.dirname(filePath));
144
+ const exists = await fs.pathExists(filePath);
145
+ if (!exists) {
146
+ await fs.writeFile(filePath, initialContent, 'utf8');
147
+ }
148
+ }
149
+
150
+ /**
151
+ * 向文件末尾追加一段内容(包含换行)。
152
+ */
153
+ export async function appendSection(filePath: string, content: string): Promise<void> {
154
+ await ensureDir(path.dirname(filePath));
155
+ await fs.appendFile(filePath, `\n${content}\n`, 'utf8');
156
+ }
157
+
158
+ /**
159
+ * 安全读取文件内容,若不存在则返回空字符串。
160
+ */
161
+ export async function readFileSafe(filePath: string): Promise<string> {
162
+ const exists = await fs.pathExists(filePath);
163
+ if (!exists) return '';
164
+ return fs.readFile(filePath, 'utf8');
165
+ }
166
+
167
+ /**
168
+ * 格式化 Markdown 标题。
169
+ */
170
+ export function formatHeading(title: string): string {
171
+ return `## ${title}\n`;
172
+ }
173
+
174
+ /**
175
+ * 补齐两位数字字符串。
176
+ */
177
+ export function pad2(value: number): string {
178
+ return String(value).padStart(2, '0');
179
+ }