From c5413c17ad79f81ee9a8a1aa540577c5495cd7b5 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 7 May 2026 23:30:51 -0400 Subject: [PATCH] feat(desktop): OAuth-first onboarding using existing dashboard provider API Replace the engineer-flavored API key form with a Sign-in-first onboarding overlay that uses the dashboard's existing /api/providers/oauth catalog and PKCE/device-code endpoints (Anthropic, Nous, OpenAI Codex, etc.). API key entry is now a fallback tab with friendly provider names instead of env var prefixes, and the loud raw resolver error is gone in favor of a one-line welcome message. --- .../components/desktop-onboarding-overlay.tsx | 828 ++++++++++++++---- .../src/components/pane-shell/pane-shell.tsx | 3 + apps/desktop/src/hermes.ts | 43 + apps/desktop/src/types/hermes.ts | 53 ++ 4 files changed, 735 insertions(+), 192 deletions(-) diff --git a/apps/desktop/src/components/desktop-onboarding-overlay.tsx b/apps/desktop/src/components/desktop-onboarding-overlay.tsx index 3bd3b69068c..c3d5d3b2e05 100644 --- a/apps/desktop/src/components/desktop-onboarding-overlay.tsx +++ b/apps/desktop/src/components/desktop-onboarding-overlay.tsx @@ -1,14 +1,21 @@ import { useStore } from '@nanostores/react' -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' -import { getEnvVars, setEnvVar } from '@/hermes' -import { AlertCircle, Check, ExternalLink, KeyRound, Loader2 } from '@/lib/icons' +import { + cancelOAuthSession, + listOAuthProviders, + pollOAuthSession, + setEnvVar, + startOAuthLogin, + submitOAuthCode +} from '@/hermes' +import { Check, ChevronRight, ExternalLink, KeyRound, Loader2, Sparkles } from '@/lib/icons' import { cn } from '@/lib/utils' import { notify, notifyError } from '@/store/notifications' import { $desktopOnboarding, completeDesktopOnboarding } from '@/store/onboarding' -import type { EnvVarInfo } from '@/types/hermes' +import type { OAuthProvider, OAuthStartResponse } from '@/types/hermes' interface DesktopOnboardingOverlayProps { enabled: boolean @@ -22,244 +29,503 @@ interface SetupStatus { interface RuntimeCheck { error?: string - model?: string ok?: boolean - provider?: string } -interface ProviderOption { - key: string - label: string - helper: string +interface ApiKeyOption { + description: string + docsUrl: string + envKey: string + id: string + name: string + placeholder?: string + short?: string } -const PREFERRED_PROVIDER_KEYS: ProviderOption[] = [ +const API_KEY_OPTIONS: ApiKeyOption[] = [ { - key: 'OPENROUTER_API_KEY', - label: 'OpenRouter', - helper: 'Works with many hosted models and is a good default for new installs.' + id: 'openrouter', + name: 'OpenRouter', + short: 'one key, many models', + envKey: 'OPENROUTER_API_KEY', + description: 'Hosts hundreds of models behind a single key. Good default for new installs.', + docsUrl: 'https://openrouter.ai/keys' }, { - key: 'ANTHROPIC_API_KEY', - label: 'Anthropic', - helper: 'Use Claude models directly.' + id: 'openai', + name: 'OpenAI', + short: 'GPT-class models', + envKey: 'OPENAI_API_KEY', + description: 'Direct access to OpenAI models.', + docsUrl: 'https://platform.openai.com/api-keys' }, { - key: 'OPENAI_API_KEY', - label: 'OpenAI', - helper: 'Use OpenAI models directly.' + id: 'gemini', + name: 'Google Gemini', + short: 'Gemini models', + envKey: 'GEMINI_API_KEY', + description: 'Direct access to Google Gemini models.', + docsUrl: 'https://aistudio.google.com/app/apikey' }, { - key: 'GEMINI_API_KEY', - label: 'Gemini', - helper: 'Use Google Gemini models.' + id: 'xai', + name: 'xAI Grok', + short: 'Grok models', + envKey: 'XAI_API_KEY', + description: 'Direct access to xAI Grok models.', + docsUrl: 'https://console.x.ai/' }, { - key: 'XAI_API_KEY', - label: 'xAI', - helper: 'Use Grok models.' - }, - { - key: 'OPENAI_BASE_URL', - label: 'Local / OpenAI-compatible', - helper: 'Use a local or self-hosted OpenAI-compatible endpoint. API key may not be required.' + id: 'local', + name: 'Local / custom endpoint', + short: 'self-hosted', + envKey: 'OPENAI_BASE_URL', + description: 'Point Hermes at a local or self-hosted OpenAI-compatible endpoint (vLLM, llama.cpp, Ollama, etc).', + docsUrl: 'https://github.com/NousResearch/hermes-agent#bring-your-own-endpoint', + placeholder: 'http://127.0.0.1:8000/v1' } ] -function optionLabel(option: ProviderOption, info?: EnvVarInfo) { - return info?.description ? `${option.label} (${option.key})` : option.label +interface FlowState { + copyState?: 'copied' | 'idle' + errorMessage?: string + expiresAt?: number + provider?: OAuthProvider + start?: OAuthStartResponse + status: 'awaiting_user' | 'error' | 'idle' | 'polling' | 'starting' | 'submitting' | 'success' + submitCode?: string } +const POLL_INTERVAL_MS = 2000 + export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway }: DesktopOnboardingOverlayProps) { const onboarding = useStore($desktopOnboarding) - const [checking, setChecking] = useState(false) - const [envVars, setEnvVars] = useState | null>(null) - const [error, setError] = useState(null) const [providerConfigured, setProviderConfigured] = useState(true) - const [saving, setSaving] = useState(false) - const [selectedKey, setSelectedKey] = useState(PREFERRED_PROVIDER_KEYS[0].key) - const [value, setValue] = useState('') - const shouldCheck = enabled || onboarding.requested - - useEffect(() => { - if (!shouldCheck) { - return - } - - let cancelled = false - - async function checkSetup() { - setChecking(true) - setError(null) + const [oauthProviders, setOauthProviders] = useState(null) + const [mode, setMode] = useState<'apikey' | 'oauth'>('oauth') + const [flow, setFlow] = useState({ status: 'idle' }) + const [apiKeyOption, setApiKeyOption] = useState(API_KEY_OPTIONS[0]) + const [apiKeyValue, setApiKeyValue] = useState('') + const [apiKeySaving, setApiKeySaving] = useState(false) + const [apiKeyError, setApiKeyError] = useState(null) + const cancelledRef = useRef(false) + const pollTimerRef = useRef(null) + const shouldShow = enabled || onboarding.requested + const refreshSetupCheck = useMemo( + () => async () => { try { - const [status, runtime, vars] = await Promise.all([ + const [status, runtime] = await Promise.all([ requestGateway('setup.status').catch(() => ({}) as SetupStatus), - requestGateway('setup.runtime_check').catch(() => ({ ok: false }) as RuntimeCheck), - getEnvVars() + requestGateway('setup.runtime_check').catch(() => ({ ok: false }) as RuntimeCheck) ]) - if (cancelled) { - return - } - - // The strict runtime check is the source of truth: it runs the same - // resolver the agent uses, so it can't be fooled by fallbacks like - // gh-CLI Copilot tokens when the user's configured model isn't a - // Copilot model. setup.status is kept as a coarse sanity check - // for backends that don't expose setup.runtime_check yet. - const runtimeOk = runtime.ok !== undefined ? Boolean(runtime.ok) : Boolean(status.provider_configured) - - setProviderConfigured(runtimeOk) - setEnvVars(vars) - - if (runtimeOk) { - completeDesktopOnboarding() - } else if (runtime.error) { - setError(runtime.error) - } - - const firstAvailable = PREFERRED_PROVIDER_KEYS.find(option => vars[option.key]) - - if (firstAvailable) { - setSelectedKey(current => (vars[current] ? current : firstAvailable.key)) - } - } catch (err) { - if (!cancelled) { - setProviderConfigured(false) - setError(err instanceof Error ? err.message : String(err)) - } - } finally { - if (!cancelled) { - setChecking(false) - } + return runtime.ok !== undefined ? Boolean(runtime.ok) : Boolean(status.provider_configured) + } catch { + return false } - } - - void checkSetup() - - return () => void (cancelled = true) - }, [requestGateway, shouldCheck]) - - const providerOptions = useMemo( - () => PREFERRED_PROVIDER_KEYS.filter(option => !envVars || envVars[option.key]), - [envVars] + }, + [requestGateway] ) - const selectedInfo = envVars?.[selectedKey] - const selectedOption = providerOptions.find(option => option.key === selectedKey) ?? PREFERRED_PROVIDER_KEYS[0] - const canSave = selectedKey === 'OPENAI_BASE_URL' ? value.trim().length > 0 : value.trim().length > 8 - - async function handleSave() { - if (!canSave || saving) { + useEffect(() => { + if (!shouldShow) { return } - setSaving(true) - setError(null) + cancelledRef.current = false - try { - await setEnvVar(selectedKey, value.trim()) - await requestGateway('reload.env').catch(() => undefined) - const status = await requestGateway('setup.status') + void (async () => { + const ok = await refreshSetupCheck() - if (!status.provider_configured) { - setError('Credential was saved, but Hermes still does not see a configured provider.') + if (cancelledRef.current) { + return + } + + setProviderConfigured(ok) + + if (ok) { + completeDesktopOnboarding() return } - notify({ kind: 'success', title: 'Hermes is ready', message: `${selectedKey} saved.` }) - setProviderConfigured(true) - setValue('') - completeDesktopOnboarding() - onCompleted?.() - } catch (err) { - notifyError(err, `Failed to save ${selectedKey}`) - setError(err instanceof Error ? err.message : String(err)) - } finally { - setSaving(false) + try { + const providers = await listOAuthProviders() + + if (cancelledRef.current) { + return + } + + setOauthProviders(providers.providers) + setMode(providers.providers.length > 0 ? 'oauth' : 'apikey') + } catch { + if (!cancelledRef.current) { + setOauthProviders([]) + setMode('apikey') + } + } + })() + + return () => { + cancelledRef.current = true + } + }, [refreshSetupCheck, shouldShow]) + + useEffect( + () => () => { + if (pollTimerRef.current !== null) { + window.clearInterval(pollTimerRef.current) + pollTimerRef.current = null + } + + if (flow.start?.session_id && flow.status !== 'success' && flow.status !== 'idle') { + cancelOAuthSession(flow.start.session_id).catch(() => undefined) + } + }, + [flow.start?.session_id, flow.status] + ) + + function clearPollTimer() { + if (pollTimerRef.current !== null) { + window.clearInterval(pollTimerRef.current) + pollTimerRef.current = null } } - if (!shouldCheck || providerConfigured) { + async function finalizeSuccess(providerName: string) { + clearPollTimer() + notify({ kind: 'success', title: 'Hermes is ready', message: `${providerName} connected.` }) + setFlow({ status: 'success' }) + + try { + await requestGateway('reload.env').catch(() => undefined) + } catch { + // best effort env reload + } + + const ok = await refreshSetupCheck() + + if (ok) { + setProviderConfigured(true) + completeDesktopOnboarding() + onCompleted?.() + } else { + setFlow({ + status: 'error', + errorMessage: 'Connected, but Hermes still cannot resolve a usable provider. Try another provider.' + }) + } + } + + async function startProviderFlow(provider: OAuthProvider) { + if (provider.flow === 'external') { + notify({ + kind: 'info', + title: `${provider.name} uses an external CLI`, + message: `Run \`${provider.cli_command}\` in a terminal, then come back to retry.` + }) + + return + } + + clearPollTimer() + setFlow({ status: 'starting', provider }) + + try { + const start = await startOAuthLogin(provider.id) + const expiresAt = Date.now() + start.expires_in * 1000 + + if (start.flow === 'pkce') { + await window.hermesDesktop?.openExternal(start.auth_url).catch(() => undefined) + setFlow({ status: 'awaiting_user', provider, start, expiresAt }) + + return + } + + await window.hermesDesktop?.openExternal(start.verification_url).catch(() => undefined) + setFlow({ status: 'polling', provider, start, expiresAt }) + pollTimerRef.current = window.setInterval(() => void pollOAuth(provider, start), POLL_INTERVAL_MS) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + setFlow({ status: 'error', provider, errorMessage: `Could not start sign-in: ${message}` }) + } + } + + async function pollOAuth(provider: OAuthProvider, start: OAuthStartResponse) { + if (start.flow !== 'device_code') { + return + } + + try { + const resp = await pollOAuthSession(provider.id, start.session_id) + + if (resp.status === 'approved') { + clearPollTimer() + await finalizeSuccess(provider.name) + } else if (resp.status !== 'pending') { + clearPollTimer() + setFlow({ + status: 'error', + provider, + start, + errorMessage: resp.error_message || `Sign-in ${resp.status}.` + }) + } + } catch (err) { + clearPollTimer() + const message = err instanceof Error ? err.message : String(err) + setFlow({ status: 'error', provider, start, errorMessage: `Polling failed: ${message}` }) + } + } + + async function submitPkce() { + if (flow.status !== 'awaiting_user' || !flow.provider || flow.start?.flow !== 'pkce') { + return + } + + const code = (flow.submitCode || '').trim() + + if (!code) { + return + } + + const provider = flow.provider + const start = flow.start + setFlow(prev => ({ ...prev, status: 'submitting' })) + + try { + const resp = await submitOAuthCode(provider.id, start.session_id, code) + + if (resp.ok && resp.status === 'approved') { + await finalizeSuccess(provider.name) + } else { + setFlow({ status: 'error', provider, start, errorMessage: resp.message || 'Token exchange failed.' }) + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + setFlow({ status: 'error', provider, start, errorMessage: message }) + } + } + + function cancelFlow() { + clearPollTimer() + + if (flow.start?.session_id) { + cancelOAuthSession(flow.start.session_id).catch(() => undefined) + } + + setFlow({ status: 'idle' }) + } + + async function copyDeviceCode() { + if (flow.start?.flow !== 'device_code') { + return + } + + try { + await navigator.clipboard.writeText(flow.start.user_code) + setFlow(prev => ({ ...prev, copyState: 'copied' })) + window.setTimeout(() => setFlow(prev => ({ ...prev, copyState: 'idle' })), 1500) + } catch { + // clipboard write blocked; user can still type the code manually + } + } + + async function saveApiKey() { + const value = apiKeyValue.trim() + const minLen = apiKeyOption.envKey === 'OPENAI_BASE_URL' ? 1 : 8 + + if (!value || value.length < minLen || apiKeySaving) { + return + } + + setApiKeySaving(true) + setApiKeyError(null) + + try { + await setEnvVar(apiKeyOption.envKey, value) + await requestGateway('reload.env').catch(() => undefined) + const ok = await refreshSetupCheck() + + if (ok) { + notify({ kind: 'success', title: 'Hermes is ready', message: `${apiKeyOption.name} connected.` }) + setProviderConfigured(true) + completeDesktopOnboarding() + setApiKeyValue('') + onCompleted?.() + } else { + setApiKeyError(`Saved, but Hermes still cannot reach ${apiKeyOption.name}. Double-check the value.`) + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + notifyError(err, `Could not save ${apiKeyOption.name}`) + setApiKeyError(message) + } finally { + setApiKeySaving(false) + } + } + + if (!shouldShow || providerConfigured) { return null } + const oauthList = oauthProviders ?? [] + return (
-
-
-
- -
-
-

Set up Hermes

-

- Add one inference provider before starting your first chat. This writes to the current Hermes - profile's `.env` file and takes effect immediately. -

-
+
+
+ +
+
+

Welcome to Hermes

+

+ Connect a model provider to start chatting. Most options take one click. +

- {checking ? ( + {flow.status === 'idle' || flow.status === 'success' ? ( + void saveApiKey()} + onApiKeySelect={setApiKeyOption} + onApiKeyValueChange={setApiKeyValue} + onModeChange={setMode} + onProviderSelect={provider => void startProviderFlow(provider)} + /> + ) : ( + void copyDeviceCode()} + onSubmitCode={() => void submitPkce()} + onSubmitCodeChange={code => setFlow(prev => ({ ...prev, submitCode: code }))} + /> + )} +
+
+
+ ) +} + +interface ProviderPickerProps { + apiKeyError: null | string + apiKeyOption: ApiKeyOption + apiKeySaving: boolean + apiKeyValue: string + loading: boolean + mode: 'apikey' | 'oauth' + oauthProviders: OAuthProvider[] + onApiKeySave: () => void + onApiKeySelect: (option: ApiKeyOption) => void + onApiKeyValueChange: (value: string) => void + onModeChange: (mode: 'apikey' | 'oauth') => void + onProviderSelect: (provider: OAuthProvider) => void +} + +function ProviderPicker({ + apiKeyError, + apiKeyOption, + apiKeySaving, + apiKeyValue, + loading, + mode, + oauthProviders, + onApiKeySave, + onApiKeySelect, + onApiKeyValueChange, + onModeChange, + onProviderSelect +}: ProviderPickerProps) { + const hasOauth = oauthProviders.length > 0 + const minLen = apiKeyOption.envKey === 'OPENAI_BASE_URL' ? 1 : 8 + const canSave = apiKeyValue.trim().length >= minLen + + return ( + <> + {hasOauth && ( +
+ + +
+ )} + + {mode === 'oauth' && hasOauth ? ( +
+ {loading ? (
- Checking provider setup... -
- ) : null} - - {onboarding.reason ? ( -
- - {onboarding.reason} -
- ) : null} - -
- -
- {providerOptions.map(option => ( - - ))} + Looking up providers...
+ ) : ( + oauthProviders.map(provider => ( + + )) + )} +
+ ) : ( +
+
+ {API_KEY_OPTIONS.map(option => ( + + ))}
- - {selectedInfo?.url ? ( +

{apiKeyOption.description}

+ {apiKeyOption.docsUrl ? ( @@ -269,34 +535,212 @@ export function DesktopOnboardingOverlay({ autoComplete="off" autoFocus className="font-mono" - onChange={event => setValue(event.target.value)} + onChange={event => onApiKeyValueChange(event.target.value)} onKeyDown={event => { if (event.key === 'Enter') { - void handleSave() + onApiKeySave() } }} - placeholder={selectedKey === 'OPENAI_BASE_URL' ? 'http://127.0.0.1:8000/v1' : 'Paste API key'} - type={selectedInfo?.is_password === false || selectedKey === 'OPENAI_BASE_URL' ? 'text' : 'password'} - value={value} + placeholder={apiKeyOption.placeholder || 'Paste API key'} + type={apiKeyOption.envKey === 'OPENAI_BASE_URL' ? 'text' : 'password'} + value={apiKeyValue} /> -

{selectedOption.helper}

+ {apiKeyError ?

{apiKeyError}

: null}
- {error ? ( -
- - {error} -
- ) : null} +
+ +
+
+ )} + + ) +} -
- + ) +} + +function flowSubtitle(flow: OAuthProvider['flow']) { + if (flow === 'pkce') { + return 'Opens your browser, asks you to paste a one-time code back here.' + } + + if (flow === 'device_code') { + return 'Opens a verification page in your browser. Hermes connects automatically.' + } + + return '' +} + +interface FlowPanelProps { + flow: FlowState + onCancel: () => void + onCopyCode: () => void + onSubmitCode: () => void + onSubmitCodeChange: (code: string) => void +} + +function FlowPanel({ flow, onCancel, onCopyCode, onSubmitCode, onSubmitCodeChange }: FlowPanelProps) { + if (flow.status === 'starting') { + return ( +
+ + Starting sign-in for {flow.provider?.name}... +
+ ) + } + + if (flow.status === 'submitting') { + return ( +
+ + Verifying your code with {flow.provider?.name}... +
+ ) + } + + if (flow.status === 'success') { + return ( +
+ + {flow.provider?.name} connected. You're ready to chat. +
+ ) + } + + if (flow.status === 'error') { + return ( +
+
+ {flow.errorMessage || 'Sign-in failed. Try again.'} +
+
+ +
+
+ ) + } + + if (flow.status === 'awaiting_user' && flow.start?.flow === 'pkce' && flow.provider) { + return ( +
+
+

Sign in with {flow.provider.name}

+
    +
  1. We opened {flow.provider.name} in your browser.
  2. +
  3. Authorize Hermes there.
  4. +
  5. Copy the authorization code and paste it below.
  6. +
+
+ onSubmitCodeChange(event.target.value)} + onKeyDown={event => { + if (event.key === 'Enter') { + onSubmitCode() + } + }} + placeholder="Paste authorization code" + value={flow.submitCode || ''} + /> +
+ +
+ +
+ ) + } + + if (flow.status === 'polling' && flow.start?.flow === 'device_code' && flow.provider) { + return ( +
+
+

Sign in with {flow.provider.name}

+

+ We opened {flow.provider.name} in your browser. Enter this code there: +

+
+
+ {flow.start.user_code} + +
+
+ +
+ + Waiting for you to authorize... +
+ +
+
+ ) + } + + return ( +
+ + Working...
) } diff --git a/apps/desktop/src/components/pane-shell/pane-shell.tsx b/apps/desktop/src/components/pane-shell/pane-shell.tsx index 95ef6a116ab..6e6aab4127b 100644 --- a/apps/desktop/src/components/pane-shell/pane-shell.tsx +++ b/apps/desktop/src/components/pane-shell/pane-shell.tsx @@ -74,6 +74,7 @@ function widthToPx(value: WidthValue | undefined) { if (typeof value === 'number') { return Number.isFinite(value) ? value : undefined } + const match = value?.trim().match(/^(-?\d*\.?\d+)(px|rem)?$/) if (!match) { @@ -203,6 +204,7 @@ export function Pane({ if (registered.current) { return } + registered.current = true ensurePaneRegistered(id, { open: defaultOpen }) }, [defaultOpen, id]) @@ -221,6 +223,7 @@ export function Pane({ if (!canResize || paneWidth <= 0) { return } + event.preventDefault() const handle = event.currentTarget diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index 6f3784298cb..a1fb7463edf 100644 --- a/apps/desktop/src/hermes.ts +++ b/apps/desktop/src/hermes.ts @@ -16,6 +16,10 @@ import type { ModelAssignmentResponse, ModelInfoResponse, ModelOptionsResponse, + OAuthPollResponse, + OAuthProvidersResponse, + OAuthStartResponse, + OAuthSubmitResponse, PaginatedSessions, SessionMessagesResponse, SessionSearchResponse, @@ -215,6 +219,45 @@ export function revealEnvVar(key: string): Promise<{ key: string; value: string }) } +export function listOAuthProviders(): Promise { + return window.hermesDesktop.api({ + path: '/api/providers/oauth' + }) +} + +export function startOAuthLogin(providerId: string): Promise { + return window.hermesDesktop.api({ + path: `/api/providers/oauth/${encodeURIComponent(providerId)}/start`, + method: 'POST', + body: {} + }) +} + +export function submitOAuthCode( + providerId: string, + sessionId: string, + code: string +): Promise { + return window.hermesDesktop.api({ + path: `/api/providers/oauth/${encodeURIComponent(providerId)}/submit`, + method: 'POST', + body: { session_id: sessionId, code } + }) +} + +export function pollOAuthSession(providerId: string, sessionId: string): Promise { + return window.hermesDesktop.api({ + path: `/api/providers/oauth/${encodeURIComponent(providerId)}/poll/${encodeURIComponent(sessionId)}` + }) +} + +export function cancelOAuthSession(sessionId: string): Promise<{ ok: boolean }> { + return window.hermesDesktop.api<{ ok: boolean }>({ + path: `/api/providers/oauth/sessions/${encodeURIComponent(sessionId)}`, + method: 'DELETE' + }) +} + export function getSkills(): Promise { return window.hermesDesktop.api({ path: '/api/skills' diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index b61f4e91954..241ad040376 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -34,6 +34,59 @@ export interface ElevenLabsVoicesResponse { voices: ElevenLabsVoice[] } +export interface OAuthProviderStatus { + error?: string + expires_at?: null | string + has_refresh_token?: boolean + last_refresh?: null | string + logged_in: boolean + source?: null | string + source_label?: null | string + token_preview?: null | string +} + +export interface OAuthProvider { + cli_command: string + docs_url: string + flow: 'device_code' | 'external' | 'pkce' + id: string + name: string + status: OAuthProviderStatus +} + +export interface OAuthProvidersResponse { + providers: OAuthProvider[] +} + +export type OAuthStartResponse = + | { + auth_url: string + expires_in: number + flow: 'pkce' + session_id: string + } + | { + expires_in: number + flow: 'device_code' + poll_interval: number + session_id: string + user_code: string + verification_url: string + } + +export interface OAuthSubmitResponse { + message?: string + ok: boolean + status: 'approved' | 'error' +} + +export interface OAuthPollResponse { + error_message?: null | string + expires_at?: null | number + session_id: string + status: 'approved' | 'denied' | 'error' | 'expired' | 'pending' +} + export interface EnvVarInfo { advanced: boolean category: string