ccmanager 2.8.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.
Files changed (77) hide show
  1. package/dist/cli.test.js +13 -2
  2. package/dist/components/App.js +125 -50
  3. package/dist/components/App.test.js +270 -0
  4. package/dist/components/ConfigureShortcuts.js +82 -8
  5. package/dist/components/DeleteWorktree.js +39 -5
  6. package/dist/components/DeleteWorktree.test.d.ts +1 -0
  7. package/dist/components/DeleteWorktree.test.js +128 -0
  8. package/dist/components/LoadingSpinner.d.ts +8 -0
  9. package/dist/components/LoadingSpinner.js +37 -0
  10. package/dist/components/LoadingSpinner.test.d.ts +1 -0
  11. package/dist/components/LoadingSpinner.test.js +187 -0
  12. package/dist/components/Menu.js +64 -16
  13. package/dist/components/Menu.recent-projects.test.js +32 -11
  14. package/dist/components/Menu.test.js +136 -4
  15. package/dist/components/MergeWorktree.js +79 -18
  16. package/dist/components/MergeWorktree.test.d.ts +1 -0
  17. package/dist/components/MergeWorktree.test.js +227 -0
  18. package/dist/components/NewWorktree.js +88 -9
  19. package/dist/components/NewWorktree.test.d.ts +1 -0
  20. package/dist/components/NewWorktree.test.js +244 -0
  21. package/dist/components/ProjectList.js +44 -13
  22. package/dist/components/ProjectList.recent-projects.test.js +8 -3
  23. package/dist/components/ProjectList.test.js +105 -8
  24. package/dist/components/RemoteBranchSelector.test.js +3 -1
  25. package/dist/hooks/useGitStatus.d.ts +11 -0
  26. package/dist/hooks/useGitStatus.js +70 -12
  27. package/dist/hooks/useGitStatus.test.js +30 -23
  28. package/dist/services/configurationManager.d.ts +75 -0
  29. package/dist/services/configurationManager.effect.test.d.ts +1 -0
  30. package/dist/services/configurationManager.effect.test.js +407 -0
  31. package/dist/services/configurationManager.js +246 -0
  32. package/dist/services/globalSessionOrchestrator.test.js +0 -8
  33. package/dist/services/projectManager.d.ts +98 -2
  34. package/dist/services/projectManager.js +228 -59
  35. package/dist/services/projectManager.test.js +242 -2
  36. package/dist/services/sessionManager.d.ts +44 -2
  37. package/dist/services/sessionManager.effect.test.d.ts +1 -0
  38. package/dist/services/sessionManager.effect.test.js +321 -0
  39. package/dist/services/sessionManager.js +216 -65
  40. package/dist/services/sessionManager.statePersistence.test.js +18 -9
  41. package/dist/services/sessionManager.test.js +40 -36
  42. package/dist/services/worktreeService.d.ts +356 -26
  43. package/dist/services/worktreeService.js +793 -353
  44. package/dist/services/worktreeService.test.js +294 -313
  45. package/dist/types/errors.d.ts +74 -0
  46. package/dist/types/errors.js +31 -0
  47. package/dist/types/errors.test.d.ts +1 -0
  48. package/dist/types/errors.test.js +201 -0
  49. package/dist/types/index.d.ts +5 -17
  50. package/dist/utils/claudeDir.d.ts +58 -6
  51. package/dist/utils/claudeDir.js +103 -8
  52. package/dist/utils/claudeDir.test.d.ts +1 -0
  53. package/dist/utils/claudeDir.test.js +108 -0
  54. package/dist/utils/concurrencyLimit.d.ts +5 -0
  55. package/dist/utils/concurrencyLimit.js +11 -0
  56. package/dist/utils/concurrencyLimit.test.js +40 -1
  57. package/dist/utils/gitStatus.d.ts +36 -8
  58. package/dist/utils/gitStatus.js +170 -88
  59. package/dist/utils/gitStatus.test.js +12 -9
  60. package/dist/utils/hookExecutor.d.ts +41 -6
  61. package/dist/utils/hookExecutor.js +75 -32
  62. package/dist/utils/hookExecutor.test.js +73 -20
  63. package/dist/utils/terminalCapabilities.d.ts +18 -0
  64. package/dist/utils/terminalCapabilities.js +81 -0
  65. package/dist/utils/terminalCapabilities.test.d.ts +1 -0
  66. package/dist/utils/terminalCapabilities.test.js +104 -0
  67. package/dist/utils/testHelpers.d.ts +106 -0
  68. package/dist/utils/testHelpers.js +153 -0
  69. package/dist/utils/testHelpers.test.d.ts +1 -0
  70. package/dist/utils/testHelpers.test.js +114 -0
  71. package/dist/utils/worktreeConfig.d.ts +77 -2
  72. package/dist/utils/worktreeConfig.js +156 -16
  73. package/dist/utils/worktreeConfig.test.d.ts +1 -0
  74. package/dist/utils/worktreeConfig.test.js +39 -0
  75. package/package.json +4 -4
  76. package/dist/integration-tests/devcontainer.integration.test.js +0 -101
  77. /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
  });