clawt 3.1.1 → 3.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -4
- package/dist/index.js +139 -114
- package/dist/postinstall.js +35 -1
- package/docs/config-file.md +2 -0
- package/docs/config.md +2 -1
- package/docs/init.md +16 -7
- package/docs/project-config.md +132 -0
- package/docs/resume.md +4 -2
- package/docs/spec.md +31 -22
- package/docs/validate.md +4 -3
- package/package.json +1 -1
- package/src/commands/config.ts +14 -28
- package/src/commands/init.ts +23 -12
- package/src/commands/resume.ts +12 -3
- package/src/commands/validate.ts +17 -3
- package/src/constants/config.ts +4 -0
- package/src/constants/index.ts +1 -0
- package/src/constants/messages/init.ts +4 -0
- package/src/constants/project-config.ts +46 -0
- package/src/constants/terminal.ts +1 -0
- package/src/types/config.ts +2 -0
- package/src/types/index.ts +1 -1
- package/src/types/projectConfig.ts +17 -0
- package/src/utils/config-strategy.ts +68 -20
- package/src/utils/index.ts +2 -2
- package/src/utils/project-config.ts +9 -0
- package/tests/unit/commands/config.test.ts +1 -0
- package/tests/unit/commands/init.test.ts +41 -6
- package/tests/unit/commands/resume.test.ts +113 -0
- package/tests/unit/commands/validate.test.ts +63 -0
- package/tests/unit/constants/config.test.ts +1 -0
- package/tests/unit/utils/config-strategy.test.ts +77 -1
- package/tests/unit/utils/project-config.test.ts +32 -0
|
@@ -73,14 +73,16 @@ export function parseConfigValue(
|
|
|
73
73
|
* - string + 有 allowedValues → Select(枚举列表)
|
|
74
74
|
* - string + 无 allowedValues → Input(自由输入)
|
|
75
75
|
*
|
|
76
|
-
* @param {
|
|
77
|
-
* @param {
|
|
78
|
-
* @
|
|
76
|
+
* @param {string} key - 配置项名称
|
|
77
|
+
* @param {unknown} currentValue - 当前值
|
|
78
|
+
* @param {readonly string[]} [allowedValues] - 可选的枚举值列表
|
|
79
|
+
* @returns {Promise<unknown>} 用户输入/选择的新值
|
|
79
80
|
*/
|
|
80
81
|
export async function promptConfigValue(
|
|
81
|
-
key:
|
|
82
|
-
currentValue:
|
|
83
|
-
|
|
82
|
+
key: string,
|
|
83
|
+
currentValue: unknown,
|
|
84
|
+
allowedValues?: readonly string[],
|
|
85
|
+
): Promise<unknown> {
|
|
84
86
|
const expectedType = typeof currentValue;
|
|
85
87
|
|
|
86
88
|
// 布尔类型策略
|
|
@@ -94,9 +96,8 @@ export async function promptConfigValue(
|
|
|
94
96
|
}
|
|
95
97
|
|
|
96
98
|
// 字符串类型:根据 allowedValues 自动选择提示策略
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
return promptEnumValue(key, currentValue as string, definition.allowedValues);
|
|
99
|
+
if (allowedValues) {
|
|
100
|
+
return promptEnumValue(key, currentValue as string, allowedValues);
|
|
100
101
|
}
|
|
101
102
|
|
|
102
103
|
return promptStringValue(key, currentValue as string);
|
|
@@ -104,23 +105,70 @@ export async function promptConfigValue(
|
|
|
104
105
|
|
|
105
106
|
/**
|
|
106
107
|
* 格式化配置值的显示样式
|
|
107
|
-
* @param {
|
|
108
|
+
* @param {unknown} value - 配置值
|
|
108
109
|
* @returns {string} 格式化后的字符串
|
|
109
110
|
*/
|
|
110
|
-
export function formatConfigValue(value:
|
|
111
|
+
export function formatConfigValue(value: unknown): string {
|
|
112
|
+
if (value === undefined || value === null) {
|
|
113
|
+
return chalk.dim('(未设置)');
|
|
114
|
+
}
|
|
111
115
|
if (typeof value === 'boolean') {
|
|
112
116
|
return value ? chalk.green('true') : chalk.yellow('false');
|
|
113
117
|
}
|
|
114
118
|
return chalk.cyan(String(value));
|
|
115
119
|
}
|
|
116
120
|
|
|
121
|
+
/**
|
|
122
|
+
* 通用交互式配置编辑器
|
|
123
|
+
* @param {T} config - 当前配置对象
|
|
124
|
+
* @param {Record<string, { description: string; allowedValues?: readonly string[] }>} definitions - 配置项定义(含 description、allowedValues)
|
|
125
|
+
* @param {object} [options] - 可选配置
|
|
126
|
+
* @param {string} [options.selectPrompt] - 选择配置项的提示语
|
|
127
|
+
* @param {Record<string, string>} [options.disabledKeys] - 不可编辑的键及其禁用提示
|
|
128
|
+
* @returns {Promise<{ key: string; newValue: unknown }>} 修改后的 key 和 newValue
|
|
129
|
+
*/
|
|
130
|
+
export async function interactiveConfigEditor<T extends object>(
|
|
131
|
+
config: T,
|
|
132
|
+
definitions: Record<string, { description: string; allowedValues?: readonly string[] }>,
|
|
133
|
+
options?: { selectPrompt?: string; disabledKeys?: Record<string, string> },
|
|
134
|
+
): Promise<{ key: keyof T; newValue: unknown }> {
|
|
135
|
+
const keys = Object.keys(definitions);
|
|
136
|
+
const disabledKeys = options?.disabledKeys ?? {};
|
|
137
|
+
const configRecord = config as Record<string, unknown>;
|
|
138
|
+
|
|
139
|
+
// 构建选择列表,显示配置项名称、当前值和描述
|
|
140
|
+
const choices = keys.map((k) => {
|
|
141
|
+
const isDisabled = k in disabledKeys;
|
|
142
|
+
const value = configRecord[k];
|
|
143
|
+
const isObject = typeof value === 'object' && value !== null;
|
|
144
|
+
return {
|
|
145
|
+
name: k,
|
|
146
|
+
message: `${k}: ${isObject || isDisabled ? chalk.dim(isObject ? JSON.stringify(value) : String(value ?? '')) : formatConfigValue(value)} ${chalk.dim(`— ${definitions[k].description}`)}`,
|
|
147
|
+
...(isDisabled && { disabled: disabledKeys[k] }),
|
|
148
|
+
};
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// @ts-expect-error enquirer 类型声明未导出 Select 类,但运行时存在
|
|
152
|
+
const selectedKey: string = await new Enquirer.Select({
|
|
153
|
+
message: options?.selectPrompt ?? MESSAGES.CONFIG_SELECT_PROMPT,
|
|
154
|
+
choices,
|
|
155
|
+
}).run();
|
|
156
|
+
|
|
157
|
+
// 根据类型和 allowedValues 自动选择提示策略
|
|
158
|
+
const currentValue = configRecord[selectedKey];
|
|
159
|
+
const definition = definitions[selectedKey];
|
|
160
|
+
const newValue = await promptConfigValue(selectedKey, currentValue, definition.allowedValues);
|
|
161
|
+
|
|
162
|
+
return { key: selectedKey as keyof T, newValue };
|
|
163
|
+
}
|
|
164
|
+
|
|
117
165
|
/**
|
|
118
166
|
* 交互式布尔值选择(内部辅助函数)
|
|
119
|
-
* @param {
|
|
167
|
+
* @param {string} key - 配置项名称
|
|
120
168
|
* @param {boolean} currentValue - 当前值
|
|
121
169
|
* @returns {Promise<boolean>} 用户选择的布尔值
|
|
122
170
|
*/
|
|
123
|
-
async function promptBooleanValue(key:
|
|
171
|
+
async function promptBooleanValue(key: string, currentValue: boolean): Promise<boolean> {
|
|
124
172
|
const choices = [
|
|
125
173
|
{ name: 'true', message: 'true' },
|
|
126
174
|
{ name: 'false', message: 'false' },
|
|
@@ -138,11 +186,11 @@ async function promptBooleanValue(key: keyof ClawtConfig, currentValue: boolean)
|
|
|
138
186
|
|
|
139
187
|
/**
|
|
140
188
|
* 交互式数字输入(内部辅助函数)
|
|
141
|
-
* @param {
|
|
189
|
+
* @param {string} key - 配置项名称
|
|
142
190
|
* @param {number} currentValue - 当前值
|
|
143
191
|
* @returns {Promise<number>} 用户输入的数字值
|
|
144
192
|
*/
|
|
145
|
-
async function promptNumberValue(key:
|
|
193
|
+
async function promptNumberValue(key: string, currentValue: number): Promise<number> {
|
|
146
194
|
// @ts-expect-error enquirer 类型声明未导出 Input 类,但运行时存在
|
|
147
195
|
const input: string = await new Enquirer.Input({
|
|
148
196
|
message: MESSAGES.CONFIG_INPUT_PROMPT(key),
|
|
@@ -158,13 +206,13 @@ async function promptNumberValue(key: keyof ClawtConfig, currentValue: number):
|
|
|
158
206
|
|
|
159
207
|
/**
|
|
160
208
|
* 交互式枚举值选择(内部辅助函数,用于有 allowedValues 的 string 配置项)
|
|
161
|
-
* @param {
|
|
209
|
+
* @param {string} key - 配置项名称
|
|
162
210
|
* @param {string} currentValue - 当前值
|
|
163
211
|
* @param {readonly string[]} allowedValues - 允许的枚举值列表
|
|
164
212
|
* @returns {Promise<string>} 用户选择的枚举值
|
|
165
213
|
*/
|
|
166
214
|
async function promptEnumValue(
|
|
167
|
-
key:
|
|
215
|
+
key: string,
|
|
168
216
|
currentValue: string,
|
|
169
217
|
allowedValues: readonly string[],
|
|
170
218
|
): Promise<string> {
|
|
@@ -183,14 +231,14 @@ async function promptEnumValue(
|
|
|
183
231
|
|
|
184
232
|
/**
|
|
185
233
|
* 交互式字符串自由输入(内部辅助函数,用于无 allowedValues 的 string 配置项)
|
|
186
|
-
* @param {
|
|
234
|
+
* @param {string} key - 配置项名称
|
|
187
235
|
* @param {string} currentValue - 当前值
|
|
188
236
|
* @returns {Promise<string>} 用户输入的字符串值
|
|
189
237
|
*/
|
|
190
|
-
async function promptStringValue(key:
|
|
238
|
+
async function promptStringValue(key: string, currentValue: string): Promise<string> {
|
|
191
239
|
// @ts-expect-error enquirer 类型声明未导出 Input 类,但运行时存在
|
|
192
240
|
return await new Enquirer.Input({
|
|
193
241
|
message: MESSAGES.CONFIG_INPUT_PROMPT(key),
|
|
194
|
-
initial: currentValue,
|
|
242
|
+
initial: currentValue || '',
|
|
195
243
|
}).run();
|
|
196
244
|
}
|
package/src/utils/index.ts
CHANGED
|
@@ -68,9 +68,9 @@ export type { ParsedActivity, StreamEvent, LineBuffer } from './stream-parser.js
|
|
|
68
68
|
export { detectTerminalApp, openCommandInNewTerminalTab } from './terminal.js';
|
|
69
69
|
export { truncateTaskDesc, printDryRunPreview } from './dry-run.js';
|
|
70
70
|
export { applyAliases } from './alias.js';
|
|
71
|
-
export { isValidConfigKey, getValidConfigKeys, parseConfigValue, promptConfigValue, formatConfigValue } from './config-strategy.js';
|
|
71
|
+
export { isValidConfigKey, getValidConfigKeys, parseConfigValue, promptConfigValue, formatConfigValue, interactiveConfigEditor } from './config-strategy.js';
|
|
72
72
|
export { checkForUpdates } from './update-checker.js';
|
|
73
|
-
export { getProjectConfigPath, loadProjectConfig, saveProjectConfig, requireProjectConfig, getMainWorkBranch } from './project-config.js';
|
|
73
|
+
export { getProjectConfigPath, loadProjectConfig, saveProjectConfig, requireProjectConfig, getMainWorkBranch, getValidateRunCommand } from './project-config.js';
|
|
74
74
|
export { getValidateBranchName, createValidateBranch, deleteValidateBranch, rebuildValidateBranch, ensureOnMainWorkBranch, handleDirtyWorkingDir } from './validate-branch.js';
|
|
75
75
|
export { safeStringify } from './json.js';
|
|
76
76
|
export { InteractivePanel } from './interactive-panel.js';
|
|
@@ -75,3 +75,12 @@ export function getMainWorkBranch(): string {
|
|
|
75
75
|
const config = requireProjectConfig();
|
|
76
76
|
return config.clawtMainWorkBranch;
|
|
77
77
|
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 从项目配置中获取 validate 成功后自动执行的命令
|
|
81
|
+
* @returns {string | undefined} 配置的命令字符串,未配置时返回 undefined
|
|
82
|
+
*/
|
|
83
|
+
export function getValidateRunCommand(): string | undefined {
|
|
84
|
+
const config = loadProjectConfig();
|
|
85
|
+
return config?.validateRunCommand || undefined;
|
|
86
|
+
}
|
|
@@ -38,6 +38,7 @@ vi.mock('../../../src/utils/index.js', async (importOriginal) => {
|
|
|
38
38
|
parseConfigValue: original.parseConfigValue,
|
|
39
39
|
promptConfigValue: original.promptConfigValue,
|
|
40
40
|
formatConfigValue: original.formatConfigValue,
|
|
41
|
+
interactiveConfigEditor: original.interactiveConfigEditor,
|
|
41
42
|
};
|
|
42
43
|
});
|
|
43
44
|
|
|
@@ -12,6 +12,12 @@ vi.mock('../../../src/constants/index.js', () => ({
|
|
|
12
12
|
INIT_SHOW: (configJson: string) => `当前项目配置:\n${configJson}`,
|
|
13
13
|
PROJECT_NOT_INITIALIZED: '项目尚未初始化,请先执行 clawt init 设置主工作分支',
|
|
14
14
|
PROJECT_CONFIG_MISSING_BRANCH: '项目配置缺少主工作分支信息,请重新执行 clawt init 设置主工作分支',
|
|
15
|
+
INIT_SELECT_PROMPT: '选择要修改的项目配置项',
|
|
16
|
+
INIT_SET_SUCCESS: (key: string, value: string) => `✓ 项目配置 ${key} 已设置为 ${value}`,
|
|
17
|
+
},
|
|
18
|
+
PROJECT_CONFIG_DEFINITIONS: {
|
|
19
|
+
clawtMainWorkBranch: { defaultValue: '', description: '主 worktree 的工作分支名' },
|
|
20
|
+
validateRunCommand: { defaultValue: undefined, description: 'validate 成功后自动执行的命令' },
|
|
15
21
|
},
|
|
16
22
|
}));
|
|
17
23
|
|
|
@@ -24,6 +30,7 @@ vi.mock('../../../src/utils/index.js', () => ({
|
|
|
24
30
|
printSuccess: vi.fn(),
|
|
25
31
|
printInfo: vi.fn(),
|
|
26
32
|
safeStringify: vi.fn((value: unknown, indent: number = 2) => JSON.stringify(value, null, indent)),
|
|
33
|
+
interactiveConfigEditor: vi.fn(),
|
|
27
34
|
}));
|
|
28
35
|
|
|
29
36
|
import { registerInitCommand } from '../../../src/commands/init.js';
|
|
@@ -32,16 +39,16 @@ import {
|
|
|
32
39
|
saveProjectConfig,
|
|
33
40
|
requireProjectConfig,
|
|
34
41
|
printSuccess,
|
|
35
|
-
printInfo,
|
|
36
42
|
getCurrentBranch,
|
|
43
|
+
interactiveConfigEditor,
|
|
37
44
|
} from '../../../src/utils/index.js';
|
|
38
45
|
|
|
39
46
|
const mockedLoadProjectConfig = vi.mocked(loadProjectConfig);
|
|
40
47
|
const mockedSaveProjectConfig = vi.mocked(saveProjectConfig);
|
|
41
48
|
const mockedRequireProjectConfig = vi.mocked(requireProjectConfig);
|
|
42
49
|
const mockedPrintSuccess = vi.mocked(printSuccess);
|
|
43
|
-
const mockedPrintInfo = vi.mocked(printInfo);
|
|
44
50
|
const mockedGetCurrentBranch = vi.mocked(getCurrentBranch);
|
|
51
|
+
const mockedInteractiveConfigEditor = vi.mocked(interactiveConfigEditor);
|
|
45
52
|
|
|
46
53
|
beforeEach(() => {
|
|
47
54
|
mockedLoadProjectConfig.mockReset();
|
|
@@ -49,8 +56,8 @@ beforeEach(() => {
|
|
|
49
56
|
mockedRequireProjectConfig.mockReset();
|
|
50
57
|
mockedRequireProjectConfig.mockReturnValue({ clawtMainWorkBranch: 'main' });
|
|
51
58
|
mockedPrintSuccess.mockReset();
|
|
52
|
-
mockedPrintInfo.mockReset();
|
|
53
59
|
mockedGetCurrentBranch.mockReturnValue('main');
|
|
60
|
+
mockedInteractiveConfigEditor.mockReset();
|
|
54
61
|
});
|
|
55
62
|
|
|
56
63
|
describe('registerInitCommand', () => {
|
|
@@ -132,15 +139,43 @@ describe('handleInit', () => {
|
|
|
132
139
|
});
|
|
133
140
|
|
|
134
141
|
describe('handleInitShow (show 子命令)', () => {
|
|
135
|
-
it('clawt init show
|
|
142
|
+
it('clawt init show 进入交互式面板并保存修改', async () => {
|
|
136
143
|
mockedRequireProjectConfig.mockReturnValue({ clawtMainWorkBranch: 'develop' });
|
|
144
|
+
mockedInteractiveConfigEditor.mockResolvedValue({ key: 'validateRunCommand', newValue: 'npm test' });
|
|
145
|
+
|
|
146
|
+
const program = new Command();
|
|
147
|
+
program.exitOverride();
|
|
148
|
+
registerInitCommand(program);
|
|
149
|
+
await program.parseAsync(['init', 'show'], { from: 'user' });
|
|
150
|
+
|
|
151
|
+
// 验证调用了交互式配置编辑器
|
|
152
|
+
expect(mockedInteractiveConfigEditor).toHaveBeenCalled();
|
|
153
|
+
// 验证保存了合并后的配置
|
|
154
|
+
expect(mockedSaveProjectConfig).toHaveBeenCalledWith({
|
|
155
|
+
clawtMainWorkBranch: 'develop',
|
|
156
|
+
validateRunCommand: 'npm test',
|
|
157
|
+
});
|
|
158
|
+
// 验证输出了成功消息
|
|
159
|
+
expect(mockedPrintSuccess).toHaveBeenCalledWith(
|
|
160
|
+
expect.stringContaining('validateRunCommand'),
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('clawt init show 修改已有配置项', async () => {
|
|
165
|
+
mockedRequireProjectConfig.mockReturnValue({
|
|
166
|
+
clawtMainWorkBranch: 'main',
|
|
167
|
+
validateRunCommand: 'npm test',
|
|
168
|
+
});
|
|
169
|
+
mockedInteractiveConfigEditor.mockResolvedValue({ key: 'clawtMainWorkBranch', newValue: 'develop' });
|
|
137
170
|
|
|
138
171
|
const program = new Command();
|
|
139
172
|
program.exitOverride();
|
|
140
173
|
registerInitCommand(program);
|
|
141
174
|
await program.parseAsync(['init', 'show'], { from: 'user' });
|
|
142
175
|
|
|
143
|
-
expect(
|
|
144
|
-
|
|
176
|
+
expect(mockedSaveProjectConfig).toHaveBeenCalledWith({
|
|
177
|
+
clawtMainWorkBranch: 'develop',
|
|
178
|
+
validateRunCommand: 'npm test',
|
|
179
|
+
});
|
|
145
180
|
});
|
|
146
181
|
});
|
|
@@ -11,6 +11,8 @@ vi.mock('../../../src/constants/index.js', () => ({
|
|
|
11
11
|
RESUME_SELECT_BRANCH: '选择要恢复的分支',
|
|
12
12
|
RESUME_MULTIPLE_MATCHES: (keyword: string) => `找到多个匹配 "${keyword}" 的分支`,
|
|
13
13
|
RESUME_NO_MATCH: (keyword: string, branches: string[]) => `未找到匹配 "${keyword}" 的分支`,
|
|
14
|
+
RESUME_ALL_CONFIRM: (count: number) => `确认恢复 ${count} 个分支?`,
|
|
15
|
+
RESUME_ALL_SUCCESS: (count: number) => `已恢复 ${count} 个分支`,
|
|
14
16
|
},
|
|
15
17
|
}));
|
|
16
18
|
|
|
@@ -19,8 +21,14 @@ vi.mock('../../../src/utils/index.js', () => ({
|
|
|
19
21
|
validateClaudeCodeInstalled: vi.fn(),
|
|
20
22
|
getProjectWorktrees: vi.fn(),
|
|
21
23
|
launchInteractiveClaude: vi.fn(),
|
|
24
|
+
launchInteractiveClaudeInNewTerminal: vi.fn(),
|
|
25
|
+
hasClaudeSessionHistory: vi.fn(),
|
|
22
26
|
resolveTargetWorktrees: vi.fn(),
|
|
23
27
|
promptGroupedMultiSelectBranches: vi.fn(),
|
|
28
|
+
printInfo: vi.fn(),
|
|
29
|
+
printSuccess: vi.fn(),
|
|
30
|
+
confirmAction: vi.fn(),
|
|
31
|
+
getConfigValue: vi.fn(),
|
|
24
32
|
}));
|
|
25
33
|
|
|
26
34
|
import { registerResumeCommand } from '../../../src/commands/resume.js';
|
|
@@ -29,24 +37,36 @@ import {
|
|
|
29
37
|
validateClaudeCodeInstalled,
|
|
30
38
|
getProjectWorktrees,
|
|
31
39
|
launchInteractiveClaude,
|
|
40
|
+
launchInteractiveClaudeInNewTerminal,
|
|
41
|
+
hasClaudeSessionHistory,
|
|
32
42
|
resolveTargetWorktrees,
|
|
33
43
|
promptGroupedMultiSelectBranches,
|
|
44
|
+
confirmAction,
|
|
45
|
+
getConfigValue,
|
|
34
46
|
} from '../../../src/utils/index.js';
|
|
35
47
|
|
|
36
48
|
const mockedValidateMainWorktree = vi.mocked(validateMainWorktree);
|
|
37
49
|
const mockedValidateClaudeCodeInstalled = vi.mocked(validateClaudeCodeInstalled);
|
|
38
50
|
const mockedGetProjectWorktrees = vi.mocked(getProjectWorktrees);
|
|
39
51
|
const mockedLaunchInteractiveClaude = vi.mocked(launchInteractiveClaude);
|
|
52
|
+
const mockedLaunchInteractiveClaudeInNewTerminal = vi.mocked(launchInteractiveClaudeInNewTerminal);
|
|
53
|
+
const mockedHasClaudeSessionHistory = vi.mocked(hasClaudeSessionHistory);
|
|
40
54
|
const mockedResolveTargetWorktrees = vi.mocked(resolveTargetWorktrees);
|
|
41
55
|
const mockedPromptGroupedMultiSelectBranches = vi.mocked(promptGroupedMultiSelectBranches);
|
|
56
|
+
const mockedConfirmAction = vi.mocked(confirmAction);
|
|
57
|
+
const mockedGetConfigValue = vi.mocked(getConfigValue);
|
|
42
58
|
|
|
43
59
|
beforeEach(() => {
|
|
44
60
|
mockedValidateMainWorktree.mockReset();
|
|
45
61
|
mockedValidateClaudeCodeInstalled.mockReset();
|
|
46
62
|
mockedGetProjectWorktrees.mockReset();
|
|
47
63
|
mockedLaunchInteractiveClaude.mockReset();
|
|
64
|
+
mockedLaunchInteractiveClaudeInNewTerminal.mockReset();
|
|
65
|
+
mockedHasClaudeSessionHistory.mockReset();
|
|
48
66
|
mockedResolveTargetWorktrees.mockReset();
|
|
49
67
|
mockedPromptGroupedMultiSelectBranches.mockReset();
|
|
68
|
+
mockedConfirmAction.mockReset();
|
|
69
|
+
mockedGetConfigValue.mockReset();
|
|
50
70
|
});
|
|
51
71
|
|
|
52
72
|
describe('registerResumeCommand', () => {
|
|
@@ -63,6 +83,7 @@ describe('handleResume', () => {
|
|
|
63
83
|
const worktree = { path: '/path/feature', branch: 'feature' };
|
|
64
84
|
mockedGetProjectWorktrees.mockReturnValue([worktree]);
|
|
65
85
|
mockedResolveTargetWorktrees.mockResolvedValue([worktree]);
|
|
86
|
+
mockedGetConfigValue.mockReturnValue(true);
|
|
66
87
|
|
|
67
88
|
const program = new Command();
|
|
68
89
|
program.exitOverride();
|
|
@@ -83,6 +104,7 @@ describe('handleResume', () => {
|
|
|
83
104
|
];
|
|
84
105
|
mockedGetProjectWorktrees.mockReturnValue(worktrees);
|
|
85
106
|
mockedPromptGroupedMultiSelectBranches.mockResolvedValue([worktrees[0]]);
|
|
107
|
+
mockedGetConfigValue.mockReturnValue(true);
|
|
86
108
|
|
|
87
109
|
const program = new Command();
|
|
88
110
|
program.exitOverride();
|
|
@@ -100,6 +122,7 @@ describe('handleResume', () => {
|
|
|
100
122
|
const worktree = { path: '/path/feature', branch: 'feature' };
|
|
101
123
|
mockedGetProjectWorktrees.mockReturnValue([worktree]);
|
|
102
124
|
mockedResolveTargetWorktrees.mockResolvedValue([worktree]);
|
|
125
|
+
mockedGetConfigValue.mockReturnValue(true);
|
|
103
126
|
|
|
104
127
|
const program = new Command();
|
|
105
128
|
program.exitOverride();
|
|
@@ -110,3 +133,93 @@ describe('handleResume', () => {
|
|
|
110
133
|
expect(mockedPromptGroupedMultiSelectBranches).not.toHaveBeenCalled();
|
|
111
134
|
});
|
|
112
135
|
});
|
|
136
|
+
|
|
137
|
+
describe('handleResume — resumeInPlace 配置', () => {
|
|
138
|
+
it('resumeInPlace 为 true 时,单选在当前终端就地恢复', async () => {
|
|
139
|
+
const worktree = { path: '/path/feature', branch: 'feature' };
|
|
140
|
+
mockedGetProjectWorktrees.mockReturnValue([worktree]);
|
|
141
|
+
mockedResolveTargetWorktrees.mockResolvedValue([worktree]);
|
|
142
|
+
mockedGetConfigValue.mockReturnValue(true);
|
|
143
|
+
|
|
144
|
+
const program = new Command();
|
|
145
|
+
program.exitOverride();
|
|
146
|
+
registerResumeCommand(program);
|
|
147
|
+
await program.parseAsync(['resume', '-b', 'feature'], { from: 'user' });
|
|
148
|
+
|
|
149
|
+
expect(mockedGetConfigValue).toHaveBeenCalledWith('resumeInPlace');
|
|
150
|
+
expect(mockedLaunchInteractiveClaude).toHaveBeenCalledWith(worktree, { autoContinue: true });
|
|
151
|
+
expect(mockedLaunchInteractiveClaudeInNewTerminal).not.toHaveBeenCalled();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('resumeInPlace 为 false 时,单选在新终端 Tab 中恢复', async () => {
|
|
155
|
+
const worktree = { path: '/path/feature', branch: 'feature' };
|
|
156
|
+
mockedGetProjectWorktrees.mockReturnValue([worktree]);
|
|
157
|
+
mockedResolveTargetWorktrees.mockResolvedValue([worktree]);
|
|
158
|
+
mockedGetConfigValue.mockReturnValue(false);
|
|
159
|
+
mockedHasClaudeSessionHistory.mockReturnValue(true);
|
|
160
|
+
|
|
161
|
+
const program = new Command();
|
|
162
|
+
program.exitOverride();
|
|
163
|
+
registerResumeCommand(program);
|
|
164
|
+
await program.parseAsync(['resume', '-b', 'feature'], { from: 'user' });
|
|
165
|
+
|
|
166
|
+
expect(mockedGetConfigValue).toHaveBeenCalledWith('resumeInPlace');
|
|
167
|
+
expect(mockedHasClaudeSessionHistory).toHaveBeenCalledWith(worktree.path);
|
|
168
|
+
expect(mockedLaunchInteractiveClaudeInNewTerminal).toHaveBeenCalledWith(worktree, true);
|
|
169
|
+
expect(mockedLaunchInteractiveClaude).not.toHaveBeenCalled();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('resumeInPlace 为 false 且无历史会话时,传 false 给新终端启动', async () => {
|
|
173
|
+
const worktree = { path: '/path/feature', branch: 'feature' };
|
|
174
|
+
mockedGetProjectWorktrees.mockReturnValue([worktree]);
|
|
175
|
+
mockedResolveTargetWorktrees.mockResolvedValue([worktree]);
|
|
176
|
+
mockedGetConfigValue.mockReturnValue(false);
|
|
177
|
+
mockedHasClaudeSessionHistory.mockReturnValue(false);
|
|
178
|
+
|
|
179
|
+
const program = new Command();
|
|
180
|
+
program.exitOverride();
|
|
181
|
+
registerResumeCommand(program);
|
|
182
|
+
await program.parseAsync(['resume', '-b', 'feature'], { from: 'user' });
|
|
183
|
+
|
|
184
|
+
expect(mockedLaunchInteractiveClaudeInNewTerminal).toHaveBeenCalledWith(worktree, false);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('多选时不受 resumeInPlace 影响,始终在新 Tab 中打开', async () => {
|
|
188
|
+
const worktrees = [
|
|
189
|
+
{ path: '/path/feature-a', branch: 'feature-a' },
|
|
190
|
+
{ path: '/path/feature-b', branch: 'feature-b' },
|
|
191
|
+
];
|
|
192
|
+
mockedGetProjectWorktrees.mockReturnValue(worktrees);
|
|
193
|
+
mockedPromptGroupedMultiSelectBranches.mockResolvedValue(worktrees);
|
|
194
|
+
mockedConfirmAction.mockResolvedValue(true);
|
|
195
|
+
mockedHasClaudeSessionHistory.mockReturnValue(false);
|
|
196
|
+
mockedGetConfigValue.mockReturnValue(true);
|
|
197
|
+
|
|
198
|
+
const program = new Command();
|
|
199
|
+
program.exitOverride();
|
|
200
|
+
registerResumeCommand(program);
|
|
201
|
+
await program.parseAsync(['resume'], { from: 'user' });
|
|
202
|
+
|
|
203
|
+
// 多选走 handleBatchResume,不读取 resumeInPlace
|
|
204
|
+
expect(mockedLaunchInteractiveClaude).not.toHaveBeenCalled();
|
|
205
|
+
expect(mockedLaunchInteractiveClaudeInNewTerminal).toHaveBeenCalledTimes(2);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('用户未选择任何分支时直接退出', async () => {
|
|
209
|
+
const worktrees = [
|
|
210
|
+
{ path: '/path/feature-a', branch: 'feature-a' },
|
|
211
|
+
{ path: '/path/feature-b', branch: 'feature-b' },
|
|
212
|
+
];
|
|
213
|
+
mockedGetProjectWorktrees.mockReturnValue(worktrees);
|
|
214
|
+
mockedPromptGroupedMultiSelectBranches.mockResolvedValue([]);
|
|
215
|
+
|
|
216
|
+
const program = new Command();
|
|
217
|
+
program.exitOverride();
|
|
218
|
+
registerResumeCommand(program);
|
|
219
|
+
await program.parseAsync(['resume'], { from: 'user' });
|
|
220
|
+
|
|
221
|
+
expect(mockedLaunchInteractiveClaude).not.toHaveBeenCalled();
|
|
222
|
+
expect(mockedLaunchInteractiveClaudeInNewTerminal).not.toHaveBeenCalled();
|
|
223
|
+
expect(mockedGetConfigValue).not.toHaveBeenCalled();
|
|
224
|
+
});
|
|
225
|
+
});
|
|
@@ -101,6 +101,7 @@ vi.mock('../../../src/utils/index.js', () => ({
|
|
|
101
101
|
handleDirtyWorkingDir: vi.fn(),
|
|
102
102
|
checkBranchExists: vi.fn().mockReturnValue(true),
|
|
103
103
|
getCurrentBranch: vi.fn().mockReturnValue('main'),
|
|
104
|
+
getValidateRunCommand: vi.fn(),
|
|
104
105
|
}));
|
|
105
106
|
|
|
106
107
|
import { registerValidateCommand } from '../../../src/commands/validate.js';
|
|
@@ -140,6 +141,7 @@ import {
|
|
|
140
141
|
printSeparator,
|
|
141
142
|
parseParallelCommands,
|
|
142
143
|
runParallelCommands,
|
|
144
|
+
getValidateRunCommand,
|
|
143
145
|
} from '../../../src/utils/index.js';
|
|
144
146
|
|
|
145
147
|
const mockedGetProjectName = vi.mocked(getProjectName);
|
|
@@ -177,6 +179,7 @@ const mockedPrintError = vi.mocked(printError);
|
|
|
177
179
|
const mockedPrintSeparator = vi.mocked(printSeparator);
|
|
178
180
|
const mockedParseParallelCommands = vi.mocked(parseParallelCommands);
|
|
179
181
|
const mockedRunParallelCommands = vi.mocked(runParallelCommands);
|
|
182
|
+
const mockedGetValidateRunCommand = vi.mocked(getValidateRunCommand);
|
|
180
183
|
|
|
181
184
|
const worktree = { path: '/path/feature', branch: 'feature' };
|
|
182
185
|
|
|
@@ -216,6 +219,7 @@ beforeEach(() => {
|
|
|
216
219
|
mockedPrintSeparator.mockReset();
|
|
217
220
|
mockedParseParallelCommands.mockReset();
|
|
218
221
|
mockedRunParallelCommands.mockReset();
|
|
222
|
+
mockedGetValidateRunCommand.mockReset();
|
|
219
223
|
// 默认让 parseParallelCommands 返回单命令数组,保持旧测试兼容
|
|
220
224
|
mockedParseParallelCommands.mockImplementation((cmd: string) => [cmd]);
|
|
221
225
|
});
|
|
@@ -632,3 +636,62 @@ describe('--run 并行命令', () => {
|
|
|
632
636
|
expect(mockedRunParallelCommands).not.toHaveBeenCalled();
|
|
633
637
|
});
|
|
634
638
|
});
|
|
639
|
+
|
|
640
|
+
describe('配置读取 fallback(resolveRunCommand)', () => {
|
|
641
|
+
/** 设置首次 validate 成功的公共 mock */
|
|
642
|
+
function setupSuccessfulFirstValidate(): void {
|
|
643
|
+
mockedIsWorkingDirClean.mockReturnValue(true);
|
|
644
|
+
mockedHasLocalCommits.mockReturnValue(true);
|
|
645
|
+
mockedHasSnapshot.mockReturnValue(false);
|
|
646
|
+
mockedGitDiffBinaryAgainstBranch.mockReturnValue(Buffer.from('diff'));
|
|
647
|
+
mockedGitWriteTree.mockReturnValue('treehash');
|
|
648
|
+
mockedGetHeadCommitHash.mockReturnValue('headhash');
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
it('未传 -r 但项目配置有 validateRunCommand 时从配置读取执行', async () => {
|
|
652
|
+
setupSuccessfulFirstValidate();
|
|
653
|
+
mockedGetValidateRunCommand.mockReturnValue('pnpm test');
|
|
654
|
+
mockedRunCommandInherited.mockReturnValue({
|
|
655
|
+
pid: 0, output: [], stdout: Buffer.alloc(0), stderr: Buffer.alloc(0),
|
|
656
|
+
status: 0, signal: null, error: undefined,
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
const program = new Command();
|
|
660
|
+
program.exitOverride();
|
|
661
|
+
registerValidateCommand(program);
|
|
662
|
+
await program.parseAsync(['validate', '-b', 'feature'], { from: 'user' });
|
|
663
|
+
|
|
664
|
+
// 应该从配置读取命令并执行
|
|
665
|
+
expect(mockedRunCommandInherited).toHaveBeenCalledWith('pnpm test', { cwd: '/repo' });
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
it('传了 -r 时以用户参数为准,忽略项目配置', async () => {
|
|
669
|
+
setupSuccessfulFirstValidate();
|
|
670
|
+
mockedGetValidateRunCommand.mockReturnValue('pnpm test');
|
|
671
|
+
mockedRunCommandInherited.mockReturnValue({
|
|
672
|
+
pid: 0, output: [], stdout: Buffer.alloc(0), stderr: Buffer.alloc(0),
|
|
673
|
+
status: 0, signal: null, error: undefined,
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
const program = new Command();
|
|
677
|
+
program.exitOverride();
|
|
678
|
+
registerValidateCommand(program);
|
|
679
|
+
await program.parseAsync(['validate', '-b', 'feature', '-r', 'pnpm build'], { from: 'user' });
|
|
680
|
+
|
|
681
|
+
// 应该使用用户传入的命令,而非配置中的
|
|
682
|
+
expect(mockedRunCommandInherited).toHaveBeenCalledWith('pnpm build', { cwd: '/repo' });
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
it('未传 -r 且项目配置无 validateRunCommand 时不执行命令', async () => {
|
|
686
|
+
setupSuccessfulFirstValidate();
|
|
687
|
+
mockedGetValidateRunCommand.mockReturnValue(undefined);
|
|
688
|
+
|
|
689
|
+
const program = new Command();
|
|
690
|
+
program.exitOverride();
|
|
691
|
+
registerValidateCommand(program);
|
|
692
|
+
await program.parseAsync(['validate', '-b', 'feature'], { from: 'user' });
|
|
693
|
+
|
|
694
|
+
// 没有命令可执行
|
|
695
|
+
expect(mockedRunCommandInherited).not.toHaveBeenCalled();
|
|
696
|
+
});
|
|
697
|
+
});
|
|
@@ -48,6 +48,7 @@ vi.mock('../../../src/constants/index.js', async (importOriginal) => {
|
|
|
48
48
|
CONFIG_INVALID_ENUM: (key: string, validValues: readonly string[]) =>
|
|
49
49
|
`配置项 ${key} 仅接受以下值: ${validValues.join(', ')}`,
|
|
50
50
|
CONFIG_INPUT_PROMPT: (key: string) => `输入 ${key} 的新值`,
|
|
51
|
+
CONFIG_SELECT_PROMPT: '选择要修改的配置项',
|
|
51
52
|
},
|
|
52
53
|
};
|
|
53
54
|
});
|
|
@@ -58,6 +59,7 @@ import {
|
|
|
58
59
|
parseConfigValue,
|
|
59
60
|
promptConfigValue,
|
|
60
61
|
formatConfigValue,
|
|
62
|
+
interactiveConfigEditor,
|
|
61
63
|
} from '../../../src/utils/config-strategy.js';
|
|
62
64
|
|
|
63
65
|
beforeEach(() => {
|
|
@@ -184,7 +186,7 @@ describe('promptConfigValue', () => {
|
|
|
184
186
|
|
|
185
187
|
it('字符串 + 有 allowedValues 使用 Select 提示', async () => {
|
|
186
188
|
mockSelectRun.mockResolvedValueOnce('iterm2');
|
|
187
|
-
const result = await promptConfigValue('terminalApp', 'auto');
|
|
189
|
+
const result = await promptConfigValue('terminalApp', 'auto', ['auto', 'iterm2', 'terminal']);
|
|
188
190
|
expect(result).toBe('iterm2');
|
|
189
191
|
expect(mockSelectRun).toHaveBeenCalledTimes(1);
|
|
190
192
|
});
|
|
@@ -217,4 +219,78 @@ describe('formatConfigValue', () => {
|
|
|
217
219
|
const result = formatConfigValue('hello');
|
|
218
220
|
expect(result).toContain('hello');
|
|
219
221
|
});
|
|
222
|
+
|
|
223
|
+
it('undefined 显示为 (未设置)', () => {
|
|
224
|
+
const result = formatConfigValue(undefined);
|
|
225
|
+
expect(result).toContain('未设置');
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('null 显示为 (未设置)', () => {
|
|
229
|
+
const result = formatConfigValue(null);
|
|
230
|
+
expect(result).toContain('未设置');
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
describe('interactiveConfigEditor', () => {
|
|
235
|
+
it('选择配置项并返回新值(布尔类型)', async () => {
|
|
236
|
+
// 第一次 Select 选择配置项,第二次 Select 选择布尔值
|
|
237
|
+
mockSelectRun.mockResolvedValueOnce('enabled');
|
|
238
|
+
mockSelectRun.mockResolvedValueOnce('true');
|
|
239
|
+
|
|
240
|
+
const config = { enabled: false, name: 'test' };
|
|
241
|
+
const definitions = {
|
|
242
|
+
enabled: { description: '是否启用' },
|
|
243
|
+
name: { description: '名称' },
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const result = await interactiveConfigEditor(config, definitions);
|
|
247
|
+
expect(result.key).toBe('enabled');
|
|
248
|
+
expect(result.newValue).toBe(true);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('选择配置项并返回新值(字符串类型)', async () => {
|
|
252
|
+
mockSelectRun.mockResolvedValueOnce('name');
|
|
253
|
+
mockInputRun.mockResolvedValueOnce('new-name');
|
|
254
|
+
|
|
255
|
+
const config = { enabled: false, name: 'test' };
|
|
256
|
+
const definitions = {
|
|
257
|
+
enabled: { description: '是否启用' },
|
|
258
|
+
name: { description: '名称' },
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
const result = await interactiveConfigEditor(config, definitions);
|
|
262
|
+
expect(result.key).toBe('name');
|
|
263
|
+
expect(result.newValue).toBe('new-name');
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('支持自定义 selectPrompt', async () => {
|
|
267
|
+
mockSelectRun.mockResolvedValueOnce('name');
|
|
268
|
+
mockInputRun.mockResolvedValueOnce('value');
|
|
269
|
+
|
|
270
|
+
const config = { name: 'test' };
|
|
271
|
+
const definitions = { name: { description: '名称' } };
|
|
272
|
+
|
|
273
|
+
await interactiveConfigEditor(config, definitions, {
|
|
274
|
+
selectPrompt: '自定义提示',
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// 验证 Select 被调用(无需检查具体参数,因为 mock 不保留构造参数)
|
|
278
|
+
expect(mockSelectRun).toHaveBeenCalledTimes(1);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('有 allowedValues 的字符串配置项使用 Select 提示', async () => {
|
|
282
|
+
mockSelectRun.mockResolvedValueOnce('mode');
|
|
283
|
+
mockSelectRun.mockResolvedValueOnce('fast');
|
|
284
|
+
|
|
285
|
+
const config = { mode: 'normal' };
|
|
286
|
+
const definitions = {
|
|
287
|
+
mode: { description: '模式', allowedValues: ['normal', 'fast', 'slow'] as const },
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
const result = await interactiveConfigEditor(config, definitions);
|
|
291
|
+
expect(result.key).toBe('mode');
|
|
292
|
+
expect(result.newValue).toBe('fast');
|
|
293
|
+
// 两次 Select:一次选择配置项,一次选择枚举值
|
|
294
|
+
expect(mockSelectRun).toHaveBeenCalledTimes(2);
|
|
295
|
+
});
|
|
220
296
|
});
|