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.
- package/.env +7 -0
- package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +124 -48
- package/apps/dashboard/app/api/claude/[workItemId]/route.ts +171 -58
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +161 -10
- 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/api/work/[id]/route.ts +35 -0
- package/apps/dashboard/app/api/work/[id]/status/route.ts +43 -1
- package/apps/dashboard/app/connect-claude/page.tsx +24 -0
- package/apps/dashboard/app/decision/[id]/page.tsx +14 -14
- package/apps/dashboard/app/demo/gates/page.tsx +42 -42
- package/apps/dashboard/app/design-system/page.tsx +868 -0
- package/apps/dashboard/app/globals.css +6 -2
- package/apps/dashboard/app/install-claude/page.tsx +9 -7
- package/apps/dashboard/app/layout.tsx +17 -5
- package/apps/dashboard/app/login/page.tsx +250 -0
- package/apps/dashboard/app/page.tsx +11 -9
- package/apps/dashboard/app/settings/page.tsx +4 -2
- package/apps/dashboard/app/signup/page.tsx +245 -0
- package/apps/dashboard/app/subscribe/page.tsx +11 -0
- package/apps/dashboard/app/welcome/page.tsx +24 -1
- package/apps/dashboard/app/work/[id]/page.tsx +34 -50
- package/apps/dashboard/components/AppShell.tsx +95 -55
- package/apps/dashboard/components/CardMenu.tsx +56 -13
- package/apps/dashboard/components/ClaudePanel.tsx +301 -582
- package/apps/dashboard/components/ClaudePanelInput.tsx +23 -14
- package/apps/dashboard/components/ConnectClaudeScreen.tsx +210 -0
- package/apps/dashboard/components/CopyableId.tsx +3 -3
- package/apps/dashboard/components/DetailReviewActions.tsx +109 -0
- package/apps/dashboard/components/DragContext.tsx +75 -65
- package/apps/dashboard/components/DraggableCard.tsx +6 -46
- package/apps/dashboard/components/DropZone.tsx +2 -2
- package/apps/dashboard/components/EditableDetailDescription.tsx +1 -1
- package/apps/dashboard/components/EditableTitle.tsx +26 -6
- package/apps/dashboard/components/ElapsedTimer.tsx +54 -0
- package/apps/dashboard/components/EpicGroup.tsx +329 -0
- package/apps/dashboard/components/GateCard.tsx +100 -16
- package/apps/dashboard/components/GateChoiceCard.tsx +15 -17
- package/apps/dashboard/components/InstallClaudeScreen.tsx +140 -51
- package/apps/dashboard/components/JettyLoader.tsx +38 -0
- package/apps/dashboard/components/KanbanBoard.tsx +147 -766
- package/apps/dashboard/components/KanbanCard.tsx +506 -0
- package/apps/dashboard/components/LazyMarkdown.tsx +12 -0
- package/apps/dashboard/components/MainNav.tsx +20 -54
- package/apps/dashboard/components/MessageBlock.tsx +391 -0
- package/apps/dashboard/components/ModeStartCard.tsx +15 -15
- package/apps/dashboard/components/OnboardingWelcome.tsx +214 -0
- package/apps/dashboard/components/PlaceholderCard.tsx +11 -21
- package/apps/dashboard/components/ProjectSwitcher.tsx +36 -8
- package/apps/dashboard/components/PrototypeTimeline.tsx +25 -25
- package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +265 -301
- package/apps/dashboard/components/RealTimeTestsWrapper.tsx +97 -74
- package/apps/dashboard/components/ReviewFooter.tsx +141 -0
- package/apps/dashboard/components/SessionList.tsx +19 -18
- package/apps/dashboard/components/SubscribeContent.tsx +206 -0
- package/apps/dashboard/components/TestTree.tsx +15 -14
- package/apps/dashboard/components/TipCard.tsx +177 -0
- package/apps/dashboard/components/Toast.tsx +5 -5
- package/apps/dashboard/components/TypeIcon.tsx +56 -0
- package/apps/dashboard/components/UpgradeBanner.tsx +30 -0
- package/apps/dashboard/components/WaveCompletionAnimation.tsx +61 -62
- package/apps/dashboard/components/WelcomeScreen.tsx +25 -27
- package/apps/dashboard/components/WorkItemHeader.tsx +4 -4
- package/apps/dashboard/components/WorkItemTree.tsx +9 -28
- package/apps/dashboard/components/settings/AccountSection.tsx +169 -0
- package/apps/dashboard/components/settings/EnvVarsSection.tsx +54 -79
- package/apps/dashboard/components/settings/GeneralSection.tsx +26 -31
- package/apps/dashboard/components/settings/SettingsLayout.tsx +4 -4
- package/apps/dashboard/components/ui/Button.tsx +104 -0
- package/apps/dashboard/components/ui/Input.tsx +78 -0
- package/apps/dashboard/contexts/ClaudeSessionContext.tsx +408 -105
- package/apps/dashboard/contexts/ConnectionStatusContext.tsx +25 -4
- package/apps/dashboard/contexts/UsageContext.tsx +155 -0
- package/apps/dashboard/contexts/usageHelpers.js +9 -0
- package/apps/dashboard/electron/ipc-handlers.js +281 -88
- package/apps/dashboard/electron/main.js +691 -131
- package/apps/dashboard/electron/preload.js +25 -4
- package/apps/dashboard/electron/session-manager.js +163 -0
- package/apps/dashboard/electron-builder.config.js +3 -5
- package/apps/dashboard/hooks/useKanbanAnimation.ts +29 -0
- package/apps/dashboard/hooks/useKanbanUndo.ts +83 -0
- package/apps/dashboard/lib/backlog-parser.ts +50 -0
- package/apps/dashboard/lib/claude-process-manager.ts +50 -11
- package/apps/dashboard/lib/constants.ts +43 -0
- package/apps/dashboard/lib/db-bridge.ts +33 -0
- package/apps/dashboard/lib/db.ts +136 -20
- package/apps/dashboard/lib/kanban-utils.ts +70 -0
- package/apps/dashboard/lib/run-migrations.js +27 -2
- package/apps/dashboard/lib/session-state-machine.ts +3 -0
- package/apps/dashboard/lib/session-stream-manager.ts +144 -38
- package/apps/dashboard/lib/shadows.ts +7 -0
- package/apps/dashboard/lib/tests.ts +3 -1
- package/apps/dashboard/lib/utils.ts +6 -0
- package/apps/dashboard/next.config.js +35 -14
- package/apps/dashboard/package.json +6 -3
- package/apps/dashboard/public/bug-icon.svg +9 -0
- package/apps/dashboard/public/buoy-icon.svg +9 -0
- package/apps/dashboard/public/fonts/Satoshi-Variable.woff2 +0 -0
- package/apps/dashboard/public/fonts/Satoshi-VariableItalic.woff2 +0 -0
- package/apps/dashboard/public/in-flight-seagull.svg +9 -0
- package/apps/dashboard/public/jetty-icon-loading-alt.svg +11 -0
- package/apps/dashboard/public/jetty-icon-loading.svg +11 -0
- package/apps/dashboard/public/jettypod_logo.png +0 -0
- package/apps/dashboard/public/pier-icon.svg +14 -0
- package/apps/dashboard/public/star-icon.svg +9 -0
- package/apps/dashboard/public/wrench-icon.svg +9 -0
- package/apps/dashboard/scripts/upload-to-r2.js +89 -0
- package/apps/dashboard/scripts/ws-server.js +191 -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 +1085 -0
- package/apps/update-server/tsconfig.json +16 -0
- package/apps/update-server/wrangler.toml +35 -0
- package/cucumber.js +9 -3
- package/docs/COMMAND_REFERENCE.md +34 -0
- package/hooks/post-checkout +32 -75
- package/hooks/post-merge +111 -10
- package/jest.setup.js +1 -0
- package/jettypod.js +54 -116
- package/lib/chore-taxonomy.js +33 -10
- package/lib/database.js +36 -16
- package/lib/db-watcher.js +1 -1
- package/lib/git-hooks/pre-commit +1 -1
- package/lib/jettypod-backup.js +27 -4
- package/lib/migrations/027-plan-at-creation-column.js +33 -0
- package/lib/migrations/028-ready-for-review-column.js +27 -0
- package/lib/migrations/029-remove-autoincrement.js +307 -0
- package/lib/migrations/029-rename-corrupted-to-cleaned.js +149 -0
- package/lib/migrations/index.js +47 -4
- package/lib/schema.js +13 -6
- package/lib/seed-onboarding.js +101 -69
- package/lib/update-command/index.js +9 -175
- package/lib/work-commands/index.js +129 -16
- package/lib/work-tracking/index.js +86 -46
- package/lib/worktree-diagnostics.js +16 -16
- package/lib/worktree-facade.js +1 -1
- package/lib/worktree-manager.js +8 -8
- package/lib/worktree-reconciler.js +5 -5
- package/package.json +9 -2
- package/scripts/ndjson-to-cucumber-json.js +152 -0
- package/scripts/postinstall.js +25 -0
- package/skills-templates/bug-mode/SKILL.md +39 -28
- package/skills-templates/bug-planning/SKILL.md +25 -29
- package/skills-templates/chore-mode/SKILL.md +131 -68
- package/skills-templates/chore-mode/verification.js +51 -10
- package/skills-templates/chore-planning/SKILL.md +47 -18
- package/skills-templates/epic-planning/SKILL.md +68 -48
- package/skills-templates/external-transition/SKILL.md +47 -47
- package/skills-templates/feature-planning/SKILL.md +83 -73
- package/skills-templates/production-mode/SKILL.md +49 -49
- package/skills-templates/request-routing/SKILL.md +27 -14
- package/skills-templates/simple-improvement/SKILL.md +68 -44
- package/skills-templates/speed-mode/SKILL.md +209 -128
- package/skills-templates/stable-mode/SKILL.md +105 -94
- 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,5 +1,8 @@
|
|
|
1
|
-
const { app, BrowserWindow, Menu, dialog } = require('electron');
|
|
2
|
-
const { spawn,
|
|
1
|
+
const { app, BrowserWindow, Menu, dialog, shell, ipcMain } = require('electron');
|
|
2
|
+
const { spawn, exec, execFile } = require('child_process');
|
|
3
|
+
const { promisify } = require('util');
|
|
4
|
+
const execAsync = promisify(exec);
|
|
5
|
+
const execFileAsync = promisify(execFile);
|
|
3
6
|
const path = require('path');
|
|
4
7
|
const fs = require('fs');
|
|
5
8
|
const http = require('http');
|
|
@@ -7,6 +10,7 @@ const { WebSocketServer } = require('ws');
|
|
|
7
10
|
const { registerIpcHandlers, closeDb } = require('./ipc-handlers');
|
|
8
11
|
const { ingestCucumberResults, closeIngesterDb } = require('./test-results-ingester');
|
|
9
12
|
const { autoUpdater } = require('electron-updater');
|
|
13
|
+
const sessionManager = require('./session-manager');
|
|
10
14
|
|
|
11
15
|
// Track child processes and servers for cleanup
|
|
12
16
|
let nextProcess = null;
|
|
@@ -16,8 +20,11 @@ const windows = new Set(); // Track all open windows
|
|
|
16
20
|
let wss = null;
|
|
17
21
|
let dbPollInterval = null;
|
|
18
22
|
let testResultsPollInterval = null;
|
|
23
|
+
let worktreeCleanupInterval = null;
|
|
19
24
|
let lastDbMtimes = { db: null, wal: null };
|
|
20
25
|
let lastTestResultsMtime = null;
|
|
26
|
+
// Map of worktree path → last mtime for cucumber-results.json
|
|
27
|
+
const worktreeTestResultsMtimes = new Map();
|
|
21
28
|
|
|
22
29
|
// Track Claude processes by session ID
|
|
23
30
|
const claudeProcesses = new Map();
|
|
@@ -32,7 +39,9 @@ const isPackaged = app.isPackaged;
|
|
|
32
39
|
// Suppress noisy EGL GPU driver error logs (eglQueryDeviceAttribEXT: Bad attribute)
|
|
33
40
|
app.commandLine.appendSwitch('enable-logging', 'stderr');
|
|
34
41
|
app.commandLine.appendSwitch('log-level', '2');
|
|
35
|
-
|
|
42
|
+
// Disable hardware acceleration to prevent GPU process crashes (exit_code=512)
|
|
43
|
+
// that cause blank white screens. Software rendering is sufficient for this app.
|
|
44
|
+
app.disableHardwareAcceleration();
|
|
36
45
|
|
|
37
46
|
// Last-selected project persistence (for dev mode)
|
|
38
47
|
const LAST_SELECTED_PROJECT_PATH = path.join(
|
|
@@ -90,6 +99,26 @@ function hasValidProject(projectPath) {
|
|
|
90
99
|
return fs.existsSync(dbPath);
|
|
91
100
|
}
|
|
92
101
|
|
|
102
|
+
// Determine the correct start URL based on auth state, Claude Code, and project status.
|
|
103
|
+
// Used both at initial window creation and after OAuth callback.
|
|
104
|
+
function getStartUrl() {
|
|
105
|
+
if (isPackaged && !isAuthenticated()) {
|
|
106
|
+
const authPage = hasLoggedInBefore() ? '/login' : '/signup';
|
|
107
|
+
log(`No auth token, showing ${authPage} screen`);
|
|
108
|
+
return `http://localhost:3000${authPage}`;
|
|
109
|
+
} else if (!claudeCodeInstalled) {
|
|
110
|
+
log('Claude Code not installed, showing install screen');
|
|
111
|
+
return 'http://localhost:3000/install-claude';
|
|
112
|
+
} else if (!claudeCodeAuthenticated) {
|
|
113
|
+
log('Claude Code not authenticated, showing connect screen');
|
|
114
|
+
return 'http://localhost:3000/connect-claude';
|
|
115
|
+
} else {
|
|
116
|
+
const hasProject = hasValidProject(projectRoot);
|
|
117
|
+
log(`Project configured: ${hasProject}, loading: ${hasProject ? '/' : '/welcome'}`);
|
|
118
|
+
return hasProject ? 'http://localhost:3000' : 'http://localhost:3000/welcome';
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
93
122
|
// Set the project root (called when user selects a project)
|
|
94
123
|
async function setProjectRoot(newPath) {
|
|
95
124
|
projectRoot = newPath;
|
|
@@ -145,8 +174,39 @@ function getMainWindow() {
|
|
|
145
174
|
// WebSocket configuration
|
|
146
175
|
const WS_PORT = 47808;
|
|
147
176
|
|
|
177
|
+
// Persistent log file for diagnostics
|
|
178
|
+
const LOG_DIR = path.join(app.getPath('home'), 'Library', 'Logs', 'jettypod');
|
|
179
|
+
const LOG_FILE = path.join(LOG_DIR, 'main.log');
|
|
180
|
+
const MAX_LOG_SIZE = 5 * 1024 * 1024; // 5MB
|
|
181
|
+
|
|
182
|
+
let logStream = null;
|
|
183
|
+
|
|
184
|
+
function initLogFile() {
|
|
185
|
+
try {
|
|
186
|
+
fs.mkdirSync(LOG_DIR, { recursive: true });
|
|
187
|
+
// Rotate if too large
|
|
188
|
+
if (fs.existsSync(LOG_FILE)) {
|
|
189
|
+
const stats = fs.statSync(LOG_FILE);
|
|
190
|
+
if (stats.size > MAX_LOG_SIZE) {
|
|
191
|
+
const rotated = `${LOG_FILE}.1`;
|
|
192
|
+
if (fs.existsSync(rotated)) fs.unlinkSync(rotated);
|
|
193
|
+
fs.renameSync(LOG_FILE, rotated);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
logStream = fs.createWriteStream(LOG_FILE, { flags: 'a' });
|
|
197
|
+
} catch {
|
|
198
|
+
// Fall back to console-only logging
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
initLogFile();
|
|
203
|
+
|
|
148
204
|
function log(msg) {
|
|
149
|
-
|
|
205
|
+
const line = `[${new Date().toISOString()}] [Electron] ${msg}`;
|
|
206
|
+
console.log(line);
|
|
207
|
+
if (logStream) {
|
|
208
|
+
logStream.write(line + '\n');
|
|
209
|
+
}
|
|
150
210
|
}
|
|
151
211
|
|
|
152
212
|
/**
|
|
@@ -198,6 +258,7 @@ function getShellEnv() {
|
|
|
198
258
|
|
|
199
259
|
// Common paths where npm/node might be installed (fallback for users who have it)
|
|
200
260
|
const additionalPaths = [
|
|
261
|
+
`${home}/.claude/local`, // Official Claude Code install script
|
|
201
262
|
'/usr/local/bin', // Homebrew (Intel Mac) / common location
|
|
202
263
|
'/opt/homebrew/bin', // Homebrew (Apple Silicon)
|
|
203
264
|
'/opt/homebrew/sbin',
|
|
@@ -252,7 +313,7 @@ function getShellEnv() {
|
|
|
252
313
|
* - Orphaned processes on port 3000
|
|
253
314
|
* - Orphaned next-router-worker processes
|
|
254
315
|
*/
|
|
255
|
-
function cleanupStaleResources() {
|
|
316
|
+
async function cleanupStaleResources() {
|
|
256
317
|
log('Cleaning up stale resources...');
|
|
257
318
|
|
|
258
319
|
// 1. Remove Next.js lock file if it exists
|
|
@@ -266,63 +327,33 @@ function cleanupStaleResources() {
|
|
|
266
327
|
}
|
|
267
328
|
}
|
|
268
329
|
|
|
269
|
-
//
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
const pids =
|
|
330
|
+
// Helper to kill PIDs from a shell command
|
|
331
|
+
async function killPidsFromCommand(cmd, label) {
|
|
332
|
+
try {
|
|
333
|
+
const { stdout } = await execAsync(cmd, { encoding: 'utf-8' });
|
|
334
|
+
const pids = stdout.trim().split('\n').filter(Boolean);
|
|
274
335
|
for (const pid of pids) {
|
|
336
|
+
if (parseInt(pid, 10) === process.pid) continue;
|
|
275
337
|
try {
|
|
276
338
|
process.kill(parseInt(pid, 10), 'SIGKILL');
|
|
277
|
-
log(`Killed
|
|
339
|
+
log(`Killed ${label}: PID ${pid}`);
|
|
278
340
|
} catch (killErr) {
|
|
279
|
-
// Process may have already exited
|
|
280
341
|
log(`Could not kill PID ${pid}: ${killErr.message}`);
|
|
281
342
|
}
|
|
282
343
|
}
|
|
344
|
+
} catch (err) {
|
|
345
|
+
// Command failed or no processes found - that's fine
|
|
283
346
|
}
|
|
284
|
-
} catch (err) {
|
|
285
|
-
// lsof command failed or no processes found - that's fine
|
|
286
|
-
log(`Port 3000 check: ${err.message || 'no stale processes'}`);
|
|
287
347
|
}
|
|
288
348
|
|
|
349
|
+
// 2. Kill any process using port 3000 (Next.js dev server port)
|
|
350
|
+
await killPidsFromCommand('lsof -ti:3000 2>/dev/null || true', 'stale process on port 3000');
|
|
351
|
+
|
|
289
352
|
// 3. Kill orphaned next-router-worker processes
|
|
290
|
-
|
|
291
|
-
const routerWorkers = execSync('pgrep -f "next-router-worker" 2>/dev/null || true', { encoding: 'utf-8' }).trim();
|
|
292
|
-
if (routerWorkers) {
|
|
293
|
-
const pids = routerWorkers.split('\n').filter(Boolean);
|
|
294
|
-
for (const pid of pids) {
|
|
295
|
-
try {
|
|
296
|
-
process.kill(parseInt(pid, 10), 'SIGKILL');
|
|
297
|
-
log(`Killed orphaned next-router-worker: PID ${pid}`);
|
|
298
|
-
} catch (killErr) {
|
|
299
|
-
log(`Could not kill router worker PID ${pid}: ${killErr.message}`);
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
} catch (err) {
|
|
304
|
-
// No orphaned workers - that's fine
|
|
305
|
-
}
|
|
353
|
+
await killPidsFromCommand('pgrep -f "next-router-worker" 2>/dev/null || true', 'orphaned next-router-worker');
|
|
306
354
|
|
|
307
355
|
// 4. Kill any orphaned node processes running our dev script
|
|
308
|
-
|
|
309
|
-
const devProcesses = execSync(`pgrep -f "node.*next.*dev" 2>/dev/null || true`, { encoding: 'utf-8' }).trim();
|
|
310
|
-
if (devProcesses) {
|
|
311
|
-
const pids = devProcesses.split('\n').filter(Boolean);
|
|
312
|
-
for (const pid of pids) {
|
|
313
|
-
// Don't kill ourselves
|
|
314
|
-
if (parseInt(pid, 10) === process.pid) continue;
|
|
315
|
-
try {
|
|
316
|
-
process.kill(parseInt(pid, 10), 'SIGKILL');
|
|
317
|
-
log(`Killed orphaned Next.js dev process: PID ${pid}`);
|
|
318
|
-
} catch (killErr) {
|
|
319
|
-
log(`Could not kill dev process PID ${pid}: ${killErr.message}`);
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
} catch (err) {
|
|
324
|
-
// No orphaned processes - that's fine
|
|
325
|
-
}
|
|
356
|
+
await killPidsFromCommand('pgrep -f "node.*next.*dev" 2>/dev/null || true', 'orphaned Next.js dev process');
|
|
326
357
|
|
|
327
358
|
log('Stale resource cleanup complete');
|
|
328
359
|
}
|
|
@@ -446,7 +477,7 @@ function startNextServer() {
|
|
|
446
477
|
// (Moved from apps/ws-server/server.js)
|
|
447
478
|
|
|
448
479
|
const wsClients = new Set();
|
|
449
|
-
const DB_POLL_MS =
|
|
480
|
+
const DB_POLL_MS = 500; // Poll database every 500ms (same as lib/db-watcher.js)
|
|
450
481
|
|
|
451
482
|
function broadcastToClients(message) {
|
|
452
483
|
const payload = JSON.stringify(message);
|
|
@@ -526,7 +557,102 @@ function startDatabasePolling() {
|
|
|
526
557
|
}, DB_POLL_MS);
|
|
527
558
|
}
|
|
528
559
|
|
|
560
|
+
// Get active worktree paths from SQLite
|
|
561
|
+
function getActiveWorktreePaths() {
|
|
562
|
+
if (!projectRoot) return [];
|
|
563
|
+
const dbPath = path.join(projectRoot, '.jettypod', 'work.db');
|
|
564
|
+
if (!fs.existsSync(dbPath)) return [];
|
|
565
|
+
try {
|
|
566
|
+
const Database = require('better-sqlite3');
|
|
567
|
+
const db = new Database(dbPath, { readonly: true });
|
|
568
|
+
const rows = db.prepare("SELECT worktree_path FROM worktrees WHERE status = 'active'").all();
|
|
569
|
+
db.close();
|
|
570
|
+
return rows.map(r => r.worktree_path).filter(p => fs.existsSync(p));
|
|
571
|
+
} catch {
|
|
572
|
+
return [];
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// Periodic worktree cleanup — catches orphaned worktrees from done/cancelled items
|
|
577
|
+
const WORKTREE_CLEANUP_MS = 60000; // 60 seconds
|
|
578
|
+
|
|
579
|
+
function startWorktreeCleanup() {
|
|
580
|
+
if (!projectRoot) {
|
|
581
|
+
log('[cleanup] No project selected, skipping worktree cleanup');
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
log('[cleanup] Starting periodic worktree cleanup (every 60s)');
|
|
586
|
+
|
|
587
|
+
worktreeCleanupInterval = setInterval(() => {
|
|
588
|
+
if (!projectRoot) return;
|
|
589
|
+
|
|
590
|
+
const dbPath = path.join(projectRoot, '.jettypod', 'work.db');
|
|
591
|
+
if (!fs.existsSync(dbPath)) return;
|
|
592
|
+
|
|
593
|
+
try {
|
|
594
|
+
const Database = require('better-sqlite3');
|
|
595
|
+
const db = new Database(dbPath, { readonly: true });
|
|
596
|
+
const orphans = db.prepare(`
|
|
597
|
+
SELECT w.id as worktree_id, w.work_item_id, w.worktree_path, wi.status as work_status
|
|
598
|
+
FROM worktrees w
|
|
599
|
+
JOIN work_items wi ON w.work_item_id = wi.id
|
|
600
|
+
WHERE w.status IN ('active', 'merged')
|
|
601
|
+
AND wi.status IN ('done', 'cancelled')
|
|
602
|
+
`).all();
|
|
603
|
+
db.close();
|
|
604
|
+
|
|
605
|
+
if (orphans.length === 0) return;
|
|
606
|
+
|
|
607
|
+
log(`[cleanup] Found ${orphans.length} orphaned worktree(s), running cleanup...`);
|
|
608
|
+
|
|
609
|
+
// Shell out to jettypod CLI for the actual cleanup (non-blocking)
|
|
610
|
+
const cliPath = getBundledCliPath();
|
|
611
|
+
execAsync(`"${cliPath}" work cleanup`, { cwd: projectRoot, timeout: 30000 })
|
|
612
|
+
.then(({ stdout }) => {
|
|
613
|
+
if (stdout && stdout.trim()) {
|
|
614
|
+
log(`[cleanup] ${stdout.trim()}`);
|
|
615
|
+
}
|
|
616
|
+
})
|
|
617
|
+
.catch((err) => {
|
|
618
|
+
log(`[cleanup] Cleanup failed: ${err.message}`);
|
|
619
|
+
});
|
|
620
|
+
} catch {
|
|
621
|
+
// Silently ignore — will retry on next interval
|
|
622
|
+
}
|
|
623
|
+
}, WORKTREE_CLEANUP_MS);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// Check a single cucumber-results.json path for changes, ingest + broadcast if changed
|
|
627
|
+
function checkTestResultsFile(resultsPath, mtimeKey, getMtime, setMtime) {
|
|
628
|
+
if (!fs.existsSync(resultsPath)) {
|
|
629
|
+
if (getMtime() !== null) setMtime(null);
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
const stats = fs.statSync(resultsPath);
|
|
634
|
+
if (stats.mtimeMs !== getMtime()) {
|
|
635
|
+
setMtime(stats.mtimeMs);
|
|
636
|
+
log(`[WS] Test results changed: ${resultsPath}`);
|
|
637
|
+
|
|
638
|
+
try {
|
|
639
|
+
const count = ingestCucumberResults(projectRoot, resultsPath);
|
|
640
|
+
if (count > 0) {
|
|
641
|
+
log(`[WS] Ingested ${count} test results into database`);
|
|
642
|
+
}
|
|
643
|
+
} catch (err) {
|
|
644
|
+
log(`[WS] Failed to ingest test results: ${err.message}`);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
broadcastToClients({
|
|
648
|
+
type: 'test_change',
|
|
649
|
+
timestamp: Date.now()
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
529
654
|
// Watch for test results file changes (cucumber-results.json)
|
|
655
|
+
// Monitors both projectRoot and all active worktrees
|
|
530
656
|
function startTestResultsPolling() {
|
|
531
657
|
if (!projectRoot) {
|
|
532
658
|
log(`[WS] No project selected, skipping test results watcher`);
|
|
@@ -548,36 +674,55 @@ function startTestResultsPolling() {
|
|
|
548
674
|
log(`[WS] Test results file not found at ${testResultsPath}, will watch for creation`);
|
|
549
675
|
}
|
|
550
676
|
|
|
677
|
+
// Refresh worktree list periodically (every 10s via counter)
|
|
678
|
+
let worktreePaths = getActiveWorktreePaths();
|
|
679
|
+
let tickCount = 0;
|
|
680
|
+
const WORKTREE_REFRESH_TICKS = Math.round(10000 / DB_POLL_MS); // ~10 seconds
|
|
681
|
+
|
|
682
|
+
if (worktreePaths.length > 0) {
|
|
683
|
+
log(`[WS] Also watching ${worktreePaths.length} active worktree(s) for test results`);
|
|
684
|
+
}
|
|
685
|
+
|
|
551
686
|
// Poll for test results changes
|
|
552
687
|
testResultsPollInterval = setInterval(() => {
|
|
553
688
|
try {
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
689
|
+
// Check main project root
|
|
690
|
+
checkTestResultsFile(
|
|
691
|
+
testResultsPath,
|
|
692
|
+
'root',
|
|
693
|
+
() => lastTestResultsMtime,
|
|
694
|
+
(v) => { lastTestResultsMtime = v; }
|
|
695
|
+
);
|
|
696
|
+
|
|
697
|
+
// Check active worktrees
|
|
698
|
+
for (const wtPath of worktreePaths) {
|
|
699
|
+
const wtResultsPath = path.join(wtPath, 'cucumber-results.json');
|
|
700
|
+
checkTestResultsFile(
|
|
701
|
+
wtResultsPath,
|
|
702
|
+
wtPath,
|
|
703
|
+
() => worktreeTestResultsMtimes.get(wtPath) ?? null,
|
|
704
|
+
(v) => {
|
|
705
|
+
if (v === null) {
|
|
706
|
+
worktreeTestResultsMtimes.delete(wtPath);
|
|
707
|
+
} else {
|
|
708
|
+
worktreeTestResultsMtimes.set(wtPath, v);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
);
|
|
560
712
|
}
|
|
561
713
|
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
//
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
log(`[WS] Ingested ${count} test results into database`);
|
|
714
|
+
// Periodically refresh worktree list
|
|
715
|
+
tickCount++;
|
|
716
|
+
if (tickCount >= WORKTREE_REFRESH_TICKS) {
|
|
717
|
+
tickCount = 0;
|
|
718
|
+
const newPaths = getActiveWorktreePaths();
|
|
719
|
+
// Clean up mtimes for removed worktrees
|
|
720
|
+
for (const oldPath of worktreePaths) {
|
|
721
|
+
if (!newPaths.includes(oldPath)) {
|
|
722
|
+
worktreeTestResultsMtimes.delete(oldPath);
|
|
572
723
|
}
|
|
573
|
-
} catch (err) {
|
|
574
|
-
log(`[WS] Failed to ingest test results: ${err.message}`);
|
|
575
724
|
}
|
|
576
|
-
|
|
577
|
-
broadcastToClients({
|
|
578
|
-
type: 'test_change',
|
|
579
|
-
timestamp: Date.now()
|
|
580
|
-
});
|
|
725
|
+
worktreePaths = newPaths;
|
|
581
726
|
}
|
|
582
727
|
} catch {
|
|
583
728
|
// File might be temporarily locked during writes - ignore
|
|
@@ -598,17 +743,45 @@ function restartPolling() {
|
|
|
598
743
|
clearInterval(testResultsPollInterval);
|
|
599
744
|
testResultsPollInterval = null;
|
|
600
745
|
lastTestResultsMtime = null;
|
|
746
|
+
worktreeTestResultsMtimes.clear();
|
|
747
|
+
}
|
|
748
|
+
if (worktreeCleanupInterval) {
|
|
749
|
+
clearInterval(worktreeCleanupInterval);
|
|
750
|
+
worktreeCleanupInterval = null;
|
|
601
751
|
}
|
|
602
752
|
|
|
603
753
|
// Start polling for the new project
|
|
604
754
|
startDatabasePolling();
|
|
605
755
|
startTestResultsPolling();
|
|
756
|
+
startWorktreeCleanup();
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
function isPortInUse(port) {
|
|
760
|
+
return new Promise((resolve) => {
|
|
761
|
+
const net = require('net');
|
|
762
|
+
const server = net.createServer();
|
|
763
|
+
server.once('error', (err) => {
|
|
764
|
+
resolve(err.code === 'EADDRINUSE');
|
|
765
|
+
});
|
|
766
|
+
server.once('listening', () => {
|
|
767
|
+
server.close();
|
|
768
|
+
resolve(false);
|
|
769
|
+
});
|
|
770
|
+
server.listen(port);
|
|
771
|
+
});
|
|
606
772
|
}
|
|
607
773
|
|
|
608
774
|
function startEmbeddedWebSocketServer() {
|
|
609
|
-
return new Promise((resolve, reject) => {
|
|
775
|
+
return new Promise(async (resolve, reject) => {
|
|
610
776
|
log('Starting embedded WebSocket server...');
|
|
611
777
|
|
|
778
|
+
// Skip if standalone ws-server.js is already running (e.g. npm run dev)
|
|
779
|
+
if (await isPortInUse(WS_PORT)) {
|
|
780
|
+
log(`[WS] Port ${WS_PORT} already in use — standalone WS server detected, skipping`);
|
|
781
|
+
resolve();
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
|
|
612
785
|
try {
|
|
613
786
|
wss = new WebSocketServer({ port: WS_PORT });
|
|
614
787
|
|
|
@@ -626,6 +799,7 @@ function startEmbeddedWebSocketServer() {
|
|
|
626
799
|
|
|
627
800
|
ws.on('error', (error) => {
|
|
628
801
|
log(`[WS] WebSocket error: ${error.message}`);
|
|
802
|
+
ws.close();
|
|
629
803
|
wsClients.delete(ws);
|
|
630
804
|
});
|
|
631
805
|
});
|
|
@@ -637,6 +811,7 @@ function startEmbeddedWebSocketServer() {
|
|
|
637
811
|
|
|
638
812
|
startDatabasePolling();
|
|
639
813
|
startTestResultsPolling();
|
|
814
|
+
startWorktreeCleanup();
|
|
640
815
|
|
|
641
816
|
log(`[WS] WebSocket server running on ws://localhost:${WS_PORT}`);
|
|
642
817
|
resolve();
|
|
@@ -659,9 +834,16 @@ function stopWebSocketServer() {
|
|
|
659
834
|
clearInterval(testResultsPollInterval);
|
|
660
835
|
testResultsPollInterval = null;
|
|
661
836
|
lastTestResultsMtime = null;
|
|
837
|
+
worktreeTestResultsMtimes.clear();
|
|
662
838
|
log('[WS] Test results polling stopped');
|
|
663
839
|
}
|
|
664
840
|
|
|
841
|
+
if (worktreeCleanupInterval) {
|
|
842
|
+
clearInterval(worktreeCleanupInterval);
|
|
843
|
+
worktreeCleanupInterval = null;
|
|
844
|
+
log('[cleanup] Worktree cleanup stopped');
|
|
845
|
+
}
|
|
846
|
+
|
|
665
847
|
if (wss) {
|
|
666
848
|
// Close all client connections
|
|
667
849
|
for (const client of wsClients) {
|
|
@@ -768,6 +950,7 @@ function killClaude(sessionId) {
|
|
|
768
950
|
}
|
|
769
951
|
|
|
770
952
|
log(`[Claude:${sessionId}] Killing process`);
|
|
953
|
+
claudeProcess.stdin.destroy();
|
|
771
954
|
claudeProcess.kill('SIGTERM');
|
|
772
955
|
claudeProcesses.delete(sessionId);
|
|
773
956
|
return { success: true };
|
|
@@ -777,6 +960,7 @@ function killAllClaudeProcesses() {
|
|
|
777
960
|
log(`[Claude] Killing all ${claudeProcesses.size} processes`);
|
|
778
961
|
for (const [sessionId, process] of claudeProcesses) {
|
|
779
962
|
log(`[Claude] Killing session ${sessionId}`);
|
|
963
|
+
process.stdin.destroy();
|
|
780
964
|
process.kill('SIGTERM');
|
|
781
965
|
}
|
|
782
966
|
claudeProcesses.clear();
|
|
@@ -948,7 +1132,10 @@ module.exports = {
|
|
|
948
1132
|
writeLastSelectedProject,
|
|
949
1133
|
installClaudeCode,
|
|
950
1134
|
updateClaudeCode,
|
|
951
|
-
getClaudeCodeInstalled
|
|
1135
|
+
getClaudeCodeInstalled,
|
|
1136
|
+
getClaudeCodeAuthenticated,
|
|
1137
|
+
checkClaudeCodeAuthenticated,
|
|
1138
|
+
loginClaudeCode
|
|
952
1139
|
};
|
|
953
1140
|
|
|
954
1141
|
// ==================== Application Menu ====================
|
|
@@ -1069,7 +1256,19 @@ function buildApplicationMenu() {
|
|
|
1069
1256
|
{
|
|
1070
1257
|
label: 'Check for Updates',
|
|
1071
1258
|
click: async () => {
|
|
1259
|
+
const headers = sessionManager.getUpdaterHeaders();
|
|
1260
|
+
if (!headers.Authorization) {
|
|
1261
|
+
dialog.showMessageBox({
|
|
1262
|
+
type: 'warning',
|
|
1263
|
+
title: 'Update Check Unavailable',
|
|
1264
|
+
message: 'Not signed in.',
|
|
1265
|
+
detail: 'Please sign in to check for updates.',
|
|
1266
|
+
buttons: ['OK']
|
|
1267
|
+
});
|
|
1268
|
+
return;
|
|
1269
|
+
}
|
|
1072
1270
|
log('[AutoUpdate] Manual check for updates...');
|
|
1271
|
+
autoUpdater.requestHeaders = headers;
|
|
1073
1272
|
try {
|
|
1074
1273
|
const result = await autoUpdater.checkForUpdates();
|
|
1075
1274
|
if (result && result.updateInfo) {
|
|
@@ -1115,6 +1314,7 @@ function createWindow() {
|
|
|
1115
1314
|
width: 1400,
|
|
1116
1315
|
height: 900,
|
|
1117
1316
|
title: 'JettyPod',
|
|
1317
|
+
backgroundColor: '#FAF9F7',
|
|
1118
1318
|
webPreferences: {
|
|
1119
1319
|
nodeIntegration: false,
|
|
1120
1320
|
contextIsolation: true,
|
|
@@ -1124,24 +1324,7 @@ function createWindow() {
|
|
|
1124
1324
|
|
|
1125
1325
|
windows.add(mainWindow);
|
|
1126
1326
|
|
|
1127
|
-
|
|
1128
|
-
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');
|
|
1133
|
-
} else if (!claudeCodeInstalled) {
|
|
1134
|
-
// Claude Code is required - show install screen
|
|
1135
|
-
startUrl = 'http://localhost:3000/install-claude';
|
|
1136
|
-
log('Claude Code not installed, showing install screen');
|
|
1137
|
-
} else {
|
|
1138
|
-
// Check if we have a valid project configured
|
|
1139
|
-
const hasProject = hasValidProject(projectRoot);
|
|
1140
|
-
startUrl = hasProject ? 'http://localhost:3000' : 'http://localhost:3000/welcome';
|
|
1141
|
-
log(`Project configured: ${hasProject}, loading: ${startUrl}`);
|
|
1142
|
-
}
|
|
1143
|
-
|
|
1144
|
-
mainWindow.loadURL(startUrl);
|
|
1327
|
+
mainWindow.loadURL(getStartUrl());
|
|
1145
1328
|
|
|
1146
1329
|
// Open DevTools in development
|
|
1147
1330
|
if (process.env.NODE_ENV === 'development') {
|
|
@@ -1201,6 +1384,7 @@ function createNewWindow() {
|
|
|
1201
1384
|
width: 1400,
|
|
1202
1385
|
height: 900,
|
|
1203
1386
|
title: 'JettyPod',
|
|
1387
|
+
backgroundColor: '#FAF9F7',
|
|
1204
1388
|
webPreferences: {
|
|
1205
1389
|
nodeIntegration: false,
|
|
1206
1390
|
contextIsolation: true,
|
|
@@ -1260,21 +1444,25 @@ function cleanup() {
|
|
|
1260
1444
|
|
|
1261
1445
|
// ==================== Claude Code Detection ====================
|
|
1262
1446
|
|
|
1263
|
-
// Track whether Claude Code is installed (checked on startup)
|
|
1447
|
+
// Track whether Claude Code is installed and authenticated (checked on startup)
|
|
1264
1448
|
let claudeCodeInstalled = false;
|
|
1449
|
+
let claudeCodeAuthenticated = false;
|
|
1450
|
+
let claudeCodeNeedsUpdate = false;
|
|
1265
1451
|
|
|
1266
1452
|
/**
|
|
1267
1453
|
* Check if Claude Code CLI is installed
|
|
1268
1454
|
* Uses 'which claude' to detect installation
|
|
1269
1455
|
* @returns {boolean} true if Claude Code is installed
|
|
1270
1456
|
*/
|
|
1271
|
-
function checkClaudeCodeInstalled() {
|
|
1457
|
+
async function checkClaudeCodeInstalled() {
|
|
1458
|
+
const shellEnv = getShellEnv();
|
|
1459
|
+
log(`[ClaudeCode] Checking with PATH: ${shellEnv.PATH}`);
|
|
1272
1460
|
try {
|
|
1273
|
-
|
|
1274
|
-
log(
|
|
1461
|
+
const { stdout } = await execFileAsync('which', ['claude'], { encoding: 'utf-8', env: shellEnv });
|
|
1462
|
+
log(`[ClaudeCode] Claude Code found at: ${stdout.trim()}`);
|
|
1275
1463
|
return true;
|
|
1276
1464
|
} catch {
|
|
1277
|
-
log('[ClaudeCode] Claude Code is NOT installed');
|
|
1465
|
+
log('[ClaudeCode] Claude Code is NOT installed (not found in PATH)');
|
|
1278
1466
|
return false;
|
|
1279
1467
|
}
|
|
1280
1468
|
}
|
|
@@ -1377,6 +1565,125 @@ function getClaudeCodeInstalled() {
|
|
|
1377
1565
|
return claudeCodeInstalled;
|
|
1378
1566
|
}
|
|
1379
1567
|
|
|
1568
|
+
function getClaudeCodeAuthenticated() {
|
|
1569
|
+
return claudeCodeAuthenticated;
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
/**
|
|
1573
|
+
* Check if Claude Code CLI is authenticated using `claude auth status --json`.
|
|
1574
|
+
* This is a local check (no API call), so it returns instantly.
|
|
1575
|
+
* On any unexpected failure, defaults to false (shows connect screen)
|
|
1576
|
+
* so the user can authenticate rather than hitting a broken state.
|
|
1577
|
+
* @returns {boolean} true if Claude Code is authenticated
|
|
1578
|
+
*/
|
|
1579
|
+
async function checkClaudeCodeAuthenticated() {
|
|
1580
|
+
if (!claudeCodeInstalled) return false;
|
|
1581
|
+
const shellEnv = getShellEnv();
|
|
1582
|
+
try {
|
|
1583
|
+
const { stdout } = await execFileAsync('claude', ['auth', 'status', '--json'], {
|
|
1584
|
+
encoding: 'utf-8',
|
|
1585
|
+
env: shellEnv,
|
|
1586
|
+
timeout: 10000
|
|
1587
|
+
});
|
|
1588
|
+
const status = JSON.parse(stdout);
|
|
1589
|
+
if (status.loggedIn) {
|
|
1590
|
+
log('[ClaudeCode] Claude Code is authenticated');
|
|
1591
|
+
return true;
|
|
1592
|
+
}
|
|
1593
|
+
log('[ClaudeCode] Claude Code is NOT authenticated');
|
|
1594
|
+
return false;
|
|
1595
|
+
} catch (err) {
|
|
1596
|
+
const combined = (err.stderr || '') + (err.stdout || '') + (err.message || '');
|
|
1597
|
+
|
|
1598
|
+
if (combined.includes('needs an update') || combined.includes('needs_update')) {
|
|
1599
|
+
log('[ClaudeCode] CLI is outdated, flagging for auto-update');
|
|
1600
|
+
claudeCodeNeedsUpdate = true;
|
|
1601
|
+
return false;
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
log(`[ClaudeCode] Auth check failed, defaulting to unauthenticated: ${combined}`);
|
|
1605
|
+
return false;
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
/**
|
|
1610
|
+
* Spawn `claude auth login` to trigger browser-based OAuth authentication.
|
|
1611
|
+
* The CLI prints an auth URL to stdout/stderr. We capture it and open it
|
|
1612
|
+
* with shell.openExternal() since the spawned process has no TTY.
|
|
1613
|
+
* @returns {Promise<{success: boolean, error?: string}>}
|
|
1614
|
+
*/
|
|
1615
|
+
function loginClaudeCode() {
|
|
1616
|
+
return new Promise((resolve) => {
|
|
1617
|
+
const shellEnv = getShellEnv();
|
|
1618
|
+
log('[ClaudeCode] Starting claude auth login...');
|
|
1619
|
+
|
|
1620
|
+
const loginProcess = spawn(getUserShell(), ['-lc', 'claude auth login'], {
|
|
1621
|
+
env: shellEnv,
|
|
1622
|
+
stdio: 'pipe'
|
|
1623
|
+
});
|
|
1624
|
+
|
|
1625
|
+
let stdout = '';
|
|
1626
|
+
let stderr = '';
|
|
1627
|
+
let urlOpened = false;
|
|
1628
|
+
|
|
1629
|
+
const tryOpenUrl = (text) => {
|
|
1630
|
+
if (urlOpened) return;
|
|
1631
|
+
// Match OAuth/auth URLs the CLI prints for non-TTY environments
|
|
1632
|
+
const urlMatch = text.match(/https?:\/\/\S+/);
|
|
1633
|
+
if (urlMatch) {
|
|
1634
|
+
const url = urlMatch[0];
|
|
1635
|
+
log(`[ClaudeCode] Found auth URL, opening in browser: ${url}`);
|
|
1636
|
+
shell.openExternal(url);
|
|
1637
|
+
urlOpened = true;
|
|
1638
|
+
}
|
|
1639
|
+
};
|
|
1640
|
+
|
|
1641
|
+
loginProcess.stdout.on('data', (data) => {
|
|
1642
|
+
const chunk = data.toString();
|
|
1643
|
+
stdout += chunk;
|
|
1644
|
+
log(`[ClaudeCode] stdout: ${chunk.trim()}`);
|
|
1645
|
+
tryOpenUrl(chunk);
|
|
1646
|
+
});
|
|
1647
|
+
|
|
1648
|
+
loginProcess.stderr.on('data', (data) => {
|
|
1649
|
+
const chunk = data.toString();
|
|
1650
|
+
stderr += chunk;
|
|
1651
|
+
log(`[ClaudeCode] stderr: ${chunk.trim()}`);
|
|
1652
|
+
tryOpenUrl(chunk);
|
|
1653
|
+
});
|
|
1654
|
+
|
|
1655
|
+
loginProcess.on('close', async (code) => {
|
|
1656
|
+
if (code === 0) {
|
|
1657
|
+
claudeCodeAuthenticated = true;
|
|
1658
|
+
log('[ClaudeCode] Auth completed successfully');
|
|
1659
|
+
resolve({ success: true });
|
|
1660
|
+
} else if (stderr.includes('needs an update') || stderr.includes('needs_update') ||
|
|
1661
|
+
stdout.includes('needs an update') || stdout.includes('needs_update')) {
|
|
1662
|
+
log('[ClaudeCode] CLI outdated during login, running auto-update...');
|
|
1663
|
+
const updateResult = await updateClaudeCode();
|
|
1664
|
+
if (updateResult.success) {
|
|
1665
|
+
log('[ClaudeCode] Auto-update succeeded, retrying login');
|
|
1666
|
+
claudeCodeNeedsUpdate = false;
|
|
1667
|
+
// Retry login after update
|
|
1668
|
+
const retryResult = await loginClaudeCode();
|
|
1669
|
+
resolve(retryResult);
|
|
1670
|
+
} else {
|
|
1671
|
+
log(`[ClaudeCode] Auto-update failed: ${updateResult.error}`);
|
|
1672
|
+
resolve({ success: false, error: 'Claude Code needs an update but the update failed. Please run "claude update" manually.' });
|
|
1673
|
+
}
|
|
1674
|
+
} else {
|
|
1675
|
+
log(`[ClaudeCode] Auth failed with code ${code}: ${stderr}`);
|
|
1676
|
+
resolve({ success: false, error: stderr || `Auth exited with code ${code}` });
|
|
1677
|
+
}
|
|
1678
|
+
});
|
|
1679
|
+
|
|
1680
|
+
loginProcess.on('error', (err) => {
|
|
1681
|
+
log(`[ClaudeCode] Auth spawn error: ${err.message}`);
|
|
1682
|
+
resolve({ success: false, error: err.message });
|
|
1683
|
+
});
|
|
1684
|
+
});
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1380
1687
|
// ==================== PATH Setup ====================
|
|
1381
1688
|
|
|
1382
1689
|
const SYMLINK_PATH = '/usr/local/bin/jettypod';
|
|
@@ -1397,7 +1704,7 @@ function getBundledCliPath() {
|
|
|
1397
1704
|
* Setup PATH by creating symlink to /usr/local/bin
|
|
1398
1705
|
* Uses osascript to request admin privileges
|
|
1399
1706
|
*/
|
|
1400
|
-
async function
|
|
1707
|
+
async function setupCliSymlink() {
|
|
1401
1708
|
// Only run on macOS
|
|
1402
1709
|
if (process.platform !== 'darwin') {
|
|
1403
1710
|
log('[PATH] Not macOS, skipping PATH setup');
|
|
@@ -1413,19 +1720,29 @@ async function setupPathOnFirstLaunch() {
|
|
|
1413
1720
|
return;
|
|
1414
1721
|
}
|
|
1415
1722
|
|
|
1416
|
-
//
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1723
|
+
// Check if symlink already points to the correct bundled CLI
|
|
1724
|
+
try {
|
|
1725
|
+
const currentTarget = fs.readlinkSync(SYMLINK_PATH);
|
|
1726
|
+
if (currentTarget === bundledPath) {
|
|
1727
|
+
log('[PATH] Symlink already points to bundled CLI, skipping');
|
|
1728
|
+
return;
|
|
1729
|
+
}
|
|
1730
|
+
log(`[PATH] Symlink points to ${currentTarget}, updating to bundled CLI`);
|
|
1731
|
+
} catch {
|
|
1732
|
+
// Symlink doesn't exist or isn't a symlink — create it
|
|
1733
|
+
log('[PATH] No existing symlink, creating');
|
|
1420
1734
|
}
|
|
1421
1735
|
|
|
1422
1736
|
log('[PATH] Setting up PATH with admin privileges...');
|
|
1423
1737
|
|
|
1424
|
-
// Use osascript to run ln with admin privileges
|
|
1425
|
-
|
|
1738
|
+
// Use osascript to run ln with admin privileges.
|
|
1739
|
+
// mkdir -p ensures the parent directory exists (e.g. /usr/local/bin may not
|
|
1740
|
+
// exist on fresh Apple Silicon Macs which use /opt/homebrew/bin by default).
|
|
1741
|
+
const symlinkDir = path.dirname(SYMLINK_PATH);
|
|
1742
|
+
const script = `do shell script "mkdir -p '${symlinkDir}' && ln -sf '${bundledPath}' '${SYMLINK_PATH}'" with administrator privileges`;
|
|
1426
1743
|
|
|
1427
1744
|
try {
|
|
1428
|
-
|
|
1745
|
+
await execAsync(`osascript -e '${script}'`, { encoding: 'utf-8' });
|
|
1429
1746
|
log('[PATH] Symlink created successfully');
|
|
1430
1747
|
} catch (error) {
|
|
1431
1748
|
// User cancelled or error occurred
|
|
@@ -1433,35 +1750,117 @@ async function setupPathOnFirstLaunch() {
|
|
|
1433
1750
|
}
|
|
1434
1751
|
}
|
|
1435
1752
|
|
|
1436
|
-
// ====================
|
|
1753
|
+
// ==================== Subscription & Access ====================
|
|
1437
1754
|
|
|
1438
1755
|
/**
|
|
1439
|
-
* Get path to
|
|
1756
|
+
* Get path to subscription.json file
|
|
1440
1757
|
* Stored in Electron's userData directory (~/.config/JettyPod/)
|
|
1441
1758
|
*/
|
|
1442
|
-
function
|
|
1443
|
-
return path.join(app.getPath('userData'), '
|
|
1759
|
+
function getSubscriptionPath() {
|
|
1760
|
+
return path.join(app.getPath('userData'), 'subscription.json');
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
/**
|
|
1764
|
+
* Get the path to the auth state file.
|
|
1765
|
+
* Stored alongside subscription.json in Electron's userData directory.
|
|
1766
|
+
*/
|
|
1767
|
+
function getAuthPath() {
|
|
1768
|
+
return path.join(app.getPath('userData'), 'auth.json');
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
/**
|
|
1772
|
+
* Check if there's a stored auth token (JWT).
|
|
1773
|
+
* Does NOT validate the token — just checks if auth.json exists with a token.
|
|
1774
|
+
*/
|
|
1775
|
+
function isAuthenticated() {
|
|
1776
|
+
const authPath = getAuthPath();
|
|
1777
|
+
if (!fs.existsSync(authPath)) {
|
|
1778
|
+
log('[Auth] No auth.json found');
|
|
1779
|
+
return false;
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
try {
|
|
1783
|
+
const data = JSON.parse(fs.readFileSync(authPath, 'utf-8'));
|
|
1784
|
+
if (data.token) {
|
|
1785
|
+
log('[Auth] Auth token found');
|
|
1786
|
+
return true;
|
|
1787
|
+
}
|
|
1788
|
+
} catch (error) {
|
|
1789
|
+
log(`[Auth] Error reading auth file: ${error.message}`);
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
return false;
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
/**
|
|
1796
|
+
* Check if the user has ever logged in before.
|
|
1797
|
+
* Uses a persistent marker file that survives logout (auth.json deletion).
|
|
1798
|
+
*/
|
|
1799
|
+
function hasLoggedInBefore() {
|
|
1800
|
+
try {
|
|
1801
|
+
const markerPath = path.join(app.getPath('userData'), 'has-logged-in');
|
|
1802
|
+
return fs.existsSync(markerPath);
|
|
1803
|
+
} catch {
|
|
1804
|
+
return false;
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
/**
|
|
1809
|
+
* Mark that the user has logged in at least once.
|
|
1810
|
+
* Called after successful authentication (both OAuth and OTP).
|
|
1811
|
+
*/
|
|
1812
|
+
function markHasLoggedIn() {
|
|
1813
|
+
try {
|
|
1814
|
+
const markerPath = path.join(app.getPath('userData'), 'has-logged-in');
|
|
1815
|
+
const dir = path.dirname(markerPath);
|
|
1816
|
+
if (!fs.existsSync(dir)) {
|
|
1817
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1818
|
+
}
|
|
1819
|
+
if (!fs.existsSync(markerPath)) {
|
|
1820
|
+
fs.writeFileSync(markerPath, new Date().toISOString());
|
|
1821
|
+
log('[Auth] Marked user as having logged in before');
|
|
1822
|
+
}
|
|
1823
|
+
} catch (error) {
|
|
1824
|
+
log(`[Auth] Failed to write login marker: ${error.message}`);
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
/**
|
|
1829
|
+
* Read the Stripe customer ID from subscription config.
|
|
1830
|
+
* Returns null if no subscription is stored.
|
|
1831
|
+
* Written by the Stripe checkout flow (chore #1000535).
|
|
1832
|
+
*/
|
|
1833
|
+
function getSubscriptionCustomerId() {
|
|
1834
|
+
const subPath = getSubscriptionPath();
|
|
1835
|
+
if (!fs.existsSync(subPath)) return null;
|
|
1836
|
+
try {
|
|
1837
|
+
const data = JSON.parse(fs.readFileSync(subPath, 'utf-8'));
|
|
1838
|
+
return data.customerId || null;
|
|
1839
|
+
} catch (error) {
|
|
1840
|
+
log(`[Subscription] Error reading subscription file: ${error.message}`);
|
|
1841
|
+
return null;
|
|
1842
|
+
}
|
|
1444
1843
|
}
|
|
1445
1844
|
|
|
1446
1845
|
/**
|
|
1447
|
-
* Check if
|
|
1448
|
-
*
|
|
1846
|
+
* Check if there's an active subscription stored locally.
|
|
1847
|
+
* Full validation against Stripe happens on update check / app refresh.
|
|
1449
1848
|
*/
|
|
1450
|
-
function
|
|
1451
|
-
const
|
|
1452
|
-
if (!fs.existsSync(
|
|
1453
|
-
log('[
|
|
1849
|
+
function isSubscriptionActive() {
|
|
1850
|
+
const subPath = getSubscriptionPath();
|
|
1851
|
+
if (!fs.existsSync(subPath)) {
|
|
1852
|
+
log('[Subscription] No subscription.json found');
|
|
1454
1853
|
return false;
|
|
1455
1854
|
}
|
|
1456
1855
|
|
|
1457
1856
|
try {
|
|
1458
|
-
const data = JSON.parse(fs.readFileSync(
|
|
1459
|
-
if (data.
|
|
1460
|
-
log('[
|
|
1857
|
+
const data = JSON.parse(fs.readFileSync(subPath, 'utf-8'));
|
|
1858
|
+
if (data.customerId) {
|
|
1859
|
+
log('[Subscription] Active subscription found');
|
|
1461
1860
|
return true;
|
|
1462
1861
|
}
|
|
1463
1862
|
} catch (error) {
|
|
1464
|
-
log(`[
|
|
1863
|
+
log(`[Subscription] Error reading subscription file: ${error.message}`);
|
|
1465
1864
|
}
|
|
1466
1865
|
|
|
1467
1866
|
return false;
|
|
@@ -1510,6 +1909,16 @@ function copyDirSync(src, dest) {
|
|
|
1510
1909
|
if (entry.isDirectory()) {
|
|
1511
1910
|
copyDirSync(srcPath, destPath);
|
|
1512
1911
|
} else {
|
|
1912
|
+
// Skip copy if dest exists and has same or newer mtime + same size
|
|
1913
|
+
try {
|
|
1914
|
+
const srcStat = fs.statSync(srcPath);
|
|
1915
|
+
const destStat = fs.statSync(destPath);
|
|
1916
|
+
if (destStat.mtimeMs >= srcStat.mtimeMs && destStat.size === srcStat.size) {
|
|
1917
|
+
continue;
|
|
1918
|
+
}
|
|
1919
|
+
} catch {
|
|
1920
|
+
// dest doesn't exist — copy it
|
|
1921
|
+
}
|
|
1513
1922
|
fs.copyFileSync(srcPath, destPath);
|
|
1514
1923
|
}
|
|
1515
1924
|
}
|
|
@@ -1652,28 +2061,168 @@ autoUpdater.on('update-downloaded', (info) => {
|
|
|
1652
2061
|
|
|
1653
2062
|
// ==================== App Lifecycle ====================
|
|
1654
2063
|
|
|
2064
|
+
// Register jettypod:// protocol for OAuth callback
|
|
2065
|
+
if (!isPackaged) {
|
|
2066
|
+
// Dev mode: pass app path so macOS can route the URL to this Electron instance
|
|
2067
|
+
app.setAsDefaultProtocolClient('jettypod', process.execPath, [path.resolve(process.argv[1])]);
|
|
2068
|
+
} else {
|
|
2069
|
+
app.setAsDefaultProtocolClient('jettypod');
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
/**
|
|
2073
|
+
* Decode a JWT payload without verifying the signature.
|
|
2074
|
+
* Used to extract user info (email, plan) from the token before saving.
|
|
2075
|
+
*/
|
|
2076
|
+
function decodeJWTPayload(token) {
|
|
2077
|
+
try {
|
|
2078
|
+
const parts = token.split('.');
|
|
2079
|
+
if (parts.length !== 3) return null;
|
|
2080
|
+
const payload = parts[1].replace(/-/g, '+').replace(/_/g, '/');
|
|
2081
|
+
return JSON.parse(Buffer.from(payload, 'base64').toString('utf-8'));
|
|
2082
|
+
} catch {
|
|
2083
|
+
return null;
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
/**
|
|
2088
|
+
* Handle a jettypod:// protocol URL (OAuth callback).
|
|
2089
|
+
* Saves the token + decoded user info to auth.json and navigates to the dashboard.
|
|
2090
|
+
*/
|
|
2091
|
+
function handleProtocolUrl(url) {
|
|
2092
|
+
log(`[Auth] Protocol handler received: ${url}`);
|
|
2093
|
+
|
|
2094
|
+
try {
|
|
2095
|
+
const parsed = new URL(url);
|
|
2096
|
+
// jettypod://auth/callback parses as hostname='auth', pathname='/callback'
|
|
2097
|
+
const isAuthCallback =
|
|
2098
|
+
(parsed.hostname === 'auth' && parsed.pathname === '/callback') ||
|
|
2099
|
+
parsed.pathname === '//auth/callback' ||
|
|
2100
|
+
parsed.pathname === '/auth/callback';
|
|
2101
|
+
if (isAuthCallback) {
|
|
2102
|
+
const token = parsed.searchParams.get('token');
|
|
2103
|
+
if (token && mainWindow) {
|
|
2104
|
+
// Decode JWT to extract user info (sub, email, plan)
|
|
2105
|
+
const payload = decodeJWTPayload(token);
|
|
2106
|
+
const user = payload ? { id: payload.sub, email: payload.email, plan: payload.plan } : undefined;
|
|
2107
|
+
|
|
2108
|
+
// Save auth.json with token AND user data
|
|
2109
|
+
const authPath = path.join(app.getPath('userData'), 'auth.json');
|
|
2110
|
+
const dir = path.dirname(authPath);
|
|
2111
|
+
if (!fs.existsSync(dir)) {
|
|
2112
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
2113
|
+
}
|
|
2114
|
+
fs.writeFileSync(authPath, JSON.stringify({ token, user, savedAt: new Date().toISOString() }, null, 2));
|
|
2115
|
+
markHasLoggedIn();
|
|
2116
|
+
log('[Auth] Token + user data saved from OAuth callback');
|
|
2117
|
+
|
|
2118
|
+
// Navigate to the appropriate onboarding/dashboard page
|
|
2119
|
+
mainWindow.loadURL(getStartUrl());
|
|
2120
|
+
mainWindow.focus();
|
|
2121
|
+
}
|
|
2122
|
+
} else if (parsed.hostname === 'subscription' && parsed.pathname === '/activated') {
|
|
2123
|
+
log('[Auth] Subscription activated deep link received');
|
|
2124
|
+
if (mainWindow) {
|
|
2125
|
+
mainWindow.loadURL(getStartUrl());
|
|
2126
|
+
mainWindow.focus();
|
|
2127
|
+
}
|
|
2128
|
+
}
|
|
2129
|
+
} catch (error) {
|
|
2130
|
+
log(`[Auth] Error handling protocol URL: ${error.message}`);
|
|
2131
|
+
}
|
|
2132
|
+
}
|
|
2133
|
+
|
|
2134
|
+
// Single-instance lock: ensure deep links are routed to the existing instance
|
|
2135
|
+
const gotTheLock = app.requestSingleInstanceLock();
|
|
2136
|
+
|
|
2137
|
+
if (!gotTheLock) {
|
|
2138
|
+
// Another instance is already running — quit this one.
|
|
2139
|
+
// The URL (if any) will be forwarded to the existing instance via 'second-instance'.
|
|
2140
|
+
app.quit();
|
|
2141
|
+
} else {
|
|
2142
|
+
// On macOS, protocol URLs for a running app arrive via 'open-url'.
|
|
2143
|
+
app.on('open-url', (event, url) => {
|
|
2144
|
+
event.preventDefault();
|
|
2145
|
+
handleProtocolUrl(url);
|
|
2146
|
+
});
|
|
2147
|
+
|
|
2148
|
+
// On Windows/Linux (and macOS second-instance fallback), the URL arrives here.
|
|
2149
|
+
app.on('second-instance', (_event, commandLine) => {
|
|
2150
|
+
// The protocol URL is typically the last argument
|
|
2151
|
+
const url = commandLine.find(arg => arg.startsWith('jettypod://'));
|
|
2152
|
+
if (url) {
|
|
2153
|
+
handleProtocolUrl(url);
|
|
2154
|
+
}
|
|
2155
|
+
// Focus the main window
|
|
2156
|
+
if (mainWindow) {
|
|
2157
|
+
if (mainWindow.isMinimized()) mainWindow.restore();
|
|
2158
|
+
mainWindow.focus();
|
|
2159
|
+
}
|
|
2160
|
+
});
|
|
2161
|
+
}
|
|
2162
|
+
|
|
1655
2163
|
app.whenReady().then(async () => {
|
|
1656
2164
|
log('Electron app ready');
|
|
1657
2165
|
|
|
2166
|
+
// Start session manager (handles auth heartbeat + auto-updater token)
|
|
2167
|
+
sessionManager.setLogger(log);
|
|
2168
|
+
if (isAuthenticated()) {
|
|
2169
|
+
sessionManager.start();
|
|
2170
|
+
}
|
|
2171
|
+
|
|
1658
2172
|
// Check for updates in the background (only in packaged app)
|
|
1659
|
-
if (isPackaged) {
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
2173
|
+
if (isPackaged && isAuthenticated()) {
|
|
2174
|
+
const headers = sessionManager.getUpdaterHeaders();
|
|
2175
|
+
if (headers.Authorization) {
|
|
2176
|
+
autoUpdater.requestHeaders = headers;
|
|
2177
|
+
log('[AutoUpdate] Checking for updates with JWT...');
|
|
2178
|
+
try {
|
|
2179
|
+
await autoUpdater.checkForUpdatesAndNotify();
|
|
2180
|
+
} catch (error) {
|
|
2181
|
+
log(`[AutoUpdate] Update check failed: ${error.message}`);
|
|
2182
|
+
}
|
|
2183
|
+
} else {
|
|
2184
|
+
log('[AutoUpdate] Skipping update check - no auth token');
|
|
1667
2185
|
}
|
|
1668
2186
|
}
|
|
1669
2187
|
|
|
1670
2188
|
// Check if Claude Code is installed (for dependency flow)
|
|
1671
2189
|
// Updates the global claudeCodeInstalled variable
|
|
1672
|
-
claudeCodeInstalled = checkClaudeCodeInstalled();
|
|
2190
|
+
claudeCodeInstalled = await checkClaudeCodeInstalled();
|
|
1673
2191
|
log(`Claude Code installed: ${claudeCodeInstalled}`);
|
|
1674
2192
|
|
|
2193
|
+
// Check if Claude Code is authenticated (only if installed)
|
|
2194
|
+
// Wrapped in try/catch so a catastrophic failure here doesn't crash startup
|
|
2195
|
+
if (claudeCodeInstalled) {
|
|
2196
|
+
try {
|
|
2197
|
+
claudeCodeAuthenticated = await checkClaudeCodeAuthenticated();
|
|
2198
|
+
} catch (err) {
|
|
2199
|
+
log(`[ClaudeCode] Auth check crashed unexpectedly: ${err.message}`);
|
|
2200
|
+
claudeCodeAuthenticated = false;
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2203
|
+
// Auto-update if CLI is outdated, then retry auth check
|
|
2204
|
+
if (!claudeCodeAuthenticated && claudeCodeNeedsUpdate) {
|
|
2205
|
+
log('[ClaudeCode] CLI outdated, running auto-update...');
|
|
2206
|
+
const updateResult = await updateClaudeCode();
|
|
2207
|
+
if (updateResult.success) {
|
|
2208
|
+
log('[ClaudeCode] Auto-update succeeded, retrying auth check');
|
|
2209
|
+
claudeCodeNeedsUpdate = false;
|
|
2210
|
+
try {
|
|
2211
|
+
claudeCodeAuthenticated = await checkClaudeCodeAuthenticated();
|
|
2212
|
+
} catch (err) {
|
|
2213
|
+
log(`[ClaudeCode] Auth re-check crashed: ${err.message}`);
|
|
2214
|
+
claudeCodeAuthenticated = false;
|
|
2215
|
+
}
|
|
2216
|
+
} else {
|
|
2217
|
+
log(`[ClaudeCode] Auto-update failed: ${updateResult.error}`);
|
|
2218
|
+
}
|
|
2219
|
+
}
|
|
2220
|
+
|
|
2221
|
+
log(`Claude Code authenticated: ${claudeCodeAuthenticated}`);
|
|
2222
|
+
}
|
|
2223
|
+
|
|
1675
2224
|
// Setup PATH on first launch (creates symlink to /usr/local/bin)
|
|
1676
|
-
await
|
|
2225
|
+
await setupCliSymlink();
|
|
1677
2226
|
|
|
1678
2227
|
// Sync bundled skills to ~/.claude/skills/
|
|
1679
2228
|
syncSkills();
|
|
@@ -1686,8 +2235,19 @@ app.whenReady().then(async () => {
|
|
|
1686
2235
|
// Register IPC handlers for database operations
|
|
1687
2236
|
registerIpcHandlers();
|
|
1688
2237
|
|
|
2238
|
+
// Post-login navigation: returns the correct path based on Claude Code state
|
|
2239
|
+
ipcMain.handle('auth:getPostLoginPath', () => {
|
|
2240
|
+
const url = getStartUrl();
|
|
2241
|
+
// Extract just the path from the full URL
|
|
2242
|
+
try {
|
|
2243
|
+
return new URL(url).pathname;
|
|
2244
|
+
} catch {
|
|
2245
|
+
return '/';
|
|
2246
|
+
}
|
|
2247
|
+
});
|
|
2248
|
+
|
|
1689
2249
|
// Clean up stale resources from previous runs (lock files, orphaned processes)
|
|
1690
|
-
cleanupStaleResources();
|
|
2250
|
+
await cleanupStaleResources();
|
|
1691
2251
|
|
|
1692
2252
|
try {
|
|
1693
2253
|
// Start servers in parallel
|