easy-devops 0.1.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 (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +325 -0
  3. package/cli/index.js +91 -0
  4. package/cli/managers/domain-manager.js +451 -0
  5. package/cli/managers/nginx-manager.js +329 -0
  6. package/cli/managers/node-manager.js +275 -0
  7. package/cli/managers/ssl-manager.js +397 -0
  8. package/cli/menus/.gitkeep +0 -0
  9. package/cli/menus/dashboard.js +223 -0
  10. package/cli/menus/domains.js +5 -0
  11. package/cli/menus/nginx.js +5 -0
  12. package/cli/menus/nodejs.js +5 -0
  13. package/cli/menus/settings.js +83 -0
  14. package/cli/menus/ssl.js +5 -0
  15. package/core/config.js +37 -0
  16. package/core/db.js +30 -0
  17. package/core/detector.js +257 -0
  18. package/core/nginx-conf-generator.js +309 -0
  19. package/core/shell.js +151 -0
  20. package/dashboard/lib/.gitkeep +0 -0
  21. package/dashboard/lib/cert-reader.js +59 -0
  22. package/dashboard/lib/domains-db.js +51 -0
  23. package/dashboard/lib/nginx-conf-generator.js +16 -0
  24. package/dashboard/lib/nginx-service.js +282 -0
  25. package/dashboard/public/js/app.js +486 -0
  26. package/dashboard/routes/.gitkeep +0 -0
  27. package/dashboard/routes/auth.js +30 -0
  28. package/dashboard/routes/domains.js +300 -0
  29. package/dashboard/routes/nginx.js +151 -0
  30. package/dashboard/routes/settings.js +78 -0
  31. package/dashboard/routes/ssl.js +105 -0
  32. package/dashboard/server.js +79 -0
  33. package/dashboard/views/index.ejs +327 -0
  34. package/dashboard/views/partials/domain-form.ejs +229 -0
  35. package/dashboard/views/partials/domains-panel.ejs +66 -0
  36. package/dashboard/views/partials/login.ejs +50 -0
  37. package/dashboard/views/partials/nginx-panel.ejs +90 -0
  38. package/dashboard/views/partials/overview.ejs +67 -0
  39. package/dashboard/views/partials/settings-panel.ejs +37 -0
  40. package/dashboard/views/partials/sidebar.ejs +45 -0
  41. package/dashboard/views/partials/ssl-panel.ejs +53 -0
  42. package/data/.gitkeep +0 -0
  43. package/install.bat +41 -0
  44. package/install.ps1 +653 -0
  45. package/install.sh +452 -0
  46. package/lib/installer/.gitkeep +0 -0
  47. package/lib/installer/detect.sh +88 -0
  48. package/lib/installer/node-versions.sh +109 -0
  49. package/lib/installer/nvm-bootstrap.sh +77 -0
  50. package/lib/installer/picker.sh +163 -0
  51. package/lib/installer/progress.sh +25 -0
  52. package/package.json +67 -0
@@ -0,0 +1,451 @@
1
+ /**
2
+ * cli/managers/domain-manager.js
3
+ *
4
+ * Domain Manager — full control over nginx reverse proxy domains from CLI.
5
+ *
6
+ * Exported functions:
7
+ * - showDomainManager() — interactive menu for managing domains
8
+ *
9
+ * Uses shared nginx-conf-generator.js (core/) for identical conf output
10
+ * to the dashboard.
11
+ */
12
+
13
+ import chalk from 'chalk';
14
+ import inquirer from 'inquirer';
15
+ import ora from 'ora';
16
+ import Table from 'cli-table3';
17
+ import { run } from '../../core/shell.js';
18
+ import { loadConfig } from '../../core/config.js';
19
+ import { getDomains, saveDomains, findDomain, createDomain, DOMAIN_DEFAULTS } from '../../dashboard/lib/domains-db.js';
20
+ import { generateConf, buildConf } from '../../core/nginx-conf-generator.js';
21
+
22
+ const isWindows = process.platform === 'win32';
23
+
24
+ // ─── Helper functions ────────────────────────────────────────────────────────
25
+
26
+ function getNginxExe(nginxDir) {
27
+ return isWindows ? `${nginxDir}\\nginx.exe` : 'nginx';
28
+ }
29
+
30
+ function nginxTestCmd(nginxDir) {
31
+ const exe = getNginxExe(nginxDir);
32
+ return isWindows ? `& "${exe}" -t` : 'nginx -t';
33
+ }
34
+
35
+ function nginxReloadCmd(nginxDir) {
36
+ const exe = getNginxExe(nginxDir);
37
+ return isWindows ? `& "${exe}" -s reload` : 'nginx -s reload';
38
+ }
39
+
40
+ function combineOutput(result) {
41
+ return [result.stdout, result.stderr].filter(Boolean).join('\n').trim();
42
+ }
43
+
44
+ // ─── List Domains ───────────────────────────────────────────────────────
45
+
46
+ async function listDomainsAction() {
47
+ const domains = getDomains();
48
+
49
+ if (domains.length === 0) {
50
+ console.log(chalk.yellow('\n No domains configured yet.\n'));
51
+ return;
52
+ }
53
+
54
+ const table = new Table({
55
+ head: [
56
+ chalk.cyan('Domain'),
57
+ chalk.cyan('Port'),
58
+ chalk.cyan('Type'),
59
+ chalk.cyan('SSL'),
60
+ chalk.cyan('Cert'),
61
+ ],
62
+ style: { head: [], border: ['gray'] },
63
+ colWidths: [30, 8, 8, 8, 8],
64
+ });
65
+
66
+ for (const d of domains) {
67
+ const sslStatus = d.ssl?.enabled ? chalk.green('HTTPS') : chalk.gray('HTTP');
68
+ const type = (d.upstreamType || 'http').toUpperCase();
69
+ const certDays = d.daysLeft !== null && d.daysLeft !== undefined
70
+ ? (d.daysLeft > 30 ? chalk.green(`${d.daysLeft}d`) : chalk.yellow(`${d.daysLeft}d`))
71
+ : chalk.gray('—');
72
+
73
+ table.push([
74
+ d.name,
75
+ d.port.toString(),
76
+ type,
77
+ sslStatus,
78
+ certDays,
79
+ ]);
80
+ }
81
+
82
+ console.log('\n' + table.toString() + '\n');
83
+ }
84
+
85
+ // ─── Add Domain ───────────────────────────────────────────────────────
86
+
87
+ async function addDomainAction() {
88
+ console.log(chalk.bold('\n Add New Domain\n'));
89
+
90
+ // Section 1: Basic
91
+ const basic = await inquirer.prompt([
92
+ { type: 'input', name: 'name', message: 'Domain name:', validate: (v) => v.trim() ? true : 'Required' },
93
+ { type: 'input', name: 'backendHost', message: 'Backend host:', default: '127.0.0.1' },
94
+ { type: 'number', name: 'port', message: 'Backend port:', default: 3000, validate: (v) => (v >= 1 && v <= 65535) ? true : 'Port must be 1-65535' },
95
+ {
96
+ type: 'list', name: 'upstreamType', message: 'Upstream type:', default: 'http',
97
+ choices: ['http', 'https', 'ws'],
98
+ },
99
+ { type: 'confirm', name: 'www', message: 'Include www subdomain?', default: false },
100
+ ]);
101
+
102
+ // Check for duplicate
103
+ if (findDomain(basic.name)) {
104
+ console.log(chalk.red(`\n Domain "${basic.name}" already exists.\n`));
105
+ return;
106
+ }
107
+
108
+ // Section 2: SSL
109
+ const sslAnswers = await inquirer.prompt([
110
+ { type: 'confirm', name: 'enabled', message: 'Enable SSL?', default: false },
111
+ ]);
112
+
113
+ let ssl = { ...DOMAIN_DEFAULTS.ssl, enabled: sslAnswers.enabled };
114
+
115
+ if (ssl.enabled) {
116
+ const { nginxDir, certbotDir } = loadConfig();
117
+ const platform = isWindows ? 'win32' : 'linux';
118
+ const defaultCertPath = platform === 'win32'
119
+ ? `C:\\Certbot\\live\\${basic.name}\\fullchain.pem`
120
+ : `${certbotDir}/live/${basic.name}/fullchain.pem`;
121
+ const defaultKeyPath = platform === 'win32'
122
+ ? `C:\\Certbot\\live\\${basic.name}\\privkey.pem`
123
+ : `${certbotDir}/live/${basic.name}/privkey.pem`;
124
+
125
+ const sslDetails = await inquirer.prompt([
126
+ { type: 'input', name: 'certPath', message: 'SSL cert path:', default: defaultCertPath },
127
+ { type: 'input', name: 'keyPath', message: 'SSL key path:', default: defaultKeyPath },
128
+ { type: 'confirm', name: 'redirect', message: 'HTTP → HTTPS redirect?', default: true },
129
+ { type: 'confirm', name: 'hsts', message: 'Enable HSTS?', default: false },
130
+ ]);
131
+
132
+ if (sslDetails.hsts) {
133
+ const { hstsMaxAge } = await inquirer.prompt([
134
+ { type: 'number', name: 'hstsMaxAge', message: 'HSTS max-age (seconds):', default: 31536000 },
135
+ ]);
136
+ ssl.hstsMaxAge = hstsMaxAge;
137
+ }
138
+
139
+ ssl = { ...ssl, ...sslDetails };
140
+ }
141
+
142
+ // Section 3: Proxy Behavior
143
+ const proxy = await inquirer.prompt([
144
+ { type: 'input', name: 'maxBodySize', message: 'Max body size:', default: '10m' },
145
+ { type: 'number', name: 'readTimeout', message: 'Read timeout (s):', default: 60 },
146
+ { type: 'number', name: 'connectTimeout', message: 'Connect timeout (s):', default: 10 },
147
+ { type: 'confirm', name: 'proxyBuffers', message: 'Enable proxy buffering?', default: false },
148
+ ]);
149
+
150
+ // Section 4: Performance
151
+ const perf = await inquirer.prompt([
152
+ { type: 'confirm', name: 'gzip', message: 'Enable gzip?', default: true },
153
+ ]);
154
+
155
+ let performance = {
156
+ ...DOMAIN_DEFAULTS.performance,
157
+ maxBodySize: proxy.maxBodySize,
158
+ readTimeout: proxy.readTimeout,
159
+ connectTimeout: proxy.connectTimeout,
160
+ proxyBuffers: proxy.proxyBuffers,
161
+ gzip: perf.gzip,
162
+ };
163
+
164
+ // Section 5: Security
165
+ const secAnswers = await inquirer.prompt([
166
+ { type: 'confirm', name: 'rateLimit', message: 'Enable rate limiting?', default: false },
167
+ ]);
168
+
169
+ let security = { ...DOMAIN_DEFAULTS.security, rateLimit: secAnswers.rateLimit };
170
+
171
+ if (secAnswers.rateLimit) {
172
+ const rateDetails = await inquirer.prompt([
173
+ { type: 'number', name: 'rateLimitRate', message: 'Requests/second:', default: 10 },
174
+ { type: 'number', name: 'rateLimitBurst', message: 'Burst queue:', default: 20 },
175
+ ]);
176
+ security.rateLimitRate = rateDetails.rateLimitRate;
177
+ security.rateLimitBurst = rateDetails.rateLimitBurst;
178
+ }
179
+
180
+ const secHeaders = await inquirer.prompt([
181
+ { type: 'confirm', name: 'securityHeaders', message: 'Add security headers?', default: false },
182
+ { type: 'confirm', name: 'custom404', message: 'Custom 404 page?', default: false },
183
+ { type: 'confirm', name: 'custom50x', message: 'Custom 50x page?', default: false },
184
+ ]);
185
+ security = { ...security, ...secHeaders };
186
+
187
+ // Section 6: Advanced (optional)
188
+ const { configureAdvanced } = await inquirer.prompt([
189
+ { type: 'confirm', name: 'configureAdvanced', message: 'Configure advanced options?', default: false },
190
+ ]);
191
+
192
+ let advanced = { ...DOMAIN_DEFAULTS.advanced };
193
+
194
+ if (configureAdvanced) {
195
+ const advDetails = await inquirer.prompt([
196
+ { type: 'confirm', name: 'accessLog', message: 'Enable domain access log?', default: true },
197
+ { type: 'editor', name: 'customLocations', message: 'Custom location blocks (nginx):', default: '' },
198
+ ]);
199
+ advanced = advDetails;
200
+ }
201
+
202
+ // Build domain object
203
+ const domain = createDomain({
204
+ name: basic.name,
205
+ port: basic.port,
206
+ backendHost: basic.backendHost,
207
+ upstreamType: basic.upstreamType,
208
+ www: basic.www,
209
+ ssl,
210
+ performance,
211
+ security,
212
+ advanced,
213
+ });
214
+
215
+ // Generate conf and test
216
+ const spinner = ora('Generating nginx config...').start();
217
+ const { nginxDir } = loadConfig();
218
+
219
+ try {
220
+ await generateConf(domain);
221
+ } catch (err) {
222
+ spinner.fail('Failed to generate config');
223
+ console.log(chalk.red(err.message));
224
+ return;
225
+ }
226
+
227
+ spinner.text = 'Testing config...';
228
+ const testResult = await run(nginxTestCmd(nginxDir), { cwd: nginxDir });
229
+
230
+ if (!testResult.success) {
231
+ spinner.fail('Config test failed');
232
+ console.log(chalk.red('\n' + combineOutput(testResult)));
233
+ // Clean up failed config
234
+ try { await import('fs/promises').then(fs => fs.unlink(domain.configFile)); } catch { /* ignore */ }
235
+ return;
236
+ }
237
+
238
+ // Save domain
239
+ const domains = getDomains();
240
+ domains.push(domain);
241
+ saveDomains(domains);
242
+
243
+ spinner.succeed('Domain added successfully');
244
+ console.log(chalk.gray(` Config: ${domain.configFile}\n`));
245
+ }
246
+
247
+ // ─── Edit Domain ───────────────────────────────────────────────────────
248
+
249
+ async function editDomainAction() {
250
+ const domains = getDomains();
251
+
252
+ if (domains.length === 0) {
253
+ console.log(chalk.yellow('\n No domains to edit.\n'));
254
+ return;
255
+ }
256
+
257
+ const { selectedName } = await inquirer.prompt([
258
+ {
259
+ type: 'list',
260
+ name: 'selectedName',
261
+ message: 'Select domain to edit:',
262
+ choices: domains.map(d => d.name),
263
+ },
264
+ ]);
265
+
266
+ const existing = findDomain(selectedName);
267
+ if (!existing) return;
268
+
269
+ console.log(chalk.bold(`\n Editing: ${selectedName}\n`));
270
+
271
+ // Prompt for each field with current value as default
272
+ const updates = await inquirer.prompt([
273
+ { type: 'input', name: 'backendHost', message: 'Backend host:', default: existing.backendHost || '127.0.0.1' },
274
+ { type: 'number', name: 'port', message: 'Backend port:', default: existing.port },
275
+ {
276
+ type: 'list', name: 'upstreamType', message: 'Upstream type:', default: existing.upstreamType || 'http',
277
+ choices: ['http', 'https', 'ws'],
278
+ },
279
+ { type: 'confirm', name: 'www', message: 'Include www?', default: existing.www || false },
280
+ { type: 'confirm', name: 'sslEnabled', message: 'SSL enabled?', default: existing.ssl?.enabled || false },
281
+ ]);
282
+
283
+ const domain = { ...existing };
284
+
285
+ // Update fields
286
+ domain.backendHost = updates.backendHost;
287
+ domain.port = updates.port;
288
+ domain.upstreamType = updates.upstreamType;
289
+ domain.www = updates.www;
290
+ domain.ssl.enabled = updates.sslEnabled;
291
+
292
+ // Regenerate config
293
+ const spinner = ora('Regenerating config...').start();
294
+ const { nginxDir } = loadConfig();
295
+
296
+ try {
297
+ await generateConf(domain);
298
+ } catch (err) {
299
+ spinner.fail('Failed to generate config');
300
+ return;
301
+ }
302
+
303
+ spinner.text = 'Testing config...';
304
+ const testResult = await run(nginxTestCmd(nginxDir), { cwd: nginxDir });
305
+
306
+ if (!testResult.success) {
307
+ spinner.fail('Config test failed');
308
+ console.log(chalk.red('\n' + combineOutput(testResult)));
309
+ return;
310
+ }
311
+
312
+ // Save
313
+ const allDomains = getDomains();
314
+ const idx = allDomains.findIndex(d => d.name === selectedName);
315
+ allDomains[idx] = domain;
316
+ saveDomains(allDomains);
317
+
318
+ spinner.succeed('Domain updated successfully\n');
319
+ }
320
+
321
+ // ─── Delete Domain ─────────────────────────────────────────────────────
322
+
323
+ async function deleteDomainAction() {
324
+ const domains = getDomains();
325
+
326
+ if (domains.length === 0) {
327
+ console.log(chalk.yellow('\n No domains to delete.\n'));
328
+ return;
329
+ }
330
+
331
+ const { selectedName, confirm } = await inquirer.prompt([
332
+ {
333
+ type: 'list',
334
+ name: 'selectedName',
335
+ message: 'Select domain to delete:',
336
+ choices: domains.map(d => d.name),
337
+ },
338
+ {
339
+ type: 'confirm',
340
+ name: 'confirm',
341
+ message: 'Are you sure?',
342
+ default: false,
343
+ },
344
+ ]);
345
+
346
+ if (!confirm) {
347
+ console.log(chalk.gray('\n Cancelled.\n'));
348
+ return;
349
+ }
350
+
351
+ const domain = findDomain(selectedName);
352
+ if (!domain) return;
353
+
354
+ // Delete config file
355
+ if (domain.configFile) {
356
+ const { default: fs } = await import('fs/promises');
357
+ try {
358
+ await fs.unlink(domain.configFile);
359
+ } catch (err) {
360
+ if (err.code !== 'ENOENT') {
361
+ console.log(chalk.yellow(`Warning: Could not delete ${domain.configFile}`));
362
+ }
363
+ }
364
+ }
365
+
366
+ // Remove from storage
367
+ const remaining = domains.filter(d => d.name !== selectedName);
368
+ saveDomains(remaining);
369
+
370
+ console.log(chalk.green(`\n Domain "${selectedName}" deleted.\n`));
371
+ }
372
+
373
+ // ─── Reload Nginx ──────────────────────────────────────────────────────
374
+
375
+ async function reloadNginxAfterChange() {
376
+ const { nginxDir } = loadConfig();
377
+ const spinner = ora('Reloading nginx...').start();
378
+
379
+ const testResult = await run(nginxTestCmd(nginxDir), { cwd: nginxDir });
380
+ if (!testResult.success) {
381
+ spinner.fail('Config test failed');
382
+ console.log(chalk.red('\n' + combineOutput(testResult)));
383
+ return false;
384
+ }
385
+
386
+ const reloadResult = await run(nginxReloadCmd(nginxDir), { cwd: nginxDir });
387
+ if (!reloadResult.success) {
388
+ spinner.fail('Reload failed');
389
+ console.log(chalk.red('\n' + combineOutput(reloadResult)));
390
+ return false;
391
+ }
392
+
393
+ spinner.succeed('Nginx reloaded');
394
+ return true;
395
+ }
396
+
397
+ // ─── Main Menu (T026, T029-T033) ──────────────────────────────────────────────
398
+
399
+ export async function showDomainManager() {
400
+ while (true) {
401
+ const domains = getDomains();
402
+
403
+ console.log(chalk.bold('\n Domain Manager'));
404
+ console.log(chalk.gray(' ' + '─'.repeat(40)));
405
+ console.log(` ${chalk.blue(domains.length)} domain${domains.length !== 1 ? 's' : ''} configured`);
406
+ console.log();
407
+
408
+ const choices = [
409
+ 'List Domains',
410
+ 'Add Domain',
411
+ 'Edit Domain',
412
+ 'Delete Domain',
413
+ new inquirer.Separator(),
414
+ '← Back',
415
+ ];
416
+
417
+ let choice;
418
+ try {
419
+ ({ choice } = await inquirer.prompt([{
420
+ type: 'list',
421
+ name: 'choice',
422
+ message: 'Select an option:',
423
+ choices,
424
+ }]));
425
+ } catch (err) {
426
+ if (err.name === 'ExitPromptError') return;
427
+ throw err;
428
+ }
429
+
430
+ switch (choice) {
431
+ case 'List Domains':
432
+ await listDomainsAction();
433
+ break;
434
+ case 'Add Domain':
435
+ await addDomainAction();
436
+ break;
437
+ case 'Edit Domain':
438
+ await editDomainAction();
439
+ break;
440
+ case 'Delete Domain':
441
+ await deleteDomainAction();
442
+ break;
443
+ case '← Back':
444
+ return;
445
+ }
446
+
447
+ if (choice !== '← Back') {
448
+ await inquirer.prompt([{ type: 'input', name: '_', message: 'Press Enter to continue...' }]);
449
+ }
450
+ }
451
+ }