deckide 3.4.0 → 3.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/deckide.js +125 -47
- package/dist/config.js +0 -2
- package/dist/routes/terminals.js +44 -84
- package/dist/server.js +28 -107
- package/dist/utils/database.js +0 -24
- package/package.json +1 -1
- package/dist/pty-client.js +0 -177
- package/dist/pty-daemon.js +0 -246
package/bin/deckide.js
CHANGED
|
@@ -32,8 +32,7 @@ function getPort() {
|
|
|
32
32
|
return loadSettings().port || 8787;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
function
|
|
36
|
-
const port = getPort();
|
|
35
|
+
function isServerRunningOnPort(port) {
|
|
37
36
|
try {
|
|
38
37
|
execSync(`curl -sf -o /dev/null http://localhost:${port}/health`, {
|
|
39
38
|
timeout: 2000, stdio: 'ignore',
|
|
@@ -44,6 +43,58 @@ function isServerRunning() {
|
|
|
44
43
|
}
|
|
45
44
|
}
|
|
46
45
|
|
|
46
|
+
function isServerRunning() {
|
|
47
|
+
return isServerRunningOnPort(getPort());
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Get PID from pid file, or null if stale/missing */
|
|
51
|
+
function getRunningPid() {
|
|
52
|
+
if (!fs.existsSync(pidFile)) return null;
|
|
53
|
+
try {
|
|
54
|
+
const pid = parseInt(fs.readFileSync(pidFile, 'utf-8').trim(), 10);
|
|
55
|
+
process.kill(pid, 0); // throws if not running
|
|
56
|
+
return pid;
|
|
57
|
+
} catch {
|
|
58
|
+
try { fs.unlinkSync(pidFile); } catch {}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Stop server: try HTTP shutdown on common ports, then fall back to killing the PID */
|
|
64
|
+
function stopServer() {
|
|
65
|
+
const port = getPort();
|
|
66
|
+
// Try HTTP shutdown on configured port
|
|
67
|
+
if (isServerRunningOnPort(port)) {
|
|
68
|
+
try {
|
|
69
|
+
execSync(`curl -sf -X POST http://localhost:${port}/api/shutdown -H "Content-Type: application/json" -d '{}'`, {
|
|
70
|
+
timeout: 5000, stdio: 'ignore',
|
|
71
|
+
});
|
|
72
|
+
try { fs.unlinkSync(pidFile); } catch {}
|
|
73
|
+
return true;
|
|
74
|
+
} catch {}
|
|
75
|
+
}
|
|
76
|
+
// Try default port 8787 if different
|
|
77
|
+
if (port !== 8787 && isServerRunningOnPort(8787)) {
|
|
78
|
+
try {
|
|
79
|
+
execSync(`curl -sf -X POST http://localhost:8787/api/shutdown -H "Content-Type: application/json" -d '{}'`, {
|
|
80
|
+
timeout: 5000, stdio: 'ignore',
|
|
81
|
+
});
|
|
82
|
+
try { fs.unlinkSync(pidFile); } catch {}
|
|
83
|
+
return true;
|
|
84
|
+
} catch {}
|
|
85
|
+
}
|
|
86
|
+
// Fall back to killing by PID
|
|
87
|
+
const pid = getRunningPid();
|
|
88
|
+
if (pid) {
|
|
89
|
+
try {
|
|
90
|
+
process.kill(pid, 'SIGTERM');
|
|
91
|
+
try { fs.unlinkSync(pidFile); } catch {}
|
|
92
|
+
return true;
|
|
93
|
+
} catch {}
|
|
94
|
+
}
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
|
|
47
98
|
// ─── CLI ────────────────────────────────────────────────────────
|
|
48
99
|
|
|
49
100
|
const args = process.argv.slice(2);
|
|
@@ -68,23 +119,23 @@ Usage:
|
|
|
68
119
|
deckide status Show server status
|
|
69
120
|
deckide logs Show server logs
|
|
70
121
|
|
|
71
|
-
deckide
|
|
72
|
-
deckide
|
|
73
|
-
deckide config get <key> Get a config value
|
|
74
|
-
deckide config reset Reset all settings
|
|
122
|
+
deckide port Show current port
|
|
123
|
+
deckide port <number> Change port (auto-restarts)
|
|
75
124
|
|
|
76
125
|
deckide auth on [user] [pass] Enable basic auth
|
|
77
126
|
deckide auth off Disable basic auth
|
|
78
127
|
deckide auth status Show auth status
|
|
79
128
|
|
|
129
|
+
deckide config Show all settings
|
|
130
|
+
deckide config set <key> <val> Set a config value
|
|
131
|
+
deckide config get <key> Get a config value
|
|
132
|
+
deckide config reset Reset all settings
|
|
133
|
+
|
|
80
134
|
Options (for start):
|
|
81
135
|
-p, --port <port> Port (default: 8787)
|
|
82
136
|
--host <host> Host (default: 0.0.0.0)
|
|
83
137
|
--no-open Don't open browser
|
|
84
138
|
--fg Run in foreground
|
|
85
|
-
|
|
86
|
-
Config keys:
|
|
87
|
-
port, host, cors, maxFileSize, trustProxy
|
|
88
139
|
`);
|
|
89
140
|
process.exit(0);
|
|
90
141
|
}
|
|
@@ -131,6 +182,7 @@ if (command === 'config') {
|
|
|
131
182
|
settings[key] = value;
|
|
132
183
|
saveSettings(settings);
|
|
133
184
|
console.log(`${key} = ${key === 'basicAuthPassword' ? '********' : value}`);
|
|
185
|
+
if (isServerRunning() || getRunningPid()) console.log('Run "deckide restart" to apply.');
|
|
134
186
|
process.exit(0);
|
|
135
187
|
}
|
|
136
188
|
|
|
@@ -144,6 +196,49 @@ if (command === 'config') {
|
|
|
144
196
|
process.exit(1);
|
|
145
197
|
}
|
|
146
198
|
|
|
199
|
+
// ── deckide port ──
|
|
200
|
+
if (command === 'port') {
|
|
201
|
+
const newPort = args[1];
|
|
202
|
+
const settings = loadSettings();
|
|
203
|
+
const currentPort = settings.port || 8787;
|
|
204
|
+
|
|
205
|
+
// Show current port
|
|
206
|
+
if (!newPort) {
|
|
207
|
+
console.log(`port: ${currentPort}`);
|
|
208
|
+
process.exit(0);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const parsed = parseInt(newPort, 10);
|
|
212
|
+
if (!Number.isFinite(parsed) || parsed < 1 || parsed > 65535) {
|
|
213
|
+
console.error('Error: port must be 1-65535');
|
|
214
|
+
process.exit(1);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (parsed === currentPort) {
|
|
218
|
+
console.log(`Already using port ${parsed}`);
|
|
219
|
+
process.exit(0);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Save new port
|
|
223
|
+
settings.port = parsed;
|
|
224
|
+
saveSettings(settings);
|
|
225
|
+
console.log(`port: ${currentPort} → ${parsed}`);
|
|
226
|
+
|
|
227
|
+
// Auto-restart if server is running
|
|
228
|
+
const wasRunning = isServerRunningOnPort(currentPort) || getRunningPid();
|
|
229
|
+
if (wasRunning) {
|
|
230
|
+
stopServer();
|
|
231
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
232
|
+
// Re-exec as start (background, no open)
|
|
233
|
+
const child = spawn(process.execPath, [fileURLToPath(import.meta.url), 'start', '--no-open'], {
|
|
234
|
+
stdio: 'inherit',
|
|
235
|
+
});
|
|
236
|
+
child.on('exit', (code) => process.exit(code));
|
|
237
|
+
} else {
|
|
238
|
+
process.exit(0);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
147
242
|
// ── deckide auth ──
|
|
148
243
|
if (command === 'auth') {
|
|
149
244
|
const sub = args[1];
|
|
@@ -211,31 +306,16 @@ if (command === 'status') {
|
|
|
211
306
|
console.log(` port: ${port}`);
|
|
212
307
|
console.log(` auth: ${settings.basicAuthEnabled ? 'enabled' : 'disabled'}`);
|
|
213
308
|
|
|
309
|
+
const pid = getRunningPid();
|
|
214
310
|
if (isServerRunning()) {
|
|
215
311
|
console.log(` server: \x1b[32mrunning\x1b[0m → http://localhost:${port}`);
|
|
312
|
+
if (pid) console.log(` pid: ${pid}`);
|
|
313
|
+
} else if (pid) {
|
|
314
|
+
console.log(` server: \x1b[33mprocess alive (pid ${pid}) but not responding on port ${port}\x1b[0m`);
|
|
216
315
|
} else {
|
|
217
316
|
console.log(' server: \x1b[31mstopped\x1b[0m');
|
|
218
317
|
}
|
|
219
318
|
|
|
220
|
-
// Check PID file
|
|
221
|
-
if (fs.existsSync(pidFile)) {
|
|
222
|
-
try {
|
|
223
|
-
const pid = parseInt(fs.readFileSync(pidFile, 'utf-8').trim(), 10);
|
|
224
|
-
process.kill(pid, 0); // Check if process exists
|
|
225
|
-
console.log(` pid: ${pid}`);
|
|
226
|
-
} catch {
|
|
227
|
-
// stale pid file
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
const daemonInfoPath = path.join(dataDir, 'pty-daemon.json');
|
|
232
|
-
if (fs.existsSync(daemonInfoPath)) {
|
|
233
|
-
try {
|
|
234
|
-
const info = JSON.parse(fs.readFileSync(daemonInfoPath, 'utf-8'));
|
|
235
|
-
console.log(` pty: pid ${info.pid}, port ${info.port}`);
|
|
236
|
-
} catch {}
|
|
237
|
-
}
|
|
238
|
-
|
|
239
319
|
process.exit(0);
|
|
240
320
|
}
|
|
241
321
|
|
|
@@ -260,19 +340,14 @@ if (command === 'logs') {
|
|
|
260
340
|
|
|
261
341
|
// ── deckide stop ──
|
|
262
342
|
if (command === 'stop') {
|
|
263
|
-
|
|
343
|
+
const pid = getRunningPid();
|
|
344
|
+
if (!isServerRunning() && !pid) {
|
|
264
345
|
console.log('Server is not running.');
|
|
265
346
|
process.exit(0);
|
|
266
347
|
}
|
|
267
|
-
|
|
268
|
-
try {
|
|
269
|
-
execSync(`curl -sf -X POST http://localhost:${port}/api/shutdown -H "Content-Type: application/json" -d '{"terminateDaemon":true}'`, {
|
|
270
|
-
timeout: 5000, stdio: 'ignore',
|
|
271
|
-
});
|
|
272
|
-
// Clean up pid file
|
|
273
|
-
try { fs.unlinkSync(pidFile); } catch {}
|
|
348
|
+
if (stopServer()) {
|
|
274
349
|
console.log('Server stopped.');
|
|
275
|
-
}
|
|
350
|
+
} else {
|
|
276
351
|
console.error('Failed to stop server.');
|
|
277
352
|
}
|
|
278
353
|
process.exit(0);
|
|
@@ -280,15 +355,10 @@ if (command === 'stop') {
|
|
|
280
355
|
|
|
281
356
|
// ── deckide restart ──
|
|
282
357
|
if (command === 'restart') {
|
|
283
|
-
if (isServerRunning()) {
|
|
284
|
-
|
|
285
|
-
try {
|
|
286
|
-
execSync(`curl -sf -X POST http://localhost:${port}/api/shutdown -H "Content-Type: application/json" -d '{"terminateDaemon":true}'`, {
|
|
287
|
-
timeout: 5000, stdio: 'ignore',
|
|
288
|
-
});
|
|
289
|
-
try { fs.unlinkSync(pidFile); } catch {}
|
|
358
|
+
if (isServerRunning() || getRunningPid()) {
|
|
359
|
+
if (stopServer()) {
|
|
290
360
|
console.log('Server stopped.');
|
|
291
|
-
}
|
|
361
|
+
}
|
|
292
362
|
// Wait a moment for port to free
|
|
293
363
|
await new Promise(r => setTimeout(r, 1000));
|
|
294
364
|
}
|
|
@@ -332,12 +402,20 @@ const settings = loadSettings();
|
|
|
332
402
|
const port = startOptions.port || settings.port || 8787;
|
|
333
403
|
const host = startOptions.host || settings.host || '0.0.0.0';
|
|
334
404
|
|
|
335
|
-
// Check if already running
|
|
336
|
-
if (
|
|
405
|
+
// Check if already running on the target port
|
|
406
|
+
if (isServerRunningOnPort(port)) {
|
|
337
407
|
console.log(`Server is already running on http://localhost:${port}`);
|
|
338
408
|
process.exit(0);
|
|
339
409
|
}
|
|
340
410
|
|
|
411
|
+
// Kill old server if running on a different port
|
|
412
|
+
const oldPid = getRunningPid();
|
|
413
|
+
if (oldPid) {
|
|
414
|
+
console.log('Stopping old server...');
|
|
415
|
+
stopServer();
|
|
416
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
417
|
+
}
|
|
418
|
+
|
|
341
419
|
// ── Background mode (default) ──
|
|
342
420
|
if (!startOptions.fg) {
|
|
343
421
|
fs.mkdirSync(dataDir, { recursive: true });
|
package/dist/config.js
CHANGED
|
@@ -68,5 +68,3 @@ if (!Number.isFinite(MAX_FILE_SIZE) || MAX_FILE_SIZE < 1024) {
|
|
|
68
68
|
}
|
|
69
69
|
// Ensure data directory exists
|
|
70
70
|
fsSync.mkdirSync(path.dirname(dbPath), { recursive: true });
|
|
71
|
-
// PTY daemon info file - written by daemon on startup so server can find its port
|
|
72
|
-
export const daemonInfoPath = path.join(path.dirname(dbPath), 'pty-daemon.json');
|
package/dist/routes/terminals.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import crypto from 'node:crypto';
|
|
2
2
|
import { Hono } from 'hono';
|
|
3
|
+
import { spawn } from 'node-pty';
|
|
3
4
|
import { TERMINAL_BUFFER_LIMIT } from '../config.js';
|
|
4
5
|
import { createHttpError, handleError, readJson } from '../utils/error.js';
|
|
5
6
|
import { getDefaultShell } from '../utils/shell.js';
|
|
6
7
|
import { saveTerminal, deleteTerminal as deleteTerminalFromDb } from '../utils/database.js';
|
|
7
8
|
// Track terminal index per deck for unique naming
|
|
8
9
|
const deckTerminalCounters = new Map();
|
|
9
|
-
export function createTerminalRouter(db, decks, terminals
|
|
10
|
+
export function createTerminalRouter(db, decks, terminals) {
|
|
10
11
|
const router = new Hono();
|
|
11
12
|
function appendToTerminalBuffer(session, data) {
|
|
12
13
|
const newBuffer = session.buffer + data;
|
|
@@ -21,13 +22,7 @@ export function createTerminalRouter(db, decks, terminals, ptyClient) {
|
|
|
21
22
|
deckTerminalCounters.set(deckId, next);
|
|
22
23
|
return next;
|
|
23
24
|
}
|
|
24
|
-
|
|
25
|
-
ptyClient.on('data', (id, data) => {
|
|
26
|
-
const session = terminals.get(id);
|
|
27
|
-
if (!session)
|
|
28
|
-
return;
|
|
29
|
-
appendToTerminalBuffer(session, data);
|
|
30
|
-
session.lastActive = Date.now();
|
|
25
|
+
function broadcastToSockets(session, data) {
|
|
31
26
|
const deadSockets = new Set();
|
|
32
27
|
session.sockets.forEach((socket) => {
|
|
33
28
|
try {
|
|
@@ -43,9 +38,8 @@ export function createTerminalRouter(db, decks, terminals, ptyClient) {
|
|
|
43
38
|
}
|
|
44
39
|
});
|
|
45
40
|
deadSockets.forEach((s) => session.sockets.delete(s));
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
ptyClient.on('exit', (id) => {
|
|
41
|
+
}
|
|
42
|
+
function handleTerminalExit(id) {
|
|
49
43
|
const session = terminals.get(id);
|
|
50
44
|
if (!session)
|
|
51
45
|
return;
|
|
@@ -59,9 +53,9 @@ export function createTerminalRouter(db, decks, terminals, ptyClient) {
|
|
|
59
53
|
catch { /* ignore */ }
|
|
60
54
|
});
|
|
61
55
|
session.sockets.clear();
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
const id =
|
|
56
|
+
}
|
|
57
|
+
function createTerminalSession(deck, title, command) {
|
|
58
|
+
const id = crypto.randomUUID();
|
|
65
59
|
// Resolve shell and arguments
|
|
66
60
|
let shell;
|
|
67
61
|
let shellArgs = [];
|
|
@@ -101,9 +95,17 @@ export function createTerminalRouter(db, decks, terminals, ptyClient) {
|
|
|
101
95
|
}
|
|
102
96
|
env.LC_ALL = env.LC_ALL || 'en_US.UTF-8';
|
|
103
97
|
env.LC_CTYPE = env.LC_CTYPE || 'en_US.UTF-8';
|
|
104
|
-
//
|
|
105
|
-
|
|
106
|
-
|
|
98
|
+
// Spawn PTY directly in this process
|
|
99
|
+
const isWindows = process.platform === 'win32';
|
|
100
|
+
const term = spawn(shell, shellArgs, {
|
|
101
|
+
cwd: deck.root,
|
|
102
|
+
cols: 120,
|
|
103
|
+
rows: 32,
|
|
104
|
+
env,
|
|
105
|
+
encoding: 'utf8',
|
|
106
|
+
...(isWindows ? { useConpty: true } : {}),
|
|
107
|
+
});
|
|
108
|
+
console.log(`[TERMINAL] Created terminal ${id}: shell=${shell}, cwd=${deck.root}, pid=${term.pid}`);
|
|
107
109
|
const resolvedTitle = title || `Terminal ${getNextTerminalIndex(deck.id)}`;
|
|
108
110
|
const createdAt = new Date().toISOString();
|
|
109
111
|
const session = {
|
|
@@ -113,18 +115,32 @@ export function createTerminalRouter(db, decks, terminals, ptyClient) {
|
|
|
113
115
|
command: command || null,
|
|
114
116
|
createdAt,
|
|
115
117
|
sockets: new Set(),
|
|
116
|
-
buffer:
|
|
118
|
+
buffer: '',
|
|
117
119
|
lastActive: Date.now(),
|
|
118
|
-
write: (data) =>
|
|
119
|
-
|
|
120
|
-
|
|
120
|
+
write: (data) => { try {
|
|
121
|
+
term.write(data);
|
|
122
|
+
}
|
|
123
|
+
catch { /* terminal may be dying */ } },
|
|
124
|
+
resize: (cols, rows) => { try {
|
|
125
|
+
term.resize(cols, rows);
|
|
126
|
+
}
|
|
127
|
+
catch { /* terminal may be dying */ } },
|
|
128
|
+
kill: () => { try {
|
|
129
|
+
term.kill();
|
|
130
|
+
}
|
|
131
|
+
catch { /* already dead */ } },
|
|
121
132
|
};
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
133
|
+
// Wire up PTY output → buffer + WebSocket broadcast
|
|
134
|
+
term.onData((data) => {
|
|
135
|
+
appendToTerminalBuffer(session, data);
|
|
136
|
+
session.lastActive = Date.now();
|
|
137
|
+
broadcastToSockets(session, data);
|
|
138
|
+
});
|
|
139
|
+
term.onExit(() => {
|
|
140
|
+
handleTerminalExit(id);
|
|
141
|
+
});
|
|
142
|
+
saveTerminal(db, id, deck.id, resolvedTitle, command || null, createdAt);
|
|
125
143
|
terminals.set(id, session);
|
|
126
|
-
// Subscribe to live output from daemon (delta since initialBuffer)
|
|
127
|
-
ptyClient.attach(id, options?.initialBuffer?.length ?? 0);
|
|
128
144
|
return session;
|
|
129
145
|
}
|
|
130
146
|
router.get('/', (c) => {
|
|
@@ -150,7 +166,7 @@ export function createTerminalRouter(db, decks, terminals, ptyClient) {
|
|
|
150
166
|
const deck = decks.get(deckId);
|
|
151
167
|
if (!deck)
|
|
152
168
|
throw createHttpError('Deck not found', 404);
|
|
153
|
-
const session =
|
|
169
|
+
const session = createTerminalSession(deck, body?.title, body?.command);
|
|
154
170
|
return c.json({ id: session.id, title: session.title }, 201);
|
|
155
171
|
}
|
|
156
172
|
catch (error) {
|
|
@@ -172,7 +188,6 @@ export function createTerminalRouter(db, decks, terminals, ptyClient) {
|
|
|
172
188
|
catch { /* ignore */ }
|
|
173
189
|
});
|
|
174
190
|
session.sockets.clear();
|
|
175
|
-
// Kill PTY in daemon
|
|
176
191
|
session.kill();
|
|
177
192
|
return c.body(null, 204);
|
|
178
193
|
}
|
|
@@ -180,60 +195,5 @@ export function createTerminalRouter(db, decks, terminals, ptyClient) {
|
|
|
180
195
|
return handleError(c, error);
|
|
181
196
|
}
|
|
182
197
|
});
|
|
183
|
-
|
|
184
|
-
* Restore terminals after a server restart.
|
|
185
|
-
* The daemon is the source of truth: only terminals alive in the daemon are restored.
|
|
186
|
-
* DB provides metadata (title, deckId, buffer) for each daemon terminal.
|
|
187
|
-
* Stale DB entries (no matching daemon terminal) are cleaned up.
|
|
188
|
-
*/
|
|
189
|
-
async function restoreTerminals(persistedTerminals, daemonTerminals) {
|
|
190
|
-
const persistedById = new Map(persistedTerminals.map((t) => [t.id, t]));
|
|
191
|
-
const daemonIds = new Set(daemonTerminals.map((t) => t.id));
|
|
192
|
-
// Iterate daemon terminals — these are the only "real" ones
|
|
193
|
-
for (const daemonInfo of daemonTerminals) {
|
|
194
|
-
const persisted = persistedById.get(daemonInfo.id);
|
|
195
|
-
const deck = persisted ? decks.get(persisted.deckId) : undefined;
|
|
196
|
-
if (!persisted || !deck) {
|
|
197
|
-
// No metadata to attach this terminal to a deck — kill it
|
|
198
|
-
console.log(`[TERMINAL] Killing daemon terminal ${daemonInfo.id} (no deck metadata)`);
|
|
199
|
-
try {
|
|
200
|
-
await ptyClient.kill(daemonInfo.id);
|
|
201
|
-
}
|
|
202
|
-
catch { /* ignore */ }
|
|
203
|
-
if (persisted)
|
|
204
|
-
deleteTerminalFromDb(db, daemonInfo.id);
|
|
205
|
-
continue;
|
|
206
|
-
}
|
|
207
|
-
try {
|
|
208
|
-
console.log(`[TERMINAL] Re-attaching to live terminal ${persisted.id} (${persisted.title})`);
|
|
209
|
-
const session = {
|
|
210
|
-
id: persisted.id,
|
|
211
|
-
deckId: persisted.deckId,
|
|
212
|
-
title: persisted.title,
|
|
213
|
-
command: persisted.command,
|
|
214
|
-
createdAt: persisted.createdAt,
|
|
215
|
-
sockets: new Set(),
|
|
216
|
-
buffer: '',
|
|
217
|
-
lastActive: Date.now(),
|
|
218
|
-
write: (data) => ptyClient.input(persisted.id, data),
|
|
219
|
-
resize: (cols, rows) => ptyClient.resize(persisted.id, cols, rows),
|
|
220
|
-
kill: () => ptyClient.kill(persisted.id),
|
|
221
|
-
};
|
|
222
|
-
terminals.set(persisted.id, session);
|
|
223
|
-
// Attach with offset 0 to get the full buffer from daemon
|
|
224
|
-
ptyClient.attach(persisted.id, 0);
|
|
225
|
-
}
|
|
226
|
-
catch (err) {
|
|
227
|
-
console.error(`[TERMINAL] Failed to restore terminal ${daemonInfo.id}:`, err);
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
// Clean up DB entries for terminals no longer alive in the daemon
|
|
231
|
-
for (const persisted of persistedTerminals) {
|
|
232
|
-
if (!daemonIds.has(persisted.id)) {
|
|
233
|
-
console.log(`[TERMINAL] Removing stale terminal ${persisted.id} from DB`);
|
|
234
|
-
deleteTerminalFromDb(db, persisted.id);
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
return { router, restoreTerminals };
|
|
198
|
+
return router;
|
|
239
199
|
}
|
package/dist/server.js
CHANGED
|
@@ -2,17 +2,16 @@ import fsSync from 'node:fs';
|
|
|
2
2
|
import crypto from 'node:crypto';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
|
-
import childProcess from 'node:child_process';
|
|
6
5
|
import { Hono } from 'hono';
|
|
7
6
|
import { serve } from '@hono/node-server';
|
|
8
7
|
import { serveStatic } from '@hono/node-server/serve-static';
|
|
9
8
|
import { bodyLimit } from 'hono/body-limit';
|
|
10
9
|
import { DatabaseSync } from 'node:sqlite';
|
|
11
|
-
import { PORT, HOST, NODE_ENV, BASIC_AUTH_USER, BASIC_AUTH_PASSWORD, CORS_ORIGIN, MAX_FILE_SIZE, MAX_REQUEST_BODY_SIZE, TRUST_PROXY,
|
|
10
|
+
import { PORT, HOST, NODE_ENV, BASIC_AUTH_USER, BASIC_AUTH_PASSWORD, CORS_ORIGIN, MAX_FILE_SIZE, MAX_REQUEST_BODY_SIZE, TRUST_PROXY, hasStatic, distDir, dbPath, } from './config.js';
|
|
12
11
|
import { securityHeaders } from './middleware/security.js';
|
|
13
12
|
import { corsMiddleware } from './middleware/cors.js';
|
|
14
13
|
import { basicAuthMiddleware, generateWsToken, isBasicAuthEnabled } from './middleware/auth.js';
|
|
15
|
-
import { checkDatabaseIntegrity, handleDatabaseCorruption, initializeDatabase, loadPersistedState,
|
|
14
|
+
import { checkDatabaseIntegrity, handleDatabaseCorruption, initializeDatabase, loadPersistedState, } from './utils/database.js';
|
|
16
15
|
import { createWorkspaceRouter, getConfigHandler } from './routes/workspaces.js';
|
|
17
16
|
import { createDeckRouter } from './routes/decks.js';
|
|
18
17
|
import { createFileRouter } from './routes/files.js';
|
|
@@ -20,7 +19,6 @@ import { createTerminalRouter } from './routes/terminals.js';
|
|
|
20
19
|
import { createGitRouter } from './routes/git.js';
|
|
21
20
|
import { createSettingsRouter } from './routes/settings.js';
|
|
22
21
|
import { setupWebSocketServer, getConnectionLimit, setConnectionLimit, getConnectionStats, clearAllConnections, } from './websocket.js';
|
|
23
|
-
import { PtyClient } from './pty-client.js';
|
|
24
22
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
25
23
|
// Request ID and logging middleware
|
|
26
24
|
const requestIdMiddleware = async (c, next) => {
|
|
@@ -37,59 +35,6 @@ const requestIdMiddleware = async (c, next) => {
|
|
|
37
35
|
console.log(`[${requestId}] ${method} ${path_} ${status} ${duration}ms`);
|
|
38
36
|
}
|
|
39
37
|
};
|
|
40
|
-
/** Wait for the daemon to write its info file after startup. */
|
|
41
|
-
async function waitForDaemonInfo(maxWaitMs = 8000) {
|
|
42
|
-
const start = Date.now();
|
|
43
|
-
while (Date.now() - start < maxWaitMs) {
|
|
44
|
-
if (fsSync.existsSync(daemonInfoPath)) {
|
|
45
|
-
try {
|
|
46
|
-
return JSON.parse(fsSync.readFileSync(daemonInfoPath, 'utf-8'));
|
|
47
|
-
}
|
|
48
|
-
catch {
|
|
49
|
-
// File may be partially written, retry
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
await new Promise((r) => setTimeout(r, 100));
|
|
53
|
-
}
|
|
54
|
-
throw new Error('PTY daemon did not start within 8 seconds');
|
|
55
|
-
}
|
|
56
|
-
/** Connect to an existing daemon or spawn a new one. Returns a connected PtyClient. */
|
|
57
|
-
async function ensureDaemon() {
|
|
58
|
-
const client = new PtyClient();
|
|
59
|
-
// Try connecting to an existing daemon
|
|
60
|
-
if (fsSync.existsSync(daemonInfoPath)) {
|
|
61
|
-
try {
|
|
62
|
-
const info = JSON.parse(fsSync.readFileSync(daemonInfoPath, 'utf-8'));
|
|
63
|
-
await client.connect(info.port);
|
|
64
|
-
console.log(`[SERVER] Connected to existing PTY daemon on port ${info.port} (pid ${info.pid})`);
|
|
65
|
-
return client;
|
|
66
|
-
}
|
|
67
|
-
catch {
|
|
68
|
-
console.log('[SERVER] Existing PTY daemon is gone, starting a new one...');
|
|
69
|
-
try {
|
|
70
|
-
fsSync.unlinkSync(daemonInfoPath);
|
|
71
|
-
}
|
|
72
|
-
catch { /* ignore */ }
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
// Spawn a new daemon process (detached so it survives server restarts)
|
|
76
|
-
const daemonScript = path.join(__dirname, 'pty-daemon.js');
|
|
77
|
-
const child = childProcess.spawn(process.execPath, [daemonScript], {
|
|
78
|
-
detached: true,
|
|
79
|
-
stdio: 'ignore',
|
|
80
|
-
windowsHide: true,
|
|
81
|
-
env: {
|
|
82
|
-
...process.env,
|
|
83
|
-
DAEMON_INFO_PATH: daemonInfoPath,
|
|
84
|
-
TERMINAL_BUFFER_LIMIT: String(TERMINAL_BUFFER_LIMIT),
|
|
85
|
-
},
|
|
86
|
-
});
|
|
87
|
-
child.unref(); // Don't keep this process alive
|
|
88
|
-
const info = await waitForDaemonInfo();
|
|
89
|
-
await client.connect(info.port);
|
|
90
|
-
console.log(`[SERVER] Started PTY daemon on port ${info.port} (pid ${info.pid})`);
|
|
91
|
-
return client;
|
|
92
|
-
}
|
|
93
38
|
export async function createServer() {
|
|
94
39
|
// Check database integrity before opening
|
|
95
40
|
if (fsSync.existsSync(dbPath) && !checkDatabaseIntegrity(dbPath)) {
|
|
@@ -103,8 +48,6 @@ export async function createServer() {
|
|
|
103
48
|
const decks = new Map();
|
|
104
49
|
const terminals = new Map();
|
|
105
50
|
loadPersistedState(db, workspaces, workspacePathIndex, decks);
|
|
106
|
-
// Start or reconnect to the PTY daemon
|
|
107
|
-
const ptyClient = await ensureDaemon();
|
|
108
51
|
// Create Hono app
|
|
109
52
|
const app = new Hono();
|
|
110
53
|
app.use('*', securityHeaders);
|
|
@@ -122,17 +65,9 @@ export async function createServer() {
|
|
|
122
65
|
app.route('/api/settings', createSettingsRouter());
|
|
123
66
|
app.route('/api/workspaces', createWorkspaceRouter(db, workspaces, workspacePathIndex));
|
|
124
67
|
app.route('/api/decks', createDeckRouter(db, workspaces, decks));
|
|
125
|
-
const
|
|
68
|
+
const terminalRouter = createTerminalRouter(db, decks, terminals);
|
|
126
69
|
app.route('/api/terminals', terminalRouter);
|
|
127
70
|
app.route('/api/git', createGitRouter(workspaces));
|
|
128
|
-
// Restore terminals: daemon is the source of truth for existence, DB provides metadata
|
|
129
|
-
const daemonTerminals = await ptyClient.list();
|
|
130
|
-
const persistedTerminals = loadPersistedTerminals(db, decks);
|
|
131
|
-
if (daemonTerminals.length > 0 || persistedTerminals.length > 0) {
|
|
132
|
-
console.log(`[TERMINAL] Restoring ${daemonTerminals.length} live terminal(s) ` +
|
|
133
|
-
`(${persistedTerminals.length} DB entries)...`);
|
|
134
|
-
await restoreTerminals(persistedTerminals, daemonTerminals);
|
|
135
|
-
}
|
|
136
71
|
app.get('/api/config', getConfigHandler());
|
|
137
72
|
app.get('/api/ws-token', (c) => c.json({ token: generateWsToken(), authEnabled: isBasicAuthEnabled() }));
|
|
138
73
|
app.get('/api/ws/stats', (c) => c.json({ limit: getConnectionLimit(), connections: getConnectionStats() }));
|
|
@@ -177,29 +112,23 @@ export async function createServer() {
|
|
|
177
112
|
console.log(` - CORS Origin: ${CORS_ORIGIN || (NODE_ENV === 'development' ? '*' : 'NOT SET')}`);
|
|
178
113
|
console.log(` - Environment: ${NODE_ENV}`);
|
|
179
114
|
});
|
|
180
|
-
// Graceful shutdown
|
|
115
|
+
// Graceful shutdown
|
|
181
116
|
let shutdownPromise = null;
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
if (options.terminateDaemon) {
|
|
185
|
-
shouldTerminateDaemon = true;
|
|
186
|
-
}
|
|
187
|
-
if (shutdownPromise) {
|
|
117
|
+
const onShutdown = () => {
|
|
118
|
+
if (shutdownPromise)
|
|
188
119
|
return shutdownPromise;
|
|
189
|
-
}
|
|
190
120
|
shutdownPromise = (async () => {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
121
|
+
// Kill all terminals
|
|
122
|
+
terminals.forEach((session) => {
|
|
123
|
+
session.sockets.forEach((socket) => {
|
|
124
|
+
try {
|
|
125
|
+
socket.close(1000, 'Server shutting down');
|
|
196
126
|
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
ptyClient.destroy();
|
|
127
|
+
catch { /* ignore */ }
|
|
128
|
+
});
|
|
129
|
+
session.kill();
|
|
130
|
+
});
|
|
131
|
+
terminals.clear();
|
|
203
132
|
try {
|
|
204
133
|
db.close();
|
|
205
134
|
}
|
|
@@ -208,32 +137,24 @@ export async function createServer() {
|
|
|
208
137
|
return shutdownPromise;
|
|
209
138
|
};
|
|
210
139
|
process.on('SIGINT', () => {
|
|
211
|
-
console.log('\n[SHUTDOWN] Received SIGINT
|
|
212
|
-
void onShutdown(
|
|
140
|
+
console.log('\n[SHUTDOWN] Received SIGINT...');
|
|
141
|
+
void onShutdown().finally(() => process.exit(0));
|
|
213
142
|
});
|
|
214
143
|
process.on('SIGTERM', () => {
|
|
215
|
-
console.log('[SHUTDOWN] Received SIGTERM
|
|
216
|
-
void onShutdown(
|
|
144
|
+
console.log('[SHUTDOWN] Received SIGTERM...');
|
|
145
|
+
void onShutdown().finally(() => process.exit(0));
|
|
217
146
|
});
|
|
218
147
|
process.on('SIGHUP', () => {
|
|
219
|
-
console.log('[SHUTDOWN] Received SIGHUP
|
|
220
|
-
void onShutdown(
|
|
148
|
+
console.log('[SHUTDOWN] Received SIGHUP...');
|
|
149
|
+
void onShutdown().finally(() => process.exit(0));
|
|
221
150
|
});
|
|
222
|
-
// HTTP shutdown endpoint
|
|
151
|
+
// HTTP shutdown endpoint
|
|
223
152
|
app.post('/api/shutdown', async (c) => {
|
|
224
|
-
let terminateDaemon = false;
|
|
225
|
-
try {
|
|
226
|
-
const body = await c.req.json();
|
|
227
|
-
terminateDaemon = body?.terminateDaemon === true;
|
|
228
|
-
}
|
|
229
|
-
catch {
|
|
230
|
-
// Body is optional; default is false.
|
|
231
|
-
}
|
|
232
153
|
setTimeout(() => {
|
|
233
|
-
console.log(
|
|
234
|
-
void onShutdown(
|
|
154
|
+
console.log('[SHUTDOWN] Shutdown requested via HTTP API');
|
|
155
|
+
void onShutdown().finally(() => process.exit(0));
|
|
235
156
|
}, 50);
|
|
236
|
-
return c.json({ ok: true
|
|
157
|
+
return c.json({ ok: true });
|
|
237
158
|
});
|
|
238
159
|
const originalExceptionHandler = process.listeners('uncaughtException')[0];
|
|
239
160
|
process.removeAllListeners('uncaughtException');
|
|
@@ -242,8 +163,8 @@ export async function createServer() {
|
|
|
242
163
|
console.log('[node-pty] AttachConsole error suppressed');
|
|
243
164
|
return;
|
|
244
165
|
}
|
|
245
|
-
console.error('[SHUTDOWN] Uncaught exception
|
|
246
|
-
void onShutdown(
|
|
166
|
+
console.error('[SHUTDOWN] Uncaught exception...');
|
|
167
|
+
void onShutdown().finally(() => {
|
|
247
168
|
if (originalExceptionHandler) {
|
|
248
169
|
originalExceptionHandler(error);
|
|
249
170
|
}
|
package/dist/utils/database.js
CHANGED
|
@@ -102,30 +102,6 @@ export function loadPersistedState(db, workspaces, workspacePathIndex, decks) {
|
|
|
102
102
|
decks.set(deck.id, deck);
|
|
103
103
|
});
|
|
104
104
|
}
|
|
105
|
-
// Terminal persistence functions
|
|
106
|
-
export function loadPersistedTerminals(db, decks) {
|
|
107
|
-
const rows = db
|
|
108
|
-
.prepare('SELECT id, deck_id, title, command, created_at FROM terminals ORDER BY created_at ASC')
|
|
109
|
-
.all();
|
|
110
|
-
const terminals = [];
|
|
111
|
-
rows.forEach((row) => {
|
|
112
|
-
const deckId = String(row.deck_id);
|
|
113
|
-
// Only load terminals for existing decks
|
|
114
|
-
if (!decks.has(deckId)) {
|
|
115
|
-
// Clean up orphaned terminal
|
|
116
|
-
db.prepare('DELETE FROM terminals WHERE id = ?').run(String(row.id));
|
|
117
|
-
return;
|
|
118
|
-
}
|
|
119
|
-
terminals.push({
|
|
120
|
-
id: String(row.id),
|
|
121
|
-
deckId,
|
|
122
|
-
title: String(row.title),
|
|
123
|
-
command: row.command ? String(row.command) : null,
|
|
124
|
-
createdAt: String(row.created_at)
|
|
125
|
-
});
|
|
126
|
-
});
|
|
127
|
-
return terminals;
|
|
128
|
-
}
|
|
129
105
|
export function saveTerminal(db, id, deckId, title, command, createdAt) {
|
|
130
106
|
const stmt = db.prepare('INSERT OR REPLACE INTO terminals (id, deck_id, title, command, created_at) VALUES (?, ?, ?, ?, ?)');
|
|
131
107
|
stmt.run(id, deckId, title, command, createdAt);
|
package/package.json
CHANGED
package/dist/pty-client.js
DELETED
|
@@ -1,177 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* PtyClient - IPC client for the main server to communicate with the PTY daemon.
|
|
3
|
-
* Uses newline-delimited JSON over a local TCP connection.
|
|
4
|
-
*/
|
|
5
|
-
import net from 'node:net';
|
|
6
|
-
import { EventEmitter } from 'node:events';
|
|
7
|
-
export class PtyClient extends EventEmitter {
|
|
8
|
-
socket = null;
|
|
9
|
-
lineBuf = '';
|
|
10
|
-
_connected = false;
|
|
11
|
-
listCallback = null;
|
|
12
|
-
shutdownCallback = null;
|
|
13
|
-
createCallbacks = new Map();
|
|
14
|
-
get connected() {
|
|
15
|
-
return this._connected;
|
|
16
|
-
}
|
|
17
|
-
connect(port) {
|
|
18
|
-
return new Promise((resolve, reject) => {
|
|
19
|
-
const socket = net.createConnection(port, '127.0.0.1');
|
|
20
|
-
socket.once('connect', () => {
|
|
21
|
-
this.socket = socket;
|
|
22
|
-
this._connected = true;
|
|
23
|
-
resolve();
|
|
24
|
-
});
|
|
25
|
-
socket.once('error', (err) => {
|
|
26
|
-
if (!this._connected) {
|
|
27
|
-
reject(err);
|
|
28
|
-
}
|
|
29
|
-
else {
|
|
30
|
-
this.emit('error', err);
|
|
31
|
-
}
|
|
32
|
-
});
|
|
33
|
-
socket.on('data', (chunk) => {
|
|
34
|
-
this.lineBuf += chunk.toString('utf8');
|
|
35
|
-
const lines = this.lineBuf.split('\n');
|
|
36
|
-
this.lineBuf = lines.pop();
|
|
37
|
-
for (const line of lines) {
|
|
38
|
-
if (!line.trim())
|
|
39
|
-
continue;
|
|
40
|
-
try {
|
|
41
|
-
this.handleMessage(JSON.parse(line));
|
|
42
|
-
}
|
|
43
|
-
catch { /* malformed message, ignore */ }
|
|
44
|
-
}
|
|
45
|
-
});
|
|
46
|
-
socket.on('close', () => {
|
|
47
|
-
this._connected = false;
|
|
48
|
-
this.socket = null;
|
|
49
|
-
if (this.shutdownCallback) {
|
|
50
|
-
this.shutdownCallback(false);
|
|
51
|
-
this.shutdownCallback = null;
|
|
52
|
-
}
|
|
53
|
-
this.emit('disconnect');
|
|
54
|
-
});
|
|
55
|
-
});
|
|
56
|
-
}
|
|
57
|
-
handleMessage(msg) {
|
|
58
|
-
switch (msg.type) {
|
|
59
|
-
case 'data':
|
|
60
|
-
this.emit('data', msg.id, msg.data);
|
|
61
|
-
break;
|
|
62
|
-
case 'exit':
|
|
63
|
-
this.emit('exit', msg.id, msg.code);
|
|
64
|
-
break;
|
|
65
|
-
case 'list_result': {
|
|
66
|
-
const cb = this.listCallback;
|
|
67
|
-
this.listCallback = null;
|
|
68
|
-
cb?.(msg.terminals);
|
|
69
|
-
break;
|
|
70
|
-
}
|
|
71
|
-
case 'shutdown_ack': {
|
|
72
|
-
const cb = this.shutdownCallback;
|
|
73
|
-
this.shutdownCallback = null;
|
|
74
|
-
cb?.(true);
|
|
75
|
-
break;
|
|
76
|
-
}
|
|
77
|
-
case 'created': {
|
|
78
|
-
const cb = this.createCallbacks.get(msg.id);
|
|
79
|
-
if (cb) {
|
|
80
|
-
this.createCallbacks.delete(msg.id);
|
|
81
|
-
cb();
|
|
82
|
-
}
|
|
83
|
-
break;
|
|
84
|
-
}
|
|
85
|
-
case 'error': {
|
|
86
|
-
if (msg.id) {
|
|
87
|
-
const cb = this.createCallbacks.get(msg.id);
|
|
88
|
-
if (cb) {
|
|
89
|
-
this.createCallbacks.delete(msg.id);
|
|
90
|
-
cb(msg.message);
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
break;
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
send(msg) {
|
|
98
|
-
if (this.socket && !this.socket.destroyed) {
|
|
99
|
-
try {
|
|
100
|
-
this.socket.write(JSON.stringify(msg) + '\n');
|
|
101
|
-
}
|
|
102
|
-
catch { /* socket may be closing */ }
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
/** Spawn a new PTY in the daemon. Resolves when daemon confirms creation. */
|
|
106
|
-
create(params) {
|
|
107
|
-
return new Promise((resolve, reject) => {
|
|
108
|
-
const timer = setTimeout(() => {
|
|
109
|
-
this.createCallbacks.delete(params.id);
|
|
110
|
-
reject(new Error(`Timeout creating terminal ${params.id}`));
|
|
111
|
-
}, 10_000);
|
|
112
|
-
this.createCallbacks.set(params.id, (err) => {
|
|
113
|
-
clearTimeout(timer);
|
|
114
|
-
if (err)
|
|
115
|
-
reject(new Error(err));
|
|
116
|
-
else
|
|
117
|
-
resolve();
|
|
118
|
-
});
|
|
119
|
-
this.send({ type: 'create', ...params });
|
|
120
|
-
});
|
|
121
|
-
}
|
|
122
|
-
/** Send keyboard input to a terminal. */
|
|
123
|
-
input(id, data) {
|
|
124
|
-
this.send({ type: 'input', id, data });
|
|
125
|
-
}
|
|
126
|
-
/** Resize a terminal. */
|
|
127
|
-
resize(id, cols, rows) {
|
|
128
|
-
this.send({ type: 'resize', id, cols, rows });
|
|
129
|
-
}
|
|
130
|
-
/** Kill a terminal and remove it from the daemon. */
|
|
131
|
-
kill(id) {
|
|
132
|
-
this.send({ type: 'kill', id });
|
|
133
|
-
}
|
|
134
|
-
/**
|
|
135
|
-
* Subscribe to live data from a terminal.
|
|
136
|
-
* The daemon will first send any buffered output since `bufferOffset`,
|
|
137
|
-
* then stream all subsequent output.
|
|
138
|
-
*/
|
|
139
|
-
attach(id, bufferOffset) {
|
|
140
|
-
this.send({ type: 'attach', id, bufferOffset });
|
|
141
|
-
}
|
|
142
|
-
/** Get a list of all terminals currently alive in the daemon. */
|
|
143
|
-
list() {
|
|
144
|
-
return new Promise((resolve) => {
|
|
145
|
-
const timer = setTimeout(() => {
|
|
146
|
-
this.listCallback = null;
|
|
147
|
-
resolve([]);
|
|
148
|
-
}, 5_000);
|
|
149
|
-
this.listCallback = (terminals) => {
|
|
150
|
-
clearTimeout(timer);
|
|
151
|
-
resolve(terminals);
|
|
152
|
-
};
|
|
153
|
-
this.send({ type: 'list' });
|
|
154
|
-
});
|
|
155
|
-
}
|
|
156
|
-
/** Ask the daemon to terminate itself and all child PTYs. */
|
|
157
|
-
shutdown(timeoutMs = 2_000) {
|
|
158
|
-
if (!this.connected) {
|
|
159
|
-
return Promise.resolve(false);
|
|
160
|
-
}
|
|
161
|
-
return new Promise((resolve) => {
|
|
162
|
-
const timer = setTimeout(() => {
|
|
163
|
-
this.shutdownCallback = null;
|
|
164
|
-
resolve(false);
|
|
165
|
-
}, timeoutMs);
|
|
166
|
-
this.shutdownCallback = (ok) => {
|
|
167
|
-
clearTimeout(timer);
|
|
168
|
-
this.shutdownCallback = null;
|
|
169
|
-
resolve(ok);
|
|
170
|
-
};
|
|
171
|
-
this.send({ type: 'shutdown' });
|
|
172
|
-
});
|
|
173
|
-
}
|
|
174
|
-
destroy() {
|
|
175
|
-
this.socket?.destroy();
|
|
176
|
-
}
|
|
177
|
-
}
|
package/dist/pty-daemon.js
DELETED
|
@@ -1,246 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* PTY Daemon - standalone process that manages PTY sessions.
|
|
3
|
-
*
|
|
4
|
-
* Spawned as a detached child by the main server so it outlives server restarts.
|
|
5
|
-
* Communicates with the server over a local TCP socket using newline-delimited JSON.
|
|
6
|
-
*
|
|
7
|
-
* Protocol (Server → Daemon):
|
|
8
|
-
* { type:"create", id, shell, shellArgs, cwd, env, cols, rows }
|
|
9
|
-
* { type:"input", id, data }
|
|
10
|
-
* { type:"resize", id, cols, rows }
|
|
11
|
-
* { type:"kill", id }
|
|
12
|
-
* { type:"attach", id, bufferOffset } -- start streaming data for this terminal
|
|
13
|
-
* { type:"list" } -- list active terminal IDs
|
|
14
|
-
* { type:"shutdown" } -- terminate daemon and all PTYs
|
|
15
|
-
*
|
|
16
|
-
* Protocol (Daemon → Server):
|
|
17
|
-
* { type:"created", id }
|
|
18
|
-
* { type:"data", id, data } -- only for attached terminals
|
|
19
|
-
* { type:"exit", id, code }
|
|
20
|
-
* { type:"list_result", terminals: [{id, bufferLength}] }
|
|
21
|
-
* { type:"shutdown_ack" }
|
|
22
|
-
* { type:"error", id?, message }
|
|
23
|
-
*/
|
|
24
|
-
import net from 'node:net';
|
|
25
|
-
import fs from 'node:fs';
|
|
26
|
-
import { spawn } from 'node-pty';
|
|
27
|
-
const DAEMON_INFO_PATH = process.env.DAEMON_INFO_PATH;
|
|
28
|
-
const BUFFER_LIMIT = Number(process.env.TERMINAL_BUFFER_LIMIT || 500000);
|
|
29
|
-
if (!DAEMON_INFO_PATH) {
|
|
30
|
-
console.error('[pty-daemon] DAEMON_INFO_PATH env var is required');
|
|
31
|
-
process.exit(1);
|
|
32
|
-
}
|
|
33
|
-
const sessions = new Map();
|
|
34
|
-
// IDs the server is currently subscribed to (receives live data for)
|
|
35
|
-
const attached = new Set();
|
|
36
|
-
let serverSocket = null;
|
|
37
|
-
let shuttingDown = false;
|
|
38
|
-
function appendBuffer(session, data) {
|
|
39
|
-
const combined = session.buffer + data;
|
|
40
|
-
session.buffer =
|
|
41
|
-
combined.length > BUFFER_LIMIT
|
|
42
|
-
? combined.slice(combined.length - BUFFER_LIMIT)
|
|
43
|
-
: combined;
|
|
44
|
-
}
|
|
45
|
-
function sendToServer(msg) {
|
|
46
|
-
if (serverSocket && !serverSocket.destroyed) {
|
|
47
|
-
try {
|
|
48
|
-
serverSocket.write(JSON.stringify(msg) + '\n');
|
|
49
|
-
}
|
|
50
|
-
catch {
|
|
51
|
-
// Socket may have just closed
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
function handleMessage(msg) {
|
|
56
|
-
switch (msg.type) {
|
|
57
|
-
case 'create': {
|
|
58
|
-
const { id, shell, shellArgs = [], cwd, env, cols = 120, rows = 32 } = msg;
|
|
59
|
-
if (sessions.has(id)) {
|
|
60
|
-
// Already exists - just ack
|
|
61
|
-
sendToServer({ type: 'created', id });
|
|
62
|
-
return;
|
|
63
|
-
}
|
|
64
|
-
try {
|
|
65
|
-
const isWindows = process.platform === 'win32';
|
|
66
|
-
const term = spawn(shell, shellArgs, {
|
|
67
|
-
cwd,
|
|
68
|
-
cols,
|
|
69
|
-
rows,
|
|
70
|
-
env,
|
|
71
|
-
encoding: 'utf8',
|
|
72
|
-
...(isWindows ? { useConpty: true } : {}),
|
|
73
|
-
});
|
|
74
|
-
const session = { id, term, buffer: '' };
|
|
75
|
-
sessions.set(id, session);
|
|
76
|
-
term.onData((data) => {
|
|
77
|
-
appendBuffer(session, data);
|
|
78
|
-
if (attached.has(id)) {
|
|
79
|
-
sendToServer({ type: 'data', id, data });
|
|
80
|
-
}
|
|
81
|
-
});
|
|
82
|
-
term.onExit(({ exitCode }) => {
|
|
83
|
-
sessions.delete(id);
|
|
84
|
-
attached.delete(id);
|
|
85
|
-
sendToServer({ type: 'exit', id, code: exitCode });
|
|
86
|
-
});
|
|
87
|
-
sendToServer({ type: 'created', id });
|
|
88
|
-
console.log(`[pty-daemon] Created terminal ${id} (pid=${term.pid})`);
|
|
89
|
-
}
|
|
90
|
-
catch (err) {
|
|
91
|
-
console.error(`[pty-daemon] Failed to create terminal ${id}:`, err);
|
|
92
|
-
sendToServer({ type: 'error', id, message: String(err) });
|
|
93
|
-
}
|
|
94
|
-
break;
|
|
95
|
-
}
|
|
96
|
-
case 'input': {
|
|
97
|
-
const session = sessions.get(msg.id);
|
|
98
|
-
if (session) {
|
|
99
|
-
try {
|
|
100
|
-
session.term.write(msg.data);
|
|
101
|
-
}
|
|
102
|
-
catch { /* terminal may be dying */ }
|
|
103
|
-
}
|
|
104
|
-
break;
|
|
105
|
-
}
|
|
106
|
-
case 'resize': {
|
|
107
|
-
const session = sessions.get(msg.id);
|
|
108
|
-
if (session) {
|
|
109
|
-
try {
|
|
110
|
-
session.term.resize(msg.cols, msg.rows);
|
|
111
|
-
}
|
|
112
|
-
catch { /* terminal may be dying */ }
|
|
113
|
-
}
|
|
114
|
-
break;
|
|
115
|
-
}
|
|
116
|
-
case 'kill': {
|
|
117
|
-
const session = sessions.get(msg.id);
|
|
118
|
-
if (session) {
|
|
119
|
-
sessions.delete(msg.id);
|
|
120
|
-
attached.delete(msg.id);
|
|
121
|
-
try {
|
|
122
|
-
session.term.kill();
|
|
123
|
-
}
|
|
124
|
-
catch { /* already dead */ }
|
|
125
|
-
console.log(`[pty-daemon] Killed terminal ${msg.id}`);
|
|
126
|
-
}
|
|
127
|
-
break;
|
|
128
|
-
}
|
|
129
|
-
case 'attach': {
|
|
130
|
-
const session = sessions.get(msg.id);
|
|
131
|
-
if (!session) {
|
|
132
|
-
sendToServer({ type: 'error', id: msg.id, message: 'Terminal not found' });
|
|
133
|
-
return;
|
|
134
|
-
}
|
|
135
|
-
attached.add(msg.id);
|
|
136
|
-
// Send buffered output the server hasn't seen yet
|
|
137
|
-
const offset = Number(msg.bufferOffset) || 0;
|
|
138
|
-
const delta = offset > 0 ? session.buffer.slice(offset) : session.buffer;
|
|
139
|
-
if (delta) {
|
|
140
|
-
sendToServer({ type: 'data', id: msg.id, data: delta });
|
|
141
|
-
}
|
|
142
|
-
break;
|
|
143
|
-
}
|
|
144
|
-
case 'list': {
|
|
145
|
-
const terminals = Array.from(sessions.entries()).map(([id, s]) => ({
|
|
146
|
-
id,
|
|
147
|
-
bufferLength: s.buffer.length,
|
|
148
|
-
}));
|
|
149
|
-
sendToServer({ type: 'list_result', terminals });
|
|
150
|
-
break;
|
|
151
|
-
}
|
|
152
|
-
case 'shutdown': {
|
|
153
|
-
sendToServer({ type: 'shutdown_ack' });
|
|
154
|
-
// ackを送信しきるため少し待ってから終了
|
|
155
|
-
setTimeout(() => {
|
|
156
|
-
shutdown();
|
|
157
|
-
}, 10);
|
|
158
|
-
break;
|
|
159
|
-
}
|
|
160
|
-
default:
|
|
161
|
-
console.warn(`[pty-daemon] Unknown message type: ${msg.type}`);
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
// TCP server - accepts one connection from the main server at a time
|
|
165
|
-
const tcpServer = net.createServer((socket) => {
|
|
166
|
-
// Close previous connection if server reconnects
|
|
167
|
-
if (serverSocket && !serverSocket.destroyed) {
|
|
168
|
-
serverSocket.destroy();
|
|
169
|
-
}
|
|
170
|
-
serverSocket = socket;
|
|
171
|
-
attached.clear(); // New connection starts with no subscriptions
|
|
172
|
-
console.log('[pty-daemon] Main server connected');
|
|
173
|
-
let lineBuf = '';
|
|
174
|
-
socket.on('data', (chunk) => {
|
|
175
|
-
lineBuf += chunk.toString('utf8');
|
|
176
|
-
const lines = lineBuf.split('\n');
|
|
177
|
-
lineBuf = lines.pop();
|
|
178
|
-
for (const line of lines) {
|
|
179
|
-
if (!line.trim())
|
|
180
|
-
continue;
|
|
181
|
-
try {
|
|
182
|
-
handleMessage(JSON.parse(line));
|
|
183
|
-
}
|
|
184
|
-
catch (err) {
|
|
185
|
-
console.error('[pty-daemon] Failed to parse message:', err);
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
});
|
|
189
|
-
socket.on('close', () => {
|
|
190
|
-
if (serverSocket === socket) {
|
|
191
|
-
serverSocket = null;
|
|
192
|
-
attached.clear();
|
|
193
|
-
console.log('[pty-daemon] Main server disconnected');
|
|
194
|
-
}
|
|
195
|
-
});
|
|
196
|
-
socket.on('error', (err) => {
|
|
197
|
-
console.error('[pty-daemon] Socket error:', err.message);
|
|
198
|
-
});
|
|
199
|
-
});
|
|
200
|
-
tcpServer.on('error', (err) => {
|
|
201
|
-
console.error('[pty-daemon] TCP server error:', err);
|
|
202
|
-
process.exit(1);
|
|
203
|
-
});
|
|
204
|
-
// Listen on a random available port, then write info file for the server to find us
|
|
205
|
-
tcpServer.listen(0, '127.0.0.1', () => {
|
|
206
|
-
const addr = tcpServer.address();
|
|
207
|
-
const info = { pid: process.pid, port: addr.port };
|
|
208
|
-
fs.writeFileSync(DAEMON_INFO_PATH, JSON.stringify(info));
|
|
209
|
-
console.log(`[pty-daemon] Running on port ${addr.port} (pid ${process.pid})`);
|
|
210
|
-
});
|
|
211
|
-
function shutdown() {
|
|
212
|
-
if (shuttingDown) {
|
|
213
|
-
return;
|
|
214
|
-
}
|
|
215
|
-
shuttingDown = true;
|
|
216
|
-
console.log('[pty-daemon] Shutting down...');
|
|
217
|
-
sessions.forEach(({ term }) => {
|
|
218
|
-
try {
|
|
219
|
-
term.kill();
|
|
220
|
-
}
|
|
221
|
-
catch { /* ignore */ }
|
|
222
|
-
});
|
|
223
|
-
sessions.clear();
|
|
224
|
-
try {
|
|
225
|
-
serverSocket?.destroy();
|
|
226
|
-
}
|
|
227
|
-
catch { /* ignore */ }
|
|
228
|
-
try {
|
|
229
|
-
tcpServer.close();
|
|
230
|
-
}
|
|
231
|
-
catch { /* ignore */ }
|
|
232
|
-
try {
|
|
233
|
-
fs.unlinkSync(DAEMON_INFO_PATH);
|
|
234
|
-
}
|
|
235
|
-
catch { /* ignore */ }
|
|
236
|
-
process.exit(0);
|
|
237
|
-
}
|
|
238
|
-
process.on('SIGTERM', shutdown);
|
|
239
|
-
process.on('SIGINT', shutdown);
|
|
240
|
-
// Keep the daemon alive even on unexpected errors
|
|
241
|
-
process.on('uncaughtException', (err) => {
|
|
242
|
-
console.error('[pty-daemon] Uncaught exception (continuing):', err);
|
|
243
|
-
});
|
|
244
|
-
process.on('unhandledRejection', (err) => {
|
|
245
|
-
console.error('[pty-daemon] Unhandled rejection (continuing):', err);
|
|
246
|
-
});
|