@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 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. Copy `.env.example` `.env`; set `SSH_HOST`, `SSH_USER`, `SSH_KEY_PATH`
624
- 3. Add GitHub Secrets: `SSH_HOST`, `SSH_USER`, `SSH_KEY` (paste private key for CI)
625
- 4. Run `deployhub doctor`
626
- 5. `git push origin main`
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. Copy `.env.example` `.env`; set `SSH_KEY_PATH`, `SSH_HOST` (or `EC2_INSTANCE_ID` + AWS creds)
683
- 3. GitHub Secrets: `SSH_HOST`, `SSH_USER`, `SSH_KEY`, plus `AWS_*` if using instance ID lookup
684
- 4. Run `deployhub doctor`, then `git push origin main`
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. Copy `.env.example` `.env`; set `SSH_HOST`, `SSH_USER`, `SSH_KEY_PATH`
712
- 3. For CI: add `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET` as GitHub Secrets
713
- 4. Run `deployhub doctor`, then `git push origin main`
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. Copy `.env.example` `.env`; set `SSH_HOST`, `SSH_USER`, `SSH_KEY_PATH`
741
- 4. Run `deployhub doctor`, then `git push origin main`
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akash-chowdhury-24/deployhub",
3
- "version": "2.0.2",
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",
@@ -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(`Deploy failed: ${failure.message}`));
74
+ console.error(chalk.red(failure.message));
75
75
  process.exit(1);
76
76
  }
77
77
 
@@ -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);
@@ -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
- const { projectName } = await inquirer.prompt([
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 { getNginxSitePath } from '../../utils/nginx.js';
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
- log.warn(`Command exited with code ${result.code}: ${result.stderr || result.stdout}`);
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 ${targetPath} && npm install --production`);
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 ${targetPath} && pm2 restart ${appName} || pm2 start npm --name "${appName}" -- start`
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 ${targetPath} && pm2 restart ${appName} || pm2 start npm --name "${appName}" -- run ${script}`
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 ${targetPath} && pm2 restart ${appName} || pm2 start ${cmd} --name "${appName}" -- ${args.join(' ')}`
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 ${targetPath} && pip install -r requirements.txt`);
168
+ await exec(ssh, `cd ${dir} && pip install -r requirements.txt`);
154
169
  if (framework === 'django') {
155
- await exec(ssh, `cd ${targetPath} && python manage.py migrate`);
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 ${targetPath} && nohup uvicorn main:app --host 0.0.0.0 --port ${port} > app.log 2>&1 &`
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 ${targetPath} && nohup gunicorn ${appTarget} --bind 0.0.0.0:${port} --daemon`
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 ${targetPath} && composer install --no-dev`);
191
+ await exec(ssh, `cd ${dir} && composer install --no-dev`);
177
192
  if (framework === 'laravel') {
178
- await exec(ssh, `cd ${targetPath} && php artisan migrate --force`);
179
- await exec(ssh, `cd ${targetPath} && php artisan config:cache`);
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 ${targetPath} && pkill -f "*.jar" || true`);
202
+ await exec(ssh, `cd ${dir} && pkill -f "*.jar" || true`);
188
203
  await exec(
189
204
  ssh,
190
- `cd ${targetPath} && nohup java -jar target/*.jar > app.log 2>&1 &`
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 ${targetPath} && pkill ${appName} || true`);
211
+ await exec(ssh, `cd ${dir} && pkill ${sh(appName)} || true`);
197
212
  await exec(
198
213
  ssh,
199
- `cd ${targetPath} && nohup ./bin/app > app.log 2>&1 &`
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 ${targetPath} && pkill -f "dotnet" || true`);
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 ${targetPath} && nohup dotnet ${dll} > app.log 2>&1 &`
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 ${targetPath} && bundle install --deployment`);
216
- await exec(ssh, `cd ${targetPath} && pkill puma || true`);
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 ${targetPath} && nohup bundle exec puma -p ${port} > app.log 2>&1 &`
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 ${targetPath} && npm install --production`);
239
+ await exec(ssh, `cd ${dir} && npm install --production`);
225
240
  await exec(
226
241
  ssh,
227
- `cd ${targetPath} && pm2 restart ${appName} || pm2 start npm --name "${appName}" -- start`
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 exec(
241
- ssh,
242
- `sudo cp ${nginxConfRemote} ${sitePath} 2>/dev/null || sudo cp ${targetPath}/nginx.conf ${sitePath}`
243
- );
244
- await exec(
245
- ssh,
246
- `sudo ln -sf ${sitePath} /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)'
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 exec(ssh, 'sudo systemctl reload nginx');
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}" "${url}"`);
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();
@@ -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
+ };
@@ -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
+ }