@assistkick/create 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.
Files changed (49) hide show
  1. package/package.json +2 -1
  2. package/templates/assistkick-product-system/packages/backend/src/routes/git.ts +231 -0
  3. package/templates/assistkick-product-system/packages/backend/src/routes/kanban.ts +4 -4
  4. package/templates/assistkick-product-system/packages/backend/src/routes/pipeline.ts +49 -2
  5. package/templates/assistkick-product-system/packages/backend/src/routes/terminal.ts +82 -0
  6. package/templates/assistkick-product-system/packages/backend/src/server.ts +19 -6
  7. package/templates/assistkick-product-system/packages/backend/src/services/github_app_service.ts +146 -0
  8. package/templates/assistkick-product-system/packages/backend/src/services/init.ts +69 -2
  9. package/templates/assistkick-product-system/packages/backend/src/services/project_service.ts +71 -0
  10. package/templates/assistkick-product-system/packages/backend/src/services/project_workspace_service.test.ts +87 -0
  11. package/templates/assistkick-product-system/packages/backend/src/services/project_workspace_service.ts +194 -0
  12. package/templates/assistkick-product-system/packages/backend/src/services/pty_session_manager.test.ts +88 -17
  13. package/templates/assistkick-product-system/packages/backend/src/services/pty_session_manager.ts +114 -39
  14. package/templates/assistkick-product-system/packages/backend/src/services/terminal_ws_handler.ts +28 -14
  15. package/templates/assistkick-product-system/packages/frontend/src/App.tsx +1 -1
  16. package/templates/assistkick-product-system/packages/frontend/src/api/client.ts +151 -0
  17. package/templates/assistkick-product-system/packages/frontend/src/components/GitRepoModal.tsx +352 -0
  18. package/templates/assistkick-product-system/packages/frontend/src/components/KanbanView.tsx +208 -95
  19. package/templates/assistkick-product-system/packages/frontend/src/components/ProjectSelector.tsx +17 -1
  20. package/templates/assistkick-product-system/packages/frontend/src/components/TerminalView.tsx +238 -105
  21. package/templates/assistkick-product-system/packages/frontend/src/components/Toolbar.tsx +15 -13
  22. package/templates/assistkick-product-system/packages/frontend/src/constants/graph.ts +1 -0
  23. package/templates/assistkick-product-system/packages/frontend/src/hooks/useProjects.ts +4 -0
  24. package/templates/assistkick-product-system/packages/frontend/src/routes/dashboard.tsx +22 -4
  25. package/templates/assistkick-product-system/packages/frontend/src/styles/index.css +486 -38
  26. package/templates/assistkick-product-system/packages/shared/db/migrations/0001_vengeful_wallop.sql +1 -0
  27. package/templates/assistkick-product-system/packages/shared/db/migrations/0002_greedy_excalibur.sql +4 -0
  28. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0001_snapshot.json +826 -0
  29. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0002_snapshot.json +854 -0
  30. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/_journal.json +14 -0
  31. package/templates/assistkick-product-system/packages/shared/db/schema.ts +5 -0
  32. package/templates/assistkick-product-system/packages/shared/lib/claude-service.ts +54 -1
  33. package/templates/assistkick-product-system/packages/shared/lib/git_workflow.ts +25 -0
  34. package/templates/assistkick-product-system/packages/shared/lib/pipeline-state-store.ts +4 -0
  35. package/templates/assistkick-product-system/packages/shared/lib/pipeline.ts +329 -89
  36. package/templates/assistkick-product-system/packages/shared/lib/pipeline_orchestrator.ts +186 -0
  37. package/templates/assistkick-product-system/packages/shared/tools/db_explorer.ts +275 -0
  38. package/templates/assistkick-product-system/packages/shared/tools/get_kanban.ts +2 -1
  39. package/templates/assistkick-product-system/packages/shared/tools/move_card.ts +3 -2
  40. package/templates/assistkick-product-system/packages/shared/tools/update_node.ts +2 -2
  41. package/templates/assistkick-product-system/tests/kanban.test.ts +1 -1
  42. package/templates/assistkick-product-system/tests/pipeline_stats_all_cards.test.ts +1 -1
  43. package/templates/assistkick-product-system/tests/web_terminal.test.ts +189 -150
  44. package/templates/skills/assistkick-bootstrap/SKILL.md +33 -25
  45. package/templates/skills/assistkick-code-reviewer/SKILL.md +23 -15
  46. package/templates/skills/assistkick-db-explorer/SKILL.md +86 -0
  47. package/templates/skills/assistkick-debugger/SKILL.md +30 -22
  48. package/templates/skills/assistkick-developer/SKILL.md +37 -29
  49. package/templates/skills/assistkick-interview/SKILL.md +34 -26
@@ -1,15 +1,13 @@
1
1
  /**
2
- * PTY Session Manager — manages restricted command execution per user.
2
+ * PTY Session Manager — manages named, project-scoped terminal sessions.
3
3
  * Implements dec_029 (Command Prefix Whitelist) and dec_030 (Server-Side PTY Session Persistence).
4
4
  *
5
- * Architecture: No interactive shell is spawned. Instead, the server manages a
6
- * command-line state machine that collects user input character-by-character,
7
- * validates complete commands against the whitelist, then spawns only the
8
- * validated command as a PTY process. While a command is running, raw input
9
- * is forwarded to it. When the command exits, the session returns to idle.
5
+ * Each session has a unique ID, a human-readable name (project name + creation timestamp),
6
+ * and is permanently bound to a project. Sessions persist across WebSocket disconnects.
10
7
  */
11
8
 
12
9
  import type { IPty } from 'node-pty';
10
+ import { randomBytes } from 'node:crypto';
13
11
 
14
12
  const OUTPUT_BUFFER_MAX = 50_000;
15
13
 
@@ -27,7 +25,10 @@ interface PtySessionManagerDeps {
27
25
  }
28
26
 
29
27
  interface PtySession {
30
- userId: string;
28
+ id: string;
29
+ name: string;
30
+ projectId: string;
31
+ projectName: string;
31
32
  pty: IPty | null;
32
33
  state: 'idle' | 'running';
33
34
  cols: number;
@@ -38,6 +39,15 @@ interface PtySession {
38
39
  createdAt: Date;
39
40
  }
40
41
 
42
+ export interface SessionInfo {
43
+ id: string;
44
+ name: string;
45
+ projectId: string;
46
+ projectName: string;
47
+ state: 'idle' | 'running';
48
+ createdAt: string;
49
+ }
50
+
41
51
  export class PtySessionManager {
42
52
  private readonly sessions = new Map<string, PtySession>();
43
53
  private readonly spawn: PtySessionManagerDeps['spawn'];
@@ -50,6 +60,20 @@ export class PtySessionManager {
50
60
  this.projectRoot = projectRoot;
51
61
  }
52
62
 
63
+ generateId = (): string => {
64
+ const hex = randomBytes(3).toString('hex');
65
+ return `term_${hex}`;
66
+ };
67
+
68
+ formatSessionName = (projectName: string, date: Date): string => {
69
+ const dd = String(date.getDate()).padStart(2, '0');
70
+ const mm = String(date.getMonth() + 1).padStart(2, '0');
71
+ const yy = String(date.getFullYear()).slice(2);
72
+ const hh = String(date.getHours()).padStart(2, '0');
73
+ const min = String(date.getMinutes()).padStart(2, '0');
74
+ return `${projectName} - ${dd}/${mm}/${yy} - ${hh}:${min}`;
75
+ };
76
+
53
77
  validateCommand = (input: string): { valid: boolean; error?: string } => {
54
78
  const trimmed = input.trim();
55
79
  if (!trimmed) {
@@ -69,19 +93,31 @@ export class PtySessionManager {
69
93
  return { valid: true };
70
94
  };
71
95
 
72
- getSession = (userId: string): PtySession | undefined => {
73
- return this.sessions.get(userId);
96
+ listSessions = (): SessionInfo[] => {
97
+ return Array.from(this.sessions.values()).map(s => ({
98
+ id: s.id,
99
+ name: s.name,
100
+ projectId: s.projectId,
101
+ projectName: s.projectName,
102
+ state: s.state,
103
+ createdAt: s.createdAt.toISOString(),
104
+ }));
74
105
  };
75
106
 
76
- createSession = (userId: string, cols: number, rows: number): PtySession => {
77
- const existing = this.sessions.get(userId);
78
- if (existing) {
79
- this.log('PTY', `Reusing existing session for user ${userId}`);
80
- return existing;
81
- }
107
+ getSession = (sessionId: string): PtySession | undefined => {
108
+ return this.sessions.get(sessionId);
109
+ };
110
+
111
+ createSession = (projectId: string, projectName: string, cols: number, rows: number): PtySession => {
112
+ const id = this.generateId();
113
+ const createdAt = new Date();
114
+ const name = this.formatSessionName(projectName, createdAt);
82
115
 
83
116
  const session: PtySession = {
84
- userId,
117
+ id,
118
+ name,
119
+ projectId,
120
+ projectName,
85
121
  pty: null,
86
122
  state: 'idle',
87
123
  cols,
@@ -89,35 +125,35 @@ export class PtySessionManager {
89
125
  outputBuffer: '',
90
126
  inputBuffer: '',
91
127
  listeners: new Set(),
92
- createdAt: new Date(),
128
+ createdAt,
93
129
  };
94
130
 
95
- this.sessions.set(userId, session);
96
- this.log('PTY', `Created new session for user ${userId}`);
131
+ this.sessions.set(id, session);
132
+ this.log('PTY', `Created session "${name}" (${id}) for project ${projectId}`);
97
133
 
98
- // Show welcome message and prompt
134
+ // Show welcome message then auto-launch claude with project context
99
135
  this.emitOutput(session, WELCOME_MSG);
100
- this.emitOutput(session, PROMPT);
136
+ this.autoLaunchClaude(session);
101
137
 
102
138
  return session;
103
139
  };
104
140
 
105
- addListener = (userId: string, listener: (data: string) => void): void => {
106
- const session = this.sessions.get(userId);
141
+ addListener = (sessionId: string, listener: (data: string) => void): void => {
142
+ const session = this.sessions.get(sessionId);
107
143
  if (session) {
108
144
  session.listeners.add(listener);
109
145
  }
110
146
  };
111
147
 
112
- removeListener = (userId: string, listener: (data: string) => void): void => {
113
- const session = this.sessions.get(userId);
148
+ removeListener = (sessionId: string, listener: (data: string) => void): void => {
149
+ const session = this.sessions.get(sessionId);
114
150
  if (session) {
115
151
  session.listeners.delete(listener);
116
152
  }
117
153
  };
118
154
 
119
- writeToSession = (userId: string, data: string): void => {
120
- const session = this.sessions.get(userId);
155
+ writeToSession = (sessionId: string, data: string): void => {
156
+ const session = this.sessions.get(sessionId);
121
157
  if (!session) return;
122
158
 
123
159
  if (session.state === 'running' && session.pty) {
@@ -129,8 +165,8 @@ export class PtySessionManager {
129
165
  }
130
166
  };
131
167
 
132
- resizeSession = (userId: string, cols: number, rows: number): void => {
133
- const session = this.sessions.get(userId);
168
+ resizeSession = (sessionId: string, cols: number, rows: number): void => {
169
+ const session = this.sessions.get(sessionId);
134
170
  if (session) {
135
171
  session.cols = cols;
136
172
  session.rows = rows;
@@ -140,25 +176,25 @@ export class PtySessionManager {
140
176
  }
141
177
  };
142
178
 
143
- destroySession = (userId: string): void => {
144
- const session = this.sessions.get(userId);
179
+ destroySession = (sessionId: string): void => {
180
+ const session = this.sessions.get(sessionId);
145
181
  if (session) {
146
182
  if (session.pty) {
147
183
  session.pty.kill();
148
184
  }
149
- this.sessions.delete(userId);
150
- this.log('PTY', `Destroyed session for user ${userId}`);
185
+ this.sessions.delete(sessionId);
186
+ this.log('PTY', `Destroyed session "${session.name}" (${sessionId})`);
151
187
  }
152
188
  };
153
189
 
154
190
  destroyAll = (): void => {
155
- for (const [userId] of this.sessions) {
156
- this.destroySession(userId);
191
+ for (const [sessionId] of this.sessions) {
192
+ this.destroySession(sessionId);
157
193
  }
158
194
  };
159
195
 
160
- getBufferedOutput = (userId: string): string => {
161
- const session = this.sessions.get(userId);
196
+ getBufferedOutput = (sessionId: string): string => {
197
+ const session = this.sessions.get(sessionId);
162
198
  return session?.outputBuffer ?? '';
163
199
  };
164
200
 
@@ -202,6 +238,45 @@ export class PtySessionManager {
202
238
  }
203
239
  };
204
240
 
241
+ private autoLaunchClaude = (session: PtySession): void => {
242
+ const projectContext = `We are working on project-id ${session.projectId}`;
243
+ const args = ['--dangerously-skip-permissions', '--append-system-prompt', projectContext];
244
+
245
+ this.log('PTY', `Auto-launching claude for session ${session.id} (project ${session.projectId})`);
246
+
247
+ let spawnedPty: IPty;
248
+ try {
249
+ spawnedPty = this.spawn('claude', args, {
250
+ name: 'xterm-256color',
251
+ cols: session.cols,
252
+ rows: session.rows,
253
+ cwd: this.projectRoot,
254
+ env: this.buildEnv(),
255
+ });
256
+ } catch (err) {
257
+ const msg = err instanceof Error ? err.message : String(err);
258
+ this.emitOutput(session, `\x1b[31mFailed to auto-launch claude: ${msg}\x1b[0m\r\n`);
259
+ this.log('PTY', `Auto-launch failed for session ${session.id}: ${msg}`);
260
+ this.emitOutput(session, PROMPT);
261
+ return;
262
+ }
263
+
264
+ session.pty = spawnedPty;
265
+ session.state = 'running';
266
+
267
+ spawnedPty.onData((data: string) => {
268
+ this.emitOutput(session, data);
269
+ });
270
+
271
+ spawnedPty.onExit(({ exitCode }) => {
272
+ this.log('PTY', `Auto-launched claude exited with code ${exitCode} for session ${session.id}`);
273
+ session.pty = null;
274
+ session.state = 'idle';
275
+ this.emitOutput(session, '\r\n');
276
+ this.emitOutput(session, PROMPT);
277
+ });
278
+ };
279
+
205
280
  private buildEnv = (): Record<string, string> => {
206
281
  const env = { ...process.env } as Record<string, string>;
207
282
  const home = env.HOME || '/root';
@@ -228,7 +303,7 @@ export class PtySessionManager {
228
303
  const cmd = parts[0];
229
304
  const args = parts.slice(1);
230
305
 
231
- this.log('PTY', `Executing validated command "${command}" for user ${session.userId}`);
306
+ this.log('PTY', `Executing validated command "${command}" for session ${session.id}`);
232
307
 
233
308
  let spawnedPty: IPty;
234
309
  try {
@@ -259,7 +334,7 @@ export class PtySessionManager {
259
334
  });
260
335
 
261
336
  spawnedPty.onExit(({ exitCode }) => {
262
- this.log('PTY', `Command exited with code ${exitCode} for user ${session.userId}`);
337
+ this.log('PTY', `Command exited with code ${exitCode} for session ${session.id}`);
263
338
  session.pty = null;
264
339
  session.state = 'idle';
265
340
  this.emitOutput(session, '\r\n');
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * WebSocket handler for terminal connections.
3
3
  * Authenticates via cookie, enforces admin-only access,
4
- * and bridges WebSocket ↔ PTY session.
4
+ * and bridges WebSocket ↔ PTY session by sessionId.
5
5
  */
6
6
 
7
7
  import type { IncomingMessage } from 'node:http';
@@ -50,7 +50,8 @@ export class TerminalWsHandler {
50
50
  }
51
51
 
52
52
  handleUpgrade = (req: IncomingMessage, socket: Duplex, head: Buffer): void => {
53
- if (req.url !== '/api/terminal') {
53
+ const url = new URL(req.url || '', 'http://localhost');
54
+ if (url.pathname !== '/api/terminal') {
54
55
  return;
55
56
  }
56
57
 
@@ -84,14 +85,27 @@ export class TerminalWsHandler {
84
85
  return;
85
86
  }
86
87
 
87
- const userId = payload.sub;
88
- this.log('TERMINAL', `Admin ${payload.email} connected to terminal`);
88
+ // Parse sessionId from query params
89
+ const url = new URL(req.url || '', 'http://localhost');
90
+ const sessionId = url.searchParams.get('sessionId');
89
91
 
90
- // Get or create PTY session (default 80x24, client will send resize)
91
- const session = this.ptyManager.createSession(userId, 80, 24);
92
+ if (!sessionId) {
93
+ this.log('TERMINAL', 'Connection rejected — no sessionId provided');
94
+ ws.close(4004, 'Session ID required');
95
+ return;
96
+ }
97
+
98
+ const session = this.ptyManager.getSession(sessionId);
99
+ if (!session) {
100
+ this.log('TERMINAL', `Connection rejected — session ${sessionId} not found`);
101
+ ws.close(4004, 'Session not found');
102
+ return;
103
+ }
104
+
105
+ this.log('TERMINAL', `Admin ${payload.email} connected to session ${session.name}`);
92
106
 
93
- // Send buffered output from previous session
94
- const buffered = this.ptyManager.getBufferedOutput(userId);
107
+ // Send buffered output from previous connection
108
+ const buffered = this.ptyManager.getBufferedOutput(sessionId);
95
109
  if (buffered) {
96
110
  ws.send(JSON.stringify({ type: 'output', data: buffered }));
97
111
  }
@@ -102,7 +116,7 @@ export class TerminalWsHandler {
102
116
  ws.send(JSON.stringify({ type: 'output', data }));
103
117
  }
104
118
  };
105
- this.ptyManager.addListener(userId, outputListener);
119
+ this.ptyManager.addListener(sessionId, outputListener);
106
120
 
107
121
  ws.on('message', (raw: Buffer | string) => {
108
122
  let msg: TerminalMessage;
@@ -113,21 +127,21 @@ export class TerminalWsHandler {
113
127
  }
114
128
 
115
129
  if (msg.type === 'input' && typeof msg.data === 'string') {
116
- this.ptyManager.writeToSession(userId, msg.data);
130
+ this.ptyManager.writeToSession(sessionId, msg.data);
117
131
  } else if (msg.type === 'resize' && typeof msg.cols === 'number' && typeof msg.rows === 'number') {
118
- this.ptyManager.resizeSession(userId, msg.cols, msg.rows);
132
+ this.ptyManager.resizeSession(sessionId, msg.cols, msg.rows);
119
133
  }
120
134
  });
121
135
 
122
136
  ws.on('close', () => {
123
- this.log('TERMINAL', `Admin ${payload.email} disconnected from terminal`);
124
- this.ptyManager.removeListener(userId, outputListener);
137
+ this.log('TERMINAL', `Admin ${payload.email} disconnected from session ${session.name}`);
138
+ this.ptyManager.removeListener(sessionId, outputListener);
125
139
  // PTY session persists (dec_030) — not destroyed on disconnect
126
140
  });
127
141
 
128
142
  ws.on('error', (err) => {
129
143
  this.log('TERMINAL', `WebSocket error: ${err.message}`);
130
- this.ptyManager.removeListener(userId, outputListener);
144
+ this.ptyManager.removeListener(sessionId, outputListener);
131
145
  });
132
146
  };
133
147
  }
@@ -17,7 +17,7 @@ export function App() {
17
17
  <Route path="/reset-password" element={<ResetPasswordRoute />} />
18
18
  <Route path="/accept-invitation" element={<AcceptInvitationRoute />} />
19
19
  <Route element={<ProtectedRoute />}>
20
- <Route path="/" element={<Navigate to="/graph" replace />} />
20
+ <Route path="/" element={<Navigate to="/kanban" replace />} />
21
21
  <Route path="/graph" element={<DashboardRoute />} />
22
22
  <Route path="/kanban" element={<DashboardRoute />} />
23
23
  <Route path="/coherence" element={<DashboardRoute />} />
@@ -190,6 +190,54 @@ export class ApiClient {
190
190
  return resp.json();
191
191
  };
192
192
 
193
+ // --- Orchestrator (Play All) ---
194
+
195
+ startPlayAll = async (projectId?: string) => {
196
+ const qs = projectId ? `?project_id=${encodeURIComponent(projectId)}` : '';
197
+ const resp = await this.fetchWithRefresh(`${this.baseUrl}/api/pipeline/play-all${qs}`, {
198
+ method: 'POST',
199
+ headers: { 'Content-Type': 'application/json' },
200
+ });
201
+ if (!resp.ok) {
202
+ let msg = `Failed to start Play All: ${resp.status}`;
203
+ try { const err = await resp.json(); msg = err.error || msg; } catch { /* non-JSON */ }
204
+ throw new Error(msg);
205
+ }
206
+ return resp.json();
207
+ };
208
+
209
+ stopPlayAll = async () => {
210
+ const resp = await this.fetchWithRefresh(`${this.baseUrl}/api/pipeline/stop-all`, {
211
+ method: 'POST',
212
+ headers: { 'Content-Type': 'application/json' },
213
+ });
214
+ if (!resp.ok) {
215
+ let msg = `Failed to stop Play All: ${resp.status}`;
216
+ try { const err = await resp.json(); msg = err.error || msg; } catch { /* non-JSON */ }
217
+ throw new Error(msg);
218
+ }
219
+ return resp.json();
220
+ };
221
+
222
+ getOrchestratorStatus = async () => {
223
+ const resp = await this.fetchWithRefresh(`${this.baseUrl}/api/pipeline/orchestrator-status`);
224
+ if (!resp.ok) throw new Error(`Failed to get orchestrator status: ${resp.status}`);
225
+ return resp.json();
226
+ };
227
+
228
+ resumePipeline = async (featureId: string) => {
229
+ const resp = await this.fetchWithRefresh(`${this.baseUrl}/api/kanban/${featureId}/resume`, {
230
+ method: 'POST',
231
+ headers: { 'Content-Type': 'application/json' },
232
+ });
233
+ if (!resp.ok) {
234
+ let msg = `Failed to resume pipeline: ${resp.status}`;
235
+ try { const err = await resp.json(); msg = err.error || msg; } catch { /* non-JSON response */ }
236
+ throw new Error(msg);
237
+ }
238
+ return resp.json();
239
+ };
240
+
193
241
  unblockCard = async (featureId: string) => {
194
242
  const resp = await this.fetchWithRefresh(`${this.baseUrl}/api/kanban/${featureId}/unblock`, {
195
243
  method: 'POST',
@@ -368,6 +416,109 @@ export class ApiClient {
368
416
  return resp.json();
369
417
  };
370
418
 
419
+ // --- Git repository management ---
420
+
421
+ getGitStatus = async (projectId: string) => {
422
+ const resp = await this.fetchWithRefresh(`${this.baseUrl}/api/projects/${projectId}/git/status`);
423
+ if (!resp.ok) throw new Error(`Failed to get git status: ${resp.status}`);
424
+ return resp.json();
425
+ };
426
+
427
+ connectGitRepo = async (projectId: string, opts: {
428
+ repoUrl?: string;
429
+ githubInstallationId?: string;
430
+ githubRepoFullName?: string;
431
+ baseBranch?: string;
432
+ }) => {
433
+ const resp = await this.fetchWithRefresh(`${this.baseUrl}/api/projects/${projectId}/git/connect`, {
434
+ method: 'POST',
435
+ headers: { 'Content-Type': 'application/json' },
436
+ body: JSON.stringify(opts),
437
+ });
438
+ if (!resp.ok) {
439
+ const data = await resp.json();
440
+ throw new Error(data.error || `Failed to connect repo: ${resp.status}`);
441
+ }
442
+ return resp.json();
443
+ };
444
+
445
+ initGitRepo = async (projectId: string) => {
446
+ const resp = await this.fetchWithRefresh(`${this.baseUrl}/api/projects/${projectId}/git/init`, {
447
+ method: 'POST',
448
+ headers: { 'Content-Type': 'application/json' },
449
+ });
450
+ if (!resp.ok) {
451
+ const data = await resp.json();
452
+ throw new Error(data.error || `Failed to init repo: ${resp.status}`);
453
+ }
454
+ return resp.json();
455
+ };
456
+
457
+ disconnectGitRepo = async (projectId: string) => {
458
+ const resp = await this.fetchWithRefresh(`${this.baseUrl}/api/projects/${projectId}/git/disconnect`, {
459
+ method: 'POST',
460
+ headers: { 'Content-Type': 'application/json' },
461
+ });
462
+ if (!resp.ok) {
463
+ const data = await resp.json();
464
+ throw new Error(data.error || `Failed to disconnect repo: ${resp.status}`);
465
+ }
466
+ return resp.json();
467
+ };
468
+
469
+ testGitHubApp = async (projectId: string) => {
470
+ const resp = await this.fetchWithRefresh(`${this.baseUrl}/api/projects/${projectId}/git/test`, {
471
+ method: 'POST',
472
+ headers: { 'Content-Type': 'application/json' },
473
+ });
474
+ if (!resp.ok) throw new Error(`Failed to test GitHub App: ${resp.status}`);
475
+ return resp.json();
476
+ };
477
+
478
+ listGitHubInstallations = async (projectId: string) => {
479
+ const resp = await this.fetchWithRefresh(`${this.baseUrl}/api/projects/${projectId}/git/installations`);
480
+ if (!resp.ok) throw new Error(`Failed to list installations: ${resp.status}`);
481
+ return resp.json();
482
+ };
483
+
484
+ listGitHubRepos = async (projectId: string, installationId: string) => {
485
+ const resp = await this.fetchWithRefresh(
486
+ `${this.baseUrl}/api/projects/${projectId}/git/repos?installation_id=${encodeURIComponent(installationId)}`,
487
+ );
488
+ if (!resp.ok) throw new Error(`Failed to list repos: ${resp.status}`);
489
+ return resp.json();
490
+ };
491
+
492
+ listTerminalSessions = async () => {
493
+ const resp = await this.fetchWithRefresh(`${this.baseUrl}/api/terminal/sessions`);
494
+ if (!resp.ok) throw new Error(`Failed to list terminal sessions: ${resp.status}`);
495
+ return resp.json();
496
+ };
497
+
498
+ createTerminalSession = async (projectId: string, projectName: string) => {
499
+ const resp = await this.fetchWithRefresh(`${this.baseUrl}/api/terminal/sessions`, {
500
+ method: 'POST',
501
+ headers: { 'Content-Type': 'application/json' },
502
+ body: JSON.stringify({ projectId, projectName }),
503
+ });
504
+ if (!resp.ok) {
505
+ const data = await resp.json();
506
+ throw new Error(data.error || `Failed to create terminal session: ${resp.status}`);
507
+ }
508
+ return resp.json();
509
+ };
510
+
511
+ killTerminalSession = async (sessionId: string) => {
512
+ const resp = await this.fetchWithRefresh(`${this.baseUrl}/api/terminal/sessions/${sessionId}`, {
513
+ method: 'DELETE',
514
+ });
515
+ if (!resp.ok) {
516
+ const data = await resp.json();
517
+ throw new Error(data.error || `Failed to kill terminal session: ${resp.status}`);
518
+ }
519
+ return resp.json();
520
+ };
521
+
371
522
  acceptInvitation = async (token: string, email: string, password: string) => {
372
523
  const resp = await fetch(`${this.baseUrl}/api/auth/accept-invitation`, {
373
524
  method: 'POST',