diff --git a/src/commands/doctor.test.ts b/src/commands/doctor.test.ts index 867d164f4..40e648053 100644 --- a/src/commands/doctor.test.ts +++ b/src/commands/doctor.test.ts @@ -7,6 +7,30 @@ const migrateLegacyConfig = vi.fn((raw: unknown) => ({ changes: ["Moved routing.allowFrom → whatsapp.allowFrom."], })); +const legacyReadConfigFileSnapshot = vi.fn().mockResolvedValue({ + path: "/tmp/clawdis.json", + exists: false, + raw: null, + parsed: {}, + valid: true, + config: {}, + issues: [], + legacyIssues: [], +}); +const createConfigIO = vi.fn(() => ({ + readConfigFileSnapshot: legacyReadConfigFileSnapshot, +})); + +const findLegacyGatewayServices = vi.fn().mockResolvedValue([]); +const uninstallLegacyGatewayServices = vi.fn().mockResolvedValue([]); +const resolveGatewayProgramArguments = vi.fn().mockResolvedValue({ + programArguments: ["node", "cli", "gateway-daemon", "--port", "18789"], +}); +const serviceInstall = vi.fn().mockResolvedValue(undefined); +const serviceIsLoaded = vi.fn().mockResolvedValue(false); +const serviceRestart = vi.fn().mockResolvedValue(undefined); +const serviceUninstall = vi.fn().mockResolvedValue(undefined); + vi.mock("@clack/prompts", () => ({ confirm: vi.fn().mockResolvedValue(true), intro: vi.fn(), @@ -20,11 +44,34 @@ vi.mock("../agents/skills-status.js", () => ({ vi.mock("../config/config.js", () => ({ CONFIG_PATH_CLAWDBOT: "/tmp/clawdbot.json", + createConfigIO, readConfigFileSnapshot, writeConfigFile, migrateLegacyConfig, })); +vi.mock("../daemon/legacy.js", () => ({ + findLegacyGatewayServices, + uninstallLegacyGatewayServices, +})); + +vi.mock("../daemon/program-args.js", () => ({ + resolveGatewayProgramArguments, +})); + +vi.mock("../daemon/service.js", () => ({ + resolveGatewayService: () => ({ + label: "LaunchAgent", + loadedText: "loaded", + notLoadedText: "not loaded", + install: serviceInstall, + uninstall: serviceUninstall, + restart: serviceRestart, + isLoaded: serviceIsLoaded, + readCommand: vi.fn(), + }), +})); + vi.mock("../runtime.js", () => ({ defaultRuntime: { log: () => {}, @@ -98,4 +145,161 @@ describe("doctor", () => { ]); expect(written.routing).toBeUndefined(); }); + + it("migrates legacy Clawdis services", async () => { + readConfigFileSnapshot.mockResolvedValue({ + path: "/tmp/clawdbot.json", + exists: true, + raw: "{}", + parsed: {}, + valid: true, + config: {}, + issues: [], + legacyIssues: [], + }); + + findLegacyGatewayServices.mockResolvedValueOnce([ + { + platform: "darwin", + label: "com.clawdis.gateway", + detail: "loaded", + }, + ]); + serviceIsLoaded.mockResolvedValueOnce(false); + serviceInstall.mockClear(); + + const { doctorCommand } = await import("./doctor.js"); + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + await doctorCommand(runtime); + + expect(uninstallLegacyGatewayServices).toHaveBeenCalledTimes(1); + expect(serviceInstall).toHaveBeenCalledTimes(1); + }); + + it("migrates legacy config file", async () => { + readConfigFileSnapshot + .mockResolvedValueOnce({ + path: "/tmp/clawdbot.json", + exists: false, + raw: null, + parsed: {}, + valid: true, + config: {}, + issues: [], + legacyIssues: [], + }) + .mockResolvedValueOnce({ + path: "/tmp/clawdbot.json", + exists: true, + raw: "{}", + parsed: { + gateway: { mode: "local", bind: "loopback" }, + agent: { + workspace: "/Users/steipete/clawdbot", + sandbox: { + workspaceRoot: "/Users/steipete/clawdbot/sandboxes", + docker: { + image: "clawdbot-sandbox", + containerPrefix: "clawdbot-sbx", + }, + }, + }, + }, + valid: true, + config: { + gateway: { mode: "local", bind: "loopback" }, + agent: { + workspace: "/Users/steipete/clawdbot", + sandbox: { + workspaceRoot: "/Users/steipete/clawdbot/sandboxes", + docker: { + image: "clawdbot-sandbox", + containerPrefix: "clawdbot-sbx", + }, + }, + }, + }, + issues: [], + legacyIssues: [], + }); + + legacyReadConfigFileSnapshot.mockResolvedValueOnce({ + path: "/Users/steipete/.clawdis/clawdis.json", + exists: true, + raw: "{}", + parsed: { + gateway: { mode: "local", bind: "loopback" }, + agent: { + workspace: "/Users/steipete/clawd", + sandbox: { + workspaceRoot: "/Users/steipete/clawd/sandboxes", + docker: { + image: "clawdis-sandbox", + containerPrefix: "clawdis-sbx", + }, + }, + }, + }, + valid: true, + config: { + gateway: { mode: "local", bind: "loopback" }, + agent: { + workspace: "/Users/steipete/clawd", + sandbox: { + workspaceRoot: "/Users/steipete/clawd/sandboxes", + docker: { + image: "clawdis-sandbox", + containerPrefix: "clawdis-sbx", + }, + }, + }, + }, + issues: [], + legacyIssues: [], + }); + + migrateLegacyConfig.mockReturnValueOnce({ + config: { + gateway: { mode: "local", bind: "loopback" }, + agent: { + workspace: "/Users/steipete/clawd", + sandbox: { + workspaceRoot: "/Users/steipete/clawd/sandboxes", + docker: { + image: "clawdis-sandbox", + containerPrefix: "clawdis-sbx", + }, + }, + }, + }, + changes: [], + }); + + const { doctorCommand } = await import("./doctor.js"); + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + await doctorCommand(runtime); + + const written = writeConfigFile.mock.calls.at(-1)?.[0] as Record< + string, + unknown + >; + const agent = written.agent as Record; + const sandbox = agent.sandbox as Record; + const docker = sandbox.docker as Record; + + expect(agent.workspace).toBe("/Users/steipete/clawdbot"); + expect(sandbox.workspaceRoot).toBe("/Users/steipete/clawdbot/sandboxes"); + expect(docker.image).toBe("clawdbot-sandbox"); + expect(docker.containerPrefix).toBe("clawdbot-sbx"); + }); }); diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index f687b29a0..d49daad8d 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -1,13 +1,24 @@ +import os from "node:os"; +import path from "node:path"; + import { confirm, intro, note, outro } from "@clack/prompts"; import { buildWorkspaceSkillStatus } from "../agents/skills-status.js"; import type { ClawdbotConfig } from "../config/config.js"; import { CONFIG_PATH_CLAWDBOT, + createConfigIO, migrateLegacyConfig, readConfigFileSnapshot, writeConfigFile, } from "../config/config.js"; +import { resolveGatewayPort, resolveIsNixMode } from "../config/paths.js"; +import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js"; +import { + findLegacyGatewayServices, + uninstallLegacyGatewayServices, +} from "../daemon/legacy.js"; +import { resolveGatewayProgramArguments } from "../daemon/program-args.js"; import { resolveGatewayService } from "../daemon/service.js"; import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; @@ -24,10 +35,272 @@ function resolveMode(cfg: ClawdbotConfig): "local" | "remote" { return cfg.gateway?.mode === "remote" ? "remote" : "local"; } +function resolveLegacyConfigPath(env: NodeJS.ProcessEnv): string { + const override = env.CLAWDIS_CONFIG_PATH?.trim(); + if (override) return override; + return path.join(os.homedir(), ".clawdis", "clawdis.json"); +} + +function replacePathSegment( + value: string | undefined, + from: string, + to: string, +): string | undefined { + if (!value) return value; + const pattern = new RegExp(`(^|[\\/])${from}([\\/]|$)`, "g"); + if (!pattern.test(value)) return value; + return value.replace(pattern, `$1${to}$2`); +} + +function replaceLegacyName(value: string | undefined): string | undefined { + if (!value) return value; + const replacedClawdis = value.replace(/clawdis/g, "clawdbot"); + return replacedClawdis.replace(/clawd(?!bot)/g, "clawdbot"); +} + +function normalizeLegacyConfigValues(cfg: ClawdbotConfig): { + config: ClawdbotConfig; + changes: string[]; +} { + const changes: string[] = []; + let next: ClawdbotConfig = cfg; + + const workspace = cfg.agent?.workspace; + const updatedWorkspace = replacePathSegment( + replacePathSegment(workspace, "clawdis", "clawdbot"), + "clawd", + "clawdbot", + ); + if (updatedWorkspace && updatedWorkspace !== workspace) { + next = { + ...next, + agent: { + ...next.agent, + workspace: updatedWorkspace, + }, + }; + changes.push(`Updated agent.workspace → ${updatedWorkspace}`); + } + + const workspaceRoot = cfg.agent?.sandbox?.workspaceRoot; + const updatedWorkspaceRoot = replacePathSegment( + replacePathSegment(workspaceRoot, "clawdis", "clawdbot"), + "clawd", + "clawdbot", + ); + if (updatedWorkspaceRoot && updatedWorkspaceRoot !== workspaceRoot) { + next = { + ...next, + agent: { + ...next.agent, + sandbox: { + ...next.agent?.sandbox, + workspaceRoot: updatedWorkspaceRoot, + }, + }, + }; + changes.push( + `Updated agent.sandbox.workspaceRoot → ${updatedWorkspaceRoot}`, + ); + } + + const dockerImage = cfg.agent?.sandbox?.docker?.image; + const updatedDockerImage = replaceLegacyName(dockerImage); + if (updatedDockerImage && updatedDockerImage !== dockerImage) { + next = { + ...next, + agent: { + ...next.agent, + sandbox: { + ...next.agent?.sandbox, + docker: { + ...next.agent?.sandbox?.docker, + image: updatedDockerImage, + }, + }, + }, + }; + changes.push(`Updated agent.sandbox.docker.image → ${updatedDockerImage}`); + } + + const containerPrefix = cfg.agent?.sandbox?.docker?.containerPrefix; + const updatedContainerPrefix = replaceLegacyName(containerPrefix); + if (updatedContainerPrefix && updatedContainerPrefix !== containerPrefix) { + next = { + ...next, + agent: { + ...next.agent, + sandbox: { + ...next.agent?.sandbox, + docker: { + ...next.agent?.sandbox?.docker, + containerPrefix: updatedContainerPrefix, + }, + }, + }, + }; + changes.push( + `Updated agent.sandbox.docker.containerPrefix → ${updatedContainerPrefix}`, + ); + } + + return { config: next, changes }; +} + +async function maybeMigrateLegacyConfigFile(runtime: RuntimeEnv) { + const legacyConfigPath = resolveLegacyConfigPath(process.env); + if (legacyConfigPath === CONFIG_PATH_CLAWDBOT) return; + + const legacyIo = createConfigIO({ configPath: legacyConfigPath }); + const legacySnapshot = await legacyIo.readConfigFileSnapshot(); + if (!legacySnapshot.exists) return; + + const currentSnapshot = await readConfigFileSnapshot(); + if (currentSnapshot.exists) { + note( + `Legacy config still exists at ${legacyConfigPath}. Current config at ${CONFIG_PATH_CLAWDBOT}.`, + "Legacy config", + ); + return; + } + + const gatewayMode = + typeof (legacySnapshot.parsed as ClawdbotConfig)?.gateway?.mode === "string" + ? (legacySnapshot.parsed as ClawdbotConfig).gateway?.mode + : undefined; + const gatewayBind = + typeof (legacySnapshot.parsed as ClawdbotConfig)?.gateway?.bind === "string" + ? (legacySnapshot.parsed as ClawdbotConfig).gateway?.bind + : undefined; + const agentWorkspace = + typeof (legacySnapshot.parsed as ClawdbotConfig)?.agent?.workspace === + "string" + ? (legacySnapshot.parsed as ClawdbotConfig).agent?.workspace + : undefined; + + note( + [ + `- File exists at ${legacyConfigPath}`, + gatewayMode ? `- gateway.mode: ${gatewayMode}` : undefined, + gatewayBind ? `- gateway.bind: ${gatewayBind}` : undefined, + agentWorkspace ? `- agent.workspace: ${agentWorkspace}` : undefined, + ] + .filter(Boolean) + .join("\n"), + "Legacy Clawdis config detected", + ); + + let nextConfig = legacySnapshot.valid ? legacySnapshot.config : null; + const { config: migratedConfig, changes } = migrateLegacyConfig( + legacySnapshot.parsed, + ); + if (migratedConfig) { + nextConfig = migratedConfig; + } else if (!nextConfig) { + note( + `Legacy config at ${legacyConfigPath} is invalid; skipping migration.`, + "Legacy config", + ); + return; + } + + const normalized = normalizeLegacyConfigValues(nextConfig); + const mergedChanges = [...changes, ...normalized.changes]; + if (mergedChanges.length > 0) { + note(mergedChanges.join("\n"), "Doctor changes"); + } + + await writeConfigFile(normalized.config); + runtime.log(`Migrated legacy config to ${CONFIG_PATH_CLAWDBOT}`); +} + +async function maybeMigrateLegacyGatewayService( + cfg: ClawdbotConfig, + runtime: RuntimeEnv, +) { + const legacyServices = await findLegacyGatewayServices(process.env); + if (legacyServices.length === 0) return; + + note( + legacyServices + .map((svc) => `- ${svc.label} (${svc.platform}, ${svc.detail})`) + .join("\n"), + "Legacy Clawdis services detected", + ); + + const migrate = guardCancel( + await confirm({ + message: "Migrate legacy Clawdis services to Clawdbot now?", + initialValue: true, + }), + runtime, + ); + if (!migrate) return; + + try { + await uninstallLegacyGatewayServices({ + env: process.env, + stdout: process.stdout, + }); + } catch (err) { + runtime.error(`Legacy service cleanup failed: ${String(err)}`); + return; + } + + if (resolveIsNixMode(process.env)) { + note("Nix mode detected; skip installing services.", "Gateway"); + return; + } + + if (resolveMode(cfg) === "remote") { + note("Gateway mode is remote; skipped local service install.", "Gateway"); + return; + } + + const service = resolveGatewayService(); + const loaded = await service.isLoaded({ env: process.env }); + if (loaded) { + note(`Clawdbot ${service.label} already ${service.loadedText}.`, "Gateway"); + return; + } + + const install = guardCancel( + await confirm({ + message: "Install Clawdbot gateway service now?", + initialValue: true, + }), + runtime, + ); + if (!install) return; + + const devMode = + process.argv[1]?.includes(`${path.sep}src${path.sep}`) && + process.argv[1]?.endsWith(".ts"); + const port = resolveGatewayPort(cfg, process.env); + const { programArguments, workingDirectory } = + await resolveGatewayProgramArguments({ port, dev: devMode }); + const environment: Record = { + PATH: process.env.PATH, + CLAWDBOT_GATEWAY_TOKEN: + cfg.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN, + CLAWDBOT_LAUNCHD_LABEL: + process.platform === "darwin" ? GATEWAY_LAUNCH_AGENT_LABEL : undefined, + }; + await service.install({ + env: process.env, + stdout: process.stdout, + programArguments, + workingDirectory, + environment, + }); +} + export async function doctorCommand(runtime: RuntimeEnv = defaultRuntime) { printWizardHeader(runtime); intro("Clawdbot doctor"); + await maybeMigrateLegacyConfigFile(runtime); + const snapshot = await readConfigFileSnapshot(); let cfg: ClawdbotConfig = snapshot.valid ? snapshot.config : {}; if ( @@ -66,6 +339,14 @@ export async function doctorCommand(runtime: RuntimeEnv = defaultRuntime) { } } + const normalized = normalizeLegacyConfigValues(cfg); + if (normalized.changes.length > 0) { + note(normalized.changes.join("\n"), "Doctor changes"); + cfg = normalized.config; + } + + await maybeMigrateLegacyGatewayService(cfg, runtime); + const workspaceDir = resolveUserPath( cfg.agent?.workspace ?? DEFAULT_WORKSPACE, );