mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-03 04:10:22 +00:00
CLI: expand config set with SecretRef/provider builders and dry-run (#49296)
* CLI: expand config set ref/provider builder and dry-run * Docs: revert README Discord token example
This commit is contained in:
@@ -1,3 +1,6 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { Command } from "commander";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ConfigFileSnapshot, OpenClawConfig } from "../config/types.js";
|
||||
@@ -12,6 +15,7 @@ const mockReadConfigFileSnapshot = vi.fn<() => Promise<ConfigFileSnapshot>>();
|
||||
const mockWriteConfigFile = vi.fn<
|
||||
(cfg: OpenClawConfig, options?: { unsetPaths?: string[][] }) => Promise<void>
|
||||
>(async () => {});
|
||||
const mockResolveSecretRefValue = vi.fn();
|
||||
|
||||
vi.mock("../config/config.js", () => ({
|
||||
readConfigFileSnapshot: () => mockReadConfigFileSnapshot(),
|
||||
@@ -19,6 +23,10 @@ vi.mock("../config/config.js", () => ({
|
||||
mockWriteConfigFile(cfg, options),
|
||||
}));
|
||||
|
||||
vi.mock("../secrets/resolve.js", () => ({
|
||||
resolveSecretRefValue: (...args: unknown[]) => mockResolveSecretRefValue(...args),
|
||||
}));
|
||||
|
||||
const mockLog = vi.fn();
|
||||
const mockError = vi.fn();
|
||||
const mockExit = vi.fn((code: number) => {
|
||||
@@ -123,6 +131,7 @@ describe("config cli", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockResolveSecretRefValue.mockResolvedValue("resolved-secret");
|
||||
});
|
||||
|
||||
describe("config set - issue #6070", () => {
|
||||
@@ -345,6 +354,23 @@ describe("config cli", () => {
|
||||
expect(mockReadConfigFileSnapshot).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts --strict-json with batch mode and applies batch payload", async () => {
|
||||
const resolved: OpenClawConfig = { gateway: { port: 18789 } };
|
||||
setSnapshot(resolved, resolved);
|
||||
|
||||
await runConfigCommand([
|
||||
"config",
|
||||
"set",
|
||||
"--batch-json",
|
||||
'[{"path":"gateway.auth.mode","value":"token"}]',
|
||||
"--strict-json",
|
||||
]);
|
||||
|
||||
expect(mockWriteConfigFile).toHaveBeenCalledTimes(1);
|
||||
const written = mockWriteConfigFile.mock.calls[0]?.[0];
|
||||
expect(written.gateway?.auth).toEqual({ mode: "token" });
|
||||
});
|
||||
|
||||
it("shows --strict-json and keeps --json as a legacy alias in help", async () => {
|
||||
const program = new Command();
|
||||
registerConfigCli(program);
|
||||
@@ -356,6 +382,485 @@ describe("config cli", () => {
|
||||
expect(helpText).toContain("--strict-json");
|
||||
expect(helpText).toContain("--json");
|
||||
expect(helpText).toContain("Legacy alias for --strict-json");
|
||||
expect(helpText).toContain("--ref-provider");
|
||||
expect(helpText).toContain("--provider-source");
|
||||
expect(helpText).toContain("--batch-json");
|
||||
expect(helpText).toContain("--dry-run");
|
||||
expect(helpText).toContain("openclaw config set gateway.port 19001 --strict-json");
|
||||
expect(helpText).toContain(
|
||||
"openclaw config set channels.discord.token --ref-provider default --ref-source",
|
||||
);
|
||||
expect(helpText).toContain("--ref-id DISCORD_BOT_TOKEN");
|
||||
expect(helpText).toContain(
|
||||
"openclaw config set --batch-file ./config-set.batch.json --dry-run",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("config set builders and dry-run", () => {
|
||||
it("supports SecretRef builder mode without requiring a value argument", async () => {
|
||||
const resolved: OpenClawConfig = {
|
||||
gateway: { port: 18789 },
|
||||
};
|
||||
setSnapshot(resolved, resolved);
|
||||
|
||||
await runConfigCommand([
|
||||
"config",
|
||||
"set",
|
||||
"channels.discord.token",
|
||||
"--ref-provider",
|
||||
"default",
|
||||
"--ref-source",
|
||||
"env",
|
||||
"--ref-id",
|
||||
"DISCORD_BOT_TOKEN",
|
||||
]);
|
||||
|
||||
expect(mockWriteConfigFile).toHaveBeenCalledTimes(1);
|
||||
const written = mockWriteConfigFile.mock.calls[0]?.[0];
|
||||
expect(written.channels?.discord?.token).toEqual({
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "DISCORD_BOT_TOKEN",
|
||||
});
|
||||
});
|
||||
|
||||
it("supports provider builder mode under secrets.providers.<alias>", async () => {
|
||||
const resolved: OpenClawConfig = {
|
||||
gateway: { port: 18789 },
|
||||
};
|
||||
setSnapshot(resolved, resolved);
|
||||
|
||||
await runConfigCommand([
|
||||
"config",
|
||||
"set",
|
||||
"secrets.providers.vaultfile",
|
||||
"--provider-source",
|
||||
"file",
|
||||
"--provider-path",
|
||||
"/tmp/vault.json",
|
||||
"--provider-mode",
|
||||
"json",
|
||||
]);
|
||||
|
||||
expect(mockWriteConfigFile).toHaveBeenCalledTimes(1);
|
||||
const written = mockWriteConfigFile.mock.calls[0]?.[0];
|
||||
expect(written.secrets?.providers?.vaultfile).toEqual({
|
||||
source: "file",
|
||||
path: "/tmp/vault.json",
|
||||
mode: "json",
|
||||
});
|
||||
});
|
||||
|
||||
it("runs resolvability checks in builder dry-run mode without writing", async () => {
|
||||
const resolved: OpenClawConfig = {
|
||||
gateway: { port: 18789 },
|
||||
secrets: {
|
||||
providers: {
|
||||
default: { source: "env" },
|
||||
},
|
||||
},
|
||||
};
|
||||
setSnapshot(resolved, resolved);
|
||||
|
||||
await runConfigCommand([
|
||||
"config",
|
||||
"set",
|
||||
"channels.discord.token",
|
||||
"--ref-provider",
|
||||
"default",
|
||||
"--ref-source",
|
||||
"env",
|
||||
"--ref-id",
|
||||
"DISCORD_BOT_TOKEN",
|
||||
"--dry-run",
|
||||
]);
|
||||
|
||||
expect(mockWriteConfigFile).not.toHaveBeenCalled();
|
||||
expect(mockResolveSecretRefValue).toHaveBeenCalledTimes(1);
|
||||
expect(mockResolveSecretRefValue).toHaveBeenCalledWith(
|
||||
{
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "DISCORD_BOT_TOKEN",
|
||||
},
|
||||
expect.objectContaining({
|
||||
env: expect.any(Object),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("requires schema validation in JSON dry-run mode", async () => {
|
||||
const resolved: OpenClawConfig = {
|
||||
gateway: { port: 18789 },
|
||||
};
|
||||
setSnapshot(resolved, resolved);
|
||||
|
||||
await expect(
|
||||
runConfigCommand([
|
||||
"config",
|
||||
"set",
|
||||
"gateway.port",
|
||||
'"not-a-number"',
|
||||
"--strict-json",
|
||||
"--dry-run",
|
||||
]),
|
||||
).rejects.toThrow("__exit__:1");
|
||||
|
||||
expect(mockWriteConfigFile).not.toHaveBeenCalled();
|
||||
expect(mockError).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Dry run failed: config schema validation failed."),
|
||||
);
|
||||
});
|
||||
|
||||
it("supports batch mode for refs/providers in dry-run", async () => {
|
||||
const resolved: OpenClawConfig = {
|
||||
gateway: { port: 18789 },
|
||||
secrets: {
|
||||
providers: {
|
||||
default: { source: "env" },
|
||||
},
|
||||
},
|
||||
};
|
||||
setSnapshot(resolved, resolved);
|
||||
|
||||
await runConfigCommand([
|
||||
"config",
|
||||
"set",
|
||||
"--batch-json",
|
||||
'[{"path":"channels.discord.token","ref":{"source":"env","provider":"default","id":"DISCORD_BOT_TOKEN"}},{"path":"secrets.providers.default","provider":{"source":"env"}}]',
|
||||
"--dry-run",
|
||||
]);
|
||||
|
||||
expect(mockWriteConfigFile).not.toHaveBeenCalled();
|
||||
expect(mockResolveSecretRefValue).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("writes sibling SecretRef paths when target uses sibling-ref shape", async () => {
|
||||
const resolved: OpenClawConfig = {
|
||||
gateway: { port: 18789 },
|
||||
channels: {
|
||||
googlechat: {
|
||||
enabled: true,
|
||||
} as never,
|
||||
} as never,
|
||||
};
|
||||
setSnapshot(resolved, resolved);
|
||||
|
||||
await runConfigCommand([
|
||||
"config",
|
||||
"set",
|
||||
"channels.googlechat.serviceAccount",
|
||||
"--ref-provider",
|
||||
"vaultfile",
|
||||
"--ref-source",
|
||||
"file",
|
||||
"--ref-id",
|
||||
"/providers/googlechat/serviceAccount",
|
||||
]);
|
||||
|
||||
expect(mockWriteConfigFile).toHaveBeenCalledTimes(1);
|
||||
const written = mockWriteConfigFile.mock.calls[0]?.[0];
|
||||
expect(written.channels?.googlechat?.serviceAccountRef).toEqual({
|
||||
source: "file",
|
||||
provider: "vaultfile",
|
||||
id: "/providers/googlechat/serviceAccount",
|
||||
});
|
||||
expect(written.channels?.googlechat?.serviceAccount).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects mixing ref-builder and provider-builder flags", async () => {
|
||||
await expect(
|
||||
runConfigCommand([
|
||||
"config",
|
||||
"set",
|
||||
"channels.discord.token",
|
||||
"--ref-provider",
|
||||
"default",
|
||||
"--ref-source",
|
||||
"env",
|
||||
"--ref-id",
|
||||
"DISCORD_BOT_TOKEN",
|
||||
"--provider-source",
|
||||
"env",
|
||||
]),
|
||||
).rejects.toThrow("__exit__:1");
|
||||
|
||||
expect(mockError).toHaveBeenCalledWith(
|
||||
expect.stringContaining("config set mode error: choose exactly one mode"),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects mixing batch mode with builder flags", async () => {
|
||||
await expect(
|
||||
runConfigCommand([
|
||||
"config",
|
||||
"set",
|
||||
"--batch-json",
|
||||
"[]",
|
||||
"--ref-provider",
|
||||
"default",
|
||||
"--ref-source",
|
||||
"env",
|
||||
"--ref-id",
|
||||
"DISCORD_BOT_TOKEN",
|
||||
]),
|
||||
).rejects.toThrow("__exit__:1");
|
||||
|
||||
expect(mockError).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
"config set mode error: batch mode (--batch-json/--batch-file) cannot be combined",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("supports batch-file mode", async () => {
|
||||
const resolved: OpenClawConfig = { gateway: { port: 18789 } };
|
||||
setSnapshot(resolved, resolved);
|
||||
|
||||
const pathname = path.join(
|
||||
os.tmpdir(),
|
||||
`openclaw-config-batch-${Date.now()}-${Math.random().toString(16).slice(2)}.json`,
|
||||
);
|
||||
fs.writeFileSync(pathname, '[{"path":"gateway.auth.mode","value":"token"}]', "utf8");
|
||||
try {
|
||||
await runConfigCommand(["config", "set", "--batch-file", pathname]);
|
||||
} finally {
|
||||
fs.rmSync(pathname, { force: true });
|
||||
}
|
||||
|
||||
expect(mockWriteConfigFile).toHaveBeenCalledTimes(1);
|
||||
const written = mockWriteConfigFile.mock.calls[0]?.[0];
|
||||
expect(written.gateway?.auth).toEqual({ mode: "token" });
|
||||
});
|
||||
|
||||
it("rejects malformed batch-file payloads", async () => {
|
||||
const pathname = path.join(
|
||||
os.tmpdir(),
|
||||
`openclaw-config-batch-invalid-${Date.now()}-${Math.random().toString(16).slice(2)}.json`,
|
||||
);
|
||||
fs.writeFileSync(pathname, '{"path":"gateway.auth.mode","value":"token"}', "utf8");
|
||||
try {
|
||||
await expect(runConfigCommand(["config", "set", "--batch-file", pathname])).rejects.toThrow(
|
||||
"__exit__:1",
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(pathname, { force: true });
|
||||
}
|
||||
|
||||
expect(mockError).toHaveBeenCalledWith(
|
||||
expect.stringContaining("--batch-file must be a JSON array."),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects malformed batch entries with mixed operation keys", async () => {
|
||||
await expect(
|
||||
runConfigCommand([
|
||||
"config",
|
||||
"set",
|
||||
"--batch-json",
|
||||
'[{"path":"channels.discord.token","value":"x","ref":{"source":"env","provider":"default","id":"DISCORD_BOT_TOKEN"}}]',
|
||||
]),
|
||||
).rejects.toThrow("__exit__:1");
|
||||
|
||||
expect(mockError).toHaveBeenCalledWith(
|
||||
expect.stringContaining("must include exactly one of: value, ref, provider"),
|
||||
);
|
||||
});
|
||||
|
||||
it("fails dry-run when a builder-assigned SecretRef is unresolved", async () => {
|
||||
const resolved: OpenClawConfig = {
|
||||
gateway: { port: 18789 },
|
||||
secrets: {
|
||||
providers: {
|
||||
default: { source: "env" },
|
||||
},
|
||||
},
|
||||
};
|
||||
setSnapshot(resolved, resolved);
|
||||
mockResolveSecretRefValue.mockRejectedValueOnce(new Error("missing env var"));
|
||||
|
||||
await expect(
|
||||
runConfigCommand([
|
||||
"config",
|
||||
"set",
|
||||
"channels.discord.token",
|
||||
"--ref-provider",
|
||||
"default",
|
||||
"--ref-source",
|
||||
"env",
|
||||
"--ref-id",
|
||||
"DISCORD_BOT_TOKEN",
|
||||
"--dry-run",
|
||||
]),
|
||||
).rejects.toThrow("__exit__:1");
|
||||
|
||||
expect(mockError).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Dry run failed: 1 SecretRef assignment(s) could not be resolved."),
|
||||
);
|
||||
});
|
||||
|
||||
it("emits structured JSON for --dry-run --json success", async () => {
|
||||
const resolved: OpenClawConfig = {
|
||||
gateway: { port: 18789 },
|
||||
secrets: {
|
||||
providers: {
|
||||
default: { source: "env" },
|
||||
},
|
||||
},
|
||||
};
|
||||
setSnapshot(resolved, resolved);
|
||||
|
||||
await runConfigCommand([
|
||||
"config",
|
||||
"set",
|
||||
"channels.discord.token",
|
||||
"--ref-provider",
|
||||
"default",
|
||||
"--ref-source",
|
||||
"env",
|
||||
"--ref-id",
|
||||
"DISCORD_BOT_TOKEN",
|
||||
"--dry-run",
|
||||
"--json",
|
||||
]);
|
||||
|
||||
const raw = mockLog.mock.calls.at(-1)?.[0];
|
||||
expect(typeof raw).toBe("string");
|
||||
const payload = JSON.parse(String(raw)) as {
|
||||
ok: boolean;
|
||||
checks: { schema: boolean; resolvability: boolean };
|
||||
refsChecked: number;
|
||||
operations: number;
|
||||
};
|
||||
expect(payload.ok).toBe(true);
|
||||
expect(payload.operations).toBe(1);
|
||||
expect(payload.refsChecked).toBe(1);
|
||||
expect(payload.checks).toEqual({
|
||||
schema: false,
|
||||
resolvability: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("emits structured JSON for --dry-run --json failure", async () => {
|
||||
const resolved: OpenClawConfig = {
|
||||
gateway: { port: 18789 },
|
||||
secrets: {
|
||||
providers: {
|
||||
default: { source: "env" },
|
||||
},
|
||||
},
|
||||
};
|
||||
setSnapshot(resolved, resolved);
|
||||
mockResolveSecretRefValue.mockRejectedValueOnce(new Error("missing env var"));
|
||||
|
||||
await expect(
|
||||
runConfigCommand([
|
||||
"config",
|
||||
"set",
|
||||
"channels.discord.token",
|
||||
"--ref-provider",
|
||||
"default",
|
||||
"--ref-source",
|
||||
"env",
|
||||
"--ref-id",
|
||||
"DISCORD_BOT_TOKEN",
|
||||
"--dry-run",
|
||||
"--json",
|
||||
]),
|
||||
).rejects.toThrow("__exit__:1");
|
||||
|
||||
const raw = mockLog.mock.calls.at(-1)?.[0];
|
||||
expect(typeof raw).toBe("string");
|
||||
const payload = JSON.parse(String(raw)) as {
|
||||
ok: boolean;
|
||||
errors?: Array<{ kind: string; message: string; ref?: string }>;
|
||||
};
|
||||
expect(payload.ok).toBe(false);
|
||||
expect(payload.errors?.some((entry) => entry.kind === "resolvability")).toBe(true);
|
||||
expect(
|
||||
payload.errors?.some((entry) => entry.ref?.includes("default:DISCORD_BOT_TOKEN")),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("aggregates schema and resolvability failures in --dry-run --json mode", async () => {
|
||||
const resolved: OpenClawConfig = {
|
||||
gateway: { port: 18789 },
|
||||
secrets: {
|
||||
providers: {
|
||||
default: { source: "env" },
|
||||
},
|
||||
},
|
||||
};
|
||||
setSnapshot(resolved, resolved);
|
||||
mockResolveSecretRefValue.mockRejectedValue(new Error("missing env var"));
|
||||
|
||||
await expect(
|
||||
runConfigCommand([
|
||||
"config",
|
||||
"set",
|
||||
"--batch-json",
|
||||
'[{"path":"gateway.port","value":"not-a-number"},{"path":"channels.discord.token","ref":{"source":"env","provider":"default","id":"DISCORD_BOT_TOKEN"}}]',
|
||||
"--dry-run",
|
||||
"--json",
|
||||
]),
|
||||
).rejects.toThrow("__exit__:1");
|
||||
|
||||
const raw = mockLog.mock.calls.at(-1)?.[0];
|
||||
expect(typeof raw).toBe("string");
|
||||
const payload = JSON.parse(String(raw)) as {
|
||||
ok: boolean;
|
||||
errors?: Array<{ kind: string; message: string; ref?: string }>;
|
||||
};
|
||||
expect(payload.ok).toBe(false);
|
||||
expect(payload.errors?.some((entry) => entry.kind === "schema")).toBe(true);
|
||||
expect(payload.errors?.some((entry) => entry.kind === "resolvability")).toBe(true);
|
||||
expect(
|
||||
payload.errors?.some((entry) => entry.ref?.includes("default:DISCORD_BOT_TOKEN")),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("fails dry-run when provider updates make existing refs unresolvable", async () => {
|
||||
const resolved: OpenClawConfig = {
|
||||
gateway: { port: 18789 },
|
||||
secrets: {
|
||||
providers: {
|
||||
vaultfile: { source: "file", path: "/tmp/secrets.json", mode: "json" },
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
enabled: true,
|
||||
apiKey: {
|
||||
source: "file",
|
||||
provider: "vaultfile",
|
||||
id: "/providers/search/apiKey",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
};
|
||||
setSnapshot(resolved, resolved);
|
||||
mockResolveSecretRefValue.mockImplementationOnce(async () => {
|
||||
throw new Error("provider mismatch");
|
||||
});
|
||||
|
||||
await expect(
|
||||
runConfigCommand([
|
||||
"config",
|
||||
"set",
|
||||
"secrets.providers.vaultfile",
|
||||
"--provider-source",
|
||||
"env",
|
||||
"--dry-run",
|
||||
]),
|
||||
).rejects.toThrow("__exit__:1");
|
||||
|
||||
expect(mockError).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Dry run failed: 1 SecretRef assignment(s) could not be resolved."),
|
||||
);
|
||||
expect(mockError).toHaveBeenCalledWith(expect.stringContaining("provider mismatch"));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user