@akash-chowdhury-24/deployhub 2.0.3 → 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
 
@@ -604,9 +606,38 @@ DeployHub supports six deployment targets. Pick based on what infrastructure you
604
606
 
605
607
  Each method below follows the same structure: **prerequisites** (before `deployhub init`), **what DeployHub automates**, **after init** (matches terminal output), and a **variable reference**.
606
608
 
609
+ ### One-time server setup (before your first deploy)
610
+
611
+ SSH-based methods (SSH, EC2, Azure VM, GCP VM) require a few one-time steps on the server **before your first deploy**. DeployHub does not silently change ownership or sudo policy for you.
612
+
613
+ SSH into your server once and run:
614
+
615
+ ```bash
616
+ sudo mkdir -p /var/www/your-app-name
617
+ sudo chown your-ssh-user:your-ssh-user /var/www/your-app-name
618
+ ```
619
+
620
+ Replace `/var/www/your-app-name` with your actual deploy path and `your-ssh-user` with your configured `SSH_USER` (e.g. `ec2-user` on Amazon Linux, `ubuntu` on Ubuntu). Without this, DeployHub cannot write your build output — `deployhub doctor` will catch it and show the exact fix.
621
+
622
+ **Frontend deploys** that auto-activate `nginx.conf` also need **passwordless sudo** for Nginx test/reload (and `cp` into `/etc/nginx/`). After installing Nginx, run `sudo visudo` and add a line like:
623
+
624
+ ```bash
625
+ your-ssh-user ALL=(ALL) NOPASSWD: /usr/sbin/nginx, /bin/cp, /usr/bin/cp, /bin/systemctl, /usr/bin/systemctl
626
+ ```
627
+
628
+ > **Security note:** This example grants broad privileges — unrestricted `cp` (any source/destination) and `systemctl` (any unit/action), not just Nginx. That keeps setup simple but is a significant trust boundary. For production, prefer a dedicated deploy user and a **narrow wrapper script** (e.g. `/usr/local/bin/deployhub-nginx-reload` that only copies to your project's config path and runs `nginx -t` + reload), then grant `NOPASSWD` only for that script. The line above is a starting point for dev/test servers; tighten it before production.
629
+
630
+ Install Nginx if it is not already present:
631
+
632
+ - **Amazon Linux / RHEL:** `sudo yum install -y nginx && sudo systemctl enable --now nginx`
633
+ - **Ubuntu / Debian:** `sudo apt install -y nginx`
634
+
635
+ DeployHub detects whether the server uses Debian-style `sites-available` or RHEL-style `conf.d` at deploy time and writes a **uniquely named** config file for your project only — it does not overwrite unrelated Nginx configs.
636
+
607
637
  ### SSH
608
638
 
609
639
  **Prerequisites (before `deployhub init`):**
640
+ - [ ] Complete **[one-time server setup](#one-time-server-setup-before-your-first-deploy)** (deploy path ownership + Nginx/sudo for frontends)
610
641
  - [ ] A Linux server with SSH enabled
611
642
  - [ ] Private SSH key file (.pem/.key) and public key in `authorized_keys`
612
643
  - [ ] Port 22 open in firewall for your IP
@@ -618,6 +649,9 @@ Each method below follows the same structure: **prerequisites** (before `deployh
618
649
  - SSH key permission check (offers to `chmod 600`)
619
650
  - SSH connectivity test during `init`
620
651
  - Deploy path write-permission check during `deployhub doctor`
652
+ - Nginx layout detection (Debian `sites-available` vs RHEL `conf.d`) at deploy time
653
+ - Nginx config test (`nginx -t`) before reload
654
+ - Passwordless sudo and Nginx checks during `deployhub doctor` (frontend)
621
655
  - Artifact upload, extract, app restart (PM2, gunicorn, etc.)
622
656
 
623
657
  **After `init`:**
@@ -643,10 +677,11 @@ Each method below follows the same structure: **prerequisites** (before `deployh
643
677
 
644
678
  **Prerequisites:**
645
679
  - [ ] Docker installed (`docker --version` works)
646
- - [ ] `Dockerfile` or `docker-compose.yml` in project
647
680
  - [ ] Registry account if pushing private images
681
+ - [ ] `docker-compose.yml` in project if you use multi-service Compose (not auto-generated)
648
682
 
649
683
  **What DeployHub automates:**
684
+ - Starter `Dockerfile` at project root when none exists (framework-aware; skipped if you already have one)
650
685
  - `.env.example` for image name, registry, remote `DOCKER_HOST`
651
686
  - Docker daemon connectivity test during `init`
652
687
  - `docker compose up` or build/push/run during deploy
@@ -669,6 +704,7 @@ Each method below follows the same structure: **prerequisites** (before `deployh
669
704
  ### AWS EC2
670
705
 
671
706
  **Prerequisites:**
707
+ - [ ] Complete **[one-time server setup](#one-time-server-setup-before-your-first-deploy)** (`ec2-user` on Amazon Linux)
672
708
  - [ ] EC2 instance launched in AWS Console (DeployHub does not create it)
673
709
  - [ ] Key pair `.pem` downloaded at launch
674
710
  - [ ] Security group: inbound SSH (22) from your IP
@@ -679,6 +715,8 @@ Each method below follows the same structure: **prerequisites** (before `deployh
679
715
  - EC2-specific `.env.example` (SSH + optional AWS API vars)
680
716
  - SSH key validation and connectivity test
681
717
  - Deploy path write-permission check during `deployhub doctor`
718
+ - Nginx layout detection and config test before reload (frontend)
719
+ - Passwordless sudo and Nginx checks during `deployhub doctor` (frontend)
682
720
  - OS user suggestion from AMI hint (ubuntu, ec2-user)
683
721
  - Optional public IP lookup via `EC2_INSTANCE_ID` + AWS CLI
684
722
 
@@ -702,6 +740,7 @@ Each method below follows the same structure: **prerequisites** (before `deployh
702
740
  ### Azure VM
703
741
 
704
742
  **Prerequisites:**
743
+ - [ ] Complete **[one-time server setup](#one-time-server-setup-before-your-first-deploy)** (`azureuser` or your VM login user)
705
744
  - [ ] Azure VM created in Portal (DeployHub does not provision it)
706
745
  - [ ] NSG rule allowing inbound SSH (port 22)
707
746
  - [ ] SSH public key on the VM
@@ -713,6 +752,8 @@ Each method below follows the same structure: **prerequisites** (before `deployh
713
752
  - Auto-detects subscription ID via `az` CLI if logged in
714
753
  - SSH key validation and connectivity test
715
754
  - Deploy path write-permission check during `deployhub doctor`
755
+ - Nginx layout detection and config test before reload (frontend)
756
+ - Passwordless sudo and Nginx checks during `deployhub doctor` (frontend)
716
757
 
717
758
  **After `init`:**
718
759
  1. Azure Portal → VM → Networking → allow SSH (22) from your IP
@@ -733,6 +774,7 @@ Each method below follows the same structure: **prerequisites** (before `deployh
733
774
  ### GCP VM
734
775
 
735
776
  **Prerequisites:**
777
+ - [ ] Complete **[one-time server setup](#one-time-server-setup-before-your-first-deploy)** (your GCP SSH username)
736
778
  - [ ] Compute Engine VM created (DeployHub does not create it)
737
779
  - [ ] Firewall rule allowing `tcp:22` (default `default-allow-ssh` may exist)
738
780
  - [ ] SSH public key in **Metadata → SSH Keys** (GCP uses metadata keys, not launch key pairs like AWS)
@@ -744,6 +786,8 @@ Each method below follows the same structure: **prerequisites** (before `deployh
744
786
  - Auto-detects project ID via `gcloud` if authenticated
745
787
  - SSH key validation and connectivity test
746
788
  - Deploy path write-permission check during `deployhub doctor`
789
+ - Nginx layout detection and config test before reload (frontend)
790
+ - Passwordless sudo and Nginx checks during `deployhub doctor` (frontend)
747
791
 
748
792
  **After `init`:**
749
793
  1. GCP Console → VPC → Firewall → ensure SSH (tcp:22) allowed from your IP
@@ -771,11 +815,12 @@ Each method below follows the same structure: **prerequisites** (before `deployh
771
815
 
772
816
  **Prerequisites:**
773
817
  - [ ] Existing Kubernetes cluster (DeployHub does not provision clusters)
774
- - [ ] `kubectl` installed and configured
775
- - [ ] Kubernetes manifests (`.yaml` or `k8s/` directory) in your repo
818
+ - [ ] `kubectl` installed and configured on your **local machine** (for `deployhub doctor` / manual `deployhub deploy`)
776
819
  - [ ] Cluster reachable from CI (kubeconfig secret or cloud auth)
777
820
 
778
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)
779
824
  - Lists `kubectl` contexts during `init` for easy selection
780
825
  - Auto-detects `~/.kube/config`
781
826
  - Complete `.env.example` for kubeconfig, context, namespace
@@ -794,6 +839,7 @@ Each method below follows the same structure: **prerequisites** (before `deployh
794
839
  | `KUBE_CONTEXT` | Context name | `my-cluster` | `kubectl config get-contexts` |
795
840
  | `KUBE_NAMESPACE` | Target namespace | `my-app` | `kubectl get namespaces` |
796
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 |
797
843
  | `KUBE_IMAGE_PULL_SECRET` | Pull secret name | `regcred` | `kubectl create secret docker-registry` |
798
844
 
799
845
  ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akash-chowdhury-24/deployhub",
3
- "version": "2.0.3",
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);
@@ -18,6 +18,7 @@ import {
18
18
  buildDeployPathWriteTestCommand,
19
19
  formatDeployPathWriteFailure,
20
20
  } from '../utils/shell-quote.js';
21
+ import { formatPasswordlessSudoGuidance } from '../utils/nginx.js';
21
22
 
22
23
  /**
23
24
  * @typedef {{ name: string, pass: boolean, message: string }} CheckResult
@@ -82,6 +83,16 @@ function resolveSshDeployPaths(config, envConfig) {
82
83
  return [...new Set(paths.filter(Boolean))];
83
84
  }
84
85
 
86
+ /**
87
+ * @param {import('../core/config.js').DeployHubConfig} config
88
+ * @returns {boolean}
89
+ */
90
+ function needsNginxActivationForDeploy(config) {
91
+ if (config.projectType === 'backend') return false;
92
+ if (config.projectType === 'frontend' && config.framework === 'nextjs') return false;
93
+ return true;
94
+ }
95
+
85
96
  /**
86
97
  * @param {import('../core/config.js').DeployHubConfig} config
87
98
  * @param {string} envName
@@ -207,6 +218,149 @@ async function runDeploymentChecks(config, envName, envConfig) {
207
218
  );
208
219
  }
209
220
 
221
+ if (needsNginxActivationForDeploy(config)) {
222
+ checks.push(
223
+ await runCheck('Nginx installed', async () => {
224
+ if (!host || !user) {
225
+ return {
226
+ name: 'Nginx installed',
227
+ pass: false,
228
+ message: 'SSH_HOST and SSH_USER are required to check Nginx on the server.',
229
+ };
230
+ }
231
+ if (!keyPath && !process.env.SSH_KEY) {
232
+ return {
233
+ name: 'Nginx installed',
234
+ pass: false,
235
+ message: 'SSH_KEY_PATH or SSH_KEY is required to check Nginx on the server.',
236
+ };
237
+ }
238
+
239
+ const provider = getDeploymentProvider(deployType, config, envName);
240
+ if (!provider.runRemoteCheck) {
241
+ return {
242
+ name: 'Nginx installed',
243
+ pass: false,
244
+ message: 'Deploy provider does not support remote checks.',
245
+ };
246
+ }
247
+
248
+ const result = await provider.runRemoteCheck('command -v nginx >/dev/null 2>&1 && echo yes');
249
+ if (result.pass && result.message.includes('yes')) {
250
+ return { name: 'Nginx installed', pass: true, message: 'Nginx installed on server' };
251
+ }
252
+ return {
253
+ name: 'Nginx installed',
254
+ pass: false,
255
+ message:
256
+ 'Nginx not found on server — install it first (e.g. sudo yum install nginx on Amazon Linux, or sudo apt install nginx on Ubuntu).',
257
+ };
258
+ })
259
+ );
260
+
261
+ checks.push(
262
+ await runCheck('Passwordless sudo', async () => {
263
+ if (!host || !user) {
264
+ return {
265
+ name: 'Passwordless sudo',
266
+ pass: false,
267
+ message: 'SSH_HOST and SSH_USER are required for sudo check.',
268
+ };
269
+ }
270
+ if (!keyPath && !process.env.SSH_KEY) {
271
+ return {
272
+ name: 'Passwordless sudo',
273
+ pass: false,
274
+ message: 'SSH_KEY_PATH or SSH_KEY is required for sudo check.',
275
+ };
276
+ }
277
+
278
+ const provider = getDeploymentProvider(deployType, config, envName);
279
+ if (!provider.runRemoteCheck) {
280
+ return {
281
+ name: 'Passwordless sudo',
282
+ pass: false,
283
+ message: 'Deploy provider does not support remote checks.',
284
+ };
285
+ }
286
+
287
+ const result = await provider.runRemoteCheck('sudo -n true');
288
+ if (result.pass) {
289
+ return {
290
+ name: 'Passwordless sudo',
291
+ pass: true,
292
+ message: 'Non-interactive sudo available (required for Nginx config activation)',
293
+ };
294
+ }
295
+ return {
296
+ name: 'Passwordless sudo',
297
+ pass: false,
298
+ message: formatPasswordlessSudoGuidance(sshUser),
299
+ };
300
+ })
301
+ );
302
+
303
+ checks.push(
304
+ await runCheck('Nginx sudo access', async () => {
305
+ if (!host || !user) {
306
+ return {
307
+ name: 'Nginx sudo access',
308
+ pass: false,
309
+ message: 'SSH_HOST and SSH_USER are required for Nginx sudo check.',
310
+ };
311
+ }
312
+ if (!keyPath && !process.env.SSH_KEY) {
313
+ return {
314
+ name: 'Nginx sudo access',
315
+ pass: false,
316
+ message: 'SSH_KEY_PATH or SSH_KEY is required for Nginx sudo check.',
317
+ };
318
+ }
319
+
320
+ const provider = getDeploymentProvider(deployType, config, envName);
321
+ if (!provider.runRemoteCheck) {
322
+ return {
323
+ name: 'Nginx sudo access',
324
+ pass: false,
325
+ message: 'Deploy provider does not support remote checks.',
326
+ };
327
+ }
328
+
329
+ const nginxInstalled = await provider.runRemoteCheck('command -v nginx >/dev/null 2>&1 && echo yes');
330
+ if (!nginxInstalled.pass || !nginxInstalled.message.includes('yes')) {
331
+ return {
332
+ name: 'Nginx sudo access',
333
+ pass: false,
334
+ message: 'Skipped — install Nginx first.',
335
+ };
336
+ }
337
+
338
+ const sudoOk = await provider.runRemoteCheck('sudo -n true');
339
+ if (!sudoOk.pass) {
340
+ return {
341
+ name: 'Nginx sudo access',
342
+ pass: false,
343
+ message: 'Skipped — configure passwordless sudo first.',
344
+ };
345
+ }
346
+
347
+ const result = await provider.runRemoteCheck('sudo -n nginx -t 2>&1');
348
+ if (result.pass) {
349
+ return {
350
+ name: 'Nginx sudo access',
351
+ pass: true,
352
+ message: 'sudo nginx -t OK (can test config before reload)',
353
+ };
354
+ }
355
+ return {
356
+ name: 'Nginx sudo access',
357
+ pass: false,
358
+ message: `sudo nginx -t failed — ${result.message}. Check Nginx install and sudoers (see README one-time server setup).`,
359
+ };
360
+ })
361
+ );
362
+ }
363
+
210
364
  const isBackend = config.projectType === 'backend' || config.projectType === 'both';
211
365
  if (isBackend) {
212
366
  const backendChecks = await runBackendProcessChecks(config, envName, deployType);
@@ -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;
@@ -3,7 +3,11 @@ 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 { getNginxSitePath } from '../../utils/nginx.js';
6
+ import {
7
+ getNginxSitesAvailablePath,
8
+ getNginxSitesEnabledPath,
9
+ getNginxConfDPath,
10
+ } from '../../utils/nginx.js';
7
11
  import { shellQuote, formatRemoteCommandFailure } from '../../utils/shell-quote.js';
8
12
 
9
13
  /** @type {Set<string>} */
@@ -240,24 +244,85 @@ export function createSshProvider(config, envName, env = process.env) {
240
244
  await exec(ssh, 'pm2 save');
241
245
  }
242
246
 
247
+ /**
248
+ * @param {import('node-ssh').NodeSSH} ssh
249
+ * @param {string} remotePath
250
+ * @param {'f'|'d'} [kind='f']
251
+ */
252
+ async function remotePathExists(ssh, remotePath, kind = 'f') {
253
+ const flag = kind === 'd' ? '-d' : '-f';
254
+ const result = await ssh.execCommand(`test ${flag} ${sh(remotePath)} && echo yes`);
255
+ return result.code === 0 && result.stdout.trim() === 'yes';
256
+ }
257
+
258
+ /**
259
+ * @param {import('node-ssh').NodeSSH} ssh
260
+ * @param {string} command
261
+ */
262
+ async function remoteCommandExists(ssh, command) {
263
+ const result = await ssh.execCommand(`command -v ${sh(command)} >/dev/null 2>&1 && echo yes`);
264
+ return result.code === 0 && result.stdout.trim() === 'yes';
265
+ }
266
+
267
+ /**
268
+ * @param {import('node-ssh').NodeSSH} ssh
269
+ * @returns {Promise<'debian'|'rhel'>}
270
+ */
271
+ async function detectNginxLayout(ssh) {
272
+ if (await remotePathExists(ssh, '/etc/nginx/sites-available', 'd')) {
273
+ return 'debian';
274
+ }
275
+ return 'rhel';
276
+ }
277
+
278
+ /**
279
+ * @param {import('node-ssh').NodeSSH} ssh
280
+ */
281
+ async function reloadNginx(ssh) {
282
+ await exec(ssh, 'sudo systemctl reload nginx 2>/dev/null || sudo nginx -s reload');
283
+ }
284
+
243
285
  /**
244
286
  * @param {import('node-ssh').NodeSSH} ssh
245
287
  * @param {string} targetPath
246
288
  */
247
289
  async function setupNginx(ssh, targetPath) {
248
- const sitePath = getNginxSitePath(config.project);
249
290
  const nginxConfRemote = `${targetPath}/nginx.conf`;
250
291
 
251
- await exec(
252
- ssh,
253
- `sudo cp ${sh(nginxConfRemote)} ${sh(sitePath)} 2>/dev/null || sudo cp ${sh(`${targetPath}/nginx.conf`)} ${sh(sitePath)}`
254
- );
255
- await exec(
256
- ssh,
257
- `sudo ln -sf ${sh(sitePath)} ${sh(`/etc/nginx/sites-enabled/${path.basename(sitePath)}`)}`
292
+ if (!(await remoteCommandExists(ssh, 'nginx'))) {
293
+ throw new Error(
294
+ 'Nginx is not installed on the server. Install it first (e.g. sudo yum install nginx on Amazon Linux, or sudo apt install nginx on Ubuntu), then re-run deploy.'
295
+ );
296
+ }
297
+
298
+ if (!(await remotePathExists(ssh, nginxConfRemote))) {
299
+ throw new Error(
300
+ `Nginx config not found at ${nginxConfRemote} — artifact may be missing nginx.conf.`
301
+ );
302
+ }
303
+
304
+ const layout = await detectNginxLayout(ssh);
305
+ log.info(
306
+ layout === 'debian'
307
+ ? 'Detected Nginx layout: Debian/Ubuntu (sites-available)'
308
+ : 'Detected Nginx layout: RHEL/Amazon Linux (conf.d)'
258
309
  );
310
+
311
+ if (layout === 'debian') {
312
+ const sitePath = getNginxSitesAvailablePath(config.project);
313
+ const enabledPath = getNginxSitesEnabledPath(config.project);
314
+ await exec(ssh, `sudo cp ${sh(nginxConfRemote)} ${sh(sitePath)}`);
315
+ await exec(ssh, `sudo ln -sf ${sh(sitePath)} ${sh(enabledPath)}`);
316
+ log.info(`Nginx config installed: ${sitePath}`);
317
+ } else {
318
+ const confPath = getNginxConfDPath(config.project);
319
+ await exec(ssh, `sudo cp ${sh(nginxConfRemote)} ${sh(confPath)}`);
320
+ log.info(`Nginx config installed: ${confPath}`);
321
+ }
322
+
259
323
  await exec(ssh, 'sudo nginx -t');
260
- await exec(ssh, 'sudo systemctl reload nginx');
324
+ await reloadNginx(ssh);
325
+ log.success('Nginx config tested and reloaded');
261
326
  }
262
327
 
263
328
  /**