okengine 0.8.0 → 0.9.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 (89) hide show
  1. package/package.json +3 -2
  2. package/site/content/docs/deployment/docker-swarm.mdx +228 -0
  3. package/site/content/docs/deployment/docker.mdx +212 -0
  4. package/site/content/docs/deployment/index.mdx +83 -0
  5. package/site/content/docs/deployment/kubernetes.mdx +176 -0
  6. package/site/content/docs/deployment/meta.json +5 -0
  7. package/site/content/docs/deployment/reverse-proxy.mdx +216 -0
  8. package/site/content/docs/elements/channel.mdx +3 -1
  9. package/site/content/docs/elements/signal.mdx +10 -8
  10. package/site/content/docs/elements/store.mdx +34 -0
  11. package/site/content/docs/get-started/index.mdx +5 -0
  12. package/site/content/docs/index.mdx +5 -0
  13. package/site/content/docs/meta.json +10 -1
  14. package/site/content/docs/plugins/index.mdx +1 -2
  15. package/site/content/docs/plugins/magic-link.mdx +2 -2
  16. package/site/content/docs/plugins/meta.json +1 -2
  17. package/site/content/docs/plugins/otp.mdx +202 -0
  18. package/site/content/docs/plugins/two-factor.mdx +2 -2
  19. package/site/content/docs/reference/cli.md +3 -2
  20. package/site/content/docs/reference/configuration.mdx +16 -0
  21. package/site/content/docs/reference/environment-variables.mdx +9 -8
  22. package/site/content/docs/reference/plugins.mdx +1 -1
  23. package/src/auth/auth.test.ts +36 -0
  24. package/src/auth/bindings.ts +3 -12
  25. package/src/auth/identity.ts +33 -0
  26. package/src/auth/index.ts +5 -0
  27. package/src/auth/otp-capability.ts +119 -0
  28. package/src/auth/otp-seal.test.ts +61 -0
  29. package/src/auth/otp-seal.ts +84 -0
  30. package/src/auth/schema.ts +3 -0
  31. package/src/auth/sessions.ts +26 -27
  32. package/src/auth/tables.ts +4 -0
  33. package/src/auth/verification.ts +61 -1
  34. package/src/cli/dev-app-runner.ts +4 -0
  35. package/src/cli/docker.ts +4 -1
  36. package/src/cli/load-config.images.test.ts +4 -0
  37. package/src/cli/load-config.ts +3 -0
  38. package/src/cli/registry.ts +1 -1
  39. package/src/console/server/operator-db.ts +34 -9
  40. package/src/docker/compose.ts +162 -6
  41. package/src/docker/derive.ts +60 -3
  42. package/src/docker/docker.test.ts +374 -1
  43. package/src/docker/helpers.ts +2 -0
  44. package/src/docker/index.ts +11 -0
  45. package/src/docker/recipes/caddy.ts +51 -0
  46. package/src/docker/recipes/dragonfly.ts +31 -0
  47. package/src/docker/recipes/index.ts +25 -2
  48. package/src/docker/recipes/pgdog.ts +84 -0
  49. package/src/docker/recipes/redis.ts +6 -3
  50. package/src/docker/recipes/traefik.ts +83 -0
  51. package/src/docker/recipes/valkey.ts +30 -0
  52. package/src/docker/stack-id.ts +5 -0
  53. package/src/docker/types.ts +18 -0
  54. package/src/drivers/channel-taqnyat-whatsapp.ts +94 -0
  55. package/src/drivers/channel-types.ts +1 -0
  56. package/src/elements/channel/otp-delivery.test.ts +76 -0
  57. package/src/elements/channel/otp-delivery.ts +291 -0
  58. package/src/elements/channel/runtime.ts +152 -114
  59. package/src/elements/channel.ts +12 -2
  60. package/src/index.ts +3 -0
  61. package/src/kernel/app.ts +60 -4
  62. package/src/kernel/boot-bind/channel.ts +51 -0
  63. package/src/kernel/boot-bind/gate.ts +14 -19
  64. package/src/kernel/boot-bind/honor-config.test.ts +18 -0
  65. package/src/kernel/boot-bind/signal.ts +20 -0
  66. package/src/kernel/boot-bind/store.test.ts +82 -0
  67. package/src/kernel/boot-bind/store.ts +22 -0
  68. package/src/kernel/boot.test.ts +5 -5
  69. package/src/kernel/fx.test.ts +3 -0
  70. package/src/kernel/fx.ts +49 -0
  71. package/src/kernel/graceful-shutdown.test.ts +76 -0
  72. package/src/kernel/graceful-shutdown.ts +106 -0
  73. package/src/kernel/horizontal-child.ts +257 -0
  74. package/src/kernel/horizontal.integration.test.ts +229 -0
  75. package/src/kernel/index.ts +8 -0
  76. package/src/kernel/ready.test.ts +76 -0
  77. package/src/plugins/auth-delivery.mailpit.integration.test.ts +5 -5
  78. package/src/plugins/auth-methods.security.test.ts +19 -29
  79. package/src/plugins/auth-methods.test.ts +7 -6
  80. package/src/plugins/index.ts +12 -8
  81. package/src/plugins/magic-link.ts +1 -23
  82. package/src/plugins/otp.test.ts +236 -0
  83. package/src/plugins/otp.ts +570 -0
  84. package/src/plugins/taqnyat.live.test.ts +4 -6
  85. package/src/release/official-plugins.ts +1 -2
  86. package/site/content/docs/plugins/email-otp.mdx +0 -117
  87. package/site/content/docs/plugins/phone-number.mdx +0 -172
  88. package/src/plugins/email-otp.ts +0 -214
  89. package/src/plugins/phone-number.ts +0 -206
@@ -5,6 +5,10 @@
5
5
  * 2. `compose.<role>.yml` — per-role services (generated)
6
6
  * 3. `compose.prod.yml` — prod overlays (generated when `--prod`)
7
7
  * 4. `compose.override.yml` — user-owned; oke never writes it
8
+ *
9
+ * Also emits {@link COMPOSE_ALL} — layers 1–3 deep-merged into one file
10
+ * (for Swarm `stack deploy -c` and single-file preferrers). Not part of the
11
+ * `-f` merge order; never includes layer 4.
8
12
  */
9
13
 
10
14
  import { defaultHostPort, envPrefix, serviceNameFor, toYaml } from "./helpers.ts";
@@ -24,6 +28,12 @@ import { APP_PORT } from "../runtime/types.ts";
24
28
  /** Canonical layer-4 filename — never written by derivation. */
25
29
  export const COMPOSE_OVERRIDE = "compose.override.yml";
26
30
 
31
+ /**
32
+ * Fully merged compose (layers 1–3) — additive single-file alternative.
33
+ * Does not replace or shadow the base {@code compose.yml} layer.
34
+ */
35
+ export const COMPOSE_ALL = "compose.all.yml";
36
+
27
37
  /**
28
38
  * Relative path refs for compose files living under {@link DeriveOptions.composeDir}.
29
39
  *
@@ -85,6 +95,8 @@ export function emitComposeLayers(
85
95
  const includeApp = options.includeApp !== false;
86
96
  const paths = composePathRefs(options.composeDir ?? DEFAULT_DOCKER_DIR);
87
97
  const files: GeneratedFile[] = [];
98
+ /** Layers 1–3 as objects — same merge Compose would apply via `-f` order. */
99
+ const mergeLayers: Record<string, unknown>[] = [];
88
100
 
89
101
  // Layer 1 — project name + network (+ optional app for deploy / oke docker)
90
102
  const base: Record<string, unknown> = {
@@ -92,21 +104,29 @@ export function emitComposeLayers(
92
104
  networks: { oke: { driver: "bridge" } },
93
105
  };
94
106
  if (includeApp) {
107
+ // `image` + `build`: local compose builds and tags; Swarm stack deploy
108
+ // ignores `build` and pulls/uses the pre-built tag.
109
+ // When an opt-in `proxy` role is present, the edge publishes 80/443 —
110
+ // leave `app` unpublished so `docker compose up --scale app=N` works.
111
+ const hasProxy = specs.some((s) => s.role === "proxy");
112
+ const backendDeps = specs.filter((s) => s.role !== "proxy");
95
113
  base.services = {
96
114
  app: {
115
+ image: `oke-${app}:latest`,
97
116
  build: {
98
117
  context: paths.buildContext,
99
118
  dockerfile: paths.dockerfile,
100
119
  },
101
- ports: [`${appPort}:${appPort}`],
120
+ ...(hasProxy ? {} : { ports: [`${appPort}:${appPort}`] }),
102
121
  env_file: [paths.envFile],
103
122
  depends_on: Object.fromEntries(
104
- specs.map((s) => [s.serviceName, { condition: "service_healthy" }]),
123
+ backendDeps.map((s) => [s.serviceName, { condition: "service_healthy" }]),
105
124
  ),
106
125
  networks: ["oke"],
107
126
  },
108
127
  };
109
128
  }
129
+ mergeLayers.push(base);
110
130
  files.push({ path: "compose.yml", content: `${toYaml(base)}\n` });
111
131
 
112
132
  // Layer 2 — per-role
@@ -134,26 +154,67 @@ export function emitComposeLayers(
134
154
  if (applied.healthcheck) service.healthcheck = applied.healthcheck;
135
155
  if (applied.volumes) service.volumes = applied.volumes;
136
156
  if (applied.user) service.user = applied.user;
157
+ if (applied.ulimits) service.ulimits = applied.ulimits;
158
+ if (applied.labels) service.labels = applied.labels;
159
+ if (applied.dependsOn) {
160
+ const deps = { ...applied.dependsOn };
161
+ if (!includeApp) delete deps.app;
162
+ if (Object.keys(deps).length > 0) service.depends_on = deps;
163
+ }
137
164
 
138
- const namedVolumes = namedVolumeDecls(applied.volumes);
165
+ const namedVolumes = namedVolumeDecls([
166
+ ...(applied.volumes ?? []),
167
+ ...extraServiceVolumes(applied.services),
168
+ ]);
139
169
  const doc: Record<string, unknown> = {
140
- services: { [spec.serviceName]: service },
170
+ services: { [spec.serviceName]: service, ...applied.services },
141
171
  networks: { oke: { external: false } },
142
172
  };
143
173
  if (Object.keys(namedVolumes).length > 0) {
144
174
  doc.volumes = namedVolumes;
145
175
  }
176
+ mergeLayers.push(doc);
146
177
  const path = `compose.${spec.role}.yml`;
147
178
  files.push({ path, content: `${toYaml(doc)}\n` });
148
179
  }
149
180
 
150
- // Layer 3 — prod overlay
181
+ // Layer 3 — prod overlay (Swarm-aware deploy + app readiness healthcheck)
151
182
  if (options.prod) {
152
183
  const prodServices: Record<string, unknown> = {};
153
184
  if (includeApp) {
154
185
  prodServices.app = {
186
+ // Single Docker HEALTHCHECK — Swarm has no separate readiness/liveness.
187
+ // Prefer kernel readiness so the routing mesh and rolling updates wait
188
+ // out booting / orphan_scan (see GET /_/ready). App GET /health stays
189
+ // for external monitors only.
190
+ healthcheck: {
191
+ test: [
192
+ "CMD",
193
+ "bun",
194
+ "-e",
195
+ `fetch("http://127.0.0.1:${appPort}/_/ready").then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))`,
196
+ ],
197
+ interval: "10s",
198
+ timeout: "3s",
199
+ retries: 3,
200
+ start_period: "60s",
201
+ },
202
+ // Match installGracefulShutdown lease release window (≥ Signal TTL).
203
+ stop_grace_period: "30s",
155
204
  deploy: {
156
205
  replicas: 1,
206
+ update_config: {
207
+ parallelism: 1,
208
+ delay: "10s",
209
+ failure_action: "rollback",
210
+ order: "start-first",
211
+ },
212
+ restart_policy: {
213
+ condition: "on-failure",
214
+ delay: "5s",
215
+ max_attempts: 3,
216
+ window: "120s",
217
+ },
157
218
  resources: {
158
219
  limits: { cpus: "1.0", memory: "512M" },
159
220
  },
@@ -171,12 +232,21 @@ export function emitComposeLayers(
171
232
  secrets: secretNames(spec),
172
233
  };
173
234
  }
235
+ const prodDoc = { services: prodServices };
236
+ mergeLayers.push(prodDoc);
174
237
  files.push({
175
238
  path: "compose.prod.yml",
176
- content: `${toYaml({ services: prodServices })}\n`,
239
+ content: `${toYaml(prodDoc)}\n`,
177
240
  });
178
241
  }
179
242
 
243
+ // Additive single-file merge of layers 1–3 (not in `-f` order; no layer 4).
244
+ const merged = mergeLayers.reduce<Record<string, unknown>>(
245
+ (acc, layer) => deepMergeCompose(acc, layer),
246
+ {},
247
+ );
248
+ files.push({ path: COMPOSE_ALL, content: `${toYaml(merged)}\n` });
249
+
180
250
  // Layer 4 — never written. Document merge order only.
181
251
  const composeFiles = [
182
252
  "compose.yml",
@@ -188,6 +258,33 @@ export function emitComposeLayers(
188
258
  return { files, composeFiles };
189
259
  }
190
260
 
261
+ /**
262
+ * Deep-merge compose documents the way Docker Compose merges `-f` overlays:
263
+ * maps recurse; sequences and scalars from the later document replace earlier.
264
+ *
265
+ * @param base - Earlier layer
266
+ * @param overlay - Later layer
267
+ */
268
+ export function deepMergeCompose(
269
+ base: Record<string, unknown>,
270
+ overlay: Record<string, unknown>,
271
+ ): Record<string, unknown> {
272
+ const out: Record<string, unknown> = { ...base };
273
+ for (const [key, value] of Object.entries(overlay)) {
274
+ const prev = out[key];
275
+ if (isPlainObject(prev) && isPlainObject(value)) {
276
+ out[key] = deepMergeCompose(prev, value);
277
+ } else {
278
+ out[key] = value;
279
+ }
280
+ }
281
+ return out;
282
+ }
283
+
284
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
285
+ return value !== null && typeof value === "object" && !Array.isArray(value);
286
+ }
287
+
191
288
  /**
192
289
  * Build docker env key/value map (credentials + connection URLs).
193
290
  *
@@ -227,6 +324,19 @@ export function buildStackEnv(
227
324
  env[`${prefix}_DB`] = spec.credentials.database;
228
325
  env[`${prefix}_URL`] = url;
229
326
  env.DATABASE_URL = url;
327
+ } else if (spec.role === "pgdog") {
328
+ // URL uses store.sql credentials (same user/db the pooler proxies).
329
+ const sql = specs.find((s) => s.role === "store.sql");
330
+ const creds = sql?.credentials ?? spec.credentials;
331
+ const poolUrl = recipe.url(spec, {
332
+ host,
333
+ port: spec.hostPort,
334
+ user: creds.user,
335
+ password: creds.password,
336
+ database: creds.database,
337
+ });
338
+ env[`${prefix}_URL`] = poolUrl;
339
+ env.OKE_PGDOG_URL = poolUrl;
230
340
  } else if (spec.role === "store.kv") {
231
341
  env[`${prefix}_PASSWORD`] = spec.credentials.password;
232
342
  env[`${prefix}_URL`] = url;
@@ -259,6 +369,10 @@ export function buildStackEnv(
259
369
  // Ollama: standalone HTTP URL; model is a stack control (OKE_AI_MODEL).
260
370
  env[`${prefix}_URL`] = url;
261
371
  env.OKE_AI_URL = url;
372
+ } else if (spec.role === "proxy") {
373
+ // Edge TLS terminator — host is the public URL, not a driver DSN.
374
+ env[`${prefix}_URL`] = url;
375
+ env.OKE_PROXY_URL = url;
262
376
  } else {
263
377
  env[`${prefix}_USER`] = spec.credentials.user;
264
378
  env[`${prefix}_PASSWORD`] = spec.credentials.password;
@@ -266,6 +380,21 @@ export function buildStackEnv(
266
380
  env[`${prefix}_URL`] = url;
267
381
  }
268
382
  }
383
+ // When PgDog sits in front of Postgres, apps talk to the pooler — wire-protocol
384
+ // transparent; zero application changes (Bun.SQL / Drizzle / any Postgres client).
385
+ const sqlSpec = specs.find((s) => s.role === "store.sql");
386
+ const pgdogSpec = specs.find((s) => s.role === "pgdog");
387
+ if (sqlSpec && pgdogSpec) {
388
+ const poolRecipe = recipeFor(pgdogSpec.image, recipes);
389
+ env.DATABASE_URL = poolRecipe.url(pgdogSpec, {
390
+ host,
391
+ port: pgdogSpec.hostPort,
392
+ user: sqlSpec.credentials.user,
393
+ password: sqlSpec.credentials.password,
394
+ database: sqlSpec.credentials.database,
395
+ });
396
+ }
397
+
269
398
  for (const key of STACK_CONTROL_KEYS) {
270
399
  const value = controls[key];
271
400
  if (value !== undefined) env[key] = value;
@@ -276,6 +405,7 @@ export function buildStackEnv(
276
405
  /** Friendly section titles for known compose roles. */
277
406
  const ROLE_SECTION_TITLE: Readonly<Record<string, string>> = {
278
407
  "store.sql": "store.sql — Postgres",
408
+ pgdog: "pgdog — connection pooler (in front of Postgres)",
279
409
  "store.kv": "store.kv — Redis",
280
410
  "store.files": "store.files — object storage (S3)",
281
411
  "store.index": "store.index — search index",
@@ -283,11 +413,13 @@ const ROLE_SECTION_TITLE: Readonly<Record<string, string>> = {
283
413
  signal: "signal — message bus",
284
414
  vault: "vault — OpenBao",
285
415
  ai: "ai — Ollama (local models)",
416
+ proxy: "proxy — TLS terminator (Caddy / Traefik)",
286
417
  };
287
418
 
288
419
  /** Friendly aliases emitted beside their role block. */
289
420
  const ROLE_ALIASES: Readonly<Record<string, readonly string[]>> = {
290
421
  "store.sql": ["DATABASE_URL", "PGDATA", "POSTGRES_INITDB_ARGS"],
422
+ pgdog: ["OKE_PGDOG_URL"],
291
423
  "store.kv": ["REDIS_URL", "OKE_STORE_KV_MAXMEMORY", "OKE_STORE_KV_MAXMEMORY_POLICY"],
292
424
  "store.files": [
293
425
  "S3_ACCESS_KEY_ID",
@@ -311,6 +443,7 @@ const ROLE_ALIASES: Readonly<Record<string, readonly string[]>> = {
311
443
  "MP_SMTP_AUTH_ALLOW_INSECURE",
312
444
  ],
313
445
  ai: ["OKE_AI_URL", "OKE_AI_MODEL"],
446
+ proxy: ["OKE_PROXY_HOST", "OKE_PROXY_ACME_EMAIL"],
314
447
  };
315
448
 
316
449
  /** Optional controls documented in `.env.docker` and preserved on regeneration. */
@@ -327,6 +460,7 @@ const ROLE_CONTROL_EXAMPLES: Readonly<Record<string, readonly string[]>> = {
327
460
  ],
328
461
  // qwen3.5:9b is a balanced local-dev starting point — override freely.
329
462
  ai: ["OKE_AI_MODEL=qwen3.5:9b"],
463
+ proxy: ["OKE_PROXY_HOST=localhost", "OKE_PROXY_ACME_EMAIL=admin@example.com"],
330
464
  };
331
465
 
332
466
  /**
@@ -344,6 +478,9 @@ function roleFromEnvKey(key: string): string | undefined {
344
478
  if (key === "PGDATA" || key === "POSTGRES_INITDB_ARGS") return "store.sql";
345
479
  if (key.startsWith("OKE_STORE_KV_MAXMEMORY")) return "store.kv";
346
480
  if (key === "OKE_AI_URL" || key === "OKE_AI_MODEL" || key === "OLLAMA_HOST") return "ai";
481
+ if (key === "OKE_PROXY_URL" || key === "OKE_PROXY_HOST" || key === "OKE_PROXY_ACME_EMAIL") {
482
+ return "proxy";
483
+ }
347
484
  return undefined;
348
485
  }
349
486
 
@@ -451,6 +588,25 @@ function namedVolumeDecls(
451
588
  return out;
452
589
  }
453
590
 
591
+ /**
592
+ * Volume mounts declared on companion services from {@link RecipeApplyResult.services}.
593
+ *
594
+ * @param services - Extra compose service fragments
595
+ */
596
+ function extraServiceVolumes(
597
+ services: Readonly<Record<string, Record<string, unknown>>> | undefined,
598
+ ): string[] {
599
+ const out: string[] = [];
600
+ for (const svc of Object.values(services ?? {})) {
601
+ const vols = svc.volumes;
602
+ if (!Array.isArray(vols)) continue;
603
+ for (const v of vols) {
604
+ if (typeof v === "string") out.push(v);
605
+ }
606
+ }
607
+ return out;
608
+ }
609
+
454
610
  function escapeEnv(value: string): string {
455
611
  if (/[\s#"'$\\]/.test(value)) {
456
612
  return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
@@ -11,14 +11,17 @@ import {
11
11
  formatStackEnv,
12
12
  } from "./compose.ts";
13
13
  import { emitDockerfile } from "./dockerfile.ts";
14
+ import { buildCaddyfile } from "./recipes/caddy.ts";
15
+ import { buildPgDogToml, buildPgDogUsersToml } from "./recipes/pgdog.ts";
14
16
  import type { DeriveOptions, DeriveResult, GeneratedFile } from "./types.ts";
15
17
  import { DEFAULT_DOCKER_DIR } from "./types.ts";
18
+ import { APP_PORT } from "../runtime/types.ts";
16
19
 
17
20
  /**
18
21
  * Derive infrastructure files from normalised image pins.
19
22
  *
20
- * Credentials land only in the returned `stackEnv` (for `.env.docker`);
21
- * generated YAML never contains cleartext secrets. Layer 4
23
+ * Credentials land only in the returned `stackEnv` (for `.env.docker`) and
24
+ * in `users.toml` when PgDog is present — never in generated YAML. Layer 4
22
25
  * (`compose.override.yml`) is listed in `composeFiles` but never written.
23
26
  *
24
27
  * @param options - Images / app / prod flag
@@ -43,7 +46,12 @@ export function deriveInfrastructure(options: DeriveOptions): DeriveResult {
43
46
  content: emitDockerfile({ appPort: normalised.appPort }),
44
47
  };
45
48
  // Always emit Dockerfile for deploy; stack-only runs ignore it.
46
- const files = [dockerfile, ...composeFilesContent];
49
+ const files = [
50
+ dockerfile,
51
+ ...composeFilesContent,
52
+ ...pgdogConfigFiles(specs),
53
+ ...proxyConfigFiles(specs, normalised.appPort),
54
+ ];
47
55
 
48
56
  for (const f of files) {
49
57
  if (f.path.endsWith(".yml") || f.path === "Dockerfile") {
@@ -65,6 +73,55 @@ export function deriveInfrastructure(options: DeriveOptions): DeriveResult {
65
73
  return { specs, files, stackEnv, composeFiles };
66
74
  }
67
75
 
76
+ /**
77
+ * Emit PgDog TOML configs when both `pgdog` and `store.sql` are in the stack.
78
+ *
79
+ * `pgdog.toml` has no secrets; `users.toml` mirrors store.sql credentials
80
+ * (same trust boundary as `.env.docker` — do not commit).
81
+ *
82
+ * @param specs - Normalised services
83
+ */
84
+ function pgdogConfigFiles(specs: DeriveResult["specs"]): GeneratedFile[] {
85
+ const sql = specs.find((s) => s.role === "store.sql");
86
+ const pooler = specs.find((s) => s.role === "pgdog");
87
+ if (!sql || !pooler) return [];
88
+ return [
89
+ {
90
+ path: "pgdog.toml",
91
+ content: buildPgDogToml({ database: sql.credentials.database }),
92
+ },
93
+ {
94
+ path: "users.toml",
95
+ content: buildPgDogUsersToml({
96
+ user: sql.credentials.user,
97
+ password: sql.credentials.password,
98
+ database: sql.credentials.database,
99
+ }),
100
+ },
101
+ ];
102
+ }
103
+
104
+ /**
105
+ * Emit proxy companion configs when `images.proxy` is pinned.
106
+ *
107
+ * Caddy gets a generated `Caddyfile`. Traefik configures via Docker labels
108
+ * (no companion file).
109
+ *
110
+ * @param specs - Normalised services
111
+ * @param appPort - App listen port (default 6530)
112
+ */
113
+ function proxyConfigFiles(
114
+ specs: DeriveResult["specs"],
115
+ appPort: number | undefined,
116
+ ): GeneratedFile[] {
117
+ const proxy = specs.find((s) => s.role === "proxy");
118
+ if (!proxy) return [];
119
+ if (/caddy/i.test(proxy.image)) {
120
+ return [{ path: "Caddyfile", content: buildCaddyfile({ appPort: appPort ?? APP_PORT }) }];
121
+ }
122
+ return [];
123
+ }
124
+
68
125
  /**
69
126
  * Write derived files to disk. Never writes `compose.override.yml` or
70
127
  * credential values into YAML. Optionally writes `docker/.env.docker`.