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)
330 lines
13 KiB
TypeScript
330 lines
13 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
import * as fs from "node:fs";
|
|
import * as path from "node:path";
|
|
import * as os from "node:os";
|
|
import { config } from "../src/config.js";
|
|
|
|
// Real-filesystem integration test: drives the actual `config()` entry point
|
|
// against a sandboxed global home (HOME) and project cwd (a tmp dir with a
|
|
// `.git` marker so the project root resolves there). No fs mocks — this
|
|
// exercises the command → configuration-core → disk path end to end.
|
|
|
|
let homeDir: string;
|
|
let projectDir: string;
|
|
let originalHome: string | undefined;
|
|
let originalUserProfile: string | undefined;
|
|
let originalCwd: string;
|
|
let logSpy: ReturnType<typeof vi.spyOn>;
|
|
let errSpy: ReturnType<typeof vi.spyOn>;
|
|
|
|
class ExitError extends Error {
|
|
constructor(public code: number) {
|
|
super(`process.exit(${code})`);
|
|
}
|
|
}
|
|
|
|
beforeEach(() => {
|
|
homeDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "argent-cli-home-")));
|
|
projectDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "argent-cli-proj-")));
|
|
fs.mkdirSync(path.join(projectDir, ".git"), { recursive: true });
|
|
originalHome = process.env.HOME;
|
|
originalUserProfile = process.env.USERPROFILE;
|
|
originalCwd = process.cwd();
|
|
process.env.HOME = homeDir;
|
|
process.env.USERPROFILE = homeDir;
|
|
process.chdir(projectDir);
|
|
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
|
|
throw new ExitError(code ?? 0);
|
|
}) as never);
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.chdir(originalCwd);
|
|
if (originalHome === undefined) delete process.env.HOME;
|
|
else process.env.HOME = originalHome;
|
|
if (originalUserProfile === undefined) delete process.env.USERPROFILE;
|
|
else process.env.USERPROFILE = originalUserProfile;
|
|
vi.restoreAllMocks();
|
|
fs.rmSync(homeDir, { recursive: true, force: true });
|
|
fs.rmSync(projectDir, { recursive: true, force: true });
|
|
});
|
|
|
|
function output(): string {
|
|
return logSpy.mock.calls.map((c: unknown[]) => c.join(" ")).join("\n");
|
|
}
|
|
function errors(): string {
|
|
return errSpy.mock.calls.map((c: unknown[]) => c.join(" ")).join("\n");
|
|
}
|
|
|
|
describe("argent config — set/get across scopes", () => {
|
|
it("sets global and reads it back as the effective value", () => {
|
|
config(["set", "lens.agent", "claude"]);
|
|
logSpy.mockClear();
|
|
config(["get", "lens.agent"]);
|
|
expect(output()).toBe("claude");
|
|
// Landed in the global config file.
|
|
const globalCfg = JSON.parse(
|
|
fs.readFileSync(path.join(homeDir, ".argent", "config.json"), "utf8")
|
|
);
|
|
expect(globalCfg).toEqual({ lens: { agent: "claude" } });
|
|
});
|
|
|
|
it("project scope overrides global under prioritize-local", () => {
|
|
config(["set", "lens.agent", "claude", "--scope", "global"]);
|
|
config(["set", "lens.agent", "codex", "--scope", "project"]);
|
|
logSpy.mockClear();
|
|
config(["get", "lens.agent"]);
|
|
expect(output()).toBe("codex");
|
|
// The project file lives under the project root, not HOME.
|
|
const projCfg = JSON.parse(
|
|
fs.readFileSync(path.join(projectDir, ".argent", "config.json"), "utf8")
|
|
);
|
|
expect(projCfg).toEqual({ lens: { agent: "codex" } });
|
|
});
|
|
|
|
it("an additive array key (ios.additionalDeviceSets) unions the scopes instead of shadowing", () => {
|
|
config([
|
|
"set",
|
|
"ios.additionalDeviceSets",
|
|
'["/tmp/sets/a","/tmp/sets/b"]',
|
|
"--scope",
|
|
"global",
|
|
]);
|
|
config([
|
|
"set",
|
|
"ios.additionalDeviceSets",
|
|
'["/tmp/sets/b","/tmp/sets/c"]',
|
|
"--scope",
|
|
"project",
|
|
]);
|
|
logSpy.mockClear();
|
|
// Effective value = global baseline + project extras, deduplicated.
|
|
config(["get", "ios.additionalDeviceSets"]);
|
|
expect(output()).toBe('["/tmp/sets/a","/tmp/sets/b","/tmp/sets/c"]');
|
|
logSpy.mockClear();
|
|
// Each scope still stores (and reports) only its own entries.
|
|
config(["get", "ios.additionalDeviceSets", "--scope", "project"]);
|
|
expect(output()).toBe('["/tmp/sets/b","/tmp/sets/c"]');
|
|
});
|
|
|
|
it("get --scope shows only that scope's stored value", () => {
|
|
config(["set", "lens.agent", "claude", "--scope", "global"]);
|
|
config(["set", "lens.agent", "codex", "--scope", "project"]);
|
|
logSpy.mockClear();
|
|
config(["get", "lens.agent", "--scope", "global"]);
|
|
expect(output()).toBe("claude");
|
|
logSpy.mockClear();
|
|
config(["get", "lens.agent", "--scope", "project"]);
|
|
expect(output()).toBe("codex");
|
|
});
|
|
|
|
it("get prints (unset) for an absent value", () => {
|
|
config(["get", "lens.agent"]);
|
|
expect(output()).toBe("(unset)");
|
|
});
|
|
|
|
it("unset removes the value at a scope", () => {
|
|
config(["set", "lens.agent", "claude"]);
|
|
config(["unset", "lens.agent"]);
|
|
logSpy.mockClear();
|
|
config(["get", "lens.agent"]);
|
|
expect(output()).toBe("(unset)");
|
|
});
|
|
|
|
it("set reports the normalized value that was stored", () => {
|
|
config(["set", "lens.agent", " codex "]);
|
|
// The message echoes the trimmed value that actually landed on disk, not the
|
|
// raw CLI input. (Assert on the un-colorized value segment — the key is
|
|
// wrapped in bold ANSI codes when color is enabled, e.g. in CI.)
|
|
expect(output()).toContain("= codex (global).");
|
|
expect(output()).not.toContain(" codex ");
|
|
logSpy.mockClear();
|
|
config(["get", "lens.agent"]);
|
|
expect(output()).toBe("codex");
|
|
});
|
|
|
|
it("a no-op unset does not create the project config file", () => {
|
|
const projectCfg = path.join(projectDir, ".argent", "config.json");
|
|
config(["unset", "lens.agent", "--scope", "project"]);
|
|
expect(output()).toContain("was not set at project scope");
|
|
expect(fs.existsSync(projectCfg)).toBe(false);
|
|
});
|
|
|
|
it("a project-scope write echoes the resolved project root", () => {
|
|
config(["set", "lens.agent", "codex", "--scope", "project"]);
|
|
// (Assert on the un-colorized segment after the bold key.)
|
|
expect(output()).toContain(`(project: ${projectDir}).`);
|
|
// A real project (a `.git` marker at projectDir) is not degenerate — no warning.
|
|
expect(errors()).toBe("");
|
|
logSpy.mockClear();
|
|
config(["unset", "lens.agent", "--scope", "project"]);
|
|
expect(output()).toContain(`(project: ${projectDir}).`);
|
|
});
|
|
|
|
it("warns when project scope resolves to the home directory", () => {
|
|
// Materialize ~/.argent via a global write, then run from a non-project dir
|
|
// under HOME: the marker walk stops at ~/.argent, so "project" IS global.
|
|
config(["set", "lens.agent", "claude"]);
|
|
const nested = path.join(homeDir, "not-a-project");
|
|
fs.mkdirSync(nested);
|
|
process.chdir(nested);
|
|
logSpy.mockClear();
|
|
config(["set", "lens.agent", "codex", "--scope", "project"]);
|
|
expect(errors()).toContain("resolved to the home directory");
|
|
expect(output()).toContain(`(project: ${homeDir}).`);
|
|
// The write landed in the global config file.
|
|
const globalCfg = JSON.parse(
|
|
fs.readFileSync(path.join(homeDir, ".argent", "config.json"), "utf8")
|
|
);
|
|
expect(globalCfg).toEqual({ lens: { agent: "codex" } });
|
|
});
|
|
});
|
|
|
|
describe("argent config — validation & errors", () => {
|
|
it("rejects an unknown key with exit 2 and a hint", () => {
|
|
expect(() => config(["set", "does.not.exist", "1"])).toThrow(ExitError);
|
|
expect(errors()).toMatch(/Unknown configuration key/);
|
|
expect(errors()).toMatch(/argent config list/);
|
|
});
|
|
|
|
it("refuses to set a telemetry key, pointing at the dedicated command", () => {
|
|
expect(() => config(["set", "telemetry.enabled", "false"])).toThrow(ExitError);
|
|
expect(errors()).toMatch(/argent telemetry/);
|
|
});
|
|
|
|
it("rejects an invalid --scope", () => {
|
|
expect(() => config(["get", "lens.agent", "--scope", "bogus"])).toThrow(ExitError);
|
|
expect(errors()).toMatch(/--scope must be/);
|
|
});
|
|
|
|
it("rejects a value that fails the schema validator", () => {
|
|
// lens.agent must be a non-blank string; a JSON number is invalid.
|
|
expect(() => config(["set", "lens.agent", "42"])).toThrow(ExitError);
|
|
expect(errors()).toMatch(/Invalid value/);
|
|
});
|
|
});
|
|
|
|
describe("argent config — list & json", () => {
|
|
it("list --json reports every schema entry with per-scope values", () => {
|
|
config(["set", "lens.agent", "codex", "--scope", "project"]);
|
|
logSpy.mockClear();
|
|
config(["list", "--json"]);
|
|
const parsed = JSON.parse(output());
|
|
const lens = parsed.config.find((e: { key: string }) => e.key === "lens.agent");
|
|
expect(lens.project).toBe("codex");
|
|
expect(lens.effective).toBe("codex");
|
|
const telemetry = parsed.config.find((e: { key: string }) => e.key === "telemetry.enabled");
|
|
expect(telemetry.manageCommand).toBe("argent telemetry");
|
|
});
|
|
|
|
it("telemetry.enabled reads as true (opt-out default) on a fresh install", () => {
|
|
config(["get", "telemetry.enabled"]);
|
|
expect(output()).toBe("true");
|
|
logSpy.mockClear();
|
|
config(["list", "--json"]);
|
|
const parsed = JSON.parse(output());
|
|
const telemetry = parsed.config.find((e: { key: string }) => e.key === "telemetry.enabled");
|
|
expect(telemetry.effective).toBe(true);
|
|
});
|
|
|
|
it("get --json emits a structured record", () => {
|
|
config(["set", "lens.agent", "claude"]);
|
|
logSpy.mockClear();
|
|
config(["get", "lens.agent", "--json"]);
|
|
expect(JSON.parse(output())).toEqual({
|
|
key: "lens.agent",
|
|
scope: "effective",
|
|
value: "claude",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("argent config — a rejected value says what to type instead", () => {
|
|
it("names the expected shape and suggests the user's own value, correctly wrapped", () => {
|
|
// The reported case: a list-valued key given a single path.
|
|
expect(() =>
|
|
config([
|
|
"set",
|
|
"ios.additionalDeviceSets",
|
|
"~/Library/Developer/Radon/Devices",
|
|
"--scope",
|
|
"project",
|
|
])
|
|
).toThrow(ExitError);
|
|
|
|
const err = errors();
|
|
expect(err).toContain("expected an array of strings");
|
|
// The suggestion carries the path they typed, not a stand-in from the docs,
|
|
// and keeps them on the scope they asked for.
|
|
expect(err).toContain(
|
|
`Did you mean: argent config set ios.additionalDeviceSets '["~/Library/Developer/Radon/Devices"]' --scope project`
|
|
);
|
|
expect(err).toContain("argent config list");
|
|
});
|
|
|
|
it("suggests nothing beyond the shape when wrapping would not help", () => {
|
|
expect(() => config(["set", "lens.agent", "42"])).toThrow(ExitError);
|
|
|
|
const err = errors();
|
|
expect(err).toContain("expected a non-empty string");
|
|
expect(err).not.toContain("Did you mean");
|
|
expect(err).toContain("Example: argent config set lens.agent claude");
|
|
});
|
|
|
|
it("keeps the global default out of the suggestion when no scope was given", () => {
|
|
expect(() => config(["set", "ios.additionalDeviceSets", "/a"])).toThrow(ExitError);
|
|
|
|
expect(errors()).toContain(`Did you mean: argent config set ios.additionalDeviceSets '["/a"]'`);
|
|
expect(errors()).not.toContain("--scope");
|
|
});
|
|
|
|
it("shows each key's expected value in list", () => {
|
|
config(["list"]);
|
|
expect(output()).toContain("an array of strings");
|
|
expect(output()).toContain('e.g. ["~/DeviceSets/ci"]');
|
|
});
|
|
|
|
it("carries the expected shape through list --json", () => {
|
|
config(["list", "--json"]);
|
|
const parsed = JSON.parse(output());
|
|
const entry = parsed.config.find((e: { key: string }) => e.key === "ios.additionalDeviceSets");
|
|
expect(entry.expected).toBe("an array of strings");
|
|
expect(entry.example).toBe('["~/DeviceSets/ci"]');
|
|
});
|
|
});
|
|
|
|
describe("argent config — unset leaves no empty parent behind", () => {
|
|
const projectConfig = () =>
|
|
fs.readFileSync(path.join(projectDir, ".argent", "config.json"), "utf8");
|
|
|
|
it("restores the document rather than leaving an empty group", () => {
|
|
config(["set", "ios.additionalDeviceSets", '["/a"]', "--scope", "project"]);
|
|
config(["unset", "ios.additionalDeviceSets", "--scope", "project"]);
|
|
|
|
expect(JSON.parse(projectConfig())).toEqual({});
|
|
});
|
|
|
|
it("keeps sibling keys and their groups", () => {
|
|
config(["set", "lens.agent", "codex", "--scope", "project"]);
|
|
config(["set", "ios.additionalDeviceSets", '["/a"]', "--scope", "project"]);
|
|
config(["unset", "ios.additionalDeviceSets", "--scope", "project"]);
|
|
|
|
expect(JSON.parse(projectConfig())).toEqual({ lens: { agent: "codex" } });
|
|
});
|
|
|
|
it("does not rewrite a file to tidy an empty group it did not create", () => {
|
|
// A no-op unset must not touch the file at all — that guarantee is why the
|
|
// fast path exists, and tidying would quietly break it.
|
|
const dir = path.join(projectDir, ".argent");
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
const file = path.join(dir, "config.json");
|
|
fs.writeFileSync(file, '{"ios":{}}');
|
|
|
|
config(["unset", "ios.additionalDeviceSets", "--scope", "project"]);
|
|
|
|
expect(fs.readFileSync(file, "utf8")).toBe('{"ios":{}}');
|
|
});
|
|
});
|