@akash-chowdhury-24/deployhub 2.0.4 → 2.0.6

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/README.md CHANGED
@@ -185,6 +185,8 @@ The wizard asks the same core questions for every setup:
185
185
  - `.github/workflows/deployhub.yml` — CI pipeline
186
186
  - `.env.example` — list of env vars you may need
187
187
  - `nginx.conf` — auto-generated if frontend deploys to SSH
188
+ - `Dockerfile` — auto-generated if missing and you chose Docker or Kubernetes deploy (your existing `Dockerfile` is never overwritten)
189
+ - `k8s/deployment.yaml` and `k8s/service.yaml` — auto-generated if missing and you chose Kubernetes deploy (existing manifests are never overwritten)
188
190
 
189
191
  ---
190
192
 
@@ -596,7 +598,7 @@ DeployHub supports six deployment targets. Pick based on what infrastructure you
596
598
  | **ec2** | AWS users with an existing EC2 instance | Running EC2 instance, security group, key pair |
597
599
  | **azure-vm** | Azure users with an existing virtual machine | Running Azure VM, NSG allowing SSH |
598
600
  | **gcp-vm** | GCP users with an existing Compute Engine VM | Running VM, firewall rule for SSH, metadata SSH key |
599
- | **kubernetes** | Teams with an existing K8s cluster | Cluster, kubectl access, manifests in repo |
601
+ | **kubernetes** | Teams with an existing K8s cluster | Cluster, kubectl access; manifests auto-generated if missing |
600
602
 
601
603
  ---
602
604
 
@@ -675,10 +677,11 @@ DeployHub detects whether the server uses Debian-style `sites-available` or RHEL
675
677
 
676
678
  **Prerequisites:**
677
679
  - [ ] Docker installed (`docker --version` works)
678
- - [ ] `Dockerfile` or `docker-compose.yml` in project
679
680
  - [ ] Registry account if pushing private images
681
+ - [ ] `docker-compose.yml` in project if you use multi-service Compose (not auto-generated)
680
682
 
681
683
  **What DeployHub automates:**
684
+ - Starter `Dockerfile` at project root when none exists (framework-aware; skipped if you already have one)
682
685
  - `.env.example` for image name, registry, remote `DOCKER_HOST`
683
686
  - Docker daemon connectivity test during `init`
684
687
  - `docker compose up` or build/push/run during deploy
@@ -812,11 +815,12 @@ DeployHub detects whether the server uses Debian-style `sites-available` or RHEL
812
815
 
813
816
  **Prerequisites:**
814
817
  - [ ] Existing Kubernetes cluster (DeployHub does not provision clusters)
815
- - [ ] `kubectl` installed and configured
816
- - [ ] Kubernetes manifests (`.yaml` or `k8s/` directory) in your repo
818
+ - [ ] `kubectl` installed and configured on your **local machine** (for `deployhub doctor` / manual `deployhub deploy`)
817
819
  - [ ] Cluster reachable from CI (kubeconfig secret or cloud auth)
818
820
 
819
821
  **What DeployHub automates:**
822
+ - Starter `k8s/deployment.yaml` and `k8s/service.yaml` when no manifests exist (skipped if you already have a `k8s/` directory or root-level Kubernetes YAML files)
823
+ - GitHub Actions installs `kubectl` on the CI runner and writes kubeconfig from secrets (no local `kubectl` required for the automated push-to-main deploy path)
820
824
  - Lists `kubectl` contexts during `init` for easy selection
821
825
  - Auto-detects `~/.kube/config`
822
826
  - Complete `.env.example` for kubeconfig, context, namespace
@@ -835,6 +839,7 @@ DeployHub detects whether the server uses Debian-style `sites-available` or RHEL
835
839
  | `KUBE_CONTEXT` | Context name | `my-cluster` | `kubectl config get-contexts` |
836
840
  | `KUBE_NAMESPACE` | Target namespace | `my-app` | `kubectl get namespaces` |
837
841
  | `DOCKER_IMAGE_NAME` | Container image | `ghcr.io/org/app` | Your registry |
842
+ | `DOCKER_IMAGE_TAG` | Image tag | `1.0.0` or `latest` | Project version or your choice |
838
843
  | `KUBE_IMAGE_PULL_SECRET` | Pull secret name | `regcred` | `kubectl create secret docker-registry` |
839
844
 
840
845
  ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akash-chowdhury-24/deployhub",
3
- "version": "2.0.4",
3
+ "version": "2.0.6",
4
4
  "description": "Zero-configuration deployment and artifact manager",
5
5
  "type": "module",
6
6
  "main": "./src/cli/index.js",
@@ -2,6 +2,7 @@ import { execa } from 'execa';
2
2
  import fs from 'fs-extra';
3
3
  import path from 'path';
4
4
  import { createLogger } from '../logger/index.js';
5
+ import { resolveDockerImageRef } from '../utils/docker-image.js';
5
6
 
6
7
  function create(config, cwd) {
7
8
  const log = createLogger('dotnet');
@@ -28,11 +29,17 @@ function create(config, cwd) {
28
29
  },
29
30
 
30
31
  async docker() {
31
- if (await fs.pathExists(path.join(cwd, 'Dockerfile'))) {
32
- await execa('docker', ['build', '-t', `${config.project}:latest`, '.'], {
33
- cwd,
34
- stdio: 'inherit',
35
- });
32
+ if (!(await fs.pathExists(path.join(cwd, 'Dockerfile')))) {
33
+ return;
34
+ }
35
+ const { fullImage, latestImage } = resolveDockerImageRef(config);
36
+ log.info(`Building Docker image (${fullImage})...`);
37
+ await execa('docker', ['build', '-t', fullImage, '.'], {
38
+ cwd,
39
+ stdio: 'inherit',
40
+ });
41
+ if (fullImage !== latestImage) {
42
+ await execa('docker', ['tag', fullImage, latestImage], { stdio: 'pipe' });
36
43
  }
37
44
  },
38
45
  };
@@ -2,6 +2,7 @@ import { execa } from 'execa';
2
2
  import fs from 'fs-extra';
3
3
  import path from 'path';
4
4
  import { createLogger } from '../logger/index.js';
5
+ import { resolveDockerImageRef } from '../utils/docker-image.js';
5
6
 
6
7
  function create(config, cwd) {
7
8
  const log = createLogger('go');
@@ -27,11 +28,17 @@ function create(config, cwd) {
27
28
  },
28
29
 
29
30
  async docker() {
30
- if (await fs.pathExists(path.join(cwd, 'Dockerfile'))) {
31
- await execa('docker', ['build', '-t', `${config.project}:latest`, '.'], {
32
- cwd,
33
- stdio: 'inherit',
34
- });
31
+ if (!(await fs.pathExists(path.join(cwd, 'Dockerfile')))) {
32
+ return;
33
+ }
34
+ const { fullImage, latestImage } = resolveDockerImageRef(config);
35
+ log.info(`Building Docker image (${fullImage})...`);
36
+ await execa('docker', ['build', '-t', fullImage, '.'], {
37
+ cwd,
38
+ stdio: 'inherit',
39
+ });
40
+ if (fullImage !== latestImage) {
41
+ await execa('docker', ['tag', fullImage, latestImage], { stdio: 'pipe' });
35
42
  }
36
43
  },
37
44
  };
@@ -2,6 +2,7 @@ import { execa } from 'execa';
2
2
  import fs from 'fs-extra';
3
3
  import path from 'path';
4
4
  import { createLogger } from '../logger/index.js';
5
+ import { resolveDockerImageRef } from '../utils/docker-image.js';
5
6
 
6
7
  function create(config, cwd) {
7
8
  const log = createLogger('java');
@@ -33,11 +34,17 @@ function create(config, cwd) {
33
34
  },
34
35
 
35
36
  async docker() {
36
- if (await fs.pathExists(path.join(cwd, 'Dockerfile'))) {
37
- await execa('docker', ['build', '-t', `${config.project}:latest`, '.'], {
38
- cwd,
39
- stdio: 'inherit',
40
- });
37
+ if (!(await fs.pathExists(path.join(cwd, 'Dockerfile')))) {
38
+ return;
39
+ }
40
+ const { fullImage, latestImage } = resolveDockerImageRef(config);
41
+ log.info(`Building Docker image (${fullImage})...`);
42
+ await execa('docker', ['build', '-t', fullImage, '.'], {
43
+ cwd,
44
+ stdio: 'inherit',
45
+ });
46
+ if (fullImage !== latestImage) {
47
+ await execa('docker', ['tag', fullImage, latestImage], { stdio: 'pipe' });
41
48
  }
42
49
  },
43
50
  };
@@ -2,6 +2,7 @@ import { execa } from 'execa';
2
2
  import fs from 'fs-extra';
3
3
  import path from 'path';
4
4
  import { createLogger } from '../logger/index.js';
5
+ import { resolveDockerImageRef } from '../utils/docker-image.js';
5
6
 
6
7
  /**
7
8
  * @typedef {Object} LanguageAdapter
@@ -64,12 +65,18 @@ function create(config, cwd) {
64
65
  log.warn('No Dockerfile found, skipping docker build');
65
66
  return;
66
67
  }
67
- log.info('Building Docker image...');
68
- const imageName = `${config.project}:latest`;
69
- await execa('docker', ['build', '-t', imageName, '.'], {
68
+ const { fullImage, latestImage } = resolveDockerImageRef(config);
69
+ log.info(`Building Docker image (${fullImage})...`);
70
+ await execa('docker', ['build', '-t', fullImage, '.'], {
70
71
  cwd,
71
72
  stdio: 'inherit',
72
73
  });
74
+ if (fullImage !== latestImage) {
75
+ await execa('docker', ['tag', fullImage, latestImage], {
76
+ cwd,
77
+ stdio: 'pipe',
78
+ });
79
+ }
73
80
  },
74
81
  };
75
82
  }
@@ -2,6 +2,7 @@ import { execa } from 'execa';
2
2
  import fs from 'fs-extra';
3
3
  import path from 'path';
4
4
  import { createLogger } from '../logger/index.js';
5
+ import { resolveDockerImageRef } from '../utils/docker-image.js';
5
6
 
6
7
  function create(config, cwd) {
7
8
  const log = createLogger('php');
@@ -30,11 +31,17 @@ function create(config, cwd) {
30
31
  },
31
32
 
32
33
  async docker() {
33
- if (await fs.pathExists(path.join(cwd, 'Dockerfile'))) {
34
- await execa('docker', ['build', '-t', `${config.project}:latest`, '.'], {
35
- cwd,
36
- stdio: 'inherit',
37
- });
34
+ if (!(await fs.pathExists(path.join(cwd, 'Dockerfile')))) {
35
+ return;
36
+ }
37
+ const { fullImage, latestImage } = resolveDockerImageRef(config);
38
+ log.info(`Building Docker image (${fullImage})...`);
39
+ await execa('docker', ['build', '-t', fullImage, '.'], {
40
+ cwd,
41
+ stdio: 'inherit',
42
+ });
43
+ if (fullImage !== latestImage) {
44
+ await execa('docker', ['tag', fullImage, latestImage], { stdio: 'pipe' });
38
45
  }
39
46
  },
40
47
  };
@@ -2,6 +2,7 @@ import { execa } from 'execa';
2
2
  import fs from 'fs-extra';
3
3
  import path from 'path';
4
4
  import { createLogger } from '../logger/index.js';
5
+ import { resolveDockerImageRef } from '../utils/docker-image.js';
5
6
 
6
7
  function create(config, cwd) {
7
8
  const log = createLogger('python');
@@ -43,10 +44,15 @@ function create(config, cwd) {
43
44
  log.warn('No Dockerfile found, skipping');
44
45
  return;
45
46
  }
46
- await execa('docker', ['build', '-t', `${config.project}:latest`, '.'], {
47
+ const { fullImage, latestImage } = resolveDockerImageRef(config);
48
+ log.info(`Building Docker image (${fullImage})...`);
49
+ await execa('docker', ['build', '-t', fullImage, '.'], {
47
50
  cwd,
48
51
  stdio: 'inherit',
49
52
  });
53
+ if (fullImage !== latestImage) {
54
+ await execa('docker', ['tag', fullImage, latestImage], { stdio: 'pipe' });
55
+ }
50
56
  },
51
57
  };
52
58
  }
@@ -2,6 +2,7 @@ import { execa } from 'execa';
2
2
  import fs from 'fs-extra';
3
3
  import path from 'path';
4
4
  import { createLogger } from '../logger/index.js';
5
+ import { resolveDockerImageRef } from '../utils/docker-image.js';
5
6
 
6
7
  /**
7
8
  * @param {import('../core/config.js').DeployHubConfig} config
@@ -54,11 +55,15 @@ function create(config, cwd) {
54
55
  log.warn('No Dockerfile found, skipping docker build');
55
56
  return;
56
57
  }
57
- log.info('Building Docker image...');
58
- await execa('docker', ['build', '-t', `${config.project}:latest`, '.'], {
58
+ const { fullImage, latestImage } = resolveDockerImageRef(config);
59
+ log.info(`Building Docker image (${fullImage})...`);
60
+ await execa('docker', ['build', '-t', fullImage, '.'], {
59
61
  cwd,
60
62
  stdio: 'inherit',
61
63
  });
64
+ if (fullImage !== latestImage) {
65
+ await execa('docker', ['tag', fullImage, latestImage], { stdio: 'pipe' });
66
+ }
62
67
  },
63
68
  };
64
69
  }
@@ -6,6 +6,11 @@ import { createLogger } from '../logger/index.js';
6
6
  import { generateChecksums, formatChecksums } from '../utils/checksums.js';
7
7
  import { getProjectVersion } from '../utils/version.js';
8
8
  import { generateNginxConfig } from '../utils/nginx.js';
9
+ import {
10
+ ensureDeployScaffold,
11
+ copyKubernetesManifestsIfPresent,
12
+ copyDeployAssetsToArtifactDir,
13
+ } from '../utils/scaffold.js';
9
14
  import { getGeneratedByMetadata, getArtifactReadmeFooter } from '../utils/author.js';
10
15
 
11
16
  /**
@@ -143,6 +148,8 @@ async function stageFrontendArtifact(cwd, stagingDir, config) {
143
148
  await copyIfExists(cwd, stagingDir, file);
144
149
  }
145
150
 
151
+ await copyKubernetesManifestsIfPresent(cwd, stagingDir);
152
+
146
153
  const hasSshDeploy = (config.deploy || []).some(
147
154
  (envName) => config.environments[envName]?.type === 'ssh'
148
155
  );
@@ -172,6 +179,8 @@ async function stageBackendArtifact(cwd, stagingDir, config) {
172
179
  await copyIfExists(cwd, stagingDir, file);
173
180
  }
174
181
 
182
+ await copyKubernetesManifestsIfPresent(cwd, stagingDir);
183
+
175
184
  await copyDirectoryIfExists(cwd, stagingDir, 'config');
176
185
  await copyDirectoryIfExists(cwd, stagingDir, 'migrations');
177
186
 
@@ -259,6 +268,8 @@ export async function createArtifact(config, deployedTargets = [], cwd = process
259
268
 
260
269
  log.info(`Staging ${projectType} artifact...`);
261
270
 
271
+ await ensureDeployScaffold(cwd, config, config.environments || {}, { silent: false });
272
+
262
273
  if (projectType === 'both') {
263
274
  await stageFrontendArtifact(cwd, stagingDir, config);
264
275
  const backendStaging = path.join(stagingDir, 'backend');
@@ -337,6 +348,8 @@ ${getArtifactReadmeFooter()}`;
337
348
  const zipPath = path.join(artifactDir, 'artifact.zip');
338
349
  await createZip(stagingDir, zipPath);
339
350
 
351
+ await copyDeployAssetsToArtifactDir(stagingDir, artifactDir);
352
+
340
353
  const checksums = await generateChecksums(stagingDir);
341
354
  const checksumContent = formatChecksums(checksums);
342
355
  await fs.writeFile(path.join(artifactDir, 'checksums.txt'), checksumContent);
@@ -20,6 +20,10 @@ import {
20
20
  } from '../utils/github-actions.js';
21
21
  import { printAuthorFooter } from '../utils/author.js';
22
22
  import { generateNginxConfig } from '../utils/nginx.js';
23
+ import {
24
+ ensureDockerfile,
25
+ ensureKubernetesManifests,
26
+ } from '../utils/scaffold.js';
23
27
  import {
24
28
  promptServerDeployment,
25
29
  buildServerEnvEntry,
@@ -190,6 +194,24 @@ async function generateProjectScaffold(config, environments, cwd) {
190
194
  await fs.writeFile(path.join(cwd, 'nginx.conf'), nginxConf);
191
195
  console.log(chalk.gray(' • nginx.conf (auto-generated)'));
192
196
  }
197
+
198
+ const dockerResult = await ensureDockerfile(cwd, config);
199
+ if (dockerResult.generated) {
200
+ console.log(chalk.gray(' • Dockerfile (auto-generated)'));
201
+ }
202
+ if (dockerResult.dockerignoreGenerated) {
203
+ console.log(chalk.gray(' • .dockerignore (auto-generated)'));
204
+ }
205
+
206
+ const k8sResult = await ensureKubernetesManifests(cwd, config, environments);
207
+ if (k8sResult.generated) {
208
+ console.log(chalk.gray(' • k8s/deployment.yaml, k8s/service.yaml (auto-generated)'));
209
+ }
210
+
211
+ return {
212
+ dockerfileGenerated: dockerResult.generated,
213
+ kubernetesGenerated: k8sResult.generated,
214
+ };
193
215
  }
194
216
 
195
217
  /**
@@ -397,7 +419,7 @@ export function registerInitCommand(program) {
397
419
  }
398
420
 
399
421
  const version = await getProjectVersion(cwd);
400
- const hasDocker =
422
+ let hasDocker =
401
423
  (detectedFrontend?.hasDocker || detectedBackend?.hasDocker) ?? false;
402
424
 
403
425
  /** @type {Record<string, unknown>} */
@@ -458,7 +480,14 @@ export function registerInitCommand(program) {
458
480
  config
459
481
  );
460
482
 
461
- await generateProjectScaffold(config, environments, cwd);
483
+ const scaffoldResult = await generateProjectScaffold(config, environments, cwd);
484
+
485
+ if (scaffoldResult?.dockerfileGenerated) {
486
+ hasDocker = true;
487
+ config.docker = true;
488
+ config.pipeline.docker = true;
489
+ await saveConfig(config, cwd);
490
+ }
462
491
 
463
492
  const envExampleDest = path.join(cwd, '.env.example');
464
493
  const envExampleContent = generateEnvExampleContent(
@@ -6,6 +6,7 @@ import { deployToAll } from '../deployment/index.js';
6
6
  import { sendNotifications } from '../notifications/index.js';
7
7
  import axios from 'axios';
8
8
  import { getProjectVersion } from '../utils/version.js';
9
+ import { ensureDeployScaffold } from '../utils/scaffold.js';
9
10
 
10
11
  /**
11
12
  * @param {import('../core/config.js').DeployHubConfig} config
@@ -40,6 +41,20 @@ export function buildPipelineStages(config, cwd, state) {
40
41
  ctx.config.port = detected.port;
41
42
  }
42
43
  }
44
+ // Resolve version before docker so pipeline build and deploy share the same tag
45
+ ctx.config.version = await getProjectVersion(ctx.cwd);
46
+ const scaffold = await ensureDeployScaffold(
47
+ ctx.cwd,
48
+ ctx.config,
49
+ ctx.config.environments || {},
50
+ { silent: false }
51
+ );
52
+ if (scaffold.dockerfile) {
53
+ ctx.config.docker = true;
54
+ if (ctx.config.pipeline) {
55
+ ctx.config.pipeline.docker = true;
56
+ }
57
+ }
43
58
  ctx.state.framework = ctx.config.framework;
44
59
  ctx.state.projectType = ctx.config.projectType || 'frontend';
45
60
  },
@@ -105,7 +120,9 @@ export function buildPipelineStages(config, cwd, state) {
105
120
  name: 'artifact',
106
121
  enabled: (ctx) => ctx.config.artifact !== false,
107
122
  async run(ctx) {
108
- ctx.config.version = await getProjectVersion(ctx.cwd);
123
+ if (!ctx.config.version) {
124
+ ctx.config.version = await getProjectVersion(ctx.cwd);
125
+ }
109
126
  const result = await createArtifact(
110
127
  ctx.config,
111
128
  /** @type {string[]} */ (ctx.state.deployedTargets || []),
@@ -323,6 +323,15 @@ export const DEPLOYMENT_ENV_DEFS = {
323
323
  example: 'ghcr.io/myorg/myapp',
324
324
  when: 'optional',
325
325
  },
326
+ {
327
+ key: 'DOCKER_IMAGE_TAG',
328
+ optionalReason: 'defaults to your project version, then "latest" if unset',
329
+ comment: [
330
+ 'Image tag written into generated manifests and used at deploy time.',
331
+ ],
332
+ example: 'latest',
333
+ when: 'optional',
334
+ },
326
335
  {
327
336
  key: 'KUBE_IMAGE_PULL_SECRET',
328
337
  optionalReason: 'only required when pulling from a private container registry',
@@ -379,7 +388,14 @@ export function getDeploymentSecretKeys(deployType, config = null) {
379
388
 
380
389
  for (const d of defs) {
381
390
  if (d.when === 'backend' && !isBackend) continue;
382
- if (d.when === 'optional') 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);
396
+ }
397
+ continue;
398
+ }
383
399
  if (d.key === 'SSH_KEY_PATH') {
384
400
  keys.push('SSH_KEY');
385
401
  continue;
@@ -503,13 +519,16 @@ export const DEPLOYMENT_GUIDE = {
503
519
  ],
504
520
  automates: [
505
521
  'Generates config, workflow, and .env.example for registry and image settings.',
522
+ 'Generates a starter Dockerfile and .dockerignore when missing.',
506
523
  'Tests Docker daemon connectivity during init.',
507
- 'Builds and runs containers via docker compose during deploy.',
524
+ 'Builds the image once during the pipeline docker stage, then reuses it on deploy.',
508
525
  ],
509
526
  after: [
510
- 'Copy .env.example to .env and set DOCKER_IMAGE_NAME (and registry creds if private).',
511
- 'If using a remote Docker host, set DOCKER_HOST and TLS cert paths in .env.',
512
- 'Add the same values as GitHub Secrets for CI.',
527
+ 'Copy .env.example to .env and set DOCKER_IMAGE_NAME (required e.g. myuser/myapp).',
528
+ 'Optional in .env: DOCKER_IMAGE_TAG, DOCKER_REGISTRY_URL, DOCKER_HOST, DOCKER_TLS_VERIFY, DOCKER_CERT_PATH.',
529
+ '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.',
513
532
  'Run deployhub doctor to verify Docker is reachable.',
514
533
  'git push origin main to trigger your first deployment.',
515
534
  ],