fix: keep self-update on the active install path (#123)
This commit is contained in:
+76
-9
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { dirname, join } from 'path'
|
||||
|
||||
type ChildProcessMocks = {
|
||||
execFileSync: ReturnType<typeof vi.fn>
|
||||
spawn: ReturnType<typeof vi.fn>
|
||||
unref: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
async function loadUpdateController(overrides: Partial<ChildProcessMocks> = {}) {
|
||||
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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user