@akash-chowdhury-24/deployhub 1.0.16 → 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.
Files changed (32) hide show
  1. package/README.md +264 -59
  2. package/package.json +4 -3
  3. package/src/commands/doctor.js +168 -170
  4. package/src/commands/init.js +80 -371
  5. package/src/commands/rollback.js +1 -1
  6. package/src/core/config.js +17 -13
  7. package/src/core/stages.js +0 -11
  8. package/src/deployment/deployment-env.js +625 -0
  9. package/src/deployment/index.js +0 -72
  10. package/src/deployment/init-helpers.js +389 -0
  11. package/src/deployment/init-prompts.js +432 -0
  12. package/src/deployment/providers/azure-vm.js +85 -2
  13. package/src/deployment/providers/docker.js +101 -8
  14. package/src/deployment/providers/ec2.js +88 -2
  15. package/src/deployment/providers/gcp-vm.js +89 -2
  16. package/src/deployment/providers/kubernetes.js +104 -7
  17. package/src/deployment/providers/ssh.js +21 -4
  18. package/src/utils/github-actions.js +28 -131
  19. package/src/utils/rollback/engine.js +69 -0
  20. package/src/deployment/providers/platforms/_shared.js +0 -167
  21. package/src/deployment/providers/platforms/aws-amplify.js +0 -367
  22. package/src/deployment/providers/platforms/azure-static-web-apps.js +0 -68
  23. package/src/deployment/providers/platforms/cloudflare-pages.js +0 -103
  24. package/src/deployment/providers/platforms/firebase-app-hosting.js +0 -95
  25. package/src/deployment/providers/platforms/firebase-hosting.js +0 -99
  26. package/src/deployment/providers/platforms/index.js +0 -44
  27. package/src/deployment/providers/platforms/netlify.js +0 -102
  28. package/src/deployment/providers/platforms/vercel.js +0 -92
  29. package/src/rollback/engine.js +0 -102
  30. package/src/utils/firebase-config-generator.js +0 -35
  31. package/src/utils/init-platform.js +0 -236
  32. package/src/utils/platform-env.js +0 -132
@@ -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,12 +1,11 @@
1
1
  import fs from 'fs-extra';
2
2
  import path from 'path';
3
- import {
4
- PLATFORM_ENV_MAP,
5
- PLATFORM_CLI_MAP,
6
- PLATFORM_CHOICES,
7
- getPlatformEnvExample,
8
- } from './platform-env.js';
9
3
  import { getWorkflowHeaderComment } from './author.js';
4
+ import {
5
+ generateDeploymentEnvSection,
6
+ getDeploymentSecretKeys,
7
+ DEPLOYMENT_ENV_KEYS,
8
+ } from '../deployment/deployment-env.js';
10
9
 
11
10
  /** @typedef {'aws'|'azure'|'gcp'|'gdrive'|'dropbox'|'local'|'ftp'|'ssh'} ProviderEnvKey */
12
11
 
@@ -28,34 +27,35 @@ const PROVIDER_ENV_MAP = {
28
27
  dropbox: ['DROPBOX_ACCESS_TOKEN'],
29
28
  local: [],
30
29
  ftp: ['FTP_HOST', 'FTP_USER', 'FTP_PASSWORD'],
31
- 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,
32
36
  };
33
37
 
34
- const BACKEND_SSH_ENV_VARS = [
35
- 'SSH_DEPLOY_PATH',
36
- 'SSH_APP_NAME',
37
- 'SSH_PORT',
38
- ];
39
-
40
38
  const PROVIDER_LABELS = {
41
39
  aws: 'AWS S3',
42
40
  azure: 'Azure Blob',
43
- gcp: 'GCP',
41
+ gcp: 'GCP Storage',
44
42
  gdrive: 'Google Drive',
45
43
  dropbox: 'Dropbox',
46
44
  ftp: 'FTP',
47
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',
48
51
  };
49
52
 
50
- const PLATFORM_LABELS = Object.fromEntries(
51
- PLATFORM_CHOICES.map(({ name, value }) => [value, name])
52
- );
53
-
54
53
  const ENV_VAR_DEFAULTS = {
55
54
  AWS_REGION: 'us-east-1',
56
55
  FTP_PORT: '21',
57
56
  FTP_PATH: '/uploads',
58
57
  SSH_DEPLOY_PATH: '/var/www/app',
58
+ SSH_SSH_PORT: '22',
59
59
  SMTP_PORT: '587',
60
60
  };
61
61
 
@@ -208,54 +208,6 @@ function getBackendSetupSteps(config) {
208
208
  return steps;
209
209
  }
210
210
 
211
- /**
212
- * @param {Record<string, unknown>[]} environments
213
- * @returns {string[]}
214
- */
215
- function getPlatformInstallSteps(environments) {
216
- /** @type {Set<string>} */
217
- const steps = new Set();
218
-
219
- for (const env of environments) {
220
- if (env.deploymentType !== 'platform' && env.frontendDeploymentType !== 'platform') {
221
- continue;
222
- }
223
- const platform = env.platform;
224
- if (!platform) continue;
225
- const cli = PLATFORM_CLI_MAP[platform];
226
- if (cli?.globalInstall) {
227
- steps.add(` - name: Install ${platform} CLI\n run: ${cli.globalInstall}`);
228
- }
229
- }
230
-
231
- return Array.from(steps);
232
- }
233
-
234
- /**
235
- * @param {string[]} deployEnvironments
236
- * @param {Record<string, Record<string, unknown>>} environments
237
- */
238
- function collectPlatformEnvVars(deployEnvironments, environments) {
239
- /** @type {Set<string>} */
240
- const envVars = new Set();
241
-
242
- for (const envName of deployEnvironments) {
243
- const env = environments[envName];
244
- if (!env) continue;
245
-
246
- if (env.deploymentType === 'platform' || env.frontendDeploymentType === 'platform') {
247
- const platform = env.platform;
248
- if (!platform) continue;
249
- const keys = PLATFORM_ENV_MAP[platform] || [];
250
- for (const key of keys) {
251
- envVars.add(`${key}: \${{ secrets.${key} }}`);
252
- }
253
- }
254
- }
255
-
256
- return envVars;
257
- }
258
-
259
211
  /**
260
212
  * @param {string[]} storageProviders
261
213
  * @param {string[]} deployEnvironments
@@ -285,27 +237,12 @@ export function generateWorkflowYaml(
285
237
  const env = environments[envName];
286
238
  if (!env) continue;
287
239
 
288
- if (env.deploymentType !== 'platform') {
289
- const keys = PROVIDER_ENV_MAP[env.type] || [];
290
- for (const key of keys) {
291
- envVars.add(`${key}: \${{ secrets.${key} }}`);
292
- }
293
- }
294
-
295
- if (env.type === 'ssh' && config) {
296
- const projectType = config.projectType || 'frontend';
297
- if (projectType === 'backend' || projectType === 'both') {
298
- for (const key of BACKEND_SSH_ENV_VARS) {
299
- envVars.add(`${key}: \${{ secrets.${key} }}`);
300
- }
301
- }
240
+ const keys = getDeploymentSecretKeys(env.type, config);
241
+ for (const key of keys) {
242
+ envVars.add(`${key}: \${{ secrets.${key} }}`);
302
243
  }
303
244
  }
304
245
 
305
- for (const line of collectPlatformEnvVars(deployEnvironments, environments)) {
306
- envVars.add(line);
307
- }
308
-
309
246
  const envBlock = Array.from(envVars)
310
247
  .map((line) => ` ${line}`)
311
248
  .join('\n');
@@ -317,11 +254,6 @@ export function generateWorkflowYaml(
317
254
  ? `${getGithubGitConfigStep()}\n`
318
255
  : '';
319
256
 
320
- const envList = deployEnvironments
321
- .map((name) => environments[name])
322
- .filter(Boolean);
323
- const platformSteps = getPlatformInstallSteps(envList).join('\n');
324
-
325
257
  const projectType = config?.projectType || 'frontend';
326
258
  const installDepsCommand =
327
259
  projectType === 'backend' || projectType === 'both'
@@ -342,7 +274,7 @@ ${uniqueBackendSteps ? `${uniqueBackendSteps}\n` : ''} - uses: actions/setu
342
274
  node-version: '20'
343
275
  ${githubGitConfigStep} - name: Install project dependencies
344
276
  run: ${installDepsCommand}
345
- ${platformSteps ? `${platformSteps}\n` : ''} - name: Install DeployHub CLI
277
+ - name: Install DeployHub CLI
346
278
  run: npm install ${installSpec} --no-save
347
279
  - run: ${getCliBuildCommand()}
348
280
  env:
@@ -481,23 +413,8 @@ export function getRequiredSecrets(
481
413
  const env = environments[envName];
482
414
  if (!env) continue;
483
415
 
484
- if (env.deploymentType === 'platform' || env.frontendDeploymentType === 'platform') {
485
- const platform = env.platform;
486
- if (platform) {
487
- const keys = PLATFORM_ENV_MAP[platform] || [];
488
- keys.forEach((k) => secrets.add(k));
489
- }
490
- } else {
491
- const keys = PROVIDER_ENV_MAP[env.type] || [];
492
- keys.forEach((k) => secrets.add(k));
493
- }
494
-
495
- if (env.type === 'ssh' && config) {
496
- const projectType = config.projectType || 'frontend';
497
- if (projectType === 'backend' || projectType === 'both') {
498
- BACKEND_SSH_ENV_VARS.forEach((k) => secrets.add(k));
499
- }
500
- }
416
+ const keys = getDeploymentSecretKeys(env.type, config);
417
+ keys.forEach((k) => secrets.add(k));
501
418
  }
502
419
 
503
420
  return Array.from(secrets);
@@ -558,31 +475,11 @@ export function generateEnvExampleContent(
558
475
 
559
476
  for (const envName of deployEnvironments) {
560
477
  const env = environments[envName];
561
- if (!env) continue;
562
-
563
- if (env.deploymentType === 'platform' || env.frontendDeploymentType === 'platform') {
564
- const platform = env.platform;
565
- if (platform) {
566
- const examples = getPlatformEnvExample(platform);
567
- addSection(
568
- PLATFORM_LABELS[platform] || platform,
569
- Object.keys(examples),
570
- examples
571
- );
572
- }
573
- continue;
574
- }
575
-
576
- const keys = PROVIDER_ENV_MAP[env.type] || [];
577
- if (keys.length > 0) {
578
- addSection(PROVIDER_LABELS[env.type] || env.type, keys);
579
- }
478
+ if (!env?.type) continue;
580
479
 
581
- if (env.type === 'ssh' && config) {
582
- const projectType = config.projectType || 'frontend';
583
- if (projectType === 'backend' || projectType === 'both') {
584
- addSection('SSH Deployment (backend)', BACKEND_SSH_ENV_VARS);
585
- }
480
+ const deploySection = generateDeploymentEnvSection(env.type, config, environments);
481
+ if (deploySection) {
482
+ sections.push(`${deploySection}\n`);
586
483
  }
587
484
  }
588
485
 
@@ -0,0 +1,69 @@
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
+ import fs from 'fs-extra';
6
+ import path from 'path';
7
+
8
+ /**
9
+ * @param {import('../../core/config.js').DeployHubConfig} config
10
+ * @param {string} artifactDir
11
+ * @param {string} envName
12
+ */
13
+ async function rollbackTarget(config, artifactDir, envName) {
14
+ const envConfig = config.environments[envName];
15
+ if (!envConfig) {
16
+ throw new Error(`Environment "${envName}" not found in config`);
17
+ }
18
+
19
+ const log = createLogger('rollback');
20
+ log.info(`Rolling back ${envName} (${envConfig.type || 'server'})...`);
21
+
22
+ const provider = getDeploymentProvider(envConfig.type, config, envName);
23
+ await provider.rollback(artifactDir);
24
+ }
25
+
26
+ /**
27
+ * @param {import('../../core/config.js').DeployHubConfig} config
28
+ * @param {string} version
29
+ * @param {string} [cwd]
30
+ */
31
+ export async function rollbackToVersion(config, version, cwd = process.cwd()) {
32
+ const log = createLogger('rollback');
33
+ const remoteKey = `${config.project}/v${version}/artifact.zip`;
34
+ const restoreDir = path.join(cwd, '.deployhub-restore', `v${version}`);
35
+ const artifactDir = path.join(restoreDir, 'artifact');
36
+
37
+ log.info(`Downloading artifact v${version}...`);
38
+ await fs.emptyDir(restoreDir);
39
+ await fs.ensureDir(artifactDir);
40
+
41
+ const zipPath = path.join(artifactDir, 'artifact.zip');
42
+ await downloadFromFirst(config.storage, remoteKey, zipPath);
43
+
44
+ log.info('Extracting artifact for rollback...');
45
+ const extractedDir = path.join(artifactDir, '_extracted');
46
+ await fs.emptyDir(extractedDir);
47
+ await extractArtifact(artifactDir, extractedDir);
48
+
49
+ const extractedDeployment = path.join(extractedDir, 'deployment.json');
50
+ if (await fs.pathExists(extractedDeployment)) {
51
+ await fs.copy(extractedDeployment, path.join(artifactDir, 'deployment.json'));
52
+ }
53
+
54
+ const targets = config.deploy || [];
55
+ if (targets.length === 0) {
56
+ log.warn('No deployment targets configured');
57
+ return artifactDir;
58
+ }
59
+
60
+ log.info('Redeploying previous artifact to server targets...');
61
+ for (const envName of targets) {
62
+ await rollbackTarget(config, artifactDir, envName);
63
+ }
64
+
65
+ log.success(`Rollback to v${version} complete`);
66
+ return artifactDir;
67
+ }
68
+
69
+ export default { rollbackToVersion };