mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-14 12:16:38 +00:00
refactor(cliproxy): modularize auth handler and binary
- extract 6 auth modules to auth/ directory (932 → 65 lines) - oauth-handler, oauth-process, token-manager - environment-detector, auth-types - extract binary/ with downloader, verifier modules Phase 3: Large Files Breakdown (auth-handler.ts)
This commit is contained in:
1 parent
cd107e354c
commit
5c28935d1e
9 files changed
+1440
-909
No files matched your search
+42
-909
@@ -10,895 +10,51 @@
|
|||||||
*
|
*
|
||||||
* Token storage: ~/.ccs/cliproxy/auth/<provider>/
|
* Token storage: ~/.ccs/cliproxy/auth/<provider>/
|
||||||
* Each provider has its own directory to avoid conflicts.
|
* Each provider has its own directory to avoid conflicts.
|
||||||
*/
|
|
||||||
|
|
||||||
import { execSync, spawn } from 'child_process';
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as path from 'path';
|
|
||||||
import { ok, fail, info, warn, color } from '../utils/ui';
|
|
||||||
import { ensureCLIProxyBinary } from './binary-manager';
|
|
||||||
import { generateConfig, getProviderAuthDir } from './config-generator';
|
|
||||||
import { CLIProxyProvider } from './types';
|
|
||||||
import {
|
|
||||||
AccountInfo,
|
|
||||||
discoverExistingAccounts,
|
|
||||||
generateNickname,
|
|
||||||
getDefaultAccount,
|
|
||||||
getProviderAccounts,
|
|
||||||
registerAccount,
|
|
||||||
touchAccount,
|
|
||||||
} from './account-manager';
|
|
||||||
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)
|
|
||||||
* See: https://github.com/router-for-me/CLIProxyAPI/tree/main/internal/auth
|
|
||||||
*
|
*
|
||||||
* OAuth flow types per provider:
|
* This file is a facade that re-exports from the auth/ submodule.
|
||||||
* - Gemini: Authorization Code Flow with local callback server on port 8085
|
* The actual implementation is split across:
|
||||||
* - Codex: Authorization Code Flow with local callback server on port 1455
|
* - auth/auth-types.ts - Types and OAuth configurations
|
||||||
* - Agy: Authorization Code Flow with local callback server on port 51121
|
* - auth/token-manager.ts - Token storage/retrieval/validation
|
||||||
* - Qwen: Device Code Flow (polling-based, NO callback port needed)
|
* - auth/oauth-handler.ts - OAuth flow handling
|
||||||
*
|
* - auth/environment-detector.ts - Headless detection and port management
|
||||||
* We auto-kill processes on callback ports before OAuth to avoid conflicts.
|
|
||||||
*/
|
*/
|
||||||
const OAUTH_CALLBACK_PORTS: Partial<Record<CLIProxyProvider, number>> = {
|
|
||||||
gemini: 8085,
|
|
||||||
// codex uses 1455
|
|
||||||
// agy uses 51121
|
|
||||||
// qwen uses Device Code Flow - no callback port needed
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
// Re-export types
|
||||||
* Kill any process using a specific port
|
export type { AuthStatus, ProviderOAuthConfig, OAuthOptions } from './auth';
|
||||||
* Used to free OAuth callback port before authentication
|
|
||||||
*/
|
// Re-export configurations
|
||||||
function killProcessOnPort(port: number, verbose: boolean): boolean {
|
export {
|
||||||
try {
|
OAUTH_CALLBACK_PORTS,
|
||||||
if (process.platform === 'win32') {
|
OAUTH_CONFIGS,
|
||||||
// Windows: use netstat + taskkill
|
PROVIDER_AUTH_PREFIXES,
|
||||||
const result = execSync(`netstat -ano | findstr :${port}`, {
|
PROVIDER_TYPE_VALUES,
|
||||||
encoding: 'utf-8',
|
getOAuthConfig,
|
||||||
stdio: 'pipe',
|
} from './auth';
|
||||||
});
|
|
||||||
const lines = result.trim().split('\n');
|
// Re-export token management functions
|
||||||
for (const line of lines) {
|
export {
|
||||||
const parts = line.trim().split(/\s+/);
|
getProviderTokenDir,
|
||||||
const pid = parts[parts.length - 1];
|
isTokenFileForProvider,
|
||||||
if (pid && /^\d+$/.test(pid)) {
|
isAuthenticated,
|
||||||
execSync(`taskkill /F /PID ${pid}`, { stdio: 'pipe' });
|
getAuthStatus,
|
||||||
if (verbose) console.error(`[auth] Killed process ${pid} on port ${port}`);
|
getAllAuthStatus,
|
||||||
}
|
clearAuth,
|
||||||
}
|
displayAuthStatus,
|
||||||
return true;
|
} from './auth';
|
||||||
} else {
|
|
||||||
// Unix: use lsof + kill
|
// Re-export environment detection functions
|
||||||
const result = execSync(`lsof -ti:${port}`, { encoding: 'utf-8', stdio: 'pipe' });
|
export {
|
||||||
const pids = result
|
isHeadlessEnvironment,
|
||||||
.trim()
|
killProcessOnPort,
|
||||||
.split('\n')
|
getTimeoutTroubleshooting,
|
||||||
.filter((p) => p);
|
showStep,
|
||||||
for (const pid of pids) {
|
} from './auth';
|
||||||
execSync(`kill -9 ${pid}`, { stdio: 'pipe' });
|
|
||||||
if (verbose) console.error(`[auth] Killed process ${pid} on port ${port}`);
|
// Re-export OAuth handling functions
|
||||||
}
|
export { triggerOAuth, ensureAuth } from './auth';
|
||||||
return pids.length > 0;
|
|
||||||
}
|
// Re-export account management initialization
|
||||||
} catch {
|
import { discoverExistingAccounts } from './account-manager';
|
||||||
// No process on port or command failed - that's fine
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detect if running in a headless environment (no browser available)
|
|
||||||
*
|
|
||||||
* IMPROVED: Avoids false positives on Windows desktop environments
|
|
||||||
* where isTTY may be undefined due to terminal wrapper behavior.
|
|
||||||
*
|
|
||||||
* Case study: Vietnamese Windows users reported "command hangs" because
|
|
||||||
* their terminal (PowerShell via npm) didn't set isTTY correctly.
|
|
||||||
*/
|
|
||||||
function isHeadlessEnvironment(): boolean {
|
|
||||||
// SSH session - always headless
|
|
||||||
if (process.env.SSH_TTY || process.env.SSH_CLIENT || process.env.SSH_CONNECTION) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// No display on Linux (X11/Wayland) - headless
|
|
||||||
if (process.platform === 'linux' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Windows desktop - NEVER headless unless SSH (already checked above)
|
|
||||||
// This fixes false positive where Windows npm wrappers don't set isTTY correctly
|
|
||||||
// Windows desktop environments always have browser capability
|
|
||||||
if (process.platform === 'win32') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// macOS - check for proper terminal
|
|
||||||
if (process.platform === 'darwin') {
|
|
||||||
// Non-interactive stdin on macOS means likely piped/scripted
|
|
||||||
if (!process.stdin.isTTY) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Linux with display - check TTY
|
|
||||||
if (process.platform === 'linux') {
|
|
||||||
if (!process.stdin.isTTY) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default fallback for unknown platforms
|
|
||||||
return !process.stdin.isTTY;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Auth status for a provider
|
|
||||||
*/
|
|
||||||
export interface AuthStatus {
|
|
||||||
/** Provider name */
|
|
||||||
provider: CLIProxyProvider;
|
|
||||||
/** Whether authentication exists */
|
|
||||||
authenticated: boolean;
|
|
||||||
/** Path to token directory */
|
|
||||||
tokenDir: string;
|
|
||||||
/** Token file paths found */
|
|
||||||
tokenFiles: string[];
|
|
||||||
/** When last authenticated (if known) */
|
|
||||||
lastAuth?: Date;
|
|
||||||
/** Accounts registered for this provider (multi-account support) */
|
|
||||||
accounts: AccountInfo[];
|
|
||||||
/** Default account ID */
|
|
||||||
defaultAccount?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* OAuth config for each provider
|
|
||||||
*/
|
|
||||||
interface ProviderOAuthConfig {
|
|
||||||
/** Provider identifier */
|
|
||||||
provider: CLIProxyProvider;
|
|
||||||
/** Display name */
|
|
||||||
displayName: string;
|
|
||||||
/** OAuth authorization URL (for manual flow) */
|
|
||||||
authUrl: string;
|
|
||||||
/** Scopes required */
|
|
||||||
scopes: string[];
|
|
||||||
/** CLI flag for auth */
|
|
||||||
authFlag: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* OAuth configurations per provider
|
|
||||||
* Note: CLIProxyAPI handles actual OAuth - these are for display/manual flow
|
|
||||||
*/
|
|
||||||
const OAUTH_CONFIGS: Record<CLIProxyProvider, ProviderOAuthConfig> = {
|
|
||||||
gemini: {
|
|
||||||
provider: 'gemini',
|
|
||||||
displayName: 'Google Gemini',
|
|
||||||
authUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
|
|
||||||
scopes: ['https://www.googleapis.com/auth/generative-language'],
|
|
||||||
authFlag: '--login',
|
|
||||||
},
|
|
||||||
codex: {
|
|
||||||
provider: 'codex',
|
|
||||||
displayName: 'Codex',
|
|
||||||
authUrl: 'https://auth.openai.com/authorize',
|
|
||||||
scopes: ['openid', 'profile'],
|
|
||||||
authFlag: '--codex-login',
|
|
||||||
},
|
|
||||||
agy: {
|
|
||||||
provider: 'agy',
|
|
||||||
displayName: 'Antigravity',
|
|
||||||
authUrl: 'https://antigravity.ai/oauth/authorize',
|
|
||||||
scopes: ['api'],
|
|
||||||
authFlag: '--antigravity-login',
|
|
||||||
},
|
|
||||||
qwen: {
|
|
||||||
provider: 'qwen',
|
|
||||||
displayName: 'Qwen Code',
|
|
||||||
authUrl: 'https://chat.qwen.ai/api/v1/oauth2/device/code',
|
|
||||||
scopes: ['openid', 'profile', 'email', 'model.completion'],
|
|
||||||
authFlag: '--qwen-login',
|
|
||||||
},
|
|
||||||
iflow: {
|
|
||||||
provider: 'iflow',
|
|
||||||
displayName: 'iFlow',
|
|
||||||
authUrl: 'https://iflow.cn/oauth',
|
|
||||||
scopes: ['phone', 'profile', 'email'],
|
|
||||||
authFlag: '--iflow-login',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get OAuth config for provider
|
|
||||||
*/
|
|
||||||
export function getOAuthConfig(provider: CLIProxyProvider): ProviderOAuthConfig {
|
|
||||||
const config = OAUTH_CONFIGS[provider];
|
|
||||||
if (!config) {
|
|
||||||
throw new Error(`Unknown provider: ${provider}`);
|
|
||||||
}
|
|
||||||
return config;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get token directory for provider
|
|
||||||
*/
|
|
||||||
export function getProviderTokenDir(provider: CLIProxyProvider): string {
|
|
||||||
return getProviderAuthDir(provider);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Provider-specific auth file prefixes (fallback detection)
|
|
||||||
* CLIProxyAPI names auth files with provider prefix (e.g., "antigravity-user@email.json")
|
|
||||||
* Note: Gemini tokens may NOT have prefix - CLIProxyAPI uses {email}-{projectID}.json format
|
|
||||||
*/
|
|
||||||
const PROVIDER_AUTH_PREFIXES: Record<CLIProxyProvider, string[]> = {
|
|
||||||
gemini: ['gemini-', 'google-'],
|
|
||||||
codex: ['codex-', 'openai-'],
|
|
||||||
agy: ['antigravity-', 'agy-'],
|
|
||||||
qwen: ['qwen-'],
|
|
||||||
iflow: ['iflow-'],
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Provider type values inside token JSON files
|
|
||||||
* CLIProxyAPI sets "type" field in token JSON (e.g., {"type": "gemini"})
|
|
||||||
*/
|
|
||||||
const PROVIDER_TYPE_VALUES: Record<CLIProxyProvider, string[]> = {
|
|
||||||
gemini: ['gemini'],
|
|
||||||
codex: ['codex'],
|
|
||||||
agy: ['antigravity'],
|
|
||||||
qwen: ['qwen'],
|
|
||||||
iflow: ['iflow'],
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a JSON file contains a token for the given provider
|
|
||||||
* Reads the file and checks the "type" field
|
|
||||||
*/
|
|
||||||
function isTokenFileForProvider(filePath: string, provider: CLIProxyProvider): boolean {
|
|
||||||
try {
|
|
||||||
const content = fs.readFileSync(filePath, 'utf-8');
|
|
||||||
const data = JSON.parse(content);
|
|
||||||
const typeValue = (data.type || '').toLowerCase();
|
|
||||||
const validTypes = PROVIDER_TYPE_VALUES[provider] || [];
|
|
||||||
return validTypes.includes(typeValue);
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if provider has valid authentication
|
|
||||||
* CLIProxyAPI stores OAuth tokens as JSON files in the auth directory.
|
|
||||||
* Detection strategy:
|
|
||||||
* 1. First check by filename prefix (fast path)
|
|
||||||
* 2. If no match, check JSON content for "type" field (Gemini uses {email}-{projectID}.json without prefix)
|
|
||||||
*/
|
|
||||||
export function isAuthenticated(provider: CLIProxyProvider): boolean {
|
|
||||||
const tokenDir = getProviderTokenDir(provider);
|
|
||||||
|
|
||||||
if (!fs.existsSync(tokenDir)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const validPrefixes = PROVIDER_AUTH_PREFIXES[provider] || [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
const files = fs.readdirSync(tokenDir);
|
|
||||||
const jsonFiles = files.filter(
|
|
||||||
(f) => f.endsWith('.json') || f.endsWith('.token') || f === 'credentials'
|
|
||||||
);
|
|
||||||
|
|
||||||
// Strategy 1: Check by filename prefix (fast path for antigravity, codex)
|
|
||||||
const prefixMatch = jsonFiles.some((f) => {
|
|
||||||
const lowerFile = f.toLowerCase();
|
|
||||||
return validPrefixes.some((prefix) => lowerFile.startsWith(prefix));
|
|
||||||
});
|
|
||||||
if (prefixMatch) return true;
|
|
||||||
|
|
||||||
// Strategy 2: Check JSON content for "type" field (needed for Gemini)
|
|
||||||
for (const f of jsonFiles) {
|
|
||||||
const filePath = path.join(tokenDir, f);
|
|
||||||
if (isTokenFileForProvider(filePath, provider)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get detailed auth status for provider
|
|
||||||
* Uses same detection strategy as isAuthenticated: prefix first, then content
|
|
||||||
*/
|
|
||||||
export function getAuthStatus(provider: CLIProxyProvider): AuthStatus {
|
|
||||||
const tokenDir = getProviderTokenDir(provider);
|
|
||||||
let tokenFiles: string[] = [];
|
|
||||||
let lastAuth: Date | undefined;
|
|
||||||
|
|
||||||
const validPrefixes = PROVIDER_AUTH_PREFIXES[provider] || [];
|
|
||||||
|
|
||||||
if (fs.existsSync(tokenDir)) {
|
|
||||||
const files = fs.readdirSync(tokenDir);
|
|
||||||
const jsonFiles = files.filter(
|
|
||||||
(f) => f.endsWith('.json') || f.endsWith('.token') || f === 'credentials'
|
|
||||||
);
|
|
||||||
|
|
||||||
// Check each file: by prefix OR by content
|
|
||||||
tokenFiles = jsonFiles.filter((f) => {
|
|
||||||
const lowerFile = f.toLowerCase();
|
|
||||||
// Strategy 1: prefix match
|
|
||||||
if (validPrefixes.some((prefix) => lowerFile.startsWith(prefix))) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
// Strategy 2: content match (for Gemini tokens without prefix)
|
|
||||||
const filePath = path.join(tokenDir, f);
|
|
||||||
return isTokenFileForProvider(filePath, provider);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get most recent modification time
|
|
||||||
for (const file of tokenFiles) {
|
|
||||||
const filePath = path.join(tokenDir, file);
|
|
||||||
try {
|
|
||||||
const stats = fs.statSync(filePath);
|
|
||||||
if (!lastAuth || stats.mtime > lastAuth) {
|
|
||||||
lastAuth = stats.mtime;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Skip if can't stat file
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get registered accounts for multi-account support
|
|
||||||
const accounts = getProviderAccounts(provider);
|
|
||||||
const defaultAccount = getDefaultAccount(provider);
|
|
||||||
|
|
||||||
return {
|
|
||||||
provider,
|
|
||||||
authenticated: tokenFiles.length > 0,
|
|
||||||
tokenDir,
|
|
||||||
tokenFiles,
|
|
||||||
lastAuth,
|
|
||||||
accounts,
|
|
||||||
defaultAccount: defaultAccount?.id,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get auth status for all providers
|
|
||||||
*/
|
|
||||||
export function getAllAuthStatus(): AuthStatus[] {
|
|
||||||
const providers: CLIProxyProvider[] = ['gemini', 'codex', 'agy', 'qwen', 'iflow'];
|
|
||||||
return providers.map(getAuthStatus);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear authentication for provider
|
|
||||||
* Only removes files belonging to the specified provider (by prefix or content)
|
|
||||||
* Does NOT remove the shared auth directory or other providers' files
|
|
||||||
*/
|
|
||||||
export function clearAuth(provider: CLIProxyProvider): boolean {
|
|
||||||
const tokenDir = getProviderTokenDir(provider);
|
|
||||||
|
|
||||||
if (!fs.existsSync(tokenDir)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const validPrefixes = PROVIDER_AUTH_PREFIXES[provider] || [];
|
|
||||||
const files = fs.readdirSync(tokenDir);
|
|
||||||
let removedCount = 0;
|
|
||||||
|
|
||||||
// Only remove files that belong to this provider
|
|
||||||
for (const file of files) {
|
|
||||||
const filePath = path.join(tokenDir, file);
|
|
||||||
const lowerFile = file.toLowerCase();
|
|
||||||
|
|
||||||
// Check by prefix first (fast path)
|
|
||||||
const matchesByPrefix = validPrefixes.some((prefix) => lowerFile.startsWith(prefix));
|
|
||||||
|
|
||||||
// If no prefix match, check by content (for Gemini tokens without prefix)
|
|
||||||
const matchesByContent = !matchesByPrefix && isTokenFileForProvider(filePath, provider);
|
|
||||||
|
|
||||||
if (matchesByPrefix || matchesByContent) {
|
|
||||||
try {
|
|
||||||
fs.unlinkSync(filePath);
|
|
||||||
removedCount++;
|
|
||||||
} catch {
|
|
||||||
// Failed to remove - skip
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// DO NOT remove the shared auth directory - other providers may still have tokens
|
|
||||||
return removedCount > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Display a single step status line
|
|
||||||
*/
|
|
||||||
function showStep(
|
|
||||||
step: number,
|
|
||||||
total: number,
|
|
||||||
status: 'ok' | 'fail' | 'progress',
|
|
||||||
message: string
|
|
||||||
): void {
|
|
||||||
const statusIcon = status === 'ok' ? '[OK]' : status === 'fail' ? '[X]' : '[..]';
|
|
||||||
console.log(`${statusIcon} [${step}/${total}] ${message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get platform-specific troubleshooting for OAuth timeout
|
|
||||||
*/
|
|
||||||
function getTimeoutTroubleshooting(provider: CLIProxyProvider, port: number | null): string[] {
|
|
||||||
const lines: string[] = [];
|
|
||||||
lines.push('');
|
|
||||||
lines.push('TROUBLESHOOTING:');
|
|
||||||
lines.push(' 1. Check browser completed auth (should show success page)');
|
|
||||||
|
|
||||||
if (port) {
|
|
||||||
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`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Trigger OAuth flow for provider
|
|
||||||
* Auto-detects headless environment and uses --no-browser flag accordingly
|
|
||||||
* Shows real-time step-by-step progress for better user feedback
|
|
||||||
* @param provider - The CLIProxy provider to authenticate
|
|
||||||
* @param options - OAuth options
|
|
||||||
* @param options.add - If true, skip confirm prompt when adding another account
|
|
||||||
* @returns Account info if successful, null otherwise
|
|
||||||
*/
|
|
||||||
export async function triggerOAuth(
|
|
||||||
provider: CLIProxyProvider,
|
|
||||||
options: {
|
|
||||||
verbose?: boolean;
|
|
||||||
headless?: boolean;
|
|
||||||
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, 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();
|
|
||||||
|
|
||||||
const log = (msg: string) => {
|
|
||||||
if (verbose) {
|
|
||||||
console.error(`[auth] ${msg}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check for existing accounts and prompt if --add not specified
|
|
||||||
const existingAccounts = getProviderAccounts(provider);
|
|
||||||
if (existingAccounts.length > 0 && !add) {
|
|
||||||
console.log('');
|
|
||||||
console.log(
|
|
||||||
info(
|
|
||||||
`${existingAccounts.length} account(s) already authenticated for ${oauthConfig.displayName}`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Import readline for confirm prompt
|
|
||||||
const readline = await import('readline');
|
|
||||||
const rl = readline.createInterface({
|
|
||||||
input: process.stdin,
|
|
||||||
output: process.stdout,
|
|
||||||
});
|
|
||||||
|
|
||||||
const confirmed = await new Promise<boolean>((resolve) => {
|
|
||||||
rl.question('[?] Add another account? (y/N): ', (answer) => {
|
|
||||||
rl.close();
|
|
||||||
resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!confirmed) {
|
|
||||||
console.log(info('Cancelled'));
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enhanced pre-flight check with real-time display
|
|
||||||
console.log('');
|
|
||||||
console.log(info(`Pre-flight OAuth check for ${oauthConfig.displayName}...`));
|
|
||||||
|
|
||||||
const preflight = await enhancedPreflightOAuthCheck(provider);
|
|
||||||
|
|
||||||
// Display each check result
|
|
||||||
for (const check of preflight.checks) {
|
|
||||||
const icon = check.status === 'ok' ? '[OK]' : check.status === 'warn' ? '[!]' : '[X]';
|
|
||||||
console.log(` ${icon} ${check.name}: ${check.message}`);
|
|
||||||
if (check.fixCommand && check.status !== 'ok') {
|
|
||||||
console.log(` Fix: ${check.fixCommand}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show firewall warning prominently on Windows
|
|
||||||
if (preflight.firewallWarning) {
|
|
||||||
console.log('');
|
|
||||||
console.log(warn('Windows Firewall may block OAuth callback'));
|
|
||||||
console.log(' If auth hangs, run as Administrator:');
|
|
||||||
console.log(` ${color(preflight.firewallFixCommand || '', 'command')}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!preflight.ready) {
|
|
||||||
console.log('');
|
|
||||||
console.log(fail('Pre-flight check failed. Resolve issues above and retry.'));
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('');
|
|
||||||
|
|
||||||
// Step 1: Ensure binary exists
|
|
||||||
showStep(1, 4, 'progress', 'Preparing CLIProxy binary...');
|
|
||||||
let binaryPath: string;
|
|
||||||
try {
|
|
||||||
binaryPath = await ensureCLIProxyBinary(verbose);
|
|
||||||
// Clear and rewrite with OK
|
|
||||||
process.stdout.write('\x1b[1A\x1b[2K'); // Move up and clear line
|
|
||||||
showStep(1, 4, 'ok', 'CLIProxy binary ready');
|
|
||||||
} catch (error) {
|
|
||||||
process.stdout.write('\x1b[1A\x1b[2K');
|
|
||||||
showStep(1, 4, 'fail', 'Failed to prepare CLIProxy binary');
|
|
||||||
console.error(fail((error as Error).message));
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure auth directory exists
|
|
||||||
const tokenDir = getProviderTokenDir(provider);
|
|
||||||
fs.mkdirSync(tokenDir, { recursive: true, mode: 0o700 });
|
|
||||||
|
|
||||||
// Generate config file (CLIProxyAPI requires it even for auth)
|
|
||||||
const configPath = generateConfig(provider);
|
|
||||||
log(`Config generated: ${configPath}`);
|
|
||||||
|
|
||||||
// Free OAuth callback port if needed
|
|
||||||
const localCallbackPort = OAUTH_CALLBACK_PORTS[provider];
|
|
||||||
if (localCallbackPort) {
|
|
||||||
const killed = killProcessOnPort(localCallbackPort, verbose);
|
|
||||||
if (killed) {
|
|
||||||
log(`Freed port ${localCallbackPort} for OAuth callback`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build args: config + auth flag + optional --no-browser for headless
|
|
||||||
const args = ['--config', configPath, oauthConfig.authFlag];
|
|
||||||
if (headless) {
|
|
||||||
args.push('--no-browser');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2: Starting callback server
|
|
||||||
showStep(2, 4, 'progress', `Starting callback server on port ${callbackPort || 'N/A'}...`);
|
|
||||||
|
|
||||||
// Show headless instructions if needed
|
|
||||||
if (headless) {
|
|
||||||
console.log('');
|
|
||||||
console.log(warn('PORT FORWARDING REQUIRED'));
|
|
||||||
console.log(` OAuth callback uses localhost:${callbackPort} which must be reachable.`);
|
|
||||||
console.log(' Run this on your LOCAL machine:');
|
|
||||||
console.log(
|
|
||||||
` ${color(`ssh -L ${callbackPort}:localhost:${callbackPort} <USER>@<HOST>`, 'command')}`
|
|
||||||
);
|
|
||||||
console.log('');
|
|
||||||
}
|
|
||||||
|
|
||||||
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: ['pipe', 'pipe', 'pipe'],
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
CLI_PROXY_AUTH_DIR: tokenDir,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
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', 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');
|
|
||||||
showStep(2, 4, 'ok', `Callback server listening on port ${callbackPort}`);
|
|
||||||
showStep(3, 4, 'progress', 'Opening browser...');
|
|
||||||
browserOpened = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// In headless mode, display OAuth URLs prominently
|
|
||||||
if (headless) {
|
|
||||||
const urlMatch = output.match(/https?:\/\/[^\s]+/);
|
|
||||||
if (urlMatch && !urlDisplayed) {
|
|
||||||
console.log('');
|
|
||||||
console.log(info(`${oauthConfig.displayName} OAuth URL:`));
|
|
||||||
console.log(` ${urlMatch[0]}`);
|
|
||||||
console.log('');
|
|
||||||
urlDisplayed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
authProcess.stderr?.on('data', (data: Buffer) => {
|
|
||||||
const output = data.toString();
|
|
||||||
stderrData += output;
|
|
||||||
log(`stderr: ${output.trim()}`);
|
|
||||||
|
|
||||||
// Also check stderr for URLs
|
|
||||||
if (headless && !urlDisplayed) {
|
|
||||||
const urlMatch = output.match(/https?:\/\/[^\s]+/);
|
|
||||||
if (urlMatch) {
|
|
||||||
console.log('');
|
|
||||||
console.log(info(`${oauthConfig.displayName} OAuth URL:`));
|
|
||||||
console.log(` ${urlMatch[0]}`);
|
|
||||||
console.log('');
|
|
||||||
urlDisplayed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// After a short delay, assume browser opened and show waiting
|
|
||||||
setTimeout(() => {
|
|
||||||
if (!browserOpened) {
|
|
||||||
process.stdout.write('\x1b[1A\x1b[2K');
|
|
||||||
showStep(2, 4, 'ok', `Callback server ready (port ${callbackPort})`);
|
|
||||||
showStep(3, 4, 'ok', 'Browser opened');
|
|
||||||
browserOpened = true;
|
|
||||||
}
|
|
||||||
showStep(4, 4, 'progress', 'Waiting for OAuth callback...');
|
|
||||||
console.log('');
|
|
||||||
console.log(info('Complete the login in your browser. This page will update automatically.'));
|
|
||||||
if (!verbose) {
|
|
||||||
console.log(info('If stuck, try: ccs ' + provider + ' --auth --verbose'));
|
|
||||||
}
|
|
||||||
}, 2000);
|
|
||||||
|
|
||||||
// Timeout after 5 minutes for headless, 2 minutes for normal
|
|
||||||
const timeoutMs = headless ? 300000 : 120000;
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
authProcess.kill();
|
|
||||||
console.log('');
|
|
||||||
console.log(fail(`OAuth timed out after ${headless ? 5 : 2} minutes`));
|
|
||||||
|
|
||||||
// Show platform-specific troubleshooting
|
|
||||||
const troubleshooting = getTimeoutTroubleshooting(provider, callbackPort);
|
|
||||||
for (const line of troubleshooting) {
|
|
||||||
console.log(line);
|
|
||||||
}
|
|
||||||
|
|
||||||
resolve(null);
|
|
||||||
}, timeoutMs);
|
|
||||||
|
|
||||||
authProcess.on('exit', (code) => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
||||||
|
|
||||||
if (code === 0) {
|
|
||||||
// Verify token was created BEFORE showing success
|
|
||||||
if (isAuthenticated(provider)) {
|
|
||||||
console.log('');
|
|
||||||
console.log(ok(`Authentication successful (${elapsed}s)`));
|
|
||||||
|
|
||||||
// Register the account in accounts registry
|
|
||||||
const account = registerAccountFromToken(provider, tokenDir, nickname);
|
|
||||||
resolve(account);
|
|
||||||
} else {
|
|
||||||
console.log('');
|
|
||||||
console.log(fail('Token not found after authentication'));
|
|
||||||
console.log('');
|
|
||||||
console.log('The browser showed success but callback was not received.');
|
|
||||||
|
|
||||||
// Show platform-specific guidance
|
|
||||||
if (process.platform === 'win32') {
|
|
||||||
console.log('');
|
|
||||||
console.log('On Windows, this usually means:');
|
|
||||||
console.log(' 1. Windows Firewall blocked the callback');
|
|
||||||
console.log(' 2. Antivirus software blocked the connection');
|
|
||||||
console.log('');
|
|
||||||
console.log('Try running as Administrator:');
|
|
||||||
console.log(
|
|
||||||
` netsh advfirewall firewall add rule name="CCS OAuth" dir=in action=allow protocol=TCP localport=${callbackPort}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('');
|
|
||||||
console.log(`Try: ccs ${provider} --auth --verbose`);
|
|
||||||
resolve(null);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.log('');
|
|
||||||
console.log(fail(`CLIProxyAPI auth exited with code ${code}`));
|
|
||||||
if (stderrData && !urlDisplayed) {
|
|
||||||
console.log(` ${stderrData.trim().split('\n')[0]}`);
|
|
||||||
}
|
|
||||||
if (headless && !urlDisplayed) {
|
|
||||||
console.log('');
|
|
||||||
console.log(info('No OAuth URL was displayed. Try with --verbose for details.'));
|
|
||||||
}
|
|
||||||
resolve(null);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
authProcess.on('error', (error) => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
console.log('');
|
|
||||||
console.log(fail(`Failed to start auth process: ${error.message}`));
|
|
||||||
resolve(null);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register account from newly created token file
|
|
||||||
* Scans auth directory for new token and extracts email
|
|
||||||
* @param provider - The CLIProxy provider
|
|
||||||
* @param tokenDir - Directory containing token files
|
|
||||||
* @param nickname - Optional nickname (uses auto-generated from email if not provided)
|
|
||||||
*/
|
|
||||||
function registerAccountFromToken(
|
|
||||||
provider: CLIProxyProvider,
|
|
||||||
tokenDir: string,
|
|
||||||
nickname?: string
|
|
||||||
): AccountInfo | null {
|
|
||||||
try {
|
|
||||||
const files = fs.readdirSync(tokenDir);
|
|
||||||
const jsonFiles = files.filter((f) => f.endsWith('.json'));
|
|
||||||
|
|
||||||
// Find newest token file for this provider
|
|
||||||
let newestFile: string | null = null;
|
|
||||||
let newestMtime = 0;
|
|
||||||
|
|
||||||
for (const file of jsonFiles) {
|
|
||||||
const filePath = path.join(tokenDir, file);
|
|
||||||
if (!isTokenFileForProvider(filePath, provider)) continue;
|
|
||||||
|
|
||||||
const stats = fs.statSync(filePath);
|
|
||||||
if (stats.mtimeMs > newestMtime) {
|
|
||||||
newestMtime = stats.mtimeMs;
|
|
||||||
newestFile = file;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!newestFile) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read token to extract email
|
|
||||||
const tokenPath = path.join(tokenDir, newestFile);
|
|
||||||
const content = fs.readFileSync(tokenPath, 'utf-8');
|
|
||||||
const data = JSON.parse(content);
|
|
||||||
const email = data.email || undefined;
|
|
||||||
|
|
||||||
// Register the account (use provided nickname or auto-generate from email)
|
|
||||||
return registerAccount(provider, newestFile, email, nickname || generateNickname(email));
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensure provider is authenticated
|
|
||||||
* Triggers OAuth flow if not authenticated
|
|
||||||
* @param provider - The CLIProxy provider
|
|
||||||
* @param options - Auth options including optional account
|
|
||||||
* @returns true if authenticated, false otherwise
|
|
||||||
*/
|
|
||||||
export async function ensureAuth(
|
|
||||||
provider: CLIProxyProvider,
|
|
||||||
options: { verbose?: boolean; headless?: boolean; account?: string } = {}
|
|
||||||
): Promise<boolean> {
|
|
||||||
// Check if already authenticated
|
|
||||||
if (isAuthenticated(provider)) {
|
|
||||||
if (options.verbose) {
|
|
||||||
console.error(`[auth] ${provider} already authenticated`);
|
|
||||||
}
|
|
||||||
// Touch the account to update last used time
|
|
||||||
const defaultAccount = getDefaultAccount(provider);
|
|
||||||
if (defaultAccount) {
|
|
||||||
touchAccount(provider, options.account || defaultAccount.id);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Not authenticated - trigger OAuth
|
|
||||||
const oauthConfig = getOAuthConfig(provider);
|
|
||||||
console.log(info(`${oauthConfig.displayName} authentication required`));
|
|
||||||
|
|
||||||
const account = await triggerOAuth(provider, options);
|
|
||||||
return account !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize accounts registry from existing tokens
|
* Initialize accounts registry from existing tokens
|
||||||
@@ -907,26 +63,3 @@ export async function ensureAuth(
|
|||||||
export function initializeAccounts(): void {
|
export function initializeAccounts(): void {
|
||||||
discoverExistingAccounts();
|
discoverExistingAccounts();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Display auth status for all providers
|
|
||||||
*/
|
|
||||||
export function displayAuthStatus(): void {
|
|
||||||
console.log('CLIProxy Authentication Status:');
|
|
||||||
console.log('');
|
|
||||||
|
|
||||||
const statuses = getAllAuthStatus();
|
|
||||||
|
|
||||||
for (const status of statuses) {
|
|
||||||
const oauthConfig = getOAuthConfig(status.provider);
|
|
||||||
const icon = status.authenticated ? '[OK]' : '[!]';
|
|
||||||
const authStatus = status.authenticated ? 'Authenticated' : 'Not authenticated';
|
|
||||||
const lastAuthStr = status.lastAuth ? ` (last: ${status.lastAuth.toLocaleDateString()})` : '';
|
|
||||||
|
|
||||||
console.log(`${icon} ${oauthConfig.displayName}: ${authStatus}${lastAuthStr}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('');
|
|
||||||
console.log('To authenticate: ccs <provider> --auth');
|
|
||||||
console.log('To logout: ccs <provider> --logout');
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
/**
|
||||||
|
* Auth Types and Configurations
|
||||||
|
*
|
||||||
|
* Type definitions and OAuth configurations for CLIProxy authentication.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { CLIProxyProvider } from '../types';
|
||||||
|
import { AccountInfo } from '../account-manager';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OAuth callback ports used by CLIProxyAPI (hardcoded in binary)
|
||||||
|
* See: https://github.com/router-for-me/CLIProxyAPI/tree/main/internal/auth
|
||||||
|
*
|
||||||
|
* OAuth flow types per provider:
|
||||||
|
* - Gemini: Authorization Code Flow with local callback server on port 8085
|
||||||
|
* - Codex: Authorization Code Flow with local callback server on port 1455
|
||||||
|
* - Agy: Authorization Code Flow with local callback server on port 51121
|
||||||
|
* - Qwen: Device Code Flow (polling-based, NO callback port needed)
|
||||||
|
*/
|
||||||
|
export const OAUTH_CALLBACK_PORTS: Partial<Record<CLIProxyProvider, number>> = {
|
||||||
|
gemini: 8085,
|
||||||
|
// codex uses 1455
|
||||||
|
// agy uses 51121
|
||||||
|
// qwen uses Device Code Flow - no callback port needed
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auth status for a provider
|
||||||
|
*/
|
||||||
|
export interface AuthStatus {
|
||||||
|
/** Provider name */
|
||||||
|
provider: CLIProxyProvider;
|
||||||
|
/** Whether authentication exists */
|
||||||
|
authenticated: boolean;
|
||||||
|
/** Path to token directory */
|
||||||
|
tokenDir: string;
|
||||||
|
/** Token file paths found */
|
||||||
|
tokenFiles: string[];
|
||||||
|
/** When last authenticated (if known) */
|
||||||
|
lastAuth?: Date;
|
||||||
|
/** Accounts registered for this provider (multi-account support) */
|
||||||
|
accounts: AccountInfo[];
|
||||||
|
/** Default account ID */
|
||||||
|
defaultAccount?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OAuth config for each provider
|
||||||
|
*/
|
||||||
|
export interface ProviderOAuthConfig {
|
||||||
|
/** Provider identifier */
|
||||||
|
provider: CLIProxyProvider;
|
||||||
|
/** Display name */
|
||||||
|
displayName: string;
|
||||||
|
/** OAuth authorization URL (for manual flow) */
|
||||||
|
authUrl: string;
|
||||||
|
/** Scopes required */
|
||||||
|
scopes: string[];
|
||||||
|
/** CLI flag for auth */
|
||||||
|
authFlag: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OAuth configurations per provider
|
||||||
|
* Note: CLIProxyAPI handles actual OAuth - these are for display/manual flow
|
||||||
|
*/
|
||||||
|
export const OAUTH_CONFIGS: Record<CLIProxyProvider, ProviderOAuthConfig> = {
|
||||||
|
gemini: {
|
||||||
|
provider: 'gemini',
|
||||||
|
displayName: 'Google Gemini',
|
||||||
|
authUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
|
||||||
|
scopes: ['https://www.googleapis.com/auth/generative-language'],
|
||||||
|
authFlag: '--login',
|
||||||
|
},
|
||||||
|
codex: {
|
||||||
|
provider: 'codex',
|
||||||
|
displayName: 'Codex',
|
||||||
|
authUrl: 'https://auth.openai.com/authorize',
|
||||||
|
scopes: ['openid', 'profile'],
|
||||||
|
authFlag: '--codex-login',
|
||||||
|
},
|
||||||
|
agy: {
|
||||||
|
provider: 'agy',
|
||||||
|
displayName: 'Antigravity',
|
||||||
|
authUrl: 'https://antigravity.ai/oauth/authorize',
|
||||||
|
scopes: ['api'],
|
||||||
|
authFlag: '--antigravity-login',
|
||||||
|
},
|
||||||
|
qwen: {
|
||||||
|
provider: 'qwen',
|
||||||
|
displayName: 'Qwen Code',
|
||||||
|
authUrl: 'https://chat.qwen.ai/api/v1/oauth2/device/code',
|
||||||
|
scopes: ['openid', 'profile', 'email', 'model.completion'],
|
||||||
|
authFlag: '--qwen-login',
|
||||||
|
},
|
||||||
|
iflow: {
|
||||||
|
provider: 'iflow',
|
||||||
|
displayName: 'iFlow',
|
||||||
|
authUrl: 'https://iflow.cn/oauth',
|
||||||
|
scopes: ['phone', 'profile', 'email'],
|
||||||
|
authFlag: '--iflow-login',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider-specific auth file prefixes (fallback detection)
|
||||||
|
* CLIProxyAPI names auth files with provider prefix (e.g., "antigravity-user@email.json")
|
||||||
|
* Note: Gemini tokens may NOT have prefix - CLIProxyAPI uses {email}-{projectID}.json format
|
||||||
|
*/
|
||||||
|
export const PROVIDER_AUTH_PREFIXES: Record<CLIProxyProvider, string[]> = {
|
||||||
|
gemini: ['gemini-', 'google-'],
|
||||||
|
codex: ['codex-', 'openai-'],
|
||||||
|
agy: ['antigravity-', 'agy-'],
|
||||||
|
qwen: ['qwen-'],
|
||||||
|
iflow: ['iflow-'],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider type values inside token JSON files
|
||||||
|
* CLIProxyAPI sets "type" field in token JSON (e.g., {"type": "gemini"})
|
||||||
|
*/
|
||||||
|
export const PROVIDER_TYPE_VALUES: Record<CLIProxyProvider, string[]> = {
|
||||||
|
gemini: ['gemini'],
|
||||||
|
codex: ['codex'],
|
||||||
|
agy: ['antigravity'],
|
||||||
|
qwen: ['qwen'],
|
||||||
|
iflow: ['iflow'],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get OAuth config for provider
|
||||||
|
*/
|
||||||
|
export function getOAuthConfig(provider: CLIProxyProvider): ProviderOAuthConfig {
|
||||||
|
const config = OAUTH_CONFIGS[provider];
|
||||||
|
if (!config) {
|
||||||
|
throw new Error(`Unknown provider: ${provider}`);
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OAuth options for triggerOAuth
|
||||||
|
*/
|
||||||
|
export interface OAuthOptions {
|
||||||
|
verbose?: boolean;
|
||||||
|
headless?: boolean;
|
||||||
|
account?: string;
|
||||||
|
add?: boolean;
|
||||||
|
nickname?: string;
|
||||||
|
/** If true, triggered from Web UI (enables project selection prompt) */
|
||||||
|
fromUI?: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
/**
|
||||||
|
* Environment Detector for CLIProxyAPI
|
||||||
|
*
|
||||||
|
* Detects headless environments and manages OAuth callback ports.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
import { CLIProxyProvider } from '../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect if running in a headless environment (no browser available)
|
||||||
|
*
|
||||||
|
* IMPROVED: Avoids false positives on Windows desktop environments
|
||||||
|
* where isTTY may be undefined due to terminal wrapper behavior.
|
||||||
|
*
|
||||||
|
* Case study: Vietnamese Windows users reported "command hangs" because
|
||||||
|
* their terminal (PowerShell via npm) didn't set isTTY correctly.
|
||||||
|
*/
|
||||||
|
export function isHeadlessEnvironment(): boolean {
|
||||||
|
// SSH session - always headless
|
||||||
|
if (process.env.SSH_TTY || process.env.SSH_CLIENT || process.env.SSH_CONNECTION) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No display on Linux (X11/Wayland) - headless
|
||||||
|
if (process.platform === 'linux' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Windows desktop - NEVER headless unless SSH (already checked above)
|
||||||
|
// This fixes false positive where Windows npm wrappers don't set isTTY correctly
|
||||||
|
// Windows desktop environments always have browser capability
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// macOS - check for proper terminal
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
// Non-interactive stdin on macOS means likely piped/scripted
|
||||||
|
if (!process.stdin.isTTY) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Linux with display - check TTY
|
||||||
|
if (process.platform === 'linux') {
|
||||||
|
if (!process.stdin.isTTY) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default fallback for unknown platforms
|
||||||
|
return !process.stdin.isTTY;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kill any process using a specific port
|
||||||
|
* Used to free OAuth callback port before authentication
|
||||||
|
*/
|
||||||
|
export function killProcessOnPort(port: number, verbose: boolean): boolean {
|
||||||
|
try {
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
// Windows: use netstat + taskkill
|
||||||
|
const result = execSync(`netstat -ano | findstr :${port}`, {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
stdio: 'pipe',
|
||||||
|
});
|
||||||
|
const lines = result.trim().split('\n');
|
||||||
|
for (const line of lines) {
|
||||||
|
const parts = line.trim().split(/\s+/);
|
||||||
|
const pid = parts[parts.length - 1];
|
||||||
|
if (pid && /^\d+$/.test(pid)) {
|
||||||
|
execSync(`taskkill /F /PID ${pid}`, { stdio: 'pipe' });
|
||||||
|
if (verbose) console.error(`[auth] Killed process ${pid} on port ${port}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
// Unix: use lsof + kill
|
||||||
|
const result = execSync(`lsof -ti:${port}`, { encoding: 'utf-8', stdio: 'pipe' });
|
||||||
|
const pids = result
|
||||||
|
.trim()
|
||||||
|
.split('\n')
|
||||||
|
.filter((p) => p);
|
||||||
|
for (const pid of pids) {
|
||||||
|
execSync(`kill -9 ${pid}`, { stdio: 'pipe' });
|
||||||
|
if (verbose) console.error(`[auth] Killed process ${pid} on port ${port}`);
|
||||||
|
}
|
||||||
|
return pids.length > 0;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// No process on port or command failed - that's fine
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get platform-specific troubleshooting for OAuth timeout
|
||||||
|
*/
|
||||||
|
export function getTimeoutTroubleshooting(
|
||||||
|
provider: CLIProxyProvider,
|
||||||
|
port: number | null
|
||||||
|
): string[] {
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push('');
|
||||||
|
lines.push('TROUBLESHOOTING:');
|
||||||
|
lines.push(' 1. Check browser completed auth (should show success page)');
|
||||||
|
|
||||||
|
if (port) {
|
||||||
|
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`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display a single step status line
|
||||||
|
*/
|
||||||
|
export function showStep(
|
||||||
|
step: number,
|
||||||
|
total: number,
|
||||||
|
status: 'ok' | 'fail' | 'progress',
|
||||||
|
message: string
|
||||||
|
): void {
|
||||||
|
const statusIcon = status === 'ok' ? '[OK]' : status === 'fail' ? '[X]' : '[..]';
|
||||||
|
console.log(`${statusIcon} [${step}/${total}] ${message}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* Auth Module - Barrel Export
|
||||||
|
*
|
||||||
|
* Re-exports all auth-related functionality from submodules.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Types and configurations
|
||||||
|
export type { AuthStatus, ProviderOAuthConfig, OAuthOptions } from './auth-types';
|
||||||
|
export {
|
||||||
|
OAUTH_CALLBACK_PORTS,
|
||||||
|
OAUTH_CONFIGS,
|
||||||
|
PROVIDER_AUTH_PREFIXES,
|
||||||
|
PROVIDER_TYPE_VALUES,
|
||||||
|
getOAuthConfig,
|
||||||
|
} from './auth-types';
|
||||||
|
|
||||||
|
// Token management
|
||||||
|
export {
|
||||||
|
getProviderTokenDir,
|
||||||
|
isTokenFileForProvider,
|
||||||
|
isAuthenticated,
|
||||||
|
getAuthStatus,
|
||||||
|
getAllAuthStatus,
|
||||||
|
clearAuth,
|
||||||
|
registerAccountFromToken,
|
||||||
|
displayAuthStatus,
|
||||||
|
} from './token-manager';
|
||||||
|
|
||||||
|
// Environment detection
|
||||||
|
export {
|
||||||
|
isHeadlessEnvironment,
|
||||||
|
killProcessOnPort,
|
||||||
|
getTimeoutTroubleshooting,
|
||||||
|
showStep,
|
||||||
|
} from './environment-detector';
|
||||||
|
|
||||||
|
// OAuth handling
|
||||||
|
export { triggerOAuth, ensureAuth } from './oauth-handler';
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
/**
|
||||||
|
* OAuth Handler for CLIProxyAPI
|
||||||
|
*
|
||||||
|
* Manages OAuth authentication flow for CLIProxy providers (Gemini, Codex, Antigravity).
|
||||||
|
* CLIProxyAPI handles OAuth internally - we just need to:
|
||||||
|
* 1. Check if auth exists (token files in CCS auth directory)
|
||||||
|
* 2. Trigger OAuth flow by spawning binary with auth flag
|
||||||
|
* 3. Auto-detect headless environments (SSH, no DISPLAY)
|
||||||
|
* 4. Use --no-browser flag for headless, display OAuth URL for manual auth
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import { fail, info, warn, color } from '../../utils/ui';
|
||||||
|
import { ensureCLIProxyBinary } from '../binary-manager';
|
||||||
|
import { generateConfig } from '../config-generator';
|
||||||
|
import { CLIProxyProvider } from '../types';
|
||||||
|
import {
|
||||||
|
AccountInfo,
|
||||||
|
getProviderAccounts,
|
||||||
|
getDefaultAccount,
|
||||||
|
touchAccount,
|
||||||
|
} from '../account-manager';
|
||||||
|
import {
|
||||||
|
enhancedPreflightOAuthCheck,
|
||||||
|
OAUTH_CALLBACK_PORTS as OAUTH_PORTS,
|
||||||
|
} from '../../management/oauth-port-diagnostics';
|
||||||
|
import { OAuthOptions, OAUTH_CALLBACK_PORTS, getOAuthConfig } from './auth-types';
|
||||||
|
import { isHeadlessEnvironment, killProcessOnPort, showStep } from './environment-detector';
|
||||||
|
import { getProviderTokenDir, isAuthenticated } from './token-manager';
|
||||||
|
import { executeOAuthProcess } from './oauth-process';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prompt user to add another account
|
||||||
|
*/
|
||||||
|
async function promptAddAccount(): Promise<boolean> {
|
||||||
|
const readline = await import('readline');
|
||||||
|
const rl = readline.createInterface({
|
||||||
|
input: process.stdin,
|
||||||
|
output: process.stdout,
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Promise<boolean>((resolve) => {
|
||||||
|
rl.question('[?] Add another account? (y/N): ', (answer) => {
|
||||||
|
rl.close();
|
||||||
|
resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run pre-flight OAuth checks
|
||||||
|
*/
|
||||||
|
async function runPreflightChecks(
|
||||||
|
provider: CLIProxyProvider,
|
||||||
|
oauthConfig: { displayName: string }
|
||||||
|
): Promise<boolean> {
|
||||||
|
console.log('');
|
||||||
|
console.log(info(`Pre-flight OAuth check for ${oauthConfig.displayName}...`));
|
||||||
|
|
||||||
|
const preflight = await enhancedPreflightOAuthCheck(provider);
|
||||||
|
|
||||||
|
for (const check of preflight.checks) {
|
||||||
|
const icon = check.status === 'ok' ? '[OK]' : check.status === 'warn' ? '[!]' : '[X]';
|
||||||
|
console.log(` ${icon} ${check.name}: ${check.message}`);
|
||||||
|
if (check.fixCommand && check.status !== 'ok') {
|
||||||
|
console.log(` Fix: ${check.fixCommand}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preflight.firewallWarning) {
|
||||||
|
console.log('');
|
||||||
|
console.log(warn('Windows Firewall may block OAuth callback'));
|
||||||
|
console.log(' If auth hangs, run as Administrator:');
|
||||||
|
console.log(` ${color(preflight.firewallFixCommand || '', 'command')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!preflight.ready) {
|
||||||
|
console.log('');
|
||||||
|
console.log(fail('Pre-flight check failed. Resolve issues above and retry.'));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepare OAuth binary and config
|
||||||
|
*/
|
||||||
|
async function prepareBinary(
|
||||||
|
provider: CLIProxyProvider,
|
||||||
|
verbose: boolean
|
||||||
|
): Promise<{ binaryPath: string; tokenDir: string; configPath: string } | null> {
|
||||||
|
showStep(1, 4, 'progress', 'Preparing CLIProxy binary...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const binaryPath = await ensureCLIProxyBinary(verbose);
|
||||||
|
process.stdout.write('\x1b[1A\x1b[2K');
|
||||||
|
showStep(1, 4, 'ok', 'CLIProxy binary ready');
|
||||||
|
|
||||||
|
const tokenDir = getProviderTokenDir(provider);
|
||||||
|
fs.mkdirSync(tokenDir, { recursive: true, mode: 0o700 });
|
||||||
|
|
||||||
|
const configPath = generateConfig(provider);
|
||||||
|
if (verbose) {
|
||||||
|
console.error(`[auth] Config generated: ${configPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { binaryPath, tokenDir, configPath };
|
||||||
|
} catch (error) {
|
||||||
|
process.stdout.write('\x1b[1A\x1b[2K');
|
||||||
|
showStep(1, 4, 'fail', 'Failed to prepare CLIProxy binary');
|
||||||
|
console.error(fail((error as Error).message));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger OAuth flow for provider
|
||||||
|
* Auto-detects headless environment and uses --no-browser flag accordingly
|
||||||
|
* Shows real-time step-by-step progress for better user feedback
|
||||||
|
*/
|
||||||
|
export async function triggerOAuth(
|
||||||
|
provider: CLIProxyProvider,
|
||||||
|
options: OAuthOptions = {}
|
||||||
|
): Promise<AccountInfo | null> {
|
||||||
|
const oauthConfig = getOAuthConfig(provider);
|
||||||
|
const { verbose = false, add = false, nickname, fromUI = false } = options;
|
||||||
|
const callbackPort = OAUTH_PORTS[provider];
|
||||||
|
const isCLI = !fromUI;
|
||||||
|
const headless = options.headless ?? isHeadlessEnvironment();
|
||||||
|
|
||||||
|
// Check for existing accounts
|
||||||
|
const existingAccounts = getProviderAccounts(provider);
|
||||||
|
if (existingAccounts.length > 0 && !add) {
|
||||||
|
console.log('');
|
||||||
|
console.log(
|
||||||
|
info(
|
||||||
|
`${existingAccounts.length} account(s) already authenticated for ${oauthConfig.displayName}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!(await promptAddAccount())) {
|
||||||
|
console.log(info('Cancelled'));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-flight checks
|
||||||
|
if (!(await runPreflightChecks(provider, oauthConfig))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// Prepare binary
|
||||||
|
const prepared = await prepareBinary(provider, verbose);
|
||||||
|
if (!prepared) return null;
|
||||||
|
|
||||||
|
const { binaryPath, tokenDir, configPath } = prepared;
|
||||||
|
|
||||||
|
// Free callback port if needed
|
||||||
|
const localCallbackPort = OAUTH_CALLBACK_PORTS[provider];
|
||||||
|
if (localCallbackPort) {
|
||||||
|
const killed = killProcessOnPort(localCallbackPort, verbose);
|
||||||
|
if (killed && verbose) {
|
||||||
|
console.error(`[auth] Freed port ${localCallbackPort} for OAuth callback`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build args
|
||||||
|
const args = ['--config', configPath, oauthConfig.authFlag];
|
||||||
|
if (headless) {
|
||||||
|
args.push('--no-browser');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show callback server step
|
||||||
|
showStep(2, 4, 'progress', `Starting callback server on port ${callbackPort || 'N/A'}...`);
|
||||||
|
|
||||||
|
// Show headless instructions
|
||||||
|
if (headless) {
|
||||||
|
console.log('');
|
||||||
|
console.log(warn('PORT FORWARDING REQUIRED'));
|
||||||
|
console.log(` OAuth callback uses localhost:${callbackPort} which must be reachable.`);
|
||||||
|
console.log(' Run this on your LOCAL machine:');
|
||||||
|
console.log(
|
||||||
|
` ${color(`ssh -L ${callbackPort}:localhost:${callbackPort} <USER>@<HOST>`, 'command')}`
|
||||||
|
);
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute OAuth process
|
||||||
|
return executeOAuthProcess({
|
||||||
|
provider,
|
||||||
|
binaryPath,
|
||||||
|
args,
|
||||||
|
tokenDir,
|
||||||
|
oauthConfig,
|
||||||
|
callbackPort,
|
||||||
|
headless,
|
||||||
|
verbose,
|
||||||
|
isCLI,
|
||||||
|
nickname,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure provider is authenticated
|
||||||
|
* Triggers OAuth flow if not authenticated
|
||||||
|
*/
|
||||||
|
export async function ensureAuth(
|
||||||
|
provider: CLIProxyProvider,
|
||||||
|
options: { verbose?: boolean; headless?: boolean; account?: string } = {}
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (isAuthenticated(provider)) {
|
||||||
|
if (options.verbose) {
|
||||||
|
console.error(`[auth] ${provider} already authenticated`);
|
||||||
|
}
|
||||||
|
const defaultAccount = getDefaultAccount(provider);
|
||||||
|
if (defaultAccount) {
|
||||||
|
touchAccount(provider, options.account || defaultAccount.id);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const oauthConfig = getOAuthConfig(provider);
|
||||||
|
console.log(info(`${oauthConfig.displayName} authentication required`));
|
||||||
|
|
||||||
|
const account = await triggerOAuth(provider, options);
|
||||||
|
return account !== null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
/**
|
||||||
|
* OAuth Process Execution for CLIProxyAPI
|
||||||
|
*
|
||||||
|
* Handles the spawning and monitoring of CLIProxy OAuth process.
|
||||||
|
* Separated from oauth-handler.ts for modularity.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawn, ChildProcess } from 'child_process';
|
||||||
|
import { ok, fail, info } from '../../utils/ui';
|
||||||
|
import { CLIProxyProvider } from '../types';
|
||||||
|
import { AccountInfo } from '../account-manager';
|
||||||
|
import {
|
||||||
|
parseProjectList,
|
||||||
|
parseDefaultProject,
|
||||||
|
isProjectSelectionPrompt,
|
||||||
|
isProjectList,
|
||||||
|
generateSessionId,
|
||||||
|
requestProjectSelection,
|
||||||
|
type GCloudProject,
|
||||||
|
type ProjectSelectionPrompt,
|
||||||
|
} from '../project-selection-handler';
|
||||||
|
import { ProviderOAuthConfig } from './auth-types';
|
||||||
|
import { getTimeoutTroubleshooting, showStep } from './environment-detector';
|
||||||
|
import { isAuthenticated, registerAccountFromToken } from './token-manager';
|
||||||
|
|
||||||
|
/** Options for OAuth process execution */
|
||||||
|
export interface OAuthProcessOptions {
|
||||||
|
provider: CLIProxyProvider;
|
||||||
|
binaryPath: string;
|
||||||
|
args: string[];
|
||||||
|
tokenDir: string;
|
||||||
|
oauthConfig: ProviderOAuthConfig;
|
||||||
|
callbackPort: number | null;
|
||||||
|
headless: boolean;
|
||||||
|
verbose: boolean;
|
||||||
|
isCLI: boolean;
|
||||||
|
nickname?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Internal state for OAuth process */
|
||||||
|
interface ProcessState {
|
||||||
|
stderrData: string;
|
||||||
|
urlDisplayed: boolean;
|
||||||
|
browserOpened: boolean;
|
||||||
|
projectPromptHandled: boolean;
|
||||||
|
accumulatedOutput: string;
|
||||||
|
parsedProjects: GCloudProject[];
|
||||||
|
sessionId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle project selection prompt
|
||||||
|
*/
|
||||||
|
async function handleProjectSelection(
|
||||||
|
output: string,
|
||||||
|
state: ProcessState,
|
||||||
|
options: OAuthProcessOptions,
|
||||||
|
authProcess: ChildProcess,
|
||||||
|
log: (msg: string) => void
|
||||||
|
): Promise<void> {
|
||||||
|
const defaultProjectId = parseDefaultProject(output) || '';
|
||||||
|
|
||||||
|
if (state.parsedProjects.length > 0 && !options.isCLI) {
|
||||||
|
log(`Requesting project selection from UI (session: ${state.sessionId})`);
|
||||||
|
|
||||||
|
const prompt: ProjectSelectionPrompt = {
|
||||||
|
sessionId: state.sessionId,
|
||||||
|
provider: options.provider,
|
||||||
|
projects: state.parsedProjects,
|
||||||
|
defaultProjectId,
|
||||||
|
supportsAll: output.includes('ALL'),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const selectedId = await requestProjectSelection(prompt);
|
||||||
|
const response = selectedId || '';
|
||||||
|
log(`User selected: ${response || '(default)'}`);
|
||||||
|
authProcess.stdin?.write(response + '\n');
|
||||||
|
} catch {
|
||||||
|
log('Project selection failed, using default');
|
||||||
|
authProcess.stdin?.write('\n');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log('CLI mode or no projects, auto-selecting default');
|
||||||
|
authProcess.stdin?.write('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle stdout data from OAuth process
|
||||||
|
*/
|
||||||
|
async function handleStdout(
|
||||||
|
output: string,
|
||||||
|
state: ProcessState,
|
||||||
|
options: OAuthProcessOptions,
|
||||||
|
authProcess: ChildProcess,
|
||||||
|
log: (msg: string) => void
|
||||||
|
): Promise<void> {
|
||||||
|
log(`stdout: ${output.trim()}`);
|
||||||
|
state.accumulatedOutput += output;
|
||||||
|
|
||||||
|
// Parse project list when available
|
||||||
|
if (isProjectList(state.accumulatedOutput) && state.parsedProjects.length === 0) {
|
||||||
|
state.parsedProjects = parseProjectList(state.accumulatedOutput);
|
||||||
|
log(`Parsed ${state.parsedProjects.length} projects`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle project selection prompt
|
||||||
|
if (!state.projectPromptHandled && isProjectSelectionPrompt(output)) {
|
||||||
|
state.projectPromptHandled = true;
|
||||||
|
await handleProjectSelection(output, state, options, authProcess, log);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect callback server / browser
|
||||||
|
if (!state.browserOpened && (output.includes('listening') || output.includes('http'))) {
|
||||||
|
process.stdout.write('\x1b[1A\x1b[2K');
|
||||||
|
showStep(2, 4, 'ok', `Callback server listening on port ${options.callbackPort}`);
|
||||||
|
showStep(3, 4, 'progress', 'Opening browser...');
|
||||||
|
state.browserOpened = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display OAuth URLs in headless mode
|
||||||
|
if (options.headless && !state.urlDisplayed) {
|
||||||
|
const urlMatch = output.match(/https?:\/\/[^\s]+/);
|
||||||
|
if (urlMatch) {
|
||||||
|
console.log('');
|
||||||
|
console.log(info(`${options.oauthConfig.displayName} OAuth URL:`));
|
||||||
|
console.log(` ${urlMatch[0]}`);
|
||||||
|
console.log('');
|
||||||
|
state.urlDisplayed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Display OAuth URL from stderr if in headless mode */
|
||||||
|
function displayUrlFromStderr(
|
||||||
|
output: string,
|
||||||
|
state: ProcessState,
|
||||||
|
oauthConfig: ProviderOAuthConfig
|
||||||
|
): void {
|
||||||
|
const urlMatch = output.match(/https?:\/\/[^\s]+/);
|
||||||
|
if (urlMatch) {
|
||||||
|
console.log('');
|
||||||
|
console.log(info(`${oauthConfig.displayName} OAuth URL:`));
|
||||||
|
console.log(` ${urlMatch[0]}`);
|
||||||
|
console.log('');
|
||||||
|
state.urlDisplayed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Handle token not found after successful process exit */
|
||||||
|
function handleTokenNotFound(provider: CLIProxyProvider, callbackPort: number | null): void {
|
||||||
|
console.log('');
|
||||||
|
console.log(fail('Token not found after authentication'));
|
||||||
|
console.log('');
|
||||||
|
console.log('The browser showed success but callback was not received.');
|
||||||
|
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
console.log('');
|
||||||
|
console.log('On Windows, this usually means:');
|
||||||
|
console.log(' 1. Windows Firewall blocked the callback');
|
||||||
|
console.log(' 2. Antivirus software blocked the connection');
|
||||||
|
console.log('');
|
||||||
|
console.log('Try running as Administrator:');
|
||||||
|
console.log(
|
||||||
|
` netsh advfirewall firewall add rule name="CCS OAuth" dir=in action=allow protocol=TCP localport=${callbackPort}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
console.log(`Try: ccs ${provider} --auth --verbose`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Handle process exit with error */
|
||||||
|
function handleProcessError(code: number | null, state: ProcessState, headless: boolean): void {
|
||||||
|
console.log('');
|
||||||
|
console.log(fail(`CLIProxyAPI auth exited with code ${code}`));
|
||||||
|
if (state.stderrData && !state.urlDisplayed) {
|
||||||
|
console.log(` ${state.stderrData.trim().split('\n')[0]}`);
|
||||||
|
}
|
||||||
|
if (headless && !state.urlDisplayed) {
|
||||||
|
console.log('');
|
||||||
|
console.log(info('No OAuth URL was displayed. Try with --verbose for details.'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute OAuth process and wait for completion
|
||||||
|
*/
|
||||||
|
export function executeOAuthProcess(options: OAuthProcessOptions): Promise<AccountInfo | null> {
|
||||||
|
const {
|
||||||
|
provider,
|
||||||
|
binaryPath,
|
||||||
|
args,
|
||||||
|
tokenDir,
|
||||||
|
oauthConfig,
|
||||||
|
callbackPort,
|
||||||
|
headless,
|
||||||
|
verbose,
|
||||||
|
nickname,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const log = (msg: string) => {
|
||||||
|
if (verbose) console.error(`[auth] ${msg}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return new Promise<AccountInfo | null>((resolve) => {
|
||||||
|
const authProcess = spawn(binaryPath, args, {
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
env: { ...process.env, CLI_PROXY_AUTH_DIR: tokenDir },
|
||||||
|
});
|
||||||
|
|
||||||
|
const state: ProcessState = {
|
||||||
|
stderrData: '',
|
||||||
|
urlDisplayed: false,
|
||||||
|
browserOpened: false,
|
||||||
|
projectPromptHandled: false,
|
||||||
|
accumulatedOutput: '',
|
||||||
|
parsedProjects: [],
|
||||||
|
sessionId: generateSessionId(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
authProcess.stdout?.on('data', async (data: Buffer) => {
|
||||||
|
await handleStdout(data.toString(), state, options, authProcess, log);
|
||||||
|
});
|
||||||
|
|
||||||
|
authProcess.stderr?.on('data', (data: Buffer) => {
|
||||||
|
const output = data.toString();
|
||||||
|
state.stderrData += output;
|
||||||
|
log(`stderr: ${output.trim()}`);
|
||||||
|
if (headless && !state.urlDisplayed) {
|
||||||
|
displayUrlFromStderr(output, state, oauthConfig);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show waiting message after delay
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!state.browserOpened) {
|
||||||
|
process.stdout.write('\x1b[1A\x1b[2K');
|
||||||
|
showStep(2, 4, 'ok', `Callback server ready (port ${callbackPort})`);
|
||||||
|
showStep(3, 4, 'ok', 'Browser opened');
|
||||||
|
state.browserOpened = true;
|
||||||
|
}
|
||||||
|
showStep(4, 4, 'progress', 'Waiting for OAuth callback...');
|
||||||
|
console.log('');
|
||||||
|
console.log(info('Complete the login in your browser. This page will update automatically.'));
|
||||||
|
if (!verbose) console.log(info('If stuck, try: ccs ' + provider + ' --auth --verbose'));
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
// Timeout handling
|
||||||
|
const timeoutMs = headless ? 300000 : 120000;
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
authProcess.kill();
|
||||||
|
console.log('');
|
||||||
|
console.log(fail(`OAuth timed out after ${headless ? 5 : 2} minutes`));
|
||||||
|
for (const line of getTimeoutTroubleshooting(provider, callbackPort ?? null)) {
|
||||||
|
console.log(line);
|
||||||
|
}
|
||||||
|
resolve(null);
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
authProcess.on('exit', (code) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||||
|
|
||||||
|
if (code === 0) {
|
||||||
|
if (isAuthenticated(provider)) {
|
||||||
|
console.log('');
|
||||||
|
console.log(ok(`Authentication successful (${elapsed}s)`));
|
||||||
|
resolve(registerAccountFromToken(provider, tokenDir, nickname));
|
||||||
|
} else {
|
||||||
|
handleTokenNotFound(provider, callbackPort);
|
||||||
|
resolve(null);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
handleProcessError(code, state, headless);
|
||||||
|
resolve(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
authProcess.on('error', (error) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
console.log('');
|
||||||
|
console.log(fail(`Failed to start auth process: ${error.message}`));
|
||||||
|
resolve(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
/**
|
||||||
|
* Token Manager for CLIProxyAPI
|
||||||
|
*
|
||||||
|
* Handles OAuth token storage, retrieval, and validation.
|
||||||
|
* Tokens are stored in ~/.ccs/cliproxy/auth/ directory.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { CLIProxyProvider } from '../types';
|
||||||
|
import { getProviderAuthDir } from '../config-generator';
|
||||||
|
import { getProviderAccounts, getDefaultAccount } from '../account-manager';
|
||||||
|
import {
|
||||||
|
AuthStatus,
|
||||||
|
PROVIDER_AUTH_PREFIXES,
|
||||||
|
PROVIDER_TYPE_VALUES,
|
||||||
|
getOAuthConfig,
|
||||||
|
} from './auth-types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get token directory for provider
|
||||||
|
*/
|
||||||
|
export function getProviderTokenDir(provider: CLIProxyProvider): string {
|
||||||
|
return getProviderAuthDir(provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a JSON file contains a token for the given provider
|
||||||
|
* Reads the file and checks the "type" field
|
||||||
|
*/
|
||||||
|
export function isTokenFileForProvider(filePath: string, provider: CLIProxyProvider): boolean {
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(filePath, 'utf-8');
|
||||||
|
const data = JSON.parse(content);
|
||||||
|
const typeValue = (data.type || '').toLowerCase();
|
||||||
|
const validTypes = PROVIDER_TYPE_VALUES[provider] || [];
|
||||||
|
return validTypes.includes(typeValue);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if provider has valid authentication
|
||||||
|
* CLIProxyAPI stores OAuth tokens as JSON files in the auth directory.
|
||||||
|
* Detection strategy:
|
||||||
|
* 1. First check by filename prefix (fast path)
|
||||||
|
* 2. If no match, check JSON content for "type" field (Gemini uses {email}-{projectID}.json without prefix)
|
||||||
|
*/
|
||||||
|
export function isAuthenticated(provider: CLIProxyProvider): boolean {
|
||||||
|
const tokenDir = getProviderTokenDir(provider);
|
||||||
|
|
||||||
|
if (!fs.existsSync(tokenDir)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validPrefixes = PROVIDER_AUTH_PREFIXES[provider] || [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const files = fs.readdirSync(tokenDir);
|
||||||
|
const jsonFiles = files.filter(
|
||||||
|
(f) => f.endsWith('.json') || f.endsWith('.token') || f === 'credentials'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Strategy 1: Check by filename prefix (fast path for antigravity, codex)
|
||||||
|
const prefixMatch = jsonFiles.some((f) => {
|
||||||
|
const lowerFile = f.toLowerCase();
|
||||||
|
return validPrefixes.some((prefix) => lowerFile.startsWith(prefix));
|
||||||
|
});
|
||||||
|
if (prefixMatch) return true;
|
||||||
|
|
||||||
|
// Strategy 2: Check JSON content for "type" field (needed for Gemini)
|
||||||
|
for (const f of jsonFiles) {
|
||||||
|
const filePath = path.join(tokenDir, f);
|
||||||
|
if (isTokenFileForProvider(filePath, provider)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get detailed auth status for provider
|
||||||
|
* Uses same detection strategy as isAuthenticated: prefix first, then content
|
||||||
|
*/
|
||||||
|
export function getAuthStatus(provider: CLIProxyProvider): AuthStatus {
|
||||||
|
const tokenDir = getProviderTokenDir(provider);
|
||||||
|
let tokenFiles: string[] = [];
|
||||||
|
let lastAuth: Date | undefined;
|
||||||
|
|
||||||
|
const validPrefixes = PROVIDER_AUTH_PREFIXES[provider] || [];
|
||||||
|
|
||||||
|
if (fs.existsSync(tokenDir)) {
|
||||||
|
const files = fs.readdirSync(tokenDir);
|
||||||
|
const jsonFiles = files.filter(
|
||||||
|
(f) => f.endsWith('.json') || f.endsWith('.token') || f === 'credentials'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check each file: by prefix OR by content
|
||||||
|
tokenFiles = jsonFiles.filter((f) => {
|
||||||
|
const lowerFile = f.toLowerCase();
|
||||||
|
// Strategy 1: prefix match
|
||||||
|
if (validPrefixes.some((prefix) => lowerFile.startsWith(prefix))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Strategy 2: content match (for Gemini tokens without prefix)
|
||||||
|
const filePath = path.join(tokenDir, f);
|
||||||
|
return isTokenFileForProvider(filePath, provider);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get most recent modification time
|
||||||
|
for (const file of tokenFiles) {
|
||||||
|
const filePath = path.join(tokenDir, file);
|
||||||
|
try {
|
||||||
|
const stats = fs.statSync(filePath);
|
||||||
|
if (!lastAuth || stats.mtime > lastAuth) {
|
||||||
|
lastAuth = stats.mtime;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Skip if can't stat file
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get registered accounts for multi-account support
|
||||||
|
const accounts = getProviderAccounts(provider);
|
||||||
|
const defaultAccount = getDefaultAccount(provider);
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider,
|
||||||
|
authenticated: tokenFiles.length > 0,
|
||||||
|
tokenDir,
|
||||||
|
tokenFiles,
|
||||||
|
lastAuth,
|
||||||
|
accounts,
|
||||||
|
defaultAccount: defaultAccount?.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get auth status for all providers
|
||||||
|
*/
|
||||||
|
export function getAllAuthStatus(): AuthStatus[] {
|
||||||
|
const providers: CLIProxyProvider[] = ['gemini', 'codex', 'agy', 'qwen', 'iflow'];
|
||||||
|
return providers.map(getAuthStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear authentication for provider
|
||||||
|
* Only removes files belonging to the specified provider (by prefix or content)
|
||||||
|
* Does NOT remove the shared auth directory or other providers' files
|
||||||
|
*/
|
||||||
|
export function clearAuth(provider: CLIProxyProvider): boolean {
|
||||||
|
const tokenDir = getProviderTokenDir(provider);
|
||||||
|
|
||||||
|
if (!fs.existsSync(tokenDir)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validPrefixes = PROVIDER_AUTH_PREFIXES[provider] || [];
|
||||||
|
const files = fs.readdirSync(tokenDir);
|
||||||
|
let removedCount = 0;
|
||||||
|
|
||||||
|
// Only remove files that belong to this provider
|
||||||
|
for (const file of files) {
|
||||||
|
const filePath = path.join(tokenDir, file);
|
||||||
|
const lowerFile = file.toLowerCase();
|
||||||
|
|
||||||
|
// Check by prefix first (fast path)
|
||||||
|
const matchesByPrefix = validPrefixes.some((prefix) => lowerFile.startsWith(prefix));
|
||||||
|
|
||||||
|
// If no prefix match, check by content (for Gemini tokens without prefix)
|
||||||
|
const matchesByContent = !matchesByPrefix && isTokenFileForProvider(filePath, provider);
|
||||||
|
|
||||||
|
if (matchesByPrefix || matchesByContent) {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(filePath);
|
||||||
|
removedCount++;
|
||||||
|
} catch {
|
||||||
|
// Failed to remove - skip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DO NOT remove the shared auth directory - other providers may still have tokens
|
||||||
|
return removedCount > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register account from newly created token file
|
||||||
|
* Scans auth directory for new token and extracts email
|
||||||
|
* @param provider - The CLIProxy provider
|
||||||
|
* @param tokenDir - Directory containing token files
|
||||||
|
* @param nickname - Optional nickname (uses auto-generated from email if not provided)
|
||||||
|
*/
|
||||||
|
export function registerAccountFromToken(
|
||||||
|
provider: CLIProxyProvider,
|
||||||
|
tokenDir: string,
|
||||||
|
nickname?: string
|
||||||
|
): import('../account-manager').AccountInfo | null {
|
||||||
|
const { registerAccount, generateNickname } = require('../account-manager');
|
||||||
|
try {
|
||||||
|
const files = fs.readdirSync(tokenDir);
|
||||||
|
const jsonFiles = files.filter((f: string) => f.endsWith('.json'));
|
||||||
|
|
||||||
|
let newestFile: string | null = null;
|
||||||
|
let newestMtime = 0;
|
||||||
|
|
||||||
|
for (const file of jsonFiles) {
|
||||||
|
const filePath = path.join(tokenDir, file);
|
||||||
|
if (!isTokenFileForProvider(filePath, provider)) continue;
|
||||||
|
|
||||||
|
const stats = fs.statSync(filePath);
|
||||||
|
if (stats.mtimeMs > newestMtime) {
|
||||||
|
newestMtime = stats.mtimeMs;
|
||||||
|
newestFile = file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!newestFile) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenPath = path.join(tokenDir, newestFile);
|
||||||
|
const content = fs.readFileSync(tokenPath, 'utf-8');
|
||||||
|
const data = JSON.parse(content);
|
||||||
|
const email = data.email || undefined;
|
||||||
|
|
||||||
|
return registerAccount(provider, newestFile, email, nickname || generateNickname(email));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display auth status for all providers
|
||||||
|
*/
|
||||||
|
export function displayAuthStatus(): void {
|
||||||
|
console.log('CLIProxy Authentication Status:');
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
const statuses = getAllAuthStatus();
|
||||||
|
|
||||||
|
for (const status of statuses) {
|
||||||
|
const oauthConfig = getOAuthConfig(status.provider);
|
||||||
|
const icon = status.authenticated ? '[OK]' : '[!]';
|
||||||
|
const authStatus = status.authenticated ? 'Authenticated' : 'Not authenticated';
|
||||||
|
const lastAuthStr = status.lastAuth ? ` (last: ${status.lastAuth.toLocaleDateString()})` : '';
|
||||||
|
|
||||||
|
console.log(`${icon} ${oauthConfig.displayName}: ${authStatus}${lastAuthStr}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
console.log('To authenticate: ccs <provider> --auth');
|
||||||
|
console.log('To logout: ccs <provider> --logout');
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
/**
|
||||||
|
* Binary Downloader
|
||||||
|
* Handles downloading files with retry logic, progress tracking, and redirect following.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as https from 'https';
|
||||||
|
import * as http from 'http';
|
||||||
|
import { DownloadResult, ProgressCallback } from '../types';
|
||||||
|
|
||||||
|
/** Default configuration for downloader */
|
||||||
|
export interface DownloaderConfig {
|
||||||
|
/** Maximum retry attempts */
|
||||||
|
maxRetries: number;
|
||||||
|
/** Enable verbose logging */
|
||||||
|
verbose: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG: DownloaderConfig = {
|
||||||
|
maxRetries: 3,
|
||||||
|
verbose: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download file from URL with progress tracking
|
||||||
|
*/
|
||||||
|
export function downloadFile(
|
||||||
|
url: string,
|
||||||
|
destPath: string,
|
||||||
|
onProgress?: ProgressCallback,
|
||||||
|
verbose = false
|
||||||
|
): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const handleResponse = (res: http.IncomingMessage) => {
|
||||||
|
// Handle redirects (GitHub releases use 302)
|
||||||
|
if (res.statusCode === 301 || res.statusCode === 302) {
|
||||||
|
const redirectUrl = res.headers.location;
|
||||||
|
if (!redirectUrl) {
|
||||||
|
reject(new Error('Redirect without location header'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (verbose) {
|
||||||
|
console.error(`[cliproxy] Following redirect: ${redirectUrl}`);
|
||||||
|
}
|
||||||
|
downloadFile(redirectUrl, destPath, onProgress, verbose).then(resolve).catch(reject);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.statusCode !== 200) {
|
||||||
|
reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalBytes = parseInt(res.headers['content-length'] || '0', 10);
|
||||||
|
let downloadedBytes = 0;
|
||||||
|
|
||||||
|
const fileStream = fs.createWriteStream(destPath);
|
||||||
|
|
||||||
|
res.on('data', (chunk: Buffer) => {
|
||||||
|
downloadedBytes += chunk.length;
|
||||||
|
if (onProgress && totalBytes > 0) {
|
||||||
|
onProgress({
|
||||||
|
total: totalBytes,
|
||||||
|
downloaded: downloadedBytes,
|
||||||
|
percentage: Math.round((downloadedBytes / totalBytes) * 100),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
res.pipe(fileStream);
|
||||||
|
|
||||||
|
fileStream.on('finish', () => {
|
||||||
|
fileStream.close();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
fileStream.on('error', (err) => {
|
||||||
|
fs.unlink(destPath, () => {}); // Cleanup partial file
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
res.on('error', (err) => {
|
||||||
|
fs.unlink(destPath, () => {});
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const protocol = url.startsWith('https') ? https : http;
|
||||||
|
const req = protocol.get(url, handleResponse);
|
||||||
|
|
||||||
|
req.on('error', reject);
|
||||||
|
req.setTimeout(60000, () => {
|
||||||
|
req.destroy();
|
||||||
|
reject(new Error('Download timeout (60s)'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download file with retry logic and exponential backoff
|
||||||
|
*/
|
||||||
|
export async function downloadWithRetry(
|
||||||
|
url: string,
|
||||||
|
destPath: string,
|
||||||
|
config: Partial<DownloaderConfig> = {}
|
||||||
|
): Promise<DownloadResult> {
|
||||||
|
const { maxRetries, verbose } = { ...DEFAULT_CONFIG, ...config };
|
||||||
|
let lastError = '';
|
||||||
|
let retries = 0;
|
||||||
|
|
||||||
|
while (retries < maxRetries) {
|
||||||
|
try {
|
||||||
|
await downloadFile(url, destPath, undefined, verbose);
|
||||||
|
return { success: true, filePath: destPath, retries };
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as Error;
|
||||||
|
lastError = err.message;
|
||||||
|
retries++;
|
||||||
|
|
||||||
|
if (retries < maxRetries) {
|
||||||
|
// Exponential backoff: 1s, 2s, 4s
|
||||||
|
const delay = Math.pow(2, retries - 1) * 1000;
|
||||||
|
if (verbose) {
|
||||||
|
console.error(`[cliproxy] Retry ${retries}/${maxRetries} after ${delay}ms: ${lastError}`);
|
||||||
|
}
|
||||||
|
await sleep(delay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Download failed after ${retries} attempts: ${lastError}`,
|
||||||
|
retries,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch text content from URL
|
||||||
|
*/
|
||||||
|
export function fetchText(url: string, verbose = false): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const handleResponse = (res: http.IncomingMessage) => {
|
||||||
|
// Handle redirects
|
||||||
|
if (res.statusCode === 301 || res.statusCode === 302) {
|
||||||
|
const redirectUrl = res.headers.location;
|
||||||
|
if (!redirectUrl) {
|
||||||
|
reject(new Error('Redirect without location header'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fetchText(redirectUrl, verbose).then(resolve).catch(reject);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.statusCode !== 200) {
|
||||||
|
reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = '';
|
||||||
|
res.on('data', (chunk) => (data += chunk));
|
||||||
|
res.on('end', () => resolve(data));
|
||||||
|
res.on('error', reject);
|
||||||
|
};
|
||||||
|
|
||||||
|
const protocol = url.startsWith('https') ? https : http;
|
||||||
|
const req = protocol.get(url, handleResponse);
|
||||||
|
req.on('error', reject);
|
||||||
|
req.setTimeout(30000, () => {
|
||||||
|
req.destroy();
|
||||||
|
reject(new Error('Request timeout (30s)'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch JSON from URL (for GitHub API)
|
||||||
|
*/
|
||||||
|
export function fetchJson(url: string, verbose = false): Promise<Record<string, unknown>> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const options = {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'CCS-CLIProxyAPI-Updater/1.0',
|
||||||
|
Accept: 'application/vnd.github.v3+json',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResponse = (res: http.IncomingMessage) => {
|
||||||
|
if (res.statusCode === 301 || res.statusCode === 302) {
|
||||||
|
const redirectUrl = res.headers.location;
|
||||||
|
if (!redirectUrl) {
|
||||||
|
reject(new Error('Redirect without location header'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fetchJson(redirectUrl, verbose).then(resolve).catch(reject);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.statusCode !== 200) {
|
||||||
|
reject(new Error(`GitHub API error: HTTP ${res.statusCode}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = '';
|
||||||
|
res.on('data', (chunk) => (data += chunk));
|
||||||
|
res.on('end', () => {
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(data));
|
||||||
|
} catch {
|
||||||
|
reject(new Error('Invalid JSON from GitHub API'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
res.on('error', reject);
|
||||||
|
};
|
||||||
|
|
||||||
|
const req = https.get(url, options, handleResponse);
|
||||||
|
req.on('error', reject);
|
||||||
|
req.setTimeout(10000, () => {
|
||||||
|
req.destroy();
|
||||||
|
reject(new Error('GitHub API timeout (10s)'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sleep helper
|
||||||
|
*/
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* Binary Verifier
|
||||||
|
* Handles checksum verification for downloaded binaries.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as crypto from 'crypto';
|
||||||
|
import { ChecksumResult } from '../types';
|
||||||
|
import { fetchText } from './downloader';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute SHA256 checksum of file
|
||||||
|
*/
|
||||||
|
export function computeChecksum(filePath: string): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const hash = crypto.createHash('sha256');
|
||||||
|
const stream = fs.createReadStream(filePath);
|
||||||
|
|
||||||
|
stream.on('data', (data) => hash.update(data));
|
||||||
|
stream.on('end', () => resolve(hash.digest('hex')));
|
||||||
|
stream.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse checksum from checksums.txt content
|
||||||
|
*/
|
||||||
|
export function parseChecksum(content: string, binaryName: string): string | null {
|
||||||
|
const lines = content.split('\n');
|
||||||
|
for (const line of lines) {
|
||||||
|
// Format: "hash filename" or "hash filename"
|
||||||
|
const parts = line.trim().split(/\s+/);
|
||||||
|
if (parts.length >= 2 && parts[1] === binaryName) {
|
||||||
|
return parts[0].toLowerCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify file checksum against checksums.txt
|
||||||
|
*/
|
||||||
|
export async function verifyChecksum(
|
||||||
|
filePath: string,
|
||||||
|
binaryName: string,
|
||||||
|
checksumsUrl: string,
|
||||||
|
verbose = false
|
||||||
|
): Promise<ChecksumResult> {
|
||||||
|
// Download checksums.txt
|
||||||
|
const checksumsContent = await fetchText(checksumsUrl, verbose);
|
||||||
|
|
||||||
|
// Parse expected checksum
|
||||||
|
const expectedHash = parseChecksum(checksumsContent, binaryName);
|
||||||
|
if (!expectedHash) {
|
||||||
|
throw new Error(`Checksum not found for ${binaryName} in checksums.txt`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute actual checksum
|
||||||
|
const actualHash = await computeChecksum(filePath);
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: actualHash === expectedHash,
|
||||||
|
expected: expectedHash,
|
||||||
|
actual: actualHash,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in new issue
Block a user