@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.
@@ -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
+ }