fix: harden web ui self-update restart (#552)
This commit is contained in:
@@ -37,6 +37,9 @@ export const useAppStore = defineStore('app', () => {
|
||||
await checkConnection()
|
||||
}
|
||||
return res.success
|
||||
} catch (err) {
|
||||
console.error('Failed to update Hermes Web UI:', err)
|
||||
return false
|
||||
} finally {
|
||||
updating.value = false
|
||||
}
|
||||
|
||||
@@ -1,42 +1,75 @@
|
||||
import { execFileSync, spawn } from 'child_process'
|
||||
import { join } from 'path'
|
||||
import { existsSync } from 'fs'
|
||||
import { delimiter, dirname, join } from 'path'
|
||||
|
||||
function getNpmBin() {
|
||||
return process.platform === 'win32' ? 'npm.cmd' : 'npm'
|
||||
function getNodeBinDir() {
|
||||
return dirname(process.execPath)
|
||||
}
|
||||
|
||||
function getGlobalPrefix() {
|
||||
return execFileSync(getNpmBin(), ['prefix', '-g'], {
|
||||
function getNodePrefix() {
|
||||
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',
|
||||
timeout: options.timeout,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: getCurrentNodeEnv(),
|
||||
}).trim()
|
||||
}
|
||||
|
||||
function getGlobalCliBin() {
|
||||
const prefix = getGlobalPrefix()
|
||||
function 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() {
|
||||
return execFileSync(getNpmBin(), ['install', '-g', 'hermes-web-ui@latest'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 10 * 60 * 1000,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
})
|
||||
return runNpm(['install', '-g', 'hermes-web-ui@latest'], { timeout: 10 * 60 * 1000 })
|
||||
}
|
||||
|
||||
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,
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
env: getCurrentNodeEnv(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -33,4 +33,17 @@ describe('App Store', () => {
|
||||
expect(store.sidebarCollapsed).toBe(false)
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,24 +1,27 @@
|
||||
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>
|
||||
spawn: 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 unref = overrides.unref ?? vi.fn()
|
||||
const spawn = overrides.spawn ?? vi.fn(() => ({ unref }))
|
||||
const existsSync = overrides.existsSync ?? vi.fn(() => true)
|
||||
|
||||
vi.resetModules()
|
||||
vi.doMock('child_process', () => ({ execFileSync, spawn }))
|
||||
vi.doMock('fs', () => ({ existsSync }))
|
||||
|
||||
const mod = await import('../../packages/server/src/controllers/update')
|
||||
return {
|
||||
...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', () => {
|
||||
const originalPort = process.env.PORT
|
||||
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never)
|
||||
@@ -40,6 +64,8 @@ describe('update controller', () => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.doUnmock('child_process')
|
||||
vi.doUnmock('fs')
|
||||
if (originalPort === undefined) {
|
||||
delete process.env.PORT
|
||||
} 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'
|
||||
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()
|
||||
|
||||
await handleUpdate(ctx)
|
||||
|
||||
expect(mocks.execFileSync).toHaveBeenCalledWith(
|
||||
process.platform === 'win32' ? 'npm.cmd' : 'npm',
|
||||
['install', '-g', 'hermes-web-ui@latest'],
|
||||
{
|
||||
process.execPath,
|
||||
[npmCli, 'install', '-g', 'hermes-web-ui@latest'],
|
||||
expect.objectContaining({
|
||||
encoding: 'utf-8',
|
||||
timeout: 10 * 60 * 1000,
|
||||
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' })
|
||||
|
||||
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.any(String), // Dynamic path based on npm prefix -g
|
||||
['restart', '--port', '9129'],
|
||||
{
|
||||
process.execPath,
|
||||
[cliScript, 'restart', '--port', '9129'],
|
||||
expect.objectContaining({
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
},
|
||||
env: expect.objectContaining({ npm_node_execpath: process.execPath }),
|
||||
}),
|
||||
)
|
||||
expect(mocks.unref).toHaveBeenCalledOnce()
|
||||
expect(exitSpy).toHaveBeenCalledWith(0)
|
||||
@@ -90,8 +137,8 @@ describe('update controller', () => {
|
||||
vi.runAllTimers()
|
||||
|
||||
expect(mocks.spawn).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
['restart', '--port', '8648'],
|
||||
process.execPath,
|
||||
[expect.any(String), 'restart', '--port', '8648'],
|
||||
expect.objectContaining({ detached: true, stdio: 'ignore', windowsHide: true }),
|
||||
)
|
||||
})
|
||||
@@ -112,4 +159,20 @@ describe('update controller', () => {
|
||||
expect(mocks.spawn).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()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user