genbox 1.0.45 → 1.0.47
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/create.js +20 -19
- package/dist/commands/init.js +1419 -1788
- package/dist/index.js +0 -2
- package/package.json +1 -1
- package/dist/commands/scan.js +0 -1162
package/dist/commands/init.js
CHANGED
|
@@ -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,1339 +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
|
-
|
|
51
|
-
const
|
|
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
|
-
*
|
|
56
|
-
* Catches both localhost URLs and Docker internal service references
|
|
72
|
+
* Scan project and convert to DetectedConfig
|
|
57
73
|
*/
|
|
58
|
-
function
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
//
|
|
146
|
-
if (
|
|
147
|
-
|
|
148
|
-
name:
|
|
149
|
-
|
|
150
|
-
|
|
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
|
|
204
|
+
return detected;
|
|
154
205
|
}
|
|
155
206
|
/**
|
|
156
|
-
*
|
|
157
|
-
* Uses URLs from configured environments instead of prompting again
|
|
207
|
+
* Load existing detected.yaml if present
|
|
158
208
|
*/
|
|
159
|
-
|
|
160
|
-
const
|
|
161
|
-
if (
|
|
162
|
-
return
|
|
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
|
-
|
|
165
|
-
|
|
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
|
-
*
|
|
222
|
+
* Save detected config to .genbox/detected.yaml
|
|
220
223
|
*/
|
|
221
|
-
function
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
245
|
+
* Interactive app selection
|
|
288
246
|
*/
|
|
289
|
-
function
|
|
290
|
-
const
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
284
|
+
* Interactive app editing
|
|
339
285
|
*/
|
|
340
|
-
function
|
|
341
|
-
const
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
|
308
|
+
return result;
|
|
388
309
|
}
|
|
389
310
|
/**
|
|
390
|
-
*
|
|
311
|
+
* Edit a single app's configuration
|
|
391
312
|
*/
|
|
392
|
-
function
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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
|
-
|
|
419
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
462
|
+
* Get project settings with editing capability
|
|
425
463
|
*/
|
|
426
|
-
function
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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
|
-
*
|
|
505
|
+
* Setup git authentication for detected repos
|
|
434
506
|
*/
|
|
435
|
-
async function
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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
|
-
|
|
443
|
-
|
|
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 (
|
|
447
|
-
console.log('');
|
|
448
|
-
|
|
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
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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
|
-
|
|
506
|
-
|
|
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
|
-
|
|
528
|
-
|
|
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
|
-
|
|
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.
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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
|
-
|
|
549
|
-
|
|
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 branch (use detected branch or allow override)
|
|
679
|
-
const detectedBranch = scan.git?.branch || 'main';
|
|
680
|
-
let defaultBranch = detectedBranch;
|
|
681
|
-
if (!nonInteractive && !options.fromScan) {
|
|
682
|
-
const branchInput = await prompts.input({
|
|
683
|
-
message: 'Default branch for new environments:',
|
|
684
|
-
default: detectedBranch,
|
|
685
|
-
});
|
|
686
|
-
defaultBranch = branchInput || detectedBranch;
|
|
687
|
-
}
|
|
688
|
-
// Store default branch in config defaults
|
|
689
|
-
if (defaultBranch && defaultBranch !== 'main') {
|
|
690
|
-
v4Config.defaults.branch = defaultBranch;
|
|
691
|
-
}
|
|
692
|
-
// Git repository setup - different handling for multi-repo vs single-repo
|
|
693
|
-
// When using --from-scan, skip git selection and use what's in detected.yaml
|
|
694
|
-
const isMultiRepo = isMultiRepoStructure;
|
|
695
|
-
if (options.fromScan) {
|
|
696
|
-
// When using --from-scan, extract git repos from detected.yaml apps
|
|
697
|
-
const detectedPath = path_1.default.join(process.cwd(), '.genbox', 'detected.yaml');
|
|
698
|
-
let detectedConfig = null;
|
|
699
|
-
try {
|
|
700
|
-
const content = fs_1.default.readFileSync(detectedPath, 'utf8');
|
|
701
|
-
detectedConfig = yaml.load(content);
|
|
702
|
-
}
|
|
703
|
-
catch { }
|
|
704
|
-
// Check for per-app git repos first (multi-repo workspace)
|
|
705
|
-
const appsWithGit = detectedConfig
|
|
706
|
-
? Object.entries(detectedConfig.apps).filter(([, app]) => app.git)
|
|
707
|
-
: [];
|
|
708
|
-
if (appsWithGit.length > 0) {
|
|
709
|
-
// Multi-repo: use per-app git repos
|
|
710
|
-
console.log('');
|
|
711
|
-
console.log(chalk_1.default.blue('=== Git Repositories (from detected.yaml) ==='));
|
|
712
|
-
console.log(chalk_1.default.dim(`Found ${appsWithGit.length} repositories`));
|
|
713
|
-
v4Config.repos = {};
|
|
714
|
-
let hasHttpsRepos = false;
|
|
715
|
-
for (const [appName, app] of appsWithGit) {
|
|
716
|
-
const git = app.git;
|
|
717
|
-
v4Config.repos[appName] = {
|
|
718
|
-
url: git.remote,
|
|
719
|
-
path: `/home/dev/${projectName}/${app.path}`,
|
|
720
|
-
branch: git.branch !== 'main' && git.branch !== 'master' ? git.branch : undefined,
|
|
721
|
-
auth: git.type === 'ssh' ? 'ssh' : 'token',
|
|
722
|
-
};
|
|
723
|
-
console.log(` ${chalk_1.default.cyan(appName)}: ${git.remote}`);
|
|
724
|
-
if (git.type === 'https') {
|
|
725
|
-
hasHttpsRepos = true;
|
|
726
|
-
}
|
|
727
|
-
}
|
|
728
|
-
// Prompt for GIT_TOKEN if any HTTPS repos are found
|
|
729
|
-
if (hasHttpsRepos && !nonInteractive) {
|
|
730
|
-
console.log('');
|
|
731
|
-
console.log(chalk_1.default.yellow('Private repositories require a GitHub token for cloning.'));
|
|
732
|
-
const gitToken = await promptForSecret('GitHub Personal Access Token (leave empty to skip):', existingEnvValues['GIT_TOKEN'], { showInstructions: !existingEnvValues['GIT_TOKEN'] });
|
|
733
|
-
if (gitToken) {
|
|
734
|
-
envVarsToAdd['GIT_TOKEN'] = gitToken;
|
|
735
|
-
console.log(chalk_1.default.green('✓ GIT_TOKEN will be added to .env.genbox'));
|
|
736
|
-
}
|
|
737
|
-
else {
|
|
738
|
-
console.log(chalk_1.default.dim(' Skipped - add GIT_TOKEN to .env.genbox later if needed'));
|
|
739
|
-
}
|
|
740
|
-
}
|
|
741
|
-
}
|
|
742
|
-
else if (scan.git) {
|
|
743
|
-
// Single repo or monorepo with root git
|
|
744
|
-
const repoName = path_1.default.basename(scan.git.remote, '.git').replace(/.*[:/]/, '');
|
|
745
|
-
v4Config.repos = {
|
|
746
|
-
[repoName]: {
|
|
747
|
-
url: scan.git.remote,
|
|
748
|
-
path: `/home/dev/${projectName}`,
|
|
749
|
-
auth: scan.git.type === 'ssh' ? 'ssh' : 'token',
|
|
750
|
-
},
|
|
751
|
-
};
|
|
752
|
-
console.log(chalk_1.default.dim(` Git: Using ${repoName} from detected.yaml`));
|
|
753
|
-
// Prompt for GIT_TOKEN if HTTPS
|
|
754
|
-
if (scan.git.type === 'https' && !nonInteractive) {
|
|
755
|
-
console.log('');
|
|
756
|
-
console.log(chalk_1.default.yellow('Private repositories require a GitHub token for cloning.'));
|
|
757
|
-
const gitToken = await promptForSecret('GitHub Personal Access Token (leave empty to skip):', existingEnvValues['GIT_TOKEN'], { showInstructions: !existingEnvValues['GIT_TOKEN'] });
|
|
758
|
-
if (gitToken) {
|
|
759
|
-
envVarsToAdd['GIT_TOKEN'] = gitToken;
|
|
760
|
-
console.log(chalk_1.default.green('✓ GIT_TOKEN will be added to .env.genbox'));
|
|
761
|
-
}
|
|
762
|
-
}
|
|
763
|
-
}
|
|
764
|
-
}
|
|
765
|
-
else if (isMultiRepo) {
|
|
766
|
-
// Multi-repo workspace: detect git repos in app directories
|
|
767
|
-
const appGitRepos = detectAppGitRepos(scan.apps, process.cwd());
|
|
768
|
-
if (appGitRepos.length > 0 && !nonInteractive) {
|
|
769
|
-
console.log('');
|
|
770
|
-
console.log(chalk_1.default.blue('=== Git Repositories ==='));
|
|
771
|
-
console.log(chalk_1.default.dim(`Found ${appGitRepos.length} git repositories in app directories`));
|
|
772
|
-
const repoChoices = appGitRepos.map(repo => ({
|
|
773
|
-
name: `${repo.appName} - ${repo.remote}`,
|
|
774
|
-
value: repo.appName,
|
|
775
|
-
checked: true, // Default to include all
|
|
776
|
-
}));
|
|
777
|
-
const selectedRepos = await prompts.checkbox({
|
|
778
|
-
message: 'Select repositories to include:',
|
|
779
|
-
choices: repoChoices,
|
|
780
|
-
});
|
|
781
|
-
if (selectedRepos.length > 0) {
|
|
782
|
-
v4Config.repos = {};
|
|
783
|
-
let hasHttpsRepos = false;
|
|
784
|
-
for (const repoName of selectedRepos) {
|
|
785
|
-
const repo = appGitRepos.find(r => r.appName === repoName);
|
|
786
|
-
v4Config.repos[repo.appName] = {
|
|
787
|
-
url: repo.remote,
|
|
788
|
-
path: `/home/dev/${projectName}/${repo.appPath}`,
|
|
789
|
-
branch: repo.branch !== 'main' && repo.branch !== 'master' ? repo.branch : undefined,
|
|
790
|
-
auth: repo.type === 'ssh' ? 'ssh' : 'token',
|
|
791
|
-
};
|
|
792
|
-
if (repo.type === 'https') {
|
|
793
|
-
hasHttpsRepos = true;
|
|
794
|
-
}
|
|
795
|
-
}
|
|
796
|
-
// Prompt for GIT_TOKEN if any HTTPS repos are selected
|
|
797
|
-
if (hasHttpsRepos) {
|
|
798
|
-
console.log('');
|
|
799
|
-
console.log(chalk_1.default.yellow('Private repositories require a GitHub token for cloning.'));
|
|
800
|
-
const gitToken = await promptForSecret('GitHub Personal Access Token (leave empty to skip):', existingEnvValues['GIT_TOKEN'], { showInstructions: !existingEnvValues['GIT_TOKEN'] });
|
|
801
|
-
if (gitToken) {
|
|
802
|
-
envVarsToAdd['GIT_TOKEN'] = gitToken;
|
|
803
|
-
console.log(chalk_1.default.green('✓ GIT_TOKEN will be added to .env.genbox'));
|
|
804
|
-
}
|
|
805
|
-
else {
|
|
806
|
-
console.log(chalk_1.default.dim(' Skipped - add GIT_TOKEN to .env.genbox later if needed'));
|
|
807
|
-
}
|
|
808
|
-
}
|
|
809
|
-
}
|
|
810
|
-
}
|
|
811
|
-
else if (appGitRepos.length > 0) {
|
|
812
|
-
// Non-interactive: include all repos
|
|
813
|
-
v4Config.repos = {};
|
|
814
|
-
for (const repo of appGitRepos) {
|
|
815
|
-
v4Config.repos[repo.appName] = {
|
|
816
|
-
url: repo.remote,
|
|
817
|
-
path: `/home/dev/${projectName}/${repo.appPath}`,
|
|
818
|
-
auth: repo.type === 'ssh' ? 'ssh' : 'token',
|
|
819
|
-
};
|
|
820
|
-
}
|
|
821
|
-
}
|
|
822
|
-
}
|
|
823
|
-
else if (scan.git) {
|
|
824
|
-
// Single repo or monorepo with root git
|
|
825
|
-
if (nonInteractive) {
|
|
826
|
-
const repoName = path_1.default.basename(scan.git.remote, '.git').replace(/.*[:/]/, '');
|
|
827
|
-
v4Config.repos = {
|
|
828
|
-
[repoName]: {
|
|
829
|
-
url: scan.git.remote,
|
|
830
|
-
path: `/home/dev/${repoName}`,
|
|
831
|
-
auth: scan.git.type === 'ssh' ? 'ssh' : 'token',
|
|
832
|
-
},
|
|
833
|
-
};
|
|
834
|
-
}
|
|
835
|
-
else {
|
|
836
|
-
const gitConfig = await setupGitAuth(scan.git, projectName);
|
|
837
|
-
if (gitConfig.repos) {
|
|
838
|
-
v4Config.repos = gitConfig.repos;
|
|
839
|
-
}
|
|
840
|
-
}
|
|
841
|
-
}
|
|
842
|
-
else if (!nonInteractive && !isMultiRepo) {
|
|
843
|
-
// Only ask to add repo for non-multi-repo projects
|
|
844
|
-
const addRepo = await prompts.confirm({
|
|
845
|
-
message: 'No git remote detected. Add a repository?',
|
|
846
|
-
default: false,
|
|
847
|
-
});
|
|
848
|
-
if (addRepo) {
|
|
849
|
-
const repoUrl = await prompts.input({
|
|
850
|
-
message: 'Repository URL (HTTPS recommended):',
|
|
851
|
-
validate: (value) => {
|
|
852
|
-
if (!value)
|
|
853
|
-
return 'Repository URL is required';
|
|
854
|
-
if (!value.startsWith('git@') && !value.startsWith('https://')) {
|
|
855
|
-
return 'Enter a valid git URL';
|
|
856
|
-
}
|
|
857
|
-
return true;
|
|
858
|
-
},
|
|
859
|
-
});
|
|
860
|
-
const repoName = path_1.default.basename(repoUrl, '.git');
|
|
861
|
-
v4Config.repos = {
|
|
862
|
-
[repoName]: {
|
|
863
|
-
url: repoUrl,
|
|
864
|
-
path: `/home/dev/${repoName}`,
|
|
865
|
-
auth: repoUrl.startsWith('git@') ? 'ssh' : 'token',
|
|
866
|
-
},
|
|
867
|
-
};
|
|
868
|
-
}
|
|
869
|
-
}
|
|
870
|
-
// Script selection - always show multi-select UI (skip in non-interactive mode and --from-scan)
|
|
871
|
-
if (!nonInteractive && !options.fromScan) {
|
|
872
|
-
// Scan for scripts
|
|
873
|
-
const scriptsSpinner = (0, ora_1.default)('Scanning for scripts...').start();
|
|
874
|
-
const fullScan = await scanner.scan(process.cwd(), { exclude, skipScripts: false });
|
|
875
|
-
scriptsSpinner.stop();
|
|
876
|
-
if (fullScan.scripts.length > 0) {
|
|
877
|
-
console.log('');
|
|
878
|
-
console.log(chalk_1.default.blue('=== Setup Scripts ==='));
|
|
879
|
-
// Group scripts by directory for display
|
|
880
|
-
const scriptsByDir = new Map();
|
|
881
|
-
for (const script of fullScan.scripts) {
|
|
882
|
-
const dir = script.path.includes('/') ? script.path.split('/')[0] : '(root)';
|
|
883
|
-
const existing = scriptsByDir.get(dir) || [];
|
|
884
|
-
existing.push(script);
|
|
885
|
-
scriptsByDir.set(dir, existing);
|
|
886
|
-
}
|
|
887
|
-
// Show grouped scripts
|
|
888
|
-
for (const [dir, scripts] of scriptsByDir) {
|
|
889
|
-
console.log(chalk_1.default.dim(` ${dir}/ (${scripts.length} scripts)`));
|
|
890
|
-
}
|
|
891
|
-
// Let user select scripts with multi-select
|
|
892
|
-
const scriptChoices = fullScan.scripts.map(s => ({
|
|
893
|
-
name: `${s.path} (${s.stage})`,
|
|
894
|
-
value: s.path,
|
|
895
|
-
checked: s.path.startsWith('scripts/'), // Default select scripts/ directory
|
|
896
|
-
}));
|
|
897
|
-
const selectedScripts = await prompts.checkbox({
|
|
898
|
-
message: 'Select scripts to include (space to toggle, enter to confirm):',
|
|
899
|
-
choices: scriptChoices,
|
|
900
|
-
});
|
|
901
|
-
if (selectedScripts.length > 0) {
|
|
902
|
-
v4Config.scripts = fullScan.scripts
|
|
903
|
-
.filter(s => selectedScripts.includes(s.path))
|
|
904
|
-
.map(s => ({
|
|
905
|
-
name: s.name,
|
|
906
|
-
path: s.path,
|
|
907
|
-
stage: s.stage,
|
|
908
|
-
}));
|
|
909
|
-
}
|
|
910
|
-
}
|
|
911
|
-
else {
|
|
912
|
-
console.log(chalk_1.default.dim('No scripts found.'));
|
|
913
|
-
}
|
|
914
|
-
}
|
|
915
|
-
// Save configuration
|
|
916
|
-
const yamlContent = yaml.dump(v4Config, {
|
|
917
|
-
lineWidth: 120,
|
|
918
|
-
noRefs: true,
|
|
919
|
-
quotingType: '"',
|
|
920
|
-
});
|
|
921
|
-
fs_1.default.writeFileSync(configPath, yamlContent);
|
|
922
|
-
console.log(chalk_1.default.green(`\n✔ Configuration saved to ${CONFIG_FILENAME}`));
|
|
923
|
-
// Add API URLs from environments to envVarsToAdd
|
|
924
|
-
// Always add LOCAL_API_URL for local development
|
|
925
|
-
envVarsToAdd['LOCAL_API_URL'] = 'http://localhost:3050';
|
|
926
|
-
if (v4Config.environments) {
|
|
927
|
-
for (const [envName, envConfig] of Object.entries(v4Config.environments)) {
|
|
928
|
-
// v4 format: urls.api contains the API URL
|
|
929
|
-
const apiUrl = envConfig.urls?.api || envConfig.urls?.gateway;
|
|
930
|
-
if (apiUrl) {
|
|
931
|
-
const varName = `${envName.toUpperCase()}_API_URL`;
|
|
932
|
-
envVarsToAdd[varName] = apiUrl;
|
|
933
|
-
}
|
|
934
|
-
}
|
|
935
|
-
}
|
|
936
|
-
// Load detected service URLs if using --from-scan
|
|
937
|
-
let detectedServiceUrls;
|
|
938
|
-
if (options.fromScan) {
|
|
939
|
-
const detectedPath = path_1.default.join(process.cwd(), '.genbox', 'detected.yaml');
|
|
940
|
-
try {
|
|
941
|
-
const content = fs_1.default.readFileSync(detectedPath, 'utf8');
|
|
942
|
-
const detectedConfig = yaml.load(content);
|
|
943
|
-
detectedServiceUrls = detectedConfig.service_urls;
|
|
944
|
-
}
|
|
945
|
-
catch { }
|
|
946
|
-
}
|
|
947
|
-
// Generate .env.genbox
|
|
948
|
-
await setupEnvFile(projectName, v4Config, nonInteractive, scan, isMultiRepo, envVarsToAdd, overwriteExisting, detectedServiceUrls);
|
|
949
|
-
// Show warnings
|
|
950
|
-
if (generated.warnings.length > 0) {
|
|
951
|
-
console.log('');
|
|
952
|
-
console.log(chalk_1.default.yellow('Warnings:'));
|
|
953
|
-
for (const warning of generated.warnings) {
|
|
954
|
-
console.log(chalk_1.default.dim(` - ${warning}`));
|
|
955
|
-
}
|
|
956
|
-
}
|
|
957
|
-
// CORS Configuration Warning
|
|
958
|
-
const hasBackendApps = scan.apps.some(a => a.type === 'backend' || a.type === 'api');
|
|
959
|
-
const hasFrontendApps = scan.apps.some(a => a.type === 'frontend');
|
|
960
|
-
if (hasBackendApps && hasFrontendApps) {
|
|
961
|
-
console.log('');
|
|
962
|
-
console.log(chalk_1.default.yellow('=== CORS Configuration Required ==='));
|
|
963
|
-
console.log(chalk_1.default.white('To use genbox environments, add .genbox.dev to your backend CORS config:'));
|
|
964
|
-
console.log('');
|
|
965
|
-
console.log(chalk_1.default.dim(' NestJS (main.ts):'));
|
|
966
|
-
console.log(chalk_1.default.cyan(` app.enableCors({`));
|
|
967
|
-
console.log(chalk_1.default.cyan(` origin: [/\\.genbox\\.dev$/, ...otherOrigins],`));
|
|
968
|
-
console.log(chalk_1.default.cyan(` credentials: true,`));
|
|
969
|
-
console.log(chalk_1.default.cyan(` });`));
|
|
970
|
-
console.log('');
|
|
971
|
-
console.log(chalk_1.default.dim(' Express:'));
|
|
972
|
-
console.log(chalk_1.default.cyan(` app.use(cors({ origin: /\\.genbox\\.dev$/ }));`));
|
|
973
|
-
console.log('');
|
|
974
|
-
console.log(chalk_1.default.dim(' Or use env var: CORS_ORIGINS=*.genbox.dev'));
|
|
975
|
-
console.log('');
|
|
976
|
-
console.log(chalk_1.default.red(' Without this, you will see CORS errors when accessing genbox environments.'));
|
|
977
|
-
// OAuth Configuration Instructions
|
|
978
|
-
console.log('');
|
|
979
|
-
console.log(chalk_1.default.yellow('=== OAuth Provider Configuration ==='));
|
|
980
|
-
console.log(chalk_1.default.white('If your app uses OAuth (Google, GitHub, etc.), add *.genbox.dev to allowed domains:'));
|
|
981
|
-
console.log('');
|
|
982
|
-
console.log(chalk_1.default.dim(' Google OAuth (console.cloud.google.com):'));
|
|
983
|
-
console.log(chalk_1.default.cyan(' Authorized JavaScript origins:'));
|
|
984
|
-
console.log(chalk_1.default.cyan(' https://*.genbox.dev'));
|
|
985
|
-
console.log(chalk_1.default.cyan(' Authorized redirect URIs:'));
|
|
986
|
-
console.log(chalk_1.default.cyan(' https://*.genbox.dev/api/auth/callback/google'));
|
|
987
|
-
console.log(chalk_1.default.cyan(' https://*.genbox.dev/auth/google/callback'));
|
|
988
|
-
console.log('');
|
|
989
|
-
console.log(chalk_1.default.dim(' GitHub OAuth (github.com/settings/developers):'));
|
|
990
|
-
console.log(chalk_1.default.cyan(' Authorization callback URL:'));
|
|
991
|
-
console.log(chalk_1.default.cyan(' https://*.genbox.dev/api/auth/callback/github'));
|
|
992
|
-
console.log('');
|
|
993
|
-
console.log(chalk_1.default.dim(' Other providers (Auth0, Clerk, etc.):'));
|
|
994
|
-
console.log(chalk_1.default.cyan(' Add *.genbox.dev to allowed callback URLs/origins'));
|
|
995
|
-
console.log('');
|
|
996
|
-
console.log(chalk_1.default.red(' Without this, OAuth flows will fail in genbox environments.'));
|
|
997
|
-
}
|
|
998
|
-
// Next steps
|
|
999
|
-
console.log('');
|
|
1000
|
-
console.log(chalk_1.default.bold('Next steps:'));
|
|
1001
|
-
console.log(chalk_1.default.dim(` 1. Review and edit ${CONFIG_FILENAME}`));
|
|
1002
|
-
if (hasBackendApps && hasFrontendApps) {
|
|
1003
|
-
console.log(chalk_1.default.yellow(` 2. Add .genbox.dev to your backend CORS configuration`));
|
|
1004
|
-
console.log(chalk_1.default.yellow(` 3. Add *.genbox.dev to OAuth providers (Google, GitHub, etc.) if using auth`));
|
|
1005
|
-
console.log(chalk_1.default.dim(` 4. Update ${ENV_FILENAME} to use API URL variables where needed`));
|
|
1006
|
-
console.log(chalk_1.default.dim(` 5. Run 'genbox profiles' to see available profiles`));
|
|
1007
|
-
console.log(chalk_1.default.dim(` 6. Run 'genbox create <name> --profile <profile>' to create an environment`));
|
|
1008
|
-
}
|
|
1009
|
-
else {
|
|
1010
|
-
console.log(chalk_1.default.dim(` 2. Update ${ENV_FILENAME} to use API URL variables where needed`));
|
|
1011
|
-
console.log(chalk_1.default.dim(` 3. Add *.genbox.dev to OAuth providers if using auth`));
|
|
1012
|
-
console.log(chalk_1.default.dim(` 4. Run 'genbox profiles' to see available profiles`));
|
|
1013
|
-
console.log(chalk_1.default.dim(` 5. Run 'genbox create <name> --profile <profile>' to create an environment`));
|
|
1014
|
-
}
|
|
1015
|
-
}
|
|
1016
|
-
catch (error) {
|
|
1017
|
-
if (error.name === 'ExitPromptError' || error.message?.includes('force closed')) {
|
|
1018
|
-
console.log('');
|
|
1019
|
-
console.log(chalk_1.default.dim('Cancelled.'));
|
|
1020
|
-
process.exit(0);
|
|
1021
|
-
}
|
|
1022
|
-
throw error;
|
|
1023
587
|
}
|
|
1024
|
-
}
|
|
588
|
+
return { repos, envVars };
|
|
589
|
+
}
|
|
590
|
+
// =============================================================================
|
|
591
|
+
// Script Selection
|
|
592
|
+
// =============================================================================
|
|
1025
593
|
/**
|
|
1026
|
-
*
|
|
594
|
+
* Interactive script selection
|
|
1027
595
|
*/
|
|
1028
|
-
function
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
async function createDefaultProfiles(scan, config) {
|
|
1033
|
-
// Get defined environments to use in profiles
|
|
1034
|
-
const definedEnvs = Object.keys(config.environments || {});
|
|
1035
|
-
return createProfilesFromScan(scan, definedEnvs);
|
|
1036
|
-
}
|
|
1037
|
-
function createProfilesFromScan(scan, definedEnvironments = []) {
|
|
1038
|
-
const profiles = {};
|
|
1039
|
-
const frontendApps = scan.apps.filter(a => a.type === 'frontend');
|
|
1040
|
-
const backendApps = scan.apps.filter(a => a.type === 'backend' || a.type === 'api');
|
|
1041
|
-
// Docker services are also runnable (api, web services from docker-compose)
|
|
1042
|
-
const dockerServices = scan.compose?.applications || [];
|
|
1043
|
-
const dockerServiceNames = dockerServices.map(s => s.name);
|
|
1044
|
-
// Combine backend apps + Docker services (avoid duplicates)
|
|
1045
|
-
const allBackendNames = new Set([
|
|
1046
|
-
...backendApps.map(a => a.name),
|
|
1047
|
-
...dockerServiceNames.filter(name => {
|
|
1048
|
-
// Include Docker services that aren't already in apps OR aren't frontend
|
|
1049
|
-
const existingApp = scan.apps.find(a => a.name === name);
|
|
1050
|
-
return !existingApp || existingApp.type !== 'frontend';
|
|
1051
|
-
}),
|
|
1052
|
-
]);
|
|
1053
|
-
const backendAppNames = Array.from(allBackendNames);
|
|
1054
|
-
const hasBackend = backendAppNames.length > 0;
|
|
1055
|
-
// Determine which environment to use for remote connections
|
|
1056
|
-
// Priority: staging > production > first defined
|
|
1057
|
-
const remoteEnv = definedEnvironments.includes('staging')
|
|
1058
|
-
? 'staging'
|
|
1059
|
-
: definedEnvironments.includes('production')
|
|
1060
|
-
? 'production'
|
|
1061
|
-
: definedEnvironments[0];
|
|
1062
|
-
// Quick UI profiles for each frontend (v4: use default_connection instead of connect_to)
|
|
1063
|
-
// Only create if we have a remote environment defined
|
|
1064
|
-
if (remoteEnv) {
|
|
1065
|
-
for (const frontend of frontendApps.slice(0, 3)) {
|
|
1066
|
-
profiles[`${frontend.name}-quick`] = {
|
|
1067
|
-
description: `${frontend.name} only, connected to ${remoteEnv}`,
|
|
1068
|
-
size: 'small',
|
|
1069
|
-
apps: [frontend.name],
|
|
1070
|
-
default_connection: remoteEnv,
|
|
1071
|
-
};
|
|
1072
|
-
}
|
|
1073
|
-
}
|
|
1074
|
-
else {
|
|
1075
|
-
// No remote environment - create local-only profiles
|
|
1076
|
-
for (const frontend of frontendApps.slice(0, 3)) {
|
|
1077
|
-
profiles[`${frontend.name}-local`] = {
|
|
1078
|
-
description: `${frontend.name} with local backend`,
|
|
1079
|
-
size: 'medium',
|
|
1080
|
-
apps: [frontend.name, ...backendAppNames],
|
|
1081
|
-
};
|
|
1082
|
-
}
|
|
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;
|
|
1083
600
|
}
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
...(remoteEnv && { source: dbSource }),
|
|
1095
|
-
},
|
|
1096
|
-
};
|
|
1097
|
-
}
|
|
1098
|
-
// Backend/service development only (for each backend app and Docker service)
|
|
1099
|
-
for (const backendName of backendAppNames) {
|
|
1100
|
-
profiles[`${backendName}-dev`] = {
|
|
1101
|
-
description: `${backendName} with local infrastructure`,
|
|
1102
|
-
size: 'medium',
|
|
1103
|
-
apps: [backendName],
|
|
1104
|
-
database: {
|
|
1105
|
-
mode: 'local',
|
|
1106
|
-
},
|
|
1107
|
-
};
|
|
1108
|
-
}
|
|
1109
|
-
// All frontends + remote backend (only if remote env defined)
|
|
1110
|
-
if (frontendApps.length > 1 && remoteEnv) {
|
|
1111
|
-
profiles[`frontends-${remoteEnv}`] = {
|
|
1112
|
-
description: `All frontends with ${remoteEnv} backend`,
|
|
1113
|
-
size: 'medium',
|
|
1114
|
-
apps: frontendApps.map(a => a.name),
|
|
1115
|
-
default_connection: remoteEnv,
|
|
1116
|
-
};
|
|
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);
|
|
1117
611
|
}
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
...scan.apps.filter(a => a.type !== 'library').map(a => a.name),
|
|
1121
|
-
...dockerServiceNames,
|
|
1122
|
-
]);
|
|
1123
|
-
if (allRunnableNames.size > 1) {
|
|
1124
|
-
const dbSource = remoteEnv || 'staging';
|
|
1125
|
-
profiles['full-stack'] = {
|
|
1126
|
-
description: 'Everything local' + (remoteEnv ? ' with DB copy' : ''),
|
|
1127
|
-
size: 'xl',
|
|
1128
|
-
apps: Array.from(allRunnableNames),
|
|
1129
|
-
database: {
|
|
1130
|
-
mode: remoteEnv ? 'copy' : 'local',
|
|
1131
|
-
...(remoteEnv && { source: dbSource }),
|
|
1132
|
-
},
|
|
1133
|
-
};
|
|
612
|
+
for (const [dir, scripts] of scriptsByDir) {
|
|
613
|
+
console.log(chalk_1.default.dim(` ${dir}/ (${scripts.length} scripts)`));
|
|
1134
614
|
}
|
|
1135
|
-
return profiles;
|
|
1136
|
-
}
|
|
1137
|
-
/**
|
|
1138
|
-
* Setup git authentication
|
|
1139
|
-
*/
|
|
1140
|
-
async function setupGitAuth(gitInfo, projectName) {
|
|
1141
615
|
console.log('');
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
],
|
|
1151
|
-
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,
|
|
1152
624
|
});
|
|
1153
|
-
let repoUrl = gitInfo.remote;
|
|
1154
|
-
if (authMethod === 'token') {
|
|
1155
|
-
// Convert SSH to HTTPS if needed
|
|
1156
|
-
if (gitInfo.type === 'ssh') {
|
|
1157
|
-
repoUrl = (0, scan_1.sshToHttps)(gitInfo.remote);
|
|
1158
|
-
console.log(chalk_1.default.dim(` Will use HTTPS: ${repoUrl}`));
|
|
1159
|
-
}
|
|
1160
|
-
// Show token setup instructions
|
|
1161
|
-
console.log('');
|
|
1162
|
-
console.log(chalk_1.default.yellow(' Add your token to .env.genbox:'));
|
|
1163
|
-
console.log(chalk_1.default.white(' GIT_TOKEN=ghp_xxxxxxxxxxxx'));
|
|
1164
|
-
}
|
|
1165
|
-
else if (authMethod === 'ssh') {
|
|
1166
|
-
// Convert HTTPS to SSH if needed
|
|
1167
|
-
if (gitInfo.type === 'https') {
|
|
1168
|
-
repoUrl = (0, scan_1.httpsToSsh)(gitInfo.remote);
|
|
1169
|
-
console.log(chalk_1.default.dim(` Will use SSH: ${repoUrl}`));
|
|
1170
|
-
}
|
|
1171
|
-
// Detect SSH keys
|
|
1172
|
-
const scanKeys = await prompts.confirm({
|
|
1173
|
-
message: 'Scan ~/.ssh/ for available keys?',
|
|
1174
|
-
default: true,
|
|
1175
|
-
});
|
|
1176
|
-
let sshKeyPath = path_1.default.join(os.homedir(), '.ssh', 'id_ed25519');
|
|
1177
|
-
if (scanKeys) {
|
|
1178
|
-
const keys = (0, scan_1.detectSshKeys)();
|
|
1179
|
-
if (keys.length > 0) {
|
|
1180
|
-
const keyChoices = keys.map((k) => ({
|
|
1181
|
-
name: `${k.name} (${k.type})`,
|
|
1182
|
-
value: k.path,
|
|
1183
|
-
}));
|
|
1184
|
-
sshKeyPath = await prompts.select({
|
|
1185
|
-
message: 'Select SSH key:',
|
|
1186
|
-
choices: keyChoices,
|
|
1187
|
-
});
|
|
1188
|
-
}
|
|
1189
|
-
}
|
|
1190
|
-
console.log('');
|
|
1191
|
-
console.log(chalk_1.default.yellow(' Add your SSH key to .env.genbox:'));
|
|
1192
|
-
console.log(chalk_1.default.white(` GIT_SSH_KEY="$(cat ${sshKeyPath})"`));
|
|
1193
|
-
}
|
|
1194
|
-
const repoName = path_1.default.basename(repoUrl, '.git');
|
|
1195
625
|
return {
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
url: repoUrl,
|
|
1199
|
-
path: `/home/dev/${repoName}`,
|
|
1200
|
-
auth: authMethod === 'public' ? undefined : authMethod,
|
|
1201
|
-
},
|
|
1202
|
-
},
|
|
626
|
+
...detected,
|
|
627
|
+
scripts: detected.scripts.filter(s => selectedScripts.includes(s.path)),
|
|
1203
628
|
};
|
|
1204
629
|
}
|
|
630
|
+
// =============================================================================
|
|
631
|
+
// Environment + Service URL Configuration (Combined)
|
|
632
|
+
// =============================================================================
|
|
1205
633
|
/**
|
|
1206
|
-
*
|
|
634
|
+
* Combined environment and service URL configuration
|
|
1207
635
|
*/
|
|
1208
|
-
async function
|
|
1209
|
-
const
|
|
1210
|
-
|
|
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
|
|
1211
643
|
const envChoice = await prompts.select({
|
|
1212
644
|
message: 'Which environments do you want to configure?',
|
|
1213
645
|
choices: [
|
|
1214
|
-
{ name: 'Staging only', value: 'staging'
|
|
1215
|
-
{ name: 'Production only', value: 'production'
|
|
1216
|
-
{ name: 'Both staging and production', value: 'both'
|
|
1217
|
-
{ name: 'Skip for now', value: 'skip'
|
|
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' },
|
|
1218
650
|
],
|
|
1219
651
|
default: 'staging',
|
|
1220
652
|
});
|
|
1221
|
-
if (envChoice
|
|
1222
|
-
return { environments: undefined, databaseUrls };
|
|
1223
|
-
}
|
|
1224
|
-
console.log('');
|
|
1225
|
-
console.log(chalk_1.default.blue('=== Environment Setup ==='));
|
|
1226
|
-
console.log(chalk_1.default.dim('These URLs will be used when connecting to external services.'));
|
|
1227
|
-
console.log(chalk_1.default.dim('Database URLs are stored in .env.genbox for database copy operations.'));
|
|
1228
|
-
const environments = {};
|
|
1229
|
-
// Get existing URLs if available
|
|
1230
|
-
const existingStagingApiUrl = existingEnvValues['STAGING_API_URL'];
|
|
1231
|
-
const existingProductionApiUrl = existingEnvValues['PRODUCTION_API_URL'] || existingEnvValues['PROD_API_URL'];
|
|
1232
|
-
const existingStagingMongoUrl = existingEnvValues['STAGING_MONGODB_URL'];
|
|
1233
|
-
const existingProdMongoUrl = existingEnvValues['PROD_MONGODB_URL'] || existingEnvValues['PRODUCTION_MONGODB_URL'];
|
|
1234
|
-
const configureStaging = envChoice === 'staging' || envChoice === 'both';
|
|
1235
|
-
const configureProduction = envChoice === 'production' || envChoice === 'both';
|
|
1236
|
-
// Configure staging if selected
|
|
1237
|
-
if (configureStaging) {
|
|
653
|
+
if (envChoice !== 'skip') {
|
|
1238
654
|
console.log('');
|
|
1239
|
-
console.log(chalk_1.default.
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
(app.name === 'api' ? existingStagingApiUrl : '');
|
|
1247
|
-
if (existingUrl) {
|
|
1248
|
-
console.log(chalk_1.default.dim(` Found existing value for ${app.name}: ${existingUrl}`));
|
|
1249
|
-
const useExisting = await prompts.confirm({
|
|
1250
|
-
message: ` Use existing ${app.name} staging URL?`,
|
|
1251
|
-
default: true,
|
|
1252
|
-
});
|
|
1253
|
-
if (useExisting) {
|
|
1254
|
-
urls[app.name] = existingUrl;
|
|
1255
|
-
continue;
|
|
1256
|
-
}
|
|
1257
|
-
}
|
|
1258
|
-
const url = await prompts.input({
|
|
1259
|
-
message: ` ${app.name} staging URL:`,
|
|
1260
|
-
default: '',
|
|
1261
|
-
});
|
|
1262
|
-
if (url) {
|
|
1263
|
-
urls[app.name] = url;
|
|
1264
|
-
}
|
|
1265
|
-
}
|
|
1266
|
-
if (Object.keys(urls).length > 0) {
|
|
1267
|
-
environments.staging = {
|
|
1268
|
-
description: 'Staging environment',
|
|
1269
|
-
urls,
|
|
1270
|
-
};
|
|
1271
|
-
}
|
|
1272
|
-
}
|
|
1273
|
-
else {
|
|
1274
|
-
const stagingApiUrl = await promptForApiUrl('staging', existingStagingApiUrl);
|
|
1275
|
-
if (stagingApiUrl) {
|
|
1276
|
-
environments.staging = {
|
|
1277
|
-
description: 'Staging environment',
|
|
1278
|
-
urls: { api: stagingApiUrl },
|
|
1279
|
-
};
|
|
1280
|
-
}
|
|
1281
|
-
}
|
|
1282
|
-
}
|
|
1283
|
-
else {
|
|
1284
|
-
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']);
|
|
1285
662
|
if (stagingApiUrl) {
|
|
1286
663
|
environments.staging = {
|
|
1287
664
|
description: 'Staging environment',
|
|
1288
665
|
urls: { api: stagingApiUrl },
|
|
1289
666
|
};
|
|
667
|
+
envVars['STAGING_API_URL'] = stagingApiUrl;
|
|
1290
668
|
}
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
console.log(chalk_1.default.dim(' Database URL (for "Copy from staging" database mode):'));
|
|
1295
|
-
console.log(chalk_1.default.dim(' Format: mongodb+srv://user:password@staging.mongodb.net/dbname'));
|
|
1296
|
-
if (existingStagingMongoUrl) {
|
|
1297
|
-
console.log(chalk_1.default.dim(` Found existing value: ${existingStagingMongoUrl.substring(0, 50)}...`));
|
|
1298
|
-
const useExisting = await prompts.confirm({
|
|
1299
|
-
message: ' Use existing staging MongoDB URL?',
|
|
1300
|
-
default: true,
|
|
1301
|
-
});
|
|
1302
|
-
if (useExisting) {
|
|
1303
|
-
databaseUrls['STAGING_MONGODB_URL'] = existingStagingMongoUrl;
|
|
1304
|
-
}
|
|
1305
|
-
else {
|
|
1306
|
-
const mongoUrl = await prompts.input({
|
|
1307
|
-
message: ' Staging MongoDB URL:',
|
|
1308
|
-
});
|
|
1309
|
-
if (mongoUrl) {
|
|
1310
|
-
databaseUrls['STAGING_MONGODB_URL'] = mongoUrl;
|
|
1311
|
-
}
|
|
1312
|
-
}
|
|
1313
|
-
}
|
|
1314
|
-
else {
|
|
1315
|
-
const mongoUrl = await prompts.input({
|
|
1316
|
-
message: ' Staging MongoDB URL (optional, press Enter to skip):',
|
|
1317
|
-
});
|
|
1318
|
-
if (mongoUrl) {
|
|
1319
|
-
databaseUrls['STAGING_MONGODB_URL'] = mongoUrl;
|
|
1320
|
-
}
|
|
1321
|
-
}
|
|
1322
|
-
}
|
|
1323
|
-
// Configure production if selected
|
|
1324
|
-
if (configureProduction) {
|
|
1325
|
-
console.log('');
|
|
1326
|
-
console.log(chalk_1.default.cyan('Production Environment:'));
|
|
1327
|
-
if (isMultiRepo) {
|
|
1328
|
-
const backendApps = scan.apps.filter(a => a.type === 'backend' || a.type === 'api');
|
|
1329
|
-
if (backendApps.length > 0) {
|
|
1330
|
-
const prodUrls = {};
|
|
1331
|
-
for (const app of backendApps) {
|
|
1332
|
-
const existingUrl = existingEnvValues[`PRODUCTION_${app.name.toUpperCase()}_URL`] ||
|
|
1333
|
-
existingEnvValues[`PROD_${app.name.toUpperCase()}_URL`] ||
|
|
1334
|
-
(app.name === 'api' ? existingProductionApiUrl : '');
|
|
1335
|
-
if (existingUrl) {
|
|
1336
|
-
console.log(chalk_1.default.dim(` Found existing value for ${app.name}: ${existingUrl}`));
|
|
1337
|
-
const useExisting = await prompts.confirm({
|
|
1338
|
-
message: ` Use existing ${app.name} production URL?`,
|
|
1339
|
-
default: true,
|
|
1340
|
-
});
|
|
1341
|
-
if (useExisting) {
|
|
1342
|
-
prodUrls[app.name] = existingUrl;
|
|
1343
|
-
continue;
|
|
1344
|
-
}
|
|
1345
|
-
}
|
|
1346
|
-
const url = await prompts.input({
|
|
1347
|
-
message: ` ${app.name} production URL:`,
|
|
1348
|
-
default: '',
|
|
1349
|
-
});
|
|
1350
|
-
if (url) {
|
|
1351
|
-
prodUrls[app.name] = url;
|
|
1352
|
-
}
|
|
1353
|
-
}
|
|
1354
|
-
if (Object.keys(prodUrls).length > 0) {
|
|
1355
|
-
environments.production = {
|
|
1356
|
-
description: 'Production (use with caution)',
|
|
1357
|
-
urls: prodUrls,
|
|
1358
|
-
safety: {
|
|
1359
|
-
read_only: true,
|
|
1360
|
-
require_confirmation: true,
|
|
1361
|
-
},
|
|
1362
|
-
};
|
|
1363
|
-
}
|
|
1364
|
-
}
|
|
1365
|
-
else {
|
|
1366
|
-
const prodApiUrl = await promptForApiUrl('production', existingProductionApiUrl);
|
|
1367
|
-
if (prodApiUrl) {
|
|
1368
|
-
environments.production = {
|
|
1369
|
-
description: 'Production (use with caution)',
|
|
1370
|
-
urls: { api: prodApiUrl },
|
|
1371
|
-
safety: {
|
|
1372
|
-
read_only: true,
|
|
1373
|
-
require_confirmation: true,
|
|
1374
|
-
},
|
|
1375
|
-
};
|
|
1376
|
-
}
|
|
669
|
+
const stagingMongoUrl = await promptWithExisting(' Staging MongoDB URL (optional):', existingEnvValues['STAGING_MONGODB_URL'], true);
|
|
670
|
+
if (stagingMongoUrl) {
|
|
671
|
+
envVars['STAGING_MONGODB_URL'] = stagingMongoUrl;
|
|
1377
672
|
}
|
|
1378
673
|
}
|
|
1379
|
-
|
|
1380
|
-
|
|
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']);
|
|
1381
678
|
if (prodApiUrl) {
|
|
1382
679
|
environments.production = {
|
|
1383
680
|
description: 'Production (use with caution)',
|
|
@@ -1387,613 +684,947 @@ async function setupEnvironments(scan, config, isMultiRepo = false, existingEnvV
|
|
|
1387
684
|
require_confirmation: true,
|
|
1388
685
|
},
|
|
1389
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;
|
|
1390
692
|
}
|
|
1391
693
|
}
|
|
1392
|
-
|
|
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) {
|
|
1393
748
|
console.log('');
|
|
1394
|
-
console.log(chalk_1.default.dim(
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
const
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
if (
|
|
1403
|
-
|
|
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}`));
|
|
1404
760
|
}
|
|
1405
761
|
else {
|
|
1406
|
-
const
|
|
1407
|
-
message:
|
|
762
|
+
const inputUrl = await prompts.input({
|
|
763
|
+
message: ` ${svc.base_url} → (${primaryEnv} URL, leave empty to skip):`,
|
|
764
|
+
default: '',
|
|
1408
765
|
});
|
|
1409
|
-
|
|
1410
|
-
|
|
766
|
+
remoteUrl = inputUrl;
|
|
767
|
+
if (remoteUrl) {
|
|
768
|
+
console.log(chalk_1.default.green(` ✓ Mapped to ${remoteUrl}`));
|
|
1411
769
|
}
|
|
1412
770
|
}
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
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,
|
|
1417
777
|
});
|
|
1418
|
-
if (mongoUrl) {
|
|
1419
|
-
databaseUrls['PROD_MONGODB_URL'] = mongoUrl;
|
|
1420
|
-
}
|
|
1421
778
|
}
|
|
1422
779
|
}
|
|
1423
|
-
return
|
|
1424
|
-
environments: Object.keys(environments).length > 0 ? environments : undefined,
|
|
1425
|
-
databaseUrls,
|
|
1426
|
-
};
|
|
780
|
+
return mappings;
|
|
1427
781
|
}
|
|
782
|
+
// =============================================================================
|
|
783
|
+
// Profile Generation and Editing
|
|
784
|
+
// =============================================================================
|
|
1428
785
|
/**
|
|
1429
|
-
*
|
|
786
|
+
* Generate and allow editing of profiles
|
|
1430
787
|
*/
|
|
1431
|
-
async function
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
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}`));
|
|
1440
808
|
}
|
|
809
|
+
if (profile.database) {
|
|
810
|
+
console.log(chalk_1.default.dim(` Database: ${profile.database.mode}${profile.database.source ? ` from ${profile.database.source}` : ''}`));
|
|
811
|
+
}
|
|
812
|
+
console.log('');
|
|
1441
813
|
}
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
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,
|
|
1445
818
|
});
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
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
|
|
837
|
+
*/
|
|
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
|
+
};
|
|
1456
936
|
}
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
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 GenboxConfigV4 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;
|
|
1460
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;
|
|
1461
1025
|
}
|
|
1462
|
-
//
|
|
1463
|
-
|
|
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
|
|
1464
1069
|
# Project: ${projectName}
|
|
1465
1070
|
# DO NOT COMMIT THIS FILE
|
|
1466
1071
|
#
|
|
1467
1072
|
# This file uses segregated sections for each app/service.
|
|
1468
1073
|
# At 'genbox create' time, only GLOBAL + selected app sections are used.
|
|
1469
|
-
# Use \${API_URL} for dynamic API URLs based on profile's connect_to setting.
|
|
1470
1074
|
|
|
1471
1075
|
# === GLOBAL ===
|
|
1472
1076
|
# These variables are always included regardless of which apps are selected
|
|
1473
1077
|
|
|
1474
1078
|
`;
|
|
1475
|
-
// Add
|
|
1476
|
-
for (const [key, value] of Object.entries(
|
|
1477
|
-
|
|
1478
|
-
}
|
|
1479
|
-
// Add
|
|
1480
|
-
if (
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
segregatedContent += `\n# Database URLs (used by profiles with database mode)\n`;
|
|
1488
|
-
if (!hasStagingMongo) {
|
|
1489
|
-
segregatedContent += `# STAGING_MONGODB_URL=mongodb+srv://user:password@staging.mongodb.net\n`;
|
|
1490
|
-
}
|
|
1491
|
-
if (!hasProdMongo) {
|
|
1492
|
-
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
|
+
}
|
|
1493
1091
|
}
|
|
1494
1092
|
}
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
const
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
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';
|
|
1507
1106
|
}
|
|
1508
|
-
|
|
1509
|
-
|
|
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}` };
|
|
1510
1189
|
}
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
const selectedEnvFiles = await prompts.checkbox({
|
|
1517
|
-
message: 'Select .env files to include in .env.genbox:',
|
|
1518
|
-
choices: envChoices,
|
|
1519
|
-
});
|
|
1520
|
-
if (selectedEnvFiles.length > 0) {
|
|
1521
|
-
for (const envFilePath of selectedEnvFiles) {
|
|
1522
|
-
const appInfo = appEnvFiles.find(e => e.fullPath === envFilePath);
|
|
1523
|
-
if (!appInfo)
|
|
1524
|
-
continue;
|
|
1525
|
-
const content = fs_1.default.readFileSync(envFilePath, 'utf8').trim();
|
|
1526
|
-
// Add section header and content
|
|
1527
|
-
segregatedContent += `# === ${appInfo.appName} ===\n`;
|
|
1528
|
-
segregatedContent += content;
|
|
1529
|
-
segregatedContent += '\n\n';
|
|
1530
|
-
}
|
|
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}` };
|
|
1531
1195
|
}
|
|
1532
1196
|
}
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
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
|
+
}
|
|
1540
1278
|
}
|
|
1279
|
+
catch { }
|
|
1541
1280
|
}
|
|
1542
1281
|
}
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
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');
|
|
1553
1296
|
break;
|
|
1554
1297
|
}
|
|
1555
1298
|
}
|
|
1556
|
-
if (
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
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() });
|
|
1567
1325
|
}
|
|
1326
|
+
serviceUrls.get(baseUrl).vars.add(varName);
|
|
1327
|
+
serviceUrls.get(baseUrl).apps.add(appName);
|
|
1568
1328
|
}
|
|
1569
1329
|
}
|
|
1570
|
-
|
|
1571
|
-
const
|
|
1572
|
-
|
|
1573
|
-
.
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
}
|
|
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
|
+
});
|
|
1581
1341
|
}
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
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;
|
|
1594
1374
|
}
|
|
1595
|
-
console.log('');
|
|
1596
|
-
console.log(chalk_1.default.dim(`Found ${detectedServiceUrls.length} service URL(s) from detected.yaml`));
|
|
1597
|
-
}
|
|
1598
|
-
else {
|
|
1599
|
-
// Fall back to extracting from collected env content
|
|
1600
|
-
serviceUrls = extractFrontendHttpUrls(segregatedContent, frontendApps);
|
|
1601
1375
|
}
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
const
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
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
|
+
}
|
|
1613
1392
|
}
|
|
1614
1393
|
}
|
|
1394
|
+
catch { }
|
|
1615
1395
|
}
|
|
1616
1396
|
}
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
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;
|
|
1624
1406
|
}
|
|
1625
|
-
|
|
1626
|
-
console.log(
|
|
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('');
|
|
1627
1414
|
}
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
}
|
|
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;
|
|
1636
1426
|
}
|
|
1427
|
+
return await prompts.input({
|
|
1428
|
+
message,
|
|
1429
|
+
default: existingValue || '',
|
|
1430
|
+
});
|
|
1637
1431
|
}
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
'# Genbox Environment Variables',
|
|
1644
|
-
`# Project: ${projectName}`,
|
|
1645
|
-
'# DO NOT COMMIT THIS FILE',
|
|
1646
|
-
'',
|
|
1647
|
-
'# ============================================',
|
|
1648
|
-
'# API URL CONFIGURATION',
|
|
1649
|
-
'# ============================================',
|
|
1650
|
-
'# Use ${API_URL} in your app env vars (e.g., VITE_API_BASE_URL=${API_URL})',
|
|
1651
|
-
'# At create time, ${API_URL} expands based on profile:',
|
|
1652
|
-
'# - connect_to: staging → uses STAGING_API_URL',
|
|
1653
|
-
'# - connect_to: production → uses PRODUCTION_API_URL',
|
|
1654
|
-
'# - local/no connect_to → uses LOCAL_API_URL',
|
|
1655
|
-
'',
|
|
1656
|
-
'LOCAL_API_URL=http://localhost:3050',
|
|
1657
|
-
'STAGING_API_URL=https://api.staging.example.com',
|
|
1658
|
-
'# PRODUCTION_API_URL=https://api.example.com',
|
|
1659
|
-
'',
|
|
1660
|
-
'# ============================================',
|
|
1661
|
-
'# STAGING ENVIRONMENT',
|
|
1662
|
-
'# ============================================',
|
|
1663
|
-
'',
|
|
1664
|
-
'# Database',
|
|
1665
|
-
'STAGING_MONGODB_URL=mongodb+srv://user:password@staging.mongodb.net',
|
|
1666
|
-
'',
|
|
1667
|
-
'# Cache & Queue',
|
|
1668
|
-
'STAGING_REDIS_URL=redis://staging-redis:6379',
|
|
1669
|
-
'STAGING_RABBITMQ_URL=amqp://user:password@staging-rabbitmq:5672',
|
|
1670
|
-
'',
|
|
1671
|
-
'# ============================================',
|
|
1672
|
-
'# PRODUCTION ENVIRONMENT',
|
|
1673
|
-
'# ============================================',
|
|
1674
|
-
'',
|
|
1675
|
-
'PROD_MONGODB_URL=mongodb+srv://readonly:password@prod.mongodb.net',
|
|
1676
|
-
'',
|
|
1677
|
-
'# ============================================',
|
|
1678
|
-
'# GIT AUTHENTICATION',
|
|
1679
|
-
'# ============================================',
|
|
1680
|
-
'',
|
|
1681
|
-
'# For HTTPS repos (Personal Access Token)',
|
|
1682
|
-
'GIT_TOKEN=ghp_xxxxxxxxxxxx',
|
|
1683
|
-
'',
|
|
1684
|
-
'# For SSH repos (paste private key content)',
|
|
1685
|
-
'# GIT_SSH_KEY="-----BEGIN OPENSSH PRIVATE KEY-----',
|
|
1686
|
-
'# ...',
|
|
1687
|
-
'# -----END OPENSSH PRIVATE KEY-----"',
|
|
1688
|
-
'',
|
|
1689
|
-
'# ============================================',
|
|
1690
|
-
'# APPLICATION SECRETS',
|
|
1691
|
-
'# ============================================',
|
|
1692
|
-
'',
|
|
1693
|
-
'JWT_SECRET=your-jwt-secret-here',
|
|
1694
|
-
'',
|
|
1695
|
-
'# OAuth',
|
|
1696
|
-
'GOOGLE_CLIENT_ID=',
|
|
1697
|
-
'GOOGLE_CLIENT_SECRET=',
|
|
1698
|
-
'',
|
|
1699
|
-
'# Payments',
|
|
1700
|
-
'STRIPE_SECRET_KEY=sk_test_xxx',
|
|
1701
|
-
'STRIPE_WEBHOOK_SECRET=whsec_xxx',
|
|
1702
|
-
'',
|
|
1703
|
-
'# ============================================',
|
|
1704
|
-
'# APPLICATION ENV VARS',
|
|
1705
|
-
'# ============================================',
|
|
1706
|
-
'# Use ${API_URL} for dynamic API URLs',
|
|
1707
|
-
'',
|
|
1708
|
-
'# Example:',
|
|
1709
|
-
'# VITE_API_BASE_URL=${API_URL}',
|
|
1710
|
-
'# NEXT_PUBLIC_API_URL=${API_URL}',
|
|
1711
|
-
'',
|
|
1712
|
-
];
|
|
1713
|
-
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;
|
|
1714
1437
|
}
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
}
|
|
1738
|
-
|
|
1739
|
-
}
|
|
1740
|
-
}
|
|
1741
|
-
// Build commands object without undefined values
|
|
1742
|
-
const commands = {};
|
|
1743
|
-
if (service.build?.command)
|
|
1744
|
-
commands.build = service.build.command;
|
|
1745
|
-
if (service.start?.command)
|
|
1746
|
-
commands.start = service.start.command;
|
|
1747
|
-
if (service.start?.dev)
|
|
1748
|
-
commands.dev = service.start.dev;
|
|
1749
|
-
if (Object.keys(commands).length > 0) {
|
|
1750
|
-
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
|
+
}
|
|
1751
1463
|
}
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
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;
|
|
1755
1493
|
}
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
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
|
+
}
|
|
1759
1519
|
}
|
|
1760
|
-
|
|
1761
|
-
|
|
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);
|
|
1762
1526
|
}
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
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,
|
|
1773
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;
|
|
1774
1558
|
}
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
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
|
+
}
|
|
1783
1605
|
}
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
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$/]`));
|
|
1793
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`));
|
|
1794
1622
|
}
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
url: repo.url,
|
|
1800
|
-
path: repo.path,
|
|
1801
|
-
branch: repo.branch,
|
|
1802
|
-
auth: repo.auth,
|
|
1803
|
-
};
|
|
1804
|
-
}
|
|
1805
|
-
// Map structure type to v4
|
|
1806
|
-
const structureMap = {
|
|
1807
|
-
'single-app': 'single-app',
|
|
1808
|
-
'monorepo-pnpm': 'monorepo',
|
|
1809
|
-
'monorepo-yarn': 'monorepo',
|
|
1810
|
-
'monorepo-npm': 'monorepo',
|
|
1811
|
-
'microservices': 'microservices',
|
|
1812
|
-
'hybrid': 'hybrid',
|
|
1813
|
-
};
|
|
1814
|
-
// Build v4 config
|
|
1815
|
-
const v4Config = {
|
|
1816
|
-
version: 4,
|
|
1817
|
-
project: {
|
|
1818
|
-
name: v2Config.project.name,
|
|
1819
|
-
structure: structureMap[v2Config.project.structure] || 'single-app',
|
|
1820
|
-
description: v2Config.project.description,
|
|
1821
|
-
},
|
|
1822
|
-
apps,
|
|
1823
|
-
provides: Object.keys(provides).length > 0 ? provides : undefined,
|
|
1824
|
-
repos: Object.keys(repos).length > 0 ? repos : undefined,
|
|
1825
|
-
defaults: {
|
|
1826
|
-
size: v2Config.system.size,
|
|
1827
|
-
},
|
|
1828
|
-
hooks: v2Config.hooks ? {
|
|
1829
|
-
post_checkout: v2Config.hooks.postCheckout,
|
|
1830
|
-
post_start: v2Config.hooks.postStart,
|
|
1831
|
-
pre_start: v2Config.hooks.preStart,
|
|
1832
|
-
} : undefined,
|
|
1833
|
-
strict: {
|
|
1834
|
-
enabled: true,
|
|
1835
|
-
allow_detect: true,
|
|
1836
|
-
warnings_as_errors: false,
|
|
1837
|
-
},
|
|
1838
|
-
};
|
|
1839
|
-
return v4Config;
|
|
1840
|
-
}
|
|
1841
|
-
/**
|
|
1842
|
-
* Convert DetectedConfig (from detected.yaml) to ProjectScan format
|
|
1843
|
-
* This allows --from-scan to use the same code paths as fresh scanning
|
|
1844
|
-
*/
|
|
1845
|
-
function convertDetectedToScan(detected) {
|
|
1846
|
-
// Convert structure type
|
|
1847
|
-
const structureTypeMap = {
|
|
1848
|
-
'single-app': 'single-app',
|
|
1849
|
-
'monorepo': 'monorepo-pnpm',
|
|
1850
|
-
'workspace': 'hybrid',
|
|
1851
|
-
'microservices': 'microservices',
|
|
1852
|
-
'hybrid': 'hybrid',
|
|
1853
|
-
};
|
|
1854
|
-
// Convert apps
|
|
1855
|
-
const apps = [];
|
|
1856
|
-
for (const [name, app] of Object.entries(detected.apps || {})) {
|
|
1857
|
-
// Map detected type to scanner type
|
|
1858
|
-
const typeMap = {
|
|
1859
|
-
'frontend': 'frontend',
|
|
1860
|
-
'backend': 'backend',
|
|
1861
|
-
'worker': 'worker',
|
|
1862
|
-
'gateway': 'gateway',
|
|
1863
|
-
'library': 'library',
|
|
1864
|
-
};
|
|
1865
|
-
// Map runner types
|
|
1866
|
-
const runnerMap = {
|
|
1867
|
-
'pm2': 'pm2',
|
|
1868
|
-
'docker': 'docker',
|
|
1869
|
-
'node': 'node',
|
|
1870
|
-
'bun': 'bun',
|
|
1871
|
-
'none': 'none',
|
|
1872
|
-
};
|
|
1873
|
-
const discoveredApp = {
|
|
1874
|
-
name,
|
|
1875
|
-
path: app.path,
|
|
1876
|
-
type: typeMap[app.type || 'library'] || 'library',
|
|
1877
|
-
framework: app.framework,
|
|
1878
|
-
port: app.port,
|
|
1879
|
-
dependencies: app.dependencies,
|
|
1880
|
-
scripts: app.commands ? {
|
|
1881
|
-
dev: app.commands.dev || '',
|
|
1882
|
-
build: app.commands.build || '',
|
|
1883
|
-
start: app.commands.start || '',
|
|
1884
|
-
} : {},
|
|
1885
|
-
runner: app.runner ? runnerMap[app.runner] : undefined,
|
|
1886
|
-
};
|
|
1887
|
-
// Add docker config if present
|
|
1888
|
-
if (app.docker) {
|
|
1889
|
-
discoveredApp.docker = {
|
|
1890
|
-
service: app.docker.service,
|
|
1891
|
-
build_context: app.docker.build_context,
|
|
1892
|
-
dockerfile: app.docker.dockerfile,
|
|
1893
|
-
};
|
|
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);
|
|
1894
1627
|
}
|
|
1895
|
-
|
|
1896
|
-
}
|
|
1897
|
-
// Convert runtimes
|
|
1898
|
-
const runtimes = detected.runtimes.map(r => ({
|
|
1899
|
-
language: r.language,
|
|
1900
|
-
version: r.version,
|
|
1901
|
-
versionSource: r.version_source,
|
|
1902
|
-
packageManager: r.package_manager,
|
|
1903
|
-
lockfile: r.lockfile,
|
|
1904
|
-
}));
|
|
1905
|
-
// Convert infrastructure to compose analysis
|
|
1906
|
-
// Note: docker_services is deprecated - apps with runner: 'docker' are now tracked per-app
|
|
1907
|
-
let compose = null;
|
|
1908
|
-
const hasInfra = detected.infrastructure && detected.infrastructure.length > 0;
|
|
1909
|
-
// Get docker apps from apps with runner: 'docker' (new approach)
|
|
1910
|
-
const dockerApps = Object.entries(detected.apps || {})
|
|
1911
|
-
.filter(([, app]) => app.runner === 'docker')
|
|
1912
|
-
.map(([name, app]) => ({
|
|
1913
|
-
name,
|
|
1914
|
-
image: app.docker?.service || name,
|
|
1915
|
-
build: app.docker?.build_context ? {
|
|
1916
|
-
context: app.docker.build_context,
|
|
1917
|
-
dockerfile: app.docker.dockerfile,
|
|
1918
|
-
} : undefined,
|
|
1919
|
-
ports: app.port ? [{ host: app.port, container: app.port }] : [],
|
|
1920
|
-
environment: {},
|
|
1921
|
-
dependsOn: app.depends_on || [],
|
|
1922
|
-
volumes: [],
|
|
1923
|
-
}));
|
|
1924
|
-
if (hasInfra || dockerApps.length > 0) {
|
|
1925
|
-
compose = {
|
|
1926
|
-
files: ['docker-compose.yml'],
|
|
1927
|
-
applications: dockerApps,
|
|
1928
|
-
databases: (detected.infrastructure || [])
|
|
1929
|
-
.filter(i => i.type === 'database')
|
|
1930
|
-
.map(i => ({
|
|
1931
|
-
name: i.name,
|
|
1932
|
-
image: i.image,
|
|
1933
|
-
ports: [{ host: i.port, container: i.port }],
|
|
1934
|
-
environment: {},
|
|
1935
|
-
dependsOn: [],
|
|
1936
|
-
volumes: [],
|
|
1937
|
-
})),
|
|
1938
|
-
caches: (detected.infrastructure || [])
|
|
1939
|
-
.filter(i => i.type === 'cache')
|
|
1940
|
-
.map(i => ({
|
|
1941
|
-
name: i.name,
|
|
1942
|
-
image: i.image,
|
|
1943
|
-
ports: [{ host: i.port, container: i.port }],
|
|
1944
|
-
environment: {},
|
|
1945
|
-
dependsOn: [],
|
|
1946
|
-
volumes: [],
|
|
1947
|
-
})),
|
|
1948
|
-
queues: (detected.infrastructure || [])
|
|
1949
|
-
.filter(i => i.type === 'queue')
|
|
1950
|
-
.map(i => ({
|
|
1951
|
-
name: i.name,
|
|
1952
|
-
image: i.image,
|
|
1953
|
-
ports: [{ host: i.port, container: i.port }],
|
|
1954
|
-
environment: {},
|
|
1955
|
-
dependsOn: [],
|
|
1956
|
-
volumes: [],
|
|
1957
|
-
})),
|
|
1958
|
-
infrastructure: [],
|
|
1959
|
-
portMap: new Map(),
|
|
1960
|
-
dependencyGraph: new Map(),
|
|
1961
|
-
};
|
|
1628
|
+
throw error;
|
|
1962
1629
|
}
|
|
1963
|
-
|
|
1964
|
-
const git = detected.git ? {
|
|
1965
|
-
remote: detected.git.remote || '',
|
|
1966
|
-
type: detected.git.type || 'https',
|
|
1967
|
-
provider: (detected.git.provider || 'other'),
|
|
1968
|
-
branch: detected.git.branch || 'main',
|
|
1969
|
-
} : undefined;
|
|
1970
|
-
// Convert scripts
|
|
1971
|
-
const scripts = (detected.scripts || []).map(s => ({
|
|
1972
|
-
name: s.name,
|
|
1973
|
-
path: s.path,
|
|
1974
|
-
stage: s.stage,
|
|
1975
|
-
isExecutable: s.executable,
|
|
1976
|
-
}));
|
|
1977
|
-
return {
|
|
1978
|
-
projectName: path_1.default.basename(detected._meta.scanned_root),
|
|
1979
|
-
root: detected._meta.scanned_root,
|
|
1980
|
-
structure: {
|
|
1981
|
-
type: structureTypeMap[detected.structure.type] || 'single-app',
|
|
1982
|
-
confidence: detected.structure.confidence,
|
|
1983
|
-
indicators: detected.structure.indicators,
|
|
1984
|
-
},
|
|
1985
|
-
runtimes,
|
|
1986
|
-
frameworks: [], // Not stored in detected.yaml
|
|
1987
|
-
compose,
|
|
1988
|
-
apps,
|
|
1989
|
-
envAnalysis: {
|
|
1990
|
-
required: [],
|
|
1991
|
-
optional: [],
|
|
1992
|
-
secrets: [],
|
|
1993
|
-
references: [],
|
|
1994
|
-
sources: [],
|
|
1995
|
-
},
|
|
1996
|
-
git,
|
|
1997
|
-
scripts,
|
|
1998
|
-
};
|
|
1999
|
-
}
|
|
1630
|
+
});
|