@simplens/onboard 1.0.1 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +331 -214
  2. package/dist/__tests__/env-config.test.d.ts +2 -0
  3. package/dist/__tests__/env-config.test.d.ts.map +1 -0
  4. package/dist/__tests__/env-config.test.js +23 -0
  5. package/dist/__tests__/env-config.test.js.map +1 -0
  6. package/dist/__tests__/infra-prompts.test.d.ts +2 -0
  7. package/dist/__tests__/infra-prompts.test.d.ts.map +1 -0
  8. package/dist/__tests__/infra-prompts.test.js +43 -0
  9. package/dist/__tests__/infra-prompts.test.js.map +1 -0
  10. package/dist/__tests__/infra.test.d.ts +2 -0
  11. package/dist/__tests__/infra.test.d.ts.map +1 -0
  12. package/dist/__tests__/infra.test.js +14 -0
  13. package/dist/__tests__/infra.test.js.map +1 -0
  14. package/dist/__tests__/nginx.test.d.ts +2 -0
  15. package/dist/__tests__/nginx.test.d.ts.map +1 -0
  16. package/dist/__tests__/nginx.test.js +16 -0
  17. package/dist/__tests__/nginx.test.js.map +1 -0
  18. package/dist/env-config.d.ts +27 -12
  19. package/dist/env-config.d.ts.map +1 -1
  20. package/dist/env-config.js +253 -128
  21. package/dist/env-config.js.map +1 -1
  22. package/dist/index.js +340 -69
  23. package/dist/index.js.map +1 -1
  24. package/dist/infra.d.ts +19 -8
  25. package/dist/infra.d.ts.map +1 -1
  26. package/dist/infra.js +267 -128
  27. package/dist/infra.js.map +1 -1
  28. package/dist/plugins.d.ts +5 -10
  29. package/dist/plugins.d.ts.map +1 -1
  30. package/dist/plugins.js +75 -44
  31. package/dist/plugins.js.map +1 -1
  32. package/dist/services.d.ts +1 -23
  33. package/dist/services.d.ts.map +1 -1
  34. package/dist/services.js +47 -62
  35. package/dist/services.js.map +1 -1
  36. package/dist/templates.d.ts +2 -1
  37. package/dist/templates.d.ts.map +1 -1
  38. package/dist/templates.js +203 -191
  39. package/dist/templates.js.map +1 -1
  40. package/dist/types/domain.d.ts +2 -0
  41. package/dist/types/domain.d.ts.map +1 -1
  42. package/dist/ui.d.ts +45 -0
  43. package/dist/ui.d.ts.map +1 -0
  44. package/dist/ui.js +93 -0
  45. package/dist/ui.js.map +1 -0
  46. package/dist/utils/logger.d.ts +1 -0
  47. package/dist/utils/logger.d.ts.map +1 -1
  48. package/dist/utils/logger.js +32 -7
  49. package/dist/utils/logger.js.map +1 -1
  50. package/dist/utils.d.ts +8 -0
  51. package/dist/utils.d.ts.map +1 -1
  52. package/dist/utils.js +66 -2
  53. package/dist/utils.js.map +1 -1
  54. package/dist/validators.d.ts +1 -52
  55. package/dist/validators.d.ts.map +1 -1
  56. package/dist/validators.js +10 -57
  57. package/dist/validators.js.map +1 -1
  58. package/package.json +3 -5
  59. package/src/__tests__/env-config.test.ts +28 -0
  60. package/src/__tests__/errors.test.ts +187 -187
  61. package/src/__tests__/infra-prompts.test.ts +54 -0
  62. package/src/__tests__/infra.test.ts +15 -0
  63. package/src/__tests__/utils.test.ts +142 -142
  64. package/src/__tests__/validators.test.ts +195 -195
  65. package/src/config/constants.ts +86 -86
  66. package/src/config/index.ts +1 -1
  67. package/src/env-config.ts +455 -311
  68. package/src/index.ts +534 -202
  69. package/src/infra.ts +404 -245
  70. package/src/plugins.ts +221 -190
  71. package/src/services.ts +175 -190
  72. package/src/templates.ts +209 -196
  73. package/src/types/domain.ts +129 -127
  74. package/src/types/errors.ts +173 -173
  75. package/src/types/index.ts +2 -2
  76. package/src/ui.ts +91 -0
  77. package/src/utils/index.ts +1 -1
  78. package/src/utils/logger.ts +144 -118
  79. package/src/utils.ts +183 -105
  80. package/src/validators.ts +145 -192
  81. package/tsconfig.json +18 -18
  82. package/vitest.config.ts +22 -22
package/src/infra.ts CHANGED
@@ -1,245 +1,404 @@
1
- import inquirer from 'inquirer';
2
- import { detectOS } from './validators.js';
3
- import { INFRA_COMPOSE_TEMPLATE, APP_COMPOSE_TEMPLATE } from './templates.js';
4
- import { writeFile, logInfo, logSuccess, logWarning } from './utils.js';
5
- import path from 'path';
6
- import type { InfraService } from './types/domain.js';
7
-
8
- const INFRA_SERVICES: InfraService[] = [
9
- { name: 'MongoDB (Database)', value: 'mongo', checked: true },
10
- { name: 'Kafka (Message Queue)', value: 'kafka', checked: true },
11
- { name: 'Kafka UI (Dashboard)', value: 'kafka-ui', checked: true },
12
- { name: 'Redis (Cache)', value: 'redis', checked: true },
13
- { name: 'Loki (Log Aggregation)', value: 'loki', checked: false },
14
- { name: 'Grafana (Observability Dashboard)', value: 'grafana', checked: false },
15
- ];
16
-
17
- /**
18
- * Prompts user to select which infrastructure services to deploy.
19
- * Services include MongoDB, Kafka, Redis, Loki, and Grafana.
20
- *
21
- * @returns Array of selected service IDs (e.g., ['mongo', 'kafka', 'redis'])
22
- * @throws Error if no services are selected
23
- *
24
- * @example
25
- * ```ts
26
- * const services = await promptInfraServices();
27
- * // Returns: ['mongo', 'kafka', 'kafka-ui', 'redis']
28
- * ```
29
- */
30
- export async function promptInfraServices(): Promise<string[]> {
31
- const answer = await inquirer.prompt<{ services: string[] }>([
32
- {
33
- type: 'checkbox',
34
- name: 'services',
35
- message: 'Select infrastructure services to run:',
36
- choices: INFRA_SERVICES,
37
- validate: (input: string[]) => {
38
- if (input.length === 0) {
39
- return 'Please select at least one service';
40
- }
41
- return true;
42
- },
43
- },
44
- ]);
45
-
46
- return answer.services;
47
- }
48
-
49
-
50
-
51
- /**
52
- * Service chunk definitions - each service as a complete block
53
- */
54
- const SERVICE_CHUNKS: Record<string, string> = {
55
- 'mongo': ` mongo:
56
- image: mongo:7.0
57
- container_name: mongo
58
- command: [ "--replSet", "rs0", "--bind_ip_all", "--port", "27017" ]
59
- ports:
60
- - 27017:27017
61
- healthcheck:
62
- test: echo "try { rs.status() } catch (err) { rs.initiate({_id:'rs0',members:[{_id:0,host:'mongo:27017'}]}) }" | mongosh --port 27017 --quiet
63
- interval: 5s
64
- timeout: 30s
65
- start_period: 0s
66
- start_interval: 1s
67
- retries: 30
68
- volumes:
69
- - "mongo_data:/data/db"
70
- - "mongo_config:/data/configdb"`,
71
-
72
- 'kafka': ` kafka:
73
- image: apache/kafka-native
74
- container_name: kafka
75
- ports:
76
- - "9092:9092"
77
- environment:
78
- # Configure listeners for both docker and host communication
79
- KAFKA_LISTENERS: CONTROLLER://localhost:9091,HOST://0.0.0.0:9092,DOCKER://0.0.0.0:9093
80
- KAFKA_ADVERTISED_LISTENERS: HOST://kafka:9092,DOCKER://kafka:9093
81
- KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,DOCKER:PLAINTEXT,HOST:PLAINTEXT
82
-
83
- # Settings required for KRaft mode
84
- KAFKA_NODE_ID: 1
85
- KAFKA_PROCESS_ROLES: broker,controller
86
- KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
87
- KAFKA_CONTROLLER_QUORUM_VOTERS: 1@localhost:9091
88
-
89
- # Listener to use for broker-to-broker communication
90
- KAFKA_INTER_BROKER_LISTENER_NAME: DOCKER
91
-
92
- # Required for a single node cluster
93
- KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
94
-
95
- # Disable auto-topic creation - API server will create topics with correct partitions
96
- KAFKA_AUTO_CREATE_TOPICS_ENABLE: "false"
97
- volumes:
98
- - "kafka_data:/var/lib/kafka/data"`,
99
-
100
- 'kafka-ui': ` kafka-ui:
101
- image: kafbat/kafka-ui:main
102
- container_name: kafka-ui
103
- ports:
104
- - 8080:8080
105
- environment:
106
- DYNAMIC_CONFIG_ENABLED: "true"
107
- KAFKA_CLUSTERS_0_NAME: local
108
- KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka:9093
109
- depends_on:
110
- - kafka`,
111
-
112
- 'redis': ` redis:
113
- image: redis:7-alpine
114
- container_name: redis
115
- ports:
116
- - "6379:6379"
117
- command: redis-server --appendonly yes
118
- volumes:
119
- - "redis_data:/data"
120
- healthcheck:
121
- test: [ "CMD", "redis-cli", "ping" ]
122
- interval: 5s
123
- timeout: 3s
124
- retries: 5`,
125
-
126
- 'loki': ` loki:
127
- image: grafana/loki:2.9.0
128
- container_name: loki
129
- ports:
130
- - "3100:3100"
131
- command: -config.file=/etc/loki/local-config.yaml
132
- volumes:
133
- - "loki_data:/loki"
134
- healthcheck:
135
- test: [ "CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:3100/ready || exit 1" ]
136
- interval: 10s
137
- timeout: 5s
138
- retries: 5`,
139
-
140
- 'grafana': ` grafana:
141
- image: grafana/grafana:10.2.0
142
- container_name: grafana
143
- ports:
144
- - "3001:3000"
145
- environment:
146
- - GF_PATHS_PROVISIONING=/etc/grafana/provisioning
147
- - GF_AUTH_ANONYMOUS_ENABLED=true
148
- - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
149
- - GF_SECURITY_ADMIN_PASSWORD=admin
150
- volumes:
151
- - "grafana_data:/var/lib/grafana"
152
- depends_on:
153
- loki:
154
- condition: service_healthy`,
155
- };
156
-
157
- /**
158
- * Service-to-volumes mapping
159
- */
160
- const SERVICE_VOLUMES: Record<string, string[]> = {
161
- 'mongo': ['mongo_data', 'mongo_config'],
162
- 'kafka': ['kafka_data'],
163
- 'kafka-ui': [],
164
- 'redis': ['redis_data'],
165
- 'loki': ['loki_data'],
166
- 'grafana': ['grafana_data'],
167
- };
168
-
169
- /**
170
- * Build docker-compose content from selected services
171
- */
172
- function buildInfraCompose(selectedServices: string[]): string {
173
- // Header
174
- const header = `# ============================================
175
- # SimpleNS Infrastructure Services
176
- # All services use Docker service names for container-to-container communication.
177
- # This ensures cross-platform compatibility (Windows, Linux, macOS).
178
- # ============================================
179
-
180
- services:
181
- # ============================================
182
- # Infrastructure Services
183
- # ============================================`;
184
-
185
- // Assemble selected service chunks
186
- const serviceBlocks: string[] = [];
187
- for (const service of selectedServices) {
188
- if (SERVICE_CHUNKS[service]) {
189
- serviceBlocks.push(SERVICE_CHUNKS[service]);
190
- }
191
- }
192
-
193
- // Collect volumes for selected services
194
- const volumeSet = new Set<string>();
195
- for (const service of selectedServices) {
196
- const volumes = SERVICE_VOLUMES[service] || [];
197
- volumes.forEach(v => volumeSet.add(v));
198
- }
199
-
200
- // Build volumes section
201
- const volumeLines: string[] = ['', 'volumes:'];
202
- for (const volume of Array.from(volumeSet).sort()) {
203
- volumeLines.push(` ${volume}:`);
204
- }
205
-
206
- // Build networks section with custom default network name
207
- const networkLines: string[] = ['', 'networks:', ' default:', ' name: simplens'];
208
-
209
- // Combine all parts
210
- return [
211
- header,
212
- serviceBlocks.join('\n\n'),
213
- volumeLines.join('\n'),
214
- networkLines.join('\n'),
215
- ].join('\n');
216
- }
217
-
218
-
219
-
220
- /**
221
- * Generate and write docker-compose.infra.yaml
222
- */
223
- export async function generateInfraCompose(
224
- targetDir: string,
225
- selectedServices: string[]
226
- ): Promise<void> {
227
- logInfo('Generating docker-compose.infra.yaml...');
228
-
229
- // Build compose content from service chunks
230
- const infraContent = buildInfraCompose(selectedServices);
231
-
232
- // Write infrastructure compose file
233
- const infraPath = path.join(targetDir, 'docker-compose.infra.yaml');
234
- await writeFile(infraPath, infraContent);
235
- logSuccess('Generated docker-compose.infra.yaml');
236
- }
237
-
238
- /**
239
- * Write app docker-compose.yaml
240
- */
241
- export async function writeAppCompose(targetDir: string): Promise<void> {
242
- const appPath = path.join(targetDir, 'docker-compose.yaml');
243
- await writeFile(appPath, APP_COMPOSE_TEMPLATE);
244
- logSuccess('Generated docker-compose.yaml');
245
- }
1
+ import { APP_COMPOSE_TEMPLATE, APP_NGINX_SERVICE_TEMPLATE } from './templates.js';
2
+ import { writeFile, logInfo, logSuccess } from './utils.js';
3
+ import { multiselect } from '@clack/prompts';
4
+ import { handleCancel, spinner } from './ui.js';
5
+ import path from 'path';
6
+ import type { InfraService } from './types/domain.js';
7
+
8
+ const INFRA_SERVICES: InfraService[] = [
9
+ { name: 'MongoDB (Database)', value: 'mongo', checked: true },
10
+ { name: 'Kafka (Message Queue)', value: 'kafka', checked: true },
11
+ { name: 'Kafka UI (Dashboard)', value: 'kafka-ui', checked: true },
12
+ { name: 'Redis (Cache)', value: 'redis', checked: true },
13
+ { name: 'Nginx (Reverse Proxy)', value: 'nginx', checked: false },
14
+ { name: 'Loki (Log Aggregation)', value: 'loki', checked: false },
15
+ { name: 'Grafana (Observability Dashboard)', value: 'grafana', checked: false },
16
+ ];
17
+
18
+ /**
19
+ * Prompts user to select which infrastructure services to deploy.
20
+ *
21
+ * @returns Array of selected service IDs (e.g., ['mongo', 'kafka', 'redis'])
22
+ * @throws Error if no services are selected
23
+ */
24
+ export async function promptInfraServices(): Promise<string[]> {
25
+ return promptInfraServicesWithBasePath({ allowNginx: true });
26
+ }
27
+
28
+ /**
29
+ * Prompts infrastructure services with optional nginx availability.
30
+ * If nginx is disabled, it is removed from choices and from result safety-check.
31
+ */
32
+ export async function promptInfraServicesWithBasePath(options: {
33
+ allowNginx: boolean;
34
+ }): Promise<string[]> {
35
+ const choices = options.allowNginx
36
+ ? INFRA_SERVICES
37
+ : INFRA_SERVICES.filter(service => service.value !== 'nginx');
38
+
39
+ const message = options.allowNginx
40
+ ? 'Select infrastructure services to run (Space to select, Enter to confirm):'
41
+ : 'Select infrastructure services to run (Space to select, Enter to confirm) — nginx disabled:';
42
+
43
+ const selected = await multiselect({
44
+ message,
45
+ options: choices.map(s => ({
46
+ value: s.value,
47
+ label: s.name,
48
+ hint: s.checked ? 'recommended' : undefined,
49
+ })),
50
+ initialValues: choices.filter(s => s.checked).map(s => s.value),
51
+ required: true,
52
+ withGuide: true,
53
+ });
54
+
55
+ handleCancel(selected);
56
+ const result = selected as string[];
57
+
58
+ if (options.allowNginx) {
59
+ return result;
60
+ }
61
+
62
+ return result.filter(service => service !== 'nginx');
63
+ }
64
+
65
+
66
+
67
+ /**
68
+ * Service chunk definitions - each service as a complete block
69
+ */
70
+ const SERVICE_CHUNKS: Record<string, string> = {
71
+ 'mongo': ` mongo:
72
+ image: mongo:7.0
73
+ container_name: mongo
74
+ command: [ "--replSet", "rs0", "--bind_ip_all", "--port", "27017" ]
75
+ ports:
76
+ - 27017:27017
77
+ healthcheck:
78
+ test: echo "try { rs.status() } catch (err) { rs.initiate({_id:'rs0',members:[{_id:0,host:'mongo:27017'}]}) }" | mongosh --port 27017 --quiet
79
+ interval: 5s
80
+ timeout: 30s
81
+ start_period: 0s
82
+ start_interval: 1s
83
+ retries: 30
84
+ volumes:
85
+ - "mongo_data:/data/db"
86
+ - "mongo_config:/data/configdb"`,
87
+
88
+ 'kafka': ` kafka:
89
+ image: apache/kafka-native
90
+ container_name: kafka
91
+ ports:
92
+ - "9092:9092"
93
+ environment:
94
+ # Configure listeners for both docker and host communication
95
+ KAFKA_LISTENERS: CONTROLLER://localhost:9091,HOST://0.0.0.0:9092,DOCKER://0.0.0.0:9093
96
+ KAFKA_ADVERTISED_LISTENERS: HOST://kafka:9092,DOCKER://kafka:9093
97
+ KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,DOCKER:PLAINTEXT,HOST:PLAINTEXT
98
+
99
+ # Settings required for KRaft mode
100
+ KAFKA_NODE_ID: 1
101
+ KAFKA_PROCESS_ROLES: broker,controller
102
+ KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
103
+ KAFKA_CONTROLLER_QUORUM_VOTERS: 1@localhost:9091
104
+
105
+ # Listener to use for broker-to-broker communication
106
+ KAFKA_INTER_BROKER_LISTENER_NAME: DOCKER
107
+
108
+ # Required for a single node cluster
109
+ KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
110
+
111
+ # Disable auto-topic creation - API server will create topics with correct partitions
112
+ KAFKA_AUTO_CREATE_TOPICS_ENABLE: "false"
113
+ volumes:
114
+ - "kafka_data:/var/lib/kafka/data"`,
115
+
116
+ 'kafka-ui': ` kafka-ui:
117
+ image: kafbat/kafka-ui:main
118
+ container_name: kafka-ui
119
+ ports:
120
+ - 8080:8080
121
+ environment:
122
+ DYNAMIC_CONFIG_ENABLED: "true"
123
+ KAFKA_CLUSTERS_0_NAME: local
124
+ KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka:9093
125
+ depends_on:
126
+ - kafka`,
127
+
128
+ 'redis': ` redis:
129
+ image: redis:7-alpine
130
+ container_name: redis
131
+ ports:
132
+ - "6379:6379"
133
+ command: redis-server --appendonly yes
134
+ volumes:
135
+ - "redis_data:/data"
136
+ healthcheck:
137
+ test: [ "CMD", "redis-cli", "ping" ]
138
+ interval: 5s
139
+ timeout: 3s
140
+ retries: 5`,
141
+
142
+ 'loki': ` loki:
143
+ image: grafana/loki:2.9.0
144
+ container_name: loki
145
+ ports:
146
+ - "3100:3100"
147
+ command: -config.file=/etc/loki/local-config.yaml
148
+ volumes:
149
+ - "loki_data:/loki"
150
+ healthcheck:
151
+ test: [ "CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:3100/ready || exit 1" ]
152
+ interval: 10s
153
+ timeout: 5s
154
+ retries: 5`,
155
+
156
+ 'grafana': ` grafana:
157
+ image: grafana/grafana:10.2.0
158
+ container_name: grafana
159
+ ports:
160
+ - "3001:3000"
161
+ environment:
162
+ - GF_PATHS_PROVISIONING=/etc/grafana/provisioning
163
+ - GF_AUTH_ANONYMOUS_ENABLED=true
164
+ - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
165
+ - GF_SECURITY_ADMIN_PASSWORD=admin
166
+ volumes:
167
+ - "grafana_data:/var/lib/grafana"
168
+ depends_on:
169
+ loki:
170
+ condition: service_healthy`,
171
+
172
+ 'nginx': ` nginx:
173
+ image: nginx:alpine
174
+ container_name: nginx
175
+ ports:
176
+ - "80:80"
177
+ volumes:
178
+ - "./nginx.conf:/etc/nginx/conf.d/default.conf:ro"
179
+ restart: unless-stopped`,
180
+ };
181
+
182
+ /**
183
+ * Service-to-volumes mapping
184
+ */
185
+ const SERVICE_VOLUMES: Record<string, string[]> = {
186
+ 'mongo': ['mongo_data', 'mongo_config'],
187
+ 'kafka': ['kafka_data'],
188
+ 'kafka-ui': [],
189
+ 'redis': ['redis_data'],
190
+ 'nginx': [],
191
+ 'loki': ['loki_data'],
192
+ 'grafana': ['grafana_data'],
193
+ };
194
+
195
+ /**
196
+ * Build docker-compose content from selected services
197
+ */
198
+ function buildInfraCompose(selectedServices: string[]): string {
199
+ // Header
200
+ const header = `# ============================================
201
+ # SimpleNS Infrastructure Services
202
+ # All services use Docker service names for container-to-container communication.
203
+ # This ensures cross-platform compatibility (Windows, Linux, macOS).
204
+ # ============================================
205
+
206
+ services:
207
+ # ============================================
208
+ # Infrastructure Services
209
+ # ============================================`;
210
+
211
+ // Assemble selected service chunks
212
+ const serviceBlocks: string[] = [];
213
+ for (const service of selectedServices) {
214
+ if (SERVICE_CHUNKS[service]) {
215
+ serviceBlocks.push(SERVICE_CHUNKS[service]);
216
+ }
217
+ }
218
+
219
+ // Collect volumes for selected services
220
+ const volumeSet = new Set<string>();
221
+ for (const service of selectedServices) {
222
+ const volumes = SERVICE_VOLUMES[service] || [];
223
+ volumes.forEach(v => volumeSet.add(v));
224
+ }
225
+
226
+ // Build volumes section
227
+ const volumeLines: string[] = ['', 'volumes:'];
228
+ for (const volume of Array.from(volumeSet).sort()) {
229
+ volumeLines.push(` ${volume}:`);
230
+ }
231
+
232
+ // Build networks section with custom default network name
233
+ const networkLines: string[] = ['', 'networks:', ' default:', ' name: simplens'];
234
+
235
+ // Combine all parts
236
+ return [
237
+ header,
238
+ serviceBlocks.join('\n\n'),
239
+ volumeLines.join('\n'),
240
+ networkLines.join('\n'),
241
+ ].join('\n');
242
+ }
243
+
244
+
245
+
246
+ /**
247
+ * Generate and write docker-compose.infra.yaml
248
+ */
249
+ export async function generateInfraCompose(
250
+ targetDir: string,
251
+ selectedServices: string[]
252
+ ): Promise<void> {
253
+ const s = spinner();
254
+ s.start('Generating docker-compose.infra.yaml...');
255
+
256
+ // Build compose content from service chunks
257
+ const infraContent = buildInfraCompose(selectedServices);
258
+
259
+ // Write infrastructure compose file
260
+ const infraPath = path.join(targetDir, 'docker-compose.infra.yaml');
261
+ await writeFile(infraPath, infraContent);
262
+ s.stop('Generated docker-compose.infra.yaml');
263
+ }
264
+
265
+ /**
266
+ * Build app docker-compose content.
267
+ * Optionally inject nginx reverse-proxy service before the volumes section.
268
+ */
269
+ export function buildAppComposeContent(includeNginx: boolean): string {
270
+ if (!includeNginx) {
271
+ return APP_COMPOSE_TEMPLATE;
272
+ }
273
+
274
+ const marker = '\nvolumes:';
275
+ if (!APP_COMPOSE_TEMPLATE.includes(marker)) {
276
+ return APP_COMPOSE_TEMPLATE;
277
+ }
278
+
279
+ return APP_COMPOSE_TEMPLATE.replace(marker, `\n${APP_NGINX_SERVICE_TEMPLATE}\n${marker}`);
280
+ }
281
+
282
+ /**
283
+ * Write app docker-compose.yaml
284
+ */
285
+ export async function writeAppCompose(
286
+ targetDir: string,
287
+ options: { includeNginx?: boolean } = {}
288
+ ): Promise<void> {
289
+ const s = spinner();
290
+ s.start('Generating docker-compose.yaml...');
291
+ const appPath = path.join(targetDir, 'docker-compose.yaml');
292
+ const appContent = buildAppComposeContent(options.includeNginx === true);
293
+ await writeFile(appPath, appContent);
294
+ s.stop('Generated docker-compose.yaml');
295
+ }
296
+
297
+ /**
298
+ * Generate nginx.conf based on basePath configuration
299
+ */
300
+ export async function generateNginxConfig(
301
+ targetDir: string,
302
+ basePath: string
303
+ ): Promise<void> {
304
+ const s = spinner();
305
+ s.start('Generating nginx.conf...');
306
+
307
+ // Normalize basePath (remove leading/trailing slashes for template)
308
+ const normalizedPath = basePath.trim().replace(/^\/|\/$/g, '');
309
+ const hasBasePath = normalizedPath.length > 0;
310
+
311
+ // Template for nginx.conf
312
+ const nginxTemplate = `server {
313
+ listen 80;
314
+ server_name localhost;
315
+
316
+ location /api/notification/ {
317
+ proxy_pass http://api:3000;
318
+ proxy_http_version 1.1;
319
+
320
+ proxy_set_header Host $host;
321
+ proxy_set_header X-Real-IP $remote_addr;
322
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
323
+ proxy_set_header X-Forwarded-Proto $scheme;
324
+ }
325
+
326
+ location = /runtime-config.js {
327
+ proxy_pass http://dashboard:3002/runtime-config.js;
328
+ proxy_http_version 1.1;
329
+
330
+ proxy_set_header Host $host;
331
+ proxy_set_header X-Real-IP $remote_addr;
332
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
333
+ proxy_set_header X-Forwarded-Proto $scheme;
334
+
335
+ # optional: prevent caching if config is dynamic
336
+ add_header Cache-Control "no-store";
337
+ }
338
+
339
+ location ^~ /_next/ {
340
+ proxy_pass http://dashboard:3002/_next/;
341
+ proxy_http_version 1.1;
342
+
343
+ proxy_set_header Host $host;
344
+ proxy_set_header X-Real-IP $remote_addr;
345
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
346
+ proxy_set_header X-Forwarded-Proto $scheme;
347
+
348
+ expires 1y;
349
+ add_header Cache-Control "public, immutable";
350
+ }
351
+ ${hasBasePath ? `
352
+ location ^~ /${normalizedPath}/_next/ {
353
+ proxy_pass http://dashboard:3002/_next/;
354
+ proxy_http_version 1.1;
355
+
356
+ proxy_set_header Host $host;
357
+ proxy_set_header X-Real-IP $remote_addr;
358
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
359
+ proxy_set_header X-Forwarded-Proto $scheme;
360
+
361
+ expires 1y;
362
+ add_header Cache-Control "public, immutable";
363
+ }
364
+ ` : ''}
365
+ location ~* \\.(png|jpg|jpeg|gif|ico|svg|webp|woff|woff2|ttf|eot)$ {
366
+ proxy_pass http://dashboard:3002;
367
+ proxy_http_version 1.1;
368
+
369
+ proxy_set_header Host $host;
370
+ proxy_set_header X-Real-IP $remote_addr;
371
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
372
+ proxy_set_header X-Forwarded-Proto $scheme;
373
+
374
+ expires 1y;
375
+ add_header Cache-Control "public, max-age=31536000";
376
+ }
377
+ ${hasBasePath ? `
378
+ location ^~ /${normalizedPath} {
379
+ proxy_pass http://dashboard:3002;
380
+ proxy_http_version 1.1;
381
+
382
+ proxy_set_header Host $host;
383
+ proxy_set_header X-Real-IP $remote_addr;
384
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
385
+ proxy_set_header X-Forwarded-Proto $scheme;
386
+ }
387
+ ` : `
388
+ location / {
389
+ proxy_pass http://dashboard:3002;
390
+ proxy_http_version 1.1;
391
+
392
+ proxy_set_header Host $host;
393
+ proxy_set_header X-Real-IP $remote_addr;
394
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
395
+ proxy_set_header X-Forwarded-Proto $scheme;
396
+ }
397
+ `}
398
+ }
399
+ `;
400
+
401
+ const nginxPath = path.join(targetDir, 'nginx.conf');
402
+ await writeFile(nginxPath, nginxTemplate);
403
+ s.stop(`Generated nginx.conf${hasBasePath ? ` with base path: /${normalizedPath}` : ' (root path)'}`);
404
+ }