Fix nonblocking preview actions (#1188)
This commit is contained in:
@@ -28,12 +28,20 @@ export interface PreviewStatus {
|
|||||||
webui_home: string
|
webui_home: string
|
||||||
action_log_path: string
|
action_log_path: string
|
||||||
dev_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
|
action_log: string
|
||||||
dev_log: string
|
dev_log: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PreviewActionResponse extends PreviewStatus {
|
export interface PreviewActionResponse extends PreviewStatus {
|
||||||
success: boolean
|
success: boolean
|
||||||
|
accepted?: boolean
|
||||||
message?: string
|
message?: string
|
||||||
code?: string
|
code?: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import { NAlert, NButton, NDescriptions, NDescriptionsItem, NSelect, NSpace, NTag, useMessage } from 'naive-ui'
|
import { NAlert, NButton, NDescriptions, NDescriptionsItem, NSelect, NSpace, NTag, useMessage } from 'naive-ui'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import {
|
import {
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
preparePreview,
|
preparePreview,
|
||||||
startPreview,
|
startPreview,
|
||||||
stopPreview,
|
stopPreview,
|
||||||
|
type PreviewActionResponse,
|
||||||
type PreviewStatus,
|
type PreviewStatus,
|
||||||
type PreviewTag,
|
type PreviewTag,
|
||||||
} from '@/api/hermes/system'
|
} from '@/api/hermes/system'
|
||||||
@@ -22,6 +23,9 @@ const actionLoading = ref('')
|
|||||||
const tags = ref<PreviewTag[]>([])
|
const tags = ref<PreviewTag[]>([])
|
||||||
const selectedTag = ref('')
|
const selectedTag = ref('')
|
||||||
const status = ref<PreviewStatus | null>(null)
|
const status = ref<PreviewStatus | null>(null)
|
||||||
|
const lastHandledCompletion = ref('')
|
||||||
|
const completionNotificationsReady = ref(false)
|
||||||
|
let pollTimer: number | null = null
|
||||||
|
|
||||||
const tagOptions = computed(() => tags.value.map(tag => ({
|
const tagOptions = computed(() => tags.value.map(tag => ({
|
||||||
label: tag.name,
|
label: tag.name,
|
||||||
@@ -29,6 +33,14 @@ const tagOptions = computed(() => tags.value.map(tag => ({
|
|||||||
})))
|
})))
|
||||||
const actionLog = computed(() => status.value?.action_log || '')
|
const actionLog = computed(() => status.value?.action_log || '')
|
||||||
const devLog = computed(() => status.value?.dev_log || '')
|
const devLog = computed(() => status.value?.dev_log || '')
|
||||||
|
const activeAction = computed(() => actionLoading.value || status.value?.active_action || '')
|
||||||
|
const hasActiveAction = computed(() => Boolean(activeAction.value))
|
||||||
|
const actionSuccessKeys: Record<string, string> = {
|
||||||
|
prepare: 'githubPreview.prepareSuccess',
|
||||||
|
install: 'githubPreview.installSuccess',
|
||||||
|
start: 'githubPreview.startSuccess',
|
||||||
|
stop: 'githubPreview.stopSuccess',
|
||||||
|
}
|
||||||
|
|
||||||
function applyErrorStatus(err: any) {
|
function applyErrorStatus(err: any) {
|
||||||
const messageText = String(err?.message || '')
|
const messageText = String(err?.message || '')
|
||||||
@@ -88,7 +100,7 @@ async function handleRefresh() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runAction(action: string, fn: () => Promise<PreviewStatus & { success?: boolean; message?: string; code?: string }>, successKey: string) {
|
async function runAction(action: string, fn: () => Promise<PreviewActionResponse>, successKey: string) {
|
||||||
actionLoading.value = action
|
actionLoading.value = action
|
||||||
try {
|
try {
|
||||||
const res = await fn()
|
const res = await fn()
|
||||||
@@ -97,7 +109,9 @@ async function runAction(action: string, fn: () => Promise<PreviewStatus & { suc
|
|||||||
message.warning(errorCodeMessage(res.code, res.message))
|
message.warning(errorCodeMessage(res.code, res.message))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (!res.accepted && !res.active_action) {
|
||||||
message.success(t(successKey))
|
message.success(t(successKey))
|
||||||
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
applyErrorStatus(err)
|
applyErrorStatus(err)
|
||||||
const payload = parseErrorPayload(err)
|
const payload = parseErrorPayload(err)
|
||||||
@@ -107,6 +121,25 @@ async function runAction(action: string, fn: () => Promise<PreviewStatus & { suc
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function pollStatus() {
|
||||||
|
try {
|
||||||
|
await loadStatus()
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPolling() {
|
||||||
|
if (pollTimer) return
|
||||||
|
pollTimer = window.setInterval(() => {
|
||||||
|
void pollStatus()
|
||||||
|
}, 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPolling() {
|
||||||
|
if (!pollTimer) return
|
||||||
|
window.clearInterval(pollTimer)
|
||||||
|
pollTimer = null
|
||||||
|
}
|
||||||
|
|
||||||
function requireTag(): string | null {
|
function requireTag(): string | null {
|
||||||
if (!selectedTag.value) {
|
if (!selectedTag.value) {
|
||||||
message.warning(t('githubPreview.selectTag'))
|
message.warning(t('githubPreview.selectTag'))
|
||||||
@@ -124,7 +157,7 @@ async function handlePrepare() {
|
|||||||
async function handleInstall() {
|
async function handleInstall() {
|
||||||
await runAction('install', async () => {
|
await runAction('install', async () => {
|
||||||
const res = await installPreview()
|
const res = await installPreview()
|
||||||
if (res.success !== false && !res.installed) {
|
if (res.success !== false && !res.accepted && !res.active_action && !res.installed) {
|
||||||
return {
|
return {
|
||||||
...res,
|
...res,
|
||||||
success: false,
|
success: false,
|
||||||
@@ -145,7 +178,41 @@ async function handleStop() {
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await handleRefresh()
|
await handleRefresh()
|
||||||
|
lastHandledCompletion.value = status.value?.last_action_completed_at || ''
|
||||||
|
completionNotificationsReady.value = true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
stopPolling()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => status.value?.active_action || '',
|
||||||
|
(action) => {
|
||||||
|
if (action) startPolling()
|
||||||
|
else stopPolling()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => status.value?.last_action_completed_at || '',
|
||||||
|
(completedAt) => {
|
||||||
|
if (!completedAt) return
|
||||||
|
if (!completionNotificationsReady.value) {
|
||||||
|
lastHandledCompletion.value = completedAt
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (completedAt === lastHandledCompletion.value || actionLoading.value) return
|
||||||
|
lastHandledCompletion.value = completedAt
|
||||||
|
const completedAction = status.value?.last_action || ''
|
||||||
|
if (status.value?.last_action_success === false) {
|
||||||
|
message.error(errorCodeMessage(status.value.last_action_code, status.value.last_action_message))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const successKey = actionSuccessKeys[completedAction]
|
||||||
|
if (successKey) message.success(t(successKey))
|
||||||
|
},
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -161,16 +228,16 @@ onMounted(async () => {
|
|||||||
:placeholder="t('githubPreview.selectTag')"
|
:placeholder="t('githubPreview.selectTag')"
|
||||||
/>
|
/>
|
||||||
<NSpace>
|
<NSpace>
|
||||||
<NButton type="primary" :loading="actionLoading === 'prepare'" :disabled="!selectedTag" @click="handlePrepare">
|
<NButton type="primary" :loading="activeAction === 'prepare'" :disabled="hasActiveAction || !selectedTag" @click="handlePrepare">
|
||||||
{{ t('githubPreview.prepare') }}
|
{{ t('githubPreview.prepare') }}
|
||||||
</NButton>
|
</NButton>
|
||||||
<NButton :loading="actionLoading === 'install'" :disabled="!status?.has_package" @click="handleInstall">
|
<NButton :loading="activeAction === 'install'" :disabled="hasActiveAction || !status?.has_package" @click="handleInstall">
|
||||||
{{ t('githubPreview.install') }}
|
{{ t('githubPreview.install') }}
|
||||||
</NButton>
|
</NButton>
|
||||||
<NButton type="success" :loading="actionLoading === 'start'" :disabled="!status?.installed" @click="handleStart">
|
<NButton type="success" :loading="activeAction === 'start'" :disabled="hasActiveAction || !status?.installed" @click="handleStart">
|
||||||
{{ t('githubPreview.start') }}
|
{{ t('githubPreview.start') }}
|
||||||
</NButton>
|
</NButton>
|
||||||
<NButton :loading="actionLoading === 'stop'" :disabled="!status?.running" @click="handleStop">
|
<NButton :loading="activeAction === 'stop'" :disabled="hasActiveAction || !status?.running" @click="handleStop">
|
||||||
{{ t('githubPreview.stop') }}
|
{{ t('githubPreview.stop') }}
|
||||||
</NButton>
|
</NButton>
|
||||||
<NButton :loading="loading || tagsLoading" @click="handleRefresh">
|
<NButton :loading="loading || tagsLoading" @click="handleRefresh">
|
||||||
|
|||||||
@@ -1036,7 +1036,7 @@ jobTriggered: 'Job ausgelost',
|
|||||||
nodeEnvironmentMissing: "Node/npm wurde nicht erkannt. Bitte installiere Node.js und versuche es erneut.",
|
nodeEnvironmentMissing: "Node/npm wurde nicht erkannt. Bitte installiere Node.js und versuche es erneut.",
|
||||||
prepareSuccess: "Vorschaucode ist bereit",
|
prepareSuccess: "Vorschaucode ist bereit",
|
||||||
installSuccess: "Abhängigkeiten installiert",
|
installSuccess: "Abhängigkeiten installiert",
|
||||||
startSuccess: "Vorschau gestartet",
|
startSuccess: "Vorschau abgeschlossen",
|
||||||
stopSuccess: "Vorschau gestoppt",
|
stopSuccess: "Vorschau gestoppt",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1138,7 +1138,7 @@ export default {
|
|||||||
nodeEnvironmentMissing: "Node/npm was not detected. Please install Node.js and try again.",
|
nodeEnvironmentMissing: "Node/npm was not detected. Please install Node.js and try again.",
|
||||||
prepareSuccess: "Preview code is ready",
|
prepareSuccess: "Preview code is ready",
|
||||||
installSuccess: "Dependencies installed",
|
installSuccess: "Dependencies installed",
|
||||||
startSuccess: "Preview started",
|
startSuccess: "Preview completed",
|
||||||
stopSuccess: "Preview stopped",
|
stopSuccess: "Preview stopped",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1036,7 +1036,7 @@ jobTriggered: 'Job ejecutado',
|
|||||||
nodeEnvironmentMissing: "No se detectó Node/npm. Instala Node.js y vuelve a intentarlo.",
|
nodeEnvironmentMissing: "No se detectó Node/npm. Instala Node.js y vuelve a intentarlo.",
|
||||||
prepareSuccess: "Código de vista previa listo",
|
prepareSuccess: "Código de vista previa listo",
|
||||||
installSuccess: "Dependencias instaladas",
|
installSuccess: "Dependencias instaladas",
|
||||||
startSuccess: "Vista previa iniciada",
|
startSuccess: "Vista previa completada",
|
||||||
stopSuccess: "Vista previa detenida",
|
stopSuccess: "Vista previa detenida",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1036,7 +1036,7 @@ jobTriggered: 'Job declenche',
|
|||||||
nodeEnvironmentMissing: "Node/npm n’a pas été détecté. Installez Node.js puis réessayez.",
|
nodeEnvironmentMissing: "Node/npm n’a pas été détecté. Installez Node.js puis réessayez.",
|
||||||
prepareSuccess: "Code de prévisualisation prêt",
|
prepareSuccess: "Code de prévisualisation prêt",
|
||||||
installSuccess: "Dépendances installées",
|
installSuccess: "Dépendances installées",
|
||||||
startSuccess: "Prévisualisation démarrée",
|
startSuccess: "Prévisualisation terminée",
|
||||||
stopSuccess: "Prévisualisation arrêtée",
|
stopSuccess: "Prévisualisation arrêtée",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1036,7 +1036,7 @@ export default {
|
|||||||
nodeEnvironmentMissing: "Node/npm が検出されませんでした。Node.js をインストールしてから再試行してください。",
|
nodeEnvironmentMissing: "Node/npm が検出されませんでした。Node.js をインストールしてから再試行してください。",
|
||||||
prepareSuccess: "プレビューコードの準備が完了しました",
|
prepareSuccess: "プレビューコードの準備が完了しました",
|
||||||
installSuccess: "依存関係をインストールしました",
|
installSuccess: "依存関係をインストールしました",
|
||||||
startSuccess: "プレビューを起動しました",
|
startSuccess: "プレビューが完了しました",
|
||||||
stopSuccess: "プレビューを停止しました",
|
stopSuccess: "プレビューを停止しました",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1036,7 +1036,7 @@ export default {
|
|||||||
nodeEnvironmentMissing: "Node/npm 환경을 찾을 수 없습니다. Node.js를 설치한 뒤 다시 시도하세요.",
|
nodeEnvironmentMissing: "Node/npm 환경을 찾을 수 없습니다. Node.js를 설치한 뒤 다시 시도하세요.",
|
||||||
prepareSuccess: "미리보기 코드가 준비되었습니다",
|
prepareSuccess: "미리보기 코드가 준비되었습니다",
|
||||||
installSuccess: "의존성이 설치되었습니다",
|
installSuccess: "의존성이 설치되었습니다",
|
||||||
startSuccess: "미리보기가 시작되었습니다",
|
startSuccess: "미리보기가 완료되었습니다",
|
||||||
stopSuccess: "미리보기가 중지되었습니다",
|
stopSuccess: "미리보기가 중지되었습니다",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1036,7 +1036,7 @@ jobTriggered: 'Job acionado',
|
|||||||
nodeEnvironmentMissing: "Node/npm não foi detectado. Instale o Node.js e tente novamente.",
|
nodeEnvironmentMissing: "Node/npm não foi detectado. Instale o Node.js e tente novamente.",
|
||||||
prepareSuccess: "Código de prévia pronto",
|
prepareSuccess: "Código de prévia pronto",
|
||||||
installSuccess: "Dependências instaladas",
|
installSuccess: "Dependências instaladas",
|
||||||
startSuccess: "Prévia iniciada",
|
startSuccess: "Prévia concluída",
|
||||||
stopSuccess: "Prévia parada",
|
stopSuccess: "Prévia parada",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1130,7 +1130,7 @@ export default {
|
|||||||
nodeEnvironmentMissing: "未偵測到可用的 Node/npm 環境,請先安裝 Node.js 後重試。",
|
nodeEnvironmentMissing: "未偵測到可用的 Node/npm 環境,請先安裝 Node.js 後重試。",
|
||||||
prepareSuccess: "預覽程式碼已準備好",
|
prepareSuccess: "預覽程式碼已準備好",
|
||||||
installSuccess: "依賴安裝完成",
|
installSuccess: "依賴安裝完成",
|
||||||
startSuccess: "預覽已啟動",
|
startSuccess: "預覽已完成",
|
||||||
stopSuccess: "預覽已停止",
|
stopSuccess: "預覽已停止",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1130,7 +1130,7 @@ export default {
|
|||||||
nodeEnvironmentMissing: "未检测到可用的 Node/npm 环境,请先安装 Node.js 后重试。",
|
nodeEnvironmentMissing: "未检测到可用的 Node/npm 环境,请先安装 Node.js 后重试。",
|
||||||
prepareSuccess: "预览代码已准备好",
|
prepareSuccess: "预览代码已准备好",
|
||||||
installSuccess: "依赖安装完成",
|
installSuccess: "依赖安装完成",
|
||||||
startSuccess: "预览已启动",
|
startSuccess: "预览已完成",
|
||||||
stopSuccess: "预览已停止",
|
stopSuccess: "预览已停止",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -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 { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'fs'
|
||||||
import { createServer } from 'net'
|
import { createServer } from 'net'
|
||||||
import { delimiter, dirname, extname, join, resolve } from 'path'
|
import { delimiter, dirname, extname, join, resolve } from 'path'
|
||||||
import { getWebUiHome } from '../config'
|
import { getWebUiHome } from '../config'
|
||||||
|
|
||||||
let updateInProgress = false
|
let updateInProgress = false
|
||||||
let previewProcess: ChildProcess | null = null
|
|
||||||
const NODE_ENVIRONMENT_MISSING_CODE = 'node_environment_missing'
|
const NODE_ENVIRONMENT_MISSING_CODE = 'node_environment_missing'
|
||||||
|
|
||||||
const PREVIEW_DIR_NAME = 'hermes-web-ui-pereview'
|
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_FRONTEND_URL = `http://localhost:${PREVIEW_FRONTEND_PORT}`
|
||||||
const PREVIEW_TAG_REF_PATTERN = /^[A-Za-z0-9._/-]+$/
|
const PREVIEW_TAG_REF_PATTERN = /^[A-Za-z0-9._/-]+$/
|
||||||
const PREVIEW_MAIN_REF = 'main'
|
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 {
|
interface PackageInfo {
|
||||||
name: string
|
name: string
|
||||||
@@ -92,8 +137,7 @@ function getPreviewGithubRepoParts(): { owner: string; repo: string } {
|
|||||||
return { owner: match[1], repo: match[2] }
|
return { owner: match[1], repo: match[2] }
|
||||||
}
|
}
|
||||||
|
|
||||||
function listPreviewTagsWithGit(): Array<{ name: string; sha: string }> {
|
function parsePreviewTagRefs(output: string): PreviewTagRef[] {
|
||||||
const output = runGit(['ls-remote', '--tags', '--refs', getPreviewRepoGitUrl()])
|
|
||||||
return output
|
return output
|
||||||
.split(/\r?\n/)
|
.split(/\r?\n/)
|
||||||
.map(line => line.trim())
|
.map(line => line.trim())
|
||||||
@@ -106,6 +150,38 @@ function listPreviewTagsWithGit(): Array<{ name: string; sha: string }> {
|
|||||||
.reverse()
|
.reverse()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function execFileText(
|
||||||
|
command: string,
|
||||||
|
args: string[],
|
||||||
|
options: { cwd?: string; timeout?: number; env?: NodeJS.ProcessEnv; maxBuffer?: number } = {},
|
||||||
|
): Promise<string> {
|
||||||
|
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<PreviewTagRef[]> {
|
||||||
|
const output = await execFileText('git', ['ls-remote', '--tags', '--refs', getPreviewRepoGitUrl()], {
|
||||||
|
timeout: 8_000,
|
||||||
|
})
|
||||||
|
return parsePreviewTagRefs(output)
|
||||||
|
}
|
||||||
|
|
||||||
function getNodeBinDir() {
|
function getNodeBinDir() {
|
||||||
return dirname(process.execPath)
|
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() {
|
function getPreviewDir() {
|
||||||
return join(getWebUiHome(), PREVIEW_DIR_NAME)
|
return join(getWebUiHome(), PREVIEW_DIR_NAME)
|
||||||
}
|
}
|
||||||
@@ -384,6 +493,13 @@ function previewPayload(extra: Record<string, any> = {}) {
|
|||||||
return {
|
return {
|
||||||
...extra,
|
...extra,
|
||||||
...getPreviewStatus(),
|
...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()),
|
action_log: readLogTail(getPreviewActionLogPath()),
|
||||||
dev_log: readLogTail(getPreviewLogPath()),
|
dev_log: readLogTail(getPreviewLogPath()),
|
||||||
}
|
}
|
||||||
@@ -396,7 +512,7 @@ function getPreviewStatus() {
|
|||||||
const hasPackage = existsSync(packagePath)
|
const hasPackage = existsSync(packagePath)
|
||||||
const installed = hasPackage && getMissingPreviewDependencyBins().length === 0
|
const installed = hasPackage && getMissingPreviewDependencyBins().length === 0
|
||||||
const runtimePids = getPreviewListeningPids()
|
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()
|
const currentTag = getCurrentPreviewTag()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -405,7 +521,7 @@ function getPreviewStatus() {
|
|||||||
has_package: hasPackage,
|
has_package: hasPackage,
|
||||||
installed,
|
installed,
|
||||||
running,
|
running,
|
||||||
pid: running ? previewProcess?.pid || runtimePids[0] || null : null,
|
pid: running ? previewState.process?.pid || runtimePids[0] || null : null,
|
||||||
current_tag: currentTag,
|
current_tag: currentTag,
|
||||||
frontend_url: PREVIEW_FRONTEND_URL,
|
frontend_url: PREVIEW_FRONTEND_URL,
|
||||||
agent_bridge_endpoint: getPreviewAgentBridgeEndpoint(),
|
agent_bridge_endpoint: getPreviewAgentBridgeEndpoint(),
|
||||||
@@ -501,7 +617,7 @@ async function waitForPreviewReady(timeoutMs = 30_000) {
|
|||||||
let lastError = ''
|
let lastError = ''
|
||||||
|
|
||||||
while (Date.now() < deadline) {
|
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()}`)
|
throw new Error(`Preview process exited before it became ready. Check log: ${getPreviewLogPath()}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -528,13 +644,13 @@ function openPreviewLogFile() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function stopPreviewProcess() {
|
async function stopPreviewProcess() {
|
||||||
const child = previewProcess
|
const child = previewState.process
|
||||||
const pids = new Set<number>()
|
const pids = new Set<number>()
|
||||||
if (child?.pid && !child.killed) pids.add(child.pid)
|
if (child?.pid && !child.killed) pids.add(child.pid)
|
||||||
for (const pid of getPreviewListeningPids()) pids.add(pid)
|
for (const pid of getPreviewListeningPids()) pids.add(pid)
|
||||||
|
|
||||||
if (!pids.size) {
|
if (!pids.size) {
|
||||||
previewProcess = null
|
previewState.process = null
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -567,7 +683,7 @@ async function stopPreviewProcess() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
previewProcess = null
|
previewState.process = null
|
||||||
await sleep(800)
|
await sleep(800)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -591,18 +707,15 @@ function getPreviewBinPath(name: string) {
|
|||||||
return join(getPreviewDir(), 'node_modules', '.bin', process.platform === 'win32' ? `${name}.cmd` : name)
|
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'))) {
|
if (!existsSync(join(getPreviewDir(), 'node_modules', 'node-pty'))) {
|
||||||
return 'node-pty'
|
return 'node-pty'
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
execFileSync(process.execPath, ['-e', "require('node-pty')"], {
|
await execFileText(process.execPath, ['-e', "require('node-pty')"], {
|
||||||
cwd: getPreviewDir(),
|
cwd: getPreviewDir(),
|
||||||
encoding: 'utf-8',
|
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
windowsHide: true,
|
|
||||||
})
|
})
|
||||||
return ''
|
return ''
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -616,7 +729,15 @@ function getMissingPreviewDependencyBins() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const missing = ['concurrently', 'vite', 'nodemon'].filter(name => !existsSync(getPreviewBinPath(name)))
|
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)
|
if (nodePtyError) missing.push(nodePtyError)
|
||||||
return missing
|
return missing
|
||||||
}
|
}
|
||||||
@@ -720,23 +841,11 @@ function assertTagRef(tag: unknown): string {
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
function runGit(args: string[], cwd?: string) {
|
async function runGitAsync(args: string[], cwd?: string) {
|
||||||
return execFileSync('git', args, {
|
return execFileText('git', args, {
|
||||||
cwd,
|
cwd,
|
||||||
encoding: 'utf-8',
|
|
||||||
timeout: 5 * 60 * 1000,
|
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 {
|
function networkErrorMessage(err: any): string {
|
||||||
@@ -748,6 +857,47 @@ function errorMessage(err: any): string {
|
|||||||
return err.stderr?.toString() || err.message || String(err)
|
return err.stderr?.toString() || err.message || String(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function queuePreviewAction(
|
||||||
|
action: string,
|
||||||
|
work: () => Promise<PreviewActionResult | void>,
|
||||||
|
normalizeError: (err: any) => { message: string; code?: string } = err => ({ message: errorMessage(err) }),
|
||||||
|
onError?: (err: any) => Promise<void>,
|
||||||
|
): 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') {
|
async function downloadGithubZip(ref: string, targetDir: string, type: 'tag' | 'branch' = 'tag') {
|
||||||
const { owner, repo } = getPreviewGithubRepoParts()
|
const { owner, repo } = getPreviewGithubRepoParts()
|
||||||
const refKind = type === 'branch' ? 'heads' : 'tags'
|
const refKind = type === 'branch' ? 'heads' : 'tags'
|
||||||
@@ -772,32 +922,27 @@ async function downloadGithubZip(ref: string, targetDir: string, type: 'tag' | '
|
|||||||
try {
|
try {
|
||||||
appendPreviewActionLog(`extract archive: ${archivePath}`)
|
appendPreviewActionLog(`extract archive: ${archivePath}`)
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
execFileSync('powershell.exe', [
|
await execFileText('powershell.exe', [
|
||||||
'-NoProfile',
|
'-NoProfile',
|
||||||
'-Command',
|
'-Command',
|
||||||
`Expand-Archive -LiteralPath ${JSON.stringify(archivePath)} -DestinationPath ${JSON.stringify(tmpRoot)} -Force`,
|
`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 {
|
} 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], {
|
const entries = (await execFileText(process.platform === 'win32' ? 'cmd.exe' : 'ls', process.platform === 'win32' ? ['/c', 'dir', '/b', tmpRoot] : [tmpRoot], {
|
||||||
encoding: 'utf-8',
|
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
|
||||||
timeout: 30_000,
|
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
|
const extracted = entries.length === 1 ? join(tmpRoot, entries[0]) : tmpRoot
|
||||||
appendPreviewActionLog(`replace preview directory: ${targetDir}`)
|
appendPreviewActionLog(`replace preview directory: ${targetDir}`)
|
||||||
rmSync(targetDir, { recursive: true, force: true })
|
rmSync(targetDir, { recursive: true, force: true })
|
||||||
mkdirSync(dirname(targetDir), { recursive: true })
|
mkdirSync(dirname(targetDir), { recursive: true })
|
||||||
if (process.platform !== 'win32') mkdirSync(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]
|
? ['/c', 'move', extracted, targetDir]
|
||||||
: ['-R', `${extracted}/.`, targetDir], {
|
: ['-R', `${extracted}/.`, targetDir], {
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
|
||||||
timeout: 5 * 60 * 1000,
|
timeout: 5 * 60 * 1000,
|
||||||
windowsHide: true,
|
|
||||||
})
|
})
|
||||||
appendPreviewActionLog('archive preview code ready')
|
appendPreviewActionLog('archive preview code ready')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -813,9 +958,8 @@ async function clonePreview(ref: string) {
|
|||||||
mkdirSync(dirname(previewDir), { recursive: true })
|
mkdirSync(dirname(previewDir), { recursive: true })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!isGitAvailable()) throw new Error('git is not available')
|
|
||||||
appendPreviewActionLog(`git clone --branch ${ref} --depth 1 ${getPreviewRepoGitUrl()} ${previewDir}`)
|
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')
|
appendPreviewActionLog('git clone completed')
|
||||||
} catch {
|
} catch {
|
||||||
appendPreviewActionLog('git clone unavailable or failed, falling back to GitHub zip')
|
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}`)
|
appendPreviewActionLog(`checkout preview tag: ${ref}`)
|
||||||
if (!existsSync(previewDir)) {
|
if (!existsSync(previewDir)) {
|
||||||
await clonePreview(ref)
|
await clonePreview(ref)
|
||||||
} else if (existsSync(join(previewDir, '.git')) && isGitAvailable()) {
|
} else if (existsSync(join(previewDir, '.git'))) {
|
||||||
try {
|
try {
|
||||||
appendPreviewActionLog('git fetch --tags --force')
|
appendPreviewActionLog('git fetch --tags --force')
|
||||||
runGit(['fetch', '--tags', '--force'], previewDir)
|
await runGitAsync(['fetch', '--tags', '--force'], previewDir)
|
||||||
appendPreviewActionLog(`git checkout --force ${ref}`)
|
appendPreviewActionLog(`git checkout --force ${ref}`)
|
||||||
runGit(['checkout', '--force', ref], previewDir)
|
await runGitAsync(['checkout', '--force', ref], previewDir)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
appendPreviewActionLog(`git checkout failed, replacing with GitHub zip: ${err.stderr?.toString() || err.message || String(err)}`)
|
appendPreviewActionLog(`git checkout failed, replacing with GitHub zip: ${err.stderr?.toString() || err.message || String(err)}`)
|
||||||
rmSync(previewDir, { recursive: true, force: true })
|
rmSync(previewDir, { recursive: true, force: true })
|
||||||
@@ -944,13 +1088,21 @@ export async function previewStatus(ctx: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function previewTags(ctx: any) {
|
export async function previewTags(ctx: any) {
|
||||||
try {
|
const cachedTags = previewState.getCachedTags()
|
||||||
if (isGitAvailable()) {
|
if (cachedTags) {
|
||||||
appendPreviewActionLog('load tags with git ls-remote')
|
ctx.body = { tags: cachedTags }
|
||||||
ctx.body = { tags: [{ name: PREVIEW_MAIN_REF, sha: '' }, ...listPreviewTagsWithGit()] }
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} catch {}
|
|
||||||
|
try {
|
||||||
|
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 {
|
try {
|
||||||
appendPreviewActionLog('load tags with GitHub API')
|
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 } }>
|
const tags = await res.json() as Array<{ name?: string; commit?: { sha?: string } }>
|
||||||
ctx.body = {
|
const parsedTags = [
|
||||||
tags: [
|
|
||||||
{ name: PREVIEW_MAIN_REF, sha: '' },
|
{ name: PREVIEW_MAIN_REF, sha: '' },
|
||||||
...tags
|
...tags
|
||||||
.filter(tag => typeof tag.name === 'string' && tag.name.trim())
|
.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 || '' })),
|
.map(tag => ({ name: tag.name, sha: tag.commit?.sha || '' })),
|
||||||
],
|
]
|
||||||
}
|
previewState.setTags(parsedTags)
|
||||||
|
ctx.body = { tags: parsedTags }
|
||||||
} catch (apiErr: any) {
|
} catch (apiErr: any) {
|
||||||
appendPreviewActionLog(`load tags failed: ${apiErr.message || String(apiErr)}`)
|
appendPreviewActionLog(`load tags failed: ${apiErr.message || String(apiErr)}`)
|
||||||
ctx.status = 502
|
ctx.status = 502
|
||||||
@@ -981,10 +1133,17 @@ export async function previewTags(ctx: any) {
|
|||||||
export async function preparePreview(ctx: any) {
|
export async function preparePreview(ctx: any) {
|
||||||
try {
|
try {
|
||||||
const tag = assertTagRef((ctx.request.body as any)?.tag)
|
const tag = assertTagRef((ctx.request.body as any)?.tag)
|
||||||
|
const queued = queuePreviewAction('prepare', async () => {
|
||||||
appendPreviewActionLog(`prepare requested: ${tag}`)
|
appendPreviewActionLog(`prepare requested: ${tag}`)
|
||||||
await stopPreviewProcess()
|
await stopPreviewProcess()
|
||||||
await checkoutPreview(tag)
|
await checkoutPreview(tag)
|
||||||
ctx.body = previewPayload({ success: true })
|
return { success: true }
|
||||||
|
})
|
||||||
|
if (!queued) {
|
||||||
|
previewActionAlreadyRunning(ctx)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
previewActionAccepted(ctx)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
appendPreviewActionLog(`prepare failed: ${errorMessage(err)}`)
|
appendPreviewActionLog(`prepare failed: ${errorMessage(err)}`)
|
||||||
ctx.status = 500
|
ctx.status = 500
|
||||||
@@ -993,18 +1152,18 @@ export async function preparePreview(ctx: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function installPreview(ctx: any) {
|
export async function installPreview(ctx: any) {
|
||||||
try {
|
const queued = queuePreviewAction('install', async () => {
|
||||||
appendPreviewActionLog('npm install requested')
|
appendPreviewActionLog('npm install requested')
|
||||||
await stopPreviewProcess()
|
await stopPreviewProcess()
|
||||||
assertPreviewPackage()
|
assertPreviewPackage()
|
||||||
const output = runNpm(['install', '--include=dev', '--ignore-scripts'], {
|
const output = await runNpmAsync(['install', '--include=dev', '--ignore-scripts'], {
|
||||||
cwd: getPreviewDir(),
|
cwd: getPreviewDir(),
|
||||||
timeout: 15 * 60 * 1000,
|
timeout: 15 * 60 * 1000,
|
||||||
logLabel: 'npm install --include=dev --ignore-scripts',
|
logLabel: 'npm install --include=dev --ignore-scripts',
|
||||||
env: getPreviewInstallEnv(),
|
env: getPreviewInstallEnv(),
|
||||||
})
|
})
|
||||||
if (existsSync(join(getPreviewDir(), 'node_modules', 'node-pty'))) {
|
if (existsSync(join(getPreviewDir(), 'node_modules', 'node-pty'))) {
|
||||||
runNpm(['rebuild', 'node-pty'], {
|
await runNpmAsync(['rebuild', 'node-pty'], {
|
||||||
cwd: getPreviewDir(),
|
cwd: getPreviewDir(),
|
||||||
timeout: 5 * 60 * 1000,
|
timeout: 5 * 60 * 1000,
|
||||||
logLabel: 'npm rebuild node-pty',
|
logLabel: 'npm rebuild node-pty',
|
||||||
@@ -1012,28 +1171,28 @@ export async function installPreview(ctx: any) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
appendPreviewActionLog(`verify preview dependencies in: ${getPreviewDir()}`)
|
appendPreviewActionLog(`verify preview dependencies in: ${getPreviewDir()}`)
|
||||||
const missing = getMissingPreviewDependencyBins()
|
const missing = await getMissingPreviewDependencyBinsAsync()
|
||||||
if (missing.length) {
|
if (missing.length) {
|
||||||
const message = `npm install completed but preview dependencies are still missing: ${missing.join(', ')}`
|
const message = `npm install completed but preview dependencies are still missing: ${missing.join(', ')}`
|
||||||
appendPreviewActionLog(message)
|
appendPreviewActionLog(message)
|
||||||
ctx.body = previewPayload({ success: false, message })
|
return { success: false, message }
|
||||||
|
}
|
||||||
|
return { success: true, message: output }
|
||||||
|
}, normalizeNodeToolError)
|
||||||
|
if (!queued) {
|
||||||
|
previewActionAlreadyRunning(ctx)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ctx.body = previewPayload({ success: true, message: output })
|
previewActionAccepted(ctx)
|
||||||
} 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 })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function startPreview(ctx: any) {
|
export async function startPreview(ctx: any) {
|
||||||
try {
|
try {
|
||||||
const tag = (ctx.request.body as any)?.tag
|
const tag = (ctx.request.body as any)?.tag
|
||||||
const requestedTag = typeof tag === 'string' && tag.trim() ? assertTagRef(tag) : ''
|
const requestedTag = typeof tag === 'string' && tag.trim() ? assertTagRef(tag) : ''
|
||||||
|
const queued = queuePreviewAction('start', async () => {
|
||||||
appendPreviewActionLog(`npm run dev requested${requestedTag ? ` for ${requestedTag}` : ''}`)
|
appendPreviewActionLog(`npm run dev requested${requestedTag ? ` for ${requestedTag}` : ''}`)
|
||||||
if (requestedTag && requestedTag !== getCurrentPreviewTag() && previewProcess?.pid && !previewProcess.killed) {
|
if (requestedTag && requestedTag !== getCurrentPreviewTag() && previewState.process?.pid && !previewState.process.killed) {
|
||||||
await stopPreviewProcess()
|
await stopPreviewProcess()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1048,18 +1207,16 @@ export async function startPreview(ctx: any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
assertPreviewPackage()
|
assertPreviewPackage()
|
||||||
const missingDependencies = getMissingPreviewDependencyBins()
|
const missingDependencies = await getMissingPreviewDependencyBinsAsync()
|
||||||
if (missingDependencies.length) {
|
if (missingDependencies.length) {
|
||||||
const message = `Preview dependencies are not installed. Missing: ${missingDependencies.join(', ')}. Run npm install first.`
|
const message = `Preview dependencies are not installed. Missing: ${missingDependencies.join(', ')}. Run npm install first.`
|
||||||
appendPreviewActionLog(`start blocked: ${message}`)
|
appendPreviewActionLog(`start blocked: ${message}`)
|
||||||
ctx.body = previewPayload({ success: false, message })
|
return { success: false, message }
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (previewProcess?.pid && !previewProcess.killed) {
|
if (previewState.process?.pid && !previewState.process.killed) {
|
||||||
appendPreviewActionLog('preview is already running')
|
appendPreviewActionLog('preview is already running')
|
||||||
ctx.body = previewPayload({ success: true, message: 'Preview is already running' })
|
return { success: true, message: 'Preview is already running' }
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await assertPreviewPortsAvailable()
|
await assertPreviewPortsAvailable()
|
||||||
@@ -1080,7 +1237,7 @@ export async function startPreview(ctx: any) {
|
|||||||
const execution = npmExecution(['run', 'dev'], env)
|
const execution = npmExecution(['run', 'dev'], env)
|
||||||
const logFd = openPreviewLogFile()
|
const logFd = openPreviewLogFile()
|
||||||
appendPreviewActionLog(`spawn preview process: ${execution.command} ${execution.args.join(' ')}`)
|
appendPreviewActionLog(`spawn preview process: ${execution.command} ${execution.args.join(' ')}`)
|
||||||
previewProcess = spawn(execution.command, execution.args, {
|
previewState.process = spawn(execution.command, execution.args, {
|
||||||
cwd: getPreviewDir(),
|
cwd: getPreviewDir(),
|
||||||
detached: true,
|
detached: true,
|
||||||
stdio: ['ignore', logFd, logFd],
|
stdio: ['ignore', logFd, logFd],
|
||||||
@@ -1088,24 +1245,31 @@ export async function startPreview(ctx: any) {
|
|||||||
env,
|
env,
|
||||||
})
|
})
|
||||||
closeSync(logFd)
|
closeSync(logFd)
|
||||||
previewProcess.on('exit', () => {
|
previewState.process.on('exit', () => {
|
||||||
appendPreviewActionLog('preview process exited')
|
appendPreviewActionLog('preview process exited')
|
||||||
previewProcess = null
|
previewState.process = null
|
||||||
})
|
})
|
||||||
previewProcess.on('error', (err) => {
|
previewState.process.on('error', (err) => {
|
||||||
console.error('[preview] failed:', err)
|
console.error('[preview] failed:', err)
|
||||||
previewProcess = null
|
previewState.process = null
|
||||||
})
|
})
|
||||||
previewProcess.unref()
|
previewState.process.unref()
|
||||||
|
|
||||||
await waitForPreviewReady()
|
await waitForPreviewReady()
|
||||||
|
|
||||||
appendPreviewActionLog(`preview ready: ${PREVIEW_FRONTEND_URL}`)
|
appendPreviewActionLog(`preview ready: ${PREVIEW_FRONTEND_URL}`)
|
||||||
ctx.body = previewPayload({ success: true, message: 'Preview started' })
|
return { success: true, message: 'Preview started' }
|
||||||
|
}, normalizeNodeToolError, async () => {
|
||||||
|
await stopPreviewProcess()
|
||||||
|
})
|
||||||
|
if (!queued) {
|
||||||
|
previewActionAlreadyRunning(ctx)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
previewActionAccepted(ctx)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const normalized = normalizeNodeToolError(err)
|
const normalized = normalizeNodeToolError(err)
|
||||||
appendPreviewActionLog(`npm run dev failed: ${normalized.message}`)
|
appendPreviewActionLog(`npm run dev failed: ${normalized.message}`)
|
||||||
await stopPreviewProcess()
|
|
||||||
ctx.status = 500
|
ctx.status = 500
|
||||||
ctx.body = previewPayload({ success: false, message: normalized.message, code: normalized.code })
|
ctx.body = previewPayload({ success: false, message: normalized.message, code: normalized.code })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,26 +2,45 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|||||||
import { delimiter, dirname, join } from 'path'
|
import { delimiter, dirname, join } from 'path'
|
||||||
|
|
||||||
type UpdateControllerMocks = {
|
type UpdateControllerMocks = {
|
||||||
|
execFile: ReturnType<typeof vi.fn>
|
||||||
execFileSync: ReturnType<typeof vi.fn>
|
execFileSync: ReturnType<typeof vi.fn>
|
||||||
spawn: ReturnType<typeof vi.fn>
|
spawn: ReturnType<typeof vi.fn>
|
||||||
unref: ReturnType<typeof vi.fn>
|
unref: ReturnType<typeof vi.fn>
|
||||||
existsSync: ReturnType<typeof vi.fn>
|
existsSync: ReturnType<typeof vi.fn>
|
||||||
|
readFileSync: ReturnType<typeof vi.fn>
|
||||||
|
appendFileSync: ReturnType<typeof vi.fn>
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadUpdateController(overrides: Partial<UpdateControllerMocks> = {}) {
|
async function loadUpdateController(overrides: Partial<UpdateControllerMocks> = {}) {
|
||||||
|
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 execFileSync = overrides.execFileSync ?? vi.fn().mockReturnValue('updated')
|
||||||
const unref = overrides.unref ?? vi.fn()
|
const unref = overrides.unref ?? vi.fn()
|
||||||
const spawn = overrides.spawn ?? vi.fn(() => ({ unref, on: vi.fn() }))
|
const spawn = overrides.spawn ?? vi.fn(() => ({ unref, on: vi.fn() }))
|
||||||
const existsSync = overrides.existsSync ?? vi.fn(() => true)
|
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.resetModules()
|
||||||
vi.doMock('child_process', () => ({ execFileSync, spawn }))
|
vi.doMock('child_process', () => ({ execFile, execFileSync, spawn }))
|
||||||
vi.doMock('fs', () => ({ existsSync }))
|
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')
|
const mod = await import('../../packages/server/src/controllers/update')
|
||||||
return {
|
return {
|
||||||
...mod,
|
...mod,
|
||||||
mocks: { execFileSync, spawn, unref, existsSync },
|
mocks: { execFile, execFileSync, spawn, unref, existsSync, readFileSync, appendFileSync },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,11 +85,13 @@ describe('update controller', () => {
|
|||||||
vi.useRealTimers()
|
vi.useRealTimers()
|
||||||
vi.doUnmock('child_process')
|
vi.doUnmock('child_process')
|
||||||
vi.doUnmock('fs')
|
vi.doUnmock('fs')
|
||||||
|
vi.unstubAllGlobals()
|
||||||
if (originalPort === undefined) {
|
if (originalPort === undefined) {
|
||||||
delete process.env.PORT
|
delete process.env.PORT
|
||||||
} else {
|
} else {
|
||||||
process.env.PORT = originalPort
|
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 () => {
|
it('updates and restarts through the running Node executable, not PATH shims', async () => {
|
||||||
@@ -188,4 +209,101 @@ describe('update controller', () => {
|
|||||||
expect(exitSpy).not.toHaveBeenCalled()
|
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),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user