@podosoft/podokit 1.0.2 → 1.1.4

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 (119) hide show
  1. package/README.md +74 -6
  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 +1 -0
  9. package/dist/deploy-compose-render.js +46 -1
  10. package/dist/deploy-compose.js +2 -1
  11. package/dist/deploy-profile.js +18 -8
  12. package/dist/deploy.js +157 -1
  13. package/dist/index.d.ts +1 -0
  14. package/dist/index.js +41 -0
  15. package/dist/lib.d.ts +2 -0
  16. package/dist/lib.js +8 -2
  17. package/dist/lockfile.d.ts +6 -1
  18. package/dist/lockfile.js +22 -4
  19. package/dist/provider.d.ts +24 -0
  20. package/dist/provider.js +142 -0
  21. package/dist/remove.js +6 -3
  22. package/dist/templates/base/AGENTS.md +9 -3
  23. package/dist/templates/base/README.md +12 -8
  24. package/dist/templates/fullstack/AGENTS.md +24 -5
  25. package/dist/templates/fullstack/README.md +75 -11
  26. package/dist/templates/fullstack/apps/api/package.json +4 -3
  27. package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +1 -0
  28. package/dist/templates/fullstack/apps/api/src/config/providers.ts +9 -0
  29. package/dist/templates/fullstack/apps/api/src/core/services.ts +7 -2
  30. package/dist/templates/fullstack/apps/api/src/database/data-source.ts +30 -8
  31. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +58 -0
  32. package/dist/templates/fullstack/apps/api/src/database/database.ts +58 -2
  33. package/dist/templates/fullstack/apps/api/src/database/migrate.ts +24 -2
  34. package/dist/templates/fullstack/apps/api/src/database/sqlite-migrator.spec.ts +50 -0
  35. package/dist/templates/fullstack/apps/api/src/database/sqlite-migrator.ts +134 -0
  36. package/dist/templates/fullstack/compose.dev.yaml +2 -2
  37. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +10 -3
  38. package/dist/templates/fullstack/dot-env.example +2 -0
  39. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +2 -2
  40. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.spec.ts +10 -4
  41. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.ts +10 -4
  42. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.ts +10 -4
  43. package/dist/templates/modules/admin-dashboard/module.manifest.json +3 -5
  44. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-enabled.ts +2 -5
  45. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.migration.spec.ts +33 -0
  46. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +13 -0
  47. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +17 -8
  48. package/dist/templates/modules/audit-log/files/apps/api/src/migrations/1720300000000-InitAuditLogs.ts +5 -4
  49. package/dist/templates/modules/audit-log/module.manifest.json +1 -1
  50. package/dist/templates/modules/auth/files/apps/api/src/auth/account-issuer-migration.spec.ts +88 -0
  51. package/dist/templates/modules/auth/files/apps/api/src/auth/account-issuer-migration.ts +123 -0
  52. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +2 -2
  53. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +4 -0
  54. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +2 -2
  55. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +4 -4
  56. package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +55 -10
  57. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +10 -14
  58. package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +24 -6
  59. package/dist/templates/modules/auth/files/apps/api/src/migrations/1720400000000-InitAppSettings.ts +2 -1
  60. package/dist/templates/modules/auth/files/apps/api/src/migrations/1720500000000-InitAuthConfig.ts +4 -3
  61. package/dist/templates/modules/auth/files/apps/api/src/settings/app-settings.migration.spec.ts +15 -4
  62. package/dist/templates/modules/auth/files/apps/api/src/settings/auth-config.migration.spec.ts +30 -0
  63. package/dist/templates/modules/auth/module.manifest.json +6 -6
  64. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +10 -0
  65. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +107 -0
  66. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/jobs.module.ts +12 -11
  67. package/dist/templates/modules/bullmq/files/apps/api/src/main-worker.ts +5 -0
  68. package/dist/templates/modules/bullmq/module.manifest.json +7 -0
  69. package/dist/templates/modules/cache-memory/files/apps/api/src/cache-memory/cache-memory.module.spec.ts +16 -0
  70. package/dist/templates/modules/cache-memory/files/apps/api/src/cache-memory/cache-memory.module.ts +12 -0
  71. package/dist/templates/modules/cache-memory/module.manifest.json +30 -0
  72. package/dist/templates/modules/events-memory/files/apps/api/src/events-provider-memory/events-memory.module.ts +14 -0
  73. package/dist/templates/modules/events-memory/module.manifest.json +30 -0
  74. package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/events-redis.module.ts +16 -0
  75. package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/redis-event.bus.ts +75 -0
  76. package/dist/templates/modules/events-redis/module.manifest.json +32 -0
  77. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.ts +9 -5
  78. package/dist/templates/modules/file-upload/files/tests/api/files.api.spec.ts +1 -1
  79. package/dist/templates/modules/file-upload/module.manifest.json +4 -3
  80. package/dist/templates/modules/job-progress/files/apps/api/src/progress/job-progress.module.ts +36 -14
  81. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.spec.ts +9 -32
  82. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.processor.ts +16 -11
  83. package/dist/templates/modules/job-progress/module.manifest.json +7 -8
  84. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/jobs-local.module.ts +42 -0
  85. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +45 -0
  86. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +212 -0
  87. package/dist/templates/modules/jobs-local/module.manifest.json +34 -0
  88. package/dist/templates/modules/mailer/files/apps/api/src/mail/mailer.ts +7 -3
  89. package/dist/templates/modules/mailer/module.manifest.json +2 -4
  90. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.spec.ts +25 -0
  91. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.ts +60 -0
  92. package/dist/templates/modules/object-storage-local/module.manifest.json +34 -0
  93. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.ts +50 -0
  94. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.module.ts +8 -0
  95. package/dist/templates/modules/object-storage-s3/files/infra/docker/minio.compose.yml +4 -4
  96. package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +20 -12
  97. package/dist/templates/modules/object-storage-s3/module.manifest.json +4 -2
  98. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +14 -1
  99. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +18 -37
  100. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +12 -0
  101. package/dist/templates/modules/rate-limit/module.manifest.json +8 -6
  102. package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.ts +82 -0
  103. package/dist/templates/modules/redis/files/apps/api/src/redis/redis.module.ts +6 -0
  104. package/dist/templates/modules/redis/module.manifest.json +2 -0
  105. package/dist/templates/modules/sse/files/apps/api/src/events/events.module.ts +5 -6
  106. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +4 -4
  107. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +6 -5
  108. package/dist/templates/modules/sse/module.manifest.json +4 -6
  109. package/dist/templates/todo/AGENTS.md +25 -6
  110. package/dist/templates/todo/README.md +72 -14
  111. package/dist/templates/todo/apps/api/src/migrations/1720100000000-InitTodos.ts +11 -0
  112. package/dist/templates/todo/apps/api/src/todos/todo.repository.spec.ts +76 -0
  113. package/dist/templates/todo/apps/api/src/todos/todo.repository.ts +9 -4
  114. package/dist/templates/todo/tests/api/todo.api.spec.ts +32 -0
  115. package/dist/templates/todo/tests/ui/todo.ui.spec.ts +31 -0
  116. package/package.json +2 -1
  117. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.ts +0 -32
  118. package/dist/templates/modules/mailer/files/apps/api/src/mail/db.ts +0 -13
  119. package/dist/templates/modules/sse/files/apps/api/src/events/events.transport.ts +0 -111
package/README.md CHANGED
@@ -1,17 +1,44 @@
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.
9
9
 
10
+ ## Choose infrastructure
11
+
12
+ `fullstack` is the default template. `todo` adds a working CRUD example; `base`
13
+ is a minimal workspace with placeholder scripts. For a small working app, use
14
+ `fullstack` or `todo` with local providers rather than choosing `base` just to
15
+ avoid external infrastructure.
16
+
17
+ | Capability | Default | Local alternative |
18
+ | --- | --- | --- |
19
+ | Database | `postgres` — PostgreSQL | `sqlite` — database file |
20
+ | Cache | `redis` | `memory` — in-process cache |
21
+ | Object storage | `s3` — AWS S3 or S3-compatible storage | `local` — local files |
22
+ | Events | `redis` | `memory` — in-process events |
23
+ | Jobs | `bullmq` — Redis queue and separate worker | `local` — database-backed embedded worker |
24
+
25
+ A simple program, prototype, desktop app, or single-process service can use
26
+ **SQLite, memory cache, and local files** without PostgreSQL, Redis, or S3.
27
+ Providers can be mixed. Local providers require one API process.
28
+
29
+ Only the database is selected during creation with `--database postgres|sqlite`.
30
+ Other capabilities use `provider set` after creation; there are no `--cache` or
31
+ `--storage` creation flags. Choosing SQLite alone leaves the other defaults
32
+ unchanged. Creation records selections, while applying a provider or adding a
33
+ feature installs the necessary optional implementation modules.
34
+
10
35
  ## Quick start
11
36
 
37
+ ### Default server setup
38
+
12
39
  ```bash
13
40
  npx @podosoft/podokit create my-app
14
- # or: bunx @podosoft/podokit create my-app
41
+ # or: bunx --bun @podosoft/podokit create my-app
15
42
 
16
43
  cd my-app
17
44
  bun install
@@ -24,10 +51,30 @@ gateway on loopback port 80; later projects reuse it and route by hostname. Stop
24
51
  the project with `podo dev down`.
25
52
 
26
53
  The `todo` template generates a working SvelteKit UI, Elysia CRUD API, and
27
- PostgreSQL persistence:
54
+ PostgreSQL or SQLite persistence:
28
55
 
29
56
  ![Generated todo app](https://raw.githubusercontent.com/podosoft-dev/podokit/main/docs/images/todo-app.png)
30
57
 
58
+ ### Small app with local providers
59
+
60
+ ```bash
61
+ bunx --bun @podosoft/podokit create podokit --template todo --database sqlite --yes
62
+ cd podokit
63
+ bunx --bun @podosoft/podokit provider set cache memory --apply
64
+ bunx --bun @podosoft/podokit provider set object-storage local --apply
65
+ bunx --bun @podosoft/podokit provider set events memory --apply
66
+ bunx --bun @podosoft/podokit provider set jobs local --apply
67
+ bun install
68
+ cp .env.example .env
69
+ bun run --cwd apps/api migration:run
70
+ ```
71
+
72
+ Run `bun run --cwd apps/api dev` and `bun run --cwd apps/web dev` in separate
73
+ terminals and open `http://localhost:5001`. This host-process setup requires no
74
+ Docker. SQLite and local files default to `./data/podokit.sqlite` and
75
+ `./data/files`, relative to the API working directory. Memory state disappears
76
+ on restart; files and the database-backed local jobs persist.
77
+
31
78
  ## Commands
32
79
 
33
80
  | Command | Purpose |
@@ -35,6 +82,8 @@ PostgreSQL persistence:
35
82
  | `podo create <name>` | Create a Bun project from `fullstack`, `todo`, or `base`. |
36
83
  | `podo add <module>` | Add a composable feature module. |
37
84
  | `podo remove <module>` | Remove a module conservatively. |
85
+ | `podo provider list` | Show active database, cache, object storage, event, and job providers. |
86
+ | `podo provider set <capability> <provider> [--apply]` | Preview or apply one provider change. |
38
87
  | `podo status` | Show the PodoKit version, modules, ownership tiers, and local edits. |
39
88
  | `podo diff` | List edited or deleted managed files. |
40
89
  | `podo doctor` | Check Bun and framework versions. |
@@ -42,7 +91,7 @@ PostgreSQL persistence:
42
91
  | `podo update [--apply]` | Preview or apply managed template and module updates. |
43
92
  | `podo eject <path...>` | Transfer managed files to application ownership. |
44
93
  | `podo dev <action>` | Manage the generated development containers. |
45
- | `podo deploy <action>` | Plan, apply, verify, or roll back an immutable Helm release. |
94
+ | `podo deploy <action>` | Plan, apply, verify, or roll back an immutable Kubernetes or Docker Compose release. |
46
95
 
47
96
  ```text
48
97
  podo create <name> [options]
@@ -50,6 +99,7 @@ podo create <name> [options]
50
99
  Options:
51
100
  --template <name> fullstack (default), todo, or base
52
101
  --dir <path> Target directory (default: ./<name>)
102
+ --database <name> postgres (default) or sqlite
53
103
  --no-ai Omit generated AI-agent guidance
54
104
  -y, --yes Accept defaults without prompts
55
105
  -h, --help Show help
@@ -80,6 +130,24 @@ remain untouched.
80
130
  See the [module guide](https://github.com/podosoft-dev/podokit/blob/main/docs/modules.md)
81
131
  and [updating guide](https://github.com/podosoft-dev/podokit/blob/main/docs/updating.md).
82
132
 
133
+ ## Runtime providers
134
+
135
+ Inspect active selections and available alternatives with `podo provider list`.
136
+ `podo provider set` is a dry run by default and installs the selected
137
+ implementation module on `--apply`. Configure providers before adding features:
138
+ `file-upload` uses local storage when selected, and `job-progress` can use local
139
+ jobs and memory events. Changes append settings to `.env.example`; merge them
140
+ into an existing `.env` manually. Switching changes code and configuration only;
141
+ it never migrates or deletes data.
142
+
143
+ S3's development settings use Silo with `STORAGE_PROVIDER=minio`; use
144
+ `STORAGE_PROVIDER=aws` for AWS S3. Local files use `LOCAL_STORAGE_PATH`.
145
+
146
+ See the [runtime provider guide](https://github.com/podosoft-dev/podokit/blob/main/docs/providers.md)
147
+ before switching an existing project.
148
+ For the complete creation sequence, see
149
+ [Getting Started](https://github.com/podosoft-dev/podokit/blob/main/docs/getting-started.md).
150
+
83
151
  ## Compatibility boundary
84
152
 
85
153
  PodoKit v1 does not convert projects generated by PodoKit 0.x. Existing projects
@@ -99,7 +167,7 @@ my-app/
99
167
  │ ├── api/ # Bun + Elysia + native Bun.SQL
100
168
  │ └── web/ # SvelteKit + Tailwind CSS v4 + shadcn-svelte
101
169
  ├── infra/
102
- │ ├── docker/ # PostgreSQL, Redis, and optional services
170
+ │ ├── docker/ # provider-aware external services
103
171
  │ └── k3s/ # deployments, services, ingress, and examples
104
172
  ├── tests/ # Playwright API and browser tests
105
173
  ├── .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;
@@ -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 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
+ }
242
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,8 +296,14 @@ function renderComposeDocument(profile, release, images, stateDigest, workerComm
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.
@@ -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,
@@ -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",