@akash-chowdhury-24/deployhub 2.0.26 → 2.0.27

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
@@ -883,13 +883,13 @@ DeployHub detects whether the server uses Debian-style `sites-available` or RHEL
883
883
  - Nginx layout detection and config test before reload (frontend)
884
884
  - Passwordless sudo and Nginx checks during `deployhub doctor` (frontend)
885
885
  - OS user suggestion from AMI hint (ubuntu, ec2-user)
886
- - Optional public IP lookup via `EC2_INSTANCE_ID` + AWS CLI
886
+ - Optional public IP lookup via `EC2_INSTANCE_ID` + `EC2_LOOKUP_AWS_*` (AWS CLI)
887
887
 
888
888
  **After `init`:**
889
889
  1. AWS Console → EC2 → Security Groups → Inbound rules → SSH port 22 from My IP
890
890
  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
891
+ 3. Copy `.env.example` → `.env`; set `SSH_KEY_PATH`, `SSH_HOST` (or `EC2_INSTANCE_ID` + `EC2_LOOKUP_AWS_*`)
892
+ 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
893
  5. Run `deployhub doctor`, then `git push origin main`
894
894
 
895
895
  | Variable | Description | Example | Where to get it |
@@ -898,9 +898,9 @@ DeployHub detects whether the server uses Debian-style `sites-available` or RHEL
898
898
  | `SSH_USER` | SSH user for AMI | `ec2-user` | AMI documentation |
899
899
  | `SSH_KEY_PATH` | Path to .pem key | `~/.ssh/ec2-key.pem` | Downloaded at instance launch |
900
900
  | `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 |
901
+ | `EC2_LOOKUP_AWS_ACCESS_KEY_ID` | AWS key for EC2 IP lookup (distinct from S3) | `AKIA...` | IAM → Users → Security credentials |
902
+ | `EC2_LOOKUP_AWS_SECRET_ACCESS_KEY` | AWS secret for EC2 IP lookup | *(secret)* | Same as above |
903
+ | `EC2_LOOKUP_AWS_REGION` | Instance region for DescribeInstances | `us-east-1` | EC2 Console top bar |
904
904
 
905
905
  ### Azure VM
906
906
 
@@ -926,7 +926,7 @@ DeployHub detects whether the server uses Debian-style `sites-available` or RHEL
926
926
  1. Azure Portal → VM → Networking → allow SSH (22) from your IP
927
927
  2. Ensure your deploy directory exists and is owned by your SSH user (see prerequisite above if `deployhub doctor` reports permission denied)
928
928
  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
929
+ 4. For CI: add `AZURE_VM_LOOKUP_TENANT_ID`, `AZURE_VM_LOOKUP_CLIENT_ID`, `AZURE_VM_LOOKUP_CLIENT_SECRET` as GitHub Secrets
930
930
  5. Run `deployhub doctor`, then `git push origin main`
931
931
 
932
932
  | Variable | Description | Example | Where to get it |
@@ -934,9 +934,9 @@ DeployHub detects whether the server uses Debian-style `sites-available` or RHEL
934
934
  | `SSH_HOST` | VM public IP | `20.1.2.3` | Azure Portal → VM overview |
935
935
  | `SSH_USER` | SSH username | `azureuser` | Chosen at VM creation |
936
936
  | `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 |
937
+ | `AZURE_VM_LOOKUP_SUBSCRIPTION_ID` | Subscription (optional IP lookup) | `uuid` | `az account show` |
938
+ | `AZURE_VM_LOOKUP_RESOURCE_GROUP` | Resource group | `my-app-rg` | Portal → Resource groups |
939
+ | `AZURE_VM_LOOKUP_VM_NAME` | VM name | `my-vm` | Portal → Virtual machines |
940
940
 
941
941
  ### GCP VM
942
942
 
@@ -975,10 +975,10 @@ DeployHub detects whether the server uses Debian-style `sites-available` or RHEL
975
975
  | `SSH_APP_NAME` | PM2 process name (backend) | `my-api` | Your choice |
976
976
  | `SSH_PORT` | App listen port (backend) | `3000` | Your app config |
977
977
  | `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` |
978
+ | `GCP_VM_LOOKUP_PROJECT_ID` | Project ID for VM IP lookup (distinct from GCP Storage) | `my-project-123` | `gcloud config get-value project` |
979
979
  | `GCP_ZONE` | VM zone (optional) | `us-central1-a` | VM instance details |
980
980
  | `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 |
981
+ | `GCP_VM_LOOKUP_KEY_FILE` | Service account JSON for VM lookup (distinct from GCP Storage) | `/path/to/key.json` | IAM → Service Accounts → Keys |
982
982
 
983
983
  ### Kubernetes
984
984
 
@@ -993,7 +993,7 @@ DeployHub detects whether the server uses Debian-style `sites-available` or RHEL
993
993
  **Init prompts (what `deployhub init` asks for Kubernetes):**
994
994
  1. Path to kubeconfig file (e.g. `~/.kube/config`)
995
995
  2. Kubernetes context
996
- 3. Namespace (defaults to project name)
996
+ 3. Namespace (defaults to project name for the first env; additional envs suggest `{project}-{envName}` to avoid collisions)
997
997
  4. Container image name
998
998
  5. Registry URL (leave empty for Docker Hub)
999
999
  6. Registry username (required to push)
@@ -1009,6 +1009,8 @@ DeployHub detects whether the server uses Debian-style `sites-available` or RHEL
1009
1009
  - Cluster connectivity test during `init`
1010
1010
  - 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
1011
 
1012
+ > **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.
1013
+
1012
1014
  > **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
1015
 
1014
1016
  **After `init`:**
@@ -1140,7 +1142,20 @@ Run `deployhub doctor` after any config change.
1140
1142
  | `deployhub clean` | Remove old local artifacts |
1141
1143
  | `deployhub update` | Check for CLI updates |
1142
1144
 
1143
- **Tests:** `npm test` — currently **320 passing** across the Jest suites.
1145
+ **Tests:** `npm test` — currently **442 passing** across the Jest suites (1 skipped).
1146
+
1147
+ ## Storage vs deployment lookup credentials
1148
+
1149
+ 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.
1150
+
1151
+ | Concern | Examples |
1152
+ |---------|----------|
1153
+ | Storage | `AWS_ACCESS_KEY_ID`, `GCP_PROJECT_ID`, `GCP_KEY_FILE` |
1154
+ | EC2 instance-IP lookup | `EC2_LOOKUP_AWS_ACCESS_KEY_ID`, `EC2_LOOKUP_AWS_SECRET_ACCESS_KEY`, `EC2_LOOKUP_AWS_REGION` |
1155
+ | GCP VM IP lookup | `GCP_VM_LOOKUP_PROJECT_ID`, `GCP_VM_LOOKUP_KEY_FILE` |
1156
+ | Azure VM IP lookup | `AZURE_VM_LOOKUP_SUBSCRIPTION_ID`, `AZURE_VM_LOOKUP_RESOURCE_GROUP`, `AZURE_VM_LOOKUP_VM_NAME`, … |
1157
+
1158
+ Multi-env secret prefixing still applies on top (e.g. `PRODUCTION_EC2_LOOKUP_AWS_ACCESS_KEY_ID`).
1144
1159
 
1145
1160
  ## GitHub Secrets
1146
1161
 
@@ -1164,7 +1179,7 @@ Add these secrets in your repository (Settings → Secrets and variables → Act
1164
1179
  | `GCP_KEY_FILE` | GCP Storage |
1165
1180
  | `GCP_BUCKET` | GCP Storage |
1166
1181
  | `DROPBOX_ACCESS_TOKEN` | Dropbox |
1167
- | `FTP_HOST`, `FTP_USER`, `FTP_PASSWORD` | FTP storage |
1182
+ | `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
1183
 
1169
1184
  ### Server deployment (SSH, EC2, VMs, Docker, Kubernetes)
1170
1185
 
@@ -1178,9 +1193,9 @@ Add these secrets in your repository (Settings → Secrets and variables → Act
1178
1193
  | `SSH_DEPLOY_PATH` | Remote directory (optional if set in config) |
1179
1194
  | `SSH_APP_NAME` | PM2 process name for backends |
1180
1195
  | `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 |
1196
+ | `EC2_INSTANCE_ID`, `EC2_LOOKUP_AWS_*` | Optional EC2 dynamic IP lookup (distinct from S3 `AWS_*`) |
1197
+ | `AZURE_VM_LOOKUP_SUBSCRIPTION_ID`, `AZURE_VM_LOOKUP_RESOURCE_GROUP`, `AZURE_VM_LOOKUP_VM_NAME` | Optional Azure VM IP lookup |
1198
+ | `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
1199
  | `DOCKER_IMAGE_NAME`, `DOCKER_REGISTRY_USERNAME`, `DOCKER_REGISTRY_TOKEN`, `DOCKER_REGISTRY_URL`, `DOCKER_HOST` | Docker deployment (`DOCKER_IMAGE_TAG` optional) |
1185
1200
  | `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
1201
 
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.27",
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
 
@@ -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.',
@@ -81,7 +81,12 @@ export async function promptServerDeployment(
81
81
  'Non-interactive env add requires --method <ssh|docker|ec2|azure-vm|gcp-vm|kubernetes>.'
82
82
  );
83
83
  }
84
- return buildNonInteractiveDeployAnswers(options.deployType, projectName, options.envName);
84
+ return buildNonInteractiveDeployAnswers(
85
+ options.deployType,
86
+ projectName,
87
+ options.envName,
88
+ existingEnvNames
89
+ );
85
90
  }
86
91
 
87
92
  /** @type {import('inquirer').QuestionCollection} */
@@ -122,7 +127,9 @@ export async function promptServerDeployment(
122
127
  const deployType = base.deployType;
123
128
 
124
129
  if (deployType === 'kubernetes') {
125
- return promptKubernetesDeployment(base, projectName, projectType);
130
+ return promptKubernetesDeployment(base, projectName, projectType, {
131
+ existingEnvNames,
132
+ });
126
133
  }
127
134
 
128
135
  if (deployType === 'docker') {
@@ -137,8 +144,14 @@ export async function promptServerDeployment(
137
144
  * @param {string} deployType
138
145
  * @param {string} projectName
139
146
  * @param {string} [envName]
147
+ * @param {string[]} [existingEnvNames]
140
148
  */
141
- function buildNonInteractiveDeployAnswers(deployType, projectName, envName) {
149
+ function buildNonInteractiveDeployAnswers(
150
+ deployType,
151
+ projectName,
152
+ envName,
153
+ existingEnvNames = []
154
+ ) {
142
155
  const base = { deployType, envName: envName || 'default' };
143
156
  if (deployType === 'docker') {
144
157
  return {
@@ -156,7 +169,11 @@ function buildNonInteractiveDeployAnswers(deployType, projectName, envName) {
156
169
  ...base,
157
170
  kubeconfig: '~/.kube/config',
158
171
  kubeContext: '',
159
- kubeNamespace: projectName,
172
+ kubeNamespace: suggestKubeNamespaceDefault(
173
+ projectName,
174
+ base.envName,
175
+ existingEnvNames
176
+ ),
160
177
  dockerImageName: projectName,
161
178
  dockerRegistryUrl: '',
162
179
  dockerRegistryUsername: '',
@@ -176,14 +193,39 @@ function buildNonInteractiveDeployAnswers(deployType, projectName, envName) {
176
193
  };
177
194
  }
178
195
 
196
+ /**
197
+ * Suggested Kubernetes namespace for prompts / --yes defaults.
198
+ * First environment → bare project name; additional envs → `{project}-{envName}`
199
+ * so the suggested default matches resolveKubeNamespace auto-scoping behavior.
200
+ *
201
+ * @param {string} projectName
202
+ * @param {string} envName
203
+ * @param {string[]} existingEnvNames
204
+ * @returns {string}
205
+ */
206
+ export function suggestKubeNamespaceDefault(projectName, envName, existingEnvNames = []) {
207
+ if ((existingEnvNames || []).length > 0) {
208
+ return `${projectName}-${envName}`;
209
+ }
210
+ return projectName;
211
+ }
212
+
179
213
  /**
180
214
  * @param {Record<string, string>} base
181
215
  * @param {string} projectName
182
216
  * @param {'frontend'|'backend'|'both'} projectType
217
+ * @param {{ existingEnvNames?: string[] }} [options]
183
218
  */
184
- async function promptKubernetesDeployment(base, projectName, projectType) {
219
+ async function promptKubernetesDeployment(base, projectName, projectType, options = {}) {
185
220
  const defaultKubeconfig = await detectKubeconfigPath();
186
221
  const contexts = await listKubeContexts();
222
+ const envName = base.envName || 'production';
223
+ const existingEnvNames = options.existingEnvNames || [];
224
+ const namespaceDefault = suggestKubeNamespaceDefault(
225
+ projectName,
226
+ envName,
227
+ existingEnvNames
228
+ );
187
229
 
188
230
  const kubeAnswers = await inquirer.prompt([
189
231
  {
@@ -202,8 +244,11 @@ async function promptKubernetesDeployment(base, projectName, projectType) {
202
244
  {
203
245
  type: 'input',
204
246
  name: 'kubeNamespace',
205
- message: 'Kubernetes namespace (e.g. my-app or default):',
206
- default: projectName,
247
+ message:
248
+ existingEnvNames.length > 0
249
+ ? `Kubernetes namespace (suggested ${namespaceDefault} so it does not collide with existing envs):`
250
+ : 'Kubernetes namespace (e.g. my-app or default):',
251
+ default: namespaceDefault,
207
252
  },
208
253
  {
209
254
  type: 'input',