@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
@@ -4,7 +4,6 @@ import { createEc2Provider } from './providers/ec2.js';
4
4
  import { createAzureVmProvider } from './providers/azure-vm.js';
5
5
  import { createGcpVmProvider } from './providers/gcp-vm.js';
6
6
  import { createKubernetesProvider } from './providers/kubernetes.js';
7
- import { createPlatformProvider } from './providers/platforms/index.js';
8
7
  import { createLogger } from '../logger/index.js';
9
8
 
10
9
  /** @type {Record<string, Function>} */
@@ -17,25 +16,6 @@ const PROVIDER_FACTORIES = {
17
16
  kubernetes: createKubernetesProvider,
18
17
  };
19
18
 
20
- /**
21
- * @param {Record<string, unknown>} environment
22
- * @returns {boolean}
23
- */
24
- function isPlatformDeployment(environment) {
25
- return environment.deploymentType === 'platform';
26
- }
27
-
28
- /**
29
- * @param {Record<string, unknown>} environment
30
- * @returns {boolean}
31
- */
32
- function isHybridEnvironment(environment) {
33
- return (
34
- environment.frontendDeploymentType === 'platform' &&
35
- (environment.backendDeploymentType === 'server' || !!environment.type)
36
- );
37
- }
38
-
39
19
  /**
40
20
  * @param {string} type
41
21
  * @param {import('../core/config.js').DeployHubConfig} config
@@ -48,10 +28,6 @@ export function getDeploymentProvider(type, config, envName, env = process.env)
48
28
  throw new Error(`Environment "${envName}" not found in config`);
49
29
  }
50
30
 
51
- if (isPlatformDeployment(environment)) {
52
- return createPlatformProvider(environment.platform, config, envName, env);
53
- }
54
-
55
31
  const providerType = type || environment.type;
56
32
  const factory = PROVIDER_FACTORIES[providerType];
57
33
  if (!factory) {
@@ -81,34 +57,6 @@ export async function deployToAll(config, artifactDir, envNames) {
81
57
  throw new Error(`Environment "${envName}" not found in config`);
82
58
  }
83
59
 
84
- if (isHybridEnvironment(envConfig)) {
85
- log.info(`Deploying hybrid target ${envName} (platform frontend + server backend)...`);
86
-
87
- const platformProvider = createPlatformProvider(
88
- envConfig.platform,
89
- config,
90
- envName
91
- );
92
- await platformProvider.deploy(artifactDir);
93
- log.success(`Frontend deployed to ${envConfig.platform}`);
94
-
95
- const serverProvider = getDeploymentProvider(envConfig.type, config, envName);
96
- await serverProvider.deploy(artifactDir);
97
- log.success(`Backend deployed via ${envConfig.type}`);
98
-
99
- deployed.push(envName);
100
- continue;
101
- }
102
-
103
- if (isPlatformDeployment(envConfig)) {
104
- const provider = createPlatformProvider(envConfig.platform, config, envName);
105
- log.info(`Deploying to ${envName} (${envConfig.platform})...`);
106
- await provider.deploy(artifactDir);
107
- deployed.push(envName);
108
- log.success(`Deployed to ${envName}`);
109
- continue;
110
- }
111
-
112
60
  const provider = getDeploymentProvider(envConfig.type, config, envName);
113
61
  log.info(`Deploying to ${envName} (${envConfig.type})...`);
114
62
  await provider.deploy(artifactDir);
@@ -128,26 +76,6 @@ export async function rollbackAll(config, artifactDir, envNames) {
128
76
  const targets = envNames || config.deploy || [];
129
77
  for (const envName of targets) {
130
78
  const envConfig = config.environments[envName];
131
-
132
- if (isHybridEnvironment(envConfig)) {
133
- const platformProvider = createPlatformProvider(
134
- envConfig.platform,
135
- config,
136
- envName
137
- );
138
- await platformProvider.rollback(artifactDir);
139
-
140
- const serverProvider = getDeploymentProvider(envConfig.type, config, envName);
141
- await serverProvider.rollback(artifactDir);
142
- continue;
143
- }
144
-
145
- if (isPlatformDeployment(envConfig)) {
146
- const provider = createPlatformProvider(envConfig.platform, config, envName);
147
- await provider.rollback(artifactDir);
148
- continue;
149
- }
150
-
151
79
  const provider = getDeploymentProvider(envConfig.type, config, envName);
152
80
  await provider.rollback(artifactDir);
153
81
  }
@@ -0,0 +1,389 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import net from 'net';
5
+ import { execa } from 'execa';
6
+ import chalk from 'chalk';
7
+ import inquirer from 'inquirer';
8
+ import { NodeSSH } from 'node-ssh';
9
+
10
+ /** @type {Record<string, string>} */
11
+ export const OS_USER_DEFAULTS = {
12
+ ubuntu: 'ubuntu',
13
+ 'amazon linux': 'ec2-user',
14
+ debian: 'admin',
15
+ centos: 'centos',
16
+ fedora: 'fedora',
17
+ rhel: 'ec2-user',
18
+ windows: 'Administrator',
19
+ };
20
+
21
+ /**
22
+ * @param {string} osHint
23
+ * @returns {string|undefined}
24
+ */
25
+ export function suggestSshUser(osHint) {
26
+ if (!osHint) return undefined;
27
+ const lower = osHint.toLowerCase();
28
+ for (const [key, user] of Object.entries(OS_USER_DEFAULTS)) {
29
+ if (lower.includes(key)) return user;
30
+ }
31
+ return undefined;
32
+ }
33
+
34
+ /**
35
+ * @param {string} keyPath
36
+ * @returns {Promise<{ ok: boolean, message: string, fixed?: boolean }>}
37
+ */
38
+ export async function validateSshKeyPath(keyPath) {
39
+ const expanded = keyPath.replace(/^~/, os.homedir());
40
+ const resolved = path.resolve(expanded);
41
+
42
+ if (!(await fs.pathExists(resolved))) {
43
+ return {
44
+ ok: false,
45
+ message: `SSH key file not found: ${resolved}`,
46
+ };
47
+ }
48
+
49
+ const stat = await fs.stat(resolved);
50
+ if (!stat.isFile()) {
51
+ return { ok: false, message: `SSH_KEY_PATH is not a file: ${resolved}` };
52
+ }
53
+
54
+ const mode = stat.mode & 0o777;
55
+ if (mode !== 0o400 && mode !== 0o600) {
56
+ const { fix } = await inquirer.prompt([
57
+ {
58
+ type: 'confirm',
59
+ name: 'fix',
60
+ message: `SSH key permissions are ${mode.toString(8)} (should be 400 or 600). Fix now?`,
61
+ default: true,
62
+ },
63
+ ]);
64
+ if (fix) {
65
+ await fs.chmod(resolved, 0o600);
66
+ return { ok: true, message: 'Permissions fixed to 600', fixed: true };
67
+ }
68
+ return {
69
+ ok: false,
70
+ message: `SSH key permissions too open (${mode.toString(8)}). Run: chmod 600 ${resolved}`,
71
+ };
72
+ }
73
+
74
+ return { ok: true, message: 'SSH key file OK' };
75
+ }
76
+
77
+ /**
78
+ * @param {string} pem
79
+ * @returns {boolean}
80
+ */
81
+ function isValidPemPrivateKey(pem) {
82
+ return pem.includes('BEGIN') && pem.includes('PRIVATE KEY');
83
+ }
84
+
85
+ /**
86
+ * Non-interactive SSH key check for deployhub doctor.
87
+ * @param {string} [keyPath]
88
+ * @param {string} [sshKey]
89
+ * @returns {Promise<{ ok: boolean, message: string }>}
90
+ */
91
+ export async function validateSshKeyForDoctor(keyPath, sshKey) {
92
+ if (!keyPath && !sshKey) {
93
+ return {
94
+ ok: false,
95
+ message: 'SSH_KEY_PATH (local) or SSH_KEY (CI) is required — see .env.example.',
96
+ };
97
+ }
98
+
99
+ if (sshKey) {
100
+ if (!isValidPemPrivateKey(sshKey)) {
101
+ return {
102
+ ok: false,
103
+ message: 'SSH_KEY is not a valid PEM private key — must include BEGIN/END PRIVATE KEY lines.',
104
+ };
105
+ }
106
+ return { ok: true, message: 'SSH_KEY PEM format looks valid' };
107
+ }
108
+
109
+ const expanded = keyPath.replace(/^~/, os.homedir());
110
+ const resolved = path.resolve(expanded);
111
+
112
+ if (!(await fs.pathExists(resolved))) {
113
+ return {
114
+ ok: false,
115
+ message: `SSH key file not found at ${keyPath} — check SSH_KEY_PATH points to your private .pem/.key file.`,
116
+ };
117
+ }
118
+
119
+ const stat = await fs.stat(resolved);
120
+ if (!stat.isFile()) {
121
+ return { ok: false, message: `SSH_KEY_PATH is not a file: ${resolved}` };
122
+ }
123
+
124
+ const content = await fs.readFile(resolved, 'utf-8');
125
+ if (!isValidPemPrivateKey(content)) {
126
+ return {
127
+ ok: false,
128
+ message: `SSH key file at ${keyPath} is not a valid PEM private key — must include BEGIN/END PRIVATE KEY lines.`,
129
+ };
130
+ }
131
+
132
+ const mode = stat.mode & 0o777;
133
+ if (mode !== 0o400 && mode !== 0o600) {
134
+ return {
135
+ ok: false,
136
+ message: `SSH key permissions are ${mode.toString(8)} (should be 400 or 600) — run: chmod 600 ${resolved}`,
137
+ };
138
+ }
139
+
140
+ return { ok: true, message: `SSH key file valid (${resolved})` };
141
+ }
142
+
143
+ /**
144
+ * TCP reachability check — independent of SSH key validity.
145
+ * @param {string} host
146
+ * @param {number} [port]
147
+ * @param {number} [timeoutMs]
148
+ * @returns {Promise<{ ok: boolean, message: string }>}
149
+ */
150
+ export function testSshHostReachability(host, port = 22, timeoutMs = 10000) {
151
+ return new Promise((resolve) => {
152
+ const socket = new net.Socket();
153
+ let settled = false;
154
+
155
+ const finish = (result) => {
156
+ if (settled) return;
157
+ settled = true;
158
+ socket.destroy();
159
+ resolve(result);
160
+ };
161
+
162
+ socket.setTimeout(timeoutMs);
163
+ socket.once('connect', () => {
164
+ finish({
165
+ ok: true,
166
+ message: `TCP connection to ${host}:${port} succeeded`,
167
+ });
168
+ });
169
+ socket.once('timeout', () => {
170
+ finish({
171
+ ok: false,
172
+ message: `Cannot reach ${host}:${port} — connection timed out. Check SSH_HOST is correct and port ${port} is open in your security group/firewall.`,
173
+ });
174
+ });
175
+ socket.once('error', (err) => {
176
+ const msg = err instanceof Error ? err.message : String(err);
177
+ finish({
178
+ ok: false,
179
+ message: `Cannot reach ${host}:${port} — ${msg}. Check SSH_HOST is correct and port ${port} is open in your security group/firewall.`,
180
+ });
181
+ });
182
+ socket.connect(port, host);
183
+ });
184
+ }
185
+
186
+ /**
187
+ * @param {{ host: string, user: string, keyPath?: string, sshKey?: string, sshPort?: number }} opts
188
+ * @returns {Promise<{ ok: boolean, message: string }>}
189
+ */
190
+ export async function testSshConnectivity(opts) {
191
+ const { host, user, keyPath, sshKey, sshPort = 22 } = opts;
192
+ if (!host || !user) {
193
+ return { ok: false, message: 'Host and user are required for SSH test' };
194
+ }
195
+
196
+ const ssh = new NodeSSH();
197
+ /** @type {string|undefined} */
198
+ let tmpKeyPath;
199
+
200
+ try {
201
+ /** @type {import('node-ssh').SSHConnectOptions} */
202
+ const connectOpts = {
203
+ host,
204
+ username: user,
205
+ port: sshPort,
206
+ readyTimeout: 15000,
207
+ };
208
+
209
+ if (sshKey) {
210
+ tmpKeyPath = path.join(os.tmpdir(), `deployhub-doctor-${Date.now()}.pem`);
211
+ await fs.writeFile(tmpKeyPath, sshKey, { mode: 0o600 });
212
+ connectOpts.privateKeyPath = tmpKeyPath;
213
+ } else if (keyPath) {
214
+ const expanded = keyPath.replace(/^~/, os.homedir());
215
+ connectOpts.privateKeyPath = path.resolve(expanded);
216
+ } else {
217
+ return { ok: false, message: 'SSH_KEY_PATH or SSH_KEY is required for connectivity test' };
218
+ }
219
+
220
+ await ssh.connect(connectOpts);
221
+ const result = await ssh.execCommand('echo deployhub-ok');
222
+ if (result.stdout.trim() !== 'deployhub-ok') {
223
+ return { ok: false, message: `Connected but remote shell test failed for ${user}@${host}:${sshPort}` };
224
+ }
225
+ return { ok: true, message: `SSH connection OK: ${user}@${host}:${sshPort}` };
226
+ } catch (err) {
227
+ const msg = err instanceof Error ? err.message : String(err);
228
+ return {
229
+ ok: false,
230
+ message: `Could not connect to ${user}@${host}:${sshPort} — ${msg}. Check host, user, key, and that port ${sshPort} is open in your firewall/security group.`,
231
+ };
232
+ } finally {
233
+ ssh.dispose();
234
+ if (tmpKeyPath) {
235
+ await fs.remove(tmpKeyPath).catch(() => {});
236
+ }
237
+ }
238
+ }
239
+
240
+ /**
241
+ * @returns {Promise<string[]>}
242
+ */
243
+ export async function listKubeContexts() {
244
+ try {
245
+ const { stdout } = await execa('kubectl', ['config', 'get-contexts', '-o', 'name'], {
246
+ stdio: 'pipe',
247
+ });
248
+ return stdout
249
+ .split('\n')
250
+ .map((l) => l.trim())
251
+ .filter(Boolean);
252
+ } catch {
253
+ return [];
254
+ }
255
+ }
256
+
257
+ /**
258
+ * @returns {Promise<string|undefined>}
259
+ */
260
+ export async function detectKubeconfigPath() {
261
+ const defaultPath = path.join(os.homedir(), '.kube', 'config');
262
+ if (await fs.pathExists(defaultPath)) {
263
+ return defaultPath;
264
+ }
265
+ return undefined;
266
+ }
267
+
268
+ /**
269
+ * @returns {Promise<string|undefined>}
270
+ */
271
+ export async function detectAzureSubscriptionId() {
272
+ try {
273
+ const { stdout } = await execa(
274
+ 'az',
275
+ ['account', 'show', '--query', 'id', '-o', 'tsv'],
276
+ { stdio: 'pipe' }
277
+ );
278
+ const id = stdout.trim();
279
+ return id || undefined;
280
+ } catch {
281
+ return undefined;
282
+ }
283
+ }
284
+
285
+ /**
286
+ * @returns {Promise<string|undefined>}
287
+ */
288
+ export async function detectGcpProjectId() {
289
+ try {
290
+ const { stdout } = await execa(
291
+ 'gcloud',
292
+ ['config', 'get-value', 'project'],
293
+ { stdio: 'pipe' }
294
+ );
295
+ const id = stdout.trim();
296
+ if (id && id !== '(unset)') return id;
297
+ } catch {
298
+ // ignore
299
+ }
300
+ return undefined;
301
+ }
302
+
303
+ /**
304
+ * @param {string} [kubeconfig]
305
+ * @param {string} [context]
306
+ */
307
+ export async function testKubeConnectivity(kubeconfig, context) {
308
+ /** @type {string[]} */
309
+ const args = ['cluster-info'];
310
+ const env = { ...process.env };
311
+
312
+ if (kubeconfig) {
313
+ const expanded = kubeconfig.replace(/^~/, os.homedir());
314
+ env.KUBECONFIG = path.resolve(expanded);
315
+ }
316
+ if (context) {
317
+ args.push('--context', context);
318
+ }
319
+
320
+ try {
321
+ await execa('kubectl', args, { stdio: 'pipe', env });
322
+ return { ok: true, message: `kubectl cluster-info OK${context ? ` (context: ${context})` : ''}` };
323
+ } catch (err) {
324
+ const msg = err instanceof Error ? err.message : String(err);
325
+ return {
326
+ ok: false,
327
+ message: `kubectl cluster-info failed — ${msg}. Check KUBECONFIG path and KUBE_CONTEXT name.`,
328
+ };
329
+ }
330
+ }
331
+
332
+ /**
333
+ * Run SSH key validation + connectivity test after init prompts.
334
+ * @param {{ host: string, user: string, keyPath?: string, sshPort?: number, deployType: string }} opts
335
+ */
336
+ export async function runSshInitValidation(opts) {
337
+ const { host, user, keyPath, sshPort = 22, deployType } = opts;
338
+
339
+ if (keyPath) {
340
+ console.log(chalk.gray('\n Validating SSH key...'));
341
+ const keyResult = await validateSshKeyPath(keyPath);
342
+ if (!keyResult.ok) {
343
+ console.log(chalk.red(` ✗ ${keyResult.message}`));
344
+ const { continueAnyway } = await inquirer.prompt([
345
+ {
346
+ type: 'confirm',
347
+ name: 'continueAnyway',
348
+ message: 'Continue without a valid SSH key? (you can fix .env later)',
349
+ default: false,
350
+ },
351
+ ]);
352
+ if (!continueAnyway) {
353
+ throw new Error('Init cancelled — fix SSH key path and run deployhub init again.');
354
+ }
355
+ } else {
356
+ console.log(chalk.green(` ✓ ${keyResult.message}`));
357
+ }
358
+ }
359
+
360
+ if (host && user && keyPath) {
361
+ console.log(chalk.gray(` Testing SSH connection to ${deployType} target...`));
362
+ const connResult = await testSshConnectivity({ host, user, keyPath, sshPort });
363
+ if (connResult.ok) {
364
+ console.log(chalk.green(` ✓ ${connResult.message}`));
365
+ } else {
366
+ console.log(chalk.yellow(` ⚠ ${connResult.message}`));
367
+ console.log(
368
+ chalk.gray(
369
+ ' (Connection test failed — you can fix firewall/credentials and run deployhub doctor later.)'
370
+ )
371
+ );
372
+ }
373
+ }
374
+ }
375
+
376
+ /**
377
+ * @param {string} deployType
378
+ */
379
+ export function getDeployTypeLabel(deployType) {
380
+ const labels = {
381
+ ssh: 'SSH (any Linux server)',
382
+ docker: 'Docker (local or remote daemon)',
383
+ ec2: 'AWS EC2 (SSH to instance)',
384
+ 'azure-vm': 'Azure VM (SSH to virtual machine)',
385
+ 'gcp-vm': 'GCP Compute Engine VM (SSH)',
386
+ kubernetes: 'Kubernetes (existing cluster)',
387
+ };
388
+ return labels[deployType] || deployType;
389
+ }