@akash-chowdhury-24/deployhub 2.0.2 → 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 +65 -12
- package/package.json +1 -1
- package/src/commands/deploy.js +1 -1
- package/src/commands/doctor.js +230 -0
- package/src/commands/init.js +4 -1
- package/src/deployment/init-helpers.js +82 -0
- package/src/deployment/init-prompts.js +2 -0
- package/src/deployment/providers/ssh.js +122 -46
- package/src/utils/nginx.js +60 -3
- package/src/utils/shell-quote.js +64 -0
package/README.md
CHANGED
|
@@ -604,26 +604,61 @@ 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
|
|
642
|
+
- [ ] Deploy directory writable by your SSH user (e.g. `sudo mkdir -p /var/www/my-app && sudo chown ubuntu:ubuntu /var/www/my-app` — `/var/www` is root-owned on most fresh Linux images)
|
|
613
643
|
- [ ] App runtime on server (Node.js, Python, etc.) for backends
|
|
614
644
|
|
|
615
645
|
**What DeployHub automates:**
|
|
616
646
|
- Complete `.env.example` with commented variables
|
|
617
647
|
- SSH key permission check (offers to `chmod 600`)
|
|
618
648
|
- SSH connectivity test during `init`
|
|
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)
|
|
619
653
|
- Artifact upload, extract, app restart (PM2, gunicorn, etc.)
|
|
620
654
|
|
|
621
655
|
**After `init`:**
|
|
622
656
|
1. Ensure port 22 is open in your server firewall
|
|
623
|
-
2.
|
|
624
|
-
3.
|
|
625
|
-
4.
|
|
626
|
-
5. `
|
|
657
|
+
2. Ensure your deploy directory exists and is owned by your SSH user (see prerequisite above if `deployhub doctor` reports permission denied)
|
|
658
|
+
3. Copy `.env.example` → `.env`; set `SSH_HOST`, `SSH_USER`, `SSH_KEY_PATH`
|
|
659
|
+
4. Add GitHub Secrets: `SSH_HOST`, `SSH_USER`, `SSH_KEY` (paste private key for CI)
|
|
660
|
+
5. Run `deployhub doctor`
|
|
661
|
+
6. `git push origin main`
|
|
627
662
|
|
|
628
663
|
| Variable | Description | Example | Where to get it |
|
|
629
664
|
|----------|-------------|---------|-----------------|
|
|
@@ -666,22 +701,28 @@ Each method below follows the same structure: **prerequisites** (before `deployh
|
|
|
666
701
|
### AWS EC2
|
|
667
702
|
|
|
668
703
|
**Prerequisites:**
|
|
704
|
+
- [ ] Complete **[one-time server setup](#one-time-server-setup-before-your-first-deploy)** (`ec2-user` on Amazon Linux)
|
|
669
705
|
- [ ] EC2 instance launched in AWS Console (DeployHub does not create it)
|
|
670
706
|
- [ ] Key pair `.pem` downloaded at launch
|
|
671
707
|
- [ ] Security group: inbound SSH (22) from your IP
|
|
708
|
+
- [ ] Deploy directory writable by your SSH user (e.g. `sudo mkdir -p /var/www/my-app && sudo chown ec2-user:ec2-user /var/www/my-app` — `/var/www` is root-owned on Amazon Linux by default)
|
|
672
709
|
- [ ] App runtime on instance for backends
|
|
673
710
|
|
|
674
711
|
**What DeployHub automates:**
|
|
675
712
|
- EC2-specific `.env.example` (SSH + optional AWS API vars)
|
|
676
713
|
- SSH key validation and connectivity test
|
|
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)
|
|
677
717
|
- OS user suggestion from AMI hint (ubuntu, ec2-user)
|
|
678
718
|
- Optional public IP lookup via `EC2_INSTANCE_ID` + AWS CLI
|
|
679
719
|
|
|
680
720
|
**After `init`:**
|
|
681
721
|
1. AWS Console → EC2 → Security Groups → Inbound rules → SSH port 22 from My IP
|
|
682
|
-
2.
|
|
683
|
-
3.
|
|
684
|
-
4.
|
|
722
|
+
2. Ensure your deploy directory exists and is owned by your SSH user (see prerequisite above if `deployhub doctor` reports permission denied)
|
|
723
|
+
3. Copy `.env.example` → `.env`; set `SSH_KEY_PATH`, `SSH_HOST` (or `EC2_INSTANCE_ID` + AWS creds)
|
|
724
|
+
4. GitHub Secrets: `SSH_HOST`, `SSH_USER`, `SSH_KEY`, plus `AWS_*` if using instance ID lookup
|
|
725
|
+
5. Run `deployhub doctor`, then `git push origin main`
|
|
685
726
|
|
|
686
727
|
| Variable | Description | Example | Where to get it |
|
|
687
728
|
|----------|-------------|---------|-----------------|
|
|
@@ -696,21 +737,27 @@ Each method below follows the same structure: **prerequisites** (before `deployh
|
|
|
696
737
|
### Azure VM
|
|
697
738
|
|
|
698
739
|
**Prerequisites:**
|
|
740
|
+
- [ ] Complete **[one-time server setup](#one-time-server-setup-before-your-first-deploy)** (`azureuser` or your VM login user)
|
|
699
741
|
- [ ] Azure VM created in Portal (DeployHub does not provision it)
|
|
700
742
|
- [ ] NSG rule allowing inbound SSH (port 22)
|
|
701
743
|
- [ ] SSH public key on the VM
|
|
744
|
+
- [ ] Deploy directory writable by your SSH user (e.g. `sudo mkdir -p /var/www/my-app && sudo chown azureuser:azureuser /var/www/my-app`)
|
|
702
745
|
- [ ] App runtime for backends
|
|
703
746
|
|
|
704
747
|
**What DeployHub automates:**
|
|
705
748
|
- Azure VM `.env.example` with SSH + optional Azure API vars
|
|
706
749
|
- Auto-detects subscription ID via `az` CLI if logged in
|
|
707
750
|
- SSH key validation and connectivity test
|
|
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)
|
|
708
754
|
|
|
709
755
|
**After `init`:**
|
|
710
756
|
1. Azure Portal → VM → Networking → allow SSH (22) from your IP
|
|
711
|
-
2.
|
|
712
|
-
3.
|
|
713
|
-
4.
|
|
757
|
+
2. Ensure your deploy directory exists and is owned by your SSH user (see prerequisite above if `deployhub doctor` reports permission denied)
|
|
758
|
+
3. Copy `.env.example` → `.env`; set `SSH_HOST`, `SSH_USER`, `SSH_KEY_PATH`
|
|
759
|
+
4. For CI: add `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET` as GitHub Secrets
|
|
760
|
+
5. Run `deployhub doctor`, then `git push origin main`
|
|
714
761
|
|
|
715
762
|
| Variable | Description | Example | Where to get it |
|
|
716
763
|
|----------|-------------|---------|-----------------|
|
|
@@ -724,21 +771,27 @@ Each method below follows the same structure: **prerequisites** (before `deployh
|
|
|
724
771
|
### GCP VM
|
|
725
772
|
|
|
726
773
|
**Prerequisites:**
|
|
774
|
+
- [ ] Complete **[one-time server setup](#one-time-server-setup-before-your-first-deploy)** (your GCP SSH username)
|
|
727
775
|
- [ ] Compute Engine VM created (DeployHub does not create it)
|
|
728
776
|
- [ ] Firewall rule allowing `tcp:22` (default `default-allow-ssh` may exist)
|
|
729
777
|
- [ ] SSH public key in **Metadata → SSH Keys** (GCP uses metadata keys, not launch key pairs like AWS)
|
|
778
|
+
- [ ] Deploy directory writable by your SSH user (e.g. `sudo mkdir -p /var/www/my-app && sudo chown $USER:$USER /var/www/my-app`)
|
|
730
779
|
- [ ] App runtime for backends
|
|
731
780
|
|
|
732
781
|
**What DeployHub automates:**
|
|
733
782
|
- GCP VM `.env.example` with SSH + optional GCP API vars
|
|
734
783
|
- Auto-detects project ID via `gcloud` if authenticated
|
|
735
784
|
- SSH key validation and connectivity test
|
|
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)
|
|
736
788
|
|
|
737
789
|
**After `init`:**
|
|
738
790
|
1. GCP Console → VPC → Firewall → ensure SSH (tcp:22) allowed from your IP
|
|
739
791
|
2. Add SSH public key: Console → Compute Engine → Metadata → SSH Keys
|
|
740
|
-
3.
|
|
741
|
-
4.
|
|
792
|
+
3. Ensure your deploy directory exists and is owned by your SSH user (see prerequisite above if `deployhub doctor` reports permission denied)
|
|
793
|
+
4. Copy `.env.example` → `.env`; set `SSH_HOST`, `SSH_USER`, `SSH_KEY_PATH`
|
|
794
|
+
5. Run `deployhub doctor`, then `git push origin main`
|
|
742
795
|
|
|
743
796
|
| Variable | Description | Example | Where to get it |
|
|
744
797
|
|----------|-------------|---------|-----------------|
|
package/package.json
CHANGED
package/src/commands/deploy.js
CHANGED
|
@@ -71,7 +71,7 @@ export function registerDeployCommand(program) {
|
|
|
71
71
|
|
|
72
72
|
const { failure } = await runPipeline(stages, { config, cwd, state });
|
|
73
73
|
if (failure) {
|
|
74
|
-
console.error(chalk.red(
|
|
74
|
+
console.error(chalk.red(failure.message));
|
|
75
75
|
process.exit(1);
|
|
76
76
|
}
|
|
77
77
|
|
package/src/commands/doctor.js
CHANGED
|
@@ -14,6 +14,11 @@ import {
|
|
|
14
14
|
getDeploymentSecretKeys,
|
|
15
15
|
} from '../deployment/deployment-env.js';
|
|
16
16
|
import { testSshConnectivity, validateSshKeyForDoctor, testSshHostReachability } from '../deployment/init-helpers.js';
|
|
17
|
+
import {
|
|
18
|
+
buildDeployPathWriteTestCommand,
|
|
19
|
+
formatDeployPathWriteFailure,
|
|
20
|
+
} from '../utils/shell-quote.js';
|
|
21
|
+
import { formatPasswordlessSudoGuidance } from '../utils/nginx.js';
|
|
17
22
|
|
|
18
23
|
/**
|
|
19
24
|
* @typedef {{ name: string, pass: boolean, message: string }} CheckResult
|
|
@@ -56,6 +61,38 @@ function resolveBackendFramework(config) {
|
|
|
56
61
|
/** @type {Set<string>} */
|
|
57
62
|
const SSH_DEPLOY_TYPES = new Set(['ssh', 'ec2', 'azure-vm', 'gcp-vm']);
|
|
58
63
|
|
|
64
|
+
/**
|
|
65
|
+
* @param {import('../core/config.js').DeployHubConfig} config
|
|
66
|
+
* @param {Record<string, unknown>} envConfig
|
|
67
|
+
* @returns {string[]}
|
|
68
|
+
*/
|
|
69
|
+
function resolveSshDeployPaths(config, envConfig) {
|
|
70
|
+
/** @type {string[]} */
|
|
71
|
+
const paths = [];
|
|
72
|
+
|
|
73
|
+
if (config.projectType === 'both') {
|
|
74
|
+
if (envConfig.frontendDeployPath) paths.push(String(envConfig.frontendDeployPath));
|
|
75
|
+
if (envConfig.backendDeployPath) paths.push(String(envConfig.backendDeployPath));
|
|
76
|
+
else if (envConfig.path) paths.push(String(envConfig.path));
|
|
77
|
+
} else {
|
|
78
|
+
const deployPath =
|
|
79
|
+
envConfig.deployPath || envConfig.path || process.env.SSH_DEPLOY_PATH;
|
|
80
|
+
if (deployPath) paths.push(String(deployPath));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return [...new Set(paths.filter(Boolean))];
|
|
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
|
+
|
|
59
96
|
/**
|
|
60
97
|
* @param {import('../core/config.js').DeployHubConfig} config
|
|
61
98
|
* @param {string} envName
|
|
@@ -131,6 +168,199 @@ async function runDeploymentChecks(config, envName, envConfig) {
|
|
|
131
168
|
})
|
|
132
169
|
);
|
|
133
170
|
|
|
171
|
+
const deployPaths = resolveSshDeployPaths(config, envConfig);
|
|
172
|
+
const sshUser = String(user || process.env.SSH_USER || 'your-user');
|
|
173
|
+
|
|
174
|
+
for (const deployPath of deployPaths) {
|
|
175
|
+
const checkName = `Deploy path write (${deployPath})`;
|
|
176
|
+
checks.push(
|
|
177
|
+
await runCheck(checkName, async () => {
|
|
178
|
+
if (!host || !user) {
|
|
179
|
+
return {
|
|
180
|
+
name: checkName,
|
|
181
|
+
pass: false,
|
|
182
|
+
message: 'SSH_HOST and SSH_USER are required for deploy path write test.',
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
if (!keyPath && !process.env.SSH_KEY) {
|
|
186
|
+
return {
|
|
187
|
+
name: checkName,
|
|
188
|
+
pass: false,
|
|
189
|
+
message: 'SSH_KEY_PATH or SSH_KEY is required for deploy path write test.',
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const provider = getDeploymentProvider(deployType, config, envName);
|
|
194
|
+
if (!provider.runRemoteCheck) {
|
|
195
|
+
return {
|
|
196
|
+
name: checkName,
|
|
197
|
+
pass: false,
|
|
198
|
+
message: 'Deploy provider does not support remote checks.',
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const command = buildDeployPathWriteTestCommand(deployPath);
|
|
203
|
+
const result = await provider.runRemoteCheck(command);
|
|
204
|
+
if (result.pass) {
|
|
205
|
+
return {
|
|
206
|
+
name: checkName,
|
|
207
|
+
pass: true,
|
|
208
|
+
message: `Write access OK for ${deployPath}`,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
name: checkName,
|
|
214
|
+
pass: false,
|
|
215
|
+
message: formatDeployPathWriteFailure(deployPath, sshUser, result.message),
|
|
216
|
+
};
|
|
217
|
+
})
|
|
218
|
+
);
|
|
219
|
+
}
|
|
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
|
+
|
|
134
364
|
const isBackend = config.projectType === 'backend' || config.projectType === 'both';
|
|
135
365
|
if (isBackend) {
|
|
136
366
|
const backendChecks = await runBackendProcessChecks(config, envName, deployType);
|
package/src/commands/init.js
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
SSH_BASED,
|
|
28
28
|
} from '../deployment/init-prompts.js';
|
|
29
29
|
import { printDeploymentNextSteps } from '../deployment/deployment-env.js';
|
|
30
|
+
import { confirmValueIfContainsSpaces } from '../deployment/init-helpers.js';
|
|
30
31
|
|
|
31
32
|
const FRONTEND_CHOICES = [
|
|
32
33
|
{ name: 'React', value: 'react' },
|
|
@@ -221,7 +222,7 @@ export function registerInitCommand(program) {
|
|
|
221
222
|
const defaultName = path.basename(cwd) || 'my-app';
|
|
222
223
|
const cliSource = DEFAULT_NPM_CLI_SOURCE;
|
|
223
224
|
|
|
224
|
-
|
|
225
|
+
let { projectName } = await inquirer.prompt([
|
|
225
226
|
{
|
|
226
227
|
type: 'input',
|
|
227
228
|
name: 'projectName',
|
|
@@ -230,6 +231,8 @@ export function registerInitCommand(program) {
|
|
|
230
231
|
},
|
|
231
232
|
]);
|
|
232
233
|
|
|
234
|
+
projectName = await confirmValueIfContainsSpaces(projectName, 'project name');
|
|
235
|
+
|
|
233
236
|
const { projectType } = await inquirer.prompt([
|
|
234
237
|
{
|
|
235
238
|
type: 'list',
|
|
@@ -6,6 +6,7 @@ import { execa } from 'execa';
|
|
|
6
6
|
import chalk from 'chalk';
|
|
7
7
|
import inquirer from 'inquirer';
|
|
8
8
|
import { NodeSSH } from 'node-ssh';
|
|
9
|
+
import { toKebabCase } from '../utils/shell-quote.js';
|
|
9
10
|
|
|
10
11
|
/** @type {Record<string, string>} */
|
|
11
12
|
export const OS_USER_DEFAULTS = {
|
|
@@ -329,6 +330,87 @@ export async function testKubeConnectivity(kubeconfig, context) {
|
|
|
329
330
|
}
|
|
330
331
|
}
|
|
331
332
|
|
|
333
|
+
/**
|
|
334
|
+
* @param {string} value
|
|
335
|
+
* @param {string} label
|
|
336
|
+
* @returns {Promise<string>}
|
|
337
|
+
*/
|
|
338
|
+
export async function confirmValueIfContainsSpaces(value, label) {
|
|
339
|
+
if (!/\s/.test(value)) return value;
|
|
340
|
+
|
|
341
|
+
const suggested = toKebabCase(value);
|
|
342
|
+
console.log(chalk.yellow(`\n ⚠ Your ${label} contains spaces: "${value}"`));
|
|
343
|
+
console.log(
|
|
344
|
+
chalk.gray(
|
|
345
|
+
` Spaces in paths can cause shell issues on the server. Suggested: "${suggested || value.replace(/\s+/g, '-')}"`
|
|
346
|
+
)
|
|
347
|
+
);
|
|
348
|
+
|
|
349
|
+
const { choice } = await inquirer.prompt([
|
|
350
|
+
{
|
|
351
|
+
type: 'list',
|
|
352
|
+
name: 'choice',
|
|
353
|
+
message: 'How would you like to proceed?',
|
|
354
|
+
choices: [
|
|
355
|
+
{
|
|
356
|
+
name: `Use suggested: ${suggested || value.replace(/\s+/g, '-')}`,
|
|
357
|
+
value: 'suggested',
|
|
358
|
+
},
|
|
359
|
+
{ name: `Keep original: ${value}`, value: 'keep' },
|
|
360
|
+
{ name: 'Enter a different value', value: 'custom' },
|
|
361
|
+
],
|
|
362
|
+
},
|
|
363
|
+
]);
|
|
364
|
+
|
|
365
|
+
if (choice === 'suggested') {
|
|
366
|
+
return suggested || value.replace(/\s+/g, '-');
|
|
367
|
+
}
|
|
368
|
+
if (choice === 'keep') {
|
|
369
|
+
return value;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const { custom } = await inquirer.prompt([
|
|
373
|
+
{
|
|
374
|
+
type: 'input',
|
|
375
|
+
name: 'custom',
|
|
376
|
+
message: `${label}:`,
|
|
377
|
+
default: suggested || value.replace(/\s+/g, '-'),
|
|
378
|
+
},
|
|
379
|
+
]);
|
|
380
|
+
return custom;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* @param {Record<string, string>} sshAnswers
|
|
385
|
+
* @param {'frontend'|'backend'|'both'} projectType
|
|
386
|
+
* @returns {Promise<Record<string, string>>}
|
|
387
|
+
*/
|
|
388
|
+
export async function resolveDeployPathsWithSpaceWarning(sshAnswers, projectType) {
|
|
389
|
+
if (projectType !== 'both' && sshAnswers.deployPath) {
|
|
390
|
+
sshAnswers.deployPath = await confirmValueIfContainsSpaces(
|
|
391
|
+
sshAnswers.deployPath,
|
|
392
|
+
'deploy path'
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (projectType === 'both') {
|
|
397
|
+
if (sshAnswers.frontendDeployPath) {
|
|
398
|
+
sshAnswers.frontendDeployPath = await confirmValueIfContainsSpaces(
|
|
399
|
+
sshAnswers.frontendDeployPath,
|
|
400
|
+
'frontend deploy path'
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
if (sshAnswers.backendDeployPath) {
|
|
404
|
+
sshAnswers.backendDeployPath = await confirmValueIfContainsSpaces(
|
|
405
|
+
sshAnswers.backendDeployPath,
|
|
406
|
+
'backend deploy path'
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return sshAnswers;
|
|
412
|
+
}
|
|
413
|
+
|
|
332
414
|
/**
|
|
333
415
|
* Run SSH key validation + connectivity test after init prompts.
|
|
334
416
|
* @param {{ host: string, user: string, keyPath?: string, sshPort?: number, deployType: string }} opts
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
detectAzureSubscriptionId,
|
|
8
8
|
detectGcpProjectId,
|
|
9
9
|
runSshInitValidation,
|
|
10
|
+
resolveDeployPathsWithSpaceWarning,
|
|
10
11
|
testKubeConnectivity,
|
|
11
12
|
getDeployTypeLabel,
|
|
12
13
|
} from './init-helpers.js';
|
|
@@ -327,6 +328,7 @@ async function promptSshBasedDeployment(base, projectName, projectType, backendC
|
|
|
327
328
|
});
|
|
328
329
|
|
|
329
330
|
const sshAnswers = await inquirer.prompt(questions);
|
|
331
|
+
await resolveDeployPathsWithSpaceWarning(sshAnswers, projectType);
|
|
330
332
|
|
|
331
333
|
await runSshInitValidation({
|
|
332
334
|
host: sshAnswers.host,
|
|
@@ -3,7 +3,12 @@ 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 {
|
|
6
|
+
import {
|
|
7
|
+
getNginxSitesAvailablePath,
|
|
8
|
+
getNginxSitesEnabledPath,
|
|
9
|
+
getNginxConfDPath,
|
|
10
|
+
} from '../../utils/nginx.js';
|
|
11
|
+
import { shellQuote, formatRemoteCommandFailure } from '../../utils/shell-quote.js';
|
|
7
12
|
|
|
8
13
|
/** @type {Set<string>} */
|
|
9
14
|
const NODE_FRAMEWORKS = new Set(['express', 'nestjs', 'fastify', 'koa', 'nextjs', 'node']);
|
|
@@ -12,6 +17,8 @@ const PYTHON_FRAMEWORKS = new Set(['fastapi', 'django', 'flask', 'python']);
|
|
|
12
17
|
/** @type {Set<string>} */
|
|
13
18
|
const PHP_FRAMEWORKS = new Set(['laravel', 'symfony', 'php']);
|
|
14
19
|
|
|
20
|
+
const sh = shellQuote;
|
|
21
|
+
|
|
15
22
|
/**
|
|
16
23
|
* @param {import('../../core/config.js').DeployHubConfig} config
|
|
17
24
|
* @param {string} envName
|
|
@@ -88,7 +95,14 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
88
95
|
log.info(`$ ${command}`);
|
|
89
96
|
const result = await ssh.execCommand(command);
|
|
90
97
|
if (result.code !== 0 && result.code !== null) {
|
|
91
|
-
|
|
98
|
+
const message = formatRemoteCommandFailure(
|
|
99
|
+
command,
|
|
100
|
+
result.code,
|
|
101
|
+
result.stderr,
|
|
102
|
+
result.stdout
|
|
103
|
+
);
|
|
104
|
+
log.error(message);
|
|
105
|
+
throw new Error(message);
|
|
92
106
|
}
|
|
93
107
|
return result;
|
|
94
108
|
}
|
|
@@ -123,26 +137,27 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
123
137
|
async function runBackendStartSequence(ssh, targetPath) {
|
|
124
138
|
const framework = resolveFramework();
|
|
125
139
|
const startCommand = resolveStartCommand();
|
|
140
|
+
const dir = sh(targetPath);
|
|
126
141
|
|
|
127
142
|
if (NODE_FRAMEWORKS.has(framework)) {
|
|
128
|
-
await exec(ssh, `cd ${
|
|
143
|
+
await exec(ssh, `cd ${dir} && npm install --production`);
|
|
129
144
|
const start = startCommand || 'npm start';
|
|
130
145
|
if (start === 'npm start') {
|
|
131
146
|
await exec(
|
|
132
147
|
ssh,
|
|
133
|
-
`cd ${
|
|
148
|
+
`cd ${dir} && pm2 restart ${sh(appName)} || pm2 start npm --name ${sh(appName)} -- start`
|
|
134
149
|
);
|
|
135
150
|
} else if (start.startsWith('npm run ')) {
|
|
136
151
|
const script = start.replace('npm run ', '');
|
|
137
152
|
await exec(
|
|
138
153
|
ssh,
|
|
139
|
-
`cd ${
|
|
154
|
+
`cd ${dir} && pm2 restart ${sh(appName)} || pm2 start npm --name ${sh(appName)} -- run ${script}`
|
|
140
155
|
);
|
|
141
156
|
} else {
|
|
142
157
|
const [cmd, ...args] = start.split(' ');
|
|
143
158
|
await exec(
|
|
144
159
|
ssh,
|
|
145
|
-
`cd ${
|
|
160
|
+
`cd ${dir} && pm2 restart ${sh(appName)} || pm2 start ${cmd} --name ${sh(appName)} -- ${args.join(' ')}`
|
|
146
161
|
);
|
|
147
162
|
}
|
|
148
163
|
await exec(ssh, 'pm2 save');
|
|
@@ -150,15 +165,15 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
150
165
|
}
|
|
151
166
|
|
|
152
167
|
if (PYTHON_FRAMEWORKS.has(framework)) {
|
|
153
|
-
await exec(ssh, `cd ${
|
|
168
|
+
await exec(ssh, `cd ${dir} && pip install -r requirements.txt`);
|
|
154
169
|
if (framework === 'django') {
|
|
155
|
-
await exec(ssh, `cd ${
|
|
170
|
+
await exec(ssh, `cd ${dir} && python manage.py migrate`);
|
|
156
171
|
}
|
|
157
172
|
if (framework === 'fastapi') {
|
|
158
173
|
await exec(ssh, 'pkill uvicorn || true');
|
|
159
174
|
await exec(
|
|
160
175
|
ssh,
|
|
161
|
-
`cd ${
|
|
176
|
+
`cd ${dir} && nohup uvicorn main:app --host 0.0.0.0 --port ${port} > app.log 2>&1 &`
|
|
162
177
|
);
|
|
163
178
|
} else {
|
|
164
179
|
await exec(ssh, 'pkill gunicorn || true');
|
|
@@ -166,17 +181,17 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
166
181
|
framework === 'django' ? 'config.wsgi:application' : 'app:app';
|
|
167
182
|
await exec(
|
|
168
183
|
ssh,
|
|
169
|
-
`cd ${
|
|
184
|
+
`cd ${dir} && nohup gunicorn ${appTarget} --bind 0.0.0.0:${port} --daemon`
|
|
170
185
|
);
|
|
171
186
|
}
|
|
172
187
|
return;
|
|
173
188
|
}
|
|
174
189
|
|
|
175
190
|
if (PHP_FRAMEWORKS.has(framework)) {
|
|
176
|
-
await exec(ssh, `cd ${
|
|
191
|
+
await exec(ssh, `cd ${dir} && composer install --no-dev`);
|
|
177
192
|
if (framework === 'laravel') {
|
|
178
|
-
await exec(ssh, `cd ${
|
|
179
|
-
await exec(ssh, `cd ${
|
|
193
|
+
await exec(ssh, `cd ${dir} && php artisan migrate --force`);
|
|
194
|
+
await exec(ssh, `cd ${dir} && php artisan config:cache`);
|
|
180
195
|
}
|
|
181
196
|
await exec(ssh, 'sudo systemctl restart php8.2-fpm');
|
|
182
197
|
await exec(ssh, 'sudo systemctl reload nginx');
|
|
@@ -184,69 +199,130 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
184
199
|
}
|
|
185
200
|
|
|
186
201
|
if (framework === 'spring' || framework === 'java') {
|
|
187
|
-
await exec(ssh, `cd ${
|
|
202
|
+
await exec(ssh, `cd ${dir} && pkill -f "*.jar" || true`);
|
|
188
203
|
await exec(
|
|
189
204
|
ssh,
|
|
190
|
-
`cd ${
|
|
205
|
+
`cd ${dir} && nohup java -jar target/*.jar > app.log 2>&1 &`
|
|
191
206
|
);
|
|
192
207
|
return;
|
|
193
208
|
}
|
|
194
209
|
|
|
195
210
|
if (framework === 'go') {
|
|
196
|
-
await exec(ssh, `cd ${
|
|
211
|
+
await exec(ssh, `cd ${dir} && pkill ${sh(appName)} || true`);
|
|
197
212
|
await exec(
|
|
198
213
|
ssh,
|
|
199
|
-
`cd ${
|
|
214
|
+
`cd ${dir} && nohup ./bin/app > app.log 2>&1 &`
|
|
200
215
|
);
|
|
201
216
|
return;
|
|
202
217
|
}
|
|
203
218
|
|
|
204
219
|
if (framework === 'dotnet') {
|
|
205
|
-
await exec(ssh, `cd ${
|
|
220
|
+
await exec(ssh, `cd ${dir} && pkill -f "dotnet" || true`);
|
|
206
221
|
const dll = startCommand?.replace('dotnet ', '') || 'App.dll';
|
|
207
222
|
await exec(
|
|
208
223
|
ssh,
|
|
209
|
-
`cd ${
|
|
224
|
+
`cd ${dir} && nohup dotnet ${dll} > app.log 2>&1 &`
|
|
210
225
|
);
|
|
211
226
|
return;
|
|
212
227
|
}
|
|
213
228
|
|
|
214
229
|
if (framework === 'rails') {
|
|
215
|
-
await exec(ssh, `cd ${
|
|
216
|
-
await exec(ssh, `cd ${
|
|
230
|
+
await exec(ssh, `cd ${dir} && bundle install --deployment`);
|
|
231
|
+
await exec(ssh, `cd ${dir} && pkill puma || true`);
|
|
217
232
|
await exec(
|
|
218
233
|
ssh,
|
|
219
|
-
`cd ${
|
|
234
|
+
`cd ${dir} && nohup bundle exec puma -p ${port} > app.log 2>&1 &`
|
|
220
235
|
);
|
|
221
236
|
return;
|
|
222
237
|
}
|
|
223
238
|
|
|
224
|
-
await exec(ssh, `cd ${
|
|
239
|
+
await exec(ssh, `cd ${dir} && npm install --production`);
|
|
225
240
|
await exec(
|
|
226
241
|
ssh,
|
|
227
|
-
`cd ${
|
|
242
|
+
`cd ${dir} && pm2 restart ${sh(appName)} || pm2 start npm --name ${sh(appName)} -- start`
|
|
228
243
|
);
|
|
229
244
|
await exec(ssh, 'pm2 save');
|
|
230
245
|
}
|
|
231
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
|
+
|
|
232
285
|
/**
|
|
233
286
|
* @param {import('node-ssh').NodeSSH} ssh
|
|
234
287
|
* @param {string} targetPath
|
|
235
288
|
*/
|
|
236
289
|
async function setupNginx(ssh, targetPath) {
|
|
237
|
-
const sitePath = getNginxSitePath(config.project);
|
|
238
290
|
const nginxConfRemote = `${targetPath}/nginx.conf`;
|
|
239
291
|
|
|
240
|
-
await
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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)'
|
|
247
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
|
+
|
|
248
323
|
await exec(ssh, 'sudo nginx -t');
|
|
249
|
-
await
|
|
324
|
+
await reloadNginx(ssh);
|
|
325
|
+
log.success('Nginx config tested and reloaded');
|
|
250
326
|
}
|
|
251
327
|
|
|
252
328
|
/**
|
|
@@ -255,8 +331,8 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
255
331
|
* @param {string} targetPath
|
|
256
332
|
*/
|
|
257
333
|
async function extractToPath(ssh, remoteZip, targetPath) {
|
|
258
|
-
await exec(ssh, `mkdir -p ${targetPath}`);
|
|
259
|
-
await exec(ssh, `unzip -o ${remoteZip} -d ${targetPath}`);
|
|
334
|
+
await exec(ssh, `mkdir -p ${sh(targetPath)}`);
|
|
335
|
+
await exec(ssh, `unzip -o ${sh(remoteZip)} -d ${sh(targetPath)}`);
|
|
260
336
|
}
|
|
261
337
|
|
|
262
338
|
/**
|
|
@@ -276,19 +352,19 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
276
352
|
|
|
277
353
|
if (projectType === 'both') {
|
|
278
354
|
const remoteStaging = `/tmp/deployhub-staging-${Date.now()}`;
|
|
279
|
-
await exec(ssh, `mkdir -p ${remoteStaging}`);
|
|
280
|
-
await exec(ssh, `unzip -o ${remoteZip} -d ${remoteStaging}`);
|
|
355
|
+
await exec(ssh, `mkdir -p ${sh(remoteStaging)}`);
|
|
356
|
+
await exec(ssh, `unzip -o ${sh(remoteZip)} -d ${sh(remoteStaging)}`);
|
|
281
357
|
|
|
282
|
-
await exec(ssh, `mkdir -p ${frontendDeployPath}`);
|
|
358
|
+
await exec(ssh, `mkdir -p ${sh(frontendDeployPath)}`);
|
|
283
359
|
await exec(
|
|
284
360
|
ssh,
|
|
285
|
-
`rsync -a ${remoteStaging}/ ${frontendDeployPath}/ --exclude backend || cp -r ${remoteStaging}/* ${frontendDeployPath}/`
|
|
361
|
+
`rsync -a ${sh(remoteStaging)}/ ${sh(frontendDeployPath)}/ --exclude backend || cp -r ${sh(remoteStaging)}/* ${sh(frontendDeployPath)}/`
|
|
286
362
|
);
|
|
287
363
|
|
|
288
|
-
await exec(ssh, `mkdir -p ${backendDeployPath}`);
|
|
364
|
+
await exec(ssh, `mkdir -p ${sh(backendDeployPath)}`);
|
|
289
365
|
await exec(
|
|
290
366
|
ssh,
|
|
291
|
-
`rsync -a ${remoteStaging}/backend/ ${backendDeployPath}/ || cp -r ${remoteStaging}/backend/* ${backendDeployPath}/`
|
|
367
|
+
`rsync -a ${sh(remoteStaging)}/backend/ ${sh(backendDeployPath)}/ || cp -r ${sh(remoteStaging)}/backend/* ${sh(backendDeployPath)}/`
|
|
292
368
|
);
|
|
293
369
|
|
|
294
370
|
if (await remoteFileExists(ssh, `${frontendDeployPath}/nginx.conf`)) {
|
|
@@ -296,7 +372,7 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
296
372
|
}
|
|
297
373
|
|
|
298
374
|
await runBackendStartSequence(ssh, backendDeployPath);
|
|
299
|
-
await exec(ssh, `rm -rf ${remoteStaging}`);
|
|
375
|
+
await exec(ssh, `rm -rf ${sh(remoteStaging)}`);
|
|
300
376
|
} else if (projectType === 'backend') {
|
|
301
377
|
log.info(`Backend deploy path: ${deployPath}`);
|
|
302
378
|
await extractToPath(ssh, remoteZip, deployPath);
|
|
@@ -313,7 +389,7 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
313
389
|
}
|
|
314
390
|
}
|
|
315
391
|
|
|
316
|
-
await exec(ssh, `rm -f ${remoteZip}`);
|
|
392
|
+
await exec(ssh, `rm -f ${sh(remoteZip)}`);
|
|
317
393
|
log.success('Deployment complete');
|
|
318
394
|
} finally {
|
|
319
395
|
ssh.dispose();
|
|
@@ -325,8 +401,8 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
325
401
|
* @param {string} remotePath
|
|
326
402
|
*/
|
|
327
403
|
async function remoteFileExists(ssh, remotePath) {
|
|
328
|
-
const result = await ssh.execCommand(`test -f ${remotePath} && echo yes`);
|
|
329
|
-
return result.stdout.trim() === 'yes';
|
|
404
|
+
const result = await ssh.execCommand(`test -f ${sh(remotePath)} && echo yes`);
|
|
405
|
+
return result.code === 0 && result.stdout.trim() === 'yes';
|
|
330
406
|
}
|
|
331
407
|
|
|
332
408
|
async function rollback(artifactDir) {
|
|
@@ -339,7 +415,7 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
339
415
|
|
|
340
416
|
const ssh = await connect();
|
|
341
417
|
try {
|
|
342
|
-
const result = await ssh.execCommand(`curl -sf -o /dev/null -w "%{http_code}"
|
|
418
|
+
const result = await ssh.execCommand(`curl -sf -o /dev/null -w "%{http_code}" ${sh(url)}`);
|
|
343
419
|
return result.stdout.trim().startsWith('2');
|
|
344
420
|
} finally {
|
|
345
421
|
ssh.dispose();
|
package/src/utils/nginx.js
CHANGED
|
@@ -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
|
-
|
|
31
|
-
|
|
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 {
|
|
83
|
+
export default {
|
|
84
|
+
generateNginxConfig,
|
|
85
|
+
sanitizeNginxProjectName,
|
|
86
|
+
getNginxSitesAvailablePath,
|
|
87
|
+
getNginxSitesEnabledPath,
|
|
88
|
+
getNginxConfDPath,
|
|
89
|
+
getNginxSitePath,
|
|
90
|
+
formatPasswordlessSudoGuidance,
|
|
91
|
+
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wrap a value for safe interpolation into a POSIX shell command string.
|
|
3
|
+
* Uses single quotes; embedded single quotes are escaped as '\''.
|
|
4
|
+
* @param {string} value
|
|
5
|
+
* @returns {string}
|
|
6
|
+
*/
|
|
7
|
+
export function shellQuote(value) {
|
|
8
|
+
if (value == null) return "''";
|
|
9
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {string} command
|
|
14
|
+
* @param {number|null|undefined} code
|
|
15
|
+
* @param {string} [stderr]
|
|
16
|
+
* @param {string} [stdout]
|
|
17
|
+
* @returns {string}
|
|
18
|
+
*/
|
|
19
|
+
export function formatRemoteCommandFailure(command, code, stderr, stdout) {
|
|
20
|
+
const cmdName = command.trim().split(/\s+/)[0] || 'command';
|
|
21
|
+
const detail = (stderr || stdout || '').trim();
|
|
22
|
+
return `Deploy failed: ${cmdName} exited with code ${code}${detail ? `: ${detail}` : ''}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Remote shell command that verifies write access to a deploy directory.
|
|
27
|
+
* @param {string} deployPath
|
|
28
|
+
* @returns {string}
|
|
29
|
+
*/
|
|
30
|
+
export function buildDeployPathWriteTestCommand(deployPath) {
|
|
31
|
+
const dir = shellQuote(deployPath);
|
|
32
|
+
const testFile = shellQuote(`${deployPath}/.deployhub-write-test`);
|
|
33
|
+
return `mkdir -p ${dir} && touch ${testFile} && rm -f ${testFile}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {string} deployPath
|
|
38
|
+
* @param {string} sshUser
|
|
39
|
+
* @param {string} [errorDetail]
|
|
40
|
+
* @returns {string}
|
|
41
|
+
*/
|
|
42
|
+
export function formatDeployPathWriteFailure(deployPath, sshUser, errorDetail) {
|
|
43
|
+
const reason = errorDetail ? ` (${errorDetail.trim()})` : '';
|
|
44
|
+
const quotedPath = shellQuote(deployPath);
|
|
45
|
+
return (
|
|
46
|
+
`Cannot write to ${deployPath} on the server${reason}.\n` +
|
|
47
|
+
` Run this on your server first:\n` +
|
|
48
|
+
` sudo mkdir -p ${quotedPath}\n` +
|
|
49
|
+
` sudo chown ${sshUser}:${sshUser} ${quotedPath}\n` +
|
|
50
|
+
` (replace ${sshUser} with your actual SSH_USER if different)`
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @param {string} name
|
|
56
|
+
* @returns {string}
|
|
57
|
+
*/
|
|
58
|
+
export function toKebabCase(name) {
|
|
59
|
+
return name
|
|
60
|
+
.trim()
|
|
61
|
+
.replace(/\s+/g, '-')
|
|
62
|
+
.replace(/[^a-zA-Z0-9-_]/g, '')
|
|
63
|
+
.toLowerCase();
|
|
64
|
+
}
|