@atercates/claude-deck 0.2.15 → 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/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/package.json +1 -1
- package/scripts/lib/prerequisites.sh +93 -18
- package/server.ts +105 -47
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { isCloudflaredInstalled, startTunnel, stopTunnel } from "@/lib/tunnels";
|
|
3
|
+
|
|
4
|
+
export async function POST(
|
|
5
|
+
_request: NextRequest,
|
|
6
|
+
{ params }: { params: Promise<{ port: string }> }
|
|
7
|
+
) {
|
|
8
|
+
try {
|
|
9
|
+
const { port: portStr } = await params;
|
|
10
|
+
const port = parseInt(portStr, 10);
|
|
11
|
+
if (isNaN(port) || port <= 0) {
|
|
12
|
+
return NextResponse.json({ error: "Invalid port" }, { status: 400 });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const installed = await isCloudflaredInstalled();
|
|
16
|
+
if (!installed) {
|
|
17
|
+
return NextResponse.json(
|
|
18
|
+
{
|
|
19
|
+
error: "cloudflared is not installed",
|
|
20
|
+
code: "CLOUDFLARED_NOT_FOUND",
|
|
21
|
+
},
|
|
22
|
+
{ status: 400 }
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const result = await startTunnel(port);
|
|
27
|
+
return NextResponse.json(result);
|
|
28
|
+
} catch (error) {
|
|
29
|
+
console.error("Error starting tunnel:", error);
|
|
30
|
+
return NextResponse.json(
|
|
31
|
+
{ error: "Failed to start tunnel" },
|
|
32
|
+
{ status: 500 }
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function DELETE(
|
|
38
|
+
_request: NextRequest,
|
|
39
|
+
{ params }: { params: Promise<{ port: string }> }
|
|
40
|
+
) {
|
|
41
|
+
try {
|
|
42
|
+
const { port: portStr } = await params;
|
|
43
|
+
const port = parseInt(portStr, 10);
|
|
44
|
+
if (isNaN(port) || port <= 0) {
|
|
45
|
+
return NextResponse.json({ error: "Invalid port" }, { status: 400 });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
await stopTunnel(port);
|
|
49
|
+
return NextResponse.json({ ok: true });
|
|
50
|
+
} catch (error) {
|
|
51
|
+
console.error("Error stopping tunnel:", error);
|
|
52
|
+
return NextResponse.json(
|
|
53
|
+
{ error: "Failed to stop tunnel" },
|
|
54
|
+
{ status: 500 }
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { isCloudflaredInstalled, getTunnelsList } from "@/lib/tunnels";
|
|
3
|
+
|
|
4
|
+
export async function GET() {
|
|
5
|
+
try {
|
|
6
|
+
const [installed, tunnels] = await Promise.all([
|
|
7
|
+
isCloudflaredInstalled(),
|
|
8
|
+
Promise.resolve(getTunnelsList()),
|
|
9
|
+
]);
|
|
10
|
+
return NextResponse.json({ installed, tunnels });
|
|
11
|
+
} catch (error) {
|
|
12
|
+
console.error("Error getting tunnels:", error);
|
|
13
|
+
return NextResponse.json(
|
|
14
|
+
{ error: "Failed to get tunnels" },
|
|
15
|
+
{ status: 500 }
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -1,9 +1,25 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useMemo, useState, useEffect } from "react";
|
|
3
|
+
import { useMemo, useState, useEffect, useCallback } from "react";
|
|
4
4
|
import { cn } from "@/lib/utils";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
ChevronRight,
|
|
7
|
+
Activity,
|
|
8
|
+
AlertCircle,
|
|
9
|
+
Moon,
|
|
10
|
+
Globe,
|
|
11
|
+
Share2,
|
|
12
|
+
X,
|
|
13
|
+
Loader2,
|
|
14
|
+
Copy,
|
|
15
|
+
Check,
|
|
16
|
+
} from "lucide-react";
|
|
6
17
|
import type { SessionStatus } from "@/components/views/types";
|
|
18
|
+
import {
|
|
19
|
+
useCloudflaredStatus,
|
|
20
|
+
useStartTunnel,
|
|
21
|
+
useStopTunnel,
|
|
22
|
+
} from "@/data/tunnels/queries";
|
|
7
23
|
|
|
8
24
|
interface ActiveSessionsSectionProps {
|
|
9
25
|
sessionStatuses: Record<string, SessionStatus>;
|
|
@@ -37,11 +53,13 @@ export function ActiveSessionsSection({
|
|
|
37
53
|
const hasWaiting = activeSessions.some((s) => s.status === "waiting");
|
|
38
54
|
const [expanded, setExpanded] = useState(hasWaiting);
|
|
39
55
|
|
|
40
|
-
// Auto-expand when a session starts waiting
|
|
41
56
|
useEffect(() => {
|
|
42
57
|
if (hasWaiting) setExpanded(true);
|
|
43
58
|
}, [hasWaiting]);
|
|
44
59
|
|
|
60
|
+
const { data: cfStatus } = useCloudflaredStatus();
|
|
61
|
+
const cloudflaredInstalled = cfStatus?.installed ?? false;
|
|
62
|
+
|
|
45
63
|
if (activeSessions.length === 0) return null;
|
|
46
64
|
|
|
47
65
|
return (
|
|
@@ -92,6 +110,19 @@ export function ActiveSessionsSection({
|
|
|
92
110
|
{session.lastLine}
|
|
93
111
|
</span>
|
|
94
112
|
)}
|
|
113
|
+
{session.listeningPorts &&
|
|
114
|
+
session.listeningPorts.length > 0 && (
|
|
115
|
+
<div className="mt-0.5 space-y-0.5">
|
|
116
|
+
{session.listeningPorts.map((port) => (
|
|
117
|
+
<PortBadge
|
|
118
|
+
key={port}
|
|
119
|
+
port={port}
|
|
120
|
+
tunnelUrl={session.tunnelUrls?.[port]}
|
|
121
|
+
cloudflaredInstalled={cloudflaredInstalled}
|
|
122
|
+
/>
|
|
123
|
+
))}
|
|
124
|
+
</div>
|
|
125
|
+
)}
|
|
95
126
|
</div>
|
|
96
127
|
</button>
|
|
97
128
|
))}
|
|
@@ -101,6 +132,112 @@ export function ActiveSessionsSection({
|
|
|
101
132
|
);
|
|
102
133
|
}
|
|
103
134
|
|
|
135
|
+
function PortBadge({
|
|
136
|
+
port,
|
|
137
|
+
tunnelUrl,
|
|
138
|
+
cloudflaredInstalled,
|
|
139
|
+
}: {
|
|
140
|
+
port: number;
|
|
141
|
+
tunnelUrl?: string;
|
|
142
|
+
cloudflaredInstalled: boolean;
|
|
143
|
+
}) {
|
|
144
|
+
const startTunnel = useStartTunnel();
|
|
145
|
+
const stopTunnel = useStopTunnel();
|
|
146
|
+
const [copied, setCopied] = useState(false);
|
|
147
|
+
|
|
148
|
+
const isStarting = startTunnel.isPending;
|
|
149
|
+
const hasTunnel = !!tunnelUrl;
|
|
150
|
+
|
|
151
|
+
const handleShare = useCallback(
|
|
152
|
+
(e: React.MouseEvent) => {
|
|
153
|
+
e.stopPropagation();
|
|
154
|
+
if (hasTunnel) {
|
|
155
|
+
stopTunnel.mutate(port);
|
|
156
|
+
} else {
|
|
157
|
+
startTunnel.mutate(port);
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
[hasTunnel, port, startTunnel, stopTunnel]
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
const handleCopy = useCallback(
|
|
164
|
+
(e: React.MouseEvent) => {
|
|
165
|
+
e.stopPropagation();
|
|
166
|
+
if (tunnelUrl) {
|
|
167
|
+
navigator.clipboard.writeText(tunnelUrl);
|
|
168
|
+
setCopied(true);
|
|
169
|
+
setTimeout(() => setCopied(false), 2000);
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
[tunnelUrl]
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
return (
|
|
176
|
+
<div className="flex flex-col gap-0.5">
|
|
177
|
+
<div className="flex items-center gap-1">
|
|
178
|
+
<a
|
|
179
|
+
href={`http://localhost:${port}`}
|
|
180
|
+
target="_blank"
|
|
181
|
+
rel="noopener noreferrer"
|
|
182
|
+
onClick={(e) => e.stopPropagation()}
|
|
183
|
+
className="inline-flex items-center gap-0.5 rounded bg-sky-500/15 px-1.5 py-0.5 font-mono text-[10px] text-sky-400 transition-colors hover:bg-sky-500/25"
|
|
184
|
+
>
|
|
185
|
+
<Globe className="h-2.5 w-2.5" />
|
|
186
|
+
{port}
|
|
187
|
+
</a>
|
|
188
|
+
{cloudflaredInstalled && (
|
|
189
|
+
<button
|
|
190
|
+
onClick={handleShare}
|
|
191
|
+
disabled={isStarting}
|
|
192
|
+
className={cn(
|
|
193
|
+
"rounded p-0.5 transition-colors",
|
|
194
|
+
hasTunnel
|
|
195
|
+
? "text-emerald-400 hover:bg-emerald-500/20"
|
|
196
|
+
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
|
197
|
+
)}
|
|
198
|
+
title={hasTunnel ? "Stop sharing" : "Share via tunnel"}
|
|
199
|
+
>
|
|
200
|
+
{isStarting ? (
|
|
201
|
+
<Loader2 className="h-3 w-3 animate-spin" />
|
|
202
|
+
) : hasTunnel ? (
|
|
203
|
+
<X className="h-3 w-3" />
|
|
204
|
+
) : (
|
|
205
|
+
<Share2 className="h-3 w-3" />
|
|
206
|
+
)}
|
|
207
|
+
</button>
|
|
208
|
+
)}
|
|
209
|
+
</div>
|
|
210
|
+
{tunnelUrl && (
|
|
211
|
+
<div className="flex items-center gap-1">
|
|
212
|
+
<a
|
|
213
|
+
href={tunnelUrl}
|
|
214
|
+
target="_blank"
|
|
215
|
+
rel="noopener noreferrer"
|
|
216
|
+
onClick={(e) => e.stopPropagation()}
|
|
217
|
+
className="inline-flex max-w-[180px] items-center gap-0.5 truncate rounded bg-emerald-500/15 px-1.5 py-0.5 font-mono text-[10px] text-emerald-400 transition-colors hover:bg-emerald-500/25"
|
|
218
|
+
>
|
|
219
|
+
<Globe className="h-2.5 w-2.5 flex-shrink-0" />
|
|
220
|
+
{tunnelUrl
|
|
221
|
+
.replace("https://", "")
|
|
222
|
+
.replace(".trycloudflare.com", "")}
|
|
223
|
+
</a>
|
|
224
|
+
<button
|
|
225
|
+
onClick={handleCopy}
|
|
226
|
+
className="text-muted-foreground hover:text-foreground rounded p-0.5 transition-colors"
|
|
227
|
+
title="Copy URL"
|
|
228
|
+
>
|
|
229
|
+
{copied ? (
|
|
230
|
+
<Check className="h-3 w-3 text-emerald-400" />
|
|
231
|
+
) : (
|
|
232
|
+
<Copy className="h-3 w-3" />
|
|
233
|
+
)}
|
|
234
|
+
</button>
|
|
235
|
+
</div>
|
|
236
|
+
)}
|
|
237
|
+
</div>
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
104
241
|
function StatusIcon({ status }: { status: string }) {
|
|
105
242
|
if (status === "running") {
|
|
106
243
|
return (
|
|
@@ -34,7 +34,14 @@ export function createWebSocketConnection(
|
|
|
34
34
|
intentionalCloseRef: React.MutableRefObject<boolean>
|
|
35
35
|
): WebSocketManager {
|
|
36
36
|
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
|
37
|
-
|
|
37
|
+
let ptyId: string | null = null;
|
|
38
|
+
|
|
39
|
+
function buildWsUrl() {
|
|
40
|
+
const base = `${protocol}//${window.location.host}/ws/terminal`;
|
|
41
|
+
return ptyId ? `${base}?ptyId=${ptyId}` : base;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const ws = new WebSocket(buildWsUrl());
|
|
38
45
|
wsRef.current = ws;
|
|
39
46
|
|
|
40
47
|
const sendResize = (cols: number, rows: number) => {
|
|
@@ -93,10 +100,11 @@ export function createWebSocketConnection(
|
|
|
93
100
|
callbacks.onConnectionStateChange("reconnecting");
|
|
94
101
|
reconnectDelayRef.current = WS_RECONNECT_BASE_DELAY;
|
|
95
102
|
|
|
96
|
-
//
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
103
|
+
// Reattach to the same PTY if we have an ID
|
|
104
|
+
if (ptyId) {
|
|
105
|
+
term.clear();
|
|
106
|
+
}
|
|
107
|
+
const newWs = new WebSocket(buildWsUrl());
|
|
100
108
|
wsRef.current = newWs;
|
|
101
109
|
newWs.onopen = savedHandlers.onopen;
|
|
102
110
|
newWs.onmessage = savedHandlers.onmessage;
|
|
@@ -135,6 +143,10 @@ export function createWebSocketConnection(
|
|
|
135
143
|
resetInactivityTimer();
|
|
136
144
|
try {
|
|
137
145
|
const msg = JSON.parse(event.data);
|
|
146
|
+
if (msg.type === "pty-id") {
|
|
147
|
+
ptyId = msg.ptyId;
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
138
150
|
if (msg.type === "output") {
|
|
139
151
|
const buffer = term.buffer.active;
|
|
140
152
|
const scrollYBefore = buffer.viewportY;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
2
|
+
|
|
3
|
+
const tunnelKeys = {
|
|
4
|
+
status: () => ["tunnels", "status"] as const,
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export function useCloudflaredStatus() {
|
|
8
|
+
return useQuery({
|
|
9
|
+
queryKey: tunnelKeys.status(),
|
|
10
|
+
queryFn: async () => {
|
|
11
|
+
const res = await fetch("/api/tunnels");
|
|
12
|
+
if (!res.ok) return { installed: false, tunnels: [] };
|
|
13
|
+
return res.json() as Promise<{
|
|
14
|
+
installed: boolean;
|
|
15
|
+
tunnels: { port: number; url: string | null }[];
|
|
16
|
+
}>;
|
|
17
|
+
},
|
|
18
|
+
staleTime: 60_000,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function useStartTunnel() {
|
|
23
|
+
const queryClient = useQueryClient();
|
|
24
|
+
return useMutation({
|
|
25
|
+
mutationFn: async (port: number) => {
|
|
26
|
+
const res = await fetch(`/api/tunnels/${port}`, { method: "POST" });
|
|
27
|
+
if (!res.ok) {
|
|
28
|
+
const data = await res.json();
|
|
29
|
+
throw new Error(data.error || "Failed to start tunnel");
|
|
30
|
+
}
|
|
31
|
+
return res.json() as Promise<{ port: number; url: string | null }>;
|
|
32
|
+
},
|
|
33
|
+
onSuccess: () => {
|
|
34
|
+
queryClient.invalidateQueries({ queryKey: tunnelKeys.status() });
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function useStopTunnel() {
|
|
40
|
+
const queryClient = useQueryClient();
|
|
41
|
+
return useMutation({
|
|
42
|
+
mutationFn: async (port: number) => {
|
|
43
|
+
const res = await fetch(`/api/tunnels/${port}`, { method: "DELETE" });
|
|
44
|
+
if (!res.ok) throw new Error("Failed to stop tunnel");
|
|
45
|
+
return res.json();
|
|
46
|
+
},
|
|
47
|
+
onSuccess: () => {
|
|
48
|
+
queryClient.invalidateQueries({ queryKey: tunnelKeys.status() });
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
}
|
package/lib/claude/watcher.ts
CHANGED
|
@@ -3,7 +3,11 @@ import path from "path";
|
|
|
3
3
|
import os from "os";
|
|
4
4
|
import { WebSocket } from "ws";
|
|
5
5
|
import { invalidateProject, invalidateAll } from "./jsonl-cache";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
onStateFileChange,
|
|
8
|
+
invalidateSessionName,
|
|
9
|
+
getStatusSnapshot,
|
|
10
|
+
} from "../status-monitor";
|
|
7
11
|
import { STATES_DIR } from "../hooks/setup";
|
|
8
12
|
|
|
9
13
|
const CLAUDE_PROJECTS_DIR = path.join(os.homedir(), ".claude", "projects");
|
|
@@ -13,6 +17,11 @@ const updateClients = new Set<WebSocket>();
|
|
|
13
17
|
export function addUpdateClient(ws: WebSocket): void {
|
|
14
18
|
updateClients.add(ws);
|
|
15
19
|
ws.on("close", () => updateClients.delete(ws));
|
|
20
|
+
|
|
21
|
+
const snapshot = getStatusSnapshot();
|
|
22
|
+
if (Object.keys(snapshot).length > 0) {
|
|
23
|
+
ws.send(JSON.stringify({ type: "session-statuses", statuses: snapshot }));
|
|
24
|
+
}
|
|
16
25
|
}
|
|
17
26
|
|
|
18
27
|
export function broadcast(msg: object): void {
|
package/lib/db/migrations.ts
CHANGED
|
@@ -6,7 +6,17 @@ interface Migration {
|
|
|
6
6
|
up: (db: Database.Database) => void;
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
-
const migrations: Migration[] = [
|
|
9
|
+
const migrations: Migration[] = [
|
|
10
|
+
{
|
|
11
|
+
id: 1,
|
|
12
|
+
name: "add_session_listening_ports",
|
|
13
|
+
up: (db) => {
|
|
14
|
+
db.exec(
|
|
15
|
+
"ALTER TABLE sessions ADD COLUMN listening_ports TEXT NOT NULL DEFAULT '[]'"
|
|
16
|
+
);
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
];
|
|
10
20
|
|
|
11
21
|
export function runMigrations(db: Database.Database): void {
|
|
12
22
|
db.exec(`
|
package/lib/db/types.ts
CHANGED
package/lib/dev-servers.ts
CHANGED
|
@@ -2,7 +2,12 @@ import { spawn, exec } from "child_process";
|
|
|
2
2
|
import { promisify } from "util";
|
|
3
3
|
import fs from "fs";
|
|
4
4
|
import path from "path";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
queries,
|
|
7
|
+
type DevServer,
|
|
8
|
+
type DevServerType,
|
|
9
|
+
type DevServerStatus,
|
|
10
|
+
} from "./db";
|
|
6
11
|
|
|
7
12
|
const execAsync = promisify(exec);
|
|
8
13
|
|
|
@@ -49,53 +54,60 @@ async function isPidRunning(pid: number): Promise<boolean> {
|
|
|
49
54
|
}
|
|
50
55
|
}
|
|
51
56
|
|
|
52
|
-
//
|
|
53
|
-
async function
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
57
|
+
// Detect which TCP ports a process is listening on via lsof
|
|
58
|
+
async function detectListeningPorts(
|
|
59
|
+
pid: number,
|
|
60
|
+
retries = 5
|
|
61
|
+
): Promise<number[]> {
|
|
62
|
+
for (let i = 0; i < retries; i++) {
|
|
63
|
+
try {
|
|
64
|
+
const { stdout } = await execAsync(
|
|
65
|
+
`lsof -P -iTCP -sTCP:LISTEN -a -p ${pid} -Fn 2>/dev/null || true`
|
|
66
|
+
);
|
|
67
|
+
const ports = [
|
|
68
|
+
...new Set(
|
|
69
|
+
stdout
|
|
70
|
+
.split("\n")
|
|
71
|
+
.filter((line) => line.startsWith("n"))
|
|
72
|
+
.map((line) => parseInt(line.slice(line.lastIndexOf(":") + 1), 10))
|
|
73
|
+
.filter((port) => !isNaN(port) && port > 0)
|
|
74
|
+
),
|
|
75
|
+
].sort((a, b) => a - b);
|
|
76
|
+
|
|
77
|
+
if (ports.length > 0) return ports;
|
|
78
|
+
} catch {
|
|
79
|
+
// lsof failed, retry
|
|
80
|
+
}
|
|
81
|
+
if (i < retries - 1) {
|
|
82
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
83
|
+
}
|
|
74
84
|
}
|
|
85
|
+
return [];
|
|
75
86
|
}
|
|
76
87
|
|
|
77
88
|
// Check Node.js server status
|
|
78
89
|
async function checkNodeStatus(server: DevServer): Promise<DevServerStatus> {
|
|
79
|
-
if (server.pid)
|
|
80
|
-
const running = await isPidRunning(server.pid);
|
|
81
|
-
if (running) return "running";
|
|
82
|
-
}
|
|
90
|
+
if (!server.pid) return "stopped";
|
|
83
91
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
92
|
+
const running = await isPidRunning(server.pid);
|
|
93
|
+
if (!running) return "stopped";
|
|
94
|
+
|
|
95
|
+
const ports = await detectListeningPorts(server.pid, 1);
|
|
96
|
+
if (ports.length > 0) {
|
|
97
|
+
const current = JSON.stringify(ports);
|
|
98
|
+
if (current !== server.ports) {
|
|
99
|
+
await queries.updateDevServer(
|
|
100
|
+
"running",
|
|
101
|
+
server.pid,
|
|
102
|
+
null,
|
|
103
|
+
current,
|
|
104
|
+
server.id
|
|
105
|
+
);
|
|
106
|
+
server.ports = current;
|
|
95
107
|
}
|
|
96
108
|
}
|
|
97
109
|
|
|
98
|
-
return "
|
|
110
|
+
return "running";
|
|
99
111
|
}
|
|
100
112
|
|
|
101
113
|
// Check Docker service status
|
|
@@ -269,7 +281,13 @@ export async function startServer(
|
|
|
269
281
|
opts.command,
|
|
270
282
|
opts.workingDirectory
|
|
271
283
|
);
|
|
272
|
-
await queries.updateDevServer(
|
|
284
|
+
await queries.updateDevServer(
|
|
285
|
+
"running",
|
|
286
|
+
null,
|
|
287
|
+
containerId,
|
|
288
|
+
JSON.stringify(ports),
|
|
289
|
+
id
|
|
290
|
+
);
|
|
273
291
|
} else {
|
|
274
292
|
const { pid } = await spawnNodeServer(
|
|
275
293
|
id,
|
|
@@ -277,7 +295,15 @@ export async function startServer(
|
|
|
277
295
|
opts.workingDirectory,
|
|
278
296
|
ports
|
|
279
297
|
);
|
|
280
|
-
|
|
298
|
+
const detectedPorts = await detectListeningPorts(pid);
|
|
299
|
+
const resolvedPorts = detectedPorts.length > 0 ? detectedPorts : ports;
|
|
300
|
+
await queries.updateDevServer(
|
|
301
|
+
"running",
|
|
302
|
+
pid,
|
|
303
|
+
null,
|
|
304
|
+
JSON.stringify(resolvedPorts),
|
|
305
|
+
id
|
|
306
|
+
);
|
|
281
307
|
}
|
|
282
308
|
} catch (error) {
|
|
283
309
|
await queries.updateDevServerStatus("failed", id);
|
|
@@ -314,19 +340,6 @@ export async function stopServer(id: string): Promise<void> {
|
|
|
314
340
|
// Process may already be dead
|
|
315
341
|
}
|
|
316
342
|
}
|
|
317
|
-
|
|
318
|
-
// Also check ports and kill anything on them
|
|
319
|
-
const ports: number[] = JSON.parse(server.ports || "[]");
|
|
320
|
-
for (const port of ports) {
|
|
321
|
-
const pid = await getPidOnPort(port);
|
|
322
|
-
if (pid) {
|
|
323
|
-
try {
|
|
324
|
-
process.kill(pid, "SIGTERM");
|
|
325
|
-
} catch {
|
|
326
|
-
// Ignore
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
343
|
}
|
|
331
344
|
|
|
332
345
|
await queries.updateDevServerStatus("stopped", id);
|
|
@@ -345,7 +358,13 @@ export async function restartServer(id: string): Promise<DevServer> {
|
|
|
345
358
|
server.command,
|
|
346
359
|
server.working_directory
|
|
347
360
|
);
|
|
348
|
-
await queries.updateDevServer(
|
|
361
|
+
await queries.updateDevServer(
|
|
362
|
+
"running",
|
|
363
|
+
null,
|
|
364
|
+
containerId,
|
|
365
|
+
server.ports,
|
|
366
|
+
id
|
|
367
|
+
);
|
|
349
368
|
} else {
|
|
350
369
|
const ports: number[] = JSON.parse(server.ports || "[]");
|
|
351
370
|
const { pid } = await spawnNodeServer(
|
|
@@ -354,7 +373,15 @@ export async function restartServer(id: string): Promise<DevServer> {
|
|
|
354
373
|
server.working_directory,
|
|
355
374
|
ports
|
|
356
375
|
);
|
|
357
|
-
await
|
|
376
|
+
const detectedPorts = await detectListeningPorts(pid);
|
|
377
|
+
const resolvedPorts = detectedPorts.length > 0 ? detectedPorts : ports;
|
|
378
|
+
await queries.updateDevServer(
|
|
379
|
+
"running",
|
|
380
|
+
pid,
|
|
381
|
+
null,
|
|
382
|
+
JSON.stringify(resolvedPorts),
|
|
383
|
+
id
|
|
384
|
+
);
|
|
358
385
|
}
|
|
359
386
|
|
|
360
387
|
return (await queries.getDevServer(id))!;
|
|
@@ -415,23 +442,14 @@ export async function detectNodeServer(
|
|
|
415
442
|
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
|
|
416
443
|
const scripts = packageJson.scripts || {};
|
|
417
444
|
|
|
418
|
-
// Look for common dev server scripts
|
|
419
445
|
const devScripts = ["dev", "start", "serve", "develop"];
|
|
420
446
|
for (const script of devScripts) {
|
|
421
447
|
if (scripts[script]) {
|
|
422
|
-
// Try to detect port from script
|
|
423
|
-
const scriptContent = scripts[script];
|
|
424
|
-
let port = 3000; // default
|
|
425
|
-
const portMatch = scriptContent.match(/(?:port|PORT)[=\s]+(\d+)/i);
|
|
426
|
-
if (portMatch) {
|
|
427
|
-
port = parseInt(portMatch[1], 10);
|
|
428
|
-
}
|
|
429
|
-
|
|
430
448
|
return {
|
|
431
449
|
type: "node",
|
|
432
450
|
name: packageJson.name || path.basename(workingDir),
|
|
433
451
|
command: `npm run ${script}`,
|
|
434
|
-
ports: [
|
|
452
|
+
ports: [],
|
|
435
453
|
};
|
|
436
454
|
}
|
|
437
455
|
}
|
package/lib/status-monitor.ts
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
import type { AgentType } from "./providers";
|
|
21
21
|
import { broadcast } from "./claude/watcher";
|
|
22
22
|
import { getDb } from "./db";
|
|
23
|
+
import { getTunnelUrls } from "./tunnels";
|
|
23
24
|
import { STATES_DIR } from "./hooks/setup";
|
|
24
25
|
import { getSessionInfo } from "@anthropic-ai/claude-agent-sdk";
|
|
25
26
|
|
|
@@ -54,6 +55,8 @@ export interface SessionStatusSnapshot {
|
|
|
54
55
|
waitingContext?: string;
|
|
55
56
|
claudeSessionId: string | null;
|
|
56
57
|
agentType: AgentType;
|
|
58
|
+
listeningPorts: number[];
|
|
59
|
+
tunnelUrls: Record<number, string>;
|
|
57
60
|
}
|
|
58
61
|
|
|
59
62
|
// --- State ---
|
|
@@ -88,6 +91,88 @@ function listStateFiles(): Map<string, StateFile> {
|
|
|
88
91
|
return map;
|
|
89
92
|
}
|
|
90
93
|
|
|
94
|
+
// --- Port detection ---
|
|
95
|
+
|
|
96
|
+
interface ListeningProcess {
|
|
97
|
+
pid: string;
|
|
98
|
+
port: number;
|
|
99
|
+
cwd: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
let cachedListeners: { data: ListeningProcess[]; ts: number } | null = null;
|
|
103
|
+
const LISTENER_CACHE_TTL = 2500;
|
|
104
|
+
|
|
105
|
+
async function getListeningProcesses(): Promise<ListeningProcess[]> {
|
|
106
|
+
const now = Date.now();
|
|
107
|
+
if (cachedListeners && now - cachedListeners.ts < LISTENER_CACHE_TTL) {
|
|
108
|
+
return cachedListeners.data;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
const { stdout } = await execAsync(
|
|
113
|
+
`lsof -P -iTCP -sTCP:LISTEN -Fn 2>/dev/null || true`
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
// Parse lsof output: lines alternate between p (pid) and n (name)
|
|
117
|
+
const results: ListeningProcess[] = [];
|
|
118
|
+
let currentPid = "";
|
|
119
|
+
for (const line of stdout.split("\n")) {
|
|
120
|
+
if (line.startsWith("p")) {
|
|
121
|
+
currentPid = line.slice(1);
|
|
122
|
+
} else if (line.startsWith("n") && currentPid) {
|
|
123
|
+
const port = parseInt(line.slice(line.lastIndexOf(":") + 1), 10);
|
|
124
|
+
if (!isNaN(port) && port > 0) {
|
|
125
|
+
results.push({ pid: currentPid, port, cwd: "" });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Deduplicate by pid+port
|
|
131
|
+
const unique = [
|
|
132
|
+
...new Map(results.map((r) => [`${r.pid}:${r.port}`, r])).values(),
|
|
133
|
+
];
|
|
134
|
+
|
|
135
|
+
// Batch-resolve cwds
|
|
136
|
+
if (unique.length > 0) {
|
|
137
|
+
const pids = [...new Set(unique.map((r) => r.pid))].join(",");
|
|
138
|
+
try {
|
|
139
|
+
const { stdout: cwdOut } = await execAsync(
|
|
140
|
+
`lsof -a -p ${pids} -d cwd -Fpn 2>/dev/null || true`
|
|
141
|
+
);
|
|
142
|
+
const cwdMap = new Map<string, string>();
|
|
143
|
+
let pid = "";
|
|
144
|
+
for (const line of cwdOut.split("\n")) {
|
|
145
|
+
if (line.startsWith("p")) pid = line.slice(1);
|
|
146
|
+
else if (line.startsWith("n") && pid) cwdMap.set(pid, line.slice(1));
|
|
147
|
+
}
|
|
148
|
+
for (const entry of unique) {
|
|
149
|
+
entry.cwd = cwdMap.get(entry.pid) || "";
|
|
150
|
+
}
|
|
151
|
+
} catch {
|
|
152
|
+
// cwd resolution failed, proceed without
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
cachedListeners = { data: unique, ts: now };
|
|
157
|
+
return unique;
|
|
158
|
+
} catch {
|
|
159
|
+
return [];
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function detectSessionPorts(
|
|
164
|
+
sessionCwd: string | null
|
|
165
|
+
): Promise<number[]> {
|
|
166
|
+
if (!sessionCwd) return [];
|
|
167
|
+
|
|
168
|
+
const listeners = await getListeningProcesses();
|
|
169
|
+
return [
|
|
170
|
+
...new Set(
|
|
171
|
+
listeners.filter((l) => l.cwd.startsWith(sessionCwd)).map((l) => l.port)
|
|
172
|
+
),
|
|
173
|
+
].sort((a, b) => a - b);
|
|
174
|
+
}
|
|
175
|
+
|
|
91
176
|
// --- tmux ---
|
|
92
177
|
|
|
93
178
|
async function listTmuxSessions(): Promise<Map<string, string>> {
|
|
@@ -149,14 +234,22 @@ async function buildSnapshot(
|
|
|
149
234
|
const entries = await Promise.all(
|
|
150
235
|
[...tmuxSessions.entries()].map(async ([sessionId, tmuxName]) => {
|
|
151
236
|
const meta = await resolveSessionMeta(sessionId);
|
|
152
|
-
|
|
237
|
+
const ports = await detectSessionPorts(meta.cwd);
|
|
238
|
+
return { sessionId, tmuxName, ports, ...meta };
|
|
153
239
|
})
|
|
154
240
|
);
|
|
155
241
|
|
|
156
|
-
|
|
242
|
+
const db = getDb();
|
|
243
|
+
const allTunnels = getTunnelUrls();
|
|
244
|
+
for (const { sessionId, tmuxName, name, cwd, ports } of entries) {
|
|
157
245
|
const agentType = getProviderIdFromSessionName(tmuxName) || "claude";
|
|
158
246
|
const state = stateFiles.get(sessionId);
|
|
159
247
|
|
|
248
|
+
const tunnelUrls: Record<number, string> = {};
|
|
249
|
+
for (const port of ports) {
|
|
250
|
+
if (allTunnels[port]) tunnelUrls[port] = allTunnels[port];
|
|
251
|
+
}
|
|
252
|
+
|
|
160
253
|
snap[sessionId] = {
|
|
161
254
|
sessionName: name,
|
|
162
255
|
cwd,
|
|
@@ -167,12 +260,21 @@ async function buildSnapshot(
|
|
|
167
260
|
: {}),
|
|
168
261
|
claudeSessionId: sessionId,
|
|
169
262
|
agentType,
|
|
263
|
+
listeningPorts: ports,
|
|
264
|
+
tunnelUrls,
|
|
170
265
|
};
|
|
266
|
+
|
|
267
|
+
try {
|
|
268
|
+
db.prepare(
|
|
269
|
+
"UPDATE sessions SET listening_ports = ? WHERE id = ? OR claude_session_id = ?"
|
|
270
|
+
).run(JSON.stringify(ports), sessionId, sessionId);
|
|
271
|
+
} catch {
|
|
272
|
+
// DB update failure shouldn't break the monitor
|
|
273
|
+
}
|
|
171
274
|
}
|
|
172
275
|
|
|
173
276
|
// Filter out hidden sessions
|
|
174
277
|
try {
|
|
175
|
-
const db = getDb();
|
|
176
278
|
const hiddenRows = db
|
|
177
279
|
.prepare("SELECT item_id FROM hidden_items WHERE item_type = 'session'")
|
|
178
280
|
.all() as { item_id: string }[];
|
|
@@ -200,7 +302,9 @@ function snapshotChanged(
|
|
|
200
302
|
!p ||
|
|
201
303
|
p.status !== n.status ||
|
|
202
304
|
p.lastLine !== n.lastLine ||
|
|
203
|
-
p.sessionName !== n.sessionName
|
|
305
|
+
p.sessionName !== n.sessionName ||
|
|
306
|
+
JSON.stringify(p.listeningPorts) !== JSON.stringify(n.listeningPorts) ||
|
|
307
|
+
JSON.stringify(p.tunnelUrls) !== JSON.stringify(n.tunnelUrls)
|
|
204
308
|
)
|
|
205
309
|
return true;
|
|
206
310
|
}
|
package/lib/tunnels.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { spawn, exec, type ChildProcess } from "child_process";
|
|
2
|
+
import { promisify } from "util";
|
|
3
|
+
import fs from "fs";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import { broadcast } from "./claude/watcher";
|
|
6
|
+
|
|
7
|
+
const execAsync = promisify(exec);
|
|
8
|
+
const LOGS_DIR = path.join(process.env.HOME || "~", ".claude-deck", "logs");
|
|
9
|
+
|
|
10
|
+
const URL_PATTERN = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
|
|
11
|
+
const URL_TIMEOUT_MS = 20000;
|
|
12
|
+
|
|
13
|
+
interface Tunnel {
|
|
14
|
+
port: number;
|
|
15
|
+
pid: number;
|
|
16
|
+
url: string | null;
|
|
17
|
+
process: ChildProcess;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Use globalThis to share the Map across Next.js module boundaries
|
|
21
|
+
// (API routes and the custom server may load this module separately)
|
|
22
|
+
const GLOBAL_KEY = "__claudeDeckTunnels" as const;
|
|
23
|
+
const activeTunnels: Map<number, Tunnel> =
|
|
24
|
+
((globalThis as Record<string, unknown>)[GLOBAL_KEY] as Map<
|
|
25
|
+
number,
|
|
26
|
+
Tunnel
|
|
27
|
+
>) ??
|
|
28
|
+
(() => {
|
|
29
|
+
const map = new Map<number, Tunnel>();
|
|
30
|
+
(globalThis as Record<string, unknown>)[GLOBAL_KEY] = map;
|
|
31
|
+
return map;
|
|
32
|
+
})();
|
|
33
|
+
|
|
34
|
+
export async function isCloudflaredInstalled(): Promise<boolean> {
|
|
35
|
+
try {
|
|
36
|
+
await execAsync("cloudflared --version 2>/dev/null");
|
|
37
|
+
return true;
|
|
38
|
+
} catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function startTunnel(
|
|
44
|
+
port: number
|
|
45
|
+
): Promise<{ port: number; url: string | null }> {
|
|
46
|
+
const existing = activeTunnels.get(port);
|
|
47
|
+
if (existing) return { port: existing.port, url: existing.url };
|
|
48
|
+
|
|
49
|
+
const logPath = path.join(LOGS_DIR, `tunnel_${port}.log`);
|
|
50
|
+
const logStream = fs.createWriteStream(logPath, { flags: "a" });
|
|
51
|
+
|
|
52
|
+
const child = spawn(
|
|
53
|
+
"cloudflared",
|
|
54
|
+
[
|
|
55
|
+
"tunnel",
|
|
56
|
+
"--url",
|
|
57
|
+
`http://localhost:${port}`,
|
|
58
|
+
"--http-host-header",
|
|
59
|
+
`localhost:${port}`,
|
|
60
|
+
],
|
|
61
|
+
{
|
|
62
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
63
|
+
detached: false,
|
|
64
|
+
}
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const tunnel: Tunnel = {
|
|
68
|
+
port,
|
|
69
|
+
pid: child.pid || 0,
|
|
70
|
+
url: null,
|
|
71
|
+
process: child,
|
|
72
|
+
};
|
|
73
|
+
activeTunnels.set(port, tunnel);
|
|
74
|
+
|
|
75
|
+
const url = await new Promise<string | null>((resolve) => {
|
|
76
|
+
const timeout = setTimeout(() => resolve(null), URL_TIMEOUT_MS);
|
|
77
|
+
let resolved = false;
|
|
78
|
+
|
|
79
|
+
const handleData = (chunk: Buffer) => {
|
|
80
|
+
const text = chunk.toString();
|
|
81
|
+
logStream.write(text);
|
|
82
|
+
if (resolved) return;
|
|
83
|
+
const match = text.match(URL_PATTERN);
|
|
84
|
+
if (match) {
|
|
85
|
+
resolved = true;
|
|
86
|
+
clearTimeout(timeout);
|
|
87
|
+
resolve(match[0]);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
child.stdout?.on("data", handleData);
|
|
92
|
+
child.stderr?.on("data", handleData);
|
|
93
|
+
|
|
94
|
+
child.on("exit", () => {
|
|
95
|
+
if (!resolved) {
|
|
96
|
+
clearTimeout(timeout);
|
|
97
|
+
resolve(null);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
tunnel.url = url;
|
|
103
|
+
|
|
104
|
+
child.on("exit", () => {
|
|
105
|
+
activeTunnels.delete(port);
|
|
106
|
+
logStream.end();
|
|
107
|
+
broadcastTunnelUpdate();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
broadcastTunnelUpdate();
|
|
111
|
+
return { port, url };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function stopTunnel(port: number): Promise<void> {
|
|
115
|
+
const tunnel = activeTunnels.get(port);
|
|
116
|
+
if (!tunnel) return;
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
tunnel.process.kill("SIGTERM");
|
|
120
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
121
|
+
if (!tunnel.process.killed) {
|
|
122
|
+
tunnel.process.kill("SIGKILL");
|
|
123
|
+
}
|
|
124
|
+
} catch {
|
|
125
|
+
// Process may already be dead
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
activeTunnels.delete(port);
|
|
129
|
+
broadcastTunnelUpdate();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function getTunnelUrls(): Record<number, string> {
|
|
133
|
+
const result: Record<number, string> = {};
|
|
134
|
+
for (const [port, tunnel] of activeTunnels) {
|
|
135
|
+
if (tunnel.url) result[port] = tunnel.url;
|
|
136
|
+
}
|
|
137
|
+
return result;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function getTunnelsList(): { port: number; url: string | null }[] {
|
|
141
|
+
return [...activeTunnels.values()].map((t) => ({ port: t.port, url: t.url }));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function stopAllTunnels(): void {
|
|
145
|
+
for (const [, tunnel] of activeTunnels) {
|
|
146
|
+
try {
|
|
147
|
+
tunnel.process.kill("SIGTERM");
|
|
148
|
+
} catch {
|
|
149
|
+
// ignore
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
activeTunnels.clear();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function broadcastTunnelUpdate(): void {
|
|
156
|
+
broadcast({ type: "tunnels-updated", tunnels: getTunnelUrls() });
|
|
157
|
+
}
|
package/package.json
CHANGED
|
@@ -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() {
|
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
|
});
|