fix: harden web ui self-update restart (#552)

This commit is contained in:
Zhicheng Han
2026-05-10 14:18:52 +02:00
committed by GitHub
parent 96c9338f6a
commit 0d14afe9b4
4 changed files with 148 additions and 36 deletions
+3
View File
@@ -37,6 +37,9 @@ export const useAppStore = defineStore('app', () => {
await checkConnection() await checkConnection()
} }
return res.success return res.success
} catch (err) {
console.error('Failed to update Hermes Web UI:', err)
return false
} finally { } finally {
updating.value = false updating.value = false
} }
+51 -18
View File
@@ -1,42 +1,75 @@
import { execFileSync, spawn } from 'child_process' import { execFileSync, spawn } from 'child_process'
import { join } from 'path' import { existsSync } from 'fs'
import { delimiter, dirname, join } from 'path'
function getNpmBin() { function getNodeBinDir() {
return process.platform === 'win32' ? 'npm.cmd' : 'npm' return dirname(process.execPath)
} }
function getGlobalPrefix() { function getNodePrefix() {
return execFileSync(getNpmBin(), ['prefix', '-g'], { return process.platform === 'win32' ? getNodeBinDir() : dirname(getNodeBinDir())
}
function getNpmCliPath() {
const prefix = getNodePrefix()
const candidates = process.platform === 'win32'
? [
join(prefix, 'node_modules', 'npm', 'bin', 'npm-cli.js'),
join(getNodeBinDir(), 'node_modules', 'npm', 'bin', 'npm-cli.js'),
]
: [join(prefix, 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js')]
const npmCli = candidates.find(existsSync)
if (!npmCli) {
throw new Error(`Unable to locate npm CLI for ${process.execPath}; checked ${candidates.join(', ')}`)
}
return npmCli
}
function getGlobalPackageBin(prefix: string) {
return process.platform === 'win32'
? join(prefix, 'node_modules', 'hermes-web-ui', 'bin', 'hermes-web-ui.mjs')
: join(prefix, 'lib', 'node_modules', 'hermes-web-ui', 'bin', 'hermes-web-ui.mjs')
}
function getCurrentNodeEnv() {
return {
...process.env,
PATH: [getNodeBinDir(), process.env.PATH].filter(Boolean).join(delimiter),
npm_node_execpath: process.execPath,
}
}
function runNpm(args: string[], options: { timeout?: number } = {}) {
return execFileSync(process.execPath, [getNpmCliPath(), ...args], {
encoding: 'utf-8', encoding: 'utf-8',
timeout: options.timeout,
stdio: ['pipe', 'pipe', 'pipe'], stdio: ['pipe', 'pipe', 'pipe'],
env: getCurrentNodeEnv(),
}).trim() }).trim()
} }
function getGlobalCliBin() { function getGlobalPrefix() {
const prefix = getGlobalPrefix() return runNpm(['prefix', '-g'])
if (process.platform === 'win32') {
return join(prefix, 'hermes-web-ui.cmd')
} }
return join(prefix, 'bin', 'hermes-web-ui') function getGlobalCliScript() {
return getGlobalPackageBin(getGlobalPrefix())
} }
function runUpdateInstall() { function runUpdateInstall() {
return execFileSync(getNpmBin(), ['install', '-g', 'hermes-web-ui@latest'], { return runNpm(['install', '-g', 'hermes-web-ui@latest'], { timeout: 10 * 60 * 1000 })
encoding: 'utf-8',
timeout: 10 * 60 * 1000,
stdio: ['pipe', 'pipe', 'pipe'],
})
} }
function spawnRestart(port: string) { function spawnRestart(port: string) {
const cli = getGlobalCliBin() const cli = getGlobalCliScript()
return spawn(cli, ['restart', '--port', port], { return spawn(process.execPath, [cli, 'restart', '--port', port], {
detached: true, detached: true,
stdio: 'ignore', stdio: 'ignore',
windowsHide: true, windowsHide: true,
env: getCurrentNodeEnv(),
}) })
} }
+13
View File
@@ -33,4 +33,17 @@ describe('App Store', () => {
expect(store.sidebarCollapsed).toBe(false) expect(store.sidebarCollapsed).toBe(false)
expect(window.localStorage.getItem('hermes_sidebar_collapsed')).toBe('0') expect(window.localStorage.getItem('hermes_sidebar_collapsed')).toBe('0')
}) })
it('clears the updating state and reports failure when self-update request fails', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
mockSystemApi.triggerUpdate.mockRejectedValue(new Error('install failed'))
const store = useAppStore()
const ok = await store.doUpdate()
expect(ok).toBe(false)
expect(store.updating).toBe(false)
expect(consoleError).toHaveBeenCalledWith('Failed to update Hermes Web UI:', expect.any(Error))
consoleError.mockRestore()
})
}) })
+80 -17
View File
@@ -1,24 +1,27 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { dirname, join } from 'path' import { delimiter, dirname, join } from 'path'
type ChildProcessMocks = { type UpdateControllerMocks = {
execFileSync: ReturnType<typeof vi.fn> execFileSync: ReturnType<typeof vi.fn>
spawn: ReturnType<typeof vi.fn> spawn: ReturnType<typeof vi.fn>
unref: ReturnType<typeof vi.fn> unref: ReturnType<typeof vi.fn>
existsSync: ReturnType<typeof vi.fn>
} }
async function loadUpdateController(overrides: Partial<ChildProcessMocks> = {}) { async function loadUpdateController(overrides: Partial<UpdateControllerMocks> = {}) {
const execFileSync = overrides.execFileSync ?? vi.fn().mockReturnValue('updated') const execFileSync = overrides.execFileSync ?? vi.fn().mockReturnValue('updated')
const unref = overrides.unref ?? vi.fn() const unref = overrides.unref ?? vi.fn()
const spawn = overrides.spawn ?? vi.fn(() => ({ unref })) const spawn = overrides.spawn ?? vi.fn(() => ({ unref }))
const existsSync = overrides.existsSync ?? vi.fn(() => true)
vi.resetModules() vi.resetModules()
vi.doMock('child_process', () => ({ execFileSync, spawn })) vi.doMock('child_process', () => ({ execFileSync, spawn }))
vi.doMock('fs', () => ({ existsSync }))
const mod = await import('../../packages/server/src/controllers/update') const mod = await import('../../packages/server/src/controllers/update')
return { return {
...mod, ...mod,
mocks: { execFileSync, spawn, unref }, mocks: { execFileSync, spawn, unref, existsSync },
} }
} }
@@ -29,6 +32,27 @@ function createMockCtx() {
} }
} }
function getNodeBinDir() {
return dirname(process.execPath)
}
function getNodePrefix() {
return process.platform === 'win32' ? getNodeBinDir() : dirname(getNodeBinDir())
}
function getNpmCliPath() {
const prefix = getNodePrefix()
return process.platform === 'win32'
? join(prefix, 'node_modules', 'npm', 'bin', 'npm-cli.js')
: join(prefix, 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js')
}
function getGlobalCliScript(prefix: string) {
return process.platform === 'win32'
? join(prefix, 'node_modules', 'hermes-web-ui', 'bin', 'hermes-web-ui.mjs')
: join(prefix, 'lib', 'node_modules', 'hermes-web-ui', 'bin', 'hermes-web-ui.mjs')
}
describe('update controller', () => { describe('update controller', () => {
const originalPort = process.env.PORT const originalPort = process.env.PORT
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never) const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never)
@@ -40,6 +64,8 @@ describe('update controller', () => {
afterEach(() => { afterEach(() => {
vi.useRealTimers() vi.useRealTimers()
vi.doUnmock('child_process')
vi.doUnmock('fs')
if (originalPort === undefined) { if (originalPort === undefined) {
delete process.env.PORT delete process.env.PORT
} else { } else {
@@ -47,35 +73,56 @@ describe('update controller', () => {
} }
}) })
it('updates using npm from PATH and restarts via global prefix', async () => { it('updates and restarts through the running Node executable, not PATH shims', async () => {
process.env.PORT = '9129' process.env.PORT = '9129'
const { handleUpdate, mocks } = await loadUpdateController() const nodeBinDir = getNodeBinDir()
const npmCli = getNpmCliPath()
const globalPrefix = getNodePrefix()
const cliScript = getGlobalCliScript(globalPrefix)
const execFileSync = vi.fn((_command: string, args: string[]) => {
if (args[1] === 'prefix') return globalPrefix
return 'updated'
})
const { handleUpdate, mocks } = await loadUpdateController({ execFileSync })
const ctx = createMockCtx() const ctx = createMockCtx()
await handleUpdate(ctx) await handleUpdate(ctx)
expect(mocks.execFileSync).toHaveBeenCalledWith( expect(mocks.execFileSync).toHaveBeenCalledWith(
process.platform === 'win32' ? 'npm.cmd' : 'npm', process.execPath,
['install', '-g', 'hermes-web-ui@latest'], [npmCli, 'install', '-g', 'hermes-web-ui@latest'],
{ expect.objectContaining({
encoding: 'utf-8', encoding: 'utf-8',
timeout: 10 * 60 * 1000, timeout: 10 * 60 * 1000,
stdio: ['pipe', 'pipe', 'pipe'], stdio: ['pipe', 'pipe', 'pipe'],
}, env: expect.objectContaining({
npm_node_execpath: process.execPath,
PATH: expect.stringContaining(`${nodeBinDir}${delimiter}`),
}),
}),
) )
expect(ctx.body).toEqual({ success: true, message: 'updated' }) expect(ctx.body).toEqual({ success: true, message: 'updated' })
vi.runAllTimers() vi.runAllTimers()
// Note: spawn is called with getGlobalCliBin() result expect(mocks.execFileSync).toHaveBeenCalledWith(
process.execPath,
[npmCli, 'prefix', '-g'],
expect.objectContaining({
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
env: expect.objectContaining({ npm_node_execpath: process.execPath }),
}),
)
expect(mocks.spawn).toHaveBeenCalledWith( expect(mocks.spawn).toHaveBeenCalledWith(
expect.any(String), // Dynamic path based on npm prefix -g process.execPath,
['restart', '--port', '9129'], [cliScript, 'restart', '--port', '9129'],
{ expect.objectContaining({
detached: true, detached: true,
stdio: 'ignore', stdio: 'ignore',
windowsHide: true, windowsHide: true,
}, env: expect.objectContaining({ npm_node_execpath: process.execPath }),
}),
) )
expect(mocks.unref).toHaveBeenCalledOnce() expect(mocks.unref).toHaveBeenCalledOnce()
expect(exitSpy).toHaveBeenCalledWith(0) expect(exitSpy).toHaveBeenCalledWith(0)
@@ -90,8 +137,8 @@ describe('update controller', () => {
vi.runAllTimers() vi.runAllTimers()
expect(mocks.spawn).toHaveBeenCalledWith( expect(mocks.spawn).toHaveBeenCalledWith(
expect.any(String), process.execPath,
['restart', '--port', '8648'], [expect.any(String), 'restart', '--port', '8648'],
expect.objectContaining({ detached: true, stdio: 'ignore', windowsHide: true }), expect.objectContaining({ detached: true, stdio: 'ignore', windowsHide: true }),
) )
}) })
@@ -112,4 +159,20 @@ describe('update controller', () => {
expect(mocks.spawn).not.toHaveBeenCalled() expect(mocks.spawn).not.toHaveBeenCalled()
expect(exitSpy).not.toHaveBeenCalled() expect(exitSpy).not.toHaveBeenCalled()
}) })
it('fails closed instead of falling back to PATH npm when the current Node install has no npm CLI', async () => {
const { handleUpdate, mocks } = await loadUpdateController({ existsSync: vi.fn(() => false) })
const ctx = createMockCtx()
await handleUpdate(ctx)
expect(ctx.status).toBe(500)
expect(ctx.body).toEqual({
success: false,
message: expect.stringContaining(`Unable to locate npm CLI for ${process.execPath}`),
})
expect(mocks.execFileSync).not.toHaveBeenCalled()
expect(mocks.spawn).not.toHaveBeenCalled()
expect(exitSpy).not.toHaveBeenCalled()
})
}) })