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.
- package/.env +7 -0
- package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +25 -9
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +7 -3
- package/apps/dashboard/app/api/tests/run/stream/route.ts +13 -1
- package/apps/dashboard/app/api/usage/route.ts +17 -0
- package/apps/dashboard/app/connect-claude/page.tsx +24 -0
- package/apps/dashboard/app/install-claude/page.tsx +8 -6
- package/apps/dashboard/app/login/page.tsx +229 -0
- package/apps/dashboard/app/page.tsx +5 -3
- package/apps/dashboard/app/settings/page.tsx +2 -0
- package/apps/dashboard/app/subscribe/page.tsx +11 -0
- package/apps/dashboard/app/welcome/page.tsx +23 -0
- package/apps/dashboard/components/AppShell.tsx +51 -9
- package/apps/dashboard/components/CardMenu.tsx +14 -5
- package/apps/dashboard/components/ClaudePanel.tsx +65 -9
- package/apps/dashboard/components/ConnectClaudeScreen.tsx +223 -0
- package/apps/dashboard/components/DragContext.tsx +73 -64
- package/apps/dashboard/components/DraggableCard.tsx +6 -46
- package/apps/dashboard/components/GateCard.tsx +21 -0
- package/apps/dashboard/components/InstallClaudeScreen.tsx +132 -30
- package/apps/dashboard/components/KanbanBoard.tsx +173 -56
- package/apps/dashboard/components/PlaceholderCard.tsx +9 -19
- package/apps/dashboard/components/ProjectSwitcher.tsx +28 -0
- package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +34 -3
- package/apps/dashboard/components/RealTimeTestsWrapper.tsx +30 -2
- package/apps/dashboard/components/SubscribeContent.tsx +191 -0
- package/apps/dashboard/components/TipCard.tsx +176 -0
- package/apps/dashboard/components/UpgradeBanner.tsx +29 -0
- package/apps/dashboard/components/WelcomeScreen.tsx +14 -4
- package/apps/dashboard/components/settings/AccountSection.tsx +163 -0
- package/apps/dashboard/contexts/ClaudeSessionContext.tsx +292 -29
- package/apps/dashboard/contexts/UsageContext.tsx +131 -0
- package/apps/dashboard/contexts/usageHelpers.js +9 -0
- package/apps/dashboard/electron/ipc-handlers.js +220 -114
- package/apps/dashboard/electron/main.js +415 -37
- package/apps/dashboard/electron/preload.js +23 -4
- package/apps/dashboard/electron/session-manager.js +141 -0
- package/apps/dashboard/electron-builder.config.js +3 -5
- package/apps/dashboard/lib/claude-process-manager.ts +6 -4
- package/apps/dashboard/lib/db-bridge.ts +32 -0
- package/apps/dashboard/lib/db.ts +159 -13
- package/apps/dashboard/lib/session-state-machine.ts +3 -0
- package/apps/dashboard/lib/session-stream-manager.ts +76 -13
- package/apps/dashboard/lib/tests.ts +3 -1
- package/apps/dashboard/next.config.js +19 -14
- package/apps/dashboard/package.json +3 -1
- package/apps/dashboard/scripts/upload-to-r2.js +89 -0
- package/apps/dashboard/tsconfig.tsbuildinfo +1 -0
- package/apps/update-server/package.json +16 -0
- package/apps/update-server/schema.sql +31 -0
- package/apps/update-server/src/index.ts +1074 -0
- package/apps/update-server/tsconfig.json +16 -0
- package/apps/update-server/wrangler.toml +35 -0
- package/docs/bdd-guidance.md +390 -0
- package/jettypod.js +5 -4
- package/lib/migrations/027-plan-at-creation-column.js +31 -0
- package/lib/migrations/028-ready-for-review-column.js +27 -0
- package/lib/schema.js +3 -1
- package/lib/seed-onboarding.js +100 -68
- package/lib/work-commands/index.js +43 -13
- package/lib/work-tracking/index.js +46 -27
- package/package.json +1 -1
- package/skills-templates/bug-mode/SKILL.md +5 -11
- package/skills-templates/request-routing/SKILL.md +24 -11
- package/skills-templates/simple-improvement/SKILL.md +35 -19
- package/skills-templates/stable-mode/SKILL.md +5 -6
- package/templates/bdd-guidance.md +139 -0
- package/templates/bdd-scaffolding/wait.js +18 -0
- package/templates/bdd-scaffolding/world.js +19 -0
- package/.jettypod-backup/work.db +0 -0
- package/apps/dashboard/app/access-code/page.tsx +0 -110
- package/lib/discovery-checkpoint.js +0 -123
- 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
|
-
|
|
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(
|
|
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
|
|
1175
|
+
// Determine start URL based on auth state, Claude Code, and project status
|
|
1128
1176
|
let startUrl;
|
|
1129
|
-
if (isPackaged && !
|
|
1130
|
-
//
|
|
1131
|
-
startUrl = 'http://localhost:3000/
|
|
1132
|
-
log('
|
|
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:
|
|
1274
|
-
log(
|
|
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
|
-
|
|
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
|
-
// ====================
|
|
1632
|
+
// ==================== Subscription & Access ====================
|
|
1437
1633
|
|
|
1438
1634
|
/**
|
|
1439
|
-
* Get path to
|
|
1635
|
+
* Get path to subscription.json file
|
|
1440
1636
|
* Stored in Electron's userData directory (~/.config/JettyPod/)
|
|
1441
1637
|
*/
|
|
1442
|
-
function
|
|
1443
|
-
return path.join(app.getPath('userData'), '
|
|
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
|
|
1448
|
-
*
|
|
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
|
|
1451
|
-
const
|
|
1452
|
-
if (!fs.existsSync(
|
|
1453
|
-
log('[
|
|
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(
|
|
1459
|
-
if (data.
|
|
1460
|
-
log('[
|
|
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(`[
|
|
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
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
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
|
-
//
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|