diff --git a/CHANGELOG.md b/CHANGELOG.md index 0526927c5..f70d80d42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - Android: tapping the foreground service notification brings the app to the front. (#179) — thanks @Syhids - Cron tool passes `id` to the gateway for update/remove/run/runs (keeps `jobId` input). (#180) — thanks @adamgall - macOS: treat location permission as always-only to avoid iOS-only enums. (#165) — thanks @Nachx639 +- Onboarding: when running from source, auto-build missing Control UI assets (`pnpm ui:build`). ## 2026.1.4-1 diff --git a/src/commands/configure.ts b/src/commands/configure.ts index 3bddae8e3..f35e03645 100644 --- a/src/commands/configure.ts +++ b/src/commands/configure.ts @@ -21,6 +21,7 @@ import { import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js"; import { resolveGatewayProgramArguments } from "../daemon/program-args.js"; import { resolveGatewayService } from "../daemon/service.js"; +import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js"; import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; import { resolveUserPath, sleep } from "../utils.js"; @@ -618,6 +619,11 @@ export async function runConfigureWizard( } } + const controlUiAssets = await ensureControlUiAssetsBuilt(runtime); + if (!controlUiAssets.ok && controlUiAssets.message) { + runtime.error(controlUiAssets.message); + } + note( (() => { const bind = nextConfig.gateway?.bind ?? "loopback"; diff --git a/src/infra/control-ui-assets.test.ts b/src/infra/control-ui-assets.test.ts new file mode 100644 index 000000000..f25db3cde --- /dev/null +++ b/src/infra/control-ui-assets.test.ts @@ -0,0 +1,59 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { + resolveControlUiDistIndexPath, + resolveControlUiRepoRoot, +} from "./control-ui-assets.js"; + +describe("control UI assets helpers", () => { + it("resolves repo root from src argv1", async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-ui-")); + try { + await fs.mkdir(path.join(tmp, "ui"), { recursive: true }); + await fs.writeFile( + path.join(tmp, "ui", "vite.config.ts"), + "export {};\n", + ); + await fs.writeFile(path.join(tmp, "package.json"), "{}\n"); + await fs.mkdir(path.join(tmp, "src"), { recursive: true }); + await fs.writeFile(path.join(tmp, "src", "index.ts"), "export {};\n"); + + expect(resolveControlUiRepoRoot(path.join(tmp, "src", "index.ts"))).toBe( + tmp, + ); + } finally { + await fs.rm(tmp, { recursive: true, force: true }); + } + }); + + it("resolves repo root from dist argv1", async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-ui-")); + try { + await fs.mkdir(path.join(tmp, "ui"), { recursive: true }); + await fs.writeFile( + path.join(tmp, "ui", "vite.config.ts"), + "export {};\n", + ); + await fs.writeFile(path.join(tmp, "package.json"), "{}\n"); + await fs.mkdir(path.join(tmp, "dist"), { recursive: true }); + await fs.writeFile(path.join(tmp, "dist", "index.js"), "export {};\n"); + + expect(resolveControlUiRepoRoot(path.join(tmp, "dist", "index.js"))).toBe( + tmp, + ); + } finally { + await fs.rm(tmp, { recursive: true, force: true }); + } + }); + + it("resolves dist control-ui index path for dist argv1", () => { + const argv1 = path.join("/tmp", "pkg", "dist", "index.js"); + expect(resolveControlUiDistIndexPath(argv1)).toBe( + path.join("/tmp", "pkg", "dist", "control-ui", "index.html"), + ); + }); +}); diff --git a/src/infra/control-ui-assets.ts b/src/infra/control-ui-assets.ts new file mode 100644 index 000000000..e005789dc --- /dev/null +++ b/src/infra/control-ui-assets.ts @@ -0,0 +1,147 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { runCommandWithTimeout, runExec } from "../process/exec.js"; +import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; + +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 function resolveControlUiDistIndexPath( + argv1: string | undefined = process.argv[1], +): string | null { + if (!argv1) return null; + const normalized = path.resolve(argv1); + const distDir = path.dirname(normalized); + if (path.basename(distDir) !== "dist") return null; + return path.join(distDir, "control-ui", "index.html"); +} + +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 indexFromDist = resolveControlUiDistIndexPath(process.argv[1]); + if (indexFromDist && fs.existsSync(indexFromDist)) { + 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\`.`, + }; + } + + const indexPath = path.join(repoRoot, "dist", "control-ui", "index.html"); + if (fs.existsSync(indexPath)) { + return { ok: true, built: false }; + } + + const pnpmWhich = process.platform === "win32" ? "where" : "which"; + const pnpm = await runExec(pnpmWhich, ["pnpm"]) + .then( + (r) => + r.stdout + .split(/\r?\n/g) + .map((l) => l.trim()) + .find(Boolean) ?? "", + ) + .catch(() => ""); + if (!pnpm) { + return { + ok: false, + built: false, + message: + "Control UI assets not found and pnpm missing. Install pnpm, then run `pnpm ui:build`.", + }; + } + + runtime.log("Control UI assets missing; building (pnpm ui:build)…"); + + const ensureInstalled = !fs.existsSync( + path.join(repoRoot, "ui", "node_modules"), + ); + if (ensureInstalled) { + const install = await runCommandWithTimeout([pnpm, "ui:install"], { + cwd: repoRoot, + timeoutMs: opts?.timeoutMs ?? 10 * 60_000, + }); + if (install.code !== 0) { + return { + ok: false, + built: false, + message: `Control UI install failed: ${summarizeCommandOutput(install.stderr) ?? `exit ${install.code}`}`, + }; + } + } + + const build = await runCommandWithTimeout([pnpm, "ui: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 }; +} diff --git a/src/wizard/onboarding.ts b/src/wizard/onboarding.ts index 728474511..ca86475d3 100644 --- a/src/wizard/onboarding.ts +++ b/src/wizard/onboarding.ts @@ -45,6 +45,7 @@ import { import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js"; import { resolveGatewayProgramArguments } from "../daemon/program-args.js"; import { resolveGatewayService } from "../daemon/service.js"; +import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js"; import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; import { resolveUserPath, sleep } from "../utils.js"; @@ -491,6 +492,11 @@ export async function runOnboardingWizard( runtime.error(`Health check failed: ${String(err)}`); } + const controlUiAssets = await ensureControlUiAssetsBuilt(runtime); + if (!controlUiAssets.ok && controlUiAssets.message) { + runtime.error(controlUiAssets.message); + } + await prompter.note( [ "Add nodes for extra features:",