@akash-chowdhury-24/deployhub 2.0.0 → 2.0.2

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.
@@ -1,7 +1,93 @@
1
+ import { execa } from 'execa';
1
2
  import { createSshProvider } from './ssh.js';
3
+ import { createLogger } from '../../logger/index.js';
2
4
 
3
- export function createEc2Provider(config, envName, env) {
4
- return createSshProvider(config, envName, env);
5
+ /**
6
+ * @param {import('../../core/config.js').DeployHubConfig} config
7
+ * @param {string} envName
8
+ * @param {Record<string, string>} [env]
9
+ */
10
+ export function createEc2Provider(config, envName, env = process.env) {
11
+ const log = createLogger('ec2');
12
+ const instanceId = env.EC2_INSTANCE_ID;
13
+ const region = env.AWS_REGION || 'us-east-1';
14
+
15
+ async function resolveHost() {
16
+ const environment = config.environments[envName];
17
+ if (environment?.host || env.SSH_HOST) {
18
+ return environment?.host || env.SSH_HOST;
19
+ }
20
+
21
+ if (!instanceId) {
22
+ throw new Error(
23
+ 'EC2 host unknown. Set SSH_HOST to your instance public IP, or set EC2_INSTANCE_ID with AWS credentials for auto lookup.'
24
+ );
25
+ }
26
+
27
+ log.info(`Looking up public IP for instance ${instanceId}...`);
28
+
29
+ /** @type {Record<string, string>} */
30
+ const awsEnv = { ...process.env };
31
+ if (env.AWS_ACCESS_KEY_ID) awsEnv.AWS_ACCESS_KEY_ID = env.AWS_ACCESS_KEY_ID;
32
+ if (env.AWS_SECRET_ACCESS_KEY) awsEnv.AWS_SECRET_ACCESS_KEY = env.AWS_SECRET_ACCESS_KEY;
33
+ awsEnv.AWS_DEFAULT_REGION = region;
34
+
35
+ try {
36
+ const { stdout } = await execa(
37
+ 'aws',
38
+ [
39
+ 'ec2',
40
+ 'describe-instances',
41
+ '--instance-ids',
42
+ instanceId,
43
+ '--query',
44
+ 'Reservations[0].Instances[0].PublicIpAddress',
45
+ '--output',
46
+ 'text',
47
+ '--region',
48
+ region,
49
+ ],
50
+ { stdio: 'pipe', env: awsEnv }
51
+ );
52
+ const publicIp = stdout.trim();
53
+ if (!publicIp || publicIp === 'None') {
54
+ throw new Error('No public IP returned');
55
+ }
56
+ log.info(`Resolved EC2 host: ${publicIp}`);
57
+ return publicIp;
58
+ } catch (err) {
59
+ const msg = err instanceof Error ? err.message : String(err);
60
+ throw new Error(
61
+ `Could not resolve public IP for ${instanceId} — ${msg}. Set SSH_HOST manually or install/configure AWS CLI with ec2:DescribeInstances access.`
62
+ );
63
+ }
64
+ }
65
+
66
+ const sshProvider = createSshProvider(config, envName, env);
67
+
68
+ async function connect() {
69
+ const host = await resolveHost();
70
+ const environment = config.environments[envName];
71
+ if (environment && !environment.host) {
72
+ environment.host = host;
73
+ }
74
+ if (!env.SSH_HOST) {
75
+ env.SSH_HOST = host;
76
+ }
77
+ return sshProvider.connect();
78
+ }
79
+
80
+ return {
81
+ ...sshProvider,
82
+ connect,
83
+ deploy: sshProvider.deploy.bind(sshProvider),
84
+ rollback: sshProvider.rollback.bind(sshProvider),
85
+ healthCheck: sshProvider.healthCheck.bind(sshProvider),
86
+ testConnection: async () => {
87
+ const ssh = await connect();
88
+ ssh.dispose();
89
+ },
90
+ };
5
91
  }
6
92
 
7
93
  export default { createEc2Provider };
@@ -1,7 +1,94 @@
1
+ import { execa } from 'execa';
1
2
  import { createSshProvider } from './ssh.js';
3
+ import { createLogger } from '../../logger/index.js';
2
4
 
3
- export function createGcpVmProvider(config, envName, env) {
4
- return createSshProvider(config, envName, env);
5
+ /**
6
+ * @param {import('../../core/config.js').DeployHubConfig} config
7
+ * @param {string} envName
8
+ * @param {Record<string, string>} [env]
9
+ */
10
+ export function createGcpVmProvider(config, envName, env = process.env) {
11
+ const log = createLogger('gcp-vm');
12
+ const projectId = env.GCP_PROJECT_ID;
13
+ const zone = env.GCP_ZONE;
14
+ const instanceName = env.GCP_INSTANCE_NAME;
15
+
16
+ async function resolveHost() {
17
+ const environment = config.environments[envName];
18
+ if (environment?.host || env.SSH_HOST) {
19
+ return environment?.host || env.SSH_HOST;
20
+ }
21
+
22
+ if (!projectId || !zone || !instanceName) {
23
+ throw new Error(
24
+ 'GCP VM host unknown. Set SSH_HOST to your instance external IP, or set GCP_PROJECT_ID, GCP_ZONE, and GCP_INSTANCE_NAME for auto lookup.'
25
+ );
26
+ }
27
+
28
+ log.info(`Looking up external IP for GCP instance ${instanceName}...`);
29
+
30
+ /** @type {Record<string, string>} */
31
+ const gcpEnv = { ...process.env };
32
+ if (env.GCP_KEY_FILE) {
33
+ gcpEnv.GOOGLE_APPLICATION_CREDENTIALS = env.GCP_KEY_FILE;
34
+ }
35
+
36
+ try {
37
+ const { stdout } = await execa(
38
+ 'gcloud',
39
+ [
40
+ 'compute',
41
+ 'instances',
42
+ 'describe',
43
+ instanceName,
44
+ '--zone',
45
+ zone,
46
+ '--project',
47
+ projectId,
48
+ '--format',
49
+ 'get(networkInterfaces[0].accessConfigs[0].natIP)',
50
+ ],
51
+ { stdio: 'pipe', env: gcpEnv }
52
+ );
53
+ const publicIp = stdout.trim();
54
+ if (!publicIp) {
55
+ throw new Error('No external IP returned');
56
+ }
57
+ log.info(`Resolved GCP VM host: ${publicIp}`);
58
+ return publicIp;
59
+ } catch (err) {
60
+ const msg = err instanceof Error ? err.message : String(err);
61
+ throw new Error(
62
+ `Could not resolve external IP for ${instanceName} — ${msg}. Set SSH_HOST manually or run gcloud auth login and verify project/zone/instance name.`
63
+ );
64
+ }
65
+ }
66
+
67
+ const sshProvider = createSshProvider(config, envName, env);
68
+
69
+ async function connect() {
70
+ const host = await resolveHost();
71
+ const environment = config.environments[envName];
72
+ if (environment && !environment.host) {
73
+ environment.host = host;
74
+ }
75
+ if (!env.SSH_HOST) {
76
+ env.SSH_HOST = host;
77
+ }
78
+ return sshProvider.connect();
79
+ }
80
+
81
+ return {
82
+ ...sshProvider,
83
+ connect,
84
+ deploy: sshProvider.deploy.bind(sshProvider),
85
+ rollback: sshProvider.rollback.bind(sshProvider),
86
+ healthCheck: sshProvider.healthCheck.bind(sshProvider),
87
+ testConnection: async () => {
88
+ const ssh = await connect();
89
+ ssh.dispose();
90
+ },
91
+ };
5
92
  }
6
93
 
7
94
  export default { createGcpVmProvider };
@@ -1,27 +1,124 @@
1
1
  import { execa } from 'execa';
2
+ import fs from 'fs-extra';
3
+ import path from 'path';
4
+ import os from 'os';
2
5
  import { createLogger } from '../../logger/index.js';
3
6
 
4
- export function createKubernetesProvider(config, envName) {
7
+ /**
8
+ * @param {import('../../core/config.js').DeployHubConfig} config
9
+ * @param {string} envName
10
+ * @param {Record<string, string>} [env]
11
+ */
12
+ export function createKubernetesProvider(config, envName, env = process.env) {
5
13
  const log = createLogger('kubernetes');
6
14
 
15
+ const kubeconfig = env.KUBECONFIG || path.join(os.homedir(), '.kube', 'config');
16
+ const context = env.KUBE_CONTEXT || '';
17
+ const namespace = env.KUBE_NAMESPACE || config.project || 'default';
18
+
19
+ function getKubectlEnv() {
20
+ const expanded = kubeconfig.replace(/^~/, os.homedir());
21
+ return { ...process.env, KUBECONFIG: path.resolve(expanded) };
22
+ }
23
+
24
+ /**
25
+ * @param {string[]} baseArgs
26
+ */
27
+ function kubectlArgs(baseArgs) {
28
+ /** @type {string[]} */
29
+ const args = [...baseArgs];
30
+ if (context) args.push('--context', context);
31
+ if (namespace) args.push('--namespace', namespace);
32
+ return args;
33
+ }
34
+
35
+ /**
36
+ * @param {string} artifactDir
37
+ */
7
38
  async function deploy(artifactDir) {
8
- log.info('Deploying to Kubernetes...');
9
- await execa('kubectl', ['apply', '-f', '.'], {
10
- cwd: artifactDir,
39
+ log.info(`Deploying to Kubernetes (namespace: ${namespace}${context ? `, context: ${context}` : ''})...`);
40
+
41
+ const manifestDir = artifactDir;
42
+ const hasManifests =
43
+ (await fs.pathExists(path.join(manifestDir, 'k8s'))) ||
44
+ (await fs.readdir(manifestDir)).some((f) => /\.ya?ml$/.test(f));
45
+
46
+ if (!hasManifests) {
47
+ throw new Error(
48
+ 'No Kubernetes manifests found in artifact. Add .yaml files or a k8s/ directory to your project.'
49
+ );
50
+ }
51
+
52
+ const applyTarget = (await fs.pathExists(path.join(manifestDir, 'k8s')))
53
+ ? path.join(manifestDir, 'k8s')
54
+ : manifestDir;
55
+
56
+ await execa('kubectl', kubectlArgs(['apply', '-f', applyTarget]), {
11
57
  stdio: 'inherit',
58
+ env: getKubectlEnv(),
12
59
  });
60
+
61
+ const imageName = env.DOCKER_IMAGE_NAME;
62
+ const imageTag = env.DOCKER_IMAGE_TAG || config.version || 'latest';
63
+ if (imageName && config.project) {
64
+ await execa(
65
+ 'kubectl',
66
+ kubectlArgs([
67
+ 'set',
68
+ 'image',
69
+ `deployment/${config.project}`,
70
+ `${config.project}=${imageName}:${imageTag}`,
71
+ ]),
72
+ { stdio: 'pipe', env: getKubectlEnv() }
73
+ ).catch(() => {
74
+ log.warn('kubectl set image skipped (deployment name may differ from project name)');
75
+ });
76
+ }
77
+
78
+ log.success('Kubernetes deployment complete');
13
79
  }
14
80
 
15
81
  async function rollback(artifactDir) {
16
- await deploy(artifactDir);
82
+ log.info('Rolling back Kubernetes deployment...');
83
+ await execa('kubectl', kubectlArgs(['rollout', 'undo', `deployment/${config.project}`]), {
84
+ stdio: 'inherit',
85
+ env: getKubectlEnv(),
86
+ }).catch(async () => {
87
+ log.warn('kubectl rollout undo failed — redeploying previous artifact instead');
88
+ await deploy(artifactDir);
89
+ });
17
90
  }
18
91
 
19
92
  async function healthCheck() {
20
- return true;
93
+ const url = config.healthCheck?.url;
94
+ if (url) {
95
+ try {
96
+ const { stdout } = await execa('curl', ['-sf', '-o', '/dev/null', '-w', '%{http_code}', url], {
97
+ stdio: 'pipe',
98
+ });
99
+ return stdout.trim().startsWith('2');
100
+ } catch {
101
+ return false;
102
+ }
103
+ }
104
+
105
+ try {
106
+ await execa(
107
+ 'kubectl',
108
+ kubectlArgs(['rollout', 'status', `deployment/${config.project}`, '--timeout=30s']),
109
+ { stdio: 'pipe', env: getKubectlEnv() }
110
+ );
111
+ return true;
112
+ } catch {
113
+ return false;
114
+ }
21
115
  }
22
116
 
23
117
  async function testConnection() {
24
- await execa('kubectl', ['cluster-info'], { stdio: 'pipe' });
118
+ await execa('kubectl', kubectlArgs(['cluster-info']), {
119
+ stdio: 'pipe',
120
+ env: getKubectlEnv(),
121
+ });
25
122
  }
26
123
 
27
124
  return { deploy, rollback, healthCheck, testConnection };
@@ -39,27 +39,44 @@ export function createSshProvider(config, envName, env = process.env) {
39
39
  const port = environment.port || config.port || Number(env.SSH_PORT) || 3000;
40
40
  const sshKey = env.SSH_KEY;
41
41
  const keyPath = environment.keyPath || env.SSH_KEY_PATH;
42
+ const sshPort = Number(env.SSH_SSH_PORT) || environment.sshPort || 22;
42
43
 
43
44
  const log = createLogger('ssh');
44
45
 
45
46
  async function connect() {
46
47
  if (!host || !user) {
47
- throw new Error('SSH host and user are required. Set SSH_HOST and SSH_USER in .env');
48
+ throw new Error(
49
+ 'SSH host and user are required. Set SSH_HOST and SSH_USER in .env (see .env.example comments).'
50
+ );
51
+ }
52
+
53
+ if (!sshKey && !keyPath) {
54
+ throw new Error(
55
+ 'SSH authentication required. Set SSH_KEY_PATH (local) or SSH_KEY (CI secret) in .env — see .env.example.'
56
+ );
48
57
  }
49
58
 
50
59
  const ssh = new NodeSSH();
51
60
  /** @type {import('node-ssh').SSHConnectOptions} */
52
- const connectOpts = { host, username: user };
61
+ const connectOpts = { host, username: user, port: sshPort };
53
62
 
54
63
  if (sshKey) {
55
64
  const tmpKeyPath = path.join(os.tmpdir(), 'deployhub-ssh-key');
56
65
  await fs.writeFile(tmpKeyPath, sshKey, { mode: 0o600 });
57
66
  connectOpts.privateKeyPath = tmpKeyPath;
58
67
  } else if (keyPath) {
59
- connectOpts.privateKeyPath = keyPath;
68
+ const expanded = keyPath.replace(/^~/, os.homedir());
69
+ connectOpts.privateKeyPath = path.resolve(expanded);
60
70
  }
61
71
 
62
- await ssh.connect(connectOpts);
72
+ try {
73
+ await ssh.connect(connectOpts);
74
+ } catch (err) {
75
+ const msg = err instanceof Error ? err.message : String(err);
76
+ throw new Error(
77
+ `SSH connection failed to ${user}@${host}:${sshPort} — ${msg}. Check SSH_HOST, SSH_USER, SSH_KEY_PATH, and that port ${sshPort} is open in your firewall/security group.`
78
+ );
79
+ }
63
80
  return ssh;
64
81
  }
65
82
 
@@ -1,6 +1,11 @@
1
1
  import fs from 'fs-extra';
2
2
  import path from 'path';
3
3
  import { getWorkflowHeaderComment } from './author.js';
4
+ import {
5
+ generateDeploymentEnvSection,
6
+ getDeploymentSecretKeys,
7
+ DEPLOYMENT_ENV_KEYS,
8
+ } from '../deployment/deployment-env.js';
4
9
 
5
10
  /** @typedef {'aws'|'azure'|'gcp'|'gdrive'|'dropbox'|'local'|'ftp'|'ssh'} ProviderEnvKey */
6
11
 
@@ -22,23 +27,27 @@ const PROVIDER_ENV_MAP = {
22
27
  dropbox: ['DROPBOX_ACCESS_TOKEN'],
23
28
  local: [],
24
29
  ftp: ['FTP_HOST', 'FTP_USER', 'FTP_PASSWORD'],
25
- ssh: ['SSH_HOST', 'SSH_USER', 'SSH_KEY'],
30
+ ssh: DEPLOYMENT_ENV_KEYS.ssh,
31
+ docker: DEPLOYMENT_ENV_KEYS.docker,
32
+ ec2: DEPLOYMENT_ENV_KEYS.ec2,
33
+ 'azure-vm': DEPLOYMENT_ENV_KEYS['azure-vm'],
34
+ 'gcp-vm': DEPLOYMENT_ENV_KEYS['gcp-vm'],
35
+ kubernetes: DEPLOYMENT_ENV_KEYS.kubernetes,
26
36
  };
27
37
 
28
- const BACKEND_SSH_ENV_VARS = [
29
- 'SSH_DEPLOY_PATH',
30
- 'SSH_APP_NAME',
31
- 'SSH_PORT',
32
- ];
33
-
34
38
  const PROVIDER_LABELS = {
35
39
  aws: 'AWS S3',
36
40
  azure: 'Azure Blob',
37
- gcp: 'GCP',
41
+ gcp: 'GCP Storage',
38
42
  gdrive: 'Google Drive',
39
43
  dropbox: 'Dropbox',
40
44
  ftp: 'FTP',
41
45
  ssh: 'SSH Deployment',
46
+ docker: 'Docker Deployment',
47
+ ec2: 'AWS EC2 Deployment',
48
+ 'azure-vm': 'Azure VM Deployment',
49
+ 'gcp-vm': 'GCP VM Deployment',
50
+ kubernetes: 'Kubernetes Deployment',
42
51
  };
43
52
 
44
53
  const ENV_VAR_DEFAULTS = {
@@ -46,6 +55,7 @@ const ENV_VAR_DEFAULTS = {
46
55
  FTP_PORT: '21',
47
56
  FTP_PATH: '/uploads',
48
57
  SSH_DEPLOY_PATH: '/var/www/app',
58
+ SSH_SSH_PORT: '22',
49
59
  SMTP_PORT: '587',
50
60
  };
51
61
 
@@ -227,19 +237,10 @@ export function generateWorkflowYaml(
227
237
  const env = environments[envName];
228
238
  if (!env) continue;
229
239
 
230
- const keys = PROVIDER_ENV_MAP[env.type] || [];
240
+ const keys = getDeploymentSecretKeys(env.type, config);
231
241
  for (const key of keys) {
232
242
  envVars.add(`${key}: \${{ secrets.${key} }}`);
233
243
  }
234
-
235
- if (env.type === 'ssh' && config) {
236
- const projectType = config.projectType || 'frontend';
237
- if (projectType === 'backend' || projectType === 'both') {
238
- for (const key of BACKEND_SSH_ENV_VARS) {
239
- envVars.add(`${key}: \${{ secrets.${key} }}`);
240
- }
241
- }
242
- }
243
244
  }
244
245
 
245
246
  const envBlock = Array.from(envVars)
@@ -412,15 +413,8 @@ export function getRequiredSecrets(
412
413
  const env = environments[envName];
413
414
  if (!env) continue;
414
415
 
415
- const keys = PROVIDER_ENV_MAP[env.type] || [];
416
+ const keys = getDeploymentSecretKeys(env.type, config);
416
417
  keys.forEach((k) => secrets.add(k));
417
-
418
- if (env.type === 'ssh' && config) {
419
- const projectType = config.projectType || 'frontend';
420
- if (projectType === 'backend' || projectType === 'both') {
421
- BACKEND_SSH_ENV_VARS.forEach((k) => secrets.add(k));
422
- }
423
- }
424
418
  }
425
419
 
426
420
  return Array.from(secrets);
@@ -481,18 +475,11 @@ export function generateEnvExampleContent(
481
475
 
482
476
  for (const envName of deployEnvironments) {
483
477
  const env = environments[envName];
484
- if (!env) continue;
485
-
486
- const keys = PROVIDER_ENV_MAP[env.type] || [];
487
- if (keys.length > 0) {
488
- addSection(PROVIDER_LABELS[env.type] || env.type, keys);
489
- }
478
+ if (!env?.type) continue;
490
479
 
491
- if (env.type === 'ssh' && config) {
492
- const projectType = config.projectType || 'frontend';
493
- if (projectType === 'backend' || projectType === 'both') {
494
- addSection('SSH Deployment (backend)', BACKEND_SSH_ENV_VARS);
495
- }
480
+ const deploySection = generateDeploymentEnvSection(env.type, config, environments);
481
+ if (deploySection) {
482
+ sections.push(`${deploySection}\n`);
496
483
  }
497
484
  }
498
485
 
@@ -1,12 +1,12 @@
1
- import { downloadFromFirst } from '../storage/index.js';
2
- import { getDeploymentProvider } from '../deployment/index.js';
3
- import { extractArtifact } from '../artifact/engine.js';
4
- import { createLogger } from '../logger/index.js';
1
+ import { downloadFromFirst } from '../../storage/index.js';
2
+ import { getDeploymentProvider } from '../../deployment/index.js';
3
+ import { extractArtifact } from '../../artifact/engine.js';
4
+ import { createLogger } from '../../logger/index.js';
5
5
  import fs from 'fs-extra';
6
6
  import path from 'path';
7
7
 
8
8
  /**
9
- * @param {import('../core/config.js').DeployHubConfig} config
9
+ * @param {import('../../core/config.js').DeployHubConfig} config
10
10
  * @param {string} artifactDir
11
11
  * @param {string} envName
12
12
  */
@@ -24,7 +24,7 @@ async function rollbackTarget(config, artifactDir, envName) {
24
24
  }
25
25
 
26
26
  /**
27
- * @param {import('../core/config.js').DeployHubConfig} config
27
+ * @param {import('../../core/config.js').DeployHubConfig} config
28
28
  * @param {string} version
29
29
  * @param {string} [cwd]
30
30
  */