refactor: auto-migrate legacy config in gateway
This commit is contained in:
@@ -79,7 +79,7 @@ export function buildProgram() {
|
|||||||
.join("\n");
|
.join("\n");
|
||||||
defaultRuntime.error(
|
defaultRuntime.error(
|
||||||
danger(
|
danger(
|
||||||
`Legacy config entries detected. Ask your agent to run \"clawdis doctor\" to migrate.\n${issues}`,
|
`Legacy config entries detected. Run \"clawdis doctor\" (or ask your agent) to migrate.\n${issues}`,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import { describe, expect, it, vi } from "vitest";
|
|||||||
|
|
||||||
const readConfigFileSnapshot = vi.fn();
|
const readConfigFileSnapshot = vi.fn();
|
||||||
const writeConfigFile = vi.fn().mockResolvedValue(undefined);
|
const writeConfigFile = vi.fn().mockResolvedValue(undefined);
|
||||||
const validateConfigObject = vi.fn((raw: unknown) => ({
|
const migrateLegacyConfig = vi.fn((raw: unknown) => ({
|
||||||
ok: true as const,
|
|
||||||
config: raw as Record<string, unknown>,
|
config: raw as Record<string, unknown>,
|
||||||
|
changes: ["Moved routing.allowFrom → whatsapp.allowFrom."],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@clack/prompts", () => ({
|
vi.mock("@clack/prompts", () => ({
|
||||||
@@ -22,7 +22,7 @@ vi.mock("../config/config.js", () => ({
|
|||||||
CONFIG_PATH_CLAWDIS: "/tmp/clawdis.json",
|
CONFIG_PATH_CLAWDIS: "/tmp/clawdis.json",
|
||||||
readConfigFileSnapshot,
|
readConfigFileSnapshot,
|
||||||
writeConfigFile,
|
writeConfigFile,
|
||||||
validateConfigObject,
|
migrateLegacyConfig,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../runtime.js", () => ({
|
vi.mock("../runtime.js", () => ({
|
||||||
@@ -81,6 +81,11 @@ describe("doctor", () => {
|
|||||||
exit: vi.fn(),
|
exit: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
migrateLegacyConfig.mockReturnValue({
|
||||||
|
config: { whatsapp: { allowFrom: ["+15555550123"] } },
|
||||||
|
changes: ["Moved routing.allowFrom → whatsapp.allowFrom."],
|
||||||
|
});
|
||||||
|
|
||||||
await doctorCommand(runtime);
|
await doctorCommand(runtime);
|
||||||
|
|
||||||
expect(writeConfigFile).toHaveBeenCalledTimes(1);
|
expect(writeConfigFile).toHaveBeenCalledTimes(1);
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import { buildWorkspaceSkillStatus } from "../agents/skills-status.js";
|
|||||||
import type { ClawdisConfig } from "../config/config.js";
|
import type { ClawdisConfig } from "../config/config.js";
|
||||||
import {
|
import {
|
||||||
CONFIG_PATH_CLAWDIS,
|
CONFIG_PATH_CLAWDIS,
|
||||||
|
migrateLegacyConfig,
|
||||||
readConfigFileSnapshot,
|
readConfigFileSnapshot,
|
||||||
validateConfigObject,
|
|
||||||
writeConfigFile,
|
writeConfigFile,
|
||||||
} from "../config/config.js";
|
} from "../config/config.js";
|
||||||
import { resolveGatewayService } from "../daemon/service.js";
|
import { resolveGatewayService } from "../daemon/service.js";
|
||||||
@@ -20,65 +20,6 @@ import {
|
|||||||
printWizardHeader,
|
printWizardHeader,
|
||||||
} from "./onboard-helpers.js";
|
} from "./onboard-helpers.js";
|
||||||
|
|
||||||
type LegacyMigration = {
|
|
||||||
id: string;
|
|
||||||
describe: string;
|
|
||||||
apply: (raw: Record<string, unknown>, changes: string[]) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const LEGACY_MIGRATIONS: LegacyMigration[] = [
|
|
||||||
// Legacy migration (2026-01-02, commit: 0766c5e3) — normalize per-provider allowlists; move WhatsApp gating into whatsapp.allowFrom.
|
|
||||||
{
|
|
||||||
id: "routing.allowFrom->whatsapp.allowFrom",
|
|
||||||
describe: "Move routing.allowFrom to whatsapp.allowFrom",
|
|
||||||
apply: (raw, changes) => {
|
|
||||||
const routing = raw.routing;
|
|
||||||
if (!routing || typeof routing !== "object") return;
|
|
||||||
const allowFrom = (routing as Record<string, unknown>).allowFrom;
|
|
||||||
if (allowFrom === undefined) return;
|
|
||||||
|
|
||||||
const whatsapp =
|
|
||||||
raw.whatsapp && typeof raw.whatsapp === "object"
|
|
||||||
? (raw.whatsapp as Record<string, unknown>)
|
|
||||||
: {};
|
|
||||||
|
|
||||||
if (whatsapp.allowFrom === undefined) {
|
|
||||||
whatsapp.allowFrom = allowFrom;
|
|
||||||
changes.push("Moved routing.allowFrom → whatsapp.allowFrom.");
|
|
||||||
} else {
|
|
||||||
changes.push("Removed routing.allowFrom (whatsapp.allowFrom already set).");
|
|
||||||
}
|
|
||||||
|
|
||||||
delete (routing as Record<string, unknown>).allowFrom;
|
|
||||||
if (Object.keys(routing as Record<string, unknown>).length === 0) {
|
|
||||||
delete raw.routing;
|
|
||||||
}
|
|
||||||
raw.whatsapp = whatsapp;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
function applyLegacyMigrations(raw: unknown): {
|
|
||||||
config: ClawdisConfig | null;
|
|
||||||
changes: string[];
|
|
||||||
} {
|
|
||||||
if (!raw || typeof raw !== "object") return { config: null, changes: [] };
|
|
||||||
const next = structuredClone(raw) as Record<string, unknown>;
|
|
||||||
const changes: string[] = [];
|
|
||||||
for (const migration of LEGACY_MIGRATIONS) {
|
|
||||||
migration.apply(next, changes);
|
|
||||||
}
|
|
||||||
if (changes.length === 0) return { config: null, changes: [] };
|
|
||||||
const validated = validateConfigObject(next);
|
|
||||||
if (!validated.ok) {
|
|
||||||
changes.push(
|
|
||||||
"Migration applied, but config still invalid; fix remaining issues manually.",
|
|
||||||
);
|
|
||||||
return { config: null, changes };
|
|
||||||
}
|
|
||||||
return { config: validated.config, changes };
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveMode(cfg: ClawdisConfig): "local" | "remote" {
|
function resolveMode(cfg: ClawdisConfig): "local" | "remote" {
|
||||||
return cfg.gateway?.mode === "remote" ? "remote" : "local";
|
return cfg.gateway?.mode === "remote" ? "remote" : "local";
|
||||||
}
|
}
|
||||||
@@ -108,9 +49,8 @@ export async function doctorCommand(runtime: RuntimeEnv = defaultRuntime) {
|
|||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
if (migrate) {
|
if (migrate) {
|
||||||
const { config: migrated, changes } = applyLegacyMigrations(
|
// Legacy migration (2026-01-02, commit: 0766c5e3) — normalize per-provider allowlists; move WhatsApp gating into whatsapp.allowFrom.
|
||||||
snapshot.parsed,
|
const { config: migrated, changes } = migrateLegacyConfig(snapshot.parsed);
|
||||||
);
|
|
||||||
if (changes.length > 0) {
|
if (changes.length > 0) {
|
||||||
note(changes.join("\n"), "Doctor changes");
|
note(changes.join("\n"), "Doctor changes");
|
||||||
}
|
}
|
||||||
@@ -144,7 +84,12 @@ export async function doctorCommand(runtime: RuntimeEnv = defaultRuntime) {
|
|||||||
await healthCommand({ json: false, timeoutMs: 10_000 }, runtime);
|
await healthCommand({ json: false, timeoutMs: 10_000 }, runtime);
|
||||||
healthOk = true;
|
healthOk = true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
runtime.error(`Health check failed: ${String(err)}`);
|
const message = String(err);
|
||||||
|
if (message.includes("gateway closed")) {
|
||||||
|
note("Gateway not running.", "Gateway");
|
||||||
|
} else {
|
||||||
|
runtime.error(`Health check failed: ${message}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!healthOk) {
|
if (!healthOk) {
|
||||||
@@ -166,7 +111,12 @@ export async function doctorCommand(runtime: RuntimeEnv = defaultRuntime) {
|
|||||||
try {
|
try {
|
||||||
await healthCommand({ json: false, timeoutMs: 10_000 }, runtime);
|
await healthCommand({ json: false, timeoutMs: 10_000 }, runtime);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
runtime.error(`Health check failed: ${String(err)}`);
|
const message = String(err);
|
||||||
|
if (message.includes("gateway closed")) {
|
||||||
|
note("Gateway not running.", "Gateway");
|
||||||
|
} else {
|
||||||
|
runtime.error(`Health check failed: ${message}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -502,6 +502,17 @@ describe("legacy config detection", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("migrates routing.allowFrom to whatsapp.allowFrom", async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
const { migrateLegacyConfig } = await import("./config.js");
|
||||||
|
const res = migrateLegacyConfig({
|
||||||
|
routing: { allowFrom: ["+15555550123"] },
|
||||||
|
});
|
||||||
|
expect(res.changes).toContain("Moved routing.allowFrom → whatsapp.allowFrom.");
|
||||||
|
expect(res.config?.whatsapp?.allowFrom).toEqual(["+15555550123"]);
|
||||||
|
expect(res.config?.routing?.allowFrom).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it("surfaces legacy issues in snapshot", async () => {
|
it("surfaces legacy issues in snapshot", async () => {
|
||||||
await withTempHome(async (home) => {
|
await withTempHome(async (home) => {
|
||||||
const configPath = path.join(home, ".clawdis", "clawdis.json");
|
const configPath = path.join(home, ".clawdis", "clawdis.json");
|
||||||
|
|||||||
@@ -1161,6 +1161,12 @@ type LegacyConfigRule = {
|
|||||||
message: string;
|
message: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type LegacyConfigMigration = {
|
||||||
|
id: string;
|
||||||
|
describe: string;
|
||||||
|
apply: (raw: Record<string, unknown>, changes: string[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
const LEGACY_CONFIG_RULES: LegacyConfigRule[] = [
|
const LEGACY_CONFIG_RULES: LegacyConfigRule[] = [
|
||||||
{
|
{
|
||||||
path: ["routing", "allowFrom"],
|
path: ["routing", "allowFrom"],
|
||||||
@@ -1169,6 +1175,37 @@ const LEGACY_CONFIG_RULES: LegacyConfigRule[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const LEGACY_CONFIG_MIGRATIONS: LegacyConfigMigration[] = [
|
||||||
|
{
|
||||||
|
id: "routing.allowFrom->whatsapp.allowFrom",
|
||||||
|
describe: "Move routing.allowFrom to whatsapp.allowFrom",
|
||||||
|
apply: (raw, changes) => {
|
||||||
|
const routing = raw.routing;
|
||||||
|
if (!routing || typeof routing !== "object") return;
|
||||||
|
const allowFrom = (routing as Record<string, unknown>).allowFrom;
|
||||||
|
if (allowFrom === undefined) return;
|
||||||
|
|
||||||
|
const whatsapp =
|
||||||
|
raw.whatsapp && typeof raw.whatsapp === "object"
|
||||||
|
? (raw.whatsapp as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
|
||||||
|
if (whatsapp.allowFrom === undefined) {
|
||||||
|
whatsapp.allowFrom = allowFrom;
|
||||||
|
changes.push("Moved routing.allowFrom → whatsapp.allowFrom.");
|
||||||
|
} else {
|
||||||
|
changes.push("Removed routing.allowFrom (whatsapp.allowFrom already set).");
|
||||||
|
}
|
||||||
|
|
||||||
|
delete (routing as Record<string, unknown>).allowFrom;
|
||||||
|
if (Object.keys(routing as Record<string, unknown>).length === 0) {
|
||||||
|
delete raw.routing;
|
||||||
|
}
|
||||||
|
raw.whatsapp = whatsapp;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
function findLegacyConfigIssues(raw: unknown): LegacyConfigIssue[] {
|
function findLegacyConfigIssues(raw: unknown): LegacyConfigIssue[] {
|
||||||
if (!raw || typeof raw !== "object") return [];
|
if (!raw || typeof raw !== "object") return [];
|
||||||
const root = raw as Record<string, unknown>;
|
const root = raw as Record<string, unknown>;
|
||||||
@@ -1189,6 +1226,27 @@ function findLegacyConfigIssues(raw: unknown): LegacyConfigIssue[] {
|
|||||||
return issues;
|
return issues;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function migrateLegacyConfig(raw: unknown): {
|
||||||
|
config: ClawdisConfig | null;
|
||||||
|
changes: string[];
|
||||||
|
} {
|
||||||
|
if (!raw || typeof raw !== "object") return { config: null, changes: [] };
|
||||||
|
const next = structuredClone(raw) as Record<string, unknown>;
|
||||||
|
const changes: string[] = [];
|
||||||
|
for (const migration of LEGACY_CONFIG_MIGRATIONS) {
|
||||||
|
migration.apply(next, changes);
|
||||||
|
}
|
||||||
|
if (changes.length === 0) return { config: null, changes: [] };
|
||||||
|
const validated = validateConfigObject(next);
|
||||||
|
if (!validated.ok) {
|
||||||
|
changes.push(
|
||||||
|
"Migration applied, but config still invalid; fix remaining issues manually.",
|
||||||
|
);
|
||||||
|
return { config: null, changes };
|
||||||
|
}
|
||||||
|
return { config: validated.config, changes };
|
||||||
|
}
|
||||||
|
|
||||||
function escapeRegExp(text: string): string {
|
function escapeRegExp(text: string): string {
|
||||||
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -204,6 +204,10 @@ vi.mock("../config/config.js", () => {
|
|||||||
CONFIG_PATH_CLAWDIS: resolveConfigPath(),
|
CONFIG_PATH_CLAWDIS: resolveConfigPath(),
|
||||||
STATE_DIR_CLAWDIS: path.dirname(resolveConfigPath()),
|
STATE_DIR_CLAWDIS: path.dirname(resolveConfigPath()),
|
||||||
isNixMode: false,
|
isNixMode: false,
|
||||||
|
migrateLegacyConfig: (raw: unknown) => ({
|
||||||
|
config: raw as Record<string, unknown>,
|
||||||
|
changes: [],
|
||||||
|
}),
|
||||||
loadConfig: () => ({
|
loadConfig: () => ({
|
||||||
agent: {
|
agent: {
|
||||||
model: "anthropic/claude-opus-4-5",
|
model: "anthropic/claude-opus-4-5",
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import {
|
|||||||
CONFIG_PATH_CLAWDIS,
|
CONFIG_PATH_CLAWDIS,
|
||||||
isNixMode,
|
isNixMode,
|
||||||
loadConfig,
|
loadConfig,
|
||||||
|
migrateLegacyConfig,
|
||||||
parseConfigJson5,
|
parseConfigJson5,
|
||||||
readConfigFileSnapshot,
|
readConfigFileSnapshot,
|
||||||
STATE_DIR_CLAWDIS,
|
STATE_DIR_CLAWDIS,
|
||||||
@@ -1322,6 +1323,31 @@ export async function startGatewayServer(
|
|||||||
port = 18789,
|
port = 18789,
|
||||||
opts: GatewayServerOptions = {},
|
opts: GatewayServerOptions = {},
|
||||||
): Promise<GatewayServer> {
|
): Promise<GatewayServer> {
|
||||||
|
const configSnapshot = await readConfigFileSnapshot();
|
||||||
|
if (configSnapshot.legacyIssues.length > 0) {
|
||||||
|
if (isNixMode) {
|
||||||
|
throw new Error(
|
||||||
|
"Legacy config entries detected while running in Nix mode. Update your Nix config to the latest schema and restart.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { config: migrated, changes } = migrateLegacyConfig(
|
||||||
|
configSnapshot.parsed,
|
||||||
|
);
|
||||||
|
if (!migrated) {
|
||||||
|
throw new Error(
|
||||||
|
"Legacy config entries detected but auto-migration failed. Run \"clawdis doctor\" to migrate.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await writeConfigFile(migrated);
|
||||||
|
if (changes.length > 0) {
|
||||||
|
log.info(
|
||||||
|
`gateway: migrated legacy config entries:\n${changes
|
||||||
|
.map((entry) => `- ${entry}`)
|
||||||
|
.join("\n")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const cfgAtStart = loadConfig();
|
const cfgAtStart = loadConfig();
|
||||||
const bindMode = opts.bind ?? cfgAtStart.gateway?.bind ?? "loopback";
|
const bindMode = opts.bind ?? cfgAtStart.gateway?.bind ?? "loopback";
|
||||||
const bindHost = opts.host ?? resolveGatewayBindHost(bindMode);
|
const bindHost = opts.host ?? resolveGatewayBindHost(bindMode);
|
||||||
|
|||||||
Reference in New Issue
Block a user