genbox 1.0.15 → 1.0.17
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 +435 -39
- package/dist/commands/scan.js +232 -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
|
*/
|
|
@@ -154,6 +365,81 @@ function findAppEnvFiles(apps, rootDir) {
|
|
|
154
365
|
}
|
|
155
366
|
return envFiles;
|
|
156
367
|
}
|
|
368
|
+
/**
|
|
369
|
+
* Read existing values from .env.genbox file
|
|
370
|
+
*/
|
|
371
|
+
function readExistingEnvGenbox() {
|
|
372
|
+
const envPath = path_1.default.join(process.cwd(), ENV_FILENAME);
|
|
373
|
+
const values = {};
|
|
374
|
+
if (!fs_1.default.existsSync(envPath)) {
|
|
375
|
+
return values;
|
|
376
|
+
}
|
|
377
|
+
try {
|
|
378
|
+
const content = fs_1.default.readFileSync(envPath, 'utf8');
|
|
379
|
+
for (const line of content.split('\n')) {
|
|
380
|
+
// Skip comments and empty lines
|
|
381
|
+
const trimmed = line.trim();
|
|
382
|
+
if (!trimmed || trimmed.startsWith('#'))
|
|
383
|
+
continue;
|
|
384
|
+
// Parse KEY=value
|
|
385
|
+
const match = trimmed.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
|
|
386
|
+
if (match) {
|
|
387
|
+
let value = match[2].trim();
|
|
388
|
+
// Remove quotes if present
|
|
389
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
390
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
391
|
+
value = value.slice(1, -1);
|
|
392
|
+
}
|
|
393
|
+
values[match[1]] = value;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
catch {
|
|
398
|
+
// Ignore read errors
|
|
399
|
+
}
|
|
400
|
+
return values;
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Mask a secret value for display (show first 4 and last 4 chars)
|
|
404
|
+
*/
|
|
405
|
+
function maskSecret(value) {
|
|
406
|
+
if (value.length <= 8) {
|
|
407
|
+
return '*'.repeat(value.length);
|
|
408
|
+
}
|
|
409
|
+
return value.slice(0, 4) + '*'.repeat(value.length - 8) + value.slice(-4);
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Prompt for a secret with existing value support
|
|
413
|
+
*/
|
|
414
|
+
async function promptForSecret(message, existingValue, options = {}) {
|
|
415
|
+
if (existingValue) {
|
|
416
|
+
console.log(chalk_1.default.dim(` Found existing value: ${maskSecret(existingValue)}`));
|
|
417
|
+
const useExisting = await prompts.confirm({
|
|
418
|
+
message: 'Use existing value?',
|
|
419
|
+
default: true,
|
|
420
|
+
});
|
|
421
|
+
if (useExisting) {
|
|
422
|
+
return existingValue;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
if (options.showInstructions) {
|
|
426
|
+
console.log('');
|
|
427
|
+
console.log(chalk_1.default.dim(' To create a token:'));
|
|
428
|
+
console.log(chalk_1.default.dim(' 1. Go to https://github.com/settings/tokens'));
|
|
429
|
+
console.log(chalk_1.default.dim(' 2. Click "Generate new token" → "Classic"'));
|
|
430
|
+
console.log(chalk_1.default.dim(' 3. Select scope: "repo" (Full control of private repositories)'));
|
|
431
|
+
console.log(chalk_1.default.dim(' 4. Generate and copy the token'));
|
|
432
|
+
console.log('');
|
|
433
|
+
}
|
|
434
|
+
let value = await prompts.password({
|
|
435
|
+
message,
|
|
436
|
+
});
|
|
437
|
+
if (value) {
|
|
438
|
+
// Strip any "KEY=" prefix if user pasted the whole line
|
|
439
|
+
value = value.replace(/^[A-Z_]+=/, '');
|
|
440
|
+
}
|
|
441
|
+
return value || undefined;
|
|
442
|
+
}
|
|
157
443
|
exports.initCommand = new commander_1.Command('init')
|
|
158
444
|
.description('Initialize a new Genbox configuration')
|
|
159
445
|
.option('--v2', 'Use legacy v2 format (single-app only)')
|
|
@@ -186,6 +472,8 @@ exports.initCommand = new commander_1.Command('init')
|
|
|
186
472
|
}
|
|
187
473
|
console.log(chalk_1.default.blue('Initializing Genbox...'));
|
|
188
474
|
console.log('');
|
|
475
|
+
// Read existing .env.genbox values (for defaults when overwriting)
|
|
476
|
+
const existingEnvValues = readExistingEnvGenbox();
|
|
189
477
|
// Track env vars to add to .env.genbox
|
|
190
478
|
const envVarsToAdd = {};
|
|
191
479
|
// Get initial exclusions from CLI options only
|
|
@@ -378,16 +666,7 @@ exports.initCommand = new commander_1.Command('init')
|
|
|
378
666
|
if (hasHttpsRepos && !nonInteractive) {
|
|
379
667
|
console.log('');
|
|
380
668
|
console.log(chalk_1.default.yellow('Private repositories require a GitHub token for cloning.'));
|
|
381
|
-
|
|
382
|
-
console.log(chalk_1.default.dim(' To create a token:'));
|
|
383
|
-
console.log(chalk_1.default.dim(' 1. Go to https://github.com/settings/tokens'));
|
|
384
|
-
console.log(chalk_1.default.dim(' 2. Click "Generate new token" → "Classic"'));
|
|
385
|
-
console.log(chalk_1.default.dim(' 3. Select scope: "repo" (Full control of private repositories)'));
|
|
386
|
-
console.log(chalk_1.default.dim(' 4. Generate and copy the token'));
|
|
387
|
-
console.log('');
|
|
388
|
-
const gitToken = await prompts.password({
|
|
389
|
-
message: 'GitHub Personal Access Token (leave empty to skip):',
|
|
390
|
-
});
|
|
669
|
+
const gitToken = await promptForSecret('GitHub Personal Access Token (leave empty to skip):', existingEnvValues['GIT_TOKEN'], { showInstructions: !existingEnvValues['GIT_TOKEN'] });
|
|
391
670
|
if (gitToken) {
|
|
392
671
|
envVarsToAdd['GIT_TOKEN'] = gitToken;
|
|
393
672
|
console.log(chalk_1.default.green('✓ GIT_TOKEN will be added to .env.genbox'));
|
|
@@ -412,9 +691,7 @@ exports.initCommand = new commander_1.Command('init')
|
|
|
412
691
|
if (scan.git.type === 'https' && !nonInteractive) {
|
|
413
692
|
console.log('');
|
|
414
693
|
console.log(chalk_1.default.yellow('Private repositories require a GitHub token for cloning.'));
|
|
415
|
-
const gitToken = await
|
|
416
|
-
message: 'GitHub Personal Access Token (leave empty to skip):',
|
|
417
|
-
});
|
|
694
|
+
const gitToken = await promptForSecret('GitHub Personal Access Token (leave empty to skip):', existingEnvValues['GIT_TOKEN'], { showInstructions: !existingEnvValues['GIT_TOKEN'] });
|
|
418
695
|
if (gitToken) {
|
|
419
696
|
envVarsToAdd['GIT_TOKEN'] = gitToken;
|
|
420
697
|
console.log(chalk_1.default.green('✓ GIT_TOKEN will be added to .env.genbox'));
|
|
@@ -457,16 +734,7 @@ exports.initCommand = new commander_1.Command('init')
|
|
|
457
734
|
if (hasHttpsRepos) {
|
|
458
735
|
console.log('');
|
|
459
736
|
console.log(chalk_1.default.yellow('Private repositories require a GitHub token for cloning.'));
|
|
460
|
-
|
|
461
|
-
console.log(chalk_1.default.dim(' To create a token:'));
|
|
462
|
-
console.log(chalk_1.default.dim(' 1. Go to https://github.com/settings/tokens'));
|
|
463
|
-
console.log(chalk_1.default.dim(' 2. Click "Generate new token" → "Classic"'));
|
|
464
|
-
console.log(chalk_1.default.dim(' 3. Select scope: "repo" (Full control of private repositories)'));
|
|
465
|
-
console.log(chalk_1.default.dim(' 4. Generate and copy the token'));
|
|
466
|
-
console.log('');
|
|
467
|
-
const gitToken = await prompts.password({
|
|
468
|
-
message: 'GitHub Personal Access Token (leave empty to skip):',
|
|
469
|
-
});
|
|
737
|
+
const gitToken = await promptForSecret('GitHub Personal Access Token (leave empty to skip):', existingEnvValues['GIT_TOKEN'], { showInstructions: !existingEnvValues['GIT_TOKEN'] });
|
|
470
738
|
if (gitToken) {
|
|
471
739
|
envVarsToAdd['GIT_TOKEN'] = gitToken;
|
|
472
740
|
console.log(chalk_1.default.green('✓ GIT_TOKEN will be added to .env.genbox'));
|
|
@@ -539,7 +807,7 @@ exports.initCommand = new commander_1.Command('init')
|
|
|
539
807
|
// Environment configuration (skip only in non-interactive mode)
|
|
540
808
|
// For --from-scan, we still want to prompt for environments since they're required for genbox to work
|
|
541
809
|
if (!nonInteractive) {
|
|
542
|
-
const envConfig = await setupEnvironments(scan, v4Config, isMultiRepo);
|
|
810
|
+
const envConfig = await setupEnvironments(scan, v4Config, isMultiRepo, existingEnvValues);
|
|
543
811
|
if (envConfig) {
|
|
544
812
|
v4Config.environments = envConfig;
|
|
545
813
|
}
|
|
@@ -610,8 +878,19 @@ exports.initCommand = new commander_1.Command('init')
|
|
|
610
878
|
}
|
|
611
879
|
}
|
|
612
880
|
}
|
|
881
|
+
// Load detected service URLs if using --from-scan
|
|
882
|
+
let detectedServiceUrls;
|
|
883
|
+
if (options.fromScan) {
|
|
884
|
+
const detectedPath = path_1.default.join(process.cwd(), '.genbox', 'detected.yaml');
|
|
885
|
+
try {
|
|
886
|
+
const content = fs_1.default.readFileSync(detectedPath, 'utf8');
|
|
887
|
+
const detectedConfig = yaml.load(content);
|
|
888
|
+
detectedServiceUrls = detectedConfig.service_urls;
|
|
889
|
+
}
|
|
890
|
+
catch { }
|
|
891
|
+
}
|
|
613
892
|
// Generate .env.genbox
|
|
614
|
-
await setupEnvFile(projectName, v4Config, nonInteractive, scan, isMultiRepo, envVarsToAdd, overwriteExisting);
|
|
893
|
+
await setupEnvFile(projectName, v4Config, nonInteractive, scan, isMultiRepo, envVarsToAdd, overwriteExisting, detectedServiceUrls);
|
|
615
894
|
// Show warnings
|
|
616
895
|
if (generated.warnings.length > 0) {
|
|
617
896
|
console.log('');
|
|
@@ -804,7 +1083,7 @@ async function setupGitAuth(gitInfo, projectName) {
|
|
|
804
1083
|
/**
|
|
805
1084
|
* Setup staging/production environments (v4 format)
|
|
806
1085
|
*/
|
|
807
|
-
async function setupEnvironments(scan, config, isMultiRepo = false) {
|
|
1086
|
+
async function setupEnvironments(scan, config, isMultiRepo = false, existingEnvValues = {}) {
|
|
808
1087
|
const setupEnvs = await prompts.confirm({
|
|
809
1088
|
message: 'Configure staging/production environments?',
|
|
810
1089
|
default: true,
|
|
@@ -818,6 +1097,9 @@ async function setupEnvironments(scan, config, isMultiRepo = false) {
|
|
|
818
1097
|
console.log(chalk_1.default.dim('Actual secrets go in .env.genbox'));
|
|
819
1098
|
console.log('');
|
|
820
1099
|
const environments = {};
|
|
1100
|
+
// Get existing staging API URL if available
|
|
1101
|
+
const existingStagingApiUrl = existingEnvValues['STAGING_API_URL'];
|
|
1102
|
+
const existingProductionApiUrl = existingEnvValues['PRODUCTION_API_URL'] || existingEnvValues['PROD_API_URL'];
|
|
821
1103
|
if (isMultiRepo) {
|
|
822
1104
|
// For multi-repo: configure API URLs per backend app
|
|
823
1105
|
const backendApps = scan.apps.filter(a => a.type === 'backend' || a.type === 'api');
|
|
@@ -825,6 +1107,20 @@ async function setupEnvironments(scan, config, isMultiRepo = false) {
|
|
|
825
1107
|
console.log(chalk_1.default.dim('Configure staging API URLs for each backend service:'));
|
|
826
1108
|
const urls = {};
|
|
827
1109
|
for (const app of backendApps) {
|
|
1110
|
+
// Check for existing app-specific URL or use general staging URL for 'api' app
|
|
1111
|
+
const existingUrl = existingEnvValues[`STAGING_${app.name.toUpperCase()}_URL`] ||
|
|
1112
|
+
(app.name === 'api' ? existingStagingApiUrl : '');
|
|
1113
|
+
if (existingUrl) {
|
|
1114
|
+
console.log(chalk_1.default.dim(` Found existing value for ${app.name}: ${existingUrl}`));
|
|
1115
|
+
const useExisting = await prompts.confirm({
|
|
1116
|
+
message: ` Use existing ${app.name} staging URL?`,
|
|
1117
|
+
default: true,
|
|
1118
|
+
});
|
|
1119
|
+
if (useExisting) {
|
|
1120
|
+
urls[app.name] = existingUrl;
|
|
1121
|
+
continue;
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
828
1124
|
const url = await prompts.input({
|
|
829
1125
|
message: ` ${app.name} staging URL (leave empty to skip):`,
|
|
830
1126
|
default: '',
|
|
@@ -845,10 +1141,23 @@ async function setupEnvironments(scan, config, isMultiRepo = false) {
|
|
|
845
1141
|
}
|
|
846
1142
|
else {
|
|
847
1143
|
// No backend apps, just ask for a single URL
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
default:
|
|
851
|
-
|
|
1144
|
+
let stagingApiUrl = '';
|
|
1145
|
+
if (existingStagingApiUrl) {
|
|
1146
|
+
console.log(chalk_1.default.dim(` Found existing value: ${existingStagingApiUrl}`));
|
|
1147
|
+
const useExisting = await prompts.confirm({
|
|
1148
|
+
message: 'Use existing staging API URL?',
|
|
1149
|
+
default: true,
|
|
1150
|
+
});
|
|
1151
|
+
if (useExisting) {
|
|
1152
|
+
stagingApiUrl = existingStagingApiUrl;
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
if (!stagingApiUrl) {
|
|
1156
|
+
stagingApiUrl = await prompts.input({
|
|
1157
|
+
message: 'Staging API URL (leave empty to skip):',
|
|
1158
|
+
default: '',
|
|
1159
|
+
});
|
|
1160
|
+
}
|
|
852
1161
|
if (stagingApiUrl) {
|
|
853
1162
|
environments.staging = {
|
|
854
1163
|
description: 'Staging environment',
|
|
@@ -863,10 +1172,23 @@ async function setupEnvironments(scan, config, isMultiRepo = false) {
|
|
|
863
1172
|
}
|
|
864
1173
|
else {
|
|
865
1174
|
// Single repo: simple single URL
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
default:
|
|
869
|
-
|
|
1175
|
+
let stagingApiUrl = '';
|
|
1176
|
+
if (existingStagingApiUrl) {
|
|
1177
|
+
console.log(chalk_1.default.dim(` Found existing value: ${existingStagingApiUrl}`));
|
|
1178
|
+
const useExisting = await prompts.confirm({
|
|
1179
|
+
message: 'Use existing staging API URL?',
|
|
1180
|
+
default: true,
|
|
1181
|
+
});
|
|
1182
|
+
if (useExisting) {
|
|
1183
|
+
stagingApiUrl = existingStagingApiUrl;
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
if (!stagingApiUrl) {
|
|
1187
|
+
stagingApiUrl = await prompts.input({
|
|
1188
|
+
message: 'Staging API URL (leave empty to skip):',
|
|
1189
|
+
default: '',
|
|
1190
|
+
});
|
|
1191
|
+
}
|
|
870
1192
|
if (stagingApiUrl) {
|
|
871
1193
|
environments.staging = {
|
|
872
1194
|
description: 'Staging environment',
|
|
@@ -889,6 +1211,21 @@ async function setupEnvironments(scan, config, isMultiRepo = false) {
|
|
|
889
1211
|
console.log(chalk_1.default.dim('Configure production API URLs for each backend service:'));
|
|
890
1212
|
const prodUrls = {};
|
|
891
1213
|
for (const app of backendApps) {
|
|
1214
|
+
// Check for existing app-specific URL or use general production URL for 'api' app
|
|
1215
|
+
const existingUrl = existingEnvValues[`PRODUCTION_${app.name.toUpperCase()}_URL`] ||
|
|
1216
|
+
existingEnvValues[`PROD_${app.name.toUpperCase()}_URL`] ||
|
|
1217
|
+
(app.name === 'api' ? existingProductionApiUrl : '');
|
|
1218
|
+
if (existingUrl) {
|
|
1219
|
+
console.log(chalk_1.default.dim(` Found existing value for ${app.name}: ${existingUrl}`));
|
|
1220
|
+
const useExisting = await prompts.confirm({
|
|
1221
|
+
message: ` Use existing ${app.name} production URL?`,
|
|
1222
|
+
default: true,
|
|
1223
|
+
});
|
|
1224
|
+
if (useExisting) {
|
|
1225
|
+
prodUrls[app.name] = existingUrl;
|
|
1226
|
+
continue;
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
892
1229
|
const url = await prompts.input({
|
|
893
1230
|
message: ` ${app.name} production URL:`,
|
|
894
1231
|
default: '',
|
|
@@ -911,10 +1248,23 @@ async function setupEnvironments(scan, config, isMultiRepo = false) {
|
|
|
911
1248
|
}
|
|
912
1249
|
}
|
|
913
1250
|
else {
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
default:
|
|
917
|
-
|
|
1251
|
+
let prodApiUrl = '';
|
|
1252
|
+
if (existingProductionApiUrl) {
|
|
1253
|
+
console.log(chalk_1.default.dim(` Found existing value: ${existingProductionApiUrl}`));
|
|
1254
|
+
const useExisting = await prompts.confirm({
|
|
1255
|
+
message: 'Use existing production API URL?',
|
|
1256
|
+
default: true,
|
|
1257
|
+
});
|
|
1258
|
+
if (useExisting) {
|
|
1259
|
+
prodApiUrl = existingProductionApiUrl;
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
if (!prodApiUrl) {
|
|
1263
|
+
prodApiUrl = await prompts.input({
|
|
1264
|
+
message: 'Production API URL:',
|
|
1265
|
+
default: '',
|
|
1266
|
+
});
|
|
1267
|
+
}
|
|
918
1268
|
if (prodApiUrl) {
|
|
919
1269
|
environments.production = {
|
|
920
1270
|
description: 'Production (use with caution)',
|
|
@@ -935,7 +1285,7 @@ async function setupEnvironments(scan, config, isMultiRepo = false) {
|
|
|
935
1285
|
/**
|
|
936
1286
|
* Setup .env.genbox file with segregated app sections
|
|
937
1287
|
*/
|
|
938
|
-
async function setupEnvFile(projectName, config, nonInteractive = false, scan, isMultiRepo = false, extraEnvVars = {}, overwriteExisting = false) {
|
|
1288
|
+
async function setupEnvFile(projectName, config, nonInteractive = false, scan, isMultiRepo = false, extraEnvVars = {}, overwriteExisting = false, detectedServiceUrls) {
|
|
939
1289
|
const envPath = path_1.default.join(process.cwd(), ENV_FILENAME);
|
|
940
1290
|
// If overwriting, delete existing file
|
|
941
1291
|
if (fs_1.default.existsSync(envPath)) {
|
|
@@ -1048,6 +1398,52 @@ async function setupEnvFile(projectName, config, nonInteractive = false, scan, i
|
|
|
1048
1398
|
}
|
|
1049
1399
|
}
|
|
1050
1400
|
}
|
|
1401
|
+
// Identify frontend apps for URL transformation
|
|
1402
|
+
const frontendApps = scan?.apps
|
|
1403
|
+
.filter(a => a.type === 'frontend')
|
|
1404
|
+
.map(a => a.name) || [];
|
|
1405
|
+
// Also get frontend apps from config if available
|
|
1406
|
+
if (config.apps) {
|
|
1407
|
+
for (const [name, app] of Object.entries(config.apps)) {
|
|
1408
|
+
if (app.type === 'frontend' && !frontendApps.includes(name)) {
|
|
1409
|
+
frontendApps.push(name);
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
if (frontendApps.length > 0 && !nonInteractive) {
|
|
1414
|
+
// Use service URLs from detected.yaml if available (preferred)
|
|
1415
|
+
// Otherwise fall back to scanning the collected env content
|
|
1416
|
+
let serviceUrls;
|
|
1417
|
+
if (detectedServiceUrls && detectedServiceUrls.length > 0) {
|
|
1418
|
+
// Convert detected service URLs to the Map format
|
|
1419
|
+
serviceUrls = new Map();
|
|
1420
|
+
for (const svc of detectedServiceUrls) {
|
|
1421
|
+
serviceUrls.set(svc.base_url, {
|
|
1422
|
+
urls: new Set([svc.base_url]),
|
|
1423
|
+
vars: svc.used_by,
|
|
1424
|
+
});
|
|
1425
|
+
}
|
|
1426
|
+
console.log('');
|
|
1427
|
+
console.log(chalk_1.default.dim(`Found ${detectedServiceUrls.length} service URL(s) from detected.yaml`));
|
|
1428
|
+
}
|
|
1429
|
+
else {
|
|
1430
|
+
// Fall back to extracting from collected env content
|
|
1431
|
+
serviceUrls = extractFrontendHttpUrls(segregatedContent, frontendApps);
|
|
1432
|
+
}
|
|
1433
|
+
if (serviceUrls.size > 0) {
|
|
1434
|
+
// Get existing staging API URL if configured
|
|
1435
|
+
const existingStagingApiUrl = extraEnvVars['STAGING_API_URL'] ||
|
|
1436
|
+
(config.environments?.staging?.urls?.api);
|
|
1437
|
+
// Prompt for staging equivalents
|
|
1438
|
+
const urlMappings = await promptForStagingUrls(serviceUrls, existingStagingApiUrl);
|
|
1439
|
+
// Transform content with expandable variables
|
|
1440
|
+
if (urlMappings.length > 0) {
|
|
1441
|
+
segregatedContent = transformEnvWithVariables(segregatedContent, urlMappings, frontendApps);
|
|
1442
|
+
console.log('');
|
|
1443
|
+
console.log(chalk_1.default.green(`✓ Configured ${urlMappings.length} service URL(s) for staging support`));
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1051
1447
|
// Add END marker
|
|
1052
1448
|
segregatedContent += `# === END ===\n`;
|
|
1053
1449
|
// 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, scripts, infrastructure
|
|
131
|
+
if (isInteractive) {
|
|
132
|
+
detected = await interactiveSelection(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,214 @@ 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 and script selection
|
|
164
|
+
*/
|
|
165
|
+
async function interactiveSelection(detected) {
|
|
166
|
+
let result = { ...detected };
|
|
167
|
+
// === App Selection ===
|
|
168
|
+
const appEntries = Object.entries(detected.apps);
|
|
169
|
+
if (appEntries.length > 0) {
|
|
170
|
+
console.log(chalk_1.default.blue('=== Detected Apps ===\n'));
|
|
171
|
+
// Show detected apps
|
|
172
|
+
for (const [name, app] of appEntries) {
|
|
173
|
+
const parts = [
|
|
174
|
+
chalk_1.default.cyan(name),
|
|
175
|
+
app.type ? `(${app.type})` : '',
|
|
176
|
+
app.framework ? `[${app.framework}]` : '',
|
|
177
|
+
app.port ? `port:${app.port}` : '',
|
|
178
|
+
].filter(Boolean);
|
|
179
|
+
console.log(` ${parts.join(' ')}`);
|
|
180
|
+
if (app.git) {
|
|
181
|
+
console.log(chalk_1.default.dim(` └─ ${app.git.remote}`));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
console.log();
|
|
185
|
+
// Let user select which apps to include
|
|
186
|
+
const appChoices = appEntries.map(([name, app]) => ({
|
|
187
|
+
name: `${name} (${app.type || 'unknown'}${app.framework ? `, ${app.framework}` : ''})`,
|
|
188
|
+
value: name,
|
|
189
|
+
checked: app.type !== 'library', // Default: include non-libraries
|
|
190
|
+
}));
|
|
191
|
+
const selectedApps = await prompts.checkbox({
|
|
192
|
+
message: 'Select apps to include:',
|
|
193
|
+
choices: appChoices,
|
|
194
|
+
});
|
|
195
|
+
// Filter apps to only selected ones
|
|
196
|
+
const filteredApps = {};
|
|
197
|
+
for (const appName of selectedApps) {
|
|
198
|
+
filteredApps[appName] = detected.apps[appName];
|
|
199
|
+
}
|
|
200
|
+
result.apps = filteredApps;
|
|
201
|
+
}
|
|
202
|
+
// === Script Selection ===
|
|
203
|
+
if (detected.scripts && detected.scripts.length > 0) {
|
|
204
|
+
console.log('');
|
|
205
|
+
console.log(chalk_1.default.blue('=== Detected Scripts ===\n'));
|
|
206
|
+
// Group scripts by directory for display
|
|
207
|
+
const scriptsByDir = new Map();
|
|
208
|
+
for (const script of detected.scripts) {
|
|
209
|
+
const dir = script.path.includes('/') ? script.path.split('/')[0] : '(root)';
|
|
210
|
+
const existing = scriptsByDir.get(dir) || [];
|
|
211
|
+
existing.push(script);
|
|
212
|
+
scriptsByDir.set(dir, existing);
|
|
213
|
+
}
|
|
214
|
+
// Show grouped scripts
|
|
215
|
+
for (const [dir, scripts] of scriptsByDir) {
|
|
216
|
+
console.log(chalk_1.default.dim(` ${dir}/ (${scripts.length} scripts)`));
|
|
217
|
+
for (const script of scripts.slice(0, 3)) {
|
|
218
|
+
console.log(` ${chalk_1.default.cyan(script.name)} (${script.stage})`);
|
|
219
|
+
}
|
|
220
|
+
if (scripts.length > 3) {
|
|
221
|
+
console.log(chalk_1.default.dim(` ... and ${scripts.length - 3} more`));
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
console.log();
|
|
225
|
+
// Let user select which scripts to include
|
|
226
|
+
const scriptChoices = detected.scripts.map(s => ({
|
|
227
|
+
name: `${s.path} (${s.stage})`,
|
|
228
|
+
value: s.path,
|
|
229
|
+
checked: s.path.startsWith('scripts/'), // Default: include scripts/ directory
|
|
230
|
+
}));
|
|
231
|
+
const selectedScripts = await prompts.checkbox({
|
|
232
|
+
message: 'Select scripts to include:',
|
|
233
|
+
choices: scriptChoices,
|
|
234
|
+
});
|
|
235
|
+
// Filter scripts to only selected ones
|
|
236
|
+
result.scripts = detected.scripts.filter(s => selectedScripts.includes(s.path));
|
|
237
|
+
}
|
|
238
|
+
// === Infrastructure Selection ===
|
|
239
|
+
if (detected.infrastructure && detected.infrastructure.length > 0) {
|
|
240
|
+
console.log('');
|
|
241
|
+
console.log(chalk_1.default.blue('=== Detected Infrastructure ===\n'));
|
|
242
|
+
for (const infra of detected.infrastructure) {
|
|
243
|
+
console.log(` ${chalk_1.default.cyan(infra.name)}: ${infra.type} (${infra.image})`);
|
|
244
|
+
}
|
|
245
|
+
console.log();
|
|
246
|
+
const infraChoices = detected.infrastructure.map(i => ({
|
|
247
|
+
name: `${i.name} (${i.type}, ${i.image})`,
|
|
248
|
+
value: i.name,
|
|
249
|
+
checked: true, // Default: include all
|
|
250
|
+
}));
|
|
251
|
+
const selectedInfra = await prompts.checkbox({
|
|
252
|
+
message: 'Select infrastructure to include:',
|
|
253
|
+
choices: infraChoices,
|
|
254
|
+
});
|
|
255
|
+
// Filter infrastructure to only selected ones
|
|
256
|
+
result.infrastructure = detected.infrastructure.filter(i => selectedInfra.includes(i.name));
|
|
257
|
+
}
|
|
258
|
+
return result;
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Scan env files in app directories for service URLs
|
|
262
|
+
*/
|
|
263
|
+
function scanEnvFilesForUrls(apps, rootDir) {
|
|
264
|
+
const serviceUrls = new Map();
|
|
265
|
+
const envPatterns = ['.env', '.env.local', '.env.development'];
|
|
266
|
+
for (const [appName, app] of Object.entries(apps)) {
|
|
267
|
+
// Only scan frontend apps
|
|
268
|
+
if (app.type !== 'frontend')
|
|
269
|
+
continue;
|
|
270
|
+
const appDir = path.join(rootDir, app.path);
|
|
271
|
+
// Find env file
|
|
272
|
+
let envContent;
|
|
273
|
+
let envSource;
|
|
274
|
+
for (const pattern of envPatterns) {
|
|
275
|
+
const envPath = path.join(appDir, pattern);
|
|
276
|
+
if (fs.existsSync(envPath)) {
|
|
277
|
+
envContent = fs.readFileSync(envPath, 'utf8');
|
|
278
|
+
envSource = pattern;
|
|
279
|
+
break;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
if (!envContent)
|
|
283
|
+
continue;
|
|
284
|
+
// Find all HTTP URLs
|
|
285
|
+
const urlRegex = /^([A-Z_][A-Z0-9_]*)=["']?(https?:\/\/[a-zA-Z0-9_.-]+(?::\d+)?[^"'\s]*)["']?/gm;
|
|
286
|
+
let match;
|
|
287
|
+
while ((match = urlRegex.exec(envContent)) !== null) {
|
|
288
|
+
const varName = match[1];
|
|
289
|
+
const fullUrl = match[2];
|
|
290
|
+
// Extract hostname
|
|
291
|
+
const hostMatch = fullUrl.match(/^https?:\/\/([a-zA-Z0-9_.-]+)/);
|
|
292
|
+
if (!hostMatch)
|
|
293
|
+
continue;
|
|
294
|
+
const hostname = hostMatch[1];
|
|
295
|
+
// Only include local URLs (localhost, Docker internal names, IPs)
|
|
296
|
+
const isLocalUrl = hostname === 'localhost' ||
|
|
297
|
+
!hostname.includes('.') ||
|
|
298
|
+
/^\d+\.\d+\.\d+\.\d+$/.test(hostname);
|
|
299
|
+
if (!isLocalUrl)
|
|
300
|
+
continue;
|
|
301
|
+
// Extract base URL
|
|
302
|
+
const baseMatch = fullUrl.match(/^(https?:\/\/[a-zA-Z0-9_.-]+(?::\d+)?)/);
|
|
303
|
+
if (!baseMatch)
|
|
304
|
+
continue;
|
|
305
|
+
const baseUrl = baseMatch[1];
|
|
306
|
+
// Add to map
|
|
307
|
+
if (!serviceUrls.has(baseUrl)) {
|
|
308
|
+
serviceUrls.set(baseUrl, { vars: new Set(), apps: new Set() });
|
|
309
|
+
}
|
|
310
|
+
serviceUrls.get(baseUrl).vars.add(varName);
|
|
311
|
+
serviceUrls.get(baseUrl).apps.add(appName);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
// Convert to DetectedServiceUrl array
|
|
315
|
+
const result = [];
|
|
316
|
+
for (const [baseUrl, { vars, apps: appNames }] of serviceUrls) {
|
|
317
|
+
const serviceInfo = getServiceInfoFromUrl(baseUrl);
|
|
318
|
+
result.push({
|
|
319
|
+
base_url: baseUrl,
|
|
320
|
+
var_name: serviceInfo.varName,
|
|
321
|
+
description: serviceInfo.description,
|
|
322
|
+
used_by: Array.from(vars),
|
|
323
|
+
apps: Array.from(appNames),
|
|
324
|
+
source: 'env files',
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
// Sort by port for consistent output
|
|
328
|
+
result.sort((a, b) => {
|
|
329
|
+
const portA = parseInt(a.base_url.match(/:(\d+)/)?.[1] || '0');
|
|
330
|
+
const portB = parseInt(b.base_url.match(/:(\d+)/)?.[1] || '0');
|
|
331
|
+
return portA - portB;
|
|
332
|
+
});
|
|
333
|
+
return result;
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Get service info from URL
|
|
337
|
+
*/
|
|
338
|
+
function getServiceInfoFromUrl(baseUrl) {
|
|
339
|
+
const urlMatch = baseUrl.match(/^https?:\/\/([a-zA-Z0-9_.-]+)(?::(\d+))?/);
|
|
340
|
+
if (!urlMatch) {
|
|
341
|
+
return { varName: 'UNKNOWN_URL', description: 'Unknown service' };
|
|
342
|
+
}
|
|
343
|
+
const hostname = urlMatch[1];
|
|
344
|
+
const port = urlMatch[2] ? parseInt(urlMatch[2]) : undefined;
|
|
345
|
+
// Generate from hostname if not localhost
|
|
346
|
+
if (hostname !== 'localhost') {
|
|
347
|
+
const varName = hostname.toUpperCase().replace(/-/g, '_') + '_URL';
|
|
348
|
+
return {
|
|
349
|
+
varName,
|
|
350
|
+
description: `${hostname} service`,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
// Generate from port for localhost
|
|
354
|
+
if (port) {
|
|
355
|
+
return {
|
|
356
|
+
varName: `PORT_${port}_URL`,
|
|
357
|
+
description: `localhost:${port}`,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
return { varName: 'LOCALHOST_URL', description: 'localhost' };
|
|
361
|
+
}
|
|
140
362
|
/**
|
|
141
363
|
* Convert ProjectScan to DetectedConfig
|
|
142
364
|
*/
|
|
@@ -408,6 +630,15 @@ function showSummary(detected) {
|
|
|
408
630
|
console.log(chalk_1.default.dim(` ${git.remote}`));
|
|
409
631
|
}
|
|
410
632
|
}
|
|
633
|
+
// Service URLs (for staging URL configuration)
|
|
634
|
+
if (detected.service_urls && detected.service_urls.length > 0) {
|
|
635
|
+
console.log(`\n Service URLs (${detected.service_urls.length}):`);
|
|
636
|
+
for (const svc of detected.service_urls) {
|
|
637
|
+
console.log(` ${chalk_1.default.cyan(svc.var_name)}: ${svc.base_url}`);
|
|
638
|
+
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` : ''}`));
|
|
639
|
+
}
|
|
640
|
+
console.log(chalk_1.default.dim('\n These URLs will need staging equivalents in init.'));
|
|
641
|
+
}
|
|
411
642
|
console.log(chalk_1.default.bold('\n📝 Next steps:\n'));
|
|
412
643
|
console.log(' 1. Review the detected configuration in .genbox/detected.yaml');
|
|
413
644
|
console.log(' 2. Run ' + chalk_1.default.cyan('genbox init --from-scan') + ' to create genbox.yaml');
|