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.
This commit is contained in:
kaitranntt
2025-12-07 08:29:53 -05:00
parent d65dd63042
commit 98fd1bedb9
2 changed files with 155 additions and 11 deletions
+25 -11
View File
@@ -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}` }
);
}
}
+130
View File
@@ -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<PortProcess | null> {
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<PortProcess | null> {
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<pid>
// c<command>
// n<network>
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<PortProcess | null> {
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);
}