From 7d7c8b7321a39fb5e6d0ea134b6d05dbedbdae82 Mon Sep 17 00:00:00 2001 From: ekko <152005280+EKKOLearnAI@users.noreply.github.com> Date: Sat, 16 May 2026 15:44:50 +0800 Subject: [PATCH] fix(cli): make port detection portable (#789) Co-authored-by: Codex --- bin/hermes-web-ui.mjs | 176 ++++++++++++++++-------- tests/server/cli-port-detection.test.ts | 92 +++++++++++++ 2 files changed, 210 insertions(+), 58 deletions(-) create mode 100644 tests/server/cli-port-detection.test.ts diff --git a/bin/hermes-web-ui.mjs b/bin/hermes-web-ui.mjs index 7853f90..9c87e27 100755 --- a/bin/hermes-web-ui.mjs +++ b/bin/hermes-web-ui.mjs @@ -2,11 +2,12 @@ import { spawn, execSync, execFileSync } from 'child_process' import { resolve, dirname, join, delimiter } from 'path' import { fileURLToPath } from 'url' -import { readFileSync, writeFileSync, unlinkSync, mkdirSync, openSync, chmodSync, statSync, existsSync } from 'fs' +import { readFileSync, writeFileSync, unlinkSync, mkdirSync, openSync, chmodSync, statSync, existsSync, realpathSync } from 'fs' import { randomBytes } from 'crypto' import { homedir } from 'os' const __dirname = dirname(fileURLToPath(import.meta.url)) +const __filename = fileURLToPath(import.meta.url) const serverEntry = resolve(__dirname, '..', 'dist', 'server', 'index.js') const pkgDir = resolve(__dirname, '..') const pkg = JSON.parse(readFileSync(resolve(pkgDir, 'package.json'), 'utf-8')) @@ -160,6 +161,39 @@ function getPort() { return argPort ?? DEFAULT_PORT } +function commandExists(command) { + try { + if (process.platform === 'win32') { + execFileSync('where', [command], { stdio: 'ignore', windowsHide: true }) + } else { + execFileSync('sh', ['-c', `command -v "$1" >/dev/null 2>&1`, 'sh', command], { stdio: 'ignore' }) + } + return true + } catch { + return false + } +} + +function parseUnixNetstatListeningPids(out, port) { + const pids = [] + for (const line of out.split(/\r?\n/)) { + const parts = line.trim().split(/\s+/) + if (parts.length < 6) continue + + const proto = parts[0]?.toLowerCase() + if (!proto?.startsWith('tcp')) continue + + const localAddress = parts[3] + const state = parts.find(part => part.toUpperCase() === 'LISTEN' || part.toUpperCase() === 'LISTENING') + if (!state || !localAddress?.endsWith(`:${port}`)) continue + + const pidPart = parts.find(part => /^\d+\//.test(part)) + const pid = pidPart ? parseInt(pidPart.split('/')[0], 10) : NaN + if (Number.isFinite(pid)) pids.push(pid) + } + return pids +} + function getListeningPids(port) { if (!port || isNaN(port)) return [] const uniquePids = (pids) => [...new Set(pids.filter(pid => Number.isFinite(pid)))] @@ -182,17 +216,31 @@ function getListeningPids(port) { return [] } - try { - const out = execSync(`lsof -tiTCP:${port} -sTCP:LISTEN`, { encoding: 'utf-8' }).trim() - return uniquePids(out.split('\n').map(pid => parseInt(pid, 10))) - } catch {} + if (commandExists('ss')) { + try { + const out = execFileSync('ss', ['-ltnp', `sport = :${port}`], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }) + const pids = uniquePids(out.split(/\r?\n/) + .map(line => line.match(/pid=(\d+)/)?.[1]) + .map(pid => parseInt(pid || '', 10))) + if (pids.length) return pids + } catch {} + } - try { - const out = execSync(`ss -ltnp 'sport = :${port}'`, { encoding: 'utf-8' }) - return uniquePids(out.split('\n') - .map(line => line.match(/pid=(\d+)/)?.[1]) - .map(pid => parseInt(pid || '', 10))) - } catch {} + if (commandExists('lsof')) { + try { + const out = execFileSync('lsof', [`-tiTCP:${port}`, '-sTCP:LISTEN'], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() + const pids = uniquePids(out.split(/\r?\n/).map(pid => parseInt(pid, 10))) + if (pids.length) return pids + } catch {} + } + + if (commandExists('netstat')) { + try { + const out = execFileSync('netstat', ['-anp', 'tcp'], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }) + const pids = uniquePids(parseUnixNetstatListeningPids(out, port)) + if (pids.length) return pids + } catch {} + } return [] } @@ -418,15 +466,16 @@ function showStatus() { } } -const command = process.argv[2] || 'start' +function main() { + const command = process.argv[2] || 'start' -if (['-v', '--version', 'version'].includes(command)) { - console.log(`hermes-web-ui v${VERSION}`) - process.exit(0) -} + if (['-v', '--version', 'version'].includes(command)) { + console.log(`hermes-web-ui v${VERSION}`) + process.exit(0) + } -if (['-h', '--help', 'help'].includes(command)) { - console.log(` + if (['-h', '--help', 'help'].includes(command)) { + console.log(` hermes-web-ui v${VERSION} Usage: hermes-web-ui [options] @@ -445,7 +494,49 @@ Options: -h, --help Show this help message --port Specify port (used with start/restart) `) - process.exit(0) + process.exit(0) + } + + switch (command) { + case 'start': + startDaemon(getPort()) + break + case 'stop': + stopDaemon() + break + case 'restart': + stopDaemon() + setTimeout(() => startDaemon(getPort()), 500) + break + case 'status': + showStatus() + break + case 'update': + case 'upgrade': + doUpdate() + break + default: + ensureNativeModules() + const port = !isNaN(command) ? parseInt(command) : DEFAULT_PORT + const windowsShell = process.platform === 'win32' ? getWindowsShell() : null + const serverEnv = { + ...process.env, + NODE_ENV: 'production', + PORT: String(port), + } + if (windowsShell) { + serverEnv.SHELL = serverEnv.SHELL?.trim() || windowsShell + serverEnv.ComSpec = serverEnv.ComSpec?.trim() || windowsShell + } + const child = spawn(process.execPath, [serverEntry], { + stdio: 'inherit', + env: serverEnv, + windowsHide: true, + }) + child.on('exit', (code) => process.exit(code ?? 1)) + process.on('SIGTERM', () => child.kill('SIGTERM')) + process.on('SIGINT', () => child.kill('SIGINT')) + } } function doUpdate() { @@ -503,43 +594,12 @@ function runUpdateInstall(npm) { }) } -switch (command) { - case 'start': - startDaemon(getPort()) - break - case 'stop': - stopDaemon() - break - case 'restart': - stopDaemon() - setTimeout(() => startDaemon(getPort()), 500) - break - case 'status': - showStatus() - break - case 'update': - case 'upgrade': - doUpdate() - break - default: - ensureNativeModules() - const port = !isNaN(command) ? parseInt(command) : DEFAULT_PORT - const windowsShell = process.platform === 'win32' ? getWindowsShell() : null - const serverEnv = { - ...process.env, - NODE_ENV: 'production', - PORT: String(port), - } - if (windowsShell) { - serverEnv.SHELL = serverEnv.SHELL?.trim() || windowsShell - serverEnv.ComSpec = serverEnv.ComSpec?.trim() || windowsShell - } - const child = spawn(process.execPath, [serverEntry], { - stdio: 'inherit', - env: serverEnv, - windowsHide: true, - }) - child.on('exit', (code) => process.exit(code ?? 1)) - process.on('SIGTERM', () => child.kill('SIGTERM')) - process.on('SIGINT', () => child.kill('SIGINT')) +if (process.argv[1] && realpathSync(resolve(process.argv[1])) === __filename) { + main() +} + +export { + commandExists, + getListeningPids, + parseUnixNetstatListeningPids, } diff --git a/tests/server/cli-port-detection.test.ts b/tests/server/cli-port-detection.test.ts new file mode 100644 index 0000000..4d41548 --- /dev/null +++ b/tests/server/cli-port-detection.test.ts @@ -0,0 +1,92 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +type ChildProcessMocks = { + execFileSync: ReturnType + execSync: ReturnType + spawn: ReturnType +} + +async function loadCli(overrides: Partial = {}) { + const execFileSync = overrides.execFileSync ?? vi.fn() + const execSync = overrides.execSync ?? vi.fn() + const spawn = overrides.spawn ?? vi.fn() + + vi.resetModules() + vi.doMock('child_process', () => ({ execFileSync, execSync, spawn })) + + const mod = await import('../../bin/hermes-web-ui.mjs') + return { + ...mod, + mocks: { execFileSync, execSync, spawn }, + } +} + +describe('CLI port detection', () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + + afterEach(() => { + vi.doUnmock('child_process') + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + }) + + it('falls back to lsof without executing ss when ss is unavailable', async () => { + Object.defineProperty(process, 'platform', { value: 'darwin' }) + + const execFileSync = vi.fn((command: string, args: string[]) => { + if (command === 'sh' && args.at(-1) === 'ss') { + throw new Error('not found') + } + if (command === 'sh' && args.at(-1) === 'lsof') { + return '' + } + if (command === 'lsof') { + return '1234\n1234\n' + } + throw new Error(`unexpected command: ${command}`) + }) + const { getListeningPids, mocks } = await loadCli({ execFileSync }) + + expect(getListeningPids(8648)).toEqual([1234]) + expect(mocks.execFileSync).not.toHaveBeenCalledWith( + 'ss', + expect.any(Array), + expect.any(Object), + ) + expect(mocks.execFileSync).toHaveBeenCalledWith( + 'lsof', + ['-tiTCP:8648', '-sTCP:LISTEN'], + expect.objectContaining({ encoding: 'utf-8' }), + ) + }) + + it('uses ss first when available', async () => { + Object.defineProperty(process, 'platform', { value: 'linux' }) + + const execFileSync = vi.fn((command: string, args: string[]) => { + if (command === 'sh' && args.at(-1) === 'ss') { + return '' + } + if (command === 'ss') { + return 'LISTEN 0 511 0.0.0.0:8648 0.0.0.0:* users:(("node",pid=4321,fd=20))\n' + } + throw new Error(`unexpected command: ${command}`) + }) + const { getListeningPids } = await loadCli({ execFileSync }) + + expect(getListeningPids(8648)).toEqual([4321]) + }) + + it('parses Linux netstat listener output as a final fallback', async () => { + const { parseUnixNetstatListeningPids } = await loadCli() + + expect(parseUnixNetstatListeningPids( + [ + 'tcp 0 0 0.0.0.0:8648 0.0.0.0:* LISTEN 2468/node', + 'tcp 0 0 0.0.0.0:5173 0.0.0.0:* LISTEN 1357/node', + ].join('\n'), + 8648, + )).toEqual([2468]) + }) +})