deckide 3.3.1 → 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 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');
@@ -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
  }
@@ -61,6 +61,24 @@ export function createWorkspaceRouter(db, workspaces, workspacePathIndex) {
61
61
  return handleError(c, error);
62
62
  }
63
63
  });
64
+ const deleteWorkspace = db.prepare('DELETE FROM workspaces WHERE id = ?');
65
+ router.delete('/:id', (c) => {
66
+ try {
67
+ const id = c.req.param('id');
68
+ const workspace = workspaces.get(id);
69
+ if (!workspace) {
70
+ throw createHttpError('Workspace not found', 404);
71
+ }
72
+ const key = getWorkspaceKey(workspace.path);
73
+ deleteWorkspace.run(id);
74
+ workspaces.delete(id);
75
+ workspacePathIndex.delete(key);
76
+ return c.json({ deleted: true });
77
+ }
78
+ catch (error) {
79
+ return handleError(c, error);
80
+ }
81
+ });
64
82
  return router;
65
83
  }
66
84
  export function getConfigHandler() {