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