deckide 3.0.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/LICENSE +21 -0
- package/README.md +192 -0
- package/apps/server/dist/config.js +77 -0
- package/apps/server/dist/index.js +5 -0
- package/apps/server/dist/middleware/auth.js +78 -0
- package/apps/server/dist/middleware/cors.js +26 -0
- package/apps/server/dist/middleware/security.js +16 -0
- package/apps/server/dist/pty-client.js +177 -0
- package/apps/server/dist/pty-daemon.js +246 -0
- package/apps/server/dist/routes/decks.js +95 -0
- package/apps/server/dist/routes/files.js +221 -0
- package/apps/server/dist/routes/git.js +775 -0
- package/apps/server/dist/routes/settings.js +95 -0
- package/apps/server/dist/routes/terminals.js +239 -0
- package/apps/server/dist/routes/workspaces.js +83 -0
- package/apps/server/dist/server.js +257 -0
- package/apps/server/dist/types.js +1 -0
- package/apps/server/dist/utils/database.js +136 -0
- package/apps/server/dist/utils/error.js +28 -0
- package/apps/server/dist/utils/path.js +98 -0
- package/apps/server/dist/utils/shell.js +4 -0
- package/apps/server/dist/websocket.js +207 -0
- package/apps/server/package.json +26 -0
- package/apps/web/dist/assets/index-C-usl0y0.css +32 -0
- package/apps/web/dist/assets/index-CibzlLP5.js +129 -0
- package/apps/web/dist/index.html +13 -0
- package/bin/deckide.js +79 -0
- package/package.json +77 -0
- package/packages/shared/dist/types.d.ts +124 -0
- package/packages/shared/dist/types.d.ts.map +1 -0
- package/packages/shared/dist/types.js +3 -0
- package/packages/shared/dist/types.js.map +1 -0
- package/packages/shared/dist/utils-node.d.ts +22 -0
- package/packages/shared/dist/utils-node.d.ts.map +1 -0
- package/packages/shared/dist/utils-node.js +35 -0
- package/packages/shared/dist/utils-node.js.map +1 -0
- package/packages/shared/dist/utils.d.ts +90 -0
- package/packages/shared/dist/utils.d.ts.map +1 -0
- package/packages/shared/dist/utils.js +186 -0
- package/packages/shared/dist/utils.js.map +1 -0
- package/packages/shared/package.json +16 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import { createHttpError, handleError } from '../utils/error.js';
|
|
4
|
+
import { PORT, BASIC_AUTH_USER, BASIC_AUTH_PASSWORD, SETTINGS_FILE } from '../config.js';
|
|
5
|
+
// Load settings from file or return defaults
|
|
6
|
+
async function loadSettings() {
|
|
7
|
+
try {
|
|
8
|
+
const data = await fs.readFile(SETTINGS_FILE, 'utf-8');
|
|
9
|
+
return JSON.parse(data);
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
// Return defaults from environment or hardcoded defaults
|
|
13
|
+
return {
|
|
14
|
+
port: PORT,
|
|
15
|
+
basicAuthEnabled: Boolean(BASIC_AUTH_USER && BASIC_AUTH_PASSWORD),
|
|
16
|
+
basicAuthUser: BASIC_AUTH_USER || '',
|
|
17
|
+
basicAuthPassword: BASIC_AUTH_PASSWORD || ''
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
// Save settings to file
|
|
22
|
+
async function saveSettings(settings) {
|
|
23
|
+
await fs.writeFile(SETTINGS_FILE, JSON.stringify(settings, null, 2), 'utf-8');
|
|
24
|
+
}
|
|
25
|
+
export function createSettingsRouter() {
|
|
26
|
+
const router = new Hono();
|
|
27
|
+
// GET /api/settings - Get current settings
|
|
28
|
+
router.get('/', async (c) => {
|
|
29
|
+
try {
|
|
30
|
+
const settings = await loadSettings();
|
|
31
|
+
// Don't send password to client if it exists (for security)
|
|
32
|
+
// Instead, send a flag indicating if password is set
|
|
33
|
+
return c.json({
|
|
34
|
+
port: settings.port,
|
|
35
|
+
basicAuthEnabled: settings.basicAuthEnabled,
|
|
36
|
+
basicAuthUser: settings.basicAuthUser,
|
|
37
|
+
basicAuthPassword: settings.basicAuthPassword ? '••••••••••••' : ''
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
return handleError(c, error);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
// POST /api/settings - Update settings
|
|
45
|
+
router.post('/', async (c) => {
|
|
46
|
+
try {
|
|
47
|
+
const body = await c.req.json();
|
|
48
|
+
// Validate settings
|
|
49
|
+
if (!body.port || body.port < 1024 || body.port > 65535) {
|
|
50
|
+
throw createHttpError('Port must be between 1024 and 65535', 400);
|
|
51
|
+
}
|
|
52
|
+
if (body.basicAuthEnabled) {
|
|
53
|
+
if (!body.basicAuthUser || !body.basicAuthPassword) {
|
|
54
|
+
throw createHttpError('Username and password are required when Basic Auth is enabled', 400);
|
|
55
|
+
}
|
|
56
|
+
if (body.basicAuthPassword.length < 12 && body.basicAuthPassword !== '••••••••••••') {
|
|
57
|
+
throw createHttpError('Password must be at least 12 characters', 400);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
// Load current settings to preserve password if placeholder is sent
|
|
61
|
+
const currentSettings = await loadSettings();
|
|
62
|
+
const newSettings = {
|
|
63
|
+
port: body.port,
|
|
64
|
+
basicAuthEnabled: body.basicAuthEnabled,
|
|
65
|
+
basicAuthUser: body.basicAuthUser,
|
|
66
|
+
// If password is placeholder, keep current password
|
|
67
|
+
basicAuthPassword: body.basicAuthPassword === '••••••••••••'
|
|
68
|
+
? currentSettings.basicAuthPassword
|
|
69
|
+
: body.basicAuthPassword
|
|
70
|
+
};
|
|
71
|
+
// Save settings
|
|
72
|
+
await saveSettings(newSettings);
|
|
73
|
+
// Update environment variables for current process
|
|
74
|
+
process.env.PORT = String(newSettings.port);
|
|
75
|
+
if (newSettings.basicAuthEnabled) {
|
|
76
|
+
process.env.BASIC_AUTH_USER = newSettings.basicAuthUser;
|
|
77
|
+
process.env.BASIC_AUTH_PASSWORD = newSettings.basicAuthPassword;
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
delete process.env.BASIC_AUTH_USER;
|
|
81
|
+
delete process.env.BASIC_AUTH_PASSWORD;
|
|
82
|
+
}
|
|
83
|
+
// Return success - client should restart server
|
|
84
|
+
return c.json({
|
|
85
|
+
success: true,
|
|
86
|
+
message: 'Settings saved. Server restart required.',
|
|
87
|
+
requiresRestart: true
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
return handleError(c, error);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
return router;
|
|
95
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { Hono } from 'hono';
|
|
3
|
+
import { TERMINAL_BUFFER_LIMIT } from '../config.js';
|
|
4
|
+
import { createHttpError, handleError, readJson } from '../utils/error.js';
|
|
5
|
+
import { getDefaultShell } from '../utils/shell.js';
|
|
6
|
+
import { saveTerminal, deleteTerminal as deleteTerminalFromDb } from '../utils/database.js';
|
|
7
|
+
// Track terminal index per deck for unique naming
|
|
8
|
+
const deckTerminalCounters = new Map();
|
|
9
|
+
export function createTerminalRouter(db, decks, terminals, ptyClient) {
|
|
10
|
+
const router = new Hono();
|
|
11
|
+
function appendToTerminalBuffer(session, data) {
|
|
12
|
+
const newBuffer = session.buffer + data;
|
|
13
|
+
session.buffer =
|
|
14
|
+
newBuffer.length > TERMINAL_BUFFER_LIMIT
|
|
15
|
+
? newBuffer.slice(newBuffer.length - TERMINAL_BUFFER_LIMIT)
|
|
16
|
+
: newBuffer;
|
|
17
|
+
}
|
|
18
|
+
function getNextTerminalIndex(deckId) {
|
|
19
|
+
const current = deckTerminalCounters.get(deckId) ?? 0;
|
|
20
|
+
const next = current + 1;
|
|
21
|
+
deckTerminalCounters.set(deckId, next);
|
|
22
|
+
return next;
|
|
23
|
+
}
|
|
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();
|
|
31
|
+
const deadSockets = new Set();
|
|
32
|
+
session.sockets.forEach((socket) => {
|
|
33
|
+
try {
|
|
34
|
+
if (socket.readyState === 1) {
|
|
35
|
+
socket.send(data);
|
|
36
|
+
}
|
|
37
|
+
else if (socket.readyState > 1) {
|
|
38
|
+
deadSockets.add(socket);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
deadSockets.add(socket);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
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) => {
|
|
49
|
+
const session = terminals.get(id);
|
|
50
|
+
if (!session)
|
|
51
|
+
return;
|
|
52
|
+
console.log(`[TERMINAL] Terminal ${id} exited`);
|
|
53
|
+
terminals.delete(id);
|
|
54
|
+
deleteTerminalFromDb(db, id);
|
|
55
|
+
session.sockets.forEach((socket) => {
|
|
56
|
+
try {
|
|
57
|
+
socket.close(1000, 'Terminal exited');
|
|
58
|
+
}
|
|
59
|
+
catch { /* ignore */ }
|
|
60
|
+
});
|
|
61
|
+
session.sockets.clear();
|
|
62
|
+
});
|
|
63
|
+
async function createTerminalSession(deck, title, command, options) {
|
|
64
|
+
const id = options?.id || crypto.randomUUID();
|
|
65
|
+
// Resolve shell and arguments
|
|
66
|
+
let shell;
|
|
67
|
+
let shellArgs = [];
|
|
68
|
+
if (command) {
|
|
69
|
+
const defaultShell = getDefaultShell();
|
|
70
|
+
shell = defaultShell;
|
|
71
|
+
if (process.platform === 'win32') {
|
|
72
|
+
if (defaultShell.toLowerCase().includes('powershell')) {
|
|
73
|
+
shellArgs = ['-NoExit', '-Command', command];
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
shellArgs = ['/K', command];
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
shellArgs = ['-c', command];
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
shell = getDefaultShell();
|
|
85
|
+
}
|
|
86
|
+
// Build environment
|
|
87
|
+
const env = {};
|
|
88
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
89
|
+
if (value !== undefined)
|
|
90
|
+
env[key] = value;
|
|
91
|
+
}
|
|
92
|
+
env.TERM = env.TERM || 'xterm-256color';
|
|
93
|
+
env.COLORTERM = 'truecolor';
|
|
94
|
+
env.TERM_PROGRAM = 'xterm.js';
|
|
95
|
+
env.TERM_PROGRAM_VERSION = '5.0.0';
|
|
96
|
+
if (process.platform === 'win32') {
|
|
97
|
+
env.LANG = 'en_US.UTF-8';
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
env.LANG = env.LANG || 'en_US.UTF-8';
|
|
101
|
+
}
|
|
102
|
+
env.LC_ALL = env.LC_ALL || 'en_US.UTF-8';
|
|
103
|
+
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}`);
|
|
107
|
+
const resolvedTitle = title || `Terminal ${getNextTerminalIndex(deck.id)}`;
|
|
108
|
+
const createdAt = new Date().toISOString();
|
|
109
|
+
const session = {
|
|
110
|
+
id,
|
|
111
|
+
deckId: deck.id,
|
|
112
|
+
title: resolvedTitle,
|
|
113
|
+
command: command || null,
|
|
114
|
+
createdAt,
|
|
115
|
+
sockets: new Set(),
|
|
116
|
+
buffer: options?.initialBuffer || '',
|
|
117
|
+
lastActive: Date.now(),
|
|
118
|
+
write: (data) => ptyClient.input(id, data),
|
|
119
|
+
resize: (cols, rows) => ptyClient.resize(id, cols, rows),
|
|
120
|
+
kill: () => ptyClient.kill(id),
|
|
121
|
+
};
|
|
122
|
+
if (!options?.skipDbSave) {
|
|
123
|
+
saveTerminal(db, id, deck.id, resolvedTitle, command || null, createdAt);
|
|
124
|
+
}
|
|
125
|
+
terminals.set(id, session);
|
|
126
|
+
// Subscribe to live output from daemon (delta since initialBuffer)
|
|
127
|
+
ptyClient.attach(id, options?.initialBuffer?.length ?? 0);
|
|
128
|
+
return session;
|
|
129
|
+
}
|
|
130
|
+
router.get('/', (c) => {
|
|
131
|
+
const deckId = c.req.query('deckId');
|
|
132
|
+
if (!deckId) {
|
|
133
|
+
return c.json({ error: 'deckId is required' }, 400);
|
|
134
|
+
}
|
|
135
|
+
const sessions = [];
|
|
136
|
+
terminals.forEach((session) => {
|
|
137
|
+
if (session.deckId === deckId) {
|
|
138
|
+
sessions.push({ id: session.id, title: session.title, createdAt: session.createdAt });
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
sessions.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
142
|
+
return c.json(sessions);
|
|
143
|
+
});
|
|
144
|
+
router.post('/', async (c) => {
|
|
145
|
+
try {
|
|
146
|
+
const body = await readJson(c);
|
|
147
|
+
const deckId = body?.deckId;
|
|
148
|
+
if (!deckId)
|
|
149
|
+
throw createHttpError('deckId is required', 400);
|
|
150
|
+
const deck = decks.get(deckId);
|
|
151
|
+
if (!deck)
|
|
152
|
+
throw createHttpError('Deck not found', 404);
|
|
153
|
+
const session = await createTerminalSession(deck, body?.title, body?.command);
|
|
154
|
+
return c.json({ id: session.id, title: session.title }, 201);
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
return handleError(c, error);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
router.delete('/:id', async (c) => {
|
|
161
|
+
try {
|
|
162
|
+
const terminalId = c.req.param('id');
|
|
163
|
+
const session = terminals.get(terminalId);
|
|
164
|
+
if (!session)
|
|
165
|
+
throw createHttpError('Terminal not found', 404);
|
|
166
|
+
terminals.delete(terminalId);
|
|
167
|
+
deleteTerminalFromDb(db, terminalId);
|
|
168
|
+
session.sockets.forEach((socket) => {
|
|
169
|
+
try {
|
|
170
|
+
socket.close(1000, 'Terminal deleted');
|
|
171
|
+
}
|
|
172
|
+
catch { /* ignore */ }
|
|
173
|
+
});
|
|
174
|
+
session.sockets.clear();
|
|
175
|
+
// Kill PTY in daemon
|
|
176
|
+
session.kill();
|
|
177
|
+
return c.body(null, 204);
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
return handleError(c, error);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
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 };
|
|
239
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { Hono } from 'hono';
|
|
3
|
+
import { DEFAULT_ROOT } from '../config.js';
|
|
4
|
+
import { createHttpError, handleError, readJson } from '../utils/error.js';
|
|
5
|
+
import { normalizeWorkspacePath, getWorkspaceKey, getWorkspaceName } from '../utils/path.js';
|
|
6
|
+
const MAX_NAME_LENGTH = 100;
|
|
7
|
+
const NAME_PATTERN = /^[\p{L}\p{N}\s\-_.]+$/u; // Unicode letters, numbers, spaces, hyphens, underscores, dots
|
|
8
|
+
function validateName(name) {
|
|
9
|
+
if (!name) {
|
|
10
|
+
return undefined;
|
|
11
|
+
}
|
|
12
|
+
if (typeof name !== 'string') {
|
|
13
|
+
throw createHttpError('name must be a string', 400);
|
|
14
|
+
}
|
|
15
|
+
const trimmed = name.trim();
|
|
16
|
+
if (trimmed.length === 0) {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
if (trimmed.length > MAX_NAME_LENGTH) {
|
|
20
|
+
throw createHttpError(`name is too long (max: ${MAX_NAME_LENGTH} characters)`, 400);
|
|
21
|
+
}
|
|
22
|
+
if (!NAME_PATTERN.test(trimmed)) {
|
|
23
|
+
throw createHttpError('name contains invalid characters', 400);
|
|
24
|
+
}
|
|
25
|
+
return trimmed;
|
|
26
|
+
}
|
|
27
|
+
export function createWorkspaceRouter(db, workspaces, workspacePathIndex) {
|
|
28
|
+
const router = new Hono();
|
|
29
|
+
const insertWorkspace = db.prepare('INSERT INTO workspaces (id, name, path, normalized_path, created_at) VALUES (?, ?, ?, ?, ?)');
|
|
30
|
+
function createWorkspace(inputPath, name) {
|
|
31
|
+
const resolvedPath = normalizeWorkspacePath(inputPath);
|
|
32
|
+
const key = getWorkspaceKey(resolvedPath);
|
|
33
|
+
if (workspacePathIndex.has(key)) {
|
|
34
|
+
throw createHttpError('Workspace path already exists', 409);
|
|
35
|
+
}
|
|
36
|
+
const validatedName = validateName(name);
|
|
37
|
+
const workspace = {
|
|
38
|
+
id: crypto.randomUUID(),
|
|
39
|
+
name: validatedName || getWorkspaceName(resolvedPath, workspaces.size + 1),
|
|
40
|
+
path: resolvedPath,
|
|
41
|
+
createdAt: new Date().toISOString()
|
|
42
|
+
};
|
|
43
|
+
insertWorkspace.run(workspace.id, workspace.name, workspace.path, key, workspace.createdAt);
|
|
44
|
+
workspaces.set(workspace.id, workspace);
|
|
45
|
+
workspacePathIndex.set(key, workspace.id);
|
|
46
|
+
return workspace;
|
|
47
|
+
}
|
|
48
|
+
router.get('/', (c) => {
|
|
49
|
+
return c.json(Array.from(workspaces.values()));
|
|
50
|
+
});
|
|
51
|
+
router.post('/', async (c) => {
|
|
52
|
+
try {
|
|
53
|
+
const body = await readJson(c);
|
|
54
|
+
if (!body?.path) {
|
|
55
|
+
throw createHttpError('path is required', 400);
|
|
56
|
+
}
|
|
57
|
+
const workspace = createWorkspace(body.path, body.name);
|
|
58
|
+
return c.json(workspace, 201);
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
return handleError(c, error);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
return router;
|
|
65
|
+
}
|
|
66
|
+
export function getConfigHandler() {
|
|
67
|
+
return (c) => {
|
|
68
|
+
try {
|
|
69
|
+
return c.json({ defaultRoot: normalizeWorkspacePath(DEFAULT_ROOT) });
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
console.error('Failed to get config:', error);
|
|
73
|
+
return c.json({ defaultRoot: '' });
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
export function requireWorkspace(workspaces, workspaceId) {
|
|
78
|
+
const workspace = workspaces.get(workspaceId);
|
|
79
|
+
if (!workspace) {
|
|
80
|
+
throw createHttpError('Workspace not found', 404);
|
|
81
|
+
}
|
|
82
|
+
return workspace;
|
|
83
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import fsSync from 'node:fs';
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import childProcess from 'node:child_process';
|
|
6
|
+
import { Hono } from 'hono';
|
|
7
|
+
import { serve } from '@hono/node-server';
|
|
8
|
+
import { serveStatic } from '@hono/node-server/serve-static';
|
|
9
|
+
import { bodyLimit } from 'hono/body-limit';
|
|
10
|
+
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';
|
|
12
|
+
import { securityHeaders } from './middleware/security.js';
|
|
13
|
+
import { corsMiddleware } from './middleware/cors.js';
|
|
14
|
+
import { basicAuthMiddleware, generateWsToken, isBasicAuthEnabled } from './middleware/auth.js';
|
|
15
|
+
import { checkDatabaseIntegrity, handleDatabaseCorruption, initializeDatabase, loadPersistedState, loadPersistedTerminals, } from './utils/database.js';
|
|
16
|
+
import { createWorkspaceRouter, getConfigHandler } from './routes/workspaces.js';
|
|
17
|
+
import { createDeckRouter } from './routes/decks.js';
|
|
18
|
+
import { createFileRouter } from './routes/files.js';
|
|
19
|
+
import { createTerminalRouter } from './routes/terminals.js';
|
|
20
|
+
import { createGitRouter } from './routes/git.js';
|
|
21
|
+
import { createSettingsRouter } from './routes/settings.js';
|
|
22
|
+
import { setupWebSocketServer, getConnectionLimit, setConnectionLimit, getConnectionStats, clearAllConnections, } from './websocket.js';
|
|
23
|
+
import { PtyClient } from './pty-client.js';
|
|
24
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
25
|
+
// Request ID and logging middleware
|
|
26
|
+
const requestIdMiddleware = async (c, next) => {
|
|
27
|
+
const requestId = c.req.header('x-request-id') || crypto.randomUUID().slice(0, 8);
|
|
28
|
+
c.set('requestId', requestId);
|
|
29
|
+
c.header('X-Request-ID', requestId);
|
|
30
|
+
const start = Date.now();
|
|
31
|
+
const method = c.req.method;
|
|
32
|
+
const path_ = c.req.path;
|
|
33
|
+
await next();
|
|
34
|
+
const duration = Date.now() - start;
|
|
35
|
+
const status = c.res.status;
|
|
36
|
+
if (NODE_ENV === 'production' || process.env.DEBUG) {
|
|
37
|
+
console.log(`[${requestId}] ${method} ${path_} ${status} ${duration}ms`);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
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
|
+
export async function createServer() {
|
|
94
|
+
// Check database integrity before opening
|
|
95
|
+
if (fsSync.existsSync(dbPath) && !checkDatabaseIntegrity(dbPath)) {
|
|
96
|
+
handleDatabaseCorruption(dbPath);
|
|
97
|
+
}
|
|
98
|
+
const db = new DatabaseSync(dbPath);
|
|
99
|
+
initializeDatabase(db);
|
|
100
|
+
// Initialize state
|
|
101
|
+
const workspaces = new Map();
|
|
102
|
+
const workspacePathIndex = new Map();
|
|
103
|
+
const decks = new Map();
|
|
104
|
+
const terminals = new Map();
|
|
105
|
+
loadPersistedState(db, workspaces, workspacePathIndex, decks);
|
|
106
|
+
// Start or reconnect to the PTY daemon
|
|
107
|
+
const ptyClient = await ensureDaemon();
|
|
108
|
+
// Create Hono app
|
|
109
|
+
const app = new Hono();
|
|
110
|
+
app.use('*', securityHeaders);
|
|
111
|
+
app.use('*', corsMiddleware);
|
|
112
|
+
app.use('*', requestIdMiddleware);
|
|
113
|
+
app.use('/api/*', bodyLimit({
|
|
114
|
+
maxSize: MAX_REQUEST_BODY_SIZE,
|
|
115
|
+
onError: (c) => c.json({ error: 'Request body too large' }, 413),
|
|
116
|
+
}));
|
|
117
|
+
if (basicAuthMiddleware) {
|
|
118
|
+
app.use('/api/*', basicAuthMiddleware);
|
|
119
|
+
}
|
|
120
|
+
app.get('/health', (c) => c.json({ status: 'ok', timestamp: new Date().toISOString(), uptime: process.uptime() }));
|
|
121
|
+
// Mount routers
|
|
122
|
+
app.route('/api/settings', createSettingsRouter());
|
|
123
|
+
app.route('/api/workspaces', createWorkspaceRouter(db, workspaces, workspacePathIndex));
|
|
124
|
+
app.route('/api/decks', createDeckRouter(db, workspaces, decks));
|
|
125
|
+
const { router: terminalRouter, restoreTerminals } = createTerminalRouter(db, decks, terminals, ptyClient);
|
|
126
|
+
app.route('/api/terminals', terminalRouter);
|
|
127
|
+
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
|
+
app.get('/api/config', getConfigHandler());
|
|
137
|
+
app.get('/api/ws-token', (c) => c.json({ token: generateWsToken(), authEnabled: isBasicAuthEnabled() }));
|
|
138
|
+
app.get('/api/ws/stats', (c) => c.json({ limit: getConnectionLimit(), connections: getConnectionStats() }));
|
|
139
|
+
app.put('/api/ws/limit', async (c) => {
|
|
140
|
+
const body = await c.req.json();
|
|
141
|
+
if (typeof body.limit !== 'number' || body.limit < 1) {
|
|
142
|
+
return c.json({ error: 'Invalid limit value' }, 400);
|
|
143
|
+
}
|
|
144
|
+
setConnectionLimit(body.limit);
|
|
145
|
+
return c.json({ limit: getConnectionLimit() });
|
|
146
|
+
});
|
|
147
|
+
app.post('/api/ws/clear', (c) => {
|
|
148
|
+
const closedCount = clearAllConnections();
|
|
149
|
+
return c.json({ cleared: closedCount });
|
|
150
|
+
});
|
|
151
|
+
const fileRouter = createFileRouter(workspaces);
|
|
152
|
+
app.route('/api', fileRouter);
|
|
153
|
+
if (hasStatic) {
|
|
154
|
+
const serveAssets = serveStatic({ root: distDir });
|
|
155
|
+
const serveIndex = serveStatic({ root: distDir, path: 'index.html' });
|
|
156
|
+
app.use('/assets/*', serveAssets);
|
|
157
|
+
app.get('*', async (c, next) => {
|
|
158
|
+
if (c.req.path.startsWith('/api'))
|
|
159
|
+
return c.text('Not found', 404);
|
|
160
|
+
return serveIndex(c, next);
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
const server = serve({ fetch: app.fetch, port: PORT, hostname: HOST });
|
|
164
|
+
setupWebSocketServer(server, terminals);
|
|
165
|
+
server.on('listening', () => {
|
|
166
|
+
const baseUrl = `http://localhost:${PORT}`;
|
|
167
|
+
console.log(`Deck IDE server listening on ${baseUrl}`);
|
|
168
|
+
console.log(`UI: ${baseUrl}`);
|
|
169
|
+
console.log(`API: ${baseUrl}/api`);
|
|
170
|
+
console.log(`Health: ${baseUrl}/health`);
|
|
171
|
+
console.log('');
|
|
172
|
+
console.log('Security Status:');
|
|
173
|
+
console.log(` - Basic Auth: ${BASIC_AUTH_USER && BASIC_AUTH_PASSWORD ? 'enabled' : 'DISABLED'}`);
|
|
174
|
+
console.log(` - Max File Size: ${Math.round(MAX_FILE_SIZE / 1024 / 1024)}MB`);
|
|
175
|
+
console.log(` - Max Request Body: ${Math.round(MAX_REQUEST_BODY_SIZE / 1024)}KB`);
|
|
176
|
+
console.log(` - Trust Proxy: ${TRUST_PROXY ? 'enabled' : 'disabled'}`);
|
|
177
|
+
console.log(` - CORS Origin: ${CORS_ORIGIN || (NODE_ENV === 'development' ? '*' : 'NOT SET')}`);
|
|
178
|
+
console.log(` - Environment: ${NODE_ENV}`);
|
|
179
|
+
});
|
|
180
|
+
// Graceful shutdown - optionally terminate daemon.
|
|
181
|
+
let shutdownPromise = null;
|
|
182
|
+
let shouldTerminateDaemon = false;
|
|
183
|
+
const onShutdown = (options = {}) => {
|
|
184
|
+
if (options.terminateDaemon) {
|
|
185
|
+
shouldTerminateDaemon = true;
|
|
186
|
+
}
|
|
187
|
+
if (shutdownPromise) {
|
|
188
|
+
return shutdownPromise;
|
|
189
|
+
}
|
|
190
|
+
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');
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
catch (err) {
|
|
199
|
+
console.warn('[SHUTDOWN] Failed to request PTY daemon shutdown:', err);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
ptyClient.destroy();
|
|
203
|
+
try {
|
|
204
|
+
db.close();
|
|
205
|
+
}
|
|
206
|
+
catch { /* ignore */ }
|
|
207
|
+
})();
|
|
208
|
+
return shutdownPromise;
|
|
209
|
+
};
|
|
210
|
+
process.on('SIGINT', () => {
|
|
211
|
+
console.log('\n[SHUTDOWN] Received SIGINT, saving state...');
|
|
212
|
+
void onShutdown({ terminateDaemon: true }).finally(() => process.exit(0));
|
|
213
|
+
});
|
|
214
|
+
process.on('SIGTERM', () => {
|
|
215
|
+
console.log('[SHUTDOWN] Received SIGTERM, saving state...');
|
|
216
|
+
void onShutdown({ terminateDaemon: true }).finally(() => process.exit(0));
|
|
217
|
+
});
|
|
218
|
+
process.on('SIGHUP', () => {
|
|
219
|
+
console.log('[SHUTDOWN] Received SIGHUP, saving state...');
|
|
220
|
+
void onShutdown({ terminateDaemon: true }).finally(() => process.exit(0));
|
|
221
|
+
});
|
|
222
|
+
// HTTP shutdown endpoint — allows cross-platform graceful shutdown from Electron
|
|
223
|
+
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
|
+
setTimeout(() => {
|
|
233
|
+
console.log(`[SHUTDOWN] Shutdown requested via HTTP API${terminateDaemon ? ' (terminate daemon)' : ''}`);
|
|
234
|
+
void onShutdown({ terminateDaemon }).finally(() => process.exit(0));
|
|
235
|
+
}, 50);
|
|
236
|
+
return c.json({ ok: true, terminateDaemon });
|
|
237
|
+
});
|
|
238
|
+
const originalExceptionHandler = process.listeners('uncaughtException')[0];
|
|
239
|
+
process.removeAllListeners('uncaughtException');
|
|
240
|
+
process.on('uncaughtException', (error) => {
|
|
241
|
+
if (error.message?.includes('AttachConsole failed')) {
|
|
242
|
+
console.log('[node-pty] AttachConsole error suppressed');
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
console.error('[SHUTDOWN] Uncaught exception, saving state before exit...');
|
|
246
|
+
void onShutdown({ terminateDaemon: true }).finally(() => {
|
|
247
|
+
if (originalExceptionHandler) {
|
|
248
|
+
originalExceptionHandler(error);
|
|
249
|
+
}
|
|
250
|
+
else {
|
|
251
|
+
console.error('Uncaught exception:', error);
|
|
252
|
+
process.exit(1);
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
return server;
|
|
257
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|