@akash-chowdhury-24/deployhub 2.0.5 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akash-chowdhury-24/deployhub",
3
- "version": "2.0.5",
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
  }
@@ -199,6 +199,9 @@ async function generateProjectScaffold(config, environments, cwd) {
199
199
  if (dockerResult.generated) {
200
200
  console.log(chalk.gray(' • Dockerfile (auto-generated)'));
201
201
  }
202
+ if (dockerResult.dockerignoreGenerated) {
203
+ console.log(chalk.gray(' • .dockerignore (auto-generated)'));
204
+ }
202
205
 
203
206
  const k8sResult = await ensureKubernetesManifests(cwd, config, environments);
204
207
  if (k8sResult.generated) {
@@ -41,6 +41,8 @@ export function buildPipelineStages(config, cwd, state) {
41
41
  ctx.config.port = detected.port;
42
42
  }
43
43
  }
44
+ // Resolve version before docker so pipeline build and deploy share the same tag
45
+ ctx.config.version = await getProjectVersion(ctx.cwd);
44
46
  const scaffold = await ensureDeployScaffold(
45
47
  ctx.cwd,
46
48
  ctx.config,
@@ -118,7 +120,9 @@ export function buildPipelineStages(config, cwd, state) {
118
120
  name: 'artifact',
119
121
  enabled: (ctx) => ctx.config.artifact !== false,
120
122
  async run(ctx) {
121
- ctx.config.version = await getProjectVersion(ctx.cwd);
123
+ if (!ctx.config.version) {
124
+ ctx.config.version = await getProjectVersion(ctx.cwd);
125
+ }
122
126
  const result = await createArtifact(
123
127
  ctx.config,
124
128
  /** @type {string[]} */ (ctx.state.deployedTargets || []),
@@ -388,7 +388,14 @@ export function getDeploymentSecretKeys(deployType, config = null) {
388
388
 
389
389
  for (const d of defs) {
390
390
  if (d.when === 'backend' && !isBackend) continue;
391
- 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
+ }
392
399
  if (d.key === 'SSH_KEY_PATH') {
393
400
  keys.push('SSH_KEY');
394
401
  continue;
@@ -512,13 +519,16 @@ export const DEPLOYMENT_GUIDE = {
512
519
  ],
513
520
  automates: [
514
521
  'Generates config, workflow, and .env.example for registry and image settings.',
522
+ 'Generates a starter Dockerfile and .dockerignore when missing.',
515
523
  'Tests Docker daemon connectivity during init.',
516
- 'Builds and runs containers via docker compose during deploy.',
524
+ 'Builds the image once during the pipeline docker stage, then reuses it on deploy.',
517
525
  ],
518
526
  after: [
519
- 'Copy .env.example to .env and set DOCKER_IMAGE_NAME (and registry creds if private).',
520
- 'If using a remote Docker host, set DOCKER_HOST and TLS cert paths in .env.',
521
- '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.',
522
532
  'Run deployhub doctor to verify Docker is reachable.',
523
533
  'git push origin main to trigger your first deployment.',
524
534
  ],
@@ -2,6 +2,17 @@ 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 { 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';
5
16
 
6
17
  /**
7
18
  * @param {import('../../core/config.js').DeployHubConfig} config
@@ -11,8 +22,8 @@ import { createLogger } from '../../logger/index.js';
11
22
  export function createDockerProvider(config, envName, env = process.env) {
12
23
  const log = createLogger('docker');
13
24
 
14
- const imageName = env.DOCKER_IMAGE_NAME || config.project;
15
- const imageTag = env.DOCKER_IMAGE_TAG || config.version || 'latest';
25
+ const { fullImage, latestImage, legacyLatestImage, imageTag } =
26
+ resolveDockerImageRef(config, env);
16
27
  const registryUrl = env.DOCKER_REGISTRY_URL || '';
17
28
  const registryUser = env.DOCKER_REGISTRY_USERNAME || '';
18
29
  const registryToken = env.DOCKER_REGISTRY_TOKEN || '';
@@ -27,12 +38,12 @@ export function createDockerProvider(config, envName, env = process.env) {
27
38
  return dockerEnv;
28
39
  }
29
40
 
30
- const fullImage = registryUrl && !imageName.includes('/')
31
- ? `${registryUrl.replace(/\/$/, '')}/${imageName}:${imageTag}`
32
- : `${imageName}:${imageTag}`;
41
+ function hasRegistryCredentials() {
42
+ return Boolean(registryUser && registryToken);
43
+ }
33
44
 
34
45
  async function dockerLogin() {
35
- if (!registryUser || !registryToken) return;
46
+ if (!hasRegistryCredentials()) return;
36
47
  const registry = registryUrl || 'https://index.docker.io/v1/';
37
48
  log.info('Logging in to container registry...');
38
49
  await execa(
@@ -47,48 +58,284 @@ export function createDockerProvider(config, envName, env = process.env) {
47
58
  }
48
59
 
49
60
  /**
50
- * @param {string} artifactDir
61
+ * Push only when registry credentials are configured. Avoids a noisy failed
62
+ * push to Docker Hub for local-only image names.
51
63
  */
52
- async function deploy(artifactDir) {
53
- log.info(`Deploying via Docker (image: ${fullImage})...`);
54
- const dockerEnv = getDockerEnv();
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
+ }
55
71
 
56
- const composePath = path.join(artifactDir, 'docker-compose.yml');
57
- const dockerfilePath = path.join(artifactDir, 'Dockerfile');
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
+ }
58
79
 
59
- const hasCompose = await fs.pathExists(composePath);
60
- const hasDockerfile = await fs.pathExists(dockerfilePath);
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
+ }
61
94
 
62
- await dockerLogin();
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
+ }
63
104
 
64
- if (hasCompose) {
65
- await execa(
66
- 'docker',
67
- ['compose', 'up', '-d', '--build'],
68
- { cwd: artifactDir, stdio: 'inherit', env: dockerEnv }
69
- );
70
- } else if (hasDockerfile) {
71
- await execa('docker', ['build', '-t', fullImage, '.'], {
72
- cwd: artifactDir,
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], {
73
113
  stdio: 'inherit',
74
- env: dockerEnv,
114
+ env: getDockerEnv(),
75
115
  });
76
- await execa('docker', ['push', fullImage], {
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,
77
287
  stdio: 'inherit',
78
- env: dockerEnv,
79
- }).catch(() => {
80
- log.warn('docker push skipped (registry may be local or push not configured)');
288
+ env: getDockerEnv(),
81
289
  });
82
- await execa('docker', ['run', '-d', '--rm', '--name', config.project, fullImage], {
83
- stdio: 'inherit',
290
+ return { ranCompose: false };
291
+ } finally {
292
+ await fs.remove(buildContext).catch(() => {});
293
+ }
294
+ }
295
+
296
+ /**
297
+ * @param {string} artifactDir
298
+ */
299
+ async function deploy(artifactDir) {
300
+ log.info(`Deploying via Docker (image: ${fullImage})...`);
301
+ const dockerEnv = getDockerEnv();
302
+
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) {
314
+ log.success('Docker deployment complete');
315
+ return;
316
+ }
317
+
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',
84
324
  env: dockerEnv,
85
- });
86
- } else {
87
- throw new Error(
88
- 'No Dockerfile or docker-compose.yml found in artifact. Add one to your project or enable Docker in pipeline config.'
89
- );
325
+ }).catch(() => {});
90
326
  }
91
327
 
328
+ await execa(
329
+ 'docker',
330
+ ['rm', '-f', config.project],
331
+ { stdio: 'pipe', env: dockerEnv }
332
+ ).catch(() => {});
333
+
334
+ await execa('docker', ['run', '-d', '--rm', '--name', config.project, fullImage], {
335
+ stdio: 'inherit',
336
+ env: dockerEnv,
337
+ });
338
+
92
339
  log.success('Docker deployment complete');
93
340
  }
94
341
 
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Shared Docker image naming for pipeline builds and deploy.
3
+ */
4
+
5
+ /**
6
+ * @param {import('../core/config.js').DeployHubConfig} config
7
+ * @param {Record<string, string|undefined>} [env]
8
+ * @returns {{
9
+ * imageName: string,
10
+ * imageTag: string,
11
+ * fullImage: string,
12
+ * latestImage: string,
13
+ * legacyLatestImage: string,
14
+ * }}
15
+ */
16
+ export function resolveDockerImageRef(config, env = process.env) {
17
+ const imageName = env.DOCKER_IMAGE_NAME || config.project;
18
+ const imageTag = env.DOCKER_IMAGE_TAG || config.version || 'latest';
19
+ const registryUrl = env.DOCKER_REGISTRY_URL || '';
20
+
21
+ const repository =
22
+ registryUrl && !imageName.includes('/')
23
+ ? `${registryUrl.replace(/\/$/, '')}/${imageName}`
24
+ : imageName;
25
+
26
+ return {
27
+ imageName,
28
+ imageTag,
29
+ fullImage: `${repository}:${imageTag}`,
30
+ latestImage: `${repository}:latest`,
31
+ legacyLatestImage: `${config.project}:latest`,
32
+ };
33
+ }
34
+
35
+ /**
36
+ * Runtime-only Dockerfile for frontend artifacts that already contain built static output.
37
+ * Used when deploy must build from an artifact (no source / no package-lock).
38
+ * @param {string} [buildOutput]
39
+ * @returns {string}
40
+ */
41
+ export function generateFrontendRuntimeDockerfile(buildOutput = 'dist') {
42
+ const output = buildOutput.replace(/^\/+|\/+$/g, '') || 'dist';
43
+ return `# Generated by DeployHub for artifact deploy (pre-built static output)
44
+ FROM nginx:alpine
45
+ COPY ${output}/ /usr/share/nginx/html/
46
+ EXPOSE 80
47
+ CMD ["nginx", "-g", "daemon off;"]
48
+ `;
49
+ }
50
+
51
+ /**
52
+ * @param {string} jarRelativePath e.g. target/app.jar
53
+ * @param {number} [port]
54
+ */
55
+ export function generateSpringRuntimeDockerfile(jarRelativePath, port = 8080) {
56
+ return `# Generated by DeployHub for artifact deploy (pre-built JAR)
57
+ FROM eclipse-temurin:17-jre-alpine
58
+ WORKDIR /app
59
+ COPY ${jarRelativePath} app.jar
60
+ EXPOSE ${port}
61
+ CMD ["java", "-jar", "app.jar"]
62
+ `;
63
+ }
64
+
65
+ /**
66
+ * @param {string} binaryRelativePath e.g. bin/app
67
+ * @param {number} [port]
68
+ */
69
+ export function generateGoRuntimeDockerfile(binaryRelativePath, port = 8080) {
70
+ return `# Generated by DeployHub for artifact deploy (pre-built Go binary)
71
+ FROM alpine:3.19
72
+ WORKDIR /app
73
+ RUN apk add --no-cache ca-certificates
74
+ COPY ${binaryRelativePath} ./app
75
+ RUN chmod +x ./app
76
+ EXPOSE ${port}
77
+ CMD ["./app"]
78
+ `;
79
+ }
80
+
81
+ /**
82
+ * @param {string} publishDir
83
+ * @param {number} [port]
84
+ */
85
+ export function generateDotnetRuntimeDockerfile(publishDir = 'publish', port = 5000) {
86
+ const dir = publishDir.replace(/^\/+|\/+$/g, '') || 'publish';
87
+ return `# Generated by DeployHub for artifact deploy (pre-built .NET publish output)
88
+ FROM mcr.microsoft.com/dotnet/aspnet:8.0
89
+ WORKDIR /app
90
+ COPY ${dir}/ .
91
+ EXPOSE ${port}
92
+ ENV ASPNETCORE_URLS=http://+:${port}
93
+ CMD ["dotnet", "App.dll"]
94
+ `;
95
+ }
96
+
97
+ /**
98
+ * @param {string} framework
99
+ * @returns {boolean}
100
+ */
101
+ export function isFrontendStaticFramework(framework) {
102
+ return ['react', 'vue', 'angular', 'svelte', 'astro', 'vanilla'].includes(framework || '');
103
+ }
104
+
105
+ /**
106
+ * Node-style backends whose generated Dockerfiles run npm ci and need a lockfile.
107
+ * @param {string} framework
108
+ */
109
+ export function isNodeBackendFramework(framework) {
110
+ return ['express', 'nestjs', 'fastify', 'koa', 'nextjs'].includes(framework || '');
111
+ }
112
+
113
+ /**
114
+ * Interpreted backends that install deps at image-build time (pip/composer/bundle/npm).
115
+ * Their artifacts lack vendor/venv/node_modules, so standalone artifact rebuild is unsupported.
116
+ * @param {string} framework
117
+ */
118
+ export function isInterpretedBackendFramework(framework) {
119
+ return [
120
+ ...['express', 'nestjs', 'fastify', 'koa', 'nextjs'],
121
+ 'fastapi',
122
+ 'django',
123
+ 'flask',
124
+ 'laravel',
125
+ 'symfony',
126
+ 'rails',
127
+ ].includes(framework || '');
128
+ }
129
+
130
+ /**
131
+ * Human-readable dependency gap for interpreted backend artifact rebuilds.
132
+ * @param {string} framework
133
+ * @returns {{ ecosystem: string, missing: string, installCmd: string }}
134
+ */
135
+ export function describeInterpretedBackendGap(framework) {
136
+ if (isNodeBackendFramework(framework)) {
137
+ return {
138
+ ecosystem: 'Node',
139
+ missing: 'package-lock.json / node_modules',
140
+ installCmd: 'npm ci',
141
+ };
142
+ }
143
+ if (['fastapi', 'django', 'flask'].includes(framework)) {
144
+ return {
145
+ ecosystem: 'Python',
146
+ missing: 'a pre-installed virtualenv / site-packages (artifacts only ship requirements.txt + source)',
147
+ installCmd: 'pip install',
148
+ };
149
+ }
150
+ if (['laravel', 'symfony'].includes(framework)) {
151
+ return {
152
+ ecosystem: 'PHP',
153
+ missing: 'vendor/ (Composer dependencies are not packaged into the artifact)',
154
+ installCmd: 'composer install',
155
+ };
156
+ }
157
+ if (framework === 'rails') {
158
+ return {
159
+ ecosystem: 'Ruby',
160
+ missing: 'vendor/bundle / installed gems (artifacts only ship Gemfile + lock)',
161
+ installCmd: 'bundle install',
162
+ };
163
+ }
164
+ return {
165
+ ecosystem: 'backend',
166
+ missing: 'installed dependencies (not packaged into the artifact)',
167
+ installCmd: 'dependency install',
168
+ };
169
+ }
170
+
171
+ export default {
172
+ resolveDockerImageRef,
173
+ generateFrontendRuntimeDockerfile,
174
+ generateSpringRuntimeDockerfile,
175
+ generateGoRuntimeDockerfile,
176
+ generateDotnetRuntimeDockerfile,
177
+ isFrontendStaticFramework,
178
+ isNodeBackendFramework,
179
+ isInterpretedBackendFramework,
180
+ describeInterpretedBackendGap,
181
+ };
@@ -5,6 +5,9 @@
5
5
  const GENERATED_HEADER =
6
6
  '# Generated by DeployHub — review exposed port and start command before deploying.\n';
7
7
 
8
+ const DOCKERIGNORE_HEADER =
9
+ '# Generated by DeployHub — safe to edit. Existing .dockerignore is never overwritten.\n';
10
+
8
11
  /**
9
12
  * @param {string} projectName
10
13
  * @returns {string}
@@ -41,6 +44,100 @@ export function resolveDockerSettings(config) {
41
44
  };
42
45
  }
43
46
 
47
+ /**
48
+ * Generate a .dockerignore matching the Dockerfile language/framework.
49
+ * @param {import('../core/config.js').DeployHubConfig} config
50
+ * @returns {string}
51
+ */
52
+ export function generateDockerignore(config) {
53
+ const settings = resolveDockerSettings(config);
54
+ const { framework, buildOutput, projectType } = settings;
55
+
56
+ /** @type {string[]} */
57
+ const lines = [DOCKERIGNORE_HEADER.trimEnd(), ''];
58
+
59
+ const common = [
60
+ '.git',
61
+ '.gitignore',
62
+ '.env',
63
+ '.env.*',
64
+ '!.env.example',
65
+ 'artifact',
66
+ '.deployhub',
67
+ 'coverage',
68
+ '*.md',
69
+ '!README.md',
70
+ '.github',
71
+ '.vscode',
72
+ '.idea',
73
+ 'deployhub.config.json',
74
+ ];
75
+ lines.push(...common, '');
76
+
77
+ const frontendStatic = ['react', 'vue', 'angular', 'svelte', 'astro', 'vanilla'];
78
+ const nodeFrameworks = [
79
+ ...frontendStatic,
80
+ 'nextjs',
81
+ 'nestjs',
82
+ 'express',
83
+ 'fastify',
84
+ 'koa',
85
+ ];
86
+ const pythonFrameworks = ['fastapi', 'django', 'flask'];
87
+ const phpFrameworks = ['laravel', 'symfony'];
88
+
89
+ if (nodeFrameworks.includes(framework) || !framework) {
90
+ lines.push('node_modules', 'npm-debug.log*', 'yarn-error.log*', '.npm', '');
91
+ // Source Dockerfile rebuilds inside the image — exclude local build output from context
92
+ if (
93
+ frontendStatic.includes(framework) ||
94
+ framework === 'nextjs' ||
95
+ framework === 'nestjs' ||
96
+ (projectType === 'frontend' && buildOutput && buildOutput !== '.')
97
+ ) {
98
+ const out = buildOutput || 'dist';
99
+ if (out !== '.') lines.push(out);
100
+ if (framework === 'nextjs') lines.push('.next');
101
+ if (out !== 'build') lines.push('build');
102
+ lines.push('');
103
+ }
104
+ }
105
+
106
+ if (pythonFrameworks.includes(framework)) {
107
+ lines.push(
108
+ '__pycache__',
109
+ '*.py[cod]',
110
+ '.venv',
111
+ 'venv',
112
+ '.pytest_cache',
113
+ '*.egg-info',
114
+ ''
115
+ );
116
+ }
117
+
118
+ if (phpFrameworks.includes(framework)) {
119
+ lines.push('vendor', '');
120
+ }
121
+
122
+ if (framework === 'spring') {
123
+ lines.push('target', '.gradle', 'build', '');
124
+ }
125
+
126
+ if (framework === 'go') {
127
+ lines.push('bin', '');
128
+ }
129
+
130
+ if (framework === 'dotnet') {
131
+ lines.push('bin', 'obj', '');
132
+ }
133
+
134
+ if (framework === 'rails') {
135
+ lines.push('tmp', 'log', 'vendor/bundle', '');
136
+ }
137
+
138
+ return `${lines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd()}\n`;
139
+ }
140
+
44
141
  /**
45
142
  * @param {import('../core/config.js').DeployHubConfig} config
46
143
  * @returns {string}
@@ -480,6 +577,7 @@ export function getDockerfileFrameworkLabel(framework) {
480
577
 
481
578
  export default {
482
579
  generateDockerfile,
580
+ generateDockerignore,
483
581
  resolveDockerSettings,
484
582
  sanitizeDockerProjectName,
485
583
  getDockerfileFrameworkLabel,
@@ -3,6 +3,7 @@ import path from 'path';
3
3
  import chalk from 'chalk';
4
4
  import {
5
5
  generateDockerfile,
6
+ generateDockerignore,
6
7
  getDockerfileFrameworkLabel,
7
8
  resolveDockerSettings,
8
9
  } from './dockerfile.js';
@@ -48,11 +49,11 @@ export function needsKubernetesManifests(
48
49
  * @param {string} cwd
49
50
  * @param {import('../core/config.js').DeployHubConfig} config
50
51
  * @param {{ silent?: boolean }} [options]
51
- * @returns {Promise<{ generated: boolean, framework?: string }>}
52
+ * @returns {Promise<{ generated: boolean }>}
52
53
  */
53
- export async function ensureDockerfile(cwd, config, options = {}) {
54
- const dockerfilePath = path.join(cwd, 'Dockerfile');
55
- if (await fs.pathExists(dockerfilePath)) {
54
+ export async function ensureDockerignore(cwd, config, options = {}) {
55
+ const dockerignorePath = path.join(cwd, '.dockerignore');
56
+ if (await fs.pathExists(dockerignorePath)) {
56
57
  return { generated: false };
57
58
  }
58
59
 
@@ -60,20 +61,56 @@ export async function ensureDockerfile(cwd, config, options = {}) {
60
61
  return { generated: false };
61
62
  }
62
63
 
63
- const settings = resolveDockerSettings(config);
64
- const content = generateDockerfile(config);
65
- await fs.writeFile(dockerfilePath, content);
64
+ await fs.writeFile(dockerignorePath, generateDockerignore(config));
66
65
 
67
66
  if (!options.silent) {
68
- const label = getDockerfileFrameworkLabel(settings.framework);
69
67
  console.log(
70
68
  chalk.yellow(
71
- `No Dockerfile found — generated a starter Dockerfile at ./Dockerfile based on your detected ${label}. Review it before deploying, especially the exposed port and start command.`
69
+ 'No .dockerignore found — generated one at ./.dockerignore to keep node_modules and build caches out of the Docker build context.'
72
70
  )
73
71
  );
74
72
  }
75
73
 
76
- return { generated: true, framework: settings.framework };
74
+ return { generated: true };
75
+ }
76
+
77
+ /**
78
+ * @param {string} cwd
79
+ * @param {import('../core/config.js').DeployHubConfig} config
80
+ * @param {{ silent?: boolean }} [options]
81
+ * @returns {Promise<{ generated: boolean, framework?: string, dockerignoreGenerated?: boolean }>}
82
+ */
83
+ export async function ensureDockerfile(cwd, config, options = {}) {
84
+ const dockerfilePath = path.join(cwd, 'Dockerfile');
85
+ let generated = false;
86
+ /** @type {string|undefined} */
87
+ let framework;
88
+
89
+ if (!(await fs.pathExists(dockerfilePath)) && needsDockerfile(config)) {
90
+ const settings = resolveDockerSettings(config);
91
+ const content = generateDockerfile(config);
92
+ await fs.writeFile(dockerfilePath, content);
93
+ generated = true;
94
+ framework = settings.framework;
95
+
96
+ if (!options.silent) {
97
+ const label = getDockerfileFrameworkLabel(settings.framework);
98
+ console.log(
99
+ chalk.yellow(
100
+ `No Dockerfile found — generated a starter Dockerfile at ./Dockerfile based on your detected ${label}. Review it before deploying, especially the exposed port and start command.`
101
+ )
102
+ );
103
+ }
104
+ }
105
+
106
+ // Always pair Dockerfile generation path with .dockerignore (never overwrite existing)
107
+ const dockerignoreResult = await ensureDockerignore(cwd, config, options);
108
+
109
+ return {
110
+ generated,
111
+ framework,
112
+ dockerignoreGenerated: dockerignoreResult.generated,
113
+ };
77
114
  }
78
115
 
79
116
  /**
@@ -133,6 +170,7 @@ export async function ensureDeployScaffold(
133
170
  const k8sResult = await ensureKubernetesManifests(cwd, config, environments, options);
134
171
  return {
135
172
  dockerfile: dockerResult.generated,
173
+ dockerignore: Boolean(dockerResult.dockerignoreGenerated),
136
174
  kubernetes: k8sResult.generated,
137
175
  };
138
176
  }
@@ -183,6 +221,7 @@ export async function copyDeployAssetsToArtifactDir(stagingDir, artifactDir) {
183
221
 
184
222
  export default {
185
223
  ensureDockerfile,
224
+ ensureDockerignore,
186
225
  ensureKubernetesManifests,
187
226
  ensureDeployScaffold,
188
227
  needsDockerfile,