diff --git a/CHANGELOG.md b/CHANGELOG.md index dbc482b97..2da1ad55c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,8 @@ - Configure: add OpenAI Codex (ChatGPT OAuth) auth choice (align with onboarding). - Doctor: suggest adding the workspace memory system when missing (opt-out via `--no-workspace-suggestions`). - Doctor: normalize default workspace path to `~/clawd` (avoid `~/clawdbot`). +- Doctor: add `--yes` and `--non-interactive` for headless/automation runs (`--non-interactive` only applies safe migrations). +- Gateway/CLI: auto-migrate legacy sessions + agent state layouts on startup (safe; WhatsApp auth still requires `clawdbot doctor`). - Workspace: only create `BOOTSTRAP.md` for brand-new workspaces (don’t recreate after deletion). - Build: fix duplicate protocol export, align Codex OAuth options, and add proper-lockfile typings. - Build: install Bun in the Dockerfile so `pnpm build` can run Bun scripts. Thanks @loukotal for PR #284. diff --git a/docs/gateway/doctor.md b/docs/gateway/doctor.md index 41ca2c6c9..38e9ee334 100644 --- a/docs/gateway/doctor.md +++ b/docs/gateway/doctor.md @@ -48,7 +48,7 @@ Doctor can migrate older on-disk layouts into the current structure: - to `~/.clawdbot/credentials/whatsapp//...` (default account id: `default`) These migrations are best-effort and idempotent; doctor will emit warnings when it leaves any legacy folders behind as backups. -The Gateway/CLI also auto-migrates the legacy agent dir on startup so auth/models land in the per-agent path without a manual doctor run. +The Gateway/CLI also auto-migrates the legacy sessions + agent dir on startup so history/auth/models land in the per-agent path without a manual doctor run. WhatsApp auth is intentionally only migrated via `clawdbot doctor`. ## Usage @@ -56,6 +56,20 @@ The Gateway/CLI also auto-migrates the legacy agent dir on startup so auth/model clawdbot doctor ``` +### Headless / automation + +```bash +clawdbot doctor --yes +``` + +Accept defaults without prompting (including restart/service/sandbox repair steps when applicable). + +```bash +clawdbot doctor --non-interactive +``` + +Run without prompts and only apply safe migrations (config normalization + on-disk state moves). Skips restart/service/sandbox actions that require human confirmation. + If you want to review changes before writing, open the config file first: ```bash diff --git a/src/cli/program.ts b/src/cli/program.ts index 89447378a..bc1bc1efa 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -19,7 +19,7 @@ import { writeConfigFile, } from "../config/config.js"; import { danger, setVerbose } from "../globals.js"; -import { autoMigrateLegacyAgentDir } from "../infra/state-migrations.js"; +import { autoMigrateLegacyState } from "../infra/state-migrations.js"; import { loginWeb, logoutWeb } from "../provider-web.js"; import { defaultRuntime } from "../runtime.js"; import { VERSION } from "../version.js"; @@ -132,7 +132,7 @@ export function buildProgram() { program.hook("preAction", async (_thisCommand, actionCommand) => { if (actionCommand.name() === "doctor") return; const cfg = loadConfig(); - await autoMigrateLegacyAgentDir({ cfg }); + await autoMigrateLegacyState({ cfg }); }); const examples = [ [ @@ -307,10 +307,18 @@ export function buildProgram() { "Disable workspace memory system suggestions", false, ) + .option("--yes", "Accept defaults without prompting", false) + .option( + "--non-interactive", + "Run without prompts (safe migrations only)", + false, + ) .action(async (opts) => { try { await doctorCommand(defaultRuntime, { workspaceSuggestions: opts.workspaceSuggestions, + yes: Boolean(opts.yes), + nonInteractive: Boolean(opts.nonInteractive), }); } catch (err) { defaultRuntime.error(String(err)); diff --git a/src/commands/doctor-state-migrations.test.ts b/src/commands/doctor-state-migrations.test.ts index 4cfa69708..760db978b 100644 --- a/src/commands/doctor-state-migrations.test.ts +++ b/src/commands/doctor-state-migrations.test.ts @@ -6,9 +6,9 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { ClawdbotConfig } from "../config/config.js"; import { - autoMigrateLegacyAgentDir, + autoMigrateLegacyState, detectLegacyStateMigrations, - resetAutoMigrateLegacyAgentDirForTest, + resetAutoMigrateLegacyStateForTest, runLegacyStateMigrations, } from "./doctor-state-migrations.js"; @@ -23,7 +23,7 @@ async function makeTempRoot() { } afterEach(async () => { - resetAutoMigrateLegacyAgentDirForTest(); + resetAutoMigrateLegacyStateForTest(); if (!tempRoot) return; await fs.promises.rm(tempRoot, { recursive: true, force: true }); tempRoot = null; @@ -111,7 +111,7 @@ describe("doctor legacy state migrations", () => { const log = { info: vi.fn(), warn: vi.fn() }; - const result = await autoMigrateLegacyAgentDir({ + const result = await autoMigrateLegacyState({ cfg, env: { CLAWDBOT_STATE_DIR: root } as NodeJS.ProcessEnv, log, @@ -123,6 +123,35 @@ describe("doctor legacy state migrations", () => { expect(log.info).toHaveBeenCalled(); }); + it("auto-migrates legacy sessions on startup", async () => { + const root = await makeTempRoot(); + const cfg: ClawdbotConfig = {}; + + const legacySessionsDir = path.join(root, "sessions"); + fs.mkdirSync(legacySessionsDir, { recursive: true }); + writeJson5(path.join(legacySessionsDir, "sessions.json"), { + "+1555": { sessionId: "a", updatedAt: 10 }, + }); + fs.writeFileSync(path.join(legacySessionsDir, "a.jsonl"), "a", "utf-8"); + + const log = { info: vi.fn(), warn: vi.fn() }; + + const result = await autoMigrateLegacyState({ + cfg, + env: { CLAWDBOT_STATE_DIR: root } as NodeJS.ProcessEnv, + log, + now: () => 123, + }); + + expect(result.migrated).toBe(true); + expect(log.info).toHaveBeenCalled(); + + const targetDir = path.join(root, "agents", "main", "sessions"); + expect(fs.existsSync(path.join(targetDir, "a.jsonl"))).toBe(true); + expect(fs.existsSync(path.join(legacySessionsDir, "a.jsonl"))).toBe(false); + expect(fs.existsSync(path.join(targetDir, "sessions.json"))).toBe(true); + }); + it("migrates legacy WhatsApp auth files without touching oauth.json", async () => { const root = await makeTempRoot(); const cfg: ClawdbotConfig = {}; diff --git a/src/commands/doctor-state-migrations.ts b/src/commands/doctor-state-migrations.ts index cda89f0c4..7448b8cd7 100644 --- a/src/commands/doctor-state-migrations.ts +++ b/src/commands/doctor-state-migrations.ts @@ -1,8 +1,10 @@ export type { LegacyStateDetection } from "../infra/state-migrations.js"; export { autoMigrateLegacyAgentDir, + autoMigrateLegacyState, detectLegacyStateMigrations, migrateLegacyAgentDir, resetAutoMigrateLegacyAgentDirForTest, + resetAutoMigrateLegacyStateForTest, runLegacyStateMigrations, } from "../infra/state-migrations.js"; diff --git a/src/commands/doctor.test.ts b/src/commands/doctor.test.ts index 5ecd8e0b2..0816c533b 100644 --- a/src/commands/doctor.test.ts +++ b/src/commands/doctor.test.ts @@ -1,4 +1,26 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +let originalIsTTY: boolean | undefined; + +function setStdinTty(value: boolean | undefined) { + try { + Object.defineProperty(process.stdin, "isTTY", { + value, + configurable: true, + }); + } catch { + // ignore + } +} + +beforeEach(() => { + originalIsTTY = process.stdin.isTTY; + setStdinTty(true); +}); + +afterEach(() => { + setStdinTty(originalIsTTY); +}); const readConfigFileSnapshot = vi.fn(); const confirm = vi.fn().mockResolvedValue(true); @@ -443,4 +465,153 @@ describe("doctor", () => { expect(docker.image).toBe("clawdis-sandbox-common:bookworm-slim"); expect(runCommandWithTimeout).not.toHaveBeenCalled(); }); + + it("runs legacy state migrations in non-interactive mode without prompting", async () => { + readConfigFileSnapshot.mockResolvedValue({ + path: "/tmp/clawdbot.json", + exists: true, + raw: "{}", + parsed: {}, + valid: true, + config: {}, + issues: [], + legacyIssues: [], + }); + + const { doctorCommand } = await import("./doctor.js"); + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + const { detectLegacyStateMigrations, runLegacyStateMigrations } = + await import("./doctor-state-migrations.js"); + detectLegacyStateMigrations.mockResolvedValueOnce({ + targetAgentId: "main", + targetMainKey: "main", + stateDir: "/tmp/state", + oauthDir: "/tmp/oauth", + sessions: { + legacyDir: "/tmp/state/sessions", + legacyStorePath: "/tmp/state/sessions/sessions.json", + targetDir: "/tmp/state/agents/main/sessions", + targetStorePath: "/tmp/state/agents/main/sessions/sessions.json", + hasLegacy: true, + }, + agentDir: { + legacyDir: "/tmp/state/agent", + targetDir: "/tmp/state/agents/main/agent", + hasLegacy: false, + }, + whatsappAuth: { + legacyDir: "/tmp/oauth", + targetDir: "/tmp/oauth/whatsapp/default", + hasLegacy: false, + }, + preview: ["- Legacy sessions detected"], + }); + runLegacyStateMigrations.mockResolvedValueOnce({ + changes: ["migrated"], + warnings: [], + }); + + confirm.mockClear(); + + await doctorCommand(runtime, { nonInteractive: true }); + + expect(runLegacyStateMigrations).toHaveBeenCalledTimes(1); + expect(confirm).not.toHaveBeenCalled(); + }); + + it("runs legacy state migrations in yes mode without prompting", async () => { + readConfigFileSnapshot.mockResolvedValue({ + path: "/tmp/clawdbot.json", + exists: true, + raw: "{}", + parsed: {}, + valid: true, + config: {}, + issues: [], + legacyIssues: [], + }); + + const { doctorCommand } = await import("./doctor.js"); + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + const { detectLegacyStateMigrations, runLegacyStateMigrations } = + await import("./doctor-state-migrations.js"); + detectLegacyStateMigrations.mockResolvedValueOnce({ + targetAgentId: "main", + targetMainKey: "main", + stateDir: "/tmp/state", + oauthDir: "/tmp/oauth", + sessions: { + legacyDir: "/tmp/state/sessions", + legacyStorePath: "/tmp/state/sessions/sessions.json", + targetDir: "/tmp/state/agents/main/sessions", + targetStorePath: "/tmp/state/agents/main/sessions/sessions.json", + hasLegacy: true, + }, + agentDir: { + legacyDir: "/tmp/state/agent", + targetDir: "/tmp/state/agents/main/agent", + hasLegacy: false, + }, + whatsappAuth: { + legacyDir: "/tmp/oauth", + targetDir: "/tmp/oauth/whatsapp/default", + hasLegacy: false, + }, + preview: ["- Legacy sessions detected"], + }); + runLegacyStateMigrations.mockResolvedValueOnce({ + changes: ["migrated"], + warnings: [], + }); + + runLegacyStateMigrations.mockClear(); + confirm.mockClear(); + + await doctorCommand(runtime, { yes: true }); + + expect(runLegacyStateMigrations).toHaveBeenCalledTimes(1); + expect(confirm).not.toHaveBeenCalled(); + }); + + it("skips gateway restarts in non-interactive mode", async () => { + readConfigFileSnapshot.mockResolvedValue({ + path: "/tmp/clawdbot.json", + exists: true, + raw: "{}", + parsed: {}, + valid: true, + config: {}, + issues: [], + legacyIssues: [], + }); + + const { healthCommand } = await import("./health.js"); + healthCommand.mockRejectedValueOnce(new Error("gateway closed")); + + serviceIsLoaded.mockResolvedValueOnce(true); + serviceRestart.mockClear(); + confirm.mockClear(); + + const { doctorCommand } = await import("./doctor.js"); + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + await doctorCommand(runtime, { nonInteractive: true }); + + expect(serviceRestart).not.toHaveBeenCalled(); + expect(confirm).not.toHaveBeenCalled(); + }); }); diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 44f2ac5e2..859623427 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -346,8 +346,46 @@ async function runSandboxScript( type DoctorOptions = { workspaceSuggestions?: boolean; + yes?: boolean; + nonInteractive?: boolean; }; +type DoctorPrompter = { + confirm: (params: Parameters[0]) => Promise; + confirmSkipInNonInteractive: ( + params: Parameters[0], + ) => Promise; + select: (params: Parameters[0], fallback: T) => Promise; +}; + +function createDoctorPrompter(params: { + runtime: RuntimeEnv; + options: DoctorOptions; +}): DoctorPrompter { + const yes = params.options.yes === true; + const requestedNonInteractive = params.options.nonInteractive === true; + const isTty = Boolean(process.stdin.isTTY); + const nonInteractive = requestedNonInteractive || (!isTty && !yes); + + const canPrompt = isTty && !yes && !nonInteractive; + const confirmDefault = async (p: Parameters[0]) => { + if (!canPrompt) return Boolean(p.initialValue ?? false); + return guardCancel(await confirm(p), params.runtime) === true; + }; + + return { + confirm: confirmDefault, + confirmSkipInNonInteractive: async (p) => { + if (nonInteractive) return false; + return confirmDefault(p); + }, + select: async (p: Parameters[0], fallback: T) => { + if (!canPrompt) return fallback; + return guardCancel(await select(p), params.runtime) as T; + }, + }; +} + const MEMORY_SYSTEM_PROMPT = [ "Memory system not found in workspace.", "Paste this into your agent:", @@ -463,6 +501,7 @@ type SandboxImageCheck = { async function handleMissingSandboxImage( params: SandboxImageCheck, runtime: RuntimeEnv, + prompter: DoctorPrompter, ) { const exists = await dockerImageExists(params.image); if (exists) return; @@ -477,13 +516,10 @@ async function handleMissingSandboxImage( let built = false; if (params.buildScript) { - const build = guardCancel( - await confirm({ - message: `Build ${params.label} sandbox image now?`, - initialValue: true, - }), - runtime, - ); + const build = await prompter.confirmSkipInNonInteractive({ + message: `Build ${params.label} sandbox image now?`, + initialValue: true, + }); if (build) { built = await runSandboxScript(params.buildScript, runtime); } @@ -496,13 +532,10 @@ async function handleMissingSandboxImage( const legacyExists = await dockerImageExists(legacyImage); if (!legacyExists) return; - const fallback = guardCancel( - await confirm({ - message: `Switch config to legacy image ${legacyImage}?`, - initialValue: false, - }), - runtime, - ); + const fallback = await prompter.confirmSkipInNonInteractive({ + message: `Switch config to legacy image ${legacyImage}?`, + initialValue: false, + }); if (!fallback) return; params.updateConfig(legacyImage); @@ -511,6 +544,7 @@ async function handleMissingSandboxImage( async function maybeRepairSandboxImages( cfg: ClawdbotConfig, runtime: RuntimeEnv, + prompter: DoctorPrompter, ): Promise { const sandbox = cfg.agent?.sandbox; const mode = sandbox?.mode ?? "off"; @@ -542,6 +576,7 @@ async function maybeRepairSandboxImages( }, }, runtime, + prompter, ); if (sandbox.browser?.enabled) { @@ -556,6 +591,7 @@ async function maybeRepairSandboxImages( }, }, runtime, + prompter, ); } @@ -717,6 +753,7 @@ async function maybeMigrateLegacyConfigFile(runtime: RuntimeEnv) { async function maybeMigrateLegacyGatewayService( cfg: ClawdbotConfig, runtime: RuntimeEnv, + prompter: DoctorPrompter, ) { const legacyServices = await findLegacyGatewayServices(process.env); if (legacyServices.length === 0) return; @@ -728,13 +765,10 @@ async function maybeMigrateLegacyGatewayService( "Legacy Clawdis services detected", ); - const migrate = guardCancel( - await confirm({ - message: "Migrate legacy Clawdis services to Clawdbot now?", - initialValue: true, - }), - runtime, - ); + const migrate = await prompter.confirmSkipInNonInteractive({ + message: "Migrate legacy Clawdis services to Clawdbot now?", + initialValue: true, + }); if (!migrate) return; try { @@ -764,23 +798,20 @@ async function maybeMigrateLegacyGatewayService( return; } - const install = guardCancel( - await confirm({ - message: "Install Clawdbot gateway service now?", - initialValue: true, - }), - runtime, - ); + const install = await prompter.confirmSkipInNonInteractive({ + message: "Install Clawdbot gateway service now?", + initialValue: true, + }); if (!install) return; - const daemonRuntime = guardCancel( - await select({ + const daemonRuntime = await prompter.select( + { message: "Gateway daemon runtime", options: GATEWAY_DAEMON_RUNTIME_OPTIONS, initialValue: DEFAULT_GATEWAY_DAEMON_RUNTIME, - }), - runtime, - ) as GatewayDaemonRuntime; + }, + DEFAULT_GATEWAY_DAEMON_RUNTIME, + ); const devMode = process.argv[1]?.includes(`${path.sep}src${path.sep}`) && process.argv[1]?.endsWith(".ts"); @@ -811,6 +842,7 @@ export async function doctorCommand( runtime: RuntimeEnv = defaultRuntime, options: DoctorOptions = {}, ) { + const prompter = createDoctorPrompter({ runtime, options }); printWizardHeader(runtime); intro("Clawdbot doctor"); @@ -833,13 +865,10 @@ export async function doctorCommand( .join("\n"), "Legacy config keys detected", ); - const migrate = guardCancel( - await confirm({ - message: "Migrate legacy config entries now?", - initialValue: true, - }), - runtime, - ); + const migrate = await prompter.confirm({ + 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( @@ -863,13 +892,10 @@ export async function doctorCommand( const legacyState = await detectLegacyStateMigrations({ cfg }); if (legacyState.preview.length > 0) { note(legacyState.preview.join("\n"), "Legacy state detected"); - const migrate = guardCancel( - await confirm({ - message: "Migrate legacy state (sessions/agent/WhatsApp auth) now?", - initialValue: true, - }), - runtime, - ); + const migrate = await prompter.confirm({ + message: "Migrate legacy state (sessions/agent/WhatsApp auth) now?", + initialValue: true, + }); if (migrate) { const migrated = await runLegacyStateMigrations({ detected: legacyState, @@ -883,13 +909,17 @@ export async function doctorCommand( } } - cfg = await maybeRepairSandboxImages(cfg, runtime); + cfg = await maybeRepairSandboxImages(cfg, runtime, prompter); - await maybeMigrateLegacyGatewayService(cfg, runtime); + await maybeMigrateLegacyGatewayService(cfg, runtime, prompter); await noteSecurityWarnings(cfg); - if (process.platform === "linux" && resolveMode(cfg) === "local") { + if ( + options.nonInteractive !== true && + process.platform === "linux" && + resolveMode(cfg) === "local" + ) { const service = resolveGatewayService(); let loaded = false; try { @@ -901,7 +931,7 @@ export async function doctorCommand( await ensureSystemdUserLingerInteractive({ runtime, prompter: { - confirm: async (p) => guardCancel(await confirm(p), runtime) === true, + confirm: async (p) => prompter.confirm(p), note, }, reason: @@ -955,13 +985,10 @@ export async function doctorCommand( "Gateway", ); } - const restart = guardCancel( - await confirm({ - message: "Restart gateway daemon now?", - initialValue: true, - }), - runtime, - ); + const restart = await prompter.confirmSkipInNonInteractive({ + message: "Restart gateway daemon now?", + initialValue: true, + }); if (restart) { await service.restart({ stdout: process.stdout }); await sleep(1500); diff --git a/src/gateway/server.ts b/src/gateway/server.ts index cf46ab54c..718f8d62a 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -54,7 +54,7 @@ import { startHeartbeatRunner } from "../infra/heartbeat-runner.js"; import { requestHeartbeatNow } from "../infra/heartbeat-wake.js"; import { getMachineDisplayName } from "../infra/machine-name.js"; import { ensureClawdbotCliOnPath } from "../infra/path-env.js"; -import { autoMigrateLegacyAgentDir } from "../infra/state-migrations.js"; +import { autoMigrateLegacyState } from "../infra/state-migrations.js"; import { enqueueSystemEvent } from "../infra/system-events.js"; import { listSystemPresence, @@ -389,7 +389,7 @@ export async function startGatewayServer( } const cfgAtStart = loadConfig(); - await autoMigrateLegacyAgentDir({ cfg: cfgAtStart, log }); + await autoMigrateLegacyState({ cfg: cfgAtStart, log }); const bindMode = opts.bind ?? cfgAtStart.gateway?.bind ?? "loopback"; const bindHost = opts.host ?? resolveGatewayBindHost(bindMode); if (!bindHost) { diff --git a/src/infra/state-migrations.ts b/src/infra/state-migrations.ts index 66e6ee1b7..c3c0aecd4 100644 --- a/src/infra/state-migrations.ts +++ b/src/infra/state-migrations.ts @@ -174,10 +174,14 @@ function removeDirIfEmpty(dir: string) { } } -export function resetAutoMigrateLegacyAgentDirForTest() { +export function resetAutoMigrateLegacyStateForTest() { autoMigrateChecked = false; } +export function resetAutoMigrateLegacyAgentDirForTest() { + resetAutoMigrateLegacyStateForTest(); +} + export async function detectLegacyStateMigrations(params: { cfg: ClawdbotConfig; env?: NodeJS.ProcessEnv; @@ -478,6 +482,21 @@ export async function autoMigrateLegacyAgentDir(params: { skipped: boolean; changes: string[]; warnings: string[]; +}> { + return await autoMigrateLegacyState(params); +} + +export async function autoMigrateLegacyState(params: { + cfg: ClawdbotConfig; + env?: NodeJS.ProcessEnv; + homedir?: () => string; + log?: MigrationLogger; + now?: () => number; +}): Promise<{ + migrated: boolean; + skipped: boolean; + changes: string[]; + warnings: string[]; }> { if (autoMigrateChecked) { return { migrated: false, skipped: true, changes: [], warnings: [] }; @@ -494,25 +513,27 @@ export async function autoMigrateLegacyAgentDir(params: { env, homedir: params.homedir, }); - if (!detected.agentDir.hasLegacy) { + if (!detected.sessions.hasLegacy && !detected.agentDir.hasLegacy) { return { migrated: false, skipped: false, changes: [], warnings: [] }; } - const { changes, warnings } = await migrateLegacyAgentDir( - detected, - params.now ?? (() => Date.now()), - ); + const now = params.now ?? (() => Date.now()); + const sessions = await migrateLegacySessions(detected, now); + const agentDir = await migrateLegacyAgentDir(detected, now); + const changes = [...sessions.changes, ...agentDir.changes]; + const warnings = [...sessions.warnings, ...agentDir.warnings]; + const logger = params.log ?? createSubsystemLogger("state-migrations"); if (changes.length > 0) { logger.info( - `Auto-migrated legacy agent dir:\n${changes + `Auto-migrated legacy state:\n${changes .map((entry) => `- ${entry}`) .join("\n")}`, ); } if (warnings.length > 0) { logger.warn( - `Legacy agent dir migration warnings:\n${warnings + `Legacy state migration warnings:\n${warnings .map((entry) => `- ${entry}`) .join("\n")}`, );