suthep 0.1.0-beta.1

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 (65) hide show
  1. package/.editorconfig +17 -0
  2. package/.prettierignore +6 -0
  3. package/.prettierrc +7 -0
  4. package/.vscode/settings.json +19 -0
  5. package/LICENSE +21 -0
  6. package/README.md +217 -0
  7. package/dist/commands/deploy.js +318 -0
  8. package/dist/commands/deploy.js.map +1 -0
  9. package/dist/commands/init.js +188 -0
  10. package/dist/commands/init.js.map +1 -0
  11. package/dist/commands/setup.js +90 -0
  12. package/dist/commands/setup.js.map +1 -0
  13. package/dist/index.js +19 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/utils/certbot.js +64 -0
  16. package/dist/utils/certbot.js.map +1 -0
  17. package/dist/utils/config-loader.js +95 -0
  18. package/dist/utils/config-loader.js.map +1 -0
  19. package/dist/utils/deployment.js +76 -0
  20. package/dist/utils/deployment.js.map +1 -0
  21. package/dist/utils/docker.js +393 -0
  22. package/dist/utils/docker.js.map +1 -0
  23. package/dist/utils/nginx.js +303 -0
  24. package/dist/utils/nginx.js.map +1 -0
  25. package/docs/README.md +95 -0
  26. package/docs/TRANSLATIONS.md +211 -0
  27. package/docs/en/README.md +76 -0
  28. package/docs/en/api-reference.md +545 -0
  29. package/docs/en/architecture.md +369 -0
  30. package/docs/en/commands.md +273 -0
  31. package/docs/en/configuration.md +347 -0
  32. package/docs/en/developer-guide.md +588 -0
  33. package/docs/en/docker-ports-config.md +333 -0
  34. package/docs/en/examples.md +537 -0
  35. package/docs/en/getting-started.md +202 -0
  36. package/docs/en/port-binding.md +268 -0
  37. package/docs/en/troubleshooting.md +441 -0
  38. package/docs/th/README.md +64 -0
  39. package/docs/th/commands.md +202 -0
  40. package/docs/th/configuration.md +325 -0
  41. package/docs/th/getting-started.md +203 -0
  42. package/example/README.md +85 -0
  43. package/example/docker-compose.yml +76 -0
  44. package/example/docker-ports-example.yml +81 -0
  45. package/example/muacle.yml +47 -0
  46. package/example/port-binding-example.yml +45 -0
  47. package/example/suthep.yml +46 -0
  48. package/example/suthep=1.yml +46 -0
  49. package/package.json +45 -0
  50. package/src/commands/deploy.ts +405 -0
  51. package/src/commands/init.ts +214 -0
  52. package/src/commands/setup.ts +112 -0
  53. package/src/index.ts +42 -0
  54. package/src/types/config.ts +52 -0
  55. package/src/utils/certbot.ts +144 -0
  56. package/src/utils/config-loader.ts +121 -0
  57. package/src/utils/deployment.ts +157 -0
  58. package/src/utils/docker.ts +755 -0
  59. package/src/utils/nginx.ts +326 -0
  60. package/suthep-0.1.1.tgz +0 -0
  61. package/suthep.example.yml +98 -0
  62. package/test +0 -0
  63. package/todo.md +6 -0
  64. package/tsconfig.json +26 -0
  65. package/vite.config.ts +46 -0
@@ -0,0 +1,405 @@
1
+ import chalk from 'chalk'
2
+ import fs from 'fs-extra'
3
+ import type { ServiceConfig } from '../types/config'
4
+ import { certificateExists, requestCertificate } from '../utils/certbot'
5
+ import { loadConfig } from '../utils/config-loader'
6
+ import { deployService, performHealthCheck } from '../utils/deployment'
7
+ import {
8
+ cleanupTempContainer,
9
+ startDockerContainer,
10
+ startDockerContainerZeroDowntime,
11
+ swapContainersForZeroDowntime,
12
+ type ZeroDowntimeContainerInfo,
13
+ } from '../utils/docker'
14
+ import {
15
+ enableSite,
16
+ generateMultiServiceNginxConfig,
17
+ generateNginxConfig,
18
+ reloadNginx,
19
+ writeNginxConfig,
20
+ } from '../utils/nginx'
21
+
22
+ interface DeployOptions {
23
+ file: string
24
+ https: boolean
25
+ nginx: boolean
26
+ }
27
+
28
+ export async function deployCommand(options: DeployOptions): Promise<void> {
29
+ console.log(chalk.blue.bold('\nšŸš€ Deploying Services\n'))
30
+
31
+ try {
32
+ // Load configuration
33
+ if (!(await fs.pathExists(options.file))) {
34
+ throw new Error(`Configuration file not found: ${options.file}`)
35
+ }
36
+
37
+ console.log(chalk.cyan(`šŸ“„ Loading configuration from ${options.file}...`))
38
+ const config = await loadConfig(options.file)
39
+
40
+ console.log(chalk.green(`āœ… Configuration loaded for project: ${config.project.name}`))
41
+ console.log(chalk.dim(` Services: ${config.services.map((s) => s.name).join(', ')}\n`))
42
+
43
+ // Group services by domain
44
+ const domainToServices = new Map<string, ServiceConfig[]>()
45
+ const allDomains = new Set<string>()
46
+
47
+ for (const service of config.services) {
48
+ for (const domain of service.domains) {
49
+ allDomains.add(domain)
50
+ if (!domainToServices.has(domain)) {
51
+ domainToServices.set(domain, [])
52
+ }
53
+ domainToServices.get(domain)!.push(service)
54
+ }
55
+ }
56
+
57
+ // Deploy each service (Docker, health checks, etc.)
58
+ // Track zero-downtime info for services that need it
59
+ const serviceTempInfo = new Map<string, ZeroDowntimeContainerInfo | null>()
60
+
61
+ for (const service of config.services) {
62
+ console.log(chalk.cyan(`\nšŸ“¦ Deploying service: ${service.name}`))
63
+
64
+ try {
65
+ // Start Docker container if configured
66
+ if (service.docker) {
67
+ console.log(chalk.dim(' 🐳 Managing Docker container...'))
68
+
69
+ // Use zero-downtime deployment if strategy is blue-green or rolling
70
+ if (
71
+ config.deployment.strategy === 'blue-green' ||
72
+ config.deployment.strategy === 'rolling'
73
+ ) {
74
+ const tempInfo = await startDockerContainerZeroDowntime(service)
75
+ serviceTempInfo.set(service.name, tempInfo)
76
+
77
+ if (tempInfo && tempInfo.oldContainerExists) {
78
+ console.log(
79
+ chalk.cyan(
80
+ ` šŸ”„ Zero-downtime deployment: new container on port ${tempInfo.tempPort}`
81
+ )
82
+ )
83
+ }
84
+ } else {
85
+ // Fallback to regular deployment
86
+ await startDockerContainer(service)
87
+ serviceTempInfo.set(service.name, null)
88
+ }
89
+ } else {
90
+ serviceTempInfo.set(service.name, null)
91
+ }
92
+
93
+ // Deploy the service (with temp info for zero-downtime)
94
+ const tempInfo = serviceTempInfo.get(service.name) || null
95
+ await deployService(service, config.deployment, tempInfo)
96
+
97
+ // Perform health check on appropriate port
98
+ if (service.healthCheck) {
99
+ console.log(chalk.dim(` šŸ„ Performing health check...`))
100
+ const checkPort =
101
+ tempInfo && tempInfo.oldContainerExists ? tempInfo.tempPort : service.port
102
+ const isHealthy = await performHealthCheck(
103
+ `http://localhost:${checkPort}${service.healthCheck.path}`,
104
+ config.deployment.healthCheckTimeout
105
+ )
106
+
107
+ if (isHealthy) {
108
+ console.log(chalk.green(` āœ… Service ${service.name} is healthy`))
109
+ } else {
110
+ throw new Error(`Health check failed for service ${service.name}`)
111
+ }
112
+ }
113
+
114
+ console.log(chalk.green.bold(`✨ Service ${service.name} deployed successfully!`))
115
+ } catch (error) {
116
+ console.error(
117
+ chalk.red(`\nāŒ Failed to deploy service ${service.name}:`),
118
+ error instanceof Error ? error.message : error
119
+ )
120
+ throw error
121
+ }
122
+ }
123
+
124
+ // Helper function to generate nginx configs with optional port overrides
125
+ const generateNginxConfigsForDomain = (
126
+ domain: string,
127
+ withHttps: boolean,
128
+ portOverrides?: Map<string, number>
129
+ ): string => {
130
+ const servicesForDomain = domainToServices.get(domain)!
131
+ if (servicesForDomain.length === 1) {
132
+ const service = servicesForDomain[0]
133
+ const portOverride = portOverrides?.get(service.name)
134
+ return generateNginxConfig(service, withHttps, portOverride)
135
+ } else {
136
+ return generateMultiServiceNginxConfig(servicesForDomain, domain, withHttps, portOverrides)
137
+ }
138
+ }
139
+
140
+ // Check if we need zero-downtime nginx updates (any service has temp container)
141
+ const needsZeroDowntimeNginx = Array.from(serviceTempInfo.values()).some(
142
+ (info) => info !== null && info.oldContainerExists
143
+ )
144
+
145
+ // Configure Nginx per domain
146
+ if (options.nginx) {
147
+ // If zero-downtime, first update nginx to point to temp ports
148
+ if (needsZeroDowntimeNginx) {
149
+ console.log(chalk.cyan(`\nāš™ļø Updating Nginx for zero-downtime deployment...`))
150
+
151
+ // Build port override map for temp ports
152
+ const tempPortOverrides = new Map<string, number>()
153
+ for (const service of config.services) {
154
+ const tempInfo = serviceTempInfo.get(service.name)
155
+ if (tempInfo && tempInfo.oldContainerExists) {
156
+ tempPortOverrides.set(service.name, tempInfo.tempPort)
157
+ }
158
+ }
159
+
160
+ for (const domain of allDomains) {
161
+ const configName = domain.replace(/\./g, '_')
162
+ try {
163
+ const nginxConfigContent = generateNginxConfigsForDomain(
164
+ domain,
165
+ false,
166
+ tempPortOverrides
167
+ )
168
+ await writeNginxConfig(configName, config.nginx.configPath, nginxConfigContent)
169
+ await enableSite(configName, config.nginx.configPath)
170
+ console.log(chalk.green(` āœ… Nginx updated for ${domain} (temporary ports)`))
171
+ } catch (error) {
172
+ console.error(
173
+ chalk.red(` āŒ Failed to update Nginx for ${domain}:`),
174
+ error instanceof Error ? error.message : error
175
+ )
176
+ throw error
177
+ }
178
+ }
179
+
180
+ // Reload nginx to switch to temp ports (graceful reload, no connection drops)
181
+ console.log(chalk.cyan(`\nšŸ”„ Reloading Nginx to switch to new containers...`))
182
+ await reloadNginx(config.nginx.reloadCommand)
183
+ console.log(chalk.green(` āœ… Nginx reloaded, traffic now routed to new containers`))
184
+
185
+ // Now swap containers (stop old, promote new)
186
+ console.log(chalk.cyan(`\nšŸ”„ Swapping containers for zero-downtime...`))
187
+ for (const service of config.services) {
188
+ const tempInfo = serviceTempInfo.get(service.name)
189
+ if (tempInfo && tempInfo.oldContainerExists && service.docker) {
190
+ try {
191
+ await swapContainersForZeroDowntime(service, tempInfo)
192
+ console.log(chalk.green(` āœ… Container swapped for ${service.name}`))
193
+ } catch (error) {
194
+ console.error(
195
+ chalk.red(` āŒ Failed to swap container for ${service.name}:`),
196
+ error instanceof Error ? error.message : error
197
+ )
198
+ throw error
199
+ }
200
+ }
201
+ }
202
+
203
+ // Update nginx back to original ports (before stopping temp containers)
204
+ console.log(chalk.cyan(`\nāš™ļø Updating Nginx back to production ports...`))
205
+ for (const domain of allDomains) {
206
+ const configName = domain.replace(/\./g, '_')
207
+ try {
208
+ const nginxConfigContent = generateNginxConfigsForDomain(domain, false)
209
+ await writeNginxConfig(configName, config.nginx.configPath, nginxConfigContent)
210
+ await enableSite(configName, config.nginx.configPath)
211
+ console.log(chalk.green(` āœ… Nginx updated for ${domain} (production ports)`))
212
+ } catch (error) {
213
+ console.error(
214
+ chalk.red(` āŒ Failed to update Nginx for ${domain}:`),
215
+ error instanceof Error ? error.message : error
216
+ )
217
+ throw error
218
+ }
219
+ }
220
+
221
+ // Reload nginx to switch to production ports (graceful reload)
222
+ console.log(chalk.cyan(`\nšŸ”„ Reloading Nginx to switch to production ports...`))
223
+ await reloadNginx(config.nginx.reloadCommand)
224
+ console.log(chalk.green(` āœ… Nginx reloaded, traffic now routed to production containers`))
225
+
226
+ // Clean up temp containers (nginx already pointing to production, so safe to remove)
227
+ console.log(chalk.cyan(`\n🧹 Cleaning up temporary containers...`))
228
+ for (const service of config.services) {
229
+ const tempInfo = serviceTempInfo.get(service.name)
230
+ if (tempInfo && tempInfo.oldContainerExists) {
231
+ await cleanupTempContainer(tempInfo.tempContainerName)
232
+ }
233
+ }
234
+ } else {
235
+ // Regular nginx configuration (no zero-downtime needed)
236
+ console.log(chalk.cyan(`\nāš™ļø Configuring Nginx reverse proxy...`))
237
+
238
+ for (const domain of allDomains) {
239
+ const servicesForDomain = domainToServices.get(domain)!
240
+ const configName = domain.replace(/\./g, '_')
241
+
242
+ try {
243
+ // Log domain and services configuration
244
+ if (servicesForDomain.length > 1) {
245
+ console.log(
246
+ chalk.cyan(
247
+ ` šŸ“‹ Configuring ${domain} with ${
248
+ servicesForDomain.length
249
+ } services: ${servicesForDomain.map((s) => s.name).join(', ')}`
250
+ )
251
+ )
252
+ console.log(
253
+ chalk.dim(
254
+ ` All services will share the same nginx config file: ${configName}.conf`
255
+ )
256
+ )
257
+ }
258
+
259
+ // Generate Nginx config
260
+ const nginxConfigContent = generateNginxConfigsForDomain(domain, false)
261
+
262
+ // Check if config file already exists and write/override it
263
+ const wasOverridden = await writeNginxConfig(
264
+ configName,
265
+ config.nginx.configPath,
266
+ nginxConfigContent
267
+ )
268
+
269
+ if (wasOverridden) {
270
+ console.log(
271
+ chalk.yellow(
272
+ ` šŸ”„ Nginx config "${configName}.conf" already exists, deleting and recreating with new configuration...`
273
+ )
274
+ )
275
+ }
276
+
277
+ await enableSite(configName, config.nginx.configPath)
278
+
279
+ console.log(chalk.green(` āœ… Nginx configured for ${domain}`))
280
+ } catch (error) {
281
+ console.error(
282
+ chalk.red(` āŒ Failed to configure Nginx for ${domain}:`),
283
+ error instanceof Error ? error.message : error
284
+ )
285
+ throw error
286
+ }
287
+ }
288
+ }
289
+ }
290
+
291
+ // Setup HTTPS with Certbot (per domain, not per service)
292
+ if (options.https && allDomains.size > 0) {
293
+ console.log(chalk.cyan(`\nšŸ” Setting up HTTPS certificates...`))
294
+
295
+ for (const domain of allDomains) {
296
+ try {
297
+ // Check if certificate already exists
298
+ const exists = await certificateExists(domain)
299
+ if (exists) {
300
+ console.log(
301
+ chalk.green(
302
+ ` āœ… SSL certificate already exists for ${domain}, skipping certificate creation`
303
+ )
304
+ )
305
+ console.log(
306
+ chalk.dim(` Using existing certificate from /etc/letsencrypt/live/${domain}/`)
307
+ )
308
+ } else {
309
+ // Request new certificate
310
+ console.log(chalk.cyan(` šŸ“œ Requesting SSL certificate for ${domain}...`))
311
+ try {
312
+ await requestCertificate(domain, config.certbot.email, config.certbot.staging)
313
+ console.log(chalk.green(` āœ… SSL certificate obtained for ${domain}`))
314
+ } catch (error: any) {
315
+ // Check if error is because certificate already exists (race condition or check missed it)
316
+ const errorMessage = error?.message || String(error) || ''
317
+ if (
318
+ errorMessage.includes('already exists') ||
319
+ errorMessage.includes('Skipping certificate creation')
320
+ ) {
321
+ console.log(
322
+ chalk.green(
323
+ ` āœ… SSL certificate already exists for ${domain} (detected during request), skipping...`
324
+ )
325
+ )
326
+ } else {
327
+ throw error // Re-throw if it's a different error
328
+ }
329
+ }
330
+ }
331
+ } catch (error) {
332
+ console.log(
333
+ chalk.yellow(
334
+ ` āš ļø Failed to obtain SSL for ${domain}: ${
335
+ error instanceof Error ? error.message : error
336
+ }`
337
+ )
338
+ )
339
+ }
340
+ }
341
+
342
+ // Update Nginx configs with HTTPS
343
+ if (options.nginx) {
344
+ console.log(chalk.cyan(`\nšŸ”„ Updating Nginx configs with HTTPS...`))
345
+ for (const domain of allDomains) {
346
+ const configName = domain.replace(/\./g, '_')
347
+
348
+ try {
349
+ const nginxConfigContent = generateNginxConfigsForDomain(domain, true)
350
+ const wasOverridden = await writeNginxConfig(
351
+ configName,
352
+ config.nginx.configPath,
353
+ nginxConfigContent
354
+ )
355
+
356
+ if (wasOverridden) {
357
+ console.log(
358
+ chalk.yellow(
359
+ ` šŸ”„ Nginx config "${configName}.conf" already exists, deleting and recreating with new HTTPS configuration...`
360
+ )
361
+ )
362
+ }
363
+ console.log(chalk.green(` āœ… HTTPS config updated for ${domain}`))
364
+ } catch (error) {
365
+ console.error(
366
+ chalk.red(` āŒ Failed to update HTTPS config for ${domain}:`),
367
+ error instanceof Error ? error.message : error
368
+ )
369
+ throw error
370
+ }
371
+ }
372
+ }
373
+ }
374
+
375
+ // Final reload Nginx after all configurations (only if we didn't already reload for zero-downtime)
376
+ if (options.nginx && !needsZeroDowntimeNginx) {
377
+ console.log(chalk.cyan(`\nšŸ”„ Reloading Nginx...`))
378
+ await reloadNginx(config.nginx.reloadCommand)
379
+ } else if (options.nginx && needsZeroDowntimeNginx) {
380
+ // Final reload after HTTPS update
381
+ console.log(chalk.cyan(`\nšŸ”„ Final Nginx reload with HTTPS...`))
382
+ await reloadNginx(config.nginx.reloadCommand)
383
+ }
384
+
385
+ console.log(chalk.green.bold('\nšŸŽ‰ All services deployed successfully!\n'))
386
+
387
+ // Print service URLs
388
+ console.log(chalk.cyan('šŸ“‹ Service URLs:'))
389
+ for (const service of config.services) {
390
+ for (const domain of service.domains) {
391
+ const protocol = options.https ? 'https' : 'http'
392
+ const servicePath = service.path || '/'
393
+ const fullPath = servicePath === '/' ? '' : servicePath
394
+ console.log(chalk.dim(` ${service.name}: ${protocol}://${domain}${fullPath}`))
395
+ }
396
+ }
397
+ console.log()
398
+ } catch (error) {
399
+ console.error(
400
+ chalk.red('\nāŒ Deployment failed:'),
401
+ error instanceof Error ? error.message : error
402
+ )
403
+ process.exit(1)
404
+ }
405
+ }
@@ -0,0 +1,214 @@
1
+ import chalk from 'chalk'
2
+ import fs from 'fs-extra'
3
+ import inquirer from 'inquirer'
4
+ import type { DeployConfig } from '../types/config'
5
+ import { saveConfig } from '../utils/config-loader'
6
+
7
+ interface InitOptions {
8
+ file: string
9
+ }
10
+
11
+ export async function initCommand(options: InitOptions): Promise<void> {
12
+ console.log(chalk.blue.bold('\nšŸš€ Suthep Deployment Configuration\n'))
13
+
14
+ // Check if file already exists
15
+ if (await fs.pathExists(options.file)) {
16
+ const { overwrite } = await inquirer.prompt([
17
+ {
18
+ type: 'confirm',
19
+ name: 'overwrite',
20
+ message: `File ${options.file} already exists. Overwrite?`,
21
+ default: false,
22
+ },
23
+ ])
24
+
25
+ if (!overwrite) {
26
+ console.log(chalk.yellow('Aborted.'))
27
+ return
28
+ }
29
+ }
30
+
31
+ // Gather project information
32
+ const projectAnswers = await inquirer.prompt([
33
+ {
34
+ type: 'input',
35
+ name: 'projectName',
36
+ message: 'Project name:',
37
+ default: 'my-app',
38
+ },
39
+ {
40
+ type: 'input',
41
+ name: 'projectVersion',
42
+ message: 'Project version:',
43
+ default: '1.0.0',
44
+ },
45
+ ])
46
+
47
+ // Gather service information
48
+ const services = []
49
+ let addMoreServices = true
50
+
51
+ while (addMoreServices) {
52
+ console.log(chalk.cyan(`\nšŸ“¦ Service ${services.length + 1} Configuration`))
53
+
54
+ const serviceAnswers = await inquirer.prompt([
55
+ {
56
+ type: 'input',
57
+ name: 'name',
58
+ message: 'Service name:',
59
+ validate: (input) => input.trim() !== '' || 'Service name is required',
60
+ },
61
+ {
62
+ type: 'number',
63
+ name: 'port',
64
+ message: 'Service port:',
65
+ default: 3000,
66
+ validate: (input: number | undefined) => {
67
+ if (input === undefined) return 'Port is required'
68
+ return (input > 0 && input < 65536) || 'Port must be between 1 and 65535'
69
+ },
70
+ },
71
+ {
72
+ type: 'input',
73
+ name: 'domains',
74
+ message: 'Domain names (comma-separated):',
75
+ validate: (input) => input.trim() !== '' || 'At least one domain is required',
76
+ filter: (input: string) => input.split(',').map((d: string) => d.trim()),
77
+ },
78
+ {
79
+ type: 'confirm',
80
+ name: 'useDocker',
81
+ message: 'Use Docker?',
82
+ default: false,
83
+ },
84
+ ])
85
+
86
+ // Docker configuration
87
+ let dockerConfig = undefined
88
+ if (serviceAnswers.useDocker) {
89
+ const dockerAnswers = await inquirer.prompt([
90
+ {
91
+ type: 'input',
92
+ name: 'image',
93
+ message: 'Docker image (leave empty to connect to existing container):',
94
+ },
95
+ {
96
+ type: 'input',
97
+ name: 'container',
98
+ message: 'Container name:',
99
+ validate: (input) => input.trim() !== '' || 'Container name is required',
100
+ },
101
+ {
102
+ type: 'number',
103
+ name: 'port',
104
+ message: 'Container port:',
105
+ default: serviceAnswers.port,
106
+ },
107
+ ])
108
+
109
+ dockerConfig = {
110
+ image: dockerAnswers.image || undefined,
111
+ container: dockerAnswers.container,
112
+ port: dockerAnswers.port,
113
+ }
114
+ }
115
+
116
+ // Health check configuration
117
+ const { addHealthCheck } = await inquirer.prompt([
118
+ {
119
+ type: 'confirm',
120
+ name: 'addHealthCheck',
121
+ message: 'Add health check?',
122
+ default: true,
123
+ },
124
+ ])
125
+
126
+ let healthCheck = undefined
127
+ if (addHealthCheck) {
128
+ const healthCheckAnswers = await inquirer.prompt([
129
+ {
130
+ type: 'input',
131
+ name: 'path',
132
+ message: 'Health check path:',
133
+ default: '/health',
134
+ },
135
+ {
136
+ type: 'number',
137
+ name: 'interval',
138
+ message: 'Health check interval (seconds):',
139
+ default: 30,
140
+ },
141
+ ])
142
+
143
+ healthCheck = healthCheckAnswers
144
+ }
145
+
146
+ services.push({
147
+ name: serviceAnswers.name,
148
+ port: serviceAnswers.port,
149
+ domains: serviceAnswers.domains,
150
+ docker: dockerConfig,
151
+ healthCheck,
152
+ })
153
+
154
+ const { addMore } = await inquirer.prompt([
155
+ {
156
+ type: 'confirm',
157
+ name: 'addMore',
158
+ message: 'Add another service?',
159
+ default: false,
160
+ },
161
+ ])
162
+
163
+ addMoreServices = addMore
164
+ }
165
+
166
+ // Certbot configuration
167
+ const certbotAnswers = await inquirer.prompt([
168
+ {
169
+ type: 'input',
170
+ name: 'email',
171
+ message: 'Email for SSL certificates:',
172
+ validate: (input) => {
173
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
174
+ return emailRegex.test(input) || 'Please enter a valid email address'
175
+ },
176
+ },
177
+ {
178
+ type: 'confirm',
179
+ name: 'staging',
180
+ message: 'Use Certbot staging environment? (for testing)',
181
+ default: false,
182
+ },
183
+ ])
184
+
185
+ // Build configuration object
186
+ const config: DeployConfig = {
187
+ project: {
188
+ name: projectAnswers.projectName,
189
+ version: projectAnswers.projectVersion,
190
+ },
191
+ services,
192
+ nginx: {
193
+ configPath: '/etc/nginx/sites-available',
194
+ reloadCommand: 'sudo nginx -t && sudo systemctl reload nginx',
195
+ },
196
+ certbot: {
197
+ email: certbotAnswers.email,
198
+ staging: certbotAnswers.staging,
199
+ },
200
+ deployment: {
201
+ strategy: 'rolling',
202
+ healthCheckTimeout: 30000,
203
+ },
204
+ }
205
+
206
+ // Save configuration
207
+ await saveConfig(options.file, config)
208
+
209
+ console.log(chalk.green(`\nāœ… Configuration saved to ${options.file}`))
210
+ console.log(chalk.dim('\nNext steps:'))
211
+ console.log(chalk.dim(` 1. Review and edit ${options.file} if needed`))
212
+ console.log(chalk.dim(' 2. Run: suthep setup'))
213
+ console.log(chalk.dim(' 3. Run: suthep deploy\n'))
214
+ }