genbox 1.0.46 → 1.0.48

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.
@@ -1,4 +1,17 @@
1
1
  "use strict";
2
+ /**
3
+ * Unified Init Command (v4)
4
+ *
5
+ * Combines scanning, configuration, and initialization into a single flow:
6
+ * 1. Scan project structure
7
+ * 2. App selection and editing
8
+ * 3. Project settings (name, size, branch)
9
+ * 4. Git auth setup
10
+ * 5. Script selection
11
+ * 6. Environment + Service URL configuration
12
+ * 7. Profile generation and editing
13
+ * 8. Generate genbox.yaml and .env.genbox
14
+ */
2
15
  var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
16
  if (k2 === undefined) k2 = k;
4
17
  var desc = Object.getOwnPropertyDescriptor(m, k);
@@ -45,1342 +58,623 @@ const path_1 = __importDefault(require("path"));
45
58
  const fs_1 = __importDefault(require("fs"));
46
59
  const yaml = __importStar(require("js-yaml"));
47
60
  const process = __importStar(require("process"));
48
- const os = __importStar(require("os"));
49
61
  const scanner_1 = require("../scanner");
50
- const config_generator_1 = require("../scanner/config-generator");
51
- const scan_1 = require("../scan");
62
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
63
+ const { version } = require('../../package.json');
52
64
  const CONFIG_FILENAME = 'genbox.yaml';
53
65
  const ENV_FILENAME = '.env.genbox';
66
+ const DETECTED_DIR = '.genbox';
67
+ const DETECTED_FILENAME = 'detected.yaml';
68
+ // =============================================================================
69
+ // Scan Phase
70
+ // =============================================================================
54
71
  /**
55
- * Extract unique HTTP URLs from frontend app sections in .env.genbox content
56
- * Catches both localhost URLs and Docker internal service references
72
+ * Scan project and convert to DetectedConfig
57
73
  */
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);
74
+ async function scanProject(rootDir, exclude = []) {
75
+ const scanner = new scanner_1.ProjectScanner();
76
+ const scan = await scanner.scan(rootDir, { exclude, skipScripts: false });
77
+ return convertScanToDetected(scan, rootDir);
78
+ }
79
+ /**
80
+ * Convert ProjectScan to DetectedConfig format
81
+ */
82
+ function convertScanToDetected(scan, root) {
83
+ const detected = {
84
+ _meta: {
85
+ generated_at: new Date().toISOString(),
86
+ genbox_version: version,
87
+ scanned_root: root,
88
+ },
89
+ structure: {
90
+ type: mapStructureType(scan.structure.type),
91
+ confidence: scan.structure.confidence || 'medium',
92
+ indicators: scan.structure.indicators || [],
93
+ },
94
+ runtimes: scan.runtimes.map(r => ({
95
+ language: r.language,
96
+ version: r.version,
97
+ version_source: r.versionSource,
98
+ package_manager: r.packageManager,
99
+ lockfile: r.lockfile,
100
+ })),
101
+ apps: {},
102
+ };
103
+ // Convert apps
104
+ const isMultiRepo = scan.structure.type === 'hybrid';
105
+ for (const app of scan.apps) {
106
+ const mappedType = mapAppType(app.type);
107
+ let appGit = undefined;
108
+ if (isMultiRepo) {
109
+ appGit = detectGitForDirectory(path_1.default.join(root, app.path));
75
110
  }
111
+ const { runner, runner_reason } = detectRunner(app, scan);
112
+ detected.apps[app.name] = {
113
+ path: app.path,
114
+ type: mappedType,
115
+ type_reason: inferTypeReason(app),
116
+ runner,
117
+ runner_reason,
118
+ port: app.port,
119
+ port_source: app.port ? inferPortSource(app) : undefined,
120
+ framework: app.framework,
121
+ framework_source: app.framework ? 'package.json dependencies' : undefined,
122
+ commands: app.scripts ? {
123
+ dev: app.scripts.dev,
124
+ build: app.scripts.build,
125
+ start: app.scripts.start,
126
+ } : undefined,
127
+ dependencies: app.dependencies,
128
+ git: appGit,
129
+ };
76
130
  }
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);
131
+ // Convert infrastructure
132
+ if (scan.compose) {
133
+ detected.infrastructure = [];
134
+ for (const db of scan.compose.databases || []) {
135
+ detected.infrastructure.push({
136
+ name: db.name,
137
+ type: 'database',
138
+ image: db.image || 'unknown',
139
+ port: db.ports?.[0]?.host || 0,
140
+ source: 'docker-compose.yml',
141
+ });
142
+ }
143
+ for (const cache of scan.compose.caches || []) {
144
+ detected.infrastructure.push({
145
+ name: cache.name,
146
+ type: 'cache',
147
+ image: cache.image || 'unknown',
148
+ port: cache.ports?.[0]?.host || 0,
149
+ source: 'docker-compose.yml',
150
+ });
151
+ }
152
+ for (const queue of scan.compose.queues || []) {
153
+ detected.infrastructure.push({
154
+ name: queue.name,
155
+ type: 'queue',
156
+ image: queue.image || 'unknown',
157
+ port: queue.ports?.[0]?.host || 0,
158
+ source: 'docker-compose.yml',
159
+ });
160
+ }
161
+ // Add Docker application services as apps with runner: 'docker'
162
+ if (scan.compose.applications && scan.compose.applications.length > 0) {
163
+ for (const dockerApp of scan.compose.applications) {
164
+ if (detected.apps[dockerApp.name])
165
+ continue;
166
+ const { type: mappedType, reason: typeReason } = inferDockerAppType(dockerApp.name, dockerApp.build?.context, root);
167
+ detected.apps[dockerApp.name] = {
168
+ path: dockerApp.build?.context || '.',
169
+ type: mappedType,
170
+ type_reason: typeReason,
171
+ runner: 'docker',
172
+ runner_reason: 'defined in docker-compose.yml',
173
+ docker: {
174
+ service: dockerApp.name,
175
+ build_context: dockerApp.build?.context,
176
+ dockerfile: dockerApp.build?.dockerfile,
177
+ image: dockerApp.image,
178
+ },
179
+ port: dockerApp.ports?.[0]?.host,
180
+ port_source: 'docker-compose.yml ports',
181
+ };
119
182
  }
120
183
  }
121
184
  }
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`
185
+ // Git info
186
+ if (scan.git) {
187
+ detected.git = {
188
+ remote: scan.git.remote,
189
+ type: scan.git.type,
190
+ provider: scan.git.provider,
191
+ branch: scan.git.branch,
143
192
  };
144
193
  }
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
- };
194
+ // Scripts
195
+ if (scan.scripts && scan.scripts.length > 0) {
196
+ detected.scripts = scan.scripts.map(s => ({
197
+ name: s.name,
198
+ path: s.path,
199
+ stage: s.stage,
200
+ stage_reason: inferStageReason(s),
201
+ executable: s.isExecutable,
202
+ }));
152
203
  }
153
- return { name: 'unknown', varPrefix: 'UNKNOWN', description: 'Unknown Service' };
204
+ return detected;
154
205
  }
155
206
  /**
156
- * Create service URL mappings based on configured environments
157
- * Uses URLs from configured environments instead of prompting again
207
+ * Load existing detected.yaml if present
158
208
  */
159
- async function createServiceUrlMappings(serviceUrls, configuredEnvs) {
160
- const mappings = [];
161
- if (serviceUrls.size === 0) {
162
- return mappings;
209
+ function loadDetectedConfig(rootDir) {
210
+ const detectedPath = path_1.default.join(rootDir, DETECTED_DIR, DETECTED_FILENAME);
211
+ if (!fs_1.default.existsSync(detectedPath))
212
+ return null;
213
+ try {
214
+ const content = fs_1.default.readFileSync(detectedPath, 'utf8');
215
+ return yaml.load(content);
163
216
  }
164
- // Determine which environment to use (prefer staging, then production)
165
- const envNames = Object.keys(configuredEnvs || {});
166
- const primaryEnv = envNames.includes('staging') ? 'staging' :
167
- envNames.includes('production') ? 'production' :
168
- envNames[0];
169
- if (!primaryEnv || !configuredEnvs?.[primaryEnv]) {
170
- // No environments configured - skip service URL mapping
171
- return mappings;
172
- }
173
- const envConfig = configuredEnvs[primaryEnv];
174
- const apiUrl = envConfig.urls?.api;
175
- if (!apiUrl) {
176
- // No API URL configured - skip
177
- return mappings;
178
- }
179
- // Sort by port number for consistent ordering
180
- const sortedServices = Array.from(serviceUrls.entries()).sort((a, b) => {
181
- const portA = parseInt(a[0].match(/:(\d+)/)?.[1] || '0');
182
- const portB = parseInt(b[0].match(/:(\d+)/)?.[1] || '0');
183
- return portA - portB;
184
- });
185
- console.log('');
186
- console.log(chalk_1.default.blue('=== Service URL Mapping ==='));
187
- console.log(chalk_1.default.dim(`Mapping localhost URLs to ${primaryEnv} environment`));
188
- console.log('');
189
- for (const [baseUrl, { vars }] of sortedServices) {
190
- const serviceInfo = getServiceNameFromUrl(baseUrl);
191
- // Auto-map API/gateway URLs to the configured API URL
192
- const isApiService = serviceInfo.name === 'gateway' ||
193
- baseUrl.includes(':3050') ||
194
- baseUrl.includes(':3105') ||
195
- vars.some(v => v.toLowerCase().includes('api'));
196
- if (isApiService && apiUrl) {
197
- console.log(chalk_1.default.green(`✓ ${serviceInfo.description}: ${baseUrl} → ${apiUrl}`));
198
- mappings.push({
199
- varName: `${serviceInfo.varPrefix}_URL`,
200
- localUrl: baseUrl,
201
- remoteUrl: apiUrl,
202
- remoteEnv: primaryEnv,
203
- description: serviceInfo.description,
204
- });
205
- }
206
- else {
207
- // For non-API services, just record the local URL without remote
208
- console.log(chalk_1.default.dim(` ${serviceInfo.description}: ${baseUrl} (local only)`));
209
- mappings.push({
210
- varName: `${serviceInfo.varPrefix}_URL`,
211
- localUrl: baseUrl,
212
- description: serviceInfo.description,
213
- });
214
- }
217
+ catch {
218
+ return null;
215
219
  }
216
- return mappings;
217
220
  }
218
221
  /**
219
- * Transform .env.genbox content to use expandable variables
222
+ * Save detected config to .genbox/detected.yaml
220
223
  */
221
- function transformEnvWithVariables(envContent, mappings, frontendApps) {
222
- if (mappings.length === 0) {
223
- return envContent;
224
- }
225
- let result = envContent;
226
- // Determine the environment name for comments
227
- const envName = mappings.find(m => m.remoteEnv)?.remoteEnv?.toUpperCase() || 'REMOTE';
228
- // Build GLOBAL section additions
229
- const globalAdditions = [
230
- '',
231
- '# Service URL Configuration',
232
- `# These expand based on profile: \${GATEWAY_URL} → LOCAL or ${envName} value`,
233
- ];
234
- for (const mapping of mappings) {
235
- globalAdditions.push(`LOCAL_${mapping.varName}=${mapping.localUrl}`);
236
- if (mapping.remoteUrl && mapping.remoteEnv) {
237
- const prefix = mapping.remoteEnv.toUpperCase();
238
- globalAdditions.push(`${prefix}_${mapping.varName}=${mapping.remoteUrl}`);
239
- }
224
+ function saveDetectedConfig(rootDir, detected) {
225
+ const genboxDir = path_1.default.join(rootDir, DETECTED_DIR);
226
+ if (!fs_1.default.existsSync(genboxDir)) {
227
+ fs_1.default.mkdirSync(genboxDir, { recursive: true });
240
228
  }
241
- globalAdditions.push('');
242
- // Insert after GLOBAL section header and existing content
243
- const globalInsertPoint = result.indexOf('# === GLOBAL ===');
244
- if (globalInsertPoint !== -1) {
245
- // Find the next section or end of GLOBAL
246
- const nextSectionMatch = result.substring(globalInsertPoint + 20).match(/\n# === [^=]+ ===/);
247
- const insertAt = nextSectionMatch
248
- ? globalInsertPoint + 20 + nextSectionMatch.index
249
- : result.length;
250
- result = result.substring(0, insertAt) + globalAdditions.join('\n') + result.substring(insertAt);
251
- }
252
- // Replace localhost URLs with variable syntax in frontend app sections
253
- // Parse sections again after modification
254
- const lines = result.split('\n');
255
- const transformedLines = [];
256
- let currentSection = 'GLOBAL';
257
- let inFrontendSection = false;
258
- for (const line of lines) {
259
- const sectionMatch = line.match(/^# === ([^=]+) ===$/);
260
- if (sectionMatch) {
261
- currentSection = sectionMatch[1].trim();
262
- inFrontendSection = frontendApps.includes(currentSection);
263
- transformedLines.push(line);
264
- continue;
265
- }
266
- if (inFrontendSection && line.includes('http://localhost:')) {
267
- // Check if this line matches any of our mappings
268
- let transformedLine = line;
269
- for (const mapping of mappings) {
270
- if (line.includes(mapping.localUrl)) {
271
- // Replace the full URL, preserving any path suffix
272
- const urlPattern = new RegExp(mapping.localUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(/[^"\'\\s]*)?', 'g');
273
- transformedLine = transformedLine.replace(urlPattern, (match, pathSuffix) => {
274
- return `\${${mapping.varName}}${pathSuffix || ''}`;
275
- });
276
- }
277
- }
278
- transformedLines.push(transformedLine);
279
- }
280
- else {
281
- transformedLines.push(line);
229
+ // Add .genbox to .gitignore if not already there
230
+ const gitignorePath = path_1.default.join(rootDir, '.gitignore');
231
+ if (fs_1.default.existsSync(gitignorePath)) {
232
+ const gitignore = fs_1.default.readFileSync(gitignorePath, 'utf8');
233
+ if (!gitignore.includes('.genbox')) {
234
+ fs_1.default.appendFileSync(gitignorePath, '\n# Genbox generated files\n.genbox/\n');
282
235
  }
283
236
  }
284
- return transformedLines.join('\n');
237
+ const filePath = path_1.default.join(genboxDir, DETECTED_FILENAME);
238
+ const content = yaml.dump(detected, { lineWidth: 120, noRefs: true });
239
+ fs_1.default.writeFileSync(filePath, content);
285
240
  }
241
+ // =============================================================================
242
+ // App Configuration
243
+ // =============================================================================
286
244
  /**
287
- * Detect git repositories in app directories (for multi-repo workspaces)
245
+ * Interactive app selection
288
246
  */
289
- function detectAppGitRepos(apps, rootDir) {
290
- const { execSync } = require('child_process');
291
- const repos = [];
292
- for (const app of apps) {
293
- const appDir = path_1.default.join(rootDir, app.path);
294
- const gitDir = path_1.default.join(appDir, '.git');
295
- if (!fs_1.default.existsSync(gitDir))
296
- continue;
297
- try {
298
- const remote = execSync('git remote get-url origin', {
299
- cwd: appDir,
300
- stdio: 'pipe',
301
- encoding: 'utf8',
302
- }).trim();
303
- if (!remote)
304
- continue;
305
- const isSSH = remote.startsWith('git@') || remote.startsWith('ssh://');
306
- let provider = 'other';
307
- if (remote.includes('github.com'))
308
- provider = 'github';
309
- else if (remote.includes('gitlab.com'))
310
- provider = 'gitlab';
311
- else if (remote.includes('bitbucket.org'))
312
- provider = 'bitbucket';
313
- let branch = 'main';
314
- try {
315
- branch = execSync('git rev-parse --abbrev-ref HEAD', {
316
- cwd: appDir,
317
- stdio: 'pipe',
318
- encoding: 'utf8',
319
- }).trim();
320
- }
321
- catch { }
322
- repos.push({
323
- appName: app.name,
324
- appPath: app.path,
325
- remote,
326
- type: isSSH ? 'ssh' : 'https',
327
- provider,
328
- branch,
329
- });
330
- }
331
- catch {
332
- // No git remote in this directory
247
+ async function selectApps(detected) {
248
+ const appEntries = Object.entries(detected.apps);
249
+ if (appEntries.length === 0)
250
+ return detected;
251
+ console.log('');
252
+ console.log(chalk_1.default.blue('=== Detected Apps ==='));
253
+ console.log('');
254
+ for (const [name, app] of appEntries) {
255
+ const parts = [
256
+ chalk_1.default.cyan(name),
257
+ app.type ? `(${app.type})` : '',
258
+ app.framework ? `[${app.framework}]` : '',
259
+ app.port ? `port:${app.port}` : '',
260
+ app.runner ? chalk_1.default.dim(`runner:${app.runner}`) : '',
261
+ ].filter(Boolean);
262
+ console.log(` ${parts.join(' ')}`);
263
+ if (app.git) {
264
+ console.log(chalk_1.default.dim(` └─ ${app.git.remote}`));
333
265
  }
334
266
  }
335
- return repos;
267
+ console.log('');
268
+ const appChoices = appEntries.map(([name, app]) => ({
269
+ name: `${name} (${app.type || 'unknown'}${app.framework ? `, ${app.framework}` : ''})`,
270
+ value: name,
271
+ checked: app.type !== 'library',
272
+ }));
273
+ const selectedApps = await prompts.checkbox({
274
+ message: 'Select apps to include:',
275
+ choices: appChoices,
276
+ });
277
+ const filteredApps = {};
278
+ for (const appName of selectedApps) {
279
+ filteredApps[appName] = detected.apps[appName];
280
+ }
281
+ return { ...detected, apps: filteredApps };
336
282
  }
337
283
  /**
338
- * Find .env files in app directories (including nested microservices)
284
+ * Interactive app editing
339
285
  */
340
- function findAppEnvFiles(apps, rootDir) {
341
- const envFiles = [];
342
- const envPatterns = ['.env', '.env.local', '.env.development'];
343
- for (const app of apps) {
344
- const appDir = path_1.default.join(rootDir, app.path);
345
- // Check for direct .env file in app directory
346
- let foundDirectEnv = false;
347
- for (const pattern of envPatterns) {
348
- const envPath = path_1.default.join(appDir, pattern);
349
- if (fs_1.default.existsSync(envPath)) {
350
- envFiles.push({
351
- appName: app.name,
352
- envFile: pattern,
353
- fullPath: envPath,
354
- });
355
- foundDirectEnv = true;
356
- break;
357
- }
358
- }
359
- // Check for nested microservices (e.g., api/apps/*)
360
- const appsSubdir = path_1.default.join(appDir, 'apps');
361
- if (fs_1.default.existsSync(appsSubdir) && fs_1.default.statSync(appsSubdir).isDirectory()) {
362
- try {
363
- const services = fs_1.default.readdirSync(appsSubdir);
364
- for (const service of services) {
365
- const serviceDir = path_1.default.join(appsSubdir, service);
366
- if (!fs_1.default.statSync(serviceDir).isDirectory())
367
- continue;
368
- for (const pattern of envPatterns) {
369
- const envPath = path_1.default.join(serviceDir, pattern);
370
- if (fs_1.default.existsSync(envPath)) {
371
- envFiles.push({
372
- appName: `${app.name}/${service}`,
373
- envFile: pattern,
374
- fullPath: envPath,
375
- isService: true,
376
- });
377
- break;
378
- }
379
- }
380
- }
381
- }
382
- catch {
383
- // Ignore errors reading subdirectories
384
- }
385
- }
286
+ async function editApps(detected) {
287
+ const appEntries = Object.entries(detected.apps);
288
+ if (appEntries.length === 0)
289
+ return detected;
290
+ const editAppsChoice = await prompts.confirm({
291
+ message: 'Do you want to edit any app configurations?',
292
+ default: false,
293
+ });
294
+ if (!editAppsChoice)
295
+ return detected;
296
+ const appChoices = appEntries.map(([name, app]) => ({
297
+ name: `${name} (${app.type || 'unknown'}, ${app.runner || 'pm2'})`,
298
+ value: name,
299
+ }));
300
+ const appsToEdit = await prompts.checkbox({
301
+ message: 'Select apps to edit:',
302
+ choices: appChoices,
303
+ });
304
+ const result = { ...detected, apps: { ...detected.apps } };
305
+ for (const appName of appsToEdit) {
306
+ result.apps[appName] = await editSingleApp(appName, result.apps[appName], detected._meta.scanned_root);
386
307
  }
387
- return envFiles;
308
+ return result;
388
309
  }
389
310
  /**
390
- * Read existing values from .env.genbox file
311
+ * Edit a single app's configuration
391
312
  */
392
- function readExistingEnvGenbox() {
393
- const envPath = path_1.default.join(process.cwd(), ENV_FILENAME);
394
- const values = {};
395
- if (!fs_1.default.existsSync(envPath)) {
396
- return values;
313
+ async function editSingleApp(name, app, rootDir) {
314
+ console.log('');
315
+ console.log(chalk_1.default.blue(`=== Editing: ${name} ===`));
316
+ console.log(chalk_1.default.dim('Current values:'));
317
+ console.log(` Type: ${chalk_1.default.cyan(app.type || 'unknown')} ${chalk_1.default.dim(`(${app.type_reason || 'detected'})`)}`);
318
+ console.log(` Runner: ${chalk_1.default.cyan(app.runner || 'pm2')} ${chalk_1.default.dim(`(${app.runner_reason || 'default'})`)}`);
319
+ console.log(` Port: ${app.port ? chalk_1.default.cyan(String(app.port)) : chalk_1.default.dim('not set')} ${app.port_source ? chalk_1.default.dim(`(${app.port_source})`) : ''}`);
320
+ console.log(` Framework: ${app.framework ? chalk_1.default.cyan(app.framework) : chalk_1.default.dim('not detected')}`);
321
+ console.log('');
322
+ const result = { ...app };
323
+ // Edit type
324
+ const typeChoices = [
325
+ { name: `frontend ${app.type === 'frontend' ? chalk_1.default.green('(current)') : ''}`, value: 'frontend' },
326
+ { name: `backend ${app.type === 'backend' ? chalk_1.default.green('(current)') : ''}`, value: 'backend' },
327
+ { name: `worker ${app.type === 'worker' ? chalk_1.default.green('(current)') : ''}`, value: 'worker' },
328
+ { name: `gateway ${app.type === 'gateway' ? chalk_1.default.green('(current)') : ''}`, value: 'gateway' },
329
+ { name: `library ${app.type === 'library' ? chalk_1.default.green('(current)') : ''}`, value: 'library' },
330
+ ];
331
+ const newType = await prompts.select({
332
+ message: 'App type:',
333
+ choices: typeChoices,
334
+ default: app.type || 'backend',
335
+ });
336
+ if (newType !== app.type) {
337
+ result.type = newType;
338
+ result.type_reason = 'manually set';
397
339
  }
398
- try {
399
- const content = fs_1.default.readFileSync(envPath, 'utf8');
400
- for (const line of content.split('\n')) {
401
- // Skip comments and empty lines
402
- const trimmed = line.trim();
403
- if (!trimmed || trimmed.startsWith('#'))
404
- continue;
405
- // Parse KEY=value
406
- const match = trimmed.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
407
- if (match) {
408
- let value = match[2].trim();
409
- // Remove quotes if present
410
- if ((value.startsWith('"') && value.endsWith('"')) ||
411
- (value.startsWith("'") && value.endsWith("'"))) {
412
- value = value.slice(1, -1);
340
+ // Edit runner
341
+ const runnerChoices = [
342
+ { name: `pm2 - Process manager (recommended for Node.js) ${app.runner === 'pm2' ? chalk_1.default.green('(current)') : ''}`, value: 'pm2' },
343
+ { name: `docker - Docker compose service ${app.runner === 'docker' ? chalk_1.default.green('(current)') : ''}`, value: 'docker' },
344
+ { name: `bun - Bun runtime ${app.runner === 'bun' ? chalk_1.default.green('(current)') : ''}`, value: 'bun' },
345
+ { name: `node - Direct Node.js execution ${app.runner === 'node' ? chalk_1.default.green('(current)') : ''}`, value: 'node' },
346
+ { name: `none - Library/not runnable ${app.runner === 'none' ? chalk_1.default.green('(current)') : ''}`, value: 'none' },
347
+ ];
348
+ const newRunner = await prompts.select({
349
+ message: 'Runner:',
350
+ choices: runnerChoices,
351
+ default: app.runner || 'pm2',
352
+ });
353
+ if (newRunner !== app.runner) {
354
+ result.runner = newRunner;
355
+ result.runner_reason = 'manually set';
356
+ }
357
+ // If runner is docker, ask for docker config
358
+ if (newRunner === 'docker') {
359
+ const dockerService = await prompts.input({
360
+ message: 'Docker service name:',
361
+ default: app.docker?.service || name,
362
+ });
363
+ const composeInfo = getDockerComposeServiceInfo(rootDir, dockerService);
364
+ if (composeInfo) {
365
+ console.log(chalk_1.default.blue('\n Detected from docker-compose.yml:'));
366
+ if (composeInfo.buildContext)
367
+ console.log(chalk_1.default.dim(` build context: ${composeInfo.buildContext}`));
368
+ if (composeInfo.dockerfile)
369
+ console.log(chalk_1.default.dim(` dockerfile: ${composeInfo.dockerfile}`));
370
+ if (composeInfo.port)
371
+ console.log(chalk_1.default.dim(` port: ${composeInfo.port}`));
372
+ if (composeInfo.healthcheck)
373
+ console.log(chalk_1.default.dim(` healthcheck: ${composeInfo.healthcheck}`));
374
+ console.log('');
375
+ }
376
+ const defaultContext = composeInfo?.buildContext || app.docker?.build_context || app.path || '.';
377
+ const dockerContext = await prompts.input({
378
+ message: 'Docker build context:',
379
+ default: defaultContext,
380
+ });
381
+ const defaultDockerfile = composeInfo?.dockerfile || app.docker?.dockerfile;
382
+ const dockerfile = defaultDockerfile ? await prompts.input({
383
+ message: 'Dockerfile:',
384
+ default: defaultDockerfile,
385
+ }) : undefined;
386
+ result.docker = {
387
+ service: dockerService,
388
+ build_context: dockerContext,
389
+ dockerfile: dockerfile || app.docker?.dockerfile,
390
+ };
391
+ if (composeInfo?.port) {
392
+ result.port = composeInfo.port;
393
+ result.port_source = 'docker-compose.yml';
394
+ }
395
+ if (composeInfo?.healthcheck) {
396
+ result.healthcheck = composeInfo.healthcheck;
397
+ }
398
+ if (composeInfo?.dependsOn?.length) {
399
+ result.depends_on = composeInfo.dependsOn;
400
+ }
401
+ }
402
+ // Edit port (only if not a library)
403
+ if (newRunner !== 'none' && result.type !== 'library') {
404
+ const currentPort = result.port;
405
+ const portDefault = currentPort ? String(currentPort) : '';
406
+ const portInput = await prompts.input({
407
+ message: currentPort ? `Port (detected: ${currentPort}, press Enter to keep):` : 'Port (leave empty to skip):',
408
+ default: portDefault,
409
+ });
410
+ if (portInput) {
411
+ const portNum = parseInt(portInput, 10);
412
+ if (!isNaN(portNum) && portNum > 0 && portNum < 65536) {
413
+ if (portNum !== currentPort) {
414
+ result.port = portNum;
415
+ result.port_source = 'manually set';
413
416
  }
414
- values[match[1]] = value;
415
417
  }
416
418
  }
417
419
  }
418
- catch {
419
- // Ignore read errors
420
+ // Edit framework
421
+ const frameworkChoices = [
422
+ { name: `Keep current: ${app.framework || 'none'}`, value: app.framework || '' },
423
+ { name: 'nextjs', value: 'nextjs' },
424
+ { name: 'react', value: 'react' },
425
+ { name: 'vue', value: 'vue' },
426
+ { name: 'vite', value: 'vite' },
427
+ { name: 'nestjs', value: 'nestjs' },
428
+ { name: 'express', value: 'express' },
429
+ { name: 'hono', value: 'hono' },
430
+ { name: 'Other (type manually)', value: '__other__' },
431
+ { name: 'None / Clear', value: '__none__' },
432
+ ];
433
+ const frameworkChoice = await prompts.select({
434
+ message: 'Framework:',
435
+ choices: frameworkChoices,
436
+ default: app.framework || '',
437
+ });
438
+ if (frameworkChoice === '__other__') {
439
+ const customFramework = await prompts.input({
440
+ message: 'Enter framework name:',
441
+ });
442
+ if (customFramework) {
443
+ result.framework = customFramework;
444
+ result.framework_source = 'manually set';
445
+ }
420
446
  }
421
- return values;
447
+ else if (frameworkChoice === '__none__') {
448
+ result.framework = undefined;
449
+ result.framework_source = undefined;
450
+ }
451
+ else if (frameworkChoice && frameworkChoice !== app.framework) {
452
+ result.framework = frameworkChoice;
453
+ result.framework_source = 'manually set';
454
+ }
455
+ console.log(chalk_1.default.green(`✓ Updated ${name}`));
456
+ return result;
422
457
  }
458
+ // =============================================================================
459
+ // Project Settings
460
+ // =============================================================================
423
461
  /**
424
- * Mask a secret value for display (show first 4 and last 4 chars)
462
+ * Get project settings with editing capability
425
463
  */
426
- function maskSecret(value) {
427
- if (value.length <= 8) {
428
- return '*'.repeat(value.length);
429
- }
430
- return value.slice(0, 4) + '*'.repeat(value.length - 8) + value.slice(-4);
464
+ async function getProjectSettings(detected, existingEnvValues) {
465
+ console.log('');
466
+ console.log(chalk_1.default.blue('=== Project Settings ==='));
467
+ console.log('');
468
+ // Project name
469
+ const defaultName = path_1.default.basename(detected._meta.scanned_root);
470
+ const projectName = await prompts.input({
471
+ message: 'Project name:',
472
+ default: defaultName,
473
+ });
474
+ // Server size
475
+ const serverSize = await prompts.select({
476
+ message: 'Default server size:',
477
+ choices: [
478
+ { name: 'Small - 2 CPU, 4GB RAM (1 credit/hr)', value: 'small' },
479
+ { name: 'Medium - 4 CPU, 8GB RAM (2 credits/hr)', value: 'medium' },
480
+ { name: 'Large - 8 CPU, 16GB RAM (4 credits/hr)', value: 'large' },
481
+ { name: 'XL - 16 CPU, 32GB RAM (8 credits/hr)', value: 'xl' },
482
+ ],
483
+ default: 'medium',
484
+ });
485
+ // Base branch
486
+ const detectedBranch = detected.git?.branch || 'main';
487
+ console.log('');
488
+ console.log(chalk_1.default.dim(' When creating environments, a new branch is created from this base branch.'));
489
+ console.log(chalk_1.default.dim(' The new branch name defaults to the environment name.'));
490
+ const baseBranch = await prompts.input({
491
+ message: 'Base branch for new environments:',
492
+ default: detectedBranch,
493
+ });
494
+ // Claude Code installation
495
+ const installClaudeCode = await prompts.confirm({
496
+ message: 'Install Claude Code CLI on genbox servers?',
497
+ default: true,
498
+ });
499
+ return { projectName, serverSize, baseBranch, installClaudeCode };
431
500
  }
501
+ // =============================================================================
502
+ // Git Auth Setup
503
+ // =============================================================================
432
504
  /**
433
- * Prompt for a secret with existing value support
505
+ * Setup git authentication for detected repos
434
506
  */
435
- async function promptForSecret(message, existingValue, options = {}) {
436
- if (existingValue) {
437
- console.log(chalk_1.default.dim(` Found existing value: ${maskSecret(existingValue)}`));
438
- const useExisting = await prompts.confirm({
439
- message: 'Use existing value?',
440
- default: true,
507
+ async function setupGitAuth(detected, projectName, existingEnvValues) {
508
+ const envVars = {};
509
+ const repos = {};
510
+ // Collect all git repos (root + per-app)
511
+ const gitRepos = [];
512
+ // Root git
513
+ if (detected.git?.remote) {
514
+ const repoName = path_1.default.basename(detected.git.remote, '.git').replace(/.*[:/]/, '');
515
+ gitRepos.push({
516
+ name: repoName,
517
+ path: `/home/dev/${projectName}`,
518
+ remote: detected.git.remote,
519
+ type: detected.git.type || 'https',
520
+ branch: detected.git.branch,
441
521
  });
442
- if (useExisting) {
443
- return existingValue;
522
+ }
523
+ // Per-app git repos
524
+ for (const [appName, app] of Object.entries(detected.apps)) {
525
+ if (app.git?.remote) {
526
+ gitRepos.push({
527
+ name: appName,
528
+ path: `/home/dev/${projectName}/${app.path}`,
529
+ remote: app.git.remote,
530
+ type: app.git.type || 'https',
531
+ branch: app.git.branch,
532
+ });
444
533
  }
445
534
  }
446
- if (options.showInstructions) {
447
- console.log('');
448
- console.log(chalk_1.default.dim(' To create a token:'));
449
- console.log(chalk_1.default.dim(' 1. Go to https://github.com/settings/tokens'));
450
- console.log(chalk_1.default.dim(' 2. Click "Generate new token" → "Classic"'));
451
- console.log(chalk_1.default.dim(' 3. Select scope: "repo" (Full control of private repositories)'));
452
- console.log(chalk_1.default.dim(' 4. Generate and copy the token'));
453
- console.log('');
535
+ if (gitRepos.length === 0) {
536
+ console.log(chalk_1.default.dim(' No git repositories detected.'));
537
+ return { repos, envVars };
454
538
  }
455
- let value = await prompts.password({
456
- message,
457
- });
458
- if (value) {
459
- // Strip any "KEY=" prefix if user pasted the whole line
460
- value = value.replace(/^[A-Z_]+=/, '');
539
+ console.log('');
540
+ console.log(chalk_1.default.blue('=== Git Repository Setup ==='));
541
+ console.log('');
542
+ for (const repo of gitRepos) {
543
+ console.log(` ${chalk_1.default.cyan(repo.name)}: ${repo.remote}`);
461
544
  }
462
- return value || undefined;
463
- }
464
- exports.initCommand = new commander_1.Command('init')
465
- .description('Initialize a new Genbox configuration')
466
- .option('--v2', 'Use legacy v2 format (single-app only)')
467
- .option('--workspace', 'Initialize as workspace config (for multi-repo projects)')
468
- .option('--force', 'Overwrite existing configuration')
469
- .option('-y, --yes', 'Use defaults without prompting')
470
- .option('--exclude <dirs>', 'Comma-separated directories to exclude')
471
- .option('--name <name>', 'Project name (for non-interactive mode)')
472
- .option('--from-scan', 'Initialize from existing .genbox/detected.yaml (created by genbox scan)')
473
- .action(async (options) => {
474
- try {
475
- const configPath = path_1.default.join(process.cwd(), CONFIG_FILENAME);
476
- const nonInteractive = options.yes || !process.stdin.isTTY;
477
- // Check for existing config
478
- let overwriteExisting = options.force || false;
479
- if (fs_1.default.existsSync(configPath) && !options.force) {
480
- if (nonInteractive) {
481
- console.log(chalk_1.default.yellow('genbox.yaml already exists. Use --force to overwrite.'));
482
- return;
483
- }
484
- console.log(chalk_1.default.yellow('genbox.yaml already exists.'));
485
- const overwrite = await prompts.confirm({
486
- message: 'Do you want to overwrite it?',
487
- default: false,
488
- });
489
- if (!overwrite) {
490
- return;
491
- }
492
- overwriteExisting = true;
493
- }
494
- console.log(chalk_1.default.blue('Initializing Genbox...'));
495
- console.log('');
496
- // Read existing .env.genbox values (for defaults when overwriting)
497
- const existingEnvValues = readExistingEnvGenbox();
498
- // Track env vars to add to .env.genbox
499
- const envVarsToAdd = {};
500
- // Get initial exclusions from CLI options only
501
- let exclude = [];
502
- if (options.exclude) {
503
- exclude = options.exclude.split(',').map((d) => d.trim()).filter(Boolean);
545
+ console.log('');
546
+ // Ask for auth method
547
+ const authMethod = await prompts.select({
548
+ message: 'How should genbox access these repositories?',
549
+ choices: [
550
+ { name: 'Personal Access Token (PAT) - recommended', value: 'token' },
551
+ { name: 'SSH Key', value: 'ssh' },
552
+ { name: 'Public (no auth needed)', value: 'public' },
553
+ ],
554
+ default: 'token',
555
+ });
556
+ let hasHttpsRepos = false;
557
+ for (const repo of gitRepos) {
558
+ let repoUrl = repo.remote;
559
+ if (authMethod === 'token' && repo.type === 'ssh') {
560
+ repoUrl = sshToHttps(repo.remote);
504
561
  }
505
- let scan;
506
- const scanner = new scanner_1.ProjectScanner();
507
- // If --from-scan is specified, load from detected.yaml
508
- if (options.fromScan) {
509
- const detectedPath = path_1.default.join(process.cwd(), '.genbox', 'detected.yaml');
510
- if (!fs_1.default.existsSync(detectedPath)) {
511
- console.log(chalk_1.default.red('No .genbox/detected.yaml found. Run "genbox scan" first.'));
512
- process.exit(1);
513
- }
514
- const spinner = (0, ora_1.default)('Loading detected configuration...').start();
515
- try {
516
- const content = fs_1.default.readFileSync(detectedPath, 'utf8');
517
- const detected = yaml.load(content);
518
- scan = convertDetectedToScan(detected);
519
- spinner.succeed('Loaded from detected.yaml');
520
- }
521
- catch (err) {
522
- spinner.fail('Failed to load detected.yaml');
523
- console.error(chalk_1.default.red(String(err)));
524
- process.exit(1);
525
- }
562
+ else if (authMethod === 'ssh' && repo.type === 'https') {
563
+ repoUrl = httpsToSsh(repo.remote);
526
564
  }
527
- else {
528
- // Scan project first (skip scripts initially)
529
- const spinner = (0, ora_1.default)('Scanning project...').start();
530
- scan = await scanner.scan(process.cwd(), { exclude, skipScripts: true });
531
- spinner.succeed('Project scanned');
565
+ if (authMethod === 'token' || repo.type === 'https') {
566
+ hasHttpsRepos = true;
532
567
  }
533
- // Display scan results
568
+ repos[repo.name] = {
569
+ url: repoUrl,
570
+ path: repo.path,
571
+ branch: repo.branch !== 'main' && repo.branch !== 'master' ? repo.branch : undefined,
572
+ auth: authMethod === 'public' ? undefined : authMethod,
573
+ };
574
+ }
575
+ // Prompt for GIT_TOKEN if using token auth
576
+ if (authMethod === 'token' && hasHttpsRepos) {
534
577
  console.log('');
535
- console.log(chalk_1.default.bold('Detected:'));
536
- console.log(` ${chalk_1.default.dim('Project:')} ${scan.projectName}`);
537
- console.log(` ${chalk_1.default.dim('Structure:')} ${scan.structure.type} (${scan.structure.confidence} confidence)`);
538
- if (scan.runtimes.length > 0) {
539
- const runtimeStr = scan.runtimes
540
- .map(r => `${r.language}${r.version ? ` ${r.version}` : ''}`)
541
- .join(', ');
542
- console.log(` ${chalk_1.default.dim('Runtimes:')} ${runtimeStr}`);
543
- }
544
- if (scan.frameworks.length > 0) {
545
- const frameworkStr = scan.frameworks.map(f => f.name).join(', ');
546
- console.log(` ${chalk_1.default.dim('Frameworks:')} ${frameworkStr}`);
578
+ console.log(chalk_1.default.yellow('Private repositories require a GitHub token for cloning.'));
579
+ const gitToken = await promptForSecret('GitHub Personal Access Token (leave empty to skip):', existingEnvValues['GIT_TOKEN'], { showInstructions: !existingEnvValues['GIT_TOKEN'] });
580
+ if (gitToken) {
581
+ envVars['GIT_TOKEN'] = gitToken;
582
+ console.log(chalk_1.default.green('✓ GIT_TOKEN will be added to .env.genbox'));
547
583
  }
548
- // For multi-repo: show apps and let user select which to include
549
- // When using --from-scan, skip app selection - use exactly what's in detected.yaml
550
- const isMultiRepoStructure = scan.structure.type === 'hybrid';
551
- let selectedApps = scan.apps;
552
- if (isMultiRepoStructure && scan.apps.length > 0 && !nonInteractive && !options.fromScan) {
553
- console.log('');
554
- console.log(chalk_1.default.blue('=== Apps Detected ==='));
555
- const appChoices = scan.apps.map(app => ({
556
- name: `${app.name} (${app.type}${app.framework ? `, ${app.framework}` : ''})`,
557
- value: app.name,
558
- checked: app.type !== 'library', // Default select non-libraries
559
- }));
560
- const selectedAppNames = await prompts.checkbox({
561
- message: 'Select apps to include:',
562
- choices: appChoices,
563
- });
564
- // Filter scan.apps to only selected ones
565
- selectedApps = scan.apps.filter(a => selectedAppNames.includes(a.name));
566
- // Update scan with filtered apps
567
- scan = { ...scan, apps: selectedApps };
568
- }
569
- else if (options.fromScan && scan.apps.length > 0) {
570
- // When using --from-scan, show what was loaded and use it directly
571
- console.log(` ${chalk_1.default.dim('Apps:')} ${scan.apps.length} from detected.yaml`);
572
- for (const app of scan.apps) {
573
- console.log(` - ${app.name} (${app.type}${app.framework ? `, ${app.framework}` : ''})`);
574
- }
575
- console.log(chalk_1.default.dim('\n (Edit .genbox/detected.yaml to change app selection)'));
576
- }
577
- else if (scan.apps.length > 0) {
578
- console.log(` ${chalk_1.default.dim('Apps:')} ${scan.apps.length} discovered`);
579
- for (const app of scan.apps.slice(0, 5)) {
580
- console.log(` - ${app.name} (${app.type}${app.framework ? `, ${app.framework}` : ''})`);
581
- }
582
- if (scan.apps.length > 5) {
583
- console.log(` ... and ${scan.apps.length - 5} more`);
584
- }
584
+ else {
585
+ console.log(chalk_1.default.dim(' Skipped - add GIT_TOKEN to .env.genbox later if needed'));
585
586
  }
586
- // Show Docker app count (apps with runner: 'docker')
587
- const dockerApps = scan.apps.filter(a => a.runner === 'docker');
588
- if (dockerApps.length > 0) {
589
- console.log(` ${chalk_1.default.dim('Docker:')} ${dockerApps.length} app(s) with docker runner`);
590
- }
591
- // Show infrastructure services from docker-compose (databases, caches, etc.)
592
- if (scan.compose && scan.compose.databases.length + scan.compose.caches.length + scan.compose.queues.length > 0) {
593
- const infraCount = scan.compose.databases.length + scan.compose.caches.length + scan.compose.queues.length;
594
- console.log(` ${chalk_1.default.dim('Infra:')} ${infraCount} infrastructure service(s)`);
595
- }
596
- if (scan.git) {
597
- console.log(` ${chalk_1.default.dim('Git:')} ${scan.git.remote} (${scan.git.type})`);
598
- console.log(` ${chalk_1.default.dim('Branch:')} ${chalk_1.default.cyan(scan.git.branch || 'main')}`);
599
- }
600
- console.log('');
601
- // Get project name (use scan value when --from-scan)
602
- const projectName = (nonInteractive || options.fromScan)
603
- ? (options.name || scan.projectName)
604
- : await prompts.input({
605
- message: 'Project name:',
606
- default: scan.projectName,
607
- });
608
- // Determine if workspace or single project (auto-detect when --from-scan)
609
- let isWorkspace = options.workspace;
610
- if (!isWorkspace && (scan.structure.type.startsWith('monorepo') || scan.structure.type === 'hybrid')) {
611
- if (nonInteractive || options.fromScan) {
612
- isWorkspace = true; // Default to workspace for monorepos
613
- }
614
- else {
615
- isWorkspace = await prompts.confirm({
616
- message: 'Detected monorepo/workspace structure. Configure as workspace?',
617
- default: true,
618
- });
619
- }
620
- }
621
- // Generate initial config (v2 format)
622
- const generator = new config_generator_1.ConfigGenerator();
623
- const generated = generator.generate(scan);
624
- // Convert to v4 format (declarative-first architecture)
625
- const v4Config = convertV2ToV4(generated.config, scan);
626
- // Update project name
627
- v4Config.project.name = projectName;
628
- // Environment configuration - do this BEFORE profiles so profiles can reference environments
629
- // (skip only in non-interactive mode, always show for --from-scan since environments are required)
630
- if (!nonInteractive) {
631
- const envResult = await setupEnvironments(scan, v4Config, isMultiRepoStructure, existingEnvValues);
632
- if (envResult.environments) {
633
- v4Config.environments = envResult.environments;
634
- }
635
- // Add collected database URLs to envVarsToAdd (for .env.genbox)
636
- Object.assign(envVarsToAdd, envResult.databaseUrls);
637
- }
638
- // Ask about profiles (skip prompt when using --from-scan)
639
- let createProfiles = true;
640
- if (!nonInteractive && !options.fromScan) {
641
- createProfiles = await prompts.confirm({
642
- message: 'Create predefined profiles for common scenarios?',
643
- default: true,
644
- });
645
- }
646
- if (createProfiles) {
647
- v4Config.profiles = (nonInteractive || options.fromScan)
648
- ? createDefaultProfilesSync(scan, v4Config)
649
- : await createDefaultProfiles(scan, v4Config);
650
- }
651
- // Get server size (use defaults when --from-scan)
652
- const serverSize = (nonInteractive || options.fromScan)
653
- ? generated.config.system.size
654
- : await prompts.select({
655
- message: 'Default server size:',
656
- choices: [
657
- { name: 'Small - 2 CPU, 4GB RAM', value: 'small' },
658
- { name: 'Medium - 4 CPU, 8GB RAM', value: 'medium' },
659
- { name: 'Large - 8 CPU, 16GB RAM', value: 'large' },
660
- { name: 'XL - 16 CPU, 32GB RAM', value: 'xl' },
661
- ],
662
- default: generated.config.system.size,
663
- });
664
- if (!v4Config.defaults) {
665
- v4Config.defaults = {};
666
- }
667
- v4Config.defaults.size = serverSize;
668
- // Ask about Claude Code installation
669
- if (!nonInteractive && !options.fromScan) {
670
- const installClaudeCode = await prompts.confirm({
671
- message: 'Install Claude Code CLI on genbox servers?',
672
- default: true,
673
- });
674
- if (installClaudeCode) {
675
- v4Config.defaults.install_claude_code = true;
676
- }
677
- }
678
- // Get default/base branch (source branch for creating new environment branches)
679
- const detectedBranch = scan.git?.branch || 'main';
680
- let defaultBranch = detectedBranch;
681
- if (!nonInteractive && !options.fromScan) {
682
- console.log('');
683
- console.log(chalk_1.default.dim(' When creating environments, a new branch is created from this base branch.'));
684
- console.log(chalk_1.default.dim(' The new branch name defaults to the environment name.'));
685
- const branchInput = await prompts.input({
686
- message: 'Base branch for new environment branches:',
687
- default: detectedBranch,
688
- });
689
- defaultBranch = branchInput || detectedBranch;
690
- }
691
- // Store default branch in config defaults
692
- if (defaultBranch && defaultBranch !== 'main') {
693
- v4Config.defaults.branch = defaultBranch;
694
- }
695
- // Git repository setup - different handling for multi-repo vs single-repo
696
- // When using --from-scan, skip git selection and use what's in detected.yaml
697
- const isMultiRepo = isMultiRepoStructure;
698
- if (options.fromScan) {
699
- // When using --from-scan, extract git repos from detected.yaml apps
700
- const detectedPath = path_1.default.join(process.cwd(), '.genbox', 'detected.yaml');
701
- let detectedConfig = null;
702
- try {
703
- const content = fs_1.default.readFileSync(detectedPath, 'utf8');
704
- detectedConfig = yaml.load(content);
705
- }
706
- catch { }
707
- // Check for per-app git repos first (multi-repo workspace)
708
- const appsWithGit = detectedConfig
709
- ? Object.entries(detectedConfig.apps).filter(([, app]) => app.git)
710
- : [];
711
- if (appsWithGit.length > 0) {
712
- // Multi-repo: use per-app git repos
713
- console.log('');
714
- console.log(chalk_1.default.blue('=== Git Repositories (from detected.yaml) ==='));
715
- console.log(chalk_1.default.dim(`Found ${appsWithGit.length} repositories`));
716
- v4Config.repos = {};
717
- let hasHttpsRepos = false;
718
- for (const [appName, app] of appsWithGit) {
719
- const git = app.git;
720
- v4Config.repos[appName] = {
721
- url: git.remote,
722
- path: `/home/dev/${projectName}/${app.path}`,
723
- branch: git.branch !== 'main' && git.branch !== 'master' ? git.branch : undefined,
724
- auth: git.type === 'ssh' ? 'ssh' : 'token',
725
- };
726
- console.log(` ${chalk_1.default.cyan(appName)}: ${git.remote}`);
727
- if (git.type === 'https') {
728
- hasHttpsRepos = true;
729
- }
730
- }
731
- // Prompt for GIT_TOKEN if any HTTPS repos are found
732
- if (hasHttpsRepos && !nonInteractive) {
733
- console.log('');
734
- console.log(chalk_1.default.yellow('Private repositories require a GitHub token for cloning.'));
735
- const gitToken = await promptForSecret('GitHub Personal Access Token (leave empty to skip):', existingEnvValues['GIT_TOKEN'], { showInstructions: !existingEnvValues['GIT_TOKEN'] });
736
- if (gitToken) {
737
- envVarsToAdd['GIT_TOKEN'] = gitToken;
738
- console.log(chalk_1.default.green('✓ GIT_TOKEN will be added to .env.genbox'));
739
- }
740
- else {
741
- console.log(chalk_1.default.dim(' Skipped - add GIT_TOKEN to .env.genbox later if needed'));
742
- }
743
- }
744
- }
745
- else if (scan.git) {
746
- // Single repo or monorepo with root git
747
- const repoName = path_1.default.basename(scan.git.remote, '.git').replace(/.*[:/]/, '');
748
- v4Config.repos = {
749
- [repoName]: {
750
- url: scan.git.remote,
751
- path: `/home/dev/${projectName}`,
752
- auth: scan.git.type === 'ssh' ? 'ssh' : 'token',
753
- },
754
- };
755
- console.log(chalk_1.default.dim(` Git: Using ${repoName} from detected.yaml`));
756
- // Prompt for GIT_TOKEN if HTTPS
757
- if (scan.git.type === 'https' && !nonInteractive) {
758
- console.log('');
759
- console.log(chalk_1.default.yellow('Private repositories require a GitHub token for cloning.'));
760
- const gitToken = await promptForSecret('GitHub Personal Access Token (leave empty to skip):', existingEnvValues['GIT_TOKEN'], { showInstructions: !existingEnvValues['GIT_TOKEN'] });
761
- if (gitToken) {
762
- envVarsToAdd['GIT_TOKEN'] = gitToken;
763
- console.log(chalk_1.default.green('✓ GIT_TOKEN will be added to .env.genbox'));
764
- }
765
- }
766
- }
767
- }
768
- else if (isMultiRepo) {
769
- // Multi-repo workspace: detect git repos in app directories
770
- const appGitRepos = detectAppGitRepos(scan.apps, process.cwd());
771
- if (appGitRepos.length > 0 && !nonInteractive) {
772
- console.log('');
773
- console.log(chalk_1.default.blue('=== Git Repositories ==='));
774
- console.log(chalk_1.default.dim(`Found ${appGitRepos.length} git repositories in app directories`));
775
- const repoChoices = appGitRepos.map(repo => ({
776
- name: `${repo.appName} - ${repo.remote}`,
777
- value: repo.appName,
778
- checked: true, // Default to include all
779
- }));
780
- const selectedRepos = await prompts.checkbox({
781
- message: 'Select repositories to include:',
782
- choices: repoChoices,
783
- });
784
- if (selectedRepos.length > 0) {
785
- v4Config.repos = {};
786
- let hasHttpsRepos = false;
787
- for (const repoName of selectedRepos) {
788
- const repo = appGitRepos.find(r => r.appName === repoName);
789
- v4Config.repos[repo.appName] = {
790
- url: repo.remote,
791
- path: `/home/dev/${projectName}/${repo.appPath}`,
792
- branch: repo.branch !== 'main' && repo.branch !== 'master' ? repo.branch : undefined,
793
- auth: repo.type === 'ssh' ? 'ssh' : 'token',
794
- };
795
- if (repo.type === 'https') {
796
- hasHttpsRepos = true;
797
- }
798
- }
799
- // Prompt for GIT_TOKEN if any HTTPS repos are selected
800
- if (hasHttpsRepos) {
801
- console.log('');
802
- console.log(chalk_1.default.yellow('Private repositories require a GitHub token for cloning.'));
803
- const gitToken = await promptForSecret('GitHub Personal Access Token (leave empty to skip):', existingEnvValues['GIT_TOKEN'], { showInstructions: !existingEnvValues['GIT_TOKEN'] });
804
- if (gitToken) {
805
- envVarsToAdd['GIT_TOKEN'] = gitToken;
806
- console.log(chalk_1.default.green('✓ GIT_TOKEN will be added to .env.genbox'));
807
- }
808
- else {
809
- console.log(chalk_1.default.dim(' Skipped - add GIT_TOKEN to .env.genbox later if needed'));
810
- }
811
- }
812
- }
813
- }
814
- else if (appGitRepos.length > 0) {
815
- // Non-interactive: include all repos
816
- v4Config.repos = {};
817
- for (const repo of appGitRepos) {
818
- v4Config.repos[repo.appName] = {
819
- url: repo.remote,
820
- path: `/home/dev/${projectName}/${repo.appPath}`,
821
- auth: repo.type === 'ssh' ? 'ssh' : 'token',
822
- };
823
- }
824
- }
825
- }
826
- else if (scan.git) {
827
- // Single repo or monorepo with root git
828
- if (nonInteractive) {
829
- const repoName = path_1.default.basename(scan.git.remote, '.git').replace(/.*[:/]/, '');
830
- v4Config.repos = {
831
- [repoName]: {
832
- url: scan.git.remote,
833
- path: `/home/dev/${repoName}`,
834
- auth: scan.git.type === 'ssh' ? 'ssh' : 'token',
835
- },
836
- };
837
- }
838
- else {
839
- const gitConfig = await setupGitAuth(scan.git, projectName);
840
- if (gitConfig.repos) {
841
- v4Config.repos = gitConfig.repos;
842
- }
843
- }
844
- }
845
- else if (!nonInteractive && !isMultiRepo) {
846
- // Only ask to add repo for non-multi-repo projects
847
- const addRepo = await prompts.confirm({
848
- message: 'No git remote detected. Add a repository?',
849
- default: false,
850
- });
851
- if (addRepo) {
852
- const repoUrl = await prompts.input({
853
- message: 'Repository URL (HTTPS recommended):',
854
- validate: (value) => {
855
- if (!value)
856
- return 'Repository URL is required';
857
- if (!value.startsWith('git@') && !value.startsWith('https://')) {
858
- return 'Enter a valid git URL';
859
- }
860
- return true;
861
- },
862
- });
863
- const repoName = path_1.default.basename(repoUrl, '.git');
864
- v4Config.repos = {
865
- [repoName]: {
866
- url: repoUrl,
867
- path: `/home/dev/${repoName}`,
868
- auth: repoUrl.startsWith('git@') ? 'ssh' : 'token',
869
- },
870
- };
871
- }
872
- }
873
- // Script selection - always show multi-select UI (skip in non-interactive mode and --from-scan)
874
- if (!nonInteractive && !options.fromScan) {
875
- // Scan for scripts
876
- const scriptsSpinner = (0, ora_1.default)('Scanning for scripts...').start();
877
- const fullScan = await scanner.scan(process.cwd(), { exclude, skipScripts: false });
878
- scriptsSpinner.stop();
879
- if (fullScan.scripts.length > 0) {
880
- console.log('');
881
- console.log(chalk_1.default.blue('=== Setup Scripts ==='));
882
- // Group scripts by directory for display
883
- const scriptsByDir = new Map();
884
- for (const script of fullScan.scripts) {
885
- const dir = script.path.includes('/') ? script.path.split('/')[0] : '(root)';
886
- const existing = scriptsByDir.get(dir) || [];
887
- existing.push(script);
888
- scriptsByDir.set(dir, existing);
889
- }
890
- // Show grouped scripts
891
- for (const [dir, scripts] of scriptsByDir) {
892
- console.log(chalk_1.default.dim(` ${dir}/ (${scripts.length} scripts)`));
893
- }
894
- // Let user select scripts with multi-select
895
- const scriptChoices = fullScan.scripts.map(s => ({
896
- name: `${s.path} (${s.stage})`,
897
- value: s.path,
898
- checked: s.path.startsWith('scripts/'), // Default select scripts/ directory
899
- }));
900
- const selectedScripts = await prompts.checkbox({
901
- message: 'Select scripts to include (space to toggle, enter to confirm):',
902
- choices: scriptChoices,
903
- });
904
- if (selectedScripts.length > 0) {
905
- v4Config.scripts = fullScan.scripts
906
- .filter(s => selectedScripts.includes(s.path))
907
- .map(s => ({
908
- name: s.name,
909
- path: s.path,
910
- stage: s.stage,
911
- }));
912
- }
913
- }
914
- else {
915
- console.log(chalk_1.default.dim('No scripts found.'));
916
- }
917
- }
918
- // Save configuration
919
- const yamlContent = yaml.dump(v4Config, {
920
- lineWidth: 120,
921
- noRefs: true,
922
- quotingType: '"',
923
- });
924
- fs_1.default.writeFileSync(configPath, yamlContent);
925
- console.log(chalk_1.default.green(`\n✔ Configuration saved to ${CONFIG_FILENAME}`));
926
- // Add API URLs from environments to envVarsToAdd
927
- // Always add LOCAL_API_URL for local development
928
- envVarsToAdd['LOCAL_API_URL'] = 'http://localhost:3050';
929
- if (v4Config.environments) {
930
- for (const [envName, envConfig] of Object.entries(v4Config.environments)) {
931
- // v4 format: urls.api contains the API URL
932
- const apiUrl = envConfig.urls?.api || envConfig.urls?.gateway;
933
- if (apiUrl) {
934
- const varName = `${envName.toUpperCase()}_API_URL`;
935
- envVarsToAdd[varName] = apiUrl;
936
- }
937
- }
938
- }
939
- // Load detected service URLs if using --from-scan
940
- let detectedServiceUrls;
941
- if (options.fromScan) {
942
- const detectedPath = path_1.default.join(process.cwd(), '.genbox', 'detected.yaml');
943
- try {
944
- const content = fs_1.default.readFileSync(detectedPath, 'utf8');
945
- const detectedConfig = yaml.load(content);
946
- detectedServiceUrls = detectedConfig.service_urls;
947
- }
948
- catch { }
949
- }
950
- // Generate .env.genbox
951
- await setupEnvFile(projectName, v4Config, nonInteractive, scan, isMultiRepo, envVarsToAdd, overwriteExisting, detectedServiceUrls);
952
- // Show warnings
953
- if (generated.warnings.length > 0) {
954
- console.log('');
955
- console.log(chalk_1.default.yellow('Warnings:'));
956
- for (const warning of generated.warnings) {
957
- console.log(chalk_1.default.dim(` - ${warning}`));
958
- }
959
- }
960
- // CORS Configuration Warning
961
- const hasBackendApps = scan.apps.some(a => a.type === 'backend' || a.type === 'api');
962
- const hasFrontendApps = scan.apps.some(a => a.type === 'frontend');
963
- if (hasBackendApps && hasFrontendApps) {
964
- console.log('');
965
- console.log(chalk_1.default.yellow('=== CORS Configuration Required ==='));
966
- console.log(chalk_1.default.white('To use genbox environments, add .genbox.dev to your backend CORS config:'));
967
- console.log('');
968
- console.log(chalk_1.default.dim(' NestJS (main.ts):'));
969
- console.log(chalk_1.default.cyan(` app.enableCors({`));
970
- console.log(chalk_1.default.cyan(` origin: [/\\.genbox\\.dev$/, ...otherOrigins],`));
971
- console.log(chalk_1.default.cyan(` credentials: true,`));
972
- console.log(chalk_1.default.cyan(` });`));
973
- console.log('');
974
- console.log(chalk_1.default.dim(' Express:'));
975
- console.log(chalk_1.default.cyan(` app.use(cors({ origin: /\\.genbox\\.dev$/ }));`));
976
- console.log('');
977
- console.log(chalk_1.default.dim(' Or use env var: CORS_ORIGINS=*.genbox.dev'));
978
- console.log('');
979
- console.log(chalk_1.default.red(' Without this, you will see CORS errors when accessing genbox environments.'));
980
- // OAuth Configuration Instructions
981
- console.log('');
982
- console.log(chalk_1.default.yellow('=== OAuth Provider Configuration ==='));
983
- console.log(chalk_1.default.white('If your app uses OAuth (Google, GitHub, etc.), add *.genbox.dev to allowed domains:'));
984
- console.log('');
985
- console.log(chalk_1.default.dim(' Google OAuth (console.cloud.google.com):'));
986
- console.log(chalk_1.default.cyan(' Authorized JavaScript origins:'));
987
- console.log(chalk_1.default.cyan(' https://*.genbox.dev'));
988
- console.log(chalk_1.default.cyan(' Authorized redirect URIs:'));
989
- console.log(chalk_1.default.cyan(' https://*.genbox.dev/api/auth/callback/google'));
990
- console.log(chalk_1.default.cyan(' https://*.genbox.dev/auth/google/callback'));
991
- console.log('');
992
- console.log(chalk_1.default.dim(' GitHub OAuth (github.com/settings/developers):'));
993
- console.log(chalk_1.default.cyan(' Authorization callback URL:'));
994
- console.log(chalk_1.default.cyan(' https://*.genbox.dev/api/auth/callback/github'));
995
- console.log('');
996
- console.log(chalk_1.default.dim(' Other providers (Auth0, Clerk, etc.):'));
997
- console.log(chalk_1.default.cyan(' Add *.genbox.dev to allowed callback URLs/origins'));
998
- console.log('');
999
- console.log(chalk_1.default.red(' Without this, OAuth flows will fail in genbox environments.'));
1000
- }
1001
- // Next steps
1002
- console.log('');
1003
- console.log(chalk_1.default.bold('Next steps:'));
1004
- console.log(chalk_1.default.dim(` 1. Review and edit ${CONFIG_FILENAME}`));
1005
- if (hasBackendApps && hasFrontendApps) {
1006
- console.log(chalk_1.default.yellow(` 2. Add .genbox.dev to your backend CORS configuration`));
1007
- console.log(chalk_1.default.yellow(` 3. Add *.genbox.dev to OAuth providers (Google, GitHub, etc.) if using auth`));
1008
- console.log(chalk_1.default.dim(` 4. Update ${ENV_FILENAME} to use API URL variables where needed`));
1009
- console.log(chalk_1.default.dim(` 5. Run 'genbox profiles' to see available profiles`));
1010
- console.log(chalk_1.default.dim(` 6. Run 'genbox create <name> --profile <profile>' to create an environment`));
1011
- }
1012
- else {
1013
- console.log(chalk_1.default.dim(` 2. Update ${ENV_FILENAME} to use API URL variables where needed`));
1014
- console.log(chalk_1.default.dim(` 3. Add *.genbox.dev to OAuth providers if using auth`));
1015
- console.log(chalk_1.default.dim(` 4. Run 'genbox profiles' to see available profiles`));
1016
- console.log(chalk_1.default.dim(` 5. Run 'genbox create <name> --profile <profile>' to create an environment`));
1017
- }
1018
- }
1019
- catch (error) {
1020
- if (error.name === 'ExitPromptError' || error.message?.includes('force closed')) {
1021
- console.log('');
1022
- console.log(chalk_1.default.dim('Cancelled.'));
1023
- process.exit(0);
1024
- }
1025
- throw error;
1026
587
  }
1027
- });
588
+ return { repos, envVars };
589
+ }
590
+ // =============================================================================
591
+ // Script Selection
592
+ // =============================================================================
1028
593
  /**
1029
- * Create default profiles (sync version for non-interactive mode)
594
+ * Interactive script selection
1030
595
  */
1031
- function createDefaultProfilesSync(scan, config) {
1032
- const definedEnvs = Object.keys(config.environments || {});
1033
- return createProfilesFromScan(scan, definedEnvs);
1034
- }
1035
- async function createDefaultProfiles(scan, config) {
1036
- // Get defined environments to use in profiles
1037
- const definedEnvs = Object.keys(config.environments || {});
1038
- return createProfilesFromScan(scan, definedEnvs);
1039
- }
1040
- function createProfilesFromScan(scan, definedEnvironments = []) {
1041
- const profiles = {};
1042
- const frontendApps = scan.apps.filter(a => a.type === 'frontend');
1043
- const backendApps = scan.apps.filter(a => a.type === 'backend' || a.type === 'api');
1044
- // Docker services are also runnable (api, web services from docker-compose)
1045
- const dockerServices = scan.compose?.applications || [];
1046
- const dockerServiceNames = dockerServices.map(s => s.name);
1047
- // Combine backend apps + Docker services (avoid duplicates)
1048
- const allBackendNames = new Set([
1049
- ...backendApps.map(a => a.name),
1050
- ...dockerServiceNames.filter(name => {
1051
- // Include Docker services that aren't already in apps OR aren't frontend
1052
- const existingApp = scan.apps.find(a => a.name === name);
1053
- return !existingApp || existingApp.type !== 'frontend';
1054
- }),
1055
- ]);
1056
- const backendAppNames = Array.from(allBackendNames);
1057
- const hasBackend = backendAppNames.length > 0;
1058
- // Determine which environment to use for remote connections
1059
- // Priority: staging > production > first defined
1060
- const remoteEnv = definedEnvironments.includes('staging')
1061
- ? 'staging'
1062
- : definedEnvironments.includes('production')
1063
- ? 'production'
1064
- : definedEnvironments[0];
1065
- // Quick UI profiles for each frontend (v4: use default_connection instead of connect_to)
1066
- // Only create if we have a remote environment defined
1067
- if (remoteEnv) {
1068
- for (const frontend of frontendApps.slice(0, 3)) {
1069
- profiles[`${frontend.name}-quick`] = {
1070
- description: `${frontend.name} only, connected to ${remoteEnv}`,
1071
- size: 'small',
1072
- apps: [frontend.name],
1073
- default_connection: remoteEnv,
1074
- };
1075
- }
1076
- }
1077
- else {
1078
- // No remote environment - create local-only profiles
1079
- for (const frontend of frontendApps.slice(0, 3)) {
1080
- profiles[`${frontend.name}-local`] = {
1081
- description: `${frontend.name} with local backend`,
1082
- size: 'medium',
1083
- apps: [frontend.name, ...backendAppNames],
1084
- };
1085
- }
1086
- }
1087
- // Full local development (with DB copy from available environment)
1088
- if (hasBackend && frontendApps.length > 0) {
1089
- const primaryFrontend = frontendApps[0];
1090
- const dbSource = remoteEnv || 'staging'; // Use defined env or default
1091
- profiles[`${primaryFrontend.name}-full`] = {
1092
- description: `${primaryFrontend.name} + local backend + DB copy`,
1093
- size: 'large',
1094
- apps: [primaryFrontend.name, ...backendAppNames],
1095
- database: {
1096
- mode: remoteEnv ? 'copy' : 'local', // Only copy if we have a source
1097
- ...(remoteEnv && { source: dbSource }),
1098
- },
1099
- };
596
+ async function selectScripts(detected) {
597
+ if (!detected.scripts || detected.scripts.length === 0) {
598
+ console.log(chalk_1.default.dim(' No scripts detected.'));
599
+ return detected;
1100
600
  }
1101
- // Backend/service development only (for each backend app and Docker service)
1102
- for (const backendName of backendAppNames) {
1103
- profiles[`${backendName}-dev`] = {
1104
- description: `${backendName} with local infrastructure`,
1105
- size: 'medium',
1106
- apps: [backendName],
1107
- database: {
1108
- mode: 'local',
1109
- },
1110
- };
1111
- }
1112
- // All frontends + remote backend (only if remote env defined)
1113
- if (frontendApps.length > 1 && remoteEnv) {
1114
- profiles[`frontends-${remoteEnv}`] = {
1115
- description: `All frontends with ${remoteEnv} backend`,
1116
- size: 'medium',
1117
- apps: frontendApps.map(a => a.name),
1118
- default_connection: remoteEnv,
1119
- };
601
+ console.log('');
602
+ console.log(chalk_1.default.blue('=== Setup Scripts ==='));
603
+ console.log('');
604
+ // Group scripts by directory
605
+ const scriptsByDir = new Map();
606
+ for (const script of detected.scripts) {
607
+ const dir = script.path.includes('/') ? script.path.split('/')[0] : '(root)';
608
+ const existing = scriptsByDir.get(dir) || [];
609
+ existing.push(script);
610
+ scriptsByDir.set(dir, existing);
1120
611
  }
1121
- // Full stack - combine apps + Docker services (deduplicated)
1122
- const allRunnableNames = new Set([
1123
- ...scan.apps.filter(a => a.type !== 'library').map(a => a.name),
1124
- ...dockerServiceNames,
1125
- ]);
1126
- if (allRunnableNames.size > 1) {
1127
- const dbSource = remoteEnv || 'staging';
1128
- profiles['full-stack'] = {
1129
- description: 'Everything local' + (remoteEnv ? ' with DB copy' : ''),
1130
- size: 'xl',
1131
- apps: Array.from(allRunnableNames),
1132
- database: {
1133
- mode: remoteEnv ? 'copy' : 'local',
1134
- ...(remoteEnv && { source: dbSource }),
1135
- },
1136
- };
612
+ for (const [dir, scripts] of scriptsByDir) {
613
+ console.log(chalk_1.default.dim(` ${dir}/ (${scripts.length} scripts)`));
1137
614
  }
1138
- return profiles;
1139
- }
1140
- /**
1141
- * Setup git authentication
1142
- */
1143
- async function setupGitAuth(gitInfo, projectName) {
1144
615
  console.log('');
1145
- console.log(chalk_1.default.blue('=== Git Repository Setup ==='));
1146
- console.log(chalk_1.default.dim(`Detected: ${gitInfo.remote}`));
1147
- const authMethod = await prompts.select({
1148
- message: 'How should genbox access this repository?',
1149
- choices: [
1150
- { name: 'Personal Access Token (PAT) - recommended', value: 'token' },
1151
- { name: 'SSH Key', value: 'ssh' },
1152
- { name: 'Public (no auth needed)', value: 'public' },
1153
- ],
1154
- default: 'token',
616
+ const scriptChoices = detected.scripts.map(s => ({
617
+ name: `${s.path} (${s.stage})`,
618
+ value: s.path,
619
+ checked: s.path.startsWith('scripts/'),
620
+ }));
621
+ const selectedScripts = await prompts.checkbox({
622
+ message: 'Select scripts to include:',
623
+ choices: scriptChoices,
1155
624
  });
1156
- let repoUrl = gitInfo.remote;
1157
- if (authMethod === 'token') {
1158
- // Convert SSH to HTTPS if needed
1159
- if (gitInfo.type === 'ssh') {
1160
- repoUrl = (0, scan_1.sshToHttps)(gitInfo.remote);
1161
- console.log(chalk_1.default.dim(` Will use HTTPS: ${repoUrl}`));
1162
- }
1163
- // Show token setup instructions
1164
- console.log('');
1165
- console.log(chalk_1.default.yellow(' Add your token to .env.genbox:'));
1166
- console.log(chalk_1.default.white(' GIT_TOKEN=ghp_xxxxxxxxxxxx'));
1167
- }
1168
- else if (authMethod === 'ssh') {
1169
- // Convert HTTPS to SSH if needed
1170
- if (gitInfo.type === 'https') {
1171
- repoUrl = (0, scan_1.httpsToSsh)(gitInfo.remote);
1172
- console.log(chalk_1.default.dim(` Will use SSH: ${repoUrl}`));
1173
- }
1174
- // Detect SSH keys
1175
- const scanKeys = await prompts.confirm({
1176
- message: 'Scan ~/.ssh/ for available keys?',
1177
- default: true,
1178
- });
1179
- let sshKeyPath = path_1.default.join(os.homedir(), '.ssh', 'id_ed25519');
1180
- if (scanKeys) {
1181
- const keys = (0, scan_1.detectSshKeys)();
1182
- if (keys.length > 0) {
1183
- const keyChoices = keys.map((k) => ({
1184
- name: `${k.name} (${k.type})`,
1185
- value: k.path,
1186
- }));
1187
- sshKeyPath = await prompts.select({
1188
- message: 'Select SSH key:',
1189
- choices: keyChoices,
1190
- });
1191
- }
1192
- }
1193
- console.log('');
1194
- console.log(chalk_1.default.yellow(' Add your SSH key to .env.genbox:'));
1195
- console.log(chalk_1.default.white(` GIT_SSH_KEY="$(cat ${sshKeyPath})"`));
1196
- }
1197
- const repoName = path_1.default.basename(repoUrl, '.git');
1198
625
  return {
1199
- repos: {
1200
- [repoName]: {
1201
- url: repoUrl,
1202
- path: `/home/dev/${repoName}`,
1203
- auth: authMethod === 'public' ? undefined : authMethod,
1204
- },
1205
- },
626
+ ...detected,
627
+ scripts: detected.scripts.filter(s => selectedScripts.includes(s.path)),
1206
628
  };
1207
629
  }
630
+ // =============================================================================
631
+ // Environment + Service URL Configuration (Combined)
632
+ // =============================================================================
1208
633
  /**
1209
- * Setup staging/production environments (v4 format)
634
+ * Combined environment and service URL configuration
1210
635
  */
1211
- async function setupEnvironments(scan, config, isMultiRepo = false, existingEnvValues = {}) {
1212
- const databaseUrls = {};
1213
- // First ask which environments they want to configure
636
+ async function setupEnvironmentsAndServiceUrls(detected, existingEnvValues) {
637
+ const envVars = {};
638
+ let environments = {};
639
+ console.log('');
640
+ console.log(chalk_1.default.blue('=== Environment Configuration ==='));
641
+ console.log('');
642
+ // Which environments to configure
1214
643
  const envChoice = await prompts.select({
1215
644
  message: 'Which environments do you want to configure?',
1216
645
  choices: [
1217
- { name: 'Staging only', value: 'staging', description: 'Connect to staging API' },
1218
- { name: 'Production only', value: 'production', description: 'Connect to production API' },
1219
- { name: 'Both staging and production', value: 'both', description: 'Configure both environments' },
1220
- { name: 'Skip for now', value: 'skip', description: 'No remote environments' },
646
+ { name: 'Staging only', value: 'staging' },
647
+ { name: 'Production only', value: 'production' },
648
+ { name: 'Both staging and production', value: 'both' },
649
+ { name: 'Skip for now', value: 'skip' },
1221
650
  ],
1222
651
  default: 'staging',
1223
652
  });
1224
- if (envChoice === 'skip') {
1225
- return { environments: undefined, databaseUrls };
1226
- }
1227
- console.log('');
1228
- console.log(chalk_1.default.blue('=== Environment Setup ==='));
1229
- console.log(chalk_1.default.dim('These URLs will be used when connecting to external services.'));
1230
- console.log(chalk_1.default.dim('Database URLs are stored in .env.genbox for database copy operations.'));
1231
- const environments = {};
1232
- // Get existing URLs if available
1233
- const existingStagingApiUrl = existingEnvValues['STAGING_API_URL'];
1234
- const existingProductionApiUrl = existingEnvValues['PRODUCTION_API_URL'] || existingEnvValues['PROD_API_URL'];
1235
- const existingStagingMongoUrl = existingEnvValues['STAGING_MONGODB_URL'];
1236
- const existingProdMongoUrl = existingEnvValues['PROD_MONGODB_URL'] || existingEnvValues['PRODUCTION_MONGODB_URL'];
1237
- const configureStaging = envChoice === 'staging' || envChoice === 'both';
1238
- const configureProduction = envChoice === 'production' || envChoice === 'both';
1239
- // Configure staging if selected
1240
- if (configureStaging) {
653
+ if (envChoice !== 'skip') {
1241
654
  console.log('');
1242
- console.log(chalk_1.default.cyan('Staging Environment:'));
1243
- if (isMultiRepo) {
1244
- const backendApps = scan.apps.filter(a => a.type === 'backend' || a.type === 'api');
1245
- if (backendApps.length > 0) {
1246
- const urls = {};
1247
- for (const app of backendApps) {
1248
- const existingUrl = existingEnvValues[`STAGING_${app.name.toUpperCase()}_URL`] ||
1249
- (app.name === 'api' ? existingStagingApiUrl : '');
1250
- if (existingUrl) {
1251
- console.log(chalk_1.default.dim(` Found existing value for ${app.name}: ${existingUrl}`));
1252
- const useExisting = await prompts.confirm({
1253
- message: ` Use existing ${app.name} staging URL?`,
1254
- default: true,
1255
- });
1256
- if (useExisting) {
1257
- urls[app.name] = existingUrl;
1258
- continue;
1259
- }
1260
- }
1261
- const url = await prompts.input({
1262
- message: ` ${app.name} staging URL:`,
1263
- default: '',
1264
- });
1265
- if (url) {
1266
- urls[app.name] = url;
1267
- }
1268
- }
1269
- if (Object.keys(urls).length > 0) {
1270
- environments.staging = {
1271
- description: 'Staging environment',
1272
- urls,
1273
- };
1274
- }
1275
- }
1276
- else {
1277
- const stagingApiUrl = await promptForApiUrl('staging', existingStagingApiUrl);
1278
- if (stagingApiUrl) {
1279
- environments.staging = {
1280
- description: 'Staging environment',
1281
- urls: { api: stagingApiUrl },
1282
- };
1283
- }
1284
- }
1285
- }
1286
- else {
1287
- const stagingApiUrl = await promptForApiUrl('staging', existingStagingApiUrl);
655
+ console.log(chalk_1.default.dim('These URLs will be used when connecting to external services.'));
656
+ const configureStaging = envChoice === 'staging' || envChoice === 'both';
657
+ const configureProduction = envChoice === 'production' || envChoice === 'both';
658
+ if (configureStaging) {
659
+ console.log('');
660
+ console.log(chalk_1.default.cyan('Staging Environment:'));
661
+ const stagingApiUrl = await promptWithExisting(' Staging API URL:', existingEnvValues['STAGING_API_URL']);
1288
662
  if (stagingApiUrl) {
1289
663
  environments.staging = {
1290
664
  description: 'Staging environment',
1291
665
  urls: { api: stagingApiUrl },
1292
666
  };
667
+ envVars['STAGING_API_URL'] = stagingApiUrl;
1293
668
  }
1294
- }
1295
- // Prompt for staging database URL (for database copy operations)
1296
- console.log('');
1297
- console.log(chalk_1.default.dim(' Database URL (for "Copy from staging" database mode):'));
1298
- console.log(chalk_1.default.dim(' Format: mongodb+srv://user:password@staging.mongodb.net/dbname'));
1299
- if (existingStagingMongoUrl) {
1300
- console.log(chalk_1.default.dim(` Found existing value: ${existingStagingMongoUrl.substring(0, 50)}...`));
1301
- const useExisting = await prompts.confirm({
1302
- message: ' Use existing staging MongoDB URL?',
1303
- default: true,
1304
- });
1305
- if (useExisting) {
1306
- databaseUrls['STAGING_MONGODB_URL'] = existingStagingMongoUrl;
1307
- }
1308
- else {
1309
- const mongoUrl = await prompts.input({
1310
- message: ' Staging MongoDB URL:',
1311
- });
1312
- if (mongoUrl) {
1313
- databaseUrls['STAGING_MONGODB_URL'] = mongoUrl;
1314
- }
1315
- }
1316
- }
1317
- else {
1318
- const mongoUrl = await prompts.input({
1319
- message: ' Staging MongoDB URL (optional, press Enter to skip):',
1320
- });
1321
- if (mongoUrl) {
1322
- databaseUrls['STAGING_MONGODB_URL'] = mongoUrl;
1323
- }
1324
- }
1325
- }
1326
- // Configure production if selected
1327
- if (configureProduction) {
1328
- console.log('');
1329
- console.log(chalk_1.default.cyan('Production Environment:'));
1330
- if (isMultiRepo) {
1331
- const backendApps = scan.apps.filter(a => a.type === 'backend' || a.type === 'api');
1332
- if (backendApps.length > 0) {
1333
- const prodUrls = {};
1334
- for (const app of backendApps) {
1335
- const existingUrl = existingEnvValues[`PRODUCTION_${app.name.toUpperCase()}_URL`] ||
1336
- existingEnvValues[`PROD_${app.name.toUpperCase()}_URL`] ||
1337
- (app.name === 'api' ? existingProductionApiUrl : '');
1338
- if (existingUrl) {
1339
- console.log(chalk_1.default.dim(` Found existing value for ${app.name}: ${existingUrl}`));
1340
- const useExisting = await prompts.confirm({
1341
- message: ` Use existing ${app.name} production URL?`,
1342
- default: true,
1343
- });
1344
- if (useExisting) {
1345
- prodUrls[app.name] = existingUrl;
1346
- continue;
1347
- }
1348
- }
1349
- const url = await prompts.input({
1350
- message: ` ${app.name} production URL:`,
1351
- default: '',
1352
- });
1353
- if (url) {
1354
- prodUrls[app.name] = url;
1355
- }
1356
- }
1357
- if (Object.keys(prodUrls).length > 0) {
1358
- environments.production = {
1359
- description: 'Production (use with caution)',
1360
- urls: prodUrls,
1361
- safety: {
1362
- read_only: true,
1363
- require_confirmation: true,
1364
- },
1365
- };
1366
- }
1367
- }
1368
- else {
1369
- const prodApiUrl = await promptForApiUrl('production', existingProductionApiUrl);
1370
- if (prodApiUrl) {
1371
- environments.production = {
1372
- description: 'Production (use with caution)',
1373
- urls: { api: prodApiUrl },
1374
- safety: {
1375
- read_only: true,
1376
- require_confirmation: true,
1377
- },
1378
- };
1379
- }
669
+ const stagingMongoUrl = await promptWithExisting(' Staging MongoDB URL (optional):', existingEnvValues['STAGING_MONGODB_URL'], true);
670
+ if (stagingMongoUrl) {
671
+ envVars['STAGING_MONGODB_URL'] = stagingMongoUrl;
1380
672
  }
1381
673
  }
1382
- else {
1383
- const prodApiUrl = await promptForApiUrl('production', existingProductionApiUrl);
674
+ if (configureProduction) {
675
+ console.log('');
676
+ console.log(chalk_1.default.cyan('Production Environment:'));
677
+ const prodApiUrl = await promptWithExisting(' Production API URL:', existingEnvValues['PRODUCTION_API_URL'] || existingEnvValues['PROD_API_URL']);
1384
678
  if (prodApiUrl) {
1385
679
  environments.production = {
1386
680
  description: 'Production (use with caution)',
@@ -1390,613 +684,947 @@ async function setupEnvironments(scan, config, isMultiRepo = false, existingEnvV
1390
684
  require_confirmation: true,
1391
685
  },
1392
686
  };
687
+ envVars['PRODUCTION_API_URL'] = prodApiUrl;
688
+ }
689
+ const prodMongoUrl = await promptWithExisting(' Production MongoDB URL (optional):', existingEnvValues['PROD_MONGODB_URL'] || existingEnvValues['PRODUCTION_MONGODB_URL'], true);
690
+ if (prodMongoUrl) {
691
+ envVars['PROD_MONGODB_URL'] = prodMongoUrl;
1393
692
  }
1394
693
  }
1395
- // Prompt for production database URL (for database copy operations)
694
+ }
695
+ // Service URL detection and mapping
696
+ const serviceUrlMappings = await setupServiceUrls(detected, environments, existingEnvValues);
697
+ // Always add LOCAL_API_URL
698
+ envVars['LOCAL_API_URL'] = 'http://localhost:3050';
699
+ return {
700
+ environments: Object.keys(environments).length > 0 ? environments : undefined,
701
+ serviceUrlMappings,
702
+ envVars,
703
+ };
704
+ }
705
+ /**
706
+ * Setup service URL mappings for frontend apps
707
+ */
708
+ async function setupServiceUrls(detected, environments, existingEnvValues) {
709
+ const frontendApps = Object.entries(detected.apps)
710
+ .filter(([, app]) => app.type === 'frontend')
711
+ .map(([name]) => name);
712
+ if (frontendApps.length === 0) {
713
+ return [];
714
+ }
715
+ // Scan env files for service URLs
716
+ const serviceUrls = scanEnvFilesForUrls(detected.apps, detected._meta.scanned_root);
717
+ if (serviceUrls.length === 0) {
718
+ return [];
719
+ }
720
+ console.log('');
721
+ console.log(chalk_1.default.blue('=== Service URL Configuration ==='));
722
+ console.log(chalk_1.default.dim('Detected local service URLs in frontend env files:'));
723
+ console.log('');
724
+ for (const svc of serviceUrls) {
725
+ console.log(` ${chalk_1.default.cyan(svc.base_url)}`);
726
+ 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` : ''}`));
727
+ }
728
+ console.log('');
729
+ // Let user select which to configure
730
+ const urlChoices = serviceUrls.map(svc => ({
731
+ name: `${svc.base_url} (${svc.used_by.length} var${svc.used_by.length > 1 ? 's' : ''})`,
732
+ value: svc.base_url,
733
+ checked: true,
734
+ }));
735
+ const selectedUrls = await prompts.checkbox({
736
+ message: 'Select service URLs to configure for remote environments:',
737
+ choices: urlChoices,
738
+ });
739
+ const selectedServices = serviceUrls.filter(svc => selectedUrls.includes(svc.base_url));
740
+ const mappings = [];
741
+ // Determine primary remote environment
742
+ const envNames = Object.keys(environments || {});
743
+ const primaryEnv = envNames.includes('staging') ? 'staging' :
744
+ envNames.includes('production') ? 'production' :
745
+ envNames[0];
746
+ const apiUrl = primaryEnv && environments?.[primaryEnv]?.urls?.api;
747
+ if (selectedServices.length > 0 && primaryEnv) {
1396
748
  console.log('');
1397
- console.log(chalk_1.default.dim(' Database URL (for "Copy from production" database mode):'));
1398
- console.log(chalk_1.default.dim(' Format: mongodb+srv://readonly:password@prod.mongodb.net/dbname'));
1399
- if (existingProdMongoUrl) {
1400
- console.log(chalk_1.default.dim(` Found existing value: ${existingProdMongoUrl.substring(0, 50)}...`));
1401
- const useExisting = await prompts.confirm({
1402
- message: ' Use existing production MongoDB URL?',
1403
- default: true,
1404
- });
1405
- if (useExisting) {
1406
- databaseUrls['PROD_MONGODB_URL'] = existingProdMongoUrl;
749
+ console.log(chalk_1.default.dim(`Mapping selected URLs to ${primaryEnv} environment:`));
750
+ for (const svc of selectedServices) {
751
+ const serviceInfo = getServiceInfoFromUrl(svc.base_url);
752
+ // Auto-map API/gateway URLs
753
+ const isApiService = serviceInfo.name === 'gateway' ||
754
+ svc.base_url.includes(':3050') ||
755
+ svc.used_by.some(v => v.toLowerCase().includes('api'));
756
+ let remoteUrl = '';
757
+ if (isApiService && apiUrl) {
758
+ remoteUrl = apiUrl;
759
+ console.log(chalk_1.default.green(` ✓ ${svc.base_url} → ${apiUrl}`));
1407
760
  }
1408
761
  else {
1409
- const mongoUrl = await prompts.input({
1410
- message: ' Production MongoDB URL:',
762
+ const inputUrl = await prompts.input({
763
+ message: ` ${svc.base_url} (${primaryEnv} URL, leave empty to skip):`,
764
+ default: '',
1411
765
  });
1412
- if (mongoUrl) {
1413
- databaseUrls['PROD_MONGODB_URL'] = mongoUrl;
766
+ remoteUrl = inputUrl;
767
+ if (remoteUrl) {
768
+ console.log(chalk_1.default.green(` ✓ Mapped to ${remoteUrl}`));
1414
769
  }
1415
770
  }
1416
- }
1417
- else {
1418
- const mongoUrl = await prompts.input({
1419
- message: ' Production MongoDB URL (optional, press Enter to skip):',
771
+ mappings.push({
772
+ varName: `${serviceInfo.varPrefix}_URL`,
773
+ localUrl: svc.base_url,
774
+ remoteUrl: remoteUrl || undefined,
775
+ remoteEnv: remoteUrl ? primaryEnv : undefined,
776
+ description: serviceInfo.description,
1420
777
  });
1421
- if (mongoUrl) {
1422
- databaseUrls['PROD_MONGODB_URL'] = mongoUrl;
1423
- }
1424
778
  }
1425
779
  }
1426
- return {
1427
- environments: Object.keys(environments).length > 0 ? environments : undefined,
1428
- databaseUrls,
1429
- };
780
+ return mappings;
1430
781
  }
782
+ // =============================================================================
783
+ // Profile Generation and Editing
784
+ // =============================================================================
1431
785
  /**
1432
- * Prompt for a single API URL with existing value support
786
+ * Generate and allow editing of profiles
1433
787
  */
1434
- async function promptForApiUrl(envName, existingUrl) {
1435
- if (existingUrl) {
1436
- console.log(chalk_1.default.dim(` Found existing value: ${existingUrl}`));
1437
- const useExisting = await prompts.confirm({
1438
- message: ` Use existing ${envName} API URL?`,
1439
- default: true,
1440
- });
1441
- if (useExisting) {
1442
- return existingUrl;
788
+ async function setupProfiles(detected, environments) {
789
+ console.log('');
790
+ console.log(chalk_1.default.blue('=== Profile Configuration ==='));
791
+ console.log('');
792
+ // Generate default profiles
793
+ const profiles = generateDefaultProfiles(detected, environments);
794
+ if (Object.keys(profiles).length === 0) {
795
+ console.log(chalk_1.default.dim(' No profiles generated (no runnable apps detected).'));
796
+ return {};
797
+ }
798
+ // Display profiles
799
+ console.log(chalk_1.default.dim('Generated profiles:'));
800
+ console.log('');
801
+ for (const [name, profile] of Object.entries(profiles)) {
802
+ console.log(` ${chalk_1.default.cyan(name)}`);
803
+ console.log(chalk_1.default.dim(` ${profile.description || 'No description'}`));
804
+ console.log(chalk_1.default.dim(` Apps: ${profile.apps?.join(', ') || 'all'}`));
805
+ console.log(chalk_1.default.dim(` Size: ${profile.size || 'default'}`));
806
+ if (profile.default_connection) {
807
+ console.log(chalk_1.default.dim(` Connection: ${profile.default_connection}`));
808
+ }
809
+ if (profile.database) {
810
+ console.log(chalk_1.default.dim(` Database: ${profile.database.mode}${profile.database.source ? ` from ${profile.database.source}` : ''}`));
1443
811
  }
812
+ console.log('');
1444
813
  }
1445
- return await prompts.input({
1446
- message: ` ${envName.charAt(0).toUpperCase() + envName.slice(1)} API URL:`,
1447
- default: '',
814
+ // Ask if user wants to edit
815
+ const editProfiles = await prompts.confirm({
816
+ message: 'Do you want to edit any profiles?',
817
+ default: false,
1448
818
  });
1449
- }
1450
- /**
1451
- * Setup .env.genbox file with segregated app sections
819
+ if (!editProfiles) {
820
+ return profiles;
821
+ }
822
+ const profileChoices = Object.entries(profiles).map(([name, profile]) => ({
823
+ name: `${name} - ${profile.description || 'No description'}`,
824
+ value: name,
825
+ }));
826
+ const profilesToEdit = await prompts.checkbox({
827
+ message: 'Select profiles to edit:',
828
+ choices: profileChoices,
829
+ });
830
+ for (const profileName of profilesToEdit) {
831
+ profiles[profileName] = await editSingleProfile(profileName, profiles[profileName], detected, environments);
832
+ }
833
+ return profiles;
834
+ }
835
+ /**
836
+ * Edit a single profile
1452
837
  */
1453
- async function setupEnvFile(projectName, config, nonInteractive = false, scan, isMultiRepo = false, extraEnvVars = {}, overwriteExisting = false, detectedServiceUrls) {
1454
- const envPath = path_1.default.join(process.cwd(), ENV_FILENAME);
1455
- // If overwriting, delete existing file
1456
- if (fs_1.default.existsSync(envPath)) {
1457
- if (overwriteExisting) {
1458
- fs_1.default.unlinkSync(envPath);
838
+ async function editSingleProfile(name, profile, detected, environments) {
839
+ console.log('');
840
+ console.log(chalk_1.default.blue(`=== Editing Profile: ${name} ===`));
841
+ const result = { ...profile };
842
+ // Edit description
843
+ const description = await prompts.input({
844
+ message: 'Description:',
845
+ default: profile.description || '',
846
+ });
847
+ result.description = description || undefined;
848
+ // Edit size
849
+ const size = await prompts.select({
850
+ message: 'Server size:',
851
+ choices: [
852
+ { name: `small ${profile.size === 'small' ? chalk_1.default.green('(current)') : ''}`, value: 'small' },
853
+ { name: `medium ${profile.size === 'medium' ? chalk_1.default.green('(current)') : ''}`, value: 'medium' },
854
+ { name: `large ${profile.size === 'large' ? chalk_1.default.green('(current)') : ''}`, value: 'large' },
855
+ { name: `xl ${profile.size === 'xl' ? chalk_1.default.green('(current)') : ''}`, value: 'xl' },
856
+ ],
857
+ default: profile.size || 'medium',
858
+ });
859
+ result.size = size;
860
+ // Edit apps
861
+ const appChoices = Object.keys(detected.apps).map(appName => ({
862
+ name: appName,
863
+ value: appName,
864
+ checked: profile.apps?.includes(appName) ?? true,
865
+ }));
866
+ const selectedApps = await prompts.checkbox({
867
+ message: 'Include apps:',
868
+ choices: appChoices,
869
+ });
870
+ result.apps = selectedApps.length > 0 ? selectedApps : undefined;
871
+ // Edit connection mode (if environments exist)
872
+ const envNames = Object.keys(environments || {});
873
+ if (envNames.length > 0) {
874
+ const connectionChoices = [
875
+ { name: 'local (run everything locally)', value: 'local' },
876
+ ...envNames.map(env => ({
877
+ name: `${env} (connect to ${env})`,
878
+ value: env,
879
+ })),
880
+ ];
881
+ const connection = await prompts.select({
882
+ message: 'Default connection:',
883
+ choices: connectionChoices,
884
+ default: profile.default_connection || 'local',
885
+ });
886
+ result.default_connection = connection === 'local' ? undefined : connection;
887
+ }
888
+ // Edit database mode
889
+ const dbMode = await prompts.select({
890
+ message: 'Database mode:',
891
+ choices: [
892
+ { name: 'local (fresh local database)', value: 'local' },
893
+ { name: 'copy (copy from remote)', value: 'copy' },
894
+ { name: 'none (no database)', value: 'none' },
895
+ ],
896
+ default: profile.database?.mode || 'local',
897
+ });
898
+ if (dbMode === 'copy' && envNames.length > 0) {
899
+ const dbSource = await prompts.select({
900
+ message: 'Copy database from:',
901
+ choices: envNames.map(env => ({ name: env, value: env })),
902
+ default: profile.database?.source || envNames[0],
903
+ });
904
+ result.database = { mode: 'copy', source: dbSource };
905
+ }
906
+ else if (dbMode === 'local') {
907
+ result.database = { mode: 'local' };
908
+ }
909
+ else {
910
+ result.database = undefined;
911
+ }
912
+ console.log(chalk_1.default.green(`✓ Updated profile: ${name}`));
913
+ return result;
914
+ }
915
+ /**
916
+ * Generate default profiles based on detected apps and environments
917
+ */
918
+ function generateDefaultProfiles(detected, environments) {
919
+ const profiles = {};
920
+ const frontendApps = Object.entries(detected.apps).filter(([, app]) => app.type === 'frontend');
921
+ const backendApps = Object.entries(detected.apps).filter(([, app]) => app.type === 'backend' || app.type === 'gateway');
922
+ const allRunnableApps = Object.entries(detected.apps).filter(([, app]) => app.type !== 'library');
923
+ const envNames = Object.keys(environments || {});
924
+ const remoteEnv = envNames.includes('staging') ? 'staging' :
925
+ envNames.includes('production') ? 'production' :
926
+ envNames[0];
927
+ // Quick UI profiles for frontends
928
+ if (remoteEnv) {
929
+ for (const [name] of frontendApps.slice(0, 2)) {
930
+ profiles[`${name}-quick`] = {
931
+ description: `${name} only, connected to ${remoteEnv}`,
932
+ size: 'small',
933
+ apps: [name],
934
+ default_connection: remoteEnv,
935
+ };
1459
936
  }
1460
- else {
1461
- console.log(chalk_1.default.dim(` ${ENV_FILENAME} already exists, skipping...`));
1462
- return;
937
+ }
938
+ // Full local development
939
+ if (frontendApps.length > 0 && backendApps.length > 0) {
940
+ const [frontendName] = frontendApps[0];
941
+ profiles[`${frontendName}-full`] = {
942
+ description: `${frontendName} + local backend` + (remoteEnv ? ' + DB copy' : ''),
943
+ size: 'large',
944
+ apps: [frontendName, ...backendApps.map(([n]) => n)],
945
+ database: remoteEnv ? { mode: 'copy', source: remoteEnv } : { mode: 'local' },
946
+ };
947
+ }
948
+ // Backend development
949
+ for (const [name] of backendApps.slice(0, 2)) {
950
+ profiles[`${name}-dev`] = {
951
+ description: `${name} with local infrastructure`,
952
+ size: 'medium',
953
+ apps: [name],
954
+ database: { mode: 'local' },
955
+ };
956
+ }
957
+ // Full stack
958
+ if (allRunnableApps.length > 1) {
959
+ profiles['full-stack'] = {
960
+ description: 'Everything local' + (remoteEnv ? ' with DB copy' : ''),
961
+ size: 'xl',
962
+ apps: allRunnableApps.map(([n]) => n),
963
+ database: remoteEnv ? { mode: 'copy', source: remoteEnv } : { mode: 'local' },
964
+ };
965
+ }
966
+ return profiles;
967
+ }
968
+ // =============================================================================
969
+ // Config Generation
970
+ // =============================================================================
971
+ /**
972
+ * Generate GenboxConfig from detected config and user inputs
973
+ */
974
+ function generateConfig(detected, settings, repos, environments, profiles) {
975
+ // Convert apps
976
+ const apps = {};
977
+ for (const [name, app] of Object.entries(detected.apps)) {
978
+ const appConfig = {
979
+ path: app.path,
980
+ type: app.type || 'backend',
981
+ };
982
+ if (app.port)
983
+ appConfig.port = app.port;
984
+ if (app.framework)
985
+ appConfig.framework = app.framework;
986
+ if (app.runner)
987
+ appConfig.runner = app.runner;
988
+ if (app.docker)
989
+ appConfig.docker = app.docker;
990
+ if (app.healthcheck)
991
+ appConfig.healthcheck = app.healthcheck;
992
+ if (app.depends_on)
993
+ appConfig.depends_on = app.depends_on;
994
+ if (app.commands) {
995
+ appConfig.commands = {};
996
+ if (app.commands.dev)
997
+ appConfig.commands.dev = app.commands.dev;
998
+ if (app.commands.build)
999
+ appConfig.commands.build = app.commands.build;
1000
+ if (app.commands.start)
1001
+ appConfig.commands.start = app.commands.start;
1463
1002
  }
1003
+ apps[name] = appConfig;
1004
+ }
1005
+ // Convert infrastructure to provides
1006
+ const provides = {};
1007
+ if (detected.infrastructure) {
1008
+ for (const infra of detected.infrastructure) {
1009
+ provides[infra.name] = {
1010
+ type: infra.type,
1011
+ image: infra.image,
1012
+ port: infra.port,
1013
+ };
1014
+ }
1015
+ }
1016
+ // Build defaults
1017
+ const defaults = {
1018
+ size: settings.serverSize,
1019
+ };
1020
+ if (settings.baseBranch !== 'main') {
1021
+ defaults.branch = settings.baseBranch;
1022
+ }
1023
+ if (settings.installClaudeCode) {
1024
+ defaults.install_claude_code = true;
1464
1025
  }
1465
- // Build segregated content with GLOBAL section first
1466
- let segregatedContent = `# Genbox Environment Variables
1026
+ // Map structure type
1027
+ const structureMap = {
1028
+ 'single-app': 'single-app',
1029
+ 'monorepo': 'monorepo',
1030
+ 'workspace': 'workspace',
1031
+ 'microservices': 'microservices',
1032
+ 'hybrid': 'hybrid',
1033
+ };
1034
+ const config = {
1035
+ version: 4,
1036
+ project: {
1037
+ name: settings.projectName,
1038
+ structure: structureMap[detected.structure.type] || 'single-app',
1039
+ },
1040
+ apps,
1041
+ defaults,
1042
+ };
1043
+ if (Object.keys(provides).length > 0)
1044
+ config.provides = provides;
1045
+ if (Object.keys(repos).length > 0)
1046
+ config.repos = repos;
1047
+ if (environments && Object.keys(environments).length > 0)
1048
+ config.environments = environments;
1049
+ if (Object.keys(profiles).length > 0)
1050
+ config.profiles = profiles;
1051
+ // Scripts
1052
+ if (detected.scripts && detected.scripts.length > 0) {
1053
+ config.scripts = detected.scripts.map(s => ({
1054
+ name: s.name,
1055
+ path: s.path,
1056
+ stage: s.stage,
1057
+ }));
1058
+ }
1059
+ return config;
1060
+ }
1061
+ // =============================================================================
1062
+ // .env.genbox Generation
1063
+ // =============================================================================
1064
+ /**
1065
+ * Generate .env.genbox file
1066
+ */
1067
+ function generateEnvFile(projectName, detected, envVars, serviceUrlMappings) {
1068
+ let content = `# Genbox Environment Variables
1467
1069
  # Project: ${projectName}
1468
1070
  # DO NOT COMMIT THIS FILE
1469
1071
  #
1470
1072
  # This file uses segregated sections for each app/service.
1471
1073
  # At 'genbox create' time, only GLOBAL + selected app sections are used.
1472
- # Use \${API_URL} for dynamic API URLs based on profile's connect_to setting.
1473
1074
 
1474
1075
  # === GLOBAL ===
1475
1076
  # These variables are always included regardless of which apps are selected
1476
1077
 
1477
1078
  `;
1478
- // Add global env vars
1479
- for (const [key, value] of Object.entries(extraEnvVars)) {
1480
- segregatedContent += `${key}=${value}\n`;
1481
- }
1482
- // Add GIT authentication placeholder if not already added
1483
- if (!extraEnvVars['GIT_TOKEN']) {
1484
- segregatedContent += `# GIT_TOKEN=ghp_xxxxxxxxxxxx\n`;
1485
- }
1486
- // Add database URL section (only show templates for missing URLs)
1487
- const hasStagingMongo = !!extraEnvVars['STAGING_MONGODB_URL'];
1488
- const hasProdMongo = !!extraEnvVars['PROD_MONGODB_URL'];
1489
- if (!hasStagingMongo || !hasProdMongo) {
1490
- segregatedContent += `\n# Database URLs (used by profiles with database mode)\n`;
1491
- if (!hasStagingMongo) {
1492
- segregatedContent += `# STAGING_MONGODB_URL=mongodb+srv://user:password@staging.mongodb.net\n`;
1493
- }
1494
- if (!hasProdMongo) {
1495
- segregatedContent += `# PROD_MONGODB_URL=mongodb+srv://readonly:password@prod.mongodb.net\n`;
1079
+ // Add env vars
1080
+ for (const [key, value] of Object.entries(envVars)) {
1081
+ content += `${key}=${value}\n`;
1082
+ }
1083
+ // Add service URL mappings
1084
+ if (serviceUrlMappings.length > 0) {
1085
+ content += `\n# Service URL Configuration\n`;
1086
+ for (const mapping of serviceUrlMappings) {
1087
+ content += `LOCAL_${mapping.varName}=${mapping.localUrl}\n`;
1088
+ if (mapping.remoteUrl && mapping.remoteEnv) {
1089
+ content += `${mapping.remoteEnv.toUpperCase()}_${mapping.varName}=${mapping.remoteUrl}\n`;
1090
+ }
1496
1091
  }
1497
1092
  }
1498
- segregatedContent += '\n';
1499
- // For multi-repo: find env files in app directories
1500
- if (isMultiRepo && scan) {
1501
- const appEnvFiles = findAppEnvFiles(scan.apps, process.cwd());
1502
- if (appEnvFiles.length > 0 && !nonInteractive) {
1503
- console.log('');
1504
- console.log(chalk_1.default.blue('=== Environment Files ==='));
1505
- // Group by app type
1506
- const directApps = appEnvFiles.filter(e => !e.isService);
1507
- const serviceApps = appEnvFiles.filter(e => e.isService);
1508
- if (directApps.length > 0) {
1509
- console.log(chalk_1.default.dim(`Found ${directApps.length} app env files`));
1093
+ // Add GIT_TOKEN placeholder if not present
1094
+ if (!envVars['GIT_TOKEN']) {
1095
+ content += `\n# Git authentication\n# GIT_TOKEN=ghp_xxxxxxxxxxxx\n`;
1096
+ }
1097
+ // Add app-specific sections from existing env files
1098
+ const appEnvFiles = findAppEnvFiles(detected.apps, detected._meta.scanned_root);
1099
+ for (const envFile of appEnvFiles) {
1100
+ try {
1101
+ const fileContent = fs_1.default.readFileSync(envFile.fullPath, 'utf8').trim();
1102
+ if (fileContent) {
1103
+ content += `\n# === ${envFile.appName} ===\n`;
1104
+ content += fileContent;
1105
+ content += '\n';
1510
1106
  }
1511
- if (serviceApps.length > 0) {
1512
- console.log(chalk_1.default.dim(`Found ${serviceApps.length} microservice env files`));
1107
+ }
1108
+ catch {
1109
+ // Skip if can't read
1110
+ }
1111
+ }
1112
+ content += `\n# === END ===\n`;
1113
+ return content;
1114
+ }
1115
+ // =============================================================================
1116
+ // Helper Functions
1117
+ // =============================================================================
1118
+ function mapStructureType(type) {
1119
+ if (type.startsWith('monorepo'))
1120
+ return 'monorepo';
1121
+ if (type === 'hybrid')
1122
+ return 'workspace';
1123
+ if (type === 'microservices')
1124
+ return 'microservices';
1125
+ return 'single-app';
1126
+ }
1127
+ function mapAppType(type) {
1128
+ switch (type) {
1129
+ case 'frontend': return 'frontend';
1130
+ case 'backend':
1131
+ case 'api': return 'backend';
1132
+ case 'worker': return 'worker';
1133
+ case 'gateway': return 'gateway';
1134
+ case 'library': return 'library';
1135
+ default: return undefined;
1136
+ }
1137
+ }
1138
+ function detectRunner(app, scan) {
1139
+ if (app.type === 'library') {
1140
+ return { runner: 'none', runner_reason: 'library apps are not runnable' };
1141
+ }
1142
+ const hasBunLockfile = scan.runtimes?.some(r => r.lockfile === 'bun.lockb');
1143
+ if (hasBunLockfile) {
1144
+ return { runner: 'bun', runner_reason: 'bun.lockb detected' };
1145
+ }
1146
+ const hasStartScript = app.scripts?.start || app.scripts?.dev;
1147
+ const isTypicalApp = ['frontend', 'backend', 'api', 'worker', 'gateway'].includes(app.type || '');
1148
+ if (hasStartScript || isTypicalApp) {
1149
+ return { runner: 'pm2', runner_reason: 'typical Node.js app with start script' };
1150
+ }
1151
+ return { runner: 'pm2', runner_reason: 'default for Node.js apps' };
1152
+ }
1153
+ function inferTypeReason(app) {
1154
+ if (!app.type)
1155
+ return 'unknown';
1156
+ const name = (app.name || app.path || '').toLowerCase();
1157
+ if (name.includes('web') || name.includes('frontend') || name.includes('ui') || name.includes('client')) {
1158
+ return `naming convention ('${app.name}' contains frontend keyword)`;
1159
+ }
1160
+ if (name.includes('api') || name.includes('backend') || name.includes('server') || name.includes('gateway')) {
1161
+ return `naming convention ('${app.name}' contains backend keyword)`;
1162
+ }
1163
+ return 'dependency analysis';
1164
+ }
1165
+ function inferPortSource(app) {
1166
+ if (app.scripts?.dev?.includes('--port'))
1167
+ return 'package.json scripts.dev (--port flag)';
1168
+ if (app.scripts?.dev?.includes('PORT='))
1169
+ return 'package.json scripts.dev (PORT= env)';
1170
+ return 'framework default';
1171
+ }
1172
+ function inferStageReason(script) {
1173
+ const name = script.name.toLowerCase();
1174
+ if (name.includes('setup') || name.includes('init'))
1175
+ return `filename contains setup/init`;
1176
+ if (name.includes('build'))
1177
+ return `filename contains build`;
1178
+ if (name.includes('start'))
1179
+ return `filename contains start`;
1180
+ return 'default assignment';
1181
+ }
1182
+ function inferDockerAppType(name, buildContext, rootDir) {
1183
+ if (buildContext && rootDir) {
1184
+ const contextPath = path_1.default.resolve(rootDir, buildContext);
1185
+ const frontendConfigs = ['vite.config.ts', 'next.config.js', 'nuxt.config.ts'];
1186
+ for (const config of frontendConfigs) {
1187
+ if (fs_1.default.existsSync(path_1.default.join(contextPath, config))) {
1188
+ return { type: 'frontend', reason: `config file found: ${config}` };
1513
1189
  }
1514
- const envChoices = appEnvFiles.map(env => ({
1515
- name: env.isService ? `${env.appName} (service)` : env.appName,
1516
- value: env.fullPath,
1517
- checked: true,
1518
- }));
1519
- const selectedEnvFiles = await prompts.checkbox({
1520
- message: 'Select .env files to include in .env.genbox:',
1521
- choices: envChoices,
1522
- });
1523
- if (selectedEnvFiles.length > 0) {
1524
- for (const envFilePath of selectedEnvFiles) {
1525
- const appInfo = appEnvFiles.find(e => e.fullPath === envFilePath);
1526
- if (!appInfo)
1527
- continue;
1528
- const content = fs_1.default.readFileSync(envFilePath, 'utf8').trim();
1529
- // Add section header and content
1530
- segregatedContent += `# === ${appInfo.appName} ===\n`;
1531
- segregatedContent += content;
1532
- segregatedContent += '\n\n';
1533
- }
1190
+ }
1191
+ const backendConfigs = ['nest-cli.json', 'tsconfig.build.json'];
1192
+ for (const config of backendConfigs) {
1193
+ if (fs_1.default.existsSync(path_1.default.join(contextPath, config))) {
1194
+ return { type: 'backend', reason: `config file found: ${config}` };
1534
1195
  }
1535
1196
  }
1536
- else if (appEnvFiles.length > 0 && nonInteractive) {
1537
- // Non-interactive: merge all env files
1538
- for (const envFile of appEnvFiles) {
1539
- const content = fs_1.default.readFileSync(envFile.fullPath, 'utf8').trim();
1540
- segregatedContent += `# === ${envFile.appName} ===\n`;
1541
- segregatedContent += content;
1542
- segregatedContent += '\n\n';
1197
+ }
1198
+ const lowerName = name.toLowerCase();
1199
+ if (lowerName.includes('web') || lowerName.includes('frontend') || lowerName.includes('ui')) {
1200
+ return { type: 'frontend', reason: `naming convention` };
1201
+ }
1202
+ if (lowerName.includes('api') || lowerName.includes('backend') || lowerName.includes('gateway')) {
1203
+ return { type: 'backend', reason: `naming convention` };
1204
+ }
1205
+ return { type: 'backend', reason: 'default for Docker services' };
1206
+ }
1207
+ function detectGitForDirectory(dir) {
1208
+ const { execSync } = require('child_process');
1209
+ const gitDir = path_1.default.join(dir, '.git');
1210
+ if (!fs_1.default.existsSync(gitDir))
1211
+ return undefined;
1212
+ try {
1213
+ const remote = execSync('git remote get-url origin', { cwd: dir, stdio: 'pipe', encoding: 'utf8' }).trim();
1214
+ if (!remote)
1215
+ return undefined;
1216
+ const isSSH = remote.startsWith('git@') || remote.startsWith('ssh://');
1217
+ let provider = 'other';
1218
+ if (remote.includes('github.com'))
1219
+ provider = 'github';
1220
+ else if (remote.includes('gitlab.com'))
1221
+ provider = 'gitlab';
1222
+ else if (remote.includes('bitbucket.org'))
1223
+ provider = 'bitbucket';
1224
+ let branch = 'main';
1225
+ try {
1226
+ branch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: dir, stdio: 'pipe', encoding: 'utf8' }).trim();
1227
+ }
1228
+ catch { }
1229
+ return { remote, type: isSSH ? 'ssh' : 'https', provider, branch };
1230
+ }
1231
+ catch {
1232
+ return undefined;
1233
+ }
1234
+ }
1235
+ function getDockerComposeServiceInfo(rootDir, serviceName) {
1236
+ const composeFiles = ['docker-compose.yml', 'docker-compose.yaml', 'compose.yml', 'compose.yaml'];
1237
+ for (const filename of composeFiles) {
1238
+ const composePath = path_1.default.join(rootDir, filename);
1239
+ if (fs_1.default.existsSync(composePath)) {
1240
+ try {
1241
+ const content = fs_1.default.readFileSync(composePath, 'utf8');
1242
+ const compose = yaml.load(content);
1243
+ if (compose?.services?.[serviceName]) {
1244
+ const service = compose.services[serviceName];
1245
+ const info = {};
1246
+ if (service.ports?.[0]) {
1247
+ const portDef = service.ports[0];
1248
+ if (typeof portDef === 'string') {
1249
+ info.port = parseInt(portDef.split(':')[0], 10);
1250
+ }
1251
+ else if (typeof portDef === 'number') {
1252
+ info.port = portDef;
1253
+ }
1254
+ }
1255
+ if (service.build) {
1256
+ if (typeof service.build === 'string') {
1257
+ info.buildContext = service.build;
1258
+ }
1259
+ else {
1260
+ info.buildContext = service.build.context;
1261
+ info.dockerfile = service.build.dockerfile;
1262
+ }
1263
+ }
1264
+ if (service.healthcheck?.test) {
1265
+ const test = service.healthcheck.test;
1266
+ if (Array.isArray(test)) {
1267
+ const healthCmd = test.slice(1).join(' ');
1268
+ const urlMatch = healthCmd.match(/https?:\/\/[^/]+(\S+)/);
1269
+ if (urlMatch)
1270
+ info.healthcheck = urlMatch[1];
1271
+ }
1272
+ }
1273
+ if (service.depends_on) {
1274
+ info.dependsOn = Array.isArray(service.depends_on) ? service.depends_on : Object.keys(service.depends_on);
1275
+ }
1276
+ return info;
1277
+ }
1543
1278
  }
1279
+ catch { }
1544
1280
  }
1545
1281
  }
1546
- // If no app env files found, check for root .env
1547
- const hasAppSections = segregatedContent.includes('# === ') &&
1548
- !segregatedContent.endsWith('# === GLOBAL ===\n');
1549
- if (!hasAppSections) {
1550
- const existingEnvFiles = ['.env.local', '.env', '.env.development'];
1551
- let existingEnvPath;
1552
- for (const envFile of existingEnvFiles) {
1553
- const fullPath = path_1.default.join(process.cwd(), envFile);
1554
- if (fs_1.default.existsSync(fullPath)) {
1555
- existingEnvPath = fullPath;
1282
+ return undefined;
1283
+ }
1284
+ function scanEnvFilesForUrls(apps, rootDir) {
1285
+ const serviceUrls = new Map();
1286
+ const envPatterns = ['.env', '.env.local', '.env.development'];
1287
+ for (const [appName, app] of Object.entries(apps)) {
1288
+ if (app.type !== 'frontend')
1289
+ continue;
1290
+ const appDir = path_1.default.join(rootDir, app.path);
1291
+ let envContent;
1292
+ for (const pattern of envPatterns) {
1293
+ const envPath = path_1.default.join(appDir, pattern);
1294
+ if (fs_1.default.existsSync(envPath)) {
1295
+ envContent = fs_1.default.readFileSync(envPath, 'utf8');
1556
1296
  break;
1557
1297
  }
1558
1298
  }
1559
- if (existingEnvPath) {
1560
- const copyExisting = nonInteractive ? true : await prompts.confirm({
1561
- message: `Found ${path_1.default.basename(existingEnvPath)}. Include in ${ENV_FILENAME}?`,
1562
- default: true,
1563
- });
1564
- if (copyExisting) {
1565
- const content = fs_1.default.readFileSync(existingEnvPath, 'utf8').trim();
1566
- segregatedContent += `# === root ===\n`;
1567
- segregatedContent += `# From ${path_1.default.basename(existingEnvPath)}\n`;
1568
- segregatedContent += content;
1569
- segregatedContent += '\n\n';
1299
+ if (!envContent)
1300
+ continue;
1301
+ for (const line of envContent.split('\n')) {
1302
+ const trimmed = line.trim();
1303
+ if (!trimmed || trimmed.startsWith('#'))
1304
+ continue;
1305
+ const lineMatch = trimmed.match(/^([A-Z_][A-Z0-9_]*)=["']?(.+?)["']?$/);
1306
+ if (!lineMatch)
1307
+ continue;
1308
+ const varName = lineMatch[1];
1309
+ const value = lineMatch[2];
1310
+ if (value.includes('@'))
1311
+ continue;
1312
+ const urlMatch = value.match(/^(https?:\/\/[a-zA-Z0-9_.-]+(?::\d+)?)/);
1313
+ if (!urlMatch)
1314
+ continue;
1315
+ const baseUrl = urlMatch[1];
1316
+ const hostMatch = baseUrl.match(/^https?:\/\/([a-zA-Z0-9_.-]+)/);
1317
+ if (!hostMatch)
1318
+ continue;
1319
+ const hostname = hostMatch[1];
1320
+ const isLocalUrl = hostname === 'localhost' || !hostname.includes('.') || /^\d+\.\d+\.\d+\.\d+$/.test(hostname);
1321
+ if (!isLocalUrl)
1322
+ continue;
1323
+ if (!serviceUrls.has(baseUrl)) {
1324
+ serviceUrls.set(baseUrl, { vars: new Set(), apps: new Set() });
1570
1325
  }
1326
+ serviceUrls.get(baseUrl).vars.add(varName);
1327
+ serviceUrls.get(baseUrl).apps.add(appName);
1571
1328
  }
1572
1329
  }
1573
- // Identify frontend apps for URL transformation
1574
- const frontendApps = scan?.apps
1575
- .filter(a => a.type === 'frontend')
1576
- .map(a => a.name) || [];
1577
- // Also get frontend apps from config if available
1578
- if (config.apps) {
1579
- for (const [name, app] of Object.entries(config.apps)) {
1580
- if (app.type === 'frontend' && !frontendApps.includes(name)) {
1581
- frontendApps.push(name);
1582
- }
1583
- }
1330
+ const result = [];
1331
+ for (const [baseUrl, { vars, apps }] of serviceUrls) {
1332
+ const serviceInfo = getServiceInfoFromUrl(baseUrl);
1333
+ result.push({
1334
+ base_url: baseUrl,
1335
+ var_name: serviceInfo.varName,
1336
+ description: serviceInfo.description,
1337
+ used_by: Array.from(vars),
1338
+ apps: Array.from(apps),
1339
+ source: 'env files',
1340
+ });
1584
1341
  }
1585
- if (frontendApps.length > 0 && !nonInteractive) {
1586
- // Use service URLs from detected.yaml if available (preferred)
1587
- // Otherwise fall back to scanning the collected env content
1588
- let serviceUrls;
1589
- if (detectedServiceUrls && detectedServiceUrls.length > 0) {
1590
- // Convert detected service URLs to the Map format
1591
- serviceUrls = new Map();
1592
- for (const svc of detectedServiceUrls) {
1593
- serviceUrls.set(svc.base_url, {
1594
- urls: new Set([svc.base_url]),
1595
- vars: svc.used_by,
1596
- });
1342
+ return result.sort((a, b) => {
1343
+ const portA = parseInt(a.base_url.match(/:(\d+)/)?.[1] || '0');
1344
+ const portB = parseInt(b.base_url.match(/:(\d+)/)?.[1] || '0');
1345
+ return portA - portB;
1346
+ });
1347
+ }
1348
+ function getServiceInfoFromUrl(baseUrl) {
1349
+ const urlMatch = baseUrl.match(/^https?:\/\/([a-zA-Z0-9_.-]+)(?::(\d+))?/);
1350
+ if (!urlMatch) {
1351
+ return { name: 'unknown', varPrefix: 'UNKNOWN', varName: 'UNKNOWN_URL', description: 'Unknown service' };
1352
+ }
1353
+ const hostname = urlMatch[1];
1354
+ const port = urlMatch[2] ? parseInt(urlMatch[2]) : undefined;
1355
+ if (hostname !== 'localhost') {
1356
+ const varPrefix = hostname.toUpperCase().replace(/-/g, '_');
1357
+ return { name: hostname, varPrefix, varName: `${varPrefix}_URL`, description: `${hostname} service` };
1358
+ }
1359
+ if (port) {
1360
+ return { name: `port-${port}`, varPrefix: `PORT_${port}`, varName: `PORT_${port}_URL`, description: `localhost:${port}` };
1361
+ }
1362
+ return { name: 'localhost', varPrefix: 'LOCALHOST', varName: 'LOCALHOST_URL', description: 'localhost' };
1363
+ }
1364
+ function findAppEnvFiles(apps, rootDir) {
1365
+ const envFiles = [];
1366
+ const envPatterns = ['.env', '.env.local', '.env.development'];
1367
+ for (const [name, app] of Object.entries(apps)) {
1368
+ const appDir = path_1.default.join(rootDir, app.path);
1369
+ for (const pattern of envPatterns) {
1370
+ const envPath = path_1.default.join(appDir, pattern);
1371
+ if (fs_1.default.existsSync(envPath)) {
1372
+ envFiles.push({ appName: name, fullPath: envPath });
1373
+ break;
1597
1374
  }
1598
- console.log('');
1599
- console.log(chalk_1.default.dim(`Found ${detectedServiceUrls.length} service URL(s) from detected.yaml`));
1600
- }
1601
- else {
1602
- // Fall back to extracting from collected env content
1603
- serviceUrls = extractFrontendHttpUrls(segregatedContent, frontendApps);
1604
1375
  }
1605
- if (serviceUrls.size > 0 && config.environments) {
1606
- // Create mappings based on configured environments (no prompting needed)
1607
- const urlMappings = await createServiceUrlMappings(serviceUrls, config.environments);
1608
- // Transform content with expandable variables
1609
- if (urlMappings.length > 0) {
1610
- const mappedCount = urlMappings.filter(m => m.remoteUrl).length;
1611
- if (mappedCount > 0) {
1612
- segregatedContent = transformEnvWithVariables(segregatedContent, urlMappings, frontendApps);
1613
- const envName = urlMappings.find(m => m.remoteEnv)?.remoteEnv || 'remote';
1614
- console.log('');
1615
- console.log(chalk_1.default.green(`✓ Configured ${mappedCount} service URL(s) for ${envName} environment`));
1376
+ // Check for nested microservices
1377
+ const appsSubdir = path_1.default.join(appDir, 'apps');
1378
+ if (fs_1.default.existsSync(appsSubdir) && fs_1.default.statSync(appsSubdir).isDirectory()) {
1379
+ try {
1380
+ const services = fs_1.default.readdirSync(appsSubdir);
1381
+ for (const service of services) {
1382
+ const serviceDir = path_1.default.join(appsSubdir, service);
1383
+ if (!fs_1.default.statSync(serviceDir).isDirectory())
1384
+ continue;
1385
+ for (const pattern of envPatterns) {
1386
+ const envPath = path_1.default.join(serviceDir, pattern);
1387
+ if (fs_1.default.existsSync(envPath)) {
1388
+ envFiles.push({ appName: `${name}/${service}`, fullPath: envPath });
1389
+ break;
1390
+ }
1391
+ }
1616
1392
  }
1617
1393
  }
1394
+ catch { }
1618
1395
  }
1619
1396
  }
1620
- // Add END marker
1621
- segregatedContent += `# === END ===\n`;
1622
- // Write the file
1623
- fs_1.default.writeFileSync(envPath, segregatedContent);
1624
- const sectionCount = (segregatedContent.match(/# === [^=]+ ===/g) || []).length - 2; // Exclude GLOBAL and END
1625
- if (sectionCount > 0) {
1626
- console.log(chalk_1.default.green(`✔ Created ${ENV_FILENAME} with ${sectionCount} app section(s)`));
1397
+ return envFiles;
1398
+ }
1399
+ async function promptForSecret(message, existingValue, options = {}) {
1400
+ if (existingValue) {
1401
+ const masked = existingValue.length <= 8 ? '*'.repeat(existingValue.length) : existingValue.slice(0, 4) + '*'.repeat(existingValue.length - 8) + existingValue.slice(-4);
1402
+ console.log(chalk_1.default.dim(` Found existing value: ${masked}`));
1403
+ const useExisting = await prompts.confirm({ message: 'Use existing value?', default: true });
1404
+ if (useExisting)
1405
+ return existingValue;
1627
1406
  }
1628
- else {
1629
- console.log(chalk_1.default.green(`✔ Created ${ENV_FILENAME}`));
1407
+ if (options.showInstructions) {
1408
+ console.log('');
1409
+ console.log(chalk_1.default.dim(' To create a token:'));
1410
+ console.log(chalk_1.default.dim(' 1. Go to https://github.com/settings/tokens'));
1411
+ console.log(chalk_1.default.dim(' 2. Click "Generate new token" → "Classic"'));
1412
+ console.log(chalk_1.default.dim(' 3. Select scope: "repo" (Full control of private repositories)'));
1413
+ console.log('');
1630
1414
  }
1631
- // Add to .gitignore
1632
- const gitignorePath = path_1.default.join(process.cwd(), '.gitignore');
1633
- if (fs_1.default.existsSync(gitignorePath)) {
1634
- const content = fs_1.default.readFileSync(gitignorePath, 'utf8');
1635
- if (!content.includes(ENV_FILENAME)) {
1636
- fs_1.default.appendFileSync(gitignorePath, `\n# Genbox secrets\n${ENV_FILENAME}\n`);
1637
- console.log(chalk_1.default.dim(` Added ${ENV_FILENAME} to .gitignore`));
1638
- }
1415
+ let value = await prompts.password({ message });
1416
+ if (value)
1417
+ value = value.replace(/^[A-Z_]+=/, '');
1418
+ return value || undefined;
1419
+ }
1420
+ async function promptWithExisting(message, existingValue, optional = false) {
1421
+ if (existingValue) {
1422
+ console.log(chalk_1.default.dim(` Found existing: ${existingValue}`));
1423
+ const useExisting = await prompts.confirm({ message: 'Use existing value?', default: true });
1424
+ if (useExisting)
1425
+ return existingValue;
1639
1426
  }
1427
+ return await prompts.input({
1428
+ message,
1429
+ default: existingValue || '',
1430
+ });
1640
1431
  }
1641
- /**
1642
- * Generate .env.genbox template
1643
- */
1644
- function generateEnvTemplate(projectName, config) {
1645
- const lines = [
1646
- '# Genbox Environment Variables',
1647
- `# Project: ${projectName}`,
1648
- '# DO NOT COMMIT THIS FILE',
1649
- '',
1650
- '# ============================================',
1651
- '# API URL CONFIGURATION',
1652
- '# ============================================',
1653
- '# Use ${API_URL} in your app env vars (e.g., VITE_API_BASE_URL=${API_URL})',
1654
- '# At create time, ${API_URL} expands based on profile:',
1655
- '# - connect_to: staging → uses STAGING_API_URL',
1656
- '# - connect_to: production → uses PRODUCTION_API_URL',
1657
- '# - local/no connect_to → uses LOCAL_API_URL',
1658
- '',
1659
- 'LOCAL_API_URL=http://localhost:3050',
1660
- 'STAGING_API_URL=https://api.staging.example.com',
1661
- '# PRODUCTION_API_URL=https://api.example.com',
1662
- '',
1663
- '# ============================================',
1664
- '# STAGING ENVIRONMENT',
1665
- '# ============================================',
1666
- '',
1667
- '# Database',
1668
- 'STAGING_MONGODB_URL=mongodb+srv://user:password@staging.mongodb.net',
1669
- '',
1670
- '# Cache & Queue',
1671
- 'STAGING_REDIS_URL=redis://staging-redis:6379',
1672
- 'STAGING_RABBITMQ_URL=amqp://user:password@staging-rabbitmq:5672',
1673
- '',
1674
- '# ============================================',
1675
- '# PRODUCTION ENVIRONMENT',
1676
- '# ============================================',
1677
- '',
1678
- 'PROD_MONGODB_URL=mongodb+srv://readonly:password@prod.mongodb.net',
1679
- '',
1680
- '# ============================================',
1681
- '# GIT AUTHENTICATION',
1682
- '# ============================================',
1683
- '',
1684
- '# For HTTPS repos (Personal Access Token)',
1685
- 'GIT_TOKEN=ghp_xxxxxxxxxxxx',
1686
- '',
1687
- '# For SSH repos (paste private key content)',
1688
- '# GIT_SSH_KEY="-----BEGIN OPENSSH PRIVATE KEY-----',
1689
- '# ...',
1690
- '# -----END OPENSSH PRIVATE KEY-----"',
1691
- '',
1692
- '# ============================================',
1693
- '# APPLICATION SECRETS',
1694
- '# ============================================',
1695
- '',
1696
- 'JWT_SECRET=your-jwt-secret-here',
1697
- '',
1698
- '# OAuth',
1699
- 'GOOGLE_CLIENT_ID=',
1700
- 'GOOGLE_CLIENT_SECRET=',
1701
- '',
1702
- '# Payments',
1703
- 'STRIPE_SECRET_KEY=sk_test_xxx',
1704
- 'STRIPE_WEBHOOK_SECRET=whsec_xxx',
1705
- '',
1706
- '# ============================================',
1707
- '# APPLICATION ENV VARS',
1708
- '# ============================================',
1709
- '# Use ${API_URL} for dynamic API URLs',
1710
- '',
1711
- '# Example:',
1712
- '# VITE_API_BASE_URL=${API_URL}',
1713
- '# NEXT_PUBLIC_API_URL=${API_URL}',
1714
- '',
1715
- ];
1716
- return lines.join('\n');
1432
+ function sshToHttps(url) {
1433
+ const match = url.match(/git@([^:]+):(.+)/);
1434
+ if (match)
1435
+ return `https://${match[1]}/${match[2]}`;
1436
+ return url;
1717
1437
  }
1718
- /**
1719
- * Convert GenboxConfigV2 to GenboxConfigV4 format
1720
- */
1721
- function convertV2ToV4(v2Config, scan) {
1722
- // Convert services to apps (v4 format)
1723
- const apps = {};
1724
- for (const [name, service] of Object.entries(v2Config.services || {})) {
1725
- const appConfig = {
1726
- path: service.path || `/home/dev/${v2Config.project.name}/${name}`,
1727
- type: service.type === 'api' ? 'backend' : service.type,
1728
- port: service.port,
1729
- };
1730
- // Only add framework if defined
1731
- if (service.framework) {
1732
- appConfig.framework = service.framework;
1733
- }
1734
- // Convert requires to connects_to (v4 format)
1735
- if (service.dependsOn?.length) {
1736
- appConfig.connects_to = service.dependsOn.reduce((acc, dep) => {
1737
- acc[dep] = {
1738
- mode: 'local',
1739
- required: true,
1740
- };
1741
- return acc;
1742
- }, {});
1743
- }
1744
- // Build commands object without undefined values
1745
- const commands = {};
1746
- if (service.build?.command)
1747
- commands.build = service.build.command;
1748
- if (service.start?.command)
1749
- commands.start = service.start.command;
1750
- if (service.start?.dev)
1751
- commands.dev = service.start.dev;
1752
- if (Object.keys(commands).length > 0) {
1753
- appConfig.commands = commands;
1438
+ function httpsToSsh(url) {
1439
+ const match = url.match(/https:\/\/([^/]+)\/(.+)/);
1440
+ if (match)
1441
+ return `git@${match[1]}:${match[2]}`;
1442
+ return url;
1443
+ }
1444
+ function readExistingEnvGenbox() {
1445
+ const envPath = path_1.default.join(process.cwd(), ENV_FILENAME);
1446
+ const values = {};
1447
+ if (!fs_1.default.existsSync(envPath))
1448
+ return values;
1449
+ try {
1450
+ const content = fs_1.default.readFileSync(envPath, 'utf8');
1451
+ for (const line of content.split('\n')) {
1452
+ const trimmed = line.trim();
1453
+ if (!trimmed || trimmed.startsWith('#'))
1454
+ continue;
1455
+ const match = trimmed.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
1456
+ if (match) {
1457
+ let value = match[2].trim();
1458
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
1459
+ value = value.slice(1, -1);
1460
+ }
1461
+ values[match[1]] = value;
1462
+ }
1754
1463
  }
1755
- // Only add env if defined
1756
- if (service.env?.length) {
1757
- appConfig.env = service.env;
1464
+ }
1465
+ catch { }
1466
+ return values;
1467
+ }
1468
+ // =============================================================================
1469
+ // Main Command
1470
+ // =============================================================================
1471
+ exports.initCommand = new commander_1.Command('init')
1472
+ .description('Initialize a new Genbox configuration')
1473
+ .option('--force', 'Overwrite existing configuration')
1474
+ .option('-y, --yes', 'Use defaults without prompting')
1475
+ .option('--exclude <dirs>', 'Comma-separated directories to exclude')
1476
+ .option('--name <name>', 'Project name (for non-interactive mode)')
1477
+ .option('--skip-edit', 'Skip app editing phase')
1478
+ .action(async (options) => {
1479
+ try {
1480
+ const rootDir = process.cwd();
1481
+ const configPath = path_1.default.join(rootDir, CONFIG_FILENAME);
1482
+ const nonInteractive = options.yes || !process.stdin.isTTY;
1483
+ // Check for existing config
1484
+ if (fs_1.default.existsSync(configPath) && !options.force) {
1485
+ if (nonInteractive) {
1486
+ console.log(chalk_1.default.yellow('genbox.yaml already exists. Use --force to overwrite.'));
1487
+ return;
1488
+ }
1489
+ console.log(chalk_1.default.yellow('genbox.yaml already exists.'));
1490
+ const overwrite = await prompts.confirm({ message: 'Do you want to overwrite it?', default: false });
1491
+ if (!overwrite)
1492
+ return;
1758
1493
  }
1759
- // Set runner and docker config
1760
- if (service.runner) {
1761
- appConfig.runner = service.runner;
1494
+ console.log(chalk_1.default.blue('\nInitializing Genbox...\n'));
1495
+ // Read existing .env.genbox values
1496
+ const existingEnvValues = readExistingEnvGenbox();
1497
+ // =========================================
1498
+ // PHASE 1: Scan or Load
1499
+ // =========================================
1500
+ let detected;
1501
+ const existingDetected = loadDetectedConfig(rootDir);
1502
+ if (existingDetected && !nonInteractive) {
1503
+ console.log(chalk_1.default.dim(`Found existing .genbox/detected.yaml (${existingDetected._meta.generated_at})`));
1504
+ const useExisting = await prompts.confirm({
1505
+ message: 'Use existing scan results or rescan?',
1506
+ default: true,
1507
+ });
1508
+ if (useExisting) {
1509
+ detected = existingDetected;
1510
+ console.log(chalk_1.default.dim('Using existing scan results.'));
1511
+ }
1512
+ else {
1513
+ const spinner = (0, ora_1.default)('Scanning project...').start();
1514
+ const exclude = options.exclude?.split(',').map(s => s.trim()) || [];
1515
+ detected = await scanProject(rootDir, exclude);
1516
+ spinner.succeed('Project scanned');
1517
+ saveDetectedConfig(rootDir, detected);
1518
+ }
1762
1519
  }
1763
- if (service.docker) {
1764
- appConfig.docker = service.docker;
1520
+ else {
1521
+ const spinner = (0, ora_1.default)('Scanning project...').start();
1522
+ const exclude = options.exclude?.split(',').map(s => s.trim()) || [];
1523
+ detected = await scanProject(rootDir, exclude);
1524
+ spinner.succeed('Project scanned');
1525
+ saveDetectedConfig(rootDir, detected);
1765
1526
  }
1766
- apps[name] = appConfig;
1767
- }
1768
- // Convert infrastructure to provides (v4 format)
1769
- const provides = {};
1770
- if (v2Config.infrastructure?.databases) {
1771
- for (const db of v2Config.infrastructure.databases) {
1772
- provides[db.container || db.type] = {
1773
- type: 'database',
1774
- image: `${db.type}:latest`,
1775
- port: db.port,
1527
+ // Display summary
1528
+ console.log('');
1529
+ console.log(chalk_1.default.bold('Detected:'));
1530
+ console.log(` ${chalk_1.default.dim('Structure:')} ${detected.structure.type} (${detected.structure.confidence} confidence)`);
1531
+ console.log(` ${chalk_1.default.dim('Apps:')} ${Object.keys(detected.apps).length} detected`);
1532
+ if (detected.infrastructure?.length) {
1533
+ console.log(` ${chalk_1.default.dim('Infra:')} ${detected.infrastructure.length} services`);
1534
+ }
1535
+ if (detected.git?.remote) {
1536
+ console.log(` ${chalk_1.default.dim('Git:')} ${detected.git.remote}`);
1537
+ console.log(` ${chalk_1.default.dim('Branch:')} ${detected.git.branch || 'main'}`);
1538
+ }
1539
+ if (nonInteractive) {
1540
+ // Non-interactive mode - use all defaults
1541
+ const settings = {
1542
+ projectName: options.name || path_1.default.basename(rootDir),
1543
+ serverSize: 'medium',
1544
+ baseBranch: detected.git?.branch || 'main',
1545
+ installClaudeCode: true,
1776
1546
  };
1547
+ const { repos, envVars: gitEnvVars } = await setupGitAuth(detected, settings.projectName, existingEnvValues);
1548
+ const environments = {};
1549
+ const profiles = generateDefaultProfiles(detected, environments);
1550
+ const config = generateConfig(detected, settings, repos, environments, profiles);
1551
+ const yamlContent = yaml.dump(config, { lineWidth: 120, noRefs: true, quotingType: '"' });
1552
+ fs_1.default.writeFileSync(configPath, yamlContent);
1553
+ console.log(chalk_1.default.green(`\n✔ Configuration saved to ${CONFIG_FILENAME}`));
1554
+ const envContent = generateEnvFile(settings.projectName, detected, { ...gitEnvVars, LOCAL_API_URL: 'http://localhost:3050' }, []);
1555
+ fs_1.default.writeFileSync(path_1.default.join(rootDir, ENV_FILENAME), envContent);
1556
+ console.log(chalk_1.default.green(`✔ Created ${ENV_FILENAME}`));
1557
+ return;
1777
1558
  }
1778
- }
1779
- if (v2Config.infrastructure?.caches) {
1780
- for (const cache of v2Config.infrastructure.caches) {
1781
- provides[cache.container || cache.type] = {
1782
- type: 'cache',
1783
- image: `${cache.type}:latest`,
1784
- port: cache.port,
1785
- };
1559
+ // =========================================
1560
+ // PHASE 2: App Configuration
1561
+ // =========================================
1562
+ detected = await selectApps(detected);
1563
+ if (!options.skipEdit) {
1564
+ detected = await editApps(detected);
1565
+ }
1566
+ // Save updated detected config
1567
+ saveDetectedConfig(rootDir, detected);
1568
+ // =========================================
1569
+ // PHASE 3: Project Settings
1570
+ // =========================================
1571
+ const settings = await getProjectSettings(detected, existingEnvValues);
1572
+ // =========================================
1573
+ // PHASE 4: Git & Scripts
1574
+ // =========================================
1575
+ const { repos, envVars: gitEnvVars } = await setupGitAuth(detected, settings.projectName, existingEnvValues);
1576
+ detected = await selectScripts(detected);
1577
+ // =========================================
1578
+ // PHASE 5: Environments & Service URLs
1579
+ // =========================================
1580
+ const { environments, serviceUrlMappings, envVars: envEnvVars } = await setupEnvironmentsAndServiceUrls(detected, existingEnvValues);
1581
+ // =========================================
1582
+ // PHASE 6: Profiles
1583
+ // =========================================
1584
+ const profiles = await setupProfiles(detected, environments);
1585
+ // =========================================
1586
+ // PHASE 7: Generate Config
1587
+ // =========================================
1588
+ const config = generateConfig(detected, settings, repos, environments, profiles);
1589
+ const yamlContent = yaml.dump(config, { lineWidth: 120, noRefs: true, quotingType: '"' });
1590
+ fs_1.default.writeFileSync(configPath, yamlContent);
1591
+ console.log(chalk_1.default.green(`\n✔ Configuration saved to ${CONFIG_FILENAME}`));
1592
+ // Generate .env.genbox
1593
+ const allEnvVars = { ...gitEnvVars, ...envEnvVars };
1594
+ const envContent = generateEnvFile(settings.projectName, detected, allEnvVars, serviceUrlMappings);
1595
+ fs_1.default.writeFileSync(path_1.default.join(rootDir, ENV_FILENAME), envContent);
1596
+ console.log(chalk_1.default.green(`✔ Created ${ENV_FILENAME}`));
1597
+ // Add .env.genbox to .gitignore
1598
+ const gitignorePath = path_1.default.join(rootDir, '.gitignore');
1599
+ if (fs_1.default.existsSync(gitignorePath)) {
1600
+ const gitignore = fs_1.default.readFileSync(gitignorePath, 'utf8');
1601
+ if (!gitignore.includes(ENV_FILENAME)) {
1602
+ fs_1.default.appendFileSync(gitignorePath, `\n# Genbox secrets\n${ENV_FILENAME}\n`);
1603
+ console.log(chalk_1.default.dim(` Added ${ENV_FILENAME} to .gitignore`));
1604
+ }
1786
1605
  }
1787
- }
1788
- if (v2Config.infrastructure?.queues) {
1789
- for (const queue of v2Config.infrastructure.queues) {
1790
- provides[queue.container || queue.type] = {
1791
- type: 'queue',
1792
- image: `${queue.type}:latest`,
1793
- port: queue.port,
1794
- additional_ports: queue.managementPort ? { management: queue.managementPort } : undefined,
1795
- };
1606
+ // =========================================
1607
+ // Show Instructions
1608
+ // =========================================
1609
+ const hasBackend = Object.values(detected.apps).some(a => a.type === 'backend' || a.type === 'gateway');
1610
+ const hasFrontend = Object.values(detected.apps).some(a => a.type === 'frontend');
1611
+ if (hasBackend && hasFrontend) {
1612
+ console.log('');
1613
+ console.log(chalk_1.default.yellow('=== CORS Configuration Required ==='));
1614
+ console.log(chalk_1.default.white('Add .genbox.dev to your backend CORS config:'));
1615
+ console.log(chalk_1.default.cyan(` origin: [/\\.genbox\\.dev$/]`));
1796
1616
  }
1617
+ console.log('');
1618
+ console.log(chalk_1.default.bold('Next steps:'));
1619
+ console.log(chalk_1.default.dim(` 1. Review ${CONFIG_FILENAME}`));
1620
+ console.log(chalk_1.default.dim(` 2. Run 'genbox profiles' to see available profiles`));
1621
+ console.log(chalk_1.default.dim(` 3. Run 'genbox create <name>' to create an environment`));
1797
1622
  }
1798
- // Convert repos (v4 format)
1799
- const repos = {};
1800
- for (const [name, repo] of Object.entries(v2Config.repos || {})) {
1801
- repos[name] = {
1802
- url: repo.url,
1803
- path: repo.path,
1804
- branch: repo.branch,
1805
- auth: repo.auth,
1806
- };
1807
- }
1808
- // Map structure type to v4
1809
- const structureMap = {
1810
- 'single-app': 'single-app',
1811
- 'monorepo-pnpm': 'monorepo',
1812
- 'monorepo-yarn': 'monorepo',
1813
- 'monorepo-npm': 'monorepo',
1814
- 'microservices': 'microservices',
1815
- 'hybrid': 'hybrid',
1816
- };
1817
- // Build v4 config
1818
- const v4Config = {
1819
- version: 4,
1820
- project: {
1821
- name: v2Config.project.name,
1822
- structure: structureMap[v2Config.project.structure] || 'single-app',
1823
- description: v2Config.project.description,
1824
- },
1825
- apps,
1826
- provides: Object.keys(provides).length > 0 ? provides : undefined,
1827
- repos: Object.keys(repos).length > 0 ? repos : undefined,
1828
- defaults: {
1829
- size: v2Config.system.size,
1830
- },
1831
- hooks: v2Config.hooks ? {
1832
- post_checkout: v2Config.hooks.postCheckout,
1833
- post_start: v2Config.hooks.postStart,
1834
- pre_start: v2Config.hooks.preStart,
1835
- } : undefined,
1836
- strict: {
1837
- enabled: true,
1838
- allow_detect: true,
1839
- warnings_as_errors: false,
1840
- },
1841
- };
1842
- return v4Config;
1843
- }
1844
- /**
1845
- * Convert DetectedConfig (from detected.yaml) to ProjectScan format
1846
- * This allows --from-scan to use the same code paths as fresh scanning
1847
- */
1848
- function convertDetectedToScan(detected) {
1849
- // Convert structure type
1850
- const structureTypeMap = {
1851
- 'single-app': 'single-app',
1852
- 'monorepo': 'monorepo-pnpm',
1853
- 'workspace': 'hybrid',
1854
- 'microservices': 'microservices',
1855
- 'hybrid': 'hybrid',
1856
- };
1857
- // Convert apps
1858
- const apps = [];
1859
- for (const [name, app] of Object.entries(detected.apps || {})) {
1860
- // Map detected type to scanner type
1861
- const typeMap = {
1862
- 'frontend': 'frontend',
1863
- 'backend': 'backend',
1864
- 'worker': 'worker',
1865
- 'gateway': 'gateway',
1866
- 'library': 'library',
1867
- };
1868
- // Map runner types
1869
- const runnerMap = {
1870
- 'pm2': 'pm2',
1871
- 'docker': 'docker',
1872
- 'node': 'node',
1873
- 'bun': 'bun',
1874
- 'none': 'none',
1875
- };
1876
- const discoveredApp = {
1877
- name,
1878
- path: app.path,
1879
- type: typeMap[app.type || 'library'] || 'library',
1880
- framework: app.framework,
1881
- port: app.port,
1882
- dependencies: app.dependencies,
1883
- scripts: app.commands ? {
1884
- dev: app.commands.dev || '',
1885
- build: app.commands.build || '',
1886
- start: app.commands.start || '',
1887
- } : {},
1888
- runner: app.runner ? runnerMap[app.runner] : undefined,
1889
- };
1890
- // Add docker config if present
1891
- if (app.docker) {
1892
- discoveredApp.docker = {
1893
- service: app.docker.service,
1894
- build_context: app.docker.build_context,
1895
- dockerfile: app.docker.dockerfile,
1896
- };
1623
+ catch (error) {
1624
+ if (error.name === 'ExitPromptError' || error.message?.includes('force closed')) {
1625
+ console.log('\n' + chalk_1.default.dim('Cancelled.'));
1626
+ process.exit(0);
1897
1627
  }
1898
- apps.push(discoveredApp);
1899
- }
1900
- // Convert runtimes
1901
- const runtimes = detected.runtimes.map(r => ({
1902
- language: r.language,
1903
- version: r.version,
1904
- versionSource: r.version_source,
1905
- packageManager: r.package_manager,
1906
- lockfile: r.lockfile,
1907
- }));
1908
- // Convert infrastructure to compose analysis
1909
- // Note: docker_services is deprecated - apps with runner: 'docker' are now tracked per-app
1910
- let compose = null;
1911
- const hasInfra = detected.infrastructure && detected.infrastructure.length > 0;
1912
- // Get docker apps from apps with runner: 'docker' (new approach)
1913
- const dockerApps = Object.entries(detected.apps || {})
1914
- .filter(([, app]) => app.runner === 'docker')
1915
- .map(([name, app]) => ({
1916
- name,
1917
- image: app.docker?.service || name,
1918
- build: app.docker?.build_context ? {
1919
- context: app.docker.build_context,
1920
- dockerfile: app.docker.dockerfile,
1921
- } : undefined,
1922
- ports: app.port ? [{ host: app.port, container: app.port }] : [],
1923
- environment: {},
1924
- dependsOn: app.depends_on || [],
1925
- volumes: [],
1926
- }));
1927
- if (hasInfra || dockerApps.length > 0) {
1928
- compose = {
1929
- files: ['docker-compose.yml'],
1930
- applications: dockerApps,
1931
- databases: (detected.infrastructure || [])
1932
- .filter(i => i.type === 'database')
1933
- .map(i => ({
1934
- name: i.name,
1935
- image: i.image,
1936
- ports: [{ host: i.port, container: i.port }],
1937
- environment: {},
1938
- dependsOn: [],
1939
- volumes: [],
1940
- })),
1941
- caches: (detected.infrastructure || [])
1942
- .filter(i => i.type === 'cache')
1943
- .map(i => ({
1944
- name: i.name,
1945
- image: i.image,
1946
- ports: [{ host: i.port, container: i.port }],
1947
- environment: {},
1948
- dependsOn: [],
1949
- volumes: [],
1950
- })),
1951
- queues: (detected.infrastructure || [])
1952
- .filter(i => i.type === 'queue')
1953
- .map(i => ({
1954
- name: i.name,
1955
- image: i.image,
1956
- ports: [{ host: i.port, container: i.port }],
1957
- environment: {},
1958
- dependsOn: [],
1959
- volumes: [],
1960
- })),
1961
- infrastructure: [],
1962
- portMap: new Map(),
1963
- dependencyGraph: new Map(),
1964
- };
1628
+ throw error;
1965
1629
  }
1966
- // Convert git
1967
- const git = detected.git ? {
1968
- remote: detected.git.remote || '',
1969
- type: detected.git.type || 'https',
1970
- provider: (detected.git.provider || 'other'),
1971
- branch: detected.git.branch || 'main',
1972
- } : undefined;
1973
- // Convert scripts
1974
- const scripts = (detected.scripts || []).map(s => ({
1975
- name: s.name,
1976
- path: s.path,
1977
- stage: s.stage,
1978
- isExecutable: s.executable,
1979
- }));
1980
- return {
1981
- projectName: path_1.default.basename(detected._meta.scanned_root),
1982
- root: detected._meta.scanned_root,
1983
- structure: {
1984
- type: structureTypeMap[detected.structure.type] || 'single-app',
1985
- confidence: detected.structure.confidence,
1986
- indicators: detected.structure.indicators,
1987
- },
1988
- runtimes,
1989
- frameworks: [], // Not stored in detected.yaml
1990
- compose,
1991
- apps,
1992
- envAnalysis: {
1993
- required: [],
1994
- optional: [],
1995
- secrets: [],
1996
- references: [],
1997
- sources: [],
1998
- },
1999
- git,
2000
- scripts,
2001
- };
2002
- }
1630
+ });