@atercates/claude-deck 0.2.14 → 0.2.16

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.
@@ -388,6 +388,72 @@ install_ripgrep() {
388
388
  esac
389
389
  }
390
390
 
391
+ check_cloudflared() {
392
+ if command -v cloudflared &> /dev/null; then
393
+ local cf_path=$(command -v cloudflared)
394
+ local cf_version=$(cloudflared --version 2>/dev/null | awk '{print $2}' || echo "unknown")
395
+ log_success "Found cloudflared $cf_version at $cf_path"
396
+ return 0
397
+ fi
398
+
399
+ source_node_managers
400
+
401
+ if command -v cloudflared &> /dev/null; then
402
+ local cf_path=$(command -v cloudflared)
403
+ local cf_version=$(cloudflared --version 2>/dev/null | awk '{print $2}' || echo "unknown")
404
+ log_success "Found cloudflared $cf_version at $cf_path"
405
+ return 0
406
+ fi
407
+
408
+ return 1
409
+ }
410
+
411
+ install_cloudflared() {
412
+ if command -v cloudflared &> /dev/null; then
413
+ return 0
414
+ fi
415
+
416
+ log_info "Installing cloudflared (Cloudflare Tunnel)..."
417
+
418
+ case "$OS" in
419
+ macos)
420
+ if ! groups | grep -q admin; then
421
+ log_info "Non-admin user - downloading pre-built cloudflared binary"
422
+ local arch
423
+ arch=$(uname -m)
424
+ local url
425
+ if [[ "$arch" == "arm64" ]]; then
426
+ url="https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-arm64.tgz"
427
+ else
428
+ url="https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-amd64.tgz"
429
+ fi
430
+
431
+ mkdir -p "$HOME/.local/bin"
432
+ curl -fsSL "$url" | tar -xz -C "$HOME/.local/bin"
433
+ chmod +x "$HOME/.local/bin/cloudflared"
434
+ export PATH="$HOME/.local/bin:$PATH"
435
+ log_success "cloudflared installed to ~/.local/bin/cloudflared"
436
+ else
437
+ install_homebrew
438
+ brew install cloudflared
439
+ fi
440
+ ;;
441
+ debian)
442
+ curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg | sudo tee /usr/share/keyrings/cloudflare-main.gpg >/dev/null
443
+ echo "deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/cloudflared.list
444
+ sudo apt-get update
445
+ sudo apt-get install -y cloudflared
446
+ ;;
447
+ redhat)
448
+ sudo rpm -i "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-x86_64.rpm" 2>/dev/null || true
449
+ ;;
450
+ *)
451
+ log_warn "Please install cloudflared manually: https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup/"
452
+ return 1
453
+ ;;
454
+ esac
455
+ }
456
+
391
457
  check_and_install_prerequisites() {
392
458
  log_info "Checking prerequisites..."
393
459
 
@@ -415,30 +481,39 @@ check_and_install_prerequisites() {
415
481
 
416
482
  if [[ ${#missing[@]} -eq 0 ]]; then
417
483
  log_success "All prerequisites met"
418
- return 0
419
- fi
484
+ else
485
+ log_warn "Missing prerequisites: ${missing[*]}"
420
486
 
421
- log_warn "Missing prerequisites: ${missing[*]}"
422
-
423
- if is_interactive; then
424
- if ! prompt_yn "Install missing prerequisites?"; then
425
- log_error "Please install manually: ${missing[*]}"
426
- exit 1
487
+ if is_interactive; then
488
+ if ! prompt_yn "Install missing prerequisites?"; then
489
+ log_error "Please install manually: ${missing[*]}"
490
+ exit 1
491
+ fi
427
492
  fi
428
- fi
429
493
 
430
- for dep in "${missing[@]}"; do
431
- case "$dep" in
432
- node) install_node ;;
433
- git) install_git ;;
434
- tmux) install_tmux ;;
435
- ripgrep) install_ripgrep ;;
436
- esac
437
- done
494
+ for dep in "${missing[@]}"; do
495
+ case "$dep" in
496
+ node) install_node ;;
497
+ git) install_git ;;
498
+ tmux) install_tmux ;;
499
+ ripgrep) install_ripgrep ;;
500
+ esac
501
+ done
438
502
 
439
- log_success "Prerequisites installed"
503
+ log_success "Prerequisites installed"
504
+ fi
440
505
 
441
506
  configure_tmux
507
+
508
+ # Optional: cloudflared for tunnel sharing
509
+ if ! check_cloudflared; then
510
+ log_info "cloudflared not found (optional - enables port sharing via Cloudflare Tunnel)"
511
+ if is_interactive; then
512
+ if prompt_yn "Install cloudflared for tunnel sharing?"; then
513
+ install_cloudflared
514
+ fi
515
+ fi
516
+ fi
442
517
  }
443
518
 
444
519
  configure_tmux() {
@@ -0,0 +1,19 @@
1
+ server {
2
+ listen 80;
3
+ server_name claudedeck.example.com;
4
+
5
+ location / {
6
+ proxy_pass http://127.0.0.1:3011;
7
+ proxy_set_header Host $host;
8
+ proxy_set_header X-Real-IP $remote_addr;
9
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
10
+ proxy_set_header X-Forwarded-Proto $scheme;
11
+
12
+ proxy_http_version 1.1;
13
+ proxy_set_header Upgrade $http_upgrade;
14
+ proxy_set_header Connection "upgrade";
15
+
16
+ proxy_read_timeout 86400s;
17
+ proxy_send_timeout 86400s;
18
+ }
19
+ }
package/server.ts CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  COOKIE_NAME,
14
14
  hasUsers,
15
15
  } from "./lib/auth";
16
+ import { stopAllTunnels } from "./lib/tunnels";
16
17
 
17
18
  const dev = process.env.NODE_ENV !== "production";
18
19
  const hostname = process.env.HOST || (dev ? "localhost" : "0.0.0.0");
@@ -89,65 +90,86 @@ app.prepare().then(async () => {
89
90
  ws.on("close", () => clearInterval(interval));
90
91
  }
91
92
 
92
- // Terminal connections
93
- terminalWss.on("connection", (ws: WebSocket) => {
94
- setupHeartbeat(ws);
95
- let ptyProcess: pty.IPty;
96
- try {
97
- const shell = process.env.SHELL || "/bin/zsh";
98
- // Use minimal env - only essentials for shell to work
99
- // This lets Next.js/Vite/etc load .env.local without interference from parent process env
100
- const minimalEnv: { [key: string]: string } = {
101
- PATH: process.env.PATH || "/usr/local/bin:/usr/bin:/bin",
102
- HOME: process.env.HOME || "/",
103
- USER: process.env.USER || "",
104
- SHELL: shell,
105
- TERM: "xterm-256color",
106
- COLORTERM: "truecolor",
107
- LANG: process.env.LANG || "en_US.UTF-8",
108
- };
109
-
110
- ptyProcess = pty.spawn(shell, [], {
111
- name: "xterm-256color",
112
- cols: 80,
113
- rows: 24,
114
- cwd: process.env.HOME || "/",
115
- env: minimalEnv,
116
- });
117
- } catch (err) {
118
- console.error("Failed to spawn pty:", err);
119
- ws.send(
120
- JSON.stringify({ type: "error", message: "Failed to start terminal" })
121
- );
122
- ws.close();
123
- return;
124
- }
93
+ // --- Persistent PTY pool ---
94
+ // PTYs survive WebSocket disconnects. Clients attach/reattach by ptyId.
95
+ interface PtyEntry {
96
+ process: pty.IPty;
97
+ ws: WebSocket | null;
98
+ buffer: string[];
99
+ }
100
+ const ptyPool = new Map<string, PtyEntry>();
101
+ const MAX_SCROLLBACK_BUFFER = 50000;
102
+
103
+ function spawnPty(): { id: string; entry: PtyEntry } {
104
+ const shell = process.env.SHELL || "/bin/zsh";
105
+ const minimalEnv: { [key: string]: string } = {
106
+ PATH: process.env.PATH || "/usr/local/bin:/usr/bin:/bin",
107
+ HOME: process.env.HOME || "/",
108
+ USER: process.env.USER || "",
109
+ SHELL: shell,
110
+ TERM: "xterm-256color",
111
+ COLORTERM: "truecolor",
112
+ LANG: process.env.LANG || "en_US.UTF-8",
113
+ };
114
+
115
+ const proc = pty.spawn(shell, [], {
116
+ name: "xterm-256color",
117
+ cols: 80,
118
+ rows: 24,
119
+ cwd: process.env.HOME || "/",
120
+ env: minimalEnv,
121
+ });
122
+
123
+ const id = `pty_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
124
+ const entry: PtyEntry = { process: proc, ws: null, buffer: [] };
125
125
 
126
- ptyProcess.onData((data: string) => {
127
- if (ws.readyState === WebSocket.OPEN) {
128
- ws.send(JSON.stringify({ type: "output", data }));
126
+ proc.onData((data: string) => {
127
+ entry.buffer.push(data);
128
+ if (entry.buffer.length > MAX_SCROLLBACK_BUFFER) {
129
+ entry.buffer.splice(0, entry.buffer.length - MAX_SCROLLBACK_BUFFER);
130
+ }
131
+ if (entry.ws?.readyState === WebSocket.OPEN) {
132
+ entry.ws.send(JSON.stringify({ type: "output", data }));
129
133
  }
130
134
  });
131
135
 
132
- ptyProcess.onExit(({ exitCode }) => {
133
- if (ws.readyState === WebSocket.OPEN) {
134
- ws.send(JSON.stringify({ type: "exit", code: exitCode }));
135
- ws.close();
136
+ proc.onExit(({ exitCode }) => {
137
+ if (entry.ws?.readyState === WebSocket.OPEN) {
138
+ entry.ws.send(JSON.stringify({ type: "exit", code: exitCode }));
136
139
  }
140
+ ptyPool.delete(id);
137
141
  });
138
142
 
143
+ ptyPool.set(id, entry);
144
+ return { id, entry };
145
+ }
146
+
147
+ function attachWsToPty(ws: WebSocket, entry: PtyEntry, _ptyId: string) {
148
+ // Detach previous WebSocket if any
149
+ if (entry.ws && entry.ws !== ws && entry.ws.readyState === WebSocket.OPEN) {
150
+ entry.ws.onclose = null;
151
+ entry.ws.onerror = null;
152
+ entry.ws.close(1000, "Replaced by new connection");
153
+ }
154
+ entry.ws = ws;
155
+
156
+ // Replay buffered output so the client sees prior terminal state
157
+ if (entry.buffer.length > 0) {
158
+ ws.send(JSON.stringify({ type: "output", data: entry.buffer.join("") }));
159
+ }
160
+
139
161
  ws.on("message", (message: Buffer) => {
140
162
  try {
141
163
  const msg = JSON.parse(message.toString());
142
164
  switch (msg.type) {
143
165
  case "input":
144
- ptyProcess.write(msg.data);
166
+ entry.process.write(msg.data);
145
167
  break;
146
168
  case "resize":
147
- ptyProcess.resize(msg.cols, msg.rows);
169
+ entry.process.resize(msg.cols, msg.rows);
148
170
  break;
149
171
  case "command":
150
- ptyProcess.write(msg.data + "\r");
172
+ entry.process.write(msg.data + "\r");
151
173
  break;
152
174
  }
153
175
  } catch (err) {
@@ -156,13 +178,42 @@ app.prepare().then(async () => {
156
178
  });
157
179
 
158
180
  ws.on("close", () => {
159
- ptyProcess.kill();
181
+ if (entry.ws === ws) entry.ws = null;
182
+ // PTY stays alive — no kill
160
183
  });
161
184
 
162
- ws.on("error", (err) => {
163
- console.error("WebSocket error:", err);
164
- ptyProcess.kill();
185
+ ws.on("error", () => {
186
+ if (entry.ws === ws) entry.ws = null;
165
187
  });
188
+ }
189
+
190
+ // Terminal connections
191
+ terminalWss.on("connection", (ws: WebSocket, request) => {
192
+ setupHeartbeat(ws);
193
+
194
+ const { query } = parse(request.url || "", true);
195
+ const requestedPtyId = typeof query.ptyId === "string" ? query.ptyId : null;
196
+
197
+ // Try to reattach to existing PTY
198
+ if (requestedPtyId && ptyPool.has(requestedPtyId)) {
199
+ const entry = ptyPool.get(requestedPtyId)!;
200
+ ws.send(JSON.stringify({ type: "pty-id", ptyId: requestedPtyId }));
201
+ attachWsToPty(ws, entry, requestedPtyId);
202
+ return;
203
+ }
204
+
205
+ // Spawn new PTY
206
+ try {
207
+ const { id, entry } = spawnPty();
208
+ ws.send(JSON.stringify({ type: "pty-id", ptyId: id }));
209
+ attachWsToPty(ws, entry, id);
210
+ } catch (err) {
211
+ console.error("Failed to spawn pty:", err);
212
+ ws.send(
213
+ JSON.stringify({ type: "error", message: "Failed to start terminal" })
214
+ );
215
+ ws.close();
216
+ }
166
217
  });
167
218
 
168
219
  await initDb();
@@ -172,6 +223,13 @@ app.prepare().then(async () => {
172
223
  startWatcher();
173
224
  startStatusMonitor();
174
225
 
226
+ const shutdown = () => {
227
+ stopAllTunnels();
228
+ process.exit(0);
229
+ };
230
+ process.on("SIGINT", shutdown);
231
+ process.on("SIGTERM", shutdown);
232
+
175
233
  server.listen(port, () => {
176
234
  console.log(`> ClaudeDeck ready on http://${hostname}:${port}`);
177
235
  });
@@ -1,69 +0,0 @@
1
- import { ChevronRight } from "lucide-react";
2
- import { CLAUDE_AUTO_APPROVE_FLAG } from "@/lib/providers";
3
-
4
- interface AdvancedSettingsProps {
5
- open: boolean;
6
- onOpenChange: (open: boolean) => void;
7
- useTmux: boolean;
8
- onUseTmuxChange: (checked: boolean) => void;
9
- skipPermissions: boolean;
10
- onSkipPermissionsChange: (checked: boolean) => void;
11
- }
12
-
13
- export function AdvancedSettings({
14
- open,
15
- onOpenChange,
16
- useTmux,
17
- onUseTmuxChange,
18
- skipPermissions,
19
- onSkipPermissionsChange,
20
- }: AdvancedSettingsProps) {
21
- return (
22
- <div className="border-border rounded-lg border">
23
- <button
24
- type="button"
25
- onClick={() => onOpenChange(!open)}
26
- className="text-muted-foreground hover:text-foreground flex w-full items-center gap-2 px-3 py-2 text-sm transition-colors"
27
- >
28
- <ChevronRight
29
- className={`h-4 w-4 transition-transform ${open ? "rotate-90" : ""}`}
30
- />
31
- Advanced Settings
32
- </button>
33
- {open && (
34
- <div className="space-y-3 border-t px-3 py-3">
35
- <div className="flex items-center gap-2">
36
- <input
37
- type="checkbox"
38
- id="useTmux"
39
- checked={useTmux}
40
- onChange={(e) => onUseTmuxChange(e.target.checked)}
41
- className="border-border bg-background accent-primary h-4 w-4 rounded"
42
- />
43
- <label htmlFor="useTmux" className="cursor-pointer text-sm">
44
- Use tmux session
45
- <span className="text-muted-foreground ml-1">
46
- (enables detach/attach)
47
- </span>
48
- </label>
49
- </div>
50
- <div className="flex items-center gap-2">
51
- <input
52
- type="checkbox"
53
- id="skipPermissions"
54
- checked={skipPermissions}
55
- onChange={(e) => onSkipPermissionsChange(e.target.checked)}
56
- className="border-border bg-background accent-primary h-4 w-4 rounded"
57
- />
58
- <label htmlFor="skipPermissions" className="cursor-pointer text-sm">
59
- Auto-approve tool calls
60
- <span className="text-muted-foreground ml-1">
61
- ({CLAUDE_AUTO_APPROVE_FLAG})
62
- </span>
63
- </label>
64
- </div>
65
- </div>
66
- )}
67
- </div>
68
- );
69
- }