diff --git a/CHANGELOG.md b/CHANGELOG.md index 9950399..819ab8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,13 @@ version with its date and start a fresh empty `[Unreleased]` above it. ### Added +- In-app sign-in when the Qoder CLI is not authenticated: the model selector + now shows a sign-in panel that runs the CLI device flow inside Obsidian — + the authorization page opens once per attempt, with open-link, copy-link and + cancel controls, localized status and error messages in all ten locales, and + an automatic model-catalog refresh once sign-in succeeds — so signing in no + longer requires a terminal. + - Message timestamps, matching New Qoder: each user bubble shows when it was sent, and each assistant reply shows one time under the whole response next to the completion line. Both reveal on hover so idle conversations stay diff --git a/src/features/chat/tabs/tab.ts b/src/features/chat/tabs/tab.ts index d61ba1d..28c82cd 100644 --- a/src/features/chat/tabs/tab.ts +++ b/src/features/chat/tabs/tab.ts @@ -423,6 +423,7 @@ function initializeInputToolbar( retryRuntimeCatalog: async () => { await plugin.qoderServices.agentCatalog.refresh(); }, + loginService: plugin.qoderServices.loginService, onModelChange: async (model: string) => { // Blank tabs keep their model choice until the first message binds them. if (tab.lifecycleState === 'blank') { diff --git a/src/features/chat/ui/toolbar/toolbar-selectors.ts b/src/features/chat/ui/toolbar/toolbar-selectors.ts index 05adaf2..2a44675 100644 --- a/src/features/chat/ui/toolbar/toolbar-selectors.ts +++ b/src/features/chat/ui/toolbar/toolbar-selectors.ts @@ -7,6 +7,10 @@ import type { TranslationKey } from '../../../../i18n/types'; import { getActiveQoderCliEdition, getQoderCliLoginCommand } from '../../../../qoder/config/cli-edition'; import { getQoderModelOverride } from '../../../../qoder/config/settings'; import type { QoderModelConfig } from '../../../../qoder/models/qoder-model-config'; +import type { + QoderLoginController, + QoderLoginFailure, +} from '../../../../qoder/services/qoder-login-service'; import { CHECK_ICON, CHEVRON_LEFT_ICON, @@ -43,6 +47,7 @@ export interface ToolbarCallbacks { getRuntimeStatus?: () => QoderRuntimeStatus; retryRuntimeCatalog?: () => Promise; subscribeRuntimeStatus?: (listener: (status: QoderRuntimeStatus) => void) => () => void; + loginService?: QoderLoginController; } const DEFAULT_RUNTIME_STATUS: QoderRuntimeStatus = { @@ -68,6 +73,15 @@ function canUseCachedModels(status: QoderRuntimeStatus): boolean { return status.kind === 'checking' || status.kind === 'offline' || status.kind === 'failed'; } +function getSignInFailureMessage(failure: QoderLoginFailure): string { + switch (failure.kind) { + case 'cliMissing': return t('chat.signIn.errorCliMissing'); + case 'nodeMissing': return t('chat.signIn.errorNodeMissing'); + case 'spawn': return t('chat.signIn.errorStartFailed'); + case 'process': return t('chat.signIn.errorProcessFailed'); + } +} + export class ModelSelector { private readonly container: HTMLElement; private buttonEl: HTMLElement | null = null; @@ -77,6 +91,7 @@ export class ModelSelector { private popover: ClickPopover | null = null; private editingModel: string | null = null; private unsubscribeRuntimeStatus: (() => void) | null = null; + private unsubscribeLoginState: (() => void) | null = null; constructor(parentEl: HTMLElement, private readonly callbacks: ToolbarCallbacks) { this.container = parentEl.createDiv({ cls: 'qoderian-model-selector' }); @@ -85,6 +100,9 @@ export class ModelSelector { this.updateDisplay(); this.renderOptions(); }) ?? null; + this.unsubscribeLoginState = callbacks.loginService?.subscribe(() => { + this.renderOptions(); + }) ?? null; } destroy(): void { @@ -92,6 +110,8 @@ export class ModelSelector { this.popover = null; this.unsubscribeRuntimeStatus?.(); this.unsubscribeRuntimeStatus = null; + this.unsubscribeLoginState?.(); + this.unsubscribeLoginState = null; } private getAvailableModels() { @@ -177,15 +197,19 @@ export class ModelSelector { statusEl.createDiv({ cls: 'qoderian-model-runtime-title', text: getRuntimeStatusLabel(status) }); statusEl.createDiv({ cls: 'qoderian-model-runtime-message', text: status.message }); if (status.kind === 'authRequired') { - statusEl.createEl('code', { - cls: 'qoderian-model-runtime-command', - text: getQoderCliLoginCommand(getActiveQoderCliEdition()), - }); + this.renderSignInFlow(statusEl); } if (status.details) { statusEl.setAttribute('title', status.details); } - if (this.callbacks.retryRuntimeCatalog) { + const loginRunning = status.kind === 'authRequired' + && (this.callbacks.loginService?.isRunning() ?? false); + // When the in-app sign-in flow is available it owns authRequired + // recovery (a successful sign-in refreshes the catalog), so the generic + // Retry button would only add a redundant, misaligned second action. + const signInOwnsAuth = status.kind === 'authRequired' + && this.callbacks.loginService !== undefined; + if (this.callbacks.retryRuntimeCatalog && !loginRunning && !signInOwnsAuth) { const retryButton = statusEl.createEl('button', { cls: 'qoderian-model-runtime-retry', text: status.kind === 'checking' ? 'Checking…' : 'Retry', @@ -295,6 +319,94 @@ export class ModelSelector { this.updateDropdownPlacement(); } + private renderSignInFlow(statusEl: HTMLElement): void { + const loginService = this.callbacks.loginService; + if (!loginService) { + statusEl.createEl('code', { + cls: 'qoderian-model-runtime-command', + text: getQoderCliLoginCommand(getActiveQoderCliEdition()), + }); + return; + } + + const state = loginService.getState(); + + if (state.phase === 'waiting') { + statusEl.createDiv({ + cls: 'qoderian-signin-waiting', + text: t('chat.signIn.waiting'), + }); + } else if (state.phase === 'succeeded') { + statusEl.createDiv({ + cls: 'qoderian-signin-waiting', + text: t('chat.signIn.verifying'), + }); + return; + } else if (state.phase === 'failed' && state.failure) { + const errorEl = statusEl.createDiv({ + cls: 'qoderian-signin-error', + text: getSignInFailureMessage(state.failure), + }); + if (state.failure.details) { + errorEl.setAttribute('title', state.failure.details); + } + } + + const actionsEl = statusEl.createDiv({ cls: 'qoderian-signin-actions' }); + + if (state.phase === 'waiting') { + if (state.authUrl) { + const openButton = actionsEl.createEl('button', { + cls: 'qoderian-signin-open mod-cta', + text: t('chat.signIn.openLink'), + }); + openButton.addEventListener('click', (event) => { + event.stopPropagation(); + loginService.openAuthUrl(); + }); + const copyButton = actionsEl.createEl('button', { + cls: 'qoderian-signin-copy', + text: t('chat.signIn.copyLink'), + }); + copyButton.addEventListener('click', (event) => { + event.stopPropagation(); + void this.copyAuthUrl(state.authUrl as string); + }); + } + const cancelButton = actionsEl.createEl('button', { + cls: 'qoderian-signin-cancel', + text: t('common.cancel'), + }); + cancelButton.addEventListener('click', (event) => { + event.stopPropagation(); + loginService.cancel(); + }); + return; + } + + const signInButton = actionsEl.createEl('button', { + cls: 'qoderian-signin-button mod-cta', + text: state.phase === 'failed' ? t('chat.signIn.retry') : t('chat.signIn.button'), + }); + if (state.phase === 'starting') { + signInButton.setAttribute('disabled', 'true'); + signInButton.setText(t('chat.signIn.starting')); + } + signInButton.addEventListener('click', (event) => { + event.stopPropagation(); + loginService.start(); + }); + } + + private async copyAuthUrl(authUrl: string): Promise { + try { + await navigator.clipboard.writeText(authUrl); + new Notice(t('chat.signIn.copied')); + } catch { + new Notice(t('chat.signIn.copyFailed')); + } + } + /** * Places the dropdown against the real viewport: stay flush with the * trigger (inline-start) and flip only when the panel would overflow, diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 369db3e..54ab8f8 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -90,6 +90,21 @@ "running": "Wird ausgeführt...", "copyFailed": "Kopieren in die Zwischenablage fehlgeschlagen" }, + "signIn": { + "button": "Anmelden", + "retry": "Erneut anmelden", + "starting": "Anmeldung wird gestartet…", + "waiting": "Warten auf Browser-Autorisierung…", + "verifying": "Angemeldet. Wird überprüft…", + "openLink": "Link öffnen", + "copyLink": "Link kopieren", + "copied": "Link kopiert", + "copyFailed": "Link konnte nicht kopiert werden", + "errorCliMissing": "Qoder CLI wurde nicht gefunden. Installieren Sie es oder legen Sie den Pfad in den Einstellungen fest.", + "errorNodeMissing": "Qoder CLI erfordert Node.js, aber Node.js wurde nicht gefunden.", + "errorStartFailed": "Der Anmeldevorgang konnte nicht gestartet werden.", + "errorProcessFailed": "Anmeldung fehlgeschlagen. Überprüfen Sie die Details und versuchen Sie es erneut." + }, "view": { "openQoderian": "Qoderian öffnen", "moveBlockedStreaming": "Warte, bis die aktuelle Antwort abgeschlossen ist, bevor du die Qoderian-Ansicht verschiebst.", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 35f2e0a..7380d97 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -90,6 +90,21 @@ "running": "Running...", "copyFailed": "Failed to copy to clipboard" }, + "signIn": { + "button": "Sign in", + "retry": "Sign in again", + "starting": "Starting sign-in…", + "waiting": "Waiting for browser authorization…", + "verifying": "Signed in. Checking status…", + "openLink": "Open link", + "copyLink": "Copy link", + "copied": "Link copied", + "copyFailed": "Could not copy the link", + "errorCliMissing": "Qoder CLI was not found. Install it or set its path in settings.", + "errorNodeMissing": "Qoder CLI requires Node.js, but Node.js was not found.", + "errorStartFailed": "Could not start the sign-in process.", + "errorProcessFailed": "Sign-in failed. Check the details and try again." + }, "view": { "openQoderian": "Open Qoderian", "moveBlockedStreaming": "Wait for the current response to finish before moving the Qoderian view.", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index d504903..36d21b4 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -90,6 +90,21 @@ "running": "Ejecutando...", "copyFailed": "No se pudo copiar al portapapeles" }, + "signIn": { + "button": "Iniciar sesión", + "retry": "Iniciar sesión de nuevo", + "starting": "Iniciando sesión…", + "waiting": "Esperando autorización del navegador…", + "verifying": "Sesión iniciada. Verificando…", + "openLink": "Abrir enlace", + "copyLink": "Copiar enlace", + "copied": "Enlace copiado", + "copyFailed": "No se pudo copiar el enlace", + "errorCliMissing": "No se encontró Qoder CLI. Instálalo o configura su ruta en los ajustes.", + "errorNodeMissing": "Qoder CLI requiere Node.js, pero no se encontró.", + "errorStartFailed": "No se pudo iniciar el proceso de inicio de sesión.", + "errorProcessFailed": "Error al iniciar sesión. Revisa los detalles e inténtalo de nuevo." + }, "view": { "openQoderian": "Abrir Qoderian", "moveBlockedStreaming": "Espera a que termine la respuesta actual antes de mover la vista de Qoderian.", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index c7c2e28..51c72c1 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -90,6 +90,21 @@ "running": "Exécution...", "copyFailed": "Échec de la copie dans le presse-papiers" }, + "signIn": { + "button": "Se connecter", + "retry": "Se connecter à nouveau", + "starting": "Démarrage de la connexion…", + "waiting": "En attente d'autorisation du navigateur…", + "verifying": "Connecté. Vérification…", + "openLink": "Ouvrir le lien", + "copyLink": "Copier le lien", + "copied": "Lien copié", + "copyFailed": "Impossible de copier le lien", + "errorCliMissing": "Qoder CLI introuvable. Installez-le ou configurez son chemin dans les paramètres.", + "errorNodeMissing": "Qoder CLI nécessite Node.js, mais Node.js est introuvable.", + "errorStartFailed": "Impossible de démarrer le processus de connexion.", + "errorProcessFailed": "Échec de la connexion. Vérifiez les détails et réessayez." + }, "view": { "openQoderian": "Ouvrir Qoderian", "moveBlockedStreaming": "Attendez la fin de la réponse actuelle avant de déplacer la vue Qoderian.", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 490afee..8b1db00 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -90,6 +90,21 @@ "running": "実行中...", "copyFailed": "クリップボードへのコピーに失敗しました" }, + "signIn": { + "button": "サインイン", + "retry": "もう一度サインイン", + "starting": "サインインを開始しています…", + "waiting": "ブラウザでの認証を待っています…", + "verifying": "サインインしました。確認中…", + "openLink": "リンクを開く", + "copyLink": "リンクをコピー", + "copied": "リンクをコピーしました", + "copyFailed": "リンクをコピーできませんでした", + "errorCliMissing": "Qoder CLI が見つかりません。インストールするか、設定でパスを指定してください。", + "errorNodeMissing": "Qoder CLI には Node.js が必要ですが、見つかりませんでした。", + "errorStartFailed": "サインイン プロセスを開始できませんでした。", + "errorProcessFailed": "サインインに失敗しました。詳細を確認して再試行してください。" + }, "view": { "openQoderian": "Qoderian を開く", "moveBlockedStreaming": "現在の応答が完了するまで Qoderian ビューを移動しないでください。", diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index 04153d7..c8a12da 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -90,6 +90,21 @@ "running": "실행 중...", "copyFailed": "클립보드에 복사하지 못했습니다" }, + "signIn": { + "button": "로그인", + "retry": "다시 로그인", + "starting": "로그인 시작 중…", + "waiting": "브라우저 승인 대기 중…", + "verifying": "로그인됨. 확인 중…", + "openLink": "링크 열기", + "copyLink": "링크 복사", + "copied": "링크가 복사되었습니다", + "copyFailed": "링크를 복사할 수 없습니다", + "errorCliMissing": "Qoder CLI를 찾을 수 없습니다. 설치하거나 설정에서 경로를 지정하세요.", + "errorNodeMissing": "Qoder CLI에는 Node.js가 필요하지만 찾을 수 없습니다.", + "errorStartFailed": "로그인 프로세스를 시작할 수 없습니다.", + "errorProcessFailed": "로그인에 실패했습니다. 세부 정보를 확인하고 다시 시도하세요." + }, "view": { "openQoderian": "Qoderian 열기", "moveBlockedStreaming": "현재 응답이 완료될 때까지 기다린 후 Qoderian 뷰를 이동하세요.", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 0d3e4c8..5d50996 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -90,6 +90,21 @@ "running": "Executando...", "copyFailed": "Falha ao copiar para a área de transferência" }, + "signIn": { + "button": "Entrar", + "retry": "Entrar novamente", + "starting": "Iniciando login…", + "waiting": "Aguardando autorização do navegador…", + "verifying": "Conectado. Verificando…", + "openLink": "Abrir link", + "copyLink": "Copiar link", + "copied": "Link copiado", + "copyFailed": "Não foi possível copiar o link", + "errorCliMissing": "Qoder CLI não encontrado. Instale-o ou configure o caminho nas configurações.", + "errorNodeMissing": "Qoder CLI requer Node.js, mas o Node.js não foi encontrado.", + "errorStartFailed": "Não foi possível iniciar o processo de login.", + "errorProcessFailed": "Falha no login. Verifique os detalhes e tente novamente." + }, "view": { "openQoderian": "Abrir Qoderian", "moveBlockedStreaming": "Aguarde a resposta atual terminar antes de mover a visualização do Qoderian.", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 2e2afde..e636969 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -90,6 +90,21 @@ "running": "Выполняется...", "copyFailed": "Не удалось скопировать в буфер обмена" }, + "signIn": { + "button": "Войти", + "retry": "Войти снова", + "starting": "Запуск входа…", + "waiting": "Ожидание авторизации в браузере…", + "verifying": "Вход выполнен. Проверка…", + "openLink": "Открыть ссылку", + "copyLink": "Копировать ссылку", + "copied": "Ссылка скопирована", + "copyFailed": "Не удалось скопировать ссылку", + "errorCliMissing": "Qoder CLI не найден. Установите его или укажите путь в настройках.", + "errorNodeMissing": "Qoder CLI требует Node.js, но Node.js не найден.", + "errorStartFailed": "Не удалось запустить процесс входа.", + "errorProcessFailed": "Ошибка входа. Проверьте детали и повторите попытку." + }, "view": { "openQoderian": "Открыть Qoderian", "moveBlockedStreaming": "Дождитесь завершения текущего ответа, прежде чем перемещать представление Qoderian.", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 4f406aa..079e432 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -90,6 +90,21 @@ "running": "运行中...", "copyFailed": "复制到剪贴板失败" }, + "signIn": { + "button": "登录", + "retry": "重新登录", + "starting": "正在启动登录…", + "waiting": "等待浏览器授权…", + "verifying": "登录成功,正在校验…", + "openLink": "打开链接", + "copyLink": "复制链接", + "copied": "链接已复制", + "copyFailed": "无法复制链接", + "errorCliMissing": "未找到 Qoder CLI,请安装或在设置中配置路径。", + "errorNodeMissing": "Qoder CLI 需要 Node.js,但未找到。", + "errorStartFailed": "无法启动登录流程。", + "errorProcessFailed": "登录失败,请查看详情后重试。" + }, "view": { "openQoderian": "打开 Qoderian", "moveBlockedStreaming": "请等待当前响应完成后再移动 Qoderian 视图。", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 5297a1a..2c246dc 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -90,6 +90,21 @@ "running": "執行中...", "copyFailed": "複製到剪貼簿失敗" }, + "signIn": { + "button": "登入", + "retry": "重新登入", + "starting": "正在啟動登入…", + "waiting": "等待瀏覽器授權…", + "verifying": "登入成功,正在驗證…", + "openLink": "開啟連結", + "copyLink": "複製連結", + "copied": "連結已複製", + "copyFailed": "無法複製連結", + "errorCliMissing": "未找到 Qoder CLI,請安裝或在設定中設定路徑。", + "errorNodeMissing": "Qoder CLI 需要 Node.js,但未找到。", + "errorStartFailed": "無法啟動登入流程。", + "errorProcessFailed": "登入失敗,請查看詳情後重試。" + }, "view": { "openQoderian": "開啟 Qoderian", "moveBlockedStreaming": "請等待目前回應完成後再移動 Qoderian 視圖。", diff --git a/src/i18n/types.ts b/src/i18n/types.ts index 98d9349..54df1b8 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -69,6 +69,21 @@ export type TranslationKey = | 'chat.bangBash.running' | 'chat.bangBash.copyFailed' + // Chat - In-app sign-in + | 'chat.signIn.button' + | 'chat.signIn.retry' + | 'chat.signIn.starting' + | 'chat.signIn.waiting' + | 'chat.signIn.verifying' + | 'chat.signIn.openLink' + | 'chat.signIn.copyLink' + | 'chat.signIn.copied' + | 'chat.signIn.copyFailed' + | 'chat.signIn.errorCliMissing' + | 'chat.signIn.errorNodeMissing' + | 'chat.signIn.errorStartFailed' + | 'chat.signIn.errorProcessFailed' + // Chat - View | 'chat.view.openQoderian' | 'chat.view.moveBlockedStreaming' diff --git a/src/qoder/commands/probe-runtime-commands.ts b/src/qoder/commands/probe-runtime-commands.ts index 570a92e..120c8ce 100644 --- a/src/qoder/commands/probe-runtime-commands.ts +++ b/src/qoder/commands/probe-runtime-commands.ts @@ -8,7 +8,6 @@ import type { QoderRuntimeStatus } from '../../core/types/services'; import { getActiveQoderCliEdition, getQoderCliBinaryBaseName, - getQoderCliLoginCommand, } from '../config/cli-edition'; import { getQoderSettings, @@ -20,6 +19,8 @@ import { sortThinkingEfforts } from '../models/model-catalog'; import type { QoderHostContext } from '../qoder-host-context'; import { createCustomSpawnFunction } from '../runtime/custom-spawn'; +const STDERR_TAIL_LIMIT = 4_000; + function mapSdkCommands(sdkCommands: SDKSlashCommand[]): SlashCommand[] { return sdkCommands.map((cmd) => ({ id: `sdk:${cmd.name}`, @@ -221,8 +222,16 @@ function getErrorDetails(error: unknown): string { } } -export function classifyQoderProbeError(error: unknown, timedOut = false): QoderRuntimeStatus { - const details = getErrorDetails(error).trim() || 'Unknown Qoder CLI initialization error'; +export function classifyQoderProbeError( + error: unknown, + timedOut = false, + stderrTail?: string, +): QoderRuntimeStatus { + const rawDetails = getErrorDetails(error).trim() || 'Unknown Qoder CLI initialization error'; + const tail = stderrTail?.trim(); + const details = tail && !rawDetails.includes(tail) + ? `${rawDetails}\n${tail}` + : rawDetails; if (timedOut || /abort(?:ed|error)|timed?\s*out/i.test(details)) { return { @@ -231,10 +240,10 @@ export function classifyQoderProbeError(error: unknown, timedOut = false): Qoder details, }; } - if (/not logged in|login required|please (?:log|sign) in|please run \/?login|sign[- ]?in required|unauthori[sz]ed|authentication required|invalid (?:api key|credential|token)|expired.*(?:credential|token)|credentials? (?:not found|missing)|\b401\b/i.test(details)) { + if (/not logged in|login required|please (?:log|sign) in|please run \/?login|sign[- ]?in required|unauthori[sz]ed|authentication required|invalid (?:api key|credential|token)|expired.*(?:credential|token)|credentials? (?:not found|missing)|no qodercli\w* login found|login not found|run "?qodercli\w* login"?|\b401\b/i.test(details)) { return { kind: 'authRequired', - message: `Qoder CLI is not signed in. Run \`${getQoderCliLoginCommand(getActiveQoderCliEdition())}\` in a terminal, then retry.`, + message: 'Qoder CLI is not signed in. Sign in to your Qoder account, then retry.', details, }; } @@ -311,9 +320,56 @@ export async function probeRuntimeCatalog( timedOut = true; abortController.abort(); }, options?.timeoutMs ?? 20_000); + const probeOptions = buildProbeOptions(plugin, vaultPath, cliPath, abortController); + // When the CLI fails before the SDK handshake (e.g. not logged in, exit 41), + // the SDK only reports "Transport closed". The real reason is written to + // stdout in the result message's `errors` array, plus diagnostics on stderr. + // Tap both streams so classification can recognize auth/setup wordings. + let diagnosticsBuffer = ''; + const appendDiagnostic = (text: string): void => { + if (!text.trim() || diagnosticsBuffer.includes(text.trim())) return; + diagnosticsBuffer += `${diagnosticsBuffer ? '\n' : ''}${text.trim()}`; + if (diagnosticsBuffer.length > STDERR_TAIL_LIMIT) { + diagnosticsBuffer = diagnosticsBuffer.slice(-STDERR_TAIL_LIMIT); + } + }; + const baseSpawn = probeOptions.spawnQoderCLIProcess; + if (baseSpawn) { + probeOptions.spawnQoderCLIProcess = (spawnOptions) => { + const child = baseSpawn(spawnOptions); + const streams = child as { + stdout?: NodeJS.EventEmitter; + stderr?: NodeJS.EventEmitter; + }; + let stdoutRemainder = ''; + streams.stdout?.on('data', (chunk: Buffer) => { + stdoutRemainder += chunk.toString('utf8'); + const lines = stdoutRemainder.split('\n'); + stdoutRemainder = lines.pop() ?? ''; + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed.startsWith('{')) continue; + try { + const message = JSON.parse(trimmed) as { errors?: unknown }; + if (Array.isArray(message.errors)) { + for (const entry of message.errors) { + if (typeof entry === 'string') appendDiagnostic(entry); + } + } + } catch { + // Non-JSON protocol lines are ignored. + } + } + }); + streams.stderr?.on('data', (chunk: Buffer) => { + appendDiagnostic(chunk.toString('utf8')); + }); + return child; + }; + } const conversation = agentQuery({ prompt: input, - options: buildProbeOptions(plugin, vaultPath, cliPath, abortController), + options: probeOptions, }); try { const initialization = await conversation.initializationResult(); @@ -350,7 +406,7 @@ export async function probeRuntimeCatalog( return { commands, agents, models, ...(usageInfo ? { usageInfo } : {}) }; } catch (error) { - return { error: classifyQoderProbeError(error, timedOut) }; + return { error: classifyQoderProbeError(error, timedOut, diagnosticsBuffer) }; } finally { window.clearTimeout(timeout); input.end(); diff --git a/src/qoder/qoder-services.ts b/src/qoder/qoder-services.ts index 9f5d4ab..1bd6c1a 100644 --- a/src/qoder/qoder-services.ts +++ b/src/qoder/qoder-services.ts @@ -24,6 +24,7 @@ import { QoderCliResolver } from './runtime/qoder-cli-resolver'; import { QoderTaskResultInterpreter } from './runtime/qoder-task-result-interpreter'; import { QoderInlineEditService } from './services/qoder-inline-edit-service'; import { QoderInstructionRefineService } from './services/qoder-instruction-refine-service'; +import { QoderLoginService } from './services/qoder-login-service'; import { QoderTitleGenerationService } from './services/qoder-title-generation-service'; import { QoderStorage } from './storage/qoder-storage'; @@ -45,6 +46,7 @@ export interface QoderServices { modelConfig: typeof qoderModelConfig; historyService: QoderConversationHistoryService; taskResultInterpreter: QoderTaskResultInterpreter; + loginService: QoderLoginService; dispose(): void; createRuntime(): ChatRuntime; createTitleGenerationService(): QoderTitleGenerationService; @@ -109,6 +111,15 @@ export async function createQoderServices( const historyService = new QoderConversationHistoryService(); const taskResultInterpreter = new QoderTaskResultInterpreter(); + const loginService = new QoderLoginService(plugin, () => { + if (disposed) return; + // Always return to idle once the post-login refresh settles; keeping + // 'succeeded' would render a stale "checking status" panel if the runtime + // later becomes authRequired again in this session. + void agentCatalog.refresh().then(() => { + if (!disposed) loginService.reset(); + }); + }); return { qoderStorage, @@ -122,8 +133,10 @@ export async function createQoderServices( modelConfig: qoderModelConfig, historyService, taskResultInterpreter, + loginService, dispose: () => { disposed = true; + loginService.dispose(); if (startupRetryTimer !== null) { window.clearTimeout(startupRetryTimer); startupRetryTimer = null; diff --git a/src/qoder/runtime/custom-spawn.ts b/src/qoder/runtime/custom-spawn.ts index 854f83d..b5f4213 100644 --- a/src/qoder/runtime/custom-spawn.ts +++ b/src/qoder/runtime/custom-spawn.ts @@ -37,7 +37,10 @@ export function createCustomSpawnFunction( const child = spawn(resolvedSpawnSpec.command, resolvedSpawnSpec.args, { cwd, env, - stdio: ['pipe', 'pipe', shouldPipeStderr ? 'pipe' : 'ignore'], + // stderr is always piped so host code (e.g. the runtime probe) can read + // CLI diagnostics such as "No qodercli login found". A drain listener is + // attached below to avoid pipe backpressure when nobody else listens. + stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true, ...(resolvedSpawnSpec.windowsVerbatimArguments ? { windowsVerbatimArguments: true } @@ -56,8 +59,12 @@ export function createCustomSpawnFunction( } } - if (shouldPipeStderr && child.stderr && typeof child.stderr.on === 'function') { - child.stderr.on('data', () => {}); + if (child.stderr && typeof child.stderr.on === 'function') { + child.stderr.on('data', (chunk: Buffer) => { + if (shouldPipeStderr) { + console.error(chunk.toString()); + } + }); } if (!child.stdin || !child.stdout) { diff --git a/src/qoder/services/qoder-login-service.ts b/src/qoder/services/qoder-login-service.ts new file mode 100644 index 0000000..3e0de72 --- /dev/null +++ b/src/qoder/services/qoder-login-service.ts @@ -0,0 +1,252 @@ +import type { ChildProcess } from 'child_process'; +import { spawn } from 'child_process'; + +import { + cliPathRequiresNode, + findNodeExecutable, + getEnhancedPath, + getMissingNodeError, +} from '../../core/env/environment'; +import { getVaultPath } from '../../core/fs/path'; +import type { QoderHostContext } from '../qoder-host-context'; +import { + resolveWindowsCmdShimSpawnSpec, + terminateSpawnedProcess, + type WindowsCmdShimSpawnSpec, +} from '../runtime/windows-cmd-shim'; + +export type QoderLoginPhase = 'idle' | 'starting' | 'waiting' | 'succeeded' | 'failed'; + +export type QoderLoginFailureKind = 'cliMissing' | 'nodeMissing' | 'spawn' | 'process'; + +export interface QoderLoginFailure { + kind: QoderLoginFailureKind; + details?: string; +} + +export interface QoderLoginState { + phase: QoderLoginPhase; + authUrl: string | null; + failure: QoderLoginFailure | null; +} + +type QoderLoginStateListener = (state: QoderLoginState) => void; + +/** Public surface consumed by the UI; keeps callers off the concrete class. */ +export interface QoderLoginController { + getState(): QoderLoginState; + subscribe(listener: QoderLoginStateListener): () => void; + isRunning(): boolean; + start(): boolean; + cancel(): void; + openAuthUrl(): void; + reset(): void; +} + +// eslint-disable-next-line no-control-regex +const ANSI_ESCAPE_PATTERN = /\u001b\[[0-9;]*[A-Za-z]/g; +// eslint-disable-next-line no-control-regex +const AUTH_URL_PATTERN = /https?:\/\/[^\s\u001b]+/; +const OUTPUT_TAIL_LIMIT = 2_000; +const INITIAL_STATE: QoderLoginState = { phase: 'idle', authUrl: null, failure: null }; + +/** Opens an external URL through Obsidian's standard external-link handling. */ +export function openExternalBrowserUrl(url: string): void { + const anchor = createEl('a', { + attr: { href: url, target: '_blank', rel: 'noopener' }, + }); + anchor.click(); + anchor.remove(); +} + +/** + * Drives `qodercli login` from inside Obsidian. + * + * The CLI runs a device-flow login when spawned without a TTY: it prints an + * authorization URL and polls until the user completes sign-in in their + * browser. This service owns that child process and exposes the flow state to + * the UI; credentials are written exclusively by the CLI itself. + */ +export class QoderLoginService implements QoderLoginController { + private state: QoderLoginState = INITIAL_STATE; + private readonly listeners = new Set(); + private child: ChildProcess | null = null; + private spawnSpec: WindowsCmdShimSpawnSpec | null = null; + private authUrl: string | null = null; + private canceled = false; + + constructor( + private readonly plugin: QoderHostContext, + private readonly onSucceeded?: () => void, + ) {} + + getState(): QoderLoginState { + return this.state; + } + + subscribe(listener: QoderLoginStateListener): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + isRunning(): boolean { + return this.state.phase === 'starting' || this.state.phase === 'waiting'; + } + + /** Starts a login attempt; returns false when one is already in flight. */ + start(): boolean { + if (this.child) return false; + + const cliPath = this.plugin.getResolvedQoderCliPath(); + if (!cliPath) { + this.setState({ phase: 'failed', authUrl: null, failure: { kind: 'cliMissing' } }); + return false; + } + const enhancedPath = getEnhancedPath(undefined, cliPath); + const missingNodeError = getMissingNodeError(cliPath, enhancedPath); + if (missingNodeError) { + this.setState({ + phase: 'failed', + authUrl: null, + failure: { kind: 'nodeMissing', details: missingNodeError }, + }); + return false; + } + + let command = cliPath; + let args: string[] = ['login']; + if (cliPathRequiresNode(cliPath)) { + const nodePath = findNodeExecutable(enhancedPath); + args = [cliPath, 'login']; + command = nodePath ?? 'node'; + } + + this.canceled = false; + this.authUrl = null; + this.setState({ phase: 'starting', authUrl: null, failure: null }); + + let child: ChildProcess; + try { + const spawnSpec = resolveWindowsCmdShimSpawnSpec({ command, args }); + child = spawn(spawnSpec.command, spawnSpec.args, { + cwd: getVaultPath(this.plugin.app) || undefined, + // BROWSER=www-browser is the CLI's headless marker: it skips its own browser launch, leaving the plugin as the single opener. + env: { ...process.env, BROWSER: 'www-browser', PATH: enhancedPath }, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + ...(spawnSpec.windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}), + }); + this.spawnSpec = spawnSpec; + } catch (error) { + this.setState({ + phase: 'failed', + authUrl: null, + failure: { kind: 'spawn', details: getErrorDetails(error) }, + }); + return false; + } + + this.child = child; + + let stdoutBuffer = ''; + let stderrBuffer = ''; + child.stdout?.on('data', (chunk: Buffer) => { + stdoutBuffer = appendChunk(stdoutBuffer, chunk); + if (this.authUrl) return; + const match = stripAnsi(stdoutBuffer).match(AUTH_URL_PATTERN); + if (!match) return; + this.authUrl = match[0]; + // One-shot auto-open per attempt; the CLI's launch is disabled via BROWSER and the waiting button re-opens manually. + openExternalBrowserUrl(this.authUrl); + this.setState({ phase: 'waiting', authUrl: this.authUrl, failure: null }); + }); + child.stderr?.on('data', (chunk: Buffer) => { + stderrBuffer = appendChunk(stderrBuffer, chunk); + }); + child.on('error', (error) => { + this.cleanup(); + this.setState({ + phase: 'failed', + authUrl: this.authUrl, + failure: { kind: 'spawn', details: getErrorDetails(error) }, + }); + }); + child.on('exit', (code) => { + this.cleanup(); + if (this.canceled) { + this.setState(INITIAL_STATE); + return; + } + if (code === 0) { + this.setState({ phase: 'succeeded', authUrl: null, failure: null }); + this.onSucceeded?.(); + return; + } + const details = stderrBuffer.trim() || stdoutBuffer.trim() + || `qodercli login exited with code ${code ?? 'null'}`; + this.setState({ + phase: 'failed', + authUrl: this.authUrl, + failure: { kind: 'process', details: details.slice(-OUTPUT_TAIL_LIMIT) }, + }); + }); + + return true; + } + + cancel(): void { + if (!this.child) return; + this.canceled = true; + const killable = { + pid: this.child.pid, + kill: this.child.kill.bind(this.child), + }; + terminateSpawnedProcess(killable, 'SIGTERM', spawn, this.spawnSpec); + } + + /** Returns to idle after an attempt finished; lets the UI offer sign-in again. */ + reset(): void { + if (this.child) return; + if (this.state.phase !== 'idle') { + this.setState(INITIAL_STATE); + } + } + + openAuthUrl(): void { + if (this.authUrl) { + openExternalBrowserUrl(this.authUrl); + } + } + + dispose(): void { + this.cancel(); + this.listeners.clear(); + } + + private cleanup(): void { + this.child = null; + this.spawnSpec = null; + } + + private setState(state: QoderLoginState): void { + this.state = state; + for (const listener of [...this.listeners]) { + listener(state); + } + } +} + +function appendChunk(buffer: string, chunk: Buffer): string { + const next = buffer + chunk.toString('utf8'); + return next.length > OUTPUT_TAIL_LIMIT ? next.slice(-OUTPUT_TAIL_LIMIT) : next; +} + +function stripAnsi(text: string): string { + return text.replace(ANSI_ESCAPE_PATTERN, ''); +} + +function getErrorDetails(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/style/toolbar/model-selector.css b/src/style/toolbar/model-selector.css index 99b9eb8..432dfe5 100644 --- a/src/style/toolbar/model-selector.css +++ b/src/style/toolbar/model-selector.css @@ -153,6 +153,38 @@ font-size: 11px; } +.qoderian-signin-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + width: 100%; +} + +.qoderian-signin-button { + padding: 3px 12px; + font-size: 11px; +} + +.qoderian-signin-waiting { + font-size: 11px; + line-height: 1.4; + color: var(--text-normal); +} + +.qoderian-signin-open, +.qoderian-signin-copy, +.qoderian-signin-cancel { + padding: 3px 9px; + font-size: 11px; +} + +.qoderian-signin-error { + font-size: 11px; + line-height: 1.4; + color: var(--text-error); +} + .qoderian-model-selector--open .qoderian-model-dropdown { opacity: 1; visibility: visible; diff --git a/tests/unit/features/chat/ui/input-toolbar.model-selector.test.ts b/tests/unit/features/chat/ui/input-toolbar.model-selector.test.ts index d7d0ad8..ee0c4f0 100644 --- a/tests/unit/features/chat/ui/input-toolbar.model-selector.test.ts +++ b/tests/unit/features/chat/ui/input-toolbar.model-selector.test.ts @@ -137,6 +137,111 @@ describe('ModelSelector', () => { expect(retryRuntimeCatalog).toHaveBeenCalledTimes(1); }); + describe('in-app sign-in flow', () => { + function buildAuthCallbacks(loginState: { + phase: string; + authUrl: string | null; + failure: { kind: string; details?: string } | null; + }) { + const loginService = { + getState: jest.fn().mockReturnValue(loginState), + subscribe: jest.fn(() => () => {}), + isRunning: jest.fn().mockReturnValue(loginState.phase === 'starting' || loginState.phase === 'waiting'), + start: jest.fn(), + cancel: jest.fn(), + openAuthUrl: jest.fn(), + reset: jest.fn(), + }; + const callbacks = { + onModelChange: jest.fn().mockResolvedValue(undefined), + onPermissionModeChange: jest.fn().mockResolvedValue(undefined), + getSettings: jest.fn().mockReturnValue({ model: 'auto', permissionMode: 'auto' }), + getModelConfig: jest.fn().mockReturnValue({ + getModelOptions: jest.fn().mockReturnValue([]), + }), + getRuntimeStatus: jest.fn().mockReturnValue({ + kind: 'authRequired', + message: 'Qoder CLI is not signed in. Sign in to your Qoder account, then retry.', + }), + retryRuntimeCatalog: jest.fn().mockResolvedValue(undefined), + loginService, + }; + return { callbacks, loginService }; + } + + it('renders a Sign in button that starts the login service', () => { + const parentEl = createMockEl(); + const { callbacks, loginService } = buildAuthCallbacks({ + phase: 'idle', authUrl: null, failure: null, + }); + + new ModelSelector(parentEl, callbacks); + + expect(parentEl.querySelector('.qoderian-model-runtime-command')).toBeNull(); + const signInButton = parentEl.querySelector('.qoderian-signin-button'); + expect(signInButton?.textContent).toBe('Sign in'); + + signInButton?.click(); + expect(loginService.start).toHaveBeenCalledTimes(1); + // The sign-in flow owns auth recovery; no redundant Retry button. + expect(parentEl.querySelector('.qoderian-model-runtime-retry')).toBeNull(); + }); + + it('shows the auth link with copy and cancel actions while waiting', () => { + const parentEl = createMockEl(); + const { callbacks, loginService } = buildAuthCallbacks({ + phase: 'waiting', + authUrl: 'https://qoder.com/device/selectAccounts?challenge=abc', + failure: null, + }); + + new ModelSelector(parentEl, callbacks); + + const openButton = parentEl.querySelector('.qoderian-signin-open'); + expect(openButton).toBeTruthy(); + expect(parentEl.querySelector('.qoderian-signin-waiting')?.textContent) + .toBe('Waiting for browser authorization…'); + // Descriptive text renders above the action row. + const statusKids = (parentEl.querySelector('.qoderian-model-runtime-status') as any) + ._children as Array<{ hasClass: (cls: string) => boolean }>; + const waitingIdx = statusKids.findIndex(kid => kid.hasClass('qoderian-signin-waiting')); + const actionsIdx = statusKids.findIndex(kid => kid.hasClass('qoderian-signin-actions')); + expect(waitingIdx).toBeGreaterThanOrEqual(0); + expect(actionsIdx).toBeGreaterThan(waitingIdx); + expect(parentEl.querySelector('.qoderian-signin-copy')).toBeTruthy(); + + // Retry is hidden while the sign-in flow owns the panel. + expect(parentEl.querySelector('.qoderian-model-runtime-retry')).toBeNull(); + + openButton?.click(); + expect(loginService.openAuthUrl).toHaveBeenCalledTimes(1); + + parentEl.querySelector('.qoderian-signin-cancel')?.click(); + expect(loginService.cancel).toHaveBeenCalledTimes(1); + }); + + it('renders the failure reason and offers signing in again', () => { + const parentEl = createMockEl(); + const { callbacks, loginService } = buildAuthCallbacks({ + phase: 'failed', + authUrl: null, + failure: { kind: 'process', details: 'Device flow poll failed' }, + }); + + new ModelSelector(parentEl, callbacks); + + expect(parentEl.querySelector('.qoderian-signin-error')?.textContent) + .toBe('Sign-in failed. Check the details and try again.'); + expect(parentEl.querySelector('.qoderian-signin-error')?.getAttribute('title')) + .toBe('Device flow poll failed'); + + const retryButton = parentEl.querySelector('.qoderian-signin-button'); + expect(retryButton?.textContent).toBe('Sign in again'); + retryButton?.click(); + expect(loginService.start).toHaveBeenCalledTimes(1); + }); + }); + it('keeps a cached model visible while a background refresh is running', () => { const parentEl = createMockEl(); const callbacks = { diff --git a/tests/unit/qoder/commands/probe-runtime-commands.test.ts b/tests/unit/qoder/commands/probe-runtime-commands.test.ts index 4a15e1b..9e0c0bd 100644 --- a/tests/unit/qoder/commands/probe-runtime-commands.test.ts +++ b/tests/unit/qoder/commands/probe-runtime-commands.test.ts @@ -155,7 +155,14 @@ describe('probeRuntimeCatalog', () => { it('classifies sign-in and compatibility failures with actionable guidance', () => { expect(classifyQoderProbeError(new Error('Authentication required: please login'))) - .toMatchObject({ kind: 'authRequired', message: expect.stringContaining('qodercli login') }); + .toMatchObject({ kind: 'authRequired', message: expect.stringContaining('Sign in') }); + expect(classifyQoderProbeError(new Error('No qodercli login found. Run "qodercli login" first.'))) + .toMatchObject({ kind: 'authRequired', message: expect.stringContaining('Sign in') }); + expect(classifyQoderProbeError( + new Error('Transport closed'), + false, + 'No qodercli login found. Run "qodercli login" first.', + )).toMatchObject({ kind: 'authRequired', message: expect.stringContaining('Sign in') }); expect(classifyQoderProbeError(new Error('Protocol version mismatch'))) .toMatchObject({ kind: 'incompatible', message: expect.stringContaining('Update qodercli') }); }); diff --git a/tests/unit/qoder/runtime/custom-spawn.test.ts b/tests/unit/qoder/runtime/custom-spawn.test.ts index c199c07..06b1fd0 100644 --- a/tests/unit/qoder/runtime/custom-spawn.test.ts +++ b/tests/unit/qoder/runtime/custom-spawn.test.ts @@ -108,42 +108,60 @@ describe('createCustomSpawnFunction', () => { ); }); - it('pipes stderr only when DEBUG_QODER_AGENT_SDK is set', () => { + it('always pipes stderr so host code can read CLI diagnostics', () => { const mockProcess = createMockProcess(); spawnMock.mockReturnValue(mockProcess as unknown as ReturnType); const spawnFn = createCustomSpawnFunction('/enhanced/path'); - const signal = new AbortController().signal; spawnFn({ command: 'node', args: ['cli.js'], cwd: '/tmp', - env: { DEBUG_QODER_AGENT_SDK: '1' }, - signal, + env: {}, + signal: new AbortController().signal, }); const spawnOptions = spawnMock.mock.calls[0][2]; expect(spawnOptions.stdio).toEqual(['pipe', 'pipe', 'pipe']); + // A drain listener is always attached to avoid pipe backpressure. expect(mockProcess.stderr?.on).toHaveBeenCalledWith('data', expect.any(Function)); }); - it('ignores stderr when DEBUG_QODER_AGENT_SDK is not set', () => { + it('logs stderr to the console only when DEBUG_QODER_AGENT_SDK is set', () => { + const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); const mockProcess = createMockProcess(); spawnMock.mockReturnValue(mockProcess as unknown as ReturnType); const spawnFn = createCustomSpawnFunction('/enhanced/path'); - const signal = new AbortController().signal; spawnFn({ command: 'node', args: ['cli.js'], cwd: '/tmp', - env: {}, - signal, + env: { DEBUG_QODER_AGENT_SDK: '1' }, + signal: new AbortController().signal, }); - const spawnOptions = spawnMock.mock.calls[0][2]; - expect(spawnOptions.stdio).toEqual(['pipe', 'pipe', 'ignore']); - expect(mockProcess.stderr?.on).not.toHaveBeenCalled(); + const onData = (mockProcess.stderr?.on as jest.Mock).mock.calls + .filter(([event]: [string]) => event === 'data') + .map(([, listener]: [string, (chunk: Buffer) => void]) => listener)[0]; + onData(Buffer.from('cli diagnostic')); + expect(consoleError).toHaveBeenCalledWith('cli diagnostic'); + + consoleError.mockClear(); + const quietProcess = createMockProcess(); + spawnMock.mockReturnValue(quietProcess as unknown as ReturnType); + spawnFn({ + command: 'node', + args: ['cli.js'], + cwd: '/tmp', + env: {}, + signal: new AbortController().signal, + }); + const quietOnData = (quietProcess.stderr?.on as jest.Mock).mock.calls + .filter(([event]: [string]) => event === 'data') + .map(([, listener]: [string, (chunk: Buffer) => void]) => listener)[0]; + quietOnData(Buffer.from('cli diagnostic')); + expect(consoleError).not.toHaveBeenCalled(); }); it('throws when process streams are missing', () => { diff --git a/tests/unit/qoder/services/qoder-login-service.test.ts b/tests/unit/qoder/services/qoder-login-service.test.ts new file mode 100644 index 0000000..1bd299b --- /dev/null +++ b/tests/unit/qoder/services/qoder-login-service.test.ts @@ -0,0 +1,227 @@ +/** @jest-environment jsdom */ +import { spawn } from 'child_process'; +import { EventEmitter } from 'events'; + +import * as env from '@/core/env/environment'; +import * as fsPath from '@/core/fs/path'; +import type { QoderHostContext } from '@/qoder/qoder-host-context'; +import { QoderLoginService } from '@/qoder/services/qoder-login-service'; + +jest.mock('child_process', () => ({ + spawn: jest.fn(), +})); + +interface FakeChild extends EventEmitter { + stdout: EventEmitter; + stderr: EventEmitter; + pid: number; + kill: jest.Mock; +} + +function createFakeChild(): FakeChild { + const child = new EventEmitter() as FakeChild; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.pid = 4242; + child.kill = jest.fn(() => true); + return child; +} + +function createPlugin(cliPath: string | null): QoderHostContext { + return { + app: {} as QoderHostContext['app'], + settings: {} as QoderHostContext['settings'], + getResolvedQoderCliPath: () => cliPath, + }; +} + +describe('QoderLoginService', () => { + const spawnMock = spawn as jest.MockedFunction; + + beforeEach(() => { + spawnMock.mockClear(); + jest.spyOn(env, 'getEnhancedPath').mockReturnValue('/enhanced'); + jest.spyOn(env, 'getMissingNodeError').mockReturnValue(null); + jest.spyOn(env, 'cliPathRequiresNode').mockReturnValue(false); + jest.spyOn(env, 'findNodeExecutable').mockReturnValue('/enhanced/node'); + jest.spyOn(fsPath, 'getVaultPath').mockReturnValue('/vault'); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('fails with cliMissing when no CLI path is resolved', () => { + const service = new QoderLoginService(createPlugin(null)); + + expect(service.start()).toBe(false); + expect(service.getState()).toEqual({ + phase: 'failed', + authUrl: null, + failure: { kind: 'cliMissing' }, + }); + expect(spawnMock).not.toHaveBeenCalled(); + }); + + it('fails with nodeMissing when a Node-backed CLI has no Node runtime', () => { + jest.spyOn(env, 'getMissingNodeError').mockReturnValue('Node not found'); + const service = new QoderLoginService(createPlugin('/bin/qodercli')); + + expect(service.start()).toBe(false); + expect(service.getState().phase).toBe('failed'); + expect(service.getState().failure?.kind).toBe('nodeMissing'); + expect(spawnMock).not.toHaveBeenCalled(); + }); + + it('spawns the CLI login command and extracts the device-flow URL', () => { + const child = createFakeChild(); + spawnMock.mockReturnValue(child as unknown as ReturnType); + const service = new QoderLoginService(createPlugin('/bin/qodercli')); + + expect(service.start()).toBe(true); + expect(service.getState().phase).toBe('starting'); + expect(spawnMock).toHaveBeenCalledWith( + '/bin/qodercli', + ['login'], + expect.objectContaining({ cwd: '/vault' }), + ); + + const clickSpy = jest.spyOn(HTMLAnchorElement.prototype, 'click') + .mockImplementation(() => {}); + + child.stdout.emit('data', Buffer.from( + 'Starting browser login...\n\n' + + 'Please open the following URL in your browser to sign in:\n\n' + + ' https://qoder.com/device/selectAccounts?challenge=abc\u001b[0m\n\n' + + 'Waiting for browser authorization...\n', + )); + + expect(service.getState()).toEqual({ + phase: 'waiting', + authUrl: 'https://qoder.com/device/selectAccounts?challenge=abc', + failure: null, + }); + // The plugin owns the browser: exactly one auto-open when the URL arrives. + expect(clickSpy).toHaveBeenCalledTimes(1); + + // Repeated URL output within the same attempt must not open again. + child.stdout.emit('data', Buffer.from( + ' https://qoder.com/device/selectAccounts?challenge=abc\n', + )); + expect(clickSpy).toHaveBeenCalledTimes(1); + + service.openAuthUrl(); + expect(clickSpy).toHaveBeenCalledTimes(2); + clickSpy.mockRestore(); + }); + + it('disables the CLI own browser launch via the BROWSER headless marker', () => { + const child = createFakeChild(); + spawnMock.mockReturnValue(child as unknown as ReturnType); + const service = new QoderLoginService(createPlugin('/bin/qodercli')); + + service.start(); + + const options = spawnMock.mock.calls[0][2] as { env: Record }; + expect(options.env.BROWSER).toBe('www-browser'); + }); + + it('rejects a second start while a login attempt is running', () => { + const child = createFakeChild(); + spawnMock.mockReturnValue(child as unknown as ReturnType); + const service = new QoderLoginService(createPlugin('/bin/qodercli')); + + expect(service.start()).toBe(true); + expect(service.start()).toBe(false); + expect(spawnMock).toHaveBeenCalledTimes(1); + }); + + it('succeeds on exit code 0 and notifies the success callback', () => { + const child = createFakeChild(); + spawnMock.mockReturnValue(child as unknown as ReturnType); + const onSucceeded = jest.fn(); + const service = new QoderLoginService(createPlugin('/bin/qodercli'), onSucceeded); + const listener = jest.fn(); + service.subscribe(listener); + + service.start(); + child.emit('exit', 0); + + expect(service.getState()).toEqual({ phase: 'succeeded', authUrl: null, failure: null }); + expect(onSucceeded).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenLastCalledWith(expect.objectContaining({ phase: 'succeeded' })); + }); + + it('fails with process details on a non-zero exit', () => { + const child = createFakeChild(); + spawnMock.mockReturnValue(child as unknown as ReturnType); + const service = new QoderLoginService(createPlugin('/bin/qodercli')); + + service.start(); + child.stderr.emit('data', Buffer.from('Device flow poll failed\n')); + child.emit('exit', 1); + + const state = service.getState(); + expect(state.phase).toBe('failed'); + expect(state.failure).toEqual({ kind: 'process', details: 'Device flow poll failed' }); + }); + + it('returns to idle when the attempt is canceled', () => { + const child = createFakeChild(); + spawnMock.mockReturnValue(child as unknown as ReturnType); + const service = new QoderLoginService(createPlugin('/bin/qodercli')); + + service.start(); + expect(service.isRunning()).toBe(true); + service.cancel(); + expect(child.kill).toHaveBeenCalledWith('SIGTERM'); + child.emit('exit', null, 'SIGTERM'); + + expect(service.getState()).toEqual({ phase: 'idle', authUrl: null, failure: null }); + expect(service.isRunning()).toBe(false); + }); + + it('reports spawn errors as failed state', () => { + const child = createFakeChild(); + spawnMock.mockReturnValue(child as unknown as ReturnType); + const service = new QoderLoginService(createPlugin('/bin/qodercli')); + + service.start(); + child.emit('error', new Error('spawn ENOENT')); + + expect(service.getState()).toEqual({ + phase: 'failed', + authUrl: null, + failure: { kind: 'spawn', details: 'spawn ENOENT' }, + }); + }); + + it('resets finished attempts to idle but leaves running attempts alone', () => { + const child = createFakeChild(); + spawnMock.mockReturnValue(child as unknown as ReturnType); + const service = new QoderLoginService(createPlugin('/bin/qodercli')); + + service.start(); + service.reset(); + expect(service.getState().phase).toBe('starting'); + + child.emit('exit', 0); + service.reset(); + expect(service.getState().phase).toBe('idle'); + }); + + it('routes Node-backed CLI paths through the node executable', () => { + jest.spyOn(env, 'cliPathRequiresNode').mockReturnValue(true); + const child = createFakeChild(); + spawnMock.mockReturnValue(child as unknown as ReturnType); + const service = new QoderLoginService(createPlugin('/npm/qodercli/cli.js')); + + service.start(); + + expect(spawnMock).toHaveBeenCalledWith( + '/enhanced/node', + ['/npm/qodercli/cli.js', 'login'], + expect.any(Object), + ); + }); +});