diff --git a/bin/hermes-web-ui.mjs b/bin/hermes-web-ui.mjs index b58e36d..7f8c8bc 100755 --- a/bin/hermes-web-ui.mjs +++ b/bin/hermes-web-ui.mjs @@ -48,12 +48,81 @@ function ensureToken() { return token } -function getPort() { +function getNodeBinDir() { + return dirname(process.execPath) +} + +function getNpmBin() { + return join(getNodeBinDir(), process.platform === 'win32' ? 'npm.cmd' : 'npm') +} + +function getCliBin() { + return join(getNodeBinDir(), process.platform === 'win32' ? 'hermes-web-ui.cmd' : 'hermes-web-ui') +} + +function getWindowsShell() { + return process.env.ComSpec || 'cmd.exe' +} + +function quoteForWindowsCommand(value) { + return `"${value.replace(/"/g, '""')}"` +} + +function spawnCli(command, args, options) { + if (process.platform === 'win32') { + const commandLine = `${quoteForWindowsCommand(command)} ${args.map(arg => String(arg)).join(' ')}` + return spawn(getWindowsShell(), ['/d', '/s', '/c', commandLine], options) + } + + return spawn(command, args, options) +} + +function getPortFromArgs() { if (process.argv[3] && !isNaN(process.argv[3])) return parseInt(process.argv[3]) if (process.argv.includes('--port')) return parseInt(process.argv[process.argv.indexOf('--port') + 1]) + return null +} + +function getRunningPort() { + const pid = getPid() + if (!pid || !isRunning(pid)) return null + + try { + if (process.platform === 'win32') { + const out = execSync(`netstat -aon -p tcp | findstr LISTENING | findstr " ${pid}$"`, { encoding: 'utf-8' }).trim() + const line = out.split('\n').find(Boolean) + const address = line?.trim().split(/\s+/)[1] + const port = address?.split(':').pop() + return port ? parseInt(port, 10) : null + } + + const out = execSync(`lsof -Pan -p ${pid} -iTCP -sTCP:LISTEN`, { encoding: 'utf-8' }).trim() + const lines = out.split('\n').slice(1) + for (const line of lines) { + const match = line.match(/:(\d+)\s+\(LISTEN\)$/) + if (match) return parseInt(match[1], 10) + } + } catch {} + + return null +} + +function getUpdatePort() { + const argPort = getPortFromArgs() + if (argPort !== null) return argPort + + const runningPort = getRunningPort() + if (runningPort !== null) return runningPort + + if (process.env.PORT && !isNaN(process.env.PORT)) return parseInt(process.env.PORT) return DEFAULT_PORT } +function getPort() { + const argPort = getPortFromArgs() + return argPort ?? DEFAULT_PORT +} + function getPid() { try { return parseInt(readFileSync(PID_FILE, 'utf-8').trim()) @@ -280,14 +349,9 @@ Options: } function doUpdate() { - const isWin = process.platform === 'win32' - const cmd = isWin - ? 'cmd /c npm install -g hermes-web-ui@latest' - : 'npm install -g hermes-web-ui@latest' - console.log(' ⬆ Updating hermes-web-ui...') - const child = spawn(isWin ? 'cmd' : 'sh', isWin ? ['/c', ...cmd.split(' ')] : ['-c', cmd], { + const child = spawnCli(getNpmBin(), ['install', '-g', 'hermes-web-ui@latest'], { stdio: 'inherit', windowsHide: true, }) @@ -295,8 +359,11 @@ function doUpdate() { child.on('exit', (code) => { if (code === 0) { console.log(' ✓ Update complete, restarting...') - stopDaemon() - setTimeout(() => startDaemon(getPort()), 500) + const restart = spawnCli(getCliBin(), ['restart', '--port', String(getUpdatePort())], { + stdio: 'inherit', + windowsHide: true, + }) + restart.on('exit', (restartCode) => process.exit(restartCode ?? 1)) } else { console.log(' ✗ Update failed') } diff --git a/packages/server/src/controllers/update.ts b/packages/server/src/controllers/update.ts index 8e31b0b..fa4fdcc 100644 --- a/packages/server/src/controllers/update.ts +++ b/packages/server/src/controllers/update.ts @@ -1,19 +1,69 @@ -import { spawn } from 'child_process' +import { execFileSync, spawn } from 'child_process' +import { dirname, join } from 'path' + +function getNodeBinDir() { + return dirname(process.execPath) +} + +function getNpmBin() { + return join(getNodeBinDir(), process.platform === 'win32' ? 'npm.cmd' : 'npm') +} + +function getCliBin() { + return join(getNodeBinDir(), process.platform === 'win32' ? 'hermes-web-ui.cmd' : 'hermes-web-ui') +} + +function getWindowsShell() { + return process.env.ComSpec || 'cmd.exe' +} + +function quoteForWindowsCommand(value: string) { + return `"${value.replace(/"/g, '""')}"` +} + +function runUpdateInstall() { + if (process.platform === 'win32') { + return execFileSync(getWindowsShell(), ['/d', '/s', '/c', `${quoteForWindowsCommand(getNpmBin())} install -g hermes-web-ui@latest`], { + encoding: 'utf-8', + timeout: 120000, + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + }) + } + + return execFileSync(getNpmBin(), ['install', '-g', 'hermes-web-ui@latest'], { + encoding: 'utf-8', + timeout: 120000, + stdio: ['pipe', 'pipe', 'pipe'], + }) +} + +function spawnRestart(port: string) { + if (process.platform === 'win32') { + return spawn(getWindowsShell(), ['/d', '/s', '/c', `${quoteForWindowsCommand(getCliBin())} restart --port ${port}`], { + detached: true, + stdio: 'ignore', + windowsHide: true, + }) + } + + return spawn(getCliBin(), ['restart', '--port', port], { + detached: true, + stdio: 'ignore', + windowsHide: true, + }) +} export async function handleUpdate(ctx: any) { - const isWin = process.platform === 'win32' - const cmd = isWin ? 'cmd /c npm install -g hermes-web-ui@latest' : 'npm install -g hermes-web-ui@latest' try { - const { execSync } = await import('child_process') - const output = execSync(cmd, { encoding: 'utf-8', timeout: 120000, stdio: ['pipe', 'pipe', 'pipe'] }) + const output = runUpdateInstall() ctx.body = { success: true, message: output.trim() } setTimeout(() => { - spawn(isWin ? 'cmd' : 'sh', isWin ? ['/c', 'hermes-web-ui restart'] : ['-c', 'hermes-web-ui restart'], { - detached: true, stdio: 'ignore', windowsHide: true, - }).unref() + spawnRestart(process.env.PORT || '8648').unref() process.exit(0) }, 2000) } catch (err: any) { - ctx.status = 500; ctx.body = { success: false, message: err.stderr || err.message } + ctx.status = 500 + ctx.body = { success: false, message: err.stderr || err.message } } } diff --git a/tests/server/update-controller.test.ts b/tests/server/update-controller.test.ts new file mode 100644 index 0000000..f888811 --- /dev/null +++ b/tests/server/update-controller.test.ts @@ -0,0 +1,115 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { dirname, join } from 'path' + +type ChildProcessMocks = { + execFileSync: ReturnType + spawn: ReturnType + unref: ReturnType +} + +async function loadUpdateController(overrides: Partial = {}) { + const execFileSync = overrides.execFileSync ?? vi.fn().mockReturnValue('updated') + const unref = overrides.unref ?? vi.fn() + const spawn = overrides.spawn ?? vi.fn(() => ({ unref })) + + vi.resetModules() + vi.doMock('child_process', () => ({ execFileSync, spawn })) + + const mod = await import('../../packages/server/src/controllers/update') + return { + ...mod, + mocks: { execFileSync, spawn, unref }, + } +} + +function createMockCtx() { + return { + status: 200, + body: null as unknown, + } +} + +describe('update controller', () => { + const originalPort = process.env.PORT + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never) + + beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + }) + + afterEach(() => { + vi.useRealTimers() + if (originalPort === undefined) { + delete process.env.PORT + } else { + process.env.PORT = originalPort + } + }) + + it('updates using npm from the active node prefix and restarts via the same cli path', async () => { + process.env.PORT = '9129' + const { handleUpdate, mocks } = await loadUpdateController() + const ctx = createMockCtx() + const nodeBinDir = dirname(process.execPath) + + await handleUpdate(ctx) + + expect(mocks.execFileSync).toHaveBeenCalledWith( + join(nodeBinDir, process.platform === 'win32' ? 'npm.cmd' : 'npm'), + ['install', '-g', 'hermes-web-ui@latest'], + { + encoding: 'utf-8', + timeout: 120000, + stdio: ['pipe', 'pipe', 'pipe'], + }, + ) + expect(ctx.body).toEqual({ success: true, message: 'updated' }) + + vi.runAllTimers() + + expect(mocks.spawn).toHaveBeenCalledWith( + join(nodeBinDir, process.platform === 'win32' ? 'hermes-web-ui.cmd' : 'hermes-web-ui'), + ['restart', '--port', '9129'], + { + detached: true, + stdio: 'ignore', + windowsHide: true, + }, + ) + expect(mocks.unref).toHaveBeenCalledOnce() + expect(exitSpy).toHaveBeenCalledWith(0) + }) + + it('falls back to the default port when PORT is not set', async () => { + delete process.env.PORT + const { handleUpdate, mocks } = await loadUpdateController() + const ctx = createMockCtx() + + await handleUpdate(ctx) + vi.runAllTimers() + + expect(mocks.spawn).toHaveBeenCalledWith( + expect.any(String), + ['restart', '--port', '8648'], + expect.objectContaining({ detached: true, stdio: 'ignore', windowsHide: true }), + ) + }) + + it('returns a 500 with stderr when installation fails', async () => { + const execFileSync = vi.fn(() => { + const error = new Error('install failed') as Error & { stderr?: string } + error.stderr = 'engine mismatch' + throw error + }) + const { handleUpdate, mocks } = await loadUpdateController({ execFileSync }) + const ctx = createMockCtx() + + await handleUpdate(ctx) + + expect(ctx.status).toBe(500) + expect(ctx.body).toEqual({ success: false, message: 'engine mismatch' }) + expect(mocks.spawn).not.toHaveBeenCalled() + expect(exitSpy).not.toHaveBeenCalled() + }) +})