@akash-chowdhury-24/deployhub 1.0.0

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 (80) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +176 -0
  3. package/install.ps1 +55 -0
  4. package/install.sh +99 -0
  5. package/package.json +86 -0
  6. package/src/adapters/dotnet.adapter.js +41 -0
  7. package/src/adapters/go.adapter.js +40 -0
  8. package/src/adapters/index.js +48 -0
  9. package/src/adapters/java.adapter.js +46 -0
  10. package/src/adapters/node.adapter.js +77 -0
  11. package/src/adapters/php.adapter.js +43 -0
  12. package/src/adapters/python.adapter.js +54 -0
  13. package/src/adapters/rails.adapter.js +66 -0
  14. package/src/artifact/engine.js +473 -0
  15. package/src/cli/index.js +45 -0
  16. package/src/commands/artifact.js +88 -0
  17. package/src/commands/build.js +44 -0
  18. package/src/commands/clean.js +50 -0
  19. package/src/commands/deploy.js +82 -0
  20. package/src/commands/doctor.js +630 -0
  21. package/src/commands/init.js +795 -0
  22. package/src/commands/logs.js +33 -0
  23. package/src/commands/rollback.js +50 -0
  24. package/src/commands/storage.js +116 -0
  25. package/src/commands/update.js +63 -0
  26. package/src/commands/verify.js +55 -0
  27. package/src/core/config.js +168 -0
  28. package/src/core/pipeline.js +77 -0
  29. package/src/core/stages.js +210 -0
  30. package/src/deployment/index.js +156 -0
  31. package/src/deployment/providers/azure-vm.js +7 -0
  32. package/src/deployment/providers/docker.js +30 -0
  33. package/src/deployment/providers/ec2.js +7 -0
  34. package/src/deployment/providers/gcp-vm.js +7 -0
  35. package/src/deployment/providers/kubernetes.js +30 -0
  36. package/src/deployment/providers/platforms/_shared.js +167 -0
  37. package/src/deployment/providers/platforms/aws-amplify.js +164 -0
  38. package/src/deployment/providers/platforms/azure-static-web-apps.js +68 -0
  39. package/src/deployment/providers/platforms/cloudflare-pages.js +103 -0
  40. package/src/deployment/providers/platforms/firebase-app-hosting.js +95 -0
  41. package/src/deployment/providers/platforms/firebase-hosting.js +99 -0
  42. package/src/deployment/providers/platforms/index.js +44 -0
  43. package/src/deployment/providers/platforms/netlify.js +102 -0
  44. package/src/deployment/providers/platforms/vercel.js +92 -0
  45. package/src/deployment/providers/ssh.js +365 -0
  46. package/src/detectors/angular.js +23 -0
  47. package/src/detectors/backend.detector.js +304 -0
  48. package/src/detectors/dotnet.js +18 -0
  49. package/src/detectors/frontend.detector.js +219 -0
  50. package/src/detectors/go.js +20 -0
  51. package/src/detectors/index.js +78 -0
  52. package/src/detectors/java.js +24 -0
  53. package/src/detectors/nextjs.js +23 -0
  54. package/src/detectors/node.js +28 -0
  55. package/src/detectors/php.js +17 -0
  56. package/src/detectors/python.js +22 -0
  57. package/src/detectors/react.js +29 -0
  58. package/src/detectors/vue.js +23 -0
  59. package/src/logger/index.js +44 -0
  60. package/src/notifications/email.js +53 -0
  61. package/src/notifications/index.js +34 -0
  62. package/src/notifications/slack.js +18 -0
  63. package/src/notifications/webhook.js +21 -0
  64. package/src/rollback/engine.js +102 -0
  65. package/src/storage/index.js +109 -0
  66. package/src/storage/providers/aws.js +96 -0
  67. package/src/storage/providers/azure.js +45 -0
  68. package/src/storage/providers/dropbox.js +49 -0
  69. package/src/storage/providers/ftp.js +69 -0
  70. package/src/storage/providers/gcp.js +45 -0
  71. package/src/storage/providers/gdrive.js +80 -0
  72. package/src/storage/providers/local.js +61 -0
  73. package/src/utils/author.js +141 -0
  74. package/src/utils/checksums.js +53 -0
  75. package/src/utils/firebase-config-generator.js +35 -0
  76. package/src/utils/github-actions.js +389 -0
  77. package/src/utils/init-platform.js +229 -0
  78. package/src/utils/nginx.js +34 -0
  79. package/src/utils/platform-env.js +132 -0
  80. package/src/utils/version.js +31 -0
@@ -0,0 +1,630 @@
1
+ import chalk from 'chalk';
2
+ import { execa } from 'execa';
3
+ import fs from 'fs-extra';
4
+ import path from 'path';
5
+ import axios from 'axios';
6
+ import { loadConfig, loadEnv } from '../core/config.js';
7
+ import { testProvider } from '../storage/index.js';
8
+ import { getDeploymentProvider } from '../deployment/index.js';
9
+ import { PROVIDER_ENV_MAP } from '../utils/github-actions.js';
10
+ import { PLATFORM_ENV_MAP, PLATFORM_CLI_MAP } from '../utils/platform-env.js';
11
+ import { createPlatformProvider } from '../deployment/providers/platforms/index.js';
12
+ import { isCliInstalled } from '../deployment/providers/platforms/_shared.js';
13
+ import { printDoctorFooter } from '../utils/author.js';
14
+ import { createLocalProvider } from '../storage/providers/local.js';
15
+
16
+ /**
17
+ * @typedef {{ name: string, pass: boolean, message: string }} CheckResult
18
+ */
19
+
20
+ /** @type {Set<string>} */
21
+ const NODE_FRAMEWORKS = new Set(['express', 'nestjs', 'fastify', 'koa', 'nextjs', 'node']);
22
+ /** @type {Set<string>} */
23
+ const PYTHON_FRAMEWORKS = new Set(['fastapi', 'django', 'flask', 'python']);
24
+ /** @type {Set<string>} */
25
+ const PHP_FRAMEWORKS = new Set(['laravel', 'symfony', 'php']);
26
+ /** @type {Set<string>} */
27
+ const JAVA_FRAMEWORKS = new Set(['spring', 'java']);
28
+
29
+ /**
30
+ * @param {string} label
31
+ * @param {() => Promise<CheckResult>} fn
32
+ * @returns {Promise<CheckResult>}
33
+ */
34
+ async function runCheck(label, fn) {
35
+ try {
36
+ return await fn();
37
+ } catch (err) {
38
+ return {
39
+ name: label,
40
+ pass: false,
41
+ message: err instanceof Error ? err.message : String(err),
42
+ };
43
+ }
44
+ }
45
+
46
+ /**
47
+ * @param {import('../core/config.js').DeployHubConfig} config
48
+ * @returns {string}
49
+ */
50
+ function resolveBackendFramework(config) {
51
+ return config.backend?.framework || config.framework || 'express';
52
+ }
53
+
54
+ /**
55
+ * @param {import('../core/config.js').DeployHubConfig} config
56
+ * @param {string} envName
57
+ * @returns {Promise<CheckResult[]>}
58
+ */
59
+ async function runBackendProcessChecks(config, envName) {
60
+ const framework = resolveBackendFramework(config);
61
+ const provider = getDeploymentProvider('ssh', config, envName);
62
+
63
+ if (!provider.runRemoteCheck) {
64
+ return [];
65
+ }
66
+
67
+ /** @type {CheckResult[]} */
68
+ const checks = [];
69
+
70
+ if (NODE_FRAMEWORKS.has(framework)) {
71
+ checks.push(
72
+ await runCheck('PM2', async () => {
73
+ const result = await provider.runRemoteCheck('pm2 --version');
74
+ if (result.pass) {
75
+ return { name: 'PM2', pass: true, message: 'PM2 installed on server' };
76
+ }
77
+ return {
78
+ name: 'PM2',
79
+ pass: false,
80
+ message: 'not found — run: npm install -g pm2',
81
+ };
82
+ })
83
+ );
84
+ }
85
+
86
+ if (PYTHON_FRAMEWORKS.has(framework)) {
87
+ checks.push(
88
+ await runCheck('gunicorn', async () => {
89
+ const result = await provider.runRemoteCheck('which gunicorn || gunicorn --version');
90
+ if (result.pass) {
91
+ return { name: 'gunicorn', pass: true, message: 'gunicorn available' };
92
+ }
93
+ return {
94
+ name: 'gunicorn',
95
+ pass: false,
96
+ message: 'not found — run: pip install gunicorn',
97
+ };
98
+ })
99
+ );
100
+
101
+ if (framework === 'fastapi') {
102
+ checks.push(
103
+ await runCheck('uvicorn', async () => {
104
+ const result = await provider.runRemoteCheck('which uvicorn || uvicorn --version');
105
+ if (result.pass) {
106
+ return { name: 'uvicorn', pass: true, message: 'uvicorn available' };
107
+ }
108
+ return {
109
+ name: 'uvicorn',
110
+ pass: false,
111
+ message: 'not found — run: pip install uvicorn',
112
+ };
113
+ })
114
+ );
115
+ }
116
+ }
117
+
118
+ if (PHP_FRAMEWORKS.has(framework)) {
119
+ checks.push(
120
+ await runCheck('php-fpm', async () => {
121
+ const result = await provider.runRemoteCheck(
122
+ 'systemctl is-active php8.2-fpm || systemctl is-active php-fpm'
123
+ );
124
+ if (result.pass && result.message.includes('active')) {
125
+ return { name: 'php-fpm', pass: true, message: 'php-fpm running' };
126
+ }
127
+ return { name: 'php-fpm', pass: false, message: 'php-fpm not running' };
128
+ })
129
+ );
130
+
131
+ checks.push(
132
+ await runCheck('nginx', async () => {
133
+ const result = await provider.runRemoteCheck('systemctl is-active nginx');
134
+ if (result.pass && result.message.includes('active')) {
135
+ return { name: 'nginx', pass: true, message: 'nginx running' };
136
+ }
137
+ return { name: 'nginx', pass: false, message: 'nginx not running' };
138
+ })
139
+ );
140
+ }
141
+
142
+ if (JAVA_FRAMEWORKS.has(framework)) {
143
+ checks.push(
144
+ await runCheck('Java', async () => {
145
+ const result = await provider.runRemoteCheck('java -version 2>&1');
146
+ if (result.pass || result.message.includes('version')) {
147
+ const versionMatch = result.message.match(/version "(\d+)/);
148
+ const major = versionMatch ? parseInt(versionMatch[1], 10) : 0;
149
+ if (major >= 17) {
150
+ return { name: 'Java', pass: true, message: 'Java 17+ installed on server' };
151
+ }
152
+ return {
153
+ name: 'Java',
154
+ pass: false,
155
+ message: `Java ${major || 'unknown'} found — Java 17+ required`,
156
+ };
157
+ }
158
+ return { name: 'Java', pass: false, message: 'Java not found on server' };
159
+ })
160
+ );
161
+ }
162
+
163
+ return checks;
164
+ }
165
+
166
+ /**
167
+ * @param {import('../core/config.js').DeployHubConfig} config
168
+ * @param {string} envName
169
+ * @param {string} [cwd]
170
+ * @returns {Promise<CheckResult[]>}
171
+ */
172
+ async function runPlatformChecks(config, envName, cwd = process.cwd()) {
173
+ const env = config.environments[envName];
174
+ const platform = env?.platform;
175
+ if (!platform) return [];
176
+
177
+ const cli = PLATFORM_CLI_MAP[platform];
178
+ const envKeys = PLATFORM_ENV_MAP[platform] || [];
179
+ /** @type {CheckResult[]} */
180
+ const checks = [];
181
+
182
+ if (cli?.binary) {
183
+ const cliLabel = cli.binary.charAt(0).toUpperCase() + cli.binary.slice(1);
184
+ checks.push(
185
+ await runCheck(`${cliLabel} CLI`, async () => {
186
+ const installed = await isCliInstalled(cli.binary);
187
+ if (installed) {
188
+ return { name: `${cliLabel} CLI`, pass: true, message: `${cli.binary} CLI installed` };
189
+ }
190
+ return {
191
+ name: `${cliLabel} CLI`,
192
+ pass: false,
193
+ message: `not found — run: ${cli.globalInstall || `npm install -g ${cli.install}`}`,
194
+ };
195
+ })
196
+ );
197
+ }
198
+
199
+ for (const key of envKeys) {
200
+ const isToken = key.includes('TOKEN') || key.includes('KEY');
201
+ checks.push(
202
+ await runCheck(key, async () => {
203
+ if (!process.env[key]) {
204
+ return { name: key, pass: false, message: 'not set in .env' };
205
+ }
206
+ if (isToken && key === envKeys[0]) {
207
+ try {
208
+ const provider = createPlatformProvider(platform, config, envName);
209
+ if (provider.testConnection) {
210
+ await provider.testConnection();
211
+ return { name: key, pass: true, message: 'token valid' };
212
+ }
213
+ } catch (err) {
214
+ return {
215
+ name: key,
216
+ pass: false,
217
+ message: err instanceof Error ? err.message : 'token invalid',
218
+ };
219
+ }
220
+ }
221
+ return { name: key, pass: true, message: 'present' };
222
+ })
223
+ );
224
+ }
225
+
226
+ if (platform === 'vercel') {
227
+ checks.push(
228
+ await runCheck('Vercel project link', async () => {
229
+ const vercelJson = path.join(cwd, '.vercel', 'project.json');
230
+ if (await fs.pathExists(vercelJson)) {
231
+ return { name: 'Vercel project link', pass: true, message: '.vercel/project.json found' };
232
+ }
233
+ return {
234
+ name: 'Vercel project link',
235
+ pass: false,
236
+ message: 'not found — run: vercel link',
237
+ };
238
+ })
239
+ );
240
+ }
241
+
242
+ if (platform === 'firebase-hosting') {
243
+ checks.push(
244
+ await runCheck('firebase.json', async () => {
245
+ if (await fs.pathExists(path.join(cwd, 'firebase.json'))) {
246
+ return { name: 'firebase.json', pass: true, message: 'found' };
247
+ }
248
+ return {
249
+ name: 'firebase.json',
250
+ pass: false,
251
+ message: 'missing — run deployhub init or create manually',
252
+ };
253
+ })
254
+ );
255
+ }
256
+
257
+ try {
258
+ const provider = createPlatformProvider(platform, config, envName);
259
+ if (provider.testConnection && envKeys.every((k) => process.env[k])) {
260
+ checks.push(
261
+ await runCheck(`${platform} connection`, async () => {
262
+ await provider.testConnection();
263
+ if (platform === 'netlify') {
264
+ return { name: 'NETLIFY_SITE_ID', pass: true, message: 'site found' };
265
+ }
266
+ if (platform === 'cloudflare-pages') {
267
+ return { name: 'CF project', pass: true, message: 'project exists' };
268
+ }
269
+ if (platform === 'aws-amplify') {
270
+ return { name: 'AMPLIFY_APP_ID', pass: true, message: 'app found in AWS' };
271
+ }
272
+ if (platform.startsWith('firebase')) {
273
+ return { name: 'Firebase project', pass: true, message: 'project ID valid' };
274
+ }
275
+ return { name: `${platform} connection`, pass: true, message: 'connected' };
276
+ })
277
+ );
278
+ }
279
+ } catch (err) {
280
+ if (envKeys.every((k) => process.env[k])) {
281
+ checks.push({
282
+ name: `${platform} connection`,
283
+ pass: false,
284
+ message: err instanceof Error ? err.message : String(err),
285
+ });
286
+ }
287
+ }
288
+
289
+ return checks;
290
+ }
291
+
292
+ /**
293
+ * @param {import('commander').Command} program
294
+ */
295
+ export function registerDoctorCommand(program) {
296
+ program
297
+ .command('doctor')
298
+ .description('Run pre-flight checks before deploying')
299
+ .action(async () => {
300
+ loadEnv();
301
+ const cwd = process.cwd();
302
+ /** @type {CheckResult[]} */
303
+ const results = [];
304
+
305
+ results.push(
306
+ await runCheck('Git', async () => {
307
+ await execa('git', ['--version'], { stdio: 'pipe' });
308
+ const gitDir = path.join(cwd, '.git');
309
+ if (!(await fs.pathExists(gitDir))) {
310
+ return { name: 'Git', pass: false, message: 'Not a git repository' };
311
+ }
312
+ try {
313
+ const { stdout } = await execa('git', ['remote', '-v'], { stdio: 'pipe' });
314
+ if (!stdout.trim()) {
315
+ return { name: 'Git', pass: false, message: 'No remote configured' };
316
+ }
317
+ } catch {
318
+ return { name: 'Git', pass: false, message: 'Could not read git remote' };
319
+ }
320
+ return {
321
+ name: 'Git',
322
+ pass: true,
323
+ message: 'Git installed, repo detected, remote set',
324
+ };
325
+ })
326
+ );
327
+
328
+ results.push(
329
+ await runCheck('Docker', async () => {
330
+ try {
331
+ await execa('docker', ['info'], { stdio: 'pipe' });
332
+ return { name: 'Docker', pass: true, message: 'Docker running' };
333
+ } catch {
334
+ return { name: 'Docker', pass: false, message: 'Docker not found or not running' };
335
+ }
336
+ })
337
+ );
338
+
339
+ results.push(
340
+ await runCheck('Build command', async () => {
341
+ let config;
342
+ try {
343
+ config = await loadConfig(cwd);
344
+ } catch {
345
+ return {
346
+ name: 'Build command',
347
+ pass: false,
348
+ message: 'deployhub.config.json not found — run deployhub init',
349
+ };
350
+ }
351
+
352
+ if (config.projectType === 'backend' && !config.buildCommand) {
353
+ return {
354
+ name: 'Build command',
355
+ pass: true,
356
+ message: 'No build step required for backend',
357
+ };
358
+ }
359
+
360
+ if (!config.buildCommand) {
361
+ return {
362
+ name: 'Build command',
363
+ pass: true,
364
+ message: 'No build command configured',
365
+ };
366
+ }
367
+
368
+ const pkgPath = path.join(cwd, 'package.json');
369
+ if (await fs.pathExists(pkgPath)) {
370
+ const pkg = await fs.readJson(pkgPath);
371
+ const cmd = config.buildCommand.replace('npm run ', '');
372
+ if (pkg.scripts?.[cmd] || config.buildCommand.includes(' ')) {
373
+ return {
374
+ name: 'Build command',
375
+ pass: true,
376
+ message: `"${config.buildCommand}" found in package.json`,
377
+ };
378
+ }
379
+ }
380
+
381
+ return {
382
+ name: 'Build command',
383
+ pass: true,
384
+ message: `Build command configured: "${config.buildCommand}"`,
385
+ };
386
+ })
387
+ );
388
+
389
+ let config = null;
390
+ try {
391
+ config = await loadConfig(cwd);
392
+ } catch {
393
+ // handled above
394
+ }
395
+
396
+ if (config) {
397
+ for (const provider of config.storage || []) {
398
+ const label = provider.charAt(0).toUpperCase() + provider.slice(1);
399
+ if (provider === 'aws') {
400
+ results.push(
401
+ await runCheck('AWS', async () => {
402
+ const keys = PROVIDER_ENV_MAP.aws;
403
+ const missing = keys.filter((k) => !process.env[k]);
404
+ if (missing.length > 0) {
405
+ return {
406
+ name: 'AWS',
407
+ pass: false,
408
+ message: `Missing: ${missing.join(', ')}`,
409
+ };
410
+ }
411
+ await testProvider('aws');
412
+ return {
413
+ name: 'AWS',
414
+ pass: true,
415
+ message: 'Credentials valid, bucket accessible',
416
+ };
417
+ })
418
+ );
419
+ } else if (provider === 'gdrive') {
420
+ results.push(
421
+ await runCheck('Google Drive', async () => {
422
+ const keys = ['GDRIVE_CLIENT_ID', 'GDRIVE_CLIENT_SECRET', 'GDRIVE_REFRESH_TOKEN'];
423
+ const missing = keys.filter((k) => !process.env[k]);
424
+ if (missing.length > 0) {
425
+ return {
426
+ name: 'Google Drive',
427
+ pass: false,
428
+ message: `Missing: ${missing.join(', ')}`,
429
+ };
430
+ }
431
+ await testProvider('gdrive');
432
+ return { name: 'Google Drive', pass: true, message: 'Connected' };
433
+ })
434
+ );
435
+ } else if (provider === 'azure') {
436
+ results.push(
437
+ await runCheck('Azure', async () => {
438
+ await testProvider('azure');
439
+ return { name: 'Azure', pass: true, message: 'Connected' };
440
+ })
441
+ );
442
+ } else if (provider === 'gcp') {
443
+ results.push(
444
+ await runCheck('GCP', async () => {
445
+ await testProvider('gcp');
446
+ return { name: 'GCP', pass: true, message: 'Connected' };
447
+ })
448
+ );
449
+ } else if (provider === 'dropbox') {
450
+ results.push(
451
+ await runCheck('Dropbox', async () => {
452
+ await testProvider('dropbox');
453
+ return { name: 'Dropbox', pass: true, message: 'Connected' };
454
+ })
455
+ );
456
+ } else if (provider === 'local') {
457
+ results.push(
458
+ await runCheck('Local storage', async () => {
459
+ await testProvider('local');
460
+ return { name: 'Local storage', pass: true, message: 'Writable' };
461
+ })
462
+ );
463
+ }
464
+ }
465
+
466
+ for (const envName of config.deploy || []) {
467
+ const env = config.environments[envName];
468
+ if (!env) continue;
469
+
470
+ if (env.deploymentType === 'platform' || env.frontendDeploymentType === 'platform') {
471
+ const platformChecks = await runPlatformChecks(config, envName, cwd);
472
+ results.push(...platformChecks);
473
+ }
474
+
475
+ if (env.type && ['ssh', 'ec2', 'azure-vm', 'gcp-vm'].includes(env.type)) {
476
+ results.push(
477
+ await runCheck('SSH target', async () => {
478
+ const provider = getDeploymentProvider(env.type, config, envName);
479
+ await provider.testConnection();
480
+ const host = env.host || process.env.SSH_HOST;
481
+ return {
482
+ name: 'SSH target',
483
+ pass: true,
484
+ message: `Can reach ${host || 'host'}`,
485
+ };
486
+ })
487
+ );
488
+
489
+ const isBackend =
490
+ config.projectType === 'backend' || config.projectType === 'both';
491
+ if (isBackend && env.type === 'ssh') {
492
+ const backendChecks = await runBackendProcessChecks(config, envName);
493
+ results.push(...backendChecks);
494
+ }
495
+ }
496
+ }
497
+
498
+ results.push(
499
+ await runCheck('Health endpoint', async () => {
500
+ const url = config.healthCheck?.url;
501
+ if (!url) {
502
+ return {
503
+ name: 'Health endpoint',
504
+ pass: false,
505
+ message: 'No URL configured',
506
+ };
507
+ }
508
+ const response = await axios.get(url, {
509
+ timeout: (config.healthCheck.timeout || 30) * 1000,
510
+ validateStatus: () => true,
511
+ });
512
+ if (response.status >= 200 && response.status < 400) {
513
+ return {
514
+ name: 'Health endpoint',
515
+ pass: true,
516
+ message: `URL reachable (HTTP ${response.status})`,
517
+ };
518
+ }
519
+ return {
520
+ name: 'Health endpoint',
521
+ pass: false,
522
+ message: `URL returned HTTP ${response.status}`,
523
+ };
524
+ })
525
+ );
526
+ }
527
+
528
+ results.push(
529
+ await runCheck('Secrets', async () => {
530
+ if (!config) {
531
+ return { name: 'Secrets', pass: false, message: 'No config found' };
532
+ }
533
+
534
+ /** @type {string[]} */
535
+ const required = [];
536
+ for (const provider of config.storage || []) {
537
+ const keys = PROVIDER_ENV_MAP[provider] || [];
538
+ required.push(...keys);
539
+ }
540
+ for (const envName of config.deploy || []) {
541
+ const env = config.environments[envName];
542
+ if (!env) continue;
543
+
544
+ if (env.deploymentType === 'platform' || env.frontendDeploymentType === 'platform') {
545
+ const platform = env.platform;
546
+ if (platform) {
547
+ const keys = PLATFORM_ENV_MAP[platform] || [];
548
+ required.push(...keys);
549
+ }
550
+ } else if (env.type) {
551
+ const keys = PROVIDER_ENV_MAP[env.type] || [];
552
+ required.push(...keys);
553
+ }
554
+ }
555
+
556
+ const unique = [...new Set(required)];
557
+ const missing = unique.filter((k) => !process.env[k]);
558
+ if (missing.length > 0) {
559
+ return {
560
+ name: 'Secrets',
561
+ pass: false,
562
+ message: `Missing: ${missing.join(', ')}`,
563
+ };
564
+ }
565
+ return { name: 'Secrets', pass: true, message: 'All required env vars present' };
566
+ })
567
+ );
568
+
569
+ results.push(
570
+ await runCheck('GitHub Actions', async () => {
571
+ const workflowPath = path.join(cwd, '.github', 'workflows', 'deployhub.yml');
572
+ if (await fs.pathExists(workflowPath)) {
573
+ return {
574
+ name: 'GitHub Actions',
575
+ pass: true,
576
+ message: 'Workflow file exists at .github/workflows/deployhub.yml',
577
+ };
578
+ }
579
+ return {
580
+ name: 'GitHub Actions',
581
+ pass: false,
582
+ message: 'Workflow file missing — run deployhub init',
583
+ };
584
+ })
585
+ );
586
+
587
+ results.push(
588
+ await runCheck('Storage write', async () => {
589
+ const provider = createLocalProvider();
590
+ const testFile = path.join(cwd, '.deployhub-doctor-test');
591
+ await fs.writeFile(testFile, 'test');
592
+ const remoteKey = `doctor-test-${Date.now()}.txt`;
593
+ await provider.upload(testFile, remoteKey);
594
+ const ok = await provider.verify(remoteKey);
595
+ await provider.delete(remoteKey);
596
+ await fs.remove(testFile);
597
+ if (ok) {
598
+ return { name: 'Storage write', pass: true, message: 'Test upload succeeded' };
599
+ }
600
+ return { name: 'Storage write', pass: false, message: 'Test upload verification failed' };
601
+ })
602
+ );
603
+
604
+ console.log('');
605
+ const pad = (name) => name.padEnd(22);
606
+ for (const r of results) {
607
+ const icon = r.pass ? chalk.green('✓') : chalk.red('✗');
608
+ console.log(` Checking ${pad(r.name)}... ${icon} ${r.message}`);
609
+ }
610
+
611
+ const passed = results.filter((r) => r.pass).length;
612
+ const total = results.length;
613
+ console.log('');
614
+ if (passed === total) {
615
+ console.log(chalk.green.bold(` ✓ Ready to deploy (${passed}/${total} checks passed)`));
616
+ } else {
617
+ const failed = total - passed;
618
+ console.log(
619
+ chalk.yellow.bold(
620
+ ` ${passed}/${total} — fix the ${failed} issue${failed > 1 ? 's' : ''} above before deploying`
621
+ )
622
+ );
623
+ }
624
+ console.log('');
625
+ printDoctorFooter();
626
+ console.log('');
627
+ });
628
+ }
629
+
630
+ export default { registerDoctorCommand };