@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.
- package/app/api/tunnels/[port]/route.ts +57 -0
- package/app/api/tunnels/route.ts +18 -0
- package/app/api/worktrees/route.ts +29 -0
- package/components/NewClaudeSessionDialog.tsx +197 -15
- package/components/Pane/DesktopTabBar.tsx +11 -0
- package/components/Pane/MobileTabBar.tsx +6 -0
- package/components/SessionList/ActiveSessionsSection.tsx +140 -3
- package/components/Terminal/hooks/websocket-connection.ts +17 -5
- package/components/views/types.ts +2 -0
- package/data/tunnels/queries.ts +51 -0
- package/lib/claude/watcher.ts +10 -1
- package/lib/db/migrations.ts +11 -1
- package/lib/db/types.ts +1 -0
- package/lib/dev-servers.ts +84 -66
- package/lib/status-monitor.ts +108 -4
- package/lib/tunnels.ts +157 -0
- package/lib/worktrees.ts +9 -0
- package/package.json +1 -1
- package/scripts/lib/prerequisites.sh +93 -18
- package/scripts/nginx-http.conf +19 -0
- package/server.ts +105 -47
- package/components/NewSessionDialog/AdvancedSettings.tsx +0 -69
|
@@ -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
|
-
|
|
419
|
-
|
|
484
|
+
else
|
|
485
|
+
log_warn "Missing prerequisites: ${missing[*]}"
|
|
420
486
|
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
133
|
-
if (ws
|
|
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
|
-
|
|
166
|
+
entry.process.write(msg.data);
|
|
145
167
|
break;
|
|
146
168
|
case "resize":
|
|
147
|
-
|
|
169
|
+
entry.process.resize(msg.cols, msg.rows);
|
|
148
170
|
break;
|
|
149
171
|
case "command":
|
|
150
|
-
|
|
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
|
-
|
|
181
|
+
if (entry.ws === ws) entry.ws = null;
|
|
182
|
+
// PTY stays alive — no kill
|
|
160
183
|
});
|
|
161
184
|
|
|
162
|
-
ws.on("error", (
|
|
163
|
-
|
|
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
|
-
}
|