ccmanager 2.7.0 → 2.9.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/dist/cli.test.js +13 -2
- package/dist/components/App.js +125 -50
- package/dist/components/App.test.js +270 -0
- package/dist/components/ConfigureShortcuts.js +82 -8
- package/dist/components/DeleteWorktree.js +39 -5
- package/dist/components/DeleteWorktree.test.d.ts +1 -0
- package/dist/components/DeleteWorktree.test.js +128 -0
- package/dist/components/LoadingSpinner.d.ts +8 -0
- package/dist/components/LoadingSpinner.js +37 -0
- package/dist/components/LoadingSpinner.test.d.ts +1 -0
- package/dist/components/LoadingSpinner.test.js +187 -0
- package/dist/components/Menu.js +64 -16
- package/dist/components/Menu.recent-projects.test.js +32 -11
- package/dist/components/Menu.test.js +136 -4
- package/dist/components/MergeWorktree.js +79 -18
- package/dist/components/MergeWorktree.test.d.ts +1 -0
- package/dist/components/MergeWorktree.test.js +227 -0
- package/dist/components/NewWorktree.js +88 -9
- package/dist/components/NewWorktree.test.d.ts +1 -0
- package/dist/components/NewWorktree.test.js +244 -0
- package/dist/components/ProjectList.js +44 -13
- package/dist/components/ProjectList.recent-projects.test.js +8 -3
- package/dist/components/ProjectList.test.js +105 -8
- package/dist/components/RemoteBranchSelector.test.js +3 -1
- package/dist/hooks/useGitStatus.d.ts +11 -0
- package/dist/hooks/useGitStatus.js +70 -12
- package/dist/hooks/useGitStatus.test.js +30 -23
- package/dist/services/configurationManager.d.ts +75 -0
- package/dist/services/configurationManager.effect.test.d.ts +1 -0
- package/dist/services/configurationManager.effect.test.js +407 -0
- package/dist/services/configurationManager.js +246 -0
- package/dist/services/globalSessionOrchestrator.test.js +0 -8
- package/dist/services/projectManager.d.ts +98 -2
- package/dist/services/projectManager.js +228 -59
- package/dist/services/projectManager.test.js +242 -2
- package/dist/services/sessionManager.d.ts +44 -2
- package/dist/services/sessionManager.effect.test.d.ts +1 -0
- package/dist/services/sessionManager.effect.test.js +321 -0
- package/dist/services/sessionManager.js +216 -65
- package/dist/services/sessionManager.statePersistence.test.js +18 -9
- package/dist/services/sessionManager.test.js +40 -36
- package/dist/services/worktreeService.d.ts +356 -26
- package/dist/services/worktreeService.js +793 -353
- package/dist/services/worktreeService.test.js +294 -313
- package/dist/types/errors.d.ts +74 -0
- package/dist/types/errors.js +31 -0
- package/dist/types/errors.test.d.ts +1 -0
- package/dist/types/errors.test.js +201 -0
- package/dist/types/index.d.ts +5 -17
- package/dist/utils/claudeDir.d.ts +58 -6
- package/dist/utils/claudeDir.js +103 -8
- package/dist/utils/claudeDir.test.d.ts +1 -0
- package/dist/utils/claudeDir.test.js +108 -0
- package/dist/utils/concurrencyLimit.d.ts +5 -0
- package/dist/utils/concurrencyLimit.js +11 -0
- package/dist/utils/concurrencyLimit.test.js +40 -1
- package/dist/utils/gitStatus.d.ts +36 -8
- package/dist/utils/gitStatus.js +170 -88
- package/dist/utils/gitStatus.test.js +12 -9
- package/dist/utils/hookExecutor.d.ts +41 -6
- package/dist/utils/hookExecutor.js +75 -32
- package/dist/utils/hookExecutor.test.js +73 -20
- package/dist/utils/terminalCapabilities.d.ts +18 -0
- package/dist/utils/terminalCapabilities.js +81 -0
- package/dist/utils/terminalCapabilities.test.d.ts +1 -0
- package/dist/utils/terminalCapabilities.test.js +104 -0
- package/dist/utils/testHelpers.d.ts +106 -0
- package/dist/utils/testHelpers.js +153 -0
- package/dist/utils/testHelpers.test.d.ts +1 -0
- package/dist/utils/testHelpers.test.js +114 -0
- package/dist/utils/worktreeConfig.d.ts +77 -2
- package/dist/utils/worktreeConfig.js +156 -16
- package/dist/utils/worktreeConfig.test.d.ts +1 -0
- package/dist/utils/worktreeConfig.test.js +39 -0
- package/package.json +4 -4
- package/dist/integration-tests/devcontainer.integration.test.js +0 -101
- /package/dist/{integration-tests/devcontainer.integration.test.d.ts → components/App.test.d.ts} +0 -0
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import { Effect, Either } from 'effect';
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
4
|
+
import { ConfigurationManager } from './configurationManager.js';
|
|
5
|
+
// Mock fs module
|
|
6
|
+
vi.mock('fs', () => ({
|
|
7
|
+
existsSync: vi.fn(),
|
|
8
|
+
mkdirSync: vi.fn(),
|
|
9
|
+
readFileSync: vi.fn(),
|
|
10
|
+
writeFileSync: vi.fn(),
|
|
11
|
+
}));
|
|
12
|
+
// Mock os module
|
|
13
|
+
vi.mock('os', () => ({
|
|
14
|
+
homedir: vi.fn(() => '/home/test'),
|
|
15
|
+
}));
|
|
16
|
+
describe('ConfigurationManager - Effect-based operations', () => {
|
|
17
|
+
let configManager;
|
|
18
|
+
let mockConfigData;
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
// Reset all mocks
|
|
21
|
+
vi.clearAllMocks();
|
|
22
|
+
// Default mock config data
|
|
23
|
+
mockConfigData = {
|
|
24
|
+
shortcuts: {
|
|
25
|
+
returnToMenu: { ctrl: true, key: 'e' },
|
|
26
|
+
cancel: { key: 'escape' },
|
|
27
|
+
},
|
|
28
|
+
command: {
|
|
29
|
+
command: 'claude',
|
|
30
|
+
args: ['--existing'],
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
// Mock file system operations
|
|
34
|
+
existsSync.mockImplementation((path) => {
|
|
35
|
+
return path.includes('config.json');
|
|
36
|
+
});
|
|
37
|
+
readFileSync.mockImplementation(() => {
|
|
38
|
+
return JSON.stringify(mockConfigData);
|
|
39
|
+
});
|
|
40
|
+
mkdirSync.mockImplementation(() => { });
|
|
41
|
+
writeFileSync.mockImplementation(() => { });
|
|
42
|
+
// Create new instance for each test
|
|
43
|
+
configManager = new ConfigurationManager();
|
|
44
|
+
});
|
|
45
|
+
afterEach(() => {
|
|
46
|
+
vi.resetAllMocks();
|
|
47
|
+
});
|
|
48
|
+
describe('loadConfigEffect', () => {
|
|
49
|
+
it('should return Effect with ConfigurationData on success', async () => {
|
|
50
|
+
const result = await Effect.runPromise(configManager.loadConfigEffect());
|
|
51
|
+
expect(result).toBeDefined();
|
|
52
|
+
expect(result.shortcuts).toBeDefined();
|
|
53
|
+
expect(result.command).toBeDefined();
|
|
54
|
+
});
|
|
55
|
+
it('should fail with FileSystemError when file read fails', async () => {
|
|
56
|
+
readFileSync.mockImplementation(() => {
|
|
57
|
+
throw new Error('EACCES: permission denied');
|
|
58
|
+
});
|
|
59
|
+
configManager = new ConfigurationManager();
|
|
60
|
+
const result = await Effect.runPromise(Effect.either(configManager.loadConfigEffect()));
|
|
61
|
+
expect(Either.isLeft(result)).toBe(true);
|
|
62
|
+
if (Either.isLeft(result)) {
|
|
63
|
+
expect(result.left._tag).toBe('FileSystemError');
|
|
64
|
+
expect(result.left.operation).toBe('read');
|
|
65
|
+
expect(result.left.cause).toContain('permission denied');
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
it('should fail with ConfigError when JSON parsing fails', async () => {
|
|
69
|
+
readFileSync.mockImplementation(() => {
|
|
70
|
+
return 'invalid json{';
|
|
71
|
+
});
|
|
72
|
+
configManager = new ConfigurationManager();
|
|
73
|
+
const result = await Effect.runPromise(Effect.either(configManager.loadConfigEffect()));
|
|
74
|
+
expect(Either.isLeft(result)).toBe(true);
|
|
75
|
+
if (Either.isLeft(result)) {
|
|
76
|
+
expect(result.left._tag).toBe('ConfigError');
|
|
77
|
+
expect(result.left.reason).toBe('parse');
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
it('should migrate legacy shortcuts and return success', async () => {
|
|
81
|
+
existsSync.mockImplementation((path) => {
|
|
82
|
+
if (path.includes('shortcuts.json'))
|
|
83
|
+
return true;
|
|
84
|
+
if (path.includes('config.json'))
|
|
85
|
+
return false;
|
|
86
|
+
return true;
|
|
87
|
+
});
|
|
88
|
+
const legacyShortcuts = {
|
|
89
|
+
returnToMenu: { ctrl: true, key: 'b' },
|
|
90
|
+
};
|
|
91
|
+
readFileSync.mockImplementation((path) => {
|
|
92
|
+
if (path.includes('shortcuts.json')) {
|
|
93
|
+
return JSON.stringify(legacyShortcuts);
|
|
94
|
+
}
|
|
95
|
+
return '{}';
|
|
96
|
+
});
|
|
97
|
+
configManager = new ConfigurationManager();
|
|
98
|
+
const result = await Effect.runPromise(configManager.loadConfigEffect());
|
|
99
|
+
expect(result.shortcuts).toEqual(legacyShortcuts);
|
|
100
|
+
expect(writeFileSync).toHaveBeenCalled();
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
describe('saveConfigEffect', () => {
|
|
104
|
+
it('should return Effect<void> on successful save', async () => {
|
|
105
|
+
const newConfig = {
|
|
106
|
+
shortcuts: {
|
|
107
|
+
returnToMenu: { ctrl: true, key: 'z' },
|
|
108
|
+
cancel: { key: 'escape' },
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
await Effect.runPromise(configManager.saveConfigEffect(newConfig));
|
|
112
|
+
expect(writeFileSync).toHaveBeenCalledWith(expect.stringContaining('config.json'), expect.any(String));
|
|
113
|
+
});
|
|
114
|
+
it('should fail with FileSystemError when file write fails', async () => {
|
|
115
|
+
writeFileSync.mockImplementation(() => {
|
|
116
|
+
throw new Error('ENOSPC: no space left on device');
|
|
117
|
+
});
|
|
118
|
+
const newConfig = {
|
|
119
|
+
shortcuts: {
|
|
120
|
+
returnToMenu: { ctrl: true, key: 'z' },
|
|
121
|
+
cancel: { key: 'escape' },
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
const result = await Effect.runPromise(Effect.either(configManager.saveConfigEffect(newConfig)));
|
|
125
|
+
expect(Either.isLeft(result)).toBe(true);
|
|
126
|
+
if (Either.isLeft(result)) {
|
|
127
|
+
expect(result.left._tag).toBe('FileSystemError');
|
|
128
|
+
expect(result.left.operation).toBe('write');
|
|
129
|
+
expect(result.left.cause).toContain('no space left on device');
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
describe('validateConfig', () => {
|
|
134
|
+
it('should return Right with valid ConfigurationData', () => {
|
|
135
|
+
const validConfig = {
|
|
136
|
+
shortcuts: {
|
|
137
|
+
returnToMenu: { ctrl: true, key: 'e' },
|
|
138
|
+
cancel: { key: 'escape' },
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
const result = configManager.validateConfig(validConfig);
|
|
142
|
+
expect(Either.isRight(result)).toBe(true);
|
|
143
|
+
if (Either.isRight(result)) {
|
|
144
|
+
expect(result.right).toEqual(validConfig);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
it('should return Left with ValidationError for invalid config', () => {
|
|
148
|
+
const invalidConfig = {
|
|
149
|
+
shortcuts: 'not an object',
|
|
150
|
+
};
|
|
151
|
+
const result = configManager.validateConfig(invalidConfig);
|
|
152
|
+
expect(Either.isLeft(result)).toBe(true);
|
|
153
|
+
if (Either.isLeft(result)) {
|
|
154
|
+
const error = result.left;
|
|
155
|
+
expect(error._tag).toBe('ValidationError');
|
|
156
|
+
expect(error.field).toBe('config');
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
it('should return Left for null config', () => {
|
|
160
|
+
const result = configManager.validateConfig(null);
|
|
161
|
+
expect(Either.isLeft(result)).toBe(true);
|
|
162
|
+
if (Either.isLeft(result)) {
|
|
163
|
+
const error = result.left;
|
|
164
|
+
expect(error._tag).toBe('ValidationError');
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
describe('getPresetByIdEffect', () => {
|
|
169
|
+
it('should return Right with preset when found', () => {
|
|
170
|
+
mockConfigData.commandPresets = {
|
|
171
|
+
presets: [
|
|
172
|
+
{ id: '1', name: 'Main', command: 'claude' },
|
|
173
|
+
{ id: '2', name: 'Custom', command: 'claude', args: ['--custom'] },
|
|
174
|
+
],
|
|
175
|
+
defaultPresetId: '1',
|
|
176
|
+
};
|
|
177
|
+
configManager = new ConfigurationManager();
|
|
178
|
+
const result = configManager.getPresetByIdEffect('2');
|
|
179
|
+
expect(Either.isRight(result)).toBe(true);
|
|
180
|
+
if (Either.isRight(result)) {
|
|
181
|
+
expect(result.right).toEqual({
|
|
182
|
+
id: '2',
|
|
183
|
+
name: 'Custom',
|
|
184
|
+
command: 'claude',
|
|
185
|
+
args: ['--custom'],
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
it('should return Left with ValidationError when preset not found', () => {
|
|
190
|
+
mockConfigData.commandPresets = {
|
|
191
|
+
presets: [{ id: '1', name: 'Main', command: 'claude' }],
|
|
192
|
+
defaultPresetId: '1',
|
|
193
|
+
};
|
|
194
|
+
configManager = new ConfigurationManager();
|
|
195
|
+
const result = configManager.getPresetByIdEffect('999');
|
|
196
|
+
expect(Either.isLeft(result)).toBe(true);
|
|
197
|
+
if (Either.isLeft(result)) {
|
|
198
|
+
const error = result.left;
|
|
199
|
+
expect(error._tag).toBe('ValidationError');
|
|
200
|
+
expect(error.field).toBe('presetId');
|
|
201
|
+
expect(error.receivedValue).toBe('999');
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
it('should include constraint in ValidationError', () => {
|
|
205
|
+
mockConfigData.commandPresets = {
|
|
206
|
+
presets: [{ id: '1', name: 'Main', command: 'claude' }],
|
|
207
|
+
defaultPresetId: '1',
|
|
208
|
+
};
|
|
209
|
+
configManager = new ConfigurationManager();
|
|
210
|
+
const result = configManager.getPresetByIdEffect('invalid-id');
|
|
211
|
+
expect(Either.isLeft(result)).toBe(true);
|
|
212
|
+
if (Either.isLeft(result)) {
|
|
213
|
+
const error = result.left;
|
|
214
|
+
expect(error.constraint).toContain('not found');
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
describe('setShortcutsEffect', () => {
|
|
219
|
+
it('should return Effect<void> on successful update', async () => {
|
|
220
|
+
const newShortcuts = {
|
|
221
|
+
returnToMenu: { ctrl: true, key: 'z' },
|
|
222
|
+
cancel: { key: 'escape' },
|
|
223
|
+
};
|
|
224
|
+
await Effect.runPromise(configManager.setShortcutsEffect(newShortcuts));
|
|
225
|
+
expect(writeFileSync).toHaveBeenCalled();
|
|
226
|
+
});
|
|
227
|
+
it('should fail with FileSystemError when save fails', async () => {
|
|
228
|
+
writeFileSync.mockImplementation(() => {
|
|
229
|
+
throw new Error('Write failed');
|
|
230
|
+
});
|
|
231
|
+
const newShortcuts = {
|
|
232
|
+
returnToMenu: { ctrl: true, key: 'z' },
|
|
233
|
+
cancel: { key: 'escape' },
|
|
234
|
+
};
|
|
235
|
+
const result = await Effect.runPromise(Effect.either(configManager.setShortcutsEffect(newShortcuts)));
|
|
236
|
+
expect(Either.isLeft(result)).toBe(true);
|
|
237
|
+
if (Either.isLeft(result)) {
|
|
238
|
+
expect(result.left._tag).toBe('FileSystemError');
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
describe('setCommandPresetsEffect', () => {
|
|
243
|
+
it('should return Effect<void> on successful update', async () => {
|
|
244
|
+
const newPresets = {
|
|
245
|
+
presets: [
|
|
246
|
+
{ id: '1', name: 'Main', command: 'claude' },
|
|
247
|
+
{ id: '2', name: 'Custom', command: 'claude', args: ['--custom'] },
|
|
248
|
+
],
|
|
249
|
+
defaultPresetId: '2',
|
|
250
|
+
};
|
|
251
|
+
await Effect.runPromise(configManager.setCommandPresetsEffect(newPresets));
|
|
252
|
+
expect(writeFileSync).toHaveBeenCalled();
|
|
253
|
+
});
|
|
254
|
+
it('should fail with FileSystemError when save fails', async () => {
|
|
255
|
+
writeFileSync.mockImplementation(() => {
|
|
256
|
+
throw new Error('Disk full');
|
|
257
|
+
});
|
|
258
|
+
const newPresets = {
|
|
259
|
+
presets: [{ id: '1', name: 'Main', command: 'claude' }],
|
|
260
|
+
defaultPresetId: '1',
|
|
261
|
+
};
|
|
262
|
+
const result = await Effect.runPromise(Effect.either(configManager.setCommandPresetsEffect(newPresets)));
|
|
263
|
+
expect(Either.isLeft(result)).toBe(true);
|
|
264
|
+
if (Either.isLeft(result)) {
|
|
265
|
+
expect(result.left._tag).toBe('FileSystemError');
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
describe('addPresetEffect', () => {
|
|
270
|
+
it('should add new preset and return Effect<void>', async () => {
|
|
271
|
+
mockConfigData.commandPresets = {
|
|
272
|
+
presets: [{ id: '1', name: 'Main', command: 'claude' }],
|
|
273
|
+
defaultPresetId: '1',
|
|
274
|
+
};
|
|
275
|
+
configManager = new ConfigurationManager();
|
|
276
|
+
const newPreset = {
|
|
277
|
+
id: '2',
|
|
278
|
+
name: 'New',
|
|
279
|
+
command: 'claude',
|
|
280
|
+
args: ['--new'],
|
|
281
|
+
};
|
|
282
|
+
await Effect.runPromise(configManager.addPresetEffect(newPreset));
|
|
283
|
+
expect(writeFileSync).toHaveBeenCalled();
|
|
284
|
+
});
|
|
285
|
+
it('should replace existing preset with same id', async () => {
|
|
286
|
+
mockConfigData.commandPresets = {
|
|
287
|
+
presets: [{ id: '1', name: 'Main', command: 'claude' }],
|
|
288
|
+
defaultPresetId: '1',
|
|
289
|
+
};
|
|
290
|
+
configManager = new ConfigurationManager();
|
|
291
|
+
const updatedPreset = {
|
|
292
|
+
id: '1',
|
|
293
|
+
name: 'Updated',
|
|
294
|
+
command: 'claude',
|
|
295
|
+
args: ['--updated'],
|
|
296
|
+
};
|
|
297
|
+
await Effect.runPromise(configManager.addPresetEffect(updatedPreset));
|
|
298
|
+
expect(writeFileSync).toHaveBeenCalled();
|
|
299
|
+
});
|
|
300
|
+
it('should fail with FileSystemError when save fails', async () => {
|
|
301
|
+
writeFileSync.mockImplementation(() => {
|
|
302
|
+
throw new Error('Save failed');
|
|
303
|
+
});
|
|
304
|
+
const newPreset = {
|
|
305
|
+
id: '2',
|
|
306
|
+
name: 'New',
|
|
307
|
+
command: 'claude',
|
|
308
|
+
};
|
|
309
|
+
const result = await Effect.runPromise(Effect.either(configManager.addPresetEffect(newPreset)));
|
|
310
|
+
expect(Either.isLeft(result)).toBe(true);
|
|
311
|
+
if (Either.isLeft(result)) {
|
|
312
|
+
expect(result.left._tag).toBe('FileSystemError');
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
describe('deletePresetEffect', () => {
|
|
317
|
+
it('should delete preset and return Effect<void>', async () => {
|
|
318
|
+
mockConfigData.commandPresets = {
|
|
319
|
+
presets: [
|
|
320
|
+
{ id: '1', name: 'Main', command: 'claude' },
|
|
321
|
+
{ id: '2', name: 'Custom', command: 'claude' },
|
|
322
|
+
],
|
|
323
|
+
defaultPresetId: '1',
|
|
324
|
+
};
|
|
325
|
+
configManager = new ConfigurationManager();
|
|
326
|
+
await Effect.runPromise(configManager.deletePresetEffect('2'));
|
|
327
|
+
expect(writeFileSync).toHaveBeenCalled();
|
|
328
|
+
});
|
|
329
|
+
it('should fail with ValidationError when deleting last preset', async () => {
|
|
330
|
+
mockConfigData.commandPresets = {
|
|
331
|
+
presets: [{ id: '1', name: 'Main', command: 'claude' }],
|
|
332
|
+
defaultPresetId: '1',
|
|
333
|
+
};
|
|
334
|
+
configManager = new ConfigurationManager();
|
|
335
|
+
const result = await Effect.runPromise(Effect.either(configManager.deletePresetEffect('1')));
|
|
336
|
+
expect(Either.isLeft(result)).toBe(true);
|
|
337
|
+
if (Either.isLeft(result)) {
|
|
338
|
+
expect(result.left._tag).toBe('ValidationError');
|
|
339
|
+
expect(result.left.field).toBe('presetId');
|
|
340
|
+
expect(result.left.constraint).toContain('Cannot delete last preset');
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
it('should fail with FileSystemError when save fails', async () => {
|
|
344
|
+
mockConfigData.commandPresets = {
|
|
345
|
+
presets: [
|
|
346
|
+
{ id: '1', name: 'Main', command: 'claude' },
|
|
347
|
+
{ id: '2', name: 'Custom', command: 'claude' },
|
|
348
|
+
],
|
|
349
|
+
defaultPresetId: '1',
|
|
350
|
+
};
|
|
351
|
+
configManager = new ConfigurationManager();
|
|
352
|
+
writeFileSync.mockImplementation(() => {
|
|
353
|
+
throw new Error('Save failed');
|
|
354
|
+
});
|
|
355
|
+
const result = await Effect.runPromise(Effect.either(configManager.deletePresetEffect('2')));
|
|
356
|
+
expect(Either.isLeft(result)).toBe(true);
|
|
357
|
+
if (Either.isLeft(result)) {
|
|
358
|
+
expect(result.left._tag).toBe('FileSystemError');
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
describe('setDefaultPresetEffect', () => {
|
|
363
|
+
it('should update default preset and return Effect<void>', async () => {
|
|
364
|
+
mockConfigData.commandPresets = {
|
|
365
|
+
presets: [
|
|
366
|
+
{ id: '1', name: 'Main', command: 'claude' },
|
|
367
|
+
{ id: '2', name: 'Custom', command: 'claude' },
|
|
368
|
+
],
|
|
369
|
+
defaultPresetId: '1',
|
|
370
|
+
};
|
|
371
|
+
configManager = new ConfigurationManager();
|
|
372
|
+
await Effect.runPromise(configManager.setDefaultPresetEffect('2'));
|
|
373
|
+
expect(writeFileSync).toHaveBeenCalled();
|
|
374
|
+
});
|
|
375
|
+
it('should fail with ValidationError when preset id does not exist', async () => {
|
|
376
|
+
mockConfigData.commandPresets = {
|
|
377
|
+
presets: [{ id: '1', name: 'Main', command: 'claude' }],
|
|
378
|
+
defaultPresetId: '1',
|
|
379
|
+
};
|
|
380
|
+
configManager = new ConfigurationManager();
|
|
381
|
+
const result = await Effect.runPromise(Effect.either(configManager.setDefaultPresetEffect('999')));
|
|
382
|
+
expect(Either.isLeft(result)).toBe(true);
|
|
383
|
+
if (Either.isLeft(result)) {
|
|
384
|
+
expect(result.left._tag).toBe('ValidationError');
|
|
385
|
+
expect(result.left.field).toBe('presetId');
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
it('should fail with FileSystemError when save fails', async () => {
|
|
389
|
+
mockConfigData.commandPresets = {
|
|
390
|
+
presets: [
|
|
391
|
+
{ id: '1', name: 'Main', command: 'claude' },
|
|
392
|
+
{ id: '2', name: 'Custom', command: 'claude' },
|
|
393
|
+
],
|
|
394
|
+
defaultPresetId: '1',
|
|
395
|
+
};
|
|
396
|
+
configManager = new ConfigurationManager();
|
|
397
|
+
writeFileSync.mockImplementation(() => {
|
|
398
|
+
throw new Error('Save failed');
|
|
399
|
+
});
|
|
400
|
+
const result = await Effect.runPromise(Effect.either(configManager.setDefaultPresetEffect('2')));
|
|
401
|
+
expect(Either.isLeft(result)).toBe(true);
|
|
402
|
+
if (Either.isLeft(result)) {
|
|
403
|
+
expect(result.left._tag).toBe('FileSystemError');
|
|
404
|
+
}
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
});
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { homedir } from 'os';
|
|
2
2
|
import { join } from 'path';
|
|
3
3
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
4
|
+
import { Effect, Either } from 'effect';
|
|
4
5
|
import { DEFAULT_SHORTCUTS, } from '../types/index.js';
|
|
6
|
+
import { FileSystemError, ConfigError, ValidationError, } from '../types/errors.js';
|
|
5
7
|
export class ConfigurationManager {
|
|
6
8
|
constructor() {
|
|
7
9
|
Object.defineProperty(this, "configPath", {
|
|
@@ -268,5 +270,249 @@ export class ConfigurationManager {
|
|
|
268
270
|
presets.selectPresetOnStart = enabled;
|
|
269
271
|
this.setCommandPresets(presets);
|
|
270
272
|
}
|
|
273
|
+
// Effect-based methods for type-safe error handling
|
|
274
|
+
/**
|
|
275
|
+
* Load configuration from file with Effect-based error handling
|
|
276
|
+
*
|
|
277
|
+
* @returns {Effect.Effect<ConfigurationData, FileSystemError | ConfigError, never>} Configuration data on success, errors on failure
|
|
278
|
+
*
|
|
279
|
+
* @example
|
|
280
|
+
* ```typescript
|
|
281
|
+
* const result = await Effect.runPromise(
|
|
282
|
+
* configManager.loadConfigEffect()
|
|
283
|
+
* );
|
|
284
|
+
* ```
|
|
285
|
+
*/
|
|
286
|
+
loadConfigEffect() {
|
|
287
|
+
return Effect.try({
|
|
288
|
+
try: () => {
|
|
289
|
+
// Try to load the new config file
|
|
290
|
+
if (existsSync(this.configPath)) {
|
|
291
|
+
const configData = readFileSync(this.configPath, 'utf-8');
|
|
292
|
+
const parsedConfig = JSON.parse(configData);
|
|
293
|
+
return this.applyDefaults(parsedConfig);
|
|
294
|
+
}
|
|
295
|
+
else {
|
|
296
|
+
// If new config doesn't exist, check for legacy shortcuts.json
|
|
297
|
+
const migratedConfig = this.migrateLegacyShortcutsSync();
|
|
298
|
+
return this.applyDefaults(migratedConfig || {});
|
|
299
|
+
}
|
|
300
|
+
},
|
|
301
|
+
catch: (error) => {
|
|
302
|
+
// Determine error type
|
|
303
|
+
if (error instanceof SyntaxError) {
|
|
304
|
+
return new ConfigError({
|
|
305
|
+
configPath: this.configPath,
|
|
306
|
+
reason: 'parse',
|
|
307
|
+
details: String(error),
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
return new FileSystemError({
|
|
311
|
+
operation: 'read',
|
|
312
|
+
path: this.configPath,
|
|
313
|
+
cause: String(error),
|
|
314
|
+
});
|
|
315
|
+
},
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Save configuration to file with Effect-based error handling
|
|
320
|
+
*
|
|
321
|
+
* @returns {Effect.Effect<void, FileSystemError, never>} Void on success, FileSystemError on write failure
|
|
322
|
+
*
|
|
323
|
+
* @example
|
|
324
|
+
* ```typescript
|
|
325
|
+
* await Effect.runPromise(
|
|
326
|
+
* configManager.saveConfigEffect(config)
|
|
327
|
+
* );
|
|
328
|
+
* ```
|
|
329
|
+
*/
|
|
330
|
+
saveConfigEffect(config) {
|
|
331
|
+
return Effect.try({
|
|
332
|
+
try: () => {
|
|
333
|
+
this.config = config;
|
|
334
|
+
writeFileSync(this.configPath, JSON.stringify(this.config, null, 2));
|
|
335
|
+
},
|
|
336
|
+
catch: (error) => {
|
|
337
|
+
return new FileSystemError({
|
|
338
|
+
operation: 'write',
|
|
339
|
+
path: this.configPath,
|
|
340
|
+
cause: String(error),
|
|
341
|
+
});
|
|
342
|
+
},
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Validate configuration structure
|
|
347
|
+
* Synchronous validation using Either
|
|
348
|
+
*/
|
|
349
|
+
validateConfig(config) {
|
|
350
|
+
if (!config || typeof config !== 'object') {
|
|
351
|
+
return Either.left(new ValidationError({
|
|
352
|
+
field: 'config',
|
|
353
|
+
constraint: 'must be a valid configuration object',
|
|
354
|
+
receivedValue: config,
|
|
355
|
+
}));
|
|
356
|
+
}
|
|
357
|
+
// Validate shortcuts field if present
|
|
358
|
+
const configObj = config;
|
|
359
|
+
if (configObj['shortcuts'] !== undefined &&
|
|
360
|
+
(typeof configObj['shortcuts'] !== 'object' ||
|
|
361
|
+
configObj['shortcuts'] === null)) {
|
|
362
|
+
return Either.left(new ValidationError({
|
|
363
|
+
field: 'config',
|
|
364
|
+
constraint: 'shortcuts must be a valid object',
|
|
365
|
+
receivedValue: config,
|
|
366
|
+
}));
|
|
367
|
+
}
|
|
368
|
+
// Additional validation could go here
|
|
369
|
+
return Either.right(config);
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Get preset by ID with Either-based error handling
|
|
373
|
+
* Synchronous lookup using Either
|
|
374
|
+
*/
|
|
375
|
+
getPresetByIdEffect(id) {
|
|
376
|
+
const presets = this.getCommandPresets();
|
|
377
|
+
const preset = presets.presets.find(p => p.id === id);
|
|
378
|
+
if (!preset) {
|
|
379
|
+
return Either.left(new ValidationError({
|
|
380
|
+
field: 'presetId',
|
|
381
|
+
constraint: 'Preset not found',
|
|
382
|
+
receivedValue: id,
|
|
383
|
+
}));
|
|
384
|
+
}
|
|
385
|
+
return Either.right(preset);
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Set shortcuts with Effect-based error handling
|
|
389
|
+
*
|
|
390
|
+
* @returns {Effect.Effect<void, FileSystemError, never>} Void on success, FileSystemError on save failure
|
|
391
|
+
*
|
|
392
|
+
* @example
|
|
393
|
+
* ```typescript
|
|
394
|
+
* await Effect.runPromise(
|
|
395
|
+
* configManager.setShortcutsEffect(shortcuts)
|
|
396
|
+
* );
|
|
397
|
+
* ```
|
|
398
|
+
*/
|
|
399
|
+
setShortcutsEffect(shortcuts) {
|
|
400
|
+
this.config.shortcuts = shortcuts;
|
|
401
|
+
return this.saveConfigEffect(this.config);
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Set command presets with Effect-based error handling
|
|
405
|
+
*/
|
|
406
|
+
setCommandPresetsEffect(presets) {
|
|
407
|
+
this.config.commandPresets = presets;
|
|
408
|
+
return this.saveConfigEffect(this.config);
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Add or update preset with Effect-based error handling
|
|
412
|
+
*/
|
|
413
|
+
addPresetEffect(preset) {
|
|
414
|
+
const presets = this.getCommandPresets();
|
|
415
|
+
// Replace if exists, otherwise add
|
|
416
|
+
const existingIndex = presets.presets.findIndex(p => p.id === preset.id);
|
|
417
|
+
if (existingIndex >= 0) {
|
|
418
|
+
presets.presets[existingIndex] = preset;
|
|
419
|
+
}
|
|
420
|
+
else {
|
|
421
|
+
presets.presets.push(preset);
|
|
422
|
+
}
|
|
423
|
+
return this.setCommandPresetsEffect(presets);
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Delete preset with Effect-based error handling
|
|
427
|
+
*/
|
|
428
|
+
deletePresetEffect(id) {
|
|
429
|
+
const presets = this.getCommandPresets();
|
|
430
|
+
// Don't delete if it's the last preset
|
|
431
|
+
if (presets.presets.length <= 1) {
|
|
432
|
+
return Effect.fail(new ValidationError({
|
|
433
|
+
field: 'presetId',
|
|
434
|
+
constraint: 'Cannot delete last preset',
|
|
435
|
+
receivedValue: id,
|
|
436
|
+
}));
|
|
437
|
+
}
|
|
438
|
+
// Remove the preset
|
|
439
|
+
presets.presets = presets.presets.filter(p => p.id !== id);
|
|
440
|
+
// Update default if needed
|
|
441
|
+
if (presets.defaultPresetId === id && presets.presets.length > 0) {
|
|
442
|
+
presets.defaultPresetId = presets.presets[0].id;
|
|
443
|
+
}
|
|
444
|
+
return this.setCommandPresetsEffect(presets);
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Set default preset with Effect-based error handling
|
|
448
|
+
*/
|
|
449
|
+
setDefaultPresetEffect(id) {
|
|
450
|
+
const presets = this.getCommandPresets();
|
|
451
|
+
// Only update if preset exists
|
|
452
|
+
if (!presets.presets.some(p => p.id === id)) {
|
|
453
|
+
return Effect.fail(new ValidationError({
|
|
454
|
+
field: 'presetId',
|
|
455
|
+
constraint: 'Preset not found',
|
|
456
|
+
receivedValue: id,
|
|
457
|
+
}));
|
|
458
|
+
}
|
|
459
|
+
presets.defaultPresetId = id;
|
|
460
|
+
return this.setCommandPresetsEffect(presets);
|
|
461
|
+
}
|
|
462
|
+
// Helper methods
|
|
463
|
+
/**
|
|
464
|
+
* Apply default values to configuration
|
|
465
|
+
*/
|
|
466
|
+
applyDefaults(config) {
|
|
467
|
+
// Ensure default values
|
|
468
|
+
if (!config.shortcuts) {
|
|
469
|
+
config.shortcuts = DEFAULT_SHORTCUTS;
|
|
470
|
+
}
|
|
471
|
+
if (!config.statusHooks) {
|
|
472
|
+
config.statusHooks = {};
|
|
473
|
+
}
|
|
474
|
+
if (!config.worktreeHooks) {
|
|
475
|
+
config.worktreeHooks = {};
|
|
476
|
+
}
|
|
477
|
+
if (!config.worktree) {
|
|
478
|
+
config.worktree = {
|
|
479
|
+
autoDirectory: false,
|
|
480
|
+
copySessionData: true,
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
if (!Object.prototype.hasOwnProperty.call(config.worktree, 'copySessionData')) {
|
|
484
|
+
config.worktree.copySessionData = true;
|
|
485
|
+
}
|
|
486
|
+
if (!config.command) {
|
|
487
|
+
config.command = {
|
|
488
|
+
command: 'claude',
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
return config;
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* Synchronous legacy shortcuts migration helper
|
|
495
|
+
*/
|
|
496
|
+
migrateLegacyShortcutsSync() {
|
|
497
|
+
if (existsSync(this.legacyShortcutsPath)) {
|
|
498
|
+
try {
|
|
499
|
+
const shortcutsData = readFileSync(this.legacyShortcutsPath, 'utf-8');
|
|
500
|
+
const shortcuts = JSON.parse(shortcutsData);
|
|
501
|
+
// Validate that it's a valid shortcuts config
|
|
502
|
+
if (shortcuts && typeof shortcuts === 'object') {
|
|
503
|
+
const config = { shortcuts };
|
|
504
|
+
// Save to new config format
|
|
505
|
+
this.config = config;
|
|
506
|
+
writeFileSync(this.configPath, JSON.stringify(this.config, null, 2));
|
|
507
|
+
console.log('Migrated shortcuts from legacy shortcuts.json to config.json');
|
|
508
|
+
return config;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
catch (error) {
|
|
512
|
+
console.error('Failed to migrate legacy shortcuts:', error);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
return null;
|
|
516
|
+
}
|
|
271
517
|
}
|
|
272
518
|
export const configurationManager = new ConfigurationManager();
|
|
@@ -35,14 +35,6 @@ vi.mock('./sessionManager.js', () => {
|
|
|
35
35
|
emit() {
|
|
36
36
|
// Mock implementation
|
|
37
37
|
}
|
|
38
|
-
async createSessionWithPreset(_worktreePath, _presetId) {
|
|
39
|
-
// Mock implementation
|
|
40
|
-
return {};
|
|
41
|
-
}
|
|
42
|
-
async createSessionWithDevcontainer(_worktreePath, _config, _presetId) {
|
|
43
|
-
// Mock implementation
|
|
44
|
-
return {};
|
|
45
|
-
}
|
|
46
38
|
}
|
|
47
39
|
return { SessionManager: MockSessionManager };
|
|
48
40
|
});
|