@decohere/scaffold 0.0.1-dev.126 → 0.0.1-dev.18

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 (264) hide show
  1. package/package.json +9 -19
  2. package/src/add-api.action.ts +25 -0
  3. package/src/add-api.command.ts +31 -0
  4. package/src/add.command.ts +20 -0
  5. package/src/add.module.ts +11 -0
  6. package/src/cli.ts +4 -0
  7. package/src/create.command.ts +20 -0
  8. package/src/create.module.ts +11 -0
  9. package/src/domain/prompt.ts +50 -0
  10. package/{types/src/index.d.ts → src/index.ts} +0 -4
  11. package/src/models/compose.model.ts +205 -0
  12. package/src/models/config-yaml.model.ts +83 -0
  13. package/src/models/const.ts +20 -0
  14. package/src/models/dev-container.model.ts +136 -0
  15. package/src/models/docker-image.model.ts +343 -0
  16. package/src/models/env-file.model.ts +45 -0
  17. package/src/models/gitignore.model.ts +35 -0
  18. package/src/models/launch-json.model.ts +60 -0
  19. package/src/models/package-json.model.ts +169 -0
  20. package/src/models/project.model.ts +141 -0
  21. package/{types/src/models/source-file.d.ts → src/models/source-file.ts} +6 -4
  22. package/src/models/tsconfig.model.ts +29 -0
  23. package/src/models/vitest-config.model.ts +26 -0
  24. package/src/models/vscode-settings.model.ts +24 -0
  25. package/src/program.ts +63 -0
  26. package/{types/src/resolve-target-dir.d.ts → src/resolve-target-dir.ts} +4 -1
  27. package/src/services/scaffold.service.ts +89 -0
  28. package/src/version.ts +56 -0
  29. package/src/webapi/api-name.ts +73 -0
  30. package/src/webapi/cli-options.ts +56 -0
  31. package/{types/src/webapi/const.d.ts → src/webapi/const.ts} +21 -9
  32. package/src/webapi/create-api.ts +275 -0
  33. package/src/webapi/devcontainer/host-scripts.ts +35 -0
  34. package/src/webapi/features/auth.feature.ts +454 -0
  35. package/src/webapi/features/auth0-cli.ts +421 -0
  36. package/src/webapi/features/biome-format.ts +34 -0
  37. package/src/webapi/features/database.feature.ts +522 -0
  38. package/src/webapi/features/finalize.feature.ts +112 -0
  39. package/src/webapi/features/postman.feature.ts +287 -0
  40. package/src/webapi/models/api-package-json.model.ts +60 -0
  41. package/src/webapi/models/auth-config.model.ts +36 -0
  42. package/src/webapi/models/controller-source-file.model.ts +232 -0
  43. package/{es6/src/webapi/models/github-ci.model.js → src/webapi/models/github-ci.model.ts} +29 -27
  44. package/src/webapi/models/main-source-file.model.ts +208 -0
  45. package/src/webapi/models/production-dockerfile.model.ts +162 -0
  46. package/src/webapi/models/sample-files.model.ts +45 -0
  47. package/src/webapi/models/webapi-image.model.ts +21 -0
  48. package/src/webapi/postman/hello-openapi.ts +92 -0
  49. package/src/webapi/postman/local-view.ts +195 -0
  50. package/src/webapi/postman/openapi-to-postman.ts +208 -0
  51. package/src/webapi/workspace-readme.ts +192 -0
  52. package/src/webapi.action.ts +22 -0
  53. package/src/webapi.command.ts +41 -0
  54. package/tests/auth-postman.wiring.spec.ts +263 -0
  55. package/tests/auth0-cli.spec.ts +232 -0
  56. package/tests/biome-format.spec.ts +49 -0
  57. package/tests/cli.integration.spec.ts +658 -0
  58. package/tests/compose.model.spec.ts +51 -0
  59. package/tests/container-image.model.spec.ts +162 -0
  60. package/tests/dev-container.model.spec.ts +29 -0
  61. package/tests/devcontainer-base-image.spec.ts +32 -0
  62. package/tests/hello-di-inject.scaffold.spec.ts +18 -0
  63. package/tests/hello-route.contract.spec.ts +94 -0
  64. package/tests/package-json.order.spec.ts +59 -0
  65. package/tests/postman-from-openapi.spec.ts +197 -0
  66. package/tests/seed-devcontainer-templates.ts +40 -0
  67. package/tests/webapi/devcontainer/codebase-rag-mcp.spec.ts +96 -0
  68. package/LICENSE +0 -15
  69. package/commonjs/src/add-api.action.js +0 -37
  70. package/commonjs/src/add-api.command.js +0 -39
  71. package/commonjs/src/add.command.js +0 -22
  72. package/commonjs/src/add.module.js +0 -24
  73. package/commonjs/src/auth0-configure.action.js +0 -28
  74. package/commonjs/src/auth0-configure.command.js +0 -24
  75. package/commonjs/src/auth0-login.action.js +0 -28
  76. package/commonjs/src/auth0-login.command.js +0 -23
  77. package/commonjs/src/auth0.command.js +0 -22
  78. package/commonjs/src/auth0.module.js +0 -28
  79. package/commonjs/src/cli.js +0 -8
  80. package/commonjs/src/create.command.js +0 -22
  81. package/commonjs/src/create.module.js +0 -24
  82. package/commonjs/src/domain/prompt.js +0 -62
  83. package/commonjs/src/index.js +0 -48
  84. package/commonjs/src/models/compose.model.js +0 -258
  85. package/commonjs/src/models/config-yaml.model.js +0 -86
  86. package/commonjs/src/models/const.js +0 -30
  87. package/commonjs/src/models/dev-container.model.js +0 -172
  88. package/commonjs/src/models/docker-image.model.js +0 -277
  89. package/commonjs/src/models/env-file.model.js +0 -48
  90. package/commonjs/src/models/gitignore.model.js +0 -48
  91. package/commonjs/src/models/launch-json.model.js +0 -70
  92. package/commonjs/src/models/package-json.model.js +0 -156
  93. package/commonjs/src/models/project.model.js +0 -132
  94. package/commonjs/src/models/source-file.js +0 -4
  95. package/commonjs/src/models/tsconfig.model.js +0 -32
  96. package/commonjs/src/models/vitest-config.model.js +0 -33
  97. package/commonjs/src/models/vscode-settings.model.js +0 -29
  98. package/commonjs/src/program.js +0 -64
  99. package/commonjs/src/resolve-target-dir.js +0 -17
  100. package/commonjs/src/services/scaffold.service.js +0 -60
  101. package/commonjs/src/version.js +0 -55
  102. package/commonjs/src/webapi/api-name.js +0 -93
  103. package/commonjs/src/webapi/auth0-commands.js +0 -250
  104. package/commonjs/src/webapi/authorize-scopes.js +0 -79
  105. package/commonjs/src/webapi/cli-options.js +0 -99
  106. package/commonjs/src/webapi/const.js +0 -54
  107. package/commonjs/src/webapi/create-api.js +0 -265
  108. package/commonjs/src/webapi/devcontainer/codebase-rag-mcp.js +0 -164
  109. package/commonjs/src/webapi/devcontainer/codebase-rag-mcp.ts +0 -182
  110. package/commonjs/src/webapi/devcontainer/host-scripts.js +0 -50
  111. package/commonjs/src/webapi/devcontainer/templates/codebase-rag.env.example +0 -3
  112. package/commonjs/src/webapi/devcontainer/templates/initialize.sh +0 -169
  113. package/commonjs/src/webapi/devcontainer/templates/post-create.sh +0 -35
  114. package/commonjs/src/webapi/devcontainer/templates/post-start.sh +0 -65
  115. package/commonjs/src/webapi/devcontainer/templates/tty-prompt.sh +0 -38
  116. package/commonjs/src/webapi/features/auth.feature.js +0 -408
  117. package/commonjs/src/webapi/features/auth0-cli.js +0 -472
  118. package/commonjs/src/webapi/features/biome-format.js +0 -47
  119. package/commonjs/src/webapi/features/database.feature.js +0 -433
  120. package/commonjs/src/webapi/features/finalize.feature.js +0 -93
  121. package/commonjs/src/webapi/features/postman.feature.js +0 -322
  122. package/commonjs/src/webapi/models/api-package-json.model.js +0 -58
  123. package/commonjs/src/webapi/models/auth-config.model.js +0 -24
  124. package/commonjs/src/webapi/models/controller-source-file.model.js +0 -190
  125. package/commonjs/src/webapi/models/github-ci.model.js +0 -193
  126. package/commonjs/src/webapi/models/main-source-file.model.js +0 -165
  127. package/commonjs/src/webapi/models/production-dockerfile.model.js +0 -75
  128. package/commonjs/src/webapi/models/sample-files.model.js +0 -58
  129. package/commonjs/src/webapi/models/webapi-image.model.js +0 -25
  130. package/commonjs/src/webapi/postman/hello-openapi.js +0 -173
  131. package/commonjs/src/webapi/postman/local-view.js +0 -182
  132. package/commonjs/src/webapi/postman/openapi-to-postman.js +0 -238
  133. package/commonjs/src/webapi/workspace-readme.js +0 -109
  134. package/commonjs/src/webapi.action.js +0 -28
  135. package/commonjs/src/webapi.command.js +0 -43
  136. package/es6/src/add-api.action.js +0 -27
  137. package/es6/src/add-api.command.js +0 -29
  138. package/es6/src/add.command.js +0 -12
  139. package/es6/src/add.module.js +0 -14
  140. package/es6/src/auth0-configure.action.js +0 -18
  141. package/es6/src/auth0-configure.command.js +0 -14
  142. package/es6/src/auth0-login.action.js +0 -18
  143. package/es6/src/auth0-login.command.js +0 -13
  144. package/es6/src/auth0.command.js +0 -12
  145. package/es6/src/auth0.module.js +0 -18
  146. package/es6/src/cli.js +0 -4
  147. package/es6/src/create.command.js +0 -12
  148. package/es6/src/create.module.js +0 -14
  149. package/es6/src/domain/prompt.js +0 -41
  150. package/es6/src/index.js +0 -9
  151. package/es6/src/models/compose.model.js +0 -254
  152. package/es6/src/models/config-yaml.model.js +0 -74
  153. package/es6/src/models/const.js +0 -20
  154. package/es6/src/models/dev-container.model.js +0 -162
  155. package/es6/src/models/docker-image.model.js +0 -235
  156. package/es6/src/models/env-file.model.js +0 -44
  157. package/es6/src/models/gitignore.model.js +0 -38
  158. package/es6/src/models/launch-json.model.js +0 -60
  159. package/es6/src/models/package-json.model.js +0 -141
  160. package/es6/src/models/project.model.js +0 -117
  161. package/es6/src/models/source-file.js +0 -1
  162. package/es6/src/models/tsconfig.model.js +0 -22
  163. package/es6/src/models/vitest-config.model.js +0 -23
  164. package/es6/src/models/vscode-settings.model.js +0 -19
  165. package/es6/src/program.js +0 -50
  166. package/es6/src/resolve-target-dir.js +0 -7
  167. package/es6/src/services/scaffold.service.js +0 -50
  168. package/es6/src/version.js +0 -34
  169. package/es6/src/webapi/api-name.js +0 -57
  170. package/es6/src/webapi/auth0-commands.js +0 -185
  171. package/es6/src/webapi/authorize-scopes.js +0 -55
  172. package/es6/src/webapi/cli-options.js +0 -34
  173. package/es6/src/webapi/const.js +0 -21
  174. package/es6/src/webapi/create-api.js +0 -245
  175. package/es6/src/webapi/devcontainer/codebase-rag-mcp.js +0 -154
  176. package/es6/src/webapi/devcontainer/codebase-rag-mcp.ts +0 -182
  177. package/es6/src/webapi/devcontainer/host-scripts.js +0 -29
  178. package/es6/src/webapi/devcontainer/templates/codebase-rag.env.example +0 -3
  179. package/es6/src/webapi/devcontainer/templates/initialize.sh +0 -169
  180. package/es6/src/webapi/devcontainer/templates/post-create.sh +0 -35
  181. package/es6/src/webapi/devcontainer/templates/post-start.sh +0 -65
  182. package/es6/src/webapi/devcontainer/templates/tty-prompt.sh +0 -38
  183. package/es6/src/webapi/features/auth.feature.js +0 -357
  184. package/es6/src/webapi/features/auth0-cli.js +0 -406
  185. package/es6/src/webapi/features/biome-format.js +0 -40
  186. package/es6/src/webapi/features/database.feature.js +0 -423
  187. package/es6/src/webapi/features/finalize.feature.js +0 -83
  188. package/es6/src/webapi/features/postman.feature.js +0 -256
  189. package/es6/src/webapi/models/api-package-json.model.js +0 -53
  190. package/es6/src/webapi/models/auth-config.model.js +0 -20
  191. package/es6/src/webapi/models/controller-source-file.model.js +0 -181
  192. package/es6/src/webapi/models/main-source-file.model.js +0 -161
  193. package/es6/src/webapi/models/production-dockerfile.model.js +0 -66
  194. package/es6/src/webapi/models/sample-files.model.js +0 -40
  195. package/es6/src/webapi/models/webapi-image.model.js +0 -18
  196. package/es6/src/webapi/postman/hello-openapi.js +0 -166
  197. package/es6/src/webapi/postman/local-view.js +0 -167
  198. package/es6/src/webapi/postman/openapi-to-postman.js +0 -179
  199. package/es6/src/webapi/workspace-readme.js +0 -88
  200. package/es6/src/webapi.action.js +0 -18
  201. package/es6/src/webapi.command.js +0 -33
  202. package/types/src/add-api.action.d.ts +0 -13
  203. package/types/src/add-api.command.d.ts +0 -7
  204. package/types/src/add.command.d.ts +0 -6
  205. package/types/src/add.module.d.ts +0 -14
  206. package/types/src/auth0-configure.action.d.ts +0 -7
  207. package/types/src/auth0-configure.command.d.ts +0 -6
  208. package/types/src/auth0-login.action.d.ts +0 -4
  209. package/types/src/auth0-login.command.d.ts +0 -6
  210. package/types/src/auth0.command.d.ts +0 -6
  211. package/types/src/auth0.module.d.ts +0 -18
  212. package/types/src/cli.d.ts +0 -2
  213. package/types/src/create.command.d.ts +0 -6
  214. package/types/src/create.module.d.ts +0 -14
  215. package/types/src/domain/prompt.d.ts +0 -4
  216. package/types/src/models/compose.model.d.ts +0 -73
  217. package/types/src/models/config-yaml.model.d.ts +0 -27
  218. package/types/src/models/const.d.ts +0 -20
  219. package/types/src/models/dev-container.model.d.ts +0 -49
  220. package/types/src/models/docker-image.model.d.ts +0 -90
  221. package/types/src/models/env-file.model.d.ts +0 -16
  222. package/types/src/models/gitignore.model.d.ts +0 -9
  223. package/types/src/models/launch-json.model.d.ts +0 -7
  224. package/types/src/models/package-json.model.d.ts +0 -36
  225. package/types/src/models/project.model.d.ts +0 -60
  226. package/types/src/models/tsconfig.model.d.ts +0 -5
  227. package/types/src/models/vitest-config.model.d.ts +0 -5
  228. package/types/src/models/vscode-settings.model.d.ts +0 -7
  229. package/types/src/program.d.ts +0 -8
  230. package/types/src/services/scaffold.service.d.ts +0 -22
  231. package/types/src/version.d.ts +0 -6
  232. package/types/src/webapi/api-name.d.ts +0 -14
  233. package/types/src/webapi/auth0-commands.d.ts +0 -11
  234. package/types/src/webapi/authorize-scopes.d.ts +0 -7
  235. package/types/src/webapi/cli-options.d.ts +0 -6
  236. package/types/src/webapi/create-api.d.ts +0 -15
  237. package/types/src/webapi/devcontainer/codebase-rag-mcp.d.ts +0 -32
  238. package/types/src/webapi/devcontainer/codebase-rag-mcp.ts +0 -182
  239. package/types/src/webapi/devcontainer/host-scripts.d.ts +0 -7
  240. package/types/src/webapi/devcontainer/templates/codebase-rag.env.example +0 -3
  241. package/types/src/webapi/devcontainer/templates/initialize.sh +0 -169
  242. package/types/src/webapi/devcontainer/templates/post-create.sh +0 -35
  243. package/types/src/webapi/devcontainer/templates/post-start.sh +0 -65
  244. package/types/src/webapi/devcontainer/templates/tty-prompt.sh +0 -38
  245. package/types/src/webapi/features/auth.feature.d.ts +0 -10
  246. package/types/src/webapi/features/auth0-cli.d.ts +0 -84
  247. package/types/src/webapi/features/biome-format.d.ts +0 -5
  248. package/types/src/webapi/features/database.feature.d.ts +0 -7
  249. package/types/src/webapi/features/finalize.feature.d.ts +0 -2
  250. package/types/src/webapi/features/postman.feature.d.ts +0 -16
  251. package/types/src/webapi/models/api-package-json.model.d.ts +0 -18
  252. package/types/src/webapi/models/auth-config.model.d.ts +0 -26
  253. package/types/src/webapi/models/controller-source-file.model.d.ts +0 -46
  254. package/types/src/webapi/models/github-ci.model.d.ts +0 -9
  255. package/types/src/webapi/models/main-source-file.model.d.ts +0 -54
  256. package/types/src/webapi/models/production-dockerfile.model.d.ts +0 -18
  257. package/types/src/webapi/models/sample-files.model.d.ts +0 -7
  258. package/types/src/webapi/models/webapi-image.model.d.ts +0 -12
  259. package/types/src/webapi/postman/hello-openapi.d.ts +0 -19
  260. package/types/src/webapi/postman/local-view.d.ts +0 -17
  261. package/types/src/webapi/postman/openapi-to-postman.d.ts +0 -62
  262. package/types/src/webapi/workspace-readme.d.ts +0 -18
  263. package/types/src/webapi.action.d.ts +0 -14
  264. package/types/src/webapi.command.d.ts +0 -11
@@ -0,0 +1,522 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { pick } from "../../domain/prompt.js";
5
+ import type { DockerCompose } from "../../models/compose.model.js";
6
+ import type { ConfigYaml } from "../../models/config-yaml.model.js";
7
+ import { WELL_KNOWN_FILES } from "../../models/const.js";
8
+ import type { DevContainer } from "../../models/dev-container.model.js";
9
+ import {
10
+ type DockerfileModel,
11
+ withPostgresqlClient,
12
+ } from "../../models/docker-image.model.js";
13
+ import type { EnvFile } from "../../models/env-file.model.js";
14
+ import type { Gitignore } from "../../models/gitignore.model.js";
15
+ import type { PackageJson } from "../../models/package-json.model.js";
16
+ import type { ProjectScaffold, WorkspaceScaffold } from "../../models/project.model.js";
17
+ import type { Feature } from "../../services/scaffold.service.js";
18
+ import { CREATE_VERSION } from "../../version.js";
19
+ import { apiEnvPrefix, apiNameFromProjectDir } from "../api-name.js";
20
+ import type { ApiPackageJson } from "../models/api-package-json.model.js";
21
+ import type { MainSourceFile } from "../models/main-source-file.model.js";
22
+
23
+ /** Bind path relative to `.devcontainer/docker-compose.yml` (dies with the working tree). */
24
+ const POSTGRES_DATA_BIND = "./postgres-data:/var/lib/postgresql/data";
25
+ const POSTGRES_DATA_GITIGNORE = ".devcontainer/postgres-data";
26
+ const DB_RESET_SCRIPT =
27
+ "rm -rf .devcontainer/postgres-data && docker compose -f .devcontainer/docker-compose.yml down && docker compose -f .devcontainer/docker-compose.yml up -d postgres";
28
+
29
+ export type DatabaseOptions = {
30
+ databaseProvider?: "none" | "postgres/typeorm";
31
+ /** When true, merge into an existing workspace (\`webapi add\`). */
32
+ add?: boolean;
33
+ };
34
+
35
+ /** Read a single KEY= from an existing target `.env` (if any). */
36
+ function readExistingEnvValue(workspace: WorkspaceScaffold, key: string): string | undefined {
37
+ try {
38
+ const raw = readFileSync(join(workspace.targetDir, ".env"), "utf8");
39
+ const line = raw.split("\n").find((l) => l.startsWith(`${key}=`));
40
+ const value = line?.slice(key.length + 1).trim();
41
+ return value || undefined;
42
+ } catch {
43
+ return undefined;
44
+ }
45
+ }
46
+
47
+ function randomPassword(): string {
48
+ return randomBytes(16).toString("hex");
49
+ }
50
+
51
+ /** Safe Postgres identifier fragment from the project name. */
52
+ function toSqlIdentFragment(name: string): string {
53
+ const cleaned = name.replace(/[^A-Za-z0-9_]/g, "_").replace(/^([^A-Za-z_])/, "p_$1");
54
+ return cleaned.length > 0 ? cleaned.slice(0, 40) : "app";
55
+ }
56
+
57
+ function passwordOrExisting(
58
+ workspace: WorkspaceScaffold,
59
+ key: string,
60
+ fallbackExistingKeys: string[] = [],
61
+ ): string {
62
+ const existing = readExistingEnvValue(workspace, key);
63
+ if (existing) return existing;
64
+ for (const alt of fallbackExistingKeys) {
65
+ const v = readExistingEnvValue(workspace, alt);
66
+ if (v) return v;
67
+ }
68
+ return randomPassword();
69
+ }
70
+
71
+ // Production: runtime app role only — no bootstrap, no admin/migrate secrets required.
72
+ function renderBaseDataSource(apiName: string): string {
73
+ const p = apiEnvPrefix(apiName);
74
+ return [
75
+ ` - name: ${apiName}`,
76
+ ` provider: postgres`,
77
+ ` host: \${${p}_DB_HOST}`,
78
+ ` port: \${${p}_DB_PORT}`,
79
+ ` database: \${${p}_DB_NAME}`,
80
+ ` username: \${${p}_DB_USER}`,
81
+ ` password: \${${p}_DB_PASSWORD}`,
82
+ ` synchronize: false`,
83
+ ` seed: false`,
84
+ ` bootstrap: false`,
85
+ ].join("\n");
86
+ }
87
+
88
+ // Dev overlay (merged by list index onto config.yaml). Enable bootstrap + migrate role.
89
+ function renderDevDataSource(apiName: string): string {
90
+ const p = apiEnvPrefix(apiName);
91
+ return [
92
+ ` - name: ${apiName}`,
93
+ ` synchronize: true`,
94
+ ` seed: true`,
95
+ ` bootstrap: true`,
96
+ ` adminUser: \${DB_ADMIN_USER}`,
97
+ ` adminPassword: \${DB_ADMIN_PASSWORD}`,
98
+ ` migrateUsername: \${${p}_DB_MIGRATE_USER}`,
99
+ ` migratePassword: \${${p}_DB_MIGRATE_PASSWORD}`,
100
+ ].join("\n");
101
+ }
102
+
103
+ function renderDatabaseSection(
104
+ apiNames: readonly string[],
105
+ items: (name: string) => string,
106
+ ): string {
107
+ return `database:\n dataSources:\n${apiNames.map(items).join("\n")}\n`;
108
+ }
109
+
110
+ /** Create-or-sync bootstrap role; runs on every Postgres container start (not only empty volume). */
111
+ const POSTGRES_ENSURE_BOOTSTRAP_SH = `#!/bin/bash
112
+ set -euo pipefail
113
+ # Superuser aligns decohere_bootstrap with DB_ADMIN_* from env_file on every start.
114
+ # Initdb.d alone is insufficient: passwords in .env can change while the volume persists.
115
+ : "\${POSTGRES_USER:?POSTGRES_USER is required}"
116
+ : "\${DB_ADMIN_USER:?DB_ADMIN_USER is required}"
117
+ : "\${DB_ADMIN_PASSWORD:?DB_ADMIN_PASSWORD is required}"
118
+
119
+ psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname postgres \\
120
+ \t--set=bootstrap_user="$DB_ADMIN_USER" \\
121
+ \t--set=bootstrap_password="$DB_ADMIN_PASSWORD" <<'EOSQL'
122
+ SELECT format(
123
+ \t'CREATE ROLE %I LOGIN PASSWORD %L CREATEDB CREATEROLE NOSUPERUSER NOBYPASSRLS',
124
+ \t:'bootstrap_user',
125
+ \t:'bootstrap_password'
126
+ )
127
+ WHERE NOT EXISTS (
128
+ \tSELECT 1 FROM pg_catalog.pg_roles WHERE rolname = :'bootstrap_user'
129
+ )\\gexec
130
+
131
+ SELECT format(
132
+ \t'ALTER ROLE %I WITH LOGIN PASSWORD %L CREATEDB CREATEROLE NOSUPERUSER NOBYPASSRLS',
133
+ \t:'bootstrap_user',
134
+ \t:'bootstrap_password'
135
+ )\\gexec
136
+
137
+ GRANT CONNECT ON DATABASE postgres TO :"bootstrap_user";
138
+ EOSQL
139
+ `;
140
+
141
+ const POSTGRES_ENTRYPOINT_SH = `#!/bin/bash
142
+ set -euo pipefail
143
+ (
144
+ \tfor _ in $(seq 1 60); do
145
+ \t\tif pg_isready -U "\${POSTGRES_USER:-postgres}" >/dev/null 2>&1; then
146
+ \t\t\t/usr/local/bin/decohere-ensure-bootstrap.sh
147
+ \t\t\texit 0
148
+ \t\tfi
149
+ \t\tsleep 0.5
150
+ \tdone
151
+ \techo "decohere: timed out waiting for Postgres before ensuring bootstrap role" >&2
152
+ \texit 1
153
+ ) &
154
+ exec docker-entrypoint.sh "$@"
155
+ `;
156
+
157
+ const POSTGRES_IMAGE_DOCKERFILE = `FROM postgres:16
158
+ COPY ensure-bootstrap-role.sh /usr/local/bin/decohere-ensure-bootstrap.sh
159
+ COPY entrypoint.sh /usr/local/bin/decohere-postgres-entrypoint.sh
160
+ RUN chmod +x /usr/local/bin/decohere-ensure-bootstrap.sh /usr/local/bin/decohere-postgres-entrypoint.sh
161
+ ENTRYPOINT ["/usr/local/bin/decohere-postgres-entrypoint.sh"]
162
+ CMD ["postgres"]
163
+ `;
164
+
165
+ const SAMPLE_ENTITY_TS = `import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";
166
+ import type { Entity as IEntity } from "@decohere/codex";
167
+
168
+ @Entity()
169
+ export class SampleEntity implements IEntity {
170
+ \t@PrimaryGeneratedColumn()
171
+ \tpublic id!: number;
172
+
173
+ \t@Column()
174
+ \tpublic name!: string;
175
+ }
176
+ `;
177
+
178
+ const SAMPLE_REPOSITORY_TS = `import { injectable } from "@decohere/crucible/decorators";
179
+ import { injectUnitOfWork } from "@decohere/codex";
180
+ import { TypeOrmRepository, TypeOrmUnitOfWork } from "@decohere/codex-typeorm";
181
+ import { SampleEntity } from "../entities/sample.entity.js";
182
+
183
+ @injectable()
184
+ export class SampleEntityRepository extends TypeOrmRepository<SampleEntity> {
185
+ \tpublic constructor(@injectUnitOfWork() unitOfWork: TypeOrmUnitOfWork) {
186
+ \t\tsuper(unitOfWork, SampleEntity);
187
+ \t}
188
+ }
189
+ `;
190
+
191
+ /** TypeORM CLI DataSource — connects as the migrate role; never enables synchronize. */
192
+ function renderTypeOrmDataSource(apiName: string): string {
193
+ const p = apiEnvPrefix(apiName);
194
+ return `import { dirname, join } from "node:path";
195
+ import { fileURLToPath } from "node:url";
196
+ import { DataSource } from "typeorm";
197
+
198
+ const root = dirname(fileURLToPath(import.meta.url));
199
+ const required = (key: string): string => {
200
+ \tconst value = process.env[key];
201
+ \tif (value == null || value === "") {
202
+ \t\tthrow new Error(\`Missing env \${key} (load .env via --env-file or export it)\`);
203
+ \t}
204
+ \treturn value;
205
+ };
206
+
207
+ /**
208
+ * CLI-only DataSource for \`pnpm db:migrate\` / \`pnpm db:migrate:generate\`.
209
+ * Uses the migrate role (DDL). Runtime API pods use the app role with synchronize: false.
210
+ */
211
+ export default new DataSource({
212
+ \ttype: "postgres",
213
+ \thost: required("${p}_DB_HOST"),
214
+ \tport: Number(required("${p}_DB_PORT")),
215
+ \tdatabase: required("${p}_DB_NAME"),
216
+ \tusername: required("${p}_DB_MIGRATE_USER"),
217
+ \tpassword: required("${p}_DB_MIGRATE_PASSWORD"),
218
+ \tsynchronize: false,
219
+ \tentities: [join(root, "entities", "*.{ts,js}")],
220
+ \tmigrations: [join(root, "migrations", "*.{ts,js}")],
221
+ });
222
+ `;
223
+ }
224
+
225
+ const DATABASE_DOCS_MD = `# Database (Postgres / TypeORM)
226
+
227
+ ## Roles
228
+
229
+ | Role | Env | Purpose |
230
+ |---|---|---|
231
+ | App | \`{PREFIX}_DB_USER\` | Runtime API (DML only in production) |
232
+ | Migrate | \`{PREFIX}_DB_MIGRATE_USER\` | Schema changes / TypeORM migrations (CI/deploy) |
233
+ | Bootstrap | \`DB_ADMIN_USER\` | Create DB + roles when \`bootstrap: true\` (dev) |
234
+
235
+ \`{PREFIX}\` is the API slug in SCREAMING_SNAKE (\`api\` → \`API\`, \`billing\` → \`BILLING\`).
236
+
237
+ ## Development (fast iteration)
238
+
239
+ \`config/config.dev.yaml\` sets \`synchronize: true\` and \`seed: true\` so the API can create tables on startup. You can keep using sync while prototyping.
240
+
241
+ Alternatively, turn sync off in the overlay and use migrations the same way as production:
242
+
243
+ \`\`\`bash
244
+ pnpm db:up
245
+ pnpm db:migrate
246
+ pnpm db:migrate:generate -- ./projects/api/src/migrations/AddSomething
247
+ \`\`\`
248
+
249
+ Do **not** mix sync and migrations for the same schema change (pick one path per change).
250
+
251
+ ## Production (migrations only)
252
+
253
+ \`config/config.yaml\` keeps \`synchronize: false\` / \`seed: false\` / \`bootstrap: false\`. Deploy steps:
254
+
255
+ 1. Ensure the app database and roles already exist (provisioner / ops — not the API pod).
256
+ 2. Run migrations with the migrate role: \`pnpm db:migrate\` (or \`pnpm db:migrate:<api>\` for multi-API).
257
+ 3. Start the API with the app role only — no migrate/admin secrets on the pod.
258
+
259
+ ## Scripts
260
+
261
+ | Script | What it does |
262
+ |---|---|
263
+ | \`db:up\` / \`db:down\` / \`db:reset\` | Local Compose Postgres |
264
+ | \`db:migrate\` | Run pending migrations (\`projects/api\` by default) |
265
+ | \`db:migrate:generate\` | Generate a migration from entity drift (pass path after \`--\`) |
266
+ | \`db:migrate:<api>\` | Same for a named API under \`projects/<api>\` |
267
+
268
+ CLI entry: \`projects/<api>/src/typeorm.data-source.ts\` (migrate credentials, \`synchronize: false\`).
269
+
270
+ ## Browse the database
271
+
272
+ Prefer the Dev Container **SQLTools** extension (Postgres driver) or any desktop client against \`127.0.0.1:5433\` (see \`.env\` for credentials).
273
+
274
+ Adminer is **not** scaffolded. If you want a browser UI in Compose, add this under \`services:\` in \`.devcontainer/docker-compose.yml\` (profile \`tools\` so it stays off by default):
275
+
276
+ \`\`\`yaml
277
+ adminer:
278
+ image: adminer
279
+ profiles:
280
+ - tools
281
+ ports:
282
+ - "127.0.0.1:8081:8080"
283
+ networks:
284
+ - <compose-network-key> # same key as postgres / devcontainer
285
+ \`\`\`
286
+
287
+ Then: \`docker compose -f .devcontainer/docker-compose.yml --profile tools up -d adminer\`
288
+ `;
289
+
290
+ function typeormCli(args: string): string {
291
+ return `node --import tsx --env-file=.env ./node_modules/typeorm/cli.js ${args}`;
292
+ }
293
+
294
+ function migrateRunScript(apiName: string): string {
295
+ return typeormCli(`migration:run -d ./projects/${apiName}/src/typeorm.data-source.ts`);
296
+ }
297
+
298
+ function migrateGenerateScript(apiName: string): string {
299
+ return typeormCli(
300
+ `migration:generate --esm -d ./projects/${apiName}/src/typeorm.data-source.ts`,
301
+ );
302
+ }
303
+
304
+ async function getOptions(
305
+ _workspace: WorkspaceScaffold,
306
+ cliOptions: Partial<DatabaseOptions>,
307
+ ): Promise<Partial<DatabaseOptions>> {
308
+ if (cliOptions.databaseProvider != null) return {};
309
+
310
+ const choices = [{ label: "none" as const }, { label: "postgres/typeorm" as const }];
311
+ const selected = await pick("\n Database: ", choices, (c) => c.label);
312
+ return { databaseProvider: selected?.label ?? "none" };
313
+ }
314
+
315
+ function scaffold(workspace: WorkspaceScaffold, options: DatabaseOptions): void {
316
+ if (options.databaseProvider !== "postgres/typeorm") return;
317
+
318
+ const name = workspace.name;
319
+ const ident = toSqlIdentFragment(name);
320
+ const apiProjects = workspace.getProjects();
321
+ const apiNames = apiProjects.map((project) => apiNameFromProjectDir(project.dir));
322
+ // Container init superuser — never reused as an API runtime role.
323
+ const postgresSuperuser = "postgres";
324
+ const postgresPassword = passwordOrExisting(workspace, "POSTGRES_PASSWORD");
325
+ const bootstrapUser = "decohere_bootstrap";
326
+ const bootstrapPassword = passwordOrExisting(workspace, "DB_ADMIN_PASSWORD", [
327
+ "POSTGRES_PASSWORD",
328
+ ]);
329
+
330
+ const devContainer = workspace.getModel<DevContainer>(WELL_KNOWN_FILES.DEVCONTAINER_JSON);
331
+ const compose = workspace.getModel<DockerCompose>(WELL_KNOWN_FILES.DOCKER_COMPOSE);
332
+
333
+ const networkKey = name;
334
+
335
+ if (compose) {
336
+ // One shared instance. Each API owns a database on it (created at bootstrap).
337
+ // Point `{PREFIX}_DB_HOST` / `{PREFIX}_DB_PORT` at another server to split later.
338
+ // Custom image: sync bootstrap role password on every start (initdb.d is once-only).
339
+ // Default data is a bind mount under the project so `rm -rf` the tree removes it.
340
+ compose
341
+ .addHeaderComment(
342
+ "Postgres data defaults to ./postgres-data under this folder (bind mount; dies with the working tree).",
343
+ `For data that outlives the checkout, replace the postgres volumes entry with a named volume`,
344
+ `e.g. "${name}-postgres-data:/var/lib/postgresql/data" and declare it under top-level volumes:.`,
345
+ )
346
+ .addService("postgres", {
347
+ build: "./postgres",
348
+ container_name: `samples-${name}-postgres`,
349
+ restart: "unless-stopped",
350
+ ports: ["127.0.0.1:5433:5432"],
351
+ volumes: [POSTGRES_DATA_BIND],
352
+ networks: [{ [networkKey]: { aliases: ["postgres"] } }],
353
+ healthcheck: {
354
+ // Ensure role/password match .env before reporting healthy.
355
+ test: [
356
+ "CMD-SHELL",
357
+ `pg_isready -U ${postgresSuperuser} && /usr/local/bin/decohere-ensure-bootstrap.sh`,
358
+ ],
359
+ interval: "2s",
360
+ timeout: "5s",
361
+ retries: 15,
362
+ },
363
+ })
364
+ .addEnvFile("postgres", "../.env");
365
+
366
+ workspace.getModel<Gitignore>(WELL_KNOWN_FILES.GITIGNORE)?.add(POSTGRES_DATA_GITIGNORE);
367
+
368
+ workspace.addFile({
369
+ files: () => [
370
+ {
371
+ filePath: ".devcontainer/postgres/Dockerfile",
372
+ content: POSTGRES_IMAGE_DOCKERFILE,
373
+ },
374
+ {
375
+ filePath: ".devcontainer/postgres/entrypoint.sh",
376
+ content: POSTGRES_ENTRYPOINT_SH,
377
+ },
378
+ {
379
+ filePath: ".devcontainer/postgres/ensure-bootstrap-role.sh",
380
+ content: POSTGRES_ENSURE_BOOTSTRAP_SH,
381
+ },
382
+ ],
383
+ });
384
+
385
+ compose.addEnvFile("devcontainer", "../.env");
386
+ }
387
+
388
+ devContainer?.addExtension("mtxr.sqltools", "mtxr.sqltools-driver-pg");
389
+ workspace
390
+ .getModel<DockerfileModel>(WELL_KNOWN_FILES.DOCKERFILE)
391
+ ?.addLayer(withPostgresqlClient());
392
+
393
+ const pkg = workspace
394
+ .getModel<PackageJson>(WELL_KNOWN_FILES.PACKAGE_JSON)
395
+ ?.addDependency("@decohere/codex", CREATE_VERSION)
396
+ .addDependency("typeorm", "^0.3.28")
397
+ .addDependency("@decohere/codex-typeorm", CREATE_VERSION)
398
+ .addDependency("tsx", "^4.20.5")
399
+ .addScript("db:up", "docker compose -f .devcontainer/docker-compose.yml up -d postgres")
400
+ .addScript("db:down", "docker compose -f .devcontainer/docker-compose.yml down")
401
+ .addScript("db:reset", DB_RESET_SCRIPT);
402
+
403
+ const primaryApi = apiNames[0];
404
+ if (pkg != null) {
405
+ for (const apiName of apiNames) {
406
+ pkg
407
+ .addScript(`db:migrate:${apiName}`, migrateRunScript(apiName))
408
+ .addScript(`db:migrate:generate:${apiName}`, migrateGenerateScript(apiName));
409
+ }
410
+ // Default aliases target the first API of a new workspace only — never overwrite on `webapi add`.
411
+ if (!options.add && primaryApi != null) {
412
+ pkg
413
+ .addScript("db:migrate", migrateRunScript(primaryApi))
414
+ .addScript("db:migrate:generate", migrateGenerateScript(primaryApi));
415
+ }
416
+ }
417
+
418
+ workspace.addFile({
419
+ files: () => [
420
+ {
421
+ filePath: "docs/database.md",
422
+ content: DATABASE_DOCS_MD,
423
+ },
424
+ ],
425
+ });
426
+
427
+ writeDatabaseConfig(workspace, apiNames);
428
+
429
+ for (const project of apiProjects) {
430
+ wireApiDatabase(project);
431
+ }
432
+
433
+ const env = workspace.getModel<EnvFile>(WELL_KNOWN_FILES.ENV_FILE);
434
+ env
435
+ ?.addEntry("POSTGRES_DB", ident)
436
+ .addEntry("POSTGRES_USER", postgresSuperuser)
437
+ .addEntry("POSTGRES_PASSWORD", postgresPassword)
438
+ .addEntry("DB_ADMIN_USER", bootstrapUser)
439
+ .addEntry("DB_ADMIN_PASSWORD", bootstrapPassword);
440
+
441
+ for (const apiName of apiNames) {
442
+ writeApiDatabaseEnv(workspace, ident, apiName);
443
+ }
444
+ }
445
+
446
+ function writeDatabaseConfig(workspace: WorkspaceScaffold, apiNames: readonly string[]): void {
447
+ const config = workspace.getModel<ConfigYaml>(WELL_KNOWN_FILES.CONFIG_YAML);
448
+ const devConfig = workspace.getModel<ConfigYaml>(WELL_KNOWN_FILES.CONFIG_DEV_YAML);
449
+ const pending = apiNames.filter((apiName) => !config?.hasDataSource(apiName));
450
+ if (pending.length === 0) return;
451
+
452
+ if (config?.hasDataSources()) {
453
+ for (const apiName of pending) {
454
+ config.appendDataSource(renderBaseDataSource(apiName));
455
+ devConfig?.appendDataSource(renderDevDataSource(apiName));
456
+ }
457
+ return;
458
+ }
459
+
460
+ config?.addSection(renderDatabaseSection(pending, renderBaseDataSource));
461
+ devConfig?.addSection(renderDatabaseSection(pending, renderDevDataSource));
462
+ }
463
+
464
+ function writeApiDatabaseEnv(
465
+ workspace: WorkspaceScaffold,
466
+ ident: string,
467
+ apiName: string,
468
+ ): void {
469
+ const prefix = apiEnvPrefix(apiName);
470
+ const sqlApi = toSqlIdentFragment(apiName);
471
+ workspace
472
+ .getModel<EnvFile>(WELL_KNOWN_FILES.ENV_FILE)
473
+ ?.addEntry(`${prefix}_DB_HOST`, "postgres")
474
+ .addEntry(`${prefix}_DB_PORT`, "5432")
475
+ .addEntry(`${prefix}_DB_NAME`, `${ident}_${sqlApi}_dev`)
476
+ .addEntry(`${prefix}_DB_USER`, `${ident}_${sqlApi}_app`)
477
+ .addEntry(`${prefix}_DB_PASSWORD`, passwordOrExisting(workspace, `${prefix}_DB_PASSWORD`))
478
+ .addEntry(`${prefix}_DB_MIGRATE_USER`, `${ident}_${sqlApi}_migrate`)
479
+ .addEntry(
480
+ `${prefix}_DB_MIGRATE_PASSWORD`,
481
+ passwordOrExisting(workspace, `${prefix}_DB_MIGRATE_PASSWORD`),
482
+ );
483
+ }
484
+
485
+ function wireApiDatabase(project: ProjectScaffold): void {
486
+ const apiName = apiNameFromProjectDir(project.dir);
487
+ project
488
+ .getModel<MainSourceFile>(WELL_KNOWN_FILES.MAIN_TS)
489
+ ?.setDataSourceName(apiName)
490
+ .addSideEffectImport("@decohere/codex-typeorm")
491
+ .addEntity({ className: "SampleEntity", importPath: "./entities/sample.entity.js" })
492
+ .addRepository({
493
+ className: "SampleEntityRepository",
494
+ importPath: "./repositories/sample.repository.js",
495
+ });
496
+ project
497
+ .getModel<ApiPackageJson>(WELL_KNOWN_FILES.API_PACKAGE_JSON)
498
+ ?.addDependency("@decohere/codex", CREATE_VERSION)
499
+ .addDependency("@decohere/codex-typeorm", CREATE_VERSION);
500
+ project.addFile({
501
+ files: () => [
502
+ {
503
+ filePath: "src/entities/sample.entity.ts",
504
+ content: SAMPLE_ENTITY_TS,
505
+ },
506
+ {
507
+ filePath: "src/repositories/sample.repository.ts",
508
+ content: SAMPLE_REPOSITORY_TS,
509
+ },
510
+ {
511
+ filePath: "src/typeorm.data-source.ts",
512
+ content: renderTypeOrmDataSource(apiName),
513
+ },
514
+ {
515
+ filePath: "src/migrations/.gitkeep",
516
+ content: "",
517
+ },
518
+ ],
519
+ });
520
+ }
521
+
522
+ export const DatabaseFeature: Feature<DatabaseOptions> = { getOptions, scaffold };
@@ -0,0 +1,112 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { PackageManager } from "@decohere/nexus/interop";
4
+ import { detectPackageManager, promptForPackageManager } from "@decohere/nexus/interop";
5
+ import { ask, closePrompt } from "../../domain/prompt.js";
6
+ import { WELL_KNOWN_FILES } from "../../models/const.js";
7
+ import type { EnvFile } from "../../models/env-file.model.js";
8
+ import type { WorkspaceScaffold } from "../../models/project.model.js";
9
+ import type { Feature } from "../../services/scaffold.service.js";
10
+ import { writeWorkspaceReadme } from "../workspace-readme.js";
11
+ import { formatScaffoldedFiles } from "./biome-format.js";
12
+
13
+ function readExistingEnvValue(workspace: WorkspaceScaffold, key: string): string | undefined {
14
+ try {
15
+ const raw = readFileSync(join(workspace.targetDir, ".env"), "utf8");
16
+ const line = raw.split("\n").find((l) => l.startsWith(`${key}=`));
17
+ const value = line?.slice(key.length + 1).trim();
18
+ return value || undefined;
19
+ } catch {
20
+ return undefined;
21
+ }
22
+ }
23
+
24
+ // State resolved during scaffold(), consumed by finalize().
25
+ let _pm: PackageManager | null = null;
26
+ let _shouldInstall = true;
27
+
28
+ type FinalizeOptions = {
29
+ installDependencies?: "yes" | "no";
30
+ add?: boolean;
31
+ databaseProvider?: "none" | "postgres/typeorm";
32
+ authProvider?: "auth0" | "none";
33
+ postmanProvider?: "api" | "local" | "none";
34
+ };
35
+
36
+ async function getOptions(
37
+ _workspace: WorkspaceScaffold,
38
+ cliOptions: Partial<FinalizeOptions>,
39
+ ): Promise<object> {
40
+ if (cliOptions.installDependencies != null) {
41
+ _shouldInstall = cliOptions.installDependencies === "yes";
42
+ }
43
+ return {};
44
+ }
45
+
46
+ async function scaffold(
47
+ workspace: WorkspaceScaffold,
48
+ options: FinalizeOptions,
49
+ ): Promise<void> {
50
+ // Local scaffold default — production deploys must set NODE_ENV explicitly.
51
+ const nodeEnv = readExistingEnvValue(workspace, "NODE_ENV") ?? "dev";
52
+ workspace.getModel<EnvFile>(WELL_KNOWN_FILES.ENV_FILE)?.addEntry("NODE_ENV", nodeEnv);
53
+
54
+ // Durable day-2 CLI examples (also refreshed on `webapi add`).
55
+ writeWorkspaceReadme(workspace, options);
56
+
57
+ if (process.env.DECOHERE_BUILDER === "1") {
58
+ _shouldInstall = false;
59
+ return;
60
+ }
61
+ _pm = detectPackageManager(workspace.targetDir) ?? (await promptForPackageManager());
62
+ if (options.installDependencies != null) {
63
+ _shouldInstall = options.installDependencies === "yes";
64
+ return;
65
+ }
66
+
67
+ const answer = await ask("\nInstall dependencies now? [yes/no] (yes): ", "yes");
68
+ _shouldInstall = answer.toLowerCase() !== "no";
69
+ }
70
+
71
+ async function finalize(
72
+ workspace: WorkspaceScaffold,
73
+ options: FinalizeOptions,
74
+ ): Promise<void> {
75
+ const formatPaths = options.add
76
+ ? workspace.getProjects().map((project) => project.dir)
77
+ : ["."];
78
+ formatScaffoldedFiles(workspace.targetDir, formatPaths.length > 0 ? formatPaths : ["."]);
79
+
80
+ if (_shouldInstall) {
81
+ const pm = _pm!;
82
+ console.log(`\nScaffolded ${workspace.name}. Installing dependencies...\n`);
83
+ await pm.install({ cwd: workspace.targetDir });
84
+ }
85
+
86
+ const apiNames = workspace
87
+ .getProjects()
88
+ .map((project) => project.dir.replace(/^projects\//, ""));
89
+ console.log(`\nDone! ${_shouldInstall ? "Next" : "To get started:"} steps:\n`);
90
+ if (!options.add) {
91
+ console.log(` cd ${workspace.name}`);
92
+ }
93
+ if (!_shouldInstall && options.add) {
94
+ const pmName = _pm?.name ?? "pnpm";
95
+ console.log(` ${pmName} install`);
96
+ }
97
+ if (options.add && apiNames[0] != null) {
98
+ console.log(` pnpm run start:${apiNames[0]}`);
99
+ console.log(` docker build -f Dockerfile.${apiNames[0]} .`);
100
+ } else {
101
+ console.log(" code . # open in VS Code");
102
+ console.log(
103
+ " # then: Reopen in Container (first create runs pnpm install)\n",
104
+ );
105
+ }
106
+ console.log(" See README.md for CLI examples.\n");
107
+
108
+ // Drop readline's stdin hold so the CLI / container can exit.
109
+ closePrompt();
110
+ }
111
+
112
+ export const FinalizeFeature: Feature = { getOptions, scaffold, finalize };