Skip to content
Merged
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
162 changes: 134 additions & 28 deletions src/oclif/commands/login.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,42 @@
import {Command, Flags} from '@oclif/core'

import {AuthEvents, type AuthLoginWithApiKeyResponse} from '../../shared/transport/events/auth-events.js'
import {
AuthEvents,
type AuthLoginCompletedEvent,
type AuthLoginWithApiKeyResponse,
type AuthStartLoginResponse,
} from '../../shared/transport/events/auth-events.js'
import {type DaemonClientOptions, formatConnectionError, withDaemonRetry} from '../lib/daemon-client.js'
import {writeJsonResponse} from '../lib/json-response.js'

const DEFAULT_OAUTH_TIMEOUT_MS = 5 * 60 * 1000

type OutputFormat = 'json' | 'text'

export interface LoginOAuthOptions extends DaemonClientOptions {
/** Max time to wait for LOGIN_COMPLETED after the browser opens. */
oauthTimeoutMs?: number
/** Invoked with the auth URL once the daemon has started the flow. */
onAuthUrl?: (authUrl: string) => void
}

export default class Login extends Command {
public static description = 'Authenticate with ByteRover for cloud sync features (optional for local usage)'
public static examples = [
'# Browser OAuth (default)',
'<%= config.bin %> <%= command.id %>',
'',
'# API key (for CI / headless environments)',
'<%= config.bin %> <%= command.id %> --api-key <key>',
'',
'# JSON output (for automation)',
'<%= config.bin %> <%= command.id %> --api-key <key> --format json',
'<%= config.bin %> <%= command.id %> --format json',
]
public static flags = {
'api-key': Flags.string({
char: 'k',
description: 'API key for authentication (get yours at https://app.byterover.dev/settings/keys)',
required: true,
description:
'API key for headless/CI login (get yours at https://app.byterover.dev/settings/keys). Omit to use the browser OAuth flow.',
}),
format: Flags.string({
default: 'text',
Expand All @@ -25,47 +45,133 @@ export default class Login extends Command {
}),
}

/** Gates the OAuth flow. DISPLAY/WAYLAND_DISPLAY deliberately not checked — unset on macOS/Windows, would false-positive. */
protected canOpenBrowser(): boolean {
// Either stream not a TTY means piped/scripted/CI — no interactive user to complete OAuth.
if (process.stdout.isTTY !== true || process.stdin.isTTY !== true) return false
// SSH has a TTY but can't reach the user's local browser.
if (process.env.SSH_CONNECTION || process.env.SSH_CLIENT || process.env.SSH_TTY) return false
return true
}

protected async loginWithApiKey(apiKey: string, options?: DaemonClientOptions): Promise<AuthLoginWithApiKeyResponse> {
return withDaemonRetry<AuthLoginWithApiKeyResponse>(
async (client) => client.requestWithAck<AuthLoginWithApiKeyResponse>(AuthEvents.LOGIN_WITH_API_KEY, {apiKey}),
options,
)
}

protected async loginWithOAuth(options?: LoginOAuthOptions): Promise<AuthLoginCompletedEvent> {
const timeoutMs = options?.oauthTimeoutMs ?? DEFAULT_OAUTH_TIMEOUT_MS

return withDaemonRetry<AuthLoginCompletedEvent>(async (client) => {
// Subscribe *before* initiating, so a fast callback cannot race past us.
let unsubscribe: (() => void) | undefined
let timer: NodeJS.Timeout | undefined
const completion = new Promise<AuthLoginCompletedEvent>((resolve, reject) => {
timer = setTimeout(() => {
unsubscribe?.()
timer = undefined
reject(new Error(`Login timed out after ${Math.round(timeoutMs / 1000)}s`))
}, timeoutMs)

unsubscribe = client.on<AuthLoginCompletedEvent>(AuthEvents.LOGIN_COMPLETED, (data) => {
if (timer) {
clearTimeout(timer)
timer = undefined
}

unsubscribe?.()
resolve(data)
})
})

try {
const startResponse = await client.requestWithAck<AuthStartLoginResponse>(AuthEvents.START_LOGIN)
options?.onAuthUrl?.(startResponse.authUrl)

return await completion
} catch (error) {
if (timer) {
clearTimeout(timer)
timer = undefined
}

unsubscribe?.()
throw error
}
}, options)
}

public async run(): Promise<void> {
const {flags} = await this.parse(Login)
const apiKey = flags['api-key']
const format = (flags.format ?? 'text') as 'json' | 'text'
const format: OutputFormat = flags.format === 'json' ? 'json' : 'text'

if (!apiKey && !this.canOpenBrowser()) {
this.emitError(
format,
'Cannot open a local browser here (non-interactive shell or SSH session). Use --api-key for headless login (get yours at https://app.byterover.dev/settings/keys).',
)
return
}

try {
if (format === 'text') {
this.log('Logging in...')
await (apiKey ? this.runApiKey(apiKey, format) : this.runOAuth(format))
} catch (error) {
Comment thread
wzlng marked this conversation as resolved.
const message = formatConnectionError(error)
if (format === 'json') {
this.emitError(format, message)
} else {
this.log(message)
}
}
Comment thread
wzlng marked this conversation as resolved.
}

const response = await this.loginWithApiKey(apiKey)
private emitError(format: OutputFormat, message: string): void {
if (format === 'json') {
writeJsonResponse({command: 'login', data: {error: message}, success: false})
} else {
this.log(message)
}
}

if (response.success) {
if (format === 'json') {
writeJsonResponse({command: 'login', data: {userEmail: response.userEmail}, success: true})
} else {
this.log(`Logged in as ${response.userEmail}`)
}
} else {
const errorMessage = response.error ?? 'Authentication failed'
if (format === 'json') {
writeJsonResponse({command: 'login', data: {error: errorMessage}, success: false})
} else {
this.log(errorMessage)
}
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Login failed'
private emitSuccess(format: OutputFormat, userEmail: string | undefined): void {
Comment thread
wzlng marked this conversation as resolved.
if (format === 'json') {
writeJsonResponse({command: 'login', data: {userEmail}, success: true})
Comment thread
wzlng marked this conversation as resolved.
Comment thread
wzlng marked this conversation as resolved.
} else {
this.log(userEmail ? `Logged in as ${userEmail}` : 'Logged in successfully')
}
}

if (format === 'json') {
writeJsonResponse({command: 'login', data: {error: errorMessage}, success: false})
} else {
this.log(formatConnectionError(error))
private async runApiKey(apiKey: string, format: OutputFormat): Promise<void> {
if (format === 'text') {
this.log('Logging in...')
}

const response = await this.loginWithApiKey(apiKey)

if (response.success) {
this.emitSuccess(format, response.userEmail)
} else {
this.emitError(format, response.error ?? 'Authentication failed')
}
}

private async runOAuth(format: OutputFormat): Promise<void> {
const onAuthUrl = (authUrl: string): void => {
if (format === 'text') {
this.log('Opening browser for authentication...')
Comment thread
wzlng marked this conversation as resolved.
this.log(`If the browser did not open, visit: ${authUrl}`)
}
}

const result = await this.loginWithOAuth({onAuthUrl})
Comment thread
wzlng marked this conversation as resolved.

if (result.success) {
this.emitSuccess(format, result.user?.email)
} else {
this.emitError(format, result.error ?? 'Authentication failed')
}
}
}
3 changes: 3 additions & 0 deletions src/oclif/commands/providers/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ export default class ProviderList extends Command {
const authBadge =
p.authMethod === 'oauth' ? chalk.cyan('[OAuth]') : p.authMethod === 'api-key' ? chalk.dim('[API Key]') : ''
this.log(` ${p.name} [${p.id}] ${status} ${authBadge}`.trimEnd())
if (p.description) {
this.log(` ${chalk.dim(p.description)}`)
}
}
} catch (error) {
if (format === 'json') {
Expand Down
4 changes: 2 additions & 2 deletions src/server/core/domain/entities/provider-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ export const PROVIDER_REGISTRY: Readonly<Record<string, ProviderDefinition>> = {
byterover: {
baseUrl: '',
category: 'popular',
description: 'Built-in LLM, logged-in ByteRover account required. Limited free usage.',
description: 'Built-in LLM, ByteRover account required. Limited free usage.',
headers: {},
id: 'byterover',
modelsEndpoint: '',
Expand Down Expand Up @@ -259,7 +259,7 @@ export const PROVIDER_REGISTRY: Readonly<Record<string, ProviderDefinition>> = {
baseUrl: '',
category: 'other',
defaultModel: 'llama3',
description: 'Connect any OpenAI-compatible endpoint (Ollama, LM Studio, etc.)',
description: 'OpenAI-compatible endpoint (Ollama, LM Studio, etc.)',
envVars: ['OPENAI_COMPATIBLE_API_KEY'],
headers: {},
id: 'openai-compatible',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,16 @@ export function ProviderSelectStep({onSelect, providers}: ProviderSelectStepProp
)}
key={provider.id}
onClick={() => onSelect(provider)}
title={provider.description}
type="button"
>
<div className="bg-muted/50 grid size-7 shrink-0 place-items-center overflow-hidden rounded-md">
{icon && <img alt="" className="size-5" src={icon} />}
</div>
<span className="text-foreground flex-1 text-sm font-medium">{provider.name}</span>
<div className="min-w-0 flex-1">
<div className="text-foreground truncate text-sm font-medium">{provider.name}</div>
<div className="text-muted-foreground min-h-[1lh] truncate text-xs">{provider.description}</div>
</div>
<div
className={cn(
'grid size-[18px] shrink-0 place-items-center rounded-full border transition-colors',
Expand Down
Loading
Loading