jettypod 4.4.120 → 4.4.121

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (208) hide show
  1. package/.env +2 -1
  2. package/Cargo.lock +6450 -0
  3. package/Cargo.toml +35 -0
  4. package/README.md +5 -1
  5. package/TAURI-MIGRATION-PLAN.md +840 -0
  6. package/apps/dashboard/app/connect-claude/page.tsx +5 -6
  7. package/apps/dashboard/app/decision/[id]/page.tsx +54 -49
  8. package/apps/dashboard/app/demo/gates/page.tsx +3 -5
  9. package/apps/dashboard/app/design-system/page.tsx +1 -1
  10. package/apps/dashboard/app/globals.css +74 -2
  11. package/apps/dashboard/app/install-claude/page.tsx +3 -5
  12. package/apps/dashboard/app/login/page.tsx +17 -20
  13. package/apps/dashboard/app/page.tsx +101 -48
  14. package/apps/dashboard/app/settings/page.tsx +60 -12
  15. package/apps/dashboard/app/signup/page.tsx +14 -17
  16. package/apps/dashboard/app/subscribe/page.tsx +0 -2
  17. package/apps/dashboard/app/tests/page.tsx +37 -4
  18. package/apps/dashboard/app/welcome/page.tsx +12 -15
  19. package/apps/dashboard/app/work/[id]/page.tsx +90 -75
  20. package/apps/dashboard/app/work/[id]/proof/page.tsx +1489 -0
  21. package/apps/dashboard/components/AppShell.tsx +70 -61
  22. package/apps/dashboard/components/CardMenu.tsx +0 -1
  23. package/apps/dashboard/components/ClaudePanel.tsx +541 -283
  24. package/apps/dashboard/components/ClaudePanelInput.tsx +23 -4
  25. package/apps/dashboard/components/ConnectClaudeScreen.tsx +1 -5
  26. package/apps/dashboard/components/CopyableId.tsx +1 -2
  27. package/apps/dashboard/components/DetailReviewActions.tsx +11 -20
  28. package/apps/dashboard/components/DragContext.tsx +132 -62
  29. package/apps/dashboard/components/DraggableCard.tsx +3 -5
  30. package/apps/dashboard/components/DropZone.tsx +5 -6
  31. package/apps/dashboard/components/EditableDetailDescription.tsx +6 -12
  32. package/apps/dashboard/components/EditableDetailTitle.tsx +6 -13
  33. package/apps/dashboard/components/EditableTitle.tsx +0 -1
  34. package/apps/dashboard/components/ElapsedTimer.tsx +15 -3
  35. package/apps/dashboard/components/EpicGroup.tsx +100 -70
  36. package/apps/dashboard/components/GateCard.tsx +0 -1
  37. package/apps/dashboard/components/GateChoiceCard.tsx +1 -2
  38. package/apps/dashboard/components/InstallClaudeScreen.tsx +1 -5
  39. package/apps/dashboard/components/JettyLoader.tsx +0 -1
  40. package/apps/dashboard/components/KanbanBoard.tsx +319 -173
  41. package/apps/dashboard/components/KanbanCard.tsx +341 -107
  42. package/apps/dashboard/components/LazyCard.tsx +62 -0
  43. package/apps/dashboard/components/LazyMarkdown.tsx +0 -1
  44. package/apps/dashboard/components/MainNav.tsx +24 -25
  45. package/apps/dashboard/components/MessageBlock.tsx +93 -16
  46. package/apps/dashboard/components/ModeStartCard.tsx +0 -1
  47. package/apps/dashboard/components/OnboardingWelcome.tsx +0 -1
  48. package/apps/dashboard/components/PlaceholderCard.tsx +0 -1
  49. package/apps/dashboard/components/ProjectSwitcher.tsx +20 -20
  50. package/apps/dashboard/components/PrototypeTimeline.tsx +47 -26
  51. package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +308 -223
  52. package/apps/dashboard/components/RealTimeTestsWrapper.tsx +303 -160
  53. package/apps/dashboard/components/ReviewFooter.tsx +12 -14
  54. package/apps/dashboard/components/SessionList.tsx +0 -1
  55. package/apps/dashboard/components/SubscribeContent.tsx +40 -11
  56. package/apps/dashboard/components/TestTree.tsx +1 -2
  57. package/apps/dashboard/components/TipCard.tsx +2 -4
  58. package/apps/dashboard/components/Toast.tsx +0 -1
  59. package/apps/dashboard/components/TypeIcon.tsx +7 -8
  60. package/apps/dashboard/components/ViewModeToolbar.tsx +104 -0
  61. package/apps/dashboard/components/WaveCompletionAnimation.tsx +5 -17
  62. package/apps/dashboard/components/WelcomeScreen.tsx +2 -6
  63. package/apps/dashboard/components/WorkItemHeader.tsx +0 -1
  64. package/apps/dashboard/components/WorkItemTree.tsx +2 -4
  65. package/apps/dashboard/components/settings/AccountSection.tsx +27 -13
  66. package/apps/dashboard/components/settings/AiContextSection.tsx +89 -0
  67. package/apps/dashboard/components/settings/ContextDocumentsSection.tsx +317 -0
  68. package/apps/dashboard/components/settings/EnvVarsSection.tsx +20 -73
  69. package/apps/dashboard/components/settings/GeneralSection.tsx +137 -26
  70. package/apps/dashboard/components/settings/ProjectStackSection.tsx +948 -0
  71. package/apps/dashboard/components/settings/SettingsLayout.tsx +0 -1
  72. package/apps/dashboard/components/ui/Button.tsx +1 -1
  73. package/apps/dashboard/components/ui/Input.tsx +1 -1
  74. package/apps/dashboard/components.json +1 -1
  75. package/apps/dashboard/contexts/ClaudeSessionContext.tsx +611 -358
  76. package/apps/dashboard/contexts/ConnectionStatusContext.tsx +0 -1
  77. package/apps/dashboard/contexts/UsageContext.tsx +62 -31
  78. package/apps/dashboard/dev.sh +35 -0
  79. package/apps/dashboard/eslint.config.mjs +9 -9
  80. package/apps/dashboard/hooks/useWebSocket.ts +138 -83
  81. package/apps/dashboard/index.html +73 -0
  82. package/apps/dashboard/lib/data-bridge.ts +722 -0
  83. package/apps/dashboard/lib/db.ts +69 -1302
  84. package/apps/dashboard/lib/environment-config.ts +173 -0
  85. package/apps/dashboard/lib/environment-verification.ts +119 -0
  86. package/apps/dashboard/lib/kanban-utils.ts +226 -26
  87. package/apps/dashboard/lib/proof-run.ts +495 -0
  88. package/apps/dashboard/lib/proof-scenario-runner.ts +346 -0
  89. package/apps/dashboard/lib/service-recovery.ts +326 -0
  90. package/apps/dashboard/lib/session-state-machine.ts +1 -0
  91. package/apps/dashboard/lib/session-state-utils.ts +0 -164
  92. package/apps/dashboard/lib/session-stream-manager.ts +253 -122
  93. package/apps/dashboard/lib/stream-manager-registry.ts +46 -6
  94. package/apps/dashboard/lib/tauri-bridge.ts +102 -0
  95. package/apps/dashboard/lib/tauri.ts +106 -0
  96. package/apps/dashboard/lib/utils.ts +3 -3
  97. package/apps/dashboard/next-env.d.ts +1 -1
  98. package/apps/dashboard/package.json +21 -33
  99. package/apps/dashboard/public/bug-icon.png +0 -0
  100. package/apps/dashboard/public/buoy-icon.png +0 -0
  101. package/apps/dashboard/public/in-flight-seagull.png +0 -0
  102. package/apps/dashboard/public/pier-icon.png +0 -0
  103. package/apps/dashboard/public/star-icon.png +0 -0
  104. package/apps/dashboard/public/wrench-icon.png +0 -0
  105. package/apps/dashboard/scripts/tauri-build.js +228 -0
  106. package/apps/dashboard/scripts/upload-tauri-to-r2.js +125 -0
  107. package/apps/dashboard/src/main.tsx +12 -0
  108. package/apps/dashboard/src/router.tsx +107 -0
  109. package/apps/dashboard/src/vite-env.d.ts +1 -0
  110. package/apps/dashboard/tsconfig.json +7 -12
  111. package/apps/dashboard/tsconfig.tsbuildinfo +1 -1
  112. package/apps/dashboard/vite.config.ts +33 -0
  113. package/apps/update-server/src/index.ts +167 -30
  114. package/claude-hooks/global-guardrails.js +14 -13
  115. package/crates/jettypod-cli/Cargo.toml +19 -0
  116. package/crates/jettypod-cli/src/commands.rs +1249 -0
  117. package/crates/jettypod-cli/src/main.rs +595 -0
  118. package/crates/jettypod-core/Cargo.toml +26 -0
  119. package/crates/jettypod-core/build.rs +98 -0
  120. package/crates/jettypod-core/migrations/V1__baseline.sql +197 -0
  121. package/crates/jettypod-core/migrations/V2__work_items_indexes.sql +6 -0
  122. package/crates/jettypod-core/migrations/V3__qa_steps.sql +2 -0
  123. package/crates/jettypod-core/src/auth.rs +294 -0
  124. package/crates/jettypod-core/src/config.rs +397 -0
  125. package/crates/jettypod-core/src/db/mod.rs +507 -0
  126. package/crates/jettypod-core/src/db/recovery.rs +114 -0
  127. package/crates/jettypod-core/src/db/startup.rs +101 -0
  128. package/crates/jettypod-core/src/db/validate.rs +149 -0
  129. package/crates/jettypod-core/src/error.rs +76 -0
  130. package/crates/jettypod-core/src/git.rs +458 -0
  131. package/crates/jettypod-core/src/lib.rs +20 -0
  132. package/crates/jettypod-core/src/sessions.rs +625 -0
  133. package/crates/jettypod-core/src/skills.rs +556 -0
  134. package/crates/jettypod-core/src/work.rs +1086 -0
  135. package/crates/jettypod-core/src/worktree.rs +628 -0
  136. package/crates/jettypod-core/src/ws.rs +767 -0
  137. package/cucumber-test.cjs +6 -0
  138. package/jettypod.js +96 -4
  139. package/lib/bdd-preflight.js +96 -0
  140. package/lib/merge-lock.js +111 -253
  141. package/lib/migrations/030-rejection-round-columns.js +54 -0
  142. package/lib/migrations/031-session-isolation-index.js +17 -0
  143. package/lib/work-commands/index.js +58 -16
  144. package/lib/work-tracking/index.js +108 -8
  145. package/package.json +1 -1
  146. package/skills-templates/bug-mode/SKILL.md +43 -1
  147. package/skills-templates/chore-mode/SKILL.md +40 -1
  148. package/skills-templates/design-system-selection/SKILL.md +273 -0
  149. package/skills-templates/epic-planning/SKILL.md +14 -0
  150. package/skills-templates/feature-planning/SKILL.md +90 -1
  151. package/skills-templates/production-mode/SKILL.md +20 -0
  152. package/skills-templates/simple-improvement/SKILL.md +39 -2
  153. package/skills-templates/speed-mode/SKILL.md +10 -15
  154. package/skills-templates/stable-mode/SKILL.md +47 -0
  155. package/apps/dashboard/README.md +0 -36
  156. package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +0 -446
  157. package/apps/dashboard/app/api/claude/[workItemId]/pin/route.ts +0 -24
  158. package/apps/dashboard/app/api/claude/[workItemId]/route.ts +0 -280
  159. package/apps/dashboard/app/api/claude/sessions/[sessionId]/content/route.ts +0 -52
  160. package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +0 -525
  161. package/apps/dashboard/app/api/claude/sessions/[sessionId]/pin/route.ts +0 -24
  162. package/apps/dashboard/app/api/claude/sessions/cleanup/route.ts +0 -34
  163. package/apps/dashboard/app/api/claude/sessions/route.ts +0 -184
  164. package/apps/dashboard/app/api/decisions/[id]/route.ts +0 -25
  165. package/apps/dashboard/app/api/internal/set-project/route.ts +0 -17
  166. package/apps/dashboard/app/api/kanban/route.ts +0 -15
  167. package/apps/dashboard/app/api/settings/env-vars/route.ts +0 -125
  168. package/apps/dashboard/app/api/settings/general/route.ts +0 -21
  169. package/apps/dashboard/app/api/tests/route.ts +0 -9
  170. package/apps/dashboard/app/api/tests/run/route.ts +0 -82
  171. package/apps/dashboard/app/api/tests/run/stream/route.ts +0 -71
  172. package/apps/dashboard/app/api/tests/undefined/route.ts +0 -9
  173. package/apps/dashboard/app/api/usage/route.ts +0 -17
  174. package/apps/dashboard/app/api/work/[id]/description/route.ts +0 -21
  175. package/apps/dashboard/app/api/work/[id]/epic/route.ts +0 -21
  176. package/apps/dashboard/app/api/work/[id]/order/route.ts +0 -21
  177. package/apps/dashboard/app/api/work/[id]/route.ts +0 -35
  178. package/apps/dashboard/app/api/work/[id]/status/route.ts +0 -63
  179. package/apps/dashboard/app/api/work/[id]/title/route.ts +0 -21
  180. package/apps/dashboard/app/layout.tsx +0 -55
  181. package/apps/dashboard/components/UpgradeBanner.tsx +0 -30
  182. package/apps/dashboard/electron/ipc-handlers.js +0 -1026
  183. package/apps/dashboard/electron/main.js +0 -2306
  184. package/apps/dashboard/electron/preload.js +0 -125
  185. package/apps/dashboard/electron/session-manager.js +0 -163
  186. package/apps/dashboard/electron-builder.config.js +0 -357
  187. package/apps/dashboard/hooks/useClaudeSessions.ts +0 -299
  188. package/apps/dashboard/lib/backlog-parser.ts +0 -50
  189. package/apps/dashboard/lib/claude-process-manager.ts +0 -529
  190. package/apps/dashboard/lib/db-bridge.ts +0 -283
  191. package/apps/dashboard/lib/prototypes.ts +0 -202
  192. package/apps/dashboard/lib/test-results-db.ts +0 -307
  193. package/apps/dashboard/lib/tests.ts +0 -282
  194. package/apps/dashboard/next.config.js +0 -66
  195. package/apps/dashboard/postcss.config.mjs +0 -7
  196. package/apps/dashboard/public/bug-icon.svg +0 -9
  197. package/apps/dashboard/public/buoy-icon.svg +0 -9
  198. package/apps/dashboard/public/file.svg +0 -1
  199. package/apps/dashboard/public/globe.svg +0 -1
  200. package/apps/dashboard/public/in-flight-seagull.svg +0 -9
  201. package/apps/dashboard/public/next.svg +0 -1
  202. package/apps/dashboard/public/pier-icon.svg +0 -14
  203. package/apps/dashboard/public/star-icon.svg +0 -9
  204. package/apps/dashboard/public/vercel.svg +0 -1
  205. package/apps/dashboard/public/window.svg +0 -1
  206. package/apps/dashboard/public/wrench-icon.svg +0 -9
  207. package/apps/dashboard/scripts/download-node.js +0 -104
  208. package/apps/dashboard/scripts/upload-to-r2.js +0 -89
@@ -0,0 +1,33 @@
1
+ import { defineConfig } from 'vite'
2
+ import react from '@vitejs/plugin-react-swc'
3
+ import tailwindcss from '@tailwindcss/vite'
4
+ import path from 'path'
5
+
6
+ export default defineConfig({
7
+ plugins: [
8
+ react(),
9
+ tailwindcss(),
10
+ ],
11
+ resolve: {
12
+ alias: {
13
+ '@': path.resolve(__dirname, '.'),
14
+ },
15
+ },
16
+ server: {
17
+ port: 1420,
18
+ strictPort: true,
19
+ },
20
+ build: {
21
+ outDir: 'dist',
22
+ emptyOutDir: true,
23
+ rollupOptions: {
24
+ output: {
25
+ manualChunks: {
26
+ 'vendor-motion': ['framer-motion'],
27
+ 'vendor-dnd': ['@dnd-kit/core', '@dnd-kit/utilities'],
28
+ 'vendor-markdown': ['react-markdown', 'remark-gfm'],
29
+ },
30
+ },
31
+ },
32
+ },
33
+ })
@@ -58,10 +58,41 @@ interface StripeCheckoutSession {
58
58
  subscription: string;
59
59
  }
60
60
 
61
+ interface TauriPlatformArtifact {
62
+ url: string;
63
+ signature: string;
64
+ }
65
+
66
+ interface TauriUpdateManifest {
67
+ version: string;
68
+ notes: string;
69
+ pub_date: string;
70
+ platforms: Record<string, TauriPlatformArtifact>;
71
+ }
72
+
61
73
  // ─── JWT ────────────────────────────────────────────────────────────
62
74
 
63
75
  const JWT_EXPIRY = 30 * 24 * 60 * 60; // 30 days
64
76
 
77
+ // Cache imported CryptoKeys to avoid re-importing on every JWT operation
78
+ const cryptoKeyCache = new Map<string, CryptoKey>();
79
+
80
+ async function getHmacKey(secret: string, usage: 'sign' | 'verify'): Promise<CryptoKey> {
81
+ const cacheKey = `${secret}:${usage}`;
82
+ let key = cryptoKeyCache.get(cacheKey);
83
+ if (!key) {
84
+ key = await crypto.subtle.importKey(
85
+ 'raw',
86
+ new TextEncoder().encode(secret),
87
+ { name: 'HMAC', hash: 'SHA-256' },
88
+ false,
89
+ [usage]
90
+ );
91
+ cryptoKeyCache.set(cacheKey, key);
92
+ }
93
+ return key;
94
+ }
95
+
65
96
  async function signJWT(payload: JWTPayload, secret: string): Promise<string> {
66
97
  const header = { alg: 'HS256', typ: 'JWT' };
67
98
  const encode = (obj: unknown) =>
@@ -71,13 +102,7 @@ async function signJWT(payload: JWTPayload, secret: string): Promise<string> {
71
102
  const payloadB64 = encode(payload);
72
103
  const signingInput = `${headerB64}.${payloadB64}`;
73
104
 
74
- const key = await crypto.subtle.importKey(
75
- 'raw',
76
- new TextEncoder().encode(secret),
77
- { name: 'HMAC', hash: 'SHA-256' },
78
- false,
79
- ['sign']
80
- );
105
+ const key = await getHmacKey(secret, 'sign');
81
106
  const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(signingInput));
82
107
  const sigB64 = btoa(String.fromCharCode(...new Uint8Array(sig)))
83
108
  .replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
@@ -92,13 +117,7 @@ async function verifyJWT(token: string, secret: string): Promise<JWTPayload | nu
92
117
  const [headerB64, payloadB64, sigB64] = parts;
93
118
  const signingInput = `${headerB64}.${payloadB64}`;
94
119
 
95
- const key = await crypto.subtle.importKey(
96
- 'raw',
97
- new TextEncoder().encode(secret),
98
- { name: 'HMAC', hash: 'SHA-256' },
99
- false,
100
- ['verify']
101
- );
120
+ const key = await getHmacKey(secret, 'verify');
102
121
 
103
122
  const sigBytes = Uint8Array.from(
104
123
  atob(sigB64.replace(/-/g, '+').replace(/_/g, '/')),
@@ -329,7 +348,7 @@ async function handleSendOTP(request: Request, env: Env): Promise<Response> {
329
348
 
330
349
  await env.AUTH_KV.put(`otp:${email}`, code, { expirationTtl: OTP_TTL });
331
350
 
332
- await fetch('https://api.resend.com/emails', {
351
+ const emailRes = await fetch('https://api.resend.com/emails', {
333
352
  method: 'POST',
334
353
  headers: {
335
354
  Authorization: `Bearer ${env.RESEND_API_KEY}`,
@@ -343,6 +362,11 @@ async function handleSendOTP(request: Request, env: Env): Promise<Response> {
343
362
  }),
344
363
  });
345
364
 
365
+ if (!emailRes.ok) {
366
+ console.error('Failed to send OTP email:', emailRes.status, await emailRes.text());
367
+ return Response.json({ error: 'Failed to send email' }, { status: 502 });
368
+ }
369
+
346
370
  return Response.json({ sent: true });
347
371
  }
348
372
 
@@ -506,11 +530,17 @@ async function incrementUsage(db: D1Database, userId: string): Promise<void> {
506
530
  // ─── Authenticated Route Handlers ───────────────────────────────────
507
531
 
508
532
  async function handleGetMe(user: JWTPayload, env: Env): Promise<Response> {
509
- // Check D1 for current plan (may differ from JWT if webhook updated it)
510
- const dbUser = await env.AUTH_DB.prepare('SELECT * FROM users WHERE id = ?').bind(user.sub).first<User>();
533
+ // Run user fetch and usage check in parallel to avoid sequential DB queries
534
+ const [dbUser, usageFromJwt] = await Promise.all([
535
+ env.AUTH_DB.prepare('SELECT * FROM users WHERE id = ?').bind(user.sub).first<User>(),
536
+ checkUsageLimit(env.AUTH_DB, user.sub, user.plan),
537
+ ]);
511
538
  const currentPlan = dbUser?.plan || user.plan;
512
539
 
513
- const usage = await checkUsageLimit(env.AUTH_DB, user.sub, currentPlan);
540
+ // If plan changed from JWT, re-check usage with correct plan
541
+ const usage = currentPlan !== user.plan
542
+ ? await checkUsageLimit(env.AUTH_DB, user.sub, currentPlan)
543
+ : usageFromJwt;
514
544
 
515
545
  const response: Record<string, unknown> = {
516
546
  user: { id: user.sub, email: user.email, plan: currentPlan },
@@ -595,6 +625,68 @@ async function determinePlanFromStripe(customerId: string, env: Env): Promise<st
595
625
  return 'free';
596
626
  }
597
627
 
628
+ // ─── Platform Telemetry ─────────────────────────────────────────────
629
+
630
+ function logUpdateCheck(request: Request, platform: 'electron' | 'tauri', meta: Record<string, string> = {}): void {
631
+ const userAgent = request.headers.get('User-Agent') || 'unknown';
632
+ console.log(JSON.stringify({
633
+ event: 'update_check',
634
+ platform,
635
+ user_agent: userAgent,
636
+ ...meta,
637
+ }));
638
+ }
639
+
640
+ // ─── Tauri Update ──────────────────────────────────────────────────
641
+
642
+ /** Compare two semver strings. Returns 1 if a > b, -1 if a < b, 0 if equal. */
643
+ function compareSemver(a: string, b: string): number {
644
+ const pa = a.replace(/^v/, '').split('.').map(Number);
645
+ const pb = b.replace(/^v/, '').split('.').map(Number);
646
+ for (let i = 0; i < 3; i++) {
647
+ const diff = (pa[i] || 0) - (pb[i] || 0);
648
+ if (diff !== 0) return diff > 0 ? 1 : -1;
649
+ }
650
+ return 0;
651
+ }
652
+
653
+ async function handleTauriUpdate(
654
+ target: string,
655
+ arch: string,
656
+ currentVersion: string,
657
+ requestUrl: URL,
658
+ env: Env
659
+ ): Promise<Response> {
660
+ const object = await env.RELEASE_ARTIFACTS.get('tauri-releases.json');
661
+ if (!object) {
662
+ return Response.json({ error: 'No Tauri releases available' }, { status: 404 });
663
+ }
664
+
665
+ const manifest = (await object.json()) as TauriUpdateManifest;
666
+ const platformKey = `${target}-${arch}`;
667
+ const platform = manifest.platforms[platformKey];
668
+
669
+ if (!platform) {
670
+ return Response.json({ error: `Unsupported platform: ${platformKey}` }, { status: 404 });
671
+ }
672
+
673
+ if (compareSemver(manifest.version, currentVersion) <= 0) {
674
+ return new Response('', { status: 204 });
675
+ }
676
+
677
+ const downloadUrl = platform.url.startsWith('http')
678
+ ? platform.url
679
+ : `${requestUrl.origin}/updates/download/${platform.url}`;
680
+
681
+ return Response.json({
682
+ version: manifest.version,
683
+ notes: manifest.notes,
684
+ pub_date: manifest.pub_date,
685
+ url: downloadUrl,
686
+ signature: platform.signature,
687
+ });
688
+ }
689
+
598
690
  // ─── Existing Route Handlers ────────────────────────────────────────
599
691
 
600
692
  async function handleUpdateManifest(env: Env): Promise<Response> {
@@ -606,7 +698,7 @@ async function handleUpdateManifest(env: Env): Promise<Response> {
606
698
  return new Response(object.body, {
607
699
  headers: {
608
700
  'Content-Type': 'text/yaml',
609
- 'Cache-Control': 'no-cache',
701
+ 'Cache-Control': 'public, max-age=3600, must-revalidate',
610
702
  'ETag': object.httpEtag,
611
703
  },
612
704
  });
@@ -641,22 +733,54 @@ async function handleArtifactDownload(pathname: string, env: Env): Promise<Respo
641
733
  });
642
734
  }
643
735
 
736
+ async function handleTauriPublicDownload(platformKey: string, env: Env): Promise<Response | null> {
737
+ const object = await env.RELEASE_ARTIFACTS.get('tauri-releases.json');
738
+ if (!object) return null;
739
+
740
+ const manifest = (await object.json()) as TauriUpdateManifest;
741
+ const platform = manifest.platforms[platformKey];
742
+ if (!platform?.url) return null;
743
+
744
+ const filename = platform.url;
745
+ const artifact = await env.RELEASE_ARTIFACTS.get(filename);
746
+ if (!artifact) return null;
747
+
748
+ const contentType = filename.endsWith('.dmg')
749
+ ? 'application/x-apple-diskimage'
750
+ : filename.endsWith('.AppImage')
751
+ ? 'application/x-executable'
752
+ : 'application/octet-stream';
753
+
754
+ return new Response(artifact.body, {
755
+ headers: {
756
+ 'Content-Type': contentType,
757
+ 'Content-Length': artifact.size.toString(),
758
+ 'Content-Disposition': `attachment; filename="${filename}"`,
759
+ 'Cache-Control': 'public, max-age=86400',
760
+ 'ETag': artifact.httpEtag,
761
+ },
762
+ });
763
+ }
764
+
644
765
  async function handlePublicDownload(arch: string, env: Env): Promise<Response> {
645
- // Parse latest-mac.yml to find current filenames
766
+ // Try Tauri artifacts first
767
+ const platformKey = arch === 'arm64' ? 'darwin-aarch64' : 'darwin-x86_64';
768
+ const tauriResponse = await handleTauriPublicDownload(platformKey, env);
769
+ if (tauriResponse) return tauriResponse;
770
+
771
+ // Fall back to Electron manifest (latest-mac.yml) during transition
646
772
  const manifest = await env.RELEASE_ARTIFACTS.get('latest-mac.yml');
647
773
  if (!manifest) {
648
774
  return Response.json({ error: 'No releases available' }, { status: 404 });
649
775
  }
650
776
 
651
777
  const yml = await manifest.text();
652
- // Find DMG filenames from the manifest
653
778
  const dmgFiles = [...yml.matchAll(/url:\s*(\S+\.dmg)/g)].map(m => m[1]);
654
779
 
655
780
  let filename: string | undefined;
656
781
  if (arch === 'arm64') {
657
782
  filename = dmgFiles.find(f => f.includes('arm64'));
658
783
  } else {
659
- // x64 — the one without arm64
660
784
  filename = dmgFiles.find(f => !f.includes('arm64'));
661
785
  }
662
786
 
@@ -700,13 +824,7 @@ async function verifyStripeSignature(
700
824
  if (age > 300) return false;
701
825
 
702
826
  const signedPayload = `${timestamp}.${payload}`;
703
- const key = await crypto.subtle.importKey(
704
- 'raw',
705
- new TextEncoder().encode(secret),
706
- { name: 'HMAC', hash: 'SHA-256' },
707
- false,
708
- ['sign']
709
- );
827
+ const key = await getHmacKey(secret, 'sign');
710
828
  const sig = await crypto.subtle.sign(
711
829
  'HMAC',
712
830
  key,
@@ -1023,12 +1141,19 @@ async function handleRequest(request: Request, url: URL, pathname: string, env:
1023
1141
  return handlePublicDownload('arm64', env);
1024
1142
  }
1025
1143
 
1144
+ if (request.method === 'GET' && pathname === '/download/linux') {
1145
+ const response = await handleTauriPublicDownload('linux-x86_64', env);
1146
+ if (response) return response;
1147
+ return Response.json({ error: 'No Linux release available' }, { status: 404 });
1148
+ }
1149
+
1026
1150
  // ── Update routes (paid plan or legacy cus_ token) ─────────────
1027
1151
 
1028
- // Update manifest
1152
+ // Update manifest (Electron)
1029
1153
  if (request.method === 'GET' && pathname === '/updates/latest-mac.yml') {
1030
1154
  const authError = await authenticateUpdateRequest(request, env);
1031
1155
  if (authError) return authError;
1156
+ logUpdateCheck(request, 'electron');
1032
1157
  return handleUpdateManifest(env);
1033
1158
  }
1034
1159
 
@@ -1039,6 +1164,18 @@ async function handleRequest(request: Request, url: URL, pathname: string, env:
1039
1164
  return handleArtifactDownload(pathname, env);
1040
1165
  }
1041
1166
 
1167
+ // Tauri update manifest — /tauri/:target/:arch/:current_version
1168
+ if (request.method === 'GET' && pathname.startsWith('/tauri/')) {
1169
+ const authError = await authenticateUpdateRequest(request, env);
1170
+ if (authError) return authError;
1171
+ const parts = pathname.split('/').filter(Boolean); // ["tauri", target, arch, version]
1172
+ if (parts.length === 4) {
1173
+ logUpdateCheck(request, 'tauri', { target: parts[1], arch: parts[2], current_version: parts[3] });
1174
+ return handleTauriUpdate(parts[1], parts[2], parts[3], url, env);
1175
+ }
1176
+ return Response.json({ error: 'Invalid update URL format' }, { status: 400 });
1177
+ }
1178
+
1042
1179
  // ── Stripe/checkout routes (existing) ──────────────────────────
1043
1180
 
1044
1181
  if (request.method === 'POST' && pathname === '/checkout/create-session') {
@@ -245,28 +245,29 @@ function evaluateBashCommand(command, inputRef, cwd) {
245
245
  }
246
246
  }
247
247
 
248
- // BLOCKED: work merge or tests merge from inside a worktree
249
- // This prevents shell CWD corruption when worktree is deleted
248
+ // BLOCKED: work merge or tests merge from inside a worktree WITHOUT explicit ID
249
+ // Merge with explicit ID is safe: the merge command handles CWD internally via
250
+ // process.chdir() and does NOT delete the worktree (only cleanup does).
251
+ // Bare merge (no ID) is blocked because getCurrentWork() relies on branch detection
252
+ // which breaks after process.chdir() moves away from the worktree.
250
253
  if (/jettypod\s+(work|tests)\s+merge\b/.test(strippedCommand)) {
251
254
  const isInWorktree = cwd && cwd.includes('.jettypod-work');
252
- if (isInWorktree) {
253
- // Extract main repo path and work item ID for helpful error message
255
+ const hasExplicitId = /merge\s+\d+/.test(strippedCommand);
256
+ if (isInWorktree && !hasExplicitId) {
254
257
  const mainRepoPath = cwd.split('.jettypod-work')[0].replace(/\/$/, '');
255
- const workIdMatch = strippedCommand.match(/merge\s+(\d+)/);
256
- const workId = workIdMatch ? workIdMatch[1] : '<id>';
257
258
 
258
259
  return {
259
260
  allowed: false,
260
- message: 'Cannot merge from inside a worktree',
261
- hint: `Current CWD: ${cwd}
262
- Main repo: ${mainRepoPath}
261
+ message: 'Cannot merge from inside a worktree without an explicit work item ID',
262
+ hint: `Provide the work item ID explicitly:
263
263
 
264
- Run these as TWO SEPARATE Bash calls:
264
+ jettypod work merge <id>
265
265
 
266
- 1. cd ${mainRepoPath}
267
- 2. jettypod work merge ${workId}
266
+ This works from anywhere — the merge command handles CWD internally.
267
+ After merge, cd to main repo before cleanup:
268
268
 
269
- Why: Hooks check CWD before 'cd &&' executes, so chained commands fail.`
269
+ cd ${mainRepoPath}
270
+ jettypod work cleanup <id>`
270
271
  };
271
272
  }
272
273
  }
@@ -0,0 +1,19 @@
1
+ [package]
2
+ name = "jettypod-cli"
3
+ version = "0.1.0"
4
+ edition = "2021"
5
+ description = "JettyPod workflow CLI"
6
+
7
+ [[bin]]
8
+ name = "jettypod"
9
+ path = "src/main.rs"
10
+
11
+ [dependencies]
12
+ jettypod-core = { path = "../jettypod-core" }
13
+ clap = { version = "4", features = ["derive"] }
14
+ tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
15
+ anyhow = { workspace = true }
16
+ log = { workspace = true }
17
+ serde_json = { workspace = true }
18
+ chrono = { workspace = true }
19
+ rusqlite = { version = "0.31", features = ["bundled"] }