mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-20 06:20:55 +00:00
* CLI: expand config set ref/provider builder and dry-run * Docs: revert README Discord token example
1215 lines
39 KiB
TypeScript
1215 lines
39 KiB
TypeScript
import type { Command } from "commander";
|
||
import JSON5 from "json5";
|
||
import { OLLAMA_DEFAULT_BASE_URL } from "../agents/ollama-defaults.js";
|
||
import type { OpenClawConfig } from "../config/config.js";
|
||
import { readConfigFileSnapshot, writeConfigFile } from "../config/config.js";
|
||
import { formatConfigIssueLines, normalizeConfigIssues } from "../config/issue-format.js";
|
||
import { CONFIG_PATH } from "../config/paths.js";
|
||
import { isBlockedObjectKey } from "../config/prototype-keys.js";
|
||
import { redactConfigObject } from "../config/redact-snapshot.js";
|
||
import {
|
||
coerceSecretRef,
|
||
isValidEnvSecretRefId,
|
||
resolveSecretInputRef,
|
||
type SecretProviderConfig,
|
||
type SecretRef,
|
||
type SecretRefSource,
|
||
} from "../config/types.secrets.js";
|
||
import { validateConfigObjectRaw } from "../config/validation.js";
|
||
import { SecretProviderSchema } from "../config/zod-schema.core.js";
|
||
import { danger, info, success } from "../globals.js";
|
||
import type { RuntimeEnv } from "../runtime.js";
|
||
import { defaultRuntime } from "../runtime.js";
|
||
import {
|
||
formatExecSecretRefIdValidationMessage,
|
||
isValidFileSecretRefId,
|
||
isValidSecretProviderAlias,
|
||
secretRefKey,
|
||
validateExecSecretRefId,
|
||
} from "../secrets/ref-contract.js";
|
||
import { resolveSecretRefValue } from "../secrets/resolve.js";
|
||
import {
|
||
discoverConfigSecretTargets,
|
||
resolveConfigSecretTargetByPath,
|
||
} from "../secrets/target-registry.js";
|
||
import { formatDocsLink } from "../terminal/links.js";
|
||
import { theme } from "../terminal/theme.js";
|
||
import { shortenHomePath } from "../utils.js";
|
||
import { formatCliCommand } from "./command-format.js";
|
||
import type {
|
||
ConfigSetDryRunError,
|
||
ConfigSetDryRunInputMode,
|
||
ConfigSetDryRunResult,
|
||
} from "./config-set-dryrun.js";
|
||
import {
|
||
hasProviderBuilderOptions,
|
||
hasRefBuilderOptions,
|
||
parseBatchSource,
|
||
type ConfigSetBatchEntry,
|
||
type ConfigSetOptions,
|
||
} from "./config-set-input.js";
|
||
import { resolveConfigSetMode } from "./config-set-parser.js";
|
||
|
||
type PathSegment = string;
|
||
type ConfigSetParseOpts = {
|
||
strictJson?: boolean;
|
||
};
|
||
type ConfigSetInputMode = ConfigSetDryRunInputMode;
|
||
type ConfigSetOperation = {
|
||
inputMode: ConfigSetInputMode;
|
||
requestedPath: PathSegment[];
|
||
setPath: PathSegment[];
|
||
value: unknown;
|
||
touchedSecretTargetPath?: string;
|
||
touchedProviderAlias?: string;
|
||
assignedRef?: SecretRef;
|
||
};
|
||
|
||
const OLLAMA_API_KEY_PATH: PathSegment[] = ["models", "providers", "ollama", "apiKey"];
|
||
const OLLAMA_PROVIDER_PATH: PathSegment[] = ["models", "providers", "ollama"];
|
||
const SECRET_PROVIDER_PATH_PREFIX: PathSegment[] = ["secrets", "providers"];
|
||
const CONFIG_SET_EXAMPLE_VALUE = formatCliCommand(
|
||
"openclaw config set gateway.port 19001 --strict-json",
|
||
);
|
||
const CONFIG_SET_EXAMPLE_REF = formatCliCommand(
|
||
"openclaw config set channels.discord.token --ref-provider default --ref-source env --ref-id DISCORD_BOT_TOKEN",
|
||
);
|
||
const CONFIG_SET_EXAMPLE_PROVIDER = formatCliCommand(
|
||
"openclaw config set secrets.providers.vault --provider-source file --provider-path /etc/openclaw/secrets.json --provider-mode json",
|
||
);
|
||
const CONFIG_SET_EXAMPLE_BATCH = formatCliCommand(
|
||
"openclaw config set --batch-file ./config-set.batch.json --dry-run",
|
||
);
|
||
const CONFIG_SET_DESCRIPTION = [
|
||
"Set config values by path (value mode, ref/provider builder mode, or batch JSON mode).",
|
||
"Examples:",
|
||
CONFIG_SET_EXAMPLE_VALUE,
|
||
CONFIG_SET_EXAMPLE_REF,
|
||
CONFIG_SET_EXAMPLE_PROVIDER,
|
||
CONFIG_SET_EXAMPLE_BATCH,
|
||
].join("\n");
|
||
|
||
class ConfigSetDryRunValidationError extends Error {
|
||
constructor(readonly result: ConfigSetDryRunResult) {
|
||
super("config set dry-run validation failed");
|
||
this.name = "ConfigSetDryRunValidationError";
|
||
}
|
||
}
|
||
|
||
function isIndexSegment(raw: string): boolean {
|
||
return /^[0-9]+$/.test(raw);
|
||
}
|
||
|
||
function parsePath(raw: string): PathSegment[] {
|
||
const trimmed = raw.trim();
|
||
if (!trimmed) {
|
||
return [];
|
||
}
|
||
const parts: string[] = [];
|
||
let current = "";
|
||
let i = 0;
|
||
while (i < trimmed.length) {
|
||
const ch = trimmed[i];
|
||
if (ch === "\\") {
|
||
const next = trimmed[i + 1];
|
||
if (next) {
|
||
current += next;
|
||
}
|
||
i += 2;
|
||
continue;
|
||
}
|
||
if (ch === ".") {
|
||
if (current) {
|
||
parts.push(current);
|
||
}
|
||
current = "";
|
||
i += 1;
|
||
continue;
|
||
}
|
||
if (ch === "[") {
|
||
if (current) {
|
||
parts.push(current);
|
||
}
|
||
current = "";
|
||
const close = trimmed.indexOf("]", i);
|
||
if (close === -1) {
|
||
throw new Error(`Invalid path (missing "]"): ${raw}`);
|
||
}
|
||
const inside = trimmed.slice(i + 1, close).trim();
|
||
if (!inside) {
|
||
throw new Error(`Invalid path (empty "[]"): ${raw}`);
|
||
}
|
||
parts.push(inside);
|
||
i = close + 1;
|
||
continue;
|
||
}
|
||
current += ch;
|
||
i += 1;
|
||
}
|
||
if (current) {
|
||
parts.push(current);
|
||
}
|
||
return parts.map((part) => part.trim()).filter(Boolean);
|
||
}
|
||
|
||
function parseValue(raw: string, opts: ConfigSetParseOpts): unknown {
|
||
const trimmed = raw.trim();
|
||
if (opts.strictJson) {
|
||
try {
|
||
return JSON5.parse(trimmed);
|
||
} catch (err) {
|
||
throw new Error(`Failed to parse JSON5 value: ${String(err)}`, { cause: err });
|
||
}
|
||
}
|
||
|
||
try {
|
||
return JSON5.parse(trimmed);
|
||
} catch {
|
||
return raw;
|
||
}
|
||
}
|
||
|
||
function hasOwnPathKey(value: Record<string, unknown>, key: string): boolean {
|
||
return Object.prototype.hasOwnProperty.call(value, key);
|
||
}
|
||
|
||
function formatDoctorHint(message: string): string {
|
||
return `Run \`${formatCliCommand("openclaw doctor")}\` ${message}`;
|
||
}
|
||
|
||
function validatePathSegments(path: PathSegment[]): void {
|
||
for (const segment of path) {
|
||
if (!isIndexSegment(segment) && isBlockedObjectKey(segment)) {
|
||
throw new Error(`Invalid path segment: ${segment}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
function getAtPath(root: unknown, path: PathSegment[]): { found: boolean; value?: unknown } {
|
||
let current: unknown = root;
|
||
for (const segment of path) {
|
||
if (!current || typeof current !== "object") {
|
||
return { found: false };
|
||
}
|
||
if (Array.isArray(current)) {
|
||
if (!isIndexSegment(segment)) {
|
||
return { found: false };
|
||
}
|
||
const index = Number.parseInt(segment, 10);
|
||
if (!Number.isFinite(index) || index < 0 || index >= current.length) {
|
||
return { found: false };
|
||
}
|
||
current = current[index];
|
||
continue;
|
||
}
|
||
const record = current as Record<string, unknown>;
|
||
if (!hasOwnPathKey(record, segment)) {
|
||
return { found: false };
|
||
}
|
||
current = record[segment];
|
||
}
|
||
return { found: true, value: current };
|
||
}
|
||
|
||
function setAtPath(root: Record<string, unknown>, path: PathSegment[], value: unknown): void {
|
||
let current: unknown = root;
|
||
for (let i = 0; i < path.length - 1; i += 1) {
|
||
const segment = path[i];
|
||
const next = path[i + 1];
|
||
const nextIsIndex = Boolean(next && isIndexSegment(next));
|
||
if (Array.isArray(current)) {
|
||
if (!isIndexSegment(segment)) {
|
||
throw new Error(`Expected numeric index for array segment "${segment}"`);
|
||
}
|
||
const index = Number.parseInt(segment, 10);
|
||
const existing = current[index];
|
||
if (!existing || typeof existing !== "object") {
|
||
current[index] = nextIsIndex ? [] : {};
|
||
}
|
||
current = current[index];
|
||
continue;
|
||
}
|
||
if (!current || typeof current !== "object") {
|
||
throw new Error(`Cannot traverse into "${segment}" (not an object)`);
|
||
}
|
||
const record = current as Record<string, unknown>;
|
||
const existing = hasOwnPathKey(record, segment) ? record[segment] : undefined;
|
||
if (!existing || typeof existing !== "object") {
|
||
record[segment] = nextIsIndex ? [] : {};
|
||
}
|
||
current = record[segment];
|
||
}
|
||
|
||
const last = path[path.length - 1];
|
||
if (Array.isArray(current)) {
|
||
if (!isIndexSegment(last)) {
|
||
throw new Error(`Expected numeric index for array segment "${last}"`);
|
||
}
|
||
const index = Number.parseInt(last, 10);
|
||
current[index] = value;
|
||
return;
|
||
}
|
||
if (!current || typeof current !== "object") {
|
||
throw new Error(`Cannot set "${last}" (parent is not an object)`);
|
||
}
|
||
(current as Record<string, unknown>)[last] = value;
|
||
}
|
||
|
||
function unsetAtPath(root: Record<string, unknown>, path: PathSegment[]): boolean {
|
||
let current: unknown = root;
|
||
for (let i = 0; i < path.length - 1; i += 1) {
|
||
const segment = path[i];
|
||
if (!current || typeof current !== "object") {
|
||
return false;
|
||
}
|
||
if (Array.isArray(current)) {
|
||
if (!isIndexSegment(segment)) {
|
||
return false;
|
||
}
|
||
const index = Number.parseInt(segment, 10);
|
||
if (!Number.isFinite(index) || index < 0 || index >= current.length) {
|
||
return false;
|
||
}
|
||
current = current[index];
|
||
continue;
|
||
}
|
||
const record = current as Record<string, unknown>;
|
||
if (!hasOwnPathKey(record, segment)) {
|
||
return false;
|
||
}
|
||
current = record[segment];
|
||
}
|
||
|
||
const last = path[path.length - 1];
|
||
if (Array.isArray(current)) {
|
||
if (!isIndexSegment(last)) {
|
||
return false;
|
||
}
|
||
const index = Number.parseInt(last, 10);
|
||
if (!Number.isFinite(index) || index < 0 || index >= current.length) {
|
||
return false;
|
||
}
|
||
current.splice(index, 1);
|
||
return true;
|
||
}
|
||
if (!current || typeof current !== "object") {
|
||
return false;
|
||
}
|
||
const record = current as Record<string, unknown>;
|
||
if (!hasOwnPathKey(record, last)) {
|
||
return false;
|
||
}
|
||
delete record[last];
|
||
return true;
|
||
}
|
||
|
||
async function loadValidConfig(runtime: RuntimeEnv = defaultRuntime) {
|
||
const snapshot = await readConfigFileSnapshot();
|
||
if (snapshot.valid) {
|
||
return snapshot;
|
||
}
|
||
runtime.error(`Config invalid at ${shortenHomePath(snapshot.path)}.`);
|
||
for (const line of formatConfigIssueLines(snapshot.issues, "-", { normalizeRoot: true })) {
|
||
runtime.error(line);
|
||
}
|
||
runtime.error(formatDoctorHint("to repair, then retry."));
|
||
runtime.exit(1);
|
||
return snapshot;
|
||
}
|
||
|
||
function parseRequiredPath(path: string): PathSegment[] {
|
||
const parsedPath = parsePath(path);
|
||
if (parsedPath.length === 0) {
|
||
throw new Error("Path is empty.");
|
||
}
|
||
validatePathSegments(parsedPath);
|
||
return parsedPath;
|
||
}
|
||
|
||
function pathEquals(path: PathSegment[], expected: PathSegment[]): boolean {
|
||
return (
|
||
path.length === expected.length && path.every((segment, index) => segment === expected[index])
|
||
);
|
||
}
|
||
|
||
function ensureValidOllamaProviderForApiKeySet(
|
||
root: Record<string, unknown>,
|
||
path: PathSegment[],
|
||
): void {
|
||
if (!pathEquals(path, OLLAMA_API_KEY_PATH)) {
|
||
return;
|
||
}
|
||
const existing = getAtPath(root, OLLAMA_PROVIDER_PATH);
|
||
if (existing.found) {
|
||
return;
|
||
}
|
||
setAtPath(root, OLLAMA_PROVIDER_PATH, {
|
||
baseUrl: OLLAMA_DEFAULT_BASE_URL,
|
||
api: "ollama",
|
||
models: [],
|
||
});
|
||
}
|
||
|
||
function toDotPath(path: PathSegment[]): string {
|
||
return path.join(".");
|
||
}
|
||
|
||
function parseSecretRefSource(raw: string, label: string): SecretRefSource {
|
||
const source = raw.trim();
|
||
if (source === "env" || source === "file" || source === "exec") {
|
||
return source;
|
||
}
|
||
throw new Error(`${label} must be one of: env, file, exec.`);
|
||
}
|
||
|
||
function parseSecretRefBuilder(params: {
|
||
provider: string;
|
||
source: string;
|
||
id: string;
|
||
fieldPrefix: string;
|
||
}): SecretRef {
|
||
const provider = params.provider.trim();
|
||
if (!provider) {
|
||
throw new Error(`${params.fieldPrefix}.provider is required.`);
|
||
}
|
||
if (!isValidSecretProviderAlias(provider)) {
|
||
throw new Error(
|
||
`${params.fieldPrefix}.provider must match /^[a-z][a-z0-9_-]{0,63}$/ (example: "default").`,
|
||
);
|
||
}
|
||
|
||
const source = parseSecretRefSource(params.source, `${params.fieldPrefix}.source`);
|
||
const id = params.id.trim();
|
||
if (!id) {
|
||
throw new Error(`${params.fieldPrefix}.id is required.`);
|
||
}
|
||
if (source === "env" && !isValidEnvSecretRefId(id)) {
|
||
throw new Error(`${params.fieldPrefix}.id must match /^[A-Z][A-Z0-9_]{0,127}$/ for env refs.`);
|
||
}
|
||
if (source === "file" && !isValidFileSecretRefId(id)) {
|
||
throw new Error(
|
||
`${params.fieldPrefix}.id must be an absolute JSON pointer (or "value" for singleValue mode).`,
|
||
);
|
||
}
|
||
if (source === "exec") {
|
||
const validated = validateExecSecretRefId(id);
|
||
if (!validated.ok) {
|
||
throw new Error(formatExecSecretRefIdValidationMessage());
|
||
}
|
||
}
|
||
return { source, provider, id };
|
||
}
|
||
|
||
function parseOptionalPositiveInteger(raw: string | undefined, flag: string): number | undefined {
|
||
if (raw === undefined) {
|
||
return undefined;
|
||
}
|
||
const trimmed = raw.trim();
|
||
if (!trimmed) {
|
||
throw new Error(`${flag} must not be empty.`);
|
||
}
|
||
const parsed = Number(trimmed);
|
||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||
throw new Error(`${flag} must be a positive integer.`);
|
||
}
|
||
return parsed;
|
||
}
|
||
|
||
function parseProviderEnvEntries(
|
||
entries: string[] | undefined,
|
||
): Record<string, string> | undefined {
|
||
if (!entries || entries.length === 0) {
|
||
return undefined;
|
||
}
|
||
const env: Record<string, string> = {};
|
||
for (const entry of entries) {
|
||
const separator = entry.indexOf("=");
|
||
if (separator <= 0) {
|
||
throw new Error(`--provider-env expects KEY=VALUE entries (received: "${entry}").`);
|
||
}
|
||
const key = entry.slice(0, separator).trim();
|
||
if (!key) {
|
||
throw new Error(`--provider-env key must not be empty (received: "${entry}").`);
|
||
}
|
||
env[key] = entry.slice(separator + 1);
|
||
}
|
||
return Object.keys(env).length > 0 ? env : undefined;
|
||
}
|
||
|
||
function parseProviderAliasPath(path: PathSegment[]): string {
|
||
const expectedPrefixMatches =
|
||
path.length === 3 &&
|
||
path[0] === SECRET_PROVIDER_PATH_PREFIX[0] &&
|
||
path[1] === SECRET_PROVIDER_PATH_PREFIX[1];
|
||
if (!expectedPrefixMatches) {
|
||
throw new Error(
|
||
'Provider builder mode requires path "secrets.providers.<alias>" (example: secrets.providers.vault).',
|
||
);
|
||
}
|
||
const alias = path[2] ?? "";
|
||
if (!isValidSecretProviderAlias(alias)) {
|
||
throw new Error(
|
||
`Provider alias "${alias}" must match /^[a-z][a-z0-9_-]{0,63}$/ (example: "default").`,
|
||
);
|
||
}
|
||
return alias;
|
||
}
|
||
|
||
function buildProviderFromBuilder(opts: ConfigSetOptions): SecretProviderConfig {
|
||
const sourceRaw = opts.providerSource?.trim();
|
||
if (!sourceRaw) {
|
||
throw new Error("--provider-source is required in provider builder mode.");
|
||
}
|
||
const source = parseSecretRefSource(sourceRaw, "--provider-source");
|
||
const timeoutMs = parseOptionalPositiveInteger(opts.providerTimeoutMs, "--provider-timeout-ms");
|
||
const maxBytes = parseOptionalPositiveInteger(opts.providerMaxBytes, "--provider-max-bytes");
|
||
const noOutputTimeoutMs = parseOptionalPositiveInteger(
|
||
opts.providerNoOutputTimeoutMs,
|
||
"--provider-no-output-timeout-ms",
|
||
);
|
||
const maxOutputBytes = parseOptionalPositiveInteger(
|
||
opts.providerMaxOutputBytes,
|
||
"--provider-max-output-bytes",
|
||
);
|
||
const providerEnv = parseProviderEnvEntries(opts.providerEnv);
|
||
|
||
let provider: SecretProviderConfig;
|
||
if (source === "env") {
|
||
const allowlist = (opts.providerAllowlist ?? []).map((entry) => entry.trim()).filter(Boolean);
|
||
for (const envName of allowlist) {
|
||
if (!isValidEnvSecretRefId(envName)) {
|
||
throw new Error(
|
||
`--provider-allowlist entry "${envName}" must match /^[A-Z][A-Z0-9_]{0,127}$/.`,
|
||
);
|
||
}
|
||
}
|
||
provider = {
|
||
source: "env",
|
||
...(allowlist.length > 0 ? { allowlist } : {}),
|
||
};
|
||
} else if (source === "file") {
|
||
const filePath = opts.providerPath?.trim();
|
||
if (!filePath) {
|
||
throw new Error("--provider-path is required when --provider-source file is used.");
|
||
}
|
||
const modeRaw = opts.providerMode?.trim();
|
||
if (modeRaw && modeRaw !== "singleValue" && modeRaw !== "json") {
|
||
throw new Error("--provider-mode must be one of: singleValue, json.");
|
||
}
|
||
const mode = modeRaw === "singleValue" || modeRaw === "json" ? modeRaw : undefined;
|
||
provider = {
|
||
source: "file",
|
||
path: filePath,
|
||
...(mode ? { mode } : {}),
|
||
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
||
...(maxBytes !== undefined ? { maxBytes } : {}),
|
||
};
|
||
} else {
|
||
const command = opts.providerCommand?.trim();
|
||
if (!command) {
|
||
throw new Error("--provider-command is required when --provider-source exec is used.");
|
||
}
|
||
provider = {
|
||
source: "exec",
|
||
command,
|
||
...(opts.providerArg && opts.providerArg.length > 0
|
||
? { args: opts.providerArg.map((entry) => entry.trim()) }
|
||
: {}),
|
||
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
||
...(noOutputTimeoutMs !== undefined ? { noOutputTimeoutMs } : {}),
|
||
...(maxOutputBytes !== undefined ? { maxOutputBytes } : {}),
|
||
...(opts.providerJsonOnly ? { jsonOnly: true } : {}),
|
||
...(providerEnv ? { env: providerEnv } : {}),
|
||
...(opts.providerPassEnv && opts.providerPassEnv.length > 0
|
||
? { passEnv: opts.providerPassEnv.map((entry) => entry.trim()).filter(Boolean) }
|
||
: {}),
|
||
...(opts.providerTrustedDir && opts.providerTrustedDir.length > 0
|
||
? { trustedDirs: opts.providerTrustedDir.map((entry) => entry.trim()).filter(Boolean) }
|
||
: {}),
|
||
...(opts.providerAllowInsecurePath ? { allowInsecurePath: true } : {}),
|
||
...(opts.providerAllowSymlinkCommand ? { allowSymlinkCommand: true } : {}),
|
||
};
|
||
}
|
||
|
||
const validated = SecretProviderSchema.safeParse(provider);
|
||
if (!validated.success) {
|
||
const issue = validated.error.issues[0];
|
||
const issuePath = issue?.path?.join(".") ?? "<provider>";
|
||
const issueMessage = issue?.message ?? "Invalid provider config.";
|
||
throw new Error(`Provider builder config invalid at ${issuePath}: ${issueMessage}`);
|
||
}
|
||
return validated.data;
|
||
}
|
||
|
||
function parseSecretRefFromUnknown(value: unknown, label: string): SecretRef {
|
||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||
throw new Error(`${label} must be an object with source/provider/id.`);
|
||
}
|
||
const candidate = value as Record<string, unknown>;
|
||
if (
|
||
typeof candidate.provider !== "string" ||
|
||
typeof candidate.source !== "string" ||
|
||
typeof candidate.id !== "string"
|
||
) {
|
||
throw new Error(`${label} must include string fields: source, provider, id.`);
|
||
}
|
||
return parseSecretRefBuilder({
|
||
provider: candidate.provider,
|
||
source: candidate.source,
|
||
id: candidate.id,
|
||
fieldPrefix: label,
|
||
});
|
||
}
|
||
|
||
function buildRefAssignmentOperation(params: {
|
||
requestedPath: PathSegment[];
|
||
ref: SecretRef;
|
||
inputMode: ConfigSetInputMode;
|
||
}): ConfigSetOperation {
|
||
const resolved = resolveConfigSecretTargetByPath(params.requestedPath);
|
||
if (resolved?.entry.secretShape === "sibling_ref" && resolved.refPathSegments) {
|
||
return {
|
||
inputMode: params.inputMode,
|
||
requestedPath: params.requestedPath,
|
||
setPath: resolved.refPathSegments,
|
||
value: params.ref,
|
||
touchedSecretTargetPath: toDotPath(resolved.pathSegments),
|
||
assignedRef: params.ref,
|
||
...(resolved.providerId ? { touchedProviderAlias: resolved.providerId } : {}),
|
||
};
|
||
}
|
||
return {
|
||
inputMode: params.inputMode,
|
||
requestedPath: params.requestedPath,
|
||
setPath: params.requestedPath,
|
||
value: params.ref,
|
||
touchedSecretTargetPath: resolved
|
||
? toDotPath(resolved.pathSegments)
|
||
: toDotPath(params.requestedPath),
|
||
assignedRef: params.ref,
|
||
...(resolved?.providerId ? { touchedProviderAlias: resolved.providerId } : {}),
|
||
};
|
||
}
|
||
|
||
function parseProviderAliasFromTargetPath(path: PathSegment[]): string | null {
|
||
if (
|
||
path.length === 3 &&
|
||
path[0] === SECRET_PROVIDER_PATH_PREFIX[0] &&
|
||
path[1] === SECRET_PROVIDER_PATH_PREFIX[1]
|
||
) {
|
||
return path[2] ?? null;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function buildValueAssignmentOperation(params: {
|
||
requestedPath: PathSegment[];
|
||
value: unknown;
|
||
inputMode: ConfigSetInputMode;
|
||
}): ConfigSetOperation {
|
||
const resolved = resolveConfigSecretTargetByPath(params.requestedPath);
|
||
const providerAlias = parseProviderAliasFromTargetPath(params.requestedPath);
|
||
const coercedRef = coerceSecretRef(params.value);
|
||
return {
|
||
inputMode: params.inputMode,
|
||
requestedPath: params.requestedPath,
|
||
setPath: params.requestedPath,
|
||
value: params.value,
|
||
...(resolved ? { touchedSecretTargetPath: toDotPath(resolved.pathSegments) } : {}),
|
||
...(providerAlias ? { touchedProviderAlias: providerAlias } : {}),
|
||
...(coercedRef ? { assignedRef: coercedRef } : {}),
|
||
};
|
||
}
|
||
|
||
function parseBatchOperations(entries: ConfigSetBatchEntry[]): ConfigSetOperation[] {
|
||
const operations: ConfigSetOperation[] = [];
|
||
for (const [index, entry] of entries.entries()) {
|
||
const path = parseRequiredPath(entry.path);
|
||
if (entry.ref !== undefined) {
|
||
const ref = parseSecretRefFromUnknown(entry.ref, `batch[${index}].ref`);
|
||
operations.push(
|
||
buildRefAssignmentOperation({
|
||
requestedPath: path,
|
||
ref,
|
||
inputMode: "json",
|
||
}),
|
||
);
|
||
continue;
|
||
}
|
||
if (entry.provider !== undefined) {
|
||
const alias = parseProviderAliasPath(path);
|
||
const validated = SecretProviderSchema.safeParse(entry.provider);
|
||
if (!validated.success) {
|
||
const issue = validated.error.issues[0];
|
||
const issuePath = issue?.path?.join(".") ?? "<provider>";
|
||
throw new Error(
|
||
`batch[${index}].provider invalid at ${issuePath}: ${issue?.message ?? ""}`,
|
||
);
|
||
}
|
||
operations.push({
|
||
inputMode: "json",
|
||
requestedPath: path,
|
||
setPath: path,
|
||
value: validated.data,
|
||
touchedProviderAlias: alias,
|
||
});
|
||
continue;
|
||
}
|
||
operations.push(
|
||
buildValueAssignmentOperation({
|
||
requestedPath: path,
|
||
value: entry.value,
|
||
inputMode: "json",
|
||
}),
|
||
);
|
||
}
|
||
return operations;
|
||
}
|
||
|
||
function modeError(message: string): Error {
|
||
return new Error(`config set mode error: ${message}`);
|
||
}
|
||
|
||
function buildSingleSetOperations(params: {
|
||
path?: string;
|
||
value?: string;
|
||
opts: ConfigSetOptions;
|
||
}): ConfigSetOperation[] {
|
||
const pathProvided = typeof params.path === "string" && params.path.trim().length > 0;
|
||
const parsedPath = pathProvided ? parseRequiredPath(params.path as string) : null;
|
||
const strictJson = Boolean(params.opts.strictJson || params.opts.json);
|
||
const modeResolution = resolveConfigSetMode({
|
||
hasBatchMode: false,
|
||
hasRefBuilderOptions: hasRefBuilderOptions(params.opts),
|
||
hasProviderBuilderOptions: hasProviderBuilderOptions(params.opts),
|
||
strictJson,
|
||
});
|
||
if (!modeResolution.ok) {
|
||
throw modeError(modeResolution.error);
|
||
}
|
||
|
||
if (modeResolution.mode === "ref_builder") {
|
||
if (!pathProvided || !parsedPath) {
|
||
throw modeError("ref builder mode requires <path>.");
|
||
}
|
||
if (params.value !== undefined) {
|
||
throw modeError("ref builder mode does not accept <value>.");
|
||
}
|
||
if (!params.opts.refProvider || !params.opts.refSource || !params.opts.refId) {
|
||
throw modeError(
|
||
"ref builder mode requires --ref-provider <alias>, --ref-source <env|file|exec>, and --ref-id <id>.",
|
||
);
|
||
}
|
||
const ref = parseSecretRefBuilder({
|
||
provider: params.opts.refProvider,
|
||
source: params.opts.refSource,
|
||
id: params.opts.refId,
|
||
fieldPrefix: "ref",
|
||
});
|
||
return [
|
||
buildRefAssignmentOperation({
|
||
requestedPath: parsedPath,
|
||
ref,
|
||
inputMode: "builder",
|
||
}),
|
||
];
|
||
}
|
||
|
||
if (modeResolution.mode === "provider_builder") {
|
||
if (!pathProvided || !parsedPath) {
|
||
throw modeError("provider builder mode requires <path>.");
|
||
}
|
||
if (params.value !== undefined) {
|
||
throw modeError("provider builder mode does not accept <value>.");
|
||
}
|
||
const alias = parseProviderAliasPath(parsedPath);
|
||
const provider = buildProviderFromBuilder(params.opts);
|
||
return [
|
||
{
|
||
inputMode: "builder",
|
||
requestedPath: parsedPath,
|
||
setPath: parsedPath,
|
||
value: provider,
|
||
touchedProviderAlias: alias,
|
||
},
|
||
];
|
||
}
|
||
|
||
if (!pathProvided || !parsedPath) {
|
||
throw modeError("value/json mode requires <path> when batch mode is not used.");
|
||
}
|
||
if (params.value === undefined) {
|
||
throw modeError("value/json mode requires <value>.");
|
||
}
|
||
const parsedValue = parseValue(params.value, { strictJson });
|
||
return [
|
||
buildValueAssignmentOperation({
|
||
requestedPath: parsedPath,
|
||
value: parsedValue,
|
||
inputMode: modeResolution.mode === "json" ? "json" : "value",
|
||
}),
|
||
];
|
||
}
|
||
|
||
function collectDryRunRefs(params: {
|
||
config: OpenClawConfig;
|
||
operations: ConfigSetOperation[];
|
||
}): SecretRef[] {
|
||
const refsByKey = new Map<string, SecretRef>();
|
||
const targetPaths = new Set<string>();
|
||
const providerAliases = new Set<string>();
|
||
|
||
for (const operation of params.operations) {
|
||
if (operation.assignedRef) {
|
||
refsByKey.set(secretRefKey(operation.assignedRef), operation.assignedRef);
|
||
}
|
||
if (operation.touchedSecretTargetPath) {
|
||
targetPaths.add(operation.touchedSecretTargetPath);
|
||
}
|
||
if (operation.touchedProviderAlias) {
|
||
providerAliases.add(operation.touchedProviderAlias);
|
||
}
|
||
}
|
||
|
||
if (targetPaths.size === 0 && providerAliases.size === 0) {
|
||
return [...refsByKey.values()];
|
||
}
|
||
|
||
const defaults = params.config.secrets?.defaults;
|
||
for (const target of discoverConfigSecretTargets(params.config)) {
|
||
const { ref } = resolveSecretInputRef({
|
||
value: target.value,
|
||
refValue: target.refValue,
|
||
defaults,
|
||
});
|
||
if (!ref) {
|
||
continue;
|
||
}
|
||
if (targetPaths.has(target.path) || providerAliases.has(ref.provider)) {
|
||
refsByKey.set(secretRefKey(ref), ref);
|
||
}
|
||
}
|
||
return [...refsByKey.values()];
|
||
}
|
||
|
||
async function collectDryRunResolvabilityErrors(params: {
|
||
refs: SecretRef[];
|
||
config: OpenClawConfig;
|
||
}): Promise<ConfigSetDryRunError[]> {
|
||
const failures: ConfigSetDryRunError[] = [];
|
||
for (const ref of params.refs) {
|
||
try {
|
||
await resolveSecretRefValue(ref, {
|
||
config: params.config,
|
||
env: process.env,
|
||
});
|
||
} catch (err) {
|
||
failures.push({
|
||
kind: "resolvability",
|
||
message: String(err),
|
||
ref: `${ref.source}:${ref.provider}:${ref.id}`,
|
||
});
|
||
}
|
||
}
|
||
return failures;
|
||
}
|
||
|
||
function collectDryRunSchemaErrors(config: OpenClawConfig): ConfigSetDryRunError[] {
|
||
const validated = validateConfigObjectRaw(config);
|
||
if (validated.ok) {
|
||
return [];
|
||
}
|
||
return formatConfigIssueLines(validated.issues, "-", { normalizeRoot: true }).map((message) => ({
|
||
kind: "schema",
|
||
message,
|
||
}));
|
||
}
|
||
|
||
function formatDryRunFailureMessage(errors: ConfigSetDryRunError[]): string {
|
||
const schemaErrors = errors.filter((error) => error.kind === "schema");
|
||
const resolveErrors = errors.filter((error) => error.kind === "resolvability");
|
||
const lines: string[] = [];
|
||
if (schemaErrors.length > 0) {
|
||
lines.push("Dry run failed: config schema validation failed.");
|
||
lines.push(...schemaErrors.map((error) => `- ${error.message}`));
|
||
}
|
||
if (resolveErrors.length > 0) {
|
||
lines.push(
|
||
`Dry run failed: ${resolveErrors.length} SecretRef assignment(s) could not be resolved.`,
|
||
);
|
||
lines.push(
|
||
...resolveErrors
|
||
.slice(0, 5)
|
||
.map((error) => `- ${error.ref ?? "<unknown-ref>"} -> ${error.message}`),
|
||
);
|
||
if (resolveErrors.length > 5) {
|
||
lines.push(`- ... ${resolveErrors.length - 5} more`);
|
||
}
|
||
}
|
||
return lines.join("\n");
|
||
}
|
||
|
||
export async function runConfigSet(opts: {
|
||
path?: string;
|
||
value?: string;
|
||
cliOptions: ConfigSetOptions;
|
||
runtime?: RuntimeEnv;
|
||
}) {
|
||
const runtime = opts.runtime ?? defaultRuntime;
|
||
try {
|
||
const hasBatchMode = Boolean(
|
||
(opts.cliOptions.batchJson && opts.cliOptions.batchJson.trim().length > 0) ||
|
||
(opts.cliOptions.batchFile && opts.cliOptions.batchFile.trim().length > 0),
|
||
);
|
||
const modeResolution = resolveConfigSetMode({
|
||
hasBatchMode,
|
||
hasRefBuilderOptions: hasRefBuilderOptions(opts.cliOptions),
|
||
hasProviderBuilderOptions: hasProviderBuilderOptions(opts.cliOptions),
|
||
strictJson: Boolean(opts.cliOptions.strictJson || opts.cliOptions.json),
|
||
});
|
||
if (!modeResolution.ok) {
|
||
throw modeError(modeResolution.error);
|
||
}
|
||
|
||
const batchEntries = parseBatchSource(opts.cliOptions);
|
||
if (batchEntries) {
|
||
if (opts.path !== undefined || opts.value !== undefined) {
|
||
throw modeError("batch mode does not accept <path> or <value> arguments.");
|
||
}
|
||
}
|
||
const operations = batchEntries
|
||
? parseBatchOperations(batchEntries)
|
||
: buildSingleSetOperations({
|
||
path: opts.path,
|
||
value: opts.value,
|
||
opts: opts.cliOptions,
|
||
});
|
||
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) {
|
||
ensureValidOllamaProviderForApiKeySet(next, operation.setPath);
|
||
setAtPath(next, operation.setPath, operation.value);
|
||
}
|
||
const nextConfig = next as OpenClawConfig;
|
||
|
||
if (opts.cliOptions.dryRun) {
|
||
const hasJsonMode = operations.some((operation) => operation.inputMode === "json");
|
||
const hasBuilderMode = operations.some((operation) => operation.inputMode === "builder");
|
||
const refs =
|
||
hasJsonMode || hasBuilderMode
|
||
? collectDryRunRefs({
|
||
config: nextConfig,
|
||
operations,
|
||
})
|
||
: [];
|
||
const errors: ConfigSetDryRunError[] = [];
|
||
if (hasJsonMode) {
|
||
errors.push(...collectDryRunSchemaErrors(nextConfig));
|
||
}
|
||
if (hasJsonMode || hasBuilderMode) {
|
||
errors.push(
|
||
...(await collectDryRunResolvabilityErrors({
|
||
refs,
|
||
config: nextConfig,
|
||
})),
|
||
);
|
||
}
|
||
const dryRunResult: ConfigSetDryRunResult = {
|
||
ok: errors.length === 0,
|
||
operations: operations.length,
|
||
configPath: shortenHomePath(snapshot.path),
|
||
inputModes: [...new Set(operations.map((operation) => operation.inputMode))],
|
||
checks: {
|
||
schema: hasJsonMode,
|
||
resolvability: hasJsonMode || hasBuilderMode,
|
||
},
|
||
refsChecked: refs.length,
|
||
...(errors.length > 0 ? { errors } : {}),
|
||
};
|
||
if (errors.length > 0) {
|
||
if (opts.cliOptions.json) {
|
||
throw new ConfigSetDryRunValidationError(dryRunResult);
|
||
}
|
||
throw new Error(formatDryRunFailureMessage(errors));
|
||
}
|
||
if (opts.cliOptions.json) {
|
||
runtime.log(JSON.stringify(dryRunResult, null, 2));
|
||
} else {
|
||
runtime.log(
|
||
info(
|
||
`Dry run successful: ${operations.length} update(s) validated against ${shortenHomePath(snapshot.path)}.`,
|
||
),
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
|
||
await writeConfigFile(next);
|
||
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
|
||
) {
|
||
runtime.log(JSON.stringify(err.result, null, 2));
|
||
runtime.exit(1);
|
||
return;
|
||
}
|
||
runtime.error(danger(String(err)));
|
||
runtime.exit(1);
|
||
}
|
||
}
|
||
|
||
export async function runConfigGet(opts: { path: string; json?: boolean; runtime?: RuntimeEnv }) {
|
||
const runtime = opts.runtime ?? defaultRuntime;
|
||
try {
|
||
const parsedPath = parseRequiredPath(opts.path);
|
||
const snapshot = await loadValidConfig(runtime);
|
||
const redacted = redactConfigObject(snapshot.config);
|
||
const res = getAtPath(redacted, parsedPath);
|
||
if (!res.found) {
|
||
runtime.error(danger(`Config path not found: ${opts.path}`));
|
||
runtime.exit(1);
|
||
return;
|
||
}
|
||
if (opts.json) {
|
||
runtime.log(JSON.stringify(res.value ?? null, null, 2));
|
||
return;
|
||
}
|
||
if (
|
||
typeof res.value === "string" ||
|
||
typeof res.value === "number" ||
|
||
typeof res.value === "boolean"
|
||
) {
|
||
runtime.log(String(res.value));
|
||
return;
|
||
}
|
||
runtime.log(JSON.stringify(res.value ?? null, null, 2));
|
||
} catch (err) {
|
||
runtime.error(danger(String(err)));
|
||
runtime.exit(1);
|
||
}
|
||
}
|
||
|
||
export async function runConfigUnset(opts: { path: string; runtime?: RuntimeEnv }) {
|
||
const runtime = opts.runtime ?? defaultRuntime;
|
||
try {
|
||
const parsedPath = parseRequiredPath(opts.path);
|
||
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 removed = unsetAtPath(next, parsedPath);
|
||
if (!removed) {
|
||
runtime.error(danger(`Config path not found: ${opts.path}`));
|
||
runtime.exit(1);
|
||
return;
|
||
}
|
||
await writeConfigFile(next, { unsetPaths: [parsedPath] });
|
||
runtime.log(info(`Removed ${opts.path}. Restart the gateway to apply.`));
|
||
} catch (err) {
|
||
runtime.error(danger(String(err)));
|
||
runtime.exit(1);
|
||
}
|
||
}
|
||
|
||
export async function runConfigFile(opts: { runtime?: RuntimeEnv }) {
|
||
const runtime = opts.runtime ?? defaultRuntime;
|
||
try {
|
||
const snapshot = await readConfigFileSnapshot();
|
||
runtime.log(shortenHomePath(snapshot.path));
|
||
} catch (err) {
|
||
runtime.error(danger(String(err)));
|
||
runtime.exit(1);
|
||
}
|
||
}
|
||
|
||
export async function runConfigValidate(opts: { json?: boolean; runtime?: RuntimeEnv } = {}) {
|
||
const runtime = opts.runtime ?? defaultRuntime;
|
||
let outputPath = CONFIG_PATH ?? "openclaw.json";
|
||
|
||
try {
|
||
const snapshot = await readConfigFileSnapshot();
|
||
outputPath = snapshot.path;
|
||
const shortPath = shortenHomePath(outputPath);
|
||
|
||
if (!snapshot.exists) {
|
||
if (opts.json) {
|
||
runtime.log(JSON.stringify({ valid: false, path: outputPath, error: "file not found" }));
|
||
} else {
|
||
runtime.error(danger(`Config file not found: ${shortPath}`));
|
||
}
|
||
runtime.exit(1);
|
||
return;
|
||
}
|
||
|
||
if (!snapshot.valid) {
|
||
const issues = normalizeConfigIssues(snapshot.issues);
|
||
|
||
if (opts.json) {
|
||
runtime.log(JSON.stringify({ valid: false, path: outputPath, issues }, null, 2));
|
||
} else {
|
||
runtime.error(danger(`Config invalid at ${shortPath}:`));
|
||
for (const line of formatConfigIssueLines(issues, danger("×"), { normalizeRoot: true })) {
|
||
runtime.error(` ${line}`);
|
||
}
|
||
runtime.error("");
|
||
runtime.error(formatDoctorHint("to repair, or fix the keys above manually."));
|
||
}
|
||
runtime.exit(1);
|
||
return;
|
||
}
|
||
|
||
if (opts.json) {
|
||
runtime.log(JSON.stringify({ valid: true, path: outputPath }));
|
||
} else {
|
||
runtime.log(success(`Config valid: ${shortPath}`));
|
||
}
|
||
} catch (err) {
|
||
if (opts.json) {
|
||
runtime.log(JSON.stringify({ valid: false, path: outputPath, error: String(err) }));
|
||
} else {
|
||
runtime.error(danger(`Config validation error: ${String(err)}`));
|
||
}
|
||
runtime.exit(1);
|
||
}
|
||
}
|
||
|
||
export function registerConfigCli(program: Command) {
|
||
const cmd = program
|
||
.command("config")
|
||
.description(
|
||
"Non-interactive config helpers (get/set/unset/file/validate). Run without subcommand for guided setup.",
|
||
)
|
||
.addHelpText(
|
||
"after",
|
||
() =>
|
||
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/config", "docs.openclaw.ai/cli/config")}\n`,
|
||
)
|
||
.option(
|
||
"--section <section>",
|
||
"Configuration sections for guided setup (repeatable). Use with no subcommand.",
|
||
(value: string, previous: string[]) => [...previous, value],
|
||
[] as string[],
|
||
)
|
||
.action(async (opts) => {
|
||
const { configureCommandFromSectionsArg } = await import("../commands/configure.js");
|
||
await configureCommandFromSectionsArg(opts.section, defaultRuntime);
|
||
});
|
||
|
||
cmd
|
||
.command("get")
|
||
.description("Get a config value by dot path")
|
||
.argument("<path>", "Config path (dot or bracket notation)")
|
||
.option("--json", "Output JSON", false)
|
||
.action(async (path: string, opts) => {
|
||
await runConfigGet({ path, json: Boolean(opts.json) });
|
||
});
|
||
|
||
cmd
|
||
.command("set")
|
||
.description(CONFIG_SET_DESCRIPTION)
|
||
.argument("[path]", "Config path (dot or bracket notation)")
|
||
.argument("[value]", "Value (JSON5 or raw string)")
|
||
.option("--strict-json", "Strict JSON5 parsing (error instead of raw string fallback)", false)
|
||
.option("--json", "Legacy alias for --strict-json", false)
|
||
.option("--dry-run", "Validate changes without writing openclaw.json", false)
|
||
.option("--ref-provider <alias>", "SecretRef builder: provider alias")
|
||
.option("--ref-source <source>", "SecretRef builder: source (env|file|exec)")
|
||
.option("--ref-id <id>", "SecretRef builder: ref id")
|
||
.option("--provider-source <source>", "Provider builder: source (env|file|exec)")
|
||
.option(
|
||
"--provider-allowlist <envVar>",
|
||
"Provider builder (env): allowlist entry (repeatable)",
|
||
(value: string, previous: string[]) => [...previous, value],
|
||
[] as string[],
|
||
)
|
||
.option("--provider-path <path>", "Provider builder (file): path")
|
||
.option("--provider-mode <mode>", "Provider builder (file): mode (singleValue|json)")
|
||
.option("--provider-timeout-ms <ms>", "Provider builder (file|exec): timeout ms")
|
||
.option("--provider-max-bytes <bytes>", "Provider builder (file): max bytes")
|
||
.option("--provider-command <path>", "Provider builder (exec): absolute command path")
|
||
.option(
|
||
"--provider-arg <arg>",
|
||
"Provider builder (exec): command arg (repeatable)",
|
||
(value: string, previous: string[]) => [...previous, value],
|
||
[] as string[],
|
||
)
|
||
.option("--provider-no-output-timeout-ms <ms>", "Provider builder (exec): no-output timeout ms")
|
||
.option("--provider-max-output-bytes <bytes>", "Provider builder (exec): max output bytes")
|
||
.option("--provider-json-only", "Provider builder (exec): require JSON output", false)
|
||
.option(
|
||
"--provider-env <key=value>",
|
||
"Provider builder (exec): env assignment (repeatable)",
|
||
(value: string, previous: string[]) => [...previous, value],
|
||
[] as string[],
|
||
)
|
||
.option(
|
||
"--provider-pass-env <envVar>",
|
||
"Provider builder (exec): pass host env var (repeatable)",
|
||
(value: string, previous: string[]) => [...previous, value],
|
||
[] as string[],
|
||
)
|
||
.option(
|
||
"--provider-trusted-dir <path>",
|
||
"Provider builder (exec): trusted directory (repeatable)",
|
||
(value: string, previous: string[]) => [...previous, value],
|
||
[] as string[],
|
||
)
|
||
.option(
|
||
"--provider-allow-insecure-path",
|
||
"Provider builder (exec): bypass strict path permission checks",
|
||
false,
|
||
)
|
||
.option(
|
||
"--provider-allow-symlink-command",
|
||
"Provider builder (exec): allow command symlink path",
|
||
false,
|
||
)
|
||
.option("--batch-json <json>", "Batch mode: JSON array of set operations")
|
||
.option("--batch-file <path>", "Batch mode: read JSON array of set operations from file")
|
||
.action(async (path: string | undefined, value: string | undefined, opts: ConfigSetOptions) => {
|
||
await runConfigSet({
|
||
path,
|
||
value,
|
||
cliOptions: opts,
|
||
});
|
||
});
|
||
|
||
cmd
|
||
.command("unset")
|
||
.description("Remove a config value by dot path")
|
||
.argument("<path>", "Config path (dot or bracket notation)")
|
||
.action(async (path: string) => {
|
||
await runConfigUnset({ path });
|
||
});
|
||
|
||
cmd
|
||
.command("file")
|
||
.description("Print the active config file path")
|
||
.action(async () => {
|
||
await runConfigFile({});
|
||
});
|
||
|
||
cmd
|
||
.command("validate")
|
||
.description("Validate the current config against the schema without starting the gateway")
|
||
.option("--json", "Output validation result as JSON", false)
|
||
.action(async (opts) => {
|
||
await runConfigValidate({ json: Boolean(opts.json) });
|
||
});
|
||
}
|