@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.
@@ -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
+ }
@@ -207,6 +207,9 @@ export function isInterpretedBackendFramework(framework) {
207
207
  'flask',
208
208
  'laravel',
209
209
  'symfony',
210
+ 'php',
211
+ 'python',
212
+ 'ruby',
210
213
  'rails',
211
214
  ].includes(framework || '');
212
215
  }
@@ -224,21 +227,21 @@ export function describeInterpretedBackendGap(framework) {
224
227
  installCmd: 'npm ci',
225
228
  };
226
229
  }
227
- if (['fastapi', 'django', 'flask'].includes(framework)) {
230
+ if (['fastapi', 'django', 'flask', 'python'].includes(framework)) {
228
231
  return {
229
232
  ecosystem: 'Python',
230
233
  missing: 'a pre-installed virtualenv / site-packages (artifacts only ship requirements.txt + source)',
231
234
  installCmd: 'pip install',
232
235
  };
233
236
  }
234
- if (['laravel', 'symfony'].includes(framework)) {
237
+ if (['laravel', 'symfony', 'php'].includes(framework)) {
235
238
  return {
236
239
  ecosystem: 'PHP',
237
240
  missing: 'vendor/ (Composer dependencies are not packaged into the artifact)',
238
241
  installCmd: 'composer install',
239
242
  };
240
243
  }
241
- if (framework === 'rails') {
244
+ if (framework === 'rails' || framework === 'ruby') {
242
245
  return {
243
246
  ecosystem: 'Ruby',
244
247
  missing: 'vendor/bundle / installed gems (artifacts only ship Gemfile + lock)',
@@ -71,6 +71,7 @@ export function resolveFallbackContainerPort(config) {
71
71
  if (framework === 'go') return 8080;
72
72
  if (framework === 'dotnet') return 5000;
73
73
  if (framework === 'rails') return 3000;
74
+ if (framework === 'ruby') return 9292;
74
75
 
75
76
  // nextjs, nestjs, express, and other Node backends
76
77
  return 3000;