diff --git a/src/commands/cloud/connect.ts b/src/commands/cloud/connect.ts index 7715174..13e91e1 100644 --- a/src/commands/cloud/connect.ts +++ b/src/commands/cloud/connect.ts @@ -484,7 +484,7 @@ async function browserConnect( return 'connected'; } const check = canWaitForBrowser(config) && baseline ? baseline.check : null; - await openOrPrintInstallUrl(config, cliConnectUrl(config, provider, workspaceId), label, noBrowser); + await openOrPrintInstallUrl(config, cliConnectUrl(config, provider, workspaceId, reconnect), label, noBrowser); return confirmBrowserConnect(config, check, `${label} to connect`); } diff --git a/src/commands/helpers.ts b/src/commands/helpers.ts index 7c02f21..ae41a69 100644 --- a/src/commands/helpers.ts +++ b/src/commands/helpers.ts @@ -265,8 +265,9 @@ export function secretStep( // marks the browser session as CLI-initiated (cookie), generates the provider // install URL, and forwards to it — so the flow ends on the console's // "go back to your terminal" page instead of the workspace dashboard. -export function cliConnectUrl(config: Config, flow: string, workspaceId: string): string { - return `${consoleBaseUrl(config)}/cli/connect?flow=${encodeURIComponent(flow)}&workspace=${encodeURIComponent(workspaceId)}`; +export function cliConnectUrl(config: Config, flow: string, workspaceId: string, reconnect = false): string { + const url = `${consoleBaseUrl(config)}/cli/connect?flow=${encodeURIComponent(flow)}&workspace=${encodeURIComponent(workspaceId)}`; + return reconnect ? `${url}&reconnect=1` : url; } // After a connect flow hands off to the browser, the terminal session should diff --git a/src/commands/integration/connect.ts b/src/commands/integration/connect.ts index 198a650..f39fda3 100644 --- a/src/commands/integration/connect.ts +++ b/src/commands/integration/connect.ts @@ -136,8 +136,8 @@ export function prReviewsFlagsGiven(args: Record): boolean { // The console's /cli/connect page carries the answer across the GitHub install round-trip // and hands it to the API, which records it on the integration before the repository sync. -export function githubConnectUrl(config: Config, workspaceId: string, prReviews: PrReviewsChoice | undefined): string { - const url = cliConnectUrl(config, 'github', workspaceId); +export function githubConnectUrl(config: Config, workspaceId: string, prReviews: PrReviewsChoice | undefined, reconnect = false): string { + const url = cliConnectUrl(config, 'github', workspaceId, reconnect); return prReviews ? `${url}&pr_reviews=${prReviews}` : url; } @@ -1148,7 +1148,7 @@ async function connectType( return 'connected'; } const check = canWaitForBrowser(config) && baseline ? baseline.check : null; - let url = cliConnectUrl(config, type, workspaceId); + let url = cliConnectUrl(config, type, workspaceId, reconnect); if (type !== 'github' && prReviewsFlagsGiven(args) && config.output !== 'json') { process.stderr.write('--pr-reviews / --no-pr-reviews only apply to --type github; ignored.\n'); } @@ -1159,7 +1159,7 @@ async function connectType( if (answer === BACK) return BACK; prReviews = answer; } - url = githubConnectUrl(config, workspaceId, prReviews); + url = githubConnectUrl(config, workspaceId, prReviews, reconnect); } await openOrPrintInstallUrl(config, url, labels[type], noBrowser); const outcome = await confirmBrowserConnect(config, check, names[type]); diff --git a/test/browser-reconnect-handoff.test.ts b/test/browser-reconnect-handoff.test.ts new file mode 100644 index 0000000..3533466 --- /dev/null +++ b/test/browser-reconnect-handoff.test.ts @@ -0,0 +1,53 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { cloudConnectCommand } from '../src/commands/cloud/connect'; +import { cliConnectUrl } from '../src/commands/helpers'; +import { integrationConnectCommand } from '../src/commands/integration/connect'; +import { mockConfig } from './helpers/config'; + +describe('browser reconnect handoff', () => { + it('encodes explicit reconnect without changing an ordinary connect URL', () => { + const config = mockConfig({ domain: 'api.baseberry.cc' }); + const normal = new URL(cliConnectUrl(config, 'posthog', 'ws_one&two')); + const reconnect = new URL(cliConnectUrl(config, 'posthog', 'ws_one&two', true)); + assert.equal(normal.searchParams.has('reconnect'), false); + assert.equal(reconnect.searchParams.get('workspace'), 'ws_one&two'); + assert.equal(reconnect.searchParams.get('reconnect'), '1'); + }); + + for (const type of ['github', 'slack', 'sentry', 'posthog']) { + it(`forwards --reconnect through ${type} integration connect`, async (t) => { + const writes: string[] = []; + t.mock.method(process.stdout, 'write', (chunk) => { + writes.push(String(chunk)); + return true; + }); + await integrationConnectCommand.execute( + mockConfig({ domain: 'api.baseberry.cc', workspaceId: 'ws_one', dryRun: true, output: 'text', quiet: true }), + {}, + { type, reconnect: true, noBrowser: true } + ); + const url = new URL(writes.join('').trim()); + assert.equal(url.searchParams.get('flow'), type); + assert.equal(url.searchParams.get('reconnect'), '1'); + }); + } + + for (const provider of ['vercel', 'planetscale', 'supabase']) { + it(`forwards --reconnect through ${provider} cloud connect`, async (t) => { + const writes: string[] = []; + t.mock.method(process.stdout, 'write', (chunk) => { + writes.push(String(chunk)); + return true; + }); + await cloudConnectCommand.execute( + mockConfig({ domain: 'api.baseberry.cc', workspaceId: 'ws_one', dryRun: true, output: 'text', quiet: true }), + {}, + { provider, reconnect: true, noBrowser: true } + ); + const url = new URL(writes.join('').trim()); + assert.equal(url.searchParams.get('flow'), provider); + assert.equal(url.searchParams.get('reconnect'), '1'); + }); + } +});