mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 08:17:11 +00:00
feat(oauth): enhance auth flow with detailed pre-flight checks and real-time progress
This commit is contained in:
+173
-78
@@ -15,12 +15,10 @@
|
||||
import { execSync, spawn } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { ProgressIndicator } from '../utils/progress-indicator';
|
||||
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 { getKillCLIProxyCommand } from '../utils/platform-commands';
|
||||
import {
|
||||
AccountInfo,
|
||||
discoverExistingAccounts,
|
||||
@@ -30,7 +28,10 @@ import {
|
||||
registerAccount,
|
||||
touchAccount,
|
||||
} from './account-manager';
|
||||
import { preflightOAuthCheck } from '../management/oauth-port-diagnostics';
|
||||
import {
|
||||
enhancedPreflightOAuthCheck,
|
||||
OAUTH_CALLBACK_PORTS as OAUTH_PORTS,
|
||||
} from '../management/oauth-port-diagnostics';
|
||||
|
||||
/**
|
||||
* OAuth callback ports used by CLIProxyAPI (hardcoded in binary)
|
||||
@@ -427,9 +428,54 @@ export function clearAuth(provider: CLIProxyProvider): boolean {
|
||||
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) {
|
||||
if (process.platform === 'win32') {
|
||||
lines.push(` 2. Verify port ${port} is reachable: curl http://localhost:${port}`);
|
||||
lines.push(' 3. Windows Firewall may block - run as Administrator:');
|
||||
lines.push(
|
||||
` netsh advfirewall firewall add rule name="CCS OAuth" dir=in action=allow protocol=TCP localport=${port}`
|
||||
);
|
||||
lines.push(` 4. Try: ccs ${provider} --auth --verbose`);
|
||||
} else if (process.platform === 'darwin') {
|
||||
lines.push(` 2. Check for port conflicts: lsof -ti:${port}`);
|
||||
lines.push(` 3. Try: ccs ${provider} --auth --verbose`);
|
||||
} else {
|
||||
lines.push(` 2. Check for port conflicts: lsof -ti:${port} or ss -tlnp | grep ${port}`);
|
||||
lines.push(` 3. Try: ccs ${provider} --auth --verbose`);
|
||||
}
|
||||
} 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
|
||||
@@ -447,6 +493,7 @@ export async function triggerOAuth(
|
||||
): Promise<AccountInfo | null> {
|
||||
const oauthConfig = getOAuthConfig(provider);
|
||||
const { verbose = false, add = false, nickname } = options;
|
||||
const callbackPort = OAUTH_PORTS[provider];
|
||||
|
||||
// Auto-detect headless if not explicitly set
|
||||
const headless = options.headless ?? isHeadlessEnvironment();
|
||||
@@ -487,25 +534,49 @@ export async function triggerOAuth(
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-flight check: verify OAuth callback port is available
|
||||
const preflight = await preflightOAuthCheck(provider);
|
||||
// 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(warn('OAuth pre-flight check failed:'));
|
||||
for (const issue of preflight.issues) {
|
||||
console.log(` ${issue}`);
|
||||
}
|
||||
console.log('');
|
||||
console.log(info('Resolve the port conflict and try again.'));
|
||||
console.log(fail('Pre-flight check failed. Resolve issues above and retry.'));
|
||||
return null;
|
||||
}
|
||||
|
||||
// Ensure binary exists
|
||||
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) {
|
||||
console.error(fail('Failed to prepare CLIProxy binary'));
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -517,13 +588,12 @@ export async function triggerOAuth(
|
||||
const configPath = generateConfig(provider);
|
||||
log(`Config generated: ${configPath}`);
|
||||
|
||||
// Free OAuth callback port if this provider shares it with another
|
||||
// Qwen and Gemini both use port 8085 - kill any existing process to avoid conflict
|
||||
const callbackPort = OAUTH_CALLBACK_PORTS[provider];
|
||||
if (callbackPort) {
|
||||
const killed = killProcessOnPort(callbackPort, verbose);
|
||||
// Free OAuth callback port if needed
|
||||
const localCallbackPort = OAUTH_CALLBACK_PORTS[provider];
|
||||
if (localCallbackPort) {
|
||||
const killed = killProcessOnPort(localCallbackPort, verbose);
|
||||
if (killed) {
|
||||
log(`Freed port ${callbackPort} for OAuth callback`);
|
||||
log(`Freed port ${localCallbackPort} for OAuth callback`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -533,31 +603,23 @@ export async function triggerOAuth(
|
||||
args.push('--no-browser');
|
||||
}
|
||||
|
||||
// Show appropriate message
|
||||
console.log('');
|
||||
// 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(info('Headless mode detected - manual authentication required'));
|
||||
console.log('');
|
||||
console.log(warn('PORT FORWARDING REQUIRED'));
|
||||
console.log(' OAuth callback uses localhost:8085 which must be reachable.');
|
||||
console.log(' Run this on your LOCAL machine (replace <USER> and <HOST>):');
|
||||
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('');
|
||||
console.log(` ${color('ssh -L 8085:localhost:8085 <USER>@<HOST>', 'command')}`);
|
||||
console.log('');
|
||||
console.log(info(`${oauthConfig.displayName} OAuth URL:`));
|
||||
} else {
|
||||
console.log(info(`Opening browser for ${oauthConfig.displayName} authentication...`));
|
||||
console.log(info('Complete the login in your browser.'));
|
||||
console.log('');
|
||||
}
|
||||
|
||||
const spinner = new ProgressIndicator(`Authenticating with ${oauthConfig.displayName}`);
|
||||
if (!headless) {
|
||||
spinner.start();
|
||||
}
|
||||
|
||||
return new Promise<AccountInfo | null>((resolve) => {
|
||||
// Spawn CLIProxyAPI with auth flag (and --no-browser if headless)
|
||||
// Spawn CLIProxyAPI with auth flag
|
||||
const authProcess = spawn(binaryPath, args, {
|
||||
stdio: ['inherit', 'pipe', 'pipe'],
|
||||
env: {
|
||||
@@ -568,23 +630,30 @@ export async function triggerOAuth(
|
||||
|
||||
let stderrData = '';
|
||||
let urlDisplayed = false;
|
||||
let browserOpened = false;
|
||||
const startTime = Date.now();
|
||||
|
||||
authProcess.stdout?.on('data', (data: Buffer) => {
|
||||
const output = data.toString();
|
||||
log(`stdout: ${output.trim()}`);
|
||||
|
||||
// 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 lines = output.split('\n');
|
||||
for (const line of lines) {
|
||||
// Look for URLs in the output
|
||||
const urlMatch = line.match(/https?:\/\/[^\s]+/);
|
||||
if (urlMatch && !urlDisplayed) {
|
||||
console.log(` ${urlMatch[0]}`);
|
||||
console.log('');
|
||||
console.log(info('Waiting for authentication... (press Ctrl+C to cancel)'));
|
||||
urlDisplayed = true;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -594,70 +663,96 @@ export async function triggerOAuth(
|
||||
stderrData += output;
|
||||
log(`stderr: ${output.trim()}`);
|
||||
|
||||
// Also check stderr for URLs (some tools output there)
|
||||
// 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('');
|
||||
console.log(info('Waiting for authentication... (press Ctrl+C to cancel)'));
|
||||
urlDisplayed = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Timeout after 5 minutes for headless (user needs time to copy URL)
|
||||
// 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(() => {
|
||||
if (!headless) spinner.fail('Authentication timeout');
|
||||
authProcess.kill();
|
||||
console.error(fail(`OAuth timed out after ${headless ? 5 : 2} minutes`));
|
||||
console.error('');
|
||||
if (!headless) {
|
||||
console.error('Troubleshooting:');
|
||||
console.error(' - Make sure a browser is available');
|
||||
console.error(' - Try running with --verbose for details');
|
||||
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)) {
|
||||
if (!headless) spinner.succeed(`Authenticated with ${oauthConfig.displayName}`);
|
||||
console.log(ok('Authentication successful'));
|
||||
console.log('');
|
||||
console.log(ok(`Authentication successful (${elapsed}s)`));
|
||||
|
||||
// Register the account in accounts registry
|
||||
const account = registerAccountFromToken(provider, tokenDir, nickname);
|
||||
resolve(account);
|
||||
} else {
|
||||
if (!headless) spinner.fail('Authentication incomplete');
|
||||
console.error(fail('Token not found after authentication'));
|
||||
// Qwen uses Device Code Flow (polling), others use Authorization Code Flow (callback)
|
||||
if (provider === 'qwen') {
|
||||
console.error(' Qwen uses Device Code Flow - ensure you completed auth in browser');
|
||||
console.error(' The polling may have timed out or been cancelled');
|
||||
console.error(' Try: ccs qwen --auth --verbose');
|
||||
} else {
|
||||
console.error(' The OAuth flow may have been cancelled or callback port was in use');
|
||||
console.error(` Try: ${getKillCLIProxyCommand()} && ccs ${provider} --auth`);
|
||||
console.error(' Or run: ccs doctor --fix');
|
||||
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 {
|
||||
if (!headless) spinner.fail('Authentication failed');
|
||||
console.error(fail(`CLIProxyAPI auth exited with code ${code}`));
|
||||
console.log('');
|
||||
console.log(fail(`CLIProxyAPI auth exited with code ${code}`));
|
||||
if (stderrData && !urlDisplayed) {
|
||||
console.error(` ${stderrData.trim().split('\n')[0]}`);
|
||||
console.log(` ${stderrData.trim().split('\n')[0]}`);
|
||||
}
|
||||
// Show headless hint if we detected headless environment
|
||||
if (headless && !urlDisplayed) {
|
||||
console.error('');
|
||||
console.error(info('No OAuth URL was displayed. Try with --verbose for details.'));
|
||||
console.log('');
|
||||
console.log(info('No OAuth URL was displayed. Try with --verbose for details.'));
|
||||
}
|
||||
resolve(null);
|
||||
}
|
||||
@@ -665,8 +760,8 @@ export async function triggerOAuth(
|
||||
|
||||
authProcess.on('error', (error) => {
|
||||
clearTimeout(timeout);
|
||||
if (!headless) spinner.fail('Authentication error');
|
||||
console.error(fail(`Failed to start auth process: ${error.message}`));
|
||||
console.log('');
|
||||
console.log(fail(`Failed to start auth process: ${error.message}`));
|
||||
resolve(null);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,7 +11,15 @@
|
||||
* - Qwen: Device Code Flow (no port needed)
|
||||
*/
|
||||
|
||||
import { getPortProcess, PortProcess, isCLIProxyProcess } from '../utils/port-utils';
|
||||
import {
|
||||
getPortProcess,
|
||||
PortProcess,
|
||||
isCLIProxyProcess,
|
||||
checkWindowsFirewall,
|
||||
testLocalhostBinding,
|
||||
FirewallCheckResult,
|
||||
BindingTestResult,
|
||||
} from '../utils/port-utils';
|
||||
import { CLIProxyProvider } from '../cliproxy/types';
|
||||
|
||||
/**
|
||||
@@ -199,28 +207,139 @@ export function formatOAuthPortDiagnostics(diagnostics: OAuthPortDiagnostic[]):
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced pre-flight check result with detailed diagnostics
|
||||
*/
|
||||
export interface EnhancedPreflightResult {
|
||||
ready: boolean;
|
||||
issues: string[];
|
||||
checks: PreflightCheck[];
|
||||
firewallWarning?: string;
|
||||
firewallFixCommand?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual pre-flight check result
|
||||
*/
|
||||
export interface PreflightCheck {
|
||||
name: string;
|
||||
status: 'ok' | 'warn' | 'fail';
|
||||
message: string;
|
||||
fixCommand?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-flight check before OAuth - returns issues or empty array if OK
|
||||
* @deprecated Use enhancedPreflightOAuthCheck for more detailed diagnostics
|
||||
*/
|
||||
export async function preflightOAuthCheck(provider: CLIProxyProvider): Promise<{
|
||||
ready: boolean;
|
||||
issues: string[];
|
||||
}> {
|
||||
const diagnostic = await checkOAuthPort(provider);
|
||||
const result = await enhancedPreflightOAuthCheck(provider);
|
||||
return {
|
||||
ready: result.ready,
|
||||
issues: result.issues,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced pre-flight check with detailed step-by-step diagnostics
|
||||
* Returns structured results for real-time display
|
||||
*/
|
||||
export async function enhancedPreflightOAuthCheck(
|
||||
provider: CLIProxyProvider
|
||||
): Promise<EnhancedPreflightResult> {
|
||||
const port = OAUTH_CALLBACK_PORTS[provider];
|
||||
const flowType = OAUTH_FLOW_TYPES[provider];
|
||||
const checks: PreflightCheck[] = [];
|
||||
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}`);
|
||||
// Device code flow doesn't need port checks
|
||||
if (flowType === 'device_code' || port === null) {
|
||||
checks.push({
|
||||
name: 'OAuth Flow',
|
||||
status: 'ok',
|
||||
message: 'Uses Device Code Flow (no callback port needed)',
|
||||
});
|
||||
return { ready: true, issues: [], checks };
|
||||
}
|
||||
|
||||
// Check 1: Port availability via process detection
|
||||
const portProcess = await getPortProcess(port);
|
||||
if (portProcess && !isCLIProxyProcess(portProcess)) {
|
||||
checks.push({
|
||||
name: 'Port Availability',
|
||||
status: 'fail',
|
||||
message: `Port ${port} blocked by ${portProcess.processName} (PID ${portProcess.pid})`,
|
||||
fixCommand:
|
||||
process.platform === 'win32'
|
||||
? `taskkill /F /PID ${portProcess.pid}`
|
||||
: `kill ${portProcess.pid}`,
|
||||
});
|
||||
issues.push(`Port ${port} is blocked by ${portProcess.processName} (PID ${portProcess.pid})`);
|
||||
} else if (portProcess && isCLIProxyProcess(portProcess)) {
|
||||
checks.push({
|
||||
name: 'Port Availability',
|
||||
status: 'ok',
|
||||
message: `Port ${port} in use by CLIProxy (OK)`,
|
||||
});
|
||||
} else {
|
||||
checks.push({
|
||||
name: 'Port Availability',
|
||||
status: 'ok',
|
||||
message: `Port ${port} is available`,
|
||||
});
|
||||
}
|
||||
|
||||
// Check 2: Localhost binding test (verifies we can actually listen)
|
||||
const bindingResult: BindingTestResult = await testLocalhostBinding(port);
|
||||
if (!bindingResult.success && !portProcess) {
|
||||
// Only fail if no process found but binding failed (weird state)
|
||||
checks.push({
|
||||
name: 'Localhost Binding',
|
||||
status: 'warn',
|
||||
message: bindingResult.message,
|
||||
});
|
||||
} else if (bindingResult.success) {
|
||||
checks.push({
|
||||
name: 'Localhost Binding',
|
||||
status: 'ok',
|
||||
message: 'Can bind to localhost',
|
||||
});
|
||||
}
|
||||
|
||||
// Check 3: Windows Firewall (only on Windows)
|
||||
let firewallWarning: string | undefined;
|
||||
let firewallFixCommand: string | undefined;
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
const firewallResult: FirewallCheckResult = await checkWindowsFirewall(port);
|
||||
if (firewallResult.mayBlock) {
|
||||
checks.push({
|
||||
name: 'Windows Firewall',
|
||||
status: 'warn',
|
||||
message: firewallResult.message,
|
||||
fixCommand: firewallResult.fixCommand,
|
||||
});
|
||||
firewallWarning = firewallResult.message;
|
||||
firewallFixCommand = firewallResult.fixCommand;
|
||||
// Don't add to issues - just a warning, not a blocker
|
||||
} else if (firewallResult.checked) {
|
||||
checks.push({
|
||||
name: 'Windows Firewall',
|
||||
status: 'ok',
|
||||
message: 'Firewall rules allow port',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ready: issues.length === 0,
|
||||
issues,
|
||||
checks,
|
||||
firewallWarning,
|
||||
firewallFixCommand,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -233,4 +352,5 @@ export default {
|
||||
getPortConflicts,
|
||||
formatOAuthPortDiagnostics,
|
||||
preflightOAuthCheck,
|
||||
enhancedPreflightOAuthCheck,
|
||||
};
|
||||
|
||||
@@ -128,3 +128,91 @@ export function isCLIProxyProcess(process: PortProcess | null): boolean {
|
||||
'cli-proxy-api.exe',
|
||||
].includes(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Windows firewall check result
|
||||
*/
|
||||
export interface FirewallCheckResult {
|
||||
checked: boolean;
|
||||
mayBlock: boolean;
|
||||
message: string;
|
||||
fixCommand?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Windows Firewall might block a port
|
||||
* Returns immediately on non-Windows platforms
|
||||
*/
|
||||
export async function checkWindowsFirewall(port: number): Promise<FirewallCheckResult> {
|
||||
if (process.platform !== 'win32') {
|
||||
return { checked: false, mayBlock: false, message: 'Not Windows' };
|
||||
}
|
||||
|
||||
try {
|
||||
// Check for inbound rules allowing our port
|
||||
const { stdout } = await execAsync(
|
||||
`netsh advfirewall firewall show rule name=all dir=in | findstr /C:"LocalPort" | findstr /C:"${port}"`,
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
|
||||
// If we find the port in firewall rules, it's likely allowed
|
||||
if (stdout.trim()) {
|
||||
return { checked: true, mayBlock: false, message: `Port ${port} found in firewall rules` };
|
||||
}
|
||||
|
||||
// Port not in rules - might be blocked
|
||||
return {
|
||||
checked: true,
|
||||
mayBlock: true,
|
||||
message: `Port ${port} may be blocked by Windows Firewall`,
|
||||
fixCommand: `netsh advfirewall firewall add rule name="CCS OAuth" dir=in action=allow protocol=TCP localport=${port}`,
|
||||
};
|
||||
} catch {
|
||||
// Command failed - could be no matching rules, assume might block
|
||||
return {
|
||||
checked: true,
|
||||
mayBlock: true,
|
||||
message: `Could not verify firewall rules for port ${port}`,
|
||||
fixCommand: `netsh advfirewall firewall add rule name="CCS OAuth" dir=in action=allow protocol=TCP localport=${port}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Localhost binding test result
|
||||
*/
|
||||
export interface BindingTestResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test if we can bind to localhost on a specific port
|
||||
* This verifies network stack is working and port is actually available
|
||||
*/
|
||||
export async function testLocalhostBinding(port: number): Promise<BindingTestResult> {
|
||||
const net = await import('net');
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const server = net.createServer();
|
||||
|
||||
server.once('error', (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === 'EADDRINUSE') {
|
||||
resolve({ success: false, message: `Port ${port} is already in use` });
|
||||
} else if (err.code === 'EACCES') {
|
||||
resolve({ success: false, message: `Permission denied for port ${port}` });
|
||||
} else {
|
||||
resolve({ success: false, message: `Cannot bind to port ${port}: ${err.message}` });
|
||||
}
|
||||
});
|
||||
|
||||
server.once('listening', () => {
|
||||
server.close(() => {
|
||||
resolve({ success: true, message: `Port ${port} is available` });
|
||||
});
|
||||
});
|
||||
|
||||
// Try to bind to localhost only (like CLIProxyAPI does)
|
||||
server.listen(port, '127.0.0.1');
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user