@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
|
@@ -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/lib/worktrees.ts
CHANGED
|
@@ -111,6 +111,15 @@ export async function createWorktree(
|
|
|
111
111
|
{ timeout: 30000 }
|
|
112
112
|
);
|
|
113
113
|
lastError = null;
|
|
114
|
+
|
|
115
|
+
// Init submodules if present
|
|
116
|
+
if (fs.existsSync(path.join(worktreePath, ".gitmodules"))) {
|
|
117
|
+
await execAsync(
|
|
118
|
+
`git -C "${worktreePath}" submodule update --init --recursive`,
|
|
119
|
+
{ timeout: 120000 }
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
114
123
|
break; // Success!
|
|
115
124
|
} catch (error: unknown) {
|
|
116
125
|
lastError = error instanceof Error ? error : new Error(String(error));
|