diff --git a/CHANGELOG.md b/CHANGELOG.md index 993dff71e..73fb59bec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -74,6 +74,7 @@ Status: stable. - **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed). ### Fixes +- Infra: resolve Control UI assets for npm global installs. (#4909) Thanks @YuriNachos. - Gateway: prevent blank token prompts from storing "undefined". (#4873) Thanks @Hisleren. - Telegram: use undici fetch for per-account proxy dispatcher. (#4456) Thanks @spiceoogway. - Telegram: fix HTML nesting for overlapping styles and links. (#4578) Thanks @ThanhNguyxn. diff --git a/src/infra/control-ui-assets.test.ts b/src/infra/control-ui-assets.test.ts index 2e8bd6448..7f3e99d4c 100644 --- a/src/infra/control-ui-assets.test.ts +++ b/src/infra/control-ui-assets.test.ts @@ -37,11 +37,46 @@ describe("control UI assets helpers", () => { } }); - it("resolves dist control-ui index path for dist argv1", () => { + it("resolves dist control-ui index path for dist argv1", async () => { const argv1 = path.resolve("/tmp", "pkg", "dist", "index.js"); const distDir = path.dirname(argv1); - expect(resolveControlUiDistIndexPath(argv1)).toBe( + expect(await resolveControlUiDistIndexPath(argv1)).toBe( path.join(distDir, "control-ui", "index.html"), ); }); + + it("resolves dist control-ui index path from package root argv1", async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ui-")); + try { + await fs.writeFile(path.join(tmp, "package.json"), JSON.stringify({ name: "openclaw" })); + await fs.writeFile(path.join(tmp, "openclaw.mjs"), "export {};\n"); + await fs.mkdir(path.join(tmp, "dist", "control-ui"), { recursive: true }); + await fs.writeFile(path.join(tmp, "dist", "control-ui", "index.html"), "\n"); + + expect(await resolveControlUiDistIndexPath(path.join(tmp, "openclaw.mjs"))).toBe( + path.join(tmp, "dist", "control-ui", "index.html"), + ); + } finally { + await fs.rm(tmp, { recursive: true, force: true }); + } + }); + + it("resolves dist control-ui index path from .bin argv1", async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ui-")); + try { + const binDir = path.join(tmp, "node_modules", ".bin"); + const pkgRoot = path.join(tmp, "node_modules", "openclaw"); + await fs.mkdir(binDir, { recursive: true }); + await fs.mkdir(path.join(pkgRoot, "dist", "control-ui"), { recursive: true }); + await fs.writeFile(path.join(binDir, "openclaw"), "#!/usr/bin/env node\n"); + await fs.writeFile(path.join(pkgRoot, "package.json"), JSON.stringify({ name: "openclaw" })); + await fs.writeFile(path.join(pkgRoot, "dist", "control-ui", "index.html"), "\n"); + + expect(await resolveControlUiDistIndexPath(path.join(binDir, "openclaw"))).toBe( + path.join(pkgRoot, "dist", "control-ui", "index.html"), + ); + } finally { + await fs.rm(tmp, { recursive: true, force: true }); + } + }); }); diff --git a/src/infra/control-ui-assets.ts b/src/infra/control-ui-assets.ts index e60b14443..423be3d57 100644 --- a/src/infra/control-ui-assets.ts +++ b/src/infra/control-ui-assets.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { runCommandWithTimeout } from "../process/exec.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; +import { resolveOpenClawPackageRoot } from "./openclaw-root.js"; export function resolveControlUiRepoRoot( argv1: string | undefined = process.argv[1], @@ -32,9 +33,9 @@ export function resolveControlUiRepoRoot( return null; } -export function resolveControlUiDistIndexPath( +export async function resolveControlUiDistIndexPath( argv1: string | undefined = process.argv[1], -): string | null { +): Promise { if (!argv1) return null; const normalized = path.resolve(argv1); @@ -44,32 +45,9 @@ export function resolveControlUiDistIndexPath( return path.join(distDir, "control-ui", "index.html"); } - // Case 2: npm global install - entrypoint is at package root (e.g., openclaw.mjs) - // or in node_modules/.bin/. Walk up to find package.json with dist/control-ui/ - const parts = normalized.split(path.sep); - - // Handle .bin symlink: node_modules/.bin/openclaw -> node_modules/openclaw/... - const binIndex = parts.lastIndexOf(".bin"); - if (binIndex > 0 && parts[binIndex - 1] === "node_modules") { - const binName = path.basename(normalized); - const nodeModulesDir = parts.slice(0, binIndex).join(path.sep); - const pkgPath = path.join(nodeModulesDir, binName, "dist", "control-ui", "index.html"); - if (fs.existsSync(pkgPath)) return pkgPath; - } - - // Walk up from entrypoint looking for package with dist/control-ui/ - let dir = path.dirname(normalized); - for (let i = 0; i < 8; i++) { - const candidate = path.join(dir, "dist", "control-ui", "index.html"); - if (fs.existsSync(path.join(dir, "package.json")) && fs.existsSync(candidate)) { - return candidate; - } - const parent = path.dirname(dir); - if (parent === dir) break; - dir = parent; - } - - return null; + const packageRoot = await resolveOpenClawPackageRoot({ argv1: normalized }); + if (!packageRoot) return null; + return path.join(packageRoot, "dist", "control-ui", "index.html"); } export type EnsureControlUiAssetsResult = { @@ -93,7 +71,7 @@ export async function ensureControlUiAssetsBuilt( runtime: RuntimeEnv = defaultRuntime, opts?: { timeoutMs?: number }, ): Promise { - const indexFromDist = resolveControlUiDistIndexPath(process.argv[1]); + const indexFromDist = await resolveControlUiDistIndexPath(process.argv[1]); if (indexFromDist && fs.existsSync(indexFromDist)) { return { ok: true, built: false }; }