From e2c437e81efb2b7d864f68de749e186127d9d5af Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 28 Jan 2026 00:15:54 +0000 Subject: [PATCH] fix: migrate legacy state/config paths --- CHANGELOG.md | 1 + src/cli/gateway-cli/dev.ts | 8 +- src/cli/gateway-cli/run.ts | 4 +- src/commands/doctor-config-flow.ts | 43 ++++- src/commands/doctor-state-migrations.test.ts | 52 ++++++ src/commands/doctor-state-migrations.ts | 2 + ...-back-legacy-sandbox-image-missing.test.ts | 6 + ...owfrom-channels-whatsapp-allowfrom.test.ts | 6 + ...-state-migrations-yes-mode-without.test.ts | 6 + ...agent-sandbox-docker-browser-prune.test.ts | 6 + ...r.warns-state-directory-is-missing.test.ts | 6 + src/commands/setup.ts | 16 +- src/config/io.compat.test.ts | 38 ++++- src/config/io.ts | 11 +- src/config/paths.test.ts | 61 ++++++- src/config/paths.ts | 75 ++++++++- src/infra/state-migrations.ts | 156 +++++++++++++++++- src/utils.test.ts | 15 ++ src/utils.ts | 13 +- 19 files changed, 492 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e37ed38be..7e663116a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ Status: unreleased. - Gateway: warn on hook tokens via query params; document header auth preference. (#2200) Thanks @YuriNachos. - Gateway: add dangerous Control UI device auth bypass flag + audit warnings. (#2248) - Doctor: warn on gateway exposure without auth. (#2016) Thanks @Alex-Alaniz. +- Config: auto-migrate legacy state/config paths and keep config resolution consistent across legacy filenames. - Discord: add configurable privileged gateway intents for presences/members. (#2266) Thanks @kentaro. - Docs: add Vercel AI Gateway to providers sidebar. (#1901) Thanks @jerilynzheng. - Agents: expand cron tool description with full schema docs. (#1988) Thanks @tomascupr. diff --git a/src/cli/gateway-cli/dev.ts b/src/cli/gateway-cli/dev.ts index cc754c4dd..565df14b8 100644 --- a/src/cli/gateway-cli/dev.ts +++ b/src/cli/gateway-cli/dev.ts @@ -4,7 +4,7 @@ import path from "node:path"; import { resolveDefaultAgentWorkspaceDir } from "../../agents/workspace.js"; import { handleReset } from "../../commands/onboard-helpers.js"; -import { CONFIG_PATH, writeConfigFile } from "../../config/config.js"; +import { createConfigIO, writeConfigFile } from "../../config/config.js"; import { defaultRuntime } from "../../runtime.js"; import { resolveUserPath, shortenHomePath } from "../../utils.js"; @@ -89,7 +89,9 @@ export async function ensureDevGatewayConfig(opts: { reset?: boolean }) { await handleReset("full", workspace, defaultRuntime); } - const configExists = fs.existsSync(CONFIG_PATH); + const io = createConfigIO(); + const configPath = io.configPath; + const configExists = fs.existsSync(configPath); if (!opts.reset && configExists) return; await writeConfigFile({ @@ -117,6 +119,6 @@ export async function ensureDevGatewayConfig(opts: { reset?: boolean }) { }, }); await ensureDevWorkspace(workspace); - defaultRuntime.log(`Dev config ready: ${shortenHomePath(CONFIG_PATH)}`); + defaultRuntime.log(`Dev config ready: ${shortenHomePath(configPath)}`); defaultRuntime.log(`Dev workspace ready: ${shortenHomePath(resolveUserPath(workspace))}`); } diff --git a/src/cli/gateway-cli/run.ts b/src/cli/gateway-cli/run.ts index 0f4d4e9b7..cb26aa98d 100644 --- a/src/cli/gateway-cli/run.ts +++ b/src/cli/gateway-cli/run.ts @@ -157,7 +157,8 @@ async function runGatewayCommand(opts: GatewayRunOpts) { const passwordRaw = toOptionString(opts.password); const tokenRaw = toOptionString(opts.token); - const configExists = fs.existsSync(CONFIG_PATH); + const snapshot = await readConfigFileSnapshot().catch(() => null); + const configExists = snapshot?.exists ?? fs.existsSync(CONFIG_PATH); const mode = cfg.gateway?.mode; if (!opts.allowUnconfigured && mode !== "local") { if (!configExists) { @@ -187,7 +188,6 @@ async function runGatewayCommand(opts: GatewayRunOpts) { return; } - const snapshot = await readConfigFileSnapshot().catch(() => null); const miskeys = extractGatewayMiskeys(snapshot?.parsed); const authConfig = { ...cfg.gateway?.auth, diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts index 879c8679e..8bc1a7730 100644 --- a/src/commands/doctor-config-flow.ts +++ b/src/commands/doctor-config-flow.ts @@ -1,3 +1,5 @@ +import fs from "node:fs"; +import path from "node:path"; import type { ZodIssue } from "zod"; import type { MoltbotConfig } from "../config/config.js"; @@ -12,6 +14,7 @@ import { formatCliCommand } from "../cli/command-format.js"; import { note } from "../terminal/note.js"; import { normalizeLegacyConfigValues } from "./doctor-legacy-config.js"; import type { DoctorOptions } from "./doctor-prompter.js"; +import { autoMigrateLegacyStateDir } from "./doctor-state-migrations.js"; function isRecord(value: unknown): value is Record { return Boolean(value && typeof value === "object" && !Array.isArray(value)); @@ -117,12 +120,50 @@ function noteOpencodeProviderOverrides(cfg: MoltbotConfig) { note(lines.join("\n"), "OpenCode Zen"); } +function hasExplicitConfigPath(env: NodeJS.ProcessEnv): boolean { + return Boolean(env.MOLTBOT_CONFIG_PATH?.trim() || env.CLAWDBOT_CONFIG_PATH?.trim()); +} + +function moveLegacyConfigFile(legacyPath: string, canonicalPath: string) { + fs.mkdirSync(path.dirname(canonicalPath), { recursive: true, mode: 0o700 }); + try { + fs.renameSync(legacyPath, canonicalPath); + } catch (err) { + fs.copyFileSync(legacyPath, canonicalPath); + fs.chmodSync(canonicalPath, 0o600); + try { + fs.unlinkSync(legacyPath); + } catch { + // Best-effort cleanup; we'll warn later if both files exist. + } + } +} + export async function loadAndMaybeMigrateDoctorConfig(params: { options: DoctorOptions; confirm: (p: { message: string; initialValue: boolean }) => Promise; }) { const shouldRepair = params.options.repair === true || params.options.yes === true; - const snapshot = await readConfigFileSnapshot(); + const stateDirResult = await autoMigrateLegacyStateDir({ env: process.env }); + if (stateDirResult.changes.length > 0) { + note(stateDirResult.changes.map((entry) => `- ${entry}`).join("\n"), "Doctor changes"); + } + if (stateDirResult.warnings.length > 0) { + note(stateDirResult.warnings.map((entry) => `- ${entry}`).join("\n"), "Doctor warnings"); + } + + let snapshot = await readConfigFileSnapshot(); + if (!hasExplicitConfigPath(process.env) && snapshot.exists) { + const basename = path.basename(snapshot.path); + if (basename === "clawdbot.json") { + const canonicalPath = path.join(path.dirname(snapshot.path), "moltbot.json"); + if (!fs.existsSync(canonicalPath)) { + moveLegacyConfigFile(snapshot.path, canonicalPath); + note(`- Config: ${snapshot.path} → ${canonicalPath}`, "Doctor changes"); + snapshot = await readConfigFileSnapshot(); + } + } + } const baseCfg = snapshot.config ?? {}; let cfg: MoltbotConfig = baseCfg; let candidate = structuredClone(baseCfg) as MoltbotConfig; diff --git a/src/commands/doctor-state-migrations.test.ts b/src/commands/doctor-state-migrations.test.ts index 15ba11804..2ae7faf05 100644 --- a/src/commands/doctor-state-migrations.test.ts +++ b/src/commands/doctor-state-migrations.test.ts @@ -6,8 +6,10 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { MoltbotConfig } from "../config/config.js"; import { + autoMigrateLegacyStateDir, autoMigrateLegacyState, detectLegacyStateMigrations, + resetAutoMigrateLegacyStateDirForTest, resetAutoMigrateLegacyStateForTest, runLegacyStateMigrations, } from "./doctor-state-migrations.js"; @@ -22,6 +24,7 @@ async function makeTempRoot() { afterEach(async () => { resetAutoMigrateLegacyStateForTest(); + resetAutoMigrateLegacyStateDirForTest(); if (!tempRoot) return; await fs.promises.rm(tempRoot, { recursive: true, force: true }); tempRoot = null; @@ -323,4 +326,53 @@ describe("doctor legacy state migrations", () => { expect(store["main"]).toBeUndefined(); expect(store["agent:main:main"]?.sessionId).toBe("legacy"); }); + + it("auto-migrates legacy state dir to ~/.moltbot", async () => { + const root = await makeTempRoot(); + const legacyDir = path.join(root, ".clawdbot"); + fs.mkdirSync(legacyDir, { recursive: true }); + fs.writeFileSync(path.join(legacyDir, "foo.txt"), "legacy", "utf-8"); + + const result = await autoMigrateLegacyStateDir({ + env: {} as NodeJS.ProcessEnv, + homedir: () => root, + }); + + const targetDir = path.join(root, ".moltbot"); + expect(fs.existsSync(path.join(targetDir, "foo.txt"))).toBe(true); + const legacyStat = fs.lstatSync(legacyDir); + expect(legacyStat.isSymbolicLink()).toBe(true); + expect(fs.realpathSync(legacyDir)).toBe(fs.realpathSync(targetDir)); + expect(result.migrated).toBe(true); + }); + + it("skips state dir migration when target exists", async () => { + const root = await makeTempRoot(); + const legacyDir = path.join(root, ".clawdbot"); + const targetDir = path.join(root, ".moltbot"); + fs.mkdirSync(legacyDir, { recursive: true }); + fs.mkdirSync(targetDir, { recursive: true }); + + const result = await autoMigrateLegacyStateDir({ + env: {} as NodeJS.ProcessEnv, + homedir: () => root, + }); + + expect(result.migrated).toBe(false); + expect(result.warnings.length).toBeGreaterThan(0); + }); + + it("skips state dir migration when env override is set", async () => { + const root = await makeTempRoot(); + const legacyDir = path.join(root, ".clawdbot"); + fs.mkdirSync(legacyDir, { recursive: true }); + + const result = await autoMigrateLegacyStateDir({ + env: { MOLTBOT_STATE_DIR: "/custom/state" } as NodeJS.ProcessEnv, + homedir: () => root, + }); + + expect(result.skipped).toBe(true); + expect(result.migrated).toBe(false); + }); }); diff --git a/src/commands/doctor-state-migrations.ts b/src/commands/doctor-state-migrations.ts index 7448b8cd7..50c59a3a0 100644 --- a/src/commands/doctor-state-migrations.ts +++ b/src/commands/doctor-state-migrations.ts @@ -1,9 +1,11 @@ export type { LegacyStateDetection } from "../infra/state-migrations.js"; export { + autoMigrateLegacyStateDir, autoMigrateLegacyAgentDir, autoMigrateLegacyState, detectLegacyStateMigrations, migrateLegacyAgentDir, + resetAutoMigrateLegacyStateDirForTest, resetAutoMigrateLegacyAgentDirForTest, resetAutoMigrateLegacyStateForTest, runLegacyStateMigrations, diff --git a/src/commands/doctor.falls-back-legacy-sandbox-image-missing.test.ts b/src/commands/doctor.falls-back-legacy-sandbox-image-missing.test.ts index 08af35e90..7ddcc2049 100644 --- a/src/commands/doctor.falls-back-legacy-sandbox-image-missing.test.ts +++ b/src/commands/doctor.falls-back-legacy-sandbox-image-missing.test.ts @@ -292,6 +292,12 @@ vi.mock("./onboard-helpers.js", () => ({ })); vi.mock("./doctor-state-migrations.js", () => ({ + autoMigrateLegacyStateDir: vi.fn().mockResolvedValue({ + migrated: false, + skipped: false, + changes: [], + warnings: [], + }), detectLegacyStateMigrations: vi.fn().mockResolvedValue({ targetAgentId: "main", targetMainKey: "main", diff --git a/src/commands/doctor.migrates-routing-allowfrom-channels-whatsapp-allowfrom.test.ts b/src/commands/doctor.migrates-routing-allowfrom-channels-whatsapp-allowfrom.test.ts index b6cb0c988..4f6651251 100644 --- a/src/commands/doctor.migrates-routing-allowfrom-channels-whatsapp-allowfrom.test.ts +++ b/src/commands/doctor.migrates-routing-allowfrom-channels-whatsapp-allowfrom.test.ts @@ -291,6 +291,12 @@ vi.mock("./onboard-helpers.js", () => ({ })); vi.mock("./doctor-state-migrations.js", () => ({ + autoMigrateLegacyStateDir: vi.fn().mockResolvedValue({ + migrated: false, + skipped: false, + changes: [], + warnings: [], + }), detectLegacyStateMigrations: vi.fn().mockResolvedValue({ targetAgentId: "main", targetMainKey: "main", diff --git a/src/commands/doctor.runs-legacy-state-migrations-yes-mode-without.test.ts b/src/commands/doctor.runs-legacy-state-migrations-yes-mode-without.test.ts index 677813bc1..f36b85b29 100644 --- a/src/commands/doctor.runs-legacy-state-migrations-yes-mode-without.test.ts +++ b/src/commands/doctor.runs-legacy-state-migrations-yes-mode-without.test.ts @@ -291,6 +291,12 @@ vi.mock("./onboard-helpers.js", () => ({ })); vi.mock("./doctor-state-migrations.js", () => ({ + autoMigrateLegacyStateDir: vi.fn().mockResolvedValue({ + migrated: false, + skipped: false, + changes: [], + warnings: [], + }), detectLegacyStateMigrations: vi.fn().mockResolvedValue({ targetAgentId: "main", targetMainKey: "main", diff --git a/src/commands/doctor.warns-per-agent-sandbox-docker-browser-prune.test.ts b/src/commands/doctor.warns-per-agent-sandbox-docker-browser-prune.test.ts index 980ddc8dc..d2d232606 100644 --- a/src/commands/doctor.warns-per-agent-sandbox-docker-browser-prune.test.ts +++ b/src/commands/doctor.warns-per-agent-sandbox-docker-browser-prune.test.ts @@ -291,6 +291,12 @@ vi.mock("./onboard-helpers.js", () => ({ })); vi.mock("./doctor-state-migrations.js", () => ({ + autoMigrateLegacyStateDir: vi.fn().mockResolvedValue({ + migrated: false, + skipped: false, + changes: [], + warnings: [], + }), detectLegacyStateMigrations: vi.fn().mockResolvedValue({ targetAgentId: "main", targetMainKey: "main", diff --git a/src/commands/doctor.warns-state-directory-is-missing.test.ts b/src/commands/doctor.warns-state-directory-is-missing.test.ts index 4bbc938fc..10b9e8a67 100644 --- a/src/commands/doctor.warns-state-directory-is-missing.test.ts +++ b/src/commands/doctor.warns-state-directory-is-missing.test.ts @@ -291,6 +291,12 @@ vi.mock("./onboard-helpers.js", () => ({ })); vi.mock("./doctor-state-migrations.js", () => ({ + autoMigrateLegacyStateDir: vi.fn().mockResolvedValue({ + migrated: false, + skipped: false, + changes: [], + warnings: [], + }), detectLegacyStateMigrations: vi.fn().mockResolvedValue({ targetAgentId: "main", targetMainKey: "main", diff --git a/src/commands/setup.ts b/src/commands/setup.ts index ad1d4ec38..2f3ea90c7 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -3,19 +3,19 @@ import fs from "node:fs/promises"; import JSON5 from "json5"; import { DEFAULT_AGENT_WORKSPACE_DIR, ensureAgentWorkspace } from "../agents/workspace.js"; -import { type MoltbotConfig, CONFIG_PATH, writeConfigFile } from "../config/config.js"; +import { type MoltbotConfig, createConfigIO, writeConfigFile } from "../config/config.js"; import { formatConfigPath, logConfigUpdated } from "../config/logging.js"; import { resolveSessionTranscriptsDir } from "../config/sessions.js"; import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; import { shortenHomePath } from "../utils.js"; -async function readConfigFileRaw(): Promise<{ +async function readConfigFileRaw(configPath: string): Promise<{ exists: boolean; parsed: MoltbotConfig; }> { try { - const raw = await fs.readFile(CONFIG_PATH, "utf-8"); + const raw = await fs.readFile(configPath, "utf-8"); const parsed = JSON5.parse(raw); if (parsed && typeof parsed === "object") { return { exists: true, parsed: parsed as MoltbotConfig }; @@ -35,7 +35,9 @@ export async function setupCommand( ? opts.workspace.trim() : undefined; - const existingRaw = await readConfigFileRaw(); + const io = createConfigIO(); + const configPath = io.configPath; + const existingRaw = await readConfigFileRaw(configPath); const cfg = existingRaw.parsed; const defaults = cfg.agents?.defaults ?? {}; @@ -55,12 +57,12 @@ export async function setupCommand( if (!existingRaw.exists || defaults.workspace !== workspace) { await writeConfigFile(next); if (!existingRaw.exists) { - runtime.log(`Wrote ${formatConfigPath()}`); + runtime.log(`Wrote ${formatConfigPath(configPath)}`); } else { - logConfigUpdated(runtime, { suffix: "(set agents.defaults.workspace)" }); + logConfigUpdated(runtime, { path: configPath, suffix: "(set agents.defaults.workspace)" }); } } else { - runtime.log(`Config OK: ${formatConfigPath()}`); + runtime.log(`Config OK: ${formatConfigPath(configPath)}`); } const ws = await ensureAgentWorkspace({ diff --git a/src/config/io.compat.test.ts b/src/config/io.compat.test.ts index 4a32658ae..fd98f2650 100644 --- a/src/config/io.compat.test.ts +++ b/src/config/io.compat.test.ts @@ -14,10 +14,15 @@ async function withTempHome(run: (home: string) => Promise): Promise } } -async function writeConfig(home: string, dirname: ".moltbot" | ".clawdbot", port: number) { +async function writeConfig( + home: string, + dirname: ".moltbot" | ".clawdbot", + port: number, + filename: "moltbot.json" | "clawdbot.json" = "moltbot.json", +) { const dir = path.join(home, dirname); await fs.mkdir(dir, { recursive: true }); - const configPath = path.join(dir, "moltbot.json"); + const configPath = path.join(dir, filename); await fs.writeFile(configPath, JSON.stringify({ gateway: { port } }, null, 2)); return configPath; } @@ -51,6 +56,35 @@ describe("config io compat (new + legacy folders)", () => { }); }); + it("falls back to ~/.clawdbot/clawdbot.json when only legacy filename exists", async () => { + await withTempHome(async (home) => { + const legacyConfigPath = await writeConfig(home, ".clawdbot", 20002, "clawdbot.json"); + + const io = createConfigIO({ + env: {} as NodeJS.ProcessEnv, + homedir: () => home, + }); + + expect(io.configPath).toBe(legacyConfigPath); + expect(io.loadConfig().gateway?.port).toBe(20002); + }); + }); + + it("prefers moltbot.json over legacy filename in the same dir", async () => { + await withTempHome(async (home) => { + const preferred = await writeConfig(home, ".clawdbot", 20003, "moltbot.json"); + await writeConfig(home, ".clawdbot", 20004, "clawdbot.json"); + + const io = createConfigIO({ + env: {} as NodeJS.ProcessEnv, + homedir: () => home, + }); + + expect(io.configPath).toBe(preferred); + expect(io.loadConfig().gateway?.port).toBe(20003); + }); + }); + it("honors explicit legacy config path env override", async () => { await withTempHome(async (home) => { const newConfigPath = await writeConfig(home, ".moltbot", 19002); diff --git a/src/config/io.ts b/src/config/io.ts index ef8ffba86..50f1edb82 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -555,7 +555,8 @@ function clearConfigCache(): void { } export function loadConfig(): MoltbotConfig { - const configPath = resolveConfigPath(); + const io = createConfigIO(); + const configPath = io.configPath; const now = Date.now(); if (shouldUseConfigCache(process.env)) { const cached = configCache; @@ -563,7 +564,7 @@ export function loadConfig(): MoltbotConfig { return cached.config; } } - const config = createConfigIO({ configPath }).loadConfig(); + const config = io.loadConfig(); if (shouldUseConfigCache(process.env)) { const cacheMs = resolveConfigCacheMs(process.env); if (cacheMs > 0) { @@ -578,12 +579,10 @@ export function loadConfig(): MoltbotConfig { } export async function readConfigFileSnapshot(): Promise { - return await createConfigIO({ - configPath: resolveConfigPath(), - }).readConfigFileSnapshot(); + return await createConfigIO().readConfigFileSnapshot(); } export async function writeConfigFile(cfg: MoltbotConfig): Promise { clearConfigCache(); - await createConfigIO({ configPath: resolveConfigPath() }).writeConfigFile(cfg); + await createConfigIO().writeConfigFile(cfg); } diff --git a/src/config/paths.test.ts b/src/config/paths.test.ts index f99e88513..e029a6a47 100644 --- a/src/config/paths.test.ts +++ b/src/config/paths.test.ts @@ -1,5 +1,7 @@ +import fs from "node:fs/promises"; +import os from "node:os"; import path from "node:path"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { resolveDefaultConfigCandidates, @@ -47,6 +49,61 @@ describe("state + config path candidates", () => { const home = "/home/test"; const candidates = resolveDefaultConfigCandidates({} as NodeJS.ProcessEnv, () => home); expect(candidates[0]).toBe(path.join(home, ".moltbot", "moltbot.json")); - expect(candidates[1]).toBe(path.join(home, ".clawdbot", "moltbot.json")); + expect(candidates[1]).toBe(path.join(home, ".moltbot", "clawdbot.json")); + expect(candidates[2]).toBe(path.join(home, ".clawdbot", "moltbot.json")); + expect(candidates[3]).toBe(path.join(home, ".clawdbot", "clawdbot.json")); + }); + + it("prefers ~/.moltbot when it exists and legacy dir is missing", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-state-")); + try { + const newDir = path.join(root, ".moltbot"); + await fs.mkdir(newDir, { recursive: true }); + const resolved = resolveStateDir({} as NodeJS.ProcessEnv, () => root); + expect(resolved).toBe(newDir); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); + + it("CONFIG_PATH prefers existing legacy filename when present", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-config-")); + const previousHome = process.env.HOME; + const previousMoltbotConfig = process.env.MOLTBOT_CONFIG_PATH; + const previousClawdbotConfig = process.env.CLAWDBOT_CONFIG_PATH; + const previousMoltbotState = process.env.MOLTBOT_STATE_DIR; + const previousClawdbotState = process.env.CLAWDBOT_STATE_DIR; + try { + const legacyDir = path.join(root, ".clawdbot"); + await fs.mkdir(legacyDir, { recursive: true }); + const legacyPath = path.join(legacyDir, "clawdbot.json"); + await fs.writeFile(legacyPath, "{}", "utf-8"); + + process.env.HOME = root; + delete process.env.MOLTBOT_CONFIG_PATH; + delete process.env.CLAWDBOT_CONFIG_PATH; + delete process.env.MOLTBOT_STATE_DIR; + delete process.env.CLAWDBOT_STATE_DIR; + + vi.resetModules(); + const { CONFIG_PATH } = await import("./paths.js"); + expect(CONFIG_PATH).toBe(legacyPath); + } finally { + if (previousHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = previousHome; + } + if (previousMoltbotConfig === undefined) delete process.env.MOLTBOT_CONFIG_PATH; + else process.env.MOLTBOT_CONFIG_PATH = previousMoltbotConfig; + if (previousClawdbotConfig === undefined) delete process.env.CLAWDBOT_CONFIG_PATH; + else process.env.CLAWDBOT_CONFIG_PATH = previousClawdbotConfig; + if (previousMoltbotState === undefined) delete process.env.MOLTBOT_STATE_DIR; + else process.env.MOLTBOT_STATE_DIR = previousMoltbotState; + if (previousClawdbotState === undefined) delete process.env.CLAWDBOT_STATE_DIR; + else process.env.CLAWDBOT_STATE_DIR = previousClawdbotState; + await fs.rm(root, { recursive: true, force: true }); + vi.resetModules(); + } }); }); diff --git a/src/config/paths.ts b/src/config/paths.ts index 2fc3937c4..df62ddec3 100644 --- a/src/config/paths.ts +++ b/src/config/paths.ts @@ -1,3 +1,4 @@ +import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import type { MoltbotConfig } from "./types.js"; @@ -18,6 +19,7 @@ export const isNixMode = resolveIsNixMode(); const LEGACY_STATE_DIRNAME = ".clawdbot"; const NEW_STATE_DIRNAME = ".moltbot"; const CONFIG_FILENAME = "moltbot.json"; +const LEGACY_CONFIG_FILENAME = "clawdbot.json"; function legacyStateDir(homedir: () => string = os.homedir): string { return path.join(homedir(), LEGACY_STATE_DIRNAME); @@ -27,10 +29,19 @@ function newStateDir(homedir: () => string = os.homedir): string { return path.join(homedir(), NEW_STATE_DIRNAME); } +export function resolveLegacyStateDir(homedir: () => string = os.homedir): string { + return legacyStateDir(homedir); +} + +export function resolveNewStateDir(homedir: () => string = os.homedir): string { + return newStateDir(homedir); +} + /** * State directory for mutable data (sessions, logs, caches). * Can be overridden via MOLTBOT_STATE_DIR (preferred) or CLAWDBOT_STATE_DIR (legacy). * Default: ~/.clawdbot (legacy default for compatibility) + * If ~/.moltbot exists and ~/.clawdbot does not, prefer ~/.moltbot. */ export function resolveStateDir( env: NodeJS.ProcessEnv = process.env, @@ -38,7 +49,12 @@ export function resolveStateDir( ): string { const override = env.MOLTBOT_STATE_DIR?.trim() || env.CLAWDBOT_STATE_DIR?.trim(); if (override) return resolveUserPath(override); - return legacyStateDir(homedir); + const legacyDir = legacyStateDir(homedir); + const newDir = newStateDir(homedir); + const hasLegacy = fs.existsSync(legacyDir); + const hasNew = fs.existsSync(newDir); + if (!hasLegacy && hasNew) return newDir; + return legacyDir; } function resolveUserPath(input: string): string { @@ -58,7 +74,7 @@ export const STATE_DIR = resolveStateDir(); * Can be overridden via MOLTBOT_CONFIG_PATH (preferred) or CLAWDBOT_CONFIG_PATH (legacy). * Default: ~/.clawdbot/moltbot.json (or $*_STATE_DIR/moltbot.json) */ -export function resolveConfigPath( +export function resolveCanonicalConfigPath( env: NodeJS.ProcessEnv = process.env, stateDir: string = resolveStateDir(env, os.homedir), ): string { @@ -67,7 +83,56 @@ export function resolveConfigPath( return path.join(stateDir, CONFIG_FILENAME); } -export const CONFIG_PATH = resolveConfigPath(); +/** + * Resolve the active config path by preferring existing config candidates + * (new/legacy filenames) before falling back to the canonical path. + */ +export function resolveConfigPathCandidate( + env: NodeJS.ProcessEnv = process.env, + homedir: () => string = os.homedir, +): string { + const candidates = resolveDefaultConfigCandidates(env, homedir); + const existing = candidates.find((candidate) => { + try { + return fs.existsSync(candidate); + } catch { + return false; + } + }); + if (existing) return existing; + return resolveCanonicalConfigPath(env, resolveStateDir(env, homedir)); +} + +/** + * Active config path (prefers existing legacy/new config files). + */ +export function resolveConfigPath( + env: NodeJS.ProcessEnv = process.env, + stateDir: string = resolveStateDir(env, os.homedir), + homedir: () => string = os.homedir, +): string { + const override = env.MOLTBOT_CONFIG_PATH?.trim() || env.CLAWDBOT_CONFIG_PATH?.trim(); + if (override) return resolveUserPath(override); + const candidates = [ + path.join(stateDir, CONFIG_FILENAME), + path.join(stateDir, LEGACY_CONFIG_FILENAME), + ]; + const existing = candidates.find((candidate) => { + try { + return fs.existsSync(candidate); + } catch { + return false; + } + }); + if (existing) return existing; + const defaultStateDir = resolveStateDir(env, homedir); + if (path.resolve(stateDir) === path.resolve(defaultStateDir)) { + return resolveConfigPathCandidate(env, homedir); + } + return path.join(stateDir, CONFIG_FILENAME); +} + +export const CONFIG_PATH = resolveConfigPathCandidate(); /** * Resolve default config path candidates across new + legacy locations. @@ -84,14 +149,18 @@ export function resolveDefaultConfigCandidates( const moltbotStateDir = env.MOLTBOT_STATE_DIR?.trim(); if (moltbotStateDir) { candidates.push(path.join(resolveUserPath(moltbotStateDir), CONFIG_FILENAME)); + candidates.push(path.join(resolveUserPath(moltbotStateDir), LEGACY_CONFIG_FILENAME)); } const legacyStateDirOverride = env.CLAWDBOT_STATE_DIR?.trim(); if (legacyStateDirOverride) { candidates.push(path.join(resolveUserPath(legacyStateDirOverride), CONFIG_FILENAME)); + candidates.push(path.join(resolveUserPath(legacyStateDirOverride), LEGACY_CONFIG_FILENAME)); } candidates.push(path.join(newStateDir(homedir), CONFIG_FILENAME)); + candidates.push(path.join(newStateDir(homedir), LEGACY_CONFIG_FILENAME)); candidates.push(path.join(legacyStateDir(homedir), CONFIG_FILENAME)); + candidates.push(path.join(legacyStateDir(homedir), LEGACY_CONFIG_FILENAME)); return candidates; } diff --git a/src/infra/state-migrations.ts b/src/infra/state-migrations.ts index cb3d5f333..f5e50740e 100644 --- a/src/infra/state-migrations.ts +++ b/src/infra/state-migrations.ts @@ -4,7 +4,12 @@ import path from "node:path"; import { resolveDefaultAgentId } from "../agents/agent-scope.js"; import type { MoltbotConfig } from "../config/config.js"; -import { resolveOAuthDir, resolveStateDir } from "../config/paths.js"; +import { + resolveLegacyStateDir, + resolveNewStateDir, + resolveOAuthDir, + resolveStateDir, +} from "../config/paths.js"; import type { SessionEntry } from "../config/sessions.js"; import type { SessionScope } from "../config/sessions/types.js"; import { saveSessionStore } from "../config/sessions.js"; @@ -59,6 +64,7 @@ type MigrationLogger = { }; let autoMigrateChecked = false; +let autoMigrateStateDirChecked = false; function isSurfaceGroupKey(key: string): boolean { return key.includes(":group:") || key.includes(":channel:"); @@ -267,6 +273,131 @@ export function resetAutoMigrateLegacyAgentDirForTest() { resetAutoMigrateLegacyStateForTest(); } +export function resetAutoMigrateLegacyStateDirForTest() { + autoMigrateStateDirChecked = false; +} + +type StateDirMigrationResult = { + migrated: boolean; + skipped: boolean; + changes: string[]; + warnings: string[]; +}; + +function resolveSymlinkTarget(linkPath: string): string | null { + try { + const target = fs.readlinkSync(linkPath); + return path.resolve(path.dirname(linkPath), target); + } catch { + return null; + } +} + +function formatStateDirMigration(legacyDir: string, targetDir: string): string { + return `State dir: ${legacyDir} → ${targetDir} (legacy path now symlinked)`; +} + +function isDirPath(filePath: string): boolean { + try { + return fs.statSync(filePath).isDirectory(); + } catch { + return false; + } +} + +export async function autoMigrateLegacyStateDir(params: { + env?: NodeJS.ProcessEnv; + homedir?: () => string; + log?: MigrationLogger; +}): Promise { + if (autoMigrateStateDirChecked) { + return { migrated: false, skipped: true, changes: [], warnings: [] }; + } + autoMigrateStateDirChecked = true; + + const env = params.env ?? process.env; + if (env.MOLTBOT_STATE_DIR?.trim() || env.CLAWDBOT_STATE_DIR?.trim()) { + return { migrated: false, skipped: true, changes: [], warnings: [] }; + } + + const homedir = params.homedir ?? os.homedir; + const legacyDir = resolveLegacyStateDir(homedir); + const targetDir = resolveNewStateDir(homedir); + const warnings: string[] = []; + const changes: string[] = []; + + let legacyStat: fs.Stats | null = null; + try { + legacyStat = fs.lstatSync(legacyDir); + } catch { + legacyStat = null; + } + if (!legacyStat) { + return { migrated: false, skipped: false, changes, warnings }; + } + if (!legacyStat.isDirectory() && !legacyStat.isSymbolicLink()) { + warnings.push(`Legacy state path is not a directory: ${legacyDir}`); + return { migrated: false, skipped: false, changes, warnings }; + } + + if (legacyStat.isSymbolicLink()) { + const legacyTarget = resolveSymlinkTarget(legacyDir); + if (legacyTarget && path.resolve(legacyTarget) === path.resolve(targetDir)) { + return { migrated: false, skipped: false, changes, warnings }; + } + warnings.push( + `Legacy state dir is a symlink (${legacyDir} → ${legacyTarget ?? "unknown"}); skipping auto-migration.`, + ); + return { migrated: false, skipped: false, changes, warnings }; + } + + if (isDirPath(targetDir)) { + warnings.push( + `State dir migration skipped: target already exists (${targetDir}). Remove or merge manually.`, + ); + return { migrated: false, skipped: false, changes, warnings }; + } + + try { + fs.renameSync(legacyDir, targetDir); + } catch (err) { + warnings.push(`Failed to move legacy state dir (${legacyDir} → ${targetDir}): ${String(err)}`); + return { migrated: false, skipped: false, changes, warnings }; + } + + try { + fs.symlinkSync(targetDir, legacyDir, "dir"); + changes.push(formatStateDirMigration(legacyDir, targetDir)); + } catch (err) { + try { + if (process.platform === "win32") { + fs.symlinkSync(targetDir, legacyDir, "junction"); + changes.push(formatStateDirMigration(legacyDir, targetDir)); + } else { + throw err; + } + } catch (fallbackErr) { + try { + fs.renameSync(targetDir, legacyDir); + warnings.push( + `State dir migration rolled back (failed to link legacy path): ${String(fallbackErr)}`, + ); + return { migrated: false, skipped: false, changes: [], warnings }; + } catch (rollbackErr) { + warnings.push( + `State dir moved but failed to link legacy path (${legacyDir} → ${targetDir}): ${String(fallbackErr)}`, + ); + warnings.push( + `Rollback failed; set MOLTBOT_STATE_DIR=${targetDir} to avoid split state: ${String(rollbackErr)}`, + ); + changes.push(`State dir: ${legacyDir} → ${targetDir}`); + } + } + } + + return { migrated: changes.length > 0, skipped: false, changes, warnings }; +} + export async function detectLegacyStateMigrations(params: { cfg: MoltbotConfig; env?: NodeJS.ProcessEnv; @@ -591,8 +722,18 @@ export async function autoMigrateLegacyState(params: { autoMigrateChecked = true; const env = params.env ?? process.env; + const stateDirResult = await autoMigrateLegacyStateDir({ + env, + homedir: params.homedir, + log: params.log, + }); if (env.CLAWDBOT_AGENT_DIR?.trim() || env.PI_CODING_AGENT_DIR?.trim()) { - return { migrated: false, skipped: true, changes: [], warnings: [] }; + return { + migrated: stateDirResult.migrated, + skipped: true, + changes: stateDirResult.changes, + warnings: stateDirResult.warnings, + }; } const detected = await detectLegacyStateMigrations({ @@ -601,14 +742,19 @@ export async function autoMigrateLegacyState(params: { homedir: params.homedir, }); if (!detected.sessions.hasLegacy && !detected.agentDir.hasLegacy) { - return { migrated: false, skipped: false, changes: [], warnings: [] }; + return { + migrated: stateDirResult.migrated, + skipped: false, + changes: stateDirResult.changes, + warnings: stateDirResult.warnings, + }; } 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 changes = [...stateDirResult.changes, ...sessions.changes, ...agentDir.changes]; + const warnings = [...stateDirResult.warnings, ...sessions.warnings, ...agentDir.warnings]; const logger = params.log ?? createSubsystemLogger("state-migrations"); if (changes.length > 0) { diff --git a/src/utils.test.ts b/src/utils.test.ts index 686808a46..769c98a4f 100644 --- a/src/utils.test.ts +++ b/src/utils.test.ts @@ -9,6 +9,7 @@ import { jidToE164, normalizeE164, normalizePath, + resolveConfigDir, resolveJidToE164, resolveUserPath, sleep, @@ -120,6 +121,20 @@ describe("jidToE164", () => { }); }); +describe("resolveConfigDir", () => { + it("prefers ~/.moltbot when legacy dir is missing", async () => { + const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "moltbot-config-dir-")); + try { + const newDir = path.join(root, ".moltbot"); + await fs.promises.mkdir(newDir, { recursive: true }); + const resolved = resolveConfigDir({} as NodeJS.ProcessEnv, () => root); + expect(resolved).toBe(newDir); + } finally { + await fs.promises.rm(root, { recursive: true, force: true }); + } + }); +}); + describe("resolveJidToE164", () => { it("resolves @lid via lidLookup when mapping file is missing", async () => { const lidLookup = { diff --git a/src/utils.ts b/src/utils.ts index cdb56c7ee..7c441f4f1 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -215,9 +215,18 @@ export function resolveConfigDir( env: NodeJS.ProcessEnv = process.env, homedir: () => string = os.homedir, ): string { - const override = env.CLAWDBOT_STATE_DIR?.trim(); + const override = env.MOLTBOT_STATE_DIR?.trim() || env.CLAWDBOT_STATE_DIR?.trim(); if (override) return resolveUserPath(override); - return path.join(homedir(), ".clawdbot"); + const legacyDir = path.join(homedir(), ".clawdbot"); + const newDir = path.join(homedir(), ".moltbot"); + try { + const hasLegacy = fs.existsSync(legacyDir); + const hasNew = fs.existsSync(newDir); + if (!hasLegacy && hasNew) return newDir; + } catch { + // best-effort + } + return legacyDir; } export function resolveHomeDir(): string | undefined {