@akash-chowdhury-24/deployhub 2.0.4 → 2.0.5

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.5",
4
4
  "description": "Zero-configuration deployment and artifact manager",
5
5
  "type": "module",
6
6
  "main": "./src/cli/index.js",
@@ -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,21 @@ 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
+
203
+ const k8sResult = await ensureKubernetesManifests(cwd, config, environments);
204
+ if (k8sResult.generated) {
205
+ console.log(chalk.gray(' • k8s/deployment.yaml, k8s/service.yaml (auto-generated)'));
206
+ }
207
+
208
+ return {
209
+ dockerfileGenerated: dockerResult.generated,
210
+ kubernetesGenerated: k8sResult.generated,
211
+ };
193
212
  }
194
213
 
195
214
  /**
@@ -397,7 +416,7 @@ export function registerInitCommand(program) {
397
416
  }
398
417
 
399
418
  const version = await getProjectVersion(cwd);
400
- const hasDocker =
419
+ let hasDocker =
401
420
  (detectedFrontend?.hasDocker || detectedBackend?.hasDocker) ?? false;
402
421
 
403
422
  /** @type {Record<string, unknown>} */
@@ -458,7 +477,14 @@ export function registerInitCommand(program) {
458
477
  config
459
478
  );
460
479
 
461
- await generateProjectScaffold(config, environments, cwd);
480
+ const scaffoldResult = await generateProjectScaffold(config, environments, cwd);
481
+
482
+ if (scaffoldResult?.dockerfileGenerated) {
483
+ hasDocker = true;
484
+ config.docker = true;
485
+ config.pipeline.docker = true;
486
+ await saveConfig(config, cwd);
487
+ }
462
488
 
463
489
  const envExampleDest = path.join(cwd, '.env.example');
464
490
  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,18 @@ export function buildPipelineStages(config, cwd, state) {
40
41
  ctx.config.port = detected.port;
41
42
  }
42
43
  }
44
+ const scaffold = await ensureDeployScaffold(
45
+ ctx.cwd,
46
+ ctx.config,
47
+ ctx.config.environments || {},
48
+ { silent: false }
49
+ );
50
+ if (scaffold.dockerfile) {
51
+ ctx.config.docker = true;
52
+ if (ctx.config.pipeline) {
53
+ ctx.config.pipeline.docker = true;
54
+ }
55
+ }
43
56
  ctx.state.framework = ctx.config.framework;
44
57
  ctx.state.projectType = ctx.config.projectType || 'frontend';
45
58
  },
@@ -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',
@@ -3,6 +3,7 @@ import fs from 'fs-extra';
3
3
  import path from 'path';
4
4
  import os from 'os';
5
5
  import { createLogger } from '../../logger/index.js';
6
+ import { sanitizeK8sName } from '../../utils/kubernetes-manifests.js';
6
7
 
7
8
  /**
8
9
  * @param {import('../../core/config.js').DeployHubConfig} config
@@ -15,6 +16,7 @@ export function createKubernetesProvider(config, envName, env = process.env) {
15
16
  const kubeconfig = env.KUBECONFIG || path.join(os.homedir(), '.kube', 'config');
16
17
  const context = env.KUBE_CONTEXT || '';
17
18
  const namespace = env.KUBE_NAMESPACE || config.project || 'default';
19
+ const deploymentName = sanitizeK8sName(config.project || 'app');
18
20
 
19
21
  function getKubectlEnv() {
20
22
  const expanded = kubeconfig.replace(/^~/, os.homedir());
@@ -66,8 +68,8 @@ export function createKubernetesProvider(config, envName, env = process.env) {
66
68
  kubectlArgs([
67
69
  'set',
68
70
  'image',
69
- `deployment/${config.project}`,
70
- `${config.project}=${imageName}:${imageTag}`,
71
+ `deployment/${deploymentName}`,
72
+ `${deploymentName}=${imageName}:${imageTag}`,
71
73
  ]),
72
74
  { stdio: 'pipe', env: getKubectlEnv() }
73
75
  ).catch(() => {
@@ -80,7 +82,7 @@ export function createKubernetesProvider(config, envName, env = process.env) {
80
82
 
81
83
  async function rollback(artifactDir) {
82
84
  log.info('Rolling back Kubernetes deployment...');
83
- await execa('kubectl', kubectlArgs(['rollout', 'undo', `deployment/${config.project}`]), {
85
+ await execa('kubectl', kubectlArgs(['rollout', 'undo', `deployment/${deploymentName}`]), {
84
86
  stdio: 'inherit',
85
87
  env: getKubectlEnv(),
86
88
  }).catch(async () => {
@@ -105,7 +107,7 @@ export function createKubernetesProvider(config, envName, env = process.env) {
105
107
  try {
106
108
  await execa(
107
109
  'kubectl',
108
- kubectlArgs(['rollout', 'status', `deployment/${config.project}`, '--timeout=30s']),
110
+ kubectlArgs(['rollout', 'status', `deployment/${deploymentName}`, '--timeout=30s']),
109
111
  { stdio: 'pipe', env: getKubectlEnv() }
110
112
  );
111
113
  return true;
@@ -0,0 +1,486 @@
1
+ /**
2
+ * Dockerfile generation based on detected framework / project settings.
3
+ */
4
+
5
+ const GENERATED_HEADER =
6
+ '# Generated by DeployHub — review exposed port and start command before deploying.\n';
7
+
8
+ /**
9
+ * @param {string} projectName
10
+ * @returns {string}
11
+ */
12
+ export function sanitizeDockerProjectName(projectName) {
13
+ return projectName.replace(/[^a-zA-Z0-9_-]/g, '-');
14
+ }
15
+
16
+ /**
17
+ * @param {import('../core/config.js').DeployHubConfig} config
18
+ * @returns {{ framework: string, buildCommand: string|null, buildOutput: string, startCommand: string|null, port: number, projectType: string }}
19
+ */
20
+ export function resolveDockerSettings(config) {
21
+ const projectType = config.projectType || 'frontend';
22
+
23
+ if (projectType === 'both' && config.backend) {
24
+ return {
25
+ framework: config.backend.framework || 'express',
26
+ buildCommand: config.backend.buildCommand ?? null,
27
+ buildOutput: config.backend.buildOutput || '.',
28
+ startCommand: config.backend.startCommand || 'npm start',
29
+ port: config.backend.port || 3000,
30
+ projectType,
31
+ };
32
+ }
33
+
34
+ return {
35
+ framework: config.framework || 'express',
36
+ buildCommand: config.buildCommand ?? null,
37
+ buildOutput: config.buildOutput || 'dist',
38
+ startCommand: config.startCommand || 'npm start',
39
+ port: config.port || 3000,
40
+ projectType,
41
+ };
42
+ }
43
+
44
+ /**
45
+ * @param {import('../core/config.js').DeployHubConfig} config
46
+ * @returns {string}
47
+ */
48
+ export function generateDockerfile(config) {
49
+ const settings = resolveDockerSettings(config);
50
+ const { framework } = settings;
51
+
52
+ const frontendStatic = ['react', 'vue', 'angular', 'svelte', 'astro', 'vanilla'];
53
+ if (frontendStatic.includes(framework)) {
54
+ return generateFrontendStaticDockerfile(settings);
55
+ }
56
+
57
+ switch (framework) {
58
+ case 'nextjs':
59
+ return generateNextjsDockerfile(settings);
60
+ case 'nestjs':
61
+ return generateNestjsDockerfile(settings);
62
+ case 'express':
63
+ case 'fastify':
64
+ case 'koa':
65
+ return generateNodeBackendDockerfile(settings);
66
+ case 'fastapi':
67
+ return generateFastapiDockerfile(settings);
68
+ case 'django':
69
+ return generateDjangoDockerfile(settings);
70
+ case 'flask':
71
+ return generateFlaskDockerfile(settings);
72
+ case 'laravel':
73
+ return generateLaravelDockerfile(settings);
74
+ case 'symfony':
75
+ return generateSymfonyDockerfile(settings);
76
+ case 'spring':
77
+ return generateSpringDockerfile(settings);
78
+ case 'go':
79
+ return generateGoDockerfile(settings);
80
+ case 'dotnet':
81
+ return generateDotnetDockerfile(settings);
82
+ case 'rails':
83
+ return generateRailsDockerfile(settings);
84
+ default:
85
+ return generateNodeBackendDockerfile(settings);
86
+ }
87
+ }
88
+
89
+ /**
90
+ * @param {{ buildCommand: string|null, buildOutput: string, port: number }} settings
91
+ */
92
+ function generateFrontendStaticDockerfile(settings) {
93
+ const buildCmd = settings.buildCommand || 'npm run build';
94
+ const output = settings.buildOutput || 'dist';
95
+
96
+ return `${GENERATED_HEADER}
97
+ FROM node:20-alpine AS build
98
+ WORKDIR /app
99
+ COPY package*.json ./
100
+ RUN npm ci
101
+ COPY . .
102
+ RUN ${buildCmd}
103
+
104
+ FROM nginx:alpine
105
+ COPY --from=build /app/${output} /usr/share/nginx/html
106
+ EXPOSE 80
107
+ CMD ["nginx", "-g", "daemon off;"]
108
+ `;
109
+ }
110
+
111
+ /**
112
+ * @param {{ buildCommand: string|null, startCommand: string|null, port: number }} settings
113
+ */
114
+ function generateNextjsDockerfile(settings) {
115
+ const buildCmd = settings.buildCommand || 'npm run build';
116
+ const startCmd = settings.startCommand || 'npm start';
117
+ const port = settings.port || 3000;
118
+ const startParts = parseCommand(startCmd);
119
+
120
+ return `${GENERATED_HEADER}
121
+ FROM node:20-alpine AS deps
122
+ WORKDIR /app
123
+ COPY package*.json ./
124
+ RUN npm ci
125
+
126
+ FROM node:20-alpine AS build
127
+ WORKDIR /app
128
+ COPY --from=deps /app/node_modules ./node_modules
129
+ COPY . .
130
+ RUN ${buildCmd}
131
+
132
+ FROM node:20-alpine AS runner
133
+ WORKDIR /app
134
+ ENV NODE_ENV=production
135
+ COPY --from=build /app/package*.json ./
136
+ COPY --from=build /app/node_modules ./node_modules
137
+ COPY --from=build /app/.next ./.next
138
+ COPY --from=build /app/public ./public
139
+ EXPOSE ${port}
140
+ CMD ${JSON.stringify(startParts)}
141
+ `;
142
+ }
143
+
144
+ /**
145
+ * @param {{ buildCommand: string|null, buildOutput: string, startCommand: string|null, port: number }} settings
146
+ */
147
+ function generateNestjsDockerfile(settings) {
148
+ const buildCmd = settings.buildCommand || 'npm run build';
149
+ const startCmd = settings.startCommand || 'node dist/main';
150
+ const port = settings.port || 3000;
151
+ const startParts = parseCommand(startCmd);
152
+
153
+ return `${GENERATED_HEADER}
154
+ FROM node:20-alpine AS build
155
+ WORKDIR /app
156
+ COPY package*.json ./
157
+ RUN npm ci
158
+ COPY . .
159
+ RUN ${buildCmd}
160
+
161
+ FROM node:20-alpine
162
+ WORKDIR /app
163
+ ENV NODE_ENV=production
164
+ COPY package*.json ./
165
+ RUN npm ci --omit=dev
166
+ COPY --from=build /app/${settings.buildOutput || 'dist'} ./${settings.buildOutput || 'dist'}
167
+ EXPOSE ${port}
168
+ CMD ${JSON.stringify(startParts)}
169
+ `;
170
+ }
171
+
172
+ /**
173
+ * @param {{ buildCommand: string|null, buildOutput: string, startCommand: string|null, port: number }} settings
174
+ */
175
+ function generateNodeBackendDockerfile(settings) {
176
+ const port = settings.port || 3000;
177
+ const startCmd = settings.startCommand || 'npm start';
178
+ const startParts = parseCommand(startCmd);
179
+
180
+ if (settings.buildCommand) {
181
+ return `${GENERATED_HEADER}
182
+ FROM node:20-alpine AS build
183
+ WORKDIR /app
184
+ COPY package*.json ./
185
+ RUN npm ci
186
+ COPY . .
187
+ RUN ${settings.buildCommand}
188
+
189
+ FROM node:20-alpine
190
+ WORKDIR /app
191
+ ENV NODE_ENV=production
192
+ COPY package*.json ./
193
+ RUN npm ci --omit=dev
194
+ COPY --from=build /app/${settings.buildOutput || '.'} ./${settings.buildOutput || '.'}
195
+ EXPOSE ${port}
196
+ CMD ${JSON.stringify(startParts)}
197
+ `;
198
+ }
199
+
200
+ return `${GENERATED_HEADER}
201
+ FROM node:20-alpine AS deps
202
+ WORKDIR /app
203
+ COPY package*.json ./
204
+ RUN npm ci --omit=dev
205
+
206
+ FROM node:20-alpine
207
+ WORKDIR /app
208
+ ENV NODE_ENV=production
209
+ COPY --from=deps /app/node_modules ./node_modules
210
+ COPY . .
211
+ EXPOSE ${port}
212
+ CMD ${JSON.stringify(startParts)}
213
+ `;
214
+ }
215
+
216
+ /**
217
+ * @param {{ startCommand: string|null, port: number }} settings
218
+ */
219
+ function generateFastapiDockerfile(settings) {
220
+ const port = settings.port || 8000;
221
+ const startCmd = settings.startCommand || `uvicorn main:app --host 0.0.0.0 --port ${port}`;
222
+ const startParts = parseCommand(startCmd);
223
+
224
+ return `${GENERATED_HEADER}
225
+ FROM python:3.11-slim AS build
226
+ WORKDIR /app
227
+ COPY requirements.txt ./
228
+ RUN pip install --no-cache-dir -r requirements.txt
229
+
230
+ FROM python:3.11-slim
231
+ WORKDIR /app
232
+ COPY --from=build /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
233
+ COPY --from=build /usr/local/bin /usr/local/bin
234
+ COPY . .
235
+ EXPOSE ${port}
236
+ CMD ${JSON.stringify(startParts)}
237
+ `;
238
+ }
239
+
240
+ /**
241
+ * @param {{ startCommand: string|null, port: number }} settings
242
+ */
243
+ function generateDjangoDockerfile(settings) {
244
+ const port = settings.port || 8000;
245
+ const startCmd =
246
+ settings.startCommand || `gunicorn config.wsgi:application --bind 0.0.0.0:${port}`;
247
+ const startParts = parseCommand(startCmd);
248
+
249
+ return `${GENERATED_HEADER}
250
+ FROM python:3.11-slim AS build
251
+ WORKDIR /app
252
+ COPY requirements.txt ./
253
+ RUN pip install --no-cache-dir -r requirements.txt
254
+
255
+ FROM python:3.11-slim
256
+ WORKDIR /app
257
+ COPY --from=build /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
258
+ COPY --from=build /usr/local/bin /usr/local/bin
259
+ COPY . .
260
+ EXPOSE ${port}
261
+ CMD ${JSON.stringify(startParts)}
262
+ `;
263
+ }
264
+
265
+ /**
266
+ * @param {{ startCommand: string|null, port: number }} settings
267
+ */
268
+ function generateFlaskDockerfile(settings) {
269
+ const port = settings.port || 5000;
270
+ const startCmd = settings.startCommand || `gunicorn app:app --bind 0.0.0.0:${port}`;
271
+ const startParts = parseCommand(startCmd);
272
+
273
+ return `${GENERATED_HEADER}
274
+ FROM python:3.11-slim AS build
275
+ WORKDIR /app
276
+ COPY requirements.txt ./
277
+ RUN pip install --no-cache-dir -r requirements.txt
278
+
279
+ FROM python:3.11-slim
280
+ WORKDIR /app
281
+ COPY --from=build /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
282
+ COPY --from=build /usr/local/bin /usr/local/bin
283
+ COPY . .
284
+ EXPOSE ${port}
285
+ CMD ${JSON.stringify(startParts)}
286
+ `;
287
+ }
288
+
289
+ /**
290
+ * @param {{ port: number }} settings
291
+ */
292
+ function generateLaravelDockerfile(settings) {
293
+ const port = settings.port || 80;
294
+
295
+ return `${GENERATED_HEADER}
296
+ FROM composer:2 AS vendor
297
+ WORKDIR /app
298
+ COPY composer.json composer.lock ./
299
+ RUN composer install --no-dev --optimize-autoloader --no-interaction
300
+
301
+ FROM php:8.2-fpm-alpine
302
+ WORKDIR /var/www/html
303
+ COPY --from=vendor /app/vendor ./vendor
304
+ COPY . .
305
+ RUN chown -R www-data:www-data storage bootstrap/cache || true
306
+ EXPOSE ${port}
307
+ CMD ["php-fpm"]
308
+ `;
309
+ }
310
+
311
+ /**
312
+ * @param {{ port: number }} settings
313
+ */
314
+ function generateSymfonyDockerfile(settings) {
315
+ const port = settings.port || 80;
316
+
317
+ return `${GENERATED_HEADER}
318
+ FROM composer:2 AS vendor
319
+ WORKDIR /app
320
+ COPY composer.json composer.lock ./
321
+ RUN composer install --no-dev --optimize-autoloader --no-interaction
322
+
323
+ FROM php:8.2-fpm-alpine
324
+ WORKDIR /var/www/html
325
+ COPY --from=vendor /app/vendor ./vendor
326
+ COPY . .
327
+ EXPOSE ${port}
328
+ CMD ["php-fpm"]
329
+ `;
330
+ }
331
+
332
+ /**
333
+ * @param {{ buildCommand: string|null, port: number }} settings
334
+ */
335
+ function generateSpringDockerfile(settings) {
336
+ const buildCmd = settings.buildCommand || 'mvn package -DskipTests';
337
+ const port = settings.port || 8080;
338
+
339
+ return `${GENERATED_HEADER}
340
+ FROM eclipse-temurin:17-jdk-alpine AS build
341
+ WORKDIR /app
342
+ COPY pom.xml ./
343
+ COPY src ./src
344
+ RUN apk add --no-cache maven && ${buildCmd}
345
+
346
+ FROM eclipse-temurin:17-jre-alpine
347
+ WORKDIR /app
348
+ COPY --from=build /app/target/*.jar app.jar
349
+ EXPOSE ${port}
350
+ CMD ["java", "-jar", "app.jar"]
351
+ `;
352
+ }
353
+
354
+ /**
355
+ * @param {{ buildCommand: string|null, startCommand: string|null, port: number }} settings
356
+ */
357
+ function generateGoDockerfile(settings) {
358
+ const buildCmd = settings.buildCommand || 'go build -o /app/bin/app .';
359
+ const port = settings.port || 8080;
360
+
361
+ return `${GENERATED_HEADER}
362
+ FROM golang:1.22-alpine AS build
363
+ WORKDIR /app
364
+ COPY go.mod go.sum ./
365
+ RUN go mod download
366
+ COPY . .
367
+ RUN ${buildCmd}
368
+
369
+ FROM alpine:3.19
370
+ WORKDIR /app
371
+ RUN apk add --no-cache ca-certificates
372
+ COPY --from=build /app/bin/app ./app
373
+ EXPOSE ${port}
374
+ CMD ["./app"]
375
+ `;
376
+ }
377
+
378
+ /**
379
+ * @param {{ buildCommand: string|null, buildOutput: string, startCommand: string|null, port: number }} settings
380
+ */
381
+ function generateDotnetDockerfile(settings) {
382
+ const buildCmd = settings.buildCommand || 'dotnet publish -c Release -o /app/publish';
383
+ const port = settings.port || 5000;
384
+ const output = settings.buildOutput || 'publish';
385
+
386
+ return `${GENERATED_HEADER}
387
+ FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
388
+ WORKDIR /src
389
+ COPY *.csproj ./
390
+ RUN dotnet restore
391
+ COPY . .
392
+ RUN ${buildCmd}
393
+
394
+ FROM mcr.microsoft.com/dotnet/aspnet:8.0
395
+ WORKDIR /app
396
+ COPY --from=build /src/${output} .
397
+ EXPOSE ${port}
398
+ ENV ASPNETCORE_URLS=http://+:${port}
399
+ CMD ["dotnet", "App.dll"]
400
+ `;
401
+ }
402
+
403
+ /**
404
+ * @param {{ startCommand: string|null, port: number }} settings
405
+ */
406
+ function generateRailsDockerfile(settings) {
407
+ const port = settings.port || 3000;
408
+ const startCmd = settings.startCommand || 'bundle exec puma -C config/puma.rb';
409
+ const startParts = parseCommand(startCmd);
410
+
411
+ return `${GENERATED_HEADER}
412
+ FROM ruby:3.2-slim AS build
413
+ WORKDIR /app
414
+ RUN apt-get update -qq && apt-get install -y build-essential libpq-dev && rm -rf /var/lib/apt/lists/*
415
+ COPY Gemfile Gemfile.lock ./
416
+ RUN bundle install --without development test
417
+ COPY . .
418
+ RUN bundle exec rake assets:precompile || true
419
+
420
+ FROM ruby:3.2-slim
421
+ WORKDIR /app
422
+ RUN apt-get update -qq && apt-get install -y libpq-dev && rm -rf /var/lib/apt/lists/*
423
+ COPY --from=build /usr/local/bundle /usr/local/bundle
424
+ COPY --from=build /app .
425
+ EXPOSE ${port}
426
+ CMD ${JSON.stringify(startParts)}
427
+ `;
428
+ }
429
+
430
+ /**
431
+ * @param {string} command
432
+ * @returns {string[]}
433
+ */
434
+ function parseCommand(command) {
435
+ const trimmed = command.trim();
436
+ if (trimmed.startsWith('[')) {
437
+ try {
438
+ return JSON.parse(trimmed);
439
+ } catch {
440
+ // fall through
441
+ }
442
+ }
443
+
444
+ const match = trimmed.match(/^(\S+)(?:\s+(.*))?$/);
445
+ if (!match) return ['sh', '-c', trimmed];
446
+ const [, bin, rest] = match;
447
+ if (!rest) return [bin];
448
+ return [bin, ...rest.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g).map((part) => part.replace(/^['"]|['"]$/g, ''))];
449
+ }
450
+
451
+ /**
452
+ * Human-readable framework label for user messages.
453
+ * @param {string} framework
454
+ */
455
+ export function getDockerfileFrameworkLabel(framework) {
456
+ const labels = {
457
+ react: 'React',
458
+ vue: 'Vue',
459
+ angular: 'Angular',
460
+ nextjs: 'Next.js',
461
+ svelte: 'Svelte',
462
+ astro: 'Astro',
463
+ vanilla: 'Vanilla JS',
464
+ express: 'Express',
465
+ nestjs: 'NestJS',
466
+ fastify: 'Fastify',
467
+ koa: 'Koa',
468
+ fastapi: 'FastAPI',
469
+ django: 'Django',
470
+ flask: 'Flask',
471
+ laravel: 'Laravel',
472
+ symfony: 'Symfony',
473
+ spring: 'Spring Boot',
474
+ go: 'Go',
475
+ dotnet: '.NET',
476
+ rails: 'Ruby on Rails',
477
+ };
478
+ return labels[framework] || framework;
479
+ }
480
+
481
+ export default {
482
+ generateDockerfile,
483
+ resolveDockerSettings,
484
+ sanitizeDockerProjectName,
485
+ getDockerfileFrameworkLabel,
486
+ };
@@ -208,6 +208,56 @@ function getBackendSetupSteps(config) {
208
208
  return steps;
209
209
  }
210
210
 
211
+ const KUBECTL_VERSION = 'v1.30.4';
212
+
213
+ /**
214
+ * @param {string[]} deployEnvironments
215
+ * @param {Record<string, { type: string }>} environments
216
+ * @returns {boolean}
217
+ */
218
+ function hasKubernetesDeploy(deployEnvironments, environments) {
219
+ return deployEnvironments.some((envName) => environments[envName]?.type === 'kubernetes');
220
+ }
221
+
222
+ /**
223
+ * @returns {string}
224
+ */
225
+ function getKubernetesSetupSteps() {
226
+ return ` - name: Setup kubectl
227
+ uses: azure/setup-kubectl@v4
228
+ with:
229
+ version: '${KUBECTL_VERSION}'
230
+
231
+ - name: Configure kubeconfig
232
+ env:
233
+ KUBECONFIG_SECRET: \${{ secrets.KUBECONFIG }}
234
+ run: |
235
+ mkdir -p "$GITHUB_WORKSPACE/.kube"
236
+ if echo "$KUBECONFIG_SECRET" | base64 -d > "$GITHUB_WORKSPACE/.kube/config" 2>/dev/null; then
237
+ :
238
+ else
239
+ printf '%s' "$KUBECONFIG_SECRET" > "$GITHUB_WORKSPACE/.kube/config"
240
+ fi
241
+ chmod 600 "$GITHUB_WORKSPACE/.kube/config"`;
242
+ }
243
+
244
+ /**
245
+ * @param {string[]} deployEnvironments
246
+ * @param {Record<string, { type: string }>} environments
247
+ * @param {Set<string>} envVars
248
+ */
249
+ function applyKubernetesWorkflowEnv(deployEnvironments, environments, envVars) {
250
+ if (!hasKubernetesDeploy(deployEnvironments, environments)) return;
251
+
252
+ for (const entry of envVars) {
253
+ if (entry.startsWith('KUBECONFIG:')) {
254
+ envVars.delete(entry);
255
+ break;
256
+ }
257
+ }
258
+ envVars.add('KUBECONFIG: ${{ github.workspace }}/.kube/config');
259
+ }
260
+
211
261
  /**
212
262
  * @param {string[]} storageProviders
213
263
  * @param {string[]} deployEnvironments
@@ -243,6 +293,8 @@ export function generateWorkflowYaml(
243
293
  }
244
294
  }
245
295
 
296
+ applyKubernetesWorkflowEnv(deployEnvironments, environments, envVars);
297
+
246
298
  const envBlock = Array.from(envVars)
247
299
  .map((line) => ` ${line}`)
248
300
  .join('\n');
@@ -253,6 +305,9 @@ export function generateWorkflowYaml(
253
305
  const githubGitConfigStep = isGithubCliSource(cliSource)
254
306
  ? `${getGithubGitConfigStep()}\n`
255
307
  : '';
308
+ const kubernetesSteps = hasKubernetesDeploy(deployEnvironments, environments)
309
+ ? `${getKubernetesSetupSteps()}\n`
310
+ : '';
256
311
 
257
312
  const projectType = config?.projectType || 'frontend';
258
313
  const installDepsCommand =
@@ -272,7 +327,7 @@ jobs:
272
327
  ${uniqueBackendSteps ? `${uniqueBackendSteps}\n` : ''} - uses: actions/setup-node@v4
273
328
  with:
274
329
  node-version: '20'
275
- ${githubGitConfigStep} - name: Install project dependencies
330
+ ${kubernetesSteps}${githubGitConfigStep} - name: Install project dependencies
276
331
  run: ${installDepsCommand}
277
332
  - name: Install DeployHub CLI
278
333
  run: npm install ${installSpec} --no-save
@@ -0,0 +1,168 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+
4
+ /**
5
+ * @param {string} name
6
+ * @returns {string}
7
+ */
8
+ export function sanitizeK8sName(name) {
9
+ const sanitized = String(name)
10
+ .toLowerCase()
11
+ .replace(/[^a-z0-9-]/g, '-')
12
+ .replace(/^-+|-+$/g, '')
13
+ .slice(0, 63);
14
+ return sanitized || 'app';
15
+ }
16
+
17
+ /**
18
+ * @param {string} cwd
19
+ * @returns {Promise<boolean>}
20
+ */
21
+ export async function hasKubernetesManifests(cwd) {
22
+ if (await fs.pathExists(path.join(cwd, 'k8s'))) {
23
+ return true;
24
+ }
25
+
26
+ let files = [];
27
+ try {
28
+ files = await fs.readdir(cwd);
29
+ } catch {
30
+ return false;
31
+ }
32
+
33
+ for (const file of files) {
34
+ if (!/\.ya?ml$/i.test(file)) continue;
35
+ const content = await fs.readFile(path.join(cwd, file), 'utf-8');
36
+ if (/^\s*apiVersion:/m.test(content) && /^\s*kind:/m.test(content)) {
37
+ return true;
38
+ }
39
+ }
40
+
41
+ return false;
42
+ }
43
+
44
+ /**
45
+ * @param {object} options
46
+ * @param {string} options.appName
47
+ * @param {string} options.imageName
48
+ * @param {string} [options.imageTag]
49
+ * @param {number} options.port
50
+ * @param {string} options.namespace
51
+ * @param {string} [options.imagePullSecret]
52
+ * @returns {{ deploymentYaml: string, serviceYaml: string }}
53
+ */
54
+ export function generateKubernetesManifests({
55
+ appName,
56
+ imageName,
57
+ imageTag = 'latest',
58
+ port,
59
+ namespace,
60
+ imagePullSecret = '',
61
+ }) {
62
+ const name = sanitizeK8sName(appName);
63
+ const image = imageName.includes(':') ? imageName : `${imageName}:${imageTag}`;
64
+ const pullSecretBlock = imagePullSecret
65
+ ? ` imagePullSecrets:\n - name: ${imagePullSecret}\n`
66
+ : '';
67
+
68
+ const deploymentYaml = `apiVersion: apps/v1
69
+ kind: Deployment
70
+ metadata:
71
+ name: ${name}
72
+ namespace: ${namespace}
73
+ spec:
74
+ # Adjust replica count as needed
75
+ replicas: 1
76
+ selector:
77
+ matchLabels:
78
+ app: ${name}
79
+ template:
80
+ metadata:
81
+ labels:
82
+ app: ${name}
83
+ spec:
84
+ ${pullSecretBlock} containers:
85
+ - name: ${name}
86
+ image: ${image}
87
+ ports:
88
+ - containerPort: ${port}
89
+ resources:
90
+ requests:
91
+ memory: "128Mi"
92
+ cpu: "100m"
93
+ limits:
94
+ memory: "512Mi"
95
+ cpu: "500m"
96
+ `;
97
+
98
+ const servicePort = port === 80 ? 80 : 80;
99
+ const targetPort = port;
100
+
101
+ const serviceYaml = `apiVersion: v1
102
+ kind: Service
103
+ metadata:
104
+ name: ${name}
105
+ namespace: ${namespace}
106
+ spec:
107
+ type: ClusterIP
108
+ selector:
109
+ app: ${name}
110
+ ports:
111
+ - port: ${servicePort}
112
+ targetPort: ${targetPort}
113
+ `;
114
+
115
+ return { deploymentYaml, serviceYaml };
116
+ }
117
+
118
+ /**
119
+ * @param {import('../core/config.js').DeployHubConfig} config
120
+ * @param {Record<string, Record<string, unknown>>} [environments]
121
+ * @returns {{ appName: string, imageName: string, imageTag: string, port: number, namespace: string, imagePullSecret: string }}
122
+ */
123
+ export function resolveKubernetesManifestOptions(config, environments = {}) {
124
+ const envList = Object.values(environments);
125
+ const k8sEnv = envList.find((env) => env.type === 'kubernetes') || {};
126
+
127
+ const appName = config.project || 'app';
128
+ const imageName =
129
+ /** @type {string} */ (k8sEnv.dockerImageName) ||
130
+ process.env.DOCKER_IMAGE_NAME ||
131
+ appName;
132
+ const imageTag =
133
+ process.env.DOCKER_IMAGE_TAG || config.version || 'latest';
134
+ const namespace =
135
+ /** @type {string} */ (k8sEnv.kubeNamespace) ||
136
+ process.env.KUBE_NAMESPACE ||
137
+ appName ||
138
+ 'default';
139
+ const imagePullSecret =
140
+ /** @type {string} */ (k8sEnv.kubeImagePullSecret) ||
141
+ process.env.KUBE_IMAGE_PULL_SECRET ||
142
+ '';
143
+
144
+ let port = 3000;
145
+ if (config.projectType === 'both' && config.backend?.port) {
146
+ port = config.backend.port;
147
+ } else if (config.port) {
148
+ port = config.port;
149
+ } else if (config.backend?.port) {
150
+ port = config.backend.port;
151
+ }
152
+
153
+ return {
154
+ appName,
155
+ imageName,
156
+ imageTag,
157
+ port,
158
+ namespace,
159
+ imagePullSecret,
160
+ };
161
+ }
162
+
163
+ export default {
164
+ sanitizeK8sName,
165
+ hasKubernetesManifests,
166
+ generateKubernetesManifests,
167
+ resolveKubernetesManifestOptions,
168
+ };
@@ -0,0 +1,192 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ import chalk from 'chalk';
4
+ import {
5
+ generateDockerfile,
6
+ getDockerfileFrameworkLabel,
7
+ resolveDockerSettings,
8
+ } from './dockerfile.js';
9
+ import {
10
+ generateKubernetesManifests,
11
+ hasKubernetesManifests,
12
+ resolveKubernetesManifestOptions,
13
+ } from './kubernetes-manifests.js';
14
+
15
+ /**
16
+ * @param {Record<string, Record<string, unknown>>} environments
17
+ * @returns {Set<string>}
18
+ */
19
+ export function getDeployTypes(environments) {
20
+ return new Set(
21
+ Object.values(environments)
22
+ .map((env) => /** @type {string} */ (env.type))
23
+ .filter(Boolean)
24
+ );
25
+ }
26
+
27
+ /**
28
+ * @param {import('../core/config.js').DeployHubConfig} config
29
+ * @param {Record<string, Record<string, unknown>>} [environments]
30
+ */
31
+ export function needsDockerfile(config, environments = config.environments || {}) {
32
+ const deployTypes = getDeployTypes(environments);
33
+ return deployTypes.has('docker') || deployTypes.has('kubernetes');
34
+ }
35
+
36
+ /**
37
+ * @param {import('../core/config.js').DeployHubConfig} config
38
+ * @param {Record<string, Record<string, unknown>>} [environments]
39
+ */
40
+ export function needsKubernetesManifests(
41
+ config,
42
+ environments = config.environments || {}
43
+ ) {
44
+ return getDeployTypes(environments).has('kubernetes');
45
+ }
46
+
47
+ /**
48
+ * @param {string} cwd
49
+ * @param {import('../core/config.js').DeployHubConfig} config
50
+ * @param {{ silent?: boolean }} [options]
51
+ * @returns {Promise<{ generated: boolean, framework?: string }>}
52
+ */
53
+ export async function ensureDockerfile(cwd, config, options = {}) {
54
+ const dockerfilePath = path.join(cwd, 'Dockerfile');
55
+ if (await fs.pathExists(dockerfilePath)) {
56
+ return { generated: false };
57
+ }
58
+
59
+ if (!needsDockerfile(config)) {
60
+ return { generated: false };
61
+ }
62
+
63
+ const settings = resolveDockerSettings(config);
64
+ const content = generateDockerfile(config);
65
+ await fs.writeFile(dockerfilePath, content);
66
+
67
+ if (!options.silent) {
68
+ const label = getDockerfileFrameworkLabel(settings.framework);
69
+ console.log(
70
+ 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.`
72
+ )
73
+ );
74
+ }
75
+
76
+ return { generated: true, framework: settings.framework };
77
+ }
78
+
79
+ /**
80
+ * @param {string} cwd
81
+ * @param {import('../core/config.js').DeployHubConfig} config
82
+ * @param {Record<string, Record<string, unknown>>} [environments]
83
+ * @param {{ silent?: boolean }} [options]
84
+ * @returns {Promise<{ generated: boolean }>}
85
+ */
86
+ export async function ensureKubernetesManifests(
87
+ cwd,
88
+ config,
89
+ environments = config.environments || {},
90
+ options = {}
91
+ ) {
92
+ if (!needsKubernetesManifests(config, environments)) {
93
+ return { generated: false };
94
+ }
95
+
96
+ if (await hasKubernetesManifests(cwd)) {
97
+ return { generated: false };
98
+ }
99
+
100
+ const manifestOptions = resolveKubernetesManifestOptions(config, environments);
101
+ const { deploymentYaml, serviceYaml } = generateKubernetesManifests(manifestOptions);
102
+
103
+ const k8sDir = path.join(cwd, 'k8s');
104
+ await fs.ensureDir(k8sDir);
105
+ await fs.writeFile(path.join(k8sDir, 'deployment.yaml'), deploymentYaml);
106
+ await fs.writeFile(path.join(k8sDir, 'service.yaml'), serviceYaml);
107
+
108
+ if (!options.silent) {
109
+ console.log(
110
+ chalk.yellow(
111
+ 'No Kubernetes manifests found — generated starter manifests at ./k8s/deployment.yaml and ./k8s/service.yaml. Review resource limits, replica count, and any environment-specific settings before deploying.'
112
+ )
113
+ );
114
+ }
115
+
116
+ return { generated: true };
117
+ }
118
+
119
+ /**
120
+ * @param {string} cwd
121
+ * @param {import('../core/config.js').DeployHubConfig} config
122
+ * @param {Record<string, Record<string, unknown>>} [environments]
123
+ * @param {{ silent?: boolean }} [options]
124
+ * @returns {Promise<{ dockerfile: boolean, kubernetes: boolean }>}
125
+ */
126
+ export async function ensureDeployScaffold(
127
+ cwd,
128
+ config,
129
+ environments = config.environments || {},
130
+ options = {}
131
+ ) {
132
+ const dockerResult = await ensureDockerfile(cwd, config, options);
133
+ const k8sResult = await ensureKubernetesManifests(cwd, config, environments, options);
134
+ return {
135
+ dockerfile: dockerResult.generated,
136
+ kubernetes: k8sResult.generated,
137
+ };
138
+ }
139
+
140
+ /**
141
+ * @param {string} srcDir
142
+ * @param {string} destDir
143
+ */
144
+ export async function copyKubernetesManifestsIfPresent(srcDir, destDir) {
145
+ const k8sSrc = path.join(srcDir, 'k8s');
146
+ if (await fs.pathExists(k8sSrc)) {
147
+ await fs.copy(k8sSrc, path.join(destDir, 'k8s'));
148
+ return;
149
+ }
150
+
151
+ let files = [];
152
+ try {
153
+ files = await fs.readdir(srcDir);
154
+ } catch {
155
+ return;
156
+ }
157
+
158
+ for (const file of files) {
159
+ if (!/\.ya?ml$/i.test(file)) continue;
160
+ const srcFile = path.join(srcDir, file);
161
+ const content = await fs.readFile(srcFile, 'utf-8');
162
+ if (/^\s*apiVersion:/m.test(content) && /^\s*kind:/m.test(content)) {
163
+ await fs.copy(srcFile, path.join(destDir, file));
164
+ }
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Copy deploy-time assets from staging into artifactDir (alongside artifact.zip).
170
+ * @param {string} stagingDir
171
+ * @param {string} artifactDir
172
+ */
173
+ export async function copyDeployAssetsToArtifactDir(stagingDir, artifactDir) {
174
+ for (const file of ['Dockerfile', 'docker-compose.yml']) {
175
+ const src = path.join(stagingDir, file);
176
+ if (await fs.pathExists(src)) {
177
+ await fs.copy(src, path.join(artifactDir, file));
178
+ }
179
+ }
180
+
181
+ await copyKubernetesManifestsIfPresent(stagingDir, artifactDir);
182
+ }
183
+
184
+ export default {
185
+ ensureDockerfile,
186
+ ensureKubernetesManifests,
187
+ ensureDeployScaffold,
188
+ needsDockerfile,
189
+ needsKubernetesManifests,
190
+ copyKubernetesManifestsIfPresent,
191
+ copyDeployAssetsToArtifactDir,
192
+ };