diff --git a/src/commands/doctor-ui.ts b/src/commands/doctor-ui.ts new file mode 100644 index 000000000..2cf8478a1 --- /dev/null +++ b/src/commands/doctor-ui.ts @@ -0,0 +1,84 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { resolveClawdbotPackageRoot } from "../infra/clawdbot-root.js"; +import { runCommandWithTimeout } from "../process/exec.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { note } from "../terminal/note.js"; +import type { DoctorPrompter } from "./doctor-prompter.js"; + +export async function maybeRepairUiProtocolFreshness( + _runtime: RuntimeEnv, + prompter: DoctorPrompter, +) { + const root = await resolveClawdbotPackageRoot({ + moduleUrl: import.meta.url, + argv1: process.argv[1], + cwd: process.cwd(), + }); + + if (!root) return; + + const schemaPath = path.join(root, "src/gateway/protocol/schema.ts"); + const uiIndexPath = path.join(root, "dist/control-ui/index.html"); + + try { + const [schemaStats, uiStats] = await Promise.all([ + fs.stat(schemaPath), + fs.stat(uiIndexPath), + ]); + + if (schemaStats.mtime > uiStats.mtime) { + const uiMtimeIso = uiStats.mtime.toISOString(); + // Find changes since the UI build + const gitLog = await runCommandWithTimeout( + [ + "git", + "-C", + root, + "log", + `--since=${uiMtimeIso}`, + "--format=%h %s", + "src/gateway/protocol/schema.ts", + ], + { timeoutMs: 5000 }, + ).catch(() => null); + + if (gitLog?.stdout.trim()) { + note( + `UI assets are older than the protocol schema.\nFunctional changes since last build:\n${gitLog.stdout + .trim() + .split("\n") + .map((l) => `- ${l}`) + .join("\n")}`, + "UI Freshness", + ); + + const shouldRepair = await prompter.confirmAggressive({ + message: + "Rebuild UI now? (Detected protocol mismatch requiring update)", + initialValue: true, + }); + + if (shouldRepair) { + note("Rebuilding stale UI assets... (this may take a moment)", "UI"); + // Use scripts/ui.js to build, assuming node is available as we are running in it. + // We use the same node executable to run the script. + const uiScriptPath = path.join(root, "scripts/ui.js"); + await runCommandWithTimeout( + [process.execPath, uiScriptPath, "build"], + { + cwd: root, + timeoutMs: 120_000, + env: { ...process.env, FORCE_COLOR: "1" }, + }, + ); + note("UI rebuild complete.", "UI"); + } + } + } + } catch (_err) { + // If files don't exist, we can't check. + // If git fails, we silently skip. + // runtime.debug(`UI freshness check failed: ${String(err)}`); + } +} diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index d9fea75ed..09d6b6869 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -66,6 +66,7 @@ import { maybeMigrateLegacyConfigFile, normalizeLegacyConfigValues, } from "./doctor-legacy-config.js"; +import { maybeRepairUiProtocolFreshness } from "./doctor-ui.js"; import { createDoctorPrompter, type DoctorOptions } from "./doctor-prompter.js"; import { maybeRepairSandboxImages, @@ -271,9 +272,9 @@ export async function doctorCommand( options.nonInteractive === true ? true : await prompter.confirm({ - message: "Migrate legacy config entries now?", - initialValue: true, - }); + message: "Migrate legacy config entries now?", + initialValue: true, + }); if (migrate) { // Legacy migration (2026-01-02, commit: 16420e5b) — normalize per-provider allowlists; move WhatsApp gating into whatsapp.allowFrom. const { config: migrated, changes } = migrateLegacyConfig( @@ -326,9 +327,9 @@ export async function doctorCommand( : options.nonInteractive === true ? false : await prompter.confirmRepair({ - message: "Generate and configure a gateway token now?", - initialValue: true, - }); + message: "Generate and configure a gateway token now?", + initialValue: true, + }); if (shouldSetToken) { const nextToken = randomToken(); cfg = { @@ -354,9 +355,9 @@ export async function doctorCommand( options.nonInteractive === true ? true : await prompter.confirm({ - message: "Migrate legacy state (sessions/agent/WhatsApp auth) now?", - initialValue: true, - }); + message: "Migrate legacy state (sessions/agent/WhatsApp auth) now?", + initialValue: true, + }); if (migrate) { const migrated = await runLegacyStateMigrations({ detected: legacyState, @@ -478,13 +479,11 @@ export async function doctorCommand( note( [ `Eligible: ${skillsReport.skills.filter((s) => s.eligible).length}`, - `Missing requirements: ${ - skillsReport.skills.filter( - (s) => !s.eligible && !s.disabled && !s.blockedByAllowlist, - ).length + `Missing requirements: ${skillsReport.skills.filter( + (s) => !s.eligible && !s.disabled && !s.blockedByAllowlist, + ).length }`, - `Blocked by allowlist: ${ - skillsReport.skills.filter((s) => s.blockedByAllowlist).length + `Blocked by allowlist: ${skillsReport.skills.filter((s) => s.blockedByAllowlist).length }`, ].join("\n"), "Skills status", @@ -494,10 +493,10 @@ export async function doctorCommand( config: cfg, workspaceDir, logger: { - info: () => {}, - warn: () => {}, - error: () => {}, - debug: () => {}, + info: () => { }, + warn: () => { }, + error: () => { }, + debug: () => { }, }, }); if (pluginRegistry.plugins.length > 0) { @@ -513,9 +512,9 @@ export async function doctorCommand( `Errors: ${errored.length}`, errored.length > 0 ? `- ${errored - .slice(0, 10) - .map((p) => p.id) - .join("\n- ")}${errored.length > 10 ? "\n- ..." : ""}` + .slice(0, 10) + .map((p) => p.id) + .join("\n- ")}${errored.length > 10 ? "\n- ..." : ""}` : null, ].filter((line): line is string => Boolean(line));