diff --git a/apps/cli/src/lib/opencode-oauth.ts b/apps/cli/src/lib/opencode-oauth.ts index ee28afe4..892e0abc 100644 --- a/apps/cli/src/lib/opencode-oauth.ts +++ b/apps/cli/src/lib/opencode-oauth.ts @@ -29,6 +29,11 @@ type IdTokenClaims = { }; type OAuthResult = { ok: true } | { ok: false; error: string }; +type OpenAIOAuthOptions = { + onAuthorizationUrl?: (url: string) => void; + onBrowserOpenFailure?: (url: string, error: Error) => void; + continueOnBrowserOpenFailure?: boolean; +}; const HTML_SUCCESS = ` @@ -212,20 +217,30 @@ const exchangeCodeForTokens = async ( return response.json() as Promise; }; +const ensureExitCode = async ( + proc: ReturnType, + commandName: string +): Promise => { + const exitCode = await proc.exited; + if (exitCode !== 0) { + throw new Error(`${commandName} exited with code ${exitCode}`); + } +}; + const openBrowser = async (url: string): Promise => { const platform = process.platform; if (platform === 'darwin') { const proc = spawn(['open', url], { stdout: 'ignore', stderr: 'ignore' }); - await proc.exited; + await ensureExitCode(proc, 'open'); return; } if (platform === 'win32') { const proc = spawn(['cmd', '/c', 'start', '', url], { stdout: 'ignore', stderr: 'ignore' }); - await proc.exited; + await ensureExitCode(proc, 'cmd /c start'); return; } const proc = spawn(['xdg-open', url], { stdout: 'ignore', stderr: 'ignore' }); - await proc.exited; + await ensureExitCode(proc, 'xdg-open'); }; const getAuthFilePath = (): string => { @@ -274,7 +289,8 @@ export const removeProviderAuth = async (providerId: string): Promise = return true; }; -export const loginOpenAIOAuth = async (): Promise => { +export const loginOpenAIOAuth = async (options: OpenAIOAuthOptions = {}): Promise => { + const continueOnBrowserOpenFailure = options.continueOnBrowserOpenFailure ?? true; let server: ReturnType | undefined; let pending: | { @@ -382,8 +398,20 @@ export const loginOpenAIOAuth = async (): Promise => { const pkce = await generatePKCE(); const state = generateState(); const authUrl = buildAuthorizeUrl(redirectUri, pkce, state); + options.onAuthorizationUrl?.(authUrl); console.log(`\nGo to: ${authUrl}\n`); - await openBrowser(authUrl); + try { + await openBrowser(authUrl); + } catch (error) { + const browserError = + error instanceof Error + ? error + : new Error(typeof error === 'string' ? error : String(error)); + options.onBrowserOpenFailure?.(authUrl, browserError); + if (!continueOnBrowserOpenFailure) { + throw browserError; + } + } const code = await waitForCallback(state); const tokens = await exchangeCodeForTokens(code, redirectUri, pkce); diff --git a/apps/cli/src/tui/components/connect-wizard.tsx b/apps/cli/src/tui/components/connect-wizard.tsx index 2f5193ff..fc4b76db 100644 --- a/apps/cli/src/tui/components/connect-wizard.tsx +++ b/apps/cli/src/tui/components/connect-wizard.tsx @@ -17,6 +17,7 @@ import { useMessagesContext } from '../context/messages-context.tsx'; import { useConfigContext } from '../context/config-context.tsx'; import { services } from '../services.ts'; import { formatError } from '../lib/format-error.ts'; +import { copyToClipboard } from '../clipboard.ts'; import { loginCopilotOAuth } from '../../lib/copilot-oauth.ts'; import { loginOpenAIOAuth, saveProviderApiKey } from '../../lib/opencode-oauth.ts'; import { @@ -75,6 +76,7 @@ export const ConnectWizard: Component = (props) => { const [error, setError] = createSignal(null); const [statusMessage, setStatusMessage] = createSignal(''); const [busy, setBusy] = createSignal(false); + const [oauthFallbackUrl, setOauthFallbackUrl] = createSignal(null); const customModelOption: ModelOption = { id: '__custom__', @@ -87,6 +89,9 @@ export const ConnectWizard: Component = (props) => { const setStepSafe = (nextStep: ConnectStep) => { setError(null); setStatusMessage(''); + if (nextStep !== 'auth') { + setOauthFallbackUrl(null); + } setStep(nextStep); }; @@ -126,6 +131,9 @@ export const ConnectWizard: Component = (props) => { return 'Loading providers...'; } if (step() === 'auth') { + if (oauthFallbackUrl()) { + return 'Browser auto-open failed. Press c to copy the URL and paste it manually.'; + } return 'Complete authentication in the browser or terminal.'; } if (step() === 'api-key') { @@ -243,7 +251,13 @@ export const ConnectWizard: Component = (props) => { setStepSafe('auth'); setBusy(true); setStatusMessage('Starting OpenAI OAuth flow...'); - const result = await loginOpenAIOAuth(); + const result = await loginOpenAIOAuth({ + continueOnBrowserOpenFailure: true, + onBrowserOpenFailure: (url) => { + setOauthFallbackUrl(url); + setStatusMessage('Could not auto-open browser. Press c to copy authorization URL.'); + } + }); setBusy(false); if (!result.ok) { @@ -471,6 +485,21 @@ export const ConnectWizard: Component = (props) => { return; } + if (currentStep === 'auth' && key.name === 'c' && !key.ctrl) { + const url = oauthFallbackUrl(); + if (!url) return; + void Result.tryPromise(() => copyToClipboard(url)).then((copyResult) => { + if (copyResult.isErr()) { + const message = formatError(copyResult.error); + setError(message); + messages.addSystemMessage(`Error: ${message}`); + return; + } + setStatusMessage('Authorization URL copied. Paste it in your browser to continue.'); + }); + return; + } + if (currentStep === 'provider') { switch (key.name) { case 'up': @@ -628,6 +657,9 @@ export const ConnectWizard: Component = (props) => { 0}> + + +