@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.
@@ -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',
@@ -11,9 +11,11 @@ import { getEnvSettings } from '../../core/config.js';
11
11
  export function createAzureVmProvider(config, envName, env = process.env) {
12
12
  const log = createLogger('azure-vm');
13
13
  const settings = getEnvSettings(config.environments[envName]);
14
- const subscriptionId = env.AZURE_SUBSCRIPTION_ID || settings.azureSubscriptionId;
15
- const resourceGroup = env.AZURE_RESOURCE_GROUP || settings.azureResourceGroup;
16
- const vmName = env.AZURE_VM_NAME || settings.azureVmName;
14
+ const subscriptionId =
15
+ env.AZURE_VM_LOOKUP_SUBSCRIPTION_ID || settings.azureSubscriptionId;
16
+ const resourceGroup =
17
+ env.AZURE_VM_LOOKUP_RESOURCE_GROUP || settings.azureResourceGroup;
18
+ const vmName = env.AZURE_VM_LOOKUP_VM_NAME || settings.azureVmName;
17
19
 
18
20
  async function resolveHost() {
19
21
  if (settings.host || env.SSH_HOST) {
@@ -23,7 +25,7 @@ export function createAzureVmProvider(config, envName, env = process.env) {
23
25
  if (!subscriptionId || !resourceGroup || !vmName) {
24
26
  throw new Error(
25
27
  'Could not resolve host via Azure VM lookup, and no SSH_HOST was set — ' +
26
- 'provide SSH_HOST (VM public IP/DNS) or set AZURE_SUBSCRIPTION_ID, AZURE_RESOURCE_GROUP, and AZURE_VM_NAME for auto lookup.'
28
+ 'provide SSH_HOST (VM public IP/DNS) or set AZURE_VM_LOOKUP_SUBSCRIPTION_ID, AZURE_VM_LOOKUP_RESOURCE_GROUP, and AZURE_VM_LOOKUP_VM_NAME for auto lookup.'
27
29
  );
28
30
  }
29
31
 
@@ -12,7 +12,8 @@ export function createEc2Provider(config, envName, env = process.env) {
12
12
  const log = createLogger('ec2');
13
13
  const settings = getEnvSettings(config.environments[envName]);
14
14
  const instanceId = env.EC2_INSTANCE_ID || settings.ec2InstanceId;
15
- const region = env.AWS_REGION || settings.awsRegion || 'us-east-1';
15
+ const region =
16
+ env.EC2_LOOKUP_AWS_REGION || settings.awsRegion || 'us-east-1';
16
17
 
17
18
  async function resolveHost() {
18
19
  if (settings.host || env.SSH_HOST) {
@@ -30,8 +31,12 @@ export function createEc2Provider(config, envName, env = process.env) {
30
31
 
31
32
  /** @type {Record<string, string>} */
32
33
  const awsEnv = { ...process.env };
33
- if (env.AWS_ACCESS_KEY_ID) awsEnv.AWS_ACCESS_KEY_ID = env.AWS_ACCESS_KEY_ID;
34
- if (env.AWS_SECRET_ACCESS_KEY) awsEnv.AWS_SECRET_ACCESS_KEY = env.AWS_SECRET_ACCESS_KEY;
34
+ if (env.EC2_LOOKUP_AWS_ACCESS_KEY_ID) {
35
+ awsEnv.AWS_ACCESS_KEY_ID = env.EC2_LOOKUP_AWS_ACCESS_KEY_ID;
36
+ }
37
+ if (env.EC2_LOOKUP_AWS_SECRET_ACCESS_KEY) {
38
+ awsEnv.AWS_SECRET_ACCESS_KEY = env.EC2_LOOKUP_AWS_SECRET_ACCESS_KEY;
39
+ }
35
40
  awsEnv.AWS_DEFAULT_REGION = region;
36
41
 
37
42
  try {
@@ -11,7 +11,7 @@ import { getEnvSettings } from '../../core/config.js';
11
11
  export function createGcpVmProvider(config, envName, env = process.env) {
12
12
  const log = createLogger('gcp-vm');
13
13
  const settings = getEnvSettings(config.environments[envName]);
14
- const projectId = env.GCP_PROJECT_ID || settings.gcpProjectId;
14
+ const projectId = env.GCP_VM_LOOKUP_PROJECT_ID || settings.gcpProjectId;
15
15
  const zone = env.GCP_ZONE || settings.gcpZone;
16
16
  const instanceName = env.GCP_INSTANCE_NAME || settings.gcpInstanceName;
17
17
 
@@ -23,7 +23,7 @@ export function createGcpVmProvider(config, envName, env = process.env) {
23
23
  if (!projectId || !zone || !instanceName) {
24
24
  throw new Error(
25
25
  'Could not resolve host via GCP instance lookup, and no SSH_HOST was set — ' +
26
- 'provide SSH_HOST (instance external IP/DNS) or set GCP_PROJECT_ID, GCP_ZONE, and GCP_INSTANCE_NAME for auto lookup.'
26
+ 'provide SSH_HOST (instance external IP/DNS) or set GCP_VM_LOOKUP_PROJECT_ID, GCP_ZONE, and GCP_INSTANCE_NAME for auto lookup.'
27
27
  );
28
28
  }
29
29
 
@@ -31,8 +31,8 @@ export function createGcpVmProvider(config, envName, env = process.env) {
31
31
 
32
32
  /** @type {Record<string, string>} */
33
33
  const gcpEnv = { ...process.env };
34
- if (env.GCP_KEY_FILE) {
35
- gcpEnv.GOOGLE_APPLICATION_CREDENTIALS = env.GCP_KEY_FILE;
34
+ if (env.GCP_VM_LOOKUP_KEY_FILE) {
35
+ gcpEnv.GOOGLE_APPLICATION_CREDENTIALS = env.GCP_VM_LOOKUP_KEY_FILE;
36
36
  }
37
37
 
38
38
  try {
@@ -426,10 +426,10 @@ export function createSshProvider(config, envName, env = process.env) {
426
426
  return;
427
427
  }
428
428
 
429
- if (framework === 'rails') {
429
+ if (framework === 'rails' || framework === 'ruby') {
430
430
  await exec(ssh, `cd ${dir} && bundle install --deployment`);
431
431
  await stopScopedBackendProcess(ssh, targetPath);
432
- await startScopedNohup(ssh, targetPath, `bundle exec puma -p ${port}`);
432
+ await startScopedNohup(ssh, targetPath, `bundle exec puma -b tcp://0.0.0.0:${port}`);
433
433
  return;
434
434
  }
435
435
 
@@ -64,6 +64,20 @@ const FRAMEWORKS = {
64
64
  port: 3000,
65
65
  },
66
66
  },
67
+ node: {
68
+ // After named Node frameworks: package.json backend without Express/Nest/Fastify/Koa.
69
+ // Exclude obvious frontend-only trees so React/Vue apps are not labeled "node".
70
+ detect: (cwd) =>
71
+ fs.existsSync(path.join(cwd, 'package.json')) && !isLikelyFrontendPackage(cwd),
72
+ defaults: {
73
+ language: 'node',
74
+ buildCommand: null,
75
+ startCommand: 'npm start',
76
+ buildOutput: '.',
77
+ testCommand: 'npm test',
78
+ port: 3000,
79
+ },
80
+ },
67
81
  fastapi: {
68
82
  detect: (cwd) => fileContains(cwd, 'requirements.txt', 'fastapi'),
69
83
  defaults: {
@@ -99,6 +113,20 @@ const FRAMEWORKS = {
99
113
  port: 5000,
100
114
  },
101
115
  },
116
+ python: {
117
+ detect: (cwd) =>
118
+ fs.existsSync(path.join(cwd, 'requirements.txt')) ||
119
+ fs.existsSync(path.join(cwd, 'pyproject.toml')) ||
120
+ fs.existsSync(path.join(cwd, 'setup.py')),
121
+ defaults: {
122
+ language: 'python',
123
+ buildCommand: null,
124
+ startCommand: 'python -m http.server 8000 --bind 0.0.0.0',
125
+ buildOutput: '.',
126
+ testCommand: 'pytest',
127
+ port: 8000,
128
+ },
129
+ },
102
130
  laravel: {
103
131
  detect: (cwd) => hasComposerPackage(cwd, 'laravel/framework'),
104
132
  defaults: {
@@ -115,12 +143,25 @@ const FRAMEWORKS = {
115
143
  defaults: {
116
144
  language: 'php',
117
145
  buildCommand: null,
118
- startCommand: 'php-fpm',
146
+ startCommand: 'php -S 0.0.0.0:80 -t public',
119
147
  buildOutput: '.',
120
148
  testCommand: 'php bin/phpunit',
121
149
  port: 80,
122
150
  },
123
151
  },
152
+ php: {
153
+ // After Laravel/Symfony: composer.json without those frameworks, or bare .php sources.
154
+ detect: (cwd) =>
155
+ fs.existsSync(path.join(cwd, 'composer.json')) || hasPhpSources(cwd),
156
+ defaults: {
157
+ language: 'php',
158
+ buildCommand: null,
159
+ startCommand: 'php -S 0.0.0.0:80 -t .',
160
+ buildOutput: '.',
161
+ testCommand: null,
162
+ port: 80,
163
+ },
164
+ },
124
165
  spring: {
125
166
  detect: (cwd) => fileContains(cwd, 'pom.xml', 'spring-boot'),
126
167
  defaults: {
@@ -132,6 +173,20 @@ const FRAMEWORKS = {
132
173
  port: 8080,
133
174
  },
134
175
  },
176
+ java: {
177
+ detect: (cwd) =>
178
+ fs.existsSync(path.join(cwd, 'pom.xml')) ||
179
+ fs.existsSync(path.join(cwd, 'build.gradle')) ||
180
+ fs.existsSync(path.join(cwd, 'build.gradle.kts')),
181
+ defaults: {
182
+ language: 'java',
183
+ buildCommand: 'mvn package',
184
+ startCommand: 'java -jar target/*.jar',
185
+ buildOutput: 'target',
186
+ testCommand: 'mvn test',
187
+ port: 8080,
188
+ },
189
+ },
135
190
  go: {
136
191
  detect: (cwd) => fs.existsSync(path.join(cwd, 'go.mod')),
137
192
  defaults: {
@@ -165,12 +220,23 @@ const FRAMEWORKS = {
165
220
  defaults: {
166
221
  language: 'ruby',
167
222
  buildCommand: 'bundle exec rake assets:precompile',
168
- startCommand: 'bundle exec puma',
223
+ startCommand: 'bundle exec puma -b tcp://0.0.0.0:3000',
169
224
  buildOutput: '.',
170
225
  testCommand: 'bundle exec rspec',
171
226
  port: 3000,
172
227
  },
173
228
  },
229
+ ruby: {
230
+ detect: (cwd) => fs.existsSync(path.join(cwd, 'Gemfile')),
231
+ defaults: {
232
+ language: 'ruby',
233
+ buildCommand: null,
234
+ startCommand: 'bundle exec puma -b tcp://0.0.0.0:9292',
235
+ buildOutput: '.',
236
+ testCommand: 'bundle exec rspec',
237
+ port: 9292,
238
+ },
239
+ },
174
240
  };
175
241
 
176
242
  const DETECTION_ORDER = [
@@ -178,15 +244,20 @@ const DETECTION_ORDER = [
178
244
  'express',
179
245
  'fastify',
180
246
  'koa',
247
+ 'node',
181
248
  'fastapi',
182
249
  'django',
183
250
  'flask',
251
+ 'python',
184
252
  'laravel',
185
253
  'symfony',
254
+ 'php',
186
255
  'spring',
256
+ 'java',
187
257
  'go',
188
258
  'dotnet',
189
259
  'rails',
260
+ 'ruby',
190
261
  ];
191
262
 
192
263
  /**
@@ -201,6 +272,22 @@ function hasPackageDep(cwd, dep) {
201
272
  return !!deps[dep];
202
273
  }
203
274
 
275
+ /**
276
+ * @param {string} cwd
277
+ * @returns {boolean}
278
+ */
279
+ function isLikelyFrontendPackage(cwd) {
280
+ return [
281
+ 'react',
282
+ 'vue',
283
+ '@angular/core',
284
+ 'svelte',
285
+ 'astro',
286
+ 'next',
287
+ 'vite',
288
+ ].some((dep) => hasPackageDep(cwd, dep));
289
+ }
290
+
204
291
  /**
205
292
  * @param {string} cwd
206
293
  * @param {string} filename
@@ -225,6 +312,24 @@ function hasComposerPackage(cwd, packageName) {
225
312
  return !!deps[packageName];
226
313
  }
227
314
 
315
+ /**
316
+ * @param {string} cwd
317
+ * @returns {boolean}
318
+ */
319
+ function hasPhpSources(cwd) {
320
+ try {
321
+ const root = fs.readdirSync(cwd);
322
+ if (root.some((f) => f.endsWith('.php'))) return true;
323
+ const publicDir = path.join(cwd, 'public');
324
+ if (fs.existsSync(publicDir) && fs.statSync(publicDir).isDirectory()) {
325
+ return fs.readdirSync(publicDir).some((f) => f.endsWith('.php'));
326
+ }
327
+ } catch {
328
+ return false;
329
+ }
330
+ return false;
331
+ }
332
+
228
333
  /**
229
334
  * @param {string} [cwd]
230
335
  * @returns {string|null}
@@ -43,13 +43,14 @@ export async function detectFramework(cwd = process.cwd()) {
43
43
  for (const detector of legacyDetectors) {
44
44
  if (detector.detect(cwd)) {
45
45
  const info = detector.getInfo(cwd);
46
+ const backendLegacy = new Set(['node', 'php', 'python', 'java', 'go', 'dotnet', 'ruby']);
46
47
  return {
47
48
  ...info,
48
- projectType: info.framework === 'node' ? 'backend' : 'frontend',
49
+ projectType: backendLegacy.has(info.framework) ? 'backend' : 'frontend',
49
50
  language: info.framework,
50
51
  startCommand: null,
51
52
  testCommand: 'npm test',
52
- port: 3000,
53
+ port: info.framework === 'php' ? 80 : info.framework === 'ruby' ? 9292 : 3000,
53
54
  };
54
55
  }
55
56
  }
@@ -0,0 +1,292 @@
1
+ /**
2
+ * Canonical credential / env-var inventory for storage × deployment collision audits.
3
+ *
4
+ * Storage lists are the COMPLETE runtime reads from src/storage/providers/*.js
5
+ * (plus schema keys from PROVIDER_ENV_MAP). Deployment lists come from
6
+ * DEPLOYMENT_ENV_DEFS / DEPLOYMENT_ENV_KEYS (the declared complete method sets).
7
+ *
8
+ * Keep this module in sync when adding providers or methods — the matrix test
9
+ * fails if storage and deployment declare overlapping bare names.
10
+ */
11
+
12
+ import { DEPLOYMENT_ENV_KEYS } from '../deployment/deployment-env.js';
13
+
14
+ /** Storage provider ids in matrix order. */
15
+ export const STORAGE_PROVIDER_ORDER = [
16
+ 'aws',
17
+ 'azure',
18
+ 'gcp',
19
+ 'gdrive',
20
+ 'dropbox',
21
+ 'ftp',
22
+ 'local',
23
+ ];
24
+
25
+ /** Deployment method ids in matrix order. */
26
+ export const DEPLOYMENT_METHOD_ORDER = [
27
+ 'ssh',
28
+ 'ec2',
29
+ 'azure-vm',
30
+ 'gcp-vm',
31
+ 'docker',
32
+ 'kubernetes',
33
+ ];
34
+
35
+ /**
36
+ * Complete env vars each storage provider reads at runtime.
37
+ * Local: no credentials — DEPLOYHUB_LOCAL_STORAGE_DIR is an optional test pin only
38
+ * (listed under runtimeExtras, not as a credential).
39
+ *
40
+ * @type {Record<string, string[]>}
41
+ */
42
+ export const STORAGE_RUNTIME_ENV_KEYS = {
43
+ aws: [
44
+ 'AWS_ACCESS_KEY_ID',
45
+ 'AWS_SECRET_ACCESS_KEY',
46
+ 'AWS_BUCKET',
47
+ 'AWS_REGION',
48
+ ],
49
+ azure: ['AZURE_CONNECTION_STRING', 'AZURE_CONTAINER'],
50
+ gcp: ['GCP_PROJECT_ID', 'GCP_KEY_FILE', 'GCP_BUCKET'],
51
+ gdrive: [
52
+ 'GDRIVE_CLIENT_ID',
53
+ 'GDRIVE_CLIENT_SECRET',
54
+ 'GDRIVE_REFRESH_TOKEN',
55
+ 'GDRIVE_FOLDER_ID',
56
+ ],
57
+ dropbox: ['DROPBOX_ACCESS_TOKEN'],
58
+ ftp: ['FTP_HOST', 'FTP_USER', 'FTP_PASSWORD', 'FTP_PORT', 'FTP_PATH'],
59
+ local: [],
60
+ };
61
+
62
+ /**
63
+ * Non-credential runtime extras (optional paths / tuning). Not treated as
64
+ * storage credentials for collision purposes, but tracked for schema-drift docs.
65
+ *
66
+ * @type {Record<string, string[]>}
67
+ */
68
+ export const STORAGE_RUNTIME_EXTRAS = {
69
+ local: ['DEPLOYHUB_LOCAL_STORAGE_DIR'],
70
+ };
71
+
72
+ /**
73
+ * Complete declared deployment method env keys (core + optional + CI).
74
+ * @type {Record<string, string[]>}
75
+ */
76
+ export const DEPLOYMENT_RUNTIME_ENV_KEYS = {
77
+ ssh: DEPLOYMENT_ENV_KEYS.ssh,
78
+ ec2: DEPLOYMENT_ENV_KEYS.ec2,
79
+ 'azure-vm': DEPLOYMENT_ENV_KEYS['azure-vm'],
80
+ 'gcp-vm': DEPLOYMENT_ENV_KEYS['gcp-vm'],
81
+ docker: DEPLOYMENT_ENV_KEYS.docker,
82
+ kubernetes: DEPLOYMENT_ENV_KEYS.kubernetes,
83
+ };
84
+
85
+ /**
86
+ * SSH runtime also reads these tuning knobs (not in DEPLOYMENT_ENV_DEFS).
87
+ * Not credentials; listed for inventory completeness.
88
+ */
89
+ export const SSH_RUNTIME_TUNING_KEYS = [
90
+ 'DEPLOYHUB_SSH_EXEC_TIMEOUT_MS',
91
+ 'DEPLOYHUB_SSH_START_TIMEOUT_MS',
92
+ ];
93
+
94
+ /**
95
+ * Intentional same-name sharing across deployment methods (same concern).
96
+ * Separated per-environment by the existing secret-prefixing system when
97
+ * multiple environments exist.
98
+ *
99
+ * @type {{ methods: [string, string], keys: string[], reason: string }[]}
100
+ */
101
+ export const SHARED_BY_DESIGN_DEPLOY_PAIRS = [
102
+ {
103
+ methods: ['ssh', 'ec2'],
104
+ keys: [
105
+ 'SSH_HOST',
106
+ 'SSH_USER',
107
+ 'SSH_KEY_PATH',
108
+ 'SSH_SSH_PORT',
109
+ 'SSH_DEPLOY_PATH',
110
+ 'SSH_APP_NAME',
111
+ 'SSH_PORT',
112
+ 'SSH_KEY',
113
+ ],
114
+ reason: 'EC2 wraps SSH after optional host lookup — same SSH concern',
115
+ },
116
+ {
117
+ methods: ['ssh', 'azure-vm'],
118
+ keys: [
119
+ 'SSH_HOST',
120
+ 'SSH_USER',
121
+ 'SSH_KEY_PATH',
122
+ 'SSH_SSH_PORT',
123
+ 'SSH_DEPLOY_PATH',
124
+ 'SSH_APP_NAME',
125
+ 'SSH_PORT',
126
+ 'SSH_KEY',
127
+ ],
128
+ reason: 'Azure VM wraps SSH after optional host lookup — same SSH concern',
129
+ },
130
+ {
131
+ methods: ['ssh', 'gcp-vm'],
132
+ keys: [
133
+ 'SSH_HOST',
134
+ 'SSH_USER',
135
+ 'SSH_KEY_PATH',
136
+ 'SSH_SSH_PORT',
137
+ 'SSH_DEPLOY_PATH',
138
+ 'SSH_APP_NAME',
139
+ 'SSH_PORT',
140
+ 'SSH_KEY',
141
+ ],
142
+ reason: 'GCP VM wraps SSH after optional host lookup — same SSH concern',
143
+ },
144
+ {
145
+ methods: ['ec2', 'azure-vm'],
146
+ keys: [
147
+ 'SSH_HOST',
148
+ 'SSH_USER',
149
+ 'SSH_KEY_PATH',
150
+ 'SSH_SSH_PORT',
151
+ 'SSH_DEPLOY_PATH',
152
+ 'SSH_APP_NAME',
153
+ 'SSH_PORT',
154
+ 'SSH_KEY',
155
+ ],
156
+ reason: 'Both SSH-based cloud VMs share the SSH credential namespace',
157
+ },
158
+ {
159
+ methods: ['ec2', 'gcp-vm'],
160
+ keys: [
161
+ 'SSH_HOST',
162
+ 'SSH_USER',
163
+ 'SSH_KEY_PATH',
164
+ 'SSH_SSH_PORT',
165
+ 'SSH_DEPLOY_PATH',
166
+ 'SSH_APP_NAME',
167
+ 'SSH_PORT',
168
+ 'SSH_KEY',
169
+ ],
170
+ reason: 'Both SSH-based cloud VMs share the SSH credential namespace',
171
+ },
172
+ {
173
+ methods: ['azure-vm', 'gcp-vm'],
174
+ keys: [
175
+ 'SSH_HOST',
176
+ 'SSH_USER',
177
+ 'SSH_KEY_PATH',
178
+ 'SSH_SSH_PORT',
179
+ 'SSH_DEPLOY_PATH',
180
+ 'SSH_APP_NAME',
181
+ 'SSH_PORT',
182
+ 'SSH_KEY',
183
+ ],
184
+ reason: 'Both SSH-based cloud VMs share the SSH credential namespace',
185
+ },
186
+ {
187
+ methods: ['docker', 'kubernetes'],
188
+ keys: [
189
+ 'DOCKER_IMAGE_NAME',
190
+ 'DOCKER_IMAGE_TAG',
191
+ 'DOCKER_REGISTRY_URL',
192
+ 'DOCKER_REGISTRY_USERNAME',
193
+ 'DOCKER_REGISTRY_TOKEN',
194
+ ],
195
+ reason:
196
+ 'Same container-registry concern — Kubernetes builds/pushes via the same Docker image helpers',
197
+ },
198
+ ];
199
+
200
+ /**
201
+ * @param {string[]} a
202
+ * @param {string[]} b
203
+ * @returns {string[]}
204
+ */
205
+ export function intersectKeys(a, b) {
206
+ const setB = new Set(b);
207
+ return a.filter((k) => setB.has(k)).sort();
208
+ }
209
+
210
+ /**
211
+ * Every storage × deployment overlap cell.
212
+ * Empty array = no overlap.
213
+ *
214
+ * @returns {Record<string, Record<string, string[]>>}
215
+ */
216
+ export function buildStorageDeployOverlapMatrix() {
217
+ /** @type {Record<string, Record<string, string[]>>} */
218
+ const matrix = {};
219
+ for (const storageId of STORAGE_PROVIDER_ORDER) {
220
+ matrix[storageId] = {};
221
+ const storageKeys = STORAGE_RUNTIME_ENV_KEYS[storageId] || [];
222
+ for (const method of DEPLOYMENT_METHOD_ORDER) {
223
+ const deployKeys = DEPLOYMENT_RUNTIME_ENV_KEYS[method] || [];
224
+ matrix[storageId][method] = intersectKeys(storageKeys, deployKeys);
225
+ }
226
+ }
227
+ return matrix;
228
+ }
229
+
230
+ /**
231
+ * Flatten all unintended storage×deploy collisions (should be empty).
232
+ * @returns {{ storage: string, method: string, keys: string[] }[]}
233
+ */
234
+ export function listStorageDeployCollisions() {
235
+ const matrix = buildStorageDeployOverlapMatrix();
236
+ /** @type {{ storage: string, method: string, keys: string[] }[]} */
237
+ const hits = [];
238
+ for (const storageId of STORAGE_PROVIDER_ORDER) {
239
+ for (const method of DEPLOYMENT_METHOD_ORDER) {
240
+ const keys = matrix[storageId][method];
241
+ if (keys.length > 0) {
242
+ hits.push({ storage: storageId, method, keys });
243
+ }
244
+ }
245
+ }
246
+ return hits;
247
+ }
248
+
249
+ /**
250
+ * Deploy×deploy shared keys, classified against SHARED_BY_DESIGN_DEPLOY_PAIRS.
251
+ * @returns {{ a: string, b: string, keys: string[], designed: boolean, reason?: string }[]}
252
+ */
253
+ export function listDeployDeployShares() {
254
+ /** @type {{ a: string, b: string, keys: string[], designed: boolean, reason?: string }[]} */
255
+ const rows = [];
256
+ for (let i = 0; i < DEPLOYMENT_METHOD_ORDER.length; i++) {
257
+ for (let j = i + 1; j < DEPLOYMENT_METHOD_ORDER.length; j++) {
258
+ const a = DEPLOYMENT_METHOD_ORDER[i];
259
+ const b = DEPLOYMENT_METHOD_ORDER[j];
260
+ const keys = intersectKeys(
261
+ DEPLOYMENT_RUNTIME_ENV_KEYS[a] || [],
262
+ DEPLOYMENT_RUNTIME_ENV_KEYS[b] || []
263
+ );
264
+ if (keys.length === 0) continue;
265
+ const designed = SHARED_BY_DESIGN_DEPLOY_PAIRS.find(
266
+ (p) =>
267
+ (p.methods[0] === a && p.methods[1] === b) ||
268
+ (p.methods[0] === b && p.methods[1] === a)
269
+ );
270
+ const unexpected = designed
271
+ ? keys.filter((k) => !designed.keys.includes(k))
272
+ : keys;
273
+ rows.push({
274
+ a,
275
+ b,
276
+ keys,
277
+ designed: Boolean(designed) && unexpected.length === 0,
278
+ reason: designed?.reason,
279
+ });
280
+ if (designed && unexpected.length > 0) {
281
+ rows.push({
282
+ a,
283
+ b,
284
+ keys: unexpected,
285
+ designed: false,
286
+ reason: `unexpected share beyond designed set for ${a}∩${b}`,
287
+ });
288
+ }
289
+ }
290
+ }
291
+ return rows;
292
+ }