import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { runCommandWithTimeout } from "../process/exec.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { resolveOpenClawPackageRoot, resolveOpenClawPackageRootSync } from "./openclaw-root.js"; const CONTROL_UI_DIST_PATH_SEGMENTS = ["dist", "control-ui", "index.html"] as const; export function resolveControlUiDistIndexPathForRoot(root: string): string { return path.join(root, ...CONTROL_UI_DIST_PATH_SEGMENTS); } export type ControlUiDistIndexHealth = { indexPath: string | null; exists: boolean; }; export async function resolveControlUiDistIndexHealth( opts: { root?: string; argv1?: string; moduleUrl?: string; } = {}, ): Promise { const indexPath = opts.root ? resolveControlUiDistIndexPathForRoot(opts.root) : await resolveControlUiDistIndexPath({ argv1: opts.argv1 ?? process.argv[1], moduleUrl: opts.moduleUrl, }); return { indexPath, exists: Boolean(indexPath && fs.existsSync(indexPath)), }; } export function resolveControlUiRepoRoot( argv1: string | undefined = process.argv[1], ): string | null { if (!argv1) { return null; } const normalized = path.resolve(argv1); const parts = normalized.split(path.sep); const srcIndex = parts.lastIndexOf("src"); if (srcIndex !== -1) { const root = parts.slice(0, srcIndex).join(path.sep); if (fs.existsSync(path.join(root, "ui", "vite.config.ts"))) { return root; } } let dir = path.dirname(normalized); for (let i = 0; i < 8; i++) { if ( fs.existsSync(path.join(dir, "package.json")) && fs.existsSync(path.join(dir, "ui", "vite.config.ts")) ) { return dir; } const parent = path.dirname(dir); if (parent === dir) { break; } dir = parent; } return null; } export async function resolveControlUiDistIndexPath( argv1OrOpts?: string | { argv1?: string; moduleUrl?: string }, ): Promise { const argv1 = typeof argv1OrOpts === "string" ? argv1OrOpts : (argv1OrOpts?.argv1 ?? process.argv[1]); const moduleUrl = typeof argv1OrOpts === "object" ? argv1OrOpts?.moduleUrl : undefined; if (!argv1) { return null; } const normalized = path.resolve(argv1); // Case 1: entrypoint is directly inside dist/ (e.g., dist/entry.js) const distDir = path.dirname(normalized); if (path.basename(distDir) === "dist") { return path.join(distDir, "control-ui", "index.html"); } const packageRoot = await resolveOpenClawPackageRoot({ argv1: normalized, moduleUrl }); if (packageRoot) { return path.join(packageRoot, "dist", "control-ui", "index.html"); } // Fallback: traverse up and find package.json with name "openclaw" + dist/control-ui/index.html // This handles global installs where path-based resolution might fail. let dir = path.dirname(normalized); for (let i = 0; i < 8; i++) { const pkgJsonPath = path.join(dir, "package.json"); const indexPath = path.join(dir, "dist", "control-ui", "index.html"); if (fs.existsSync(pkgJsonPath)) { try { const raw = fs.readFileSync(pkgJsonPath, "utf-8"); const parsed = JSON.parse(raw) as { name?: unknown }; if (parsed.name === "openclaw") { return fs.existsSync(indexPath) ? indexPath : null; } // Stop at the first package boundary to avoid resolving through unrelated ancestors. return null; } catch { // Invalid package.json at package boundary; abort fallback resolution. return null; } } const parent = path.dirname(dir); if (parent === dir) { break; } dir = parent; } return null; } export type ControlUiRootResolveOptions = { argv1?: string; moduleUrl?: string; cwd?: string; execPath?: string; }; function addCandidate(candidates: Set, value: string | null) { if (!value) { return; } candidates.add(path.resolve(value)); } export function resolveControlUiRootOverrideSync(rootOverride: string): string | null { const resolved = path.resolve(rootOverride); try { const stats = fs.statSync(resolved); if (stats.isFile()) { return path.basename(resolved) === "index.html" ? path.dirname(resolved) : null; } if (stats.isDirectory()) { const indexPath = path.join(resolved, "index.html"); return fs.existsSync(indexPath) ? resolved : null; } } catch { return null; } return null; } export function resolveControlUiRootSync(opts: ControlUiRootResolveOptions = {}): string | null { const candidates = new Set(); const argv1 = opts.argv1 ?? process.argv[1]; const cwd = opts.cwd ?? process.cwd(); const moduleDir = opts.moduleUrl ? path.dirname(fileURLToPath(opts.moduleUrl)) : null; const argv1Dir = argv1 ? path.dirname(path.resolve(argv1)) : null; const execDir = (() => { try { const execPath = opts.execPath ?? process.execPath; return path.dirname(fs.realpathSync(execPath)); } catch { return null; } })(); const packageRoot = resolveOpenClawPackageRootSync({ argv1, moduleUrl: opts.moduleUrl, cwd, }); // Packaged app: control-ui lives alongside the executable. addCandidate(candidates, execDir ? path.join(execDir, "control-ui") : null); if (moduleDir) { // dist/.js -> dist/control-ui addCandidate(candidates, path.join(moduleDir, "control-ui")); // dist/gateway/control-ui.js -> dist/control-ui addCandidate(candidates, path.join(moduleDir, "../control-ui")); // src/gateway/control-ui.ts -> dist/control-ui addCandidate(candidates, path.join(moduleDir, "../../dist/control-ui")); } if (argv1Dir) { // openclaw.mjs or dist/.js addCandidate(candidates, path.join(argv1Dir, "dist", "control-ui")); addCandidate(candidates, path.join(argv1Dir, "control-ui")); } if (packageRoot) { addCandidate(candidates, path.join(packageRoot, "dist", "control-ui")); } addCandidate(candidates, path.join(cwd, "dist", "control-ui")); for (const dir of candidates) { const indexPath = path.join(dir, "index.html"); if (fs.existsSync(indexPath)) { return dir; } } return null; } export type EnsureControlUiAssetsResult = { ok: boolean; built: boolean; message?: string; }; function summarizeCommandOutput(text: string): string | undefined { const lines = text .split(/\r?\n/g) .map((l) => l.trim()) .filter(Boolean); if (!lines.length) { return undefined; } const last = lines.at(-1); if (!last) { return undefined; } return last.length > 240 ? `${last.slice(0, 239)}…` : last; } export async function ensureControlUiAssetsBuilt( runtime: RuntimeEnv = defaultRuntime, opts?: { timeoutMs?: number }, ): Promise { const health = await resolveControlUiDistIndexHealth({ argv1: process.argv[1] }); const indexFromDist = health.indexPath; if (health.exists) { return { ok: true, built: false }; } const repoRoot = resolveControlUiRepoRoot(process.argv[1]); if (!repoRoot) { const hint = indexFromDist ? `Missing Control UI assets at ${indexFromDist}` : "Missing Control UI assets"; return { ok: false, built: false, message: `${hint}. Build them with \`pnpm ui:build\` (auto-installs UI deps).`, }; } const indexPath = resolveControlUiDistIndexPathForRoot(repoRoot); if (fs.existsSync(indexPath)) { return { ok: true, built: false }; } const uiScript = path.join(repoRoot, "scripts", "ui.js"); if (!fs.existsSync(uiScript)) { return { ok: false, built: false, message: `Control UI assets missing but ${uiScript} is unavailable.`, }; } runtime.log("Control UI assets missing; building (ui:build, auto-installs UI deps)…"); const build = await runCommandWithTimeout([process.execPath, uiScript, "build"], { cwd: repoRoot, timeoutMs: opts?.timeoutMs ?? 10 * 60_000, }); if (build.code !== 0) { return { ok: false, built: false, message: `Control UI build failed: ${summarizeCommandOutput(build.stderr) ?? `exit ${build.code}`}`, }; } if (!fs.existsSync(indexPath)) { return { ok: false, built: true, message: `Control UI build completed but ${indexPath} is still missing.`, }; } return { ok: true, built: true }; }