// `sd config` — schema-driven read/write surface for `.seeds/config.yaml`.
//
// Designed as warren V2's wire contract (warren ROADMAP R-10):
// - `sd config schema --json` emits the JSON Schema (warren auto-renders a form)
// - `sd config show [--path
]` reads the current value
// - `sd config set ` validates + writes atomically (YAML-parsed)
// - `sd config unset ` removes a value
//
// Writes hold the config.yaml advisory lock; mutations validate the entire
// post-write file against configSchema() before persisting, so a partial set
// that would leave the file inconsistent is rejected.
import { randomBytes } from "node:crypto";
import { renameSync } from "node:fs";
import { join } from "node:path";
import { Command } from "commander";
import { findSeedsDir } from "../config.ts";
import { configSchema } from "../config-schema.ts";
import { accent, muted, outputJson, printSuccess, writeStdout } from "../output.ts";
import { withLock } from "../store.ts";
import { CONFIG_FILE } from "../types.ts";
import { compileSchema } from "../validation.ts";
import { parseScalarOrFlow, parseYaml, stringifyYaml, type YamlValue } from "../yaml.ts";
function configPath(seedsDir: string): string {
return join(seedsDir, CONFIG_FILE);
}
function pathParts(path: string): string[] {
const parts = path.split(".").filter((p) => p.length > 0);
if (parts.length === 0) throw new Error("Config path must be non-empty");
return parts;
}
function describeType(v: unknown): string {
if (v === null) return "null";
if (Array.isArray(v)) return "array";
return typeof v;
}
function getAtPath(data: YamlValue, parts: string[]): YamlValue | undefined {
let cur: YamlValue = data;
for (const p of parts) {
if (cur === null || typeof cur !== "object" || Array.isArray(cur)) return undefined;
cur = (cur as Record)[p] as YamlValue;
if (cur === undefined) return undefined;
}
return cur;
}
function setAtPath(data: Record, parts: string[], value: YamlValue): void {
let cur: Record = data;
for (let i = 0; i < parts.length - 1; i++) {
const p = parts[i];
if (p === undefined) continue;
const nxt = cur[p];
if (nxt === undefined || nxt === null) {
const empty: Record = {};
cur[p] = empty;
cur = empty;
} else if (typeof nxt === "object" && !Array.isArray(nxt)) {
cur = nxt as Record;
} else {
const soFar = parts.slice(0, i + 1).join(".");
throw new Error(`Cannot set: '${soFar}' is not an object (got: ${describeType(nxt)})`);
}
}
const last = parts[parts.length - 1];
if (last !== undefined) cur[last] = value;
}
function unsetAtPath(data: Record, parts: string[]): boolean {
let cur: Record = data;
for (let i = 0; i < parts.length - 1; i++) {
const p = parts[i];
if (p === undefined) continue;
const nxt = cur[p];
if (nxt === null || typeof nxt !== "object" || Array.isArray(nxt)) return false;
cur = nxt as Record;
}
const last = parts[parts.length - 1];
if (last === undefined) return false;
if (!(last in cur)) return false;
delete cur[last];
return true;
}
function validateConfig(data: unknown): void {
// Strip the meta-schema URI: configSchema() advertises draft 2020-12 for
// downstream consumers (warren's UI), but our shared compileSchema runs
// AJV in default (draft-07) mode and rejects unknown $schema URIs.
const { $schema: _meta, ...schema } = configSchema();
const validator = compileSchema(schema);
const result = validator(data);
if (!result.valid) {
const lines = result.diff.errors.map((e) => ` ${e.path || "(root)"}: ${e.fix}`);
throw new Error(`Config validation failed:\n${lines.join("\n")}`);
}
}
async function readRawConfig(seedsDir: string): Promise> {
const file = Bun.file(configPath(seedsDir));
if (!(await file.exists())) {
throw new Error(`Config not found at ${configPath(seedsDir)}`);
}
const content = await file.text();
return parseYaml(content);
}
async function writeRawConfig(seedsDir: string, data: Record): Promise {
const path = configPath(seedsDir);
const tmpPath = `${path}.tmp.${randomBytes(4).toString("hex")}`;
await Bun.write(tmpPath, stringifyYaml(data));
renameSync(tmpPath, path);
}
function displayValue(v: YamlValue): string {
if (typeof v === "string") return v;
return JSON.stringify(v);
}
async function runSchema(jsonMode: boolean): Promise {
const schema = configSchema();
if (jsonMode) {
await writeStdout(`${JSON.stringify(schema)}\n`);
return;
}
await writeStdout(`${JSON.stringify(schema, null, 2)}\n`);
}
async function runShow(pathArg: string | undefined, jsonMode: boolean): Promise {
const dir = await findSeedsDir();
const raw = await readRawConfig(dir);
if (pathArg) {
const parts = pathParts(pathArg);
const value = getAtPath(raw, parts);
if (value === undefined) {
throw new Error(`Path not found: ${pathArg}`);
}
if (jsonMode) {
await outputJson({ success: true, command: "config show", path: pathArg, value });
} else if (typeof value === "object" && value !== null && !Array.isArray(value)) {
process.stdout.write(stringifyYaml(value as Record));
} else if (typeof value === "string") {
console.log(value);
} else {
await writeStdout(`${JSON.stringify(value)}\n`);
}
return;
}
if (jsonMode) {
await outputJson({ success: true, command: "config show", config: raw });
} else {
process.stdout.write(stringifyYaml(raw));
}
}
async function runSet(pathArg: string, valueArg: string, jsonMode: boolean): Promise {
const dir = await findSeedsDir();
const parts = pathParts(pathArg);
const value = parseScalarOrFlow(valueArg);
await withLock(configPath(dir), async () => {
const raw = await readRawConfig(dir);
setAtPath(raw, parts, value);
validateConfig(raw);
await writeRawConfig(dir, raw);
});
if (jsonMode) {
await outputJson({ success: true, command: "config set", path: pathArg, value });
} else {
printSuccess(`Set ${accent(pathArg)} ${muted("=")} ${displayValue(value)}`);
}
}
async function runUnset(pathArg: string, jsonMode: boolean): Promise {
const dir = await findSeedsDir();
const parts = pathParts(pathArg);
let removed = false;
await withLock(configPath(dir), async () => {
const raw = await readRawConfig(dir);
removed = unsetAtPath(raw, parts);
if (!removed) return;
validateConfig(raw);
await writeRawConfig(dir, raw);
});
if (jsonMode) {
await outputJson({ success: true, command: "config unset", path: pathArg, removed });
} else if (removed) {
printSuccess(`Unset ${accent(pathArg)}`);
} else {
console.log(muted(`No such path: ${pathArg}`));
}
}
export function register(program: Command): void {
const config = new Command("config").description("Read, write, and inspect .seeds/config.yaml");
config
.command("schema")
.description("Emit the JSON Schema for .seeds/config.yaml")
.option("--json", "Compact single-line JSON (default is pretty-printed)")
.action(async (opts: { json?: boolean }) => {
await runSchema(opts.json === true);
});
config
.command("show")
.description("Print the current config (or a value at --path)")
.option("--path ", "Dot-path to read (e.g. plan_templates.feature.sections.context)")
.option("--json", "Output as JSON")
.action(async (opts: { path?: string; json?: boolean }) => {
await runShow(opts.path, opts.json === true);
});
config
.command("set ")
.description("Set a config value at ; is YAML-parsed")
.option("--json", "Output as JSON")
.action(async (path: string, value: string, opts: { json?: boolean }) => {
await runSet(path, value, opts.json === true);
});
config
.command("unset ")
.description("Remove the config value at ")
.option("--json", "Output as JSON")
.action(async (path: string, opts: { json?: boolean }) => {
await runUnset(path, opts.json === true);
});
program.addCommand(config);
}