@supatype/cli 0.1.0-alpha.13 → 0.1.0-alpha.14

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 (54) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-test.log +208 -91
  3. package/.turbo/turbo-typecheck.log +1 -1
  4. package/dist/app-config.d.ts +10 -0
  5. package/dist/app-config.d.ts.map +1 -1
  6. package/dist/app-config.js +72 -0
  7. package/dist/app-config.js.map +1 -1
  8. package/dist/cli.d.ts.map +1 -1
  9. package/dist/cli.js +2 -0
  10. package/dist/cli.js.map +1 -1
  11. package/dist/commands/add.d.ts +3 -0
  12. package/dist/commands/add.d.ts.map +1 -0
  13. package/dist/commands/add.js +83 -0
  14. package/dist/commands/add.js.map +1 -0
  15. package/dist/commands/app.js +2 -2
  16. package/dist/commands/app.js.map +1 -1
  17. package/dist/commands/init.d.ts +29 -1
  18. package/dist/commands/init.d.ts.map +1 -1
  19. package/dist/commands/init.js +569 -90
  20. package/dist/commands/init.js.map +1 -1
  21. package/dist/commands/keys.d.ts +15 -1
  22. package/dist/commands/keys.d.ts.map +1 -1
  23. package/dist/commands/keys.js +39 -4
  24. package/dist/commands/keys.js.map +1 -1
  25. package/dist/kong-config.d.ts +9 -0
  26. package/dist/kong-config.d.ts.map +1 -1
  27. package/dist/kong-config.js +18 -1
  28. package/dist/kong-config.js.map +1 -1
  29. package/dist/project-config.d.ts +16 -0
  30. package/dist/project-config.d.ts.map +1 -1
  31. package/dist/project-config.js +34 -0
  32. package/dist/project-config.js.map +1 -1
  33. package/dist/prompts.d.ts +8 -0
  34. package/dist/prompts.d.ts.map +1 -0
  35. package/dist/prompts.js +20 -0
  36. package/dist/prompts.js.map +1 -0
  37. package/dist/self-host-compose.d.ts.map +1 -1
  38. package/dist/self-host-compose.js +61 -17
  39. package/dist/self-host-compose.js.map +1 -1
  40. package/package.json +2 -1
  41. package/src/app-config.ts +80 -0
  42. package/src/cli.ts +2 -0
  43. package/src/commands/add.ts +94 -0
  44. package/src/commands/app.ts +2 -2
  45. package/src/commands/init.ts +738 -88
  46. package/src/commands/keys.ts +49 -4
  47. package/src/kong-config.ts +24 -1
  48. package/src/project-config.ts +45 -0
  49. package/src/prompts.ts +21 -0
  50. package/src/self-host-compose.ts +61 -17
  51. package/tests/config.test.ts +26 -0
  52. package/tests/init.test.ts +128 -15
  53. package/tests/runtime-contract.test.ts +111 -1
  54. package/tsconfig.tsbuildinfo +1 -1
@@ -1,7 +1,11 @@
1
1
  import type { Command } from "commander"
2
2
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
3
- import { resolve, join, dirname } from "node:path"
3
+ import { resolve, join, dirname, basename } from "node:path"
4
4
  import { fileURLToPath } from "node:url"
5
+ import { spawnSync } from "node:child_process"
6
+ import * as p from "@clack/prompts"
7
+ import { ensureNotCancelled, printLogo } from "../prompts.js"
8
+ import { generateAndWriteKeys } from "./keys.js"
5
9
 
6
10
  export { scaffold }
7
11
 
@@ -25,17 +29,115 @@ function cliPackageVersion(): string {
25
29
  }
26
30
  }
27
31
 
32
+ // ─── Options model ─────────────────────────────────────────────────────────--
33
+
34
+ type PackageManager = "npm" | "pnpm" | "yarn" | "bun"
35
+
36
+ /** Where the project runs in production (drives committed config + local override). */
37
+ type ProductionTarget = "cloud" | "self-host" | "later"
38
+
39
+ export interface ScaffoldAppOptions {
40
+ mode: "none" | "static" | "proxy"
41
+ staticDir?: string
42
+ upstream?: string
43
+ start?: string
44
+ viteDevUrl?: string
45
+ }
46
+
47
+ /** File-affecting answers that drive what `scaffold()` writes. */
48
+ export interface ScaffoldOptions {
49
+ projectName: string
50
+ /** Local development runtime (docker recommended). */
51
+ provider: "docker" | "native"
52
+ productionTarget: ProductionTarget
53
+ domain?: string
54
+ /** ACME contact email for Let's Encrypt HTTPS (self-host + domain). */
55
+ tlsEmail?: string
56
+ schemaPath: string
57
+ app: ScaffoldAppOptions
58
+ email: "console" | "smtp" | "resend" | "ses"
59
+ /** Object storage while developing locally (`supatype dev`). */
60
+ storageLocal: "local" | "s3"
61
+ /** Object storage when deployed to production. */
62
+ storageProduction: "local" | "s3"
63
+ helloFunction: boolean
64
+ }
65
+
66
+ type StorageProvider = ScaffoldOptions["storageLocal"]
67
+
68
+ const STORAGE_PROVIDER_OPTIONS: {
69
+ value: StorageProvider
70
+ label: string
71
+ hint: string
72
+ }[] = [
73
+ { value: "local", label: "Local", hint: "storage you host yourself (MinIO)" },
74
+ { value: "s3", label: "S3", hint: "external bucket (AWS S3 or compatible)" },
75
+ ]
76
+
77
+ /** Wizard result = scaffold options plus runtime actions (install / keys). */
78
+ interface WizardResult extends ScaffoldOptions {
79
+ packageManager: PackageManager
80
+ install: boolean
81
+ generateKeys: boolean
82
+ }
83
+
84
+ /** `--mode dev|standalone` is mapped onto a production target for back-compat. */
85
+ function productionTargetFromMode(mode: string): ProductionTarget {
86
+ return mode === "standalone" ? "self-host" : "later"
87
+ }
88
+
89
+ /** supatype-server mode written to the committed config for a production target. */
90
+ function serverModeForTarget(target: ProductionTarget): "dev" | "standalone" | "managed" {
91
+ switch (target) {
92
+ case "cloud":
93
+ return "managed"
94
+ case "self-host":
95
+ return "standalone"
96
+ case "later":
97
+ return "dev"
98
+ }
99
+ }
100
+
101
+ export function defaultScaffoldOptions(
102
+ projectName: string,
103
+ productionTarget: ProductionTarget = "later",
104
+ ): ScaffoldOptions {
105
+ return {
106
+ projectName,
107
+ provider: "docker",
108
+ productionTarget,
109
+ ...(productionTarget === "self-host" ? { domain: "" } : {}),
110
+ schemaPath: "schema/index.ts",
111
+ app: { mode: "none" },
112
+ email: "console",
113
+ storageLocal: "local",
114
+ storageProduction: "local",
115
+ helloFunction: false,
116
+ }
117
+ }
118
+
119
+ // ─── Registration ──────────────────────────────────────────────────────────--
120
+
121
+ interface InitCliOptions {
122
+ mode: string
123
+ defaults?: boolean
124
+ install: boolean
125
+ keys: boolean
126
+ }
127
+
28
128
  export function registerInit(program: Command): void {
29
129
  program
30
130
  .command("init [name]")
31
131
  .description("Scaffold a new Supatype project")
32
132
  .option(
33
133
  "--mode <mode>",
34
- "Server mode in supatype.config.ts: dev (default) | standalone (native ACME — not Compose self-host)",
134
+ "Back-compat: dev (default, local only) | standalone (self-host production target)",
35
135
  "dev",
36
136
  )
37
- .action(async (name?: string, opts: { mode: string } = { mode: "dev" }) => {
38
- const projectName = name ?? "my-project"
137
+ .option("-y, --defaults", "Skip all prompts and use sensible defaults")
138
+ .option("--no-install", "Do not run the package manager install step")
139
+ .option("--no-keys", "Do not generate ANON_KEY / SERVICE_ROLE_KEY")
140
+ .action(async (name: string | undefined, opts: InitCliOptions) => {
39
141
  const dir = name ? resolve(process.cwd(), name) : process.cwd()
40
142
 
41
143
  if (name && existsSync(dir)) {
@@ -43,31 +145,304 @@ export function registerInit(program: Command): void {
43
145
  process.exit(1)
44
146
  }
45
147
 
148
+ const defaultName = name ?? basename(dir) ?? "my-project"
149
+ const interactive = !opts.defaults && Boolean(process.stdin.isTTY)
150
+
151
+ const modeTarget = productionTargetFromMode(opts.mode)
152
+
153
+ let result: WizardResult
154
+ if (interactive) {
155
+ printLogo()
156
+ result = await runWizard(defaultName, modeTarget)
157
+ } else {
158
+ result = {
159
+ ...defaultScaffoldOptions(defaultName, modeTarget),
160
+ packageManager: detectInvokingPackageManager(),
161
+ install: true,
162
+ generateKeys: true,
163
+ }
164
+ }
165
+
166
+ // CLI flags override wizard / default action choices.
167
+ const doInstall = opts.install !== false && result.install
168
+ const doKeys = opts.keys !== false && result.generateKeys
169
+
46
170
  if (name) mkdirSync(dir, { recursive: true })
47
171
 
48
- scaffold(dir, projectName, opts.mode as "dev" | "standalone")
49
-
50
- console.log(`\nSupatype project ready${name ? ` in ${name}/` : ""}.\n`)
51
- console.log("Next steps:")
52
- if (name) console.log(` cd ${name}`)
53
- console.log(" npm install")
54
- console.log(" supatype keys")
55
- console.log(" supatype dev # Docker Compose stack (Kong :18473)")
56
- console.log(" supatype push # apply schema + generate types")
57
- console.log("\nStatic frontend (self-host):")
58
- console.log(" supatype app add --static ./public")
59
- console.log(" npm run build # write files into public/")
60
- console.log(" supatype self-host compose up -d")
61
- if (opts.mode === "standalone") {
62
- console.log("\nStandalone (native TLS with ACME):")
63
- console.log(" Edit supatype.config.ts — set server.domain")
64
- console.log(" supatype dev # or run supatype-server with your TLS setup")
65
- }
66
- console.log()
172
+ scaffold(dir, result)
173
+
174
+ if (doInstall) runInstall(dir, result.packageManager)
175
+ const keysGenerated = doKeys ? writeKeys(dir) : false
176
+
177
+ printNextSteps({
178
+ name,
179
+ result,
180
+ installed: doInstall,
181
+ keysGenerated,
182
+ })
67
183
  })
68
184
  }
69
185
 
70
- function scaffold(dir: string, projectName: string, mode: "dev" | "standalone" = "dev"): void {
186
+ // ─── Wizard ──────────────────────────────────────────────────────────────────
187
+
188
+ async function runWizard(
189
+ defaultName: string,
190
+ defaultTarget: ProductionTarget,
191
+ ): Promise<WizardResult> {
192
+ p.intro("Create a new Supatype project")
193
+
194
+ const projectName = ensureNotCancelled(
195
+ await p.text({
196
+ message: "Project name",
197
+ defaultValue: defaultName,
198
+ placeholder: defaultName,
199
+ }),
200
+ ).trim() || defaultName
201
+
202
+ const packageManager = ensureNotCancelled(
203
+ await p.select<PackageManager>({
204
+ message: "Package manager",
205
+ initialValue: detectInvokingPackageManager(),
206
+ options: [
207
+ { value: "npm", label: "npm" },
208
+ { value: "pnpm", label: "pnpm" },
209
+ { value: "yarn", label: "yarn" },
210
+ { value: "bun", label: "bun" },
211
+ ],
212
+ }),
213
+ )
214
+
215
+ const productionTarget = ensureNotCancelled(
216
+ await p.select<ProductionTarget>({
217
+ message: "Where will this run in production?",
218
+ initialValue: defaultTarget,
219
+ options: [
220
+ { value: "cloud", label: "Supatype Cloud", hint: "managed; deploy via supatype link" },
221
+ { value: "self-host", label: "Self-host", hint: "your own server with TLS" },
222
+ { value: "later", label: "Decide later", hint: "local development only for now" },
223
+ ],
224
+ }),
225
+ )
226
+
227
+ let domain: string | undefined
228
+ let tlsEmail: string | undefined
229
+ if (productionTarget === "self-host") {
230
+ domain = ensureNotCancelled(
231
+ await p.text({
232
+ message: "Production domain for ACME TLS (optional, can set later)",
233
+ placeholder: "api.example.com",
234
+ defaultValue: "",
235
+ }),
236
+ ).trim()
237
+ if (domain) {
238
+ tlsEmail =
239
+ ensureNotCancelled(
240
+ await p.text({
241
+ message: "Email for Let's Encrypt (HTTPS) certificates",
242
+ placeholder: "you@example.com",
243
+ defaultValue: "",
244
+ }),
245
+ ).trim() || undefined
246
+ }
247
+ }
248
+
249
+ const provider = ensureNotCancelled(
250
+ await p.select<ScaffoldOptions["provider"]>({
251
+ message: "How should Postgres and the server run for local development?",
252
+ initialValue: "docker",
253
+ options: [
254
+ { value: "docker", label: "Docker", hint: "Docker Compose stack (recommended)" },
255
+ { value: "native", label: "Native", hint: "host Postgres + server binaries, no Docker" },
256
+ ],
257
+ }),
258
+ )
259
+
260
+ const schemaPath = ensureNotCancelled(
261
+ await p.text({
262
+ message: "Where should your schema live?",
263
+ defaultValue: "schema/index.ts",
264
+ placeholder: "schema/index.ts",
265
+ }),
266
+ ).trim() || "schema/index.ts"
267
+
268
+ const app = await promptApp()
269
+
270
+ const email = ensureNotCancelled(
271
+ await p.select<ScaffoldOptions["email"]>({
272
+ message: "Email provider",
273
+ initialValue: "console",
274
+ options: [
275
+ { value: "console", label: "console", hint: "log emails to the terminal (dev)" },
276
+ { value: "smtp", label: "SMTP" },
277
+ { value: "resend", label: "Resend" },
278
+ { value: "ses", label: "Amazon SES" },
279
+ ],
280
+ }),
281
+ )
282
+
283
+ const storageLocal = ensureNotCancelled(
284
+ await p.select<StorageProvider>({
285
+ message: "Local storage (for development)?",
286
+ initialValue: "local",
287
+ options: STORAGE_PROVIDER_OPTIONS,
288
+ }),
289
+ )
290
+
291
+ const storageProduction = ensureNotCancelled(
292
+ await p.select<StorageProvider>({
293
+ message: "Production storage?",
294
+ initialValue: "local",
295
+ options: STORAGE_PROVIDER_OPTIONS,
296
+ }),
297
+ )
298
+
299
+ const helloFunction = ensureNotCancelled(
300
+ await p.confirm({
301
+ message: "Create a hello-world edge function?",
302
+ initialValue: false,
303
+ }),
304
+ )
305
+
306
+ const install = ensureNotCancelled(
307
+ await p.confirm({
308
+ message: `Install dependencies with ${packageManager} now?`,
309
+ initialValue: true,
310
+ }),
311
+ )
312
+
313
+ const generateKeys = ensureNotCancelled(
314
+ await p.confirm({
315
+ message: "Generate ANON_KEY and SERVICE_ROLE_KEY now?",
316
+ initialValue: true,
317
+ }),
318
+ )
319
+
320
+ p.outro("Setting up your project...")
321
+
322
+ return {
323
+ projectName,
324
+ provider,
325
+ productionTarget,
326
+ ...(domain !== undefined ? { domain } : {}),
327
+ ...(tlsEmail !== undefined ? { tlsEmail } : {}),
328
+ schemaPath,
329
+ app,
330
+ email,
331
+ storageLocal,
332
+ storageProduction,
333
+ helloFunction,
334
+ packageManager,
335
+ install,
336
+ generateKeys,
337
+ }
338
+ }
339
+
340
+ async function promptApp(): Promise<ScaffoldAppOptions> {
341
+ const mode = ensureNotCancelled(
342
+ await p.select<ScaffoldAppOptions["mode"]>({
343
+ message: "Host a frontend app at /?",
344
+ initialValue: "none",
345
+ options: [
346
+ { value: "none", label: "No app", hint: "API only" },
347
+ { value: "static", label: "Static site", hint: "serve a built directory" },
348
+ { value: "proxy", label: "Local dev server", hint: "forward requests to a dev server you run" },
349
+ ],
350
+ }),
351
+ )
352
+
353
+ if (mode === "static") {
354
+ const staticDir = ensureNotCancelled(
355
+ await p.text({
356
+ message: "Directory to serve",
357
+ defaultValue: "./public",
358
+ placeholder: "./public",
359
+ }),
360
+ ).trim() || "./public"
361
+ const viteDevUrl = await promptViteDevUrl()
362
+ return { mode, staticDir, ...(viteDevUrl ? { viteDevUrl } : {}) }
363
+ }
364
+
365
+ if (mode === "proxy") {
366
+ const upstream = ensureNotCancelled(
367
+ await p.text({
368
+ message: "URL of your running dev server",
369
+ defaultValue: "http://localhost:3000",
370
+ placeholder: "http://localhost:3000",
371
+ }),
372
+ ).trim() || "http://localhost:3000"
373
+ const start = ensureNotCancelled(
374
+ await p.text({
375
+ message: "package.json script that starts your dev server",
376
+ defaultValue: "dev",
377
+ placeholder: "dev",
378
+ }),
379
+ ).trim() || "dev"
380
+ const viteDevUrl = await promptViteDevUrl()
381
+ return { mode, upstream, start, ...(viteDevUrl ? { viteDevUrl } : {}) }
382
+ }
383
+
384
+ return { mode: "none" }
385
+ }
386
+
387
+ async function promptViteDevUrl(): Promise<string | undefined> {
388
+ const useVite = ensureNotCancelled(
389
+ await p.confirm({
390
+ message: "Enable live reload from a separate Vite dev server?",
391
+ initialValue: false,
392
+ }),
393
+ )
394
+ if (!useVite) return undefined
395
+ return (
396
+ ensureNotCancelled(
397
+ await p.text({
398
+ message: "Vite dev server URL",
399
+ defaultValue: "http://127.0.0.1:5173",
400
+ placeholder: "http://127.0.0.1:5173",
401
+ }),
402
+ ).trim() || "http://127.0.0.1:5173"
403
+ )
404
+ }
405
+
406
+ // ─── Package manager ───────────────────────────────────────────────────────--
407
+
408
+ function detectInvokingPackageManager(): PackageManager {
409
+ const ua = process.env["npm_config_user_agent"] ?? ""
410
+ if (ua.startsWith("pnpm")) return "pnpm"
411
+ if (ua.startsWith("yarn")) return "yarn"
412
+ if (ua.startsWith("bun")) return "bun"
413
+ return "npm"
414
+ }
415
+
416
+ function runInstall(dir: string, pm: PackageManager): void {
417
+ console.log(`\nInstalling dependencies with ${pm}...`)
418
+ const res = spawnSync(pm, ["install"], {
419
+ cwd: dir,
420
+ stdio: "inherit",
421
+ shell: process.platform === "win32",
422
+ })
423
+ if (res.status !== 0 || res.error) {
424
+ console.warn(
425
+ `\n[supatype] Dependency install did not complete (run "${pm} install" manually).`,
426
+ )
427
+ }
428
+ }
429
+
430
+ function writeKeys(dir: string): boolean {
431
+ const keys = generateAndWriteKeys(dir)
432
+ if (!keys) {
433
+ console.warn(
434
+ "\n[supatype] Could not generate keys (JWT_SECRET missing). Run `supatype keys` manually.",
435
+ )
436
+ return false
437
+ }
438
+ return true
439
+ }
440
+
441
+ // ─── Scaffold ──────────────────────────────────────────────────────────────--
442
+
443
+ function scaffold(dir: string, optsOrName: ScaffoldOptions | string): void {
444
+ const opts =
445
+ typeof optsOrName === "string" ? defaultScaffoldOptions(optsOrName) : optsOrName
71
446
  const write = (rel: string, content: string) => {
72
447
  const full = join(dir, rel)
73
448
  mkdirSync(resolve(full, ".."), { recursive: true })
@@ -77,17 +452,28 @@ function scaffold(dir: string, projectName: string, mode: "dev" | "standalone" =
77
452
 
78
453
  const pkgPath = join(dir, "package.json")
79
454
  if (!existsSync(pkgPath)) {
80
- write("package.json", packageJsonTemplate(projectName, cliPackageVersion()))
455
+ write("package.json", packageJsonTemplate(opts, cliPackageVersion()))
81
456
  } else {
82
457
  console.log(" skipped package.json (already exists)")
83
458
  }
84
459
 
85
- write("supatype.config.ts", tsConfigTemplate(projectName, mode))
86
- write("schema/index.ts", schemaTemplate())
87
- write(".env", envTemplate(projectName))
88
- write("seed.ts", seedTemplate(projectName))
460
+ write("supatype.config.ts", tsConfigTemplate(opts))
461
+ if (opts.productionTarget !== "later") {
462
+ write("supatype.local.config.ts", localConfigTemplate())
463
+ }
464
+ write(opts.schemaPath, schemaTemplate())
465
+ write(".env", envTemplate(opts))
466
+ write("seed.ts", seedTemplate(opts.projectName))
89
467
  write("seeds/.gitkeep", "")
90
- write("public/.gitkeep", "")
468
+ if (opts.app.mode === "static") {
469
+ const staticRel = staticDirRelative(opts.app.staticDir)
470
+ write(`${staticRel}/.gitkeep`, "")
471
+ } else {
472
+ write("public/.gitkeep", "")
473
+ }
474
+
475
+ if (opts.helloFunction) scaffoldHelloFunction(dir, write)
476
+
91
477
  const gitignorePath = join(dir, ".gitignore")
92
478
  if (existsSync(gitignorePath)) {
93
479
  const merged = mergeGitignoreTemplate(readFileSync(gitignorePath, "utf8"))
@@ -102,17 +488,41 @@ function scaffold(dir: string, projectName: string, mode: "dev" | "standalone" =
102
488
  }
103
489
  }
104
490
 
491
+ function staticDirRelative(staticDir?: string): string {
492
+ const raw = (staticDir ?? "./public").trim()
493
+ return raw.replace(/^\.\//, "").replace(/\/+$/, "") || "public"
494
+ }
495
+
496
+ function scaffoldHelloFunction(
497
+ dir: string,
498
+ write: (rel: string, content: string) => void,
499
+ ): void {
500
+ write("functions/hello/index.ts", helloFunctionTemplate())
501
+ if (!existsSync(join(dir, "functions/_shared/README.md"))) {
502
+ write("functions/_shared/README.md", sharedFunctionsReadme())
503
+ }
504
+ if (!existsSync(join(dir, "functions/.env.local"))) {
505
+ write("functions/.env.local", functionsEnvLocalTemplate())
506
+ }
507
+ }
508
+
105
509
  // ─── Templates ───────────────────────────────────────────────────────────────
106
510
 
107
- function packageJsonTemplate(projectName: string, cliVersion: string): string {
511
+ function packageJsonTemplate(opts: ScaffoldOptions, cliVersion: string): string {
512
+ const scripts: string[] = [
513
+ ` "dev": "supatype dev"`,
514
+ ` "push": "supatype push"`,
515
+ ` "seed": "tsx seed.ts"`,
516
+ ]
517
+ if (opts.helloFunction) {
518
+ scripts.push(` "functions": "supatype functions serve"`)
519
+ }
108
520
  return `{
109
- "name": "${projectName}",
521
+ "name": "${opts.projectName}",
110
522
  "private": true,
111
523
  "type": "module",
112
524
  "scripts": {
113
- "dev": "supatype dev",
114
- "push": "supatype push",
115
- "seed": "tsx seed.ts"
525
+ ${scripts.join(",\n")}
116
526
  },
117
527
  "dependencies": {
118
528
  "@supatype/cli": "^${cliVersion}",
@@ -126,55 +536,131 @@ function packageJsonTemplate(projectName: string, cliVersion: string): string {
126
536
  `
127
537
  }
128
538
 
129
- function tsConfigTemplate(projectName: string, mode: "dev" | "standalone"): string {
130
- const domainField =
131
- mode === "standalone"
132
- ? ` domain: "", // e.g. "api.example.com" for ACME TLS\n`
133
- : ""
134
- return `import { defineConfig } from "@supatype/cli"
539
+ function tsConfigTemplate(opts: ScaffoldOptions): string {
540
+ const serverMode = serverModeForTarget(opts.productionTarget)
541
+ const hasLocalOverride = opts.productionTarget !== "later"
542
+ const lines: string[] = []
543
+ lines.push(`import { defineConfig } from "@supatype/cli"`)
544
+ lines.push("")
545
+ if (hasLocalOverride) {
546
+ lines.push(`// Committed config = ${opts.productionTarget} production target.`)
547
+ lines.push(`// Local development overrides live in supatype.local.config.ts (gitignored).`)
548
+ }
549
+ lines.push(`export default defineConfig({`)
550
+ lines.push(` project: { name: "${opts.projectName}" },`)
551
+ lines.push(` provider: "${opts.provider}",`)
552
+ if (opts.provider === "docker") {
553
+ lines.push(` // provider: "native" // host Postgres + supatype-server binaries (no Docker)`)
554
+ }
555
+ lines.push(` database: {`)
556
+ lines.push(` provider: "${opts.provider}",`)
557
+ lines.push(` },`)
558
+ lines.push(` server: {`)
559
+ lines.push(` mode: "${serverMode}",`)
560
+ lines.push(` port: 54321,`)
561
+ if (serverMode === "standalone") {
562
+ lines.push(` domain: "${opts.domain ?? ""}", // e.g. "api.example.com" for ACME TLS`)
563
+ if (opts.tlsEmail) {
564
+ lines.push(` tls: { email: "${opts.tlsEmail}" }, // automatic HTTPS via Let's Encrypt`)
565
+ } else {
566
+ lines.push(` // tls: { email: "you@example.com" }, // set to enable automatic HTTPS (Let's Encrypt)`)
567
+ }
568
+ }
569
+ lines.push(` },`)
570
+ lines.push(...appConfigLines(opts.app))
571
+ if (opts.productionTarget !== "later") {
572
+ lines.push(` environments: { default: "production" }, // supatype link --env production ...`)
573
+ }
574
+ lines.push(
575
+ ` // Optional: pin component versions (native cache + Docker images synced to .env on dev/push)`,
576
+ )
577
+ lines.push(` // versions: { engine: "0.1.2", server: "1.0.5", postgres: "17.2", deno: "2.2.0" },`)
578
+ lines.push(` email: { provider: "${opts.email}" },`)
579
+ lines.push(...storageConfigLines(opts.storageLocal, opts.storageProduction))
580
+ lines.push(` schema: { path: "${opts.schemaPath}", pg_schema: "public" },`)
581
+ lines.push(
582
+ ` // Self-host production: supatype self-host compose (Docker only). Standalone + domain = native ACME dev.`,
583
+ )
584
+ lines.push(`})`)
585
+ return lines.join("\n") + "\n"
586
+ }
135
587
 
136
- export default defineConfig({
137
- project: { name: "${projectName}" },
138
- provider: "docker",
139
- // provider: "native" // host Postgres + supatype-server binaries (no Docker)
140
- database: {
141
- provider: "docker",
142
- },
143
- server: {
144
- mode: "${mode}",
145
- port: 54321,
146
- ${domainField} },
147
- app: {
148
- mode: "none",
149
- // mode: "static", static_dir: "./public", // supatype app add --static ./public
150
- // mode: "proxy", upstream: "http://localhost:3000", start: "dev",
151
- // vite_dev_url: "http://127.0.0.1:5173", // dev HMR at /_vite (when using a separate Vite server)
152
- },
153
- // Optional: pin component versions (native cache + Docker images synced to .env on dev/push)
154
- // versions: { engine: "0.1.2", server: "1.0.5", postgres: "17.2", deno: "2.2.0" },
155
- email: { provider: "console" },
156
- storage: { provider: "local", local_path: ".supatype/storage" },
157
- schema: { path: "schema/index.ts", pg_schema: "public" },
158
- // Self-host production: supatype self-host compose (Docker only). Standalone + domain = native ACME dev.
159
- })
588
+ function localConfigTemplate(): string {
589
+ return `import type { SupatypeConfig } from "@supatype/cli"
590
+
591
+ // Local development overrides gitignored, deep-merged over supatype.config.ts.
592
+ // Keeps \`supatype dev\` in local mode while the committed config targets production.
593
+ const localConfig: Partial<SupatypeConfig> = {
594
+ server: { mode: "dev" },
595
+ }
596
+
597
+ export default localConfig
160
598
  `
161
599
  }
162
600
 
601
+ function appConfigLines(app: ScaffoldAppOptions): string[] {
602
+ if (app.mode === "static") {
603
+ const out = [
604
+ ` app: {`,
605
+ ` mode: "static",`,
606
+ ` static_dir: "${app.staticDir ?? "./public"}",`,
607
+ ]
608
+ if (app.viteDevUrl) out.push(` vite_dev_url: "${app.viteDevUrl}",`)
609
+ out.push(` },`)
610
+ return out
611
+ }
612
+ if (app.mode === "proxy") {
613
+ const out = [
614
+ ` app: {`,
615
+ ` mode: "proxy",`,
616
+ ` upstream: "${app.upstream ?? "http://localhost:3000"}",`,
617
+ ` start: "${app.start ?? "dev"}",`,
618
+ ]
619
+ if (app.viteDevUrl) out.push(` vite_dev_url: "${app.viteDevUrl}",`)
620
+ out.push(` },`)
621
+ return out
622
+ }
623
+ return [
624
+ ` app: {`,
625
+ ` mode: "none",`,
626
+ ` // mode: "static", static_dir: "./public", // supatype app add --static ./public`,
627
+ ` // mode: "proxy", upstream: "http://localhost:3000", start: "dev",`,
628
+ ` // vite_dev_url: "http://127.0.0.1:5173", // live reload from a separate Vite dev server`,
629
+ ` },`,
630
+ ]
631
+ }
632
+
633
+ function storageConfigLines(
634
+ storageLocal: StorageProvider,
635
+ storageProduction: StorageProvider,
636
+ ): string[] {
637
+ const lines: string[] = []
638
+ if (storageLocal === "s3") {
639
+ lines.push(` storage: { provider: "s3" }, // dev — configure S3_* in .env`)
640
+ } else {
641
+ lines.push(` storage: { provider: "local", local_path: ".supatype/storage" },`)
642
+ }
643
+ if (storageProduction === "s3" && storageLocal !== "s3") {
644
+ lines.push(` // Production storage: external S3 bucket — set production S3_* in .env`)
645
+ } else if (storageProduction === "local" && storageLocal === "s3") {
646
+ lines.push(` // Production storage: MinIO on your server (included in self-host compose)`)
647
+ }
648
+ return lines
649
+ }
650
+
163
651
  function schemaTemplate(): string {
164
- return `import type { Model, Public, Owner, Role, SupatypeAuthUserId, Unique, Email, UUID } from "@supatype/types"
652
+ return `import type { Model, LoggedIn, Owner, Public, Role, SupatypeAuthUserId, UUID } from "@supatype/types"
165
653
 
166
- export type User = Model<{
654
+ /** App profile for a signed-in user. \`id\` matches the Supatype auth user id. */
655
+ export type Profile = Model<{
167
656
  id: SupatypeAuthUserId
168
- email: Unique<Email>
169
- name: string
170
- created_at: string
171
- updated_at: string
657
+ display_name: string
172
658
  }, {
173
659
  access: {
174
- read: Public
175
- create: Public
660
+ read: LoggedIn
661
+ create: Owner<"id">
176
662
  update: Owner<"id">
177
- delete: Role<"admin">
663
+ delete: Owner<"id">
178
664
  }
179
665
  }>
180
666
 
@@ -192,34 +678,112 @@ export type SiteSettings = Model<{
192
678
  `
193
679
  }
194
680
 
195
- function envTemplate(projectName: string): string {
196
- return `DATABASE_URL=postgresql://supatype_admin:postgres@localhost:5432/${projectName}
681
+ function envTemplate(opts: ScaffoldOptions): string {
682
+ const sections: string[] = []
683
+ sections.push(`DATABASE_URL=postgresql://supatype_admin:postgres@localhost:5432/${opts.projectName}
197
684
  POSTGRES_USER=supatype_admin
198
685
  POSTGRES_PASSWORD=postgres
199
- POSTGRES_DB=${projectName}
686
+ POSTGRES_DB=${opts.projectName}`)
200
687
 
201
- # JWT — run \`supatype keys\` to generate ANON_KEY and SERVICE_ROLE_KEY
688
+ sections.push(`# JWT — run \`supatype keys\` to generate ANON_KEY and SERVICE_ROLE_KEY
202
689
  JWT_SECRET=super-secret-jwt-token-change-in-production
203
690
  ANON_KEY=
204
- SERVICE_ROLE_KEY=
691
+ SERVICE_ROLE_KEY=`)
692
+
693
+ sections.push(`# Site URL (used by GoTrue for email redirects)
694
+ SITE_URL=http://localhost:3000`)
695
+
696
+ sections.push(emailEnvSection(opts.email, opts.projectName))
697
+ sections.push(storageEnvSections(opts.storageLocal, opts.storageProduction))
205
698
 
206
- # Site URL (used by GoTrue for email redirects)
207
- SITE_URL=http://localhost:3000
699
+ sections.push(
700
+ `# Self-host compose uses the same DATABASE_URL when Postgres is published on localhost:5432`,
701
+ )
208
702
 
209
- # SMTP — leave empty to use email autoconfirm in dev (no emails sent)
703
+ return sections.join("\n\n") + "\n"
704
+ }
705
+
706
+ function emailEnvSection(email: ScaffoldOptions["email"], projectName: string): string {
707
+ switch (email) {
708
+ case "resend":
709
+ return `# Email (Resend)
710
+ RESEND_API_KEY=
711
+ RESEND_FROM=onboarding@resend.dev`
712
+ case "ses":
713
+ return `# Email (Amazon SES)
714
+ SES_FROM=
715
+ AWS_REGION=us-east-1
716
+ AWS_ACCESS_KEY_ID=
717
+ AWS_SECRET_ACCESS_KEY=`
718
+ case "smtp":
719
+ return `# Email (SMTP)
720
+ SMTP_HOST=
721
+ SMTP_PORT=587
722
+ SMTP_USER=
723
+ SMTP_PASS=
724
+ SMTP_SENDER_NAME=${projectName}`
725
+ case "console":
726
+ default:
727
+ return `# SMTP — leave empty to use email autoconfirm in dev (no emails sent)
210
728
  SMTP_HOST=
211
729
  SMTP_PORT=
212
730
  SMTP_USER=
213
731
  SMTP_PASS=
214
- SMTP_SENDER_NAME=${projectName}
732
+ SMTP_SENDER_NAME=${projectName}`
733
+ }
734
+ }
215
735
 
216
- # Storage (MinIO for local dev)
736
+ function storageEnvSections(
737
+ storageLocal: StorageProvider,
738
+ storageProduction: StorageProvider,
739
+ ): string {
740
+ if (storageLocal === storageProduction) {
741
+ if (storageLocal === "s3") {
742
+ return `# Storage (local development and production — external bucket)
743
+ # Use separate buckets for dev and production in your provider.
744
+ S3_ENDPOINT=
745
+ S3_REGION=us-east-1
746
+ S3_BUCKET=
747
+ S3_ACCESS_KEY=
748
+ S3_SECRET_KEY=`
749
+ }
750
+ return `${localStorageEnvSection("local")}
751
+
752
+ # Production storage (MinIO on your server)
753
+ # Included in the self-host compose stack — no extra configuration needed.`
754
+ }
755
+
756
+ return [localStorageEnvSection(storageLocal), productionStorageEnvSection(storageProduction)].join(
757
+ "\n\n",
758
+ )
759
+ }
760
+
761
+ function localStorageEnvSection(storage: StorageProvider): string {
762
+ if (storage === "s3") {
763
+ return `# Storage (local development — external bucket)
764
+ S3_ENDPOINT=
765
+ S3_REGION=us-east-1
766
+ S3_BUCKET=
767
+ S3_ACCESS_KEY=
768
+ S3_SECRET_KEY=`
769
+ }
770
+ return `# Storage (local development — MinIO)
217
771
  S3_ENDPOINT=http://localhost:9000
218
772
  S3_ACCESS_KEY=supatype
219
- S3_SECRET_KEY=supatype-secret
773
+ S3_SECRET_KEY=supatype-secret`
774
+ }
220
775
 
221
- # Self-host compose uses the same DATABASE_URL when Postgres is published on localhost:5432
222
- `
776
+ function productionStorageEnvSection(storage: StorageProvider): string {
777
+ if (storage === "s3") {
778
+ return `# Storage (production — external bucket)
779
+ S3_ENDPOINT=
780
+ S3_REGION=us-east-1
781
+ S3_BUCKET=
782
+ S3_ACCESS_KEY=
783
+ S3_SECRET_KEY=`
784
+ }
785
+ return `# Storage (production — MinIO on your server)
786
+ # Included in the self-host compose stack — no extra configuration needed.`
223
787
  }
224
788
 
225
789
  function seedTemplate(projectName: string): string {
@@ -235,7 +799,7 @@ async function seed() {
235
799
  console.log("Seeding ${projectName}...")
236
800
 
237
801
  // TODO: insert seed data
238
- // await db\`INSERT INTO users (email, name) VALUES ('admin@example.com', 'Admin')\`
802
+ // await db\`INSERT INTO profile (id, display_name) VALUES ('...', 'Admin')\`
239
803
 
240
804
  await db.end()
241
805
  console.log("Done.")
@@ -248,6 +812,37 @@ seed().catch((e) => {
248
812
  `
249
813
  }
250
814
 
815
+ function helloFunctionTemplate(): string {
816
+ return `// hello — Supatype Edge Function
817
+ // Docs: https://supatype.com/docs/edge-functions
818
+
819
+ export default async function handler(req: Request): Promise<Response> {
820
+ const { method } = req
821
+
822
+ if (method === "POST") {
823
+ const body = await req.json()
824
+ return new Response(JSON.stringify({ message: "Hello from hello!", received: body }), {
825
+ status: 200,
826
+ headers: { "Content-Type": "application/json" },
827
+ })
828
+ }
829
+
830
+ return new Response(JSON.stringify({ message: "Hello from hello!" }), {
831
+ status: 200,
832
+ headers: { "Content-Type": "application/json" },
833
+ })
834
+ }
835
+ `
836
+ }
837
+
838
+ function sharedFunctionsReadme(): string {
839
+ return "# Shared Code\n\nFiles in `_shared/` are available to all functions via relative imports.\nThis directory is not deployed as a function.\n\nExample: `import { sendEmail } from '../_shared/email.ts'`\n"
840
+ }
841
+
842
+ function functionsEnvLocalTemplate(): string {
843
+ return "# Local environment variables for edge functions\n# These are NOT committed to git\n# Set production env vars via: npx supatype functions env set KEY=value\n"
844
+ }
845
+
251
846
  function gitignoreTemplate(): string {
252
847
  return `.env
253
848
  node_modules/
@@ -272,3 +867,58 @@ export function mergeGitignoreTemplate(existingContent: string): string {
272
867
  `
273
868
  return existingContent.endsWith("\n") ? `${existingContent}${block}` : `${existingContent}\n${block}`
274
869
  }
870
+
871
+ // ─── Next steps ────────────────────────────────────────────────────────────--
872
+
873
+ function printNextSteps(args: {
874
+ name: string | undefined
875
+ result: WizardResult
876
+ installed: boolean
877
+ keysGenerated: boolean
878
+ }): void {
879
+ const { name, result, installed, keysGenerated } = args
880
+ console.log(`\nSupatype project ready${name ? ` in ${name}/` : ""}.\n`)
881
+ console.log("Next steps:")
882
+ if (name) console.log(` cd ${name}`)
883
+ if (!installed) console.log(` ${result.packageManager} install`)
884
+ if (!keysGenerated) console.log(" supatype keys")
885
+ console.log(" supatype dev # Docker Compose stack (Kong :18473)")
886
+ console.log(" supatype push # apply schema + generate types")
887
+ if (result.helloFunction) {
888
+ console.log(" supatype functions serve # run edge functions locally")
889
+ }
890
+
891
+ if (result.app.mode === "none") {
892
+ console.log("\nStatic frontend (self-host):")
893
+ console.log(" supatype app add --static ./public")
894
+ console.log(" npm run build # write files into public/")
895
+ console.log(" supatype self-host compose up -d")
896
+ }
897
+
898
+ if (result.productionTarget === "cloud") {
899
+ console.log("\nDeploy to Supatype Cloud:")
900
+ console.log(" supatype login")
901
+ console.log(" supatype link --env production --project <ref>")
902
+ console.log(" supatype push --env production")
903
+ console.log("\nsupatype.local.config.ts keeps `supatype dev` local while the committed config targets cloud.")
904
+ } else if (result.productionTarget === "self-host") {
905
+ console.log("\nSelf-host production (your own server):")
906
+ const domain = result.domain?.trim()
907
+ if (domain) {
908
+ console.log(` 1. Point DNS: an A record for ${domain} -> your server's public IP`)
909
+ console.log(" 2. Open ports 80 and 443 on the server firewall")
910
+ if (!result.tlsEmail) {
911
+ console.log(" 3. Set server.tls.email in supatype.config.ts (required for HTTPS)")
912
+ }
913
+ console.log(" supatype self-host compose up -d # Kong provisions HTTPS automatically")
914
+ console.log(` Your Supatype platform goes live at https://${domain}`)
915
+ console.log(" Your app, REST, Auth, Storage, Realtime, Functions, and Studio — all behind one HTTPS domain (certs persist in valkey-data)")
916
+ } else {
917
+ console.log(" Set server.domain + server.tls.email in supatype.config.ts to enable automatic HTTPS")
918
+ console.log(" supatype self-host compose up -d # Docker stack")
919
+ }
920
+ console.log(" supatype link --env production ... # then: supatype push --env production")
921
+ console.log("\nsupatype.local.config.ts keeps `supatype dev` local while the committed config targets self-host.")
922
+ }
923
+ console.log()
924
+ }