jettypod 4.4.115 → 4.4.118

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 (73) hide show
  1. package/.env +7 -0
  2. package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +25 -9
  3. package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +7 -3
  4. package/apps/dashboard/app/api/tests/run/stream/route.ts +13 -1
  5. package/apps/dashboard/app/api/usage/route.ts +17 -0
  6. package/apps/dashboard/app/connect-claude/page.tsx +24 -0
  7. package/apps/dashboard/app/install-claude/page.tsx +8 -6
  8. package/apps/dashboard/app/login/page.tsx +229 -0
  9. package/apps/dashboard/app/page.tsx +5 -3
  10. package/apps/dashboard/app/settings/page.tsx +2 -0
  11. package/apps/dashboard/app/subscribe/page.tsx +11 -0
  12. package/apps/dashboard/app/welcome/page.tsx +23 -0
  13. package/apps/dashboard/components/AppShell.tsx +51 -9
  14. package/apps/dashboard/components/CardMenu.tsx +14 -5
  15. package/apps/dashboard/components/ClaudePanel.tsx +65 -9
  16. package/apps/dashboard/components/ConnectClaudeScreen.tsx +223 -0
  17. package/apps/dashboard/components/DragContext.tsx +73 -64
  18. package/apps/dashboard/components/DraggableCard.tsx +6 -46
  19. package/apps/dashboard/components/GateCard.tsx +21 -0
  20. package/apps/dashboard/components/InstallClaudeScreen.tsx +132 -30
  21. package/apps/dashboard/components/KanbanBoard.tsx +173 -56
  22. package/apps/dashboard/components/PlaceholderCard.tsx +9 -19
  23. package/apps/dashboard/components/ProjectSwitcher.tsx +28 -0
  24. package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +34 -3
  25. package/apps/dashboard/components/RealTimeTestsWrapper.tsx +30 -2
  26. package/apps/dashboard/components/SubscribeContent.tsx +191 -0
  27. package/apps/dashboard/components/TipCard.tsx +176 -0
  28. package/apps/dashboard/components/UpgradeBanner.tsx +29 -0
  29. package/apps/dashboard/components/WelcomeScreen.tsx +14 -4
  30. package/apps/dashboard/components/settings/AccountSection.tsx +163 -0
  31. package/apps/dashboard/contexts/ClaudeSessionContext.tsx +292 -29
  32. package/apps/dashboard/contexts/UsageContext.tsx +131 -0
  33. package/apps/dashboard/contexts/usageHelpers.js +9 -0
  34. package/apps/dashboard/electron/ipc-handlers.js +220 -114
  35. package/apps/dashboard/electron/main.js +415 -37
  36. package/apps/dashboard/electron/preload.js +23 -4
  37. package/apps/dashboard/electron/session-manager.js +141 -0
  38. package/apps/dashboard/electron-builder.config.js +3 -5
  39. package/apps/dashboard/lib/claude-process-manager.ts +6 -4
  40. package/apps/dashboard/lib/db-bridge.ts +32 -0
  41. package/apps/dashboard/lib/db.ts +159 -13
  42. package/apps/dashboard/lib/session-state-machine.ts +3 -0
  43. package/apps/dashboard/lib/session-stream-manager.ts +76 -13
  44. package/apps/dashboard/lib/tests.ts +3 -1
  45. package/apps/dashboard/next.config.js +19 -14
  46. package/apps/dashboard/package.json +3 -1
  47. package/apps/dashboard/scripts/upload-to-r2.js +89 -0
  48. package/apps/dashboard/tsconfig.tsbuildinfo +1 -0
  49. package/apps/update-server/package.json +16 -0
  50. package/apps/update-server/schema.sql +31 -0
  51. package/apps/update-server/src/index.ts +1074 -0
  52. package/apps/update-server/tsconfig.json +16 -0
  53. package/apps/update-server/wrangler.toml +35 -0
  54. package/docs/bdd-guidance.md +390 -0
  55. package/jettypod.js +5 -4
  56. package/lib/migrations/027-plan-at-creation-column.js +31 -0
  57. package/lib/migrations/028-ready-for-review-column.js +27 -0
  58. package/lib/schema.js +3 -1
  59. package/lib/seed-onboarding.js +100 -68
  60. package/lib/work-commands/index.js +43 -13
  61. package/lib/work-tracking/index.js +46 -27
  62. package/package.json +1 -1
  63. package/skills-templates/bug-mode/SKILL.md +5 -11
  64. package/skills-templates/request-routing/SKILL.md +24 -11
  65. package/skills-templates/simple-improvement/SKILL.md +35 -19
  66. package/skills-templates/stable-mode/SKILL.md +5 -6
  67. package/templates/bdd-guidance.md +139 -0
  68. package/templates/bdd-scaffolding/wait.js +18 -0
  69. package/templates/bdd-scaffolding/world.js +19 -0
  70. package/.jettypod-backup/work.db +0 -0
  71. package/apps/dashboard/app/access-code/page.tsx +0 -110
  72. package/lib/discovery-checkpoint.js +0 -123
  73. package/skills-templates/project-discovery/SKILL.md +0 -372
@@ -153,77 +153,6 @@ function initializeJettypod(projectPath) {
153
153
 
154
154
  }
155
155
 
156
- /**
157
- * Check if a project folder has no implementation code.
158
- */
159
- function isBlankProject(projectPath) {
160
- const IMPL_PATTERNS = [
161
- /\.js$/, /\.ts$/, /\.jsx$/, /\.tsx$/, /\.mjs$/, /\.cjs$/,
162
- /\.py$/, /\.rb$/, /\.go$/, /\.rs$/, /\.java$/, /\.kt$/,
163
- /\.c$/, /\.cpp$/, /\.h$/, /\.swift$/, /\.php$/,
164
- /^src\//, /^lib\//, /^app\//,
165
- /^package\.json$/, /^Cargo\.toml$/, /^requirements\.txt$/,
166
- /^Gemfile$/, /^go\.mod$/, /^pom\.xml$/, /^build\.gradle$/,
167
- ];
168
- const IGNORE = [/^\.git\//, /^\.jettypod\//, /^\.claude\//, /^node_modules\//];
169
-
170
- function scan(dir, baseDir) {
171
- if (!fs.existsSync(dir)) return false;
172
- let entries;
173
- try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return false; }
174
- for (const entry of entries) {
175
- const rel = path.relative(baseDir, path.join(dir, entry.name));
176
- if (IGNORE.some(p => p.test(rel) || p.test(entry.name))) continue;
177
- if (entry.isDirectory()) {
178
- if (IMPL_PATTERNS.some(p => p.test(rel + '/'))) return true;
179
- if (scan(path.join(dir, entry.name), baseDir)) return true;
180
- } else {
181
- if (IMPL_PATTERNS.some(p => p.test(rel))) return true;
182
- }
183
- }
184
- return false;
185
- }
186
-
187
- return !scan(projectPath, projectPath);
188
- }
189
-
190
- /**
191
- * Seed onboarding epic and chores into a blank project's database.
192
- */
193
- function seedOnboardingEpic(projectPath) {
194
- const dbPath = path.join(projectPath, '.jettypod', 'work.db');
195
- const db = new Database(dbPath);
196
- db.pragma('journal_mode = WAL');
197
- db.pragma('foreign_keys = ON');
198
- runMigrations(db);
199
-
200
- // Check if already seeded
201
- const existing = db.prepare("SELECT id FROM work_items WHERE type = 'epic' AND title = 'Project Onboarding'").get();
202
- if (existing) { db.close(); return; }
203
-
204
- const insert = db.prepare(
205
- 'INSERT INTO work_items (type, title, description, parent_id, status, created_at) VALUES (?, ?, ?, ?, ?, ?)'
206
- );
207
- const now = new Date().toISOString();
208
-
209
- const epicResult = insert.run('epic', 'Project Onboarding',
210
- 'Get your project set up and planned. Work through these chores one at a time — each one is a short conversation.', null, 'backlog', now);
211
- const epicId = epicResult.lastInsertRowid;
212
-
213
- const chores = [
214
- { title: 'Align on the user journey', desc: 'Help the user define what their product does.\n\nCLAUDE SESSION GUIDANCE:\nOpen with: "What do users DO in this product?"\n\nOUTCOME:\n- A clear description of the core user journey' },
215
- { title: 'Explore UX approaches', desc: 'Help the user decide how the product should feel.\n\nCONTEXT FROM PREVIOUS CHORE:\nRead previous decisions first.\n\nCLAUDE SESSION GUIDANCE:\nPresent 3 UX approaches.\n\nOUTCOME:\n- 3 UX options compared\n- A winner chosen' },
216
- { title: 'Choose a tech stack', desc: 'Help the user pick the right tech stack.\n\nCONTEXT FROM PREVIOUS CHORES:\nRead previous decisions first.\n\nCLAUDE SESSION GUIDANCE:\nPresent 3 tech stack options.\n\nOUTCOME:\n- A tech stack chosen with rationale' },
217
- { title: 'Break the project into epics', desc: 'Break the project into buildable phases.\n\nCONTEXT FROM PREVIOUS CHORES:\nRead all previous decisions.\n\nCLAUDE SESSION GUIDANCE:\nPropose 3-5 epics.\n\nOUTCOME:\n- 3-5 epics created in the backlog' },
218
- ];
219
-
220
- for (const chore of chores) {
221
- insert.run('chore', chore.title, chore.desc, epicId, 'backlog', now);
222
- }
223
-
224
- db.close();
225
- }
226
-
227
156
  // Register all IPC handlers
228
157
  function registerIpcHandlers() {
229
158
  // ==================== Kanban ====================
@@ -731,7 +660,56 @@ function registerIpcHandlers() {
731
660
  return listDevServers();
732
661
  });
733
662
 
734
- // ==================== Project Dialog ====================
663
+ // ==================== New Project Dialog ====================
664
+ ipcMain.handle('dialog:newProject', async () => {
665
+ const mainWindow = BrowserWindow.getFocusedWindow();
666
+
667
+ const result = await dialog.showSaveDialog(mainWindow, {
668
+ title: 'Create New Project',
669
+ buttonLabel: 'Create',
670
+ nameFieldLabel: 'Project Name:',
671
+ showsTagField: false,
672
+ });
673
+
674
+ if (result.canceled || !result.filePath) {
675
+ return { success: false, canceled: true };
676
+ }
677
+
678
+ const projectPath = result.filePath;
679
+
680
+ // Create the directory
681
+ try {
682
+ fs.mkdirSync(projectPath, { recursive: true });
683
+ } catch (mkdirError) {
684
+ return {
685
+ success: false,
686
+ error: `Failed to create directory: ${mkdirError.message}`
687
+ };
688
+ }
689
+
690
+ // Initialize JettyPod in the new directory
691
+ try {
692
+ initializeJettypod(projectPath);
693
+ } catch (initError) {
694
+ return {
695
+ success: false,
696
+ error: `Failed to initialize JettyPod: ${initError.message}`
697
+ };
698
+ }
699
+
700
+ const { setProjectRoot } = require('./main');
701
+ await setProjectRoot(projectPath);
702
+
703
+ closeDb();
704
+ cachedDb = null;
705
+ projectRoot = projectPath;
706
+
707
+ addRecentProject(projectPath);
708
+
709
+ return { success: true, path: projectPath };
710
+ });
711
+
712
+ // ==================== Open Project Dialog ====================
735
713
  ipcMain.handle('dialog:openProject', async () => {
736
714
  const mainWindow = BrowserWindow.getFocusedWindow();
737
715
 
@@ -770,15 +748,6 @@ function registerIpcHandlers() {
770
748
  cachedDb = null;
771
749
  projectRoot = selectedPath;
772
750
 
773
- // Seed onboarding epic for blank projects (safe to call every open - has dupe check)
774
- if (isBlankProject(selectedPath)) {
775
- try {
776
- seedOnboardingEpic(selectedPath);
777
- } catch (e) {
778
- console.error('Could not seed onboarding:', e.message);
779
- }
780
- }
781
-
782
751
  // Add to recent projects list
783
752
  addRecentProject(selectedPath);
784
753
 
@@ -827,15 +796,6 @@ function registerIpcHandlers() {
827
796
  cachedDb = null;
828
797
  projectRoot = projectPath;
829
798
 
830
- // Seed onboarding epic for blank projects (safe to call every open - has dupe check)
831
- if (isBlankProject(projectPath)) {
832
- try {
833
- seedOnboardingEpic(projectPath);
834
- } catch (e) {
835
- console.error('Could not seed onboarding:', e.message);
836
- }
837
- }
838
-
839
799
  // Update recent projects (move to top)
840
800
  addRecentProject(projectPath);
841
801
 
@@ -858,49 +818,195 @@ function registerIpcHandlers() {
858
818
  return await updateClaudeCode();
859
819
  });
860
820
 
861
- // ==================== Access Code Gating ====================
862
- ipcMain.handle('access:validate', (event, code) => {
863
- // Speed mode: accept any non-empty code
864
- // Real validation will be added in stable mode
865
- if (!code || code.trim() === '') {
866
- return { success: false, error: 'Access code is required' };
821
+ ipcMain.handle('claudeCode:isAuthenticated', () => {
822
+ const { checkClaudeCodeAuthenticated } = require('./main');
823
+ return checkClaudeCodeAuthenticated();
824
+ });
825
+
826
+ ipcMain.handle('claudeCode:login', async () => {
827
+ const { loginClaudeCode } = require('./main');
828
+ return await loginClaudeCode();
829
+ });
830
+
831
+ // ==================== Subscription Gating ====================
832
+
833
+ const UPDATE_SERVER_URL = 'https://jettypod-update-server.spangbaryn2.workers.dev';
834
+
835
+ ipcMain.handle('subscription:createCheckout', async (event, plan) => {
836
+ try {
837
+ // Include JWT auth header so checkout session gets user_id in metadata
838
+ const headers = { 'Content-Type': 'application/json' };
839
+ const authPath = path.join(app.getPath('userData'), 'auth.json');
840
+ if (fs.existsSync(authPath)) {
841
+ try {
842
+ const authData = JSON.parse(fs.readFileSync(authPath, 'utf-8'));
843
+ if (authData.token) {
844
+ headers['Authorization'] = `Bearer ${authData.token}`;
845
+ }
846
+ } catch {
847
+ // Continue without auth if read fails
848
+ }
849
+ }
850
+
851
+ const response = await fetch(`${UPDATE_SERVER_URL}/checkout/create-session`, {
852
+ method: 'POST',
853
+ headers,
854
+ body: JSON.stringify({ plan: plan || 'monthly' }),
855
+ });
856
+
857
+ if (!response.ok) {
858
+ return { success: false, error: 'Failed to create checkout session' };
859
+ }
860
+
861
+ const data = await response.json();
862
+ if (data.url) {
863
+ const { shell } = require('electron');
864
+ shell.openExternal(data.url);
865
+ return { success: true };
866
+ }
867
+ return { success: false, error: 'No checkout URL returned' };
868
+ } catch (error) {
869
+ return { success: false, error: `Checkout failed: ${error.message}` };
870
+ }
871
+ });
872
+
873
+ ipcMain.handle('billing:openCustomerPortal', async () => {
874
+ try {
875
+ const headers = { 'Content-Type': 'application/json' };
876
+ const authPath = path.join(app.getPath('userData'), 'auth.json');
877
+ if (fs.existsSync(authPath)) {
878
+ try {
879
+ const authData = JSON.parse(fs.readFileSync(authPath, 'utf-8'));
880
+ if (authData.token) {
881
+ headers['Authorization'] = `Bearer ${authData.token}`;
882
+ }
883
+ } catch {
884
+ // Continue without auth if read fails
885
+ }
886
+ }
887
+
888
+ const response = await fetch(`${UPDATE_SERVER_URL}/billing/customer-portal`, {
889
+ method: 'POST',
890
+ headers,
891
+ });
892
+
893
+ if (!response.ok) {
894
+ return { success: false, error: 'Failed to open billing portal' };
895
+ }
896
+
897
+ const data = await response.json();
898
+ if (data.url) {
899
+ shell.openExternal(data.url);
900
+ return { success: true };
901
+ }
902
+ return { success: false, error: 'No portal URL returned' };
903
+ } catch (error) {
904
+ return { success: false, error: `Billing portal failed: ${error.message}` };
867
905
  }
906
+ });
868
907
 
869
- // Store the access grant
870
- const accessPath = path.join(app.getPath('userData'), 'access-granted.json');
871
- const accessData = {
872
- activated: true,
873
- activatedAt: new Date().toISOString(),
874
- code: code.trim()
875
- };
908
+ ipcMain.handle('subscription:activate', async (event, customerId) => {
909
+ if (!customerId || !customerId.trim().startsWith('cus_')) {
910
+ return { success: false, error: 'Invalid customer ID. It should start with cus_' };
911
+ }
876
912
 
877
913
  try {
878
- // Ensure directory exists
879
- const dir = path.dirname(accessPath);
914
+ // Validate against the update server
915
+ const response = await fetch(`${UPDATE_SERVER_URL}/subscription/validate`, {
916
+ method: 'POST',
917
+ headers: { 'Content-Type': 'application/json' },
918
+ body: JSON.stringify({ customerId: customerId.trim() }),
919
+ });
920
+
921
+ const result = await response.json();
922
+ if (!result.valid) {
923
+ return { success: false, error: result.error || 'No active subscription found' };
924
+ }
925
+
926
+ // Store the subscription
927
+ const subPath = path.join(app.getPath('userData'), 'subscription.json');
928
+ const subData = {
929
+ customerId: customerId.trim(),
930
+ activatedAt: new Date().toISOString(),
931
+ };
932
+
933
+ const dir = path.dirname(subPath);
880
934
  if (!fs.existsSync(dir)) {
881
935
  fs.mkdirSync(dir, { recursive: true });
882
936
  }
883
- fs.writeFileSync(accessPath, JSON.stringify(accessData, null, 2));
937
+ fs.writeFileSync(subPath, JSON.stringify(subData, null, 2));
884
938
  return { success: true };
885
939
  } catch (error) {
886
- return { success: false, error: `Failed to save access: ${error.message}` };
940
+ return { success: false, error: `Activation failed: ${error.message}` };
941
+ }
942
+ });
943
+
944
+ ipcMain.handle('subscription:getStatus', () => {
945
+ const subPath = path.join(app.getPath('userData'), 'subscription.json');
946
+ if (!fs.existsSync(subPath)) {
947
+ return { active: false };
948
+ }
949
+
950
+ try {
951
+ const data = JSON.parse(fs.readFileSync(subPath, 'utf-8'));
952
+ return { active: !!data.customerId, customerId: data.customerId, activatedAt: data.activatedAt };
953
+ } catch {
954
+ return { active: false };
955
+ }
956
+ });
957
+
958
+ // ==================== Auth (JWT-based) ====================
959
+
960
+ ipcMain.handle('auth:loginWithGoogle', () => {
961
+ const { shell } = require('electron');
962
+ shell.openExternal(`${UPDATE_SERVER_URL}/auth/google`);
963
+ return { success: true };
964
+ });
965
+
966
+ ipcMain.handle('auth:saveToken', (event, token, user) => {
967
+ const authPath = path.join(app.getPath('userData'), 'auth.json');
968
+ const dir = path.dirname(authPath);
969
+ if (!fs.existsSync(dir)) {
970
+ fs.mkdirSync(dir, { recursive: true });
887
971
  }
972
+ fs.writeFileSync(authPath, JSON.stringify({ token, user, savedAt: new Date().toISOString() }, null, 2));
973
+ return { success: true };
888
974
  });
889
975
 
890
- ipcMain.handle('access:getStatus', () => {
891
- const accessPath = path.join(app.getPath('userData'), 'access-granted.json');
892
- if (!fs.existsSync(accessPath)) {
893
- return { activated: false };
976
+ ipcMain.handle('auth:getStatus', () => {
977
+ const authPath = path.join(app.getPath('userData'), 'auth.json');
978
+ if (!fs.existsSync(authPath)) {
979
+ return { authenticated: false };
980
+ }
981
+
982
+ try {
983
+ const data = JSON.parse(fs.readFileSync(authPath, 'utf-8'));
984
+ return { authenticated: !!data.token, token: data.token, user: data.user };
985
+ } catch {
986
+ return { authenticated: false };
894
987
  }
988
+ });
989
+
990
+ ipcMain.handle('auth:getToken', () => {
991
+ const authPath = path.join(app.getPath('userData'), 'auth.json');
992
+ if (!fs.existsSync(authPath)) return null;
895
993
 
896
994
  try {
897
- const data = JSON.parse(fs.readFileSync(accessPath, 'utf-8'));
898
- return { activated: !!data.activated, activatedAt: data.activatedAt };
995
+ const data = JSON.parse(fs.readFileSync(authPath, 'utf-8'));
996
+ return data.token || null;
899
997
  } catch {
900
- return { activated: false };
998
+ return null;
901
999
  }
902
1000
  });
903
1001
 
1002
+ ipcMain.handle('auth:logout', () => {
1003
+ const authPath = path.join(app.getPath('userData'), 'auth.json');
1004
+ if (fs.existsSync(authPath)) {
1005
+ fs.unlinkSync(authPath);
1006
+ }
1007
+ return { success: true };
1008
+ });
1009
+
904
1010
  // ==================== Shell Operations ====================
905
1011
  ipcMain.handle('shell:openPath', async (event, filePath) => {
906
1012
  // Use openExternal with file:// URL to open in default browser