@akash-chowdhury-24/deployhub 2.0.14 → 2.0.15

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
@@ -86,7 +86,7 @@ This interactive wizard will:
86
86
  - Set up storage providers (AWS, Google Drive, Azure, GCP, Dropbox, Local)
87
87
  - Optionally configure deployment targets (SSH, Docker, EC2, Azure VM, GCP VM, Kubernetes)
88
88
  - Generate `deployhub.config.json`
89
- - Generate `.github/workflows/deployhub.yml`
89
+ - Generate `.github/workflows/deployhub.yml` and `.github/workflows/deployhub-rollback.yml`
90
90
  - Generate `.env.example`
91
91
 
92
92
  ### 2. Configure credentials
@@ -182,7 +182,8 @@ The wizard asks the same core questions for every setup:
182
182
  **Generated files:**
183
183
 
184
184
  - `deployhub.config.json` — project settings (no secrets)
185
- - `.github/workflows/deployhub.yml` — CI pipeline
185
+ - `.github/workflows/deployhub.yml` — CI deploy pipeline (push to main)
186
+ - `.github/workflows/deployhub-rollback.yml` — manual CI rollback (`workflow_dispatch`)
186
187
  - `.env.example` — list of env vars you may need
187
188
  - `nginx.conf` — auto-generated if frontend deploys to SSH
188
189
  - `Dockerfile` — auto-generated if missing and you chose Docker or Kubernetes deploy (your existing `Dockerfile` is never overwritten)
@@ -231,9 +232,25 @@ deployhub artifact restore <buildId> # download a past build
231
232
  deployhub deploy # deploy latest artifact without rebuilding
232
233
  deployhub rollback # previous build from history
233
234
  deployhub rollback <buildId> # exact build (required if semver is ambiguous)
235
+ deployhub sync-workflows # regenerate deploy + rollback GitHub Actions YAML
236
+ deployhub sync-k8s-ports # fix containerPort/targetPort in existing k8s manifests
234
237
  deployhub logs # last deployment logs
235
238
  ```
236
239
 
240
+ ### Rollback behavior
241
+
242
+ `deployhub rollback` restores a previous artifact from storage `history.json` and redeploys it:
243
+
244
+ | Argument | Behavior |
245
+ |----------|----------|
246
+ | *(none)* | Rolls back to the **previous** build (second entry in newest-first history) |
247
+ | Exact `buildId` | Rolls back to that specific build |
248
+ | Semver / version string matching **multiple** builds | Does **not** guess — prints the matching `buildId`s and exits; re-run with an exact one |
249
+
250
+ `buildId` looks like `{semver}-{stamp}` where the stamp is a short git SHA when available, otherwise a CI run id, otherwise a high-resolution timestamp. When `DOCKER_IMAGE_TAG` is left unset, Docker and Kubernetes use that same `buildId` as the image tag — so you can correlate an artifact in storage with the image that was pushed.
251
+
252
+ For CI-triggered rollback, see [CI rollback](#ci-rollback-deployhub-rollbackyml).
253
+
237
254
  ---
238
255
 
239
256
  ## Walkthrough: Storage only
@@ -555,17 +572,27 @@ All JS frontends share the same install/build flow: `npm ci` → `npm run build`
555
572
  After `init`, commit these files:
556
573
 
557
574
  ```bash
558
- git add deployhub.config.json .github/workflows/deployhub.yml .env.example
575
+ git add deployhub.config.json .github/workflows/deployhub.yml .github/workflows/deployhub-rollback.yml .env.example
559
576
  git commit -m "Add DeployHub CI"
560
577
  ```
561
578
 
562
579
  1. Open **Settings → Secrets and variables → Actions** in your GitHub repo.
563
580
  2. Add every secret listed at the end of `deployhub init` (storage + deployment).
564
- 3. Push to `main` or `master` — the workflow triggers on push.
581
+ 3. Push to `main` or `master` — the deploy workflow triggers on push.
582
+
583
+ The deploy workflow (`deployhub.yml`) installs the correct language runtime (Node, Python, Java, Go, .NET, Ruby) based on your `deployhub.config.json`, installs DeployHub, runs `deployhub build`, and uses your secrets.
584
+
585
+ To run a deploy manually: **Actions → DeployHub → Run workflow**.
586
+
587
+ ### CI rollback (`deployhub-rollback.yml`)
565
588
 
566
- The workflow installs the correct language runtime (Node, Python, Java, Go, .NET, Ruby) based on your `deployhub.config.json`, installs DeployHub, runs `deployhub build`, and uses your secrets.
589
+ `init` also generates a separate **DeployHub Rollback** workflow (`deployhub-rollback.yml`). It is triggered only via GitHub Actions' **Run workflow** button (not on push):
567
590
 
568
- To run manually: **Actions → DeployHub → Run workflow**.
591
+ 1. Open **Actions → DeployHub Rollback → Run workflow**.
592
+ 2. Optionally enter an exact `buildId` (leave blank to roll back to the previous build).
593
+ 3. Run the workflow.
594
+
595
+ Already-initialized projects that only have `deployhub.yml` will not get the rollback workflow from a re-run of unrelated commands — run **`deployhub sync-workflows`** once to regenerate both workflow files from your current `deployhub.config.json`, then commit and push.
569
596
 
570
597
  ---
571
598
 
@@ -648,6 +675,8 @@ DeployHub detects whether the server uses Debian-style `sites-available` or RHEL
648
675
 
649
676
  ### SSH
650
677
 
678
+ **Verification:** Real-world verified DEPLOY and ROLLBACK.
679
+
651
680
  **Prerequisites (before `deployhub init`):**
652
681
  - [ ] Complete **[one-time server setup](#one-time-server-setup-before-your-first-deploy)** (deploy path ownership + Nginx/sudo for frontends)
653
682
  - [ ] A Linux server with SSH enabled
@@ -687,7 +716,7 @@ DeployHub detects whether the server uses Debian-style `sites-available` or RHEL
687
716
 
688
717
  ### Docker
689
718
 
690
- **Verification:** Real-world verified (local and CI Docker deploys).
719
+ **Verification:** Real-world verified DEPLOY and ROLLBACK (local and CI).
691
720
 
692
721
  **Prerequisites:**
693
722
  - [ ] Docker installed (`docker --version` works)
@@ -722,6 +751,8 @@ DeployHub detects whether the server uses Debian-style `sites-available` or RHEL
722
751
 
723
752
  ### AWS EC2
724
753
 
754
+ **Verification:** Real-world verified DEPLOY and ROLLBACK.
755
+
725
756
  **Prerequisites:**
726
757
  - [ ] Complete **[one-time server setup](#one-time-server-setup-before-your-first-deploy)** (`ec2-user` on Amazon Linux)
727
758
  - [ ] EC2 instance launched in AWS Console (DeployHub does not create it)
@@ -758,6 +789,8 @@ DeployHub detects whether the server uses Debian-style `sites-available` or RHEL
758
789
 
759
790
  ### Azure VM
760
791
 
792
+ **Verification:** Real-world verified DEPLOY. Rollback logic confirmed via shared-SSH-path audit; not yet live-tested independently.
793
+
761
794
  **Prerequisites:**
762
795
  - [ ] Complete **[one-time server setup](#one-time-server-setup-before-your-first-deploy)** (`azureuser` or your VM login user)
763
796
  - [ ] Azure VM created in Portal (DeployHub does not provision it)
@@ -792,6 +825,8 @@ DeployHub detects whether the server uses Debian-style `sites-available` or RHEL
792
825
 
793
826
  ### GCP VM
794
827
 
828
+ **Verification:** Real-world verified DEPLOY. Rollback logic confirmed via shared-SSH-path audit; not yet live-tested independently.
829
+
795
830
  **Prerequisites:**
796
831
  - [ ] Complete **[one-time server setup](#one-time-server-setup-before-your-first-deploy)** (your GCP SSH username)
797
832
  - [ ] Compute Engine VM created (DeployHub does not create it)
@@ -832,7 +867,7 @@ DeployHub detects whether the server uses Debian-style `sites-available` or RHEL
832
867
 
833
868
  ### Kubernetes
834
869
 
835
- **Verification:** Real-world verified (including k3s and CI deploys).
870
+ **Verification:** Real-world verified DEPLOY and ROLLBACK (including k3s and CI).
836
871
 
837
872
  **Prerequisites:**
838
873
  - [ ] Existing Kubernetes cluster (DeployHub does not provision clusters)
@@ -868,7 +903,7 @@ DeployHub detects whether the server uses Debian-style `sites-available` or RHEL
868
903
  6. Add GitHub Secrets for CI (see table — **`KUBECONFIG` must be the file contents, not a path**)
869
904
  7. Run `deployhub doctor`, then `git push origin main`
870
905
 
871
- > **Warning Service `targetPort`:** the default `targetPort` in generated `k8s/service.yaml` may not match your app's actual exposed port (e.g. a static nginx image serves on port **80**, not the config's default like 3000). Verify and adjust `k8s/service.yaml`'s `targetPort` (and the Deployment `containerPort` if needed) before your first deploy.
906
+ > **Ports in generated Kubernetes manifests:** `containerPort` and `targetPort` are derived from your project's Dockerfile `EXPOSE` line (frontend / nginx images correctly get **80**; backends get their real exposed port). If no Dockerfile or usable `EXPOSE` exists yet, DeployHub falls back to a per-project-type default that matches its Dockerfile templates. Fresh `init` does **not** require a manual port edit. The Service's external `port:` stays **80** for all project types by design (Ingress-friendly); only the internal `targetPort` / `containerPort` track the container. Already-initialized projects with stale `3000` values can run `deployhub sync-k8s-ports` to patch just those fields.
872
907
 
873
908
  | Variable | Description | Example | Where to get it |
874
909
  |----------|-------------|---------|-----------------|
@@ -961,8 +996,10 @@ Run `deployhub doctor` after any config change.
961
996
  | `deployhub storage list` | List storage providers and connection status |
962
997
  | `deployhub deploy` | Deploy latest artifact |
963
998
  | `deployhub rollback [buildId\|semver]` | Previous build, or exact buildId (ambiguous semver lists matches and exits) |
999
+ | `deployhub sync-workflows` | Regenerate `.github/workflows/deployhub.yml` and `deployhub-rollback.yml` from config |
1000
+ | `deployhub sync-k8s-ports` | Update only `containerPort` / `targetPort` in `k8s/deployment.yaml` and `k8s/service.yaml` from Dockerfile `EXPOSE` (or config/fallback). Does **not** change replicas, resources, env, or probes. Heavily customized / multi-container manifests may need a manual review |
964
1001
  | `deployhub logs` | Show logs from last deployment |
965
- | `deployhub doctor` | Pre-flight checks |
1002
+ | `deployhub doctor` | Pre-flight checks (including an informational warning if the rollback workflow file is missing) |
966
1003
  | `deployhub verify` | Health check on configured endpoint |
967
1004
  | `deployhub clean` | Remove old local artifacts |
968
1005
  | `deployhub update` | Check for CLI updates |
@@ -1009,6 +1046,8 @@ Add these secrets in your repository (Settings → Secrets and variables → Act
1009
1046
  | `DOCKER_IMAGE_NAME`, `DOCKER_REGISTRY_USERNAME`, `DOCKER_REGISTRY_TOKEN`, `DOCKER_REGISTRY_URL`, `DOCKER_HOST` | Docker deployment (`DOCKER_IMAGE_TAG` optional) |
1010
1047
  | `KUBECONFIG`, `KUBE_CONTEXT`, `KUBE_NAMESPACE`, `DOCKER_IMAGE_NAME`, `DOCKER_REGISTRY_USERNAME`, `DOCKER_REGISTRY_TOKEN`, `DOCKER_REGISTRY_URL`, `DOCKER_IMAGE_TAG`, `KUBE_IMAGE_PULL_SECRET` | Kubernetes — **`KUBECONFIG` in GitHub Secrets must be the kubeconfig file contents (or base64), not a filesystem path**. `DOCKER_IMAGE_TAG`, `KUBE_NAMESPACE`, `DOCKER_REGISTRY_URL`, and `KUBE_IMAGE_PULL_SECRET` are optional |
1011
1048
 
1049
+ **Kubernetes rollback vs deploy — registry credentials:** Kubernetes **rollback** specifically **requires** `DOCKER_REGISTRY_USERNAME` and `DOCKER_REGISTRY_TOKEN`. Rollback rebuilds and must push a fresh image tagged with the restored `buildId`; without registry credentials it fails loudly and early (a rollback that cannot push can never succeed against a real cluster). This is stricter than a normal Kubernetes **deploy**, which may still allow local-only / no-push flows in some setups. If deploy worked without those secrets but rollback fails asking for them, that asymmetry is intentional.
1050
+
1012
1051
  See [Deployment method guides](#deployment-method-guides) for full per-method variable tables with examples.
1013
1052
 
1014
1053
  ## `deployhub doctor` Output
@@ -1025,11 +1064,14 @@ The doctor command runs independent checks and always completes without crashing
1025
1064
  Checking Health endpoint... ✓ URL reachable (HTTP 200)
1026
1065
  Checking Secrets... ✓ All required env vars present
1027
1066
  Checking GitHub Actions... ✓ Workflow file exists at .github/workflows/deployhub.yml
1067
+ Checking Rollback workflow... ✓ Missing .github/workflows/deployhub-rollback.yml — run deployhub sync-workflows to add CI rollback (workflow_dispatch)
1028
1068
  Checking Storage write... ✓ Test upload succeeded
1029
1069
 
1030
- ✓ Ready to deploy (10/10 checks passed)
1070
+ ✓ Ready to deploy (11/11 checks passed)
1031
1071
  ```
1032
1072
 
1073
+ The **Rollback workflow** check is informational and non-blocking (`✓` even when the file is missing). It suggests `deployhub sync-workflows` so already-initialized projects can pick up `deployhub-rollback.yml` without a full re-init. When the file exists, the message confirms the path instead.
1074
+
1033
1075
  If checks fail:
1034
1076
 
1035
1077
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akash-chowdhury-24/deployhub",
3
- "version": "2.0.14",
3
+ "version": "2.0.15",
4
4
  "description": "Zero-configuration deployment and artifact manager",
5
5
  "type": "module",
6
6
  "main": "./src/cli/index.js",
package/src/cli/index.js CHANGED
@@ -14,6 +14,7 @@ import { registerVerifyCommand } from '../commands/verify.js';
14
14
  import { registerCleanCommand } from '../commands/clean.js';
15
15
  import { registerUpdateCommand } from '../commands/update.js';
16
16
  import { registerSyncWorkflowsCommand } from '../commands/sync-workflows.js';
17
+ import { registerSyncK8sPortsCommand } from '../commands/sync-k8s-ports.js';
17
18
  import { formatVersionOutput, printBanner, shouldShowBanner } from '../utils/author.js';
18
19
 
19
20
  loadEnv();
@@ -43,5 +44,6 @@ registerVerifyCommand(program);
43
44
  registerCleanCommand(program);
44
45
  registerUpdateCommand(program);
45
46
  registerSyncWorkflowsCommand(program);
47
+ registerSyncK8sPortsCommand(program);
46
48
 
47
49
  program.parse();
@@ -0,0 +1,71 @@
1
+ import chalk from 'chalk';
2
+ import { loadConfig, loadEnv } from '../core/config.js';
3
+ import { resolveContainerPort } from '../utils/dockerfile-expose.js';
4
+ import {
5
+ getDefaultKubernetesManifestPaths,
6
+ syncKubernetesManifestPorts,
7
+ } from '../utils/kubernetes-manifests.js';
8
+ import fs from 'fs-extra';
9
+
10
+ /**
11
+ * Surgically fix containerPort/targetPort in existing k8s manifests from Dockerfile EXPOSE.
12
+ * @param {import('commander').Command} program
13
+ */
14
+ export function registerSyncK8sPortsCommand(program) {
15
+ program
16
+ .command('sync-k8s-ports')
17
+ .description(
18
+ 'Update only containerPort/targetPort in k8s/deployment.yaml and k8s/service.yaml ' +
19
+ 'from the Dockerfile EXPOSE port (or config/fallback). Does not change replicas, ' +
20
+ 'resources, env, probes, or Service port. Heavily customized / multi-container ' +
21
+ 'manifests may need a manual review.'
22
+ )
23
+ .action(async () => {
24
+ loadEnv();
25
+ const cwd = process.cwd();
26
+ const config = await loadConfig(cwd);
27
+
28
+ const { deploymentPath, servicePath } = getDefaultKubernetesManifestPaths(cwd);
29
+ const hasDeployment = await fs.pathExists(deploymentPath);
30
+ const hasService = await fs.pathExists(servicePath);
31
+
32
+ if (!hasDeployment && !hasService) {
33
+ console.log(
34
+ chalk.yellow(
35
+ 'No k8s/deployment.yaml or k8s/service.yaml found — nothing to patch. ' +
36
+ 'Generate starter manifests via deployhub init, or add manifests under ./k8s/.'
37
+ )
38
+ );
39
+ return;
40
+ }
41
+
42
+ const { port, source } = await resolveContainerPort(cwd, config);
43
+ const result = await syncKubernetesManifestPorts(cwd, port);
44
+
45
+ console.log(
46
+ chalk.green(
47
+ `✓ Resolved container port ${port} (source: ${source})`
48
+ )
49
+ );
50
+
51
+ if (result.patched.length === 0) {
52
+ console.log(chalk.gray(' Port fields already match — no files changed.'));
53
+ } else {
54
+ for (const file of result.patched) {
55
+ console.log(chalk.gray(` Updated ${file}`));
56
+ }
57
+ }
58
+
59
+ console.log('');
60
+ console.log(
61
+ chalk.yellow(
62
+ 'Note: Only containerPort and targetPort are updated. Replicas, resources, env, ' +
63
+ 'probes, and Service port: are left unchanged. If your manifests are heavily ' +
64
+ 'customized (multiple containers, non-standard field layout), review the diff ' +
65
+ 'manually before deploying.'
66
+ )
67
+ );
68
+ });
69
+ }
70
+
71
+ export default { registerSyncK8sPortsCommand };
@@ -0,0 +1,117 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+
4
+ /** Static SPA frameworks that use nginx:alpine + EXPOSE 80 in generated Dockerfiles. */
5
+ const STATIC_FRONTEND_FRAMEWORKS = new Set([
6
+ 'react',
7
+ 'vue',
8
+ 'angular',
9
+ 'svelte',
10
+ 'astro',
11
+ 'vanilla',
12
+ ]);
13
+
14
+ /**
15
+ * Parse the listening port from Dockerfile EXPOSE instructions.
16
+ * Uses the last EXPOSE line (final stage in multi-stage builds).
17
+ * Supports `EXPOSE 80`, `EXPOSE 80/tcp`, and multi-port lines (first numeric wins on that line).
18
+ *
19
+ * @param {string} content
20
+ * @returns {number|null}
21
+ */
22
+ export function parseDockerfileExposePort(content) {
23
+ if (!content || typeof content !== 'string') return null;
24
+
25
+ /** @type {number|null} */
26
+ let lastPort = null;
27
+
28
+ for (const rawLine of content.split(/\r?\n/)) {
29
+ const line = rawLine.trim();
30
+ if (!line || line.startsWith('#')) continue;
31
+
32
+ const match = line.match(/^EXPOSE\s+(.+)$/i);
33
+ if (!match) continue;
34
+
35
+ const tokens = match[1].trim().split(/\s+/);
36
+ for (const token of tokens) {
37
+ const portToken = token.split('/')[0];
38
+ if (!/^\d+$/.test(portToken)) continue;
39
+ const port = Number(portToken);
40
+ if (port >= 1 && port <= 65535) {
41
+ lastPort = port;
42
+ break;
43
+ }
44
+ }
45
+ }
46
+
47
+ return lastPort;
48
+ }
49
+
50
+ /**
51
+ * Per-type fallback matching generated Dockerfile templates when EXPOSE is unavailable.
52
+ *
53
+ * @param {import('../core/config.js').DeployHubConfig} config
54
+ * @returns {number}
55
+ */
56
+ export function resolveFallbackContainerPort(config) {
57
+ const projectType = config.projectType || 'frontend';
58
+ const framework =
59
+ (projectType === 'both'
60
+ ? config.backend?.framework || config.framework
61
+ : config.framework) ||
62
+ (projectType === 'frontend' ? 'react' : 'express');
63
+
64
+ if (projectType === 'frontend' && STATIC_FRONTEND_FRAMEWORKS.has(framework)) {
65
+ return 80;
66
+ }
67
+
68
+ if (['laravel', 'symfony', 'php'].includes(framework)) return 80;
69
+ if (['fastapi', 'django', 'flask', 'python'].includes(framework)) return 8000;
70
+ if (['spring', 'java'].includes(framework)) return 8080;
71
+ if (framework === 'go') return 8080;
72
+ if (framework === 'dotnet') return 5000;
73
+ if (framework === 'rails') return 3000;
74
+
75
+ // nextjs, nestjs, express, and other Node backends
76
+ return 3000;
77
+ }
78
+
79
+ /**
80
+ * Precedence: Dockerfile EXPOSE → config.port / backend.port → per-type fallback.
81
+ *
82
+ * @param {string} cwd
83
+ * @param {import('../core/config.js').DeployHubConfig} config
84
+ * @returns {Promise<{ port: number, source: 'expose'|'config'|'fallback' }>}
85
+ */
86
+ export async function resolveContainerPort(cwd, config) {
87
+ const dockerfilePath = path.join(cwd, 'Dockerfile');
88
+ if (await fs.pathExists(dockerfilePath)) {
89
+ try {
90
+ const content = await fs.readFile(dockerfilePath, 'utf8');
91
+ const exposed = parseDockerfileExposePort(content);
92
+ if (exposed != null) {
93
+ return { port: exposed, source: 'expose' };
94
+ }
95
+ } catch {
96
+ // treat as unparseable / unreadable → continue
97
+ }
98
+ }
99
+
100
+ if (config.projectType === 'both' && config.backend?.port) {
101
+ return { port: Number(config.backend.port), source: 'config' };
102
+ }
103
+ if (config.port) {
104
+ return { port: Number(config.port), source: 'config' };
105
+ }
106
+ if (config.backend?.port) {
107
+ return { port: Number(config.backend.port), source: 'config' };
108
+ }
109
+
110
+ return { port: resolveFallbackContainerPort(config), source: 'fallback' };
111
+ }
112
+
113
+ export default {
114
+ parseDockerfileExposePort,
115
+ resolveFallbackContainerPort,
116
+ resolveContainerPort,
117
+ };
@@ -1,5 +1,6 @@
1
1
  import fs from 'fs-extra';
2
2
  import path from 'path';
3
+ import { resolveContainerPort } from './dockerfile-expose.js';
3
4
 
4
5
  /**
5
6
  * @param {string} name
@@ -65,6 +66,11 @@ export function generateKubernetesManifests({
65
66
  ? ` imagePullSecrets:\n - name: ${imagePullSecret}\n`
66
67
  : '';
67
68
 
69
+ // Service port stays 80 (Ingress-friendly cluster-facing port).
70
+ // containerPort / targetPort must match the container's listening port (EXPOSE).
71
+ const servicePort = 80;
72
+ const targetPort = port;
73
+
68
74
  const deploymentYaml = `apiVersion: apps/v1
69
75
  kind: Deployment
70
76
  metadata:
@@ -96,9 +102,6 @@ ${pullSecretBlock} containers:
96
102
  cpu: "500m"
97
103
  `;
98
104
 
99
- const servicePort = port === 80 ? 80 : 80;
100
- const targetPort = port;
101
-
102
105
  const serviceYaml = `apiVersion: v1
103
106
  kind: Service
104
107
  metadata:
@@ -116,12 +119,29 @@ spec:
116
119
  return { deploymentYaml, serviceYaml };
117
120
  }
118
121
 
122
+ /**
123
+ * Surgically update only containerPort / targetPort in manifest YAML.
124
+ * Does not change Service `port:`, replicas, resources, env, probes, etc.
125
+ *
126
+ * @param {string} yaml
127
+ * @param {number} port
128
+ * @returns {{ content: string, changed: boolean }}
129
+ */
130
+ export function patchKubernetesManifestPorts(yaml, port) {
131
+ const next = String(yaml)
132
+ .replace(/^([ \t]*-?[ \t]*containerPort:[ \t]*)\d+[ \t]*$/gm, `$1${port}`)
133
+ .replace(/^([ \t]*targetPort:[ \t]*)\d+[ \t]*$/gm, `$1${port}`);
134
+
135
+ return { content: next, changed: next !== yaml };
136
+ }
137
+
119
138
  /**
120
139
  * @param {import('../core/config.js').DeployHubConfig} config
121
140
  * @param {Record<string, Record<string, unknown>>} [environments]
141
+ * @param {{ port?: number }} [options]
122
142
  * @returns {{ appName: string, imageName: string, imageTag: string, port: number, namespace: string, imagePullSecret: string }}
123
143
  */
124
- export function resolveKubernetesManifestOptions(config, environments = {}) {
144
+ export function resolveKubernetesManifestOptions(config, environments = {}, options = {}) {
125
145
  const envList = Object.values(environments);
126
146
  const k8sEnv = envList.find((env) => env.type === 'kubernetes') || {};
127
147
 
@@ -142,13 +162,18 @@ export function resolveKubernetesManifestOptions(config, environments = {}) {
142
162
  process.env.KUBE_IMAGE_PULL_SECRET ||
143
163
  '';
144
164
 
145
- let port = 3000;
146
- if (config.projectType === 'both' && config.backend?.port) {
147
- port = config.backend.port;
165
+ /** @type {number} */
166
+ let port;
167
+ if (typeof options.port === 'number' && Number.isFinite(options.port)) {
168
+ port = options.port;
169
+ } else if (config.projectType === 'both' && config.backend?.port) {
170
+ port = Number(config.backend.port);
148
171
  } else if (config.port) {
149
- port = config.port;
172
+ port = Number(config.port);
150
173
  } else if (config.backend?.port) {
151
- port = config.backend.port;
174
+ port = Number(config.backend.port);
175
+ } else {
176
+ port = 3000;
152
177
  }
153
178
 
154
179
  return {
@@ -161,9 +186,77 @@ export function resolveKubernetesManifestOptions(config, environments = {}) {
161
186
  };
162
187
  }
163
188
 
189
+ /**
190
+ * Resolve manifest options with Dockerfile EXPOSE → config → fallback port.
191
+ *
192
+ * @param {string} cwd
193
+ * @param {import('../core/config.js').DeployHubConfig} config
194
+ * @param {Record<string, Record<string, unknown>>} [environments]
195
+ */
196
+ export async function resolveKubernetesManifestOptionsFromCwd(
197
+ cwd,
198
+ config,
199
+ environments = {}
200
+ ) {
201
+ const { port, source } = await resolveContainerPort(cwd, config);
202
+ return {
203
+ ...resolveKubernetesManifestOptions(config, environments, { port }),
204
+ portSource: source,
205
+ };
206
+ }
207
+
208
+ /**
209
+ * Paths DeployHub normally writes for Kubernetes starter manifests.
210
+ * @param {string} cwd
211
+ * @returns {{ deploymentPath: string, servicePath: string }}
212
+ */
213
+ export function getDefaultKubernetesManifestPaths(cwd) {
214
+ const k8sDir = path.join(cwd, 'k8s');
215
+ return {
216
+ deploymentPath: path.join(k8sDir, 'deployment.yaml'),
217
+ servicePath: path.join(k8sDir, 'service.yaml'),
218
+ };
219
+ }
220
+
221
+ /**
222
+ * Patch containerPort/targetPort in existing k8s/deployment.yaml + service.yaml only.
223
+ *
224
+ * @param {string} cwd
225
+ * @param {number} port
226
+ * @returns {Promise<{ patched: string[], skipped: string[], port: number }>}
227
+ */
228
+ export async function syncKubernetesManifestPorts(cwd, port) {
229
+ const { deploymentPath, servicePath } = getDefaultKubernetesManifestPaths(cwd);
230
+ /** @type {string[]} */
231
+ const patched = [];
232
+ /** @type {string[]} */
233
+ const skipped = [];
234
+
235
+ for (const filePath of [deploymentPath, servicePath]) {
236
+ if (!(await fs.pathExists(filePath))) {
237
+ skipped.push(path.relative(cwd, filePath));
238
+ continue;
239
+ }
240
+ const original = await fs.readFile(filePath, 'utf8');
241
+ const { content, changed } = patchKubernetesManifestPorts(original, port);
242
+ if (changed) {
243
+ await fs.writeFile(filePath, content);
244
+ patched.push(path.relative(cwd, filePath));
245
+ } else {
246
+ skipped.push(path.relative(cwd, filePath));
247
+ }
248
+ }
249
+
250
+ return { patched, skipped, port };
251
+ }
252
+
164
253
  export default {
165
254
  sanitizeK8sName,
166
255
  hasKubernetesManifests,
167
256
  generateKubernetesManifests,
257
+ patchKubernetesManifestPorts,
168
258
  resolveKubernetesManifestOptions,
259
+ resolveKubernetesManifestOptionsFromCwd,
260
+ getDefaultKubernetesManifestPaths,
261
+ syncKubernetesManifestPorts,
169
262
  };
@@ -10,7 +10,7 @@ import {
10
10
  import {
11
11
  generateKubernetesManifests,
12
12
  hasKubernetesManifests,
13
- resolveKubernetesManifestOptions,
13
+ resolveKubernetesManifestOptionsFromCwd,
14
14
  } from './kubernetes-manifests.js';
15
15
 
16
16
  /**
@@ -134,7 +134,11 @@ export async function ensureKubernetesManifests(
134
134
  return { generated: false };
135
135
  }
136
136
 
137
- const manifestOptions = resolveKubernetesManifestOptions(config, environments);
137
+ const manifestOptions = await resolveKubernetesManifestOptionsFromCwd(
138
+ cwd,
139
+ config,
140
+ environments
141
+ );
138
142
  const { deploymentYaml, serviceYaml } = generateKubernetesManifests(manifestOptions);
139
143
 
140
144
  const k8sDir = path.join(cwd, 'k8s');