@getjack/jack 0.1.19 → 0.1.22

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 (105) hide show
  1. package/package.json +5 -2
  2. package/src/commands/down.ts +11 -1
  3. package/src/commands/init.ts +19 -6
  4. package/src/commands/new.ts +56 -4
  5. package/src/commands/publish.ts +1 -1
  6. package/src/lib/agents.ts +3 -1
  7. package/src/lib/auth/ensure-auth.test.ts +3 -3
  8. package/src/lib/control-plane.ts +15 -1
  9. package/src/lib/deploy-upload.ts +26 -1
  10. package/src/lib/hooks.ts +232 -1
  11. package/src/lib/managed-deploy.ts +13 -6
  12. package/src/lib/managed-down.ts +66 -45
  13. package/src/lib/progress.ts +76 -5
  14. package/src/lib/project-list.ts +6 -1
  15. package/src/lib/project-operations.ts +21 -31
  16. package/src/lib/project-resolver.ts +1 -1
  17. package/src/lib/zip-packager.ts +36 -7
  18. package/src/templates/index.ts +1 -1
  19. package/src/templates/types.ts +16 -0
  20. package/templates/CLAUDE.md +172 -5
  21. package/templates/miniapp/.jack.json +1 -3
  22. package/templates/saas/.jack.json +154 -0
  23. package/templates/saas/AGENTS.md +333 -0
  24. package/templates/saas/bun.lock +925 -0
  25. package/templates/saas/components.json +21 -0
  26. package/templates/saas/index.html +12 -0
  27. package/templates/saas/package.json +75 -0
  28. package/templates/saas/public/icon.png +0 -0
  29. package/templates/saas/public/og.png +0 -0
  30. package/templates/saas/schema.sql +73 -0
  31. package/templates/saas/src/auth.ts +77 -0
  32. package/templates/saas/src/client/App.tsx +63 -0
  33. package/templates/saas/src/client/components/ProtectedRoute.tsx +29 -0
  34. package/templates/saas/src/client/components/ThemeToggle.tsx +32 -0
  35. package/templates/saas/src/client/components/ui/accordion.tsx +62 -0
  36. package/templates/saas/src/client/components/ui/alert-dialog.tsx +133 -0
  37. package/templates/saas/src/client/components/ui/alert.tsx +60 -0
  38. package/templates/saas/src/client/components/ui/aspect-ratio.tsx +9 -0
  39. package/templates/saas/src/client/components/ui/avatar.tsx +39 -0
  40. package/templates/saas/src/client/components/ui/badge.tsx +39 -0
  41. package/templates/saas/src/client/components/ui/breadcrumb.tsx +102 -0
  42. package/templates/saas/src/client/components/ui/button-group.tsx +78 -0
  43. package/templates/saas/src/client/components/ui/button.tsx +60 -0
  44. package/templates/saas/src/client/components/ui/card.tsx +75 -0
  45. package/templates/saas/src/client/components/ui/carousel.tsx +228 -0
  46. package/templates/saas/src/client/components/ui/chart.tsx +326 -0
  47. package/templates/saas/src/client/components/ui/checkbox.tsx +29 -0
  48. package/templates/saas/src/client/components/ui/collapsible.tsx +19 -0
  49. package/templates/saas/src/client/components/ui/command.tsx +159 -0
  50. package/templates/saas/src/client/components/ui/context-menu.tsx +224 -0
  51. package/templates/saas/src/client/components/ui/dialog.tsx +127 -0
  52. package/templates/saas/src/client/components/ui/drawer.tsx +124 -0
  53. package/templates/saas/src/client/components/ui/dropdown-menu.tsx +226 -0
  54. package/templates/saas/src/client/components/ui/empty.tsx +94 -0
  55. package/templates/saas/src/client/components/ui/field.tsx +232 -0
  56. package/templates/saas/src/client/components/ui/form.tsx +152 -0
  57. package/templates/saas/src/client/components/ui/hover-card.tsx +38 -0
  58. package/templates/saas/src/client/components/ui/input-group.tsx +158 -0
  59. package/templates/saas/src/client/components/ui/input-otp.tsx +68 -0
  60. package/templates/saas/src/client/components/ui/input.tsx +21 -0
  61. package/templates/saas/src/client/components/ui/item.tsx +172 -0
  62. package/templates/saas/src/client/components/ui/kbd.tsx +28 -0
  63. package/templates/saas/src/client/components/ui/label.tsx +21 -0
  64. package/templates/saas/src/client/components/ui/menubar.tsx +250 -0
  65. package/templates/saas/src/client/components/ui/navigation-menu.tsx +161 -0
  66. package/templates/saas/src/client/components/ui/pagination.tsx +106 -0
  67. package/templates/saas/src/client/components/ui/popover.tsx +42 -0
  68. package/templates/saas/src/client/components/ui/progress.tsx +26 -0
  69. package/templates/saas/src/client/components/ui/radio-group.tsx +45 -0
  70. package/templates/saas/src/client/components/ui/resizable.tsx +46 -0
  71. package/templates/saas/src/client/components/ui/scroll-area.tsx +56 -0
  72. package/templates/saas/src/client/components/ui/select.tsx +173 -0
  73. package/templates/saas/src/client/components/ui/separator.tsx +28 -0
  74. package/templates/saas/src/client/components/ui/sheet.tsx +128 -0
  75. package/templates/saas/src/client/components/ui/sidebar.tsx +694 -0
  76. package/templates/saas/src/client/components/ui/skeleton.tsx +13 -0
  77. package/templates/saas/src/client/components/ui/slider.tsx +58 -0
  78. package/templates/saas/src/client/components/ui/sonner.tsx +38 -0
  79. package/templates/saas/src/client/components/ui/spinner.tsx +16 -0
  80. package/templates/saas/src/client/components/ui/switch.tsx +28 -0
  81. package/templates/saas/src/client/components/ui/table.tsx +90 -0
  82. package/templates/saas/src/client/components/ui/tabs.tsx +54 -0
  83. package/templates/saas/src/client/components/ui/textarea.tsx +18 -0
  84. package/templates/saas/src/client/components/ui/toggle-group.tsx +80 -0
  85. package/templates/saas/src/client/components/ui/toggle.tsx +44 -0
  86. package/templates/saas/src/client/components/ui/tooltip.tsx +57 -0
  87. package/templates/saas/src/client/hooks/use-mobile.ts +19 -0
  88. package/templates/saas/src/client/hooks/useAuth.ts +14 -0
  89. package/templates/saas/src/client/hooks/useSubscription.ts +86 -0
  90. package/templates/saas/src/client/index.css +165 -0
  91. package/templates/saas/src/client/lib/auth-client.ts +7 -0
  92. package/templates/saas/src/client/lib/plans.ts +82 -0
  93. package/templates/saas/src/client/lib/utils.ts +6 -0
  94. package/templates/saas/src/client/main.tsx +15 -0
  95. package/templates/saas/src/client/pages/DashboardPage.tsx +394 -0
  96. package/templates/saas/src/client/pages/ForgotPasswordPage.tsx +153 -0
  97. package/templates/saas/src/client/pages/HomePage.tsx +285 -0
  98. package/templates/saas/src/client/pages/LoginPage.tsx +169 -0
  99. package/templates/saas/src/client/pages/PricingPage.tsx +467 -0
  100. package/templates/saas/src/client/pages/ResetPasswordPage.tsx +200 -0
  101. package/templates/saas/src/client/pages/SignupPage.tsx +192 -0
  102. package/templates/saas/src/index.ts +208 -0
  103. package/templates/saas/tsconfig.json +18 -0
  104. package/templates/saas/vite.config.ts +14 -0
  105. package/templates/saas/wrangler.jsonc +20 -0
@@ -0,0 +1,192 @@
1
+ import { useState } from "react";
2
+ import { authClient } from "../lib/auth-client";
3
+ import { ThemeToggle } from "../components/ThemeToggle";
4
+
5
+ interface SignupPageProps {
6
+ navigate: (route: "/" | "/login" | "/signup" | "/pricing" | "/dashboard" | "/forgot-password" | "/reset-password") => void;
7
+ }
8
+
9
+ export default function SignupPage({ navigate }: SignupPageProps) {
10
+ const [name, setName] = useState("");
11
+ const [email, setEmail] = useState("");
12
+ const [password, setPassword] = useState("");
13
+ const [error, setError] = useState<string | null>(null);
14
+ const [isLoading, setIsLoading] = useState(false);
15
+
16
+ const handleSubmit = async (e: React.FormEvent) => {
17
+ e.preventDefault();
18
+ setError(null);
19
+ setIsLoading(true);
20
+
21
+ // Basic password validation
22
+ if (password.length < 8) {
23
+ setError("Password must be at least 8 characters long.");
24
+ setIsLoading(false);
25
+ return;
26
+ }
27
+
28
+ try {
29
+ const result = await authClient.signUp.email({
30
+ email,
31
+ password,
32
+ name,
33
+ });
34
+
35
+ if (result.error) {
36
+ setError(result.error.message || "Failed to create account. Please try again.");
37
+ setIsLoading(false);
38
+ return;
39
+ }
40
+
41
+ navigate("/dashboard");
42
+ } catch (err) {
43
+ setError("An unexpected error occurred. Please try again.");
44
+ setIsLoading(false);
45
+ }
46
+ };
47
+
48
+ return (
49
+ <div className="min-h-screen bg-background flex flex-col">
50
+ {/* Navigation */}
51
+ <nav className="border-b border-border">
52
+ <div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
53
+ <div className="flex justify-between items-center h-16">
54
+ <button
55
+ type="button"
56
+ onClick={() => navigate("/")}
57
+ className="text-xl font-bold hover:opacity-80 transition-opacity"
58
+ >
59
+ jack-template
60
+ </button>
61
+ <ThemeToggle />
62
+ </div>
63
+ </div>
64
+ </nav>
65
+
66
+ {/* Signup Form */}
67
+ <div className="flex-1 flex items-center justify-center px-4 py-12">
68
+ <div className="w-full max-w-md">
69
+ <div className="text-center mb-8">
70
+ <h1 className="text-2xl font-bold mb-2">Create your account</h1>
71
+ <p className="text-muted-foreground">Get started for free. No credit card required.</p>
72
+ </div>
73
+
74
+ <form onSubmit={handleSubmit} className="space-y-4">
75
+ {error && (
76
+ <div className="p-3 bg-destructive/10 border border-destructive/20 rounded-md">
77
+ <p className="text-sm text-destructive">{error}</p>
78
+ </div>
79
+ )}
80
+
81
+ <div>
82
+ <label htmlFor="name" className="block text-sm font-medium mb-2">
83
+ Name
84
+ </label>
85
+ <input
86
+ id="name"
87
+ type="text"
88
+ value={name}
89
+ onChange={(e) => setName(e.target.value)}
90
+ placeholder="Your name"
91
+ required
92
+ disabled={isLoading}
93
+ className="w-full px-3 py-2 border border-input rounded-md bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed"
94
+ />
95
+ </div>
96
+
97
+ <div>
98
+ <label htmlFor="email" className="block text-sm font-medium mb-2">
99
+ Email
100
+ </label>
101
+ <input
102
+ id="email"
103
+ type="email"
104
+ value={email}
105
+ onChange={(e) => setEmail(e.target.value)}
106
+ placeholder="you@example.com"
107
+ required
108
+ disabled={isLoading}
109
+ className="w-full px-3 py-2 border border-input rounded-md bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed"
110
+ />
111
+ </div>
112
+
113
+ <div>
114
+ <label htmlFor="password" className="block text-sm font-medium mb-2">
115
+ Password
116
+ </label>
117
+ <input
118
+ id="password"
119
+ type="password"
120
+ value={password}
121
+ onChange={(e) => setPassword(e.target.value)}
122
+ placeholder="At least 8 characters"
123
+ required
124
+ disabled={isLoading}
125
+ className="w-full px-3 py-2 border border-input rounded-md bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed"
126
+ />
127
+ <p className="mt-1 text-xs text-muted-foreground">
128
+ Must be at least 8 characters long
129
+ </p>
130
+ </div>
131
+
132
+ <button
133
+ type="submit"
134
+ disabled={isLoading}
135
+ className="w-full px-4 py-2 bg-primary text-primary-foreground rounded-md hover:opacity-90 transition-opacity font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center"
136
+ >
137
+ {isLoading ? (
138
+ <>
139
+ <svg className="animate-spin -ml-1 mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24">
140
+ <circle
141
+ className="opacity-25"
142
+ cx="12"
143
+ cy="12"
144
+ r="10"
145
+ stroke="currentColor"
146
+ strokeWidth="4"
147
+ />
148
+ <path
149
+ className="opacity-75"
150
+ fill="currentColor"
151
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
152
+ />
153
+ </svg>
154
+ Creating account...
155
+ </>
156
+ ) : (
157
+ "Create account"
158
+ )}
159
+ </button>
160
+ </form>
161
+
162
+ <p className="mt-6 text-xs text-center text-muted-foreground">
163
+ By creating an account, you agree to our Terms of Service and Privacy Policy.
164
+ </p>
165
+
166
+ <div className="mt-6 text-center">
167
+ <p className="text-sm text-muted-foreground">
168
+ Already have an account?{" "}
169
+ <button
170
+ type="button"
171
+ onClick={() => navigate("/login")}
172
+ className="text-primary hover:underline font-medium"
173
+ >
174
+ Sign in
175
+ </button>
176
+ </p>
177
+ </div>
178
+
179
+ <div className="mt-4 text-center">
180
+ <button
181
+ type="button"
182
+ onClick={() => navigate("/")}
183
+ className="text-sm text-muted-foreground hover:text-foreground transition-colors"
184
+ >
185
+ Back to home
186
+ </button>
187
+ </div>
188
+ </div>
189
+ </div>
190
+ </div>
191
+ );
192
+ }
@@ -0,0 +1,208 @@
1
+ // Server-side Worker - handles API routes, keeps secrets secure
2
+ /// <reference types="@cloudflare/workers-types" />
3
+
4
+ import { Hono } from "hono";
5
+ import { cors } from "hono/cors";
6
+ import Stripe from "stripe";
7
+ import { createAuth } from "./auth";
8
+
9
+ // Environment bindings and secrets
10
+ type Env = {
11
+ // Bindings
12
+ DB: D1Database;
13
+ ASSETS: Fetcher;
14
+
15
+ // Secrets (set via `jack secrets set`)
16
+ BETTER_AUTH_SECRET: string;
17
+ BETTER_AUTH_URL?: string;
18
+ STRIPE_SECRET_KEY: string;
19
+ STRIPE_WEBHOOK_SECRET: string;
20
+ STRIPE_PRO_PRICE_ID?: string;
21
+ STRIPE_ENTERPRISE_PRICE_ID?: string;
22
+ };
23
+
24
+ const app = new Hono<{ Bindings: Env }>();
25
+
26
+ // CORS for API routes
27
+ app.use("/api/*", cors());
28
+
29
+ // Mount Better Auth handler - handles all auth routes including Stripe webhooks
30
+ // Routes: /api/auth/signup, /api/auth/signin, /api/auth/session, /api/auth/stripe/webhook, etc.
31
+ app.all("/api/auth/*", async (c) => {
32
+ const auth = createAuth(c.env);
33
+ return auth.handler(c.req.raw);
34
+ });
35
+
36
+ // Health check endpoint
37
+ app.get("/api/health", (c) => {
38
+ return c.json({
39
+ status: "ok",
40
+ timestamp: new Date().toISOString(),
41
+ });
42
+ });
43
+
44
+ // Config endpoint - exposes non-sensitive configuration to frontend
45
+ app.get("/api/config", (c) => {
46
+ const isStripeTestMode = c.env.STRIPE_SECRET_KEY?.startsWith("sk_test_") ?? true;
47
+ return c.json({
48
+ stripeTestMode: isStripeTestMode,
49
+ });
50
+ });
51
+
52
+ // Get subscription status with real-time Stripe data
53
+ app.get("/api/subscription-status", async (c) => {
54
+ try {
55
+ const auth = createAuth(c.env);
56
+ const session = await auth.api.getSession({ headers: c.req.raw.headers });
57
+
58
+ if (!session?.user) {
59
+ return c.json({ error: "Unauthorized" }, 401);
60
+ }
61
+
62
+ const stripeClient = new Stripe(c.env.STRIPE_SECRET_KEY);
63
+
64
+ // Find customer by email
65
+ const customers = await stripeClient.customers.list({
66
+ email: session.user.email,
67
+ limit: 1,
68
+ });
69
+
70
+ if (customers.data.length === 0) {
71
+ console.log(`[subscription-status] No Stripe customer for: ${session.user.email}`);
72
+ return c.json({ subscription: null });
73
+ }
74
+
75
+ // Get subscriptions for this customer (active or scheduled to cancel)
76
+ const subscriptions = await stripeClient.subscriptions.list({
77
+ customer: customers.data[0].id,
78
+ limit: 10, // Get more to find the right one
79
+ expand: ["data.default_payment_method"], // Force fresh data
80
+ });
81
+
82
+ // Find active or trialing subscription (even if set to cancel at period end)
83
+ const sub = subscriptions.data.find(
84
+ (s) => s.status === "active" || s.status === "trialing"
85
+ );
86
+
87
+ if (!sub) {
88
+ console.log(`[subscription-status] No active subscriptions for customer: ${customers.data[0].id}`);
89
+ return c.json({ subscription: null });
90
+ }
91
+
92
+ console.log(`[subscription-status] Found: id=${sub.id}, status=${sub.status}, cancel_at_period_end=${sub.cancel_at_period_end}`);
93
+
94
+ return c.json({
95
+ subscription: {
96
+ id: sub.id,
97
+ status: sub.status,
98
+ cancelAtPeriodEnd: sub.cancel_at_period_end,
99
+ periodEnd: sub.current_period_end
100
+ ? new Date(sub.current_period_end * 1000).toISOString()
101
+ : null,
102
+ plan: sub.items.data[0]?.price?.lookup_key || sub.items.data[0]?.price?.id,
103
+ },
104
+ });
105
+ } catch (err) {
106
+ console.error("[subscription-status] Error:", err);
107
+ return c.json({
108
+ error: "Failed to fetch subscription status",
109
+ details: err instanceof Error ? err.message : String(err)
110
+ }, 500);
111
+ }
112
+ });
113
+
114
+ // Resubscribe - undo a pending cancellation
115
+ app.post("/api/resubscribe", async (c) => {
116
+ try {
117
+ const auth = createAuth(c.env);
118
+ const session = await auth.api.getSession({ headers: c.req.raw.headers });
119
+
120
+ if (!session?.user) {
121
+ return c.json({ error: "Unauthorized" }, 401);
122
+ }
123
+
124
+ const stripeClient = new Stripe(c.env.STRIPE_SECRET_KEY);
125
+
126
+ // Find customer
127
+ const customers = await stripeClient.customers.list({
128
+ email: session.user.email,
129
+ limit: 1,
130
+ });
131
+
132
+ if (customers.data.length === 0) {
133
+ return c.json({ error: "No billing account found" }, 404);
134
+ }
135
+
136
+ // Find active subscription set to cancel
137
+ const subscriptions = await stripeClient.subscriptions.list({
138
+ customer: customers.data[0].id,
139
+ status: "active",
140
+ limit: 1,
141
+ });
142
+
143
+ if (subscriptions.data.length === 0) {
144
+ return c.json({ error: "No active subscription found" }, 404);
145
+ }
146
+
147
+ const sub = subscriptions.data[0];
148
+
149
+ // Undo the cancellation in Stripe (if set)
150
+ if (sub.cancel_at_period_end) {
151
+ await stripeClient.subscriptions.update(sub.id, {
152
+ cancel_at_period_end: false,
153
+ });
154
+ }
155
+
156
+ // Also clear the local cancelAt in Better Auth's database
157
+ await c.env.DB.prepare(
158
+ "UPDATE subscription SET cancelAt = NULL WHERE stripeSubscriptionId = ?"
159
+ ).bind(sub.id).run();
160
+
161
+ return c.json({ success: true });
162
+ } catch (err) {
163
+ console.error("[resubscribe] Error:", err);
164
+ return c.json({
165
+ error: "Failed to resubscribe",
166
+ details: err instanceof Error ? err.message : String(err),
167
+ }, 500);
168
+ }
169
+ });
170
+
171
+ // Billing portal - redirects authenticated user to Stripe Customer Portal
172
+ app.get("/api/billing-portal", async (c) => {
173
+ const auth = createAuth(c.env);
174
+ const session = await auth.api.getSession({ headers: c.req.raw.headers });
175
+
176
+ if (!session?.user) {
177
+ return c.json({ error: "Unauthorized" }, 401);
178
+ }
179
+
180
+ // Get the Stripe customer ID from the user's subscription
181
+ const stripeClient = new Stripe(c.env.STRIPE_SECRET_KEY);
182
+
183
+ // Find the customer by email (Better Auth creates customer on signup)
184
+ const customers = await stripeClient.customers.list({
185
+ email: session.user.email,
186
+ limit: 1,
187
+ });
188
+
189
+ if (customers.data.length === 0) {
190
+ return c.json({ error: "No billing account found" }, 404);
191
+ }
192
+
193
+ const returnUrl = c.req.header("origin") || c.req.header("referer") || c.env.BETTER_AUTH_URL || "/";
194
+
195
+ const portalSession = await stripeClient.billingPortal.sessions.create({
196
+ customer: customers.data[0].id,
197
+ return_url: `${returnUrl}/#/dashboard`,
198
+ });
199
+
200
+ return c.redirect(portalSession.url);
201
+ });
202
+
203
+ // Serve React app for all other routes
204
+ app.get("*", (c) => c.env.ASSETS.fetch(c.req.raw));
205
+
206
+ // Export type for client-side usage with hono/client
207
+ export type AppType = typeof app;
208
+ export default app;
@@ -0,0 +1,18 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
5
+ "module": "ESNext",
6
+ "moduleResolution": "bundler",
7
+ "jsx": "react-jsx",
8
+ "strict": true,
9
+ "skipLibCheck": true,
10
+ "noEmit": true,
11
+ "types": ["@cloudflare/workers-types"],
12
+ "baseUrl": ".",
13
+ "paths": {
14
+ "@/*": ["./src/client/*"]
15
+ }
16
+ },
17
+ "include": ["src"]
18
+ }
@@ -0,0 +1,14 @@
1
+ import { cloudflare } from "@cloudflare/vite-plugin";
2
+ import tailwindcss from "@tailwindcss/vite";
3
+ import react from "@vitejs/plugin-react";
4
+ import { resolve } from "node:path";
5
+ import { defineConfig } from "vite";
6
+
7
+ export default defineConfig({
8
+ plugins: [react(), tailwindcss(), cloudflare()],
9
+ resolve: {
10
+ alias: {
11
+ "@": resolve(__dirname, "./src/client"),
12
+ },
13
+ },
14
+ });
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "jack-template",
3
+ "main": "src/index.ts",
4
+ "compatibility_date": "2024-12-01",
5
+ "compatibility_flags": ["nodejs_compat"],
6
+ "assets": {
7
+ "binding": "ASSETS",
8
+ "directory": "dist/client",
9
+ "not_found_handling": "single-page-application",
10
+ // Required for API routes (/api/*) to work alongside static assets
11
+ // Without this, Cloudflare serves static files directly, bypassing the worker
12
+ "run_worker_first": true
13
+ },
14
+ "d1_databases": [
15
+ {
16
+ "binding": "DB",
17
+ "database_name": "jack-template-db"
18
+ }
19
+ ]
20
+ }