feat: add /config chat config updates
This commit is contained in:
@@ -138,6 +138,7 @@
|
||||
- Onboarding: clarify WhatsApp owner number prompt and label pairing phone number.
|
||||
- Auto-reply: normalize routed replies to drop NO_REPLY and apply response prefixes.
|
||||
- Commands: add /debug for runtime config overrides (memory-only).
|
||||
- Commands: add /config to persist config changes from chat (owner-only).
|
||||
- Daemon runtime: remove Bun from selection options.
|
||||
- CLI: restore hidden `gateway-daemon` alias for legacy launchd configs.
|
||||
- Onboarding/Configure: add OpenAI API key flow that stores in shared `~/.clawdbot/.env` for launchd; simplify Anthropic token prompt order.
|
||||
|
||||
@@ -153,6 +153,7 @@ Chat messages support `/...` commands (text and native). See [/tools/slash-comma
|
||||
|
||||
Highlights:
|
||||
- `/status` for quick diagnostics.
|
||||
- `/config` for persisted config changes.
|
||||
- `/debug` for runtime-only config overrides (memory, not disk).
|
||||
|
||||
## Setup + onboarding
|
||||
|
||||
@@ -39,6 +39,7 @@ Text + native (when enabled):
|
||||
- `/status`
|
||||
- `/status` (show current status; includes a short usage line when available)
|
||||
- `/usage` (alias: `/status`)
|
||||
- `/config show|set|unset` (persist config to disk, owner-only)
|
||||
- `/debug show|set|unset|reset` (runtime overrides, owner-only)
|
||||
- `/cost on|off` (toggle per-response usage line)
|
||||
- `/stop`
|
||||
@@ -82,6 +83,23 @@ Notes:
|
||||
- Overrides apply immediately to new config reads, but do **not** write to `clawdbot.json`.
|
||||
- Use `/debug reset` to clear all overrides and return to the on-disk config.
|
||||
|
||||
## Config updates
|
||||
|
||||
`/config` writes to your on-disk config (`clawdbot.json`). Owner-only.
|
||||
|
||||
Examples:
|
||||
|
||||
```
|
||||
/config show
|
||||
/config show messages.responsePrefix
|
||||
/config set messages.responsePrefix="[clawdbot]"
|
||||
/config unset messages.responsePrefix
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Config is validated before write; invalid changes are rejected.
|
||||
- `/config` updates persist across restarts.
|
||||
|
||||
## Surface notes
|
||||
|
||||
- **Text commands** run in the normal chat session (DMs share `main`, groups have their own session).
|
||||
|
||||
@@ -136,6 +136,13 @@ export const CHAT_COMMANDS: ChatCommandDefinition[] = (() => {
|
||||
description: "Show current status.",
|
||||
textAlias: "/status",
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "config",
|
||||
nativeName: "config",
|
||||
description: "Show or set config values.",
|
||||
textAlias: "/config",
|
||||
acceptsArgs: true,
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "debug",
|
||||
nativeName: "debug",
|
||||
|
||||
@@ -20,6 +20,17 @@ import {
|
||||
waitForEmbeddedPiRunEnd,
|
||||
} from "../../agents/pi-embedded.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import {
|
||||
readConfigFileSnapshot,
|
||||
validateConfigObject,
|
||||
writeConfigFile,
|
||||
} from "../../config/config.js";
|
||||
import {
|
||||
getConfigValueAtPath,
|
||||
parseConfigPath,
|
||||
setConfigValueAtPath,
|
||||
unsetConfigValueAtPath,
|
||||
} from "../../config/config-paths.js";
|
||||
import {
|
||||
getConfigOverrides,
|
||||
resetConfigOverrides,
|
||||
@@ -72,6 +83,7 @@ import type {
|
||||
} from "../thinking.js";
|
||||
import type { ReplyPayload } from "../types.js";
|
||||
import { isAbortTrigger, setAbortMemory } from "./abort.js";
|
||||
import { parseConfigCommand } from "./config-commands.js";
|
||||
import { parseDebugCommand } from "./debug-commands.js";
|
||||
import type { InlineDirectives } from "./directive-handling.js";
|
||||
import { stripMentions, stripStructuralPrefixes } from "./mentions.js";
|
||||
@@ -628,6 +640,124 @@ export async function handleCommands(params: {
|
||||
return { shouldContinue: false, reply };
|
||||
}
|
||||
|
||||
const configCommand = allowTextCommands
|
||||
? parseConfigCommand(command.commandBodyNormalized)
|
||||
: null;
|
||||
if (configCommand) {
|
||||
if (!command.isAuthorizedSender) {
|
||||
logVerbose(
|
||||
`Ignoring /config from unauthorized sender: ${command.senderE164 || "<unknown>"}`,
|
||||
);
|
||||
return { shouldContinue: false };
|
||||
}
|
||||
if (configCommand.action === "error") {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `⚠️ ${configCommand.message}` },
|
||||
};
|
||||
}
|
||||
const snapshot = await readConfigFileSnapshot();
|
||||
if (!snapshot.valid || !snapshot.parsed || typeof snapshot.parsed !== "object") {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: "⚠️ Config file is invalid; fix it before using /config." },
|
||||
};
|
||||
}
|
||||
const parsedBase = structuredClone(
|
||||
snapshot.parsed as Record<string, unknown>,
|
||||
);
|
||||
|
||||
if (configCommand.action === "show") {
|
||||
const pathRaw = configCommand.path?.trim();
|
||||
if (pathRaw) {
|
||||
const parsedPath = parseConfigPath(pathRaw);
|
||||
if (!parsedPath.ok || !parsedPath.path) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `⚠️ ${parsedPath.error ?? "Invalid path."}` },
|
||||
};
|
||||
}
|
||||
const value = getConfigValueAtPath(parsedBase, parsedPath.path);
|
||||
const rendered = JSON.stringify(value ?? null, null, 2);
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: `⚙️ Config ${pathRaw}:\n\`\`\`json\n${rendered}\n\`\`\``,
|
||||
},
|
||||
};
|
||||
}
|
||||
const json = JSON.stringify(parsedBase, null, 2);
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `⚙️ Config (raw):\n\`\`\`json\n${json}\n\`\`\`` },
|
||||
};
|
||||
}
|
||||
|
||||
if (configCommand.action === "unset") {
|
||||
const parsedPath = parseConfigPath(configCommand.path);
|
||||
if (!parsedPath.ok || !parsedPath.path) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `⚠️ ${parsedPath.error ?? "Invalid path."}` },
|
||||
};
|
||||
}
|
||||
const removed = unsetConfigValueAtPath(parsedBase, parsedPath.path);
|
||||
if (!removed) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `⚙️ No config value found for ${configCommand.path}.` },
|
||||
};
|
||||
}
|
||||
const validated = validateConfigObject(parsedBase);
|
||||
if (!validated.ok) {
|
||||
const issue = validated.issues[0];
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: `⚠️ Config invalid after unset (${issue.path}: ${issue.message}).`,
|
||||
},
|
||||
};
|
||||
}
|
||||
await writeConfigFile(validated.config);
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `⚙️ Config updated: ${configCommand.path} removed.` },
|
||||
};
|
||||
}
|
||||
|
||||
if (configCommand.action === "set") {
|
||||
const parsedPath = parseConfigPath(configCommand.path);
|
||||
if (!parsedPath.ok || !parsedPath.path) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `⚠️ ${parsedPath.error ?? "Invalid path."}` },
|
||||
};
|
||||
}
|
||||
setConfigValueAtPath(parsedBase, parsedPath.path, configCommand.value);
|
||||
const validated = validateConfigObject(parsedBase);
|
||||
if (!validated.ok) {
|
||||
const issue = validated.issues[0];
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: `⚠️ Config invalid after set (${issue.path}: ${issue.message}).`,
|
||||
},
|
||||
};
|
||||
}
|
||||
await writeConfigFile(validated.config);
|
||||
const valueLabel =
|
||||
typeof configCommand.value === "string"
|
||||
? `"${configCommand.value}"`
|
||||
: JSON.stringify(configCommand.value);
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: `⚙️ Config updated: ${configCommand.path}=${valueLabel ?? "null"}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const debugCommand = allowTextCommands
|
||||
? parseDebugCommand(command.commandBodyNormalized)
|
||||
: null;
|
||||
|
||||
23
src/auto-reply/reply/config-commands.test.ts
Normal file
23
src/auto-reply/reply/config-commands.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseConfigCommand } from "./config-commands.js";
|
||||
|
||||
describe("parseConfigCommand", () => {
|
||||
it("parses show/unset", () => {
|
||||
expect(parseConfigCommand("/config")).toEqual({ action: "show" });
|
||||
expect(parseConfigCommand("/config show")).toEqual({ action: "show", path: undefined });
|
||||
expect(parseConfigCommand("/config show foo.bar")).toEqual({
|
||||
action: "show",
|
||||
path: "foo.bar",
|
||||
});
|
||||
expect(parseConfigCommand("/config unset foo.bar")).toEqual({
|
||||
action: "unset",
|
||||
path: "foo.bar",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses set with JSON", () => {
|
||||
const cmd = parseConfigCommand('/config set foo={"a":1}');
|
||||
expect(cmd).toEqual({ action: "set", path: "foo", value: { a: 1 } });
|
||||
});
|
||||
});
|
||||
62
src/auto-reply/reply/config-commands.ts
Normal file
62
src/auto-reply/reply/config-commands.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { parseConfigValue } from "./config-value.js";
|
||||
|
||||
export type ConfigCommand =
|
||||
| { action: "show"; path?: string }
|
||||
| { action: "set"; path: string; value: unknown }
|
||||
| { action: "unset"; path: string }
|
||||
| { action: "error"; message: string };
|
||||
|
||||
export function parseConfigCommand(raw: string): ConfigCommand | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed.toLowerCase().startsWith("/config")) return null;
|
||||
const rest = trimmed.slice("/config".length).trim();
|
||||
if (!rest) return { action: "show" };
|
||||
|
||||
const match = rest.match(/^(\S+)(?:\s+([\s\S]+))?$/);
|
||||
if (!match) return { action: "error", message: "Invalid /config syntax." };
|
||||
const action = match[1].toLowerCase();
|
||||
const args = (match[2] ?? "").trim();
|
||||
|
||||
switch (action) {
|
||||
case "show":
|
||||
return { action: "show", path: args || undefined };
|
||||
case "unset": {
|
||||
if (!args)
|
||||
return { action: "error", message: "Usage: /config unset path" };
|
||||
return { action: "unset", path: args };
|
||||
}
|
||||
case "set": {
|
||||
if (!args) {
|
||||
return {
|
||||
action: "error",
|
||||
message: "Usage: /config set path=value",
|
||||
};
|
||||
}
|
||||
const eqIndex = args.indexOf("=");
|
||||
if (eqIndex <= 0) {
|
||||
return {
|
||||
action: "error",
|
||||
message: "Usage: /config set path=value",
|
||||
};
|
||||
}
|
||||
const path = args.slice(0, eqIndex).trim();
|
||||
const rawValue = args.slice(eqIndex + 1);
|
||||
if (!path) {
|
||||
return {
|
||||
action: "error",
|
||||
message: "Usage: /config set path=value",
|
||||
};
|
||||
}
|
||||
const parsed = parseConfigValue(rawValue);
|
||||
if (parsed.error) {
|
||||
return { action: "error", message: parsed.error };
|
||||
}
|
||||
return { action: "set", path, value: parsed.value };
|
||||
}
|
||||
default:
|
||||
return {
|
||||
action: "error",
|
||||
message: "Usage: /config show|set|unset",
|
||||
};
|
||||
}
|
||||
}
|
||||
37
src/auto-reply/reply/config-value.ts
Normal file
37
src/auto-reply/reply/config-value.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
export function parseConfigValue(
|
||||
raw: string,
|
||||
): { value?: unknown; error?: string } {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return { error: "Missing value." };
|
||||
|
||||
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
||||
try {
|
||||
return { value: JSON.parse(trimmed) };
|
||||
} catch (err) {
|
||||
return { error: `Invalid JSON: ${String(err)}` };
|
||||
}
|
||||
}
|
||||
|
||||
if (trimmed === "true") return { value: true };
|
||||
if (trimmed === "false") return { value: false };
|
||||
if (trimmed === "null") return { value: null };
|
||||
|
||||
if (/^-?\d+(\.\d+)?$/.test(trimmed)) {
|
||||
const num = Number(trimmed);
|
||||
if (Number.isFinite(num)) return { value: num };
|
||||
}
|
||||
|
||||
if (
|
||||
(trimmed.startsWith("\"") && trimmed.endsWith("\"")) ||
|
||||
(trimmed.startsWith("'") && trimmed.endsWith("'"))
|
||||
) {
|
||||
try {
|
||||
return { value: JSON.parse(trimmed) };
|
||||
} catch {
|
||||
const unquoted = trimmed.slice(1, -1);
|
||||
return { value: unquoted };
|
||||
}
|
||||
}
|
||||
|
||||
return { value: trimmed };
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { parseConfigValue } from "./config-value.js";
|
||||
|
||||
export type DebugCommand =
|
||||
| { action: "show" }
|
||||
| { action: "reset" }
|
||||
@@ -5,42 +7,6 @@ export type DebugCommand =
|
||||
| { action: "unset"; path: string }
|
||||
| { action: "error"; message: string };
|
||||
|
||||
function parseDebugValue(raw: string): { value?: unknown; error?: string } {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return { error: "Missing value." };
|
||||
|
||||
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
||||
try {
|
||||
return { value: JSON.parse(trimmed) };
|
||||
} catch (err) {
|
||||
return { error: `Invalid JSON: ${String(err)}` };
|
||||
}
|
||||
}
|
||||
|
||||
if (trimmed === "true") return { value: true };
|
||||
if (trimmed === "false") return { value: false };
|
||||
if (trimmed === "null") return { value: null };
|
||||
|
||||
if (/^-?\d+(\.\d+)?$/.test(trimmed)) {
|
||||
const num = Number(trimmed);
|
||||
if (Number.isFinite(num)) return { value: num };
|
||||
}
|
||||
|
||||
if (
|
||||
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
||||
(trimmed.startsWith("'") && trimmed.endsWith("'"))
|
||||
) {
|
||||
try {
|
||||
return { value: JSON.parse(trimmed) };
|
||||
} catch {
|
||||
const unquoted = trimmed.slice(1, -1);
|
||||
return { value: unquoted };
|
||||
}
|
||||
}
|
||||
|
||||
return { value: trimmed };
|
||||
}
|
||||
|
||||
export function parseDebugCommand(raw: string): DebugCommand | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed.toLowerCase().startsWith("/debug")) return null;
|
||||
@@ -84,7 +50,7 @@ export function parseDebugCommand(raw: string): DebugCommand | null {
|
||||
message: "Usage: /debug set path=value",
|
||||
};
|
||||
}
|
||||
const parsed = parseDebugValue(rawValue);
|
||||
const parsed = parseConfigValue(rawValue);
|
||||
if (parsed.error) {
|
||||
return { action: "error", message: parsed.error };
|
||||
}
|
||||
|
||||
@@ -360,7 +360,7 @@ export function buildHelpMessage(): string {
|
||||
return [
|
||||
"ℹ️ Help",
|
||||
"Shortcuts: /new reset | /compact [instructions] | /restart relink (if enabled)",
|
||||
"Options: /think <level> | /verbose on|off | /reasoning on|off | /elevated on|off | /model <id> | /cost on|off | /debug show",
|
||||
"Options: /think <level> | /verbose on|off | /reasoning on|off | /elevated on|off | /model <id> | /cost on|off | /config show | /debug show",
|
||||
"More: /commands for all slash commands",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
28
src/config/config-paths.test.ts
Normal file
28
src/config/config-paths.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
getConfigValueAtPath,
|
||||
parseConfigPath,
|
||||
setConfigValueAtPath,
|
||||
unsetConfigValueAtPath,
|
||||
} from "./config-paths.js";
|
||||
|
||||
describe("config paths", () => {
|
||||
it("rejects empty and blocked paths", () => {
|
||||
expect(parseConfigPath("")).toEqual({
|
||||
ok: false,
|
||||
error: "Invalid path. Use dot notation (e.g. foo.bar).",
|
||||
});
|
||||
expect(parseConfigPath("__proto__.polluted").ok).toBe(false);
|
||||
});
|
||||
|
||||
it("sets, gets, and unsets nested values", () => {
|
||||
const root: Record<string, unknown> = {};
|
||||
const parsed = parseConfigPath("foo.bar");
|
||||
if (!parsed.ok || !parsed.path) throw new Error("path parse failed");
|
||||
setConfigValueAtPath(root, parsed.path, 123);
|
||||
expect(getConfigValueAtPath(root, parsed.path)).toBe(123);
|
||||
expect(unsetConfigValueAtPath(root, parsed.path)).toBe(true);
|
||||
expect(getConfigValueAtPath(root, parsed.path)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
88
src/config/config-paths.ts
Normal file
88
src/config/config-paths.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
type PathNode = Record<string, unknown>;
|
||||
|
||||
const BLOCKED_KEYS = new Set(["__proto__", "prototype", "constructor"]);
|
||||
|
||||
export function parseConfigPath(raw: string): {
|
||||
ok: boolean;
|
||||
path?: string[];
|
||||
error?: string;
|
||||
} {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return { ok: false, error: "Invalid path. Use dot notation (e.g. foo.bar)." };
|
||||
}
|
||||
const parts = trimmed.split(".").map((part) => part.trim());
|
||||
if (parts.some((part) => !part)) {
|
||||
return { ok: false, error: "Invalid path. Use dot notation (e.g. foo.bar)." };
|
||||
}
|
||||
if (parts.some((part) => BLOCKED_KEYS.has(part))) {
|
||||
return { ok: false, error: "Invalid path segment." };
|
||||
}
|
||||
return { ok: true, path: parts };
|
||||
}
|
||||
|
||||
export function setConfigValueAtPath(
|
||||
root: PathNode,
|
||||
path: string[],
|
||||
value: unknown,
|
||||
): void {
|
||||
let cursor: PathNode = root;
|
||||
for (let idx = 0; idx < path.length - 1; idx += 1) {
|
||||
const key = path[idx];
|
||||
const next = cursor[key];
|
||||
if (!isPlainObject(next)) {
|
||||
cursor[key] = {};
|
||||
}
|
||||
cursor = cursor[key] as PathNode;
|
||||
}
|
||||
cursor[path[path.length - 1]] = value;
|
||||
}
|
||||
|
||||
export function unsetConfigValueAtPath(
|
||||
root: PathNode,
|
||||
path: string[],
|
||||
): boolean {
|
||||
const stack: Array<{ node: PathNode; key: string }> = [];
|
||||
let cursor: PathNode = root;
|
||||
for (let idx = 0; idx < path.length - 1; idx += 1) {
|
||||
const key = path[idx];
|
||||
const next = cursor[key];
|
||||
if (!isPlainObject(next)) return false;
|
||||
stack.push({ node: cursor, key });
|
||||
cursor = next;
|
||||
}
|
||||
const leafKey = path[path.length - 1];
|
||||
if (!(leafKey in cursor)) return false;
|
||||
delete cursor[leafKey];
|
||||
for (let idx = stack.length - 1; idx >= 0; idx -= 1) {
|
||||
const { node, key } = stack[idx];
|
||||
const child = node[key];
|
||||
if (isPlainObject(child) && Object.keys(child).length === 0) {
|
||||
delete node[key];
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getConfigValueAtPath(
|
||||
root: PathNode,
|
||||
path: string[],
|
||||
): unknown {
|
||||
let cursor: unknown = root;
|
||||
for (const key of path) {
|
||||
if (!isPlainObject(cursor)) return undefined;
|
||||
cursor = cursor[key];
|
||||
}
|
||||
return cursor;
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
!Array.isArray(value) &&
|
||||
Object.prototype.toString.call(value) === "[object Object]"
|
||||
);
|
||||
}
|
||||
@@ -1,68 +1,14 @@
|
||||
import type { ClawdbotConfig } from "./types.js";
|
||||
import {
|
||||
parseConfigPath,
|
||||
setConfigValueAtPath,
|
||||
unsetConfigValueAtPath,
|
||||
} from "./config-paths.js";
|
||||
|
||||
type OverrideTree = Record<string, unknown>;
|
||||
|
||||
let overrides: OverrideTree = {};
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
!Array.isArray(value) &&
|
||||
Object.prototype.toString.call(value) === "[object Object]"
|
||||
);
|
||||
}
|
||||
|
||||
function parsePath(raw: string): string[] | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return null;
|
||||
const parts = trimmed.split(".").map((part) => part.trim());
|
||||
if (parts.some((part) => !part)) return null;
|
||||
return parts;
|
||||
}
|
||||
|
||||
function setOverrideAtPath(
|
||||
root: OverrideTree,
|
||||
path: string[],
|
||||
value: unknown,
|
||||
): void {
|
||||
let cursor: OverrideTree = root;
|
||||
for (let idx = 0; idx < path.length - 1; idx += 1) {
|
||||
const key = path[idx];
|
||||
const next = cursor[key];
|
||||
if (!isPlainObject(next)) {
|
||||
cursor[key] = {};
|
||||
}
|
||||
cursor = cursor[key] as OverrideTree;
|
||||
}
|
||||
cursor[path[path.length - 1]] = value;
|
||||
}
|
||||
|
||||
function unsetOverrideAtPath(root: OverrideTree, path: string[]): boolean {
|
||||
const stack: Array<{ node: OverrideTree; key: string }> = [];
|
||||
let cursor: OverrideTree = root;
|
||||
for (let idx = 0; idx < path.length - 1; idx += 1) {
|
||||
const key = path[idx];
|
||||
const next = cursor[key];
|
||||
if (!isPlainObject(next)) return false;
|
||||
stack.push({ node: cursor, key });
|
||||
cursor = next;
|
||||
}
|
||||
const leafKey = path[path.length - 1];
|
||||
if (!(leafKey in cursor)) return false;
|
||||
delete cursor[leafKey];
|
||||
for (let idx = stack.length - 1; idx >= 0; idx -= 1) {
|
||||
const { node, key } = stack[idx];
|
||||
const child = node[key];
|
||||
if (isPlainObject(child) && Object.keys(child).length === 0) {
|
||||
delete node[key];
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function mergeOverrides(base: unknown, override: unknown): unknown {
|
||||
if (!isPlainObject(base) || !isPlainObject(override)) return override;
|
||||
const next: OverrideTree = { ...base };
|
||||
@@ -73,6 +19,15 @@ function mergeOverrides(base: unknown, override: unknown): unknown {
|
||||
return next;
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
!Array.isArray(value) &&
|
||||
Object.prototype.toString.call(value) === "[object Object]"
|
||||
);
|
||||
}
|
||||
|
||||
export function getConfigOverrides(): OverrideTree {
|
||||
return overrides;
|
||||
}
|
||||
@@ -88,14 +43,11 @@ export function setConfigOverride(
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
} {
|
||||
const path = parsePath(pathRaw);
|
||||
if (!path) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "Invalid path. Use dot notation (e.g. foo.bar).",
|
||||
};
|
||||
const parsed = parseConfigPath(pathRaw);
|
||||
if (!parsed.ok || !parsed.path) {
|
||||
return { ok: false, error: parsed.error ?? "Invalid path." };
|
||||
}
|
||||
setOverrideAtPath(overrides, path, value);
|
||||
setConfigValueAtPath(overrides, parsed.path, value);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@@ -104,11 +56,11 @@ export function unsetConfigOverride(pathRaw: string): {
|
||||
removed: boolean;
|
||||
error?: string;
|
||||
} {
|
||||
const path = parsePath(pathRaw);
|
||||
if (!path) {
|
||||
return { ok: false, removed: false, error: "Invalid path." };
|
||||
const parsed = parseConfigPath(pathRaw);
|
||||
if (!parsed.ok || !parsed.path) {
|
||||
return { ok: false, removed: false, error: parsed.error ?? "Invalid path." };
|
||||
}
|
||||
const removed = unsetOverrideAtPath(overrides, path);
|
||||
const removed = unsetConfigValueAtPath(overrides, parsed.path);
|
||||
return { ok: true, removed };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user