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.
- package/.github/ISSUE_TEMPLATE/bug_report.yml +107 -0
- package/.github/ISSUE_TEMPLATE/config.yml +15 -0
- package/.github/ISSUE_TEMPLATE/feature_request.yml +56 -0
- package/.github/workflows/ci-pr.yml +50 -0
- package/.github/workflows/publish.yml +121 -0
- package/.github/workflows/sync-master-to-dev.yml +100 -0
- package/AGENTS.md +20 -0
- package/CHANGELOG.md +12 -0
- package/LICENSE +21 -0
- package/README.md +90 -0
- package/dist/cli.d.ts +6 -0
- package/dist/cli.js +2678 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +80 -0
- package/dist/index.js +2682 -0
- package/dist/index.js.map +1 -0
- package/docs/ai-workflow.md +58 -0
- package/package.json +44 -0
- package/src/ai.ts +173 -0
- package/src/cli.ts +189 -0
- package/src/config.ts +134 -0
- package/src/deps.ts +210 -0
- package/src/gh.ts +228 -0
- package/src/git.ts +285 -0
- package/src/global-config.ts +296 -0
- package/src/index.ts +3 -0
- package/src/logger.ts +122 -0
- package/src/logs-viewer.ts +420 -0
- package/src/logs.ts +132 -0
- package/src/loop.ts +422 -0
- package/src/monitor.ts +291 -0
- package/src/runtime-tracker.ts +65 -0
- package/src/summary.ts +255 -0
- package/src/types.ts +176 -0
- package/src/utils.ts +179 -0
- package/src/webhook.ts +107 -0
- package/tests/deps.test.ts +72 -0
- package/tests/e2e/cli.e2e.test.ts +77 -0
- package/tests/e2e/gh-pr-create.e2e.test.ts +55 -0
- package/tests/e2e/gh-run-list.e2e.test.ts +47 -0
- package/tests/gh-pr-create.test.ts +55 -0
- package/tests/gh-run-list.test.ts +35 -0
- package/tests/global-config.test.ts +52 -0
- package/tests/logger-file.test.ts +56 -0
- package/tests/logger.test.ts +72 -0
- package/tests/logs-viewer.test.ts +57 -0
- package/tests/logs.test.ts +33 -0
- package/tests/prompt.test.ts +20 -0
- package/tests/run-command-stream.test.ts +60 -0
- package/tests/summary.test.ts +58 -0
- package/tests/token-usage.test.ts +33 -0
- package/tests/utils.test.ts +8 -0
- package/tests/webhook.test.ts +89 -0
- package/tsconfig.json +18 -0
- package/tsup.config.ts +18 -0
package/src/ai.ts
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { AiCliConfig, AiResult, IterationRecord, TokenUsage } from './types';
|
|
2
|
+
import { runCommand } from './utils';
|
|
3
|
+
import { Logger } from './logger';
|
|
4
|
+
|
|
5
|
+
interface PromptInput {
|
|
6
|
+
readonly task: string;
|
|
7
|
+
readonly workflowGuide: string;
|
|
8
|
+
readonly plan: string;
|
|
9
|
+
readonly notes: string;
|
|
10
|
+
readonly iteration: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 构建 AI 提示文本。
|
|
15
|
+
*/
|
|
16
|
+
export function buildPrompt(input: PromptInput): string {
|
|
17
|
+
const sections = [
|
|
18
|
+
'# 背景任务',
|
|
19
|
+
input.task,
|
|
20
|
+
'# 工作流程基线(供 AI 自主执行)',
|
|
21
|
+
input.workflowGuide,
|
|
22
|
+
'# 当前持久化计划',
|
|
23
|
+
input.plan || '(暂无计划,首轮请生成可执行计划并写入 plan 文件)',
|
|
24
|
+
'# 历史迭代与记忆',
|
|
25
|
+
input.notes || '(首次执行,暂无历史)',
|
|
26
|
+
'# 本轮执行要求',
|
|
27
|
+
[
|
|
28
|
+
'1. 自我检查并补全需求;明确交付物与验收标准。',
|
|
29
|
+
'2. 更新/细化计划,必要时在 plan 文件中重写任务树与优先级。',
|
|
30
|
+
'3. 设计开发步骤并直接生成代码(无需再次请求确认)。',
|
|
31
|
+
'4. 进行代码自审,给出风险与改进清单。',
|
|
32
|
+
'5. 生成单元测试与 e2e 测试代码并给出运行命令;如果环境允许可直接运行命令。',
|
|
33
|
+
'6. 维护持久化记忆文件:摘要本轮关键结论、遗留问题、下一步建议。',
|
|
34
|
+
'7. 准备提交 PR 所需的标题与描述(含变更摘要、测试结果、风险)。',
|
|
35
|
+
'8. 当所有目标完成时,在输出中加入标记 <<DONE>> 以便外层停止循环。'
|
|
36
|
+
].join('\n')
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
return sections.join('\n\n');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function pickNumber(pattern: RegExp, text: string): number | undefined {
|
|
43
|
+
const match = pattern.exec(text);
|
|
44
|
+
if (!match || match.length < 2) return undefined;
|
|
45
|
+
const value = Number.parseInt(match[match.length - 1], 10);
|
|
46
|
+
return Number.isNaN(value) ? undefined : value;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 从日志文本中解析 token 使用量。
|
|
51
|
+
*/
|
|
52
|
+
export function parseTokenUsage(logs: string): TokenUsage | null {
|
|
53
|
+
const total = pickNumber(/total[_\s]tokens:\s*(\d+)/i, logs);
|
|
54
|
+
const input = pickNumber(/(input|prompt)[_\s]tokens:\s*(\d+)/i, logs);
|
|
55
|
+
const output = pickNumber(/(output|completion)[_\s]tokens:\s*(\d+)/i, logs);
|
|
56
|
+
const consumed = pickNumber(/tokens?\s+used:\s*(\d+)/i, logs) ?? pickNumber(/consumed\s+(\d+)\s+tokens?/i, logs);
|
|
57
|
+
|
|
58
|
+
const totalTokens = total ?? (input !== undefined || output !== undefined ? (input ?? 0) + (output ?? 0) : consumed);
|
|
59
|
+
if (totalTokens === undefined) return null;
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
inputTokens: input,
|
|
63
|
+
outputTokens: output,
|
|
64
|
+
totalTokens
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function addOptional(a?: number, b?: number): number | undefined {
|
|
69
|
+
if (typeof a !== 'number' && typeof b !== 'number') return undefined;
|
|
70
|
+
return (a ?? 0) + (b ?? 0);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 合并多轮 token 统计。
|
|
75
|
+
*/
|
|
76
|
+
export function mergeTokenUsage(previous: TokenUsage | null, current?: TokenUsage | null): TokenUsage | null {
|
|
77
|
+
if (!current) return previous;
|
|
78
|
+
if (!previous) return { ...current };
|
|
79
|
+
return {
|
|
80
|
+
inputTokens: addOptional(previous.inputTokens, current.inputTokens),
|
|
81
|
+
outputTokens: addOptional(previous.outputTokens, current.outputTokens),
|
|
82
|
+
totalTokens: previous.totalTokens + current.totalTokens
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 调用 AI CLI 并返回输出。
|
|
88
|
+
*/
|
|
89
|
+
export async function runAi(prompt: string, ai: AiCliConfig, logger: Logger, cwd: string): Promise<AiResult> {
|
|
90
|
+
const args = [...ai.args];
|
|
91
|
+
const verboseCommand = ai.promptArg
|
|
92
|
+
? [ai.command, ...ai.args, ai.promptArg, '<prompt>'].join(' ')
|
|
93
|
+
: [ai.command, ...ai.args, '<stdin>'].join(' ');
|
|
94
|
+
const streamPrefix = `[${ai.command}] `;
|
|
95
|
+
const streamErrorPrefix = `[${ai.command} stderr] `;
|
|
96
|
+
|
|
97
|
+
let result;
|
|
98
|
+
if (ai.promptArg) {
|
|
99
|
+
args.push(ai.promptArg, prompt);
|
|
100
|
+
result = await runCommand(ai.command, args, {
|
|
101
|
+
cwd,
|
|
102
|
+
logger,
|
|
103
|
+
verboseLabel: 'ai',
|
|
104
|
+
verboseCommand,
|
|
105
|
+
stream: {
|
|
106
|
+
enabled: true,
|
|
107
|
+
stdoutPrefix: streamPrefix,
|
|
108
|
+
stderrPrefix: streamErrorPrefix
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
} else {
|
|
112
|
+
result = await runCommand(ai.command, args, {
|
|
113
|
+
cwd,
|
|
114
|
+
input: prompt,
|
|
115
|
+
logger,
|
|
116
|
+
verboseLabel: 'ai',
|
|
117
|
+
verboseCommand,
|
|
118
|
+
stream: {
|
|
119
|
+
enabled: true,
|
|
120
|
+
stdoutPrefix: streamPrefix,
|
|
121
|
+
stderrPrefix: streamErrorPrefix
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (result.exitCode !== 0) {
|
|
127
|
+
throw new Error(`AI CLI 执行失败: ${result.stderr || result.stdout}`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
logger.success('AI 输出完成');
|
|
131
|
+
const usage = parseTokenUsage([result.stdout, result.stderr].filter(Boolean).join('\n'));
|
|
132
|
+
return {
|
|
133
|
+
output: result.stdout.trim(),
|
|
134
|
+
usage
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* 生成 notes 迭代记录文本。
|
|
140
|
+
*/
|
|
141
|
+
export function formatIterationRecord(record: IterationRecord): string {
|
|
142
|
+
const lines = [
|
|
143
|
+
`### 迭代 ${record.iteration} | ${record.timestamp}`,
|
|
144
|
+
'',
|
|
145
|
+
'#### 提示上下文',
|
|
146
|
+
'```',
|
|
147
|
+
record.prompt,
|
|
148
|
+
'```',
|
|
149
|
+
'',
|
|
150
|
+
'#### AI 输出',
|
|
151
|
+
'```',
|
|
152
|
+
record.aiOutput,
|
|
153
|
+
'```',
|
|
154
|
+
''
|
|
155
|
+
];
|
|
156
|
+
|
|
157
|
+
if (record.testResults && record.testResults.length > 0) {
|
|
158
|
+
lines.push('#### 测试结果');
|
|
159
|
+
record.testResults.forEach(result => {
|
|
160
|
+
const label = result.kind === 'unit' ? '单元测试' : 'e2e 测试';
|
|
161
|
+
const status = result.success ? '✅ 通过' : '❌ 失败';
|
|
162
|
+
lines.push(`${status} | ${label} | 命令: ${result.command} | 退出码: ${result.exitCode}`);
|
|
163
|
+
if (!result.success) {
|
|
164
|
+
lines.push('```');
|
|
165
|
+
lines.push(result.stderr || result.stdout || '(无输出)');
|
|
166
|
+
lines.push('```');
|
|
167
|
+
lines.push('');
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return lines.join('\n');
|
|
173
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import { buildLoopConfig, CliOptions, defaultNotesPath, defaultPlanPath, defaultWorkflowDoc } from './config';
|
|
4
|
+
import { applyShortcutArgv, loadGlobalConfig } from './global-config';
|
|
5
|
+
import { generateBranchName, getCurrentBranch } from './git';
|
|
6
|
+
import { buildAutoLogFilePath } from './logs';
|
|
7
|
+
import { runLogsViewer } from './logs-viewer';
|
|
8
|
+
import { runLoop } from './loop';
|
|
9
|
+
import { defaultLogger } from './logger';
|
|
10
|
+
import { runMonitor } from './monitor';
|
|
11
|
+
import { resolvePath } from './utils';
|
|
12
|
+
|
|
13
|
+
function parseInteger(value: string, defaultValue: number): number {
|
|
14
|
+
const parsed = Number.parseInt(value, 10);
|
|
15
|
+
if (Number.isNaN(parsed)) return defaultValue;
|
|
16
|
+
return parsed;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function collect(value: string, previous: string[]): string[] {
|
|
20
|
+
return [...previous, value];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function normalizeOptional(value: unknown): string | undefined {
|
|
24
|
+
if (typeof value !== 'string') return undefined;
|
|
25
|
+
const trimmed = value.trim();
|
|
26
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function hasOption(argv: string[], option: string): boolean {
|
|
30
|
+
return argv.some(arg => arg === option || arg.startsWith(`${option}=`));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function buildBackgroundArgs(argv: string[], logFile: string, branchName?: string, injectBranch = false): string[] {
|
|
34
|
+
const rawArgs = argv.slice(1);
|
|
35
|
+
const filtered = rawArgs.filter(arg => !(arg === '--background' || arg.startsWith('--background=')));
|
|
36
|
+
if (!hasOption(filtered, '--log-file')) {
|
|
37
|
+
filtered.push('--log-file', logFile);
|
|
38
|
+
}
|
|
39
|
+
if (injectBranch && branchName && !hasOption(filtered, '--branch')) {
|
|
40
|
+
filtered.push('--branch', branchName);
|
|
41
|
+
}
|
|
42
|
+
return filtered;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* CLI 入口。
|
|
47
|
+
*/
|
|
48
|
+
export async function runCli(argv: string[]): Promise<void> {
|
|
49
|
+
const globalConfig = await loadGlobalConfig(defaultLogger);
|
|
50
|
+
const effectiveArgv = applyShortcutArgv(argv, globalConfig);
|
|
51
|
+
const program = new Command();
|
|
52
|
+
|
|
53
|
+
program
|
|
54
|
+
.name('wheel-ai')
|
|
55
|
+
.description('基于 AI CLI 的持续迭代开发工具')
|
|
56
|
+
.version('1.0.0');
|
|
57
|
+
|
|
58
|
+
program
|
|
59
|
+
.command('run')
|
|
60
|
+
.requiredOption('-t, --task <task>', '需要完成的任务描述(会进入 AI 提示)')
|
|
61
|
+
.option('-i, --iterations <number>', '最大迭代次数', value => parseInteger(value, 5), 5)
|
|
62
|
+
.option('--ai-cli <command>', 'AI CLI 命令', 'claude')
|
|
63
|
+
.option('--ai-args <args...>', 'AI CLI 参数', [])
|
|
64
|
+
.option('--ai-prompt-arg <flag>', '用于传入 prompt 的参数(为空则使用 stdin)')
|
|
65
|
+
.option('--notes-file <path>', '持久化记忆文件', defaultNotesPath())
|
|
66
|
+
.option('--plan-file <path>', '计划文件', defaultPlanPath())
|
|
67
|
+
.option('--workflow-doc <path>', 'AI 工作流程说明文件', defaultWorkflowDoc())
|
|
68
|
+
.option('--worktree', '在独立 worktree 上执行', false)
|
|
69
|
+
.option('--branch <name>', 'worktree 分支名(默认自动生成或当前分支)')
|
|
70
|
+
.option('--worktree-path <path>', 'worktree 路径,默认 ../worktrees/<branch>')
|
|
71
|
+
.option('--base-branch <name>', '创建分支的基线分支', 'main')
|
|
72
|
+
.option('--skip-install', '跳过开始任务前的依赖检查', false)
|
|
73
|
+
.option('--run-tests', '运行单元测试命令', false)
|
|
74
|
+
.option('--run-e2e', '运行 e2e 测试命令', false)
|
|
75
|
+
.option('--unit-command <cmd>', '单元测试命令', 'yarn test')
|
|
76
|
+
.option('--e2e-command <cmd>', 'e2e 测试命令', 'yarn e2e')
|
|
77
|
+
.option('--auto-commit', '自动 git commit', false)
|
|
78
|
+
.option('--auto-push', '自动 git push', false)
|
|
79
|
+
.option('--pr', '使用 gh 创建 PR', false)
|
|
80
|
+
.option('--pr-title <title>', 'PR 标题')
|
|
81
|
+
.option('--pr-body <path>', 'PR 描述文件路径(可留空自动生成)')
|
|
82
|
+
.option('--draft', '以草稿形式创建 PR', false)
|
|
83
|
+
.option('--reviewer <user...>', 'PR reviewers', collect, [])
|
|
84
|
+
.option('--webhook <url>', 'webhook 通知 URL(可重复)', collect, [])
|
|
85
|
+
.option('--webhook-timeout <ms>', 'webhook 请求超时(毫秒)', value => parseInteger(value, 8000))
|
|
86
|
+
.option('--stop-signal <token>', 'AI 输出中的停止标记', '<<DONE>>')
|
|
87
|
+
.option('--log-file <path>', '日志输出文件路径')
|
|
88
|
+
.option('--background', '切入后台运行', false)
|
|
89
|
+
.option('-v, --verbose', '输出调试日志', false)
|
|
90
|
+
.action(async (options) => {
|
|
91
|
+
const useWorktree = Boolean(options.worktree);
|
|
92
|
+
const branchInput = normalizeOptional(options.branch);
|
|
93
|
+
const logFileInput = normalizeOptional(options.logFile);
|
|
94
|
+
const background = Boolean(options.background);
|
|
95
|
+
|
|
96
|
+
let branchName = branchInput;
|
|
97
|
+
if (useWorktree && !branchName) {
|
|
98
|
+
branchName = generateBranchName();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let logFile = logFileInput;
|
|
102
|
+
if (background && !logFile) {
|
|
103
|
+
let branchForLog = branchName;
|
|
104
|
+
if (!branchForLog) {
|
|
105
|
+
try {
|
|
106
|
+
const current = await getCurrentBranch(process.cwd(), defaultLogger);
|
|
107
|
+
branchForLog = current || 'detached';
|
|
108
|
+
} catch {
|
|
109
|
+
branchForLog = 'unknown';
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
logFile = buildAutoLogFilePath(branchForLog);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (background) {
|
|
116
|
+
if (!logFile) {
|
|
117
|
+
throw new Error('后台运行需要指定日志文件');
|
|
118
|
+
}
|
|
119
|
+
const args = buildBackgroundArgs(effectiveArgv, logFile, branchName, useWorktree && !branchInput);
|
|
120
|
+
const child = spawn(process.execPath, [...process.execArgv, ...args], {
|
|
121
|
+
detached: true,
|
|
122
|
+
stdio: 'ignore'
|
|
123
|
+
});
|
|
124
|
+
child.unref();
|
|
125
|
+
const displayLogFile = resolvePath(process.cwd(), logFile);
|
|
126
|
+
console.log(`已切入后台运行,日志输出至 ${displayLogFile}`);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const cliOptions: CliOptions = {
|
|
131
|
+
task: options.task as string,
|
|
132
|
+
iterations: options.iterations as number,
|
|
133
|
+
aiCli: options.aiCli as string,
|
|
134
|
+
aiArgs: (options.aiArgs as string[]) ?? [],
|
|
135
|
+
aiPromptArg: options.aiPromptArg as string | undefined,
|
|
136
|
+
notesFile: options.notesFile as string,
|
|
137
|
+
planFile: options.planFile as string,
|
|
138
|
+
workflowDoc: options.workflowDoc as string,
|
|
139
|
+
useWorktree,
|
|
140
|
+
branch: branchName,
|
|
141
|
+
worktreePath: options.worktreePath as string | undefined,
|
|
142
|
+
baseBranch: options.baseBranch as string,
|
|
143
|
+
runTests: Boolean(options.runTests),
|
|
144
|
+
runE2e: Boolean(options.runE2e),
|
|
145
|
+
unitCommand: options.unitCommand as string | undefined,
|
|
146
|
+
e2eCommand: options.e2eCommand as string | undefined,
|
|
147
|
+
autoCommit: Boolean(options.autoCommit),
|
|
148
|
+
autoPush: Boolean(options.autoPush),
|
|
149
|
+
pr: Boolean(options.pr),
|
|
150
|
+
prTitle: options.prTitle as string | undefined,
|
|
151
|
+
prBody: options.prBody as string | undefined,
|
|
152
|
+
draft: Boolean(options.draft),
|
|
153
|
+
reviewers: (options.reviewer as string[]) ?? [],
|
|
154
|
+
webhookUrls: (options.webhook as string[]) ?? [],
|
|
155
|
+
webhookTimeout: options.webhookTimeout as number | undefined,
|
|
156
|
+
stopSignal: options.stopSignal as string,
|
|
157
|
+
logFile,
|
|
158
|
+
verbose: Boolean(options.verbose),
|
|
159
|
+
skipInstall: Boolean(options.skipInstall)
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const config = buildLoopConfig(cliOptions, process.cwd());
|
|
163
|
+
await runLoop(config);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
program
|
|
167
|
+
.command('monitor')
|
|
168
|
+
.description('查看后台运行日志')
|
|
169
|
+
.action(async () => {
|
|
170
|
+
await runMonitor();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
program
|
|
174
|
+
.command('logs')
|
|
175
|
+
.description('查看历史日志')
|
|
176
|
+
.action(async () => {
|
|
177
|
+
await runLogsViewer();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
await program.parseAsync(effectiveArgv);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (require.main === module) {
|
|
184
|
+
runCli(process.argv).catch(error => {
|
|
185
|
+
const message = error instanceof Error ? error.stack ?? error.message : String(error);
|
|
186
|
+
defaultLogger.error(message);
|
|
187
|
+
process.exitCode = 1;
|
|
188
|
+
});
|
|
189
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { AiCliConfig, LoopConfig, PrConfig, TestConfig, WebhookConfig, WorktreeConfig, WorkflowFiles } from './types';
|
|
3
|
+
import { resolvePath } from './utils';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* CLI 参数解析后的配置。
|
|
7
|
+
*/
|
|
8
|
+
export interface CliOptions {
|
|
9
|
+
readonly task: string;
|
|
10
|
+
readonly iterations: number;
|
|
11
|
+
readonly aiCli: string;
|
|
12
|
+
readonly aiArgs: string[];
|
|
13
|
+
readonly aiPromptArg?: string;
|
|
14
|
+
readonly notesFile: string;
|
|
15
|
+
readonly planFile: string;
|
|
16
|
+
readonly workflowDoc: string;
|
|
17
|
+
readonly useWorktree: boolean;
|
|
18
|
+
readonly branch?: string;
|
|
19
|
+
readonly worktreePath?: string;
|
|
20
|
+
readonly baseBranch: string;
|
|
21
|
+
readonly runTests: boolean;
|
|
22
|
+
readonly runE2e: boolean;
|
|
23
|
+
readonly unitCommand?: string;
|
|
24
|
+
readonly e2eCommand?: string;
|
|
25
|
+
readonly autoCommit: boolean;
|
|
26
|
+
readonly autoPush: boolean;
|
|
27
|
+
readonly pr: boolean;
|
|
28
|
+
readonly prTitle?: string;
|
|
29
|
+
readonly prBody?: string;
|
|
30
|
+
readonly draft: boolean;
|
|
31
|
+
readonly reviewers?: string[];
|
|
32
|
+
readonly webhookUrls: string[];
|
|
33
|
+
readonly webhookTimeout?: number;
|
|
34
|
+
readonly stopSignal: string;
|
|
35
|
+
readonly logFile?: string;
|
|
36
|
+
readonly verbose: boolean;
|
|
37
|
+
readonly skipInstall: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function buildAiConfig(options: CliOptions): AiCliConfig {
|
|
41
|
+
return {
|
|
42
|
+
command: options.aiCli,
|
|
43
|
+
args: options.aiArgs,
|
|
44
|
+
promptArg: options.aiPromptArg
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function buildWorktreeConfig(options: CliOptions): WorktreeConfig {
|
|
49
|
+
return {
|
|
50
|
+
useWorktree: options.useWorktree,
|
|
51
|
+
branchName: options.branch,
|
|
52
|
+
worktreePath: options.worktreePath,
|
|
53
|
+
baseBranch: options.baseBranch
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function buildTestConfig(options: CliOptions): TestConfig {
|
|
58
|
+
return {
|
|
59
|
+
unitCommand: options.unitCommand,
|
|
60
|
+
e2eCommand: options.e2eCommand
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function buildPrConfig(options: CliOptions): PrConfig {
|
|
65
|
+
return {
|
|
66
|
+
enable: options.pr,
|
|
67
|
+
title: options.prTitle,
|
|
68
|
+
bodyPath: options.prBody,
|
|
69
|
+
draft: options.draft,
|
|
70
|
+
reviewers: options.reviewers
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function buildWebhookConfig(options: CliOptions): WebhookConfig | undefined {
|
|
75
|
+
if (!options.webhookUrls || options.webhookUrls.length === 0) return undefined;
|
|
76
|
+
return {
|
|
77
|
+
urls: options.webhookUrls,
|
|
78
|
+
timeoutMs: options.webhookTimeout
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function buildWorkflowFiles(options: CliOptions, cwd: string): WorkflowFiles {
|
|
83
|
+
return {
|
|
84
|
+
workflowDoc: resolvePath(cwd, options.workflowDoc),
|
|
85
|
+
notesFile: resolvePath(cwd, options.notesFile),
|
|
86
|
+
planFile: resolvePath(cwd, options.planFile)
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 构建循环执行所需的配置对象。
|
|
92
|
+
*/
|
|
93
|
+
export function buildLoopConfig(options: CliOptions, cwd: string): LoopConfig {
|
|
94
|
+
return {
|
|
95
|
+
task: options.task,
|
|
96
|
+
iterations: options.iterations,
|
|
97
|
+
stopSignal: options.stopSignal,
|
|
98
|
+
ai: buildAiConfig(options),
|
|
99
|
+
workflowFiles: buildWorkflowFiles(options, cwd),
|
|
100
|
+
git: buildWorktreeConfig(options),
|
|
101
|
+
tests: buildTestConfig(options),
|
|
102
|
+
pr: buildPrConfig(options),
|
|
103
|
+
webhooks: buildWebhookConfig(options),
|
|
104
|
+
cwd,
|
|
105
|
+
logFile: options.logFile ? resolvePath(cwd, options.logFile) : undefined,
|
|
106
|
+
verbose: options.verbose,
|
|
107
|
+
runTests: options.runTests,
|
|
108
|
+
runE2e: options.runE2e,
|
|
109
|
+
autoCommit: options.autoCommit,
|
|
110
|
+
autoPush: options.autoPush,
|
|
111
|
+
skipInstall: options.skipInstall
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* 默认 notes 文件路径。
|
|
117
|
+
*/
|
|
118
|
+
export function defaultNotesPath(): string {
|
|
119
|
+
return path.join('memory', 'notes.md');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* 默认 plan 文件路径。
|
|
124
|
+
*/
|
|
125
|
+
export function defaultPlanPath(): string {
|
|
126
|
+
return path.join('memory', 'plan.md');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* 默认工作流说明文件路径。
|
|
131
|
+
*/
|
|
132
|
+
export function defaultWorkflowDoc(): string {
|
|
133
|
+
return path.join('docs', 'ai-workflow.md');
|
|
134
|
+
}
|
package/src/deps.ts
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import fs from 'fs-extra';
|
|
3
|
+
import { Logger } from './logger';
|
|
4
|
+
import { runCommand } from './utils';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 支持的包管理器类型。
|
|
8
|
+
*/
|
|
9
|
+
export type PackageManager = 'yarn' | 'pnpm' | 'npm';
|
|
10
|
+
|
|
11
|
+
type PackageManagerSource = 'packageManager' | 'lockfile' | 'default';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 解析包管理器所需的提示信息。
|
|
15
|
+
*/
|
|
16
|
+
export interface PackageManagerHints {
|
|
17
|
+
readonly packageManagerField?: string;
|
|
18
|
+
readonly hasYarnLock: boolean;
|
|
19
|
+
readonly hasPnpmLock: boolean;
|
|
20
|
+
readonly hasNpmLock: boolean;
|
|
21
|
+
readonly hasNpmShrinkwrap: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 包管理器解析结果。
|
|
26
|
+
*/
|
|
27
|
+
export interface PackageManagerResolution {
|
|
28
|
+
readonly manager: PackageManager;
|
|
29
|
+
readonly source: PackageManagerSource;
|
|
30
|
+
readonly hasLock: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function parsePackageManagerField(value?: string): PackageManager | null {
|
|
34
|
+
if (!value) return null;
|
|
35
|
+
const normalized = value.trim().toLowerCase();
|
|
36
|
+
if (normalized === 'yarn' || normalized.startsWith('yarn@')) return 'yarn';
|
|
37
|
+
if (normalized === 'pnpm' || normalized.startsWith('pnpm@')) return 'pnpm';
|
|
38
|
+
if (normalized === 'npm' || normalized.startsWith('npm@')) return 'npm';
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function hasLockForManager(manager: PackageManager, hints: PackageManagerHints): boolean {
|
|
43
|
+
if (manager === 'yarn') return hints.hasYarnLock;
|
|
44
|
+
if (manager === 'pnpm') return hints.hasPnpmLock;
|
|
45
|
+
return hints.hasNpmLock || hints.hasNpmShrinkwrap;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 根据 packageManager 字段或锁文件推断包管理器。
|
|
50
|
+
*/
|
|
51
|
+
export function resolvePackageManager(hints: PackageManagerHints): PackageManagerResolution {
|
|
52
|
+
const fromField = parsePackageManagerField(hints.packageManagerField);
|
|
53
|
+
if (fromField) {
|
|
54
|
+
return {
|
|
55
|
+
manager: fromField,
|
|
56
|
+
source: 'packageManager',
|
|
57
|
+
hasLock: hasLockForManager(fromField, hints)
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (hints.hasYarnLock) {
|
|
62
|
+
return {
|
|
63
|
+
manager: 'yarn',
|
|
64
|
+
source: 'lockfile',
|
|
65
|
+
hasLock: true
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
if (hints.hasPnpmLock) {
|
|
69
|
+
return {
|
|
70
|
+
manager: 'pnpm',
|
|
71
|
+
source: 'lockfile',
|
|
72
|
+
hasLock: true
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
if (hints.hasNpmLock || hints.hasNpmShrinkwrap) {
|
|
76
|
+
return {
|
|
77
|
+
manager: 'npm',
|
|
78
|
+
source: 'lockfile',
|
|
79
|
+
hasLock: true
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
manager: 'yarn',
|
|
85
|
+
source: 'default',
|
|
86
|
+
hasLock: false
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 生成安装依赖命令。
|
|
92
|
+
*/
|
|
93
|
+
export function buildInstallCommand(resolution: PackageManagerResolution): string {
|
|
94
|
+
switch (resolution.manager) {
|
|
95
|
+
case 'yarn': {
|
|
96
|
+
const args = ['yarn', 'install'];
|
|
97
|
+
if (resolution.hasLock) {
|
|
98
|
+
args.push('--frozen-lockfile');
|
|
99
|
+
} else {
|
|
100
|
+
args.push('--no-lockfile');
|
|
101
|
+
}
|
|
102
|
+
return args.join(' ');
|
|
103
|
+
}
|
|
104
|
+
case 'pnpm': {
|
|
105
|
+
const args = ['pnpm', 'install'];
|
|
106
|
+
if (resolution.hasLock) {
|
|
107
|
+
args.push('--frozen-lockfile');
|
|
108
|
+
}
|
|
109
|
+
return args.join(' ');
|
|
110
|
+
}
|
|
111
|
+
case 'npm': {
|
|
112
|
+
const args = resolution.hasLock ? ['npm', 'ci'] : ['npm', 'install'];
|
|
113
|
+
args.push('--no-audit', '--no-fund');
|
|
114
|
+
return args.join(' ');
|
|
115
|
+
}
|
|
116
|
+
default: {
|
|
117
|
+
return 'yarn install';
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function resolveSourceLabel(source: PackageManagerSource): string {
|
|
123
|
+
switch (source) {
|
|
124
|
+
case 'packageManager':
|
|
125
|
+
return 'packageManager 字段';
|
|
126
|
+
case 'lockfile':
|
|
127
|
+
return '锁文件';
|
|
128
|
+
case 'default':
|
|
129
|
+
return '默认策略';
|
|
130
|
+
default:
|
|
131
|
+
return '未知来源';
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function extractPackageManagerField(value: unknown): string | undefined {
|
|
136
|
+
if (typeof value !== 'object' || value === null) return undefined;
|
|
137
|
+
const candidate = value as Record<string, unknown>;
|
|
138
|
+
const field = candidate.packageManager;
|
|
139
|
+
return typeof field === 'string' ? field : undefined;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function readPackageManagerHints(cwd: string, logger: Logger): Promise<PackageManagerHints | null> {
|
|
143
|
+
const packageJsonPath = path.join(cwd, 'package.json');
|
|
144
|
+
const hasPackageJson = await fs.pathExists(packageJsonPath);
|
|
145
|
+
if (!hasPackageJson) return null;
|
|
146
|
+
|
|
147
|
+
let packageManagerField: string | undefined;
|
|
148
|
+
try {
|
|
149
|
+
const packageJson = await fs.readJson(packageJsonPath);
|
|
150
|
+
packageManagerField = extractPackageManagerField(packageJson);
|
|
151
|
+
} catch (error) {
|
|
152
|
+
logger.warn(`读取 package.json 失败,将改用锁文件判断包管理器: ${String(error)}`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const [hasYarnLock, hasPnpmLock, hasNpmLock, hasNpmShrinkwrap] = await Promise.all([
|
|
156
|
+
fs.pathExists(path.join(cwd, 'yarn.lock')),
|
|
157
|
+
fs.pathExists(path.join(cwd, 'pnpm-lock.yaml')),
|
|
158
|
+
fs.pathExists(path.join(cwd, 'package-lock.json')),
|
|
159
|
+
fs.pathExists(path.join(cwd, 'npm-shrinkwrap.json'))
|
|
160
|
+
]);
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
packageManagerField,
|
|
164
|
+
hasYarnLock,
|
|
165
|
+
hasPnpmLock,
|
|
166
|
+
hasNpmLock,
|
|
167
|
+
hasNpmShrinkwrap
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* 确保依赖已安装(按锁文件或 packageManager 字段选择包管理器)。
|
|
173
|
+
*/
|
|
174
|
+
export async function ensureDependencies(cwd: string, logger: Logger): Promise<void> {
|
|
175
|
+
const hints = await readPackageManagerHints(cwd, logger);
|
|
176
|
+
if (!hints) {
|
|
177
|
+
logger.info('未检测到 package.json,跳过依赖检查');
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const resolution = resolvePackageManager(hints);
|
|
182
|
+
const sourceLabel = resolveSourceLabel(resolution.source);
|
|
183
|
+
logger.info(`依赖检查:使用 ${resolution.manager}(来源:${sourceLabel})`);
|
|
184
|
+
|
|
185
|
+
if (resolution.source === 'default') {
|
|
186
|
+
logger.warn('未检测到 packageManager 配置或锁文件,将按默认策略安装依赖');
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const command = buildInstallCommand(resolution);
|
|
190
|
+
logger.info(`开始安装依赖: ${command}`);
|
|
191
|
+
|
|
192
|
+
const result = await runCommand('bash', ['-lc', command], {
|
|
193
|
+
cwd,
|
|
194
|
+
logger,
|
|
195
|
+
verboseLabel: 'deps',
|
|
196
|
+
verboseCommand: `bash -lc "${command}"`,
|
|
197
|
+
stream: {
|
|
198
|
+
enabled: true,
|
|
199
|
+
stdoutPrefix: '[deps] ',
|
|
200
|
+
stderrPrefix: '[deps err] '
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
if (result.exitCode !== 0) {
|
|
205
|
+
const details = result.stderr || result.stdout || '无输出';
|
|
206
|
+
throw new Error(`依赖安装失败: ${details}`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
logger.success('依赖检查完成');
|
|
210
|
+
}
|