diff --git a/src/cliproxy/auth-handler.ts b/src/cliproxy/auth-handler.ts index 025f745e..8ec68e3c 100644 --- a/src/cliproxy/auth-handler.ts +++ b/src/cliproxy/auth-handler.ts @@ -32,6 +32,16 @@ import { enhancedPreflightOAuthCheck, OAUTH_CALLBACK_PORTS as OAUTH_PORTS, } from '../management/oauth-port-diagnostics'; +import { + parseProjectList, + parseDefaultProject, + isProjectSelectionPrompt, + isProjectList, + generateSessionId, + requestProjectSelection, + type GCloudProject, + type ProjectSelectionPrompt, +} from './project-selection-handler'; /** * OAuth callback ports used by CLIProxyAPI (hardcoded in binary) @@ -451,20 +461,8 @@ function getTimeoutTroubleshooting(provider: CLIProxyProvider, port: number | nu lines.push(' 1. Check browser completed auth (should show success page)'); if (port) { - if (process.platform === 'win32') { - lines.push(` 2. Verify port ${port} is reachable: curl http://localhost:${port}`); - lines.push(' 3. Windows Firewall may block - run as Administrator:'); - lines.push( - ` netsh advfirewall firewall add rule name="CCS OAuth" dir=in action=allow protocol=TCP localport=${port}` - ); - lines.push(` 4. Try: ccs ${provider} --auth --verbose`); - } else if (process.platform === 'darwin') { - lines.push(` 2. Check for port conflicts: lsof -ti:${port}`); - lines.push(` 3. Try: ccs ${provider} --auth --verbose`); - } else { - lines.push(` 2. Check for port conflicts: lsof -ti:${port} or ss -tlnp | grep ${port}`); - lines.push(` 3. Try: ccs ${provider} --auth --verbose`); - } + lines.push(` 2. Check for port conflicts: lsof -ti:${port} or ss -tlnp | grep ${port}`); + lines.push(` 3. Try: ccs ${provider} --auth --verbose`); } else { lines.push(` 2. Try: ccs ${provider} --auth --verbose`); } @@ -489,11 +487,14 @@ export async function triggerOAuth( account?: string; add?: boolean; nickname?: string; + /** If true, triggered from Web UI (enables project selection prompt) */ + fromUI?: boolean; } = {} ): Promise { const oauthConfig = getOAuthConfig(provider); - const { verbose = false, add = false, nickname } = options; + const { verbose = false, add = false, nickname, fromUI = false } = options; const callbackPort = OAUTH_PORTS[provider]; + const isCLI = !fromUI; // CLI mode = auto-select default project // Auto-detect headless if not explicitly set const headless = options.headless ?? isHeadlessEnvironment(); @@ -620,8 +621,9 @@ export async function triggerOAuth( return new Promise((resolve) => { // Spawn CLIProxyAPI with auth flag + // Use pipe for stdin to auto-respond to interactive prompts (e.g., project selection) const authProcess = spawn(binaryPath, args, { - stdio: ['inherit', 'pipe', 'pipe'], + stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, CLI_PROXY_AUTH_DIR: tokenDir, @@ -631,12 +633,63 @@ export async function triggerOAuth( let stderrData = ''; let urlDisplayed = false; let browserOpened = false; + let projectPromptHandled = false; + let accumulatedOutput = ''; // Accumulate output to parse project list + let parsedProjects: GCloudProject[] = []; + const sessionId = generateSessionId(); // Unique session ID for this auth flow const startTime = Date.now(); - authProcess.stdout?.on('data', (data: Buffer) => { + authProcess.stdout?.on('data', async (data: Buffer) => { const output = data.toString(); log(`stdout: ${output.trim()}`); + // Accumulate output for project list parsing + accumulatedOutput += output; + + // Parse project list when available + if (isProjectList(accumulatedOutput) && parsedProjects.length === 0) { + parsedProjects = parseProjectList(accumulatedOutput); + log(`Parsed ${parsedProjects.length} projects`); + } + + // Handle project selection prompt + if (!projectPromptHandled && isProjectSelectionPrompt(output)) { + projectPromptHandled = true; + + const defaultProjectId = parseDefaultProject(output) || ''; + + // If we have projects and this is a UI-triggered flow, request selection + if (parsedProjects.length > 0 && !isCLI) { + log(`Requesting project selection from UI (session: ${sessionId})`); + + const prompt: ProjectSelectionPrompt = { + sessionId, + provider, + projects: parsedProjects, + defaultProjectId, + supportsAll: output.includes('ALL'), + }; + + try { + // Request selection from UI (with timeout fallback to default) + const selectedId = await requestProjectSelection(prompt); + + // Write selection to stdin (empty = default, else project ID or ALL) + const response = selectedId || ''; + log(`User selected: ${response || '(default)'}`); + authProcess.stdin?.write(response + '\n'); + } catch { + // Fallback to default on error + log('Project selection failed, using default'); + authProcess.stdin?.write('\n'); + } + } else { + // CLI mode or no projects: auto-select default + log('CLI mode or no projects, auto-selecting default'); + authProcess.stdin?.write('\n'); + } + } + // Detect when callback server starts or browser opens if (!browserOpened && (output.includes('listening') || output.includes('http'))) { process.stdout.write('\x1b[1A\x1b[2K'); diff --git a/src/cliproxy/project-selection-handler.ts b/src/cliproxy/project-selection-handler.ts new file mode 100644 index 00000000..bb0a2c2d --- /dev/null +++ b/src/cliproxy/project-selection-handler.ts @@ -0,0 +1,218 @@ +/** + * Project Selection Handler + * + * Manages interactive project selection prompts during OAuth flow. + * Parses CLIProxyAPI stdout for project lists and coordinates + * between backend (stdin writer) and frontend (WebSocket/UI). + */ + +import { EventEmitter } from 'events'; + +/** + * Parsed project from CLIProxyAPI output + */ +export interface GCloudProject { + id: string; + name: string; + index: number; +} + +/** + * Project selection prompt data sent to UI + */ +export interface ProjectSelectionPrompt { + sessionId: string; + provider: string; + projects: GCloudProject[]; + defaultProjectId: string; + supportsAll: boolean; +} + +/** + * Project selection response from UI + */ +export interface ProjectSelectionResponse { + sessionId: string; + selectedId: string; // Project ID or 'ALL' +} + +/** + * Pending project selection with resolver + */ +interface PendingSelection { + prompt: ProjectSelectionPrompt; + resolve: (value: string) => void; + timeout: NodeJS.Timeout; +} + +// Global event emitter for project selection events +export const projectSelectionEvents = new EventEmitter(); + +// Pending selections by session ID +const pendingSelections = new Map(); + +// Default timeout for project selection (30 seconds) +const SELECTION_TIMEOUT_MS = 30000; + +/** + * Parse project list from CLIProxyAPI stdout + * + * Expected format: + * "Available Google Cloud projects: + * [1] project-id-123 (Project Name) + * [2] another-project (Another Name) + * Type 'ALL' to onboard every listed project." + */ +export function parseProjectList(output: string): GCloudProject[] { + const projects: GCloudProject[] = []; + + // Match lines like: [1] project-id (Project Name) + const projectRegex = /\[(\d+)\]\s+(\S+)\s+\(([^)]+)\)/g; + let match; + + while ((match = projectRegex.exec(output)) !== null) { + projects.push({ + index: parseInt(match[1], 10), + id: match[2], + name: match[3], + }); + } + + return projects; +} + +/** + * Parse default project ID from prompt + * + * Expected format: "Enter project ID [default-project-id] or ALL:" + */ +export function parseDefaultProject(output: string): string | null { + const match = output.match(/Enter project ID \[([^\]]+)\] or ALL:/); + return match ? match[1] : null; +} + +/** + * Check if output contains project selection prompt + */ +export function isProjectSelectionPrompt(output: string): boolean { + return output.includes('Enter project ID') && output.includes('or ALL:'); +} + +/** + * Check if output contains project list + */ +export function isProjectList(output: string): boolean { + return output.includes('Available Google Cloud projects:'); +} + +/** + * Generate unique session ID for OAuth flow + */ +export function generateSessionId(): string { + return `auth-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`; +} + +/** + * Request project selection from UI + * Returns promise that resolves when user selects a project + * + * @param prompt - Project selection prompt data + * @returns Promise - Selected project ID or 'ALL', or default if timeout + */ +export function requestProjectSelection(prompt: ProjectSelectionPrompt): Promise { + return new Promise((resolve) => { + // Set timeout for auto-selection + const timeout = setTimeout(() => { + const pending = pendingSelections.get(prompt.sessionId); + if (pending) { + pendingSelections.delete(prompt.sessionId); + // Auto-select default on timeout + resolve(prompt.defaultProjectId); + projectSelectionEvents.emit('selection:timeout', prompt.sessionId); + } + }, SELECTION_TIMEOUT_MS); + + // Store pending selection + pendingSelections.set(prompt.sessionId, { + prompt, + resolve, + timeout, + }); + + // Emit event for WebSocket broadcast + projectSelectionEvents.emit('selection:required', prompt); + }); +} + +/** + * Submit project selection from UI + * + * @param response - Selection response with session ID and selected project + * @returns boolean - True if selection was accepted + */ +export function submitProjectSelection(response: ProjectSelectionResponse): boolean { + const pending = pendingSelections.get(response.sessionId); + + if (!pending) { + return false; + } + + // Clear timeout and remove from pending + clearTimeout(pending.timeout); + pendingSelections.delete(response.sessionId); + + // Resolve the promise with selected ID + pending.resolve(response.selectedId); + projectSelectionEvents.emit('selection:submitted', response); + + return true; +} + +/** + * Cancel pending project selection + */ +export function cancelProjectSelection(sessionId: string): boolean { + const pending = pendingSelections.get(sessionId); + + if (!pending) { + return false; + } + + clearTimeout(pending.timeout); + pendingSelections.delete(sessionId); + + // Resolve with default (empty string to auto-select) + pending.resolve(''); + projectSelectionEvents.emit('selection:cancelled', sessionId); + + return true; +} + +/** + * Get pending project selection prompt + */ +export function getPendingSelection(sessionId: string): ProjectSelectionPrompt | null { + const pending = pendingSelections.get(sessionId); + return pending ? pending.prompt : null; +} + +/** + * Check if there's a pending selection + */ +export function hasPendingSelection(sessionId: string): boolean { + return pendingSelections.has(sessionId); +} + +export default { + parseProjectList, + parseDefaultProject, + isProjectSelectionPrompt, + isProjectList, + generateSessionId, + requestProjectSelection, + submitProjectSelection, + cancelProjectSelection, + getPendingSelection, + hasPendingSelection, + projectSelectionEvents, +}; diff --git a/src/web-server/routes.ts b/src/web-server/routes.ts index 9a4486fd..74066c0c 100644 --- a/src/web-server/routes.ts +++ b/src/web-server/routes.ts @@ -17,6 +17,7 @@ import { initializeAccounts, triggerOAuth, } from '../cliproxy/auth-handler'; +import { submitProjectSelection, getPendingSelection } from '../cliproxy/project-selection-handler'; import { fetchCliproxyStats, fetchCliproxyModels, @@ -639,6 +640,7 @@ apiRoutes.post( add: true, // Always add mode from UI headless: false, // Force interactive mode nickname: nickname || undefined, + fromUI: true, // Enable project selection prompt in UI }); if (account) { @@ -661,6 +663,48 @@ apiRoutes.post( } ); +/** + * GET /api/cliproxy/auth/project-selection/:sessionId - Get pending project selection prompt + * Returns project list for user to select from during OAuth flow + */ +apiRoutes.get( + '/cliproxy/auth/project-selection/:sessionId', + (req: Request, res: Response): void => { + const { sessionId } = req.params; + + const pending = getPendingSelection(sessionId); + if (pending) { + res.json(pending); + } else { + res.status(404).json({ error: 'No pending project selection for this session' }); + } + } +); + +/** + * POST /api/cliproxy/auth/project-selection/:sessionId - Submit project selection + * Submits user's project choice during OAuth flow + */ +apiRoutes.post( + '/cliproxy/auth/project-selection/:sessionId', + (req: Request, res: Response): void => { + const { sessionId } = req.params; + const { selectedId } = req.body; + + if (!selectedId && selectedId !== '') { + res.status(400).json({ error: 'selectedId is required (use empty string for default)' }); + return; + } + + const success = submitProjectSelection({ sessionId, selectedId }); + if (success) { + res.json({ success: true }); + } else { + res.status(404).json({ error: 'No pending project selection for this session' }); + } + } +); + // ==================== Settings (Phase 05) ==================== /** diff --git a/src/web-server/websocket.ts b/src/web-server/websocket.ts index 9ecba5e8..22de8762 100644 --- a/src/web-server/websocket.ts +++ b/src/web-server/websocket.ts @@ -2,11 +2,16 @@ * WebSocket Handler (Phase 04) * * Manages WebSocket connections, broadcasts file changes, and handles client messages. + * Also broadcasts project selection events during OAuth flows. */ import { WebSocketServer, WebSocket } from 'ws'; import { createFileWatcher, FileChangeEvent } from './file-watcher'; import { info, warn } from '../utils/ui'; +import { + projectSelectionEvents, + type ProjectSelectionPrompt, +} from '../cliproxy/project-selection-handler'; export interface WSMessage { type: string; @@ -81,9 +86,51 @@ export function setupWebSocket(wss: WebSocketServer): { cleanup: () => void } { }); }); + // Listen for project selection events and broadcast to clients + const handleProjectSelectionRequired = (prompt: ProjectSelectionPrompt): void => { + console.log(info(`[WS] Broadcasting project selection prompt (session: ${prompt.sessionId})`)); + broadcast({ + type: 'projectSelectionRequired', + ...prompt, + timestamp: Date.now(), + }); + }; + + const handleProjectSelectionTimeout = (sessionId: string): void => { + console.log(info(`[WS] Project selection timed out (session: ${sessionId})`)); + broadcast({ + type: 'projectSelectionTimeout', + sessionId, + timestamp: Date.now(), + }); + }; + + const handleProjectSelectionSubmitted = (response: { + sessionId: string; + selectedId: string; + }): void => { + console.log(info(`[WS] Project selection submitted (session: ${response.sessionId})`)); + broadcast({ + type: 'projectSelectionSubmitted', + ...response, + timestamp: Date.now(), + }); + }; + + // Subscribe to project selection events + projectSelectionEvents.on('selection:required', handleProjectSelectionRequired); + projectSelectionEvents.on('selection:timeout', handleProjectSelectionTimeout); + projectSelectionEvents.on('selection:submitted', handleProjectSelectionSubmitted); + // Cleanup function const cleanup = (): void => { watcher.close(); + + // Unsubscribe from project selection events + projectSelectionEvents.off('selection:required', handleProjectSelectionRequired); + projectSelectionEvents.off('selection:timeout', handleProjectSelectionTimeout); + projectSelectionEvents.off('selection:submitted', handleProjectSelectionSubmitted); + clients.forEach((client) => { client.close(1001, 'Server shutting down'); }); diff --git a/ui/src/components/layout.tsx b/ui/src/components/layout.tsx index a98494cc..41002086 100644 --- a/ui/src/components/layout.tsx +++ b/ui/src/components/layout.tsx @@ -11,6 +11,8 @@ import { LocalhostDisclaimer } from '@/components/localhost-disclaimer'; import { Skeleton } from '@/components/ui/skeleton'; import { ClaudeKitBadge } from '@/components/claudekit-badge'; import { SponsorButton } from '@/components/sponsor-button'; +import { ProjectSelectionDialog } from '@/components/project-selection-dialog'; +import { useProjectSelection } from '@/hooks/use-project-selection'; function PageLoader() { return ( @@ -22,6 +24,8 @@ function PageLoader() { } export function Layout() { + const { isOpen, prompt, onSelect, onClose } = useProjectSelection(); + return ( @@ -46,6 +50,20 @@ export function Layout() { + + {/* Global project selection dialog for OAuth flows */} + {prompt && ( + + )} ); } diff --git a/ui/src/components/project-selection-dialog.tsx b/ui/src/components/project-selection-dialog.tsx new file mode 100644 index 00000000..b80b00b9 --- /dev/null +++ b/ui/src/components/project-selection-dialog.tsx @@ -0,0 +1,203 @@ +/** + * Project Selection Dialog Component + * + * Displays during OAuth flow when CLIProxyAPI requires user to select + * a Google Cloud project. Shows list of available projects with option + * to select one or ALL. + */ + +import { useState, useEffect } from 'react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Loader2, FolderOpen, Check, Circle, CheckCircle } from 'lucide-react'; + +interface GCloudProject { + id: string; + name: string; + index: number; +} + +interface ProjectSelectionDialogProps { + open: boolean; + onClose: () => void; + sessionId: string; + provider: string; + projects: GCloudProject[]; + defaultProjectId: string; + supportsAll: boolean; + onSelect: (selectedId: string) => Promise; + /** Timeout in seconds before auto-selecting default */ + timeoutSeconds?: number; +} + +export function ProjectSelectionDialog({ + open, + onClose, + sessionId, + provider, + projects, + defaultProjectId, + supportsAll, + onSelect, + timeoutSeconds = 30, +}: ProjectSelectionDialogProps) { + const [selectedId, setSelectedId] = useState(defaultProjectId); + const [isSubmitting, setIsSubmitting] = useState(false); + const [countdown, setCountdown] = useState(timeoutSeconds); + + // Countdown timer for auto-selection + useEffect(() => { + if (!open) { + setCountdown(timeoutSeconds); + return; + } + + const timer = setInterval(() => { + setCountdown((prev) => { + if (prev <= 1) { + clearInterval(timer); + // Auto-submit on timeout + handleSubmit(true); + return 0; + } + return prev - 1; + }); + }, 1000); + + return () => clearInterval(timer); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, timeoutSeconds]); + + // Reset state when dialog opens + useEffect(() => { + if (open) { + setSelectedId(defaultProjectId); + setIsSubmitting(false); + setCountdown(timeoutSeconds); + } + }, [open, defaultProjectId, timeoutSeconds]); + + const handleSubmit = async (isTimeout = false) => { + if (isSubmitting) return; + setIsSubmitting(true); + + try { + // Empty string means use default (press Enter behavior) + const submitId = isTimeout ? '' : selectedId; + await onSelect(submitId); + onClose(); + } catch (error) { + console.error('Failed to submit project selection:', error); + // On error, submit empty to use default + try { + await onSelect(''); + } catch { + // Ignore double-error + } + onClose(); + } + }; + + const providerDisplay = provider.charAt(0).toUpperCase() + provider.slice(1); + + // Suppress unused variable warning - sessionId used for identification + void sessionId; + + return ( + !isOpen && !isSubmitting && onClose()}> + + + + + Select Google Cloud Project + + + Choose which project to use for {providerDisplay} authentication. + {countdown > 0 && ( + + (Auto-selecting default in {countdown}s) + + )} + + + +
+
+ {projects.map((project) => ( +
!isSubmitting && setSelectedId(project.id)} + > + {selectedId === project.id ? ( + + ) : ( + + )} +
+
{project.name}
+
{project.id}
+
+ {project.id === defaultProjectId && ( + Default + )} +
+ ))} + + {supportsAll && ( +
!isSubmitting && setSelectedId('ALL')} + > + {selectedId === 'ALL' ? ( + + ) : ( + + )} +
+
All Projects
+
+ Onboard all {projects.length} listed projects +
+
+
+ )} +
+ +
+ + +
+
+
+
+ ); +} diff --git a/ui/src/hooks/use-project-selection.ts b/ui/src/hooks/use-project-selection.ts new file mode 100644 index 00000000..89f16e68 --- /dev/null +++ b/ui/src/hooks/use-project-selection.ts @@ -0,0 +1,111 @@ +/** + * Project Selection Hook + * + * Listens for WebSocket project selection events and manages the dialog state. + * Used in conjunction with ProjectSelectionDialog component. + */ + +import { useState, useEffect, useCallback } from 'react'; + +interface GCloudProject { + id: string; + name: string; + index: number; +} + +interface ProjectSelectionPrompt { + sessionId: string; + provider: string; + projects: GCloudProject[]; + defaultProjectId: string; + supportsAll: boolean; +} + +interface ProjectSelectionState { + isOpen: boolean; + prompt: ProjectSelectionPrompt | null; +} + +export function useProjectSelection() { + const [state, setState] = useState({ + isOpen: false, + prompt: null, + }); + + // Listen for WebSocket messages via custom events + useEffect(() => { + const handleMessage = (event: CustomEvent<{ type: string; [key: string]: unknown }>) => { + const data = event.detail; + + if (data.type === 'projectSelectionRequired') { + console.log('[ProjectSelection] Received prompt:', data.sessionId); + setState({ + isOpen: true, + prompt: { + sessionId: data.sessionId as string, + provider: data.provider as string, + projects: data.projects as GCloudProject[], + defaultProjectId: data.defaultProjectId as string, + supportsAll: data.supportsAll as boolean, + }, + }); + } else if (data.type === 'projectSelectionTimeout') { + console.log('[ProjectSelection] Timeout:', data.sessionId); + // Close dialog if this session timed out + setState((prev) => { + if (prev.prompt?.sessionId === data.sessionId) { + return { isOpen: false, prompt: null }; + } + return prev; + }); + } else if (data.type === 'projectSelectionSubmitted') { + console.log('[ProjectSelection] Submitted:', data.sessionId); + // Close dialog if this session was submitted + setState((prev) => { + if (prev.prompt?.sessionId === data.sessionId) { + return { isOpen: false, prompt: null }; + } + return prev; + }); + } + }; + + // Listen for custom ws-message events dispatched by useWebSocket + window.addEventListener('ws-message', handleMessage as EventListener); + + return () => { + window.removeEventListener('ws-message', handleMessage as EventListener); + }; + }, []); + + const handleSelect = useCallback( + async (selectedId: string) => { + if (!state.prompt) return; + + const response = await fetch( + `/api/cliproxy/auth/project-selection/${state.prompt.sessionId}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ selectedId }), + } + ); + + if (!response.ok) { + throw new Error('Failed to submit project selection'); + } + }, + [state.prompt] + ); + + const handleClose = useCallback(() => { + setState({ isOpen: false, prompt: null }); + }, []); + + return { + isOpen: state.isOpen, + prompt: state.prompt, + onSelect: handleSelect, + onClose: handleClose, + }; +} diff --git a/ui/src/hooks/use-websocket.ts b/ui/src/hooks/use-websocket.ts index f365bd1d..8ff5de46 100644 --- a/ui/src/hooks/use-websocket.ts +++ b/ui/src/hooks/use-websocket.ts @@ -82,6 +82,9 @@ export function useWebSocket() { try { const message: WSMessage = JSON.parse(event.data); handleMessage(message); + + // Dispatch custom event for other hooks to listen (e.g., project selection) + window.dispatchEvent(new CustomEvent('ws-message', { detail: message })); } catch { console.log('[WS] Invalid message'); }