mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 17:31:56 -10:00
feat(settings): user-settings v0 — typed settings + workspace scaffold
PRD: slices/user-settings-v0/IMPLEMENTATION-PRD.md.
Closes the "UI works on founder's machine vs npm-shipped install
elsewhere" gap UEP introduced via env-var workarounds.
CLI (Items 1, 5)
- ConfigStore extended with 7 new keys: workspace.{root,slices_root,
steering_path,field_notes_root,specs_root}, files.allowlist,
progress.scan_roots. Existing 5 daemon/db/transcripts keys preserved
unchanged. ENV_MAP, KEY_TO_PATH, DEFAULTS extended in lockstep.
- Per-subdir defaults derive from workspace.root cascade; per-subdir
override > workspace.root cascade > built-in default.
- New ResolvedSetting + resolveWithSource() / resolveAllWithSource()
helpers expose value/source/default for honest provenance.
- reset(key?) supports both whole-file (delete) and per-key reset.
- parseNamedPairs decoder for files.allowlist + progress.scan_roots
(preserves UEP "name:/abs/path,..." env-var format).
- rig config bare action: --json structured RiggedConfig (preserves
legacy shape); --json --with-source emits per-key provenance.
- rig config get --show-source / --json adds source-aware reads.
- rig config reset [key] adds per-key reset.
Init-workspace verb (Item 2)
- New rig config init-workspace [--root] [--force] [--dry-run] [--json].
- Creates 5 subdirs: slices/ steering/ progress/ field-notes/ specs/.
- Drops one-paragraph README.md per subdir + steering/STEERING.md
placeholder. Idempotent. --force overwrites scaffolded files but
never deletes operator content under directories. Operator-explicit
per founder safety stop — never auto-runs.
UEP env-var graduation (Item 4)
- OPENRIG_FILES_ALLOWLIST + OPENRIG_PROGRESS_SCAN_ROOTS become the env
override for the new typed keys (no breaking change). decodeAllowlist
+ decodeProgressScanRoots factor the parsing out so settings-resolved
values use the same path. Daemon startup reads via SettingsStore
(env > settings file > empty) instead of env-only.
Daemon (Items 3, 4, 6 backend)
- New SettingsStore mirrors CLI ConfigStore for daemon-side reads +
writes (no cross-package dep; constants kept parallel).
- New /api/config routes: GET (all), GET /:key, POST /:key (set),
DELETE /:key (reset), POST /init-workspace. UI surface only —
agent-edit stays on CLI shell-out per founder dialog.
- startup.ts wires SettingsStore + decodes raw allowlist/scan-roots
strings to feed UEP routes + ProgressIndexer.
UI (Item 6)
- New System drawer Settings tab alongside existing Log + Status.
- New useSettings / useSetSetting / useResetSetting / useInitWorkspace
hooks against /api/config.
- New SettingsTab component: Workspace section (root + 5 per-subdir),
Files section (allowlist), Progress section (scan_roots), legacy
daemon/db/transcripts sections. Each row: value + source badge +
default + Edit/Save/Reset; Reset hidden for source=default. Init
Workspace button calls the daemon route.
Skill (Item 8)
- New daemon/specs/agents/shared/skills/core/openrig-user-settings/
SKILL.md teaches agents the canonical CLI verbs, common operator
requests, and the "never write the JSON file directly" boundary.
Loaded by Claude Code / Codex via existing skills mechanism.
Cross-CLI-version drift (Item 9)
- New keys remain unset on older daemons; UI Settings tab shows current
values via /api/config which is feature-gated by the new keys'
presence in resolved output. Existing 5-key behavior preserved.
NO new SQLite schema, migrations, or event types per PRD.
NO auto-scaffold per founder safety stop.
Tests
- cli: 26 new in config-store-extended.test.ts; existing 800 preserved
(840+ passing).
- daemon: 12 new in user-settings-store.test.ts + 9 in
user-settings-routes.test.ts; existing 2867 preserved.
- ui: 5 new in settings-tab.test.tsx; existing 525 preserved.
- tsc clean across all three packages.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
1 parent
4f48164bd8
commit
be2a239441
17 files changed
+2237
-90
No files matched your search
@@ -0,0 +1,160 @@
|
||||
// User Settings v0 — `rig config init-workspace` operator-explicit scaffolding.
|
||||
//
|
||||
// Creates the 5-subdir default workspace at ~/.openrig/workspace/ (or a
|
||||
// caller-supplied --root). Operator-explicit per founder safety stop:
|
||||
// never auto-runs on `rig setup` / `rig daemon start` / first `rig up`.
|
||||
//
|
||||
// Behavior:
|
||||
// - Reads workspace.root setting (default ~/.openrig/workspace/).
|
||||
// - Creates the 5 canonical subdirs: slices/ steering/ progress/ field-notes/ specs/.
|
||||
// - Drops a 1-paragraph README.md in each subdir.
|
||||
// - Drops a placeholder steering/STEERING.md.
|
||||
// - --dry-run: report what would be created without acting.
|
||||
// - --force: overwrite existing files (NOT directories — never deletes
|
||||
// operator content).
|
||||
// - Idempotent without --force: existing-dir + existing-readme is a no-op.
|
||||
|
||||
import { Command } from "commander";
|
||||
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { ConfigStore } from "../config-store.js";
|
||||
|
||||
export interface InitWorkspaceOpts {
|
||||
root?: string;
|
||||
force?: boolean;
|
||||
dryRun?: boolean;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
export interface InitWorkspaceResult {
|
||||
root: string;
|
||||
/** True when this run would (or did) create the root dir; false if root existed. */
|
||||
rootCreated: boolean;
|
||||
subdirs: Array<{ name: string; path: string; created: boolean }>;
|
||||
files: Array<{ relPath: string; absPath: string; created: boolean; skipped: "exists" | null }>;
|
||||
/** True when --dry-run; nothing was actually written. */
|
||||
dryRun: boolean;
|
||||
}
|
||||
|
||||
const SUBDIRS = ["slices", "steering", "progress", "field-notes", "specs"] as const;
|
||||
|
||||
function readmeContent(subdir: string): string {
|
||||
switch (subdir) {
|
||||
case "slices":
|
||||
return "# slices\n\nYour slice authoring workspace. OpenRig's Slice Story View browses\nthis directory by default. Each slice lives in its own subdirectory\nwith README.md + IMPLEMENTATION-PRD.md + dogfood-evidence.\n";
|
||||
case "steering":
|
||||
return "# steering\n\nThe `STEERING.md` priority-stack lives here. OpenRig's Steering surface\ncomposes the 6-panel view (priority stack, roadmap rail, in-motion,\nlane rails, loop state, health gates) from this file.\n";
|
||||
case "progress":
|
||||
return "# progress\n\nPROGRESS.md tree. OpenRig's Progress browse view scans this directory\nrecursively (max depth 6) for PROGRESS.md files and renders them as a\nhierarchical tree.\n";
|
||||
case "field-notes":
|
||||
return "# field-notes\n\nOperator field notes. Free-form markdown notes from your daily work.\nOpenRig's UI surfaces these alongside slices for context.\n";
|
||||
case "specs":
|
||||
return "# specs\n\nWorkspace specs (rig specs / agent specs / workflow specs / context\npacks). OpenRig's Spec Library browses this directory alongside the\ndaemon's bundled built-in specs.\n";
|
||||
default:
|
||||
return `# ${subdir}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
const STEERING_PLACEHOLDER = `---
|
||||
title: Priority Stack
|
||||
status: placeholder
|
||||
---
|
||||
|
||||
# OpenRig Priority Stack
|
||||
|
||||
This file is a placeholder created by \`rig config init-workspace\`. Edit
|
||||
it to record your top 3 priorities. OpenRig's Steering surface composes
|
||||
the 6-panel view from this file alongside your PROGRESS.md tree.
|
||||
|
||||
## Top 3
|
||||
|
||||
1. <priority one>
|
||||
2. <priority two>
|
||||
3. <priority three>
|
||||
|
||||
## In Motion
|
||||
|
||||
(Active slices land here as you push them through the priority rail.)
|
||||
|
||||
## Loop State
|
||||
|
||||
(Health gates + loop diagnostics land here.)
|
||||
`;
|
||||
|
||||
export function initWorkspaceCommand(configPath?: string): Command {
|
||||
const cmd = new Command("init-workspace")
|
||||
.description("Scaffold the default workspace at ~/.openrig/workspace/ with 5 subdirs")
|
||||
.option("--root <path>", "Override workspace root (default: workspace.root setting)")
|
||||
.option("--force", "Overwrite existing scaffolded files (does NOT remove directories)")
|
||||
.option("--dry-run", "Show what would be created without writing")
|
||||
.option("--json", "JSON output")
|
||||
.action((opts: InitWorkspaceOpts) => {
|
||||
try {
|
||||
const result = runInitWorkspace({ ...opts, configPath });
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
if (result.dryRun) console.log(`(dry-run) workspace root: ${result.root}`);
|
||||
else console.log(`workspace root: ${result.root}`);
|
||||
for (const sub of result.subdirs) {
|
||||
console.log(` ${sub.created ? "+" : " "} ${sub.name}/`);
|
||||
}
|
||||
for (const f of result.files) {
|
||||
console.log(` ${f.created ? "+" : " "} ${f.relPath}${f.skipped ? ` (skipped: ${f.skipped})` : ""}`);
|
||||
}
|
||||
if (result.dryRun) console.log("(dry-run; no files written.)");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error((err as Error).message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
export function runInitWorkspace(opts: InitWorkspaceOpts & { configPath?: string }): InitWorkspaceResult {
|
||||
const store = new ConfigStore(opts.configPath);
|
||||
const root = opts.root ?? (store.get("workspace.root") as string);
|
||||
const dryRun = !!opts.dryRun;
|
||||
const force = !!opts.force;
|
||||
|
||||
const rootExists = existsSync(root);
|
||||
const result: InitWorkspaceResult = {
|
||||
root,
|
||||
rootCreated: !rootExists,
|
||||
subdirs: [],
|
||||
files: [],
|
||||
dryRun,
|
||||
};
|
||||
|
||||
if (!rootExists && !dryRun) mkdirSync(root, { recursive: true });
|
||||
|
||||
for (const sub of SUBDIRS) {
|
||||
const subPath = join(root, sub);
|
||||
const subExists = existsSync(subPath);
|
||||
if (!subExists && !dryRun) mkdirSync(subPath, { recursive: true });
|
||||
result.subdirs.push({ name: sub, path: subPath, created: !subExists });
|
||||
|
||||
// README.md per subdir.
|
||||
const readmePath = join(subPath, "README.md");
|
||||
const readmeExists = existsSync(readmePath);
|
||||
if (readmeExists && !force) {
|
||||
result.files.push({ relPath: `${sub}/README.md`, absPath: readmePath, created: false, skipped: "exists" });
|
||||
} else {
|
||||
if (!dryRun) writeFileSync(readmePath, readmeContent(sub), "utf-8");
|
||||
result.files.push({ relPath: `${sub}/README.md`, absPath: readmePath, created: true, skipped: null });
|
||||
}
|
||||
}
|
||||
|
||||
// steering/STEERING.md placeholder (dropped at the steering subdir).
|
||||
const steeringPath = join(root, "steering", "STEERING.md");
|
||||
const steeringExists = existsSync(steeringPath);
|
||||
if (steeringExists && !force) {
|
||||
result.files.push({ relPath: "steering/STEERING.md", absPath: steeringPath, created: false, skipped: "exists" });
|
||||
} else {
|
||||
if (!dryRun) writeFileSync(steeringPath, STEERING_PLACEHOLDER, "utf-8");
|
||||
result.files.push({ relPath: "steering/STEERING.md", absPath: steeringPath, created: true, skipped: null });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -1,33 +1,69 @@
|
||||
import { Command } from "commander";
|
||||
import { ConfigStore } from "../config-store.js";
|
||||
import { ConfigStore, VALID_KEYS, type ResolvedSetting, type ValidKey } from "../config-store.js";
|
||||
import { initWorkspaceCommand } from "./config-init-workspace.js";
|
||||
|
||||
function formatRow(key: string, value: unknown): string {
|
||||
return `${key.padEnd(28)} ${value}`;
|
||||
}
|
||||
|
||||
function summarizeSettings(store: ConfigStore): Record<ValidKey, ResolvedSetting> {
|
||||
return store.resolveAllWithSource();
|
||||
}
|
||||
|
||||
export function configCommand(configPath?: string): Command {
|
||||
const cmd = new Command("config").description("Inspect and change OpenRig configuration");
|
||||
const store = new ConfigStore(configPath);
|
||||
|
||||
cmd
|
||||
.option("--json", "JSON output for agents")
|
||||
.option("--json", "JSON output for agents (resolved RiggedConfig)")
|
||||
.option("--with-source", "Include source/default per key (honest provenance)")
|
||||
.addHelpText("after", `
|
||||
Examples:
|
||||
rig config # show all resolved config
|
||||
rig config --json # JSON output
|
||||
rig config get daemon.port # read a single key
|
||||
rig config set daemon.port 7434 # change a value
|
||||
rig config reset # delete config file, revert to defaults
|
||||
rig config # show all resolved config
|
||||
rig config --json # JSON RiggedConfig (structured)
|
||||
rig config --json --with-source # JSON per-key with source + default
|
||||
rig config get daemon.port # read a single key
|
||||
rig config get workspace.slices_root --show-source
|
||||
rig config set daemon.port 7434 # change a value
|
||||
rig config set workspace.slices_root /path # configure a workspace path
|
||||
rig config reset # delete config file, revert all to defaults
|
||||
rig config reset workspace.slices_root # clear one key, revert to default
|
||||
rig config init-workspace # scaffold ~/.openrig/workspace/ with 5 subdirs
|
||||
|
||||
Keys:
|
||||
daemon.* port, host
|
||||
db.path
|
||||
transcripts.* enabled, path
|
||||
workspace.* root, slices_root, steering_path, field_notes_root, specs_root
|
||||
files.allowlist name:/abs/path,name:/abs/path
|
||||
progress.scan_roots name:/abs/path,name:/abs/path
|
||||
|
||||
Keys: daemon.port, daemon.host, db.path, transcripts.enabled, transcripts.path
|
||||
Precedence: CLI flag > environment variable > config file > default`)
|
||||
.action((opts: { json?: boolean }) => {
|
||||
.action((opts: { json?: boolean; withSource?: boolean }) => {
|
||||
try {
|
||||
if (opts.withSource) {
|
||||
const all = summarizeSettings(store);
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(all, null, 2));
|
||||
} else {
|
||||
for (const key of VALID_KEYS) {
|
||||
const r = all[key];
|
||||
console.log(formatRow(key, `${r.value} (source: ${r.source})`));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Default: structured RiggedConfig output (preserves pre-v0
|
||||
// bare-action shape so existing scripts / tests keep working).
|
||||
const config = store.resolve();
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(config, null, 2));
|
||||
} else {
|
||||
console.log(`daemon.port ${config.daemon.port}`);
|
||||
console.log(`daemon.host ${config.daemon.host}`);
|
||||
console.log(`db.path ${config.db.path}`);
|
||||
console.log(`transcripts.enabled ${config.transcripts.enabled}`);
|
||||
console.log(`transcripts.path ${config.transcripts.path}`);
|
||||
const all = summarizeSettings(store);
|
||||
for (const key of VALID_KEYS) {
|
||||
const r = all[key];
|
||||
console.log(formatRow(key, `${r.value} (source: ${r.source})`));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error((err as Error).message);
|
||||
@@ -37,9 +73,20 @@ Precedence: CLI flag > environment variable > config file > default`)
|
||||
|
||||
const getCmd = new Command("get")
|
||||
.argument("<key>", "Config key (e.g. daemon.port)")
|
||||
.option("--json", "JSON output with value + source + default")
|
||||
.option("--show-source", "Print value + source on a single line")
|
||||
.description("Read a single config value")
|
||||
.action((key: string) => {
|
||||
.action((key: string, opts: { json?: boolean; showSource?: boolean }) => {
|
||||
try {
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(store.resolveWithSource(key), null, 2));
|
||||
return;
|
||||
}
|
||||
if (opts.showSource) {
|
||||
const r = store.resolveWithSource(key);
|
||||
console.log(`${r.value}\t(source: ${r.source})`);
|
||||
return;
|
||||
}
|
||||
console.log(String(store.get(key)));
|
||||
} catch (err) {
|
||||
console.error((err as Error).message);
|
||||
@@ -62,15 +109,26 @@ Precedence: CLI flag > environment variable > config file > default`)
|
||||
});
|
||||
|
||||
const resetCmd = new Command("reset")
|
||||
.description("Delete config file and revert to defaults")
|
||||
.action(() => {
|
||||
store.reset();
|
||||
console.log("Config reset to defaults.");
|
||||
.argument("[key]", "Optional config key to reset (omit to reset entire file)")
|
||||
.description("Clear a config override (or delete the entire file when no key given)")
|
||||
.action((key: string | undefined) => {
|
||||
try {
|
||||
store.reset(key);
|
||||
if (key) {
|
||||
console.log(`${key} reset to default (${store.get(key)}).`);
|
||||
} else {
|
||||
console.log("Config reset to defaults.");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error((err as Error).message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
|
||||
cmd.addCommand(getCmd);
|
||||
cmd.addCommand(setCmd);
|
||||
cmd.addCommand(resetCmd);
|
||||
cmd.addCommand(initWorkspaceCommand(configPath));
|
||||
|
||||
return cmd;
|
||||
}
|
||||
@@ -6,42 +6,112 @@ import {
|
||||
readOpenRigEnv,
|
||||
} from "./openrig-compat.js";
|
||||
|
||||
// User Settings v0 — extends the existing ConfigStore with new namespaces
|
||||
// (workspace.*, files.*, progress.*) without changing the existing 5
|
||||
// daemon/db/transcripts keys' behavior. Storage stays single-source-of-
|
||||
// truth at ~/.openrig/config.json. Resolution stays env > file > default.
|
||||
|
||||
export interface RiggedConfig {
|
||||
daemon: { port: number; host: string };
|
||||
db: { path: string };
|
||||
transcripts: { enabled: boolean; path: string };
|
||||
// User Settings v0 — workspace paths.
|
||||
workspace: {
|
||||
root: string;
|
||||
slicesRoot: string;
|
||||
steeringPath: string;
|
||||
fieldNotesRoot: string;
|
||||
specsRoot: string;
|
||||
};
|
||||
// User Settings v0 — UEP env-var graduation.
|
||||
// Values are stored as raw named-pair strings ("name:/abs/path,...")
|
||||
// matching the OPENRIG_FILES_ALLOWLIST / OPENRIG_PROGRESS_SCAN_ROOTS
|
||||
// formats; decoded helpers (parseNamedPairs) turn them into structured
|
||||
// arrays.
|
||||
files: {
|
||||
allowlist: string;
|
||||
};
|
||||
progress: {
|
||||
scanRoots: string;
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULTS: RiggedConfig = {
|
||||
const DEFAULT_WORKSPACE_ROOT = getDefaultOpenRigPath("workspace");
|
||||
|
||||
const DEFAULTS = {
|
||||
daemon: { port: 7433, host: "127.0.0.1" },
|
||||
db: { path: getDefaultOpenRigPath("openrig.sqlite") },
|
||||
transcripts: { enabled: true, path: getDefaultOpenRigPath("transcripts") },
|
||||
};
|
||||
workspace: {
|
||||
root: DEFAULT_WORKSPACE_ROOT,
|
||||
slicesRoot: "",
|
||||
steeringPath: "",
|
||||
fieldNotesRoot: "",
|
||||
specsRoot: "",
|
||||
},
|
||||
files: { allowlist: "" },
|
||||
progress: { scanRoots: "" },
|
||||
} as const;
|
||||
|
||||
const VALID_KEYS = [
|
||||
export const VALID_KEYS = [
|
||||
"daemon.port",
|
||||
"daemon.host",
|
||||
"db.path",
|
||||
"transcripts.enabled",
|
||||
"transcripts.path",
|
||||
"workspace.root",
|
||||
"workspace.slices_root",
|
||||
"workspace.steering_path",
|
||||
"workspace.field_notes_root",
|
||||
"workspace.specs_root",
|
||||
"files.allowlist",
|
||||
"progress.scan_roots",
|
||||
] as const;
|
||||
|
||||
type ValidKey = typeof VALID_KEYS[number];
|
||||
export type ValidKey = typeof VALID_KEYS[number];
|
||||
|
||||
const ENV_MAP: Record<ValidKey, { primary: string; legacy: string }> = {
|
||||
export const ENV_MAP: Record<ValidKey, { primary: string; legacy: string }> = {
|
||||
"daemon.port": { primary: "OPENRIG_PORT", legacy: "RIGGED_PORT" },
|
||||
"daemon.host": { primary: "OPENRIG_HOST", legacy: "RIGGED_HOST" },
|
||||
"db.path": { primary: "OPENRIG_DB", legacy: "RIGGED_DB" },
|
||||
"transcripts.enabled": { primary: "OPENRIG_TRANSCRIPTS_ENABLED", legacy: "RIGGED_TRANSCRIPTS_ENABLED" },
|
||||
"transcripts.path": { primary: "OPENRIG_TRANSCRIPTS_PATH", legacy: "RIGGED_TRANSCRIPTS_PATH" },
|
||||
"workspace.root": { primary: "OPENRIG_WORKSPACE_ROOT", legacy: "RIGGED_WORKSPACE_ROOT" },
|
||||
"workspace.slices_root": { primary: "OPENRIG_WORKSPACE_SLICES_ROOT", legacy: "RIGGED_WORKSPACE_SLICES_ROOT" },
|
||||
"workspace.steering_path": { primary: "OPENRIG_WORKSPACE_STEERING_PATH", legacy: "RIGGED_WORKSPACE_STEERING_PATH" },
|
||||
"workspace.field_notes_root": { primary: "OPENRIG_WORKSPACE_FIELD_NOTES_ROOT", legacy: "RIGGED_WORKSPACE_FIELD_NOTES_ROOT" },
|
||||
"workspace.specs_root": { primary: "OPENRIG_WORKSPACE_SPECS_ROOT", legacy: "RIGGED_WORKSPACE_SPECS_ROOT" },
|
||||
// UEP env-var graduation: existing OPENRIG_FILES_ALLOWLIST /
|
||||
// OPENRIG_PROGRESS_SCAN_ROOTS become the env override for the new
|
||||
// typed keys (no breaking change).
|
||||
"files.allowlist": { primary: "OPENRIG_FILES_ALLOWLIST", legacy: "RIGGED_FILES_ALLOWLIST" },
|
||||
"progress.scan_roots": { primary: "OPENRIG_PROGRESS_SCAN_ROOTS", legacy: "RIGGED_PROGRESS_SCAN_ROOTS" },
|
||||
};
|
||||
|
||||
// Maps dotted-string config keys to the camelCase RiggedConfig path.
|
||||
// Workspace per-subdir keys are stored on disk as `workspace.slices_root`
|
||||
// (snake) and exposed in RiggedConfig as `workspace.slicesRoot` (camel)
|
||||
// to match TS conventions.
|
||||
const KEY_TO_PATH: Record<ValidKey, string[]> = {
|
||||
"daemon.port": ["daemon", "port"],
|
||||
"daemon.host": ["daemon", "host"],
|
||||
"db.path": ["db", "path"],
|
||||
"transcripts.enabled": ["transcripts", "enabled"],
|
||||
"transcripts.path": ["transcripts", "path"],
|
||||
"workspace.root": ["workspace", "root"],
|
||||
"workspace.slices_root": ["workspace", "slicesRoot"],
|
||||
"workspace.steering_path": ["workspace", "steeringPath"],
|
||||
"workspace.field_notes_root": ["workspace", "fieldNotesRoot"],
|
||||
"workspace.specs_root": ["workspace", "specsRoot"],
|
||||
"files.allowlist": ["files", "allowlist"],
|
||||
"progress.scan_roots": ["progress", "scanRoots"],
|
||||
};
|
||||
|
||||
function isValidKey(key: string): key is ValidKey {
|
||||
return (VALID_KEYS as readonly string[]).includes(key);
|
||||
}
|
||||
|
||||
function getNestedValue(obj: Record<string, unknown>, key: string): unknown {
|
||||
const parts = key.split(".");
|
||||
function getNestedValue(obj: Record<string, unknown>, parts: string[]): unknown {
|
||||
let current: unknown = obj;
|
||||
for (const part of parts) {
|
||||
if (current == null || typeof current !== "object") return undefined;
|
||||
@@ -50,8 +120,7 @@ function getNestedValue(obj: Record<string, unknown>, key: string): unknown {
|
||||
return current;
|
||||
}
|
||||
|
||||
function setNestedValue(obj: Record<string, unknown>, key: string, value: unknown): void {
|
||||
const parts = key.split(".");
|
||||
function setNestedValue(obj: Record<string, unknown>, parts: string[], value: unknown): void {
|
||||
let current = obj;
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
const part = parts[i]!;
|
||||
@@ -63,12 +132,35 @@ function setNestedValue(obj: Record<string, unknown>, key: string, value: unknow
|
||||
current[parts[parts.length - 1]!] = value;
|
||||
}
|
||||
|
||||
function getDefaultValue(key: ValidKey): string | number | boolean {
|
||||
return getNestedValue(DEFAULTS as unknown as Record<string, unknown>, key) as string | number | boolean;
|
||||
// Per-subdir defaults derived from workspace.root. v0 uses canonical
|
||||
// subdir names per founder dialog (slices/ steering/ progress/ field-notes/
|
||||
// specs/). Steering is a FILE path (STEERING.md) under the steering subdir.
|
||||
export function deriveWorkspaceDefault(key: ValidKey, workspaceRoot: string): string {
|
||||
switch (key) {
|
||||
case "workspace.slices_root": return join(workspaceRoot, "slices");
|
||||
case "workspace.steering_path": return join(workspaceRoot, "steering", "STEERING.md");
|
||||
case "workspace.field_notes_root": return join(workspaceRoot, "field-notes");
|
||||
case "workspace.specs_root": return join(workspaceRoot, "specs");
|
||||
default: return "";
|
||||
}
|
||||
}
|
||||
|
||||
function coerceValue(key: ValidKey, raw: string): string | number | boolean {
|
||||
const defaultVal = getDefaultValue(key);
|
||||
const WORKSPACE_DERIVED_KEYS: ReadonlySet<ValidKey> = new Set([
|
||||
"workspace.slices_root",
|
||||
"workspace.steering_path",
|
||||
"workspace.field_notes_root",
|
||||
"workspace.specs_root",
|
||||
]);
|
||||
|
||||
function getDefaultValue(key: ValidKey, workspaceRoot: string): string | number | boolean {
|
||||
if (WORKSPACE_DERIVED_KEYS.has(key)) {
|
||||
return deriveWorkspaceDefault(key, workspaceRoot);
|
||||
}
|
||||
return getNestedValue(DEFAULTS as unknown as Record<string, unknown>, KEY_TO_PATH[key]) as string | number | boolean;
|
||||
}
|
||||
|
||||
function coerceValue(key: ValidKey, raw: string, workspaceRoot: string): string | number | boolean {
|
||||
const defaultVal = getDefaultValue(key, workspaceRoot);
|
||||
if (typeof defaultVal === "number") {
|
||||
const n = parseInt(raw, 10);
|
||||
if (isNaN(n)) throw new Error(`Invalid value for ${key}: expected a number, got "${raw}"`);
|
||||
@@ -82,6 +174,14 @@ function coerceValue(key: ValidKey, raw: string): string | number | boolean {
|
||||
return raw;
|
||||
}
|
||||
|
||||
export type SettingSource = "env" | "file" | "default";
|
||||
|
||||
export interface ResolvedSetting {
|
||||
value: string | number | boolean;
|
||||
source: SettingSource;
|
||||
defaultValue: string | number | boolean;
|
||||
}
|
||||
|
||||
export class ConfigStore {
|
||||
readonly configPath: string;
|
||||
|
||||
@@ -92,62 +192,123 @@ export class ConfigStore {
|
||||
resolve(): RiggedConfig {
|
||||
const fileConfig = this.readConfigFile();
|
||||
|
||||
const resolveKey = (key: ValidKey): string | number | boolean => {
|
||||
// 1. Environment variable
|
||||
const envVal = readOpenRigEnv(ENV_MAP[key].primary, ENV_MAP[key].legacy);
|
||||
if (envVal !== undefined && envVal !== "") {
|
||||
return coerceValue(key, envVal);
|
||||
}
|
||||
// 2. Config file
|
||||
const fileVal = getNestedValue(fileConfig, key);
|
||||
if (fileVal !== undefined && fileVal !== null) {
|
||||
return fileVal as string | number | boolean;
|
||||
}
|
||||
// 3. Default
|
||||
return getDefaultValue(key);
|
||||
};
|
||||
// Workspace root must be resolved first so derived per-subdir
|
||||
// defaults can use it.
|
||||
const workspaceRoot = this.resolveOne("workspace.root", fileConfig, DEFAULT_WORKSPACE_ROOT).value as string;
|
||||
|
||||
const v = (key: ValidKey) =>
|
||||
this.resolveOne(key, fileConfig, workspaceRoot).value;
|
||||
|
||||
return {
|
||||
daemon: {
|
||||
port: resolveKey("daemon.port") as number,
|
||||
host: resolveKey("daemon.host") as string,
|
||||
port: v("daemon.port") as number,
|
||||
host: v("daemon.host") as string,
|
||||
},
|
||||
db: {
|
||||
path: resolveKey("db.path") as string,
|
||||
path: v("db.path") as string,
|
||||
},
|
||||
transcripts: {
|
||||
enabled: resolveKey("transcripts.enabled") as boolean,
|
||||
path: resolveKey("transcripts.path") as string,
|
||||
enabled: v("transcripts.enabled") as boolean,
|
||||
path: v("transcripts.path") as string,
|
||||
},
|
||||
workspace: {
|
||||
root: workspaceRoot,
|
||||
slicesRoot: v("workspace.slices_root") as string,
|
||||
steeringPath: v("workspace.steering_path") as string,
|
||||
fieldNotesRoot: v("workspace.field_notes_root") as string,
|
||||
specsRoot: v("workspace.specs_root") as string,
|
||||
},
|
||||
files: {
|
||||
allowlist: v("files.allowlist") as string,
|
||||
},
|
||||
progress: {
|
||||
scanRoots: v("progress.scan_roots") as string,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
get(key: string): string | number | boolean {
|
||||
/**
|
||||
* Resolve a single key with its source. Used by the daemon HTTP route
|
||||
* + UI Settings panel for honest provenance display (env / file /
|
||||
* default).
|
||||
*/
|
||||
resolveWithSource(key: string): ResolvedSetting {
|
||||
if (!isValidKey(key)) {
|
||||
throw new Error(`Unknown config key "${key}". Valid keys: ${VALID_KEYS.join(", ")}`);
|
||||
}
|
||||
const config = this.resolve();
|
||||
return getNestedValue(config as unknown as Record<string, unknown>, key) as string | number | boolean;
|
||||
const fileConfig = this.readConfigFile();
|
||||
const workspaceRoot = this.resolveOne("workspace.root", fileConfig, DEFAULT_WORKSPACE_ROOT).value as string;
|
||||
return this.resolveOne(key, fileConfig, workspaceRoot);
|
||||
}
|
||||
|
||||
/** Resolve all valid keys with sources. Convenience for the UI. */
|
||||
resolveAllWithSource(): Record<ValidKey, ResolvedSetting> {
|
||||
const fileConfig = this.readConfigFile();
|
||||
const workspaceRoot = this.resolveOne("workspace.root", fileConfig, DEFAULT_WORKSPACE_ROOT).value as string;
|
||||
const out = {} as Record<ValidKey, ResolvedSetting>;
|
||||
for (const key of VALID_KEYS) {
|
||||
out[key] = this.resolveOne(key, fileConfig, workspaceRoot);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private resolveOne(key: ValidKey, fileConfig: Record<string, unknown>, workspaceRoot: string): ResolvedSetting {
|
||||
const defaultValue = getDefaultValue(key, workspaceRoot);
|
||||
// 1. Environment variable
|
||||
const envVal = readOpenRigEnv(ENV_MAP[key].primary, ENV_MAP[key].legacy);
|
||||
if (envVal !== undefined && envVal !== "") {
|
||||
return { value: coerceValue(key, envVal, workspaceRoot), source: "env", defaultValue };
|
||||
}
|
||||
// 2. Config file
|
||||
const fileVal = getNestedValue(fileConfig, KEY_TO_PATH[key]);
|
||||
if (fileVal !== undefined && fileVal !== null && fileVal !== "") {
|
||||
return { value: fileVal as string | number | boolean, source: "file", defaultValue };
|
||||
}
|
||||
// 3. Default
|
||||
return { value: defaultValue, source: "default", defaultValue };
|
||||
}
|
||||
|
||||
get(key: string): string | number | boolean {
|
||||
return this.resolveWithSource(key).value;
|
||||
}
|
||||
|
||||
set(key: string, value: string): void {
|
||||
if (!isValidKey(key)) {
|
||||
throw new Error(`Unknown config key "${key}". Valid keys: ${VALID_KEYS.join(", ")}`);
|
||||
}
|
||||
const coerced = coerceValue(key, value);
|
||||
// readConfigFile (not Raw) — throws on malformed config so set doesn't silently overwrite
|
||||
const fileConfig = this.readConfigFile();
|
||||
setNestedValue(fileConfig, key, coerced);
|
||||
const workspaceRoot = (getNestedValue(fileConfig, KEY_TO_PATH["workspace.root"]) as string | undefined)
|
||||
|| readOpenRigEnv(ENV_MAP["workspace.root"].primary, ENV_MAP["workspace.root"].legacy)
|
||||
|| DEFAULT_WORKSPACE_ROOT;
|
||||
const coerced = coerceValue(key, value, workspaceRoot);
|
||||
setNestedValue(fileConfig, KEY_TO_PATH[key], coerced);
|
||||
mkdirSync(dirname(this.configPath), { recursive: true });
|
||||
writeFileSync(this.configPath, JSON.stringify(fileConfig, null, 2) + "\n", "utf-8");
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
try {
|
||||
unlinkSync(this.configPath);
|
||||
} catch {
|
||||
// File doesn't exist — that's fine
|
||||
/**
|
||||
* Clear an override. Without a key argument, deletes the whole config
|
||||
* file (revert all to defaults). With a key, removes just that key
|
||||
* from the config file.
|
||||
*/
|
||||
reset(key?: string): void {
|
||||
if (key === undefined) {
|
||||
try { unlinkSync(this.configPath); } catch { /* missing is fine */ }
|
||||
return;
|
||||
}
|
||||
if (!isValidKey(key)) {
|
||||
throw new Error(`Unknown config key "${key}". Valid keys: ${VALID_KEYS.join(", ")}`);
|
||||
}
|
||||
if (!existsSync(this.configPath)) return;
|
||||
const fileConfig = this.readConfigFile();
|
||||
const parts = KEY_TO_PATH[key];
|
||||
const parentParts = parts.slice(0, -1);
|
||||
const leaf = parts[parts.length - 1]!;
|
||||
const parent = getNestedValue(fileConfig, parentParts) as Record<string, unknown> | undefined;
|
||||
if (parent && leaf in parent) {
|
||||
delete parent[leaf];
|
||||
}
|
||||
writeFileSync(this.configPath, JSON.stringify(fileConfig, null, 2) + "\n", "utf-8");
|
||||
}
|
||||
|
||||
private readConfigFile(): Record<string, unknown> {
|
||||
@@ -161,5 +322,33 @@ export class ConfigStore {
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- User Settings v0: named-pair decoder ---
|
||||
//
|
||||
// `files.allowlist` and `progress.scan_roots` are stored as the same
|
||||
// comma-separated `name:/abs/path` strings UEP introduced via env var.
|
||||
// This helper decodes the raw string into structured pairs. Invalid
|
||||
// entries (no colon, empty name/path) are silently skipped per UEP
|
||||
// convention; duplicate names: last wins.
|
||||
|
||||
export interface NamedPair {
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export function parseNamedPairs(raw: string): NamedPair[] {
|
||||
if (!raw || !raw.trim()) return [];
|
||||
const out = new Map<string, string>();
|
||||
for (const pair of raw.split(",")) {
|
||||
const trimmed = pair.trim();
|
||||
if (!trimmed) continue;
|
||||
const colon = trimmed.indexOf(":");
|
||||
if (colon === -1) continue;
|
||||
const name = trimmed.slice(0, colon).trim();
|
||||
const rawPath = trimmed.slice(colon + 1).trim();
|
||||
if (!name || !rawPath) continue;
|
||||
out.set(name, rawPath);
|
||||
}
|
||||
return Array.from(out.entries()).map(([name, path]) => ({ name, path }));
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
// User Settings v0 — extended ConfigStore + init-workspace tests.
|
||||
//
|
||||
// Pins the load-bearing behaviors of the v0 extension:
|
||||
// - new keys parse + validate; legacy 5 keys' behavior preserved
|
||||
// - per-subdir override resolution (workspace.root / per-subdir overrides / default)
|
||||
// - env > file > default precedence for new keys
|
||||
// - UEP env-var graduation: OPENRIG_FILES_ALLOWLIST + OPENRIG_PROGRESS_SCAN_ROOTS still work
|
||||
// - reset(key) clears one key; bare reset deletes the file
|
||||
// - parseNamedPairs decodes the named-pair format
|
||||
// - init-workspace creates 5 subdirs + READMEs + STEERING placeholder; idempotent
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import {
|
||||
ConfigStore,
|
||||
VALID_KEYS,
|
||||
parseNamedPairs,
|
||||
deriveWorkspaceDefault,
|
||||
} from "../src/config-store.js";
|
||||
import { runInitWorkspace } from "../src/commands/config-init-workspace.js";
|
||||
|
||||
function clearEnv(): () => void {
|
||||
const keysToClear = [
|
||||
"OPENRIG_PORT", "OPENRIG_HOST", "OPENRIG_DB",
|
||||
"OPENRIG_TRANSCRIPTS_ENABLED", "OPENRIG_TRANSCRIPTS_PATH",
|
||||
"OPENRIG_WORKSPACE_ROOT", "OPENRIG_WORKSPACE_SLICES_ROOT",
|
||||
"OPENRIG_WORKSPACE_STEERING_PATH", "OPENRIG_WORKSPACE_FIELD_NOTES_ROOT",
|
||||
"OPENRIG_WORKSPACE_SPECS_ROOT",
|
||||
"OPENRIG_FILES_ALLOWLIST", "OPENRIG_PROGRESS_SCAN_ROOTS",
|
||||
"RIGGED_PORT", "RIGGED_HOST", "RIGGED_DB",
|
||||
"RIGGED_TRANSCRIPTS_ENABLED", "RIGGED_TRANSCRIPTS_PATH",
|
||||
"RIGGED_WORKSPACE_ROOT", "RIGGED_WORKSPACE_SLICES_ROOT",
|
||||
"RIGGED_FILES_ALLOWLIST", "RIGGED_PROGRESS_SCAN_ROOTS",
|
||||
];
|
||||
const saved: Record<string, string | undefined> = {};
|
||||
for (const k of keysToClear) {
|
||||
saved[k] = process.env[k];
|
||||
delete process.env[k];
|
||||
}
|
||||
return () => {
|
||||
for (const k of keysToClear) {
|
||||
if (saved[k] !== undefined) process.env[k] = saved[k]!;
|
||||
else delete process.env[k];
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
describe("ConfigStore — extended namespaces (User Settings v0)", () => {
|
||||
let tmpDir: string;
|
||||
let configPath: string;
|
||||
let restoreEnv: () => void;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), "config-store-ext-"));
|
||||
configPath = join(tmpDir, "config.json");
|
||||
restoreEnv = clearEnv();
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
restoreEnv();
|
||||
});
|
||||
|
||||
it("VALID_KEYS includes the 5 legacy + 7 new keys", () => {
|
||||
const expected = [
|
||||
"daemon.port", "daemon.host", "db.path",
|
||||
"transcripts.enabled", "transcripts.path",
|
||||
"workspace.root", "workspace.slices_root", "workspace.steering_path",
|
||||
"workspace.field_notes_root", "workspace.specs_root",
|
||||
"files.allowlist", "progress.scan_roots",
|
||||
];
|
||||
expect([...VALID_KEYS]).toEqual(expected);
|
||||
});
|
||||
|
||||
it("legacy 5-key behavior preserved: get/set/reset still work", () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
store.set("daemon.port", "9999");
|
||||
expect(store.get("daemon.port")).toBe(9999);
|
||||
store.set("transcripts.enabled", "false");
|
||||
expect(store.get("transcripts.enabled")).toBe(false);
|
||||
store.reset("daemon.port");
|
||||
expect(store.get("daemon.port")).toBe(7433);
|
||||
});
|
||||
|
||||
it("workspace.root default is ~/.openrig/workspace; per-subdir defaults derive from it", () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
const cfg = store.resolve();
|
||||
expect(cfg.workspace.root).toMatch(/\.openrig[\/\\]workspace$/);
|
||||
expect(cfg.workspace.slicesRoot).toBe(join(cfg.workspace.root, "slices"));
|
||||
expect(cfg.workspace.steeringPath).toBe(join(cfg.workspace.root, "steering", "STEERING.md"));
|
||||
expect(cfg.workspace.fieldNotesRoot).toBe(join(cfg.workspace.root, "field-notes"));
|
||||
expect(cfg.workspace.specsRoot).toBe(join(cfg.workspace.root, "specs"));
|
||||
});
|
||||
|
||||
it("setting workspace.root cascades into per-subdir defaults", () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
store.set("workspace.root", "/custom/ws");
|
||||
const cfg = store.resolve();
|
||||
expect(cfg.workspace.root).toBe("/custom/ws");
|
||||
expect(cfg.workspace.slicesRoot).toBe("/custom/ws/slices");
|
||||
expect(cfg.workspace.steeringPath).toBe("/custom/ws/steering/STEERING.md");
|
||||
});
|
||||
|
||||
it("per-subdir override beats workspace.root cascade", () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
store.set("workspace.root", "/ws");
|
||||
store.set("workspace.slices_root", "/founder/slices");
|
||||
const cfg = store.resolve();
|
||||
// workspace.root cascade applies to OTHER subdirs:
|
||||
expect(cfg.workspace.fieldNotesRoot).toBe("/ws/field-notes");
|
||||
// per-subdir override wins:
|
||||
expect(cfg.workspace.slicesRoot).toBe("/founder/slices");
|
||||
});
|
||||
|
||||
it("env > file > default for new keys", () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
// No file → default
|
||||
expect(store.resolveWithSource("workspace.slices_root").source).toBe("default");
|
||||
|
||||
// File set → file source
|
||||
store.set("workspace.slices_root", "/from/file");
|
||||
expect(store.resolveWithSource("workspace.slices_root").value).toBe("/from/file");
|
||||
expect(store.resolveWithSource("workspace.slices_root").source).toBe("file");
|
||||
|
||||
// Env set → env source
|
||||
process.env["OPENRIG_WORKSPACE_SLICES_ROOT"] = "/from/env";
|
||||
try {
|
||||
const r = store.resolveWithSource("workspace.slices_root");
|
||||
expect(r.value).toBe("/from/env");
|
||||
expect(r.source).toBe("env");
|
||||
} finally {
|
||||
delete process.env["OPENRIG_WORKSPACE_SLICES_ROOT"];
|
||||
}
|
||||
});
|
||||
|
||||
it("UEP env-var graduation: OPENRIG_FILES_ALLOWLIST is the env override for files.allowlist", () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
process.env["OPENRIG_FILES_ALLOWLIST"] = "ws:/Users/me,docs:/var/docs";
|
||||
try {
|
||||
const r = store.resolveWithSource("files.allowlist");
|
||||
expect(r.value).toBe("ws:/Users/me,docs:/var/docs");
|
||||
expect(r.source).toBe("env");
|
||||
} finally {
|
||||
delete process.env["OPENRIG_FILES_ALLOWLIST"];
|
||||
}
|
||||
});
|
||||
|
||||
it("UEP env-var graduation: OPENRIG_PROGRESS_SCAN_ROOTS is the env override for progress.scan_roots", () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
process.env["OPENRIG_PROGRESS_SCAN_ROOTS"] = "main:/code/main";
|
||||
try {
|
||||
const r = store.resolveWithSource("progress.scan_roots");
|
||||
expect(r.value).toBe("main:/code/main");
|
||||
expect(r.source).toBe("env");
|
||||
} finally {
|
||||
delete process.env["OPENRIG_PROGRESS_SCAN_ROOTS"];
|
||||
}
|
||||
});
|
||||
|
||||
it("legacy RIGGED_FILES_ALLOWLIST falls back when OPENRIG_* is unset", () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
process.env["RIGGED_FILES_ALLOWLIST"] = "legacy:/old/path";
|
||||
try {
|
||||
expect(store.get("files.allowlist")).toBe("legacy:/old/path");
|
||||
} finally {
|
||||
delete process.env["RIGGED_FILES_ALLOWLIST"];
|
||||
}
|
||||
});
|
||||
|
||||
it("set rejects unknown keys with hint listing valid keys", () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
expect(() => store.set("workspace.bogus", "x")).toThrow(/Unknown config key/);
|
||||
expect(() => store.set("workspace.bogus", "x")).toThrow(/workspace\.root/);
|
||||
});
|
||||
|
||||
it("get rejects unknown keys", () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
expect(() => store.get("nope.doesnt.exist")).toThrow(/Unknown config key/);
|
||||
});
|
||||
|
||||
it("reset(key) clears just one key; reset() deletes whole file", () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
store.set("workspace.root", "/ws");
|
||||
store.set("workspace.slices_root", "/ws/slices-custom");
|
||||
store.reset("workspace.slices_root");
|
||||
expect(store.resolveWithSource("workspace.slices_root").source).toBe("default");
|
||||
expect(store.resolveWithSource("workspace.root").source).toBe("file");
|
||||
|
||||
store.reset();
|
||||
expect(existsSync(configPath)).toBe(false);
|
||||
});
|
||||
|
||||
it("resolveAllWithSource returns every valid key with source + default", () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
const all = store.resolveAllWithSource();
|
||||
for (const key of VALID_KEYS) {
|
||||
expect(all[key]).toBeDefined();
|
||||
expect(all[key].source).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("malformed config.json throws with reset hint (preserves legacy behavior)", () => {
|
||||
writeFileSync(configPath, "{not-json");
|
||||
const store = new ConfigStore(configPath);
|
||||
expect(() => store.resolve()).toThrow(/malformed/i);
|
||||
expect(() => store.resolve()).toThrow(/reset/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseNamedPairs", () => {
|
||||
it("returns empty array for empty/whitespace input", () => {
|
||||
expect(parseNamedPairs("")).toEqual([]);
|
||||
expect(parseNamedPairs(" ")).toEqual([]);
|
||||
});
|
||||
|
||||
it("splits comma-separated name:path pairs", () => {
|
||||
const out = parseNamedPairs("ws:/abs/path,docs:/var/docs");
|
||||
expect(out).toEqual([
|
||||
{ name: "ws", path: "/abs/path" },
|
||||
{ name: "docs", path: "/var/docs" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips entries without a colon", () => {
|
||||
expect(parseNamedPairs("just-name,ws:/path")).toEqual([{ name: "ws", path: "/path" }]);
|
||||
});
|
||||
|
||||
it("trims whitespace around each pair + name + path", () => {
|
||||
expect(parseNamedPairs(" ws : /abs/path , docs:/var/docs ")).toEqual([
|
||||
{ name: "ws", path: "/abs/path" },
|
||||
{ name: "docs", path: "/var/docs" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("dedupes by name (last wins)", () => {
|
||||
expect(parseNamedPairs("ws:/old,ws:/new")).toEqual([{ name: "ws", path: "/new" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveWorkspaceDefault", () => {
|
||||
it("returns canonical subpaths under workspace root", () => {
|
||||
expect(deriveWorkspaceDefault("workspace.slices_root", "/ws")).toBe("/ws/slices");
|
||||
expect(deriveWorkspaceDefault("workspace.steering_path", "/ws")).toBe("/ws/steering/STEERING.md");
|
||||
expect(deriveWorkspaceDefault("workspace.field_notes_root", "/ws")).toBe("/ws/field-notes");
|
||||
expect(deriveWorkspaceDefault("workspace.specs_root", "/ws")).toBe("/ws/specs");
|
||||
});
|
||||
});
|
||||
|
||||
describe("init-workspace runner", () => {
|
||||
let tmpDir: string;
|
||||
let configPath: string;
|
||||
let workspaceRoot: string;
|
||||
let restoreEnv: () => void;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), "init-workspace-"));
|
||||
configPath = join(tmpDir, "config.json");
|
||||
workspaceRoot = join(tmpDir, "workspace");
|
||||
restoreEnv = clearEnv();
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
restoreEnv();
|
||||
});
|
||||
|
||||
it("--dry-run reports what would be created without writing anything", () => {
|
||||
const result = runInitWorkspace({ dryRun: true, root: workspaceRoot, configPath });
|
||||
expect(result.dryRun).toBe(true);
|
||||
expect(result.subdirs.map((s) => s.name)).toEqual([
|
||||
"slices", "steering", "progress", "field-notes", "specs",
|
||||
]);
|
||||
expect(existsSync(workspaceRoot)).toBe(false);
|
||||
});
|
||||
|
||||
it("creates 5 subdirs + READMEs + STEERING placeholder", () => {
|
||||
const result = runInitWorkspace({ root: workspaceRoot, configPath });
|
||||
expect(result.dryRun).toBe(false);
|
||||
expect(existsSync(workspaceRoot)).toBe(true);
|
||||
for (const sub of ["slices", "steering", "progress", "field-notes", "specs"]) {
|
||||
expect(existsSync(join(workspaceRoot, sub))).toBe(true);
|
||||
expect(existsSync(join(workspaceRoot, sub, "README.md"))).toBe(true);
|
||||
}
|
||||
const steeringMd = readFileSync(join(workspaceRoot, "steering", "STEERING.md"), "utf-8");
|
||||
expect(steeringMd).toContain("OpenRig Priority Stack");
|
||||
});
|
||||
|
||||
it("is idempotent: running twice without --force is a no-op for existing files", () => {
|
||||
runInitWorkspace({ root: workspaceRoot, configPath });
|
||||
const sliceReadme = join(workspaceRoot, "slices", "README.md");
|
||||
writeFileSync(sliceReadme, "operator-edited content", "utf-8");
|
||||
|
||||
const second = runInitWorkspace({ root: workspaceRoot, configPath });
|
||||
const sliceFile = second.files.find((f) => f.relPath === "slices/README.md");
|
||||
expect(sliceFile?.skipped).toBe("exists");
|
||||
expect(readFileSync(sliceReadme, "utf-8")).toBe("operator-edited content");
|
||||
});
|
||||
|
||||
it("--force overwrites existing files but never deletes operator content under directories", () => {
|
||||
runInitWorkspace({ root: workspaceRoot, configPath });
|
||||
const operatorFile = join(workspaceRoot, "slices", "my-slice.md");
|
||||
writeFileSync(operatorFile, "my work", "utf-8");
|
||||
const operatorReadme = join(workspaceRoot, "slices", "README.md");
|
||||
writeFileSync(operatorReadme, "edited", "utf-8");
|
||||
|
||||
runInitWorkspace({ root: workspaceRoot, force: true, configPath });
|
||||
// Operator file under the subdir survives
|
||||
expect(existsSync(operatorFile)).toBe(true);
|
||||
expect(readFileSync(operatorFile, "utf-8")).toBe("my work");
|
||||
// README is overwritten
|
||||
expect(readFileSync(operatorReadme, "utf-8")).toContain("# slices");
|
||||
});
|
||||
|
||||
it("--root override beats configured workspace.root", () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
store.set("workspace.root", "/should/not/be/used");
|
||||
const result = runInitWorkspace({ root: workspaceRoot, configPath });
|
||||
expect(result.root).toBe(workspaceRoot);
|
||||
expect(existsSync(workspaceRoot)).toBe(true);
|
||||
});
|
||||
|
||||
it("reads workspace.root from settings when --root is not given", () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
store.set("workspace.root", workspaceRoot);
|
||||
const result = runInitWorkspace({ configPath });
|
||||
expect(result.root).toBe(workspaceRoot);
|
||||
expect(existsSync(workspaceRoot)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
---
|
||||
name: openrig-user-settings
|
||||
description: Use when the user asks an OpenRig agent to read or change OpenRig configuration settings — workspace paths, file allowlists, progress scan roots, daemon port/host, transcripts, or anything stored in ~/.openrig/config.json
|
||||
---
|
||||
|
||||
# OpenRig User Settings
|
||||
|
||||
OpenRig settings live in `~/.openrig/config.json` and are managed via the
|
||||
`rig config` CLI family. The agent-editable surface is **CLI shell-out**;
|
||||
do NOT write the JSON file directly unless the operator specifically asks
|
||||
for raw-text editing (which is handled by the UI Files browser, not by you).
|
||||
|
||||
## Read
|
||||
|
||||
```bash
|
||||
rig config # all keys, value + source
|
||||
rig config --json # JSON: full RiggedConfig
|
||||
rig config --json --with-source # JSON: {key: {value, source, default}}
|
||||
rig config get <key> # single value
|
||||
rig config get <key> --show-source # value + source on one line
|
||||
rig config get <key> --json # {value, source, default}
|
||||
```
|
||||
|
||||
Resolution order: **env var > config file > default**. Always use
|
||||
`--show-source` (or `--json`) when reporting back to the operator so they
|
||||
see *where* the value came from.
|
||||
|
||||
## Write
|
||||
|
||||
```bash
|
||||
rig config set <key> <value> # set a key
|
||||
rig config reset <key> # clear one override (revert to default)
|
||||
rig config reset # delete the whole file
|
||||
```
|
||||
|
||||
## Initialize a default workspace
|
||||
|
||||
```bash
|
||||
rig config init-workspace # scaffold ~/.openrig/workspace/ + 5 subdirs
|
||||
rig config init-workspace --dry-run # show what would be created
|
||||
rig config init-workspace --root /path # use a custom root
|
||||
rig config init-workspace --force # overwrite scaffolded files (NOT operator content)
|
||||
```
|
||||
|
||||
This creates `slices/ steering/ progress/ field-notes/ specs/` under the
|
||||
workspace root, with one-paragraph READMEs and a placeholder
|
||||
`steering/STEERING.md`. It is operator-explicit per founder safety stop —
|
||||
do NOT run it on your own initiative; only when the operator asks.
|
||||
|
||||
## Common keys
|
||||
|
||||
| Key | Type | Purpose |
|
||||
|---|---|---|
|
||||
| `daemon.port` | number | OpenRig daemon HTTP port (default 7433) |
|
||||
| `daemon.host` | string | Daemon bind host (default 127.0.0.1) |
|
||||
| `db.path` | string | SQLite DB path (default ~/.openrig/openrig.sqlite) |
|
||||
| `transcripts.enabled` | boolean | Whether the daemon writes transcripts |
|
||||
| `transcripts.path` | string | Transcripts dir (default ~/.openrig/transcripts) |
|
||||
| `workspace.root` | string | Single-root override (default ~/.openrig/workspace) |
|
||||
| `workspace.slices_root` | string | Per-subdir override; default `<root>/slices` |
|
||||
| `workspace.steering_path` | string | STEERING.md path; default `<root>/steering/STEERING.md` |
|
||||
| `workspace.field_notes_root` | string | Field notes dir; default `<root>/field-notes` |
|
||||
| `workspace.specs_root` | string | Specs dir; default `<root>/specs` |
|
||||
| `files.allowlist` | string | `name:/abs/path,name:/abs/path` — UI Files browser roots |
|
||||
| `progress.scan_roots` | string | `name:/abs/path,name:/abs/path` — Progress browse roots |
|
||||
|
||||
Resolution order for workspace per-subdir keys: per-subdir override >
|
||||
`workspace.root` cascade > built-in default.
|
||||
|
||||
## Common operator requests → canonical commands
|
||||
|
||||
> **"Make the UI work against my substrate layout"** (founder's case):
|
||||
> set the per-subdir workspace overrides explicitly:
|
||||
>
|
||||
> ```bash
|
||||
> rig config set workspace.slices_root /Users/me/code/substrate/.../slices
|
||||
> rig config set workspace.steering_path /Users/me/code/substrate/openrig-work/STEERING.md
|
||||
> rig config set workspace.progress_scan_roots 'work:/Users/me/code/substrate/openrig-work,missions:/Users/me/code/substrate/openrig-work/missions'
|
||||
> rig config set workspace.field_notes_root /Users/me/code/substrate/openrig-work/field-notes
|
||||
> ```
|
||||
>
|
||||
> No restart needed for the CLI; for the daemon's reads to pick up
|
||||
> changes affecting startup-time wiring (`files.allowlist`,
|
||||
> `progress.scan_roots`), restart with `rig daemon restart`.
|
||||
|
||||
> **"Initialize a default workspace"**:
|
||||
>
|
||||
> ```bash
|
||||
> rig config init-workspace
|
||||
> ```
|
||||
|
||||
> **"Allow the file browser to see X directory"**:
|
||||
>
|
||||
> ```bash
|
||||
> # Append to existing list (operator decides the name token):
|
||||
> rig config get files.allowlist --show-source # check current
|
||||
> rig config set files.allowlist 'workspace:/path/to/ws,docs:/path/to/docs'
|
||||
> rig daemon restart # picks up new roots
|
||||
> ```
|
||||
|
||||
> **"What's my current setting for X and where does it come from?"**:
|
||||
>
|
||||
> ```bash
|
||||
> rig config get <key> --show-source
|
||||
> ```
|
||||
|
||||
## When NOT to use this skill
|
||||
|
||||
- **Per-rig config** — settings are host-global at v0; per-rig is v0+1.
|
||||
- **Secrets / credentials** — the `vault.specialist` managed-app handles
|
||||
secrets. Don't put API keys or tokens in `~/.openrig/config.json`.
|
||||
- **Settings sync across hosts** — out of scope at v0.
|
||||
|
||||
## Errors you might see
|
||||
|
||||
- `Unknown config key "<x>". Valid keys: …` — the key name is wrong.
|
||||
Check the table above; suggest the correct key.
|
||||
- `Config file at <path> is malformed. Fix the JSON or reset with: rig config reset` —
|
||||
hand-edit broke the JSON. Operator can `rig config reset` (deletes the
|
||||
whole file) or fix the syntax in the Files browser.
|
||||
- `Invalid value for <key>: expected a number/true-false, got "<x>"` —
|
||||
wrong type for a typed key. Reach for the key's type in the table.
|
||||
|
||||
## Provenance + safety
|
||||
|
||||
- Never write to `~/.openrig/config.json` directly. Use `rig config set`.
|
||||
- Don't run `rig config init-workspace` on your own initiative — wait for
|
||||
an operator request.
|
||||
- Always cite the resolved source (env / file / default) when reporting
|
||||
values back, so the operator knows whether their change actually took.
|
||||
@@ -51,17 +51,13 @@ const ENV_VAR = "OPENRIG_FILES_ALLOWLIST";
|
||||
const LEGACY_ENV_VAR = "RIGGED_FILES_ALLOWLIST";
|
||||
|
||||
/**
|
||||
* Reads the allowlist env var and returns the parsed root list. Each
|
||||
* pair is `<name>:<absolute-path>`; whitespace around delimiters is
|
||||
* trimmed. Invalid pairs (no colon, empty name, non-absolute path)
|
||||
* are silently skipped (logged at construction by the caller, not
|
||||
* here — keep this pure for tests). Duplicate names: last entry wins
|
||||
* (operator presumably means to override).
|
||||
* Decodes a raw `name:/abs/path,name:/abs/path` allowlist string into
|
||||
* canonical AllowlistRoot[]. Whitespace around delimiters trimmed.
|
||||
* Invalid pairs (no colon, empty name, non-absolute path) silently
|
||||
* skipped. Duplicate names: last wins. Same shape both env-var and
|
||||
* settings-file-resolved values produce.
|
||||
*/
|
||||
export function readAllowlistFromEnv(env: NodeJS.ProcessEnv = process.env): AllowlistRoot[] {
|
||||
// Use || (not ??) so an empty-string OPENRIG_FILES_ALLOWLIST falls
|
||||
// back to the legacy var. ?? only handles null/undefined.
|
||||
const raw = (env[ENV_VAR] || env[LEGACY_ENV_VAR] || "").toString();
|
||||
export function decodeAllowlist(raw: string): AllowlistRoot[] {
|
||||
if (!raw.trim()) return [];
|
||||
const out = new Map<string, string>();
|
||||
for (const pair of raw.split(",")) {
|
||||
@@ -73,9 +69,6 @@ export function readAllowlistFromEnv(env: NodeJS.ProcessEnv = process.env): Allo
|
||||
const rawPath = trimmed.slice(colon + 1).trim();
|
||||
if (!name || !rawPath) continue;
|
||||
if (!path.isAbsolute(rawPath)) continue;
|
||||
// Resolve to canonical absolute path. If the path doesn't exist
|
||||
// on disk yet, fall back to path.resolve so the operator can
|
||||
// pre-configure roots. realpath would throw on missing paths.
|
||||
let canonical: string;
|
||||
try {
|
||||
canonical = fs.realpathSync(rawPath);
|
||||
@@ -87,6 +80,18 @@ export function readAllowlistFromEnv(env: NodeJS.ProcessEnv = process.env): Allo
|
||||
return Array.from(out.entries()).map(([name, canonicalPath]) => ({ name, canonicalPath }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the allowlist env var directly and returns the parsed root list.
|
||||
* Preserved for backward-compat callers; v0 callers prefer the resolved
|
||||
* settings-store path which honors env > settings-file > empty.
|
||||
*/
|
||||
export function readAllowlistFromEnv(env: NodeJS.ProcessEnv = process.env): AllowlistRoot[] {
|
||||
// Use || (not ??) so an empty-string OPENRIG_FILES_ALLOWLIST falls
|
||||
// back to the legacy var. ?? only handles null/undefined.
|
||||
const raw = (env[ENV_VAR] || env[LEGACY_ENV_VAR] || "").toString();
|
||||
return decodeAllowlist(raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a relative path against an allowlist root. Throws
|
||||
* FilePathSafetyError on any unsafe condition. Returns the
|
||||
|
||||
@@ -83,8 +83,13 @@ const SKIP_DIRS = new Set(["node_modules", ".git", ".worktrees", "dist", "build"
|
||||
const ENV_VAR = "OPENRIG_PROGRESS_SCAN_ROOTS";
|
||||
const LEGACY_ENV_VAR = "RIGGED_PROGRESS_SCAN_ROOTS";
|
||||
|
||||
export function readProgressRootsFromEnv(env: NodeJS.ProcessEnv = process.env): ProgressScanRoot[] {
|
||||
const raw = env[ENV_VAR] ?? env[LEGACY_ENV_VAR] ?? "";
|
||||
/**
|
||||
* Decodes a raw `name:/abs/path,...` progress-roots string into a
|
||||
* ProgressScanRoot[]. Same semantics as decodeAllowlist in path-safety;
|
||||
* keep them parallel. Used by both the env-only legacy path and the
|
||||
* settings-resolved (env > file > empty) path.
|
||||
*/
|
||||
export function decodeProgressScanRoots(raw: string): ProgressScanRoot[] {
|
||||
if (!raw.trim()) return [];
|
||||
const out = new Map<string, string>();
|
||||
for (const pair of raw.split(",")) {
|
||||
@@ -102,6 +107,11 @@ export function readProgressRootsFromEnv(env: NodeJS.ProcessEnv = process.env):
|
||||
return Array.from(out.entries()).map(([name, canonicalPath]) => ({ name, canonicalPath }));
|
||||
}
|
||||
|
||||
export function readProgressRootsFromEnv(env: NodeJS.ProcessEnv = process.env): ProgressScanRoot[] {
|
||||
const raw = env[ENV_VAR] ?? env[LEGACY_ENV_VAR] ?? "";
|
||||
return decodeProgressScanRoots(raw);
|
||||
}
|
||||
|
||||
export interface ProgressIndexerOpts {
|
||||
roots: ProgressScanRoot[];
|
||||
/** Override max recursion depth for tests. */
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
// User Settings v0 — daemon-side settings store.
|
||||
//
|
||||
// The CLI's @openrig/cli ConfigStore is the canonical write surface
|
||||
// (operator + agent edit via `rig config`). The daemon needs read+write
|
||||
// access too — for the UI's System drawer Settings panel + the daemon
|
||||
// HTTP route at /api/config. Rather than depend on the CLI package
|
||||
// (which would require workspace exports + a dist build), this module
|
||||
// duplicates the small, stable resolution + write logic. The constants
|
||||
// (VALID_KEYS, ENV_MAP, KEY_TO_PATH) are kept in lockstep with
|
||||
// cli/src/config-store.ts via cross-package tests.
|
||||
//
|
||||
// Storage: same single source of truth at ~/.openrig/config.json.
|
||||
// Resolution: same env > file > default precedence. Decoded helpers
|
||||
// (parseNamedPairs / resolveAllowlist / resolveProgressScanRoots /
|
||||
// resolveWorkspacePaths) project the raw strings into structured data
|
||||
// the daemon's UEP routes + Slice Story View consume.
|
||||
|
||||
import { readFileSync, writeFileSync, mkdirSync, unlinkSync, existsSync } from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import * as os from "node:os";
|
||||
|
||||
const DEFAULT_CONFIG_PATH = path.join(
|
||||
process.env["OPENRIG_HOME"] || process.env["RIGGED_HOME"] || path.join(os.homedir(), ".openrig"),
|
||||
"config.json",
|
||||
);
|
||||
|
||||
const DEFAULT_WORKSPACE_ROOT = path.join(
|
||||
process.env["OPENRIG_HOME"] || process.env["RIGGED_HOME"] || path.join(os.homedir(), ".openrig"),
|
||||
"workspace",
|
||||
);
|
||||
|
||||
export const SETTINGS_VALID_KEYS = [
|
||||
"daemon.port",
|
||||
"daemon.host",
|
||||
"db.path",
|
||||
"transcripts.enabled",
|
||||
"transcripts.path",
|
||||
"workspace.root",
|
||||
"workspace.slices_root",
|
||||
"workspace.steering_path",
|
||||
"workspace.field_notes_root",
|
||||
"workspace.specs_root",
|
||||
"files.allowlist",
|
||||
"progress.scan_roots",
|
||||
] as const;
|
||||
|
||||
export type SettingsValidKey = typeof SETTINGS_VALID_KEYS[number];
|
||||
|
||||
const ENV_MAP: Record<SettingsValidKey, { primary: string; legacy: string }> = {
|
||||
"daemon.port": { primary: "OPENRIG_PORT", legacy: "RIGGED_PORT" },
|
||||
"daemon.host": { primary: "OPENRIG_HOST", legacy: "RIGGED_HOST" },
|
||||
"db.path": { primary: "OPENRIG_DB", legacy: "RIGGED_DB" },
|
||||
"transcripts.enabled": { primary: "OPENRIG_TRANSCRIPTS_ENABLED", legacy: "RIGGED_TRANSCRIPTS_ENABLED" },
|
||||
"transcripts.path": { primary: "OPENRIG_TRANSCRIPTS_PATH", legacy: "RIGGED_TRANSCRIPTS_PATH" },
|
||||
"workspace.root": { primary: "OPENRIG_WORKSPACE_ROOT", legacy: "RIGGED_WORKSPACE_ROOT" },
|
||||
"workspace.slices_root": { primary: "OPENRIG_WORKSPACE_SLICES_ROOT", legacy: "RIGGED_WORKSPACE_SLICES_ROOT" },
|
||||
"workspace.steering_path": { primary: "OPENRIG_WORKSPACE_STEERING_PATH", legacy: "RIGGED_WORKSPACE_STEERING_PATH" },
|
||||
"workspace.field_notes_root": { primary: "OPENRIG_WORKSPACE_FIELD_NOTES_ROOT", legacy: "RIGGED_WORKSPACE_FIELD_NOTES_ROOT" },
|
||||
"workspace.specs_root": { primary: "OPENRIG_WORKSPACE_SPECS_ROOT", legacy: "RIGGED_WORKSPACE_SPECS_ROOT" },
|
||||
"files.allowlist": { primary: "OPENRIG_FILES_ALLOWLIST", legacy: "RIGGED_FILES_ALLOWLIST" },
|
||||
"progress.scan_roots": { primary: "OPENRIG_PROGRESS_SCAN_ROOTS", legacy: "RIGGED_PROGRESS_SCAN_ROOTS" },
|
||||
};
|
||||
|
||||
const KEY_TO_PATH: Record<SettingsValidKey, string[]> = {
|
||||
"daemon.port": ["daemon", "port"],
|
||||
"daemon.host": ["daemon", "host"],
|
||||
"db.path": ["db", "path"],
|
||||
"transcripts.enabled": ["transcripts", "enabled"],
|
||||
"transcripts.path": ["transcripts", "path"],
|
||||
"workspace.root": ["workspace", "root"],
|
||||
"workspace.slices_root": ["workspace", "slicesRoot"],
|
||||
"workspace.steering_path": ["workspace", "steeringPath"],
|
||||
"workspace.field_notes_root": ["workspace", "fieldNotesRoot"],
|
||||
"workspace.specs_root": ["workspace", "specsRoot"],
|
||||
"files.allowlist": ["files", "allowlist"],
|
||||
"progress.scan_roots": ["progress", "scanRoots"],
|
||||
};
|
||||
|
||||
export type SettingSource = "env" | "file" | "default";
|
||||
|
||||
export interface ResolvedSetting {
|
||||
value: string | number | boolean;
|
||||
source: SettingSource;
|
||||
defaultValue: string | number | boolean;
|
||||
}
|
||||
|
||||
export function isSettingsValidKey(key: string): key is SettingsValidKey {
|
||||
return (SETTINGS_VALID_KEYS as readonly string[]).includes(key);
|
||||
}
|
||||
|
||||
function readEnv(primary: string, legacy: string): string | undefined {
|
||||
const p = process.env[primary];
|
||||
if (p !== undefined && p !== "") return p;
|
||||
const l = process.env[legacy];
|
||||
if (l !== undefined && l !== "") return l;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getNestedValue(obj: Record<string, unknown>, parts: string[]): unknown {
|
||||
let current: unknown = obj;
|
||||
for (const part of parts) {
|
||||
if (current == null || typeof current !== "object") return undefined;
|
||||
current = (current as Record<string, unknown>)[part];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function setNestedValue(obj: Record<string, unknown>, parts: string[], value: unknown): void {
|
||||
let current = obj;
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
const part = parts[i]!;
|
||||
if (!(part in current) || typeof current[part] !== "object" || current[part] === null) {
|
||||
current[part] = {};
|
||||
}
|
||||
current = current[part] as Record<string, unknown>;
|
||||
}
|
||||
current[parts[parts.length - 1]!] = value;
|
||||
}
|
||||
|
||||
function deriveWorkspaceDefault(key: SettingsValidKey, workspaceRoot: string): string {
|
||||
switch (key) {
|
||||
case "workspace.slices_root": return path.join(workspaceRoot, "slices");
|
||||
case "workspace.steering_path": return path.join(workspaceRoot, "steering", "STEERING.md");
|
||||
case "workspace.field_notes_root": return path.join(workspaceRoot, "field-notes");
|
||||
case "workspace.specs_root": return path.join(workspaceRoot, "specs");
|
||||
default: return "";
|
||||
}
|
||||
}
|
||||
|
||||
const WORKSPACE_DERIVED_KEYS: ReadonlySet<SettingsValidKey> = new Set([
|
||||
"workspace.slices_root",
|
||||
"workspace.steering_path",
|
||||
"workspace.field_notes_root",
|
||||
"workspace.specs_root",
|
||||
]);
|
||||
|
||||
function getDefaultValue(key: SettingsValidKey, workspaceRoot: string): string | number | boolean {
|
||||
if (WORKSPACE_DERIVED_KEYS.has(key)) {
|
||||
return deriveWorkspaceDefault(key, workspaceRoot);
|
||||
}
|
||||
switch (key) {
|
||||
case "daemon.port": return 7433;
|
||||
case "daemon.host": return "127.0.0.1";
|
||||
case "db.path": return path.join(path.dirname(DEFAULT_CONFIG_PATH), "openrig.sqlite");
|
||||
case "transcripts.enabled": return true;
|
||||
case "transcripts.path": return path.join(path.dirname(DEFAULT_CONFIG_PATH), "transcripts");
|
||||
case "workspace.root": return DEFAULT_WORKSPACE_ROOT;
|
||||
case "files.allowlist": return "";
|
||||
case "progress.scan_roots": return "";
|
||||
default: return "";
|
||||
}
|
||||
}
|
||||
|
||||
function coerceValue(key: SettingsValidKey, raw: string, workspaceRoot: string): string | number | boolean {
|
||||
const def = getDefaultValue(key, workspaceRoot);
|
||||
if (typeof def === "number") {
|
||||
const n = parseInt(raw, 10);
|
||||
if (isNaN(n)) throw new Error(`Invalid value for ${key}: expected a number, got "${raw}"`);
|
||||
return n;
|
||||
}
|
||||
if (typeof def === "boolean") {
|
||||
if (raw === "true" || raw === "1") return true;
|
||||
if (raw === "false" || raw === "0") return false;
|
||||
throw new Error(`Invalid value for ${key}: expected true/false, got "${raw}"`);
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
export interface ResolvedConfig {
|
||||
workspaceRoot: string;
|
||||
workspaceSlicesRoot: string;
|
||||
workspaceSteeringPath: string;
|
||||
workspaceFieldNotesRoot: string;
|
||||
workspaceSpecsRoot: string;
|
||||
filesAllowlistRaw: string;
|
||||
progressScanRootsRaw: string;
|
||||
}
|
||||
|
||||
export class SettingsStore {
|
||||
readonly configPath: string;
|
||||
|
||||
constructor(configPath?: string) {
|
||||
this.configPath = configPath ?? DEFAULT_CONFIG_PATH;
|
||||
}
|
||||
|
||||
resolveOne(key: SettingsValidKey, fileConfig?: Record<string, unknown>, workspaceRoot?: string): ResolvedSetting {
|
||||
const fc = fileConfig ?? this.readConfigFile();
|
||||
const wr = workspaceRoot ?? this.resolveWorkspaceRootRaw(fc);
|
||||
const defaultValue = getDefaultValue(key, wr);
|
||||
const envVal = readEnv(ENV_MAP[key].primary, ENV_MAP[key].legacy);
|
||||
if (envVal !== undefined && envVal !== "") {
|
||||
return { value: coerceValue(key, envVal, wr), source: "env", defaultValue };
|
||||
}
|
||||
const fileVal = getNestedValue(fc, KEY_TO_PATH[key]);
|
||||
if (fileVal !== undefined && fileVal !== null && fileVal !== "") {
|
||||
return { value: fileVal as string | number | boolean, source: "file", defaultValue };
|
||||
}
|
||||
return { value: defaultValue, source: "default", defaultValue };
|
||||
}
|
||||
|
||||
resolveAllWithSource(): Record<SettingsValidKey, ResolvedSetting> {
|
||||
const fc = this.readConfigFile();
|
||||
const wr = this.resolveWorkspaceRootRaw(fc);
|
||||
const out = {} as Record<SettingsValidKey, ResolvedSetting>;
|
||||
for (const key of SETTINGS_VALID_KEYS) {
|
||||
out[key] = this.resolveOne(key, fc, wr);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Project the raw resolution into a flattened config structure for daemon consumers. */
|
||||
resolveConfig(): ResolvedConfig {
|
||||
const fc = this.readConfigFile();
|
||||
const wr = this.resolveWorkspaceRootRaw(fc);
|
||||
return {
|
||||
workspaceRoot: wr,
|
||||
workspaceSlicesRoot: this.resolveOne("workspace.slices_root", fc, wr).value as string,
|
||||
workspaceSteeringPath: this.resolveOne("workspace.steering_path", fc, wr).value as string,
|
||||
workspaceFieldNotesRoot: this.resolveOne("workspace.field_notes_root", fc, wr).value as string,
|
||||
workspaceSpecsRoot: this.resolveOne("workspace.specs_root", fc, wr).value as string,
|
||||
filesAllowlistRaw: this.resolveOne("files.allowlist", fc, wr).value as string,
|
||||
progressScanRootsRaw: this.resolveOne("progress.scan_roots", fc, wr).value as string,
|
||||
};
|
||||
}
|
||||
|
||||
set(key: string, value: string): void {
|
||||
if (!isSettingsValidKey(key)) {
|
||||
throw new Error(`Unknown config key "${key}". Valid keys: ${SETTINGS_VALID_KEYS.join(", ")}`);
|
||||
}
|
||||
const fc = this.readConfigFile();
|
||||
const wr = this.resolveWorkspaceRootRaw(fc);
|
||||
const coerced = coerceValue(key, value, wr);
|
||||
setNestedValue(fc, KEY_TO_PATH[key], coerced);
|
||||
mkdirSync(path.dirname(this.configPath), { recursive: true });
|
||||
writeFileSync(this.configPath, JSON.stringify(fc, null, 2) + "\n", "utf-8");
|
||||
}
|
||||
|
||||
reset(key?: string): void {
|
||||
if (key === undefined) {
|
||||
try { unlinkSync(this.configPath); } catch { /* missing is fine */ }
|
||||
return;
|
||||
}
|
||||
if (!isSettingsValidKey(key)) {
|
||||
throw new Error(`Unknown config key "${key}". Valid keys: ${SETTINGS_VALID_KEYS.join(", ")}`);
|
||||
}
|
||||
if (!existsSync(this.configPath)) return;
|
||||
const fc = this.readConfigFile();
|
||||
const parts = KEY_TO_PATH[key];
|
||||
const parent = getNestedValue(fc, parts.slice(0, -1)) as Record<string, unknown> | undefined;
|
||||
if (parent && parts[parts.length - 1]! in parent) {
|
||||
delete parent[parts[parts.length - 1]!];
|
||||
}
|
||||
writeFileSync(this.configPath, JSON.stringify(fc, null, 2) + "\n", "utf-8");
|
||||
}
|
||||
|
||||
private resolveWorkspaceRootRaw(fileConfig: Record<string, unknown>): string {
|
||||
const envVal = readEnv(ENV_MAP["workspace.root"].primary, ENV_MAP["workspace.root"].legacy);
|
||||
if (envVal) return envVal;
|
||||
const fileVal = getNestedValue(fileConfig, KEY_TO_PATH["workspace.root"]) as string | undefined;
|
||||
if (fileVal) return fileVal;
|
||||
return DEFAULT_WORKSPACE_ROOT;
|
||||
}
|
||||
|
||||
private readConfigFile(): Record<string, unknown> {
|
||||
if (!existsSync(this.configPath)) return {};
|
||||
const raw = readFileSync(this.configPath, "utf-8");
|
||||
try {
|
||||
return JSON.parse(raw) as Record<string, unknown>;
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Config file at ${this.configPath} is malformed. Fix the JSON or reset with: rig config reset`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- User Settings v0: shared decoders ---
|
||||
|
||||
export interface NamedPair {
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export function parseNamedPairs(raw: string): NamedPair[] {
|
||||
if (!raw || !raw.trim()) return [];
|
||||
const out = new Map<string, string>();
|
||||
for (const pair of raw.split(",")) {
|
||||
const trimmed = pair.trim();
|
||||
if (!trimmed) continue;
|
||||
const colon = trimmed.indexOf(":");
|
||||
if (colon === -1) continue;
|
||||
const name = trimmed.slice(0, colon).trim();
|
||||
const rawPath = trimmed.slice(colon + 1).trim();
|
||||
if (!name || !rawPath) continue;
|
||||
out.set(name, rawPath);
|
||||
}
|
||||
return Array.from(out.entries()).map(([name, path]) => ({ name, path }));
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
// User Settings v0 — daemon HTTP route for the UI System drawer
|
||||
// Settings panel.
|
||||
//
|
||||
// Endpoints:
|
||||
// GET /api/config → all keys with value/source/default
|
||||
// GET /api/config/:key → one key with value/source/default
|
||||
// POST /api/config/:key → set a key (body: { value: string })
|
||||
// DELETE /api/config/:key → reset one key (revert to default)
|
||||
// POST /api/config/init-workspace → scaffold default workspace dirs
|
||||
//
|
||||
// The CLI (`rig config get/set/reset/init-workspace`) is the canonical
|
||||
// edit surface for operators + agents per founder dialog 2026-05-04.
|
||||
// This route exists for the UI; agents stay on CLI-shell-out per the
|
||||
// shipped openrig-user-settings skill.
|
||||
|
||||
import { Hono } from "hono";
|
||||
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
SETTINGS_VALID_KEYS,
|
||||
isSettingsValidKey,
|
||||
type SettingsStore,
|
||||
} from "../domain/user-settings/settings-store.js";
|
||||
|
||||
interface InitWorkspaceBody {
|
||||
root?: string;
|
||||
force?: boolean;
|
||||
dryRun?: boolean;
|
||||
}
|
||||
|
||||
const SUBDIRS = ["slices", "steering", "progress", "field-notes", "specs"] as const;
|
||||
|
||||
function readmeContent(subdir: string): string {
|
||||
switch (subdir) {
|
||||
case "slices": return "# slices\n\nYour slice authoring workspace. OpenRig's Slice Story View browses\nthis directory by default.\n";
|
||||
case "steering": return "# steering\n\nThe `STEERING.md` priority-stack lives here. OpenRig's Steering surface\ncomposes the 6-panel view from this file.\n";
|
||||
case "progress": return "# progress\n\nPROGRESS.md tree. OpenRig's Progress browse view scans this directory\nrecursively for PROGRESS.md files.\n";
|
||||
case "field-notes": return "# field-notes\n\nOperator field notes. Free-form markdown notes from your daily work.\n";
|
||||
case "specs": return "# specs\n\nWorkspace specs (rig / agent / workflow specs / context packs).\n";
|
||||
default: return `# ${subdir}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
const STEERING_PLACEHOLDER = `---
|
||||
title: Priority Stack
|
||||
status: placeholder
|
||||
---
|
||||
|
||||
# OpenRig Priority Stack
|
||||
|
||||
This file is a placeholder created by \`rig config init-workspace\`. Edit
|
||||
it to record your top 3 priorities.
|
||||
|
||||
## Top 3
|
||||
|
||||
1. <priority one>
|
||||
2. <priority two>
|
||||
3. <priority three>
|
||||
|
||||
## In Motion
|
||||
|
||||
(Active slices land here.)
|
||||
|
||||
## Loop State
|
||||
|
||||
(Health gates + loop diagnostics land here.)
|
||||
`;
|
||||
|
||||
export function configRoutes(): Hono {
|
||||
const router = new Hono();
|
||||
|
||||
router.get("/", (c) => {
|
||||
const store = c.get("settingsStore" as never) as SettingsStore | undefined;
|
||||
if (!store) return c.json({ error: "settings_unavailable" }, 503);
|
||||
return c.json({ settings: store.resolveAllWithSource() });
|
||||
});
|
||||
|
||||
router.post("/init-workspace", async (c) => {
|
||||
const store = c.get("settingsStore" as never) as SettingsStore | undefined;
|
||||
if (!store) return c.json({ error: "settings_unavailable" }, 503);
|
||||
const body = (await c.req.json<InitWorkspaceBody>().catch(() => ({}))) as InitWorkspaceBody;
|
||||
const root = body.root ?? (store.resolveOne("workspace.root").value as string);
|
||||
const dryRun = !!body.dryRun;
|
||||
const force = !!body.force;
|
||||
|
||||
const subdirs: Array<{ name: string; path: string; created: boolean }> = [];
|
||||
const files: Array<{ relPath: string; absPath: string; created: boolean; skipped: "exists" | null }> = [];
|
||||
|
||||
const rootExists = existsSync(root);
|
||||
if (!rootExists && !dryRun) mkdirSync(root, { recursive: true });
|
||||
|
||||
for (const sub of SUBDIRS) {
|
||||
const subPath = join(root, sub);
|
||||
const subExists = existsSync(subPath);
|
||||
if (!subExists && !dryRun) mkdirSync(subPath, { recursive: true });
|
||||
subdirs.push({ name: sub, path: subPath, created: !subExists });
|
||||
|
||||
const readmePath = join(subPath, "README.md");
|
||||
const readmeExists = existsSync(readmePath);
|
||||
if (readmeExists && !force) {
|
||||
files.push({ relPath: `${sub}/README.md`, absPath: readmePath, created: false, skipped: "exists" });
|
||||
} else {
|
||||
if (!dryRun) writeFileSync(readmePath, readmeContent(sub), "utf-8");
|
||||
files.push({ relPath: `${sub}/README.md`, absPath: readmePath, created: true, skipped: null });
|
||||
}
|
||||
}
|
||||
|
||||
const steeringPath = join(root, "steering", "STEERING.md");
|
||||
const steeringExists = existsSync(steeringPath);
|
||||
if (steeringExists && !force) {
|
||||
files.push({ relPath: "steering/STEERING.md", absPath: steeringPath, created: false, skipped: "exists" });
|
||||
} else {
|
||||
if (!dryRun) writeFileSync(steeringPath, STEERING_PLACEHOLDER, "utf-8");
|
||||
files.push({ relPath: "steering/STEERING.md", absPath: steeringPath, created: true, skipped: null });
|
||||
}
|
||||
|
||||
return c.json({ root, rootCreated: !rootExists, subdirs, files, dryRun });
|
||||
});
|
||||
|
||||
router.get("/:key", (c) => {
|
||||
const store = c.get("settingsStore" as never) as SettingsStore | undefined;
|
||||
if (!store) return c.json({ error: "settings_unavailable" }, 503);
|
||||
const key = c.req.param("key");
|
||||
if (!isSettingsValidKey(key)) {
|
||||
return c.json({ error: `Unknown config key '${key}'`, validKeys: SETTINGS_VALID_KEYS }, 400);
|
||||
}
|
||||
return c.json(store.resolveOne(key));
|
||||
});
|
||||
|
||||
router.post("/:key", async (c) => {
|
||||
const store = c.get("settingsStore" as never) as SettingsStore | undefined;
|
||||
if (!store) return c.json({ error: "settings_unavailable" }, 503);
|
||||
const key = c.req.param("key");
|
||||
if (!isSettingsValidKey(key)) {
|
||||
return c.json({ error: `Unknown config key '${key}'`, validKeys: SETTINGS_VALID_KEYS }, 400);
|
||||
}
|
||||
const body = (await c.req.json<{ value?: string }>().catch(() => ({}))) as { value?: string };
|
||||
if (typeof body.value !== "string") {
|
||||
return c.json({ error: "value_required", hint: "POST body must be { \"value\": <string> }" }, 400);
|
||||
}
|
||||
try {
|
||||
store.set(key, body.value);
|
||||
return c.json({ ok: true, key, resolved: store.resolveOne(key) });
|
||||
} catch (err) {
|
||||
return c.json({ error: (err as Error).message }, 400);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete("/:key", (c) => {
|
||||
const store = c.get("settingsStore" as never) as SettingsStore | undefined;
|
||||
if (!store) return c.json({ error: "settings_unavailable" }, 503);
|
||||
const key = c.req.param("key");
|
||||
if (!isSettingsValidKey(key)) {
|
||||
return c.json({ error: `Unknown config key '${key}'`, validKeys: SETTINGS_VALID_KEYS }, 400);
|
||||
}
|
||||
try {
|
||||
store.reset(key);
|
||||
return c.json({ ok: true, key, resolved: store.resolveOne(key) });
|
||||
} catch (err) {
|
||||
return c.json({ error: (err as Error).message }, 400);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -53,6 +53,7 @@ import { askRoutes } from "./routes/ask.js";
|
||||
import type { AskService } from "./domain/ask-service.js";
|
||||
import { specReviewRoutes } from "./routes/spec-review.js";
|
||||
import { specLibraryRoutes } from "./routes/spec-library.js";
|
||||
import { configRoutes } from "./routes/config.js";
|
||||
import type { SpecReviewService } from "./domain/spec-review-service.js";
|
||||
import type { SpecLibraryService } from "./domain/spec-library-service.js";
|
||||
import type { ChatRepository } from "./domain/chat-repository.js";
|
||||
@@ -152,6 +153,8 @@ export interface AppDeps {
|
||||
// "slices_root_not_configured" 503 so the UI can surface a setup hint.
|
||||
sliceIndexer?: import("./domain/slices/slice-indexer.js").SliceIndexer;
|
||||
sliceDetailProjector?: import("./domain/slices/slice-detail-projector.js").SliceDetailProjector;
|
||||
/** User Settings v0 — daemon-side settings store (env > file > default). */
|
||||
settingsStore?: import("./domain/user-settings/settings-store.js").SettingsStore;
|
||||
/** UI Enhancement Pack v0 — file allowlist + browser routes (item 3). */
|
||||
filesAllowlist?: import("./domain/files/path-safety.js").AllowlistRoot[];
|
||||
/** UI Enhancement Pack v0 — atomic write service (item 4). */
|
||||
@@ -337,6 +340,7 @@ export function createApp(deps: AppDeps): Hono {
|
||||
c.set("sliceIndexer" as never, deps.sliceIndexer);
|
||||
c.set("sliceDetailProjector" as never, deps.sliceDetailProjector);
|
||||
c.set("filesAllowlist" as never, deps.filesAllowlist);
|
||||
c.set("settingsStore" as never, deps.settingsStore);
|
||||
c.set("fileWriteService" as never, deps.fileWriteService);
|
||||
c.set("progressIndexer" as never, deps.progressIndexer);
|
||||
c.set("steeringComposer" as never, deps.steeringComposer);
|
||||
@@ -385,6 +389,7 @@ export function createApp(deps: AppDeps): Hono {
|
||||
app.route("/api/ask", askRoutes);
|
||||
app.route("/api/specs/review", specReviewRoutes());
|
||||
app.route("/api/specs/library", specLibraryRoutes());
|
||||
app.route("/api/config", configRoutes());
|
||||
app.route("/api/whoami", whoamiRoutes());
|
||||
app.route("/api/seat", seatRoutes);
|
||||
app.route("/api/rigs/:rigId/chat", chatRoutes());
|
||||
|
||||
@@ -737,23 +737,26 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
// with structured config hints so the UI can surface a setup message
|
||||
// instead of a generic error.
|
||||
{
|
||||
const { readAllowlistFromEnv } = await import("./domain/files/path-safety.js");
|
||||
const { decodeAllowlist } = await import("./domain/files/path-safety.js");
|
||||
const { FileWriteService } = await import("./domain/files/file-write-service.js");
|
||||
const { ProgressIndexer, readProgressRootsFromEnv } = await import("./domain/progress/progress-indexer.js");
|
||||
const filesAllowlist = readAllowlistFromEnv();
|
||||
const { ProgressIndexer, decodeProgressScanRoots } = await import("./domain/progress/progress-indexer.js");
|
||||
// User Settings v0 — UEP env-vars graduated to typed settings.
|
||||
// Resolution: env > settings file > empty. SettingsStore handles
|
||||
// the env > file > default precedence; we just decode the raw
|
||||
// string into structured roots.
|
||||
const { SettingsStore } = await import("./domain/user-settings/settings-store.js");
|
||||
const settingsStore = new SettingsStore();
|
||||
deps.settingsStore = settingsStore;
|
||||
const cfg = settingsStore.resolveConfig();
|
||||
const filesAllowlist = decodeAllowlist(cfg.filesAllowlistRaw);
|
||||
deps.filesAllowlist = filesAllowlist;
|
||||
deps.fileWriteService = filesAllowlist.length > 0
|
||||
? new FileWriteService({
|
||||
allowlist: filesAllowlist,
|
||||
// Honor OPENRIG_HOME for state isolation. Without this, the
|
||||
// service's DEFAULT_AUDIT_FILE falls back to process.env.HOME
|
||||
// and writes audit rows to the host's ~/.openrig/, which
|
||||
// breaks isolated dogfood/test daemons (rows bleed back to
|
||||
// the operator's real home regardless of OPENRIG_HOME).
|
||||
auditFilePath: nodePath.join(OPENRIG_HOME, "file-edit-audit.jsonl"),
|
||||
})
|
||||
: null;
|
||||
deps.progressIndexer = new ProgressIndexer({ roots: readProgressRootsFromEnv() });
|
||||
deps.progressIndexer = new ProgressIndexer({ roots: decodeProgressScanRoots(cfg.progressScanRootsRaw) });
|
||||
|
||||
// Operator Surface Reconciliation v0 — steering composer (item 1).
|
||||
// Reads workspace root + per-section overrides from env. Empty
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
// User Settings v0 — daemon HTTP route tests.
|
||||
//
|
||||
// Pins the load-bearing behaviors of /api/config:
|
||||
// - GET /api/config returns all 12 keys with source + default
|
||||
// - GET /api/config/:key returns one key
|
||||
// - POST /api/config/:key sets the value, persists to disk
|
||||
// - DELETE /api/config/:key reverts one key to default
|
||||
// - POST /api/config/init-workspace creates 5 subdirs + READMEs
|
||||
// - 503 when settingsStore is unavailable
|
||||
// - 400 on unknown keys / missing body
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import { mkdtempSync, rmSync, existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { SettingsStore } from "../src/domain/user-settings/settings-store.js";
|
||||
import { configRoutes } from "../src/routes/config.js";
|
||||
|
||||
function clearEnv(): () => void {
|
||||
const keys = [
|
||||
"OPENRIG_PORT", "OPENRIG_FILES_ALLOWLIST", "OPENRIG_PROGRESS_SCAN_ROOTS",
|
||||
"OPENRIG_WORKSPACE_ROOT",
|
||||
];
|
||||
const saved: Record<string, string | undefined> = {};
|
||||
for (const k of keys) {
|
||||
saved[k] = process.env[k];
|
||||
delete process.env[k];
|
||||
}
|
||||
return () => {
|
||||
for (const k of keys) {
|
||||
if (saved[k] !== undefined) process.env[k] = saved[k]!;
|
||||
else delete process.env[k];
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
describe("config routes (User Settings v0)", () => {
|
||||
let tmpDir: string;
|
||||
let configPath: string;
|
||||
let store: SettingsStore;
|
||||
let restoreEnv: () => void;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), "config-routes-"));
|
||||
configPath = join(tmpDir, "config.json");
|
||||
store = new SettingsStore(configPath);
|
||||
restoreEnv = clearEnv();
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
restoreEnv();
|
||||
});
|
||||
|
||||
function buildApp(): Hono {
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("settingsStore" as never, store);
|
||||
await next();
|
||||
});
|
||||
app.route("/api/config", configRoutes());
|
||||
return app;
|
||||
}
|
||||
|
||||
it("GET /api/config returns all 12 keys with source + default", async () => {
|
||||
const app = buildApp();
|
||||
const res = await app.request("/api/config");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { settings: Record<string, { value: unknown; source: string }> };
|
||||
expect(Object.keys(body.settings).length).toBe(12);
|
||||
expect(body.settings["daemon.port"]?.source).toBe("default");
|
||||
});
|
||||
|
||||
it("GET /api/config/:key returns the resolved value", async () => {
|
||||
store.set("workspace.root", "/custom/ws");
|
||||
const app = buildApp();
|
||||
const res = await app.request("/api/config/workspace.root");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { value: string; source: string };
|
||||
expect(body.value).toBe("/custom/ws");
|
||||
expect(body.source).toBe("file");
|
||||
});
|
||||
|
||||
it("GET /api/config/:key 400s on unknown key", async () => {
|
||||
const app = buildApp();
|
||||
const res = await app.request("/api/config/workspace.bogus");
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json() as { validKeys: string[] };
|
||||
expect(body.validKeys).toContain("workspace.root");
|
||||
});
|
||||
|
||||
it("POST /api/config/:key sets the value and persists to disk", async () => {
|
||||
const app = buildApp();
|
||||
const res = await app.request("/api/config/workspace.slices_root", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ value: "/founder/slices" }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { ok: boolean; resolved: { value: string } };
|
||||
expect(body.ok).toBe(true);
|
||||
expect(body.resolved.value).toBe("/founder/slices");
|
||||
// Disk persisted
|
||||
expect(JSON.parse(readFileSync(configPath, "utf-8")).workspace.slicesRoot).toBe("/founder/slices");
|
||||
});
|
||||
|
||||
it("POST /api/config/:key 400s without value field", async () => {
|
||||
const app = buildApp();
|
||||
const res = await app.request("/api/config/workspace.root", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("DELETE /api/config/:key resets to default", async () => {
|
||||
store.set("workspace.slices_root", "/x");
|
||||
const app = buildApp();
|
||||
const res = await app.request("/api/config/workspace.slices_root", { method: "DELETE" });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { resolved: { source: string } };
|
||||
expect(body.resolved.source).toBe("default");
|
||||
});
|
||||
|
||||
it("POST /api/config/init-workspace creates 5 subdirs + READMEs", async () => {
|
||||
const root = join(tmpDir, "workspace");
|
||||
const app = buildApp();
|
||||
const res = await app.request("/api/config/init-workspace", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ root }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { root: string; subdirs: Array<{ name: string }>; files: Array<{ relPath: string }> };
|
||||
expect(body.root).toBe(root);
|
||||
expect(body.subdirs.map((s) => s.name)).toEqual([
|
||||
"slices", "steering", "progress", "field-notes", "specs",
|
||||
]);
|
||||
expect(existsSync(join(root, "slices", "README.md"))).toBe(true);
|
||||
expect(existsSync(join(root, "steering", "STEERING.md"))).toBe(true);
|
||||
});
|
||||
|
||||
it("POST /api/config/init-workspace --dry-run does not write", async () => {
|
||||
const root = join(tmpDir, "ws-dry");
|
||||
const app = buildApp();
|
||||
const res = await app.request("/api/config/init-workspace", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ root, dryRun: true }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(existsSync(root)).toBe(false);
|
||||
});
|
||||
|
||||
it("503 when settingsStore is missing from context", async () => {
|
||||
const app = new Hono();
|
||||
app.route("/api/config", configRoutes());
|
||||
const res = await app.request("/api/config");
|
||||
expect(res.status).toBe(503);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
// User Settings v0 — daemon-side SettingsStore tests.
|
||||
//
|
||||
// Pins that the daemon's SettingsStore stays in lockstep with the CLI
|
||||
// ConfigStore: same VALID_KEYS, same env-map, same env > file > default
|
||||
// resolution, same file format. Drift between the two would mean the
|
||||
// daemon's UI route surfaces a different value than the CLI.
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtempSync, rmSync, writeFileSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import {
|
||||
SettingsStore,
|
||||
SETTINGS_VALID_KEYS,
|
||||
parseNamedPairs,
|
||||
} from "../src/domain/user-settings/settings-store.js";
|
||||
|
||||
function clearEnv(): () => void {
|
||||
const keys = [
|
||||
"OPENRIG_PORT", "OPENRIG_HOST", "OPENRIG_DB",
|
||||
"OPENRIG_TRANSCRIPTS_ENABLED", "OPENRIG_TRANSCRIPTS_PATH",
|
||||
"OPENRIG_WORKSPACE_ROOT", "OPENRIG_WORKSPACE_SLICES_ROOT",
|
||||
"OPENRIG_WORKSPACE_STEERING_PATH", "OPENRIG_WORKSPACE_FIELD_NOTES_ROOT",
|
||||
"OPENRIG_WORKSPACE_SPECS_ROOT",
|
||||
"OPENRIG_FILES_ALLOWLIST", "OPENRIG_PROGRESS_SCAN_ROOTS",
|
||||
"RIGGED_PORT", "RIGGED_HOST", "RIGGED_FILES_ALLOWLIST",
|
||||
"RIGGED_PROGRESS_SCAN_ROOTS",
|
||||
];
|
||||
const saved: Record<string, string | undefined> = {};
|
||||
for (const k of keys) {
|
||||
saved[k] = process.env[k];
|
||||
delete process.env[k];
|
||||
}
|
||||
return () => {
|
||||
for (const k of keys) {
|
||||
if (saved[k] !== undefined) process.env[k] = saved[k]!;
|
||||
else delete process.env[k];
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
describe("SettingsStore (User Settings v0)", () => {
|
||||
let tmpDir: string;
|
||||
let configPath: string;
|
||||
let restoreEnv: () => void;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), "settings-store-"));
|
||||
configPath = join(tmpDir, "config.json");
|
||||
restoreEnv = clearEnv();
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
restoreEnv();
|
||||
});
|
||||
|
||||
it("SETTINGS_VALID_KEYS matches the documented 12-key set", () => {
|
||||
expect([...SETTINGS_VALID_KEYS]).toEqual([
|
||||
"daemon.port", "daemon.host", "db.path",
|
||||
"transcripts.enabled", "transcripts.path",
|
||||
"workspace.root", "workspace.slices_root", "workspace.steering_path",
|
||||
"workspace.field_notes_root", "workspace.specs_root",
|
||||
"files.allowlist", "progress.scan_roots",
|
||||
]);
|
||||
});
|
||||
|
||||
it("resolveAllWithSource returns every key with source + default", () => {
|
||||
const store = new SettingsStore(configPath);
|
||||
const all = store.resolveAllWithSource();
|
||||
for (const key of SETTINGS_VALID_KEYS) {
|
||||
expect(all[key]).toBeDefined();
|
||||
expect(["env", "file", "default"]).toContain(all[key].source);
|
||||
}
|
||||
});
|
||||
|
||||
it("env > file > default for daemon.port", () => {
|
||||
const store = new SettingsStore(configPath);
|
||||
// No file → default
|
||||
expect(store.resolveOne("daemon.port").source).toBe("default");
|
||||
expect(store.resolveOne("daemon.port").value).toBe(7433);
|
||||
|
||||
// File → file
|
||||
store.set("daemon.port", "9999");
|
||||
expect(store.resolveOne("daemon.port").value).toBe(9999);
|
||||
expect(store.resolveOne("daemon.port").source).toBe("file");
|
||||
|
||||
// Env wins
|
||||
process.env["OPENRIG_PORT"] = "8888";
|
||||
try {
|
||||
expect(store.resolveOne("daemon.port").value).toBe(8888);
|
||||
expect(store.resolveOne("daemon.port").source).toBe("env");
|
||||
} finally {
|
||||
delete process.env["OPENRIG_PORT"];
|
||||
}
|
||||
});
|
||||
|
||||
it("workspace.root cascades into per-subdir defaults", () => {
|
||||
const store = new SettingsStore(configPath);
|
||||
store.set("workspace.root", "/custom/ws");
|
||||
const cfg = store.resolveConfig();
|
||||
expect(cfg.workspaceRoot).toBe("/custom/ws");
|
||||
expect(cfg.workspaceSlicesRoot).toBe("/custom/ws/slices");
|
||||
expect(cfg.workspaceSteeringPath).toBe("/custom/ws/steering/STEERING.md");
|
||||
expect(cfg.workspaceFieldNotesRoot).toBe("/custom/ws/field-notes");
|
||||
expect(cfg.workspaceSpecsRoot).toBe("/custom/ws/specs");
|
||||
});
|
||||
|
||||
it("per-subdir override beats the workspace.root cascade", () => {
|
||||
const store = new SettingsStore(configPath);
|
||||
store.set("workspace.root", "/ws");
|
||||
store.set("workspace.slices_root", "/founder/slices");
|
||||
const cfg = store.resolveConfig();
|
||||
expect(cfg.workspaceSlicesRoot).toBe("/founder/slices");
|
||||
// Other subdirs still cascade from workspace.root:
|
||||
expect(cfg.workspaceFieldNotesRoot).toBe("/ws/field-notes");
|
||||
});
|
||||
|
||||
it("UEP env-var graduation: OPENRIG_FILES_ALLOWLIST resolves files.allowlist", () => {
|
||||
const store = new SettingsStore(configPath);
|
||||
process.env["OPENRIG_FILES_ALLOWLIST"] = "ws:/Users/me";
|
||||
try {
|
||||
expect(store.resolveOne("files.allowlist").value).toBe("ws:/Users/me");
|
||||
expect(store.resolveOne("files.allowlist").source).toBe("env");
|
||||
} finally {
|
||||
delete process.env["OPENRIG_FILES_ALLOWLIST"];
|
||||
}
|
||||
});
|
||||
|
||||
it("set + reset round-trips through the file format", () => {
|
||||
const store = new SettingsStore(configPath);
|
||||
store.set("workspace.slices_root", "/founder/slices");
|
||||
expect(existsSync(configPath)).toBe(true);
|
||||
expect(store.resolveOne("workspace.slices_root").value).toBe("/founder/slices");
|
||||
|
||||
store.reset("workspace.slices_root");
|
||||
expect(store.resolveOne("workspace.slices_root").source).toBe("default");
|
||||
|
||||
store.reset();
|
||||
expect(existsSync(configPath)).toBe(false);
|
||||
});
|
||||
|
||||
it("set rejects unknown keys", () => {
|
||||
const store = new SettingsStore(configPath);
|
||||
expect(() => store.set("workspace.bogus", "x")).toThrow(/Unknown config key/);
|
||||
});
|
||||
|
||||
it("malformed JSON throws with reset hint", () => {
|
||||
writeFileSync(configPath, "{not json");
|
||||
const store = new SettingsStore(configPath);
|
||||
expect(() => store.resolveAllWithSource()).toThrow(/malformed/i);
|
||||
});
|
||||
|
||||
it("file format matches CLI ConfigStore: nested keys persist as JSON", () => {
|
||||
const store = new SettingsStore(configPath);
|
||||
store.set("workspace.slices_root", "/x");
|
||||
store.set("daemon.port", "1234");
|
||||
const raw = JSON.parse(require("node:fs").readFileSync(configPath, "utf-8"));
|
||||
expect(raw.workspace.slicesRoot).toBe("/x");
|
||||
expect(raw.daemon.port).toBe(1234);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseNamedPairs (daemon copy)", () => {
|
||||
it("decodes name:path,name:path pairs", () => {
|
||||
expect(parseNamedPairs("ws:/abs/a,docs:/abs/b")).toEqual([
|
||||
{ name: "ws", path: "/abs/a" },
|
||||
{ name: "docs", path: "/abs/b" },
|
||||
]);
|
||||
});
|
||||
it("returns empty for empty/whitespace", () => {
|
||||
expect(parseNamedPairs("")).toEqual([]);
|
||||
expect(parseNamedPairs(" ")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -2,8 +2,11 @@ import { useEffect, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { PanelsTopLeft, ServerCog } from "lucide-react";
|
||||
import { LogFeedList } from "./ActivityFeed.js";
|
||||
import { SettingsTab } from "./system/SettingsTab.js";
|
||||
import type { ActivityEvent } from "../hooks/useActivityFeed.js";
|
||||
|
||||
type SystemTab = "log" | "status" | "settings";
|
||||
|
||||
async function fetchHealth(): Promise<boolean> {
|
||||
const res = await fetch("/healthz");
|
||||
if (!res.ok) throw new Error("unhealthy");
|
||||
@@ -19,7 +22,7 @@ async function fetchCmux(): Promise<{ available: boolean }> {
|
||||
interface SystemPanelProps {
|
||||
onClose: () => void;
|
||||
events: ActivityEvent[];
|
||||
initialTab?: "log" | "status";
|
||||
initialTab?: SystemTab;
|
||||
}
|
||||
|
||||
function statusTone(ok: boolean | null): string {
|
||||
@@ -33,7 +36,7 @@ function statusLabel(ok: boolean | null, positive: string, negative: string, unk
|
||||
}
|
||||
|
||||
export function SystemPanel({ onClose, events, initialTab = "log" }: SystemPanelProps) {
|
||||
const [activeTab, setActiveTab] = useState<"log" | "status">(initialTab);
|
||||
const [activeTab, setActiveTab] = useState<SystemTab>(initialTab);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveTab(initialTab);
|
||||
@@ -88,13 +91,21 @@ export function SystemPanel({ onClose, events, initialTab = "log" }: SystemPanel
|
||||
>
|
||||
Status
|
||||
</button>
|
||||
<button
|
||||
data-testid="system-tab-settings"
|
||||
onClick={() => setActiveTab("settings")}
|
||||
className={`flex-1 py-2 text-xs font-mono uppercase text-center ${activeTab === "settings" ? "border-b-2 border-stone-800 font-bold text-stone-900" : "text-stone-400"}`}
|
||||
>
|
||||
Settings
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === "log" ? (
|
||||
{activeTab === "log" && (
|
||||
<div className="flex flex-1 min-h-0 flex-col overflow-hidden" data-testid="system-log-tab">
|
||||
<LogFeedList events={events} />
|
||||
</div>
|
||||
) : (
|
||||
)}
|
||||
{activeTab === "status" && (
|
||||
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-4" data-testid="system-status-tab">
|
||||
<section className="border border-stone-300/28 bg-white/12 px-3 py-3">
|
||||
<div className="font-mono text-[8px] text-stone-400 uppercase tracking-wider mb-3">Runtime</div>
|
||||
@@ -124,6 +135,7 @@ export function SystemPanel({ onClose, events, initialTab = "log" }: SystemPanel
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
{activeTab === "settings" && <SettingsTab />}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
// User Settings v0 — System drawer Settings tab.
|
||||
//
|
||||
// Three sections at v0: Workspace, Files, Progress. Each setting shows
|
||||
// the resolved value + source (env / file / default) + default.
|
||||
// Operators set per-key via inline form; Init Workspace button + Reset
|
||||
// button per setting.
|
||||
//
|
||||
// Keep this read+write surface small. The CLI (`rig config get/set/reset`)
|
||||
// is the canonical agent-edit path per founder dialog.
|
||||
|
||||
import { useState, type ReactNode } from "react";
|
||||
import {
|
||||
useSettings,
|
||||
useSetSetting,
|
||||
useResetSetting,
|
||||
useInitWorkspace,
|
||||
type SettingsKey,
|
||||
type ResolvedSetting,
|
||||
} from "../../hooks/useSettings.js";
|
||||
|
||||
interface SettingsRowProps {
|
||||
label: string;
|
||||
settingKey: SettingsKey;
|
||||
resolved: ResolvedSetting;
|
||||
testIdPrefix: string;
|
||||
}
|
||||
|
||||
function SettingsRow({ label, settingKey, resolved, testIdPrefix }: SettingsRowProps) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(String(resolved.value ?? ""));
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const setMutation = useSetSetting();
|
||||
const resetMutation = useResetSetting();
|
||||
|
||||
const isOverridden = resolved.source !== "default";
|
||||
|
||||
const onSave = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
await setMutation.mutateAsync({ key: settingKey, value: draft });
|
||||
setEditing(false);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
const onReset = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
await resetMutation.mutateAsync(settingKey);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid={`${testIdPrefix}-${settingKey}`}
|
||||
className="border border-stone-300/40 bg-white/8 px-3 py-2 space-y-1"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-mono text-[10px] text-stone-700 truncate">{label}</span>
|
||||
<span className="font-mono text-[8px] uppercase tracking-[0.10em] text-stone-500 shrink-0">
|
||||
source: {resolved.source}
|
||||
</span>
|
||||
</div>
|
||||
{editing ? (
|
||||
<div className="space-y-1">
|
||||
<input
|
||||
data-testid={`${testIdPrefix}-${settingKey}-input`}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
className="w-full border border-stone-300 bg-white/80 px-2 py-1 font-mono text-[10px]"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
data-testid={`${testIdPrefix}-${settingKey}-save`}
|
||||
onClick={() => void onSave()}
|
||||
disabled={setMutation.isPending}
|
||||
className="font-mono text-[8px] uppercase border border-stone-300 px-2 py-0.5 hover:bg-stone-200 disabled:opacity-50"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setEditing(false); setDraft(String(resolved.value ?? "")); setError(null); }}
|
||||
className="font-mono text-[8px] uppercase text-stone-500 hover:text-stone-900"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
<div className="font-mono text-[10px] text-stone-800 break-all">{String(resolved.value ?? "")}</div>
|
||||
<div className="font-mono text-[8px] text-stone-500 break-all">default: {String(resolved.defaultValue ?? "")}</div>
|
||||
<div className="flex gap-1 pt-1">
|
||||
<button
|
||||
data-testid={`${testIdPrefix}-${settingKey}-edit`}
|
||||
onClick={() => { setEditing(true); setError(null); }}
|
||||
className="font-mono text-[8px] uppercase border border-stone-300 px-1 py-0.5 hover:bg-stone-200"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
{isOverridden && (
|
||||
<button
|
||||
data-testid={`${testIdPrefix}-${settingKey}-reset`}
|
||||
onClick={() => void onReset()}
|
||||
disabled={resetMutation.isPending}
|
||||
className="font-mono text-[8px] uppercase border border-stone-300 px-1 py-0.5 hover:bg-stone-200 disabled:opacity-50"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{error && <div data-testid={`${testIdPrefix}-${settingKey}-error`} className="font-mono text-[9px] text-red-600">{error}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
<section className="space-y-2">
|
||||
<div className="font-mono text-[8px] uppercase tracking-[0.16em] text-stone-500">{title}</div>
|
||||
<div className="space-y-1">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsTab() {
|
||||
const { data, isLoading, error } = useSettings();
|
||||
const initWorkspace = useInitWorkspace();
|
||||
const [initResult, setInitResult] = useState<string | null>(null);
|
||||
const [initError, setInitError] = useState<string | null>(null);
|
||||
|
||||
const onInitWorkspace = async () => {
|
||||
setInitError(null);
|
||||
setInitResult(null);
|
||||
try {
|
||||
const r = await initWorkspace.mutateAsync({});
|
||||
setInitResult(`Initialized at ${r.root} — created ${r.subdirs.filter((s) => s.created).length} subdir(s).`);
|
||||
} catch (err) {
|
||||
setInitError((err as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <div data-testid="settings-loading" className="px-4 py-3 font-mono text-[10px] text-stone-400">Loading settings…</div>;
|
||||
}
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div data-testid="settings-error" className="px-4 py-3 font-mono text-[10px] text-red-600">
|
||||
Could not load settings: {(error as Error)?.message ?? "unknown"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const s = data.settings;
|
||||
|
||||
return (
|
||||
<div data-testid="settings-tab" className="flex-1 overflow-y-auto px-4 py-3 space-y-4">
|
||||
<Section title="Workspace">
|
||||
<SettingsRow label="Workspace root" settingKey="workspace.root" resolved={s["workspace.root"]} testIdPrefix="setting" />
|
||||
<SettingsRow label="Slices root" settingKey="workspace.slices_root" resolved={s["workspace.slices_root"]} testIdPrefix="setting" />
|
||||
<SettingsRow label="Steering path" settingKey="workspace.steering_path" resolved={s["workspace.steering_path"]} testIdPrefix="setting" />
|
||||
<SettingsRow label="Field notes root" settingKey="workspace.field_notes_root" resolved={s["workspace.field_notes_root"]} testIdPrefix="setting" />
|
||||
<SettingsRow label="Specs root" settingKey="workspace.specs_root" resolved={s["workspace.specs_root"]} testIdPrefix="setting" />
|
||||
<button
|
||||
data-testid="settings-init-workspace"
|
||||
onClick={() => void onInitWorkspace()}
|
||||
disabled={initWorkspace.isPending}
|
||||
className="mt-2 font-mono text-[9px] uppercase border border-stone-400 px-2 py-1 hover:bg-stone-200 disabled:opacity-50"
|
||||
>
|
||||
{initWorkspace.isPending ? "Initializing…" : "Init Workspace"}
|
||||
</button>
|
||||
{initResult && <div data-testid="settings-init-result" className="font-mono text-[9px] text-stone-600">{initResult}</div>}
|
||||
{initError && <div data-testid="settings-init-error" className="font-mono text-[9px] text-red-600">{initError}</div>}
|
||||
</Section>
|
||||
|
||||
<Section title="Files (browser allowlist)">
|
||||
<SettingsRow label="Allowlist (name:/abs/path,...)" settingKey="files.allowlist" resolved={s["files.allowlist"]} testIdPrefix="setting" />
|
||||
</Section>
|
||||
|
||||
<Section title="Progress">
|
||||
<SettingsRow label="Scan roots (name:/abs/path,...)" settingKey="progress.scan_roots" resolved={s["progress.scan_roots"]} testIdPrefix="setting" />
|
||||
</Section>
|
||||
|
||||
<Section title="Daemon (legacy)">
|
||||
<SettingsRow label="Port" settingKey="daemon.port" resolved={s["daemon.port"]} testIdPrefix="setting" />
|
||||
<SettingsRow label="Host" settingKey="daemon.host" resolved={s["daemon.host"]} testIdPrefix="setting" />
|
||||
</Section>
|
||||
|
||||
<Section title="Database / Transcripts (legacy)">
|
||||
<SettingsRow label="DB path" settingKey="db.path" resolved={s["db.path"]} testIdPrefix="setting" />
|
||||
<SettingsRow label="Transcripts enabled" settingKey="transcripts.enabled" resolved={s["transcripts.enabled"]} testIdPrefix="setting" />
|
||||
<SettingsRow label="Transcripts path" settingKey="transcripts.path" resolved={s["transcripts.path"]} testIdPrefix="setting" />
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// User Settings v0 — UI hooks for the daemon /api/config route.
|
||||
//
|
||||
// Consumed by the System drawer Settings tab. Bypasses CLI shell-out
|
||||
// per founder dialog (CLI is the canonical agent-edit path; UI uses
|
||||
// the daemon HTTP route directly).
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
export type SettingSource = "env" | "file" | "default";
|
||||
|
||||
export interface ResolvedSetting {
|
||||
value: string | number | boolean;
|
||||
source: SettingSource;
|
||||
defaultValue: string | number | boolean;
|
||||
}
|
||||
|
||||
export type SettingsKey =
|
||||
| "daemon.port" | "daemon.host" | "db.path"
|
||||
| "transcripts.enabled" | "transcripts.path"
|
||||
| "workspace.root" | "workspace.slices_root" | "workspace.steering_path"
|
||||
| "workspace.field_notes_root" | "workspace.specs_root"
|
||||
| "files.allowlist" | "progress.scan_roots";
|
||||
|
||||
export interface SettingsResponse {
|
||||
settings: Record<SettingsKey, ResolvedSetting>;
|
||||
}
|
||||
|
||||
async function fetchSettings(): Promise<SettingsResponse> {
|
||||
const res = await fetch("/api/config");
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({})) as { error?: string };
|
||||
throw new Error(body.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export function useSettings() {
|
||||
return useQuery({
|
||||
queryKey: ["settings", "all"],
|
||||
queryFn: fetchSettings,
|
||||
staleTime: 0,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetSetting() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (input: { key: SettingsKey; value: string }) => {
|
||||
const res = await fetch(`/api/config/${encodeURIComponent(input.key)}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ value: input.value }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({})) as { error?: string };
|
||||
throw new Error(body.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json() as Promise<{ ok: boolean; resolved: ResolvedSetting }>;
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useResetSetting() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (key: SettingsKey) => {
|
||||
const res = await fetch(`/api/config/${encodeURIComponent(key)}`, { method: "DELETE" });
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({})) as { error?: string };
|
||||
throw new Error(body.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json() as Promise<{ ok: boolean; resolved: ResolvedSetting }>;
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export interface InitWorkspaceResponse {
|
||||
root: string;
|
||||
rootCreated: boolean;
|
||||
subdirs: Array<{ name: string; path: string; created: boolean }>;
|
||||
files: Array<{ relPath: string; absPath: string; created: boolean; skipped: "exists" | null }>;
|
||||
dryRun: boolean;
|
||||
}
|
||||
|
||||
export function useInitWorkspace() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (input: { root?: string; force?: boolean; dryRun?: boolean }) => {
|
||||
const res = await fetch("/api/config/init-workspace", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({})) as { error?: string };
|
||||
throw new Error(body.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json() as Promise<InitWorkspaceResponse>;
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
// User Settings v0 — System drawer Settings tab tests.
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
|
||||
import { createTestRouter } from "./helpers/test-router.js";
|
||||
import { SettingsTab } from "../src/components/system/SettingsTab.js";
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.fetch = mockFetch as unknown as typeof fetch;
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
}) as unknown as Response;
|
||||
}
|
||||
|
||||
function makeSettingsResponse() {
|
||||
return {
|
||||
settings: {
|
||||
"daemon.port": { value: 7433, source: "default", defaultValue: 7433 },
|
||||
"daemon.host": { value: "127.0.0.1", source: "default", defaultValue: "127.0.0.1" },
|
||||
"db.path": { value: "/Users/me/.openrig/openrig.sqlite", source: "default", defaultValue: "/Users/me/.openrig/openrig.sqlite" },
|
||||
"transcripts.enabled": { value: true, source: "default", defaultValue: true },
|
||||
"transcripts.path": { value: "/Users/me/.openrig/transcripts", source: "default", defaultValue: "/Users/me/.openrig/transcripts" },
|
||||
"workspace.root": { value: "/Users/me/.openrig/workspace", source: "default", defaultValue: "/Users/me/.openrig/workspace" },
|
||||
"workspace.slices_root": { value: "/Users/me/.openrig/workspace/slices", source: "default", defaultValue: "/Users/me/.openrig/workspace/slices" },
|
||||
"workspace.steering_path": { value: "/Users/me/.openrig/workspace/steering/STEERING.md", source: "default", defaultValue: "/Users/me/.openrig/workspace/steering/STEERING.md" },
|
||||
"workspace.field_notes_root": { value: "/Users/me/.openrig/workspace/field-notes", source: "default", defaultValue: "/Users/me/.openrig/workspace/field-notes" },
|
||||
"workspace.specs_root": { value: "/Users/me/.openrig/workspace/specs", source: "default", defaultValue: "/Users/me/.openrig/workspace/specs" },
|
||||
"files.allowlist": { value: "", source: "default", defaultValue: "" },
|
||||
"progress.scan_roots": { value: "ws:/Users/me/work", source: "env", defaultValue: "" },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("SettingsTab — User Settings v0", () => {
|
||||
it("renders all 12 settings rows with source badges", async () => {
|
||||
mockFetch.mockResolvedValue(jsonResponse(makeSettingsResponse()));
|
||||
render(createTestRouter({ component: () => <SettingsTab />, path: "/" }));
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId("settings-tab")).toBeDefined());
|
||||
expect(screen.getByTestId("setting-workspace.root")).toBeDefined();
|
||||
expect(screen.getByTestId("setting-workspace.slices_root")).toBeDefined();
|
||||
expect(screen.getByTestId("setting-workspace.steering_path")).toBeDefined();
|
||||
expect(screen.getByTestId("setting-files.allowlist")).toBeDefined();
|
||||
expect(screen.getByTestId("setting-progress.scan_roots")).toBeDefined();
|
||||
expect(screen.getByTestId("setting-daemon.port")).toBeDefined();
|
||||
expect(screen.getByTestId("setting-db.path")).toBeDefined();
|
||||
|
||||
// Source badge surfaces the env-resolved row honestly.
|
||||
expect(screen.getByTestId("setting-progress.scan_roots").textContent).toContain("source: env");
|
||||
expect(screen.getByTestId("setting-workspace.root").textContent).toContain("source: default");
|
||||
});
|
||||
|
||||
it("Edit + Save sends POST to /api/config/:key with the value", async () => {
|
||||
const calls: Array<{ url: string; init?: RequestInit }> = [];
|
||||
mockFetch.mockImplementation(async (url: RequestInfo | URL, init?: RequestInit) => {
|
||||
calls.push({ url: String(url), init });
|
||||
if (init?.method === "POST" && String(url).includes("/api/config/workspace.slices_root")) {
|
||||
return jsonResponse({ ok: true, key: "workspace.slices_root", resolved: { value: "/founder/slices", source: "file", defaultValue: "" } });
|
||||
}
|
||||
return jsonResponse(makeSettingsResponse());
|
||||
});
|
||||
|
||||
render(createTestRouter({ component: () => <SettingsTab />, path: "/" }));
|
||||
await waitFor(() => expect(screen.getByTestId("setting-workspace.slices_root")).toBeDefined());
|
||||
|
||||
fireEvent.click(screen.getByTestId("setting-workspace.slices_root-edit"));
|
||||
const input = screen.getByTestId("setting-workspace.slices_root-input") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "/founder/slices" } });
|
||||
fireEvent.click(screen.getByTestId("setting-workspace.slices_root-save"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(calls.some((c) =>
|
||||
c.url.includes("/api/config/workspace.slices_root")
|
||||
&& c.init?.method === "POST"
|
||||
&& (c.init.body as string).includes("/founder/slices")
|
||||
)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("Reset button is hidden for default-source rows; visible for non-default", async () => {
|
||||
mockFetch.mockResolvedValue(jsonResponse(makeSettingsResponse()));
|
||||
render(createTestRouter({ component: () => <SettingsTab />, path: "/" }));
|
||||
await waitFor(() => expect(screen.getByTestId("setting-workspace.root")).toBeDefined());
|
||||
|
||||
// workspace.root is source=default → no reset
|
||||
expect(screen.queryByTestId("setting-workspace.root-reset")).toBeNull();
|
||||
// progress.scan_roots is source=env → reset visible (env still counts as overridden)
|
||||
expect(screen.queryByTestId("setting-progress.scan_roots-reset")).toBeDefined();
|
||||
});
|
||||
|
||||
it("Init Workspace button POSTs to /api/config/init-workspace", async () => {
|
||||
let initCalled = false;
|
||||
mockFetch.mockImplementation(async (url: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (String(url).includes("/api/config/init-workspace")) {
|
||||
initCalled = true;
|
||||
return jsonResponse({
|
||||
root: "/Users/me/.openrig/workspace",
|
||||
rootCreated: true,
|
||||
subdirs: [
|
||||
{ name: "slices", path: "/x/slices", created: true },
|
||||
{ name: "steering", path: "/x/steering", created: true },
|
||||
{ name: "progress", path: "/x/progress", created: true },
|
||||
{ name: "field-notes", path: "/x/field-notes", created: true },
|
||||
{ name: "specs", path: "/x/specs", created: true },
|
||||
],
|
||||
files: [],
|
||||
dryRun: false,
|
||||
});
|
||||
}
|
||||
return jsonResponse(makeSettingsResponse());
|
||||
});
|
||||
|
||||
render(createTestRouter({ component: () => <SettingsTab />, path: "/" }));
|
||||
await waitFor(() => expect(screen.getByTestId("settings-init-workspace")).toBeDefined());
|
||||
fireEvent.click(screen.getByTestId("settings-init-workspace"));
|
||||
|
||||
await waitFor(() => expect(initCalled).toBe(true));
|
||||
await waitFor(() => expect(screen.getByTestId("settings-init-result")).toBeDefined());
|
||||
expect(screen.getByTestId("settings-init-result").textContent).toContain("created 5 subdir");
|
||||
});
|
||||
|
||||
it("renders a friendly error when the daemon route 503s", async () => {
|
||||
mockFetch.mockResolvedValue(jsonResponse({ error: "settings_unavailable" }, 503));
|
||||
render(createTestRouter({ component: () => <SettingsTab />, path: "/" }));
|
||||
await waitFor(() => expect(screen.getByTestId("settings-error")).toBeDefined());
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user