#!/usr/bin/env node /** * ===================================================== * CLI: check-app-ready * * 功能: * - AI 调用,检测 Vite dev server 和页面状态 * - 不依赖页面注入 * - 捕获已存在和后续构建/热更新错误 * - 页面可访问即 READY,出现错误即 ERROR * - 超时返回 TIMEOUT * - 默认包含构建校验,可通过 --skip-build 跳过 * * 使用: * node scripts/check-app-ready.mjs [页面路径] * 例如:node scripts/check-app-ready.mjs /components/button * node scripts/check-app-ready.mjs /prototypes/home * * 跳过构建校验: * node scripts/check-app-ready.mjs --skip-build /components/button * * 输出(JSON): * { * status: "READY" | "ERROR" | "TIMEOUT", * phase: "server|build|page|done", * message: "...", * url: "http://localhost:51720/components/button", * errors: [...], * logs: [...], * buildCheck?: { status: "SUCCESS" | "FAILED" | "SKIPPED", errors: [...], logs: [...] } * lintCheck?: { status: "SUCCESS" | "FAILED" | "SKIPPED", errors: [...], logs: [...] } * typeCheck?: { status: "SUCCESS" | "FAILED" | "SKIPPED", errors: [...], logs: [...] } * checks?: [{ name: "lint|typecheck|build", status: "...", message: "...", errors: [...] }] * homeUrl?: "http://localhost:51720" * targetUrl?: "http://localhost:51720/components/button" * targetPath?: "http://localhost:51720/prototypes/ref-app-home/index.html" * } * ===================================================== */ import { spawn } from 'node:child_process' import { setTimeout as sleep } from 'node:timers/promises' import process from 'node:process' import fs from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' import { decodeOutput, getPreferredNpmCommand, getPreferredNpxCommand } from './utils/command-runtime.mjs' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) const APP_ROOT = path.resolve(__dirname, '..') /* ================= 配置 ================= */ // 解析命令行参数 const args = process.argv.slice(2); const skipBuild = args.includes('--skip-build'); const pagePath = args.find(arg => !arg.startsWith('--')) || '/'; const CONFIG = { devCommand: ['run', 'dev'], // 启动 Vite 的命令参数 devServerInfoPath: path.resolve(__dirname, '../.axhub/make/.dev-server-info.json'), // 开发服务器信息文件 pagePath, // 目标页面路径(从命令行参数获取) pollIntervalMs: 500, // 页面轮询间隔 stableCheckMs: 1000, // 错误稳定判断时间 timeoutMs: 30_000, // 总超时 skipBuild // 是否跳过构建校验 } /* ================= 工具函数 ================= */ function jsonExit(payload, code = 0) { process.stdout.write(JSON.stringify(payload, null, 2)) process.exit(code) } /** * 尝试通过 HTTP 请求获取页面内容,检查是否有错误信息 */ async function checkPageForErrors(url) { try { const res = await fetch(url, { method: 'GET' }) const text = await res.text() // Only treat the HTML as an error page when Vite's overlay is present. // The app template includes global error handlers with object literals like // `{ error: event.error }`, which would otherwise cause false positives. const hasViteOverlay = text.includes('vite-error-overlay') || text.includes('__vite_error_overlay__') || /\[plugin:vite:/i.test(text) || /Transform failed/i.test(text) if (!hasViteOverlay) return [] const errorPatterns = [ /\bError:\s*([^\n]+)/, /\bSyntaxError:\s*([^\n]+)/, /\bReferenceError:\s*([^\n]+)/, /\[plugin:vite:[^\]]+\]\s*([^\n]+)/i, /Transform failed/i ] for (const pattern of errorPatterns) { const match = text.match(pattern) if (match) return [match[1] || match[0]] } return ['Detected Vite error overlay but could not extract message'] } catch (err) { return [] } } async function isServerAlive(url) { try { const res = await fetch(url, { method: 'GET' }) return res.ok } catch { return false } } /** * 读取开发服务器信息 * 优先从 .axhub/make/.dev-server-info.json 读取实际运行的端口 */ function getServerInfo() { try { if (fs.existsSync(CONFIG.devServerInfoPath)) { const info = JSON.parse(fs.readFileSync(CONFIG.devServerInfoPath, 'utf8')) return { port: info.port, host: info.host || 'localhost', localIP: info.localIP || 'localhost' } } } catch (err) { logs.push(`Failed to read .axhub/make/.dev-server-info.json: ${err.message}`) } // 如果没有端口信息,返回 null 表示需要等待服务器启动 return null } /** * 生成服务器首页 URL * 使用 localhost 而不是 0.0.0.0,因为浏览器无法访问 0.0.0.0 */ function getHomeUrl(serverInfo) { // 如果 host 是 0.0.0.0,使用 localhost 替代 const host = serverInfo.host === '0.0.0.0' ? 'localhost' : serverInfo.host return `http://${host}:${serverInfo.port}` } /** * 获取可访问的 host * 将 0.0.0.0 转换为 localhost,因为浏览器无法直接访问 0.0.0.0 */ function getAccessibleHost(serverInfo) { return serverInfo.host === '0.0.0.0' ? 'localhost' : serverInfo.host } function getTargetUrl(serverInfo, targetPath) { const host = getAccessibleHost(serverInfo) return `http://${host}:${serverInfo.port}${targetPath}` } function getEntryHtmlPath(targetPath) { const normalized = targetPath.startsWith('/') ? targetPath : `/${targetPath}` if (normalized.endsWith('.html')) return normalized if (normalized.endsWith('/')) return `${normalized}index.html` return `${normalized}/index.html` } /* ================= 全局状态 ================= */ let logs = [] let errors = [] let lastErrorTime = 0 let errorCache = new Set() // 用于去重错误信息 /* ================= 阶段 1:启动或 attach Vite ================= */ function startOrAttachVite() { logs.push('Checking Vite server...') const npmCommand = getPreferredNpmCommand() const child = spawn(npmCommand, CONFIG.devCommand, { stdio: ['ignore', 'pipe', 'pipe'], cwd: APP_ROOT, shell: false, }) child.stdout.on('data', (data) => { const text = decodeOutput(data).trim() if (text) logs.push(text) // 检测构建错误 if (/error/i.test(text) || /failed to compile/i.test(text)) { errors.push(text) lastErrorTime = Date.now() } }) child.stderr.on('data', (data) => { const text = decodeOutput(data).trim() if (text) { // 过滤掉一些正常的警告信息 if (!/deprecated|experimental/i.test(text)) { errors.push(text) lastErrorTime = Date.now() } logs.push(text) } }) child.on('error', (err) => { errors.push(`Process error: ${err.message}`) lastErrorTime = Date.now() }) return child } /* ================= 阶段 2:轮询页面可访问性 ================= */ async function waitForPage(url) { const start = Date.now() let lastCheckTime = 0 while (Date.now() - start < CONFIG.timeoutMs) { const now = Date.now() // 每隔一段时间尝试获取错误信息(即使页面不可访问) if (now - lastCheckTime > 2000) { const pageErrors = await checkPageForErrors(url) if (pageErrors.length > 0) { // 去重:只添加未见过的错误 pageErrors.forEach(err => { const errorKey = err.substring(0, 200) // 使用前200个字符作为唯一标识 if (!errorCache.has(errorKey)) { errorCache.add(errorKey) errors.push(err) } }) } lastCheckTime = now } if (await isServerAlive(url)) return true await sleep(CONFIG.pollIntervalMs) } return false } /* ================= 阶段 3:等待稳定状态 ================= */ async function waitForStable(pageUrl) { const startTime = Date.now() while (Date.now() - startTime < CONFIG.timeoutMs) { const now = Date.now() // 页面可访问 const pageOk = await isServerAlive(pageUrl) // 如果页面可访问,尝试检查页面内容中的错误 if (pageOk) { const pageErrors = await checkPageForErrors(pageUrl) if (pageErrors.length > 0) { return { status: 'ERROR', phase: 'build', message: 'Detected error in page content', url: pageUrl, errors: pageErrors, logs } } } // 错误稳定:最近 stableCheckMs 内没有新的错误 const stable = (now - lastErrorTime) > CONFIG.stableCheckMs if (!pageOk) { // 页面不可访问,继续轮询 await sleep(CONFIG.pollIntervalMs) continue } if (errors.length > 0) { return { status: 'ERROR', phase: 'build', message: 'Detected Vite build/runtime error', url: pageUrl, errors, logs } } if (pageOk && stable) { return { status: 'READY', phase: 'done', message: 'Page ready and stable', url: pageUrl, errors: [], logs } } await sleep(CONFIG.pollIntervalMs) } return { status: 'TIMEOUT', phase: 'server', message: 'Timeout waiting for page/stable state', url: pageUrl, errors, logs } } /** * 为结果添加服务器首页信息 */ function addUrls(result, serverInfo) { if (!serverInfo) { return { ...result, homeUrl: null, targetUrl: null, targetPath: null } } const entryHtmlPath = getEntryHtmlPath(CONFIG.pagePath) return { ...result, homeUrl: getHomeUrl(serverInfo), targetUrl: getTargetUrl(serverInfo, CONFIG.pagePath), targetPath: getTargetUrl(serverInfo, entryHtmlPath) } } function readPackageJson() { const pkgPath = path.resolve(APP_ROOT, 'package.json') try { return JSON.parse(fs.readFileSync(pkgPath, 'utf8')) } catch (err) { logs.push(`Failed to read package.json: ${err.message}`) return null } } function getScriptCommand(pkgJson, scriptName) { if (!pkgJson || !pkgJson.scripts) return null return pkgJson.scripts[scriptName] || null } function hasEslintConfig(pkgJson) { if (pkgJson && pkgJson.eslintConfig) return true const configFiles = [ '.eslintrc', '.eslintrc.js', '.eslintrc.cjs', '.eslintrc.json', '.eslintrc.yaml', '.eslintrc.yml', 'eslint.config.js', 'eslint.config.cjs', 'eslint.config.mjs' ] return configFiles.some((file) => fs.existsSync(path.resolve(APP_ROOT, file))) } function hasTsConfig() { return fs.existsSync(path.resolve(APP_ROOT, 'tsconfig.json')) } function toCheckItem(name, result) { if (!result) return null return { name, status: result.status, message: result.message, errors: result.errors || [] } } function buildChecksSummary({ lintResult, typeCheckResult, buildResult }) { return [ toCheckItem('lint', lintResult), toCheckItem('typecheck', typeCheckResult), toCheckItem('build', buildResult) ].filter(Boolean) } async function runCommandCheck({ label, command, args = [], env = {}, logTag }) { logs.push(`${label} check started`) return new Promise((resolve) => { const checkErrors = [] const checkLogs = [] const resolvedCommand = command === 'npm' ? getPreferredNpmCommand() : command === 'npx' ? getPreferredNpxCommand() : command const proc = spawn(resolvedCommand, args, { cwd: APP_ROOT, env: { ...process.env, ...env }, stdio: ['ignore', 'pipe', 'pipe'] }) const appendLog = (line, isError = false) => { if (!line) return checkLogs.push(line) logs.push(`[${logTag}] ${line}`) if (isError && !/deprecated|experimental/i.test(line)) { checkErrors.push(line) } } proc.stdout.on('data', (data) => { appendLog(decodeOutput(data).trim(), false) }) proc.stderr.on('data', (data) => { appendLog(decodeOutput(data).trim(), true) }) proc.on('close', (code) => { if (code === 0 && checkErrors.length === 0) { resolve({ status: 'SUCCESS', message: `${label} completed successfully`, errors: [], logs: checkLogs }) return } resolve({ status: 'FAILED', message: `${label} failed (exit code: ${code})`, errors: checkErrors.length > 0 ? checkErrors : [`${label} exited with code ${code}`], logs: checkLogs }) }) proc.on('error', (err) => { logs.push(`${label} process error: ${err.message}`) resolve({ status: 'FAILED', message: `${label} process error: ${err.message}`, errors: [err.message], logs: checkLogs }) }) }) } async function runLintCheck() { const pkgJson = readPackageJson() const lintScript = getScriptCommand(pkgJson, 'lint') if (lintScript) { return runCommandCheck({ label: 'Lint', command: 'npm', args: ['run', 'lint'], logTag: 'LINT' }) } if (!hasEslintConfig(pkgJson)) { return { status: 'SKIPPED', message: 'Lint skipped: no eslint config or lint script found', errors: [], logs: [] } } return runCommandCheck({ label: 'Lint', command: 'npx', args: ['eslint', '.'], logTag: 'LINT' }) } async function runTypeCheck() { const pkgJson = readPackageJson() const typecheckScript = getScriptCommand(pkgJson, 'typecheck') if (typecheckScript) { return runCommandCheck({ label: 'Typecheck', command: 'npm', args: ['run', 'typecheck'], logTag: 'TYPECHECK' }) } if (!hasTsConfig()) { return { status: 'SKIPPED', message: 'Typecheck skipped: no tsconfig.json or typecheck script found', errors: [], logs: [] } } return runCommandCheck({ label: 'Typecheck', command: 'npx', args: ['tsc', '--noEmit'], logTag: 'TYPECHECK' }) } /** * 扫描并更新 .axhub/make/entries.json * 确保新创建的目录被包含在入口列表中 */ async function scanEntries() { logs.push('Scanning entries...') return new Promise((resolve) => { const scanProcess = spawn(process.execPath, ['scripts/scan-entries.js'], { cwd: APP_ROOT, stdio: ['ignore', 'pipe', 'pipe'] }) scanProcess.stdout.on('data', (data) => { const text = decodeOutput(data).trim() if (text) logs.push(`[SCAN] ${text}`) }) scanProcess.stderr.on('data', (data) => { const text = decodeOutput(data).trim() if (text) logs.push(`[SCAN ERROR] ${text}`) }) scanProcess.on('close', (code) => { if (code === 0) { logs.push('Entry scanning completed') resolve({ success: true }) } else { logs.push(`Entry scanning failed with exit code ${code}`) resolve({ success: false }) } }) scanProcess.on('error', (err) => { logs.push(`Entry scanning error: ${err.message}`) resolve({ success: false }) }) }) } /** * 执行独立构建校验 * 针对指定的入口 key 执行单独构建,不是全量构建 */ async function runBuildCheck(entryKey) { const originalEntryKey = String(entryKey ?? '').trim() logs.push(`Starting build check for entry: ${originalEntryKey || '(auto)'}`) // 先扫描入口,确保 .axhub/make/entries.json 是最新的 const scanResult = await scanEntries() if (!scanResult.success) { return { status: 'FAILED', message: 'Failed to scan entries before build', errors: ['Entry scanning failed'], logs: [] } } const resolvedEntryKey = originalEntryKey || resolveDefaultEntryKey() if (!resolvedEntryKey) { logs.push('Build check skipped: no entry key resolved') return { status: 'SKIPPED', message: 'Build check skipped: no entry key resolved', errors: [], logs: [] } } return new Promise((resolve) => { const buildErrors = [] const buildLogs = [] const npxCommand = getPreferredNpxCommand() // 使用 ENTRY_KEY 环境变量触发单独构建 const buildProcess = spawn(npxCommand, ['vite', 'build'], { cwd: APP_ROOT, env: { ...process.env, ENTRY_KEY: resolvedEntryKey }, stdio: ['ignore', 'pipe', 'pipe'] }) buildProcess.stdout.on('data', (data) => { const text = decodeOutput(data).trim() if (text) { buildLogs.push(text) logs.push(`[BUILD] ${text}`) } }) buildProcess.stderr.on('data', (data) => { const text = decodeOutput(data).trim() if (text) { buildLogs.push(text) logs.push(`[BUILD ERROR] ${text}`) // 捕获构建错误 if (/error|failed/i.test(text) && !/deprecated|experimental/i.test(text)) { buildErrors.push(text) } } }) buildProcess.on('close', (code) => { if (code === 0 && buildErrors.length === 0) { logs.push(`Build check completed successfully for ${resolvedEntryKey}`) resolve({ status: 'SUCCESS', message: `Build completed successfully for ${resolvedEntryKey}`, errors: [], logs: buildLogs }) } else { logs.push(`Build check failed for ${resolvedEntryKey} with exit code ${code}`) resolve({ status: 'FAILED', message: `Build failed for ${resolvedEntryKey} (exit code: ${code})`, errors: buildErrors.length > 0 ? buildErrors : [`Build process exited with code ${code}`], logs: buildLogs }) } }) buildProcess.on('error', (err) => { logs.push(`Build process error: ${err.message}`) resolve({ status: 'FAILED', message: `Build process error: ${err.message}`, errors: [err.message], logs: buildLogs }) }) }) } function resolveDefaultEntryKey() { try { const entriesPath = path.resolve(APP_ROOT, '.axhub/make/entries.json') if (!fs.existsSync(entriesPath)) return null const raw = JSON.parse(fs.readFileSync(entriesPath, 'utf8')) const jsEntries = raw && typeof raw === 'object' ? (raw.js || {}) : {} const keys = Object.keys(jsEntries || {}).filter(Boolean).sort((a, b) => a.localeCompare(b)) if (keys.length === 0) return null const pickFromPrefix = (prefix) => keys.find((k) => k.startsWith(prefix)) return ( pickFromPrefix('prototypes/') || pickFromPrefix('components/') || pickFromPrefix('themes/') || keys[0] || null ) } catch (err) { logs.push(`Failed to resolve default entry key: ${err.message}`) return null } } /** * 从页面路径推断入口 key * 例如:/components/button -> components/button */ function getEntryKeyFromPath(pagePath) { // 移除开头的斜杠 return pagePath.replace(/^\//, '') } /* ================= 主流程 ================= */ async function main() { try { // 获取服务器信息 const serverInfo = getServerInfo() // 如果没有端口信息,等待服务器启动 if (!serverInfo) { logs.push('Waiting for server to start...') // 启动服务器并等待 const viteProcess = startOrAttachVite() // 等待 .axhub/make/.dev-server-info.json 文件生成 const maxWait = 10000 // 10秒 const startTime = Date.now() let newServerInfo = null while (Date.now() - startTime < maxWait) { await new Promise(resolve => setTimeout(resolve, 500)) newServerInfo = getServerInfo() if (newServerInfo) break } if (!newServerInfo) { return jsonExit(addUrls({ status: 'ERROR', phase: 'server', message: 'Server failed to start - no port information available', url: CONFIG.pagePath, errors: ['Server did not write port information within timeout'], logs }, null), 1) } // 使用新获取的服务器信息 const accessibleHost = getAccessibleHost(newServerInfo) const pageUrl = `http://${accessibleHost}:${newServerInfo.port}${CONFIG.pagePath}` logs.push(`Target URL: ${pageUrl}`) logs.push(`Server info: port=${newServerInfo.port}, host=${newServerInfo.host}`) // 继续后续流程... await continueWithServerInfo(newServerInfo, pageUrl, viteProcess) } else { const accessibleHost = getAccessibleHost(serverInfo) const pageUrl = `http://${accessibleHost}:${serverInfo.port}${CONFIG.pagePath}` logs.push(`Target URL: ${pageUrl}`) logs.push(`Server info: port=${serverInfo.port}, host=${serverInfo.host}`) // 继续后续流程... await continueWithServerInfo(serverInfo, pageUrl, null) } } catch (err) { const serverInfo = getServerInfo() jsonExit(addUrls({ status: 'ERROR', phase: 'server', message: err.message, url: CONFIG.pagePath, errors: [String(err)], logs }, serverInfo), 1) } } async function continueWithServerInfo(serverInfo, pageUrl, viteProcess) { try { // 步骤 1: 执行 lint 检查 const lintResult = await runLintCheck() if (lintResult.status === 'FAILED') { return jsonExit(addUrls({ status: 'ERROR', phase: 'lint', message: lintResult.message, url: pageUrl, errors: lintResult.errors, logs, lintCheck: lintResult, checks: buildChecksSummary({ lintResult }) }, serverInfo), 1) } // 步骤 2: 执行 typecheck 检查 const typeCheckResult = await runTypeCheck() if (typeCheckResult.status === 'FAILED') { return jsonExit(addUrls({ status: 'ERROR', phase: 'typecheck', message: typeCheckResult.message, url: pageUrl, errors: typeCheckResult.errors, logs, lintCheck: lintResult, typeCheck: typeCheckResult, checks: buildChecksSummary({ lintResult, typeCheckResult }) }, serverInfo), 1) } // 步骤 3: 执行构建校验(除非指定 --skip-build) let buildResult = null if (!CONFIG.skipBuild) { const entryKey = getEntryKeyFromPath(CONFIG.pagePath) logs.push(`Build check enabled for entry: ${entryKey}`) buildResult = await runBuildCheck(entryKey) // 如果构建失败,直接返回错误 if (buildResult.status === 'FAILED') { return jsonExit(addUrls({ status: 'ERROR', phase: 'build', message: buildResult.message, url: pageUrl, errors: buildResult.errors, logs, buildCheck: buildResult, lintCheck: lintResult, typeCheck: typeCheckResult, checks: buildChecksSummary({ lintResult, typeCheckResult, buildResult }) }, serverInfo), 1) } } else { logs.push('Build check skipped (--skip-build flag)') buildResult = { status: 'SKIPPED', message: 'Build check skipped (--skip-build flag)', errors: [], logs: [] } } // 步骤 4: 开发服务器校验 const accessibleHost = getAccessibleHost(serverInfo) // 检查服务器是否已经在运行 const serverAlreadyRunning = await isServerAlive(`http://${accessibleHost}:${serverInfo.port}`) let viteChild = viteProcess if (!serverAlreadyRunning && !viteChild) { logs.push('Server not running, starting Vite...') viteChild = startOrAttachVite() } else { logs.push('Server already running, skipping start') } // 等待页面可访问 const pageReachable = await waitForPage(pageUrl) if (!pageReachable) { if (viteChild) viteChild.kill() return jsonExit(addUrls({ status: 'TIMEOUT', phase: 'page', message: 'Page never became reachable', url: pageUrl, errors, logs, buildCheck: buildResult, lintCheck: lintResult, typeCheck: typeCheckResult, checks: buildChecksSummary({ lintResult, typeCheckResult, buildResult }) }, serverInfo), 1) } // 等待稳定状态 const result = await waitForStable(pageUrl) // 清理进程 if (viteChild) viteChild.kill() // 添加构建结果到最终输出 const finalResult = { ...result, buildCheck: buildResult, lintCheck: lintResult, typeCheck: typeCheckResult, checks: buildChecksSummary({ lintResult, typeCheckResult, buildResult }) } jsonExit(addUrls(finalResult, serverInfo), result.status === 'READY' ? 0 : 1) } catch (err) { jsonExit(addUrls({ status: 'ERROR', phase: 'server', message: err.message, url: CONFIG.pagePath, errors: [String(err)], logs }, serverInfo), 1) } } main()