@akash-chowdhury-24/deployhub 2.0.26 → 2.0.28

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
@@ -651,6 +651,7 @@ All JS frontends share the same install/build flow: `npm ci` → `npm run build`
651
651
 
652
652
  - **Detect:** `composer.json` with `laravel/framework` or `symfony/framework-bundle`.
653
653
  - **Install:** Composer (on CI and server).
654
+ - **CI runtime:** Generated workflows install PHP via `shivammathur/setup-php@v2` with Composer. Default version is **8.4** (meets current Laravel platform requirements). Override with `"phpVersion": "8.3"` at the config root or under `"backend"` in `deployhub.config.json`, then run `deployhub sync-workflows`.
654
655
  - **Deploy:** SSH with PHP-FPM or `php artisan` for Laravel.
655
656
 
656
657
  > ⚠️ **PHP-FPM deployments restart the FPM service for the ENTIRE host on every deploy.** If you run multiple DeployHub-managed environments on the same server, deploying ANY of them will briefly interrupt in-flight requests for ALL of them. For production use with multiple environments, either use separate hosts per environment, or set up per-environment PHP-FPM pools manually (not yet automated by DeployHub).
@@ -695,7 +696,9 @@ git commit -m "Add DeployHub CI"
695
696
  2. Add every secret listed at the end of `deployhub init` (storage + deployment).
696
697
  3. Push to `main` or `master` — the deploy workflow triggers on push.
697
698
 
698
- 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.
699
+ The deploy workflow (`deployhub.yml`) installs the correct language runtime (Node, Python, PHP, Java, Go, .NET, Ruby) based on your `deployhub.config.json`, installs DeployHub, runs `deployhub build`, and uses your secrets. For PHP projects, CI uses `shivammathur/setup-php` (default **8.4**; override with `phpVersion` / `backend.phpVersion`).
700
+
701
+ When an environment uses Kubernetes, the workflow installs `kubectl` and writes kubeconfig from secrets — but only when that run actually needs cluster access (push with a push-triggered k8s env, or workflow_dispatch / rollback targeting a k8s env, `all`, or blank). Plain pushes that only auto-deploy non-k8s environments (e.g. EC2 development) skip those steps.
699
702
 
700
703
  To run a deploy manually: **Actions → DeployHub → Run workflow**.
701
704
 
@@ -883,13 +886,13 @@ DeployHub detects whether the server uses Debian-style `sites-available` or RHEL
883
886
  - Nginx layout detection and config test before reload (frontend)
884
887
  - Passwordless sudo and Nginx checks during `deployhub doctor` (frontend)
885
888
  - OS user suggestion from AMI hint (ubuntu, ec2-user)
886
- - Optional public IP lookup via `EC2_INSTANCE_ID` + AWS CLI
889
+ - Optional public IP lookup via `EC2_INSTANCE_ID` + `EC2_LOOKUP_AWS_*` (AWS CLI)
887
890
 
888
891
  **After `init`:**
889
892
  1. AWS Console → EC2 → Security Groups → Inbound rules → SSH port 22 from My IP
890
893
  2. Ensure your deploy directory exists and is owned by your SSH user (see prerequisite above if `deployhub doctor` reports permission denied)
891
- 3. Copy `.env.example` → `.env`; set `SSH_KEY_PATH`, `SSH_HOST` (or `EC2_INSTANCE_ID` + AWS creds)
892
- 4. GitHub Secrets: `SSH_HOST`, `SSH_USER`, `SSH_KEY`, plus `AWS_*` if using instance ID lookup
894
+ 3. Copy `.env.example` → `.env`; set `SSH_KEY_PATH`, `SSH_HOST` (or `EC2_INSTANCE_ID` + `EC2_LOOKUP_AWS_*`)
895
+ 4. GitHub Secrets: `SSH_HOST`, `SSH_USER`, `SSH_KEY`, plus `EC2_LOOKUP_AWS_*` if using instance ID lookup (these are **not** the same secrets as AWS S3 storage)
893
896
  5. Run `deployhub doctor`, then `git push origin main`
894
897
 
895
898
  | Variable | Description | Example | Where to get it |
@@ -898,9 +901,9 @@ DeployHub detects whether the server uses Debian-style `sites-available` or RHEL
898
901
  | `SSH_USER` | SSH user for AMI | `ec2-user` | AMI documentation |
899
902
  | `SSH_KEY_PATH` | Path to .pem key | `~/.ssh/ec2-key.pem` | Downloaded at instance launch |
900
903
  | `EC2_INSTANCE_ID` | Instance ID (optional) | `i-0abc123...` | EC2 Console |
901
- | `AWS_ACCESS_KEY_ID` | AWS key for API lookup | `AKIA...` | IAM → Users → Security credentials |
902
- | `AWS_SECRET_ACCESS_KEY` | AWS secret | *(secret)* | Same as above |
903
- | `AWS_REGION` | Instance region | `us-east-1` | EC2 Console top bar |
904
+ | `EC2_LOOKUP_AWS_ACCESS_KEY_ID` | AWS key for EC2 IP lookup (distinct from S3) | `AKIA...` | IAM → Users → Security credentials |
905
+ | `EC2_LOOKUP_AWS_SECRET_ACCESS_KEY` | AWS secret for EC2 IP lookup | *(secret)* | Same as above |
906
+ | `EC2_LOOKUP_AWS_REGION` | Instance region for DescribeInstances | `us-east-1` | EC2 Console top bar |
904
907
 
905
908
  ### Azure VM
906
909
 
@@ -926,7 +929,7 @@ DeployHub detects whether the server uses Debian-style `sites-available` or RHEL
926
929
  1. Azure Portal → VM → Networking → allow SSH (22) from your IP
927
930
  2. Ensure your deploy directory exists and is owned by your SSH user (see prerequisite above if `deployhub doctor` reports permission denied)
928
931
  3. Copy `.env.example` → `.env`; set `SSH_HOST`, `SSH_USER`, `SSH_KEY_PATH`
929
- 4. For CI: add `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET` as GitHub Secrets
932
+ 4. For CI: add `AZURE_VM_LOOKUP_TENANT_ID`, `AZURE_VM_LOOKUP_CLIENT_ID`, `AZURE_VM_LOOKUP_CLIENT_SECRET` as GitHub Secrets
930
933
  5. Run `deployhub doctor`, then `git push origin main`
931
934
 
932
935
  | Variable | Description | Example | Where to get it |
@@ -934,9 +937,9 @@ DeployHub detects whether the server uses Debian-style `sites-available` or RHEL
934
937
  | `SSH_HOST` | VM public IP | `20.1.2.3` | Azure Portal → VM overview |
935
938
  | `SSH_USER` | SSH username | `azureuser` | Chosen at VM creation |
936
939
  | `SSH_KEY_PATH` | Private key path | `~/.ssh/azure.pem` | Your key file |
937
- | `AZURE_SUBSCRIPTION_ID` | Subscription (optional) | `uuid` | `az account show` |
938
- | `AZURE_RESOURCE_GROUP` | Resource group | `my-app-rg` | Portal → Resource groups |
939
- | `AZURE_VM_NAME` | VM name | `my-vm` | Portal → Virtual machines |
940
+ | `AZURE_VM_LOOKUP_SUBSCRIPTION_ID` | Subscription (optional IP lookup) | `uuid` | `az account show` |
941
+ | `AZURE_VM_LOOKUP_RESOURCE_GROUP` | Resource group | `my-app-rg` | Portal → Resource groups |
942
+ | `AZURE_VM_LOOKUP_VM_NAME` | VM name | `my-vm` | Portal → Virtual machines |
940
943
 
941
944
  ### GCP VM
942
945
 
@@ -975,10 +978,10 @@ DeployHub detects whether the server uses Debian-style `sites-available` or RHEL
975
978
  | `SSH_APP_NAME` | PM2 process name (backend) | `my-api` | Your choice |
976
979
  | `SSH_PORT` | App listen port (backend) | `3000` | Your app config |
977
980
  | `SSH_KEY` | Private key contents (CI only) | `-----BEGIN...` | Same key as `SSH_KEY_PATH` |
978
- | `GCP_PROJECT_ID` | Project ID (optional) | `my-project-123` | `gcloud config get-value project` |
981
+ | `GCP_VM_LOOKUP_PROJECT_ID` | Project ID for VM IP lookup (distinct from GCP Storage) | `my-project-123` | `gcloud config get-value project` |
979
982
  | `GCP_ZONE` | VM zone (optional) | `us-central1-a` | VM instance details |
980
983
  | `GCP_INSTANCE_NAME` | Instance name (optional) | `my-vm` | Compute Engine list |
981
- | `GCP_KEY_FILE` | Service account JSON (optional, CI) | `/path/to/key.json` | IAM → Service Accounts → Keys |
984
+ | `GCP_VM_LOOKUP_KEY_FILE` | Service account JSON for VM lookup (distinct from GCP Storage) | `/path/to/key.json` | IAM → Service Accounts → Keys |
982
985
 
983
986
  ### Kubernetes
984
987
 
@@ -993,7 +996,7 @@ DeployHub detects whether the server uses Debian-style `sites-available` or RHEL
993
996
  **Init prompts (what `deployhub init` asks for Kubernetes):**
994
997
  1. Path to kubeconfig file (e.g. `~/.kube/config`)
995
998
  2. Kubernetes context
996
- 3. Namespace (defaults to project name)
999
+ 3. Namespace (defaults to project name for the first env; additional envs suggest `{project}-{envName}` to avoid collisions)
997
1000
  4. Container image name
998
1001
  5. Registry URL (leave empty for Docker Hub)
999
1002
  6. Registry username (required to push)
@@ -1002,13 +1005,15 @@ DeployHub detects whether the server uses Debian-style `sites-available` or RHEL
1002
1005
 
1003
1006
  **What DeployHub automates:**
1004
1007
  - 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)
1005
- - 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)
1008
+ - GitHub Actions installs `kubectl` on the CI runner and writes kubeconfig from secrets when the run targets a Kubernetes environment (skipped on push when k8s is manual-only)
1006
1009
  - Lists `kubectl` contexts during `init` for easy selection
1007
1010
  - Auto-detects `~/.kube/config`
1008
1011
  - Complete `.env.example` for kubeconfig, context, namespace, and registry settings
1009
1012
  - Cluster connectivity test during `init`
1010
1013
  - On deploy: registry login → reuse or build image → push (unique tag unless `DOCKER_IMAGE_TAG` is set) → ensure namespace exists (prompt locally / auto-create in CI) → `kubectl apply` → `kubectl set image` with the full resolved image ref → `kubectl rollout restart` when that ref is unchanged so pods pick up a new digest
1011
1014
 
1015
+ > **Limitation — interpreted backends (Node / Python / PHP / Rails) + rollback:** Kubernetes rollback forces a rebuild from the restored artifact when the exact `buildId` image is not already local. Those artifacts ship source/`composer.json` (etc.) but **not** installed deps (`vendor/`, `node_modules`, …), so DeployHub **refuses** with a clear error instead of a confusing Docker build failure. Successful rollback needs the restored image tag already present locally (or a prior `pipeline.docker` build that produced it). Same rule as standalone Docker deploy fallback.
1016
+
1012
1017
  > **Limitation — multiple Kubernetes clusters:** A generated workflow writes **one** kubeconfig file per job. Multiple Kubernetes environments that target **different clusters** in the same workflow run are not yet fully supported (follow-up). Same-cluster multi-namespace / multi-env is fine.
1013
1018
 
1014
1019
  **After `init`:**
@@ -1140,7 +1145,20 @@ Run `deployhub doctor` after any config change.
1140
1145
  | `deployhub clean` | Remove old local artifacts |
1141
1146
  | `deployhub update` | Check for CLI updates |
1142
1147
 
1143
- **Tests:** `npm test` — currently **320 passing** across the Jest suites.
1148
+ **Tests:** `npm test` — currently **442 passing** across the Jest suites (1 skipped).
1149
+
1150
+ ## Storage vs deployment lookup credentials
1151
+
1152
+ Storage credentials (`AWS_*` for S3, `GCP_*` for GCP Storage, …) are **project-wide and unprefixed**. Optional cloud-API credentials used only for **dynamic VM IP lookup** on EC2 / Azure VM / GCP VM use a distinct `{METHOD}_LOOKUP_…` namespace so they never collide with storage — even on a single-environment project.
1153
+
1154
+ | Concern | Examples |
1155
+ |---------|----------|
1156
+ | Storage | `AWS_ACCESS_KEY_ID`, `GCP_PROJECT_ID`, `GCP_KEY_FILE` |
1157
+ | EC2 instance-IP lookup | `EC2_LOOKUP_AWS_ACCESS_KEY_ID`, `EC2_LOOKUP_AWS_SECRET_ACCESS_KEY`, `EC2_LOOKUP_AWS_REGION` |
1158
+ | GCP VM IP lookup | `GCP_VM_LOOKUP_PROJECT_ID`, `GCP_VM_LOOKUP_KEY_FILE` |
1159
+ | Azure VM IP lookup | `AZURE_VM_LOOKUP_SUBSCRIPTION_ID`, `AZURE_VM_LOOKUP_RESOURCE_GROUP`, `AZURE_VM_LOOKUP_VM_NAME`, … |
1160
+
1161
+ Multi-env secret prefixing still applies on top (e.g. `PRODUCTION_EC2_LOOKUP_AWS_ACCESS_KEY_ID`).
1144
1162
 
1145
1163
  ## GitHub Secrets
1146
1164
 
@@ -1164,7 +1182,7 @@ Add these secrets in your repository (Settings → Secrets and variables → Act
1164
1182
  | `GCP_KEY_FILE` | GCP Storage |
1165
1183
  | `GCP_BUCKET` | GCP Storage |
1166
1184
  | `DROPBOX_ACCESS_TOKEN` | Dropbox |
1167
- | `FTP_HOST`, `FTP_USER`, `FTP_PASSWORD` | FTP storage |
1185
+ | `FTP_HOST`, `FTP_USER`, `FTP_PASSWORD`, `FTP_PORT`, `FTP_PATH` | FTP storage (`FTP_PORT` default 21, `FTP_PATH` default `/uploads` — connection tuning, not secrets) |
1168
1186
 
1169
1187
  ### Server deployment (SSH, EC2, VMs, Docker, Kubernetes)
1170
1188
 
@@ -1178,9 +1196,9 @@ Add these secrets in your repository (Settings → Secrets and variables → Act
1178
1196
  | `SSH_DEPLOY_PATH` | Remote directory (optional if set in config) |
1179
1197
  | `SSH_APP_NAME` | PM2 process name for backends |
1180
1198
  | `SSH_PORT` | App port on server (backend) |
1181
- | `EC2_INSTANCE_ID`, `AWS_*` | Optional EC2 dynamic IP lookup |
1182
- | `AZURE_SUBSCRIPTION_ID`, `AZURE_RESOURCE_GROUP`, `AZURE_VM_NAME` | Optional Azure VM IP lookup |
1183
- | `GCP_PROJECT_ID`, `GCP_ZONE`, `GCP_INSTANCE_NAME`, `GCP_KEY_FILE` | Optional GCP VM IP lookup |
1199
+ | `EC2_INSTANCE_ID`, `EC2_LOOKUP_AWS_*` | Optional EC2 dynamic IP lookup (distinct from S3 `AWS_*`) |
1200
+ | `AZURE_VM_LOOKUP_SUBSCRIPTION_ID`, `AZURE_VM_LOOKUP_RESOURCE_GROUP`, `AZURE_VM_LOOKUP_VM_NAME` | Optional Azure VM IP lookup |
1201
+ | `GCP_VM_LOOKUP_PROJECT_ID`, `GCP_ZONE`, `GCP_INSTANCE_NAME`, `GCP_VM_LOOKUP_KEY_FILE` | Optional GCP VM IP lookup (project/key distinct from GCP Storage) |
1184
1202
  | `DOCKER_IMAGE_NAME`, `DOCKER_REGISTRY_USERNAME`, `DOCKER_REGISTRY_TOKEN`, `DOCKER_REGISTRY_URL`, `DOCKER_HOST` | Docker deployment (`DOCKER_IMAGE_TAG` optional) |
1185
1203
  | `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 |
1186
1204
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akash-chowdhury-24/deployhub",
3
- "version": "2.0.26",
3
+ "version": "2.0.28",
4
4
  "description": "Zero-configuration deployment and artifact manager",
5
5
  "type": "module",
6
6
  "main": "./src/cli/index.js",
@@ -32,6 +32,7 @@ const ADAPTERS = {
32
32
  go: goAdapter,
33
33
  dotnet: dotnetAdapter,
34
34
  rails: railsAdapter,
35
+ ruby: railsAdapter,
35
36
  };
36
37
 
37
38
  /**
@@ -241,7 +241,7 @@ async function stageBackendArtifact(cwd, stagingDir, config) {
241
241
  await copyDirectoryIfExists(cwd, stagingDir, 'resources');
242
242
  await copyDirectoryIfExists(cwd, stagingDir, 'storage');
243
243
  await copyDirectoryIfExists(cwd, stagingDir, 'bin');
244
- } else if (framework === 'spring') {
244
+ } else if (framework === 'spring' || framework === 'java') {
245
245
  await copyIfExists(cwd, stagingDir, 'pom.xml');
246
246
  const targetDir = path.join(cwd, 'target');
247
247
  if (await fs.pathExists(targetDir)) {
@@ -261,7 +261,7 @@ async function stageBackendArtifact(cwd, stagingDir, config) {
261
261
  await copyIfExists(cwd, stagingDir, f);
262
262
  }
263
263
  await copyDirectoryIfExists(cwd, stagingDir, settings.buildOutput || 'publish');
264
- } else if (framework === 'rails') {
264
+ } else if (framework === 'rails' || framework === 'ruby') {
265
265
  await copyIfExists(cwd, stagingDir, 'Gemfile');
266
266
  await copyIfExists(cwd, stagingDir, 'Gemfile.lock');
267
267
  await copyIfExists(cwd, stagingDir, 'config.ru');
@@ -13,7 +13,7 @@ import {
13
13
  import { loadEnvArtifactHistory } from '../storage/index.js';
14
14
  import { testProvider } from '../storage/index.js';
15
15
  import { getDeploymentProvider } from '../deployment/index.js';
16
- import { PROVIDER_ENV_MAP, getRollbackWorkflowDoctorCheck, getWorkflowDriftDoctorChecks } from '../utils/github-actions.js';
16
+ import { PROVIDER_ENV_MAP, STORAGE_PROVIDER_IDS, getRollbackWorkflowDoctorCheck, getWorkflowDriftDoctorChecks } from '../utils/github-actions.js';
17
17
  import { printDoctorFooter } from '../utils/author.js';
18
18
  import { createLocalProvider } from '../storage/providers/local.js';
19
19
  import {
@@ -561,9 +561,12 @@ export async function runDeploymentChecks(config, envName, envConfig) {
561
561
  if (deployType === 'ec2' && process.env.EC2_INSTANCE_ID) {
562
562
  checks.push(
563
563
  await runCheck('EC2 API', async () => {
564
- const missing = ['AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', 'AWS_REGION'].filter(
565
- (k) => !process.env[k]
566
- );
564
+ const required = [
565
+ 'EC2_LOOKUP_AWS_ACCESS_KEY_ID',
566
+ 'EC2_LOOKUP_AWS_SECRET_ACCESS_KEY',
567
+ 'EC2_LOOKUP_AWS_REGION',
568
+ ];
569
+ const missing = required.filter((k) => !process.env[k]);
567
570
  if (missing.length > 0) {
568
571
  return {
569
572
  name: 'EC2 API',
@@ -969,6 +972,7 @@ export function registerDoctorCommand(program) {
969
972
  /** @type {string[]} */
970
973
  const required = [];
971
974
  for (const provider of config.storage || []) {
975
+ if (!STORAGE_PROVIDER_IDS.has(provider)) continue;
972
976
  const keys = PROVIDER_ENV_MAP[provider] || [];
973
977
  required.push(...keys);
974
978
  }
@@ -54,15 +54,20 @@ const BACKEND_CHOICES = [
54
54
  { name: 'Node.js — NestJS', value: 'nestjs' },
55
55
  { name: 'Node.js — Fastify', value: 'fastify' },
56
56
  { name: 'Node.js — Koa', value: 'koa' },
57
+ { name: 'Node.js — plain', value: 'node' },
57
58
  { name: 'Python — FastAPI', value: 'fastapi' },
58
59
  { name: 'Python — Django', value: 'django' },
59
60
  { name: 'Python — Flask', value: 'flask' },
61
+ { name: 'Python — plain', value: 'python' },
60
62
  { name: 'PHP — Laravel', value: 'laravel' },
61
63
  { name: 'PHP — Symfony', value: 'symfony' },
64
+ { name: 'PHP — plain (Composer / .php)', value: 'php' },
62
65
  { name: 'Java — Spring Boot', value: 'spring' },
66
+ { name: 'Java — plain (Maven/Gradle)', value: 'java' },
63
67
  { name: 'Go', value: 'go' },
64
68
  { name: '.NET (ASP.NET Core)', value: 'dotnet' },
65
69
  { name: 'Ruby on Rails', value: 'rails' },
70
+ { name: 'Ruby — plain (Rack/Puma)', value: 'ruby' },
66
71
  { name: "Other (I'll configure manually)", value: 'other' },
67
72
  ];
68
73
 
@@ -20,6 +20,8 @@ import {
20
20
  const SideConfigSchema = z.object({
21
21
  framework: z.string(),
22
22
  language: z.string().optional(),
23
+ /** PHP runtime for CI (`shivammathur/setup-php`); e.g. `"8.4"`. */
24
+ phpVersion: z.string().optional(),
23
25
  buildCommand: z.string().nullable().optional(),
24
26
  startCommand: z.string().nullable().optional(),
25
27
  buildOutput: z.string().optional(),
@@ -81,6 +83,11 @@ const ConfigSchema = z.object({
81
83
  projectType: z.enum(['frontend', 'backend', 'both']).default('frontend'),
82
84
  framework: z.string().optional(),
83
85
  language: z.string().optional(),
86
+ /**
87
+ * PHP runtime for generated GitHub Actions (`shivammathur/setup-php`).
88
+ * Prefer `backend.phpVersion` for backend/both projects. Default when unset: `8.4`.
89
+ */
90
+ phpVersion: z.string().optional(),
84
91
  buildCommand: z.string().nullable().optional(),
85
92
  startCommand: z.string().nullable().optional(),
86
93
  buildOutput: z.string().optional(),
@@ -169,27 +169,32 @@ export const DEPLOYMENT_ENV_DEFS = {
169
169
  when: 'optional',
170
170
  },
171
171
  {
172
- key: 'AWS_ACCESS_KEY_ID',
172
+ key: 'EC2_LOOKUP_AWS_ACCESS_KEY_ID',
173
173
  optionalReason:
174
174
  'only required if using EC2_INSTANCE_ID for dynamic IP lookup; otherwise leave blank',
175
175
  comment: [
176
+ 'EC2 instance-IP lookup credential (NOT the same as AWS S3 storage credentials).',
176
177
  'AWS access key with ec2:DescribeInstances permission.',
177
178
  'Create in AWS Console → IAM → Users → Security credentials.',
178
179
  ],
179
180
  when: 'optional',
180
181
  },
181
182
  {
182
- key: 'AWS_SECRET_ACCESS_KEY',
183
+ key: 'EC2_LOOKUP_AWS_SECRET_ACCESS_KEY',
183
184
  optionalReason:
184
185
  'only required if using EC2_INSTANCE_ID for dynamic IP lookup; otherwise leave blank',
185
- comment: ['Secret for the AWS access key above.'],
186
+ comment: [
187
+ 'Secret for EC2_LOOKUP_AWS_ACCESS_KEY_ID (distinct from AWS_SECRET_ACCESS_KEY used by S3).',
188
+ ],
186
189
  when: 'optional',
187
190
  },
188
191
  {
189
- key: 'AWS_REGION',
192
+ key: 'EC2_LOOKUP_AWS_REGION',
190
193
  optionalReason:
191
194
  'only required if using EC2_INSTANCE_ID for dynamic IP lookup; otherwise leave blank',
192
- comment: ['AWS region where your EC2 instance runs.'],
195
+ comment: [
196
+ 'AWS region for EC2 DescribeInstances lookup (distinct from AWS_REGION used by S3).',
197
+ ],
193
198
  example: 'us-east-1',
194
199
  when: 'optional',
195
200
  },
@@ -199,52 +204,60 @@ export const DEPLOYMENT_ENV_DEFS = {
199
204
  ...SSH_BACKEND_ENV_VARS,
200
205
  ...SSH_CI_ENV_VARS,
201
206
  {
202
- key: 'AZURE_SUBSCRIPTION_ID',
207
+ key: 'AZURE_VM_LOOKUP_SUBSCRIPTION_ID',
203
208
  optionalReason:
204
209
  'only required for dynamic VM IP lookup via Azure API; otherwise set SSH_HOST directly',
205
210
  comment: [
206
- 'Azure subscription ID. Used to look up VM public IP via Azure API.',
211
+ 'Azure VM IP-lookup subscription ID (deployment-side; not Azure Blob storage).',
207
212
  'Find in Azure Portal → Subscriptions, or run: az account show --query id -o tsv',
208
213
  ],
209
214
  when: 'optional',
210
215
  },
211
216
  {
212
- key: 'AZURE_RESOURCE_GROUP',
217
+ key: 'AZURE_VM_LOOKUP_RESOURCE_GROUP',
213
218
  optionalReason:
214
219
  'only required for dynamic VM IP lookup via Azure API; otherwise leave blank',
215
- comment: ['Resource group containing your VM.'],
220
+ comment: [
221
+ 'Resource group containing your VM (deployment lookup; not Azure Blob storage).',
222
+ ],
216
223
  example: 'my-app-rg',
217
224
  when: 'optional',
218
225
  },
219
226
  {
220
- key: 'AZURE_VM_NAME',
227
+ key: 'AZURE_VM_LOOKUP_VM_NAME',
221
228
  optionalReason:
222
229
  'only required for dynamic VM IP lookup via Azure API; otherwise set SSH_HOST directly',
223
230
  comment: [
224
- 'Name of the Azure virtual machine.',
231
+ 'Name of the Azure virtual machine for IP lookup.',
225
232
  'If unset, SSH_HOST must be set to the VM public IP or DNS.',
226
233
  ],
227
234
  when: 'optional',
228
235
  },
229
236
  {
230
- key: 'AZURE_TENANT_ID',
237
+ key: 'AZURE_VM_LOOKUP_TENANT_ID',
231
238
  optionalReason:
232
239
  'only required for non-interactive CI deploys (GitHub Actions) using a service principal',
233
- comment: ['Azure AD tenant ID for service principal auth in CI.'],
240
+ comment: [
241
+ 'Azure AD tenant ID for service principal auth in CI (VM deploy lookup).',
242
+ ],
234
243
  when: 'optional',
235
244
  },
236
245
  {
237
- key: 'AZURE_CLIENT_ID',
246
+ key: 'AZURE_VM_LOOKUP_CLIENT_ID',
238
247
  optionalReason:
239
248
  'only required for non-interactive CI deploys (GitHub Actions) using a service principal',
240
- comment: ['Service principal application (client) ID for CI auth.'],
249
+ comment: [
250
+ 'Service principal application (client) ID for CI auth (VM deploy lookup).',
251
+ ],
241
252
  when: 'optional',
242
253
  },
243
254
  {
244
- key: 'AZURE_CLIENT_SECRET',
255
+ key: 'AZURE_VM_LOOKUP_CLIENT_SECRET',
245
256
  optionalReason:
246
257
  'only required for non-interactive CI deploys (GitHub Actions) using a service principal',
247
- comment: ['Service principal client secret for CI auth.'],
258
+ comment: [
259
+ 'Service principal client secret for CI auth (VM deploy lookup).',
260
+ ],
248
261
  when: 'optional',
249
262
  },
250
263
  ],
@@ -253,11 +266,11 @@ export const DEPLOYMENT_ENV_DEFS = {
253
266
  ...SSH_BACKEND_ENV_VARS,
254
267
  ...SSH_CI_ENV_VARS,
255
268
  {
256
- key: 'GCP_PROJECT_ID',
269
+ key: 'GCP_VM_LOOKUP_PROJECT_ID',
257
270
  optionalReason:
258
271
  'only required for dynamic VM IP lookup via GCP API; otherwise set SSH_HOST directly',
259
272
  comment: [
260
- 'GCP project ID. Used to look up VM IP via Compute API.',
273
+ 'GCP project ID for Compute Engine IP lookup (NOT the same as GCP Storage GCP_PROJECT_ID).',
261
274
  'Find in GCP Console → Dashboard, or run: gcloud config get-value project',
262
275
  ],
263
276
  when: 'optional',
@@ -282,11 +295,11 @@ export const DEPLOYMENT_ENV_DEFS = {
282
295
  when: 'optional',
283
296
  },
284
297
  {
285
- key: 'GCP_KEY_FILE',
298
+ key: 'GCP_VM_LOOKUP_KEY_FILE',
286
299
  optionalReason:
287
300
  'only required for dynamic VM IP lookup via GCP API or non-interactive CI auth; otherwise leave blank',
288
301
  comment: [
289
- 'Path to a GCP service account JSON key file.',
302
+ 'Path to a GCP service account JSON key for VM IP lookup (distinct from GCP Storage GCP_KEY_FILE).',
290
303
  'Create in GCP Console → IAM → Service Accounts → Keys.',
291
304
  ],
292
305
  when: 'optional',
@@ -381,6 +394,27 @@ export const DEPLOYMENT_ENV_KEYS = Object.fromEntries(
381
394
  ])
382
395
  );
383
396
 
397
+ /**
398
+ * Deployment-side cloud-API lookup credentials — distinct from storage-provider
399
+ * env vars (storage stays project-wide / unprefixed).
400
+ *
401
+ * Convention: `{METHOD}_LOOKUP_…` — method-scoped, purpose-clear, layers under
402
+ * the existing multi-env prefixing system (e.g. PRODUCTION_EC2_LOOKUP_AWS_ACCESS_KEY_ID).
403
+ */
404
+ export const DEPLOYMENT_LOOKUP_ENV_KEYS = new Set([
405
+ 'EC2_LOOKUP_AWS_ACCESS_KEY_ID',
406
+ 'EC2_LOOKUP_AWS_SECRET_ACCESS_KEY',
407
+ 'EC2_LOOKUP_AWS_REGION',
408
+ 'AZURE_VM_LOOKUP_SUBSCRIPTION_ID',
409
+ 'AZURE_VM_LOOKUP_RESOURCE_GROUP',
410
+ 'AZURE_VM_LOOKUP_VM_NAME',
411
+ 'AZURE_VM_LOOKUP_TENANT_ID',
412
+ 'AZURE_VM_LOOKUP_CLIENT_ID',
413
+ 'AZURE_VM_LOOKUP_CLIENT_SECRET',
414
+ 'GCP_VM_LOOKUP_PROJECT_ID',
415
+ 'GCP_VM_LOOKUP_KEY_FILE',
416
+ ]);
417
+
384
418
  /**
385
419
  * Locally required env keys for doctor method-specific checks.
386
420
  * Excludes optional and CI-only vars.
@@ -482,13 +516,25 @@ export function getDeploymentSecretChecklistItems(deployType, config = null) {
482
516
 
483
517
  const key = toGithubSecretKey(d.key);
484
518
  const required = d.when !== 'optional';
485
- const note =
519
+ let note =
486
520
  d.when === 'optional'
487
521
  ? d.optionalReason
488
522
  : d.when === 'ci'
489
523
  ? d.optionalReason || 'required for GitHub Actions CI (paste private key contents)'
490
524
  : undefined;
491
525
 
526
+ if (DEPLOYMENT_LOOKUP_ENV_KEYS.has(d.key)) {
527
+ const purpose =
528
+ deployType === 'ec2'
529
+ ? 'EC2 instance-IP lookup credential (distinct from AWS S3 storage)'
530
+ : deployType === 'gcp-vm'
531
+ ? 'GCP VM instance-IP lookup credential (distinct from GCP Storage)'
532
+ : deployType === 'azure-vm'
533
+ ? 'Azure VM IP-lookup credential (distinct from Azure Blob storage)'
534
+ : 'deployment cloud-API lookup credential';
535
+ note = note ? `${purpose}; ${note}` : purpose;
536
+ }
537
+
492
538
  const existing = byKey.get(key);
493
539
  if (existing) {
494
540
  // Prefer required if any def for this key is required
@@ -731,21 +777,48 @@ export function formatSecretChecklistLine(item) {
731
777
  * @param {string} deployType
732
778
  * @param {import('../core/config.js').DeployHubConfig} [config]
733
779
  * @param {Record<string, Record<string, unknown>>} [environments]
780
+ * @param {{ envName?: string }} [options]
734
781
  * @returns {string}
735
782
  */
736
783
  export function generateDeploymentEnvSection(
737
784
  deployType,
738
785
  config = null,
739
- environments = {}
786
+ environments = {},
787
+ options = {}
740
788
  ) {
741
789
  const defs = DEPLOYMENT_ENV_DEFS[deployType] || [];
742
790
  const projectType = config?.projectType || 'frontend';
743
791
  const isBackend = projectType === 'backend' || projectType === 'both';
792
+ const envName = options.envName;
793
+ const cfg = {
794
+ ...(config || {}),
795
+ environments: environments || config?.environments || {},
796
+ };
797
+ const shouldPrefix =
798
+ !!envName && envUsesPrefixedSecrets(envName, /** @type {any} */ (cfg));
744
799
 
745
- const title = DEPLOYMENT_SECTION_TITLES[deployType] || deployType;
800
+ const baseTitle = DEPLOYMENT_SECTION_TITLES[deployType] || deployType;
801
+ const title =
802
+ envName && Object.keys(environments || {}).length > 1
803
+ ? `${baseTitle} (${envName})`
804
+ : baseTitle;
746
805
  /** @type {string[]} */
747
806
  const lines = [`# ${title}`];
748
807
 
808
+ // Prefer defaults from this environment's config when generating per-env sections.
809
+ const envEntry = envName
810
+ ? /** @type {Record<string, unknown>} */ (
811
+ (environments || {})[envName] || {}
812
+ )
813
+ : /** @type {Record<string, unknown>} */ (
814
+ Object.values(environments || {})[0] || {}
815
+ );
816
+ // Support both new { config: {...} } shape and flat legacy env entries.
817
+ const settings =
818
+ envEntry && typeof envEntry.config === 'object' && envEntry.config
819
+ ? /** @type {Record<string, unknown>} */ (envEntry.config)
820
+ : envEntry;
821
+
749
822
  for (const d of defs) {
750
823
  if (d.when === 'backend' && !isBackend) continue;
751
824
 
@@ -762,8 +835,10 @@ export function generateDeploymentEnvSection(
762
835
  lines.push(`# Example: ${d.example}`);
763
836
  }
764
837
 
765
- const defaultVal = d.default || getDefaultFromConfig(d.key, config, environments);
766
- lines.push(defaultVal ? `${d.key}=${defaultVal}` : `${d.key}=`);
838
+ const key = shouldPrefix ? prefixSecretKey(envName, d.key) : d.key;
839
+ const defaultVal =
840
+ d.default || getDefaultFromConfig(d.key, config, settings);
841
+ lines.push(defaultVal ? `${key}=${defaultVal}` : `${key}=`);
767
842
  lines.push('');
768
843
  }
769
844
 
@@ -774,23 +849,21 @@ export function generateDeploymentEnvSection(
774
849
  /**
775
850
  * @param {string} key
776
851
  * @param {import('../core/config.js').DeployHubConfig} [config]
777
- * @param {Record<string, Record<string, unknown>>} [environments]
852
+ * @param {Record<string, unknown>} [settings]
778
853
  */
779
- function getDefaultFromConfig(key, config, environments) {
780
- const envEntry = Object.values(environments || {})[0] || {};
781
-
854
+ function getDefaultFromConfig(key, config, settings = {}) {
782
855
  const map = {
783
- SSH_HOST: envEntry.host,
784
- SSH_USER: envEntry.user,
785
- SSH_DEPLOY_PATH: envEntry.deployPath || envEntry.path,
786
- SSH_APP_NAME: envEntry.appName,
856
+ SSH_HOST: settings.host,
857
+ SSH_USER: settings.user,
858
+ SSH_DEPLOY_PATH: settings.deployPath || settings.path,
859
+ SSH_APP_NAME: settings.appName,
787
860
  SSH_PORT: config?.port || config?.backend?.port,
788
861
  SSH_SSH_PORT: '22',
789
- KUBE_NAMESPACE: config?.project || 'default',
790
- DOCKER_IMAGE_NAME: config?.project,
862
+ KUBE_NAMESPACE: settings.kubeNamespace || config?.project || 'default',
863
+ DOCKER_IMAGE_NAME: settings.dockerImageName || config?.project,
791
864
  DOCKER_IMAGE_TAG: '',
792
- DOCKER_REGISTRY_URL: envEntry.dockerRegistryUrl,
793
- AWS_REGION: 'us-east-1',
865
+ DOCKER_REGISTRY_URL: settings.dockerRegistryUrl,
866
+ EC2_LOOKUP_AWS_REGION: settings.awsRegion || 'us-east-1',
794
867
  };
795
868
 
796
869
  const val = map[key];
@@ -871,8 +944,8 @@ export const DEPLOYMENT_GUIDE = {
871
944
  after: [
872
945
  'AWS Console → EC2 → Security Groups → your instance group → Inbound rules →',
873
946
  ' Add rule: SSH, port 22, source: My IP',
874
- 'Copy .env.example to .env — set SSH_KEY_PATH, SSH_HOST (or EC2_INSTANCE_ID + AWS creds).',
875
- 'Add GitHub Secrets: SSH_HOST, SSH_USER, SSH_KEY, plus AWS_* if using instance ID lookup.',
947
+ 'Copy .env.example to .env — set SSH_KEY_PATH, SSH_HOST (or EC2_INSTANCE_ID + EC2_LOOKUP_AWS_*).',
948
+ 'Add GitHub Secrets: SSH_HOST, SSH_USER, SSH_KEY, plus EC2_LOOKUP_AWS_* if using instance ID lookup (distinct from S3 AWS_*).',
876
949
  'Run deployhub doctor to verify SSH and optional AWS API access.',
877
950
  'git push origin main to trigger your first deployment.',
878
951
  ],
@@ -893,7 +966,7 @@ export const DEPLOYMENT_GUIDE = {
893
966
  'Azure Portal → VM → Networking → Inbound port rules → allow SSH (22) from your IP.',
894
967
  'Or: az network nsg rule create --name AllowSSH --priority 1000 --source-address-prefix YOUR_IP ...',
895
968
  'Copy .env.example to .env and fill in SSH_HOST, SSH_USER, SSH_KEY_PATH.',
896
- 'For CI: add AZURE_TENANT_ID, AZURE_CLIENT_ID, AZURE_CLIENT_SECRET as GitHub Secrets.',
969
+ 'For CI: add AZURE_VM_LOOKUP_TENANT_ID, AZURE_VM_LOOKUP_CLIENT_ID, AZURE_VM_LOOKUP_CLIENT_SECRET as GitHub Secrets.',
897
970
  'Run deployhub doctor, then git push origin main.',
898
971
  ],
899
972
  },
@@ -940,6 +1013,7 @@ export const DEPLOYMENT_GUIDE = {
940
1013
  'Copy .env.example to .env and set DOCKER_IMAGE_NAME, DOCKER_REGISTRY_USERNAME, and DOCKER_REGISTRY_TOKEN.',
941
1014
  'Skipping registry credentials will very likely cause ImagePullBackOff — the cluster cannot see local Docker images.',
942
1015
  'For private registries: also create kubectl create secret docker-registry ... and set KUBE_IMAGE_PULL_SECRET.',
1016
+ 'Interpreted backends (Node/Python/PHP/Rails): rollback refuses to rebuild from the artifact when the buildId image is not local — keep pipeline.docker builds so the restored tag exists.',
943
1017
  'Add the GitHub Secrets listed below (Settings → Secrets and variables → Actions).',
944
1018
  'Run deployhub doctor to verify cluster access and that your image is pullable.',
945
1019
  'git push origin main to trigger your first deployment.',