ccmanager 2.8.0 → 2.9.1
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/components/Session.js +11 -6
- 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/shortcutManager.d.ts +2 -0
- package/dist/services/shortcutManager.js +53 -0
- package/dist/services/shortcutManager.test.d.ts +1 -0
- package/dist/services/shortcutManager.test.js +30 -0
- 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,74 @@
|
|
|
1
|
+
declare const GitError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").Equals<A, {}> extends true ? void : { readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }) => import("effect/Cause").YieldableError & {
|
|
2
|
+
readonly _tag: "GitError";
|
|
3
|
+
} & Readonly<A>;
|
|
4
|
+
/**
|
|
5
|
+
* Git operation errors
|
|
6
|
+
* Used when git commands fail with non-zero exit codes
|
|
7
|
+
*/
|
|
8
|
+
export declare class GitError extends GitError_base<{
|
|
9
|
+
readonly command: string;
|
|
10
|
+
readonly exitCode: number;
|
|
11
|
+
readonly stderr: string;
|
|
12
|
+
readonly stdout?: string;
|
|
13
|
+
}> {
|
|
14
|
+
}
|
|
15
|
+
declare const FileSystemError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").Equals<A, {}> extends true ? void : { readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }) => import("effect/Cause").YieldableError & {
|
|
16
|
+
readonly _tag: "FileSystemError";
|
|
17
|
+
} & Readonly<A>;
|
|
18
|
+
/**
|
|
19
|
+
* File system operation errors
|
|
20
|
+
* Used when file system operations (read, write, delete, mkdir, stat) fail
|
|
21
|
+
*/
|
|
22
|
+
export declare class FileSystemError extends FileSystemError_base<{
|
|
23
|
+
readonly operation: 'read' | 'write' | 'delete' | 'mkdir' | 'stat';
|
|
24
|
+
readonly path: string;
|
|
25
|
+
readonly cause: string;
|
|
26
|
+
}> {
|
|
27
|
+
}
|
|
28
|
+
declare const ConfigError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").Equals<A, {}> extends true ? void : { readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }) => import("effect/Cause").YieldableError & {
|
|
29
|
+
readonly _tag: "ConfigError";
|
|
30
|
+
} & Readonly<A>;
|
|
31
|
+
/**
|
|
32
|
+
* Configuration errors
|
|
33
|
+
* Used when configuration operations fail (parsing, validation, migration)
|
|
34
|
+
*/
|
|
35
|
+
export declare class ConfigError extends ConfigError_base<{
|
|
36
|
+
readonly configPath: string;
|
|
37
|
+
readonly reason: 'parse' | 'validation' | 'missing' | 'migration';
|
|
38
|
+
readonly details: string;
|
|
39
|
+
}> {
|
|
40
|
+
}
|
|
41
|
+
declare const ProcessError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").Equals<A, {}> extends true ? void : { readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }) => import("effect/Cause").YieldableError & {
|
|
42
|
+
readonly _tag: "ProcessError";
|
|
43
|
+
} & Readonly<A>;
|
|
44
|
+
/**
|
|
45
|
+
* Process/PTY errors
|
|
46
|
+
* Used when process spawning or PTY operations fail
|
|
47
|
+
*/
|
|
48
|
+
export declare class ProcessError extends ProcessError_base<{
|
|
49
|
+
readonly processId?: number;
|
|
50
|
+
readonly command: string;
|
|
51
|
+
readonly signal?: string;
|
|
52
|
+
readonly exitCode?: number;
|
|
53
|
+
readonly message: string;
|
|
54
|
+
}> {
|
|
55
|
+
}
|
|
56
|
+
declare const ValidationError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").Equals<A, {}> extends true ? void : { readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }) => import("effect/Cause").YieldableError & {
|
|
57
|
+
readonly _tag: "ValidationError";
|
|
58
|
+
} & Readonly<A>;
|
|
59
|
+
/**
|
|
60
|
+
* Validation errors
|
|
61
|
+
* Used when input validation fails
|
|
62
|
+
*/
|
|
63
|
+
export declare class ValidationError extends ValidationError_base<{
|
|
64
|
+
readonly field: string;
|
|
65
|
+
readonly constraint: string;
|
|
66
|
+
readonly receivedValue: unknown;
|
|
67
|
+
}> {
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Union type for all application errors
|
|
71
|
+
* Enables discriminated union type narrowing using _tag property
|
|
72
|
+
*/
|
|
73
|
+
export type AppError = GitError | FileSystemError | ConfigError | ProcessError | ValidationError;
|
|
74
|
+
export {};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Data } from 'effect';
|
|
2
|
+
/**
|
|
3
|
+
* Git operation errors
|
|
4
|
+
* Used when git commands fail with non-zero exit codes
|
|
5
|
+
*/
|
|
6
|
+
export class GitError extends Data.TaggedError('GitError') {
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* File system operation errors
|
|
10
|
+
* Used when file system operations (read, write, delete, mkdir, stat) fail
|
|
11
|
+
*/
|
|
12
|
+
export class FileSystemError extends Data.TaggedError('FileSystemError') {
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Configuration errors
|
|
16
|
+
* Used when configuration operations fail (parsing, validation, migration)
|
|
17
|
+
*/
|
|
18
|
+
export class ConfigError extends Data.TaggedError('ConfigError') {
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Process/PTY errors
|
|
22
|
+
* Used when process spawning or PTY operations fail
|
|
23
|
+
*/
|
|
24
|
+
export class ProcessError extends Data.TaggedError('ProcessError') {
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Validation errors
|
|
28
|
+
* Used when input validation fails
|
|
29
|
+
*/
|
|
30
|
+
export class ValidationError extends Data.TaggedError('ValidationError') {
|
|
31
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
/**
|
|
3
|
+
* Tests for structured error types using Effect-ts Data.TaggedError
|
|
4
|
+
*/
|
|
5
|
+
describe('Error Types', () => {
|
|
6
|
+
describe('GitError', () => {
|
|
7
|
+
it('should create GitError with required fields', async () => {
|
|
8
|
+
const { GitError } = await import('./errors.js');
|
|
9
|
+
const error = new GitError({
|
|
10
|
+
command: 'git worktree add',
|
|
11
|
+
exitCode: 1,
|
|
12
|
+
stderr: 'fatal: invalid reference',
|
|
13
|
+
});
|
|
14
|
+
expect(error).toBeInstanceOf(Error);
|
|
15
|
+
expect(error._tag).toBe('GitError');
|
|
16
|
+
expect(error.command).toBe('git worktree add');
|
|
17
|
+
expect(error.exitCode).toBe(1);
|
|
18
|
+
expect(error.stderr).toBe('fatal: invalid reference');
|
|
19
|
+
});
|
|
20
|
+
it('should create GitError with optional stdout', async () => {
|
|
21
|
+
const { GitError } = await import('./errors.js');
|
|
22
|
+
const error = new GitError({
|
|
23
|
+
command: 'git status',
|
|
24
|
+
exitCode: 128,
|
|
25
|
+
stderr: 'not a git repository',
|
|
26
|
+
stdout: 'some output',
|
|
27
|
+
});
|
|
28
|
+
expect(error.stdout).toBe('some output');
|
|
29
|
+
});
|
|
30
|
+
it('should have stack trace', async () => {
|
|
31
|
+
const { GitError } = await import('./errors.js');
|
|
32
|
+
const error = new GitError({
|
|
33
|
+
command: 'git log',
|
|
34
|
+
exitCode: 1,
|
|
35
|
+
stderr: 'error',
|
|
36
|
+
});
|
|
37
|
+
expect(error.stack).toBeDefined();
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
describe('FileSystemError', () => {
|
|
41
|
+
it('should create FileSystemError with required fields', async () => {
|
|
42
|
+
const { FileSystemError } = await import('./errors.js');
|
|
43
|
+
const error = new FileSystemError({
|
|
44
|
+
operation: 'read',
|
|
45
|
+
path: '/tmp/config.json',
|
|
46
|
+
cause: 'ENOENT: no such file or directory',
|
|
47
|
+
});
|
|
48
|
+
expect(error).toBeInstanceOf(Error);
|
|
49
|
+
expect(error._tag).toBe('FileSystemError');
|
|
50
|
+
expect(error.operation).toBe('read');
|
|
51
|
+
expect(error.path).toBe('/tmp/config.json');
|
|
52
|
+
expect(error.cause).toBe('ENOENT: no such file or directory');
|
|
53
|
+
});
|
|
54
|
+
it('should support all operation types', async () => {
|
|
55
|
+
const { FileSystemError } = await import('./errors.js');
|
|
56
|
+
const operations = ['read', 'write', 'delete', 'mkdir', 'stat'];
|
|
57
|
+
for (const operation of operations) {
|
|
58
|
+
const error = new FileSystemError({
|
|
59
|
+
operation,
|
|
60
|
+
path: '/test',
|
|
61
|
+
cause: 'test',
|
|
62
|
+
});
|
|
63
|
+
expect(error.operation).toBe(operation);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
describe('ConfigError', () => {
|
|
68
|
+
it('should create ConfigError with required fields', async () => {
|
|
69
|
+
const { ConfigError } = await import('./errors.js');
|
|
70
|
+
const error = new ConfigError({
|
|
71
|
+
configPath: '~/.config/ccmanager/config.json',
|
|
72
|
+
reason: 'parse',
|
|
73
|
+
details: 'Unexpected token in JSON at position 42',
|
|
74
|
+
});
|
|
75
|
+
expect(error).toBeInstanceOf(Error);
|
|
76
|
+
expect(error._tag).toBe('ConfigError');
|
|
77
|
+
expect(error.configPath).toBe('~/.config/ccmanager/config.json');
|
|
78
|
+
expect(error.reason).toBe('parse');
|
|
79
|
+
expect(error.details).toBe('Unexpected token in JSON at position 42');
|
|
80
|
+
});
|
|
81
|
+
it('should support all reason types', async () => {
|
|
82
|
+
const { ConfigError } = await import('./errors.js');
|
|
83
|
+
const reasons = [
|
|
84
|
+
'parse',
|
|
85
|
+
'validation',
|
|
86
|
+
'missing',
|
|
87
|
+
'migration',
|
|
88
|
+
];
|
|
89
|
+
for (const reason of reasons) {
|
|
90
|
+
const error = new ConfigError({
|
|
91
|
+
configPath: '/test',
|
|
92
|
+
reason,
|
|
93
|
+
details: 'test',
|
|
94
|
+
});
|
|
95
|
+
expect(error.reason).toBe(reason);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
describe('ProcessError', () => {
|
|
100
|
+
it('should create ProcessError with required fields', async () => {
|
|
101
|
+
const { ProcessError } = await import('./errors.js');
|
|
102
|
+
const error = new ProcessError({
|
|
103
|
+
command: 'claude',
|
|
104
|
+
message: 'Failed to spawn process',
|
|
105
|
+
});
|
|
106
|
+
expect(error).toBeInstanceOf(Error);
|
|
107
|
+
expect(error._tag).toBe('ProcessError');
|
|
108
|
+
expect(error.command).toBe('claude');
|
|
109
|
+
expect(error.message).toBe('Failed to spawn process');
|
|
110
|
+
});
|
|
111
|
+
it('should create ProcessError with optional fields', async () => {
|
|
112
|
+
const { ProcessError } = await import('./errors.js');
|
|
113
|
+
const error = new ProcessError({
|
|
114
|
+
processId: 1234,
|
|
115
|
+
command: 'devcontainer exec',
|
|
116
|
+
signal: 'SIGTERM',
|
|
117
|
+
exitCode: 143,
|
|
118
|
+
message: 'Process terminated',
|
|
119
|
+
});
|
|
120
|
+
expect(error.processId).toBe(1234);
|
|
121
|
+
expect(error.signal).toBe('SIGTERM');
|
|
122
|
+
expect(error.exitCode).toBe(143);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
describe('ValidationError', () => {
|
|
126
|
+
it('should create ValidationError with required fields', async () => {
|
|
127
|
+
const { ValidationError } = await import('./errors.js');
|
|
128
|
+
const error = new ValidationError({
|
|
129
|
+
field: 'presetId',
|
|
130
|
+
constraint: 'must be a valid preset ID',
|
|
131
|
+
receivedValue: 'invalid-id',
|
|
132
|
+
});
|
|
133
|
+
expect(error).toBeInstanceOf(Error);
|
|
134
|
+
expect(error._tag).toBe('ValidationError');
|
|
135
|
+
expect(error.field).toBe('presetId');
|
|
136
|
+
expect(error.constraint).toBe('must be a valid preset ID');
|
|
137
|
+
expect(error.receivedValue).toBe('invalid-id');
|
|
138
|
+
});
|
|
139
|
+
it('should handle null and undefined received values', async () => {
|
|
140
|
+
const { ValidationError } = await import('./errors.js');
|
|
141
|
+
const nullError = new ValidationError({
|
|
142
|
+
field: 'name',
|
|
143
|
+
constraint: 'required',
|
|
144
|
+
receivedValue: null,
|
|
145
|
+
});
|
|
146
|
+
expect(nullError.receivedValue).toBeNull();
|
|
147
|
+
const undefinedError = new ValidationError({
|
|
148
|
+
field: 'name',
|
|
149
|
+
constraint: 'required',
|
|
150
|
+
receivedValue: undefined,
|
|
151
|
+
});
|
|
152
|
+
expect(undefinedError.receivedValue).toBeUndefined();
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
describe('AppError Union Type', () => {
|
|
156
|
+
it('should support discriminated union via _tag', async () => {
|
|
157
|
+
const { GitError, FileSystemError, ConfigError, ProcessError, ValidationError, } = await import('./errors.js');
|
|
158
|
+
const errors = [
|
|
159
|
+
new GitError({ command: 'git', exitCode: 1, stderr: 'error' }),
|
|
160
|
+
new FileSystemError({ operation: 'read', path: '/test', cause: 'error' }),
|
|
161
|
+
new ConfigError({
|
|
162
|
+
configPath: '/test',
|
|
163
|
+
reason: 'parse',
|
|
164
|
+
details: 'error',
|
|
165
|
+
}),
|
|
166
|
+
new ProcessError({ command: 'cmd', message: 'error' }),
|
|
167
|
+
new ValidationError({
|
|
168
|
+
field: 'test',
|
|
169
|
+
constraint: 'required',
|
|
170
|
+
receivedValue: null,
|
|
171
|
+
}),
|
|
172
|
+
];
|
|
173
|
+
const tags = errors.map(e => e._tag);
|
|
174
|
+
expect(tags).toEqual([
|
|
175
|
+
'GitError',
|
|
176
|
+
'FileSystemError',
|
|
177
|
+
'ConfigError',
|
|
178
|
+
'ProcessError',
|
|
179
|
+
'ValidationError',
|
|
180
|
+
]);
|
|
181
|
+
});
|
|
182
|
+
it('should be able to narrow types using _tag', async () => {
|
|
183
|
+
const { GitError } = await import('./errors.js');
|
|
184
|
+
// Create a GitError which is a valid AppError
|
|
185
|
+
const error = new GitError({
|
|
186
|
+
command: 'git log',
|
|
187
|
+
exitCode: 1,
|
|
188
|
+
stderr: 'error',
|
|
189
|
+
});
|
|
190
|
+
if (error._tag === 'GitError') {
|
|
191
|
+
// TypeScript should narrow the type here
|
|
192
|
+
expect(error.command).toBe('git log');
|
|
193
|
+
expect(error.exitCode).toBe(1);
|
|
194
|
+
expect(error.stderr).toBe('error');
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
throw new Error('Should be GitError');
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
});
|
package/dist/types/index.d.ts
CHANGED
|
@@ -127,7 +127,6 @@ export interface IProjectManager {
|
|
|
127
127
|
setMode(mode: MenuMode): void;
|
|
128
128
|
selectProject(project: GitProject): void;
|
|
129
129
|
getWorktreeService(projectPath?: string): IWorktreeService;
|
|
130
|
-
refreshProjects(): Promise<void>;
|
|
131
130
|
getRecentProjects(limit?: number): RecentProject[];
|
|
132
131
|
addRecentProject(project: GitProject): void;
|
|
133
132
|
clearRecentProjects(): void;
|
|
@@ -144,22 +143,11 @@ export declare class AmbiguousBranchError extends Error {
|
|
|
144
143
|
constructor(branchName: string, matches: RemoteBranchMatch[]);
|
|
145
144
|
}
|
|
146
145
|
export interface IWorktreeService {
|
|
147
|
-
|
|
146
|
+
getWorktreesEffect(): import('effect').Effect.Effect<Worktree[], import('../types/errors.js').GitError, never>;
|
|
148
147
|
getGitRootPath(): string;
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
error?: string;
|
|
152
|
-
}>;
|
|
153
|
-
deleteWorktree(worktreePath: string, options?: {
|
|
148
|
+
createWorktreeEffect(worktreePath: string, branch: string, baseBranch: string, copySessionData?: boolean, copyClaudeDirectory?: boolean): import('effect').Effect.Effect<Worktree, import('../types/errors.js').GitError | import('../types/errors.js').FileSystemError, never>;
|
|
149
|
+
deleteWorktreeEffect(worktreePath: string, options?: {
|
|
154
150
|
deleteBranch?: boolean;
|
|
155
|
-
}):
|
|
156
|
-
|
|
157
|
-
error?: string;
|
|
158
|
-
};
|
|
159
|
-
mergeWorktree(worktreePath: string, targetBranch?: string): {
|
|
160
|
-
success: boolean;
|
|
161
|
-
mergedBranch?: string;
|
|
162
|
-
error?: string;
|
|
163
|
-
deletedWorktree?: boolean;
|
|
164
|
-
};
|
|
151
|
+
}): import('effect').Effect.Effect<void, import('../types/errors.js').GitError, never>;
|
|
152
|
+
mergeWorktreeEffect(sourceBranch: string, targetBranch: string, useRebase?: boolean): import('effect').Effect.Effect<void, import('../types/errors.js').GitError, never>;
|
|
165
153
|
}
|
|
@@ -4,19 +4,71 @@
|
|
|
4
4
|
* CLAUDE_CONFIG_DIR environment variable and convert worktree paths to Claude's
|
|
5
5
|
* project naming convention.
|
|
6
6
|
*/
|
|
7
|
+
import { Effect, Either } from 'effect';
|
|
8
|
+
import { ValidationError, FileSystemError } from '../types/errors.js';
|
|
7
9
|
/**
|
|
8
|
-
* Get the Claude directory path
|
|
9
|
-
*
|
|
10
|
+
* Get the Claude directory path using Either for synchronous validation
|
|
11
|
+
* Returns Either with ValidationError if HOME directory cannot be determined
|
|
10
12
|
*/
|
|
11
|
-
export declare function getClaudeDir(): string
|
|
13
|
+
export declare function getClaudeDir(): Either.Either<string, ValidationError>;
|
|
12
14
|
/**
|
|
13
|
-
* Get the Claude projects directory path
|
|
14
|
-
*
|
|
15
|
+
* Get the Claude projects directory path using Either
|
|
16
|
+
*
|
|
17
|
+
* Propagates ValidationError from getClaudeDir if HOME directory cannot be determined.
|
|
18
|
+
*
|
|
19
|
+
* @returns {Either.Either<string, ValidationError>} Either containing projects directory path or ValidationError
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* ```typescript
|
|
23
|
+
* import {Either} from 'effect';
|
|
24
|
+
* import {getClaudeProjectsDir} from './utils/claudeDir.js';
|
|
25
|
+
*
|
|
26
|
+
* const projectsDirEither = getClaudeProjectsDir();
|
|
27
|
+
*
|
|
28
|
+
* if (Either.isRight(projectsDirEither)) {
|
|
29
|
+
* console.log(`Projects directory: ${projectsDirEither.right}`);
|
|
30
|
+
* } else {
|
|
31
|
+
* console.error(`Failed to get directory: ${projectsDirEither.left.field} ${projectsDirEither.left.constraint}`);
|
|
32
|
+
* }
|
|
33
|
+
* ```
|
|
15
34
|
*/
|
|
16
|
-
export declare function getClaudeProjectsDir(): string
|
|
35
|
+
export declare function getClaudeProjectsDir(): Either.Either<string, ValidationError>;
|
|
17
36
|
/**
|
|
18
37
|
* Convert a worktree path to Claude's project naming convention
|
|
38
|
+
* Pure transformation, cannot fail
|
|
19
39
|
* @param worktreePath The path to the worktree
|
|
20
40
|
* @returns The project name used by Claude
|
|
21
41
|
*/
|
|
22
42
|
export declare function pathToClaudeProjectName(worktreePath: string): string;
|
|
43
|
+
/**
|
|
44
|
+
* Check if Claude project directory exists using Effect
|
|
45
|
+
*
|
|
46
|
+
* Returns false for ENOENT (directory doesn't exist), fails with FileSystemError for other errors.
|
|
47
|
+
*
|
|
48
|
+
* @param {string} projectName - Claude project name to check
|
|
49
|
+
* @returns {Effect.Effect<boolean, FileSystemError, never>} Effect containing boolean indicating existence or FileSystemError
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* ```typescript
|
|
53
|
+
* import {Effect} from 'effect';
|
|
54
|
+
* import {claudeDirExists, pathToClaudeProjectName} from './utils/claudeDir.js';
|
|
55
|
+
*
|
|
56
|
+
* const projectName = pathToClaudeProjectName('/path/to/worktree');
|
|
57
|
+
*
|
|
58
|
+
* // Check if directory exists with error handling
|
|
59
|
+
* const exists = await Effect.runPromise(
|
|
60
|
+
* Effect.catchAll(
|
|
61
|
+
* claudeDirExists(projectName),
|
|
62
|
+
* (error) => {
|
|
63
|
+
* console.error(`Failed to check directory: ${error.cause}`);
|
|
64
|
+
* return Effect.succeed(false); // Assume doesn't exist on error
|
|
65
|
+
* }
|
|
66
|
+
* )
|
|
67
|
+
* );
|
|
68
|
+
*
|
|
69
|
+
* if (exists) {
|
|
70
|
+
* console.log('Claude project directory exists');
|
|
71
|
+
* }
|
|
72
|
+
* ```
|
|
73
|
+
*/
|
|
74
|
+
export declare function claudeDirExists(projectName: string): Effect.Effect<boolean, FileSystemError>;
|
package/dist/utils/claudeDir.js
CHANGED
|
@@ -6,27 +6,65 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import path from 'path';
|
|
8
8
|
import os from 'os';
|
|
9
|
+
import { promises as fs } from 'fs';
|
|
10
|
+
import { Effect, Either } from 'effect';
|
|
11
|
+
import { ValidationError, FileSystemError } from '../types/errors.js';
|
|
9
12
|
/**
|
|
10
|
-
* Get the Claude directory path
|
|
11
|
-
*
|
|
13
|
+
* Get the Claude directory path using Either for synchronous validation
|
|
14
|
+
* Returns Either with ValidationError if HOME directory cannot be determined
|
|
12
15
|
*/
|
|
13
16
|
export function getClaudeDir() {
|
|
14
17
|
const envConfigDir = process.env['CLAUDE_CONFIG_DIR'];
|
|
15
18
|
if (envConfigDir) {
|
|
16
|
-
return envConfigDir.trim();
|
|
19
|
+
return Either.right(envConfigDir.trim());
|
|
20
|
+
}
|
|
21
|
+
// Try to get home directory
|
|
22
|
+
try {
|
|
23
|
+
const homeDir = os.homedir();
|
|
24
|
+
if (!homeDir) {
|
|
25
|
+
return Either.left(new ValidationError({
|
|
26
|
+
field: 'HOME',
|
|
27
|
+
constraint: 'must be set',
|
|
28
|
+
receivedValue: undefined,
|
|
29
|
+
}));
|
|
30
|
+
}
|
|
31
|
+
return Either.right(path.join(homeDir, '.claude'));
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return Either.left(new ValidationError({
|
|
35
|
+
field: 'HOME',
|
|
36
|
+
constraint: 'must be accessible',
|
|
37
|
+
receivedValue: undefined,
|
|
38
|
+
}));
|
|
17
39
|
}
|
|
18
|
-
// Default to ~/.claude for backward compatibility and when not set
|
|
19
|
-
return path.join(os.homedir(), '.claude');
|
|
20
40
|
}
|
|
21
41
|
/**
|
|
22
|
-
* Get the Claude projects directory path
|
|
23
|
-
*
|
|
42
|
+
* Get the Claude projects directory path using Either
|
|
43
|
+
*
|
|
44
|
+
* Propagates ValidationError from getClaudeDir if HOME directory cannot be determined.
|
|
45
|
+
*
|
|
46
|
+
* @returns {Either.Either<string, ValidationError>} Either containing projects directory path or ValidationError
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* ```typescript
|
|
50
|
+
* import {Either} from 'effect';
|
|
51
|
+
* import {getClaudeProjectsDir} from './utils/claudeDir.js';
|
|
52
|
+
*
|
|
53
|
+
* const projectsDirEither = getClaudeProjectsDir();
|
|
54
|
+
*
|
|
55
|
+
* if (Either.isRight(projectsDirEither)) {
|
|
56
|
+
* console.log(`Projects directory: ${projectsDirEither.right}`);
|
|
57
|
+
* } else {
|
|
58
|
+
* console.error(`Failed to get directory: ${projectsDirEither.left.field} ${projectsDirEither.left.constraint}`);
|
|
59
|
+
* }
|
|
60
|
+
* ```
|
|
24
61
|
*/
|
|
25
62
|
export function getClaudeProjectsDir() {
|
|
26
|
-
return
|
|
63
|
+
return Either.map(getClaudeDir(), dir => path.join(dir, 'projects'));
|
|
27
64
|
}
|
|
28
65
|
/**
|
|
29
66
|
* Convert a worktree path to Claude's project naming convention
|
|
67
|
+
* Pure transformation, cannot fail
|
|
30
68
|
* @param worktreePath The path to the worktree
|
|
31
69
|
* @returns The project name used by Claude
|
|
32
70
|
*/
|
|
@@ -37,3 +75,60 @@ export function pathToClaudeProjectName(worktreePath) {
|
|
|
37
75
|
// Handle both forward slashes (Linux/macOS) and backslashes (Windows)
|
|
38
76
|
return resolved.replace(/[/\\.]/g, '-');
|
|
39
77
|
}
|
|
78
|
+
/**
|
|
79
|
+
* Check if Claude project directory exists using Effect
|
|
80
|
+
*
|
|
81
|
+
* Returns false for ENOENT (directory doesn't exist), fails with FileSystemError for other errors.
|
|
82
|
+
*
|
|
83
|
+
* @param {string} projectName - Claude project name to check
|
|
84
|
+
* @returns {Effect.Effect<boolean, FileSystemError, never>} Effect containing boolean indicating existence or FileSystemError
|
|
85
|
+
*
|
|
86
|
+
* @example
|
|
87
|
+
* ```typescript
|
|
88
|
+
* import {Effect} from 'effect';
|
|
89
|
+
* import {claudeDirExists, pathToClaudeProjectName} from './utils/claudeDir.js';
|
|
90
|
+
*
|
|
91
|
+
* const projectName = pathToClaudeProjectName('/path/to/worktree');
|
|
92
|
+
*
|
|
93
|
+
* // Check if directory exists with error handling
|
|
94
|
+
* const exists = await Effect.runPromise(
|
|
95
|
+
* Effect.catchAll(
|
|
96
|
+
* claudeDirExists(projectName),
|
|
97
|
+
* (error) => {
|
|
98
|
+
* console.error(`Failed to check directory: ${error.cause}`);
|
|
99
|
+
* return Effect.succeed(false); // Assume doesn't exist on error
|
|
100
|
+
* }
|
|
101
|
+
* )
|
|
102
|
+
* );
|
|
103
|
+
*
|
|
104
|
+
* if (exists) {
|
|
105
|
+
* console.log('Claude project directory exists');
|
|
106
|
+
* }
|
|
107
|
+
* ```
|
|
108
|
+
*/
|
|
109
|
+
export function claudeDirExists(projectName) {
|
|
110
|
+
const claudeDirEither = getClaudeProjectsDir();
|
|
111
|
+
if (Either.isLeft(claudeDirEither)) {
|
|
112
|
+
// If we can't determine the projects directory, return false
|
|
113
|
+
return Effect.succeed(false);
|
|
114
|
+
}
|
|
115
|
+
const projectPath = path.join(claudeDirEither.right, projectName);
|
|
116
|
+
return Effect.catchAll(Effect.tryPromise({
|
|
117
|
+
try: () => fs.stat(projectPath).then(() => true),
|
|
118
|
+
catch: error => error,
|
|
119
|
+
}), error => {
|
|
120
|
+
// ENOENT means directory doesn't exist, which is not an error
|
|
121
|
+
if (typeof error === 'object' &&
|
|
122
|
+
error !== null &&
|
|
123
|
+
'code' in error &&
|
|
124
|
+
error.code === 'ENOENT') {
|
|
125
|
+
return Effect.succeed(false);
|
|
126
|
+
}
|
|
127
|
+
// Other errors are filesystem issues
|
|
128
|
+
return Effect.fail(new FileSystemError({
|
|
129
|
+
operation: 'stat',
|
|
130
|
+
path: projectPath,
|
|
131
|
+
cause: error instanceof Error ? error.message : String(error),
|
|
132
|
+
}));
|
|
133
|
+
});
|
|
134
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { Effect, Either } from 'effect';
|
|
3
|
+
import { getClaudeDir, getClaudeProjectsDir, pathToClaudeProjectName, claudeDirExists, } from './claudeDir.js';
|
|
4
|
+
describe('claudeDir', () => {
|
|
5
|
+
const originalEnv = process.env;
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
process.env = { ...originalEnv };
|
|
8
|
+
});
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
process.env = originalEnv;
|
|
11
|
+
});
|
|
12
|
+
describe('getClaudeDir', () => {
|
|
13
|
+
it('should return Either.right with CLAUDE_CONFIG_DIR when set', () => {
|
|
14
|
+
process.env['CLAUDE_CONFIG_DIR'] = '/custom/claude';
|
|
15
|
+
const result = getClaudeDir();
|
|
16
|
+
expect(Either.isRight(result)).toBe(true);
|
|
17
|
+
if (Either.isRight(result)) {
|
|
18
|
+
expect(result.right).toBe('/custom/claude');
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
it('should trim whitespace from CLAUDE_CONFIG_DIR', () => {
|
|
22
|
+
process.env['CLAUDE_CONFIG_DIR'] = ' /custom/claude ';
|
|
23
|
+
const result = getClaudeDir();
|
|
24
|
+
expect(Either.isRight(result)).toBe(true);
|
|
25
|
+
if (Either.isRight(result)) {
|
|
26
|
+
expect(result.right).toBe('/custom/claude');
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
it('should return Either.right with default ~/.claude when env var not set', () => {
|
|
30
|
+
delete process.env['CLAUDE_CONFIG_DIR'];
|
|
31
|
+
const result = getClaudeDir();
|
|
32
|
+
expect(Either.isRight(result)).toBe(true);
|
|
33
|
+
if (Either.isRight(result)) {
|
|
34
|
+
expect(result.right).toContain('.claude');
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
it('should return Either.right even when HOME vars deleted (os.homedir has fallbacks)', () => {
|
|
38
|
+
delete process.env['CLAUDE_CONFIG_DIR'];
|
|
39
|
+
delete process.env['HOME'];
|
|
40
|
+
delete process.env['USERPROFILE'];
|
|
41
|
+
const result = getClaudeDir();
|
|
42
|
+
// os.homedir() has multiple fallback mechanisms, so this will likely still succeed
|
|
43
|
+
expect(Either.isRight(result)).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
describe('getClaudeProjectsDir', () => {
|
|
47
|
+
it('should return Either.right with projects subdirectory', () => {
|
|
48
|
+
process.env['CLAUDE_CONFIG_DIR'] = '/custom/claude';
|
|
49
|
+
const result = getClaudeProjectsDir();
|
|
50
|
+
expect(Either.isRight(result)).toBe(true);
|
|
51
|
+
if (Either.isRight(result)) {
|
|
52
|
+
expect(result.right).toBe('/custom/claude/projects');
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
it('should return Either.right from getClaudeDir fallbacks', () => {
|
|
56
|
+
delete process.env['CLAUDE_CONFIG_DIR'];
|
|
57
|
+
delete process.env['HOME'];
|
|
58
|
+
delete process.env['USERPROFILE'];
|
|
59
|
+
const result = getClaudeProjectsDir();
|
|
60
|
+
// Since getClaudeDir has fallbacks, this should succeed
|
|
61
|
+
expect(Either.isRight(result)).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
describe('pathToClaudeProjectName', () => {
|
|
65
|
+
it('should convert absolute path to Claude naming convention', () => {
|
|
66
|
+
const result = pathToClaudeProjectName('/home/user/projects/myapp');
|
|
67
|
+
expect(result).toBe('-home-user-projects-myapp');
|
|
68
|
+
});
|
|
69
|
+
it('should replace forward slashes with dashes', () => {
|
|
70
|
+
const result = pathToClaudeProjectName('/a/b/c');
|
|
71
|
+
expect(result).toBe('-a-b-c');
|
|
72
|
+
});
|
|
73
|
+
it('should replace backslashes with dashes (Windows)', () => {
|
|
74
|
+
const result = pathToClaudeProjectName('C:\\Users\\test\\app');
|
|
75
|
+
expect(result).toContain('-');
|
|
76
|
+
expect(result).not.toContain('\\');
|
|
77
|
+
});
|
|
78
|
+
it('should replace dots with dashes', () => {
|
|
79
|
+
const result = pathToClaudeProjectName('/home/user/my.app');
|
|
80
|
+
expect(result).toBe('-home-user-my-app');
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
describe('claudeDirExists', () => {
|
|
84
|
+
it('should return Effect with false for nonexistent directory', async () => {
|
|
85
|
+
const effect = claudeDirExists('nonexistent-dir-12345-test-project');
|
|
86
|
+
const result = await Effect.runPromise(effect);
|
|
87
|
+
expect(result).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
it('should check in Claude projects directory', async () => {
|
|
90
|
+
// This test verifies the path construction, not the existence
|
|
91
|
+
// Since ~/.claude/projects likely doesn't exist in test environment
|
|
92
|
+
const effect = claudeDirExists('any-project-name');
|
|
93
|
+
const result = await Effect.runPromise(effect);
|
|
94
|
+
// Should return false (directory doesn't exist) not throw an error
|
|
95
|
+
expect(result).toBe(false);
|
|
96
|
+
});
|
|
97
|
+
it('should fail with FileSystemError on access error', async () => {
|
|
98
|
+
// Mock fs.stat to throw a non-ENOENT error
|
|
99
|
+
const { promises: fs } = await import('fs');
|
|
100
|
+
vi.spyOn(fs, 'stat').mockRejectedValue(Object.assign(new Error('Permission denied'), { code: 'EACCES' }));
|
|
101
|
+
const effect = claudeDirExists('test-project');
|
|
102
|
+
const exit = await Effect.runPromiseExit(effect);
|
|
103
|
+
expect(exit._tag).toBe('Failure');
|
|
104
|
+
// Restore original
|
|
105
|
+
vi.mocked(fs.stat).mockRestore();
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
});
|