fix(onboarding): auto-build Control UI assets

This commit is contained in:
Peter Steinberger
2026-01-04 21:53:23 +01:00
parent ff605194ef
commit 0faa200924
5 changed files with 219 additions and 0 deletions

View File

@@ -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

View File

@@ -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";

View File

@@ -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"),
);
});
});

View File

@@ -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<EnsureControlUiAssetsResult> {
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 };
}

View File

@@ -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:",