genbox 1.0.14 → 1.0.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -51,6 +51,217 @@ const config_generator_1 = require("../scanner/config-generator");
51
51
  const scan_1 = require("../scan");
52
52
  const CONFIG_FILENAME = 'genbox.yaml';
53
53
  const ENV_FILENAME = '.env.genbox';
54
+ /**
55
+ * Extract unique HTTP URLs from frontend app sections in .env.genbox content
56
+ * Catches both localhost URLs and Docker internal service references
57
+ */
58
+ function extractFrontendHttpUrls(envContent, frontendApps) {
59
+ const serviceUrls = new Map();
60
+ // Parse sections
61
+ const sections = new Map();
62
+ let currentSection = 'GLOBAL';
63
+ let currentContent = [];
64
+ for (const line of envContent.split('\n')) {
65
+ const sectionMatch = line.match(/^# === ([^=]+) ===$/);
66
+ if (sectionMatch) {
67
+ if (currentContent.length > 0) {
68
+ sections.set(currentSection, currentContent.join('\n'));
69
+ }
70
+ currentSection = sectionMatch[1].trim();
71
+ currentContent = [];
72
+ }
73
+ else {
74
+ currentContent.push(line);
75
+ }
76
+ }
77
+ if (currentContent.length > 0) {
78
+ sections.set(currentSection, currentContent.join('\n'));
79
+ }
80
+ // Only process frontend app sections
81
+ for (const appName of frontendApps) {
82
+ const appSection = sections.get(appName);
83
+ if (!appSection)
84
+ continue;
85
+ // Find all HTTP URLs
86
+ // Match: VAR_NAME="http://host:port/path" or VAR_NAME=http://host:port/path
87
+ const urlRegex = /^([A-Z_][A-Z0-9_]*)=["']?(https?:\/\/[a-zA-Z0-9_.-]+(?::\d+)?[^"'\s]*)["']?/gm;
88
+ let match;
89
+ while ((match = urlRegex.exec(appSection)) !== null) {
90
+ const varName = match[1];
91
+ const fullUrl = match[2];
92
+ // Extract hostname from URL
93
+ const hostMatch = fullUrl.match(/^https?:\/\/([a-zA-Z0-9_.-]+)/);
94
+ if (!hostMatch)
95
+ continue;
96
+ const hostname = hostMatch[1];
97
+ // Only include URLs that look like local/development URLs:
98
+ // - localhost
99
+ // - Single-word hosts (Docker internal names like "auth", "gateway", "redis")
100
+ // - IP addresses
101
+ const isLocalUrl = hostname === 'localhost' ||
102
+ !hostname.includes('.') || // No dots = likely Docker internal name
103
+ /^\d+\.\d+\.\d+\.\d+$/.test(hostname); // IP address
104
+ if (!isLocalUrl) {
105
+ continue;
106
+ }
107
+ // Extract base URL (protocol + host + port)
108
+ const baseMatch = fullUrl.match(/^(https?:\/\/[a-zA-Z0-9_.-]+(?::\d+)?)/);
109
+ if (!baseMatch)
110
+ continue;
111
+ const baseUrl = baseMatch[1];
112
+ // Group by base URL
113
+ if (!serviceUrls.has(baseUrl)) {
114
+ serviceUrls.set(baseUrl, { urls: new Set(), vars: [] });
115
+ }
116
+ serviceUrls.get(baseUrl).urls.add(fullUrl);
117
+ if (!serviceUrls.get(baseUrl).vars.includes(varName)) {
118
+ serviceUrls.get(baseUrl).vars.push(varName);
119
+ }
120
+ }
121
+ }
122
+ return serviceUrls;
123
+ }
124
+ /**
125
+ * Determine service name from URL (hostname or port)
126
+ * Generates variable prefix from hostname or port number
127
+ */
128
+ function getServiceNameFromUrl(baseUrl) {
129
+ // Parse the URL
130
+ const urlMatch = baseUrl.match(/^https?:\/\/([a-zA-Z0-9_.-]+)(?::(\d+))?/);
131
+ if (!urlMatch) {
132
+ return { name: 'unknown', varPrefix: 'UNKNOWN', description: 'Unknown Service' };
133
+ }
134
+ const hostname = urlMatch[1];
135
+ const port = urlMatch[2] ? parseInt(urlMatch[2]) : undefined;
136
+ // Generate from hostname if not localhost
137
+ if (hostname !== 'localhost') {
138
+ const varPrefix = hostname.toUpperCase().replace(/-/g, '_');
139
+ return {
140
+ name: hostname,
141
+ varPrefix: `${varPrefix}`,
142
+ description: `${hostname} service`
143
+ };
144
+ }
145
+ // Generate from port for localhost URLs
146
+ if (port) {
147
+ return {
148
+ name: `port-${port}`,
149
+ varPrefix: `PORT_${port}`,
150
+ description: `localhost:${port}`
151
+ };
152
+ }
153
+ return { name: 'unknown', varPrefix: 'UNKNOWN', description: 'Unknown Service' };
154
+ }
155
+ /**
156
+ * Prompt user for staging URLs for each unique service URL found in frontend apps
157
+ */
158
+ async function promptForStagingUrls(serviceUrls, existingStagingApiUrl) {
159
+ const mappings = [];
160
+ if (serviceUrls.size === 0) {
161
+ return mappings;
162
+ }
163
+ console.log('');
164
+ console.log(chalk_1.default.blue('=== Frontend Service URL Configuration ==='));
165
+ console.log(chalk_1.default.dim('Frontend apps have service URLs that need staging equivalents.'));
166
+ console.log(chalk_1.default.dim('This enables profiles like admin-quick to connect to staging backend.'));
167
+ console.log('');
168
+ // Sort by port number for consistent ordering
169
+ const sortedServices = Array.from(serviceUrls.entries()).sort((a, b) => {
170
+ const portA = parseInt(a[0].match(/:(\d+)/)?.[1] || '0');
171
+ const portB = parseInt(b[0].match(/:(\d+)/)?.[1] || '0');
172
+ return portA - portB;
173
+ });
174
+ for (const [baseUrl, { urls, vars }] of sortedServices) {
175
+ const serviceInfo = getServiceNameFromUrl(baseUrl);
176
+ // Show what URLs use this service
177
+ console.log(chalk_1.default.cyan(`${serviceInfo.description} (${baseUrl})`));
178
+ console.log(chalk_1.default.dim(` Used by: ${vars.slice(0, 3).join(', ')}${vars.length > 3 ? ` +${vars.length - 3} more` : ''}`));
179
+ // For gateway, suggest existing staging URL if available
180
+ let defaultUrl = '';
181
+ if ((serviceInfo.name === 'gateway' || baseUrl.includes(':3050')) && existingStagingApiUrl) {
182
+ defaultUrl = existingStagingApiUrl;
183
+ }
184
+ const stagingUrl = await prompts.input({
185
+ message: ` Staging URL (leave empty to skip):`,
186
+ default: defaultUrl,
187
+ });
188
+ mappings.push({
189
+ varName: `${serviceInfo.varPrefix}_URL`,
190
+ localUrl: baseUrl,
191
+ stagingUrl: stagingUrl || undefined,
192
+ description: serviceInfo.description,
193
+ });
194
+ }
195
+ return mappings;
196
+ }
197
+ /**
198
+ * Transform .env.genbox content to use expandable variables
199
+ */
200
+ function transformEnvWithVariables(envContent, mappings, frontendApps) {
201
+ if (mappings.length === 0) {
202
+ return envContent;
203
+ }
204
+ let result = envContent;
205
+ // Build GLOBAL section additions
206
+ const globalAdditions = [
207
+ '',
208
+ '# Service URL Configuration',
209
+ '# These expand based on profile: ${GATEWAY_URL} → LOCAL or STAGING value',
210
+ ];
211
+ for (const mapping of mappings) {
212
+ globalAdditions.push(`LOCAL_${mapping.varName}=${mapping.localUrl}`);
213
+ if (mapping.stagingUrl) {
214
+ globalAdditions.push(`STAGING_${mapping.varName}=${mapping.stagingUrl}`);
215
+ }
216
+ else {
217
+ globalAdditions.push(`# STAGING_${mapping.varName}=https://your-staging-url.com`);
218
+ }
219
+ }
220
+ globalAdditions.push('');
221
+ // Insert after GLOBAL section header and existing content
222
+ const globalInsertPoint = result.indexOf('# === GLOBAL ===');
223
+ if (globalInsertPoint !== -1) {
224
+ // Find the next section or end of GLOBAL
225
+ const nextSectionMatch = result.substring(globalInsertPoint + 20).match(/\n# === [^=]+ ===/);
226
+ const insertAt = nextSectionMatch
227
+ ? globalInsertPoint + 20 + nextSectionMatch.index
228
+ : result.length;
229
+ result = result.substring(0, insertAt) + globalAdditions.join('\n') + result.substring(insertAt);
230
+ }
231
+ // Replace localhost URLs with variable syntax in frontend app sections
232
+ // Parse sections again after modification
233
+ const lines = result.split('\n');
234
+ const transformedLines = [];
235
+ let currentSection = 'GLOBAL';
236
+ let inFrontendSection = false;
237
+ for (const line of lines) {
238
+ const sectionMatch = line.match(/^# === ([^=]+) ===$/);
239
+ if (sectionMatch) {
240
+ currentSection = sectionMatch[1].trim();
241
+ inFrontendSection = frontendApps.includes(currentSection);
242
+ transformedLines.push(line);
243
+ continue;
244
+ }
245
+ if (inFrontendSection && line.includes('http://localhost:')) {
246
+ // Check if this line matches any of our mappings
247
+ let transformedLine = line;
248
+ for (const mapping of mappings) {
249
+ if (line.includes(mapping.localUrl)) {
250
+ // Replace the full URL, preserving any path suffix
251
+ const urlPattern = new RegExp(mapping.localUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(/[^"\'\\s]*)?', 'g');
252
+ transformedLine = transformedLine.replace(urlPattern, (match, pathSuffix) => {
253
+ return `\${${mapping.varName}}${pathSuffix || ''}`;
254
+ });
255
+ }
256
+ }
257
+ transformedLines.push(transformedLine);
258
+ }
259
+ else {
260
+ transformedLines.push(line);
261
+ }
262
+ }
263
+ return transformedLines.join('\n');
264
+ }
54
265
  /**
55
266
  * Detect git repositories in app directories (for multi-repo workspaces)
56
267
  */
@@ -162,6 +373,7 @@ exports.initCommand = new commander_1.Command('init')
162
373
  .option('-y, --yes', 'Use defaults without prompting')
163
374
  .option('--exclude <dirs>', 'Comma-separated directories to exclude')
164
375
  .option('--name <name>', 'Project name (for non-interactive mode)')
376
+ .option('--from-scan', 'Initialize from existing .genbox/detected.yaml (created by genbox scan)')
165
377
  .action(async (options) => {
166
378
  try {
167
379
  const configPath = path_1.default.join(process.cwd(), CONFIG_FILENAME);
@@ -192,11 +404,34 @@ exports.initCommand = new commander_1.Command('init')
192
404
  if (options.exclude) {
193
405
  exclude = options.exclude.split(',').map((d) => d.trim()).filter(Boolean);
194
406
  }
195
- // Scan project first (skip scripts initially)
196
- const spinner = (0, ora_1.default)('Scanning project...').start();
407
+ let scan;
197
408
  const scanner = new scanner_1.ProjectScanner();
198
- let scan = await scanner.scan(process.cwd(), { exclude, skipScripts: true });
199
- spinner.succeed('Project scanned');
409
+ // If --from-scan is specified, load from detected.yaml
410
+ if (options.fromScan) {
411
+ const detectedPath = path_1.default.join(process.cwd(), '.genbox', 'detected.yaml');
412
+ if (!fs_1.default.existsSync(detectedPath)) {
413
+ console.log(chalk_1.default.red('No .genbox/detected.yaml found. Run "genbox scan" first.'));
414
+ process.exit(1);
415
+ }
416
+ const spinner = (0, ora_1.default)('Loading detected configuration...').start();
417
+ try {
418
+ const content = fs_1.default.readFileSync(detectedPath, 'utf8');
419
+ const detected = yaml.load(content);
420
+ scan = convertDetectedToScan(detected);
421
+ spinner.succeed('Loaded from detected.yaml');
422
+ }
423
+ catch (err) {
424
+ spinner.fail('Failed to load detected.yaml');
425
+ console.error(chalk_1.default.red(String(err)));
426
+ process.exit(1);
427
+ }
428
+ }
429
+ else {
430
+ // Scan project first (skip scripts initially)
431
+ const spinner = (0, ora_1.default)('Scanning project...').start();
432
+ scan = await scanner.scan(process.cwd(), { exclude, skipScripts: true });
433
+ spinner.succeed('Project scanned');
434
+ }
200
435
  // Display scan results
201
436
  console.log('');
202
437
  console.log(chalk_1.default.bold('Detected:'));
@@ -213,9 +448,10 @@ exports.initCommand = new commander_1.Command('init')
213
448
  console.log(` ${chalk_1.default.dim('Frameworks:')} ${frameworkStr}`);
214
449
  }
215
450
  // For multi-repo: show apps and let user select which to include
451
+ // When using --from-scan, skip app selection - use exactly what's in detected.yaml
216
452
  const isMultiRepoStructure = scan.structure.type === 'hybrid';
217
453
  let selectedApps = scan.apps;
218
- if (isMultiRepoStructure && scan.apps.length > 0 && !nonInteractive) {
454
+ if (isMultiRepoStructure && scan.apps.length > 0 && !nonInteractive && !options.fromScan) {
219
455
  console.log('');
220
456
  console.log(chalk_1.default.blue('=== Apps Detected ==='));
221
457
  const appChoices = scan.apps.map(app => ({
@@ -232,6 +468,14 @@ exports.initCommand = new commander_1.Command('init')
232
468
  // Update scan with filtered apps
233
469
  scan = { ...scan, apps: selectedApps };
234
470
  }
471
+ else if (options.fromScan && scan.apps.length > 0) {
472
+ // When using --from-scan, show what was loaded and use it directly
473
+ console.log(` ${chalk_1.default.dim('Apps:')} ${scan.apps.length} from detected.yaml`);
474
+ for (const app of scan.apps) {
475
+ console.log(` - ${app.name} (${app.type}${app.framework ? `, ${app.framework}` : ''})`);
476
+ }
477
+ console.log(chalk_1.default.dim('\n (Edit .genbox/detected.yaml to change app selection)'));
478
+ }
235
479
  else if (scan.apps.length > 0) {
236
480
  console.log(` ${chalk_1.default.dim('Apps:')} ${scan.apps.length} discovered`);
237
481
  for (const app of scan.apps.slice(0, 5)) {
@@ -248,17 +492,17 @@ exports.initCommand = new commander_1.Command('init')
248
492
  console.log(` ${chalk_1.default.dim('Git:')} ${scan.git.remote} (${scan.git.type})`);
249
493
  }
250
494
  console.log('');
251
- // Get project name
252
- const projectName = nonInteractive
495
+ // Get project name (use scan value when --from-scan)
496
+ const projectName = (nonInteractive || options.fromScan)
253
497
  ? (options.name || scan.projectName)
254
498
  : await prompts.input({
255
499
  message: 'Project name:',
256
500
  default: scan.projectName,
257
501
  });
258
- // Determine if workspace or single project
502
+ // Determine if workspace or single project (auto-detect when --from-scan)
259
503
  let isWorkspace = options.workspace;
260
504
  if (!isWorkspace && (scan.structure.type.startsWith('monorepo') || scan.structure.type === 'hybrid')) {
261
- if (nonInteractive) {
505
+ if (nonInteractive || options.fromScan) {
262
506
  isWorkspace = true; // Default to workspace for monorepos
263
507
  }
264
508
  else {
@@ -271,25 +515,25 @@ exports.initCommand = new commander_1.Command('init')
271
515
  // Generate initial config (v2 format)
272
516
  const generator = new config_generator_1.ConfigGenerator();
273
517
  const generated = generator.generate(scan);
274
- // Convert to v3 format
275
- const v3Config = convertV2ToV3(generated.config, scan);
518
+ // Convert to v4 format (declarative-first architecture)
519
+ const v4Config = convertV2ToV4(generated.config, scan);
276
520
  // Update project name
277
- v3Config.project.name = projectName;
278
- // Ask about profiles
521
+ v4Config.project.name = projectName;
522
+ // Ask about profiles (skip prompt when using --from-scan)
279
523
  let createProfiles = true;
280
- if (!nonInteractive) {
524
+ if (!nonInteractive && !options.fromScan) {
281
525
  createProfiles = await prompts.confirm({
282
526
  message: 'Create predefined profiles for common scenarios?',
283
527
  default: true,
284
528
  });
285
529
  }
286
530
  if (createProfiles) {
287
- v3Config.profiles = nonInteractive
288
- ? createDefaultProfilesSync(scan, v3Config)
289
- : await createDefaultProfiles(scan, v3Config);
531
+ v4Config.profiles = (nonInteractive || options.fromScan)
532
+ ? createDefaultProfilesSync(scan, v4Config)
533
+ : await createDefaultProfiles(scan, v4Config);
290
534
  }
291
- // Get server size
292
- const serverSize = nonInteractive
535
+ // Get server size (use defaults when --from-scan)
536
+ const serverSize = (nonInteractive || options.fromScan)
293
537
  ? generated.config.system.size
294
538
  : await prompts.select({
295
539
  message: 'Default server size:',
@@ -301,13 +545,99 @@ exports.initCommand = new commander_1.Command('init')
301
545
  ],
302
546
  default: generated.config.system.size,
303
547
  });
304
- if (!v3Config.defaults) {
305
- v3Config.defaults = {};
548
+ if (!v4Config.defaults) {
549
+ v4Config.defaults = {};
306
550
  }
307
- v3Config.defaults.size = serverSize;
551
+ v4Config.defaults.size = serverSize;
308
552
  // Git repository setup - different handling for multi-repo vs single-repo
553
+ // When using --from-scan, skip git selection and use what's in detected.yaml
309
554
  const isMultiRepo = isMultiRepoStructure;
310
- if (isMultiRepo) {
555
+ if (options.fromScan) {
556
+ // When using --from-scan, extract git repos from detected.yaml apps
557
+ const detectedPath = path_1.default.join(process.cwd(), '.genbox', 'detected.yaml');
558
+ let detectedConfig = null;
559
+ try {
560
+ const content = fs_1.default.readFileSync(detectedPath, 'utf8');
561
+ detectedConfig = yaml.load(content);
562
+ }
563
+ catch { }
564
+ // Check for per-app git repos first (multi-repo workspace)
565
+ const appsWithGit = detectedConfig
566
+ ? Object.entries(detectedConfig.apps).filter(([, app]) => app.git)
567
+ : [];
568
+ if (appsWithGit.length > 0) {
569
+ // Multi-repo: use per-app git repos
570
+ console.log('');
571
+ console.log(chalk_1.default.blue('=== Git Repositories (from detected.yaml) ==='));
572
+ console.log(chalk_1.default.dim(`Found ${appsWithGit.length} repositories`));
573
+ v4Config.repos = {};
574
+ let hasHttpsRepos = false;
575
+ for (const [appName, app] of appsWithGit) {
576
+ const git = app.git;
577
+ v4Config.repos[appName] = {
578
+ url: git.remote,
579
+ path: `/home/dev/${projectName}/${app.path}`,
580
+ branch: git.branch !== 'main' && git.branch !== 'master' ? git.branch : undefined,
581
+ auth: git.type === 'ssh' ? 'ssh' : 'token',
582
+ };
583
+ console.log(` ${chalk_1.default.cyan(appName)}: ${git.remote}`);
584
+ if (git.type === 'https') {
585
+ hasHttpsRepos = true;
586
+ }
587
+ }
588
+ // Prompt for GIT_TOKEN if any HTTPS repos are found
589
+ if (hasHttpsRepos && !nonInteractive) {
590
+ console.log('');
591
+ console.log(chalk_1.default.yellow('Private repositories require a GitHub token for cloning.'));
592
+ console.log('');
593
+ console.log(chalk_1.default.dim(' To create a token:'));
594
+ console.log(chalk_1.default.dim(' 1. Go to https://github.com/settings/tokens'));
595
+ console.log(chalk_1.default.dim(' 2. Click "Generate new token" → "Classic"'));
596
+ console.log(chalk_1.default.dim(' 3. Select scope: "repo" (Full control of private repositories)'));
597
+ console.log(chalk_1.default.dim(' 4. Generate and copy the token'));
598
+ console.log('');
599
+ let gitToken = await prompts.password({
600
+ message: 'GitHub Personal Access Token (leave empty to skip):',
601
+ });
602
+ if (gitToken) {
603
+ // Strip any "GIT_TOKEN=" prefix if user pasted the whole line
604
+ gitToken = gitToken.replace(/^GIT_TOKEN=/i, '');
605
+ envVarsToAdd['GIT_TOKEN'] = gitToken;
606
+ console.log(chalk_1.default.green('✓ GIT_TOKEN will be added to .env.genbox'));
607
+ }
608
+ else {
609
+ console.log(chalk_1.default.dim(' Skipped - add GIT_TOKEN to .env.genbox later if needed'));
610
+ }
611
+ }
612
+ }
613
+ else if (scan.git) {
614
+ // Single repo or monorepo with root git
615
+ const repoName = path_1.default.basename(scan.git.remote, '.git').replace(/.*[:/]/, '');
616
+ v4Config.repos = {
617
+ [repoName]: {
618
+ url: scan.git.remote,
619
+ path: `/home/dev/${projectName}`,
620
+ auth: scan.git.type === 'ssh' ? 'ssh' : 'token',
621
+ },
622
+ };
623
+ console.log(chalk_1.default.dim(` Git: Using ${repoName} from detected.yaml`));
624
+ // Prompt for GIT_TOKEN if HTTPS
625
+ if (scan.git.type === 'https' && !nonInteractive) {
626
+ console.log('');
627
+ console.log(chalk_1.default.yellow('Private repositories require a GitHub token for cloning.'));
628
+ let gitToken = await prompts.password({
629
+ message: 'GitHub Personal Access Token (leave empty to skip):',
630
+ });
631
+ if (gitToken) {
632
+ // Strip any "GIT_TOKEN=" prefix if user pasted the whole line
633
+ gitToken = gitToken.replace(/^GIT_TOKEN=/i, '');
634
+ envVarsToAdd['GIT_TOKEN'] = gitToken;
635
+ console.log(chalk_1.default.green('✓ GIT_TOKEN will be added to .env.genbox'));
636
+ }
637
+ }
638
+ }
639
+ }
640
+ else if (isMultiRepo) {
311
641
  // Multi-repo workspace: detect git repos in app directories
312
642
  const appGitRepos = detectAppGitRepos(scan.apps, process.cwd());
313
643
  if (appGitRepos.length > 0 && !nonInteractive) {
@@ -324,11 +654,11 @@ exports.initCommand = new commander_1.Command('init')
324
654
  choices: repoChoices,
325
655
  });
326
656
  if (selectedRepos.length > 0) {
327
- v3Config.repos = {};
657
+ v4Config.repos = {};
328
658
  let hasHttpsRepos = false;
329
659
  for (const repoName of selectedRepos) {
330
660
  const repo = appGitRepos.find(r => r.appName === repoName);
331
- v3Config.repos[repo.appName] = {
661
+ v4Config.repos[repo.appName] = {
332
662
  url: repo.remote,
333
663
  path: `/home/dev/${projectName}/${repo.appPath}`,
334
664
  branch: repo.branch !== 'main' && repo.branch !== 'master' ? repo.branch : undefined,
@@ -349,10 +679,12 @@ exports.initCommand = new commander_1.Command('init')
349
679
  console.log(chalk_1.default.dim(' 3. Select scope: "repo" (Full control of private repositories)'));
350
680
  console.log(chalk_1.default.dim(' 4. Generate and copy the token'));
351
681
  console.log('');
352
- const gitToken = await prompts.password({
682
+ let gitToken = await prompts.password({
353
683
  message: 'GitHub Personal Access Token (leave empty to skip):',
354
684
  });
355
685
  if (gitToken) {
686
+ // Strip any "GIT_TOKEN=" prefix if user pasted the whole line
687
+ gitToken = gitToken.replace(/^GIT_TOKEN=/i, '');
356
688
  envVarsToAdd['GIT_TOKEN'] = gitToken;
357
689
  console.log(chalk_1.default.green('✓ GIT_TOKEN will be added to .env.genbox'));
358
690
  }
@@ -364,9 +696,9 @@ exports.initCommand = new commander_1.Command('init')
364
696
  }
365
697
  else if (appGitRepos.length > 0) {
366
698
  // Non-interactive: include all repos
367
- v3Config.repos = {};
699
+ v4Config.repos = {};
368
700
  for (const repo of appGitRepos) {
369
- v3Config.repos[repo.appName] = {
701
+ v4Config.repos[repo.appName] = {
370
702
  url: repo.remote,
371
703
  path: `/home/dev/${projectName}/${repo.appPath}`,
372
704
  auth: repo.type === 'ssh' ? 'ssh' : 'token',
@@ -378,7 +710,7 @@ exports.initCommand = new commander_1.Command('init')
378
710
  // Single repo or monorepo with root git
379
711
  if (nonInteractive) {
380
712
  const repoName = path_1.default.basename(scan.git.remote, '.git').replace(/.*[:/]/, '');
381
- v3Config.repos = {
713
+ v4Config.repos = {
382
714
  [repoName]: {
383
715
  url: scan.git.remote,
384
716
  path: `/home/dev/${repoName}`,
@@ -389,10 +721,7 @@ exports.initCommand = new commander_1.Command('init')
389
721
  else {
390
722
  const gitConfig = await setupGitAuth(scan.git, projectName);
391
723
  if (gitConfig.repos) {
392
- v3Config.repos = gitConfig.repos;
393
- }
394
- if (gitConfig.git_auth) {
395
- v3Config.git_auth = gitConfig.git_auth;
724
+ v4Config.repos = gitConfig.repos;
396
725
  }
397
726
  }
398
727
  }
@@ -415,7 +744,7 @@ exports.initCommand = new commander_1.Command('init')
415
744
  },
416
745
  });
417
746
  const repoName = path_1.default.basename(repoUrl, '.git');
418
- v3Config.repos = {
747
+ v4Config.repos = {
419
748
  [repoName]: {
420
749
  url: repoUrl,
421
750
  path: `/home/dev/${repoName}`,
@@ -424,15 +753,16 @@ exports.initCommand = new commander_1.Command('init')
424
753
  };
425
754
  }
426
755
  }
427
- // Environment configuration (skip in non-interactive mode)
756
+ // Environment configuration (skip only in non-interactive mode)
757
+ // For --from-scan, we still want to prompt for environments since they're required for genbox to work
428
758
  if (!nonInteractive) {
429
- const envConfig = await setupEnvironments(scan, v3Config, isMultiRepo);
759
+ const envConfig = await setupEnvironments(scan, v4Config, isMultiRepo);
430
760
  if (envConfig) {
431
- v3Config.environments = envConfig;
761
+ v4Config.environments = envConfig;
432
762
  }
433
763
  }
434
- // Script selection - always show multi-select UI (skip in non-interactive mode)
435
- if (!nonInteractive) {
764
+ // Script selection - always show multi-select UI (skip in non-interactive mode and --from-scan)
765
+ if (!nonInteractive && !options.fromScan) {
436
766
  // Scan for scripts
437
767
  const scriptsSpinner = (0, ora_1.default)('Scanning for scripts...').start();
438
768
  const fullScan = await scanner.scan(process.cwd(), { exclude, skipScripts: false });
@@ -463,7 +793,7 @@ exports.initCommand = new commander_1.Command('init')
463
793
  choices: scriptChoices,
464
794
  });
465
795
  if (selectedScripts.length > 0) {
466
- v3Config.scripts = fullScan.scripts
796
+ v4Config.scripts = fullScan.scripts
467
797
  .filter(s => selectedScripts.includes(s.path))
468
798
  .map(s => ({
469
799
  name: s.name,
@@ -477,7 +807,7 @@ exports.initCommand = new commander_1.Command('init')
477
807
  }
478
808
  }
479
809
  // Save configuration
480
- const yamlContent = yaml.dump(v3Config, {
810
+ const yamlContent = yaml.dump(v4Config, {
481
811
  lineWidth: 120,
482
812
  noRefs: true,
483
813
  quotingType: '"',
@@ -487,19 +817,29 @@ exports.initCommand = new commander_1.Command('init')
487
817
  // Add API URLs from environments to envVarsToAdd
488
818
  // Always add LOCAL_API_URL for local development
489
819
  envVarsToAdd['LOCAL_API_URL'] = 'http://localhost:3050';
490
- if (v3Config.environments) {
491
- for (const [envName, envConfig] of Object.entries(v3Config.environments)) {
492
- const apiUrl = envConfig.api?.api ||
493
- envConfig.api?.url ||
494
- envConfig.api?.gateway;
820
+ if (v4Config.environments) {
821
+ for (const [envName, envConfig] of Object.entries(v4Config.environments)) {
822
+ // v4 format: urls.api contains the API URL
823
+ const apiUrl = envConfig.urls?.api || envConfig.urls?.gateway;
495
824
  if (apiUrl) {
496
825
  const varName = `${envName.toUpperCase()}_API_URL`;
497
826
  envVarsToAdd[varName] = apiUrl;
498
827
  }
499
828
  }
500
829
  }
830
+ // Load detected service URLs if using --from-scan
831
+ let detectedServiceUrls;
832
+ if (options.fromScan) {
833
+ const detectedPath = path_1.default.join(process.cwd(), '.genbox', 'detected.yaml');
834
+ try {
835
+ const content = fs_1.default.readFileSync(detectedPath, 'utf8');
836
+ const detectedConfig = yaml.load(content);
837
+ detectedServiceUrls = detectedConfig.service_urls;
838
+ }
839
+ catch { }
840
+ }
501
841
  // Generate .env.genbox
502
- await setupEnvFile(projectName, v3Config, nonInteractive, scan, isMultiRepo, envVarsToAdd, overwriteExisting);
842
+ await setupEnvFile(projectName, v4Config, nonInteractive, scan, isMultiRepo, envVarsToAdd, overwriteExisting, detectedServiceUrls);
503
843
  // Show warnings
504
844
  if (generated.warnings.length > 0) {
505
845
  console.log('');
@@ -509,16 +849,15 @@ exports.initCommand = new commander_1.Command('init')
509
849
  }
510
850
  }
511
851
  // Show API URL guidance if environments are configured
512
- if (v3Config.environments && Object.keys(v3Config.environments).length > 0) {
852
+ if (v4Config.environments && Object.keys(v4Config.environments).length > 0) {
513
853
  console.log('');
514
854
  console.log(chalk_1.default.blue('=== API URL Configuration ==='));
515
855
  console.log(chalk_1.default.dim('The following API URLs were added to .env.genbox:'));
516
856
  console.log('');
517
857
  console.log(chalk_1.default.dim(' LOCAL_API_URL=http://localhost:3050'));
518
- for (const [envName, envConfig] of Object.entries(v3Config.environments)) {
519
- const apiUrl = envConfig.api?.api ||
520
- envConfig.api?.url ||
521
- envConfig.api?.gateway;
858
+ for (const [envName, envConfig] of Object.entries(v4Config.environments)) {
859
+ // v4 format: urls.api contains the API URL
860
+ const apiUrl = envConfig.urls?.api || envConfig.urls?.gateway;
522
861
  if (apiUrl) {
523
862
  const varName = `${envName.toUpperCase()}_API_URL`;
524
863
  console.log(chalk_1.default.dim(` ${varName}=${apiUrl}`));
@@ -531,9 +870,9 @@ exports.initCommand = new commander_1.Command('init')
531
870
  console.log(chalk_1.default.cyan(' NEXT_PUBLIC_API_URL=${API_URL}'));
532
871
  console.log('');
533
872
  console.log(chalk_1.default.dim(' At create time, ${API_URL} expands based on profile:'));
534
- console.log(chalk_1.default.dim(' • connect_to: staging → uses STAGING_API_URL'));
535
- console.log(chalk_1.default.dim(' • connect_to: production → uses PRODUCTION_API_URL'));
536
- console.log(chalk_1.default.dim(' • local/no connect_to → uses LOCAL_API_URL'));
873
+ console.log(chalk_1.default.dim(' • default_connection: staging → uses STAGING_API_URL'));
874
+ console.log(chalk_1.default.dim(' • default_connection: production → uses PRODUCTION_API_URL'));
875
+ console.log(chalk_1.default.dim(' • local/no default_connection → uses LOCAL_API_URL'));
537
876
  }
538
877
  // Next steps
539
878
  console.log('');
@@ -566,13 +905,13 @@ function createProfilesFromScan(scan) {
566
905
  const frontendApps = scan.apps.filter(a => a.type === 'frontend');
567
906
  const backendApps = scan.apps.filter(a => a.type === 'backend' || a.type === 'api');
568
907
  const hasApi = scan.apps.some(a => a.name === 'api' || a.type === 'backend');
569
- // Quick UI profiles for each frontend
908
+ // Quick UI profiles for each frontend (v4: use default_connection instead of connect_to)
570
909
  for (const frontend of frontendApps.slice(0, 3)) {
571
910
  profiles[`${frontend.name}-quick`] = {
572
911
  description: `${frontend.name} only, connected to staging`,
573
912
  size: 'small',
574
913
  apps: [frontend.name],
575
- connect_to: 'staging',
914
+ default_connection: 'staging',
576
915
  };
577
916
  }
578
917
  // Full local development
@@ -599,13 +938,13 @@ function createProfilesFromScan(scan) {
599
938
  },
600
939
  };
601
940
  }
602
- // All frontends + staging
941
+ // All frontends + staging (v4: use default_connection)
603
942
  if (frontendApps.length > 1) {
604
943
  profiles['frontends-staging'] = {
605
944
  description: 'All frontends with staging backend',
606
945
  size: 'medium',
607
946
  apps: frontendApps.map(a => a.name),
608
- connect_to: 'staging',
947
+ default_connection: 'staging',
609
948
  };
610
949
  }
611
950
  // Full stack
@@ -639,14 +978,12 @@ async function setupGitAuth(gitInfo, projectName) {
639
978
  default: 'token',
640
979
  });
641
980
  let repoUrl = gitInfo.remote;
642
- let git_auth;
643
981
  if (authMethod === 'token') {
644
982
  // Convert SSH to HTTPS if needed
645
983
  if (gitInfo.type === 'ssh') {
646
984
  repoUrl = (0, scan_1.sshToHttps)(gitInfo.remote);
647
985
  console.log(chalk_1.default.dim(` Will use HTTPS: ${repoUrl}`));
648
986
  }
649
- git_auth = { method: 'token' };
650
987
  // Show token setup instructions
651
988
  console.log('');
652
989
  console.log(chalk_1.default.yellow(' Add your token to .env.genbox:'));
@@ -677,7 +1014,6 @@ async function setupGitAuth(gitInfo, projectName) {
677
1014
  });
678
1015
  }
679
1016
  }
680
- git_auth = { method: 'ssh', ssh_key_path: sshKeyPath };
681
1017
  console.log('');
682
1018
  console.log(chalk_1.default.yellow(' Add your SSH key to .env.genbox:'));
683
1019
  console.log(chalk_1.default.white(` GIT_SSH_KEY="$(cat ${sshKeyPath})"`));
@@ -691,11 +1027,10 @@ async function setupGitAuth(gitInfo, projectName) {
691
1027
  auth: authMethod === 'public' ? undefined : authMethod,
692
1028
  },
693
1029
  },
694
- git_auth,
695
1030
  };
696
1031
  }
697
1032
  /**
698
- * Setup staging/production environments
1033
+ * Setup staging/production environments (v4 format)
699
1034
  */
700
1035
  async function setupEnvironments(scan, config, isMultiRepo = false) {
701
1036
  const setupEnvs = await prompts.confirm({
@@ -716,22 +1051,23 @@ async function setupEnvironments(scan, config, isMultiRepo = false) {
716
1051
  const backendApps = scan.apps.filter(a => a.type === 'backend' || a.type === 'api');
717
1052
  if (backendApps.length > 0) {
718
1053
  console.log(chalk_1.default.dim('Configure staging API URLs for each backend service:'));
719
- const stagingApi = {};
1054
+ const urls = {};
720
1055
  for (const app of backendApps) {
721
1056
  const url = await prompts.input({
722
1057
  message: ` ${app.name} staging URL (leave empty to skip):`,
723
1058
  default: '',
724
1059
  });
725
1060
  if (url) {
726
- stagingApi[app.name] = url;
1061
+ urls[app.name] = url;
727
1062
  }
728
1063
  }
729
- if (Object.keys(stagingApi).length > 0) {
1064
+ if (Object.keys(urls).length > 0) {
1065
+ // Add database URLs
1066
+ urls['mongodb'] = '${STAGING_MONGODB_URL}';
1067
+ urls['redis'] = '${STAGING_REDIS_URL}';
730
1068
  environments.staging = {
731
1069
  description: 'Staging environment',
732
- api: stagingApi,
733
- mongodb: { url: '${STAGING_MONGODB_URL}' },
734
- redis: { url: '${STAGING_REDIS_URL}' },
1070
+ urls,
735
1071
  };
736
1072
  }
737
1073
  }
@@ -744,9 +1080,11 @@ async function setupEnvironments(scan, config, isMultiRepo = false) {
744
1080
  if (stagingApiUrl) {
745
1081
  environments.staging = {
746
1082
  description: 'Staging environment',
747
- api: { gateway: stagingApiUrl },
748
- mongodb: { url: '${STAGING_MONGODB_URL}' },
749
- redis: { url: '${STAGING_REDIS_URL}' },
1083
+ urls: {
1084
+ api: stagingApiUrl,
1085
+ mongodb: '${STAGING_MONGODB_URL}',
1086
+ redis: '${STAGING_REDIS_URL}',
1087
+ },
750
1088
  };
751
1089
  }
752
1090
  }
@@ -760,9 +1098,11 @@ async function setupEnvironments(scan, config, isMultiRepo = false) {
760
1098
  if (stagingApiUrl) {
761
1099
  environments.staging = {
762
1100
  description: 'Staging environment',
763
- api: { gateway: stagingApiUrl },
764
- mongodb: { url: '${STAGING_MONGODB_URL}' },
765
- redis: { url: '${STAGING_REDIS_URL}' },
1101
+ urls: {
1102
+ api: stagingApiUrl,
1103
+ mongodb: '${STAGING_MONGODB_URL}',
1104
+ redis: '${STAGING_REDIS_URL}',
1105
+ },
766
1106
  };
767
1107
  }
768
1108
  }
@@ -775,23 +1115,24 @@ async function setupEnvironments(scan, config, isMultiRepo = false) {
775
1115
  const backendApps = scan.apps.filter(a => a.type === 'backend' || a.type === 'api');
776
1116
  if (backendApps.length > 0) {
777
1117
  console.log(chalk_1.default.dim('Configure production API URLs for each backend service:'));
778
- const prodApi = {};
1118
+ const prodUrls = {};
779
1119
  for (const app of backendApps) {
780
1120
  const url = await prompts.input({
781
1121
  message: ` ${app.name} production URL:`,
782
1122
  default: '',
783
1123
  });
784
1124
  if (url) {
785
- prodApi[app.name] = url;
1125
+ prodUrls[app.name] = url;
786
1126
  }
787
1127
  }
788
- if (Object.keys(prodApi).length > 0) {
1128
+ if (Object.keys(prodUrls).length > 0) {
1129
+ prodUrls['mongodb'] = '${PROD_MONGODB_URL}';
789
1130
  environments.production = {
790
1131
  description: 'Production (use with caution)',
791
- api: prodApi,
792
- mongodb: {
793
- url: '${PROD_MONGODB_URL}',
1132
+ urls: prodUrls,
1133
+ safety: {
794
1134
  read_only: true,
1135
+ require_confirmation: true,
795
1136
  },
796
1137
  };
797
1138
  }
@@ -805,10 +1146,13 @@ async function setupEnvironments(scan, config, isMultiRepo = false) {
805
1146
  if (prodApiUrl) {
806
1147
  environments.production = {
807
1148
  description: 'Production (use with caution)',
808
- api: { gateway: prodApiUrl },
809
- mongodb: {
810
- url: '${PROD_MONGODB_URL}',
1149
+ urls: {
1150
+ api: prodApiUrl,
1151
+ mongodb: '${PROD_MONGODB_URL}',
1152
+ },
1153
+ safety: {
811
1154
  read_only: true,
1155
+ require_confirmation: true,
812
1156
  },
813
1157
  };
814
1158
  }
@@ -819,7 +1163,7 @@ async function setupEnvironments(scan, config, isMultiRepo = false) {
819
1163
  /**
820
1164
  * Setup .env.genbox file with segregated app sections
821
1165
  */
822
- async function setupEnvFile(projectName, config, nonInteractive = false, scan, isMultiRepo = false, extraEnvVars = {}, overwriteExisting = false) {
1166
+ async function setupEnvFile(projectName, config, nonInteractive = false, scan, isMultiRepo = false, extraEnvVars = {}, overwriteExisting = false, detectedServiceUrls) {
823
1167
  const envPath = path_1.default.join(process.cwd(), ENV_FILENAME);
824
1168
  // If overwriting, delete existing file
825
1169
  if (fs_1.default.existsSync(envPath)) {
@@ -932,6 +1276,52 @@ async function setupEnvFile(projectName, config, nonInteractive = false, scan, i
932
1276
  }
933
1277
  }
934
1278
  }
1279
+ // Identify frontend apps for URL transformation
1280
+ const frontendApps = scan?.apps
1281
+ .filter(a => a.type === 'frontend')
1282
+ .map(a => a.name) || [];
1283
+ // Also get frontend apps from config if available
1284
+ if (config.apps) {
1285
+ for (const [name, app] of Object.entries(config.apps)) {
1286
+ if (app.type === 'frontend' && !frontendApps.includes(name)) {
1287
+ frontendApps.push(name);
1288
+ }
1289
+ }
1290
+ }
1291
+ if (frontendApps.length > 0 && !nonInteractive) {
1292
+ // Use service URLs from detected.yaml if available (preferred)
1293
+ // Otherwise fall back to scanning the collected env content
1294
+ let serviceUrls;
1295
+ if (detectedServiceUrls && detectedServiceUrls.length > 0) {
1296
+ // Convert detected service URLs to the Map format
1297
+ serviceUrls = new Map();
1298
+ for (const svc of detectedServiceUrls) {
1299
+ serviceUrls.set(svc.base_url, {
1300
+ urls: new Set([svc.base_url]),
1301
+ vars: svc.used_by,
1302
+ });
1303
+ }
1304
+ console.log('');
1305
+ console.log(chalk_1.default.dim(`Found ${detectedServiceUrls.length} service URL(s) from detected.yaml`));
1306
+ }
1307
+ else {
1308
+ // Fall back to extracting from collected env content
1309
+ serviceUrls = extractFrontendHttpUrls(segregatedContent, frontendApps);
1310
+ }
1311
+ if (serviceUrls.size > 0) {
1312
+ // Get existing staging API URL if configured
1313
+ const existingStagingApiUrl = extraEnvVars['STAGING_API_URL'] ||
1314
+ (config.environments?.staging?.urls?.api);
1315
+ // Prompt for staging equivalents
1316
+ const urlMappings = await promptForStagingUrls(serviceUrls, existingStagingApiUrl);
1317
+ // Transform content with expandable variables
1318
+ if (urlMappings.length > 0) {
1319
+ segregatedContent = transformEnvWithVariables(segregatedContent, urlMappings, frontendApps);
1320
+ console.log('');
1321
+ console.log(chalk_1.default.green(`✓ Configured ${urlMappings.length} service URL(s) for staging support`));
1322
+ }
1323
+ }
1324
+ }
935
1325
  // Add END marker
936
1326
  segregatedContent += `# === END ===\n`;
937
1327
  // Write the file
@@ -1031,10 +1421,10 @@ function generateEnvTemplate(projectName, config) {
1031
1421
  return lines.join('\n');
1032
1422
  }
1033
1423
  /**
1034
- * Convert GenboxConfigV2 to GenboxConfigV3 format
1424
+ * Convert GenboxConfigV2 to GenboxConfigV4 format
1035
1425
  */
1036
- function convertV2ToV3(v2Config, scan) {
1037
- // Convert services to apps
1426
+ function convertV2ToV4(v2Config, scan) {
1427
+ // Convert services to apps (v4 format)
1038
1428
  const apps = {};
1039
1429
  for (const [name, service] of Object.entries(v2Config.services || {})) {
1040
1430
  const appConfig = {
@@ -1046,10 +1436,13 @@ function convertV2ToV3(v2Config, scan) {
1046
1436
  if (service.framework) {
1047
1437
  appConfig.framework = service.framework;
1048
1438
  }
1049
- // Only add requires if there are dependencies
1439
+ // Convert requires to connects_to (v4 format)
1050
1440
  if (service.dependsOn?.length) {
1051
- appConfig.requires = service.dependsOn.reduce((acc, dep) => {
1052
- acc[dep] = 'required';
1441
+ appConfig.connects_to = service.dependsOn.reduce((acc, dep) => {
1442
+ acc[dep] = {
1443
+ mode: 'local',
1444
+ required: true,
1445
+ };
1053
1446
  return acc;
1054
1447
  }, {});
1055
1448
  }
@@ -1070,11 +1463,11 @@ function convertV2ToV3(v2Config, scan) {
1070
1463
  }
1071
1464
  apps[name] = appConfig;
1072
1465
  }
1073
- // Convert infrastructure
1074
- const infrastructure = {};
1466
+ // Convert infrastructure to provides (v4 format)
1467
+ const provides = {};
1075
1468
  if (v2Config.infrastructure?.databases) {
1076
1469
  for (const db of v2Config.infrastructure.databases) {
1077
- infrastructure[db.container || db.type] = {
1470
+ provides[db.container || db.type] = {
1078
1471
  type: 'database',
1079
1472
  image: `${db.type}:latest`,
1080
1473
  port: db.port,
@@ -1083,7 +1476,7 @@ function convertV2ToV3(v2Config, scan) {
1083
1476
  }
1084
1477
  if (v2Config.infrastructure?.caches) {
1085
1478
  for (const cache of v2Config.infrastructure.caches) {
1086
- infrastructure[cache.container || cache.type] = {
1479
+ provides[cache.container || cache.type] = {
1087
1480
  type: 'cache',
1088
1481
  image: `${cache.type}:latest`,
1089
1482
  port: cache.port,
@@ -1092,15 +1485,15 @@ function convertV2ToV3(v2Config, scan) {
1092
1485
  }
1093
1486
  if (v2Config.infrastructure?.queues) {
1094
1487
  for (const queue of v2Config.infrastructure.queues) {
1095
- infrastructure[queue.container || queue.type] = {
1488
+ provides[queue.container || queue.type] = {
1096
1489
  type: 'queue',
1097
1490
  image: `${queue.type}:latest`,
1098
1491
  port: queue.port,
1099
- management_port: queue.managementPort,
1492
+ additional_ports: queue.managementPort ? { management: queue.managementPort } : undefined,
1100
1493
  };
1101
1494
  }
1102
1495
  }
1103
- // Convert repos
1496
+ // Convert repos (v4 format)
1104
1497
  const repos = {};
1105
1498
  for (const [name, repo] of Object.entries(v2Config.repos || {})) {
1106
1499
  repos[name] = {
@@ -1110,18 +1503,25 @@ function convertV2ToV3(v2Config, scan) {
1110
1503
  auth: repo.auth,
1111
1504
  };
1112
1505
  }
1113
- // Build v3 config
1114
- const v3Config = {
1115
- version: '3.0',
1506
+ // Map structure type to v4
1507
+ const structureMap = {
1508
+ 'single-app': 'single-app',
1509
+ 'monorepo-pnpm': 'monorepo',
1510
+ 'monorepo-yarn': 'monorepo',
1511
+ 'monorepo-npm': 'monorepo',
1512
+ 'microservices': 'microservices',
1513
+ 'hybrid': 'hybrid',
1514
+ };
1515
+ // Build v4 config
1516
+ const v4Config = {
1517
+ version: 4,
1116
1518
  project: {
1117
1519
  name: v2Config.project.name,
1118
- structure: v2Config.project.structure === 'single-app' ? 'single-app' :
1119
- v2Config.project.structure.startsWith('monorepo') ? 'monorepo' :
1120
- v2Config.project.structure,
1520
+ structure: structureMap[v2Config.project.structure] || 'single-app',
1121
1521
  description: v2Config.project.description,
1122
1522
  },
1123
1523
  apps,
1124
- infrastructure: Object.keys(infrastructure).length > 0 ? infrastructure : undefined,
1524
+ provides: Object.keys(provides).length > 0 ? provides : undefined,
1125
1525
  repos: Object.keys(repos).length > 0 ? repos : undefined,
1126
1526
  defaults: {
1127
1527
  size: v2Config.system.size,
@@ -1131,6 +1531,135 @@ function convertV2ToV3(v2Config, scan) {
1131
1531
  post_start: v2Config.hooks.postStart,
1132
1532
  pre_start: v2Config.hooks.preStart,
1133
1533
  } : undefined,
1534
+ strict: {
1535
+ enabled: true,
1536
+ allow_detect: true,
1537
+ warnings_as_errors: false,
1538
+ },
1539
+ };
1540
+ return v4Config;
1541
+ }
1542
+ /**
1543
+ * Convert DetectedConfig (from detected.yaml) to ProjectScan format
1544
+ * This allows --from-scan to use the same code paths as fresh scanning
1545
+ */
1546
+ function convertDetectedToScan(detected) {
1547
+ // Convert structure type
1548
+ const structureTypeMap = {
1549
+ 'single-app': 'single-app',
1550
+ 'monorepo': 'monorepo-pnpm',
1551
+ 'workspace': 'hybrid',
1552
+ 'microservices': 'microservices',
1553
+ 'hybrid': 'hybrid',
1554
+ };
1555
+ // Convert apps
1556
+ const apps = [];
1557
+ for (const [name, app] of Object.entries(detected.apps || {})) {
1558
+ // Map detected type to scanner type
1559
+ const typeMap = {
1560
+ 'frontend': 'frontend',
1561
+ 'backend': 'backend',
1562
+ 'worker': 'worker',
1563
+ 'gateway': 'gateway',
1564
+ 'library': 'library',
1565
+ };
1566
+ apps.push({
1567
+ name,
1568
+ path: app.path,
1569
+ type: typeMap[app.type || 'library'] || 'library',
1570
+ framework: app.framework,
1571
+ port: app.port,
1572
+ dependencies: app.dependencies,
1573
+ scripts: app.commands ? {
1574
+ dev: app.commands.dev || '',
1575
+ build: app.commands.build || '',
1576
+ start: app.commands.start || '',
1577
+ } : {},
1578
+ });
1579
+ }
1580
+ // Convert runtimes
1581
+ const runtimes = detected.runtimes.map(r => ({
1582
+ language: r.language,
1583
+ version: r.version,
1584
+ versionSource: r.version_source,
1585
+ packageManager: r.package_manager,
1586
+ lockfile: r.lockfile,
1587
+ }));
1588
+ // Convert infrastructure to compose analysis
1589
+ let compose = null;
1590
+ if (detected.infrastructure && detected.infrastructure.length > 0) {
1591
+ compose = {
1592
+ files: ['docker-compose.yml'],
1593
+ applications: [],
1594
+ databases: detected.infrastructure
1595
+ .filter(i => i.type === 'database')
1596
+ .map(i => ({
1597
+ name: i.name,
1598
+ image: i.image,
1599
+ ports: [{ host: i.port, container: i.port }],
1600
+ environment: {},
1601
+ dependsOn: [],
1602
+ volumes: [],
1603
+ })),
1604
+ caches: detected.infrastructure
1605
+ .filter(i => i.type === 'cache')
1606
+ .map(i => ({
1607
+ name: i.name,
1608
+ image: i.image,
1609
+ ports: [{ host: i.port, container: i.port }],
1610
+ environment: {},
1611
+ dependsOn: [],
1612
+ volumes: [],
1613
+ })),
1614
+ queues: detected.infrastructure
1615
+ .filter(i => i.type === 'queue')
1616
+ .map(i => ({
1617
+ name: i.name,
1618
+ image: i.image,
1619
+ ports: [{ host: i.port, container: i.port }],
1620
+ environment: {},
1621
+ dependsOn: [],
1622
+ volumes: [],
1623
+ })),
1624
+ infrastructure: [],
1625
+ portMap: new Map(),
1626
+ dependencyGraph: new Map(),
1627
+ };
1628
+ }
1629
+ // Convert git
1630
+ const git = detected.git ? {
1631
+ remote: detected.git.remote || '',
1632
+ type: detected.git.type || 'https',
1633
+ provider: (detected.git.provider || 'other'),
1634
+ branch: detected.git.branch || 'main',
1635
+ } : undefined;
1636
+ // Convert scripts
1637
+ const scripts = (detected.scripts || []).map(s => ({
1638
+ name: s.name,
1639
+ path: s.path,
1640
+ stage: s.stage,
1641
+ isExecutable: s.executable,
1642
+ }));
1643
+ return {
1644
+ projectName: path_1.default.basename(detected._meta.scanned_root),
1645
+ root: detected._meta.scanned_root,
1646
+ structure: {
1647
+ type: structureTypeMap[detected.structure.type] || 'single-app',
1648
+ confidence: detected.structure.confidence,
1649
+ indicators: detected.structure.indicators,
1650
+ },
1651
+ runtimes,
1652
+ frameworks: [], // Not stored in detected.yaml
1653
+ compose,
1654
+ apps,
1655
+ envAnalysis: {
1656
+ required: [],
1657
+ optional: [],
1658
+ secrets: [],
1659
+ references: [],
1660
+ sources: [],
1661
+ },
1662
+ git,
1663
+ scripts,
1134
1664
  };
1135
- return v3Config;
1136
1665
  }