@supatype/cli 0.1.0-alpha.12 → 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 (67) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-test.log +207 -89
  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/binary-cache.d.ts +11 -3
  9. package/dist/binary-cache.d.ts.map +1 -1
  10. package/dist/binary-cache.js +62 -39
  11. package/dist/binary-cache.js.map +1 -1
  12. package/dist/cli.d.ts.map +1 -1
  13. package/dist/cli.js +2 -0
  14. package/dist/cli.js.map +1 -1
  15. package/dist/commands/add.d.ts +3 -0
  16. package/dist/commands/add.d.ts.map +1 -0
  17. package/dist/commands/add.js +83 -0
  18. package/dist/commands/add.js.map +1 -0
  19. package/dist/commands/app.js +2 -2
  20. package/dist/commands/app.js.map +1 -1
  21. package/dist/commands/init.d.ts +29 -1
  22. package/dist/commands/init.d.ts.map +1 -1
  23. package/dist/commands/init.js +569 -90
  24. package/dist/commands/init.js.map +1 -1
  25. package/dist/commands/keys.d.ts +15 -1
  26. package/dist/commands/keys.d.ts.map +1 -1
  27. package/dist/commands/keys.js +39 -4
  28. package/dist/commands/keys.js.map +1 -1
  29. package/dist/engine-client.d.ts.map +1 -1
  30. package/dist/engine-client.js +12 -4
  31. package/dist/engine-client.js.map +1 -1
  32. package/dist/kong-config.d.ts +9 -0
  33. package/dist/kong-config.d.ts.map +1 -1
  34. package/dist/kong-config.js +18 -1
  35. package/dist/kong-config.js.map +1 -1
  36. package/dist/project-config.d.ts +16 -0
  37. package/dist/project-config.d.ts.map +1 -1
  38. package/dist/project-config.js +34 -0
  39. package/dist/project-config.js.map +1 -1
  40. package/dist/prompts.d.ts +8 -0
  41. package/dist/prompts.d.ts.map +1 -0
  42. package/dist/prompts.js +20 -0
  43. package/dist/prompts.js.map +1 -0
  44. package/dist/scripts/postinstall.js +5 -1
  45. package/dist/scripts/postinstall.js.map +1 -1
  46. package/dist/self-host-compose.d.ts.map +1 -1
  47. package/dist/self-host-compose.js +61 -17
  48. package/dist/self-host-compose.js.map +1 -1
  49. package/package.json +2 -1
  50. package/src/app-config.ts +80 -0
  51. package/src/binary-cache.ts +64 -42
  52. package/src/cli.ts +2 -0
  53. package/src/commands/add.ts +94 -0
  54. package/src/commands/app.ts +2 -2
  55. package/src/commands/init.ts +738 -88
  56. package/src/commands/keys.ts +49 -4
  57. package/src/engine-client.ts +11 -4
  58. package/src/kong-config.ts +24 -1
  59. package/src/project-config.ts +45 -0
  60. package/src/prompts.ts +21 -0
  61. package/src/scripts/postinstall.ts +7 -1
  62. package/src/self-host-compose.ts +61 -17
  63. package/tests/config.test.ts +26 -0
  64. package/tests/init.test.ts +128 -15
  65. package/tests/minisign.test.ts +102 -0
  66. package/tests/runtime-contract.test.ts +111 -1
  67. package/tsconfig.tsbuildinfo +1 -1
@@ -1,5 +1,5 @@
1
1
  import type { Command } from "commander"
2
- import { readFileSync, existsSync } from "node:fs"
2
+ import { readFileSync, existsSync, writeFileSync } from "node:fs"
3
3
  import { resolve } from "node:path"
4
4
  import { signJwt } from "../jwt.js"
5
5
 
@@ -41,13 +41,58 @@ export function registerKeys(program: Command): void {
41
41
 
42
42
  // ─── Helpers ─────────────────────────────────────────────────────────────────
43
43
 
44
- export function resolveSecret(): string | undefined {
44
+ /** Mint a long-lived anon + service_role JWT pair from a secret. */
45
+ export function signKeyPair(
46
+ secret: string,
47
+ expYears = 10,
48
+ ): { anonKey: string; serviceKey: string } {
49
+ const now = Math.floor(Date.now() / 1000)
50
+ const exp = now + expYears * 365 * 24 * 60 * 60
51
+ return {
52
+ anonKey: signJwt({ iss: "supatype", role: "anon", iat: now, exp }, secret),
53
+ serviceKey: signJwt({ iss: "supatype", role: "service_role", iat: now, exp }, secret),
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Generate keys from the JWT_SECRET found in `dir`'s .env (or env var) and
59
+ * rewrite the ANON_KEY / SERVICE_ROLE_KEY lines in that .env file in place.
60
+ * Returns the minted pair, or null if no secret could be resolved.
61
+ */
62
+ export function generateAndWriteKeys(
63
+ dir: string,
64
+ expYears = 10,
65
+ ): { anonKey: string; serviceKey: string } | null {
66
+ const secret = resolveSecret(dir)
67
+ if (!secret) return null
68
+
69
+ const { anonKey, serviceKey } = signKeyPair(secret, expYears)
70
+
71
+ const envPath = resolve(dir, ".env")
72
+ if (existsSync(envPath)) {
73
+ let content = readFileSync(envPath, "utf8")
74
+ content = upsertEnvVar(content, "ANON_KEY", anonKey)
75
+ content = upsertEnvVar(content, "SERVICE_ROLE_KEY", serviceKey)
76
+ writeFileSync(envPath, content, "utf8")
77
+ }
78
+
79
+ return { anonKey, serviceKey }
80
+ }
81
+
82
+ function upsertEnvVar(content: string, key: string, value: string): string {
83
+ const re = new RegExp(`^${key}=.*$`, "m")
84
+ if (re.test(content)) return content.replace(re, `${key}=${value}`)
85
+ const sep = content.endsWith("\n") || content.length === 0 ? "" : "\n"
86
+ return `${content}${sep}${key}=${value}\n`
87
+ }
88
+
89
+ export function resolveSecret(dir: string = process.cwd()): string | undefined {
45
90
  // 1. Check environment variable
46
91
  const fromEnv = process.env["JWT_SECRET"]
47
92
  if (fromEnv) return fromEnv
48
93
 
49
- // 2. Parse .env file in cwd
50
- const envPath = resolve(process.cwd(), ".env")
94
+ // 2. Parse .env file in the target directory
95
+ const envPath = resolve(dir, ".env")
51
96
  if (!existsSync(envPath)) return undefined
52
97
 
53
98
  try {
@@ -13,7 +13,8 @@ import { mkdirSync, writeFileSync, unlinkSync, existsSync, readdirSync } from "n
13
13
  import { tmpdir, homedir } from "node:os"
14
14
  import { join } from "node:path"
15
15
  import { loadConfig } from "./config.js"
16
- import { resolveBinary, currentPlatform, cachePath } from "./binary-cache.js"
16
+ import { currentPlatform, cachePath } from "./binary-cache.js"
17
+ import { ensureBinary } from "./ensure-binary.js"
17
18
 
18
19
  // ---------------------------------------------------------------------------
19
20
  // Types (kept for backward compatibility with existing callers)
@@ -86,10 +87,16 @@ async function getEngineBin(): Promise<string> {
86
87
 
87
88
  try {
88
89
  const config = loadConfig(cwd)
89
- _engineBin = await resolveBinary("engine", config)
90
+ // Download-on-miss (with retry) so a fresh machine or a failed postinstall
91
+ // self-heals on first use instead of silently skipping type/admin refresh.
92
+ _engineBin = await ensureBinary("engine", config)
90
93
  return _engineBin
91
- } catch {
92
- // No valid project config fall through to default cache path.
94
+ } catch (err) {
95
+ // A real download/verification failure must surface, not fall back to a
96
+ // possibly-stale cached binary from a different version.
97
+ const message = err instanceof Error ? err.message : String(err)
98
+ if (message.includes("Failed to download")) throw err
99
+ // Otherwise (no valid project config) fall through to default cache scan.
93
100
  }
94
101
 
95
102
  // No config found — scan the cache for any available engine binary.
@@ -18,6 +18,11 @@ export interface KongDeclarativeOptions {
18
18
  studioServiceUrl?: string | undefined
19
19
  /** See {@link RuntimeRouteOptions.studioStripPath}. */
20
20
  studioStripPath?: boolean | undefined
21
+ /**
22
+ * When set, append a global Kong `acme` plugin (Let's Encrypt) with Redis/Valkey
23
+ * storage so the self-host gateway provisions and renews TLS automatically.
24
+ */
25
+ acme?: { email: string; domain: string; redisHost: string } | undefined
21
26
  }
22
27
 
23
28
  /** Escape a string for use inside YAML double quotes. */
@@ -85,9 +90,27 @@ ${route.paths.map((path) => ` - ${path}`).join("\n")}
85
90
  ${protocols}${routePlugins}${graphqlPlugins}`
86
91
  }).join("\n")
87
92
 
93
+ const acme = opts.acme
94
+ const pluginsBlock = acme
95
+ ? `
96
+ plugins:
97
+ - name: acme
98
+ config:
99
+ account_email: ${yamlQuotedString(acme.email)}
100
+ tos_accepted: true
101
+ domains:
102
+ - ${yamlQuotedString(acme.domain)}
103
+ storage: redis
104
+ storage_config:
105
+ redis:
106
+ host: ${yamlQuotedString(acme.redisHost)}
107
+ port: 6379
108
+ `
109
+ : ""
110
+
88
111
  return `_format_version: "3.0"
89
112
  ${consumersBlock}
90
113
  services:
91
114
  ${servicesBlock}
92
- `
115
+ ${pluginsBlock}`
93
116
  }
@@ -59,6 +59,16 @@ export interface SupatypeProjectConfig {
59
59
  postgrestPort?: number
60
60
  /** Domain for ACME TLS certificate (mode=standalone). */
61
61
  domain?: string
62
+ /**
63
+ * TLS for self-host HTTPS (Kong ACME / Let's Encrypt).
64
+ * Requires `mode: "standalone"` and a non-empty `domain`.
65
+ */
66
+ tls?: {
67
+ /** ACME contact email for Let's Encrypt (required to enable HTTPS). */
68
+ email?: string
69
+ /** "kong" (default) = Kong acme plugin; "none" = stay HTTP even with a domain. */
70
+ provider?: "kong" | "none"
71
+ }
62
72
  }
63
73
  app: {
64
74
  /**
@@ -298,6 +308,23 @@ export function mergeProjectConfig(
298
308
  ...(base.admin !== undefined || override.admin !== undefined
299
309
  ? { admin: { ...base.admin, ...override.admin } as NonNullable<SupatypeProjectConfig["admin"]> }
300
310
  : {}),
311
+ ...(base.environments !== undefined || override.environments !== undefined
312
+ ? (() => {
313
+ const b = base.environments
314
+ const o = override.environments
315
+ const mergedBranchDefaults =
316
+ b?.branchDefaults !== undefined || o?.branchDefaults !== undefined
317
+ ? { ...(b?.branchDefaults ?? {}), ...(o?.branchDefaults ?? {}) }
318
+ : undefined
319
+ return {
320
+ environments: {
321
+ ...b,
322
+ ...o,
323
+ ...(mergedBranchDefaults !== undefined ? { branchDefaults: mergedBranchDefaults } : {}),
324
+ } as NonNullable<SupatypeProjectConfig["environments"]>,
325
+ }
326
+ })()
327
+ : {}),
301
328
  }
302
329
  }
303
330
 
@@ -373,6 +400,24 @@ export function serverBaseUrl(cfg: SupatypeProjectConfig): string | undefined {
373
400
  }
374
401
  }
375
402
 
403
+ /**
404
+ * True when `supatype self-host compose` should render Kong ACME TLS (Let's Encrypt).
405
+ * Gated on a real self-host render (not `supatype dev`), standalone mode, a non-empty
406
+ * domain, an ACME contact email, and `tls.provider !== "none"`.
407
+ */
408
+ export function selfHostTlsEnabled(
409
+ cfg: SupatypeProjectConfig,
410
+ devLocal = false,
411
+ ): boolean {
412
+ if (devLocal) return false
413
+ if (cfg.server.mode !== "standalone") return false
414
+ const domain = cfg.server.domain?.trim()
415
+ if (!domain) return false
416
+ const tls = cfg.server.tls
417
+ if (!tls || tls.provider === "none") return false
418
+ return Boolean(tls.email?.trim())
419
+ }
420
+
376
421
  /** Resolved runtime provider (`config.provider` ?? `database.provider` ?? native). */
377
422
  export function resolveRuntimeProvider(cfg: SupatypeProjectConfig): "native" | "docker" {
378
423
  return cfg.provider ?? cfg.database.provider ?? "native"
package/src/prompts.ts ADDED
@@ -0,0 +1,21 @@
1
+ import * as p from "@clack/prompts"
2
+ import { SUPATYPE_ASCII_LOGO_WORDMARK, colorLogoLines } from "./dev-logo.js"
3
+
4
+ /** Print the coloured Supatype ASCII wordmark at the top of an interactive command. */
5
+ export function printLogo(): void {
6
+ console.log()
7
+ console.log(colorLogoLines([...SUPATYPE_ASCII_LOGO_WORDMARK]).join("\n"))
8
+ console.log()
9
+ }
10
+
11
+ /**
12
+ * Unwrap a clack prompt result, exiting cleanly when the user cancels (Ctrl-C).
13
+ * Shared by all interactive commands so cancellation behaves consistently.
14
+ */
15
+ export function ensureNotCancelled<T>(value: T | symbol, cancelMessage = "Cancelled."): T {
16
+ if (p.isCancel(value)) {
17
+ p.cancel(cancelMessage)
18
+ process.exit(0)
19
+ }
20
+ return value as T
21
+ }
@@ -38,7 +38,13 @@ async function main() {
38
38
  }
39
39
 
40
40
  if (anyFailed) {
41
- console.error("[supatype] Some downloads failed. Run 'supatype update' to retry.")
41
+ // npm hides postinstall output unless --foreground-scripts, so don't rely on
42
+ // this being seen: the CLI re-attempts the download (with retry) on first use.
43
+ console.error(
44
+ "[supatype] Some component binaries failed to download. " +
45
+ "They will be re-downloaded automatically on first use; " +
46
+ "run 'supatype update' to retry now.",
47
+ )
42
48
  } else {
43
49
  console.log("[supatype] All component binaries downloaded successfully.")
44
50
  }
@@ -1,7 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
2
2
  import { dirname, join, relative, resolve } from "node:path"
3
3
  import { spawnSync } from "node:child_process"
4
- import { preferredFunctionsPathFromProject, type SupatypeProjectConfig } from "./project-config.js"
4
+ import { preferredFunctionsPathFromProject, selfHostTlsEnabled, type SupatypeProjectConfig } from "./project-config.js"
5
5
  import { hasEngineOverride, hasStudioOverride, pinnedVersion, fetchLatestVersion, VERSION_PIN_LOCAL } from "./binary-cache.js"
6
6
  import { buildKongDeclarative } from "./kong-config.js"
7
7
 
@@ -228,6 +228,11 @@ export function renderSelfHostCompose(
228
228
  const projectMount = projectMountPath(cwd)
229
229
  const kongMount = kongMountPath(cwd)
230
230
  const devLocal = options?.devLocal === true
231
+ const tlsEnabled = selfHostTlsEnabled(config, devLocal)
232
+ const domain = config.server.domain?.trim() ?? ""
233
+ // When TLS is on, default external URLs to https://<domain> so auth links/redirects use HTTPS.
234
+ const externalUrlFallback = tlsEnabled ? `https://${domain}` : "http://localhost:18473"
235
+ const siteUrlFallback = tlsEnabled ? `https://${domain}` : "http://localhost:3000"
231
236
  const studioHostDev = devLocal && hasStudioOverride(config)
232
237
  const appEnv = serverAppEnvForCompose(config, devLocal)
233
238
  const staticDir = staticDirForCompose(config) ?? "./dist"
@@ -239,7 +244,7 @@ export function renderSelfHostCompose(
239
244
  studio:
240
245
  ${studioService}
241
246
  environment:
242
- SUPATYPE_CLOUD_JSON: '{"url":"\${API_EXTERNAL_URL:-http://localhost:18473}","anonKey":"\${ANON_KEY:-}"}'
247
+ SUPATYPE_CLOUD_JSON: '{"url":"\${API_EXTERNAL_URL:-${externalUrlFallback}}","anonKey":"\${ANON_KEY:-}"}'
243
248
  expose:
244
249
  - "3002"
245
250
  `
@@ -270,6 +275,43 @@ ${studioService}
270
275
  - "9000:9000"
271
276
  - "9001:9001"
272
277
  `
278
+ const kongTlsEnv = tlsEnabled
279
+ ? ` KONG_PROXY_LISTEN: "0.0.0.0:8000, 0.0.0.0:8443 ssl"
280
+ KONG_LUA_SSL_TRUSTED_CERTIFICATE: system
281
+ `
282
+ : ""
283
+ const kongPorts = tlsEnabled
284
+ ? ` - "80:8000"
285
+ - "443:8443"`
286
+ : ` - "\${SUPATYPE_KONG_PORT:-18473}:8000"`
287
+ const kongTlsDependsOn = tlsEnabled ? "\n - valkey" : ""
288
+ const valkeyBlock = tlsEnabled
289
+ ? `
290
+ valkey:
291
+ image: \${SUPATYPE_VALKEY_IMAGE:-valkey/valkey:8-alpine}
292
+ command: ["valkey-server", "--appendonly", "yes"]
293
+ expose:
294
+ - "6379"
295
+ volumes:
296
+ - valkey-data:/data
297
+ `
298
+ : ""
299
+ const tlsHintComment = tlsEnabled
300
+ ? ""
301
+ : ` # HTTPS is off. To enable automatic TLS (Let's Encrypt) for production, set in supatype.config.ts:
302
+ # server: { mode: "standalone", domain: "your.domain", tls: { email: "you@example.com" } }
303
+ # then re-run \`supatype self-host compose up -d\`. Kong publishes :80/:443 and provisions certs automatically.
304
+ `
305
+ const volumesBlock = tlsEnabled
306
+ ? `volumes:
307
+ db-data:
308
+ minio-data:
309
+ valkey-data:
310
+ `
311
+ : `volumes:
312
+ db-data:
313
+ minio-data:
314
+ `
273
315
 
274
316
  return `# Generated by supatype self-host compose
275
317
  # Kong → supatype-server (unified gateway) → internal PostgREST / storage / etc.
@@ -330,12 +372,12 @@ ${dbPorts} volumes:
330
372
  SUPATYPE_FUNCTIONS_ROOT: /project/functions
331
373
  SUPATYPE_DENO_FUNCTIONS_DIR: /project/functions
332
374
  PORT: "8001"
333
- SUPATYPE_URL: \${API_EXTERNAL_URL:-http://localhost:18473}
375
+ SUPATYPE_URL: \${API_EXTERNAL_URL:-${externalUrlFallback}}
334
376
  SUPATYPE_ANON_KEY: \${ANON_KEY:-}
335
377
  SUPATYPE_SERVICE_ROLE_KEY: \${SERVICE_ROLE_KEY:-}
336
378
  STRIPE_SECRET_KEY: \${STRIPE_SECRET_KEY:-}
337
379
  STRIPE_WEBHOOK_SECRET: \${STRIPE_WEBHOOK_SECRET:-}
338
- SITE_URL: \${SITE_URL:-\${API_EXTERNAL_URL:-http://localhost:18473}}
380
+ SITE_URL: \${SITE_URL:-\${API_EXTERNAL_URL:-${externalUrlFallback}}}
339
381
  depends_on:
340
382
  db:
341
383
  condition: service_healthy
@@ -374,7 +416,7 @@ ${serverPorts} volumes:
374
416
  SUPATYPE_POSTGREST_URL: http://postgrest:3000
375
417
  SUPATYPE_GRAPHQL_URL: http://postgrest:3000
376
418
  SUPATYPE_STORAGE_URL: http://storage:5000
377
- SUPATYPE_URL: \${API_EXTERNAL_URL:-http://localhost:18473}
419
+ SUPATYPE_URL: \${API_EXTERNAL_URL:-${externalUrlFallback}}
378
420
  SUPATYPE_ANON_KEY: \${ANON_KEY:-}
379
421
  SUPATYPE_SERVICE_ROLE_KEY: \${SERVICE_ROLE_KEY:-}
380
422
  SUPATYPE_SQL_DATABASE_URL: "postgresql://\${POSTGRES_USER:-supatype_admin}:\${POSTGRES_PASSWORD:-postgres}@db:5432/\${POSTGRES_DB:-supatype}"
@@ -384,11 +426,11 @@ ${serverPorts} volumes:
384
426
  ${appEnv}
385
427
  GOTRUE_API_HOST: 0.0.0.0
386
428
  GOTRUE_API_PORT: 9999
387
- API_EXTERNAL_URL: \${API_EXTERNAL_URL:-http://localhost:18473}
388
- GOTRUE_API_EXTERNAL_URL: \${API_EXTERNAL_URL:-http://localhost:18473}
429
+ API_EXTERNAL_URL: \${API_EXTERNAL_URL:-${externalUrlFallback}}
430
+ GOTRUE_API_EXTERNAL_URL: \${API_EXTERNAL_URL:-${externalUrlFallback}}
389
431
  GOTRUE_DB_DRIVER: postgres
390
432
  GOTRUE_DB_DATABASE_URL: "postgres://\${POSTGRES_USER:-supatype_admin}:\${POSTGRES_PASSWORD:-postgres}@db:5432/\${POSTGRES_DB:-supatype}?search_path=auth"
391
- GOTRUE_SITE_URL: \${SITE_URL:-http://localhost:3000}
433
+ GOTRUE_SITE_URL: \${SITE_URL:-${siteUrlFallback}}
392
434
  GOTRUE_JWT_SECRET: \${JWT_SECRET:-super-secret-jwt-token-change-in-production}
393
435
  GOTRUE_JWT_EXP: 3600
394
436
  GOTRUE_JWT_AUD: authenticated
@@ -428,8 +470,7 @@ ${minioPorts} volumes:
428
470
  depends_on:
429
471
  db:
430
472
  condition: service_healthy
431
- ${studioBlock}
432
- kong:
473
+ ${studioBlock}${valkeyBlock}${tlsHintComment} kong:
433
474
  image: kong:3.6
434
475
  environment:
435
476
  KONG_DATABASE: "off"
@@ -438,17 +479,14 @@ ${studioBlock}
438
479
  KONG_ADMIN_ACCESS_LOG: /dev/stdout
439
480
  KONG_PROXY_ERROR_LOG: /dev/stderr
440
481
  KONG_ADMIN_ERROR_LOG: /dev/stderr
441
- volumes:
482
+ ${kongTlsEnv} volumes:
442
483
  - ${kongMount}:/etc/kong/kong.yml:ro
443
484
  ports:
444
- - "\${SUPATYPE_KONG_PORT:-18473}:8000"
485
+ ${kongPorts}
445
486
  depends_on:
446
- ${kongDependsOn}
487
+ ${kongDependsOn}${kongTlsDependsOn}
447
488
 
448
- volumes:
449
- db-data:
450
- minio-data:
451
- `
489
+ ${volumesBlock}`
452
490
  }
453
491
 
454
492
  function ensureComposeManifest(cwd: string): void {
@@ -480,6 +518,9 @@ export function writeSelfHostCompose(
480
518
  ensureComposeManifest(cwd)
481
519
  writeFileSync(paths.composePath, renderSelfHostCompose(config, cwd, options), "utf8")
482
520
  const studioHostDev = options?.devLocal === true && hasStudioOverride(config)
521
+ const tlsEnabled = selfHostTlsEnabled(config, options?.devLocal === true)
522
+ const domain = config.server.domain?.trim()
523
+ const acmeEmail = config.server.tls?.email?.trim()
483
524
  writeFileSync(
484
525
  paths.kongPath,
485
526
  buildKongDeclarative({
@@ -488,6 +529,9 @@ export function writeSelfHostCompose(
488
529
  studioServiceUrl: COMPOSE_STUDIO_HOST_URL,
489
530
  studioStripPath: false,
490
531
  }),
532
+ ...(tlsEnabled && domain && acmeEmail
533
+ ? { acme: { email: acmeEmail, domain, redisHost: "valkey" } }
534
+ : {}),
491
535
  }),
492
536
  "utf8",
493
537
  )
@@ -249,4 +249,30 @@ describe("mergeProjectConfig()", () => {
249
249
  expect(merged.app.start).toBe("dev:site")
250
250
  expect(merged.app.static_dir).toBe("./dist")
251
251
  })
252
+
253
+ it("preserves base environments when a local override sets only server.mode", () => {
254
+ const base = defineConfig({
255
+ ...minimalProject("p"),
256
+ server: { mode: "standalone", domain: "api.example.com" },
257
+ environments: { default: "production" },
258
+ })
259
+ const merged = mergeProjectConfig(base, { server: { mode: "dev" } })
260
+ expect(merged.server.mode).toBe("dev")
261
+ expect(merged.environments?.default).toBe("production")
262
+ })
263
+
264
+ it("deep-merges environments.branchDefaults across base and override", () => {
265
+ const base = defineConfig({
266
+ ...minimalProject("p"),
267
+ environments: { default: "production", branchDefaults: { main: "production" } },
268
+ })
269
+ const merged = mergeProjectConfig(base, {
270
+ environments: { branchDefaults: { staging: "preview" } },
271
+ })
272
+ expect(merged.environments?.default).toBe("production")
273
+ expect(merged.environments?.branchDefaults).toEqual({
274
+ main: "production",
275
+ staging: "preview",
276
+ })
277
+ })
252
278
  })
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"
2
2
  import { mkdirSync, rmSync, existsSync, readFileSync, writeFileSync } from "node:fs"
3
3
  import { join } from "node:path"
4
4
  import { tmpdir } from "node:os"
5
- import { scaffold } from "../src/commands/init.js"
5
+ import { scaffold, defaultScaffoldOptions } from "../src/commands/init.js"
6
6
 
7
7
  let tmpRoot: string
8
8
 
@@ -17,7 +17,7 @@ afterEach(() => {
17
17
 
18
18
  describe("scaffold()", () => {
19
19
  it("creates all expected files", () => {
20
- scaffold(tmpRoot, "my-app")
20
+ scaffold(tmpRoot, defaultScaffoldOptions("my-app"))
21
21
 
22
22
  const expected = [
23
23
  "package.json",
@@ -35,7 +35,7 @@ describe("scaffold()", () => {
35
35
  })
36
36
 
37
37
  it("supatype.config.ts embeds the project name and exports defineConfig", () => {
38
- scaffold(tmpRoot, "blog-app")
38
+ scaffold(tmpRoot, defaultScaffoldOptions("blog-app"))
39
39
  const content = readFileSync(join(tmpRoot, "supatype.config.ts"), "utf8")
40
40
  expect(content).toContain("blog-app")
41
41
  expect(content).toContain("defineConfig")
@@ -46,7 +46,7 @@ describe("scaffold()", () => {
46
46
  })
47
47
 
48
48
  it("package.json includes @supatype/cli and @supatype/types", () => {
49
- scaffold(tmpRoot, "pkg-app")
49
+ scaffold(tmpRoot, defaultScaffoldOptions("pkg-app"))
50
50
  const content = readFileSync(join(tmpRoot, "package.json"), "utf8")
51
51
  expect(content).toContain("@supatype/cli")
52
52
  expect(content).toContain("@supatype/types")
@@ -56,18 +56,18 @@ describe("scaffold()", () => {
56
56
  it("skips package.json when it already exists", () => {
57
57
  const pkgPath = join(tmpRoot, "package.json")
58
58
  writeFileSync(pkgPath, '{"name":"existing"}', "utf8")
59
- scaffold(tmpRoot, "my-app")
59
+ scaffold(tmpRoot, defaultScaffoldOptions("my-app"))
60
60
  expect(readFileSync(pkgPath, "utf8")).toBe('{"name":"existing"}')
61
61
  })
62
62
 
63
63
  it("supatype.config.ts documents self-host workflow", () => {
64
- scaffold(tmpRoot, "my-app")
64
+ scaffold(tmpRoot, defaultScaffoldOptions("my-app"))
65
65
  const content = readFileSync(join(tmpRoot, "supatype.config.ts"), "utf8")
66
66
  expect(content).toContain("self-host")
67
67
  })
68
68
 
69
69
  it(".env contains DATABASE_URL, JWT_SECRET, POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB", () => {
70
- scaffold(tmpRoot, "my-app")
70
+ scaffold(tmpRoot, defaultScaffoldOptions("my-app"))
71
71
  const content = readFileSync(join(tmpRoot, ".env"), "utf8")
72
72
  expect(content).toContain("DATABASE_URL=")
73
73
  expect(content).toContain("JWT_SECRET=")
@@ -77,23 +77,24 @@ describe("scaffold()", () => {
77
77
  })
78
78
 
79
79
  it(".env contains ANON_KEY, SERVICE_ROLE_KEY, and SITE_URL placeholders", () => {
80
- scaffold(tmpRoot, "my-app")
80
+ scaffold(tmpRoot, defaultScaffoldOptions("my-app"))
81
81
  const content = readFileSync(join(tmpRoot, ".env"), "utf8")
82
82
  expect(content).toContain("ANON_KEY=")
83
83
  expect(content).toContain("SERVICE_ROLE_KEY=")
84
84
  expect(content).toContain("SITE_URL=")
85
85
  })
86
86
 
87
- it("schema/index.ts exports a User model using RFC v2 Model<>", () => {
88
- scaffold(tmpRoot, "my-app")
87
+ it("schema/index.ts exports a Profile model using RFC v2 Model<>", () => {
88
+ scaffold(tmpRoot, defaultScaffoldOptions("my-app"))
89
89
  const content = readFileSync(join(tmpRoot, "schema/index.ts"), "utf8")
90
- expect(content).toContain("export type User")
90
+ expect(content).toContain("export type Profile")
91
+ expect(content).toContain("display_name")
91
92
  expect(content).toContain("Model<")
92
93
  expect(content).toContain("access:")
93
94
  })
94
95
 
95
96
  it(".gitignore excludes .env, node_modules, and engine binary", () => {
96
- scaffold(tmpRoot, "my-app")
97
+ scaffold(tmpRoot, defaultScaffoldOptions("my-app"))
97
98
  const content = readFileSync(join(tmpRoot, ".gitignore"), "utf8")
98
99
  expect(content).toContain(".env")
99
100
  expect(content).toContain("node_modules/")
@@ -102,19 +103,19 @@ describe("scaffold()", () => {
102
103
  })
103
104
 
104
105
  it("seed.ts references the project name", () => {
105
- scaffold(tmpRoot, "acme")
106
+ scaffold(tmpRoot, defaultScaffoldOptions("acme"))
106
107
  const content = readFileSync(join(tmpRoot, "seed.ts"), "utf8")
107
108
  expect(content).toContain("acme")
108
109
  })
109
110
 
110
111
  it("different project names produce different config bodies", () => {
111
- scaffold(tmpRoot, "alpha")
112
+ scaffold(tmpRoot, defaultScaffoldOptions("alpha"))
112
113
  const alpha = readFileSync(join(tmpRoot, "supatype.config.ts"), "utf8")
113
114
 
114
115
  const tmp2 = join(tmpdir(), `dt-init-test2-${Date.now()}`)
115
116
  mkdirSync(tmp2, { recursive: true })
116
117
  try {
117
- scaffold(tmp2, "beta")
118
+ scaffold(tmp2, defaultScaffoldOptions("beta"))
118
119
  const beta = readFileSync(join(tmp2, "supatype.config.ts"), "utf8")
119
120
  expect(alpha).toContain("alpha")
120
121
  expect(beta).toContain("beta")
@@ -124,4 +125,116 @@ describe("scaffold()", () => {
124
125
  rmSync(tmp2, { recursive: true, force: true })
125
126
  }
126
127
  })
128
+
129
+ it("self-host target emits standalone mode + domain and a local override", () => {
130
+ scaffold(tmpRoot, { ...defaultScaffoldOptions("my-app", "self-host"), domain: "api.example.com" })
131
+ const content = readFileSync(join(tmpRoot, "supatype.config.ts"), "utf8")
132
+ expect(content).toContain('mode: "standalone"')
133
+ expect(content).toContain('domain: "api.example.com"')
134
+ expect(content).toContain('environments: { default: "production" }')
135
+ expect(existsSync(join(tmpRoot, "supatype.local.config.ts"))).toBe(true)
136
+ const local = readFileSync(join(tmpRoot, "supatype.local.config.ts"), "utf8")
137
+ expect(local).toContain('mode: "dev"')
138
+ expect(local).toContain("Partial<SupatypeConfig>")
139
+ })
140
+
141
+ it("self-host with a TLS email emits an active tls block", () => {
142
+ scaffold(tmpRoot, {
143
+ ...defaultScaffoldOptions("my-app", "self-host"),
144
+ domain: "api.example.com",
145
+ tlsEmail: "ops@example.com",
146
+ })
147
+ const content = readFileSync(join(tmpRoot, "supatype.config.ts"), "utf8")
148
+ expect(content).toContain('tls: { email: "ops@example.com" }')
149
+ })
150
+
151
+ it("self-host without a TLS email emits a commented tls hint", () => {
152
+ scaffold(tmpRoot, { ...defaultScaffoldOptions("my-app", "self-host"), domain: "api.example.com" })
153
+ const content = readFileSync(join(tmpRoot, "supatype.config.ts"), "utf8")
154
+ expect(content).toContain('// tls: { email: "you@example.com" }')
155
+ })
156
+
157
+ it("cloud target emits managed mode + environments and a local override", () => {
158
+ scaffold(tmpRoot, defaultScaffoldOptions("my-app", "cloud"))
159
+ const content = readFileSync(join(tmpRoot, "supatype.config.ts"), "utf8")
160
+ expect(content).toContain('mode: "managed"')
161
+ expect(content).toContain('environments: { default: "production" }')
162
+ expect(existsSync(join(tmpRoot, "supatype.local.config.ts"))).toBe(true)
163
+ })
164
+
165
+ it("later target stays in dev mode with no local override", () => {
166
+ scaffold(tmpRoot, defaultScaffoldOptions("my-app", "later"))
167
+ const content = readFileSync(join(tmpRoot, "supatype.config.ts"), "utf8")
168
+ expect(content).toContain('mode: "dev"')
169
+ expect(content).not.toContain("environments:")
170
+ expect(existsSync(join(tmpRoot, "supatype.local.config.ts"))).toBe(false)
171
+ })
172
+
173
+ it("static app mode writes the static dir and config block", () => {
174
+ scaffold(tmpRoot, {
175
+ ...defaultScaffoldOptions("my-app"),
176
+ app: { mode: "static", staticDir: "./dist" },
177
+ })
178
+ const content = readFileSync(join(tmpRoot, "supatype.config.ts"), "utf8")
179
+ expect(content).toContain('mode: "static"')
180
+ expect(content).toContain('static_dir: "./dist"')
181
+ expect(existsSync(join(tmpRoot, "dist/.gitkeep"))).toBe(true)
182
+ })
183
+
184
+ it("proxy app mode writes upstream and start in the config", () => {
185
+ scaffold(tmpRoot, {
186
+ ...defaultScaffoldOptions("my-app"),
187
+ app: { mode: "proxy", upstream: "http://localhost:4000", start: "dev" },
188
+ })
189
+ const content = readFileSync(join(tmpRoot, "supatype.config.ts"), "utf8")
190
+ expect(content).toContain('mode: "proxy"')
191
+ expect(content).toContain('upstream: "http://localhost:4000"')
192
+ expect(content).toContain('start: "dev"')
193
+ })
194
+
195
+ it("hello-world function scaffolds function files and a functions script", () => {
196
+ scaffold(tmpRoot, { ...defaultScaffoldOptions("my-app"), helloFunction: true })
197
+ expect(existsSync(join(tmpRoot, "functions/hello/index.ts"))).toBe(true)
198
+ expect(existsSync(join(tmpRoot, "functions/_shared/README.md"))).toBe(true)
199
+ expect(existsSync(join(tmpRoot, "functions/.env.local"))).toBe(true)
200
+ const pkg = readFileSync(join(tmpRoot, "package.json"), "utf8")
201
+ expect(pkg).toContain("supatype functions serve")
202
+ })
203
+
204
+ it("custom schema path is honored", () => {
205
+ scaffold(tmpRoot, { ...defaultScaffoldOptions("my-app"), schemaPath: "db/schema.ts" })
206
+ expect(existsSync(join(tmpRoot, "db/schema.ts"))).toBe(true)
207
+ const content = readFileSync(join(tmpRoot, "supatype.config.ts"), "utf8")
208
+ expect(content).toContain('path: "db/schema.ts"')
209
+ })
210
+
211
+ it("s3 storage and resend email reflect in config and .env", () => {
212
+ scaffold(tmpRoot, {
213
+ ...defaultScaffoldOptions("my-app"),
214
+ email: "resend",
215
+ storageLocal: "s3",
216
+ storageProduction: "s3",
217
+ })
218
+ const config = readFileSync(join(tmpRoot, "supatype.config.ts"), "utf8")
219
+ expect(config).toContain('email: { provider: "resend" }')
220
+ expect(config).toContain('storage: { provider: "s3" }')
221
+ const env = readFileSync(join(tmpRoot, ".env"), "utf8")
222
+ expect(env).toContain("RESEND_API_KEY=")
223
+ expect(env).toContain("S3_BUCKET=")
224
+ expect(env).toContain("local development and production")
225
+ })
226
+
227
+ it("mixed local dev and s3 production writes both storage sections", () => {
228
+ scaffold(tmpRoot, {
229
+ ...defaultScaffoldOptions("my-app"),
230
+ storageLocal: "local",
231
+ storageProduction: "s3",
232
+ })
233
+ const config = readFileSync(join(tmpRoot, "supatype.config.ts"), "utf8")
234
+ expect(config).toContain('provider: "local"')
235
+ expect(config).toContain("Production storage: external S3")
236
+ const env = readFileSync(join(tmpRoot, ".env"), "utf8")
237
+ expect(env).toContain("local development — MinIO")
238
+ expect(env).toContain("production — external bucket")
239
+ })
127
240
  })