@akash-chowdhury-24/deployhub 2.0.3 → 2.0.4

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/README.md CHANGED
@@ -604,9 +604,38 @@ DeployHub supports six deployment targets. Pick based on what infrastructure you
604
604
 
605
605
  Each method below follows the same structure: **prerequisites** (before `deployhub init`), **what DeployHub automates**, **after init** (matches terminal output), and a **variable reference**.
606
606
 
607
+ ### One-time server setup (before your first deploy)
608
+
609
+ SSH-based methods (SSH, EC2, Azure VM, GCP VM) require a few one-time steps on the server **before your first deploy**. DeployHub does not silently change ownership or sudo policy for you.
610
+
611
+ SSH into your server once and run:
612
+
613
+ ```bash
614
+ sudo mkdir -p /var/www/your-app-name
615
+ sudo chown your-ssh-user:your-ssh-user /var/www/your-app-name
616
+ ```
617
+
618
+ Replace `/var/www/your-app-name` with your actual deploy path and `your-ssh-user` with your configured `SSH_USER` (e.g. `ec2-user` on Amazon Linux, `ubuntu` on Ubuntu). Without this, DeployHub cannot write your build output — `deployhub doctor` will catch it and show the exact fix.
619
+
620
+ **Frontend deploys** that auto-activate `nginx.conf` also need **passwordless sudo** for Nginx test/reload (and `cp` into `/etc/nginx/`). After installing Nginx, run `sudo visudo` and add a line like:
621
+
622
+ ```bash
623
+ your-ssh-user ALL=(ALL) NOPASSWD: /usr/sbin/nginx, /bin/cp, /usr/bin/cp, /bin/systemctl, /usr/bin/systemctl
624
+ ```
625
+
626
+ > **Security note:** This example grants broad privileges — unrestricted `cp` (any source/destination) and `systemctl` (any unit/action), not just Nginx. That keeps setup simple but is a significant trust boundary. For production, prefer a dedicated deploy user and a **narrow wrapper script** (e.g. `/usr/local/bin/deployhub-nginx-reload` that only copies to your project's config path and runs `nginx -t` + reload), then grant `NOPASSWD` only for that script. The line above is a starting point for dev/test servers; tighten it before production.
627
+
628
+ Install Nginx if it is not already present:
629
+
630
+ - **Amazon Linux / RHEL:** `sudo yum install -y nginx && sudo systemctl enable --now nginx`
631
+ - **Ubuntu / Debian:** `sudo apt install -y nginx`
632
+
633
+ DeployHub detects whether the server uses Debian-style `sites-available` or RHEL-style `conf.d` at deploy time and writes a **uniquely named** config file for your project only — it does not overwrite unrelated Nginx configs.
634
+
607
635
  ### SSH
608
636
 
609
637
  **Prerequisites (before `deployhub init`):**
638
+ - [ ] Complete **[one-time server setup](#one-time-server-setup-before-your-first-deploy)** (deploy path ownership + Nginx/sudo for frontends)
610
639
  - [ ] A Linux server with SSH enabled
611
640
  - [ ] Private SSH key file (.pem/.key) and public key in `authorized_keys`
612
641
  - [ ] Port 22 open in firewall for your IP
@@ -618,6 +647,9 @@ Each method below follows the same structure: **prerequisites** (before `deployh
618
647
  - SSH key permission check (offers to `chmod 600`)
619
648
  - SSH connectivity test during `init`
620
649
  - Deploy path write-permission check during `deployhub doctor`
650
+ - Nginx layout detection (Debian `sites-available` vs RHEL `conf.d`) at deploy time
651
+ - Nginx config test (`nginx -t`) before reload
652
+ - Passwordless sudo and Nginx checks during `deployhub doctor` (frontend)
621
653
  - Artifact upload, extract, app restart (PM2, gunicorn, etc.)
622
654
 
623
655
  **After `init`:**
@@ -669,6 +701,7 @@ Each method below follows the same structure: **prerequisites** (before `deployh
669
701
  ### AWS EC2
670
702
 
671
703
  **Prerequisites:**
704
+ - [ ] Complete **[one-time server setup](#one-time-server-setup-before-your-first-deploy)** (`ec2-user` on Amazon Linux)
672
705
  - [ ] EC2 instance launched in AWS Console (DeployHub does not create it)
673
706
  - [ ] Key pair `.pem` downloaded at launch
674
707
  - [ ] Security group: inbound SSH (22) from your IP
@@ -679,6 +712,8 @@ Each method below follows the same structure: **prerequisites** (before `deployh
679
712
  - EC2-specific `.env.example` (SSH + optional AWS API vars)
680
713
  - SSH key validation and connectivity test
681
714
  - Deploy path write-permission check during `deployhub doctor`
715
+ - Nginx layout detection and config test before reload (frontend)
716
+ - Passwordless sudo and Nginx checks during `deployhub doctor` (frontend)
682
717
  - OS user suggestion from AMI hint (ubuntu, ec2-user)
683
718
  - Optional public IP lookup via `EC2_INSTANCE_ID` + AWS CLI
684
719
 
@@ -702,6 +737,7 @@ Each method below follows the same structure: **prerequisites** (before `deployh
702
737
  ### Azure VM
703
738
 
704
739
  **Prerequisites:**
740
+ - [ ] Complete **[one-time server setup](#one-time-server-setup-before-your-first-deploy)** (`azureuser` or your VM login user)
705
741
  - [ ] Azure VM created in Portal (DeployHub does not provision it)
706
742
  - [ ] NSG rule allowing inbound SSH (port 22)
707
743
  - [ ] SSH public key on the VM
@@ -713,6 +749,8 @@ Each method below follows the same structure: **prerequisites** (before `deployh
713
749
  - Auto-detects subscription ID via `az` CLI if logged in
714
750
  - SSH key validation and connectivity test
715
751
  - Deploy path write-permission check during `deployhub doctor`
752
+ - Nginx layout detection and config test before reload (frontend)
753
+ - Passwordless sudo and Nginx checks during `deployhub doctor` (frontend)
716
754
 
717
755
  **After `init`:**
718
756
  1. Azure Portal → VM → Networking → allow SSH (22) from your IP
@@ -733,6 +771,7 @@ Each method below follows the same structure: **prerequisites** (before `deployh
733
771
  ### GCP VM
734
772
 
735
773
  **Prerequisites:**
774
+ - [ ] Complete **[one-time server setup](#one-time-server-setup-before-your-first-deploy)** (your GCP SSH username)
736
775
  - [ ] Compute Engine VM created (DeployHub does not create it)
737
776
  - [ ] Firewall rule allowing `tcp:22` (default `default-allow-ssh` may exist)
738
777
  - [ ] SSH public key in **Metadata → SSH Keys** (GCP uses metadata keys, not launch key pairs like AWS)
@@ -744,6 +783,8 @@ Each method below follows the same structure: **prerequisites** (before `deployh
744
783
  - Auto-detects project ID via `gcloud` if authenticated
745
784
  - SSH key validation and connectivity test
746
785
  - Deploy path write-permission check during `deployhub doctor`
786
+ - Nginx layout detection and config test before reload (frontend)
787
+ - Passwordless sudo and Nginx checks during `deployhub doctor` (frontend)
747
788
 
748
789
  **After `init`:**
749
790
  1. GCP Console → VPC → Firewall → ensure SSH (tcp:22) allowed from your IP
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akash-chowdhury-24/deployhub",
3
- "version": "2.0.3",
3
+ "version": "2.0.4",
4
4
  "description": "Zero-configuration deployment and artifact manager",
5
5
  "type": "module",
6
6
  "main": "./src/cli/index.js",
@@ -18,6 +18,7 @@ import {
18
18
  buildDeployPathWriteTestCommand,
19
19
  formatDeployPathWriteFailure,
20
20
  } from '../utils/shell-quote.js';
21
+ import { formatPasswordlessSudoGuidance } from '../utils/nginx.js';
21
22
 
22
23
  /**
23
24
  * @typedef {{ name: string, pass: boolean, message: string }} CheckResult
@@ -82,6 +83,16 @@ function resolveSshDeployPaths(config, envConfig) {
82
83
  return [...new Set(paths.filter(Boolean))];
83
84
  }
84
85
 
86
+ /**
87
+ * @param {import('../core/config.js').DeployHubConfig} config
88
+ * @returns {boolean}
89
+ */
90
+ function needsNginxActivationForDeploy(config) {
91
+ if (config.projectType === 'backend') return false;
92
+ if (config.projectType === 'frontend' && config.framework === 'nextjs') return false;
93
+ return true;
94
+ }
95
+
85
96
  /**
86
97
  * @param {import('../core/config.js').DeployHubConfig} config
87
98
  * @param {string} envName
@@ -207,6 +218,149 @@ async function runDeploymentChecks(config, envName, envConfig) {
207
218
  );
208
219
  }
209
220
 
221
+ if (needsNginxActivationForDeploy(config)) {
222
+ checks.push(
223
+ await runCheck('Nginx installed', async () => {
224
+ if (!host || !user) {
225
+ return {
226
+ name: 'Nginx installed',
227
+ pass: false,
228
+ message: 'SSH_HOST and SSH_USER are required to check Nginx on the server.',
229
+ };
230
+ }
231
+ if (!keyPath && !process.env.SSH_KEY) {
232
+ return {
233
+ name: 'Nginx installed',
234
+ pass: false,
235
+ message: 'SSH_KEY_PATH or SSH_KEY is required to check Nginx on the server.',
236
+ };
237
+ }
238
+
239
+ const provider = getDeploymentProvider(deployType, config, envName);
240
+ if (!provider.runRemoteCheck) {
241
+ return {
242
+ name: 'Nginx installed',
243
+ pass: false,
244
+ message: 'Deploy provider does not support remote checks.',
245
+ };
246
+ }
247
+
248
+ const result = await provider.runRemoteCheck('command -v nginx >/dev/null 2>&1 && echo yes');
249
+ if (result.pass && result.message.includes('yes')) {
250
+ return { name: 'Nginx installed', pass: true, message: 'Nginx installed on server' };
251
+ }
252
+ return {
253
+ name: 'Nginx installed',
254
+ pass: false,
255
+ message:
256
+ 'Nginx not found on server — install it first (e.g. sudo yum install nginx on Amazon Linux, or sudo apt install nginx on Ubuntu).',
257
+ };
258
+ })
259
+ );
260
+
261
+ checks.push(
262
+ await runCheck('Passwordless sudo', async () => {
263
+ if (!host || !user) {
264
+ return {
265
+ name: 'Passwordless sudo',
266
+ pass: false,
267
+ message: 'SSH_HOST and SSH_USER are required for sudo check.',
268
+ };
269
+ }
270
+ if (!keyPath && !process.env.SSH_KEY) {
271
+ return {
272
+ name: 'Passwordless sudo',
273
+ pass: false,
274
+ message: 'SSH_KEY_PATH or SSH_KEY is required for sudo check.',
275
+ };
276
+ }
277
+
278
+ const provider = getDeploymentProvider(deployType, config, envName);
279
+ if (!provider.runRemoteCheck) {
280
+ return {
281
+ name: 'Passwordless sudo',
282
+ pass: false,
283
+ message: 'Deploy provider does not support remote checks.',
284
+ };
285
+ }
286
+
287
+ const result = await provider.runRemoteCheck('sudo -n true');
288
+ if (result.pass) {
289
+ return {
290
+ name: 'Passwordless sudo',
291
+ pass: true,
292
+ message: 'Non-interactive sudo available (required for Nginx config activation)',
293
+ };
294
+ }
295
+ return {
296
+ name: 'Passwordless sudo',
297
+ pass: false,
298
+ message: formatPasswordlessSudoGuidance(sshUser),
299
+ };
300
+ })
301
+ );
302
+
303
+ checks.push(
304
+ await runCheck('Nginx sudo access', async () => {
305
+ if (!host || !user) {
306
+ return {
307
+ name: 'Nginx sudo access',
308
+ pass: false,
309
+ message: 'SSH_HOST and SSH_USER are required for Nginx sudo check.',
310
+ };
311
+ }
312
+ if (!keyPath && !process.env.SSH_KEY) {
313
+ return {
314
+ name: 'Nginx sudo access',
315
+ pass: false,
316
+ message: 'SSH_KEY_PATH or SSH_KEY is required for Nginx sudo check.',
317
+ };
318
+ }
319
+
320
+ const provider = getDeploymentProvider(deployType, config, envName);
321
+ if (!provider.runRemoteCheck) {
322
+ return {
323
+ name: 'Nginx sudo access',
324
+ pass: false,
325
+ message: 'Deploy provider does not support remote checks.',
326
+ };
327
+ }
328
+
329
+ const nginxInstalled = await provider.runRemoteCheck('command -v nginx >/dev/null 2>&1 && echo yes');
330
+ if (!nginxInstalled.pass || !nginxInstalled.message.includes('yes')) {
331
+ return {
332
+ name: 'Nginx sudo access',
333
+ pass: false,
334
+ message: 'Skipped — install Nginx first.',
335
+ };
336
+ }
337
+
338
+ const sudoOk = await provider.runRemoteCheck('sudo -n true');
339
+ if (!sudoOk.pass) {
340
+ return {
341
+ name: 'Nginx sudo access',
342
+ pass: false,
343
+ message: 'Skipped — configure passwordless sudo first.',
344
+ };
345
+ }
346
+
347
+ const result = await provider.runRemoteCheck('sudo -n nginx -t 2>&1');
348
+ if (result.pass) {
349
+ return {
350
+ name: 'Nginx sudo access',
351
+ pass: true,
352
+ message: 'sudo nginx -t OK (can test config before reload)',
353
+ };
354
+ }
355
+ return {
356
+ name: 'Nginx sudo access',
357
+ pass: false,
358
+ message: `sudo nginx -t failed — ${result.message}. Check Nginx install and sudoers (see README one-time server setup).`,
359
+ };
360
+ })
361
+ );
362
+ }
363
+
210
364
  const isBackend = config.projectType === 'backend' || config.projectType === 'both';
211
365
  if (isBackend) {
212
366
  const backendChecks = await runBackendProcessChecks(config, envName, deployType);
@@ -3,7 +3,11 @@ import fs from 'fs-extra';
3
3
  import path from 'path';
4
4
  import os from 'os';
5
5
  import { createLogger } from '../../logger/index.js';
6
- import { getNginxSitePath } from '../../utils/nginx.js';
6
+ import {
7
+ getNginxSitesAvailablePath,
8
+ getNginxSitesEnabledPath,
9
+ getNginxConfDPath,
10
+ } from '../../utils/nginx.js';
7
11
  import { shellQuote, formatRemoteCommandFailure } from '../../utils/shell-quote.js';
8
12
 
9
13
  /** @type {Set<string>} */
@@ -240,24 +244,85 @@ export function createSshProvider(config, envName, env = process.env) {
240
244
  await exec(ssh, 'pm2 save');
241
245
  }
242
246
 
247
+ /**
248
+ * @param {import('node-ssh').NodeSSH} ssh
249
+ * @param {string} remotePath
250
+ * @param {'f'|'d'} [kind='f']
251
+ */
252
+ async function remotePathExists(ssh, remotePath, kind = 'f') {
253
+ const flag = kind === 'd' ? '-d' : '-f';
254
+ const result = await ssh.execCommand(`test ${flag} ${sh(remotePath)} && echo yes`);
255
+ return result.code === 0 && result.stdout.trim() === 'yes';
256
+ }
257
+
258
+ /**
259
+ * @param {import('node-ssh').NodeSSH} ssh
260
+ * @param {string} command
261
+ */
262
+ async function remoteCommandExists(ssh, command) {
263
+ const result = await ssh.execCommand(`command -v ${sh(command)} >/dev/null 2>&1 && echo yes`);
264
+ return result.code === 0 && result.stdout.trim() === 'yes';
265
+ }
266
+
267
+ /**
268
+ * @param {import('node-ssh').NodeSSH} ssh
269
+ * @returns {Promise<'debian'|'rhel'>}
270
+ */
271
+ async function detectNginxLayout(ssh) {
272
+ if (await remotePathExists(ssh, '/etc/nginx/sites-available', 'd')) {
273
+ return 'debian';
274
+ }
275
+ return 'rhel';
276
+ }
277
+
278
+ /**
279
+ * @param {import('node-ssh').NodeSSH} ssh
280
+ */
281
+ async function reloadNginx(ssh) {
282
+ await exec(ssh, 'sudo systemctl reload nginx 2>/dev/null || sudo nginx -s reload');
283
+ }
284
+
243
285
  /**
244
286
  * @param {import('node-ssh').NodeSSH} ssh
245
287
  * @param {string} targetPath
246
288
  */
247
289
  async function setupNginx(ssh, targetPath) {
248
- const sitePath = getNginxSitePath(config.project);
249
290
  const nginxConfRemote = `${targetPath}/nginx.conf`;
250
291
 
251
- await exec(
252
- ssh,
253
- `sudo cp ${sh(nginxConfRemote)} ${sh(sitePath)} 2>/dev/null || sudo cp ${sh(`${targetPath}/nginx.conf`)} ${sh(sitePath)}`
254
- );
255
- await exec(
256
- ssh,
257
- `sudo ln -sf ${sh(sitePath)} ${sh(`/etc/nginx/sites-enabled/${path.basename(sitePath)}`)}`
292
+ if (!(await remoteCommandExists(ssh, 'nginx'))) {
293
+ throw new Error(
294
+ 'Nginx is not installed on the server. Install it first (e.g. sudo yum install nginx on Amazon Linux, or sudo apt install nginx on Ubuntu), then re-run deploy.'
295
+ );
296
+ }
297
+
298
+ if (!(await remotePathExists(ssh, nginxConfRemote))) {
299
+ throw new Error(
300
+ `Nginx config not found at ${nginxConfRemote} — artifact may be missing nginx.conf.`
301
+ );
302
+ }
303
+
304
+ const layout = await detectNginxLayout(ssh);
305
+ log.info(
306
+ layout === 'debian'
307
+ ? 'Detected Nginx layout: Debian/Ubuntu (sites-available)'
308
+ : 'Detected Nginx layout: RHEL/Amazon Linux (conf.d)'
258
309
  );
310
+
311
+ if (layout === 'debian') {
312
+ const sitePath = getNginxSitesAvailablePath(config.project);
313
+ const enabledPath = getNginxSitesEnabledPath(config.project);
314
+ await exec(ssh, `sudo cp ${sh(nginxConfRemote)} ${sh(sitePath)}`);
315
+ await exec(ssh, `sudo ln -sf ${sh(sitePath)} ${sh(enabledPath)}`);
316
+ log.info(`Nginx config installed: ${sitePath}`);
317
+ } else {
318
+ const confPath = getNginxConfDPath(config.project);
319
+ await exec(ssh, `sudo cp ${sh(nginxConfRemote)} ${sh(confPath)}`);
320
+ log.info(`Nginx config installed: ${confPath}`);
321
+ }
322
+
259
323
  await exec(ssh, 'sudo nginx -t');
260
- await exec(ssh, 'sudo systemctl reload nginx');
324
+ await reloadNginx(ssh);
325
+ log.success('Nginx config tested and reloaded');
261
326
  }
262
327
 
263
328
  /**
@@ -1,3 +1,12 @@
1
+ /**
2
+ * Sanitize a project name for use in Nginx config file paths.
3
+ * @param {string} projectName
4
+ * @returns {string}
5
+ */
6
+ export function sanitizeNginxProjectName(projectName) {
7
+ return projectName.replace(/[^a-zA-Z0-9_-]/g, '-');
8
+ }
9
+
1
10
  /**
2
11
  * Generate nginx server block config for SPA frontend deployments.
3
12
  *
@@ -22,13 +31,61 @@ export function generateNginxConfig(projectName, deployPath, buildOutput = 'dist
22
31
  `;
23
32
  }
24
33
 
34
+ /**
35
+ * Debian/Ubuntu: sites-available path for this project.
36
+ * @param {string} projectName
37
+ * @returns {string}
38
+ */
39
+ export function getNginxSitesAvailablePath(projectName) {
40
+ return `/etc/nginx/sites-available/${sanitizeNginxProjectName(projectName)}`;
41
+ }
42
+
43
+ /**
44
+ * Debian/Ubuntu: sites-enabled symlink path for this project.
45
+ * @param {string} projectName
46
+ * @returns {string}
47
+ */
48
+ export function getNginxSitesEnabledPath(projectName) {
49
+ return `/etc/nginx/sites-enabled/${sanitizeNginxProjectName(projectName)}`;
50
+ }
51
+
52
+ /**
53
+ * RHEL/Amazon Linux: conf.d drop-in path for this project.
54
+ * @param {string} projectName
55
+ * @returns {string}
56
+ */
57
+ export function getNginxConfDPath(projectName) {
58
+ return `/etc/nginx/conf.d/${sanitizeNginxProjectName(projectName)}.conf`;
59
+ }
60
+
25
61
  /**
26
62
  * @param {string} projectName
27
63
  * @returns {string}
28
64
  */
29
65
  export function getNginxSitePath(projectName) {
30
- const safeName = projectName.replace(/[^a-zA-Z0-9_-]/g, '-');
31
- return `/etc/nginx/sites-available/${safeName}`;
66
+ return getNginxSitesAvailablePath(projectName);
67
+ }
68
+
69
+ /**
70
+ * @param {string} sshUser
71
+ * @returns {string}
72
+ */
73
+ export function formatPasswordlessSudoGuidance(sshUser) {
74
+ return (
75
+ `Passwordless sudo required to activate Nginx config during deploy.\n` +
76
+ ` On your server, run sudo visudo and add a line like:\n` +
77
+ ` ${sshUser} ALL=(ALL) NOPASSWD: /usr/sbin/nginx, /bin/cp, /usr/bin/cp, /bin/systemctl, /usr/bin/systemctl\n` +
78
+ ` (replace ${sshUser} with your SSH_USER)\n` +
79
+ ` Security: this grants broad cp/systemctl access — see README one-time server setup for a production-hardening note.`
80
+ );
32
81
  }
33
82
 
34
- export default { generateNginxConfig, getNginxSitePath };
83
+ export default {
84
+ generateNginxConfig,
85
+ sanitizeNginxProjectName,
86
+ getNginxSitesAvailablePath,
87
+ getNginxSitesEnabledPath,
88
+ getNginxConfDPath,
89
+ getNginxSitePath,
90
+ formatPasswordlessSudoGuidance,
91
+ };