@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/bin/portl.js ADDED
@@ -0,0 +1,1837 @@
1
+ #!/usr/bin/env node
2
+ /* global console, fetch */
3
+
4
+ import { promises as fs } from 'node:fs';
5
+ import { spawn } from 'node:child_process';
6
+ import path from 'node:path';
7
+ import process from 'node:process';
8
+ import readline from 'node:readline/promises';
9
+ import { stdin as input, stdout as output } from 'node:process';
10
+ import { URL, URLSearchParams } from 'node:url';
11
+ import { runInit } from '../src/commands/init.js';
12
+
13
+ const [, , command, ...args] = process.argv;
14
+ const SUPPORTED_PROVIDER_IDS = new Set([
15
+ 'postgres.generic',
16
+ 'supabase',
17
+ 'neon',
18
+ 's3.generic',
19
+ 'r2',
20
+ ]);
21
+
22
+ const ANSI = {
23
+ reset: '\x1b[0m',
24
+ bold: '\x1b[1m',
25
+ cyan: '\x1b[36m',
26
+ green: '\x1b[32m',
27
+ yellow: '\x1b[33m',
28
+ red: '\x1b[31m',
29
+ dim: '\x1b[2m',
30
+ };
31
+
32
+ const PORTL_ASCII = `
33
+ ██████╗ ██████╗ ██████╗ ████████╗██╗
34
+ ██╔══██╗██╔═══██╗██╔══██╗╚══██╔══╝██║
35
+ ██████╔╝██║ ██║██████╔╝ ██║ ██║
36
+ ██╔═══╝ ██║ ██║██╔══██╗ ██║ ██║
37
+ ██║ ╚██████╔╝██║ ██║ ██║ ███████╗
38
+ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝
39
+ `;
40
+
41
+ function c(text, color) {
42
+ return `${color}${text}${ANSI.reset}`;
43
+ }
44
+
45
+ function showHelp() {
46
+ console.log(`${c('Portl CLI', ANSI.bold)} - Bring your infra. Ship with AI.
47
+
48
+ Usage:
49
+ ${c('portl init', ANSI.cyan)} Interactive setup wizard
50
+ ${c('portl connect supabase', ANSI.cyan)} Connect Supabase via OAuth
51
+ ${c('portl bootstrap github-actions', ANSI.cyan)} Generate GitHub workflow
52
+ ${c('portl bootstrap stripe-webhook', ANSI.cyan)} Setup Stripe webhooks
53
+ ${c('portl --help', ANSI.cyan)} Show this help
54
+
55
+ Examples:
56
+ ${c('portl init', ANSI.dim)} # Start the setup wizard
57
+ ${c('portl connect supabase --project-id my-app', ANSI.dim)} # Connect Supabase OAuth
58
+ ${c('portl bootstrap github-actions --repository owner/repo', ANSI.dim)}
59
+
60
+ The ${c('init', ANSI.bold)} command walks you through:
61
+ • Database setup (PostgreSQL, Supabase, Neon)
62
+ • File storage (S3, Cloudflare R2)
63
+ • Payments (Stripe, Mercado Pago)
64
+ • CI/CD (GitHub Actions)
65
+
66
+ For more information, visit: ${c('https://portl.dev', ANSI.cyan)}
67
+ `);
68
+ }
69
+
70
+ function isYes(value, defaultYes = true) {
71
+ if (!value || !value.trim()) {
72
+ return defaultYes;
73
+ }
74
+
75
+ const normalized = value.trim().toLowerCase();
76
+ return normalized === 'y' || normalized === 'yes' || normalized === 's' || normalized === 'si';
77
+ }
78
+
79
+ function getArgValue(flag) {
80
+ const index = args.indexOf(flag);
81
+ if (index < 0 || index === args.length - 1) {
82
+ return null;
83
+ }
84
+ return args[index + 1];
85
+ }
86
+
87
+ function hasFlag(flag) {
88
+ return args.includes(flag);
89
+ }
90
+
91
+ function resolveApiOrigin(apiBaseUrl) {
92
+ try {
93
+ const parsed = new URL(apiBaseUrl);
94
+ return `${parsed.protocol}//${parsed.host}`;
95
+ } catch {
96
+ return 'http://localhost:7130';
97
+ }
98
+ }
99
+
100
+ function formatTerminalLink(label, url) {
101
+ // OSC 8 hyperlink; supported in many modern terminals.
102
+ return `\u001B]8;;${url}\u0007${label}\u001B]8;;\u0007`;
103
+ }
104
+
105
+ async function tryOpenBrowser(url) {
106
+ const command =
107
+ process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
108
+
109
+ const child = spawn(command, [url], {
110
+ stdio: 'ignore',
111
+ detached: process.platform !== 'win32',
112
+ shell: process.platform === 'win32',
113
+ });
114
+
115
+ return new Promise((resolve) => {
116
+ let done = false;
117
+ const finish = (result) => {
118
+ if (!done) {
119
+ done = true;
120
+ resolve(result);
121
+ }
122
+ };
123
+
124
+ child.on('error', (error) => {
125
+ finish({
126
+ opened: false,
127
+ command,
128
+ error: error instanceof Error ? error.message : String(error),
129
+ });
130
+ });
131
+
132
+ child.on('exit', (code) => {
133
+ if (code === 0 || code === null) {
134
+ finish({ opened: true, command });
135
+ } else {
136
+ finish({ opened: false, command, error: `exit code ${code}` });
137
+ }
138
+ });
139
+
140
+ globalThis.setTimeout(() => {
141
+ finish({ opened: true, command });
142
+ }, 900);
143
+ });
144
+ }
145
+
146
+ async function sleep(ms) {
147
+ await new Promise((resolve) => {
148
+ globalThis.setTimeout(resolve, ms);
149
+ });
150
+ }
151
+
152
+ function parseDotEnv(content) {
153
+ const values = {};
154
+ const lines = content.split('\n');
155
+ for (const rawLine of lines) {
156
+ const line = rawLine.trim();
157
+ if (!line || line.startsWith('#')) {
158
+ continue;
159
+ }
160
+ const separatorIndex = line.indexOf('=');
161
+ if (separatorIndex <= 0) {
162
+ continue;
163
+ }
164
+ const key = line.slice(0, separatorIndex).trim();
165
+ const value = line.slice(separatorIndex + 1).trim();
166
+ values[key] = value;
167
+ }
168
+ return values;
169
+ }
170
+
171
+ async function getLocalEnvValue(key, cwd) {
172
+ const envPath = path.join(cwd, '.env');
173
+ try {
174
+ const envContent = await fs.readFile(envPath, 'utf8');
175
+ const envValues = parseDotEnv(envContent);
176
+ const value = envValues[key];
177
+ return value && value.trim() ? value.trim() : null;
178
+ } catch {
179
+ return null;
180
+ }
181
+ }
182
+
183
+ async function resolveBackendApiKey(cwd) {
184
+ const fromProcess = process.env.ACCESS_API_KEY;
185
+ if (fromProcess && fromProcess.trim()) {
186
+ return fromProcess.trim();
187
+ }
188
+
189
+ return getLocalEnvValue('ACCESS_API_KEY', cwd);
190
+ }
191
+
192
+ async function readExistingPortlConfig(cwd) {
193
+ const configPath = path.join(cwd, 'portl.config.json');
194
+ try {
195
+ const raw = await fs.readFile(configPath, 'utf8');
196
+ const parsed = JSON.parse(raw);
197
+ return parsed && typeof parsed === 'object' ? parsed : null;
198
+ } catch {
199
+ return null;
200
+ }
201
+ }
202
+
203
+ function findExistingIntegration(existingConfig, category) {
204
+ const integrations = Array.isArray(existingConfig?.integrations)
205
+ ? existingConfig.integrations
206
+ : [];
207
+ return integrations.find((integration) => integration?.category === category) || null;
208
+ }
209
+
210
+ function buildReuseIntegration(existingIntegration) {
211
+ return {
212
+ providerId: existingIntegration.providerId,
213
+ category: existingIntegration.category,
214
+ connectable: false,
215
+ config: existingIntegration.config || {},
216
+ envTemplate: {},
217
+ };
218
+ }
219
+
220
+ async function maybeReuseExistingIntegration(rl, label, existingIntegration) {
221
+ if (!existingIntegration) {
222
+ return null;
223
+ }
224
+
225
+ const reuse = isYes(
226
+ await ask(rl, `Reuse existing ${label} config (${existingIntegration.providerId})? [Y/n]`, 'y')
227
+ );
228
+
229
+ return reuse ? buildReuseIntegration(existingIntegration) : null;
230
+ }
231
+
232
+ async function ask(rl, question, defaultValue) {
233
+ const suffix = defaultValue ? ` ${c(`(${defaultValue})`, ANSI.dim)}` : '';
234
+ const answer = await rl.question(`${question}${suffix}: `);
235
+ const trimmed = answer.trim();
236
+ return trimmed || defaultValue || '';
237
+ }
238
+
239
+ async function askNonEmpty(rl, question, defaultValue) {
240
+ while (true) {
241
+ const value = await ask(rl, question, defaultValue);
242
+ if (value.trim()) {
243
+ return value.trim();
244
+ }
245
+ console.log(c('Value cannot be empty. Try again.', ANSI.yellow));
246
+ }
247
+ }
248
+
249
+ async function chooseOption(rl, title, options) {
250
+ console.log(`\n${c(title, ANSI.bold)}`);
251
+ options.forEach((option, index) => {
252
+ console.log(` ${index + 1}. ${option.label} ${c(`(${option.value})`, ANSI.dim)}`);
253
+ });
254
+
255
+ while (true) {
256
+ const answer = await rl.question('Select an option number: ');
257
+ const selected = Number.parseInt(answer.trim(), 10);
258
+ if (!Number.isNaN(selected) && selected >= 1 && selected <= options.length) {
259
+ return options[selected - 1];
260
+ }
261
+
262
+ console.log(c('Invalid option number. Try again.', ANSI.yellow));
263
+ }
264
+ }
265
+
266
+ async function chooseNamedOption(rl, title, options) {
267
+ console.log(`\n${c(title, ANSI.bold)}`);
268
+ options.forEach((option, index) => {
269
+ console.log(` ${index + 1}. ${option.label}`);
270
+ });
271
+
272
+ while (true) {
273
+ const answer = await rl.question('Select an option number: ');
274
+ const selected = Number.parseInt(answer.trim(), 10);
275
+ if (!Number.isNaN(selected) && selected >= 1 && selected <= options.length) {
276
+ return options[selected - 1];
277
+ }
278
+
279
+ console.log(c('Invalid option number. Try again.', ANSI.yellow));
280
+ }
281
+ }
282
+
283
+ async function chooseMultipleOptions(rl, title, options, defaultValues = []) {
284
+ console.log(`\n${c(title, ANSI.bold)}`);
285
+ options.forEach((option, index) => {
286
+ console.log(` ${index + 1}. ${option.label} ${c(`(${option.value})`, ANSI.dim)}`);
287
+ });
288
+
289
+ const defaultIndices = options
290
+ .map((option, index) => (defaultValues.includes(option.value) ? String(index + 1) : null))
291
+ .filter(Boolean)
292
+ .join(',');
293
+
294
+ while (true) {
295
+ const answer = await ask(rl, 'Select option numbers (comma separated)', defaultIndices || '1');
296
+ const tokens = answer
297
+ .split(',')
298
+ .map((token) => token.trim())
299
+ .filter(Boolean);
300
+
301
+ if (tokens.length === 0) {
302
+ console.log(c('Select at least one option.', ANSI.yellow));
303
+ continue;
304
+ }
305
+
306
+ const parsed = tokens.map((token) => Number.parseInt(token, 10));
307
+ const allValid = parsed.every(
308
+ (value) => !Number.isNaN(value) && value >= 1 && value <= options.length
309
+ );
310
+
311
+ if (!allValid) {
312
+ console.log(c('Invalid option list. Example: 1,2,4', ANSI.yellow));
313
+ continue;
314
+ }
315
+
316
+ const uniqueSorted = [...new Set(parsed)];
317
+ return uniqueSorted.map((index) => options[index - 1]);
318
+ }
319
+ }
320
+
321
+ async function portlRequest(baseUrl, endpoint, method, apiKey, body) {
322
+ const normalizedBaseUrl = baseUrl.replace(/\/+$/, '');
323
+ const response = await fetch(`${normalizedBaseUrl}${endpoint}`, {
324
+ method,
325
+ headers: {
326
+ 'Content-Type': 'application/json',
327
+ ...(apiKey ? { 'x-api-key': apiKey } : {}),
328
+ },
329
+ body: body ? JSON.stringify(body) : undefined,
330
+ });
331
+
332
+ const text = await response.text();
333
+ let data = null;
334
+
335
+ try {
336
+ data = text ? JSON.parse(text) : null;
337
+ } catch {
338
+ data = text;
339
+ }
340
+
341
+ if (!response.ok) {
342
+ const message =
343
+ (data && typeof data === 'object' && data.message) ||
344
+ (data && typeof data === 'object' && data.error) ||
345
+ `HTTP ${response.status}`;
346
+ throw new Error(String(message));
347
+ }
348
+
349
+ return data;
350
+ }
351
+
352
+ async function startSupabaseOAuthRequest({
353
+ apiBaseUrl,
354
+ apiKey,
355
+ projectId,
356
+ redirectUri,
357
+ organizationSlug,
358
+ }) {
359
+ const query = new URLSearchParams({
360
+ projectId,
361
+ redirectUri,
362
+ ...(organizationSlug ? { organizationSlug } : {}),
363
+ }).toString();
364
+
365
+ return portlRequest(apiBaseUrl, `/providers/oauth/supabase/start?${query}`, 'GET', apiKey);
366
+ }
367
+
368
+ async function waitForSupabaseOAuthConnection({
369
+ apiBaseUrl,
370
+ apiKey,
371
+ projectId,
372
+ startedAt,
373
+ timeoutMs = 120000,
374
+ pollMs = 2000,
375
+ }) {
376
+ const deadline = Date.now() + timeoutMs;
377
+
378
+ while (Date.now() < deadline) {
379
+ try {
380
+ const statusResponse = await portlRequest(
381
+ apiBaseUrl,
382
+ `/providers/oauth/status?projectId=${encodeURIComponent(projectId)}`,
383
+ 'GET',
384
+ apiKey
385
+ );
386
+ const connections = Array.isArray(statusResponse?.connections)
387
+ ? statusResponse.connections
388
+ : [];
389
+ const supabaseConnection = connections.find((entry) => entry.providerId === 'supabase');
390
+
391
+ if (supabaseConnection) {
392
+ const updatedAtMs = Date.parse(supabaseConnection.updatedAt || '');
393
+ if (Number.isNaN(updatedAtMs) || updatedAtMs >= startedAt - 1000) {
394
+ return supabaseConnection;
395
+ }
396
+ }
397
+ } catch {
398
+ // ignore transient polling errors while waiting for callback completion
399
+ }
400
+
401
+ await sleep(pollMs);
402
+ }
403
+
404
+ return null;
405
+ }
406
+
407
+ async function fetchSupabaseProjects({ apiBaseUrl, apiKey, projectId }) {
408
+ return portlRequest(
409
+ apiBaseUrl,
410
+ `/providers/oauth/supabase/projects?projectId=${encodeURIComponent(projectId)}`,
411
+ 'GET',
412
+ apiKey
413
+ );
414
+ }
415
+
416
+ async function selectSupabaseProject({ apiBaseUrl, apiKey, projectId, projectRef }) {
417
+ return portlRequest(apiBaseUrl, '/providers/oauth/supabase/select-project', 'POST', apiKey, {
418
+ projectId,
419
+ projectRef,
420
+ });
421
+ }
422
+
423
+ async function fetchSupabaseOAuthStatus({ apiBaseUrl, apiKey, projectId }) {
424
+ return portlRequest(
425
+ apiBaseUrl,
426
+ `/providers/oauth/status?projectId=${encodeURIComponent(projectId)}`,
427
+ 'GET',
428
+ apiKey
429
+ );
430
+ }
431
+
432
+ async function fetchProviderInstances({ apiBaseUrl, apiKey, projectId }) {
433
+ return portlRequest(
434
+ apiBaseUrl,
435
+ `/providers/instances?projectId=${encodeURIComponent(projectId)}`,
436
+ 'GET',
437
+ apiKey
438
+ );
439
+ }
440
+
441
+ function normalizeUrl(value) {
442
+ return value.replace(/\/+$/, '');
443
+ }
444
+
445
+ function normalizeR2Endpoint(accountId, endpointInput, bucket) {
446
+ const defaultEndpoint = `https://${accountId}.r2.cloudflarestorage.com`;
447
+ const raw = endpointInput.trim() || defaultEndpoint;
448
+ const withProtocol = /^https?:\/\//i.test(raw) ? raw : `https://${raw}`;
449
+
450
+ try {
451
+ const parsed = new URL(withProtocol);
452
+ const pathSegments = parsed.pathname.split('/').filter(Boolean);
453
+ if (pathSegments.length > 0) {
454
+ const firstPath = pathSegments[0];
455
+ if (firstPath === bucket) {
456
+ console.log(
457
+ c('Detected bucket in R2 endpoint path. Auto-fixing to account endpoint.', ANSI.yellow)
458
+ );
459
+ } else {
460
+ console.log(
461
+ c(
462
+ `Detected extra path "${parsed.pathname}" in endpoint. Using account endpoint only.`,
463
+ ANSI.yellow
464
+ )
465
+ );
466
+ }
467
+ parsed.pathname = '/';
468
+ }
469
+ return normalizeUrl(parsed.toString());
470
+ } catch {
471
+ return normalizeUrl(withProtocol);
472
+ }
473
+ }
474
+
475
+ // ============================================================================
476
+ // DEPRECATED FUNCTIONS - TO BE REMOVED
477
+ // The following functions are from the old init flow and are no longer used.
478
+ // They are kept temporarily for reference but will be removed after testing.
479
+ // ============================================================================
480
+
481
+ async function collectDatabaseConfig(rl, options = {}) {
482
+ const reusedIntegration = await maybeReuseExistingIntegration(
483
+ rl,
484
+ 'database',
485
+ options.existingIntegration
486
+ );
487
+ if (reusedIntegration) {
488
+ return reusedIntegration;
489
+ }
490
+
491
+ const provider = await chooseOption(rl, 'Database Provider', [
492
+ { label: 'PostgreSQL (generic)', value: 'postgres.generic' },
493
+ { label: 'Neon', value: 'neon' },
494
+ { label: 'Supabase', value: 'supabase' },
495
+ { label: 'Custom (manual template only)', value: 'custom.database' },
496
+ ]);
497
+
498
+ if (provider.value === 'supabase') {
499
+ if (options.apiKey) {
500
+ try {
501
+ const oauthStatus = await fetchSupabaseOAuthStatus({
502
+ apiBaseUrl: options.apiBaseUrl || 'http://localhost:7130/api',
503
+ apiKey: options.apiKey,
504
+ projectId: options.projectId || 'local',
505
+ });
506
+ const supabaseConnection = Array.isArray(oauthStatus?.connections)
507
+ ? oauthStatus.connections.find((entry) => entry.providerId === 'supabase')
508
+ : null;
509
+
510
+ if (supabaseConnection?.externalProjectRef) {
511
+ const reuseExisting = isYes(
512
+ await ask(
513
+ rl,
514
+ `Reuse connected Supabase project ${supabaseConnection.externalProjectRef}? [Y/n]`,
515
+ 'y'
516
+ )
517
+ );
518
+
519
+ if (reuseExisting) {
520
+ return {
521
+ providerId: provider.value,
522
+ category: 'database',
523
+ connectable: false,
524
+ config: {
525
+ setupMode: 'oauth.reuse',
526
+ projectRef: supabaseConnection.externalProjectRef,
527
+ },
528
+ envTemplate: {
529
+ PORTL_DB_SUPABASE_SETUP_MODE: 'oauth.reuse',
530
+ PORTL_DB_SUPABASE_PROJECT_REF: supabaseConnection.externalProjectRef,
531
+ },
532
+ };
533
+ }
534
+ }
535
+ } catch {
536
+ // ignore status fetch errors and continue with normal setup flow
537
+ }
538
+ }
539
+
540
+ const setupMode = await chooseOption(rl, 'Supabase Setup Mode', [
541
+ { label: 'OAuth link (recommended)', value: 'oauth' },
542
+ { label: 'Manual keys (URL + service role key)', value: 'manual' },
543
+ ]);
544
+
545
+ if (setupMode.value === 'manual') {
546
+ const url = await askNonEmpty(rl, 'Supabase URL', 'https://YOUR-PROJECT.supabase.co');
547
+ const serviceRoleKey = await askNonEmpty(rl, 'Supabase service role key', '');
548
+ return {
549
+ providerId: provider.value,
550
+ category: 'database',
551
+ config: { url, serviceRoleKey, setupMode: 'manual' },
552
+ envTemplate: {
553
+ PORTL_DB_SUPABASE_SETUP_MODE: 'manual',
554
+ PORTL_DB_SUPABASE_URL: url,
555
+ PORTL_DB_SUPABASE_SERVICE_ROLE_KEY: serviceRoleKey,
556
+ },
557
+ };
558
+ }
559
+
560
+ let authorizeUrl = '';
561
+ let callbackUrl = '';
562
+
563
+ if (options.apiKey) {
564
+ try {
565
+ const resolvedApiBaseUrl = options.apiBaseUrl || 'http://localhost:7130/api';
566
+ const fallbackRedirectUri = `${resolveApiOrigin(resolvedApiBaseUrl)}/api/providers/oauth/cli/result`;
567
+ const oauthStart = await startSupabaseOAuthRequest({
568
+ apiBaseUrl: resolvedApiBaseUrl,
569
+ apiKey: options.apiKey,
570
+ projectId: options.projectId || 'local',
571
+ redirectUri:
572
+ options.redirectUri || process.env.PORTL_OAUTH_CLI_REDIRECT_URI || fallbackRedirectUri,
573
+ });
574
+ authorizeUrl = oauthStart.authorizeUrl || '';
575
+ callbackUrl = oauthStart.callbackUrl || '';
576
+
577
+ if (authorizeUrl) {
578
+ console.log(c('\nSupabase OAuth link generated in wizard:', ANSI.green));
579
+ console.log(formatTerminalLink('Open Supabase OAuth', authorizeUrl));
580
+ console.log(authorizeUrl);
581
+
582
+ if (options.autoOpenOAuth !== false) {
583
+ const openResult = await tryOpenBrowser(authorizeUrl);
584
+ if (openResult.opened) {
585
+ console.log(c('Opened your browser automatically.', ANSI.dim));
586
+ } else {
587
+ console.log(
588
+ c(
589
+ `Could not open browser automatically (${openResult.command || 'open'}).`,
590
+ ANSI.yellow
591
+ )
592
+ );
593
+ console.log(c('If it did not open, enter using this link:', ANSI.dim));
594
+ console.log(authorizeUrl);
595
+ }
596
+ }
597
+ }
598
+ } catch (error) {
599
+ const message = error instanceof Error ? error.message : 'OAuth start failed';
600
+ console.log(c(`Could not generate OAuth link now: ${message}`, ANSI.yellow));
601
+ }
602
+ } else {
603
+ console.log(
604
+ c('OAuth link not generated in files-only mode. Run: portl connect supabase', ANSI.dim)
605
+ );
606
+ }
607
+
608
+ return {
609
+ providerId: provider.value,
610
+ category: 'database',
611
+ connectable: false,
612
+ config: {
613
+ setupMode: 'oauth',
614
+ authorizeUrl: authorizeUrl || null,
615
+ callbackUrl: callbackUrl || null,
616
+ },
617
+ envTemplate: {
618
+ PORTL_DB_SUPABASE_SETUP_MODE: 'oauth',
619
+ PORTL_DB_SUPABASE_OAUTH_AUTHORIZE_URL: authorizeUrl,
620
+ PORTL_DB_SUPABASE_OAUTH_CALLBACK_URL: callbackUrl,
621
+ },
622
+ };
623
+ }
624
+
625
+ if (provider.value === 'custom.database') {
626
+ const notes = await ask(
627
+ rl,
628
+ 'Custom database notes (optional)',
629
+ 'Set your custom connection settings here'
630
+ );
631
+ return {
632
+ providerId: provider.value,
633
+ category: 'database',
634
+ config: { notes },
635
+ envTemplate: {
636
+ PORTL_DB_CUSTOM_NOTES: notes,
637
+ },
638
+ };
639
+ }
640
+
641
+ const connectionString = await askNonEmpty(
642
+ rl,
643
+ `${provider.label} connection string`,
644
+ 'postgresql://postgres:postgres@localhost:5432/portl'
645
+ );
646
+
647
+ return {
648
+ providerId: provider.value,
649
+ category: 'database',
650
+ config: { connectionString },
651
+ envTemplate: {
652
+ PORTL_DB_CONNECTION_STRING: connectionString,
653
+ },
654
+ };
655
+ }
656
+
657
+ async function collectStorageConfig(rl, quickMode = true, options = {}) {
658
+ const reusedIntegration = await maybeReuseExistingIntegration(
659
+ rl,
660
+ 'storage',
661
+ options.existingIntegration
662
+ );
663
+ if (reusedIntegration) {
664
+ return reusedIntegration;
665
+ }
666
+
667
+ if (quickMode) {
668
+ if (options.apiKey) {
669
+ try {
670
+ const instances = await fetchProviderInstances({
671
+ apiBaseUrl: options.apiBaseUrl || 'http://localhost:7130/api',
672
+ apiKey: options.apiKey,
673
+ projectId: options.projectId || 'local',
674
+ });
675
+ const existingR2 = Array.isArray(instances)
676
+ ? instances.find(
677
+ (instance) =>
678
+ instance.providerId === 'r2' && instance.connectionStatus === 'connected'
679
+ )
680
+ : null;
681
+
682
+ if (existingR2) {
683
+ const reuseExisting = isYes(
684
+ await ask(rl, 'Reuse connected Cloudflare R2 backend connection? [Y/n]', 'y')
685
+ );
686
+
687
+ if (reuseExisting) {
688
+ return {
689
+ providerId: 'r2',
690
+ category: 'storage',
691
+ connectable: false,
692
+ config: {
693
+ setupMode: 'reuse-existing',
694
+ providerId: 'r2',
695
+ },
696
+ envTemplate: {
697
+ PORTL_STORAGE_SETUP_MODE: 'reuse-existing',
698
+ PORTL_STORAGE_PROVIDER: 'r2',
699
+ },
700
+ };
701
+ }
702
+ }
703
+ } catch {
704
+ // ignore lookup errors and continue with normal flow
705
+ }
706
+ }
707
+ }
708
+
709
+ const provider = await chooseOption(rl, 'Storage Provider', [
710
+ { label: 'S3 compatible (generic)', value: 's3.generic' },
711
+ { label: 'Cloudflare R2', value: 'r2' },
712
+ { label: 'Skip for now', value: 'none' },
713
+ ]);
714
+
715
+ if (provider.value === 'none') {
716
+ return null;
717
+ }
718
+
719
+ if (provider.value === 'r2') {
720
+ const accountId = await askNonEmpty(rl, 'R2 account ID', '');
721
+ const accessKeyId = await askNonEmpty(rl, 'R2 access key ID', '');
722
+ const secretAccessKey = await askNonEmpty(rl, 'R2 secret access key', '');
723
+ const bucket = await askNonEmpty(rl, 'R2 bucket', 'portl-assets');
724
+ const endpointInput = quickMode
725
+ ? ''
726
+ : await ask(
727
+ rl,
728
+ 'R2 endpoint override (optional, leave blank to auto)',
729
+ `https://${accountId}.r2.cloudflarestorage.com`
730
+ );
731
+ const endpoint = normalizeR2Endpoint(accountId, endpointInput, bucket);
732
+ if (quickMode) {
733
+ console.log(c(`Using auto R2 endpoint: ${endpoint}`, ANSI.dim));
734
+ }
735
+
736
+ return {
737
+ providerId: provider.value,
738
+ category: 'storage',
739
+ config: {
740
+ accountId,
741
+ accessKeyId,
742
+ secretAccessKey,
743
+ bucket,
744
+ endpoint,
745
+ },
746
+ envTemplate: {
747
+ PORTL_STORAGE_R2_ACCOUNT_ID: accountId,
748
+ PORTL_STORAGE_R2_ACCESS_KEY_ID: accessKeyId,
749
+ PORTL_STORAGE_R2_SECRET_ACCESS_KEY: secretAccessKey,
750
+ PORTL_STORAGE_R2_BUCKET: bucket,
751
+ PORTL_STORAGE_R2_ENDPOINT: endpoint,
752
+ },
753
+ };
754
+ }
755
+
756
+ const accessKeyId = await askNonEmpty(rl, 'S3 access key ID', '');
757
+ const secretAccessKey = await askNonEmpty(rl, 'S3 secret access key', '');
758
+ const bucket = await askNonEmpty(rl, 'S3 bucket', 'portl-assets');
759
+ const region = await askNonEmpty(rl, 'S3 region', 'us-east-1');
760
+ const endpoint = quickMode
761
+ ? ''
762
+ : await ask(rl, 'S3 endpoint override (optional)', 'http://127.0.0.1:9000');
763
+ const forcePathStyleAnswer = quickMode ? 'y' : await ask(rl, 'Force path style? [Y/n]', 'y');
764
+
765
+ return {
766
+ providerId: provider.value,
767
+ category: 'storage',
768
+ config: {
769
+ accessKeyId,
770
+ secretAccessKey,
771
+ bucket,
772
+ region,
773
+ ...(endpoint ? { endpoint } : {}),
774
+ forcePathStyle: isYes(forcePathStyleAnswer, true),
775
+ },
776
+ envTemplate: {
777
+ PORTL_STORAGE_S3_ACCESS_KEY_ID: accessKeyId,
778
+ PORTL_STORAGE_S3_SECRET_ACCESS_KEY: secretAccessKey,
779
+ PORTL_STORAGE_S3_BUCKET: bucket,
780
+ PORTL_STORAGE_S3_REGION: region,
781
+ PORTL_STORAGE_S3_ENDPOINT: endpoint,
782
+ PORTL_STORAGE_S3_FORCE_PATH_STYLE: String(isYes(forcePathStyleAnswer, true)),
783
+ },
784
+ };
785
+ }
786
+
787
+ async function collectPaymentsConfig(rl, quickMode = true, options = {}) {
788
+ const reusedIntegration = await maybeReuseExistingIntegration(
789
+ rl,
790
+ 'payments',
791
+ options.existingIntegration
792
+ );
793
+ if (reusedIntegration) {
794
+ return reusedIntegration;
795
+ }
796
+
797
+ const provider = await chooseOption(rl, 'Payments Provider', [
798
+ { label: 'Stripe', value: 'stripe' },
799
+ { label: 'MercadoPago', value: 'mercadopago' },
800
+ { label: 'Skip for now', value: 'none' },
801
+ ]);
802
+
803
+ if (provider.value === 'none') {
804
+ return null;
805
+ }
806
+
807
+ if (provider.value === 'stripe') {
808
+ const secretKey = await askNonEmpty(rl, 'Stripe secret key', '');
809
+ const publishableKey = await ask(rl, 'Stripe publishable key (optional)', '');
810
+ const webhookSecret = quickMode ? '' : await ask(rl, 'Stripe webhook secret (optional)', '');
811
+
812
+ return {
813
+ providerId: 'stripe',
814
+ category: 'payments',
815
+ config: {
816
+ provider: 'stripe',
817
+ secretKey,
818
+ publishableKey: publishableKey || null,
819
+ webhookSecret: webhookSecret || null,
820
+ },
821
+ envTemplate: {
822
+ PORTL_PAYMENTS_PROVIDER: 'stripe',
823
+ PORTL_PAYMENTS_STRIPE_SECRET_KEY: secretKey,
824
+ PORTL_PAYMENTS_STRIPE_PUBLISHABLE_KEY: publishableKey,
825
+ PORTL_PAYMENTS_STRIPE_WEBHOOK_SECRET: webhookSecret,
826
+ },
827
+ };
828
+ }
829
+
830
+ const accessToken = await askNonEmpty(rl, 'MercadoPago access token', '');
831
+ const publicKey = await ask(rl, 'MercadoPago public key (optional)', '');
832
+ const webhookSecret = quickMode ? '' : await ask(rl, 'MercadoPago webhook secret (optional)', '');
833
+
834
+ return {
835
+ providerId: 'mercadopago',
836
+ category: 'payments',
837
+ config: {
838
+ provider: 'mercadopago',
839
+ accessToken,
840
+ publicKey: publicKey || null,
841
+ webhookSecret: webhookSecret || null,
842
+ },
843
+ envTemplate: {
844
+ PORTL_PAYMENTS_PROVIDER: 'mercadopago',
845
+ PORTL_PAYMENTS_MP_ACCESS_TOKEN: accessToken,
846
+ PORTL_PAYMENTS_MP_PUBLIC_KEY: publicKey,
847
+ PORTL_PAYMENTS_MP_WEBHOOK_SECRET: webhookSecret,
848
+ },
849
+ };
850
+ }
851
+
852
+ async function collectCiCdConfig(rl, quickMode = true, options = {}) {
853
+ const reusedIntegration = await maybeReuseExistingIntegration(
854
+ rl,
855
+ 'CI/CD',
856
+ options.existingIntegration
857
+ );
858
+ if (reusedIntegration) {
859
+ return reusedIntegration;
860
+ }
861
+
862
+ const provider = await chooseOption(rl, 'CI/CD Provider', [
863
+ { label: 'GitHub Actions', value: 'github.actions' },
864
+ { label: 'GitLab CI', value: 'gitlab.ci' },
865
+ { label: 'Skip for now', value: 'none' },
866
+ ]);
867
+
868
+ if (provider.value === 'none') {
869
+ return null;
870
+ }
871
+
872
+ const repository = await askNonEmpty(rl, 'Repository (owner/repo)', '');
873
+ const token = await ask(rl, `${provider.label} token (optional)`, '');
874
+ const workflowPath = quickMode
875
+ ? ''
876
+ : await ask(
877
+ rl,
878
+ provider.value === 'github.actions'
879
+ ? 'Workflow file path (optional)'
880
+ : 'Pipeline file path (optional)',
881
+ provider.value === 'github.actions' ? '.github/workflows/portl.yml' : '.gitlab-ci.yml'
882
+ );
883
+
884
+ return {
885
+ providerId: provider.value,
886
+ category: 'cicd',
887
+ config: {
888
+ repository,
889
+ token: token || null,
890
+ workflowPath: workflowPath || null,
891
+ },
892
+ envTemplate: {
893
+ PORTL_CICD_PROVIDER: provider.value,
894
+ PORTL_CICD_REPOSITORY: repository,
895
+ PORTL_CICD_TOKEN: token,
896
+ PORTL_CICD_WORKFLOW_PATH: workflowPath,
897
+ },
898
+ };
899
+ }
900
+
901
+ async function collectAuthConfig(rl, quickMode = true, options = {}) {
902
+ const reusedIntegration = await maybeReuseExistingIntegration(
903
+ rl,
904
+ 'auth',
905
+ options.existingIntegration
906
+ );
907
+ if (reusedIntegration) {
908
+ return reusedIntegration;
909
+ }
910
+
911
+ const provider = await chooseOption(rl, 'Auth Provider', [
912
+ { label: 'Clerk', value: 'clerk' },
913
+ { label: 'Auth0', value: 'auth0' },
914
+ { label: 'Supabase Auth', value: 'supabase.auth' },
915
+ { label: 'Skip for now', value: 'none' },
916
+ ]);
917
+
918
+ if (provider.value === 'none') {
919
+ return null;
920
+ }
921
+
922
+ if (provider.value === 'clerk') {
923
+ const publishableKey = await askNonEmpty(rl, 'Clerk publishable key', '');
924
+ const secretKey = await askNonEmpty(rl, 'Clerk secret key', '');
925
+ return {
926
+ providerId: provider.value,
927
+ category: 'auth',
928
+ config: { publishableKey, secretKey },
929
+ envTemplate: {
930
+ PORTL_AUTH_PROVIDER: 'clerk',
931
+ PORTL_AUTH_CLERK_PUBLISHABLE_KEY: publishableKey,
932
+ PORTL_AUTH_CLERK_SECRET_KEY: secretKey,
933
+ },
934
+ };
935
+ }
936
+
937
+ if (provider.value === 'auth0') {
938
+ const domain = await askNonEmpty(rl, 'Auth0 domain', '');
939
+ const clientId = await askNonEmpty(rl, 'Auth0 client id', '');
940
+ const clientSecret = await askNonEmpty(rl, 'Auth0 client secret', '');
941
+ return {
942
+ providerId: provider.value,
943
+ category: 'auth',
944
+ config: { domain, clientId, clientSecret },
945
+ envTemplate: {
946
+ PORTL_AUTH_PROVIDER: 'auth0',
947
+ PORTL_AUTH_AUTH0_DOMAIN: domain,
948
+ PORTL_AUTH_AUTH0_CLIENT_ID: clientId,
949
+ PORTL_AUTH_AUTH0_CLIENT_SECRET: clientSecret,
950
+ },
951
+ };
952
+ }
953
+
954
+ const url = await askNonEmpty(rl, 'Supabase auth URL', 'https://YOUR-PROJECT.supabase.co');
955
+ const anonKey = await askNonEmpty(rl, 'Supabase anon key', '');
956
+ const serviceRoleKey = quickMode ? '' : await ask(rl, 'Supabase service role key (optional)', '');
957
+
958
+ return {
959
+ providerId: provider.value,
960
+ category: 'auth',
961
+ config: {
962
+ url,
963
+ anonKey,
964
+ serviceRoleKey: serviceRoleKey || null,
965
+ },
966
+ envTemplate: {
967
+ PORTL_AUTH_PROVIDER: 'supabase.auth',
968
+ PORTL_AUTH_SUPABASE_URL: url,
969
+ PORTL_AUTH_SUPABASE_ANON_KEY: anonKey,
970
+ PORTL_AUTH_SUPABASE_SERVICE_ROLE_KEY: serviceRoleKey,
971
+ },
972
+ };
973
+ }
974
+
975
+ function buildConfigFile({ projectId, apiBaseUrl, integrations }) {
976
+ return {
977
+ $schema: 'https://docs.portl.dev/schema/portl.config.json',
978
+ projectId,
979
+ generatedAt: new Date().toISOString(),
980
+ api: {
981
+ baseUrl: apiBaseUrl,
982
+ },
983
+ integrations: integrations.map((integration) => ({
984
+ key: integration.category,
985
+ providerId: integration.providerId,
986
+ category: integration.category,
987
+ config: integration.config,
988
+ })),
989
+ };
990
+ }
991
+
992
+ function buildEnvTemplate({ apiBaseUrl, apiKey, projectId, envEntries }) {
993
+ const lines = [];
994
+ lines.push('# Portl environment template');
995
+ lines.push('# Generated by: npx @portl/cli init (or `finns init`)');
996
+ lines.push('');
997
+ lines.push(`PORTL_API_BASE_URL=${apiBaseUrl}`);
998
+ lines.push(`PORTL_PROJECT_ID=${projectId}`);
999
+ lines.push(`PORTL_API_KEY=${apiKey || 'ik_replace_with_your_key'}`);
1000
+ lines.push('');
1001
+ lines.push('# Integrations');
1002
+
1003
+ for (const [key, value] of Object.entries(envEntries)) {
1004
+ lines.push(`${key}=${value ?? ''}`);
1005
+ }
1006
+
1007
+ lines.push('');
1008
+ lines.push('# Security note: rotate secrets before production use.');
1009
+ return `${lines.join('\n')}\n`;
1010
+ }
1011
+
1012
+ async function writeFiles(cwd, configData, envTemplate) {
1013
+ const configPath = path.join(cwd, 'portl.config.json');
1014
+ const envTemplatePath = path.join(cwd, '.env.portl.template');
1015
+
1016
+ await fs.writeFile(configPath, `${JSON.stringify(configData, null, 2)}\n`, 'utf8');
1017
+ await fs.writeFile(envTemplatePath, envTemplate, 'utf8');
1018
+
1019
+ return { configPath, envTemplatePath };
1020
+ }
1021
+
1022
+ function resolveDefaultWebhookUrl(providerSlug) {
1023
+ const base =
1024
+ process.env.PORTL_WEBHOOK_BASE_URL ||
1025
+ process.env.PORTL_PUBLIC_URL ||
1026
+ process.env.PORTL_APP_URL ||
1027
+ process.env.API_BASE_URL ||
1028
+ 'http://localhost:7130';
1029
+
1030
+ const normalizedBase = String(base)
1031
+ .replace(/\/+$/, '')
1032
+ .replace(/\/api$/, '');
1033
+ return `${normalizedBase}/api/webhooks/${providerSlug}`;
1034
+ }
1035
+
1036
+ async function updateEnvTemplateValues(cwd, entries) {
1037
+ const envTemplatePath = path.join(cwd, '.env.portl.template');
1038
+ let existing = '';
1039
+
1040
+ try {
1041
+ existing = await fs.readFile(envTemplatePath, 'utf8');
1042
+ } catch {
1043
+ existing = '# Portl environment template\n\n';
1044
+ }
1045
+
1046
+ const lines = existing.split('\n');
1047
+ const lineIndexByKey = new Map();
1048
+
1049
+ lines.forEach((line, index) => {
1050
+ const separatorIndex = line.indexOf('=');
1051
+ if (separatorIndex > 0) {
1052
+ lineIndexByKey.set(line.slice(0, separatorIndex).trim(), index);
1053
+ }
1054
+ });
1055
+
1056
+ for (const [key, value] of Object.entries(entries)) {
1057
+ const formatted = `${key}=${value ?? ''}`;
1058
+ const existingIndex = lineIndexByKey.get(key);
1059
+ if (existingIndex !== undefined) {
1060
+ lines[existingIndex] = formatted;
1061
+ } else {
1062
+ lines.push(formatted);
1063
+ }
1064
+ }
1065
+
1066
+ await fs.writeFile(envTemplatePath, `${lines.join('\n').replace(/\n+$/, '\n')}\n`, 'utf8');
1067
+ return envTemplatePath;
1068
+ }
1069
+
1070
+ async function updatePortlIntegrationConfig(cwd, category, providerId, configPatch) {
1071
+ const configPath = path.join(cwd, 'portl.config.json');
1072
+
1073
+ try {
1074
+ const raw = await fs.readFile(configPath, 'utf8');
1075
+ const configData = JSON.parse(raw);
1076
+
1077
+ if (!Array.isArray(configData.integrations)) {
1078
+ return null;
1079
+ }
1080
+
1081
+ const integration = configData.integrations.find(
1082
+ (entry) => entry?.category === category || entry?.providerId === providerId
1083
+ );
1084
+
1085
+ if (!integration) {
1086
+ return null;
1087
+ }
1088
+
1089
+ integration.providerId = providerId;
1090
+ integration.category = category;
1091
+ integration.config = {
1092
+ ...(integration.config || {}),
1093
+ ...configPatch,
1094
+ };
1095
+
1096
+ await fs.writeFile(configPath, `${JSON.stringify(configData, null, 2)}\n`, 'utf8');
1097
+ return configPath;
1098
+ } catch {
1099
+ return null;
1100
+ }
1101
+ }
1102
+
1103
+ function buildGitHubActionsWorkflow({ repository, branch, workflowPath, nodeVersion = '22' }) {
1104
+ const defaultBranch = branch?.trim() || 'main';
1105
+ const targetRepository = repository?.trim() || 'owner/repo';
1106
+ const targetPath = workflowPath?.trim() || '.github/workflows/portl.yml';
1107
+
1108
+ return `name: Portl CI
1109
+
1110
+ on:
1111
+ push:
1112
+ branches: ["${defaultBranch}"]
1113
+ pull_request:
1114
+ branches: ["${defaultBranch}"]
1115
+
1116
+ jobs:
1117
+ verify:
1118
+ runs-on: ubuntu-latest
1119
+ steps:
1120
+ - name: Checkout
1121
+ uses: actions/checkout@v4
1122
+ - name: Setup Node.js
1123
+ uses: actions/setup-node@v4
1124
+ with:
1125
+ node-version: ${nodeVersion}
1126
+ cache: npm
1127
+ - name: Install dependencies
1128
+ run: npm install
1129
+ - name: Build
1130
+ run: npm run build
1131
+
1132
+ # Generated by Portl for ${targetRepository}
1133
+ # Save as ${targetPath}
1134
+ `;
1135
+ }
1136
+
1137
+ function resolveGitHubActionsIntegrationConfig(configData) {
1138
+ const integrations = Array.isArray(configData?.integrations) ? configData.integrations : [];
1139
+ return integrations.find((integration) => integration?.providerId === 'github.actions') || null;
1140
+ }
1141
+
1142
+ function resolvePaymentsIntegrationConfig(configData, providerId) {
1143
+ const integrations = Array.isArray(configData?.integrations) ? configData.integrations : [];
1144
+ return integrations.find((integration) => integration?.providerId === providerId) || null;
1145
+ }
1146
+
1147
+ async function writeGitHubActionsWorkflow(cwd, workflowPath, content, { force = false } = {}) {
1148
+ const normalizedPath = workflowPath?.trim() || '.github/workflows/portl.yml';
1149
+ const absolutePath = path.resolve(cwd, normalizedPath);
1150
+ const directory = path.dirname(absolutePath);
1151
+
1152
+ await fs.mkdir(directory, { recursive: true });
1153
+
1154
+ let existed = false;
1155
+ if (!force) {
1156
+ try {
1157
+ await fs.access(absolutePath);
1158
+ existed = true;
1159
+ } catch {
1160
+ existed = false;
1161
+ }
1162
+ }
1163
+
1164
+ await fs.writeFile(absolutePath, content, 'utf8');
1165
+ return { absolutePath, existed };
1166
+ }
1167
+
1168
+ async function maybeBootstrapGitHubActionsFromInit(rl, cwd, integrations) {
1169
+ const cicdIntegration = integrations.find(
1170
+ (integration) => integration.providerId === 'github.actions'
1171
+ );
1172
+ if (!cicdIntegration) {
1173
+ return null;
1174
+ }
1175
+
1176
+ const repository = String(cicdIntegration.config?.repository || '').trim();
1177
+ const workflowPath =
1178
+ String(cicdIntegration.config?.workflowPath || '').trim() || '.github/workflows/portl.yml';
1179
+ const branch = String(cicdIntegration.config?.branch || '').trim() || 'main';
1180
+
1181
+ const shouldWrite = isYes(await ask(rl, `Write ${workflowPath} into this repo now? [Y/n]`, 'y'));
1182
+
1183
+ if (!shouldWrite) {
1184
+ return null;
1185
+ }
1186
+
1187
+ let force = false;
1188
+ try {
1189
+ await fs.access(path.resolve(cwd, workflowPath));
1190
+ force = isYes(await ask(rl, `${workflowPath} already exists. Overwrite it? [y/N]`, 'n'), false);
1191
+ if (!force) {
1192
+ console.log(c(`Skipped workflow write for ${workflowPath}.`, ANSI.dim));
1193
+ return null;
1194
+ }
1195
+ } catch {
1196
+ // File does not exist; continue.
1197
+ }
1198
+
1199
+ const workflowContent = buildGitHubActionsWorkflow({
1200
+ repository,
1201
+ branch,
1202
+ workflowPath,
1203
+ });
1204
+
1205
+ const result = await writeGitHubActionsWorkflow(cwd, workflowPath, workflowContent, { force });
1206
+ console.log(
1207
+ ` ${c('✓', ANSI.green)} GitHub Actions workflow: ${path.relative(cwd, result.absolutePath)}`
1208
+ );
1209
+ return result;
1210
+ }
1211
+
1212
+ async function createStripeWebhookEndpoint({ secretKey, webhookUrl, enabledEvents, description }) {
1213
+ const form = new URLSearchParams();
1214
+ form.set('url', webhookUrl);
1215
+ form.set('description', description);
1216
+
1217
+ for (const eventName of enabledEvents) {
1218
+ form.append('enabled_events[]', eventName);
1219
+ }
1220
+
1221
+ const response = await fetch('https://api.stripe.com/v1/webhook_endpoints', {
1222
+ method: 'POST',
1223
+ headers: {
1224
+ Authorization: `Bearer ${secretKey}`,
1225
+ 'Content-Type': 'application/x-www-form-urlencoded',
1226
+ },
1227
+ body: form.toString(),
1228
+ });
1229
+
1230
+ const text = await response.text();
1231
+ const data = text ? JSON.parse(text) : null;
1232
+
1233
+ if (!response.ok) {
1234
+ const message =
1235
+ data?.error?.message || `Stripe webhook create failed with status ${response.status}`;
1236
+ throw new Error(message);
1237
+ }
1238
+
1239
+ return data;
1240
+ }
1241
+
1242
+ async function OLD_runInit_DEPRECATED() {
1243
+ const rl = readline.createInterface({ input, output });
1244
+
1245
+ try {
1246
+ console.log(c(PORTL_ASCII, ANSI.cyan));
1247
+ console.log(`${c('Portl setup wizard', ANSI.bold)} ${c('- Fase 2', ANSI.dim)}\n`);
1248
+
1249
+ const quickMode = isYes(await ask(rl, 'Quick mode (recommended, fewer fields)? [Y/n]', 'y'));
1250
+ const existingConfig = await readExistingPortlConfig(process.cwd());
1251
+ const projectId = await askNonEmpty(rl, 'Project name/id', 'local');
1252
+ const apiBaseUrl = quickMode
1253
+ ? 'http://localhost:7130/api'
1254
+ : await askNonEmpty(rl, 'Portl API base URL', 'http://localhost:7130/api');
1255
+ if (quickMode) {
1256
+ console.log(c(`Using default Portl API base URL: ${apiBaseUrl}`, ANSI.dim));
1257
+ }
1258
+ const runMode = await chooseOption(rl, 'Execution mode', [
1259
+ { label: 'Generate files only (recommended)', value: 'files-only' },
1260
+ { label: 'Generate + validate/connect live in backend', value: 'connect-live' },
1261
+ ]);
1262
+
1263
+ let shouldConnectNow = runMode.value === 'connect-live';
1264
+ let apiKey = '';
1265
+ if (shouldConnectNow) {
1266
+ const detectedApiKey = await resolveBackendApiKey(process.cwd());
1267
+ if (detectedApiKey) {
1268
+ apiKey = detectedApiKey;
1269
+ console.log(c('Using ACCESS_API_KEY from shell or local .env', ANSI.dim));
1270
+ } else {
1271
+ console.log(
1272
+ c(
1273
+ 'No ACCESS_API_KEY found in local .env. You can paste one now, or continue files-only.',
1274
+ ANSI.yellow
1275
+ )
1276
+ );
1277
+ const enteredApiKey = await ask(rl, 'ACCESS_API_KEY (optional, press Enter to skip)', '');
1278
+ if (enteredApiKey.trim()) {
1279
+ apiKey = enteredApiKey.trim();
1280
+ } else {
1281
+ shouldConnectNow = false;
1282
+ console.log(c('Continuing in files-only mode.', ANSI.dim));
1283
+ }
1284
+ }
1285
+ } else {
1286
+ const detectedApiKey = await resolveBackendApiKey(process.cwd());
1287
+ if (detectedApiKey) {
1288
+ apiKey = detectedApiKey;
1289
+ }
1290
+ }
1291
+
1292
+ const selectedCapabilities = await chooseMultipleOptions(
1293
+ rl,
1294
+ 'Capabilities to configure',
1295
+ [
1296
+ { label: 'Database', value: 'database' },
1297
+ { label: 'Storage', value: 'storage' },
1298
+ { label: 'Payments (paywall/checkouts)', value: 'payments' },
1299
+ { label: 'CI/CD', value: 'cicd' },
1300
+ { label: 'Auth', value: 'auth' },
1301
+ ],
1302
+ ['database', 'storage']
1303
+ );
1304
+
1305
+ const collectors = {
1306
+ database: {
1307
+ label: 'Database',
1308
+ run: () =>
1309
+ collectDatabaseConfig(rl, {
1310
+ enableLiveActions: shouldConnectNow,
1311
+ apiKey,
1312
+ apiBaseUrl,
1313
+ projectId,
1314
+ redirectUri:
1315
+ process.env.PORTL_OAUTH_CLI_REDIRECT_URI ||
1316
+ process.env.PROVIDERS_OAUTH_DEFAULT_REDIRECT_URI,
1317
+ autoOpenOAuth: true,
1318
+ existingIntegration: findExistingIntegration(existingConfig, 'database'),
1319
+ }),
1320
+ },
1321
+ storage: {
1322
+ label: 'Storage',
1323
+ run: () =>
1324
+ collectStorageConfig(rl, quickMode, {
1325
+ apiKey,
1326
+ apiBaseUrl,
1327
+ projectId,
1328
+ existingIntegration: findExistingIntegration(existingConfig, 'storage'),
1329
+ }),
1330
+ },
1331
+ payments: {
1332
+ label: 'Payments',
1333
+ run: () =>
1334
+ collectPaymentsConfig(rl, quickMode, {
1335
+ existingIntegration: findExistingIntegration(existingConfig, 'payments'),
1336
+ }),
1337
+ },
1338
+ cicd: {
1339
+ label: 'CI/CD',
1340
+ run: () =>
1341
+ collectCiCdConfig(rl, quickMode, {
1342
+ existingIntegration: findExistingIntegration(existingConfig, 'cicd'),
1343
+ }),
1344
+ },
1345
+ auth: {
1346
+ label: 'Auth',
1347
+ run: () =>
1348
+ collectAuthConfig(rl, quickMode, {
1349
+ existingIntegration: findExistingIntegration(existingConfig, 'auth'),
1350
+ }),
1351
+ },
1352
+ };
1353
+
1354
+ const integrations = [];
1355
+ const totalSteps = selectedCapabilities.length + 1;
1356
+ let currentStep = 1;
1357
+
1358
+ for (const capability of selectedCapabilities) {
1359
+ const collector = collectors[capability.value];
1360
+ if (!collector) {
1361
+ continue;
1362
+ }
1363
+
1364
+ console.log(`\n${c(`Step ${currentStep}/${totalSteps}`, ANSI.bold)} - ${collector.label}`);
1365
+ currentStep += 1;
1366
+ const integration = await collector.run();
1367
+ if (integration) {
1368
+ integrations.push(integration);
1369
+ }
1370
+ }
1371
+
1372
+ const envEntries = integrations.reduce((acc, integration) => {
1373
+ return { ...acc, ...integration.envTemplate };
1374
+ }, {});
1375
+
1376
+ console.log(`\n${c(`Step ${totalSteps}/${totalSteps}`, ANSI.bold)} - Summary`);
1377
+ if (integrations.length === 0) {
1378
+ console.log(` - ${c('No integrations selected (or all skipped).', ANSI.yellow)}`);
1379
+ } else {
1380
+ integrations.forEach((integration) => {
1381
+ const isConnectable =
1382
+ SUPPORTED_PROVIDER_IDS.has(integration.providerId) && integration.connectable !== false;
1383
+ console.log(
1384
+ ` - ${integration.category}: ${c(integration.providerId, ANSI.bold)} ${
1385
+ isConnectable ? c('connectable', ANSI.green) : c('template-only', ANSI.yellow)
1386
+ }`
1387
+ );
1388
+ });
1389
+ }
1390
+
1391
+ const connectableIntegrations = integrations.filter(
1392
+ (integration) =>
1393
+ SUPPORTED_PROVIDER_IDS.has(integration.providerId) && integration.connectable !== false
1394
+ );
1395
+
1396
+ let validationResults = [];
1397
+ if (shouldConnectNow && apiKey && connectableIntegrations.length > 0) {
1398
+ const shouldValidate = isYes(await ask(rl, 'Validate providers against API now? [Y/n]', 'y'));
1399
+
1400
+ if (shouldValidate) {
1401
+ console.log(`\n${c('Validating integrations...', ANSI.bold)}`);
1402
+ validationResults = await Promise.all(
1403
+ connectableIntegrations.map(async (integration) => {
1404
+ try {
1405
+ const data = await portlRequest(apiBaseUrl, '/providers/validate', 'POST', apiKey, {
1406
+ providerId: integration.providerId,
1407
+ config: integration.config,
1408
+ });
1409
+ console.log(
1410
+ ` ${c('✓', ANSI.green)} ${integration.providerId}: ${data.status || 'connected'}`
1411
+ );
1412
+ return { providerId: integration.providerId, ok: data.status === 'connected' };
1413
+ } catch (error) {
1414
+ const message = error instanceof Error ? error.message : 'Validation failed';
1415
+ console.log(` ${c('x', ANSI.red)} ${integration.providerId}: ${message}`);
1416
+ return { providerId: integration.providerId, ok: false, error: message };
1417
+ }
1418
+ })
1419
+ );
1420
+ }
1421
+ }
1422
+
1423
+ if (shouldConnectNow && apiKey && connectableIntegrations.length > 0) {
1424
+ const allValid =
1425
+ validationResults.length === 0 || validationResults.every((result) => result.ok);
1426
+ const shouldConnect = allValid
1427
+ ? isYes(await ask(rl, 'Connect providers in Portl backend now? [Y/n]', 'y'))
1428
+ : isYes(await ask(rl, 'Some validations failed. Try connect anyway? [y/N]', 'n'), false);
1429
+
1430
+ if (shouldConnect) {
1431
+ console.log(`\n${c('Connecting providers...', ANSI.bold)}`);
1432
+ for (const integration of connectableIntegrations) {
1433
+ try {
1434
+ const response = await portlRequest(apiBaseUrl, '/providers/connect', 'POST', apiKey, {
1435
+ providerId: integration.providerId,
1436
+ projectId,
1437
+ config: integration.config,
1438
+ });
1439
+ const status = response?.instance?.connectionStatus || 'connected';
1440
+ console.log(` ${c('✓', ANSI.green)} ${integration.providerId}: ${status}`);
1441
+ } catch (error) {
1442
+ const message = error instanceof Error ? error.message : 'Connection failed';
1443
+ console.log(` ${c('x', ANSI.red)} ${integration.providerId}: ${message}`);
1444
+ }
1445
+ }
1446
+ }
1447
+ }
1448
+
1449
+ if (!shouldConnectNow) {
1450
+ console.log(
1451
+ c(
1452
+ '\nSkipped live backend validation/connect. You can connect later from UI or rerun with ACCESS_API_KEY.',
1453
+ ANSI.dim
1454
+ )
1455
+ );
1456
+ }
1457
+
1458
+ const configFile = buildConfigFile({
1459
+ projectId,
1460
+ apiBaseUrl,
1461
+ integrations,
1462
+ });
1463
+ const envTemplate = buildEnvTemplate({
1464
+ apiBaseUrl,
1465
+ apiKey,
1466
+ projectId,
1467
+ envEntries,
1468
+ });
1469
+
1470
+ const { configPath, envTemplatePath } = await writeFiles(
1471
+ process.cwd(),
1472
+ configFile,
1473
+ envTemplate
1474
+ );
1475
+
1476
+ await maybeBootstrapGitHubActionsFromInit(rl, process.cwd(), integrations);
1477
+
1478
+ console.log(`\n${c('Done.', ANSI.green)} Files generated:`);
1479
+ console.log(` - ${path.basename(configPath)}`);
1480
+ console.log(` - ${path.basename(envTemplatePath)}`);
1481
+ console.log(`\n${c('Next:', ANSI.bold)} connect your agent with MCP and start building.`);
1482
+ } finally {
1483
+ rl.close();
1484
+ }
1485
+ }
1486
+
1487
+ function runLink() {
1488
+ const projectId =
1489
+ getArgValue('--project-id') || getArgValue('-p') || process.env.PORTL_PROJECT_ID || null;
1490
+
1491
+ if (!projectId) {
1492
+ console.error(c('Missing --project-id for `portl link`.', ANSI.red));
1493
+ process.exit(1);
1494
+ }
1495
+
1496
+ console.log(c('Portl project linked', ANSI.green));
1497
+ console.log(`Project ID: ${projectId}`);
1498
+ console.log('Use `portl init` (or `finns init`) to configure providers.');
1499
+ }
1500
+
1501
+ async function runConnect() {
1502
+ const target = (args[0] || '').trim().toLowerCase();
1503
+ if (target !== 'supabase') {
1504
+ console.error(
1505
+ c(
1506
+ 'Usage: portl connect supabase [--project-id <id>] [--redirect-uri <url>] [--no-open] [--no-wait]',
1507
+ ANSI.red
1508
+ )
1509
+ );
1510
+ process.exit(1);
1511
+ }
1512
+
1513
+ const cwd = process.cwd();
1514
+ const projectId = getArgValue('--project-id') || process.env.PORTL_PROJECT_ID || 'local';
1515
+ const apiBaseUrl =
1516
+ getArgValue('--api-base-url') || process.env.PORTL_API_BASE_URL || 'http://localhost:7130/api';
1517
+ const apiOrigin = resolveApiOrigin(apiBaseUrl);
1518
+ const redirectUri =
1519
+ getArgValue('--redirect-uri') ||
1520
+ process.env.PORTL_OAUTH_CLI_REDIRECT_URI ||
1521
+ `${apiOrigin}/api/providers/oauth/cli/result`;
1522
+ const apiKey = getArgValue('--api-key') || (await resolveBackendApiKey(cwd));
1523
+ const verbose = hasFlag('--verbose');
1524
+ const shouldAutoOpen = !hasFlag('--no-open');
1525
+ const shouldWait = !hasFlag('--no-wait');
1526
+
1527
+ if (!apiKey) {
1528
+ console.error(c('Missing ACCESS_API_KEY for OAuth start.', ANSI.red));
1529
+ console.error(
1530
+ c(
1531
+ 'Set ACCESS_API_KEY in .env or pass --api-key <value>. This is your Portl backend key.',
1532
+ ANSI.yellow
1533
+ )
1534
+ );
1535
+ process.exit(1);
1536
+ }
1537
+
1538
+ const startedAt = Date.now();
1539
+ const response = await startSupabaseOAuthRequest({
1540
+ apiBaseUrl,
1541
+ apiKey,
1542
+ projectId,
1543
+ redirectUri,
1544
+ });
1545
+
1546
+ console.log(c('Supabase OAuth link generated', ANSI.green));
1547
+ console.log(formatTerminalLink('Open Supabase OAuth', response.authorizeUrl));
1548
+ console.log(response.authorizeUrl);
1549
+
1550
+ if (shouldAutoOpen) {
1551
+ const openResult = await tryOpenBrowser(response.authorizeUrl);
1552
+ if (openResult.opened) {
1553
+ console.log(c('Opened your browser automatically.', ANSI.dim));
1554
+ } else {
1555
+ console.log(
1556
+ c(`Could not open browser automatically (${openResult.command || 'open'}).`, ANSI.yellow)
1557
+ );
1558
+ console.log(c('If it did not open, enter using this link:', ANSI.dim));
1559
+ console.log(response.authorizeUrl);
1560
+ }
1561
+ } else {
1562
+ console.log(c('Auto-open disabled (--no-open). Open the link manually.', ANSI.dim));
1563
+ }
1564
+
1565
+ if (shouldWait) {
1566
+ console.log(c('Waiting for OAuth callback confirmation...', ANSI.dim));
1567
+ const connection = await waitForSupabaseOAuthConnection({
1568
+ apiBaseUrl,
1569
+ apiKey,
1570
+ projectId,
1571
+ startedAt,
1572
+ });
1573
+
1574
+ if (connection) {
1575
+ console.log(c('Supabase OAuth confirmed.', ANSI.green));
1576
+ if (connection.externalProjectRef) {
1577
+ console.log(`Project ref: ${connection.externalProjectRef}`);
1578
+ }
1579
+
1580
+ const projectListing = await fetchSupabaseProjects({
1581
+ apiBaseUrl,
1582
+ apiKey,
1583
+ projectId,
1584
+ });
1585
+ const projects = Array.isArray(projectListing?.projects) ? projectListing.projects : [];
1586
+ if (projects.length > 1) {
1587
+ const promptRl = readline.createInterface({ input, output });
1588
+ try {
1589
+ const selected = await chooseNamedOption(
1590
+ promptRl,
1591
+ 'Choose Supabase project',
1592
+ projects.map((project) => ({
1593
+ value: project.ref || project.id || 'unknown',
1594
+ label: `${project.name || project.ref || project.id || 'Unnamed'} ${c(`(${project.ref || project.id || 'unknown'})`, ANSI.dim)}`,
1595
+ }))
1596
+ );
1597
+
1598
+ const currentRef = projectListing?.selectedProjectRef || connection.externalProjectRef;
1599
+ if (selected.value && selected.value !== currentRef) {
1600
+ const selectionResult = await selectSupabaseProject({
1601
+ apiBaseUrl,
1602
+ apiKey,
1603
+ projectId,
1604
+ projectRef: selected.value,
1605
+ });
1606
+ console.log(c('Supabase project selected.', ANSI.green));
1607
+ console.log(`Project ref: ${selectionResult.projectRef}`);
1608
+ }
1609
+ } finally {
1610
+ promptRl.close();
1611
+ }
1612
+ }
1613
+ } else {
1614
+ console.log(
1615
+ c(
1616
+ 'Timeout waiting for callback. If consent is done, re-run status check from dashboard.',
1617
+ ANSI.yellow
1618
+ )
1619
+ );
1620
+ }
1621
+ } else {
1622
+ console.log(c('No wait mode enabled (--no-wait).', ANSI.dim));
1623
+ }
1624
+
1625
+ if (verbose) {
1626
+ console.log(`Project: ${projectId}`);
1627
+ console.log(`Callback URL: ${response.callbackUrl}`);
1628
+ console.log(`Redirect URL: ${redirectUri}`);
1629
+ }
1630
+ console.log(c('Complete consent in browser, then return to terminal.', ANSI.dim));
1631
+ }
1632
+
1633
+ async function runBootstrap() {
1634
+ const target = (args[0] || '').trim().toLowerCase();
1635
+ if (
1636
+ target !== 'github-actions' &&
1637
+ target !== 'stripe-webhook' &&
1638
+ target !== 'mercadopago-webhook'
1639
+ ) {
1640
+ console.error(
1641
+ c('Usage: portl bootstrap github-actions|stripe-webhook|mercadopago-webhook ...', ANSI.red)
1642
+ );
1643
+ process.exit(1);
1644
+ }
1645
+
1646
+ const cwd = process.cwd();
1647
+ const existingConfig = await readExistingPortlConfig(cwd);
1648
+ const force = hasFlag('--force');
1649
+
1650
+ if (target === 'github-actions') {
1651
+ const existingIntegration = resolveGitHubActionsIntegrationConfig(existingConfig);
1652
+ const repository =
1653
+ getArgValue('--repository') ||
1654
+ existingIntegration?.config?.repository ||
1655
+ process.env.PORTL_CICD_REPOSITORY ||
1656
+ '';
1657
+ const workflowPath =
1658
+ getArgValue('--workflow-path') ||
1659
+ existingIntegration?.config?.workflowPath ||
1660
+ process.env.PORTL_CICD_WORKFLOW_PATH ||
1661
+ '.github/workflows/portl.yml';
1662
+ const branch = getArgValue('--branch') || existingIntegration?.config?.branch || 'main';
1663
+
1664
+ if (!String(repository).trim()) {
1665
+ console.error(
1666
+ c(
1667
+ 'Missing repository. Pass --repository owner/repo or configure github.actions in portl.config.json.',
1668
+ ANSI.red
1669
+ )
1670
+ );
1671
+ process.exit(1);
1672
+ }
1673
+
1674
+ const workflowContent = buildGitHubActionsWorkflow({
1675
+ repository: String(repository),
1676
+ branch: String(branch),
1677
+ workflowPath: String(workflowPath),
1678
+ });
1679
+
1680
+ if (!force) {
1681
+ try {
1682
+ await fs.access(path.resolve(cwd, String(workflowPath)));
1683
+ console.error(
1684
+ c(`${workflowPath} already exists. Re-run with --force to overwrite.`, ANSI.yellow)
1685
+ );
1686
+ process.exit(1);
1687
+ } catch {
1688
+ // File does not exist; continue.
1689
+ }
1690
+ }
1691
+
1692
+ const result = await writeGitHubActionsWorkflow(cwd, String(workflowPath), workflowContent, {
1693
+ force,
1694
+ });
1695
+
1696
+ console.log(c('GitHub Actions workflow generated', ANSI.green));
1697
+ console.log(`Repository: ${repository}`);
1698
+ console.log(`Path: ${path.relative(cwd, result.absolutePath)}`);
1699
+ console.log(c('Next: commit the workflow and let your agent build on top of it.', ANSI.dim));
1700
+ return;
1701
+ }
1702
+
1703
+ if (target === 'stripe-webhook') {
1704
+ const existingIntegration = resolvePaymentsIntegrationConfig(existingConfig, 'stripe');
1705
+ const secretKey =
1706
+ getArgValue('--secret-key') ||
1707
+ existingIntegration?.config?.secretKey ||
1708
+ process.env.PORTL_PAYMENTS_STRIPE_SECRET_KEY ||
1709
+ '';
1710
+ const webhookUrl =
1711
+ getArgValue('--webhook-url') ||
1712
+ existingIntegration?.config?.webhookUrl ||
1713
+ resolveDefaultWebhookUrl('stripe');
1714
+ const enabledEvents = (
1715
+ getArgValue('--events') ||
1716
+ 'checkout.session.completed,checkout.session.async_payment_succeeded,payment_intent.succeeded,payment_intent.payment_failed'
1717
+ )
1718
+ .split(',')
1719
+ .map((value) => value.trim())
1720
+ .filter(Boolean);
1721
+
1722
+ if (!String(secretKey).trim()) {
1723
+ console.error(
1724
+ c(
1725
+ 'Missing Stripe secret key. Pass --secret-key or configure stripe in portl.config.json.',
1726
+ ANSI.red
1727
+ )
1728
+ );
1729
+ process.exit(1);
1730
+ }
1731
+
1732
+ const webhook = await createStripeWebhookEndpoint({
1733
+ secretKey: String(secretKey),
1734
+ webhookUrl: String(webhookUrl),
1735
+ enabledEvents,
1736
+ description: 'Portl webhook endpoint',
1737
+ });
1738
+
1739
+ await updatePortlIntegrationConfig(cwd, 'payments', 'stripe', {
1740
+ webhookUrl: webhook.url,
1741
+ webhookSecret: webhook.secret,
1742
+ webhookEndpointId: webhook.id,
1743
+ enabledEvents: webhook.enabled_events || enabledEvents,
1744
+ });
1745
+ await updateEnvTemplateValues(cwd, {
1746
+ PORTL_PAYMENTS_STRIPE_WEBHOOK_URL: webhook.url,
1747
+ PORTL_PAYMENTS_STRIPE_WEBHOOK_SECRET: webhook.secret || '',
1748
+ PORTL_PAYMENTS_STRIPE_WEBHOOK_ID: webhook.id || '',
1749
+ });
1750
+
1751
+ console.log(c('Stripe webhook created', ANSI.green));
1752
+ console.log(`Webhook URL: ${webhook.url}`);
1753
+ console.log(`Webhook ID: ${webhook.id}`);
1754
+ console.log(c('Webhook secret synced into .env.portl.template.', ANSI.dim));
1755
+ return;
1756
+ }
1757
+
1758
+ const existingIntegration = resolvePaymentsIntegrationConfig(existingConfig, 'mercadopago');
1759
+ const webhookUrl =
1760
+ getArgValue('--webhook-url') ||
1761
+ existingIntegration?.config?.webhookUrl ||
1762
+ resolveDefaultWebhookUrl('mercadopago');
1763
+ const topics = (getArgValue('--topics') || 'payment')
1764
+ .split(',')
1765
+ .map((value) => value.trim())
1766
+ .filter(Boolean);
1767
+
1768
+ await updatePortlIntegrationConfig(cwd, 'payments', 'mercadopago', {
1769
+ webhookUrl,
1770
+ topics,
1771
+ });
1772
+ await updateEnvTemplateValues(cwd, {
1773
+ PORTL_PAYMENTS_MP_WEBHOOK_URL: webhookUrl,
1774
+ PORTL_PAYMENTS_MP_WEBHOOK_TOPICS: topics.join(','),
1775
+ });
1776
+
1777
+ console.log(c('MercadoPago webhook scaffold prepared', ANSI.green));
1778
+ console.log(`Webhook URL: ${webhookUrl}`);
1779
+ console.log(`Topics: ${topics.join(', ')}`);
1780
+ console.log(
1781
+ c(
1782
+ 'Next: register this URL in Your Integrations > Notifications in Mercado Pago dashboard.',
1783
+ ANSI.dim
1784
+ )
1785
+ );
1786
+ }
1787
+
1788
+ async function main() {
1789
+ if (!command || command === '--help' || command === '-h') {
1790
+ showHelp();
1791
+ process.exit(0);
1792
+ }
1793
+
1794
+ if (command === 'init') {
1795
+ const rl = readline.createInterface({ input, output });
1796
+ try {
1797
+ await runInit(rl);
1798
+ } finally {
1799
+ rl.close();
1800
+ }
1801
+ process.exit(0);
1802
+ }
1803
+
1804
+ if (command === 'connect') {
1805
+ await runConnect();
1806
+ process.exit(0);
1807
+ }
1808
+
1809
+ if (command === 'bootstrap') {
1810
+ await runBootstrap();
1811
+ process.exit(0);
1812
+ }
1813
+
1814
+ if (command === 'link') {
1815
+ runLink();
1816
+ process.exit(0);
1817
+ }
1818
+
1819
+ console.error(c(`Unknown command: ${command}`, ANSI.red));
1820
+ showHelp();
1821
+ process.exit(1);
1822
+ }
1823
+
1824
+ main().catch((error) => {
1825
+ console.error(
1826
+ c(`\nPortl CLI failed: ${error instanceof Error ? error.message : String(error)}`, ANSI.red)
1827
+ );
1828
+ process.exit(1);
1829
+ });
1830
+
1831
+ // Export utility functions for use by init command
1832
+ export {
1833
+ startSupabaseOAuthRequest,
1834
+ waitForSupabaseOAuthConnection,
1835
+ tryOpenBrowser,
1836
+ };
1837
+