fix(cli): make port detection portable (#789)
Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
+111
-51
@@ -2,11 +2,12 @@
|
|||||||
import { spawn, execSync, execFileSync } from 'child_process'
|
import { spawn, execSync, execFileSync } from 'child_process'
|
||||||
import { resolve, dirname, join, delimiter } from 'path'
|
import { resolve, dirname, join, delimiter } from 'path'
|
||||||
import { fileURLToPath } from 'url'
|
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 { randomBytes } from 'crypto'
|
||||||
import { homedir } from 'os'
|
import { homedir } from 'os'
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
const serverEntry = resolve(__dirname, '..', 'dist', 'server', 'index.js')
|
const serverEntry = resolve(__dirname, '..', 'dist', 'server', 'index.js')
|
||||||
const pkgDir = resolve(__dirname, '..')
|
const pkgDir = resolve(__dirname, '..')
|
||||||
const pkg = JSON.parse(readFileSync(resolve(pkgDir, 'package.json'), 'utf-8'))
|
const pkg = JSON.parse(readFileSync(resolve(pkgDir, 'package.json'), 'utf-8'))
|
||||||
@@ -160,6 +161,39 @@ function getPort() {
|
|||||||
return argPort ?? DEFAULT_PORT
|
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) {
|
function getListeningPids(port) {
|
||||||
if (!port || isNaN(port)) return []
|
if (!port || isNaN(port)) return []
|
||||||
const uniquePids = (pids) => [...new Set(pids.filter(pid => Number.isFinite(pid)))]
|
const uniquePids = (pids) => [...new Set(pids.filter(pid => Number.isFinite(pid)))]
|
||||||
@@ -182,17 +216,31 @@ function getListeningPids(port) {
|
|||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (commandExists('ss')) {
|
||||||
try {
|
try {
|
||||||
const out = execSync(`lsof -tiTCP:${port} -sTCP:LISTEN`, { encoding: 'utf-8' }).trim()
|
const out = execFileSync('ss', ['-ltnp', `sport = :${port}`], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] })
|
||||||
return uniquePids(out.split('\n').map(pid => parseInt(pid, 10)))
|
const pids = uniquePids(out.split(/\r?\n/)
|
||||||
} 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(line => line.match(/pid=(\d+)/)?.[1])
|
||||||
.map(pid => parseInt(pid || '', 10)))
|
.map(pid => parseInt(pid || '', 10)))
|
||||||
|
if (pids.length) return pids
|
||||||
} catch {}
|
} 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 []
|
return []
|
||||||
}
|
}
|
||||||
@@ -418,14 +466,15 @@ function showStatus() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const command = process.argv[2] || 'start'
|
function main() {
|
||||||
|
const command = process.argv[2] || 'start'
|
||||||
|
|
||||||
if (['-v', '--version', 'version'].includes(command)) {
|
if (['-v', '--version', 'version'].includes(command)) {
|
||||||
console.log(`hermes-web-ui v${VERSION}`)
|
console.log(`hermes-web-ui v${VERSION}`)
|
||||||
process.exit(0)
|
process.exit(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (['-h', '--help', 'help'].includes(command)) {
|
if (['-h', '--help', 'help'].includes(command)) {
|
||||||
console.log(`
|
console.log(`
|
||||||
hermes-web-ui v${VERSION}
|
hermes-web-ui v${VERSION}
|
||||||
|
|
||||||
@@ -446,6 +495,48 @@ Options:
|
|||||||
--port <port> Specify port (used with start/restart)
|
--port <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() {
|
function doUpdate() {
|
||||||
@@ -503,43 +594,12 @@ function runUpdateInstall(npm) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (command) {
|
if (process.argv[1] && realpathSync(resolve(process.argv[1])) === __filename) {
|
||||||
case 'start':
|
main()
|
||||||
startDaemon(getPort())
|
}
|
||||||
break
|
|
||||||
case 'stop':
|
export {
|
||||||
stopDaemon()
|
commandExists,
|
||||||
break
|
getListeningPids,
|
||||||
case 'restart':
|
parseUnixNetstatListeningPids,
|
||||||
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'))
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
type ChildProcessMocks = {
|
||||||
|
execFileSync: ReturnType<typeof vi.fn>
|
||||||
|
execSync: ReturnType<typeof vi.fn>
|
||||||
|
spawn: ReturnType<typeof vi.fn>
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCli(overrides: Partial<ChildProcessMocks> = {}) {
|
||||||
|
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])
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user