Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 33 additions & 5 deletions apps/cli/src/lib/opencode-oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `<!doctype html>
<html>
Expand Down Expand Up @@ -212,20 +217,30 @@ const exchangeCodeForTokens = async (
return response.json() as Promise<TokenResponse>;
};

const ensureExitCode = async (
proc: ReturnType<typeof spawn>,
commandName: string
): Promise<void> => {
const exitCode = await proc.exited;
if (exitCode !== 0) {
throw new Error(`${commandName} exited with code ${exitCode}`);
}
};

const openBrowser = async (url: string): Promise<void> => {
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 => {
Expand Down Expand Up @@ -274,7 +289,8 @@ export const removeProviderAuth = async (providerId: string): Promise<boolean> =
return true;
};

export const loginOpenAIOAuth = async (): Promise<OAuthResult> => {
export const loginOpenAIOAuth = async (options: OpenAIOAuthOptions = {}): Promise<OAuthResult> => {
const continueOnBrowserOpenFailure = options.continueOnBrowserOpenFailure ?? true;
let server: ReturnType<typeof Bun.serve> | undefined;
let pending:
| {
Expand Down Expand Up @@ -382,8 +398,20 @@ export const loginOpenAIOAuth = async (): Promise<OAuthResult> => {
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);
Expand Down
34 changes: 33 additions & 1 deletion apps/cli/src/tui/components/connect-wizard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -75,6 +76,7 @@ export const ConnectWizard: Component<ConnectWizardProps> = (props) => {
const [error, setError] = createSignal<string | null>(null);
const [statusMessage, setStatusMessage] = createSignal('');
const [busy, setBusy] = createSignal(false);
const [oauthFallbackUrl, setOauthFallbackUrl] = createSignal<string | null>(null);

const customModelOption: ModelOption = {
id: '__custom__',
Expand All @@ -87,6 +89,9 @@ export const ConnectWizard: Component<ConnectWizardProps> = (props) => {
const setStepSafe = (nextStep: ConnectStep) => {
setError(null);
setStatusMessage('');
if (nextStep !== 'auth') {
setOauthFallbackUrl(null);
}
setStep(nextStep);
};

Expand Down Expand Up @@ -126,6 +131,9 @@ export const ConnectWizard: Component<ConnectWizardProps> = (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') {
Expand Down Expand Up @@ -243,7 +251,13 @@ export const ConnectWizard: Component<ConnectWizardProps> = (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) {
Expand Down Expand Up @@ -471,6 +485,21 @@ export const ConnectWizard: Component<ConnectWizardProps> = (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':
Expand Down Expand Up @@ -628,6 +657,9 @@ export const ConnectWizard: Component<ConnectWizardProps> = (props) => {
<Show when={statusMessage().length > 0}>
<text fg={colors.textMuted} content={` ${statusMessage()}`} />
</Show>
<Show when={step() === 'auth' && oauthFallbackUrl()}>
<text fg={colors.textSubtle} content={` Authorization URL: ${oauthFallbackUrl()}`} />
</Show>
<Show when={error()}>
<text fg={colors.error} content={` ${error()}`} />
</Show>
Expand Down