@ts47andres/exeggutor 1.1.4 → 1.1.5
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/LICENSE +201 -201
- package/README.md +230 -230
- package/bin/exeggutor.js +217 -217
- package/package.json +63 -63
- package/packages/backend/bin/FolderPicker.exe +0 -0
- package/packages/backend/git-wrapper/git +60 -60
- package/packages/backend/native/FolderPicker.cs +139 -139
- package/packages/backend/package.json +25 -25
- package/packages/backend/scripts/compile-picker.js +16 -0
- package/packages/backend/scripts/git-guard.ps1 +48 -48
- package/packages/backend/src/gitWorktree.ts +320 -320
- package/packages/backend/src/index.ts +554 -554
- package/packages/backend/src/ptyManager.ts +414 -414
- package/packages/backend/src/tailscale.ts +138 -138
- package/packages/backend/src/workspaceDb.ts +151 -151
- package/packages/frontend/dist/index.html +15 -15
- package/packages/frontend/package.json +29 -29
- package/src/autostart.js +162 -162
- package/src/cli.js +613 -613
- package/src/server-manager.js +139 -139
|
@@ -1,554 +1,554 @@
|
|
|
1
|
-
import fastify, { FastifyInstance } from 'fastify';
|
|
2
|
-
import fastifyCors from '@fastify/cors';
|
|
3
|
-
import fastifyWebsocket from '@fastify/websocket';
|
|
4
|
-
import fastifyStatic from '@fastify/static';
|
|
5
|
-
import * as path from 'path';
|
|
6
|
-
import * as fs from 'fs';
|
|
7
|
-
import * as os from 'os';
|
|
8
|
-
import * as crypto from 'crypto';
|
|
9
|
-
import * as db from './workspaceDb';
|
|
10
|
-
import * as git from './gitWorktree';
|
|
11
|
-
import * as pty from './ptyManager';
|
|
12
|
-
import * as tailscale from './tailscale';
|
|
13
|
-
|
|
14
|
-
const PORT = parseInt(process.env.EXEGGUTOR_BACKEND_PORT || '17492', 10); // Backend API port from env or default.
|
|
15
|
-
const FRONTEND_DIST = process.env.EXEGGUTOR_FRONTEND_DIST || ''; // Path to built frontend dist/ folder.
|
|
16
|
-
const configPath = path.resolve(os.homedir(), '.exeggutor.json'); // Path to CLI config file.
|
|
17
|
-
let authToken = process.env.EXEGGUTOR_AUTH_TOKEN || ''; // Active authorization token.
|
|
18
|
-
const TAILSCALE_MODE = process.env.EXEGGUTOR_TAILSCALE === '1'; // Whether to expose the server over Tailscale.
|
|
19
|
-
try {
|
|
20
|
-
if (fs.existsSync(configPath)) {
|
|
21
|
-
const cfg = JSON.parse(fs.readFileSync(configPath, 'utf8')); // Loaded configuration settings.
|
|
22
|
-
if (cfg.authToken) {
|
|
23
|
-
authToken = cfg.authToken;
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
} catch (_) {
|
|
27
|
-
// Safe ignore config load errors.
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
// Determine the host binding for the server.
|
|
31
|
-
function resolveListenHost(): string {
|
|
32
|
-
if (TAILSCALE_MODE) {
|
|
33
|
-
return '0.0.0.0'; // Bind to all interfaces so Tailscale can forward traffic.
|
|
34
|
-
}
|
|
35
|
-
return '127.0.0.1'; // Local-only binding for security.
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
const server: FastifyInstance = fastify({ logger: true }); // Fastify server instance running local services with logging enabled.
|
|
39
|
-
const observerSockets = new Set<any>(); // Registry containing all active WebSocket connections for the observer sidebar.
|
|
40
|
-
const sessionCodes = new Map<string, { token: string; expires: number }>(); // One-time session codes for dashboard authentication exchange.
|
|
41
|
-
|
|
42
|
-
// Broadcasts the latest status of all terminal sessions to all observer socket clients.
|
|
43
|
-
function broadcastObserverUpdate(): void {
|
|
44
|
-
const payload = JSON.stringify({
|
|
45
|
-
type: 'observer',
|
|
46
|
-
sessions: pty.getAllSessions(),
|
|
47
|
-
}); // Serialized payload containing status mapping of all terminal sessions.
|
|
48
|
-
observerSockets.forEach(ws => {
|
|
49
|
-
try {
|
|
50
|
-
ws.send(payload);
|
|
51
|
-
} catch (err) {
|
|
52
|
-
// Clean up failed sockets.
|
|
53
|
-
}
|
|
54
|
-
});
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
// Registers HTTP routes, WebSockets endpoints, and starts the Fastify service.
|
|
58
|
-
async function bootstrap(): Promise<void> {
|
|
59
|
-
// Clean up any orphaned terminal shell processes from previous runs.
|
|
60
|
-
await pty.cleanOrphanedPtyProcesses();
|
|
61
|
-
|
|
62
|
-
await server.register(fastifyCors, { origin: true });
|
|
63
|
-
await server.register(fastifyWebsocket);
|
|
64
|
-
|
|
65
|
-
server.addHook('preHandler', async (request, reply) => {
|
|
66
|
-
const url = request.url; // Target request URL path.
|
|
67
|
-
if ((url.startsWith('/api') || url.startsWith('/ws')) && url !== '/api/auth/exchange-session') {
|
|
68
|
-
let token = (request.query as any)?.token; // Token extracted from query parameter.
|
|
69
|
-
if (!token) {
|
|
70
|
-
const authHeader = request.headers.authorization; // Auth header content.
|
|
71
|
-
if (authHeader && authHeader.startsWith('Bearer ')) {
|
|
72
|
-
token = authHeader.substring(7);
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
if (!authToken || token !== authToken) {
|
|
76
|
-
reply.status(401).send({ error: 'Unauthorized' });
|
|
77
|
-
return reply;
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
}); // Registers authentication pre-handler hook.
|
|
81
|
-
|
|
82
|
-
// Serve built frontend statically when EXEGGUTOR_FRONTEND_DIST is set (production mode).
|
|
83
|
-
if (FRONTEND_DIST && fs.existsSync(FRONTEND_DIST)) {
|
|
84
|
-
await server.register(fastifyStatic, {
|
|
85
|
-
root: FRONTEND_DIST,
|
|
86
|
-
wildcard: false,
|
|
87
|
-
prefix: '/',
|
|
88
|
-
});
|
|
89
|
-
// SPA fallback: serve index.html for all non-API, non-WS routes.
|
|
90
|
-
server.setNotFoundHandler((request, reply) => {
|
|
91
|
-
if (request.url.startsWith('/api') || request.url.startsWith('/ws')) {
|
|
92
|
-
reply.status(404).send({ error: 'Not found' });
|
|
93
|
-
} else {
|
|
94
|
-
const indexPath = path.join(FRONTEND_DIST, 'index.html');
|
|
95
|
-
if (fs.existsSync(indexPath)) {
|
|
96
|
-
reply.type('text/html').send(fs.readFileSync(indexPath, 'utf8'));
|
|
97
|
-
} else {
|
|
98
|
-
reply.status(404).send('Not found');
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
});
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
pty.startStatusAuditor(broadcastObserverUpdate);
|
|
105
|
-
|
|
106
|
-
server.post('/api/auth/issue-session', async (request, reply) => {
|
|
107
|
-
const code = crypto.randomBytes(16).toString('hex'); // Unpredictable one-time session code.
|
|
108
|
-
sessionCodes.set(code, { token: authToken, expires: Date.now() + 30000 }); // Code valid for 30 seconds.
|
|
109
|
-
return { code };
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
server.post('/api/auth/exchange-session', async (request, reply) => {
|
|
113
|
-
const { code } = request.body as any; // Session code from the URL.
|
|
114
|
-
const entry = sessionCodes.get(code); // Lookup matching code entry.
|
|
115
|
-
if (!entry || entry.expires < Date.now()) {
|
|
116
|
-
reply.status(401).send({ error: 'Invalid or expired session code' });
|
|
117
|
-
return reply;
|
|
118
|
-
}
|
|
119
|
-
sessionCodes.delete(code); // Invalidate code after use.
|
|
120
|
-
return { token: entry.token };
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
setInterval(() => {
|
|
124
|
-
const now = Date.now(); // Current timestamp.
|
|
125
|
-
sessionCodes.forEach((entry, code) => {
|
|
126
|
-
if (entry.expires < now) {
|
|
127
|
-
sessionCodes.delete(code);
|
|
128
|
-
}
|
|
129
|
-
}); // Purge expired session codes every 60 seconds.
|
|
130
|
-
}, 60000);
|
|
131
|
-
|
|
132
|
-
server.get('/api/workspaces', async (request, reply) => {
|
|
133
|
-
const list = db.getWorkspaces(); // Workspace list from database.
|
|
134
|
-
return list;
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
server.post<{ Body: { name: string; path: string } }>(
|
|
138
|
-
'/api/workspaces',
|
|
139
|
-
async (request, reply) => {
|
|
140
|
-
const { name, path: folderPath } = request.body; // Deconstructed parameters from request body.
|
|
141
|
-
if (!name || typeof name !== 'string' || name.trim().length === 0) {
|
|
142
|
-
reply.status(400);
|
|
143
|
-
return { error: 'Workspace name is required' };
|
|
144
|
-
}
|
|
145
|
-
if (name.trim().length > 100) {
|
|
146
|
-
reply.status(400);
|
|
147
|
-
return { error: 'Workspace name must be 100 characters or fewer' };
|
|
148
|
-
}
|
|
149
|
-
if (!folderPath || typeof folderPath !== 'string' || folderPath.trim().length === 0) {
|
|
150
|
-
reply.status(400);
|
|
151
|
-
return { error: 'Workspace path is required' };
|
|
152
|
-
}
|
|
153
|
-
const resolvedPath = path.resolve(folderPath.trim()); // Resolved absolute target path.
|
|
154
|
-
if (!fs.existsSync(resolvedPath)) {
|
|
155
|
-
reply.status(400);
|
|
156
|
-
return { error: 'Workspace path does not exist' };
|
|
157
|
-
}
|
|
158
|
-
if (!fs.statSync(resolvedPath).isDirectory()) {
|
|
159
|
-
reply.status(400);
|
|
160
|
-
return { error: 'Workspace path must be a directory' };
|
|
161
|
-
}
|
|
162
|
-
try {
|
|
163
|
-
fs.accessSync(resolvedPath, fs.constants.R_OK);
|
|
164
|
-
} catch {
|
|
165
|
-
reply.status(400);
|
|
166
|
-
return { error: 'Workspace path is not readable' };
|
|
167
|
-
}
|
|
168
|
-
const createdWs = db.createWorkspace(name.trim(), resolvedPath); // Reference to the created workspace.
|
|
169
|
-
return createdWs;
|
|
170
|
-
}
|
|
171
|
-
);
|
|
172
|
-
|
|
173
|
-
server.delete<{ Params: { id: string } }>('/api/workspaces/:id', async (request, reply) => {
|
|
174
|
-
const id = request.params.id; // Target workspace ID.
|
|
175
|
-
console.log(`[API] DELETE /api/workspaces/${id} -> deleting workspace`);
|
|
176
|
-
const wsList = db.getWorkspaces(); // Entire registered workspaces array.
|
|
177
|
-
const targetWs = wsList.find(w => w.id === id); // Found workspace object matching the target ID.
|
|
178
|
-
if (targetWs) {
|
|
179
|
-
console.log(`[API] Deleting workspace "${targetWs.name}" (id=${id}) with ${targetWs.tabs.length} tabs`);
|
|
180
|
-
await Promise.all(
|
|
181
|
-
targetWs.tabs.map(async tab => {
|
|
182
|
-
console.log(`[API] Cleaning up tab ${tab.id} for workspace delete`);
|
|
183
|
-
await pty.killPtySession(tab.id);
|
|
184
|
-
if (tab.worktreePath) {
|
|
185
|
-
try {
|
|
186
|
-
await git.removeGitWorktree(targetWs.path, tab.worktreePath);
|
|
187
|
-
} catch (err) {
|
|
188
|
-
// Ignore pruning issues.
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
})
|
|
192
|
-
); // Run PTY shutdowns and worktree cleanups concurrently.
|
|
193
|
-
db.deleteWorkspace(id);
|
|
194
|
-
console.log(`[API] Workspace ${id} deleted`);
|
|
195
|
-
}
|
|
196
|
-
const successResp = { success: true }; // Server confirmation object.
|
|
197
|
-
return successResp;
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
server.put<{ Params: { id: string }; Body: Partial<Omit<db.Workspace, 'id' | 'tabs'>> }>(
|
|
201
|
-
'/api/workspaces/:id',
|
|
202
|
-
async (request, reply) => {
|
|
203
|
-
const id = request.params.id; // Target workspace ID.
|
|
204
|
-
const updates = request.body; // Updated attributes from body.
|
|
205
|
-
const updated = db.updateWorkspace(id, updates); // Workspace with applied patches.
|
|
206
|
-
const returnResult = updated; // Refactored return workspace.
|
|
207
|
-
return returnResult;
|
|
208
|
-
}
|
|
209
|
-
);
|
|
210
|
-
|
|
211
|
-
const MAX_TABS_PER_WORKSPACE = 4; // Maximum number of terminal tabs allowed per workspace.
|
|
212
|
-
|
|
213
|
-
server.post<{ Params: { id: string }; Body: { name: string; shell?: string } }>(
|
|
214
|
-
'/api/workspaces/:id/tabs',
|
|
215
|
-
async (request, reply) => {
|
|
216
|
-
const id = request.params.id; // Parent workspace ID.
|
|
217
|
-
const { name, shell } = request.body; // Input body variables.
|
|
218
|
-
console.log(`[API] POST /api/workspaces/${id}/tabs -> creating tab name="${name}" shell="${shell}"`);
|
|
219
|
-
const targetWs = db.getWorkspaces().find(w => w.id === id); // Find matching workspace database entry.
|
|
220
|
-
if (!targetWs) {
|
|
221
|
-
reply.status(404);
|
|
222
|
-
const errorResp = { error: 'Workspace not found' }; // Missing workspace error.
|
|
223
|
-
return errorResp;
|
|
224
|
-
}
|
|
225
|
-
if (targetWs.tabs.length >= MAX_TABS_PER_WORKSPACE) {
|
|
226
|
-
reply.status(400);
|
|
227
|
-
const errorResp = { error: `Maximum ${MAX_TABS_PER_WORKSPACE} terminal tabs per workspace` }; // Tab limit error.
|
|
228
|
-
return errorResp;
|
|
229
|
-
}
|
|
230
|
-
const tab = db.createTerminalTab(id, name, targetWs.path, shell); // New tab object created.
|
|
231
|
-
if (tab) {
|
|
232
|
-
console.log(`[API] Tab created: id=${tab.id} name="${name}" cwd=${tab.cwd}`);
|
|
233
|
-
}
|
|
234
|
-
const returnResult = tab; // Created tab descriptor.
|
|
235
|
-
return returnResult;
|
|
236
|
-
}
|
|
237
|
-
);
|
|
238
|
-
|
|
239
|
-
server.put<{ Params: { id: string; tabId: string }; Body: { name?: string; branch?: string } }>(
|
|
240
|
-
'/api/workspaces/:id/tabs/:tabId',
|
|
241
|
-
async (request, reply) => {
|
|
242
|
-
const { id, tabId } = request.params; // Workspace and tab parameter keys.
|
|
243
|
-
const { name, branch } = request.body; // Extracted updates.
|
|
244
|
-
console.log(`[API] PUT /api/workspaces/${id}/tabs/${tabId} -> name="${name}" branch="${branch}"`);
|
|
245
|
-
const targetWs = db.getWorkspaces().find(w => w.id === id); // Target workspace object.
|
|
246
|
-
if (!targetWs) {
|
|
247
|
-
reply.status(404);
|
|
248
|
-
const errorResp = { error: 'Workspace not found' }; // Error payload.
|
|
249
|
-
return errorResp;
|
|
250
|
-
}
|
|
251
|
-
const tab = targetWs.tabs.find(t => t.id === tabId); // Selected terminal tab.
|
|
252
|
-
if (!tab) {
|
|
253
|
-
reply.status(404);
|
|
254
|
-
const errorResp = { error: 'Terminal tab not found' }; // Error payload.
|
|
255
|
-
return errorResp;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
let worktreePath = tab.worktreePath; // Preserve current worktree path reference.
|
|
259
|
-
let newCwd = tab.cwd; // Preserve current target current working directory.
|
|
260
|
-
|
|
261
|
-
if (branch !== undefined && branch !== tab.branch) {
|
|
262
|
-
console.log(`[API] Branch change detected for tab ${tabId}: "${tab.branch}" -> "${branch}", killing PTY`);
|
|
263
|
-
await pty.killPtySession(tabId);
|
|
264
|
-
if (tab.worktreePath) {
|
|
265
|
-
try {
|
|
266
|
-
await git.removeGitWorktree(targetWs.path, tab.worktreePath);
|
|
267
|
-
} catch (err) {
|
|
268
|
-
// Prune error.
|
|
269
|
-
}
|
|
270
|
-
worktreePath = undefined;
|
|
271
|
-
newCwd = targetWs.path;
|
|
272
|
-
}
|
|
273
|
-
if (branch && branch.trim().length > 0) {
|
|
274
|
-
try {
|
|
275
|
-
const wPath = await git.setupGitWorktree(targetWs.path, branch); // Spawn isolated git worktree folder.
|
|
276
|
-
worktreePath = wPath;
|
|
277
|
-
newCwd = wPath;
|
|
278
|
-
} catch (err: any) {
|
|
279
|
-
reply.status(400);
|
|
280
|
-
const errorResp = { error: err.message || 'Failed to setup git worktree' }; // Setup error.
|
|
281
|
-
return errorResp;
|
|
282
|
-
}
|
|
283
|
-
} else {
|
|
284
|
-
newCwd = targetWs.path;
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
const updates: Partial<db.TerminalTab> = {}; // Config updates map.
|
|
289
|
-
if (name !== undefined) {
|
|
290
|
-
updates.name = name;
|
|
291
|
-
}
|
|
292
|
-
if (branch !== undefined) {
|
|
293
|
-
updates.branch = branch ? branch : undefined;
|
|
294
|
-
updates.worktreePath = worktreePath;
|
|
295
|
-
updates.cwd = newCwd;
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
const updated = db.updateTerminalTab(id, tabId, updates); // Flush updates to sessions database.
|
|
299
|
-
const returnResult = updated; // Updated tab object.
|
|
300
|
-
return returnResult;
|
|
301
|
-
}
|
|
302
|
-
);
|
|
303
|
-
|
|
304
|
-
server.post<{ Params: { id: string; tabId: string }; Body: { name: string } }>(
|
|
305
|
-
'/api/workspaces/:id/tabs/:tabId/branches',
|
|
306
|
-
async (request, reply) => {
|
|
307
|
-
const { id, tabId } = request.params; // Workspace and tab keys.
|
|
308
|
-
const { name: branchName } = request.body; // New branch name.
|
|
309
|
-
console.log(`[API] POST /api/workspaces/${id}/tabs/${tabId}/branches -> branch="${branchName}"`);
|
|
310
|
-
const targetWs = db.getWorkspaces().find(w => w.id === id); // Found workspace.
|
|
311
|
-
if (!targetWs) {
|
|
312
|
-
reply.status(404);
|
|
313
|
-
const errorResp = { error: 'Workspace not found' }; // Error payload.
|
|
314
|
-
return errorResp;
|
|
315
|
-
}
|
|
316
|
-
const tab = targetWs.tabs.find(t => t.id === tabId); // Target tab config.
|
|
317
|
-
if (!tab) {
|
|
318
|
-
reply.status(404);
|
|
319
|
-
const errorResp = { error: 'Terminal tab not found' }; // Error.
|
|
320
|
-
return errorResp;
|
|
321
|
-
}
|
|
322
|
-
try {
|
|
323
|
-
await git.createBranch(targetWs.path, branchName);
|
|
324
|
-
await pty.killPtySession(tabId);
|
|
325
|
-
if (tab.worktreePath) {
|
|
326
|
-
try {
|
|
327
|
-
await git.removeGitWorktree(targetWs.path, tab.worktreePath);
|
|
328
|
-
} catch (err) {
|
|
329
|
-
// Prune error.
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
const wPath = await git.setupGitWorktree(targetWs.path, branchName); // Create new worktree directly.
|
|
333
|
-
const updated = db.updateTerminalTab(id, tabId, {
|
|
334
|
-
branch: branchName,
|
|
335
|
-
worktreePath: wPath,
|
|
336
|
-
cwd: wPath,
|
|
337
|
-
}); // Apply updates to database tab.
|
|
338
|
-
const returnResult = updated; // Tab payload.
|
|
339
|
-
return returnResult;
|
|
340
|
-
} catch (err: any) {
|
|
341
|
-
reply.status(400);
|
|
342
|
-
const errorResp = { error: err.message || 'Failed to create git branch' }; // Error payload.
|
|
343
|
-
return errorResp;
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
);
|
|
347
|
-
|
|
348
|
-
server.delete<{ Params: { id: string; tabId: string } }>(
|
|
349
|
-
'/api/workspaces/:id/tabs/:tabId',
|
|
350
|
-
async (request, reply) => {
|
|
351
|
-
const { id, tabId } = request.params; // Tab and workspace parameters.
|
|
352
|
-
console.log(`[API] DELETE /api/workspaces/${id}/tabs/${tabId} -> deleting tab`);
|
|
353
|
-
const targetWs = db.getWorkspaces().find(w => w.id === id); // Found workspace.
|
|
354
|
-
if (targetWs) {
|
|
355
|
-
const tab = targetWs.tabs.find(t => t.id === tabId); // Target tab config.
|
|
356
|
-
console.log(`[API] Killing PTY and cleaning up worktree for tab ${tabId}`);
|
|
357
|
-
await pty.killPtySession(tabId);
|
|
358
|
-
if (tab && tab.worktreePath) {
|
|
359
|
-
try {
|
|
360
|
-
await git.removeGitWorktree(targetWs.path, tab.worktreePath);
|
|
361
|
-
} catch (err) {
|
|
362
|
-
// Ignore.
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
db.deleteTerminalTab(id, tabId);
|
|
366
|
-
console.log(`[API] Tab ${tabId} deleted`);
|
|
367
|
-
}
|
|
368
|
-
const successResp = { success: true }; // Operation complete.
|
|
369
|
-
return successResp;
|
|
370
|
-
}
|
|
371
|
-
);
|
|
372
|
-
|
|
373
|
-
server.get<{ Params: { id: string } }>('/api/workspaces/:id/git/branches', async (request, reply) => {
|
|
374
|
-
const id = request.params.id; // Target workspace ID.
|
|
375
|
-
const ws = db.getWorkspaces().find(w => w.id === id); // Target workspace instance.
|
|
376
|
-
if (!ws) {
|
|
377
|
-
reply.status(404);
|
|
378
|
-
const errorResp = { error: 'Workspace not found' }; // Error workspace message.
|
|
379
|
-
return errorResp;
|
|
380
|
-
}
|
|
381
|
-
try {
|
|
382
|
-
const branches = await git.getBranches(ws.path); // Retrieve active branch array from workspace folder.
|
|
383
|
-
const returnResult = branches; // Branches array result.
|
|
384
|
-
return returnResult;
|
|
385
|
-
} catch (err: any) {
|
|
386
|
-
reply.status(500);
|
|
387
|
-
const errorResp = { error: err.message || 'Failed to query git branches' }; // Error payload.
|
|
388
|
-
return errorResp;
|
|
389
|
-
}
|
|
390
|
-
});
|
|
391
|
-
|
|
392
|
-
server.get<{ Querystring: { name: string } }>('/api/branches/in-use', async (request, reply) => {
|
|
393
|
-
const { name } = request.query; // Branch name to check.
|
|
394
|
-
const allWorkspaces = db.getWorkspaces(); // All registered workspaces.
|
|
395
|
-
let inUse = false; // Default safety flag.
|
|
396
|
-
for (const ws of allWorkspaces) {
|
|
397
|
-
for (const tab of ws.tabs) {
|
|
398
|
-
if (tab.branch === name) {
|
|
399
|
-
inUse = true;
|
|
400
|
-
break;
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
|
-
if (inUse) { break; }
|
|
404
|
-
}
|
|
405
|
-
const result = { inUse }; // Response payload.
|
|
406
|
-
return result;
|
|
407
|
-
});
|
|
408
|
-
|
|
409
|
-
// Returns the current Tailscale status and connection info for the server.
|
|
410
|
-
server.get('/api/tailscale/status', async (request, reply) => {
|
|
411
|
-
const installed = tailscale.isTailscaleInstalled(); // Whether the tailscale binary exists on PATH.
|
|
412
|
-
const info = installed ? tailscale.getTailscaleInfo() : null; // Parsed tailscale status, null if not connected.
|
|
413
|
-
return {
|
|
414
|
-
installed,
|
|
415
|
-
connected: info !== null,
|
|
416
|
-
tailscale: info,
|
|
417
|
-
tailscaleMode: TAILSCALE_MODE,
|
|
418
|
-
};
|
|
419
|
-
});
|
|
420
|
-
|
|
421
|
-
server.get('/api/browse', async (request, reply) => {
|
|
422
|
-
try {
|
|
423
|
-
const folder = await git.showFolderPicker();
|
|
424
|
-
if (!folder) {
|
|
425
|
-
// User cancelled the dialog — not an error.
|
|
426
|
-
return { path: '', cancelled: true };
|
|
427
|
-
}
|
|
428
|
-
return { path: folder };
|
|
429
|
-
} catch (err: any) {
|
|
430
|
-
reply.status(500);
|
|
431
|
-
return { path: '', error: err.message || 'Failed to open folder picker' };
|
|
432
|
-
}
|
|
433
|
-
});
|
|
434
|
-
|
|
435
|
-
server.route({
|
|
436
|
-
method: 'GET',
|
|
437
|
-
url: '/ws/observer',
|
|
438
|
-
handler: (request, reply) => {
|
|
439
|
-
reply.status(400).send('WebSocket connection expected');
|
|
440
|
-
},
|
|
441
|
-
wsHandler: (connection, req) => {
|
|
442
|
-
observerSockets.add(connection);
|
|
443
|
-
const initialPayload = JSON.stringify({
|
|
444
|
-
type: 'observer',
|
|
445
|
-
sessions: pty.getAllSessions(),
|
|
446
|
-
}); // Initial data load payload for new client.
|
|
447
|
-
connection.send(initialPayload);
|
|
448
|
-
connection.on('close', () => {
|
|
449
|
-
observerSockets.delete(connection);
|
|
450
|
-
});
|
|
451
|
-
},
|
|
452
|
-
});
|
|
453
|
-
|
|
454
|
-
server.route({
|
|
455
|
-
method: 'GET',
|
|
456
|
-
url: '/ws/terminal/:tabId',
|
|
457
|
-
handler: (request, reply) => {
|
|
458
|
-
reply.status(400).send('WebSocket connection expected');
|
|
459
|
-
},
|
|
460
|
-
wsHandler: async (connection, req) => {
|
|
461
|
-
const tabId = (req.params as any).tabId; // Extract tabId from parameters.
|
|
462
|
-
console.log(`[WS] Terminal WebSocket connect: tabId=${tabId}`);
|
|
463
|
-
const workspaces = db.getWorkspaces(); // Load workspaces registry.
|
|
464
|
-
let activeTab: db.TerminalTab | undefined = undefined; // Matches the target active tab.
|
|
465
|
-
let activeWs: db.Workspace | undefined = undefined; // Matches the parent workspace containing the tab.
|
|
466
|
-
for (const ws of workspaces) {
|
|
467
|
-
const found = ws.tabs.find(t => t.id === tabId); // Match lookup.
|
|
468
|
-
if (found) {
|
|
469
|
-
activeTab = found;
|
|
470
|
-
activeWs = ws;
|
|
471
|
-
break;
|
|
472
|
-
}
|
|
473
|
-
}
|
|
474
|
-
if (!activeTab || !activeWs) {
|
|
475
|
-
console.log(`[WS] Terminal tab ${tabId} not found in any workspace, closing connection`);
|
|
476
|
-
connection.close(4001, 'Terminal tab not found');
|
|
477
|
-
return;
|
|
478
|
-
}
|
|
479
|
-
console.log(`[WS] Calling getOrCreatePtySession for tab ${tabId} (workspace=${activeWs.name}, cwd=${activeTab.cwd})`);
|
|
480
|
-
let session: pty.TerminalSession; // Reference to the created or retrieved terminal session.
|
|
481
|
-
try {
|
|
482
|
-
session = await pty.getOrCreatePtySession(
|
|
483
|
-
activeWs.id,
|
|
484
|
-
activeTab.id,
|
|
485
|
-
activeTab.cwd,
|
|
486
|
-
activeTab.shell,
|
|
487
|
-
broadcastObserverUpdate
|
|
488
|
-
); // Await spawn or attach of persistent session process (serialized on Windows).
|
|
489
|
-
} catch (err: any) {
|
|
490
|
-
console.log(`[WS] getOrCreatePtySession failed for tab ${tabId}: ${err.message}`);
|
|
491
|
-
connection.close(4002, 'Terminal session creation failed');
|
|
492
|
-
return;
|
|
493
|
-
}
|
|
494
|
-
console.log(`[WS] getOrCreatePtySession returned for tab ${tabId}, PID=${session.ptyProcess.pid}`);
|
|
495
|
-
|
|
496
|
-
session.activeSocket = connection; // Register the newly connected socket as the active connection.
|
|
497
|
-
|
|
498
|
-
session.outputBuffer.forEach(chunk => {
|
|
499
|
-
connection.send(chunk);
|
|
500
|
-
});
|
|
501
|
-
|
|
502
|
-
// Handles incoming data from the persistent PTY process by sending it over the WebSocket.
|
|
503
|
-
session.onData = (data: string) => {
|
|
504
|
-
try {
|
|
505
|
-
if (session.activeSocket === connection) {
|
|
506
|
-
connection.send(data);
|
|
507
|
-
}
|
|
508
|
-
} catch (err) {
|
|
509
|
-
// Socket write failed.
|
|
510
|
-
}
|
|
511
|
-
};
|
|
512
|
-
|
|
513
|
-
// Receives input and resize instructions from the WebSocket and forwards them to the PTY session.
|
|
514
|
-
connection.on('message', (messageData: any) => {
|
|
515
|
-
const rawMessage = messageData.toString(); // Normalized string representation of the incoming socket message.
|
|
516
|
-
try {
|
|
517
|
-
const parsed = JSON.parse(rawMessage); // Parsed client websocket message.
|
|
518
|
-
if (parsed && parsed.type === 'resize') {
|
|
519
|
-
pty.resizePtySession(tabId, parsed.cols, parsed.rows);
|
|
520
|
-
} else if (parsed && parsed.type === 'input') {
|
|
521
|
-
pty.writeToPtySession(tabId, parsed.data);
|
|
522
|
-
}
|
|
523
|
-
} catch (err) {
|
|
524
|
-
// Ignore invalid JSON payloads to prevent writing garbage or raw JSON controls to the shell.
|
|
525
|
-
}
|
|
526
|
-
});
|
|
527
|
-
|
|
528
|
-
// Disconnects the WebSocket connection and marks the PTY session active socket as empty.
|
|
529
|
-
connection.on('close', () => {
|
|
530
|
-
console.log(`[WS] Terminal WebSocket disconnect: tabId=${tabId}`);
|
|
531
|
-
if (session.activeSocket === connection) {
|
|
532
|
-
session.activeSocket = undefined;
|
|
533
|
-
session.onData = () => {};
|
|
534
|
-
}
|
|
535
|
-
});
|
|
536
|
-
},
|
|
537
|
-
});
|
|
538
|
-
|
|
539
|
-
const listenHost = resolveListenHost(); // Network interface to bind the server to.
|
|
540
|
-
await server.listen({ port: PORT, host: listenHost });
|
|
541
|
-
const tailscaleInfo = TAILSCALE_MODE ? tailscale.getTailscaleInfo() : null; // Information about the current Tailscale connection.
|
|
542
|
-
if (tailscaleInfo) {
|
|
543
|
-
console.log(`Tailscale URL: http://${tailscaleInfo.ip}:${PORT}`);
|
|
544
|
-
if (tailscaleInfo.dnsName) {
|
|
545
|
-
console.log(`Tailscale DNS: https://${tailscaleInfo.dnsName}:${PORT}`);
|
|
546
|
-
}
|
|
547
|
-
}
|
|
548
|
-
console.log(`Backend daemon is listening on port ${PORT} (bound to ${listenHost})`);
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
bootstrap().catch(err => {
|
|
552
|
-
console.error('Fatal initialization error:', err);
|
|
553
|
-
process.exit(1);
|
|
554
|
-
});
|
|
1
|
+
import fastify, { FastifyInstance } from 'fastify';
|
|
2
|
+
import fastifyCors from '@fastify/cors';
|
|
3
|
+
import fastifyWebsocket from '@fastify/websocket';
|
|
4
|
+
import fastifyStatic from '@fastify/static';
|
|
5
|
+
import * as path from 'path';
|
|
6
|
+
import * as fs from 'fs';
|
|
7
|
+
import * as os from 'os';
|
|
8
|
+
import * as crypto from 'crypto';
|
|
9
|
+
import * as db from './workspaceDb';
|
|
10
|
+
import * as git from './gitWorktree';
|
|
11
|
+
import * as pty from './ptyManager';
|
|
12
|
+
import * as tailscale from './tailscale';
|
|
13
|
+
|
|
14
|
+
const PORT = parseInt(process.env.EXEGGUTOR_BACKEND_PORT || '17492', 10); // Backend API port from env or default.
|
|
15
|
+
const FRONTEND_DIST = process.env.EXEGGUTOR_FRONTEND_DIST || ''; // Path to built frontend dist/ folder.
|
|
16
|
+
const configPath = path.resolve(os.homedir(), '.exeggutor.json'); // Path to CLI config file.
|
|
17
|
+
let authToken = process.env.EXEGGUTOR_AUTH_TOKEN || ''; // Active authorization token.
|
|
18
|
+
const TAILSCALE_MODE = process.env.EXEGGUTOR_TAILSCALE === '1'; // Whether to expose the server over Tailscale.
|
|
19
|
+
try {
|
|
20
|
+
if (fs.existsSync(configPath)) {
|
|
21
|
+
const cfg = JSON.parse(fs.readFileSync(configPath, 'utf8')); // Loaded configuration settings.
|
|
22
|
+
if (cfg.authToken) {
|
|
23
|
+
authToken = cfg.authToken;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
} catch (_) {
|
|
27
|
+
// Safe ignore config load errors.
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Determine the host binding for the server.
|
|
31
|
+
function resolveListenHost(): string {
|
|
32
|
+
if (TAILSCALE_MODE) {
|
|
33
|
+
return '0.0.0.0'; // Bind to all interfaces so Tailscale can forward traffic.
|
|
34
|
+
}
|
|
35
|
+
return '127.0.0.1'; // Local-only binding for security.
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const server: FastifyInstance = fastify({ logger: true }); // Fastify server instance running local services with logging enabled.
|
|
39
|
+
const observerSockets = new Set<any>(); // Registry containing all active WebSocket connections for the observer sidebar.
|
|
40
|
+
const sessionCodes = new Map<string, { token: string; expires: number }>(); // One-time session codes for dashboard authentication exchange.
|
|
41
|
+
|
|
42
|
+
// Broadcasts the latest status of all terminal sessions to all observer socket clients.
|
|
43
|
+
function broadcastObserverUpdate(): void {
|
|
44
|
+
const payload = JSON.stringify({
|
|
45
|
+
type: 'observer',
|
|
46
|
+
sessions: pty.getAllSessions(),
|
|
47
|
+
}); // Serialized payload containing status mapping of all terminal sessions.
|
|
48
|
+
observerSockets.forEach(ws => {
|
|
49
|
+
try {
|
|
50
|
+
ws.send(payload);
|
|
51
|
+
} catch (err) {
|
|
52
|
+
// Clean up failed sockets.
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Registers HTTP routes, WebSockets endpoints, and starts the Fastify service.
|
|
58
|
+
async function bootstrap(): Promise<void> {
|
|
59
|
+
// Clean up any orphaned terminal shell processes from previous runs.
|
|
60
|
+
await pty.cleanOrphanedPtyProcesses();
|
|
61
|
+
|
|
62
|
+
await server.register(fastifyCors, { origin: true });
|
|
63
|
+
await server.register(fastifyWebsocket);
|
|
64
|
+
|
|
65
|
+
server.addHook('preHandler', async (request, reply) => {
|
|
66
|
+
const url = request.url; // Target request URL path.
|
|
67
|
+
if ((url.startsWith('/api') || url.startsWith('/ws')) && url !== '/api/auth/exchange-session') {
|
|
68
|
+
let token = (request.query as any)?.token; // Token extracted from query parameter.
|
|
69
|
+
if (!token) {
|
|
70
|
+
const authHeader = request.headers.authorization; // Auth header content.
|
|
71
|
+
if (authHeader && authHeader.startsWith('Bearer ')) {
|
|
72
|
+
token = authHeader.substring(7);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (!authToken || token !== authToken) {
|
|
76
|
+
reply.status(401).send({ error: 'Unauthorized' });
|
|
77
|
+
return reply;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}); // Registers authentication pre-handler hook.
|
|
81
|
+
|
|
82
|
+
// Serve built frontend statically when EXEGGUTOR_FRONTEND_DIST is set (production mode).
|
|
83
|
+
if (FRONTEND_DIST && fs.existsSync(FRONTEND_DIST)) {
|
|
84
|
+
await server.register(fastifyStatic, {
|
|
85
|
+
root: FRONTEND_DIST,
|
|
86
|
+
wildcard: false,
|
|
87
|
+
prefix: '/',
|
|
88
|
+
});
|
|
89
|
+
// SPA fallback: serve index.html for all non-API, non-WS routes.
|
|
90
|
+
server.setNotFoundHandler((request, reply) => {
|
|
91
|
+
if (request.url.startsWith('/api') || request.url.startsWith('/ws')) {
|
|
92
|
+
reply.status(404).send({ error: 'Not found' });
|
|
93
|
+
} else {
|
|
94
|
+
const indexPath = path.join(FRONTEND_DIST, 'index.html');
|
|
95
|
+
if (fs.existsSync(indexPath)) {
|
|
96
|
+
reply.type('text/html').send(fs.readFileSync(indexPath, 'utf8'));
|
|
97
|
+
} else {
|
|
98
|
+
reply.status(404).send('Not found');
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
pty.startStatusAuditor(broadcastObserverUpdate);
|
|
105
|
+
|
|
106
|
+
server.post('/api/auth/issue-session', async (request, reply) => {
|
|
107
|
+
const code = crypto.randomBytes(16).toString('hex'); // Unpredictable one-time session code.
|
|
108
|
+
sessionCodes.set(code, { token: authToken, expires: Date.now() + 30000 }); // Code valid for 30 seconds.
|
|
109
|
+
return { code };
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
server.post('/api/auth/exchange-session', async (request, reply) => {
|
|
113
|
+
const { code } = request.body as any; // Session code from the URL.
|
|
114
|
+
const entry = sessionCodes.get(code); // Lookup matching code entry.
|
|
115
|
+
if (!entry || entry.expires < Date.now()) {
|
|
116
|
+
reply.status(401).send({ error: 'Invalid or expired session code' });
|
|
117
|
+
return reply;
|
|
118
|
+
}
|
|
119
|
+
sessionCodes.delete(code); // Invalidate code after use.
|
|
120
|
+
return { token: entry.token };
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
setInterval(() => {
|
|
124
|
+
const now = Date.now(); // Current timestamp.
|
|
125
|
+
sessionCodes.forEach((entry, code) => {
|
|
126
|
+
if (entry.expires < now) {
|
|
127
|
+
sessionCodes.delete(code);
|
|
128
|
+
}
|
|
129
|
+
}); // Purge expired session codes every 60 seconds.
|
|
130
|
+
}, 60000);
|
|
131
|
+
|
|
132
|
+
server.get('/api/workspaces', async (request, reply) => {
|
|
133
|
+
const list = db.getWorkspaces(); // Workspace list from database.
|
|
134
|
+
return list;
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
server.post<{ Body: { name: string; path: string } }>(
|
|
138
|
+
'/api/workspaces',
|
|
139
|
+
async (request, reply) => {
|
|
140
|
+
const { name, path: folderPath } = request.body; // Deconstructed parameters from request body.
|
|
141
|
+
if (!name || typeof name !== 'string' || name.trim().length === 0) {
|
|
142
|
+
reply.status(400);
|
|
143
|
+
return { error: 'Workspace name is required' };
|
|
144
|
+
}
|
|
145
|
+
if (name.trim().length > 100) {
|
|
146
|
+
reply.status(400);
|
|
147
|
+
return { error: 'Workspace name must be 100 characters or fewer' };
|
|
148
|
+
}
|
|
149
|
+
if (!folderPath || typeof folderPath !== 'string' || folderPath.trim().length === 0) {
|
|
150
|
+
reply.status(400);
|
|
151
|
+
return { error: 'Workspace path is required' };
|
|
152
|
+
}
|
|
153
|
+
const resolvedPath = path.resolve(folderPath.trim()); // Resolved absolute target path.
|
|
154
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
155
|
+
reply.status(400);
|
|
156
|
+
return { error: 'Workspace path does not exist' };
|
|
157
|
+
}
|
|
158
|
+
if (!fs.statSync(resolvedPath).isDirectory()) {
|
|
159
|
+
reply.status(400);
|
|
160
|
+
return { error: 'Workspace path must be a directory' };
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
fs.accessSync(resolvedPath, fs.constants.R_OK);
|
|
164
|
+
} catch {
|
|
165
|
+
reply.status(400);
|
|
166
|
+
return { error: 'Workspace path is not readable' };
|
|
167
|
+
}
|
|
168
|
+
const createdWs = db.createWorkspace(name.trim(), resolvedPath); // Reference to the created workspace.
|
|
169
|
+
return createdWs;
|
|
170
|
+
}
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
server.delete<{ Params: { id: string } }>('/api/workspaces/:id', async (request, reply) => {
|
|
174
|
+
const id = request.params.id; // Target workspace ID.
|
|
175
|
+
console.log(`[API] DELETE /api/workspaces/${id} -> deleting workspace`);
|
|
176
|
+
const wsList = db.getWorkspaces(); // Entire registered workspaces array.
|
|
177
|
+
const targetWs = wsList.find(w => w.id === id); // Found workspace object matching the target ID.
|
|
178
|
+
if (targetWs) {
|
|
179
|
+
console.log(`[API] Deleting workspace "${targetWs.name}" (id=${id}) with ${targetWs.tabs.length} tabs`);
|
|
180
|
+
await Promise.all(
|
|
181
|
+
targetWs.tabs.map(async tab => {
|
|
182
|
+
console.log(`[API] Cleaning up tab ${tab.id} for workspace delete`);
|
|
183
|
+
await pty.killPtySession(tab.id);
|
|
184
|
+
if (tab.worktreePath) {
|
|
185
|
+
try {
|
|
186
|
+
await git.removeGitWorktree(targetWs.path, tab.worktreePath);
|
|
187
|
+
} catch (err) {
|
|
188
|
+
// Ignore pruning issues.
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
})
|
|
192
|
+
); // Run PTY shutdowns and worktree cleanups concurrently.
|
|
193
|
+
db.deleteWorkspace(id);
|
|
194
|
+
console.log(`[API] Workspace ${id} deleted`);
|
|
195
|
+
}
|
|
196
|
+
const successResp = { success: true }; // Server confirmation object.
|
|
197
|
+
return successResp;
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
server.put<{ Params: { id: string }; Body: Partial<Omit<db.Workspace, 'id' | 'tabs'>> }>(
|
|
201
|
+
'/api/workspaces/:id',
|
|
202
|
+
async (request, reply) => {
|
|
203
|
+
const id = request.params.id; // Target workspace ID.
|
|
204
|
+
const updates = request.body; // Updated attributes from body.
|
|
205
|
+
const updated = db.updateWorkspace(id, updates); // Workspace with applied patches.
|
|
206
|
+
const returnResult = updated; // Refactored return workspace.
|
|
207
|
+
return returnResult;
|
|
208
|
+
}
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
const MAX_TABS_PER_WORKSPACE = 4; // Maximum number of terminal tabs allowed per workspace.
|
|
212
|
+
|
|
213
|
+
server.post<{ Params: { id: string }; Body: { name: string; shell?: string } }>(
|
|
214
|
+
'/api/workspaces/:id/tabs',
|
|
215
|
+
async (request, reply) => {
|
|
216
|
+
const id = request.params.id; // Parent workspace ID.
|
|
217
|
+
const { name, shell } = request.body; // Input body variables.
|
|
218
|
+
console.log(`[API] POST /api/workspaces/${id}/tabs -> creating tab name="${name}" shell="${shell}"`);
|
|
219
|
+
const targetWs = db.getWorkspaces().find(w => w.id === id); // Find matching workspace database entry.
|
|
220
|
+
if (!targetWs) {
|
|
221
|
+
reply.status(404);
|
|
222
|
+
const errorResp = { error: 'Workspace not found' }; // Missing workspace error.
|
|
223
|
+
return errorResp;
|
|
224
|
+
}
|
|
225
|
+
if (targetWs.tabs.length >= MAX_TABS_PER_WORKSPACE) {
|
|
226
|
+
reply.status(400);
|
|
227
|
+
const errorResp = { error: `Maximum ${MAX_TABS_PER_WORKSPACE} terminal tabs per workspace` }; // Tab limit error.
|
|
228
|
+
return errorResp;
|
|
229
|
+
}
|
|
230
|
+
const tab = db.createTerminalTab(id, name, targetWs.path, shell); // New tab object created.
|
|
231
|
+
if (tab) {
|
|
232
|
+
console.log(`[API] Tab created: id=${tab.id} name="${name}" cwd=${tab.cwd}`);
|
|
233
|
+
}
|
|
234
|
+
const returnResult = tab; // Created tab descriptor.
|
|
235
|
+
return returnResult;
|
|
236
|
+
}
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
server.put<{ Params: { id: string; tabId: string }; Body: { name?: string; branch?: string } }>(
|
|
240
|
+
'/api/workspaces/:id/tabs/:tabId',
|
|
241
|
+
async (request, reply) => {
|
|
242
|
+
const { id, tabId } = request.params; // Workspace and tab parameter keys.
|
|
243
|
+
const { name, branch } = request.body; // Extracted updates.
|
|
244
|
+
console.log(`[API] PUT /api/workspaces/${id}/tabs/${tabId} -> name="${name}" branch="${branch}"`);
|
|
245
|
+
const targetWs = db.getWorkspaces().find(w => w.id === id); // Target workspace object.
|
|
246
|
+
if (!targetWs) {
|
|
247
|
+
reply.status(404);
|
|
248
|
+
const errorResp = { error: 'Workspace not found' }; // Error payload.
|
|
249
|
+
return errorResp;
|
|
250
|
+
}
|
|
251
|
+
const tab = targetWs.tabs.find(t => t.id === tabId); // Selected terminal tab.
|
|
252
|
+
if (!tab) {
|
|
253
|
+
reply.status(404);
|
|
254
|
+
const errorResp = { error: 'Terminal tab not found' }; // Error payload.
|
|
255
|
+
return errorResp;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
let worktreePath = tab.worktreePath; // Preserve current worktree path reference.
|
|
259
|
+
let newCwd = tab.cwd; // Preserve current target current working directory.
|
|
260
|
+
|
|
261
|
+
if (branch !== undefined && branch !== tab.branch) {
|
|
262
|
+
console.log(`[API] Branch change detected for tab ${tabId}: "${tab.branch}" -> "${branch}", killing PTY`);
|
|
263
|
+
await pty.killPtySession(tabId);
|
|
264
|
+
if (tab.worktreePath) {
|
|
265
|
+
try {
|
|
266
|
+
await git.removeGitWorktree(targetWs.path, tab.worktreePath);
|
|
267
|
+
} catch (err) {
|
|
268
|
+
// Prune error.
|
|
269
|
+
}
|
|
270
|
+
worktreePath = undefined;
|
|
271
|
+
newCwd = targetWs.path;
|
|
272
|
+
}
|
|
273
|
+
if (branch && branch.trim().length > 0) {
|
|
274
|
+
try {
|
|
275
|
+
const wPath = await git.setupGitWorktree(targetWs.path, branch); // Spawn isolated git worktree folder.
|
|
276
|
+
worktreePath = wPath;
|
|
277
|
+
newCwd = wPath;
|
|
278
|
+
} catch (err: any) {
|
|
279
|
+
reply.status(400);
|
|
280
|
+
const errorResp = { error: err.message || 'Failed to setup git worktree' }; // Setup error.
|
|
281
|
+
return errorResp;
|
|
282
|
+
}
|
|
283
|
+
} else {
|
|
284
|
+
newCwd = targetWs.path;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const updates: Partial<db.TerminalTab> = {}; // Config updates map.
|
|
289
|
+
if (name !== undefined) {
|
|
290
|
+
updates.name = name;
|
|
291
|
+
}
|
|
292
|
+
if (branch !== undefined) {
|
|
293
|
+
updates.branch = branch ? branch : undefined;
|
|
294
|
+
updates.worktreePath = worktreePath;
|
|
295
|
+
updates.cwd = newCwd;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const updated = db.updateTerminalTab(id, tabId, updates); // Flush updates to sessions database.
|
|
299
|
+
const returnResult = updated; // Updated tab object.
|
|
300
|
+
return returnResult;
|
|
301
|
+
}
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
server.post<{ Params: { id: string; tabId: string }; Body: { name: string } }>(
|
|
305
|
+
'/api/workspaces/:id/tabs/:tabId/branches',
|
|
306
|
+
async (request, reply) => {
|
|
307
|
+
const { id, tabId } = request.params; // Workspace and tab keys.
|
|
308
|
+
const { name: branchName } = request.body; // New branch name.
|
|
309
|
+
console.log(`[API] POST /api/workspaces/${id}/tabs/${tabId}/branches -> branch="${branchName}"`);
|
|
310
|
+
const targetWs = db.getWorkspaces().find(w => w.id === id); // Found workspace.
|
|
311
|
+
if (!targetWs) {
|
|
312
|
+
reply.status(404);
|
|
313
|
+
const errorResp = { error: 'Workspace not found' }; // Error payload.
|
|
314
|
+
return errorResp;
|
|
315
|
+
}
|
|
316
|
+
const tab = targetWs.tabs.find(t => t.id === tabId); // Target tab config.
|
|
317
|
+
if (!tab) {
|
|
318
|
+
reply.status(404);
|
|
319
|
+
const errorResp = { error: 'Terminal tab not found' }; // Error.
|
|
320
|
+
return errorResp;
|
|
321
|
+
}
|
|
322
|
+
try {
|
|
323
|
+
await git.createBranch(targetWs.path, branchName);
|
|
324
|
+
await pty.killPtySession(tabId);
|
|
325
|
+
if (tab.worktreePath) {
|
|
326
|
+
try {
|
|
327
|
+
await git.removeGitWorktree(targetWs.path, tab.worktreePath);
|
|
328
|
+
} catch (err) {
|
|
329
|
+
// Prune error.
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
const wPath = await git.setupGitWorktree(targetWs.path, branchName); // Create new worktree directly.
|
|
333
|
+
const updated = db.updateTerminalTab(id, tabId, {
|
|
334
|
+
branch: branchName,
|
|
335
|
+
worktreePath: wPath,
|
|
336
|
+
cwd: wPath,
|
|
337
|
+
}); // Apply updates to database tab.
|
|
338
|
+
const returnResult = updated; // Tab payload.
|
|
339
|
+
return returnResult;
|
|
340
|
+
} catch (err: any) {
|
|
341
|
+
reply.status(400);
|
|
342
|
+
const errorResp = { error: err.message || 'Failed to create git branch' }; // Error payload.
|
|
343
|
+
return errorResp;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
server.delete<{ Params: { id: string; tabId: string } }>(
|
|
349
|
+
'/api/workspaces/:id/tabs/:tabId',
|
|
350
|
+
async (request, reply) => {
|
|
351
|
+
const { id, tabId } = request.params; // Tab and workspace parameters.
|
|
352
|
+
console.log(`[API] DELETE /api/workspaces/${id}/tabs/${tabId} -> deleting tab`);
|
|
353
|
+
const targetWs = db.getWorkspaces().find(w => w.id === id); // Found workspace.
|
|
354
|
+
if (targetWs) {
|
|
355
|
+
const tab = targetWs.tabs.find(t => t.id === tabId); // Target tab config.
|
|
356
|
+
console.log(`[API] Killing PTY and cleaning up worktree for tab ${tabId}`);
|
|
357
|
+
await pty.killPtySession(tabId);
|
|
358
|
+
if (tab && tab.worktreePath) {
|
|
359
|
+
try {
|
|
360
|
+
await git.removeGitWorktree(targetWs.path, tab.worktreePath);
|
|
361
|
+
} catch (err) {
|
|
362
|
+
// Ignore.
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
db.deleteTerminalTab(id, tabId);
|
|
366
|
+
console.log(`[API] Tab ${tabId} deleted`);
|
|
367
|
+
}
|
|
368
|
+
const successResp = { success: true }; // Operation complete.
|
|
369
|
+
return successResp;
|
|
370
|
+
}
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
server.get<{ Params: { id: string } }>('/api/workspaces/:id/git/branches', async (request, reply) => {
|
|
374
|
+
const id = request.params.id; // Target workspace ID.
|
|
375
|
+
const ws = db.getWorkspaces().find(w => w.id === id); // Target workspace instance.
|
|
376
|
+
if (!ws) {
|
|
377
|
+
reply.status(404);
|
|
378
|
+
const errorResp = { error: 'Workspace not found' }; // Error workspace message.
|
|
379
|
+
return errorResp;
|
|
380
|
+
}
|
|
381
|
+
try {
|
|
382
|
+
const branches = await git.getBranches(ws.path); // Retrieve active branch array from workspace folder.
|
|
383
|
+
const returnResult = branches; // Branches array result.
|
|
384
|
+
return returnResult;
|
|
385
|
+
} catch (err: any) {
|
|
386
|
+
reply.status(500);
|
|
387
|
+
const errorResp = { error: err.message || 'Failed to query git branches' }; // Error payload.
|
|
388
|
+
return errorResp;
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
server.get<{ Querystring: { name: string } }>('/api/branches/in-use', async (request, reply) => {
|
|
393
|
+
const { name } = request.query; // Branch name to check.
|
|
394
|
+
const allWorkspaces = db.getWorkspaces(); // All registered workspaces.
|
|
395
|
+
let inUse = false; // Default safety flag.
|
|
396
|
+
for (const ws of allWorkspaces) {
|
|
397
|
+
for (const tab of ws.tabs) {
|
|
398
|
+
if (tab.branch === name) {
|
|
399
|
+
inUse = true;
|
|
400
|
+
break;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
if (inUse) { break; }
|
|
404
|
+
}
|
|
405
|
+
const result = { inUse }; // Response payload.
|
|
406
|
+
return result;
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
// Returns the current Tailscale status and connection info for the server.
|
|
410
|
+
server.get('/api/tailscale/status', async (request, reply) => {
|
|
411
|
+
const installed = tailscale.isTailscaleInstalled(); // Whether the tailscale binary exists on PATH.
|
|
412
|
+
const info = installed ? tailscale.getTailscaleInfo() : null; // Parsed tailscale status, null if not connected.
|
|
413
|
+
return {
|
|
414
|
+
installed,
|
|
415
|
+
connected: info !== null,
|
|
416
|
+
tailscale: info,
|
|
417
|
+
tailscaleMode: TAILSCALE_MODE,
|
|
418
|
+
};
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
server.get('/api/browse', async (request, reply) => {
|
|
422
|
+
try {
|
|
423
|
+
const folder = await git.showFolderPicker();
|
|
424
|
+
if (!folder) {
|
|
425
|
+
// User cancelled the dialog — not an error.
|
|
426
|
+
return { path: '', cancelled: true };
|
|
427
|
+
}
|
|
428
|
+
return { path: folder };
|
|
429
|
+
} catch (err: any) {
|
|
430
|
+
reply.status(500);
|
|
431
|
+
return { path: '', error: err.message || 'Failed to open folder picker' };
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
server.route({
|
|
436
|
+
method: 'GET',
|
|
437
|
+
url: '/ws/observer',
|
|
438
|
+
handler: (request, reply) => {
|
|
439
|
+
reply.status(400).send('WebSocket connection expected');
|
|
440
|
+
},
|
|
441
|
+
wsHandler: (connection, req) => {
|
|
442
|
+
observerSockets.add(connection);
|
|
443
|
+
const initialPayload = JSON.stringify({
|
|
444
|
+
type: 'observer',
|
|
445
|
+
sessions: pty.getAllSessions(),
|
|
446
|
+
}); // Initial data load payload for new client.
|
|
447
|
+
connection.send(initialPayload);
|
|
448
|
+
connection.on('close', () => {
|
|
449
|
+
observerSockets.delete(connection);
|
|
450
|
+
});
|
|
451
|
+
},
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
server.route({
|
|
455
|
+
method: 'GET',
|
|
456
|
+
url: '/ws/terminal/:tabId',
|
|
457
|
+
handler: (request, reply) => {
|
|
458
|
+
reply.status(400).send('WebSocket connection expected');
|
|
459
|
+
},
|
|
460
|
+
wsHandler: async (connection, req) => {
|
|
461
|
+
const tabId = (req.params as any).tabId; // Extract tabId from parameters.
|
|
462
|
+
console.log(`[WS] Terminal WebSocket connect: tabId=${tabId}`);
|
|
463
|
+
const workspaces = db.getWorkspaces(); // Load workspaces registry.
|
|
464
|
+
let activeTab: db.TerminalTab | undefined = undefined; // Matches the target active tab.
|
|
465
|
+
let activeWs: db.Workspace | undefined = undefined; // Matches the parent workspace containing the tab.
|
|
466
|
+
for (const ws of workspaces) {
|
|
467
|
+
const found = ws.tabs.find(t => t.id === tabId); // Match lookup.
|
|
468
|
+
if (found) {
|
|
469
|
+
activeTab = found;
|
|
470
|
+
activeWs = ws;
|
|
471
|
+
break;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
if (!activeTab || !activeWs) {
|
|
475
|
+
console.log(`[WS] Terminal tab ${tabId} not found in any workspace, closing connection`);
|
|
476
|
+
connection.close(4001, 'Terminal tab not found');
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
console.log(`[WS] Calling getOrCreatePtySession for tab ${tabId} (workspace=${activeWs.name}, cwd=${activeTab.cwd})`);
|
|
480
|
+
let session: pty.TerminalSession; // Reference to the created or retrieved terminal session.
|
|
481
|
+
try {
|
|
482
|
+
session = await pty.getOrCreatePtySession(
|
|
483
|
+
activeWs.id,
|
|
484
|
+
activeTab.id,
|
|
485
|
+
activeTab.cwd,
|
|
486
|
+
activeTab.shell,
|
|
487
|
+
broadcastObserverUpdate
|
|
488
|
+
); // Await spawn or attach of persistent session process (serialized on Windows).
|
|
489
|
+
} catch (err: any) {
|
|
490
|
+
console.log(`[WS] getOrCreatePtySession failed for tab ${tabId}: ${err.message}`);
|
|
491
|
+
connection.close(4002, 'Terminal session creation failed');
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
console.log(`[WS] getOrCreatePtySession returned for tab ${tabId}, PID=${session.ptyProcess.pid}`);
|
|
495
|
+
|
|
496
|
+
session.activeSocket = connection; // Register the newly connected socket as the active connection.
|
|
497
|
+
|
|
498
|
+
session.outputBuffer.forEach(chunk => {
|
|
499
|
+
connection.send(chunk);
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
// Handles incoming data from the persistent PTY process by sending it over the WebSocket.
|
|
503
|
+
session.onData = (data: string) => {
|
|
504
|
+
try {
|
|
505
|
+
if (session.activeSocket === connection) {
|
|
506
|
+
connection.send(data);
|
|
507
|
+
}
|
|
508
|
+
} catch (err) {
|
|
509
|
+
// Socket write failed.
|
|
510
|
+
}
|
|
511
|
+
};
|
|
512
|
+
|
|
513
|
+
// Receives input and resize instructions from the WebSocket and forwards them to the PTY session.
|
|
514
|
+
connection.on('message', (messageData: any) => {
|
|
515
|
+
const rawMessage = messageData.toString(); // Normalized string representation of the incoming socket message.
|
|
516
|
+
try {
|
|
517
|
+
const parsed = JSON.parse(rawMessage); // Parsed client websocket message.
|
|
518
|
+
if (parsed && parsed.type === 'resize') {
|
|
519
|
+
pty.resizePtySession(tabId, parsed.cols, parsed.rows);
|
|
520
|
+
} else if (parsed && parsed.type === 'input') {
|
|
521
|
+
pty.writeToPtySession(tabId, parsed.data);
|
|
522
|
+
}
|
|
523
|
+
} catch (err) {
|
|
524
|
+
// Ignore invalid JSON payloads to prevent writing garbage or raw JSON controls to the shell.
|
|
525
|
+
}
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
// Disconnects the WebSocket connection and marks the PTY session active socket as empty.
|
|
529
|
+
connection.on('close', () => {
|
|
530
|
+
console.log(`[WS] Terminal WebSocket disconnect: tabId=${tabId}`);
|
|
531
|
+
if (session.activeSocket === connection) {
|
|
532
|
+
session.activeSocket = undefined;
|
|
533
|
+
session.onData = () => {};
|
|
534
|
+
}
|
|
535
|
+
});
|
|
536
|
+
},
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
const listenHost = resolveListenHost(); // Network interface to bind the server to.
|
|
540
|
+
await server.listen({ port: PORT, host: listenHost });
|
|
541
|
+
const tailscaleInfo = TAILSCALE_MODE ? tailscale.getTailscaleInfo() : null; // Information about the current Tailscale connection.
|
|
542
|
+
if (tailscaleInfo) {
|
|
543
|
+
console.log(`Tailscale URL: http://${tailscaleInfo.ip}:${PORT}`);
|
|
544
|
+
if (tailscaleInfo.dnsName) {
|
|
545
|
+
console.log(`Tailscale DNS: https://${tailscaleInfo.dnsName}:${PORT}`);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
console.log(`Backend daemon is listening on port ${PORT} (bound to ${listenHost})`);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
bootstrap().catch(err => {
|
|
552
|
+
console.error('Fatal initialization error:', err);
|
|
553
|
+
process.exit(1);
|
|
554
|
+
});
|