@atercates/claude-deck 0.2.2 → 0.2.4

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.
@@ -0,0 +1,57 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { queries } from "@/lib/db";
3
+ import {
4
+ verifyPassword,
5
+ verifyTotpCode,
6
+ createSession,
7
+ buildSessionCookie,
8
+ checkRateLimit,
9
+ } from "@/lib/auth";
10
+
11
+ export async function POST(request: NextRequest) {
12
+ const ip =
13
+ request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
14
+ request.headers.get("x-real-ip") ||
15
+ "unknown";
16
+
17
+ const rateCheck = checkRateLimit(ip);
18
+ if (!rateCheck.allowed) {
19
+ return NextResponse.json(
20
+ { error: "Too many login attempts. Try again later." },
21
+ {
22
+ status: 429,
23
+ headers: { "Retry-After": String(rateCheck.retryAfterSeconds) },
24
+ }
25
+ );
26
+ }
27
+
28
+ const body = await request.json();
29
+ const { username, password, totpCode } = body;
30
+
31
+ const INVALID = NextResponse.json(
32
+ { error: "Invalid credentials" },
33
+ { status: 401 }
34
+ );
35
+
36
+ if (!username || !password) return INVALID;
37
+
38
+ const user = queries.getUserByUsername(username);
39
+ if (!user) return INVALID;
40
+
41
+ const validPassword = await verifyPassword(password, user.password_hash);
42
+ if (!validPassword) return INVALID;
43
+
44
+ if (user.totp_secret) {
45
+ if (!totpCode) {
46
+ return NextResponse.json({ requiresTotp: true });
47
+ }
48
+ if (!verifyTotpCode(user.totp_secret, totpCode)) {
49
+ return INVALID;
50
+ }
51
+ }
52
+
53
+ const { token } = createSession(user.id);
54
+ const response = NextResponse.json({ ok: true });
55
+ response.headers.set("Set-Cookie", buildSessionCookie(token));
56
+ return response;
57
+ }
@@ -0,0 +1,13 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { deleteSession, buildClearCookie, COOKIE_NAME } from "@/lib/auth";
3
+
4
+ export async function POST(request: NextRequest) {
5
+ const token = request.cookies.get(COOKIE_NAME)?.value;
6
+ if (token) {
7
+ deleteSession(token);
8
+ }
9
+
10
+ const response = NextResponse.json({ ok: true });
11
+ response.headers.set("Set-Cookie", buildClearCookie());
12
+ return response;
13
+ }
@@ -0,0 +1,29 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import {
3
+ validateSession,
4
+ renewSession,
5
+ COOKIE_NAME,
6
+ hasUsers,
7
+ } from "@/lib/auth";
8
+
9
+ export async function GET(request: NextRequest) {
10
+ if (!hasUsers()) {
11
+ return NextResponse.json({ authenticated: false, needsSetup: true });
12
+ }
13
+
14
+ const token = request.cookies.get(COOKIE_NAME)?.value;
15
+ if (!token) {
16
+ return NextResponse.json({ authenticated: false }, { status: 401 });
17
+ }
18
+
19
+ const user = validateSession(token);
20
+ if (!user) {
21
+ return NextResponse.json({ authenticated: false }, { status: 401 });
22
+ }
23
+
24
+ renewSession(token);
25
+ return NextResponse.json({
26
+ authenticated: true,
27
+ username: user.username,
28
+ });
29
+ }
@@ -0,0 +1,67 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { randomBytes } from "crypto";
3
+ import { queries } from "@/lib/db";
4
+ import {
5
+ hashPassword,
6
+ verifyTotpCode,
7
+ createSession,
8
+ buildSessionCookie,
9
+ hasUsers,
10
+ } from "@/lib/auth";
11
+
12
+ export async function POST(request: NextRequest) {
13
+ if (hasUsers()) {
14
+ return NextResponse.json(
15
+ { error: "Setup already completed" },
16
+ { status: 403 }
17
+ );
18
+ }
19
+
20
+ const body = await request.json();
21
+ const { username, password, totpSecret, totpCode } = body;
22
+
23
+ if (
24
+ !username ||
25
+ typeof username !== "string" ||
26
+ username.length < 3 ||
27
+ username.length > 32 ||
28
+ !/^[a-zA-Z0-9_]+$/.test(username)
29
+ ) {
30
+ return NextResponse.json(
31
+ {
32
+ error:
33
+ "Username must be 3-32 characters, alphanumeric and underscore only",
34
+ },
35
+ { status: 400 }
36
+ );
37
+ }
38
+
39
+ if (!password || typeof password !== "string" || password.length < 8) {
40
+ return NextResponse.json(
41
+ { error: "Password must be at least 8 characters" },
42
+ { status: 400 }
43
+ );
44
+ }
45
+
46
+ if (totpSecret) {
47
+ if (!totpCode || !verifyTotpCode(totpSecret, totpCode)) {
48
+ return NextResponse.json(
49
+ {
50
+ error:
51
+ "Invalid TOTP code. Scan the QR code again and enter the current code.",
52
+ },
53
+ { status: 400 }
54
+ );
55
+ }
56
+ }
57
+
58
+ const id = randomBytes(16).toString("hex");
59
+ const passwordHash = await hashPassword(password);
60
+
61
+ queries.createUser(id, username, passwordHash, totpSecret || null);
62
+
63
+ const { token } = createSession(id);
64
+ const response = NextResponse.json({ ok: true });
65
+ response.headers.set("Set-Cookie", buildSessionCookie(token));
66
+ return response;
67
+ }
@@ -0,0 +1,8 @@
1
+ import { NextResponse } from "next/server";
2
+
3
+ export async function POST() {
4
+ // With JSONL-based detection, acknowledge is a no-op.
5
+ // Status is determined by file content, not by a flag.
6
+ // The endpoint exists for API compatibility.
7
+ return NextResponse.json({ ok: true });
8
+ }
@@ -1,237 +1,6 @@
1
1
  import { NextResponse } from "next/server";
2
- import { exec } from "child_process";
3
- import { promisify } from "util";
4
- import * as fs from "fs";
5
- import * as path from "path";
6
- import * as os from "os";
7
- import { statusDetector, type SessionStatus } from "@/lib/status-detector";
8
- import type { AgentType } from "@/lib/providers";
9
- import {
10
- getManagedSessionPattern,
11
- getProviderIdFromSessionName,
12
- getSessionIdFromName,
13
- } from "@/lib/providers/registry";
14
- import { getDb } from "@/lib/db";
15
-
16
- const execAsync = promisify(exec);
17
-
18
- interface SessionStatusResponse {
19
- sessionName: string;
20
- status: SessionStatus;
21
- lastLine?: string;
22
- claudeSessionId?: string | null;
23
- agentType?: AgentType;
24
- }
25
-
26
- async function getTmuxSessions(): Promise<string[]> {
27
- try {
28
- const { stdout } = await execAsync(
29
- "tmux list-sessions -F '#{session_name}' 2>/dev/null || true"
30
- );
31
- return stdout.trim().split("\n").filter(Boolean);
32
- } catch {
33
- return [];
34
- }
35
- }
36
-
37
- async function getTmuxSessionCwd(sessionName: string): Promise<string | null> {
38
- try {
39
- const { stdout } = await execAsync(
40
- `tmux display-message -t "${sessionName}" -p "#{pane_current_path}" 2>/dev/null || echo ""`
41
- );
42
- const cwd = stdout.trim();
43
- return cwd || null;
44
- } catch {
45
- return null;
46
- }
47
- }
48
-
49
- // Get Claude session ID from tmux environment variable
50
- async function getClaudeSessionIdFromEnv(
51
- sessionName: string
52
- ): Promise<string | null> {
53
- try {
54
- const { stdout } = await execAsync(
55
- `tmux show-environment -t "${sessionName}" CLAUDE_SESSION_ID 2>/dev/null || echo ""`
56
- );
57
- const line = stdout.trim();
58
- if (line.startsWith("CLAUDE_SESSION_ID=")) {
59
- const sessionId = line.replace("CLAUDE_SESSION_ID=", "");
60
- if (sessionId && sessionId !== "null") {
61
- return sessionId;
62
- }
63
- }
64
- return null;
65
- } catch {
66
- return null;
67
- }
68
- }
69
-
70
- // Get Claude session ID by looking at session files on disk
71
- function getClaudeSessionIdFromFiles(projectPath: string): string | null {
72
- const home = os.homedir();
73
- const claudeDir = process.env.CLAUDE_CONFIG_DIR || path.join(home, ".claude");
74
- const projectDirName = projectPath.replace(/\//g, "-");
75
- const projectDir = path.join(claudeDir, "projects", projectDirName);
76
-
77
- if (!fs.existsSync(projectDir)) {
78
- return null;
79
- }
80
-
81
- const uuidPattern =
82
- /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.jsonl$/;
83
-
84
- try {
85
- const files = fs.readdirSync(projectDir);
86
- let mostRecent: string | null = null;
87
- let mostRecentTime = 0;
88
-
89
- for (const file of files) {
90
- if (file.startsWith("agent-")) continue;
91
- if (!uuidPattern.test(file)) continue;
92
-
93
- const filePath = path.join(projectDir, file);
94
- const stat = fs.statSync(filePath);
95
-
96
- if (stat.mtimeMs > mostRecentTime) {
97
- mostRecentTime = stat.mtimeMs;
98
- mostRecent = file.replace(".jsonl", "");
99
- }
100
- }
101
-
102
- if (mostRecent && Date.now() - mostRecentTime < 5 * 60 * 1000) {
103
- return mostRecent;
104
- }
105
-
106
- const configFile = path.join(claudeDir, ".claude.json");
107
- if (fs.existsSync(configFile)) {
108
- try {
109
- const config = JSON.parse(fs.readFileSync(configFile, "utf-8"));
110
- if (config.projects?.[projectPath]?.lastSessionId) {
111
- return config.projects[projectPath].lastSessionId;
112
- }
113
- } catch {
114
- // Ignore config parse errors
115
- }
116
- }
117
-
118
- return null;
119
- } catch {
120
- return null;
121
- }
122
- }
123
-
124
- async function getClaudeSessionId(sessionName: string): Promise<string | null> {
125
- const envId = await getClaudeSessionIdFromEnv(sessionName);
126
- if (envId) {
127
- return envId;
128
- }
129
-
130
- const cwd = await getTmuxSessionCwd(sessionName);
131
- if (cwd) {
132
- return getClaudeSessionIdFromFiles(cwd);
133
- }
134
-
135
- return null;
136
- }
137
-
138
- async function getLastLine(sessionName: string): Promise<string> {
139
- try {
140
- const { stdout } = await execAsync(
141
- `tmux capture-pane -t "${sessionName}" -p -S -5 2>/dev/null || echo ""`
142
- );
143
- const lines = stdout.trim().split("\n").filter(Boolean);
144
- return lines.pop() || "";
145
- } catch {
146
- return "";
147
- }
148
- }
149
-
150
- // UUID pattern for claude-deck managed sessions (derived from registry)
151
- const UUID_PATTERN = getManagedSessionPattern();
152
-
153
- // Track previous statuses to detect changes
154
- const previousStatuses = new Map<string, SessionStatus>();
155
-
156
- function getAgentTypeFromSessionName(sessionName: string): AgentType {
157
- return getProviderIdFromSessionName(sessionName) || "claude";
158
- }
2
+ import { getStatusSnapshot } from "@/lib/status-monitor";
159
3
 
160
4
  export async function GET() {
161
- try {
162
- const sessions = await getTmuxSessions();
163
-
164
- // Get status for claude-deck managed sessions
165
- const managedSessions = sessions.filter((s) => UUID_PATTERN.test(s));
166
-
167
- // Use the new status detector
168
- const statusMap: Record<string, SessionStatusResponse> = {};
169
-
170
- const db = getDb();
171
- const sessionsToUpdate: string[] = [];
172
-
173
- // Process all sessions in parallel for speed
174
- const sessionPromises = managedSessions.map(async (sessionName) => {
175
- const [status, claudeSessionId, lastLine] = await Promise.all([
176
- statusDetector.getStatus(sessionName),
177
- getClaudeSessionId(sessionName),
178
- getLastLine(sessionName),
179
- ]);
180
- const id = getSessionIdFromName(sessionName);
181
- const agentType = getAgentTypeFromSessionName(sessionName);
182
-
183
- return { sessionName, id, status, claudeSessionId, lastLine, agentType };
184
- });
185
-
186
- const results = await Promise.all(sessionPromises);
187
-
188
- for (const {
189
- sessionName,
190
- id,
191
- status,
192
- claudeSessionId,
193
- lastLine,
194
- agentType,
195
- } of results) {
196
- // Track status changes - update DB when session becomes active
197
- const prevStatus = previousStatuses.get(id);
198
- if (status === "running" || status === "waiting") {
199
- if (prevStatus !== status) {
200
- sessionsToUpdate.push(id);
201
- }
202
- }
203
- previousStatuses.set(id, status);
204
-
205
- statusMap[id] = {
206
- sessionName,
207
- status,
208
- lastLine,
209
- claudeSessionId,
210
- agentType,
211
- };
212
- }
213
-
214
- // Batch update sessions and claude_session_id
215
- for (const id of sessionsToUpdate) {
216
- db.prepare(
217
- "UPDATE sessions SET updated_at = datetime('now') WHERE id = ?"
218
- ).run(id);
219
- }
220
-
221
- for (const { id, claudeSessionId } of results) {
222
- if (claudeSessionId) {
223
- db.prepare(
224
- "UPDATE sessions SET claude_session_id = ? WHERE id = ? AND (claude_session_id IS NULL OR claude_session_id != ?)"
225
- ).run(claudeSessionId, id, claudeSessionId);
226
- }
227
- }
228
-
229
- // Cleanup old trackers
230
- statusDetector.cleanup();
231
-
232
- return NextResponse.json({ statuses: statusMap });
233
- } catch (error) {
234
- console.error("Error getting session statuses:", error);
235
- return NextResponse.json({ statuses: {} });
236
- }
5
+ return NextResponse.json({ statuses: getStatusSnapshot() });
237
6
  }
@@ -0,0 +1,192 @@
1
+ "use client";
2
+
3
+ import { useState, useRef, useEffect } from "react";
4
+ import { useRouter } from "next/navigation";
5
+ import { Button } from "@/components/ui/button";
6
+ import { Input } from "@/components/ui/input";
7
+ import { Loader2, Lock, Eye, EyeOff } from "lucide-react";
8
+
9
+ export default function LoginPage() {
10
+ const router = useRouter();
11
+ const [username, setUsername] = useState("");
12
+ const [password, setPassword] = useState("");
13
+ const [totpCode, setTotpCode] = useState("");
14
+ const [showPassword, setShowPassword] = useState(false);
15
+ const [error, setError] = useState("");
16
+ const [loading, setLoading] = useState(false);
17
+ const [step, setStep] = useState<"credentials" | "totp">("credentials");
18
+ const totpInputRef = useRef<HTMLInputElement>(null);
19
+
20
+ useEffect(() => {
21
+ if (step === "totp" && totpInputRef.current) {
22
+ totpInputRef.current.focus();
23
+ }
24
+ }, [step]);
25
+
26
+ const handleSubmit = async (e: React.FormEvent) => {
27
+ e.preventDefault();
28
+ setError("");
29
+ setLoading(true);
30
+
31
+ try {
32
+ const res = await fetch("/api/auth/login", {
33
+ method: "POST",
34
+ headers: { "Content-Type": "application/json" },
35
+ body: JSON.stringify({
36
+ username,
37
+ password,
38
+ ...(step === "totp" ? { totpCode } : {}),
39
+ }),
40
+ });
41
+
42
+ const data = await res.json();
43
+
44
+ if (res.status === 429) {
45
+ setError(
46
+ `Too many attempts. Try again in ${data.retryAfterSeconds || 60}s.`
47
+ );
48
+ return;
49
+ }
50
+
51
+ if (data.requiresTotp) {
52
+ setStep("totp");
53
+ return;
54
+ }
55
+
56
+ if (!res.ok) {
57
+ setError(data.error || "Invalid credentials");
58
+ if (step === "totp") setTotpCode("");
59
+ return;
60
+ }
61
+
62
+ router.push("/");
63
+ router.refresh();
64
+ } catch {
65
+ setError("Connection error");
66
+ } finally {
67
+ setLoading(false);
68
+ }
69
+ };
70
+
71
+ useEffect(() => {
72
+ if (step === "totp" && totpCode.length === 6) {
73
+ const form = document.getElementById("login-form") as HTMLFormElement;
74
+ form?.requestSubmit();
75
+ }
76
+ }, [totpCode, step]);
77
+
78
+ return (
79
+ <div className="bg-background flex min-h-screen items-center justify-center p-4">
80
+ <div className="border-border bg-card w-full max-w-sm rounded-xl border p-8 shadow-lg">
81
+ <div className="mb-8 text-center">
82
+ <div className="bg-primary/10 text-primary mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full">
83
+ <Lock className="h-6 w-6" />
84
+ </div>
85
+ <h1 className="text-foreground text-2xl font-semibold">ClaudeDeck</h1>
86
+ <p className="text-muted-foreground mt-1 text-sm">
87
+ {step === "credentials"
88
+ ? "Sign in to continue"
89
+ : "Enter your 2FA code"}
90
+ </p>
91
+ </div>
92
+
93
+ <form id="login-form" onSubmit={handleSubmit} className="space-y-4">
94
+ {step === "credentials" ? (
95
+ <>
96
+ <div className="space-y-2">
97
+ <label
98
+ htmlFor="username"
99
+ className="text-foreground text-sm font-medium"
100
+ >
101
+ Username
102
+ </label>
103
+ <Input
104
+ id="username"
105
+ type="text"
106
+ value={username}
107
+ onChange={(e) => setUsername(e.target.value)}
108
+ autoComplete="username"
109
+ autoFocus
110
+ required
111
+ />
112
+ </div>
113
+ <div className="space-y-2">
114
+ <label
115
+ htmlFor="password"
116
+ className="text-foreground text-sm font-medium"
117
+ >
118
+ Password
119
+ </label>
120
+ <div className="relative">
121
+ <Input
122
+ id="password"
123
+ type={showPassword ? "text" : "password"}
124
+ value={password}
125
+ onChange={(e) => setPassword(e.target.value)}
126
+ autoComplete="current-password"
127
+ required
128
+ />
129
+ <button
130
+ type="button"
131
+ onClick={() => setShowPassword(!showPassword)}
132
+ className="text-muted-foreground hover:text-foreground absolute top-1/2 right-3 -translate-y-1/2"
133
+ tabIndex={-1}
134
+ >
135
+ {showPassword ? (
136
+ <EyeOff className="h-4 w-4" />
137
+ ) : (
138
+ <Eye className="h-4 w-4" />
139
+ )}
140
+ </button>
141
+ </div>
142
+ </div>
143
+ </>
144
+ ) : (
145
+ <div className="space-y-2">
146
+ <label
147
+ htmlFor="totp"
148
+ className="text-foreground text-sm font-medium"
149
+ >
150
+ Authentication code
151
+ </label>
152
+ <Input
153
+ ref={totpInputRef}
154
+ id="totp"
155
+ type="text"
156
+ inputMode="numeric"
157
+ pattern="[0-9]*"
158
+ maxLength={6}
159
+ value={totpCode}
160
+ onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ""))}
161
+ placeholder="000000"
162
+ className="text-center text-2xl tracking-[0.5em]"
163
+ autoComplete="one-time-code"
164
+ required
165
+ />
166
+ <button
167
+ type="button"
168
+ onClick={() => {
169
+ setStep("credentials");
170
+ setTotpCode("");
171
+ setError("");
172
+ }}
173
+ className="text-muted-foreground hover:text-foreground text-xs underline"
174
+ >
175
+ Back to login
176
+ </button>
177
+ </div>
178
+ )}
179
+
180
+ {error && (
181
+ <p className="text-destructive text-center text-sm">{error}</p>
182
+ )}
183
+
184
+ <Button type="submit" className="w-full" disabled={loading}>
185
+ {loading && <Loader2 className="h-4 w-4 animate-spin" />}
186
+ {step === "credentials" ? "Sign in" : "Verify"}
187
+ </Button>
188
+ </form>
189
+ </div>
190
+ </div>
191
+ );
192
+ }
package/app/page.tsx CHANGED
@@ -296,6 +296,37 @@ function HomeContent() {
296
296
  [addTab, focusedPaneId, buildSessionCommand, runSessionInTerminal]
297
297
  );
298
298
 
299
+ // Attach to an already-running tmux session (non-destructive)
300
+ const attachToActiveTmux = useCallback(
301
+ (sessionId: string, tmuxSessionName: string) => {
302
+ const terminalInfo = getTerminalWithFallback();
303
+ if (!terminalInfo) return;
304
+
305
+ const { terminal, paneId } = terminalInfo;
306
+ const activeTab = getActiveTab(paneId);
307
+ const isInTmux = !!activeTab?.attachedTmux;
308
+
309
+ if (isInTmux) {
310
+ terminal.sendInput("\x02d");
311
+ }
312
+
313
+ setTimeout(
314
+ () => {
315
+ terminal.sendInput("\x03");
316
+ setTimeout(() => {
317
+ terminal.sendCommand(
318
+ `tmux attach -t ${tmuxSessionName} 2>/dev/null`
319
+ );
320
+ attachSession(paneId, sessionId, tmuxSessionName);
321
+ terminal.focus();
322
+ }, 50);
323
+ },
324
+ isInTmux ? 100 : 0
325
+ );
326
+ },
327
+ [getTerminalWithFallback, getActiveTab, attachSession]
328
+ );
329
+
299
330
  const resumeClaudeSession = useCallback(
300
331
  (
301
332
  claudeSessionId: string,
@@ -464,6 +495,7 @@ function HomeContent() {
464
495
  paneId={paneId}
465
496
  sessions={sessions}
466
497
  projects={projects}
498
+ sessionStatuses={sessionStatuses}
467
499
  onRegisterTerminal={registerTerminalRef}
468
500
  onMenuClick={isMobile ? () => setSidebarOpen(true) : undefined}
469
501
  onSelectSession={handleSelectSession}
@@ -473,6 +505,7 @@ function HomeContent() {
473
505
  [
474
506
  sessions,
475
507
  projects,
508
+ sessionStatuses,
476
509
  registerTerminalRef,
477
510
  isMobile,
478
511
  handleSelectSession,
@@ -586,6 +619,7 @@ function HomeContent() {
586
619
  updateSettings,
587
620
  requestPermission,
588
621
  attachToSession,
622
+ attachToActiveTmux,
589
623
  openSessionInNewTab,
590
624
  handleNewSessionInProject,
591
625
  handleOpenTerminal,