@cluesmith/codev 2.0.0-rc.47 → 2.0.0-rc.49
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/bin/af.js +2 -2
- package/bin/consult.js +1 -1
- package/dist/agent-farm/commands/start.d.ts +3 -0
- package/dist/agent-farm/commands/start.d.ts.map +1 -1
- package/dist/agent-farm/commands/start.js +65 -0
- package/dist/agent-farm/commands/start.js.map +1 -1
- package/dist/agent-farm/commands/status.d.ts +2 -0
- package/dist/agent-farm/commands/status.d.ts.map +1 -1
- package/dist/agent-farm/commands/status.js +56 -1
- package/dist/agent-farm/commands/status.js.map +1 -1
- package/dist/agent-farm/commands/stop.d.ts +6 -0
- package/dist/agent-farm/commands/stop.d.ts.map +1 -1
- package/dist/agent-farm/commands/stop.js +36 -1
- package/dist/agent-farm/commands/stop.js.map +1 -1
- package/dist/agent-farm/db/index.d.ts.map +1 -1
- package/dist/agent-farm/db/index.js +17 -0
- package/dist/agent-farm/db/index.js.map +1 -1
- package/dist/agent-farm/db/schema.d.ts +1 -1
- package/dist/agent-farm/db/schema.d.ts.map +1 -1
- package/dist/agent-farm/db/schema.js +2 -0
- package/dist/agent-farm/db/schema.js.map +1 -1
- package/dist/agent-farm/lib/tower-client.d.ts +157 -0
- package/dist/agent-farm/lib/tower-client.d.ts.map +1 -0
- package/dist/agent-farm/lib/tower-client.js +223 -0
- package/dist/agent-farm/lib/tower-client.js.map +1 -0
- package/dist/agent-farm/servers/tower-server.js +836 -224
- package/dist/agent-farm/servers/tower-server.js.map +1 -1
- package/dist/commands/adopt.js +1 -1
- package/package.json +1 -1
- package/templates/tower.html +2 -2
- package/dist/agent-farm/servers/dashboard-server.d.ts +0 -7
- package/dist/agent-farm/servers/dashboard-server.d.ts.map +0 -1
- package/dist/agent-farm/servers/dashboard-server.js +0 -2181
- package/dist/agent-farm/servers/dashboard-server.js.map +0 -1
|
@@ -1,2181 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* Dashboard server for Agent Farm.
|
|
4
|
-
* Serves the split-pane dashboard UI and provides state/tab management APIs.
|
|
5
|
-
*/
|
|
6
|
-
import http from 'node:http';
|
|
7
|
-
import fs from 'node:fs';
|
|
8
|
-
import path from 'node:path';
|
|
9
|
-
import net from 'node:net';
|
|
10
|
-
import httpProxy from 'http-proxy';
|
|
11
|
-
import { spawn, execSync, exec } from 'node:child_process';
|
|
12
|
-
import { promisify } from 'node:util';
|
|
13
|
-
import { randomUUID, timingSafeEqual } from 'node:crypto';
|
|
14
|
-
import { fileURLToPath } from 'node:url';
|
|
15
|
-
const execAsync = promisify(exec);
|
|
16
|
-
import { Command } from 'commander';
|
|
17
|
-
import { getPortForTerminal } from '../utils/terminal-ports.js';
|
|
18
|
-
import { escapeHtml, parseJsonBody, isRequestAllowed as isRequestAllowedBase, } from '../utils/server-utils.js';
|
|
19
|
-
import { loadState, getAnnotations, addAnnotation, removeAnnotation, getUtils, addUtil, removeUtil, updateUtil, getBuilder, getBuilders, removeBuilder, upsertBuilder, clearState, getArchitect, setArchitect, } from '../state.js';
|
|
20
|
-
import { TerminalManager } from '../../terminal/pty-manager.js';
|
|
21
|
-
const __filename = fileURLToPath(import.meta.url);
|
|
22
|
-
const __dirname = path.dirname(__filename);
|
|
23
|
-
// Default dashboard port
|
|
24
|
-
const DEFAULT_DASHBOARD_PORT = 4200;
|
|
25
|
-
// Parse arguments with Commander for proper --help and validation
|
|
26
|
-
const program = new Command()
|
|
27
|
-
.name('dashboard-server')
|
|
28
|
-
.description('Dashboard server for Agent Farm')
|
|
29
|
-
.argument('[port]', 'Port to listen on', String(DEFAULT_DASHBOARD_PORT))
|
|
30
|
-
.argument('[bindHost]', 'Host to bind to (default: localhost, use 0.0.0.0 for remote)')
|
|
31
|
-
.option('-p, --port <port>', 'Port to listen on (overrides positional argument)')
|
|
32
|
-
.option('-b, --bind <host>', 'Host to bind to (overrides positional argument)')
|
|
33
|
-
.parse(process.argv);
|
|
34
|
-
const opts = program.opts();
|
|
35
|
-
const args = program.args;
|
|
36
|
-
// Support both positional arg and --port flag (flag takes precedence)
|
|
37
|
-
const portArg = opts.port || args[0] || String(DEFAULT_DASHBOARD_PORT);
|
|
38
|
-
const port = parseInt(portArg, 10);
|
|
39
|
-
// Bind host: flag > positional arg > default (undefined = localhost)
|
|
40
|
-
const bindHost = opts.bind || args[1] || undefined;
|
|
41
|
-
if (isNaN(port) || port < 1 || port > 65535) {
|
|
42
|
-
console.error(`Error: Invalid port "${portArg}". Must be a number between 1 and 65535.`);
|
|
43
|
-
process.exit(1);
|
|
44
|
-
}
|
|
45
|
-
// Configuration - ports are relative to the dashboard port
|
|
46
|
-
// This ensures multi-project support (e.g., dashboard on 4300 uses 4350 for annotations)
|
|
47
|
-
const CONFIG = {
|
|
48
|
-
dashboardPort: port,
|
|
49
|
-
architectPort: port + 1,
|
|
50
|
-
builderPortStart: port + 10,
|
|
51
|
-
utilPortStart: port + 30,
|
|
52
|
-
openPortStart: port + 50,
|
|
53
|
-
maxTabs: 20, // DoS protection: max concurrent tabs
|
|
54
|
-
};
|
|
55
|
-
// Find project root by looking for .agent-farm directory
|
|
56
|
-
function findProjectRoot() {
|
|
57
|
-
let dir = process.cwd();
|
|
58
|
-
while (dir !== '/') {
|
|
59
|
-
if (fs.existsSync(path.join(dir, '.agent-farm'))) {
|
|
60
|
-
return dir;
|
|
61
|
-
}
|
|
62
|
-
if (fs.existsSync(path.join(dir, 'codev'))) {
|
|
63
|
-
return dir;
|
|
64
|
-
}
|
|
65
|
-
dir = path.dirname(dir);
|
|
66
|
-
}
|
|
67
|
-
return process.cwd();
|
|
68
|
-
}
|
|
69
|
-
// Get project name from root path, with truncation for long names
|
|
70
|
-
function getProjectName(projectRoot) {
|
|
71
|
-
const baseName = path.basename(projectRoot);
|
|
72
|
-
const maxLength = 30;
|
|
73
|
-
if (baseName.length <= maxLength) {
|
|
74
|
-
return baseName;
|
|
75
|
-
}
|
|
76
|
-
// Truncate with ellipsis for very long names
|
|
77
|
-
return '...' + baseName.slice(-(maxLength - 3));
|
|
78
|
-
}
|
|
79
|
-
function findTemplatePath(filename, required = false) {
|
|
80
|
-
// Templates are at package root: packages/codev/templates/
|
|
81
|
-
// From compiled: dist/agent-farm/servers/ -> ../../../templates/
|
|
82
|
-
// From source: src/agent-farm/servers/ -> ../../../templates/
|
|
83
|
-
const pkgPath = path.resolve(__dirname, '../../../templates/', filename);
|
|
84
|
-
if (fs.existsSync(pkgPath))
|
|
85
|
-
return pkgPath;
|
|
86
|
-
if (required) {
|
|
87
|
-
throw new Error(`Template not found: ${filename}`);
|
|
88
|
-
}
|
|
89
|
-
return null;
|
|
90
|
-
}
|
|
91
|
-
const projectRoot = findProjectRoot();
|
|
92
|
-
// Use modular dashboard template (Spec 0060)
|
|
93
|
-
const templatePath = findTemplatePath('dashboard/index.html', true);
|
|
94
|
-
// Terminal backend is always node-pty (Spec 0085)
|
|
95
|
-
const terminalBackend = 'node-pty';
|
|
96
|
-
// Load dashboard frontend preference from config (Spec 0085)
|
|
97
|
-
function loadDashboardFrontend() {
|
|
98
|
-
const configPath = path.resolve(projectRoot, 'af-config.json');
|
|
99
|
-
if (fs.existsSync(configPath)) {
|
|
100
|
-
try {
|
|
101
|
-
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
102
|
-
return config?.dashboard?.frontend ?? 'react';
|
|
103
|
-
}
|
|
104
|
-
catch { /* ignore */ }
|
|
105
|
-
}
|
|
106
|
-
return 'react';
|
|
107
|
-
}
|
|
108
|
-
const dashboardFrontend = loadDashboardFrontend();
|
|
109
|
-
// React dashboard dist path (built by Vite)
|
|
110
|
-
const reactDashboardPath = path.resolve(__dirname, '../../../dashboard/dist');
|
|
111
|
-
const useReactDashboard = dashboardFrontend === 'react' && fs.existsSync(reactDashboardPath);
|
|
112
|
-
if (useReactDashboard) {
|
|
113
|
-
console.log('Dashboard frontend: React');
|
|
114
|
-
}
|
|
115
|
-
else if (dashboardFrontend === 'react') {
|
|
116
|
-
console.log('Dashboard frontend: React (dist not found, falling back to legacy)');
|
|
117
|
-
}
|
|
118
|
-
else {
|
|
119
|
-
console.log('Dashboard frontend: legacy');
|
|
120
|
-
}
|
|
121
|
-
const terminalManager = new TerminalManager({ projectRoot });
|
|
122
|
-
console.log('Terminal backend: node-pty');
|
|
123
|
-
// Clear stale terminalIds on startup — TerminalManager starts empty, so any
|
|
124
|
-
// persisted terminalId from a previous run is no longer valid.
|
|
125
|
-
{
|
|
126
|
-
const arch = getArchitect();
|
|
127
|
-
if (arch?.terminalId) {
|
|
128
|
-
setArchitect({ ...arch, terminalId: undefined });
|
|
129
|
-
}
|
|
130
|
-
for (const builder of getBuilders()) {
|
|
131
|
-
if (builder.terminalId) {
|
|
132
|
-
upsertBuilder({ ...builder, terminalId: undefined });
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
for (const util of getUtils()) {
|
|
136
|
-
if (util.terminalId) {
|
|
137
|
-
updateUtil(util.id, { terminalId: undefined });
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
// Auto-create architect PTY session if architect exists with a tmux session
|
|
142
|
-
async function initArchitectTerminal() {
|
|
143
|
-
const architect = getArchitect();
|
|
144
|
-
if (!architect || !architect.tmuxSession || architect.terminalId)
|
|
145
|
-
return;
|
|
146
|
-
try {
|
|
147
|
-
// Verify the tmux session actually exists before trying to attach.
|
|
148
|
-
// If it doesn't exist, tmux attach exits immediately, leaving a dead terminalId.
|
|
149
|
-
const { spawnSync } = await import('node:child_process');
|
|
150
|
-
const probe = spawnSync('tmux', ['has-session', '-t', architect.tmuxSession], { stdio: 'ignore' });
|
|
151
|
-
if (probe.status !== 0) {
|
|
152
|
-
console.log(`initArchitectTerminal: tmux session '${architect.tmuxSession}' does not exist yet`);
|
|
153
|
-
return;
|
|
154
|
-
}
|
|
155
|
-
// Use tmux directly (not via bash -c) to avoid DA response chaff.
|
|
156
|
-
// bash -c creates a brief window where readline echoes DA responses as text.
|
|
157
|
-
const info = await terminalManager.createSession({
|
|
158
|
-
command: 'tmux',
|
|
159
|
-
args: ['attach-session', '-t', architect.tmuxSession],
|
|
160
|
-
cwd: projectRoot,
|
|
161
|
-
cols: 200,
|
|
162
|
-
rows: 50,
|
|
163
|
-
label: 'architect',
|
|
164
|
-
});
|
|
165
|
-
// Wait to detect immediate exit (e.g., tmux session disappeared between check and attach)
|
|
166
|
-
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
167
|
-
const session = terminalManager.getSession(info.id);
|
|
168
|
-
if (!session || session.info.exitCode !== undefined) {
|
|
169
|
-
console.error(`initArchitectTerminal: PTY exited immediately (exit=${session?.info.exitCode})`);
|
|
170
|
-
terminalManager.killSession(info.id);
|
|
171
|
-
return;
|
|
172
|
-
}
|
|
173
|
-
setArchitect({ ...architect, terminalId: info.id });
|
|
174
|
-
console.log(`Architect terminal session created: ${info.id}`);
|
|
175
|
-
// Listen for exit and auto-restart
|
|
176
|
-
session.on('exit', (exitCode) => {
|
|
177
|
-
console.log(`Architect terminal exited (code=${exitCode}), will attempt restart...`);
|
|
178
|
-
// Clear the terminalId so we can recreate
|
|
179
|
-
const arch = getArchitect();
|
|
180
|
-
if (arch) {
|
|
181
|
-
setArchitect({ ...arch, terminalId: undefined });
|
|
182
|
-
}
|
|
183
|
-
// Schedule restart after a brief delay
|
|
184
|
-
setTimeout(() => {
|
|
185
|
-
console.log('Attempting to restart architect terminal...');
|
|
186
|
-
initArchitectTerminal().catch((err) => {
|
|
187
|
-
console.error('Failed to restart architect terminal:', err.message);
|
|
188
|
-
});
|
|
189
|
-
}, 2000);
|
|
190
|
-
});
|
|
191
|
-
}
|
|
192
|
-
catch (err) {
|
|
193
|
-
console.error('Failed to create architect terminal session:', err.message);
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
// Poll for architect state and create PTY session once available
|
|
197
|
-
// start.ts writes architect to DB before spawning this server, but there can be a small delay
|
|
198
|
-
(async function waitForArchitectAndInit() {
|
|
199
|
-
for (let attempt = 0; attempt < 30; attempt++) {
|
|
200
|
-
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
201
|
-
try {
|
|
202
|
-
const arch = getArchitect();
|
|
203
|
-
if (!arch)
|
|
204
|
-
continue;
|
|
205
|
-
if (arch.terminalId)
|
|
206
|
-
return; // Already has terminal
|
|
207
|
-
if (!arch.tmuxSession)
|
|
208
|
-
continue; // No tmux session yet
|
|
209
|
-
console.log(`initArchitectTerminal: attempt ${attempt + 1}, tmux=${arch.tmuxSession}`);
|
|
210
|
-
await initArchitectTerminal();
|
|
211
|
-
const updated = getArchitect();
|
|
212
|
-
if (updated?.terminalId) {
|
|
213
|
-
console.log(`initArchitectTerminal: success, terminalId=${updated.terminalId}`);
|
|
214
|
-
return;
|
|
215
|
-
}
|
|
216
|
-
console.log(`initArchitectTerminal: attempt ${attempt + 1} failed, terminalId still unset`);
|
|
217
|
-
}
|
|
218
|
-
catch (err) {
|
|
219
|
-
console.error(`initArchitectTerminal: attempt ${attempt + 1} error:`, err.message);
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
console.warn('initArchitectTerminal: gave up after 30 attempts');
|
|
223
|
-
})();
|
|
224
|
-
// Log telemetry
|
|
225
|
-
try {
|
|
226
|
-
const metricsPath = path.join(projectRoot, '.agent-farm', 'metrics.log');
|
|
227
|
-
fs.mkdirSync(path.dirname(metricsPath), { recursive: true });
|
|
228
|
-
fs.appendFileSync(metricsPath, JSON.stringify({
|
|
229
|
-
event: 'backend_selected',
|
|
230
|
-
backend: 'node-pty',
|
|
231
|
-
timestamp: new Date().toISOString(),
|
|
232
|
-
}) + '\n');
|
|
233
|
-
}
|
|
234
|
-
catch { /* ignore */ }
|
|
235
|
-
// Clean up dead processes from state (called on state load)
|
|
236
|
-
function cleanupDeadProcesses() {
|
|
237
|
-
// Clean up dead shell processes
|
|
238
|
-
for (const util of getUtils()) {
|
|
239
|
-
if (!isProcessRunning(util.pid)) {
|
|
240
|
-
console.log(`Auto-closing shell tab ${util.name} (process ${util.pid} exited)`);
|
|
241
|
-
if (util.tmuxSession) {
|
|
242
|
-
killTmuxSession(util.tmuxSession);
|
|
243
|
-
}
|
|
244
|
-
removeUtil(util.id);
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
// Clean up dead annotation processes
|
|
248
|
-
for (const annotation of getAnnotations()) {
|
|
249
|
-
if (!isProcessRunning(annotation.pid)) {
|
|
250
|
-
console.log(`Auto-closing file tab ${annotation.file} (process ${annotation.pid} exited)`);
|
|
251
|
-
removeAnnotation(annotation.id);
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
// Load state with cleanup
|
|
256
|
-
function loadStateWithCleanup() {
|
|
257
|
-
cleanupDeadProcesses();
|
|
258
|
-
return loadState();
|
|
259
|
-
}
|
|
260
|
-
// Generate unique ID using crypto for collision resistance
|
|
261
|
-
function generateId(prefix) {
|
|
262
|
-
const uuid = randomUUID().replace(/-/g, '').substring(0, 8).toUpperCase();
|
|
263
|
-
return `${prefix}${uuid}`;
|
|
264
|
-
}
|
|
265
|
-
// Get all ports currently used in state
|
|
266
|
-
function getUsedPorts(state) {
|
|
267
|
-
const ports = new Set();
|
|
268
|
-
if (state.architect?.port)
|
|
269
|
-
ports.add(state.architect.port);
|
|
270
|
-
for (const builder of state.builders || []) {
|
|
271
|
-
if (builder.port)
|
|
272
|
-
ports.add(builder.port);
|
|
273
|
-
}
|
|
274
|
-
for (const util of state.utils || []) {
|
|
275
|
-
if (util.port)
|
|
276
|
-
ports.add(util.port);
|
|
277
|
-
}
|
|
278
|
-
for (const annotation of state.annotations || []) {
|
|
279
|
-
if (annotation.port)
|
|
280
|
-
ports.add(annotation.port);
|
|
281
|
-
}
|
|
282
|
-
return ports;
|
|
283
|
-
}
|
|
284
|
-
// Find available port in range (checks both state and actual availability)
|
|
285
|
-
async function findAvailablePort(startPort, state) {
|
|
286
|
-
// Get ports already allocated in state
|
|
287
|
-
const usedPorts = state ? getUsedPorts(state) : new Set();
|
|
288
|
-
// Skip ports already in state
|
|
289
|
-
let port = startPort;
|
|
290
|
-
while (usedPorts.has(port)) {
|
|
291
|
-
port++;
|
|
292
|
-
}
|
|
293
|
-
// Then verify the port is actually available for binding
|
|
294
|
-
return new Promise((resolve) => {
|
|
295
|
-
const server = net.createServer();
|
|
296
|
-
server.listen(port, () => {
|
|
297
|
-
const { port: boundPort } = server.address();
|
|
298
|
-
server.close(() => resolve(boundPort));
|
|
299
|
-
});
|
|
300
|
-
server.on('error', () => {
|
|
301
|
-
resolve(findAvailablePort(port + 1, state));
|
|
302
|
-
});
|
|
303
|
-
});
|
|
304
|
-
}
|
|
305
|
-
// Wait for a port to be accepting connections (server ready)
|
|
306
|
-
async function waitForPortReady(port, timeoutMs = 5000) {
|
|
307
|
-
const startTime = Date.now();
|
|
308
|
-
const pollInterval = 100; // Check every 100ms
|
|
309
|
-
while (Date.now() - startTime < timeoutMs) {
|
|
310
|
-
const isReady = await new Promise((resolve) => {
|
|
311
|
-
const socket = new net.Socket();
|
|
312
|
-
socket.setTimeout(pollInterval);
|
|
313
|
-
socket.on('connect', () => {
|
|
314
|
-
socket.destroy();
|
|
315
|
-
resolve(true);
|
|
316
|
-
});
|
|
317
|
-
socket.on('error', () => {
|
|
318
|
-
socket.destroy();
|
|
319
|
-
resolve(false);
|
|
320
|
-
});
|
|
321
|
-
socket.on('timeout', () => {
|
|
322
|
-
socket.destroy();
|
|
323
|
-
resolve(false);
|
|
324
|
-
});
|
|
325
|
-
socket.connect(port, '127.0.0.1');
|
|
326
|
-
});
|
|
327
|
-
if (isReady) {
|
|
328
|
-
return true;
|
|
329
|
-
}
|
|
330
|
-
// Wait before next poll
|
|
331
|
-
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
|
332
|
-
}
|
|
333
|
-
return false;
|
|
334
|
-
}
|
|
335
|
-
// Kill tmux session
|
|
336
|
-
function killTmuxSession(sessionName) {
|
|
337
|
-
try {
|
|
338
|
-
execSync(`tmux kill-session -t "${sessionName}" 2>/dev/null`, { stdio: 'ignore' });
|
|
339
|
-
}
|
|
340
|
-
catch {
|
|
341
|
-
// Session may not exist
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
// Check if a process is running
|
|
345
|
-
function isProcessRunning(pid) {
|
|
346
|
-
try {
|
|
347
|
-
// Signal 0 doesn't kill, just checks if process exists
|
|
348
|
-
process.kill(pid, 0);
|
|
349
|
-
return true;
|
|
350
|
-
}
|
|
351
|
-
catch {
|
|
352
|
-
return false;
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
// Graceful process termination with two-phase shutdown
|
|
356
|
-
async function killProcessGracefully(pid, tmuxSession) {
|
|
357
|
-
// First kill tmux session if provided
|
|
358
|
-
if (tmuxSession) {
|
|
359
|
-
killTmuxSession(tmuxSession);
|
|
360
|
-
}
|
|
361
|
-
// Guard: PID 0 sends signal to entire process group — never do that
|
|
362
|
-
if (!pid || pid <= 0)
|
|
363
|
-
return;
|
|
364
|
-
try {
|
|
365
|
-
// First try SIGTERM
|
|
366
|
-
process.kill(pid, 'SIGTERM');
|
|
367
|
-
// Wait up to 500ms for process to exit
|
|
368
|
-
await new Promise((resolve) => {
|
|
369
|
-
let attempts = 0;
|
|
370
|
-
const checkInterval = setInterval(() => {
|
|
371
|
-
attempts++;
|
|
372
|
-
try {
|
|
373
|
-
// Signal 0 checks if process exists
|
|
374
|
-
process.kill(pid, 0);
|
|
375
|
-
if (attempts >= 5) {
|
|
376
|
-
// Process still alive after 500ms, use SIGKILL
|
|
377
|
-
clearInterval(checkInterval);
|
|
378
|
-
try {
|
|
379
|
-
process.kill(pid, 'SIGKILL');
|
|
380
|
-
}
|
|
381
|
-
catch {
|
|
382
|
-
// Already dead
|
|
383
|
-
}
|
|
384
|
-
resolve();
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
catch {
|
|
388
|
-
// Process is dead
|
|
389
|
-
clearInterval(checkInterval);
|
|
390
|
-
resolve();
|
|
391
|
-
}
|
|
392
|
-
}, 100);
|
|
393
|
-
});
|
|
394
|
-
}
|
|
395
|
-
catch {
|
|
396
|
-
// Process may already be dead
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
// Spawn detached process with error handling
|
|
400
|
-
function spawnDetached(command, args, cwd) {
|
|
401
|
-
try {
|
|
402
|
-
const child = spawn(command, args, {
|
|
403
|
-
cwd,
|
|
404
|
-
detached: true,
|
|
405
|
-
stdio: 'ignore',
|
|
406
|
-
});
|
|
407
|
-
child.on('error', (err) => {
|
|
408
|
-
console.error(`Failed to spawn ${command}:`, err.message);
|
|
409
|
-
});
|
|
410
|
-
child.unref();
|
|
411
|
-
return child.pid || null;
|
|
412
|
-
}
|
|
413
|
-
catch (err) {
|
|
414
|
-
console.error(`Failed to spawn ${command}:`, err.message);
|
|
415
|
-
return null;
|
|
416
|
-
}
|
|
417
|
-
}
|
|
418
|
-
// Check if tmux session exists
|
|
419
|
-
function tmuxSessionExists(sessionName) {
|
|
420
|
-
try {
|
|
421
|
-
execSync(`tmux has-session -t "${sessionName}" 2>/dev/null`, { stdio: 'ignore' });
|
|
422
|
-
return true;
|
|
423
|
-
}
|
|
424
|
-
catch {
|
|
425
|
-
return false;
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
// Create a PTY terminal session via the TerminalManager.
|
|
429
|
-
// Returns the terminal session ID, or null on failure.
|
|
430
|
-
async function createTerminalSession(shellCommand, cwd, label) {
|
|
431
|
-
if (!terminalManager)
|
|
432
|
-
return null;
|
|
433
|
-
try {
|
|
434
|
-
const info = await terminalManager.createSession({
|
|
435
|
-
command: '/bin/bash',
|
|
436
|
-
args: ['-c', shellCommand],
|
|
437
|
-
cwd,
|
|
438
|
-
cols: 200,
|
|
439
|
-
rows: 50,
|
|
440
|
-
label,
|
|
441
|
-
});
|
|
442
|
-
return info.id;
|
|
443
|
-
}
|
|
444
|
-
catch (err) {
|
|
445
|
-
console.error(`Failed to create terminal session:`, err.message);
|
|
446
|
-
return null;
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
/**
|
|
450
|
-
* Generate a short 4-character base64-encoded ID for worktree names
|
|
451
|
-
*/
|
|
452
|
-
function generateShortId() {
|
|
453
|
-
const num = Math.floor(Math.random() * 0xFFFFFF);
|
|
454
|
-
const bytes = new Uint8Array([num >> 16, (num >> 8) & 0xFF, num & 0xFF]);
|
|
455
|
-
return btoa(String.fromCharCode(...bytes))
|
|
456
|
-
.replace(/\+/g, '-')
|
|
457
|
-
.replace(/\//g, '_')
|
|
458
|
-
.replace(/=/g, '')
|
|
459
|
-
.substring(0, 4);
|
|
460
|
-
}
|
|
461
|
-
/**
|
|
462
|
-
* Spawn a worktree builder - creates git worktree and starts builder CLI
|
|
463
|
-
* Similar to shell spawning but with git worktree isolation
|
|
464
|
-
*/
|
|
465
|
-
async function spawnWorktreeBuilder(builderPort, state) {
|
|
466
|
-
const shortId = generateShortId();
|
|
467
|
-
const builderId = `worktree-${shortId}`;
|
|
468
|
-
const branchName = `builder/worktree-${shortId}`;
|
|
469
|
-
const worktreePath = path.resolve(projectRoot, '.builders', builderId);
|
|
470
|
-
const sessionName = `builder-${builderId}`;
|
|
471
|
-
try {
|
|
472
|
-
// Ensure .builders directory exists
|
|
473
|
-
const buildersDir = path.resolve(projectRoot, '.builders');
|
|
474
|
-
if (!fs.existsSync(buildersDir)) {
|
|
475
|
-
fs.mkdirSync(buildersDir, { recursive: true });
|
|
476
|
-
}
|
|
477
|
-
// Create git branch and worktree
|
|
478
|
-
execSync(`git branch "${branchName}" HEAD`, { cwd: projectRoot, stdio: 'ignore' });
|
|
479
|
-
execSync(`git worktree add "${worktreePath}" "${branchName}"`, { cwd: projectRoot, stdio: 'ignore' });
|
|
480
|
-
// Get builder command from af-config.json or use default shell
|
|
481
|
-
const afConfigPath = path.resolve(projectRoot, 'af-config.json');
|
|
482
|
-
const defaultShell = process.env.SHELL || 'bash';
|
|
483
|
-
let builderCommand = defaultShell;
|
|
484
|
-
if (fs.existsSync(afConfigPath)) {
|
|
485
|
-
try {
|
|
486
|
-
const config = JSON.parse(fs.readFileSync(afConfigPath, 'utf-8'));
|
|
487
|
-
builderCommand = config?.shell?.builder || defaultShell;
|
|
488
|
-
}
|
|
489
|
-
catch {
|
|
490
|
-
// Use default
|
|
491
|
-
}
|
|
492
|
-
}
|
|
493
|
-
// Create PTY terminal session via node-pty
|
|
494
|
-
const terminalId = await createTerminalSession(builderCommand, worktreePath, `builder-${builderId}`);
|
|
495
|
-
if (!terminalId) {
|
|
496
|
-
// Cleanup on failure
|
|
497
|
-
try {
|
|
498
|
-
execSync(`git worktree remove "${worktreePath}" --force`, { cwd: projectRoot, stdio: 'ignore' });
|
|
499
|
-
execSync(`git branch -D "${branchName}"`, { cwd: projectRoot, stdio: 'ignore' });
|
|
500
|
-
}
|
|
501
|
-
catch {
|
|
502
|
-
// Best effort cleanup
|
|
503
|
-
}
|
|
504
|
-
return null;
|
|
505
|
-
}
|
|
506
|
-
const builder = {
|
|
507
|
-
id: builderId,
|
|
508
|
-
name: `Worktree ${shortId}`,
|
|
509
|
-
port: 0,
|
|
510
|
-
pid: 0,
|
|
511
|
-
status: 'implementing',
|
|
512
|
-
phase: 'interactive',
|
|
513
|
-
worktree: worktreePath,
|
|
514
|
-
branch: branchName,
|
|
515
|
-
tmuxSession: sessionName,
|
|
516
|
-
type: 'worktree',
|
|
517
|
-
terminalId,
|
|
518
|
-
};
|
|
519
|
-
return { builder, pid: 0 };
|
|
520
|
-
}
|
|
521
|
-
catch (err) {
|
|
522
|
-
console.error(`Failed to spawn worktree builder:`, err.message);
|
|
523
|
-
// Cleanup any partial state
|
|
524
|
-
killTmuxSession(sessionName);
|
|
525
|
-
try {
|
|
526
|
-
execSync(`git worktree remove "${worktreePath}" --force`, { cwd: projectRoot, stdio: 'ignore' });
|
|
527
|
-
execSync(`git branch -D "${branchName}"`, { cwd: projectRoot, stdio: 'ignore' });
|
|
528
|
-
}
|
|
529
|
-
catch {
|
|
530
|
-
// Best effort cleanup
|
|
531
|
-
}
|
|
532
|
-
return null;
|
|
533
|
-
}
|
|
534
|
-
}
|
|
535
|
-
// parseJsonBody imported from ../utils/server-utils.js
|
|
536
|
-
// Validate path is within project root (prevent path traversal)
|
|
537
|
-
// Handles URL-encoded dots (%2e), symlinks, and other encodings
|
|
538
|
-
function validatePathWithinProject(filePath) {
|
|
539
|
-
// First decode any URL encoding to catch %2e%2e (encoded ..)
|
|
540
|
-
let decodedPath;
|
|
541
|
-
try {
|
|
542
|
-
decodedPath = decodeURIComponent(filePath);
|
|
543
|
-
}
|
|
544
|
-
catch {
|
|
545
|
-
// Invalid encoding
|
|
546
|
-
return null;
|
|
547
|
-
}
|
|
548
|
-
// Resolve to absolute path
|
|
549
|
-
const resolvedPath = decodedPath.startsWith('/')
|
|
550
|
-
? path.resolve(decodedPath)
|
|
551
|
-
: path.resolve(projectRoot, decodedPath);
|
|
552
|
-
// Normalize to remove any .. or . segments
|
|
553
|
-
const normalizedPath = path.normalize(resolvedPath);
|
|
554
|
-
// First check normalized path (for paths that don't exist yet)
|
|
555
|
-
if (!normalizedPath.startsWith(projectRoot + path.sep) && normalizedPath !== projectRoot) {
|
|
556
|
-
return null; // Path escapes project root
|
|
557
|
-
}
|
|
558
|
-
// If file exists, resolve symlinks to prevent symlink-based path traversal
|
|
559
|
-
// An attacker could create a symlink within the repo pointing outside
|
|
560
|
-
if (fs.existsSync(normalizedPath)) {
|
|
561
|
-
try {
|
|
562
|
-
const realPath = fs.realpathSync(normalizedPath);
|
|
563
|
-
if (!realPath.startsWith(projectRoot + path.sep) && realPath !== projectRoot) {
|
|
564
|
-
return null; // Symlink target escapes project root
|
|
565
|
-
}
|
|
566
|
-
return realPath;
|
|
567
|
-
}
|
|
568
|
-
catch {
|
|
569
|
-
// realpathSync failed (broken symlink, permissions, etc.)
|
|
570
|
-
return null;
|
|
571
|
-
}
|
|
572
|
-
}
|
|
573
|
-
return normalizedPath;
|
|
574
|
-
}
|
|
575
|
-
// Count total tabs for DoS protection
|
|
576
|
-
function countTotalTabs(state) {
|
|
577
|
-
return state.builders.length + state.utils.length + state.annotations.length;
|
|
578
|
-
}
|
|
579
|
-
// Find open server script (prefer .ts for dev, .js for compiled)
|
|
580
|
-
function getOpenServerPath() {
|
|
581
|
-
const tsPath = path.join(__dirname, 'open-server.ts');
|
|
582
|
-
const jsPath = path.join(__dirname, 'open-server.js');
|
|
583
|
-
if (fs.existsSync(tsPath)) {
|
|
584
|
-
return { script: tsPath, useTsx: true };
|
|
585
|
-
}
|
|
586
|
-
return { script: jsPath, useTsx: false };
|
|
587
|
-
}
|
|
588
|
-
/**
|
|
589
|
-
* Escape a string for safe use in shell commands
|
|
590
|
-
* Handles special characters that could cause command injection
|
|
591
|
-
*/
|
|
592
|
-
function escapeShellArg(str) {
|
|
593
|
-
// Single-quote the string and escape any single quotes within it
|
|
594
|
-
return "'" + str.replace(/'/g, "'\\''") + "'";
|
|
595
|
-
}
|
|
596
|
-
/**
|
|
597
|
-
* Get today's git commits from all branches for the current user
|
|
598
|
-
*/
|
|
599
|
-
async function getGitCommits(projectRoot) {
|
|
600
|
-
try {
|
|
601
|
-
const { stdout: authorRaw } = await execAsync('git config user.name', { cwd: projectRoot });
|
|
602
|
-
const author = authorRaw.trim();
|
|
603
|
-
if (!author)
|
|
604
|
-
return [];
|
|
605
|
-
// Escape author name to prevent command injection
|
|
606
|
-
const safeAuthor = escapeShellArg(author);
|
|
607
|
-
// Get commits from all branches since midnight
|
|
608
|
-
const { stdout: output } = await execAsync(`git log --all --since="midnight" --author=${safeAuthor} --format="%H|%s|%aI|%D"`, { cwd: projectRoot, maxBuffer: 10 * 1024 * 1024 });
|
|
609
|
-
if (!output.trim())
|
|
610
|
-
return [];
|
|
611
|
-
return output.trim().split('\n').filter(Boolean).map(line => {
|
|
612
|
-
const parts = line.split('|');
|
|
613
|
-
const hash = parts[0] || '';
|
|
614
|
-
const message = parts[1] || '';
|
|
615
|
-
const time = parts[2] || '';
|
|
616
|
-
const refs = parts.slice(3).join('|'); // refs might contain |
|
|
617
|
-
// Extract branch name from refs
|
|
618
|
-
let branch = 'unknown';
|
|
619
|
-
const headMatch = refs.match(/HEAD -> ([^,]+)/);
|
|
620
|
-
const branchMatch = refs.match(/([^,\s]+)$/);
|
|
621
|
-
if (headMatch) {
|
|
622
|
-
branch = headMatch[1];
|
|
623
|
-
}
|
|
624
|
-
else if (branchMatch && branchMatch[1]) {
|
|
625
|
-
branch = branchMatch[1];
|
|
626
|
-
}
|
|
627
|
-
return {
|
|
628
|
-
hash: hash.slice(0, 7),
|
|
629
|
-
message: message.slice(0, 100), // Truncate long messages
|
|
630
|
-
time,
|
|
631
|
-
branch,
|
|
632
|
-
};
|
|
633
|
-
});
|
|
634
|
-
}
|
|
635
|
-
catch (err) {
|
|
636
|
-
console.error('Error getting git commits:', err.message);
|
|
637
|
-
return [];
|
|
638
|
-
}
|
|
639
|
-
}
|
|
640
|
-
/**
|
|
641
|
-
* Get unique files modified today
|
|
642
|
-
*/
|
|
643
|
-
async function getModifiedFiles(projectRoot) {
|
|
644
|
-
try {
|
|
645
|
-
const { stdout: authorRaw } = await execAsync('git config user.name', { cwd: projectRoot });
|
|
646
|
-
const author = authorRaw.trim();
|
|
647
|
-
if (!author)
|
|
648
|
-
return [];
|
|
649
|
-
// Escape author name to prevent command injection
|
|
650
|
-
const safeAuthor = escapeShellArg(author);
|
|
651
|
-
const { stdout: output } = await execAsync(`git log --all --since="midnight" --author=${safeAuthor} --name-only --format=""`, { cwd: projectRoot, maxBuffer: 10 * 1024 * 1024 });
|
|
652
|
-
if (!output.trim())
|
|
653
|
-
return [];
|
|
654
|
-
const files = [...new Set(output.trim().split('\n').filter(Boolean))];
|
|
655
|
-
return files.sort();
|
|
656
|
-
}
|
|
657
|
-
catch (err) {
|
|
658
|
-
console.error('Error getting modified files:', err.message);
|
|
659
|
-
return [];
|
|
660
|
-
}
|
|
661
|
-
}
|
|
662
|
-
/**
|
|
663
|
-
* Get GitHub PRs created or merged today via gh CLI
|
|
664
|
-
* Combines PRs created today AND PRs merged today (which may have been created earlier)
|
|
665
|
-
*/
|
|
666
|
-
async function getGitHubPRs(projectRoot) {
|
|
667
|
-
try {
|
|
668
|
-
// Use local time for the date (spec says "today" means local machine time)
|
|
669
|
-
const now = new Date();
|
|
670
|
-
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
|
671
|
-
// Fetch PRs created today AND PRs merged today in parallel
|
|
672
|
-
const [createdResult, mergedResult] = await Promise.allSettled([
|
|
673
|
-
execAsync(`gh pr list --author "@me" --state all --search "created:>=${today}" --json number,title,state,url`, { cwd: projectRoot, timeout: 15000 }),
|
|
674
|
-
execAsync(`gh pr list --author "@me" --state merged --search "merged:>=${today}" --json number,title,state,url`, { cwd: projectRoot, timeout: 15000 }),
|
|
675
|
-
]);
|
|
676
|
-
const prsMap = new Map();
|
|
677
|
-
// Process PRs created today
|
|
678
|
-
if (createdResult.status === 'fulfilled' && createdResult.value.stdout.trim()) {
|
|
679
|
-
const prs = JSON.parse(createdResult.value.stdout);
|
|
680
|
-
for (const pr of prs) {
|
|
681
|
-
prsMap.set(pr.number, {
|
|
682
|
-
number: pr.number,
|
|
683
|
-
title: pr.title.slice(0, 100),
|
|
684
|
-
state: pr.state,
|
|
685
|
-
url: pr.url,
|
|
686
|
-
});
|
|
687
|
-
}
|
|
688
|
-
}
|
|
689
|
-
// Process PRs merged today (may overlap with created, deduped by Map)
|
|
690
|
-
if (mergedResult.status === 'fulfilled' && mergedResult.value.stdout.trim()) {
|
|
691
|
-
const prs = JSON.parse(mergedResult.value.stdout);
|
|
692
|
-
for (const pr of prs) {
|
|
693
|
-
prsMap.set(pr.number, {
|
|
694
|
-
number: pr.number,
|
|
695
|
-
title: pr.title.slice(0, 100),
|
|
696
|
-
state: pr.state,
|
|
697
|
-
url: pr.url,
|
|
698
|
-
});
|
|
699
|
-
}
|
|
700
|
-
}
|
|
701
|
-
return Array.from(prsMap.values());
|
|
702
|
-
}
|
|
703
|
-
catch (err) {
|
|
704
|
-
// gh CLI might not be available or authenticated
|
|
705
|
-
console.error('Error getting GitHub PRs:', err.message);
|
|
706
|
-
return [];
|
|
707
|
-
}
|
|
708
|
-
}
|
|
709
|
-
/**
|
|
710
|
-
* Get builder activity from state.db for today
|
|
711
|
-
* Note: state.json doesn't track timestamps, so we can only report current builders
|
|
712
|
-
* without duration. They'll be counted as activity points, not time intervals.
|
|
713
|
-
*/
|
|
714
|
-
function getBuilderActivity() {
|
|
715
|
-
try {
|
|
716
|
-
const builders = getBuilders();
|
|
717
|
-
// Return current builders without time tracking (state.json lacks timestamps)
|
|
718
|
-
// Time tracking will rely primarily on git commits
|
|
719
|
-
return builders.map(b => ({
|
|
720
|
-
id: b.id,
|
|
721
|
-
status: b.status || 'unknown',
|
|
722
|
-
startTime: '', // Unknown - not tracked in state.json
|
|
723
|
-
endTime: undefined,
|
|
724
|
-
}));
|
|
725
|
-
}
|
|
726
|
-
catch (err) {
|
|
727
|
-
console.error('Error getting builder activity:', err.message);
|
|
728
|
-
return [];
|
|
729
|
-
}
|
|
730
|
-
}
|
|
731
|
-
/**
|
|
732
|
-
* Detect project status changes in projectlist.md today
|
|
733
|
-
* Handles YAML format inside Markdown fenced code blocks
|
|
734
|
-
*/
|
|
735
|
-
async function getProjectChanges(projectRoot) {
|
|
736
|
-
try {
|
|
737
|
-
const projectlistPath = path.join(projectRoot, 'codev/projectlist.md');
|
|
738
|
-
if (!fs.existsSync(projectlistPath))
|
|
739
|
-
return [];
|
|
740
|
-
// Get the first commit hash from today that touched projectlist.md
|
|
741
|
-
const { stdout: firstCommitOutput } = await execAsync(`git log --since="midnight" --format=%H -- codev/projectlist.md | tail -1`, { cwd: projectRoot });
|
|
742
|
-
if (!firstCommitOutput.trim())
|
|
743
|
-
return [];
|
|
744
|
-
// Get diff of projectlist.md from that commit's parent to HEAD
|
|
745
|
-
let diff;
|
|
746
|
-
try {
|
|
747
|
-
const { stdout } = await execAsync(`git diff ${firstCommitOutput.trim()}^..HEAD -- codev/projectlist.md`, { cwd: projectRoot, maxBuffer: 1024 * 1024 });
|
|
748
|
-
diff = stdout;
|
|
749
|
-
}
|
|
750
|
-
catch {
|
|
751
|
-
return [];
|
|
752
|
-
}
|
|
753
|
-
if (!diff.trim())
|
|
754
|
-
return [];
|
|
755
|
-
// Parse status changes from diff
|
|
756
|
-
// Format is YAML inside Markdown code blocks:
|
|
757
|
-
// - id: "0058"
|
|
758
|
-
// title: "File Search Autocomplete"
|
|
759
|
-
// status: implementing
|
|
760
|
-
const changes = [];
|
|
761
|
-
const lines = diff.split('\n');
|
|
762
|
-
let currentId = '';
|
|
763
|
-
let currentTitle = '';
|
|
764
|
-
let oldStatus = '';
|
|
765
|
-
let newStatus = '';
|
|
766
|
-
for (const line of lines) {
|
|
767
|
-
// Track current project context from YAML id field
|
|
768
|
-
// Match lines like: " - id: \"0058\"" or "+ - id: \"0058\""
|
|
769
|
-
const idMatch = line.match(/^[+-]?\s*-\s*id:\s*["']?(\d{4})["']?/);
|
|
770
|
-
if (idMatch) {
|
|
771
|
-
// If we have a pending status change from previous project, emit it
|
|
772
|
-
if (oldStatus && newStatus && currentId) {
|
|
773
|
-
changes.push({
|
|
774
|
-
id: currentId,
|
|
775
|
-
title: currentTitle,
|
|
776
|
-
oldStatus,
|
|
777
|
-
newStatus,
|
|
778
|
-
});
|
|
779
|
-
oldStatus = '';
|
|
780
|
-
newStatus = '';
|
|
781
|
-
}
|
|
782
|
-
currentId = idMatch[1];
|
|
783
|
-
currentTitle = ''; // Will be filled by title line
|
|
784
|
-
}
|
|
785
|
-
// Track title (comes after id in YAML)
|
|
786
|
-
// Match lines like: " title: \"File Search Autocomplete\""
|
|
787
|
-
const titleMatch = line.match(/^[+-]?\s*title:\s*["']?([^"']+)["']?/);
|
|
788
|
-
if (titleMatch && currentId) {
|
|
789
|
-
currentTitle = titleMatch[1].trim();
|
|
790
|
-
}
|
|
791
|
-
// Track status changes
|
|
792
|
-
// Match lines like: "- status: implementing" or "+ status: implemented"
|
|
793
|
-
const statusMatch = line.match(/^([+-])\s*status:\s*(\w+)/);
|
|
794
|
-
if (statusMatch) {
|
|
795
|
-
const [, modifier, status] = statusMatch;
|
|
796
|
-
if (modifier === '-') {
|
|
797
|
-
oldStatus = status;
|
|
798
|
-
}
|
|
799
|
-
else if (modifier === '+') {
|
|
800
|
-
newStatus = status;
|
|
801
|
-
}
|
|
802
|
-
}
|
|
803
|
-
}
|
|
804
|
-
// Emit final pending change if exists
|
|
805
|
-
if (oldStatus && newStatus && currentId) {
|
|
806
|
-
changes.push({
|
|
807
|
-
id: currentId,
|
|
808
|
-
title: currentTitle,
|
|
809
|
-
oldStatus,
|
|
810
|
-
newStatus,
|
|
811
|
-
});
|
|
812
|
-
}
|
|
813
|
-
return changes;
|
|
814
|
-
}
|
|
815
|
-
catch (err) {
|
|
816
|
-
console.error('Error getting project changes:', err.message);
|
|
817
|
-
return [];
|
|
818
|
-
}
|
|
819
|
-
}
|
|
820
|
-
/**
|
|
821
|
-
* Merge overlapping time intervals
|
|
822
|
-
*/
|
|
823
|
-
function mergeIntervals(intervals) {
|
|
824
|
-
if (intervals.length === 0)
|
|
825
|
-
return [];
|
|
826
|
-
// Sort by start time
|
|
827
|
-
const sorted = [...intervals].sort((a, b) => a.start.getTime() - b.start.getTime());
|
|
828
|
-
const merged = [{ ...sorted[0] }];
|
|
829
|
-
for (let i = 1; i < sorted.length; i++) {
|
|
830
|
-
const last = merged[merged.length - 1];
|
|
831
|
-
const current = sorted[i];
|
|
832
|
-
// If overlapping or within 2 hours, merge
|
|
833
|
-
const gapMs = current.start.getTime() - last.end.getTime();
|
|
834
|
-
const twoHoursMs = 2 * 60 * 60 * 1000;
|
|
835
|
-
if (gapMs <= twoHoursMs) {
|
|
836
|
-
last.end = new Date(Math.max(last.end.getTime(), current.end.getTime()));
|
|
837
|
-
}
|
|
838
|
-
else {
|
|
839
|
-
merged.push({ ...current });
|
|
840
|
-
}
|
|
841
|
-
}
|
|
842
|
-
return merged;
|
|
843
|
-
}
|
|
844
|
-
/**
|
|
845
|
-
* Calculate active time from commits and builder activity
|
|
846
|
-
*/
|
|
847
|
-
function calculateTimeTracking(commits, builders) {
|
|
848
|
-
const intervals = [];
|
|
849
|
-
const fiveMinutesMs = 5 * 60 * 1000;
|
|
850
|
-
// Add commit timestamps (treat each as 5-minute interval)
|
|
851
|
-
for (const commit of commits) {
|
|
852
|
-
if (commit.time) {
|
|
853
|
-
const time = new Date(commit.time);
|
|
854
|
-
if (!isNaN(time.getTime())) {
|
|
855
|
-
intervals.push({
|
|
856
|
-
start: time,
|
|
857
|
-
end: new Date(time.getTime() + fiveMinutesMs),
|
|
858
|
-
});
|
|
859
|
-
}
|
|
860
|
-
}
|
|
861
|
-
}
|
|
862
|
-
// Add builder sessions
|
|
863
|
-
for (const builder of builders) {
|
|
864
|
-
if (builder.startTime) {
|
|
865
|
-
const start = new Date(builder.startTime);
|
|
866
|
-
const end = builder.endTime ? new Date(builder.endTime) : new Date();
|
|
867
|
-
if (!isNaN(start.getTime()) && !isNaN(end.getTime())) {
|
|
868
|
-
intervals.push({ start, end });
|
|
869
|
-
}
|
|
870
|
-
}
|
|
871
|
-
}
|
|
872
|
-
if (intervals.length === 0) {
|
|
873
|
-
return {
|
|
874
|
-
activeMinutes: 0,
|
|
875
|
-
firstActivity: '',
|
|
876
|
-
lastActivity: '',
|
|
877
|
-
};
|
|
878
|
-
}
|
|
879
|
-
const merged = mergeIntervals(intervals);
|
|
880
|
-
const totalMinutes = merged.reduce((sum, interval) => sum + (interval.end.getTime() - interval.start.getTime()) / (1000 * 60), 0);
|
|
881
|
-
return {
|
|
882
|
-
activeMinutes: Math.round(totalMinutes),
|
|
883
|
-
firstActivity: merged[0].start.toISOString(),
|
|
884
|
-
lastActivity: merged[merged.length - 1].end.toISOString(),
|
|
885
|
-
};
|
|
886
|
-
}
|
|
887
|
-
/**
|
|
888
|
-
* Find the consult CLI path
|
|
889
|
-
* Returns the path to the consult binary, checking multiple locations
|
|
890
|
-
*/
|
|
891
|
-
function findConsultPath() {
|
|
892
|
-
// When running from dist/, check relative paths
|
|
893
|
-
// dist/agent-farm/servers/ -> ../../../bin/consult.js
|
|
894
|
-
const distPath = path.join(__dirname, '../../../bin/consult.js');
|
|
895
|
-
if (fs.existsSync(distPath)) {
|
|
896
|
-
return distPath;
|
|
897
|
-
}
|
|
898
|
-
// When running from src/ with tsx, check src-relative paths
|
|
899
|
-
// src/agent-farm/servers/ -> ../../../bin/consult.js (won't exist, it's .ts in src)
|
|
900
|
-
// But bin/ is at packages/codev/bin/consult.js, so it should still work
|
|
901
|
-
// Fall back to npx consult (works if @cluesmith/codev is installed)
|
|
902
|
-
return 'npx consult';
|
|
903
|
-
}
|
|
904
|
-
/**
|
|
905
|
-
* Generate AI summary via consult CLI
|
|
906
|
-
*/
|
|
907
|
-
async function generateAISummary(data) {
|
|
908
|
-
// Build prompt with commit messages and file names only (security: no full diffs)
|
|
909
|
-
const hours = Math.floor(data.timeTracking.activeMinutes / 60);
|
|
910
|
-
const mins = data.timeTracking.activeMinutes % 60;
|
|
911
|
-
const prompt = `Summarize this developer's activity today for a standup report.
|
|
912
|
-
|
|
913
|
-
Commits (${data.commits.length}):
|
|
914
|
-
${data.commits.slice(0, 20).map(c => `- ${c.message}`).join('\n') || '(none)'}
|
|
915
|
-
${data.commits.length > 20 ? `... and ${data.commits.length - 20} more` : ''}
|
|
916
|
-
|
|
917
|
-
PRs: ${data.prs.map(p => `#${p.number} ${p.title} (${p.state})`).join(', ') || 'None'}
|
|
918
|
-
|
|
919
|
-
Files modified: ${data.files.length} files
|
|
920
|
-
${data.files.slice(0, 10).join(', ')}${data.files.length > 10 ? ` ... and ${data.files.length - 10} more` : ''}
|
|
921
|
-
|
|
922
|
-
Project status changes:
|
|
923
|
-
${data.projectChanges.map(p => `- ${p.id} ${p.title}: ${p.oldStatus} → ${p.newStatus}`).join('\n') || '(none)'}
|
|
924
|
-
|
|
925
|
-
Active time: ~${hours}h ${mins}m
|
|
926
|
-
|
|
927
|
-
Write a brief, professional summary (2-3 sentences) focusing on accomplishments. Be concise and suitable for a standup or status report.`;
|
|
928
|
-
try {
|
|
929
|
-
// Use consult CLI to generate summary
|
|
930
|
-
const consultCmd = findConsultPath();
|
|
931
|
-
const safePrompt = escapeShellArg(prompt);
|
|
932
|
-
// Use async exec with timeout
|
|
933
|
-
const { stdout } = await execAsync(`${consultCmd} --model gemini general ${safePrompt}`, { timeout: 60000, maxBuffer: 1024 * 1024 });
|
|
934
|
-
return stdout.trim();
|
|
935
|
-
}
|
|
936
|
-
catch (err) {
|
|
937
|
-
console.error('AI summary generation failed:', err.message);
|
|
938
|
-
return '';
|
|
939
|
-
}
|
|
940
|
-
}
|
|
941
|
-
/**
|
|
942
|
-
* Collect all activity data for today
|
|
943
|
-
*/
|
|
944
|
-
async function collectActivitySummary(projectRoot) {
|
|
945
|
-
// Collect data from all sources in parallel - these are now truly async
|
|
946
|
-
const [commits, files, prs, builders, projectChanges] = await Promise.all([
|
|
947
|
-
getGitCommits(projectRoot),
|
|
948
|
-
getModifiedFiles(projectRoot),
|
|
949
|
-
getGitHubPRs(projectRoot),
|
|
950
|
-
Promise.resolve(getBuilderActivity()), // This one is sync (reads from state)
|
|
951
|
-
getProjectChanges(projectRoot),
|
|
952
|
-
]);
|
|
953
|
-
const timeTracking = calculateTimeTracking(commits, builders);
|
|
954
|
-
// Generate AI summary (skip if no activity)
|
|
955
|
-
let aiSummary = '';
|
|
956
|
-
if (commits.length > 0 || prs.length > 0) {
|
|
957
|
-
aiSummary = await generateAISummary({
|
|
958
|
-
commits,
|
|
959
|
-
prs,
|
|
960
|
-
files,
|
|
961
|
-
timeTracking,
|
|
962
|
-
projectChanges,
|
|
963
|
-
});
|
|
964
|
-
}
|
|
965
|
-
return {
|
|
966
|
-
commits,
|
|
967
|
-
prs,
|
|
968
|
-
builders,
|
|
969
|
-
projectChanges,
|
|
970
|
-
files,
|
|
971
|
-
timeTracking,
|
|
972
|
-
aiSummary: aiSummary || undefined,
|
|
973
|
-
};
|
|
974
|
-
}
|
|
975
|
-
// Insecure remote mode - set when bindHost is 0.0.0.0
|
|
976
|
-
const insecureRemoteMode = bindHost === '0.0.0.0';
|
|
977
|
-
// ============================================================
|
|
978
|
-
// Terminal Proxy (Spec 0062 - Secure Remote Access)
|
|
979
|
-
// ============================================================
|
|
980
|
-
// Create http-proxy instance for terminal proxying
|
|
981
|
-
const terminalProxy = httpProxy.createProxyServer({ ws: true });
|
|
982
|
-
// Handle proxy errors gracefully
|
|
983
|
-
terminalProxy.on('error', (err, req, res) => {
|
|
984
|
-
console.error('Terminal proxy error:', err.message);
|
|
985
|
-
if (res && 'writeHead' in res && !res.headersSent) {
|
|
986
|
-
res.writeHead(502, { 'Content-Type': 'application/json' });
|
|
987
|
-
res.end(JSON.stringify({ error: 'Terminal unavailable' }));
|
|
988
|
-
}
|
|
989
|
-
});
|
|
990
|
-
// getPortForTerminal is imported from utils/terminal-ports.ts (Spec 0062)
|
|
991
|
-
// Security: Validate request origin (uses base from server-utils with insecureRemoteMode override)
|
|
992
|
-
function isRequestAllowed(req) {
|
|
993
|
-
// Skip all security checks in insecure remote mode
|
|
994
|
-
if (insecureRemoteMode) {
|
|
995
|
-
return true;
|
|
996
|
-
}
|
|
997
|
-
return isRequestAllowedBase(req);
|
|
998
|
-
}
|
|
999
|
-
/**
|
|
1000
|
-
* Timing-safe token comparison to prevent timing attacks
|
|
1001
|
-
*/
|
|
1002
|
-
function isValidToken(provided, expected) {
|
|
1003
|
-
if (!provided)
|
|
1004
|
-
return false;
|
|
1005
|
-
// Ensure both strings are same length for timing-safe comparison
|
|
1006
|
-
const providedBuf = Buffer.from(provided);
|
|
1007
|
-
const expectedBuf = Buffer.from(expected);
|
|
1008
|
-
if (providedBuf.length !== expectedBuf.length) {
|
|
1009
|
-
// Still do a comparison to maintain constant time
|
|
1010
|
-
timingSafeEqual(expectedBuf, expectedBuf);
|
|
1011
|
-
return false;
|
|
1012
|
-
}
|
|
1013
|
-
return timingSafeEqual(providedBuf, expectedBuf);
|
|
1014
|
-
}
|
|
1015
|
-
/**
|
|
1016
|
-
* Generate HTML for login page
|
|
1017
|
-
*/
|
|
1018
|
-
function getLoginPageHtml() {
|
|
1019
|
-
return `<!DOCTYPE html>
|
|
1020
|
-
<html>
|
|
1021
|
-
<head>
|
|
1022
|
-
<title>Dashboard Login</title>
|
|
1023
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1024
|
-
<style>
|
|
1025
|
-
body { font-family: system-ui; background: #1a1a2e; color: #eee;
|
|
1026
|
-
display: flex; justify-content: center; align-items: center;
|
|
1027
|
-
min-height: 100vh; margin: 0; }
|
|
1028
|
-
.login { background: #16213e; padding: 2rem; border-radius: 8px;
|
|
1029
|
-
max-width: 400px; width: 90%; }
|
|
1030
|
-
h1 { margin-top: 0; }
|
|
1031
|
-
input { width: 100%; padding: 0.75rem; margin: 0.5rem 0;
|
|
1032
|
-
border: 1px solid #444; border-radius: 4px;
|
|
1033
|
-
background: #0f0f23; color: #eee; font-size: 1rem;
|
|
1034
|
-
box-sizing: border-box; }
|
|
1035
|
-
button { width: 100%; padding: 0.75rem; margin-top: 1rem;
|
|
1036
|
-
background: #4a7c59; color: white; border: none;
|
|
1037
|
-
border-radius: 4px; font-size: 1rem; cursor: pointer; }
|
|
1038
|
-
button:hover { background: #5a9c69; }
|
|
1039
|
-
.error { color: #ff6b6b; margin-top: 0.5rem; display: none; }
|
|
1040
|
-
</style>
|
|
1041
|
-
</head>
|
|
1042
|
-
<body>
|
|
1043
|
-
<div class="login">
|
|
1044
|
-
<h1>Agent Farm Login</h1>
|
|
1045
|
-
<p>Enter your API key to access the dashboard.</p>
|
|
1046
|
-
<input type="password" id="key" placeholder="API Key" autofocus>
|
|
1047
|
-
<div class="error" id="error">Invalid API key</div>
|
|
1048
|
-
<button onclick="login()">Login</button>
|
|
1049
|
-
</div>
|
|
1050
|
-
<script>
|
|
1051
|
-
// Check for key in URL (from QR code scan) or localStorage
|
|
1052
|
-
(async function() {
|
|
1053
|
-
const urlParams = new URLSearchParams(window.location.search);
|
|
1054
|
-
const keyFromUrl = urlParams.get('key');
|
|
1055
|
-
const keyFromStorage = localStorage.getItem('codev_web_key');
|
|
1056
|
-
const key = keyFromUrl || keyFromStorage;
|
|
1057
|
-
|
|
1058
|
-
if (key) {
|
|
1059
|
-
if (keyFromUrl) {
|
|
1060
|
-
localStorage.setItem('codev_web_key', keyFromUrl);
|
|
1061
|
-
}
|
|
1062
|
-
await verifyAndLoadDashboard(key);
|
|
1063
|
-
}
|
|
1064
|
-
})();
|
|
1065
|
-
|
|
1066
|
-
async function verifyAndLoadDashboard(key) {
|
|
1067
|
-
try {
|
|
1068
|
-
// Fetch the actual dashboard with auth header
|
|
1069
|
-
const res = await fetch(window.location.pathname, {
|
|
1070
|
-
headers: {
|
|
1071
|
-
'Authorization': 'Bearer ' + key,
|
|
1072
|
-
'Accept': 'text/html'
|
|
1073
|
-
}
|
|
1074
|
-
});
|
|
1075
|
-
if (res.ok) {
|
|
1076
|
-
// Replace entire page with dashboard
|
|
1077
|
-
const html = await res.text();
|
|
1078
|
-
document.open();
|
|
1079
|
-
document.write(html);
|
|
1080
|
-
document.close();
|
|
1081
|
-
// Clean URL without reload
|
|
1082
|
-
history.replaceState({}, '', window.location.pathname);
|
|
1083
|
-
} else {
|
|
1084
|
-
// Key invalid
|
|
1085
|
-
localStorage.removeItem('codev_web_key');
|
|
1086
|
-
document.getElementById('error').style.display = 'block';
|
|
1087
|
-
document.getElementById('error').textContent = 'Invalid API key';
|
|
1088
|
-
}
|
|
1089
|
-
} catch (e) {
|
|
1090
|
-
document.getElementById('error').style.display = 'block';
|
|
1091
|
-
document.getElementById('error').textContent = 'Connection error';
|
|
1092
|
-
}
|
|
1093
|
-
}
|
|
1094
|
-
|
|
1095
|
-
async function login() {
|
|
1096
|
-
const key = document.getElementById('key').value;
|
|
1097
|
-
if (!key) return;
|
|
1098
|
-
localStorage.setItem('codev_web_key', key);
|
|
1099
|
-
await verifyAndLoadDashboard(key);
|
|
1100
|
-
}
|
|
1101
|
-
document.getElementById('key').addEventListener('keypress', (e) => {
|
|
1102
|
-
if (e.key === 'Enter') login();
|
|
1103
|
-
});
|
|
1104
|
-
</script>
|
|
1105
|
-
</body>
|
|
1106
|
-
</html>`;
|
|
1107
|
-
}
|
|
1108
|
-
// Create server
|
|
1109
|
-
const server = http.createServer(async (req, res) => {
|
|
1110
|
-
// Security: Validate Host and Origin headers
|
|
1111
|
-
if (!isRequestAllowed(req)) {
|
|
1112
|
-
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
|
1113
|
-
res.end('Forbidden');
|
|
1114
|
-
return;
|
|
1115
|
-
}
|
|
1116
|
-
// CRITICAL: When CODEV_WEB_KEY is set, ALL requests require auth
|
|
1117
|
-
// NO localhost bypass - tunnel daemons (cloudflared) run locally and proxy
|
|
1118
|
-
// to localhost, so checking remoteAddress would incorrectly trust remote traffic
|
|
1119
|
-
const webKey = process.env.CODEV_WEB_KEY;
|
|
1120
|
-
if (webKey) {
|
|
1121
|
-
const authHeader = req.headers.authorization;
|
|
1122
|
-
const token = authHeader?.replace('Bearer ', '');
|
|
1123
|
-
if (!isValidToken(token, webKey)) {
|
|
1124
|
-
// Return login page for HTML requests, 401 for API
|
|
1125
|
-
if (req.headers.accept?.includes('text/html')) {
|
|
1126
|
-
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
1127
|
-
res.end(getLoginPageHtml());
|
|
1128
|
-
return;
|
|
1129
|
-
}
|
|
1130
|
-
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
1131
|
-
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
|
1132
|
-
return;
|
|
1133
|
-
}
|
|
1134
|
-
}
|
|
1135
|
-
// When CODEV_WEB_KEY is NOT set: no auth required (local dev mode only)
|
|
1136
|
-
// CORS headers
|
|
1137
|
-
const origin = req.headers.origin;
|
|
1138
|
-
if (insecureRemoteMode || webKey) {
|
|
1139
|
-
// Allow any origin in insecure remote mode or when using auth (tunnel access)
|
|
1140
|
-
res.setHeader('Access-Control-Allow-Origin', origin || '*');
|
|
1141
|
-
}
|
|
1142
|
-
else if (origin && (origin.startsWith('http://localhost:') || origin.startsWith('http://127.0.0.1:'))) {
|
|
1143
|
-
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
1144
|
-
}
|
|
1145
|
-
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
|
|
1146
|
-
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
|
1147
|
-
// Prevent caching of API responses
|
|
1148
|
-
res.setHeader('Cache-Control', 'no-store');
|
|
1149
|
-
if (req.method === 'OPTIONS') {
|
|
1150
|
-
res.writeHead(200);
|
|
1151
|
-
res.end();
|
|
1152
|
-
return;
|
|
1153
|
-
}
|
|
1154
|
-
const url = new URL(req.url || '/', `http://localhost:${port}`);
|
|
1155
|
-
try {
|
|
1156
|
-
// Spec 0085: node-pty terminal manager REST API routes
|
|
1157
|
-
if (terminalManager && url.pathname.startsWith('/api/terminals')) {
|
|
1158
|
-
if (terminalManager.handleRequest(req, res)) {
|
|
1159
|
-
return;
|
|
1160
|
-
}
|
|
1161
|
-
}
|
|
1162
|
-
// API: Get state
|
|
1163
|
-
if (req.method === 'GET' && url.pathname === '/api/state') {
|
|
1164
|
-
const state = loadStateWithCleanup();
|
|
1165
|
-
// Include project name for tab title
|
|
1166
|
-
const stateWithProject = {
|
|
1167
|
-
...state,
|
|
1168
|
-
projectName: getProjectName(projectRoot),
|
|
1169
|
-
};
|
|
1170
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1171
|
-
res.end(JSON.stringify(stateWithProject));
|
|
1172
|
-
return;
|
|
1173
|
-
}
|
|
1174
|
-
// API: Create file tab (annotation)
|
|
1175
|
-
if (req.method === 'POST' && url.pathname === '/api/tabs/file') {
|
|
1176
|
-
const body = await parseJsonBody(req);
|
|
1177
|
-
const filePath = body.path;
|
|
1178
|
-
if (!filePath) {
|
|
1179
|
-
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
1180
|
-
res.end('Missing path');
|
|
1181
|
-
return;
|
|
1182
|
-
}
|
|
1183
|
-
// Validate path is within project root (prevent path traversal)
|
|
1184
|
-
const fullPath = validatePathWithinProject(filePath);
|
|
1185
|
-
if (!fullPath) {
|
|
1186
|
-
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
|
1187
|
-
res.end('Path must be within project directory');
|
|
1188
|
-
return;
|
|
1189
|
-
}
|
|
1190
|
-
// Check file exists
|
|
1191
|
-
if (!fs.existsSync(fullPath)) {
|
|
1192
|
-
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
1193
|
-
res.end(`File not found: ${filePath}`);
|
|
1194
|
-
return;
|
|
1195
|
-
}
|
|
1196
|
-
// Check if already open
|
|
1197
|
-
const annotations = getAnnotations();
|
|
1198
|
-
const existing = annotations.find((a) => a.file === fullPath);
|
|
1199
|
-
if (existing) {
|
|
1200
|
-
// Verify the process is still running
|
|
1201
|
-
if (isProcessRunning(existing.pid)) {
|
|
1202
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1203
|
-
res.end(JSON.stringify({ id: existing.id, port: existing.port, existing: true }));
|
|
1204
|
-
return;
|
|
1205
|
-
}
|
|
1206
|
-
// Process is dead - clean up stale entry and spawn new one
|
|
1207
|
-
console.log(`Cleaning up stale annotation for ${fullPath} (pid ${existing.pid} dead)`);
|
|
1208
|
-
removeAnnotation(existing.id);
|
|
1209
|
-
}
|
|
1210
|
-
// DoS protection: check tab limit
|
|
1211
|
-
const state = loadState();
|
|
1212
|
-
if (countTotalTabs(state) >= CONFIG.maxTabs) {
|
|
1213
|
-
res.writeHead(429, { 'Content-Type': 'text/plain' });
|
|
1214
|
-
res.end(`Tab limit reached (max ${CONFIG.maxTabs}). Close some tabs first.`);
|
|
1215
|
-
return;
|
|
1216
|
-
}
|
|
1217
|
-
// Find available port (pass state to avoid already-allocated ports)
|
|
1218
|
-
const openPort = await findAvailablePort(CONFIG.openPortStart, state);
|
|
1219
|
-
// Start open server
|
|
1220
|
-
const { script: serverScript, useTsx } = getOpenServerPath();
|
|
1221
|
-
if (!fs.existsSync(serverScript)) {
|
|
1222
|
-
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
1223
|
-
res.end('Open server not found');
|
|
1224
|
-
return;
|
|
1225
|
-
}
|
|
1226
|
-
// Use tsx for TypeScript files, node for compiled JavaScript
|
|
1227
|
-
const cmd = useTsx ? 'npx' : 'node';
|
|
1228
|
-
const args = useTsx
|
|
1229
|
-
? ['tsx', serverScript, String(openPort), fullPath]
|
|
1230
|
-
: [serverScript, String(openPort), fullPath];
|
|
1231
|
-
const pid = spawnDetached(cmd, args, projectRoot);
|
|
1232
|
-
if (!pid) {
|
|
1233
|
-
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
1234
|
-
res.end('Failed to start open server');
|
|
1235
|
-
return;
|
|
1236
|
-
}
|
|
1237
|
-
// Wait for open server to be ready (accepting connections)
|
|
1238
|
-
const serverReady = await waitForPortReady(openPort, 5000);
|
|
1239
|
-
if (!serverReady) {
|
|
1240
|
-
// Server didn't start in time - kill it and report error
|
|
1241
|
-
try {
|
|
1242
|
-
process.kill(pid);
|
|
1243
|
-
}
|
|
1244
|
-
catch {
|
|
1245
|
-
// Process may have already died
|
|
1246
|
-
}
|
|
1247
|
-
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
1248
|
-
res.end('Open server failed to start (timeout)');
|
|
1249
|
-
return;
|
|
1250
|
-
}
|
|
1251
|
-
// Create annotation record
|
|
1252
|
-
const annotation = {
|
|
1253
|
-
id: generateId('A'),
|
|
1254
|
-
file: fullPath,
|
|
1255
|
-
port: openPort,
|
|
1256
|
-
pid,
|
|
1257
|
-
parent: { type: 'architect' },
|
|
1258
|
-
};
|
|
1259
|
-
addAnnotation(annotation);
|
|
1260
|
-
res.writeHead(201, { 'Content-Type': 'application/json' });
|
|
1261
|
-
res.end(JSON.stringify({ id: annotation.id, port: openPort }));
|
|
1262
|
-
return;
|
|
1263
|
-
}
|
|
1264
|
-
// API: Create builder tab (spawns worktree builder with random ID)
|
|
1265
|
-
if (req.method === 'POST' && url.pathname === '/api/tabs/builder') {
|
|
1266
|
-
const builderState = loadState();
|
|
1267
|
-
// DoS protection: check tab limit
|
|
1268
|
-
if (countTotalTabs(builderState) >= CONFIG.maxTabs) {
|
|
1269
|
-
res.writeHead(429, { 'Content-Type': 'text/plain' });
|
|
1270
|
-
res.end(`Tab limit reached (max ${CONFIG.maxTabs}). Close some tabs first.`);
|
|
1271
|
-
return;
|
|
1272
|
-
}
|
|
1273
|
-
// Find available port for builder
|
|
1274
|
-
const builderPort = await findAvailablePort(CONFIG.builderPortStart, builderState);
|
|
1275
|
-
// Spawn worktree builder
|
|
1276
|
-
const result = await spawnWorktreeBuilder(builderPort, builderState);
|
|
1277
|
-
if (!result) {
|
|
1278
|
-
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
1279
|
-
res.end('Failed to spawn worktree builder');
|
|
1280
|
-
return;
|
|
1281
|
-
}
|
|
1282
|
-
// Save builder to state
|
|
1283
|
-
upsertBuilder(result.builder);
|
|
1284
|
-
res.writeHead(201, { 'Content-Type': 'application/json' });
|
|
1285
|
-
res.end(JSON.stringify({ id: result.builder.id, port: result.builder.port, name: result.builder.name }));
|
|
1286
|
-
return;
|
|
1287
|
-
}
|
|
1288
|
-
// API: Create shell tab (supports worktree parameter for Spec 0057)
|
|
1289
|
-
if (req.method === 'POST' && url.pathname === '/api/tabs/shell') {
|
|
1290
|
-
const body = await parseJsonBody(req);
|
|
1291
|
-
const name = body.name || undefined;
|
|
1292
|
-
const command = body.command || undefined;
|
|
1293
|
-
const worktree = body.worktree === true;
|
|
1294
|
-
const branch = body.branch || undefined;
|
|
1295
|
-
// Validate name if provided (prevent command injection)
|
|
1296
|
-
if (name && !/^[a-zA-Z0-9_-]+$/.test(name)) {
|
|
1297
|
-
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
1298
|
-
res.end('Invalid name format');
|
|
1299
|
-
return;
|
|
1300
|
-
}
|
|
1301
|
-
// Validate branch name if provided (prevent command injection)
|
|
1302
|
-
// Allow: letters, numbers, underscores, hyphens, slashes, dots
|
|
1303
|
-
// Reject: control chars, spaces, .., @{, trailing/leading slashes
|
|
1304
|
-
if (branch) {
|
|
1305
|
-
const invalidPatterns = [
|
|
1306
|
-
/[\x00-\x1f\x7f]/, // Control characters
|
|
1307
|
-
/\s/, // Whitespace
|
|
1308
|
-
/\.\./, // Parent directory traversal
|
|
1309
|
-
/@\{/, // Git reflog syntax
|
|
1310
|
-
/^\//, // Leading slash
|
|
1311
|
-
/\/$/, // Trailing slash
|
|
1312
|
-
/\/\//, // Double slash
|
|
1313
|
-
/^-/, // Leading hyphen (could be flag)
|
|
1314
|
-
];
|
|
1315
|
-
const isInvalid = invalidPatterns.some(p => p.test(branch));
|
|
1316
|
-
if (isInvalid) {
|
|
1317
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1318
|
-
res.end(JSON.stringify({
|
|
1319
|
-
success: false,
|
|
1320
|
-
error: 'Invalid branch name. Avoid spaces, control characters, .., @{, and leading/trailing slashes.'
|
|
1321
|
-
}));
|
|
1322
|
-
return;
|
|
1323
|
-
}
|
|
1324
|
-
}
|
|
1325
|
-
const shellState = loadState();
|
|
1326
|
-
// DoS protection: check tab limit
|
|
1327
|
-
if (countTotalTabs(shellState) >= CONFIG.maxTabs) {
|
|
1328
|
-
res.writeHead(429, { 'Content-Type': 'text/plain' });
|
|
1329
|
-
res.end(`Tab limit reached (max ${CONFIG.maxTabs}). Close some tabs first.`);
|
|
1330
|
-
return;
|
|
1331
|
-
}
|
|
1332
|
-
// Determine working directory (project root or worktree)
|
|
1333
|
-
let cwd = projectRoot;
|
|
1334
|
-
let worktreePath;
|
|
1335
|
-
if (worktree) {
|
|
1336
|
-
// Create worktree for the shell
|
|
1337
|
-
const worktreesDir = path.join(projectRoot, '.worktrees');
|
|
1338
|
-
if (!fs.existsSync(worktreesDir)) {
|
|
1339
|
-
fs.mkdirSync(worktreesDir, { recursive: true });
|
|
1340
|
-
}
|
|
1341
|
-
// Generate worktree name
|
|
1342
|
-
const worktreeName = branch || `temp-${Date.now()}`;
|
|
1343
|
-
worktreePath = path.join(worktreesDir, worktreeName);
|
|
1344
|
-
// Check if worktree already exists
|
|
1345
|
-
if (fs.existsSync(worktreePath)) {
|
|
1346
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1347
|
-
res.end(JSON.stringify({
|
|
1348
|
-
success: false,
|
|
1349
|
-
error: `Worktree '${worktreeName}' already exists at ${worktreePath}`
|
|
1350
|
-
}));
|
|
1351
|
-
return;
|
|
1352
|
-
}
|
|
1353
|
-
// Create worktree
|
|
1354
|
-
try {
|
|
1355
|
-
let gitCmd;
|
|
1356
|
-
if (branch) {
|
|
1357
|
-
// Check if branch already exists
|
|
1358
|
-
let branchExists = false;
|
|
1359
|
-
try {
|
|
1360
|
-
execSync(`git rev-parse --verify "${branch}"`, { cwd: projectRoot, stdio: 'pipe' });
|
|
1361
|
-
branchExists = true;
|
|
1362
|
-
}
|
|
1363
|
-
catch {
|
|
1364
|
-
// Branch doesn't exist
|
|
1365
|
-
}
|
|
1366
|
-
if (branchExists) {
|
|
1367
|
-
// Checkout existing branch into worktree
|
|
1368
|
-
gitCmd = `git worktree add "${worktreePath}" "${branch}"`;
|
|
1369
|
-
}
|
|
1370
|
-
else {
|
|
1371
|
-
// Create new branch and worktree
|
|
1372
|
-
gitCmd = `git worktree add "${worktreePath}" -b "${branch}"`;
|
|
1373
|
-
}
|
|
1374
|
-
}
|
|
1375
|
-
else {
|
|
1376
|
-
// Detached HEAD worktree
|
|
1377
|
-
gitCmd = `git worktree add "${worktreePath}" --detach`;
|
|
1378
|
-
}
|
|
1379
|
-
execSync(gitCmd, { cwd: projectRoot, stdio: 'pipe' });
|
|
1380
|
-
// Symlink .env from project root into worktree (if it exists)
|
|
1381
|
-
const rootEnvPath = path.join(projectRoot, '.env');
|
|
1382
|
-
const worktreeEnvPath = path.join(worktreePath, '.env');
|
|
1383
|
-
if (fs.existsSync(rootEnvPath) && !fs.existsSync(worktreeEnvPath)) {
|
|
1384
|
-
try {
|
|
1385
|
-
fs.symlinkSync(rootEnvPath, worktreeEnvPath);
|
|
1386
|
-
}
|
|
1387
|
-
catch {
|
|
1388
|
-
// Non-fatal: continue without .env symlink
|
|
1389
|
-
}
|
|
1390
|
-
}
|
|
1391
|
-
cwd = worktreePath;
|
|
1392
|
-
}
|
|
1393
|
-
catch (gitError) {
|
|
1394
|
-
const errorMsg = gitError instanceof Error
|
|
1395
|
-
? gitError.stderr?.toString() || gitError.message
|
|
1396
|
-
: 'Unknown error';
|
|
1397
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1398
|
-
res.end(JSON.stringify({
|
|
1399
|
-
success: false,
|
|
1400
|
-
error: `Git worktree creation failed: ${errorMsg}`
|
|
1401
|
-
}));
|
|
1402
|
-
return;
|
|
1403
|
-
}
|
|
1404
|
-
}
|
|
1405
|
-
// Generate ID and name
|
|
1406
|
-
const id = generateId('U');
|
|
1407
|
-
const utilName = name || (worktree ? `worktree-${shellState.utils.length + 1}` : `shell-${shellState.utils.length + 1}`);
|
|
1408
|
-
const sessionName = `af-shell-${id}`;
|
|
1409
|
-
// Get shell command - if command provided, run it then keep shell open
|
|
1410
|
-
const shell = process.env.SHELL || '/bin/bash';
|
|
1411
|
-
const shellCommand = command
|
|
1412
|
-
? `${shell} -c '${command.replace(/'/g, "'\\''")}; exec ${shell}'`
|
|
1413
|
-
: shell;
|
|
1414
|
-
// Create PTY terminal session via node-pty
|
|
1415
|
-
const terminalId = await createTerminalSession(shellCommand, cwd, `shell-${utilName}`);
|
|
1416
|
-
if (!terminalId) {
|
|
1417
|
-
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
1418
|
-
res.end('Failed to create terminal session');
|
|
1419
|
-
return;
|
|
1420
|
-
}
|
|
1421
|
-
const util = {
|
|
1422
|
-
id,
|
|
1423
|
-
name: utilName,
|
|
1424
|
-
port: 0,
|
|
1425
|
-
pid: 0,
|
|
1426
|
-
tmuxSession: sessionName,
|
|
1427
|
-
worktreePath: worktreePath,
|
|
1428
|
-
terminalId,
|
|
1429
|
-
};
|
|
1430
|
-
addUtil(util);
|
|
1431
|
-
res.writeHead(201, { 'Content-Type': 'application/json' });
|
|
1432
|
-
res.end(JSON.stringify({ success: true, id, port: 0, name: utilName, terminalId }));
|
|
1433
|
-
return;
|
|
1434
|
-
}
|
|
1435
|
-
// API: Check if tab process is running (Bugfix #132)
|
|
1436
|
-
if (req.method === 'GET' && url.pathname.match(/^\/api\/tabs\/[^/]+\/running$/)) {
|
|
1437
|
-
const match = url.pathname.match(/^\/api\/tabs\/([^/]+)\/running$/);
|
|
1438
|
-
if (!match) {
|
|
1439
|
-
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
1440
|
-
res.end('Invalid tab ID');
|
|
1441
|
-
return;
|
|
1442
|
-
}
|
|
1443
|
-
const tabId = decodeURIComponent(match[1]);
|
|
1444
|
-
let running = false;
|
|
1445
|
-
let found = false;
|
|
1446
|
-
// Check if it's a shell tab
|
|
1447
|
-
if (tabId.startsWith('shell-')) {
|
|
1448
|
-
const utilId = tabId.replace('shell-', '');
|
|
1449
|
-
const tabUtils = getUtils();
|
|
1450
|
-
const util = tabUtils.find((u) => u.id === utilId);
|
|
1451
|
-
if (util) {
|
|
1452
|
-
found = true;
|
|
1453
|
-
// Check tmux session status (Spec 0076)
|
|
1454
|
-
if (util.tmuxSession) {
|
|
1455
|
-
running = tmuxSessionExists(util.tmuxSession);
|
|
1456
|
-
}
|
|
1457
|
-
else {
|
|
1458
|
-
// Fallback for shells without tmux session (shouldn't happen in practice)
|
|
1459
|
-
running = isProcessRunning(util.pid);
|
|
1460
|
-
}
|
|
1461
|
-
}
|
|
1462
|
-
}
|
|
1463
|
-
// Check if it's a builder tab
|
|
1464
|
-
if (tabId.startsWith('builder-')) {
|
|
1465
|
-
const builderId = tabId.replace('builder-', '');
|
|
1466
|
-
const builder = getBuilder(builderId);
|
|
1467
|
-
if (builder) {
|
|
1468
|
-
found = true;
|
|
1469
|
-
// Check tmux session status (Spec 0076)
|
|
1470
|
-
if (builder.tmuxSession) {
|
|
1471
|
-
running = tmuxSessionExists(builder.tmuxSession);
|
|
1472
|
-
}
|
|
1473
|
-
else {
|
|
1474
|
-
// Fallback for builders without tmux session (shouldn't happen in practice)
|
|
1475
|
-
running = isProcessRunning(builder.pid);
|
|
1476
|
-
}
|
|
1477
|
-
}
|
|
1478
|
-
}
|
|
1479
|
-
if (found) {
|
|
1480
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1481
|
-
res.end(JSON.stringify({ running }));
|
|
1482
|
-
}
|
|
1483
|
-
else {
|
|
1484
|
-
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1485
|
-
res.end(JSON.stringify({ running: false }));
|
|
1486
|
-
}
|
|
1487
|
-
return;
|
|
1488
|
-
}
|
|
1489
|
-
// API: Close tab
|
|
1490
|
-
if (req.method === 'DELETE' && url.pathname.startsWith('/api/tabs/')) {
|
|
1491
|
-
const tabId = decodeURIComponent(url.pathname.replace('/api/tabs/', ''));
|
|
1492
|
-
let found = false;
|
|
1493
|
-
// Check if it's a file tab
|
|
1494
|
-
if (tabId.startsWith('file-')) {
|
|
1495
|
-
const annotationId = tabId.replace('file-', '');
|
|
1496
|
-
const tabAnnotations = getAnnotations();
|
|
1497
|
-
const annotation = tabAnnotations.find((a) => a.id === annotationId);
|
|
1498
|
-
if (annotation) {
|
|
1499
|
-
await killProcessGracefully(annotation.pid);
|
|
1500
|
-
removeAnnotation(annotationId);
|
|
1501
|
-
found = true;
|
|
1502
|
-
}
|
|
1503
|
-
}
|
|
1504
|
-
// Check if it's a builder tab
|
|
1505
|
-
if (tabId.startsWith('builder-')) {
|
|
1506
|
-
const builderId = tabId.replace('builder-', '');
|
|
1507
|
-
const builder = getBuilder(builderId);
|
|
1508
|
-
if (builder) {
|
|
1509
|
-
await killProcessGracefully(builder.pid);
|
|
1510
|
-
removeBuilder(builderId);
|
|
1511
|
-
found = true;
|
|
1512
|
-
}
|
|
1513
|
-
}
|
|
1514
|
-
// Check if it's a shell tab
|
|
1515
|
-
if (tabId.startsWith('shell-')) {
|
|
1516
|
-
const utilId = tabId.replace('shell-', '');
|
|
1517
|
-
const tabUtils = getUtils();
|
|
1518
|
-
const util = tabUtils.find((u) => u.id === utilId);
|
|
1519
|
-
if (util) {
|
|
1520
|
-
// Kill PTY session if present
|
|
1521
|
-
if (util.terminalId && terminalManager) {
|
|
1522
|
-
terminalManager.killSession(util.terminalId);
|
|
1523
|
-
}
|
|
1524
|
-
await killProcessGracefully(util.pid, util.tmuxSession);
|
|
1525
|
-
// Note: worktrees are NOT cleaned up on tab close - they may contain useful context
|
|
1526
|
-
// Users can manually clean up with `git worktree list` and `git worktree remove`
|
|
1527
|
-
removeUtil(utilId);
|
|
1528
|
-
found = true;
|
|
1529
|
-
}
|
|
1530
|
-
}
|
|
1531
|
-
if (found) {
|
|
1532
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1533
|
-
res.end(JSON.stringify({ success: true }));
|
|
1534
|
-
}
|
|
1535
|
-
else {
|
|
1536
|
-
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
1537
|
-
res.end('Tab not found');
|
|
1538
|
-
}
|
|
1539
|
-
return;
|
|
1540
|
-
}
|
|
1541
|
-
// API: Stop all
|
|
1542
|
-
if (req.method === 'POST' && url.pathname === '/api/stop') {
|
|
1543
|
-
const stopState = loadState();
|
|
1544
|
-
// Kill all tmux sessions first
|
|
1545
|
-
for (const util of stopState.utils) {
|
|
1546
|
-
if (util.tmuxSession) {
|
|
1547
|
-
killTmuxSession(util.tmuxSession);
|
|
1548
|
-
}
|
|
1549
|
-
}
|
|
1550
|
-
if (stopState.architect?.tmuxSession) {
|
|
1551
|
-
killTmuxSession(stopState.architect.tmuxSession);
|
|
1552
|
-
}
|
|
1553
|
-
// Kill all processes gracefully
|
|
1554
|
-
const pids = [];
|
|
1555
|
-
if (stopState.architect) {
|
|
1556
|
-
pids.push(stopState.architect.pid);
|
|
1557
|
-
}
|
|
1558
|
-
for (const builder of stopState.builders) {
|
|
1559
|
-
pids.push(builder.pid);
|
|
1560
|
-
}
|
|
1561
|
-
for (const util of stopState.utils) {
|
|
1562
|
-
pids.push(util.pid);
|
|
1563
|
-
}
|
|
1564
|
-
for (const annotation of stopState.annotations) {
|
|
1565
|
-
pids.push(annotation.pid);
|
|
1566
|
-
}
|
|
1567
|
-
// Kill all processes in parallel
|
|
1568
|
-
await Promise.all(pids.map((pid) => killProcessGracefully(pid)));
|
|
1569
|
-
// Clear state
|
|
1570
|
-
clearState();
|
|
1571
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1572
|
-
res.end(JSON.stringify({ success: true, killed: pids.length }));
|
|
1573
|
-
// Exit after a short delay
|
|
1574
|
-
setTimeout(() => process.exit(0), 500);
|
|
1575
|
-
return;
|
|
1576
|
-
}
|
|
1577
|
-
// Open file route - handles file clicks from terminal
|
|
1578
|
-
// Returns a small HTML page that messages the dashboard via BroadcastChannel
|
|
1579
|
-
if (req.method === 'GET' && url.pathname === '/open-file') {
|
|
1580
|
-
const filePath = url.searchParams.get('path');
|
|
1581
|
-
const line = url.searchParams.get('line');
|
|
1582
|
-
const sourcePort = url.searchParams.get('sourcePort');
|
|
1583
|
-
if (!filePath) {
|
|
1584
|
-
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
1585
|
-
res.end('Missing path parameter');
|
|
1586
|
-
return;
|
|
1587
|
-
}
|
|
1588
|
-
// Determine base path for relative path resolution
|
|
1589
|
-
// If sourcePort is provided, look up the builder/util to get its worktree
|
|
1590
|
-
let basePath = projectRoot;
|
|
1591
|
-
if (sourcePort) {
|
|
1592
|
-
const portNum = parseInt(sourcePort, 10);
|
|
1593
|
-
const builders = getBuilders();
|
|
1594
|
-
// Check if it's a builder terminal
|
|
1595
|
-
const builder = builders.find((b) => b.port === portNum);
|
|
1596
|
-
if (builder && builder.worktree) {
|
|
1597
|
-
basePath = builder.worktree;
|
|
1598
|
-
}
|
|
1599
|
-
// Check if it's a utility terminal (they run in project root, so no change needed)
|
|
1600
|
-
// Architect terminal also runs in project root
|
|
1601
|
-
}
|
|
1602
|
-
// Validate path is within project (or builder worktree)
|
|
1603
|
-
// For relative paths, resolve against the determined base path
|
|
1604
|
-
let fullPath;
|
|
1605
|
-
if (filePath.startsWith('/')) {
|
|
1606
|
-
// Absolute path - validate against project root
|
|
1607
|
-
fullPath = validatePathWithinProject(filePath);
|
|
1608
|
-
}
|
|
1609
|
-
else {
|
|
1610
|
-
// Relative path - resolve against base path, then validate
|
|
1611
|
-
const resolvedPath = path.resolve(basePath, filePath);
|
|
1612
|
-
// For builder worktrees, the path is within project root (worktrees are under .builders/)
|
|
1613
|
-
fullPath = validatePathWithinProject(resolvedPath);
|
|
1614
|
-
}
|
|
1615
|
-
if (!fullPath) {
|
|
1616
|
-
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
|
1617
|
-
res.end('Path must be within project directory');
|
|
1618
|
-
return;
|
|
1619
|
-
}
|
|
1620
|
-
// Check file exists
|
|
1621
|
-
if (!fs.existsSync(fullPath)) {
|
|
1622
|
-
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
1623
|
-
res.end(`File not found: ${filePath}`);
|
|
1624
|
-
return;
|
|
1625
|
-
}
|
|
1626
|
-
// HTML-escape the file path for safe display (uses imported escapeHtml from server-utils.js)
|
|
1627
|
-
const safeFilePath = escapeHtml(filePath);
|
|
1628
|
-
const safeLineDisplay = line ? ':' + escapeHtml(line) : '';
|
|
1629
|
-
// Serve a small HTML page that communicates back to dashboard
|
|
1630
|
-
// Note: We only use BroadcastChannel, not API call (dashboard handles tab creation)
|
|
1631
|
-
const html = `<!DOCTYPE html>
|
|
1632
|
-
<html>
|
|
1633
|
-
<head>
|
|
1634
|
-
<title>Opening file...</title>
|
|
1635
|
-
<style>
|
|
1636
|
-
body {
|
|
1637
|
-
font-family: system-ui;
|
|
1638
|
-
background: #1a1a1a;
|
|
1639
|
-
color: #ccc;
|
|
1640
|
-
display: flex;
|
|
1641
|
-
align-items: center;
|
|
1642
|
-
justify-content: center;
|
|
1643
|
-
height: 100vh;
|
|
1644
|
-
margin: 0;
|
|
1645
|
-
}
|
|
1646
|
-
.message { text-align: center; }
|
|
1647
|
-
.path { color: #3b82f6; font-family: monospace; margin: 8px 0; }
|
|
1648
|
-
</style>
|
|
1649
|
-
</head>
|
|
1650
|
-
<body>
|
|
1651
|
-
<div class="message">
|
|
1652
|
-
<p>Opening file...</p>
|
|
1653
|
-
<p class="path">${safeFilePath}${safeLineDisplay}</p>
|
|
1654
|
-
</div>
|
|
1655
|
-
<script>
|
|
1656
|
-
(async function() {
|
|
1657
|
-
const path = ${JSON.stringify(fullPath)};
|
|
1658
|
-
const line = ${line ? parseInt(line, 10) : 'null'};
|
|
1659
|
-
|
|
1660
|
-
// Use BroadcastChannel to message the dashboard
|
|
1661
|
-
// Dashboard will handle opening the file tab
|
|
1662
|
-
const channel = new BroadcastChannel('agent-farm');
|
|
1663
|
-
channel.postMessage({
|
|
1664
|
-
type: 'openFile',
|
|
1665
|
-
path: path,
|
|
1666
|
-
line: line
|
|
1667
|
-
});
|
|
1668
|
-
|
|
1669
|
-
// Close this window/tab after a short delay
|
|
1670
|
-
setTimeout(() => {
|
|
1671
|
-
window.close();
|
|
1672
|
-
// If window.close() doesn't work (wasn't opened by script),
|
|
1673
|
-
// show success message
|
|
1674
|
-
document.body.innerHTML = '<div class="message"><p>File opened in dashboard</p><p class="path">You can close this tab</p></div>';
|
|
1675
|
-
}, 500);
|
|
1676
|
-
})();
|
|
1677
|
-
</script>
|
|
1678
|
-
</body>
|
|
1679
|
-
</html>`;
|
|
1680
|
-
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
1681
|
-
res.end(html);
|
|
1682
|
-
return;
|
|
1683
|
-
}
|
|
1684
|
-
// API: Check if projectlist.md exists (for starter page polling)
|
|
1685
|
-
if (req.method === 'GET' && url.pathname === '/api/projectlist-exists') {
|
|
1686
|
-
const projectlistPath = path.join(projectRoot, 'codev/projectlist.md');
|
|
1687
|
-
const exists = fs.existsSync(projectlistPath);
|
|
1688
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1689
|
-
res.end(JSON.stringify({ exists }));
|
|
1690
|
-
return;
|
|
1691
|
-
}
|
|
1692
|
-
// Read file contents (for Projects tab to read projectlist.md)
|
|
1693
|
-
if (req.method === 'GET' && url.pathname === '/file') {
|
|
1694
|
-
const filePath = url.searchParams.get('path');
|
|
1695
|
-
if (!filePath) {
|
|
1696
|
-
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
1697
|
-
res.end('Missing path parameter');
|
|
1698
|
-
return;
|
|
1699
|
-
}
|
|
1700
|
-
// Validate path is within project root (prevent path traversal)
|
|
1701
|
-
const fullPath = validatePathWithinProject(filePath);
|
|
1702
|
-
if (!fullPath) {
|
|
1703
|
-
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
|
1704
|
-
res.end('Path must be within project directory');
|
|
1705
|
-
return;
|
|
1706
|
-
}
|
|
1707
|
-
// Check file exists
|
|
1708
|
-
if (!fs.existsSync(fullPath)) {
|
|
1709
|
-
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
1710
|
-
res.end(`File not found: ${filePath}`);
|
|
1711
|
-
return;
|
|
1712
|
-
}
|
|
1713
|
-
// Check if it's a directory
|
|
1714
|
-
const stat = fs.statSync(fullPath);
|
|
1715
|
-
if (stat.isDirectory()) {
|
|
1716
|
-
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
1717
|
-
res.end(`Cannot read directory as file: ${filePath}`);
|
|
1718
|
-
return;
|
|
1719
|
-
}
|
|
1720
|
-
// Read and return file contents
|
|
1721
|
-
try {
|
|
1722
|
-
const content = fs.readFileSync(fullPath, 'utf-8');
|
|
1723
|
-
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
1724
|
-
res.end(content);
|
|
1725
|
-
}
|
|
1726
|
-
catch (err) {
|
|
1727
|
-
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
1728
|
-
res.end('Error reading file: ' + err.message);
|
|
1729
|
-
}
|
|
1730
|
-
return;
|
|
1731
|
-
}
|
|
1732
|
-
// API: Get directory tree for file browser (Spec 0055)
|
|
1733
|
-
if (req.method === 'GET' && url.pathname === '/api/files') {
|
|
1734
|
-
// Directories to exclude from the tree
|
|
1735
|
-
const EXCLUDED_DIRS = new Set([
|
|
1736
|
-
'node_modules',
|
|
1737
|
-
'.git',
|
|
1738
|
-
'dist',
|
|
1739
|
-
'__pycache__',
|
|
1740
|
-
'.next',
|
|
1741
|
-
'.nuxt',
|
|
1742
|
-
'.turbo',
|
|
1743
|
-
'coverage',
|
|
1744
|
-
'.nyc_output',
|
|
1745
|
-
'.cache',
|
|
1746
|
-
'.parcel-cache',
|
|
1747
|
-
'build',
|
|
1748
|
-
'.svelte-kit',
|
|
1749
|
-
'vendor',
|
|
1750
|
-
'.venv',
|
|
1751
|
-
'venv',
|
|
1752
|
-
'env',
|
|
1753
|
-
]);
|
|
1754
|
-
// Recursively build directory tree
|
|
1755
|
-
function buildTree(dirPath, relativePath = '') {
|
|
1756
|
-
const entries = [];
|
|
1757
|
-
try {
|
|
1758
|
-
const items = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
1759
|
-
for (const item of items) {
|
|
1760
|
-
// Skip excluded directories only (allow dotfiles like .github, .eslintrc, etc.)
|
|
1761
|
-
if (EXCLUDED_DIRS.has(item.name))
|
|
1762
|
-
continue;
|
|
1763
|
-
const itemRelPath = relativePath ? `${relativePath}/${item.name}` : item.name;
|
|
1764
|
-
const itemFullPath = path.join(dirPath, item.name);
|
|
1765
|
-
if (item.isDirectory()) {
|
|
1766
|
-
const children = buildTree(itemFullPath, itemRelPath);
|
|
1767
|
-
entries.push({
|
|
1768
|
-
name: item.name,
|
|
1769
|
-
path: itemRelPath,
|
|
1770
|
-
type: 'dir',
|
|
1771
|
-
children,
|
|
1772
|
-
});
|
|
1773
|
-
}
|
|
1774
|
-
else if (item.isFile()) {
|
|
1775
|
-
entries.push({
|
|
1776
|
-
name: item.name,
|
|
1777
|
-
path: itemRelPath,
|
|
1778
|
-
type: 'file',
|
|
1779
|
-
});
|
|
1780
|
-
}
|
|
1781
|
-
}
|
|
1782
|
-
}
|
|
1783
|
-
catch (err) {
|
|
1784
|
-
// Ignore permission errors or inaccessible directories
|
|
1785
|
-
console.error(`Error reading directory ${dirPath}:`, err.message);
|
|
1786
|
-
}
|
|
1787
|
-
// Sort: directories first, then files, alphabetically within each group
|
|
1788
|
-
entries.sort((a, b) => {
|
|
1789
|
-
if (a.type === 'dir' && b.type === 'file')
|
|
1790
|
-
return -1;
|
|
1791
|
-
if (a.type === 'file' && b.type === 'dir')
|
|
1792
|
-
return 1;
|
|
1793
|
-
return a.name.localeCompare(b.name);
|
|
1794
|
-
});
|
|
1795
|
-
return entries;
|
|
1796
|
-
}
|
|
1797
|
-
const tree = buildTree(projectRoot);
|
|
1798
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1799
|
-
res.end(JSON.stringify(tree));
|
|
1800
|
-
return;
|
|
1801
|
-
}
|
|
1802
|
-
// API: Get hash of file tree for change detection (auto-refresh)
|
|
1803
|
-
if (req.method === 'GET' && url.pathname === '/api/files/hash') {
|
|
1804
|
-
// Build a lightweight hash based on directory mtimes
|
|
1805
|
-
// This is faster than building the full tree
|
|
1806
|
-
function getTreeHash(dirPath) {
|
|
1807
|
-
const EXCLUDED_DIRS = new Set([
|
|
1808
|
-
'node_modules', '.git', 'dist', '__pycache__', '.next',
|
|
1809
|
-
'.nuxt', '.turbo', 'coverage', '.nyc_output', '.cache',
|
|
1810
|
-
'.parcel-cache', 'build', '.svelte-kit', 'vendor', '.venv', 'venv', 'env',
|
|
1811
|
-
]);
|
|
1812
|
-
let hash = '';
|
|
1813
|
-
function walk(dir) {
|
|
1814
|
-
try {
|
|
1815
|
-
const stat = fs.statSync(dir);
|
|
1816
|
-
hash += `${dir}:${stat.mtimeMs};`;
|
|
1817
|
-
const items = fs.readdirSync(dir, { withFileTypes: true });
|
|
1818
|
-
for (const item of items) {
|
|
1819
|
-
if (EXCLUDED_DIRS.has(item.name))
|
|
1820
|
-
continue;
|
|
1821
|
-
if (item.isDirectory()) {
|
|
1822
|
-
walk(path.join(dir, item.name));
|
|
1823
|
-
}
|
|
1824
|
-
else if (item.isFile()) {
|
|
1825
|
-
// Include file mtime for change detection
|
|
1826
|
-
const fileStat = fs.statSync(path.join(dir, item.name));
|
|
1827
|
-
hash += `${item.name}:${fileStat.mtimeMs};`;
|
|
1828
|
-
}
|
|
1829
|
-
}
|
|
1830
|
-
}
|
|
1831
|
-
catch {
|
|
1832
|
-
// Ignore errors
|
|
1833
|
-
}
|
|
1834
|
-
}
|
|
1835
|
-
walk(dirPath);
|
|
1836
|
-
// Simple hash: sum of char codes
|
|
1837
|
-
let sum = 0;
|
|
1838
|
-
for (let i = 0; i < hash.length; i++) {
|
|
1839
|
-
sum = ((sum << 5) - sum + hash.charCodeAt(i)) | 0;
|
|
1840
|
-
}
|
|
1841
|
-
return sum.toString(16);
|
|
1842
|
-
}
|
|
1843
|
-
const hash = getTreeHash(projectRoot);
|
|
1844
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1845
|
-
res.end(JSON.stringify({ hash }));
|
|
1846
|
-
return;
|
|
1847
|
-
}
|
|
1848
|
-
// API: Create a new file (Bugfix #131)
|
|
1849
|
-
if (req.method === 'POST' && url.pathname === '/api/files') {
|
|
1850
|
-
const body = await parseJsonBody(req);
|
|
1851
|
-
const filePath = body.path;
|
|
1852
|
-
const content = body.content || '';
|
|
1853
|
-
if (!filePath) {
|
|
1854
|
-
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1855
|
-
res.end(JSON.stringify({ error: 'Missing path' }));
|
|
1856
|
-
return;
|
|
1857
|
-
}
|
|
1858
|
-
// Validate path is within project root (prevent path traversal)
|
|
1859
|
-
const fullPath = validatePathWithinProject(filePath);
|
|
1860
|
-
if (!fullPath) {
|
|
1861
|
-
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
1862
|
-
res.end(JSON.stringify({ error: 'Path must be within project directory' }));
|
|
1863
|
-
return;
|
|
1864
|
-
}
|
|
1865
|
-
// Check if file already exists
|
|
1866
|
-
if (fs.existsSync(fullPath)) {
|
|
1867
|
-
res.writeHead(409, { 'Content-Type': 'application/json' });
|
|
1868
|
-
res.end(JSON.stringify({ error: 'File already exists' }));
|
|
1869
|
-
return;
|
|
1870
|
-
}
|
|
1871
|
-
// Additional security: validate parent directories don't symlink outside project
|
|
1872
|
-
// Find the deepest existing parent and ensure it's within project
|
|
1873
|
-
let checkDir = path.dirname(fullPath);
|
|
1874
|
-
while (checkDir !== projectRoot && !fs.existsSync(checkDir)) {
|
|
1875
|
-
checkDir = path.dirname(checkDir);
|
|
1876
|
-
}
|
|
1877
|
-
if (fs.existsSync(checkDir) && checkDir !== projectRoot) {
|
|
1878
|
-
try {
|
|
1879
|
-
const realParent = fs.realpathSync(checkDir);
|
|
1880
|
-
if (!realParent.startsWith(projectRoot + path.sep) && realParent !== projectRoot) {
|
|
1881
|
-
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
1882
|
-
res.end(JSON.stringify({ error: 'Path must be within project directory' }));
|
|
1883
|
-
return;
|
|
1884
|
-
}
|
|
1885
|
-
}
|
|
1886
|
-
catch {
|
|
1887
|
-
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
1888
|
-
res.end(JSON.stringify({ error: 'Cannot resolve path' }));
|
|
1889
|
-
return;
|
|
1890
|
-
}
|
|
1891
|
-
}
|
|
1892
|
-
try {
|
|
1893
|
-
// Create parent directories if they don't exist
|
|
1894
|
-
const parentDir = path.dirname(fullPath);
|
|
1895
|
-
if (!fs.existsSync(parentDir)) {
|
|
1896
|
-
fs.mkdirSync(parentDir, { recursive: true });
|
|
1897
|
-
}
|
|
1898
|
-
// Write the file
|
|
1899
|
-
fs.writeFileSync(fullPath, content, 'utf-8');
|
|
1900
|
-
res.writeHead(201, { 'Content-Type': 'application/json' });
|
|
1901
|
-
res.end(JSON.stringify({ success: true, path: filePath }));
|
|
1902
|
-
}
|
|
1903
|
-
catch (err) {
|
|
1904
|
-
console.error('Error creating file:', err.message);
|
|
1905
|
-
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1906
|
-
res.end(JSON.stringify({ error: 'Failed to create file: ' + err.message }));
|
|
1907
|
-
}
|
|
1908
|
-
return;
|
|
1909
|
-
}
|
|
1910
|
-
// API: Hot reload check (Spec 0060)
|
|
1911
|
-
// Returns modification times for all dashboard CSS/JS files
|
|
1912
|
-
if (req.method === 'GET' && url.pathname === '/api/hot-reload') {
|
|
1913
|
-
try {
|
|
1914
|
-
const dashboardDir = path.join(__dirname, '../../../templates/dashboard');
|
|
1915
|
-
const cssDir = path.join(dashboardDir, 'css');
|
|
1916
|
-
const jsDir = path.join(dashboardDir, 'js');
|
|
1917
|
-
const mtimes = {};
|
|
1918
|
-
// Collect CSS file modification times
|
|
1919
|
-
if (fs.existsSync(cssDir)) {
|
|
1920
|
-
for (const file of fs.readdirSync(cssDir)) {
|
|
1921
|
-
if (file.endsWith('.css')) {
|
|
1922
|
-
const stat = fs.statSync(path.join(cssDir, file));
|
|
1923
|
-
mtimes[`css/${file}`] = stat.mtimeMs;
|
|
1924
|
-
}
|
|
1925
|
-
}
|
|
1926
|
-
}
|
|
1927
|
-
// Collect JS file modification times
|
|
1928
|
-
if (fs.existsSync(jsDir)) {
|
|
1929
|
-
for (const file of fs.readdirSync(jsDir)) {
|
|
1930
|
-
if (file.endsWith('.js')) {
|
|
1931
|
-
const stat = fs.statSync(path.join(jsDir, file));
|
|
1932
|
-
mtimes[`js/${file}`] = stat.mtimeMs;
|
|
1933
|
-
}
|
|
1934
|
-
}
|
|
1935
|
-
}
|
|
1936
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1937
|
-
res.end(JSON.stringify({ mtimes }));
|
|
1938
|
-
}
|
|
1939
|
-
catch (err) {
|
|
1940
|
-
console.error('Hot reload check error:', err);
|
|
1941
|
-
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1942
|
-
res.end(JSON.stringify({ error: err.message }));
|
|
1943
|
-
}
|
|
1944
|
-
return;
|
|
1945
|
-
}
|
|
1946
|
-
// Serve dashboard CSS files
|
|
1947
|
-
if (req.method === 'GET' && url.pathname.startsWith('/dashboard/css/')) {
|
|
1948
|
-
const filename = url.pathname.replace('/dashboard/css/', '');
|
|
1949
|
-
// Validate filename to prevent path traversal
|
|
1950
|
-
if (!filename || filename.includes('..') || filename.includes('/') || !filename.endsWith('.css')) {
|
|
1951
|
-
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
1952
|
-
res.end('Invalid filename');
|
|
1953
|
-
return;
|
|
1954
|
-
}
|
|
1955
|
-
const cssPath = path.join(__dirname, '../../../templates/dashboard/css', filename);
|
|
1956
|
-
if (fs.existsSync(cssPath)) {
|
|
1957
|
-
const content = fs.readFileSync(cssPath, 'utf-8');
|
|
1958
|
-
res.writeHead(200, { 'Content-Type': 'text/css; charset=utf-8' });
|
|
1959
|
-
res.end(content);
|
|
1960
|
-
return;
|
|
1961
|
-
}
|
|
1962
|
-
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
1963
|
-
res.end('CSS file not found');
|
|
1964
|
-
return;
|
|
1965
|
-
}
|
|
1966
|
-
// Serve dashboard JS files
|
|
1967
|
-
if (req.method === 'GET' && url.pathname.startsWith('/dashboard/js/')) {
|
|
1968
|
-
const filename = url.pathname.replace('/dashboard/js/', '');
|
|
1969
|
-
// Validate filename to prevent path traversal
|
|
1970
|
-
if (!filename || filename.includes('..') || filename.includes('/') || !filename.endsWith('.js')) {
|
|
1971
|
-
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
1972
|
-
res.end('Invalid filename');
|
|
1973
|
-
return;
|
|
1974
|
-
}
|
|
1975
|
-
const jsPath = path.join(__dirname, '../../../templates/dashboard/js', filename);
|
|
1976
|
-
if (fs.existsSync(jsPath)) {
|
|
1977
|
-
const content = fs.readFileSync(jsPath, 'utf-8');
|
|
1978
|
-
res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8' });
|
|
1979
|
-
res.end(content);
|
|
1980
|
-
return;
|
|
1981
|
-
}
|
|
1982
|
-
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
1983
|
-
res.end('JS file not found');
|
|
1984
|
-
return;
|
|
1985
|
-
}
|
|
1986
|
-
// Terminal proxy route (Spec 0062 - Secure Remote Access)
|
|
1987
|
-
// Routes /terminal/:id to the appropriate terminal instance
|
|
1988
|
-
const terminalMatch = url.pathname.match(/^\/terminal\/([^/]+)(\/.*)?$/);
|
|
1989
|
-
if (terminalMatch) {
|
|
1990
|
-
const terminalId = terminalMatch[1];
|
|
1991
|
-
const terminalPort = getPortForTerminal(terminalId, loadState());
|
|
1992
|
-
if (!terminalPort) {
|
|
1993
|
-
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1994
|
-
res.end(JSON.stringify({ error: `Terminal not found: ${terminalId}` }));
|
|
1995
|
-
return;
|
|
1996
|
-
}
|
|
1997
|
-
// Rewrite the URL to strip the /terminal/:id prefix
|
|
1998
|
-
req.url = terminalMatch[2] || '/';
|
|
1999
|
-
terminalProxy.web(req, res, { target: `http://localhost:${terminalPort}` });
|
|
2000
|
-
return;
|
|
2001
|
-
}
|
|
2002
|
-
// Annotation proxy route (Spec 0062 - Secure Remote Access)
|
|
2003
|
-
// Routes /annotation/:id to the appropriate open-server instance
|
|
2004
|
-
const annotationMatch = url.pathname.match(/^\/annotation\/([^/]+)(\/.*)?$/);
|
|
2005
|
-
if (annotationMatch) {
|
|
2006
|
-
const annotationId = annotationMatch[1];
|
|
2007
|
-
const annotations = getAnnotations();
|
|
2008
|
-
const annotation = annotations.find((a) => a.id === annotationId);
|
|
2009
|
-
if (!annotation) {
|
|
2010
|
-
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
2011
|
-
res.end(JSON.stringify({ error: `Annotation not found: ${annotationId}` }));
|
|
2012
|
-
return;
|
|
2013
|
-
}
|
|
2014
|
-
// Rewrite the URL to strip the /annotation/:id prefix, preserving query string
|
|
2015
|
-
const remainingPath = annotationMatch[2] || '/';
|
|
2016
|
-
req.url = url.search ? `${remainingPath}${url.search}` : remainingPath;
|
|
2017
|
-
terminalProxy.web(req, res, { target: `http://localhost:${annotation.port}` });
|
|
2018
|
-
return;
|
|
2019
|
-
}
|
|
2020
|
-
// Serve dashboard (Spec 0085: React or legacy based on config)
|
|
2021
|
-
if (useReactDashboard && req.method === 'GET') {
|
|
2022
|
-
// Serve React dashboard static files
|
|
2023
|
-
const filePath = url.pathname === '/' || url.pathname === '/index.html'
|
|
2024
|
-
? path.join(reactDashboardPath, 'index.html')
|
|
2025
|
-
: path.join(reactDashboardPath, url.pathname);
|
|
2026
|
-
// Security: Prevent path traversal
|
|
2027
|
-
const resolved = path.resolve(filePath);
|
|
2028
|
-
if (!resolved.startsWith(reactDashboardPath)) {
|
|
2029
|
-
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
|
2030
|
-
res.end('Forbidden');
|
|
2031
|
-
return;
|
|
2032
|
-
}
|
|
2033
|
-
if (fs.existsSync(resolved) && fs.statSync(resolved).isFile()) {
|
|
2034
|
-
const ext = path.extname(resolved);
|
|
2035
|
-
const mimeTypes = {
|
|
2036
|
-
'.html': 'text/html; charset=utf-8',
|
|
2037
|
-
'.js': 'application/javascript',
|
|
2038
|
-
'.css': 'text/css',
|
|
2039
|
-
'.json': 'application/json',
|
|
2040
|
-
'.svg': 'image/svg+xml',
|
|
2041
|
-
'.png': 'image/png',
|
|
2042
|
-
'.ico': 'image/x-icon',
|
|
2043
|
-
'.map': 'application/json',
|
|
2044
|
-
};
|
|
2045
|
-
const contentType = mimeTypes[ext] ?? 'application/octet-stream';
|
|
2046
|
-
// Cache static assets (hashed filenames) but not index.html
|
|
2047
|
-
if (ext !== '.html') {
|
|
2048
|
-
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
|
2049
|
-
}
|
|
2050
|
-
res.writeHead(200, { 'Content-Type': contentType });
|
|
2051
|
-
fs.createReadStream(resolved).pipe(res);
|
|
2052
|
-
return;
|
|
2053
|
-
}
|
|
2054
|
-
// SPA fallback: serve index.html for client-side routing
|
|
2055
|
-
if (!url.pathname.startsWith('/api/') && !url.pathname.startsWith('/ws/') && !url.pathname.startsWith('/terminal/') && !url.pathname.startsWith('/annotation/')) {
|
|
2056
|
-
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
2057
|
-
fs.createReadStream(path.join(reactDashboardPath, 'index.html')).pipe(res);
|
|
2058
|
-
return;
|
|
2059
|
-
}
|
|
2060
|
-
}
|
|
2061
|
-
if (!useReactDashboard && req.method === 'GET' && (url.pathname === '/' || url.pathname === '/index.html')) {
|
|
2062
|
-
// Legacy vanilla JS dashboard
|
|
2063
|
-
try {
|
|
2064
|
-
let template = fs.readFileSync(templatePath, 'utf-8');
|
|
2065
|
-
const state = loadStateWithCleanup();
|
|
2066
|
-
// Inject project name into template (HTML-escaped for security)
|
|
2067
|
-
const projectName = escapeHtml(getProjectName(projectRoot));
|
|
2068
|
-
template = template.replace(/\{\{PROJECT_NAME\}\}/g, projectName);
|
|
2069
|
-
// Inject state into template
|
|
2070
|
-
const stateJson = JSON.stringify(state);
|
|
2071
|
-
template = template.replace('// STATE_INJECTION_POINT', `window.INITIAL_STATE = ${stateJson};`);
|
|
2072
|
-
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
2073
|
-
res.end(template);
|
|
2074
|
-
}
|
|
2075
|
-
catch (err) {
|
|
2076
|
-
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
2077
|
-
res.end('Error loading dashboard: ' + err.message);
|
|
2078
|
-
}
|
|
2079
|
-
return;
|
|
2080
|
-
}
|
|
2081
|
-
// 404 for everything else
|
|
2082
|
-
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
2083
|
-
res.end('Not found');
|
|
2084
|
-
}
|
|
2085
|
-
catch (err) {
|
|
2086
|
-
console.error('Request error:', err);
|
|
2087
|
-
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
2088
|
-
res.end('Internal server error: ' + err.message);
|
|
2089
|
-
}
|
|
2090
|
-
});
|
|
2091
|
-
// Spec 0085: Attach node-pty WebSocket handler for /ws/terminal/:id routes
|
|
2092
|
-
if (terminalManager) {
|
|
2093
|
-
terminalManager.attachWebSocket(server);
|
|
2094
|
-
}
|
|
2095
|
-
// WebSocket upgrade handler for terminal proxy (Spec 0062)
|
|
2096
|
-
// WebSocket for bidirectional terminal communication
|
|
2097
|
-
server.on('upgrade', (req, socket, head) => {
|
|
2098
|
-
// Security check for non-auth mode
|
|
2099
|
-
const host = req.headers.host;
|
|
2100
|
-
if (!insecureRemoteMode && !process.env.CODEV_WEB_KEY && host && !host.startsWith('localhost') && !host.startsWith('127.0.0.1')) {
|
|
2101
|
-
socket.destroy();
|
|
2102
|
-
return;
|
|
2103
|
-
}
|
|
2104
|
-
// CRITICAL: When CODEV_WEB_KEY is set, ALL WebSocket upgrades require auth
|
|
2105
|
-
// NO localhost bypass - tunnel daemons run locally, so remoteAddress is unreliable
|
|
2106
|
-
const webKey = process.env.CODEV_WEB_KEY;
|
|
2107
|
-
if (webKey && !insecureRemoteMode) {
|
|
2108
|
-
// Check Sec-WebSocket-Protocol for auth token
|
|
2109
|
-
// Format: "auth-<token>, tty" or just "tty"
|
|
2110
|
-
const protocols = req.headers['sec-websocket-protocol']?.split(',').map((p) => p.trim()) || [];
|
|
2111
|
-
const authProtocol = protocols.find((p) => p.startsWith('auth-'));
|
|
2112
|
-
const token = authProtocol?.substring(5); // Remove 'auth-' prefix
|
|
2113
|
-
if (!isValidToken(token, webKey)) {
|
|
2114
|
-
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
|
2115
|
-
socket.destroy();
|
|
2116
|
-
return;
|
|
2117
|
-
}
|
|
2118
|
-
// Remove auth protocol from the list before forwarding
|
|
2119
|
-
const cleanProtocols = protocols.filter((p) => !p.startsWith('auth-'));
|
|
2120
|
-
req.headers['sec-websocket-protocol'] = cleanProtocols.join(', ') || 'tty';
|
|
2121
|
-
}
|
|
2122
|
-
const reqUrl = new URL(req.url || '/', `http://localhost:${port}`);
|
|
2123
|
-
const terminalMatch = reqUrl.pathname.match(/^\/terminal\/([^/]+)(\/.*)?$/);
|
|
2124
|
-
if (terminalMatch) {
|
|
2125
|
-
const terminalId = terminalMatch[1];
|
|
2126
|
-
const terminalPort = getPortForTerminal(terminalId, loadState());
|
|
2127
|
-
if (terminalPort) {
|
|
2128
|
-
// Rewrite URL to strip /terminal/:id prefix
|
|
2129
|
-
req.url = terminalMatch[2] || '/';
|
|
2130
|
-
terminalProxy.ws(req, socket, head, { target: `http://localhost:${terminalPort}` });
|
|
2131
|
-
}
|
|
2132
|
-
else {
|
|
2133
|
-
// Terminal not found - close the socket
|
|
2134
|
-
socket.destroy();
|
|
2135
|
-
}
|
|
2136
|
-
}
|
|
2137
|
-
// Non-terminal WebSocket requests are ignored (socket will time out)
|
|
2138
|
-
});
|
|
2139
|
-
// Handle WebSocket proxy errors separately
|
|
2140
|
-
terminalProxy.on('error', (err, req, socket) => {
|
|
2141
|
-
console.error('WebSocket proxy error:', err.message);
|
|
2142
|
-
if (socket && 'destroy' in socket && typeof socket.destroy === 'function' && !socket.destroyed) {
|
|
2143
|
-
socket.destroy();
|
|
2144
|
-
}
|
|
2145
|
-
});
|
|
2146
|
-
// Handle server errors (e.g., port already in use)
|
|
2147
|
-
server.on('error', (err) => {
|
|
2148
|
-
if (err.code === 'EADDRINUSE') {
|
|
2149
|
-
console.error(`Error: Port ${port} is already in use.`);
|
|
2150
|
-
console.error(`Run 'lsof -i :${port}' to find the process, or use 'af ports cleanup' to clean up orphans.`);
|
|
2151
|
-
process.exit(1);
|
|
2152
|
-
}
|
|
2153
|
-
else {
|
|
2154
|
-
console.error(`Server error: ${err.message}`);
|
|
2155
|
-
process.exit(1);
|
|
2156
|
-
}
|
|
2157
|
-
});
|
|
2158
|
-
if (bindHost) {
|
|
2159
|
-
server.listen(port, bindHost, () => {
|
|
2160
|
-
console.log(`Dashboard: http://${bindHost}:${port}`);
|
|
2161
|
-
});
|
|
2162
|
-
}
|
|
2163
|
-
else {
|
|
2164
|
-
server.listen(port, () => {
|
|
2165
|
-
console.log(`Dashboard: http://localhost:${port}`);
|
|
2166
|
-
});
|
|
2167
|
-
}
|
|
2168
|
-
// Spec 0085: Graceful shutdown for node-pty terminal manager
|
|
2169
|
-
process.on('SIGTERM', () => {
|
|
2170
|
-
if (terminalManager) {
|
|
2171
|
-
terminalManager.shutdown();
|
|
2172
|
-
}
|
|
2173
|
-
process.exit(0);
|
|
2174
|
-
});
|
|
2175
|
-
process.on('SIGINT', () => {
|
|
2176
|
-
if (terminalManager) {
|
|
2177
|
-
terminalManager.shutdown();
|
|
2178
|
-
}
|
|
2179
|
-
process.exit(0);
|
|
2180
|
-
});
|
|
2181
|
-
//# sourceMappingURL=dashboard-server.js.map
|