@portl/cli 0.1.0

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/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@portl/cli",
3
+ "version": "0.1.0",
4
+ "description": "Portl CLI - BYOI orchestration for AI coding agents",
5
+ "type": "module",
6
+ "bin": {
7
+ "portl": "./bin/portl.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "src",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "scripts": {
16
+ "dev": "node ./bin/portl.js --help",
17
+ "pack:check": "npm pack --dry-run"
18
+ },
19
+ "engines": {
20
+ "node": ">=20"
21
+ },
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "keywords": [
26
+ "portl",
27
+ "cli",
28
+ "mcp",
29
+ "byoi",
30
+ "ai-agents",
31
+ "backend-as-a-service"
32
+ ],
33
+ "author": "Portl",
34
+ "license": "Apache-2.0",
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "https://github.com/portl-dev/portl.git",
38
+ "directory": "portl-cli"
39
+ },
40
+ "homepage": "https://portl.dev"
41
+ }
@@ -0,0 +1,663 @@
1
+ /**
2
+ * Simplified init command - conversational wizard for Portl setup
3
+ */
4
+
5
+ import { promises as fs } from 'node:fs';
6
+ import path from 'node:path';
7
+ import {
8
+ showBanner,
9
+ showSection,
10
+ showTip,
11
+ showSuccess,
12
+ showWarning,
13
+ showError,
14
+ showInfo,
15
+ showSummary,
16
+ showNextSteps,
17
+ askYesNo,
18
+ askNonEmpty,
19
+ selectOption,
20
+ Spinner,
21
+ c,
22
+ ANSI,
23
+ } from '../utils/prompts.js';
24
+
25
+ /**
26
+ * Read existing portl.config.json if it exists
27
+ */
28
+ async function readExistingConfig(cwd) {
29
+ try {
30
+ const configPath = path.join(cwd, 'portl.config.json');
31
+ const content = await fs.readFile(configPath, 'utf8');
32
+ return JSON.parse(content);
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Detect API key from environment or .env file
40
+ */
41
+ async function detectApiKey(cwd) {
42
+ // Check environment variable first
43
+ if (process.env.ACCESS_API_KEY) {
44
+ return process.env.ACCESS_API_KEY;
45
+ }
46
+
47
+ // Try to read from .env file
48
+ try {
49
+ const envPath = path.join(cwd, '.env');
50
+ const content = await fs.readFile(envPath, 'utf8');
51
+ const match = content.match(/^ACCESS_API_KEY\s*=\s*(.+)$/m);
52
+ if (match && match[1]) {
53
+ return match[1].trim().replace(/^['"]|['"]$/g, '');
54
+ }
55
+ } catch {
56
+ // .env file doesn't exist or can't be read
57
+ }
58
+
59
+ return null;
60
+ }
61
+
62
+ /**
63
+ * Collect database configuration
64
+ */
65
+ async function collectDatabaseConfig(rl, apiKey, apiBaseUrl, projectId) {
66
+ showSection('DATABASE', '📦');
67
+
68
+ const needsDatabase = await askYesNo(rl, 'Do you need a database?', true);
69
+ showTip('Portl connects to your existing database (PostgreSQL, MySQL, MongoDB, Supabase, Neon)');
70
+ console.log('');
71
+
72
+ if (!needsDatabase) {
73
+ showInfo('Skipping database configuration');
74
+ return null;
75
+ }
76
+
77
+ const provider = await selectOption(rl, 'Choose your database provider', [
78
+ { label: 'Supabase', value: 'supabase', description: 'PostgreSQL + Auth + Storage' },
79
+ { label: 'Neon', value: 'neon', description: 'Serverless PostgreSQL' },
80
+ { label: 'PostgreSQL', value: 'postgres.generic', description: 'Self-hosted or managed' },
81
+ { label: 'MySQL', value: 'mysql', description: 'Self-hosted or managed' },
82
+ { label: 'MongoDB', value: 'mongodb', description: 'Document database' },
83
+ { label: 'PlanetScale', value: 'planetscale', description: 'Serverless MySQL' },
84
+ ]);
85
+
86
+ console.log('');
87
+
88
+ if (provider.value === 'supabase') {
89
+ const useOAuth = await askYesNo(rl, '🔐 Connect via OAuth?', true);
90
+ console.log('');
91
+
92
+ if (useOAuth) {
93
+ if (apiKey) {
94
+ // Execute OAuth flow immediately
95
+ showInfo('Opening browser for Supabase authentication...');
96
+
97
+ try {
98
+ // Import OAuth functions from main portl.js
99
+ const { startSupabaseOAuthRequest, waitForSupabaseOAuthConnection } = await import('../../bin/portl.js');
100
+
101
+ const resolvedApiBaseUrl = apiBaseUrl || 'http://localhost:7130/api';
102
+ const oauthStart = await startSupabaseOAuthRequest({
103
+ apiBaseUrl: resolvedApiBaseUrl,
104
+ apiKey,
105
+ projectId: projectId || 'local',
106
+ redirectUri: process.env.PORTL_OAUTH_CLI_REDIRECT_URI || `${resolvedApiBaseUrl.replace('/api', '')}/api/providers/oauth/cli/result`,
107
+ });
108
+
109
+ if (oauthStart.authorizeUrl) {
110
+ console.log('');
111
+ console.log(c('🔗 OAuth URL:', ANSI.cyan));
112
+ console.log(c(oauthStart.authorizeUrl, ANSI.dim));
113
+ console.log('');
114
+
115
+ // Try to open browser
116
+ const { tryOpenBrowser } = await import('../../bin/portl.js');
117
+ const openResult = await tryOpenBrowser(oauthStart.authorizeUrl);
118
+
119
+ if (openResult.opened) {
120
+ showSuccess('Browser opened automatically');
121
+ } else {
122
+ showWarning('Could not open browser automatically');
123
+ showInfo('Please open the URL above in your browser');
124
+ }
125
+
126
+ // Wait for OAuth callback
127
+ const spinner = new Spinner('Waiting for OAuth callback...');
128
+ spinner.start();
129
+
130
+ try {
131
+ const connection = await waitForSupabaseOAuthConnection({
132
+ apiBaseUrl: resolvedApiBaseUrl,
133
+ apiKey,
134
+ projectId: projectId || 'local',
135
+ startedAt: Date.now(),
136
+ timeoutMs: 120000, // 2 minutes
137
+ pollMs: 2000,
138
+ });
139
+
140
+ if (connection?.externalProjectRef) {
141
+ spinner.success(`Connected to Supabase project: ${connection.externalProjectRef}`);
142
+
143
+ return {
144
+ providerId: 'supabase',
145
+ category: 'database',
146
+ connectable: true,
147
+ config: {
148
+ setupMode: 'oauth',
149
+ projectRef: connection.externalProjectRef,
150
+ },
151
+ envTemplate: {
152
+ PORTL_DB_SUPABASE_SETUP_MODE: 'oauth',
153
+ PORTL_DB_SUPABASE_PROJECT_REF: connection.externalProjectRef,
154
+ },
155
+ };
156
+ } else {
157
+ spinner.fail('OAuth completed but no project found');
158
+ showWarning('You may need to manually configure Supabase');
159
+ }
160
+ } catch (error) {
161
+ spinner.fail('OAuth timeout or failed');
162
+ showWarning('OAuth did not complete in time. Run "portl connect supabase" to retry');
163
+ }
164
+ }
165
+ } catch (error) {
166
+ showError(`OAuth setup failed: ${error.message}`);
167
+ showInfo('Falling back to manual configuration');
168
+ }
169
+ } else {
170
+ // No API key - can't do OAuth now
171
+ showInfo('OAuth requires API key - will complete after init');
172
+ showTip('Run "portl connect supabase" after init to complete OAuth');
173
+ }
174
+
175
+ return {
176
+ providerId: 'supabase',
177
+ category: 'database',
178
+ connectable: true,
179
+ config: { setupMode: 'oauth' },
180
+ envTemplate: {
181
+ PORTL_DB_SUPABASE_SETUP_MODE: 'oauth',
182
+ },
183
+ };
184
+ } else {
185
+ // Manual setup
186
+ const url = await askNonEmpty(rl, 'Supabase URL', 'https://YOUR-PROJECT.supabase.co');
187
+ const serviceRoleKey = await askNonEmpty(rl, 'Service role key');
188
+
189
+ return {
190
+ providerId: 'supabase',
191
+ category: 'database',
192
+ connectable: true,
193
+ config: { setupMode: 'manual', url, serviceRoleKey },
194
+ envTemplate: {
195
+ PORTL_DB_SUPABASE_SETUP_MODE: 'manual',
196
+ PORTL_DB_SUPABASE_URL: url,
197
+ PORTL_DB_SUPABASE_SERVICE_ROLE_KEY: serviceRoleKey,
198
+ },
199
+ };
200
+ }
201
+ } else if (provider.value === 'neon') {
202
+ const connectionString = await askNonEmpty(rl, 'Neon connection string');
203
+
204
+ return {
205
+ providerId: 'neon',
206
+ category: 'database',
207
+ connectable: true,
208
+ config: { connectionString },
209
+ envTemplate: {
210
+ PORTL_DB_NEON_CONNECTION_STRING: connectionString,
211
+ },
212
+ };
213
+ } else if (provider.value === 'mysql') {
214
+ const host = await askNonEmpty(rl, 'MySQL host', 'localhost');
215
+ const port = await askNonEmpty(rl, 'MySQL port', '3306');
216
+ const database = await askNonEmpty(rl, 'Database name');
217
+ const user = await askNonEmpty(rl, 'Database user');
218
+ const password = await askNonEmpty(rl, 'Database password');
219
+
220
+ return {
221
+ providerId: 'mysql',
222
+ category: 'database',
223
+ connectable: false,
224
+ config: { host, port, database, user, password },
225
+ envTemplate: {
226
+ PORTL_DB_MYSQL_HOST: host,
227
+ PORTL_DB_MYSQL_PORT: port,
228
+ PORTL_DB_MYSQL_DATABASE: database,
229
+ PORTL_DB_MYSQL_USER: user,
230
+ PORTL_DB_MYSQL_PASSWORD: password,
231
+ },
232
+ };
233
+ } else if (provider.value === 'mongodb') {
234
+ const connectionString = await askNonEmpty(rl, 'MongoDB connection string', 'mongodb://localhost:27017/mydb');
235
+
236
+ return {
237
+ providerId: 'mongodb',
238
+ category: 'database',
239
+ connectable: false,
240
+ config: { connectionString },
241
+ envTemplate: {
242
+ PORTL_DB_MONGODB_CONNECTION_STRING: connectionString,
243
+ },
244
+ };
245
+ } else if (provider.value === 'planetscale') {
246
+ const host = await askNonEmpty(rl, 'PlanetScale host', 'aws.connect.psdb.cloud');
247
+ const database = await askNonEmpty(rl, 'Database name');
248
+ const username = await askNonEmpty(rl, 'Username');
249
+ const password = await askNonEmpty(rl, 'Password');
250
+
251
+ return {
252
+ providerId: 'planetscale',
253
+ category: 'database',
254
+ connectable: false,
255
+ config: { host, database, username, password },
256
+ envTemplate: {
257
+ PORTL_DB_PLANETSCALE_HOST: host,
258
+ PORTL_DB_PLANETSCALE_DATABASE: database,
259
+ PORTL_DB_PLANETSCALE_USERNAME: username,
260
+ PORTL_DB_PLANETSCALE_PASSWORD: password,
261
+ },
262
+ };
263
+ } else {
264
+ // Generic PostgreSQL
265
+ const host = await askNonEmpty(rl, 'PostgreSQL host', 'localhost');
266
+ const port = await askNonEmpty(rl, 'PostgreSQL port', '5432');
267
+ const database = await askNonEmpty(rl, 'Database name');
268
+ const user = await askNonEmpty(rl, 'Database user');
269
+ const password = await askNonEmpty(rl, 'Database password');
270
+
271
+ return {
272
+ providerId: 'postgres.generic',
273
+ category: 'database',
274
+ connectable: true,
275
+ config: { host, port, database, user, password },
276
+ envTemplate: {
277
+ PORTL_DB_PG_HOST: host,
278
+ PORTL_DB_PG_PORT: port,
279
+ PORTL_DB_PG_DATABASE: database,
280
+ PORTL_DB_PG_USER: user,
281
+ PORTL_DB_PG_PASSWORD: password,
282
+ },
283
+ };
284
+ }
285
+ }
286
+
287
+ /**
288
+ * Collect storage configuration
289
+ */
290
+ async function collectStorageConfig(rl) {
291
+ showSection('STORAGE', '💾');
292
+
293
+ const needsStorage = await askYesNo(rl, 'Do you need file storage?', false);
294
+ showTip('Portl supports S3, Cloudflare R2, and compatible providers');
295
+ console.log('');
296
+
297
+ if (!needsStorage) {
298
+ showInfo('Skipping storage configuration');
299
+ return null;
300
+ }
301
+
302
+ const provider = await selectOption(rl, 'Choose your storage provider', [
303
+ { label: 'Cloudflare R2', value: 'r2', description: 'S3-compatible, zero egress fees' },
304
+ { label: 'Generic S3', value: 's3.generic', description: 'AWS S3 or compatible' },
305
+ ]);
306
+
307
+ console.log('');
308
+
309
+ if (provider.value === 'r2') {
310
+ const accountId = await askNonEmpty(rl, 'Cloudflare account ID');
311
+ const endpoint = `https://${accountId}.r2.cloudflarestorage.com`;
312
+ const accessKeyId = await askNonEmpty(rl, 'R2 access key ID');
313
+ const secretAccessKey = await askNonEmpty(rl, 'R2 secret access key');
314
+ const bucket = await askNonEmpty(rl, 'Bucket name');
315
+
316
+ showTip(`Auto-configured endpoint: ${endpoint}`);
317
+
318
+ return {
319
+ providerId: 'r2',
320
+ category: 'storage',
321
+ connectable: true,
322
+ config: { endpoint, accessKeyId, secretAccessKey, bucket, accountId },
323
+ envTemplate: {
324
+ PORTL_STORAGE_R2_ACCOUNT_ID: accountId,
325
+ PORTL_STORAGE_R2_ENDPOINT: endpoint,
326
+ PORTL_STORAGE_R2_ACCESS_KEY_ID: accessKeyId,
327
+ PORTL_STORAGE_R2_SECRET_ACCESS_KEY: secretAccessKey,
328
+ PORTL_STORAGE_R2_BUCKET: bucket,
329
+ },
330
+ };
331
+ } else {
332
+ // Generic S3
333
+ const endpoint = await askNonEmpty(rl, 'S3 endpoint', 'https://s3.amazonaws.com');
334
+ const accessKeyId = await askNonEmpty(rl, 'Access key ID');
335
+ const secretAccessKey = await askNonEmpty(rl, 'Secret access key');
336
+ const bucket = await askNonEmpty(rl, 'Bucket name');
337
+ const region = await askNonEmpty(rl, 'Region', 'us-east-1');
338
+
339
+ return {
340
+ providerId: 's3.generic',
341
+ category: 'storage',
342
+ connectable: true,
343
+ config: { endpoint, accessKeyId, secretAccessKey, bucket, region },
344
+ envTemplate: {
345
+ PORTL_STORAGE_S3_ENDPOINT: endpoint,
346
+ PORTL_STORAGE_S3_ACCESS_KEY_ID: accessKeyId,
347
+ PORTL_STORAGE_S3_SECRET_ACCESS_KEY: secretAccessKey,
348
+ PORTL_STORAGE_S3_BUCKET: bucket,
349
+ PORTL_STORAGE_S3_REGION: region,
350
+ },
351
+ };
352
+ }
353
+ }
354
+
355
+ /**
356
+ * Collect payments configuration
357
+ */
358
+ async function collectPaymentsConfig(rl) {
359
+ showSection('PAYMENTS', '💳');
360
+
361
+ const needsPayments = await askYesNo(rl, 'Do you need payments?', false);
362
+ showTip('Portl supports Stripe and Mercado Pago');
363
+ console.log('');
364
+
365
+ if (!needsPayments) {
366
+ showInfo('Skipping payments configuration');
367
+ return null;
368
+ }
369
+
370
+ const provider = await selectOption(rl, 'Choose your payment provider', [
371
+ { label: 'Stripe', value: 'stripe' },
372
+ { label: 'Mercado Pago', value: 'mercadopago' },
373
+ ]);
374
+
375
+ console.log('');
376
+
377
+ if (provider.value === 'stripe') {
378
+ const secretKey = await askNonEmpty(rl, 'Stripe secret key (sk_...)');
379
+ const publishableKey = await askNonEmpty(rl, 'Stripe publishable key (pk_...)');
380
+
381
+ return {
382
+ providerId: 'stripe',
383
+ category: 'payments',
384
+ connectable: false,
385
+ config: { secretKey, publishableKey },
386
+ envTemplate: {
387
+ PORTL_PAYMENTS_STRIPE_SECRET_KEY: secretKey,
388
+ PORTL_PAYMENTS_STRIPE_PUBLISHABLE_KEY: publishableKey,
389
+ },
390
+ };
391
+ } else {
392
+ const accessToken = await askNonEmpty(rl, 'Mercado Pago access token');
393
+ const publicKey = await askNonEmpty(rl, 'Mercado Pago public key');
394
+
395
+ return {
396
+ providerId: 'mercadopago',
397
+ category: 'payments',
398
+ connectable: false,
399
+ config: { accessToken, publicKey },
400
+ envTemplate: {
401
+ PORTL_PAYMENTS_MP_ACCESS_TOKEN: accessToken,
402
+ PORTL_PAYMENTS_MP_PUBLIC_KEY: publicKey,
403
+ },
404
+ };
405
+ }
406
+ }
407
+
408
+ /**
409
+ * Collect CI/CD configuration
410
+ */
411
+ async function collectCicdConfig(rl) {
412
+ showSection('CI/CD', '🔄');
413
+
414
+ const needsCicd = await askYesNo(rl, 'Do you need CI/CD integration?', false);
415
+ showTip('Portl supports GitHub Actions');
416
+ console.log('');
417
+
418
+ if (!needsCicd) {
419
+ showInfo('Skipping CI/CD configuration');
420
+ return null;
421
+ }
422
+
423
+ const repository = await askNonEmpty(rl, 'GitHub repository (owner/repo)', 'yourusername/yourrepo');
424
+
425
+ return {
426
+ providerId: 'github.actions',
427
+ category: 'cicd',
428
+ connectable: false,
429
+ config: { repository },
430
+ envTemplate: {
431
+ PORTL_CICD_GITHUB_REPOSITORY: repository,
432
+ },
433
+ };
434
+ }
435
+
436
+ /**
437
+ * Write portl.config.json
438
+ */
439
+ async function writeConfigFile(cwd, projectId, integrations) {
440
+ const config = {
441
+ projectId,
442
+ integrations: integrations.map(integration => ({
443
+ providerId: integration.providerId,
444
+ category: integration.category,
445
+ config: integration.config,
446
+ })),
447
+ };
448
+
449
+ const configPath = path.join(cwd, 'portl.config.json');
450
+ await fs.writeFile(configPath, JSON.stringify(config, null, 2), 'utf8');
451
+
452
+ return configPath;
453
+ }
454
+
455
+ /**
456
+ * Write .env.portl template
457
+ */
458
+ async function writeEnvTemplate(cwd, integrations) {
459
+ const envLines = ['# Portl Environment Variables', '# Generated by: portl init', ''];
460
+
461
+ for (const integration of integrations) {
462
+ if (integration.envTemplate && Object.keys(integration.envTemplate).length > 0) {
463
+ envLines.push(`# ${integration.providerId} (${integration.category})`);
464
+
465
+ for (const [key, value] of Object.entries(integration.envTemplate)) {
466
+ envLines.push(`${key}=${value}`);
467
+ }
468
+
469
+ envLines.push('');
470
+ }
471
+ }
472
+
473
+ const envPath = path.join(cwd, '.env.portl');
474
+ await fs.writeFile(envPath, envLines.join('\n'), 'utf8');
475
+
476
+ return envPath;
477
+ }
478
+
479
+ /**
480
+ * Setup MCP configuration for AI agents
481
+ */
482
+ async function setupMcpConfig(cwd, rl) {
483
+ showSection('MCP SETUP', '🤖');
484
+
485
+ const setupMcp = await askYesNo(rl, 'Configure MCP for AI agents?', true);
486
+ showTip('MCP lets Claude/Cursor/Copilot access your infrastructure directly');
487
+ console.log('');
488
+
489
+ if (!setupMcp) {
490
+ showInfo('Skipping MCP configuration');
491
+ return;
492
+ }
493
+
494
+ // Read API key from .env.portl if exists
495
+ let apiKey = '';
496
+ let apiBaseUrl = 'http://localhost:7130';
497
+ try {
498
+ const envContent = await fs.readFile(path.join(cwd, '.env.portl'), 'utf8');
499
+ const keyMatch = envContent.match(/ACCESS_API_KEY=(.+)/);
500
+ if (keyMatch) apiKey = keyMatch[1].trim();
501
+ const urlMatch = envContent.match(/API_BASE_URL=(.+)/);
502
+ if (urlMatch) apiBaseUrl = urlMatch[1].trim();
503
+ } catch {
504
+ // No .env.portl yet
505
+ }
506
+
507
+ // MCP server config
508
+ const mcpServerConfig = {
509
+ command: 'npx',
510
+ args: ['-y', '@portl/mcp@latest'],
511
+ env: {
512
+ API_BASE_URL: apiBaseUrl,
513
+ ...(apiKey && { API_KEY: apiKey }),
514
+ },
515
+ };
516
+
517
+ // Detect Claude Desktop config path
518
+ const homeDir = process.env.HOME || process.env.USERPROFILE;
519
+ const claudeConfigPaths = [
520
+ path.join(homeDir, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'), // macOS
521
+ path.join(homeDir, 'AppData', 'Roaming', 'Claude', 'claude_desktop_config.json'), // Windows
522
+ path.join(homeDir, '.config', 'claude', 'claude_desktop_config.json'), // Linux
523
+ ];
524
+
525
+ let claudeConfigPath = null;
526
+ for (const p of claudeConfigPaths) {
527
+ try {
528
+ await fs.access(path.dirname(p));
529
+ claudeConfigPath = p;
530
+ break;
531
+ } catch {
532
+ // Path doesn't exist
533
+ }
534
+ }
535
+
536
+ if (claudeConfigPath) {
537
+ try {
538
+ // Read existing config or create new
539
+ let config = { mcpServers: {} };
540
+ try {
541
+ const existing = await fs.readFile(claudeConfigPath, 'utf8');
542
+ config = JSON.parse(existing);
543
+ if (!config.mcpServers) config.mcpServers = {};
544
+ } catch {
545
+ // File doesn't exist, use default
546
+ }
547
+
548
+ // Add Portl MCP server
549
+ config.mcpServers.portl = mcpServerConfig;
550
+
551
+ // Ensure directory exists
552
+ await fs.mkdir(path.dirname(claudeConfigPath), { recursive: true });
553
+ await fs.writeFile(claudeConfigPath, JSON.stringify(config, null, 2), 'utf8');
554
+
555
+ showSuccess('Claude Desktop MCP configured');
556
+ showTip('Restart Claude Desktop to activate');
557
+ } catch (error) {
558
+ showWarning(`Could not configure Claude Desktop: ${error.message}`);
559
+ }
560
+ } else {
561
+ showInfo('Claude Desktop not detected');
562
+ }
563
+
564
+ // Create local .mcp.json for other tools (Cursor, etc)
565
+ const localMcpConfig = {
566
+ mcpServers: {
567
+ portl: mcpServerConfig,
568
+ },
569
+ };
570
+
571
+ await fs.writeFile(
572
+ path.join(cwd, '.mcp.json'),
573
+ JSON.stringify(localMcpConfig, null, 2),
574
+ 'utf8'
575
+ );
576
+
577
+ showSuccess('Local MCP config created (.mcp.json)');
578
+ }
579
+
580
+ /**
581
+ * Main init command
582
+ */
583
+ export async function runInit(rl) {
584
+ const cwd = process.cwd();
585
+
586
+ showBanner();
587
+
588
+ // Get project name
589
+ const projectId = await askNonEmpty(rl, 'Project name', 'my-app');
590
+ console.log('');
591
+
592
+ // Detect API key automatically
593
+ const apiKey = await detectApiKey(cwd);
594
+ const apiBaseUrl = 'http://localhost:7130/api'; // Default for now
595
+
596
+ if (apiKey) {
597
+ showSuccess('API key detected from environment');
598
+ showTip('OAuth connections will be completed automatically');
599
+ } else {
600
+ showInfo('No API key found - generating config files only');
601
+ showTip('Add ACCESS_API_KEY to your .env to enable OAuth');
602
+ }
603
+
604
+ // Collect integrations
605
+ const integrations = [];
606
+
607
+ // Database
608
+ const dbConfig = await collectDatabaseConfig(rl, apiKey, apiBaseUrl, projectId);
609
+ if (dbConfig) integrations.push(dbConfig);
610
+
611
+ // Storage
612
+ const storageConfig = await collectStorageConfig(rl);
613
+ if (storageConfig) integrations.push(storageConfig);
614
+
615
+ // Payments
616
+ const paymentsConfig = await collectPaymentsConfig(rl);
617
+ if (paymentsConfig) integrations.push(paymentsConfig);
618
+
619
+ // CI/CD
620
+ const cicdConfig = await collectCicdConfig(rl);
621
+ if (cicdConfig) integrations.push(cicdConfig);
622
+
623
+ // Summary
624
+ const summaryItems = integrations.map(i => {
625
+ const status = i.connectable && apiKey ? c('(connected)', ANSI.green) : c('(configured)', ANSI.dim);
626
+ return `${i.category}: ${c(i.providerId, ANSI.bold)} ${status}`;
627
+ });
628
+
629
+ showSummary('SUMMARY', summaryItems);
630
+
631
+ // Write files
632
+ const spinner = new Spinner('Generating configuration files...');
633
+ spinner.start();
634
+
635
+ try {
636
+ await writeConfigFile(cwd, projectId, integrations);
637
+ await writeEnvTemplate(cwd, integrations);
638
+ spinner.success('Configuration files generated');
639
+ } catch (error) {
640
+ spinner.fail('Failed to write configuration files');
641
+ throw error;
642
+ }
643
+
644
+ // Setup MCP
645
+ await setupMcpConfig(cwd, rl);
646
+
647
+ // Next steps
648
+ console.log('');
649
+ console.log(c('Files created:', ANSI.bold));
650
+ console.log(` ${c('📄', ANSI.green)} portl.config.json`);
651
+ console.log(` ${c('📄', ANSI.green)} .env.portl`);
652
+ console.log('');
653
+
654
+ const nextSteps = [
655
+ 'Restart Claude Desktop / Cursor to load MCP',
656
+ 'Start coding with AI - your infra is ready!',
657
+ ];
658
+
659
+ showNextSteps(nextSteps);
660
+
661
+ console.log(c('Done! 🚀', ANSI.green));
662
+ console.log('');
663
+ }