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
@@ -1,4 +1,4 @@
1
- const { app, BrowserWindow, Menu, dialog } = require('electron');
1
+ const { app, BrowserWindow, Menu, dialog, shell } = require('electron');
2
2
  const { spawn, execSync } = require('child_process');
3
3
  const path = require('path');
4
4
  const fs = require('fs');
@@ -7,6 +7,7 @@ const { WebSocketServer } = require('ws');
7
7
  const { registerIpcHandlers, closeDb } = require('./ipc-handlers');
8
8
  const { ingestCucumberResults, closeIngesterDb } = require('./test-results-ingester');
9
9
  const { autoUpdater } = require('electron-updater');
10
+ const sessionManager = require('./session-manager');
10
11
 
11
12
  // Track child processes and servers for cleanup
12
13
  let nextProcess = null;
@@ -145,8 +146,39 @@ function getMainWindow() {
145
146
  // WebSocket configuration
146
147
  const WS_PORT = 47808;
147
148
 
149
+ // Persistent log file for diagnostics
150
+ const LOG_DIR = path.join(app.getPath('home'), 'Library', 'Logs', 'jettypod');
151
+ const LOG_FILE = path.join(LOG_DIR, 'main.log');
152
+ const MAX_LOG_SIZE = 5 * 1024 * 1024; // 5MB
153
+
154
+ let logStream = null;
155
+
156
+ function initLogFile() {
157
+ try {
158
+ fs.mkdirSync(LOG_DIR, { recursive: true });
159
+ // Rotate if too large
160
+ if (fs.existsSync(LOG_FILE)) {
161
+ const stats = fs.statSync(LOG_FILE);
162
+ if (stats.size > MAX_LOG_SIZE) {
163
+ const rotated = `${LOG_FILE}.1`;
164
+ if (fs.existsSync(rotated)) fs.unlinkSync(rotated);
165
+ fs.renameSync(LOG_FILE, rotated);
166
+ }
167
+ }
168
+ logStream = fs.createWriteStream(LOG_FILE, { flags: 'a' });
169
+ } catch {
170
+ // Fall back to console-only logging
171
+ }
172
+ }
173
+
174
+ initLogFile();
175
+
148
176
  function log(msg) {
149
- console.log(`[Electron] ${msg}`);
177
+ const line = `[${new Date().toISOString()}] [Electron] ${msg}`;
178
+ console.log(line);
179
+ if (logStream) {
180
+ logStream.write(line + '\n');
181
+ }
150
182
  }
151
183
 
152
184
  /**
@@ -198,6 +230,7 @@ function getShellEnv() {
198
230
 
199
231
  // Common paths where npm/node might be installed (fallback for users who have it)
200
232
  const additionalPaths = [
233
+ `${home}/.claude/local`, // Official Claude Code install script
201
234
  '/usr/local/bin', // Homebrew (Intel Mac) / common location
202
235
  '/opt/homebrew/bin', // Homebrew (Apple Silicon)
203
236
  '/opt/homebrew/sbin',
@@ -566,7 +599,7 @@ function startTestResultsPolling() {
566
599
 
567
600
  // Ingest results into SQLite before broadcasting
568
601
  try {
569
- const count = ingestCucumberResults(projectRoot);
602
+ const count = ingestCucumberResults(testResultsPath);
570
603
  if (count > 0) {
571
604
  log(`[WS] Ingested ${count} test results into database`);
572
605
  }
@@ -948,7 +981,10 @@ module.exports = {
948
981
  writeLastSelectedProject,
949
982
  installClaudeCode,
950
983
  updateClaudeCode,
951
- getClaudeCodeInstalled
984
+ getClaudeCodeInstalled,
985
+ getClaudeCodeAuthenticated,
986
+ checkClaudeCodeAuthenticated,
987
+ loginClaudeCode
952
988
  };
953
989
 
954
990
  // ==================== Application Menu ====================
@@ -1069,7 +1105,19 @@ function buildApplicationMenu() {
1069
1105
  {
1070
1106
  label: 'Check for Updates',
1071
1107
  click: async () => {
1108
+ const headers = sessionManager.getUpdaterHeaders();
1109
+ if (!headers.Authorization) {
1110
+ dialog.showMessageBox({
1111
+ type: 'warning',
1112
+ title: 'Update Check Unavailable',
1113
+ message: 'Not signed in.',
1114
+ detail: 'Please sign in to check for updates.',
1115
+ buttons: ['OK']
1116
+ });
1117
+ return;
1118
+ }
1072
1119
  log('[AutoUpdate] Manual check for updates...');
1120
+ autoUpdater.requestHeaders = headers;
1073
1121
  try {
1074
1122
  const result = await autoUpdater.checkForUpdates();
1075
1123
  if (result && result.updateInfo) {
@@ -1124,16 +1172,20 @@ function createWindow() {
1124
1172
 
1125
1173
  windows.add(mainWindow);
1126
1174
 
1127
- // Determine start URL based on access code, Claude Code, and project status
1175
+ // Determine start URL based on auth state, Claude Code, and project status
1128
1176
  let startUrl;
1129
- if (isPackaged && !isAccessGranted()) {
1130
- // Access code required - show access code screen (production only)
1131
- startUrl = 'http://localhost:3000/access-code';
1132
- log('Access not granted, showing access code screen');
1177
+ if (isPackaged && !isAuthenticated()) {
1178
+ // Auth required - show login screen (production only)
1179
+ startUrl = 'http://localhost:3000/login';
1180
+ log('No auth token, showing login screen');
1133
1181
  } else if (!claudeCodeInstalled) {
1134
1182
  // Claude Code is required - show install screen
1135
1183
  startUrl = 'http://localhost:3000/install-claude';
1136
1184
  log('Claude Code not installed, showing install screen');
1185
+ } else if (!claudeCodeAuthenticated) {
1186
+ // Claude Code installed but not authenticated - show connect screen
1187
+ startUrl = 'http://localhost:3000/connect-claude';
1188
+ log('Claude Code not authenticated, showing connect screen');
1137
1189
  } else {
1138
1190
  // Check if we have a valid project configured
1139
1191
  const hasProject = hasValidProject(projectRoot);
@@ -1260,8 +1312,10 @@ function cleanup() {
1260
1312
 
1261
1313
  // ==================== Claude Code Detection ====================
1262
1314
 
1263
- // Track whether Claude Code is installed (checked on startup)
1315
+ // Track whether Claude Code is installed and authenticated (checked on startup)
1264
1316
  let claudeCodeInstalled = false;
1317
+ let claudeCodeAuthenticated = false;
1318
+ let claudeCodeNeedsUpdate = false;
1265
1319
 
1266
1320
  /**
1267
1321
  * Check if Claude Code CLI is installed
@@ -1269,12 +1323,14 @@ let claudeCodeInstalled = false;
1269
1323
  * @returns {boolean} true if Claude Code is installed
1270
1324
  */
1271
1325
  function checkClaudeCodeInstalled() {
1326
+ const shellEnv = getShellEnv();
1327
+ log(`[ClaudeCode] Checking with PATH: ${shellEnv.PATH}`);
1272
1328
  try {
1273
- execSync('which claude', { encoding: 'utf-8', stdio: 'pipe', env: getShellEnv() });
1274
- log('[ClaudeCode] Claude Code is installed');
1329
+ const which = execSync('which claude', { encoding: 'utf-8', stdio: 'pipe', env: shellEnv }).trim();
1330
+ log(`[ClaudeCode] Claude Code found at: ${which}`);
1275
1331
  return true;
1276
1332
  } catch {
1277
- log('[ClaudeCode] Claude Code is NOT installed');
1333
+ log('[ClaudeCode] Claude Code is NOT installed (not found in PATH)');
1278
1334
  return false;
1279
1335
  }
1280
1336
  }
@@ -1377,6 +1433,143 @@ function getClaudeCodeInstalled() {
1377
1433
  return claudeCodeInstalled;
1378
1434
  }
1379
1435
 
1436
+ function getClaudeCodeAuthenticated() {
1437
+ return claudeCodeAuthenticated;
1438
+ }
1439
+
1440
+ /**
1441
+ * Check if Claude Code CLI is authenticated.
1442
+ * Runs a lightweight command that requires auth to succeed.
1443
+ * On any unexpected failure, defaults to false (shows connect screen)
1444
+ * so the user can authenticate rather than hitting a broken state.
1445
+ * @returns {boolean} true if Claude Code is authenticated
1446
+ */
1447
+ function checkClaudeCodeAuthenticated() {
1448
+ if (!claudeCodeInstalled) return false;
1449
+ const shellEnv = getShellEnv();
1450
+ try {
1451
+ // claude -p with a simple prompt fails fast if not authenticated
1452
+ execSync('claude -p "ping" --output-format text', {
1453
+ encoding: 'utf-8',
1454
+ stdio: 'pipe',
1455
+ env: shellEnv,
1456
+ timeout: 15000
1457
+ });
1458
+ log('[ClaudeCode] Claude Code is authenticated');
1459
+ return true;
1460
+ } catch (err) {
1461
+ const stderr = (err.stderr || '').toString();
1462
+ const stdout = (err.stdout || '').toString();
1463
+ const message = err.message || '';
1464
+
1465
+ // Timeout — cli hung, don't block startup, show connect screen
1466
+ if (err.killed || message.includes('ETIMEDOUT') || message.includes('timed out')) {
1467
+ log('[ClaudeCode] Auth check timed out, defaulting to unauthenticated');
1468
+ return false;
1469
+ }
1470
+
1471
+ // Outdated CLI — flag for auto-update
1472
+ const combined = stderr + stdout + message;
1473
+ if (combined.includes('needs an update') || combined.includes('needs_update')) {
1474
+ log('[ClaudeCode] CLI is outdated, flagging for auto-update');
1475
+ claudeCodeNeedsUpdate = true;
1476
+ return false;
1477
+ }
1478
+
1479
+ // Auth-related errors — definitely not authenticated
1480
+ if (stderr.includes('not logged in') || stderr.includes('login') ||
1481
+ stdout.includes('not logged in') || stdout.includes('login')) {
1482
+ log('[ClaudeCode] Claude Code is NOT authenticated');
1483
+ return false;
1484
+ }
1485
+
1486
+ // Any other error (network, spawn failure, unexpected crash) —
1487
+ // default to unauthenticated so user sees the connect screen
1488
+ // rather than silently failing in the main app
1489
+ log(`[ClaudeCode] Auth check failed, defaulting to unauthenticated: ${stderr || stdout || message}`);
1490
+ return false;
1491
+ }
1492
+ }
1493
+
1494
+ /**
1495
+ * Spawn `claude` to trigger browser-based OAuth authentication.
1496
+ * The CLI auto-detects missing credentials and starts the OAuth flow,
1497
+ * printing an auth URL to stdout/stderr. We capture it and open it
1498
+ * with shell.openExternal() since the spawned process has no TTY.
1499
+ * @returns {Promise<{success: boolean, error?: string}>}
1500
+ */
1501
+ function loginClaudeCode() {
1502
+ return new Promise((resolve) => {
1503
+ const shellEnv = getShellEnv();
1504
+ log('[ClaudeCode] Starting claude auth flow...');
1505
+
1506
+ const loginProcess = spawn(getUserShell(), ['-lc', 'claude'], {
1507
+ env: shellEnv,
1508
+ stdio: 'pipe'
1509
+ });
1510
+
1511
+ let stdout = '';
1512
+ let stderr = '';
1513
+ let urlOpened = false;
1514
+
1515
+ const tryOpenUrl = (text) => {
1516
+ if (urlOpened) return;
1517
+ // Match OAuth/auth URLs the CLI prints for non-TTY environments
1518
+ const urlMatch = text.match(/https?:\/\/\S+/);
1519
+ if (urlMatch) {
1520
+ const url = urlMatch[0];
1521
+ log(`[ClaudeCode] Found auth URL, opening in browser: ${url}`);
1522
+ shell.openExternal(url);
1523
+ urlOpened = true;
1524
+ }
1525
+ };
1526
+
1527
+ loginProcess.stdout.on('data', (data) => {
1528
+ const chunk = data.toString();
1529
+ stdout += chunk;
1530
+ log(`[ClaudeCode] stdout: ${chunk.trim()}`);
1531
+ tryOpenUrl(chunk);
1532
+ });
1533
+
1534
+ loginProcess.stderr.on('data', (data) => {
1535
+ const chunk = data.toString();
1536
+ stderr += chunk;
1537
+ log(`[ClaudeCode] stderr: ${chunk.trim()}`);
1538
+ tryOpenUrl(chunk);
1539
+ });
1540
+
1541
+ loginProcess.on('close', async (code) => {
1542
+ if (code === 0) {
1543
+ claudeCodeAuthenticated = true;
1544
+ log('[ClaudeCode] Auth completed successfully');
1545
+ resolve({ success: true });
1546
+ } else if (stderr.includes('needs an update') || stderr.includes('needs_update') ||
1547
+ stdout.includes('needs an update') || stdout.includes('needs_update')) {
1548
+ log('[ClaudeCode] CLI outdated during login, running auto-update...');
1549
+ const updateResult = await updateClaudeCode();
1550
+ if (updateResult.success) {
1551
+ log('[ClaudeCode] Auto-update succeeded, retrying login');
1552
+ claudeCodeNeedsUpdate = false;
1553
+ // Retry login after update
1554
+ const retryResult = await loginClaudeCode();
1555
+ resolve(retryResult);
1556
+ } else {
1557
+ log(`[ClaudeCode] Auto-update failed: ${updateResult.error}`);
1558
+ resolve({ success: false, error: 'Claude Code needs an update but the update failed. Please run "claude update" manually.' });
1559
+ }
1560
+ } else {
1561
+ log(`[ClaudeCode] Auth failed with code ${code}: ${stderr}`);
1562
+ resolve({ success: false, error: stderr || `Auth exited with code ${code}` });
1563
+ }
1564
+ });
1565
+
1566
+ loginProcess.on('error', (err) => {
1567
+ log(`[ClaudeCode] Auth spawn error: ${err.message}`);
1568
+ resolve({ success: false, error: err.message });
1569
+ });
1570
+ });
1571
+ }
1572
+
1380
1573
  // ==================== PATH Setup ====================
1381
1574
 
1382
1575
  const SYMLINK_PATH = '/usr/local/bin/jettypod';
@@ -1421,8 +1614,11 @@ async function setupPathOnFirstLaunch() {
1421
1614
 
1422
1615
  log('[PATH] Setting up PATH with admin privileges...');
1423
1616
 
1424
- // Use osascript to run ln with admin privileges
1425
- const script = `do shell script "ln -sf '${bundledPath}' '${SYMLINK_PATH}'" with administrator privileges`;
1617
+ // Use osascript to run ln with admin privileges.
1618
+ // mkdir -p ensures the parent directory exists (e.g. /usr/local/bin may not
1619
+ // exist on fresh Apple Silicon Macs which use /opt/homebrew/bin by default).
1620
+ const symlinkDir = path.dirname(SYMLINK_PATH);
1621
+ const script = `do shell script "mkdir -p '${symlinkDir}' && ln -sf '${bundledPath}' '${SYMLINK_PATH}'" with administrator privileges`;
1426
1622
 
1427
1623
  try {
1428
1624
  execSync(`osascript -e '${script}'`, { encoding: 'utf-8' });
@@ -1433,35 +1629,84 @@ async function setupPathOnFirstLaunch() {
1433
1629
  }
1434
1630
  }
1435
1631
 
1436
- // ==================== Access Code Gating ====================
1632
+ // ==================== Subscription & Access ====================
1437
1633
 
1438
1634
  /**
1439
- * Get path to access-granted.json file
1635
+ * Get path to subscription.json file
1440
1636
  * Stored in Electron's userData directory (~/.config/JettyPod/)
1441
1637
  */
1442
- function getAccessGrantedPath() {
1443
- return path.join(app.getPath('userData'), 'access-granted.json');
1638
+ function getSubscriptionPath() {
1639
+ return path.join(app.getPath('userData'), 'subscription.json');
1640
+ }
1641
+
1642
+ /**
1643
+ * Get the path to the auth state file.
1644
+ * Stored alongside subscription.json in Electron's userData directory.
1645
+ */
1646
+ function getAuthPath() {
1647
+ return path.join(app.getPath('userData'), 'auth.json');
1648
+ }
1649
+
1650
+ /**
1651
+ * Check if there's a stored auth token (JWT).
1652
+ * Does NOT validate the token — just checks if auth.json exists with a token.
1653
+ */
1654
+ function isAuthenticated() {
1655
+ const authPath = getAuthPath();
1656
+ if (!fs.existsSync(authPath)) {
1657
+ log('[Auth] No auth.json found');
1658
+ return false;
1659
+ }
1660
+
1661
+ try {
1662
+ const data = JSON.parse(fs.readFileSync(authPath, 'utf-8'));
1663
+ if (data.token) {
1664
+ log('[Auth] Auth token found');
1665
+ return true;
1666
+ }
1667
+ } catch (error) {
1668
+ log(`[Auth] Error reading auth file: ${error.message}`);
1669
+ }
1670
+
1671
+ return false;
1672
+ }
1673
+
1674
+ /**
1675
+ * Read the Stripe customer ID from subscription config.
1676
+ * Returns null if no subscription is stored.
1677
+ * Written by the Stripe checkout flow (chore #1000535).
1678
+ */
1679
+ function getSubscriptionCustomerId() {
1680
+ const subPath = getSubscriptionPath();
1681
+ if (!fs.existsSync(subPath)) return null;
1682
+ try {
1683
+ const data = JSON.parse(fs.readFileSync(subPath, 'utf-8'));
1684
+ return data.customerId || null;
1685
+ } catch (error) {
1686
+ log(`[Subscription] Error reading subscription file: ${error.message}`);
1687
+ return null;
1688
+ }
1444
1689
  }
1445
1690
 
1446
1691
  /**
1447
- * Check if app access has been granted via access code
1448
- * @returns {boolean} True if access has been granted
1692
+ * Check if there's an active subscription stored locally.
1693
+ * Full validation against Stripe happens on update check / app refresh.
1449
1694
  */
1450
- function isAccessGranted() {
1451
- const accessPath = getAccessGrantedPath();
1452
- if (!fs.existsSync(accessPath)) {
1453
- log('[Access] No access-granted.json found');
1695
+ function isSubscriptionActive() {
1696
+ const subPath = getSubscriptionPath();
1697
+ if (!fs.existsSync(subPath)) {
1698
+ log('[Subscription] No subscription.json found');
1454
1699
  return false;
1455
1700
  }
1456
1701
 
1457
1702
  try {
1458
- const data = JSON.parse(fs.readFileSync(accessPath, 'utf-8'));
1459
- if (data.activated) {
1460
- log('[Access] Access granted');
1703
+ const data = JSON.parse(fs.readFileSync(subPath, 'utf-8'));
1704
+ if (data.customerId) {
1705
+ log('[Subscription] Active subscription found');
1461
1706
  return true;
1462
1707
  }
1463
1708
  } catch (error) {
1464
- log(`[Access] Error reading access file: ${error.message}`);
1709
+ log(`[Subscription] Error reading subscription file: ${error.message}`);
1465
1710
  }
1466
1711
 
1467
1712
  return false;
@@ -1652,18 +1897,120 @@ autoUpdater.on('update-downloaded', (info) => {
1652
1897
 
1653
1898
  // ==================== App Lifecycle ====================
1654
1899
 
1900
+ // Register jettypod:// protocol for OAuth callback
1901
+ if (!isPackaged) {
1902
+ // Dev mode: pass app path so macOS can route the URL to this Electron instance
1903
+ app.setAsDefaultProtocolClient('jettypod', process.execPath, [path.resolve(process.argv[1])]);
1904
+ } else {
1905
+ app.setAsDefaultProtocolClient('jettypod');
1906
+ }
1907
+
1908
+ /**
1909
+ * Decode a JWT payload without verifying the signature.
1910
+ * Used to extract user info (email, plan) from the token before saving.
1911
+ */
1912
+ function decodeJWTPayload(token) {
1913
+ try {
1914
+ const parts = token.split('.');
1915
+ if (parts.length !== 3) return null;
1916
+ const payload = parts[1].replace(/-/g, '+').replace(/_/g, '/');
1917
+ return JSON.parse(Buffer.from(payload, 'base64').toString('utf-8'));
1918
+ } catch {
1919
+ return null;
1920
+ }
1921
+ }
1922
+
1923
+ /**
1924
+ * Handle a jettypod:// protocol URL (OAuth callback).
1925
+ * Saves the token + decoded user info to auth.json and navigates to the dashboard.
1926
+ */
1927
+ function handleProtocolUrl(url) {
1928
+ log(`[Auth] Protocol handler received: ${url}`);
1929
+
1930
+ try {
1931
+ const parsed = new URL(url);
1932
+ // jettypod://auth/callback parses as hostname='auth', pathname='/callback'
1933
+ const isAuthCallback =
1934
+ (parsed.hostname === 'auth' && parsed.pathname === '/callback') ||
1935
+ parsed.pathname === '//auth/callback' ||
1936
+ parsed.pathname === '/auth/callback';
1937
+ if (isAuthCallback) {
1938
+ const token = parsed.searchParams.get('token');
1939
+ if (token && mainWindow) {
1940
+ // Decode JWT to extract user info (sub, email, plan)
1941
+ const payload = decodeJWTPayload(token);
1942
+ const user = payload ? { id: payload.sub, email: payload.email, plan: payload.plan } : undefined;
1943
+
1944
+ // Save auth.json with token AND user data
1945
+ const authPath = path.join(app.getPath('userData'), 'auth.json');
1946
+ const dir = path.dirname(authPath);
1947
+ if (!fs.existsSync(dir)) {
1948
+ fs.mkdirSync(dir, { recursive: true });
1949
+ }
1950
+ fs.writeFileSync(authPath, JSON.stringify({ token, user, savedAt: new Date().toISOString() }, null, 2));
1951
+ log('[Auth] Token + user data saved from OAuth callback');
1952
+
1953
+ // Navigate to main page
1954
+ mainWindow.loadURL('http://localhost:3000');
1955
+ mainWindow.focus();
1956
+ }
1957
+ }
1958
+ } catch (error) {
1959
+ log(`[Auth] Error handling protocol URL: ${error.message}`);
1960
+ }
1961
+ }
1962
+
1963
+ // Single-instance lock: ensure deep links are routed to the existing instance
1964
+ const gotTheLock = app.requestSingleInstanceLock();
1965
+
1966
+ if (!gotTheLock) {
1967
+ // Another instance is already running — quit this one.
1968
+ // The URL (if any) will be forwarded to the existing instance via 'second-instance'.
1969
+ app.quit();
1970
+ } else {
1971
+ // On macOS, protocol URLs for a running app arrive via 'open-url'.
1972
+ app.on('open-url', (event, url) => {
1973
+ event.preventDefault();
1974
+ handleProtocolUrl(url);
1975
+ });
1976
+
1977
+ // On Windows/Linux (and macOS second-instance fallback), the URL arrives here.
1978
+ app.on('second-instance', (_event, commandLine) => {
1979
+ // The protocol URL is typically the last argument
1980
+ const url = commandLine.find(arg => arg.startsWith('jettypod://'));
1981
+ if (url) {
1982
+ handleProtocolUrl(url);
1983
+ }
1984
+ // Focus the main window
1985
+ if (mainWindow) {
1986
+ if (mainWindow.isMinimized()) mainWindow.restore();
1987
+ mainWindow.focus();
1988
+ }
1989
+ });
1990
+ }
1991
+
1655
1992
  app.whenReady().then(async () => {
1656
1993
  log('Electron app ready');
1657
1994
 
1995
+ // Start session manager (handles auth heartbeat + auto-updater token)
1996
+ sessionManager.setLogger(log);
1997
+ if (isAuthenticated()) {
1998
+ sessionManager.start();
1999
+ }
2000
+
1658
2001
  // Check for updates in the background (only in packaged app)
1659
- if (isPackaged) {
1660
- log('[AutoUpdate] Checking for updates...');
1661
- try {
1662
- await autoUpdater.checkForUpdatesAndNotify();
1663
- } catch (error) {
1664
- // Log error but don't crash - auto-update failures shouldn't prevent app launch
1665
- console.error('[AutoUpdate] Failed to check for updates:', error.message);
1666
- log(`[AutoUpdate] Update check failed: ${error.message}`);
2002
+ if (isPackaged && isAuthenticated()) {
2003
+ const headers = sessionManager.getUpdaterHeaders();
2004
+ if (headers.Authorization) {
2005
+ autoUpdater.requestHeaders = headers;
2006
+ log('[AutoUpdate] Checking for updates with JWT...');
2007
+ try {
2008
+ await autoUpdater.checkForUpdatesAndNotify();
2009
+ } catch (error) {
2010
+ log(`[AutoUpdate] Update check failed: ${error.message}`);
2011
+ }
2012
+ } else {
2013
+ log('[AutoUpdate] Skipping update check - no auth token');
1667
2014
  }
1668
2015
  }
1669
2016
 
@@ -1672,6 +2019,37 @@ app.whenReady().then(async () => {
1672
2019
  claudeCodeInstalled = checkClaudeCodeInstalled();
1673
2020
  log(`Claude Code installed: ${claudeCodeInstalled}`);
1674
2021
 
2022
+ // Check if Claude Code is authenticated (only if installed)
2023
+ // Wrapped in try/catch so a catastrophic failure here doesn't crash startup
2024
+ if (claudeCodeInstalled) {
2025
+ try {
2026
+ claudeCodeAuthenticated = checkClaudeCodeAuthenticated();
2027
+ } catch (err) {
2028
+ log(`[ClaudeCode] Auth check crashed unexpectedly: ${err.message}`);
2029
+ claudeCodeAuthenticated = false;
2030
+ }
2031
+
2032
+ // Auto-update if CLI is outdated, then retry auth check
2033
+ if (!claudeCodeAuthenticated && claudeCodeNeedsUpdate) {
2034
+ log('[ClaudeCode] CLI outdated, running auto-update...');
2035
+ const updateResult = await updateClaudeCode();
2036
+ if (updateResult.success) {
2037
+ log('[ClaudeCode] Auto-update succeeded, retrying auth check');
2038
+ claudeCodeNeedsUpdate = false;
2039
+ try {
2040
+ claudeCodeAuthenticated = checkClaudeCodeAuthenticated();
2041
+ } catch (err) {
2042
+ log(`[ClaudeCode] Auth re-check crashed: ${err.message}`);
2043
+ claudeCodeAuthenticated = false;
2044
+ }
2045
+ } else {
2046
+ log(`[ClaudeCode] Auto-update failed: ${updateResult.error}`);
2047
+ }
2048
+ }
2049
+
2050
+ log(`Claude Code authenticated: ${claudeCodeAuthenticated}`);
2051
+ }
2052
+
1675
2053
  // Setup PATH on first launch (creates symlink to /usr/local/bin)
1676
2054
  await setupPathOnFirstLaunch();
1677
2055
 
@@ -46,6 +46,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
46
46
 
47
47
  // Project info
48
48
  getProjectName: () => ipcRenderer.invoke('db:getProjectName'),
49
+
49
50
  },
50
51
 
51
52
  // Claude subprocess management
@@ -76,6 +77,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
76
77
  // Project operations
77
78
  project: {
78
79
  openDialog: () => ipcRenderer.invoke('dialog:openProject'),
80
+ newProject: () => ipcRenderer.invoke('dialog:newProject'),
79
81
  getRecent: () => ipcRenderer.invoke('projects:getRecent'),
80
82
  addRecent: (path) => ipcRenderer.invoke('projects:addRecent', path),
81
83
  openRecent: (path) => ipcRenderer.invoke('projects:openRecent', path),
@@ -85,13 +87,30 @@ contextBridge.exposeInMainWorld('electronAPI', {
85
87
  claudeCode: {
86
88
  install: () => ipcRenderer.invoke('claudeCode:install'),
87
89
  isInstalled: () => ipcRenderer.invoke('claudeCode:isInstalled'),
90
+ isAuthenticated: () => ipcRenderer.invoke('claudeCode:isAuthenticated'),
91
+ login: () => ipcRenderer.invoke('claudeCode:login'),
88
92
  update: () => ipcRenderer.invoke('claudeCode:update'),
89
93
  },
90
94
 
91
- // Access code gating
92
- access: {
93
- validate: (code) => ipcRenderer.invoke('access:validate', code),
94
- getStatus: () => ipcRenderer.invoke('access:getStatus'),
95
+ // Subscription gating (legacy)
96
+ subscription: {
97
+ createCheckout: (plan) => ipcRenderer.invoke('subscription:createCheckout', plan),
98
+ activate: (customerId) => ipcRenderer.invoke('subscription:activate', customerId),
99
+ getStatus: () => ipcRenderer.invoke('subscription:getStatus'),
100
+ },
101
+
102
+ // Billing
103
+ billing: {
104
+ openCustomerPortal: () => ipcRenderer.invoke('billing:openCustomerPortal'),
105
+ },
106
+
107
+ // Auth (JWT-based)
108
+ auth: {
109
+ loginWithGoogle: () => ipcRenderer.invoke('auth:loginWithGoogle'),
110
+ saveToken: (token, user) => ipcRenderer.invoke('auth:saveToken', token, user),
111
+ getStatus: () => ipcRenderer.invoke('auth:getStatus'),
112
+ getToken: () => ipcRenderer.invoke('auth:getToken'),
113
+ logout: () => ipcRenderer.invoke('auth:logout'),
95
114
  },
96
115
 
97
116
  // Shell operations