mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 04:17:11 +00:00
feat(doctor): add OAuth diagnostics for Windows headless false positives
Add environment and OAuth port diagnostics to help troubleshoot authentication issues reported by Windows users: - Add ENVIRONMENT section showing platform, SSH, TTY, browser capability - Add OAUTH READINESS section with pre-flight port availability checks - Fix Windows headless false positive (isTTY undefined on npm wrappers) - Add pre-flight OAuth check before auth attempt to fail fast New files: - src/management/environment-diagnostics.ts - src/management/oauth-port-diagnostics.ts Case study: Vietnamese Windows users reported "command hangs" because Windows terminal wrappers don't set isTTY correctly, triggering headless mode when browser should be available.
This commit is contained in:
@@ -27,6 +27,7 @@ import {
|
||||
registerAccount,
|
||||
touchAccount,
|
||||
} from './account-manager';
|
||||
import { preflightOAuthCheck } from '../management/oauth-port-diagnostics';
|
||||
|
||||
/**
|
||||
* OAuth callback ports used by CLIProxyAPI (hardcoded in binary)
|
||||
@@ -90,26 +91,50 @@ function killProcessOnPort(port: number, verbose: boolean): boolean {
|
||||
|
||||
/**
|
||||
* 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
|
||||
// SSH session - always headless
|
||||
if (process.env.SSH_TTY || process.env.SSH_CLIENT || process.env.SSH_CONNECTION) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// No display (Linux/X11)
|
||||
// No display on Linux (X11/Wayland) - headless
|
||||
if (process.platform === 'linux' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Non-interactive (piped stdin) - skip on Windows
|
||||
// Windows npm wrappers don't set isTTY correctly (returns undefined, not 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' && !process.stdin.isTTY) {
|
||||
return true;
|
||||
if (process.platform === 'win32') {
|
||||
return false;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -422,6 +447,19 @@ export async function triggerOAuth(
|
||||
}
|
||||
};
|
||||
|
||||
// Pre-flight check: verify OAuth callback port is available
|
||||
const preflight = await preflightOAuthCheck(provider);
|
||||
if (!preflight.ready) {
|
||||
console.log('');
|
||||
console.log('[!] OAuth pre-flight check failed:');
|
||||
for (const issue of preflight.issues) {
|
||||
console.log(` ${issue}`);
|
||||
}
|
||||
console.log('');
|
||||
console.log('[i] Resolve the port conflict and try again.');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Ensure binary exists
|
||||
let binaryPath: string;
|
||||
try {
|
||||
|
||||
+127
-4
@@ -19,6 +19,8 @@ import {
|
||||
CLIPROXY_DEFAULT_PORT,
|
||||
} from '../cliproxy';
|
||||
import { getPortProcess, isCLIProxyProcess } from '../utils/port-utils';
|
||||
import { getEnvironmentDiagnostics } from './environment-diagnostics';
|
||||
import { checkAuthCodePorts } from './oauth-port-diagnostics';
|
||||
|
||||
// Make ora optional (might not be available during npm install postinstall)
|
||||
// ora v9+ is an ES module, need to use .default for CommonJS
|
||||
@@ -146,31 +148,41 @@ class Doctor {
|
||||
this.checkCcsDirectory();
|
||||
console.log('');
|
||||
|
||||
// Group 2: Configuration
|
||||
// Group 2: Environment (NEW - OAuth readiness diagnostics)
|
||||
console.log(header('ENVIRONMENT'));
|
||||
this.checkEnvironment();
|
||||
console.log('');
|
||||
|
||||
// Group 3: Configuration
|
||||
console.log(header('CONFIGURATION'));
|
||||
this.checkConfigFiles();
|
||||
this.checkClaudeSettings();
|
||||
console.log('');
|
||||
|
||||
// Group 3: Profiles & Delegation
|
||||
// Group 4: Profiles & Delegation
|
||||
console.log(header('PROFILES & DELEGATION'));
|
||||
this.checkProfiles();
|
||||
this.checkInstances();
|
||||
this.checkDelegation();
|
||||
console.log('');
|
||||
|
||||
// Group 4: System Health
|
||||
// Group 5: System Health
|
||||
console.log(header('SYSTEM HEALTH'));
|
||||
this.checkPermissions();
|
||||
this.checkCcsSymlinks();
|
||||
this.checkSettingsSymlinks();
|
||||
console.log('');
|
||||
|
||||
// Group 5: CLIProxy (OAuth profiles)
|
||||
// Group 6: CLIProxy (OAuth profiles)
|
||||
console.log(header('CLIPROXY (OAUTH PROFILES)'));
|
||||
await this.checkCLIProxy();
|
||||
console.log('');
|
||||
|
||||
// Group 7: OAuth Readiness (NEW - port availability)
|
||||
console.log(header('OAUTH READINESS'));
|
||||
await this.checkOAuthPorts();
|
||||
console.log('');
|
||||
|
||||
this.showReport();
|
||||
return this.results;
|
||||
}
|
||||
@@ -766,6 +778,117 @@ class Doctor {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 10.1: Environment detection (OAuth readiness)
|
||||
* Helps diagnose Windows headless false positives
|
||||
*/
|
||||
private checkEnvironment(): void {
|
||||
const spinner = ora('Checking environment').start();
|
||||
const diag = getEnvironmentDiagnostics();
|
||||
|
||||
// Determine overall environment health
|
||||
let envStatus: 'OK' | 'WARN' = 'OK';
|
||||
let envMessage = 'Browser available';
|
||||
|
||||
// Check for potential issues
|
||||
if (diag.detectedHeadless) {
|
||||
if (diag.platform === 'win32' && diag.ttyStatus === 'undefined') {
|
||||
// Windows false positive - this is actually a warning
|
||||
envStatus = 'WARN';
|
||||
envMessage = 'Headless detected (may be false positive on Windows)';
|
||||
} else if (diag.sshSession) {
|
||||
envMessage = 'SSH session (headless mode)';
|
||||
} else {
|
||||
envMessage = 'Headless environment';
|
||||
}
|
||||
}
|
||||
|
||||
if (envStatus === 'WARN') {
|
||||
spinner.warn();
|
||||
console.log(` ${warn('Environment'.padEnd(22))} ${envMessage}`);
|
||||
} else {
|
||||
spinner.succeed();
|
||||
console.log(` ${ok('Environment'.padEnd(22))} ${envMessage}`);
|
||||
}
|
||||
|
||||
// Show key environment details
|
||||
console.log(` ${''.padEnd(24)} Platform: ${diag.platformName}`);
|
||||
if (diag.sshSession) {
|
||||
console.log(` ${''.padEnd(24)} SSH: Yes (${diag.sshReason})`);
|
||||
}
|
||||
if (diag.ttyStatus === 'undefined') {
|
||||
console.log(` ${''.padEnd(24)} TTY: undefined [!]`);
|
||||
}
|
||||
console.log(` ${''.padEnd(24)} Browser: ${diag.browserReason}`);
|
||||
|
||||
this.results.addCheck(
|
||||
'Environment',
|
||||
envStatus === 'OK' ? 'success' : 'warning',
|
||||
envMessage,
|
||||
envStatus === 'WARN' ? 'If browser opens correctly, this warning can be ignored' : undefined,
|
||||
{
|
||||
status: envStatus,
|
||||
info: envMessage,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 10.2: OAuth callback ports availability
|
||||
* Pre-flight check for OAuth authentication
|
||||
*/
|
||||
private async checkOAuthPorts(): Promise<void> {
|
||||
const spinner = ora('Checking OAuth callback ports').start();
|
||||
const portDiagnostics = await checkAuthCodePorts();
|
||||
|
||||
// Count issues
|
||||
const conflicts = portDiagnostics.filter((d) => d.status === 'occupied');
|
||||
|
||||
if (conflicts.length === 0) {
|
||||
spinner.succeed();
|
||||
console.log(` ${ok('OAuth Ports'.padEnd(22))} All callback ports available`);
|
||||
this.results.addCheck('OAuth Ports', 'success', undefined, undefined, {
|
||||
status: 'OK',
|
||||
info: 'All callback ports available',
|
||||
});
|
||||
} else {
|
||||
spinner.warn();
|
||||
console.log(` ${warn('OAuth Ports'.padEnd(22))} ${conflicts.length} port conflict(s)`);
|
||||
this.results.addCheck(
|
||||
'OAuth Ports',
|
||||
'warning',
|
||||
`${conflicts.length} port conflict(s)`,
|
||||
'Close conflicting applications before OAuth',
|
||||
{ status: 'WARN', info: `${conflicts.length} conflict(s)` }
|
||||
);
|
||||
}
|
||||
|
||||
// Show individual port status
|
||||
for (const diag of portDiagnostics) {
|
||||
const providerName = diag.provider.charAt(0).toUpperCase() + diag.provider.slice(1);
|
||||
const portStr = diag.port !== null ? `(${diag.port})` : '';
|
||||
|
||||
let statusIcon: string;
|
||||
switch (diag.status) {
|
||||
case 'free':
|
||||
case 'cliproxy':
|
||||
statusIcon = ok(`${providerName} ${portStr}`.padEnd(20));
|
||||
break;
|
||||
case 'occupied':
|
||||
statusIcon = warn(`${providerName} ${portStr}`.padEnd(20));
|
||||
break;
|
||||
default:
|
||||
statusIcon = info(`${providerName} ${portStr}`.padEnd(20));
|
||||
}
|
||||
|
||||
console.log(` ${statusIcon} ${diag.message}`);
|
||||
|
||||
if (diag.recommendation && diag.status === 'occupied') {
|
||||
console.log(` ${''.padEnd(24)} Fix: ${diag.recommendation}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 11: CLIProxy health (OAuth profiles: gemini, codex, agy, qwen)
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* Environment Diagnostics Module
|
||||
*
|
||||
* Provides detailed environment detection diagnostics for troubleshooting
|
||||
* OAuth authentication issues, particularly for Windows users experiencing
|
||||
* false headless detection.
|
||||
*
|
||||
* Case study: Vietnamese Windows users reported "command hangs" because
|
||||
* Windows terminal wrappers don't set isTTY correctly, triggering
|
||||
* headless mode when browser should be available.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Detailed environment diagnostic information
|
||||
*/
|
||||
export interface EnvironmentDiagnostics {
|
||||
/** Operating system platform */
|
||||
platform: NodeJS.Platform;
|
||||
/** Platform display name */
|
||||
platformName: string;
|
||||
/** Current shell (from SHELL env or detected) */
|
||||
shell: string;
|
||||
/** Terminal program (TERM_PROGRAM env) */
|
||||
termProgram: string | null;
|
||||
/** Whether running in SSH session */
|
||||
sshSession: boolean;
|
||||
/** SSH detection reason */
|
||||
sshReason: string | null;
|
||||
/** stdin TTY status */
|
||||
ttyStatus: boolean | 'undefined';
|
||||
/** stdout TTY status */
|
||||
stdoutTty: boolean | 'undefined';
|
||||
/** Display environment (Linux X11/Wayland) */
|
||||
display: string | null;
|
||||
/** Windows Terminal detected (WT_SESSION env) */
|
||||
windowsTerminal: boolean;
|
||||
/** VS Code integrated terminal detected */
|
||||
vsCodeTerminal: boolean;
|
||||
/** CI environment detected */
|
||||
ciEnvironment: boolean;
|
||||
/** Final headless detection result */
|
||||
detectedHeadless: boolean;
|
||||
/** Reasons why headless was detected (or not) */
|
||||
headlessReasons: string[];
|
||||
/** Browser capability assessment */
|
||||
browserCapability: 'available' | 'unlikely' | 'unknown';
|
||||
/** Browser capability reason */
|
||||
browserReason: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect current shell from environment
|
||||
*/
|
||||
function detectShell(): string {
|
||||
if (process.env.SHELL) {
|
||||
return process.env.SHELL;
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
if (process.env.PSModulePath) {
|
||||
return 'PowerShell';
|
||||
}
|
||||
if (process.env.COMSPEC) {
|
||||
return process.env.COMSPEC;
|
||||
}
|
||||
return 'cmd.exe';
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if running in SSH session
|
||||
*/
|
||||
function checkSshSession(): { isSsh: boolean; reason: string | null } {
|
||||
if (process.env.SSH_TTY) {
|
||||
return { isSsh: true, reason: 'SSH_TTY set' };
|
||||
}
|
||||
if (process.env.SSH_CLIENT) {
|
||||
return { isSsh: true, reason: 'SSH_CLIENT set' };
|
||||
}
|
||||
if (process.env.SSH_CONNECTION) {
|
||||
return { isSsh: true, reason: 'SSH_CONNECTION set' };
|
||||
}
|
||||
return { isSsh: false, reason: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check browser capability based on environment
|
||||
*/
|
||||
function assessBrowserCapability(
|
||||
platform: NodeJS.Platform,
|
||||
sshSession: boolean,
|
||||
display: string | null,
|
||||
windowsTerminal: boolean,
|
||||
vsCodeTerminal: boolean
|
||||
): { capability: 'available' | 'unlikely' | 'unknown'; reason: string } {
|
||||
// SSH session - no browser
|
||||
if (sshSession) {
|
||||
return { capability: 'unlikely', reason: 'SSH session detected' };
|
||||
}
|
||||
|
||||
// Windows desktop - should always have browser
|
||||
if (platform === 'win32') {
|
||||
if (windowsTerminal || vsCodeTerminal) {
|
||||
return { capability: 'available', reason: 'Windows desktop terminal' };
|
||||
}
|
||||
// Even plain cmd.exe on Windows desktop should have browser access
|
||||
return { capability: 'available', reason: 'Windows desktop environment' };
|
||||
}
|
||||
|
||||
// macOS - always has browser unless SSH
|
||||
if (platform === 'darwin') {
|
||||
return { capability: 'available', reason: 'macOS desktop environment' };
|
||||
}
|
||||
|
||||
// Linux - depends on DISPLAY/WAYLAND
|
||||
if (platform === 'linux') {
|
||||
if (display) {
|
||||
return { capability: 'available', reason: `Display available: ${display}` };
|
||||
}
|
||||
return { capability: 'unlikely', reason: 'No DISPLAY or WAYLAND_DISPLAY' };
|
||||
}
|
||||
|
||||
return { capability: 'unknown', reason: 'Unknown platform' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if environment is headless (improved detection)
|
||||
* Returns both result and reasons for transparency
|
||||
*/
|
||||
function detectHeadless(): { isHeadless: boolean; reasons: string[] } {
|
||||
const reasons: string[] = [];
|
||||
|
||||
// SSH session
|
||||
if (process.env.SSH_TTY || process.env.SSH_CLIENT || process.env.SSH_CONNECTION) {
|
||||
reasons.push('SSH session detected');
|
||||
return { isHeadless: true, reasons };
|
||||
}
|
||||
|
||||
// Linux without display
|
||||
if (process.platform === 'linux' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) {
|
||||
reasons.push('Linux without DISPLAY/WAYLAND_DISPLAY');
|
||||
return { isHeadless: true, reasons };
|
||||
}
|
||||
|
||||
// Windows desktop - NEVER headless unless SSH (already checked above)
|
||||
// This fixes false positive on Windows where isTTY is undefined
|
||||
if (process.platform === 'win32') {
|
||||
reasons.push('Windows desktop - browser available');
|
||||
return { isHeadless: false, reasons };
|
||||
}
|
||||
|
||||
// macOS - check for proper terminal
|
||||
if (process.platform === 'darwin') {
|
||||
if (!process.stdin.isTTY) {
|
||||
reasons.push('Non-interactive stdin on macOS');
|
||||
return { isHeadless: true, reasons };
|
||||
}
|
||||
reasons.push('macOS with TTY - browser available');
|
||||
return { isHeadless: false, reasons };
|
||||
}
|
||||
|
||||
// Linux with display - check TTY
|
||||
if (process.platform === 'linux') {
|
||||
if (!process.stdin.isTTY) {
|
||||
reasons.push('Non-interactive stdin on Linux');
|
||||
return { isHeadless: true, reasons };
|
||||
}
|
||||
reasons.push('Linux with display and TTY');
|
||||
return { isHeadless: false, reasons };
|
||||
}
|
||||
|
||||
// Default fallback
|
||||
reasons.push('Default: assuming headless');
|
||||
return { isHeadless: true, reasons };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get comprehensive environment diagnostics
|
||||
*/
|
||||
export function getEnvironmentDiagnostics(): EnvironmentDiagnostics {
|
||||
const platform = process.platform;
|
||||
const ssh = checkSshSession();
|
||||
const display =
|
||||
process.platform === 'linux'
|
||||
? process.env.DISPLAY || process.env.WAYLAND_DISPLAY || null
|
||||
: null;
|
||||
const windowsTerminal = !!process.env.WT_SESSION;
|
||||
const vsCodeTerminal = !!(
|
||||
process.env.VSCODE_GIT_IPC_HANDLE ||
|
||||
process.env.VSCODE_IPC_HOOK_CLI ||
|
||||
process.env.TERM_PROGRAM === 'vscode'
|
||||
);
|
||||
const ciEnvironment = !!(
|
||||
process.env.CI ||
|
||||
process.env.GITHUB_ACTIONS ||
|
||||
process.env.GITLAB_CI ||
|
||||
process.env.JENKINS_URL
|
||||
);
|
||||
|
||||
const browser = assessBrowserCapability(
|
||||
platform,
|
||||
ssh.isSsh,
|
||||
display,
|
||||
windowsTerminal,
|
||||
vsCodeTerminal
|
||||
);
|
||||
const headless = detectHeadless();
|
||||
|
||||
// Platform display name
|
||||
const platformNames: Record<string, string> = {
|
||||
win32: 'Windows',
|
||||
darwin: 'macOS',
|
||||
linux: 'Linux',
|
||||
freebsd: 'FreeBSD',
|
||||
openbsd: 'OpenBSD',
|
||||
};
|
||||
|
||||
return {
|
||||
platform,
|
||||
platformName: platformNames[platform] || platform,
|
||||
shell: detectShell(),
|
||||
termProgram: process.env.TERM_PROGRAM || null,
|
||||
sshSession: ssh.isSsh,
|
||||
sshReason: ssh.reason,
|
||||
ttyStatus: process.stdin.isTTY === undefined ? 'undefined' : process.stdin.isTTY,
|
||||
stdoutTty: process.stdout.isTTY === undefined ? 'undefined' : process.stdout.isTTY,
|
||||
display,
|
||||
windowsTerminal,
|
||||
vsCodeTerminal,
|
||||
ciEnvironment,
|
||||
detectedHeadless: headless.isHeadless,
|
||||
headlessReasons: headless.reasons,
|
||||
browserCapability: browser.capability,
|
||||
browserReason: browser.reason,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if environment should use headless OAuth flow
|
||||
* Uses improved detection that avoids Windows false positives
|
||||
*/
|
||||
export function shouldUseHeadlessAuth(): boolean {
|
||||
return detectHeadless().isHeadless;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format diagnostics for display
|
||||
*/
|
||||
export function formatEnvironmentDiagnostics(diag: EnvironmentDiagnostics): string[] {
|
||||
const lines: string[] = [];
|
||||
|
||||
// Platform
|
||||
lines.push(`Platform ${diag.platformName} (${diag.platform})`);
|
||||
|
||||
// Shell
|
||||
lines.push(`Shell ${diag.shell}`);
|
||||
|
||||
// Terminal
|
||||
if (diag.termProgram) {
|
||||
lines.push(`Terminal Program ${diag.termProgram}`);
|
||||
}
|
||||
if (diag.windowsTerminal) {
|
||||
lines.push(`Windows Terminal Yes`);
|
||||
}
|
||||
if (diag.vsCodeTerminal) {
|
||||
lines.push(`VS Code Terminal Yes`);
|
||||
}
|
||||
|
||||
// SSH
|
||||
lines.push(`SSH Session ${diag.sshSession ? `Yes (${diag.sshReason})` : 'No'}`);
|
||||
|
||||
// TTY
|
||||
const ttyDisplay =
|
||||
diag.ttyStatus === 'undefined' ? 'undefined [!]' : diag.ttyStatus ? 'Yes' : 'No';
|
||||
lines.push(`stdin TTY ${ttyDisplay}`);
|
||||
|
||||
// Display (Linux)
|
||||
if (diag.platform === 'linux') {
|
||||
lines.push(`Display ${diag.display || 'Not set'}`);
|
||||
}
|
||||
|
||||
// CI
|
||||
if (diag.ciEnvironment) {
|
||||
lines.push(`CI Environment Yes`);
|
||||
}
|
||||
|
||||
// Browser capability
|
||||
const browserIcon =
|
||||
diag.browserCapability === 'available'
|
||||
? '[OK]'
|
||||
: diag.browserCapability === 'unlikely'
|
||||
? '[!]'
|
||||
: '[?]';
|
||||
lines.push(`Browser Capability ${browserIcon} ${diag.browserReason}`);
|
||||
|
||||
// Headless detection result
|
||||
const headlessIcon = diag.detectedHeadless ? '[!]' : '[OK]';
|
||||
lines.push(`Headless Mode ${headlessIcon} ${diag.detectedHeadless ? 'Yes' : 'No'}`);
|
||||
for (const reason of diag.headlessReasons) {
|
||||
lines.push(` → ${reason}`);
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
export default {
|
||||
getEnvironmentDiagnostics,
|
||||
shouldUseHeadlessAuth,
|
||||
formatEnvironmentDiagnostics,
|
||||
};
|
||||
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* OAuth Port Diagnostics Module
|
||||
*
|
||||
* Pre-flight checks for OAuth callback ports to detect conflicts
|
||||
* before users attempt authentication.
|
||||
*
|
||||
* OAuth flows require specific localhost ports for callbacks:
|
||||
* - Gemini: 8085
|
||||
* - Codex: 1455
|
||||
* - Agy: 51121
|
||||
* - Qwen: Device Code Flow (no port needed)
|
||||
*/
|
||||
|
||||
import { getPortProcess, PortProcess, isCLIProxyProcess } from '../utils/port-utils';
|
||||
import { CLIProxyProvider } from '../cliproxy/types';
|
||||
|
||||
/**
|
||||
* OAuth callback ports for each provider
|
||||
* Extracted from CLIProxyAPI source
|
||||
*/
|
||||
export const OAUTH_CALLBACK_PORTS: Record<CLIProxyProvider, number | null> = {
|
||||
gemini: 8085,
|
||||
codex: 1455,
|
||||
agy: 51121,
|
||||
qwen: null, // Device Code Flow - no callback port
|
||||
iflow: null, // Device Code Flow - no callback port
|
||||
};
|
||||
|
||||
/**
|
||||
* OAuth flow types
|
||||
*/
|
||||
export type OAuthFlowType = 'authorization_code' | 'device_code';
|
||||
|
||||
/**
|
||||
* OAuth flow type per provider
|
||||
*/
|
||||
export const OAUTH_FLOW_TYPES: Record<CLIProxyProvider, OAuthFlowType> = {
|
||||
gemini: 'authorization_code',
|
||||
codex: 'authorization_code',
|
||||
agy: 'authorization_code',
|
||||
qwen: 'device_code',
|
||||
iflow: 'device_code',
|
||||
};
|
||||
|
||||
/**
|
||||
* Port diagnostic result
|
||||
*/
|
||||
export interface OAuthPortDiagnostic {
|
||||
/** Provider name */
|
||||
provider: CLIProxyProvider;
|
||||
/** OAuth flow type */
|
||||
flowType: OAuthFlowType;
|
||||
/** Callback port (null for device code flow) */
|
||||
port: number | null;
|
||||
/** Port status */
|
||||
status: 'free' | 'occupied' | 'cliproxy' | 'not_applicable';
|
||||
/** Process occupying the port (if any) */
|
||||
process: PortProcess | null;
|
||||
/** Human-readable status message */
|
||||
message: string;
|
||||
/** Recommendation for fixing (if issue detected) */
|
||||
recommendation: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check OAuth port availability for a single provider
|
||||
*/
|
||||
export async function checkOAuthPort(provider: CLIProxyProvider): Promise<OAuthPortDiagnostic> {
|
||||
const port = OAUTH_CALLBACK_PORTS[provider];
|
||||
const flowType = OAUTH_FLOW_TYPES[provider];
|
||||
|
||||
// Device code flow doesn't need callback port
|
||||
if (port === null) {
|
||||
return {
|
||||
provider,
|
||||
flowType,
|
||||
port: null,
|
||||
status: 'not_applicable',
|
||||
process: null,
|
||||
message: 'Uses Device Code Flow (no callback port needed)',
|
||||
recommendation: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if port is in use
|
||||
const portProcess = await getPortProcess(port);
|
||||
|
||||
if (!portProcess) {
|
||||
return {
|
||||
provider,
|
||||
flowType,
|
||||
port,
|
||||
status: 'free',
|
||||
process: null,
|
||||
message: `Port ${port} is available`,
|
||||
recommendation: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if it's CLIProxy (expected if proxy is running)
|
||||
if (isCLIProxyProcess(portProcess)) {
|
||||
return {
|
||||
provider,
|
||||
flowType,
|
||||
port,
|
||||
status: 'cliproxy',
|
||||
process: portProcess,
|
||||
message: `Port ${port} in use by CLIProxy (expected)`,
|
||||
recommendation: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Port is occupied by another process
|
||||
return {
|
||||
provider,
|
||||
flowType,
|
||||
port,
|
||||
status: 'occupied',
|
||||
process: portProcess,
|
||||
message: `Port ${port} occupied by ${portProcess.processName}`,
|
||||
recommendation: `Kill process: kill ${portProcess.pid} (or close ${portProcess.processName})`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check OAuth ports for all providers
|
||||
*/
|
||||
export async function checkAllOAuthPorts(): Promise<OAuthPortDiagnostic[]> {
|
||||
const providers: CLIProxyProvider[] = ['gemini', 'codex', 'agy', 'qwen', 'iflow'];
|
||||
const results: OAuthPortDiagnostic[] = [];
|
||||
|
||||
for (const provider of providers) {
|
||||
const diagnostic = await checkOAuthPort(provider);
|
||||
results.push(diagnostic);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check OAuth ports for providers that use Authorization Code flow only
|
||||
*/
|
||||
export async function checkAuthCodePorts(): Promise<OAuthPortDiagnostic[]> {
|
||||
const providers: CLIProxyProvider[] = ['gemini', 'codex', 'agy'];
|
||||
const results: OAuthPortDiagnostic[] = [];
|
||||
|
||||
for (const provider of providers) {
|
||||
const diagnostic = await checkOAuthPort(provider);
|
||||
results.push(diagnostic);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get providers with port conflicts
|
||||
*/
|
||||
export async function getPortConflicts(): Promise<OAuthPortDiagnostic[]> {
|
||||
const allPorts = await checkAllOAuthPorts();
|
||||
return allPorts.filter((d) => d.status === 'occupied');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format OAuth port diagnostics for display
|
||||
*/
|
||||
export function formatOAuthPortDiagnostics(diagnostics: OAuthPortDiagnostic[]): string[] {
|
||||
const lines: string[] = [];
|
||||
|
||||
for (const diag of diagnostics) {
|
||||
const providerName = diag.provider.charAt(0).toUpperCase() + diag.provider.slice(1);
|
||||
const portStr = diag.port !== null ? `(${diag.port})` : '';
|
||||
|
||||
let statusIcon: string;
|
||||
switch (diag.status) {
|
||||
case 'free':
|
||||
statusIcon = '[OK]';
|
||||
break;
|
||||
case 'cliproxy':
|
||||
statusIcon = '[OK]';
|
||||
break;
|
||||
case 'occupied':
|
||||
statusIcon = '[!]';
|
||||
break;
|
||||
case 'not_applicable':
|
||||
statusIcon = '[i]';
|
||||
break;
|
||||
default:
|
||||
statusIcon = '[?]';
|
||||
}
|
||||
|
||||
const label = `${providerName} ${portStr}`.padEnd(20);
|
||||
lines.push(`${statusIcon} ${label} ${diag.message}`);
|
||||
|
||||
if (diag.recommendation) {
|
||||
lines.push(` → ${diag.recommendation}`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-flight check before OAuth - returns issues or empty array if OK
|
||||
*/
|
||||
export async function preflightOAuthCheck(provider: CLIProxyProvider): Promise<{
|
||||
ready: boolean;
|
||||
issues: string[];
|
||||
}> {
|
||||
const diagnostic = await checkOAuthPort(provider);
|
||||
const issues: string[] = [];
|
||||
|
||||
if (diagnostic.status === 'occupied' && diagnostic.process) {
|
||||
issues.push(
|
||||
`OAuth callback port ${diagnostic.port} is blocked by ${diagnostic.process.processName} (PID ${diagnostic.process.pid})`
|
||||
);
|
||||
if (diagnostic.recommendation) {
|
||||
issues.push(`Fix: ${diagnostic.recommendation}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ready: issues.length === 0,
|
||||
issues,
|
||||
};
|
||||
}
|
||||
|
||||
export default {
|
||||
OAUTH_CALLBACK_PORTS,
|
||||
OAUTH_FLOW_TYPES,
|
||||
checkOAuthPort,
|
||||
checkAllOAuthPorts,
|
||||
checkAuthCodePorts,
|
||||
getPortConflicts,
|
||||
formatOAuthPortDiagnostics,
|
||||
preflightOAuthCheck,
|
||||
};
|
||||
Reference in New Issue
Block a user