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,271 @@
1
+ import chalk from 'chalk'
2
+ import fs from 'fs-extra'
3
+ import type { ServiceConfig } from '../types/config'
4
+ import { loadConfig } from '../utils/config-loader'
5
+ import { waitForService } from '../utils/deployment'
6
+ import { isContainerRunning, startDockerContainer } from '../utils/docker'
7
+ import {
8
+ enableSite,
9
+ generateMultiServiceNginxConfig,
10
+ generateNginxConfig,
11
+ reloadNginx,
12
+ writeNginxConfig,
13
+ } from '../utils/nginx'
14
+
15
+ interface UpOptions {
16
+ file: string
17
+ all: boolean
18
+ serviceName?: string
19
+ https: boolean
20
+ nginx: boolean
21
+ }
22
+
23
+ export async function upCommand(options: UpOptions): Promise<void> {
24
+ console.log(chalk.blue.bold('\n🚀 Bringing Up Services\n'))
25
+
26
+ try {
27
+ // Load configuration (this will also load .env files for variable substitution)
28
+ if (!(await fs.pathExists(options.file))) {
29
+ throw new Error(`Configuration file not found: ${options.file}`)
30
+ }
31
+
32
+ console.log(chalk.cyan(`📄 Loading configuration from ${options.file}...`))
33
+ const config = await loadConfig(options.file)
34
+
35
+ console.log(chalk.green(`✅ Configuration loaded for project: ${config.project.name}`))
36
+
37
+ // Determine which services to bring up
38
+ let servicesToUp: ServiceConfig[] = []
39
+
40
+ if (options.all) {
41
+ servicesToUp = config.services
42
+ console.log(
43
+ chalk.cyan(`📋 Bringing up all services: ${servicesToUp.map((s) => s.name).join(', ')}\n`)
44
+ )
45
+ } else 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
+ servicesToUp = [service]
57
+ console.log(chalk.cyan(`📋 Bringing up service: ${options.serviceName}\n`))
58
+ } else {
59
+ throw new Error('Either specify a service name or use --all flag')
60
+ }
61
+
62
+ // Group services by domain for nginx config management
63
+ const domainToServices = new Map<string, ServiceConfig[]>()
64
+ const allDomains = new Set<string>()
65
+
66
+ for (const service of servicesToUp) {
67
+ for (const domain of service.domains) {
68
+ allDomains.add(domain)
69
+ if (!domainToServices.has(domain)) {
70
+ domainToServices.set(domain, [])
71
+ }
72
+ domainToServices.get(domain)!.push(service)
73
+ }
74
+ }
75
+
76
+ // Start Docker containers
77
+ for (const service of servicesToUp) {
78
+ if (service.docker) {
79
+ console.log(chalk.cyan(`\n🐳 Starting Docker container for service: ${service.name}`))
80
+ try {
81
+ await startDockerContainer(service)
82
+ console.log(chalk.green(` ✅ Container started for service: ${service.name}`))
83
+ } catch (error) {
84
+ console.error(
85
+ chalk.red(` ❌ Failed to start container for service ${service.name}:`),
86
+ error instanceof Error ? error.message : error
87
+ )
88
+ throw error
89
+ }
90
+ }
91
+ }
92
+
93
+ // Wait for services to be healthy
94
+ for (const service of servicesToUp) {
95
+ if (service.healthCheck) {
96
+ console.log(chalk.cyan(`\n🏥 Waiting for service ${service.name} to be healthy...`))
97
+ const isHealthy = await waitForService(service, config.deployment.healthCheckTimeout)
98
+ if (isHealthy) {
99
+ console.log(chalk.green(` ✅ Service ${service.name} is healthy`))
100
+ } else {
101
+ console.log(
102
+ chalk.yellow(` ⚠️ Service ${service.name} health check timeout, continuing anyway...`)
103
+ )
104
+ }
105
+ }
106
+ }
107
+
108
+ // Configure Nginx
109
+ if (options.nginx && allDomains.size > 0) {
110
+ console.log(chalk.cyan(`\n⚙️ Configuring Nginx reverse proxy...`))
111
+
112
+ // Create a set of service names being brought up for quick lookup
113
+ const servicesBeingUpped = new Set(servicesToUp.map((s) => s.name))
114
+
115
+ // For each domain, find all services that use it (from all config services)
116
+ // and include all active services (both newly brought up and already running)
117
+ for (const domain of allDomains) {
118
+ const configName = domain.replace(/\./g, '_')
119
+
120
+ // Find all services that use this domain (from entire config)
121
+ const allServicesForDomain = config.services.filter((s) => s.domains.includes(domain))
122
+
123
+ // Check which services are actually running (have active containers)
124
+ const activeServices: ServiceConfig[] = []
125
+ for (const service of allServicesForDomain) {
126
+ if (service.docker) {
127
+ const isRunning = await isContainerRunning(service.docker.container)
128
+ if (isRunning) {
129
+ activeServices.push(service)
130
+ }
131
+ } else {
132
+ // Service without docker - include if it's being brought up or assume it's running
133
+ if (servicesBeingUpped.has(service.name)) {
134
+ activeServices.push(service)
135
+ } else {
136
+ // For non-docker services, assume they're running if not explicitly being brought up
137
+ // This is a best-effort approach
138
+ activeServices.push(service)
139
+ }
140
+ }
141
+ }
142
+
143
+ if (activeServices.length === 0) {
144
+ console.log(
145
+ chalk.yellow(` ⚠️ No active services found for ${domain}, skipping Nginx config`)
146
+ )
147
+ continue
148
+ }
149
+
150
+ try {
151
+ // Log domain and services configuration
152
+ if (activeServices.length > 1) {
153
+ console.log(
154
+ chalk.cyan(
155
+ ` 📋 Configuring ${domain} with ${
156
+ activeServices.length
157
+ } active service(s): ${activeServices.map((s) => s.name).join(', ')}`
158
+ )
159
+ )
160
+ } else {
161
+ console.log(
162
+ chalk.cyan(` 📋 Configuring ${domain} for service: ${activeServices[0].name}`)
163
+ )
164
+ }
165
+
166
+ // Generate Nginx config for all active services on this domain
167
+ let nginxConfigContent: string
168
+ if (activeServices.length === 1) {
169
+ nginxConfigContent = generateNginxConfig(activeServices[0], false)
170
+ } else {
171
+ nginxConfigContent = generateMultiServiceNginxConfig(activeServices, domain, false)
172
+ }
173
+
174
+ // Write config file
175
+ await writeNginxConfig(configName, config.nginx.configPath, nginxConfigContent)
176
+ await enableSite(configName, config.nginx.configPath)
177
+
178
+ console.log(chalk.green(` ✅ Nginx configured for ${domain}`))
179
+ } catch (error) {
180
+ console.error(
181
+ chalk.red(` ❌ Failed to configure Nginx for ${domain}:`),
182
+ error instanceof Error ? error.message : error
183
+ )
184
+ throw error
185
+ }
186
+ }
187
+
188
+ // Update with HTTPS if enabled
189
+ if (options.https) {
190
+ console.log(chalk.cyan(`\n🔄 Updating Nginx configs with HTTPS...`))
191
+ for (const domain of allDomains) {
192
+ const configName = domain.replace(/\./g, '_')
193
+
194
+ // Find all active services for this domain again
195
+ const allServicesForDomain = config.services.filter((s) => s.domains.includes(domain))
196
+ const activeServices: ServiceConfig[] = []
197
+ for (const service of allServicesForDomain) {
198
+ if (service.docker) {
199
+ const isRunning = await isContainerRunning(service.docker.container)
200
+ if (isRunning) {
201
+ activeServices.push(service)
202
+ }
203
+ } else {
204
+ if (servicesBeingUpped.has(service.name)) {
205
+ activeServices.push(service)
206
+ } else {
207
+ activeServices.push(service)
208
+ }
209
+ }
210
+ }
211
+
212
+ if (activeServices.length === 0) {
213
+ continue
214
+ }
215
+
216
+ try {
217
+ let nginxConfigContent: string
218
+ if (activeServices.length === 1) {
219
+ nginxConfigContent = generateNginxConfig(activeServices[0], true)
220
+ } else {
221
+ nginxConfigContent = generateMultiServiceNginxConfig(activeServices, domain, true)
222
+ }
223
+ await writeNginxConfig(configName, config.nginx.configPath, nginxConfigContent)
224
+ console.log(chalk.green(` ✅ HTTPS config updated for ${domain}`))
225
+ } catch (error) {
226
+ console.error(
227
+ chalk.red(` ❌ Failed to update HTTPS config for ${domain}:`),
228
+ error instanceof Error ? error.message : error
229
+ )
230
+ throw error
231
+ }
232
+ }
233
+ }
234
+
235
+ // Reload Nginx
236
+ console.log(chalk.cyan(`\n🔄 Reloading Nginx...`))
237
+ try {
238
+ await reloadNginx(config.nginx.reloadCommand)
239
+ console.log(chalk.green(` ✅ Nginx reloaded`))
240
+ } catch (error) {
241
+ console.error(
242
+ chalk.red(` ❌ Failed to reload Nginx:`),
243
+ error instanceof Error ? error.message : error
244
+ )
245
+ throw error
246
+ }
247
+ }
248
+
249
+ console.log(chalk.green.bold('\n✅ Services brought up successfully!\n'))
250
+
251
+ // Print service URLs
252
+ if (allDomains.size > 0) {
253
+ console.log(chalk.cyan('📋 Service URLs:'))
254
+ for (const service of servicesToUp) {
255
+ for (const domain of service.domains) {
256
+ const protocol = options.https ? 'https' : 'http'
257
+ const servicePath = service.path || '/'
258
+ const fullPath = servicePath === '/' ? '' : servicePath
259
+ console.log(chalk.dim(` ${service.name}: ${protocol}://${domain}${fullPath}`))
260
+ }
261
+ }
262
+ console.log()
263
+ }
264
+ } catch (error) {
265
+ console.error(
266
+ chalk.red('\n❌ Failed to bring up services:'),
267
+ error instanceof Error ? error.message : error
268
+ )
269
+ process.exit(1)
270
+ }
271
+ }
package/src/index.ts ADDED
@@ -0,0 +1,109 @@
1
+ import { Command } from 'commander'
2
+ import { readFileSync } from 'fs'
3
+ import { dirname, join } from 'path'
4
+ import { fileURLToPath } from 'url'
5
+ import { deployCommand } from './commands/deploy'
6
+ import { downCommand } from './commands/down'
7
+ import { initCommand } from './commands/init'
8
+ import { setupCommand } from './commands/setup'
9
+ import { upCommand } from './commands/up'
10
+
11
+ const __filename = fileURLToPath(import.meta.url)
12
+ const __dirname = dirname(__filename)
13
+ const packageJsonPath = join(__dirname, '..', 'package.json')
14
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'))
15
+
16
+ const program = new Command()
17
+
18
+ program
19
+ .name('suthep')
20
+ .description('CLI tool for deploying projects with automatic Nginx reverse proxy and HTTPS setup')
21
+ .version(packageJson.version)
22
+
23
+ program
24
+ .command('init')
25
+ .description('Initialize a new deployment configuration file')
26
+ .option('-f, --file <path>', 'Configuration file path', 'suthep.yml')
27
+ .action(initCommand)
28
+
29
+ program
30
+ .command('setup')
31
+ .description('Setup Nginx and Certbot on the system')
32
+ .option('--nginx-only', 'Only setup Nginx')
33
+ .option('--certbot-only', 'Only setup Certbot')
34
+ .action(setupCommand)
35
+
36
+ program
37
+ .command('deploy')
38
+ .description('Deploy a project using the configuration file')
39
+ .option('-f, --file <path>', 'Configuration file path', 'suthep.yml')
40
+ .option('--no-https', 'Skip HTTPS setup')
41
+ .option('--no-nginx', 'Skip Nginx configuration')
42
+ .option(
43
+ '-e, --env <key=value>',
44
+ 'Set environment variables (can be used multiple times, e.g., -e KEY1=value1 -e KEY2=value2)',
45
+ (value, previous: string[] = []) => {
46
+ return [...previous, value]
47
+ },
48
+ []
49
+ )
50
+ .argument('[service-name]', 'Name of the service to deploy (deploys all if not specified)')
51
+ .action((serviceName, options: any) => {
52
+ // Parse environment variables from CLI
53
+ const cliEnvVars: Record<string, string> = {}
54
+ if (options.env && Array.isArray(options.env)) {
55
+ for (const envVar of options.env) {
56
+ const equalsIndex = envVar.indexOf('=')
57
+ if (equalsIndex === -1 || equalsIndex === 0) {
58
+ throw new Error(
59
+ `Invalid environment variable format: ${envVar}. Expected KEY=VALUE (e.g., -e KEY=value)`
60
+ )
61
+ }
62
+ const key = envVar.substring(0, equalsIndex)
63
+ const value = envVar.substring(equalsIndex + 1)
64
+ cliEnvVars[key] = value
65
+ }
66
+ }
67
+
68
+ deployCommand({
69
+ file: options.file || 'suthep.yml',
70
+ https: options.https !== false,
71
+ nginx: options.nginx !== false,
72
+ serviceName: serviceName,
73
+ cliEnvVars,
74
+ })
75
+ })
76
+
77
+ program
78
+ .command('down')
79
+ .description('Bring down services (stop containers and disable Nginx configs)')
80
+ .option('-f, --file <path>', 'Configuration file path', 'suthep.yml')
81
+ .option('--all', 'Bring down all services', false)
82
+ .argument('[service-name]', 'Name of the service to bring down')
83
+ .action((serviceName, options) => {
84
+ downCommand({
85
+ file: options.file || 'suthep.yml',
86
+ all: options.all || false,
87
+ serviceName: serviceName,
88
+ })
89
+ })
90
+
91
+ program
92
+ .command('up')
93
+ .description('Bring up services (start containers and enable Nginx configs)')
94
+ .option('-f, --file <path>', 'Configuration file path', 'suthep.yml')
95
+ .option('--all', 'Bring up all services', false)
96
+ .option('--no-https', 'Skip HTTPS setup')
97
+ .option('--no-nginx', 'Skip Nginx configuration')
98
+ .argument('[service-name]', 'Name of the service to bring up')
99
+ .action((serviceName, options) => {
100
+ upCommand({
101
+ file: options.file || 'suthep.yml',
102
+ all: options.all || false,
103
+ serviceName: serviceName,
104
+ https: options.https !== false,
105
+ nginx: options.nginx !== false,
106
+ })
107
+ })
108
+
109
+ program.parse()
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Configuration type definitions for the Suthep deployment tool
3
+ */
4
+
5
+ export interface ProjectConfig {
6
+ name: string
7
+ version: string
8
+ }
9
+
10
+ export interface HealthCheckConfig {
11
+ path: string
12
+ interval: number
13
+ }
14
+
15
+ export interface DockerConfig {
16
+ image?: string
17
+ container: string
18
+ port: number
19
+ }
20
+
21
+ export interface ServiceConfig {
22
+ name: string
23
+ port: number
24
+ domains: string[]
25
+ path?: string // Path prefix for this service (e.g., '/api', '/'). Defaults to '/'
26
+ docker?: DockerConfig
27
+ healthCheck?: HealthCheckConfig
28
+ environment?: Record<string, string>
29
+ }
30
+
31
+ export interface NginxConfig {
32
+ configPath: string
33
+ reloadCommand: string
34
+ }
35
+
36
+ export interface CertbotConfig {
37
+ email: string
38
+ staging: boolean
39
+ }
40
+
41
+ export interface DeploymentConfig {
42
+ strategy: 'rolling' | 'blue-green'
43
+ healthCheckTimeout: number
44
+ }
45
+
46
+ export interface DeployConfig {
47
+ project: ProjectConfig
48
+ services: ServiceConfig[]
49
+ nginx: NginxConfig
50
+ certbot: CertbotConfig
51
+ deployment: DeploymentConfig
52
+ }
@@ -0,0 +1,222 @@
1
+ import { execa } from 'execa'
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
3
+ import {
4
+ certificateExists,
5
+ checkCertificateExpiration,
6
+ renewCertificates,
7
+ requestCertificate,
8
+ revokeCertificate,
9
+ } from '../certbot'
10
+
11
+ // Mock execa
12
+ vi.mock('execa', () => ({
13
+ execa: vi.fn(),
14
+ }))
15
+
16
+ describe('certbot', () => {
17
+ beforeEach(() => {
18
+ vi.clearAllMocks()
19
+ })
20
+
21
+ describe('certificateExists', () => {
22
+ it('should return true if certificate files exist', async () => {
23
+ vi.mocked(execa)
24
+ .mockResolvedValueOnce({ stdout: '', stderr: '' } as any) // test fullchain.pem
25
+ .mockResolvedValueOnce({ stdout: '', stderr: '' } as any) // test privkey.pem
26
+
27
+ const result = await certificateExists('example.com')
28
+
29
+ expect(execa).toHaveBeenCalledWith('sudo', [
30
+ 'test',
31
+ '-f',
32
+ '/etc/letsencrypt/live/example.com/fullchain.pem',
33
+ ])
34
+ expect(execa).toHaveBeenCalledWith('sudo', [
35
+ 'test',
36
+ '-f',
37
+ '/etc/letsencrypt/live/example.com/privkey.pem',
38
+ ])
39
+ expect(result).toBe(true)
40
+ })
41
+
42
+ it('should return false if certificate files do not exist', async () => {
43
+ vi.mocked(execa).mockRejectedValue(new Error('File not found'))
44
+
45
+ const result = await certificateExists('example.com')
46
+
47
+ expect(result).toBe(false)
48
+ })
49
+
50
+ it('should fallback to certbot certificates command if file test fails', async () => {
51
+ vi.mocked(execa)
52
+ .mockRejectedValueOnce(new Error('File not found')) // test fullchain.pem fails
53
+ .mockResolvedValueOnce({
54
+ stdout: 'Certificate Name: example.com\nDomains: example.com',
55
+ stderr: '',
56
+ } as any) // certbot certificates
57
+
58
+ const result = await certificateExists('example.com')
59
+
60
+ expect(execa).toHaveBeenCalledWith('sudo', ['certbot', 'certificates'])
61
+ expect(result).toBe(true)
62
+ })
63
+
64
+ it('should return false if certbot command also fails', async () => {
65
+ vi.mocked(execa)
66
+ .mockRejectedValueOnce(new Error('File not found')) // test fullchain.pem fails
67
+ .mockRejectedValueOnce(new Error('Certbot error')) // certbot certificates fails
68
+
69
+ const result = await certificateExists('example.com')
70
+
71
+ expect(result).toBe(false)
72
+ })
73
+ })
74
+
75
+ describe('requestCertificate', () => {
76
+ it('should request certificate with correct arguments', async () => {
77
+ // Mock certificateExists to return false (certificate doesn't exist)
78
+ vi.mocked(execa)
79
+ .mockRejectedValueOnce(new Error('File not found')) // certificateExists - file test fails
80
+ .mockRejectedValueOnce(new Error('Certbot error')) // certificateExists - certbot check fails
81
+ .mockResolvedValueOnce({ stdout: '', stderr: '' } as any) // certbot certonly
82
+
83
+ await requestCertificate('example.com', 'admin@example.com', false)
84
+
85
+ expect(execa).toHaveBeenCalledWith('sudo', [
86
+ 'certbot',
87
+ 'certonly',
88
+ '--nginx',
89
+ '-d',
90
+ 'example.com',
91
+ '--non-interactive',
92
+ '--agree-tos',
93
+ '--email',
94
+ 'admin@example.com',
95
+ ])
96
+ })
97
+
98
+ it('should include --staging flag when staging is true', async () => {
99
+ // Mock certificateExists to return false (certificate doesn't exist)
100
+ vi.mocked(execa)
101
+ .mockRejectedValueOnce(new Error('File not found')) // certificateExists - file test fails
102
+ .mockRejectedValueOnce(new Error('Certbot error')) // certificateExists - certbot check fails
103
+ .mockResolvedValueOnce({ stdout: '', stderr: '' } as any) // certbot certonly
104
+
105
+ await requestCertificate('example.com', 'admin@example.com', true)
106
+
107
+ expect(execa).toHaveBeenCalledWith(
108
+ 'sudo',
109
+ expect.arrayContaining(['certbot', 'certonly', '--staging'])
110
+ )
111
+ })
112
+
113
+ it('should throw error if certificate already exists', async () => {
114
+ // Mock certificateExists to return true (both file checks succeed)
115
+ vi.mocked(execa)
116
+ .mockResolvedValueOnce({ stdout: '', stderr: '' } as any) // certificateExists - fullchain.pem exists
117
+ .mockResolvedValueOnce({ stdout: '', stderr: '' } as any) // certificateExists - privkey.pem exists
118
+
119
+ await expect(requestCertificate('example.com', 'admin@example.com', false)).rejects.toThrow(
120
+ 'Certificate for example.com already exists'
121
+ )
122
+ })
123
+
124
+ it('should handle certificate already exists error from certbot', async () => {
125
+ // Mock certificateExists to return false, but certbot returns already exists error
126
+ vi.mocked(execa)
127
+ .mockRejectedValueOnce(new Error('File not found')) // certificateExists - file test fails
128
+ .mockRejectedValueOnce(new Error('Certbot error')) // certificateExists - certbot check fails
129
+ .mockRejectedValueOnce({
130
+ stderr: 'Certificate already exists for example.com',
131
+ message: 'Certificate already exists',
132
+ } as any)
133
+
134
+ await expect(requestCertificate('example.com', 'admin@example.com', false)).rejects.toThrow(
135
+ 'Certificate for example.com already exists'
136
+ )
137
+ })
138
+
139
+ it('should throw error with details if certificate request fails', async () => {
140
+ vi.mocked(execa)
141
+ .mockResolvedValueOnce({ stdout: '', stderr: '' } as any) // certificateExists check
142
+ .mockRejectedValueOnce({
143
+ stderr: 'DNS validation failed',
144
+ message: 'Validation error',
145
+ } as any)
146
+
147
+ await expect(requestCertificate('example.com', 'admin@example.com', false)).rejects.toThrow(
148
+ 'Failed to obtain SSL certificate for example.com'
149
+ )
150
+ })
151
+ })
152
+
153
+ describe('renewCertificates', () => {
154
+ it('should renew certificates with correct command', async () => {
155
+ vi.mocked(execa).mockResolvedValue({ stdout: '', stderr: '' } as any)
156
+
157
+ await renewCertificates()
158
+
159
+ expect(execa).toHaveBeenCalledWith('sudo', ['certbot', 'renew', '--quiet'])
160
+ })
161
+
162
+ it('should throw error if renewal fails', async () => {
163
+ vi.mocked(execa).mockRejectedValue(new Error('Renewal failed'))
164
+
165
+ await expect(renewCertificates()).rejects.toThrow('Failed to renew SSL certificates')
166
+ })
167
+ })
168
+
169
+ describe('checkCertificateExpiration', () => {
170
+ it('should return expiration date if certificate exists', async () => {
171
+ const mockStdout = 'Expiry Date: 2024-12-31 23:59:59+00:00'
172
+ vi.mocked(execa).mockResolvedValue({ stdout: mockStdout, stderr: '' } as any)
173
+
174
+ const result = await checkCertificateExpiration('example.com')
175
+
176
+ expect(execa).toHaveBeenCalledWith('sudo', ['certbot', 'certificates', '-d', 'example.com'])
177
+ expect(result).toBeInstanceOf(Date)
178
+ // Check that it's a valid date - the exact year might vary based on parsing
179
+ expect(result).not.toBeNull()
180
+ })
181
+
182
+ it('should return null if expiration date cannot be parsed', async () => {
183
+ vi.mocked(execa).mockResolvedValue({ stdout: 'No expiry date found', stderr: '' } as any)
184
+
185
+ const result = await checkCertificateExpiration('example.com')
186
+
187
+ expect(result).toBeNull()
188
+ })
189
+
190
+ it('should return null if command fails', async () => {
191
+ vi.mocked(execa).mockRejectedValue(new Error('Command failed'))
192
+
193
+ const result = await checkCertificateExpiration('example.com')
194
+
195
+ expect(result).toBeNull()
196
+ })
197
+ })
198
+
199
+ describe('revokeCertificate', () => {
200
+ it('should revoke certificate with correct command', async () => {
201
+ vi.mocked(execa).mockResolvedValue({ stdout: '', stderr: '' } as any)
202
+
203
+ await revokeCertificate('example.com')
204
+
205
+ expect(execa).toHaveBeenCalledWith('sudo', [
206
+ 'certbot',
207
+ 'revoke',
208
+ '-d',
209
+ 'example.com',
210
+ '--non-interactive',
211
+ ])
212
+ })
213
+
214
+ it('should throw error if revocation fails', async () => {
215
+ vi.mocked(execa).mockRejectedValue(new Error('Revocation failed'))
216
+
217
+ await expect(revokeCertificate('example.com')).rejects.toThrow(
218
+ 'Failed to revoke certificate for example.com'
219
+ )
220
+ })
221
+ })
222
+ })