@akash-chowdhury-24/deployhub 2.0.2 → 2.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -610,20 +610,23 @@ Each method below follows the same structure: **prerequisites** (before `deployh
610
610
  - [ ] A Linux server with SSH enabled
611
611
  - [ ] Private SSH key file (.pem/.key) and public key in `authorized_keys`
612
612
  - [ ] Port 22 open in firewall for your IP
613
+ - [ ] 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
614
  - [ ] App runtime on server (Node.js, Python, etc.) for backends
614
615
 
615
616
  **What DeployHub automates:**
616
617
  - Complete `.env.example` with commented variables
617
618
  - SSH key permission check (offers to `chmod 600`)
618
619
  - SSH connectivity test during `init`
620
+ - Deploy path write-permission check during `deployhub doctor`
619
621
  - Artifact upload, extract, app restart (PM2, gunicorn, etc.)
620
622
 
621
623
  **After `init`:**
622
624
  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`
625
+ 2. Ensure your deploy directory exists and is owned by your SSH user (see prerequisite above if `deployhub doctor` reports permission denied)
626
+ 3. Copy `.env.example` `.env`; set `SSH_HOST`, `SSH_USER`, `SSH_KEY_PATH`
627
+ 4. Add GitHub Secrets: `SSH_HOST`, `SSH_USER`, `SSH_KEY` (paste private key for CI)
628
+ 5. Run `deployhub doctor`
629
+ 6. `git push origin main`
627
630
 
628
631
  | Variable | Description | Example | Where to get it |
629
632
  |----------|-------------|---------|-----------------|
@@ -669,19 +672,22 @@ Each method below follows the same structure: **prerequisites** (before `deployh
669
672
  - [ ] EC2 instance launched in AWS Console (DeployHub does not create it)
670
673
  - [ ] Key pair `.pem` downloaded at launch
671
674
  - [ ] Security group: inbound SSH (22) from your IP
675
+ - [ ] 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
676
  - [ ] App runtime on instance for backends
673
677
 
674
678
  **What DeployHub automates:**
675
679
  - EC2-specific `.env.example` (SSH + optional AWS API vars)
676
680
  - SSH key validation and connectivity test
681
+ - Deploy path write-permission check during `deployhub doctor`
677
682
  - OS user suggestion from AMI hint (ubuntu, ec2-user)
678
683
  - Optional public IP lookup via `EC2_INSTANCE_ID` + AWS CLI
679
684
 
680
685
  **After `init`:**
681
686
  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`
687
+ 2. Ensure your deploy directory exists and is owned by your SSH user (see prerequisite above if `deployhub doctor` reports permission denied)
688
+ 3. Copy `.env.example` → `.env`; set `SSH_KEY_PATH`, `SSH_HOST` (or `EC2_INSTANCE_ID` + AWS creds)
689
+ 4. GitHub Secrets: `SSH_HOST`, `SSH_USER`, `SSH_KEY`, plus `AWS_*` if using instance ID lookup
690
+ 5. Run `deployhub doctor`, then `git push origin main`
685
691
 
686
692
  | Variable | Description | Example | Where to get it |
687
693
  |----------|-------------|---------|-----------------|
@@ -699,18 +705,21 @@ Each method below follows the same structure: **prerequisites** (before `deployh
699
705
  - [ ] Azure VM created in Portal (DeployHub does not provision it)
700
706
  - [ ] NSG rule allowing inbound SSH (port 22)
701
707
  - [ ] SSH public key on the VM
708
+ - [ ] 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
709
  - [ ] App runtime for backends
703
710
 
704
711
  **What DeployHub automates:**
705
712
  - Azure VM `.env.example` with SSH + optional Azure API vars
706
713
  - Auto-detects subscription ID via `az` CLI if logged in
707
714
  - SSH key validation and connectivity test
715
+ - Deploy path write-permission check during `deployhub doctor`
708
716
 
709
717
  **After `init`:**
710
718
  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`
719
+ 2. Ensure your deploy directory exists and is owned by your SSH user (see prerequisite above if `deployhub doctor` reports permission denied)
720
+ 3. Copy `.env.example` `.env`; set `SSH_HOST`, `SSH_USER`, `SSH_KEY_PATH`
721
+ 4. For CI: add `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET` as GitHub Secrets
722
+ 5. Run `deployhub doctor`, then `git push origin main`
714
723
 
715
724
  | Variable | Description | Example | Where to get it |
716
725
  |----------|-------------|---------|-----------------|
@@ -727,18 +736,21 @@ Each method below follows the same structure: **prerequisites** (before `deployh
727
736
  - [ ] Compute Engine VM created (DeployHub does not create it)
728
737
  - [ ] Firewall rule allowing `tcp:22` (default `default-allow-ssh` may exist)
729
738
  - [ ] SSH public key in **Metadata → SSH Keys** (GCP uses metadata keys, not launch key pairs like AWS)
739
+ - [ ] 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
740
  - [ ] App runtime for backends
731
741
 
732
742
  **What DeployHub automates:**
733
743
  - GCP VM `.env.example` with SSH + optional GCP API vars
734
744
  - Auto-detects project ID via `gcloud` if authenticated
735
745
  - SSH key validation and connectivity test
746
+ - Deploy path write-permission check during `deployhub doctor`
736
747
 
737
748
  **After `init`:**
738
749
  1. GCP Console → VPC → Firewall → ensure SSH (tcp:22) allowed from your IP
739
750
  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`
751
+ 3. Ensure your deploy directory exists and is owned by your SSH user (see prerequisite above if `deployhub doctor` reports permission denied)
752
+ 4. Copy `.env.example` `.env`; set `SSH_HOST`, `SSH_USER`, `SSH_KEY_PATH`
753
+ 5. Run `deployhub doctor`, then `git push origin main`
742
754
 
743
755
  | Variable | Description | Example | Where to get it |
744
756
  |----------|-------------|---------|-----------------|
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.3",
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,10 @@ 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';
17
21
 
18
22
  /**
19
23
  * @typedef {{ name: string, pass: boolean, message: string }} CheckResult
@@ -56,6 +60,28 @@ function resolveBackendFramework(config) {
56
60
  /** @type {Set<string>} */
57
61
  const SSH_DEPLOY_TYPES = new Set(['ssh', 'ec2', 'azure-vm', 'gcp-vm']);
58
62
 
63
+ /**
64
+ * @param {import('../core/config.js').DeployHubConfig} config
65
+ * @param {Record<string, unknown>} envConfig
66
+ * @returns {string[]}
67
+ */
68
+ function resolveSshDeployPaths(config, envConfig) {
69
+ /** @type {string[]} */
70
+ const paths = [];
71
+
72
+ if (config.projectType === 'both') {
73
+ if (envConfig.frontendDeployPath) paths.push(String(envConfig.frontendDeployPath));
74
+ if (envConfig.backendDeployPath) paths.push(String(envConfig.backendDeployPath));
75
+ else if (envConfig.path) paths.push(String(envConfig.path));
76
+ } else {
77
+ const deployPath =
78
+ envConfig.deployPath || envConfig.path || process.env.SSH_DEPLOY_PATH;
79
+ if (deployPath) paths.push(String(deployPath));
80
+ }
81
+
82
+ return [...new Set(paths.filter(Boolean))];
83
+ }
84
+
59
85
  /**
60
86
  * @param {import('../core/config.js').DeployHubConfig} config
61
87
  * @param {string} envName
@@ -131,6 +157,56 @@ async function runDeploymentChecks(config, envName, envConfig) {
131
157
  })
132
158
  );
133
159
 
160
+ const deployPaths = resolveSshDeployPaths(config, envConfig);
161
+ const sshUser = String(user || process.env.SSH_USER || 'your-user');
162
+
163
+ for (const deployPath of deployPaths) {
164
+ const checkName = `Deploy path write (${deployPath})`;
165
+ checks.push(
166
+ await runCheck(checkName, async () => {
167
+ if (!host || !user) {
168
+ return {
169
+ name: checkName,
170
+ pass: false,
171
+ message: 'SSH_HOST and SSH_USER are required for deploy path write test.',
172
+ };
173
+ }
174
+ if (!keyPath && !process.env.SSH_KEY) {
175
+ return {
176
+ name: checkName,
177
+ pass: false,
178
+ message: 'SSH_KEY_PATH or SSH_KEY is required for deploy path write test.',
179
+ };
180
+ }
181
+
182
+ const provider = getDeploymentProvider(deployType, config, envName);
183
+ if (!provider.runRemoteCheck) {
184
+ return {
185
+ name: checkName,
186
+ pass: false,
187
+ message: 'Deploy provider does not support remote checks.',
188
+ };
189
+ }
190
+
191
+ const command = buildDeployPathWriteTestCommand(deployPath);
192
+ const result = await provider.runRemoteCheck(command);
193
+ if (result.pass) {
194
+ return {
195
+ name: checkName,
196
+ pass: true,
197
+ message: `Write access OK for ${deployPath}`,
198
+ };
199
+ }
200
+
201
+ return {
202
+ name: checkName,
203
+ pass: false,
204
+ message: formatDeployPathWriteFailure(deployPath, sshUser, result.message),
205
+ };
206
+ })
207
+ );
208
+ }
209
+
134
210
  const isBackend = config.projectType === 'backend' || config.projectType === 'both';
135
211
  if (isBackend) {
136
212
  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,
@@ -4,6 +4,7 @@ import path from 'path';
4
4
  import os from 'os';
5
5
  import { createLogger } from '../../logger/index.js';
6
6
  import { getNginxSitePath } from '../../utils/nginx.js';
7
+ import { shellQuote, formatRemoteCommandFailure } from '../../utils/shell-quote.js';
7
8
 
8
9
  /** @type {Set<string>} */
9
10
  const NODE_FRAMEWORKS = new Set(['express', 'nestjs', 'fastify', 'koa', 'nextjs', 'node']);
@@ -12,6 +13,8 @@ const PYTHON_FRAMEWORKS = new Set(['fastapi', 'django', 'flask', 'python']);
12
13
  /** @type {Set<string>} */
13
14
  const PHP_FRAMEWORKS = new Set(['laravel', 'symfony', 'php']);
14
15
 
16
+ const sh = shellQuote;
17
+
15
18
  /**
16
19
  * @param {import('../../core/config.js').DeployHubConfig} config
17
20
  * @param {string} envName
@@ -88,7 +91,14 @@ export function createSshProvider(config, envName, env = process.env) {
88
91
  log.info(`$ ${command}`);
89
92
  const result = await ssh.execCommand(command);
90
93
  if (result.code !== 0 && result.code !== null) {
91
- log.warn(`Command exited with code ${result.code}: ${result.stderr || result.stdout}`);
94
+ const message = formatRemoteCommandFailure(
95
+ command,
96
+ result.code,
97
+ result.stderr,
98
+ result.stdout
99
+ );
100
+ log.error(message);
101
+ throw new Error(message);
92
102
  }
93
103
  return result;
94
104
  }
@@ -123,26 +133,27 @@ export function createSshProvider(config, envName, env = process.env) {
123
133
  async function runBackendStartSequence(ssh, targetPath) {
124
134
  const framework = resolveFramework();
125
135
  const startCommand = resolveStartCommand();
136
+ const dir = sh(targetPath);
126
137
 
127
138
  if (NODE_FRAMEWORKS.has(framework)) {
128
- await exec(ssh, `cd ${targetPath} && npm install --production`);
139
+ await exec(ssh, `cd ${dir} && npm install --production`);
129
140
  const start = startCommand || 'npm start';
130
141
  if (start === 'npm start') {
131
142
  await exec(
132
143
  ssh,
133
- `cd ${targetPath} && pm2 restart ${appName} || pm2 start npm --name "${appName}" -- start`
144
+ `cd ${dir} && pm2 restart ${sh(appName)} || pm2 start npm --name ${sh(appName)} -- start`
134
145
  );
135
146
  } else if (start.startsWith('npm run ')) {
136
147
  const script = start.replace('npm run ', '');
137
148
  await exec(
138
149
  ssh,
139
- `cd ${targetPath} && pm2 restart ${appName} || pm2 start npm --name "${appName}" -- run ${script}`
150
+ `cd ${dir} && pm2 restart ${sh(appName)} || pm2 start npm --name ${sh(appName)} -- run ${script}`
140
151
  );
141
152
  } else {
142
153
  const [cmd, ...args] = start.split(' ');
143
154
  await exec(
144
155
  ssh,
145
- `cd ${targetPath} && pm2 restart ${appName} || pm2 start ${cmd} --name "${appName}" -- ${args.join(' ')}`
156
+ `cd ${dir} && pm2 restart ${sh(appName)} || pm2 start ${cmd} --name ${sh(appName)} -- ${args.join(' ')}`
146
157
  );
147
158
  }
148
159
  await exec(ssh, 'pm2 save');
@@ -150,15 +161,15 @@ export function createSshProvider(config, envName, env = process.env) {
150
161
  }
151
162
 
152
163
  if (PYTHON_FRAMEWORKS.has(framework)) {
153
- await exec(ssh, `cd ${targetPath} && pip install -r requirements.txt`);
164
+ await exec(ssh, `cd ${dir} && pip install -r requirements.txt`);
154
165
  if (framework === 'django') {
155
- await exec(ssh, `cd ${targetPath} && python manage.py migrate`);
166
+ await exec(ssh, `cd ${dir} && python manage.py migrate`);
156
167
  }
157
168
  if (framework === 'fastapi') {
158
169
  await exec(ssh, 'pkill uvicorn || true');
159
170
  await exec(
160
171
  ssh,
161
- `cd ${targetPath} && nohup uvicorn main:app --host 0.0.0.0 --port ${port} > app.log 2>&1 &`
172
+ `cd ${dir} && nohup uvicorn main:app --host 0.0.0.0 --port ${port} > app.log 2>&1 &`
162
173
  );
163
174
  } else {
164
175
  await exec(ssh, 'pkill gunicorn || true');
@@ -166,17 +177,17 @@ export function createSshProvider(config, envName, env = process.env) {
166
177
  framework === 'django' ? 'config.wsgi:application' : 'app:app';
167
178
  await exec(
168
179
  ssh,
169
- `cd ${targetPath} && nohup gunicorn ${appTarget} --bind 0.0.0.0:${port} --daemon`
180
+ `cd ${dir} && nohup gunicorn ${appTarget} --bind 0.0.0.0:${port} --daemon`
170
181
  );
171
182
  }
172
183
  return;
173
184
  }
174
185
 
175
186
  if (PHP_FRAMEWORKS.has(framework)) {
176
- await exec(ssh, `cd ${targetPath} && composer install --no-dev`);
187
+ await exec(ssh, `cd ${dir} && composer install --no-dev`);
177
188
  if (framework === 'laravel') {
178
- await exec(ssh, `cd ${targetPath} && php artisan migrate --force`);
179
- await exec(ssh, `cd ${targetPath} && php artisan config:cache`);
189
+ await exec(ssh, `cd ${dir} && php artisan migrate --force`);
190
+ await exec(ssh, `cd ${dir} && php artisan config:cache`);
180
191
  }
181
192
  await exec(ssh, 'sudo systemctl restart php8.2-fpm');
182
193
  await exec(ssh, 'sudo systemctl reload nginx');
@@ -184,47 +195,47 @@ export function createSshProvider(config, envName, env = process.env) {
184
195
  }
185
196
 
186
197
  if (framework === 'spring' || framework === 'java') {
187
- await exec(ssh, `cd ${targetPath} && pkill -f "*.jar" || true`);
198
+ await exec(ssh, `cd ${dir} && pkill -f "*.jar" || true`);
188
199
  await exec(
189
200
  ssh,
190
- `cd ${targetPath} && nohup java -jar target/*.jar > app.log 2>&1 &`
201
+ `cd ${dir} && nohup java -jar target/*.jar > app.log 2>&1 &`
191
202
  );
192
203
  return;
193
204
  }
194
205
 
195
206
  if (framework === 'go') {
196
- await exec(ssh, `cd ${targetPath} && pkill ${appName} || true`);
207
+ await exec(ssh, `cd ${dir} && pkill ${sh(appName)} || true`);
197
208
  await exec(
198
209
  ssh,
199
- `cd ${targetPath} && nohup ./bin/app > app.log 2>&1 &`
210
+ `cd ${dir} && nohup ./bin/app > app.log 2>&1 &`
200
211
  );
201
212
  return;
202
213
  }
203
214
 
204
215
  if (framework === 'dotnet') {
205
- await exec(ssh, `cd ${targetPath} && pkill -f "dotnet" || true`);
216
+ await exec(ssh, `cd ${dir} && pkill -f "dotnet" || true`);
206
217
  const dll = startCommand?.replace('dotnet ', '') || 'App.dll';
207
218
  await exec(
208
219
  ssh,
209
- `cd ${targetPath} && nohup dotnet ${dll} > app.log 2>&1 &`
220
+ `cd ${dir} && nohup dotnet ${dll} > app.log 2>&1 &`
210
221
  );
211
222
  return;
212
223
  }
213
224
 
214
225
  if (framework === 'rails') {
215
- await exec(ssh, `cd ${targetPath} && bundle install --deployment`);
216
- await exec(ssh, `cd ${targetPath} && pkill puma || true`);
226
+ await exec(ssh, `cd ${dir} && bundle install --deployment`);
227
+ await exec(ssh, `cd ${dir} && pkill puma || true`);
217
228
  await exec(
218
229
  ssh,
219
- `cd ${targetPath} && nohup bundle exec puma -p ${port} > app.log 2>&1 &`
230
+ `cd ${dir} && nohup bundle exec puma -p ${port} > app.log 2>&1 &`
220
231
  );
221
232
  return;
222
233
  }
223
234
 
224
- await exec(ssh, `cd ${targetPath} && npm install --production`);
235
+ await exec(ssh, `cd ${dir} && npm install --production`);
225
236
  await exec(
226
237
  ssh,
227
- `cd ${targetPath} && pm2 restart ${appName} || pm2 start npm --name "${appName}" -- start`
238
+ `cd ${dir} && pm2 restart ${sh(appName)} || pm2 start npm --name ${sh(appName)} -- start`
228
239
  );
229
240
  await exec(ssh, 'pm2 save');
230
241
  }
@@ -239,11 +250,11 @@ export function createSshProvider(config, envName, env = process.env) {
239
250
 
240
251
  await exec(
241
252
  ssh,
242
- `sudo cp ${nginxConfRemote} ${sitePath} 2>/dev/null || sudo cp ${targetPath}/nginx.conf ${sitePath}`
253
+ `sudo cp ${sh(nginxConfRemote)} ${sh(sitePath)} 2>/dev/null || sudo cp ${sh(`${targetPath}/nginx.conf`)} ${sh(sitePath)}`
243
254
  );
244
255
  await exec(
245
256
  ssh,
246
- `sudo ln -sf ${sitePath} /etc/nginx/sites-enabled/${path.basename(sitePath)}`
257
+ `sudo ln -sf ${sh(sitePath)} ${sh(`/etc/nginx/sites-enabled/${path.basename(sitePath)}`)}`
247
258
  );
248
259
  await exec(ssh, 'sudo nginx -t');
249
260
  await exec(ssh, 'sudo systemctl reload nginx');
@@ -255,8 +266,8 @@ export function createSshProvider(config, envName, env = process.env) {
255
266
  * @param {string} targetPath
256
267
  */
257
268
  async function extractToPath(ssh, remoteZip, targetPath) {
258
- await exec(ssh, `mkdir -p ${targetPath}`);
259
- await exec(ssh, `unzip -o ${remoteZip} -d ${targetPath}`);
269
+ await exec(ssh, `mkdir -p ${sh(targetPath)}`);
270
+ await exec(ssh, `unzip -o ${sh(remoteZip)} -d ${sh(targetPath)}`);
260
271
  }
261
272
 
262
273
  /**
@@ -276,19 +287,19 @@ export function createSshProvider(config, envName, env = process.env) {
276
287
 
277
288
  if (projectType === 'both') {
278
289
  const remoteStaging = `/tmp/deployhub-staging-${Date.now()}`;
279
- await exec(ssh, `mkdir -p ${remoteStaging}`);
280
- await exec(ssh, `unzip -o ${remoteZip} -d ${remoteStaging}`);
290
+ await exec(ssh, `mkdir -p ${sh(remoteStaging)}`);
291
+ await exec(ssh, `unzip -o ${sh(remoteZip)} -d ${sh(remoteStaging)}`);
281
292
 
282
- await exec(ssh, `mkdir -p ${frontendDeployPath}`);
293
+ await exec(ssh, `mkdir -p ${sh(frontendDeployPath)}`);
283
294
  await exec(
284
295
  ssh,
285
- `rsync -a ${remoteStaging}/ ${frontendDeployPath}/ --exclude backend || cp -r ${remoteStaging}/* ${frontendDeployPath}/`
296
+ `rsync -a ${sh(remoteStaging)}/ ${sh(frontendDeployPath)}/ --exclude backend || cp -r ${sh(remoteStaging)}/* ${sh(frontendDeployPath)}/`
286
297
  );
287
298
 
288
- await exec(ssh, `mkdir -p ${backendDeployPath}`);
299
+ await exec(ssh, `mkdir -p ${sh(backendDeployPath)}`);
289
300
  await exec(
290
301
  ssh,
291
- `rsync -a ${remoteStaging}/backend/ ${backendDeployPath}/ || cp -r ${remoteStaging}/backend/* ${backendDeployPath}/`
302
+ `rsync -a ${sh(remoteStaging)}/backend/ ${sh(backendDeployPath)}/ || cp -r ${sh(remoteStaging)}/backend/* ${sh(backendDeployPath)}/`
292
303
  );
293
304
 
294
305
  if (await remoteFileExists(ssh, `${frontendDeployPath}/nginx.conf`)) {
@@ -296,7 +307,7 @@ export function createSshProvider(config, envName, env = process.env) {
296
307
  }
297
308
 
298
309
  await runBackendStartSequence(ssh, backendDeployPath);
299
- await exec(ssh, `rm -rf ${remoteStaging}`);
310
+ await exec(ssh, `rm -rf ${sh(remoteStaging)}`);
300
311
  } else if (projectType === 'backend') {
301
312
  log.info(`Backend deploy path: ${deployPath}`);
302
313
  await extractToPath(ssh, remoteZip, deployPath);
@@ -313,7 +324,7 @@ export function createSshProvider(config, envName, env = process.env) {
313
324
  }
314
325
  }
315
326
 
316
- await exec(ssh, `rm -f ${remoteZip}`);
327
+ await exec(ssh, `rm -f ${sh(remoteZip)}`);
317
328
  log.success('Deployment complete');
318
329
  } finally {
319
330
  ssh.dispose();
@@ -325,8 +336,8 @@ export function createSshProvider(config, envName, env = process.env) {
325
336
  * @param {string} remotePath
326
337
  */
327
338
  async function remoteFileExists(ssh, remotePath) {
328
- const result = await ssh.execCommand(`test -f ${remotePath} && echo yes`);
329
- return result.stdout.trim() === 'yes';
339
+ const result = await ssh.execCommand(`test -f ${sh(remotePath)} && echo yes`);
340
+ return result.code === 0 && result.stdout.trim() === 'yes';
330
341
  }
331
342
 
332
343
  async function rollback(artifactDir) {
@@ -339,7 +350,7 @@ export function createSshProvider(config, envName, env = process.env) {
339
350
 
340
351
  const ssh = await connect();
341
352
  try {
342
- const result = await ssh.execCommand(`curl -sf -o /dev/null -w "%{http_code}" "${url}"`);
353
+ const result = await ssh.execCommand(`curl -sf -o /dev/null -w "%{http_code}" ${sh(url)}`);
343
354
  return result.stdout.trim().startsWith('2');
344
355
  } finally {
345
356
  ssh.dispose();
@@ -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
+ }