ccmanager 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -0
- package/dist/cli.d.ts +8 -1
- package/dist/cli.js +31 -4
- package/dist/components/App.d.ts +5 -1
- package/dist/components/App.js +16 -5
- package/dist/components/Menu.d.ts +2 -0
- package/dist/components/Menu.js +13 -2
- package/dist/integration-tests/devcontainer.integration.test.d.ts +1 -0
- package/dist/integration-tests/devcontainer.integration.test.js +101 -0
- package/dist/services/sessionManager.d.ts +5 -2
- package/dist/services/sessionManager.js +59 -46
- package/dist/services/sessionManager.test.js +358 -142
- package/dist/types/index.d.ts +5 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -19,6 +19,7 @@ https://github.com/user-attachments/assets/15914a88-e288-4ac9-94d5-8127f2e19dbf
|
|
|
19
19
|
- Command presets with automatic fallback support
|
|
20
20
|
- Configurable state detection strategies for different CLI tools
|
|
21
21
|
- Status change hooks for automation and notifications
|
|
22
|
+
- Devcontainer integration
|
|
22
23
|
|
|
23
24
|
## Why CCManager over Claude Squad?
|
|
24
25
|
|
|
@@ -177,6 +178,35 @@ CCManager can automatically generate worktree directory paths based on branch na
|
|
|
177
178
|
|
|
178
179
|
For detailed configuration and examples, see [docs/worktree-auto-directory.md](docs/worktree-auto-directory.md).
|
|
179
180
|
|
|
181
|
+
## Devcontainer Integration
|
|
182
|
+
|
|
183
|
+
CCManager supports running AI assistant sessions inside devcontainers while keeping the manager itself on the host machine. This enables sandboxed development environments with restricted network access while maintaining host-level notifications and automation.
|
|
184
|
+
|
|
185
|
+
### Features
|
|
186
|
+
|
|
187
|
+
- **Host-based management**: CCManager runs on your host machine, managing sessions inside containers
|
|
188
|
+
- **Seamless integration**: All existing features (presets, status hooks, etc.) work with devcontainers
|
|
189
|
+
- **Security-focused**: Compatible with Anthropic's recommended devcontainer configurations
|
|
190
|
+
- **Persistent state**: Configuration and history persist across container recreations
|
|
191
|
+
|
|
192
|
+
### Usage
|
|
193
|
+
|
|
194
|
+
```bash
|
|
195
|
+
# Start CCManager with devcontainer support
|
|
196
|
+
npx ccmanager --devc-up-command "<your devcontainer up command>" \
|
|
197
|
+
--devc-exec-command "<your devcontainer exec command>"
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
The devcontainer integration requires both commands:
|
|
201
|
+
- `--devc-up-command`: Any command to start the devcontainer
|
|
202
|
+
- `--devc-exec-command`: Any command to execute inside the container
|
|
203
|
+
|
|
204
|
+
### Benefits
|
|
205
|
+
|
|
206
|
+
- **Safe experimentation**: Run commands like `claude --dangerously-skip-permissions` without risk
|
|
207
|
+
|
|
208
|
+
For detailed setup and configuration, see [docs/devcontainer.md](docs/devcontainer.md).
|
|
209
|
+
|
|
180
210
|
## Git Worktree Configuration
|
|
181
211
|
|
|
182
212
|
CCManager can display enhanced git status information for each worktree when Git's worktree configuration extension is enabled.
|
package/dist/cli.d.ts
CHANGED
package/dist/cli.js
CHANGED
|
@@ -4,19 +4,35 @@ import { render } from 'ink';
|
|
|
4
4
|
import meow from 'meow';
|
|
5
5
|
import App from './components/App.js';
|
|
6
6
|
import { worktreeConfigManager } from './services/worktreeConfigManager.js';
|
|
7
|
-
meow(`
|
|
7
|
+
const cli = meow(`
|
|
8
8
|
Usage
|
|
9
9
|
$ ccmanager
|
|
10
10
|
|
|
11
11
|
Options
|
|
12
|
-
--help
|
|
13
|
-
--version
|
|
12
|
+
--help Show help
|
|
13
|
+
--version Show version
|
|
14
|
+
--devc-up-command Command to start devcontainer
|
|
15
|
+
--devc-exec-command Command to execute in devcontainer
|
|
14
16
|
|
|
15
17
|
Examples
|
|
16
18
|
$ ccmanager
|
|
19
|
+
$ ccmanager --devc-up-command "devcontainer up --workspace-folder ." --devc-exec-command "devcontainer exec --workspace-folder ."
|
|
17
20
|
`, {
|
|
18
21
|
importMeta: import.meta,
|
|
22
|
+
flags: {
|
|
23
|
+
devcUpCommand: {
|
|
24
|
+
type: 'string',
|
|
25
|
+
},
|
|
26
|
+
devcExecCommand: {
|
|
27
|
+
type: 'string',
|
|
28
|
+
},
|
|
29
|
+
},
|
|
19
30
|
});
|
|
31
|
+
// Validate devcontainer arguments using XOR
|
|
32
|
+
if (!!cli.flags.devcUpCommand !== !!cli.flags.devcExecCommand) {
|
|
33
|
+
console.error('Error: Both --devc-up-command and --devc-exec-command must be provided together');
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
20
36
|
// Check if we're in a TTY environment
|
|
21
37
|
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
22
38
|
console.error('Error: ccmanager must be run in an interactive terminal (TTY)');
|
|
@@ -24,4 +40,15 @@ if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
|
24
40
|
}
|
|
25
41
|
// Initialize worktree config manager
|
|
26
42
|
worktreeConfigManager.initialize();
|
|
27
|
-
|
|
43
|
+
// Prepare devcontainer config
|
|
44
|
+
const devcontainerConfig = cli.flags.devcUpCommand && cli.flags.devcExecCommand
|
|
45
|
+
? {
|
|
46
|
+
upCommand: cli.flags.devcUpCommand,
|
|
47
|
+
execCommand: cli.flags.devcExecCommand,
|
|
48
|
+
}
|
|
49
|
+
: undefined;
|
|
50
|
+
// Pass config to App
|
|
51
|
+
const appProps = devcontainerConfig ? { devcontainerConfig } : {};
|
|
52
|
+
render(React.createElement(App, { ...appProps }));
|
|
53
|
+
// Export for testing
|
|
54
|
+
export const parsedArgs = cli;
|
package/dist/components/App.d.ts
CHANGED
package/dist/components/App.js
CHANGED
|
@@ -11,7 +11,7 @@ import { SessionManager } from '../services/sessionManager.js';
|
|
|
11
11
|
import { WorktreeService } from '../services/worktreeService.js';
|
|
12
12
|
import { shortcutManager } from '../services/shortcutManager.js';
|
|
13
13
|
import { configurationManager } from '../services/configurationManager.js';
|
|
14
|
-
const App = () => {
|
|
14
|
+
const App = ({ devcontainerConfig }) => {
|
|
15
15
|
const { exit } = useApp();
|
|
16
16
|
const [view, setView] = useState('menu');
|
|
17
17
|
const [sessionManager] = useState(() => new SessionManager());
|
|
@@ -87,7 +87,12 @@ const App = () => {
|
|
|
87
87
|
}
|
|
88
88
|
try {
|
|
89
89
|
// Use preset-based session creation with default preset
|
|
90
|
-
|
|
90
|
+
if (devcontainerConfig) {
|
|
91
|
+
session = await sessionManager.createSessionWithDevcontainer(worktree.path, devcontainerConfig);
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
session = await sessionManager.createSessionWithPreset(worktree.path);
|
|
95
|
+
}
|
|
91
96
|
}
|
|
92
97
|
catch (error) {
|
|
93
98
|
setError(`Failed to create session: ${error}`);
|
|
@@ -102,7 +107,13 @@ const App = () => {
|
|
|
102
107
|
return;
|
|
103
108
|
try {
|
|
104
109
|
// Create session with selected preset
|
|
105
|
-
|
|
110
|
+
let session;
|
|
111
|
+
if (devcontainerConfig) {
|
|
112
|
+
session = await sessionManager.createSessionWithDevcontainer(selectedWorktree.path, devcontainerConfig, presetId);
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
session = await sessionManager.createSessionWithPreset(selectedWorktree.path, presetId);
|
|
116
|
+
}
|
|
106
117
|
setActiveSession(session);
|
|
107
118
|
setView('session');
|
|
108
119
|
setSelectedWorktree(null);
|
|
@@ -120,7 +131,7 @@ const App = () => {
|
|
|
120
131
|
};
|
|
121
132
|
const handleReturnToMenu = () => {
|
|
122
133
|
setActiveSession(null);
|
|
123
|
-
|
|
134
|
+
// Don't clear error here - let user dismiss it manually
|
|
124
135
|
// Add a small delay to ensure Session cleanup completes
|
|
125
136
|
setTimeout(() => {
|
|
126
137
|
setView('menu');
|
|
@@ -210,7 +221,7 @@ const App = () => {
|
|
|
210
221
|
handleReturnToMenu();
|
|
211
222
|
};
|
|
212
223
|
if (view === 'menu') {
|
|
213
|
-
return (React.createElement(Menu, { key: menuKey, sessionManager: sessionManager, onSelectWorktree: handleSelectWorktree }));
|
|
224
|
+
return (React.createElement(Menu, { key: menuKey, sessionManager: sessionManager, onSelectWorktree: handleSelectWorktree, error: error, onDismissError: () => setError(null) }));
|
|
214
225
|
}
|
|
215
226
|
if (view === 'session' && activeSession) {
|
|
216
227
|
return (React.createElement(Box, { flexDirection: "column" },
|
|
@@ -4,6 +4,8 @@ import { SessionManager } from '../services/sessionManager.js';
|
|
|
4
4
|
interface MenuProps {
|
|
5
5
|
sessionManager: SessionManager;
|
|
6
6
|
onSelectWorktree: (worktree: Worktree) => void;
|
|
7
|
+
error?: string | null;
|
|
8
|
+
onDismissError?: () => void;
|
|
7
9
|
}
|
|
8
10
|
declare const Menu: React.FC<MenuProps>;
|
|
9
11
|
export default Menu;
|
package/dist/components/Menu.js
CHANGED
|
@@ -5,7 +5,7 @@ import { WorktreeService } from '../services/worktreeService.js';
|
|
|
5
5
|
import { STATUS_ICONS, STATUS_LABELS, MENU_ICONS, } from '../constants/statusIcons.js';
|
|
6
6
|
import { useGitStatus } from '../hooks/useGitStatus.js';
|
|
7
7
|
import { prepareWorktreeItems, calculateColumnPositions, assembleWorktreeLabel, } from '../utils/worktreeUtils.js';
|
|
8
|
-
const Menu = ({ sessionManager, onSelectWorktree }) => {
|
|
8
|
+
const Menu = ({ sessionManager, onSelectWorktree, error, onDismissError, }) => {
|
|
9
9
|
const [baseWorktrees, setBaseWorktrees] = useState([]);
|
|
10
10
|
const [defaultBranch, setDefaultBranch] = useState(null);
|
|
11
11
|
const worktrees = useGitStatus(baseWorktrees, defaultBranch);
|
|
@@ -82,6 +82,11 @@ const Menu = ({ sessionManager, onSelectWorktree }) => {
|
|
|
82
82
|
}, [worktrees, sessions, defaultBranch]);
|
|
83
83
|
// Handle hotkeys
|
|
84
84
|
useInput((input, _key) => {
|
|
85
|
+
// Dismiss error on any key press when error is shown
|
|
86
|
+
if (error && onDismissError) {
|
|
87
|
+
onDismissError();
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
85
90
|
const keyPressed = input.toLowerCase();
|
|
86
91
|
// Handle number keys 0-9 for worktree selection (first 10 only)
|
|
87
92
|
if (/^[0-9]$/.test(keyPressed)) {
|
|
@@ -198,7 +203,13 @@ const Menu = ({ sessionManager, onSelectWorktree }) => {
|
|
|
198
203
|
React.createElement(Text, { bold: true, color: "green" }, "CCManager - Claude Code Worktree Manager")),
|
|
199
204
|
React.createElement(Box, { marginBottom: 1 },
|
|
200
205
|
React.createElement(Text, { dimColor: true }, "Select a worktree to start or resume a Claude Code session:")),
|
|
201
|
-
React.createElement(SelectInput, { items: items, onSelect: handleSelect, isFocused:
|
|
206
|
+
React.createElement(SelectInput, { items: items, onSelect: handleSelect, isFocused: !error }),
|
|
207
|
+
error && (React.createElement(Box, { marginTop: 1, paddingX: 1, borderStyle: "round", borderColor: "red" },
|
|
208
|
+
React.createElement(Box, { flexDirection: "column" },
|
|
209
|
+
React.createElement(Text, { color: "red", bold: true },
|
|
210
|
+
"Error: ",
|
|
211
|
+
error),
|
|
212
|
+
React.createElement(Text, { color: "gray", dimColor: true }, "Press any key to dismiss")))),
|
|
202
213
|
React.createElement(Box, { marginTop: 1, flexDirection: "column" },
|
|
203
214
|
React.createElement(Text, { dimColor: true },
|
|
204
215
|
"Status: ",
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { SessionManager } from '../services/sessionManager.js';
|
|
3
|
+
import { spawn } from 'node-pty';
|
|
4
|
+
import { exec } from 'child_process';
|
|
5
|
+
// Mock modules
|
|
6
|
+
vi.mock('node-pty');
|
|
7
|
+
vi.mock('child_process');
|
|
8
|
+
vi.mock('util', () => ({
|
|
9
|
+
promisify: vi.fn((fn) => {
|
|
10
|
+
return (cmd, options) => {
|
|
11
|
+
return new Promise((resolve, reject) => {
|
|
12
|
+
const callback = (err, stdout, stderr) => {
|
|
13
|
+
if (err) {
|
|
14
|
+
reject(err);
|
|
15
|
+
}
|
|
16
|
+
else {
|
|
17
|
+
resolve({ stdout, stderr });
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
// Call the original function with the promisified callback
|
|
21
|
+
fn(cmd, options, callback);
|
|
22
|
+
});
|
|
23
|
+
};
|
|
24
|
+
}),
|
|
25
|
+
}));
|
|
26
|
+
vi.mock('../services/configurationManager.js', () => ({
|
|
27
|
+
configurationManager: {
|
|
28
|
+
getDefaultPreset: vi.fn(() => ({
|
|
29
|
+
id: 'claude',
|
|
30
|
+
name: 'Claude',
|
|
31
|
+
command: 'claude',
|
|
32
|
+
args: [],
|
|
33
|
+
})),
|
|
34
|
+
getPresetById: vi.fn(),
|
|
35
|
+
},
|
|
36
|
+
}));
|
|
37
|
+
vi.mock('../services/worktreeService.js', () => ({
|
|
38
|
+
WorktreeService: vi.fn(),
|
|
39
|
+
}));
|
|
40
|
+
const mockSpawn = vi.mocked(spawn);
|
|
41
|
+
const mockExec = vi.mocked(exec);
|
|
42
|
+
describe('Devcontainer Integration', () => {
|
|
43
|
+
let sessionManager;
|
|
44
|
+
let mockPty;
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
vi.clearAllMocks();
|
|
47
|
+
sessionManager = new SessionManager();
|
|
48
|
+
// Mock PTY process
|
|
49
|
+
mockPty = {
|
|
50
|
+
onData: vi.fn(),
|
|
51
|
+
onExit: vi.fn(),
|
|
52
|
+
write: vi.fn(),
|
|
53
|
+
resize: vi.fn(),
|
|
54
|
+
kill: vi.fn(),
|
|
55
|
+
pid: 12345,
|
|
56
|
+
cols: 80,
|
|
57
|
+
rows: 24,
|
|
58
|
+
process: 'claude',
|
|
59
|
+
};
|
|
60
|
+
mockSpawn.mockReturnValue(mockPty);
|
|
61
|
+
});
|
|
62
|
+
it('should execute devcontainer up command before creating session', async () => {
|
|
63
|
+
const devcontainerConfig = {
|
|
64
|
+
upCommand: 'devcontainer up --workspace-folder .',
|
|
65
|
+
execCommand: 'devcontainer exec --workspace-folder .',
|
|
66
|
+
};
|
|
67
|
+
mockExec.mockImplementation((cmd, options, callback) => {
|
|
68
|
+
if (typeof options === 'function') {
|
|
69
|
+
callback = options;
|
|
70
|
+
options = undefined;
|
|
71
|
+
}
|
|
72
|
+
if (callback && typeof callback === 'function') {
|
|
73
|
+
callback(null, 'Container started', '');
|
|
74
|
+
}
|
|
75
|
+
return {};
|
|
76
|
+
});
|
|
77
|
+
await sessionManager.createSessionWithDevcontainer('/test/worktree', devcontainerConfig);
|
|
78
|
+
expect(mockExec).toHaveBeenCalledWith(devcontainerConfig.upCommand, { cwd: '/test/worktree' }, expect.any(Function));
|
|
79
|
+
});
|
|
80
|
+
it('should handle devcontainer command execution with presets', async () => {
|
|
81
|
+
const devcontainerConfig = {
|
|
82
|
+
upCommand: 'devcontainer up --workspace-folder .',
|
|
83
|
+
execCommand: 'devcontainer exec --workspace-folder .',
|
|
84
|
+
};
|
|
85
|
+
mockExec.mockImplementation((cmd, options, callback) => {
|
|
86
|
+
if (typeof options === 'function') {
|
|
87
|
+
callback = options;
|
|
88
|
+
options = undefined;
|
|
89
|
+
}
|
|
90
|
+
if (callback && typeof callback === 'function') {
|
|
91
|
+
callback(null, 'Container started', '');
|
|
92
|
+
}
|
|
93
|
+
return {};
|
|
94
|
+
});
|
|
95
|
+
await sessionManager.createSessionWithDevcontainer('/test/worktree', devcontainerConfig);
|
|
96
|
+
// Should spawn with devcontainer exec command
|
|
97
|
+
expect(mockSpawn).toHaveBeenCalledWith('devcontainer', ['exec', '--workspace-folder', '.', '--', 'claude'], expect.objectContaining({
|
|
98
|
+
cwd: '/test/worktree',
|
|
99
|
+
}));
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Session, SessionManager as ISessionManager, SessionState } from '../types/index.js';
|
|
1
|
+
import { Session, SessionManager as ISessionManager, SessionState, DevcontainerConfig } from '../types/index.js';
|
|
2
2
|
import { EventEmitter } from 'events';
|
|
3
3
|
export declare class SessionManager extends EventEmitter implements ISessionManager {
|
|
4
4
|
sessions: Map<string, Session>;
|
|
@@ -7,7 +7,9 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
|
|
|
7
7
|
private spawn;
|
|
8
8
|
detectTerminalState(session: Session): SessionState;
|
|
9
9
|
constructor();
|
|
10
|
-
|
|
10
|
+
private createSessionId;
|
|
11
|
+
private createTerminal;
|
|
12
|
+
private createSessionInternal;
|
|
11
13
|
createSessionWithPreset(worktreePath: string, presetId?: string): Promise<Session>;
|
|
12
14
|
private setupDataHandler;
|
|
13
15
|
private setupExitHandler;
|
|
@@ -18,5 +20,6 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
|
|
|
18
20
|
destroySession(worktreePath: string): void;
|
|
19
21
|
getAllSessions(): Session[];
|
|
20
22
|
private executeStatusHook;
|
|
23
|
+
createSessionWithDevcontainer(worktreePath: string, devcontainerConfig: DevcontainerConfig, presetId?: string): Promise<Session>;
|
|
21
24
|
destroy(): void;
|
|
22
25
|
}
|
|
@@ -2,10 +2,12 @@ import { spawn } from 'node-pty';
|
|
|
2
2
|
import { EventEmitter } from 'events';
|
|
3
3
|
import pkg from '@xterm/headless';
|
|
4
4
|
import { exec } from 'child_process';
|
|
5
|
+
import { promisify } from 'util';
|
|
5
6
|
import { configurationManager } from './configurationManager.js';
|
|
6
7
|
import { WorktreeService } from './worktreeService.js';
|
|
7
8
|
import { createStateDetector } from './stateDetector.js';
|
|
8
9
|
const { Terminal } = pkg;
|
|
10
|
+
const execAsync = promisify(exec);
|
|
9
11
|
export class SessionManager extends EventEmitter {
|
|
10
12
|
async spawn(command, args, worktreePath) {
|
|
11
13
|
const spawnOptions = {
|
|
@@ -45,27 +47,19 @@ export class SessionManager extends EventEmitter {
|
|
|
45
47
|
});
|
|
46
48
|
this.sessions = new Map();
|
|
47
49
|
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
}
|
|
54
|
-
const id = `session-${Date.now()}-${Math.random()
|
|
55
|
-
.toString(36)
|
|
56
|
-
.substr(2, 9)}`;
|
|
57
|
-
// Get command configuration
|
|
58
|
-
const commandConfig = configurationManager.getCommandConfig();
|
|
59
|
-
const command = commandConfig.command || 'claude';
|
|
60
|
-
const args = commandConfig.args || [];
|
|
61
|
-
// Spawn the process with fallback support
|
|
62
|
-
const ptyProcess = await this.spawn(command, args, worktreePath);
|
|
63
|
-
// Create virtual terminal for state detection
|
|
64
|
-
const terminal = new Terminal({
|
|
50
|
+
createSessionId() {
|
|
51
|
+
return `session-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
52
|
+
}
|
|
53
|
+
createTerminal() {
|
|
54
|
+
return new Terminal({
|
|
65
55
|
cols: process.stdout.columns || 80,
|
|
66
56
|
rows: process.stdout.rows || 24,
|
|
67
57
|
allowProposedApi: true,
|
|
68
58
|
});
|
|
59
|
+
}
|
|
60
|
+
async createSessionInternal(worktreePath, ptyProcess, commandConfig, options = {}) {
|
|
61
|
+
const id = this.createSessionId();
|
|
62
|
+
const terminal = this.createTerminal();
|
|
69
63
|
const session = {
|
|
70
64
|
id,
|
|
71
65
|
worktreePath,
|
|
@@ -76,9 +70,10 @@ export class SessionManager extends EventEmitter {
|
|
|
76
70
|
lastActivity: new Date(),
|
|
77
71
|
isActive: false,
|
|
78
72
|
terminal,
|
|
79
|
-
isPrimaryCommand: true,
|
|
73
|
+
isPrimaryCommand: options.isPrimaryCommand ?? true,
|
|
80
74
|
commandConfig,
|
|
81
|
-
detectionStrategy: 'claude',
|
|
75
|
+
detectionStrategy: options.detectionStrategy ?? 'claude',
|
|
76
|
+
devcontainerConfig: options.devcontainerConfig,
|
|
82
77
|
};
|
|
83
78
|
// Set up persistent background data handler for state detection
|
|
84
79
|
this.setupBackgroundHandler(session);
|
|
@@ -92,9 +87,6 @@ export class SessionManager extends EventEmitter {
|
|
|
92
87
|
if (existing) {
|
|
93
88
|
return existing;
|
|
94
89
|
}
|
|
95
|
-
const id = `session-${Date.now()}-${Math.random()
|
|
96
|
-
.toString(36)
|
|
97
|
-
.substr(2, 9)}`;
|
|
98
90
|
// Get preset configuration
|
|
99
91
|
let preset = presetId ? configurationManager.getPresetById(presetId) : null;
|
|
100
92
|
if (!preset) {
|
|
@@ -130,31 +122,10 @@ export class SessionManager extends EventEmitter {
|
|
|
130
122
|
throw error;
|
|
131
123
|
}
|
|
132
124
|
}
|
|
133
|
-
|
|
134
|
-
const terminal = new Terminal({
|
|
135
|
-
cols: process.stdout.columns || 80,
|
|
136
|
-
rows: process.stdout.rows || 24,
|
|
137
|
-
allowProposedApi: true,
|
|
138
|
-
});
|
|
139
|
-
const session = {
|
|
140
|
-
id,
|
|
141
|
-
worktreePath,
|
|
142
|
-
process: ptyProcess,
|
|
143
|
-
state: 'busy', // Session starts as busy when created
|
|
144
|
-
output: [],
|
|
145
|
-
outputHistory: [],
|
|
146
|
-
lastActivity: new Date(),
|
|
147
|
-
isActive: false,
|
|
148
|
-
terminal,
|
|
125
|
+
return this.createSessionInternal(worktreePath, ptyProcess, commandConfig, {
|
|
149
126
|
isPrimaryCommand,
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
};
|
|
153
|
-
// Set up persistent background data handler for state detection
|
|
154
|
-
this.setupBackgroundHandler(session);
|
|
155
|
-
this.sessions.set(worktreePath, session);
|
|
156
|
-
this.emit('sessionCreated', session);
|
|
157
|
-
return session;
|
|
127
|
+
detectionStrategy: preset.detectionStrategy,
|
|
128
|
+
});
|
|
158
129
|
}
|
|
159
130
|
setupDataHandler(session) {
|
|
160
131
|
// This handler always runs for all data
|
|
@@ -304,6 +275,48 @@ export class SessionManager extends EventEmitter {
|
|
|
304
275
|
});
|
|
305
276
|
}
|
|
306
277
|
}
|
|
278
|
+
async createSessionWithDevcontainer(worktreePath, devcontainerConfig, presetId) {
|
|
279
|
+
// Check if session already exists
|
|
280
|
+
const existing = this.sessions.get(worktreePath);
|
|
281
|
+
if (existing) {
|
|
282
|
+
return existing;
|
|
283
|
+
}
|
|
284
|
+
// Execute devcontainer up command first
|
|
285
|
+
try {
|
|
286
|
+
await execAsync(devcontainerConfig.upCommand, { cwd: worktreePath });
|
|
287
|
+
}
|
|
288
|
+
catch (error) {
|
|
289
|
+
throw new Error(`Failed to start devcontainer: ${error instanceof Error ? error.message : String(error)}`);
|
|
290
|
+
}
|
|
291
|
+
// Get preset configuration
|
|
292
|
+
let preset = presetId ? configurationManager.getPresetById(presetId) : null;
|
|
293
|
+
if (!preset) {
|
|
294
|
+
preset = configurationManager.getDefaultPreset();
|
|
295
|
+
}
|
|
296
|
+
// Parse the exec command to extract arguments
|
|
297
|
+
const execParts = devcontainerConfig.execCommand.split(/\s+/);
|
|
298
|
+
const devcontainerCmd = execParts[0] || 'devcontainer'; // Should be 'devcontainer'
|
|
299
|
+
const execArgs = execParts.slice(1); // Rest of the exec command args
|
|
300
|
+
// Build the full command: devcontainer exec [args] -- [preset command] [preset args]
|
|
301
|
+
const fullArgs = [
|
|
302
|
+
...execArgs,
|
|
303
|
+
'--',
|
|
304
|
+
preset.command,
|
|
305
|
+
...(preset.args || []),
|
|
306
|
+
];
|
|
307
|
+
// Spawn the process within devcontainer
|
|
308
|
+
const ptyProcess = await this.spawn(devcontainerCmd, fullArgs, worktreePath);
|
|
309
|
+
const commandConfig = {
|
|
310
|
+
command: preset.command,
|
|
311
|
+
args: preset.args,
|
|
312
|
+
fallbackArgs: preset.fallbackArgs,
|
|
313
|
+
};
|
|
314
|
+
return this.createSessionInternal(worktreePath, ptyProcess, commandConfig, {
|
|
315
|
+
isPrimaryCommand: true,
|
|
316
|
+
detectionStrategy: preset.detectionStrategy,
|
|
317
|
+
devcontainerConfig,
|
|
318
|
+
});
|
|
319
|
+
}
|
|
307
320
|
destroy() {
|
|
308
321
|
// Clean up all sessions
|
|
309
322
|
for (const worktreePath of this.sessions.keys()) {
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
-
import { SessionManager } from './sessionManager.js';
|
|
3
|
-
import { configurationManager } from './configurationManager.js';
|
|
4
2
|
import { spawn } from 'node-pty';
|
|
5
3
|
import { EventEmitter } from 'events';
|
|
4
|
+
import { exec } from 'child_process';
|
|
6
5
|
// Mock node-pty
|
|
7
6
|
vi.mock('node-pty');
|
|
7
|
+
// Mock child_process
|
|
8
|
+
vi.mock('child_process', () => ({
|
|
9
|
+
exec: vi.fn(),
|
|
10
|
+
}));
|
|
8
11
|
// Mock configuration manager
|
|
9
12
|
vi.mock('./configurationManager.js', () => ({
|
|
10
13
|
configurationManager: {
|
|
@@ -28,6 +31,10 @@ vi.mock('@xterm/headless', () => ({
|
|
|
28
31
|
})),
|
|
29
32
|
},
|
|
30
33
|
}));
|
|
34
|
+
// Mock worktreeService
|
|
35
|
+
vi.mock('./worktreeService.js', () => ({
|
|
36
|
+
WorktreeService: vi.fn(),
|
|
37
|
+
}));
|
|
31
38
|
// Create a mock IPty class
|
|
32
39
|
class MockPty extends EventEmitter {
|
|
33
40
|
constructor() {
|
|
@@ -71,53 +78,146 @@ class MockPty extends EventEmitter {
|
|
|
71
78
|
describe('SessionManager', () => {
|
|
72
79
|
let sessionManager;
|
|
73
80
|
let mockPty;
|
|
74
|
-
|
|
81
|
+
let SessionManager;
|
|
82
|
+
let configurationManager;
|
|
83
|
+
beforeEach(async () => {
|
|
75
84
|
vi.clearAllMocks();
|
|
85
|
+
// Dynamically import after mocks are set up
|
|
86
|
+
const sessionManagerModule = await import('./sessionManager.js');
|
|
87
|
+
const configManagerModule = await import('./configurationManager.js');
|
|
88
|
+
SessionManager = sessionManagerModule.SessionManager;
|
|
89
|
+
configurationManager = configManagerModule.configurationManager;
|
|
76
90
|
sessionManager = new SessionManager();
|
|
77
91
|
mockPty = new MockPty();
|
|
78
92
|
});
|
|
79
93
|
afterEach(() => {
|
|
80
94
|
sessionManager.destroy();
|
|
81
95
|
});
|
|
82
|
-
describe('
|
|
83
|
-
it('should
|
|
84
|
-
// Setup mock
|
|
85
|
-
vi.mocked(configurationManager.
|
|
96
|
+
describe('createSessionWithPreset', () => {
|
|
97
|
+
it('should use default preset when no preset ID specified', async () => {
|
|
98
|
+
// Setup mock preset
|
|
99
|
+
vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
|
|
100
|
+
id: '1',
|
|
101
|
+
name: 'Main',
|
|
86
102
|
command: 'claude',
|
|
103
|
+
args: ['--preset-arg'],
|
|
87
104
|
});
|
|
88
105
|
// Setup spawn mock
|
|
89
106
|
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
90
|
-
// Create session
|
|
91
|
-
await sessionManager.
|
|
92
|
-
// Verify spawn was called with
|
|
93
|
-
expect(spawn).toHaveBeenCalledWith('claude', [], {
|
|
107
|
+
// Create session with preset
|
|
108
|
+
await sessionManager.createSessionWithPreset('/test/worktree');
|
|
109
|
+
// Verify spawn was called with preset config
|
|
110
|
+
expect(spawn).toHaveBeenCalledWith('claude', ['--preset-arg'], {
|
|
94
111
|
name: 'xterm-color',
|
|
95
112
|
cols: expect.any(Number),
|
|
96
113
|
rows: expect.any(Number),
|
|
97
114
|
cwd: '/test/worktree',
|
|
98
115
|
env: process.env,
|
|
99
116
|
});
|
|
100
|
-
// Session creation verified by spawn being called
|
|
101
117
|
});
|
|
102
|
-
it('should
|
|
103
|
-
// Setup mock
|
|
104
|
-
vi.mocked(configurationManager.
|
|
118
|
+
it('should use specific preset when ID provided', async () => {
|
|
119
|
+
// Setup mock preset
|
|
120
|
+
vi.mocked(configurationManager.getPresetById).mockReturnValue({
|
|
121
|
+
id: '2',
|
|
122
|
+
name: 'Development',
|
|
105
123
|
command: 'claude',
|
|
106
|
-
args: ['--resume', '--
|
|
124
|
+
args: ['--resume', '--dev'],
|
|
125
|
+
fallbackArgs: ['--no-mcp'],
|
|
107
126
|
});
|
|
108
127
|
// Setup spawn mock
|
|
109
128
|
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
110
|
-
// Create session
|
|
111
|
-
await sessionManager.
|
|
112
|
-
// Verify
|
|
113
|
-
expect(
|
|
129
|
+
// Create session with specific preset
|
|
130
|
+
await sessionManager.createSessionWithPreset('/test/worktree', '2');
|
|
131
|
+
// Verify getPresetById was called with correct ID
|
|
132
|
+
expect(configurationManager.getPresetById).toHaveBeenCalledWith('2');
|
|
133
|
+
// Verify spawn was called with preset config
|
|
134
|
+
expect(spawn).toHaveBeenCalledWith('claude', ['--resume', '--dev'], {
|
|
135
|
+
name: 'xterm-color',
|
|
136
|
+
cols: expect.any(Number),
|
|
137
|
+
rows: expect.any(Number),
|
|
114
138
|
cwd: '/test/worktree',
|
|
115
|
-
|
|
116
|
-
|
|
139
|
+
env: process.env,
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
it('should fall back to default preset if specified preset not found', async () => {
|
|
143
|
+
// Setup mocks
|
|
144
|
+
vi.mocked(configurationManager.getPresetById).mockReturnValue(undefined);
|
|
145
|
+
vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
|
|
146
|
+
id: '1',
|
|
147
|
+
name: 'Main',
|
|
148
|
+
command: 'claude',
|
|
149
|
+
});
|
|
150
|
+
// Setup spawn mock
|
|
151
|
+
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
152
|
+
// Create session with non-existent preset
|
|
153
|
+
await sessionManager.createSessionWithPreset('/test/worktree', 'invalid');
|
|
154
|
+
// Verify fallback to default preset
|
|
155
|
+
expect(configurationManager.getDefaultPreset).toHaveBeenCalled();
|
|
156
|
+
expect(spawn).toHaveBeenCalledWith('claude', [], expect.any(Object));
|
|
157
|
+
});
|
|
158
|
+
it('should try fallback args with preset if main command fails', async () => {
|
|
159
|
+
// Setup mock preset with fallback
|
|
160
|
+
vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
|
|
161
|
+
id: '1',
|
|
162
|
+
name: 'Main',
|
|
163
|
+
command: 'claude',
|
|
164
|
+
args: ['--bad-flag'],
|
|
165
|
+
fallbackArgs: ['--good-flag'],
|
|
166
|
+
});
|
|
167
|
+
// Mock spawn to fail first, succeed second
|
|
168
|
+
let callCount = 0;
|
|
169
|
+
vi.mocked(spawn).mockImplementation(() => {
|
|
170
|
+
callCount++;
|
|
171
|
+
if (callCount === 1) {
|
|
172
|
+
throw new Error('Command failed');
|
|
173
|
+
}
|
|
174
|
+
return mockPty;
|
|
175
|
+
});
|
|
176
|
+
// Create session
|
|
177
|
+
await sessionManager.createSessionWithPreset('/test/worktree');
|
|
178
|
+
// Verify both attempts were made
|
|
179
|
+
expect(spawn).toHaveBeenCalledTimes(2);
|
|
180
|
+
expect(spawn).toHaveBeenNthCalledWith(1, 'claude', ['--bad-flag'], expect.any(Object));
|
|
181
|
+
expect(spawn).toHaveBeenNthCalledWith(2, 'claude', ['--good-flag'], expect.any(Object));
|
|
182
|
+
});
|
|
183
|
+
it('should return existing session if already created', async () => {
|
|
184
|
+
// Setup mock preset
|
|
185
|
+
vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
|
|
186
|
+
id: '1',
|
|
187
|
+
name: 'Main',
|
|
188
|
+
command: 'claude',
|
|
189
|
+
});
|
|
190
|
+
// Setup spawn mock
|
|
191
|
+
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
192
|
+
// Create session twice
|
|
193
|
+
const session1 = await sessionManager.createSessionWithPreset('/test/worktree');
|
|
194
|
+
const session2 = await sessionManager.createSessionWithPreset('/test/worktree');
|
|
195
|
+
// Should return the same session
|
|
196
|
+
expect(session1).toBe(session2);
|
|
197
|
+
// Spawn should only be called once
|
|
198
|
+
expect(spawn).toHaveBeenCalledTimes(1);
|
|
199
|
+
});
|
|
200
|
+
it('should throw error when spawn fails with fallback args', async () => {
|
|
201
|
+
// Setup mock preset with fallback
|
|
202
|
+
vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
|
|
203
|
+
id: '1',
|
|
204
|
+
name: 'Main',
|
|
205
|
+
command: 'nonexistent-command',
|
|
206
|
+
args: ['--flag1'],
|
|
207
|
+
fallbackArgs: ['--flag2'],
|
|
208
|
+
});
|
|
209
|
+
// Mock spawn to always throw error
|
|
210
|
+
vi.mocked(spawn).mockImplementation(() => {
|
|
211
|
+
throw new Error('Command not found');
|
|
212
|
+
});
|
|
213
|
+
// Expect createSessionWithPreset to throw the original error
|
|
214
|
+
await expect(sessionManager.createSessionWithPreset('/test/worktree')).rejects.toThrow('Command not found');
|
|
117
215
|
});
|
|
118
216
|
it('should use fallback args when main command exits with code 1', async () => {
|
|
119
|
-
// Setup mock
|
|
120
|
-
vi.mocked(configurationManager.
|
|
217
|
+
// Setup mock preset with fallback
|
|
218
|
+
vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
|
|
219
|
+
id: '1',
|
|
220
|
+
name: 'Main',
|
|
121
221
|
command: 'claude',
|
|
122
222
|
args: ['--invalid-flag'],
|
|
123
223
|
fallbackArgs: ['--resume'],
|
|
@@ -130,7 +230,7 @@ describe('SessionManager', () => {
|
|
|
130
230
|
.mockReturnValueOnce(firstMockPty)
|
|
131
231
|
.mockReturnValueOnce(secondMockPty);
|
|
132
232
|
// Create session
|
|
133
|
-
const session = await sessionManager.
|
|
233
|
+
const session = await sessionManager.createSessionWithPreset('/test/worktree');
|
|
134
234
|
// Verify initial spawn
|
|
135
235
|
expect(spawn).toHaveBeenCalledTimes(1);
|
|
136
236
|
expect(spawn).toHaveBeenCalledWith('claude', ['--invalid-flag'], expect.objectContaining({ cwd: '/test/worktree' }));
|
|
@@ -145,38 +245,11 @@ describe('SessionManager', () => {
|
|
|
145
245
|
expect(session.process).toBe(secondMockPty);
|
|
146
246
|
expect(session.isPrimaryCommand).toBe(false);
|
|
147
247
|
});
|
|
148
|
-
it('should throw error when spawn fails and no fallback configured', async () => {
|
|
149
|
-
// Setup mock configuration without fallback
|
|
150
|
-
vi.mocked(configurationManager.getCommandConfig).mockReturnValue({
|
|
151
|
-
command: 'claude',
|
|
152
|
-
args: ['--invalid-flag'],
|
|
153
|
-
});
|
|
154
|
-
// Mock spawn to throw error
|
|
155
|
-
vi.mocked(spawn).mockImplementation(() => {
|
|
156
|
-
throw new Error('spawn failed');
|
|
157
|
-
});
|
|
158
|
-
// Expect createSession to throw
|
|
159
|
-
await expect(sessionManager.createSession('/test/worktree')).rejects.toThrow('spawn failed');
|
|
160
|
-
});
|
|
161
|
-
it('should handle custom command configuration', async () => {
|
|
162
|
-
// Setup mock configuration with custom command
|
|
163
|
-
vi.mocked(configurationManager.getCommandConfig).mockReturnValue({
|
|
164
|
-
command: 'my-custom-claude',
|
|
165
|
-
args: ['--config', '/path/to/config'],
|
|
166
|
-
});
|
|
167
|
-
// Setup spawn mock
|
|
168
|
-
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
169
|
-
// Create session
|
|
170
|
-
await sessionManager.createSession('/test/worktree');
|
|
171
|
-
// Verify spawn was called with custom command
|
|
172
|
-
expect(spawn).toHaveBeenCalledWith('my-custom-claude', ['--config', '/path/to/config'], expect.objectContaining({
|
|
173
|
-
cwd: '/test/worktree',
|
|
174
|
-
}));
|
|
175
|
-
// Session creation verified by spawn being called
|
|
176
|
-
});
|
|
177
248
|
it('should not use fallback if main command succeeds', async () => {
|
|
178
|
-
// Setup mock
|
|
179
|
-
vi.mocked(configurationManager.
|
|
249
|
+
// Setup mock preset with fallback
|
|
250
|
+
vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
|
|
251
|
+
id: '1',
|
|
252
|
+
name: 'Main',
|
|
180
253
|
command: 'claude',
|
|
181
254
|
args: ['--resume'],
|
|
182
255
|
fallbackArgs: ['--other-flag'],
|
|
@@ -184,53 +257,57 @@ describe('SessionManager', () => {
|
|
|
184
257
|
// Setup spawn mock - process doesn't exit early
|
|
185
258
|
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
186
259
|
// Create session
|
|
187
|
-
await sessionManager.
|
|
260
|
+
await sessionManager.createSessionWithPreset('/test/worktree');
|
|
188
261
|
// Wait a bit to ensure no early exit
|
|
189
262
|
await new Promise(resolve => setTimeout(resolve, 600));
|
|
190
263
|
// Verify only one spawn attempt
|
|
191
264
|
expect(spawn).toHaveBeenCalledTimes(1);
|
|
192
265
|
expect(spawn).toHaveBeenCalledWith('claude', ['--resume'], expect.objectContaining({ cwd: '/test/worktree' }));
|
|
193
|
-
// Session creation verified by spawn being called
|
|
194
266
|
});
|
|
195
|
-
it('should
|
|
196
|
-
// Setup mock
|
|
197
|
-
vi.mocked(configurationManager.
|
|
198
|
-
|
|
267
|
+
it('should handle custom command configuration', async () => {
|
|
268
|
+
// Setup mock preset with custom command
|
|
269
|
+
vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
|
|
270
|
+
id: '1',
|
|
271
|
+
name: 'Main',
|
|
272
|
+
command: 'my-custom-claude',
|
|
273
|
+
args: ['--config', '/path/to/config'],
|
|
199
274
|
});
|
|
200
275
|
// Setup spawn mock
|
|
201
276
|
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
202
|
-
// Create session
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
expect(spawn).toHaveBeenCalledTimes(1);
|
|
277
|
+
// Create session
|
|
278
|
+
await sessionManager.createSessionWithPreset('/test/worktree');
|
|
279
|
+
// Verify spawn was called with custom command
|
|
280
|
+
expect(spawn).toHaveBeenCalledWith('my-custom-claude', ['--config', '/path/to/config'], expect.objectContaining({
|
|
281
|
+
cwd: '/test/worktree',
|
|
282
|
+
}));
|
|
209
283
|
});
|
|
210
|
-
it('should throw error when spawn fails
|
|
211
|
-
// Setup mock
|
|
212
|
-
vi.mocked(configurationManager.
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
284
|
+
it('should throw error when spawn fails and no fallback configured', async () => {
|
|
285
|
+
// Setup mock preset without fallback
|
|
286
|
+
vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
|
|
287
|
+
id: '1',
|
|
288
|
+
name: 'Main',
|
|
289
|
+
command: 'claude',
|
|
290
|
+
args: ['--invalid-flag'],
|
|
216
291
|
});
|
|
217
|
-
// Mock spawn to
|
|
292
|
+
// Mock spawn to throw error
|
|
218
293
|
vi.mocked(spawn).mockImplementation(() => {
|
|
219
|
-
throw new Error('
|
|
294
|
+
throw new Error('spawn failed');
|
|
220
295
|
});
|
|
221
|
-
// Expect
|
|
222
|
-
await expect(sessionManager.
|
|
296
|
+
// Expect createSessionWithPreset to throw
|
|
297
|
+
await expect(sessionManager.createSessionWithPreset('/test/worktree')).rejects.toThrow('spawn failed');
|
|
223
298
|
});
|
|
224
299
|
});
|
|
225
300
|
describe('session lifecycle', () => {
|
|
226
301
|
it('should destroy session and clean up resources', async () => {
|
|
227
302
|
// Setup
|
|
228
|
-
vi.mocked(configurationManager.
|
|
303
|
+
vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
|
|
304
|
+
id: '1',
|
|
305
|
+
name: 'Main',
|
|
229
306
|
command: 'claude',
|
|
230
307
|
});
|
|
231
308
|
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
232
309
|
// Create and destroy session
|
|
233
|
-
await sessionManager.
|
|
310
|
+
await sessionManager.createSessionWithPreset('/test/worktree');
|
|
234
311
|
sessionManager.destroySession('/test/worktree');
|
|
235
312
|
// Verify cleanup
|
|
236
313
|
expect(mockPty.kill).toHaveBeenCalled();
|
|
@@ -238,7 +315,9 @@ describe('SessionManager', () => {
|
|
|
238
315
|
});
|
|
239
316
|
it('should handle session exit event', async () => {
|
|
240
317
|
// Setup
|
|
241
|
-
vi.mocked(configurationManager.
|
|
318
|
+
vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
|
|
319
|
+
id: '1',
|
|
320
|
+
name: 'Main',
|
|
242
321
|
command: 'claude',
|
|
243
322
|
});
|
|
244
323
|
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
@@ -248,7 +327,7 @@ describe('SessionManager', () => {
|
|
|
248
327
|
exitedSession = session;
|
|
249
328
|
});
|
|
250
329
|
// Create session
|
|
251
|
-
const createdSession = await sessionManager.
|
|
330
|
+
const createdSession = await sessionManager.createSessionWithPreset('/test/worktree');
|
|
252
331
|
// Simulate process exit after successful creation
|
|
253
332
|
setTimeout(() => {
|
|
254
333
|
mockPty.emit('exit', { exitCode: 0 });
|
|
@@ -259,27 +338,46 @@ describe('SessionManager', () => {
|
|
|
259
338
|
expect(sessionManager.getSession('/test/worktree')).toBeUndefined();
|
|
260
339
|
});
|
|
261
340
|
});
|
|
262
|
-
describe('
|
|
263
|
-
|
|
341
|
+
describe('createSessionWithDevcontainer', () => {
|
|
342
|
+
beforeEach(() => {
|
|
343
|
+
// Reset shouldFail flag
|
|
344
|
+
const mockExec = vi.mocked(exec);
|
|
345
|
+
mockExec.shouldFail = false;
|
|
346
|
+
// Setup exec mock to work with promisify
|
|
347
|
+
mockExec.mockImplementation(((...args) => {
|
|
348
|
+
const [command, , callback] = args;
|
|
349
|
+
if (callback) {
|
|
350
|
+
// Handle callback style
|
|
351
|
+
if (command.includes('devcontainer up')) {
|
|
352
|
+
if (mockExec.shouldFail) {
|
|
353
|
+
callback(new Error('Container startup failed'));
|
|
354
|
+
}
|
|
355
|
+
else {
|
|
356
|
+
callback(null, '', '');
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}));
|
|
361
|
+
});
|
|
362
|
+
it('should execute devcontainer up command before creating session', async () => {
|
|
264
363
|
// Setup mock preset
|
|
265
364
|
vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
|
|
266
365
|
id: '1',
|
|
267
366
|
name: 'Main',
|
|
268
367
|
command: 'claude',
|
|
269
|
-
args: ['--
|
|
368
|
+
args: ['--resume'],
|
|
270
369
|
});
|
|
271
370
|
// Setup spawn mock
|
|
272
371
|
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
273
|
-
// Create session with
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
});
|
|
372
|
+
// Create session with devcontainer
|
|
373
|
+
const devcontainerConfig = {
|
|
374
|
+
upCommand: 'devcontainer up --workspace-folder .',
|
|
375
|
+
execCommand: 'devcontainer exec --workspace-folder .',
|
|
376
|
+
};
|
|
377
|
+
await sessionManager.createSessionWithDevcontainer('/test/worktree', devcontainerConfig);
|
|
378
|
+
// Verify spawn was called correctly which proves devcontainer up succeeded
|
|
379
|
+
// Verify spawn was called with devcontainer exec
|
|
380
|
+
expect(spawn).toHaveBeenCalledWith('devcontainer', ['exec', '--workspace-folder', '.', '--', 'claude', '--resume'], expect.objectContaining({ cwd: '/test/worktree' }));
|
|
283
381
|
});
|
|
284
382
|
it('should use specific preset when ID provided', async () => {
|
|
285
383
|
// Setup mock preset
|
|
@@ -288,76 +386,194 @@ describe('SessionManager', () => {
|
|
|
288
386
|
name: 'Development',
|
|
289
387
|
command: 'claude',
|
|
290
388
|
args: ['--resume', '--dev'],
|
|
291
|
-
fallbackArgs: ['--no-mcp'],
|
|
292
389
|
});
|
|
293
390
|
// Setup spawn mock
|
|
294
391
|
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
295
|
-
// Create session with specific preset
|
|
296
|
-
|
|
297
|
-
|
|
392
|
+
// Create session with devcontainer and specific preset
|
|
393
|
+
const devcontainerConfig = {
|
|
394
|
+
upCommand: 'devcontainer up',
|
|
395
|
+
execCommand: 'devcontainer exec',
|
|
396
|
+
};
|
|
397
|
+
await sessionManager.createSessionWithDevcontainer('/test/worktree', devcontainerConfig, '2');
|
|
398
|
+
// Verify correct preset was used
|
|
298
399
|
expect(configurationManager.getPresetById).toHaveBeenCalledWith('2');
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
400
|
+
expect(spawn).toHaveBeenCalledWith('devcontainer', ['exec', '--', 'claude', '--resume', '--dev'], expect.any(Object));
|
|
401
|
+
});
|
|
402
|
+
it('should throw error when devcontainer up fails', async () => {
|
|
403
|
+
// Setup exec to fail
|
|
404
|
+
const mockExec = vi.mocked(exec);
|
|
405
|
+
mockExec.shouldFail = true;
|
|
406
|
+
// Create session with devcontainer
|
|
407
|
+
const devcontainerConfig = {
|
|
408
|
+
upCommand: 'devcontainer up',
|
|
409
|
+
execCommand: 'devcontainer exec',
|
|
410
|
+
};
|
|
411
|
+
await expect(sessionManager.createSessionWithDevcontainer('/test/worktree', devcontainerConfig)).rejects.toThrow('Failed to start devcontainer: Container startup failed');
|
|
412
|
+
});
|
|
413
|
+
it('should return existing session if already created', async () => {
|
|
414
|
+
// Setup mock preset
|
|
415
|
+
vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
|
|
416
|
+
id: '1',
|
|
417
|
+
name: 'Main',
|
|
418
|
+
command: 'claude',
|
|
306
419
|
});
|
|
420
|
+
// Setup spawn mock
|
|
421
|
+
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
422
|
+
const devcontainerConfig = {
|
|
423
|
+
upCommand: 'devcontainer up',
|
|
424
|
+
execCommand: 'devcontainer exec',
|
|
425
|
+
};
|
|
426
|
+
// Create session twice
|
|
427
|
+
const session1 = await sessionManager.createSessionWithDevcontainer('/test/worktree', devcontainerConfig);
|
|
428
|
+
const session2 = await sessionManager.createSessionWithDevcontainer('/test/worktree', devcontainerConfig);
|
|
429
|
+
// Should return the same session
|
|
430
|
+
expect(session1).toBe(session2);
|
|
431
|
+
// spawn should only be called once
|
|
432
|
+
expect(spawn).toHaveBeenCalledTimes(1);
|
|
307
433
|
});
|
|
308
|
-
it('should
|
|
309
|
-
// Setup
|
|
310
|
-
vi.mocked(configurationManager.getPresetById).mockReturnValue(undefined);
|
|
434
|
+
it('should handle complex exec commands with multiple arguments', async () => {
|
|
435
|
+
// Setup mock preset
|
|
311
436
|
vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
|
|
312
437
|
id: '1',
|
|
313
438
|
name: 'Main',
|
|
314
439
|
command: 'claude',
|
|
440
|
+
args: ['--model', 'opus'],
|
|
315
441
|
});
|
|
316
442
|
// Setup spawn mock
|
|
317
443
|
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
318
|
-
// Create session with
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
444
|
+
// Create session with complex exec command
|
|
445
|
+
const devcontainerConfig = {
|
|
446
|
+
upCommand: 'devcontainer up --workspace-folder . --log-level debug',
|
|
447
|
+
execCommand: 'devcontainer exec --workspace-folder . --container-name mycontainer',
|
|
448
|
+
};
|
|
449
|
+
await sessionManager.createSessionWithDevcontainer('/test/worktree', devcontainerConfig);
|
|
450
|
+
// Verify spawn was called with properly parsed exec command
|
|
451
|
+
expect(spawn).toHaveBeenCalledWith('devcontainer', [
|
|
452
|
+
'exec',
|
|
453
|
+
'--workspace-folder',
|
|
454
|
+
'.',
|
|
455
|
+
'--container-name',
|
|
456
|
+
'mycontainer',
|
|
457
|
+
'--',
|
|
458
|
+
'claude',
|
|
459
|
+
'--model',
|
|
460
|
+
'opus',
|
|
461
|
+
], expect.any(Object));
|
|
323
462
|
});
|
|
324
|
-
it('should
|
|
325
|
-
//
|
|
463
|
+
it('should spawn process with devcontainer exec command', async () => {
|
|
464
|
+
// Create a new session manager and reset mocks
|
|
465
|
+
vi.clearAllMocks();
|
|
466
|
+
sessionManager = new SessionManager();
|
|
326
467
|
vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
|
|
327
468
|
id: '1',
|
|
328
469
|
name: 'Main',
|
|
329
470
|
command: 'claude',
|
|
330
|
-
args: [
|
|
331
|
-
fallbackArgs: ['--good-flag'],
|
|
471
|
+
args: [],
|
|
332
472
|
});
|
|
333
|
-
//
|
|
334
|
-
|
|
335
|
-
vi.mocked(
|
|
336
|
-
|
|
337
|
-
if (
|
|
338
|
-
|
|
473
|
+
// Setup spawn mock
|
|
474
|
+
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
475
|
+
const mockExec = vi.mocked(exec);
|
|
476
|
+
mockExec.mockImplementation((cmd, options, callback) => {
|
|
477
|
+
if (typeof options === 'function') {
|
|
478
|
+
callback = options;
|
|
479
|
+
options = undefined;
|
|
339
480
|
}
|
|
340
|
-
|
|
481
|
+
if (callback && typeof callback === 'function') {
|
|
482
|
+
callback(null, 'Container started', '');
|
|
483
|
+
}
|
|
484
|
+
return {};
|
|
341
485
|
});
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
expect(spawn).
|
|
486
|
+
await sessionManager.createSessionWithDevcontainer('/test/worktree2', {
|
|
487
|
+
upCommand: 'devcontainer up --workspace-folder .',
|
|
488
|
+
execCommand: 'devcontainer exec --workspace-folder .',
|
|
489
|
+
});
|
|
490
|
+
// Should spawn with devcontainer exec command
|
|
491
|
+
expect(spawn).toHaveBeenCalledWith('devcontainer', ['exec', '--workspace-folder', '.', '--', 'claude'], expect.objectContaining({
|
|
492
|
+
cwd: '/test/worktree2',
|
|
493
|
+
}));
|
|
494
|
+
});
|
|
495
|
+
it('should use preset with devcontainer', async () => {
|
|
496
|
+
const mockExec = vi.mocked(exec);
|
|
497
|
+
mockExec.mockImplementation((cmd, options, callback) => {
|
|
498
|
+
if (typeof options === 'function') {
|
|
499
|
+
callback = options;
|
|
500
|
+
options = undefined;
|
|
501
|
+
}
|
|
502
|
+
if (callback && typeof callback === 'function') {
|
|
503
|
+
callback(null, 'Container started', '');
|
|
504
|
+
}
|
|
505
|
+
return {};
|
|
506
|
+
});
|
|
507
|
+
await sessionManager.createSessionWithDevcontainer('/test/worktree', {
|
|
508
|
+
upCommand: 'devcontainer up --workspace-folder .',
|
|
509
|
+
execCommand: 'devcontainer exec --workspace-folder .',
|
|
510
|
+
}, 'custom-preset');
|
|
511
|
+
// Should call createSessionWithPreset internally
|
|
512
|
+
const session = sessionManager.getSession('/test/worktree');
|
|
513
|
+
expect(session).toBeDefined();
|
|
514
|
+
expect(session?.devcontainerConfig).toEqual({
|
|
515
|
+
upCommand: 'devcontainer up --workspace-folder .',
|
|
516
|
+
execCommand: 'devcontainer exec --workspace-folder .',
|
|
517
|
+
});
|
|
518
|
+
});
|
|
519
|
+
it('should parse exec command and append preset command', async () => {
|
|
520
|
+
const mockExec = vi.mocked(exec);
|
|
521
|
+
mockExec.mockImplementation((cmd, options, callback) => {
|
|
522
|
+
if (typeof options === 'function') {
|
|
523
|
+
callback = options;
|
|
524
|
+
options = undefined;
|
|
525
|
+
}
|
|
526
|
+
if (callback && typeof callback === 'function') {
|
|
527
|
+
callback(null, 'Container started', '');
|
|
528
|
+
}
|
|
529
|
+
return {};
|
|
530
|
+
});
|
|
531
|
+
const config = {
|
|
532
|
+
upCommand: 'devcontainer up --workspace-folder /path/to/project',
|
|
533
|
+
execCommand: 'devcontainer exec --workspace-folder /path/to/project --user vscode',
|
|
534
|
+
};
|
|
535
|
+
await sessionManager.createSessionWithDevcontainer('/test/worktree', config);
|
|
536
|
+
expect(spawn).toHaveBeenCalledWith('devcontainer', [
|
|
537
|
+
'exec',
|
|
538
|
+
'--workspace-folder',
|
|
539
|
+
'/path/to/project',
|
|
540
|
+
'--user',
|
|
541
|
+
'vscode',
|
|
542
|
+
'--',
|
|
543
|
+
'claude',
|
|
544
|
+
], expect.any(Object));
|
|
348
545
|
});
|
|
349
|
-
it('should
|
|
350
|
-
|
|
351
|
-
|
|
546
|
+
it('should handle preset with args in devcontainer', async () => {
|
|
547
|
+
const mockExec = vi.mocked(exec);
|
|
548
|
+
mockExec.mockImplementation((cmd, options, callback) => {
|
|
549
|
+
if (typeof options === 'function') {
|
|
550
|
+
callback = options;
|
|
551
|
+
options = undefined;
|
|
552
|
+
}
|
|
553
|
+
if (callback && typeof callback === 'function') {
|
|
554
|
+
callback(null, 'Container started', '');
|
|
555
|
+
}
|
|
556
|
+
return {};
|
|
557
|
+
});
|
|
558
|
+
vi.mocked(configurationManager.getPresetById).mockReturnValue({
|
|
559
|
+
id: 'claude-with-args',
|
|
560
|
+
name: 'Claude with Args',
|
|
352
561
|
command: 'claude',
|
|
353
|
-
args: ['
|
|
562
|
+
args: ['-m', 'claude-3-opus'],
|
|
354
563
|
});
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
564
|
+
await sessionManager.createSessionWithDevcontainer('/test/worktree', {
|
|
565
|
+
upCommand: 'devcontainer up --workspace-folder .',
|
|
566
|
+
execCommand: 'devcontainer exec --workspace-folder .',
|
|
567
|
+
}, 'claude-with-args');
|
|
568
|
+
expect(spawn).toHaveBeenCalledWith('devcontainer', [
|
|
569
|
+
'exec',
|
|
570
|
+
'--workspace-folder',
|
|
571
|
+
'.',
|
|
572
|
+
'--',
|
|
573
|
+
'claude',
|
|
574
|
+
'-m',
|
|
575
|
+
'claude-3-opus',
|
|
576
|
+
], expect.any(Object));
|
|
361
577
|
});
|
|
362
578
|
});
|
|
363
579
|
});
|
package/dist/types/index.d.ts
CHANGED
|
@@ -26,10 +26,10 @@ export interface Session {
|
|
|
26
26
|
isPrimaryCommand?: boolean;
|
|
27
27
|
commandConfig?: CommandConfig;
|
|
28
28
|
detectionStrategy?: StateDetectionStrategy;
|
|
29
|
+
devcontainerConfig?: DevcontainerConfig;
|
|
29
30
|
}
|
|
30
31
|
export interface SessionManager {
|
|
31
32
|
sessions: Map<string, Session>;
|
|
32
|
-
createSession(worktreePath: string): Promise<Session>;
|
|
33
33
|
getSession(worktreePath: string): Session | undefined;
|
|
34
34
|
destroySession(worktreePath: string): void;
|
|
35
35
|
getAllSessions(): Session[];
|
|
@@ -76,6 +76,10 @@ export interface CommandPresetsConfig {
|
|
|
76
76
|
defaultPresetId: string;
|
|
77
77
|
selectPresetOnStart?: boolean;
|
|
78
78
|
}
|
|
79
|
+
export interface DevcontainerConfig {
|
|
80
|
+
upCommand: string;
|
|
81
|
+
execCommand: string;
|
|
82
|
+
}
|
|
79
83
|
export interface ConfigurationData {
|
|
80
84
|
shortcuts?: ShortcutConfig;
|
|
81
85
|
statusHooks?: StatusHookConfig;
|