fix: migrate legacy state/config paths
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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))}`);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
export type { LegacyStateDetection } from "../infra/state-migrations.js";
|
||||
export {
|
||||
autoMigrateLegacyStateDir,
|
||||
autoMigrateLegacyAgentDir,
|
||||
autoMigrateLegacyState,
|
||||
detectLegacyStateMigrations,
|
||||
migrateLegacyAgentDir,
|
||||
resetAutoMigrateLegacyStateDirForTest,
|
||||
resetAutoMigrateLegacyAgentDirForTest,
|
||||
resetAutoMigrateLegacyStateForTest,
|
||||
runLegacyStateMigrations,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -14,10 +14,15 @@ async function withTempHome(run: (home: string) => Promise<void>): Promise<void>
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -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<ConfigFileSnapshot> {
|
||||
return await createConfigIO({
|
||||
configPath: resolveConfigPath(),
|
||||
}).readConfigFileSnapshot();
|
||||
return await createConfigIO().readConfigFileSnapshot();
|
||||
}
|
||||
|
||||
export async function writeConfigFile(cfg: MoltbotConfig): Promise<void> {
|
||||
clearConfigCache();
|
||||
await createConfigIO({ configPath: resolveConfigPath() }).writeConfigFile(cfg);
|
||||
await createConfigIO().writeConfigFile(cfg);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<StateDirMigrationResult> {
|
||||
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) {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
13
src/utils.ts
13
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 {
|
||||
|
||||
Reference in New Issue
Block a user