From 98fd1bedb9b38c4900f8cc4049d74347d407d499 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Sun, 7 Dec 2025 08:29:53 -0500 Subject: [PATCH] feat(doctor): improve port detection with process identification - Add port-utils.ts module to detect which process occupies CLIProxy port - Enhance doctor command to show specific process (PID/name) using port - Distinguish between CLIProxy process vs other conflicting processes - Support cross-platform detection (Windows netstat, Unix lsof) - Provide actionable guidance when port conflict detected This helps users identify if CLIProxy is already running or if another application needs to be terminated to free up the port. --- src/management/doctor.ts | 36 +++++++---- src/utils/port-utils.ts | 130 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 155 insertions(+), 11 deletions(-) create mode 100644 src/utils/port-utils.ts diff --git a/src/management/doctor.ts b/src/management/doctor.ts index 3dfaee1b..6f4058a3 100644 --- a/src/management/doctor.ts +++ b/src/management/doctor.ts @@ -13,12 +13,12 @@ import packageJson from '../../package.json'; import { isCLIProxyInstalled, getCLIProxyPath, - isPortAvailable, getAllAuthStatus, getConfigPath, getInstalledCliproxyVersion, CLIPROXY_DEFAULT_PORT, } from '../cliproxy'; +import { getPortProcess, isCLIProxyProcess } from '../utils/port-utils'; // Make ora optional (might not be available during npm install postinstall) // ora v9+ is an ES module, need to use .default for CommonJS @@ -843,26 +843,40 @@ class Doctor { } } - // 4. Port availability + // 4. Port status (check if CLIProxy or other process) const portSpinner = ora(`Checking port ${CLIPROXY_DEFAULT_PORT}`).start(); - const portAvailable = await isPortAvailable(CLIPROXY_DEFAULT_PORT); + const portProcess = await getPortProcess(CLIPROXY_DEFAULT_PORT); - if (portAvailable) { - portSpinner.succeed(); - console.log(` ${ok('CLIProxy Port'.padEnd(22))} ${CLIPROXY_DEFAULT_PORT} available`); + if (!portProcess) { + // Port is free + portSpinner.info(); + console.log( + ` ${info('CLIProxy Port'.padEnd(22))} ${CLIPROXY_DEFAULT_PORT} free (proxy not running)` + ); this.results.addCheck('CLIProxy Port', 'success', undefined, undefined, { status: 'OK', - info: `Port ${CLIPROXY_DEFAULT_PORT} available`, + info: `Port ${CLIPROXY_DEFAULT_PORT} free`, + }); + } else if (isCLIProxyProcess(portProcess)) { + // CLIProxy is running (expected) + portSpinner.succeed(); + console.log(` ${ok('CLIProxy Port'.padEnd(22))} CLIProxy running (PID ${portProcess.pid})`); + this.results.addCheck('CLIProxy Port', 'success', undefined, undefined, { + status: 'OK', + info: `CLIProxy running (PID ${portProcess.pid})`, }); } else { + // Port conflict - different process portSpinner.warn(); - console.log(` ${warn('CLIProxy Port'.padEnd(22))} ${CLIPROXY_DEFAULT_PORT} in use`); + console.log( + ` ${warn('CLIProxy Port'.padEnd(22))} ${CLIPROXY_DEFAULT_PORT} occupied by ${portProcess.processName}` + ); this.results.addCheck( 'CLIProxy Port', 'warning', - `Port ${CLIPROXY_DEFAULT_PORT} is in use`, - `Check: lsof -i :${CLIPROXY_DEFAULT_PORT}`, - { status: 'WARN', info: `Port ${CLIPROXY_DEFAULT_PORT} in use` } + `Port ${CLIPROXY_DEFAULT_PORT} occupied by ${portProcess.processName} (PID ${portProcess.pid})`, + `Kill process: kill ${portProcess.pid} (or restart conflicting application)`, + { status: 'WARN', info: `Occupied by ${portProcess.processName}` } ); } } diff --git a/src/utils/port-utils.ts b/src/utils/port-utils.ts new file mode 100644 index 00000000..37d5c171 --- /dev/null +++ b/src/utils/port-utils.ts @@ -0,0 +1,130 @@ +/** + * Port utilities for detecting process ownership + */ + +import { exec } from 'child_process'; +import { promisify } from 'util'; + +const execAsync = promisify(exec); + +export interface PortProcess { + pid: number; + processName: string; + commandLine?: string; +} + +/** + * Get process information for a port + * @param port Port number to check + * @returns Process info or null if port is free + */ +export async function getPortProcess(port: number): Promise { + const isWindows = process.platform === 'win32'; + + try { + if (isWindows) { + return await getPortProcessWindows(port); + } else { + return await getPortProcessUnix(port); + } + } catch { + // If detection fails, return null (assume port is free) + return null; + } +} + +/** + * Unix/Linux/macOS implementation using lsof + */ +async function getPortProcessUnix(port: number): Promise { + try { + const { stdout } = await execAsync(`lsof -i :${port} -sTCP:LISTEN -t -F pcn`, { + timeout: 3000, + }); + + if (!stdout.trim()) { + return null; // Port free + } + + // Parse lsof -F output: + // p + // c + // n + const lines = stdout.trim().split('\n'); + let pid: number | null = null; + let processName: string | null = null; + + for (const line of lines) { + if (line.startsWith('p')) { + pid = parseInt(line.substring(1), 10); + } else if (line.startsWith('c')) { + processName = line.substring(1); + } + } + + if (pid && processName) { + return { pid, processName }; + } + + return null; + } catch { + return null; + } +} + +/** + * Windows implementation using netstat + */ +async function getPortProcessWindows(port: number): Promise { + try { + // netstat -ano finds PID, then tasklist gets process name + const { stdout: netstatOut } = await execAsync( + `netstat -ano | findstr :${port} | findstr LISTENING`, + { timeout: 3000 } + ); + + if (!netstatOut.trim()) { + return null; // Port free + } + + // Parse netstat output to get PID (last column) + const match = netstatOut.match(/\\s+(\d+)\\s*$/m); + if (!match) { + return null; + } + + const pid = parseInt(match[1], 10); + + // Get process name from PID + const { stdout: tasklistOut } = await execAsync(`tasklist /FI "PID eq ${pid}" /NH`, { + timeout: 3000, + }); + + const taskMatch = tasklistOut.match(/^([^\\s]+)/); + const processName = taskMatch ? taskMatch[1] : `PID-${pid}`; + + return { pid, processName }; + } catch { + return null; + } +} + +/** + * Check if process is CLIProxy + */ +export function isCLIProxyProcess(process: PortProcess | null): boolean { + if (!process) { + return false; + } + + // Match cli-proxy, cli-proxy.exe, cliproxy, cliproxy.exe, cli-proxy-api + const name = process.processName.toLowerCase(); + return [ + 'cli-proxy', + 'cli-proxy.exe', + 'cliproxy', + 'cliproxy.exe', + 'cli-proxy-api', + 'cli-proxy-api.exe', + ].includes(name); +}