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

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 (58) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-test.log +209 -92
  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/commands/self-host.d.ts.map +1 -1
  26. package/dist/commands/self-host.js +5 -5
  27. package/dist/commands/self-host.js.map +1 -1
  28. package/dist/kong-config.d.ts +9 -0
  29. package/dist/kong-config.d.ts.map +1 -1
  30. package/dist/kong-config.js +18 -1
  31. package/dist/kong-config.js.map +1 -1
  32. package/dist/project-config.d.ts +16 -0
  33. package/dist/project-config.d.ts.map +1 -1
  34. package/dist/project-config.js +34 -0
  35. package/dist/project-config.js.map +1 -1
  36. package/dist/prompts.d.ts +8 -0
  37. package/dist/prompts.d.ts.map +1 -0
  38. package/dist/prompts.js +20 -0
  39. package/dist/prompts.js.map +1 -0
  40. package/dist/self-host-compose.d.ts.map +1 -1
  41. package/dist/self-host-compose.js +62 -17
  42. package/dist/self-host-compose.js.map +1 -1
  43. package/package.json +2 -1
  44. package/src/app-config.ts +80 -0
  45. package/src/cli.ts +2 -0
  46. package/src/commands/add.ts +94 -0
  47. package/src/commands/app.ts +2 -2
  48. package/src/commands/init.ts +738 -88
  49. package/src/commands/keys.ts +49 -4
  50. package/src/commands/self-host.ts +25 -5
  51. package/src/kong-config.ts +24 -1
  52. package/src/project-config.ts +45 -0
  53. package/src/prompts.ts +21 -0
  54. package/src/self-host-compose.ts +62 -17
  55. package/tests/config.test.ts +26 -0
  56. package/tests/init.test.ts +128 -15
  57. package/tests/runtime-contract.test.ts +111 -1
  58. 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 {
@@ -17,7 +17,7 @@ import { resolveBinary } from "../binary-cache.js"
17
17
  import { generateUnits } from "../systemd.js"
18
18
  import { readPid } from "../process-manager.js"
19
19
  import { localStorageEnv } from "../local-storage.js"
20
- import { runDockerCompose, writeSelfHostCompose } from "../self-host-compose.js"
20
+ import { composeProjectName, runDockerCompose, writeSelfHostCompose } from "../self-host-compose.js"
21
21
 
22
22
  export function registerSelfHost(program: Command): void {
23
23
  const selfHostCmd = program
@@ -47,7 +47,12 @@ export function registerSelfHost(program: Command): void {
47
47
  const cwd = process.cwd()
48
48
  const config = loadConfig(cwd)
49
49
  const out = writeSelfHostCompose(cwd, config)
50
- const status = runDockerCompose(out.composePath, opts.detach ? ["up", "-d"] : ["up"], cwd)
50
+ const status = runDockerCompose(
51
+ out.composePath,
52
+ opts.detach ? ["up", "-d"] : ["up"],
53
+ cwd,
54
+ composeProjectName(config.project.name),
55
+ )
51
56
  process.exitCode = status
52
57
  })
53
58
 
@@ -58,7 +63,12 @@ export function registerSelfHost(program: Command): void {
58
63
  const cwd = process.cwd()
59
64
  const config = loadConfig(cwd)
60
65
  const out = writeSelfHostCompose(cwd, config)
61
- process.exitCode = runDockerCompose(out.composePath, ["down"], cwd)
66
+ process.exitCode = runDockerCompose(
67
+ out.composePath,
68
+ ["down"],
69
+ cwd,
70
+ composeProjectName(config.project.name),
71
+ )
62
72
  })
63
73
 
64
74
  composeCmd
@@ -68,7 +78,12 @@ export function registerSelfHost(program: Command): void {
68
78
  const cwd = process.cwd()
69
79
  const config = loadConfig(cwd)
70
80
  const out = writeSelfHostCompose(cwd, config)
71
- process.exitCode = runDockerCompose(out.composePath, ["ps"], cwd)
81
+ process.exitCode = runDockerCompose(
82
+ out.composePath,
83
+ ["ps"],
84
+ cwd,
85
+ composeProjectName(config.project.name),
86
+ )
72
87
  })
73
88
 
74
89
  composeCmd
@@ -83,7 +98,12 @@ export function registerSelfHost(program: Command): void {
83
98
  const args = ["logs"]
84
99
  if (opts.follow) args.push("-f")
85
100
  if (opts.service) args.push(opts.service)
86
- process.exitCode = runDockerCompose(out.composePath, args, cwd)
101
+ process.exitCode = runDockerCompose(
102
+ out.composePath,
103
+ args,
104
+ cwd,
105
+ composeProjectName(config.project.name),
106
+ )
87
107
  })
88
108
 
89
109
  // ── Legacy native/systemd helpers (hidden; use compose for self-host) ─────
@@ -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
+ }
@@ -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,44 @@ ${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
+ KONG_LUA_SSL_VERIFY_DEPTH: "2"
282
+ `
283
+ : ""
284
+ const kongPorts = tlsEnabled
285
+ ? ` - "80:8000"
286
+ - "443:8443"`
287
+ : ` - "\${SUPATYPE_KONG_PORT:-18473}:8000"`
288
+ const kongTlsDependsOn = tlsEnabled ? "\n - valkey" : ""
289
+ const valkeyBlock = tlsEnabled
290
+ ? `
291
+ valkey:
292
+ image: \${SUPATYPE_VALKEY_IMAGE:-valkey/valkey:8-alpine}
293
+ command: ["valkey-server", "--appendonly", "yes"]
294
+ expose:
295
+ - "6379"
296
+ volumes:
297
+ - valkey-data:/data
298
+ `
299
+ : ""
300
+ const tlsHintComment = tlsEnabled
301
+ ? ""
302
+ : ` # HTTPS is off. To enable automatic TLS (Let's Encrypt) for production, set in supatype.config.ts:
303
+ # server: { mode: "standalone", domain: "your.domain", tls: { email: "you@example.com" } }
304
+ # then re-run \`supatype self-host compose up -d\`. Kong publishes :80/:443 and provisions certs automatically.
305
+ `
306
+ const volumesBlock = tlsEnabled
307
+ ? `volumes:
308
+ db-data:
309
+ minio-data:
310
+ valkey-data:
311
+ `
312
+ : `volumes:
313
+ db-data:
314
+ minio-data:
315
+ `
273
316
 
274
317
  return `# Generated by supatype self-host compose
275
318
  # Kong → supatype-server (unified gateway) → internal PostgREST / storage / etc.
@@ -330,12 +373,12 @@ ${dbPorts} volumes:
330
373
  SUPATYPE_FUNCTIONS_ROOT: /project/functions
331
374
  SUPATYPE_DENO_FUNCTIONS_DIR: /project/functions
332
375
  PORT: "8001"
333
- SUPATYPE_URL: \${API_EXTERNAL_URL:-http://localhost:18473}
376
+ SUPATYPE_URL: \${API_EXTERNAL_URL:-${externalUrlFallback}}
334
377
  SUPATYPE_ANON_KEY: \${ANON_KEY:-}
335
378
  SUPATYPE_SERVICE_ROLE_KEY: \${SERVICE_ROLE_KEY:-}
336
379
  STRIPE_SECRET_KEY: \${STRIPE_SECRET_KEY:-}
337
380
  STRIPE_WEBHOOK_SECRET: \${STRIPE_WEBHOOK_SECRET:-}
338
- SITE_URL: \${SITE_URL:-\${API_EXTERNAL_URL:-http://localhost:18473}}
381
+ SITE_URL: \${SITE_URL:-\${API_EXTERNAL_URL:-${externalUrlFallback}}}
339
382
  depends_on:
340
383
  db:
341
384
  condition: service_healthy
@@ -374,7 +417,7 @@ ${serverPorts} volumes:
374
417
  SUPATYPE_POSTGREST_URL: http://postgrest:3000
375
418
  SUPATYPE_GRAPHQL_URL: http://postgrest:3000
376
419
  SUPATYPE_STORAGE_URL: http://storage:5000
377
- SUPATYPE_URL: \${API_EXTERNAL_URL:-http://localhost:18473}
420
+ SUPATYPE_URL: \${API_EXTERNAL_URL:-${externalUrlFallback}}
378
421
  SUPATYPE_ANON_KEY: \${ANON_KEY:-}
379
422
  SUPATYPE_SERVICE_ROLE_KEY: \${SERVICE_ROLE_KEY:-}
380
423
  SUPATYPE_SQL_DATABASE_URL: "postgresql://\${POSTGRES_USER:-supatype_admin}:\${POSTGRES_PASSWORD:-postgres}@db:5432/\${POSTGRES_DB:-supatype}"
@@ -384,11 +427,11 @@ ${serverPorts} volumes:
384
427
  ${appEnv}
385
428
  GOTRUE_API_HOST: 0.0.0.0
386
429
  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}
430
+ API_EXTERNAL_URL: \${API_EXTERNAL_URL:-${externalUrlFallback}}
431
+ GOTRUE_API_EXTERNAL_URL: \${API_EXTERNAL_URL:-${externalUrlFallback}}
389
432
  GOTRUE_DB_DRIVER: postgres
390
433
  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}
434
+ GOTRUE_SITE_URL: \${SITE_URL:-${siteUrlFallback}}
392
435
  GOTRUE_JWT_SECRET: \${JWT_SECRET:-super-secret-jwt-token-change-in-production}
393
436
  GOTRUE_JWT_EXP: 3600
394
437
  GOTRUE_JWT_AUD: authenticated
@@ -428,8 +471,7 @@ ${minioPorts} volumes:
428
471
  depends_on:
429
472
  db:
430
473
  condition: service_healthy
431
- ${studioBlock}
432
- kong:
474
+ ${studioBlock}${valkeyBlock}${tlsHintComment} kong:
433
475
  image: kong:3.6
434
476
  environment:
435
477
  KONG_DATABASE: "off"
@@ -438,17 +480,14 @@ ${studioBlock}
438
480
  KONG_ADMIN_ACCESS_LOG: /dev/stdout
439
481
  KONG_PROXY_ERROR_LOG: /dev/stderr
440
482
  KONG_ADMIN_ERROR_LOG: /dev/stderr
441
- volumes:
483
+ ${kongTlsEnv} volumes:
442
484
  - ${kongMount}:/etc/kong/kong.yml:ro
443
485
  ports:
444
- - "\${SUPATYPE_KONG_PORT:-18473}:8000"
486
+ ${kongPorts}
445
487
  depends_on:
446
- ${kongDependsOn}
488
+ ${kongDependsOn}${kongTlsDependsOn}
447
489
 
448
- volumes:
449
- db-data:
450
- minio-data:
451
- `
490
+ ${volumesBlock}`
452
491
  }
453
492
 
454
493
  function ensureComposeManifest(cwd: string): void {
@@ -480,6 +519,9 @@ export function writeSelfHostCompose(
480
519
  ensureComposeManifest(cwd)
481
520
  writeFileSync(paths.composePath, renderSelfHostCompose(config, cwd, options), "utf8")
482
521
  const studioHostDev = options?.devLocal === true && hasStudioOverride(config)
522
+ const tlsEnabled = selfHostTlsEnabled(config, options?.devLocal === true)
523
+ const domain = config.server.domain?.trim()
524
+ const acmeEmail = config.server.tls?.email?.trim()
483
525
  writeFileSync(
484
526
  paths.kongPath,
485
527
  buildKongDeclarative({
@@ -488,6 +530,9 @@ export function writeSelfHostCompose(
488
530
  studioServiceUrl: COMPOSE_STUDIO_HOST_URL,
489
531
  studioStripPath: false,
490
532
  }),
533
+ ...(tlsEnabled && domain && acmeEmail
534
+ ? { acme: { email: acmeEmail, domain, redisHost: "valkey" } }
535
+ : {}),
491
536
  }),
492
537
  "utf8",
493
538
  )
@@ -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
  })