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,412 @@
1
+ import { execa } from 'execa'
2
+ import fs from 'fs-extra'
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
4
+ import type { ServiceConfig } from '../../types/config'
5
+ import {
6
+ configExists,
7
+ disableSite,
8
+ enableSite,
9
+ generateMultiServiceNginxConfig,
10
+ generateNginxConfig,
11
+ reloadNginx,
12
+ writeNginxConfig,
13
+ } from '../nginx'
14
+
15
+ // Mock fs-extra
16
+ vi.mock('fs-extra', () => ({
17
+ default: {
18
+ pathExists: vi.fn(),
19
+ remove: vi.fn(),
20
+ writeFile: vi.fn(),
21
+ ensureDir: vi.fn(),
22
+ },
23
+ }))
24
+
25
+ // Mock execa
26
+ vi.mock('execa', () => ({
27
+ execa: vi.fn(),
28
+ }))
29
+
30
+ describe('nginx', () => {
31
+ beforeEach(() => {
32
+ vi.clearAllMocks()
33
+ })
34
+
35
+ describe('generateNginxConfig', () => {
36
+ it('should generate HTTP-only config for service', () => {
37
+ const service: ServiceConfig = {
38
+ name: 'api',
39
+ port: 3000,
40
+ domains: ['api.example.com'],
41
+ }
42
+
43
+ const config = generateNginxConfig(service, false)
44
+
45
+ expect(config).toContain('server_name api.example.com')
46
+ expect(config).toContain('listen 80')
47
+ expect(config).toContain('server localhost:3000')
48
+ expect(config).not.toContain('listen 443')
49
+ expect(config).not.toContain('ssl_certificate')
50
+ })
51
+
52
+ it('should generate HTTPS config with redirect for service', () => {
53
+ const service: ServiceConfig = {
54
+ name: 'api',
55
+ port: 3000,
56
+ domains: ['api.example.com'],
57
+ }
58
+
59
+ const config = generateNginxConfig(service, true)
60
+
61
+ expect(config).toContain('listen 443 ssl http2')
62
+ expect(config).toContain('ssl_certificate')
63
+ expect(config).toContain('/etc/letsencrypt/live/api.example.com/fullchain.pem')
64
+ expect(config).toContain('return 301 https://$server_name$request_uri')
65
+ })
66
+
67
+ it('should combine www and root domain in same server_name', () => {
68
+ const service: ServiceConfig = {
69
+ name: 'web',
70
+ port: 3000,
71
+ domains: ['muacle.com', 'www.muacle.com'],
72
+ }
73
+
74
+ const config = generateNginxConfig(service, false)
75
+
76
+ // Should combine both www and non-www in same server_name
77
+ expect(config).toContain('server_name www.muacle.com muacle.com')
78
+ expect(config).not.toContain('return 301')
79
+ })
80
+
81
+ it('should not redirect subdomains', () => {
82
+ const service: ServiceConfig = {
83
+ name: 'api',
84
+ port: 3000,
85
+ domains: ['dev.muacle.com'],
86
+ }
87
+
88
+ const config = generateNginxConfig(service, false)
89
+
90
+ // Subdomains should not have redirect logic
91
+ expect(config).toContain('server_name dev.muacle.com')
92
+ expect(config).not.toContain('return 301')
93
+ })
94
+
95
+ it('should include health check location if configured', () => {
96
+ const service: ServiceConfig = {
97
+ name: 'api',
98
+ port: 3000,
99
+ domains: ['api.example.com'],
100
+ healthCheck: {
101
+ path: '/health',
102
+ interval: 30,
103
+ },
104
+ }
105
+
106
+ const config = generateNginxConfig(service, false)
107
+
108
+ expect(config).toContain('location /health')
109
+ expect(config).toContain('access_log off')
110
+ })
111
+
112
+ it('should use custom path if specified', () => {
113
+ const service: ServiceConfig = {
114
+ name: 'api',
115
+ port: 3000,
116
+ domains: ['api.example.com'],
117
+ path: '/api',
118
+ }
119
+
120
+ const config = generateNginxConfig(service, false)
121
+
122
+ expect(config).toContain('location /api')
123
+ })
124
+
125
+ it('should use port override if provided', () => {
126
+ const service: ServiceConfig = {
127
+ name: 'api',
128
+ port: 3000,
129
+ domains: ['api.example.com'],
130
+ }
131
+
132
+ const config = generateNginxConfig(service, false, 4000)
133
+
134
+ expect(config).toContain('server localhost:4000')
135
+ })
136
+
137
+ it('should generate unique upstream names for domains with special characters', () => {
138
+ const service: ServiceConfig = {
139
+ name: 'api',
140
+ port: 3000,
141
+ domains: ['api-test.example.com'],
142
+ }
143
+
144
+ const config = generateNginxConfig(service, false)
145
+
146
+ expect(config).toMatch(/upstream [a-zA-Z0-9_]+_api/)
147
+ expect(config).toContain('proxy_pass http://')
148
+ })
149
+ })
150
+
151
+ describe('generateMultiServiceNginxConfig', () => {
152
+ it('should generate config for multiple services on same domain', () => {
153
+ const services: ServiceConfig[] = [
154
+ {
155
+ name: 'api',
156
+ port: 3000,
157
+ domains: ['example.com'],
158
+ path: '/api',
159
+ },
160
+ {
161
+ name: 'web',
162
+ port: 8080,
163
+ domains: ['example.com'],
164
+ path: '/',
165
+ },
166
+ ]
167
+
168
+ const config = generateMultiServiceNginxConfig(services, 'example.com', false)
169
+
170
+ expect(config).toContain('server_name example.com')
171
+ expect(config).toContain('location /api')
172
+ expect(config).toContain('location /')
173
+ expect(config).toContain('server localhost:3000')
174
+ expect(config).toContain('server localhost:8080')
175
+ })
176
+
177
+ it('should sort services by path length (longest first)', () => {
178
+ const services: ServiceConfig[] = [
179
+ {
180
+ name: 'root',
181
+ port: 8080,
182
+ domains: ['example.com'],
183
+ path: '/',
184
+ },
185
+ {
186
+ name: 'api',
187
+ port: 3000,
188
+ domains: ['example.com'],
189
+ path: '/api/v1',
190
+ },
191
+ {
192
+ name: 'v2',
193
+ port: 3001,
194
+ domains: ['example.com'],
195
+ path: '/api',
196
+ },
197
+ ]
198
+
199
+ const config = generateMultiServiceNginxConfig(services, 'example.com', false)
200
+
201
+ // Find the order of location blocks by checking which service comment appears first
202
+ const apiV1ServiceIndex = config.indexOf('# Service: api')
203
+ const apiServiceIndex = config.indexOf('# Service: v2')
204
+ const rootServiceIndex = config.indexOf('# Service: root')
205
+
206
+ // /api/v1 (api service) should come before /api (v2 service), which should come before / (root service)
207
+ // The path /api/v1 (length 7) should come before /api (length 5), which should come before / (length 1)
208
+ expect(apiV1ServiceIndex).toBeGreaterThan(-1)
209
+ expect(apiServiceIndex).toBeGreaterThan(-1)
210
+ expect(rootServiceIndex).toBeGreaterThan(-1)
211
+ expect(apiV1ServiceIndex).toBeLessThan(apiServiceIndex)
212
+ expect(apiServiceIndex).toBeLessThan(rootServiceIndex)
213
+ })
214
+
215
+ it('should generate HTTPS config for multiple services', () => {
216
+ const services: ServiceConfig[] = [
217
+ {
218
+ name: 'api',
219
+ port: 3000,
220
+ domains: ['example.com'],
221
+ path: '/api',
222
+ },
223
+ ]
224
+
225
+ const config = generateMultiServiceNginxConfig(services, 'example.com', true)
226
+
227
+ expect(config).toContain('listen 443 ssl http2')
228
+ expect(config).toContain('ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem')
229
+ expect(config).toContain('return 301 https://$server_name$request_uri')
230
+ })
231
+
232
+ it('should include health checks for services that have them', () => {
233
+ const services: ServiceConfig[] = [
234
+ {
235
+ name: 'api',
236
+ port: 3000,
237
+ domains: ['example.com'],
238
+ path: '/api',
239
+ healthCheck: {
240
+ path: '/health',
241
+ interval: 30,
242
+ },
243
+ },
244
+ {
245
+ name: 'web',
246
+ port: 8080,
247
+ domains: ['example.com'],
248
+ path: '/',
249
+ },
250
+ ]
251
+
252
+ const config = generateMultiServiceNginxConfig(services, 'example.com', false)
253
+
254
+ expect(config).toContain('location /health')
255
+ expect(config).toContain('# Health check for api')
256
+ })
257
+
258
+ it('should use port overrides if provided', () => {
259
+ const services: ServiceConfig[] = [
260
+ {
261
+ name: 'api',
262
+ port: 3000,
263
+ domains: ['example.com'],
264
+ path: '/api',
265
+ },
266
+ ]
267
+
268
+ const portOverrides = new Map([['api', 4000]])
269
+
270
+ const config = generateMultiServiceNginxConfig(services, 'example.com', false, portOverrides)
271
+
272
+ expect(config).toContain('server localhost:4000')
273
+ })
274
+ })
275
+
276
+ describe('configExists', () => {
277
+ it('should return true if config file exists', async () => {
278
+ vi.mocked(fs.pathExists).mockResolvedValue(true as any)
279
+
280
+ const result = await configExists('example.com', '/etc/nginx/sites-available')
281
+
282
+ expect(fs.pathExists).toHaveBeenCalledWith('/etc/nginx/sites-available/example.com.conf')
283
+ expect(result).toBe(true)
284
+ })
285
+
286
+ it('should return false if config file does not exist', async () => {
287
+ vi.mocked(fs.pathExists).mockResolvedValue(false as any)
288
+
289
+ const result = await configExists('example.com', '/etc/nginx/sites-available')
290
+
291
+ expect(result).toBe(false)
292
+ })
293
+ })
294
+
295
+ describe('writeNginxConfig', () => {
296
+ it('should write config file if it does not exist', async () => {
297
+ vi.mocked(fs.pathExists).mockResolvedValue(false as any)
298
+ vi.mocked(fs.writeFile).mockResolvedValue(undefined as any)
299
+
300
+ const result = await writeNginxConfig(
301
+ 'example.com',
302
+ '/etc/nginx/sites-available',
303
+ 'server { ... }'
304
+ )
305
+
306
+ expect(fs.remove).not.toHaveBeenCalled()
307
+ expect(fs.writeFile).toHaveBeenCalledWith(
308
+ '/etc/nginx/sites-available/example.com.conf',
309
+ 'server { ... }'
310
+ )
311
+ expect(result).toBe(false)
312
+ })
313
+
314
+ it('should delete existing config file before writing new one', async () => {
315
+ vi.mocked(fs.pathExists).mockResolvedValue(true as any)
316
+ vi.mocked(fs.remove).mockResolvedValue(undefined as any)
317
+ vi.mocked(fs.writeFile).mockResolvedValue(undefined as any)
318
+
319
+ const result = await writeNginxConfig(
320
+ 'example.com',
321
+ '/etc/nginx/sites-available',
322
+ 'server { ... }'
323
+ )
324
+
325
+ expect(fs.remove).toHaveBeenCalledWith('/etc/nginx/sites-available/example.com.conf')
326
+ expect(fs.writeFile).toHaveBeenCalledWith(
327
+ '/etc/nginx/sites-available/example.com.conf',
328
+ 'server { ... }'
329
+ )
330
+ expect(result).toBe(true)
331
+ })
332
+ })
333
+
334
+ describe('enableSite', () => {
335
+ it('should create symlink to enable site', async () => {
336
+ vi.mocked(fs.pathExists).mockResolvedValue(false as any)
337
+ vi.mocked(fs.ensureDir).mockResolvedValue(undefined as any)
338
+ vi.mocked(execa).mockResolvedValue({ stdout: '', stderr: '' } as any)
339
+
340
+ await enableSite('example.com', '/etc/nginx/sites-available')
341
+
342
+ expect(fs.ensureDir).toHaveBeenCalled()
343
+ expect(execa).toHaveBeenCalledWith('sudo', [
344
+ 'ln',
345
+ '-sf',
346
+ '/etc/nginx/sites-available/example.com.conf',
347
+ '/etc/nginx/sites-enabled/example.com.conf',
348
+ ])
349
+ })
350
+
351
+ it('should remove existing symlink before creating new one', async () => {
352
+ vi.mocked(fs.pathExists).mockResolvedValue(true as any)
353
+ vi.mocked(fs.remove).mockResolvedValue(undefined as any)
354
+ vi.mocked(fs.ensureDir).mockResolvedValue(undefined as any)
355
+ vi.mocked(execa).mockResolvedValue({ stdout: '', stderr: '' } as any)
356
+
357
+ await enableSite('example.com', '/etc/nginx/sites-available')
358
+
359
+ expect(fs.remove).toHaveBeenCalledWith('/etc/nginx/sites-enabled/example.com.conf')
360
+ })
361
+ })
362
+
363
+ describe('disableSite', () => {
364
+ it('should remove symlink if it exists', async () => {
365
+ vi.mocked(fs.pathExists).mockResolvedValue(true as any)
366
+ vi.mocked(fs.remove).mockResolvedValue(undefined as any)
367
+
368
+ await disableSite('example.com', '/etc/nginx/sites-available')
369
+
370
+ expect(fs.remove).toHaveBeenCalledWith('/etc/nginx/sites-enabled/example.com.conf')
371
+ })
372
+
373
+ it('should not throw error if symlink does not exist', async () => {
374
+ vi.mocked(fs.pathExists).mockResolvedValue(false as any)
375
+
376
+ await expect(disableSite('example.com', '/etc/nginx/sites-available')).resolves.not.toThrow()
377
+ })
378
+ })
379
+
380
+ describe('reloadNginx', () => {
381
+ it('should test and reload nginx configuration', async () => {
382
+ vi.mocked(execa)
383
+ .mockResolvedValueOnce({ stdout: '', stderr: '' } as any) // nginx -t
384
+ .mockResolvedValueOnce({ stdout: '', stderr: '' } as any) // reload command
385
+
386
+ await reloadNginx('sudo systemctl reload nginx')
387
+
388
+ expect(execa).toHaveBeenCalledWith('sudo', ['nginx', '-t'])
389
+ expect(execa).toHaveBeenCalledWith('sudo', ['systemctl', 'reload', 'nginx'], {
390
+ shell: true,
391
+ })
392
+ })
393
+
394
+ it('should throw error if nginx test fails', async () => {
395
+ vi.mocked(execa).mockRejectedValue(new Error('Configuration test failed'))
396
+
397
+ await expect(reloadNginx('sudo systemctl reload nginx')).rejects.toThrow(
398
+ 'Failed to reload Nginx'
399
+ )
400
+ })
401
+
402
+ it('should throw error if reload command fails', async () => {
403
+ vi.mocked(execa)
404
+ .mockResolvedValueOnce({ stdout: '', stderr: '' } as any) // nginx -t
405
+ .mockRejectedValueOnce(new Error('Reload failed'))
406
+
407
+ await expect(reloadNginx('sudo systemctl reload nginx')).rejects.toThrow(
408
+ 'Failed to reload Nginx'
409
+ )
410
+ })
411
+ })
412
+ })
@@ -0,0 +1,144 @@
1
+ import { execa } from 'execa'
2
+
3
+ /**
4
+ * Request an SSL certificate from Let's Encrypt using Certbot
5
+ */
6
+ export async function requestCertificate(
7
+ domain: string,
8
+ email: string,
9
+ staging: boolean = false
10
+ ): Promise<void> {
11
+ // Check if certificate already exists before requesting
12
+ const exists = await certificateExists(domain)
13
+ if (exists) {
14
+ throw new Error(
15
+ `Certificate for ${domain} already exists. Use certificateExists() to check before calling this function.`
16
+ )
17
+ }
18
+
19
+ const args = [
20
+ 'certonly',
21
+ '--nginx',
22
+ '-d',
23
+ domain,
24
+ '--non-interactive',
25
+ '--agree-tos',
26
+ '--email',
27
+ email,
28
+ ]
29
+
30
+ if (staging) {
31
+ args.push('--staging')
32
+ }
33
+
34
+ try {
35
+ await execa('sudo', ['certbot', ...args])
36
+ } catch (error: any) {
37
+ const errorMessage = error?.stderr || error?.message || String(error) || 'Unknown error'
38
+ const errorLower = errorMessage.toLowerCase()
39
+
40
+ // Check if error is due to certificate already existing
41
+ if (
42
+ errorLower.includes('certificate already exists') ||
43
+ errorLower.includes('already have a certificate') ||
44
+ errorLower.includes('duplicate certificate')
45
+ ) {
46
+ throw new Error(`Certificate for ${domain} already exists. Skipping certificate creation.`)
47
+ }
48
+
49
+ throw new Error(`Failed to obtain SSL certificate for ${domain}: ${errorMessage}`)
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Renew all SSL certificates
55
+ */
56
+ export async function renewCertificates(): Promise<void> {
57
+ try {
58
+ await execa('sudo', ['certbot', 'renew', '--quiet'])
59
+ } catch (error) {
60
+ throw new Error(
61
+ `Failed to renew SSL certificates: ${error instanceof Error ? error.message : error}`
62
+ )
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Check if a certificate exists for a domain
68
+ */
69
+ export async function certificateExists(domain: string): Promise<boolean> {
70
+ try {
71
+ // First, check if certificate files exist using test command (most reliable)
72
+ try {
73
+ await execa('sudo', ['test', '-f', `/etc/letsencrypt/live/${domain}/fullchain.pem`])
74
+ await execa('sudo', ['test', '-f', `/etc/letsencrypt/live/${domain}/privkey.pem`])
75
+ // Both files exist
76
+ return true
77
+ } catch {
78
+ // Files don't exist, continue to certbot check
79
+ }
80
+
81
+ // Fallback: Check using certbot certificates command
82
+ try {
83
+ const { stdout } = await execa('sudo', ['certbot', 'certificates'])
84
+
85
+ // Check if the domain appears in the certificates list
86
+ const lines = stdout.split('\n')
87
+ for (let i = 0; i < lines.length; i++) {
88
+ const line = lines[i]
89
+ // Check if this line contains "Domains:" and includes our domain
90
+ if (line.includes('Domains:') && line.includes(domain)) {
91
+ return true
92
+ }
93
+ // Also check for the domain in certificate paths
94
+ if (
95
+ line.includes(domain) &&
96
+ (line.includes('/live/') || line.includes('Certificate Name:'))
97
+ ) {
98
+ return true
99
+ }
100
+ }
101
+ } catch {
102
+ // If certbot command fails, assume no certificate exists
103
+ }
104
+
105
+ return false
106
+ } catch (error) {
107
+ // If all checks fail, assume no certificate exists
108
+ return false
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Check certificate expiration for a domain
114
+ */
115
+ export async function checkCertificateExpiration(domain: string): Promise<Date | null> {
116
+ try {
117
+ const { stdout } = await execa('sudo', ['certbot', 'certificates', '-d', domain])
118
+
119
+ // Parse expiration date from output
120
+ const expiryMatch = stdout.match(/Expiry Date: ([^\n]+)/)
121
+ if (expiryMatch) {
122
+ return new Date(expiryMatch[1])
123
+ }
124
+
125
+ return null
126
+ } catch (error) {
127
+ return null
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Revoke a certificate for a domain
133
+ */
134
+ export async function revokeCertificate(domain: string): Promise<void> {
135
+ try {
136
+ await execa('sudo', ['certbot', 'revoke', '-d', domain, '--non-interactive'])
137
+ } catch (error) {
138
+ throw new Error(
139
+ `Failed to revoke certificate for ${domain}: ${
140
+ error instanceof Error ? error.message : error
141
+ }`
142
+ )
143
+ }
144
+ }