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,768 @@
1
+ import chalk from 'chalk'
2
+ import { execa } from 'execa'
3
+ import type { ServiceConfig } from '../types/config'
4
+ import { getLoadedEnvVars, mergeEnvVars } from './env-loader'
5
+
6
+ /**
7
+ * Interface for zero-downtime deployment container info
8
+ */
9
+ export interface ZeroDowntimeContainerInfo {
10
+ tempContainerName: string
11
+ tempPort: number
12
+ oldContainerExists: boolean
13
+ }
14
+
15
+ /**
16
+ * Helper function to add environment variables to Docker run arguments
17
+ * Ensures consistent env var handling across all container creation functions
18
+ */
19
+ function addEnvVarsToDockerArgs(
20
+ args: string[],
21
+ service: ServiceConfig,
22
+ cliEnvVars?: Record<string, string>
23
+ ): void {
24
+ // Merge environment variables (priority: CLI > Service > .env files)
25
+ const envVars = mergeEnvVars(getLoadedEnvVars(), service.environment, cliEnvVars)
26
+ if (Object.keys(envVars).length > 0) {
27
+ for (const [key, value] of Object.entries(envVars)) {
28
+ args.push('-e', `${key}=${value}`)
29
+ }
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Start a new container on a temporary port for zero-downtime deployment
35
+ * Returns information about the temporary container
36
+ */
37
+ export async function startDockerContainerZeroDowntime(
38
+ service: ServiceConfig,
39
+ cliEnvVars?: Record<string, string>
40
+ ): Promise<ZeroDowntimeContainerInfo | null> {
41
+ if (!service.docker) {
42
+ return null
43
+ }
44
+
45
+ const { image, container, port } = service.docker
46
+
47
+ if (!image) {
48
+ throw new Error(
49
+ `Image is required for zero-downtime deployment. Please specify an "image" field in the docker configuration for service "${service.name}".`
50
+ )
51
+ }
52
+
53
+ try {
54
+ // Check if old container exists
55
+ let oldContainerExists = false
56
+ let oldContainerRunning = false
57
+ try {
58
+ const { stdout } = await execa('docker', ['inspect', '--type', 'container', container], {
59
+ stderr: 'pipe',
60
+ })
61
+ oldContainerExists = true
62
+
63
+ try {
64
+ const containerInfo = JSON.parse(stdout)
65
+ if (containerInfo && containerInfo[0]) {
66
+ oldContainerRunning = containerInfo[0].State?.Running || false
67
+ console.log(
68
+ chalk.dim(
69
+ ` 📋 Existing container "${container}" found (running: ${oldContainerRunning})`
70
+ )
71
+ )
72
+ }
73
+ } catch (parseError) {
74
+ // If we can't parse, that's okay - we know the container exists
75
+ }
76
+ } catch (error: any) {
77
+ // Container doesn't exist - this is a fresh deployment
78
+ oldContainerExists = false
79
+ console.log(chalk.dim(` 📋 No existing container found, performing fresh deployment`))
80
+ }
81
+
82
+ // For zero-downtime, we need a temporary port and container name
83
+ const tempPort = oldContainerExists ? service.port + 10000 : service.port
84
+ const tempContainerName = oldContainerExists ? `${container}-new` : container
85
+
86
+ // Check if temp container already exists (from a failed previous deployment)
87
+ try {
88
+ await execa('docker', ['inspect', '--type', 'container', tempContainerName], {
89
+ stderr: 'pipe',
90
+ })
91
+ // Temp container exists, remove it
92
+ console.log(chalk.yellow(` 🧹 Cleaning up previous temporary container...`))
93
+ await execa('docker', ['rm', '-f', tempContainerName])
94
+ } catch (error) {
95
+ // Temp container doesn't exist, which is fine
96
+ }
97
+
98
+ // Check if temp port is available
99
+ if (oldContainerExists) {
100
+ try {
101
+ const { stdout: portCheck } = await execa('docker', ['ps', '--format', '{{.Ports}}'])
102
+ const portPattern = new RegExp(`:${tempPort}->`, 'g')
103
+ if (portCheck && portPattern.test(portCheck)) {
104
+ throw new Error(
105
+ `Temporary port ${tempPort} is already in use. Please ensure no other containers are using ports in the range ${service.port}-${tempPort}.`
106
+ )
107
+ }
108
+ } catch (error) {
109
+ if (error instanceof Error && error.message.includes('Temporary port')) {
110
+ throw error
111
+ }
112
+ }
113
+ }
114
+
115
+ // Pull the latest image
116
+ try {
117
+ console.log(chalk.dim(` 📥 Pulling latest image: ${image}...`))
118
+ await execa('docker', ['pull', image])
119
+ console.log(chalk.green(` ✅ Image pulled successfully: ${image}`))
120
+ } catch (error: any) {
121
+ const errorDetails = error?.stderr || error?.message || 'Unknown error'
122
+ console.log(
123
+ chalk.yellow(` ⚠️ Failed to pull image ${image}, using existing local image if available`)
124
+ )
125
+ console.log(chalk.dim(` Error: ${errorDetails}`))
126
+ }
127
+
128
+ // Create Docker port binding
129
+ const args = [
130
+ 'run',
131
+ '-d',
132
+ '--name',
133
+ tempContainerName,
134
+ '-p',
135
+ `${tempPort}:${port}`, // Port binding: host:container
136
+ '--restart',
137
+ 'unless-stopped',
138
+ ]
139
+
140
+ // Add environment variables (merge .env vars, service-specific env vars, and CLI env vars)
141
+ addEnvVarsToDockerArgs(args, service, cliEnvVars)
142
+
143
+ args.push(image)
144
+
145
+ try {
146
+ await execa('docker', args)
147
+ if (oldContainerExists) {
148
+ console.log(
149
+ chalk.green(
150
+ ` ✅ Created new container "${tempContainerName}" on temporary port ${tempPort}`
151
+ )
152
+ )
153
+ } else {
154
+ console.log(chalk.green(` ✅ Created and started container: ${tempContainerName}`))
155
+ }
156
+ } catch (error: any) {
157
+ const errorMessage = error?.message || String(error) || 'Unknown error'
158
+ const errorStderr = error?.stderr || ''
159
+ const errorStdout = error?.stdout || ''
160
+
161
+ const fullError = [errorMessage, errorStderr, errorStdout]
162
+ .filter(Boolean)
163
+ .join('\n')
164
+ .toLowerCase()
165
+
166
+ if (
167
+ fullError.includes('port is already allocated') ||
168
+ fullError.includes('bind: address already in use')
169
+ ) {
170
+ throw new Error(
171
+ `Port ${tempPort} is already in use. Please ensure the port is available for zero-downtime deployment.`
172
+ )
173
+ }
174
+
175
+ if (
176
+ fullError.includes('container name is already in use') ||
177
+ fullError.includes('is already in use')
178
+ ) {
179
+ throw new Error(
180
+ `Container name "${tempContainerName}" is already in use. Please remove it manually and try again.`
181
+ )
182
+ }
183
+
184
+ const details = errorStderr || errorStdout || errorMessage
185
+ throw new Error(`Failed to create Docker container "${tempContainerName}": ${details}`)
186
+ }
187
+
188
+ return {
189
+ tempContainerName,
190
+ tempPort,
191
+ oldContainerExists,
192
+ }
193
+ } catch (error: any) {
194
+ if (error instanceof Error && error.message) {
195
+ throw new Error(
196
+ `Failed to start Docker container for zero-downtime deployment of service "${service.name}": ${error.message}`
197
+ )
198
+ }
199
+
200
+ const errorDetails =
201
+ error?.message || error?.stderr || error?.stdout || String(error) || 'Unknown error'
202
+ throw new Error(
203
+ `Failed to start Docker container for zero-downtime deployment of service "${service.name}": ${errorDetails}`
204
+ )
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Swap containers for zero-downtime deployment
210
+ * Stops old container and creates new container on original port
211
+ * Note: Temp container cleanup should happen after nginx is updated to original port
212
+ */
213
+ export async function swapContainersForZeroDowntime(
214
+ service: ServiceConfig,
215
+ tempInfo: ZeroDowntimeContainerInfo,
216
+ cliEnvVars?: Record<string, string>
217
+ ): Promise<void> {
218
+ if (!service.docker) {
219
+ return
220
+ }
221
+
222
+ const { container, image, port } = service.docker
223
+
224
+ if (!image) {
225
+ throw new Error(`Image is required for container swap. Service: ${service.name}`)
226
+ }
227
+
228
+ try {
229
+ // Step 1: Stop and remove old container (nginx still pointing to temp port, so no downtime)
230
+ if (tempInfo.oldContainerExists) {
231
+ console.log(chalk.cyan(` 🔄 Stopping old container "${container}"...`))
232
+ try {
233
+ await execa('docker', ['stop', container])
234
+ console.log(chalk.green(` ✅ Stopped old container: ${container}`))
235
+ } catch (error: any) {
236
+ const errorDetails = error?.stderr || error?.message || 'Unknown error'
237
+ // If container is already stopped, that's fine
238
+ if (!errorDetails.toLowerCase().includes('already stopped')) {
239
+ console.log(chalk.yellow(` ⚠️ Could not stop old container: ${errorDetails}`))
240
+ }
241
+ }
242
+
243
+ try {
244
+ await execa('docker', ['rm', container])
245
+ console.log(chalk.green(` ✅ Removed old container: ${container}`))
246
+ } catch (error: any) {
247
+ const errorDetails = error?.stderr || error?.message || 'Unknown error'
248
+ // If container doesn't exist, that's fine
249
+ if (
250
+ !errorDetails.toLowerCase().includes('no such container') &&
251
+ !errorDetails.toLowerCase().includes('container not found')
252
+ ) {
253
+ console.log(chalk.yellow(` ⚠️ Could not remove old container: ${errorDetails}`))
254
+ }
255
+ }
256
+
257
+ // Step 2: Create new container on original port (temp container still running on temp port)
258
+ console.log(chalk.cyan(` 🔄 Creating new container on production port...`))
259
+
260
+ const args = [
261
+ 'run',
262
+ '-d',
263
+ '--name',
264
+ container,
265
+ '-p',
266
+ `${service.port}:${port}`,
267
+ '--restart',
268
+ 'unless-stopped',
269
+ ]
270
+
271
+ // Add environment variables (merge .env vars, service-specific env vars, and CLI env vars)
272
+ addEnvVarsToDockerArgs(args, service, cliEnvVars)
273
+
274
+ args.push(image)
275
+
276
+ try {
277
+ await execa('docker', args)
278
+ console.log(
279
+ chalk.green(
280
+ ` ✅ Created new container "${container}" on production port ${service.port}`
281
+ )
282
+ )
283
+ } catch (error: any) {
284
+ const errorDetails = error?.stderr || error?.message || String(error) || 'Unknown error'
285
+ throw new Error(`Failed to create final container "${container}": ${errorDetails}`)
286
+ }
287
+ }
288
+ } catch (error: any) {
289
+ const errorDetails = error?.stderr || error?.message || String(error) || 'Unknown error'
290
+ throw new Error(`Failed to swap containers for zero-downtime deployment: ${errorDetails}`)
291
+ }
292
+ }
293
+
294
+ /**
295
+ * Clean up temporary container after zero-downtime deployment
296
+ * Should be called after nginx has been updated to point to the new container
297
+ */
298
+ export async function cleanupTempContainer(tempContainerName: string): Promise<void> {
299
+ try {
300
+ console.log(chalk.cyan(` 🧹 Cleaning up temporary container "${tempContainerName}"...`))
301
+
302
+ // Stop temp container
303
+ try {
304
+ await execa('docker', ['stop', tempContainerName])
305
+ } catch (error: any) {
306
+ const errorDetails = error?.stderr || error?.message || 'Unknown error'
307
+ // If already stopped, that's fine
308
+ if (!errorDetails.toLowerCase().includes('already stopped')) {
309
+ console.log(chalk.yellow(` ⚠️ Could not stop temp container: ${errorDetails}`))
310
+ }
311
+ }
312
+
313
+ // Remove temp container
314
+ try {
315
+ await execa('docker', ['rm', tempContainerName])
316
+ console.log(chalk.green(` ✅ Removed temporary container: ${tempContainerName}`))
317
+ } catch (error: any) {
318
+ const errorDetails = error?.stderr || error?.message || 'Unknown error'
319
+ // If doesn't exist, that's fine
320
+ if (
321
+ !errorDetails.toLowerCase().includes('no such container') &&
322
+ !errorDetails.toLowerCase().includes('container not found')
323
+ ) {
324
+ console.log(chalk.yellow(` ⚠️ Could not remove temp container: ${errorDetails}`))
325
+ }
326
+ }
327
+ } catch (error: any) {
328
+ const errorDetails = error?.stderr || error?.message || String(error) || 'Unknown error'
329
+ console.log(chalk.yellow(` ⚠️ Error during temp container cleanup: ${errorDetails}`))
330
+ // Don't throw - cleanup failures shouldn't fail the deployment
331
+ }
332
+ }
333
+
334
+ /**
335
+ * Start or connect to a Docker container for a service
336
+ * For zero-downtime deployments, use startDockerContainerZeroDowntime instead
337
+ */
338
+ export async function startDockerContainer(
339
+ service: ServiceConfig,
340
+ cliEnvVars?: Record<string, string>
341
+ ): Promise<void> {
342
+ if (!service.docker) {
343
+ return
344
+ }
345
+
346
+ const { image, container, port } = service.docker
347
+
348
+ try {
349
+ // Check if container exists using docker inspect (exact name match)
350
+ let containerExists = false
351
+ let containerState = ''
352
+ try {
353
+ const { stdout } = await execa('docker', ['inspect', '--type', 'container', container], {
354
+ stderr: 'pipe',
355
+ })
356
+ containerExists = true
357
+
358
+ // Parse container state from inspect output
359
+ try {
360
+ const containerInfo = JSON.parse(stdout)
361
+ if (containerInfo && containerInfo[0]) {
362
+ containerState = containerInfo[0].State?.Status || 'unknown'
363
+ const isRunning = containerInfo[0].State?.Running || false
364
+ console.log(
365
+ chalk.dim(
366
+ ` 📋 Container "${container}" exists (state: ${containerState}, running: ${isRunning})`
367
+ )
368
+ )
369
+ }
370
+ } catch (parseError) {
371
+ // If we can't parse, that's okay - we know the container exists
372
+ }
373
+ } catch (error: any) {
374
+ // Container doesn't exist - this is expected for new deployments
375
+ containerExists = false
376
+ const errorMessage = error?.stderr || error?.message || ''
377
+ if (
378
+ errorMessage.includes('No such container') ||
379
+ errorMessage.includes('Error: No such object')
380
+ ) {
381
+ console.log(chalk.dim(` 📋 Container "${container}" does not exist, will create new one`))
382
+ }
383
+ }
384
+
385
+ let shouldCreateNewContainer = true
386
+
387
+ if (containerExists) {
388
+ // Container exists - always remove and recreate for fresh deployment
389
+ if (!image) {
390
+ throw new Error(
391
+ `Container "${container}" exists and needs to be recreated for redeployment. ` +
392
+ `No image specified in configuration for service "${service.name}". ` +
393
+ `Please add an "image" field to the docker configuration to allow container recreation.`
394
+ )
395
+ }
396
+
397
+ // Always recreate container on redeploy to ensure fresh deployment
398
+ console.log(
399
+ chalk.yellow(` 🔄 Removing existing container "${container}" for redeployment...`)
400
+ )
401
+
402
+ // Stop and remove old container (force remove will stop if running)
403
+ try {
404
+ await execa('docker', ['rm', '-f', container])
405
+ console.log(chalk.green(` ✅ Removed existing container: ${container}`))
406
+
407
+ // Verify container was actually removed
408
+ try {
409
+ await execa('docker', ['inspect', '--type', 'container', container], {
410
+ stdout: 'ignore',
411
+ stderr: 'ignore',
412
+ })
413
+ // If we get here, container still exists - this shouldn't happen
414
+ throw new Error(
415
+ `Container "${container}" was not properly removed. Please remove it manually and try again.`
416
+ )
417
+ } catch (verifyError: any) {
418
+ // Container doesn't exist anymore - this is what we want
419
+ const verifyMessage = verifyError?.stderr || verifyError?.message || ''
420
+ if (
421
+ verifyMessage.includes('No such container') ||
422
+ verifyMessage.includes('Error: No such object')
423
+ ) {
424
+ console.log(chalk.dim(` ✓ Verified container "${container}" was removed`))
425
+ }
426
+ }
427
+ } catch (error: any) {
428
+ const errorDetails = error?.stderr || error?.message || String(error) || 'Unknown error'
429
+ // If container doesn't exist, that's okay - it might have been removed already
430
+ if (
431
+ errorDetails.toLowerCase().includes('no such container') ||
432
+ errorDetails.toLowerCase().includes('container not found')
433
+ ) {
434
+ console.log(chalk.yellow(` ⚠️ Container "${container}" was already removed`))
435
+ } else {
436
+ throw new Error(
437
+ `Failed to remove old container "${container}" for service "${service.name}": ${errorDetails}`
438
+ )
439
+ }
440
+ }
441
+ // Will create new container below with fresh image
442
+ }
443
+
444
+ // Create new container (either doesn't exist, or was recreated above)
445
+ if (shouldCreateNewContainer && image) {
446
+ // Pull the latest image before creating container
447
+ try {
448
+ console.log(chalk.dim(` 📥 Pulling latest image: ${image}...`))
449
+ await execa('docker', ['pull', image])
450
+ console.log(chalk.green(` ✅ Image pulled successfully: ${image}`))
451
+ } catch (error: any) {
452
+ // If pull fails, log warning but continue (image might be local or pull might fail)
453
+ const errorDetails = error?.stderr || error?.message || 'Unknown error'
454
+ console.log(
455
+ chalk.yellow(
456
+ ` ⚠️ Failed to pull image ${image}, using existing local image if available`
457
+ )
458
+ )
459
+ console.log(chalk.dim(` Error: ${errorDetails}`))
460
+ }
461
+
462
+ // Container doesn't exist and image is provided, create and run it
463
+ // First check if the host port is already in use
464
+ try {
465
+ const { stdout: portCheck } = await execa('docker', ['ps', '--format', '{{.Ports}}'])
466
+
467
+ // Check if port is already mapped
468
+ const portPattern = new RegExp(`:${service.port}->`, 'g')
469
+ if (portCheck && portPattern.test(portCheck)) {
470
+ throw new Error(
471
+ `Port ${service.port} is already in use by another container. Please use a different port for service "${service.name}".`
472
+ )
473
+ }
474
+ } catch (error) {
475
+ // If docker ps fails or port check fails, we'll let docker run handle it
476
+ // But if it's our custom error, rethrow it
477
+ if (error instanceof Error && error.message.includes('Port')) {
478
+ throw error
479
+ }
480
+ }
481
+
482
+ // Create Docker port binding: hostPort:containerPort
483
+ // service.port = host port (accessible from host machine)
484
+ // port = container port (what the app listens on inside container)
485
+ // Format: -p hostPort:containerPort
486
+ const args = [
487
+ 'run',
488
+ '-d',
489
+ '--name',
490
+ container,
491
+ '-p',
492
+ `${service.port}:${port}`, // Port binding: host:container
493
+ '--restart',
494
+ 'unless-stopped',
495
+ ]
496
+
497
+ // Add environment variables (merge .env vars, service-specific env vars, and CLI env vars)
498
+ addEnvVarsToDockerArgs(args, service, cliEnvVars)
499
+
500
+ args.push(image)
501
+
502
+ try {
503
+ await execa('docker', args)
504
+ console.log(chalk.green(` ✅ Created and started container: ${container}`))
505
+ } catch (error: any) {
506
+ // Extract error details from execa error
507
+ const errorMessage = error?.message || String(error) || 'Unknown error'
508
+ const errorStderr = error?.stderr || ''
509
+ const errorStdout = error?.stdout || ''
510
+
511
+ const fullError = [errorMessage, errorStderr, errorStdout]
512
+ .filter(Boolean)
513
+ .join('\n')
514
+ .toLowerCase()
515
+
516
+ // Check if error is due to port binding
517
+ if (
518
+ fullError.includes('port is already allocated') ||
519
+ fullError.includes('bind: address already in use') ||
520
+ fullError.includes('port already in use') ||
521
+ fullError.includes('port is already in use')
522
+ ) {
523
+ throw new Error(
524
+ `Port ${service.port} is already in use. Please use a different port for service "${service.name}".`
525
+ )
526
+ }
527
+
528
+ // Check if error is due to container name already in use
529
+ if (
530
+ fullError.includes('container name is already in use') ||
531
+ fullError.includes('is already in use')
532
+ ) {
533
+ throw new Error(
534
+ `Container name "${container}" is already in use. This might happen if the container was created between checks. ` +
535
+ `Please remove the container manually or wait a moment and try again.`
536
+ )
537
+ }
538
+
539
+ // Check if error is due to image not found
540
+ if (
541
+ fullError.includes('no such image') ||
542
+ fullError.includes('pull access denied') ||
543
+ fullError.includes('repository does not exist')
544
+ ) {
545
+ throw new Error(
546
+ `Docker image "${image}" not found or cannot be accessed. ` +
547
+ `Please verify the image name and ensure you have access to pull it.`
548
+ )
549
+ }
550
+
551
+ // Generic error with more details
552
+ const details = errorStderr || errorStdout || errorMessage
553
+ throw new Error(`Failed to create Docker container "${container}": ${details}`)
554
+ }
555
+ } else if (shouldCreateNewContainer && !image) {
556
+ // Only throw error if we need to create a container but no image is provided
557
+ throw new Error(
558
+ `Container "${container}" does not exist and no image specified in configuration. ` +
559
+ `Please either:\n` +
560
+ ` 1. Add an "image" field to the docker configuration for service "${service.name}", or\n` +
561
+ ` 2. Create the container "${container}" manually before deploying.`
562
+ )
563
+ }
564
+ // If shouldCreateNewContainer is false, it means we successfully handled an existing container
565
+ } catch (error: any) {
566
+ // If error is already a well-formed Error with a message, preserve it
567
+ if (error instanceof Error && error.message) {
568
+ // Check if the error message already includes context about the container/service
569
+ if (error.message.includes(container) || error.message.includes(service.name)) {
570
+ throw error
571
+ }
572
+ // Otherwise, wrap with more context
573
+ throw new Error(
574
+ `Failed to start Docker container "${container}" for service "${service.name}": ${error.message}`
575
+ )
576
+ }
577
+
578
+ // Handle non-Error objects or errors without messages
579
+ const errorDetails =
580
+ error?.message || error?.stderr || error?.stdout || String(error) || 'Unknown error'
581
+ throw new Error(
582
+ `Failed to start Docker container "${container}" for service "${service.name}": ${errorDetails}`
583
+ )
584
+ }
585
+ }
586
+
587
+ /**
588
+ * Stop a Docker container
589
+ */
590
+ export async function stopDockerContainer(containerName: string): Promise<void> {
591
+ try {
592
+ await execa('docker', ['stop', containerName])
593
+ } catch (error) {
594
+ throw new Error(
595
+ `Failed to stop container ${containerName}: ${error instanceof Error ? error.message : error}`
596
+ )
597
+ }
598
+ }
599
+
600
+ /**
601
+ * Remove a Docker container
602
+ */
603
+ export async function removeDockerContainer(containerName: string): Promise<void> {
604
+ try {
605
+ await execa('docker', ['rm', '-f', containerName])
606
+ } catch (error) {
607
+ throw new Error(
608
+ `Failed to remove container ${containerName}: ${
609
+ error instanceof Error ? error.message : error
610
+ }`
611
+ )
612
+ }
613
+ }
614
+
615
+ /**
616
+ * Check if a Docker container is running
617
+ */
618
+ export async function isContainerRunning(containerName: string): Promise<boolean> {
619
+ try {
620
+ const { stdout } = await execa('docker', [
621
+ 'ps',
622
+ '--filter',
623
+ `name=${containerName}`,
624
+ '--format',
625
+ '{{.Names}}',
626
+ ])
627
+ return stdout.includes(containerName)
628
+ } catch (error) {
629
+ return false
630
+ }
631
+ }
632
+
633
+ /**
634
+ * Get container logs
635
+ */
636
+ export async function getContainerLogs(
637
+ containerName: string,
638
+ lines: number = 100
639
+ ): Promise<string> {
640
+ try {
641
+ const { stdout } = await execa('docker', ['logs', '--tail', lines.toString(), containerName])
642
+ return stdout
643
+ } catch (error) {
644
+ throw new Error(
645
+ `Failed to get logs for container ${containerName}: ${
646
+ error instanceof Error ? error.message : error
647
+ }`
648
+ )
649
+ }
650
+ }
651
+
652
+ /**
653
+ * Inspect a Docker container
654
+ */
655
+ export async function inspectContainer(containerName: string): Promise<any> {
656
+ try {
657
+ const { stdout } = await execa('docker', ['inspect', containerName])
658
+ return JSON.parse(stdout)[0]
659
+ } catch (error) {
660
+ throw new Error(
661
+ `Failed to inspect container ${containerName}: ${
662
+ error instanceof Error ? error.message : error
663
+ }`
664
+ )
665
+ }
666
+ }
667
+
668
+ /**
669
+ * Get the port mapping for an existing container
670
+ * Returns the port mapping in format "hostPort:containerPort" or null if not found
671
+ */
672
+ export async function getContainerPortMapping(containerName: string): Promise<string | null> {
673
+ try {
674
+ const containerInfo = await inspectContainer(containerName)
675
+ const portBindings = containerInfo.NetworkSettings?.Ports
676
+
677
+ if (!portBindings) {
678
+ return null
679
+ }
680
+
681
+ // Find the first port binding
682
+ for (const [containerPort, hostBindings] of Object.entries(portBindings)) {
683
+ if (hostBindings && Array.isArray(hostBindings) && hostBindings.length > 0) {
684
+ const hostPort = hostBindings[0].HostPort
685
+ // Remove /tcp or /udp suffix from container port
686
+ const cleanContainerPort = containerPort.replace(/\/.*$/, '')
687
+ return `${hostPort}:${cleanContainerPort}`
688
+ }
689
+ }
690
+
691
+ return null
692
+ } catch (error) {
693
+ return null
694
+ }
695
+ }
696
+
697
+ /**
698
+ * Check if container needs to be recreated based on configuration changes
699
+ */
700
+ export async function needsRecreate(
701
+ service: ServiceConfig,
702
+ containerName: string
703
+ ): Promise<boolean> {
704
+ if (!service.docker) {
705
+ return false
706
+ }
707
+
708
+ const expectedPortMapping = `${service.port}:${service.docker.port}`
709
+ const currentPortMapping = await getContainerPortMapping(containerName)
710
+
711
+ // If port mapping is different, need to recreate
712
+ if (currentPortMapping !== expectedPortMapping) {
713
+ return true
714
+ }
715
+
716
+ // Check if image is different (if image is specified in config)
717
+ if (service.docker.image) {
718
+ try {
719
+ const containerInfo = await inspectContainer(containerName)
720
+ const currentImage = containerInfo.Config?.Image
721
+
722
+ if (currentImage && currentImage !== service.docker.image) {
723
+ return true
724
+ }
725
+ } catch (error) {
726
+ // If we can't check, assume no recreation needed
727
+ }
728
+ }
729
+
730
+ // Check if environment variables have changed
731
+ if (service.environment) {
732
+ try {
733
+ const containerInfo = await inspectContainer(containerName)
734
+ const currentEnv = containerInfo.Config?.Env || []
735
+
736
+ // Convert current env array to object
737
+ const currentEnvObj: Record<string, string> = {}
738
+ for (const envVar of currentEnv) {
739
+ const [key, ...valueParts] = envVar.split('=')
740
+ if (key) {
741
+ currentEnvObj[key] = valueParts.join('=')
742
+ }
743
+ }
744
+
745
+ // Compare with expected environment variables
746
+ for (const [key, value] of Object.entries(service.environment)) {
747
+ if (currentEnvObj[key] !== value) {
748
+ return true // Environment variable changed, need to recreate
749
+ }
750
+ }
751
+
752
+ // Check if any environment variables were removed
753
+ for (const key of Object.keys(currentEnvObj)) {
754
+ // Skip PATH and other system variables
755
+ if (key === 'PATH' || key === 'HOSTNAME' || key.startsWith('_')) {
756
+ continue
757
+ }
758
+ // If a variable exists in container but not in config, and it was explicitly set before
759
+ // we'll recreate to ensure consistency (this is a conservative approach)
760
+ // For now, we only check if config vars match, not if extra vars exist
761
+ }
762
+ } catch (error) {
763
+ // If we can't check, assume no recreation needed
764
+ }
765
+ }
766
+
767
+ return false
768
+ }