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,486 @@
1
+ /* global Vue, io */
2
+ const { createApp } = Vue;
3
+
4
+ // Default form structure matching DOMAIN_DEFAULTS
5
+ const DEFAULT_FORM = {
6
+ name: '',
7
+ port: 3000,
8
+ backendHost: '127.0.0.1',
9
+ upstreamType: 'http',
10
+ www: false,
11
+ ssl: {
12
+ enabled: false,
13
+ certPath: '',
14
+ keyPath: '',
15
+ redirect: true,
16
+ hsts: false,
17
+ hstsMaxAge: 31536000,
18
+ },
19
+ performance: {
20
+ maxBodySize: '10m',
21
+ readTimeout: 60,
22
+ connectTimeout: 10,
23
+ proxyBuffers: false,
24
+ gzip: true,
25
+ gzipTypes: 'text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript',
26
+ },
27
+ security: {
28
+ rateLimit: false,
29
+ rateLimitRate: 10,
30
+ rateLimitBurst: 20,
31
+ securityHeaders: false,
32
+ custom404: false,
33
+ custom50x: false,
34
+ },
35
+ advanced: {
36
+ accessLog: true,
37
+ customLocations: '',
38
+ },
39
+ };
40
+
41
+ createApp({
42
+ data() {
43
+ return {
44
+ theme: localStorage.getItem('ed-theme') || 'dark',
45
+ authenticated: false,
46
+ page: 'overview',
47
+
48
+ login: { password: '', error: '', loading: false },
49
+
50
+ navItems: [
51
+ { id: 'overview', label: 'Overview', icon: '⚡' },
52
+ { id: 'nginx', label: 'Nginx', icon: '🌐' },
53
+ { id: 'ssl', label: 'SSL Certs', icon: '🔒' },
54
+ { id: 'domains', label: 'Domains', icon: '🔗' },
55
+ { id: 'settings', label: 'Settings', icon: '⚙️' },
56
+ ],
57
+
58
+ nginx: {
59
+ status: null, loading: false,
60
+ actionMsg: '', actionError: '',
61
+ configs: [], selectedConfig: '',
62
+ configContent: '', configSaving: false, configMsg: '',
63
+ logs: [], logsLoading: false,
64
+ },
65
+
66
+ ssl: { certs: [], loading: false, error: '', renewingDomain: null },
67
+
68
+ // T017-T019: Updated domains state
69
+ domains: {
70
+ list: [], loading: false, error: '',
71
+ showForm: false,
72
+ editingName: null, // T017: null = adding, string = editing
73
+ saving: false,
74
+ dirty: false, // T019: Track unsaved changes
75
+ form: JSON.parse(JSON.stringify(DEFAULT_FORM)), // T025: Nested v2 structure
76
+ },
77
+
78
+ // T018: Collapsible section state
79
+ sections: {
80
+ basic: true,
81
+ ssl: true,
82
+ performance: false,
83
+ security: false,
84
+ advanced: false,
85
+ },
86
+
87
+ settings: {
88
+ dashboardPort: '', nginxDir: '', certbotDir: '',
89
+ password: '', loading: false, msg: '', error: '',
90
+ platform: 'linux', // T022: Platform from settings API
91
+ },
92
+ };
93
+ },
94
+
95
+ computed: {
96
+ isDark() { return this.theme === 'dark'; },
97
+ expiringCount() { return this.ssl.certs.filter(c => c.daysLeft !== null && c.daysLeft < 30).length; },
98
+ },
99
+
100
+ watch: {
101
+ theme(val) {
102
+ localStorage.setItem('ed-theme', val);
103
+ document.documentElement.classList.toggle('dark', val === 'dark');
104
+ },
105
+
106
+ // T019: Deep watch form for dirty tracking
107
+ 'domains.form': {
108
+ handler() {
109
+ if (this.domains.showForm) {
110
+ this.domains.dirty = true;
111
+ }
112
+ },
113
+ deep: true,
114
+ },
115
+
116
+ // T024: Auto-populate SSL paths when SSL is toggled on
117
+ 'domains.form.ssl.enabled'(newVal) {
118
+ if (newVal) {
119
+ this.autoPopulateCertPaths();
120
+ }
121
+ },
122
+ },
123
+
124
+ // T020: beforeunload guard for unsaved changes
125
+ beforeMount() {
126
+ window.addEventListener('beforeunload', (e) => {
127
+ if (this.domains.dirty && this.domains.showForm) {
128
+ e.preventDefault();
129
+ e.returnValue = '';
130
+ }
131
+ });
132
+ },
133
+
134
+ async mounted() {
135
+ document.documentElement.classList.toggle('dark', this.theme === 'dark');
136
+
137
+ // Check if already authenticated
138
+ try {
139
+ const r = await fetch('/api/auth');
140
+ const d = await r.json();
141
+ if (d.authenticated) { this.authenticated = true; this.loadPage('overview'); }
142
+ } catch { /* server may not be up yet */ }
143
+
144
+ // T022: Load settings to get platform for SSL path auto-population
145
+ await this.loadSettings();
146
+
147
+ // Real-time nginx status via Socket.io
148
+ const socket = io();
149
+ socket.on('nginx:status', (status) => {
150
+ this.nginx.status = status;
151
+ });
152
+ },
153
+
154
+ methods: {
155
+ // ── Core API helper ────────────────────────────────────────────────────────
156
+ async api(method, path, body) {
157
+ const opts = { method, headers: { 'Content-Type': 'application/json' } };
158
+ if (body !== undefined) opts.body = JSON.stringify(body);
159
+ const r = await fetch(path, opts);
160
+ const data = await r.json().catch(() => ({}));
161
+ return { ok: r.ok, status: r.status, data };
162
+ },
163
+
164
+ // ── Theme ─────────────────────────────────────────────────────────────────
165
+ toggleTheme() { this.theme = this.theme === 'dark' ? 'light' : 'dark'; },
166
+
167
+ // ── Auth ──────────────────────────────────────────────────────────────────
168
+ async doLogin() {
169
+ this.login.loading = true; this.login.error = '';
170
+ const r = await this.api('POST', '/api/login', { password: this.login.password });
171
+ this.login.loading = false;
172
+ if (r.ok) { this.authenticated = true; this.login.password = ''; this.loadPage('overview'); }
173
+ else { this.login.error = r.data.error || 'Invalid password'; }
174
+ },
175
+
176
+ async doLogout() {
177
+ await this.api('POST', '/api/logout');
178
+ this.authenticated = false;
179
+ },
180
+
181
+ // ── Navigation (T021: Nav click guard) ────────────────────────────────────
182
+ async navigateTo(pageId) {
183
+ // T021: Check for unsaved changes before navigation
184
+ if (this.domains.dirty && this.domains.showForm) {
185
+ if (!confirm('You have unsaved changes. Discard them and continue?')) {
186
+ return;
187
+ }
188
+ this.resetDomainForm();
189
+ }
190
+ await this.loadPage(pageId);
191
+ },
192
+
193
+ async loadPage(p) {
194
+ this.page = p;
195
+ if (p === 'overview') {
196
+ await Promise.all([this.loadNginxStatus(), this.loadSSL(), this.loadDomains()]);
197
+ } else if (p === 'nginx') {
198
+ await Promise.all([this.loadNginxStatus(), this.loadNginxConfigs(), this.loadNginxLogs()]);
199
+ } else if (p === 'ssl') { await this.loadSSL(); }
200
+ else if (p === 'domains') { await this.loadDomains(); }
201
+ else if (p === 'settings') { await this.loadSettings(); }
202
+ },
203
+
204
+ // ── Collapsible Sections ────────────────────────────────────────────
205
+ toggleSection(sectionId) {
206
+ this.sections[sectionId] = !this.sections[sectionId];
207
+ },
208
+
209
+ // ── Nginx ─────────────────────────────────────────────────────────────────
210
+ async loadNginxStatus() {
211
+ this.nginx.loading = true;
212
+ const r = await this.api('GET', '/api/nginx/status');
213
+ this.nginx.loading = false;
214
+ if (r.ok) this.nginx.status = r.data;
215
+ },
216
+
217
+ async nginxAction(action) {
218
+ this.nginx.actionMsg = ''; this.nginx.actionError = '';
219
+ const r = await this.api('POST', `/api/nginx/${action}`);
220
+ if (r.ok) { this.nginx.actionMsg = r.data.output || `${action} successful`; await this.loadNginxStatus(); }
221
+ else { this.nginx.actionError = r.data.output || r.data.error || `${action} failed`; }
222
+ },
223
+
224
+ async loadNginxConfigs() {
225
+ const r = await this.api('GET', '/api/nginx/configs');
226
+ if (r.ok) this.nginx.configs = r.data;
227
+ },
228
+
229
+ async selectConfig(filename) {
230
+ if (!filename) return;
231
+ this.nginx.configMsg = '';
232
+ const r = await this.api('GET', `/api/nginx/config/${filename}`);
233
+ if (r.ok) this.nginx.configContent = r.data.content;
234
+ },
235
+
236
+ async saveNginxConfig() {
237
+ if (!this.nginx.selectedConfig) return;
238
+ this.nginx.configSaving = true; this.nginx.configMsg = '';
239
+ const r = await this.api('POST', `/api/nginx/config/${this.nginx.selectedConfig}`, { content: this.nginx.configContent });
240
+ this.nginx.configSaving = false;
241
+ this.nginx.configMsg = r.ok
242
+ ? 'Saved — config test passed ✓'
243
+ : (r.data.output || r.data.error || 'Save failed');
244
+ },
245
+
246
+ async loadNginxLogs() {
247
+ this.nginx.logsLoading = true;
248
+ const r = await this.api('GET', '/api/nginx/logs');
249
+ this.nginx.logsLoading = false;
250
+ if (r.ok) this.nginx.logs = r.data.lines || [];
251
+ },
252
+
253
+ // ── SSL ───────────────────────────────────────────────────────────────────
254
+ async loadSSL() {
255
+ this.ssl.loading = true; this.ssl.error = '';
256
+ const r = await this.api('GET', '/api/ssl');
257
+ this.ssl.loading = false;
258
+ if (r.ok) this.ssl.certs = Array.isArray(r.data) ? r.data : [];
259
+ else this.ssl.error = r.data.error || 'Failed to load certificates';
260
+ },
261
+
262
+ async renewCert(domain) {
263
+ this.ssl.renewingDomain = domain;
264
+ await this.api('POST', `/api/ssl/renew/${domain}`);
265
+ this.ssl.renewingDomain = null;
266
+ await this.loadSSL();
267
+ },
268
+
269
+ async renewAll() {
270
+ this.ssl.renewingDomain = 'all';
271
+ await this.api('POST', '/api/ssl/renew-all');
272
+ this.ssl.renewingDomain = null;
273
+ await this.loadSSL();
274
+ },
275
+
276
+ certStatus(cert) {
277
+ if (cert.daysLeft === null || cert.daysLeft === undefined) return 'Unknown';
278
+ if (cert.daysLeft > 30) return 'Healthy';
279
+ if (cert.daysLeft >= 10) return 'Expiring';
280
+ return 'Critical';
281
+ },
282
+ certDaysColor(cert) {
283
+ if (cert.daysLeft === null || cert.daysLeft === undefined) return 'text-gray-400';
284
+ if (cert.daysLeft > 30) return 'text-green-500';
285
+ if (cert.daysLeft >= 10) return 'text-yellow-500';
286
+ return 'text-red-500';
287
+ },
288
+ certBadgeClass(cert) {
289
+ const s = this.certStatus(cert);
290
+ if (s === 'Healthy') return 'bg-green-100 dark:bg-green-950/60 text-green-700 dark:text-green-400';
291
+ if (s === 'Expiring') return 'bg-yellow-100 dark:bg-yellow-950/60 text-yellow-700 dark:text-yellow-400';
292
+ if (s === 'Critical') return 'bg-red-100 dark:bg-red-950/60 text-red-700 dark:text-red-400';
293
+ return 'bg-gray-100 dark:bg-gray-800 text-gray-500';
294
+ },
295
+ certBadgeType(cert) {
296
+ const s = this.certStatus(cert);
297
+ if (s === 'Healthy') return 'success';
298
+ if (s === 'Expiring') return 'warning';
299
+ if (s === 'Critical') return 'danger';
300
+ return 'neutral';
301
+ },
302
+
303
+ // ── Domains ───────────────────────────────────────────────────────────────
304
+ async loadDomains() {
305
+ this.domains.loading = true;
306
+ const r = await this.api('GET', '/api/domains');
307
+ this.domains.loading = false;
308
+ if (r.ok) this.domains.list = r.data;
309
+ },
310
+
311
+ toggleDomainForm() {
312
+ if (this.domains.showForm && this.domains.dirty) {
313
+ if (!confirm('Discard unsaved changes?')) return;
314
+ }
315
+ this.domains.showForm = !this.domains.showForm;
316
+ if (!this.domains.showForm) {
317
+ this.resetDomainForm();
318
+ }
319
+ },
320
+
321
+ resetDomainForm() {
322
+ this.domains.form = JSON.parse(JSON.stringify(DEFAULT_FORM));
323
+ this.domains.editingName = null;
324
+ this.domains.dirty = false;
325
+ this.domains.error = '';
326
+ // Reset sections
327
+ this.sections = {
328
+ basic: true,
329
+ ssl: true,
330
+ performance: false,
331
+ security: false,
332
+ advanced: false,
333
+ };
334
+ },
335
+
336
+ // T023: editDomain method
337
+ async editDomain(name) {
338
+ // Check for unsaved changes
339
+ if (this.domains.dirty && this.domains.showForm) {
340
+ if (!confirm('Discard unsaved changes?')) return;
341
+ }
342
+
343
+ // Fetch domain data
344
+ const r = await this.api('GET', `/api/domains`);
345
+ if (!r.ok) {
346
+ this.domains.error = 'Failed to load domain data';
347
+ return;
348
+ }
349
+
350
+ const domain = r.data.find(d => d.name === name);
351
+ if (!domain) {
352
+ this.domains.error = 'Domain not found';
353
+ return;
354
+ }
355
+
356
+ // Populate form with domain data
357
+ this.domains.form = {
358
+ name: domain.name,
359
+ port: domain.port,
360
+ backendHost: domain.backendHost || '127.0.0.1',
361
+ upstreamType: domain.upstreamType || 'http',
362
+ www: domain.www || false,
363
+ ssl: {
364
+ enabled: domain.ssl?.enabled || false,
365
+ certPath: domain.ssl?.certPath || '',
366
+ keyPath: domain.ssl?.keyPath || '',
367
+ redirect: domain.ssl?.redirect ?? true,
368
+ hsts: domain.ssl?.hsts || false,
369
+ hstsMaxAge: domain.ssl?.hstsMaxAge || 31536000,
370
+ },
371
+ performance: {
372
+ maxBodySize: domain.performance?.maxBodySize || '10m',
373
+ readTimeout: domain.performance?.readTimeout || 60,
374
+ connectTimeout: domain.performance?.connectTimeout || 10,
375
+ proxyBuffers: domain.performance?.proxyBuffers || false,
376
+ gzip: domain.performance?.gzip ?? true,
377
+ gzipTypes: domain.performance?.gzipTypes || DEFAULT_FORM.performance.gzipTypes,
378
+ },
379
+ security: {
380
+ rateLimit: domain.security?.rateLimit || false,
381
+ rateLimitRate: domain.security?.rateLimitRate || 10,
382
+ rateLimitBurst: domain.security?.rateLimitBurst || 20,
383
+ securityHeaders: domain.security?.securityHeaders || false,
384
+ custom404: domain.security?.custom404 || false,
385
+ custom50x: domain.security?.custom50x || false,
386
+ },
387
+ advanced: {
388
+ accessLog: domain.advanced?.accessLog ?? true,
389
+ customLocations: domain.advanced?.customLocations || '',
390
+ },
391
+ };
392
+
393
+ this.domains.editingName = name;
394
+ this.domains.showForm = true;
395
+ this.domains.dirty = false;
396
+
397
+ // Expand all sections for editing
398
+ this.sections = {
399
+ basic: true,
400
+ ssl: true,
401
+ performance: true,
402
+ security: true,
403
+ advanced: true,
404
+ };
405
+ },
406
+
407
+ async saveDomain() {
408
+ this.domains.error = '';
409
+ this.domains.saving = true;
410
+
411
+ const method = this.domains.editingName ? 'PUT' : 'POST';
412
+ const url = this.domains.editingName
413
+ ? `/api/domains/${encodeURIComponent(this.domains.editingName)}`
414
+ : '/api/domains';
415
+
416
+ const r = await this.api(method, url, this.domains.form);
417
+ this.domains.saving = false;
418
+
419
+ if (r.ok) {
420
+ this.domains.showForm = false;
421
+ this.domains.dirty = false;
422
+ this.domains.editingName = null;
423
+ this.resetDomainForm();
424
+ await this.loadDomains();
425
+ } else {
426
+ this.domains.error = r.data.error || 'Failed to save domain';
427
+ }
428
+ },
429
+
430
+ async deleteDomain(name) {
431
+ if (!confirm(`Delete domain "${name}"? This cannot be undone.`)) return;
432
+ await this.api('DELETE', `/api/domains/${name}`);
433
+ await this.loadDomains();
434
+ },
435
+
436
+ async reloadDomain(name) {
437
+ await this.api('POST', `/api/domains/${name}/reload`);
438
+ },
439
+
440
+ // T024: Auto-populate SSL paths when SSL is enabled
441
+ autoPopulateCertPaths() {
442
+ if (this.domains.form.ssl.enabled) {
443
+ if (!this.domains.form.ssl.certPath || !this.domains.form.ssl.keyPath) {
444
+ const domain = this.domains.form.name;
445
+ if (domain) {
446
+ const platform = this.settings.platform;
447
+ if (platform === 'win32') {
448
+ this.domains.form.ssl.certPath = `C:\\Certbot\\live\\${domain}\\fullchain.pem`;
449
+ this.domains.form.ssl.keyPath = `C:\\Certbot\\live\\${domain}\\privkey.pem`;
450
+ } else {
451
+ this.domains.form.ssl.certPath = `/etc/letsencrypt/live/${domain}/fullchain.pem`;
452
+ this.domains.form.ssl.keyPath = `/etc/letsencrypt/live/${domain}/privkey.pem`;
453
+ }
454
+ }
455
+ }
456
+ }
457
+ },
458
+
459
+ // ── Settings (T022: Updated to capture platform) ───────────────────────────
460
+ async loadSettings() {
461
+ this.settings.loading = true;
462
+ const r = await this.api('GET', '/api/settings');
463
+ this.settings.loading = false;
464
+ if (r.ok) {
465
+ this.settings.dashboardPort = r.data.dashboardPort;
466
+ this.settings.nginxDir = r.data.nginxDir;
467
+ this.settings.certbotDir = r.data.certbotDir;
468
+ this.settings.platform = r.data.platform || 'linux'; // T022
469
+ }
470
+ },
471
+
472
+ async saveSettings() {
473
+ this.settings.loading = true; this.settings.msg = ''; this.settings.error = '';
474
+ const payload = {
475
+ dashboardPort: this.settings.dashboardPort,
476
+ nginxDir: this.settings.nginxDir,
477
+ certbotDir: this.settings.certbotDir,
478
+ };
479
+ if (this.settings.password) payload.dashboardPassword = this.settings.password;
480
+ const r = await this.api('POST', '/api/settings', payload);
481
+ this.settings.loading = false;
482
+ if (r.ok) { this.settings.msg = 'Settings saved ✓'; this.settings.password = ''; }
483
+ else { this.settings.error = r.data.error || 'Save failed'; }
484
+ },
485
+ },
486
+ }).mount('#app');
File without changes
@@ -0,0 +1,30 @@
1
+ import { Router } from 'express';
2
+ import { loadConfig } from '../../core/config.js';
3
+
4
+ const router = Router();
5
+
6
+ router.post('/login', (req, res) => {
7
+ const { password } = req.body;
8
+ const { dashboardPassword } = loadConfig();
9
+ if (password === dashboardPassword) {
10
+ req.session.authenticated = true;
11
+ res.json({ ok: true });
12
+ } else {
13
+ res.status(401).json({ ok: false, error: 'Invalid password' });
14
+ }
15
+ });
16
+
17
+ router.get('/auth', (req, res) => {
18
+ if (req.session.authenticated === true) {
19
+ res.json({ authenticated: true });
20
+ } else {
21
+ res.json({ authenticated: false });
22
+ }
23
+ });
24
+
25
+ router.post('/logout', (req, res) => {
26
+ req.session.destroy();
27
+ res.json({ ok: true });
28
+ });
29
+
30
+ export default router;