@simplens/onboard 1.0.0 → 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 +258 -141
  21. package/dist/env-config.js.map +1 -1
  22. package/dist/index.js +341 -71
  23. package/dist/index.js.map +1 -1
  24. package/dist/infra.d.ts +17 -14
  25. package/dist/infra.d.ts.map +1 -1
  26. package/dist/infra.js +265 -176
  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 +3 -2
  37. package/dist/templates.d.ts.map +1 -1
  38. package/dist/templates.js +203 -198
  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 -320
  68. package/src/index.ts +534 -203
  69. package/src/infra.ts +404 -300
  70. package/src/plugins.ts +221 -190
  71. package/src/services.ts +175 -190
  72. package/src/templates.ts +209 -203
  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 -20
package/src/infra.ts CHANGED
@@ -1,300 +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
- * Get infrastructure host based on OS
51
- */
52
- export function getInfraHost(): string {
53
- // const os = detectOS();
54
-
55
- // if (os === 'linux') {
56
- // logWarning('Linux detected: host.docker.internal does not work by default.');
57
-
58
- // const answer = await inquirer.prompt<{ hostChoice: string; customHost?: string }>([
59
- // {
60
- // type: 'list',
61
- // name: 'hostChoice',
62
- // message: 'Select host configuration:',
63
- // choices: [
64
- // { name: 'Use Docker bridge IP (172.17.0.1)', value: '172.17.0.1' },
65
- // { name: 'Enter custom IP/hostname', value: 'custom' },
66
- // ],
67
- // },
68
- // {
69
- // type: 'input',
70
- // name: 'customHost',
71
- // message: 'Enter your machine IP or hostname:',
72
- // when: (answers) => answers.hostChoice === 'custom',
73
- // validate: (input: string) => {
74
- // if (!input || input.trim().length === 0) {
75
- // return 'Please enter a valid IP or hostname';
76
- // }
77
- // return true;
78
- // },
79
- // },
80
- // ]);
81
-
82
- // return answer.hostChoice === 'custom' ? answer.customHost! : answer.hostChoice;
83
- // }
84
-
85
- // For Windows, linux and macOS, use host.docker.internal
86
- return 'host.docker.internal';
87
- }
88
-
89
- /**
90
- * Service chunk definitions - each service as a complete block
91
- */
92
- const SERVICE_CHUNKS: Record<string, string> = {
93
- 'mongo': ` mongo:
94
- image: mongo:7.0
95
- container_name: mongo
96
- command: [ "--replSet", "rs0", "--bind_ip_all", "--port", "27017" ]
97
- ports:
98
- - 27017:27017
99
- extra_hosts:
100
- - "host.docker.internal:host-gateway"
101
- healthcheck:
102
- test: echo "try { rs.status() } catch (err) { rs.initiate({_id:'rs0',members:[{_id:0,host:'{{INFRA_HOST}}:27017'}]}) }" | mongosh --port 27017 --quiet
103
- interval: 5s
104
- timeout: 30s
105
- start_period: 0s
106
- start_interval: 1s
107
- retries: 30
108
- volumes:
109
- - "mongo_data:/data/db"
110
- - "mongo_config:/data/configdb"`,
111
-
112
- 'kafka': ` kafka:
113
- image: apache/kafka-native
114
- container_name: kafka
115
- ports:
116
- - "9092:9092"
117
- environment:
118
- # Configure listeners for both docker and host communication
119
- KAFKA_LISTENERS: CONTROLLER://localhost:9091,HOST://0.0.0.0:9092,DOCKER://0.0.0.0:9093
120
- KAFKA_ADVERTISED_LISTENERS: HOST://{{INFRA_HOST}}:9092,DOCKER://kafka:9093
121
- KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,DOCKER:PLAINTEXT,HOST:PLAINTEXT
122
-
123
- # Settings required for KRaft mode
124
- KAFKA_NODE_ID: 1
125
- KAFKA_PROCESS_ROLES: broker,controller
126
- KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
127
- KAFKA_CONTROLLER_QUORUM_VOTERS: 1@localhost:9091
128
-
129
- # Listener to use for broker-to-broker communication
130
- KAFKA_INTER_BROKER_LISTENER_NAME: DOCKER
131
-
132
- # Required for a single node cluster
133
- KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
134
-
135
- # Disable auto-topic creation - API server will create topics with correct partitions
136
- KAFKA_AUTO_CREATE_TOPICS_ENABLE: "false"
137
- volumes:
138
- - "kafka_data:/var/lib/kafka/data"`,
139
-
140
- 'kafka-ui': ` kafka-ui:
141
- image: kafbat/kafka-ui:main
142
- container_name: kafka-ui
143
- ports:
144
- - 8080:8080
145
- environment:
146
- DYNAMIC_CONFIG_ENABLED: "true"
147
- KAFKA_CLUSTERS_0_NAME: local
148
- KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka:9093
149
- depends_on:
150
- - kafka`,
151
-
152
- 'redis': ` redis:
153
- image: redis:7-alpine
154
- container_name: redis
155
- ports:
156
- - "6379:6379"
157
- command: redis-server --appendonly yes
158
- volumes:
159
- - "redis_data:/data"
160
- healthcheck:
161
- test: [ "CMD", "redis-cli", "ping" ]
162
- interval: 5s
163
- timeout: 3s
164
- retries: 5`,
165
-
166
- 'loki': ` loki:
167
- image: grafana/loki:2.9.0
168
- container_name: loki
169
- ports:
170
- - "3100:3100"
171
- command: -config.file=/etc/loki/local-config.yaml
172
- volumes:
173
- - "loki_data:/loki"
174
- healthcheck:
175
- test: [ "CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:3100/ready || exit 1" ]
176
- interval: 10s
177
- timeout: 5s
178
- retries: 5`,
179
-
180
- 'grafana': ` grafana:
181
- image: grafana/grafana:10.2.0
182
- container_name: grafana
183
- ports:
184
- - "3001:3000"
185
- environment:
186
- - GF_PATHS_PROVISIONING=/etc/grafana/provisioning
187
- - GF_AUTH_ANONYMOUS_ENABLED=true
188
- - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
189
- - GF_SECURITY_ADMIN_PASSWORD=admin
190
- volumes:
191
- - "grafana_data:/var/lib/grafana"
192
- depends_on:
193
- loki:
194
- condition: service_healthy`,
195
- };
196
-
197
- /**
198
- * Service-to-volumes mapping
199
- */
200
- const SERVICE_VOLUMES: Record<string, string[]> = {
201
- 'mongo': ['mongo_data', 'mongo_config'],
202
- 'kafka': ['kafka_data'],
203
- 'kafka-ui': [],
204
- 'redis': ['redis_data'],
205
- 'loki': ['loki_data'],
206
- 'grafana': ['grafana_data'],
207
- };
208
-
209
- /**
210
- * Build docker-compose content from selected services
211
- */
212
- function buildInfraCompose(selectedServices: string[]): string {
213
- // Header
214
- const header = `# ============================================
215
- # INFRA_HOST: Set this in .env to your machine's IP/hostname when running
216
- # infrastructure on a separate system from application services.
217
- # Default: host.docker.internal (for same-system deployment)
218
- # ============================================
219
-
220
- services:
221
- # ============================================
222
- # Infrastructure Services
223
- # ============================================`;
224
-
225
- // Assemble selected service chunks
226
- const serviceBlocks: string[] = [];
227
- for (const service of selectedServices) {
228
- if (SERVICE_CHUNKS[service]) {
229
- serviceBlocks.push(SERVICE_CHUNKS[service]);
230
- }
231
- }
232
-
233
- // Collect volumes for selected services
234
- const volumeSet = new Set<string>();
235
- for (const service of selectedServices) {
236
- const volumes = SERVICE_VOLUMES[service] || [];
237
- volumes.forEach(v => volumeSet.add(v));
238
- }
239
-
240
- // Build volumes section
241
- const volumeLines: string[] = ['', 'volumes:'];
242
- for (const volume of Array.from(volumeSet).sort()) {
243
- volumeLines.push(` ${volume}:`);
244
- }
245
-
246
- // Build networks section with custom default network name
247
- const networkLines: string[] = ['', 'networks:', ' default:', ' name: simplens'];
248
-
249
- // Combine all parts
250
- return [
251
- header,
252
- serviceBlocks.join('\n\n'),
253
- volumeLines.join('\n'),
254
- networkLines.join('\n'),
255
- ].join('\n');
256
- }
257
-
258
- /**
259
- * Replace INFRA_HOST placeholder in template
260
- */
261
- export function replaceInfraHost(template: string, infraHost: string): string {
262
- return template.replace(/\{\{INFRA_HOST\}\}/g, infraHost);
263
- }
264
-
265
- /**
266
- * Generate and write docker-compose.infra.yaml
267
- */
268
- export async function generateInfraCompose(
269
- targetDir: string,
270
- selectedServices: string[]
271
- ): Promise<string> {
272
- logInfo('Generating docker-compose.infra.yaml...');
273
-
274
- // Get infrastructure host
275
- const infraHost = getInfraHost();
276
- logSuccess(`Using infrastructure host: ${infraHost}`);
277
-
278
- // Build compose content from service chunks
279
- let infraContent = buildInfraCompose(selectedServices);
280
-
281
- // Replace host placeholder
282
- infraContent = replaceInfraHost(infraContent, infraHost);
283
-
284
- // Write infrastructure compose file
285
- const infraPath = path.join(targetDir, 'docker-compose.infra.yaml');
286
- await writeFile(infraPath, infraContent);
287
- logSuccess('Generated docker-compose.infra.yaml');
288
-
289
- // Return infraHost for env configuration
290
- return infraHost;
291
- }
292
-
293
- /**
294
- * Write app docker-compose.yaml
295
- */
296
- export async function writeAppCompose(targetDir: string): Promise<void> {
297
- const appPath = path.join(targetDir, 'docker-compose.yaml');
298
- await writeFile(appPath, APP_COMPOSE_TEMPLATE);
299
- logSuccess('Generated docker-compose.yaml');
300
- }
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
+ }