fix(onboarding): auto-build Control UI assets
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
59
src/infra/control-ui-assets.test.ts
Normal file
59
src/infra/control-ui-assets.test.ts
Normal 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"),
|
||||
);
|
||||
});
|
||||
});
|
||||
147
src/infra/control-ui-assets.ts
Normal file
147
src/infra/control-ui-assets.ts
Normal 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 };
|
||||
}
|
||||
@@ -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:",
|
||||
|
||||
Reference in New Issue
Block a user