feat(cliproxy): implement interactive project selection for OAuth flows

- Add ProjectSelectionHandler to parse and handle CLIProxy project prompts
- Integrate project selection into AuthHandler with UI-first support
- Add project selection API endpoints and WebSocket events
- Implement ProjectSelectionDialog and useProjectSelection hook in UI
- Fixes macOS OAuth timeout by handling interactive project selection via UI
This commit is contained in:
kaitranntt
2025-12-18 04:11:59 -05:00
parent a87ef973fc
commit a66abba174
8 changed files with 714 additions and 17 deletions
+70 -17
View File
@@ -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<AccountInfo | null> {
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<AccountInfo | null>((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');
+218
View File
@@ -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<string, PendingSelection>();
// 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<string> - Selected project ID or 'ALL', or default if timeout
*/
export function requestProjectSelection(prompt: ProjectSelectionPrompt): Promise<string> {
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,
};
+44
View File
@@ -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) ====================
/**
+47
View File
@@ -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');
});
+18
View File
@@ -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 (
<SidebarProvider>
<AppSidebar />
@@ -46,6 +50,20 @@ export function Layout() {
</div>
<LocalhostDisclaimer />
</main>
{/* Global project selection dialog for OAuth flows */}
{prompt && (
<ProjectSelectionDialog
open={isOpen}
onClose={onClose}
sessionId={prompt.sessionId}
provider={prompt.provider}
projects={prompt.projects}
defaultProjectId={prompt.defaultProjectId}
supportsAll={prompt.supportsAll}
onSelect={onSelect}
/>
)}
</SidebarProvider>
);
}
@@ -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<void>;
/** 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 (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && !isSubmitting && onClose()}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FolderOpen className="w-5 h-5" />
Select Google Cloud Project
</DialogTitle>
<DialogDescription>
Choose which project to use for {providerDisplay} authentication.
{countdown > 0 && (
<span className="text-muted-foreground ml-1">
(Auto-selecting default in {countdown}s)
</span>
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
{projects.map((project) => (
<div
key={project.id}
className={`flex items-center space-x-3 p-3 rounded-lg border cursor-pointer transition-colors ${
selectedId === project.id
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/50'
}`}
onClick={() => !isSubmitting && setSelectedId(project.id)}
>
{selectedId === project.id ? (
<CheckCircle className="w-5 h-5 text-primary" />
) : (
<Circle className="w-5 h-5 text-muted-foreground" />
)}
<div className="flex-1">
<div className="font-medium">{project.name}</div>
<div className="text-sm text-muted-foreground font-mono">{project.id}</div>
</div>
{project.id === defaultProjectId && (
<span className="text-xs px-2 py-1 bg-secondary rounded">Default</span>
)}
</div>
))}
{supportsAll && (
<div
className={`flex items-center space-x-3 p-3 rounded-lg border cursor-pointer transition-colors ${
selectedId === 'ALL'
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/50'
}`}
onClick={() => !isSubmitting && setSelectedId('ALL')}
>
{selectedId === 'ALL' ? (
<CheckCircle className="w-5 h-5 text-primary" />
) : (
<Circle className="w-5 h-5 text-muted-foreground" />
)}
<div className="flex-1">
<div className="font-medium">All Projects</div>
<div className="text-sm text-muted-foreground">
Onboard all {projects.length} listed projects
</div>
</div>
</div>
)}
</div>
<div className="flex items-center justify-end gap-2 pt-2">
<Button variant="ghost" onClick={() => handleSubmit(true)} disabled={isSubmitting}>
Use Default
</Button>
<Button onClick={() => handleSubmit(false)} disabled={isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Selecting...
</>
) : (
<>
<Check className="w-4 h-4 mr-2" />
Confirm Selection
</>
)}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
+111
View File
@@ -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<ProjectSelectionState>({
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,
};
}
+3
View File
@@ -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');
}