@podosoft/podokit 1.0.1 → 1.1.2

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 (118) hide show
  1. package/README.md +19 -5
  2. package/dist/add.d.ts +14 -1
  3. package/dist/add.js +42 -5
  4. package/dist/create.d.ts +4 -0
  5. package/dist/create.js +9 -0
  6. package/dist/deploy-compose-profile.d.ts +3 -0
  7. package/dist/deploy-compose-profile.js +74 -8
  8. package/dist/deploy-compose-render.d.ts +2 -1
  9. package/dist/deploy-compose-render.js +50 -5
  10. package/dist/deploy-compose.js +2 -1
  11. package/dist/deploy-profile.js +18 -8
  12. package/dist/deploy-render.js +5 -5
  13. package/dist/deploy.js +157 -1
  14. package/dist/index.d.ts +1 -0
  15. package/dist/index.js +41 -0
  16. package/dist/lib.d.ts +2 -0
  17. package/dist/lib.js +8 -2
  18. package/dist/lockfile.d.ts +6 -1
  19. package/dist/lockfile.js +22 -4
  20. package/dist/provider.d.ts +24 -0
  21. package/dist/provider.js +142 -0
  22. package/dist/remove.js +6 -3
  23. package/dist/templates/fullstack/AGENTS.md +5 -4
  24. package/dist/templates/fullstack/README.md +10 -3
  25. package/dist/templates/fullstack/apps/api/package.json +3 -2
  26. package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +1 -0
  27. package/dist/templates/fullstack/apps/api/src/config/providers.ts +9 -0
  28. package/dist/templates/fullstack/apps/api/src/core/services.ts +7 -2
  29. package/dist/templates/fullstack/apps/api/src/database/data-source.ts +30 -8
  30. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +58 -0
  31. package/dist/templates/fullstack/apps/api/src/database/database.ts +58 -2
  32. package/dist/templates/fullstack/apps/api/src/database/migrate.ts +24 -2
  33. package/dist/templates/fullstack/apps/api/src/database/sqlite-migrator.spec.ts +50 -0
  34. package/dist/templates/fullstack/apps/api/src/database/sqlite-migrator.ts +134 -0
  35. package/dist/templates/fullstack/compose.dev.yaml +2 -2
  36. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +10 -3
  37. package/dist/templates/fullstack/dot-env.example +2 -0
  38. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +2 -2
  39. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.spec.ts +10 -4
  40. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.ts +10 -4
  41. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.ts +10 -4
  42. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +1 -1
  43. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/setup-2fa/+page.svelte +1 -1
  44. package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +10 -2
  45. package/dist/templates/modules/admin-dashboard/module.manifest.json +3 -5
  46. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-enabled.ts +2 -5
  47. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.migration.spec.ts +33 -0
  48. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +13 -0
  49. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +17 -8
  50. package/dist/templates/modules/audit-log/files/apps/api/src/migrations/1720300000000-InitAuditLogs.ts +5 -4
  51. package/dist/templates/modules/audit-log/module.manifest.json +1 -1
  52. package/dist/templates/modules/auth/files/apps/api/src/auth/account-issuer-migration.spec.ts +170 -0
  53. package/dist/templates/modules/auth/files/apps/api/src/auth/account-issuer-migration.ts +260 -0
  54. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +2 -2
  55. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +4 -0
  56. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +2 -2
  57. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +4 -4
  58. package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +55 -10
  59. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +10 -14
  60. package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +22 -6
  61. package/dist/templates/modules/auth/files/apps/api/src/migrations/1720400000000-InitAppSettings.ts +2 -1
  62. package/dist/templates/modules/auth/files/apps/api/src/migrations/1720500000000-InitAuthConfig.ts +4 -3
  63. package/dist/templates/modules/auth/files/apps/api/src/settings/app-settings.migration.spec.ts +15 -4
  64. package/dist/templates/modules/auth/files/apps/api/src/settings/auth-config.migration.spec.ts +30 -0
  65. package/dist/templates/modules/auth/module.manifest.json +1 -1
  66. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +10 -0
  67. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +107 -0
  68. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/jobs.module.ts +12 -11
  69. package/dist/templates/modules/bullmq/files/apps/api/src/main-worker.ts +5 -0
  70. package/dist/templates/modules/bullmq/module.manifest.json +7 -0
  71. package/dist/templates/modules/cache-memory/files/apps/api/src/cache-memory/cache-memory.module.spec.ts +16 -0
  72. package/dist/templates/modules/cache-memory/files/apps/api/src/cache-memory/cache-memory.module.ts +12 -0
  73. package/dist/templates/modules/cache-memory/module.manifest.json +30 -0
  74. package/dist/templates/modules/events-memory/files/apps/api/src/events-provider-memory/events-memory.module.ts +14 -0
  75. package/dist/templates/modules/events-memory/module.manifest.json +30 -0
  76. package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/events-redis.module.ts +16 -0
  77. package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/redis-event.bus.ts +75 -0
  78. package/dist/templates/modules/events-redis/module.manifest.json +32 -0
  79. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.ts +9 -5
  80. package/dist/templates/modules/file-upload/files/tests/api/files.api.spec.ts +1 -1
  81. package/dist/templates/modules/file-upload/module.manifest.json +4 -3
  82. package/dist/templates/modules/job-progress/files/apps/api/src/progress/job-progress.module.ts +36 -14
  83. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.spec.ts +9 -32
  84. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.processor.ts +16 -11
  85. package/dist/templates/modules/job-progress/module.manifest.json +7 -8
  86. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/jobs-local.module.ts +42 -0
  87. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +45 -0
  88. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +212 -0
  89. package/dist/templates/modules/jobs-local/module.manifest.json +34 -0
  90. package/dist/templates/modules/mailer/files/apps/api/src/mail/mailer.ts +7 -3
  91. package/dist/templates/modules/mailer/module.manifest.json +2 -4
  92. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.spec.ts +25 -0
  93. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.ts +60 -0
  94. package/dist/templates/modules/object-storage-local/module.manifest.json +34 -0
  95. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.ts +50 -0
  96. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.module.ts +8 -0
  97. package/dist/templates/modules/object-storage-s3/files/infra/docker/minio.compose.yml +4 -4
  98. package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +20 -12
  99. package/dist/templates/modules/object-storage-s3/module.manifest.json +4 -2
  100. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +14 -1
  101. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +18 -37
  102. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +12 -0
  103. package/dist/templates/modules/rate-limit/module.manifest.json +8 -6
  104. package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.ts +82 -0
  105. package/dist/templates/modules/redis/files/apps/api/src/redis/redis.module.ts +6 -0
  106. package/dist/templates/modules/redis/module.manifest.json +2 -0
  107. package/dist/templates/modules/sse/files/apps/api/src/events/events.module.ts +5 -6
  108. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +4 -4
  109. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +6 -5
  110. package/dist/templates/modules/sse/module.manifest.json +4 -6
  111. package/dist/templates/todo/AGENTS.md +1 -1
  112. package/dist/templates/todo/README.md +8 -3
  113. package/dist/toolchain.d.ts +1 -0
  114. package/dist/toolchain.js +4 -0
  115. package/package.json +2 -1
  116. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.ts +0 -32
  117. package/dist/templates/modules/mailer/files/apps/api/src/mail/db.ts +0 -13
  118. package/dist/templates/modules/sse/files/apps/api/src/events/events.transport.ts +0 -111
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # @podosoft/podokit
2
2
 
3
3
  PodoKit is an opinionated Bun 1.4.0 full-stack starter and CLI. It generates an
4
- Elysia API, a SvelteKit web application, PostgreSQL integration through native
5
- `Bun.SQL`, Tailwind CSS v4, shadcn-svelte, Docker, and k3s assets.
4
+ Elysia API, a SvelteKit web application, selectable PostgreSQL/SQLite integration
5
+ through native `Bun.SQL`, Tailwind CSS v4, shadcn-svelte, Docker, and k3s assets.
6
6
 
7
7
  The CLI itself remains Node-compatible, so either `npx` or `bunx` can create and
8
8
  maintain projects. Generated PodoKit v1 applications are Bun-only.
@@ -24,7 +24,7 @@ gateway on loopback port 80; later projects reuse it and route by hostname. Stop
24
24
  the project with `podo dev down`.
25
25
 
26
26
  The `todo` template generates a working SvelteKit UI, Elysia CRUD API, and
27
- PostgreSQL persistence:
27
+ PostgreSQL or SQLite persistence:
28
28
 
29
29
  ![Generated todo app](https://raw.githubusercontent.com/podosoft-dev/podokit/main/docs/images/todo-app.png)
30
30
 
@@ -35,6 +35,8 @@ PostgreSQL persistence:
35
35
  | `podo create <name>` | Create a Bun project from `fullstack`, `todo`, or `base`. |
36
36
  | `podo add <module>` | Add a composable feature module. |
37
37
  | `podo remove <module>` | Remove a module conservatively. |
38
+ | `podo provider list` | Show active database, cache, object storage, event, and job providers. |
39
+ | `podo provider set <capability> <provider> [--apply]` | Preview or apply one provider change. |
38
40
  | `podo status` | Show the PodoKit version, modules, ownership tiers, and local edits. |
39
41
  | `podo diff` | List edited or deleted managed files. |
40
42
  | `podo doctor` | Check Bun and framework versions. |
@@ -42,7 +44,7 @@ PostgreSQL persistence:
42
44
  | `podo update [--apply]` | Preview or apply managed template and module updates. |
43
45
  | `podo eject <path...>` | Transfer managed files to application ownership. |
44
46
  | `podo dev <action>` | Manage the generated development containers. |
45
- | `podo deploy <action>` | Plan, apply, verify, or roll back an immutable Helm release. |
47
+ | `podo deploy <action>` | Plan, apply, verify, or roll back an immutable Kubernetes or Docker Compose release. |
46
48
 
47
49
  ```text
48
50
  podo create <name> [options]
@@ -50,6 +52,7 @@ podo create <name> [options]
50
52
  Options:
51
53
  --template <name> fullstack (default), todo, or base
52
54
  --dir <path> Target directory (default: ./<name>)
55
+ --database <name> postgres (default) or sqlite
53
56
  --no-ai Omit generated AI-agent guidance
54
57
  -y, --yes Accept defaults without prompts
55
58
  -h, --help Show help
@@ -80,6 +83,17 @@ remain untouched.
80
83
  See the [module guide](https://github.com/podosoft-dev/podokit/blob/main/docs/modules.md)
81
84
  and [updating guide](https://github.com/podosoft-dev/podokit/blob/main/docs/updating.md).
82
85
 
86
+ ## Runtime providers
87
+
88
+ Generated projects use stable capability contracts with server defaults
89
+ (`postgres`, `redis`, `s3`, Redis events, and `bullmq`) and local alternatives
90
+ (`sqlite`, memory cache/events, local files/jobs). `podo provider set` is a dry
91
+ run by default and installs the selected implementation module on `--apply`.
92
+ It changes code and configuration only; it never migrates or deletes data.
93
+
94
+ See the [runtime provider guide](https://github.com/podosoft-dev/podokit/blob/main/docs/providers.md)
95
+ before switching an existing project.
96
+
83
97
  ## Compatibility boundary
84
98
 
85
99
  PodoKit v1 does not convert projects generated by PodoKit 0.x. Existing projects
@@ -99,7 +113,7 @@ my-app/
99
113
  │ ├── api/ # Bun + Elysia + native Bun.SQL
100
114
  │ └── web/ # SvelteKit + Tailwind CSS v4 + shadcn-svelte
101
115
  ├── infra/
102
- │ ├── docker/ # PostgreSQL, Redis, and optional services
116
+ │ ├── docker/ # provider-aware external services
103
117
  │ └── k3s/ # deployments, services, ingress, and examples
104
118
  ├── tests/ # Playwright API and browser tests
105
119
  ├── .podokit/ # assembly and managed-file metadata
package/dist/add.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { type TemplateVars } from "@podosoft/podokit-template-engine";
2
+ import { type ProviderCapability } from "@podosoft/podokit-runtime";
2
3
  interface Injection {
3
4
  file: string;
4
5
  marker: string;
@@ -8,7 +9,13 @@ interface Injection {
8
9
  }
9
10
  /** Highest `module.manifest.json` schema version this CLI understands. A package
10
11
  * module declaring a higher version is rejected rather than mis-applied. */
11
- export declare const SUPPORTED_MANIFEST_VERSION = 2;
12
+ export declare const SUPPORTED_MANIFEST_VERSION = 3;
13
+ export interface ModuleRuntimeConstraints {
14
+ /** Maximum safe API replicas for process-local providers. */
15
+ maxApiReplicas?: number;
16
+ /** Whether jobs must execute inside the API process. */
17
+ embeddedWorker?: boolean;
18
+ }
12
19
  export interface ModulePathMove {
13
20
  /** Project-relative file or directory path from an older module layout. */
14
21
  from: string;
@@ -39,6 +46,12 @@ export interface ModuleManifest {
39
46
  name: string;
40
47
  description: string;
41
48
  requires?: string[];
49
+ /** Runtime capabilities that must have an implementation module installed. */
50
+ requiresCapabilities?: ProviderCapability[];
51
+ /** Provider implementations supplied by this module. */
52
+ provides?: Partial<Record<ProviderCapability, string>>;
53
+ /** Deployment restrictions introduced by this implementation. */
54
+ runtimeConstraints?: ModuleRuntimeConstraints;
42
55
  targetApp: string;
43
56
  dependencies?: Record<string, string>;
44
57
  devDependencies?: Record<string, string>;
package/dist/add.js CHANGED
@@ -11,11 +11,11 @@ const node_fs_1 = require("node:fs");
11
11
  const node_path_1 = require("node:path");
12
12
  const node_module_1 = require("node:module");
13
13
  const podokit_template_engine_1 = require("@podosoft/podokit-template-engine");
14
+ const podokit_runtime_1 = require("@podosoft/podokit-runtime");
14
15
  const lockfile_1 = require("./lockfile");
15
- const toolchain_1 = require("./toolchain");
16
16
  /** Highest `module.manifest.json` schema version this CLI understands. A package
17
17
  * module declaring a higher version is rejected rather than mis-applied. */
18
- exports.SUPPORTED_MANIFEST_VERSION = 2;
18
+ exports.SUPPORTED_MANIFEST_VERSION = 3;
19
19
  /** Return the legacy targetApp package fields plus any additional app overlays. */
20
20
  function modulePackageOverlays(manifest) {
21
21
  const overlays = new Map();
@@ -88,6 +88,43 @@ function assertManifestVersion(name, manifest) {
88
88
  if ((manifest.manifestVersion ?? exports.SUPPORTED_MANIFEST_VERSION) > exports.SUPPORTED_MANIFEST_VERSION) {
89
89
  throw new Error(`Module "${name}" needs a newer PodoKit (manifest v${manifest.manifestVersion}, this CLI supports v${exports.SUPPORTED_MANIFEST_VERSION}). Update @podosoft/podokit.`);
90
90
  }
91
+ for (const capability of manifest.requiresCapabilities ?? []) {
92
+ if (!(0, podokit_runtime_1.isProviderCapability)(capability)) {
93
+ throw new Error(`Module "${name}" requires unknown capability "${capability}".`);
94
+ }
95
+ }
96
+ for (const [capability, provider] of Object.entries(manifest.provides ?? {})) {
97
+ if (!(0, podokit_runtime_1.isProviderCapability)(capability)
98
+ || typeof provider !== "string"
99
+ || !(0, podokit_runtime_1.isProviderName)(capability, provider)) {
100
+ throw new Error(`Module "${name}" declares invalid provider ${capability}=${String(provider)}.`);
101
+ }
102
+ }
103
+ const maxApiReplicas = manifest.runtimeConstraints?.maxApiReplicas;
104
+ if (maxApiReplicas !== undefined
105
+ && (!Number.isSafeInteger(maxApiReplicas) || maxApiReplicas < 1)) {
106
+ throw new Error(`Module "${name}" declares an invalid maxApiReplicas constraint.`);
107
+ }
108
+ }
109
+ function providerModuleName(projectRoot, modulesDir, capability) {
110
+ const project = (0, lockfile_1.readManifest)(projectRoot);
111
+ if (!project)
112
+ throw new Error("Provider capabilities require a PodoKit project manifest.");
113
+ const selected = project.providers[capability];
114
+ for (const available of listModules(modulesDir, projectRoot)) {
115
+ const resolved = resolveModule(available.name, modulesDir, projectRoot);
116
+ if (!resolved)
117
+ continue;
118
+ const provides = readModuleManifest(resolved.dir).provides;
119
+ if (provides?.[capability] === selected)
120
+ return available.name;
121
+ }
122
+ throw new Error(`No module provides the selected ${capability} provider "${selected}". ` +
123
+ `Choose another provider with podo provider set ${capability} <provider> --apply.`);
124
+ }
125
+ function requiredModules(projectRoot, modulesDir, manifest) {
126
+ const capabilities = (manifest.requiresCapabilities ?? []).map((capability) => providerModuleName(projectRoot, modulesDir, capability));
127
+ return [...new Set([...(manifest.requires ?? []), ...capabilities])];
91
128
  }
92
129
  function assertRequiredProjectFiles(projectRoot, name, manifest) {
93
130
  const contents = manifest.requiredProjectContents ?? {};
@@ -136,7 +173,7 @@ function assertModuleGraphPrerequisites(projectRoot, module, modulesDir, visited
136
173
  const manifest = readModuleManifest(resolved.dir);
137
174
  assertManifestVersion(module, manifest);
138
175
  assertRequiredProjectFiles(projectRoot, module, manifest);
139
- for (const required of manifest.requires ?? []) {
176
+ for (const required of requiredModules(projectRoot, modulesDir, manifest)) {
140
177
  assertModuleGraphPrerequisites(projectRoot, required, modulesDir, visited, module);
141
178
  }
142
179
  }
@@ -267,7 +304,7 @@ function applyModule(projectRoot, module, modulesDir, applied, adopt) {
267
304
  const projectManifest = (0, lockfile_1.readManifest)(projectRoot);
268
305
  const vars = {
269
306
  projectName: appName,
270
- ...(projectManifest ? (0, toolchain_1.toolchainTemplateVars)(projectManifest.toolchain) : {}),
307
+ ...(projectManifest ? (0, lockfile_1.manifestTemplateVars)(projectManifest) : {}),
271
308
  };
272
309
  const manifest = readModuleManifest(moduleDir, vars);
273
310
  applied.add(module);
@@ -278,7 +315,7 @@ function applyModule(projectRoot, module, modulesDir, applied, adopt) {
278
315
  const preserved = [];
279
316
  const adopted = [];
280
317
  const touched = [];
281
- for (const required of manifest.requires ?? []) {
318
+ for (const required of requiredModules(projectRoot, modulesDir, manifest)) {
282
319
  if (applied.has(required) || isApplied(projectRoot, modulesDir, required))
283
320
  continue;
284
321
  const result = applyModule(projectRoot, required, modulesDir, applied, adopt);
package/dist/create.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type DatabaseProviderName, type ProviderSelections } from "@podosoft/podokit-runtime";
1
2
  import { DEFAULT_TEMPLATE } from "./templates";
2
3
  import { type Runtime, type Toolchain } from "./toolchain";
3
4
  export type PackageManager = "bun";
@@ -19,12 +20,15 @@ export interface CreateOptions {
19
20
  podokitVersion?: string;
20
21
  /** Include AI agent guidance (AGENTS.md, CLAUDE.md, editor rules). Defaults to true. */
21
22
  ai?: boolean;
23
+ /** Database implementation selected for the generated project. */
24
+ database?: DatabaseProviderName;
22
25
  }
23
26
  export interface CreateResult {
24
27
  projectDir: string;
25
28
  packageManager: Toolchain["packageManager"];
26
29
  toolchain: Toolchain;
27
30
  template: string;
31
+ providers: ProviderSelections;
28
32
  }
29
33
  /** Validate a project name: no path separators, npm-friendly characters. */
30
34
  export declare function assertValidName(name: string): void;
package/dist/create.js CHANGED
@@ -6,6 +6,7 @@ exports.create = create;
6
6
  const node_fs_1 = require("node:fs");
7
7
  const node_path_1 = require("node:path");
8
8
  const podokit_template_engine_1 = require("@podosoft/podokit-template-engine");
9
+ const podokit_runtime_1 = require("@podosoft/podokit-runtime");
9
10
  const templates_1 = require("./templates");
10
11
  Object.defineProperty(exports, "DEFAULT_TEMPLATE", { enumerable: true, get: function () { return templates_1.DEFAULT_TEMPLATE; } });
11
12
  const lockfile_1 = require("./lockfile");
@@ -39,6 +40,11 @@ function create(options) {
39
40
  assertValidName(name);
40
41
  const template = options.template ?? templates_1.DEFAULT_TEMPLATE;
41
42
  const toolchain = (0, toolchain_1.resolveToolchain)(options.runtime, options.packageManager);
43
+ const database = options.database ?? "postgres";
44
+ if (!(0, podokit_runtime_1.isProviderName)("database", database)) {
45
+ throw new Error('Database provider must be either "postgres" or "sqlite".');
46
+ }
47
+ const providers = (0, podokit_runtime_1.resolveProviderSelections)({ database });
42
48
  const projectDir = options.targetDir
43
49
  ? (0, node_path_1.isAbsolute)(options.targetDir)
44
50
  ? options.targetDir
@@ -53,6 +59,7 @@ function create(options) {
53
59
  }
54
60
  const vars = {
55
61
  projectName: name,
62
+ ...(0, lockfile_1.providerTemplateVars)(providers),
56
63
  ...(0, toolchain_1.toolchainTemplateVars)(toolchain),
57
64
  };
58
65
  (0, podokit_template_engine_1.writeTree)((0, assemble_1.renderProjectTemplate)(templatesDir, template, vars), projectDir);
@@ -65,11 +72,13 @@ function create(options) {
65
72
  toolchain,
66
73
  answers: vars,
67
74
  version: options.podokitVersion,
75
+ providers,
68
76
  });
69
77
  return {
70
78
  projectDir,
71
79
  packageManager: toolchain.packageManager,
72
80
  toolchain,
73
81
  template,
82
+ providers,
74
83
  };
75
84
  }
@@ -68,6 +68,9 @@ export interface DockerComposeProfileV1 {
68
68
  host: string;
69
69
  bindAddress: string;
70
70
  port: number;
71
+ webSocketPaths: string[];
72
+ trustedProxyCidrs: string[];
73
+ gatewayImage: string;
71
74
  };
72
75
  workloads: {
73
76
  api: ComposeWorkloadProfile;
@@ -6,8 +6,10 @@ exports.initializeComposeProfile = initializeComposeProfile;
6
6
  exports.composeProfileDigest = composeProfileDigest;
7
7
  const node_crypto_1 = require("node:crypto");
8
8
  const node_fs_1 = require("node:fs");
9
+ const node_net_1 = require("node:net");
9
10
  const lockfile_1 = require("./lockfile");
10
11
  const deploy_schema_1 = require("./deploy-schema");
12
+ const websocket_paths_1 = require("./websocket-paths");
11
13
  /** Decimal core count, e.g. "0.25" or "2". */
12
14
  const CPU_VALUE = /^(?:0\.[0-9]{1,3}|[1-9][0-9]{0,2}(?:\.[0-9]{1,3})?)$/;
13
15
  /** Compose byte suffix, e.g. "512m" or "2g". */
@@ -17,6 +19,31 @@ const PROJECT_NAME = /^[a-z0-9][a-z0-9_-]*$/;
17
19
  /** An absolute POSIX path on the target host, with no traversal or control bytes. */
18
20
  const ABSOLUTE_PATH = /^\/(?:[A-Za-z0-9._-]+\/?)+$/;
19
21
  const VOLUME_NAME = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
22
+ function parseTrustedProxyCidrs(value, field) {
23
+ if (!Array.isArray(value)) {
24
+ throw new Error(`Deployment profile field "${field}" must be a string array.`);
25
+ }
26
+ const parsed = value.map((entry, index) => {
27
+ if (typeof entry !== "string" || entry !== entry.trim()) {
28
+ throw new Error(`Deployment profile field "${field}[${index}]" must be an IP CIDR block.`);
29
+ }
30
+ const separator = entry.lastIndexOf("/");
31
+ const address = entry.slice(0, separator);
32
+ const prefixText = entry.slice(separator + 1);
33
+ const family = (0, node_net_1.isIP)(address);
34
+ if (separator <= 0 ||
35
+ family === 0 ||
36
+ !/^(?:0|[1-9][0-9]*)$/.test(prefixText) ||
37
+ Number(prefixText) > (family === 4 ? 32 : 128)) {
38
+ throw new Error(`Deployment profile field "${field}[${index}]" must be an IP CIDR block.`);
39
+ }
40
+ return entry;
41
+ });
42
+ if (new Set(parsed).size !== parsed.length) {
43
+ throw new Error(`Deployment profile field "${field}" contains duplicate CIDR blocks.`);
44
+ }
45
+ return parsed.sort();
46
+ }
20
47
  function validateCpu(value, field) {
21
48
  if (!CPU_VALUE.test(value)) {
22
49
  throw new Error(`Deployment profile field "${field}" must be a decimal CPU count.`);
@@ -134,7 +161,15 @@ function parseComposeProfile(value) {
134
161
  const migration = value.migration === undefined ? undefined : (0, deploy_schema_1.requiredRecord)(value, "migration");
135
162
  const sync = value.sync === undefined ? undefined : (0, deploy_schema_1.requiredRecord)(value, "sync");
136
163
  (0, deploy_schema_1.assertOnlyKeys)(target, ["context", "endpointFingerprint", "project"], "target");
137
- (0, deploy_schema_1.assertOnlyKeys)(exposure, ["mode", "host", "bindAddress", "port"], "exposure");
164
+ (0, deploy_schema_1.assertOnlyKeys)(exposure, [
165
+ "mode",
166
+ "host",
167
+ "bindAddress",
168
+ "port",
169
+ "webSocketPaths",
170
+ "trustedProxyCidrs",
171
+ "gatewayImage",
172
+ ], "exposure");
138
173
  (0, deploy_schema_1.assertOnlyKeys)(workloads, ["api", "web", "worker"], "workloads");
139
174
  (0, deploy_schema_1.assertOnlyKeys)(dependencies, ["postgres", "redis", "objectStorage"], "dependencies");
140
175
  (0, deploy_schema_1.assertOnlyKeys)(secrets, ["api", "web", "registryLogin"], "secrets");
@@ -161,6 +196,15 @@ function parseComposeProfile(value) {
161
196
  const port = (0, deploy_schema_1.requiredNumber)(exposure, "port");
162
197
  if (port > 65535)
163
198
  throw new Error("Deployment exposure.port must be a TCP port.");
199
+ const webSocketPaths = (0, websocket_paths_1.parseExactWebSocketPaths)(exposure.webSocketPaths ?? [], "exposure.webSocketPaths");
200
+ const trustedProxyCidrs = parseTrustedProxyCidrs(exposure.trustedProxyCidrs ?? [], "exposure.trustedProxyCidrs");
201
+ if (trustedProxyCidrs.length > 0 && webSocketPaths.length === 0) {
202
+ throw new Error("Deployment exposure.trustedProxyCidrs requires at least one exposure.webSocketPaths entry.");
203
+ }
204
+ const gatewayImage = exposure.gatewayImage === undefined
205
+ ? "caddy:2.10-alpine"
206
+ : (0, deploy_schema_1.requiredString)(exposure, "gatewayImage");
207
+ (0, deploy_schema_1.validateImage)(gatewayImage, "exposure.gatewayImage");
164
208
  const web = parseComposeWorkload((0, deploy_schema_1.requiredRecord)(workloads, "web"), "workloads.web");
165
209
  // Compose has no load balancer. Two replicas of the service that publishes the
166
210
  // host port means the second one cannot bind it, and the deployment fails half
@@ -188,7 +232,15 @@ function parseComposeProfile(value) {
188
232
  driver: "docker-compose",
189
233
  target: { context, endpointFingerprint, project },
190
234
  release: (0, deploy_schema_1.parseReleaseProfile)((0, deploy_schema_1.requiredRecord)(value, "release")),
191
- exposure: { mode: "publishedPort", host, bindAddress, port },
235
+ exposure: {
236
+ mode: "publishedPort",
237
+ host,
238
+ bindAddress,
239
+ port,
240
+ webSocketPaths,
241
+ trustedProxyCidrs,
242
+ gatewayImage,
243
+ },
192
244
  workloads: {
193
245
  api: parseComposeWorkload((0, deploy_schema_1.requiredRecord)(workloads, "api"), "workloads.api"),
194
246
  web,
@@ -249,6 +301,9 @@ function buildDefaultComposeProfile(projectName, options) {
249
301
  // network before TLS exists.
250
302
  bindAddress: "127.0.0.1",
251
303
  port: 8080,
304
+ webSocketPaths: [],
305
+ trustedProxyCidrs: [],
306
+ gatewayImage: "caddy:2.10-alpine",
252
307
  },
253
308
  workloads: {
254
309
  api: { replicas: 2, resources: { cpuLimit: "1", memoryLimit: "1g" } },
@@ -273,8 +328,8 @@ function buildDefaultComposeProfile(projectName, options) {
273
328
  },
274
329
  objectStorage: {
275
330
  mode: "disabled",
276
- image: "quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z",
277
- clientImage: "quay.io/minio/mc:RELEASE.2025-08-13T08-35-41Z",
331
+ image: "pgsty/silo:RELEASE.2026-09-16T00-00-00Z",
332
+ clientImage: "pgsty/mc:RELEASE.2026-09-16T00-00-00Z",
278
333
  envFile: `${secretsDirectory}/object-storage.env`,
279
334
  requiredKeys: [
280
335
  "MINIO_ROOT_PASSWORD",
@@ -319,12 +374,24 @@ function initializeComposeProfile(projectRoot, name, options) {
319
374
  throw new Error(`Deployment profile "${name}" already exists.`);
320
375
  const draft = buildDefaultComposeProfile(projectName, options);
321
376
  const modules = new Set(manifest.modules.map((module) => module.name));
322
- const usesRedis = [...modules].some((module) => ["redis", "bullmq", "job-progress", "rate-limit", "sse"].includes(module));
377
+ const providers = manifest.providers;
378
+ const usesRedis = (providers.cache === "redis"
379
+ && ["redis", "rate-limit"].some((module) => modules.has(module)))
380
+ || (providers.events === "redis" && modules.has("sse"))
381
+ || (providers.jobs === "bullmq" && modules.has("bullmq"));
382
+ const usesEmbeddedProviders = providers.database === "sqlite"
383
+ || providers.cache === "memory"
384
+ || providers["object-storage"] === "local"
385
+ || providers.events === "memory"
386
+ || providers.jobs === "local";
387
+ draft.workloads.api.replicas = usesEmbeddedProviders ? 1 : 2;
388
+ draft.dependencies.postgres.mode = providers.database === "postgres" ? "managed" : "disabled";
323
389
  draft.dependencies.redis.mode = usesRedis ? "managed" : "disabled";
324
- draft.dependencies.objectStorage.mode = modules.has("object-storage-s3")
390
+ draft.dependencies.objectStorage.mode = providers["object-storage"] === "s3"
391
+ && modules.has("object-storage-s3")
325
392
  ? "managed"
326
393
  : "disabled";
327
- if (modules.has("bullmq")) {
394
+ if (providers.jobs === "bullmq" && modules.has("bullmq")) {
328
395
  draft.workloads.worker = { replicas: 1, resources: { cpuLimit: "0.5", memoryLimit: "512m" } };
329
396
  }
330
397
  const usesAuth = modules.has("auth");
@@ -336,7 +403,6 @@ function initializeComposeProfile(projectRoot, name, options) {
336
403
  draft.runtimeConfig = {
337
404
  ...draft.runtimeConfig,
338
405
  ...(usesAuth ? { BETTER_AUTH_URL: `https://${host}` } : {}),
339
- ...(modules.has("sse") && usesRedis ? { SSE_TRANSPORT: "redis" } : {}),
340
406
  ...(modules.has("rate-limit")
341
407
  ? {
342
408
  RATE_LIMIT_TTL: "60",
@@ -18,6 +18,7 @@ export interface ComposeRuntime {
18
18
  export interface ComposeImages {
19
19
  api: string;
20
20
  web: string;
21
+ gateway: string | null;
21
22
  postgres: string | null;
22
23
  redis: string | null;
23
24
  objectStorage: string | null;
@@ -38,7 +39,7 @@ export declare function derivedRuntimeConfig(profile: DockerComposeProfileV1): R
38
39
  * the containers instead of leaving stale values running.
39
40
  */
40
41
  export declare function rolloutStateDigest(profile: DockerComposeProfileV1, secretIdentities: Record<string, string>): string;
41
- export declare function renderComposeDocument(profile: DockerComposeProfileV1, release: string, images: ComposeImages, stateDigest: string): string;
42
+ export declare function renderComposeDocument(profile: DockerComposeProfileV1, release: string, images: ComposeImages, stateDigest: string, workerCommand?: string[]): string;
42
43
  /**
43
44
  * The migration runs as its own Compose project so it can be started, waited on,
44
45
  * and removed without touching the running application definition.
@@ -239,8 +239,46 @@ function applicationService(profile, role, image, release, stateDigest, options)
239
239
  " healthcheck:\n disable: true\n") +
240
240
  labelsBlock(profile, release, stateDigest, " "));
241
241
  }
242
- function renderComposeDocument(profile, release, images, stateDigest) {
242
+ function gatewayService(profile, image, release, stateDigest) {
243
+ const globalOptions = profile.exposure.trustedProxyCidrs.length
244
+ ? `{
245
+ servers {
246
+ trusted_proxies static ${profile.exposure.trustedProxyCidrs.join(" ")}
247
+ trusted_proxies_strict
248
+ }
249
+ }
250
+ `
251
+ : "";
252
+ const caddyfile = globalOptions +
253
+ `:3000 {\n` +
254
+ ` @api_websockets path ${profile.exposure.webSocketPaths.join(" ")}\n` +
255
+ ` handle @api_websockets {\n` +
256
+ ` reverse_proxy ${serviceName(profile, "api")}:${CONTAINER_PORT}\n` +
257
+ ` }\n` +
258
+ ` handle {\n` +
259
+ ` reverse_proxy ${serviceName(profile, "web")}:${CONTAINER_PORT}\n` +
260
+ ` }\n` +
261
+ `}\n`;
262
+ const start = "printf '%s' \"$${CADDYFILE_CONTENT}\" > /tmp/Caddyfile\n" +
263
+ "exec caddy run --config /tmp/Caddyfile --adapter caddyfile";
264
+ return (` ${serviceName(profile, "gateway")}:\n` +
265
+ ` image: ${quote(image)}\n` +
266
+ ` restart: unless-stopped\n` +
267
+ ` entrypoint: ["sh", "-c", ${quote(start)}]\n` +
268
+ environmentBlock({ CADDYFILE_CONTENT: caddyfile }, " ") +
269
+ ` depends_on:\n` +
270
+ ` ${serviceName(profile, "api")}:\n condition: service_healthy\n` +
271
+ ` ${serviceName(profile, "web")}:\n condition: service_healthy\n` +
272
+ ` ports:\n - ${quote(`${profile.exposure.bindAddress}:${profile.exposure.port}:${CONTAINER_PORT}`)}\n` +
273
+ ` networks: [${profile.target.project}]\n` +
274
+ ` healthcheck:\n` +
275
+ ` test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:2019/config/"]\n` +
276
+ ` interval: 10s\n timeout: 5s\n retries: 6\n start_period: 10s\n` +
277
+ labelsBlock(profile, release, stateDigest, " "));
278
+ }
279
+ function renderComposeDocument(profile, release, images, stateDigest, workerCommand = ["bun", "dist/main-worker.js"]) {
243
280
  const services = [];
281
+ const hasGateway = profile.exposure.webSocketPaths.length > 0;
244
282
  if (profile.dependencies.postgres.mode === "managed" && images.postgres) {
245
283
  services.push(postgresService(profile, images.postgres));
246
284
  }
@@ -258,13 +296,19 @@ function renderComposeDocument(profile, release, images, stateDigest) {
258
296
  }));
259
297
  services.push(applicationService(profile, "web", images.web, release, stateDigest, {
260
298
  healthcheckPath: "/",
261
- publish: true,
299
+ publish: !hasGateway,
262
300
  }));
301
+ if (hasGateway) {
302
+ if (!images.gateway) {
303
+ throw new Error("A resolved gateway image is required when WebSocket paths are configured.");
304
+ }
305
+ services.push(gatewayService(profile, images.gateway, release, stateDigest));
306
+ }
263
307
  if (profile.workloads.worker) {
264
308
  // The worker runs the API image with a different entry point, exactly as the
265
309
  // Kubernetes driver does.
266
310
  services.push(applicationService(profile, "worker", images.api, release, stateDigest, {
267
- command: `["node", "dist/main-worker"]`,
311
+ command: `[${workerCommand.map((part) => quote(part)).join(", ")}]`,
268
312
  }));
269
313
  }
270
314
  const volumes = [
@@ -288,7 +332,7 @@ function renderComposeDocument(profile, release, images, stateDigest) {
288
332
  * The migration runs as its own Compose project so it can be started, waited on,
289
333
  * and removed without touching the running application definition.
290
334
  */
291
- function renderMigrationDocument(profile, release, images, defaultCommand = ["npm", "run", "migrate:all"]) {
335
+ function renderMigrationDocument(profile, release, images, defaultCommand = ["bun", "run", "migrate:all"]) {
292
336
  const command = profile.migration?.command ?? defaultCommand;
293
337
  return (`# Generated by PodoKit. One-shot migration for ${release}.\n` +
294
338
  `name: ${profile.target.project}-migrate\n` +
@@ -306,6 +350,7 @@ function defaultComposeImages(profile, release) {
306
350
  return {
307
351
  api: `${profile.release.apiRepository}:${release}`,
308
352
  web: `${profile.release.webRepository}:${release}`,
353
+ gateway: profile.exposure.webSocketPaths.length > 0 ? profile.exposure.gatewayImage : null,
309
354
  postgres: profile.dependencies.postgres.mode === "managed"
310
355
  ? profile.dependencies.postgres.image
311
356
  : null,
@@ -325,8 +370,8 @@ function renderComposeDeployment(projectRoot, profileName, profile, release, res
325
370
  const root = (0, node_path_1.join)((0, node_path_1.resolve)(projectRoot), ".podokit", "runtime", "deploy", profileName);
326
371
  (0, node_fs_1.rmSync)(root, { recursive: true, force: true });
327
372
  (0, node_fs_1.mkdirSync)(root, { recursive: true });
328
- const composeDocument = renderComposeDocument(profile, release, images, effectiveStateDigest);
329
373
  const toolchain = (0, lockfile_1.readManifest)(projectRoot)?.toolchain ?? (0, toolchain_1.resolveToolchain)();
374
+ const composeDocument = renderComposeDocument(profile, release, images, effectiveStateDigest, (0, toolchain_1.toolchainWorkerCommand)(toolchain));
330
375
  const migrationDocument = renderMigrationDocument(profile, release, images, (0, toolchain_1.toolchainMigrationCommand)(toolchain));
331
376
  const composeFile = (0, node_path_1.join)(root, "compose.yaml");
332
377
  const migrationFile = (0, node_path_1.join)(root, "compose.migrate.yaml");
@@ -150,6 +150,7 @@ function resolveComposeImages(profile, release, runner) {
150
150
  return {
151
151
  api: resolveImage(defaults.api, runner),
152
152
  web: resolveImage(defaults.web, runner),
153
+ gateway: defaults.gateway ? resolveImage(defaults.gateway, runner) : null,
153
154
  postgres: defaults.postgres ? resolveImage(defaults.postgres, runner) : null,
154
155
  redis: defaults.redis ? resolveImage(defaults.redis, runner) : null,
155
156
  objectStorage: defaults.objectStorage ? resolveImage(defaults.objectStorage, runner) : null,
@@ -372,7 +373,7 @@ function planComposeDeployment(projectRoot, profileName, release, runner = deplo
372
373
  actions.push({
373
374
  order: order++,
374
375
  kind: "application",
375
- description: `Roll out api, web${profile.workloads.worker ? ", worker" : ""} and wait for health.`,
376
+ description: `Roll out api, web${profile.workloads.worker ? ", worker" : ""}${profile.exposure.webSocketPaths.length ? ", gateway" : ""} and wait for health.`,
376
377
  });
377
378
  actions.push({
378
379
  order: order++,
@@ -279,8 +279,19 @@ function initializeDeploymentProfile(projectRoot, name, options) {
279
279
  throw new Error("Project name cannot be converted to a Kubernetes release name.");
280
280
  const host = options.host ?? "app.example.com";
281
281
  const modules = new Set(manifest.modules.map((module) => module.name));
282
- const usesRedis = [...modules].some((module) => ["redis", "bullmq", "job-progress", "rate-limit", "sse"].includes(module));
283
- const usesObjectStorage = modules.has("object-storage-s3");
282
+ const providers = manifest.providers;
283
+ const usesPostgres = providers.database === "postgres";
284
+ const usesRedis = (providers.cache === "redis"
285
+ && ["redis", "rate-limit"].some((module) => modules.has(module)))
286
+ || (providers.events === "redis" && modules.has("sse"))
287
+ || (providers.jobs === "bullmq" && modules.has("bullmq"));
288
+ const usesObjectStorage = providers["object-storage"] === "s3"
289
+ && modules.has("object-storage-s3");
290
+ const usesEmbeddedProviders = providers.database === "sqlite"
291
+ || providers.cache === "memory"
292
+ || providers["object-storage"] === "local"
293
+ || providers.events === "memory"
294
+ || providers.jobs === "local";
284
295
  const usesAuth = modules.has("auth");
285
296
  const apiRequiredKeys = [
286
297
  ...(usesAuth ? ["BETTER_AUTH_SECRET"] : []),
@@ -311,7 +322,7 @@ function initializeDeploymentProfile(projectRoot, name, options) {
311
322
  },
312
323
  workloads: {
313
324
  api: {
314
- replicas: 2,
325
+ replicas: usesEmbeddedProviders ? 1 : 2,
315
326
  resources: {
316
327
  cpuRequest: "250m",
317
328
  cpuLimit: "1000m",
@@ -328,7 +339,7 @@ function initializeDeploymentProfile(projectRoot, name, options) {
328
339
  memoryLimit: "1Gi",
329
340
  },
330
341
  },
331
- worker: modules.has("bullmq")
342
+ worker: providers.jobs === "bullmq" && modules.has("bullmq")
332
343
  ? {
333
344
  replicas: 1,
334
345
  resources: {
@@ -342,7 +353,7 @@ function initializeDeploymentProfile(projectRoot, name, options) {
342
353
  },
343
354
  dependencies: {
344
355
  postgres: {
345
- mode: "inCluster",
356
+ mode: usesPostgres ? "inCluster" : "disabled",
346
357
  image: "postgres:16.10-alpine",
347
358
  secretName: `${releaseName}-postgres`,
348
359
  storageClassName: "",
@@ -357,8 +368,8 @@ function initializeDeploymentProfile(projectRoot, name, options) {
357
368
  },
358
369
  objectStorage: {
359
370
  mode: usesObjectStorage ? "inCluster" : "disabled",
360
- image: "quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z",
361
- clientImage: "quay.io/minio/mc:RELEASE.2025-08-13T08-35-41Z",
371
+ image: "pgsty/silo:RELEASE.2026-09-16T00-00-00Z",
372
+ clientImage: "pgsty/mc:RELEASE.2026-09-16T00-00-00Z",
362
373
  secretName: `${releaseName}-object-storage`,
363
374
  storageClassName: "",
364
375
  storageSize: "8Gi",
@@ -379,7 +390,6 @@ function initializeDeploymentProfile(projectRoot, name, options) {
379
390
  ADDRESS_HEADER: "x-forwarded-for",
380
391
  XFF_DEPTH: "1",
381
392
  ...(usesAuth ? { BETTER_AUTH_URL: `https://${host}` } : {}),
382
- ...(modules.has("sse") && usesRedis ? { SSE_TRANSPORT: "redis" } : {}),
383
393
  ...(modules.has("rate-limit")
384
394
  ? {
385
395
  RATE_LIMIT_TTL: "60",