suthep 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 (83) hide show
  1. package/.editorconfig +17 -0
  2. package/.github/workflows/publish.yml +42 -0
  3. package/.prettierignore +6 -0
  4. package/.prettierrc +7 -0
  5. package/.scannerwork/.sonar_lock +0 -0
  6. package/.scannerwork/report-task.txt +6 -0
  7. package/.vscode/settings.json +19 -0
  8. package/LICENSE +21 -0
  9. package/README.md +317 -0
  10. package/dist/commands/deploy.js +371 -0
  11. package/dist/commands/deploy.js.map +1 -0
  12. package/dist/commands/down.js +179 -0
  13. package/dist/commands/down.js.map +1 -0
  14. package/dist/commands/init.js +188 -0
  15. package/dist/commands/init.js.map +1 -0
  16. package/dist/commands/setup.js +90 -0
  17. package/dist/commands/setup.js.map +1 -0
  18. package/dist/commands/up.js +213 -0
  19. package/dist/commands/up.js.map +1 -0
  20. package/dist/index.js +66 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/utils/certbot.js +64 -0
  23. package/dist/utils/certbot.js.map +1 -0
  24. package/dist/utils/config-loader.js +127 -0
  25. package/dist/utils/config-loader.js.map +1 -0
  26. package/dist/utils/deployment.js +85 -0
  27. package/dist/utils/deployment.js.map +1 -0
  28. package/dist/utils/docker.js +425 -0
  29. package/dist/utils/docker.js.map +1 -0
  30. package/dist/utils/env-loader.js +53 -0
  31. package/dist/utils/env-loader.js.map +1 -0
  32. package/dist/utils/nginx.js +378 -0
  33. package/dist/utils/nginx.js.map +1 -0
  34. package/docs/README.md +38 -0
  35. package/docs/english/01-introduction.md +84 -0
  36. package/docs/english/02-installation.md +200 -0
  37. package/docs/english/03-quick-start.md +258 -0
  38. package/docs/english/04-configuration.md +433 -0
  39. package/docs/english/05-commands.md +336 -0
  40. package/docs/english/06-examples.md +456 -0
  41. package/docs/english/07-troubleshooting.md +417 -0
  42. package/docs/english/08-advanced.md +411 -0
  43. package/docs/english/README.md +48 -0
  44. package/docs/thai/01-introduction.md +84 -0
  45. package/docs/thai/02-installation.md +200 -0
  46. package/docs/thai/03-quick-start.md +258 -0
  47. package/docs/thai/04-configuration.md +433 -0
  48. package/docs/thai/05-commands.md +336 -0
  49. package/docs/thai/06-examples.md +456 -0
  50. package/docs/thai/07-troubleshooting.md +417 -0
  51. package/docs/thai/08-advanced.md +411 -0
  52. package/docs/thai/README.md +48 -0
  53. package/example/suthep-complete.yml +103 -0
  54. package/example/suthep-docker-only.yml +71 -0
  55. package/example/suthep-env-example.yml +113 -0
  56. package/example/suthep-no-docker.yml +51 -0
  57. package/example/suthep-path-routing.yml +62 -0
  58. package/example/suthep.example.yml +88 -0
  59. package/package.json +51 -0
  60. package/src/commands/deploy.ts +488 -0
  61. package/src/commands/down.ts +240 -0
  62. package/src/commands/init.ts +214 -0
  63. package/src/commands/setup.ts +112 -0
  64. package/src/commands/up.ts +271 -0
  65. package/src/index.ts +109 -0
  66. package/src/types/config.ts +52 -0
  67. package/src/utils/__tests__/certbot.test.ts +222 -0
  68. package/src/utils/__tests__/config-loader.test.ts +419 -0
  69. package/src/utils/__tests__/deployment.test.ts +243 -0
  70. package/src/utils/__tests__/nginx.test.ts +412 -0
  71. package/src/utils/certbot.ts +144 -0
  72. package/src/utils/config-loader.ts +184 -0
  73. package/src/utils/deployment.ts +157 -0
  74. package/src/utils/docker.ts +768 -0
  75. package/src/utils/env-loader.ts +135 -0
  76. package/src/utils/nginx.ts +443 -0
  77. package/suthep-1.0.0.tgz +0 -0
  78. package/suthep.example.yml +98 -0
  79. package/suthep.yml +39 -0
  80. package/todo.md +6 -0
  81. package/tsconfig.json +26 -0
  82. package/vite.config.ts +46 -0
  83. package/vitest.config.ts +21 -0
@@ -0,0 +1,488 @@
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
+ getCanonicalDomain,
19
+ reloadNginx,
20
+ writeNginxConfig,
21
+ } from '../utils/nginx'
22
+
23
+ interface DeployOptions {
24
+ file: string
25
+ https: boolean
26
+ nginx: boolean
27
+ serviceName?: string
28
+ cliEnvVars?: Record<string, string>
29
+ }
30
+
31
+ export async function deployCommand(options: DeployOptions): Promise<void> {
32
+ console.log(chalk.blue.bold('\nšŸš€ Deploying Services\n'))
33
+
34
+ try {
35
+ // Load configuration (this will also load .env files for variable substitution)
36
+ if (!(await fs.pathExists(options.file))) {
37
+ throw new Error(`Configuration file not found: ${options.file}`)
38
+ }
39
+
40
+ console.log(chalk.cyan(`šŸ“„ Loading configuration from ${options.file}...`))
41
+ const config = await loadConfig(options.file)
42
+
43
+ // Filter services based on serviceName if provided
44
+ let servicesToDeploy: ServiceConfig[] = []
45
+ if (options.serviceName) {
46
+ const service = config.services.find((s) => s.name === options.serviceName)
47
+ if (!service) {
48
+ throw new Error(
49
+ `Service "${
50
+ options.serviceName
51
+ }" not found in configuration. Available services: ${config.services
52
+ .map((s) => s.name)
53
+ .join(', ')}`
54
+ )
55
+ }
56
+ servicesToDeploy = [service]
57
+ console.log(chalk.green(`āœ… Configuration loaded for project: ${config.project.name}`))
58
+ console.log(chalk.cyan(`šŸ“‹ Deploying service: ${options.serviceName}\n`))
59
+ } else {
60
+ servicesToDeploy = config.services
61
+ console.log(chalk.green(`āœ… Configuration loaded for project: ${config.project.name}`))
62
+ console.log(
63
+ chalk.cyan(`šŸ“‹ Deploying all services: ${servicesToDeploy.map((s) => s.name).join(', ')}\n`)
64
+ )
65
+ }
66
+
67
+ // Group services by domain
68
+ // When deploying a single service, include ALL services from config that share the same domain(s)
69
+ // This ensures nginx config includes both old and new services
70
+ const domainToServices = new Map<string, ServiceConfig[]>()
71
+ const allDomains = new Set<string>()
72
+
73
+ // Collect all domains from services being deployed
74
+ for (const service of servicesToDeploy) {
75
+ for (const domain of service.domains) {
76
+ allDomains.add(domain)
77
+ }
78
+ }
79
+
80
+ // If deploying a single service, include all services that share the same domain(s)
81
+ // Otherwise, use only services being deployed
82
+ const servicesForNginx = options.serviceName
83
+ ? config.services.filter((service) => {
84
+ // Include service if it shares any domain with services being deployed
85
+ return service.domains.some((domain) => allDomains.has(domain))
86
+ })
87
+ : servicesToDeploy
88
+
89
+ // Log when additional services are included in nginx config
90
+ if (options.serviceName && servicesForNginx.length > servicesToDeploy.length) {
91
+ const additionalServices = servicesForNginx.filter(
92
+ (s) => !servicesToDeploy.some((d) => d.name === s.name)
93
+ )
94
+ console.log(
95
+ chalk.cyan(
96
+ ` šŸ“‹ Including ${
97
+ additionalServices.length
98
+ } additional service(s) in nginx config: ${additionalServices
99
+ .map((s) => s.name)
100
+ .join(', ')}`
101
+ )
102
+ )
103
+ }
104
+
105
+ // Group services by canonical domain for nginx configuration
106
+ // This ensures www and non-www root domains use the same config file
107
+ const canonicalDomains = new Set<string>()
108
+ for (const service of servicesForNginx) {
109
+ for (const domain of service.domains) {
110
+ if (allDomains.has(domain)) {
111
+ // Get canonical domain (www if both exist, otherwise the domain itself)
112
+ const canonicalDomain = getCanonicalDomain(domain, allDomains)
113
+ canonicalDomains.add(canonicalDomain)
114
+
115
+ // Group services by canonical domain
116
+ if (!domainToServices.has(canonicalDomain)) {
117
+ domainToServices.set(canonicalDomain, [])
118
+ }
119
+ // Only add service if not already added for this canonical domain
120
+ if (!domainToServices.get(canonicalDomain)!.some((s) => s.name === service.name)) {
121
+ domainToServices.get(canonicalDomain)!.push(service)
122
+ }
123
+ }
124
+ }
125
+ }
126
+
127
+ // Deploy each service (Docker, health checks, etc.)
128
+ // Track zero-downtime info for services that need it
129
+ const serviceTempInfo = new Map<string, ZeroDowntimeContainerInfo | null>()
130
+
131
+ for (const service of servicesToDeploy) {
132
+ console.log(chalk.cyan(`\nšŸ“¦ Deploying service: ${service.name}`))
133
+
134
+ try {
135
+ // Start Docker container if configured
136
+ if (service.docker) {
137
+ console.log(chalk.dim(' 🐳 Managing Docker container...'))
138
+
139
+ // Use zero-downtime deployment if strategy is blue-green or rolling
140
+ if (
141
+ config.deployment.strategy === 'blue-green' ||
142
+ config.deployment.strategy === 'rolling'
143
+ ) {
144
+ const tempInfo = await startDockerContainerZeroDowntime(service, options.cliEnvVars)
145
+ serviceTempInfo.set(service.name, tempInfo)
146
+
147
+ if (tempInfo && tempInfo.oldContainerExists) {
148
+ console.log(
149
+ chalk.cyan(
150
+ ` šŸ”„ Zero-downtime deployment: new container on port ${tempInfo.tempPort}`
151
+ )
152
+ )
153
+ }
154
+ } else {
155
+ // Fallback to regular deployment
156
+ await startDockerContainer(service, options.cliEnvVars)
157
+ serviceTempInfo.set(service.name, null)
158
+ }
159
+ } else {
160
+ serviceTempInfo.set(service.name, null)
161
+ }
162
+
163
+ // Deploy the service (with temp info for zero-downtime)
164
+ const tempInfo = serviceTempInfo.get(service.name) || null
165
+ await deployService(service, config.deployment, tempInfo)
166
+
167
+ // Perform health check on appropriate port
168
+ if (service.healthCheck) {
169
+ console.log(chalk.dim(` šŸ„ Performing health check...`))
170
+ const checkPort =
171
+ tempInfo && tempInfo.oldContainerExists ? tempInfo.tempPort : service.port
172
+ const isHealthy = await performHealthCheck(
173
+ `http://localhost:${checkPort}${service.healthCheck.path}`,
174
+ config.deployment.healthCheckTimeout
175
+ )
176
+
177
+ if (isHealthy) {
178
+ console.log(chalk.green(` āœ… Service ${service.name} is healthy`))
179
+ } else {
180
+ throw new Error(`Health check failed for service ${service.name}`)
181
+ }
182
+ }
183
+
184
+ console.log(chalk.green.bold(`✨ Service ${service.name} deployed successfully!`))
185
+ } catch (error) {
186
+ console.error(
187
+ chalk.red(`\nāŒ Failed to deploy service ${service.name}:`),
188
+ error instanceof Error ? error.message : error
189
+ )
190
+ throw error
191
+ }
192
+ }
193
+
194
+ // Helper function to generate nginx configs with optional port overrides
195
+ const generateNginxConfigsForDomain = (
196
+ domain: string,
197
+ withHttps: boolean,
198
+ portOverrides?: Map<string, number>
199
+ ): string => {
200
+ const servicesForDomain = domainToServices.get(domain)!
201
+ if (servicesForDomain.length === 1) {
202
+ const service = servicesForDomain[0]
203
+ const portOverride = portOverrides?.get(service.name)
204
+ return generateNginxConfig(service, withHttps, portOverride)
205
+ } else {
206
+ return generateMultiServiceNginxConfig(servicesForDomain, domain, withHttps, portOverrides)
207
+ }
208
+ }
209
+
210
+ // Check if we need zero-downtime nginx updates (any service has temp container)
211
+ const needsZeroDowntimeNginx = Array.from(serviceTempInfo.values()).some(
212
+ (info) => info !== null && info.oldContainerExists
213
+ )
214
+
215
+ // Configure Nginx per domain
216
+ if (options.nginx) {
217
+ // If zero-downtime, first update nginx to point to temp ports
218
+ if (needsZeroDowntimeNginx) {
219
+ console.log(chalk.cyan(`\nāš™ļø Updating Nginx for zero-downtime deployment...`))
220
+
221
+ // Build port override map for temp ports
222
+ const tempPortOverrides = new Map<string, number>()
223
+ for (const service of servicesToDeploy) {
224
+ const tempInfo = serviceTempInfo.get(service.name)
225
+ if (tempInfo && tempInfo.oldContainerExists) {
226
+ tempPortOverrides.set(service.name, tempInfo.tempPort)
227
+ }
228
+ }
229
+
230
+ for (const canonicalDomain of canonicalDomains) {
231
+ const configName = canonicalDomain.replace(/\./g, '_')
232
+ try {
233
+ const nginxConfigContent = generateNginxConfigsForDomain(
234
+ canonicalDomain,
235
+ false,
236
+ tempPortOverrides
237
+ )
238
+ await writeNginxConfig(configName, config.nginx.configPath, nginxConfigContent)
239
+ await enableSite(configName, config.nginx.configPath)
240
+ console.log(chalk.green(` āœ… Nginx updated for ${canonicalDomain} (temporary ports)`))
241
+ } catch (error) {
242
+ console.error(
243
+ chalk.red(` āŒ Failed to update Nginx for ${canonicalDomain}:`),
244
+ error instanceof Error ? error.message : error
245
+ )
246
+ throw error
247
+ }
248
+ }
249
+
250
+ // Reload nginx to switch to temp ports (graceful reload, no connection drops)
251
+ console.log(chalk.cyan(`\nšŸ”„ Reloading Nginx to switch to new containers...`))
252
+ await reloadNginx(config.nginx.reloadCommand)
253
+ console.log(chalk.green(` āœ… Nginx reloaded, traffic now routed to new containers`))
254
+
255
+ // Now swap containers (stop old, promote new)
256
+ console.log(chalk.cyan(`\nšŸ”„ Swapping containers for zero-downtime...`))
257
+ for (const service of servicesToDeploy) {
258
+ const tempInfo = serviceTempInfo.get(service.name)
259
+ if (tempInfo && tempInfo.oldContainerExists && service.docker) {
260
+ try {
261
+ await swapContainersForZeroDowntime(service, tempInfo, options.cliEnvVars)
262
+ console.log(chalk.green(` āœ… Container swapped for ${service.name}`))
263
+ } catch (error) {
264
+ console.error(
265
+ chalk.red(` āŒ Failed to swap container for ${service.name}:`),
266
+ error instanceof Error ? error.message : error
267
+ )
268
+ throw error
269
+ }
270
+ }
271
+ }
272
+
273
+ // Update nginx back to original ports (before stopping temp containers)
274
+ console.log(chalk.cyan(`\nāš™ļø Updating Nginx back to production ports...`))
275
+ for (const canonicalDomain of canonicalDomains) {
276
+ const configName = canonicalDomain.replace(/\./g, '_')
277
+ try {
278
+ const nginxConfigContent = generateNginxConfigsForDomain(canonicalDomain, false)
279
+ await writeNginxConfig(configName, config.nginx.configPath, nginxConfigContent)
280
+ await enableSite(configName, config.nginx.configPath)
281
+ console.log(chalk.green(` āœ… Nginx updated for ${canonicalDomain} (production ports)`))
282
+ } catch (error) {
283
+ console.error(
284
+ chalk.red(` āŒ Failed to update Nginx for ${canonicalDomain}:`),
285
+ error instanceof Error ? error.message : error
286
+ )
287
+ throw error
288
+ }
289
+ }
290
+
291
+ // Reload nginx to switch to production ports (graceful reload)
292
+ console.log(chalk.cyan(`\nšŸ”„ Reloading Nginx to switch to production ports...`))
293
+ await reloadNginx(config.nginx.reloadCommand)
294
+ console.log(chalk.green(` āœ… Nginx reloaded, traffic now routed to production containers`))
295
+
296
+ // Clean up temp containers (nginx already pointing to production, so safe to remove)
297
+ console.log(chalk.cyan(`\n🧹 Cleaning up temporary containers...`))
298
+ for (const service of servicesToDeploy) {
299
+ const tempInfo = serviceTempInfo.get(service.name)
300
+ if (tempInfo && tempInfo.oldContainerExists) {
301
+ await cleanupTempContainer(tempInfo.tempContainerName)
302
+ }
303
+ }
304
+ } else {
305
+ // Regular nginx configuration (no zero-downtime needed)
306
+ console.log(chalk.cyan(`\nāš™ļø Configuring Nginx reverse proxy...`))
307
+
308
+ for (const canonicalDomain of canonicalDomains) {
309
+ const servicesForDomain = domainToServices.get(canonicalDomain)!
310
+ const configName = canonicalDomain.replace(/\./g, '_')
311
+
312
+ try {
313
+ // Log domain and services configuration
314
+ if (servicesForDomain.length > 1) {
315
+ console.log(
316
+ chalk.cyan(
317
+ ` šŸ“‹ Configuring ${canonicalDomain} with ${
318
+ servicesForDomain.length
319
+ } services: ${servicesForDomain.map((s) => s.name).join(', ')}`
320
+ )
321
+ )
322
+ console.log(
323
+ chalk.dim(
324
+ ` All services will share the same nginx config file: ${configName}.conf`
325
+ )
326
+ )
327
+ } else {
328
+ // Single service, but log it anyway for clarity
329
+ console.log(
330
+ chalk.cyan(
331
+ ` šŸ“‹ Configuring ${canonicalDomain} with service: ${servicesForDomain[0].name}`
332
+ )
333
+ )
334
+ }
335
+
336
+ // Generate Nginx config
337
+ const nginxConfigContent = generateNginxConfigsForDomain(canonicalDomain, false)
338
+
339
+ // Check if config file already exists and write/override it
340
+ const wasOverridden = await writeNginxConfig(
341
+ configName,
342
+ config.nginx.configPath,
343
+ nginxConfigContent
344
+ )
345
+
346
+ if (wasOverridden) {
347
+ console.log(
348
+ chalk.yellow(
349
+ ` šŸ”„ Nginx config "${configName}.conf" already exists, deleting and recreating with new configuration...`
350
+ )
351
+ )
352
+ }
353
+
354
+ await enableSite(configName, config.nginx.configPath)
355
+
356
+ console.log(chalk.green(` āœ… Nginx configured for ${canonicalDomain}`))
357
+ } catch (error) {
358
+ console.error(
359
+ chalk.red(` āŒ Failed to configure Nginx for ${canonicalDomain}:`),
360
+ error instanceof Error ? error.message : error
361
+ )
362
+ throw error
363
+ }
364
+ }
365
+ }
366
+ }
367
+
368
+ // Setup HTTPS with Certbot (per canonical domain, not per service)
369
+ if (options.https && canonicalDomains.size > 0) {
370
+ console.log(chalk.cyan(`\nšŸ” Setting up HTTPS certificates...`))
371
+
372
+ for (const canonicalDomain of canonicalDomains) {
373
+ try {
374
+ // Check if certificate already exists
375
+ const exists = await certificateExists(canonicalDomain)
376
+ if (exists) {
377
+ console.log(
378
+ chalk.green(
379
+ ` āœ… SSL certificate already exists for ${canonicalDomain}, skipping certificate creation`
380
+ )
381
+ )
382
+ console.log(
383
+ chalk.dim(
384
+ ` Using existing certificate from /etc/letsencrypt/live/${canonicalDomain}/`
385
+ )
386
+ )
387
+ } else {
388
+ // Request new certificate
389
+ console.log(chalk.cyan(` šŸ“œ Requesting SSL certificate for ${canonicalDomain}...`))
390
+ try {
391
+ await requestCertificate(
392
+ canonicalDomain,
393
+ config.certbot.email,
394
+ config.certbot.staging
395
+ )
396
+ console.log(chalk.green(` āœ… SSL certificate obtained for ${canonicalDomain}`))
397
+ } catch (error: any) {
398
+ // Check if error is because certificate already exists (race condition or check missed it)
399
+ const errorMessage = error?.message || String(error) || ''
400
+ if (
401
+ errorMessage.includes('already exists') ||
402
+ errorMessage.includes('Skipping certificate creation')
403
+ ) {
404
+ console.log(
405
+ chalk.green(
406
+ ` āœ… SSL certificate already exists for ${canonicalDomain} (detected during request), skipping...`
407
+ )
408
+ )
409
+ } else {
410
+ throw error // Re-throw if it's a different error
411
+ }
412
+ }
413
+ }
414
+ } catch (error) {
415
+ console.log(
416
+ chalk.yellow(
417
+ ` āš ļø Failed to obtain SSL for ${canonicalDomain}: ${
418
+ error instanceof Error ? error.message : error
419
+ }`
420
+ )
421
+ )
422
+ }
423
+ }
424
+
425
+ // Update Nginx configs with HTTPS
426
+ if (options.nginx) {
427
+ console.log(chalk.cyan(`\nšŸ”„ Updating Nginx configs with HTTPS...`))
428
+ for (const canonicalDomain of canonicalDomains) {
429
+ const configName = canonicalDomain.replace(/\./g, '_')
430
+
431
+ try {
432
+ const nginxConfigContent = generateNginxConfigsForDomain(canonicalDomain, true)
433
+ const wasOverridden = await writeNginxConfig(
434
+ configName,
435
+ config.nginx.configPath,
436
+ nginxConfigContent
437
+ )
438
+
439
+ if (wasOverridden) {
440
+ console.log(
441
+ chalk.yellow(
442
+ ` šŸ”„ Nginx config "${configName}.conf" already exists, deleting and recreating with new HTTPS configuration...`
443
+ )
444
+ )
445
+ }
446
+ console.log(chalk.green(` āœ… HTTPS config updated for ${canonicalDomain}`))
447
+ } catch (error) {
448
+ console.error(
449
+ chalk.red(` āŒ Failed to update HTTPS config for ${canonicalDomain}:`),
450
+ error instanceof Error ? error.message : error
451
+ )
452
+ throw error
453
+ }
454
+ }
455
+ }
456
+ }
457
+
458
+ // Final reload Nginx after all configurations (only if we didn't already reload for zero-downtime)
459
+ if (options.nginx && !needsZeroDowntimeNginx) {
460
+ console.log(chalk.cyan(`\nšŸ”„ Reloading Nginx...`))
461
+ await reloadNginx(config.nginx.reloadCommand)
462
+ } else if (options.nginx && needsZeroDowntimeNginx) {
463
+ // Final reload after HTTPS update
464
+ console.log(chalk.cyan(`\nšŸ”„ Final Nginx reload with HTTPS...`))
465
+ await reloadNginx(config.nginx.reloadCommand)
466
+ }
467
+
468
+ console.log(chalk.green.bold('\nšŸŽ‰ All services deployed successfully!\n'))
469
+
470
+ // Print service URLs
471
+ console.log(chalk.cyan('šŸ“‹ Service URLs:'))
472
+ for (const service of servicesToDeploy) {
473
+ for (const domain of service.domains) {
474
+ const protocol = options.https ? 'https' : 'http'
475
+ const servicePath = service.path || '/'
476
+ const fullPath = servicePath === '/' ? '' : servicePath
477
+ console.log(chalk.dim(` ${service.name}: ${protocol}://${domain}${fullPath}`))
478
+ }
479
+ }
480
+ console.log()
481
+ } catch (error) {
482
+ console.error(
483
+ chalk.red('\nāŒ Deployment failed:'),
484
+ error instanceof Error ? error.message : error
485
+ )
486
+ process.exit(1)
487
+ }
488
+ }