feat: 灵犀 Studio Web UI 定制版
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Executable
+755
@@ -0,0 +1,755 @@
|
||||
#!/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'
|
||||
|
||||
// ─── 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-web-ui.cmd')
|
||||
: join(prefix, 'bin', 'hermes-web-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 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-web-ui is already running (PID: ${existing})`)
|
||||
console.log(` Use "hermes-web-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], {
|
||||
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 url = `http://localhost:${port}`
|
||||
console.log(` ✓ hermes-web-ui started`)
|
||||
console.log(` ${url}`)
|
||||
console.log(` Log: ${LOG_FILE}`)
|
||||
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() {
|
||||
const stoppedPreviewPids = stopPreviewRuntimeFromCli()
|
||||
const pidFromFile = readPidFile()
|
||||
if (pidFromFile && !isRunning(pidFromFile)) {
|
||||
removePid()
|
||||
console.log(` ✓ hermes-web-ui was not running (cleaned stale PID: ${pidFromFile})`)
|
||||
return
|
||||
}
|
||||
|
||||
const pid = pidFromFile ?? recoverPidFromPort()
|
||||
if (!pid) {
|
||||
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, 'SIGTERM')
|
||||
// Wait briefly for graceful shutdown
|
||||
for (let i = 0; i < 10; i++) {
|
||||
if (!isRunning(pid)) break
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 500)
|
||||
}
|
||||
} 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-web-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-web-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-web-ui v${VERSION}`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
if (['-h', '--help', 'help'].includes(command)) {
|
||||
console.log(`
|
||||
hermes-web-ui v${VERSION}
|
||||
|
||||
Usage: hermes-web-ui <command> [options]
|
||||
|
||||
Commands:
|
||||
start [port] Start the server (default port: ${DEFAULT_PORT})
|
||||
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
|
||||
--port <port> Specify port (used with start/restart)
|
||||
--restart Restart after clear-login-locks
|
||||
`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
switch (command) {
|
||||
case 'start':
|
||||
startDaemon(getPort())
|
||||
break
|
||||
case 'stop':
|
||||
stopDaemon()
|
||||
break
|
||||
case 'restart':
|
||||
stopDaemon()
|
||||
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()
|
||||
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], {
|
||||
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-web-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, ['restart', '--port', String(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,
|
||||
getListeningPids,
|
||||
parseUnixNetstatListeningPids,
|
||||
resetDefaultLogin,
|
||||
stopDaemon,
|
||||
}
|
||||
Reference in New Issue
Block a user