[codex] add version preview workflow (#1086)
* add version preview workflow * fix sidebar group test * fix legacy usage schema migration
This commit is contained in:
@@ -1,8 +1,106 @@
|
||||
import { execFileSync, spawn } from 'child_process'
|
||||
import { existsSync } from 'fs'
|
||||
import { delimiter, dirname, join } from 'path'
|
||||
import { execFileSync, spawn, type ChildProcess } from 'child_process'
|
||||
import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'fs'
|
||||
import { createServer } from 'net'
|
||||
import { delimiter, dirname, join, resolve } from 'path'
|
||||
import { getWebUiHome } from '../config'
|
||||
|
||||
let updateInProgress = false
|
||||
let previewProcess: ChildProcess | null = null
|
||||
|
||||
const PREVIEW_DIR_NAME = 'hermes-web-ui-pereview'
|
||||
const PREVIEW_HOME_DIR_NAME = 'hermes-web-ui-pereview-home'
|
||||
const PREVIEW_BACKEND_PORT = 8650
|
||||
const PREVIEW_FRONTEND_PORT = 8651
|
||||
const PREVIEW_AGENT_BRIDGE_PORT = 18650
|
||||
const PREVIEW_FRONTEND_URL = `http://localhost:${PREVIEW_FRONTEND_PORT}`
|
||||
const PREVIEW_TAG_REF_PATTERN = /^[A-Za-z0-9._/-]+$/
|
||||
const PREVIEW_MAIN_REF = 'main'
|
||||
|
||||
interface PackageInfo {
|
||||
name: string
|
||||
version: string
|
||||
repositoryUrl?: string
|
||||
}
|
||||
|
||||
function readPackageInfo(): PackageInfo | null {
|
||||
const candidatePaths = [
|
||||
// ts-node dev: packages/server/src/controllers -> repo root
|
||||
resolve(__dirname, '../../../../package.json'),
|
||||
// bundled server: dist/server -> repo root/package root
|
||||
resolve(__dirname, '../../package.json'),
|
||||
// fallback for processes started at the repo root
|
||||
resolve(process.cwd(), 'package.json'),
|
||||
]
|
||||
|
||||
for (const packagePath of candidatePaths) {
|
||||
if (!existsSync(packagePath)) continue
|
||||
try {
|
||||
const pkg = JSON.parse(readFileSync(packagePath, 'utf-8'))
|
||||
if (pkg?.name && pkg?.version) {
|
||||
const repository = typeof pkg.repository === 'string'
|
||||
? pkg.repository
|
||||
: typeof pkg.repository?.url === 'string'
|
||||
? pkg.repository.url
|
||||
: ''
|
||||
return {
|
||||
name: String(pkg.name),
|
||||
version: String(pkg.version),
|
||||
repositoryUrl: repository,
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function normalizeGithubRepoUrl(raw: string): string {
|
||||
return raw
|
||||
.trim()
|
||||
.replace(/^git\+/, '')
|
||||
.replace(/^git@github\.com:/, 'https://github.com/')
|
||||
.replace(/\.git$/, '')
|
||||
}
|
||||
|
||||
function getPreviewRepoBaseUrl(): string {
|
||||
const configured = process.env.HERMES_WEB_UI_PREVIEW_REPO?.trim()
|
||||
const repository = configured || readPackageInfo()?.repositoryUrl || ''
|
||||
const normalized = normalizeGithubRepoUrl(repository)
|
||||
if (!normalized) throw new Error('Preview repository is not configured')
|
||||
return normalized
|
||||
}
|
||||
|
||||
function getPreviewRepoGitUrl(): string {
|
||||
return `${getPreviewRepoBaseUrl()}.git`
|
||||
}
|
||||
|
||||
function getPreviewRepoApiUrl(): string {
|
||||
const baseUrl = getPreviewRepoBaseUrl()
|
||||
const match = baseUrl.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+)$/)
|
||||
if (!match) throw new Error(`Preview zip fallback only supports GitHub repositories: ${baseUrl}`)
|
||||
return `https://api.github.com/repos/${match[1]}/${match[2]}`
|
||||
}
|
||||
|
||||
function getPreviewGithubRepoParts(): { owner: string; repo: string } {
|
||||
const baseUrl = getPreviewRepoBaseUrl()
|
||||
const match = baseUrl.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+)$/)
|
||||
if (!match) throw new Error(`Preview zip fallback only supports GitHub repositories: ${baseUrl}`)
|
||||
return { owner: match[1], repo: match[2] }
|
||||
}
|
||||
|
||||
function listPreviewTagsWithGit(): Array<{ name: string; sha: string }> {
|
||||
const output = runGit(['ls-remote', '--tags', '--refs', getPreviewRepoGitUrl()])
|
||||
return output
|
||||
.split(/\r?\n/)
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean)
|
||||
.map(line => {
|
||||
const [sha, ref] = line.split(/\s+/)
|
||||
return { sha: sha || '', name: (ref || '').replace(/^refs\/tags\//, '') }
|
||||
})
|
||||
.filter(tag => tag.name)
|
||||
.reverse()
|
||||
}
|
||||
|
||||
function getNodeBinDir() {
|
||||
return dirname(process.execPath)
|
||||
@@ -55,20 +153,495 @@ function getCurrentNodeEnv() {
|
||||
}
|
||||
}
|
||||
|
||||
function runNpm(args: string[], options: { timeout?: number } = {}) {
|
||||
function runNpm(args: string[], options: { timeout?: number; cwd?: string; logLabel?: string; env?: NodeJS.ProcessEnv } = {}) {
|
||||
const npmCli = getNpmCliPath()
|
||||
const command = npmCli ? process.execPath : getNpmBin()
|
||||
const commandArgs = npmCli ? [npmCli, ...args] : args
|
||||
const label = options.logLabel || ''
|
||||
|
||||
return execFileSync(command, commandArgs, {
|
||||
if (label) appendPreviewActionLog(`${label}: ${command} ${commandArgs.join(' ')}${options.cwd ? `\ncwd: ${options.cwd}` : ''}`)
|
||||
try {
|
||||
const output = execFileSync(command, commandArgs, {
|
||||
encoding: 'utf-8',
|
||||
timeout: options.timeout,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...getCurrentNodeEnv(),
|
||||
...options.env,
|
||||
},
|
||||
cwd: options.cwd,
|
||||
windowsHide: true,
|
||||
}).trim()
|
||||
if (label) {
|
||||
if (output) appendPreviewActionLog(`${label} output:\n${output}`)
|
||||
appendPreviewActionLog(`${label} completed`)
|
||||
}
|
||||
return output
|
||||
} catch (err: any) {
|
||||
if (label) {
|
||||
const stderr = err.stderr?.toString() || ''
|
||||
const stdout = err.stdout?.toString() || ''
|
||||
appendPreviewActionLog(`${label} failed`)
|
||||
if (stdout) appendPreviewActionLog(`${label} stdout:\n${stdout}`)
|
||||
if (stderr) appendPreviewActionLog(`${label} stderr:\n${stderr}`)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
function getPreviewDir() {
|
||||
return join(getWebUiHome(), PREVIEW_DIR_NAME)
|
||||
}
|
||||
|
||||
function getPreviewHomeDir() {
|
||||
return join(getWebUiHome(), PREVIEW_HOME_DIR_NAME)
|
||||
}
|
||||
|
||||
function getPreviewAgentBridgeEndpoint() {
|
||||
return process.platform === 'win32'
|
||||
? `tcp://127.0.0.1:${PREVIEW_AGENT_BRIDGE_PORT}`
|
||||
: `ipc://${join(getPreviewHomeDir(), 'agent-bridge.sock')}`
|
||||
}
|
||||
|
||||
function getPreviewPackagePath() {
|
||||
return join(getPreviewDir(), 'package.json')
|
||||
}
|
||||
|
||||
function getPreviewLogPath() {
|
||||
return join(getPreviewDir(), 'preview-dev.log')
|
||||
}
|
||||
|
||||
function getPreviewActionLogPath() {
|
||||
return join(getPreviewDir(), 'preview-action.log')
|
||||
}
|
||||
|
||||
function getPreviewInstallEnv() {
|
||||
return {
|
||||
NODE_ENV: 'development',
|
||||
npm_config_production: 'false',
|
||||
npm_config_omit: '',
|
||||
NPM_CONFIG_PRODUCTION: 'false',
|
||||
NPM_CONFIG_OMIT: '',
|
||||
}
|
||||
}
|
||||
|
||||
function readLogTail(path: string, maxChars = 24_000): string {
|
||||
if (!existsSync(path)) return ''
|
||||
const raw = readFileSync(path, 'utf-8')
|
||||
return raw.length > maxChars ? raw.slice(raw.length - maxChars) : raw
|
||||
}
|
||||
|
||||
function getCurrentPreviewTag() {
|
||||
const tagPath = join(getPreviewDir(), '.preview-tag')
|
||||
if (!existsSync(tagPath)) return ''
|
||||
try {
|
||||
return readFileSync(tagPath, 'utf-8').trim()
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function appendPreviewActionLog(message: string) {
|
||||
mkdirSync(getPreviewDir(), { recursive: true })
|
||||
appendFileSync(getPreviewActionLogPath(), `[${new Date().toISOString()}] ${message}\n`, 'utf-8')
|
||||
}
|
||||
|
||||
function previewPayload(extra: Record<string, any> = {}) {
|
||||
return {
|
||||
...extra,
|
||||
...getPreviewStatus(),
|
||||
action_log: readLogTail(getPreviewActionLogPath()),
|
||||
dev_log: readLogTail(getPreviewLogPath()),
|
||||
}
|
||||
}
|
||||
|
||||
function getPreviewStatus() {
|
||||
const previewDir = getPreviewDir()
|
||||
const packagePath = getPreviewPackagePath()
|
||||
const exists = existsSync(previewDir)
|
||||
const hasPackage = existsSync(packagePath)
|
||||
const installed = hasPackage && getMissingPreviewDependencyBins().length === 0
|
||||
const running = Boolean(previewProcess?.pid && !previewProcess.killed)
|
||||
const currentTag = getCurrentPreviewTag()
|
||||
|
||||
return {
|
||||
preview_dir: previewDir,
|
||||
exists,
|
||||
has_package: hasPackage,
|
||||
installed,
|
||||
running,
|
||||
pid: running ? previewProcess?.pid : null,
|
||||
current_tag: currentTag,
|
||||
frontend_url: PREVIEW_FRONTEND_URL,
|
||||
agent_bridge_endpoint: getPreviewAgentBridgeEndpoint(),
|
||||
log_path: getPreviewLogPath(),
|
||||
action_log_path: getPreviewActionLogPath(),
|
||||
dev_log_path: getPreviewLogPath(),
|
||||
webui_home: getPreviewHomeDir(),
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function isPortAvailable(port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const server = createServer()
|
||||
server.once('error', () => resolve(false))
|
||||
server.once('listening', () => {
|
||||
server.close(() => resolve(true))
|
||||
})
|
||||
server.listen(port, '127.0.0.1')
|
||||
})
|
||||
}
|
||||
|
||||
async function assertPreviewPortsAvailable() {
|
||||
const ports = [
|
||||
PREVIEW_BACKEND_PORT,
|
||||
PREVIEW_FRONTEND_PORT,
|
||||
...(process.platform === 'win32' ? [PREVIEW_AGENT_BRIDGE_PORT] : []),
|
||||
]
|
||||
const checks = await Promise.all(ports.map(port => isPortAvailable(port)))
|
||||
const busy = ports.filter((_, index) => !checks[index])
|
||||
|
||||
if (busy.length) {
|
||||
throw new Error(`Preview port(s) already in use: ${busy.join(', ')}. Stop the existing dev server and try again.`)
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForPreviewReady(timeoutMs = 30_000) {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
let lastError = ''
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
if (!previewProcess || previewProcess.killed) {
|
||||
throw new Error(`Preview process exited before it became ready. Check log: ${getPreviewLogPath()}`)
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${PREVIEW_FRONTEND_PORT}/`, {
|
||||
signal: AbortSignal.timeout(1500),
|
||||
})
|
||||
if (res.ok) return
|
||||
lastError = `HTTP ${res.status}`
|
||||
} catch (err: any) {
|
||||
lastError = err.message || String(err)
|
||||
}
|
||||
|
||||
await sleep(1000)
|
||||
}
|
||||
|
||||
throw new Error(`Preview did not become ready on port ${PREVIEW_FRONTEND_PORT}. Last error: ${lastError}. Check log: ${getPreviewLogPath()}`)
|
||||
}
|
||||
|
||||
function openPreviewLogFile() {
|
||||
mkdirSync(getPreviewDir(), { recursive: true })
|
||||
writeFileSync(getPreviewLogPath(), `[preview] starting ${new Date().toISOString()}\n`, 'utf-8')
|
||||
return openSync(getPreviewLogPath(), 'a')
|
||||
}
|
||||
|
||||
async function stopPreviewProcess() {
|
||||
const child = previewProcess
|
||||
if (!child?.pid || child.killed) {
|
||||
previewProcess = null
|
||||
return
|
||||
}
|
||||
|
||||
appendPreviewActionLog(`stopping preview process pid=${child.pid}`)
|
||||
try {
|
||||
if (process.platform === 'win32') {
|
||||
spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true })
|
||||
} else {
|
||||
try {
|
||||
process.kill(-child.pid, 'SIGTERM')
|
||||
} catch {
|
||||
child.kill('SIGTERM')
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
child.kill()
|
||||
}
|
||||
|
||||
previewProcess = null
|
||||
await sleep(800)
|
||||
}
|
||||
|
||||
function assertPreviewPackage() {
|
||||
const packagePath = getPreviewPackagePath()
|
||||
if (!existsSync(packagePath)) {
|
||||
throw new Error(`Preview package.json not found: ${packagePath}`)
|
||||
}
|
||||
|
||||
const pkg = JSON.parse(readFileSync(packagePath, 'utf-8'))
|
||||
if (pkg?.name !== 'hermes-web-ui') {
|
||||
throw new Error(`Preview directory is not hermes-web-ui: ${getPreviewDir()}`)
|
||||
}
|
||||
}
|
||||
|
||||
function getPreviewBinPath(name: string) {
|
||||
return join(getPreviewDir(), 'node_modules', '.bin', process.platform === 'win32' ? `${name}.cmd` : name)
|
||||
}
|
||||
|
||||
function getPreviewNodePtyError() {
|
||||
if (!existsSync(join(getPreviewDir(), 'node_modules', 'node-pty'))) {
|
||||
return 'node-pty'
|
||||
}
|
||||
|
||||
try {
|
||||
execFileSync(process.execPath, ['-e', "require('node-pty')"], {
|
||||
cwd: getPreviewDir(),
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
timeout: 30_000,
|
||||
windowsHide: true,
|
||||
})
|
||||
return ''
|
||||
} catch (err: any) {
|
||||
return `node-pty (${err.stderr?.toString().trim() || err.message || String(err)})`
|
||||
}
|
||||
}
|
||||
|
||||
function getMissingPreviewDependencyBins() {
|
||||
if (!existsSync(join(getPreviewDir(), 'node_modules'))) {
|
||||
return ['node_modules']
|
||||
}
|
||||
|
||||
const missing = ['concurrently', 'vite', 'nodemon'].filter(name => !existsSync(getPreviewBinPath(name)))
|
||||
const nodePtyError = getPreviewNodePtyError()
|
||||
if (nodePtyError) missing.push(nodePtyError)
|
||||
return missing
|
||||
}
|
||||
|
||||
function patchFileIfExists(path: string, patcher: (source: string) => string) {
|
||||
if (!existsSync(path)) return
|
||||
const source = readFileSync(path, 'utf-8')
|
||||
const next = patcher(source)
|
||||
if (next !== source) writeFileSync(path, next, 'utf-8')
|
||||
}
|
||||
|
||||
function patchPreviewWebSocketClient(source: string) {
|
||||
return source.replace(
|
||||
/const host = import\.meta\.env\.DEV\s*\?\s*formatHostForPort\(location\.hostname,\s*\d+\)\s*:\s*location\.host/g,
|
||||
[
|
||||
'const directDevPort = import.meta.env.VITE_HERMES_DIRECT_WS_PORT',
|
||||
' const host = import.meta.env.DEV && directDevPort',
|
||||
' ? formatHostForPort(location.hostname, Number(directDevPort))',
|
||||
' : location.host',
|
||||
].join('\n'),
|
||||
)
|
||||
}
|
||||
|
||||
function patchPreviewApiClient(source: string) {
|
||||
return source.replace(
|
||||
/return localStorage\.getItem\(['"]hermes_server_url['"]\) \|\| DEFAULT_BASE_URL/,
|
||||
"return import.meta.env.VITE_HERMES_PREVIEW === '1' ? DEFAULT_BASE_URL : localStorage.getItem('hermes_server_url') || DEFAULT_BASE_URL",
|
||||
)
|
||||
}
|
||||
|
||||
function patchPreviewViteConfig(source: string) {
|
||||
let next = source.replace(
|
||||
/const BACKEND = ['"]http:\/\/127\.0\.0\.1:\d+['"]/,
|
||||
[
|
||||
`const BACKEND_PORT = process.env.HERMES_WEB_UI_BACKEND_PORT || '${PREVIEW_BACKEND_PORT}'`,
|
||||
'const BACKEND = `http://127.0.0.1:${BACKEND_PORT}`',
|
||||
].join('\n'),
|
||||
)
|
||||
if (!next.includes('HERMES_WEB_UI_FRONTEND_PORT')) {
|
||||
next = next.replace(
|
||||
/server:\s*\{/,
|
||||
`server: {\n port: Number(process.env.HERMES_WEB_UI_FRONTEND_PORT || ${PREVIEW_FRONTEND_PORT}),\n strictPort: true,`,
|
||||
)
|
||||
}
|
||||
next = next.replace(
|
||||
/(changeOrigin:\s*true,)(?!\s*\n\s*ws:\s*true,)/,
|
||||
'$1\n ws: true,',
|
||||
)
|
||||
return next
|
||||
}
|
||||
|
||||
function patchPreviewSidebar(source: string) {
|
||||
let next = source
|
||||
if (!next.includes('VITE_HERMES_PREVIEW')) {
|
||||
next = next.replace(
|
||||
/const isSuperAdmin = computed\(\(\) => isStoredSuperAdmin\(\)\);/,
|
||||
"const isSuperAdmin = computed(() => isStoredSuperAdmin());\nconst isVersionPreview = import.meta.env.VITE_HERMES_PREVIEW === '1';",
|
||||
)
|
||||
}
|
||||
next = next.replace(
|
||||
/<RouteLinkItem v-if="isSuperAdmin" class="nav-item" :to="\{ name: 'hermes\.versionPreview' \}"/,
|
||||
'<RouteLinkItem v-if="isSuperAdmin && !isVersionPreview" class="nav-item" :to="{ name: \'hermes.versionPreview\' }"',
|
||||
)
|
||||
return next
|
||||
}
|
||||
|
||||
function applyPreviewRuntimePatch() {
|
||||
const previewDir = getPreviewDir()
|
||||
const packagePath = getPreviewPackagePath()
|
||||
const viteConfigPath = join(previewDir, 'vite.config.ts')
|
||||
|
||||
if (existsSync(packagePath)) {
|
||||
const pkg = JSON.parse(readFileSync(packagePath, 'utf-8'))
|
||||
pkg.scripts = {
|
||||
...pkg.scripts,
|
||||
'dev:client': `vite --host --port ${PREVIEW_FRONTEND_PORT} --strictPort`,
|
||||
}
|
||||
writeFileSync(packagePath, JSON.stringify(pkg, null, 2) + '\n', 'utf-8')
|
||||
}
|
||||
|
||||
if (existsSync(viteConfigPath)) {
|
||||
patchFileIfExists(viteConfigPath, patchPreviewViteConfig)
|
||||
}
|
||||
|
||||
patchFileIfExists(join(previewDir, 'packages/client/src/components/hermes/chat/TerminalPanel.vue'), patchPreviewWebSocketClient)
|
||||
patchFileIfExists(join(previewDir, 'packages/client/src/views/hermes/TerminalView.vue'), patchPreviewWebSocketClient)
|
||||
patchFileIfExists(join(previewDir, 'packages/client/src/api/hermes/kanban.ts'), patchPreviewWebSocketClient)
|
||||
patchFileIfExists(join(previewDir, 'packages/client/src/api/client.ts'), patchPreviewApiClient)
|
||||
patchFileIfExists(join(previewDir, 'packages/client/src/components/layout/AppSidebar.vue'), patchPreviewSidebar)
|
||||
}
|
||||
|
||||
function assertTagRef(tag: unknown): string {
|
||||
const value = typeof tag === 'string' ? tag.trim() : ''
|
||||
if (!value) throw new Error('Tag is required')
|
||||
if (!PREVIEW_TAG_REF_PATTERN.test(value) || value.includes('..')) {
|
||||
throw new Error('Invalid tag')
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function runGit(args: string[], cwd?: string) {
|
||||
return execFileSync('git', args, {
|
||||
cwd,
|
||||
encoding: 'utf-8',
|
||||
timeout: options.timeout,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: getCurrentNodeEnv(),
|
||||
timeout: 5 * 60 * 1000,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
}).trim()
|
||||
}
|
||||
|
||||
function isGitAvailable() {
|
||||
try {
|
||||
runGit(['--version'])
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function networkErrorMessage(err: any): string {
|
||||
const detail = err.stderr?.toString() || err.message || String(err)
|
||||
return `Unable to connect to GitHub. Please check your network or proxy settings. ${detail}`
|
||||
}
|
||||
|
||||
function errorMessage(err: any): string {
|
||||
return err.stderr?.toString() || err.message || String(err)
|
||||
}
|
||||
|
||||
async function downloadGithubZip(ref: string, targetDir: string, type: 'tag' | 'branch' = 'tag') {
|
||||
const { owner, repo } = getPreviewGithubRepoParts()
|
||||
const refKind = type === 'branch' ? 'heads' : 'tags'
|
||||
const archiveKind = process.platform === 'win32' ? 'zip' : 'tar.gz'
|
||||
const url = `https://codeload.github.com/${owner}/${repo}/${archiveKind}/refs/${refKind}/${encodeURIComponent(ref)}`
|
||||
appendPreviewActionLog(`download archive: ${url}`)
|
||||
const res = await fetch(url, {
|
||||
headers: { 'User-Agent': 'hermes-web-ui-preview' },
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
})
|
||||
if (!res.ok) throw new Error(`Failed to download GitHub archive: HTTP ${res.status}`)
|
||||
|
||||
const tmpRoot = `${targetDir}.download`
|
||||
const archivePath = `${tmpRoot}.${archiveKind === 'zip' ? 'zip' : 'tar.gz'}`
|
||||
rmSync(tmpRoot, { recursive: true, force: true })
|
||||
rmSync(archivePath, { force: true })
|
||||
mkdirSync(tmpRoot, { recursive: true })
|
||||
const archiveBuffer = Buffer.from(await res.arrayBuffer())
|
||||
writeFileSync(archivePath, archiveBuffer)
|
||||
appendPreviewActionLog(`downloaded archive: ${archiveBuffer.length} bytes`)
|
||||
|
||||
try {
|
||||
appendPreviewActionLog(`extract archive: ${archivePath}`)
|
||||
if (process.platform === 'win32') {
|
||||
execFileSync('powershell.exe', [
|
||||
'-NoProfile',
|
||||
'-Command',
|
||||
`Expand-Archive -LiteralPath ${JSON.stringify(archivePath)} -DestinationPath ${JSON.stringify(tmpRoot)} -Force`,
|
||||
], { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, timeout: 5 * 60 * 1000 })
|
||||
} else {
|
||||
execFileSync('tar', ['-xzf', archivePath, '-C', tmpRoot], { stdio: ['ignore', 'pipe', 'pipe'], timeout: 5 * 60 * 1000 })
|
||||
}
|
||||
|
||||
const entries = execFileSync(process.platform === 'win32' ? 'cmd.exe' : 'ls', process.platform === 'win32' ? ['/c', 'dir', '/b', tmpRoot] : [tmpRoot], {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
timeout: 30_000,
|
||||
windowsHide: true,
|
||||
}).trim().split(/\r?\n/).filter(Boolean)
|
||||
const extracted = entries.length === 1 ? join(tmpRoot, entries[0]) : tmpRoot
|
||||
appendPreviewActionLog(`replace preview directory: ${targetDir}`)
|
||||
rmSync(targetDir, { recursive: true, force: true })
|
||||
mkdirSync(dirname(targetDir), { recursive: true })
|
||||
if (process.platform !== 'win32') mkdirSync(targetDir, { recursive: true })
|
||||
execFileSync(process.platform === 'win32' ? 'cmd.exe' : 'cp', process.platform === 'win32'
|
||||
? ['/c', 'move', extracted, targetDir]
|
||||
: ['-R', `${extracted}/.`, targetDir], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
timeout: 5 * 60 * 1000,
|
||||
windowsHide: true,
|
||||
})
|
||||
appendPreviewActionLog('archive preview code ready')
|
||||
} finally {
|
||||
rmSync(tmpRoot, { recursive: true, force: true })
|
||||
rmSync(archivePath, { force: true })
|
||||
}
|
||||
}
|
||||
|
||||
async function clonePreview(ref: string) {
|
||||
const previewDir = getPreviewDir()
|
||||
appendPreviewActionLog(`prepare preview clone for tag: ${ref}`)
|
||||
rmSync(previewDir, { recursive: true, force: true })
|
||||
mkdirSync(dirname(previewDir), { recursive: true })
|
||||
|
||||
try {
|
||||
if (!isGitAvailable()) throw new Error('git is not available')
|
||||
appendPreviewActionLog(`git clone --branch ${ref} --depth 1 ${getPreviewRepoGitUrl()} ${previewDir}`)
|
||||
runGit(['clone', '--branch', ref, '--depth', '1', getPreviewRepoGitUrl(), previewDir])
|
||||
appendPreviewActionLog('git clone completed')
|
||||
} catch {
|
||||
appendPreviewActionLog('git clone unavailable or failed, falling back to GitHub zip')
|
||||
rmSync(previewDir, { recursive: true, force: true })
|
||||
await downloadGithubZip(ref, previewDir, ref === PREVIEW_MAIN_REF ? 'branch' : 'tag')
|
||||
}
|
||||
}
|
||||
|
||||
async function checkoutPreview(ref: string) {
|
||||
const previewDir = getPreviewDir()
|
||||
appendPreviewActionLog(`checkout preview tag: ${ref}`)
|
||||
if (!existsSync(previewDir)) {
|
||||
await clonePreview(ref)
|
||||
} else if (existsSync(join(previewDir, '.git')) && isGitAvailable()) {
|
||||
try {
|
||||
appendPreviewActionLog('git fetch --tags --force')
|
||||
runGit(['fetch', '--tags', '--force'], previewDir)
|
||||
appendPreviewActionLog(`git checkout --force ${ref}`)
|
||||
runGit(['checkout', '--force', ref], previewDir)
|
||||
} catch (err: any) {
|
||||
appendPreviewActionLog(`git checkout failed, replacing with GitHub zip: ${err.stderr?.toString() || err.message || String(err)}`)
|
||||
rmSync(previewDir, { recursive: true, force: true })
|
||||
await downloadGithubZip(ref, previewDir, ref === PREVIEW_MAIN_REF ? 'branch' : 'tag')
|
||||
}
|
||||
} else {
|
||||
appendPreviewActionLog('preview directory is missing git metadata or package.json, replacing with GitHub zip')
|
||||
rmSync(previewDir, { recursive: true, force: true })
|
||||
await downloadGithubZip(ref, previewDir, ref === PREVIEW_MAIN_REF ? 'branch' : 'tag')
|
||||
}
|
||||
|
||||
assertPreviewPackage()
|
||||
appendPreviewActionLog('apply preview runtime port patch')
|
||||
applyPreviewRuntimePatch()
|
||||
writeFileSync(join(previewDir, '.preview-tag'), `${ref}\n`)
|
||||
appendPreviewActionLog(`preview tag ready: ${ref}`)
|
||||
}
|
||||
|
||||
function getGlobalRoot() {
|
||||
return runNpm(['root', '-g'])
|
||||
}
|
||||
@@ -154,3 +727,178 @@ export async function handleUpdate(ctx: any) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function previewStatus(ctx: any) {
|
||||
ctx.body = previewPayload()
|
||||
}
|
||||
|
||||
export async function previewTags(ctx: any) {
|
||||
try {
|
||||
if (isGitAvailable()) {
|
||||
appendPreviewActionLog('load tags with git ls-remote')
|
||||
ctx.body = { tags: [{ name: PREVIEW_MAIN_REF, sha: '' }, ...listPreviewTagsWithGit()] }
|
||||
return
|
||||
}
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
appendPreviewActionLog('load tags with GitHub API')
|
||||
const res = await fetch(`${getPreviewRepoApiUrl()}/tags?per_page=100`, {
|
||||
headers: { 'User-Agent': 'hermes-web-ui-preview' },
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
})
|
||||
if (!res.ok) {
|
||||
throw new Error(`GitHub API HTTP ${res.status}`)
|
||||
}
|
||||
|
||||
const tags = await res.json() as Array<{ name?: string; commit?: { sha?: string } }>
|
||||
ctx.body = {
|
||||
tags: [
|
||||
{ name: PREVIEW_MAIN_REF, sha: '' },
|
||||
...tags
|
||||
.filter(tag => typeof tag.name === 'string' && tag.name.trim())
|
||||
.map(tag => ({ name: tag.name, sha: tag.commit?.sha || '' })),
|
||||
],
|
||||
}
|
||||
} catch (apiErr: any) {
|
||||
appendPreviewActionLog(`load tags failed: ${apiErr.message || String(apiErr)}`)
|
||||
ctx.status = 502
|
||||
ctx.body = previewPayload({ error: networkErrorMessage(apiErr) })
|
||||
}
|
||||
}
|
||||
|
||||
export async function preparePreview(ctx: any) {
|
||||
try {
|
||||
const tag = assertTagRef((ctx.request.body as any)?.tag)
|
||||
appendPreviewActionLog(`prepare requested: ${tag}`)
|
||||
await stopPreviewProcess()
|
||||
await checkoutPreview(tag)
|
||||
ctx.body = previewPayload({ success: true })
|
||||
} catch (err: any) {
|
||||
appendPreviewActionLog(`prepare failed: ${errorMessage(err)}`)
|
||||
ctx.status = 500
|
||||
ctx.body = previewPayload({ success: false, message: errorMessage(err) })
|
||||
}
|
||||
}
|
||||
|
||||
export async function installPreview(ctx: any) {
|
||||
try {
|
||||
appendPreviewActionLog('npm install requested')
|
||||
await stopPreviewProcess()
|
||||
assertPreviewPackage()
|
||||
const output = runNpm(['install', '--include=dev', '--ignore-scripts'], {
|
||||
cwd: getPreviewDir(),
|
||||
timeout: 15 * 60 * 1000,
|
||||
logLabel: 'npm install --include=dev --ignore-scripts',
|
||||
env: getPreviewInstallEnv(),
|
||||
})
|
||||
if (existsSync(join(getPreviewDir(), 'node_modules', 'node-pty'))) {
|
||||
runNpm(['rebuild', 'node-pty'], {
|
||||
cwd: getPreviewDir(),
|
||||
timeout: 5 * 60 * 1000,
|
||||
logLabel: 'npm rebuild node-pty',
|
||||
env: getPreviewInstallEnv(),
|
||||
})
|
||||
}
|
||||
appendPreviewActionLog(`verify preview dependencies in: ${getPreviewDir()}`)
|
||||
const missing = getMissingPreviewDependencyBins()
|
||||
if (missing.length) {
|
||||
const message = `npm install completed but preview dependencies are still missing: ${missing.join(', ')}`
|
||||
appendPreviewActionLog(message)
|
||||
ctx.body = previewPayload({ success: false, message })
|
||||
return
|
||||
}
|
||||
ctx.body = previewPayload({ success: true, message: output })
|
||||
} catch (err: any) {
|
||||
appendPreviewActionLog(`npm install failed: ${err.stderr?.toString() || err.message || String(err)}`)
|
||||
ctx.status = 500
|
||||
ctx.body = previewPayload({ success: false, message: err.stderr?.toString() || err.message || String(err) })
|
||||
}
|
||||
}
|
||||
|
||||
export async function startPreview(ctx: any) {
|
||||
try {
|
||||
const tag = (ctx.request.body as any)?.tag
|
||||
const requestedTag = typeof tag === 'string' && tag.trim() ? assertTagRef(tag) : ''
|
||||
appendPreviewActionLog(`npm run dev requested${requestedTag ? ` for ${requestedTag}` : ''}`)
|
||||
if (requestedTag && requestedTag !== getCurrentPreviewTag() && previewProcess?.pid && !previewProcess.killed) {
|
||||
await stopPreviewProcess()
|
||||
}
|
||||
|
||||
if (requestedTag) {
|
||||
const currentTag = getCurrentPreviewTag()
|
||||
if (requestedTag === currentTag && existsSync(getPreviewPackagePath())) {
|
||||
appendPreviewActionLog(`skip checkout, preview tag already prepared: ${requestedTag}`)
|
||||
appendPreviewActionLog('apply preview runtime port patch')
|
||||
applyPreviewRuntimePatch()
|
||||
} else {
|
||||
await checkoutPreview(requestedTag)
|
||||
}
|
||||
}
|
||||
assertPreviewPackage()
|
||||
const missingDependencies = getMissingPreviewDependencyBins()
|
||||
if (missingDependencies.length) {
|
||||
const message = `Preview dependencies are not installed. Missing: ${missingDependencies.join(', ')}. Run npm install first.`
|
||||
appendPreviewActionLog(`start blocked: ${message}`)
|
||||
ctx.body = previewPayload({ success: false, message })
|
||||
return
|
||||
}
|
||||
|
||||
if (previewProcess?.pid && !previewProcess.killed) {
|
||||
appendPreviewActionLog('preview is already running')
|
||||
ctx.body = previewPayload({ success: true, message: 'Preview is already running' })
|
||||
return
|
||||
}
|
||||
|
||||
await assertPreviewPortsAvailable()
|
||||
|
||||
const npmCli = getNpmCliPath()
|
||||
const command = npmCli ? process.execPath : getNpmBin()
|
||||
const commandArgs = npmCli ? [npmCli, 'run', 'dev'] : ['run', 'dev']
|
||||
const logFd = openPreviewLogFile()
|
||||
appendPreviewActionLog(`spawn preview process: ${command} ${commandArgs.join(' ')}`)
|
||||
previewProcess = spawn(command, commandArgs, {
|
||||
cwd: getPreviewDir(),
|
||||
detached: true,
|
||||
stdio: ['ignore', logFd, logFd],
|
||||
windowsHide: true,
|
||||
env: {
|
||||
...getCurrentNodeEnv(),
|
||||
NODE_ENV: 'development',
|
||||
PORT: String(PREVIEW_BACKEND_PORT),
|
||||
HERMES_WEB_UI_HOME: getPreviewHomeDir(),
|
||||
HERMES_WEBUI_STATE_DIR: getPreviewHomeDir(),
|
||||
HERMES_AGENT_BRIDGE_ENDPOINT: getPreviewAgentBridgeEndpoint(),
|
||||
AUTH_TOKEN: '',
|
||||
HERMES_WEB_UI_BACKEND_PORT: String(PREVIEW_BACKEND_PORT),
|
||||
HERMES_WEB_UI_FRONTEND_PORT: String(PREVIEW_FRONTEND_PORT),
|
||||
VITE_HERMES_PREVIEW: '1',
|
||||
},
|
||||
})
|
||||
closeSync(logFd)
|
||||
previewProcess.on('exit', () => {
|
||||
appendPreviewActionLog('preview process exited')
|
||||
previewProcess = null
|
||||
})
|
||||
previewProcess.on('error', (err) => {
|
||||
console.error('[preview] failed:', err)
|
||||
previewProcess = null
|
||||
})
|
||||
previewProcess.unref()
|
||||
|
||||
await waitForPreviewReady()
|
||||
|
||||
appendPreviewActionLog(`preview ready: ${PREVIEW_FRONTEND_URL}`)
|
||||
ctx.body = previewPayload({ success: true, message: 'Preview started' })
|
||||
} catch (err: any) {
|
||||
appendPreviewActionLog(`npm run dev failed: ${err.stderr?.toString() || err.message || String(err)}`)
|
||||
ctx.status = 500
|
||||
ctx.body = previewPayload({ success: false, message: err.message || String(err) })
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopPreview(ctx: any) {
|
||||
appendPreviewActionLog('stop preview requested')
|
||||
await stopPreviewProcess()
|
||||
ctx.body = previewPayload({ success: true })
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ export const USAGE_SCHEMA: Record<string, string> = {
|
||||
reasoning_tokens: 'INTEGER NOT NULL DEFAULT 0',
|
||||
model: "TEXT NOT NULL DEFAULT ''",
|
||||
profile: "TEXT NOT NULL DEFAULT 'default'",
|
||||
created_at: 'INTEGER NOT NULL',
|
||||
created_at: 'INTEGER NOT NULL DEFAULT 0',
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import Router from '@koa/router'
|
||||
import * as ctrl from '../controllers/update'
|
||||
import { requireSuperAdmin } from '../middleware/user-auth'
|
||||
|
||||
export const updateRoutes = new Router()
|
||||
|
||||
updateRoutes.post('/api/hermes/update', ctrl.handleUpdate)
|
||||
updateRoutes.get('/api/hermes/update/preview', requireSuperAdmin, ctrl.previewStatus)
|
||||
updateRoutes.get('/api/hermes/update/preview/tags', requireSuperAdmin, ctrl.previewTags)
|
||||
updateRoutes.post('/api/hermes/update/preview/prepare', requireSuperAdmin, ctrl.preparePreview)
|
||||
updateRoutes.post('/api/hermes/update/preview/install', requireSuperAdmin, ctrl.installPreview)
|
||||
updateRoutes.post('/api/hermes/update/preview/start', requireSuperAdmin, ctrl.startPreview)
|
||||
updateRoutes.post('/api/hermes/update/preview/stop', requireSuperAdmin, ctrl.stopPreview)
|
||||
|
||||
Reference in New Issue
Block a user