mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:30:42 +00:00
feat: add config apply patch command
This commit is contained in:
@@ -98,9 +98,18 @@ You will need to create a new application with a bot, add the bot to your server
|
||||
|
||||
```bash
|
||||
export DISCORD_BOT_TOKEN="YOUR_BOT_TOKEN"
|
||||
openclaw config set channels.discord.token --ref-provider default --ref-source env --ref-id DISCORD_BOT_TOKEN --dry-run
|
||||
openclaw config set channels.discord.token --ref-provider default --ref-source env --ref-id DISCORD_BOT_TOKEN
|
||||
openclaw config set channels.discord.enabled true --strict-json
|
||||
cat > discord.patch.json5 <<'JSON5'
|
||||
{
|
||||
channels: {
|
||||
discord: {
|
||||
enabled: true,
|
||||
token: { source: "env", provider: "default", id: "DISCORD_BOT_TOKEN" },
|
||||
},
|
||||
},
|
||||
}
|
||||
JSON5
|
||||
openclaw config apply --file ./discord.patch.json5 --dry-run
|
||||
openclaw config apply --file ./discord.patch.json5
|
||||
openclaw gateway
|
||||
```
|
||||
|
||||
@@ -141,7 +150,7 @@ openclaw gateway
|
||||
DISCORD_BOT_TOKEN=...
|
||||
```
|
||||
|
||||
Plaintext `token` values are supported. SecretRef values are also supported for `channels.discord.token` across env/file/exec providers. See [Secrets Management](/gateway/secrets).
|
||||
For scripted or remote setup, write the same JSON5 block with `openclaw config apply --file ./discord.patch.json5 --dry-run` and then rerun without `--dry-run`. Plaintext `token` values are supported. SecretRef values are also supported for `channels.discord.token` across env/file/exec providers. See [Secrets Management](/gateway/secrets).
|
||||
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
@@ -36,17 +36,25 @@ Production-ready for DMs and channels via Slack app integrations. Default mode i
|
||||
|
||||
<Step title="Configure OpenClaw">
|
||||
|
||||
```json5
|
||||
Recommended SecretRef setup:
|
||||
|
||||
```bash
|
||||
export SLACK_APP_TOKEN=xapp-...
|
||||
export SLACK_BOT_TOKEN=xoxb-...
|
||||
cat > slack.socket.patch.json5 <<'JSON5'
|
||||
{
|
||||
channels: {
|
||||
slack: {
|
||||
enabled: true,
|
||||
mode: "socket",
|
||||
appToken: "xapp-...",
|
||||
botToken: "xoxb-...",
|
||||
appToken: { source: "env", provider: "default", id: "SLACK_APP_TOKEN" },
|
||||
botToken: { source: "env", provider: "default", id: "SLACK_BOT_TOKEN" },
|
||||
},
|
||||
},
|
||||
}
|
||||
JSON5
|
||||
openclaw config apply --file ./slack.socket.patch.json5 --dry-run
|
||||
openclaw config apply --file ./slack.socket.patch.json5
|
||||
```
|
||||
|
||||
Env fallback (default account only):
|
||||
@@ -83,18 +91,26 @@ openclaw gateway
|
||||
|
||||
<Step title="Configure OpenClaw">
|
||||
|
||||
```json5
|
||||
Recommended SecretRef setup:
|
||||
|
||||
```bash
|
||||
export SLACK_BOT_TOKEN=xoxb-...
|
||||
export SLACK_SIGNING_SECRET=...
|
||||
cat > slack.http.patch.json5 <<'JSON5'
|
||||
{
|
||||
channels: {
|
||||
slack: {
|
||||
enabled: true,
|
||||
mode: "http",
|
||||
botToken: "xoxb-...",
|
||||
signingSecret: "your-signing-secret",
|
||||
botToken: { source: "env", provider: "default", id: "SLACK_BOT_TOKEN" },
|
||||
signingSecret: { source: "env", provider: "default", id: "SLACK_SIGNING_SECRET" },
|
||||
webhookPath: "/slack/events",
|
||||
},
|
||||
},
|
||||
}
|
||||
JSON5
|
||||
openclaw config apply --file ./slack.http.patch.json5 --dry-run
|
||||
openclaw config apply --file ./slack.http.patch.json5
|
||||
```
|
||||
|
||||
<Note>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
---
|
||||
summary: "CLI reference for `openclaw config` (get/set/unset/file/schema/validate)"
|
||||
summary: "CLI reference for `openclaw config` (get/set/apply/unset/file/schema/validate)"
|
||||
read_when:
|
||||
- You want to read or edit config non-interactively
|
||||
title: "Config"
|
||||
sidebarTitle: "Config"
|
||||
---
|
||||
|
||||
Config helpers for non-interactive edits in `openclaw.json`: get/set/unset/file/schema/validate values by path and print the active config file. Run without a subcommand to open the configure wizard (same as `openclaw configure`).
|
||||
Config helpers for non-interactive edits in `openclaw.json`: get/set/apply/unset/file/schema/validate values by path and print the active config file. Run without a subcommand to open the configure wizard (same as `openclaw configure`).
|
||||
|
||||
## Root options
|
||||
|
||||
@@ -31,6 +31,7 @@ openclaw config set agents.list[0].tools.exec.node "node-id-or-name"
|
||||
openclaw config set agents.defaults.models '{"openai/gpt-5.4":{}}' --strict-json --merge
|
||||
openclaw config set channels.discord.token --ref-provider default --ref-source env --ref-id DISCORD_BOT_TOKEN
|
||||
openclaw config set secrets.providers.vaultfile --provider-source file --provider-path /etc/openclaw/secrets.json --provider-mode json
|
||||
openclaw config apply --file ./openclaw.patch.json5 --dry-run
|
||||
openclaw config unset plugins.entries.brave.config.webSearch.apiKey
|
||||
openclaw config set channels.discord.token --ref-provider default --ref-source env --ref-id DISCORD_BOT_TOKEN --dry-run
|
||||
openclaw config validate
|
||||
@@ -165,6 +166,62 @@ SecretRef assignments are rejected on unsupported runtime-mutable surfaces (for
|
||||
|
||||
Batch parsing always uses the batch payload (`--batch-json`/`--batch-file`) as the source of truth. `--strict-json` / `--json` do not change batch parsing behavior.
|
||||
|
||||
## `config apply`
|
||||
|
||||
Use `config apply` when you want to paste or pipe a config-shaped patch instead of running many path-based `config set` commands. The input is a JSON5 object. Objects merge recursively, arrays and scalar values replace the target value, and `null` deletes the target path.
|
||||
|
||||
```bash
|
||||
openclaw config apply --file ./openclaw.patch.json5 --dry-run
|
||||
openclaw config apply --file ./openclaw.patch.json5
|
||||
```
|
||||
|
||||
You can also pipe a patch over stdin, which is useful for remote setup scripts:
|
||||
|
||||
```bash
|
||||
ssh openclaw-host 'openclaw config apply --stdin --dry-run' < ./openclaw.patch.json5
|
||||
ssh openclaw-host 'openclaw config apply --stdin' < ./openclaw.patch.json5
|
||||
```
|
||||
|
||||
Example patch:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
slack: {
|
||||
enabled: true,
|
||||
mode: "socket",
|
||||
botToken: { source: "env", provider: "default", id: "SLACK_BOT_TOKEN" },
|
||||
appToken: { source: "env", provider: "default", id: "SLACK_APP_TOKEN" },
|
||||
groupPolicy: "open",
|
||||
requireMention: false,
|
||||
},
|
||||
discord: {
|
||||
enabled: true,
|
||||
token: { source: "env", provider: "default", id: "DISCORD_BOT_TOKEN" },
|
||||
dmPolicy: "disabled",
|
||||
dm: { enabled: false },
|
||||
groupPolicy: "allowlist",
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "openai/gpt-5.5" },
|
||||
models: {
|
||||
"openai/gpt-5.5": { params: { fastMode: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Use `--replace-path <path>` when one object or array must become exactly the provided value instead of being recursively patched:
|
||||
|
||||
```bash
|
||||
openclaw config apply --file ./discord.patch.json5 --replace-path 'channels.discord.guilds["123"].channels'
|
||||
```
|
||||
|
||||
`--dry-run` runs schema and SecretRef resolvability checks without writing. Exec-backed SecretRefs are skipped by default during dry-run; add `--allow-exec` when you intentionally want dry-run to execute provider commands.
|
||||
|
||||
JSON path/value mode remains supported for both SecretRefs and providers:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -113,6 +113,71 @@ with `openclaw config get gateway.auth.token` (or generate one with `openclaw do
|
||||
If you changed the gateway to password auth, use `gateway.auth.password` / `OPENCLAW_GATEWAY_PASSWORD` instead.
|
||||
Approve devices with `openclaw devices list` and `openclaw devices approve <requestId>`. When in doubt, use Shelley from your browser!
|
||||
|
||||
## Remote channel setup
|
||||
|
||||
For remote hosts, prefer one `config apply` patch over many SSH calls to `config set`. Keep real tokens in the VM environment or `~/.openclaw/.env`, and put only SecretRefs in `openclaw.json`.
|
||||
|
||||
On the VM, make the service environment contain the secrets it needs:
|
||||
|
||||
```bash
|
||||
cat >> ~/.openclaw/.env <<'EOF'
|
||||
SLACK_BOT_TOKEN=xoxb-...
|
||||
SLACK_APP_TOKEN=xapp-...
|
||||
DISCORD_BOT_TOKEN=...
|
||||
OPENAI_API_KEY=sk-...
|
||||
EOF
|
||||
```
|
||||
|
||||
From your local machine, create a patch file and pipe it to the VM:
|
||||
|
||||
```json5
|
||||
// openclaw.remote.patch.json5
|
||||
{
|
||||
secrets: {
|
||||
providers: {
|
||||
default: { source: "env" },
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
slack: {
|
||||
enabled: true,
|
||||
mode: "socket",
|
||||
botToken: { source: "env", provider: "default", id: "SLACK_BOT_TOKEN" },
|
||||
appToken: { source: "env", provider: "default", id: "SLACK_APP_TOKEN" },
|
||||
groupPolicy: "open",
|
||||
requireMention: false,
|
||||
},
|
||||
discord: {
|
||||
enabled: true,
|
||||
token: { source: "env", provider: "default", id: "DISCORD_BOT_TOKEN" },
|
||||
dmPolicy: "disabled",
|
||||
dm: { enabled: false },
|
||||
groupPolicy: "allowlist",
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "openai/gpt-5.5" },
|
||||
models: {
|
||||
"openai/gpt-5.5": { params: { fastMode: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
ssh <vm-name>.exe.xyz 'openclaw config apply --stdin --dry-run' < ./openclaw.remote.patch.json5
|
||||
ssh <vm-name>.exe.xyz 'openclaw config apply --stdin' < ./openclaw.remote.patch.json5
|
||||
ssh <vm-name>.exe.xyz 'openclaw gateway restart && openclaw health'
|
||||
```
|
||||
|
||||
Use `--replace-path` when a nested allowlist should become exactly the patch value, for example when replacing a Discord channel allowlist:
|
||||
|
||||
```bash
|
||||
ssh <vm-name>.exe.xyz 'openclaw config apply --stdin --replace-path "channels.discord.guilds[\"123\"].channels"' < ./discord.patch.json5
|
||||
```
|
||||
|
||||
## Remote access
|
||||
|
||||
Remote access is handled by [exe.dev](https://exe.dev)'s authentication. By
|
||||
|
||||
@@ -1384,6 +1384,263 @@ describe("config cli", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("applies a config patch object in one write", async () => {
|
||||
const resolved = {
|
||||
secrets: {
|
||||
providers: {
|
||||
default: { source: "env" },
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"openai/gpt-5.4": { alias: "GPT 5.4" },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
setSnapshot(resolved, resolved);
|
||||
|
||||
const pathname = path.join(
|
||||
os.tmpdir(),
|
||||
`openclaw-config-apply-${Date.now()}-${Math.random().toString(16).slice(2)}.json5`,
|
||||
);
|
||||
fs.writeFileSync(
|
||||
pathname,
|
||||
JSON.stringify({
|
||||
channels: {
|
||||
slack: {
|
||||
enabled: true,
|
||||
mode: "socket",
|
||||
botToken: { source: "env", provider: "default", id: "SLACK_BOT_TOKEN" },
|
||||
appToken: { source: "env", provider: "default", id: "SLACK_APP_TOKEN" },
|
||||
groupPolicy: "open",
|
||||
requireMention: false,
|
||||
},
|
||||
discord: {
|
||||
enabled: true,
|
||||
token: { source: "env", provider: "default", id: "DISCORD_BOT_TOKEN" },
|
||||
groupPolicy: "allowlist",
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "openai/gpt-5.5" },
|
||||
models: {
|
||||
"openai/gpt-5.5": { params: { fastMode: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
try {
|
||||
await runConfigCommand(["config", "apply", "--file", pathname]);
|
||||
} finally {
|
||||
fs.rmSync(pathname, { force: true });
|
||||
}
|
||||
|
||||
expect(mockWriteConfigFile).toHaveBeenCalledTimes(1);
|
||||
const written = mockWriteConfigFile.mock.calls[0]?.[0] as Record<string, unknown>;
|
||||
expect(
|
||||
((written.agents as Record<string, unknown>).defaults as Record<string, unknown>).models,
|
||||
).toEqual({
|
||||
"openai/gpt-5.4": { alias: "GPT 5.4" },
|
||||
"openai/gpt-5.5": { params: { fastMode: true } },
|
||||
});
|
||||
expect(
|
||||
(
|
||||
((written.agents as Record<string, unknown>).defaults as Record<string, unknown>)
|
||||
.model as Record<string, unknown>
|
||||
).primary,
|
||||
).toBe("openai/gpt-5.5");
|
||||
expect(
|
||||
((written.channels as Record<string, unknown>).slack as Record<string, unknown>).botToken,
|
||||
).toEqual({ source: "env", provider: "default", id: "SLACK_BOT_TOKEN" });
|
||||
expect(
|
||||
((written.channels as Record<string, unknown>).discord as Record<string, unknown>).token,
|
||||
).toEqual({ source: "env", provider: "default", id: "DISCORD_BOT_TOKEN" });
|
||||
});
|
||||
|
||||
it("dry-runs config apply and resolves changed SecretRefs", async () => {
|
||||
const resolved = {
|
||||
secrets: {
|
||||
providers: {
|
||||
default: { source: "env" },
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
setSnapshot(resolved, resolved);
|
||||
|
||||
const pathname = path.join(
|
||||
os.tmpdir(),
|
||||
`openclaw-config-apply-dry-${Date.now()}-${Math.random().toString(16).slice(2)}.json5`,
|
||||
);
|
||||
fs.writeFileSync(
|
||||
pathname,
|
||||
JSON.stringify({
|
||||
channels: {
|
||||
discord: {
|
||||
token: { source: "env", provider: "default", id: "DISCORD_BOT_TOKEN" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
try {
|
||||
await runConfigCommand(["config", "apply", "--file", pathname, "--dry-run"]);
|
||||
} finally {
|
||||
fs.rmSync(pathname, { force: true });
|
||||
}
|
||||
|
||||
expect(mockWriteConfigFile).not.toHaveBeenCalled();
|
||||
expect(mockResolveSecretRefValue).toHaveBeenCalledTimes(1);
|
||||
expect(mockResolveSecretRefValue).toHaveBeenCalledWith(
|
||||
{ source: "env", provider: "default", id: "DISCORD_BOT_TOKEN" },
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("dry-runs nested SecretRefs inside config apply replacements", async () => {
|
||||
const resolved = {
|
||||
secrets: {
|
||||
providers: {
|
||||
default: { source: "env" },
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
slack: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
setSnapshot(resolved, resolved);
|
||||
mockResolveSecretRefValue.mockRejectedValue(new Error("missing env var"));
|
||||
|
||||
const pathname = path.join(
|
||||
os.tmpdir(),
|
||||
`openclaw-config-apply-nested-ref-${Date.now()}-${Math.random()
|
||||
.toString(16)
|
||||
.slice(2)}.json5`,
|
||||
);
|
||||
fs.writeFileSync(
|
||||
pathname,
|
||||
JSON.stringify({
|
||||
channels: {
|
||||
slack: {
|
||||
enabled: true,
|
||||
mode: "socket",
|
||||
botToken: { source: "env", provider: "default", id: "SLACK_BOT_TOKEN" },
|
||||
appToken: { source: "env", provider: "default", id: "SLACK_APP_TOKEN" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
try {
|
||||
await expect(
|
||||
runConfigCommand([
|
||||
"config",
|
||||
"apply",
|
||||
"--file",
|
||||
pathname,
|
||||
"--replace-path",
|
||||
"channels.slack",
|
||||
"--dry-run",
|
||||
]),
|
||||
).rejects.toThrow("__exit__:1");
|
||||
} finally {
|
||||
fs.rmSync(pathname, { force: true });
|
||||
}
|
||||
|
||||
expect(mockWriteConfigFile).not.toHaveBeenCalled();
|
||||
expect(mockResolveSecretRefValue).toHaveBeenCalledTimes(2);
|
||||
expect(mockError).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Dry run failed: 2 SecretRef assignment(s) could not be resolved."),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects config apply --json without dry-run", async () => {
|
||||
await expect(runConfigCommand(["config", "apply", "--stdin", "--json"])).rejects.toThrow(
|
||||
"__exit__:1",
|
||||
);
|
||||
expect(mockError).toHaveBeenCalledWith(
|
||||
expect.stringContaining("config apply mode error: --json requires --dry-run."),
|
||||
);
|
||||
expect(mockWriteConfigFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("supports replace-path and null deletes in config apply", async () => {
|
||||
const resolved = {
|
||||
channels: {
|
||||
slack: {
|
||||
appToken: { source: "env", provider: "default", id: "SLACK_APP_TOKEN" },
|
||||
},
|
||||
discord: {
|
||||
guilds: {
|
||||
guild: {
|
||||
channels: {
|
||||
old: { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
setSnapshot(resolved, resolved);
|
||||
|
||||
const pathname = path.join(
|
||||
os.tmpdir(),
|
||||
`openclaw-config-apply-replace-${Date.now()}-${Math.random().toString(16).slice(2)}.json5`,
|
||||
);
|
||||
fs.writeFileSync(
|
||||
pathname,
|
||||
JSON.stringify({
|
||||
channels: {
|
||||
slack: {
|
||||
appToken: null,
|
||||
},
|
||||
discord: {
|
||||
guilds: {
|
||||
guild: {
|
||||
channels: {
|
||||
maintainers: { enabled: true, requireMention: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
try {
|
||||
await runConfigCommand([
|
||||
"config",
|
||||
"apply",
|
||||
"--file",
|
||||
pathname,
|
||||
"--replace-path",
|
||||
"channels.discord.guilds.guild.channels",
|
||||
]);
|
||||
} finally {
|
||||
fs.rmSync(pathname, { force: true });
|
||||
}
|
||||
|
||||
const written = mockWriteConfigFile.mock.calls[0]?.[0] as Record<string, unknown>;
|
||||
const channels = (written.channels as Record<string, unknown>).discord as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(
|
||||
((channels.guilds as Record<string, unknown>).guild as Record<string, unknown>)
|
||||
.channels as Record<string, unknown>,
|
||||
).toEqual({ maintainers: { enabled: true, requireMention: true } });
|
||||
expect((written.channels as Record<string, unknown>).slack).not.toHaveProperty("appToken");
|
||||
expect(mockWriteConfigFile.mock.calls[0]?.[1]).toEqual({
|
||||
unsetPaths: [["channels", "slack", "appToken"]],
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects malformed batch entries with mixed operation keys", async () => {
|
||||
await expect(
|
||||
runConfigCommand([
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import fs from "node:fs";
|
||||
import type { Command } from "commander";
|
||||
import JSON5 from "json5";
|
||||
import { readConfigFileSnapshot, replaceConfigFile } from "../config/config.js";
|
||||
@@ -67,11 +68,27 @@ type ConfigSetOperation = {
|
||||
requestedPath: PathSegment[];
|
||||
setPath: PathSegment[];
|
||||
value: unknown;
|
||||
mutation?: "set" | "merge" | "replace" | "delete";
|
||||
schemaValidated?: boolean;
|
||||
touchedSecretTargetPath?: string;
|
||||
touchedProviderAlias?: string;
|
||||
assignedRef?: SecretRef;
|
||||
};
|
||||
type ConfigApplyOptions = {
|
||||
file?: string | undefined;
|
||||
stdin?: boolean | undefined;
|
||||
dryRun?: boolean | undefined;
|
||||
allowExec?: boolean | undefined;
|
||||
json?: boolean | undefined;
|
||||
replacePath?: string[] | undefined;
|
||||
};
|
||||
type ConfigMutationOptions = {
|
||||
dryRun?: boolean | undefined;
|
||||
allowExec?: boolean | undefined;
|
||||
json?: boolean | undefined;
|
||||
merge?: boolean | undefined;
|
||||
replace?: boolean | undefined;
|
||||
};
|
||||
|
||||
const GATEWAY_AUTH_MODE_PATH: PathSegment[] = ["gateway", "auth", "mode"];
|
||||
const SECRET_PROVIDER_PATH_PREFIX: PathSegment[] = ["secrets", "providers"];
|
||||
@@ -88,6 +105,10 @@ const CONFIG_SET_EXAMPLE_PROVIDER = formatCliCommand(
|
||||
const CONFIG_SET_EXAMPLE_BATCH = formatCliCommand(
|
||||
"openclaw config set --batch-file ./config-set.batch.json --dry-run",
|
||||
);
|
||||
const CONFIG_APPLY_EXAMPLE_FILE = formatCliCommand(
|
||||
"openclaw config apply --file ./openclaw.patch.json5 --dry-run",
|
||||
);
|
||||
const CONFIG_APPLY_EXAMPLE_STDIN = formatCliCommand("openclaw config apply --stdin");
|
||||
const CONFIG_SET_DESCRIPTION = [
|
||||
"Set config values by path (value mode, ref/provider builder mode, or batch JSON mode).",
|
||||
"Examples:",
|
||||
@@ -96,6 +117,13 @@ const CONFIG_SET_DESCRIPTION = [
|
||||
CONFIG_SET_EXAMPLE_PROVIDER,
|
||||
CONFIG_SET_EXAMPLE_BATCH,
|
||||
].join("\n");
|
||||
const CONFIG_APPLY_DESCRIPTION = [
|
||||
"Apply a JSON5 config patch object in one validated write.",
|
||||
"Objects merge recursively, arrays/scalars replace, and null deletes a path.",
|
||||
"Examples:",
|
||||
CONFIG_APPLY_EXAMPLE_FILE,
|
||||
CONFIG_APPLY_EXAMPLE_STDIN,
|
||||
].join("\n");
|
||||
const CONFIG_SET_POLICY_ERROR_MAX_ISSUES = 5;
|
||||
|
||||
class ConfigSetDryRunValidationError extends Error {
|
||||
@@ -880,6 +908,147 @@ function parseBatchOperations(entries: ConfigSetBatchEntry[]): ConfigSetOperatio
|
||||
return operations;
|
||||
}
|
||||
|
||||
function configApplyModeError(message: string): Error {
|
||||
return new Error(`config apply mode error: ${message}`);
|
||||
}
|
||||
|
||||
async function readStdinText(): Promise<string> {
|
||||
let raw = "";
|
||||
process.stdin.setEncoding("utf8");
|
||||
for await (const chunk of process.stdin) {
|
||||
raw += String(chunk);
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
async function readConfigApplyPatch(opts: ConfigApplyOptions): Promise<unknown> {
|
||||
const file = normalizeOptionalString(opts.file);
|
||||
const stdin = Boolean(opts.stdin);
|
||||
if (Boolean(file) === stdin) {
|
||||
throw configApplyModeError("provide exactly one of --file <path> or --stdin.");
|
||||
}
|
||||
const sourceLabel = stdin ? "--stdin" : "--file";
|
||||
const raw = stdin ? await readStdinText() : fs.readFileSync(file as string, "utf8");
|
||||
try {
|
||||
return JSON5.parse(raw);
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to parse ${sourceLabel} as JSON5: ${String(err)}`, { cause: err });
|
||||
}
|
||||
}
|
||||
|
||||
function parseReplacePaths(paths: string[] | undefined): PathSegment[][] {
|
||||
return (paths ?? []).map((path) => parseRequiredPath(path));
|
||||
}
|
||||
|
||||
function matchesAnyPath(path: PathSegment[], candidates: PathSegment[][]): boolean {
|
||||
return candidates.some((candidate) => pathEquals(path, candidate));
|
||||
}
|
||||
|
||||
function buildDeleteOperation(path: PathSegment[]): ConfigSetOperation {
|
||||
return {
|
||||
inputMode: "json",
|
||||
requestedPath: path,
|
||||
setPath: path,
|
||||
value: undefined,
|
||||
mutation: "delete",
|
||||
};
|
||||
}
|
||||
|
||||
function buildApplyValueOperation(params: {
|
||||
path: PathSegment[];
|
||||
value: unknown;
|
||||
mutation?: "set" | "replace";
|
||||
}): ConfigSetOperation {
|
||||
const ref = isPlainRecord(params.value) ? coerceSecretRef(params.value) : null;
|
||||
if (ref) {
|
||||
return {
|
||||
...buildRefAssignmentOperation({
|
||||
requestedPath: params.path,
|
||||
ref: parseSecretRefFromUnknown(params.value, `patch.${toDotPath(params.path)}`),
|
||||
inputMode: "json",
|
||||
}),
|
||||
...(params.mutation ? { mutation: params.mutation } : {}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
...buildValueAssignmentOperation({
|
||||
requestedPath: params.path,
|
||||
value: params.value,
|
||||
inputMode: "json",
|
||||
}),
|
||||
...(params.mutation ? { mutation: params.mutation } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildConfigApplyOperations(params: {
|
||||
patch: unknown;
|
||||
replacePaths: PathSegment[][];
|
||||
}): ConfigSetOperation[] {
|
||||
if (!isPlainRecord(params.patch)) {
|
||||
throw configApplyModeError("input must be a JSON5 object patch.");
|
||||
}
|
||||
const operations: ConfigSetOperation[] = [];
|
||||
const visit = (value: unknown, path: PathSegment[]) => {
|
||||
validatePathSegments(path);
|
||||
if (path.length > 0 && matchesAnyPath(path, params.replacePaths)) {
|
||||
operations.push(
|
||||
value === null
|
||||
? buildDeleteOperation(path)
|
||||
: buildApplyValueOperation({ path, value, mutation: "replace" }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (path.length > 0 && value === null) {
|
||||
operations.push(buildDeleteOperation(path));
|
||||
return;
|
||||
}
|
||||
if (path.length > 0 && isPlainRecord(value) && coerceSecretRef(value)) {
|
||||
operations.push(buildApplyValueOperation({ path, value }));
|
||||
return;
|
||||
}
|
||||
if (isPlainRecord(value)) {
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
visit(child, [...path, key]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (path.length === 0) {
|
||||
throw configApplyModeError("input must contain at least one config key.");
|
||||
}
|
||||
operations.push(buildApplyValueOperation({ path, value }));
|
||||
};
|
||||
|
||||
visit(params.patch, []);
|
||||
if (operations.length === 0) {
|
||||
throw configApplyModeError("input patch did not contain any config updates.");
|
||||
}
|
||||
return operations;
|
||||
}
|
||||
|
||||
function collectSecretRefsFromUnknown(value: unknown): SecretRef[] {
|
||||
const refs: SecretRef[] = [];
|
||||
const visit = (candidate: unknown) => {
|
||||
const ref = coerceSecretRef(candidate);
|
||||
if (ref) {
|
||||
refs.push(ref);
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(candidate)) {
|
||||
for (const entry of candidate) {
|
||||
visit(entry);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (isPlainRecord(candidate)) {
|
||||
for (const entry of Object.values(candidate)) {
|
||||
visit(entry);
|
||||
}
|
||||
}
|
||||
};
|
||||
visit(value);
|
||||
return refs;
|
||||
}
|
||||
|
||||
function modeError(message: string): Error {
|
||||
return new Error(`config set mode error: ${message}`);
|
||||
}
|
||||
@@ -978,6 +1147,9 @@ function collectDryRunRefs(params: {
|
||||
if (operation.assignedRef) {
|
||||
refsByKey.set(secretRefKey(operation.assignedRef), operation.assignedRef);
|
||||
}
|
||||
for (const ref of collectSecretRefsFromUnknown(operation.value)) {
|
||||
refsByKey.set(secretRefKey(ref), ref);
|
||||
}
|
||||
if (operation.touchedSecretTargetPath) {
|
||||
targetPaths.add(operation.touchedSecretTargetPath);
|
||||
}
|
||||
@@ -1170,6 +1342,203 @@ function formatDryRunFailureMessage(params: {
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
async function runConfigOperations(params: {
|
||||
runtime: RuntimeEnv;
|
||||
operations: ConfigSetOperation[];
|
||||
options: ConfigMutationOptions;
|
||||
successMode: "set" | "apply";
|
||||
}) {
|
||||
const { runtime, operations, options } = params;
|
||||
if (
|
||||
operations.some((operation) =>
|
||||
pathStartsWith(operation.requestedPath, PLUGIN_INSTALL_RECORD_PATH_PREFIX),
|
||||
)
|
||||
) {
|
||||
throw new Error(formatPluginInstallConfigSetError());
|
||||
}
|
||||
const snapshot = await loadValidConfig(runtime);
|
||||
// Use snapshot.resolved (config after $include and ${ENV} resolution, but BEFORE runtime defaults)
|
||||
// instead of snapshot.config (runtime-merged with defaults).
|
||||
// This prevents runtime defaults from leaking into the written config file (issue #6070)
|
||||
const next = structuredClone(snapshot.resolved) as Record<string, unknown>;
|
||||
const unsetPaths: PathSegment[][] = [];
|
||||
for (const operation of operations) {
|
||||
if (operation.mutation === "delete") {
|
||||
unsetAtPath(next, operation.setPath);
|
||||
unsetPaths.push(operation.setPath);
|
||||
continue;
|
||||
}
|
||||
if (operation.mutation === "merge" || (options.merge && operation.mutation !== "replace")) {
|
||||
mergeAtPath(next, operation.setPath, operation.value);
|
||||
} else {
|
||||
assertNonDestructiveReplacement({
|
||||
root: next,
|
||||
path: operation.setPath,
|
||||
value: operation.value,
|
||||
allowReplace: options.replace || operation.mutation === "replace",
|
||||
});
|
||||
setAtPath(next, operation.setPath, operation.value);
|
||||
}
|
||||
}
|
||||
const removedGatewayAuthPaths = pruneInactiveGatewayAuthCredentials({
|
||||
root: next,
|
||||
operations,
|
||||
});
|
||||
const nextConfig = next as OpenClawConfig;
|
||||
const policyIssues = collectUnsupportedSecretRefPolicyIssues(nextConfig);
|
||||
const policyIssueLines = formatConfigIssueLines(policyIssues, "", { normalizeRoot: true }).map(
|
||||
(line) => line.trim(),
|
||||
);
|
||||
|
||||
if (options.dryRun) {
|
||||
const hasJsonMode = operations.some((operation) => operation.inputMode === "json");
|
||||
const hasBuilderMode = operations.some((operation) => operation.inputMode === "builder");
|
||||
const requiresFullSchemaValidation = operations.some(
|
||||
(operation) => operation.inputMode === "json" && operation.schemaValidated !== true,
|
||||
);
|
||||
const refs =
|
||||
hasJsonMode || hasBuilderMode
|
||||
? collectDryRunRefs({
|
||||
config: nextConfig,
|
||||
operations,
|
||||
})
|
||||
: [];
|
||||
const selectedDryRunRefs = selectDryRunRefsForResolution({
|
||||
refs,
|
||||
allowExecInDryRun: Boolean(options.allowExec),
|
||||
});
|
||||
const errors: ConfigSetDryRunError[] = [];
|
||||
if ((!hasJsonMode || !requiresFullSchemaValidation) && policyIssueLines.length > 0) {
|
||||
errors.push(
|
||||
...policyIssueLines.map((message) => ({
|
||||
kind: "schema" as const,
|
||||
message,
|
||||
})),
|
||||
);
|
||||
}
|
||||
if (requiresFullSchemaValidation) {
|
||||
errors.push(
|
||||
...collectDryRunSchemaErrors({
|
||||
config: nextConfig,
|
||||
operations,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (hasJsonMode || hasBuilderMode) {
|
||||
errors.push(
|
||||
...collectDryRunStaticErrorsForSkippedExecRefs({
|
||||
refs: selectedDryRunRefs.skippedExecRefs,
|
||||
config: nextConfig,
|
||||
}),
|
||||
);
|
||||
errors.push(
|
||||
...(await collectDryRunResolvabilityErrors({
|
||||
refs: selectedDryRunRefs.refsToResolve,
|
||||
config: nextConfig,
|
||||
})),
|
||||
);
|
||||
}
|
||||
const dedupedErrors = dedupeDryRunErrors(errors);
|
||||
const dryRunResult: ConfigSetDryRunResult = {
|
||||
ok: dedupedErrors.length === 0,
|
||||
operations: operations.length,
|
||||
configPath: shortenHomePath(snapshot.path),
|
||||
inputModes: [...new Set(operations.map((operation) => operation.inputMode))],
|
||||
checks: {
|
||||
schema: requiresFullSchemaValidation || policyIssueLines.length > 0,
|
||||
resolvability: hasJsonMode || hasBuilderMode,
|
||||
resolvabilityComplete:
|
||||
(hasJsonMode || hasBuilderMode) && selectedDryRunRefs.skippedExecRefs.length === 0,
|
||||
},
|
||||
refsChecked: selectedDryRunRefs.refsToResolve.length,
|
||||
skippedExecRefs: selectedDryRunRefs.skippedExecRefs.length,
|
||||
...(dedupedErrors.length > 0 ? { errors: dedupedErrors } : {}),
|
||||
};
|
||||
if (dedupedErrors.length > 0) {
|
||||
if (options.json) {
|
||||
throw new ConfigSetDryRunValidationError(dryRunResult);
|
||||
}
|
||||
throw new Error(
|
||||
formatDryRunFailureMessage({
|
||||
errors: dedupedErrors,
|
||||
skippedExecRefs: selectedDryRunRefs.skippedExecRefs.length,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (options.json) {
|
||||
writeRuntimeJson(runtime, dryRunResult);
|
||||
} else {
|
||||
if (!dryRunResult.checks.schema && !dryRunResult.checks.resolvability) {
|
||||
runtime.log(
|
||||
info(
|
||||
"Dry run note: value mode does not run schema/resolvability checks. Use --strict-json, builder flags, or batch mode to enable validation checks.",
|
||||
),
|
||||
);
|
||||
}
|
||||
if (dryRunResult.skippedExecRefs > 0) {
|
||||
runtime.log(
|
||||
info(
|
||||
`Dry run note: skipped ${dryRunResult.skippedExecRefs} exec SecretRef resolvability check(s). Re-run with --allow-exec to execute exec providers during dry-run.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
runtime.log(
|
||||
info(
|
||||
`Dry run successful: ${operations.length} update(s) validated against ${shortenHomePath(snapshot.path)}.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (policyIssueLines.length > 0) {
|
||||
throw new Error(formatUnsupportedSecretRefPolicyFailureMessage(policyIssueLines));
|
||||
}
|
||||
|
||||
await replaceConfigFile({
|
||||
nextConfig: next,
|
||||
...(snapshot.hash !== undefined ? { baseHash: snapshot.hash } : {}),
|
||||
...(unsetPaths.length > 0 ? { writeOptions: { unsetPaths } } : {}),
|
||||
});
|
||||
if (removedGatewayAuthPaths.length > 0) {
|
||||
runtime.log(
|
||||
info(
|
||||
`Removed inactive ${removedGatewayAuthPaths.join(", ")} for gateway.auth.mode=${nextConfig.gateway?.auth?.mode ?? "<unset>"}.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (params.successMode === "set" && operations.length === 1) {
|
||||
const operation = operations[0];
|
||||
const action = operation?.mutation === "delete" ? "Removed" : "Updated";
|
||||
runtime.log(
|
||||
info(`${action} ${toDotPath(operation?.requestedPath ?? [])}. Restart the gateway to apply.`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (params.successMode === "set") {
|
||||
runtime.log(info(`Updated ${operations.length} config paths. Restart the gateway to apply.`));
|
||||
return;
|
||||
}
|
||||
runtime.log(info(`Applied ${operations.length} config update(s). Restart the gateway to apply.`));
|
||||
}
|
||||
|
||||
function handleConfigMutationError(params: {
|
||||
err: unknown;
|
||||
runtime: RuntimeEnv;
|
||||
options: ConfigMutationOptions;
|
||||
}) {
|
||||
if (
|
||||
params.options.dryRun &&
|
||||
params.options.json &&
|
||||
params.err instanceof ConfigSetDryRunValidationError
|
||||
) {
|
||||
writeRuntimeJson(params.runtime, params.err.result);
|
||||
params.runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
params.runtime.error(danger(String(params.err)));
|
||||
params.runtime.exit(1);
|
||||
}
|
||||
|
||||
export async function runConfigSet(opts: {
|
||||
path?: string;
|
||||
value?: string;
|
||||
@@ -1208,177 +1577,46 @@ export async function runConfigSet(opts: {
|
||||
value: opts.value,
|
||||
opts: opts.cliOptions,
|
||||
});
|
||||
if (
|
||||
operations.some((operation) =>
|
||||
pathStartsWith(operation.requestedPath, PLUGIN_INSTALL_RECORD_PATH_PREFIX),
|
||||
)
|
||||
) {
|
||||
throw new Error(formatPluginInstallConfigSetError());
|
||||
}
|
||||
const snapshot = await loadValidConfig(runtime);
|
||||
// Use snapshot.resolved (config after $include and ${ENV} resolution, but BEFORE runtime defaults)
|
||||
// instead of snapshot.config (runtime-merged with defaults).
|
||||
// This prevents runtime defaults from leaking into the written config file (issue #6070)
|
||||
const next = structuredClone(snapshot.resolved) as Record<string, unknown>;
|
||||
for (const operation of operations) {
|
||||
if (opts.cliOptions.merge) {
|
||||
mergeAtPath(next, operation.setPath, operation.value);
|
||||
} else {
|
||||
assertNonDestructiveReplacement({
|
||||
root: next,
|
||||
path: operation.setPath,
|
||||
value: operation.value,
|
||||
allowReplace: opts.cliOptions.replace,
|
||||
});
|
||||
setAtPath(next, operation.setPath, operation.value);
|
||||
}
|
||||
}
|
||||
const removedGatewayAuthPaths = pruneInactiveGatewayAuthCredentials({
|
||||
root: next,
|
||||
await runConfigOperations({
|
||||
runtime,
|
||||
operations,
|
||||
options: opts.cliOptions,
|
||||
successMode: "set",
|
||||
});
|
||||
const nextConfig = next as OpenClawConfig;
|
||||
const policyIssues = collectUnsupportedSecretRefPolicyIssues(nextConfig);
|
||||
const policyIssueLines = formatConfigIssueLines(policyIssues, "", { normalizeRoot: true }).map(
|
||||
(line) => line.trim(),
|
||||
);
|
||||
|
||||
if (opts.cliOptions.dryRun) {
|
||||
const hasJsonMode = operations.some((operation) => operation.inputMode === "json");
|
||||
const hasBuilderMode = operations.some((operation) => operation.inputMode === "builder");
|
||||
const requiresFullSchemaValidation = operations.some(
|
||||
(operation) => operation.inputMode === "json" && operation.schemaValidated !== true,
|
||||
);
|
||||
const refs =
|
||||
hasJsonMode || hasBuilderMode
|
||||
? collectDryRunRefs({
|
||||
config: nextConfig,
|
||||
operations,
|
||||
})
|
||||
: [];
|
||||
const selectedDryRunRefs = selectDryRunRefsForResolution({
|
||||
refs,
|
||||
allowExecInDryRun: Boolean(opts.cliOptions.allowExec),
|
||||
});
|
||||
const errors: ConfigSetDryRunError[] = [];
|
||||
if ((!hasJsonMode || !requiresFullSchemaValidation) && policyIssueLines.length > 0) {
|
||||
errors.push(
|
||||
...policyIssueLines.map((message) => ({
|
||||
kind: "schema" as const,
|
||||
message,
|
||||
})),
|
||||
);
|
||||
}
|
||||
if (requiresFullSchemaValidation) {
|
||||
errors.push(
|
||||
...collectDryRunSchemaErrors({
|
||||
config: nextConfig,
|
||||
operations,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (hasJsonMode || hasBuilderMode) {
|
||||
errors.push(
|
||||
...collectDryRunStaticErrorsForSkippedExecRefs({
|
||||
refs: selectedDryRunRefs.skippedExecRefs,
|
||||
config: nextConfig,
|
||||
}),
|
||||
);
|
||||
errors.push(
|
||||
...(await collectDryRunResolvabilityErrors({
|
||||
refs: selectedDryRunRefs.refsToResolve,
|
||||
config: nextConfig,
|
||||
})),
|
||||
);
|
||||
}
|
||||
const dedupedErrors = dedupeDryRunErrors(errors);
|
||||
const dryRunResult: ConfigSetDryRunResult = {
|
||||
ok: dedupedErrors.length === 0,
|
||||
operations: operations.length,
|
||||
configPath: shortenHomePath(snapshot.path),
|
||||
inputModes: [...new Set(operations.map((operation) => operation.inputMode))],
|
||||
checks: {
|
||||
schema: requiresFullSchemaValidation || policyIssueLines.length > 0,
|
||||
resolvability: hasJsonMode || hasBuilderMode,
|
||||
resolvabilityComplete:
|
||||
(hasJsonMode || hasBuilderMode) && selectedDryRunRefs.skippedExecRefs.length === 0,
|
||||
},
|
||||
refsChecked: selectedDryRunRefs.refsToResolve.length,
|
||||
skippedExecRefs: selectedDryRunRefs.skippedExecRefs.length,
|
||||
...(dedupedErrors.length > 0 ? { errors: dedupedErrors } : {}),
|
||||
};
|
||||
if (dedupedErrors.length > 0) {
|
||||
if (opts.cliOptions.json) {
|
||||
throw new ConfigSetDryRunValidationError(dryRunResult);
|
||||
}
|
||||
throw new Error(
|
||||
formatDryRunFailureMessage({
|
||||
errors: dedupedErrors,
|
||||
skippedExecRefs: selectedDryRunRefs.skippedExecRefs.length,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (opts.cliOptions.json) {
|
||||
writeRuntimeJson(runtime, dryRunResult);
|
||||
} else {
|
||||
if (!dryRunResult.checks.schema && !dryRunResult.checks.resolvability) {
|
||||
runtime.log(
|
||||
info(
|
||||
"Dry run note: value mode does not run schema/resolvability checks. Use --strict-json, builder flags, or batch mode to enable validation checks.",
|
||||
),
|
||||
);
|
||||
}
|
||||
if (dryRunResult.skippedExecRefs > 0) {
|
||||
runtime.log(
|
||||
info(
|
||||
`Dry run note: skipped ${dryRunResult.skippedExecRefs} exec SecretRef resolvability check(s). Re-run with --allow-exec to execute exec providers during dry-run.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
runtime.log(
|
||||
info(
|
||||
`Dry run successful: ${operations.length} update(s) validated against ${shortenHomePath(snapshot.path)}.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (policyIssueLines.length > 0) {
|
||||
throw new Error(formatUnsupportedSecretRefPolicyFailureMessage(policyIssueLines));
|
||||
}
|
||||
|
||||
await replaceConfigFile({
|
||||
nextConfig: next,
|
||||
...(snapshot.hash !== undefined ? { baseHash: snapshot.hash } : {}),
|
||||
});
|
||||
if (removedGatewayAuthPaths.length > 0) {
|
||||
runtime.log(
|
||||
info(
|
||||
`Removed inactive ${removedGatewayAuthPaths.join(", ")} for gateway.auth.mode=${nextConfig.gateway?.auth?.mode ?? "<unset>"}.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (operations.length === 1) {
|
||||
runtime.log(
|
||||
info(
|
||||
`Updated ${toDotPath(operations[0]?.requestedPath ?? [])}. Restart the gateway to apply.`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
runtime.log(info(`Updated ${operations.length} config paths. Restart the gateway to apply.`));
|
||||
} catch (err) {
|
||||
if (
|
||||
opts.cliOptions.dryRun &&
|
||||
opts.cliOptions.json &&
|
||||
err instanceof ConfigSetDryRunValidationError
|
||||
) {
|
||||
writeRuntimeJson(runtime, err.result);
|
||||
runtime.exit(1);
|
||||
return;
|
||||
handleConfigMutationError({ err, runtime, options: opts.cliOptions });
|
||||
}
|
||||
}
|
||||
|
||||
export async function runConfigApply(opts: {
|
||||
cliOptions: ConfigApplyOptions;
|
||||
runtime?: RuntimeEnv;
|
||||
}) {
|
||||
const runtime = opts.runtime ?? defaultRuntime;
|
||||
try {
|
||||
if (opts.cliOptions.allowExec && !opts.cliOptions.dryRun) {
|
||||
throw configApplyModeError("--allow-exec requires --dry-run.");
|
||||
}
|
||||
runtime.error(danger(String(err)));
|
||||
runtime.exit(1);
|
||||
if (opts.cliOptions.json && !opts.cliOptions.dryRun) {
|
||||
throw configApplyModeError("--json requires --dry-run.");
|
||||
}
|
||||
const patch = await readConfigApplyPatch(opts.cliOptions);
|
||||
const operations = buildConfigApplyOperations({
|
||||
patch,
|
||||
replacePaths: parseReplacePaths(opts.cliOptions.replacePath),
|
||||
});
|
||||
await runConfigOperations({
|
||||
runtime,
|
||||
operations,
|
||||
options: {
|
||||
dryRun: opts.cliOptions.dryRun,
|
||||
allowExec: opts.cliOptions.allowExec,
|
||||
json: opts.cliOptions.json,
|
||||
},
|
||||
successMode: "apply",
|
||||
});
|
||||
} catch (err) {
|
||||
handleConfigMutationError({ err, runtime, options: opts.cliOptions });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1530,7 +1768,7 @@ export function registerConfigCli(program: Command) {
|
||||
const cmd = program
|
||||
.command("config")
|
||||
.description(
|
||||
"Non-interactive config helpers (get/set/unset/file/schema/validate). Run without subcommand for guided setup.",
|
||||
"Non-interactive config helpers (get/set/apply/unset/file/schema/validate). Run without subcommand for guided setup.",
|
||||
)
|
||||
.addHelpText(
|
||||
"after",
|
||||
@@ -1641,6 +1879,32 @@ export function registerConfigCli(program: Command) {
|
||||
});
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("apply")
|
||||
.description(CONFIG_APPLY_DESCRIPTION)
|
||||
.option("--file <path>", "Read a JSON5 config patch object from file")
|
||||
.option("--stdin", "Read a JSON5 config patch object from stdin", false)
|
||||
.option(
|
||||
"--dry-run",
|
||||
"Validate changes without writing openclaw.json (checks schema and SecretRef resolvability; exec SecretRefs are skipped unless --allow-exec is set)",
|
||||
false,
|
||||
)
|
||||
.option(
|
||||
"--allow-exec",
|
||||
"Dry-run only: allow exec SecretRef resolvability checks (may execute provider commands)",
|
||||
false,
|
||||
)
|
||||
.option("--json", "Output dry-run result as JSON", false)
|
||||
.option(
|
||||
"--replace-path <path>",
|
||||
"Replace the object or array at this dot/bracket path instead of recursively applying it (repeatable)",
|
||||
(value: string, previous: string[]) => [...previous, value],
|
||||
[] as string[],
|
||||
)
|
||||
.action(async (opts: ConfigApplyOptions) => {
|
||||
await runConfigApply({ cliOptions: opts });
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("unset")
|
||||
.description("Remove a config value by dot path")
|
||||
|
||||
Reference in New Issue
Block a user