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.
- package/LICENSE +21 -0
- package/README.md +325 -0
- package/cli/index.js +91 -0
- package/cli/managers/domain-manager.js +451 -0
- package/cli/managers/nginx-manager.js +329 -0
- package/cli/managers/node-manager.js +275 -0
- package/cli/managers/ssl-manager.js +397 -0
- package/cli/menus/.gitkeep +0 -0
- package/cli/menus/dashboard.js +223 -0
- package/cli/menus/domains.js +5 -0
- package/cli/menus/nginx.js +5 -0
- package/cli/menus/nodejs.js +5 -0
- package/cli/menus/settings.js +83 -0
- package/cli/menus/ssl.js +5 -0
- package/core/config.js +37 -0
- package/core/db.js +30 -0
- package/core/detector.js +257 -0
- package/core/nginx-conf-generator.js +309 -0
- package/core/shell.js +151 -0
- package/dashboard/lib/.gitkeep +0 -0
- package/dashboard/lib/cert-reader.js +59 -0
- package/dashboard/lib/domains-db.js +51 -0
- package/dashboard/lib/nginx-conf-generator.js +16 -0
- package/dashboard/lib/nginx-service.js +282 -0
- package/dashboard/public/js/app.js +486 -0
- package/dashboard/routes/.gitkeep +0 -0
- package/dashboard/routes/auth.js +30 -0
- package/dashboard/routes/domains.js +300 -0
- package/dashboard/routes/nginx.js +151 -0
- package/dashboard/routes/settings.js +78 -0
- package/dashboard/routes/ssl.js +105 -0
- package/dashboard/server.js +79 -0
- package/dashboard/views/index.ejs +327 -0
- package/dashboard/views/partials/domain-form.ejs +229 -0
- package/dashboard/views/partials/domains-panel.ejs +66 -0
- package/dashboard/views/partials/login.ejs +50 -0
- package/dashboard/views/partials/nginx-panel.ejs +90 -0
- package/dashboard/views/partials/overview.ejs +67 -0
- package/dashboard/views/partials/settings-panel.ejs +37 -0
- package/dashboard/views/partials/sidebar.ejs +45 -0
- package/dashboard/views/partials/ssl-panel.ejs +53 -0
- package/data/.gitkeep +0 -0
- package/install.bat +41 -0
- package/install.ps1 +653 -0
- package/install.sh +452 -0
- package/lib/installer/.gitkeep +0 -0
- package/lib/installer/detect.sh +88 -0
- package/lib/installer/node-versions.sh +109 -0
- package/lib/installer/nvm-bootstrap.sh +77 -0
- package/lib/installer/picker.sh +163 -0
- package/lib/installer/progress.sh +25 -0
- package/package.json +67 -0
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cli/managers/ssl-manager.js
|
|
3
|
+
*
|
|
4
|
+
* SSL Manager — view certificate status, renew certificates, install certbot.
|
|
5
|
+
*
|
|
6
|
+
* Exported functions:
|
|
7
|
+
* - showSslManager() — interactive menu for managing SSL certificates
|
|
8
|
+
*
|
|
9
|
+
* All shell calls go through core/shell.js (run / runLive).
|
|
10
|
+
* Platform differences (Windows/Linux) are handled via isWindows guards.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import chalk from 'chalk';
|
|
14
|
+
import inquirer from 'inquirer';
|
|
15
|
+
import ora from 'ora';
|
|
16
|
+
import fs from 'fs/promises';
|
|
17
|
+
import path from 'path';
|
|
18
|
+
import { run, runLive } from '../../core/shell.js';
|
|
19
|
+
import { loadConfig } from '../../core/config.js';
|
|
20
|
+
|
|
21
|
+
const isWindows = process.platform === 'win32';
|
|
22
|
+
|
|
23
|
+
// ─── getCertbotDir ────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
function getCertbotDir() {
|
|
26
|
+
const config = loadConfig();
|
|
27
|
+
return path.join(config.certbotDir, 'live');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ─── parseCertExpiry ──────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
async function parseCertExpiry(certPath) {
|
|
33
|
+
const result = await run(`openssl x509 -enddate -noout -in "${certPath}"`);
|
|
34
|
+
|
|
35
|
+
if (result.success && result.stdout) {
|
|
36
|
+
const match = result.stdout.match(/notAfter=(.+)/);
|
|
37
|
+
if (match) {
|
|
38
|
+
const expiryDate = new Date(match[1].trim());
|
|
39
|
+
const daysLeft = Math.floor((expiryDate - Date.now()) / 86400000);
|
|
40
|
+
return { expiryDate, daysLeft };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Fallback: use file mtime + 90 days
|
|
45
|
+
try {
|
|
46
|
+
const stat = await fs.stat(certPath);
|
|
47
|
+
const expiryDate = new Date(stat.mtime.getTime() + 90 * 86400000);
|
|
48
|
+
const daysLeft = Math.floor((expiryDate - Date.now()) / 86400000);
|
|
49
|
+
return { expiryDate, daysLeft, errorReason: 'expiry estimated from file date' };
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ─── getCertbotExe ────────────────────────────────────────────────────────────
|
|
56
|
+
// Returns the certbot command to use, or null if not installed.
|
|
57
|
+
// On Windows, checks PATH first, then the well-known install location used by
|
|
58
|
+
// the official EFF winget package (EFF.Certbot).
|
|
59
|
+
// Always returns a PS-safe invocation string (& "..." for full paths).
|
|
60
|
+
|
|
61
|
+
const CERTBOT_WIN_EXE = 'C:\\Program Files\\Certbot\\bin\\certbot.exe';
|
|
62
|
+
|
|
63
|
+
async function getCertbotExe() {
|
|
64
|
+
if (!isWindows) {
|
|
65
|
+
const r = await run('which certbot');
|
|
66
|
+
return (r.exitCode === 0 && r.stdout.trim()) ? 'certbot' : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 1. On PATH?
|
|
70
|
+
const pathResult = await run('where.exe certbot');
|
|
71
|
+
if (pathResult.exitCode === 0 && pathResult.stdout.trim()) {
|
|
72
|
+
return 'certbot';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 2. Well-known install location (winget / official EFF installer)
|
|
76
|
+
const exeCheck = await run(`Test-Path "${CERTBOT_WIN_EXE}"`);
|
|
77
|
+
if (exeCheck.stdout.trim().toLowerCase() === 'true') {
|
|
78
|
+
// Must use & "..." in PowerShell to invoke a path with spaces
|
|
79
|
+
return `& "${CERTBOT_WIN_EXE}"`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function isCertbotInstalled() {
|
|
86
|
+
return (await getCertbotExe()) !== null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ─── isPort80Busy ─────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
async function isPort80Busy() {
|
|
92
|
+
const cmd = isWindows
|
|
93
|
+
? 'netstat -ano | findstr ":80"'
|
|
94
|
+
: "ss -tlnp | grep ':80 '";
|
|
95
|
+
const result = await run(cmd);
|
|
96
|
+
const busy = result.success && result.stdout.length > 0;
|
|
97
|
+
return { busy, detail: busy ? result.stdout.split('\n')[0].trim() : null };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ─── stopNginx / startNginx ───────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
async function stopNginx() {
|
|
103
|
+
const { nginxDir } = loadConfig();
|
|
104
|
+
const cmd = isWindows
|
|
105
|
+
? 'taskkill /f /IM nginx.exe'
|
|
106
|
+
: 'systemctl stop nginx';
|
|
107
|
+
await run(cmd);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function startNginx() {
|
|
111
|
+
const { nginxDir } = loadConfig();
|
|
112
|
+
const cmd = isWindows
|
|
113
|
+
? `& "${nginxDir}\\nginx.exe"`
|
|
114
|
+
: 'systemctl start nginx';
|
|
115
|
+
await run(cmd);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ─── listCerts ────────────────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
async function listCerts(liveDir) {
|
|
121
|
+
let entries;
|
|
122
|
+
try {
|
|
123
|
+
entries = await fs.readdir(liveDir, { withFileTypes: true });
|
|
124
|
+
} catch {
|
|
125
|
+
return [];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const domains = entries.filter(e => e.isDirectory()).map(e => e.name);
|
|
129
|
+
const certs = [];
|
|
130
|
+
|
|
131
|
+
for (const domain of domains) {
|
|
132
|
+
const certPath = path.join(liveDir, domain, 'cert.pem');
|
|
133
|
+
|
|
134
|
+
let status = 'error';
|
|
135
|
+
let expiryDate = null;
|
|
136
|
+
let daysLeft = null;
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
await fs.stat(certPath);
|
|
140
|
+
const expiry = await parseCertExpiry(certPath);
|
|
141
|
+
if (expiry !== null) {
|
|
142
|
+
expiryDate = expiry.expiryDate;
|
|
143
|
+
daysLeft = expiry.daysLeft;
|
|
144
|
+
if (daysLeft > 30) {
|
|
145
|
+
status = 'healthy';
|
|
146
|
+
} else if (daysLeft >= 10) {
|
|
147
|
+
status = 'expiring';
|
|
148
|
+
} else {
|
|
149
|
+
status = 'critical';
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
} catch {
|
|
153
|
+
status = 'error';
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
certs.push({ domain, status, expiryDate, daysLeft });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return certs;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ─── renderCertRow ────────────────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
function renderCertRow(cert) {
|
|
165
|
+
const domainPadded = cert.domain.padEnd(35);
|
|
166
|
+
|
|
167
|
+
if (cert.status === 'error') {
|
|
168
|
+
console.log(` ${chalk.gray('❌')} ${chalk.gray(domainPadded)} ${chalk.gray('ERROR')}`);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const expiryStr = cert.expiryDate
|
|
173
|
+
? cert.expiryDate.toDateString().replace(/^\S+\s/, '')
|
|
174
|
+
: '—';
|
|
175
|
+
const daysStr = cert.daysLeft !== null ? `${cert.daysLeft}d` : '—';
|
|
176
|
+
|
|
177
|
+
if (cert.status === 'healthy') {
|
|
178
|
+
console.log(` ${chalk.green('✅')} ${chalk.green(domainPadded)} ${chalk.green(daysStr.padEnd(6))} ${chalk.green(`(${expiryStr})`)}`);
|
|
179
|
+
} else if (cert.status === 'expiring') {
|
|
180
|
+
console.log(` ${chalk.yellow('⚠️')} ${chalk.yellow(domainPadded)} ${chalk.yellow(daysStr.padEnd(6))} ${chalk.yellow(`(${expiryStr})`)}`);
|
|
181
|
+
} else {
|
|
182
|
+
console.log(` ${chalk.red('❌')} ${chalk.red(domainPadded)} ${chalk.red(daysStr.padEnd(6))} ${chalk.red(`(${expiryStr})`)}`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ─── renewCert ────────────────────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
async function renewCert(domain) {
|
|
189
|
+
const certbotExe = await getCertbotExe();
|
|
190
|
+
if (!certbotExe) {
|
|
191
|
+
console.log(chalk.red('\n certbot not found — install it first\n'));
|
|
192
|
+
return { domain, success: false, exitCode: null };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
await stopNginx();
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
const portCheck = await isPort80Busy();
|
|
199
|
+
if (portCheck.busy) {
|
|
200
|
+
console.log(chalk.yellow(`\n ⚠ Port 80 is in use: ${portCheck.detail}`));
|
|
201
|
+
console.log(chalk.yellow(' Stop that process before renewing.\n'));
|
|
202
|
+
return { domain, success: false, exitCode: null };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const exitCode = await runLive(
|
|
206
|
+
`${certbotExe} certonly --standalone -d "${domain}"`,
|
|
207
|
+
{ timeout: 120000 },
|
|
208
|
+
);
|
|
209
|
+
return { domain, success: exitCode === 0, exitCode };
|
|
210
|
+
} finally {
|
|
211
|
+
await startNginx();
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ─── renewExpiring ────────────────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
async function renewExpiring(certs) {
|
|
218
|
+
const expiring = certs.filter(c => c.daysLeft !== null && c.daysLeft < 30);
|
|
219
|
+
if (expiring.length === 0) return [];
|
|
220
|
+
|
|
221
|
+
const certbotExe = await getCertbotExe();
|
|
222
|
+
if (!certbotExe) return [];
|
|
223
|
+
|
|
224
|
+
await stopNginx();
|
|
225
|
+
|
|
226
|
+
const results = [];
|
|
227
|
+
try {
|
|
228
|
+
for (const cert of expiring) {
|
|
229
|
+
const exitCode = await runLive(
|
|
230
|
+
`${certbotExe} certonly --standalone -d "${cert.domain}"`,
|
|
231
|
+
{ timeout: 120000 },
|
|
232
|
+
);
|
|
233
|
+
results.push({ domain: cert.domain, success: exitCode === 0, exitCode });
|
|
234
|
+
}
|
|
235
|
+
} finally {
|
|
236
|
+
await startNginx();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return results;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ─── installCertbot ───────────────────────────────────────────────────────────
|
|
243
|
+
|
|
244
|
+
async function installCertbot() {
|
|
245
|
+
if (isWindows) {
|
|
246
|
+
// Official EFF certbot package via winget — installs to
|
|
247
|
+
// C:\Program Files\Certbot\bin\ and adds it to the system PATH.
|
|
248
|
+
// pip install certbot is NOT used: it lands in a user Python Scripts
|
|
249
|
+
// folder that is not on PATH and cannot renew certs system-wide.
|
|
250
|
+
console.log(chalk.gray('\n Installing via winget (EFF.Certbot) ...\n'));
|
|
251
|
+
const exitCode = await runLive(
|
|
252
|
+
'winget install -e --id EFF.Certbot --accept-package-agreements --accept-source-agreements',
|
|
253
|
+
{ timeout: 180000 },
|
|
254
|
+
);
|
|
255
|
+
if (exitCode !== 0) return { success: false };
|
|
256
|
+
// winget updates the system PATH but the current session won't see it yet.
|
|
257
|
+
// Verify via the known exe path directly.
|
|
258
|
+
const check = await run(`Test-Path "${CERTBOT_WIN_EXE}"`);
|
|
259
|
+
return { success: check.stdout.trim().toLowerCase() === 'true' };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const exitCode = await runLive('sudo apt-get install -y certbot', { timeout: 180000 });
|
|
263
|
+
return { success: exitCode === 0 };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ─── showSslManager ───────────────────────────────────────────────────────────
|
|
267
|
+
|
|
268
|
+
export async function showSslManager() {
|
|
269
|
+
while (true) {
|
|
270
|
+
const liveDir = getCertbotDir();
|
|
271
|
+
|
|
272
|
+
const spinner = ora('Loading certificates…').start();
|
|
273
|
+
const certs = await listCerts(liveDir);
|
|
274
|
+
spinner.stop();
|
|
275
|
+
|
|
276
|
+
console.log(chalk.bold('\n SSL Manager'));
|
|
277
|
+
console.log(chalk.gray(' ' + '─'.repeat(40)));
|
|
278
|
+
|
|
279
|
+
if (certs.length === 0) {
|
|
280
|
+
console.log(chalk.gray(' No certificates found'));
|
|
281
|
+
} else {
|
|
282
|
+
for (const cert of certs) {
|
|
283
|
+
renderCertRow(cert);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
console.log();
|
|
287
|
+
|
|
288
|
+
const choices = [
|
|
289
|
+
'Renew a certificate',
|
|
290
|
+
'Renew all expiring (< 30 days)',
|
|
291
|
+
'Install certbot',
|
|
292
|
+
new inquirer.Separator(),
|
|
293
|
+
'← Back',
|
|
294
|
+
];
|
|
295
|
+
|
|
296
|
+
let choice;
|
|
297
|
+
try {
|
|
298
|
+
({ choice } = await inquirer.prompt([{
|
|
299
|
+
type: 'list',
|
|
300
|
+
name: 'choice',
|
|
301
|
+
message: 'Select an option:',
|
|
302
|
+
choices,
|
|
303
|
+
}]));
|
|
304
|
+
} catch (err) {
|
|
305
|
+
if (err.name === 'ExitPromptError') return;
|
|
306
|
+
throw err;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
switch (choice) {
|
|
310
|
+
case 'Renew a certificate': {
|
|
311
|
+
const installed = await isCertbotInstalled();
|
|
312
|
+
if (!installed) {
|
|
313
|
+
console.log(chalk.yellow('\n ⚠ certbot not found — select "Install certbot" first\n'));
|
|
314
|
+
break;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (certs.length === 0) {
|
|
318
|
+
console.log(chalk.gray('\n No certificates found to renew\n'));
|
|
319
|
+
break;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
let selectedDomain;
|
|
323
|
+
try {
|
|
324
|
+
({ selectedDomain } = await inquirer.prompt([{
|
|
325
|
+
type: 'list',
|
|
326
|
+
name: 'selectedDomain',
|
|
327
|
+
message: 'Select domain to renew:',
|
|
328
|
+
choices: certs.map(c => c.domain),
|
|
329
|
+
}]));
|
|
330
|
+
} catch (err) {
|
|
331
|
+
if (err.name === 'ExitPromptError') return;
|
|
332
|
+
throw err;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const renewResult = await renewCert(selectedDomain);
|
|
336
|
+
if (renewResult.success) {
|
|
337
|
+
console.log(chalk.green('\n ✓ Renewed successfully\n'));
|
|
338
|
+
} else {
|
|
339
|
+
console.log(chalk.red('\n ✗ Renewal failed — see output above\n'));
|
|
340
|
+
}
|
|
341
|
+
break;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
case 'Renew all expiring (< 30 days)': {
|
|
345
|
+
const installed = await isCertbotInstalled();
|
|
346
|
+
if (!installed) {
|
|
347
|
+
console.log(chalk.yellow('\n ⚠ certbot not found — select "Install certbot" first\n'));
|
|
348
|
+
break;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const results = await renewExpiring(certs);
|
|
352
|
+
if (results.length === 0) {
|
|
353
|
+
console.log(chalk.gray('\n No certificates expiring within 30 days\n'));
|
|
354
|
+
} else {
|
|
355
|
+
console.log();
|
|
356
|
+
for (const r of results) {
|
|
357
|
+
if (r.success) {
|
|
358
|
+
console.log(` ${chalk.green('✓ ' + r.domain)}`);
|
|
359
|
+
} else {
|
|
360
|
+
console.log(` ${chalk.red('✗ ' + r.domain)}`);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
console.log();
|
|
364
|
+
}
|
|
365
|
+
break;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
case 'Install certbot': {
|
|
369
|
+
const alreadyInstalled = await isCertbotInstalled();
|
|
370
|
+
if (alreadyInstalled) {
|
|
371
|
+
console.log(chalk.gray('\n certbot is already installed\n'));
|
|
372
|
+
break;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const installResult = await installCertbot();
|
|
376
|
+
if (installResult.success) {
|
|
377
|
+
console.log(chalk.green('\n ✓ certbot installed successfully\n'));
|
|
378
|
+
} else {
|
|
379
|
+
console.log(chalk.red('\n ✗ Installation failed\n'));
|
|
380
|
+
}
|
|
381
|
+
break;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
case '← Back':
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (choice !== '← Back') {
|
|
389
|
+
try {
|
|
390
|
+
await inquirer.prompt([{ type: 'input', name: '_', message: 'Press Enter to continue...' }]);
|
|
391
|
+
} catch (err) {
|
|
392
|
+
if (err.name === 'ExitPromptError') return;
|
|
393
|
+
throw err;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import inquirer from 'inquirer';
|
|
3
|
+
import ora from 'ora';
|
|
4
|
+
import { spawn } from 'child_process';
|
|
5
|
+
import { createServer } from 'net';
|
|
6
|
+
import { openSync, closeSync, writeSync } from 'fs';
|
|
7
|
+
import fs from 'fs/promises';
|
|
8
|
+
import http from 'http';
|
|
9
|
+
import { fileURLToPath } from 'url';
|
|
10
|
+
import path from 'path';
|
|
11
|
+
import { loadConfig } from '../../core/config.js';
|
|
12
|
+
import { dbGet, dbSet } from '../../core/db.js';
|
|
13
|
+
import { run } from '../../core/shell.js';
|
|
14
|
+
|
|
15
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const isWindows = process.platform === 'win32';
|
|
17
|
+
const LOG_PATH = path.resolve(__dirname, '../../data/dashboard.log');
|
|
18
|
+
const SERVER_PATH = path.resolve(__dirname, '../../dashboard/server.js');
|
|
19
|
+
|
|
20
|
+
// ─── Port helpers ─────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
function isPortFree(port) {
|
|
23
|
+
return new Promise((resolve) => {
|
|
24
|
+
const srv = createServer();
|
|
25
|
+
srv.once('error', () => resolve(false));
|
|
26
|
+
srv.once('listening', () => srv.close(() => resolve(true)));
|
|
27
|
+
srv.listen(port, '127.0.0.1');
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function findFreePort(startPort) {
|
|
32
|
+
let port = startPort;
|
|
33
|
+
while (!(await isPortFree(port))) {
|
|
34
|
+
port++;
|
|
35
|
+
if (port > 65535) throw new Error('No free port found');
|
|
36
|
+
}
|
|
37
|
+
return port;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ─── HTTP ping — most reliable way to know Express is actually up ─────────────
|
|
41
|
+
|
|
42
|
+
function httpPing(port, timeoutMs = 2000) {
|
|
43
|
+
return new Promise((resolve) => {
|
|
44
|
+
const req = http.get(`http://localhost:${port}/`, (res) => {
|
|
45
|
+
res.destroy();
|
|
46
|
+
resolve(true);
|
|
47
|
+
});
|
|
48
|
+
req.on('error', () => resolve(false));
|
|
49
|
+
req.setTimeout(timeoutMs, () => { req.destroy(); resolve(false); });
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Poll every 500 ms for up to `maxWaitMs`
|
|
54
|
+
async function waitForServer(port, maxWaitMs = 8000) {
|
|
55
|
+
const deadline = Date.now() + maxWaitMs;
|
|
56
|
+
while (Date.now() < deadline) {
|
|
57
|
+
if (await httpPing(port, 1000)) return true;
|
|
58
|
+
await new Promise(r => setTimeout(r, 500));
|
|
59
|
+
}
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ─── PID check ────────────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
function isPidAlive(pid) {
|
|
66
|
+
if (!pid) return false;
|
|
67
|
+
try { process.kill(pid, 0); return true; } catch { return false; }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ─── Status ───────────────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
async function getDashboardStatus() {
|
|
73
|
+
const { dashboardPort } = loadConfig();
|
|
74
|
+
const storedPid = dbGet('dashboard-pid');
|
|
75
|
+
const storedPort = dbGet('dashboard-port') ?? dashboardPort;
|
|
76
|
+
const pidAlive = isPidAlive(storedPid);
|
|
77
|
+
const responding = pidAlive && await httpPing(storedPort, 1000);
|
|
78
|
+
|
|
79
|
+
if (storedPid && !pidAlive) {
|
|
80
|
+
dbSet('dashboard-pid', null);
|
|
81
|
+
dbSet('dashboard-port', null);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
running: responding,
|
|
86
|
+
port: storedPort,
|
|
87
|
+
configPort: dashboardPort,
|
|
88
|
+
pid: responding ? storedPid : null,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ─── Start ────────────────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
async function startDashboard(port) {
|
|
95
|
+
await fs.mkdir(path.dirname(LOG_PATH), { recursive: true });
|
|
96
|
+
|
|
97
|
+
// openSync gives a real fd immediately — required by spawn's stdio option
|
|
98
|
+
const logFd = openSync(LOG_PATH, 'a');
|
|
99
|
+
writeSync(logFd, `\n--- dashboard start ${new Date().toISOString()} port=${port} ---\n`);
|
|
100
|
+
|
|
101
|
+
const child = spawn(process.execPath, [SERVER_PATH], {
|
|
102
|
+
detached: true,
|
|
103
|
+
stdio: ['ignore', logFd, logFd],
|
|
104
|
+
windowsHide: true,
|
|
105
|
+
env: { ...process.env, DASHBOARD_PORT: String(port) },
|
|
106
|
+
});
|
|
107
|
+
child.unref();
|
|
108
|
+
// Parent closes its copy of the fd; the child keeps its own inherited copy
|
|
109
|
+
closeSync(logFd);
|
|
110
|
+
|
|
111
|
+
dbSet('dashboard-pid', child.pid);
|
|
112
|
+
dbSet('dashboard-port', port);
|
|
113
|
+
|
|
114
|
+
// Wait up to 8 s for Express to respond (polls every 500 ms)
|
|
115
|
+
const up = await waitForServer(port, 8000);
|
|
116
|
+
return { success: up, pid: child.pid, port, logPath: LOG_PATH };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ─── Stop ─────────────────────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
async function stopDashboard(pid) {
|
|
122
|
+
if (!pid) return { success: false };
|
|
123
|
+
try {
|
|
124
|
+
if (isWindows) {
|
|
125
|
+
await run(`taskkill /PID ${pid} /F`);
|
|
126
|
+
} else {
|
|
127
|
+
process.kill(pid, 'SIGTERM');
|
|
128
|
+
}
|
|
129
|
+
} catch { /* already gone */ }
|
|
130
|
+
dbSet('dashboard-pid', null);
|
|
131
|
+
dbSet('dashboard-port', null);
|
|
132
|
+
return { success: true };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ─── Browser ──────────────────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
function openBrowser(url) {
|
|
138
|
+
const cmd = isWindows ? `Start-Process "${url}"` : `xdg-open "${url}"`;
|
|
139
|
+
run(cmd).catch(() => {});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ─── Menu ─────────────────────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
export default async function dashboardMenu() {
|
|
145
|
+
while (true) {
|
|
146
|
+
const spinner = ora('Checking dashboard status...').start();
|
|
147
|
+
const status = await getDashboardStatus();
|
|
148
|
+
spinner.stop();
|
|
149
|
+
|
|
150
|
+
const url = `http://localhost:${status.port}`;
|
|
151
|
+
|
|
152
|
+
console.log(chalk.bold('\n Dashboard'));
|
|
153
|
+
console.log(chalk.gray(' ' + '─'.repeat(40)));
|
|
154
|
+
|
|
155
|
+
if (status.running) {
|
|
156
|
+
console.log(` ${chalk.green('✅ Running')} | ${chalk.cyan(url)} | PID ${status.pid}`);
|
|
157
|
+
} else {
|
|
158
|
+
console.log(` ${chalk.red('❌ Stopped')} | port ${status.configPort}`);
|
|
159
|
+
}
|
|
160
|
+
console.log();
|
|
161
|
+
|
|
162
|
+
const choices = status.running
|
|
163
|
+
? ['Open in browser', 'Stop dashboard', new inquirer.Separator(), '← Back']
|
|
164
|
+
: ['Start dashboard', new inquirer.Separator(), '← Back'];
|
|
165
|
+
|
|
166
|
+
let choice;
|
|
167
|
+
try {
|
|
168
|
+
({ choice } = await inquirer.prompt([{
|
|
169
|
+
type: 'list',
|
|
170
|
+
name: 'choice',
|
|
171
|
+
message: 'Select an option:',
|
|
172
|
+
choices,
|
|
173
|
+
}]));
|
|
174
|
+
} catch (err) {
|
|
175
|
+
if (err.name === 'ExitPromptError') return;
|
|
176
|
+
throw err;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
switch (choice) {
|
|
180
|
+
case 'Start dashboard': {
|
|
181
|
+
const portToUse = await findFreePort(status.configPort);
|
|
182
|
+
|
|
183
|
+
if (portToUse !== status.configPort) {
|
|
184
|
+
console.log(chalk.yellow(`\n Port ${status.configPort} is in use — using port ${portToUse} instead.`));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const sp = ora(`Starting dashboard on port ${portToUse}...`).start();
|
|
188
|
+
const result = await startDashboard(portToUse);
|
|
189
|
+
|
|
190
|
+
if (result.success) {
|
|
191
|
+
sp.succeed(`Dashboard started -> http://localhost:${portToUse} (PID ${result.pid})`);
|
|
192
|
+
} else {
|
|
193
|
+
sp.fail(`Dashboard did not start — check log: ${result.logPath}`);
|
|
194
|
+
}
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
case 'Stop dashboard': {
|
|
199
|
+
const sp = ora('Stopping dashboard...').start();
|
|
200
|
+
await stopDashboard(status.pid);
|
|
201
|
+
sp.succeed('Dashboard stopped');
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
case 'Open in browser':
|
|
206
|
+
openBrowser(url);
|
|
207
|
+
console.log(chalk.gray(`\n Opening ${url} ...\n`));
|
|
208
|
+
break;
|
|
209
|
+
|
|
210
|
+
case '← Back':
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (choice !== '← Back') {
|
|
215
|
+
try {
|
|
216
|
+
await inquirer.prompt([{ type: 'input', name: '_', message: 'Press Enter to continue...' }]);
|
|
217
|
+
} catch (err) {
|
|
218
|
+
if (err.name === 'ExitPromptError') return;
|
|
219
|
+
throw err;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|