jettypod 4.4.120 → 4.4.121

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 (208) hide show
  1. package/.env +2 -1
  2. package/Cargo.lock +6450 -0
  3. package/Cargo.toml +35 -0
  4. package/README.md +5 -1
  5. package/TAURI-MIGRATION-PLAN.md +840 -0
  6. package/apps/dashboard/app/connect-claude/page.tsx +5 -6
  7. package/apps/dashboard/app/decision/[id]/page.tsx +54 -49
  8. package/apps/dashboard/app/demo/gates/page.tsx +3 -5
  9. package/apps/dashboard/app/design-system/page.tsx +1 -1
  10. package/apps/dashboard/app/globals.css +74 -2
  11. package/apps/dashboard/app/install-claude/page.tsx +3 -5
  12. package/apps/dashboard/app/login/page.tsx +17 -20
  13. package/apps/dashboard/app/page.tsx +101 -48
  14. package/apps/dashboard/app/settings/page.tsx +60 -12
  15. package/apps/dashboard/app/signup/page.tsx +14 -17
  16. package/apps/dashboard/app/subscribe/page.tsx +0 -2
  17. package/apps/dashboard/app/tests/page.tsx +37 -4
  18. package/apps/dashboard/app/welcome/page.tsx +12 -15
  19. package/apps/dashboard/app/work/[id]/page.tsx +90 -75
  20. package/apps/dashboard/app/work/[id]/proof/page.tsx +1489 -0
  21. package/apps/dashboard/components/AppShell.tsx +70 -61
  22. package/apps/dashboard/components/CardMenu.tsx +0 -1
  23. package/apps/dashboard/components/ClaudePanel.tsx +541 -283
  24. package/apps/dashboard/components/ClaudePanelInput.tsx +23 -4
  25. package/apps/dashboard/components/ConnectClaudeScreen.tsx +1 -5
  26. package/apps/dashboard/components/CopyableId.tsx +1 -2
  27. package/apps/dashboard/components/DetailReviewActions.tsx +11 -20
  28. package/apps/dashboard/components/DragContext.tsx +132 -62
  29. package/apps/dashboard/components/DraggableCard.tsx +3 -5
  30. package/apps/dashboard/components/DropZone.tsx +5 -6
  31. package/apps/dashboard/components/EditableDetailDescription.tsx +6 -12
  32. package/apps/dashboard/components/EditableDetailTitle.tsx +6 -13
  33. package/apps/dashboard/components/EditableTitle.tsx +0 -1
  34. package/apps/dashboard/components/ElapsedTimer.tsx +15 -3
  35. package/apps/dashboard/components/EpicGroup.tsx +100 -70
  36. package/apps/dashboard/components/GateCard.tsx +0 -1
  37. package/apps/dashboard/components/GateChoiceCard.tsx +1 -2
  38. package/apps/dashboard/components/InstallClaudeScreen.tsx +1 -5
  39. package/apps/dashboard/components/JettyLoader.tsx +0 -1
  40. package/apps/dashboard/components/KanbanBoard.tsx +319 -173
  41. package/apps/dashboard/components/KanbanCard.tsx +341 -107
  42. package/apps/dashboard/components/LazyCard.tsx +62 -0
  43. package/apps/dashboard/components/LazyMarkdown.tsx +0 -1
  44. package/apps/dashboard/components/MainNav.tsx +24 -25
  45. package/apps/dashboard/components/MessageBlock.tsx +93 -16
  46. package/apps/dashboard/components/ModeStartCard.tsx +0 -1
  47. package/apps/dashboard/components/OnboardingWelcome.tsx +0 -1
  48. package/apps/dashboard/components/PlaceholderCard.tsx +0 -1
  49. package/apps/dashboard/components/ProjectSwitcher.tsx +20 -20
  50. package/apps/dashboard/components/PrototypeTimeline.tsx +47 -26
  51. package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +308 -223
  52. package/apps/dashboard/components/RealTimeTestsWrapper.tsx +303 -160
  53. package/apps/dashboard/components/ReviewFooter.tsx +12 -14
  54. package/apps/dashboard/components/SessionList.tsx +0 -1
  55. package/apps/dashboard/components/SubscribeContent.tsx +40 -11
  56. package/apps/dashboard/components/TestTree.tsx +1 -2
  57. package/apps/dashboard/components/TipCard.tsx +2 -4
  58. package/apps/dashboard/components/Toast.tsx +0 -1
  59. package/apps/dashboard/components/TypeIcon.tsx +7 -8
  60. package/apps/dashboard/components/ViewModeToolbar.tsx +104 -0
  61. package/apps/dashboard/components/WaveCompletionAnimation.tsx +5 -17
  62. package/apps/dashboard/components/WelcomeScreen.tsx +2 -6
  63. package/apps/dashboard/components/WorkItemHeader.tsx +0 -1
  64. package/apps/dashboard/components/WorkItemTree.tsx +2 -4
  65. package/apps/dashboard/components/settings/AccountSection.tsx +27 -13
  66. package/apps/dashboard/components/settings/AiContextSection.tsx +89 -0
  67. package/apps/dashboard/components/settings/ContextDocumentsSection.tsx +317 -0
  68. package/apps/dashboard/components/settings/EnvVarsSection.tsx +20 -73
  69. package/apps/dashboard/components/settings/GeneralSection.tsx +137 -26
  70. package/apps/dashboard/components/settings/ProjectStackSection.tsx +948 -0
  71. package/apps/dashboard/components/settings/SettingsLayout.tsx +0 -1
  72. package/apps/dashboard/components/ui/Button.tsx +1 -1
  73. package/apps/dashboard/components/ui/Input.tsx +1 -1
  74. package/apps/dashboard/components.json +1 -1
  75. package/apps/dashboard/contexts/ClaudeSessionContext.tsx +611 -358
  76. package/apps/dashboard/contexts/ConnectionStatusContext.tsx +0 -1
  77. package/apps/dashboard/contexts/UsageContext.tsx +62 -31
  78. package/apps/dashboard/dev.sh +35 -0
  79. package/apps/dashboard/eslint.config.mjs +9 -9
  80. package/apps/dashboard/hooks/useWebSocket.ts +138 -83
  81. package/apps/dashboard/index.html +73 -0
  82. package/apps/dashboard/lib/data-bridge.ts +722 -0
  83. package/apps/dashboard/lib/db.ts +69 -1302
  84. package/apps/dashboard/lib/environment-config.ts +173 -0
  85. package/apps/dashboard/lib/environment-verification.ts +119 -0
  86. package/apps/dashboard/lib/kanban-utils.ts +226 -26
  87. package/apps/dashboard/lib/proof-run.ts +495 -0
  88. package/apps/dashboard/lib/proof-scenario-runner.ts +346 -0
  89. package/apps/dashboard/lib/service-recovery.ts +326 -0
  90. package/apps/dashboard/lib/session-state-machine.ts +1 -0
  91. package/apps/dashboard/lib/session-state-utils.ts +0 -164
  92. package/apps/dashboard/lib/session-stream-manager.ts +253 -122
  93. package/apps/dashboard/lib/stream-manager-registry.ts +46 -6
  94. package/apps/dashboard/lib/tauri-bridge.ts +102 -0
  95. package/apps/dashboard/lib/tauri.ts +106 -0
  96. package/apps/dashboard/lib/utils.ts +3 -3
  97. package/apps/dashboard/next-env.d.ts +1 -1
  98. package/apps/dashboard/package.json +21 -33
  99. package/apps/dashboard/public/bug-icon.png +0 -0
  100. package/apps/dashboard/public/buoy-icon.png +0 -0
  101. package/apps/dashboard/public/in-flight-seagull.png +0 -0
  102. package/apps/dashboard/public/pier-icon.png +0 -0
  103. package/apps/dashboard/public/star-icon.png +0 -0
  104. package/apps/dashboard/public/wrench-icon.png +0 -0
  105. package/apps/dashboard/scripts/tauri-build.js +228 -0
  106. package/apps/dashboard/scripts/upload-tauri-to-r2.js +125 -0
  107. package/apps/dashboard/src/main.tsx +12 -0
  108. package/apps/dashboard/src/router.tsx +107 -0
  109. package/apps/dashboard/src/vite-env.d.ts +1 -0
  110. package/apps/dashboard/tsconfig.json +7 -12
  111. package/apps/dashboard/tsconfig.tsbuildinfo +1 -1
  112. package/apps/dashboard/vite.config.ts +33 -0
  113. package/apps/update-server/src/index.ts +167 -30
  114. package/claude-hooks/global-guardrails.js +14 -13
  115. package/crates/jettypod-cli/Cargo.toml +19 -0
  116. package/crates/jettypod-cli/src/commands.rs +1249 -0
  117. package/crates/jettypod-cli/src/main.rs +595 -0
  118. package/crates/jettypod-core/Cargo.toml +26 -0
  119. package/crates/jettypod-core/build.rs +98 -0
  120. package/crates/jettypod-core/migrations/V1__baseline.sql +197 -0
  121. package/crates/jettypod-core/migrations/V2__work_items_indexes.sql +6 -0
  122. package/crates/jettypod-core/migrations/V3__qa_steps.sql +2 -0
  123. package/crates/jettypod-core/src/auth.rs +294 -0
  124. package/crates/jettypod-core/src/config.rs +397 -0
  125. package/crates/jettypod-core/src/db/mod.rs +507 -0
  126. package/crates/jettypod-core/src/db/recovery.rs +114 -0
  127. package/crates/jettypod-core/src/db/startup.rs +101 -0
  128. package/crates/jettypod-core/src/db/validate.rs +149 -0
  129. package/crates/jettypod-core/src/error.rs +76 -0
  130. package/crates/jettypod-core/src/git.rs +458 -0
  131. package/crates/jettypod-core/src/lib.rs +20 -0
  132. package/crates/jettypod-core/src/sessions.rs +625 -0
  133. package/crates/jettypod-core/src/skills.rs +556 -0
  134. package/crates/jettypod-core/src/work.rs +1086 -0
  135. package/crates/jettypod-core/src/worktree.rs +628 -0
  136. package/crates/jettypod-core/src/ws.rs +767 -0
  137. package/cucumber-test.cjs +6 -0
  138. package/jettypod.js +96 -4
  139. package/lib/bdd-preflight.js +96 -0
  140. package/lib/merge-lock.js +111 -253
  141. package/lib/migrations/030-rejection-round-columns.js +54 -0
  142. package/lib/migrations/031-session-isolation-index.js +17 -0
  143. package/lib/work-commands/index.js +58 -16
  144. package/lib/work-tracking/index.js +108 -8
  145. package/package.json +1 -1
  146. package/skills-templates/bug-mode/SKILL.md +43 -1
  147. package/skills-templates/chore-mode/SKILL.md +40 -1
  148. package/skills-templates/design-system-selection/SKILL.md +273 -0
  149. package/skills-templates/epic-planning/SKILL.md +14 -0
  150. package/skills-templates/feature-planning/SKILL.md +90 -1
  151. package/skills-templates/production-mode/SKILL.md +20 -0
  152. package/skills-templates/simple-improvement/SKILL.md +39 -2
  153. package/skills-templates/speed-mode/SKILL.md +10 -15
  154. package/skills-templates/stable-mode/SKILL.md +47 -0
  155. package/apps/dashboard/README.md +0 -36
  156. package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +0 -446
  157. package/apps/dashboard/app/api/claude/[workItemId]/pin/route.ts +0 -24
  158. package/apps/dashboard/app/api/claude/[workItemId]/route.ts +0 -280
  159. package/apps/dashboard/app/api/claude/sessions/[sessionId]/content/route.ts +0 -52
  160. package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +0 -525
  161. package/apps/dashboard/app/api/claude/sessions/[sessionId]/pin/route.ts +0 -24
  162. package/apps/dashboard/app/api/claude/sessions/cleanup/route.ts +0 -34
  163. package/apps/dashboard/app/api/claude/sessions/route.ts +0 -184
  164. package/apps/dashboard/app/api/decisions/[id]/route.ts +0 -25
  165. package/apps/dashboard/app/api/internal/set-project/route.ts +0 -17
  166. package/apps/dashboard/app/api/kanban/route.ts +0 -15
  167. package/apps/dashboard/app/api/settings/env-vars/route.ts +0 -125
  168. package/apps/dashboard/app/api/settings/general/route.ts +0 -21
  169. package/apps/dashboard/app/api/tests/route.ts +0 -9
  170. package/apps/dashboard/app/api/tests/run/route.ts +0 -82
  171. package/apps/dashboard/app/api/tests/run/stream/route.ts +0 -71
  172. package/apps/dashboard/app/api/tests/undefined/route.ts +0 -9
  173. package/apps/dashboard/app/api/usage/route.ts +0 -17
  174. package/apps/dashboard/app/api/work/[id]/description/route.ts +0 -21
  175. package/apps/dashboard/app/api/work/[id]/epic/route.ts +0 -21
  176. package/apps/dashboard/app/api/work/[id]/order/route.ts +0 -21
  177. package/apps/dashboard/app/api/work/[id]/route.ts +0 -35
  178. package/apps/dashboard/app/api/work/[id]/status/route.ts +0 -63
  179. package/apps/dashboard/app/api/work/[id]/title/route.ts +0 -21
  180. package/apps/dashboard/app/layout.tsx +0 -55
  181. package/apps/dashboard/components/UpgradeBanner.tsx +0 -30
  182. package/apps/dashboard/electron/ipc-handlers.js +0 -1026
  183. package/apps/dashboard/electron/main.js +0 -2306
  184. package/apps/dashboard/electron/preload.js +0 -125
  185. package/apps/dashboard/electron/session-manager.js +0 -163
  186. package/apps/dashboard/electron-builder.config.js +0 -357
  187. package/apps/dashboard/hooks/useClaudeSessions.ts +0 -299
  188. package/apps/dashboard/lib/backlog-parser.ts +0 -50
  189. package/apps/dashboard/lib/claude-process-manager.ts +0 -529
  190. package/apps/dashboard/lib/db-bridge.ts +0 -283
  191. package/apps/dashboard/lib/prototypes.ts +0 -202
  192. package/apps/dashboard/lib/test-results-db.ts +0 -307
  193. package/apps/dashboard/lib/tests.ts +0 -282
  194. package/apps/dashboard/next.config.js +0 -66
  195. package/apps/dashboard/postcss.config.mjs +0 -7
  196. package/apps/dashboard/public/bug-icon.svg +0 -9
  197. package/apps/dashboard/public/buoy-icon.svg +0 -9
  198. package/apps/dashboard/public/file.svg +0 -1
  199. package/apps/dashboard/public/globe.svg +0 -1
  200. package/apps/dashboard/public/in-flight-seagull.svg +0 -9
  201. package/apps/dashboard/public/next.svg +0 -1
  202. package/apps/dashboard/public/pier-icon.svg +0 -14
  203. package/apps/dashboard/public/star-icon.svg +0 -9
  204. package/apps/dashboard/public/vercel.svg +0 -1
  205. package/apps/dashboard/public/window.svg +0 -1
  206. package/apps/dashboard/public/wrench-icon.svg +0 -9
  207. package/apps/dashboard/scripts/download-node.js +0 -104
  208. package/apps/dashboard/scripts/upload-to-r2.js +0 -89
@@ -1,2306 +0,0 @@
1
- const { app, BrowserWindow, Menu, dialog, shell, ipcMain } = require('electron');
2
- const { spawn, exec, execFile } = require('child_process');
3
- const { promisify } = require('util');
4
- const execAsync = promisify(exec);
5
- const execFileAsync = promisify(execFile);
6
- const path = require('path');
7
- const fs = require('fs');
8
- const http = require('http');
9
- const { WebSocketServer } = require('ws');
10
- const { registerIpcHandlers, closeDb } = require('./ipc-handlers');
11
- const { ingestCucumberResults, closeIngesterDb } = require('./test-results-ingester');
12
- const { autoUpdater } = require('electron-updater');
13
- const sessionManager = require('./session-manager');
14
-
15
- // Track child processes and servers for cleanup
16
- let nextProcess = null;
17
- let nextServer = null; // HTTP server for production Next.js
18
- let mainWindow = null;
19
- const windows = new Set(); // Track all open windows
20
- let wss = null;
21
- let dbPollInterval = null;
22
- let testResultsPollInterval = null;
23
- let worktreeCleanupInterval = null;
24
- let lastDbMtimes = { db: null, wal: null };
25
- let lastTestResultsMtime = null;
26
- // Map of worktree path → last mtime for cucumber-results.json
27
- const worktreeTestResultsMtimes = new Map();
28
-
29
- // Track Claude processes by session ID
30
- const claudeProcesses = new Map();
31
-
32
- // Track dev server processes by project path
33
- // Map: projectPath -> { process, port, command, status }
34
- const devServerProcesses = new Map();
35
-
36
- // Detect if running as packaged app
37
- const isPackaged = app.isPackaged;
38
-
39
- // Suppress noisy EGL GPU driver error logs (eglQueryDeviceAttribEXT: Bad attribute)
40
- app.commandLine.appendSwitch('enable-logging', 'stderr');
41
- app.commandLine.appendSwitch('log-level', '2');
42
- // Disable hardware acceleration to prevent GPU process crashes (exit_code=512)
43
- // that cause blank white screens. Software rendering is sufficient for this app.
44
- app.disableHardwareAcceleration();
45
-
46
- // Last-selected project persistence (for dev mode)
47
- const LAST_SELECTED_PROJECT_PATH = path.join(
48
- process.env.HOME || process.env.USERPROFILE || '',
49
- '.config', 'JettyPod', 'last-selected-project.json'
50
- );
51
-
52
- function readLastSelectedProject() {
53
- try {
54
- if (fs.existsSync(LAST_SELECTED_PROJECT_PATH)) {
55
- const data = JSON.parse(fs.readFileSync(LAST_SELECTED_PROJECT_PATH, 'utf-8'));
56
- return data.projectPath || null;
57
- }
58
- } catch {
59
- // Ignore read errors - treat as no project selected
60
- }
61
- return null;
62
- }
63
-
64
- function writeLastSelectedProject(projectPath) {
65
- const dir = path.dirname(LAST_SELECTED_PROJECT_PATH);
66
- if (!fs.existsSync(dir)) {
67
- fs.mkdirSync(dir, { recursive: true });
68
- }
69
- fs.writeFileSync(LAST_SELECTED_PROJECT_PATH, JSON.stringify({ projectPath }));
70
- }
71
-
72
- // Paths - different for development vs production
73
- let dashboardDir;
74
- let projectRoot;
75
- let preloadPath;
76
-
77
- if (isPackaged) {
78
- // In packaged app, resources are in app.asar or app directory
79
- // process.resourcesPath points to Contents/Resources
80
- dashboardDir = path.join(process.resourcesPath, 'app');
81
- // For packaged app, project root is the user's project (not the app bundle)
82
- // We'll use an environment variable or default to null (no project)
83
- projectRoot = process.env.JETTYPOD_PROJECT_PATH || null;
84
- preloadPath = path.join(dashboardDir, 'electron', 'preload.js');
85
- } else {
86
- // Development mode - use last-selected project if available, otherwise show welcome
87
- dashboardDir = path.join(__dirname, '..');
88
- projectRoot = readLastSelectedProject();
89
- if (projectRoot) {
90
- process.env.JETTYPOD_PROJECT_PATH = projectRoot;
91
- }
92
- preloadPath = path.join(__dirname, 'preload.js');
93
- }
94
-
95
- // Check if a valid JettyPod project exists at the given path
96
- function hasValidProject(projectPath) {
97
- if (!projectPath) return false;
98
- const dbPath = path.join(projectPath, '.jettypod', 'work.db');
99
- return fs.existsSync(dbPath);
100
- }
101
-
102
- // Determine the correct start URL based on auth state, Claude Code, and project status.
103
- // Used both at initial window creation and after OAuth callback.
104
- function getStartUrl() {
105
- if (isPackaged && !isAuthenticated()) {
106
- const authPage = hasLoggedInBefore() ? '/login' : '/signup';
107
- log(`No auth token, showing ${authPage} screen`);
108
- return `http://localhost:3000${authPage}`;
109
- } else if (!claudeCodeInstalled) {
110
- log('Claude Code not installed, showing install screen');
111
- return 'http://localhost:3000/install-claude';
112
- } else if (!claudeCodeAuthenticated) {
113
- log('Claude Code not authenticated, showing connect screen');
114
- return 'http://localhost:3000/connect-claude';
115
- } else {
116
- const hasProject = hasValidProject(projectRoot);
117
- log(`Project configured: ${hasProject}, loading: ${hasProject ? '/' : '/welcome'}`);
118
- return hasProject ? 'http://localhost:3000' : 'http://localhost:3000/welcome';
119
- }
120
- }
121
-
122
- // Set the project root (called when user selects a project)
123
- async function setProjectRoot(newPath) {
124
- projectRoot = newPath;
125
- process.env.JETTYPOD_PROJECT_PATH = newPath;
126
- log(`Project root set to: ${projectRoot}`);
127
-
128
- // Persist for next launch (dev mode remembers last project)
129
- writeLastSelectedProject(newPath);
130
-
131
- // Restart database and test results polling for the new project
132
- restartPolling();
133
-
134
- // In dev mode, Next.js runs as a child process with its own copy of process.env.
135
- // Notify it of the project change so it can update its env and close cached DB connections.
136
- if (!isPackaged && nextProcess) {
137
- await syncProjectToNextServer(newPath);
138
- }
139
- }
140
-
141
- // Notify the Next.js dev server of a project path change
142
- function syncProjectToNextServer(projectPath) {
143
- return new Promise((resolve) => {
144
- const payload = JSON.stringify({ projectPath: projectPath || '' });
145
- const req = http.request({
146
- hostname: 'localhost',
147
- port: 3000,
148
- path: '/api/internal/set-project',
149
- method: 'POST',
150
- headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) }
151
- }, (res) => {
152
- if (res.statusCode === 200) {
153
- log('Next.js dev server updated with new project path');
154
- } else {
155
- log(`Warning: Failed to update Next.js dev server (status ${res.statusCode})`);
156
- }
157
- res.resume(); // drain the response
158
- resolve();
159
- });
160
- req.on('error', (err) => {
161
- log(`Warning: Could not reach Next.js dev server to update project: ${err.message}`);
162
- resolve(); // resolve anyway — don't block project switch if dev server is unreachable
163
- });
164
- req.write(payload);
165
- req.end();
166
- });
167
- }
168
-
169
- // Get the main window for reloading after project selection
170
- function getMainWindow() {
171
- return mainWindow;
172
- }
173
-
174
- // WebSocket configuration
175
- const WS_PORT = 47808;
176
-
177
- // Persistent log file for diagnostics
178
- const LOG_DIR = path.join(app.getPath('home'), 'Library', 'Logs', 'jettypod');
179
- const LOG_FILE = path.join(LOG_DIR, 'main.log');
180
- const MAX_LOG_SIZE = 5 * 1024 * 1024; // 5MB
181
-
182
- let logStream = null;
183
-
184
- function initLogFile() {
185
- try {
186
- fs.mkdirSync(LOG_DIR, { recursive: true });
187
- // Rotate if too large
188
- if (fs.existsSync(LOG_FILE)) {
189
- const stats = fs.statSync(LOG_FILE);
190
- if (stats.size > MAX_LOG_SIZE) {
191
- const rotated = `${LOG_FILE}.1`;
192
- if (fs.existsSync(rotated)) fs.unlinkSync(rotated);
193
- fs.renameSync(LOG_FILE, rotated);
194
- }
195
- }
196
- logStream = fs.createWriteStream(LOG_FILE, { flags: 'a' });
197
- } catch {
198
- // Fall back to console-only logging
199
- }
200
- }
201
-
202
- initLogFile();
203
-
204
- function log(msg) {
205
- const line = `[${new Date().toISOString()}] [Electron] ${msg}`;
206
- console.log(line);
207
- if (logStream) {
208
- logStream.write(line + '\n');
209
- }
210
- }
211
-
212
- /**
213
- * Get path to bundled Node.js in the app resources.
214
- * Returns null if not found (e.g., in dev mode).
215
- */
216
- function getBundledNodePath() {
217
- if (process.resourcesPath) {
218
- const bundledPath = path.join(process.resourcesPath, 'node', 'bin');
219
- if (fs.existsSync(path.join(bundledPath, 'node'))) {
220
- return bundledPath;
221
- }
222
- }
223
- return null;
224
- }
225
-
226
- /**
227
- * Get path where Claude Code will be installed.
228
- * Uses app-specific directory to avoid conflicts with system installs.
229
- */
230
- function getClaudeInstallPath() {
231
- const home = process.env.HOME || '';
232
- return path.join(home, '.jettypod', 'node_modules', '.bin');
233
- }
234
-
235
- /**
236
- * Get shell environment with proper PATH for spawning CLI tools like npm/claude.
237
- * GUI apps on macOS don't inherit the user's shell PATH, so we need to construct it.
238
- * This includes common locations for npm, homebrew, nvm, and other tools.
239
- */
240
- function getUserShell() {
241
- return process.env.SHELL || '/bin/zsh';
242
- }
243
-
244
- function getShellEnv() {
245
- const home = process.env.HOME || '';
246
-
247
- // Priority paths (checked first)
248
- const priorityPaths = [];
249
-
250
- // Add JettyPod's Claude install location (highest priority)
251
- priorityPaths.push(getClaudeInstallPath());
252
-
253
- // Add bundled Node.js (for running Claude)
254
- const bundledNode = getBundledNodePath();
255
- if (bundledNode) {
256
- priorityPaths.push(bundledNode);
257
- }
258
-
259
- // Common paths where npm/node might be installed (fallback for users who have it)
260
- const additionalPaths = [
261
- `${home}/.claude/local`, // Official Claude Code install script
262
- '/usr/local/bin', // Homebrew (Intel Mac) / common location
263
- '/opt/homebrew/bin', // Homebrew (Apple Silicon)
264
- '/opt/homebrew/sbin',
265
- `${home}/.nvm/current/bin`, // nvm with 'current' alias
266
- `${home}/.volta/bin`, // Volta
267
- `${home}/.npm-global/bin`, // npm global (user-configured)
268
- `${home}/.local/bin`, // Common user bin
269
- `${home}/bin`,
270
- '/usr/bin',
271
- '/bin',
272
- '/usr/sbin',
273
- '/sbin',
274
- ].filter(Boolean);
275
-
276
- // Try to get active nvm node version if nvm is installed
277
- const nvmDir = process.env.NVM_DIR || `${home}/.nvm`;
278
- if (fs.existsSync(nvmDir)) {
279
- // Check for .nvmrc or default version
280
- const defaultPath = path.join(nvmDir, 'alias', 'default');
281
- if (fs.existsSync(defaultPath)) {
282
- try {
283
- const defaultVersion = fs.readFileSync(defaultPath, 'utf-8').trim();
284
- // Handle both direct versions and aliases like 'lts/*'
285
- const versionDir = path.join(nvmDir, 'versions', 'node');
286
- if (fs.existsSync(versionDir)) {
287
- const versions = fs.readdirSync(versionDir).filter(v => v.startsWith('v'));
288
- // Find matching version or latest
289
- const match = versions.find(v => v.includes(defaultVersion)) || versions.sort().pop();
290
- if (match) {
291
- additionalPaths.unshift(path.join(versionDir, match, 'bin'));
292
- }
293
- }
294
- } catch {
295
- // Ignore errors reading nvm config
296
- }
297
- }
298
- }
299
-
300
- const existingPath = process.env.PATH || '';
301
- const newPath = [...priorityPaths, ...additionalPaths, existingPath].join(':');
302
-
303
- return {
304
- ...process.env,
305
- PATH: newPath,
306
- };
307
- }
308
-
309
- /**
310
- * Clean up stale resources before starting servers
311
- * This handles cases where the app was force-quit and left behind:
312
- * - Next.js lock files
313
- * - Orphaned processes on port 3000
314
- * - Orphaned next-router-worker processes
315
- */
316
- async function cleanupStaleResources() {
317
- log('Cleaning up stale resources...');
318
-
319
- // 1. Remove Next.js lock file if it exists
320
- const lockFilePath = path.join(dashboardDir, '.next', 'dev', 'lock');
321
- if (fs.existsSync(lockFilePath)) {
322
- try {
323
- fs.unlinkSync(lockFilePath);
324
- log(`Removed stale lock file: ${lockFilePath}`);
325
- } catch (err) {
326
- log(`Warning: Could not remove lock file: ${err.message}`);
327
- }
328
- }
329
-
330
- // Helper to kill PIDs from a shell command
331
- async function killPidsFromCommand(cmd, label) {
332
- try {
333
- const { stdout } = await execAsync(cmd, { encoding: 'utf-8' });
334
- const pids = stdout.trim().split('\n').filter(Boolean);
335
- for (const pid of pids) {
336
- if (parseInt(pid, 10) === process.pid) continue;
337
- try {
338
- process.kill(parseInt(pid, 10), 'SIGKILL');
339
- log(`Killed ${label}: PID ${pid}`);
340
- } catch (killErr) {
341
- log(`Could not kill PID ${pid}: ${killErr.message}`);
342
- }
343
- }
344
- } catch (err) {
345
- // Command failed or no processes found - that's fine
346
- }
347
- }
348
-
349
- // 2. Kill any process using port 3000 (Next.js dev server port)
350
- await killPidsFromCommand('lsof -ti:3000 2>/dev/null || true', 'stale process on port 3000');
351
-
352
- // 3. Kill orphaned next-router-worker processes
353
- await killPidsFromCommand('pgrep -f "next-router-worker" 2>/dev/null || true', 'orphaned next-router-worker');
354
-
355
- // 4. Kill any orphaned node processes running our dev script
356
- await killPidsFromCommand('pgrep -f "node.*next.*dev" 2>/dev/null || true', 'orphaned Next.js dev process');
357
-
358
- log('Stale resource cleanup complete');
359
- }
360
-
361
- function startNextServer() {
362
- return new Promise((resolve, reject) => {
363
- if (isPackaged) {
364
- // Production mode - run Next.js programmatically within Electron's Node
365
- // (Can't spawn process.execPath as that's the Electron binary, not Node)
366
- log('Starting Next.js production server (programmatic)...');
367
- log(`Dashboard dir: ${dashboardDir}`);
368
-
369
- // Set environment before requiring Next
370
- process.env.NODE_ENV = 'production';
371
- process.env.JETTYPOD_IS_PACKAGED = '1'; // Signal to Next.js this is packaged app
372
- process.env.JETTYPOD_RESOURCES_PATH = process.resourcesPath; // For migrations to find bundled files
373
- // Only set project path if we have one (don't set null which becomes string "null")
374
- if (projectRoot) {
375
- process.env.JETTYPOD_PROJECT_PATH = projectRoot;
376
- }
377
- // Set enhanced PATH so API routes can find claude CLI
378
- process.env.PATH = getShellEnv().PATH;
379
-
380
- // Change to dashboard dir so Next.js finds its config
381
- const originalCwd = process.cwd();
382
- process.chdir(dashboardDir);
383
-
384
- try {
385
- // Require Next.js from the packaged app's node_modules
386
- const next = require(path.join(dashboardDir, 'node_modules', 'next'));
387
-
388
- const nextApp = next({
389
- dev: false,
390
- dir: dashboardDir,
391
- conf: {
392
- distDir: '.next'
393
- }
394
- });
395
-
396
- const handle = nextApp.getRequestHandler();
397
-
398
- nextApp.prepare().then(() => {
399
- nextServer = http.createServer((req, res) => {
400
- handle(req, res);
401
- });
402
-
403
- nextServer.listen(3000, (err) => {
404
- if (err) {
405
- log(`Next.js server error: ${err.message}`);
406
- reject(err);
407
- return;
408
- }
409
- log('Next.js production server ready on http://localhost:3000');
410
- resolve();
411
- });
412
- }).catch((err) => {
413
- log(`Next.js prepare error: ${err.message}`);
414
- process.chdir(originalCwd);
415
- reject(err);
416
- });
417
- } catch (err) {
418
- log(`Next.js require error: ${err.message}`);
419
- process.chdir(originalCwd);
420
- reject(err);
421
- }
422
- } else {
423
- // Development mode - run npm run dev as child process
424
- log('Starting Next.js dev server...');
425
-
426
- nextProcess = spawn('npm', ['run', 'dev'], {
427
- cwd: dashboardDir,
428
- shell: true,
429
- detached: true, // Create process group for reliable cleanup
430
- env: {
431
- ...process.env,
432
- JETTYPOD_PROJECT_PATH: projectRoot
433
- }
434
- });
435
-
436
- let resolved = false;
437
-
438
- nextProcess.stdout.on('data', (data) => {
439
- const text = data.toString();
440
- process.stdout.write(`[Next.js] ${text}`);
441
-
442
- // Detect when server is ready
443
- if (!resolved && (text.includes('Ready in') || text.includes('localhost:3000') || text.includes('started server'))) {
444
- resolved = true;
445
- log('Next.js server ready');
446
- resolve();
447
- }
448
- });
449
-
450
- nextProcess.stderr.on('data', (data) => {
451
- process.stderr.write(`[Next.js] ${data}`);
452
- });
453
-
454
- nextProcess.on('error', (err) => {
455
- log(`Next.js spawn error: ${err.message}`);
456
- if (!resolved) reject(err);
457
- });
458
-
459
- nextProcess.on('exit', (code) => {
460
- log(`Next.js exited with code ${code}`);
461
- nextProcess = null;
462
- });
463
-
464
- // Timeout fallback - assume ready after 10 seconds
465
- setTimeout(() => {
466
- if (!resolved) {
467
- resolved = true;
468
- log('Next.js server assumed ready (timeout)');
469
- resolve();
470
- }
471
- }, 10000);
472
- }
473
- });
474
- }
475
-
476
- // ==================== Embedded WebSocket Server ====================
477
- // (Moved from apps/ws-server/server.js)
478
-
479
- const wsClients = new Set();
480
- const DB_POLL_MS = 500; // Poll database every 500ms (same as lib/db-watcher.js)
481
-
482
- function broadcastToClients(message) {
483
- const payload = JSON.stringify(message);
484
- for (const client of wsClients) {
485
- if (client.readyState === 1) { // WebSocket.OPEN
486
- client.send(payload);
487
- }
488
- }
489
- }
490
-
491
- // Watch database file for changes using polling (WAL-aware)
492
- // SQLite in WAL mode writes to the -wal file first, so we must watch both files
493
- function startDatabasePolling() {
494
- if (!projectRoot) {
495
- log(`[WS] No project selected, skipping database watcher`);
496
- return;
497
- }
498
-
499
- const dbPath = path.join(projectRoot, '.jettypod', 'work.db');
500
-
501
- if (!fs.existsSync(dbPath)) {
502
- log(`[WS] Database not found at ${dbPath}, waiting...`);
503
- setTimeout(startDatabasePolling, 5000);
504
- return;
505
- }
506
-
507
- // Initialize last modification times
508
- try {
509
- const dbStats = fs.statSync(dbPath);
510
- lastDbMtimes.db = dbStats.mtimeMs;
511
-
512
- // Also check WAL file (SQLite in WAL mode writes here first)
513
- const walPath = dbPath + '-wal';
514
- if (fs.existsSync(walPath)) {
515
- const walStats = fs.statSync(walPath);
516
- lastDbMtimes.wal = walStats.mtimeMs;
517
- }
518
- } catch (err) {
519
- log(`[WS] Failed to get initial db stats: ${err.message}`);
520
- return;
521
- }
522
-
523
- log(`[WS] Polling database for changes (WAL-aware)...`);
524
-
525
- // Use polling instead of fs.watch - more reliable for SQLite WAL mode
526
- dbPollInterval = setInterval(() => {
527
- try {
528
- let changed = false;
529
-
530
- // Check main db file
531
- const dbStats = fs.statSync(dbPath);
532
- if (dbStats.mtimeMs !== lastDbMtimes.db) {
533
- lastDbMtimes.db = dbStats.mtimeMs;
534
- changed = true;
535
- }
536
-
537
- // Check WAL file (where SQLite writes first in WAL mode)
538
- const walPath = dbPath + '-wal';
539
- if (fs.existsSync(walPath)) {
540
- const walStats = fs.statSync(walPath);
541
- if (walStats.mtimeMs !== lastDbMtimes.wal) {
542
- lastDbMtimes.wal = walStats.mtimeMs;
543
- changed = true;
544
- }
545
- }
546
-
547
- if (changed) {
548
- log(`[WS] Database changed`);
549
- broadcastToClients({
550
- type: 'db_change',
551
- timestamp: Date.now()
552
- });
553
- }
554
- } catch {
555
- // File might be temporarily locked during writes - ignore
556
- }
557
- }, DB_POLL_MS);
558
- }
559
-
560
- // Get active worktree paths from SQLite
561
- function getActiveWorktreePaths() {
562
- if (!projectRoot) return [];
563
- const dbPath = path.join(projectRoot, '.jettypod', 'work.db');
564
- if (!fs.existsSync(dbPath)) return [];
565
- try {
566
- const Database = require('better-sqlite3');
567
- const db = new Database(dbPath, { readonly: true });
568
- const rows = db.prepare("SELECT worktree_path FROM worktrees WHERE status = 'active'").all();
569
- db.close();
570
- return rows.map(r => r.worktree_path).filter(p => fs.existsSync(p));
571
- } catch {
572
- return [];
573
- }
574
- }
575
-
576
- // Periodic worktree cleanup — catches orphaned worktrees from done/cancelled items
577
- const WORKTREE_CLEANUP_MS = 60000; // 60 seconds
578
-
579
- function startWorktreeCleanup() {
580
- if (!projectRoot) {
581
- log('[cleanup] No project selected, skipping worktree cleanup');
582
- return;
583
- }
584
-
585
- log('[cleanup] Starting periodic worktree cleanup (every 60s)');
586
-
587
- worktreeCleanupInterval = setInterval(() => {
588
- if (!projectRoot) return;
589
-
590
- const dbPath = path.join(projectRoot, '.jettypod', 'work.db');
591
- if (!fs.existsSync(dbPath)) return;
592
-
593
- try {
594
- const Database = require('better-sqlite3');
595
- const db = new Database(dbPath, { readonly: true });
596
- const orphans = db.prepare(`
597
- SELECT w.id as worktree_id, w.work_item_id, w.worktree_path, wi.status as work_status
598
- FROM worktrees w
599
- JOIN work_items wi ON w.work_item_id = wi.id
600
- WHERE w.status IN ('active', 'merged')
601
- AND wi.status IN ('done', 'cancelled')
602
- `).all();
603
- db.close();
604
-
605
- if (orphans.length === 0) return;
606
-
607
- log(`[cleanup] Found ${orphans.length} orphaned worktree(s), running cleanup...`);
608
-
609
- // Shell out to jettypod CLI for the actual cleanup (non-blocking)
610
- const cliPath = getBundledCliPath();
611
- execAsync(`"${cliPath}" work cleanup`, { cwd: projectRoot, timeout: 30000 })
612
- .then(({ stdout }) => {
613
- if (stdout && stdout.trim()) {
614
- log(`[cleanup] ${stdout.trim()}`);
615
- }
616
- })
617
- .catch((err) => {
618
- log(`[cleanup] Cleanup failed: ${err.message}`);
619
- });
620
- } catch {
621
- // Silently ignore — will retry on next interval
622
- }
623
- }, WORKTREE_CLEANUP_MS);
624
- }
625
-
626
- // Check a single cucumber-results.json path for changes, ingest + broadcast if changed
627
- function checkTestResultsFile(resultsPath, mtimeKey, getMtime, setMtime) {
628
- if (!fs.existsSync(resultsPath)) {
629
- if (getMtime() !== null) setMtime(null);
630
- return;
631
- }
632
-
633
- const stats = fs.statSync(resultsPath);
634
- if (stats.mtimeMs !== getMtime()) {
635
- setMtime(stats.mtimeMs);
636
- log(`[WS] Test results changed: ${resultsPath}`);
637
-
638
- try {
639
- const count = ingestCucumberResults(projectRoot, resultsPath);
640
- if (count > 0) {
641
- log(`[WS] Ingested ${count} test results into database`);
642
- }
643
- } catch (err) {
644
- log(`[WS] Failed to ingest test results: ${err.message}`);
645
- }
646
-
647
- broadcastToClients({
648
- type: 'test_change',
649
- timestamp: Date.now()
650
- });
651
- }
652
- }
653
-
654
- // Watch for test results file changes (cucumber-results.json)
655
- // Monitors both projectRoot and all active worktrees
656
- function startTestResultsPolling() {
657
- if (!projectRoot) {
658
- log(`[WS] No project selected, skipping test results watcher`);
659
- return;
660
- }
661
-
662
- const testResultsPath = path.join(projectRoot, 'cucumber-results.json');
663
-
664
- // Initialize last modification time if file exists
665
- if (fs.existsSync(testResultsPath)) {
666
- try {
667
- const stats = fs.statSync(testResultsPath);
668
- lastTestResultsMtime = stats.mtimeMs;
669
- log(`[WS] Watching test results at ${testResultsPath}`);
670
- } catch (err) {
671
- log(`[WS] Failed to get initial test results stats: ${err.message}`);
672
- }
673
- } else {
674
- log(`[WS] Test results file not found at ${testResultsPath}, will watch for creation`);
675
- }
676
-
677
- // Refresh worktree list periodically (every 10s via counter)
678
- let worktreePaths = getActiveWorktreePaths();
679
- let tickCount = 0;
680
- const WORKTREE_REFRESH_TICKS = Math.round(10000 / DB_POLL_MS); // ~10 seconds
681
-
682
- if (worktreePaths.length > 0) {
683
- log(`[WS] Also watching ${worktreePaths.length} active worktree(s) for test results`);
684
- }
685
-
686
- // Poll for test results changes
687
- testResultsPollInterval = setInterval(() => {
688
- try {
689
- // Check main project root
690
- checkTestResultsFile(
691
- testResultsPath,
692
- 'root',
693
- () => lastTestResultsMtime,
694
- (v) => { lastTestResultsMtime = v; }
695
- );
696
-
697
- // Check active worktrees
698
- for (const wtPath of worktreePaths) {
699
- const wtResultsPath = path.join(wtPath, 'cucumber-results.json');
700
- checkTestResultsFile(
701
- wtResultsPath,
702
- wtPath,
703
- () => worktreeTestResultsMtimes.get(wtPath) ?? null,
704
- (v) => {
705
- if (v === null) {
706
- worktreeTestResultsMtimes.delete(wtPath);
707
- } else {
708
- worktreeTestResultsMtimes.set(wtPath, v);
709
- }
710
- }
711
- );
712
- }
713
-
714
- // Periodically refresh worktree list
715
- tickCount++;
716
- if (tickCount >= WORKTREE_REFRESH_TICKS) {
717
- tickCount = 0;
718
- const newPaths = getActiveWorktreePaths();
719
- // Clean up mtimes for removed worktrees
720
- for (const oldPath of worktreePaths) {
721
- if (!newPaths.includes(oldPath)) {
722
- worktreeTestResultsMtimes.delete(oldPath);
723
- }
724
- }
725
- worktreePaths = newPaths;
726
- }
727
- } catch {
728
- // File might be temporarily locked during writes - ignore
729
- }
730
- }, DB_POLL_MS);
731
- }
732
-
733
- // Stop and restart polling for the current projectRoot.
734
- // Called when the user selects/switches a project after app startup.
735
- function restartPolling() {
736
- // Stop existing polling
737
- if (dbPollInterval) {
738
- clearInterval(dbPollInterval);
739
- dbPollInterval = null;
740
- lastDbMtimes = { db: null, wal: null };
741
- }
742
- if (testResultsPollInterval) {
743
- clearInterval(testResultsPollInterval);
744
- testResultsPollInterval = null;
745
- lastTestResultsMtime = null;
746
- worktreeTestResultsMtimes.clear();
747
- }
748
- if (worktreeCleanupInterval) {
749
- clearInterval(worktreeCleanupInterval);
750
- worktreeCleanupInterval = null;
751
- }
752
-
753
- // Start polling for the new project
754
- startDatabasePolling();
755
- startTestResultsPolling();
756
- startWorktreeCleanup();
757
- }
758
-
759
- function isPortInUse(port) {
760
- return new Promise((resolve) => {
761
- const net = require('net');
762
- const server = net.createServer();
763
- server.once('error', (err) => {
764
- resolve(err.code === 'EADDRINUSE');
765
- });
766
- server.once('listening', () => {
767
- server.close();
768
- resolve(false);
769
- });
770
- server.listen(port);
771
- });
772
- }
773
-
774
- function startEmbeddedWebSocketServer() {
775
- return new Promise(async (resolve, reject) => {
776
- log('Starting embedded WebSocket server...');
777
-
778
- // Skip if standalone ws-server.js is already running (e.g. npm run dev)
779
- if (await isPortInUse(WS_PORT)) {
780
- log(`[WS] Port ${WS_PORT} already in use — standalone WS server detected, skipping`);
781
- resolve();
782
- return;
783
- }
784
-
785
- try {
786
- wss = new WebSocketServer({ port: WS_PORT });
787
-
788
- wss.on('connection', (ws) => {
789
- wsClients.add(ws);
790
- log(`[WS] Client connected. Total clients: ${wsClients.size}`);
791
-
792
- // Send initial connection confirmation
793
- ws.send(JSON.stringify({ type: 'connected', timestamp: Date.now() }));
794
-
795
- ws.on('close', () => {
796
- wsClients.delete(ws);
797
- log(`[WS] Client disconnected. Total clients: ${wsClients.size}`);
798
- });
799
-
800
- ws.on('error', (error) => {
801
- log(`[WS] WebSocket error: ${error.message}`);
802
- ws.close();
803
- wsClients.delete(ws);
804
- });
805
- });
806
-
807
- wss.on('error', (err) => {
808
- log(`[WS] Server error: ${err.message}`);
809
- reject(err);
810
- });
811
-
812
- startDatabasePolling();
813
- startTestResultsPolling();
814
- startWorktreeCleanup();
815
-
816
- log(`[WS] WebSocket server running on ws://localhost:${WS_PORT}`);
817
- resolve();
818
- } catch (err) {
819
- log(`[WS] Failed to start WebSocket server: ${err.message}`);
820
- reject(err);
821
- }
822
- });
823
- }
824
-
825
- function stopWebSocketServer() {
826
- if (dbPollInterval) {
827
- clearInterval(dbPollInterval);
828
- dbPollInterval = null;
829
- lastDbMtimes = { db: null, wal: null };
830
- log('[WS] Database polling stopped');
831
- }
832
-
833
- if (testResultsPollInterval) {
834
- clearInterval(testResultsPollInterval);
835
- testResultsPollInterval = null;
836
- lastTestResultsMtime = null;
837
- worktreeTestResultsMtimes.clear();
838
- log('[WS] Test results polling stopped');
839
- }
840
-
841
- if (worktreeCleanupInterval) {
842
- clearInterval(worktreeCleanupInterval);
843
- worktreeCleanupInterval = null;
844
- log('[cleanup] Worktree cleanup stopped');
845
- }
846
-
847
- if (wss) {
848
- // Close all client connections
849
- for (const client of wsClients) {
850
- client.close();
851
- }
852
- wsClients.clear();
853
-
854
- wss.close(() => {
855
- log('[WS] WebSocket server closed');
856
- });
857
- wss = null;
858
- }
859
- }
860
-
861
- // ==================== Claude Subprocess Management ====================
862
-
863
- function spawnClaude(sessionId, cwd) {
864
- // Kill existing process for this session if any
865
- if (claudeProcesses.has(sessionId)) {
866
- killClaude(sessionId);
867
- }
868
-
869
- log(`[Claude] Spawning for session ${sessionId} in ${cwd}`);
870
-
871
- const claudeProcess = spawn(getUserShell(), ['-lc', 'claude'], {
872
- cwd: cwd || projectRoot,
873
- env: {
874
- ...getShellEnv(),
875
- JETTYPOD_PROJECT_PATH: projectRoot
876
- }
877
- });
878
-
879
- claudeProcesses.set(sessionId, claudeProcess);
880
-
881
- claudeProcess.stdout.on('data', (data) => {
882
- const text = data.toString();
883
- log(`[Claude:${sessionId}] stdout: ${text.substring(0, 100)}...`);
884
- if (mainWindow && !mainWindow.isDestroyed()) {
885
- mainWindow.webContents.send('claude:output', {
886
- sessionId,
887
- type: 'stdout',
888
- data: text
889
- });
890
- }
891
- });
892
-
893
- claudeProcess.stderr.on('data', (data) => {
894
- const text = data.toString();
895
- log(`[Claude:${sessionId}] stderr: ${text.substring(0, 100)}...`);
896
- if (mainWindow && !mainWindow.isDestroyed()) {
897
- mainWindow.webContents.send('claude:output', {
898
- sessionId,
899
- type: 'stderr',
900
- data: text
901
- });
902
- }
903
- });
904
-
905
- claudeProcess.on('error', (err) => {
906
- log(`[Claude:${sessionId}] spawn error: ${err.message}`);
907
- if (mainWindow && !mainWindow.isDestroyed()) {
908
- mainWindow.webContents.send('claude:output', {
909
- sessionId,
910
- type: 'error',
911
- data: err.message
912
- });
913
- }
914
- claudeProcesses.delete(sessionId);
915
- });
916
-
917
- claudeProcess.on('exit', (code, signal) => {
918
- log(`[Claude:${sessionId}] exited with code ${code}, signal ${signal}`);
919
- if (mainWindow && !mainWindow.isDestroyed()) {
920
- mainWindow.webContents.send('claude:output', {
921
- sessionId,
922
- type: 'exit',
923
- code,
924
- signal
925
- });
926
- }
927
- claudeProcesses.delete(sessionId);
928
- });
929
-
930
- return { success: true, sessionId };
931
- }
932
-
933
- function writeToClaudeStdin(sessionId, text) {
934
- const claudeProcess = claudeProcesses.get(sessionId);
935
- if (!claudeProcess) {
936
- log(`[Claude:${sessionId}] No process found for write`);
937
- return { success: false, error: 'No process found' };
938
- }
939
-
940
- log(`[Claude:${sessionId}] Writing to stdin: ${text.substring(0, 50)}...`);
941
- claudeProcess.stdin.write(text);
942
- return { success: true };
943
- }
944
-
945
- function killClaude(sessionId) {
946
- const claudeProcess = claudeProcesses.get(sessionId);
947
- if (!claudeProcess) {
948
- log(`[Claude:${sessionId}] No process found to kill`);
949
- return { success: false, error: 'No process found' };
950
- }
951
-
952
- log(`[Claude:${sessionId}] Killing process`);
953
- claudeProcess.stdin.destroy();
954
- claudeProcess.kill('SIGTERM');
955
- claudeProcesses.delete(sessionId);
956
- return { success: true };
957
- }
958
-
959
- function killAllClaudeProcesses() {
960
- log(`[Claude] Killing all ${claudeProcesses.size} processes`);
961
- for (const [sessionId, process] of claudeProcesses) {
962
- log(`[Claude] Killing session ${sessionId}`);
963
- process.stdin.destroy();
964
- process.kill('SIGTERM');
965
- }
966
- claudeProcesses.clear();
967
- }
968
-
969
- // ==================== Dev Server Management ====================
970
-
971
- function spawnDevServer(projectPath, command = 'npm run dev', port = 3001) {
972
- // Kill existing server for this project if any
973
- if (devServerProcesses.has(projectPath)) {
974
- killDevServer(projectPath);
975
- }
976
-
977
- log(`[DevServer] Spawning for ${projectPath} on port ${port}`);
978
- log(`[DevServer] Command: ${command}`);
979
-
980
- // Parse command into command and args
981
- const [cmd, ...args] = command.split(' ');
982
-
983
- const devProcess = spawn(cmd, args, {
984
- cwd: projectPath,
985
- shell: true,
986
- env: {
987
- ...process.env,
988
- PORT: String(port),
989
- JETTYPOD_PROJECT_PATH: projectRoot
990
- }
991
- });
992
-
993
- const serverInfo = {
994
- process: devProcess,
995
- port,
996
- command,
997
- status: 'starting',
998
- startedAt: Date.now()
999
- };
1000
-
1001
- devServerProcesses.set(projectPath, serverInfo);
1002
-
1003
- devProcess.stdout.on('data', (data) => {
1004
- const text = data.toString();
1005
- log(`[DevServer:${port}] stdout: ${text.substring(0, 100)}...`);
1006
-
1007
- // Detect common "ready" signals
1008
- if (serverInfo.status === 'starting') {
1009
- const readySignals = ['Ready', 'Listening', 'started', 'running on', 'localhost:'];
1010
- if (readySignals.some(signal => text.toLowerCase().includes(signal.toLowerCase()))) {
1011
- serverInfo.status = 'running';
1012
- log(`[DevServer:${port}] Server is ready`);
1013
- }
1014
- }
1015
-
1016
- if (mainWindow && !mainWindow.isDestroyed()) {
1017
- mainWindow.webContents.send('devServer:output', {
1018
- projectPath,
1019
- port,
1020
- type: 'stdout',
1021
- data: text
1022
- });
1023
- }
1024
- });
1025
-
1026
- devProcess.stderr.on('data', (data) => {
1027
- const text = data.toString();
1028
- log(`[DevServer:${port}] stderr: ${text.substring(0, 100)}...`);
1029
- if (mainWindow && !mainWindow.isDestroyed()) {
1030
- mainWindow.webContents.send('devServer:output', {
1031
- projectPath,
1032
- port,
1033
- type: 'stderr',
1034
- data: text
1035
- });
1036
- }
1037
- });
1038
-
1039
- devProcess.on('error', (err) => {
1040
- log(`[DevServer:${port}] spawn error: ${err.message}`);
1041
- serverInfo.status = 'error';
1042
- if (mainWindow && !mainWindow.isDestroyed()) {
1043
- mainWindow.webContents.send('devServer:output', {
1044
- projectPath,
1045
- port,
1046
- type: 'error',
1047
- data: err.message
1048
- });
1049
- }
1050
- devServerProcesses.delete(projectPath);
1051
- });
1052
-
1053
- devProcess.on('exit', (code, signal) => {
1054
- log(`[DevServer:${port}] exited with code ${code}, signal ${signal}`);
1055
- if (mainWindow && !mainWindow.isDestroyed()) {
1056
- mainWindow.webContents.send('devServer:output', {
1057
- projectPath,
1058
- port,
1059
- type: 'exit',
1060
- code,
1061
- signal
1062
- });
1063
- }
1064
- devServerProcesses.delete(projectPath);
1065
- });
1066
-
1067
- return { success: true, projectPath, port };
1068
- }
1069
-
1070
- function killDevServer(projectPath) {
1071
- const serverInfo = devServerProcesses.get(projectPath);
1072
- if (!serverInfo) {
1073
- log(`[DevServer] No server found for ${projectPath}`);
1074
- return { success: false, error: 'No server found' };
1075
- }
1076
-
1077
- log(`[DevServer] Killing server for ${projectPath} on port ${serverInfo.port}`);
1078
- serverInfo.process.kill('SIGTERM');
1079
- devServerProcesses.delete(projectPath);
1080
- return { success: true };
1081
- }
1082
-
1083
- function getDevServerStatus(projectPath) {
1084
- const serverInfo = devServerProcesses.get(projectPath);
1085
- if (!serverInfo) {
1086
- return { running: false };
1087
- }
1088
- return {
1089
- running: true,
1090
- port: serverInfo.port,
1091
- command: serverInfo.command,
1092
- status: serverInfo.status,
1093
- startedAt: serverInfo.startedAt
1094
- };
1095
- }
1096
-
1097
- function listDevServers() {
1098
- const servers = [];
1099
- for (const [projectPath, serverInfo] of devServerProcesses) {
1100
- servers.push({
1101
- projectPath,
1102
- port: serverInfo.port,
1103
- command: serverInfo.command,
1104
- status: serverInfo.status,
1105
- startedAt: serverInfo.startedAt
1106
- });
1107
- }
1108
- return servers;
1109
- }
1110
-
1111
- function killAllDevServers() {
1112
- log(`[DevServer] Killing all ${devServerProcesses.size} servers`);
1113
- for (const [projectPath, serverInfo] of devServerProcesses) {
1114
- log(`[DevServer] Killing server for ${projectPath}`);
1115
- serverInfo.process.kill('SIGTERM');
1116
- }
1117
- devServerProcesses.clear();
1118
- }
1119
-
1120
- // Export for IPC handlers
1121
- module.exports = {
1122
- spawnClaude,
1123
- writeToClaudeStdin,
1124
- killClaude,
1125
- getClaudeProcesses: () => claudeProcesses,
1126
- spawnDevServer,
1127
- killDevServer,
1128
- getDevServerStatus,
1129
- listDevServers,
1130
- setProjectRoot,
1131
- getMainWindow,
1132
- writeLastSelectedProject,
1133
- installClaudeCode,
1134
- updateClaudeCode,
1135
- getClaudeCodeInstalled,
1136
- getClaudeCodeAuthenticated,
1137
- checkClaudeCodeAuthenticated,
1138
- loginClaudeCode
1139
- };
1140
-
1141
- // ==================== Application Menu ====================
1142
-
1143
- function buildApplicationMenu() {
1144
- const isMac = process.platform === 'darwin';
1145
-
1146
- const template = [
1147
- // App menu (macOS only)
1148
- ...(isMac ? [{
1149
- label: app.name,
1150
- submenu: [
1151
- { role: 'about' },
1152
- { type: 'separator' },
1153
- { role: 'services' },
1154
- { type: 'separator' },
1155
- { role: 'hide' },
1156
- { role: 'hideOthers' },
1157
- { role: 'unhide' },
1158
- { type: 'separator' },
1159
- { role: 'quit' }
1160
- ]
1161
- }] : []),
1162
- // File menu
1163
- {
1164
- label: 'File',
1165
- submenu: [
1166
- {
1167
- label: 'New Window',
1168
- accelerator: 'CmdOrCtrl+Shift+N',
1169
- click: () => {
1170
- createNewWindow();
1171
- }
1172
- },
1173
- {
1174
- label: 'Open Folder...',
1175
- accelerator: 'CmdOrCtrl+O',
1176
- click: async () => {
1177
- await openFolder();
1178
- }
1179
- },
1180
- {
1181
- label: 'Switch Project...',
1182
- accelerator: 'CmdOrCtrl+Shift+O',
1183
- click: () => {
1184
- if (mainWindow) {
1185
- projectRoot = null;
1186
- process.env.JETTYPOD_PROJECT_PATH = '';
1187
- // Sync the cleared project to Next.js dev server
1188
- if (!isPackaged && nextProcess) {
1189
- syncProjectToNextServer('');
1190
- }
1191
- mainWindow.loadURL('http://localhost:3000/welcome');
1192
- log('Navigated to project selection screen');
1193
- }
1194
- }
1195
- },
1196
- { type: 'separator' },
1197
- isMac ? { role: 'close' } : { role: 'quit' }
1198
- ]
1199
- },
1200
- // Edit menu
1201
- {
1202
- label: 'Edit',
1203
- submenu: [
1204
- { role: 'undo' },
1205
- { role: 'redo' },
1206
- { type: 'separator' },
1207
- { role: 'cut' },
1208
- { role: 'copy' },
1209
- { role: 'paste' },
1210
- ...(isMac ? [
1211
- { role: 'pasteAndMatchStyle' },
1212
- { role: 'delete' },
1213
- { role: 'selectAll' },
1214
- ] : [
1215
- { role: 'delete' },
1216
- { type: 'separator' },
1217
- { role: 'selectAll' }
1218
- ])
1219
- ]
1220
- },
1221
- // View menu
1222
- {
1223
- label: 'View',
1224
- submenu: [
1225
- { role: 'reload' },
1226
- { role: 'forceReload' },
1227
- { role: 'toggleDevTools' },
1228
- { type: 'separator' },
1229
- { role: 'resetZoom' },
1230
- { role: 'zoomIn' },
1231
- { role: 'zoomOut' },
1232
- { type: 'separator' },
1233
- { role: 'togglefullscreen' }
1234
- ]
1235
- },
1236
- // Window menu
1237
- {
1238
- label: 'Window',
1239
- submenu: [
1240
- { role: 'minimize' },
1241
- { role: 'zoom' },
1242
- ...(isMac ? [
1243
- { type: 'separator' },
1244
- { role: 'front' },
1245
- { type: 'separator' },
1246
- { role: 'window' }
1247
- ] : [
1248
- { role: 'close' }
1249
- ])
1250
- ]
1251
- },
1252
- // Help menu
1253
- {
1254
- label: 'Help',
1255
- submenu: [
1256
- {
1257
- label: 'Check for Updates',
1258
- click: async () => {
1259
- const headers = sessionManager.getUpdaterHeaders();
1260
- if (!headers.Authorization) {
1261
- dialog.showMessageBox({
1262
- type: 'warning',
1263
- title: 'Update Check Unavailable',
1264
- message: 'Not signed in.',
1265
- detail: 'Please sign in to check for updates.',
1266
- buttons: ['OK']
1267
- });
1268
- return;
1269
- }
1270
- log('[AutoUpdate] Manual check for updates...');
1271
- autoUpdater.requestHeaders = headers;
1272
- try {
1273
- const result = await autoUpdater.checkForUpdates();
1274
- if (result && result.updateInfo) {
1275
- dialog.showMessageBox({
1276
- type: 'info',
1277
- title: 'Update Available',
1278
- message: `Version ${result.updateInfo.version} is available.`,
1279
- buttons: ['OK']
1280
- });
1281
- } else {
1282
- dialog.showMessageBox({
1283
- type: 'info',
1284
- title: 'No Updates',
1285
- message: "You're up to date!",
1286
- buttons: ['OK']
1287
- });
1288
- }
1289
- } catch (error) {
1290
- log(`[AutoUpdate] Manual check failed: ${error.message}`);
1291
- dialog.showMessageBox({
1292
- type: 'error',
1293
- title: 'Update Check Failed',
1294
- message: 'Unable to check for updates.',
1295
- detail: 'The update server is unavailable. Please check your internet connection and try again later.',
1296
- buttons: ['OK']
1297
- });
1298
- }
1299
- }
1300
- }
1301
- ]
1302
- }
1303
- ];
1304
-
1305
- return Menu.buildFromTemplate(template);
1306
- }
1307
-
1308
- // ==================== Window Management ====================
1309
-
1310
- function createWindow() {
1311
- log('Creating browser window...');
1312
-
1313
- mainWindow = new BrowserWindow({
1314
- width: 1400,
1315
- height: 900,
1316
- title: 'JettyPod',
1317
- backgroundColor: '#FAF9F7',
1318
- webPreferences: {
1319
- nodeIntegration: false,
1320
- contextIsolation: true,
1321
- preload: preloadPath
1322
- }
1323
- });
1324
-
1325
- windows.add(mainWindow);
1326
-
1327
- mainWindow.loadURL(getStartUrl());
1328
-
1329
- // Open DevTools in development
1330
- if (process.env.NODE_ENV === 'development') {
1331
- mainWindow.webContents.openDevTools();
1332
- }
1333
-
1334
- mainWindow.on('closed', () => {
1335
- windows.delete(mainWindow);
1336
- mainWindow = null;
1337
- });
1338
-
1339
- log('Window created');
1340
- }
1341
-
1342
- // Open a folder dialog and switch the current window to that project
1343
- async function openFolder() {
1344
- const focusedWindow = BrowserWindow.getFocusedWindow();
1345
- if (!focusedWindow) {
1346
- log('[openFolder] No focused window');
1347
- return;
1348
- }
1349
-
1350
- const result = await dialog.showOpenDialog(focusedWindow, {
1351
- properties: ['openDirectory'],
1352
- title: 'Open JettyPod Project'
1353
- });
1354
-
1355
- if (result.canceled || result.filePaths.length === 0) {
1356
- log('[openFolder] Dialog canceled');
1357
- return;
1358
- }
1359
-
1360
- const selectedPath = result.filePaths[0];
1361
- log(`[openFolder] Selected: ${selectedPath}`);
1362
-
1363
- // Validate the folder has a .jettypod directory
1364
- if (!hasValidProject(selectedPath)) {
1365
- log('[openFolder] Invalid project - missing .jettypod directory');
1366
- dialog.showErrorBox('Not a JettyPod project',
1367
- 'The selected folder does not contain a .jettypod directory.\n\nPlease select a folder that has been initialized with JettyPod.');
1368
- return;
1369
- }
1370
-
1371
- // Update the project root (await ensures Next.js dev server is synced before reload)
1372
- await setProjectRoot(selectedPath);
1373
-
1374
- // Reload the focused window to the main dashboard (not welcome)
1375
- focusedWindow.loadURL('http://localhost:3000');
1376
- log('[openFolder] Window reloaded with new project');
1377
- }
1378
-
1379
- // Create a new window showing the welcome screen (for opening a second project)
1380
- function createNewWindow() {
1381
- log('Creating new window...');
1382
-
1383
- const newWindow = new BrowserWindow({
1384
- width: 1400,
1385
- height: 900,
1386
- title: 'JettyPod',
1387
- backgroundColor: '#FAF9F7',
1388
- webPreferences: {
1389
- nodeIntegration: false,
1390
- contextIsolation: true,
1391
- preload: preloadPath
1392
- }
1393
- });
1394
-
1395
- windows.add(newWindow);
1396
-
1397
- // New windows always start at welcome screen so user can select a project
1398
- newWindow.loadURL('http://localhost:3000/welcome');
1399
-
1400
- newWindow.on('closed', () => {
1401
- windows.delete(newWindow);
1402
- log(`Window closed. ${windows.size} windows remaining.`);
1403
- });
1404
-
1405
- log(`New window created. Total windows: ${windows.size}`);
1406
- return newWindow;
1407
- }
1408
-
1409
- function cleanup() {
1410
- log('Shutting down...');
1411
-
1412
- if (nextProcess) {
1413
- log('Killing Next.js dev server...');
1414
- // Use SIGKILL and kill the entire process group to ensure all child processes are terminated
1415
- const pid = nextProcess.pid;
1416
- try {
1417
- // Kill the process group (negative PID) to terminate all descendants
1418
- process.kill(-pid, 'SIGKILL');
1419
- log(`Killed Next.js process group (PID: ${pid})`);
1420
- } catch (killErr) {
1421
- // Fallback to killing just the process if process group kill fails
1422
- try {
1423
- nextProcess.kill('SIGKILL');
1424
- log(`Killed Next.js process (PID: ${pid})`);
1425
- } catch (fallbackErr) {
1426
- log(`Warning: Could not kill Next.js process: ${fallbackErr.message}`);
1427
- }
1428
- }
1429
- nextProcess = null;
1430
- }
1431
-
1432
- if (nextServer) {
1433
- log('Closing Next.js production server...');
1434
- nextServer.close();
1435
- nextServer = null;
1436
- }
1437
-
1438
- killAllClaudeProcesses();
1439
- killAllDevServers();
1440
- stopWebSocketServer();
1441
- closeDb();
1442
- closeIngesterDb();
1443
- }
1444
-
1445
- // ==================== Claude Code Detection ====================
1446
-
1447
- // Track whether Claude Code is installed and authenticated (checked on startup)
1448
- let claudeCodeInstalled = false;
1449
- let claudeCodeAuthenticated = false;
1450
- let claudeCodeNeedsUpdate = false;
1451
-
1452
- /**
1453
- * Check if Claude Code CLI is installed
1454
- * Uses 'which claude' to detect installation
1455
- * @returns {boolean} true if Claude Code is installed
1456
- */
1457
- async function checkClaudeCodeInstalled() {
1458
- const shellEnv = getShellEnv();
1459
- log(`[ClaudeCode] Checking with PATH: ${shellEnv.PATH}`);
1460
- try {
1461
- const { stdout } = await execFileAsync('which', ['claude'], { encoding: 'utf-8', env: shellEnv });
1462
- log(`[ClaudeCode] Claude Code found at: ${stdout.trim()}`);
1463
- return true;
1464
- } catch {
1465
- log('[ClaudeCode] Claude Code is NOT installed (not found in PATH)');
1466
- return false;
1467
- }
1468
- }
1469
-
1470
- /**
1471
- * Install Claude Code using the official Anthropic install script.
1472
- * This is the recommended installation method that handles PATH setup automatically.
1473
- * @returns {Promise<{success: boolean, error?: string}>}
1474
- */
1475
- async function installClaudeCode() {
1476
- log('[ClaudeCode] Starting installation via official script...');
1477
-
1478
- const shellEnv = getShellEnv();
1479
-
1480
- return new Promise((resolve) => {
1481
- // Run the official Anthropic install script
1482
- const installProcess = spawn(getUserShell(), ['-lc', 'curl -fsSL https://claude.ai/install.sh | bash'], {
1483
- env: shellEnv
1484
- });
1485
-
1486
- let stderr = '';
1487
- let stdout = '';
1488
-
1489
- installProcess.stdout.on('data', (data) => {
1490
- stdout += data.toString();
1491
- log(`[ClaudeCode] stdout: ${data}`);
1492
- });
1493
-
1494
- installProcess.stderr.on('data', (data) => {
1495
- stderr += data.toString();
1496
- log(`[ClaudeCode] stderr: ${data}`);
1497
- });
1498
-
1499
- installProcess.on('error', (err) => {
1500
- log(`[ClaudeCode] Install error: ${err.message}`);
1501
- resolve({ success: false, error: err.message });
1502
- });
1503
-
1504
- installProcess.on('exit', (code) => {
1505
- if (code === 0) {
1506
- log('[ClaudeCode] Installation successful');
1507
- claudeCodeInstalled = true;
1508
- resolve({ success: true });
1509
- } else {
1510
- log(`[ClaudeCode] Installation failed with code ${code}`);
1511
- const errorMsg = stderr || stdout || `Exit code ${code}`;
1512
- resolve({ success: false, error: errorMsg });
1513
- }
1514
- });
1515
- });
1516
- }
1517
-
1518
- /**
1519
- * Update Claude Code via `claude update`
1520
- * @returns {Promise<{success: boolean, error?: string}>}
1521
- */
1522
- async function updateClaudeCode() {
1523
- log('[ClaudeCode] Starting update...');
1524
-
1525
- const shellEnv = getShellEnv();
1526
- log(`[ClaudeCode] Using PATH: ${shellEnv.PATH.substring(0, 200)}...`);
1527
-
1528
- return new Promise((resolve) => {
1529
- const updateProcess = spawn(getUserShell(), ['-lc', 'claude update'], {
1530
- env: shellEnv
1531
- });
1532
-
1533
- let stderr = '';
1534
- let stdout = '';
1535
-
1536
- updateProcess.stdout.on('data', (data) => {
1537
- stdout += data.toString();
1538
- log(`[ClaudeCode] stdout: ${data}`);
1539
- });
1540
-
1541
- updateProcess.stderr.on('data', (data) => {
1542
- stderr += data.toString();
1543
- log(`[ClaudeCode] stderr: ${data}`);
1544
- });
1545
-
1546
- updateProcess.on('error', (err) => {
1547
- log(`[ClaudeCode] Update error: ${err.message}`);
1548
- resolve({ success: false, error: err.message });
1549
- });
1550
-
1551
- updateProcess.on('exit', (code) => {
1552
- if (code === 0) {
1553
- log('[ClaudeCode] Update successful');
1554
- resolve({ success: true, output: stdout });
1555
- } else {
1556
- log(`[ClaudeCode] Update failed with code ${code}`);
1557
- resolve({ success: false, error: stderr || stdout || `Exit code ${code}` });
1558
- }
1559
- });
1560
- });
1561
- }
1562
-
1563
- // Export for IPC handlers
1564
- function getClaudeCodeInstalled() {
1565
- return claudeCodeInstalled;
1566
- }
1567
-
1568
- function getClaudeCodeAuthenticated() {
1569
- return claudeCodeAuthenticated;
1570
- }
1571
-
1572
- /**
1573
- * Check if Claude Code CLI is authenticated using `claude auth status --json`.
1574
- * This is a local check (no API call), so it returns instantly.
1575
- * On any unexpected failure, defaults to false (shows connect screen)
1576
- * so the user can authenticate rather than hitting a broken state.
1577
- * @returns {boolean} true if Claude Code is authenticated
1578
- */
1579
- async function checkClaudeCodeAuthenticated() {
1580
- if (!claudeCodeInstalled) return false;
1581
- const shellEnv = getShellEnv();
1582
- try {
1583
- const { stdout } = await execFileAsync('claude', ['auth', 'status', '--json'], {
1584
- encoding: 'utf-8',
1585
- env: shellEnv,
1586
- timeout: 10000
1587
- });
1588
- const status = JSON.parse(stdout);
1589
- if (status.loggedIn) {
1590
- log('[ClaudeCode] Claude Code is authenticated');
1591
- return true;
1592
- }
1593
- log('[ClaudeCode] Claude Code is NOT authenticated');
1594
- return false;
1595
- } catch (err) {
1596
- const combined = (err.stderr || '') + (err.stdout || '') + (err.message || '');
1597
-
1598
- if (combined.includes('needs an update') || combined.includes('needs_update')) {
1599
- log('[ClaudeCode] CLI is outdated, flagging for auto-update');
1600
- claudeCodeNeedsUpdate = true;
1601
- return false;
1602
- }
1603
-
1604
- log(`[ClaudeCode] Auth check failed, defaulting to unauthenticated: ${combined}`);
1605
- return false;
1606
- }
1607
- }
1608
-
1609
- /**
1610
- * Spawn `claude auth login` to trigger browser-based OAuth authentication.
1611
- * The CLI prints an auth URL to stdout/stderr. We capture it and open it
1612
- * with shell.openExternal() since the spawned process has no TTY.
1613
- * @returns {Promise<{success: boolean, error?: string}>}
1614
- */
1615
- function loginClaudeCode() {
1616
- return new Promise((resolve) => {
1617
- const shellEnv = getShellEnv();
1618
- log('[ClaudeCode] Starting claude auth login...');
1619
-
1620
- const loginProcess = spawn(getUserShell(), ['-lc', 'claude auth login'], {
1621
- env: shellEnv,
1622
- stdio: 'pipe'
1623
- });
1624
-
1625
- let stdout = '';
1626
- let stderr = '';
1627
- let urlOpened = false;
1628
-
1629
- const tryOpenUrl = (text) => {
1630
- if (urlOpened) return;
1631
- // Match OAuth/auth URLs the CLI prints for non-TTY environments
1632
- const urlMatch = text.match(/https?:\/\/\S+/);
1633
- if (urlMatch) {
1634
- const url = urlMatch[0];
1635
- log(`[ClaudeCode] Found auth URL, opening in browser: ${url}`);
1636
- shell.openExternal(url);
1637
- urlOpened = true;
1638
- }
1639
- };
1640
-
1641
- loginProcess.stdout.on('data', (data) => {
1642
- const chunk = data.toString();
1643
- stdout += chunk;
1644
- log(`[ClaudeCode] stdout: ${chunk.trim()}`);
1645
- tryOpenUrl(chunk);
1646
- });
1647
-
1648
- loginProcess.stderr.on('data', (data) => {
1649
- const chunk = data.toString();
1650
- stderr += chunk;
1651
- log(`[ClaudeCode] stderr: ${chunk.trim()}`);
1652
- tryOpenUrl(chunk);
1653
- });
1654
-
1655
- loginProcess.on('close', async (code) => {
1656
- if (code === 0) {
1657
- claudeCodeAuthenticated = true;
1658
- log('[ClaudeCode] Auth completed successfully');
1659
- resolve({ success: true });
1660
- } else if (stderr.includes('needs an update') || stderr.includes('needs_update') ||
1661
- stdout.includes('needs an update') || stdout.includes('needs_update')) {
1662
- log('[ClaudeCode] CLI outdated during login, running auto-update...');
1663
- const updateResult = await updateClaudeCode();
1664
- if (updateResult.success) {
1665
- log('[ClaudeCode] Auto-update succeeded, retrying login');
1666
- claudeCodeNeedsUpdate = false;
1667
- // Retry login after update
1668
- const retryResult = await loginClaudeCode();
1669
- resolve(retryResult);
1670
- } else {
1671
- log(`[ClaudeCode] Auto-update failed: ${updateResult.error}`);
1672
- resolve({ success: false, error: 'Claude Code needs an update but the update failed. Please run "claude update" manually.' });
1673
- }
1674
- } else {
1675
- log(`[ClaudeCode] Auth failed with code ${code}: ${stderr}`);
1676
- resolve({ success: false, error: stderr || `Auth exited with code ${code}` });
1677
- }
1678
- });
1679
-
1680
- loginProcess.on('error', (err) => {
1681
- log(`[ClaudeCode] Auth spawn error: ${err.message}`);
1682
- resolve({ success: false, error: err.message });
1683
- });
1684
- });
1685
- }
1686
-
1687
- // ==================== PATH Setup ====================
1688
-
1689
- const SYMLINK_PATH = '/usr/local/bin/jettypod';
1690
-
1691
- /**
1692
- * Get the path to the bundled jettypod CLI
1693
- * In packaged app: Contents/Resources/bin/jettypod
1694
- */
1695
- function getBundledCliPath() {
1696
- if (isPackaged) {
1697
- return path.join(process.resourcesPath, 'bin', 'jettypod');
1698
- }
1699
- // Development mode - use the bin/jettypod in project root
1700
- return path.join(__dirname, '..', '..', '..', 'bin', 'jettypod');
1701
- }
1702
-
1703
- /**
1704
- * Setup PATH by creating symlink to /usr/local/bin
1705
- * Uses osascript to request admin privileges
1706
- */
1707
- async function setupCliSymlink() {
1708
- // Only run on macOS
1709
- if (process.platform !== 'darwin') {
1710
- log('[PATH] Not macOS, skipping PATH setup');
1711
- return;
1712
- }
1713
-
1714
- const bundledPath = getBundledCliPath();
1715
- log(`[PATH] Bundled CLI path: ${bundledPath}`);
1716
-
1717
- // Check if bundled CLI exists
1718
- if (!fs.existsSync(bundledPath)) {
1719
- log('[PATH] Bundled CLI not found, skipping PATH setup');
1720
- return;
1721
- }
1722
-
1723
- // Check if symlink already points to the correct bundled CLI
1724
- try {
1725
- const currentTarget = fs.readlinkSync(SYMLINK_PATH);
1726
- if (currentTarget === bundledPath) {
1727
- log('[PATH] Symlink already points to bundled CLI, skipping');
1728
- return;
1729
- }
1730
- log(`[PATH] Symlink points to ${currentTarget}, updating to bundled CLI`);
1731
- } catch {
1732
- // Symlink doesn't exist or isn't a symlink — create it
1733
- log('[PATH] No existing symlink, creating');
1734
- }
1735
-
1736
- log('[PATH] Setting up PATH with admin privileges...');
1737
-
1738
- // Use osascript to run ln with admin privileges.
1739
- // mkdir -p ensures the parent directory exists (e.g. /usr/local/bin may not
1740
- // exist on fresh Apple Silicon Macs which use /opt/homebrew/bin by default).
1741
- const symlinkDir = path.dirname(SYMLINK_PATH);
1742
- const script = `do shell script "mkdir -p '${symlinkDir}' && ln -sf '${bundledPath}' '${SYMLINK_PATH}'" with administrator privileges`;
1743
-
1744
- try {
1745
- await execAsync(`osascript -e '${script}'`, { encoding: 'utf-8' });
1746
- log('[PATH] Symlink created successfully');
1747
- } catch (error) {
1748
- // User cancelled or error occurred
1749
- log(`[PATH] Setup failed or cancelled: ${error.message}`);
1750
- }
1751
- }
1752
-
1753
- // ==================== Subscription & Access ====================
1754
-
1755
- /**
1756
- * Get path to subscription.json file
1757
- * Stored in Electron's userData directory (~/.config/JettyPod/)
1758
- */
1759
- function getSubscriptionPath() {
1760
- return path.join(app.getPath('userData'), 'subscription.json');
1761
- }
1762
-
1763
- /**
1764
- * Get the path to the auth state file.
1765
- * Stored alongside subscription.json in Electron's userData directory.
1766
- */
1767
- function getAuthPath() {
1768
- return path.join(app.getPath('userData'), 'auth.json');
1769
- }
1770
-
1771
- /**
1772
- * Check if there's a stored auth token (JWT).
1773
- * Does NOT validate the token — just checks if auth.json exists with a token.
1774
- */
1775
- function isAuthenticated() {
1776
- const authPath = getAuthPath();
1777
- if (!fs.existsSync(authPath)) {
1778
- log('[Auth] No auth.json found');
1779
- return false;
1780
- }
1781
-
1782
- try {
1783
- const data = JSON.parse(fs.readFileSync(authPath, 'utf-8'));
1784
- if (data.token) {
1785
- log('[Auth] Auth token found');
1786
- return true;
1787
- }
1788
- } catch (error) {
1789
- log(`[Auth] Error reading auth file: ${error.message}`);
1790
- }
1791
-
1792
- return false;
1793
- }
1794
-
1795
- /**
1796
- * Check if the user has ever logged in before.
1797
- * Uses a persistent marker file that survives logout (auth.json deletion).
1798
- */
1799
- function hasLoggedInBefore() {
1800
- try {
1801
- const markerPath = path.join(app.getPath('userData'), 'has-logged-in');
1802
- return fs.existsSync(markerPath);
1803
- } catch {
1804
- return false;
1805
- }
1806
- }
1807
-
1808
- /**
1809
- * Mark that the user has logged in at least once.
1810
- * Called after successful authentication (both OAuth and OTP).
1811
- */
1812
- function markHasLoggedIn() {
1813
- try {
1814
- const markerPath = path.join(app.getPath('userData'), 'has-logged-in');
1815
- const dir = path.dirname(markerPath);
1816
- if (!fs.existsSync(dir)) {
1817
- fs.mkdirSync(dir, { recursive: true });
1818
- }
1819
- if (!fs.existsSync(markerPath)) {
1820
- fs.writeFileSync(markerPath, new Date().toISOString());
1821
- log('[Auth] Marked user as having logged in before');
1822
- }
1823
- } catch (error) {
1824
- log(`[Auth] Failed to write login marker: ${error.message}`);
1825
- }
1826
- }
1827
-
1828
- /**
1829
- * Read the Stripe customer ID from subscription config.
1830
- * Returns null if no subscription is stored.
1831
- * Written by the Stripe checkout flow (chore #1000535).
1832
- */
1833
- function getSubscriptionCustomerId() {
1834
- const subPath = getSubscriptionPath();
1835
- if (!fs.existsSync(subPath)) return null;
1836
- try {
1837
- const data = JSON.parse(fs.readFileSync(subPath, 'utf-8'));
1838
- return data.customerId || null;
1839
- } catch (error) {
1840
- log(`[Subscription] Error reading subscription file: ${error.message}`);
1841
- return null;
1842
- }
1843
- }
1844
-
1845
- /**
1846
- * Check if there's an active subscription stored locally.
1847
- * Full validation against Stripe happens on update check / app refresh.
1848
- */
1849
- function isSubscriptionActive() {
1850
- const subPath = getSubscriptionPath();
1851
- if (!fs.existsSync(subPath)) {
1852
- log('[Subscription] No subscription.json found');
1853
- return false;
1854
- }
1855
-
1856
- try {
1857
- const data = JSON.parse(fs.readFileSync(subPath, 'utf-8'));
1858
- if (data.customerId) {
1859
- log('[Subscription] Active subscription found');
1860
- return true;
1861
- }
1862
- } catch (error) {
1863
- log(`[Subscription] Error reading subscription file: ${error.message}`);
1864
- }
1865
-
1866
- return false;
1867
- }
1868
-
1869
- // ==================== Skills Auto-Sync ====================
1870
-
1871
- /**
1872
- * Get the path to bundled skills
1873
- * In packaged app: Contents/Resources/skills
1874
- * In dev mode: ~/.claude/skills (the user's existing skills)
1875
- */
1876
- function getBundledSkillsPath() {
1877
- if (isPackaged) {
1878
- return path.join(process.resourcesPath, 'skills');
1879
- }
1880
- // Development mode - use the user's existing skills directory
1881
- return path.join(require('os').homedir(), '.claude', 'skills');
1882
- }
1883
-
1884
- /**
1885
- * Get the user's skills directory path
1886
- * @returns {string} Path to ~/.claude/skills/
1887
- */
1888
- function getUserSkillsPath() {
1889
- return path.join(require('os').homedir(), '.claude', 'skills');
1890
- }
1891
-
1892
- /**
1893
- * Recursively copy a directory
1894
- * @param {string} src - Source directory
1895
- * @param {string} dest - Destination directory
1896
- */
1897
- function copyDirSync(src, dest) {
1898
- // Create destination directory if it doesn't exist
1899
- if (!fs.existsSync(dest)) {
1900
- fs.mkdirSync(dest, { recursive: true });
1901
- }
1902
-
1903
- const entries = fs.readdirSync(src, { withFileTypes: true });
1904
-
1905
- for (const entry of entries) {
1906
- const srcPath = path.join(src, entry.name);
1907
- const destPath = path.join(dest, entry.name);
1908
-
1909
- if (entry.isDirectory()) {
1910
- copyDirSync(srcPath, destPath);
1911
- } else {
1912
- // Skip copy if dest exists and has same or newer mtime + same size
1913
- try {
1914
- const srcStat = fs.statSync(srcPath);
1915
- const destStat = fs.statSync(destPath);
1916
- if (destStat.mtimeMs >= srcStat.mtimeMs && destStat.size === srcStat.size) {
1917
- continue;
1918
- }
1919
- } catch {
1920
- // dest doesn't exist — copy it
1921
- }
1922
- fs.copyFileSync(srcPath, destPath);
1923
- }
1924
- }
1925
- }
1926
-
1927
- /**
1928
- * Sync bundled skills to user's ~/.claude/skills/ directory
1929
- * Called on every app launch to ensure skills are up to date
1930
- *
1931
- * Error handling: All errors are caught and logged - sync failures
1932
- * should never prevent the app from launching.
1933
- */
1934
- function syncSkills() {
1935
- try {
1936
- const bundledPath = getBundledSkillsPath();
1937
- const userPath = getUserSkillsPath();
1938
-
1939
- log(`[Skills] Syncing skills from ${bundledPath} to ${userPath}`);
1940
-
1941
- // Check if bundled skills exist
1942
- if (!fs.existsSync(bundledPath)) {
1943
- log('[Skills] No bundled skills found, skipping sync');
1944
- return;
1945
- }
1946
-
1947
- // Create user skills directory if it doesn't exist
1948
- if (!fs.existsSync(userPath)) {
1949
- log('[Skills] Creating user skills directory');
1950
- try {
1951
- fs.mkdirSync(userPath, { recursive: true });
1952
- } catch (dirError) {
1953
- log(`[Skills] Warning: Failed to create skills directory: ${dirError.message}`);
1954
- // Cannot proceed without directory - exit gracefully
1955
- return;
1956
- }
1957
- }
1958
-
1959
- // Get list of bundled skills (directories)
1960
- let bundledSkills;
1961
- try {
1962
- bundledSkills = fs.readdirSync(bundledPath, { withFileTypes: true })
1963
- .filter(entry => entry.isDirectory())
1964
- .map(entry => entry.name);
1965
- } catch (readError) {
1966
- log(`[Skills] Warning: Failed to read bundled skills: ${readError.message}`);
1967
- return;
1968
- }
1969
-
1970
- log(`[Skills] Found ${bundledSkills.length} bundled skills to sync`);
1971
-
1972
- // Copy each skill directory (overwrites existing)
1973
- for (const skillName of bundledSkills) {
1974
- const srcSkillPath = path.join(bundledPath, skillName);
1975
- const destSkillPath = path.join(userPath, skillName);
1976
-
1977
- try {
1978
- log(`[Skills] Syncing skill: ${skillName}`);
1979
- copyDirSync(srcSkillPath, destSkillPath);
1980
- } catch (copyError) {
1981
- // Log error but continue with other skills
1982
- log(`[Skills] Warning: Failed to sync skill ${skillName}: ${copyError.message}`);
1983
- }
1984
- }
1985
-
1986
- log('[Skills] Sync complete');
1987
- } catch (error) {
1988
- // Catch-all for any unexpected errors
1989
- log(`[Skills] Warning: Skills sync failed: ${error.message}`);
1990
- // Don't throw - app should continue launching
1991
- }
1992
- }
1993
-
1994
- // ==================== Auto-Update Event Handlers ====================
1995
-
1996
- // Track download state to detect download failures
1997
- let isDownloading = false;
1998
-
1999
- // Handle auto-updater errors (network failures, server unavailable, etc.)
2000
- autoUpdater.on('error', (error) => {
2001
- log(`[AutoUpdate] Error: ${error.message}`);
2002
-
2003
- // If download was in progress, show user-friendly error with retry option
2004
- if (isDownloading) {
2005
- isDownloading = false;
2006
- dialog.showMessageBox({
2007
- type: 'error',
2008
- title: 'Download Failed',
2009
- message: 'Failed to download the update.',
2010
- detail: 'Please check your internet connection and try again.',
2011
- buttons: ['Retry', 'Cancel'],
2012
- defaultId: 0
2013
- }).then((result) => {
2014
- if (result.response === 0) {
2015
- log('[AutoUpdate] User chose to retry download');
2016
- autoUpdater.downloadUpdate();
2017
- } else {
2018
- log('[AutoUpdate] User cancelled retry');
2019
- }
2020
- });
2021
- }
2022
- // Otherwise, this is a background check failure - just log it
2023
- // App continues functioning normally
2024
- });
2025
-
2026
- autoUpdater.on('update-available', (info) => {
2027
- isDownloading = true; // Mark download as starting
2028
- log(`[AutoUpdate] Update available: ${info.version}`);
2029
- dialog.showMessageBox({
2030
- type: 'info',
2031
- title: 'Update Available',
2032
- message: `A new version (${info.version}) is available.`,
2033
- detail: 'The update will be downloaded in the background.',
2034
- buttons: ['OK']
2035
- });
2036
- });
2037
-
2038
- autoUpdater.on('download-progress', (progressObj) => {
2039
- log(`[AutoUpdate] Download progress: ${progressObj.percent.toFixed(1)}%`);
2040
- });
2041
-
2042
- autoUpdater.on('update-downloaded', (info) => {
2043
- isDownloading = false; // Download complete
2044
- log(`[AutoUpdate] Update downloaded: ${info.version}`);
2045
- dialog.showMessageBox({
2046
- type: 'info',
2047
- title: 'Restart to Update',
2048
- message: `Version ${info.version} has been downloaded.`,
2049
- detail: 'Restart now to install the update?',
2050
- buttons: ['Restart Now', 'Later'],
2051
- defaultId: 0
2052
- }).then((result) => {
2053
- if (result.response === 0) {
2054
- log('[AutoUpdate] User chose to restart');
2055
- autoUpdater.quitAndInstall();
2056
- } else {
2057
- log('[AutoUpdate] User chose to restart later');
2058
- }
2059
- });
2060
- });
2061
-
2062
- // ==================== App Lifecycle ====================
2063
-
2064
- // Register jettypod:// protocol for OAuth callback
2065
- if (!isPackaged) {
2066
- // Dev mode: pass app path so macOS can route the URL to this Electron instance
2067
- app.setAsDefaultProtocolClient('jettypod', process.execPath, [path.resolve(process.argv[1])]);
2068
- } else {
2069
- app.setAsDefaultProtocolClient('jettypod');
2070
- }
2071
-
2072
- /**
2073
- * Decode a JWT payload without verifying the signature.
2074
- * Used to extract user info (email, plan) from the token before saving.
2075
- */
2076
- function decodeJWTPayload(token) {
2077
- try {
2078
- const parts = token.split('.');
2079
- if (parts.length !== 3) return null;
2080
- const payload = parts[1].replace(/-/g, '+').replace(/_/g, '/');
2081
- return JSON.parse(Buffer.from(payload, 'base64').toString('utf-8'));
2082
- } catch {
2083
- return null;
2084
- }
2085
- }
2086
-
2087
- /**
2088
- * Handle a jettypod:// protocol URL (OAuth callback).
2089
- * Saves the token + decoded user info to auth.json and navigates to the dashboard.
2090
- */
2091
- function handleProtocolUrl(url) {
2092
- log(`[Auth] Protocol handler received: ${url}`);
2093
-
2094
- try {
2095
- const parsed = new URL(url);
2096
- // jettypod://auth/callback parses as hostname='auth', pathname='/callback'
2097
- const isAuthCallback =
2098
- (parsed.hostname === 'auth' && parsed.pathname === '/callback') ||
2099
- parsed.pathname === '//auth/callback' ||
2100
- parsed.pathname === '/auth/callback';
2101
- if (isAuthCallback) {
2102
- const token = parsed.searchParams.get('token');
2103
- if (token && mainWindow) {
2104
- // Decode JWT to extract user info (sub, email, plan)
2105
- const payload = decodeJWTPayload(token);
2106
- const user = payload ? { id: payload.sub, email: payload.email, plan: payload.plan } : undefined;
2107
-
2108
- // Save auth.json with token AND user data
2109
- const authPath = path.join(app.getPath('userData'), 'auth.json');
2110
- const dir = path.dirname(authPath);
2111
- if (!fs.existsSync(dir)) {
2112
- fs.mkdirSync(dir, { recursive: true });
2113
- }
2114
- fs.writeFileSync(authPath, JSON.stringify({ token, user, savedAt: new Date().toISOString() }, null, 2));
2115
- markHasLoggedIn();
2116
- log('[Auth] Token + user data saved from OAuth callback');
2117
-
2118
- // Navigate to the appropriate onboarding/dashboard page
2119
- mainWindow.loadURL(getStartUrl());
2120
- mainWindow.focus();
2121
- }
2122
- } else if (parsed.hostname === 'subscription' && parsed.pathname === '/activated') {
2123
- log('[Auth] Subscription activated deep link received');
2124
- if (mainWindow) {
2125
- mainWindow.loadURL(getStartUrl());
2126
- mainWindow.focus();
2127
- }
2128
- }
2129
- } catch (error) {
2130
- log(`[Auth] Error handling protocol URL: ${error.message}`);
2131
- }
2132
- }
2133
-
2134
- // Single-instance lock: ensure deep links are routed to the existing instance
2135
- const gotTheLock = app.requestSingleInstanceLock();
2136
-
2137
- if (!gotTheLock) {
2138
- // Another instance is already running — quit this one.
2139
- // The URL (if any) will be forwarded to the existing instance via 'second-instance'.
2140
- app.quit();
2141
- } else {
2142
- // On macOS, protocol URLs for a running app arrive via 'open-url'.
2143
- app.on('open-url', (event, url) => {
2144
- event.preventDefault();
2145
- handleProtocolUrl(url);
2146
- });
2147
-
2148
- // On Windows/Linux (and macOS second-instance fallback), the URL arrives here.
2149
- app.on('second-instance', (_event, commandLine) => {
2150
- // The protocol URL is typically the last argument
2151
- const url = commandLine.find(arg => arg.startsWith('jettypod://'));
2152
- if (url) {
2153
- handleProtocolUrl(url);
2154
- }
2155
- // Focus the main window
2156
- if (mainWindow) {
2157
- if (mainWindow.isMinimized()) mainWindow.restore();
2158
- mainWindow.focus();
2159
- }
2160
- });
2161
- }
2162
-
2163
- app.whenReady().then(async () => {
2164
- log('Electron app ready');
2165
-
2166
- // Start session manager (handles auth heartbeat + auto-updater token)
2167
- sessionManager.setLogger(log);
2168
- if (isAuthenticated()) {
2169
- sessionManager.start();
2170
- }
2171
-
2172
- // Check for updates in the background (only in packaged app)
2173
- if (isPackaged && isAuthenticated()) {
2174
- const headers = sessionManager.getUpdaterHeaders();
2175
- if (headers.Authorization) {
2176
- autoUpdater.requestHeaders = headers;
2177
- log('[AutoUpdate] Checking for updates with JWT...');
2178
- try {
2179
- await autoUpdater.checkForUpdatesAndNotify();
2180
- } catch (error) {
2181
- log(`[AutoUpdate] Update check failed: ${error.message}`);
2182
- }
2183
- } else {
2184
- log('[AutoUpdate] Skipping update check - no auth token');
2185
- }
2186
- }
2187
-
2188
- // Check if Claude Code is installed (for dependency flow)
2189
- // Updates the global claudeCodeInstalled variable
2190
- claudeCodeInstalled = await checkClaudeCodeInstalled();
2191
- log(`Claude Code installed: ${claudeCodeInstalled}`);
2192
-
2193
- // Check if Claude Code is authenticated (only if installed)
2194
- // Wrapped in try/catch so a catastrophic failure here doesn't crash startup
2195
- if (claudeCodeInstalled) {
2196
- try {
2197
- claudeCodeAuthenticated = await checkClaudeCodeAuthenticated();
2198
- } catch (err) {
2199
- log(`[ClaudeCode] Auth check crashed unexpectedly: ${err.message}`);
2200
- claudeCodeAuthenticated = false;
2201
- }
2202
-
2203
- // Auto-update if CLI is outdated, then retry auth check
2204
- if (!claudeCodeAuthenticated && claudeCodeNeedsUpdate) {
2205
- log('[ClaudeCode] CLI outdated, running auto-update...');
2206
- const updateResult = await updateClaudeCode();
2207
- if (updateResult.success) {
2208
- log('[ClaudeCode] Auto-update succeeded, retrying auth check');
2209
- claudeCodeNeedsUpdate = false;
2210
- try {
2211
- claudeCodeAuthenticated = await checkClaudeCodeAuthenticated();
2212
- } catch (err) {
2213
- log(`[ClaudeCode] Auth re-check crashed: ${err.message}`);
2214
- claudeCodeAuthenticated = false;
2215
- }
2216
- } else {
2217
- log(`[ClaudeCode] Auto-update failed: ${updateResult.error}`);
2218
- }
2219
- }
2220
-
2221
- log(`Claude Code authenticated: ${claudeCodeAuthenticated}`);
2222
- }
2223
-
2224
- // Setup PATH on first launch (creates symlink to /usr/local/bin)
2225
- await setupCliSymlink();
2226
-
2227
- // Sync bundled skills to ~/.claude/skills/
2228
- syncSkills();
2229
-
2230
- // Set up application menu
2231
- const menu = buildApplicationMenu();
2232
- Menu.setApplicationMenu(menu);
2233
- log('Application menu set');
2234
-
2235
- // Register IPC handlers for database operations
2236
- registerIpcHandlers();
2237
-
2238
- // Post-login navigation: returns the correct path based on Claude Code state
2239
- ipcMain.handle('auth:getPostLoginPath', () => {
2240
- const url = getStartUrl();
2241
- // Extract just the path from the full URL
2242
- try {
2243
- return new URL(url).pathname;
2244
- } catch {
2245
- return '/';
2246
- }
2247
- });
2248
-
2249
- // Clean up stale resources from previous runs (lock files, orphaned processes)
2250
- await cleanupStaleResources();
2251
-
2252
- try {
2253
- // Start servers in parallel
2254
- await Promise.all([
2255
- startNextServer(),
2256
- startEmbeddedWebSocketServer()
2257
- ]);
2258
-
2259
- // Small delay to ensure servers are fully ready
2260
- await new Promise(r => setTimeout(r, 1000));
2261
-
2262
- createWindow();
2263
- } catch (err) {
2264
- log(`Failed to start servers: ${err.message}`);
2265
- app.quit();
2266
- }
2267
- });
2268
-
2269
- app.on('window-all-closed', () => {
2270
- log('All windows closed');
2271
- cleanup();
2272
- app.quit();
2273
- });
2274
-
2275
- app.on('before-quit', () => {
2276
- log('App quitting...');
2277
- cleanup();
2278
- });
2279
-
2280
- // Handle Ctrl+C in terminal
2281
- process.on('SIGINT', () => {
2282
- log('Received SIGINT');
2283
- cleanup();
2284
- app.quit();
2285
- });
2286
-
2287
- process.on('SIGTERM', () => {
2288
- log('Received SIGTERM');
2289
- cleanup();
2290
- app.quit();
2291
- });
2292
-
2293
- // Handle uncaught exceptions - ensure cleanup runs even on crashes
2294
- process.on('uncaughtException', (err) => {
2295
- log(`Uncaught exception: ${err.message}`);
2296
- log(err.stack);
2297
- cleanup();
2298
- app.quit();
2299
- });
2300
-
2301
- // Handle unhandled promise rejections
2302
- process.on('unhandledRejection', (reason, promise) => {
2303
- log(`Unhandled rejection at: ${promise}, reason: ${reason}`);
2304
- cleanup();
2305
- app.quit();
2306
- });