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,1026 +0,0 @@
1
- const { ipcMain, dialog, BrowserWindow, app, shell } = require('electron');
2
- const Database = require('better-sqlite3');
3
- const path = require('path');
4
- const fs = require('fs');
5
- const { execSync } = require('child_process');
6
- const { runMigrations } = require('../lib/run-migrations');
7
-
8
- // ==================== Recent Projects Storage ====================
9
- const MAX_RECENT_PROJECTS = 10;
10
-
11
- function getRecentProjectsPath() {
12
- return path.join(app.getPath('userData'), 'recent-projects.json');
13
- }
14
-
15
- function readRecentProjects() {
16
- const recentPath = getRecentProjectsPath();
17
- if (fs.existsSync(recentPath)) {
18
- try {
19
- return JSON.parse(fs.readFileSync(recentPath, 'utf-8'));
20
- } catch {
21
- return [];
22
- }
23
- }
24
- return [];
25
- }
26
-
27
- function writeRecentProjects(projects) {
28
- const recentPath = getRecentProjectsPath();
29
- // Ensure the directory exists
30
- const dir = path.dirname(recentPath);
31
- if (!fs.existsSync(dir)) {
32
- fs.mkdirSync(dir, { recursive: true });
33
- }
34
- fs.writeFileSync(recentPath, JSON.stringify(projects, null, 2));
35
- }
36
-
37
- function addRecentProject(projectPath) {
38
- const projects = readRecentProjects();
39
-
40
- // Get project name from config or folder name
41
- let name = path.basename(projectPath);
42
- const configPath = path.join(projectPath, '.jettypod', 'config.json');
43
- if (fs.existsSync(configPath)) {
44
- try {
45
- const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
46
- if (config.name) {
47
- name = config.name;
48
- }
49
- } catch {
50
- // Use folder name as fallback
51
- }
52
- }
53
-
54
- // Remove existing entry with same path
55
- const filtered = projects.filter(p => p.path !== projectPath);
56
-
57
- // Add new entry at the beginning
58
- const newEntry = {
59
- name,
60
- path: projectPath,
61
- lastOpened: new Date().toISOString()
62
- };
63
- filtered.unshift(newEntry);
64
-
65
- // Keep only the most recent projects
66
- const limited = filtered.slice(0, MAX_RECENT_PROJECTS);
67
-
68
- writeRecentProjects(limited);
69
- return newEntry;
70
- }
71
-
72
- // Singleton database connection
73
- let cachedDb = null;
74
- let projectRoot = null;
75
-
76
- function getProjectRoot() {
77
- if (projectRoot) return projectRoot;
78
-
79
- // Use JETTYPOD_PROJECT_PATH if set
80
- if (process.env.JETTYPOD_PROJECT_PATH) {
81
- projectRoot = process.env.JETTYPOD_PROJECT_PATH;
82
- return projectRoot;
83
- }
84
- try {
85
- projectRoot = execSync('git rev-parse --show-toplevel', { encoding: 'utf-8' }).trim();
86
- return projectRoot;
87
- } catch {
88
- throw new Error('Not in a git repository and JETTYPOD_PROJECT_PATH not set');
89
- }
90
- }
91
-
92
- function getDbPath() {
93
- return path.join(getProjectRoot(), '.jettypod', 'work.db');
94
- }
95
-
96
- function getDb() {
97
- if (!cachedDb) {
98
- const dbPath = getDbPath();
99
- try {
100
- cachedDb = new Database(dbPath);
101
- cachedDb.pragma('journal_mode = WAL');
102
- cachedDb.pragma('foreign_keys = ON');
103
- // Run pending migrations to handle old project databases
104
- try {
105
- runMigrations(cachedDb);
106
- } catch (err) {
107
- console.error('Failed to run migrations:', err);
108
- }
109
- } catch (err) {
110
- console.error(`[DB] Failed to open database at ${dbPath}:`, err.message);
111
- cachedDb = null;
112
- throw err;
113
- }
114
- }
115
- return cachedDb;
116
- }
117
-
118
- function getConfigPath() {
119
- return path.join(getProjectRoot(), '.jettypod', 'config.json');
120
- }
121
-
122
- function readConfig() {
123
- const configPath = getConfigPath();
124
- if (fs.existsSync(configPath)) {
125
- return JSON.parse(fs.readFileSync(configPath, 'utf-8'));
126
- }
127
- return {};
128
- }
129
-
130
- function writeConfig(config) {
131
- const configPath = getConfigPath();
132
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
133
- }
134
-
135
- // Initialize JettyPod in a directory (replaces CLI 'jettypod init')
136
- // This allows the packaged Electron app to work without the CLI being installed
137
- function initializeJettypod(projectPath) {
138
- const jettypodDir = path.join(projectPath, '.jettypod');
139
-
140
- // Create .jettypod directory if it doesn't exist
141
- if (!fs.existsSync(jettypodDir)) {
142
- fs.mkdirSync(jettypodDir, { recursive: true });
143
- }
144
-
145
- // Create config.json with minimal initial values if it doesn't exist
146
- const configPath = path.join(jettypodDir, 'config.json');
147
- if (!fs.existsSync(configPath)) {
148
- const projectName = path.basename(projectPath);
149
- const initialConfig = {
150
- name: projectName,
151
- project_state: 'internal',
152
- created_at: new Date().toISOString()
153
- };
154
- fs.writeFileSync(configPath, JSON.stringify(initialConfig, null, 2));
155
- }
156
-
157
- // The database (work.db) will be created automatically when getDb() is called
158
- // runMigrations() in getDb() will create all necessary tables
159
-
160
- }
161
-
162
- // Register all IPC handlers
163
- function registerIpcHandlers() {
164
- // ==================== Kanban ====================
165
- ipcMain.handle('db:getKanbanData', (event, doneLimit = 50) => {
166
- const db = getDb();
167
-
168
- // Get all epics for lookup
169
- const epics = db.prepare(`
170
- SELECT id, title FROM work_items WHERE type = 'epic'
171
- `).all();
172
- const epicMap = new Map(epics.map(e => [e.id, e.title]));
173
-
174
- // Single query: get all non-epic work items with parent type and workflow progress
175
- const allWorkItems = db.prepare(`
176
- SELECT w.id, w.type, w.title, w.description, w.status, w.parent_id, w.epic_id,
177
- w.branch_name, w.mode, w.phase, w.completed_at, w.created_at, w.display_order,
178
- p.type as parent_type,
179
- wc.current_step, wc.total_steps
180
- FROM work_items w
181
- LEFT JOIN work_items p ON w.parent_id = p.id
182
- LEFT JOIN workflow_checkpoints wc ON wc.work_item_id = w.id
183
- WHERE w.type IN ('feature', 'chore', 'bug')
184
- ORDER BY COALESCE(w.display_order, w.id)
185
- `).all();
186
-
187
- // Partition: children of features vs kanban-eligible items
188
- const choresByFeature = new Map();
189
- const bugsByFeature = new Map();
190
- const allItems = [];
191
-
192
- for (const item of allWorkItems) {
193
- if (item.parent_type === 'feature') {
194
- // Child of a feature — group by parent
195
- const map = item.type === 'bug' ? bugsByFeature : choresByFeature;
196
- const existing = map.get(item.parent_id) || [];
197
- existing.push(item);
198
- map.set(item.parent_id, existing);
199
- } else {
200
- // Top-level or under epic — kanban item
201
- allItems.push(item);
202
- }
203
- }
204
-
205
- const inFlight = [];
206
- const backlogGroups = new Map();
207
- const doneGroups = new Map();
208
-
209
- function getGroup(groups, item) {
210
- const epicId = item.parent_id || item.epic_id;
211
- const key = epicId ? `epic-${epicId}` : 'ungrouped';
212
-
213
- if (!groups.has(key)) {
214
- groups.set(key, {
215
- epicId: epicId,
216
- epicTitle: epicId ? epicMap.get(epicId) || null : null,
217
- items: []
218
- });
219
- }
220
- return groups.get(key);
221
- }
222
-
223
- for (const item of allItems) {
224
- const { parent_type, ...cleanItem } = item;
225
-
226
- if (cleanItem.type === 'feature') {
227
- cleanItem.chores = choresByFeature.get(cleanItem.id) || [];
228
- cleanItem.bugs = bugsByFeature.get(cleanItem.id) || [];
229
- }
230
-
231
- if (cleanItem.status === 'in_progress') {
232
- const epicId = cleanItem.parent_id || cleanItem.epic_id;
233
- const epicTitle = epicId ? epicMap.get(epicId) || null : null;
234
- inFlight.push({ ...cleanItem, epicTitle });
235
- } else if (cleanItem.status === 'backlog' || cleanItem.status === 'todo' || cleanItem.status === null) {
236
- const group = getGroup(backlogGroups, cleanItem);
237
- group.items.push(cleanItem);
238
- } else if (cleanItem.status === 'done') {
239
- const epicId = cleanItem.parent_id || cleanItem.epic_id;
240
- if (epicId) {
241
- const group = getGroup(doneGroups, cleanItem);
242
- group.items.push(cleanItem);
243
- } else {
244
- const key = `item-${cleanItem.id}`;
245
- doneGroups.set(key, {
246
- epicId: null,
247
- epicTitle: null,
248
- items: [cleanItem]
249
- });
250
- }
251
- }
252
- }
253
-
254
- if (!backlogGroups.has('ungrouped')) {
255
- backlogGroups.set('ungrouped', { epicId: null, epicTitle: null, items: [] });
256
- }
257
-
258
- // Sort done items
259
- for (const [, group] of doneGroups) {
260
- group.items.sort((a, b) => {
261
- const dateA = a.completed_at ? new Date(a.completed_at).getTime() : 0;
262
- const dateB = b.completed_at ? new Date(b.completed_at).getTime() : 0;
263
- return dateB - dateA;
264
- });
265
- }
266
-
267
- // Sort and limit done groups
268
- const sortedDoneGroups = new Map(
269
- Array.from(doneGroups.entries()).sort(([, groupA], [, groupB]) => {
270
- const mostRecentA = groupA.items[0]?.completed_at ? new Date(groupA.items[0].completed_at).getTime() : 0;
271
- const mostRecentB = groupB.items[0]?.completed_at ? new Date(groupB.items[0].completed_at).getTime() : 0;
272
- return mostRecentB - mostRecentA;
273
- })
274
- );
275
-
276
- // Limit done items per group to ensure every group gets representation
277
- const perGroupLimit = doneLimit;
278
- const limitedDoneGroups = new Map();
279
- for (const [key, group] of sortedDoneGroups) {
280
- if (group.items.length > perGroupLimit) {
281
- limitedDoneGroups.set(key, { ...group, items: group.items.slice(0, perGroupLimit) });
282
- } else {
283
- limitedDoneGroups.set(key, group);
284
- }
285
- }
286
-
287
- // Convert Maps to arrays for serialization
288
- return {
289
- inFlight,
290
- backlog: Array.from(backlogGroups.entries()),
291
- done: Array.from(limitedDoneGroups.entries())
292
- };
293
- });
294
-
295
- // ==================== Work Items ====================
296
- ipcMain.handle('db:getAllWorkItems', () => {
297
- const db = getDb();
298
- return db.prepare(`
299
- SELECT id, type, title, description, status, parent_id, epic_id,
300
- branch_name, mode, phase, completed_at, created_at
301
- FROM work_items ORDER BY id
302
- `).all();
303
- });
304
-
305
- ipcMain.handle('db:getWorkItem', (event, id) => {
306
- const db = getDb();
307
- return db.prepare(`
308
- SELECT id, type, title, description, status, parent_id, epic_id,
309
- branch_name, mode, phase, completed_at, created_at
310
- FROM work_items WHERE id = ?
311
- `).get(id) || null;
312
- });
313
-
314
- ipcMain.handle('db:getChildWorkItems', (event, parentId) => {
315
- const db = getDb();
316
- return db.prepare(`
317
- SELECT id, type, title, description, status, parent_id, epic_id,
318
- branch_name, mode, phase, completed_at, created_at
319
- FROM work_items WHERE parent_id = ? ORDER BY id
320
- `).all(parentId);
321
- });
322
-
323
- ipcMain.handle('db:updateWorkItemTitle', (event, id, title) => {
324
- const db = getDb();
325
- const result = db.prepare(`UPDATE work_items SET title = ? WHERE id = ?`).run(title, id);
326
- return result.changes > 0;
327
- });
328
-
329
- ipcMain.handle('db:updateWorkItemStatus', (event, id, status) => {
330
- const db = getDb();
331
- const completedAt = status === 'done' ? new Date().toISOString() : null;
332
- const result = db.prepare(`
333
- UPDATE work_items SET status = ?, completed_at = ? WHERE id = ?
334
- `).run(status, completedAt, id);
335
-
336
- if (status === 'cancelled' && result.changes > 0) {
337
- db.prepare(`
338
- UPDATE claude_sessions SET status = 'orphaned', completed_at = datetime('now')
339
- WHERE work_item_id = ? AND status = 'active'
340
- `).run(id);
341
- }
342
-
343
- return result.changes > 0;
344
- });
345
-
346
- ipcMain.handle('db:updateWorkItemOrder', (event, id, displayOrder) => {
347
- const db = getDb();
348
- const result = db.prepare(`UPDATE work_items SET display_order = ? WHERE id = ?`).run(displayOrder, id);
349
- return result.changes > 0;
350
- });
351
-
352
- ipcMain.handle('db:updateWorkItemEpic', (event, id, epicId) => {
353
- const db = getDb();
354
- const result = db.prepare(`
355
- UPDATE work_items SET parent_id = ? WHERE id = ? AND type IN ('feature', 'chore', 'bug')
356
- `).run(epicId, id);
357
- return result.changes > 0;
358
- });
359
-
360
- // ==================== Decisions ====================
361
- ipcMain.handle('db:getDecision', (event, id) => {
362
- const db = getDb();
363
- if (id > 0) {
364
- return db.prepare(`
365
- SELECT d.id, d.work_item_id, d.aspect, d.decision, d.rationale, d.created_at,
366
- w.title as work_item_title
367
- FROM discovery_decisions d
368
- LEFT JOIN work_items w ON d.work_item_id = w.id
369
- WHERE d.id = ?
370
- `).get(id) || null;
371
- } else {
372
- const workItemId = Math.abs(id);
373
- return db.prepare(`
374
- SELECT id * -1 as id, id as work_item_id, 'UX Approach' as aspect,
375
- discovery_winner as decision, discovery_rationale as rationale,
376
- COALESCE(discovery_completed_at, created_at) as created_at,
377
- title as work_item_title
378
- FROM work_items WHERE id = ? AND discovery_winner IS NOT NULL
379
- `).get(workItemId) || null;
380
- }
381
- });
382
-
383
- ipcMain.handle('db:getDecisionsForWorkItem', (event, workItemId) => {
384
- const db = getDb();
385
- return db.prepare(`
386
- SELECT id, work_item_id, aspect, decision, rationale, created_at
387
- FROM discovery_decisions WHERE work_item_id = ? ORDER BY created_at
388
- `).all(workItemId);
389
- });
390
-
391
- // ==================== Sessions ====================
392
- ipcMain.handle('db:listSessions', () => {
393
- const db = getDb();
394
- return db.prepare(`
395
- SELECT cs.id, COALESCE(w.title, cs.session_title, 'Untitled Session') as title,
396
- cs.work_item_id as featureId, w.title as featureTitle, cs.status,
397
- COALESCE(cs.completed_at, cs.started_at) as updatedAt
398
- FROM claude_sessions cs
399
- LEFT JOIN work_items w ON cs.work_item_id = w.id
400
- WHERE cs.status = 'active'
401
- ORDER BY COALESCE(cs.completed_at, cs.started_at) DESC
402
- `).all();
403
- });
404
-
405
- ipcMain.handle('db:createSession', (event, title) => {
406
- const db = getDb();
407
- const result = db.prepare(`
408
- INSERT INTO claude_sessions (work_item_id, title, session_title, status, started_at)
409
- VALUES (NULL, ?, ?, 'active', datetime('now'))
410
- `).run(title, title);
411
- return result.lastInsertRowid;
412
- });
413
-
414
- ipcMain.handle('db:getSession', (event, sessionId) => {
415
- const db = getDb();
416
- return db.prepare(`
417
- SELECT id, work_item_id, title, session_title, status, started_at, completed_at
418
- FROM claude_sessions WHERE id = ?
419
- `).get(sessionId) || null;
420
- });
421
-
422
- ipcMain.handle('db:closeSession', (event, sessionId) => {
423
- const db = getDb();
424
- const result = db.prepare(`
425
- UPDATE claude_sessions SET status = 'completed', completed_at = datetime('now') WHERE id = ?
426
- `).run(sessionId);
427
- return result.changes > 0;
428
- });
429
-
430
- ipcMain.handle('db:closeSessionByWorkItem', (event, workItemId) => {
431
- const db = getDb();
432
- const result = db.prepare(`
433
- UPDATE claude_sessions SET status = 'completed', completed_at = datetime('now')
434
- WHERE work_item_id = ? AND status = 'active'
435
- `).run(workItemId);
436
- return result.changes > 0;
437
- });
438
-
439
- ipcMain.handle('db:linkSession', (event, sessionId, workItemId) => {
440
- const db = getDb();
441
- const workItem = db.prepare(`SELECT title FROM work_items WHERE id = ?`).get(workItemId);
442
- if (!workItem) return { success: false, error: 'Work item not found' };
443
-
444
- const result = db.prepare(`
445
- UPDATE claude_sessions SET work_item_id = ?, title = ?
446
- WHERE id = ? AND work_item_id IS NULL
447
- `).run(workItemId, workItem.title, sessionId);
448
-
449
- if (result.changes === 0) {
450
- return { success: false, error: 'Session already linked or not found' };
451
- }
452
- return { success: true };
453
- });
454
-
455
- ipcMain.handle('db:isLinkableWorkItem', (event, workItemId) => {
456
- const db = getDb();
457
- const workItem = db.prepare(`
458
- SELECT w.id, w.type, w.parent_id, p.type as parent_type
459
- FROM work_items w LEFT JOIN work_items p ON w.parent_id = p.id WHERE w.id = ?
460
- `).get(workItemId);
461
-
462
- if (!workItem) return { linkable: false, reason: 'Work item not found' };
463
- if (workItem.type === 'epic') return { linkable: false, reason: 'Epics cannot have sessions' };
464
- if (workItem.type === 'chore' && workItem.parent_type === 'feature') {
465
- return { linkable: false, reason: "Chores under features use the feature's session", redirectToId: workItem.parent_id };
466
- }
467
- return { linkable: true };
468
- });
469
-
470
- ipcMain.handle('db:getActiveSessionByWorkItem', (event, workItemId) => {
471
- const db = getDb();
472
- return db.prepare(`
473
- SELECT id, work_item_id, title, session_title, status, started_at, completed_at
474
- FROM claude_sessions WHERE work_item_id = ? AND status = 'active'
475
- `).get(workItemId) || null;
476
- });
477
-
478
- ipcMain.handle('db:getOrCreateSessionForWorkItem', (event, workItemId) => {
479
- const db = getDb();
480
-
481
- // Check if linkable
482
- const workItem = db.prepare(`
483
- SELECT w.id, w.type, w.parent_id, w.title, p.type as parent_type
484
- FROM work_items w LEFT JOIN work_items p ON w.parent_id = p.id WHERE w.id = ?
485
- `).get(workItemId);
486
-
487
- if (!workItem) return { session: null, created: false, error: 'Work item not found' };
488
- if (workItem.type === 'epic') return { session: null, created: false, error: 'Epics cannot have sessions' };
489
- if (workItem.type === 'chore' && workItem.parent_type === 'feature') {
490
- return { session: null, created: false, error: "Chores under features use the feature's session", redirectToId: workItem.parent_id };
491
- }
492
-
493
- // Check for existing
494
- const existing = db.prepare(`
495
- SELECT id, work_item_id, title, session_title, status, started_at, completed_at
496
- FROM claude_sessions WHERE work_item_id = ? AND status = 'active'
497
- `).get(workItemId);
498
- if (existing) return { session: existing, created: false };
499
-
500
- // Create new
501
- const result = db.prepare(`
502
- INSERT INTO claude_sessions (work_item_id, title, status, started_at)
503
- VALUES (?, ?, 'active', datetime('now'))
504
- `).run(workItemId, workItem.title);
505
-
506
- const newSession = db.prepare(`
507
- SELECT id, work_item_id, title, session_title, status, started_at, completed_at
508
- FROM claude_sessions WHERE id = ?
509
- `).get(result.lastInsertRowid);
510
-
511
- return { session: newSession, created: true };
512
- });
513
-
514
- ipcMain.handle('db:countActiveSessions', () => {
515
- const db = getDb();
516
- const result = db.prepare(`SELECT COUNT(*) as count FROM claude_sessions WHERE status = 'active'`).get();
517
- return result.count;
518
- });
519
-
520
- ipcMain.handle('db:cleanupStaleSessions', (event, retentionDays = 30) => {
521
- const db = getDb();
522
- const result = db.prepare(`
523
- DELETE FROM claude_sessions
524
- WHERE status = 'orphaned'
525
- OR (status = 'completed' AND completed_at < datetime('now', '-' || ? || ' days'))
526
- OR (status = 'error' AND completed_at < datetime('now', '-' || ? || ' days'))
527
- `).run(retentionDays, retentionDays);
528
- return result.changes;
529
- });
530
-
531
- // ==================== Session Content ====================
532
- ipcMain.handle('db:getSessionContent', (event, sessionId) => {
533
- const db = getDb();
534
- const session = db.prepare(`SELECT content FROM claude_sessions WHERE id = ?`).get(sessionId);
535
- if (!session?.content) return [];
536
- try {
537
- return JSON.parse(session.content);
538
- } catch {
539
- return [];
540
- }
541
- });
542
-
543
- ipcMain.handle('db:getSessionContentByWorkItem', (event, workItemId) => {
544
- const db = getDb();
545
- const session = db.prepare(`
546
- SELECT content FROM claude_sessions WHERE work_item_id = ? AND status = 'active'
547
- `).get(workItemId);
548
- if (!session?.content) return [];
549
- try {
550
- return JSON.parse(session.content);
551
- } catch {
552
- return [];
553
- }
554
- });
555
-
556
- ipcMain.handle('db:appendSessionContent', (event, sessionId, turn) => {
557
- const db = getDb();
558
- const result = db.prepare(`
559
- UPDATE claude_sessions
560
- SET content = json_insert(COALESCE(content, '[]'), '$[#]', json(?))
561
- WHERE id = ?
562
- `).run(JSON.stringify(turn), sessionId);
563
- return result.changes > 0;
564
- });
565
-
566
- ipcMain.handle('db:appendSessionContentByWorkItem', (event, workItemId, turn) => {
567
- const db = getDb();
568
- const result = db.prepare(`
569
- UPDATE claude_sessions
570
- SET content = json_insert(COALESCE(content, '[]'), '$[#]', json(?))
571
- WHERE work_item_id = ? AND status = 'active'
572
- `).run(JSON.stringify(turn), workItemId);
573
- return result.changes > 0;
574
- });
575
-
576
- // ==================== Environment Variables ====================
577
- ipcMain.handle('db:getEnvVars', () => {
578
- const config = readConfig();
579
- const envVars = config.envVars || {};
580
- return Object.entries(envVars).map(([name, value]) => ({ name, value }));
581
- });
582
-
583
- ipcMain.handle('db:setEnvVar', (event, name, value) => {
584
- const config = readConfig();
585
- const envVars = config.envVars || {};
586
- envVars[name] = value;
587
- config.envVars = envVars;
588
- writeConfig(config);
589
- return true;
590
- });
591
-
592
- ipcMain.handle('db:deleteEnvVar', (event, name) => {
593
- const config = readConfig();
594
- const envVars = config.envVars || {};
595
- delete envVars[name];
596
- config.envVars = envVars;
597
- writeConfig(config);
598
- return true;
599
- });
600
-
601
- // ==================== Project Info ====================
602
- ipcMain.handle('db:getProjectName', () => {
603
- const configPath = getConfigPath();
604
- if (fs.existsSync(configPath)) {
605
- const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
606
- return config.name || 'Untitled Project';
607
- }
608
- return 'Untitled Project';
609
- });
610
-
611
- // ==================== Claude Subprocess ====================
612
- // Import Claude functions from main process
613
- const { spawnClaude, writeToClaudeStdin, killClaude } = require('./main');
614
-
615
- ipcMain.handle('claude:spawn', (event, sessionId, cwd) => {
616
- return spawnClaude(sessionId, cwd);
617
- });
618
-
619
- ipcMain.handle('claude:write', (event, sessionId, text) => {
620
- return writeToClaudeStdin(sessionId, text);
621
- });
622
-
623
- ipcMain.handle('claude:kill', (event, sessionId) => {
624
- return killClaude(sessionId);
625
- });
626
-
627
- // ==================== Dev Server ====================
628
- const { spawnDevServer, killDevServer, getDevServerStatus, listDevServers } = require('./main');
629
-
630
- ipcMain.handle('devServer:spawn', (event, projectPath, command, port) => {
631
- return spawnDevServer(projectPath, command, port);
632
- });
633
-
634
- ipcMain.handle('devServer:kill', (event, projectPath) => {
635
- return killDevServer(projectPath);
636
- });
637
-
638
- ipcMain.handle('devServer:status', (event, projectPath) => {
639
- return getDevServerStatus(projectPath);
640
- });
641
-
642
- ipcMain.handle('devServer:list', () => {
643
- return listDevServers();
644
- });
645
-
646
- // ==================== New Project Dialog ====================
647
- ipcMain.handle('dialog:newProject', async () => {
648
- const mainWindow = BrowserWindow.getFocusedWindow();
649
-
650
- const result = await dialog.showSaveDialog(mainWindow, {
651
- title: 'Create New Project',
652
- buttonLabel: 'Create',
653
- nameFieldLabel: 'Project Name:',
654
- showsTagField: false,
655
- });
656
-
657
- if (result.canceled || !result.filePath) {
658
- return { success: false, canceled: true };
659
- }
660
-
661
- const projectPath = result.filePath;
662
-
663
- // Create the directory
664
- try {
665
- fs.mkdirSync(projectPath, { recursive: true });
666
- } catch (mkdirError) {
667
- return {
668
- success: false,
669
- error: `Failed to create directory: ${mkdirError.message}`
670
- };
671
- }
672
-
673
- // Initialize JettyPod in the new directory
674
- try {
675
- initializeJettypod(projectPath);
676
- } catch (initError) {
677
- return {
678
- success: false,
679
- error: `Failed to initialize JettyPod: ${initError.message}`
680
- };
681
- }
682
-
683
- // Reset DB and project root BEFORE async call to prevent stale state
684
- // during await (other IPC handlers could fire while we're waiting)
685
- closeDb();
686
- cachedDb = null;
687
- projectRoot = projectPath;
688
-
689
- const { setProjectRoot } = require('./main');
690
- await setProjectRoot(projectPath);
691
-
692
- addRecentProject(projectPath);
693
-
694
- return { success: true, path: projectPath };
695
- });
696
-
697
- // ==================== Open Project Dialog ====================
698
- ipcMain.handle('dialog:openProject', async () => {
699
- const mainWindow = BrowserWindow.getFocusedWindow();
700
-
701
- const result = await dialog.showOpenDialog(mainWindow, {
702
- title: 'Select JettyPod Project',
703
- properties: ['openDirectory'],
704
- buttonLabel: 'Open Project'
705
- });
706
-
707
- if (result.canceled || result.filePaths.length === 0) {
708
- return { success: false, canceled: true };
709
- }
710
-
711
- const selectedPath = result.filePaths[0];
712
- const dbPath = path.join(selectedPath, '.jettypod', 'work.db');
713
-
714
- // Auto-initialize JettyPod if not already set up
715
- if (!fs.existsSync(dbPath)) {
716
- try {
717
- initializeJettypod(selectedPath);
718
- } catch (initError) {
719
- return {
720
- success: false,
721
- error: `Failed to initialize JettyPod: ${initError.message}`
722
- };
723
- }
724
- }
725
-
726
- // Reset DB and project root BEFORE async call to prevent stale state
727
- // during await (other IPC handlers could fire while we're waiting)
728
- closeDb();
729
- cachedDb = null;
730
- projectRoot = selectedPath;
731
-
732
- const { setProjectRoot } = require('./main');
733
- await setProjectRoot(selectedPath);
734
-
735
- addRecentProject(selectedPath);
736
-
737
- return { success: true, path: selectedPath };
738
- });
739
-
740
- // ==================== Recent Projects ====================
741
- ipcMain.handle('projects:getRecent', () => {
742
- return readRecentProjects();
743
- });
744
-
745
- ipcMain.handle('projects:addRecent', (event, projectPath) => {
746
- return addRecentProject(projectPath);
747
- });
748
-
749
- ipcMain.handle('projects:openRecent', async (event, projectPath) => {
750
- const dbPath = path.join(projectPath, '.jettypod', 'work.db');
751
-
752
- // Validate the project directory exists
753
- if (!fs.existsSync(projectPath)) {
754
- return {
755
- success: false,
756
- error: 'Project directory no longer exists.'
757
- };
758
- }
759
-
760
- // Auto-initialize JettyPod if not already set up
761
- if (!fs.existsSync(dbPath)) {
762
- try {
763
- initializeJettypod(projectPath);
764
- } catch (initError) {
765
- return {
766
- success: false,
767
- error: `Failed to initialize JettyPod: ${initError.message}`
768
- };
769
- }
770
- }
771
-
772
- // Reset DB and project root BEFORE async call to prevent stale state
773
- // during await (other IPC handlers could fire while we're waiting)
774
- closeDb();
775
- cachedDb = null;
776
- projectRoot = projectPath;
777
-
778
- const { setProjectRoot } = require('./main');
779
- await setProjectRoot(projectPath);
780
-
781
- addRecentProject(projectPath);
782
-
783
- return { success: true, path: projectPath };
784
- });
785
-
786
- // ==================== Claude Code Installation ====================
787
- ipcMain.handle('claudeCode:install', async () => {
788
- const { installClaudeCode } = require('./main');
789
- return await installClaudeCode();
790
- });
791
-
792
- ipcMain.handle('claudeCode:isInstalled', () => {
793
- const { getClaudeCodeInstalled } = require('./main');
794
- return getClaudeCodeInstalled();
795
- });
796
-
797
- ipcMain.handle('claudeCode:update', async () => {
798
- const { updateClaudeCode } = require('./main');
799
- return await updateClaudeCode();
800
- });
801
-
802
- ipcMain.handle('claudeCode:isAuthenticated', async () => {
803
- const { checkClaudeCodeAuthenticated } = require('./main');
804
- return await checkClaudeCodeAuthenticated();
805
- });
806
-
807
- ipcMain.handle('claudeCode:login', async () => {
808
- const { loginClaudeCode } = require('./main');
809
- return await loginClaudeCode();
810
- });
811
-
812
- // ==================== Subscription Gating ====================
813
-
814
- const UPDATE_SERVER_URL = 'https://jettypod-update-server.spangbaryn2.workers.dev';
815
-
816
- ipcMain.handle('subscription:createCheckout', async (event, plan) => {
817
- try {
818
- // Include JWT auth header so checkout session gets user_id in metadata
819
- const headers = { 'Content-Type': 'application/json' };
820
- const authPath = path.join(app.getPath('userData'), 'auth.json');
821
- if (fs.existsSync(authPath)) {
822
- try {
823
- const authData = JSON.parse(fs.readFileSync(authPath, 'utf-8'));
824
- if (authData.token) {
825
- headers['Authorization'] = `Bearer ${authData.token}`;
826
- }
827
- } catch {
828
- // Continue without auth if read fails
829
- }
830
- }
831
-
832
- const response = await fetch(`${UPDATE_SERVER_URL}/checkout/create-session`, {
833
- method: 'POST',
834
- headers,
835
- body: JSON.stringify({ plan: plan || 'monthly' }),
836
- });
837
-
838
- if (!response.ok) {
839
- return { success: false, error: 'Failed to create checkout session' };
840
- }
841
-
842
- const data = await response.json();
843
- if (data.url) {
844
- const { shell } = require('electron');
845
- shell.openExternal(data.url);
846
- return { success: true };
847
- }
848
- return { success: false, error: 'No checkout URL returned' };
849
- } catch (error) {
850
- return { success: false, error: `Checkout failed: ${error.message}` };
851
- }
852
- });
853
-
854
- ipcMain.handle('billing:openCustomerPortal', async () => {
855
- try {
856
- const headers = { 'Content-Type': 'application/json' };
857
- const authPath = path.join(app.getPath('userData'), 'auth.json');
858
- if (fs.existsSync(authPath)) {
859
- try {
860
- const authData = JSON.parse(fs.readFileSync(authPath, 'utf-8'));
861
- if (authData.token) {
862
- headers['Authorization'] = `Bearer ${authData.token}`;
863
- }
864
- } catch {
865
- // Continue without auth if read fails
866
- }
867
- }
868
-
869
- const response = await fetch(`${UPDATE_SERVER_URL}/billing/customer-portal`, {
870
- method: 'POST',
871
- headers,
872
- });
873
-
874
- if (!response.ok) {
875
- return { success: false, error: 'Failed to open billing portal' };
876
- }
877
-
878
- const data = await response.json();
879
- if (data.url) {
880
- shell.openExternal(data.url);
881
- return { success: true };
882
- }
883
- return { success: false, error: 'No portal URL returned' };
884
- } catch (error) {
885
- return { success: false, error: `Billing portal failed: ${error.message}` };
886
- }
887
- });
888
-
889
- ipcMain.handle('subscription:activate', async (event, customerId) => {
890
- if (!customerId || !customerId.trim().startsWith('cus_')) {
891
- return { success: false, error: 'Invalid customer ID. It should start with cus_' };
892
- }
893
-
894
- try {
895
- // Validate against the update server
896
- const response = await fetch(`${UPDATE_SERVER_URL}/subscription/validate`, {
897
- method: 'POST',
898
- headers: { 'Content-Type': 'application/json' },
899
- body: JSON.stringify({ customerId: customerId.trim() }),
900
- });
901
-
902
- const result = await response.json();
903
- if (!result.valid) {
904
- return { success: false, error: result.error || 'No active subscription found' };
905
- }
906
-
907
- // Store the subscription
908
- const subPath = path.join(app.getPath('userData'), 'subscription.json');
909
- const subData = {
910
- customerId: customerId.trim(),
911
- activatedAt: new Date().toISOString(),
912
- };
913
-
914
- const dir = path.dirname(subPath);
915
- if (!fs.existsSync(dir)) {
916
- fs.mkdirSync(dir, { recursive: true });
917
- }
918
- fs.writeFileSync(subPath, JSON.stringify(subData, null, 2));
919
- return { success: true };
920
- } catch (error) {
921
- return { success: false, error: `Activation failed: ${error.message}` };
922
- }
923
- });
924
-
925
- ipcMain.handle('subscription:getStatus', () => {
926
- const subPath = path.join(app.getPath('userData'), 'subscription.json');
927
- if (!fs.existsSync(subPath)) {
928
- return { active: false };
929
- }
930
-
931
- try {
932
- const data = JSON.parse(fs.readFileSync(subPath, 'utf-8'));
933
- return { active: !!data.customerId, customerId: data.customerId, activatedAt: data.activatedAt };
934
- } catch {
935
- return { active: false };
936
- }
937
- });
938
-
939
- // ==================== Auth (JWT-based) ====================
940
-
941
- ipcMain.handle('auth:loginWithGoogle', () => {
942
- const { shell } = require('electron');
943
- shell.openExternal(`${UPDATE_SERVER_URL}/auth/google`);
944
- return { success: true };
945
- });
946
-
947
- ipcMain.handle('auth:saveToken', (event, token, user) => {
948
- const authPath = path.join(app.getPath('userData'), 'auth.json');
949
- const dir = path.dirname(authPath);
950
- if (!fs.existsSync(dir)) {
951
- fs.mkdirSync(dir, { recursive: true });
952
- }
953
- fs.writeFileSync(authPath, JSON.stringify({ token, user, savedAt: new Date().toISOString() }, null, 2));
954
-
955
- // Mark that the user has logged in at least once (persists across logout)
956
- const markerPath = path.join(app.getPath('userData'), 'has-logged-in');
957
- if (!fs.existsSync(markerPath)) {
958
- fs.writeFileSync(markerPath, new Date().toISOString());
959
- }
960
-
961
- return { success: true };
962
- });
963
-
964
- ipcMain.handle('auth:getStatus', () => {
965
- const authPath = path.join(app.getPath('userData'), 'auth.json');
966
- if (!fs.existsSync(authPath)) {
967
- return { authenticated: false };
968
- }
969
-
970
- try {
971
- const data = JSON.parse(fs.readFileSync(authPath, 'utf-8'));
972
- return { authenticated: !!data.token, token: data.token, user: data.user };
973
- } catch {
974
- return { authenticated: false };
975
- }
976
- });
977
-
978
- ipcMain.handle('auth:getToken', () => {
979
- const authPath = path.join(app.getPath('userData'), 'auth.json');
980
- if (!fs.existsSync(authPath)) return null;
981
-
982
- try {
983
- const data = JSON.parse(fs.readFileSync(authPath, 'utf-8'));
984
- return data.token || null;
985
- } catch {
986
- return null;
987
- }
988
- });
989
-
990
- ipcMain.handle('auth:logout', () => {
991
- const authPath = path.join(app.getPath('userData'), 'auth.json');
992
- if (fs.existsSync(authPath)) {
993
- fs.unlinkSync(authPath);
994
- }
995
- // Note: does NOT delete has-logged-in marker — returning users see /login, not /signup
996
- return { success: true };
997
- });
998
-
999
- ipcMain.handle('auth:hasLoggedInBefore', () => {
1000
- try {
1001
- const markerPath = path.join(app.getPath('userData'), 'has-logged-in');
1002
- return fs.existsSync(markerPath);
1003
- } catch {
1004
- return false;
1005
- }
1006
- });
1007
-
1008
- // ==================== Shell Operations ====================
1009
- ipcMain.handle('shell:openPath', async (event, filePath) => {
1010
- // Use openExternal with file:// URL to open in default browser
1011
- const fileUrl = `file://${filePath}`;
1012
- return shell.openExternal(fileUrl);
1013
- });
1014
-
1015
- console.log('[IPC] All handlers registered');
1016
- }
1017
-
1018
- // Close database connection (for graceful shutdown)
1019
- function closeDb() {
1020
- if (cachedDb) {
1021
- cachedDb.close();
1022
- cachedDb = null;
1023
- }
1024
- }
1025
-
1026
- module.exports = { registerIpcHandlers, closeDb };