diff --git a/packages/client/src/api/hermes/system.ts b/packages/client/src/api/hermes/system.ts
index b2a9837..a106c8a 100644
--- a/packages/client/src/api/hermes/system.ts
+++ b/packages/client/src/api/hermes/system.ts
@@ -28,12 +28,20 @@ export interface PreviewStatus {
webui_home: string
action_log_path: string
dev_log_path: string
+ active_action: string | null
+ active_action_started_at: string | null
+ last_action: string | null
+ last_action_completed_at: string | null
+ last_action_success: boolean | null
+ last_action_message: string
+ last_action_code: string
action_log: string
dev_log: string
}
export interface PreviewActionResponse extends PreviewStatus {
success: boolean
+ accepted?: boolean
message?: string
code?: string
}
diff --git a/packages/client/src/components/hermes/settings/GithubPreviewSettings.vue b/packages/client/src/components/hermes/settings/GithubPreviewSettings.vue
index dfbeb8f..dc1d75f 100644
--- a/packages/client/src/components/hermes/settings/GithubPreviewSettings.vue
+++ b/packages/client/src/components/hermes/settings/GithubPreviewSettings.vue
@@ -1,5 +1,5 @@
@@ -161,16 +228,16 @@ onMounted(async () => {
:placeholder="t('githubPreview.selectTag')"
/>
-
+
{{ t('githubPreview.prepare') }}
-
+
{{ t('githubPreview.install') }}
-
+
{{ t('githubPreview.start') }}
-
+
{{ t('githubPreview.stop') }}
diff --git a/packages/client/src/i18n/locales/de.ts b/packages/client/src/i18n/locales/de.ts
index 958683e..281f9b7 100644
--- a/packages/client/src/i18n/locales/de.ts
+++ b/packages/client/src/i18n/locales/de.ts
@@ -1036,7 +1036,7 @@ jobTriggered: 'Job ausgelost',
nodeEnvironmentMissing: "Node/npm wurde nicht erkannt. Bitte installiere Node.js und versuche es erneut.",
prepareSuccess: "Vorschaucode ist bereit",
installSuccess: "Abhängigkeiten installiert",
- startSuccess: "Vorschau gestartet",
+ startSuccess: "Vorschau abgeschlossen",
stopSuccess: "Vorschau gestoppt",
},
diff --git a/packages/client/src/i18n/locales/en.ts b/packages/client/src/i18n/locales/en.ts
index 97cd8a0..1d98c63 100644
--- a/packages/client/src/i18n/locales/en.ts
+++ b/packages/client/src/i18n/locales/en.ts
@@ -1138,7 +1138,7 @@ export default {
nodeEnvironmentMissing: "Node/npm was not detected. Please install Node.js and try again.",
prepareSuccess: "Preview code is ready",
installSuccess: "Dependencies installed",
- startSuccess: "Preview started",
+ startSuccess: "Preview completed",
stopSuccess: "Preview stopped",
},
diff --git a/packages/client/src/i18n/locales/es.ts b/packages/client/src/i18n/locales/es.ts
index 990dfc2..40bb2ce 100644
--- a/packages/client/src/i18n/locales/es.ts
+++ b/packages/client/src/i18n/locales/es.ts
@@ -1036,7 +1036,7 @@ jobTriggered: 'Job ejecutado',
nodeEnvironmentMissing: "No se detectó Node/npm. Instala Node.js y vuelve a intentarlo.",
prepareSuccess: "Código de vista previa listo",
installSuccess: "Dependencias instaladas",
- startSuccess: "Vista previa iniciada",
+ startSuccess: "Vista previa completada",
stopSuccess: "Vista previa detenida",
},
diff --git a/packages/client/src/i18n/locales/fr.ts b/packages/client/src/i18n/locales/fr.ts
index fe4abb0..95476bf 100644
--- a/packages/client/src/i18n/locales/fr.ts
+++ b/packages/client/src/i18n/locales/fr.ts
@@ -1036,7 +1036,7 @@ jobTriggered: 'Job declenche',
nodeEnvironmentMissing: "Node/npm n’a pas été détecté. Installez Node.js puis réessayez.",
prepareSuccess: "Code de prévisualisation prêt",
installSuccess: "Dépendances installées",
- startSuccess: "Prévisualisation démarrée",
+ startSuccess: "Prévisualisation terminée",
stopSuccess: "Prévisualisation arrêtée",
},
diff --git a/packages/client/src/i18n/locales/ja.ts b/packages/client/src/i18n/locales/ja.ts
index a8df3a9..9dd9603 100644
--- a/packages/client/src/i18n/locales/ja.ts
+++ b/packages/client/src/i18n/locales/ja.ts
@@ -1036,7 +1036,7 @@ export default {
nodeEnvironmentMissing: "Node/npm が検出されませんでした。Node.js をインストールしてから再試行してください。",
prepareSuccess: "プレビューコードの準備が完了しました",
installSuccess: "依存関係をインストールしました",
- startSuccess: "プレビューを起動しました",
+ startSuccess: "プレビューが完了しました",
stopSuccess: "プレビューを停止しました",
},
diff --git a/packages/client/src/i18n/locales/ko.ts b/packages/client/src/i18n/locales/ko.ts
index 7cb90e5..7329fe1 100644
--- a/packages/client/src/i18n/locales/ko.ts
+++ b/packages/client/src/i18n/locales/ko.ts
@@ -1036,7 +1036,7 @@ export default {
nodeEnvironmentMissing: "Node/npm 환경을 찾을 수 없습니다. Node.js를 설치한 뒤 다시 시도하세요.",
prepareSuccess: "미리보기 코드가 준비되었습니다",
installSuccess: "의존성이 설치되었습니다",
- startSuccess: "미리보기가 시작되었습니다",
+ startSuccess: "미리보기가 완료되었습니다",
stopSuccess: "미리보기가 중지되었습니다",
},
diff --git a/packages/client/src/i18n/locales/pt.ts b/packages/client/src/i18n/locales/pt.ts
index e7c5596..31b7d42 100644
--- a/packages/client/src/i18n/locales/pt.ts
+++ b/packages/client/src/i18n/locales/pt.ts
@@ -1036,7 +1036,7 @@ jobTriggered: 'Job acionado',
nodeEnvironmentMissing: "Node/npm não foi detectado. Instale o Node.js e tente novamente.",
prepareSuccess: "Código de prévia pronto",
installSuccess: "Dependências instaladas",
- startSuccess: "Prévia iniciada",
+ startSuccess: "Prévia concluída",
stopSuccess: "Prévia parada",
},
diff --git a/packages/client/src/i18n/locales/zh-TW.ts b/packages/client/src/i18n/locales/zh-TW.ts
index 4d768ff..00b6f3f 100644
--- a/packages/client/src/i18n/locales/zh-TW.ts
+++ b/packages/client/src/i18n/locales/zh-TW.ts
@@ -1130,7 +1130,7 @@ export default {
nodeEnvironmentMissing: "未偵測到可用的 Node/npm 環境,請先安裝 Node.js 後重試。",
prepareSuccess: "預覽程式碼已準備好",
installSuccess: "依賴安裝完成",
- startSuccess: "預覽已啟動",
+ startSuccess: "預覽已完成",
stopSuccess: "預覽已停止",
},
diff --git a/packages/client/src/i18n/locales/zh.ts b/packages/client/src/i18n/locales/zh.ts
index 92d04a1..0301fc3 100644
--- a/packages/client/src/i18n/locales/zh.ts
+++ b/packages/client/src/i18n/locales/zh.ts
@@ -1130,7 +1130,7 @@ export default {
nodeEnvironmentMissing: "未检测到可用的 Node/npm 环境,请先安装 Node.js 后重试。",
prepareSuccess: "预览代码已准备好",
installSuccess: "依赖安装完成",
- startSuccess: "预览已启动",
+ startSuccess: "预览已完成",
stopSuccess: "预览已停止",
},
diff --git a/packages/server/src/controllers/update.ts b/packages/server/src/controllers/update.ts
index dd7b47b..bb582ed 100644
--- a/packages/server/src/controllers/update.ts
+++ b/packages/server/src/controllers/update.ts
@@ -1,11 +1,10 @@
-import { execFileSync, spawn, type ChildProcess } from 'child_process'
+import { execFile, 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, extname, join, resolve } from 'path'
import { getWebUiHome } from '../config'
let updateInProgress = false
-let previewProcess: ChildProcess | null = null
const NODE_ENVIRONMENT_MISSING_CODE = 'node_environment_missing'
const PREVIEW_DIR_NAME = 'hermes-web-ui-pereview'
@@ -19,6 +18,52 @@ const PREVIEW_AGENT_BRIDGE_TRANSPORT_ENV = 'HERMES_WEB_UI_PREVIEW_AGENT_BRIDGE_T
const PREVIEW_FRONTEND_URL = `http://localhost:${PREVIEW_FRONTEND_PORT}`
const PREVIEW_TAG_REF_PATTERN = /^[A-Za-z0-9._/-]+$/
const PREVIEW_MAIN_REF = 'main'
+const PREVIEW_TAGS_CACHE_MS = 5 * 60 * 1000
+
+type PreviewTagRef = { name: string; sha: string }
+type PreviewTagsCache = { expiresAt: number; tags: PreviewTagRef[] }
+type PreviewActionResult = { success: boolean; message?: string; code?: string }
+
+class PreviewRuntimeState {
+ process: ChildProcess | null = null
+ tagsCache: PreviewTagsCache | null = null
+ activeAction: string | null = null
+ activeActionStartedAt: string | null = null
+ lastAction: string | null = null
+ lastActionCompletedAt: string | null = null
+ lastActionResult: PreviewActionResult | null = null
+
+ getCachedTags(): PreviewTagRef[] | null {
+ return this.tagsCache && this.tagsCache.expiresAt > Date.now()
+ ? this.tagsCache.tags
+ : null
+ }
+
+ setTags(tags: PreviewTagRef[]) {
+ this.tagsCache = { tags, expiresAt: Date.now() + PREVIEW_TAGS_CACHE_MS }
+ }
+
+ beginAction(action: string): boolean {
+ if (this.activeAction) return false
+ this.activeAction = action
+ this.activeActionStartedAt = new Date().toISOString()
+ this.lastAction = null
+ this.lastActionCompletedAt = null
+ this.lastActionResult = null
+ return true
+ }
+
+ endAction(action: string, result: PreviewActionResult) {
+ if (this.activeAction !== action) return
+ this.activeAction = null
+ this.activeActionStartedAt = null
+ this.lastAction = action
+ this.lastActionCompletedAt = new Date().toISOString()
+ this.lastActionResult = result
+ }
+}
+
+const previewState = new PreviewRuntimeState()
interface PackageInfo {
name: string
@@ -92,8 +137,7 @@ function getPreviewGithubRepoParts(): { owner: string; repo: string } {
return { owner: match[1], repo: match[2] }
}
-function listPreviewTagsWithGit(): Array<{ name: string; sha: string }> {
- const output = runGit(['ls-remote', '--tags', '--refs', getPreviewRepoGitUrl()])
+function parsePreviewTagRefs(output: string): PreviewTagRef[] {
return output
.split(/\r?\n/)
.map(line => line.trim())
@@ -106,6 +150,38 @@ function listPreviewTagsWithGit(): Array<{ name: string; sha: string }> {
.reverse()
}
+function execFileText(
+ command: string,
+ args: string[],
+ options: { cwd?: string; timeout?: number; env?: NodeJS.ProcessEnv; maxBuffer?: number } = {},
+): Promise {
+ return new Promise((resolve, reject) => {
+ execFile(command, args, {
+ cwd: options.cwd,
+ encoding: 'utf-8',
+ timeout: options.timeout,
+ env: options.env,
+ windowsHide: true,
+ maxBuffer: options.maxBuffer || 1024 * 1024,
+ }, (error, stdout, stderr) => {
+ if (error) {
+ ;(error as any).stdout = stdout
+ ;(error as any).stderr = stderr
+ reject(error)
+ return
+ }
+ resolve(String(stdout || '').trim())
+ })
+ })
+}
+
+async function listPreviewTagsWithGitAsync(): Promise {
+ const output = await execFileText('git', ['ls-remote', '--tags', '--refs', getPreviewRepoGitUrl()], {
+ timeout: 8_000,
+ })
+ return parsePreviewTagRefs(output)
+}
+
function getNodeBinDir() {
return dirname(process.execPath)
}
@@ -292,6 +368,39 @@ function runNpm(args: string[], options: { timeout?: number; cwd?: string; logLa
}
}
+async function runNpmAsync(args: string[], options: { timeout?: number; cwd?: string; logLabel?: string; env?: NodeJS.ProcessEnv } = {}) {
+ const env = {
+ ...getCurrentNodeEnv(),
+ ...options.env,
+ }
+ const execution = npmExecution(args, env)
+ const label = options.logLabel || ''
+
+ if (label) appendPreviewActionLog(`${label}: ${execution.command} ${execution.args.join(' ')}${options.cwd ? `\ncwd: ${options.cwd}` : ''}`)
+ try {
+ const output = await execFileText(execution.command, execution.args, {
+ cwd: options.cwd,
+ timeout: options.timeout,
+ env,
+ maxBuffer: 16 * 1024 * 1024,
+ })
+ 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)
}
@@ -384,6 +493,13 @@ function previewPayload(extra: Record = {}) {
return {
...extra,
...getPreviewStatus(),
+ active_action: previewState.activeAction,
+ active_action_started_at: previewState.activeActionStartedAt,
+ last_action: previewState.lastAction,
+ last_action_completed_at: previewState.lastActionCompletedAt,
+ last_action_success: previewState.lastActionResult?.success ?? null,
+ last_action_message: previewState.lastActionResult?.message || '',
+ last_action_code: previewState.lastActionResult?.code || '',
action_log: readLogTail(getPreviewActionLogPath()),
dev_log: readLogTail(getPreviewLogPath()),
}
@@ -396,7 +512,7 @@ function getPreviewStatus() {
const hasPackage = existsSync(packagePath)
const installed = hasPackage && getMissingPreviewDependencyBins().length === 0
const runtimePids = getPreviewListeningPids()
- const running = Boolean(previewProcess?.pid && !previewProcess.killed) || runtimePids.length > 0
+ const running = Boolean(previewState.process?.pid && !previewState.process.killed) || runtimePids.length > 0
const currentTag = getCurrentPreviewTag()
return {
@@ -405,7 +521,7 @@ function getPreviewStatus() {
has_package: hasPackage,
installed,
running,
- pid: running ? previewProcess?.pid || runtimePids[0] || null : null,
+ pid: running ? previewState.process?.pid || runtimePids[0] || null : null,
current_tag: currentTag,
frontend_url: PREVIEW_FRONTEND_URL,
agent_bridge_endpoint: getPreviewAgentBridgeEndpoint(),
@@ -501,7 +617,7 @@ async function waitForPreviewReady(timeoutMs = 30_000) {
let lastError = ''
while (Date.now() < deadline) {
- if (!previewProcess || previewProcess.killed) {
+ if (!previewState.process || previewState.process.killed) {
throw new Error(`Preview process exited before it became ready. Check log: ${getPreviewLogPath()}`)
}
@@ -528,13 +644,13 @@ function openPreviewLogFile() {
}
async function stopPreviewProcess() {
- const child = previewProcess
+ const child = previewState.process
const pids = new Set()
if (child?.pid && !child.killed) pids.add(child.pid)
for (const pid of getPreviewListeningPids()) pids.add(pid)
if (!pids.size) {
- previewProcess = null
+ previewState.process = null
return
}
@@ -567,7 +683,7 @@ async function stopPreviewProcess() {
}
}
- previewProcess = null
+ previewState.process = null
await sleep(800)
}
@@ -591,18 +707,15 @@ function getPreviewBinPath(name: string) {
return join(getPreviewDir(), 'node_modules', '.bin', process.platform === 'win32' ? `${name}.cmd` : name)
}
-function getPreviewNodePtyError() {
+async function getPreviewNodePtyErrorAsync() {
if (!existsSync(join(getPreviewDir(), 'node_modules', 'node-pty'))) {
return 'node-pty'
}
try {
- execFileSync(process.execPath, ['-e', "require('node-pty')"], {
+ await execFileText(process.execPath, ['-e', "require('node-pty')"], {
cwd: getPreviewDir(),
- encoding: 'utf-8',
- stdio: ['ignore', 'pipe', 'pipe'],
timeout: 30_000,
- windowsHide: true,
})
return ''
} catch (err: any) {
@@ -616,7 +729,15 @@ function getMissingPreviewDependencyBins() {
}
const missing = ['concurrently', 'vite', 'nodemon'].filter(name => !existsSync(getPreviewBinPath(name)))
- const nodePtyError = getPreviewNodePtyError()
+ if (!existsSync(join(getPreviewDir(), 'node_modules', 'node-pty'))) missing.push('node-pty')
+ return missing
+}
+
+async function getMissingPreviewDependencyBinsAsync() {
+ const missing = getMissingPreviewDependencyBins()
+ if (missing.includes('node_modules') || missing.includes('node-pty')) return missing
+
+ const nodePtyError = await getPreviewNodePtyErrorAsync()
if (nodePtyError) missing.push(nodePtyError)
return missing
}
@@ -720,23 +841,11 @@ function assertTagRef(tag: unknown): string {
return value
}
-function runGit(args: string[], cwd?: string) {
- return execFileSync('git', args, {
+async function runGitAsync(args: string[], cwd?: string) {
+ return execFileText('git', args, {
cwd,
- encoding: 'utf-8',
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 {
@@ -748,6 +857,47 @@ function errorMessage(err: any): string {
return err.stderr?.toString() || err.message || String(err)
}
+function queuePreviewAction(
+ action: string,
+ work: () => Promise,
+ normalizeError: (err: any) => { message: string; code?: string } = err => ({ message: errorMessage(err) }),
+ onError?: (err: any) => Promise,
+): boolean {
+ if (!previewState.beginAction(action)) return false
+
+ void (async () => {
+ try {
+ const result = await work()
+ const normalized = result || { success: true }
+ previewState.endAction(action, normalized)
+ appendPreviewActionLog(`${action} completed${normalized.success === false ? ': failed' : ''}`)
+ } catch (err: any) {
+ if (onError) {
+ try { await onError(err) } catch {}
+ }
+ const normalized = normalizeError(err)
+ appendPreviewActionLog(`${action} failed: ${normalized.message}`)
+ previewState.endAction(action, {
+ success: false,
+ message: normalized.message,
+ code: normalized.code,
+ })
+ }
+ })()
+
+ return true
+}
+
+function previewActionAlreadyRunning(ctx: any) {
+ ctx.status = 409
+ ctx.body = previewPayload({ success: false, message: `Preview action already running: ${previewState.activeAction}` })
+}
+
+function previewActionAccepted(ctx: any) {
+ ctx.status = 202
+ ctx.body = previewPayload({ success: true, accepted: true })
+}
+
async function downloadGithubZip(ref: string, targetDir: string, type: 'tag' | 'branch' = 'tag') {
const { owner, repo } = getPreviewGithubRepoParts()
const refKind = type === 'branch' ? 'heads' : 'tags'
@@ -772,32 +922,27 @@ async function downloadGithubZip(ref: string, targetDir: string, type: 'tag' | '
try {
appendPreviewActionLog(`extract archive: ${archivePath}`)
if (process.platform === 'win32') {
- execFileSync('powershell.exe', [
+ await execFileText('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 })
+ ], { timeout: 5 * 60 * 1000 })
} else {
- execFileSync('tar', ['-xzf', archivePath, '-C', tmpRoot], { stdio: ['ignore', 'pipe', 'pipe'], timeout: 5 * 60 * 1000 })
+ await execFileText('tar', ['-xzf', archivePath, '-C', tmpRoot], { 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'],
+ const entries = (await execFileText(process.platform === 'win32' ? 'cmd.exe' : 'ls', process.platform === 'win32' ? ['/c', 'dir', '/b', tmpRoot] : [tmpRoot], {
timeout: 30_000,
- windowsHide: true,
- }).trim().split(/\r?\n/).filter(Boolean)
+ })).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'
+ await execFileText(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 {
@@ -813,9 +958,8 @@ async function clonePreview(ref: string) {
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])
+ await runGitAsync(['clone', '--branch', ref, '--depth', '1', getPreviewRepoGitUrl(), previewDir])
appendPreviewActionLog('git clone completed')
} catch {
appendPreviewActionLog('git clone unavailable or failed, falling back to GitHub zip')
@@ -829,12 +973,12 @@ async function checkoutPreview(ref: string) {
appendPreviewActionLog(`checkout preview tag: ${ref}`)
if (!existsSync(previewDir)) {
await clonePreview(ref)
- } else if (existsSync(join(previewDir, '.git')) && isGitAvailable()) {
+ } else if (existsSync(join(previewDir, '.git'))) {
try {
appendPreviewActionLog('git fetch --tags --force')
- runGit(['fetch', '--tags', '--force'], previewDir)
+ await runGitAsync(['fetch', '--tags', '--force'], previewDir)
appendPreviewActionLog(`git checkout --force ${ref}`)
- runGit(['checkout', '--force', ref], previewDir)
+ await runGitAsync(['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 })
@@ -944,13 +1088,21 @@ export async function previewStatus(ctx: any) {
}
export async function previewTags(ctx: any) {
+ const cachedTags = previewState.getCachedTags()
+ if (cachedTags) {
+ ctx.body = { tags: cachedTags }
+ return
+ }
+
try {
- if (isGitAvailable()) {
- appendPreviewActionLog('load tags with git ls-remote')
- ctx.body = { tags: [{ name: PREVIEW_MAIN_REF, sha: '' }, ...listPreviewTagsWithGit()] }
- return
- }
- } catch {}
+ appendPreviewActionLog('load tags with git ls-remote')
+ const tags = [{ name: PREVIEW_MAIN_REF, sha: '' }, ...await listPreviewTagsWithGitAsync()]
+ previewState.setTags(tags)
+ ctx.body = { tags }
+ return
+ } catch (gitErr: any) {
+ appendPreviewActionLog(`load tags with git failed: ${gitErr.message || String(gitErr)}`)
+ }
try {
appendPreviewActionLog('load tags with GitHub API')
@@ -963,14 +1115,14 @@ export async function previewTags(ctx: any) {
}
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 || '' })),
- ],
- }
+ const parsedTags = [
+ { name: PREVIEW_MAIN_REF, sha: '' },
+ ...tags
+ .filter((tag): tag is { name: string; commit?: { sha?: string } } => typeof tag.name === 'string' && Boolean(tag.name.trim()))
+ .map(tag => ({ name: tag.name, sha: tag.commit?.sha || '' })),
+ ]
+ previewState.setTags(parsedTags)
+ ctx.body = { tags: parsedTags }
} catch (apiErr: any) {
appendPreviewActionLog(`load tags failed: ${apiErr.message || String(apiErr)}`)
ctx.status = 502
@@ -981,10 +1133,17 @@ export async function previewTags(ctx: any) {
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 })
+ const queued = queuePreviewAction('prepare', async () => {
+ appendPreviewActionLog(`prepare requested: ${tag}`)
+ await stopPreviewProcess()
+ await checkoutPreview(tag)
+ return { success: true }
+ })
+ if (!queued) {
+ previewActionAlreadyRunning(ctx)
+ return
+ }
+ previewActionAccepted(ctx)
} catch (err: any) {
appendPreviewActionLog(`prepare failed: ${errorMessage(err)}`)
ctx.status = 500
@@ -993,18 +1152,18 @@ export async function preparePreview(ctx: any) {
}
export async function installPreview(ctx: any) {
- try {
+ const queued = queuePreviewAction('install', async () => {
appendPreviewActionLog('npm install requested')
await stopPreviewProcess()
assertPreviewPackage()
- const output = runNpm(['install', '--include=dev', '--ignore-scripts'], {
+ const output = await runNpmAsync(['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'], {
+ await runNpmAsync(['rebuild', 'node-pty'], {
cwd: getPreviewDir(),
timeout: 5 * 60 * 1000,
logLabel: 'npm rebuild node-pty',
@@ -1012,100 +1171,105 @@ export async function installPreview(ctx: any) {
})
}
appendPreviewActionLog(`verify preview dependencies in: ${getPreviewDir()}`)
- const missing = getMissingPreviewDependencyBins()
+ const missing = await getMissingPreviewDependencyBinsAsync()
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
+ return { success: false, message }
}
- ctx.body = previewPayload({ success: true, message: output })
- } catch (err: any) {
- const normalized = normalizeNodeToolError(err)
- appendPreviewActionLog(`npm install failed: ${normalized.message}`)
- ctx.status = 500
- ctx.body = previewPayload({ success: false, message: normalized.message, code: normalized.code })
+ return { success: true, message: output }
+ }, normalizeNodeToolError)
+ if (!queued) {
+ previewActionAlreadyRunning(ctx)
+ return
}
+ previewActionAccepted(ctx)
}
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)
+ const queued = queuePreviewAction('start', async () => {
+ appendPreviewActionLog(`npm run dev requested${requestedTag ? ` for ${requestedTag}` : ''}`)
+ if (requestedTag && requestedTag !== getCurrentPreviewTag() && previewState.process?.pid && !previewState.process.killed) {
+ await stopPreviewProcess()
}
- }
- 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 })
+
+ 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 = await getMissingPreviewDependencyBinsAsync()
+ if (missingDependencies.length) {
+ const message = `Preview dependencies are not installed. Missing: ${missingDependencies.join(', ')}. Run npm install first.`
+ appendPreviewActionLog(`start blocked: ${message}`)
+ return { success: false, message }
+ }
+
+ if (previewState.process?.pid && !previewState.process.killed) {
+ appendPreviewActionLog('preview is already running')
+ return { success: true, message: 'Preview is already running' }
+ }
+
+ await assertPreviewPortsAvailable()
+
+ const 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(),
+ HERMES_AGENT_BRIDGE_WORKER_PORT_BASE: String(PREVIEW_AGENT_BRIDGE_WORKER_PORT_BASE),
+ AUTH_TOKEN: '',
+ HERMES_WEB_UI_BACKEND_PORT: String(PREVIEW_BACKEND_PORT),
+ HERMES_WEB_UI_FRONTEND_PORT: String(PREVIEW_FRONTEND_PORT),
+ VITE_HERMES_PREVIEW: '1',
+ }
+ const execution = npmExecution(['run', 'dev'], env)
+ const logFd = openPreviewLogFile()
+ appendPreviewActionLog(`spawn preview process: ${execution.command} ${execution.args.join(' ')}`)
+ previewState.process = spawn(execution.command, execution.args, {
+ cwd: getPreviewDir(),
+ detached: true,
+ stdio: ['ignore', logFd, logFd],
+ windowsHide: true,
+ env,
+ })
+ closeSync(logFd)
+ previewState.process.on('exit', () => {
+ appendPreviewActionLog('preview process exited')
+ previewState.process = null
+ })
+ previewState.process.on('error', (err) => {
+ console.error('[preview] failed:', err)
+ previewState.process = null
+ })
+ previewState.process.unref()
+
+ await waitForPreviewReady()
+
+ appendPreviewActionLog(`preview ready: ${PREVIEW_FRONTEND_URL}`)
+ return { success: true, message: 'Preview started' }
+ }, normalizeNodeToolError, async () => {
+ await stopPreviewProcess()
+ })
+ if (!queued) {
+ previewActionAlreadyRunning(ctx)
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 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(),
- HERMES_AGENT_BRIDGE_WORKER_PORT_BASE: String(PREVIEW_AGENT_BRIDGE_WORKER_PORT_BASE),
- AUTH_TOKEN: '',
- HERMES_WEB_UI_BACKEND_PORT: String(PREVIEW_BACKEND_PORT),
- HERMES_WEB_UI_FRONTEND_PORT: String(PREVIEW_FRONTEND_PORT),
- VITE_HERMES_PREVIEW: '1',
- }
- const execution = npmExecution(['run', 'dev'], env)
- const logFd = openPreviewLogFile()
- appendPreviewActionLog(`spawn preview process: ${execution.command} ${execution.args.join(' ')}`)
- previewProcess = spawn(execution.command, execution.args, {
- cwd: getPreviewDir(),
- detached: true,
- stdio: ['ignore', logFd, logFd],
- windowsHide: true,
- env,
- })
- 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' })
+ previewActionAccepted(ctx)
} catch (err: any) {
const normalized = normalizeNodeToolError(err)
appendPreviewActionLog(`npm run dev failed: ${normalized.message}`)
- await stopPreviewProcess()
ctx.status = 500
ctx.body = previewPayload({ success: false, message: normalized.message, code: normalized.code })
}
diff --git a/tests/server/update-controller.test.ts b/tests/server/update-controller.test.ts
index 0123e94..e9918ee 100644
--- a/tests/server/update-controller.test.ts
+++ b/tests/server/update-controller.test.ts
@@ -2,26 +2,45 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { delimiter, dirname, join } from 'path'
type UpdateControllerMocks = {
+ execFile: ReturnType
execFileSync: ReturnType
spawn: ReturnType
unref: ReturnType
existsSync: ReturnType
+ readFileSync: ReturnType
+ appendFileSync: ReturnType
}
async function loadUpdateController(overrides: Partial = {}) {
+ const execFile = overrides.execFile ?? vi.fn((_command: string, _args: string[], _options: any, callback: any) => callback(null, '', ''))
const execFileSync = overrides.execFileSync ?? vi.fn().mockReturnValue('updated')
const unref = overrides.unref ?? vi.fn()
const spawn = overrides.spawn ?? vi.fn(() => ({ unref, on: vi.fn() }))
const existsSync = overrides.existsSync ?? vi.fn(() => true)
+ const readFileSync = overrides.readFileSync ?? vi.fn(() => JSON.stringify({
+ name: 'hermes-web-ui',
+ version: '0.0.0',
+ repository: { url: 'https://github.com/EKKOLearnAI/hermes-web-ui.git' },
+ }))
+ const appendFileSync = overrides.appendFileSync ?? vi.fn()
vi.resetModules()
- vi.doMock('child_process', () => ({ execFileSync, spawn }))
- vi.doMock('fs', () => ({ existsSync }))
+ vi.doMock('child_process', () => ({ execFile, execFileSync, spawn }))
+ vi.doMock('fs', () => ({
+ appendFileSync,
+ closeSync: vi.fn(),
+ existsSync,
+ mkdirSync: vi.fn(),
+ openSync: vi.fn(() => 1),
+ readFileSync,
+ rmSync: vi.fn(),
+ writeFileSync: vi.fn(),
+ }))
const mod = await import('../../packages/server/src/controllers/update')
return {
...mod,
- mocks: { execFileSync, spawn, unref, existsSync },
+ mocks: { execFile, execFileSync, spawn, unref, existsSync, readFileSync, appendFileSync },
}
}
@@ -66,11 +85,13 @@ describe('update controller', () => {
vi.useRealTimers()
vi.doUnmock('child_process')
vi.doUnmock('fs')
+ vi.unstubAllGlobals()
if (originalPort === undefined) {
delete process.env.PORT
} else {
process.env.PORT = originalPort
}
+ delete process.env.HERMES_WEB_UI_PREVIEW_REPO
})
it('updates and restarts through the running Node executable, not PATH shims', async () => {
@@ -188,4 +209,101 @@ describe('update controller', () => {
expect(exitSpy).not.toHaveBeenCalled()
})
+ it('loads preview tags through async git with a short timeout', async () => {
+ process.env.HERMES_WEB_UI_PREVIEW_REPO = 'https://github.com/EKKOLearnAI/hermes-web-ui'
+ const execFile = vi.fn((_command: string, _args: string[], _options: any, callback: any) => {
+ callback(null, [
+ 'abc123\trefs/tags/v0.6.6',
+ 'def456\trefs/tags/v0.6.7',
+ ].join('\n'), '')
+ })
+ const execFileSync = vi.fn(() => 'git version 2.0.0')
+ const { previewTags, mocks } = await loadUpdateController({ execFile, execFileSync })
+ const ctx = createMockCtx()
+
+ await previewTags(ctx)
+
+ expect(ctx.status).toBe(200)
+ expect(ctx.body).toEqual({
+ tags: [
+ { name: 'main', sha: '' },
+ { name: 'v0.6.7', sha: 'def456' },
+ { name: 'v0.6.6', sha: 'abc123' },
+ ],
+ })
+ expect(mocks.execFile).toHaveBeenCalledWith(
+ 'git',
+ ['ls-remote', '--tags', '--refs', 'https://github.com/EKKOLearnAI/hermes-web-ui.git'],
+ expect.objectContaining({ timeout: 8000 }),
+ expect.any(Function),
+ )
+ })
+
+ it('falls back to GitHub API when async git tag loading fails', async () => {
+ process.env.HERMES_WEB_UI_PREVIEW_REPO = 'https://github.com/EKKOLearnAI/hermes-web-ui'
+ const execFile = vi.fn((_command: string, _args: string[], _options: any, callback: any) => {
+ callback(new Error('git timeout'), '', '')
+ })
+ const execFileSync = vi.fn(() => 'git version 2.0.0')
+ const fetchMock = vi.fn(async () => ({
+ ok: true,
+ json: async () => [
+ { name: 'v0.6.7', commit: { sha: 'def456' } },
+ { name: 'v0.6.6', commit: { sha: 'abc123' } },
+ ],
+ }))
+ vi.stubGlobal('fetch', fetchMock)
+ const { previewTags } = await loadUpdateController({ execFile, execFileSync })
+ const ctx = createMockCtx()
+
+ await previewTags(ctx)
+
+ expect(ctx.status).toBe(200)
+ expect(ctx.body).toEqual({
+ tags: [
+ { name: 'main', sha: '' },
+ { name: 'v0.6.7', sha: 'def456' },
+ { name: 'v0.6.6', sha: 'abc123' },
+ ],
+ })
+ expect(fetchMock).toHaveBeenCalledWith(
+ 'https://api.github.com/repos/EKKOLearnAI/hermes-web-ui/tags?per_page=100',
+ expect.objectContaining({
+ headers: { 'User-Agent': 'hermes-web-ui-preview' },
+ signal: expect.any(AbortSignal),
+ }),
+ )
+ })
+
+ it('runs preview npm install through async execFile', async () => {
+ const npmCli = getNpmCliPath()
+ const execFile = vi.fn((_command: string, _args: string[], _options: any, callback: any) => {
+ callback(null, 'installed', '')
+ })
+ const execFileSync = vi.fn(() => '')
+ const { installPreview, mocks } = await loadUpdateController({ execFile, execFileSync })
+ const ctx = createMockCtx()
+
+ await installPreview(ctx)
+
+ expect(ctx.status).toBe(202)
+ expect((ctx.body as any).success).toBe(true)
+ expect((ctx.body as any).accepted).toBe(true)
+ expect((ctx.body as any).active_action).toBe('install')
+ expect(mocks.execFile).toHaveBeenCalledWith(
+ process.execPath,
+ [npmCli, 'install', '--include=dev', '--ignore-scripts'],
+ expect.objectContaining({
+ timeout: 15 * 60 * 1000,
+ cwd: expect.any(String),
+ }),
+ expect.any(Function),
+ )
+ expect(mocks.execFileSync).not.toHaveBeenCalledWith(
+ process.execPath,
+ [npmCli, 'install', '--include=dev', '--ignore-scripts'],
+ expect.any(Object),
+ )
+ })
+
})