mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
81de9f66a6
Fixes #643. ## Before / after ``` $ argent config set ios.additionalDeviceSets "~/Library/Developer/Radon/Devices" --scope project Error: Invalid value for config key "ios.additionalDeviceSets". # ← the whole message ``` ``` $ argent config set ios.additionalDeviceSets "~/Library/Developer/Radon/Devices" --scope project Error: Invalid value for config key "ios.additionalDeviceSets": expected an array of strings. Did you mean: argent config set ios.additionalDeviceSets '["~/Library/Developer/Radon/Devices"]' --scope project Run `argent config list` to see each key's expected value. ``` ``` $ argent config unset ios.additionalDeviceSets --scope project $ cat .argent/config.json { "ios": {} } → {} ``` ## Symptom 1 — the message The issue names three gaps: no expected type, no example, no pointer to `config list`. All three are closed, plus one the issue didn't ask for and I think is the actual fix. **The suggestion carries the user's own value.** A generic `Example: … '["~/DeviceSets/ci"]'` would hand someone a path they have never heard of and expect them to spot the difference. Where the correction is mechanical — a single item offered to a list-valued key — the error offers back what they typed, wrapped, shell-quoted, and **on the scope they asked for**. Pasting it works; verified live that it lands in project scope, not global. Nothing is coerced. The issue floated accepting a bare string and storing it wrapped; I didn't, because `config set` persists, gets committed, and is read by other machines, so a value silently stored in a shape the user didn't type is expensive to debug and `coerceCliValue` already has one silent fallback. Showing the corrected command costs the user one keystroke and keeps the store honest. If a reviewer prefers coercion, this is the smaller change to build on. **The expected shape comes from the validator, not a per-key string** — `asStringArray` *is* "an array of strings", so swapping a key's `parse` swaps its description with it and the two cannot drift. A bespoke validator can override via a new optional `expected`, and degrades to today's exact message if it says nothing. **`config list` states it too.** The issue notes `list` doesn't close the gap either — its description describes the *elements* but never says the value is a list. It now shows `value: an array of strings, e.g. ["~/DeviceSets/ci"]`, and `list --json` carries both fields. `telemetry.enabled` deliberately gets no example: `setConfigValue` refuses it before validating (it's managed by `argent telemetry`), so a "here's how to set it" hint would point at a command you may not run. A schema guard test keeps that honest as keys are added — and asserts every example **actually round-trips through its own validator**, so an example can never ship that reproduces the error it exists to fix. ## Symptom 2 — the empty group Deleting a key now removes any container the delete emptied, stopping at the first ancestor that still holds something. **This is file hygiene, not a behaviour change.** I traced every raw reader of the document — `readConfigObject`, and telemetry's `consent.ts` and `notice.ts`, which each do their own read — and an empty parent is already indistinguishable from an absent one: `getAtPath` returns `undefined` either way, before `parse`, so the merge layer never sees a container at all. The only observable difference is the bytes on disk, which is exactly the complaint. Guards worth noting: - **The root always survives** — an emptied config is `{}`, never a deleted file. `.argent/` is itself a project-root marker, so removing the file wouldn't restore the prior state anyway, and `updateConfig` is a shared publish path with a lock/rename protocol that has no defined meaning for file removal. - **A no-op unset still doesn't touch the file.** That fast path exists so an unset that removes nothing can't materialise a project config and dirty git status; pruning lives inside the mutate callback, which the fast path returns before reaching. So a group someone deliberately left empty by hand is never tidied away — pinned by a test asserting the file stays byte-identical. - Siblings at any level stop the unwind, including an empty array value, which is preserved. **Two existing tests asserted the old behaviour and now assert the new one** — `config-access.test.ts:47` and `config.test.ts:109` (the latter is `clearRememberedAgent`'s sibling-preservation test, so it doubles as proof siblings survive). ## Known same-class case, not fixed here `resetFirstRunNotice` (`packages/telemetry/src/notice.ts`) does its own inline delete and still leaves `{"notices":{}}`. It doesn't go through `deleteAtPath`, so it doesn't inherit this. It's a one-line change to route it through the same function — happy to do it here or as a follow-up, but it pulls the telemetry package into a config PR so I left it out by default. ## Verification Live against the built CLI: the reported case; pasting the suggestion back and confirming it lands in **project** scope; unset leaving `{}`; a sibling key surviving; a non-array key correctly getting the generic example and *no* bogus "Did you mean"; `config list` output; `telemetry.enabled` still delegating unchanged. configuration-core 103 passed, argent-cli 285 passed. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
384 lines
15 KiB
TypeScript
384 lines
15 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
import * as fs from "node:fs";
|
|
import * as path from "node:path";
|
|
import * as os from "node:os";
|
|
import { configFilePath } from "../src/paths.js";
|
|
import { readConfigObject, getAtPath, setAtPath, deleteAtPath } from "../src/config.js";
|
|
import {
|
|
getConfigValue,
|
|
getConfigValueByKey,
|
|
setConfigValue,
|
|
unsetConfigValue,
|
|
listConfig,
|
|
coerceCliValue,
|
|
getAdditionalIosDeviceSets,
|
|
UnknownConfigKeyError,
|
|
ConfigScopeError,
|
|
ConfigValidationError,
|
|
ConfigManagedElsewhereError,
|
|
} from "../src/config-access.js";
|
|
import {
|
|
CONFIG_SCHEMA,
|
|
describeExpectedValue,
|
|
type ConfigDefinition,
|
|
} from "../src/config-schema.js";
|
|
|
|
// Sandbox both scopes: `homeDir` for global (~/.argent), `cwd` for the project
|
|
// root (a tmp dir seeded with a `.git` marker so resolveProjectRoot stops there).
|
|
let homeDir: string;
|
|
let projectDir: string;
|
|
|
|
beforeEach(() => {
|
|
homeDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "argent-home-")));
|
|
projectDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "argent-project-")));
|
|
fs.mkdirSync(path.join(projectDir, ".git"), { recursive: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(homeDir, { recursive: true, force: true });
|
|
fs.rmSync(projectDir, { recursive: true, force: true });
|
|
});
|
|
|
|
const opts = () => ({ homeDir, cwd: projectDir });
|
|
|
|
describe("dotted-path helpers", () => {
|
|
it("gets, sets, and deletes nested leaves", () => {
|
|
const obj: Record<string, unknown> = {};
|
|
setAtPath(obj, "ios.deviceSet", "/tmp/set");
|
|
expect(obj).toEqual({ ios: { deviceSet: "/tmp/set" } });
|
|
expect(getAtPath(obj, "ios.deviceSet")).toBe("/tmp/set");
|
|
expect(deleteAtPath(obj, "ios.deviceSet")).toBe(true);
|
|
// The emptied parent goes with it, so unset restores the prior document.
|
|
expect(obj).toEqual({});
|
|
expect(deleteAtPath(obj, "ios.deviceSet")).toBe(false);
|
|
});
|
|
|
|
it("refuses prototype-polluting segments", () => {
|
|
const obj: Record<string, unknown> = {};
|
|
expect(() => setAtPath(obj, "__proto__.polluted", true)).toThrow(/forbidden/);
|
|
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("getConfigValue — scope merge (lens.agent = prioritize-local)", () => {
|
|
it("returns null-equivalent (undefined) when neither scope is set", () => {
|
|
expect(getConfigValueByKey("lens.agent", opts())).toBeUndefined();
|
|
});
|
|
|
|
it("reads the global value when only global is set", () => {
|
|
setConfigValue("lens.agent", "claude", "global", opts());
|
|
expect(getConfigValueByKey("lens.agent", opts())).toBe("claude");
|
|
});
|
|
|
|
it("project overrides global under prioritize-local", () => {
|
|
setConfigValue("lens.agent", "claude", "global", opts());
|
|
setConfigValue("lens.agent", "codex", "project", opts());
|
|
expect(getConfigValueByKey("lens.agent", opts())).toBe("codex");
|
|
// Each scope's file holds only its own value.
|
|
expect(readConfigObject("global", opts())).toEqual({ lens: { agent: "claude" } });
|
|
expect(readConfigObject("project", opts())).toEqual({ lens: { agent: "codex" } });
|
|
});
|
|
|
|
it("writes project config under <project-root>/.argent/config.json", () => {
|
|
setConfigValue("lens.agent", "codex", "project", opts());
|
|
expect(fs.existsSync(configFilePath("project", opts()))).toBe(true);
|
|
expect(configFilePath("project", opts())).toBe(path.join(projectDir, ".argent", "config.json"));
|
|
});
|
|
});
|
|
|
|
describe("recordings.directory — schema entry", () => {
|
|
it("is unset by default (the client then uses its built-in .argent/recordings)", () => {
|
|
expect(getConfigValueByKey("recordings.directory", opts())).toBeUndefined();
|
|
});
|
|
|
|
it("accepts a path at either scope and trims it", () => {
|
|
expect(setConfigValue("recordings.directory", " ~/Movies/argent ", "global", opts())).toBe(
|
|
"~/Movies/argent"
|
|
);
|
|
expect(setConfigValue("recordings.directory", "clips", "project", opts())).toBe("clips");
|
|
});
|
|
|
|
it("project scope wins over global (prioritize-local)", () => {
|
|
setConfigValue("recordings.directory", "/global/recordings", "global", opts());
|
|
setConfigValue("recordings.directory", "/project/recordings", "project", opts());
|
|
expect(getConfigValueByKey("recordings.directory", opts())).toBe("/project/recordings");
|
|
unsetConfigValue("recordings.directory", "project", opts());
|
|
expect(getConfigValueByKey("recordings.directory", opts())).toBe("/global/recordings");
|
|
});
|
|
|
|
it("rejects a non-string value", () => {
|
|
expect(() => setConfigValue("recordings.directory", 42, "global", opts())).toThrow(
|
|
ConfigValidationError
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("setConfigValue — validation", () => {
|
|
it("rejects an unknown key", () => {
|
|
expect(() => setConfigValue("nope.nope", "x", "global", opts())).toThrow(UnknownConfigKeyError);
|
|
});
|
|
|
|
it("rejects a project write for a global-only value via ConfigScopeError", () => {
|
|
// A settable, global-only definition supplied through the registry param
|
|
// (telemetry.enabled is global-only too, but it's manageCommand-delegated so
|
|
// it throws ConfigManagedElsewhereError first — this isolates the scope check).
|
|
const registry: ConfigDefinition[] = [
|
|
{
|
|
key: "test.onlyGlobal",
|
|
description: "test",
|
|
scopes: ["global"],
|
|
parse: (r) => (typeof r === "boolean" ? r : undefined),
|
|
merge: "prioritize-restrictive",
|
|
},
|
|
];
|
|
expect(() => setConfigValue("test.onlyGlobal", true, "project", opts(), registry)).toThrow(
|
|
ConfigScopeError
|
|
);
|
|
// Global scope is accepted.
|
|
expect(() => setConfigValue("test.onlyGlobal", true, "global", opts(), registry)).not.toThrow();
|
|
});
|
|
|
|
it("refuses to set a manageCommand-delegated key (telemetry)", () => {
|
|
expect(() => setConfigValue("telemetry.enabled", false, "global", opts())).toThrow(
|
|
ConfigManagedElsewhereError
|
|
);
|
|
expect(() => unsetConfigValue("telemetry.enabled", "global", opts())).toThrow(
|
|
ConfigManagedElsewhereError
|
|
);
|
|
});
|
|
|
|
it("rejects an invalid value shape", () => {
|
|
// lens.agent expects a non-blank string.
|
|
expect(() => setConfigValue("lens.agent", 42, "global", opts())).toThrow(ConfigValidationError);
|
|
expect(() => setConfigValue("lens.agent", " ", "global", opts())).toThrow(
|
|
ConfigValidationError
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("unsetConfigValue", () => {
|
|
it("removes a stored value and reports whether anything was removed", () => {
|
|
setConfigValue("lens.agent", "claude", "global", opts());
|
|
expect(unsetConfigValue("lens.agent", "global", opts())).toBe(true);
|
|
expect(getConfigValueByKey("lens.agent", opts())).toBeUndefined();
|
|
expect(unsetConfigValue("lens.agent", "global", opts())).toBe(false);
|
|
});
|
|
|
|
it("a no-op unset never materializes the scope's config file", () => {
|
|
const projectConfig = configFilePath("project", opts());
|
|
expect(fs.existsSync(projectConfig)).toBe(false);
|
|
// Nothing is stored at the project scope, so this removes nothing…
|
|
expect(unsetConfigValue("lens.agent", "project", opts())).toBe(false);
|
|
// …and must not create <project-root>/.argent/config.json to prove it.
|
|
expect(fs.existsSync(projectConfig)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("setConfigValue — return value", () => {
|
|
it("returns the normalized (stored) value, not the raw input", () => {
|
|
// asString trims, so the stored/returned value is the trimmed form.
|
|
expect(setConfigValue("lens.agent", " codex ", "global", opts())).toBe("codex");
|
|
expect(getConfigValueByKey("lens.agent", opts())).toBe("codex");
|
|
});
|
|
});
|
|
|
|
describe("listConfig", () => {
|
|
it("reports every schema entry with per-scope and effective values", () => {
|
|
setConfigValue("lens.agent", "claude", "global", opts());
|
|
setConfigValue("lens.agent", "codex", "project", opts());
|
|
const entries = listConfig(opts());
|
|
const lens = entries.find((e) => e.key === "lens.agent")!;
|
|
expect(lens.global).toBe("claude");
|
|
expect(lens.project).toBe("codex");
|
|
expect(lens.effective).toBe("codex");
|
|
const telemetry = entries.find((e) => e.key === "telemetry.enabled")!;
|
|
expect(telemetry.manageCommand).toBe("argent telemetry");
|
|
expect(telemetry.scopes).toEqual(["global"]);
|
|
});
|
|
});
|
|
|
|
describe("telemetry.enabled — opt-out default", () => {
|
|
it("reads as true (the opt-out default) when nothing is stored", () => {
|
|
expect(getConfigValueByKey("telemetry.enabled", opts())).toBe(true);
|
|
const entry = listConfig(opts()).find((e) => e.key === "telemetry.enabled")!;
|
|
expect(entry.effective).toBe(true);
|
|
expect(entry.global).toBeUndefined();
|
|
});
|
|
|
|
it("reflects a persisted opt-out from the global config file", () => {
|
|
// Written by hand — `setConfigValue` refuses manageCommand-delegated keys,
|
|
// matching how `argent telemetry disable` owns this write in production.
|
|
fs.mkdirSync(path.join(homeDir, ".argent"), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(homeDir, ".argent", "config.json"),
|
|
JSON.stringify({ telemetry: { enabled: false } })
|
|
);
|
|
expect(getConfigValueByKey("telemetry.enabled", opts())).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("coerceCliValue", () => {
|
|
it("parses JSON scalars and arrays, falling back to a bare string", () => {
|
|
expect(coerceCliValue("true")).toBe(true);
|
|
expect(coerceCliValue("42")).toBe(42);
|
|
expect(coerceCliValue('["a","b"]')).toEqual(["a", "b"]);
|
|
expect(coerceCliValue("/tmp/device-set")).toBe("/tmp/device-set");
|
|
expect(coerceCliValue("claude")).toBe("claude");
|
|
});
|
|
});
|
|
|
|
describe("ios.additionalDeviceSets — additive union across scopes", () => {
|
|
it("reads as an empty list when neither scope is set", () => {
|
|
expect(getAdditionalIosDeviceSets(opts())).toEqual([]);
|
|
expect(getConfigValueByKey("ios.additionalDeviceSets", opts())).toBeUndefined();
|
|
});
|
|
|
|
it("unions the scopes additively: global baseline first, project extras after, deduped", () => {
|
|
setConfigValue("ios.additionalDeviceSets", ["/tmp/sets/a", "/tmp/sets/b"], "global", opts());
|
|
setConfigValue("ios.additionalDeviceSets", ["/tmp/sets/b", "/tmp/sets/c"], "project", opts());
|
|
expect(getConfigValueByKey("ios.additionalDeviceSets", opts())).toEqual([
|
|
"/tmp/sets/a",
|
|
"/tmp/sets/b",
|
|
"/tmp/sets/c",
|
|
]);
|
|
// Each scope's file holds only its own entries — the union is read-time.
|
|
expect(readConfigObject("global", opts())).toEqual({
|
|
ios: { additionalDeviceSets: ["/tmp/sets/a", "/tmp/sets/b"] },
|
|
});
|
|
expect(readConfigObject("project", opts())).toEqual({
|
|
ios: { additionalDeviceSets: ["/tmp/sets/b", "/tmp/sets/c"] },
|
|
});
|
|
});
|
|
|
|
it("rejects a non-array value; normalizes entries (trims, drops blanks/non-strings)", () => {
|
|
expect(() =>
|
|
setConfigValue("ios.additionalDeviceSets", "/tmp/sets/a", "global", opts())
|
|
).toThrow(ConfigValidationError);
|
|
expect(
|
|
setConfigValue("ios.additionalDeviceSets", [" /tmp/sets/a ", "", 42], "global", opts())
|
|
).toEqual(["/tmp/sets/a"]);
|
|
});
|
|
|
|
it("getAdditionalIosDeviceSets expands ~ and resolves relative entries per scope", () => {
|
|
setConfigValue("ios.additionalDeviceSets", ["~/DeviceSets/ci", "shared"], "global", opts());
|
|
setConfigValue("ios.additionalDeviceSets", ["device-sets/e2e", "/abs/set"], "project", opts());
|
|
expect(getAdditionalIosDeviceSets(opts())).toEqual([
|
|
path.join(homeDir, "DeviceSets/ci"),
|
|
// Relative global entries resolve against home…
|
|
path.join(homeDir, "shared"),
|
|
// …while relative project entries resolve against the project root.
|
|
path.join(projectDir, "device-sets/e2e"),
|
|
path.resolve("/abs/set"),
|
|
]);
|
|
});
|
|
|
|
it("drops duplicates that only converge after normalization", () => {
|
|
setConfigValue("ios.additionalDeviceSets", ["~/DeviceSets/ci"], "global", opts());
|
|
setConfigValue(
|
|
"ios.additionalDeviceSets",
|
|
[path.join(homeDir, "DeviceSets", "ci")],
|
|
"project",
|
|
opts()
|
|
);
|
|
expect(getAdditionalIosDeviceSets(opts())).toEqual([path.join(homeDir, "DeviceSets/ci")]);
|
|
});
|
|
|
|
it("strips trailing separators so slash-suffixed spellings dedup too", () => {
|
|
setConfigValue("ios.additionalDeviceSets", ["~/DeviceSets/ci/"], "global", opts());
|
|
setConfigValue(
|
|
"ios.additionalDeviceSets",
|
|
[path.join(homeDir, "DeviceSets", "ci"), "~"],
|
|
"project",
|
|
opts()
|
|
);
|
|
expect(getAdditionalIosDeviceSets(opts())).toEqual([
|
|
path.join(homeDir, "DeviceSets/ci"),
|
|
// Bare `~` resolves to home without a trailing separator either.
|
|
homeDir,
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("getConfigValue — direct definition + custom-typed default", () => {
|
|
it("applies the schema default when no scope contributes a value", () => {
|
|
const def: ConfigDefinition<string> = {
|
|
key: "demo.value",
|
|
description: "demo",
|
|
scopes: ["project", "global"],
|
|
parse: (r) => (typeof r === "string" && r.trim() ? r.trim() : undefined),
|
|
merge: "prioritize-local",
|
|
default: "fallback",
|
|
};
|
|
expect(getConfigValue(def, opts())).toBe("fallback");
|
|
});
|
|
});
|
|
|
|
describe("every schema entry can describe itself", () => {
|
|
it("says what value it expects", () => {
|
|
for (const def of CONFIG_SCHEMA) {
|
|
expect(describeExpectedValue(def), `key: ${def.key}`).toBeTruthy();
|
|
}
|
|
});
|
|
|
|
it("offers an example for every key a user may set", () => {
|
|
for (const def of CONFIG_SCHEMA) {
|
|
if (def.manageCommand) continue;
|
|
expect(def.example, `key: ${def.key}`).toBeTruthy();
|
|
}
|
|
});
|
|
|
|
it("offers examples that are actually accepted", () => {
|
|
// An example that its own validator rejects would hand the user a command
|
|
// reproducing the error it exists to fix.
|
|
for (const def of CONFIG_SCHEMA) {
|
|
if (!def.example) continue;
|
|
expect(def.parse(coerceCliValue(def.example)), `key: ${def.key}`).not.toBeUndefined();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("deleteAtPath prunes only what it emptied", () => {
|
|
it("removes a container the delete emptied", () => {
|
|
const obj: Record<string, unknown> = { ios: { additionalDeviceSets: ["/a"] } };
|
|
expect(deleteAtPath(obj, "ios.additionalDeviceSets")).toBe(true);
|
|
expect(obj).toEqual({});
|
|
});
|
|
|
|
it("stops at the first ancestor that still holds something", () => {
|
|
const obj: Record<string, unknown> = { ios: { deviceSet: "x", additionalDeviceSets: ["/a"] } };
|
|
expect(deleteAtPath(obj, "ios.additionalDeviceSets")).toBe(true);
|
|
expect(obj).toEqual({ ios: { deviceSet: "x" } });
|
|
});
|
|
|
|
it("unwinds a chain deeper than one level", () => {
|
|
const obj: Record<string, unknown> = { a: { b: { c: { d: 1 } } } };
|
|
expect(deleteAtPath(obj, "a.b.c.d")).toBe(true);
|
|
expect(obj).toEqual({});
|
|
});
|
|
|
|
it("keeps a sibling group intact while unwinding", () => {
|
|
const obj: Record<string, unknown> = { a: { b: { c: 1 } }, keep: { x: 1 } };
|
|
expect(deleteAtPath(obj, "a.b.c")).toBe(true);
|
|
expect(obj).toEqual({ keep: { x: 1 } });
|
|
});
|
|
|
|
it("leaves an empty array sibling alone", () => {
|
|
const obj: Record<string, unknown> = { a: { list: [], gone: 1 } };
|
|
expect(deleteAtPath(obj, "a.gone")).toBe(true);
|
|
expect(obj).toEqual({ a: { list: [] } });
|
|
});
|
|
|
|
it("empties the root object rather than removing it", () => {
|
|
const obj: Record<string, unknown> = { lens: { agent: "claude" } };
|
|
expect(deleteAtPath(obj, "lens.agent")).toBe(true);
|
|
expect(obj).toEqual({});
|
|
});
|
|
|
|
it("changes nothing when the path does not resolve", () => {
|
|
const obj: Record<string, unknown> = { ios: { deviceSet: "x" } };
|
|
expect(deleteAtPath(obj, "ios.missing")).toBe(false);
|
|
expect(deleteAtPath(obj, "nope.missing")).toBe(false);
|
|
expect(obj).toEqual({ ios: { deviceSet: "x" } });
|
|
});
|
|
});
|