deckide 3.4.0 → 3.5.1

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 CHANGED
@@ -32,8 +32,7 @@ function getPort() {
32
32
  return loadSettings().port || 8787;
33
33
  }
34
34
 
35
- function isServerRunning() {
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 config Show all settings
72
- deckide config set <key> <val> Set a config value
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
- if (!isServerRunning()) {
343
+ const pid = getRunningPid();
344
+ if (!isServerRunning() && !pid) {
264
345
  console.log('Server is not running.');
265
346
  process.exit(0);
266
347
  }
267
- const port = getPort();
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
- } catch {
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
- const port = getPort();
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
- } catch {}
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 (isServerRunning()) {
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');
@@ -17,7 +17,7 @@ export const corsMiddleware = async (c, next) => {
17
17
  c.header('Access-Control-Allow-Origin', `http://localhost:${PORT}`);
18
18
  }
19
19
  }
20
- c.header('Access-Control-Allow-Methods', 'GET,POST,PUT,OPTIONS');
20
+ c.header('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,OPTIONS');
21
21
  c.header('Access-Control-Allow-Headers', 'Content-Type,Authorization');
22
22
  if (c.req.method === 'OPTIONS') {
23
23
  return c.body(null, 204);
@@ -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, ptyClient) {
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
- // Central data handler: daemon streams output → update buffer → forward to WebSockets
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
- // Central exit handler: PTY exited → close WebSockets, remove from map and DB
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
- async function createTerminalSession(deck, title, command, options) {
64
- const id = options?.id || crypto.randomUUID();
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
- // Create PTY in the daemon
105
- await ptyClient.create({ id, shell, shellArgs, cwd: deck.root, env, cols: 120, rows: 32 });
106
- console.log(`[TERMINAL] Created terminal ${id} in daemon: shell=${shell}, cwd=${deck.root}`);
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: options?.initialBuffer || '',
118
+ buffer: '',
117
119
  lastActive: Date.now(),
118
- write: (data) => ptyClient.input(id, data),
119
- resize: (cols, rows) => ptyClient.resize(id, cols, rows),
120
- kill: () => ptyClient.kill(id),
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
- if (!options?.skipDbSave) {
123
- saveTerminal(db, id, deck.id, resolvedTitle, command || null, createdAt);
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 = await createTerminalSession(deck, body?.title, body?.command);
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, TERMINAL_BUFFER_LIMIT, hasStatic, distDir, dbPath, daemonInfoPath, } from './config.js';
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, loadPersistedTerminals, } from './utils/database.js';
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 { router: terminalRouter, restoreTerminals } = createTerminalRouter(db, decks, terminals, ptyClient);
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 - optionally terminate daemon.
115
+ // Graceful shutdown
181
116
  let shutdownPromise = null;
182
- let shouldTerminateDaemon = false;
183
- const onShutdown = (options = {}) => {
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
- if (shouldTerminateDaemon) {
192
- try {
193
- const stopped = await ptyClient.shutdown();
194
- if (!stopped) {
195
- console.warn('[SHUTDOWN] PTY daemon shutdown was not acknowledged');
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
- catch (err) {
199
- console.warn('[SHUTDOWN] Failed to request PTY daemon shutdown:', err);
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, saving state...');
212
- void onShutdown({ terminateDaemon: true }).finally(() => process.exit(0));
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, saving state...');
216
- void onShutdown({ terminateDaemon: true }).finally(() => process.exit(0));
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, saving state...');
220
- void onShutdown({ terminateDaemon: true }).finally(() => process.exit(0));
148
+ console.log('[SHUTDOWN] Received SIGHUP...');
149
+ void onShutdown().finally(() => process.exit(0));
221
150
  });
222
- // HTTP shutdown endpoint — allows cross-platform graceful shutdown from Electron
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(`[SHUTDOWN] Shutdown requested via HTTP API${terminateDaemon ? ' (terminate daemon)' : ''}`);
234
- void onShutdown({ terminateDaemon }).finally(() => process.exit(0));
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, terminateDaemon });
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, saving state before exit...');
246
- void onShutdown({ terminateDaemon: true }).finally(() => {
166
+ console.error('[SHUTDOWN] Uncaught exception...');
167
+ void onShutdown().finally(() => {
247
168
  if (originalExceptionHandler) {
248
169
  originalExceptionHandler(error);
249
170
  }
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deckide",
3
- "version": "3.4.0",
3
+ "version": "3.5.1",
4
4
  "description": "Deck IDE - Browser-based IDE with terminal, file explorer, and git integration",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
- }
@@ -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
- });