easy-devops 0.1.1 → 0.1.3

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/cli/index.js CHANGED
@@ -11,6 +11,7 @@ import sslMenu from './menus/ssl.js';
11
11
  import domainsMenu from './menus/domains.js';
12
12
  import dashboardMenu from './menus/dashboard.js';
13
13
  import settingsMenu from './menus/settings.js';
14
+ import updateMenu from './menus/update.js';
14
15
 
15
16
  const require = createRequire(import.meta.url);
16
17
  const { version } = require('../package.json');
@@ -44,6 +45,7 @@ async function showMainMenu() {
44
45
  '🔗 Domain Manager',
45
46
  '🎛️ Open Dashboard',
46
47
  '⚙️ Settings',
48
+ '🔄 Check for Updates',
47
49
  '✖ Exit',
48
50
  ],
49
51
  }]);
@@ -58,6 +60,7 @@ async function dispatch(choice) {
58
60
  case '🔗 Domain Manager': await domainsMenu(); break;
59
61
  case '🎛️ Open Dashboard': await dashboardMenu(); break;
60
62
  case '⚙️ Settings': await settingsMenu(); break;
63
+ case '🔄 Check for Updates': await updateMenu(); break;
61
64
  case '✖ Exit': process.exit(0);
62
65
  }
63
66
  }
@@ -15,6 +15,7 @@ import inquirer from 'inquirer';
15
15
  import ora from 'ora';
16
16
  import { run, runLive } from '../../core/shell.js';
17
17
  import { loadConfig } from '../../core/config.js';
18
+ import { ensureNginxInclude } from '../../core/nginx-conf-generator.js';
18
19
 
19
20
  const isWindows = process.platform === 'win32';
20
21
 
@@ -51,6 +52,7 @@ async function getNginxStatus(nginxDir) {
51
52
  // ─── testConfig ───────────────────────────────────────────────────────────────
52
53
 
53
54
  async function testConfig(nginxExe, nginxDir) {
55
+ await ensureNginxInclude(nginxDir);
54
56
  const cmd = isWindows ? `& "${nginxExe}" -t` : 'nginx -t';
55
57
  const result = await run(cmd, { cwd: nginxDir });
56
58
  return {
@@ -242,25 +242,84 @@ async function renewExpiring(certs) {
242
242
  // ─── installCertbot ───────────────────────────────────────────────────────────
243
243
 
244
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.
245
+ if (!isWindows) {
246
+ const exitCode = await runLive('sudo apt-get install -y certbot', { timeout: 180000 });
247
+ return { success: exitCode === 0 };
248
+ }
249
+
250
+ // ── Windows: winget → Chocolatey → direct EFF installer ──────────────────────
251
+
252
+ // 1. Try winget (Windows 10/11 desktop; not on Windows Server by default)
253
+ const wingetCheck = await run('where.exe winget 2>$null');
254
+ if (wingetCheck.success && wingetCheck.stdout.trim().length > 0) {
250
255
  console.log(chalk.gray('\n Installing via winget (EFF.Certbot) ...\n'));
251
256
  const exitCode = await runLive(
252
257
  'winget install -e --id EFF.Certbot --accept-package-agreements --accept-source-agreements',
253
258
  { timeout: 180000 },
254
259
  );
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
+ if (exitCode === 0) {
261
+ const check = await run(`Test-Path "${CERTBOT_WIN_EXE}"`);
262
+ return { success: check.stdout.trim().toLowerCase() === 'true' };
263
+ }
264
+ console.log(chalk.yellow(' winget failed, trying Chocolatey...\n'));
260
265
  }
261
266
 
262
- const exitCode = await runLive('sudo apt-get install -y certbot', { timeout: 180000 });
263
- return { success: exitCode === 0 };
267
+ // 2. Try Chocolatey (common on Windows Server)
268
+ const chocoCheck = await run('where.exe choco 2>$null');
269
+ if (chocoCheck.success && chocoCheck.stdout.trim().length > 0) {
270
+ console.log(chalk.gray('\n Installing via Chocolatey ...\n'));
271
+ const exitCode = await runLive('choco install certbot -y', { timeout: 180000 });
272
+ if (exitCode === 0) {
273
+ const check = await run(`Test-Path "${CERTBOT_WIN_EXE}"`);
274
+ return { success: check.stdout.trim().toLowerCase() === 'true' };
275
+ }
276
+ console.log(chalk.yellow(' Chocolatey failed, trying direct download...\n'));
277
+ }
278
+
279
+ // 3. Direct download — try multiple sources in order
280
+ const INSTALLER_FILENAME = 'certbot-beta-installer-win_amd64_signed.exe';
281
+ const downloadSources = [
282
+ `https://github.com/certbot/certbot/releases/latest/download/${INSTALLER_FILENAME}`,
283
+ `https://dl.eff.org/${INSTALLER_FILENAME}`,
284
+ ];
285
+
286
+ let downloaded = false;
287
+ for (const url of downloadSources) {
288
+ const label = new URL(url).hostname;
289
+ console.log(chalk.gray(`\n Downloading certbot installer from ${label} ...\n`));
290
+
291
+ const downloadResult = await run(
292
+ `$ProgressPreference='SilentlyContinue'; Invoke-WebRequest -Uri '${url}' -OutFile "$env:TEMP\\certbot-installer.exe" -UseBasicParsing -TimeoutSec 120`,
293
+ { timeout: 130000 },
294
+ );
295
+
296
+ if (downloadResult.success) {
297
+ downloaded = true;
298
+ break;
299
+ }
300
+
301
+ console.log(chalk.yellow(` Failed from ${label}: ${(downloadResult.stderr || downloadResult.stdout).split('\n')[0].trim()}`));
302
+ }
303
+
304
+ if (!downloaded) {
305
+ console.log(chalk.red('\n Download failed from all sources.'));
306
+ console.log(chalk.gray(' Install manually: https://certbot.eff.org/instructions?ws=other&os=windows\n'));
307
+ return { success: false };
308
+ }
309
+
310
+ console.log(chalk.gray(' Running installer silently ...\n'));
311
+
312
+ await run(
313
+ `Start-Process -FilePath "$env:TEMP\\certbot-installer.exe" -ArgumentList '/S' -Wait -NoNewWindow`,
314
+ { timeout: 120000 },
315
+ );
316
+
317
+ // Cleanup installer
318
+ await run(`Remove-Item -Force "$env:TEMP\\certbot-installer.exe" -ErrorAction SilentlyContinue`);
319
+
320
+ // Verify
321
+ const check = await run(`Test-Path "${CERTBOT_WIN_EXE}"`);
322
+ return { success: check.stdout.trim().toLowerCase() === 'true' };
264
323
  }
265
324
 
266
325
  // ─── showSslManager ───────────────────────────────────────────────────────────
@@ -69,7 +69,7 @@ function isPidAlive(pid) {
69
69
 
70
70
  // ─── Status ───────────────────────────────────────────────────────────────────
71
71
 
72
- async function getDashboardStatus() {
72
+ export async function getDashboardStatus() {
73
73
  const { dashboardPort } = loadConfig();
74
74
  const storedPid = dbGet('dashboard-pid');
75
75
  const storedPort = dbGet('dashboard-port') ?? dashboardPort;
@@ -91,7 +91,7 @@ async function getDashboardStatus() {
91
91
 
92
92
  // ─── Start ────────────────────────────────────────────────────────────────────
93
93
 
94
- async function startDashboard(port) {
94
+ export async function startDashboard(port) {
95
95
  await fs.mkdir(path.dirname(LOG_PATH), { recursive: true });
96
96
 
97
97
  // openSync gives a real fd immediately — required by spawn's stdio option
@@ -118,7 +118,7 @@ async function startDashboard(port) {
118
118
 
119
119
  // ─── Stop ─────────────────────────────────────────────────────────────────────
120
120
 
121
- async function stopDashboard(pid) {
121
+ export async function stopDashboard(pid) {
122
122
  if (!pid) return { success: false };
123
123
  try {
124
124
  if (isWindows) {
@@ -0,0 +1,178 @@
1
+ /**
2
+ * cli/menus/update.js
3
+ *
4
+ * Check for updates and upgrade easy-devops in place.
5
+ *
6
+ * Flow:
7
+ * 1. Fetch latest version from npm registry
8
+ * 2. If an update is available, offer to install it
9
+ * 3. Before installing: record dashboard running state in DB
10
+ * (key: 'update-pre-dashboard') so it survives a crash mid-update
11
+ * 4. Stop dashboard if it was running
12
+ * 5. Run: npm install -g easy-devops@<latest>
13
+ * 6. Re-start dashboard if it was running before
14
+ * 7. Clear the saved state key
15
+ */
16
+
17
+ import chalk from 'chalk';
18
+ import inquirer from 'inquirer';
19
+ import ora from 'ora';
20
+ import path from 'path';
21
+ import { fileURLToPath } from 'url';
22
+ import { createRequire } from 'module';
23
+ import { run } from '../../core/shell.js';
24
+ import { dbGet, dbSet } from '../../core/db.js';
25
+ import { loadConfig } from '../../core/config.js';
26
+ import { getDashboardStatus, startDashboard, stopDashboard } from './dashboard.js';
27
+
28
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
29
+ const require = createRequire(import.meta.url);
30
+ const { version: currentVersion } = require('../../package.json');
31
+
32
+ // ─── Version helpers ──────────────────────────────────────────────────────────
33
+
34
+ async function fetchLatestVersion() {
35
+ const result = await run('npm view easy-devops version', { timeout: 20000 });
36
+ if (result.success && result.stdout.trim()) return result.stdout.trim();
37
+ return null;
38
+ }
39
+
40
+ function isNewer(latest, current) {
41
+ const parse = v => v.replace(/^v/, '').split('.').map(Number);
42
+ const [la, lb, lc] = parse(latest);
43
+ const [ca, cb, cc] = parse(current);
44
+ if (la !== ca) return la > ca;
45
+ if (lb !== cb) return lb > cb;
46
+ return lc > cc;
47
+ }
48
+
49
+ // ─── Recover interrupted update ───────────────────────────────────────────────
50
+ // If a previous update crashed after stopping the dashboard but before restarting
51
+ // it, the key 'update-pre-dashboard' is still set. Offer to restart it.
52
+
53
+ async function recoverIfNeeded() {
54
+ const saved = dbGet('update-pre-dashboard');
55
+ if (!saved?.wasRunning) return;
56
+
57
+ console.log(chalk.yellow('\n A previous update left the dashboard stopped.'));
58
+ const { restart } = await inquirer.prompt([{
59
+ type: 'confirm',
60
+ name: 'restart',
61
+ message: 'Restart the dashboard now?',
62
+ default: true,
63
+ }]);
64
+
65
+ if (restart) {
66
+ const port = saved.port || loadConfig().dashboardPort;
67
+ const sp = ora(`Starting dashboard on port ${port}...`).start();
68
+ const res = await startDashboard(port);
69
+ res.success
70
+ ? sp.succeed(`Dashboard restarted on port ${port}`)
71
+ : sp.fail('Could not restart dashboard — use the Dashboard menu');
72
+ }
73
+
74
+ dbSet('update-pre-dashboard', null);
75
+ }
76
+
77
+ // ─── Perform update ───────────────────────────────────────────────────────────
78
+
79
+ async function performUpdate(latestVersion) {
80
+ // Step 1 — snapshot dashboard state and persist it
81
+ const status = await getDashboardStatus();
82
+ dbSet('update-pre-dashboard', {
83
+ wasRunning: status.running,
84
+ pid: status.pid,
85
+ port: status.port,
86
+ });
87
+
88
+ // Step 2 — stop dashboard if running
89
+ if (status.running) {
90
+ const sp = ora('Stopping dashboard...').start();
91
+ await stopDashboard(status.pid);
92
+ sp.succeed('Dashboard stopped');
93
+ }
94
+
95
+ // Step 3 — install new version
96
+ const sp = ora(`Installing easy-devops@${latestVersion}...`).start();
97
+ const result = await run(`npm install -g easy-devops@${latestVersion}`, { timeout: 120000 });
98
+
99
+ if (!result.success) {
100
+ sp.fail('Update failed');
101
+ console.log(chalk.red('\n' + (result.stderr || result.stdout) + '\n'));
102
+ // Leave 'update-pre-dashboard' in DB so recovery runs on next launch
103
+ return false;
104
+ }
105
+
106
+ sp.succeed(`Updated to v${latestVersion}`);
107
+
108
+ // Step 4 — restart dashboard if it was running before
109
+ const saved = dbGet('update-pre-dashboard');
110
+ dbSet('update-pre-dashboard', null);
111
+
112
+ if (saved?.wasRunning) {
113
+ const port = saved.port || loadConfig().dashboardPort;
114
+ const restSp = ora(`Restarting dashboard on port ${port}...`).start();
115
+ const res = await startDashboard(port);
116
+ res.success
117
+ ? restSp.succeed(`Dashboard restarted on port ${port}`)
118
+ : restSp.fail('Could not restart dashboard — use the Dashboard menu');
119
+ }
120
+
121
+ return true;
122
+ }
123
+
124
+ // ─── Menu ─────────────────────────────────────────────────────────────────────
125
+
126
+ export default async function updateMenu() {
127
+ // Recover from a crashed previous update first
128
+ await recoverIfNeeded();
129
+
130
+ const spinner = ora('Checking for updates...').start();
131
+ const latestVersion = await fetchLatestVersion();
132
+ spinner.stop();
133
+
134
+ console.log(chalk.bold('\n Check for Updates'));
135
+ console.log(chalk.gray(' ' + '─'.repeat(40)));
136
+ console.log(` Current version : ${chalk.cyan('v' + currentVersion)}`);
137
+
138
+ if (!latestVersion) {
139
+ console.log(chalk.yellow(' Could not reach npm registry. Check your internet connection.\n'));
140
+ await inquirer.prompt([{ type: 'input', name: '_', message: 'Press Enter to go back...' }]);
141
+ return;
142
+ }
143
+
144
+ const updateAvailable = isNewer(latestVersion, currentVersion);
145
+
146
+ if (updateAvailable) {
147
+ console.log(` Latest version : ${chalk.green('v' + latestVersion)} ${chalk.yellow('← update available')}\n`);
148
+ } else {
149
+ console.log(` Latest version : ${chalk.green('v' + latestVersion)} ${chalk.gray('✓ up to date')}\n`);
150
+ }
151
+
152
+ const choices = updateAvailable
153
+ ? [`Update to v${latestVersion}`, new inquirer.Separator(), '← Back']
154
+ : ['← Back'];
155
+
156
+ let choice;
157
+ try {
158
+ ({ choice } = await inquirer.prompt([{
159
+ type: 'list',
160
+ name: 'choice',
161
+ message: 'Select an option:',
162
+ choices,
163
+ }]));
164
+ } catch (err) {
165
+ if (err.name === 'ExitPromptError') return;
166
+ throw err;
167
+ }
168
+
169
+ if (choice === `Update to v${latestVersion}`) {
170
+ const success = await performUpdate(latestVersion);
171
+ if (success) {
172
+ console.log(chalk.gray('\n Restart easy-devops to use the new version.\n'));
173
+ }
174
+ try {
175
+ await inquirer.prompt([{ type: 'input', name: '_', message: 'Press Enter to continue...' }]);
176
+ } catch { /* ExitPromptError */ }
177
+ }
178
+ }
@@ -11,6 +11,63 @@ import fs from 'fs/promises';
11
11
  import path from 'path';
12
12
  import { loadConfig } from './config.js';
13
13
 
14
+ const isWindows = process.platform === 'win32';
15
+
16
+ /** Returns the conf.d directory for domain config files. */
17
+ function getConfDDir(nginxDir) {
18
+ return isWindows
19
+ ? path.join(nginxDir, 'conf', 'conf.d')
20
+ : path.join(nginxDir, 'conf.d');
21
+ }
22
+
23
+ /** Returns the path to nginx.conf. */
24
+ function getNginxConfPath(nginxDir) {
25
+ return isWindows
26
+ ? path.join(nginxDir, 'conf', 'nginx.conf')
27
+ : path.join(nginxDir, 'nginx.conf');
28
+ }
29
+
30
+ /** Returns the include directive line for this platform. */
31
+ function buildIncludeLine(nginxDir) {
32
+ if (isWindows) {
33
+ const fwd = nginxDir.replace(/\\/g, '/');
34
+ return ` include "${fwd}/conf/conf.d/*.conf";`;
35
+ }
36
+ return ` include ${nginxDir}/conf.d/*.conf;`;
37
+ }
38
+
39
+ /**
40
+ * Ensures that nginx.conf contains an include directive for conf.d/*.conf.
41
+ * If the line is missing it is inserted just before the closing } of the http block.
42
+ * @param {string} nginxDir
43
+ */
44
+ export async function ensureNginxInclude(nginxDir) {
45
+ const confPath = getNginxConfPath(nginxDir);
46
+
47
+ let content;
48
+ try {
49
+ content = await fs.readFile(confPath, 'utf8');
50
+ } catch {
51
+ return; // nginx.conf not present yet — skip silently
52
+ }
53
+
54
+ // Already has a conf.d include
55
+ if (/include\s+[^\n]*conf\.d[^\n]*\*\.conf/.test(content)) return;
56
+
57
+ const includeLine = buildIncludeLine(nginxDir);
58
+
59
+ // Insert before the last } in the file (closes the http block)
60
+ const lastBrace = content.lastIndexOf('}');
61
+ if (lastBrace === -1) return;
62
+
63
+ const newContent =
64
+ content.slice(0, lastBrace) +
65
+ `${includeLine}\n` +
66
+ content.slice(lastBrace);
67
+
68
+ await fs.writeFile(confPath, newContent, 'utf8');
69
+ }
70
+
14
71
  // ─── DOMAIN DEFAULTS (v2 schema) ─────────────────────────────────────────────
15
72
 
16
73
  export const DOMAIN_DEFAULTS = {
@@ -274,13 +331,17 @@ export function buildConf(domain, nginxDir, certbotDir) {
274
331
  */
275
332
  export async function generateConf(domain) {
276
333
  const { nginxDir, certbotDir } = loadConfig();
277
- const confPath = path.join(nginxDir, 'conf.d', `${domain.name}.conf`);
334
+ const confDir = getConfDDir(nginxDir);
335
+ const confPath = path.join(confDir, `${domain.name}.conf`);
278
336
  const confContent = buildConf(domain, nginxDir, certbotDir);
279
337
 
280
338
  // Ensure conf.d directory exists
281
- await fs.mkdir(path.dirname(confPath), { recursive: true });
339
+ await fs.mkdir(confDir, { recursive: true });
282
340
  await fs.writeFile(confPath, confContent, 'utf8');
283
341
 
342
+ // Ensure nginx.conf includes conf.d
343
+ await ensureNginxInclude(nginxDir);
344
+
284
345
  // Update domain with config file path
285
346
  domain.configFile = confPath;
286
347
  domain.updatedAt = new Date().toISOString();
@@ -2,6 +2,7 @@ import fs from 'fs/promises';
2
2
  import path from 'path';
3
3
  import { run } from '../../core/shell.js';
4
4
  import { loadConfig } from '../../core/config.js';
5
+ import { ensureNginxInclude } from '../../core/nginx-conf-generator.js';
5
6
 
6
7
  // ─── Error Types ──────────────────────────────────────────────────────────────
7
8
 
@@ -23,6 +24,12 @@ function getNginxDir() {
23
24
  return nginxDir;
24
25
  }
25
26
 
27
+ function getConfDDir(nginxDir) {
28
+ return process.platform === 'win32'
29
+ ? path.join(nginxDir, 'conf', 'conf.d')
30
+ : path.join(nginxDir, 'conf.d');
31
+ }
32
+
26
33
  /**
27
34
  * Returns the PS-safe invocation string for the nginx binary.
28
35
  * On Windows: checks PATH first, then falls back to configured nginxDir.
@@ -50,7 +57,7 @@ export function validateFilename(filename) {
50
57
  throw new InvalidFilenameError('Invalid filename');
51
58
  }
52
59
  const nginxDir = getNginxDir();
53
- const confDir = path.join(nginxDir, 'conf.d');
60
+ const confDir = getConfDDir(nginxDir);
54
61
  const resolved = path.resolve(path.join(confDir, filename));
55
62
  if (!resolved.startsWith(path.resolve(confDir))) {
56
63
  throw new InvalidFilenameError('Invalid filename');
@@ -135,6 +142,7 @@ export async function start() {
135
142
  }
136
143
 
137
144
  // Test config before starting
145
+ await ensureNginxInclude(nginxDir);
138
146
  const testResult = await run(`${nginxExe} -t`);
139
147
  if (!testResult.success) {
140
148
  return { success: false, output: combineOutput(testResult) };
@@ -201,11 +209,13 @@ export async function stop() {
201
209
 
202
210
  export async function test() {
203
211
  const nginxExe = getNginxExe();
212
+ const nginxDir = getNginxDir();
204
213
  const versionResult = await run(`${nginxExe} -v`);
205
214
  if (!versionResult.success && !versionResult.stderr.includes('nginx/')) {
206
215
  throw new NginxNotFoundError('nginx binary not found');
207
216
  }
208
217
 
218
+ await ensureNginxInclude(nginxDir);
209
219
  const result = await run(`${nginxExe} -t`);
210
220
  return { success: result.success, output: combineOutput(result) };
211
221
  }
@@ -214,7 +224,7 @@ export async function test() {
214
224
 
215
225
  export async function listConfigs() {
216
226
  const nginxDir = getNginxDir();
217
- const confDir = path.join(nginxDir, 'conf.d');
227
+ const confDir = getConfDDir(nginxDir);
218
228
  let entries;
219
229
  try {
220
230
  entries = await fs.readdir(confDir);
@@ -228,7 +238,7 @@ export async function listConfigs() {
228
238
  export async function getConfig(filename) {
229
239
  validateFilename(filename);
230
240
  const nginxDir = getNginxDir();
231
- const confPath = path.join(nginxDir, 'conf.d', filename);
241
+ const confPath = path.join(getConfDDir(nginxDir), filename);
232
242
  const content = await fs.readFile(confPath, 'utf8');
233
243
  return { content };
234
244
  }
@@ -236,7 +246,7 @@ export async function getConfig(filename) {
236
246
  export async function saveConfig(filename, content) {
237
247
  validateFilename(filename);
238
248
  const nginxDir = getNginxDir();
239
- const confPath = path.join(nginxDir, 'conf.d', filename);
249
+ const confPath = path.join(getConfDDir(nginxDir), filename);
240
250
  const backupPath = confPath + '.bak';
241
251
 
242
252
  // Backup only if the file already exists (it may be a new file)
@@ -252,6 +262,7 @@ export async function saveConfig(filename, content) {
252
262
  await fs.writeFile(confPath, content, 'utf8');
253
263
 
254
264
  const nginxExe = getNginxExe();
265
+ await ensureNginxInclude(nginxDir);
255
266
  const result = await run(`${nginxExe} -t`);
256
267
  if (!result.success) {
257
268
  if (hasBackup) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "easy-devops",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "A unified DevOps management tool with CLI and web dashboard for managing Nginx, SSL certificates, and Node.js on Linux and Windows servers.",
5
5
  "keywords": [
6
6
  "devops",