deckide 3.5.15 → 3.5.17

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.
@@ -2,7 +2,7 @@ import crypto from 'node:crypto';
2
2
  import { Hono } from 'hono';
3
3
  import { createHttpError, handleError, readJson } from '../utils/error.js';
4
4
  import { requireWorkspace } from './workspaces.js';
5
- export function createDeckRouter(db, workspaces, decks) {
5
+ export function createDeckRouter(db, workspaces, decks, terminals) {
6
6
  const router = new Hono();
7
7
  const insertDeck = db.prepare('INSERT INTO decks (id, name, root, workspace_id, created_at) VALUES (?, ?, ?, ?, ?)');
8
8
  function createDeck(name, workspaceId) {
@@ -76,12 +76,33 @@ export function createDeckRouter(db, workspaces, decks) {
76
76
  });
77
77
  const deleteDeckStmt = db.prepare('DELETE FROM decks WHERE id = ?');
78
78
  const deleteTerminalsByDeckStmt = db.prepare('DELETE FROM terminals WHERE deck_id = ?');
79
+ function closeDeckTerminalSockets(sockets, reason) {
80
+ sockets.forEach((socket) => {
81
+ try {
82
+ socket.close(1000, reason);
83
+ }
84
+ catch { /* ignore */ }
85
+ });
86
+ sockets.clear();
87
+ }
79
88
  router.delete('/:id', (c) => {
80
89
  try {
81
90
  const deckId = c.req.param('id');
82
91
  if (!decks.has(deckId)) {
83
92
  throw createHttpError('Deck not found', 404);
84
93
  }
94
+ const deckTerminalIds = Array.from(terminals.values())
95
+ .filter((session) => session.deckId === deckId)
96
+ .map((session) => session.id);
97
+ deckTerminalIds.forEach((terminalId) => {
98
+ const session = terminals.get(terminalId);
99
+ if (!session)
100
+ return;
101
+ terminals.delete(terminalId);
102
+ session.resizeOwner = null;
103
+ closeDeckTerminalSockets(session.sockets, 'Deck deleted');
104
+ session.kill();
105
+ });
85
106
  deleteTerminalsByDeckStmt.run(deckId);
86
107
  deleteDeckStmt.run(deckId);
87
108
  decks.delete(deckId);
@@ -5,43 +5,99 @@ import { TERMINAL_BUFFER_LIMIT } from '../config.js';
5
5
  import { createHttpError, handleError, readJson } from '../utils/error.js';
6
6
  import { getDefaultShell } from '../utils/shell.js';
7
7
  import { saveTerminal, deleteTerminal as deleteTerminalFromDb } from '../utils/database.js';
8
- // Track terminal index per deck for unique naming
9
- const deckTerminalCounters = new Map();
8
+ const DEFAULT_TERMINAL_TITLE = 'ターミナル';
9
+ const MAX_SOCKET_BUFFERED_AMOUNT = 1024 * 1024;
10
10
  export function createTerminalRouter(db, decks, terminals) {
11
11
  const router = new Hono();
12
+ function toBuffer(data) {
13
+ return Buffer.isBuffer(data) ? data : Buffer.from(data, 'utf8');
14
+ }
12
15
  function appendToTerminalBuffer(session, data) {
13
- const newBuffer = session.buffer + data;
14
- if (newBuffer.length > TERMINAL_BUFFER_LIMIT) {
15
- const excess = newBuffer.length - TERMINAL_BUFFER_LIMIT;
16
- session.buffer = newBuffer.slice(excess);
17
- session.bufferBase += excess;
16
+ const chunk = toBuffer(data);
17
+ if (chunk.length === 0) {
18
+ return;
18
19
  }
19
- else {
20
- session.buffer = newBuffer;
20
+ if (chunk.length >= TERMINAL_BUFFER_LIMIT) {
21
+ const retainedChunk = Buffer.from(chunk.subarray(chunk.length - TERMINAL_BUFFER_LIMIT));
22
+ session.bufferBase += session.bufferLength + (chunk.length - TERMINAL_BUFFER_LIMIT);
23
+ session.bufferChunks = [retainedChunk];
24
+ session.bufferLength = retainedChunk.length;
25
+ return;
26
+ }
27
+ session.bufferChunks.push(Buffer.from(chunk));
28
+ session.bufferLength += chunk.length;
29
+ while (session.bufferLength > TERMINAL_BUFFER_LIMIT && session.bufferChunks.length > 0) {
30
+ const overflow = session.bufferLength - TERMINAL_BUFFER_LIMIT;
31
+ const firstChunk = session.bufferChunks[0];
32
+ if (firstChunk.length <= overflow) {
33
+ session.bufferChunks.shift();
34
+ session.bufferBase += firstChunk.length;
35
+ session.bufferLength -= firstChunk.length;
36
+ continue;
37
+ }
38
+ session.bufferChunks[0] = Buffer.from(firstChunk.subarray(overflow));
39
+ session.bufferBase += overflow;
40
+ session.bufferLength -= overflow;
21
41
  }
22
42
  }
23
- function getNextTerminalIndex(deckId) {
24
- const current = deckTerminalCounters.get(deckId) ?? 0;
25
- const next = current + 1;
26
- deckTerminalCounters.set(deckId, next);
27
- return next;
43
+ function getUniqueTerminalTitle(deckId, requestedTitle) {
44
+ const trimmedTitle = requestedTitle?.trim();
45
+ const baseTitle = trimmedTitle && trimmedTitle.length > 0 ? trimmedTitle : null;
46
+ const existingTitles = new Set(Array.from(terminals.values())
47
+ .filter((session) => session.deckId === deckId)
48
+ .map((session) => session.title));
49
+ if (!baseTitle) {
50
+ let index = 1;
51
+ while (existingTitles.has(`${DEFAULT_TERMINAL_TITLE} ${index}`)) {
52
+ index++;
53
+ }
54
+ return `${DEFAULT_TERMINAL_TITLE} ${index}`;
55
+ }
56
+ if (!existingTitles.has(baseTitle)) {
57
+ return baseTitle;
58
+ }
59
+ let suffix = 2;
60
+ while (existingTitles.has(`${baseTitle} ${suffix}`)) {
61
+ suffix++;
62
+ }
63
+ return `${baseTitle} ${suffix}`;
28
64
  }
29
65
  function broadcastToSockets(session, data) {
66
+ const payload = toBuffer(data);
30
67
  const deadSockets = new Set();
31
68
  session.sockets.forEach((socket) => {
32
69
  try {
33
- if (socket.readyState === 1) {
34
- socket.send(data);
70
+ if (socket.readyState !== 1) {
71
+ deadSockets.add(socket);
72
+ return;
35
73
  }
36
- else if (socket.readyState > 1) {
74
+ if (socket.bufferedAmount > MAX_SOCKET_BUFFERED_AMOUNT) {
75
+ try {
76
+ socket.close(1009, 'Terminal output overflow');
77
+ }
78
+ catch { /* ignore */ }
37
79
  deadSockets.add(socket);
80
+ return;
38
81
  }
82
+ socket.send(payload, { binary: true }, (error) => {
83
+ if (error) {
84
+ try {
85
+ socket.close(1011, 'Terminal stream error');
86
+ }
87
+ catch { /* ignore */ }
88
+ }
89
+ });
39
90
  }
40
91
  catch {
41
92
  deadSockets.add(socket);
42
93
  }
43
94
  });
44
- deadSockets.forEach((s) => session.sockets.delete(s));
95
+ deadSockets.forEach((s) => {
96
+ session.sockets.delete(s);
97
+ if (session.resizeOwner === s) {
98
+ session.resizeOwner = null;
99
+ }
100
+ });
45
101
  }
46
102
  function handleTerminalExit(id) {
47
103
  const session = terminals.get(id);
@@ -57,6 +113,7 @@ export function createTerminalRouter(db, decks, terminals) {
57
113
  catch { /* ignore */ }
58
114
  });
59
115
  session.sockets.clear();
116
+ session.resizeOwner = null;
60
117
  }
61
118
  function createTerminalSession(deck, title, command) {
62
119
  const id = crypto.randomUUID();
@@ -106,11 +163,11 @@ export function createTerminalRouter(db, decks, terminals) {
106
163
  cols: 120,
107
164
  rows: 32,
108
165
  env,
109
- encoding: 'utf8',
166
+ encoding: null,
110
167
  ...(isWindows ? { useConpty: true } : {}),
111
168
  });
112
169
  console.log(`[TERMINAL] Created terminal ${id}: shell=${shell}, cwd=${deck.root}, pid=${term.pid}`);
113
- const resolvedTitle = title || `Terminal ${getNextTerminalIndex(deck.id)}`;
170
+ const resolvedTitle = getUniqueTerminalTitle(deck.id, title);
114
171
  const createdAt = new Date().toISOString();
115
172
  const session = {
116
173
  id,
@@ -119,13 +176,18 @@ export function createTerminalRouter(db, decks, terminals) {
119
176
  command: command || null,
120
177
  createdAt,
121
178
  sockets: new Set(),
122
- buffer: '',
179
+ resizeOwner: null,
180
+ bufferChunks: [],
181
+ bufferLength: 0,
123
182
  bufferBase: 0,
124
183
  lastActive: Date.now(),
125
- write: (data) => { try {
126
- term.write(data);
127
- }
128
- catch { /* terminal may be dying */ } },
184
+ write: (data) => {
185
+ const payload = Buffer.isBuffer(data) ? data : Buffer.from(data);
186
+ try {
187
+ term.write(payload);
188
+ }
189
+ catch { /* terminal may be dying */ }
190
+ },
129
191
  resize: (cols, rows) => { try {
130
192
  term.resize(cols, rows);
131
193
  }
@@ -136,7 +198,7 @@ export function createTerminalRouter(db, decks, terminals) {
136
198
  catch { /* already dead */ } },
137
199
  };
138
200
  // Wire up PTY output → buffer + WebSocket broadcast
139
- term.onData((data) => {
201
+ term.on('data', (data) => {
140
202
  appendToTerminalBuffer(session, data);
141
203
  session.lastActive = Date.now();
142
204
  broadcastToSockets(session, data);
@@ -193,6 +255,7 @@ export function createTerminalRouter(db, decks, terminals) {
193
255
  catch { /* ignore */ }
194
256
  });
195
257
  session.sockets.clear();
258
+ session.resizeOwner = null;
196
259
  session.kill();
197
260
  return c.body(null, 204);
198
261
  }
package/dist/server.js CHANGED
@@ -65,7 +65,7 @@ export async function createServer() {
65
65
  // Mount routers
66
66
  app.route('/api/settings', createSettingsRouter());
67
67
  app.route('/api/workspaces', createWorkspaceRouter(db, workspaces, workspacePathIndex));
68
- app.route('/api/decks', createDeckRouter(db, workspaces, decks));
68
+ app.route('/api/decks', createDeckRouter(db, workspaces, decks, terminals));
69
69
  const terminalRouter = createTerminalRouter(db, decks, terminals);
70
70
  app.route('/api/terminals', terminalRouter);
71
71
  app.route('/api/git', createGitRouter(workspaces));
package/dist/websocket.js CHANGED
@@ -1,11 +1,13 @@
1
1
  import crypto from 'node:crypto';
2
- import { WebSocketServer } from 'ws';
2
+ import { WebSocketServer, WebSocket } from 'ws';
3
3
  import { PORT, TRUST_PROXY, CORS_ORIGIN, NODE_ENV } from './config.js';
4
4
  import { logSecurityEvent } from './middleware/security.js';
5
5
  import { verifyWebSocketAuth } from './middleware/auth.js';
6
6
  const MIN_TERMINAL_SIZE = 1;
7
7
  const MAX_TERMINAL_SIZE = 500;
8
8
  const MAX_MESSAGE_SIZE = 64 * 1024; // 64KB max message size
9
+ const MAX_SOCKET_BUFFERED_AMOUNT = 1024 * 1024;
10
+ const HEARTBEAT_INTERVAL_MS = 30_000;
9
11
  // Configurable connection limit per IP (default: 1000)
10
12
  let maxConnectionsPerIP = 1000;
11
13
  // Track connections per IP
@@ -75,6 +77,8 @@ function untrackConnection(ip, socket) {
75
77
  }
76
78
  }
77
79
  function validateTerminalSize(value) {
80
+ if (value == null)
81
+ return MIN_TERMINAL_SIZE;
78
82
  const intValue = Math.floor(value);
79
83
  if (!Number.isFinite(intValue) || intValue < MIN_TERMINAL_SIZE) {
80
84
  return MIN_TERMINAL_SIZE;
@@ -84,8 +88,113 @@ function validateTerminalSize(value) {
84
88
  }
85
89
  return intValue;
86
90
  }
91
+ function rawDataByteLength(data) {
92
+ if (typeof data === 'string') {
93
+ return Buffer.byteLength(data, 'utf8');
94
+ }
95
+ if (Array.isArray(data)) {
96
+ return data.reduce((total, chunk) => total + chunk.length, 0);
97
+ }
98
+ if (data instanceof ArrayBuffer) {
99
+ return data.byteLength;
100
+ }
101
+ return data.length;
102
+ }
103
+ function rawDataToBuffer(data) {
104
+ if (typeof data === 'string') {
105
+ return Buffer.from(data, 'utf8');
106
+ }
107
+ if (Array.isArray(data)) {
108
+ return Buffer.concat(data);
109
+ }
110
+ if (data instanceof ArrayBuffer) {
111
+ return Buffer.from(data);
112
+ }
113
+ // data is Buffer
114
+ return Buffer.isBuffer(data) ? data : Buffer.from(data);
115
+ }
116
+ function canSendToSocket(socket) {
117
+ if (socket.readyState !== WebSocket.OPEN) {
118
+ return false;
119
+ }
120
+ if (socket.bufferedAmount > MAX_SOCKET_BUFFERED_AMOUNT) {
121
+ try {
122
+ socket.close(1009, 'Terminal output overflow');
123
+ }
124
+ catch { /* ignore */ }
125
+ return false;
126
+ }
127
+ return true;
128
+ }
129
+ function sendControl(socket, message) {
130
+ if (!canSendToSocket(socket)) {
131
+ return false;
132
+ }
133
+ try {
134
+ socket.send(JSON.stringify(message));
135
+ return true;
136
+ }
137
+ catch {
138
+ try {
139
+ socket.close(1011, 'Terminal control send failed');
140
+ }
141
+ catch { /* ignore */ }
142
+ return false;
143
+ }
144
+ }
145
+ function readBufferedRange(session, startOffset, endOffset) {
146
+ const relativeStart = Math.max(0, startOffset - session.bufferBase);
147
+ const relativeEnd = Math.max(relativeStart, Math.min(session.bufferLength, endOffset - session.bufferBase));
148
+ const totalLength = relativeEnd - relativeStart;
149
+ if (totalLength <= 0 || session.bufferChunks.length === 0) {
150
+ return Buffer.alloc(0);
151
+ }
152
+ if (session.bufferChunks.length === 1) {
153
+ return session.bufferChunks[0].subarray(relativeStart, relativeEnd);
154
+ }
155
+ const slices = [];
156
+ let traversed = 0;
157
+ for (const chunk of session.bufferChunks) {
158
+ const chunkStart = traversed;
159
+ const chunkEnd = traversed + chunk.length;
160
+ traversed = chunkEnd;
161
+ if (chunkEnd <= relativeStart) {
162
+ continue;
163
+ }
164
+ if (chunkStart >= relativeEnd) {
165
+ break;
166
+ }
167
+ const startInChunk = Math.max(0, relativeStart - chunkStart);
168
+ const endInChunk = Math.min(chunk.length, relativeEnd - chunkStart);
169
+ slices.push(chunk.subarray(startInChunk, endInChunk));
170
+ }
171
+ return slices.length === 1 ? slices[0] : Buffer.concat(slices, totalLength);
172
+ }
87
173
  export function setupWebSocketServer(server, terminals) {
88
- const wss = new WebSocketServer({ server });
174
+ const wss = new WebSocketServer({ server, maxPayload: MAX_MESSAGE_SIZE });
175
+ const heartbeatState = new WeakMap();
176
+ const heartbeatInterval = setInterval(() => {
177
+ for (const socket of wss.clients) {
178
+ if (heartbeatState.get(socket) === false) {
179
+ try {
180
+ socket.terminate();
181
+ }
182
+ catch { /* ignore */ }
183
+ continue;
184
+ }
185
+ heartbeatState.set(socket, false);
186
+ try {
187
+ socket.ping();
188
+ }
189
+ catch {
190
+ try {
191
+ socket.terminate();
192
+ }
193
+ catch { /* ignore */ }
194
+ }
195
+ }
196
+ }, HEARTBEAT_INTERVAL_MS);
197
+ heartbeatInterval.unref?.();
89
198
  const WS_ALLOWED_ORIGINS = new Set([
90
199
  `http://localhost:${PORT}`,
91
200
  ]);
@@ -101,6 +210,7 @@ export function setupWebSocketServer(server, terminals) {
101
210
  wss.on('connection', (socket, req) => {
102
211
  const socketId = crypto.randomUUID();
103
212
  const clientIP = getClientIP(req);
213
+ heartbeatState.set(socket, true);
104
214
  // Validate Origin header to prevent Cross-Site WebSocket Hijacking
105
215
  // Skip check if CORS_ORIGIN is '*' or unset in development mode
106
216
  const skipOriginCheck = CORS_ORIGIN === '*' || (!CORS_ORIGIN && NODE_ENV !== 'production');
@@ -120,6 +230,9 @@ export function setupWebSocketServer(server, terminals) {
120
230
  // Socket might already be closed
121
231
  }
122
232
  });
233
+ socket.on('pong', () => {
234
+ heartbeatState.set(socket, true);
235
+ });
123
236
  // Check connection limit per IP
124
237
  if (!trackConnection(clientIP, socket)) {
125
238
  logSecurityEvent('WS_CONNECTION_LIMIT_EXCEEDED', { ip: clientIP });
@@ -146,67 +259,111 @@ export function setupWebSocketServer(server, terminals) {
146
259
  socket.close(1000, 'Terminal not found');
147
260
  return;
148
261
  }
262
+ const hadNoSockets = session.sockets.size === 0;
149
263
  session.sockets.add(socket);
264
+ if (hadNoSockets && !session.resizeOwner) {
265
+ session.resizeOwner = socket;
266
+ }
150
267
  session.lastActive = Date.now();
151
268
  // Send buffer content if available
152
- // bufferOffset: absolute character count the client already received
153
- // bufferBase: absolute position of buffer[0] (chars dropped from start)
269
+ // bufferOffset: absolute byte count the client already processed
270
+ // bufferBase: absolute byte position of buffer[0] (bytes dropped from start)
154
271
  const offsetParam = url.searchParams.get('bufferOffset');
155
272
  const clientOffset = offsetParam ? Math.max(0, parseInt(offsetParam, 10) || 0) : 0;
156
- if (session.buffer) {
273
+ const bufferStart = session.bufferBase;
274
+ const bufferEnd = session.bufferBase + session.bufferLength;
275
+ let replayStartOffset = clientOffset;
276
+ let resetTerminal = false;
277
+ if (clientOffset <= bufferStart) {
278
+ replayStartOffset = bufferStart;
279
+ resetTerminal = session.bufferLength > 0;
280
+ }
281
+ else if (clientOffset >= bufferEnd) {
282
+ replayStartOffset = bufferEnd;
283
+ }
284
+ if (!sendControl(socket, { type: 'sync', offsetBase: replayStartOffset, reset: resetTerminal })) {
285
+ return;
286
+ }
287
+ if (session.bufferLength > 0) {
157
288
  try {
158
- const bufferStart = session.bufferBase;
159
- const bufferEnd = session.bufferBase + session.buffer.length;
160
- let bufferToSend;
289
+ let bufferToSend = Buffer.alloc(0);
161
290
  if (clientOffset <= bufferStart) {
162
291
  // Client's last position is before (or at) what we have — send everything
163
- bufferToSend = session.buffer;
292
+ bufferToSend = readBufferedRange(session, bufferStart, bufferEnd);
164
293
  }
165
294
  else if (clientOffset >= bufferEnd) {
166
295
  // Client is fully up to date
167
- bufferToSend = '';
296
+ bufferToSend = Buffer.alloc(0);
168
297
  }
169
298
  else {
170
299
  // Send only the delta
171
- bufferToSend = session.buffer.slice(clientOffset - bufferStart);
300
+ bufferToSend = readBufferedRange(session, clientOffset, bufferEnd);
172
301
  }
173
- if (bufferToSend) {
174
- socket.send(bufferToSend);
302
+ if (bufferToSend.length > 0 && canSendToSocket(socket)) {
303
+ socket.send(bufferToSend, { binary: true });
175
304
  }
176
305
  }
177
306
  catch (error) {
178
307
  console.error(`Failed to send buffer to socket ${socketId}:`, error);
179
308
  }
180
309
  }
181
- socket.on('message', (data) => {
310
+ sendControl(socket, { type: 'ready' });
311
+ socket.on('message', (data, isBinary) => {
182
312
  try {
183
- // Convert to string
184
- const message = data.toString('utf8');
185
- // Check message size
186
- const messageSize = Buffer.byteLength(message, 'utf8');
313
+ const messageSize = rawDataByteLength(data);
187
314
  if (messageSize > MAX_MESSAGE_SIZE) {
188
315
  logSecurityEvent('WS_MESSAGE_TOO_LARGE', { ip: clientIP, size: messageSize });
189
- socket.send('\r\n\x1b[31mMessage too large. Maximum size is 64KB.\x1b[0m\r\n');
316
+ socket.close(1009, 'Message too large');
190
317
  return;
191
318
  }
192
319
  session.lastActive = Date.now();
193
- // Check for resize message
194
- if (message.startsWith('\u0000resize:')) {
195
- const payload = message.slice('\u0000resize:'.length);
196
- const [colsRaw, rowsRaw] = payload.split(',');
197
- const cols = validateTerminalSize(Number(colsRaw));
198
- const rows = validateTerminalSize(Number(rowsRaw));
320
+ // Text frames carry JSON control messages (claim, resize).
321
+ // Note: the ws library delivers text frames as Buffer by default,
322
+ // so we use the isBinary flag rather than typeof === 'string'.
323
+ if (!isBinary) {
324
+ const text = typeof data === 'string'
325
+ ? data
326
+ : Array.isArray(data)
327
+ ? Buffer.concat(data).toString('utf8')
328
+ : Buffer.from(data).toString('utf8');
329
+ let control = null;
199
330
  try {
200
- session.resize(cols, rows);
331
+ control = JSON.parse(text);
332
+ }
333
+ catch {
334
+ control = null;
201
335
  }
202
- catch (resizeError) {
203
- console.error(`Failed to resize terminal ${id}:`, resizeError);
336
+ if (control?.type === 'claim') {
337
+ session.resizeOwner = socket;
338
+ return;
339
+ }
340
+ if (control?.type === 'resize') {
341
+ if (session.resizeOwner && session.resizeOwner !== socket) {
342
+ return;
343
+ }
344
+ session.resizeOwner = socket;
345
+ const cols = validateTerminalSize(control.cols);
346
+ const rows = validateTerminalSize(control.rows);
347
+ try {
348
+ session.resize(cols, rows);
349
+ }
350
+ catch (resizeError) {
351
+ console.error(`Failed to resize terminal ${id}:`, resizeError);
352
+ }
353
+ return;
354
+ }
355
+ // Unknown text frame — still forward to terminal for compatibility
356
+ try {
357
+ session.write(Buffer.from(text, 'utf8'));
358
+ }
359
+ catch (writeError) {
360
+ console.error(`Failed to write text input to terminal ${id}:`, writeError);
204
361
  }
205
362
  return;
206
363
  }
364
+ // Binary frames carry terminal input (keystrokes, paste, etc.)
207
365
  try {
208
- // Write user input to terminal
209
- session.write(message);
366
+ session.write(rawDataToBuffer(data));
210
367
  }
211
368
  catch (writeError) {
212
369
  console.error(`Failed to write to terminal ${id}:`, writeError);
@@ -218,12 +375,19 @@ export function setupWebSocketServer(server, terminals) {
218
375
  });
219
376
  socket.on('close', () => {
220
377
  session.sockets.delete(socket);
378
+ if (session.resizeOwner === socket) {
379
+ session.resizeOwner = null;
380
+ }
221
381
  session.lastActive = Date.now();
382
+ heartbeatState.delete(socket);
222
383
  untrackConnection(clientIP, socket);
223
384
  });
224
385
  });
225
386
  wss.on('error', (error) => {
226
387
  console.error('WebSocket server error:', error);
227
388
  });
389
+ wss.on('close', () => {
390
+ clearInterval(heartbeatInterval);
391
+ });
228
392
  return wss;
229
393
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deckide",
3
- "version": "3.5.15",
3
+ "version": "3.5.17",
4
4
  "description": "Deck IDE - Browser-based IDE with terminal, file explorer, and git integration",
5
5
  "type": "module",
6
6
  "bin": {