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,21 +1,43 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { createContext, useContext, useState, type ReactNode } from 'react';
|
|
3
|
+
import { createContext, useContext, useState, useEffect, useRef, useMemo, type ReactNode } from 'react';
|
|
4
4
|
|
|
5
5
|
export type ConnectionStatus = 'connected' | 'reconnecting' | 'disconnected';
|
|
6
6
|
|
|
7
|
+
const DISCONNECT_DELAY_MS = 5000;
|
|
8
|
+
|
|
7
9
|
interface ConnectionStatusContextValue {
|
|
8
10
|
status: ConnectionStatus;
|
|
9
11
|
setStatus: (status: ConnectionStatus) => void;
|
|
12
|
+
showDisconnected: boolean;
|
|
10
13
|
}
|
|
11
14
|
|
|
12
15
|
const ConnectionStatusContext = createContext<ConnectionStatusContextValue | null>(null);
|
|
13
16
|
|
|
14
17
|
export function ConnectionStatusProvider({ children }: { children: ReactNode }) {
|
|
15
18
|
const [status, setStatus] = useState<ConnectionStatus>('disconnected');
|
|
19
|
+
const [showDisconnected, setShowDisconnected] = useState(false);
|
|
20
|
+
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
if (status === 'connected') {
|
|
24
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
25
|
+
setShowDisconnected(false);
|
|
26
|
+
} else {
|
|
27
|
+
timerRef.current = setTimeout(() => {
|
|
28
|
+
setShowDisconnected(true);
|
|
29
|
+
}, DISCONNECT_DELAY_MS);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return () => {
|
|
33
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
34
|
+
};
|
|
35
|
+
}, [status]);
|
|
36
|
+
|
|
37
|
+
const value = useMemo(() => ({ status, setStatus, showDisconnected }), [status, showDisconnected]);
|
|
16
38
|
|
|
17
39
|
return (
|
|
18
|
-
<ConnectionStatusContext.Provider value={
|
|
40
|
+
<ConnectionStatusContext.Provider value={value}>
|
|
19
41
|
{children}
|
|
20
42
|
</ConnectionStatusContext.Provider>
|
|
21
43
|
);
|
|
@@ -24,8 +46,7 @@ export function ConnectionStatusProvider({ children }: { children: ReactNode })
|
|
|
24
46
|
export function useConnectionStatus() {
|
|
25
47
|
const context = useContext(ConnectionStatusContext);
|
|
26
48
|
if (!context) {
|
|
27
|
-
|
|
28
|
-
return { status: 'disconnected' as ConnectionStatus, setStatus: () => {} };
|
|
49
|
+
return { status: 'disconnected' as ConnectionStatus, setStatus: () => {}, showDisconnected: false };
|
|
29
50
|
}
|
|
30
51
|
return context;
|
|
31
52
|
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext, useState, useEffect, useCallback, useMemo, type ReactNode } from 'react';
|
|
4
|
+
import { useWebSocket, type WebSocketMessage } from '../hooks/useWebSocket';
|
|
5
|
+
import { getWebSocketUrl } from '../lib/utils';
|
|
6
|
+
|
|
7
|
+
const FREE_WEEKLY_LIMIT = 20;
|
|
8
|
+
const API_BASE = 'https://jettypod-update-server.spangbaryn2.workers.dev';
|
|
9
|
+
|
|
10
|
+
interface UsageState {
|
|
11
|
+
used: number;
|
|
12
|
+
limit: number;
|
|
13
|
+
remaining: number;
|
|
14
|
+
allowed: boolean;
|
|
15
|
+
plan: string;
|
|
16
|
+
loading: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface UsageContextValue extends UsageState {
|
|
20
|
+
refresh: () => Promise<void>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const UsageContext = createContext<UsageContextValue | null>(null);
|
|
24
|
+
|
|
25
|
+
export function UsageProvider({ children }: { children: ReactNode }) {
|
|
26
|
+
const [state, setState] = useState<UsageState>({
|
|
27
|
+
used: 0,
|
|
28
|
+
limit: FREE_WEEKLY_LIMIT,
|
|
29
|
+
remaining: FREE_WEEKLY_LIMIT,
|
|
30
|
+
allowed: true,
|
|
31
|
+
plan: 'free',
|
|
32
|
+
loading: true,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const fetchUsage = useCallback(async () => {
|
|
36
|
+
// Get plan from auth (if Electron)
|
|
37
|
+
let plan = 'free';
|
|
38
|
+
try {
|
|
39
|
+
if (window.electronAPI?.isElectron) {
|
|
40
|
+
const status = await window.electronAPI.auth.getStatus();
|
|
41
|
+
if (status.authenticated && status.user) {
|
|
42
|
+
plan = status.user.plan || 'free';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Check server-side plan — the local JWT may be stale
|
|
46
|
+
if (plan === 'free') {
|
|
47
|
+
const token = await window.electronAPI.auth.getToken();
|
|
48
|
+
if (token) {
|
|
49
|
+
try {
|
|
50
|
+
const res = await fetch(`${API_BASE}/auth/me`, {
|
|
51
|
+
headers: { 'Authorization': `Bearer ${token}` },
|
|
52
|
+
});
|
|
53
|
+
if (res.ok) {
|
|
54
|
+
const data = await res.json() as { user: { plan: string; email: string }; token?: string };
|
|
55
|
+
if (data.user.plan !== 'free') {
|
|
56
|
+
plan = data.user.plan;
|
|
57
|
+
// Server issued a fresh JWT with updated plan — save it locally
|
|
58
|
+
if (data.token) {
|
|
59
|
+
await window.electronAPI.auth.saveToken(data.token, data.user);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
} catch {
|
|
64
|
+
// Network error — fall through with local plan
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
} catch {
|
|
70
|
+
// Auth unavailable — default to free
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Paid plans have unlimited usage
|
|
74
|
+
if (plan !== 'free') {
|
|
75
|
+
setState({
|
|
76
|
+
used: 0,
|
|
77
|
+
limit: Infinity,
|
|
78
|
+
remaining: Infinity,
|
|
79
|
+
allowed: true,
|
|
80
|
+
plan,
|
|
81
|
+
loading: false,
|
|
82
|
+
});
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Free plan — count from local DB via API route
|
|
87
|
+
try {
|
|
88
|
+
const res = await fetch('/api/usage');
|
|
89
|
+
console.log('[usage] UsageContext fetch status:', res.status);
|
|
90
|
+
if (!res.ok) {
|
|
91
|
+
console.error('[usage] UsageContext fetch not ok:', res.status, res.statusText);
|
|
92
|
+
setState(prev => ({ ...prev, allowed: true, loading: false }));
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const usage = await res.json();
|
|
96
|
+
console.log('[usage] UsageContext received:', usage);
|
|
97
|
+
setState({
|
|
98
|
+
used: typeof usage.used === 'number' ? usage.used : 0,
|
|
99
|
+
limit: typeof usage.limit === 'number' ? usage.limit : FREE_WEEKLY_LIMIT,
|
|
100
|
+
remaining: typeof usage.remaining === 'number' ? usage.remaining : FREE_WEEKLY_LIMIT,
|
|
101
|
+
allowed: typeof usage.allowed === 'boolean' ? usage.allowed : true,
|
|
102
|
+
plan,
|
|
103
|
+
loading: false,
|
|
104
|
+
});
|
|
105
|
+
} catch (err) {
|
|
106
|
+
console.error('[usage] UsageContext fetch error:', err);
|
|
107
|
+
setState(prev => ({ ...prev, allowed: true, loading: false }));
|
|
108
|
+
}
|
|
109
|
+
}, []);
|
|
110
|
+
|
|
111
|
+
// Fetch on mount
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
fetchUsage();
|
|
114
|
+
}, [fetchUsage]);
|
|
115
|
+
|
|
116
|
+
// Refresh on window focus
|
|
117
|
+
useEffect(() => {
|
|
118
|
+
const handleFocus = () => fetchUsage();
|
|
119
|
+
window.addEventListener('focus', handleFocus);
|
|
120
|
+
return () => window.removeEventListener('focus', handleFocus);
|
|
121
|
+
}, [fetchUsage]);
|
|
122
|
+
|
|
123
|
+
// Refresh on WebSocket db_change events (e.g., CLI creates a work item)
|
|
124
|
+
const handleWsMessage = useCallback((message: WebSocketMessage) => {
|
|
125
|
+
if (message.type === 'db_change') {
|
|
126
|
+
fetchUsage();
|
|
127
|
+
}
|
|
128
|
+
}, [fetchUsage]);
|
|
129
|
+
|
|
130
|
+
useWebSocket({ url: getWebSocketUrl(), onMessage: handleWsMessage });
|
|
131
|
+
|
|
132
|
+
const value = useMemo(() => ({ ...state, refresh: fetchUsage }), [state, fetchUsage]);
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<UsageContext.Provider value={value}>
|
|
136
|
+
{children}
|
|
137
|
+
</UsageContext.Provider>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function useUsage() {
|
|
142
|
+
const context = useContext(UsageContext);
|
|
143
|
+
if (!context) {
|
|
144
|
+
return {
|
|
145
|
+
used: 0,
|
|
146
|
+
limit: FREE_WEEKLY_LIMIT,
|
|
147
|
+
remaining: FREE_WEEKLY_LIMIT,
|
|
148
|
+
allowed: true,
|
|
149
|
+
plan: 'free',
|
|
150
|
+
loading: false,
|
|
151
|
+
refresh: async () => {},
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
return context;
|
|
155
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
function checkUsageLimit(plan, used, limit) {
|
|
2
|
+
if (plan !== 'free') {
|
|
3
|
+
return { allowed: true, used: 0, limit: Infinity, remaining: Infinity };
|
|
4
|
+
}
|
|
5
|
+
const remaining = Math.max(0, limit - used);
|
|
6
|
+
return { allowed: remaining > 0, used, limit, remaining };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
module.exports = { checkUsageLimit };
|
|
@@ -96,14 +96,20 @@ function getDbPath() {
|
|
|
96
96
|
function getDb() {
|
|
97
97
|
if (!cachedDb) {
|
|
98
98
|
const dbPath = getDbPath();
|
|
99
|
-
cachedDb = new Database(dbPath);
|
|
100
|
-
cachedDb.pragma('journal_mode = WAL');
|
|
101
|
-
cachedDb.pragma('foreign_keys = ON');
|
|
102
|
-
// Run pending migrations to handle old project databases
|
|
103
99
|
try {
|
|
104
|
-
|
|
100
|
+
cachedDb = new Database(dbPath);
|
|
101
|
+
cachedDb.pragma('journal_mode = WAL');
|
|
102
|
+
cachedDb.pragma('foreign_keys = ON');
|
|
103
|
+
// Run pending migrations to handle old project databases
|
|
104
|
+
try {
|
|
105
|
+
runMigrations(cachedDb);
|
|
106
|
+
} catch (err) {
|
|
107
|
+
console.error('Failed to run migrations:', err);
|
|
108
|
+
}
|
|
105
109
|
} catch (err) {
|
|
106
|
-
console.error(
|
|
110
|
+
console.error(`[DB] Failed to open database at ${dbPath}:`, err.message);
|
|
111
|
+
cachedDb = null;
|
|
112
|
+
throw err;
|
|
107
113
|
}
|
|
108
114
|
}
|
|
109
115
|
return cachedDb;
|
|
@@ -165,48 +171,8 @@ function registerIpcHandlers() {
|
|
|
165
171
|
`).all();
|
|
166
172
|
const epicMap = new Map(epics.map(e => [e.id, e.title]));
|
|
167
173
|
|
|
168
|
-
//
|
|
169
|
-
const
|
|
170
|
-
SELECT c.id, c.type, c.title, c.description, c.status, c.parent_id, c.epic_id,
|
|
171
|
-
c.branch_name, c.mode, c.phase, c.completed_at, c.created_at,
|
|
172
|
-
wc.current_step, wc.total_steps
|
|
173
|
-
FROM work_items c
|
|
174
|
-
INNER JOIN work_items f ON c.parent_id = f.id
|
|
175
|
-
LEFT JOIN workflow_checkpoints wc ON wc.work_item_id = c.id
|
|
176
|
-
WHERE c.type = 'chore' AND f.type = 'feature'
|
|
177
|
-
ORDER BY c.id
|
|
178
|
-
`).all();
|
|
179
|
-
|
|
180
|
-
const choresByFeature = new Map();
|
|
181
|
-
for (const chore of featureChores) {
|
|
182
|
-
if (chore.parent_id) {
|
|
183
|
-
const existing = choresByFeature.get(chore.parent_id) || [];
|
|
184
|
-
existing.push(chore);
|
|
185
|
-
choresByFeature.set(chore.parent_id, existing);
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
// Get all bugs that belong to features
|
|
190
|
-
const featureBugs = db.prepare(`
|
|
191
|
-
SELECT b.id, b.type, b.title, b.description, b.status, b.parent_id, b.epic_id,
|
|
192
|
-
b.branch_name, b.mode, b.phase, b.completed_at, b.created_at
|
|
193
|
-
FROM work_items b
|
|
194
|
-
INNER JOIN work_items f ON b.parent_id = f.id
|
|
195
|
-
WHERE b.type = 'bug' AND f.type = 'feature'
|
|
196
|
-
ORDER BY b.id
|
|
197
|
-
`).all();
|
|
198
|
-
|
|
199
|
-
const bugsByFeature = new Map();
|
|
200
|
-
for (const bug of featureBugs) {
|
|
201
|
-
if (bug.parent_id) {
|
|
202
|
-
const existing = bugsByFeature.get(bug.parent_id) || [];
|
|
203
|
-
existing.push(bug);
|
|
204
|
-
bugsByFeature.set(bug.parent_id, existing);
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
// Get kanban-eligible items
|
|
209
|
-
const allItems = db.prepare(`
|
|
174
|
+
// Single query: get all non-epic work items with parent type and workflow progress
|
|
175
|
+
const allWorkItems = db.prepare(`
|
|
210
176
|
SELECT w.id, w.type, w.title, w.description, w.status, w.parent_id, w.epic_id,
|
|
211
177
|
w.branch_name, w.mode, w.phase, w.completed_at, w.created_at, w.display_order,
|
|
212
178
|
p.type as parent_type,
|
|
@@ -215,10 +181,27 @@ function registerIpcHandlers() {
|
|
|
215
181
|
LEFT JOIN work_items p ON w.parent_id = p.id
|
|
216
182
|
LEFT JOIN workflow_checkpoints wc ON wc.work_item_id = w.id
|
|
217
183
|
WHERE w.type IN ('feature', 'chore', 'bug')
|
|
218
|
-
AND (w.parent_id IS NULL OR p.type = 'epic')
|
|
219
184
|
ORDER BY COALESCE(w.display_order, w.id)
|
|
220
185
|
`).all();
|
|
221
186
|
|
|
187
|
+
// Partition: children of features vs kanban-eligible items
|
|
188
|
+
const choresByFeature = new Map();
|
|
189
|
+
const bugsByFeature = new Map();
|
|
190
|
+
const allItems = [];
|
|
191
|
+
|
|
192
|
+
for (const item of allWorkItems) {
|
|
193
|
+
if (item.parent_type === 'feature') {
|
|
194
|
+
// Child of a feature — group by parent
|
|
195
|
+
const map = item.type === 'bug' ? bugsByFeature : choresByFeature;
|
|
196
|
+
const existing = map.get(item.parent_id) || [];
|
|
197
|
+
existing.push(item);
|
|
198
|
+
map.set(item.parent_id, existing);
|
|
199
|
+
} else {
|
|
200
|
+
// Top-level or under epic — kanban item
|
|
201
|
+
allItems.push(item);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
222
205
|
const inFlight = [];
|
|
223
206
|
const backlogGroups = new Map();
|
|
224
207
|
const doneGroups = new Map();
|
|
@@ -660,7 +643,58 @@ function registerIpcHandlers() {
|
|
|
660
643
|
return listDevServers();
|
|
661
644
|
});
|
|
662
645
|
|
|
663
|
-
// ==================== Project Dialog ====================
|
|
646
|
+
// ==================== New Project Dialog ====================
|
|
647
|
+
ipcMain.handle('dialog:newProject', async () => {
|
|
648
|
+
const mainWindow = BrowserWindow.getFocusedWindow();
|
|
649
|
+
|
|
650
|
+
const result = await dialog.showSaveDialog(mainWindow, {
|
|
651
|
+
title: 'Create New Project',
|
|
652
|
+
buttonLabel: 'Create',
|
|
653
|
+
nameFieldLabel: 'Project Name:',
|
|
654
|
+
showsTagField: false,
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
if (result.canceled || !result.filePath) {
|
|
658
|
+
return { success: false, canceled: true };
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
const projectPath = result.filePath;
|
|
662
|
+
|
|
663
|
+
// Create the directory
|
|
664
|
+
try {
|
|
665
|
+
fs.mkdirSync(projectPath, { recursive: true });
|
|
666
|
+
} catch (mkdirError) {
|
|
667
|
+
return {
|
|
668
|
+
success: false,
|
|
669
|
+
error: `Failed to create directory: ${mkdirError.message}`
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// Initialize JettyPod in the new directory
|
|
674
|
+
try {
|
|
675
|
+
initializeJettypod(projectPath);
|
|
676
|
+
} catch (initError) {
|
|
677
|
+
return {
|
|
678
|
+
success: false,
|
|
679
|
+
error: `Failed to initialize JettyPod: ${initError.message}`
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// Reset DB and project root BEFORE async call to prevent stale state
|
|
684
|
+
// during await (other IPC handlers could fire while we're waiting)
|
|
685
|
+
closeDb();
|
|
686
|
+
cachedDb = null;
|
|
687
|
+
projectRoot = projectPath;
|
|
688
|
+
|
|
689
|
+
const { setProjectRoot } = require('./main');
|
|
690
|
+
await setProjectRoot(projectPath);
|
|
691
|
+
|
|
692
|
+
addRecentProject(projectPath);
|
|
693
|
+
|
|
694
|
+
return { success: true, path: projectPath };
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
// ==================== Open Project Dialog ====================
|
|
664
698
|
ipcMain.handle('dialog:openProject', async () => {
|
|
665
699
|
const mainWindow = BrowserWindow.getFocusedWindow();
|
|
666
700
|
|
|
@@ -689,17 +723,15 @@ function registerIpcHandlers() {
|
|
|
689
723
|
}
|
|
690
724
|
}
|
|
691
725
|
|
|
692
|
-
//
|
|
693
|
-
//
|
|
694
|
-
const { setProjectRoot } = require('./main');
|
|
695
|
-
await setProjectRoot(selectedPath);
|
|
696
|
-
|
|
697
|
-
// Reset the cached database connection so it reconnects to the new project
|
|
726
|
+
// Reset DB and project root BEFORE async call to prevent stale state
|
|
727
|
+
// during await (other IPC handlers could fire while we're waiting)
|
|
698
728
|
closeDb();
|
|
699
729
|
cachedDb = null;
|
|
700
730
|
projectRoot = selectedPath;
|
|
701
731
|
|
|
702
|
-
|
|
732
|
+
const { setProjectRoot } = require('./main');
|
|
733
|
+
await setProjectRoot(selectedPath);
|
|
734
|
+
|
|
703
735
|
addRecentProject(selectedPath);
|
|
704
736
|
|
|
705
737
|
return { success: true, path: selectedPath };
|
|
@@ -737,17 +769,15 @@ function registerIpcHandlers() {
|
|
|
737
769
|
}
|
|
738
770
|
}
|
|
739
771
|
|
|
740
|
-
//
|
|
741
|
-
//
|
|
742
|
-
const { setProjectRoot } = require('./main');
|
|
743
|
-
await setProjectRoot(projectPath);
|
|
744
|
-
|
|
745
|
-
// Reset the cached database connection so it reconnects to the new project
|
|
772
|
+
// Reset DB and project root BEFORE async call to prevent stale state
|
|
773
|
+
// during await (other IPC handlers could fire while we're waiting)
|
|
746
774
|
closeDb();
|
|
747
775
|
cachedDb = null;
|
|
748
776
|
projectRoot = projectPath;
|
|
749
777
|
|
|
750
|
-
|
|
778
|
+
const { setProjectRoot } = require('./main');
|
|
779
|
+
await setProjectRoot(projectPath);
|
|
780
|
+
|
|
751
781
|
addRecentProject(projectPath);
|
|
752
782
|
|
|
753
783
|
return { success: true, path: projectPath };
|
|
@@ -769,46 +799,209 @@ function registerIpcHandlers() {
|
|
|
769
799
|
return await updateClaudeCode();
|
|
770
800
|
});
|
|
771
801
|
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
802
|
+
ipcMain.handle('claudeCode:isAuthenticated', async () => {
|
|
803
|
+
const { checkClaudeCodeAuthenticated } = require('./main');
|
|
804
|
+
return await checkClaudeCodeAuthenticated();
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
ipcMain.handle('claudeCode:login', async () => {
|
|
808
|
+
const { loginClaudeCode } = require('./main');
|
|
809
|
+
return await loginClaudeCode();
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
// ==================== Subscription Gating ====================
|
|
813
|
+
|
|
814
|
+
const UPDATE_SERVER_URL = 'https://jettypod-update-server.spangbaryn2.workers.dev';
|
|
815
|
+
|
|
816
|
+
ipcMain.handle('subscription:createCheckout', async (event, plan) => {
|
|
817
|
+
try {
|
|
818
|
+
// Include JWT auth header so checkout session gets user_id in metadata
|
|
819
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
820
|
+
const authPath = path.join(app.getPath('userData'), 'auth.json');
|
|
821
|
+
if (fs.existsSync(authPath)) {
|
|
822
|
+
try {
|
|
823
|
+
const authData = JSON.parse(fs.readFileSync(authPath, 'utf-8'));
|
|
824
|
+
if (authData.token) {
|
|
825
|
+
headers['Authorization'] = `Bearer ${authData.token}`;
|
|
826
|
+
}
|
|
827
|
+
} catch {
|
|
828
|
+
// Continue without auth if read fails
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
const response = await fetch(`${UPDATE_SERVER_URL}/checkout/create-session`, {
|
|
833
|
+
method: 'POST',
|
|
834
|
+
headers,
|
|
835
|
+
body: JSON.stringify({ plan: plan || 'monthly' }),
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
if (!response.ok) {
|
|
839
|
+
return { success: false, error: 'Failed to create checkout session' };
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
const data = await response.json();
|
|
843
|
+
if (data.url) {
|
|
844
|
+
const { shell } = require('electron');
|
|
845
|
+
shell.openExternal(data.url);
|
|
846
|
+
return { success: true };
|
|
847
|
+
}
|
|
848
|
+
return { success: false, error: 'No checkout URL returned' };
|
|
849
|
+
} catch (error) {
|
|
850
|
+
return { success: false, error: `Checkout failed: ${error.message}` };
|
|
778
851
|
}
|
|
852
|
+
});
|
|
779
853
|
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
854
|
+
ipcMain.handle('billing:openCustomerPortal', async () => {
|
|
855
|
+
try {
|
|
856
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
857
|
+
const authPath = path.join(app.getPath('userData'), 'auth.json');
|
|
858
|
+
if (fs.existsSync(authPath)) {
|
|
859
|
+
try {
|
|
860
|
+
const authData = JSON.parse(fs.readFileSync(authPath, 'utf-8'));
|
|
861
|
+
if (authData.token) {
|
|
862
|
+
headers['Authorization'] = `Bearer ${authData.token}`;
|
|
863
|
+
}
|
|
864
|
+
} catch {
|
|
865
|
+
// Continue without auth if read fails
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
const response = await fetch(`${UPDATE_SERVER_URL}/billing/customer-portal`, {
|
|
870
|
+
method: 'POST',
|
|
871
|
+
headers,
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
if (!response.ok) {
|
|
875
|
+
return { success: false, error: 'Failed to open billing portal' };
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
const data = await response.json();
|
|
879
|
+
if (data.url) {
|
|
880
|
+
shell.openExternal(data.url);
|
|
881
|
+
return { success: true };
|
|
882
|
+
}
|
|
883
|
+
return { success: false, error: 'No portal URL returned' };
|
|
884
|
+
} catch (error) {
|
|
885
|
+
return { success: false, error: `Billing portal failed: ${error.message}` };
|
|
886
|
+
}
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
ipcMain.handle('subscription:activate', async (event, customerId) => {
|
|
890
|
+
if (!customerId || !customerId.trim().startsWith('cus_')) {
|
|
891
|
+
return { success: false, error: 'Invalid customer ID. It should start with cus_' };
|
|
892
|
+
}
|
|
787
893
|
|
|
788
894
|
try {
|
|
789
|
-
//
|
|
790
|
-
const
|
|
895
|
+
// Validate against the update server
|
|
896
|
+
const response = await fetch(`${UPDATE_SERVER_URL}/subscription/validate`, {
|
|
897
|
+
method: 'POST',
|
|
898
|
+
headers: { 'Content-Type': 'application/json' },
|
|
899
|
+
body: JSON.stringify({ customerId: customerId.trim() }),
|
|
900
|
+
});
|
|
901
|
+
|
|
902
|
+
const result = await response.json();
|
|
903
|
+
if (!result.valid) {
|
|
904
|
+
return { success: false, error: result.error || 'No active subscription found' };
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// Store the subscription
|
|
908
|
+
const subPath = path.join(app.getPath('userData'), 'subscription.json');
|
|
909
|
+
const subData = {
|
|
910
|
+
customerId: customerId.trim(),
|
|
911
|
+
activatedAt: new Date().toISOString(),
|
|
912
|
+
};
|
|
913
|
+
|
|
914
|
+
const dir = path.dirname(subPath);
|
|
791
915
|
if (!fs.existsSync(dir)) {
|
|
792
916
|
fs.mkdirSync(dir, { recursive: true });
|
|
793
917
|
}
|
|
794
|
-
fs.writeFileSync(
|
|
918
|
+
fs.writeFileSync(subPath, JSON.stringify(subData, null, 2));
|
|
795
919
|
return { success: true };
|
|
796
920
|
} catch (error) {
|
|
797
|
-
return { success: false, error: `
|
|
921
|
+
return { success: false, error: `Activation failed: ${error.message}` };
|
|
922
|
+
}
|
|
923
|
+
});
|
|
924
|
+
|
|
925
|
+
ipcMain.handle('subscription:getStatus', () => {
|
|
926
|
+
const subPath = path.join(app.getPath('userData'), 'subscription.json');
|
|
927
|
+
if (!fs.existsSync(subPath)) {
|
|
928
|
+
return { active: false };
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
try {
|
|
932
|
+
const data = JSON.parse(fs.readFileSync(subPath, 'utf-8'));
|
|
933
|
+
return { active: !!data.customerId, customerId: data.customerId, activatedAt: data.activatedAt };
|
|
934
|
+
} catch {
|
|
935
|
+
return { active: false };
|
|
936
|
+
}
|
|
937
|
+
});
|
|
938
|
+
|
|
939
|
+
// ==================== Auth (JWT-based) ====================
|
|
940
|
+
|
|
941
|
+
ipcMain.handle('auth:loginWithGoogle', () => {
|
|
942
|
+
const { shell } = require('electron');
|
|
943
|
+
shell.openExternal(`${UPDATE_SERVER_URL}/auth/google`);
|
|
944
|
+
return { success: true };
|
|
945
|
+
});
|
|
946
|
+
|
|
947
|
+
ipcMain.handle('auth:saveToken', (event, token, user) => {
|
|
948
|
+
const authPath = path.join(app.getPath('userData'), 'auth.json');
|
|
949
|
+
const dir = path.dirname(authPath);
|
|
950
|
+
if (!fs.existsSync(dir)) {
|
|
951
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
952
|
+
}
|
|
953
|
+
fs.writeFileSync(authPath, JSON.stringify({ token, user, savedAt: new Date().toISOString() }, null, 2));
|
|
954
|
+
|
|
955
|
+
// Mark that the user has logged in at least once (persists across logout)
|
|
956
|
+
const markerPath = path.join(app.getPath('userData'), 'has-logged-in');
|
|
957
|
+
if (!fs.existsSync(markerPath)) {
|
|
958
|
+
fs.writeFileSync(markerPath, new Date().toISOString());
|
|
798
959
|
}
|
|
960
|
+
|
|
961
|
+
return { success: true };
|
|
799
962
|
});
|
|
800
963
|
|
|
801
|
-
ipcMain.handle('
|
|
802
|
-
const
|
|
803
|
-
if (!fs.existsSync(
|
|
804
|
-
return {
|
|
964
|
+
ipcMain.handle('auth:getStatus', () => {
|
|
965
|
+
const authPath = path.join(app.getPath('userData'), 'auth.json');
|
|
966
|
+
if (!fs.existsSync(authPath)) {
|
|
967
|
+
return { authenticated: false };
|
|
805
968
|
}
|
|
806
969
|
|
|
807
970
|
try {
|
|
808
|
-
const data = JSON.parse(fs.readFileSync(
|
|
809
|
-
return {
|
|
971
|
+
const data = JSON.parse(fs.readFileSync(authPath, 'utf-8'));
|
|
972
|
+
return { authenticated: !!data.token, token: data.token, user: data.user };
|
|
973
|
+
} catch {
|
|
974
|
+
return { authenticated: false };
|
|
975
|
+
}
|
|
976
|
+
});
|
|
977
|
+
|
|
978
|
+
ipcMain.handle('auth:getToken', () => {
|
|
979
|
+
const authPath = path.join(app.getPath('userData'), 'auth.json');
|
|
980
|
+
if (!fs.existsSync(authPath)) return null;
|
|
981
|
+
|
|
982
|
+
try {
|
|
983
|
+
const data = JSON.parse(fs.readFileSync(authPath, 'utf-8'));
|
|
984
|
+
return data.token || null;
|
|
985
|
+
} catch {
|
|
986
|
+
return null;
|
|
987
|
+
}
|
|
988
|
+
});
|
|
989
|
+
|
|
990
|
+
ipcMain.handle('auth:logout', () => {
|
|
991
|
+
const authPath = path.join(app.getPath('userData'), 'auth.json');
|
|
992
|
+
if (fs.existsSync(authPath)) {
|
|
993
|
+
fs.unlinkSync(authPath);
|
|
994
|
+
}
|
|
995
|
+
// Note: does NOT delete has-logged-in marker — returning users see /login, not /signup
|
|
996
|
+
return { success: true };
|
|
997
|
+
});
|
|
998
|
+
|
|
999
|
+
ipcMain.handle('auth:hasLoggedInBefore', () => {
|
|
1000
|
+
try {
|
|
1001
|
+
const markerPath = path.join(app.getPath('userData'), 'has-logged-in');
|
|
1002
|
+
return fs.existsSync(markerPath);
|
|
810
1003
|
} catch {
|
|
811
|
-
return
|
|
1004
|
+
return false;
|
|
812
1005
|
}
|
|
813
1006
|
});
|
|
814
1007
|
|