ccmanager 3.4.0 → 3.5.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/README.md +11 -5
- package/dist/components/App.js +17 -3
- package/dist/components/App.test.js +5 -5
- package/dist/components/Configuration.d.ts +2 -0
- package/dist/components/Configuration.js +6 -2
- package/dist/components/ConfigureCommand.js +34 -11
- package/dist/components/ConfigureOther.js +18 -4
- package/dist/components/ConfigureOther.test.js +48 -12
- package/dist/components/ConfigureShortcuts.js +27 -85
- package/dist/components/ConfigureStatusHooks.js +19 -4
- package/dist/components/ConfigureStatusHooks.test.js +46 -12
- package/dist/components/ConfigureWorktree.js +18 -4
- package/dist/components/ConfigureWorktreeHooks.js +19 -4
- package/dist/components/ConfigureWorktreeHooks.test.js +49 -14
- package/dist/components/Menu.js +72 -14
- package/dist/components/NewWorktree.js +2 -2
- package/dist/components/NewWorktree.test.js +6 -6
- package/dist/components/PresetSelector.js +2 -2
- package/dist/contexts/ConfigEditorContext.d.ts +21 -0
- package/dist/contexts/ConfigEditorContext.js +25 -0
- package/dist/services/autoApprovalVerifier.js +3 -3
- package/dist/services/autoApprovalVerifier.test.js +2 -2
- package/dist/services/config/configEditor.d.ts +46 -0
- package/dist/services/{configurationManager.effect.test.js → config/configEditor.effect.test.js} +46 -49
- package/dist/services/config/configEditor.js +101 -0
- package/dist/services/{configurationManager.selectPresetOnStart.test.js → config/configEditor.selectPresetOnStart.test.js} +27 -19
- package/dist/services/{configurationManager.test.js → config/configEditor.test.js} +60 -132
- package/dist/services/config/configReader.d.ts +28 -0
- package/dist/services/config/configReader.js +95 -0
- package/dist/services/config/configReader.multiProject.test.d.ts +1 -0
- package/dist/services/config/configReader.multiProject.test.js +136 -0
- package/dist/services/config/globalConfigManager.d.ts +30 -0
- package/dist/services/config/globalConfigManager.js +216 -0
- package/dist/services/config/index.d.ts +13 -0
- package/dist/services/config/index.js +13 -0
- package/dist/services/config/projectConfigManager.d.ts +41 -0
- package/dist/services/config/projectConfigManager.js +181 -0
- package/dist/services/config/projectConfigManager.test.d.ts +1 -0
- package/dist/services/config/projectConfigManager.test.js +105 -0
- package/dist/services/config/testUtils.d.ts +81 -0
- package/dist/services/config/testUtils.js +351 -0
- package/dist/services/sessionManager.autoApproval.test.js +5 -5
- package/dist/services/sessionManager.effect.test.js +27 -18
- package/dist/services/sessionManager.js +17 -34
- package/dist/services/sessionManager.statePersistence.test.js +5 -4
- package/dist/services/sessionManager.test.js +52 -47
- package/dist/services/shortcutManager.d.ts +0 -1
- package/dist/services/shortcutManager.js +5 -16
- package/dist/services/shortcutManager.test.js +2 -2
- package/dist/services/worktreeService.d.ts +12 -0
- package/dist/services/worktreeService.js +24 -4
- package/dist/services/worktreeService.sort.test.js +105 -109
- package/dist/services/worktreeService.test.js +5 -5
- package/dist/types/index.d.ts +41 -7
- package/dist/utils/gitUtils.d.ts +8 -0
- package/dist/utils/gitUtils.js +32 -0
- package/dist/utils/hookExecutor.js +2 -2
- package/dist/utils/hookExecutor.test.js +8 -12
- package/dist/utils/worktreeUtils.test.js +0 -1
- package/package.json +7 -7
- package/dist/services/configurationManager.d.ts +0 -121
- package/dist/services/configurationManager.js +0 -597
- /package/dist/services/{configurationManager.effect.test.d.ts → config/configEditor.effect.test.d.ts} +0 -0
- /package/dist/services/{configurationManager.selectPresetOnStart.test.d.ts → config/configEditor.selectPresetOnStart.test.d.ts} +0 -0
- /package/dist/services/{configurationManager.test.d.ts → config/configEditor.test.d.ts} +0 -0
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Test utilities for config module
|
|
3
|
+
*
|
|
4
|
+
* WARNING: This file is intended for TEST USE ONLY.
|
|
5
|
+
* Do not import from production code.
|
|
6
|
+
*
|
|
7
|
+
* These functions provide Effect-based wrappers for testing config operations.
|
|
8
|
+
*/
|
|
9
|
+
import { Effect, Either } from 'effect';
|
|
10
|
+
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
|
11
|
+
import { DEFAULT_SHORTCUTS, } from '../../types/index.js';
|
|
12
|
+
import { FileSystemError, ConfigError, ValidationError, } from '../../types/errors.js';
|
|
13
|
+
/**
|
|
14
|
+
* TEST ONLY: Load configuration from file with Effect-based error handling
|
|
15
|
+
*
|
|
16
|
+
* @param configPath - Path to the config file
|
|
17
|
+
* @param legacyShortcutsPath - Path to legacy shortcuts file for migration
|
|
18
|
+
* @returns Effect with ConfigurationData on success, errors on failure
|
|
19
|
+
*/
|
|
20
|
+
export function loadConfigEffect(configPath, legacyShortcutsPath) {
|
|
21
|
+
return Effect.try({
|
|
22
|
+
try: () => {
|
|
23
|
+
if (existsSync(configPath)) {
|
|
24
|
+
const configData = readFileSync(configPath, 'utf-8');
|
|
25
|
+
const parsedConfig = JSON.parse(configData);
|
|
26
|
+
return applyDefaults(parsedConfig);
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
const migratedConfig = migrateLegacyShortcutsSync(configPath, legacyShortcutsPath);
|
|
30
|
+
return applyDefaults(migratedConfig || {});
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
catch: (error) => {
|
|
34
|
+
if (error instanceof SyntaxError) {
|
|
35
|
+
return new ConfigError({
|
|
36
|
+
configPath,
|
|
37
|
+
reason: 'parse',
|
|
38
|
+
details: String(error),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
return new FileSystemError({
|
|
42
|
+
operation: 'read',
|
|
43
|
+
path: configPath,
|
|
44
|
+
cause: String(error),
|
|
45
|
+
});
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Type guard to check if value is a non-null object
|
|
51
|
+
*/
|
|
52
|
+
function isObject(value) {
|
|
53
|
+
return value !== null && typeof value === 'object';
|
|
54
|
+
}
|
|
55
|
+
function validationError(constraint, receivedValue) {
|
|
56
|
+
return Either.left(new ValidationError({
|
|
57
|
+
field: 'config',
|
|
58
|
+
constraint,
|
|
59
|
+
receivedValue,
|
|
60
|
+
}));
|
|
61
|
+
}
|
|
62
|
+
function validationSuccess(config) {
|
|
63
|
+
return Either.right(config);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* TEST ONLY: Validate configuration structure
|
|
67
|
+
*/
|
|
68
|
+
export function validateConfig(config) {
|
|
69
|
+
if (!isObject(config)) {
|
|
70
|
+
return validationError('must be a valid configuration object', config);
|
|
71
|
+
}
|
|
72
|
+
const shortcuts = config['shortcuts'];
|
|
73
|
+
if (shortcuts !== undefined && !isObject(shortcuts)) {
|
|
74
|
+
return validationError('shortcuts must be a valid object', config);
|
|
75
|
+
}
|
|
76
|
+
return validationSuccess(config);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Apply default values to configuration
|
|
80
|
+
*/
|
|
81
|
+
function applyDefaults(config) {
|
|
82
|
+
if (!config.shortcuts) {
|
|
83
|
+
config.shortcuts = DEFAULT_SHORTCUTS;
|
|
84
|
+
}
|
|
85
|
+
if (!config.statusHooks) {
|
|
86
|
+
config.statusHooks = {};
|
|
87
|
+
}
|
|
88
|
+
if (!config.worktreeHooks) {
|
|
89
|
+
config.worktreeHooks = {};
|
|
90
|
+
}
|
|
91
|
+
if (!config.worktree) {
|
|
92
|
+
config.worktree = {
|
|
93
|
+
autoDirectory: false,
|
|
94
|
+
copySessionData: true,
|
|
95
|
+
sortByLastSession: false,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
if (!Object.prototype.hasOwnProperty.call(config.worktree, 'copySessionData')) {
|
|
99
|
+
config.worktree.copySessionData = true;
|
|
100
|
+
}
|
|
101
|
+
if (!Object.prototype.hasOwnProperty.call(config.worktree, 'sortByLastSession')) {
|
|
102
|
+
config.worktree.sortByLastSession = false;
|
|
103
|
+
}
|
|
104
|
+
if (!config.autoApproval) {
|
|
105
|
+
config.autoApproval = {
|
|
106
|
+
enabled: false,
|
|
107
|
+
timeout: 30,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
if (!Object.prototype.hasOwnProperty.call(config.autoApproval, 'enabled')) {
|
|
112
|
+
config.autoApproval.enabled = false;
|
|
113
|
+
}
|
|
114
|
+
if (!Object.prototype.hasOwnProperty.call(config.autoApproval, 'timeout')) {
|
|
115
|
+
config.autoApproval.timeout = 30;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return config;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Synchronous legacy shortcuts migration helper
|
|
122
|
+
*/
|
|
123
|
+
function migrateLegacyShortcutsSync(configPath, legacyShortcutsPath) {
|
|
124
|
+
if (existsSync(legacyShortcutsPath)) {
|
|
125
|
+
try {
|
|
126
|
+
const shortcutsData = readFileSync(legacyShortcutsPath, 'utf-8');
|
|
127
|
+
const shortcuts = JSON.parse(shortcutsData);
|
|
128
|
+
if (shortcuts && typeof shortcuts === 'object') {
|
|
129
|
+
const config = { shortcuts };
|
|
130
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
131
|
+
console.log('Migrated shortcuts from legacy shortcuts.json to config.json');
|
|
132
|
+
return config;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
console.error('Failed to migrate legacy shortcuts:', error);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
// ============================================================================
|
|
142
|
+
// Test-only helper functions for GlobalConfigManager
|
|
143
|
+
// These functions were moved from GlobalConfigManager class to reduce its API
|
|
144
|
+
// surface while keeping tests functional.
|
|
145
|
+
// ============================================================================
|
|
146
|
+
/**
|
|
147
|
+
* TEST ONLY: Add or update a preset in the config manager
|
|
148
|
+
*/
|
|
149
|
+
export function addPreset(configManager, preset) {
|
|
150
|
+
const presets = configManager.getCommandPresets();
|
|
151
|
+
// Replace if exists, otherwise add
|
|
152
|
+
const existingIndex = presets.presets.findIndex(p => p.id === preset.id);
|
|
153
|
+
if (existingIndex >= 0) {
|
|
154
|
+
presets.presets[existingIndex] = preset;
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
presets.presets.push(preset);
|
|
158
|
+
}
|
|
159
|
+
configManager.setCommandPresets(presets);
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* TEST ONLY: Delete a preset by ID
|
|
163
|
+
*/
|
|
164
|
+
export function deletePreset(configManager, id) {
|
|
165
|
+
const presets = configManager.getCommandPresets();
|
|
166
|
+
// Don't delete if it's the last preset
|
|
167
|
+
if (presets.presets.length <= 1) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
// Remove the preset
|
|
171
|
+
presets.presets = presets.presets.filter(p => p.id !== id);
|
|
172
|
+
// Update default if needed
|
|
173
|
+
if (presets.defaultPresetId === id && presets.presets.length > 0) {
|
|
174
|
+
presets.defaultPresetId = presets.presets[0].id;
|
|
175
|
+
}
|
|
176
|
+
configManager.setCommandPresets(presets);
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* TEST ONLY: Set the default preset ID
|
|
180
|
+
*/
|
|
181
|
+
export function setDefaultPreset(configManager, id) {
|
|
182
|
+
const presets = configManager.getCommandPresets();
|
|
183
|
+
// Only update if preset exists
|
|
184
|
+
if (presets.presets.some(p => p.id === id)) {
|
|
185
|
+
presets.defaultPresetId = id;
|
|
186
|
+
configManager.setCommandPresets(presets);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* TEST ONLY: Save configuration to file with Effect-based error handling
|
|
191
|
+
*/
|
|
192
|
+
export function saveConfigEffect(configManager, config, configPath) {
|
|
193
|
+
return Effect.try({
|
|
194
|
+
try: () => {
|
|
195
|
+
configManager.setCommandPresets(config.commandPresets || configManager.getCommandPresets());
|
|
196
|
+
if (config.shortcuts) {
|
|
197
|
+
configManager.setShortcuts(config.shortcuts);
|
|
198
|
+
}
|
|
199
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
200
|
+
},
|
|
201
|
+
catch: (error) => {
|
|
202
|
+
return new FileSystemError({
|
|
203
|
+
operation: 'write',
|
|
204
|
+
path: configPath,
|
|
205
|
+
cause: String(error),
|
|
206
|
+
});
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* TEST ONLY: Set shortcuts with Effect-based error handling
|
|
212
|
+
*/
|
|
213
|
+
export function setShortcutsEffect(configManager, shortcuts, configPath) {
|
|
214
|
+
return Effect.try({
|
|
215
|
+
try: () => {
|
|
216
|
+
configManager.setShortcuts(shortcuts);
|
|
217
|
+
const config = {
|
|
218
|
+
...{},
|
|
219
|
+
shortcuts,
|
|
220
|
+
commandPresets: configManager.getCommandPresets(),
|
|
221
|
+
};
|
|
222
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
223
|
+
},
|
|
224
|
+
catch: (error) => {
|
|
225
|
+
return new FileSystemError({
|
|
226
|
+
operation: 'write',
|
|
227
|
+
path: configPath,
|
|
228
|
+
cause: String(error),
|
|
229
|
+
});
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* TEST ONLY: Set command presets with Effect-based error handling
|
|
235
|
+
*/
|
|
236
|
+
export function setCommandPresetsEffect(configManager, presets, configPath) {
|
|
237
|
+
return Effect.try({
|
|
238
|
+
try: () => {
|
|
239
|
+
configManager.setCommandPresets(presets);
|
|
240
|
+
const config = {
|
|
241
|
+
...{},
|
|
242
|
+
commandPresets: presets,
|
|
243
|
+
};
|
|
244
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
245
|
+
},
|
|
246
|
+
catch: (error) => {
|
|
247
|
+
return new FileSystemError({
|
|
248
|
+
operation: 'write',
|
|
249
|
+
path: configPath,
|
|
250
|
+
cause: String(error),
|
|
251
|
+
});
|
|
252
|
+
},
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* TEST ONLY: Add or update preset with Effect-based error handling
|
|
257
|
+
*/
|
|
258
|
+
export function addPresetEffect(configManager, preset, configPath) {
|
|
259
|
+
const presets = configManager.getCommandPresets();
|
|
260
|
+
// Replace if exists, otherwise add
|
|
261
|
+
const existingIndex = presets.presets.findIndex(p => p.id === preset.id);
|
|
262
|
+
if (existingIndex >= 0) {
|
|
263
|
+
presets.presets[existingIndex] = preset;
|
|
264
|
+
}
|
|
265
|
+
else {
|
|
266
|
+
presets.presets.push(preset);
|
|
267
|
+
}
|
|
268
|
+
return setCommandPresetsEffect(configManager, presets, configPath);
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* TEST ONLY: Delete preset with Effect-based error handling
|
|
272
|
+
*/
|
|
273
|
+
export function deletePresetEffect(configManager, id, configPath) {
|
|
274
|
+
const presets = configManager.getCommandPresets();
|
|
275
|
+
// Don't delete if it's the last preset
|
|
276
|
+
if (presets.presets.length <= 1) {
|
|
277
|
+
return Effect.fail(new ValidationError({
|
|
278
|
+
field: 'presetId',
|
|
279
|
+
constraint: 'Cannot delete last preset',
|
|
280
|
+
receivedValue: id,
|
|
281
|
+
}));
|
|
282
|
+
}
|
|
283
|
+
// Remove the preset
|
|
284
|
+
presets.presets = presets.presets.filter(p => p.id !== id);
|
|
285
|
+
// Update default if needed
|
|
286
|
+
if (presets.defaultPresetId === id && presets.presets.length > 0) {
|
|
287
|
+
presets.defaultPresetId = presets.presets[0].id;
|
|
288
|
+
}
|
|
289
|
+
return setCommandPresetsEffect(configManager, presets, configPath);
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* TEST ONLY: Set default preset with Effect-based error handling
|
|
293
|
+
*/
|
|
294
|
+
export function setDefaultPresetEffect(configManager, id, configPath) {
|
|
295
|
+
const presets = configManager.getCommandPresets();
|
|
296
|
+
// Only update if preset exists
|
|
297
|
+
if (!presets.presets.some(p => p.id === id)) {
|
|
298
|
+
return Effect.fail(new ValidationError({
|
|
299
|
+
field: 'presetId',
|
|
300
|
+
constraint: 'Preset not found',
|
|
301
|
+
receivedValue: id,
|
|
302
|
+
}));
|
|
303
|
+
}
|
|
304
|
+
presets.defaultPresetId = id;
|
|
305
|
+
return setCommandPresetsEffect(configManager, presets, configPath);
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* TEST ONLY: Get the default preset
|
|
309
|
+
*/
|
|
310
|
+
export function getDefaultPreset(configManager) {
|
|
311
|
+
const presets = configManager.getCommandPresets();
|
|
312
|
+
const defaultPreset = presets.presets.find(p => p.id === presets.defaultPresetId);
|
|
313
|
+
return defaultPreset || presets.presets[0];
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* TEST ONLY: Get whether to select preset on start
|
|
317
|
+
*/
|
|
318
|
+
export function getSelectPresetOnStart(configManager) {
|
|
319
|
+
const presets = configManager.getCommandPresets();
|
|
320
|
+
return presets.selectPresetOnStart ?? false;
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* TEST ONLY: Set whether to select preset on start
|
|
324
|
+
*/
|
|
325
|
+
export function setSelectPresetOnStart(configManager, enabled) {
|
|
326
|
+
const presets = configManager.getCommandPresets();
|
|
327
|
+
presets.selectPresetOnStart = enabled;
|
|
328
|
+
configManager.setCommandPresets(presets);
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* TEST ONLY: Get whether auto-approval is enabled
|
|
332
|
+
*/
|
|
333
|
+
export function isAutoApprovalEnabled(configManager) {
|
|
334
|
+
const config = configManager.getAutoApprovalConfig();
|
|
335
|
+
return config?.enabled ?? false;
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* TEST ONLY: Get preset by ID with Either-based error handling
|
|
339
|
+
*/
|
|
340
|
+
export function getPresetByIdEffect(configManager, id) {
|
|
341
|
+
const presets = configManager.getCommandPresets();
|
|
342
|
+
const preset = presets.presets.find(p => p.id === id);
|
|
343
|
+
if (!preset) {
|
|
344
|
+
return Either.left(new ValidationError({
|
|
345
|
+
field: 'presetId',
|
|
346
|
+
constraint: 'Preset not found',
|
|
347
|
+
receivedValue: id,
|
|
348
|
+
}));
|
|
349
|
+
}
|
|
350
|
+
return Either.right(preset);
|
|
351
|
+
}
|
|
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
|
2
2
|
import { EventEmitter } from 'events';
|
|
3
3
|
import { spawn } from './bunTerminal.js';
|
|
4
4
|
import { STATE_CHECK_INTERVAL_MS, STATE_PERSISTENCE_DURATION_MS, } from '../constants/statePersistence.js';
|
|
5
|
-
import { Effect } from 'effect';
|
|
5
|
+
import { Effect, Either } from 'effect';
|
|
6
6
|
const detectStateMock = vi.fn();
|
|
7
7
|
// Create a deferred promise pattern for controllable mock
|
|
8
8
|
let verifyResolve = null;
|
|
@@ -20,8 +20,8 @@ vi.mock('./stateDetector/index.js', () => ({
|
|
|
20
20
|
detectBackgroundTask: () => false,
|
|
21
21
|
}),
|
|
22
22
|
}));
|
|
23
|
-
vi.mock('./
|
|
24
|
-
|
|
23
|
+
vi.mock('./config/configReader.js', () => ({
|
|
24
|
+
configReader: {
|
|
25
25
|
getConfig: vi.fn().mockReturnValue({
|
|
26
26
|
commands: [
|
|
27
27
|
{
|
|
@@ -33,12 +33,12 @@ vi.mock('./configurationManager.js', () => ({
|
|
|
33
33
|
],
|
|
34
34
|
defaultCommandId: 'test',
|
|
35
35
|
}),
|
|
36
|
-
|
|
36
|
+
getPresetByIdEffect: vi.fn().mockReturnValue(Either.right({
|
|
37
37
|
id: 'test',
|
|
38
38
|
name: 'Test',
|
|
39
39
|
command: 'test',
|
|
40
40
|
args: [],
|
|
41
|
-
}),
|
|
41
|
+
})),
|
|
42
42
|
getDefaultPreset: vi.fn().mockReturnValue({
|
|
43
43
|
id: 'test',
|
|
44
44
|
name: 'Test',
|
|
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
|
2
2
|
import { Effect, Either } from 'effect';
|
|
3
3
|
import { spawn } from './bunTerminal.js';
|
|
4
4
|
import { EventEmitter } from 'events';
|
|
5
|
+
import { ValidationError } from '../types/errors.js';
|
|
5
6
|
// Mock bunTerminal
|
|
6
7
|
vi.mock('./bunTerminal.js', () => ({
|
|
7
8
|
spawn: vi.fn(function () {
|
|
@@ -14,10 +15,10 @@ vi.mock('child_process', () => ({
|
|
|
14
15
|
execFile: vi.fn(),
|
|
15
16
|
}));
|
|
16
17
|
// Mock configuration manager
|
|
17
|
-
vi.mock('./
|
|
18
|
-
|
|
18
|
+
vi.mock('./config/configReader.js', () => ({
|
|
19
|
+
configReader: {
|
|
19
20
|
getDefaultPreset: vi.fn(),
|
|
20
|
-
|
|
21
|
+
getPresetByIdEffect: vi.fn(),
|
|
21
22
|
setWorktreeLastOpened: vi.fn(),
|
|
22
23
|
getWorktreeLastOpenedTime: vi.fn(),
|
|
23
24
|
getWorktreeLastOpened: vi.fn(() => ({})),
|
|
@@ -83,14 +84,14 @@ describe('SessionManager Effect-based Operations', () => {
|
|
|
83
84
|
let sessionManager;
|
|
84
85
|
let mockPty;
|
|
85
86
|
let SessionManager;
|
|
86
|
-
let
|
|
87
|
+
let configReader;
|
|
87
88
|
beforeEach(async () => {
|
|
88
89
|
vi.clearAllMocks();
|
|
89
90
|
// Dynamically import after mocks are set up
|
|
90
91
|
const sessionManagerModule = await import('./sessionManager.js');
|
|
91
|
-
const configManagerModule = await import('./
|
|
92
|
+
const configManagerModule = await import('./config/configReader.js');
|
|
92
93
|
SessionManager = sessionManagerModule.SessionManager;
|
|
93
|
-
|
|
94
|
+
configReader = configManagerModule.configReader;
|
|
94
95
|
sessionManager = new SessionManager();
|
|
95
96
|
mockPty = new MockPty();
|
|
96
97
|
});
|
|
@@ -100,7 +101,7 @@ describe('SessionManager Effect-based Operations', () => {
|
|
|
100
101
|
describe('createSessionWithPreset returning Effect', () => {
|
|
101
102
|
it('should return Effect that succeeds with Session', async () => {
|
|
102
103
|
// Setup mock preset
|
|
103
|
-
vi.mocked(
|
|
104
|
+
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
104
105
|
id: '1',
|
|
105
106
|
name: 'Main',
|
|
106
107
|
command: 'claude',
|
|
@@ -117,9 +118,13 @@ describe('SessionManager Effect-based Operations', () => {
|
|
|
117
118
|
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
118
119
|
});
|
|
119
120
|
it('should return Effect that fails with ConfigError when preset not found', async () => {
|
|
120
|
-
// Setup mocks -
|
|
121
|
-
vi.mocked(
|
|
122
|
-
|
|
121
|
+
// Setup mocks - getPresetByIdEffect returns Left, getDefaultPreset returns undefined
|
|
122
|
+
vi.mocked(configReader.getPresetByIdEffect).mockReturnValue(Either.left(new ValidationError({
|
|
123
|
+
field: 'presetId',
|
|
124
|
+
constraint: 'Preset not found',
|
|
125
|
+
receivedValue: 'invalid-preset',
|
|
126
|
+
})));
|
|
127
|
+
vi.mocked(configReader.getDefaultPreset).mockReturnValue(undefined);
|
|
123
128
|
// Create session with non-existent preset - should return Effect
|
|
124
129
|
const effect = sessionManager.createSessionWithPresetEffect('/test/worktree', 'invalid-preset');
|
|
125
130
|
// Execute the Effect and expect it to fail with ConfigError
|
|
@@ -135,7 +140,7 @@ describe('SessionManager Effect-based Operations', () => {
|
|
|
135
140
|
});
|
|
136
141
|
it('should return Effect that fails with ProcessError when spawn fails', async () => {
|
|
137
142
|
// Setup mock preset
|
|
138
|
-
vi.mocked(
|
|
143
|
+
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
139
144
|
id: '1',
|
|
140
145
|
name: 'Main',
|
|
141
146
|
command: 'invalid-command',
|
|
@@ -160,7 +165,7 @@ describe('SessionManager Effect-based Operations', () => {
|
|
|
160
165
|
});
|
|
161
166
|
it('should return existing session without creating new Effect', async () => {
|
|
162
167
|
// Setup mock preset
|
|
163
|
-
vi.mocked(
|
|
168
|
+
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
164
169
|
id: '1',
|
|
165
170
|
name: 'Main',
|
|
166
171
|
command: 'claude',
|
|
@@ -181,7 +186,7 @@ describe('SessionManager Effect-based Operations', () => {
|
|
|
181
186
|
describe('createSessionWithDevcontainer returning Effect', () => {
|
|
182
187
|
it('should return Effect that succeeds with Session', async () => {
|
|
183
188
|
// Setup mock preset
|
|
184
|
-
vi.mocked(
|
|
189
|
+
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
185
190
|
id: '1',
|
|
186
191
|
name: 'Main',
|
|
187
192
|
command: 'claude',
|
|
@@ -244,9 +249,13 @@ describe('SessionManager Effect-based Operations', () => {
|
|
|
244
249
|
}
|
|
245
250
|
});
|
|
246
251
|
it('should return Effect that fails with ConfigError when preset not found', async () => {
|
|
247
|
-
// Setup mocks -
|
|
248
|
-
vi.mocked(
|
|
249
|
-
|
|
252
|
+
// Setup mocks - getPresetByIdEffect returns Left, getDefaultPreset returns undefined
|
|
253
|
+
vi.mocked(configReader.getPresetByIdEffect).mockReturnValue(Either.left(new ValidationError({
|
|
254
|
+
field: 'presetId',
|
|
255
|
+
constraint: 'Preset not found',
|
|
256
|
+
receivedValue: 'invalid-preset',
|
|
257
|
+
})));
|
|
258
|
+
vi.mocked(configReader.getDefaultPreset).mockReturnValue(undefined);
|
|
250
259
|
// Mock exec to succeed (devcontainer up)
|
|
251
260
|
const { exec } = await import('child_process');
|
|
252
261
|
const mockExec = vi.mocked(exec);
|
|
@@ -276,7 +285,7 @@ describe('SessionManager Effect-based Operations', () => {
|
|
|
276
285
|
describe('terminateSession returning Effect', () => {
|
|
277
286
|
it('should return Effect that succeeds when session exists', async () => {
|
|
278
287
|
// Setup mock preset and create a session first
|
|
279
|
-
vi.mocked(
|
|
288
|
+
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
280
289
|
id: '1',
|
|
281
290
|
name: 'Main',
|
|
282
291
|
command: 'claude',
|
|
@@ -305,7 +314,7 @@ describe('SessionManager Effect-based Operations', () => {
|
|
|
305
314
|
});
|
|
306
315
|
it('should return Effect that succeeds even when process kill fails', async () => {
|
|
307
316
|
// Setup mock preset and create a session
|
|
308
|
-
vi.mocked(
|
|
317
|
+
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
309
318
|
id: '1',
|
|
310
319
|
name: 'Main',
|
|
311
320
|
command: 'claude',
|
|
@@ -3,11 +3,12 @@ import { EventEmitter } from 'events';
|
|
|
3
3
|
import pkg from '@xterm/headless';
|
|
4
4
|
import { exec } from 'child_process';
|
|
5
5
|
import { promisify } from 'util';
|
|
6
|
-
import {
|
|
6
|
+
import { configReader } from './config/configReader.js';
|
|
7
|
+
import { setWorktreeLastOpened } from './worktreeService.js';
|
|
7
8
|
import { executeStatusHook } from '../utils/hookExecutor.js';
|
|
8
9
|
import { createStateDetector } from './stateDetector/index.js';
|
|
9
10
|
import { STATE_PERSISTENCE_DURATION_MS, STATE_CHECK_INTERVAL_MS, } from '../constants/statePersistence.js';
|
|
10
|
-
import { Effect } from 'effect';
|
|
11
|
+
import { Effect, Either } from 'effect';
|
|
11
12
|
import { ProcessError, ConfigError } from '../types/errors.js';
|
|
12
13
|
import { autoApprovalVerifier } from './autoApprovalVerifier.js';
|
|
13
14
|
import { logger } from '../utils/logger.js';
|
|
@@ -33,7 +34,7 @@ export class SessionManager extends EventEmitter {
|
|
|
33
34
|
const detectedState = session.stateDetector.detectState(session.terminal, stateData.state);
|
|
34
35
|
// If auto-approval is enabled and state is waiting_input, convert to pending_auto_approval
|
|
35
36
|
if (detectedState === 'waiting_input' &&
|
|
36
|
-
|
|
37
|
+
configReader.isAutoApprovalEnabled() &&
|
|
37
38
|
!stateData.autoApprovalFailed) {
|
|
38
39
|
return 'pending_auto_approval';
|
|
39
40
|
}
|
|
@@ -188,7 +189,7 @@ export class SessionManager extends EventEmitter {
|
|
|
188
189
|
logLevel: 'off',
|
|
189
190
|
});
|
|
190
191
|
}
|
|
191
|
-
async createSessionInternal(worktreePath, ptyProcess,
|
|
192
|
+
async createSessionInternal(worktreePath, ptyProcess, options = {}) {
|
|
192
193
|
const id = this.createSessionId();
|
|
193
194
|
const terminal = this.createTerminal();
|
|
194
195
|
const detectionStrategy = options.detectionStrategy ?? 'claude';
|
|
@@ -204,7 +205,6 @@ export class SessionManager extends EventEmitter {
|
|
|
204
205
|
terminal,
|
|
205
206
|
stateCheckInterval: undefined, // Will be set in setupBackgroundHandler
|
|
206
207
|
isPrimaryCommand: options.isPrimaryCommand ?? true,
|
|
207
|
-
commandConfig,
|
|
208
208
|
detectionStrategy,
|
|
209
209
|
devcontainerConfig: options.devcontainerConfig ?? undefined,
|
|
210
210
|
stateMutex: new Mutex(createInitialSessionStateData()),
|
|
@@ -214,7 +214,7 @@ export class SessionManager extends EventEmitter {
|
|
|
214
214
|
this.setupBackgroundHandler(session);
|
|
215
215
|
this.sessions.set(worktreePath, session);
|
|
216
216
|
// Record the timestamp when this worktree was opened
|
|
217
|
-
|
|
217
|
+
setWorktreeLastOpened(worktreePath, Date.now());
|
|
218
218
|
this.emit('sessionCreated', session);
|
|
219
219
|
return session;
|
|
220
220
|
}
|
|
@@ -244,12 +244,12 @@ export class SessionManager extends EventEmitter {
|
|
|
244
244
|
if (existing) {
|
|
245
245
|
return existing;
|
|
246
246
|
}
|
|
247
|
-
// Get preset configuration
|
|
247
|
+
// Get preset configuration using Either-based lookup
|
|
248
248
|
let preset = presetId
|
|
249
|
-
?
|
|
249
|
+
? Either.getOrElse(configReader.getPresetByIdEffect(presetId), () => null)
|
|
250
250
|
: null;
|
|
251
251
|
if (!preset) {
|
|
252
|
-
preset =
|
|
252
|
+
preset = configReader.getDefaultPreset();
|
|
253
253
|
}
|
|
254
254
|
// Validate preset exists
|
|
255
255
|
if (!preset) {
|
|
@@ -263,14 +263,9 @@ export class SessionManager extends EventEmitter {
|
|
|
263
263
|
}
|
|
264
264
|
const command = preset.command;
|
|
265
265
|
const args = preset.args || [];
|
|
266
|
-
const commandConfig = {
|
|
267
|
-
command: preset.command,
|
|
268
|
-
args: preset.args,
|
|
269
|
-
fallbackArgs: preset.fallbackArgs,
|
|
270
|
-
};
|
|
271
266
|
// Spawn the process - fallback will be handled by setupExitHandler
|
|
272
267
|
const ptyProcess = await this.spawn(command, args, worktreePath);
|
|
273
|
-
return this.createSessionInternal(worktreePath, ptyProcess,
|
|
268
|
+
return this.createSessionInternal(worktreePath, ptyProcess, {
|
|
274
269
|
isPrimaryCommand: true,
|
|
275
270
|
detectionStrategy: preset.detectionStrategy,
|
|
276
271
|
});
|
|
@@ -329,8 +324,6 @@ export class SessionManager extends EventEmitter {
|
|
|
329
324
|
if (e.exitCode === 1 && !e.signal && session.isPrimaryCommand) {
|
|
330
325
|
try {
|
|
331
326
|
let fallbackProcess;
|
|
332
|
-
// Use fallback args if available, otherwise use empty args
|
|
333
|
-
const fallbackArgs = session.commandConfig?.fallbackArgs || [];
|
|
334
327
|
// Check if we're in a devcontainer session
|
|
335
328
|
if (session.devcontainerConfig) {
|
|
336
329
|
// Parse the exec command to extract arguments
|
|
@@ -338,17 +331,12 @@ export class SessionManager extends EventEmitter {
|
|
|
338
331
|
const devcontainerCmd = execParts[0] || 'devcontainer';
|
|
339
332
|
const execArgs = execParts.slice(1);
|
|
340
333
|
// Build fallback command for devcontainer
|
|
341
|
-
const fallbackFullArgs = [
|
|
342
|
-
...execArgs,
|
|
343
|
-
'--',
|
|
344
|
-
session.commandConfig?.command || 'claude',
|
|
345
|
-
...fallbackArgs,
|
|
346
|
-
];
|
|
334
|
+
const fallbackFullArgs = [...execArgs, '--', 'claude'];
|
|
347
335
|
fallbackProcess = await this.spawn(devcontainerCmd, fallbackFullArgs, session.worktreePath);
|
|
348
336
|
}
|
|
349
337
|
else {
|
|
350
338
|
// Regular fallback without devcontainer
|
|
351
|
-
fallbackProcess = await this.spawn(
|
|
339
|
+
fallbackProcess = await this.spawn('claude', [], session.worktreePath);
|
|
352
340
|
}
|
|
353
341
|
// Replace the process
|
|
354
342
|
session.process = fallbackProcess;
|
|
@@ -466,7 +454,7 @@ export class SessionManager extends EventEmitter {
|
|
|
466
454
|
session.isActive = active;
|
|
467
455
|
// If becoming active, record the timestamp when this worktree was opened
|
|
468
456
|
if (active) {
|
|
469
|
-
|
|
457
|
+
setWorktreeLastOpened(worktreePath, Date.now());
|
|
470
458
|
// Emit a restore event with the output history if available
|
|
471
459
|
if (session.outputHistory.length > 0) {
|
|
472
460
|
this.emit('sessionRestore', session);
|
|
@@ -620,12 +608,12 @@ export class SessionManager extends EventEmitter {
|
|
|
620
608
|
message: `Failed to start devcontainer: ${error instanceof Error ? error.message : String(error)}`,
|
|
621
609
|
});
|
|
622
610
|
}
|
|
623
|
-
// Get preset configuration
|
|
611
|
+
// Get preset configuration using Either-based lookup
|
|
624
612
|
let preset = presetId
|
|
625
|
-
?
|
|
613
|
+
? Either.getOrElse(configReader.getPresetByIdEffect(presetId), () => null)
|
|
626
614
|
: null;
|
|
627
615
|
if (!preset) {
|
|
628
|
-
preset =
|
|
616
|
+
preset = configReader.getDefaultPreset();
|
|
629
617
|
}
|
|
630
618
|
// Validate preset exists
|
|
631
619
|
if (!preset) {
|
|
@@ -650,12 +638,7 @@ export class SessionManager extends EventEmitter {
|
|
|
650
638
|
];
|
|
651
639
|
// Spawn the process within devcontainer
|
|
652
640
|
const ptyProcess = await this.spawn(devcontainerCmd, fullArgs, worktreePath);
|
|
653
|
-
|
|
654
|
-
command: preset.command,
|
|
655
|
-
args: preset.args,
|
|
656
|
-
fallbackArgs: preset.fallbackArgs,
|
|
657
|
-
};
|
|
658
|
-
return this.createSessionInternal(worktreePath, ptyProcess, commandConfig, {
|
|
641
|
+
return this.createSessionInternal(worktreePath, ptyProcess, {
|
|
659
642
|
isPrimaryCommand: true,
|
|
660
643
|
detectionStrategy: preset.detectionStrategy,
|
|
661
644
|
devcontainerConfig,
|