@akash-chowdhury-24/deployhub 2.0.6 → 2.0.8

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akash-chowdhury-24/deployhub",
3
- "version": "2.0.6",
3
+ "version": "2.0.8",
4
4
  "description": "Zero-configuration deployment and artifact manager",
5
5
  "type": "module",
6
6
  "main": "./src/cli/index.js",
@@ -19,6 +19,7 @@ import {
19
19
  formatDeployPathWriteFailure,
20
20
  } from '../utils/shell-quote.js';
21
21
  import { formatPasswordlessSudoGuidance } from '../utils/nginx.js';
22
+ import { checkImagePullability } from '../utils/docker-image-deploy.js';
22
23
 
23
24
  /**
24
25
  * @typedef {{ name: string, pass: boolean, message: string }} CheckResult
@@ -410,6 +411,30 @@ async function runDeploymentChecks(config, envName, envConfig) {
410
411
  }
411
412
  })
412
413
  );
414
+
415
+ checks.push(
416
+ await runCheck('Container image pullable', async () => {
417
+ const result = await checkImagePullability(config, process.env);
418
+ return {
419
+ name: 'Container image pullable',
420
+ pass: result.ok,
421
+ message: result.message,
422
+ };
423
+ })
424
+ );
425
+ }
426
+
427
+ if (deployType === 'docker' && process.env.DOCKER_REGISTRY_USERNAME && process.env.DOCKER_REGISTRY_TOKEN) {
428
+ checks.push(
429
+ await runCheck('Container image pullable', async () => {
430
+ const result = await checkImagePullability(config, process.env);
431
+ return {
432
+ name: 'Container image pullable',
433
+ pass: result.ok,
434
+ message: result.message,
435
+ };
436
+ })
437
+ );
413
438
  }
414
439
 
415
440
  if (deployType === 'ec2' && process.env.EC2_INSTANCE_ID) {
@@ -13,7 +13,7 @@ import {
13
13
  import { getProjectVersion } from '../utils/version.js';
14
14
  import {
15
15
  writeWorkflowFile,
16
- getRequiredSecrets,
16
+ getGithubSecretsChecklist,
17
17
  generateEnvExampleContent,
18
18
  addDeployhubToPackageJson,
19
19
  DEFAULT_NPM_CLI_SOURCE,
@@ -30,7 +30,10 @@ import {
30
30
  getDockerEnvSecrets,
31
31
  SSH_BASED,
32
32
  } from '../deployment/init-prompts.js';
33
- import { printDeploymentNextSteps } from '../deployment/deployment-env.js';
33
+ import {
34
+ printDeploymentNextSteps,
35
+ formatSecretChecklistLine,
36
+ } from '../deployment/deployment-env.js';
34
37
  import { confirmValueIfContainsSpaces } from '../deployment/init-helpers.js';
35
38
 
36
39
  const FRONTEND_CHOICES = [
@@ -498,23 +501,34 @@ export function registerInitCommand(program) {
498
501
  );
499
502
  await fs.writeFile(envExampleDest, envExampleContent);
500
503
 
501
- const secrets = getRequiredSecrets(config.storage, deploy, environments, config);
504
+ const secretsChecklist = getGithubSecretsChecklist(
505
+ config.storage,
506
+ deploy,
507
+ environments,
508
+ config
509
+ );
502
510
 
503
511
  console.log('');
504
512
  if (primaryDeployType) {
505
513
  console.log(chalk.green.bold(`✔ Config generated for ${primaryDeployType} deployment.`));
506
- printDeploymentNextSteps(primaryDeployType, secrets);
514
+ printDeploymentNextSteps(primaryDeployType, secretsChecklist);
507
515
  } else {
508
516
  console.log(chalk.green.bold('✓ DeployHub initialized successfully!'));
509
517
  console.log('');
510
518
  console.log(chalk.bold('Next steps:'));
511
519
  console.log(' 1. Copy .env.example to .env and fill in credentials');
512
- if (secrets.length > 0) {
520
+ if (secretsChecklist.length > 0) {
513
521
  console.log(' 2. Add these secrets to GitHub (Settings → Secrets):');
514
- secrets.forEach((s) => console.log(` • ${s}`));
522
+ secretsChecklist.forEach((item) =>
523
+ console.log(` ${formatSecretChecklistLine(item)}`)
524
+ );
515
525
  }
516
- console.log(` ${secrets.length > 0 ? '3' : '2'}. Run ${chalk.cyan('deployhub doctor')} to verify your setup`);
517
- console.log(` ${secrets.length > 0 ? '4' : '3'}. Push to main — GitHub Actions will run ${chalk.cyan('deployhub build')} automatically`);
526
+ console.log(
527
+ ` ${secretsChecklist.length > 0 ? '3' : '2'}. Run ${chalk.cyan('deployhub doctor')} to verify your setup`
528
+ );
529
+ console.log(
530
+ ` ${secretsChecklist.length > 0 ? '4' : '3'}. Push to main — GitHub Actions will run ${chalk.cyan('deployhub build')} automatically`
531
+ );
518
532
  }
519
533
 
520
534
  console.log('');
@@ -316,12 +316,11 @@ export const DEPLOYMENT_ENV_DEFS = {
316
316
  },
317
317
  {
318
318
  key: 'DOCKER_IMAGE_NAME',
319
- optionalReason: 'only required if your manifests need an image override at deploy time',
320
319
  comment: [
321
- 'Container image to deploy (must match manifests or be overridden).',
320
+ 'Container image to build, push, and deploy.',
321
+ 'Kubernetes clusters pull from a registry — local-only Docker images will not work.',
322
322
  ],
323
323
  example: 'ghcr.io/myorg/myapp',
324
- when: 'optional',
325
324
  },
326
325
  {
327
326
  key: 'DOCKER_IMAGE_TAG',
@@ -332,6 +331,30 @@ export const DEPLOYMENT_ENV_DEFS = {
332
331
  example: 'latest',
333
332
  when: 'optional',
334
333
  },
334
+ {
335
+ key: 'DOCKER_REGISTRY_URL',
336
+ optionalReason: 'leave empty for Docker Hub',
337
+ comment: [
338
+ 'Container registry URL. Leave empty for Docker Hub.',
339
+ 'Examples: https://index.docker.io/v1/ | https://ghcr.io',
340
+ ],
341
+ when: 'optional',
342
+ },
343
+ {
344
+ key: 'DOCKER_REGISTRY_USERNAME',
345
+ comment: [
346
+ 'Registry username — required to push so the cluster can pull your image.',
347
+ 'Even public Docker Hub repos require authentication to push.',
348
+ ],
349
+ example: 'myuser',
350
+ },
351
+ {
352
+ key: 'DOCKER_REGISTRY_TOKEN',
353
+ comment: [
354
+ 'Registry password or personal access token — required to push so the cluster can pull.',
355
+ 'Docker Hub: access token. GHCR: GitHub PAT with write:packages.',
356
+ ],
357
+ },
335
358
  {
336
359
  key: 'KUBE_IMAGE_PULL_SECRET',
337
360
  optionalReason: 'only required when pulling from a private container registry',
@@ -353,6 +376,8 @@ export const DEPLOYMENT_ENV_KEYS = Object.fromEntries(
353
376
  );
354
377
 
355
378
  /**
379
+ * Locally required env keys for doctor method-specific checks.
380
+ * Excludes optional and CI-only vars.
356
381
  * @param {string} deployType
357
382
  * @param {import('../core/config.js').DeployHubConfig} [config]
358
383
  * @returns {string[]}
@@ -373,7 +398,16 @@ export function getDeploymentEnvKeys(deployType, config = null) {
373
398
  }
374
399
 
375
400
  /**
376
- * Required secrets for GitHub Actions (includes CI-only vars like SSH_KEY).
401
+ * Map a def key to the GitHub Actions secret name (SSH_KEY_PATH SSH_KEY).
402
+ * @param {string} key
403
+ */
404
+ function toGithubSecretKey(key) {
405
+ return key === 'SSH_KEY_PATH' ? 'SSH_KEY' : key;
406
+ }
407
+
408
+ /**
409
+ * Genuinely required secrets for doctor "Secrets" check and required checklist items.
410
+ * Includes CI-only vars (e.g. SSH_KEY) but NOT optional vars.
377
411
  * @param {string} deployType
378
412
  * @param {import('../core/config.js').DeployHubConfig} [config]
379
413
  * @returns {string[]}
@@ -388,22 +422,93 @@ export function getDeploymentSecretKeys(deployType, config = null) {
388
422
 
389
423
  for (const d of defs) {
390
424
  if (d.when === 'backend' && !isBackend) continue;
391
- // Docker optional vars must still appear in CI workflow/secrets checklist —
392
- // empty secrets are fine when unused; missing DOCKER_IMAGE_NAME is not.
393
- if (d.when === 'optional') {
394
- if (deployType === 'docker' && d.key.startsWith('DOCKER_')) {
395
- keys.push(d.key);
425
+ if (d.when === 'optional') continue;
426
+ keys.push(toGithubSecretKey(d.key));
427
+ }
428
+
429
+ return [...new Set(keys)];
430
+ }
431
+
432
+ /**
433
+ * Broad CI-wiring list for the GitHub Actions workflow generator.
434
+ * Includes required, CI-only, and optional keys so `${{ secrets.X }}` is
435
+ * available when the user sets optional secrets — empty secrets are fine.
436
+ * @param {string} deployType
437
+ * @param {import('../core/config.js').DeployHubConfig} [config]
438
+ * @returns {string[]}
439
+ */
440
+ export function getDeploymentWorkflowSecretKeys(deployType, config = null) {
441
+ const defs = DEPLOYMENT_ENV_DEFS[deployType] || [];
442
+ const projectType = config?.projectType || 'frontend';
443
+ const isBackend = projectType === 'backend' || projectType === 'both';
444
+
445
+ /** @type {string[]} */
446
+ const keys = [];
447
+
448
+ for (const d of defs) {
449
+ if (d.when === 'backend' && !isBackend) continue;
450
+ keys.push(toGithubSecretKey(d.key));
451
+ }
452
+
453
+ return [...new Set(keys)];
454
+ }
455
+
456
+ /**
457
+ * @typedef {{ key: string, required: boolean, note?: string }} SecretChecklistItem
458
+ */
459
+
460
+ /**
461
+ * Labeled GitHub Secrets checklist entries for a single deploy method.
462
+ * @param {string} deployType
463
+ * @param {import('../core/config.js').DeployHubConfig} [config]
464
+ * @returns {SecretChecklistItem[]}
465
+ */
466
+ export function getDeploymentSecretChecklistItems(deployType, config = null) {
467
+ const defs = DEPLOYMENT_ENV_DEFS[deployType] || [];
468
+ const projectType = config?.projectType || 'frontend';
469
+ const isBackend = projectType === 'backend' || projectType === 'both';
470
+
471
+ /** @type {Map<string, SecretChecklistItem>} */
472
+ const byKey = new Map();
473
+
474
+ for (const d of defs) {
475
+ if (d.when === 'backend' && !isBackend) continue;
476
+
477
+ const key = toGithubSecretKey(d.key);
478
+ const required = d.when !== 'optional';
479
+ const note =
480
+ d.when === 'optional'
481
+ ? d.optionalReason
482
+ : d.when === 'ci'
483
+ ? d.optionalReason || 'required for GitHub Actions CI (paste private key contents)'
484
+ : undefined;
485
+
486
+ const existing = byKey.get(key);
487
+ if (existing) {
488
+ // Prefer required if any def for this key is required
489
+ if (required && !existing.required) {
490
+ byKey.set(key, { key, required: true, note });
396
491
  }
397
492
  continue;
398
493
  }
399
- if (d.key === 'SSH_KEY_PATH') {
400
- keys.push('SSH_KEY');
401
- continue;
402
- }
403
- keys.push(d.key);
494
+
495
+ byKey.set(key, { key, required, note });
404
496
  }
405
497
 
406
- return keys;
498
+ return Array.from(byKey.values());
499
+ }
500
+
501
+ /**
502
+ * Format a checklist item for console output.
503
+ * @param {SecretChecklistItem} item
504
+ */
505
+ export function formatSecretChecklistLine(item) {
506
+ if (item.required) {
507
+ const note = item.note ? ` — ${item.note}` : '';
508
+ return `• ${item.key} (required${note})`;
509
+ }
510
+ const note = item.note ? ` — ${item.note}` : '';
511
+ return `• ${item.key} (optional${note})`;
407
512
  }
408
513
 
409
514
  /**
@@ -449,6 +554,7 @@ export function generateDeploymentEnvSection(
449
554
  return lines.join('\n').trimEnd();
450
555
  }
451
556
 
557
+
452
558
  /**
453
559
  * @param {string} key
454
560
  * @param {import('../core/config.js').DeployHubConfig} [config]
@@ -527,8 +633,7 @@ export const DEPLOYMENT_GUIDE = {
527
633
  'Copy .env.example to .env and set DOCKER_IMAGE_NAME (required — e.g. myuser/myapp).',
528
634
  'Optional in .env: DOCKER_IMAGE_TAG, DOCKER_REGISTRY_URL, DOCKER_HOST, DOCKER_TLS_VERIFY, DOCKER_CERT_PATH.',
529
635
  'If using a private registry: also set DOCKER_REGISTRY_USERNAME and DOCKER_REGISTRY_TOKEN.',
530
- 'Add GitHub Secrets (Settings → Secrets and variables → Actions): DOCKER_IMAGE_NAME (required). Local .env is NOT used by GitHub Actions — doctor only checks your machine.',
531
- 'Also add as GitHub Secrets if set locally: DOCKER_IMAGE_TAG, DOCKER_REGISTRY_URL, DOCKER_REGISTRY_USERNAME, DOCKER_REGISTRY_TOKEN, DOCKER_HOST, DOCKER_TLS_VERIFY, DOCKER_CERT_PATH.',
636
+ 'Add the GitHub Secrets listed below (Settings → Secrets and variables → Actions). Local .env is NOT used by GitHub Actions — doctor only checks your machine.',
532
637
  'Run deployhub doctor to verify Docker is reachable.',
533
638
  'git push origin main to trigger your first deployment.',
534
639
  ],
@@ -601,30 +706,36 @@ export const DEPLOYMENT_GUIDE = {
601
706
  before: [
602
707
  'An existing Kubernetes cluster (DeployHub does not provision clusters).',
603
708
  'kubectl installed and configured (kubectl cluster-info works).',
709
+ 'A container registry account — clusters pull images from a registry, not your local Docker daemon.',
710
+ 'Registry credentials (username + token) to push your image so the cluster can pull it.',
604
711
  'Kubernetes manifests (Deployment, Service, etc.) in your repo or artifact.',
605
712
  'Cluster access from CI: kubeconfig or cloud-specific auth for GitHub Actions.',
606
713
  ],
607
714
  automates: [
608
715
  'Lists available kubectl contexts during init so you pick from a menu.',
609
716
  'Auto-detects ~/.kube/config if present.',
610
- 'Generates complete .env.example for kubeconfig, context, and namespace.',
717
+ 'Generates complete .env.example for kubeconfig, context, namespace, and registry settings.',
611
718
  'Tests cluster connectivity during init.',
719
+ 'Builds and pushes your container image during deployhub build/deploy (pipeline docker stage).',
612
720
  ],
613
721
  after: [
614
722
  'Ensure your kubeconfig context points to the correct cluster.',
615
723
  'Create namespace if needed: kubectl create namespace YOUR_NAMESPACE',
616
- 'For private registries: kubectl create secret docker-registry ... and set KUBE_IMAGE_PULL_SECRET.',
617
- 'Copy .env.example to .env; add KUBECONFIG contents or auth secrets to GitHub Actions.',
618
- 'Run deployhub doctor, then git push origin main.',
724
+ 'Copy .env.example to .env and set DOCKER_IMAGE_NAME, DOCKER_REGISTRY_USERNAME, and DOCKER_REGISTRY_TOKEN.',
725
+ 'Skipping registry credentials will very likely cause ImagePullBackOff the cluster cannot see local Docker images.',
726
+ 'For private registries: also create kubectl create secret docker-registry ... and set KUBE_IMAGE_PULL_SECRET.',
727
+ 'Add the GitHub Secrets listed below (Settings → Secrets and variables → Actions).',
728
+ 'Run deployhub doctor to verify cluster access and that your image is pullable.',
729
+ 'git push origin main to trigger your first deployment.',
619
730
  ],
620
731
  },
621
732
  };
622
733
 
623
734
  /**
624
735
  * @param {string} deployType
625
- * @param {string[]} [extraSecrets]
736
+ * @param {SecretChecklistItem[]} [checklist]
626
737
  */
627
- export function printDeploymentNextSteps(deployType, extraSecrets = []) {
738
+ export function printDeploymentNextSteps(deployType, checklist = []) {
628
739
  const guide = DEPLOYMENT_GUIDE[deployType];
629
740
  if (!guide) return;
630
741
 
@@ -635,10 +746,10 @@ export function printDeploymentNextSteps(deployType, extraSecrets = []) {
635
746
  console.log(` ${i + 1}. ${step}`);
636
747
  });
637
748
 
638
- if (extraSecrets.length > 0) {
749
+ if (checklist.length > 0) {
639
750
  console.log('\n GitHub Secrets to add (Settings → Secrets and variables → Actions):');
640
- for (const s of extraSecrets) {
641
- console.log(` ${s}`);
751
+ for (const item of checklist) {
752
+ console.log(` ${formatSecretChecklistLine(item)}`);
642
753
  }
643
754
  }
644
755
  }
@@ -94,6 +94,23 @@ async function promptKubernetesDeployment(base, projectName, projectType) {
94
94
  message: 'Container image name (e.g. ghcr.io/myorg/myapp):',
95
95
  default: projectName,
96
96
  },
97
+ {
98
+ type: 'input',
99
+ name: 'dockerRegistryUrl',
100
+ message: 'Registry URL (leave empty for Docker Hub):',
101
+ },
102
+ {
103
+ type: 'input',
104
+ name: 'dockerRegistryUsername',
105
+ message:
106
+ 'Registry username (required — needed to push your image so the cluster can pull it):',
107
+ },
108
+ {
109
+ type: 'password',
110
+ name: 'dockerRegistryToken',
111
+ message:
112
+ 'Registry token/password (required — needed to push your image so the cluster can pull it):',
113
+ },
97
114
  {
98
115
  type: 'input',
99
116
  name: 'healthUrl',
@@ -115,6 +132,9 @@ async function promptKubernetesDeployment(base, projectName, projectType) {
115
132
  kubeContext: kubeAnswers.kubeContext,
116
133
  kubeNamespace: kubeAnswers.kubeNamespace,
117
134
  dockerImageName: kubeAnswers.dockerImageName,
135
+ dockerRegistryUrl: kubeAnswers.dockerRegistryUrl,
136
+ dockerRegistryUsername: kubeAnswers.dockerRegistryUsername,
137
+ dockerRegistryToken: kubeAnswers.dockerRegistryToken,
118
138
  healthUrl: kubeAnswers.healthUrl,
119
139
  };
120
140
  }
@@ -366,6 +386,7 @@ export function buildServerEnvEntry(
366
386
  envEntry.kubeContext = deployAnswers.kubeContext;
367
387
  envEntry.kubeNamespace = deployAnswers.kubeNamespace || projectName;
368
388
  envEntry.dockerImageName = deployAnswers.dockerImageName || projectName;
389
+ envEntry.dockerRegistryUrl = deployAnswers.dockerRegistryUrl || '';
369
390
  return envEntry;
370
391
  }
371
392
 
@@ -418,7 +439,7 @@ export function buildServerEnvEntry(
418
439
  * @returns {Record<string, string>|null}
419
440
  */
420
441
  export function getDockerEnvSecrets(deployAnswers) {
421
- if (deployAnswers.deployType !== 'docker') return null;
442
+ if (!['docker', 'kubernetes'].includes(deployAnswers.deployType)) return null;
422
443
 
423
444
  /** @type {Record<string, string>} */
424
445
  const vars = {};
@@ -1,18 +1,6 @@
1
1
  import { execa } from 'execa';
2
- import fs from 'fs-extra';
3
- import path from 'path';
4
2
  import { createLogger } from '../../logger/index.js';
5
- import { extractArtifact } from '../../artifact/engine.js';
6
- import {
7
- describeInterpretedBackendGap,
8
- generateDotnetRuntimeDockerfile,
9
- generateFrontendRuntimeDockerfile,
10
- generateGoRuntimeDockerfile,
11
- generateSpringRuntimeDockerfile,
12
- isFrontendStaticFramework,
13
- isInterpretedBackendFramework,
14
- resolveDockerImageRef,
15
- } from '../../utils/docker-image.js';
3
+ import { createDockerImageDeployContext } from '../../utils/docker-image-deploy.js';
16
4
 
17
5
  /**
18
6
  * @param {import('../../core/config.js').DeployHubConfig} config
@@ -21,277 +9,8 @@ import {
21
9
  */
22
10
  export function createDockerProvider(config, envName, env = process.env) {
23
11
  const log = createLogger('docker');
24
-
25
- const { fullImage, latestImage, legacyLatestImage, imageTag } =
26
- resolveDockerImageRef(config, env);
27
- const registryUrl = env.DOCKER_REGISTRY_URL || '';
28
- const registryUser = env.DOCKER_REGISTRY_USERNAME || '';
29
- const registryToken = env.DOCKER_REGISTRY_TOKEN || '';
30
- const dockerHost = env.DOCKER_HOST || '';
31
-
32
- function getDockerEnv() {
33
- /** @type {Record<string, string>} */
34
- const dockerEnv = { ...process.env };
35
- if (dockerHost) dockerEnv.DOCKER_HOST = dockerHost;
36
- if (env.DOCKER_TLS_VERIFY) dockerEnv.DOCKER_TLS_VERIFY = env.DOCKER_TLS_VERIFY;
37
- if (env.DOCKER_CERT_PATH) dockerEnv.DOCKER_CERT_PATH = env.DOCKER_CERT_PATH;
38
- return dockerEnv;
39
- }
40
-
41
- function hasRegistryCredentials() {
42
- return Boolean(registryUser && registryToken);
43
- }
44
-
45
- async function dockerLogin() {
46
- if (!hasRegistryCredentials()) return;
47
- const registry = registryUrl || 'https://index.docker.io/v1/';
48
- log.info('Logging in to container registry...');
49
- await execa(
50
- 'docker',
51
- ['login', registry, '-u', registryUser, '--password-stdin'],
52
- {
53
- input: registryToken,
54
- stdio: ['pipe', 'inherit', 'inherit'],
55
- env: getDockerEnv(),
56
- }
57
- );
58
- }
59
-
60
- /**
61
- * Push only when registry credentials are configured. Avoids a noisy failed
62
- * push to Docker Hub for local-only image names.
63
- */
64
- async function maybePushImage() {
65
- if (!hasRegistryCredentials()) {
66
- log.info(
67
- 'docker push skipped (DOCKER_REGISTRY_USERNAME/TOKEN not set — local image only)'
68
- );
69
- return;
70
- }
71
-
72
- log.info(`Pushing ${fullImage} to registry...`);
73
- await execa('docker', ['push', fullImage], {
74
- stdio: 'inherit',
75
- env: getDockerEnv(),
76
- });
77
- log.success(`Pushed ${fullImage}`);
78
- }
79
-
80
- /**
81
- * @param {string} ref
82
- */
83
- async function imageExists(ref) {
84
- try {
85
- await execa('docker', ['image', 'inspect', ref], {
86
- stdio: 'pipe',
87
- env: getDockerEnv(),
88
- });
89
- return true;
90
- } catch {
91
- return false;
92
- }
93
- }
94
-
95
- /**
96
- * Prefer the image already built during the pipeline `docker` stage.
97
- * Retag when the pipeline used `:latest` and deploy needs a version tag.
98
- */
99
- async function ensureImageFromPipeline() {
100
- if (await imageExists(fullImage)) {
101
- log.info(`Reusing existing image ${fullImage}`);
102
- return true;
103
- }
104
-
105
- const candidates = [...new Set([latestImage, legacyLatestImage])].filter(
106
- (ref) => ref !== fullImage
107
- );
108
-
109
- for (const candidate of candidates) {
110
- if (!(await imageExists(candidate))) continue;
111
- log.info(`Re-tagging pipeline image ${candidate} → ${fullImage}`);
112
- await execa('docker', ['tag', candidate, fullImage], {
113
- stdio: 'inherit',
114
- env: getDockerEnv(),
115
- });
116
- return true;
117
- }
118
-
119
- return false;
120
- }
121
-
122
- /**
123
- * Backend artifacts omit lockfiles/node_modules. Prefer a pre-built binary
124
- * runtime image when present; otherwise fail with an actionable error.
125
- * @param {string} buildContext
126
- * @param {Record<string, unknown>} metadata
127
- * @param {string} framework
128
- * @param {number} port
129
- */
130
- async function prepareBackendBuildContext(buildContext, metadata, framework, port) {
131
- if (framework === 'spring') {
132
- const targetDir = path.join(buildContext, 'target');
133
- if (await fs.pathExists(targetDir)) {
134
- const jars = (await fs.readdir(targetDir)).filter((f) => f.endsWith('.jar'));
135
- if (jars.length > 0) {
136
- const jarRel = `target/${jars[0]}`;
137
- log.info(`Building runtime image from pre-built JAR (${jarRel})...`);
138
- await fs.writeFile(
139
- path.join(buildContext, 'Dockerfile'),
140
- generateSpringRuntimeDockerfile(jarRel, port)
141
- );
142
- return;
143
- }
144
- }
145
- }
146
-
147
- if (framework === 'go') {
148
- const binDir = path.join(buildContext, 'bin');
149
- if (await fs.pathExists(binDir)) {
150
- const bins = await fs.readdir(binDir);
151
- if (bins.length > 0) {
152
- const binRel = `bin/${bins[0]}`;
153
- log.info(`Building runtime image from pre-built Go binary (${binRel})...`);
154
- await fs.writeFile(
155
- path.join(buildContext, 'Dockerfile'),
156
- generateGoRuntimeDockerfile(binRel, port)
157
- );
158
- return;
159
- }
160
- }
161
- }
162
-
163
- if (framework === 'dotnet') {
164
- const publishDir =
165
- /** @type {string} */ (metadata.buildOutput) ||
166
- config.buildOutput ||
167
- 'publish';
168
- const publishPath = path.join(buildContext, publishDir);
169
- if (await fs.pathExists(publishPath)) {
170
- log.info(`Building runtime image from pre-built .NET output (${publishDir}/)...`);
171
- await fs.writeFile(
172
- path.join(buildContext, 'Dockerfile'),
173
- generateDotnetRuntimeDockerfile(publishDir, port)
174
- );
175
- return;
176
- }
177
- }
178
-
179
- const dockerfilePath = path.join(buildContext, 'Dockerfile');
180
- if (!(await fs.pathExists(dockerfilePath))) {
181
- throw new Error(
182
- 'No Dockerfile found in artifact and no pipeline image to reuse. ' +
183
- 'Add a Dockerfile and enable pipeline.docker so the image is built from project source.'
184
- );
185
- }
186
-
187
- // Interpreted backends (Node/Python/PHP/Ruby): never rebuild from artifact.
188
- // Artifacts ship source + manifest files, not installed dependency trees.
189
- if (isInterpretedBackendFramework(framework)) {
190
- const gap = describeInterpretedBackendGap(framework);
191
- throw new Error(
192
- `Cannot rebuild ${gap.ecosystem} backend image "${fullImage}" from the packaged artifact.\n` +
193
- `Backend artifacts include source/manifests but not ${gap.missing}, ` +
194
- `so Dockerfiles that run \`${gap.installCmd}\` cannot reliably succeed from the artifact alone.\n\n` +
195
- 'What to do instead:\n' +
196
- ' 1. Enable pipeline.docker in deployhub.config.json (default when Docker deploy is selected).\n' +
197
- ' 2. Run a full `deployhub build` so the image is built from the project root (with full deps).\n' +
198
- ' 3. Deploy will reuse that local image (retag/push/run) — it will not rebuild from the artifact.\n\n' +
199
- `Standalone \`deployhub deploy\` without a pre-built local image is not supported for ${gap.ecosystem} backends.`
200
- );
201
- }
202
-
203
- // Remaining backends (unknown frameworks): try the packaged Dockerfile, but warn.
204
- log.warn(
205
- 'Building backend image from extracted artifact. Prefer pipeline.docker so the image ' +
206
- 'is built once from full project source, then reused on deploy.'
207
- );
208
- }
209
-
210
- /**
211
- * Standalone deploy fallback: extract the packaged artifact and build a
212
- * runtime image from pre-built output (frontend) or compiled backend artifacts.
213
- * Never builds from artifactDir root — that only has zip/metadata + a source Dockerfile.
214
- * @param {string} artifactDir
215
- */
216
- async function buildFromArtifactContents(artifactDir) {
217
- const zipPath = path.join(artifactDir, 'artifact.zip');
218
- if (!(await fs.pathExists(zipPath))) {
219
- throw new Error(
220
- `No local image found for ${fullImage} and no artifact.zip to build from. ` +
221
- 'Enable pipeline.docker so the image is built from project source, or run deployhub build first.'
222
- );
223
- }
224
-
225
- const buildContext = path.join(artifactDir, '_docker_build');
226
- await fs.remove(buildContext);
227
- await extractArtifact(artifactDir, buildContext);
228
-
229
- try {
230
- const metadataPath = path.join(buildContext, 'metadata.json');
231
- const metadata = (await fs.pathExists(metadataPath))
232
- ? await fs.readJson(metadataPath)
233
- : {};
234
-
235
- const projectType = metadata.projectType || config.projectType || 'frontend';
236
- const framework =
237
- metadata.framework ||
238
- config.framework ||
239
- config.frontend?.framework ||
240
- '';
241
- const buildOutput =
242
- metadata.buildOutput ||
243
- config.buildOutput ||
244
- config.frontend?.buildOutput ||
245
- 'dist';
246
- const port =
247
- Number(metadata.port) ||
248
- config.port ||
249
- config.backend?.port ||
250
- 3000;
251
-
252
- const composePath = path.join(buildContext, 'docker-compose.yml');
253
- if (await fs.pathExists(composePath)) {
254
- log.info('Building via docker compose from extracted artifact...');
255
- await execa('docker', ['compose', 'up', '-d', '--build'], {
256
- cwd: buildContext,
257
- stdio: 'inherit',
258
- env: getDockerEnv(),
259
- });
260
- return { ranCompose: true };
261
- }
262
-
263
- const isStaticFrontend =
264
- projectType === 'frontend' || isFrontendStaticFramework(framework);
265
-
266
- if (isStaticFrontend) {
267
- const outputDir = path.join(buildContext, buildOutput);
268
- if (!(await fs.pathExists(outputDir))) {
269
- throw new Error(
270
- `Frontend artifact is missing build output "${buildOutput}". ` +
271
- 'Cannot build a runtime image from this artifact.'
272
- );
273
- }
274
- log.info(
275
- `Building runtime image from pre-built ${buildOutput}/ (no source rebuild)...`
276
- );
277
- await fs.writeFile(
278
- path.join(buildContext, 'Dockerfile'),
279
- generateFrontendRuntimeDockerfile(buildOutput)
280
- );
281
- } else {
282
- await prepareBackendBuildContext(buildContext, metadata, framework, port);
283
- }
284
-
285
- await execa('docker', ['build', '-t', fullImage, '.'], {
286
- cwd: buildContext,
287
- stdio: 'inherit',
288
- env: getDockerEnv(),
289
- });
290
- return { ranCompose: false };
291
- } finally {
292
- await fs.remove(buildContext).catch(() => {});
293
- }
294
- }
12
+ const imageOps = createDockerImageDeployContext(config, env, log);
13
+ const { fullImage, getDockerEnv, ensureImageReadyForDeploy } = imageOps;
295
14
 
296
15
  /**
297
16
  * @param {string} artifactDir
@@ -300,31 +19,12 @@ export function createDockerProvider(config, envName, env = process.env) {
300
19
  log.info(`Deploying via Docker (image: ${fullImage})...`);
301
20
  const dockerEnv = getDockerEnv();
302
21
 
303
- await dockerLogin();
304
-
305
- const reused = await ensureImageFromPipeline();
306
- let ranCompose = false;
307
-
308
- if (!reused) {
309
- const result = await buildFromArtifactContents(artifactDir);
310
- ranCompose = Boolean(result?.ranCompose);
311
- }
312
-
313
- if (ranCompose) {
22
+ const result = await ensureImageReadyForDeploy(artifactDir);
23
+ if (result.ranCompose) {
314
24
  log.success('Docker deployment complete');
315
25
  return;
316
26
  }
317
27
 
318
- await maybePushImage();
319
-
320
- // Keep :latest in sync when deploy uses a version tag
321
- if (imageTag !== 'latest' && fullImage !== latestImage) {
322
- await execa('docker', ['tag', fullImage, latestImage], {
323
- stdio: 'pipe',
324
- env: dockerEnv,
325
- }).catch(() => {});
326
- }
327
-
328
28
  await execa(
329
29
  'docker',
330
30
  ['rm', '-f', config.project],
@@ -4,6 +4,7 @@ import path from 'path';
4
4
  import os from 'os';
5
5
  import { createLogger } from '../../logger/index.js';
6
6
  import { sanitizeK8sName } from '../../utils/kubernetes-manifests.js';
7
+ import { createDockerImageDeployContext } from '../../utils/docker-image-deploy.js';
7
8
 
8
9
  /**
9
10
  * @param {import('../../core/config.js').DeployHubConfig} config
@@ -12,6 +13,7 @@ import { sanitizeK8sName } from '../../utils/kubernetes-manifests.js';
12
13
  */
13
14
  export function createKubernetesProvider(config, envName, env = process.env) {
14
15
  const log = createLogger('kubernetes');
16
+ const imageOps = createDockerImageDeployContext(config, env, log);
15
17
 
16
18
  const kubeconfig = env.KUBECONFIG || path.join(os.homedir(), '.kube', 'config');
17
19
  const context = env.KUBE_CONTEXT || '';
@@ -51,6 +53,14 @@ export function createKubernetesProvider(config, envName, env = process.env) {
51
53
  );
52
54
  }
53
55
 
56
+ log.info(`Ensuring container image ${imageOps.fullImage} is built and pushed before apply...`);
57
+ const imageResult = await imageOps.ensureImageReadyForDeploy(artifactDir);
58
+ if (imageResult.ranCompose) {
59
+ log.warn(
60
+ 'docker compose was used — ensure the cluster can pull the resulting image from your registry.'
61
+ );
62
+ }
63
+
54
64
  const applyTarget = (await fs.pathExists(path.join(manifestDir, 'k8s')))
55
65
  ? path.join(manifestDir, 'k8s')
56
66
  : manifestDir;
@@ -0,0 +1,420 @@
1
+ import { execa } from 'execa';
2
+ import fs from 'fs-extra';
3
+ import path from 'path';
4
+ import { extractArtifact } from '../artifact/engine.js';
5
+ import {
6
+ describeInterpretedBackendGap,
7
+ generateDotnetRuntimeDockerfile,
8
+ generateFrontendRuntimeDockerfile,
9
+ generateGoRuntimeDockerfile,
10
+ generateSpringRuntimeDockerfile,
11
+ isFrontendStaticFramework,
12
+ isInterpretedBackendFramework,
13
+ resolveDockerImageRef,
14
+ } from './docker-image.js';
15
+
16
+ /**
17
+ * Shared Docker image build, reuse, push, and pullability logic used by
18
+ * docker and kubernetes deploy providers.
19
+ *
20
+ * @param {import('../core/config.js').DeployHubConfig} config
21
+ * @param {Record<string, string>} [env]
22
+ * @param {{ info: Function, warn: Function, success: Function }} log
23
+ */
24
+ export function createDockerImageDeployContext(config, env = process.env, log) {
25
+ const { fullImage, latestImage, legacyLatestImage, imageTag } =
26
+ resolveDockerImageRef(config, env);
27
+ const registryUrl = env.DOCKER_REGISTRY_URL || '';
28
+ const registryUser = env.DOCKER_REGISTRY_USERNAME || '';
29
+ const registryToken = env.DOCKER_REGISTRY_TOKEN || '';
30
+ const dockerHost = env.DOCKER_HOST || '';
31
+
32
+ function getDockerEnv() {
33
+ /** @type {Record<string, string>} */
34
+ const dockerEnv = { ...process.env };
35
+ if (dockerHost) dockerEnv.DOCKER_HOST = dockerHost;
36
+ if (env.DOCKER_TLS_VERIFY) dockerEnv.DOCKER_TLS_VERIFY = env.DOCKER_TLS_VERIFY;
37
+ if (env.DOCKER_CERT_PATH) dockerEnv.DOCKER_CERT_PATH = env.DOCKER_CERT_PATH;
38
+ return dockerEnv;
39
+ }
40
+
41
+ function hasRegistryCredentials() {
42
+ return Boolean(registryUser && registryToken);
43
+ }
44
+
45
+ async function dockerLogin() {
46
+ if (!hasRegistryCredentials()) return;
47
+ const registry = registryUrl || 'https://index.docker.io/v1/';
48
+ log.info('Logging in to container registry...');
49
+ await execa(
50
+ 'docker',
51
+ ['login', registry, '-u', registryUser, '--password-stdin'],
52
+ {
53
+ input: registryToken,
54
+ stdio: ['pipe', 'inherit', 'inherit'],
55
+ env: getDockerEnv(),
56
+ }
57
+ );
58
+ }
59
+
60
+ /**
61
+ * Push only when registry credentials are configured. Avoids a noisy failed
62
+ * push to Docker Hub for local-only image names.
63
+ */
64
+ async function maybePushImage() {
65
+ if (!hasRegistryCredentials()) {
66
+ log.info(
67
+ 'docker push skipped (DOCKER_REGISTRY_USERNAME/TOKEN not set — local image only)'
68
+ );
69
+ return;
70
+ }
71
+
72
+ log.info(`Pushing ${fullImage} to registry...`);
73
+ await execa('docker', ['push', fullImage], {
74
+ stdio: 'inherit',
75
+ env: getDockerEnv(),
76
+ });
77
+ log.success(`Pushed ${fullImage}`);
78
+ }
79
+
80
+ /**
81
+ * @param {string} ref
82
+ */
83
+ async function imageExistsLocally(ref) {
84
+ try {
85
+ await execa('docker', ['image', 'inspect', ref], {
86
+ stdio: 'pipe',
87
+ env: getDockerEnv(),
88
+ });
89
+ return true;
90
+ } catch {
91
+ return false;
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Prefer the image already built during the pipeline `docker` stage.
97
+ * Retag when the pipeline used `:latest` and deploy needs a version tag.
98
+ */
99
+ async function ensureImageFromPipeline() {
100
+ if (await imageExistsLocally(fullImage)) {
101
+ log.info(`Reusing existing image ${fullImage}`);
102
+ return true;
103
+ }
104
+
105
+ const candidates = [...new Set([latestImage, legacyLatestImage])].filter(
106
+ (ref) => ref !== fullImage
107
+ );
108
+
109
+ for (const candidate of candidates) {
110
+ if (!(await imageExistsLocally(candidate))) continue;
111
+ log.info(`Re-tagging pipeline image ${candidate} → ${fullImage}`);
112
+ await execa('docker', ['tag', candidate, fullImage], {
113
+ stdio: 'inherit',
114
+ env: getDockerEnv(),
115
+ });
116
+ return true;
117
+ }
118
+
119
+ return false;
120
+ }
121
+
122
+ /**
123
+ * @param {string} buildContext
124
+ * @param {Record<string, unknown>} metadata
125
+ * @param {string} framework
126
+ * @param {number} port
127
+ */
128
+ async function prepareBackendBuildContext(buildContext, metadata, framework, port) {
129
+ if (framework === 'spring') {
130
+ const targetDir = path.join(buildContext, 'target');
131
+ if (await fs.pathExists(targetDir)) {
132
+ const jars = (await fs.readdir(targetDir)).filter((f) => f.endsWith('.jar'));
133
+ if (jars.length > 0) {
134
+ const jarRel = `target/${jars[0]}`;
135
+ log.info(`Building runtime image from pre-built JAR (${jarRel})...`);
136
+ await fs.writeFile(
137
+ path.join(buildContext, 'Dockerfile'),
138
+ generateSpringRuntimeDockerfile(jarRel, port)
139
+ );
140
+ return;
141
+ }
142
+ }
143
+ }
144
+
145
+ if (framework === 'go') {
146
+ const binDir = path.join(buildContext, 'bin');
147
+ if (await fs.pathExists(binDir)) {
148
+ const bins = await fs.readdir(binDir);
149
+ if (bins.length > 0) {
150
+ const binRel = `bin/${bins[0]}`;
151
+ log.info(`Building runtime image from pre-built Go binary (${binRel})...`);
152
+ await fs.writeFile(
153
+ path.join(buildContext, 'Dockerfile'),
154
+ generateGoRuntimeDockerfile(binRel, port)
155
+ );
156
+ return;
157
+ }
158
+ }
159
+ }
160
+
161
+ if (framework === 'dotnet') {
162
+ const publishDir =
163
+ /** @type {string} */ (metadata.buildOutput) ||
164
+ config.buildOutput ||
165
+ 'publish';
166
+ const publishPath = path.join(buildContext, publishDir);
167
+ if (await fs.pathExists(publishPath)) {
168
+ log.info(`Building runtime image from pre-built .NET output (${publishDir}/)...`);
169
+ await fs.writeFile(
170
+ path.join(buildContext, 'Dockerfile'),
171
+ generateDotnetRuntimeDockerfile(publishDir, port)
172
+ );
173
+ return;
174
+ }
175
+ }
176
+
177
+ const dockerfilePath = path.join(buildContext, 'Dockerfile');
178
+ if (!(await fs.pathExists(dockerfilePath))) {
179
+ throw new Error(
180
+ 'No Dockerfile found in artifact and no pipeline image to reuse. ' +
181
+ 'Add a Dockerfile and enable pipeline.docker so the image is built from project source.'
182
+ );
183
+ }
184
+
185
+ if (isInterpretedBackendFramework(framework)) {
186
+ const gap = describeInterpretedBackendGap(framework);
187
+ throw new Error(
188
+ `Cannot rebuild ${gap.ecosystem} backend image "${fullImage}" from the packaged artifact.\n` +
189
+ `Backend artifacts include source/manifests but not ${gap.missing}, ` +
190
+ `so Dockerfiles that run \`${gap.installCmd}\` cannot reliably succeed from the artifact alone.\n\n` +
191
+ 'What to do instead:\n' +
192
+ ' 1. Enable pipeline.docker in deployhub.config.json (default when Docker deploy is selected).\n' +
193
+ ' 2. Run a full `deployhub build` so the image is built from the project root (with full deps).\n' +
194
+ ' 3. Deploy will reuse that local image (retag/push/run) — it will not rebuild from the artifact.\n\n' +
195
+ `Standalone \`deployhub deploy\` without a pre-built local image is not supported for ${gap.ecosystem} backends.`
196
+ );
197
+ }
198
+
199
+ log.warn(
200
+ 'Building backend image from extracted artifact. Prefer pipeline.docker so the image ' +
201
+ 'is built once from full project source, then reused on deploy.'
202
+ );
203
+ }
204
+
205
+ /**
206
+ * @param {string} artifactDir
207
+ */
208
+ async function buildFromArtifactContents(artifactDir) {
209
+ const zipPath = path.join(artifactDir, 'artifact.zip');
210
+ if (!(await fs.pathExists(zipPath))) {
211
+ throw new Error(
212
+ `No local image found for ${fullImage} and no artifact.zip to build from. ` +
213
+ 'Enable pipeline.docker so the image is built from project source, or run deployhub build first.'
214
+ );
215
+ }
216
+
217
+ const buildContext = path.join(artifactDir, '_docker_build');
218
+ await fs.remove(buildContext);
219
+ await extractArtifact(artifactDir, buildContext);
220
+
221
+ try {
222
+ const metadataPath = path.join(buildContext, 'metadata.json');
223
+ const metadata = (await fs.pathExists(metadataPath))
224
+ ? await fs.readJson(metadataPath)
225
+ : {};
226
+
227
+ const projectType = metadata.projectType || config.projectType || 'frontend';
228
+ const framework =
229
+ metadata.framework ||
230
+ config.framework ||
231
+ config.frontend?.framework ||
232
+ '';
233
+ const buildOutput =
234
+ metadata.buildOutput ||
235
+ config.buildOutput ||
236
+ config.frontend?.buildOutput ||
237
+ 'dist';
238
+ const port =
239
+ Number(metadata.port) ||
240
+ config.port ||
241
+ config.backend?.port ||
242
+ 3000;
243
+
244
+ const composePath = path.join(buildContext, 'docker-compose.yml');
245
+ if (await fs.pathExists(composePath)) {
246
+ log.info('Building via docker compose from extracted artifact...');
247
+ await execa('docker', ['compose', 'up', '-d', '--build'], {
248
+ cwd: buildContext,
249
+ stdio: 'inherit',
250
+ env: getDockerEnv(),
251
+ });
252
+ return { ranCompose: true };
253
+ }
254
+
255
+ const isStaticFrontend =
256
+ projectType === 'frontend' || isFrontendStaticFramework(framework);
257
+
258
+ if (isStaticFrontend) {
259
+ const outputDir = path.join(buildContext, buildOutput);
260
+ if (!(await fs.pathExists(outputDir))) {
261
+ throw new Error(
262
+ `Frontend artifact is missing build output "${buildOutput}". ` +
263
+ 'Cannot build a runtime image from this artifact.'
264
+ );
265
+ }
266
+ log.info(
267
+ `Building runtime image from pre-built ${buildOutput}/ (no source rebuild)...`
268
+ );
269
+ await fs.writeFile(
270
+ path.join(buildContext, 'Dockerfile'),
271
+ generateFrontendRuntimeDockerfile(buildOutput)
272
+ );
273
+ } else {
274
+ await prepareBackendBuildContext(buildContext, metadata, framework, port);
275
+ }
276
+
277
+ await execa('docker', ['build', '-t', fullImage, '.'], {
278
+ cwd: buildContext,
279
+ stdio: 'inherit',
280
+ env: getDockerEnv(),
281
+ });
282
+ return { ranCompose: false };
283
+ } finally {
284
+ await fs.remove(buildContext).catch(() => {});
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Ensure a deployable image exists locally, then push when credentials are set.
290
+ * @param {string} artifactDir
291
+ * @param {{ skipPush?: boolean }} [options]
292
+ */
293
+ async function ensureImageReadyForDeploy(artifactDir, options = {}) {
294
+ await dockerLogin();
295
+
296
+ const reused = await ensureImageFromPipeline();
297
+ let ranCompose = false;
298
+
299
+ if (!reused) {
300
+ const result = await buildFromArtifactContents(artifactDir);
301
+ ranCompose = Boolean(result?.ranCompose);
302
+ }
303
+
304
+ if (ranCompose) {
305
+ return { ranCompose: true };
306
+ }
307
+
308
+ if (!options.skipPush) {
309
+ await maybePushImage();
310
+ }
311
+
312
+ const dockerEnv = getDockerEnv();
313
+ if (imageTag !== 'latest' && fullImage !== latestImage) {
314
+ await execa('docker', ['tag', fullImage, latestImage], {
315
+ stdio: 'pipe',
316
+ env: dockerEnv,
317
+ }).catch(() => {});
318
+ }
319
+
320
+ return { ranCompose: false };
321
+ }
322
+
323
+ return {
324
+ fullImage,
325
+ latestImage,
326
+ imageTag,
327
+ getDockerEnv,
328
+ hasRegistryCredentials,
329
+ dockerLogin,
330
+ maybePushImage,
331
+ ensureImageFromPipeline,
332
+ buildFromArtifactContents,
333
+ ensureImageReadyForDeploy,
334
+ };
335
+ }
336
+
337
+ /**
338
+ * Verify that an image:tag exists in a registry and is accessible with current credentials.
339
+ *
340
+ * @param {import('../core/config.js').DeployHubConfig} config
341
+ * @param {Record<string, string>} [env]
342
+ * @returns {Promise<{ ok: boolean, message: string }>}
343
+ */
344
+ export async function checkImagePullability(config, env = process.env) {
345
+ const { fullImage } = resolveDockerImageRef(config, env);
346
+ const registryUser = env.DOCKER_REGISTRY_USERNAME || '';
347
+ const registryToken = env.DOCKER_REGISTRY_TOKEN || '';
348
+ const registryUrl = env.DOCKER_REGISTRY_URL || '';
349
+ const dockerHost = env.DOCKER_HOST || '';
350
+
351
+ /** @type {Record<string, string>} */
352
+ const dockerEnv = { ...process.env };
353
+ if (dockerHost) dockerEnv.DOCKER_HOST = dockerHost;
354
+ if (env.DOCKER_TLS_VERIFY) dockerEnv.DOCKER_TLS_VERIFY = env.DOCKER_TLS_VERIFY;
355
+ if (env.DOCKER_CERT_PATH) dockerEnv.DOCKER_CERT_PATH = env.DOCKER_CERT_PATH;
356
+
357
+ if (registryUser && registryToken) {
358
+ const registry = registryUrl || 'https://index.docker.io/v1/';
359
+ try {
360
+ await execa(
361
+ 'docker',
362
+ ['login', registry, '-u', registryUser, '--password-stdin'],
363
+ {
364
+ input: registryToken,
365
+ stdio: ['pipe', 'pipe', 'pipe'],
366
+ env: dockerEnv,
367
+ }
368
+ );
369
+ } catch (err) {
370
+ const msg = err instanceof Error ? err.message : String(err);
371
+ return {
372
+ ok: false,
373
+ message:
374
+ `Image ${fullImage} is not pullable — registry login failed (${msg}). ` +
375
+ 'Check DOCKER_REGISTRY_USERNAME and DOCKER_REGISTRY_TOKEN.',
376
+ };
377
+ }
378
+ }
379
+
380
+ try {
381
+ await execa('docker', ['manifest', 'inspect', fullImage], {
382
+ stdio: 'pipe',
383
+ env: dockerEnv,
384
+ });
385
+ return { ok: true, message: `Image ${fullImage} is pullable` };
386
+ } catch (err) {
387
+ const stderr =
388
+ err instanceof Error && 'stderr' in err
389
+ ? String(/** @type {{ stderr?: string }} */ (err).stderr || '')
390
+ : '';
391
+ const combined = `${err instanceof Error ? err.message : String(err)} ${stderr}`.toLowerCase();
392
+
393
+ let hint = '';
394
+ if (combined.includes('unauthorized') || combined.includes('authentication required')) {
395
+ hint =
396
+ 'The image may exist but is private and inaccessible with your current credentials. ' +
397
+ 'Set DOCKER_REGISTRY_USERNAME and DOCKER_REGISTRY_TOKEN, or configure KUBE_IMAGE_PULL_SECRET for the cluster.';
398
+ } else if (combined.includes('not found') || combined.includes('manifest unknown')) {
399
+ hint =
400
+ 'The image does not exist in the registry yet.';
401
+ } else if (combined.includes('denied')) {
402
+ hint = 'Access denied — check registry credentials and repository permissions.';
403
+ } else {
404
+ hint = 'Verify the image name/tag and registry connectivity.';
405
+ }
406
+
407
+ const hasCreds = Boolean(registryUser && registryToken);
408
+
409
+ return {
410
+ ok: false,
411
+ message:
412
+ `Image ${fullImage} is not pullable. ${hint}\n` +
413
+ (hasCreds
414
+ ? ' DeployHub will build and push this image automatically during your next `deployhub build`.\n'
415
+ : ' If you have DOCKER_REGISTRY_USERNAME/DOCKER_REGISTRY_TOKEN configured, DeployHub will build and push this image automatically during your next `deployhub build`.\n' +
416
+ ' If not, either configure those variables, or push the image manually before deploying:\n') +
417
+ ` docker push ${fullImage}`,
418
+ };
419
+ }
420
+ }
@@ -3,7 +3,8 @@ import path from 'path';
3
3
  import { getWorkflowHeaderComment } from './author.js';
4
4
  import {
5
5
  generateDeploymentEnvSection,
6
- getDeploymentSecretKeys,
6
+ getDeploymentWorkflowSecretKeys,
7
+ getDeploymentSecretChecklistItems,
7
8
  DEPLOYMENT_ENV_KEYS,
8
9
  } from '../deployment/deployment-env.js';
9
10
 
@@ -287,7 +288,7 @@ export function generateWorkflowYaml(
287
288
  const env = environments[envName];
288
289
  if (!env) continue;
289
290
 
290
- const keys = getDeploymentSecretKeys(env.type, config);
291
+ const keys = getDeploymentWorkflowSecretKeys(env.type, config);
291
292
  for (const key of keys) {
292
293
  envVars.add(`${key}: \${{ secrets.${key} }}`);
293
294
  }
@@ -438,10 +439,12 @@ export async function guessCliGithubRepo(cwd = process.cwd()) {
438
439
  }
439
440
 
440
441
  /**
442
+ * Flat list of required secret key names (for callers that only need keys).
441
443
  * @param {string[]} storageProviders
442
444
  * @param {string[]} deployEnvironments
443
445
  * @param {Record<string, { type: string }>} environments
444
446
  * @param {import('../core/config.js').DeployHubConfig} [config]
447
+ * @param {string|null} [cliSource]
445
448
  * @returns {string[]}
446
449
  */
447
450
  export function getRequiredSecrets(
@@ -451,28 +454,69 @@ export function getRequiredSecrets(
451
454
  config = null,
452
455
  cliSource = null
453
456
  ) {
454
- /** @type {Set<string>} */
455
- const secrets = new Set();
457
+ return getGithubSecretsChecklist(
458
+ storageProviders,
459
+ deployEnvironments,
460
+ environments,
461
+ config,
462
+ cliSource
463
+ )
464
+ .filter((item) => item.required)
465
+ .map((item) => item.key);
466
+ }
467
+
468
+ /**
469
+ * Labeled GitHub Secrets checklist (required + optional) for post-init output.
470
+ * @param {string[]} storageProviders
471
+ * @param {string[]} deployEnvironments
472
+ * @param {Record<string, { type: string }>} environments
473
+ * @param {import('../core/config.js').DeployHubConfig} [config]
474
+ * @param {string|null} [cliSource]
475
+ * @returns {import('../deployment/deployment-env.js').SecretChecklistItem[]}
476
+ */
477
+ export function getGithubSecretsChecklist(
478
+ storageProviders,
479
+ deployEnvironments,
480
+ environments,
481
+ config = null,
482
+ cliSource = null
483
+ ) {
484
+ /** @type {Map<string, import('../deployment/deployment-env.js').SecretChecklistItem>} */
485
+ const byKey = new Map();
456
486
 
457
487
  const resolvedCliSource = cliSource || config?.cli?.source;
458
488
  if (isGithubCliSource(resolvedCliSource)) {
459
- secrets.add(GITHUB_CLI_TOKEN_SECRET);
489
+ byKey.set(GITHUB_CLI_TOKEN_SECRET, {
490
+ key: GITHUB_CLI_TOKEN_SECRET,
491
+ required: true,
492
+ note: 'required when installing DeployHub CLI from a private GitHub repo',
493
+ });
460
494
  }
461
495
 
462
496
  for (const provider of storageProviders) {
463
497
  const keys = PROVIDER_ENV_MAP[provider] || [];
464
- keys.forEach((k) => secrets.add(k));
498
+ for (const key of keys) {
499
+ byKey.set(key, { key, required: true });
500
+ }
465
501
  }
466
502
 
467
503
  for (const envName of deployEnvironments) {
468
504
  const env = environments[envName];
469
- if (!env) continue;
505
+ if (!env?.type) continue;
470
506
 
471
- const keys = getDeploymentSecretKeys(env.type, config);
472
- keys.forEach((k) => secrets.add(k));
507
+ for (const item of getDeploymentSecretChecklistItems(env.type, config)) {
508
+ const existing = byKey.get(item.key);
509
+ if (existing) {
510
+ if (item.required && !existing.required) {
511
+ byKey.set(item.key, item);
512
+ }
513
+ continue;
514
+ }
515
+ byKey.set(item.key, item);
516
+ }
473
517
  }
474
518
 
475
- return Array.from(secrets);
519
+ return Array.from(byKey.values());
476
520
  }
477
521
 
478
522
  /**