jettypod 4.4.116 → 4.4.120

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 (162) hide show
  1. package/.env +7 -0
  2. package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +124 -48
  3. package/apps/dashboard/app/api/claude/[workItemId]/route.ts +171 -58
  4. package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +161 -10
  5. package/apps/dashboard/app/api/tests/run/stream/route.ts +13 -1
  6. package/apps/dashboard/app/api/usage/route.ts +17 -0
  7. package/apps/dashboard/app/api/work/[id]/route.ts +35 -0
  8. package/apps/dashboard/app/api/work/[id]/status/route.ts +43 -1
  9. package/apps/dashboard/app/connect-claude/page.tsx +24 -0
  10. package/apps/dashboard/app/decision/[id]/page.tsx +14 -14
  11. package/apps/dashboard/app/demo/gates/page.tsx +42 -42
  12. package/apps/dashboard/app/design-system/page.tsx +868 -0
  13. package/apps/dashboard/app/globals.css +6 -2
  14. package/apps/dashboard/app/install-claude/page.tsx +9 -7
  15. package/apps/dashboard/app/layout.tsx +17 -5
  16. package/apps/dashboard/app/login/page.tsx +250 -0
  17. package/apps/dashboard/app/page.tsx +11 -9
  18. package/apps/dashboard/app/settings/page.tsx +4 -2
  19. package/apps/dashboard/app/signup/page.tsx +245 -0
  20. package/apps/dashboard/app/subscribe/page.tsx +11 -0
  21. package/apps/dashboard/app/welcome/page.tsx +24 -1
  22. package/apps/dashboard/app/work/[id]/page.tsx +34 -50
  23. package/apps/dashboard/components/AppShell.tsx +95 -55
  24. package/apps/dashboard/components/CardMenu.tsx +56 -13
  25. package/apps/dashboard/components/ClaudePanel.tsx +301 -582
  26. package/apps/dashboard/components/ClaudePanelInput.tsx +23 -14
  27. package/apps/dashboard/components/ConnectClaudeScreen.tsx +210 -0
  28. package/apps/dashboard/components/CopyableId.tsx +3 -3
  29. package/apps/dashboard/components/DetailReviewActions.tsx +109 -0
  30. package/apps/dashboard/components/DragContext.tsx +75 -65
  31. package/apps/dashboard/components/DraggableCard.tsx +6 -46
  32. package/apps/dashboard/components/DropZone.tsx +2 -2
  33. package/apps/dashboard/components/EditableDetailDescription.tsx +1 -1
  34. package/apps/dashboard/components/EditableTitle.tsx +26 -6
  35. package/apps/dashboard/components/ElapsedTimer.tsx +54 -0
  36. package/apps/dashboard/components/EpicGroup.tsx +329 -0
  37. package/apps/dashboard/components/GateCard.tsx +100 -16
  38. package/apps/dashboard/components/GateChoiceCard.tsx +15 -17
  39. package/apps/dashboard/components/InstallClaudeScreen.tsx +140 -51
  40. package/apps/dashboard/components/JettyLoader.tsx +38 -0
  41. package/apps/dashboard/components/KanbanBoard.tsx +147 -766
  42. package/apps/dashboard/components/KanbanCard.tsx +506 -0
  43. package/apps/dashboard/components/LazyMarkdown.tsx +12 -0
  44. package/apps/dashboard/components/MainNav.tsx +20 -54
  45. package/apps/dashboard/components/MessageBlock.tsx +391 -0
  46. package/apps/dashboard/components/ModeStartCard.tsx +15 -15
  47. package/apps/dashboard/components/OnboardingWelcome.tsx +214 -0
  48. package/apps/dashboard/components/PlaceholderCard.tsx +11 -21
  49. package/apps/dashboard/components/ProjectSwitcher.tsx +36 -8
  50. package/apps/dashboard/components/PrototypeTimeline.tsx +25 -25
  51. package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +265 -301
  52. package/apps/dashboard/components/RealTimeTestsWrapper.tsx +97 -74
  53. package/apps/dashboard/components/ReviewFooter.tsx +141 -0
  54. package/apps/dashboard/components/SessionList.tsx +19 -18
  55. package/apps/dashboard/components/SubscribeContent.tsx +206 -0
  56. package/apps/dashboard/components/TestTree.tsx +15 -14
  57. package/apps/dashboard/components/TipCard.tsx +177 -0
  58. package/apps/dashboard/components/Toast.tsx +5 -5
  59. package/apps/dashboard/components/TypeIcon.tsx +56 -0
  60. package/apps/dashboard/components/UpgradeBanner.tsx +30 -0
  61. package/apps/dashboard/components/WaveCompletionAnimation.tsx +61 -62
  62. package/apps/dashboard/components/WelcomeScreen.tsx +25 -27
  63. package/apps/dashboard/components/WorkItemHeader.tsx +4 -4
  64. package/apps/dashboard/components/WorkItemTree.tsx +9 -28
  65. package/apps/dashboard/components/settings/AccountSection.tsx +169 -0
  66. package/apps/dashboard/components/settings/EnvVarsSection.tsx +54 -79
  67. package/apps/dashboard/components/settings/GeneralSection.tsx +26 -31
  68. package/apps/dashboard/components/settings/SettingsLayout.tsx +4 -4
  69. package/apps/dashboard/components/ui/Button.tsx +104 -0
  70. package/apps/dashboard/components/ui/Input.tsx +78 -0
  71. package/apps/dashboard/contexts/ClaudeSessionContext.tsx +408 -105
  72. package/apps/dashboard/contexts/ConnectionStatusContext.tsx +25 -4
  73. package/apps/dashboard/contexts/UsageContext.tsx +155 -0
  74. package/apps/dashboard/contexts/usageHelpers.js +9 -0
  75. package/apps/dashboard/electron/ipc-handlers.js +281 -88
  76. package/apps/dashboard/electron/main.js +691 -131
  77. package/apps/dashboard/electron/preload.js +25 -4
  78. package/apps/dashboard/electron/session-manager.js +163 -0
  79. package/apps/dashboard/electron-builder.config.js +3 -5
  80. package/apps/dashboard/hooks/useKanbanAnimation.ts +29 -0
  81. package/apps/dashboard/hooks/useKanbanUndo.ts +83 -0
  82. package/apps/dashboard/lib/backlog-parser.ts +50 -0
  83. package/apps/dashboard/lib/claude-process-manager.ts +50 -11
  84. package/apps/dashboard/lib/constants.ts +43 -0
  85. package/apps/dashboard/lib/db-bridge.ts +33 -0
  86. package/apps/dashboard/lib/db.ts +136 -20
  87. package/apps/dashboard/lib/kanban-utils.ts +70 -0
  88. package/apps/dashboard/lib/run-migrations.js +27 -2
  89. package/apps/dashboard/lib/session-state-machine.ts +3 -0
  90. package/apps/dashboard/lib/session-stream-manager.ts +144 -38
  91. package/apps/dashboard/lib/shadows.ts +7 -0
  92. package/apps/dashboard/lib/tests.ts +3 -1
  93. package/apps/dashboard/lib/utils.ts +6 -0
  94. package/apps/dashboard/next.config.js +35 -14
  95. package/apps/dashboard/package.json +6 -3
  96. package/apps/dashboard/public/bug-icon.svg +9 -0
  97. package/apps/dashboard/public/buoy-icon.svg +9 -0
  98. package/apps/dashboard/public/fonts/Satoshi-Variable.woff2 +0 -0
  99. package/apps/dashboard/public/fonts/Satoshi-VariableItalic.woff2 +0 -0
  100. package/apps/dashboard/public/in-flight-seagull.svg +9 -0
  101. package/apps/dashboard/public/jetty-icon-loading-alt.svg +11 -0
  102. package/apps/dashboard/public/jetty-icon-loading.svg +11 -0
  103. package/apps/dashboard/public/jettypod_logo.png +0 -0
  104. package/apps/dashboard/public/pier-icon.svg +14 -0
  105. package/apps/dashboard/public/star-icon.svg +9 -0
  106. package/apps/dashboard/public/wrench-icon.svg +9 -0
  107. package/apps/dashboard/scripts/upload-to-r2.js +89 -0
  108. package/apps/dashboard/scripts/ws-server.js +191 -0
  109. package/apps/dashboard/tsconfig.tsbuildinfo +1 -0
  110. package/apps/update-server/package.json +16 -0
  111. package/apps/update-server/schema.sql +31 -0
  112. package/apps/update-server/src/index.ts +1085 -0
  113. package/apps/update-server/tsconfig.json +16 -0
  114. package/apps/update-server/wrangler.toml +35 -0
  115. package/cucumber.js +9 -3
  116. package/docs/COMMAND_REFERENCE.md +34 -0
  117. package/hooks/post-checkout +32 -75
  118. package/hooks/post-merge +111 -10
  119. package/jest.setup.js +1 -0
  120. package/jettypod.js +54 -116
  121. package/lib/chore-taxonomy.js +33 -10
  122. package/lib/database.js +36 -16
  123. package/lib/db-watcher.js +1 -1
  124. package/lib/git-hooks/pre-commit +1 -1
  125. package/lib/jettypod-backup.js +27 -4
  126. package/lib/migrations/027-plan-at-creation-column.js +33 -0
  127. package/lib/migrations/028-ready-for-review-column.js +27 -0
  128. package/lib/migrations/029-remove-autoincrement.js +307 -0
  129. package/lib/migrations/029-rename-corrupted-to-cleaned.js +149 -0
  130. package/lib/migrations/index.js +47 -4
  131. package/lib/schema.js +13 -6
  132. package/lib/seed-onboarding.js +101 -69
  133. package/lib/update-command/index.js +9 -175
  134. package/lib/work-commands/index.js +129 -16
  135. package/lib/work-tracking/index.js +86 -46
  136. package/lib/worktree-diagnostics.js +16 -16
  137. package/lib/worktree-facade.js +1 -1
  138. package/lib/worktree-manager.js +8 -8
  139. package/lib/worktree-reconciler.js +5 -5
  140. package/package.json +9 -2
  141. package/scripts/ndjson-to-cucumber-json.js +152 -0
  142. package/scripts/postinstall.js +25 -0
  143. package/skills-templates/bug-mode/SKILL.md +39 -28
  144. package/skills-templates/bug-planning/SKILL.md +25 -29
  145. package/skills-templates/chore-mode/SKILL.md +131 -68
  146. package/skills-templates/chore-mode/verification.js +51 -10
  147. package/skills-templates/chore-planning/SKILL.md +47 -18
  148. package/skills-templates/epic-planning/SKILL.md +68 -48
  149. package/skills-templates/external-transition/SKILL.md +47 -47
  150. package/skills-templates/feature-planning/SKILL.md +83 -73
  151. package/skills-templates/production-mode/SKILL.md +49 -49
  152. package/skills-templates/request-routing/SKILL.md +27 -14
  153. package/skills-templates/simple-improvement/SKILL.md +68 -44
  154. package/skills-templates/speed-mode/SKILL.md +209 -128
  155. package/skills-templates/stable-mode/SKILL.md +105 -94
  156. package/templates/bdd-guidance.md +139 -0
  157. package/templates/bdd-scaffolding/wait.js +18 -0
  158. package/templates/bdd-scaffolding/world.js +19 -0
  159. package/.jettypod-backup/work.db +0 -0
  160. package/apps/dashboard/app/access-code/page.tsx +0 -110
  161. package/lib/discovery-checkpoint.js +0 -123
  162. package/skills-templates/project-discovery/SKILL.md +0 -372
@@ -1,21 +1,43 @@
1
1
  'use client';
2
2
 
3
- import { createContext, useContext, useState, type ReactNode } from 'react';
3
+ import { createContext, useContext, useState, useEffect, useRef, useMemo, type ReactNode } from 'react';
4
4
 
5
5
  export type ConnectionStatus = 'connected' | 'reconnecting' | 'disconnected';
6
6
 
7
+ const DISCONNECT_DELAY_MS = 5000;
8
+
7
9
  interface ConnectionStatusContextValue {
8
10
  status: ConnectionStatus;
9
11
  setStatus: (status: ConnectionStatus) => void;
12
+ showDisconnected: boolean;
10
13
  }
11
14
 
12
15
  const ConnectionStatusContext = createContext<ConnectionStatusContextValue | null>(null);
13
16
 
14
17
  export function ConnectionStatusProvider({ children }: { children: ReactNode }) {
15
18
  const [status, setStatus] = useState<ConnectionStatus>('disconnected');
19
+ const [showDisconnected, setShowDisconnected] = useState(false);
20
+ const timerRef = useRef<NodeJS.Timeout | null>(null);
21
+
22
+ useEffect(() => {
23
+ if (status === 'connected') {
24
+ if (timerRef.current) clearTimeout(timerRef.current);
25
+ setShowDisconnected(false);
26
+ } else {
27
+ timerRef.current = setTimeout(() => {
28
+ setShowDisconnected(true);
29
+ }, DISCONNECT_DELAY_MS);
30
+ }
31
+
32
+ return () => {
33
+ if (timerRef.current) clearTimeout(timerRef.current);
34
+ };
35
+ }, [status]);
36
+
37
+ const value = useMemo(() => ({ status, setStatus, showDisconnected }), [status, showDisconnected]);
16
38
 
17
39
  return (
18
- <ConnectionStatusContext.Provider value={{ status, setStatus }}>
40
+ <ConnectionStatusContext.Provider value={value}>
19
41
  {children}
20
42
  </ConnectionStatusContext.Provider>
21
43
  );
@@ -24,8 +46,7 @@ export function ConnectionStatusProvider({ children }: { children: ReactNode })
24
46
  export function useConnectionStatus() {
25
47
  const context = useContext(ConnectionStatusContext);
26
48
  if (!context) {
27
- // Return a safe default when used outside the provider
28
- return { status: 'disconnected' as ConnectionStatus, setStatus: () => {} };
49
+ return { status: 'disconnected' as ConnectionStatus, setStatus: () => {}, showDisconnected: false };
29
50
  }
30
51
  return context;
31
52
  }
@@ -0,0 +1,155 @@
1
+ 'use client';
2
+
3
+ import { createContext, useContext, useState, useEffect, useCallback, useMemo, type ReactNode } from 'react';
4
+ import { useWebSocket, type WebSocketMessage } from '../hooks/useWebSocket';
5
+ import { getWebSocketUrl } from '../lib/utils';
6
+
7
+ const FREE_WEEKLY_LIMIT = 20;
8
+ const API_BASE = 'https://jettypod-update-server.spangbaryn2.workers.dev';
9
+
10
+ interface UsageState {
11
+ used: number;
12
+ limit: number;
13
+ remaining: number;
14
+ allowed: boolean;
15
+ plan: string;
16
+ loading: boolean;
17
+ }
18
+
19
+ interface UsageContextValue extends UsageState {
20
+ refresh: () => Promise<void>;
21
+ }
22
+
23
+ const UsageContext = createContext<UsageContextValue | null>(null);
24
+
25
+ export function UsageProvider({ children }: { children: ReactNode }) {
26
+ const [state, setState] = useState<UsageState>({
27
+ used: 0,
28
+ limit: FREE_WEEKLY_LIMIT,
29
+ remaining: FREE_WEEKLY_LIMIT,
30
+ allowed: true,
31
+ plan: 'free',
32
+ loading: true,
33
+ });
34
+
35
+ const fetchUsage = useCallback(async () => {
36
+ // Get plan from auth (if Electron)
37
+ let plan = 'free';
38
+ try {
39
+ if (window.electronAPI?.isElectron) {
40
+ const status = await window.electronAPI.auth.getStatus();
41
+ if (status.authenticated && status.user) {
42
+ plan = status.user.plan || 'free';
43
+ }
44
+
45
+ // Check server-side plan — the local JWT may be stale
46
+ if (plan === 'free') {
47
+ const token = await window.electronAPI.auth.getToken();
48
+ if (token) {
49
+ try {
50
+ const res = await fetch(`${API_BASE}/auth/me`, {
51
+ headers: { 'Authorization': `Bearer ${token}` },
52
+ });
53
+ if (res.ok) {
54
+ const data = await res.json() as { user: { plan: string; email: string }; token?: string };
55
+ if (data.user.plan !== 'free') {
56
+ plan = data.user.plan;
57
+ // Server issued a fresh JWT with updated plan — save it locally
58
+ if (data.token) {
59
+ await window.electronAPI.auth.saveToken(data.token, data.user);
60
+ }
61
+ }
62
+ }
63
+ } catch {
64
+ // Network error — fall through with local plan
65
+ }
66
+ }
67
+ }
68
+ }
69
+ } catch {
70
+ // Auth unavailable — default to free
71
+ }
72
+
73
+ // Paid plans have unlimited usage
74
+ if (plan !== 'free') {
75
+ setState({
76
+ used: 0,
77
+ limit: Infinity,
78
+ remaining: Infinity,
79
+ allowed: true,
80
+ plan,
81
+ loading: false,
82
+ });
83
+ return;
84
+ }
85
+
86
+ // Free plan — count from local DB via API route
87
+ try {
88
+ const res = await fetch('/api/usage');
89
+ console.log('[usage] UsageContext fetch status:', res.status);
90
+ if (!res.ok) {
91
+ console.error('[usage] UsageContext fetch not ok:', res.status, res.statusText);
92
+ setState(prev => ({ ...prev, allowed: true, loading: false }));
93
+ return;
94
+ }
95
+ const usage = await res.json();
96
+ console.log('[usage] UsageContext received:', usage);
97
+ setState({
98
+ used: typeof usage.used === 'number' ? usage.used : 0,
99
+ limit: typeof usage.limit === 'number' ? usage.limit : FREE_WEEKLY_LIMIT,
100
+ remaining: typeof usage.remaining === 'number' ? usage.remaining : FREE_WEEKLY_LIMIT,
101
+ allowed: typeof usage.allowed === 'boolean' ? usage.allowed : true,
102
+ plan,
103
+ loading: false,
104
+ });
105
+ } catch (err) {
106
+ console.error('[usage] UsageContext fetch error:', err);
107
+ setState(prev => ({ ...prev, allowed: true, loading: false }));
108
+ }
109
+ }, []);
110
+
111
+ // Fetch on mount
112
+ useEffect(() => {
113
+ fetchUsage();
114
+ }, [fetchUsage]);
115
+
116
+ // Refresh on window focus
117
+ useEffect(() => {
118
+ const handleFocus = () => fetchUsage();
119
+ window.addEventListener('focus', handleFocus);
120
+ return () => window.removeEventListener('focus', handleFocus);
121
+ }, [fetchUsage]);
122
+
123
+ // Refresh on WebSocket db_change events (e.g., CLI creates a work item)
124
+ const handleWsMessage = useCallback((message: WebSocketMessage) => {
125
+ if (message.type === 'db_change') {
126
+ fetchUsage();
127
+ }
128
+ }, [fetchUsage]);
129
+
130
+ useWebSocket({ url: getWebSocketUrl(), onMessage: handleWsMessage });
131
+
132
+ const value = useMemo(() => ({ ...state, refresh: fetchUsage }), [state, fetchUsage]);
133
+
134
+ return (
135
+ <UsageContext.Provider value={value}>
136
+ {children}
137
+ </UsageContext.Provider>
138
+ );
139
+ }
140
+
141
+ export function useUsage() {
142
+ const context = useContext(UsageContext);
143
+ if (!context) {
144
+ return {
145
+ used: 0,
146
+ limit: FREE_WEEKLY_LIMIT,
147
+ remaining: FREE_WEEKLY_LIMIT,
148
+ allowed: true,
149
+ plan: 'free',
150
+ loading: false,
151
+ refresh: async () => {},
152
+ };
153
+ }
154
+ return context;
155
+ }
@@ -0,0 +1,9 @@
1
+ function checkUsageLimit(plan, used, limit) {
2
+ if (plan !== 'free') {
3
+ return { allowed: true, used: 0, limit: Infinity, remaining: Infinity };
4
+ }
5
+ const remaining = Math.max(0, limit - used);
6
+ return { allowed: remaining > 0, used, limit, remaining };
7
+ }
8
+
9
+ module.exports = { checkUsageLimit };
@@ -96,14 +96,20 @@ function getDbPath() {
96
96
  function getDb() {
97
97
  if (!cachedDb) {
98
98
  const dbPath = getDbPath();
99
- cachedDb = new Database(dbPath);
100
- cachedDb.pragma('journal_mode = WAL');
101
- cachedDb.pragma('foreign_keys = ON');
102
- // Run pending migrations to handle old project databases
103
99
  try {
104
- runMigrations(cachedDb);
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
+ }
105
109
  } catch (err) {
106
- console.error('Failed to run migrations:', err);
110
+ console.error(`[DB] Failed to open database at ${dbPath}:`, err.message);
111
+ cachedDb = null;
112
+ throw err;
107
113
  }
108
114
  }
109
115
  return cachedDb;
@@ -165,48 +171,8 @@ function registerIpcHandlers() {
165
171
  `).all();
166
172
  const epicMap = new Map(epics.map(e => [e.id, e.title]));
167
173
 
168
- // Get all chores that belong to features
169
- const featureChores = db.prepare(`
170
- SELECT c.id, c.type, c.title, c.description, c.status, c.parent_id, c.epic_id,
171
- c.branch_name, c.mode, c.phase, c.completed_at, c.created_at,
172
- wc.current_step, wc.total_steps
173
- FROM work_items c
174
- INNER JOIN work_items f ON c.parent_id = f.id
175
- LEFT JOIN workflow_checkpoints wc ON wc.work_item_id = c.id
176
- WHERE c.type = 'chore' AND f.type = 'feature'
177
- ORDER BY c.id
178
- `).all();
179
-
180
- const choresByFeature = new Map();
181
- for (const chore of featureChores) {
182
- if (chore.parent_id) {
183
- const existing = choresByFeature.get(chore.parent_id) || [];
184
- existing.push(chore);
185
- choresByFeature.set(chore.parent_id, existing);
186
- }
187
- }
188
-
189
- // Get all bugs that belong to features
190
- const featureBugs = db.prepare(`
191
- SELECT b.id, b.type, b.title, b.description, b.status, b.parent_id, b.epic_id,
192
- b.branch_name, b.mode, b.phase, b.completed_at, b.created_at
193
- FROM work_items b
194
- INNER JOIN work_items f ON b.parent_id = f.id
195
- WHERE b.type = 'bug' AND f.type = 'feature'
196
- ORDER BY b.id
197
- `).all();
198
-
199
- const bugsByFeature = new Map();
200
- for (const bug of featureBugs) {
201
- if (bug.parent_id) {
202
- const existing = bugsByFeature.get(bug.parent_id) || [];
203
- existing.push(bug);
204
- bugsByFeature.set(bug.parent_id, existing);
205
- }
206
- }
207
-
208
- // Get kanban-eligible items
209
- const allItems = db.prepare(`
174
+ // Single query: get all non-epic work items with parent type and workflow progress
175
+ const allWorkItems = db.prepare(`
210
176
  SELECT w.id, w.type, w.title, w.description, w.status, w.parent_id, w.epic_id,
211
177
  w.branch_name, w.mode, w.phase, w.completed_at, w.created_at, w.display_order,
212
178
  p.type as parent_type,
@@ -215,10 +181,27 @@ function registerIpcHandlers() {
215
181
  LEFT JOIN work_items p ON w.parent_id = p.id
216
182
  LEFT JOIN workflow_checkpoints wc ON wc.work_item_id = w.id
217
183
  WHERE w.type IN ('feature', 'chore', 'bug')
218
- AND (w.parent_id IS NULL OR p.type = 'epic')
219
184
  ORDER BY COALESCE(w.display_order, w.id)
220
185
  `).all();
221
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
+
222
205
  const inFlight = [];
223
206
  const backlogGroups = new Map();
224
207
  const doneGroups = new Map();
@@ -660,7 +643,58 @@ function registerIpcHandlers() {
660
643
  return listDevServers();
661
644
  });
662
645
 
663
- // ==================== Project Dialog ====================
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 ====================
664
698
  ipcMain.handle('dialog:openProject', async () => {
665
699
  const mainWindow = BrowserWindow.getFocusedWindow();
666
700
 
@@ -689,17 +723,15 @@ function registerIpcHandlers() {
689
723
  }
690
724
  }
691
725
 
692
- // Update the project root (also persists as last-selected and sets env var)
693
- // Await ensures Next.js dev server is synced before renderer reloads
694
- const { setProjectRoot } = require('./main');
695
- await setProjectRoot(selectedPath);
696
-
697
- // Reset the cached database connection so it reconnects to the new project
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)
698
728
  closeDb();
699
729
  cachedDb = null;
700
730
  projectRoot = selectedPath;
701
731
 
702
- // Add to recent projects list
732
+ const { setProjectRoot } = require('./main');
733
+ await setProjectRoot(selectedPath);
734
+
703
735
  addRecentProject(selectedPath);
704
736
 
705
737
  return { success: true, path: selectedPath };
@@ -737,17 +769,15 @@ function registerIpcHandlers() {
737
769
  }
738
770
  }
739
771
 
740
- // Update the project root (also persists as last-selected and sets env var)
741
- // Await ensures Next.js dev server is synced before renderer reloads
742
- const { setProjectRoot } = require('./main');
743
- await setProjectRoot(projectPath);
744
-
745
- // Reset the cached database connection so it reconnects to the new project
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)
746
774
  closeDb();
747
775
  cachedDb = null;
748
776
  projectRoot = projectPath;
749
777
 
750
- // Update recent projects (move to top)
778
+ const { setProjectRoot } = require('./main');
779
+ await setProjectRoot(projectPath);
780
+
751
781
  addRecentProject(projectPath);
752
782
 
753
783
  return { success: true, path: projectPath };
@@ -769,46 +799,209 @@ function registerIpcHandlers() {
769
799
  return await updateClaudeCode();
770
800
  });
771
801
 
772
- // ==================== Access Code Gating ====================
773
- ipcMain.handle('access:validate', (event, code) => {
774
- // Speed mode: accept any non-empty code
775
- // Real validation will be added in stable mode
776
- if (!code || code.trim() === '') {
777
- return { success: false, error: 'Access code is required' };
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}` };
778
851
  }
852
+ });
779
853
 
780
- // Store the access grant
781
- const accessPath = path.join(app.getPath('userData'), 'access-granted.json');
782
- const accessData = {
783
- activated: true,
784
- activatedAt: new Date().toISOString(),
785
- code: code.trim()
786
- };
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
+ }
787
893
 
788
894
  try {
789
- // Ensure directory exists
790
- const dir = path.dirname(accessPath);
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);
791
915
  if (!fs.existsSync(dir)) {
792
916
  fs.mkdirSync(dir, { recursive: true });
793
917
  }
794
- fs.writeFileSync(accessPath, JSON.stringify(accessData, null, 2));
918
+ fs.writeFileSync(subPath, JSON.stringify(subData, null, 2));
795
919
  return { success: true };
796
920
  } catch (error) {
797
- return { success: false, error: `Failed to save access: ${error.message}` };
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());
798
959
  }
960
+
961
+ return { success: true };
799
962
  });
800
963
 
801
- ipcMain.handle('access:getStatus', () => {
802
- const accessPath = path.join(app.getPath('userData'), 'access-granted.json');
803
- if (!fs.existsSync(accessPath)) {
804
- return { activated: false };
964
+ ipcMain.handle('auth:getStatus', () => {
965
+ const authPath = path.join(app.getPath('userData'), 'auth.json');
966
+ if (!fs.existsSync(authPath)) {
967
+ return { authenticated: false };
805
968
  }
806
969
 
807
970
  try {
808
- const data = JSON.parse(fs.readFileSync(accessPath, 'utf-8'));
809
- return { activated: !!data.activated, activatedAt: data.activatedAt };
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);
810
1003
  } catch {
811
- return { activated: false };
1004
+ return false;
812
1005
  }
813
1006
  });
814
1007