ante-erp-cli 1.7.0 → 1.7.2

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.
package/bin/ante-cli.js CHANGED
@@ -33,6 +33,7 @@ import { uninstall } from '../src/commands/uninstall.js';
33
33
  import { migrate, seed, shell, optimize, reset as dbReset, info } from '../src/commands/database.js';
34
34
  import { cloneDb } from '../src/commands/clone-db.js';
35
35
  import { setDomain } from '../src/commands/set-domain.js';
36
+ import { sslEnable, sslStatus } from '../src/commands/ssl-enable.js';
36
37
 
37
38
  // Installation & Setup
38
39
  program
@@ -214,6 +215,24 @@ program
214
215
  .option('--no-interactive', 'Non-interactive mode')
215
216
  .action(setDomain);
216
217
 
218
+ // SSL/HTTPS Management
219
+ const sslCmd = program
220
+ .command('ssl')
221
+ .description('SSL/HTTPS certificate management');
222
+
223
+ sslCmd
224
+ .command('enable')
225
+ .description('Enable SSL/HTTPS with automatic certificate from Let\'s Encrypt')
226
+ .option('--email <email>', 'Email for certificate notifications')
227
+ .option('--staging', 'Use Let\'s Encrypt staging environment (for testing)')
228
+ .option('--no-interactive', 'Non-interactive mode')
229
+ .action(sslEnable);
230
+
231
+ sslCmd
232
+ .command('status')
233
+ .description('Check SSL certificate status and expiry')
234
+ .action(sslStatus);
235
+
217
236
  // Help
218
237
  program
219
238
  .command('help [command]')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ante-erp-cli",
3
- "version": "1.7.0",
3
+ "version": "1.7.2",
4
4
  "description": "Comprehensive CLI tool for managing ANTE ERP self-hosted installations",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,349 @@
1
+ import chalk from 'chalk';
2
+ import inquirer from 'inquirer';
3
+ import ora from 'ora';
4
+ import { readFileSync } from 'fs';
5
+ import { join } from 'path';
6
+ import { getInstallDir } from '../utils/config.js';
7
+ import { isNginxInstalled } from '../utils/nginx.js';
8
+ import {
9
+ obtainSSLCertificate,
10
+ updateNginxForSSL,
11
+ setupAutoRenewal,
12
+ checkSSLStatus,
13
+ extractDomain
14
+ } from '../utils/ssl.js';
15
+
16
+ /**
17
+ * Validate email format
18
+ * @param {string} email - Email to validate
19
+ * @returns {boolean|string} True if valid, error message if invalid
20
+ */
21
+ function validateEmail(email) {
22
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
23
+ if (!email || email.trim() === '') {
24
+ return 'Email is required';
25
+ }
26
+ if (!emailRegex.test(email)) {
27
+ return 'Please enter a valid email address';
28
+ }
29
+ return true;
30
+ }
31
+
32
+ /**
33
+ * Read environment variable from .env file
34
+ * @param {string} envPath - Path to .env file
35
+ * @param {string} key - Environment variable key
36
+ * @returns {string} Value or empty string
37
+ */
38
+ function getEnvValue(envPath, key) {
39
+ try {
40
+ const envContent = readFileSync(envPath, 'utf8');
41
+ const match = envContent.match(new RegExp(`^${key}=(.*)$`, 'm'));
42
+ return match ? match[1] : '';
43
+ } catch {
44
+ return '';
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Enable SSL/HTTPS for ANTE installation
50
+ */
51
+ export async function sslEnable(options) {
52
+ try {
53
+ console.log(chalk.bold('\nšŸ”’ Enable SSL/HTTPS for ANTE\n'));
54
+
55
+ // Check if NGINX is installed
56
+ const nginxInstalled = await isNginxInstalled();
57
+ if (!nginxInstalled) {
58
+ console.log(chalk.red('āœ— NGINX is not installed'));
59
+ console.log(chalk.yellow('\n⚠ SSL requires NGINX to be configured first.'));
60
+ console.log(chalk.gray(' Run "ante set-domain" to configure domains and NGINX.\n'));
61
+ process.exit(1);
62
+ }
63
+
64
+ // Get installation directory
65
+ const installDir = getInstallDir();
66
+ const envPath = join(installDir, '.env');
67
+
68
+ console.log(chalk.gray(`Installation: ${installDir}\n`));
69
+
70
+ // Read current configuration
71
+ const frontendUrl = getEnvValue(envPath, 'FRONTEND_URL');
72
+ const apiUrl = getEnvValue(envPath, 'API_URL');
73
+
74
+ if (!frontendUrl || !apiUrl) {
75
+ console.log(chalk.red('āœ— Domain configuration not found'));
76
+ console.log(chalk.yellow('\n⚠ Please configure domains first using:'));
77
+ console.log(chalk.gray(' ante set-domain\n'));
78
+ process.exit(1);
79
+ }
80
+
81
+ // Extract domains from URLs
82
+ const frontendDomain = extractDomain(frontendUrl);
83
+ const apiDomain = extractDomain(apiUrl);
84
+
85
+ // Check if domains are already using HTTPS
86
+ if (frontendUrl.startsWith('https://') && apiUrl.startsWith('https://')) {
87
+ console.log(chalk.yellow('⚠ Domains are already configured with HTTPS:\n'));
88
+ console.log(chalk.gray(` Frontend: ${frontendUrl}`));
89
+ console.log(chalk.gray(` API: ${apiUrl}\n`));
90
+
91
+ // Check certificate status
92
+ const frontendStatus = await checkSSLStatus(frontendDomain);
93
+ const apiStatus = await checkSSLStatus(apiDomain);
94
+
95
+ if (frontendStatus.installed) {
96
+ console.log(chalk.green(`āœ“ Frontend SSL: Valid (expires in ${frontendStatus.daysUntilExpiry} days)`));
97
+ }
98
+ if (apiStatus.installed) {
99
+ console.log(chalk.green(`āœ“ API SSL: Valid (expires in ${apiStatus.daysUntilExpiry} days)`));
100
+ }
101
+
102
+ console.log();
103
+
104
+ const { proceed } = await inquirer.prompt([
105
+ {
106
+ type: 'confirm',
107
+ name: 'proceed',
108
+ message: 'Do you want to renew or reconfigure SSL certificates?',
109
+ default: false
110
+ }
111
+ ]);
112
+
113
+ if (!proceed) {
114
+ console.log(chalk.gray('\nSSL configuration cancelled.\n'));
115
+ return;
116
+ }
117
+ }
118
+
119
+ // Show current configuration
120
+ console.log(chalk.cyan('Current Configuration:'));
121
+ console.log(chalk.gray(` Frontend: ${frontendUrl}`));
122
+ console.log(chalk.gray(` API: ${apiUrl}\n`));
123
+
124
+ // Detect domains to configure
125
+ const domains = [];
126
+
127
+ if (frontendDomain !== 'localhost' && !frontendDomain.match(/^\d+\.\d+\.\d+\.\d+$/)) {
128
+ domains.push({
129
+ name: 'Frontend',
130
+ domain: frontendDomain,
131
+ port: frontendUrl.match(/:(\d+)/) ? frontendUrl.match(/:(\d+)/)[1] : 8080,
132
+ url: frontendUrl
133
+ });
134
+ }
135
+
136
+ if (apiDomain !== 'localhost' && !apiDomain.match(/^\d+\.\d+\.\d+\.\d+$/) && apiDomain !== frontendDomain) {
137
+ domains.push({
138
+ name: 'API',
139
+ domain: apiDomain,
140
+ port: apiUrl.match(/:(\d+)/) ? apiUrl.match(/:(\d+)/)[1] : 3001,
141
+ url: apiUrl
142
+ });
143
+ }
144
+
145
+ if (domains.length === 0) {
146
+ console.log(chalk.red('āœ— No valid domains found for SSL configuration'));
147
+ console.log(chalk.yellow('\n⚠ SSL certificates require domain names (not localhost or IP addresses).'));
148
+ console.log(chalk.gray(' Configure a domain using "ante set-domain" first.\n'));
149
+ process.exit(1);
150
+ }
151
+
152
+ // Interactive mode: ask for email and confirmation
153
+ if (options.interactive !== false) {
154
+ console.log(chalk.yellow('SSL certificates will be obtained for:\n'));
155
+ domains.forEach(d => {
156
+ console.log(chalk.white(` • ${d.domain} (${d.name})`));
157
+ });
158
+ console.log();
159
+
160
+ const answers = await inquirer.prompt([
161
+ {
162
+ type: 'input',
163
+ name: 'email',
164
+ message: 'Email address for certificate notifications:',
165
+ validate: validateEmail,
166
+ default: options.email || ''
167
+ },
168
+ {
169
+ type: 'confirm',
170
+ name: 'confirm',
171
+ message: 'Proceed with SSL certificate installation?',
172
+ default: true
173
+ }
174
+ ]);
175
+
176
+ if (!answers.confirm) {
177
+ console.log(chalk.gray('\nSSL configuration cancelled.\n'));
178
+ return;
179
+ }
180
+
181
+ options.email = answers.email;
182
+ } else {
183
+ // Non-interactive mode: require email
184
+ if (!options.email) {
185
+ console.log(chalk.red('āœ— Email is required in non-interactive mode'));
186
+ console.log(chalk.gray(' Use: ante ssl enable --email your@email.com\n'));
187
+ process.exit(1);
188
+ }
189
+
190
+ const emailValidation = validateEmail(options.email);
191
+ if (emailValidation !== true) {
192
+ console.log(chalk.red(`āœ— ${emailValidation}\n`));
193
+ process.exit(1);
194
+ }
195
+ }
196
+
197
+ console.log(chalk.bold('\nšŸš€ Starting SSL Configuration...\n'));
198
+
199
+ // Step 1: Obtain SSL certificates for all domains
200
+ console.log(chalk.cyan('šŸ“‹ Obtaining SSL certificates...\n'));
201
+
202
+ for (const domainConfig of domains) {
203
+ try {
204
+ console.log(chalk.gray(` Obtaining certificate for ${domainConfig.domain}...`));
205
+ await obtainSSLCertificate({
206
+ domain: domainConfig.domain,
207
+ email: options.email,
208
+ staging: options.staging || false
209
+ });
210
+ } catch (error) {
211
+ console.log(chalk.red(`\nāœ— Failed to obtain SSL certificate for ${domainConfig.domain}:`), error.message);
212
+ console.log(chalk.yellow('\n⚠ Troubleshooting:'));
213
+ console.log(chalk.gray(' 1. Verify DNS points to this server'));
214
+ console.log(chalk.gray(' 2. Ensure ports 80 and 443 are open'));
215
+ console.log(chalk.gray(' 3. Check NGINX is running: systemctl status nginx'));
216
+ console.log(chalk.gray(' 4. View certbot logs: /var/log/letsencrypt/letsencrypt.log\n'));
217
+ process.exit(1);
218
+ }
219
+ }
220
+
221
+ console.log(chalk.green('\nāœ“ All SSL certificates obtained\n'));
222
+
223
+ // Step 2: Update NGINX configuration with SSL
224
+ console.log(chalk.cyan('šŸ”§ Configuring NGINX for HTTPS...\n'));
225
+
226
+ try {
227
+ // Extract frontend and API ports
228
+ const frontendPort = frontendUrl.match(/:(\d+)/) ? parseInt(frontendUrl.match(/:(\d+)/)[1]) : 8080;
229
+ const apiPort = apiUrl.match(/:(\d+)/) ? parseInt(apiUrl.match(/:(\d+)/)[1]) : 3001;
230
+
231
+ await updateNginxForSSL({
232
+ frontendDomain: frontendUrl,
233
+ apiDomain: apiUrl,
234
+ frontendPort,
235
+ apiPort
236
+ });
237
+ } catch (error) {
238
+ console.log(chalk.red('\nāœ— Failed to configure NGINX:'), error.message);
239
+ console.log(chalk.yellow('\n⚠ Troubleshooting:'));
240
+ console.log(chalk.gray(' 1. Check NGINX configuration: nginx -t'));
241
+ console.log(chalk.gray(' 2. View NGINX logs: journalctl -u nginx'));
242
+ console.log(chalk.gray(' 3. Restore backup: cp /etc/nginx/sites-enabled/ante.backup /etc/nginx/sites-enabled/ante\n'));
243
+ process.exit(1);
244
+ }
245
+
246
+ // Setup automatic renewal
247
+ console.log(chalk.cyan('\nšŸ”„ Setting up automatic certificate renewal...\n'));
248
+ await setupAutoRenewal();
249
+
250
+ // Success summary
251
+ console.log(chalk.bold.green('\nāœ“ SSL/HTTPS Configuration Complete!\n'));
252
+
253
+ console.log(chalk.cyan('Secured Domains:'));
254
+ domains.forEach(d => {
255
+ const httpsUrl = d.url.replace('http://', 'https://').replace(/:\d+$/, '');
256
+ console.log(chalk.white(` • ${d.domain}`));
257
+ console.log(chalk.gray(` ${httpsUrl}\n`));
258
+ });
259
+
260
+ console.log(chalk.cyan('Certificate Details:'));
261
+ console.log(chalk.gray(' Provider: Let\'s Encrypt'));
262
+ console.log(chalk.gray(' Validity: 90 days'));
263
+ console.log(chalk.gray(' Auto-renewal: Enabled (daily check at 3:00 AM)'));
264
+
265
+ console.log(chalk.yellow('\n⚠ Important:'));
266
+ console.log(chalk.gray(' • Update your .env file to use HTTPS URLs'));
267
+ console.log(chalk.gray(' • Restart services: ante restart'));
268
+ console.log(chalk.gray(' • Update DNS if not already configured'));
269
+
270
+ console.log(chalk.cyan('\nšŸ’” Next Steps:'));
271
+ console.log(chalk.white(' 1. Update environment variables:'));
272
+ console.log(chalk.gray(' ante set-domain --frontend https://your-domain --api https://api-domain'));
273
+ console.log(chalk.white(' 2. Restart services:'));
274
+ console.log(chalk.gray(' ante restart'));
275
+ console.log(chalk.white(' 3. Test HTTPS access:'));
276
+ domains.forEach(d => {
277
+ const httpsUrl = d.url.replace('http://', 'https://').replace(/:\d+$/, '');
278
+ console.log(chalk.gray(` ${httpsUrl}`));
279
+ });
280
+ console.log();
281
+
282
+ } catch (error) {
283
+ console.error(chalk.red('\nāœ— SSL configuration failed:'), error.message);
284
+ console.log(chalk.yellow('\n⚠ Common Issues:'));
285
+ console.log(chalk.gray(' • Domain does not resolve to this server'));
286
+ console.log(chalk.gray(' • Ports 80/443 are not accessible'));
287
+ console.log(chalk.gray(' • NGINX is not properly configured'));
288
+ console.log(chalk.gray(' • Firewall is blocking traffic\n'));
289
+ process.exit(1);
290
+ }
291
+ }
292
+
293
+ /**
294
+ * Check SSL certificate status
295
+ */
296
+ export async function sslStatus() {
297
+ try {
298
+ console.log(chalk.bold('\nšŸ”’ SSL Certificate Status\n'));
299
+
300
+ const installDir = getInstallDir();
301
+ const envPath = join(installDir, '.env');
302
+
303
+ const frontendUrl = getEnvValue(envPath, 'FRONTEND_URL');
304
+ const apiUrl = getEnvValue(envPath, 'API_URL');
305
+
306
+ if (!frontendUrl || !apiUrl) {
307
+ console.log(chalk.red('āœ— Domain configuration not found\n'));
308
+ process.exit(1);
309
+ }
310
+
311
+ const frontendDomain = extractDomain(frontendUrl);
312
+ const apiDomain = extractDomain(apiUrl);
313
+
314
+ const domains = [
315
+ { name: 'Frontend', domain: frontendDomain, url: frontendUrl },
316
+ { name: 'API', domain: apiDomain, url: apiUrl }
317
+ ];
318
+
319
+ for (const { name, domain, url } of domains) {
320
+ console.log(chalk.cyan(`${name}:`));
321
+ console.log(chalk.gray(` Domain: ${domain}`));
322
+ console.log(chalk.gray(` URL: ${url}`));
323
+
324
+ const spinner = ora('Checking certificate...').start();
325
+ const status = await checkSSLStatus(domain);
326
+
327
+ if (status.installed) {
328
+ spinner.succeed('Certificate found');
329
+ console.log(chalk.gray(` Expiry: ${new Date(status.expiryDate).toLocaleDateString()}`));
330
+ console.log(chalk.gray(` Days remaining: ${status.daysUntilExpiry}`));
331
+
332
+ if (status.daysUntilExpiry < 30) {
333
+ console.log(chalk.yellow(` Status: Expiring soon (renew recommended)`));
334
+ } else {
335
+ console.log(chalk.green(` Status: Valid`));
336
+ }
337
+ } else {
338
+ spinner.fail('No certificate found');
339
+ console.log(chalk.gray(` Run "ante ssl enable" to obtain a certificate`));
340
+ }
341
+
342
+ console.log();
343
+ }
344
+
345
+ } catch (error) {
346
+ console.error(chalk.red('\nāœ— Failed to check SSL status:'), error.message);
347
+ process.exit(1);
348
+ }
349
+ }
@@ -50,15 +50,20 @@ export async function installNginx(spinner) {
50
50
  * @param {string} config.apiDomain - API domain (e.g., api.ante.example.com)
51
51
  * @param {number} config.frontendPort - Frontend Docker port (default: 8080)
52
52
  * @param {number} config.apiPort - API Docker port (default: 3001)
53
+ * @param {boolean} config.ssl - Enable SSL configuration (default: false)
53
54
  * @returns {string} NGINX configuration content
54
55
  */
55
56
  export function generateNginxConfig(config) {
56
- const { frontendDomain, apiDomain, frontendPort = 8080, apiPort = 3001 } = config;
57
+ const { frontendDomain, apiDomain, frontendPort = 8080, apiPort = 3001, ssl = false } = config;
57
58
 
58
59
  // Extract domain without protocol
59
60
  const frontendHost = frontendDomain.replace(/^https?:\/\//, '').replace(/:\d+$/, '');
60
61
  const apiHost = apiDomain.replace(/^https?:\/\//, '').replace(/:\d+$/, '');
61
62
 
63
+ if (ssl) {
64
+ return generateSslNginxConfig({ frontendHost, apiHost, frontendPort, apiPort });
65
+ }
66
+
62
67
  return `# ANTE Frontend Configuration
63
68
  server {
64
69
  listen 80;
@@ -127,6 +132,138 @@ server {
127
132
  `;
128
133
  }
129
134
 
135
+ /**
136
+ * Generate NGINX configuration with SSL support
137
+ * @param {Object} config - Configuration object
138
+ * @param {string} config.frontendHost - Frontend hostname
139
+ * @param {string} config.apiHost - API hostname
140
+ * @param {number} config.frontendPort - Frontend port
141
+ * @param {number} config.apiPort - API port
142
+ * @returns {string} NGINX configuration with SSL
143
+ */
144
+ function generateSslNginxConfig(config) {
145
+ const { frontendHost, apiHost, frontendPort, apiPort } = config;
146
+
147
+ return `# ANTE Frontend Configuration (HTTPS)
148
+ server {
149
+ listen 443 ssl;
150
+ listen [::]:443 ssl;
151
+ http2 on;
152
+ server_name ${frontendHost};
153
+
154
+ # SSL Certificate paths
155
+ ssl_certificate /etc/letsencrypt/live/${frontendHost}/fullchain.pem;
156
+ ssl_certificate_key /etc/letsencrypt/live/${frontendHost}/privkey.pem;
157
+
158
+ # SSL Configuration
159
+ ssl_protocols TLSv1.2 TLSv1.3;
160
+ ssl_ciphers HIGH:!aNULL:!MD5;
161
+ ssl_prefer_server_ciphers on;
162
+ ssl_session_cache shared:SSL:10m;
163
+ ssl_session_timeout 10m;
164
+
165
+ # Security headers
166
+ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
167
+ add_header X-Frame-Options SAMEORIGIN always;
168
+ add_header X-Content-Type-Options nosniff always;
169
+ add_header X-XSS-Protection "1; mode=block" always;
170
+
171
+ # Increase buffer sizes for large headers
172
+ client_header_buffer_size 16k;
173
+ large_client_header_buffers 4 16k;
174
+
175
+ # Increase body size for file uploads
176
+ client_max_body_size 100M;
177
+
178
+ location / {
179
+ proxy_pass http://localhost:${frontendPort};
180
+ proxy_http_version 1.1;
181
+ proxy_set_header Upgrade $http_upgrade;
182
+ proxy_set_header Connection 'upgrade';
183
+ proxy_set_header Host $host;
184
+ proxy_set_header X-Real-IP $remote_addr;
185
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
186
+ proxy_set_header X-Forwarded-Proto $scheme;
187
+ proxy_cache_bypass $http_upgrade;
188
+
189
+ # Timeout settings
190
+ proxy_connect_timeout 60s;
191
+ proxy_send_timeout 60s;
192
+ proxy_read_timeout 60s;
193
+ }
194
+ }
195
+
196
+ # HTTP to HTTPS redirect for ${frontendHost}
197
+ server {
198
+ listen 80;
199
+ listen [::]:80;
200
+ server_name ${frontendHost};
201
+ return 301 https://$server_name$request_uri;
202
+ }
203
+
204
+ # ANTE API Configuration (HTTPS)
205
+ server {
206
+ listen 443 ssl;
207
+ listen [::]:443 ssl;
208
+ http2 on;
209
+ server_name ${apiHost};
210
+
211
+ # SSL Certificate paths
212
+ ssl_certificate /etc/letsencrypt/live/${apiHost}/fullchain.pem;
213
+ ssl_certificate_key /etc/letsencrypt/live/${apiHost}/privkey.pem;
214
+
215
+ # SSL Configuration
216
+ ssl_protocols TLSv1.2 TLSv1.3;
217
+ ssl_ciphers HIGH:!aNULL:!MD5;
218
+ ssl_prefer_server_ciphers on;
219
+ ssl_session_cache shared:SSL:10m;
220
+ ssl_session_timeout 10m;
221
+
222
+ # Security headers
223
+ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
224
+ add_header X-Frame-Options SAMEORIGIN always;
225
+ add_header X-Content-Type-Options nosniff always;
226
+ add_header X-XSS-Protection "1; mode=block" always;
227
+
228
+ # Increase buffer sizes for large headers
229
+ client_header_buffer_size 16k;
230
+ large_client_header_buffers 4 16k;
231
+
232
+ # Increase body size for file uploads
233
+ client_max_body_size 100M;
234
+
235
+ location / {
236
+ proxy_pass http://localhost:${apiPort};
237
+ proxy_http_version 1.1;
238
+ proxy_set_header Upgrade $http_upgrade;
239
+ proxy_set_header Connection 'upgrade';
240
+ proxy_set_header Host $host;
241
+ proxy_set_header X-Real-IP $remote_addr;
242
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
243
+ proxy_set_header X-Forwarded-Proto $scheme;
244
+ proxy_cache_bypass $http_upgrade;
245
+
246
+ # Timeout settings
247
+ proxy_connect_timeout 60s;
248
+ proxy_send_timeout 60s;
249
+ proxy_read_timeout 60s;
250
+
251
+ # WebSocket support
252
+ proxy_set_header X-Forwarded-Host $host;
253
+ proxy_set_header X-Forwarded-Server $host;
254
+ }
255
+ }
256
+
257
+ # HTTP to HTTPS redirect for ${apiHost}
258
+ server {
259
+ listen 80;
260
+ listen [::]:80;
261
+ server_name ${apiHost};
262
+ return 301 https://$server_name$request_uri;
263
+ }
264
+ `;
265
+ }
266
+
130
267
  /**
131
268
  * Configure NGINX for ANTE domains
132
269
  * @param {Object} config - Configuration object
@@ -0,0 +1,329 @@
1
+ import chalk from 'chalk';
2
+ import ora from 'ora';
3
+ import { execa } from 'execa';
4
+ import { writeFileSync, existsSync, copyFileSync } from 'fs';
5
+ import { generateNginxConfig } from './nginx.js';
6
+
7
+ /**
8
+ * Check if certbot is installed
9
+ * @returns {Promise<boolean>} True if certbot is installed
10
+ */
11
+ export async function isCertbotInstalled() {
12
+ try {
13
+ await execa('which', ['certbot']);
14
+ return true;
15
+ } catch {
16
+ return false;
17
+ }
18
+ }
19
+
20
+ /**
21
+ * Install certbot on Ubuntu/Debian
22
+ * @param {Object} spinner - Ora spinner instance
23
+ * @returns {Promise<void>}
24
+ */
25
+ export async function installCertbot(spinner) {
26
+ try {
27
+ spinner.text = 'Installing certbot...';
28
+
29
+ // Update package list
30
+ await execa('apt-get', ['update', '-qq'], { stdio: 'pipe' });
31
+
32
+ // Install certbot
33
+ await execa('apt-get', ['install', '-y', '-qq', 'certbot', 'python3-certbot-nginx'], { stdio: 'pipe' });
34
+
35
+ spinner.succeed('Certbot installed successfully');
36
+ return true;
37
+ } catch (error) {
38
+ spinner.fail('Failed to install certbot');
39
+ throw new Error(`Certbot installation failed: ${error.message}`);
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Check if a domain has valid DNS resolution
45
+ * @param {string} domain - Domain to check
46
+ * @returns {Promise<boolean>} True if domain resolves
47
+ */
48
+ export async function checkDomainDNS(domain) {
49
+ try {
50
+ const { stdout } = await execa('host', [domain], { stdio: 'pipe' });
51
+ return stdout.includes('has address') || stdout.includes('has IPv6 address');
52
+ } catch {
53
+ return false;
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Obtain SSL certificate using certbot
59
+ * @param {Object} config - Configuration object
60
+ * @param {string} config.domain - Domain name (e.g., ante.example.com)
61
+ * @param {string} config.email - Email for certificate notifications
62
+ * @param {boolean} [config.staging=false] - Use staging environment for testing
63
+ * @returns {Promise<void>}
64
+ */
65
+ export async function obtainSSLCertificate(config) {
66
+ const { domain, email, staging = false } = config;
67
+ const spinner = ora(`Obtaining SSL certificate for ${domain}...`).start();
68
+
69
+ try {
70
+ // Check if certbot is installed
71
+ const certbotInstalled = await isCertbotInstalled();
72
+ if (!certbotInstalled) {
73
+ spinner.text = 'Certbot not found, installing...';
74
+ await installCertbot(spinner);
75
+ spinner.start(`Obtaining SSL certificate for ${domain}...`);
76
+ }
77
+
78
+ // Check DNS resolution
79
+ spinner.text = `Checking DNS resolution for ${domain}...`;
80
+ const dnsResolved = await checkDomainDNS(domain);
81
+ if (!dnsResolved) {
82
+ spinner.warn(`Warning: ${domain} does not resolve to an IP address`);
83
+ console.log(chalk.yellow('\n⚠ DNS Check Failed:'));
84
+ console.log(chalk.gray(' The domain does not currently resolve. Certbot may fail.'));
85
+ console.log(chalk.gray(' Make sure your DNS is properly configured before continuing.\n'));
86
+ }
87
+
88
+ spinner.text = `Obtaining SSL certificate for ${domain}...`;
89
+
90
+ // Build certbot command
91
+ const certbotArgs = [
92
+ 'certonly',
93
+ '--nginx',
94
+ '-d', domain,
95
+ '--email', email,
96
+ '--agree-tos',
97
+ '--no-eff-email',
98
+ '--non-interactive'
99
+ ];
100
+
101
+ // Add staging flag if testing
102
+ if (staging) {
103
+ certbotArgs.push('--staging');
104
+ }
105
+
106
+ // Run certbot
107
+ try {
108
+ await execa('certbot', certbotArgs, { stdio: 'pipe' });
109
+ spinner.succeed(`SSL certificate obtained for ${domain}`);
110
+ } catch (error) {
111
+ spinner.fail(`Failed to obtain SSL certificate for ${domain}`);
112
+ throw new Error(`Certbot failed: ${error.message}`);
113
+ }
114
+
115
+ return true;
116
+ } catch (error) {
117
+ spinner.fail('Failed to obtain SSL certificate');
118
+ throw error;
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Update NGINX configuration to use SSL
124
+ * @param {Object} config - Configuration object
125
+ * @param {string} config.frontendDomain - Frontend domain URL
126
+ * @param {string} config.apiDomain - API domain URL
127
+ * @param {number} [config.frontendPort=8080] - Frontend port
128
+ * @param {number} [config.apiPort=3001] - API port
129
+ * @returns {Promise<void>}
130
+ */
131
+ export async function updateNginxForSSL(config) {
132
+ const { frontendDomain, apiDomain, frontendPort = 8080, apiPort = 3001 } = config;
133
+ const spinner = ora('Updating NGINX configuration for HTTPS...').start();
134
+
135
+ try {
136
+ const configPath = '/etc/nginx/sites-enabled/ante';
137
+ const backupPath = `${configPath}.backup`;
138
+
139
+ // Check if config exists
140
+ if (!existsSync(configPath)) {
141
+ spinner.fail('NGINX configuration not found');
142
+ throw new Error('Please run "ante set-domain" first to configure NGINX');
143
+ }
144
+
145
+ // Backup existing configuration
146
+ spinner.text = 'Backing up current NGINX configuration...';
147
+ copyFileSync(configPath, backupPath);
148
+
149
+ // Generate new SSL-enabled configuration
150
+ spinner.text = 'Generating SSL configuration...';
151
+ const sslConfig = generateNginxConfig({
152
+ frontendDomain,
153
+ apiDomain,
154
+ frontendPort,
155
+ apiPort,
156
+ ssl: true
157
+ });
158
+
159
+ // Write new configuration
160
+ writeFileSync(configPath, sslConfig);
161
+
162
+ // Test NGINX configuration
163
+ spinner.text = 'Testing NGINX configuration...';
164
+ try {
165
+ const { stderr } = await execa('nginx', ['-t'], { stdio: 'pipe' });
166
+
167
+ // Check for warnings
168
+ if (stderr && !stderr.includes('syntax is ok')) {
169
+ console.log(chalk.yellow('\n⚠ NGINX warnings:'));
170
+ console.log(chalk.gray(stderr));
171
+ }
172
+ } catch (error) {
173
+ spinner.fail('NGINX configuration test failed');
174
+
175
+ // Restore backup
176
+ console.log(chalk.yellow('\n⚠ Restoring previous configuration...'));
177
+ copyFileSync(backupPath, configPath);
178
+
179
+ throw new Error(`Invalid NGINX configuration: ${error.stderr || error.message}`);
180
+ }
181
+
182
+ // Reload NGINX
183
+ spinner.text = 'Reloading NGINX...';
184
+ try {
185
+ await execa('systemctl', ['reload', 'nginx'], { stdio: 'pipe' });
186
+ } catch {
187
+ // If reload fails, try restart
188
+ await execa('systemctl', ['restart', 'nginx'], { stdio: 'pipe' });
189
+ }
190
+
191
+ // Verify NGINX is running
192
+ const { stdout } = await execa('systemctl', ['is-active', 'nginx'], { stdio: 'pipe' });
193
+ if (stdout.trim() !== 'active') {
194
+ throw new Error('NGINX failed to start after configuration update');
195
+ }
196
+
197
+ spinner.succeed('NGINX configured for HTTPS');
198
+
199
+ // Show configuration info
200
+ const frontendHost = frontendDomain.replace(/^https?:\/\//, '').replace(/:\d+$/, '');
201
+ const apiHost = apiDomain.replace(/^https?:\/\//, '').replace(/:\d+$/, '');
202
+ console.log(chalk.gray('\nšŸ“ NGINX Configuration:'));
203
+ console.log(chalk.gray(` Frontend: https://${frontendHost} → localhost:${frontendPort}`));
204
+ console.log(chalk.gray(` API: https://${apiHost} → localhost:${apiPort}`));
205
+ console.log(chalk.gray(` HTTP redirects to HTTPS: Enabled\n`));
206
+
207
+ } catch (error) {
208
+ spinner.fail('Failed to update NGINX configuration');
209
+ throw error;
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Setup automatic SSL certificate renewal via cron
215
+ * @returns {Promise<void>}
216
+ */
217
+ export async function setupAutoRenewal() {
218
+ const spinner = ora('Setting up automatic SSL renewal...').start();
219
+
220
+ try {
221
+ // Check if certbot timer is enabled (systemd-based auto-renewal)
222
+ try {
223
+ const { stdout } = await execa('systemctl', ['is-enabled', 'certbot.timer'], { stdio: 'pipe' });
224
+ if (stdout.trim() === 'enabled') {
225
+ spinner.succeed('Automatic SSL renewal already configured (systemd timer)');
226
+ return;
227
+ }
228
+ } catch {
229
+ // Timer not enabled, continue with cron setup
230
+ }
231
+
232
+ // Try to enable systemd timer first (modern approach)
233
+ try {
234
+ await execa('systemctl', ['enable', 'certbot.timer'], { stdio: 'pipe' });
235
+ await execa('systemctl', ['start', 'certbot.timer'], { stdio: 'pipe' });
236
+ spinner.succeed('Automatic SSL renewal configured (systemd timer)');
237
+ return;
238
+ } catch {
239
+ // Systemd timer not available, fallback to cron
240
+ }
241
+
242
+ // Fallback: Setup cron job
243
+ const cronJob = '0 3 * * * certbot renew --quiet --nginx --post-hook "systemctl reload nginx"';
244
+ const cronFile = '/etc/cron.d/certbot-renewal';
245
+
246
+ // Write cron job
247
+ writeFileSync(cronFile, `# Certbot automatic renewal\nSHELL=/bin/bash\nPATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin\n\n${cronJob}\n`);
248
+
249
+ // Set proper permissions
250
+ await execa('chmod', ['644', cronFile]);
251
+
252
+ spinner.succeed('Automatic SSL renewal configured (cron)');
253
+
254
+ console.log(chalk.gray('\nšŸ“ SSL Renewal Details:'));
255
+ console.log(chalk.gray(' Renewal check: Daily at 3:00 AM'));
256
+ console.log(chalk.gray(' Method: Cron job'));
257
+ console.log(chalk.gray(' Certificates will auto-renew when within 30 days of expiry\n'));
258
+
259
+ } catch (error) {
260
+ spinner.fail('Failed to setup automatic renewal');
261
+ throw error;
262
+ }
263
+ }
264
+
265
+ /**
266
+ * Check SSL certificate status for a domain
267
+ * @param {string} domain - Domain name
268
+ * @returns {Promise<Object>} Certificate status information
269
+ */
270
+ export async function checkSSLStatus(domain) {
271
+ try {
272
+ const certPath = `/etc/letsencrypt/live/${domain}/cert.pem`;
273
+
274
+ if (!existsSync(certPath)) {
275
+ return {
276
+ installed: false,
277
+ message: 'No SSL certificate found'
278
+ };
279
+ }
280
+
281
+ // Get certificate expiry date
282
+ const { stdout } = await execa('openssl', [
283
+ 'x509',
284
+ '-enddate',
285
+ '-noout',
286
+ '-in',
287
+ certPath
288
+ ], { stdio: 'pipe' });
289
+
290
+ const expiryMatch = stdout.match(/notAfter=(.+)/);
291
+ const expiryDate = expiryMatch ? new Date(expiryMatch[1]) : null;
292
+
293
+ if (expiryDate) {
294
+ const daysUntilExpiry = Math.floor((expiryDate - new Date()) / (1000 * 60 * 60 * 24));
295
+
296
+ return {
297
+ installed: true,
298
+ expiryDate: expiryDate.toISOString(),
299
+ daysUntilExpiry,
300
+ status: daysUntilExpiry > 30 ? 'valid' : 'expiring-soon'
301
+ };
302
+ }
303
+
304
+ return {
305
+ installed: true,
306
+ message: 'Certificate found but unable to parse expiry date'
307
+ };
308
+
309
+ } catch (error) {
310
+ return {
311
+ installed: false,
312
+ error: error.message
313
+ };
314
+ }
315
+ }
316
+
317
+ /**
318
+ * Extract domain from URL
319
+ * @param {string} url - URL string
320
+ * @returns {string} Domain name
321
+ */
322
+ export function extractDomain(url) {
323
+ try {
324
+ const parsed = new URL(url);
325
+ return parsed.hostname;
326
+ } catch {
327
+ return url;
328
+ }
329
+ }