fix: migrate legacy state/config paths

This commit is contained in:
Peter Steinberger
2026-01-28 00:15:54 +00:00
parent 0770194b29
commit e2c437e81e
19 changed files with 492 additions and 33 deletions

View File

@@ -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<string, unknown> {
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<boolean>;
}) {
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;

View File

@@ -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);
});
});

View File

@@ -1,9 +1,11 @@
export type { LegacyStateDetection } from "../infra/state-migrations.js";
export {
autoMigrateLegacyStateDir,
autoMigrateLegacyAgentDir,
autoMigrateLegacyState,
detectLegacyStateMigrations,
migrateLegacyAgentDir,
resetAutoMigrateLegacyStateDirForTest,
resetAutoMigrateLegacyAgentDirForTest,
resetAutoMigrateLegacyStateForTest,
runLegacyStateMigrations,

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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({