create-start-kit-dev 0.1.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.
Files changed (3) hide show
  1. package/README.md +49 -0
  2. package/dist/index.mjs +811 -0
  3. package/package.json +54 -0
package/README.md ADDED
@@ -0,0 +1,49 @@
1
+ # create-start-kit-dev
2
+
3
+ CLI for scaffolding and configuring [Start Kit](https://github.com/CarlosZiegler/start-kit.dev) projects.
4
+
5
+ ## Usage
6
+
7
+ ### Create a new project
8
+
9
+ ```bash
10
+ bunx create-start-kit-dev create my-app
11
+ ```
12
+
13
+ This downloads the template, installs dependencies, and runs the interactive setup wizard.
14
+
15
+ ### Initialize an existing project
16
+
17
+ ```bash
18
+ bunx create-start-kit-dev init
19
+ ```
20
+
21
+ ### Run a specific setup phase
22
+
23
+ ```bash
24
+ bunx create-start-kit-dev init --step database
25
+ ```
26
+
27
+ ## Setup Phases
28
+
29
+ The wizard guides you through these phases (resumable if interrupted):
30
+
31
+ | Phase | What it does |
32
+ |-------|-------------|
33
+ | **Branding** | App name, description, logo, colors |
34
+ | **Features** | Toggle AI, payments, storage, i18n |
35
+ | **Database** | PostgreSQL connection and schema setup |
36
+ | **Environment** | Generate `.env` with required variables |
37
+ | **Infrastructure** | Docker and deployment config |
38
+
39
+ ## Development
40
+
41
+ ```bash
42
+ bun install
43
+ bun run build # Build with tsdown
44
+ bun run dev # Watch mode
45
+ ```
46
+
47
+ ## License
48
+
49
+ MIT
package/dist/index.mjs ADDED
@@ -0,0 +1,811 @@
1
+ #!/usr/bin/env bun
2
+ import { confirm, intro, isCancel, log, multiselect, outro, select, spinner, text } from "@clack/prompts";
3
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { downloadTemplate } from "giget";
5
+
6
+ //#region src/lib/state.ts
7
+ const STATE_FILE = ".setup-state.json";
8
+ const DEFAULT_STATE = {
9
+ version: 1,
10
+ completedPhases: []
11
+ };
12
+ function getStatePath() {
13
+ return `${process.cwd()}/${STATE_FILE}`;
14
+ }
15
+ function loadState() {
16
+ const path = getStatePath();
17
+ if (!existsSync(path)) return { ...DEFAULT_STATE };
18
+ const content = readFileSync(path, "utf-8");
19
+ return JSON.parse(content);
20
+ }
21
+ async function saveState(state) {
22
+ await Bun.write(getStatePath(), JSON.stringify(state, null, 2));
23
+ }
24
+ function isPhaseCompleted(state, phase) {
25
+ return state.completedPhases.includes(phase);
26
+ }
27
+ function markPhaseCompleted(state, phase) {
28
+ if (!state.completedPhases.includes(phase)) state.completedPhases.push(phase);
29
+ return state;
30
+ }
31
+
32
+ //#endregion
33
+ //#region src/lib/helpers.ts
34
+ const NAME_PATTERN = /name:\s*"[^"]*"/;
35
+ const DESCRIPTION_PATTERN = /description:\s*"[^"]*"/;
36
+ function updateJsonFile(path, updates) {
37
+ const content = readFileSync(path, "utf-8");
38
+ const json = JSON.parse(content);
39
+ for (const [key, value] of Object.entries(updates)) json[key] = value;
40
+ writeFileSync(path, `${JSON.stringify(json, null, 2)}\n`);
41
+ }
42
+ function updateAppConfig(path, name, description) {
43
+ let content = readFileSync(path, "utf-8");
44
+ content = content.replace(NAME_PATTERN, `name: "${name}"`);
45
+ content = content.replace(DESCRIPTION_PATTERN, `description: "${description}"`);
46
+ writeFileSync(path, content);
47
+ }
48
+ function writeEnvFile(path, vars, comments) {
49
+ const lines = [];
50
+ for (const [key, value] of Object.entries(vars)) {
51
+ if (comments?.[key]) lines.push(`# ${comments[key]}`);
52
+ const formatted = value.includes(" ") ? `"${value}"` : value;
53
+ lines.push(`${key}=${formatted}`);
54
+ }
55
+ writeFileSync(path, `${lines.join("\n")}\n`);
56
+ }
57
+ function readEnvFile(path) {
58
+ try {
59
+ const content = readFileSync(path, "utf-8");
60
+ const result = {};
61
+ for (const line of content.split("\n")) {
62
+ const trimmed = line.trim();
63
+ if (trimmed && !trimmed.startsWith("#")) {
64
+ const eqIndex = trimmed.indexOf("=");
65
+ if (eqIndex > 0) {
66
+ const key = trimmed.slice(0, eqIndex);
67
+ let value = trimmed.slice(eqIndex + 1);
68
+ if (value.startsWith("\"") && value.endsWith("\"") || value.startsWith("'") && value.endsWith("'")) value = value.slice(1, -1);
69
+ result[key] = value;
70
+ }
71
+ }
72
+ }
73
+ return result;
74
+ } catch {
75
+ return {};
76
+ }
77
+ }
78
+ function generateSecret(bytes = 32) {
79
+ const buffer = new Uint8Array(bytes);
80
+ crypto.getRandomValues(buffer);
81
+ return btoa(String.fromCharCode(...buffer));
82
+ }
83
+ async function exec(command) {
84
+ const proc = Bun.spawn([
85
+ "sh",
86
+ "-c",
87
+ command
88
+ ], {
89
+ stdout: "pipe",
90
+ stderr: "pipe"
91
+ });
92
+ const stdout = await new Response(proc.stdout).text();
93
+ const stderr = await new Response(proc.stderr).text();
94
+ const exitCode = await proc.exited;
95
+ return {
96
+ stdout: stdout.trim(),
97
+ stderr: stderr.trim(),
98
+ exitCode
99
+ };
100
+ }
101
+ async function testDbConnection(url) {
102
+ try {
103
+ const { SQL } = await import("bun");
104
+ const sql = new SQL(url);
105
+ const result = sql.query("SELECT version()").get();
106
+ sql.close();
107
+ return {
108
+ ok: true,
109
+ version: result.version
110
+ };
111
+ } catch (error) {
112
+ return {
113
+ ok: false,
114
+ error: error instanceof Error ? error.message : String(error)
115
+ };
116
+ }
117
+ }
118
+
119
+ //#endregion
120
+ //#region src/lib/validators.ts
121
+ const DOMAIN_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i;
122
+ function isValidDomain(value) {
123
+ return DOMAIN_PATTERN.test(value);
124
+ }
125
+ function isValidPostgresUrl(value) {
126
+ return value.startsWith("postgresql://") || value.startsWith("postgres://");
127
+ }
128
+ function isValidAppName(value) {
129
+ return value.length >= 2 && value.length <= 50;
130
+ }
131
+ function toKebabCase(value) {
132
+ return value.toLowerCase().replace(/[^a-z0-9\s-]/g, "").replace(/\s+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
133
+ }
134
+
135
+ //#endregion
136
+ //#region src/phases/branding.ts
137
+ async function runBranding(state) {
138
+ const appName = await text({
139
+ message: "What's your app name?",
140
+ placeholder: "My SaaS App",
141
+ initialValue: state.branding?.appName,
142
+ validate: (v) => {
143
+ if (!isValidAppName(v)) return "Name must be 2-50 characters";
144
+ }
145
+ });
146
+ if (isCancel(appName)) process.exit(0);
147
+ const description = await text({
148
+ message: "Short description?",
149
+ placeholder: "A platform for managing widgets",
150
+ initialValue: state.branding?.description
151
+ });
152
+ if (isCancel(description)) process.exit(0);
153
+ const domain = await text({
154
+ message: "What's your domain? (for emails, auth origins)",
155
+ placeholder: "mysaasapp.com",
156
+ initialValue: state.branding?.domain,
157
+ validate: (v) => {
158
+ if (v && !isValidDomain(v)) return "Enter a valid domain (e.g. myapp.com)";
159
+ }
160
+ });
161
+ if (isCancel(domain)) process.exit(0);
162
+ const s = spinner();
163
+ s.start("Updating files...");
164
+ const kebabName = toKebabCase(appName);
165
+ updateJsonFile("package.json", { name: kebabName });
166
+ updateAppConfig("src/lib/config/app.config.ts", appName, description);
167
+ s.stop("Files updated");
168
+ log.success(`package.json → name: "${kebabName}"`);
169
+ log.success(`app.config.ts → name: "${appName}"`);
170
+ state.branding = {
171
+ appName,
172
+ description,
173
+ domain: domain || ""
174
+ };
175
+ markPhaseCompleted(state, "branding");
176
+ await saveState(state);
177
+ return state;
178
+ }
179
+
180
+ //#endregion
181
+ //#region src/phases/database.ts
182
+ async function runDatabase(state) {
183
+ const dbChoice = await select({
184
+ message: "Do you already have a PostgreSQL database?",
185
+ options: [{
186
+ value: "own",
187
+ label: "Yes, I have a connection URL"
188
+ }, {
189
+ value: "instagres",
190
+ label: "No, create one instantly with Instagres (pg.new)",
191
+ hint: "free 72h, claim to keep"
192
+ }]
193
+ });
194
+ if (isCancel(dbChoice)) process.exit(0);
195
+ let databaseUrl;
196
+ if (dbChoice === "instagres") {
197
+ const s = spinner();
198
+ s.start("Creating instant Neon database via Instagres...");
199
+ const result = await exec("bunx get-db --yes --env .env --key DATABASE_URL");
200
+ if (result.exitCode !== 0) {
201
+ s.stop("Failed to create database");
202
+ log.error(`get-db failed: ${result.stderr}`);
203
+ log.info("You can try manually: npx get-db --yes");
204
+ log.info("Or provide your own DATABASE_URL and re-run: bunx create-start-kit-dev init --step database");
205
+ process.exit(1);
206
+ }
207
+ s.stop("Database created!");
208
+ databaseUrl = readEnvFile(".env").DATABASE_URL ?? "";
209
+ if (!databaseUrl) {
210
+ log.error("DATABASE_URL not found in .env after get-db. Check .env manually.");
211
+ process.exit(1);
212
+ }
213
+ log.success("DATABASE_URL written to .env");
214
+ log.warn("This database expires in 72 hours.");
215
+ log.info("Claim it at https://neon.tech or run: npx get-db claim");
216
+ } else {
217
+ const url = await text({
218
+ message: "Enter your DATABASE_URL:",
219
+ placeholder: "postgresql://user:pass@host:5432/mydb",
220
+ validate: (v) => {
221
+ if (!isValidPostgresUrl(v)) return "Must start with postgresql:// or postgres://";
222
+ }
223
+ });
224
+ if (isCancel(url)) process.exit(0);
225
+ databaseUrl = url;
226
+ }
227
+ const s = spinner();
228
+ s.start("Testing database connection...");
229
+ const test = await testDbConnection(databaseUrl);
230
+ if (!test.ok) {
231
+ s.stop("Connection failed");
232
+ log.error(`Could not connect: ${test.error}`);
233
+ log.info("Check your DATABASE_URL and try again.");
234
+ process.exit(1);
235
+ }
236
+ s.stop("Connected to PostgreSQL");
237
+ const shouldMigrate = await confirm({
238
+ message: "Run database migrations now?",
239
+ initialValue: true
240
+ });
241
+ if (isCancel(shouldMigrate)) process.exit(0);
242
+ if (shouldMigrate) {
243
+ const ms = spinner();
244
+ ms.start("Running migrations...");
245
+ const envVars = readEnvFile(".env");
246
+ if (!envVars.DATABASE_URL) {
247
+ envVars.DATABASE_URL = databaseUrl;
248
+ writeEnvFile(".env", envVars);
249
+ }
250
+ const migrateResult = await exec("bun run db:push");
251
+ if (migrateResult.exitCode !== 0) {
252
+ ms.stop("Migration failed");
253
+ log.error(migrateResult.stderr);
254
+ log.info("You can run migrations later with: bun run db:push");
255
+ } else ms.stop("Migrations applied!");
256
+ }
257
+ state.database = {
258
+ provider: dbChoice,
259
+ migrated: shouldMigrate === true
260
+ };
261
+ markPhaseCompleted(state, "database");
262
+ await saveState(state);
263
+ return state;
264
+ }
265
+
266
+ //#endregion
267
+ //#region src/phases/env.ts
268
+ const DUMMY_VALUES = {
269
+ RESEND_API_KEY: "re_dummy_replace_me",
270
+ S3_ACCESS_KEY_ID: "dummy_replace_me",
271
+ S3_SECRET_ACCESS_KEY: "dummy_replace_me",
272
+ S3_BUCKET: "dummy-bucket"
273
+ };
274
+ function setupCoreVars(ctx) {
275
+ if (!ctx.envVars.BETTER_AUTH_SECRET || ctx.envVars.BETTER_AUTH_SECRET.includes("haha")) {
276
+ ctx.envVars.BETTER_AUTH_SECRET = generateSecret();
277
+ log.success("BETTER_AUTH_SECRET — auto-generated");
278
+ } else log.info("BETTER_AUTH_SECRET — keeping existing value");
279
+ ctx.envVars.BETTER_AUTH_BASE_URL = ctx.envVars.BETTER_AUTH_BASE_URL ?? "http://localhost:3000";
280
+ ctx.envVars.VITE_BETTER_AUTH_BASE_URL = ctx.envVars.VITE_BETTER_AUTH_BASE_URL ?? "http://localhost:3000";
281
+ }
282
+ async function setupEmail(ctx, enabled) {
283
+ if (enabled) {
284
+ const resendKey = await text({
285
+ message: "RESEND_API_KEY (get one at resend.com, or Enter for placeholder):",
286
+ placeholder: "re_xxxxxxxxxxxx",
287
+ initialValue: ctx.envVars.RESEND_API_KEY
288
+ });
289
+ if (isCancel(resendKey)) process.exit(0);
290
+ if (!resendKey || resendKey === DUMMY_VALUES.RESEND_API_KEY) {
291
+ ctx.envVars.RESEND_API_KEY = DUMMY_VALUES.RESEND_API_KEY;
292
+ ctx.placeholders.push("RESEND_API_KEY");
293
+ log.warn("Using placeholder — emails will fail until you add a real key");
294
+ } else ctx.envVars.RESEND_API_KEY = resendKey;
295
+ const domain = ctx.state.branding?.domain ?? "yourdomain.com";
296
+ const appName = ctx.state.branding?.appName ?? "App";
297
+ ctx.envVars.RESEND_FROM_EMAIL = ctx.envVars.RESEND_FROM_EMAIL ?? `${appName} <noreply@${domain}>`;
298
+ return;
299
+ }
300
+ ctx.envVars.RESEND_API_KEY = ctx.envVars.RESEND_API_KEY ?? DUMMY_VALUES.RESEND_API_KEY;
301
+ if (ctx.envVars.RESEND_API_KEY === DUMMY_VALUES.RESEND_API_KEY) ctx.placeholders.push("RESEND_API_KEY");
302
+ }
303
+ async function setupStorageCredentials(ctx) {
304
+ const accessKey = await text({
305
+ message: "S3_ACCESS_KEY_ID:",
306
+ initialValue: ctx.envVars.S3_ACCESS_KEY_ID
307
+ });
308
+ if (isCancel(accessKey)) process.exit(0);
309
+ ctx.envVars.S3_ACCESS_KEY_ID = accessKey;
310
+ const secretKey = await text({
311
+ message: "S3_SECRET_ACCESS_KEY:",
312
+ initialValue: ctx.envVars.S3_SECRET_ACCESS_KEY
313
+ });
314
+ if (isCancel(secretKey)) process.exit(0);
315
+ ctx.envVars.S3_SECRET_ACCESS_KEY = secretKey;
316
+ const bucket = await text({
317
+ message: "S3_BUCKET:",
318
+ initialValue: ctx.envVars.S3_BUCKET ?? "app-assets"
319
+ });
320
+ if (isCancel(bucket)) process.exit(0);
321
+ ctx.envVars.S3_BUCKET = bucket;
322
+ const endpoint = await text({
323
+ message: "S3_ENDPOINT (optional, Enter to skip):",
324
+ initialValue: ctx.envVars.S3_ENDPOINT
325
+ });
326
+ if (isCancel(endpoint)) process.exit(0);
327
+ if (endpoint) ctx.envVars.S3_ENDPOINT = endpoint;
328
+ ctx.envVars.S3_REGION = ctx.envVars.S3_REGION ?? "us-east-1";
329
+ ctx.envVars.STORAGE_PROVIDER = ctx.envVars.STORAGE_PROVIDER ?? "s3";
330
+ }
331
+ function setSeaweedfsDefaults(ctx) {
332
+ ctx.envVars.STORAGE_PROVIDER = "seaweedfs";
333
+ ctx.envVars.S3_ENDPOINT = "http://localhost:8333";
334
+ ctx.envVars.S3_ACCESS_KEY_ID = "minioadmin";
335
+ ctx.envVars.S3_SECRET_ACCESS_KEY = "minioadmin";
336
+ ctx.envVars.S3_BUCKET = "app-assets";
337
+ ctx.envVars.S3_REGION = "us-east-1";
338
+ if (ctx.state.infra) ctx.state.infra.seaweedfs = true;
339
+ else ctx.state.infra = {
340
+ seaweedfs: true,
341
+ redis: false
342
+ };
343
+ }
344
+ function setStorageDummies(ctx) {
345
+ ctx.envVars.STORAGE_PROVIDER = ctx.envVars.STORAGE_PROVIDER ?? "s3";
346
+ ctx.envVars.S3_ACCESS_KEY_ID = DUMMY_VALUES.S3_ACCESS_KEY_ID;
347
+ ctx.envVars.S3_SECRET_ACCESS_KEY = DUMMY_VALUES.S3_SECRET_ACCESS_KEY;
348
+ ctx.envVars.S3_BUCKET = DUMMY_VALUES.S3_BUCKET;
349
+ ctx.envVars.S3_REGION = "us-east-1";
350
+ ctx.placeholders.push("S3_ACCESS_KEY_ID", "S3_SECRET_ACCESS_KEY", "S3_BUCKET");
351
+ log.warn("Using placeholders — file uploads will fail until configured");
352
+ }
353
+ async function setupStorage(ctx, enabled) {
354
+ if (!enabled) {
355
+ ctx.envVars.STORAGE_PROVIDER = ctx.envVars.STORAGE_PROVIDER ?? "s3";
356
+ ctx.envVars.S3_ACCESS_KEY_ID = ctx.envVars.S3_ACCESS_KEY_ID ?? DUMMY_VALUES.S3_ACCESS_KEY_ID;
357
+ ctx.envVars.S3_SECRET_ACCESS_KEY = ctx.envVars.S3_SECRET_ACCESS_KEY ?? DUMMY_VALUES.S3_SECRET_ACCESS_KEY;
358
+ ctx.envVars.S3_BUCKET = ctx.envVars.S3_BUCKET ?? DUMMY_VALUES.S3_BUCKET;
359
+ ctx.envVars.S3_REGION = ctx.envVars.S3_REGION ?? "us-east-1";
360
+ if (ctx.envVars.S3_ACCESS_KEY_ID === DUMMY_VALUES.S3_ACCESS_KEY_ID) ctx.placeholders.push("S3_ACCESS_KEY_ID", "S3_SECRET_ACCESS_KEY", "S3_BUCKET");
361
+ return;
362
+ }
363
+ const storageChoice = await select({
364
+ message: "Do you have S3-compatible storage credentials?",
365
+ options: [
366
+ {
367
+ value: "dummy",
368
+ label: "No, use placeholder values",
369
+ hint: "storage calls will fail gracefully"
370
+ },
371
+ {
372
+ value: "credentials",
373
+ label: "Yes, I have credentials (S3, R2, Minio, etc.)"
374
+ },
375
+ {
376
+ value: "seaweedfs",
377
+ label: "Start SeaweedFS via Docker (local S3)"
378
+ }
379
+ ]
380
+ });
381
+ if (isCancel(storageChoice)) process.exit(0);
382
+ if (storageChoice === "credentials") await setupStorageCredentials(ctx);
383
+ else if (storageChoice === "seaweedfs") setSeaweedfsDefaults(ctx);
384
+ else setStorageDummies(ctx);
385
+ }
386
+ async function setupStripe(ctx) {
387
+ const stripeSecret = await text({
388
+ message: "STRIPE_SECRET_KEY (Enter to skip):",
389
+ placeholder: "sk_test_xxxxx",
390
+ initialValue: ctx.envVars.STRIPE_SECRET_KEY
391
+ });
392
+ if (isCancel(stripeSecret)) process.exit(0);
393
+ if (!stripeSecret) {
394
+ log.info("Stripe skipped — payments feature will be disabled");
395
+ return;
396
+ }
397
+ ctx.envVars.STRIPE_SECRET_KEY = stripeSecret;
398
+ const webhookSecret = await text({
399
+ message: "STRIPE_WEBHOOK_SECRET:",
400
+ placeholder: "whsec_xxxxx",
401
+ initialValue: ctx.envVars.STRIPE_WEBHOOK_SECRET
402
+ });
403
+ if (isCancel(webhookSecret)) process.exit(0);
404
+ if (webhookSecret) ctx.envVars.STRIPE_WEBHOOK_SECRET = webhookSecret;
405
+ const publishableKey = await text({
406
+ message: "STRIPE_PUBLISHABLE_KEY (for client):",
407
+ placeholder: "pk_test_xxxxx",
408
+ initialValue: ctx.envVars.STRIPE_PUBLISHABLE_KEY
409
+ });
410
+ if (isCancel(publishableKey)) process.exit(0);
411
+ if (publishableKey) {
412
+ ctx.envVars.STRIPE_PUBLISHABLE_KEY = publishableKey;
413
+ ctx.envVars.VITE_STRIPE_ENABLED = "true";
414
+ }
415
+ }
416
+ async function setupAI(ctx) {
417
+ const openai = await text({
418
+ message: "OpenAI API key (optional, Enter to skip):",
419
+ placeholder: "sk-proj-xxxxx",
420
+ initialValue: ctx.envVars.OPENAI_API_KEY
421
+ });
422
+ if (isCancel(openai)) process.exit(0);
423
+ if (openai) ctx.envVars.OPENAI_API_KEY = openai;
424
+ const anthropic = await text({
425
+ message: "Anthropic API key (optional, Enter to skip):",
426
+ initialValue: ctx.envVars.ANTHROPIC_API_KEY
427
+ });
428
+ if (isCancel(anthropic)) process.exit(0);
429
+ if (anthropic) ctx.envVars.ANTHROPIC_API_KEY = anthropic;
430
+ }
431
+ async function setupRedis(ctx) {
432
+ const redisChoice = await select({
433
+ message: "Do you have an external Redis?",
434
+ options: [
435
+ {
436
+ value: "skip",
437
+ label: "No, skip",
438
+ hint: "chat won't have resumable streams"
439
+ },
440
+ {
441
+ value: "url",
442
+ label: "Yes, I have a Redis URL"
443
+ },
444
+ {
445
+ value: "docker",
446
+ label: "Start Redis via Docker"
447
+ }
448
+ ]
449
+ });
450
+ if (isCancel(redisChoice)) process.exit(0);
451
+ if (redisChoice === "url") {
452
+ const redisUrl = await text({
453
+ message: "REDIS_URL:",
454
+ placeholder: "redis://localhost:6379",
455
+ initialValue: ctx.envVars.REDIS_URL
456
+ });
457
+ if (isCancel(redisUrl)) process.exit(0);
458
+ if (redisUrl) ctx.envVars.REDIS_URL = redisUrl;
459
+ } else if (redisChoice === "docker") {
460
+ ctx.envVars.REDIS_URL = "redis://localhost:6379";
461
+ if (ctx.state.infra) ctx.state.infra.redis = true;
462
+ else ctx.state.infra = {
463
+ seaweedfs: false,
464
+ redis: true
465
+ };
466
+ }
467
+ }
468
+ async function runEnv(state) {
469
+ const features = state.features ?? {
470
+ stripe: false,
471
+ ai: false,
472
+ storage: false,
473
+ redis: false,
474
+ email: false
475
+ };
476
+ const ctx = {
477
+ envVars: { ...readEnvFile(".env") },
478
+ placeholders: [],
479
+ state
480
+ };
481
+ setupCoreVars(ctx);
482
+ await setupEmail(ctx, features.email);
483
+ await setupStorage(ctx, features.storage);
484
+ if (features.stripe) await setupStripe(ctx);
485
+ if (features.ai) await setupAI(ctx);
486
+ if (features.redis) await setupRedis(ctx);
487
+ const s = spinner();
488
+ s.start("Writing .env file...");
489
+ writeEnvFile(".env", ctx.envVars);
490
+ s.stop(`.env written (${Object.keys(ctx.envVars).length} variables)`);
491
+ if (ctx.placeholders.length > 0) {
492
+ log.warn(`${ctx.placeholders.length} placeholders need real values later:`);
493
+ for (const p of ctx.placeholders) log.warn(` \u2022 ${p}`);
494
+ }
495
+ state.env = {
496
+ placeholders: ctx.placeholders,
497
+ written: true
498
+ };
499
+ markPhaseCompleted(state, "env");
500
+ await saveState(state);
501
+ return state;
502
+ }
503
+
504
+ //#endregion
505
+ //#region src/phases/features.ts
506
+ const FEATURE_OPTIONS = [
507
+ {
508
+ value: "stripe",
509
+ label: "Stripe",
510
+ hint: "payments & subscriptions"
511
+ },
512
+ {
513
+ value: "ai",
514
+ label: "AI Chat",
515
+ hint: "OpenAI / Anthropic / Gemini"
516
+ },
517
+ {
518
+ value: "storage",
519
+ label: "Storage",
520
+ hint: "S3 / SeaweedFS / R2 / Minio"
521
+ },
522
+ {
523
+ value: "redis",
524
+ label: "Redis",
525
+ hint: "resumable chat streams"
526
+ },
527
+ {
528
+ value: "email",
529
+ label: "Email",
530
+ hint: "Resend — transactional emails"
531
+ }
532
+ ];
533
+ async function runFeatures(state) {
534
+ const selected = await multiselect({
535
+ message: "Which features do you want to enable?",
536
+ options: FEATURE_OPTIONS.map((f) => ({
537
+ value: f.value,
538
+ label: `${f.label} (${f.hint})`
539
+ })),
540
+ initialValues: state.features ? Object.entries(state.features).filter(([, v]) => v).map(([k]) => k) : [],
541
+ required: false
542
+ });
543
+ if (isCancel(selected)) process.exit(0);
544
+ const features = {
545
+ stripe: selected.includes("stripe"),
546
+ ai: selected.includes("ai"),
547
+ storage: selected.includes("storage"),
548
+ redis: selected.includes("redis"),
549
+ email: selected.includes("email")
550
+ };
551
+ const enabled = Object.entries(features).filter(([, v]) => v).map(([k]) => k);
552
+ if (enabled.length > 0) log.success(`Enabled: ${enabled.join(", ")}`);
553
+ else log.info("No optional features selected — core app only");
554
+ state.features = features;
555
+ markPhaseCompleted(state, "features");
556
+ await saveState(state);
557
+ return state;
558
+ }
559
+
560
+ //#endregion
561
+ //#region src/phases/infra.ts
562
+ async function startDockerService(name, service, successMsg) {
563
+ const s = spinner();
564
+ s.start(`Starting ${name}...`);
565
+ const result = await exec(`docker compose up -d ${service}`);
566
+ if (result.exitCode !== 0) {
567
+ s.stop(`Failed to start ${name}`);
568
+ log.error(result.stderr);
569
+ log.info(`Make sure Docker is running, then try: docker compose up -d ${service}`);
570
+ } else s.stop(successMsg);
571
+ }
572
+ async function checkDatabase(envVars) {
573
+ if (!envVars.DATABASE_URL) return;
574
+ const db = await testDbConnection(envVars.DATABASE_URL);
575
+ return db.ok ? "Database — connected" : `Database — failed: ${db.error}`;
576
+ }
577
+ async function checkRedis(envVars) {
578
+ if (!envVars.REDIS_URL) return;
579
+ try {
580
+ const { RedisClient } = await import("bun");
581
+ const redis = new RedisClient(envVars.REDIS_URL);
582
+ await redis.connect();
583
+ await redis.disconnect();
584
+ return "Redis — connected";
585
+ } catch {
586
+ return "Redis — not reachable (will use fallback)";
587
+ }
588
+ }
589
+ async function runInfra(state) {
590
+ const needsSeaweedfs = state.infra?.seaweedfs ?? false;
591
+ const needsRedis = state.infra?.redis ?? false;
592
+ if (needsSeaweedfs || needsRedis) {
593
+ if (needsSeaweedfs) await startDockerService("SeaweedFS (local S3)", "seaweedfs", "SeaweedFS running on localhost:8333");
594
+ if (needsRedis) await startDockerService("Redis", "redis", "Redis running on localhost:6379");
595
+ } else {
596
+ log.info("No Docker services needed!");
597
+ log.info("Your setup uses external services or placeholders.");
598
+ }
599
+ const checks = spinner();
600
+ checks.start("Running final checks...");
601
+ const envVars = readEnvFile(".env");
602
+ const results = (await Promise.all([checkDatabase(envVars), checkRedis(envVars)])).filter((r) => r !== void 0);
603
+ checks.stop("Health checks complete");
604
+ for (const r of results) if (r.includes("failed") || r.includes("not reachable")) log.warn(r);
605
+ else log.success(r);
606
+ markPhaseCompleted(state, "infra");
607
+ await saveState(state);
608
+ return state;
609
+ }
610
+
611
+ //#endregion
612
+ //#region src/phases/scaffold.ts
613
+ const TEMPLATE_URI = "gh:CarlosZiegler/start-kit.dev/apps/start-template#main";
614
+ async function fetchTemplate(targetDir) {
615
+ const s = spinner();
616
+ s.start("Downloading template...");
617
+ try {
618
+ await downloadTemplate(TEMPLATE_URI, {
619
+ dir: targetDir,
620
+ force: false
621
+ });
622
+ s.stop("Template downloaded");
623
+ } catch (error) {
624
+ s.stop("Download failed");
625
+ log.error(String(error));
626
+ process.exit(1);
627
+ }
628
+ await exec(`git -C "${targetDir}" init`);
629
+ }
630
+ async function installDeps(targetDir) {
631
+ const s = spinner();
632
+ s.start("Installing dependencies...");
633
+ const result = await exec(`cd "${targetDir}" && bun install`);
634
+ if (result.exitCode !== 0) {
635
+ s.stop("Install failed");
636
+ log.error(result.stderr);
637
+ log.info(`Try manually: cd ${targetDir} && bun install`);
638
+ process.exit(1);
639
+ }
640
+ s.stop("Dependencies installed");
641
+ }
642
+ async function runScaffold(projectNameArg) {
643
+ let projectName = projectNameArg;
644
+ if (!projectName) {
645
+ const name = await text({
646
+ message: "What's your project name?",
647
+ placeholder: "my-saas-app",
648
+ validate: (v = "") => {
649
+ if (!isValidAppName(v)) return "Name must be 2-50 characters";
650
+ }
651
+ });
652
+ if (isCancel(name)) process.exit(0);
653
+ projectName = name;
654
+ }
655
+ const dirName = toKebabCase(projectName);
656
+ const targetDir = `${process.cwd()}/${dirName}`;
657
+ if (existsSync(targetDir)) {
658
+ log.error(`Directory "${dirName}" already exists.`);
659
+ process.exit(1);
660
+ }
661
+ log.info(`Creating project in ./${dirName}`);
662
+ await fetchTemplate(targetDir);
663
+ await installDeps(targetDir);
664
+ await exec(`rm -f "${targetDir}/.setup-state.json"`);
665
+ log.success(`Project created in ./${dirName}`);
666
+ return targetDir;
667
+ }
668
+
669
+ //#endregion
670
+ //#region src/index.ts
671
+ const PHASES = [
672
+ {
673
+ key: "branding",
674
+ label: "Branding",
675
+ run: runBranding
676
+ },
677
+ {
678
+ key: "features",
679
+ label: "Features",
680
+ run: runFeatures
681
+ },
682
+ {
683
+ key: "database",
684
+ label: "Database",
685
+ run: runDatabase
686
+ },
687
+ {
688
+ key: "env",
689
+ label: "Environment",
690
+ run: runEnv
691
+ },
692
+ {
693
+ key: "infra",
694
+ label: "Infrastructure",
695
+ run: runInfra
696
+ }
697
+ ];
698
+ function showPhaseStatus(state) {
699
+ for (const phase of PHASES) {
700
+ const done = isPhaseCompleted(state, phase.key);
701
+ const icon = done ? "✓" : "○";
702
+ log.info(`${icon} ${phase.label}${done ? " (completed)" : ""}`);
703
+ }
704
+ }
705
+ async function getResumeStartIndex(state) {
706
+ const firstIncomplete = PHASES.findIndex((p) => !isPhaseCompleted(state, p.key));
707
+ const options = [];
708
+ if (firstIncomplete >= 0) options.push({
709
+ value: "continue",
710
+ label: `Continue from ${PHASES[firstIncomplete].label}`
711
+ });
712
+ options.push({
713
+ value: "start-over",
714
+ label: "Start over"
715
+ }, {
716
+ value: "jump",
717
+ label: "Jump to a specific phase"
718
+ });
719
+ const choice = await select({
720
+ message: "What would you like to do?",
721
+ options
722
+ });
723
+ if (isCancel(choice)) process.exit(0);
724
+ if (choice === "continue") return firstIncomplete;
725
+ if (choice === "start-over") return -1;
726
+ return await selectPhase(state);
727
+ }
728
+ async function selectPhase(state) {
729
+ const jumpTo = await select({
730
+ message: "Which phase?",
731
+ options: PHASES.map((p, i) => ({
732
+ value: i,
733
+ label: p.label,
734
+ hint: isPhaseCompleted(state, p.key) ? "completed" : void 0
735
+ }))
736
+ });
737
+ if (isCancel(jumpTo)) process.exit(0);
738
+ return jumpTo;
739
+ }
740
+ async function runSinglePhase(targetStep, state) {
741
+ const phase = PHASES.find((p) => p.key === targetStep);
742
+ if (!phase) {
743
+ log.error(`Unknown phase: ${targetStep}`);
744
+ log.info(`Available: ${PHASES.map((p) => p.key).join(", ")}`);
745
+ process.exit(1);
746
+ }
747
+ await phase.run(state);
748
+ outro(`Phase "${phase.label}" complete!`);
749
+ }
750
+ async function runWizard(state) {
751
+ showPhaseStatus(state);
752
+ let startFrom = 0;
753
+ let currentState = state;
754
+ if (state.completedPhases.length > 0) {
755
+ const resumeIndex = await getResumeStartIndex(state);
756
+ if (resumeIndex === -1) currentState = {
757
+ version: 1,
758
+ completedPhases: []
759
+ };
760
+ else startFrom = resumeIndex;
761
+ }
762
+ for (let i = startFrom; i < PHASES.length; i++) {
763
+ const phase = PHASES[i];
764
+ log.step(`Phase ${i + 1}/${PHASES.length}: ${phase.label}`);
765
+ currentState = await phase.run(currentState);
766
+ }
767
+ outro("Setup complete! Run `bun dev` to start your app.");
768
+ }
769
+ function showUsage() {
770
+ console.log("Usage:");
771
+ console.log(" bunx create-start-kit-dev create [project-name] Create a new project");
772
+ console.log(" bunx create-start-kit-dev init [--step <phase>] Setup existing project");
773
+ console.log("");
774
+ console.log("Phases: branding, features, database, env, infra");
775
+ }
776
+ async function handleCreate(args) {
777
+ const projectName = args.at(1);
778
+ intro("Start Kit — Create New Project");
779
+ const targetDir = await runScaffold(projectName);
780
+ process.chdir(targetDir);
781
+ log.step("Now let's configure your project...");
782
+ await runWizard(loadState());
783
+ }
784
+ async function handleInit(args) {
785
+ const stepIndex = args.indexOf("--step");
786
+ const targetStep = stepIndex >= 0 ? args.at(stepIndex + 1) : void 0;
787
+ intro("Start Kit Setup");
788
+ const state = loadState();
789
+ if (targetStep) await runSinglePhase(targetStep, state);
790
+ else await runWizard(state);
791
+ }
792
+ async function main() {
793
+ const args = process.argv.slice(2);
794
+ const command = args.at(0);
795
+ if (command === "create") {
796
+ await handleCreate(args);
797
+ return;
798
+ }
799
+ if (command === "init") {
800
+ await handleInit(args);
801
+ return;
802
+ }
803
+ showUsage();
804
+ }
805
+ main().catch((error) => {
806
+ console.error("Setup failed:", error);
807
+ process.exit(1);
808
+ });
809
+
810
+ //#endregion
811
+ export { };
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "create-start-kit-dev",
3
+ "version": "0.1.0",
4
+ "description": "CLI for scaffolding and configuring Start Kit projects",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": {
8
+ "name": "Carlos Ziegler",
9
+ "url": "https://github.com/CarlosZiegler"
10
+ },
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "https://github.com/CarlosZiegler/start-kit.dev",
14
+ "directory": "packages/cli"
15
+ },
16
+ "homepage": "https://github.com/CarlosZiegler/start-kit.dev#readme",
17
+ "bugs": {
18
+ "url": "https://github.com/CarlosZiegler/start-kit.dev/issues"
19
+ },
20
+ "keywords": [
21
+ "cli",
22
+ "starter",
23
+ "template",
24
+ "tanstack",
25
+ "tanstack-start",
26
+ "bun",
27
+ "typescript",
28
+ "scaffold",
29
+ "create"
30
+ ],
31
+ "bin": {
32
+ "create-start-kit-dev": "./dist/index.mjs"
33
+ },
34
+ "files": [
35
+ "dist"
36
+ ],
37
+ "scripts": {
38
+ "build": "tsdown",
39
+ "dev": "tsdown --watch"
40
+ },
41
+ "dependencies": {
42
+ "@clack/prompts": "^1.0.1",
43
+ "giget": "^3.1.2",
44
+ "picocolors": "^1.1.1"
45
+ },
46
+ "devDependencies": {
47
+ "@types/bun": "^1.3.9",
48
+ "tsdown": "^0.20.3",
49
+ "typescript": "^5.9.3"
50
+ },
51
+ "engines": {
52
+ "bun": ">=1.0.0"
53
+ }
54
+ }