genbox 1.0.15 → 1.0.16
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/dist/commands/create.js +52 -15
- package/dist/commands/init.js +279 -5
- package/dist/commands/scan.js +174 -1
- package/package.json +1 -1
package/dist/commands/create.js
CHANGED
|
@@ -319,10 +319,46 @@ function parseEnvGenboxSections(content) {
|
|
|
319
319
|
}
|
|
320
320
|
return sections;
|
|
321
321
|
}
|
|
322
|
+
/**
|
|
323
|
+
* Build a map of service URL variables based on connection type
|
|
324
|
+
* e.g., if connectTo=staging: GATEWAY_URL → STAGING_GATEWAY_URL value
|
|
325
|
+
*/
|
|
326
|
+
function buildServiceUrlMap(envVarsFromFile, connectTo) {
|
|
327
|
+
const urlMap = {};
|
|
328
|
+
const prefix = connectTo ? `${connectTo.toUpperCase()}_` : 'LOCAL_';
|
|
329
|
+
// Find all service URL variables (LOCAL_*_URL and STAGING_*_URL patterns)
|
|
330
|
+
const serviceNames = new Set();
|
|
331
|
+
for (const key of Object.keys(envVarsFromFile)) {
|
|
332
|
+
const match = key.match(/^(LOCAL|STAGING|PRODUCTION)_(.+_URL)$/);
|
|
333
|
+
if (match) {
|
|
334
|
+
serviceNames.add(match[2]);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
// Build mapping: VARNAME → value from appropriate prefix
|
|
338
|
+
for (const serviceName of serviceNames) {
|
|
339
|
+
const prefixedKey = `${prefix}${serviceName}`;
|
|
340
|
+
const localKey = `LOCAL_${serviceName}`;
|
|
341
|
+
// Use prefixed value if available, otherwise fall back to local
|
|
342
|
+
const value = envVarsFromFile[prefixedKey] || envVarsFromFile[localKey];
|
|
343
|
+
if (value) {
|
|
344
|
+
urlMap[serviceName] = value;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
// Also handle legacy API_URL for backwards compatibility
|
|
348
|
+
if (!urlMap['API_URL']) {
|
|
349
|
+
const apiUrl = envVarsFromFile[`${prefix}API_URL`] ||
|
|
350
|
+
envVarsFromFile['LOCAL_API_URL'] ||
|
|
351
|
+
envVarsFromFile['STAGING_API_URL'];
|
|
352
|
+
if (apiUrl) {
|
|
353
|
+
urlMap['API_URL'] = apiUrl;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return urlMap;
|
|
357
|
+
}
|
|
322
358
|
/**
|
|
323
359
|
* Build env content for a specific app by combining GLOBAL + app-specific sections
|
|
324
360
|
*/
|
|
325
|
-
function buildAppEnvContent(sections, appName,
|
|
361
|
+
function buildAppEnvContent(sections, appName, serviceUrlMap) {
|
|
326
362
|
const parts = [];
|
|
327
363
|
// Always include GLOBAL section
|
|
328
364
|
const globalSection = sections.get('GLOBAL');
|
|
@@ -335,8 +371,11 @@ function buildAppEnvContent(sections, appName, apiUrl) {
|
|
|
335
371
|
parts.push(appSection);
|
|
336
372
|
}
|
|
337
373
|
let envContent = parts.join('\n\n');
|
|
338
|
-
// Expand ${
|
|
339
|
-
|
|
374
|
+
// Expand all ${VARNAME} references using the service URL map
|
|
375
|
+
for (const [varName, value] of Object.entries(serviceUrlMap)) {
|
|
376
|
+
const pattern = new RegExp(`\\$\\{${varName}\\}`, 'g');
|
|
377
|
+
envContent = envContent.replace(pattern, value);
|
|
378
|
+
}
|
|
340
379
|
// Keep only actual env vars (filter out pure comment lines but keep var definitions)
|
|
341
380
|
envContent = envContent
|
|
342
381
|
.split('\n')
|
|
@@ -393,21 +432,19 @@ function buildPayload(resolved, config, publicKey, privateKey, configLoader) {
|
|
|
393
432
|
envVarsFromFile[match[1]] = value;
|
|
394
433
|
}
|
|
395
434
|
}
|
|
396
|
-
// Determine
|
|
435
|
+
// Determine connection type from profile's connect_to (v3) or default_connection (v4)
|
|
397
436
|
let connectTo;
|
|
398
437
|
if (resolved.profile && config.profiles?.[resolved.profile]) {
|
|
399
438
|
const profile = config.profiles[resolved.profile];
|
|
400
439
|
connectTo = (0, config_loader_1.getProfileConnection)(profile);
|
|
401
440
|
}
|
|
402
|
-
|
|
403
|
-
if
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
// Use local API URL
|
|
410
|
-
apiUrl = envVarsFromFile['LOCAL_API_URL'] || 'http://localhost:3050';
|
|
441
|
+
// Build service URL map for variable expansion
|
|
442
|
+
// This maps GATEWAY_URL → STAGING_GATEWAY_URL value (if connectTo=staging)
|
|
443
|
+
// or GATEWAY_URL → LOCAL_GATEWAY_URL value (if local)
|
|
444
|
+
const serviceUrlMap = buildServiceUrlMap(envVarsFromFile, connectTo);
|
|
445
|
+
// Log what's being expanded for debugging
|
|
446
|
+
if (connectTo && Object.keys(serviceUrlMap).length > 0) {
|
|
447
|
+
console.log(chalk_1.default.dim(` Using ${connectTo} URLs for variable expansion`));
|
|
411
448
|
}
|
|
412
449
|
// Add env file for each app - filtered by selected apps only
|
|
413
450
|
for (const app of resolved.apps) {
|
|
@@ -421,7 +458,7 @@ function buildPayload(resolved, config, publicKey, privateKey, configLoader) {
|
|
|
421
458
|
for (const serviceSectionName of servicesSections) {
|
|
422
459
|
const serviceName = serviceSectionName.split('/')[1];
|
|
423
460
|
// Build service-specific env content (GLOBAL + service section)
|
|
424
|
-
const serviceEnvContent = buildAppEnvContent(sections, serviceSectionName,
|
|
461
|
+
const serviceEnvContent = buildAppEnvContent(sections, serviceSectionName, serviceUrlMap);
|
|
425
462
|
const stagingName = `${app.name}-${serviceName}.env`;
|
|
426
463
|
const targetPath = `${repoPath}/apps/${serviceName}/.env`;
|
|
427
464
|
files.push({
|
|
@@ -434,7 +471,7 @@ function buildPayload(resolved, config, publicKey, privateKey, configLoader) {
|
|
|
434
471
|
}
|
|
435
472
|
else {
|
|
436
473
|
// Regular app - build app-specific env content (GLOBAL + app section)
|
|
437
|
-
const appEnvContent = buildAppEnvContent(sections, app.name,
|
|
474
|
+
const appEnvContent = buildAppEnvContent(sections, app.name, serviceUrlMap);
|
|
438
475
|
files.push({
|
|
439
476
|
path: `/home/dev/.env-staging/${app.name}.env`,
|
|
440
477
|
content: appEnvContent,
|
package/dist/commands/init.js
CHANGED
|
@@ -51,6 +51,217 @@ const config_generator_1 = require("../scanner/config-generator");
|
|
|
51
51
|
const scan_1 = require("../scan");
|
|
52
52
|
const CONFIG_FILENAME = 'genbox.yaml';
|
|
53
53
|
const ENV_FILENAME = '.env.genbox';
|
|
54
|
+
/**
|
|
55
|
+
* Extract unique HTTP URLs from frontend app sections in .env.genbox content
|
|
56
|
+
* Catches both localhost URLs and Docker internal service references
|
|
57
|
+
*/
|
|
58
|
+
function extractFrontendHttpUrls(envContent, frontendApps) {
|
|
59
|
+
const serviceUrls = new Map();
|
|
60
|
+
// Parse sections
|
|
61
|
+
const sections = new Map();
|
|
62
|
+
let currentSection = 'GLOBAL';
|
|
63
|
+
let currentContent = [];
|
|
64
|
+
for (const line of envContent.split('\n')) {
|
|
65
|
+
const sectionMatch = line.match(/^# === ([^=]+) ===$/);
|
|
66
|
+
if (sectionMatch) {
|
|
67
|
+
if (currentContent.length > 0) {
|
|
68
|
+
sections.set(currentSection, currentContent.join('\n'));
|
|
69
|
+
}
|
|
70
|
+
currentSection = sectionMatch[1].trim();
|
|
71
|
+
currentContent = [];
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
currentContent.push(line);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (currentContent.length > 0) {
|
|
78
|
+
sections.set(currentSection, currentContent.join('\n'));
|
|
79
|
+
}
|
|
80
|
+
// Only process frontend app sections
|
|
81
|
+
for (const appName of frontendApps) {
|
|
82
|
+
const appSection = sections.get(appName);
|
|
83
|
+
if (!appSection)
|
|
84
|
+
continue;
|
|
85
|
+
// Find all HTTP URLs
|
|
86
|
+
// Match: VAR_NAME="http://host:port/path" or VAR_NAME=http://host:port/path
|
|
87
|
+
const urlRegex = /^([A-Z_][A-Z0-9_]*)=["']?(https?:\/\/[a-zA-Z0-9_.-]+(?::\d+)?[^"'\s]*)["']?/gm;
|
|
88
|
+
let match;
|
|
89
|
+
while ((match = urlRegex.exec(appSection)) !== null) {
|
|
90
|
+
const varName = match[1];
|
|
91
|
+
const fullUrl = match[2];
|
|
92
|
+
// Extract hostname from URL
|
|
93
|
+
const hostMatch = fullUrl.match(/^https?:\/\/([a-zA-Z0-9_.-]+)/);
|
|
94
|
+
if (!hostMatch)
|
|
95
|
+
continue;
|
|
96
|
+
const hostname = hostMatch[1];
|
|
97
|
+
// Only include URLs that look like local/development URLs:
|
|
98
|
+
// - localhost
|
|
99
|
+
// - Single-word hosts (Docker internal names like "auth", "gateway", "redis")
|
|
100
|
+
// - IP addresses
|
|
101
|
+
const isLocalUrl = hostname === 'localhost' ||
|
|
102
|
+
!hostname.includes('.') || // No dots = likely Docker internal name
|
|
103
|
+
/^\d+\.\d+\.\d+\.\d+$/.test(hostname); // IP address
|
|
104
|
+
if (!isLocalUrl) {
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
// Extract base URL (protocol + host + port)
|
|
108
|
+
const baseMatch = fullUrl.match(/^(https?:\/\/[a-zA-Z0-9_.-]+(?::\d+)?)/);
|
|
109
|
+
if (!baseMatch)
|
|
110
|
+
continue;
|
|
111
|
+
const baseUrl = baseMatch[1];
|
|
112
|
+
// Group by base URL
|
|
113
|
+
if (!serviceUrls.has(baseUrl)) {
|
|
114
|
+
serviceUrls.set(baseUrl, { urls: new Set(), vars: [] });
|
|
115
|
+
}
|
|
116
|
+
serviceUrls.get(baseUrl).urls.add(fullUrl);
|
|
117
|
+
if (!serviceUrls.get(baseUrl).vars.includes(varName)) {
|
|
118
|
+
serviceUrls.get(baseUrl).vars.push(varName);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return serviceUrls;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Determine service name from URL (hostname or port)
|
|
126
|
+
* Generates variable prefix from hostname or port number
|
|
127
|
+
*/
|
|
128
|
+
function getServiceNameFromUrl(baseUrl) {
|
|
129
|
+
// Parse the URL
|
|
130
|
+
const urlMatch = baseUrl.match(/^https?:\/\/([a-zA-Z0-9_.-]+)(?::(\d+))?/);
|
|
131
|
+
if (!urlMatch) {
|
|
132
|
+
return { name: 'unknown', varPrefix: 'UNKNOWN', description: 'Unknown Service' };
|
|
133
|
+
}
|
|
134
|
+
const hostname = urlMatch[1];
|
|
135
|
+
const port = urlMatch[2] ? parseInt(urlMatch[2]) : undefined;
|
|
136
|
+
// Generate from hostname if not localhost
|
|
137
|
+
if (hostname !== 'localhost') {
|
|
138
|
+
const varPrefix = hostname.toUpperCase().replace(/-/g, '_');
|
|
139
|
+
return {
|
|
140
|
+
name: hostname,
|
|
141
|
+
varPrefix: `${varPrefix}`,
|
|
142
|
+
description: `${hostname} service`
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
// Generate from port for localhost URLs
|
|
146
|
+
if (port) {
|
|
147
|
+
return {
|
|
148
|
+
name: `port-${port}`,
|
|
149
|
+
varPrefix: `PORT_${port}`,
|
|
150
|
+
description: `localhost:${port}`
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
return { name: 'unknown', varPrefix: 'UNKNOWN', description: 'Unknown Service' };
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Prompt user for staging URLs for each unique service URL found in frontend apps
|
|
157
|
+
*/
|
|
158
|
+
async function promptForStagingUrls(serviceUrls, existingStagingApiUrl) {
|
|
159
|
+
const mappings = [];
|
|
160
|
+
if (serviceUrls.size === 0) {
|
|
161
|
+
return mappings;
|
|
162
|
+
}
|
|
163
|
+
console.log('');
|
|
164
|
+
console.log(chalk_1.default.blue('=== Frontend Service URL Configuration ==='));
|
|
165
|
+
console.log(chalk_1.default.dim('Frontend apps have service URLs that need staging equivalents.'));
|
|
166
|
+
console.log(chalk_1.default.dim('This enables profiles like admin-quick to connect to staging backend.'));
|
|
167
|
+
console.log('');
|
|
168
|
+
// Sort by port number for consistent ordering
|
|
169
|
+
const sortedServices = Array.from(serviceUrls.entries()).sort((a, b) => {
|
|
170
|
+
const portA = parseInt(a[0].match(/:(\d+)/)?.[1] || '0');
|
|
171
|
+
const portB = parseInt(b[0].match(/:(\d+)/)?.[1] || '0');
|
|
172
|
+
return portA - portB;
|
|
173
|
+
});
|
|
174
|
+
for (const [baseUrl, { urls, vars }] of sortedServices) {
|
|
175
|
+
const serviceInfo = getServiceNameFromUrl(baseUrl);
|
|
176
|
+
// Show what URLs use this service
|
|
177
|
+
console.log(chalk_1.default.cyan(`${serviceInfo.description} (${baseUrl})`));
|
|
178
|
+
console.log(chalk_1.default.dim(` Used by: ${vars.slice(0, 3).join(', ')}${vars.length > 3 ? ` +${vars.length - 3} more` : ''}`));
|
|
179
|
+
// For gateway, suggest existing staging URL if available
|
|
180
|
+
let defaultUrl = '';
|
|
181
|
+
if ((serviceInfo.name === 'gateway' || baseUrl.includes(':3050')) && existingStagingApiUrl) {
|
|
182
|
+
defaultUrl = existingStagingApiUrl;
|
|
183
|
+
}
|
|
184
|
+
const stagingUrl = await prompts.input({
|
|
185
|
+
message: ` Staging URL (leave empty to skip):`,
|
|
186
|
+
default: defaultUrl,
|
|
187
|
+
});
|
|
188
|
+
mappings.push({
|
|
189
|
+
varName: `${serviceInfo.varPrefix}_URL`,
|
|
190
|
+
localUrl: baseUrl,
|
|
191
|
+
stagingUrl: stagingUrl || undefined,
|
|
192
|
+
description: serviceInfo.description,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
return mappings;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Transform .env.genbox content to use expandable variables
|
|
199
|
+
*/
|
|
200
|
+
function transformEnvWithVariables(envContent, mappings, frontendApps) {
|
|
201
|
+
if (mappings.length === 0) {
|
|
202
|
+
return envContent;
|
|
203
|
+
}
|
|
204
|
+
let result = envContent;
|
|
205
|
+
// Build GLOBAL section additions
|
|
206
|
+
const globalAdditions = [
|
|
207
|
+
'',
|
|
208
|
+
'# Service URL Configuration',
|
|
209
|
+
'# These expand based on profile: ${GATEWAY_URL} → LOCAL or STAGING value',
|
|
210
|
+
];
|
|
211
|
+
for (const mapping of mappings) {
|
|
212
|
+
globalAdditions.push(`LOCAL_${mapping.varName}=${mapping.localUrl}`);
|
|
213
|
+
if (mapping.stagingUrl) {
|
|
214
|
+
globalAdditions.push(`STAGING_${mapping.varName}=${mapping.stagingUrl}`);
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
globalAdditions.push(`# STAGING_${mapping.varName}=https://your-staging-url.com`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
globalAdditions.push('');
|
|
221
|
+
// Insert after GLOBAL section header and existing content
|
|
222
|
+
const globalInsertPoint = result.indexOf('# === GLOBAL ===');
|
|
223
|
+
if (globalInsertPoint !== -1) {
|
|
224
|
+
// Find the next section or end of GLOBAL
|
|
225
|
+
const nextSectionMatch = result.substring(globalInsertPoint + 20).match(/\n# === [^=]+ ===/);
|
|
226
|
+
const insertAt = nextSectionMatch
|
|
227
|
+
? globalInsertPoint + 20 + nextSectionMatch.index
|
|
228
|
+
: result.length;
|
|
229
|
+
result = result.substring(0, insertAt) + globalAdditions.join('\n') + result.substring(insertAt);
|
|
230
|
+
}
|
|
231
|
+
// Replace localhost URLs with variable syntax in frontend app sections
|
|
232
|
+
// Parse sections again after modification
|
|
233
|
+
const lines = result.split('\n');
|
|
234
|
+
const transformedLines = [];
|
|
235
|
+
let currentSection = 'GLOBAL';
|
|
236
|
+
let inFrontendSection = false;
|
|
237
|
+
for (const line of lines) {
|
|
238
|
+
const sectionMatch = line.match(/^# === ([^=]+) ===$/);
|
|
239
|
+
if (sectionMatch) {
|
|
240
|
+
currentSection = sectionMatch[1].trim();
|
|
241
|
+
inFrontendSection = frontendApps.includes(currentSection);
|
|
242
|
+
transformedLines.push(line);
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
if (inFrontendSection && line.includes('http://localhost:')) {
|
|
246
|
+
// Check if this line matches any of our mappings
|
|
247
|
+
let transformedLine = line;
|
|
248
|
+
for (const mapping of mappings) {
|
|
249
|
+
if (line.includes(mapping.localUrl)) {
|
|
250
|
+
// Replace the full URL, preserving any path suffix
|
|
251
|
+
const urlPattern = new RegExp(mapping.localUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(/[^"\'\\s]*)?', 'g');
|
|
252
|
+
transformedLine = transformedLine.replace(urlPattern, (match, pathSuffix) => {
|
|
253
|
+
return `\${${mapping.varName}}${pathSuffix || ''}`;
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
transformedLines.push(transformedLine);
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
transformedLines.push(line);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return transformedLines.join('\n');
|
|
264
|
+
}
|
|
54
265
|
/**
|
|
55
266
|
* Detect git repositories in app directories (for multi-repo workspaces)
|
|
56
267
|
*/
|
|
@@ -385,10 +596,12 @@ exports.initCommand = new commander_1.Command('init')
|
|
|
385
596
|
console.log(chalk_1.default.dim(' 3. Select scope: "repo" (Full control of private repositories)'));
|
|
386
597
|
console.log(chalk_1.default.dim(' 4. Generate and copy the token'));
|
|
387
598
|
console.log('');
|
|
388
|
-
|
|
599
|
+
let gitToken = await prompts.password({
|
|
389
600
|
message: 'GitHub Personal Access Token (leave empty to skip):',
|
|
390
601
|
});
|
|
391
602
|
if (gitToken) {
|
|
603
|
+
// Strip any "GIT_TOKEN=" prefix if user pasted the whole line
|
|
604
|
+
gitToken = gitToken.replace(/^GIT_TOKEN=/i, '');
|
|
392
605
|
envVarsToAdd['GIT_TOKEN'] = gitToken;
|
|
393
606
|
console.log(chalk_1.default.green('✓ GIT_TOKEN will be added to .env.genbox'));
|
|
394
607
|
}
|
|
@@ -412,10 +625,12 @@ exports.initCommand = new commander_1.Command('init')
|
|
|
412
625
|
if (scan.git.type === 'https' && !nonInteractive) {
|
|
413
626
|
console.log('');
|
|
414
627
|
console.log(chalk_1.default.yellow('Private repositories require a GitHub token for cloning.'));
|
|
415
|
-
|
|
628
|
+
let gitToken = await prompts.password({
|
|
416
629
|
message: 'GitHub Personal Access Token (leave empty to skip):',
|
|
417
630
|
});
|
|
418
631
|
if (gitToken) {
|
|
632
|
+
// Strip any "GIT_TOKEN=" prefix if user pasted the whole line
|
|
633
|
+
gitToken = gitToken.replace(/^GIT_TOKEN=/i, '');
|
|
419
634
|
envVarsToAdd['GIT_TOKEN'] = gitToken;
|
|
420
635
|
console.log(chalk_1.default.green('✓ GIT_TOKEN will be added to .env.genbox'));
|
|
421
636
|
}
|
|
@@ -464,10 +679,12 @@ exports.initCommand = new commander_1.Command('init')
|
|
|
464
679
|
console.log(chalk_1.default.dim(' 3. Select scope: "repo" (Full control of private repositories)'));
|
|
465
680
|
console.log(chalk_1.default.dim(' 4. Generate and copy the token'));
|
|
466
681
|
console.log('');
|
|
467
|
-
|
|
682
|
+
let gitToken = await prompts.password({
|
|
468
683
|
message: 'GitHub Personal Access Token (leave empty to skip):',
|
|
469
684
|
});
|
|
470
685
|
if (gitToken) {
|
|
686
|
+
// Strip any "GIT_TOKEN=" prefix if user pasted the whole line
|
|
687
|
+
gitToken = gitToken.replace(/^GIT_TOKEN=/i, '');
|
|
471
688
|
envVarsToAdd['GIT_TOKEN'] = gitToken;
|
|
472
689
|
console.log(chalk_1.default.green('✓ GIT_TOKEN will be added to .env.genbox'));
|
|
473
690
|
}
|
|
@@ -610,8 +827,19 @@ exports.initCommand = new commander_1.Command('init')
|
|
|
610
827
|
}
|
|
611
828
|
}
|
|
612
829
|
}
|
|
830
|
+
// Load detected service URLs if using --from-scan
|
|
831
|
+
let detectedServiceUrls;
|
|
832
|
+
if (options.fromScan) {
|
|
833
|
+
const detectedPath = path_1.default.join(process.cwd(), '.genbox', 'detected.yaml');
|
|
834
|
+
try {
|
|
835
|
+
const content = fs_1.default.readFileSync(detectedPath, 'utf8');
|
|
836
|
+
const detectedConfig = yaml.load(content);
|
|
837
|
+
detectedServiceUrls = detectedConfig.service_urls;
|
|
838
|
+
}
|
|
839
|
+
catch { }
|
|
840
|
+
}
|
|
613
841
|
// Generate .env.genbox
|
|
614
|
-
await setupEnvFile(projectName, v4Config, nonInteractive, scan, isMultiRepo, envVarsToAdd, overwriteExisting);
|
|
842
|
+
await setupEnvFile(projectName, v4Config, nonInteractive, scan, isMultiRepo, envVarsToAdd, overwriteExisting, detectedServiceUrls);
|
|
615
843
|
// Show warnings
|
|
616
844
|
if (generated.warnings.length > 0) {
|
|
617
845
|
console.log('');
|
|
@@ -935,7 +1163,7 @@ async function setupEnvironments(scan, config, isMultiRepo = false) {
|
|
|
935
1163
|
/**
|
|
936
1164
|
* Setup .env.genbox file with segregated app sections
|
|
937
1165
|
*/
|
|
938
|
-
async function setupEnvFile(projectName, config, nonInteractive = false, scan, isMultiRepo = false, extraEnvVars = {}, overwriteExisting = false) {
|
|
1166
|
+
async function setupEnvFile(projectName, config, nonInteractive = false, scan, isMultiRepo = false, extraEnvVars = {}, overwriteExisting = false, detectedServiceUrls) {
|
|
939
1167
|
const envPath = path_1.default.join(process.cwd(), ENV_FILENAME);
|
|
940
1168
|
// If overwriting, delete existing file
|
|
941
1169
|
if (fs_1.default.existsSync(envPath)) {
|
|
@@ -1048,6 +1276,52 @@ async function setupEnvFile(projectName, config, nonInteractive = false, scan, i
|
|
|
1048
1276
|
}
|
|
1049
1277
|
}
|
|
1050
1278
|
}
|
|
1279
|
+
// Identify frontend apps for URL transformation
|
|
1280
|
+
const frontendApps = scan?.apps
|
|
1281
|
+
.filter(a => a.type === 'frontend')
|
|
1282
|
+
.map(a => a.name) || [];
|
|
1283
|
+
// Also get frontend apps from config if available
|
|
1284
|
+
if (config.apps) {
|
|
1285
|
+
for (const [name, app] of Object.entries(config.apps)) {
|
|
1286
|
+
if (app.type === 'frontend' && !frontendApps.includes(name)) {
|
|
1287
|
+
frontendApps.push(name);
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
if (frontendApps.length > 0 && !nonInteractive) {
|
|
1292
|
+
// Use service URLs from detected.yaml if available (preferred)
|
|
1293
|
+
// Otherwise fall back to scanning the collected env content
|
|
1294
|
+
let serviceUrls;
|
|
1295
|
+
if (detectedServiceUrls && detectedServiceUrls.length > 0) {
|
|
1296
|
+
// Convert detected service URLs to the Map format
|
|
1297
|
+
serviceUrls = new Map();
|
|
1298
|
+
for (const svc of detectedServiceUrls) {
|
|
1299
|
+
serviceUrls.set(svc.base_url, {
|
|
1300
|
+
urls: new Set([svc.base_url]),
|
|
1301
|
+
vars: svc.used_by,
|
|
1302
|
+
});
|
|
1303
|
+
}
|
|
1304
|
+
console.log('');
|
|
1305
|
+
console.log(chalk_1.default.dim(`Found ${detectedServiceUrls.length} service URL(s) from detected.yaml`));
|
|
1306
|
+
}
|
|
1307
|
+
else {
|
|
1308
|
+
// Fall back to extracting from collected env content
|
|
1309
|
+
serviceUrls = extractFrontendHttpUrls(segregatedContent, frontendApps);
|
|
1310
|
+
}
|
|
1311
|
+
if (serviceUrls.size > 0) {
|
|
1312
|
+
// Get existing staging API URL if configured
|
|
1313
|
+
const existingStagingApiUrl = extraEnvVars['STAGING_API_URL'] ||
|
|
1314
|
+
(config.environments?.staging?.urls?.api);
|
|
1315
|
+
// Prompt for staging equivalents
|
|
1316
|
+
const urlMappings = await promptForStagingUrls(serviceUrls, existingStagingApiUrl);
|
|
1317
|
+
// Transform content with expandable variables
|
|
1318
|
+
if (urlMappings.length > 0) {
|
|
1319
|
+
segregatedContent = transformEnvWithVariables(segregatedContent, urlMappings, frontendApps);
|
|
1320
|
+
console.log('');
|
|
1321
|
+
console.log(chalk_1.default.green(`✓ Configured ${urlMappings.length} service URL(s) for staging support`));
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1051
1325
|
// Add END marker
|
|
1052
1326
|
segregatedContent += `# === END ===\n`;
|
|
1053
1327
|
// Write the file
|
package/dist/commands/scan.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* genbox scan # Output to .genbox/detected.yaml
|
|
13
13
|
* genbox scan --stdout # Output to stdout
|
|
14
14
|
* genbox scan --json # Output as JSON
|
|
15
|
+
* genbox scan -i # Interactive mode (select apps)
|
|
15
16
|
*/
|
|
16
17
|
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
17
18
|
if (k2 === undefined) k2 = k;
|
|
@@ -52,6 +53,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
52
53
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
53
54
|
exports.scanCommand = void 0;
|
|
54
55
|
const commander_1 = require("commander");
|
|
56
|
+
const prompts = __importStar(require("@inquirer/prompts"));
|
|
55
57
|
const chalk_1 = __importDefault(require("chalk"));
|
|
56
58
|
const fs = __importStar(require("fs"));
|
|
57
59
|
const path = __importStar(require("path"));
|
|
@@ -109,9 +111,11 @@ exports.scanCommand = new commander_1.Command('scan')
|
|
|
109
111
|
.option('--json', 'Output as JSON instead of YAML')
|
|
110
112
|
.option('--no-infra', 'Skip infrastructure detection (docker-compose)')
|
|
111
113
|
.option('--no-scripts', 'Skip script detection')
|
|
114
|
+
.option('-i, --interactive', 'Interactive mode - select apps before writing')
|
|
112
115
|
.option('-e, --exclude <patterns>', 'Comma-separated patterns to exclude', '')
|
|
113
116
|
.action(async (options) => {
|
|
114
117
|
const cwd = process.cwd();
|
|
118
|
+
const isInteractive = options.interactive && !options.stdout && process.stdin.isTTY;
|
|
115
119
|
console.log(chalk_1.default.cyan('\n🔍 Scanning project...\n'));
|
|
116
120
|
try {
|
|
117
121
|
// Run the scanner
|
|
@@ -122,7 +126,21 @@ exports.scanCommand = new commander_1.Command('scan')
|
|
|
122
126
|
skipScripts: !options.scripts,
|
|
123
127
|
});
|
|
124
128
|
// Convert scan result to DetectedConfig format
|
|
125
|
-
|
|
129
|
+
let detected = convertScanToDetected(scan, cwd);
|
|
130
|
+
// Interactive mode: let user select apps
|
|
131
|
+
if (isInteractive && Object.keys(detected.apps).length > 0) {
|
|
132
|
+
detected = await interactiveAppSelection(detected);
|
|
133
|
+
}
|
|
134
|
+
// Scan env files for service URLs (only for selected frontend apps)
|
|
135
|
+
const frontendApps = Object.entries(detected.apps)
|
|
136
|
+
.filter(([, app]) => app.type === 'frontend')
|
|
137
|
+
.map(([name]) => name);
|
|
138
|
+
if (frontendApps.length > 0) {
|
|
139
|
+
const serviceUrls = scanEnvFilesForUrls(detected.apps, cwd);
|
|
140
|
+
if (serviceUrls.length > 0) {
|
|
141
|
+
detected.service_urls = serviceUrls;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
126
144
|
// Output
|
|
127
145
|
if (options.stdout) {
|
|
128
146
|
outputToStdout(detected, options.json);
|
|
@@ -133,10 +151,156 @@ exports.scanCommand = new commander_1.Command('scan')
|
|
|
133
151
|
}
|
|
134
152
|
}
|
|
135
153
|
catch (error) {
|
|
154
|
+
if (error.name === 'ExitPromptError' || error.message?.includes('force closed')) {
|
|
155
|
+
console.log('\n' + chalk_1.default.dim('Cancelled.'));
|
|
156
|
+
process.exit(0);
|
|
157
|
+
}
|
|
136
158
|
console.error(chalk_1.default.red('Scan failed:'), error);
|
|
137
159
|
process.exit(1);
|
|
138
160
|
}
|
|
139
161
|
});
|
|
162
|
+
/**
|
|
163
|
+
* Interactive app selection
|
|
164
|
+
*/
|
|
165
|
+
async function interactiveAppSelection(detected) {
|
|
166
|
+
const appEntries = Object.entries(detected.apps);
|
|
167
|
+
console.log(chalk_1.default.blue('=== Detected Apps ===\n'));
|
|
168
|
+
// Show detected apps
|
|
169
|
+
for (const [name, app] of appEntries) {
|
|
170
|
+
const parts = [
|
|
171
|
+
chalk_1.default.cyan(name),
|
|
172
|
+
app.type ? `(${app.type})` : '',
|
|
173
|
+
app.framework ? `[${app.framework}]` : '',
|
|
174
|
+
app.port ? `port:${app.port}` : '',
|
|
175
|
+
].filter(Boolean);
|
|
176
|
+
console.log(` ${parts.join(' ')}`);
|
|
177
|
+
if (app.git) {
|
|
178
|
+
console.log(chalk_1.default.dim(` └─ ${app.git.remote}`));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
console.log();
|
|
182
|
+
// Let user select which apps to include
|
|
183
|
+
const choices = appEntries.map(([name, app]) => ({
|
|
184
|
+
name: `${name} (${app.type || 'unknown'}${app.framework ? `, ${app.framework}` : ''})`,
|
|
185
|
+
value: name,
|
|
186
|
+
checked: app.type !== 'library', // Default: include non-libraries
|
|
187
|
+
}));
|
|
188
|
+
const selectedApps = await prompts.checkbox({
|
|
189
|
+
message: 'Select apps to include in detected.yaml:',
|
|
190
|
+
choices,
|
|
191
|
+
});
|
|
192
|
+
// Filter apps to only selected ones
|
|
193
|
+
const filteredApps = {};
|
|
194
|
+
for (const appName of selectedApps) {
|
|
195
|
+
filteredApps[appName] = detected.apps[appName];
|
|
196
|
+
}
|
|
197
|
+
return {
|
|
198
|
+
...detected,
|
|
199
|
+
apps: filteredApps,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Scan env files in app directories for service URLs
|
|
204
|
+
*/
|
|
205
|
+
function scanEnvFilesForUrls(apps, rootDir) {
|
|
206
|
+
const serviceUrls = new Map();
|
|
207
|
+
const envPatterns = ['.env', '.env.local', '.env.development'];
|
|
208
|
+
for (const [appName, app] of Object.entries(apps)) {
|
|
209
|
+
// Only scan frontend apps
|
|
210
|
+
if (app.type !== 'frontend')
|
|
211
|
+
continue;
|
|
212
|
+
const appDir = path.join(rootDir, app.path);
|
|
213
|
+
// Find env file
|
|
214
|
+
let envContent;
|
|
215
|
+
let envSource;
|
|
216
|
+
for (const pattern of envPatterns) {
|
|
217
|
+
const envPath = path.join(appDir, pattern);
|
|
218
|
+
if (fs.existsSync(envPath)) {
|
|
219
|
+
envContent = fs.readFileSync(envPath, 'utf8');
|
|
220
|
+
envSource = pattern;
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
if (!envContent)
|
|
225
|
+
continue;
|
|
226
|
+
// Find all HTTP URLs
|
|
227
|
+
const urlRegex = /^([A-Z_][A-Z0-9_]*)=["']?(https?:\/\/[a-zA-Z0-9_.-]+(?::\d+)?[^"'\s]*)["']?/gm;
|
|
228
|
+
let match;
|
|
229
|
+
while ((match = urlRegex.exec(envContent)) !== null) {
|
|
230
|
+
const varName = match[1];
|
|
231
|
+
const fullUrl = match[2];
|
|
232
|
+
// Extract hostname
|
|
233
|
+
const hostMatch = fullUrl.match(/^https?:\/\/([a-zA-Z0-9_.-]+)/);
|
|
234
|
+
if (!hostMatch)
|
|
235
|
+
continue;
|
|
236
|
+
const hostname = hostMatch[1];
|
|
237
|
+
// Only include local URLs (localhost, Docker internal names, IPs)
|
|
238
|
+
const isLocalUrl = hostname === 'localhost' ||
|
|
239
|
+
!hostname.includes('.') ||
|
|
240
|
+
/^\d+\.\d+\.\d+\.\d+$/.test(hostname);
|
|
241
|
+
if (!isLocalUrl)
|
|
242
|
+
continue;
|
|
243
|
+
// Extract base URL
|
|
244
|
+
const baseMatch = fullUrl.match(/^(https?:\/\/[a-zA-Z0-9_.-]+(?::\d+)?)/);
|
|
245
|
+
if (!baseMatch)
|
|
246
|
+
continue;
|
|
247
|
+
const baseUrl = baseMatch[1];
|
|
248
|
+
// Add to map
|
|
249
|
+
if (!serviceUrls.has(baseUrl)) {
|
|
250
|
+
serviceUrls.set(baseUrl, { vars: new Set(), apps: new Set() });
|
|
251
|
+
}
|
|
252
|
+
serviceUrls.get(baseUrl).vars.add(varName);
|
|
253
|
+
serviceUrls.get(baseUrl).apps.add(appName);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
// Convert to DetectedServiceUrl array
|
|
257
|
+
const result = [];
|
|
258
|
+
for (const [baseUrl, { vars, apps: appNames }] of serviceUrls) {
|
|
259
|
+
const serviceInfo = getServiceInfoFromUrl(baseUrl);
|
|
260
|
+
result.push({
|
|
261
|
+
base_url: baseUrl,
|
|
262
|
+
var_name: serviceInfo.varName,
|
|
263
|
+
description: serviceInfo.description,
|
|
264
|
+
used_by: Array.from(vars),
|
|
265
|
+
apps: Array.from(appNames),
|
|
266
|
+
source: 'env files',
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
// Sort by port for consistent output
|
|
270
|
+
result.sort((a, b) => {
|
|
271
|
+
const portA = parseInt(a.base_url.match(/:(\d+)/)?.[1] || '0');
|
|
272
|
+
const portB = parseInt(b.base_url.match(/:(\d+)/)?.[1] || '0');
|
|
273
|
+
return portA - portB;
|
|
274
|
+
});
|
|
275
|
+
return result;
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Get service info from URL
|
|
279
|
+
*/
|
|
280
|
+
function getServiceInfoFromUrl(baseUrl) {
|
|
281
|
+
const urlMatch = baseUrl.match(/^https?:\/\/([a-zA-Z0-9_.-]+)(?::(\d+))?/);
|
|
282
|
+
if (!urlMatch) {
|
|
283
|
+
return { varName: 'UNKNOWN_URL', description: 'Unknown service' };
|
|
284
|
+
}
|
|
285
|
+
const hostname = urlMatch[1];
|
|
286
|
+
const port = urlMatch[2] ? parseInt(urlMatch[2]) : undefined;
|
|
287
|
+
// Generate from hostname if not localhost
|
|
288
|
+
if (hostname !== 'localhost') {
|
|
289
|
+
const varName = hostname.toUpperCase().replace(/-/g, '_') + '_URL';
|
|
290
|
+
return {
|
|
291
|
+
varName,
|
|
292
|
+
description: `${hostname} service`,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
// Generate from port for localhost
|
|
296
|
+
if (port) {
|
|
297
|
+
return {
|
|
298
|
+
varName: `PORT_${port}_URL`,
|
|
299
|
+
description: `localhost:${port}`,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
return { varName: 'LOCALHOST_URL', description: 'localhost' };
|
|
303
|
+
}
|
|
140
304
|
/**
|
|
141
305
|
* Convert ProjectScan to DetectedConfig
|
|
142
306
|
*/
|
|
@@ -408,6 +572,15 @@ function showSummary(detected) {
|
|
|
408
572
|
console.log(chalk_1.default.dim(` ${git.remote}`));
|
|
409
573
|
}
|
|
410
574
|
}
|
|
575
|
+
// Service URLs (for staging URL configuration)
|
|
576
|
+
if (detected.service_urls && detected.service_urls.length > 0) {
|
|
577
|
+
console.log(`\n Service URLs (${detected.service_urls.length}):`);
|
|
578
|
+
for (const svc of detected.service_urls) {
|
|
579
|
+
console.log(` ${chalk_1.default.cyan(svc.var_name)}: ${svc.base_url}`);
|
|
580
|
+
console.log(chalk_1.default.dim(` Used by: ${svc.used_by.slice(0, 3).join(', ')}${svc.used_by.length > 3 ? ` +${svc.used_by.length - 3} more` : ''}`));
|
|
581
|
+
}
|
|
582
|
+
console.log(chalk_1.default.dim('\n These URLs will need staging equivalents in init.'));
|
|
583
|
+
}
|
|
411
584
|
console.log(chalk_1.default.bold('\n📝 Next steps:\n'));
|
|
412
585
|
console.log(' 1. Review the detected configuration in .genbox/detected.yaml');
|
|
413
586
|
console.log(' 2. Run ' + chalk_1.default.cyan('genbox init --from-scan') + ' to create genbox.yaml');
|