@atercates/claude-deck 0.2.1 → 0.2.3

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.
Files changed (49) hide show
  1. package/README.md +4 -18
  2. package/app/api/auth/login/route.ts +57 -0
  3. package/app/api/auth/logout/route.ts +13 -0
  4. package/app/api/auth/session/route.ts +29 -0
  5. package/app/api/auth/setup/route.ts +67 -0
  6. package/app/api/projects/[id]/dev-servers/[dsId]/route.ts +1 -1
  7. package/app/api/projects/[id]/repositories/[repoId]/route.ts +1 -1
  8. package/app/api/sessions/[id]/fork/route.ts +1 -1
  9. package/app/api/sessions/[id]/pr/route.ts +1 -1
  10. package/app/api/sessions/[id]/preview/route.ts +1 -1
  11. package/app/api/sessions/[id]/route.ts +13 -4
  12. package/app/api/sessions/[id]/send-keys/route.ts +1 -1
  13. package/app/api/sessions/route.ts +2 -2
  14. package/app/login/page.tsx +192 -0
  15. package/app/setup/page.tsx +279 -0
  16. package/components/ConductorPanel.tsx +1 -1
  17. package/components/DevServers/ServerLogsModal.tsx +24 -21
  18. package/components/DiffViewer/DiffModal.tsx +0 -1
  19. package/components/FileExplorer/index.tsx +1 -1
  20. package/components/GitDrawer/FileEditDialog.tsx +1 -1
  21. package/components/GitPanel/FileChanges.tsx +6 -2
  22. package/components/GitPanel/index.tsx +1 -1
  23. package/components/Pane/index.tsx +16 -15
  24. package/components/Projects/ProjectCard.tsx +1 -1
  25. package/components/QuickSwitcher.tsx +1 -0
  26. package/components/SessionList/SessionList.types.ts +1 -1
  27. package/components/SessionList/index.tsx +8 -8
  28. package/components/Terminal/hooks/useTerminalConnection.ts +3 -2
  29. package/components/Terminal/hooks/websocket-connection.ts +1 -0
  30. package/data/git/queries.ts +0 -1
  31. package/lib/auth/index.ts +15 -0
  32. package/lib/auth/password.ts +14 -0
  33. package/lib/auth/rate-limit.ts +40 -0
  34. package/lib/auth/session.ts +83 -0
  35. package/lib/auth/totp.ts +36 -0
  36. package/lib/claude/process-manager.ts +1 -1
  37. package/lib/code-search.ts +5 -5
  38. package/lib/db/index.ts +1 -1
  39. package/lib/db/queries.ts +64 -0
  40. package/lib/db/schema.ts +19 -0
  41. package/lib/db/types.ts +16 -0
  42. package/lib/git-history.ts +1 -1
  43. package/lib/git.ts +0 -1
  44. package/lib/multi-repo-git.ts +0 -1
  45. package/lib/projects.ts +29 -8
  46. package/package.json +8 -4
  47. package/scripts/agent-os +1 -1
  48. package/scripts/install.sh +2 -2
  49. package/server.ts +20 -1
@@ -0,0 +1,83 @@
1
+ import { randomBytes } from "crypto";
2
+ import { queries } from "@/lib/db";
3
+ import type { User } from "@/lib/db";
4
+
5
+ const SESSION_DURATION_DAYS = 30;
6
+ const SESSION_TOKEN_BYTES = 32;
7
+
8
+ export function createSession(userId: string): {
9
+ token: string;
10
+ expiresAt: string;
11
+ } {
12
+ const id = randomBytes(16).toString("hex");
13
+ const token = randomBytes(SESSION_TOKEN_BYTES).toString("hex");
14
+ const expiresAt = new Date(
15
+ Date.now() + SESSION_DURATION_DAYS * 24 * 60 * 60 * 1000
16
+ ).toISOString();
17
+
18
+ queries.createAuthSession(id, token, userId, expiresAt);
19
+
20
+ return { token, expiresAt };
21
+ }
22
+
23
+ export function validateSession(token: string): User | null {
24
+ if (!token || token.length !== SESSION_TOKEN_BYTES * 2) return null;
25
+
26
+ const session = queries.getAuthSessionByToken(token);
27
+ if (!session) return null;
28
+
29
+ if (new Date(session.expires_at) < new Date()) {
30
+ queries.deleteAuthSession(token);
31
+ return null;
32
+ }
33
+
34
+ const user = queries.getUserById(session.user_id);
35
+ if (!user) {
36
+ queries.deleteAuthSession(token);
37
+ return null;
38
+ }
39
+
40
+ return user;
41
+ }
42
+
43
+ export function renewSession(token: string): void {
44
+ const expiresAt = new Date(
45
+ Date.now() + SESSION_DURATION_DAYS * 24 * 60 * 60 * 1000
46
+ ).toISOString();
47
+ queries.renewAuthSession(token, expiresAt);
48
+ }
49
+
50
+ export function deleteSession(token: string): void {
51
+ queries.deleteAuthSession(token);
52
+ }
53
+
54
+ export function cleanupExpiredSessions(): void {
55
+ queries.deleteExpiredAuthSessions();
56
+ }
57
+
58
+ export const COOKIE_NAME = "claude_deck_session";
59
+
60
+ export function buildSessionCookie(token: string): string {
61
+ const maxAge = SESSION_DURATION_DAYS * 24 * 60 * 60;
62
+ return `${COOKIE_NAME}=${token}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=${maxAge}`;
63
+ }
64
+
65
+ export function buildClearCookie(): string {
66
+ return `${COOKIE_NAME}=; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=0`;
67
+ }
68
+
69
+ export function parseCookies(
70
+ cookieHeader: string | undefined
71
+ ): Record<string, string> {
72
+ if (!cookieHeader) return {};
73
+ return Object.fromEntries(
74
+ cookieHeader.split(";").map((c) => {
75
+ const [key, ...rest] = c.trim().split("=");
76
+ return [key, rest.join("=")];
77
+ })
78
+ );
79
+ }
80
+
81
+ export function hasUsers(): boolean {
82
+ return queries.getUserCount() > 0;
83
+ }
@@ -0,0 +1,36 @@
1
+ import { TOTP, Secret } from "otpauth";
2
+
3
+ const ISSUER = "ClaudeDeck";
4
+
5
+ export function generateTotpSecret(username: string): {
6
+ secret: string;
7
+ uri: string;
8
+ } {
9
+ const secret = new Secret({ size: 20 });
10
+ const totp = new TOTP({
11
+ issuer: ISSUER,
12
+ label: username,
13
+ algorithm: "SHA1",
14
+ digits: 6,
15
+ period: 30,
16
+ secret,
17
+ });
18
+
19
+ return {
20
+ secret: secret.base32,
21
+ uri: totp.toString(),
22
+ };
23
+ }
24
+
25
+ export function verifyTotpCode(secret: string, code: string): boolean {
26
+ const totp = new TOTP({
27
+ issuer: ISSUER,
28
+ algorithm: "SHA1",
29
+ digits: 6,
30
+ period: 30,
31
+ secret: Secret.fromBase32(secret),
32
+ });
33
+
34
+ const delta = totp.validate({ token: code, window: 1 });
35
+ return delta !== null;
36
+ }
@@ -1,7 +1,7 @@
1
1
  import { spawn, ChildProcess } from "child_process";
2
2
  import { WebSocket } from "ws";
3
3
  import { StreamParser } from "./stream-parser";
4
- import { queries, type Session } from "../db";
4
+ import { queries } from "../db";
5
5
  import type { ClaudeSessionOptions, ClientEvent } from "./types";
6
6
 
7
7
  interface ManagedSession {
@@ -1,4 +1,4 @@
1
- import { execSync } from "child_process";
1
+ import { execSync, spawnSync } from "child_process";
2
2
 
3
3
  /**
4
4
  * Check if ripgrep is available on the system
@@ -46,13 +46,13 @@ export function searchCode(
46
46
  const {
47
47
  maxResults = 100,
48
48
  contextLines = 2,
49
- filePattern = "*",
50
- caseSensitive = false,
49
+ filePattern: _filePattern = "*",
50
+ caseSensitive: _caseSensitive = false,
51
51
  } = options;
52
52
 
53
53
  try {
54
54
  // Use spawn instead of execSync for better control
55
- const { spawnSync } = require("child_process");
55
+ // spawnSync imported at top level
56
56
 
57
57
  const args = [
58
58
  "--json",
@@ -100,7 +100,7 @@ export function searchCode(
100
100
  } catch (error) {
101
101
  console.error("Error in searchCode:", error);
102
102
  // ENOENT = command not found
103
- if ((error as any).code === "ENOENT") {
103
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
104
104
  throw new Error(
105
105
  "ripgrep (rg) not found. Install with: brew install ripgrep"
106
106
  );
package/lib/db/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import Database from "better-sqlite3";
2
+ import fs from "fs";
2
3
  import path from "path";
3
4
  import os from "os";
4
5
  import { createSchema } from "./schema";
@@ -14,7 +15,6 @@ let _db: Database.Database | null = null;
14
15
 
15
16
  export function getDb(): Database.Database {
16
17
  if (!_db) {
17
- const fs = require("fs");
18
18
  fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
19
19
 
20
20
  _db = new Database(DB_PATH);
package/lib/db/queries.ts CHANGED
@@ -6,6 +6,8 @@ import type {
6
6
  ProjectDevServer,
7
7
  ProjectRepository,
8
8
  DevServer,
9
+ User,
10
+ AuthSession,
9
11
  } from "./types";
10
12
 
11
13
  function query<T>(sql: string, params: unknown[] = []): T[] {
@@ -457,4 +459,66 @@ export const queries = {
457
459
  itemType,
458
460
  itemId,
459
461
  ]),
462
+
463
+ getUserCount(): number {
464
+ return (
465
+ queryOne<{ count: number }>("SELECT COUNT(*) as count FROM users") ?? {
466
+ count: 0,
467
+ }
468
+ ).count;
469
+ },
470
+
471
+ getUserByUsername(username: string): User | null {
472
+ return queryOne<User>("SELECT * FROM users WHERE username = ?", [username]);
473
+ },
474
+
475
+ getUserById(id: string): User | null {
476
+ return queryOne<User>("SELECT * FROM users WHERE id = ?", [id]);
477
+ },
478
+
479
+ createUser(
480
+ id: string,
481
+ username: string,
482
+ passwordHash: string,
483
+ totpSecret: string | null
484
+ ): void {
485
+ execute(
486
+ "INSERT INTO users (id, username, password_hash, totp_secret) VALUES (?, ?, ?, ?)",
487
+ [id, username, passwordHash, totpSecret]
488
+ );
489
+ },
490
+
491
+ getAuthSessionByToken(token: string): AuthSession | null {
492
+ return queryOne<AuthSession>(
493
+ "SELECT * FROM auth_sessions WHERE token = ?",
494
+ [token]
495
+ );
496
+ },
497
+
498
+ createAuthSession(
499
+ id: string,
500
+ token: string,
501
+ userId: string,
502
+ expiresAt: string
503
+ ): void {
504
+ execute(
505
+ "INSERT INTO auth_sessions (id, token, user_id, expires_at) VALUES (?, ?, ?, ?)",
506
+ [id, token, userId, expiresAt]
507
+ );
508
+ },
509
+
510
+ renewAuthSession(token: string, expiresAt: string): void {
511
+ execute("UPDATE auth_sessions SET expires_at = ? WHERE token = ?", [
512
+ expiresAt,
513
+ token,
514
+ ]);
515
+ },
516
+
517
+ deleteAuthSession(token: string): void {
518
+ execute("DELETE FROM auth_sessions WHERE token = ?", [token]);
519
+ },
520
+
521
+ deleteExpiredAuthSessions(): void {
522
+ execute("DELETE FROM auth_sessions WHERE expires_at < datetime('now')");
523
+ },
460
524
  };
package/lib/db/schema.ts CHANGED
@@ -110,5 +110,24 @@ export function createSchema(db: Database.Database): void {
110
110
 
111
111
  INSERT OR IGNORE INTO projects (id, name, working_directory, is_uncategorized, sort_order)
112
112
  VALUES ('uncategorized', 'Uncategorized', '~', 1, 999999);
113
+
114
+ CREATE TABLE IF NOT EXISTS users (
115
+ id TEXT PRIMARY KEY,
116
+ username TEXT NOT NULL UNIQUE,
117
+ password_hash TEXT NOT NULL,
118
+ totp_secret TEXT,
119
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
120
+ );
121
+
122
+ CREATE TABLE IF NOT EXISTS auth_sessions (
123
+ id TEXT PRIMARY KEY,
124
+ token TEXT NOT NULL UNIQUE,
125
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
126
+ expires_at TEXT NOT NULL,
127
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
128
+ );
129
+
130
+ CREATE INDEX IF NOT EXISTS idx_auth_sessions_token ON auth_sessions(token);
131
+ CREATE INDEX IF NOT EXISTS idx_auth_sessions_expires ON auth_sessions(expires_at);
113
132
  `);
114
133
  }
package/lib/db/types.ts CHANGED
@@ -90,3 +90,19 @@ export interface DevServer {
90
90
  created_at: string;
91
91
  updated_at: string;
92
92
  }
93
+
94
+ export interface User {
95
+ id: string;
96
+ username: string;
97
+ password_hash: string;
98
+ totp_secret: string | null;
99
+ created_at: string;
100
+ }
101
+
102
+ export interface AuthSession {
103
+ id: string;
104
+ token: string;
105
+ user_id: string;
106
+ expires_at: string;
107
+ created_at: string;
108
+ }
@@ -241,7 +241,7 @@ export function getCommitDetail(
241
241
  }
242
242
 
243
243
  // Get total stats
244
- let totalFilesChanged = files.length;
244
+ const totalFilesChanged = files.length;
245
245
  let totalAdditions = 0;
246
246
  let totalDeletions = 0;
247
247
  for (const file of files) {
package/lib/git.ts CHANGED
@@ -5,7 +5,6 @@
5
5
  import { exec } from "child_process";
6
6
  import { promisify } from "util";
7
7
  import * as path from "path";
8
- import * as fs from "fs";
9
8
 
10
9
  const execAsync = promisify(exec);
11
10
 
@@ -7,7 +7,6 @@ import {
7
7
  isGitRepo,
8
8
  expandPath,
9
9
  type GitFile,
10
- type GitStatus,
11
10
  } from "./git-status";
12
11
  import type { ProjectRepository } from "./db";
13
12
 
package/lib/projects.ts CHANGED
@@ -5,7 +5,6 @@
5
5
  * Sessions inherit settings from their parent project.
6
6
  */
7
7
 
8
- import { randomUUID } from "crypto";
9
8
  import fs from "fs";
10
9
  import path from "path";
11
10
  import { exec } from "child_process";
@@ -187,7 +186,9 @@ export async function getAllProjects(): Promise<Project[]> {
187
186
  /**
188
187
  * Get all projects with their dev server configurations
189
188
  */
190
- export async function getAllProjectsWithDevServers(): Promise<ProjectWithRepositories[]> {
189
+ export async function getAllProjectsWithDevServers(): Promise<
190
+ ProjectWithRepositories[]
191
+ > {
191
192
  const projects = await getAllProjects();
192
193
  const result: ProjectWithRepositories[] = [];
193
194
  for (const p of projects) {
@@ -242,7 +243,10 @@ export async function updateProject(
242
243
  /**
243
244
  * Toggle project expanded state
244
245
  */
245
- export async function toggleProjectExpanded(id: string, expanded: boolean): Promise<void> {
246
+ export async function toggleProjectExpanded(
247
+ id: string,
248
+ expanded: boolean
249
+ ): Promise<void> {
246
250
  await queries.updateProjectExpanded(expanded, id);
247
251
  }
248
252
 
@@ -273,7 +277,9 @@ export async function deleteProject(id: string): Promise<boolean> {
273
277
  /**
274
278
  * Get sessions for a project
275
279
  */
276
- export async function getProjectSessions(projectId: string): Promise<Session[]> {
280
+ export async function getProjectSessions(
281
+ projectId: string
282
+ ): Promise<Session[]> {
277
283
  return queries.getSessionsByProject(projectId);
278
284
  }
279
285
 
@@ -478,7 +484,9 @@ export function validateWorkingDirectory(dir: string): boolean {
478
484
  /**
479
485
  * Get repositories for a project
480
486
  */
481
- export async function getProjectRepositories(projectId: string): Promise<ProjectRepository[]> {
487
+ export async function getProjectRepositories(
488
+ projectId: string
489
+ ): Promise<ProjectRepository[]> {
482
490
  const rawRepos = await queries.getProjectRepositories(projectId);
483
491
  return rawRepos.map((r) => ({
484
492
  ...r,
@@ -509,14 +517,23 @@ export async function addProjectRepository(
509
517
  for (const repo of existing) {
510
518
  if (repo.is_primary) {
511
519
  await queries.updateProjectRepository(
512
- repo.name, repo.path, false, repo.sort_order, repo.id
520
+ repo.name,
521
+ repo.path,
522
+ false,
523
+ repo.sort_order,
524
+ repo.id
513
525
  );
514
526
  }
515
527
  }
516
528
  }
517
529
 
518
530
  await queries.createProjectRepository(
519
- id, projectId, opts.name, opts.path, isPrimary, maxOrder + 1
531
+ id,
532
+ projectId,
533
+ opts.name,
534
+ opts.path,
535
+ isPrimary,
536
+ maxOrder + 1
520
537
  );
521
538
 
522
539
  const raw = (await queries.getProjectRepository(id))!;
@@ -549,7 +566,11 @@ export async function updateProjectRepository(
549
566
  for (const repo of allRepos) {
550
567
  if (repo.is_primary && repo.id !== id) {
551
568
  await queries.updateProjectRepository(
552
- repo.name, repo.path, false, repo.sort_order, repo.id
569
+ repo.name,
570
+ repo.path,
571
+ false,
572
+ repo.sort_order,
573
+ repo.id
553
574
  );
554
575
  }
555
576
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atercates/claude-deck",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "claude-deck": "./scripts/claude-deck"
@@ -28,7 +28,7 @@
28
28
  ],
29
29
  "repository": {
30
30
  "type": "git",
31
- "url": "git+https://github.com/atercates/claude-deck.git"
31
+ "url": "git+https://github.com/ATERCATES/claude-deck.git"
32
32
  },
33
33
  "keywords": [
34
34
  "claude",
@@ -43,9 +43,9 @@
43
43
  "node": ">=24"
44
44
  },
45
45
  "bugs": {
46
- "url": "https://github.com/atercates/claude-deck/issues"
46
+ "url": "https://github.com/ATERCATES/claude-deck/issues"
47
47
  },
48
- "homepage": "https://github.com/atercates/claude-deck#readme",
48
+ "homepage": "https://github.com/ATERCATES/claude-deck#readme",
49
49
  "dependencies": {
50
50
  "@anthropic-ai/claude-agent-sdk": "^0.2.104",
51
51
  "@codemirror/lang-css": "^6.3.1",
@@ -78,6 +78,7 @@
78
78
  "@xterm/addon-search": "^0.16.0",
79
79
  "@xterm/addon-web-links": "^0.12.0",
80
80
  "@xterm/xterm": "^6.0.0",
81
+ "bcryptjs": "^3.0.3",
81
82
  "better-sqlite3": "^12.8.0",
82
83
  "chokidar": "^5.0.0",
83
84
  "class-variance-authority": "^0.7.1",
@@ -88,6 +89,8 @@
88
89
  "next": "^16.2.3",
89
90
  "next-themes": "^0.4.6",
90
91
  "node-pty": "1.2.0-beta.12",
92
+ "otpauth": "^9.5.0",
93
+ "qrcode": "^1.5.4",
91
94
  "react": "^19.2.5",
92
95
  "react-dom": "^19.2.5",
93
96
  "react-markdown": "^10.1.0",
@@ -107,6 +110,7 @@
107
110
  "@tauri-apps/cli": "^2.10.1",
108
111
  "@types/better-sqlite3": "^7.6.13",
109
112
  "@types/node": "^25.6.0",
113
+ "@types/qrcode": "^1.5.6",
110
114
  "@types/react": "^19.2.14",
111
115
  "@types/react-dom": "^19.2.3",
112
116
  "@types/ws": "^8.18.1",
package/scripts/agent-os CHANGED
@@ -9,7 +9,7 @@ set -euo pipefail
9
9
  # Configuration
10
10
  AGENT_OS_HOME="${AGENT_OS_HOME:-$HOME/.agent-os}"
11
11
  PORT="${AGENT_OS_PORT:-3011}"
12
- REPO_URL="https://github.com/atercates/agent-os.git"
12
+ REPO_URL="https://github.com/ATERCATES/agent-os.git"
13
13
 
14
14
  # Derived paths
15
15
  REPO_DIR="$AGENT_OS_HOME/repo"
@@ -3,7 +3,7 @@
3
3
  # ClaudeDeck Installer
4
4
  #
5
5
  # Usage:
6
- # curl -fsSL https://raw.githubusercontent.com/atercates/claude-deck/main/scripts/install.sh | bash
6
+ # curl -fsSL https://raw.githubusercontent.com/ATERCATES/claude-deck/main/scripts/install.sh | bash
7
7
  #
8
8
 
9
9
  set -e
@@ -19,7 +19,7 @@ log_info() { echo -e "${BLUE}==>${NC} $1"; }
19
19
  log_success() { echo -e "${GREEN}==>${NC} $1"; }
20
20
  log_error() { echo -e "${RED}==>${NC} $1"; }
21
21
 
22
- REPO_URL="https://github.com/atercates/claude-deck.git"
22
+ REPO_URL="https://github.com/ATERCATES/claude-deck.git"
23
23
  INSTALL_DIR="$HOME/.claude-deck/repo"
24
24
 
25
25
  echo ""
package/server.ts CHANGED
@@ -5,6 +5,12 @@ import { WebSocketServer, WebSocket } from "ws";
5
5
  import * as pty from "node-pty";
6
6
  import { initDb } from "./lib/db";
7
7
  import { startWatcher, addUpdateClient } from "./lib/claude/watcher";
8
+ import {
9
+ validateSession,
10
+ parseCookies,
11
+ COOKIE_NAME,
12
+ hasUsers,
13
+ } from "./lib/auth";
8
14
 
9
15
  const dev = process.env.NODE_ENV !== "production";
10
16
  const hostname = "0.0.0.0";
@@ -35,6 +41,19 @@ app.prepare().then(async () => {
35
41
  server.on("upgrade", (request, socket, head) => {
36
42
  const { pathname } = parse(request.url || "");
37
43
 
44
+ // Validate auth for WebSocket connections
45
+ if (hasUsers()) {
46
+ const cookies = parseCookies(request.headers.cookie);
47
+ const token = cookies[COOKIE_NAME];
48
+ const user = token ? validateSession(token) : null;
49
+
50
+ if (!user) {
51
+ socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
52
+ socket.destroy();
53
+ return;
54
+ }
55
+ }
56
+
38
57
  if (pathname === "/ws/terminal") {
39
58
  terminalWss.handleUpgrade(request, socket, head, (ws) => {
40
59
  terminalWss.emit("connection", ws, request);
@@ -49,7 +68,7 @@ app.prepare().then(async () => {
49
68
 
50
69
  // Heartbeat: ping every 30s, kill if no pong in 10s
51
70
  const HEARTBEAT_INTERVAL = 30000;
52
- const HEARTBEAT_TIMEOUT = 10000;
71
+ const _HEARTBEAT_TIMEOUT = 10000;
53
72
 
54
73
  function setupHeartbeat(ws: WebSocket) {
55
74
  let alive = true;