@atercates/claude-deck 0.2.1

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 (293) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +123 -0
  3. package/app/api/claude/hidden/route.ts +66 -0
  4. package/app/api/claude/projects/[name]/sessions/route.ts +71 -0
  5. package/app/api/claude/projects/route.ts +44 -0
  6. package/app/api/code-search/available/route.ts +12 -0
  7. package/app/api/code-search/route.ts +47 -0
  8. package/app/api/dev-servers/[id]/logs/route.ts +23 -0
  9. package/app/api/dev-servers/[id]/restart/route.ts +20 -0
  10. package/app/api/dev-servers/[id]/route.ts +51 -0
  11. package/app/api/dev-servers/[id]/stop/route.ts +20 -0
  12. package/app/api/dev-servers/detect/route.ts +39 -0
  13. package/app/api/dev-servers/route.ts +48 -0
  14. package/app/api/exec/route.ts +60 -0
  15. package/app/api/files/content/route.ts +76 -0
  16. package/app/api/files/route.ts +37 -0
  17. package/app/api/files/upload-temp/route.ts +41 -0
  18. package/app/api/git/check/route.ts +54 -0
  19. package/app/api/git/clone/route.ts +99 -0
  20. package/app/api/git/commit/route.ts +75 -0
  21. package/app/api/git/discard/route.ts +38 -0
  22. package/app/api/git/file-content/route.ts +64 -0
  23. package/app/api/git/history/[hash]/diff/route.ts +38 -0
  24. package/app/api/git/history/[hash]/route.ts +34 -0
  25. package/app/api/git/history/route.ts +27 -0
  26. package/app/api/git/multi-status/route.ts +46 -0
  27. package/app/api/git/pr/route.ts +164 -0
  28. package/app/api/git/push/route.ts +64 -0
  29. package/app/api/git/stage/route.ts +40 -0
  30. package/app/api/git/status/route.ts +51 -0
  31. package/app/api/git/unstage/route.ts +46 -0
  32. package/app/api/groups/[...path]/route.ts +136 -0
  33. package/app/api/groups/route.ts +93 -0
  34. package/app/api/orchestrate/spawn/route.ts +45 -0
  35. package/app/api/orchestrate/workers/[id]/route.ts +89 -0
  36. package/app/api/orchestrate/workers/route.ts +31 -0
  37. package/app/api/projects/[id]/detect/route.ts +27 -0
  38. package/app/api/projects/[id]/dev-servers/[dsId]/route.ts +66 -0
  39. package/app/api/projects/[id]/dev-servers/route.ts +51 -0
  40. package/app/api/projects/[id]/repositories/[repoId]/route.ts +67 -0
  41. package/app/api/projects/[id]/repositories/route.ts +74 -0
  42. package/app/api/projects/[id]/route.ts +108 -0
  43. package/app/api/projects/detect/route.ts +33 -0
  44. package/app/api/projects/route.ts +59 -0
  45. package/app/api/sessions/[id]/claude-session/route.ts +42 -0
  46. package/app/api/sessions/[id]/fork/route.ts +74 -0
  47. package/app/api/sessions/[id]/mcp-config/route.ts +34 -0
  48. package/app/api/sessions/[id]/messages/route.ts +60 -0
  49. package/app/api/sessions/[id]/pr/route.ts +188 -0
  50. package/app/api/sessions/[id]/preview/route.ts +42 -0
  51. package/app/api/sessions/[id]/route.ts +229 -0
  52. package/app/api/sessions/[id]/send-keys/route.ts +119 -0
  53. package/app/api/sessions/[id]/summarize/route.ts +331 -0
  54. package/app/api/sessions/init-script/route.ts +84 -0
  55. package/app/api/sessions/route.ts +209 -0
  56. package/app/api/sessions/status/route.ts +237 -0
  57. package/app/api/system/route.ts +9 -0
  58. package/app/api/tmux/kill-all/route.ts +57 -0
  59. package/app/api/tmux/rename/route.ts +30 -0
  60. package/app/globals.css +174 -0
  61. package/app/icon.svg +11 -0
  62. package/app/layout.tsx +122 -0
  63. package/app/page.tsx +629 -0
  64. package/components/ChatMessage.tsx +65 -0
  65. package/components/ChatView.tsx +276 -0
  66. package/components/ClaudeProjects/ClaudeProjectCard.tsx +195 -0
  67. package/components/ClaudeProjects/ClaudeProjectsSection.tsx +89 -0
  68. package/components/ClaudeProjects/ClaudeSessionCard.tsx +100 -0
  69. package/components/ClaudeProjects/index.ts +1 -0
  70. package/components/CodeSearch/CodeSearchResults.tsx +177 -0
  71. package/components/ConductorPanel.tsx +256 -0
  72. package/components/DevServers/DevServerCard.tsx +311 -0
  73. package/components/DevServers/DevServersSection.tsx +91 -0
  74. package/components/DevServers/ServerLogsModal.tsx +151 -0
  75. package/components/DevServers/StartServerDialog.tsx +359 -0
  76. package/components/DevServers/index.ts +4 -0
  77. package/components/DiffViewer/DiffModal.tsx +151 -0
  78. package/components/DiffViewer/UnifiedDiff.tsx +185 -0
  79. package/components/DiffViewer/index.tsx +2 -0
  80. package/components/DirectoryPicker.tsx +355 -0
  81. package/components/FileExplorer/FileEditor.tsx +276 -0
  82. package/components/FileExplorer/FileTabs.tsx +118 -0
  83. package/components/FileExplorer/FileTree.tsx +214 -0
  84. package/components/FileExplorer/HtmlRenderer.tsx +16 -0
  85. package/components/FileExplorer/MarkdownRenderer.tsx +18 -0
  86. package/components/FileExplorer/index.tsx +520 -0
  87. package/components/FilePicker.tsx +339 -0
  88. package/components/FolderPicker.tsx +201 -0
  89. package/components/GitDrawer/FileEditDialog.tsx +400 -0
  90. package/components/GitDrawer/index.tsx +464 -0
  91. package/components/GitPanel/CommitForm.tsx +205 -0
  92. package/components/GitPanel/CommitHistory.tsx +174 -0
  93. package/components/GitPanel/CommitItem.tsx +196 -0
  94. package/components/GitPanel/FileChanges.tsx +414 -0
  95. package/components/GitPanel/GitPanelTabs.tsx +39 -0
  96. package/components/GitPanel/index.tsx +817 -0
  97. package/components/MessageInput.tsx +82 -0
  98. package/components/NewClaudeSessionDialog.tsx +166 -0
  99. package/components/NewSessionDialog/AdvancedSettings.tsx +78 -0
  100. package/components/NewSessionDialog/AgentSelector.tsx +37 -0
  101. package/components/NewSessionDialog/CreatingOverlay.tsx +94 -0
  102. package/components/NewSessionDialog/NewSessionDialog.types.ts +136 -0
  103. package/components/NewSessionDialog/ProjectSelector.tsx +146 -0
  104. package/components/NewSessionDialog/WorkingDirectoryInput.tsx +55 -0
  105. package/components/NewSessionDialog/WorktreeSection.tsx +92 -0
  106. package/components/NewSessionDialog/hooks/useNewSessionForm.ts +370 -0
  107. package/components/NewSessionDialog/index.tsx +106 -0
  108. package/components/NotificationSettings.tsx +127 -0
  109. package/components/PRCreationModal.tsx +272 -0
  110. package/components/Pane/DesktopTabBar.tsx +353 -0
  111. package/components/Pane/MobileTabBar.tsx +210 -0
  112. package/components/Pane/OpenInVSCode.tsx +69 -0
  113. package/components/Pane/PaneSkeletons.tsx +57 -0
  114. package/components/Pane/index.tsx +558 -0
  115. package/components/PaneLayout.tsx +60 -0
  116. package/components/Projects/DevServersSection.tsx +140 -0
  117. package/components/Projects/DirectoryField.tsx +92 -0
  118. package/components/Projects/NewProjectDialog.tsx +188 -0
  119. package/components/Projects/NewProjectDialog.types.ts +46 -0
  120. package/components/Projects/ProjectCard.tsx +276 -0
  121. package/components/Projects/ProjectSettingsDialog.tsx +811 -0
  122. package/components/Projects/hooks/useNewProjectForm.ts +249 -0
  123. package/components/Projects/index.ts +3 -0
  124. package/components/Providers.tsx +49 -0
  125. package/components/QuickSwitcher.tsx +306 -0
  126. package/components/SessionList/KillAllConfirm.tsx +46 -0
  127. package/components/SessionList/SelectionToolbar.tsx +164 -0
  128. package/components/SessionList/SessionList.types.ts +37 -0
  129. package/components/SessionList/SessionListHeader.tsx +71 -0
  130. package/components/SessionList/hooks/useSessionListMutations.ts +269 -0
  131. package/components/SessionList/index.tsx +189 -0
  132. package/components/ShellDrawer/index.tsx +106 -0
  133. package/components/SidebarFooter.tsx +55 -0
  134. package/components/Terminal/KeybarToggleButton.tsx +45 -0
  135. package/components/Terminal/ScrollToBottomButton.tsx +32 -0
  136. package/components/Terminal/SearchBar.tsx +71 -0
  137. package/components/Terminal/TerminalToolbar.tsx +551 -0
  138. package/components/Terminal/VirtualKeyboard.tsx +711 -0
  139. package/components/Terminal/constants.ts +20 -0
  140. package/components/Terminal/hooks/index.ts +5 -0
  141. package/components/Terminal/hooks/resize-handlers.ts +140 -0
  142. package/components/Terminal/hooks/terminal-init.ts +151 -0
  143. package/components/Terminal/hooks/touch-scroll.ts +155 -0
  144. package/components/Terminal/hooks/useTerminalConnection.ts +282 -0
  145. package/components/Terminal/hooks/useTerminalConnection.types.ts +39 -0
  146. package/components/Terminal/hooks/useTerminalSearch.ts +103 -0
  147. package/components/Terminal/hooks/websocket-connection.ts +274 -0
  148. package/components/Terminal/index.tsx +320 -0
  149. package/components/ThemeToggle.tsx +168 -0
  150. package/components/TmuxSessions.tsx +132 -0
  151. package/components/ToolCallDisplay.tsx +71 -0
  152. package/components/WorkerCard.tsx +245 -0
  153. package/components/a/ABadge.tsx +115 -0
  154. package/components/a/AButton.tsx +163 -0
  155. package/components/a/ADialog.tsx +93 -0
  156. package/components/a/ADropdownMenu.tsx +279 -0
  157. package/components/a/AIconButton.tsx +190 -0
  158. package/components/a/ASheet.tsx +150 -0
  159. package/components/a/ATooltip.tsx +77 -0
  160. package/components/a/index.ts +64 -0
  161. package/components/mobile/SwipeSidebar.tsx +122 -0
  162. package/components/ui/badge.tsx +41 -0
  163. package/components/ui/button.tsx +60 -0
  164. package/components/ui/context-menu.tsx +197 -0
  165. package/components/ui/dialog.tsx +143 -0
  166. package/components/ui/dropdown-menu.tsx +257 -0
  167. package/components/ui/input.tsx +21 -0
  168. package/components/ui/scroll-area.tsx +52 -0
  169. package/components/ui/select.tsx +159 -0
  170. package/components/ui/skeleton.tsx +111 -0
  171. package/components/ui/switch.tsx +31 -0
  172. package/components/ui/textarea.tsx +21 -0
  173. package/components/ui/tooltip.tsx +32 -0
  174. package/components/views/DesktopView.tsx +244 -0
  175. package/components/views/MobileView.tsx +110 -0
  176. package/components/views/types.ts +75 -0
  177. package/contexts/PaneContext.tsx +336 -0
  178. package/data/claude/index.ts +9 -0
  179. package/data/claude/keys.ts +6 -0
  180. package/data/claude/queries.ts +120 -0
  181. package/data/claude/useClaudeUpdates.ts +37 -0
  182. package/data/code-search/index.ts +2 -0
  183. package/data/code-search/keys.ts +7 -0
  184. package/data/code-search/queries.ts +61 -0
  185. package/data/dev-servers/index.ts +8 -0
  186. package/data/dev-servers/keys.ts +4 -0
  187. package/data/dev-servers/queries.ts +104 -0
  188. package/data/files/index.ts +3 -0
  189. package/data/files/keys.ts +4 -0
  190. package/data/files/queries.ts +25 -0
  191. package/data/git/keys.ts +15 -0
  192. package/data/git/queries.ts +395 -0
  193. package/data/groups/index.ts +1 -0
  194. package/data/groups/mutations.ts +95 -0
  195. package/data/projects/index.ts +10 -0
  196. package/data/projects/keys.ts +4 -0
  197. package/data/projects/queries.ts +193 -0
  198. package/data/repositories/index.ts +7 -0
  199. package/data/repositories/keys.ts +5 -0
  200. package/data/repositories/queries.ts +122 -0
  201. package/data/sessions/index.ts +12 -0
  202. package/data/sessions/keys.ts +8 -0
  203. package/data/sessions/queries.ts +218 -0
  204. package/data/statuses/index.ts +1 -0
  205. package/data/statuses/queries.ts +69 -0
  206. package/hooks/useCopyToClipboard.ts +48 -0
  207. package/hooks/useDevServersManager.ts +73 -0
  208. package/hooks/useDirectoryBrowser.ts +90 -0
  209. package/hooks/useDrawerAnimation.ts +27 -0
  210. package/hooks/useFileDrop.ts +87 -0
  211. package/hooks/useFileEditor.ts +184 -0
  212. package/hooks/useGroups.ts +37 -0
  213. package/hooks/useHomePath.ts +34 -0
  214. package/hooks/useKeyRepeat.ts +55 -0
  215. package/hooks/useKeybarVisibility.ts +42 -0
  216. package/hooks/useNotifications.ts +257 -0
  217. package/hooks/useProjects.ts +53 -0
  218. package/hooks/useSessionStatuses.ts +30 -0
  219. package/hooks/useSessions.ts +86 -0
  220. package/hooks/useSpeechRecognition.ts +124 -0
  221. package/hooks/useViewport.ts +32 -0
  222. package/hooks/useViewportHeight.ts +50 -0
  223. package/lib/async-operations.ts +35 -0
  224. package/lib/banner.ts +81 -0
  225. package/lib/claude/jsonl-cache.ts +86 -0
  226. package/lib/claude/jsonl-reader.ts +271 -0
  227. package/lib/claude/process-manager.ts +278 -0
  228. package/lib/claude/stream-parser.ts +173 -0
  229. package/lib/claude/types.ts +154 -0
  230. package/lib/claude/watcher.ts +71 -0
  231. package/lib/client/session-registry.ts +111 -0
  232. package/lib/code-search.ts +121 -0
  233. package/lib/db/index.ts +48 -0
  234. package/lib/db/migrations.ts +45 -0
  235. package/lib/db/queries.ts +460 -0
  236. package/lib/db/schema.ts +114 -0
  237. package/lib/db/types.ts +92 -0
  238. package/lib/db.ts +2 -0
  239. package/lib/dev-servers.ts +509 -0
  240. package/lib/diff-parser.ts +221 -0
  241. package/lib/env-setup.ts +285 -0
  242. package/lib/file-upload.ts +34 -0
  243. package/lib/file-utils.ts +50 -0
  244. package/lib/files.ts +207 -0
  245. package/lib/git-history.ts +294 -0
  246. package/lib/git-status.ts +391 -0
  247. package/lib/git.ts +257 -0
  248. package/lib/mcp-config.ts +81 -0
  249. package/lib/multi-repo-git.ts +179 -0
  250. package/lib/notifications.ts +219 -0
  251. package/lib/orchestration.ts +448 -0
  252. package/lib/panes.ts +232 -0
  253. package/lib/ports.ts +97 -0
  254. package/lib/pr-generation.ts +307 -0
  255. package/lib/pr.ts +234 -0
  256. package/lib/projects.ts +578 -0
  257. package/lib/providers/registry.ts +70 -0
  258. package/lib/providers.ts +121 -0
  259. package/lib/query-client.ts +14 -0
  260. package/lib/rangeSelectionUtils.ts +65 -0
  261. package/lib/status-detector.ts +375 -0
  262. package/lib/terminal-themes.ts +265 -0
  263. package/lib/theme-config.ts +327 -0
  264. package/lib/utils.ts +6 -0
  265. package/lib/worktrees.ts +262 -0
  266. package/mcp/orchestration-server.ts +438 -0
  267. package/package.json +139 -0
  268. package/postcss.config.mjs +7 -0
  269. package/public/icon.svg +10 -0
  270. package/public/icons/icon-128x128.png +0 -0
  271. package/public/icons/icon-144x144.png +0 -0
  272. package/public/icons/icon-152x152.png +0 -0
  273. package/public/icons/icon-192x192.png +0 -0
  274. package/public/icons/icon-384x384.png +0 -0
  275. package/public/icons/icon-512x512.png +0 -0
  276. package/public/icons/icon-72x72.png +0 -0
  277. package/public/icons/icon-96x96.png +0 -0
  278. package/public/manifest.json +61 -0
  279. package/public/sw.js +64 -0
  280. package/scripts/agent-os +91 -0
  281. package/scripts/install.sh +48 -0
  282. package/scripts/lib/ai-clis.sh +132 -0
  283. package/scripts/lib/commands.sh +487 -0
  284. package/scripts/lib/common.sh +89 -0
  285. package/scripts/lib/prerequisites.sh +462 -0
  286. package/scripts/setup.sh +134 -0
  287. package/server.ts +155 -0
  288. package/stores/fileOpen.ts +26 -0
  289. package/stores/index.ts +1 -0
  290. package/stores/initialPrompt.ts +24 -0
  291. package/stores/sessionSelection.ts +48 -0
  292. package/styles/themes.css +603 -0
  293. package/tsconfig.json +33 -0
@@ -0,0 +1,509 @@
1
+ import { spawn, exec } from "child_process";
2
+ import { promisify } from "util";
3
+ import fs from "fs";
4
+ import path from "path";
5
+ import { queries, type DevServer, type DevServerType, type DevServerStatus } from "./db";
6
+
7
+ const execAsync = promisify(exec);
8
+
9
+ const LOGS_DIR = path.join(process.env.HOME || "~", ".claude-deck", "logs");
10
+
11
+ // Ensure logs directory exists
12
+ if (!fs.existsSync(LOGS_DIR)) {
13
+ fs.mkdirSync(LOGS_DIR, { recursive: true });
14
+ }
15
+
16
+ export interface StartServerOptions {
17
+ projectId: string;
18
+ type: DevServerType;
19
+ name: string;
20
+ command: string;
21
+ workingDirectory: string;
22
+ ports?: number[];
23
+ }
24
+
25
+ export interface DetectedServer {
26
+ type: DevServerType;
27
+ name: string;
28
+ command: string;
29
+ ports: number[];
30
+ }
31
+
32
+ // Generate unique ID
33
+ function generateId(): string {
34
+ return `ds_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
35
+ }
36
+
37
+ // Get log file path for a server
38
+ export function getLogPath(serverId: string): string {
39
+ return path.join(LOGS_DIR, `${serverId}.log`);
40
+ }
41
+
42
+ // Check if a process is running by PID
43
+ async function isPidRunning(pid: number): Promise<boolean> {
44
+ try {
45
+ process.kill(pid, 0);
46
+ return true;
47
+ } catch {
48
+ return false;
49
+ }
50
+ }
51
+
52
+ // Check if a port is in use
53
+ async function isPortInUse(port: number): Promise<boolean> {
54
+ try {
55
+ const { stdout } = await execAsync(
56
+ `lsof -i :${port} -t 2>/dev/null || true`
57
+ );
58
+ return stdout.trim().length > 0;
59
+ } catch {
60
+ return false;
61
+ }
62
+ }
63
+
64
+ // Get PID using a port
65
+ async function getPidOnPort(port: number): Promise<number | null> {
66
+ try {
67
+ const { stdout } = await execAsync(
68
+ `lsof -i :${port} -t 2>/dev/null | head -1`
69
+ );
70
+ const pid = parseInt(stdout.trim(), 10);
71
+ return isNaN(pid) ? null : pid;
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+
77
+ // Check Node.js server status
78
+ async function checkNodeStatus(server: DevServer): Promise<DevServerStatus> {
79
+ if (server.pid) {
80
+ const running = await isPidRunning(server.pid);
81
+ if (running) return "running";
82
+ }
83
+
84
+ // Check if any of its ports are in use
85
+ const ports: number[] = JSON.parse(server.ports || "[]");
86
+ for (const port of ports) {
87
+ if (await isPortInUse(port)) {
88
+ // Port is in use, try to get the PID
89
+ const pid = await getPidOnPort(port);
90
+ if (pid) {
91
+ // Update PID in database
92
+ await queries.updateDevServerPid(pid, "running", server.id);
93
+ return "running";
94
+ }
95
+ }
96
+ }
97
+
98
+ return "stopped";
99
+ }
100
+
101
+ // Check Docker service status
102
+ async function checkDockerStatus(server: DevServer): Promise<DevServerStatus> {
103
+ if (!server.container_id) return "stopped";
104
+
105
+ try {
106
+ const { stdout } = await execAsync(
107
+ `docker inspect -f '{{.State.Status}}' ${server.container_id} 2>/dev/null || echo ""`
108
+ );
109
+ const status = stdout.trim();
110
+ if (status === "running") return "running";
111
+ if (status === "starting" || status === "restarting") return "starting";
112
+ return "stopped";
113
+ } catch {
114
+ return "stopped";
115
+ }
116
+ }
117
+
118
+ // Get live status for a server
119
+ export async function getServerStatus(
120
+ server: DevServer
121
+ ): Promise<DevServerStatus> {
122
+ if (server.type === "docker") {
123
+ return checkDockerStatus(server);
124
+ }
125
+ return checkNodeStatus(server);
126
+ }
127
+
128
+ // Get all servers with live status
129
+ export async function getAllServers(): Promise<DevServer[]> {
130
+ const servers = await queries.getAllDevServers();
131
+
132
+ // Update status for each server
133
+ for (const server of servers) {
134
+ const liveStatus = await getServerStatus(server);
135
+ if (liveStatus !== server.status) {
136
+ await queries.updateDevServerStatus(liveStatus, server.id);
137
+ server.status = liveStatus;
138
+ }
139
+ }
140
+
141
+ return servers;
142
+ }
143
+
144
+ // Get servers for a project
145
+ export async function getServersByProject(
146
+ projectId: string
147
+ ): Promise<DevServer[]> {
148
+ const servers = await queries.getDevServersByProject(projectId);
149
+
150
+ for (const server of servers) {
151
+ const liveStatus = await getServerStatus(server);
152
+ if (liveStatus !== server.status) {
153
+ await queries.updateDevServerStatus(liveStatus, server.id);
154
+ server.status = liveStatus;
155
+ }
156
+ }
157
+
158
+ return servers;
159
+ }
160
+
161
+ // Expand ~ to home directory
162
+ function expandHome(filePath: string): string {
163
+ if (filePath.startsWith("~/")) {
164
+ return path.join(process.env.HOME || "", filePath.slice(2));
165
+ }
166
+ return filePath;
167
+ }
168
+
169
+ // Start a Node.js server
170
+ async function spawnNodeServer(
171
+ id: string,
172
+ command: string,
173
+ workingDirectory: string,
174
+ ports: number[]
175
+ ): Promise<{ pid: number }> {
176
+ const logPath = getLogPath(id);
177
+ // Open log file for appending (get file descriptor for spawn)
178
+ const logFd = fs.openSync(logPath, "a");
179
+
180
+ // Expand ~ to absolute path - Node.js spawn doesn't handle tilde
181
+ const cwd = expandHome(workingDirectory);
182
+
183
+ // Build minimal env - only essentials for shell to work
184
+ // This lets Next.js/Vite/etc load .env.local without interference from parent process env
185
+ const env: Record<string, string | undefined> = {
186
+ PATH: process.env.PATH,
187
+ HOME: process.env.HOME,
188
+ USER: process.env.USER,
189
+ SHELL: process.env.SHELL,
190
+ TERM: process.env.TERM || "xterm-256color",
191
+ LANG: process.env.LANG || "en_US.UTF-8",
192
+ };
193
+
194
+ // Add port if specified
195
+ if (ports.length > 0) {
196
+ env.PORT = String(ports[0]);
197
+ }
198
+
199
+ const fullCommand = `cd "${cwd}" && ${command}`;
200
+
201
+ const child = spawn(fullCommand, [], {
202
+ cwd,
203
+ env: env as NodeJS.ProcessEnv,
204
+ shell: true,
205
+ detached: true,
206
+ stdio: ["ignore", logFd, logFd],
207
+ });
208
+
209
+ if (child.pid) child.unref();
210
+
211
+ // Close our reference to the fd - the child process has its own
212
+ fs.closeSync(logFd);
213
+
214
+ // Give it a moment to start
215
+ await new Promise((resolve) => setTimeout(resolve, 500));
216
+
217
+ return { pid: child.pid || 0 };
218
+ }
219
+
220
+ // Start a Docker Compose service
221
+ async function spawnDockerService(
222
+ command: string,
223
+ workingDirectory: string
224
+ ): Promise<{ containerId: string | null }> {
225
+ try {
226
+ // command is expected to be the service name
227
+ await execAsync(`docker compose up -d ${command}`, {
228
+ cwd: workingDirectory,
229
+ });
230
+
231
+ // Get container ID
232
+ const { stdout } = await execAsync(
233
+ `docker compose ps -q ${command} 2>/dev/null || echo ""`,
234
+ { cwd: workingDirectory }
235
+ );
236
+ const containerId = stdout.trim() || null;
237
+
238
+ return { containerId };
239
+ } catch (error) {
240
+ console.error("Failed to start Docker service:", error);
241
+ return { containerId: null };
242
+ }
243
+ }
244
+
245
+ // Start a server
246
+ export async function startServer(
247
+ opts: StartServerOptions
248
+ ): Promise<DevServer> {
249
+ const id = generateId();
250
+ const ports = opts.ports || [];
251
+
252
+ // Create database record first
253
+ await queries.createDevServer(
254
+ id,
255
+ opts.projectId,
256
+ opts.type,
257
+ opts.name,
258
+ opts.command,
259
+ "starting",
260
+ null, // pid
261
+ null, // container_id
262
+ JSON.stringify(ports),
263
+ opts.workingDirectory
264
+ );
265
+
266
+ try {
267
+ if (opts.type === "docker") {
268
+ const { containerId } = await spawnDockerService(
269
+ opts.command,
270
+ opts.workingDirectory
271
+ );
272
+ await queries.updateDevServer("running", null, containerId, JSON.stringify(ports), id);
273
+ } else {
274
+ const { pid } = await spawnNodeServer(
275
+ id,
276
+ opts.command,
277
+ opts.workingDirectory,
278
+ ports
279
+ );
280
+ await queries.updateDevServer("running", pid, null, JSON.stringify(ports), id);
281
+ }
282
+ } catch (error) {
283
+ await queries.updateDevServerStatus("failed", id);
284
+ throw error;
285
+ }
286
+
287
+ return (await queries.getDevServer(id))!;
288
+ }
289
+
290
+ // Stop a server
291
+ export async function stopServer(id: string): Promise<void> {
292
+ const server = await queries.getDevServer(id);
293
+ if (!server) return;
294
+
295
+ if (server.type === "docker") {
296
+ if (server.container_id) {
297
+ try {
298
+ await execAsync(`docker stop ${server.container_id}`);
299
+ } catch {
300
+ // Container may already be stopped
301
+ }
302
+ }
303
+ } else {
304
+ if (server.pid) {
305
+ try {
306
+ process.kill(server.pid, "SIGTERM");
307
+ // Give it time to gracefully shut down
308
+ await new Promise((resolve) => setTimeout(resolve, 1000));
309
+ // Force kill if still running
310
+ if (await isPidRunning(server.pid)) {
311
+ process.kill(server.pid, "SIGKILL");
312
+ }
313
+ } catch {
314
+ // Process may already be dead
315
+ }
316
+ }
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
+ }
331
+
332
+ await queries.updateDevServerStatus("stopped", id);
333
+ }
334
+
335
+ // Restart a server
336
+ export async function restartServer(id: string): Promise<DevServer> {
337
+ const server = await queries.getDevServer(id);
338
+ if (!server) throw new Error("Server not found");
339
+
340
+ await stopServer(id);
341
+
342
+ // Re-start with same config
343
+ if (server.type === "docker") {
344
+ const { containerId } = await spawnDockerService(
345
+ server.command,
346
+ server.working_directory
347
+ );
348
+ await queries.updateDevServer("running", null, containerId, server.ports, id);
349
+ } else {
350
+ const ports: number[] = JSON.parse(server.ports || "[]");
351
+ const { pid } = await spawnNodeServer(
352
+ id,
353
+ server.command,
354
+ server.working_directory,
355
+ ports
356
+ );
357
+ await queries.updateDevServer("running", pid, null, server.ports, id);
358
+ }
359
+
360
+ return (await queries.getDevServer(id))!;
361
+ }
362
+
363
+ // Remove a server (stop and delete)
364
+ export async function removeServer(id: string): Promise<void> {
365
+ await stopServer(id);
366
+ await queries.deleteDevServer(id);
367
+
368
+ // Clean up log file
369
+ const logPath = getLogPath(id);
370
+ if (fs.existsSync(logPath)) {
371
+ fs.unlinkSync(logPath);
372
+ }
373
+ }
374
+
375
+ // Get logs for a server
376
+ export async function getServerLogs(
377
+ id: string,
378
+ lines = 100
379
+ ): Promise<string[]> {
380
+ const server = await queries.getDevServer(id);
381
+ if (!server) return [];
382
+
383
+ if (server.type === "docker" && server.container_id) {
384
+ try {
385
+ const { stdout } = await execAsync(
386
+ `docker logs --tail ${lines} ${server.container_id} 2>&1`
387
+ );
388
+ return stdout.split("\n");
389
+ } catch {
390
+ return [];
391
+ }
392
+ }
393
+
394
+ // Node.js server - read log file
395
+ const logPath = getLogPath(id);
396
+ if (!fs.existsSync(logPath)) return [];
397
+
398
+ try {
399
+ const content = fs.readFileSync(logPath, "utf-8");
400
+ const allLines = content.split("\n");
401
+ return allLines.slice(-lines);
402
+ } catch {
403
+ return [];
404
+ }
405
+ }
406
+
407
+ // Detect available Node.js dev server in a directory
408
+ export async function detectNodeServer(
409
+ workingDir: string
410
+ ): Promise<DetectedServer | null> {
411
+ const packageJsonPath = path.join(workingDir, "package.json");
412
+ if (!fs.existsSync(packageJsonPath)) return null;
413
+
414
+ try {
415
+ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
416
+ const scripts = packageJson.scripts || {};
417
+
418
+ // Look for common dev server scripts
419
+ const devScripts = ["dev", "start", "serve", "develop"];
420
+ for (const script of devScripts) {
421
+ 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
+ return {
431
+ type: "node",
432
+ name: packageJson.name || path.basename(workingDir),
433
+ command: `npm run ${script}`,
434
+ ports: [port],
435
+ };
436
+ }
437
+ }
438
+ } catch {
439
+ // Ignore parse errors
440
+ }
441
+
442
+ return null;
443
+ }
444
+
445
+ // Detect Docker Compose services in a directory
446
+ export async function detectDockerServices(
447
+ workingDir: string
448
+ ): Promise<DetectedServer[]> {
449
+ const composeFiles = [
450
+ "docker-compose.yml",
451
+ "docker-compose.yaml",
452
+ "compose.yml",
453
+ "compose.yaml",
454
+ ];
455
+
456
+ for (const file of composeFiles) {
457
+ const composePath = path.join(workingDir, file);
458
+ if (fs.existsSync(composePath)) {
459
+ try {
460
+ const { stdout } = await execAsync(
461
+ `docker compose -f ${file} config --services 2>/dev/null || echo ""`,
462
+ { cwd: workingDir }
463
+ );
464
+ const services = stdout.trim().split("\n").filter(Boolean);
465
+
466
+ return services.map((service) => ({
467
+ type: "docker" as const,
468
+ name: service,
469
+ command: service,
470
+ ports: [], // Would need to parse compose file for ports
471
+ }));
472
+ } catch {
473
+ // Docker not available or compose file invalid
474
+ }
475
+ }
476
+ }
477
+
478
+ return [];
479
+ }
480
+
481
+ // Detect all available servers in a directory
482
+ export async function detectServers(
483
+ workingDir: string
484
+ ): Promise<DetectedServer[]> {
485
+ const servers: DetectedServer[] = [];
486
+
487
+ const nodeServer = await detectNodeServer(workingDir);
488
+ if (nodeServer) {
489
+ servers.push(nodeServer);
490
+ }
491
+
492
+ const dockerServices = await detectDockerServices(workingDir);
493
+ servers.push(...dockerServices);
494
+
495
+ return servers;
496
+ }
497
+
498
+ // Clean up orphaned servers on startup
499
+ export async function cleanupOrphanedServers(): Promise<void> {
500
+ const servers = await queries.getAllDevServers();
501
+
502
+ for (const server of servers) {
503
+ const liveStatus = await getServerStatus(server);
504
+ if (server.status === "running" && liveStatus === "stopped") {
505
+ // Server was running but is now dead
506
+ await queries.updateDevServerStatus("stopped", server.id);
507
+ }
508
+ }
509
+ }
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Unified diff parser
3
+ * Parses git diff output into structured data
4
+ */
5
+
6
+ export interface DiffLine {
7
+ type: "context" | "addition" | "deletion" | "header";
8
+ content: string;
9
+ oldLineNumber: number | null;
10
+ newLineNumber: number | null;
11
+ }
12
+
13
+ export interface DiffHunk {
14
+ header: string;
15
+ oldStart: number;
16
+ oldCount: number;
17
+ newStart: number;
18
+ newCount: number;
19
+ lines: DiffLine[];
20
+ }
21
+
22
+ export interface ParsedDiff {
23
+ oldFile: string;
24
+ newFile: string;
25
+ hunks: DiffHunk[];
26
+ additions: number;
27
+ deletions: number;
28
+ isBinary: boolean;
29
+ isNew: boolean;
30
+ isDeleted: boolean;
31
+ isRenamed: boolean;
32
+ }
33
+
34
+ /**
35
+ * Parse a unified diff string into structured data
36
+ */
37
+ export function parseDiff(diffText: string): ParsedDiff {
38
+ const lines = diffText.split("\n");
39
+
40
+ let oldFile = "";
41
+ let newFile = "";
42
+ const hunks: DiffHunk[] = [];
43
+ let currentHunk: DiffHunk | null = null;
44
+ let additions = 0;
45
+ let deletions = 0;
46
+ let isBinary = false;
47
+ let isNew = false;
48
+ let isDeleted = false;
49
+ let isRenamed = false;
50
+
51
+ let oldLineNum = 0;
52
+ let newLineNum = 0;
53
+
54
+ for (let i = 0; i < lines.length; i++) {
55
+ const line = lines[i];
56
+
57
+ // Binary file detection
58
+ if (line.startsWith("Binary files")) {
59
+ isBinary = true;
60
+ continue;
61
+ }
62
+
63
+ // Old file header
64
+ if (line.startsWith("--- ")) {
65
+ oldFile = line.slice(4);
66
+ if (oldFile === "/dev/null") {
67
+ isNew = true;
68
+ }
69
+ // Strip a/ prefix
70
+ if (oldFile.startsWith("a/")) {
71
+ oldFile = oldFile.slice(2);
72
+ }
73
+ continue;
74
+ }
75
+
76
+ // New file header
77
+ if (line.startsWith("+++ ")) {
78
+ newFile = line.slice(4);
79
+ if (newFile === "/dev/null") {
80
+ isDeleted = true;
81
+ }
82
+ // Strip b/ prefix
83
+ if (newFile.startsWith("b/")) {
84
+ newFile = newFile.slice(2);
85
+ }
86
+ continue;
87
+ }
88
+
89
+ // Rename detection
90
+ if (line.startsWith("rename from ") || line.startsWith("rename to ")) {
91
+ isRenamed = true;
92
+ continue;
93
+ }
94
+
95
+ // Hunk header: @@ -start,count +start,count @@
96
+ const hunkMatch = line.match(
97
+ /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/
98
+ );
99
+ if (hunkMatch) {
100
+ if (currentHunk) {
101
+ hunks.push(currentHunk);
102
+ }
103
+
104
+ const oldStart = parseInt(hunkMatch[1], 10);
105
+ const oldCount = hunkMatch[2] ? parseInt(hunkMatch[2], 10) : 1;
106
+ const newStart = parseInt(hunkMatch[3], 10);
107
+ const newCount = hunkMatch[4] ? parseInt(hunkMatch[4], 10) : 1;
108
+ const context = hunkMatch[5] || "";
109
+
110
+ currentHunk = {
111
+ header: line,
112
+ oldStart,
113
+ oldCount,
114
+ newStart,
115
+ newCount,
116
+ lines: [],
117
+ };
118
+
119
+ // Add header line with context (function name, etc.)
120
+ if (context.trim()) {
121
+ currentHunk.lines.push({
122
+ type: "header",
123
+ content: context.trim(),
124
+ oldLineNumber: null,
125
+ newLineNumber: null,
126
+ });
127
+ }
128
+
129
+ oldLineNum = oldStart;
130
+ newLineNum = newStart;
131
+ continue;
132
+ }
133
+
134
+ // Inside a hunk
135
+ if (currentHunk) {
136
+ if (line.startsWith("+")) {
137
+ currentHunk.lines.push({
138
+ type: "addition",
139
+ content: line.slice(1),
140
+ oldLineNumber: null,
141
+ newLineNumber: newLineNum,
142
+ });
143
+ newLineNum++;
144
+ additions++;
145
+ } else if (line.startsWith("-")) {
146
+ currentHunk.lines.push({
147
+ type: "deletion",
148
+ content: line.slice(1),
149
+ oldLineNumber: oldLineNum,
150
+ newLineNumber: null,
151
+ });
152
+ oldLineNum++;
153
+ deletions++;
154
+ } else if (line.startsWith(" ") || line === "") {
155
+ // Context line or empty
156
+ const content = line.startsWith(" ") ? line.slice(1) : line;
157
+ currentHunk.lines.push({
158
+ type: "context",
159
+ content,
160
+ oldLineNumber: oldLineNum,
161
+ newLineNumber: newLineNum,
162
+ });
163
+ oldLineNum++;
164
+ newLineNum++;
165
+ }
166
+ // Skip other lines like ""
167
+ }
168
+ }
169
+
170
+ // Don't forget the last hunk
171
+ if (currentHunk) {
172
+ hunks.push(currentHunk);
173
+ }
174
+
175
+ return {
176
+ oldFile,
177
+ newFile,
178
+ hunks,
179
+ additions,
180
+ deletions,
181
+ isBinary,
182
+ isNew,
183
+ isDeleted,
184
+ isRenamed,
185
+ };
186
+ }
187
+
188
+ /**
189
+ * Get a summary string for a diff
190
+ */
191
+ export function getDiffSummary(diff: ParsedDiff): string {
192
+ if (diff.isBinary) {
193
+ return "Binary file";
194
+ }
195
+ if (diff.isNew) {
196
+ return `New file (+${diff.additions})`;
197
+ }
198
+ if (diff.isDeleted) {
199
+ return `Deleted (-${diff.deletions})`;
200
+ }
201
+ if (diff.isRenamed) {
202
+ return `Renamed (+${diff.additions}, -${diff.deletions})`;
203
+ }
204
+ return `+${diff.additions}, -${diff.deletions}`;
205
+ }
206
+
207
+ /**
208
+ * Get file name from diff (handles renames)
209
+ */
210
+ export function getDiffFileName(diff: ParsedDiff): string {
211
+ if (diff.isNew) {
212
+ return diff.newFile;
213
+ }
214
+ if (diff.isDeleted) {
215
+ return diff.oldFile;
216
+ }
217
+ if (diff.isRenamed) {
218
+ return `${diff.oldFile} → ${diff.newFile}`;
219
+ }
220
+ return diff.newFile || diff.oldFile;
221
+ }