@soapjs/cli 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. package/.nvmrc +1 -0
  2. package/LICENSE +21 -0
  3. package/README.md +360 -0
  4. package/build/cli.d.ts +3 -0
  5. package/build/cli.js +50 -0
  6. package/build/commands/add/add.command.d.ts +2 -0
  7. package/build/commands/add/add.command.js +709 -0
  8. package/build/commands/add/command-plan.d.ts +15 -0
  9. package/build/commands/add/command-plan.js +182 -0
  10. package/build/commands/add/entity-plan.d.ts +7 -0
  11. package/build/commands/add/entity-plan.js +106 -0
  12. package/build/commands/add/event-plan.d.ts +8 -0
  13. package/build/commands/add/event-plan.js +59 -0
  14. package/build/commands/add/query-plan.d.ts +10 -0
  15. package/build/commands/add/query-plan.js +156 -0
  16. package/build/commands/add/repository-plan.d.ts +11 -0
  17. package/build/commands/add/repository-plan.js +252 -0
  18. package/build/commands/add/resource-plan.d.ts +52 -0
  19. package/build/commands/add/resource-plan.js +2031 -0
  20. package/build/commands/add/route-plan.d.ts +24 -0
  21. package/build/commands/add/route-plan.js +256 -0
  22. package/build/commands/add/socket-plan.d.ts +9 -0
  23. package/build/commands/add/socket-plan.js +81 -0
  24. package/build/commands/add/use-case-plan.d.ts +7 -0
  25. package/build/commands/add/use-case-plan.js +86 -0
  26. package/build/commands/check/check.command.d.ts +2 -0
  27. package/build/commands/check/check.command.js +113 -0
  28. package/build/commands/create/create.command.d.ts +2 -0
  29. package/build/commands/create/create.command.js +234 -0
  30. package/build/commands/create/project-plan.d.ts +44 -0
  31. package/build/commands/create/project-plan.js +1430 -0
  32. package/build/commands/doctor/doctor.command.d.ts +2 -0
  33. package/build/commands/doctor/doctor.command.js +38 -0
  34. package/build/commands/generate/bruno-analysis.d.ts +19 -0
  35. package/build/commands/generate/bruno-analysis.js +51 -0
  36. package/build/commands/generate/bruno-plan.d.ts +6 -0
  37. package/build/commands/generate/bruno-plan.js +326 -0
  38. package/build/commands/generate/generate.command.d.ts +2 -0
  39. package/build/commands/generate/generate.command.js +130 -0
  40. package/build/commands/info/info.command.d.ts +2 -0
  41. package/build/commands/info/info.command.js +26 -0
  42. package/build/commands/remove/remove.command.d.ts +2 -0
  43. package/build/commands/remove/remove.command.js +328 -0
  44. package/build/commands/shared/common-options.d.ts +10 -0
  45. package/build/commands/shared/common-options.js +23 -0
  46. package/build/commands/update/update.command.d.ts +2 -0
  47. package/build/commands/update/update.command.js +155 -0
  48. package/build/config/auth-policy.d.ts +4 -0
  49. package/build/config/auth-policy.js +54 -0
  50. package/build/config/find-soap-root.d.ts +1 -0
  51. package/build/config/find-soap-root.js +22 -0
  52. package/build/config/load-soap-config.d.ts +2 -0
  53. package/build/config/load-soap-config.js +30 -0
  54. package/build/config/schemas/types.d.ts +127 -0
  55. package/build/config/schemas/types.js +2 -0
  56. package/build/config/schemas/validation.d.ts +5 -0
  57. package/build/config/schemas/validation.js +130 -0
  58. package/build/config/soap-config.service.d.ts +4 -0
  59. package/build/config/soap-config.service.js +24 -0
  60. package/build/config/write-soap-config.d.ts +8 -0
  61. package/build/config/write-soap-config.js +25 -0
  62. package/build/core/command-context.d.ts +20 -0
  63. package/build/core/command-context.js +30 -0
  64. package/build/core/errors.d.ts +6 -0
  65. package/build/core/errors.js +23 -0
  66. package/build/core/output.d.ts +12 -0
  67. package/build/core/output.js +30 -0
  68. package/build/core/result.d.ts +9 -0
  69. package/build/core/result.js +11 -0
  70. package/build/dependencies/dependency-resolver.d.ts +6 -0
  71. package/build/dependencies/dependency-resolver.js +68 -0
  72. package/build/dependencies/package-manager.d.ts +7 -0
  73. package/build/dependencies/package-manager.js +54 -0
  74. package/build/index.d.ts +2 -0
  75. package/build/index.js +9 -0
  76. package/build/io/conflict-policy.d.ts +10 -0
  77. package/build/io/conflict-policy.js +32 -0
  78. package/build/io/file-writer.d.ts +19 -0
  79. package/build/io/file-writer.js +65 -0
  80. package/build/io/format-file.d.ts +1 -0
  81. package/build/io/format-file.js +13 -0
  82. package/build/presets/create-presets.d.ts +4 -0
  83. package/build/presets/create-presets.js +97 -0
  84. package/build/presets/index.d.ts +2 -0
  85. package/build/presets/index.js +18 -0
  86. package/build/presets/preset.types.d.ts +6 -0
  87. package/build/presets/preset.types.js +2 -0
  88. package/build/prompts/add-resource.prompt.d.ts +13 -0
  89. package/build/prompts/add-resource.prompt.js +80 -0
  90. package/build/prompts/add-route.prompt.d.ts +16 -0
  91. package/build/prompts/add-route.prompt.js +140 -0
  92. package/build/prompts/create-project.prompt.d.ts +11 -0
  93. package/build/prompts/create-project.prompt.js +156 -0
  94. package/build/prompts/generate-bruno.prompt.d.ts +7 -0
  95. package/build/prompts/generate-bruno.prompt.js +21 -0
  96. package/build/prompts/index.d.ts +8 -0
  97. package/build/prompts/index.js +24 -0
  98. package/build/prompts/inquirer-prompt-adapter.d.ts +8 -0
  99. package/build/prompts/inquirer-prompt-adapter.js +52 -0
  100. package/build/prompts/mock-prompt-adapter.d.ts +13 -0
  101. package/build/prompts/mock-prompt-adapter.js +60 -0
  102. package/build/prompts/prompt-adapter.d.ts +7 -0
  103. package/build/prompts/prompt-adapter.js +2 -0
  104. package/build/prompts/prompt.types.d.ts +26 -0
  105. package/build/prompts/prompt.types.js +2 -0
  106. package/build/registry/registry.service.d.ts +19 -0
  107. package/build/registry/registry.service.js +68 -0
  108. package/build/resolvers/add-resource.resolver.d.ts +23 -0
  109. package/build/resolvers/add-resource.resolver.js +73 -0
  110. package/build/resolvers/add-route.resolver.d.ts +34 -0
  111. package/build/resolvers/add-route.resolver.js +83 -0
  112. package/build/resolvers/create-config.resolver.d.ts +32 -0
  113. package/build/resolvers/create-config.resolver.js +57 -0
  114. package/build/resolvers/generate-bruno.resolver.d.ts +17 -0
  115. package/build/resolvers/generate-bruno.resolver.js +23 -0
  116. package/build/resolvers/index.d.ts +5 -0
  117. package/build/resolvers/index.js +21 -0
  118. package/build/resolvers/resolver.types.d.ts +8 -0
  119. package/build/resolvers/resolver.types.js +2 -0
  120. package/build/summary/create-summary.d.ts +2 -0
  121. package/build/summary/create-summary.js +24 -0
  122. package/build/summary/index.d.ts +1 -0
  123. package/build/summary/index.js +17 -0
  124. package/build/templates/naming.d.ts +11 -0
  125. package/build/templates/naming.js +30 -0
  126. package/build/templates/template-context.d.ts +6 -0
  127. package/build/templates/template-context.js +2 -0
  128. package/build/templates/template-engine.d.ts +1 -0
  129. package/build/templates/template-engine.js +10 -0
  130. package/build/templates/template-resolver.d.ts +2 -0
  131. package/build/templates/template-resolver.js +17 -0
  132. package/build/terminal/terminal-capabilities.d.ts +6 -0
  133. package/build/terminal/terminal-capabilities.js +14 -0
  134. package/docs/adr/0001-soap-cli-project-aware-generator.md +108 -0
  135. package/docs/cli/add-resource.md +127 -0
  136. package/docs/cli/add-route.md +79 -0
  137. package/docs/cli/bruno.md +58 -0
  138. package/docs/cli/create.md +73 -0
  139. package/docs/cli/index.md +92 -0
  140. package/docs/cli/interactive-mode.md +61 -0
  141. package/docs/cli/remove.md +45 -0
  142. package/docs/guides/auth.md +90 -0
  143. package/docs/guides/cqrs-events-realtime.md +100 -0
  144. package/docs/guides/index.md +24 -0
  145. package/docs/guides/quality-and-safety.md +88 -0
  146. package/docs/guides/regular-api.md +119 -0
  147. package/docs/guides/storage.md +101 -0
  148. package/docs/plans/interactive-mode-plan.md +601 -0
  149. package/package.json +44 -0
@@ -0,0 +1,1430 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.createControllersTs = exports.createResourcesTs = exports.createProjectFiles = exports.targetRoot = exports.zonesOptions = exports.contractsOptions = exports.docsOptions = exports.telemetryOptions = exports.realtimeOptions = exports.messagingOptions = exports.authOptions = exports.databaseOptions = exports.parseCsvOption = exports.createSoapConfigBundle = exports.createDefaultCapabilities = void 0;
7
+ const path_1 = __importDefault(require("path"));
8
+ const naming_1 = require("../../templates/naming");
9
+ function createDefaultCapabilities() {
10
+ return {
11
+ databases: [],
12
+ auth: [],
13
+ messaging: ["in-memory"],
14
+ realtime: [],
15
+ telemetry: ["logs"],
16
+ apiClient: [],
17
+ docs: [],
18
+ contracts: [],
19
+ };
20
+ }
21
+ exports.createDefaultCapabilities = createDefaultCapabilities;
22
+ function createSoapConfigBundle(plan) {
23
+ const defaultAuth = plan.capabilities.auth[0] ?? "none";
24
+ const brunoEnabled = plan.capabilities.apiClient.includes("bruno");
25
+ return {
26
+ project: {
27
+ schemaVersion: 1,
28
+ name: plan.name,
29
+ framework: plan.framework,
30
+ architecture: plan.architecture,
31
+ language: "typescript",
32
+ packageManager: plan.packageManager,
33
+ capabilities: plan.capabilities,
34
+ zones: plan.zones,
35
+ },
36
+ structure: {
37
+ featuresRoot: "src/features",
38
+ commonRoot: "src/common",
39
+ configRoot: "src/config",
40
+ paths: {
41
+ domain: "domain",
42
+ application: "application",
43
+ ports: "application/ports",
44
+ useCases: "application/use-cases",
45
+ commands: "application/commands",
46
+ queries: "application/queries",
47
+ data: "data",
48
+ api: "api",
49
+ contracts: "contracts",
50
+ sockets: "api/sockets",
51
+ },
52
+ },
53
+ api: {
54
+ baseUrl: "http://localhost:3000",
55
+ health: {
56
+ method: "GET",
57
+ path: "/health",
58
+ },
59
+ auth: {
60
+ default: defaultAuth,
61
+ loginRoute: defaultAuth === "none"
62
+ ? undefined
63
+ : {
64
+ method: "POST",
65
+ path: "/auth/login",
66
+ tokenVariable: "accessToken",
67
+ },
68
+ },
69
+ bruno: {
70
+ enabled: brunoEnabled,
71
+ collectionPath: "bruno",
72
+ environment: "Local",
73
+ },
74
+ },
75
+ registry: {
76
+ resources: [],
77
+ routes: [],
78
+ generatedFiles: [],
79
+ },
80
+ };
81
+ }
82
+ exports.createSoapConfigBundle = createSoapConfigBundle;
83
+ function parseCsvOption(value, allowed) {
84
+ if (!value) {
85
+ return [];
86
+ }
87
+ const rawValues = Array.isArray(value) ? value : [value];
88
+ const result = rawValues.flatMap((item) => item.split(",")).map((item) => item.trim()).filter(Boolean);
89
+ const normalized = result.filter((item) => item !== "none");
90
+ const invalid = normalized.find((item) => !allowed.includes(item));
91
+ if (invalid) {
92
+ throw new Error(`Unsupported option value "${invalid}". Allowed values: none, ${allowed.join(", ")}.`);
93
+ }
94
+ return Array.from(new Set(normalized));
95
+ }
96
+ exports.parseCsvOption = parseCsvOption;
97
+ exports.databaseOptions = ["mongo", "postgres", "mysql", "sqlite", "redis"];
98
+ const sqlDatabaseOptions = ["postgres", "mysql", "sqlite"];
99
+ exports.authOptions = ["jwt", "api-key", "local"];
100
+ exports.messagingOptions = ["in-memory", "kafka"];
101
+ exports.realtimeOptions = ["ws"];
102
+ exports.telemetryOptions = ["logs", "otel-noop"];
103
+ exports.docsOptions = ["openapi"];
104
+ exports.contractsOptions = ["zod"];
105
+ exports.zonesOptions = ["public", "private", "admin"];
106
+ function enabledSqlDatabases(capabilities) {
107
+ return sqlDatabaseOptions.filter((database) => capabilities.databases.includes(database));
108
+ }
109
+ function targetRoot(cwd, name) {
110
+ return path_1.default.resolve(cwd, name);
111
+ }
112
+ exports.targetRoot = targetRoot;
113
+ function createProjectFiles(plan) {
114
+ const packageJson = {
115
+ name: plan.name,
116
+ version: "0.1.0",
117
+ private: true,
118
+ engines: {
119
+ node: ">=24.17.0",
120
+ },
121
+ scripts: {
122
+ dev: "tsx src/index.ts",
123
+ "dev:watch": "tsx watch src/index.ts",
124
+ build: "tsc -p tsconfig.json",
125
+ start: "node build/index.js",
126
+ test: "npm run build && find build -name '*.spec.js' -o -name '*.test.js' | xargs node --test",
127
+ lint: "tsc -p tsconfig.json --noEmit",
128
+ bruno: plan.capabilities.apiClient.includes("bruno") ? "cd bruno && bru run --env Local" : "echo \"Bruno is not enabled\"",
129
+ "test:api": plan.capabilities.apiClient.includes("bruno") ? "npm run bruno" : "echo \"Bruno is not enabled\"",
130
+ },
131
+ dependencies: plan.dependencies.dependencies,
132
+ devDependencies: plan.dependencies.devDependencies,
133
+ };
134
+ const dockerServices = createDockerCompose(plan);
135
+ return [
136
+ {
137
+ path: "package.json",
138
+ type: "project",
139
+ content: JSON.stringify(packageJson, null, 2),
140
+ },
141
+ {
142
+ path: "tsconfig.json",
143
+ type: "project",
144
+ content: JSON.stringify({
145
+ compilerOptions: {
146
+ target: "ES2022",
147
+ module: "commonjs",
148
+ moduleResolution: "node",
149
+ strict: true,
150
+ esModuleInterop: true,
151
+ skipLibCheck: true,
152
+ forceConsistentCasingInFileNames: true,
153
+ experimentalDecorators: true,
154
+ emitDecoratorMetadata: true,
155
+ outDir: "build",
156
+ rootDir: "src",
157
+ },
158
+ include: ["src/**/*"],
159
+ exclude: ["node_modules", "build"],
160
+ }, null, 2),
161
+ },
162
+ {
163
+ path: ".gitignore",
164
+ type: "project",
165
+ content: ["node_modules", "build", ".env", "*.log", ".DS_Store"].join("\n"),
166
+ },
167
+ {
168
+ path: ".nvmrc",
169
+ type: "project",
170
+ content: "24.17.0\n",
171
+ },
172
+ {
173
+ path: ".dockerignore",
174
+ type: "docker",
175
+ content: ["node_modules", "build", ".git", ".env", "bruno"].join("\n"),
176
+ },
177
+ {
178
+ path: ".env.example",
179
+ type: "config",
180
+ content: createEnvExample(plan),
181
+ },
182
+ {
183
+ path: "README.md",
184
+ type: "project",
185
+ content: createReadme(plan),
186
+ },
187
+ {
188
+ path: "Dockerfile",
189
+ type: "docker",
190
+ content: createDockerfile(),
191
+ },
192
+ {
193
+ path: "docker-compose.yml",
194
+ type: "docker",
195
+ content: dockerServices,
196
+ },
197
+ {
198
+ path: "Makefile",
199
+ type: "project",
200
+ content: createMakefile(),
201
+ },
202
+ {
203
+ path: "src/index.ts",
204
+ type: "project",
205
+ content: createIndexTs(plan),
206
+ },
207
+ {
208
+ path: "src/config/config.ts",
209
+ type: "config",
210
+ content: createConfigTs(plan),
211
+ },
212
+ {
213
+ path: "src/config/dependencies.ts",
214
+ type: "config",
215
+ content: createDependenciesTs(plan),
216
+ },
217
+ {
218
+ path: "src/config/resources.ts",
219
+ type: "config",
220
+ content: createResourcesTs([]),
221
+ },
222
+ {
223
+ path: "src/config/controllers.ts",
224
+ type: "config",
225
+ content: createControllersTs(createInitialControllers(plan)),
226
+ },
227
+ {
228
+ path: "src/features/index.ts",
229
+ type: "config",
230
+ content: createFeaturesIndexTs(plan),
231
+ },
232
+ ...createCqrsFiles(plan),
233
+ ...createMongoFiles(plan),
234
+ ...createSqlFiles(plan),
235
+ ...createEventFiles(plan),
236
+ ...createSocketFiles(plan),
237
+ ...createAuthFiles(plan),
238
+ {
239
+ path: "src/features/health/.gitkeep",
240
+ type: "project",
241
+ content: "",
242
+ },
243
+ ...createBrunoFiles(plan),
244
+ ];
245
+ }
246
+ exports.createProjectFiles = createProjectFiles;
247
+ function createIndexTs(plan) {
248
+ const docsImports = plan.capabilities.docs.includes("openapi")
249
+ ? "import { DocumentationPlugin } from '@soapjs/soap-openapi';\n"
250
+ : "";
251
+ const socketImport = plan.capabilities.realtime.includes("ws")
252
+ ? "import { createSocketRuntime } from './common/sockets/socket.setup';\n"
253
+ : "";
254
+ const cqrsImport = plan.architecture === "cqrs" ? "import './config/cqrs';\n" : "";
255
+ const docsPlugin = plan.capabilities.docs.includes("openapi")
256
+ ? `\n plugins: [\n {\n plugin: new DocumentationPlugin(),\n options: {\n info: {\n title: '${plan.name} API',\n version: '0.1.0',\n },\n servers: [{ url: \`http://localhost:\${config.port}\`, description: 'Local' }],\n interactivePath: '/docs',\n openApiPath: '/openapi.json',\n },\n },\n ],`
257
+ : "";
258
+ const cqrsOption = plan.architecture === "cqrs" ? "\n cqrs: true," : "";
259
+ return `import 'reflect-metadata';
260
+ import { bootstrap } from '@soapjs/soap-express';
261
+ ${docsImports}${socketImport}${cqrsImport}import './features';
262
+ import { config } from './config/config';
263
+ import { controllers } from './config/controllers';
264
+ import { buildContainer } from './config/dependencies';
265
+
266
+ async function main(): Promise<void> {
267
+ const { container, drainables, logger, authStrategies } = await buildContainer(config);
268
+
269
+ const app = await bootstrap({
270
+ port: config.port,
271
+ container,
272
+ logger,
273
+ drainables,
274
+ controllers,
275
+ middleware: {
276
+ cors: true,
277
+ helmet: true,
278
+ logging: true,
279
+ compression: true,
280
+ },
281
+ auth: authStrategies,
282
+ healthCheck: true,${cqrsOption}${docsPlugin}
283
+ });
284
+ ${plan.capabilities.realtime.includes("ws") ? `\n const socketRuntime = createSocketRuntime(config, app.getServer());\n app.registerDrainable(socketRuntime);\n` : ""}
285
+
286
+ logger.info('${plan.name} API ready', { port: config.port });
287
+ }
288
+
289
+ main().catch((error) => {
290
+ console.error(error);
291
+ process.exit(1);
292
+ });
293
+ `;
294
+ }
295
+ function createConfigTs(plan) {
296
+ const mongo = plan.capabilities.databases.includes("mongo")
297
+ ? "\n mongoUri: readEnv('MONGO_URI', 'mongodb://localhost:27017/app'),"
298
+ : "";
299
+ const postgres = plan.capabilities.databases.includes("postgres")
300
+ ? "\n postgres: {\n host: readEnv('POSTGRES_HOST', 'localhost'),\n port: Number(readEnv('POSTGRES_PORT', '5432')),\n database: readEnv('POSTGRES_DB', 'app'),\n user: readEnv('POSTGRES_USER', 'app'),\n password: readEnv('POSTGRES_PASSWORD', 'app'),\n },"
301
+ : "";
302
+ const mysql = plan.capabilities.databases.includes("mysql")
303
+ ? "\n mysql: {\n host: readEnv('MYSQL_HOST', 'localhost'),\n port: Number(readEnv('MYSQL_PORT', '3306')),\n database: readEnv('MYSQL_DATABASE', 'app'),\n user: readEnv('MYSQL_USER', 'app'),\n password: readEnv('MYSQL_PASSWORD', 'app'),\n },"
304
+ : "";
305
+ const sqlite = plan.capabilities.databases.includes("sqlite")
306
+ ? "\n sqlite: {\n filename: readEnv('SQLITE_FILENAME', './data/app.sqlite'),\n },"
307
+ : "";
308
+ const redis = plan.capabilities.databases.includes("redis")
309
+ ? "\n redisUrl: readEnv('REDIS_URL', 'redis://localhost:6379'),"
310
+ : "";
311
+ const kafka = plan.capabilities.messaging.includes("kafka")
312
+ ? "\n kafkaBrokers: readEnv('KAFKA_BROKERS', 'localhost:9092').split(','),\n eventBus: readEnv('EVENT_BUS', 'in-memory'),"
313
+ : "\n eventBus: 'in-memory',";
314
+ const sockets = plan.capabilities.realtime.includes("ws")
315
+ ? "\n wsPath: readEnv('WS_PATH', '/ws'),\n wsHeartbeatMs: Number(readEnv('WS_HEARTBEAT_MS', '30000')),"
316
+ : "";
317
+ const jwt = plan.capabilities.auth.includes("jwt") || plan.capabilities.auth.includes("local")
318
+ ? "\n jwtSecret: readEnv('JWT_SECRET', 'dev-secret'),"
319
+ : "";
320
+ const apiKey = plan.capabilities.auth.includes("api-key")
321
+ ? "\n apiKeyHeader: readEnv('API_KEY_HEADER', 'x-api-key'),\n devApiKey: readEnv('DEV_API_KEY', 'dev-api-key'),"
322
+ : "";
323
+ return `import 'dotenv/config';
324
+
325
+ function readEnv(name: string, fallback?: string): string {
326
+ const value = process.env[name] ?? fallback;
327
+
328
+ if (value === undefined || value === '') {
329
+ throw new Error(\`Missing required environment variable: \${name}\`);
330
+ }
331
+
332
+ return value;
333
+ }
334
+
335
+ export const config = {
336
+ nodeEnv: readEnv('NODE_ENV', 'development'),
337
+ port: Number(readEnv('PORT', '3000')),
338
+ logLevel: readEnv('LOG_LEVEL', 'debug'),${mongo}${postgres}${mysql}${sqlite}${redis}${kafka}${sockets}${jwt}${apiKey}
339
+ };
340
+
341
+ export type AppConfig = typeof config;
342
+ `;
343
+ }
344
+ function createDependenciesTs(plan) {
345
+ const authImport = plan.capabilities.auth.length > 0
346
+ ? "import { createAuthStrategies } from '../features/auth/auth.setup';\n"
347
+ : "";
348
+ const mongoImport = plan.capabilities.databases.includes("mongo")
349
+ ? "import { SoapMongo } from '@soapjs/soap-node-mongo';\nimport { createMongoClient } from '../common/data/mongo/mongo.client';\n"
350
+ : "";
351
+ const sqlDatabases = enabledSqlDatabases(plan.capabilities);
352
+ const sqlImport = sqlDatabases.length > 0
353
+ ? `import { SoapSQL } from '@soapjs/soap-node-sql';
354
+ ${sqlDatabases.map((database) => `import { create${(0, naming_1.createNameVariants)(database).pascalName}Client } from '../common/data/${database}/${database}.client';`).join("\n")}
355
+ `
356
+ : "";
357
+ const eventImport = plan.capabilities.messaging.length > 0
358
+ ? "import { DomainEventBus } from '@soapjs/soap/cqrs';\nimport { createEventBus } from '../common/events/event-bus.setup';\n"
359
+ : "";
360
+ const authStrategies = plan.capabilities.auth.length > 0 ? "createAuthStrategies(_config)" : "[]";
361
+ const resourceContextType = [
362
+ plan.capabilities.databases.includes("mongo") ? " mongo?: SoapMongo;" : undefined,
363
+ sqlDatabases.length > 0 ? " sql?: Partial<Record<'postgres' | 'mysql' | 'sqlite', SoapSQL>>;" : undefined,
364
+ ].filter(Boolean).join("\n");
365
+ const mongoSetup = plan.capabilities.databases.includes("mongo")
366
+ ? `\n const mongo = await createMongoClient(_config);\n drainables.push(mongo);\n resources.mongo = mongo;\n`
367
+ : "";
368
+ const sqlSetup = sqlDatabases.length > 0
369
+ ? `\n resources.sql = {};
370
+ ${sqlDatabases.map((database) => {
371
+ const names = (0, naming_1.createNameVariants)(database);
372
+ return ` const ${names.camelName} = await create${names.pascalName}Client(_config);
373
+ drainables.push(${names.camelName});
374
+ resources.sql.${database} = ${names.camelName};`;
375
+ }).join("\n")}\n`
376
+ : "";
377
+ const eventSetup = plan.capabilities.messaging.length > 0
378
+ ? `\n const eventBusRuntime = await createEventBus(_config, logger);\n container.bindValue(DomainEventBus.Token, eventBusRuntime.bus);\n if (eventBusRuntime.drainable) {\n drainables.push(eventBusRuntime.drainable);\n }\n`
379
+ : "";
380
+ return `import { ConsoleLogger, DIContainer } from '@soapjs/soap-express';
381
+ import { Drainable } from '@soapjs/soap/events';
382
+ import { AuthStrategy } from '@soapjs/soap/http';
383
+ import { AppConfig } from './config';
384
+ import { registerResources } from './resources';
385
+ ${mongoImport}${sqlImport}${eventImport}${authImport}
386
+
387
+ export interface ResourceContext {
388
+ ${resourceContextType}
389
+ }
390
+
391
+ export async function buildContainer(_config: AppConfig): Promise<{
392
+ container: DIContainer;
393
+ drainables: Drainable[];
394
+ logger: ConsoleLogger;
395
+ authStrategies: AuthStrategy[];
396
+ }> {
397
+ const container = new DIContainer();
398
+ const logger = new ConsoleLogger();
399
+ const drainables: Drainable[] = [];
400
+ const resources: ResourceContext = {};
401
+ const authStrategies: AuthStrategy[] = ${authStrategies};
402
+ ${mongoSetup}${sqlSetup}${eventSetup}
403
+
404
+ await registerResources(container, resources);
405
+
406
+ return {
407
+ container,
408
+ drainables,
409
+ logger,
410
+ authStrategies,
411
+ };
412
+ }
413
+ `;
414
+ }
415
+ function createResourcesTs(resources) {
416
+ if (resources.length === 0) {
417
+ return `import { DIContainer } from '@soapjs/soap-express';
418
+ import { ResourceContext } from './dependencies';
419
+
420
+ export async function registerResources(_container: DIContainer, _resources: ResourceContext): Promise<void> {}
421
+ `;
422
+ }
423
+ const imports = resources
424
+ .map((resource) => `import { ${resource.functionName} } from '${resource.importPath}';`)
425
+ .join("\n");
426
+ return `import { DIContainer } from '@soapjs/soap-express';
427
+ import { ResourceContext } from './dependencies';
428
+ ${imports}
429
+
430
+ export async function registerResources(container: DIContainer, resources: ResourceContext): Promise<void> {
431
+ ${resources.map((resource) => ` await ${resource.functionName}(container, resources);`).join("\n")}
432
+ }
433
+ `;
434
+ }
435
+ exports.createResourcesTs = createResourcesTs;
436
+ function createMongoFiles(plan) {
437
+ if (!plan.capabilities.databases.includes("mongo")) {
438
+ return [];
439
+ }
440
+ return [
441
+ {
442
+ path: "src/config/mongo.config.ts",
443
+ type: "config",
444
+ content: `import { MongoConfig } from '@soapjs/soap-node-mongo';
445
+ import { AppConfig } from './config';
446
+
447
+ export function createMongoConfig(config: Pick<AppConfig, 'mongoUri'>): MongoConfig {
448
+ const uri = new URL(config.mongoUri);
449
+ const database = uri.pathname.replace(/^\\//, '') || 'app';
450
+ const hosts = uri.hostname.split(',').filter(Boolean);
451
+ const ports = uri.port ? [Number(uri.port)] : undefined;
452
+ const user = uri.username ? decodeURIComponent(uri.username) : undefined;
453
+ const password = uri.password ? decodeURIComponent(uri.password) : undefined;
454
+ const authSource = uri.searchParams.get('authSource') ?? undefined;
455
+ const replicaSet = uri.searchParams.get('replicaSet') ?? undefined;
456
+ const srv = uri.protocol === 'mongodb+srv:';
457
+
458
+ return new MongoConfig(database, hosts, ports, user, password, undefined, authSource, undefined, replicaSet, srv);
459
+ }
460
+ `,
461
+ },
462
+ {
463
+ path: "src/common/data/mongo/mongo.client.ts",
464
+ type: "config",
465
+ content: `import { SoapMongo } from '@soapjs/soap-node-mongo';
466
+ import { AppConfig } from '../../../config/config';
467
+ import { createMongoConfig } from '../../../config/mongo.config';
468
+
469
+ export async function createMongoClient(config: AppConfig): Promise<SoapMongo> {
470
+ return SoapMongo.create(createMongoConfig(config));
471
+ }
472
+ `,
473
+ },
474
+ {
475
+ path: "src/common/data/mongo/mongo.source-factory.ts",
476
+ type: "config",
477
+ content: `import { MongoSource, SoapMongo } from '@soapjs/soap-node-mongo';
478
+ import { Document } from 'mongodb';
479
+
480
+ export function createMongoSource<T extends Document = Document>(mongo: SoapMongo, collectionName: string): MongoSource<T> {
481
+ return new MongoSource<T>(mongo, collectionName);
482
+ }
483
+ `,
484
+ },
485
+ ];
486
+ }
487
+ function createSqlFiles(plan) {
488
+ const databases = enabledSqlDatabases(plan.capabilities);
489
+ if (databases.length === 0) {
490
+ return [];
491
+ }
492
+ return [
493
+ ...databases.flatMap((database) => createSqlDatabaseFiles(database)),
494
+ {
495
+ path: "src/common/data/sql/sql.source-factory.ts",
496
+ type: "config",
497
+ content: `import { SoapSQL, SqlDataSource } from '@soapjs/soap-node-sql';
498
+
499
+ export function createSqlSource<T = Record<string, unknown>>(sql: SoapSQL, tableName: string): SqlDataSource<T> {
500
+ return new SqlDataSource<T>(sql, tableName);
501
+ }
502
+ `,
503
+ },
504
+ {
505
+ path: "src/common/data/sql/migrations/.gitkeep",
506
+ type: "config",
507
+ content: "",
508
+ },
509
+ ];
510
+ }
511
+ function createSqlDatabaseFiles(database) {
512
+ const names = (0, naming_1.createNameVariants)(database);
513
+ const configProperty = names.camelName;
514
+ return [
515
+ {
516
+ path: `src/config/${database}.config.ts`,
517
+ type: "config",
518
+ content: `import { SqlDatabaseConfig } from '@soapjs/soap-node-sql';
519
+ import { AppConfig } from './config';
520
+
521
+ export function create${names.pascalName}Config(config: Pick<AppConfig, '${configProperty}'>): SqlDatabaseConfig {
522
+ return new SqlDatabaseConfig({
523
+ ${createSqlConfigProperties(database, configProperty)}
524
+ connectionLimit: 10,
525
+ });
526
+ }
527
+ `,
528
+ },
529
+ {
530
+ path: `src/common/data/${database}/${database}.client.ts`,
531
+ type: "config",
532
+ content: `import { SoapSQL } from '@soapjs/soap-node-sql';
533
+ import { AppConfig } from '../../../config/config';
534
+ import { create${names.pascalName}Config } from '../../../config/${database}.config';
535
+
536
+ export async function create${names.pascalName}Client(config: AppConfig): Promise<SoapSQL> {
537
+ return SoapSQL.create(create${names.pascalName}Config(config));
538
+ }
539
+ `,
540
+ },
541
+ ];
542
+ }
543
+ function createSqlConfigProperties(database, configProperty) {
544
+ if (database === "sqlite") {
545
+ return ` type: 'sqlite',
546
+ filename: config.${configProperty}.filename,`;
547
+ }
548
+ const sqlType = database === "postgres" ? "postgresql" : "mysql";
549
+ return ` type: '${sqlType}',
550
+ host: config.${configProperty}.host,
551
+ port: config.${configProperty}.port,
552
+ database: config.${configProperty}.database,
553
+ username: config.${configProperty}.user,
554
+ password: config.${configProperty}.password,`;
555
+ }
556
+ function createCqrsFiles(plan) {
557
+ if (plan.architecture !== "cqrs") {
558
+ return [];
559
+ }
560
+ return [
561
+ {
562
+ path: "src/config/cqrs.ts",
563
+ type: "config",
564
+ content: `// Generated CQRS handler imports. Updated by soap add command/query.
565
+ export {};
566
+ `,
567
+ },
568
+ {
569
+ path: "src/types/soap-express-cqrs.d.ts",
570
+ type: "config",
571
+ content: `declare module '@soapjs/soap-express/cqrs' {
572
+ export * from '@soapjs/soap-express/build/cqrs';
573
+ }
574
+ `,
575
+ },
576
+ ];
577
+ }
578
+ function createEventFiles(plan) {
579
+ if (plan.capabilities.messaging.length === 0) {
580
+ return [];
581
+ }
582
+ const files = [
583
+ {
584
+ path: "src/common/events/domain-event.ts",
585
+ type: "config",
586
+ content: `export { BaseDomainEvent, DomainEvent } from '@soapjs/soap/domain';
587
+ export { DomainEventBus, DomainEventConsumer, InMemoryDomainEventBus } from '@soapjs/soap/cqrs';
588
+ `,
589
+ },
590
+ {
591
+ path: "src/common/events/event-bus.setup.ts",
592
+ type: "config",
593
+ content: plan.capabilities.messaging.includes("kafka")
594
+ ? `import { DomainEventBus, InMemoryDomainEventBus } from '@soapjs/soap/cqrs';
595
+ import { Drainable } from '@soapjs/soap/events';
596
+ import { Logger } from '@soapjs/soap/common';
597
+ import { AppConfig } from '../../config/config';
598
+ import { createKafkaDomainEventBus } from './kafka/kafka-event-bus';
599
+
600
+ export interface EventBusRuntime {
601
+ bus: DomainEventBus;
602
+ drainable?: Drainable;
603
+ }
604
+
605
+ export async function createEventBus(config: AppConfig, logger: Logger): Promise<EventBusRuntime> {
606
+ if (config.eventBus === 'kafka') {
607
+ const bus = createKafkaDomainEventBus(config, logger);
608
+ await bus.start();
609
+
610
+ return {
611
+ bus,
612
+ drainable: {
613
+ close: () => bus.stop(),
614
+ },
615
+ };
616
+ }
617
+
618
+ return { bus: new InMemoryDomainEventBus() };
619
+ }
620
+ `
621
+ : `import { DomainEventBus, InMemoryDomainEventBus } from '@soapjs/soap/cqrs';
622
+ import { Drainable } from '@soapjs/soap/events';
623
+ import { Logger } from '@soapjs/soap/common';
624
+ import { AppConfig } from '../../config/config';
625
+
626
+ export interface EventBusRuntime {
627
+ bus: DomainEventBus;
628
+ drainable?: Drainable;
629
+ }
630
+
631
+ export async function createEventBus(_config: AppConfig, _logger: Logger): Promise<EventBusRuntime> {
632
+ return { bus: new InMemoryDomainEventBus() };
633
+ }
634
+ `,
635
+ },
636
+ ];
637
+ if (plan.capabilities.messaging.includes("kafka")) {
638
+ files.push({
639
+ path: "src/config/kafka.config.ts",
640
+ type: "config",
641
+ content: `import { AppConfig } from './config';
642
+
643
+ export function createKafkaConfig(config: Pick<AppConfig, 'kafkaBrokers'>) {
644
+ return {
645
+ brokers: config.kafkaBrokers,
646
+ clientId: 'soapjs-service',
647
+ topicName: 'domain-events',
648
+ groupId: 'soapjs-service',
649
+ ensureTopic: true,
650
+ };
651
+ }
652
+ `,
653
+ }, {
654
+ path: "src/common/events/kafka/kafka.client.ts",
655
+ type: "config",
656
+ content: `import { KafkaEventBus } from '@soapjs/soap-node-kafka';
657
+ import { AppConfig } from '../../../config/config';
658
+ import { createKafkaConfig } from '../../../config/kafka.config';
659
+
660
+ export function createKafkaClient(config: AppConfig): KafkaEventBus<Record<string, unknown>, Record<string, unknown>> {
661
+ return new KafkaEventBus<Record<string, unknown>, Record<string, unknown>>(createKafkaConfig(config));
662
+ }
663
+ `,
664
+ }, {
665
+ path: "src/common/events/kafka/kafka-event-bus.ts",
666
+ type: "config",
667
+ content: `import { KafkaDomainEventBus } from '@soapjs/soap-node-kafka';
668
+ import { Logger } from '@soapjs/soap/common';
669
+ import { AppConfig } from '../../../config/config';
670
+ import { createKafkaClient } from './kafka.client';
671
+
672
+ export function createKafkaDomainEventBus(config: AppConfig, logger: Logger): KafkaDomainEventBus {
673
+ return new KafkaDomainEventBus(createKafkaClient(config), {
674
+ topic: 'domain-events',
675
+ groupId: 'soapjs-service',
676
+ logger,
677
+ });
678
+ }
679
+ `,
680
+ });
681
+ }
682
+ return files;
683
+ }
684
+ function createSocketFiles(plan) {
685
+ if (!plan.capabilities.realtime.includes("ws")) {
686
+ return [];
687
+ }
688
+ return [
689
+ {
690
+ path: "src/config/sockets.ts",
691
+ type: "config",
692
+ content: `import { AppSocketHandler } from '../common/sockets/socket.setup';
693
+
694
+ export const socketHandlers: AppSocketHandler[] = [];
695
+ `,
696
+ },
697
+ {
698
+ path: "src/common/sockets/socket.setup.ts",
699
+ type: "config",
700
+ content: `import { Server } from 'http';
701
+ import { Drainable } from '@soapjs/soap/events';
702
+ import { SocketMessage, SocketServer, WebSocketServerAdapter } from '@soapjs/soap-node-socket';
703
+ import { AppConfig } from '../../config/config';
704
+ import { socketHandlers } from '../../config/sockets';
705
+
706
+ export interface AppSocketHandler {
707
+ event: string;
708
+ handle(clientId: string, message: SocketMessage, server: SocketServer): Promise<void> | void;
709
+ }
710
+
711
+ export interface SocketRuntime extends Drainable {
712
+ server: SocketServer;
713
+ }
714
+
715
+ export function createSocketRuntime(config: AppConfig, httpServer: Server): SocketRuntime {
716
+ const adapter = new WebSocketServerAdapter({ server: httpServer, path: config.wsPath });
717
+ const server = new SocketServer(adapter as any, {
718
+ port: config.port,
719
+ heartbeatInterval: config.wsHeartbeatMs,
720
+ onConnection: (clientId) => {
721
+ server.sendToClient(clientId, {
722
+ type: 'connected',
723
+ payload: { clientId },
724
+ });
725
+ },
726
+ onMessage: async (clientId, message) => {
727
+ const handler = socketHandlers.find((item) => item.event === message.type);
728
+ if (handler) {
729
+ await handler.handle(clientId, message, server);
730
+ }
731
+ },
732
+ });
733
+
734
+ return {
735
+ server,
736
+ close: async () => server.shutdown(),
737
+ };
738
+ }
739
+ `,
740
+ },
741
+ {
742
+ path: "src/common/sockets/ws.socket-server.ts",
743
+ type: "config",
744
+ content: `export { createSocketRuntime } from './socket.setup';
745
+ export type { AppSocketHandler, SocketRuntime } from './socket.setup';
746
+ `,
747
+ },
748
+ ];
749
+ }
750
+ function createControllersTs(controllers) {
751
+ if (controllers.length === 0) {
752
+ return `export const controllers = [];
753
+ `;
754
+ }
755
+ const imports = controllers
756
+ .map((controller) => `import { ${controller.className} } from '${controller.importPath}';`)
757
+ .join("\n");
758
+ const names = controllers.map((controller) => ` ${controller.spread ? "..." : ""}${controller.className},`).join("\n");
759
+ return `${imports}
760
+
761
+ export const controllers = [
762
+ ${names}
763
+ ];
764
+ `;
765
+ }
766
+ exports.createControllersTs = createControllersTs;
767
+ function createInitialControllers(plan) {
768
+ if (!usesJwtAuth(plan)) {
769
+ return [];
770
+ }
771
+ return [
772
+ {
773
+ className: "AuthController",
774
+ importPath: "../features/auth/api/auth.controller",
775
+ },
776
+ ];
777
+ }
778
+ function createEnvExample(plan) {
779
+ const lines = ["NODE_ENV=development", "PORT=3000", "LOG_LEVEL=debug"];
780
+ if (plan.capabilities.databases.includes("mongo")) {
781
+ lines.push("MONGO_URI=mongodb://localhost:27017/app");
782
+ }
783
+ if (plan.capabilities.databases.includes("postgres")) {
784
+ lines.push("POSTGRES_HOST=localhost", "POSTGRES_PORT=5432", "POSTGRES_DB=app", "POSTGRES_USER=app", "POSTGRES_PASSWORD=app");
785
+ }
786
+ if (plan.capabilities.databases.includes("mysql")) {
787
+ lines.push("MYSQL_HOST=localhost", "MYSQL_PORT=3306", "MYSQL_DATABASE=app", "MYSQL_USER=app", "MYSQL_PASSWORD=app");
788
+ }
789
+ if (plan.capabilities.databases.includes("sqlite")) {
790
+ lines.push("SQLITE_FILENAME=./data/app.sqlite");
791
+ }
792
+ if (plan.capabilities.databases.includes("redis")) {
793
+ lines.push("REDIS_URL=redis://localhost:6379");
794
+ }
795
+ if (plan.capabilities.auth.includes("jwt") || plan.capabilities.auth.includes("local")) {
796
+ lines.push("JWT_SECRET=change-me");
797
+ }
798
+ if (plan.capabilities.auth.includes("api-key")) {
799
+ lines.push("API_KEY_HEADER=x-api-key", "DEV_API_KEY=dev-api-key");
800
+ }
801
+ if (plan.capabilities.messaging.includes("kafka")) {
802
+ lines.push("KAFKA_BROKERS=localhost:9092", "EVENT_BUS=in-memory");
803
+ }
804
+ if (plan.capabilities.realtime.includes("ws")) {
805
+ lines.push("WS_PATH=/ws", "WS_HEARTBEAT_MS=30000");
806
+ }
807
+ return `${lines.join("\n")}\n`;
808
+ }
809
+ function createReadme(plan) {
810
+ const runCommand = plan.packageManager === "npm" ? "npm run dev" : `${plan.packageManager} dev`;
811
+ const buildCommand = plan.packageManager === "npm" ? "npm run build" : `${plan.packageManager} build`;
812
+ const installCommand = `${plan.packageManager} install`;
813
+ const dockerSection = `## Docker Development
814
+
815
+ \`\`\`bash
816
+ make up
817
+ make logs
818
+ curl http://localhost:3000/health
819
+ make down
820
+ \`\`\`
821
+
822
+ `;
823
+ const brunoSection = plan.capabilities.apiClient.includes("bruno")
824
+ ? `## Bruno API Tests
825
+
826
+ \`\`\`bash
827
+ ${plan.packageManager === "npm" ? "npm run bruno" : `${plan.packageManager} bruno`}
828
+ ${plan.packageManager === "npm" ? "npm run test:api" : `${plan.packageManager} test:api`}
829
+ \`\`\`
830
+
831
+ The collection is generated in \`bruno/\` and uses the \`Local\` environment.
832
+
833
+ `
834
+ : "";
835
+ const openApiSection = plan.capabilities.docs.includes("openapi")
836
+ ? `## OpenAPI
837
+
838
+ Start the API, then open:
839
+
840
+ - http://localhost:3000/docs
841
+ - http://localhost:3000/openapi.json
842
+
843
+ \`\`\`bash
844
+ curl http://localhost:3000/openapi.json
845
+ \`\`\`
846
+
847
+ `
848
+ : "";
849
+ const authSection = createReadmeAuthSection(plan);
850
+ const addCommands = `## Add More Code
851
+
852
+ \`\`\`bash
853
+ soap add resource invoice --crud
854
+ soap add route invoices export --method get --path export
855
+ soap update config --add-contracts zod
856
+ soap update config --add-api-client bruno
857
+ \`\`\`
858
+ `;
859
+ return `# ${plan.name}
860
+
861
+ Generated SoapJS service.
862
+
863
+ ## Capabilities
864
+
865
+ - Framework: ${plan.framework}
866
+ - Architecture: ${plan.architecture}
867
+ - Databases: ${plan.capabilities.databases.length ? plan.capabilities.databases.join(", ") : "none"}
868
+ - Auth: ${plan.capabilities.auth.length ? plan.capabilities.auth.join(", ") : "none"}
869
+ - Messaging: ${plan.capabilities.messaging.join(", ")}
870
+ - Telemetry: ${plan.capabilities.telemetry.join(", ")}
871
+ - Realtime: ${plan.capabilities.realtime.length ? plan.capabilities.realtime.join(", ") : "none"}
872
+ - API client: ${plan.capabilities.apiClient.length ? plan.capabilities.apiClient.join(", ") : "none"}
873
+ - Docs: ${plan.capabilities.docs.length ? plan.capabilities.docs.join(", ") : "none"}
874
+ - Contracts: ${plan.capabilities.contracts.length ? plan.capabilities.contracts.join(", ") : "none"}
875
+
876
+ ## Local Development
877
+
878
+ \`\`\`bash
879
+ ${installCommand}
880
+ ${runCommand}
881
+ \`\`\`
882
+
883
+ Health check:
884
+
885
+ \`\`\`bash
886
+ curl http://localhost:3000/health
887
+ \`\`\`
888
+
889
+ Build:
890
+
891
+ \`\`\`bash
892
+ ${buildCommand}
893
+ \`\`\`
894
+
895
+ ${dockerSection}${brunoSection}${openApiSection}${authSection}## Folder Structure
896
+
897
+ \`\`\`txt
898
+ src/
899
+ index.ts
900
+ config/
901
+ config.ts
902
+ controllers.ts
903
+ dependencies.ts
904
+ resources.ts
905
+ common/
906
+ features/
907
+ <resource>/
908
+ domain/
909
+ application/
910
+ data/
911
+ api/
912
+ contracts/
913
+ \`\`\`
914
+
915
+ Generated metadata is stored in \`.soap/\`.
916
+
917
+ ${addCommands}
918
+ `;
919
+ }
920
+ function createReadmeAuthSection(plan) {
921
+ const sections = [];
922
+ if (plan.capabilities.auth.includes("jwt") || plan.capabilities.auth.includes("local")) {
923
+ sections.push(`### JWT/local auth dev credentials
924
+
925
+ Default Bruno login variables:
926
+
927
+ - email: \`admin@example.com\`
928
+ - password: \`admin123\`
929
+
930
+ Set \`JWT_SECRET\` in \`.env.example\` or your local \`.env\`.
931
+
932
+ `);
933
+ }
934
+ if (plan.capabilities.auth.includes("api-key")) {
935
+ sections.push(`### API key auth dev credentials
936
+
937
+ Default header and key:
938
+
939
+ - \`API_KEY_HEADER=x-api-key\`
940
+ - \`DEV_API_KEY=dev-api-key\`
941
+
942
+ `);
943
+ }
944
+ return sections.length > 0 ? `## Auth\n\n${sections.join("\n")}` : "";
945
+ }
946
+ function createDockerfile() {
947
+ return `FROM node:20-alpine
948
+ WORKDIR /app
949
+ COPY package*.json ./
950
+ RUN npm install
951
+ COPY . .
952
+ RUN npm run build
953
+ CMD ["npm", "start"]
954
+ `;
955
+ }
956
+ function createDockerCompose(plan) {
957
+ const services = [
958
+ "services:",
959
+ " api:",
960
+ " build: .",
961
+ " ports:",
962
+ " - \"3000:3000\"",
963
+ " env_file:",
964
+ " - .env.example",
965
+ ];
966
+ if (plan.capabilities.databases.includes("mongo")) {
967
+ services.push(" mongo:", " image: mongo:7", " ports:", " - \"27017:27017\"", " volumes:", " - mongo-data:/data/db");
968
+ }
969
+ if (plan.capabilities.databases.includes("postgres")) {
970
+ services.push(" postgres:", " image: postgres:16", " environment:", " POSTGRES_DB: app", " POSTGRES_USER: app", " POSTGRES_PASSWORD: app", " ports:", " - \"5432:5432\"", " volumes:", " - postgres-data:/var/lib/postgresql/data");
971
+ }
972
+ if (plan.capabilities.databases.includes("mysql")) {
973
+ services.push(" mysql:", " image: mysql:8", " environment:", " MYSQL_DATABASE: app", " MYSQL_USER: app", " MYSQL_PASSWORD: app", " MYSQL_ROOT_PASSWORD: app", " ports:", " - \"3306:3306\"", " volumes:", " - mysql-data:/var/lib/mysql");
974
+ }
975
+ if (plan.capabilities.databases.includes("redis")) {
976
+ services.push(" redis:", " image: redis:7", " ports:", " - \"6379:6379\"");
977
+ }
978
+ if (plan.capabilities.messaging.includes("kafka")) {
979
+ services.push(" redpanda:", " image: redpandadata/redpanda:v24.1.1", " command: redpanda start --overprovisioned --smp 1 --memory 512M --reserve-memory 0M --node-id 0 --check=false", " ports:", " - \"9092:9092\"");
980
+ }
981
+ const volumes = ["volumes:"];
982
+ if (plan.capabilities.databases.includes("mongo"))
983
+ volumes.push(" mongo-data:");
984
+ if (plan.capabilities.databases.includes("postgres"))
985
+ volumes.push(" postgres-data:");
986
+ if (plan.capabilities.databases.includes("mysql"))
987
+ volumes.push(" mysql-data:");
988
+ return `${services.join("\n")}\n${volumes.length > 1 ? volumes.join("\n") : ""}\n`;
989
+ }
990
+ function createMakefile() {
991
+ return `.PHONY: help up down down-clean logs build dev test lint bruno test-api openapi kafka-mode
992
+
993
+ help:
994
+ \t@grep -E '^[a-zA-Z_-]+:' Makefile
995
+
996
+ up:
997
+ \tdocker compose up -d --build
998
+
999
+ down:
1000
+ \tdocker compose down
1001
+
1002
+ down-clean:
1003
+ \tdocker compose down -v
1004
+
1005
+ logs:
1006
+ \tdocker compose logs -f api
1007
+
1008
+ build:
1009
+ \tnpm run build
1010
+
1011
+ dev:
1012
+ \tnpm run dev
1013
+
1014
+ test:
1015
+ \tnpm test
1016
+
1017
+ lint:
1018
+ \tnpm run lint
1019
+
1020
+ bruno:
1021
+ \tnpm run bruno
1022
+
1023
+ test-api:
1024
+ \tnpm run test:api
1025
+
1026
+ openapi:
1027
+ \tcurl http://localhost:3000/openapi.json
1028
+
1029
+ kafka-mode:
1030
+ \tEVENT_BUS=kafka npm run dev
1031
+ `;
1032
+ }
1033
+ function createAuthFiles(plan) {
1034
+ if (plan.capabilities.auth.length === 0) {
1035
+ return [];
1036
+ }
1037
+ const files = [
1038
+ {
1039
+ path: "src/features/auth/domain/auth-user.ts",
1040
+ type: "config",
1041
+ owner: "auth",
1042
+ content: `export interface AuthUser {
1043
+ id: string;
1044
+ email: string;
1045
+ name: string;
1046
+ roles: string[];
1047
+ }
1048
+ `,
1049
+ },
1050
+ ];
1051
+ if (usesJwtAuth(plan)) {
1052
+ files.push({
1053
+ path: "src/features/auth/data/dev-users.ts",
1054
+ type: "config",
1055
+ owner: "auth",
1056
+ content: `import { AuthUser } from '../domain/auth-user';
1057
+
1058
+ export interface DevUser extends AuthUser {
1059
+ password: string;
1060
+ }
1061
+
1062
+ export const devUsers: DevUser[] = [
1063
+ {
1064
+ id: 'dev-admin',
1065
+ email: 'admin@example.com',
1066
+ name: 'Dev Admin',
1067
+ password: 'admin123',
1068
+ roles: ['admin'],
1069
+ },
1070
+ ];
1071
+
1072
+ export function findDevUser(email: string): DevUser | undefined {
1073
+ return devUsers.find((user) => user.email === email);
1074
+ }
1075
+ `,
1076
+ }, {
1077
+ path: "src/features/auth/auth.tokens.ts",
1078
+ type: "config",
1079
+ owner: "auth",
1080
+ content: `import jwt from 'jsonwebtoken';
1081
+ import { AuthUser } from './domain/auth-user';
1082
+
1083
+ export interface AuthTokenPayload {
1084
+ sub: string;
1085
+ email: string;
1086
+ name: string;
1087
+ roles: string[];
1088
+ }
1089
+
1090
+ export function signAccessToken(user: AuthUser, secret: string): string {
1091
+ return jwt.sign(
1092
+ {
1093
+ sub: user.id,
1094
+ email: user.email,
1095
+ name: user.name,
1096
+ roles: user.roles,
1097
+ },
1098
+ secret,
1099
+ { expiresIn: '1h' }
1100
+ );
1101
+ }
1102
+
1103
+ export function verifyAccessToken(token: string, secret: string): AuthUser {
1104
+ const payload = jwt.verify(token, secret) as AuthTokenPayload;
1105
+
1106
+ return {
1107
+ id: payload.sub,
1108
+ email: payload.email,
1109
+ name: payload.name,
1110
+ roles: payload.roles ?? [],
1111
+ };
1112
+ }
1113
+ `,
1114
+ }, {
1115
+ path: "src/features/auth/jwt.strategy.ts",
1116
+ type: "config",
1117
+ owner: "auth",
1118
+ content: `import { AuthStrategy, HttpContext } from '@soapjs/soap/http';
1119
+ import { verifyAccessToken } from './auth.tokens';
1120
+ import { AuthUser } from './domain/auth-user';
1121
+
1122
+ export class JwtAuthStrategy implements AuthStrategy<AuthUser> {
1123
+ readonly name = 'jwt';
1124
+
1125
+ constructor(private readonly secret: string) {}
1126
+
1127
+ async authenticate(ctx: HttpContext) {
1128
+ const authorization = ctx.req.headers.authorization;
1129
+ const header = Array.isArray(authorization) ? authorization[0] : authorization;
1130
+
1131
+ if (!header?.startsWith('Bearer ')) {
1132
+ return null;
1133
+ }
1134
+
1135
+ const token = header.slice('Bearer '.length);
1136
+ const user = verifyAccessToken(token, this.secret);
1137
+
1138
+ return { user };
1139
+ }
1140
+ }
1141
+ `,
1142
+ }, {
1143
+ path: "src/features/auth/api/auth.controller.ts",
1144
+ type: "config",
1145
+ owner: "auth",
1146
+ content: `import { Request } from 'express';
1147
+ import { Auth, Controller, Get, Post } from '@soapjs/soap-express';
1148
+ import { config } from '../../../config/config';
1149
+ import { signAccessToken } from '../auth.tokens';
1150
+ import { findDevUser } from '../data/dev-users';
1151
+
1152
+ @Controller('/auth', {
1153
+ apiDoc: {
1154
+ tags: ['Auth'],
1155
+ description: 'Development auth endpoints generated by SoapJS CLI',
1156
+ },
1157
+ })
1158
+ export class AuthController {
1159
+ @Post('/login')
1160
+ async login(req: Request): Promise<unknown> {
1161
+ const { email, password } = req.body ?? {};
1162
+ const user = typeof email === 'string' ? findDevUser(email) : undefined;
1163
+
1164
+ if (!user || user.password !== password) {
1165
+ return { error: 'Invalid credentials' };
1166
+ }
1167
+
1168
+ const { password: _password, ...safeUser } = user;
1169
+ const accessToken = signAccessToken(safeUser, config.jwtSecret);
1170
+
1171
+ return {
1172
+ accessToken,
1173
+ user: safeUser,
1174
+ };
1175
+ }
1176
+
1177
+ @Get('/me')
1178
+ @Auth('jwt')
1179
+ async me(req: Request): Promise<unknown> {
1180
+ return (req as Request & { user?: unknown }).user;
1181
+ }
1182
+ }
1183
+ `,
1184
+ });
1185
+ }
1186
+ if (plan.capabilities.auth.includes("api-key")) {
1187
+ files.push({
1188
+ path: "src/features/auth/api-key.strategy.ts",
1189
+ type: "config",
1190
+ owner: "auth",
1191
+ content: `import { AuthStrategy, HttpContext } from '@soapjs/soap/http';
1192
+ import { AuthUser } from './domain/auth-user';
1193
+
1194
+ export class ApiKeyAuthStrategy implements AuthStrategy<AuthUser> {
1195
+ readonly name = 'api-key';
1196
+
1197
+ constructor(
1198
+ private readonly headerName: string,
1199
+ private readonly expectedApiKey: string
1200
+ ) {}
1201
+
1202
+ async authenticate(ctx: HttpContext) {
1203
+ const value = ctx.req.headers[this.headerName.toLowerCase()];
1204
+ const apiKey = Array.isArray(value) ? value[0] : value;
1205
+
1206
+ if (!apiKey || apiKey !== this.expectedApiKey) {
1207
+ return null;
1208
+ }
1209
+
1210
+ return {
1211
+ user: {
1212
+ id: 'api-key-client',
1213
+ email: 'api-key@example.com',
1214
+ name: 'API Key Client',
1215
+ roles: ['admin'],
1216
+ },
1217
+ };
1218
+ }
1219
+ }
1220
+ `,
1221
+ });
1222
+ }
1223
+ files.push({
1224
+ path: "src/features/auth/auth.setup.ts",
1225
+ type: "config",
1226
+ owner: "auth",
1227
+ content: createAuthSetupTs(plan),
1228
+ }, {
1229
+ path: "src/features/auth/index.ts",
1230
+ type: "config",
1231
+ owner: "auth",
1232
+ content: createAuthIndexTs(plan),
1233
+ });
1234
+ return files;
1235
+ }
1236
+ function usesJwtAuth(plan) {
1237
+ return plan.capabilities.auth.includes("jwt") || plan.capabilities.auth.includes("local");
1238
+ }
1239
+ function createAuthSetupTs(plan) {
1240
+ const imports = [
1241
+ "import { AuthStrategy } from '@soapjs/soap/http';",
1242
+ "import { AppConfig } from '../../config/config';",
1243
+ ];
1244
+ const body = [" const strategies: AuthStrategy[] = [];"];
1245
+ if (usesJwtAuth(plan)) {
1246
+ imports.push("import { JwtAuthStrategy } from './jwt.strategy';");
1247
+ body.push("", " strategies.push(new JwtAuthStrategy(config.jwtSecret));");
1248
+ }
1249
+ if (plan.capabilities.auth.includes("api-key")) {
1250
+ imports.push("import { ApiKeyAuthStrategy } from './api-key.strategy';");
1251
+ body.push("", " strategies.push(new ApiKeyAuthStrategy(config.apiKeyHeader, config.devApiKey));");
1252
+ }
1253
+ body.push("", " return strategies;");
1254
+ return `${imports.join("\n")}
1255
+
1256
+ export function createAuthStrategies(config: AppConfig): AuthStrategy[] {
1257
+ ${body.join("\n")}
1258
+ }
1259
+ `;
1260
+ }
1261
+ function createAuthIndexTs(plan) {
1262
+ const exports = ["export * from './auth.setup';", "export * from './domain/auth-user';"];
1263
+ if (usesJwtAuth(plan)) {
1264
+ exports.push("export * from './api/auth.controller';", "export * from './auth.tokens';", "export * from './jwt.strategy';");
1265
+ }
1266
+ if (plan.capabilities.auth.includes("api-key")) {
1267
+ exports.push("export * from './api-key.strategy';");
1268
+ }
1269
+ return `${exports.join("\n")}\n`;
1270
+ }
1271
+ function createFeaturesIndexTs(plan) {
1272
+ const exports = [];
1273
+ if (plan.capabilities.auth.length > 0) {
1274
+ exports.push("export * from './auth';");
1275
+ }
1276
+ return exports.length > 0 ? `${exports.join("\n")}\n` : "export {};\n";
1277
+ }
1278
+ function createBrunoFiles(plan) {
1279
+ if (!plan.capabilities.apiClient.includes("bruno")) {
1280
+ return [];
1281
+ }
1282
+ const files = [
1283
+ {
1284
+ path: "bruno/bruno.json",
1285
+ type: "bruno",
1286
+ content: JSON.stringify({ version: "1", name: plan.name, type: "collection" }, null, 2),
1287
+ },
1288
+ {
1289
+ path: "bruno/Health/health.bru",
1290
+ type: "bruno",
1291
+ content: `meta {
1292
+ name: Health
1293
+ type: http
1294
+ seq: 1
1295
+ }
1296
+
1297
+ get {
1298
+ url: {{baseUrl}}/health
1299
+ body: none
1300
+ auth: none
1301
+ }
1302
+
1303
+ tests {
1304
+ function onResponse(request, response) {
1305
+ expect(response.status).to.be.within(200, 299);
1306
+ }
1307
+ }
1308
+ `,
1309
+ },
1310
+ {
1311
+ path: "bruno/environments/Local.bru",
1312
+ type: "bruno",
1313
+ content: `vars {
1314
+ baseUrl: http://localhost:3000
1315
+ accessToken:
1316
+ apiKey:
1317
+ id:
1318
+ email: admin@example.com
1319
+ password: admin123
1320
+ }
1321
+ `,
1322
+ },
1323
+ ];
1324
+ if (usesJwtAuth(plan)) {
1325
+ files.push({
1326
+ path: "bruno/Auth/login.bru",
1327
+ type: "bruno",
1328
+ owner: "auth",
1329
+ content: createBrunoLoginBru(2),
1330
+ }, {
1331
+ path: "bruno/Auth/me.bru",
1332
+ type: "bruno",
1333
+ owner: "auth",
1334
+ content: createBrunoRequestBru({
1335
+ name: "Me",
1336
+ method: "GET",
1337
+ path: "/auth/me",
1338
+ sequence: 3,
1339
+ auth: "jwt",
1340
+ }),
1341
+ });
1342
+ }
1343
+ return files;
1344
+ }
1345
+ function createBrunoLoginBru(sequence) {
1346
+ return `meta {
1347
+ name: Login
1348
+ type: http
1349
+ seq: ${sequence}
1350
+ }
1351
+
1352
+ post {
1353
+ url: {{baseUrl}}/auth/login
1354
+ body: json
1355
+ auth: none
1356
+ }
1357
+
1358
+ body:json {
1359
+ {
1360
+ "email": "{{email}}",
1361
+ "password": "{{password}}"
1362
+ }
1363
+ }
1364
+
1365
+ script:post-response {
1366
+ const data = res.getBody();
1367
+ if (data?.accessToken) {
1368
+ bru.setEnvVar("accessToken", data.accessToken);
1369
+ }
1370
+ }
1371
+
1372
+ tests {
1373
+ function onResponse(request, response) {
1374
+ expect(response.status).to.be.within(200, 299);
1375
+ const body = response.getBody();
1376
+ expect(body.accessToken).to.be.a('string').and.not.empty;
1377
+ }
1378
+ }
1379
+ `;
1380
+ }
1381
+ function createBrunoRequestBru(options) {
1382
+ const method = options.method.toLowerCase();
1383
+ const bodyType = options.includeJsonBody ? "json" : "none";
1384
+ const authBlock = options.auth === "jwt"
1385
+ ? `
1386
+ headers {
1387
+ Authorization: Bearer {{accessToken}}
1388
+ }
1389
+ `
1390
+ : options.auth === "api-key"
1391
+ ? `
1392
+ headers {
1393
+ x-api-key: {{apiKey}}
1394
+ }
1395
+ `
1396
+ : "";
1397
+ const bodyBlock = options.includeJsonBody
1398
+ ? `
1399
+ body:json {
1400
+ {
1401
+ "name": "Example"
1402
+ }
1403
+ }
1404
+ `
1405
+ : "";
1406
+ const bodyExpectation = options.expectBody
1407
+ ? `
1408
+ expect(response.getBody()).to.exist;
1409
+ `
1410
+ : "";
1411
+ const testsBlock = `
1412
+ tests {
1413
+ function onResponse(request, response) {
1414
+ expect(response.status).to.be.within(200, 299);${bodyExpectation}
1415
+ }
1416
+ }
1417
+ `;
1418
+ return `meta {
1419
+ name: ${options.name}
1420
+ type: http
1421
+ seq: ${options.sequence}
1422
+ }
1423
+
1424
+ ${method} {
1425
+ url: {{baseUrl}}${options.path}
1426
+ body: ${bodyType}
1427
+ auth: none
1428
+ }
1429
+ ${authBlock}${bodyBlock}${testsBlock}`;
1430
+ }