create-whop-kit 0.1.0 → 0.2.0

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.
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/utils/exec.ts
4
+ import { execSync } from "child_process";
5
+ function exec(cmd, cwd) {
6
+ try {
7
+ const stdout = execSync(cmd, {
8
+ cwd,
9
+ stdio: "pipe",
10
+ encoding: "utf-8",
11
+ timeout: 12e4
12
+ }).trim();
13
+ return { stdout, success: true };
14
+ } catch {
15
+ return { stdout: "", success: false };
16
+ }
17
+ }
18
+ function hasCommand(cmd) {
19
+ return exec(`which ${cmd}`).success;
20
+ }
21
+ function detectPackageManager() {
22
+ if (hasCommand("pnpm")) return "pnpm";
23
+ if (hasCommand("yarn")) return "yarn";
24
+ if (hasCommand("bun")) return "bun";
25
+ return "npm";
26
+ }
27
+
28
+ // src/scaffolding/manifest.ts
29
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
30
+ import { join } from "path";
31
+ var MANIFEST_DIR = ".whop";
32
+ var MANIFEST_FILE = "config.json";
33
+ function getManifestPath(projectDir) {
34
+ return join(projectDir, MANIFEST_DIR, MANIFEST_FILE);
35
+ }
36
+ function createManifest(projectDir, data) {
37
+ const dir = join(projectDir, MANIFEST_DIR);
38
+ if (!existsSync(dir)) {
39
+ mkdirSync(dir, { recursive: true });
40
+ }
41
+ const manifest = {
42
+ version: 1,
43
+ ...data,
44
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
45
+ };
46
+ writeFileSync(getManifestPath(projectDir), JSON.stringify(manifest, null, 2) + "\n");
47
+ }
48
+ function readManifest(projectDir) {
49
+ const path = getManifestPath(projectDir);
50
+ if (!existsSync(path)) return null;
51
+ try {
52
+ return JSON.parse(readFileSync(path, "utf-8"));
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+ function addFeatureToManifest(projectDir, feature) {
58
+ const manifest = readManifest(projectDir);
59
+ if (!manifest) return;
60
+ if (!manifest.features.includes(feature)) {
61
+ manifest.features.push(feature);
62
+ }
63
+ writeFileSync(getManifestPath(projectDir), JSON.stringify(manifest, null, 2) + "\n");
64
+ }
65
+
66
+ export {
67
+ exec,
68
+ hasCommand,
69
+ detectPackageManager,
70
+ createManifest,
71
+ readManifest,
72
+ addFeatureToManifest
73
+ };
@@ -0,0 +1,480 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ createManifest,
4
+ detectPackageManager,
5
+ exec,
6
+ hasCommand
7
+ } from "./chunk-M4AXERQP.js";
8
+
9
+ // src/cli-create.ts
10
+ import { runMain } from "citty";
11
+
12
+ // src/commands/init.ts
13
+ import { resolve, basename as basename2 } from "path";
14
+ import { existsSync as existsSync4 } from "fs";
15
+ import * as p2 from "@clack/prompts";
16
+ import pc2 from "picocolors";
17
+ import { defineCommand } from "citty";
18
+
19
+ // src/templates.ts
20
+ var TEMPLATES = {
21
+ nextjs: {
22
+ name: "Next.js",
23
+ description: "Full-stack React with App Router, SSR, and API routes",
24
+ repo: "colinmcdermott/whop-saas-starter-v2",
25
+ available: true
26
+ },
27
+ astro: {
28
+ name: "Astro",
29
+ description: "Content-focused with islands architecture",
30
+ repo: "colinmcdermott/whop-astro-starter",
31
+ available: true
32
+ },
33
+ tanstack: {
34
+ name: "TanStack Start",
35
+ description: "Full-stack React with TanStack Router",
36
+ repo: "",
37
+ available: false
38
+ },
39
+ vite: {
40
+ name: "Vite + React",
41
+ description: "Lightweight SPA with Vite bundler",
42
+ repo: "",
43
+ available: false
44
+ }
45
+ };
46
+ var APP_TYPES = {
47
+ saas: {
48
+ name: "SaaS",
49
+ description: "Subscription tiers, dashboard, billing portal",
50
+ available: true
51
+ },
52
+ course: {
53
+ name: "Course",
54
+ description: "Lessons, progress tracking, drip content",
55
+ available: false
56
+ },
57
+ community: {
58
+ name: "Community",
59
+ description: "Member feeds, gated content, roles",
60
+ available: false
61
+ },
62
+ blank: {
63
+ name: "Blank",
64
+ description: "Just auth + payments, you build the rest",
65
+ available: false
66
+ }
67
+ };
68
+ var DB_OPTIONS = {
69
+ neon: {
70
+ name: "Neon",
71
+ description: "Serverless Postgres (recommended)",
72
+ envVarHint: "postgresql://user:pass@ep-xxx.us-east-2.aws.neon.tech/dbname?sslmode=require"
73
+ },
74
+ supabase: {
75
+ name: "Supabase",
76
+ description: "Open-source Firebase alternative",
77
+ envVarHint: "postgresql://postgres.xxx:pass@aws-0-us-east-1.pooler.supabase.com:6543/postgres"
78
+ },
79
+ local: {
80
+ name: "Local PostgreSQL",
81
+ description: "Your own Postgres instance",
82
+ envVarHint: "postgresql://postgres:postgres@localhost:5432/myapp"
83
+ },
84
+ later: {
85
+ name: "Configure later",
86
+ description: "Skip database setup for now",
87
+ envVarHint: ""
88
+ }
89
+ };
90
+
91
+ // src/utils/checks.ts
92
+ import * as p from "@clack/prompts";
93
+ import pc from "picocolors";
94
+ function checkNodeVersion(minimum = 18) {
95
+ const major = parseInt(process.versions.node.split(".")[0], 10);
96
+ if (major < minimum) {
97
+ p.log.error(
98
+ `Node.js ${pc.bold(`v${minimum}+`)} is required. You have ${pc.bold(`v${process.versions.node}`)}.`
99
+ );
100
+ process.exit(1);
101
+ }
102
+ }
103
+ function checkGit() {
104
+ if (!hasCommand("git")) {
105
+ p.log.error(
106
+ `${pc.bold("git")} is required but not found. Install it from ${pc.cyan("https://git-scm.com")}`
107
+ );
108
+ process.exit(1);
109
+ }
110
+ }
111
+ function validateDatabaseUrl(url) {
112
+ if (!url.startsWith("postgres://") && !url.startsWith("postgresql://")) {
113
+ return "Must be a PostgreSQL connection string (starts with postgres:// or postgresql://)";
114
+ }
115
+ return void 0;
116
+ }
117
+ function validateWhopAppId(id) {
118
+ if (id && !id.startsWith("app_")) {
119
+ return 'Whop App IDs start with "app_"';
120
+ }
121
+ return void 0;
122
+ }
123
+
124
+ // src/utils/cleanup.ts
125
+ import { rmSync, existsSync } from "fs";
126
+ function cleanupDir(dir) {
127
+ if (existsSync(dir)) {
128
+ try {
129
+ rmSync(dir, { recursive: true, force: true });
130
+ } catch {
131
+ }
132
+ }
133
+ }
134
+
135
+ // src/scaffolding/clone.ts
136
+ import { existsSync as existsSync2, readFileSync, writeFileSync, rmSync as rmSync2 } from "fs";
137
+ import { join, basename } from "path";
138
+ function cloneTemplate(repo, projectDir) {
139
+ const result = exec(
140
+ `git clone --depth 1 https://github.com/${repo}.git "${projectDir}"`
141
+ );
142
+ if (!result.success || !existsSync2(projectDir)) {
143
+ return false;
144
+ }
145
+ const gitDir = join(projectDir, ".git");
146
+ if (existsSync2(gitDir)) {
147
+ rmSync2(gitDir, { recursive: true, force: true });
148
+ }
149
+ return true;
150
+ }
151
+ function updatePackageName(projectDir, name) {
152
+ const pkgPath = join(projectDir, "package.json");
153
+ if (existsSync2(pkgPath)) {
154
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
155
+ pkg.name = basename(name);
156
+ writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
157
+ }
158
+ }
159
+ function initGit(projectDir) {
160
+ exec("git init", projectDir);
161
+ exec("git add -A", projectDir);
162
+ exec('git commit -m "initial: scaffolded with create-whop-kit"', projectDir);
163
+ }
164
+
165
+ // src/scaffolding/env-file.ts
166
+ import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, existsSync as existsSync3 } from "fs";
167
+ import { join as join2 } from "path";
168
+ function writeEnvFile(projectDir, values) {
169
+ const examplePath = join2(projectDir, ".env.example");
170
+ const envPath = join2(projectDir, ".env.local");
171
+ const filled = Object.fromEntries(
172
+ Object.entries(values).filter(([, v]) => v)
173
+ );
174
+ if (existsSync3(examplePath)) {
175
+ let content = readFileSync2(examplePath, "utf-8");
176
+ for (const [key, value] of Object.entries(filled)) {
177
+ const pattern = new RegExp(
178
+ `^(#\\s*)?${escapeRegex(key)}=.*$`,
179
+ "m"
180
+ );
181
+ if (pattern.test(content)) {
182
+ content = content.replace(pattern, `${key}="${value}"`);
183
+ }
184
+ }
185
+ writeFileSync2(envPath, content);
186
+ } else {
187
+ const lines = Object.entries(filled).map(
188
+ ([key, value]) => `${key}="${value}"`
189
+ );
190
+ writeFileSync2(envPath, lines.join("\n") + "\n");
191
+ }
192
+ }
193
+ function escapeRegex(str) {
194
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
195
+ }
196
+
197
+ // src/commands/init.ts
198
+ function isCancelled(value) {
199
+ return p2.isCancel(value);
200
+ }
201
+ var init_default = defineCommand({
202
+ meta: {
203
+ name: "create-whop-kit",
204
+ version: "0.2.0",
205
+ description: "Scaffold a new Whop-powered app with whop-kit"
206
+ },
207
+ args: {
208
+ name: {
209
+ type: "positional",
210
+ description: "Project name",
211
+ required: false
212
+ },
213
+ framework: {
214
+ type: "string",
215
+ description: "Framework: nextjs, astro"
216
+ },
217
+ type: {
218
+ type: "string",
219
+ description: "App type: saas",
220
+ default: "saas"
221
+ },
222
+ db: {
223
+ type: "string",
224
+ description: "Database: neon, supabase, local, later"
225
+ },
226
+ "db-url": {
227
+ type: "string",
228
+ description: "Database connection URL"
229
+ },
230
+ "app-id": {
231
+ type: "string",
232
+ description: "Whop App ID"
233
+ },
234
+ "api-key": {
235
+ type: "string",
236
+ description: "Whop API Key"
237
+ },
238
+ "webhook-secret": {
239
+ type: "string",
240
+ description: "Whop webhook secret"
241
+ },
242
+ yes: {
243
+ type: "boolean",
244
+ alias: "y",
245
+ description: "Skip optional prompts, use defaults",
246
+ default: false
247
+ },
248
+ "dry-run": {
249
+ type: "boolean",
250
+ description: "Show what would be created without doing it",
251
+ default: false
252
+ },
253
+ verbose: {
254
+ type: "boolean",
255
+ description: "Show detailed output",
256
+ default: false
257
+ }
258
+ },
259
+ async run({ args }) {
260
+ checkNodeVersion(18);
261
+ checkGit();
262
+ console.log("");
263
+ p2.intro(`${pc2.bgCyan(pc2.black(" create-whop-kit "))} Create a Whop-powered app`);
264
+ const isNonInteractive = !!(args.framework && args.db);
265
+ let projectName = args.name;
266
+ if (!projectName) {
267
+ const result = await p2.text({
268
+ message: "Project name",
269
+ placeholder: "my-whop-app",
270
+ validate: (v) => {
271
+ if (!v) return "Project name is required";
272
+ if (existsSync4(resolve(v))) return `Directory "${v}" already exists`;
273
+ }
274
+ });
275
+ if (isCancelled(result)) {
276
+ p2.cancel("Cancelled.");
277
+ process.exit(0);
278
+ }
279
+ projectName = result;
280
+ } else if (existsSync4(resolve(projectName))) {
281
+ p2.log.error(`Directory "${projectName}" already exists`);
282
+ process.exit(1);
283
+ }
284
+ let appType = args.type;
285
+ if (!isNonInteractive && !args.yes) {
286
+ const result = await p2.select({
287
+ message: "What are you building?",
288
+ options: Object.entries(APP_TYPES).map(([value, t]) => ({
289
+ value,
290
+ label: t.available ? t.name : `${t.name} ${pc2.dim("(coming soon)")}`,
291
+ hint: t.description,
292
+ disabled: !t.available
293
+ }))
294
+ });
295
+ if (isCancelled(result)) {
296
+ p2.cancel("Cancelled.");
297
+ process.exit(0);
298
+ }
299
+ appType = result;
300
+ }
301
+ let framework = args.framework;
302
+ if (!framework) {
303
+ const result = await p2.select({
304
+ message: "Which framework?",
305
+ options: Object.entries(TEMPLATES).map(([value, t]) => ({
306
+ value,
307
+ label: t.available ? t.name : `${t.name} ${pc2.dim("(coming soon)")}`,
308
+ hint: t.description,
309
+ disabled: !t.available
310
+ }))
311
+ });
312
+ if (isCancelled(result)) {
313
+ p2.cancel("Cancelled.");
314
+ process.exit(0);
315
+ }
316
+ framework = result;
317
+ }
318
+ const template = TEMPLATES[framework];
319
+ if (!template || !template.available) {
320
+ p2.log.error(`Framework "${framework}" is not available. Options: ${Object.keys(TEMPLATES).filter((k) => TEMPLATES[k].available).join(", ")}`);
321
+ process.exit(1);
322
+ }
323
+ let database = args.db;
324
+ if (!database) {
325
+ const result = await p2.select({
326
+ message: "Which database?",
327
+ options: Object.entries(DB_OPTIONS).map(([value, d]) => ({
328
+ value,
329
+ label: d.name,
330
+ hint: d.description
331
+ }))
332
+ });
333
+ if (isCancelled(result)) {
334
+ p2.cancel("Cancelled.");
335
+ process.exit(0);
336
+ }
337
+ database = result;
338
+ }
339
+ let dbUrl = args["db-url"] ?? "";
340
+ if (database !== "later" && !dbUrl) {
341
+ const result = await p2.text({
342
+ message: "Database URL",
343
+ placeholder: DB_OPTIONS[database]?.envVarHint ?? "postgresql://...",
344
+ validate: (v) => {
345
+ if (!v) return "Required (choose 'Configure later' to skip)";
346
+ return validateDatabaseUrl(v);
347
+ }
348
+ });
349
+ if (isCancelled(result)) {
350
+ p2.cancel("Cancelled.");
351
+ process.exit(0);
352
+ }
353
+ dbUrl = result;
354
+ }
355
+ let appId = args["app-id"] ?? "";
356
+ let apiKey = args["api-key"] ?? "";
357
+ let webhookSecret = args["webhook-secret"] ?? "";
358
+ if (!isNonInteractive && !args.yes) {
359
+ const setupWhop = await p2.confirm({
360
+ message: "Configure Whop credentials now? (you can do this later via the setup wizard)",
361
+ initialValue: false
362
+ });
363
+ if (!isCancelled(setupWhop) && setupWhop) {
364
+ if (!appId) {
365
+ const result = await p2.text({
366
+ message: "Whop App ID",
367
+ placeholder: "app_xxxxxxxxx",
368
+ validate: (v) => v ? validateWhopAppId(v) : void 0
369
+ });
370
+ if (!isCancelled(result)) appId = result ?? "";
371
+ }
372
+ if (!apiKey) {
373
+ const result = await p2.text({
374
+ message: "Whop API Key",
375
+ placeholder: "apik_xxxxxxxxx (optional, press Enter to skip)"
376
+ });
377
+ if (!isCancelled(result)) apiKey = result ?? "";
378
+ }
379
+ if (!webhookSecret) {
380
+ const result = await p2.text({
381
+ message: "Whop Webhook Secret",
382
+ placeholder: "optional, press Enter to skip"
383
+ });
384
+ if (!isCancelled(result)) webhookSecret = result ?? "";
385
+ }
386
+ }
387
+ }
388
+ if (args["dry-run"]) {
389
+ p2.log.info(pc2.dim("Dry run \u2014 showing what would be created:\n"));
390
+ console.log(` ${pc2.bold("Project:")} ${projectName}`);
391
+ console.log(` ${pc2.bold("Framework:")} ${template.name}`);
392
+ console.log(` ${pc2.bold("App type:")} ${APP_TYPES[appType]?.name ?? appType}`);
393
+ console.log(` ${pc2.bold("Database:")} ${DB_OPTIONS[database]?.name ?? database}`);
394
+ console.log(` ${pc2.bold("Template:")} github.com/${template.repo}`);
395
+ if (dbUrl) console.log(` ${pc2.bold("DB URL:")} ${dbUrl.substring(0, 30)}...`);
396
+ if (appId) console.log(` ${pc2.bold("Whop App:")} ${appId}`);
397
+ console.log("");
398
+ p2.outro("No files were created.");
399
+ return;
400
+ }
401
+ const projectDir = resolve(projectName);
402
+ const s = p2.spinner();
403
+ s.start(`Cloning ${template.name} template...`);
404
+ const cloned = cloneTemplate(template.repo, projectDir);
405
+ if (!cloned) {
406
+ s.stop("Failed to clone template");
407
+ p2.log.error(`Could not clone github.com/${template.repo}. Check your internet connection.`);
408
+ cleanupDir(projectDir);
409
+ process.exit(1);
410
+ }
411
+ updatePackageName(projectDir, projectName);
412
+ s.stop(`${template.name} template cloned`);
413
+ const envVars = {};
414
+ if (dbUrl) envVars["DATABASE_URL"] = dbUrl;
415
+ if (framework === "nextjs") {
416
+ if (appId) envVars["NEXT_PUBLIC_WHOP_APP_ID"] = appId;
417
+ } else {
418
+ if (appId) envVars["WHOP_APP_ID"] = appId;
419
+ }
420
+ if (apiKey) envVars["WHOP_API_KEY"] = apiKey;
421
+ if (webhookSecret) envVars["WHOP_WEBHOOK_SECRET"] = webhookSecret;
422
+ if (Object.keys(envVars).length > 0) {
423
+ s.start("Configuring environment...");
424
+ writeEnvFile(projectDir, envVars);
425
+ s.stop("Environment configured");
426
+ }
427
+ createManifest(projectDir, {
428
+ framework,
429
+ appType,
430
+ database,
431
+ features: [],
432
+ templateVersion: "0.2.0"
433
+ });
434
+ const pm = detectPackageManager();
435
+ s.start(`Installing dependencies with ${pm}...`);
436
+ const installResult = exec(`${pm} install`, projectDir);
437
+ if (!installResult.success) {
438
+ s.stop(`${pm} install failed`);
439
+ p2.log.warning("Dependency installation failed. Run it manually after setup.");
440
+ } else {
441
+ s.stop("Dependencies installed");
442
+ }
443
+ initGit(projectDir);
444
+ const configured = [];
445
+ const missing = [];
446
+ if (dbUrl) configured.push("Database");
447
+ else missing.push("Database URL");
448
+ if (appId) configured.push("Whop App ID");
449
+ else missing.push("Whop App ID");
450
+ if (apiKey) configured.push("Whop API Key");
451
+ else missing.push("Whop API Key");
452
+ if (webhookSecret) configured.push("Webhook Secret");
453
+ else missing.push("Webhook Secret");
454
+ let summary = "";
455
+ if (configured.length > 0) {
456
+ summary += `${pc2.green("\u2713")} ${configured.join(", ")}
457
+ `;
458
+ }
459
+ if (missing.length > 0) {
460
+ summary += `${pc2.yellow("\u25CB")} Missing: ${missing.join(", ")}
461
+ `;
462
+ summary += ` ${pc2.dim("Configure via the setup wizard or .env.local")}
463
+ `;
464
+ }
465
+ summary += `
466
+ `;
467
+ summary += ` ${pc2.bold("cd")} ${basename2(projectName)}
468
+ `;
469
+ if (dbUrl) {
470
+ summary += ` ${pc2.bold(`${pm} run db:push`)}
471
+ `;
472
+ }
473
+ summary += ` ${pc2.bold(`${pm} run dev`)}`;
474
+ p2.note(summary, "Your app is ready");
475
+ p2.outro(`${pc2.green("Happy building!")} ${pc2.dim("\u2014 whop-kit")}`);
476
+ }
477
+ });
478
+
479
+ // src/cli-create.ts
480
+ runMain(init_default);
@@ -0,0 +1,413 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ addFeatureToManifest,
4
+ detectPackageManager,
5
+ exec,
6
+ readManifest
7
+ } from "./chunk-M4AXERQP.js";
8
+
9
+ // src/cli-kit.ts
10
+ import { defineCommand as defineCommand5, runMain } from "citty";
11
+
12
+ // src/commands/add.ts
13
+ import * as p4 from "@clack/prompts";
14
+ import pc2 from "picocolors";
15
+ import { defineCommand } from "citty";
16
+
17
+ // src/features/email.ts
18
+ import * as p from "@clack/prompts";
19
+
20
+ // src/features/helpers.ts
21
+ import { readFileSync, writeFileSync, existsSync } from "fs";
22
+ import { join } from "path";
23
+ function appendEnvVar(projectDir, key, value) {
24
+ const envPath = join(projectDir, ".env.local");
25
+ if (!existsSync(envPath)) {
26
+ writeFileSync(envPath, `${key}="${value}"
27
+ `);
28
+ return;
29
+ }
30
+ let content = readFileSync(envPath, "utf-8");
31
+ const pattern = new RegExp(`^(#\\s*)?${key}=.*$`, "m");
32
+ if (pattern.test(content)) {
33
+ content = content.replace(pattern, `${key}="${value}"`);
34
+ } else {
35
+ content = content.trimEnd() + `
36
+ ${key}="${value}"
37
+ `;
38
+ }
39
+ writeFileSync(envPath, content);
40
+ }
41
+
42
+ // src/features/email.ts
43
+ var emailFeature = {
44
+ name: "Email",
45
+ description: "Transactional email via Resend or SendGrid",
46
+ configKey: "email",
47
+ async run(projectDir) {
48
+ const provider = await p.select({
49
+ message: "Email provider",
50
+ options: [
51
+ { value: "resend", label: "Resend", hint: "Modern email API" },
52
+ { value: "sendgrid", label: "SendGrid", hint: "Established platform" }
53
+ ]
54
+ });
55
+ if (p.isCancel(provider)) {
56
+ p.cancel("Cancelled.");
57
+ process.exit(0);
58
+ }
59
+ const apiKey = await p.text({
60
+ message: `${provider === "resend" ? "Resend" : "SendGrid"} API key`,
61
+ placeholder: provider === "resend" ? "re_xxxxxxxxx" : "SG.xxxxxxxxx",
62
+ validate: (v) => !v ? "API key is required" : void 0
63
+ });
64
+ if (p.isCancel(apiKey)) {
65
+ p.cancel("Cancelled.");
66
+ process.exit(0);
67
+ }
68
+ const fromAddress = await p.text({
69
+ message: "From email address",
70
+ placeholder: "noreply@yourdomain.com"
71
+ });
72
+ if (p.isCancel(fromAddress)) {
73
+ p.cancel("Cancelled.");
74
+ process.exit(0);
75
+ }
76
+ appendEnvVar(projectDir, "EMAIL_PROVIDER", provider);
77
+ appendEnvVar(projectDir, "EMAIL_API_KEY", apiKey);
78
+ if (fromAddress) {
79
+ appendEnvVar(projectDir, "EMAIL_FROM_ADDRESS", fromAddress);
80
+ }
81
+ }
82
+ };
83
+
84
+ // src/features/analytics.ts
85
+ import * as p2 from "@clack/prompts";
86
+ var analyticsFeature = {
87
+ name: "Analytics",
88
+ description: "Product analytics via PostHog, Google Analytics, or Plausible",
89
+ configKey: "analytics",
90
+ async run(projectDir) {
91
+ const provider = await p2.select({
92
+ message: "Analytics provider",
93
+ options: [
94
+ { value: "posthog", label: "PostHog", hint: "Open-source product analytics" },
95
+ { value: "google", label: "Google Analytics", hint: "GA4" },
96
+ { value: "plausible", label: "Plausible", hint: "Privacy-friendly analytics" }
97
+ ]
98
+ });
99
+ if (p2.isCancel(provider)) {
100
+ p2.cancel("Cancelled.");
101
+ process.exit(0);
102
+ }
103
+ const placeholders = {
104
+ posthog: "phc_xxxxxxxxx",
105
+ google: "G-XXXXXXXXXX",
106
+ plausible: "yourdomain.com"
107
+ };
108
+ const id = await p2.text({
109
+ message: `${provider === "google" ? "Measurement" : provider === "posthog" ? "Project API" : "Site"} ID`,
110
+ placeholder: placeholders[provider] ?? "",
111
+ validate: (v) => !v ? "ID is required" : void 0
112
+ });
113
+ if (p2.isCancel(id)) {
114
+ p2.cancel("Cancelled.");
115
+ process.exit(0);
116
+ }
117
+ appendEnvVar(projectDir, "ANALYTICS_PROVIDER", provider);
118
+ appendEnvVar(projectDir, "ANALYTICS_ID", id);
119
+ }
120
+ };
121
+
122
+ // src/features/webhook-event.ts
123
+ import * as p3 from "@clack/prompts";
124
+ import pc from "picocolors";
125
+ var webhookEventFeature = {
126
+ name: "Webhook Event",
127
+ description: "Add a new webhook event handler",
128
+ configKey: "webhook-event",
129
+ async run() {
130
+ const eventName = await p3.text({
131
+ message: "Event name",
132
+ placeholder: "payment_succeeded",
133
+ validate: (v) => !v ? "Event name is required" : void 0
134
+ });
135
+ if (p3.isCancel(eventName)) {
136
+ p3.cancel("Cancelled.");
137
+ process.exit(0);
138
+ }
139
+ const code = `
140
+ ${eventName}: async (data) => {
141
+ const userId = data.user_id as string | undefined;
142
+ if (!userId) return;
143
+ // TODO: Handle ${eventName}
144
+ console.log(\`[Webhook] ${eventName} for \${userId}\`);
145
+ },`;
146
+ p3.note(
147
+ `Add this to the ${pc.bold("on")} object in your webhook route:
148
+
149
+ ${pc.cyan(code)}`,
150
+ "Add to your webhook handler"
151
+ );
152
+ p3.log.info(
153
+ `File: ${pc.dim("app/api/webhooks/whop/route.ts")} (Next.js) or ${pc.dim("src/pages/api/webhooks/whop.ts")} (Astro)`
154
+ );
155
+ }
156
+ };
157
+
158
+ // src/commands/add.ts
159
+ var FEATURES = {
160
+ email: emailFeature,
161
+ analytics: analyticsFeature,
162
+ "webhook-event": webhookEventFeature
163
+ };
164
+ var add_default = defineCommand({
165
+ meta: {
166
+ name: "add",
167
+ description: "Add a feature to your Whop project"
168
+ },
169
+ args: {
170
+ feature: {
171
+ type: "positional",
172
+ description: `Feature to add: ${Object.keys(FEATURES).join(", ")}`,
173
+ required: false
174
+ }
175
+ },
176
+ async run({ args }) {
177
+ console.log("");
178
+ p4.intro(`${pc2.bgCyan(pc2.black(" whop-kit add "))} Add a feature`);
179
+ const manifest = readManifest(".");
180
+ if (!manifest) {
181
+ p4.log.error(
182
+ "No .whop/config.json found. Run this command from a project created with create-whop-kit."
183
+ );
184
+ process.exit(1);
185
+ }
186
+ let featureKey = args.feature;
187
+ if (!featureKey) {
188
+ const result = await p4.select({
189
+ message: "What would you like to add?",
190
+ options: Object.entries(FEATURES).map(([value, f]) => {
191
+ const installed = manifest.features.includes(f.configKey);
192
+ return {
193
+ value,
194
+ label: installed ? `${f.name} ${pc2.green("\u2713 configured")}` : f.name,
195
+ hint: f.description
196
+ };
197
+ })
198
+ });
199
+ if (p4.isCancel(result)) {
200
+ p4.cancel("Cancelled.");
201
+ process.exit(0);
202
+ }
203
+ featureKey = result;
204
+ }
205
+ const feature = FEATURES[featureKey];
206
+ if (!feature) {
207
+ p4.log.error(
208
+ `Unknown feature "${featureKey}". Available: ${Object.keys(FEATURES).join(", ")}`
209
+ );
210
+ process.exit(1);
211
+ }
212
+ if (manifest.features.includes(feature.configKey)) {
213
+ const proceed = await p4.confirm({
214
+ message: `${feature.name} is already configured. Reconfigure?`,
215
+ initialValue: false
216
+ });
217
+ if (p4.isCancel(proceed) || !proceed) {
218
+ p4.cancel("Cancelled.");
219
+ process.exit(0);
220
+ }
221
+ }
222
+ await feature.run(".");
223
+ addFeatureToManifest(".", feature.configKey);
224
+ p4.outro(`${pc2.green("\u2713")} ${feature.name} configured`);
225
+ }
226
+ });
227
+
228
+ // src/commands/status.ts
229
+ import { readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
230
+ import { join as join2 } from "path";
231
+ import * as p5 from "@clack/prompts";
232
+ import pc3 from "picocolors";
233
+ import { defineCommand as defineCommand2 } from "citty";
234
+ var ENV_CHECKS = [
235
+ { key: "DATABASE_URL", label: "Database", required: true },
236
+ { key: "NEXT_PUBLIC_WHOP_APP_ID", label: "Whop App ID", required: true },
237
+ { key: "WHOP_API_KEY", label: "Whop API Key", required: true },
238
+ { key: "WHOP_WEBHOOK_SECRET", label: "Webhook Secret", required: true },
239
+ { key: "EMAIL_PROVIDER", label: "Email Provider", required: false },
240
+ { key: "EMAIL_API_KEY", label: "Email API Key", required: false },
241
+ { key: "ANALYTICS_PROVIDER", label: "Analytics Provider", required: false },
242
+ { key: "ANALYTICS_ID", label: "Analytics ID", required: false }
243
+ ];
244
+ function readEnvFile(projectDir) {
245
+ const envPath = join2(projectDir, ".env.local");
246
+ if (!existsSync2(envPath)) return {};
247
+ const content = readFileSync2(envPath, "utf-8");
248
+ const vars = {};
249
+ for (const line of content.split("\n")) {
250
+ const trimmed = line.trim();
251
+ if (!trimmed || trimmed.startsWith("#")) continue;
252
+ const match = trimmed.match(/^([A-Z_][A-Z0-9_]*)=["']?(.*)["']?$/);
253
+ if (match) {
254
+ vars[match[1]] = match[2].replace(/["']$/, "");
255
+ }
256
+ }
257
+ return vars;
258
+ }
259
+ var status_default = defineCommand2({
260
+ meta: {
261
+ name: "status",
262
+ description: "Show your project's configuration status"
263
+ },
264
+ async run() {
265
+ console.log("");
266
+ p5.intro(`${pc3.bgCyan(pc3.black(" whop-kit status "))} Project health`);
267
+ const manifest = readManifest(".");
268
+ if (!manifest) {
269
+ p5.log.error(
270
+ "No .whop/config.json found. Are you in a project created with create-whop-kit?"
271
+ );
272
+ process.exit(1);
273
+ }
274
+ const envVars = readEnvFile(".");
275
+ if (!envVars["NEXT_PUBLIC_WHOP_APP_ID"] && envVars["WHOP_APP_ID"]) {
276
+ envVars["NEXT_PUBLIC_WHOP_APP_ID"] = envVars["WHOP_APP_ID"];
277
+ }
278
+ console.log(` ${pc3.bold("Framework:")} ${manifest.framework}`);
279
+ console.log(` ${pc3.bold("App type:")} ${manifest.appType}`);
280
+ console.log(` ${pc3.bold("Database:")} ${manifest.database}`);
281
+ console.log(` ${pc3.bold("Created:")} ${new Date(manifest.createdAt).toLocaleDateString()}`);
282
+ console.log("");
283
+ console.log(` ${pc3.bold("Configuration:")}`);
284
+ let allRequired = true;
285
+ for (const check of ENV_CHECKS) {
286
+ const value = envVars[check.key];
287
+ const isSet = !!value;
288
+ if (check.required && !isSet) allRequired = false;
289
+ const icon = isSet ? pc3.green("\u2713") : check.required ? pc3.red("\u2717") : pc3.yellow("\u25CB");
290
+ const maskedValue = isSet ? pc3.dim(value.substring(0, 8) + "...") : check.required ? pc3.red("not set") : pc3.dim("not set (optional)");
291
+ console.log(` ${icon} ${check.label.padEnd(20)} ${maskedValue}`);
292
+ }
293
+ console.log("");
294
+ if (manifest.features.length > 0) {
295
+ console.log(` ${pc3.bold("Features:")} ${manifest.features.join(", ")}`);
296
+ }
297
+ if (allRequired) {
298
+ p5.outro(pc3.green("All required configuration is set. Ready to run!"));
299
+ } else {
300
+ p5.outro(
301
+ `${pc3.yellow("Some required config is missing.")} Run ${pc3.bold("whop-kit add")} or edit ${pc3.dim(".env.local")}`
302
+ );
303
+ }
304
+ }
305
+ });
306
+
307
+ // src/commands/open.ts
308
+ import * as p6 from "@clack/prompts";
309
+ import pc4 from "picocolors";
310
+ import { defineCommand as defineCommand3 } from "citty";
311
+ var DASHBOARDS = {
312
+ whop: { name: "Whop Developer Dashboard", url: "https://whop.com/dashboard/developer" },
313
+ neon: { name: "Neon Console", url: "https://console.neon.tech" },
314
+ supabase: { name: "Supabase Dashboard", url: "https://supabase.com/dashboard" },
315
+ vercel: { name: "Vercel Dashboard", url: "https://vercel.com/dashboard" }
316
+ };
317
+ function openUrl(url) {
318
+ const platform = process.platform;
319
+ if (platform === "darwin") exec(`open "${url}"`);
320
+ else if (platform === "win32") exec(`start "${url}"`);
321
+ else exec(`xdg-open "${url}"`);
322
+ }
323
+ var open_default = defineCommand3({
324
+ meta: {
325
+ name: "open",
326
+ description: "Open a provider dashboard in your browser"
327
+ },
328
+ args: {
329
+ target: {
330
+ type: "positional",
331
+ description: `Dashboard to open: ${Object.keys(DASHBOARDS).join(", ")}`,
332
+ required: false
333
+ }
334
+ },
335
+ async run({ args }) {
336
+ let target = args.target;
337
+ if (!target) {
338
+ const result = await p6.select({
339
+ message: "Which dashboard?",
340
+ options: Object.entries(DASHBOARDS).map(([value, d]) => ({
341
+ value,
342
+ label: d.name,
343
+ hint: d.url
344
+ }))
345
+ });
346
+ if (p6.isCancel(result)) {
347
+ p6.cancel("Cancelled.");
348
+ process.exit(0);
349
+ }
350
+ target = result;
351
+ }
352
+ const dashboard = DASHBOARDS[target];
353
+ if (!dashboard) {
354
+ p6.log.error(`Unknown dashboard "${target}". Options: ${Object.keys(DASHBOARDS).join(", ")}`);
355
+ process.exit(1);
356
+ }
357
+ openUrl(dashboard.url);
358
+ console.log(`
359
+ Opening ${pc4.bold(dashboard.name)} \u2192 ${pc4.cyan(dashboard.url)}
360
+ `);
361
+ }
362
+ });
363
+
364
+ // src/commands/upgrade.ts
365
+ import * as p7 from "@clack/prompts";
366
+ import pc5 from "picocolors";
367
+ import { defineCommand as defineCommand4 } from "citty";
368
+ var upgrade_default = defineCommand4({
369
+ meta: {
370
+ name: "upgrade",
371
+ description: "Update whop-kit to the latest version in your project"
372
+ },
373
+ async run() {
374
+ console.log("");
375
+ p7.intro(`${pc5.bgCyan(pc5.black(" whop-kit upgrade "))}`);
376
+ const manifest = readManifest(".");
377
+ if (!manifest) {
378
+ p7.log.error("No .whop/config.json found. Are you in a whop-kit project?");
379
+ process.exit(1);
380
+ }
381
+ const pm = detectPackageManager();
382
+ const s = p7.spinner();
383
+ s.start("Checking for updates...");
384
+ const latest = exec("npm view whop-kit version");
385
+ s.stop(latest.success ? `Latest: whop-kit@${latest.stdout}` : "Could not check latest version");
386
+ s.start(`Upgrading whop-kit with ${pm}...`);
387
+ const cmd = pm === "npm" ? "npm install whop-kit@latest" : pm === "yarn" ? "yarn add whop-kit@latest" : pm === "bun" ? "bun add whop-kit@latest" : "pnpm add whop-kit@latest";
388
+ const result = exec(cmd);
389
+ if (result.success) {
390
+ s.stop(pc5.green("whop-kit upgraded"));
391
+ } else {
392
+ s.stop(pc5.red("Upgrade failed"));
393
+ p7.log.error("Try running manually: " + pc5.bold(cmd));
394
+ }
395
+ p7.outro("Done");
396
+ }
397
+ });
398
+
399
+ // src/cli-kit.ts
400
+ var main = defineCommand5({
401
+ meta: {
402
+ name: "whop-kit",
403
+ version: "0.2.0",
404
+ description: "Manage your Whop project"
405
+ },
406
+ subCommands: {
407
+ add: add_default,
408
+ status: status_default,
409
+ open: open_default,
410
+ upgrade: upgrade_default
411
+ }
412
+ });
413
+ runMain(main);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "create-whop-kit",
3
- "version": "0.1.0",
4
- "description": "Scaffold a new Whop-powered app with whop-kit",
3
+ "version": "0.2.0",
4
+ "description": "Scaffold and manage Whop-powered apps with whop-kit",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "Colin McDermott",
@@ -10,22 +10,22 @@
10
10
  "url": "https://github.com/colinmcdermott/create-whop-kit"
11
11
  },
12
12
  "bin": {
13
- "create-whop-kit": "./dist/index.js"
13
+ "create-whop-kit": "./dist/cli-create.js",
14
+ "whop-kit": "./dist/cli-kit.js"
14
15
  },
15
16
  "files": [
16
17
  "dist",
17
- "templates",
18
- "README.md",
19
- "LICENSE"
18
+ "README.md"
20
19
  ],
21
20
  "scripts": {
22
21
  "build": "tsup",
23
22
  "dev": "tsup --watch",
24
- "start": "node dist/index.js",
25
23
  "prepublishOnly": "npm run build"
26
24
  },
27
25
  "dependencies": {
28
- "@clack/prompts": "^0.10.0"
26
+ "@clack/prompts": "^0.10.0",
27
+ "citty": "^0.2.2",
28
+ "picocolors": "^1.1.1"
29
29
  },
30
30
  "devDependencies": {
31
31
  "tsup": "^8.4.0",
package/dist/index.js DELETED
@@ -1,209 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- // src/index.ts
4
- import * as p from "@clack/prompts";
5
- import { execSync } from "child_process";
6
- import { existsSync, readFileSync, writeFileSync } from "fs";
7
- import { resolve, join, basename } from "path";
8
- import { fileURLToPath } from "url";
9
- var __dirname = fileURLToPath(new URL(".", import.meta.url));
10
- var TEMPLATES_DIR = resolve(__dirname, "..", "templates");
11
- var TEMPLATES = {
12
- nextjs: {
13
- name: "Next.js",
14
- description: "Full-stack React with App Router, SSR, and API routes",
15
- repo: "colinmcdermott/whop-saas-starter-v2",
16
- available: true
17
- },
18
- astro: {
19
- name: "Astro",
20
- description: "Content-focused with islands architecture",
21
- repo: "",
22
- available: false
23
- },
24
- tanstack: {
25
- name: "TanStack Start",
26
- description: "Full-stack React with TanStack Router",
27
- repo: "",
28
- available: false
29
- },
30
- vite: {
31
- name: "Vite + React",
32
- description: "Lightweight SPA with Vite bundler",
33
- repo: "",
34
- available: false
35
- }
36
- };
37
- var APP_TYPES = {
38
- saas: {
39
- name: "SaaS",
40
- description: "Subscription tiers, dashboard, billing portal",
41
- available: true
42
- },
43
- course: {
44
- name: "Course",
45
- description: "Lessons, progress tracking, drip content",
46
- available: false
47
- },
48
- community: {
49
- name: "Community",
50
- description: "Member feeds, gated content, roles",
51
- available: false
52
- },
53
- blank: {
54
- name: "Blank",
55
- description: "Just auth + payments, you build the rest",
56
- available: false
57
- }
58
- };
59
- var DB_OPTIONS = {
60
- neon: {
61
- name: "Neon",
62
- description: "Serverless Postgres (recommended)",
63
- envVarHint: "postgresql://user:pass@ep-xxx.us-east-2.aws.neon.tech/dbname?sslmode=require"
64
- },
65
- supabase: {
66
- name: "Supabase",
67
- description: "Open-source Firebase alternative",
68
- envVarHint: "postgresql://postgres.xxx:pass@aws-0-us-east-1.pooler.supabase.com:6543/postgres"
69
- },
70
- local: {
71
- name: "Local PostgreSQL",
72
- description: "Your own Postgres instance",
73
- envVarHint: "postgresql://postgres:postgres@localhost:5432/myapp"
74
- },
75
- later: {
76
- name: "Configure later",
77
- description: "Skip database setup for now",
78
- envVarHint: ""
79
- }
80
- };
81
- function run(cmd, cwd) {
82
- try {
83
- return execSync(cmd, { cwd, stdio: "pipe", encoding: "utf-8" }).trim();
84
- } catch {
85
- return "";
86
- }
87
- }
88
- function hasCommand(cmd) {
89
- return run(`which ${cmd}`) !== "";
90
- }
91
- async function main() {
92
- const args = process.argv.slice(2);
93
- const projectName = args[0];
94
- console.log("");
95
- p.intro("Create Whop Kit App");
96
- const name = projectName ?? await p.text({
97
- message: "Project name",
98
- placeholder: "my-whop-app",
99
- validate: (v) => {
100
- if (!v) return "Project name is required";
101
- if (existsSync(resolve(v))) return `Directory "${v}" already exists`;
102
- }
103
- });
104
- if (p.isCancel(name)) {
105
- p.cancel("Cancelled.");
106
- process.exit(0);
107
- }
108
- const appType = await p.select({
109
- message: "What are you building?",
110
- options: Object.entries(APP_TYPES).map(([value, { name: name2, description, available }]) => ({
111
- value,
112
- label: available ? name2 : `${name2} (coming soon)`,
113
- hint: description,
114
- disabled: !available
115
- }))
116
- });
117
- if (p.isCancel(appType)) {
118
- p.cancel("Cancelled.");
119
- process.exit(0);
120
- }
121
- const framework = await p.select({
122
- message: "Which framework?",
123
- options: Object.entries(TEMPLATES).map(([value, { name: name2, description, available }]) => ({
124
- value,
125
- label: available ? name2 : `${name2} (coming soon)`,
126
- hint: description,
127
- disabled: !available
128
- }))
129
- });
130
- if (p.isCancel(framework)) {
131
- p.cancel("Cancelled.");
132
- process.exit(0);
133
- }
134
- const database = await p.select({
135
- message: "Which database?",
136
- options: Object.entries(DB_OPTIONS).map(([value, { name: name2, description }]) => ({
137
- value,
138
- label: name2,
139
- hint: description
140
- }))
141
- });
142
- if (p.isCancel(database)) {
143
- p.cancel("Cancelled.");
144
- process.exit(0);
145
- }
146
- let databaseUrl = "";
147
- if (database !== "later") {
148
- const dbUrl = await p.text({
149
- message: "Database URL",
150
- placeholder: DB_OPTIONS[database].envVarHint,
151
- validate: (v) => {
152
- if (!v) return "Database URL is required (or go back and choose 'Configure later')";
153
- }
154
- });
155
- if (p.isCancel(dbUrl)) {
156
- p.cancel("Cancelled.");
157
- process.exit(0);
158
- }
159
- databaseUrl = dbUrl;
160
- }
161
- const template = TEMPLATES[framework];
162
- const projectDir = resolve(name);
163
- const s = p.spinner();
164
- s.start("Cloning template...");
165
- const cloneResult = run(
166
- `git clone --depth 1 https://github.com/${template.repo}.git "${projectDir}" 2>&1`
167
- );
168
- if (!existsSync(projectDir)) {
169
- s.stop("Failed to clone template");
170
- p.log.error(cloneResult || "Git clone failed. Make sure git is installed.");
171
- process.exit(1);
172
- }
173
- run(`rm -rf "${join(projectDir, ".git")}"`);
174
- const pkgPath = join(projectDir, "package.json");
175
- if (existsSync(pkgPath)) {
176
- const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
177
- pkg.name = basename(name);
178
- writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
179
- }
180
- s.stop("Template cloned");
181
- if (databaseUrl) {
182
- s.start("Configuring environment...");
183
- const envContent = `DATABASE_URL="${databaseUrl}"
184
- `;
185
- writeFileSync(join(projectDir, ".env.local"), envContent);
186
- s.stop("Environment configured");
187
- }
188
- const packageManager = hasCommand("pnpm") ? "pnpm" : hasCommand("yarn") ? "yarn" : "npm";
189
- s.start(`Installing dependencies with ${packageManager}...`);
190
- run(`${packageManager} install`, projectDir);
191
- s.stop("Dependencies installed");
192
- run("git init", projectDir);
193
- run("git add -A", projectDir);
194
- run('git commit -m "initial: scaffolded with create-whop-kit"', projectDir);
195
- const relativePath = name;
196
- p.note(
197
- [
198
- `cd ${relativePath}`,
199
- databaseUrl ? `${packageManager} run db:push` : `# Add DATABASE_URL to .env.local first`,
200
- `${packageManager} run dev`
201
- ].join("\n"),
202
- "Next steps"
203
- );
204
- p.outro("Happy building!");
205
- }
206
- main().catch((err) => {
207
- console.error(err);
208
- process.exit(1);
209
- });