@podosoft/podokit 1.5.0 → 1.6.1

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 (215) hide show
  1. package/README.md +2 -1
  2. package/dist/app-proxy-timeouts.d.ts +27 -0
  3. package/dist/app-proxy-timeouts.js +180 -0
  4. package/dist/assemble.d.ts +5 -2
  5. package/dist/assemble.js +14 -4
  6. package/dist/deploy-checks.d.ts +44 -0
  7. package/dist/deploy-checks.js +267 -0
  8. package/dist/deploy-compose-profile.d.ts +6 -2
  9. package/dist/deploy-compose-profile.js +15 -6
  10. package/dist/deploy-compose-render.d.ts +12 -4
  11. package/dist/deploy-compose-render.js +47 -18
  12. package/dist/deploy-compose.d.ts +2 -5
  13. package/dist/deploy-compose.js +7 -2
  14. package/dist/deploy-profile.d.ts +6 -2
  15. package/dist/deploy-profile.js +11 -7
  16. package/dist/deploy-render.d.ts +4 -1
  17. package/dist/deploy-render.js +27 -21
  18. package/dist/deploy-schema.d.ts +60 -2
  19. package/dist/deploy-schema.js +141 -14
  20. package/dist/deploy.d.ts +2 -5
  21. package/dist/deploy.js +5 -2
  22. package/dist/dev.d.ts +21 -0
  23. package/dist/dev.js +78 -3
  24. package/dist/eject.js +2 -0
  25. package/dist/index.js +85 -5
  26. package/dist/lib.d.ts +2 -0
  27. package/dist/lib.js +5 -2
  28. package/dist/lockfile.d.ts +24 -0
  29. package/dist/lockfile.js +74 -4
  30. package/dist/provider.js +10 -5
  31. package/dist/remove.js +1 -1
  32. package/dist/template-files.d.ts +42 -0
  33. package/dist/template-files.js +102 -0
  34. package/dist/templates/fullstack/AGENTS.md +30 -4
  35. package/dist/templates/fullstack/README.md +8 -7
  36. package/dist/templates/fullstack/apps/api/package.json +4 -4
  37. package/dist/templates/fullstack/apps/api/scripts/build.extensions.mjs +6 -0
  38. package/dist/templates/fullstack/apps/api/scripts/build.mjs +65 -16
  39. package/dist/templates/fullstack/apps/api/scripts/run-migrations.mjs +7 -3
  40. package/dist/templates/fullstack/apps/api/scripts/verify-api-contract.ts +7 -2
  41. package/dist/templates/fullstack/apps/api/src/app.spec.ts +22 -0
  42. package/dist/templates/fullstack/apps/api/src/app.ts +41 -25
  43. package/dist/templates/fullstack/apps/api/src/build.spec.ts +121 -0
  44. package/dist/templates/fullstack/apps/api/src/config/env.extensions.ts +20 -0
  45. package/dist/templates/fullstack/apps/api/src/config/env.validation.spec.ts +76 -0
  46. package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +18 -3
  47. package/dist/templates/fullstack/apps/api/src/config/provider-selection.spec.ts +53 -0
  48. package/dist/templates/fullstack/apps/api/src/config/provider-selection.ts +79 -0
  49. package/dist/templates/fullstack/apps/api/src/config/providers.ts +7 -3
  50. package/dist/templates/fullstack/apps/api/src/core/api-contract.extensions.ts +6 -0
  51. package/dist/templates/fullstack/apps/api/src/core/api-contract.spec.ts +72 -4
  52. package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +78 -6
  53. package/dist/templates/fullstack/apps/api/src/core/services.ts +13 -0
  54. package/dist/templates/fullstack/apps/api/src/database/data-source.spec.ts +27 -0
  55. package/dist/templates/fullstack/apps/api/src/database/data-source.ts +7 -10
  56. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +12 -0
  57. package/dist/templates/fullstack/apps/api/src/database/database.ts +2 -3
  58. package/dist/templates/fullstack/apps/api/src/database/entities.ts +12 -0
  59. package/dist/templates/fullstack/apps/api/src/database/migrate.ts +12 -2
  60. package/dist/templates/fullstack/apps/api/src/database/migration-files.spec.ts +116 -0
  61. package/dist/templates/fullstack/apps/api/src/database/migration-files.ts +134 -0
  62. package/dist/templates/fullstack/apps/api/src/database/migration-readiness.spec.ts +36 -0
  63. package/dist/templates/fullstack/apps/api/src/database/migration-readiness.ts +25 -0
  64. package/dist/templates/fullstack/apps/api/src/database/run-migrations.spec.ts +12 -1
  65. package/dist/templates/fullstack/apps/api/src/database/sqlite-migrator.ts +2 -36
  66. package/dist/templates/fullstack/apps/api/src/main.ts +2 -2
  67. package/dist/templates/fullstack/apps/api/tsconfig.json +2 -0
  68. package/dist/templates/fullstack/apps/web/package.json +1 -1
  69. package/dist/templates/fullstack/apps/web/src/app.css +3 -0
  70. package/dist/templates/fullstack/apps/web/src/app.extensions.css +11 -0
  71. package/dist/templates/fullstack/apps/web/src/lib/components/site-runtime.svelte +3 -1
  72. package/dist/templates/fullstack/apps/web/src/lib/server/api.test.ts +19 -0
  73. package/dist/templates/fullstack/apps/web/src/lib/server/api.ts +4 -1
  74. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +79 -0
  75. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +55 -1
  76. package/dist/templates/fullstack/apps/web/src/lib/server/proxy-timeouts.ts +2 -1
  77. package/dist/templates/fullstack/apps/web/tsconfig.json +1 -1
  78. package/dist/templates/fullstack/apps/web/vite.config.test.ts +33 -0
  79. package/dist/templates/fullstack/apps/web/vite.config.ts +55 -13
  80. package/dist/templates/fullstack/apps/web/vite.extensions.ts +23 -0
  81. package/dist/templates/fullstack/compose.dev.yaml +7 -4
  82. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/SKILL.md +2 -2
  83. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +13 -3
  84. package/dist/templates/fullstack/dot-claude/skills/podokit-update/SKILL.md +5 -2
  85. package/dist/templates/fullstack/dot-env.docker.example +5 -2
  86. package/dist/templates/fullstack/dot-env.example +4 -0
  87. package/dist/templates/fullstack/dot-podokit/dev.json +1 -1
  88. package/dist/templates/fullstack/infra/k3s/configmap.yaml +1 -0
  89. package/dist/templates/fullstack/tests/README.md +50 -1
  90. package/dist/templates/fullstack/tests/helpers/app-paths.ts +18 -0
  91. package/dist/templates/fullstack/tests/helpers/paths.ts +33 -0
  92. package/dist/templates/fullstack/tests/helpers/seeded-admin.ts +15 -0
  93. package/dist/templates/fullstack/tests/package.json +3 -3
  94. package/dist/templates/fullstack/tests/playwright.config.ts +10 -4
  95. package/dist/templates/fullstack/tests/playwright.extensions.ts +149 -1
  96. package/dist/templates/fullstack/tests/run-e2e.ts +28 -0
  97. package/dist/templates/fullstack/tests/ui/smoke.ui.spec.ts +5 -4
  98. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.test.ts +37 -1
  99. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +15 -6
  100. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flag-settings.test.ts +31 -0
  101. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flag-settings.ts +31 -0
  102. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flags.ts +12 -0
  103. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-nav.ts +11 -0
  104. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/nav.test.ts +53 -0
  105. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/nav.ts +35 -0
  106. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/registry.svelte.ts +15 -0
  107. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +11 -1
  108. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/admin-sidebar.svelte +2 -23
  109. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.svelte +7 -2
  110. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.test.ts +30 -0
  111. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/impersonation-banner.svelte +3 -2
  112. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/settings/app-flags-settings.svelte +65 -0
  113. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.svelte +32 -23
  114. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.test.ts +27 -0
  115. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-footer.svelte +4 -1
  116. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-runtime.svelte +3 -1
  117. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/theme-toggle.svelte +1 -1
  118. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +6 -0
  119. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +6 -0
  120. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/app-hooks.ts +11 -0
  121. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/non-indexable-paths.ts +8 -0
  122. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/search-indexing.test.ts +31 -0
  123. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/search-indexing.ts +5 -1
  124. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.svelte +1 -1
  125. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.server.ts +2 -1
  126. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.svelte +1 -5
  127. package/dist/templates/modules/admin-dashboard/files/dot-claude/skills/podokit-data-table/SKILL.md +2 -0
  128. package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +1 -1
  129. package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +159 -170
  130. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +72 -81
  131. package/dist/templates/modules/admin-dashboard/files/tests/api/email.api.spec.ts +1 -6
  132. package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +1 -1
  133. package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +43 -53
  134. package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +3 -1
  135. package/dist/templates/modules/admin-dashboard/files/tests/helpers/accounts.ts +24 -6
  136. package/dist/templates/modules/admin-dashboard/files/tests/helpers/app-accounts.ts +24 -0
  137. package/dist/templates/modules/admin-dashboard/files/tests/helpers/devices.ts +41 -11
  138. package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +5 -3
  139. package/dist/templates/modules/admin-dashboard/files/tests/helpers/hydration.ts +13 -4
  140. package/dist/templates/modules/admin-dashboard/files/tests/helpers/mailpit.ts +3 -9
  141. package/dist/templates/modules/admin-dashboard/files/tests/helpers/sms.ts +2 -4
  142. package/dist/templates/modules/admin-dashboard/files/tests/playwright.config.ts +14 -6
  143. package/dist/templates/modules/admin-dashboard/files/tests/ui/account-flows.ui.spec.ts +4 -5
  144. package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +55 -55
  145. package/dist/templates/modules/admin-dashboard/files/tests/ui/app-flags.ui.spec.ts +63 -0
  146. package/dist/templates/modules/admin-dashboard/files/tests/ui/appearance.ui.spec.ts +12 -16
  147. package/dist/templates/modules/admin-dashboard/files/tests/ui/audit.ui.spec.ts +3 -2
  148. package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +32 -35
  149. package/dist/templates/modules/admin-dashboard/files/tests/ui/dashboard.ui.spec.ts +4 -2
  150. package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +1 -2
  151. package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +1 -2
  152. package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +82 -91
  153. package/dist/templates/modules/admin-dashboard/files/tests/ui/guards.ui.spec.ts +6 -2
  154. package/dist/templates/modules/admin-dashboard/files/tests/ui/i18n.ui.spec.ts +16 -20
  155. package/dist/templates/modules/admin-dashboard/files/tests/ui/impersonation.ui.spec.ts +6 -3
  156. package/dist/templates/modules/admin-dashboard/files/tests/ui/login-methods.ui.spec.ts +2 -3
  157. package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +2 -4
  158. package/dist/templates/modules/admin-dashboard/files/tests/ui/organizations.ui.spec.ts +5 -2
  159. package/dist/templates/modules/admin-dashboard/files/tests/ui/passkey.ui.spec.ts +7 -2
  160. package/dist/templates/modules/admin-dashboard/files/tests/ui/same-origin-requests.ui.spec.ts +89 -0
  161. package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +5 -4
  162. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +67 -73
  163. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +84 -93
  164. package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +8 -7
  165. package/dist/templates/modules/admin-dashboard/files/tests/ui/theme-mode.ui.spec.ts +35 -0
  166. package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +51 -54
  167. package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +10 -9
  168. package/dist/templates/modules/admin-dashboard/module.manifest.json +7 -2
  169. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.module.ts +5 -1
  170. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +17 -0
  171. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +18 -0
  172. package/dist/templates/modules/audit-log/module.manifest.json +15 -3
  173. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-app-name.spec.ts +21 -0
  174. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +13 -1
  175. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-environment.ts +9 -2
  176. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +19 -3
  177. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +2 -0
  178. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.runtime.spec.ts +10 -0
  179. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +7 -5
  180. package/dist/templates/modules/auth/files/apps/api/src/auth/schema-recheck.spec.ts +86 -0
  181. package/dist/templates/modules/auth/files/apps/api/src/auth/schema-recheck.ts +44 -0
  182. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +2 -1
  183. package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +3 -1
  184. package/dist/templates/modules/auth/files/apps/api/src/settings/app-flags.ts +9 -0
  185. package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.spec.ts +43 -0
  186. package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +30 -5
  187. package/dist/templates/modules/auth/module.manifest.json +8 -2
  188. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +107 -1
  189. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +113 -9
  190. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/worker.module.spec.ts +40 -0
  191. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/worker.module.ts +52 -5
  192. package/dist/templates/modules/bullmq/files/apps/api/src/main-worker.ts +9 -7
  193. package/dist/templates/modules/bullmq/files/apps/api/src/worker.extensions.ts +13 -0
  194. package/dist/templates/modules/bullmq/module.manifest.json +5 -1
  195. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.spec.ts +2 -0
  196. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +174 -1
  197. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +150 -3
  198. package/dist/templates/modules/jobs-local/module.manifest.json +3 -0
  199. package/dist/templates/modules/logging/files/apps/api/src/logging/logging.module.spec.ts +16 -0
  200. package/dist/templates/modules/logging/files/apps/api/src/logging/logging.module.ts +15 -10
  201. package/dist/templates/modules/mailer/module.manifest.json +1 -1
  202. package/dist/templates/modules/object-storage-local/files/tests/api/storage-local.api.spec.ts +3 -6
  203. package/dist/templates/modules/object-storage-s3/files/infra/docker/minio.compose.yml +5 -4
  204. package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +3 -6
  205. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +45 -20
  206. package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +3 -6
  207. package/dist/templates/todo/AGENTS.md +5 -1
  208. package/dist/templates/todo/README.md +6 -4
  209. package/dist/text-diff.d.ts +12 -0
  210. package/dist/text-diff.js +111 -0
  211. package/dist/update.d.ts +61 -3
  212. package/dist/update.js +152 -27
  213. package/package.json +2 -2
  214. package/dist/templates/modules/admin-dashboard/files/tests/api/exclusive.api.spec.ts +0 -75
  215. package/dist/templates/modules/admin-dashboard/files/tests/helpers/exclusive.ts +0 -80
@@ -54,7 +54,20 @@ Merge added module settings from `.env.example` into an existing `.env`.
54
54
  `apps/web/src/lib/server/proxy-timeouts.ts` (`APP_PROXY_TIMEOUTS`, `null` for
55
55
  none); never edit the managed deadline. Both lists take path prefixes with
56
56
  `:name` parameters, such as `/api/hosts/:hostId/file-transfers`, and the most
57
- specific prefix wins.
57
+ specific prefix wins. The proxy and `serverApiClient` send the request's public
58
+ origin to the API in `x-forwarded-host`/`x-forwarded-proto` only when
59
+ `CORS_ORIGIN` lists it; an API route may use them to choose among its configured
60
+ origins, never as a new origin.
61
+ - `apps/web/src/app.css` is managed. Put application styles (theme variable
62
+ overrides, `@theme` entries, fonts, base styles, and `@source` for packages
63
+ whose built components use Tailwind classes) in the app-owned
64
+ `apps/web/src/app.extensions.css`, which `app.css` imports last.
65
+ - `apps/web/vite.config.ts` is managed. Put Vite options (warmup, `optimizeDeps`,
66
+ `define`, plugins, allowed hosts) in `appViteConfig` and SvelteKit options
67
+ (`alias`, `csrf`) in `appSvelteKitConfig` of the app-owned
68
+ `apps/web/vite.extensions.ts`. `bun run dev` proxies the exact
69
+ `webSocketPaths` of `.podokit/dev.json` to the API like the `podo dev` gateway,
70
+ so do not add a WebSocket proxy of your own.
58
71
  - Server-side API calls (`serverApiClient`) give up after `BACKEND_TIMEOUT_MS`
59
72
  (2000), and the proxy answers 504 `BACKEND_TIMEOUT` when the API sends no
60
73
  response headers within `BACKEND_PROXY_TIMEOUT_MS` (30000). Once the API's
@@ -86,7 +99,19 @@ Merge added module settings from `.env.example` into an existing `.env`.
86
99
  Better Auth schema before the application migrations. `{{apiRun}} migration:run` runs
87
100
  only the application migrations and never creates Better Auth tables;
88
101
  `{{apiRun}} migration:revert` reverts the latest application migration.
89
- - e2e (Playwright, ships in `tests/`): `{{rootRun}} test:e2e`.
102
+ - Name a migration `apps/api/src/migrations/<timestamp>-<Name>.ts` with exactly one exported
103
+ migration class. The build lists compiled migrations in `dist/migrations/migrations.json` and
104
+ fails when one is lost; `{{apiRun}} migrate:list` prints what a build would run.
105
+ - e2e (Playwright, ships in `tests/`): `{{rootRun}} test:e2e`. Run the whole suite on more
106
+ workers with `E2E_WORKERS=2`, never `--workers`. Tag a test `{ tag: "@exclusive" }` when
107
+ it changes state other tests rely on (a server-wide setting, a seeded account's profile);
108
+ tagged tests run alone after the rest, in a second Playwright run that `test:e2e` starts
109
+ even when the first one failed, so run the suite through `test:e2e`, not
110
+ `bunx playwright test`, on more workers. Give each test its own mail and SMS recipients and
111
+ never empty the shared Mailpit inbox or SMS sink. See `tests/README.md`. The managed specs
112
+ read the app's first screen, public pages, and seeded accounts from the owned
113
+ `tests/helpers/app-paths.ts` (and `app-accounts.ts` with admin-dashboard): change those
114
+ instead of editing or ejecting a managed spec, and test the app's own screens in specs of its own.
90
115
  - Playwright officially runs on Node, so `bunx playwright` respects its Node
91
116
  shebang. Node LTS is a test-tool dependency only; API, web, worker, migrations,
92
117
  builds, and unit tests run on Bun.
@@ -121,7 +146,7 @@ changes need an e2e/unit test.
121
146
  This project is managed by the `podo` CLI. Files have an ownership tier recorded
122
147
  in `.podokit/files.lock` — respect it:
123
148
 
124
- - **owned** (your code: `apps/web/src/routes/**`, your components/services, `apps/api/src/app.extensions.ts`, and this `AGENTS.md`) — edit freely. Add application-owned services or module descriptors through `app.extensions.ts` instead of changing toolkit registrations. Set Bun server options (`maxRequestBodySize`, `idleTimeout`) with its `apiServerOptions` export rather than editing `main.ts`; `API_MAX_REQUEST_BODY_SIZE` and `API_IDLE_TIMEOUT` apply when the export leaves them out.
149
+ - **owned** (your code: `apps/web/src/routes/**`, your components/services, `apps/api/src/app.extensions.ts`, and this `AGENTS.md`) — edit freely. Add application-owned services or module descriptors through `app.extensions.ts` instead of changing toolkit registrations. Set Bun server options (`maxRequestBodySize`, `idleTimeout`) with its `apiServerOptions` export rather than editing `main.ts`; `API_MAX_REQUEST_BODY_SIZE` and `API_IDLE_TIMEOUT` apply when the export leaves them out. Declare application environment variables in the owned `apps/api/src/config/env.extensions.ts` (`appEnvShape`, `refineAppEnv`) instead of editing `env.validation.ts`, and TypeORM entities in `APP_ENTITIES` of the owned `apps/api/src/database/entities.ts` instead of editing `data-source.ts`. List the operations your API must keep in `APP_DOCUMENTED_ROUTES` of the owned `apps/api/src/core/api-contract.extensions.ts`; `{{apiRun}} contract` checks they are registered and documented. With `auth`, declare app on/off settings in `APP_FLAG_DEFAULTS` of the owned `apps/api/src/settings/app-flags.ts` instead of editing `flag-defaults.ts`.
125
150
  - **assembled** (e.g. `apps/api/src/app.ts`, `auth.ts`) — contain `// podokit:begin:… / …:end` fenced regions that the toolkit recomputes. Edit outside the fences only; **never hand-edit inside the fences**.
126
151
  - **managed** (bootstrap/config like `main.ts`) — the toolkit may update these; avoid gratuitous edits.
127
152
 
@@ -140,7 +165,8 @@ Useful commands:
140
165
  - `podo add <module>` — add a feature (auth, admin-dashboard, redis, bullmq, …). It wires itself into the app; run `podo add` with no argument to list modules.
141
166
  - `podo remove <module>` — un-apply a module (inverse of add; refuses if another module needs it, keeps files you edited).
142
167
  - `podo provider list` / `podo provider set <capability> <provider> [--apply]` — inspect or switch database, cache, object storage, events, and jobs. Set is dry-run by default and never migrates or deletes provider data. Local providers require one API process.
143
- - `podo status` / `podo diff` — see your local edits vs. what PodoKit generated.
168
+ - `podo status` / `podo diff` — see your local edits vs. what PodoKit generated; `podo diff <path>` shows a file's line diff against the template, owned files included.
169
+ - `podo restore <path>` — rewrite a managed file from the template, or hand an ejected file back to PodoKit.
144
170
  - `podo update` — pull in template/module improvements (3-way merges your edits; never touches owned files).
145
171
  - `podo deploy` — plan, apply, verify, or roll back an immutable release on Kubernetes or one Docker host. Production API WebSocket paths belong in the deployment profile's exact `exposure.webSocketPaths` allowlist, and API routes that take bodies above the web server's `BODY_SIZE_LIMIT` (file uploads) in `exposure.apiPaths` (and `.podokit/dev.json` `apiPaths` for `podo dev`; `podo add` and `podo deploy init` fill in the paths modules declare), which reach the API without the web proxy, so the API must check their size, session, and `Origin` itself; Compose trusts forwarding headers only from `exposure.trustedProxyCidrs`. Tag `vX.Y.Z` to build and push the images (`.github/workflows/release.yml`; check its runner — GitHub-hosted minutes are free for public repositories only). Use `.agents/skills/podokit-deploy`.
146
172
  - `podo deploy sync` — copy local build output into a running Compose deployment and restart it, for iterating without a release. Not a release: no image is published and the next `apply` discards it. Use `.agents/skills/podokit-deploy-fast`.
@@ -55,8 +55,9 @@ cp .env.example .env
55
55
  ```
56
56
 
57
57
  Open the URL printed by the command; a new project defaults to
58
- **http://{{projectName}}.localhost**. The committed `.podokit/dev.json` is the
59
- source of truth if you change the hostname. The first running PodoKit project starts
58
+ **http://{{devHostname}}**. The committed `.podokit/dev.json` is the
59
+ source of truth if you change the hostname: `podo dev` sets `CORS_ORIGIN` and
60
+ `BETTER_AUTH_URL` for the web and API containers from it. The first running PodoKit project starts
60
61
  one user-level Traefik gateway on `127.0.0.1:80`; additional projects reuse it and
61
62
  route by that hostname. Even a single app uses
62
63
  the same topology, with one route and no project-specific host port.
@@ -188,11 +189,11 @@ HTTP, fix its public origin at build time instead:
188
189
  docker build --build-arg PODOKIT_BUILD_ORIGIN=https://example.com -f apps/web/Dockerfile -t {{projectName}}-web .
189
190
  ```
190
191
 
191
- For a Docker Compose deployment behind a trusted reverse proxy, set
192
- `PROTOCOL_HEADER=x-forwarded-proto` and `HOST_HEADER=x-forwarded-host` in the
193
- deployment profile runtime configuration and list the proxy's direct CIDR blocks in
194
- `exposure.trustedProxyCidrs`. Never trust client-controlled forwarded headers on a
195
- directly exposed container.
192
+ Set `exposure.edgeProxies` in the deployment profile to the number of reverse
193
+ proxies in front of the deployment; PodoKit derives which forwarded headers web and
194
+ API trust from it, and trusts none when it is `0`. With a Compose gateway, also list
195
+ the proxy's direct CIDR blocks in `exposure.trustedProxyCidrs`. Never count a proxy
196
+ that clients can bypass.
196
197
 
197
198
  If the production API exposes WebSocket endpoints, list their exact paths under
198
199
  `exposure.webSocketPaths` in the deployment profile. The Compose driver then keeps a
@@ -10,14 +10,15 @@
10
10
  "test": "bun test src",
11
11
  "contract": "bun scripts/verify-api-contract.ts",
12
12
  "migrate:all": "bun scripts/run-migrations.mjs",
13
- "typeorm": "bunx typeorm-ts-node-commonjs -d src/database/data-source.ts",
13
+ "migrate:list": "bun scripts/run-migrations.mjs --list",
14
+ "typeorm": "bunx --bun typeorm -d src/database/data-source.ts",
14
15
  "migration:run": "bun src/database/migrate.ts",
15
16
  "migration:revert": "bun src/database/migrate.ts --revert",
16
17
  "migration:generate": "bun run typeorm -- migration:generate"
17
18
  },
18
19
  "dependencies": {
19
- "@podosoft/podokit-contracts": "^0.5.0",
20
- "@podosoft/podokit-runtime": "^0.4.1",
20
+ "@podosoft/podokit-contracts": "^0.6.0",
21
+ "@podosoft/podokit-runtime": "^0.5.1",
21
22
  "@elysia/cors": "^1.4.2",
22
23
  "@elysia/openapi": "^1.4.15",
23
24
  "dotenv": "^16.4.5",
@@ -30,7 +31,6 @@
30
31
  "devDependencies": {
31
32
  "@types/bun": "^1.3.9",
32
33
  "@types/node": "^20.17.0",
33
- "ts-node": "^10.9.2",
34
34
  "typescript": "^6.0.3"
35
35
  }
36
36
  }
@@ -0,0 +1,6 @@
1
+ // This owned file is never overwritten by `podo update`. List entry points the
2
+ // API build bundles next to PodoKit's, such as a maintenance command run from
3
+ // the image. Each must exist. For example:
4
+ //
5
+ // export const appBuildTargets = [{ entrypoint: "scripts/repair-data.ts", outdir: "dist/scripts" }];
6
+ export const appBuildTargets = [];
@@ -1,4 +1,12 @@
1
- import { existsSync, readdirSync } from "node:fs";
1
+ import { existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import { appBuildTargets } from "./build.extensions.mjs";
5
+
6
+ // Bun inlines process.env.NODE_ENV as "development" unless told otherwise, which
7
+ // would make every production check in the bundle, its dependencies included,
8
+ // take the development branch. Keep it a read of the runtime environment.
9
+ const define = { "process.env.NODE_ENV": "process.env.NODE_ENV" };
2
10
 
3
11
  const targets = [
4
12
  { entrypoint: "src/main.ts", outdir: "dist" },
@@ -8,11 +16,20 @@ const targets = [
8
16
  { entrypoint: "src/bootstrap-admin-runtime.ts", outdir: "dist" },
9
17
  ].filter((target) => existsSync(target.entrypoint));
10
18
 
19
+ for (const target of appBuildTargets) {
20
+ if (!existsSync(target.entrypoint)) {
21
+ process.stderr.write(`Build failed: ${target.entrypoint} from build.extensions.mjs does not exist\n`);
22
+ process.exit(1);
23
+ }
24
+ targets.push(target);
25
+ }
26
+
11
27
  for (const target of targets) {
12
28
  const result = await Bun.build({
13
29
  entrypoints: [target.entrypoint],
14
30
  outdir: target.outdir,
15
31
  target: "bun",
32
+ define,
16
33
  });
17
34
  if (!result.success) {
18
35
  for (const log of result.logs) process.stderr.write(`${log}\n`);
@@ -20,20 +37,52 @@ for (const target of targets) {
20
37
  }
21
38
  }
22
39
 
23
- if (existsSync("src/migrations")) {
24
- const migrations = readdirSync("src/migrations")
25
- .filter((file) => /^\d+-.+\.ts$/.test(file))
26
- .map((file) => `src/migrations/${file}`);
27
- if (migrations.length > 0) {
28
- const result = await Bun.build({
29
- entrypoints: migrations,
30
- outdir: "dist/migrations",
31
- target: "bun",
32
- external: ["typeorm"],
33
- });
34
- if (!result.success) {
35
- for (const log of result.logs) process.stderr.write(`${log}\n`);
36
- process.exit(1);
37
- }
40
+ function fail(message) {
41
+ process.stderr.write(`Build migrations failed: ${message}\n`);
42
+ process.exit(1);
43
+ }
44
+
45
+ function isMigration(value) {
46
+ return typeof value === "function" &&
47
+ typeof value.prototype?.up === "function" &&
48
+ typeof value.prototype?.down === "function";
49
+ }
50
+
51
+ // Every source migration becomes dist/migrations/<file>.js, listed with its name
52
+ // in dist/migrations/migrations.json, which the migrators and the API read in
53
+ // production. A build that emits them anywhere else, or loses one, fails here
54
+ // instead of a deployment that runs no migrations.
55
+ const sources = existsSync("src/migrations")
56
+ ? readdirSync("src/migrations").filter((file) => /^\d+-.+\.ts$/.test(file)).sort()
57
+ : [];
58
+ rmSync("dist/migrations", { recursive: true, force: true });
59
+ mkdirSync("dist/migrations", { recursive: true });
60
+ if (sources.length > 0) {
61
+ const result = await Bun.build({
62
+ entrypoints: sources.map((file) => `src/migrations/${file}`),
63
+ outdir: "dist/migrations",
64
+ root: "src/migrations",
65
+ target: "bun",
66
+ define,
67
+ external: ["typeorm"],
68
+ });
69
+ if (!result.success) {
70
+ for (const log of result.logs) process.stderr.write(`${log}\n`);
71
+ process.exit(1);
38
72
  }
39
73
  }
74
+ const files = sources.map((file) => file.replace(/\.ts$/, ".js"));
75
+ const built = readdirSync("dist/migrations", { recursive: true }).map(String).sort();
76
+ if (JSON.stringify(built) !== JSON.stringify(files)) {
77
+ fail(`expected ${JSON.stringify(files)} in dist/migrations, found ${JSON.stringify(built)}`);
78
+ }
79
+ const list = [];
80
+ for (const file of files) {
81
+ const module = await import(pathToFileURL(resolve("dist/migrations", file)).href);
82
+ const classes = Object.values(module).filter(isMigration);
83
+ if (classes.length !== 1) fail(`${file} must export exactly one migration class`);
84
+ const [Migration] = classes;
85
+ list.push({ file, name: new Migration().name || Migration.name });
86
+ }
87
+ writeFileSync("dist/migrations/migrations.json", `${JSON.stringify(list, null, 2)}\n`);
88
+ process.stdout.write(`Built ${list.length} migrations\n`);
@@ -9,9 +9,13 @@ function entry(name) {
9
9
  return existsSync(source) ? source : null;
10
10
  }
11
11
 
12
+ const args = process.argv.slice(2);
12
13
  // The auth module adds src/migrate.ts, which applies the Better Auth schema
13
- // before it runs the application migrations.
14
- const target = entry("migrate") ?? entry("database/migrate");
14
+ // before it runs the application migrations. --list needs neither the
15
+ // database nor auth, so it uses the application migrator.
16
+ const target = args.includes("--list")
17
+ ? entry("database/migrate")
18
+ : entry("migrate") ?? entry("database/migrate");
15
19
  if (!target) {
16
20
  process.stderr.write("Run database migrations failed: no migration entry in src or dist\n");
17
21
  process.exit(1);
@@ -19,7 +23,7 @@ if (!target) {
19
23
  // Run in the API directory wherever this was started from: Bun reads decorator
20
24
  // settings from the tsconfig.json of the working directory, and the data source
21
25
  // finds .env and the migrations relative to it.
22
- const subprocess = Bun.spawn([process.execPath, fileURLToPath(target)], {
26
+ const subprocess = Bun.spawn([process.execPath, fileURLToPath(target), ...args], {
23
27
  cwd: fileURLToPath(new URL("..", import.meta.url)),
24
28
  stdin: "inherit",
25
29
  stdout: "inherit",
@@ -2,11 +2,14 @@ import { readFileSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
3
  import { createApp } from "../src/app";
4
4
  import { validateEnv } from "../src/config/env.validation";
5
+ import { PROVIDERS } from "../src/config/providers";
5
6
  import {
6
7
  assertApiContract,
8
+ assertApplicationRoutes,
7
9
  assertConsistentRouteParameters,
8
10
  documentedApiRoutes,
9
11
  } from "../src/core/api-contract";
12
+ import { APP_DOCUMENTED_ROUTES } from "../src/core/api-contract.extensions";
10
13
  import { createCoreServices } from "../src/core/services";
11
14
 
12
15
  interface ProjectManifest {
@@ -28,9 +31,11 @@ try {
28
31
  }
29
32
  const document: unknown = await response.json();
30
33
  const modules = manifest.modules.map((module) => module.name);
31
- assertApiContract(document, manifest.template, modules);
34
+ assertApiContract(document, manifest.template, modules, PROVIDERS);
35
+ assertApplicationRoutes(document, app.routes, APP_DOCUMENTED_ROUTES);
32
36
  process.stdout.write(
33
- `Verified ${documentedApiRoutes(document).size} documented API operations for ${manifest.template}.\n`,
37
+ `Verified ${documentedApiRoutes(document).size} documented API operations for ${manifest.template}, ` +
38
+ `including ${APP_DOCUMENTED_ROUTES.length} application operations.\n`,
34
39
  );
35
40
  } finally {
36
41
  await services.close();
@@ -1,6 +1,8 @@
1
1
  import { describe, expect, it, mock, type Mock } from "bun:test";
2
+ import { AppException } from "@podosoft/podokit-contracts";
2
3
  import {
3
4
  cachedOpenApiDocument,
5
+ renderError,
4
6
  setRetryAfter,
5
7
  UNAVAILABLE_RETRY_AFTER_SECONDS,
6
8
  type OpenApiDocumentLoader,
@@ -98,3 +100,23 @@ describe("setRetryAfter", () => {
98
100
  expect(headers).toEqual({ "Retry-After": "7" });
99
101
  });
100
102
  });
103
+
104
+ describe("renderError", () => {
105
+ it("puts an AppException's details in the envelope", () => {
106
+ const set = { headers: {} as Record<string, string | number>, status: undefined as number | string | undefined };
107
+ const details = { fields: [{ path: "email", code: "taken" }] };
108
+ const body = renderError("UNKNOWN", new AppException("SIGNUP_INVALID", "Invalid sign-up", 422, details), "/signup", set);
109
+ expect(set.status).toBe(422);
110
+ expect(body.error).toMatchObject({ code: "SIGNUP_INVALID", statusCode: 422, path: "/signup", details });
111
+ expect(renderError("UNKNOWN", new AppException("GONE", "Gone", 410), "/x", set).error).not.toHaveProperty("details");
112
+ });
113
+
114
+ it("hides the message of an unexpected error", () => {
115
+ const set = { headers: {} as Record<string, string | number>, status: undefined as number | string | undefined };
116
+ expect(renderError("UNKNOWN", new Error("secret detail"), "/x", set).error).toMatchObject({
117
+ code: "INTERNAL_ERROR",
118
+ message: "Internal server error",
119
+ statusCode: 500,
120
+ });
121
+ });
122
+ });
@@ -4,6 +4,8 @@ import { AppException, type ErrorEnvelope } from "@podosoft/podokit-contracts";
4
4
  import { Elysia } from "elysia";
5
5
  import { configureServices, extensionModules } from "./app.extensions";
6
6
  import { validateEnv, type AppEnv } from "./config/env.validation";
7
+ import { assertSelectedProvidersInstalled } from "./config/provider-selection";
8
+ import { PROVIDERS } from "./config/providers";
7
9
  import { stampClientAddress } from "./core/client-address";
8
10
  import {
9
11
  appContext,
@@ -125,6 +127,43 @@ function errorEnvelope(
125
127
  };
126
128
  }
127
129
 
130
+ export interface ErrorResponseState {
131
+ status?: number | string;
132
+ headers: Record<string, string | number>;
133
+ }
134
+
135
+ /**
136
+ * Renders a failed request as the standard error envelope: an AppException with
137
+ * its code, status, and details; an unreachable database as 503; Elysia's
138
+ * validation and routing errors as 400 and 404; anything else as 500 without
139
+ * its message.
140
+ */
141
+ export function renderError(code: unknown, error: unknown, path: string, set: ErrorResponseState): ErrorEnvelope {
142
+ if (error instanceof AppException) {
143
+ set.status = error.statusCode;
144
+ if (error.statusCode === 503) setRetryAfter(set.headers);
145
+ return errorEnvelope(error.code, error.message, error.statusCode, path, error.details);
146
+ }
147
+ if (isDatabaseUnavailable(error)) {
148
+ set.status = 503;
149
+ setRetryAfter(set.headers);
150
+ return errorEnvelope("DATABASE_UNAVAILABLE", "The database is temporarily unavailable", 503, path);
151
+ }
152
+ const statusCode = code === "NOT_FOUND" ? 404 : code === "VALIDATION" ? 400 : 500;
153
+ set.status = statusCode;
154
+ const publicCode = code === "NOT_FOUND"
155
+ ? "NOT_FOUND"
156
+ : code === "VALIDATION"
157
+ ? "VALIDATION_ERROR"
158
+ : "INTERNAL_ERROR";
159
+ const message = statusCode === 500
160
+ ? "Internal server error"
161
+ : error instanceof Error
162
+ ? error.message
163
+ : "Request failed";
164
+ return errorEnvelope(publicCode, message, statusCode, path);
165
+ }
166
+
128
167
  const buildApp = (options: CreateAppOptions = {}) => {
129
168
  const env = options.env ?? validateEnv(process.env);
130
169
  const services = options.services ?? createCoreServices(env);
@@ -135,6 +174,7 @@ const buildApp = (options: CreateAppOptions = {}) => {
135
174
  ];
136
175
  for (const module of modules) module.configure?.(env, services);
137
176
  configureServices(services);
177
+ assertSelectedProvidersInstalled(services, PROVIDERS);
138
178
  services.freeze();
139
179
  const context = appContext(env, services);
140
180
  const corsOrigin = env.CORS_ORIGIN?.split(",").map((origin) => origin.trim());
@@ -174,31 +214,7 @@ const buildApp = (options: CreateAppOptions = {}) => {
174
214
  "Complete request",
175
215
  );
176
216
  })
177
- .onError(({ code, error, path, set }) => {
178
- if (error instanceof AppException) {
179
- set.status = error.statusCode;
180
- if (error.statusCode === 503) setRetryAfter(set.headers);
181
- return errorEnvelope(error.code, error.message, error.statusCode, path);
182
- }
183
- if (isDatabaseUnavailable(error)) {
184
- set.status = 503;
185
- setRetryAfter(set.headers);
186
- return errorEnvelope("DATABASE_UNAVAILABLE", "The database is temporarily unavailable", 503, path);
187
- }
188
- const statusCode = code === "NOT_FOUND" ? 404 : code === "VALIDATION" ? 400 : 500;
189
- set.status = statusCode;
190
- const publicCode = code === "NOT_FOUND"
191
- ? "NOT_FOUND"
192
- : code === "VALIDATION"
193
- ? "VALIDATION_ERROR"
194
- : "INTERNAL_ERROR";
195
- const message = statusCode === 500
196
- ? "Internal server error"
197
- : error instanceof Error
198
- ? error.message
199
- : "Request failed";
200
- return errorEnvelope(publicCode, message, statusCode, path);
201
- });
217
+ .onError(({ code, error, path, set }) => renderError(code, error, path, set));
202
218
 
203
219
  const plugins: AppPlugin[] = [
204
220
  healthPlugin,
@@ -0,0 +1,121 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { cpSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+
6
+ const BUILD = join(__dirname, "../scripts/build.mjs");
7
+ // The application's own build.extensions.mjs names entry points this temporary
8
+ // layout does not contain, so each build starts without application targets. A
9
+ // test that needs some passes its own scripts/build.extensions.mjs.
10
+ const NO_APP_TARGETS = "export const appBuildTargets = [];\n";
11
+
12
+ /** Builds an API layout made of `files` with the app's build script. */
13
+ async function build(files: Record<string, string>): Promise<{ root: string; code: number; stderr: string }> {
14
+ const root = mkdtempSync(join(tmpdir(), "api-build-"));
15
+ mkdirSync(join(root, "scripts"));
16
+ cpSync(BUILD, join(root, "scripts/build.mjs"));
17
+ writeFileSync(join(root, "scripts/build.extensions.mjs"), NO_APP_TARGETS);
18
+ for (const [path, content] of Object.entries(files)) {
19
+ mkdirSync(dirname(join(root, path)), { recursive: true });
20
+ writeFileSync(join(root, path), content);
21
+ }
22
+ const child = Bun.spawn([process.execPath, "scripts/build.mjs"], { cwd: root, stdout: "pipe", stderr: "pipe" });
23
+ const [stderr, code] = await Promise.all([new Response(child.stderr).text(), child.exited]);
24
+ return { root, code, stderr };
25
+ }
26
+
27
+ async function run(root: string, entry: string, env: Record<string, string>): Promise<string> {
28
+ const child = Bun.spawn([process.execPath, entry], { cwd: root, env: { ...process.env, ...env }, stdout: "pipe" });
29
+ const [stdout] = await Promise.all([new Response(child.stdout).text(), child.exited]);
30
+ return stdout.trim();
31
+ }
32
+
33
+ const migration = (name: string, body = ""): string =>
34
+ `${body}export class ${name} { name = "${name}"; async up() {} async down() {} }\n`;
35
+
36
+ describe("API build", () => {
37
+ it("builds every migration flat into dist/migrations and lists it with its name", async () => {
38
+ const { root, code, stderr } = await build({
39
+ "src/config/table.ts": 'export const TABLE = "example";\n',
40
+ "src/migrations/1700000000002-AddIndex.ts": migration(
41
+ "AddIndex1700000000002",
42
+ 'import { TABLE } from "../config/table";\nvoid TABLE;\n',
43
+ ),
44
+ "src/migrations/1700000000001-CreateExample.ts": migration("CreateExample1700000000001"),
45
+ "dist/migrations/1690000000000-Removed.js": migration("Removed1690000000000"),
46
+ });
47
+ try {
48
+ expect({ code, stderr }).toEqual({ code: 0, stderr: "" });
49
+ expect(readdirSync(join(root, "dist/migrations"), { recursive: true }).map(String).sort()).toEqual([
50
+ "1700000000001-CreateExample.js",
51
+ "1700000000002-AddIndex.js",
52
+ "migrations.json",
53
+ ]);
54
+ expect(JSON.parse(readFileSync(join(root, "dist/migrations/migrations.json"), "utf8"))).toEqual([
55
+ { file: "1700000000001-CreateExample.js", name: "CreateExample1700000000001" },
56
+ { file: "1700000000002-AddIndex.js", name: "AddIndex1700000000002" },
57
+ ]);
58
+ } finally {
59
+ rmSync(root, { recursive: true, force: true });
60
+ }
61
+ });
62
+
63
+ it("writes an empty list for an app without migrations", async () => {
64
+ const { root, code } = await build({ "src/main.ts": "export {};\n" });
65
+ try {
66
+ expect(code).toBe(0);
67
+ expect(readFileSync(join(root, "dist/migrations/migrations.json"), "utf8")).toBe("[]\n");
68
+ } finally {
69
+ rmSync(root, { recursive: true, force: true });
70
+ }
71
+ });
72
+
73
+ it("fails on a migration file without exactly one migration class", async () => {
74
+ const { root, code, stderr } = await build({
75
+ "src/migrations/1700000000001-Two.ts": `${migration("A1700000000001")}${migration("B1700000000001")}`,
76
+ });
77
+ try {
78
+ expect(code).toBe(1);
79
+ expect(stderr).toContain("1700000000001-Two.js must export exactly one migration class");
80
+ } finally {
81
+ rmSync(root, { recursive: true, force: true });
82
+ }
83
+ });
84
+
85
+ it("leaves NODE_ENV to the environment the bundle runs in", async () => {
86
+ const { root, code, stderr } = await build({ "src/main.ts": "console.log(process.env.NODE_ENV);\n" });
87
+ try {
88
+ expect({ code, stderr }).toEqual({ code: 0, stderr: "" });
89
+ expect(await run(root, "dist/main.js", { NODE_ENV: "production" })).toBe("production");
90
+ expect(await run(root, "dist/main.js", { NODE_ENV: "test" })).toBe("test");
91
+ } finally {
92
+ rmSync(root, { recursive: true, force: true });
93
+ }
94
+ });
95
+
96
+ it("bundles the application's entry points from build.extensions.mjs", async () => {
97
+ const { root, code, stderr } = await build({
98
+ "scripts/build.extensions.mjs":
99
+ 'export const appBuildTargets = [{ entrypoint: "scripts/repair.ts", outdir: "dist/scripts" }];\n',
100
+ "scripts/repair.ts": 'console.log("repaired", process.env.NODE_ENV);\n',
101
+ });
102
+ try {
103
+ expect({ code, stderr }).toEqual({ code: 0, stderr: "" });
104
+ expect(await run(root, "dist/scripts/repair.js", { NODE_ENV: "production" })).toBe("repaired production");
105
+ } finally {
106
+ rmSync(root, { recursive: true, force: true });
107
+ }
108
+ });
109
+
110
+ it("fails on an application entry point that does not exist", async () => {
111
+ const { root, code, stderr } = await build({
112
+ "scripts/build.extensions.mjs": 'export const appBuildTargets = [{ entrypoint: "scripts/gone.ts", outdir: "dist" }];\n',
113
+ });
114
+ try {
115
+ expect(code).toBe(1);
116
+ expect(stderr).toContain("scripts/gone.ts from build.extensions.mjs does not exist");
117
+ } finally {
118
+ rmSync(root, { recursive: true, force: true });
119
+ }
120
+ });
121
+ });
@@ -0,0 +1,20 @@
1
+ import type { z } from "zod";
2
+ import type { AppEnv } from "./env.validation";
3
+
4
+ // This owned file is never overwritten by `podo update`. Declare the
5
+ // application's environment variables here: they are validated with PodoKit's
6
+ // at startup, fail it with the same message when invalid, and are typed on
7
+ // `AppEnv`. A name PodoKit already defines is rejected. For example:
8
+ //
9
+ // import { z } from "zod";
10
+ // export const appEnvShape = {
11
+ // REPORTS_BUCKET: z.string().min(1),
12
+ // SYNC_INTERVAL_SECONDS: z.coerce.number().int().positive().default(60),
13
+ // } satisfies z.ZodRawShape;
14
+ export const appEnvShape = {} satisfies z.ZodRawShape;
15
+
16
+ /**
17
+ * Checks across variables, such as one that production requires. Report a
18
+ * problem with `context.addIssue({ code: "custom", path: ["NAME"], message })`.
19
+ */
20
+ export function refineAppEnv(_env: AppEnv, _context: z.RefinementCtx): void {}
@@ -0,0 +1,76 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { copyFileSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { validateEnv } from "./env.validation";
5
+
6
+ describe("environment validation", () => {
7
+ it("listens on every address and keeps SQLite under data/ by default", () => {
8
+ expect(validateEnv({})).toMatchObject({ HOST: "0.0.0.0", SQLITE_PATH: "./data/podokit.sqlite" });
9
+ });
10
+
11
+ it("reads HOST and SQLITE_PATH", () => {
12
+ expect(validateEnv({ HOST: " 127.0.0.1 ", SQLITE_PATH: "/var/lib/app/app.sqlite" }))
13
+ .toMatchObject({ HOST: "127.0.0.1", SQLITE_PATH: "/var/lib/app/app.sqlite" });
14
+ expect(() => validateEnv({ HOST: " " })).toThrow("Invalid environment");
15
+ });
16
+ });
17
+
18
+ /** Runs `script` next to a copy of env.validation.ts and an env.extensions.ts of the test's own. */
19
+ async function withExtensions(extensions: string, script: string): Promise<{ code: number; stdout: string; stderr: string }> {
20
+ // Inside the API directory, so the copies resolve zod like the originals.
21
+ const root = mkdtempSync(join(__dirname, ".env-extensions-"));
22
+ try {
23
+ copyFileSync(join(__dirname, "env.validation.ts"), join(root, "env.validation.ts"));
24
+ writeFileSync(join(root, "env.extensions.ts"), extensions);
25
+ writeFileSync(join(root, "check.ts"), script);
26
+ const child = Bun.spawn([process.execPath, join(root, "check.ts")], { stdout: "pipe", stderr: "pipe" });
27
+ const [stdout, stderr, code] = await Promise.all([
28
+ new Response(child.stdout).text(),
29
+ new Response(child.stderr).text(),
30
+ child.exited,
31
+ ]);
32
+ return { code, stdout: stdout.trim(), stderr };
33
+ } finally {
34
+ rmSync(root, { recursive: true, force: true });
35
+ }
36
+ }
37
+
38
+ describe("application environment", () => {
39
+ const extensions = [
40
+ 'import { z } from "zod";',
41
+ 'import type { AppEnv } from "./env.validation";',
42
+ "export const appEnvShape = {",
43
+ " REPORTS_BUCKET: z.string().min(1),",
44
+ " SYNC_INTERVAL_SECONDS: z.coerce.number().int().positive().default(60),",
45
+ "};",
46
+ "export function refineAppEnv(env: AppEnv, context: z.RefinementCtx): void {",
47
+ ' if (env.NODE_ENV === "production" && env.REPORTS_BUCKET === "dev") {',
48
+ ' context.addIssue({ code: "custom", path: ["REPORTS_BUCKET"], message: "use a production bucket" });',
49
+ " }",
50
+ "}",
51
+ "",
52
+ ].join("\n");
53
+
54
+ it("validates and types the variables of env.extensions.ts with PodoKit's", async () => {
55
+ const result = await withExtensions(extensions, [
56
+ 'import { validateEnv } from "./env.validation";',
57
+ 'const env = validateEnv({ REPORTS_BUCKET: "reports" });',
58
+ "const interval: number = env.SYNC_INTERVAL_SECONDS;",
59
+ "console.log(JSON.stringify([env.REPORTS_BUCKET, interval, env.PORT]));",
60
+ "for (const input of [{}, { NODE_ENV: \"production\", REPORTS_BUCKET: \"dev\" }]) {",
61
+ " try { validateEnv(input); } catch (error) { console.log((error as Error).message.includes(\"REPORTS_BUCKET\")); }",
62
+ "}",
63
+ "",
64
+ ].join("\n"));
65
+ expect(result).toMatchObject({ code: 0, stdout: '["reports",60,5002]\ntrue\ntrue' });
66
+ });
67
+
68
+ it("rejects a variable PodoKit already defines", async () => {
69
+ const result = await withExtensions(
70
+ 'import { z } from "zod";\nexport const appEnvShape = { PORT: z.string() };\nexport function refineAppEnv(): void {}\n',
71
+ 'import "./env.validation";\n',
72
+ );
73
+ expect(result.code).toBe(1);
74
+ expect(result.stderr).toContain("env.extensions.ts redefines PORT");
75
+ });
76
+ });