Files
hermes-ui/bin/hermes-ui.mjs
T
toor 1e9ea89d86
Build / build (push) Canceled after 0s
NPM Lockfile Check / npm ci --ignore-scripts (push) Canceled after 0s
Playwright / e2e (push) Canceled after 0s
chore: bootstrap hermes-ui skeleton from 0.6.29 snapshot
Import the client, server, skills, tests, and build scripts as the starting
point for the rebuild, then trim the surfaces this project does not ship:
Electron desktop packaging, the marketing website, and the ESP32 firmware
tree. CI keeps the web build, e2e, lockfile, release, and Docker workflows.

Rename the package and CLI entry points to hermes-ui, drop the prepare hook so
npm ci no longer triggers a build, and drop the removed tsconfig and nodemon
references. Rewrite AGENTS.md, ARCHITECTURE.md, DEVELOPMENT.md, and the READMEs
so the documented package map and hard rules match this repository.

reference_code/ is ignored: it is a read-only snapshot, not an input to the build.
2026-10-16 10:34:35 +08:00

822 lines
24 KiB
JavaScript
Executable File

#!/usr/bin/env node
import { spawn, execSync, execFileSync } from 'child_process'
import { resolve, dirname, join, delimiter } from 'path'
import { fileURLToPath } from 'url'
import { readFileSync, writeFileSync, unlinkSync, mkdirSync, openSync, chmodSync, statSync, existsSync, realpathSync } from 'fs'
import { randomBytes, scryptSync } from 'crypto'
import { homedir } from 'os'
const __dirname = dirname(fileURLToPath(import.meta.url))
const __filename = fileURLToPath(import.meta.url)
const serverEntry = resolve(__dirname, '..', 'dist', 'server', 'index.js')
const pkgDir = resolve(__dirname, '..')
const pkg = JSON.parse(readFileSync(resolve(pkgDir, 'package.json'), 'utf-8'))
const VERSION = pkg.version
const WEB_UI_HOME = process.env.HERMES_WEB_UI_HOME?.trim()
? resolve(process.env.HERMES_WEB_UI_HOME.trim())
: resolve(homedir(), '.hermes-web-ui')
const PID_DIR = WEB_UI_HOME
const PID_FILE = join(PID_DIR, 'server.pid')
const LOG_FILE = join(PID_DIR, 'server.log')
const TOKEN_FILE = join(PID_DIR, '.token')
const LOGIN_LOCK_FILE = join(WEB_UI_HOME, '.login-lock.json')
const WEB_UI_DB_FILE = join(WEB_UI_HOME, 'hermes-web-ui.db')
const DEFAULT_PORT = 8648
const PREVIEW_BACKEND_PORT = 8650
const PREVIEW_FRONTEND_PORT = 8651
const PREVIEW_AGENT_BRIDGE_PORT = 18650
const DEFAULT_USERNAME = 'admin'
const DEFAULT_PASSWORD = '123456'
const DEFAULT_RESTART_GRACE_MS = 5000
const DEFAULT_STOP_GRACE_MS = 15000
const STOP_POLL_INTERVAL_MS = 500
function envPositiveInt(name) {
const value = Number(process.env[name])
return Number.isFinite(value) && value > 0 ? value : undefined
}
function shouldPreserveBridgeOnShutdown() {
const raw = String(process.env.HERMES_AGENT_BRIDGE_STOP_ON_SHUTDOWN || '').trim().toLowerCase()
return ['0', 'false', 'no', 'off'].includes(raw)
}
function getDaemonStopGraceMs(options = {}) {
const { restart = false } = options
if (restart && shouldPreserveBridgeOnShutdown()) {
return envPositiveInt('HERMES_WEB_UI_RESTART_GRACE_MS') ?? DEFAULT_RESTART_GRACE_MS
}
if (restart) {
return envPositiveInt('HERMES_WEB_UI_RESTART_GRACE_MS')
?? envPositiveInt('HERMES_WEB_UI_STOP_GRACE_MS')
?? DEFAULT_STOP_GRACE_MS
}
return envPositiveInt('HERMES_WEB_UI_STOP_GRACE_MS') ?? DEFAULT_STOP_GRACE_MS
}
// ─── Auto-fix node-pty native module ──────────────────────────
function ensureNativeModules() {
const prebuildDir = join(pkgDir, 'node_modules', 'node-pty', 'prebuilds', `${process.platform}-${process.arch}`)
const helper = join(prebuildDir, 'spawn-helper')
try {
chmodSync(helper, 0o755)
} catch {}
}
function getToken() {
try {
return readFileSync(TOKEN_FILE, 'utf-8').trim()
} catch {
return null
}
}
function ensureToken() {
// If AUTH_TOKEN is set, let server handle it.
if (process.env.AUTH_TOKEN) return process.env.AUTH_TOKEN
let token = getToken()
if (!token) {
mkdirSync(dirname(TOKEN_FILE), { recursive: true })
token = randomBytes(32).toString('hex')
writeFileSync(TOKEN_FILE, token + '\n', { mode: 0o600 })
}
return token
}
function getNodeBinDir() {
return dirname(process.execPath)
}
function getNpmBin() {
return join(getNodeBinDir(), process.platform === 'win32' ? 'npm.cmd' : 'npm')
}
function getCurrentNodeEnv() {
return {
...process.env,
PATH: [getNodeBinDir(), process.env.PATH].filter(Boolean).join(delimiter),
npm_node_execpath: process.execPath,
}
}
function getGlobalPrefix() {
return execFileSync(getNpmBin(), ['prefix', '-g'], {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
env: getCurrentNodeEnv(),
}).trim()
}
function getGlobalCliBin() {
const prefix = getGlobalPrefix()
return process.platform === 'win32'
? join(prefix, 'hermes-ui.cmd')
: join(prefix, 'bin', 'hermes-ui')
}
function getWindowsShell() {
const systemRoot = process.env.SystemRoot || 'C:\\Windows'
const candidates = [
process.env.ComSpec,
join(systemRoot, 'System32', 'cmd.exe'),
].filter(Boolean)
for (const candidate of candidates) {
if (existsSync(candidate)) return candidate
}
return 'cmd.exe'
}
function quoteForWindowsCommand(value) {
return `"${value.replace(/"/g, '""')}"`
}
function spawnCli(command, args, options) {
if (process.platform === 'win32') {
const lowerCommand = String(command).toLowerCase()
if (!lowerCommand.endsWith('.cmd') && !lowerCommand.endsWith('.bat')) {
return spawn(command, args, options)
}
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 shouldOpenBrowser(argv = process.argv) {
return !argv.includes('--no-open')
}
function getRestartArgs(port, argv = process.argv) {
const args = ['restart', '--port', String(port)]
if (!shouldOpenBrowser(argv)) args.push('--no-open')
return args
}
function enableClientMode() {
process.env.HERMES_WEB_UI_DISABLE_GATEWAY_AUTOSTART = '1'
process.env.CORS_ORIGINS = '*'
}
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) {
if (!port || isNaN(port)) return []
const uniquePids = (pids) => [...new Set(pids.filter(pid => Number.isFinite(pid)))]
try {
if (process.platform === 'win32') {
const out = execSync('netstat -aon -p tcp', { encoding: 'utf-8' })
return uniquePids(out.split('\n')
.map(line => line.trim())
.filter(line => line.includes('LISTENING'))
.map(line => line.split(/\s+/))
.filter(parts => {
const address = parts[1] || ''
const listenPort = parseInt(address.split(':').pop(), 10)
return listenPort === port
})
.map(parts => parseInt(parts[parts.length - 1], 10)))
}
} catch {
return []
}
if (commandExists('ss')) {
try {
const out = execFileSync('ss', ['-ltnp', `sport = :${port}`], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] })
const pids = uniquePids(out.split(/\r?\n/)
.map(line => line.match(/pid=(\d+)/)?.[1])
.map(pid => parseInt(pid || '', 10)))
if (pids.length) return pids
} 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 []
}
function killListeningPids(port, pids = getListeningPids(port)) {
if (pids.length === 0) return
console.log(` ⚠ Port ${port} is in use by PID(s): ${pids.join(' ')}, killing...`)
try {
if (process.platform === 'win32') {
execSync(`taskkill /F /PID ${pids.join(' /PID ')}`, { encoding: 'utf-8' })
} else {
execSync(`kill -9 ${pids.join(' ')}`, { encoding: 'utf-8' })
}
} catch {}
}
function stopPreviewRuntimeFromCli() {
const previewPorts = [
PREVIEW_BACKEND_PORT,
PREVIEW_FRONTEND_PORT,
...(process.platform === 'win32' ? [PREVIEW_AGENT_BRIDGE_PORT] : []),
]
const pids = [...new Set(previewPorts.flatMap(port => getListeningPids(port)))]
if (!pids.length) return 0
console.log(` ⏹ Stopping preview runtime (PID(s): ${pids.join(' ')})...`)
for (const pid of pids) {
try {
if (process.platform === 'win32') {
execFileSync('taskkill.exe', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true })
} else {
execSync(`kill -TERM -${pid}`, { stdio: 'ignore' })
}
} catch {
try {
if (process.platform === 'win32') {
execFileSync('taskkill.exe', ['/PID', String(pid), '/F'], { stdio: 'ignore', windowsHide: true })
} else {
execSync(`kill -9 ${pid}`, { stdio: 'ignore' })
}
} catch {}
}
}
return pids.length
}
function recoverPidFromPort() {
const port = getPortFromArgs() ?? DEFAULT_PORT
for (const pid of getListeningPids(port)) {
if (isRunning(pid)) {
mkdirSync(PID_DIR, { recursive: true })
writePid(pid)
return pid
}
}
return null
}
function readPidFile() {
try {
const pid = parseInt(readFileSync(PID_FILE, 'utf-8').trim())
return Number.isFinite(pid) ? pid : null
} catch {}
return null
}
function getPid() {
const pid = readPidFile()
if (pid) {
if (isRunning(pid)) return pid
removePid()
}
return recoverPidFromPort()
}
function isRunning(pid) {
try {
process.kill(pid, 0)
return true
} catch (err) {
return err?.code === 'EPERM'
}
}
function writePid(pid) {
writeFileSync(PID_FILE, String(pid))
}
function removePid() {
try { unlinkSync(PID_FILE) } catch {}
}
function startDaemon(port) {
const existing = getPid()
if (existing && isRunning(existing)) {
console.log(` ✗ hermes-ui is already running (PID: ${existing})`)
console.log(` Use "hermes-ui stop" to stop it first`)
process.exit(1)
}
removePid()
// Check if port is already in use
const occupied = getListeningPids(port)
if (occupied.length) {
killListeningPids(port, occupied)
// Brief wait for port to be released
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 500)
}
mkdirSync(PID_DIR, { recursive: true })
ensureNativeModules()
const token = ensureToken()
// Rotate log if over 3MB — keep last 2000 lines
const MAX_LOG_SIZE = 3 * 1024 * 1024
const MAX_LOG_LINES = 2000
try {
const stat = statSync(LOG_FILE)
if (stat.size > MAX_LOG_SIZE) {
const content = readFileSync(LOG_FILE, 'utf-8')
const lines = content.split('\n')
const kept = lines.slice(-MAX_LOG_LINES)
writeFileSync(LOG_FILE, kept.join('\n'), 'utf-8')
console.log(` ↻ Log rotated (${(stat.size / 1024 / 1024).toFixed(1)}MB → ${kept.length} lines)`)
}
} catch { }
const logStream = openSync(LOG_FILE, 'a')
const windowsShell = process.platform === 'win32' ? getWindowsShell() : null
const serverEnv = { ...process.env, NODE_ENV: 'production', PORT: String(port), AUTH_TOKEN: token }
if (windowsShell) {
serverEnv.SHELL = serverEnv.SHELL?.trim() || windowsShell
serverEnv.ComSpec = serverEnv.ComSpec?.trim() || windowsShell
}
const child = spawn(process.execPath, [serverEntry], {
cwd: pkgDir,
detached: true,
stdio: ['ignore', logStream, logStream],
env: serverEnv,
windowsHide: true,
})
child.on('error', (err) => {
console.error(` ✗ Failed to start: ${err.message}`)
removePid()
process.exit(1)
})
child.unref()
writePid(child.pid)
// Poll health endpoint until server is ready (setTimeout to avoid overlapping requests)
const healthUrl = `http://127.0.0.1:${port}/health`
const maxWait = 30000
const interval = 500
let waited = 0
console.log(` ⏳ Starting hermes-web-ui (PID: ${child.pid}, port: ${port})...`)
function poll() {
waited += interval
if (!isRunning(child.pid)) {
console.log(' ✗ Failed to start hermes-web-ui')
console.log(` Check log: ${LOG_FILE}`)
removePid()
process.exit(1)
return
}
fetch(healthUrl).then(res => {
if (res.ok) {
const listeningPid = recoverPidFromPort()
if (listeningPid) {
writePid(listeningPid)
}
const url = `http://localhost:${port}`
console.log(` ✓ hermes-ui started`)
console.log(` ${url}`)
console.log(` Log: ${LOG_FILE}`)
if (shouldOpenBrowser()) {
const isWin = process.platform === 'win32'
const cmd = isWin ? `start ${url}` : process.platform === 'darwin' ? `open ${url}` : `xdg-open ${url}`
try { execSync(cmd, { stdio: 'ignore' }) } catch {}
}
} else if (waited < maxWait) {
setTimeout(poll, interval)
} else {
console.log(` ⚠ Server process is running but health check failed after ${maxWait / 1000}s`)
console.log(` Check log: ${LOG_FILE}`)
const url = `http://localhost:${port}`
console.log(` ${url}`)
}
}).catch(() => {
if (waited < maxWait) {
setTimeout(poll, interval)
} else {
console.log(` ⚠ Server process is running but health check failed after ${maxWait / 1000}s`)
console.log(` Check log: ${LOG_FILE}`)
const url = `http://localhost:${port}`
console.log(` ${url}`)
}
})
}
setTimeout(poll, interval)
}
function stopDaemon(options = {}) {
const { restart = false } = options
const stoppedPreviewPids = stopPreviewRuntimeFromCli()
let pidFromFile = readPidFile()
let cleanedStalePid = false
if (pidFromFile && !isRunning(pidFromFile)) {
removePid()
console.log(` ✓ hermes-web-ui was not running (cleaned stale PID: ${pidFromFile})`)
pidFromFile = null
cleanedStalePid = true
}
const pid = pidFromFile ?? recoverPidFromPort()
if (!pid) {
if (cleanedStalePid) return
if (stoppedPreviewPids) {
console.log(` ✓ hermes-web-ui preview stopped`)
return
}
console.log(' ✗ hermes-web-ui is not running')
process.exit(1)
}
if (!isRunning(pid)) {
removePid()
console.log(` ✓ hermes-web-ui was not running (cleaned stale PID)`)
return
}
try {
try {
process.kill(pid, restart ? 'SIGUSR2' : 'SIGTERM')
// Restart uses a shorter grace window than stop. By default the server
// still shuts down the bridge; set HERMES_AGENT_BRIDGE_STOP_ON_SHUTDOWN=0
// to keep the bridge across restarts.
const graceMs = getDaemonStopGraceMs({ restart })
const attempts = Math.max(1, Math.ceil(graceMs / STOP_POLL_INTERVAL_MS))
for (let i = 0; i < attempts; i++) {
if (!isRunning(pid)) break
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, STOP_POLL_INTERVAL_MS)
}
} catch {}
// Force kill if still alive
if (isRunning(pid)) {
try {
process.kill(pid, 'SIGKILL')
} catch (err) {
if (err?.code !== 'ESRCH') throw err
}
}
removePid()
console.log(` ✓ hermes-ui stopped (PID: ${pid})`)
} catch (err) {
console.log(` ✗ Failed to stop: ${err.message}`)
process.exit(1)
}
}
function showStatus() {
const pid = getPid()
if (pid && isRunning(pid)) {
console.log(` ✓ hermes-web-ui is running (PID: ${pid})`)
console.log(` PID file: ${PID_FILE}`)
} else {
if (pid) removePid()
console.log(' ✗ hermes-web-ui is not running')
}
}
function clearLoginLocks(options = {}) {
const { silent = false, checkRunning = true } = options
const serverRunning = checkRunning ? !!getPid() : false
let removed = false
try {
unlinkSync(LOGIN_LOCK_FILE)
removed = true
if (!silent) console.log(` ✓ Removed login lock file: ${LOGIN_LOCK_FILE}`)
} catch (err) {
if (err?.code === 'ENOENT') {
if (!silent) console.log(` ✓ No login lock file found: ${LOGIN_LOCK_FILE}`)
} else {
if (!silent) console.log(` ✗ Failed to remove login lock file: ${err.message}`)
throw err
}
}
if (!silent && serverRunning) {
console.log(' ⚠ hermes-web-ui is running; restart it to clear in-memory login locks.')
console.log(' Run: hermes-ui restart')
}
return { path: LOGIN_LOCK_FILE, removed, serverRunning }
}
function hashPassword(password) {
const salt = randomBytes(16).toString('hex')
const hash = scryptSync(password, salt, 64).toString('hex')
return `scrypt:${salt}:${hash}`
}
async function resetDefaultLogin(options = {}) {
const { silent = false } = options
mkdirSync(WEB_UI_HOME, { recursive: true })
const { DatabaseSync } = await import('node:sqlite')
const db = new DatabaseSync(WEB_UI_DB_FILE)
try {
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'admin',
status TEXT NOT NULL DEFAULT 'active',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
last_login_at INTEGER
)
`)
const now = Date.now()
const passwordHash = hashPassword(DEFAULT_PASSWORD)
const existing = db.prepare('SELECT id FROM users WHERE username = ?').get(DEFAULT_USERNAME)
if (existing?.id) {
db.prepare(
`UPDATE users
SET password_hash = ?, role = 'super_admin', status = 'active', updated_at = ?
WHERE id = ?`
).run(passwordHash, now, existing.id)
if (!silent) {
console.log(` ✓ Reset default login: ${DEFAULT_USERNAME} / ${DEFAULT_PASSWORD}`)
console.log(` Database: ${WEB_UI_DB_FILE}`)
}
return { path: WEB_UI_DB_FILE, username: DEFAULT_USERNAME, password: DEFAULT_PASSWORD, action: 'updated' }
}
db.prepare(
`INSERT INTO users (username, password_hash, role, status, created_at, updated_at)
VALUES (?, ?, 'super_admin', 'active', ?, ?)`
).run(DEFAULT_USERNAME, passwordHash, now, now)
if (!silent) {
console.log(` ✓ Created default login: ${DEFAULT_USERNAME} / ${DEFAULT_PASSWORD}`)
console.log(` Database: ${WEB_UI_DB_FILE}`)
}
return { path: WEB_UI_DB_FILE, username: DEFAULT_USERNAME, password: DEFAULT_PASSWORD, action: 'created' }
} finally {
db.close()
}
}
async function main() {
const command = process.argv[2] || 'start'
if (['-v', '--version', 'version'].includes(command)) {
console.log(`hermes-ui v${VERSION}`)
process.exit(0)
}
if (['-h', '--help', 'help'].includes(command)) {
console.log(`
hermes-ui v${VERSION}
Usage: hermes-ui <command> [options]
Commands:
start [port] Start the server (default port: ${DEFAULT_PORT})
client [port] Start server for a remote client (disable gateway autostart, allow all CORS)
stop Stop the server
restart [port] Restart the server
status Show server status
clear-login-locks Delete the login IP lock file
reset-default-login Create or reset the default login (${DEFAULT_USERNAME} / ${DEFAULT_PASSWORD})
update Update to latest version and restart
upgrade Alias for update
version Show version number
Options:
-v, --version Show version number
-h, --help Show this help message
--no-open Do not open a browser after startup
--port <port> Specify port (used with start/client/restart)
--restart Restart after clear-login-locks
`)
process.exit(0)
}
switch (command) {
case 'start':
startDaemon(getPort())
break
case 'client':
enableClientMode()
startDaemon(getPort())
break
case 'stop':
stopDaemon()
break
case 'restart':
stopDaemon({ restart: true })
setTimeout(() => startDaemon(getPort()), 500)
break
case 'status':
showStatus()
break
case 'clear-login-locks': {
const restartAfterClear = process.argv.includes('--restart')
const result = clearLoginLocks()
if (restartAfterClear && result.serverRunning) {
const port = getRunningPort() ?? getPort()
stopDaemon({ restart: true })
setTimeout(() => startDaemon(port), 500)
}
break
}
case 'reset-default-login':
await resetDefaultLogin()
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], {
cwd: pkgDir,
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() {
console.log(' ⬆ Updating hermes-web-ui...')
const npm = getNpmBin()
try {
console.log(' 🧹 Cleaning npm cache...')
execFileSync(npm, ['cache', 'clean', '--force'], {
stdio: 'inherit',
env: getCurrentNodeEnv(),
})
} catch (err) {
console.log(` ⚠ Failed to clean npm cache, continuing update: ${err?.message || err}`)
}
runUpdateInstall(npm)
}
function runUpdateInstall(npm) {
const child = spawnCli(npm, ['install', '-g', 'hermes-ui@latest'], {
stdio: 'inherit',
windowsHide: true,
env: getCurrentNodeEnv(),
})
child.on('error', (err) => {
console.log(` ✗ Update failed: ${err.message}`)
process.exit(1)
})
child.on('exit', (code) => {
if (code === 0) {
console.log(' ✓ Update complete, restarting...')
const cli = getGlobalCliBin()
if (!existsSync(cli)) {
console.log(` ✗ Updated CLI not found: ${cli}`)
process.exit(1)
}
const restart = spawnCli(cli, getRestartArgs(getUpdatePort()), {
stdio: 'inherit',
windowsHide: true,
env: getCurrentNodeEnv(),
})
restart.on('error', (err) => {
console.log(` ✗ Restart failed: ${err.message}`)
process.exit(1)
})
restart.on('exit', (restartCode) => process.exit(restartCode ?? 1))
} else {
console.log(' ✗ Update failed')
process.exit(code ?? 1)
}
})
}
if (process.argv[1] && realpathSync(resolve(process.argv[1])) === __filename) {
main().catch(err => {
console.error(` ✗ ${err?.message || err}`)
process.exit(1)
})
}
export {
clearLoginLocks,
commandExists,
getDaemonStopGraceMs,
getListeningPids,
getRestartArgs,
parseUnixNetstatListeningPids,
resetDefaultLogin,
shouldOpenBrowser,
stopDaemon,
}