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,184 @@
1
+ import fs from 'fs-extra'
2
+ import yaml from 'js-yaml'
3
+ import { dirname, resolve } from 'path'
4
+ import type { DeployConfig } from '../types/config'
5
+ import { loadAndApplyEnvFiles } from './env-loader'
6
+
7
+ /**
8
+ * Substitute environment variables in a string
9
+ * Replaces ${VAR_NAME} or ${VAR_NAME:-default} patterns with values from envVars or process.env
10
+ *
11
+ * @param text The text containing variable references
12
+ * @param envVars Environment variables object
13
+ * @returns Text with variables substituted
14
+ */
15
+ function substituteEnvVars(text: string, envVars: Record<string, string>): string {
16
+ // Match ${VAR_NAME} or ${VAR_NAME:-default} patterns
17
+ return text.replace(/\$\{([^}:]+)(?::-([^}]*))?\}/g, (match, varName, defaultValue) => {
18
+ // Check envVars first, then process.env
19
+ const value = envVars[varName] || process.env[varName]
20
+
21
+ if (value !== undefined && value !== null) {
22
+ return value
23
+ }
24
+
25
+ // Use default value if provided
26
+ if (defaultValue !== undefined) {
27
+ return defaultValue
28
+ }
29
+
30
+ // Return original match if not found and no default
31
+ return match
32
+ })
33
+ }
34
+
35
+ /**
36
+ * Recursively substitute environment variables in an object
37
+ */
38
+ function substituteInObject(obj: any, envVars: Record<string, string>): any {
39
+ if (typeof obj === 'string') {
40
+ return substituteEnvVars(obj, envVars)
41
+ } else if (Array.isArray(obj)) {
42
+ return obj.map((item) => substituteInObject(item, envVars))
43
+ } else if (obj !== null && typeof obj === 'object') {
44
+ const result: any = {}
45
+ for (const [key, value] of Object.entries(obj)) {
46
+ result[key] = substituteInObject(value, envVars)
47
+ }
48
+ return result
49
+ }
50
+ return obj
51
+ }
52
+
53
+ /**
54
+ * Load and parse a YAML configuration file with environment variable substitution
55
+ * Variables in the format ${VAR_NAME} or ${VAR_NAME:-default} will be replaced
56
+ * with values from .env files or process.env
57
+ */
58
+ export async function loadConfig(filePath: string): Promise<DeployConfig> {
59
+ try {
60
+ // Load .env files from the directory containing the config file
61
+ const configDir = dirname(resolve(filePath))
62
+ const envVars = await loadAndApplyEnvFiles(configDir)
63
+
64
+ // Read the YAML file content
65
+ const fileContent = await fs.readFile(filePath, 'utf8')
66
+
67
+ // Substitute environment variables in the YAML content before parsing
68
+ const substitutedContent = substituteEnvVars(fileContent, envVars)
69
+
70
+ // Parse the YAML
71
+ const config = yaml.load(substitutedContent) as DeployConfig
72
+
73
+ // Also substitute in the parsed object (in case YAML parsing didn't handle strings properly)
74
+ const finalConfig = substituteInObject(config, envVars) as DeployConfig
75
+
76
+ validateConfig(finalConfig)
77
+
78
+ return finalConfig
79
+ } catch (error) {
80
+ if (error instanceof Error) {
81
+ throw new Error(`Failed to load configuration from ${filePath}: ${error.message}`)
82
+ }
83
+ throw error
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Validate the configuration object
89
+ */
90
+ function validateConfig(config: any): asserts config is DeployConfig {
91
+ if (!config.project || !config.project.name) {
92
+ throw new Error('Configuration must include project.name')
93
+ }
94
+
95
+ if (!config.services || !Array.isArray(config.services) || config.services.length === 0) {
96
+ throw new Error('Configuration must include at least one service')
97
+ }
98
+
99
+ // Track ports and container names to detect conflicts
100
+ const usedPorts = new Map<number, string[]>()
101
+ const usedContainers = new Map<string, string>()
102
+
103
+ for (const service of config.services) {
104
+ if (!service.name) {
105
+ throw new Error('Each service must have a name')
106
+ }
107
+ if (!service.port) {
108
+ throw new Error(`Service ${service.name} must have a port`)
109
+ }
110
+ if (!service.domains || !Array.isArray(service.domains) || service.domains.length === 0) {
111
+ throw new Error(`Service ${service.name} must have at least one domain`)
112
+ }
113
+
114
+ // Check for port conflicts
115
+ if (usedPorts.has(service.port)) {
116
+ const conflictingServices = usedPorts.get(service.port)!
117
+ throw new Error(
118
+ `Port conflict: Service "${service.name}" uses port ${
119
+ service.port
120
+ } which is already used by: ${conflictingServices.join(
121
+ ', '
122
+ )}. Each service must use a unique port.`
123
+ )
124
+ }
125
+ usedPorts.set(service.port, [service.name])
126
+
127
+ // Check for Docker container name conflicts
128
+ if (service.docker) {
129
+ const containerName = service.docker.container
130
+ if (usedContainers.has(containerName)) {
131
+ const conflictingService = usedContainers.get(containerName)!
132
+ throw new Error(
133
+ `Docker container name conflict: Service "${service.name}" uses container name "${containerName}" which is already used by service "${conflictingService}". Each Docker container must have a unique name.`
134
+ )
135
+ }
136
+ usedContainers.set(containerName, service.name)
137
+ }
138
+ }
139
+
140
+ // Check for duplicate service names
141
+ const serviceNames = new Set<string>()
142
+ for (const service of config.services) {
143
+ if (serviceNames.has(service.name)) {
144
+ throw new Error(
145
+ `Duplicate service name: "${service.name}" is used multiple times. Each service must have a unique name.`
146
+ )
147
+ }
148
+ serviceNames.add(service.name)
149
+ }
150
+
151
+ if (!config.nginx) {
152
+ config.nginx = {
153
+ configPath: '/etc/nginx/sites-available',
154
+ reloadCommand: 'sudo nginx -t && sudo systemctl reload nginx',
155
+ }
156
+ }
157
+
158
+ if (!config.certbot) {
159
+ config.certbot = {
160
+ email: '',
161
+ staging: false,
162
+ }
163
+ }
164
+
165
+ if (!config.deployment) {
166
+ config.deployment = {
167
+ strategy: 'rolling',
168
+ healthCheckTimeout: 30000,
169
+ }
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Save configuration to a YAML file
175
+ */
176
+ export async function saveConfig(filePath: string, config: DeployConfig): Promise<void> {
177
+ const yamlContent = yaml.dump(config, {
178
+ indent: 2,
179
+ lineWidth: 120,
180
+ noRefs: true,
181
+ })
182
+
183
+ await fs.writeFile(filePath, yamlContent, 'utf8')
184
+ }
@@ -0,0 +1,157 @@
1
+ import type { DeploymentConfig, ServiceConfig } from '../types/config'
2
+ import type { ZeroDowntimeContainerInfo } from './docker'
3
+
4
+ /**
5
+ * Perform a health check on a service endpoint
6
+ */
7
+ export async function performHealthCheck(url: string, timeout: number = 30000): Promise<boolean> {
8
+ const startTime = Date.now()
9
+ const interval = 2000 // Check every 2 seconds
10
+
11
+ while (Date.now() - startTime < timeout) {
12
+ try {
13
+ const response = await fetch(url, {
14
+ method: 'GET',
15
+ signal: AbortSignal.timeout(5000), // 5 second timeout per request
16
+ })
17
+
18
+ if (response.ok) {
19
+ return true
20
+ }
21
+ } catch (error) {
22
+ // Endpoint not ready yet, continue waiting
23
+ }
24
+
25
+ // Wait before next check
26
+ await new Promise((resolve) => setTimeout(resolve, interval))
27
+ }
28
+
29
+ return false
30
+ }
31
+
32
+ /**
33
+ * Deploy a service with zero-downtime strategy
34
+ */
35
+ export async function deployService(
36
+ service: ServiceConfig,
37
+ deploymentConfig: DeploymentConfig,
38
+ tempInfo: ZeroDowntimeContainerInfo | null = null
39
+ ): Promise<void> {
40
+ if (deploymentConfig.strategy === 'rolling') {
41
+ await rollingDeploy(service, deploymentConfig, tempInfo)
42
+ } else if (deploymentConfig.strategy === 'blue-green') {
43
+ await blueGreenDeploy(service, deploymentConfig, tempInfo)
44
+ } else {
45
+ throw new Error(`Unknown deployment strategy: ${deploymentConfig.strategy}`)
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Rolling deployment strategy
51
+ * For single instance, uses zero-downtime approach similar to blue-green
52
+ */
53
+ async function rollingDeploy(
54
+ service: ServiceConfig,
55
+ deploymentConfig: DeploymentConfig,
56
+ tempInfo: ZeroDowntimeContainerInfo | null
57
+ ): Promise<void> {
58
+ // For rolling deployment with single instance:
59
+ // Similar to blue-green - use temporary container and port
60
+
61
+ if (!tempInfo || !tempInfo.oldContainerExists) {
62
+ // No existing container, just check health on the new container
63
+ if (service.healthCheck) {
64
+ const healthUrl = `http://localhost:${service.port}${service.healthCheck.path}`
65
+ const isHealthy = await performHealthCheck(healthUrl, deploymentConfig.healthCheckTimeout)
66
+
67
+ if (!isHealthy) {
68
+ throw new Error(`Service ${service.name} failed health check during rolling deployment`)
69
+ }
70
+ }
71
+ } else {
72
+ // Check health on temporary port
73
+ if (service.healthCheck) {
74
+ const healthUrl = `http://localhost:${tempInfo.tempPort}${service.healthCheck.path}`
75
+ const isHealthy = await performHealthCheck(healthUrl, deploymentConfig.healthCheckTimeout)
76
+
77
+ if (!isHealthy) {
78
+ throw new Error(
79
+ `Service ${service.name} failed health check on temporary container during rolling deployment`
80
+ )
81
+ }
82
+ }
83
+ }
84
+
85
+ // Add a small delay to ensure service is fully ready
86
+ await new Promise((resolve) => setTimeout(resolve, 2000))
87
+ }
88
+
89
+ /**
90
+ * Blue-green deployment strategy for single instance
91
+ * Uses temporary container and port for zero-downtime deployment
92
+ */
93
+ async function blueGreenDeploy(
94
+ service: ServiceConfig,
95
+ deploymentConfig: DeploymentConfig,
96
+ tempInfo: ZeroDowntimeContainerInfo | null
97
+ ): Promise<void> {
98
+ // For blue-green deployment with single instance:
99
+ // 1. New container is already started on temporary port (handled in deploy command)
100
+ // 2. Run health checks on new container
101
+ // 3. Switch nginx to new port (handled in deploy command)
102
+ // 4. Stop old container and promote new one (handled in deploy command)
103
+
104
+ if (!tempInfo || !tempInfo.oldContainerExists) {
105
+ // No existing container, just check health on the new container
106
+ if (service.healthCheck) {
107
+ const healthUrl = `http://localhost:${service.port}${service.healthCheck.path}`
108
+ const isHealthy = await performHealthCheck(healthUrl, deploymentConfig.healthCheckTimeout)
109
+
110
+ if (!isHealthy) {
111
+ throw new Error(`Service ${service.name} failed health check during blue-green deployment`)
112
+ }
113
+ }
114
+ } else {
115
+ // Check health on temporary port
116
+ if (service.healthCheck) {
117
+ const healthUrl = `http://localhost:${tempInfo.tempPort}${service.healthCheck.path}`
118
+ const isHealthy = await performHealthCheck(healthUrl, deploymentConfig.healthCheckTimeout)
119
+
120
+ if (!isHealthy) {
121
+ throw new Error(
122
+ `Service ${service.name} failed health check on temporary container during blue-green deployment`
123
+ )
124
+ }
125
+ }
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Wait for a service to become healthy
131
+ */
132
+ export async function waitForService(
133
+ service: ServiceConfig,
134
+ timeout: number = 60000
135
+ ): Promise<boolean> {
136
+ if (!service.healthCheck) {
137
+ // No health check configured, assume service is ready after a short delay
138
+ await new Promise((resolve) => setTimeout(resolve, 5000))
139
+ return true
140
+ }
141
+
142
+ const healthUrl = `http://localhost:${service.port}${service.healthCheck.path}`
143
+ return await performHealthCheck(healthUrl, timeout)
144
+ }
145
+
146
+ /**
147
+ * Gracefully shutdown a service
148
+ */
149
+ export async function gracefulShutdown(
150
+ _service: ServiceConfig,
151
+ timeout: number = 30000
152
+ ): Promise<void> {
153
+ // Send shutdown signal and wait for graceful termination
154
+ // This is a placeholder - actual implementation would depend on how services are managed
155
+
156
+ await new Promise((resolve) => setTimeout(resolve, Math.min(timeout, 5000)))
157
+ }