@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 +24 -12
- package/package.json +1 -1
- package/src/commands/deploy.js +1 -1
- package/src/commands/doctor.js +76 -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 +49 -38
- package/src/utils/shell-quote.js +64 -0
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.
|
|
624
|
-
3.
|
|
625
|
-
4.
|
|
626
|
-
5. `
|
|
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.
|
|
683
|
-
3.
|
|
684
|
-
4.
|
|
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.
|
|
712
|
-
3.
|
|
713
|
-
4.
|
|
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.
|
|
741
|
-
4.
|
|
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
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,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);
|
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,
|
|
@@ -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
|
-
|
|
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 ${
|
|
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 ${
|
|
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 ${
|
|
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 ${
|
|
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 ${
|
|
164
|
+
await exec(ssh, `cd ${dir} && pip install -r requirements.txt`);
|
|
154
165
|
if (framework === 'django') {
|
|
155
|
-
await exec(ssh, `cd ${
|
|
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 ${
|
|
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 ${
|
|
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 ${
|
|
187
|
+
await exec(ssh, `cd ${dir} && composer install --no-dev`);
|
|
177
188
|
if (framework === 'laravel') {
|
|
178
|
-
await exec(ssh, `cd ${
|
|
179
|
-
await exec(ssh, `cd ${
|
|
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 ${
|
|
198
|
+
await exec(ssh, `cd ${dir} && pkill -f "*.jar" || true`);
|
|
188
199
|
await exec(
|
|
189
200
|
ssh,
|
|
190
|
-
`cd ${
|
|
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 ${
|
|
207
|
+
await exec(ssh, `cd ${dir} && pkill ${sh(appName)} || true`);
|
|
197
208
|
await exec(
|
|
198
209
|
ssh,
|
|
199
|
-
`cd ${
|
|
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 ${
|
|
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 ${
|
|
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 ${
|
|
216
|
-
await exec(ssh, `cd ${
|
|
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 ${
|
|
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 ${
|
|
235
|
+
await exec(ssh, `cd ${dir} && npm install --production`);
|
|
225
236
|
await exec(
|
|
226
237
|
ssh,
|
|
227
|
-
`cd ${
|
|
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}
|
|
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}"
|
|
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
|
+
}
|