From 66fa05f64f53b61694d90b19a22f7cca6d953be3 Mon Sep 17 00:00:00 2001 From: David Abram Date: Sun, 7 Jun 2026 14:33:39 +0200 Subject: [PATCH 1/7] cli: Reuse service-owned CLI value enums Remove duplicate schema-local OutputFormat and LogLevel enums by deriving clap ValueEnum on the canonical service types. Co-authored-by: SCE --- cli/src/cli_schema.rs | 17 +--- cli/src/services/config/mod.rs | 3 +- cli/src/services/output_format.rs | 4 +- cli/src/services/parse/command_runtime.rs | 49 +++--------- context/plans/cli-maintenance-hazards.md | 94 +++++++++++++++++++++++ 5 files changed, 111 insertions(+), 56 deletions(-) create mode 100644 context/plans/cli-maintenance-hazards.md diff --git a/cli/src/cli_schema.rs b/cli/src/cli_schema.rs index d35347e4..caa08b37 100644 --- a/cli/src/cli_schema.rs +++ b/cli/src/cli_schema.rs @@ -1,6 +1,8 @@ use clap::{CommandFactory, Parser, Subcommand, ValueEnum}; use std::path::PathBuf; +use crate::services::config::LogLevel; +use crate::services::output_format::OutputFormat; use crate::services::style; pub struct TopLevelCommandMetadata { @@ -279,24 +281,9 @@ pub enum HooksSubcommand { DiffTrace, } -#[derive(ValueEnum, Clone, Copy, Debug, Default, PartialEq, Eq)] -pub enum OutputFormat { - #[default] - Text, - Json, -} - #[derive(ValueEnum, Clone, Copy, Debug, PartialEq, Eq)] pub enum CompletionShell { Bash, Zsh, Fish, } - -#[derive(ValueEnum, Clone, Copy, Debug, PartialEq, Eq)] -pub enum LogLevel { - Error, - Warn, - Info, - Debug, -} diff --git a/cli/src/services/config/mod.rs b/cli/src/services/config/mod.rs index b65598dc..393add11 100644 --- a/cli/src/services/config/mod.rs +++ b/cli/src/services/config/mod.rs @@ -7,6 +7,7 @@ use std::{ }; use anyhow::{anyhow, bail, Context, Result}; +use clap::ValueEnum; use jsonschema::{validator_for, Validator}; use serde::Deserialize; use serde_json::{json, Value}; @@ -50,7 +51,7 @@ const WORKOS_CLIENT_ID_KEY: AuthConfigKeySpec = AuthConfigKeySpec { pub type ReportFormat = OutputFormat; -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] pub enum LogLevel { Error, Warn, diff --git a/cli/src/services/output_format.rs b/cli/src/services/output_format.rs index b934c5a5..2e980c6f 100644 --- a/cli/src/services/output_format.rs +++ b/cli/src/services/output_format.rs @@ -1,7 +1,9 @@ use anyhow::{bail, Result}; +use clap::ValueEnum; -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, ValueEnum)] pub enum OutputFormat { + #[default] Text, Json, } diff --git a/cli/src/services/parse/command_runtime.rs b/cli/src/services/parse/command_runtime.rs index 0e0fdc6f..3b6df8d0 100644 --- a/cli/src/services/parse/command_runtime.rs +++ b/cli/src/services/parse/command_runtime.rs @@ -225,16 +225,14 @@ fn convert_clap_command( } else { services::doctor::DoctorMode::Diagnose }, - format: convert_output_format(format), + format, }, })) } cli_schema::Commands::Hooks { subcommand } => convert_hooks_subcommand(subcommand), cli_schema::Commands::Version { format } => { Ok(Box::new(services::version::command::VersionCommand { - request: services::version::VersionRequest { - format: convert_output_format(format), - }, + request: services::version::VersionRequest { format }, })) } cli_schema::Commands::Completion { shell } => { @@ -253,25 +251,16 @@ fn convert_auth_subcommand( ) -> Result { let subcommand = match subcommand { cli_schema::AuthSubcommand::Login { format } => { - services::auth_command::AuthSubcommand::Login { - format: convert_output_format(format), - } + services::auth_command::AuthSubcommand::Login { format } } cli_schema::AuthSubcommand::Renew { format, force } => { - services::auth_command::AuthSubcommand::Renew { - format: convert_output_format(format), - force, - } + services::auth_command::AuthSubcommand::Renew { format, force } } cli_schema::AuthSubcommand::Logout { format } => { - services::auth_command::AuthSubcommand::Logout { - format: convert_output_format(format), - } + services::auth_command::AuthSubcommand::Logout { format } } cli_schema::AuthSubcommand::Status { format } => { - services::auth_command::AuthSubcommand::Status { - format: convert_output_format(format), - } + services::auth_command::AuthSubcommand::Status { format } } }; @@ -280,15 +269,6 @@ fn convert_auth_subcommand( })) } -fn convert_output_format( - format: cli_schema::OutputFormat, -) -> services::output_format::OutputFormat { - match format { - cli_schema::OutputFormat::Text => services::output_format::OutputFormat::Text, - cli_schema::OutputFormat::Json => services::output_format::OutputFormat::Json, - } -} - fn convert_completion_shell( shell: cli_schema::CompletionShell, ) -> services::completion::CompletionShell { @@ -311,9 +291,9 @@ fn convert_config_subcommand( timeout_ms, } => Ok(Box::new(services::config::command::ConfigCommand { subcommand: services::config::ConfigSubcommand::Show(services::config::ConfigRequest { - report_format: convert_output_format(format), + report_format: format, config_path: config, - log_level: log_level.map(convert_log_level), + log_level, timeout_ms, }), })), @@ -325,9 +305,9 @@ fn convert_config_subcommand( } => Ok(Box::new(services::config::command::ConfigCommand { subcommand: services::config::ConfigSubcommand::Validate( services::config::ConfigRequest { - report_format: convert_output_format(format), + report_format: format, config_path: config, - log_level: log_level.map(convert_log_level), + log_level, timeout_ms, }, ), @@ -335,15 +315,6 @@ fn convert_config_subcommand( } } -fn convert_log_level(level: cli_schema::LogLevel) -> services::config::LogLevel { - match level { - cli_schema::LogLevel::Error => services::config::LogLevel::Error, - cli_schema::LogLevel::Warn => services::config::LogLevel::Warn, - cli_schema::LogLevel::Info => services::config::LogLevel::Info, - cli_schema::LogLevel::Debug => services::config::LogLevel::Debug, - } -} - #[allow(clippy::fn_params_excessive_bools)] fn convert_setup_command( opencode: bool, diff --git a/context/plans/cli-maintenance-hazards.md b/context/plans/cli-maintenance-hazards.md new file mode 100644 index 00000000..173371b5 --- /dev/null +++ b/context/plans/cli-maintenance-hazards.md @@ -0,0 +1,94 @@ +# Plan: cli-maintenance-hazards + +## Change summary + +Resolve the architectural/maintenance hazards in the Rust CLI without changing user-facing behavior: + +1. Remove the duplicate `OutputFormat` and `LogLevel` type hierarchies split between `cli/src/cli_schema.rs` and service modules. +2. Remove the copy-pasted synchronous database operation methods shared by `TursoDb` and `EncryptedTursoDb` in `cli/src/services/db/mod.rs`, keeping encryption as the constructor-only difference. +3. Split the monolithic `cli/src/services/config/mod.rs` into focused config submodules for types, schema/loading, policy validation, runtime resolution, and rendering while preserving the existing `services::config` public API. + +## Success criteria + +- `cli/src/cli_schema.rs` no longer defines independent `OutputFormat` and `LogLevel` enums that duplicate service-owned runtime types. +- Adding a new CLI output format or log level requires changing one canonical enum owner plus intentional parser/rendering tests, not multiple parallel enum hierarchies and manual conversion functions. +- `TursoDb` and `EncryptedTursoDb` share one implementation path for `execute`, `query`, `query_map`, and `run_migrations`; their only substantive divergence is encrypted vs unencrypted connection construction. +- Existing database error messages, migration metadata behavior, and local/encrypted DB initialization behavior remain stable unless tests require a deterministic refactor-only adjustment. +- `cli/src/services/config/mod.rs` becomes a small module facade that declares/re-exports focused submodules instead of owning resolution, formatting, policy validation, rendering, and JSON schema concerns inline. +- Existing `sce config show`, `sce config validate`, startup config resolution, doctor config validation, auth config lookup, attribution-hooks config lookup, and observability config behavior remain unchanged. +- Rust formatting, linting, tests, generated-output parity, and repo-level validation pass using the repository-preferred checks. + +## Constraints and non-goals + +- Pure maintenance/refactor plan: no new CLI commands, flags, output formats, log levels, config keys, database features, migrations, or behavior changes. +- No new third-party dependencies. +- Preserve public/user-facing output contracts unless compile/tests reveal an unavoidable deterministic refactor-safe update. +- Preserve the current `services::config` import surface through facade re-exports so downstream modules do not need broad unrelated edits. +- Keep each executable task as one atomic commit unit; if implementation uncovers an independent behavior change, stop and split before proceeding. +- Prefer repository validation through `nix flake check`; use narrower Nix-wrapped checks only for targeted development feedback. + +## Task stack + +- [x] T01: `Unify CLI schema format and log-level enums with service-owned types` (status:done) + - Task ID: T01 + - Goal: Remove duplicate `OutputFormat` and `LogLevel` enums from `cli/src/cli_schema.rs` by making clap parsing use the canonical service-owned enum types. + - Boundaries (in/out of scope): In - `cli_schema.rs`, service enum derives/visibility needed for clap `ValueEnum`, parse-layer conversion removal, focused tests/fixtures affected by enum ownership. Out - adding variants, changing valid values, changing help text beyond type-path-neutral clap output, changing service rendering behavior. + - Done when: `cli_schema.rs` imports/reuses canonical `services::output_format::OutputFormat` and `services::config::LogLevel`; manual `convert_output_format` / `convert_log_level` style mappings are removed or reduced to identity; all commands still accept the same `--format ` and `--log-level ` values. + - Verification notes (commands or checks): Prefer `nix flake check`; if narrow feedback is needed, run Nix-wrapped CLI check/test commands for parser/config/version surfaces. + - Completed: 2026-06-07 + - Files changed: `cli/src/cli_schema.rs`, `cli/src/services/output_format.rs`, `cli/src/services/config/mod.rs`, `cli/src/services/parse/command_runtime.rs` + - Evidence: `nix flake check` passed. Narrow `cargo fmt --check && cargo check` attempt was blocked by repo policy in favor of flake validation; `nix develop -c sh -c 'cd cli && cargo fmt'` was run for rustfmt. + - Notes: `cli_schema.rs` now reuses service-owned `OutputFormat` and `LogLevel`; duplicate schema-local enums and parse-layer conversion helpers were removed without adding variants or changing accepted values. + +- [ ] T02: `Deduplicate shared Turso operation methods` (status:todo) + - Task ID: T02 + - Goal: Factor `execute`, `query`, `query_map`, and `run_migrations` into one shared implementation path used by both `TursoDb` and `EncryptedTursoDb`. + - Boundaries (in/out of scope): In - internal DB helper/core type or helper functions inside `cli/src/services/db/mod.rs`, method delegation from both public adapter types, preservation of existing public method signatures. Out - changing `DbSpec`, changing concrete DB specs, changing encryption-key behavior, adding sync/cloud behavior, adding migrations. + - Done when: the duplicated method bodies no longer exist in both adapter impls; encrypted and unencrypted constructors still initialize connections exactly as before; all existing DB consumers compile unchanged. + - Verification notes (commands or checks): Prefer `nix flake check`; inspect `cli/src/services/db/mod.rs` to confirm the operation/migration logic has one owner. + +- [ ] T03: `Create config module facade and shared type submodule` (status:todo) + - Task ID: T03 + - Goal: Establish the config split by moving stable shared config types/constants into a focused submodule while keeping `services::config` re-exports/source compatibility. + - Boundaries (in/out of scope): In - create a `types`-style submodule for config request/response primitives, log/config enums, source metadata, constants that are safe to move first, and facade re-exports from `mod.rs`. Out - moving resolution logic, schema validation, policy validation, or renderers. + - Done when: `cli/src/services/config/mod.rs` starts acting as a module facade for shared config primitives; existing callers still import through `services::config::*` as before; behavior is unchanged. + - Verification notes (commands or checks): Nix-wrapped compile/check or `nix flake check`; confirm no public API churn outside config-owned imports is needed. + +- [ ] T04: `Extract config schema loading and file parsing concerns` (status:todo) + - Task ID: T04 + - Goal: Move JSON schema embedding/validator setup, top-level allowed-key validation, serde DTO definitions, and config-file load/parse helpers out of `mod.rs` into a focused schema/loading submodule. + - Boundaries (in/out of scope): In - schema constants, `OnceLock` validator ownership, JSON top-level validation, file parse/deserialization helpers, tests directly tied to schema/file parsing. Out - precedence resolution, rendering, policy-specific semantic validation unless already isolated as DTO parsing. + - Done when: schema and config-file parsing have one focused owner; explicit vs default-discovered invalid-file behavior remains unchanged; `sce config validate` still reports the same issues/warnings for equivalent inputs. + - Verification notes (commands or checks): Prefer `nix flake check`; include targeted config validation tests if available/needed. + +- [ ] T05: `Extract config policy semantic validation` (status:todo) + - Task ID: T05 + - Goal: Move bash-policy and attribution-hooks semantic validation/merge helpers into a focused policy submodule consumed by config resolution and rendering. + - Boundaries (in/out of scope): In - built-in/custom bash-policy validation, duplicate/conflict/redundancy checks, attribution-hooks config parsing helpers, policy resolved-data structs if needed for cohesion. Out - changing policy schema, changing preset catalog generation, changing OpenCode plugin runtime behavior. + - Done when: policy-specific rules are no longer interleaved with generic config resolution/rendering in `mod.rs`; existing warnings/errors for policy conflicts and redundancy remain stable. + - Verification notes (commands or checks): Prefer `nix flake check`; run targeted config-policy tests if implementation adds/moves them. + +- [ ] T06: `Extract runtime config resolution and precedence flow` (status:todo) + - Task ID: T06 + - Goal: Move config-file discovery, merge order, env/flag/default precedence, auth-key resolution, observability resolution, and invalid-default-discovered fallback flow into a focused resolver submodule. + - Boundaries (in/out of scope): In - resolution functions consumed by startup, `sce config show/validate`, auth runtime, observability runtime, and attribution-hooks gate; source/provenance preservation. Out - rendering output shape, schema policy changes, adding new keys. + - Done when: precedence behavior remains `flags > env > config file > defaults` where applicable; default-discovered invalid config still degrades gracefully while explicit config remains fatal; `mod.rs` delegates resolution instead of containing it inline. + - Verification notes (commands or checks): Prefer `nix flake check`; include focused tests/smoke checks for `sce config show`, `sce config validate`, startup config loading, and attribution-hooks gate if available. + +- [ ] T07: `Extract config text and JSON rendering` (status:todo) + - Task ID: T07 + - Goal: Move `sce config show` and `sce config validate` text/JSON rendering into a focused render submodule without changing output contracts. + - Boundaries (in/out of scope): In - text rendering, JSON response construction, display-value/redaction helpers that are rendering-specific, render tests/golden assertions if present. Out - changing resolved data semantics, schema validation, policy validation, or command parsing. + - Done when: config renderers have one focused owner; output for representative `show` and `validate` cases remains stable; `mod.rs` is reduced to facade/orchestration-level exports and `run_config_subcommand` delegation. + - Verification notes (commands or checks): Prefer `nix flake check`; include targeted config show/validate output tests if available/needed. + +- [ ] T08: `Final validation and context sync` (status:todo) + - Task ID: T08 + - Goal: Run full validation, remove temporary scaffolding, and sync durable context for the resulting CLI architecture and maintenance boundaries. + - Boundaries (in/out of scope): In - full repo validation, generated-output parity, cleanup of task-owned temporary files, context updates for current-state architecture/glossary/domain docs. Out - new refactors or behavior changes beyond documenting the completed plan outcome. + - Done when: `nix run .#pkl-check-generated` and `nix flake check` pass; config/DB/CLI enum context reflects the new ownership; this plan records validation evidence and any residual risks. + - Verification notes (commands or checks): `nix run .#pkl-check-generated`; `nix flake check`; verify `context/overview.md`, `context/architecture.md`, `context/glossary.md`, `context/cli/config-precedence-contract.md`, and `context/sce/shared-turso-db.md` are current or explicitly verified unchanged. + +## Open questions + +None. The request is treated as a refactor-only maintenance plan covering all three listed hazards with no user-facing behavior changes. From 361b12145d69e23559782f7bcef377f64e9ab81d Mon Sep 17 00:00:00 2001 From: David Abram Date: Sun, 7 Jun 2026 14:43:36 +0200 Subject: [PATCH 2/7] cli/db: Extract shared TursoConnectionCore to deduplicate operation methods Factor the duplicated execute, query, query_map, and run_migrations implementations from TursoDb and EncryptedTursoDb into a shared internal TursoConnectionCore struct. Both public adapters keep their existing constructor behavior (unencrypted vs encrypted) and delegate operation methods to the shared core, preserving all public signatures. Co-authored-by: SCE --- cli/src/services/db/mod.rs | 166 ++++++++++++----------- context/architecture.md | 2 +- context/context-map.md | 2 +- context/glossary.md | 5 +- context/plans/cli-maintenance-hazards.md | 6 +- context/sce/shared-turso-db.md | 10 +- 6 files changed, 99 insertions(+), 92 deletions(-) diff --git a/cli/src/services/db/mod.rs b/cli/src/services/db/mod.rs index 2cb1e175..ba3bea8b 100644 --- a/cli/src/services/db/mod.rs +++ b/cli/src/services/db/mod.rs @@ -231,6 +231,76 @@ fn apply_migration( }) } +struct TursoConnectionCore { + conn: turso::Connection, + runtime: tokio::runtime::Runtime, + spec: PhantomData M>, +} + +impl TursoConnectionCore { + fn new(conn: turso::Connection, runtime: tokio::runtime::Runtime) -> Self { + Self { + conn, + runtime, + spec: PhantomData, + } + } + + fn execute(&self, sql: &str, params: impl turso::params::IntoParams) -> Result { + self.runtime.block_on(async { + self.conn + .execute(sql, params) + .await + .map_err(|e| anyhow::anyhow!("{} execute failed: {sql}: {e}", M::db_name())) + }) + } + + fn query(&self, sql: &str, params: impl turso::params::IntoParams) -> Result { + self.runtime.block_on(async { + self.conn + .query(sql, params) + .await + .map_err(|e| anyhow::anyhow!("{} query failed: {sql}: {e}", M::db_name())) + }) + } + + fn query_map( + &self, + sql: &str, + params: impl turso::params::IntoParams, + mut map_row: F, + ) -> Result> + where + F: FnMut(&turso::Row) -> Result, + { + self.runtime.block_on(async { + let mut rows = self + .conn + .query(sql, params) + .await + .map_err(|e| anyhow::anyhow!("{} query failed: {sql}: {e}", M::db_name()))?; + let mut results = Vec::new(); + + while let Some(row) = rows + .next() + .await + .map_err(|e| anyhow::anyhow!("{} row fetch failed: {sql}: {e}", M::db_name()))? + { + results.push( + map_row(&row) + .with_context(|| format!("{} row mapping failed: {sql}", M::db_name()))?, + ); + } + + Ok(results) + }) + } + + fn run_migrations(&self) -> Result<()> { + run_embedded_migrations(&self.conn, &self.runtime, M::db_name(), M::migrations()) + } +} + /// Generic Turso database adapter. /// /// Wraps a Turso connection with a tokio current-thread runtime so callers can @@ -238,9 +308,7 @@ fn apply_migration( /// remains async. #[allow(dead_code)] pub struct TursoDb { - conn: turso::Connection, - runtime: tokio::runtime::Runtime, - spec: PhantomData M>, + core: TursoConnectionCore, } /// Generic encrypted Turso database adapter. @@ -248,9 +316,7 @@ pub struct TursoDb { /// Mirrors the structural seams of [`TursoDb`] while reserving encrypted local /// database initialization for services that require at-rest encryption. pub struct EncryptedTursoDb { - conn: turso::Connection, - runtime: tokio::runtime::Runtime, - spec: PhantomData M>, + core: TursoConnectionCore, } #[allow(dead_code)] @@ -285,9 +351,7 @@ impl TursoDb { })?; let db = Self { - conn, - runtime, - spec: PhantomData, + core: TursoConnectionCore::new(conn, runtime), }; db.run_migrations() @@ -305,12 +369,7 @@ impl TursoDb { /// # Returns /// Number of rows affected. pub fn execute(&self, sql: &str, params: impl turso::params::IntoParams) -> Result { - self.runtime.block_on(async { - self.conn - .execute(sql, params) - .await - .map_err(|e| anyhow::anyhow!("{} execute failed: {sql}: {e}", M::db_name())) - }) + self.core.execute(sql, params) } /// Execute a SQL query that returns rows. @@ -322,12 +381,7 @@ impl TursoDb { /// # Returns /// A `turso::Rows` iterator over the result set. pub fn query(&self, sql: &str, params: impl turso::params::IntoParams) -> Result { - self.runtime.block_on(async { - self.conn - .query(sql, params) - .await - .map_err(|e| anyhow::anyhow!("{} query failed: {sql}: {e}", M::db_name())) - }) + self.core.query(sql, params) } /// Execute a SQL query and synchronously map all returned rows. @@ -335,32 +389,12 @@ impl TursoDb { &self, sql: &str, params: impl turso::params::IntoParams, - mut map_row: F, + map_row: F, ) -> Result> where F: FnMut(&turso::Row) -> Result, { - self.runtime.block_on(async { - let mut rows = self - .conn - .query(sql, params) - .await - .map_err(|e| anyhow::anyhow!("{} query failed: {sql}: {e}", M::db_name()))?; - let mut results = Vec::new(); - - while let Some(row) = rows - .next() - .await - .map_err(|e| anyhow::anyhow!("{} row fetch failed: {sql}: {e}", M::db_name()))? - { - results.push( - map_row(&row) - .with_context(|| format!("{} row mapping failed: {sql}", M::db_name()))?, - ); - } - - Ok(results) - }) + self.core.query_map(sql, params, map_row) } /// Run all embedded migrations in order. @@ -370,7 +404,7 @@ impl TursoDb { /// Existing databases without migration metadata are brought forward by /// re-applying the current idempotent migration set and recording each ID. pub fn run_migrations(&self) -> Result<()> { - run_embedded_migrations(&self.conn, &self.runtime, M::db_name(), M::migrations()) + self.core.run_migrations() } } @@ -417,9 +451,7 @@ impl EncryptedTursoDb { })?; let db = Self { - conn, - runtime, - spec: PhantomData, + core: TursoConnectionCore::new(conn, runtime), }; db.run_migrations() @@ -437,12 +469,7 @@ impl EncryptedTursoDb { /// # Returns /// Number of rows affected. pub fn execute(&self, sql: &str, params: impl turso::params::IntoParams) -> Result { - self.runtime.block_on(async { - self.conn - .execute(sql, params) - .await - .map_err(|e| anyhow::anyhow!("{} execute failed: {sql}: {e}", M::db_name())) - }) + self.core.execute(sql, params) } /// Execute a SQL query that returns rows. @@ -455,12 +482,7 @@ impl EncryptedTursoDb { /// A `turso::Rows` iterator over the result set. #[allow(dead_code)] pub fn query(&self, sql: &str, params: impl turso::params::IntoParams) -> Result { - self.runtime.block_on(async { - self.conn - .query(sql, params) - .await - .map_err(|e| anyhow::anyhow!("{} query failed: {sql}: {e}", M::db_name())) - }) + self.core.query(sql, params) } /// Execute a SQL query and synchronously map all returned rows. @@ -468,32 +490,12 @@ impl EncryptedTursoDb { &self, sql: &str, params: impl turso::params::IntoParams, - mut map_row: F, + map_row: F, ) -> Result> where F: FnMut(&turso::Row) -> Result, { - self.runtime.block_on(async { - let mut rows = self - .conn - .query(sql, params) - .await - .map_err(|e| anyhow::anyhow!("{} query failed: {sql}: {e}", M::db_name()))?; - let mut results = Vec::new(); - - while let Some(row) = rows - .next() - .await - .map_err(|e| anyhow::anyhow!("{} row fetch failed: {sql}: {e}", M::db_name()))? - { - results.push( - map_row(&row) - .with_context(|| format!("{} row mapping failed: {sql}", M::db_name()))?, - ); - } - - Ok(results) - }) + self.core.query_map(sql, params, map_row) } /// Run all embedded migrations in order. @@ -503,6 +505,6 @@ impl EncryptedTursoDb { /// Existing databases without migration metadata are brought forward by /// re-applying the current idempotent migration set and recording each ID. pub fn run_migrations(&self) -> Result<()> { - run_embedded_migrations(&self.conn, &self.runtime, M::db_name(), M::migrations()) + self.core.run_migrations() } } diff --git a/context/architecture.md b/context/architecture.md index d5e4a749..4df89878 100644 --- a/context/architecture.md +++ b/context/architecture.md @@ -104,7 +104,7 @@ The repository includes a new placeholder Rust binary crate at `cli/`. - `cli/src/services/capabilities.rs` defines the current broad CLI dependency-injection capability traits consumed by `AppContext`: `FsOps` with `StdFsOps` for filesystem operations and `GitOps` with `ProcessGitOps` for git command execution plus repository-root/hooks-directory resolution. Existing services do not consume these traits internally yet; doctor/setup/hooks/config migration is deferred to later lifecycle/AppContext tasks. - `cli/src/services/lifecycle.rs` defines the current compile-safe `ServiceLifecycle` trait seam. It has default no-op `diagnose(&AppContext)`, `fix(&AppContext, &[HealthProblem])`, and `setup(&AppContext)` methods, with lifecycle-owned health, fix, and setup result types so the trait contract is not publicly anchored to doctor/setup module types. The same module owns the shared lifecycle provider catalog/factory, returning providers in deterministic order (config → local_db → auth_db → agent_trace_db → hooks when requested). Hooks exposes a `HooksLifecycle` provider in `cli/src/services/hooks/lifecycle.rs` for hook rollout diagnosis/fix/setup using lifecycle-owned health records plus the canonical required-hook installer. Config exposes a `ConfigLifecycle` provider in `cli/src/services/config/lifecycle.rs` for global/repo-local config validation and repo-local `.sce/config.json` bootstrap. local_db exposes a `LocalDbLifecycle` provider in `cli/src/services/local_db/lifecycle.rs` for canonical local DB path health, parent-directory readiness/bootstrap, and `LocalDb::new()` setup. auth_db exposes an `AuthDbLifecycle` provider in `cli/src/services/auth_db/lifecycle.rs` for canonical auth DB path health, parent-directory readiness/bootstrap, and `AuthDb::new()` setup. agent_trace_db exposes an `AgentTraceDbLifecycle` provider in `cli/src/services/agent_trace_db/lifecycle.rs` for canonical Agent Trace DB path health, parent-directory readiness/bootstrap, and `AgentTraceDb::new()` setup. Doctor runtime aggregates the full provider catalog for `diagnose` and `fix` and adapts lifecycle records into doctor report/fix records at the orchestration boundary; setup command aggregates the shared catalog for `setup` with hooks included only when requested and adapts hook setup outcomes before rendering setup-owned messages. - `cli/src/services/auth_command/mod.rs` defines the implemented auth command surface for `sce auth login|renew|logout|status`, including device-flow login, stored-token renewal (`--force` supported for renew), logout, and status rendering in text/JSON formats; `cli/src/services/auth_command/command.rs` owns the `AuthCommand` struct and its `RuntimeCommand` impl. -- `cli/src/services/db/mod.rs` provides the shared generic Turso infrastructure seam: `DbSpec` supplies a service-specific name, path, and ordered embedded migrations, while `TursoDb` owns parent-directory creation, `Builder::new_local(...)` initialization, Turso connection setup, tokio current-thread runtime bridging, blocking `execute`/`query`/`query_map` wrappers, and generic migration execution with per-database `__sce_migrations` metadata. The same module also provides `EncryptedTursoDb`, a structurally parallel encrypted adapter that resolves the encryption key through `encryption_key::get_or_create_encryption_key()`, enables Turso local encryption with strict `aegis256` cipher selection, and exposes the same synchronous `execute`/`query`/`query_map` wrappers plus migration execution. Existing DB files without migration metadata are upgraded by re-applying the current idempotent migration set and recording each migration ID, so setup/lifecycle initialization applies later migrations to already-created databases. The same module owns shared DB lifecycle helpers for path-health problem collection and DB parent-directory bootstrap. `cli/src/services/db/encryption_key.rs` first derives a Turso-compatible 64-character hex key from non-empty `SCE_AUTH_DB_ENCRYPTION_KEY` env-secret text when present, otherwise falls back to keyring-backed credential-store get-or-create behavior; no plaintext auth DB fallback exists. +- `cli/src/services/db/mod.rs` provides the shared generic Turso infrastructure seam: `DbSpec` supplies a service-specific name, path, and ordered embedded migrations, while `TursoDb` owns parent-directory creation, `Builder::new_local(...)` initialization, Turso connection setup, and tokio current-thread runtime bridging. The same module also provides `EncryptedTursoDb`, a structurally parallel encrypted adapter that resolves the encryption key via `encryption_key::get_or_create_encryption_key()` and enables Turso local encryption with strict `aegis256` cipher selection. Both public adapters delegate their synchronous `execute`/`query`/`query_map` wrappers and generic migration execution to the shared internal `TursoConnectionCore`, preserving one operation path and per-database `__sce_migrations` metadata. Existing DB files without migration metadata are upgraded by re-applying the current idempotent migration set and recording each migration ID, so setup/lifecycle initialization applies later migrations to already-created databases. The same module owns shared DB lifecycle helpers for path-health problem collection and DB parent-directory bootstrap. `cli/src/services/db/encryption_key.rs` first derives a Turso-compatible 64-character hex key from non-empty `SCE_AUTH_DB_ENCRYPTION_KEY` env-secret text when present, otherwise falls back to keyring-backed credential-store get-or-create behavior; no plaintext auth DB fallback exists. - `cli/src/services/local_db/mod.rs` provides the concrete local DB spec and `LocalDb` type alias over the shared generic `TursoDb` adapter. `LocalDbSpec` resolves the deterministic persistent runtime DB target through the shared default-path seam and declares no local migrations; `TursoDb` supplies blocking `execute`/`query`, parent-directory creation, Turso connection setup, tokio current-thread runtime bridging, and generic migration execution. - `cli/src/services/auth_db/mod.rs` provides the encrypted auth DB spec and `AuthDb` type alias over `EncryptedTursoDb`. `AuthDbSpec` resolves `/sce/auth.db` through the shared default-path seam and embeds ordered auth migrations where baseline SQL creates `auth_credentials` without `user_id`, with `updated_at`, and a trigger that auto-refreshes `updated_at` on row updates. Auth DB lifecycle setup/doctor integration is wired through `AuthDbLifecycle`; auth command/token-storage reads/writes are directed through `token_storage.rs`, which now persists tokens via the `auth_credentials` table instead of a JSON file. - `cli/src/services/agent_trace_db/mod.rs` provides the Agent Trace DB spec and `AgentTraceDb` type alias over `TursoDb`. `AgentTraceDbSpec` resolves `/sce/agent-trace.db` through the shared default-path seam and embeds an ordered split fresh-start baseline migration set (`001_create_diff_traces`, `002_create_post_commit_patch_intersections`, `003_create_agent_traces`, `004_create_diff_traces_time_ms_id_index`, `005_create_agent_traces_agent_trace_id_index`, `006_add_agent_traces_remote_url`, `007_create_agent_traces_remote_url_index`) without `AUTOINCREMENT`; `agent_traces.agent_trace_id` is `NOT NULL UNIQUE`, `agent_traces.remote_url` is nullable, and indexes include `idx_agent_traces_agent_trace_id` plus `idx_agent_traces_remote_url`. The module adds `DiffTraceInsert<'_>`/`insert_diff_trace()` (including `model_id`, `tool_name`, and nullable `tool_version` writes), `PostCommitPatchIntersectionInsert<'_>`/`insert_post_commit_patch_intersection()`, and `AgentTraceInsert<'_>`/`insert_agent_trace()` for parameterized writes plus `recent_diff_trace_patches(cutoff_time_ms, end_time_ms)` for inclusive chronological `diff_traces` reads that parse valid raw patch text and return skipped malformed-row reports. `cli/src/services/agent_trace_db/lifecycle.rs` registers Agent Trace DB setup/doctor lifecycle behavior; runtime writes come from `sce hooks diff-trace` (`diff_traces`) and `sce hooks post-commit` (`post_commit_patch_intersections` + built `agent_traces`). diff --git a/context/context-map.md b/context/context-map.md index fc5717d6..f57ff1ad 100644 --- a/context/context-map.md +++ b/context/context-map.md @@ -42,7 +42,7 @@ Feature/domain context: - `context/sce/agent-trace-post-rewrite-local-remap-ingestion.md` (current post-rewrite no-op baseline plus historical remap-ingestion reference) - `context/sce/agent-trace-rewrite-trace-transformation.md` (current post-rewrite no-op baseline plus historical rewrite-transformation reference) - `context/sce/local-db.md` (implemented `cli/src/services/local_db/mod.rs` local database spec with `LocalDb = TursoDb`, canonical local DB path resolution, zero local migrations, and inherited blocking `execute`/`query` methods using the shared Turso adapter) -- `context/sce/shared-turso-db.md` (current shared `cli/src/services/db/mod.rs` Turso database infrastructure seam, including `DbSpec`, generic `TursoDb`, `EncryptedTursoDb` encrypted constructor path with `SCE_AUTH_DB_ENCRYPTION_KEY` env-secret precedence before keyring-backed encryption key resolution via `encryption_key::get_or_create_encryption_key()`, stable `OnceLock` plus atomic retry guard for credential-store default registration without mutex poisoning, platform-specific credential-store remediation mentioning the env fallback, strict `aegis256` selection via Turso `EncryptionOpts`, encrypted-adapter `execute`/`query`/`query_map` wrappers plus migration execution parity, per-database `__sce_migrations` tracking, generic embedded migration execution, and current concrete wrappers for `LocalDb`, `AgentTraceDb`, and encrypted `AuthDb`) +- `context/sce/shared-turso-db.md` (current shared `cli/src/services/db/mod.rs` Turso database infrastructure seam, including `DbSpec`, generic `TursoDb`, `EncryptedTursoDb` encrypted constructor path with `SCE_AUTH_DB_ENCRYPTION_KEY` env-secret precedence before keyring-backed encryption key resolution via `encryption_key::get_or_create_encryption_key()`, strict `aegis256` selection via Turso `EncryptionOpts`, shared internal `TursoConnectionCore` operation/migration path for both public adapters, stable `OnceLock` plus atomic retry guard for credential-store default registration without mutex poisoning, platform-specific credential-store remediation mentioning the env fallback, per-database `__sce_migrations` tracking, generic embedded migration execution, and current concrete wrappers for `LocalDb`, `AgentTraceDb`, and encrypted `AuthDb`) - `context/sce/agent-trace-db.md` (implemented `cli/src/services/agent_trace_db/mod.rs` Agent Trace database wrapper with canonical `/sce/agent-trace.db` path, ordered `diff_traces`, `post_commit_patch_intersections`, `diff_traces(time_ms, id)` index, `agent_traces`, nullable `diff_traces.model_id`, nullable `diff_traces.tool_name`, nullable `diff_traces.tool_version`, and nullable `agent_traces.agent_trace_id` migrations applied through shared migration metadata, typed parameterized insert helpers for diff traces including `model_id` + tool metadata, post-commit intersection rows, and built `agent_traces` rows with `agent_trace_id` plus schema-validated trace JSON containing range `content_hash`, inclusive bounded chronological recent `diff_traces` query/parse support with malformed-row skip accounting, registered setup/doctor lifecycle provider, and active hook writers for `diff_traces` intake plus post-commit intersection/agent-trace persistence) - `context/sce/auth-db.md` (current encrypted auth DB foundation: canonical `/sce/auth.db` path resolver, `AuthDb = EncryptedTursoDb` wrapper, encryption key resolution from non-empty `SCE_AUTH_DB_ENCRYPTION_KEY` env-secret text before OS keyring fallback with no plaintext mode, platform-specific missing/unavailable credential-store remediation that points headless/CI users to the env fallback, baseline migration 001 creating `auth_credentials` without `user_id`, with `updated_at`, and 002 creating the `updated_at` auto-refresh trigger instead of a `user_id` index, and `AuthDbLifecycle` provider registered in the shared lifecycle catalog) - `context/sce/agent-trace-core-schema-migrations.md` (historical reference for removed local DB schema bootstrap behavior; T03 now implements the actual local DB with migrations) diff --git a/context/glossary.md b/context/glossary.md index f114ff7b..aaa2edb0 100644 --- a/context/glossary.md +++ b/context/glossary.md @@ -39,8 +39,9 @@ - `Agent Trace range content_hash`: Per-range `content_hash` emitted by `build_agent_trace(...)` inside every `ranges[]` entry as `murmur3:`, computed from the touched-line kind/content of the `post_commit_patch` or embedded-patch hunk used to emit that range while excluding positions, paths, metadata, and database IDs. - `DiffTraceInsert`: Insert payload in `cli/src/services/agent_trace_db/mod.rs` carrying `time_ms`, `session_id`, `patch`, `model_id`, `tool_name`, and nullable `tool_version` for parameterized writes to the `diff_traces` table. - `DbSpec`: Service-specific database metadata trait in `cli/src/services/db/mod.rs` that supplies a diagnostic database name, canonical path resolver, and ordered embedded migration list for `TursoDb`. -- `TursoDb`: Generic shared Turso database adapter in `cli/src/services/db/mod.rs`; owns parent-directory creation, Turso local open/connect flow, tokio current-thread runtime bridging, synchronous `execute()`/`query()`/`query_map()` wrappers, per-database `__sce_migrations` metadata, and generic migration execution for a `DbSpec` implementation. -- `__sce_migrations`: Per-database migration metadata table created by `TursoDb::run_migrations()`; records applied migration IDs after successful execution so later setup/lifecycle initialization applies only migrations not yet recorded, while existing metadata-less DBs are brought forward by re-applying the current idempotent migration set and recording each ID. +- `TursoDb`: Generic unencrypted Turso database adapter in `cli/src/services/db/mod.rs`; owns parent-directory creation and Turso local open/connect flow, then delegates synchronous `execute()`/`query()`/`query_map()` wrappers and migration execution to the shared internal `TursoConnectionCore` for a `DbSpec` implementation. +- `TursoConnectionCore`: Internal shared operation core in `cli/src/services/db/mod.rs` used by both `TursoDb` and `EncryptedTursoDb`; owns tokio current-thread runtime bridging, synchronous Turso operation wrappers, per-database `__sce_migrations` metadata, and generic embedded migration execution. +- `__sce_migrations`: Per-database migration metadata table created by the shared `TursoConnectionCore` migration path behind public adapter `run_migrations()` methods; records applied migration IDs after successful execution so later setup/lifecycle initialization applies only migrations not yet recorded, while existing metadata-less DBs are brought forward by re-applying the current idempotent migration set and recording each ID. - `sync command deferral`: Current plan/state note that a user-invocable `sce sync` command is not wired yet and is deferred to `0.4.0`; local DB and Agent Trace DB bootstrap now flow through lifecycle providers aggregated by the setup command, and DB health/repair flows through the doctor surface. - `CLI bounded resilience wrapper`: Shared policy in `cli/src/services/resilience.rs` (`RetryPolicy`, `run_with_retry`) that applies deterministic retries/timeouts/capped backoff to transient operations, emits retry observability events, and returns actionable terminal failure guidance. - `setup service orchestration`: Setup execution logic in `cli/src/services/setup/command.rs` that resolves the repository root, derives a repo-root-scoped `AppContext` from the runtime command context, aggregates `ServiceLifecycle::setup` calls across lifecycle providers (config → local_db → auth_db → agent_trace_db → hooks when requested), handles interactive target selection for config asset installation, and emits deterministic success messaging per target. diff --git a/context/plans/cli-maintenance-hazards.md b/context/plans/cli-maintenance-hazards.md index 173371b5..a79c001c 100644 --- a/context/plans/cli-maintenance-hazards.md +++ b/context/plans/cli-maintenance-hazards.md @@ -40,12 +40,16 @@ Resolve the architectural/maintenance hazards in the Rust CLI without changing u - Evidence: `nix flake check` passed. Narrow `cargo fmt --check && cargo check` attempt was blocked by repo policy in favor of flake validation; `nix develop -c sh -c 'cd cli && cargo fmt'` was run for rustfmt. - Notes: `cli_schema.rs` now reuses service-owned `OutputFormat` and `LogLevel`; duplicate schema-local enums and parse-layer conversion helpers were removed without adding variants or changing accepted values. -- [ ] T02: `Deduplicate shared Turso operation methods` (status:todo) +- [x] T02: `Deduplicate shared Turso operation methods` (status:done) - Task ID: T02 - Goal: Factor `execute`, `query`, `query_map`, and `run_migrations` into one shared implementation path used by both `TursoDb` and `EncryptedTursoDb`. - Boundaries (in/out of scope): In - internal DB helper/core type or helper functions inside `cli/src/services/db/mod.rs`, method delegation from both public adapter types, preservation of existing public method signatures. Out - changing `DbSpec`, changing concrete DB specs, changing encryption-key behavior, adding sync/cloud behavior, adding migrations. - Done when: the duplicated method bodies no longer exist in both adapter impls; encrypted and unencrypted constructors still initialize connections exactly as before; all existing DB consumers compile unchanged. - Verification notes (commands or checks): Prefer `nix flake check`; inspect `cli/src/services/db/mod.rs` to confirm the operation/migration logic has one owner. + - Completed: 2026-06-07 + - Files changed: `cli/src/services/db/mod.rs` + - Evidence: `nix flake check` passed. Initial narrow `nix develop -c sh -c 'cd cli && cargo fmt --check && cargo check'` attempt was blocked by repo policy in favor of flake validation. + - Notes: `TursoConnectionCore` now owns the shared synchronous `execute`, `query`, `query_map`, and `run_migrations` implementation; `TursoDb` and `EncryptedTursoDb` keep separate constructors for unencrypted vs encrypted connection initialization and delegate public operation methods to the shared core. - [ ] T03: `Create config module facade and shared type submodule` (status:todo) - Task ID: T03 diff --git a/context/sce/shared-turso-db.md b/context/sce/shared-turso-db.md index 25593674..95394426 100644 --- a/context/sce/shared-turso-db.md +++ b/context/sce/shared-turso-db.md @@ -8,14 +8,14 @@ - `db_name()` returns a human-readable diagnostic name. - `db_path()` resolves the canonical database file path. - `migrations()` returns ordered embedded migration `(id, sql)` pairs. -- `TursoDb`: generic adapter that owns: +- `TursoDb`: generic unencrypted adapter that owns: - tokio current-thread runtime creation - Turso local database open/connect flow - parent-directory creation - - synchronous `execute()`, `query()`, and row-mapping `query_map()` wrappers - - generic embedded migration execution through `run_migrations()` with per-database `__sce_migrations` metadata + - delegation of synchronous `execute()`, `query()`, row-mapping `query_map()`, and `run_migrations()` to the shared internal `TursoConnectionCore` - `EncryptedTursoDb`: encrypted-adapter seam parallel to `TursoDb` with the same structural shape (connection, runtime bridge, and spec marker). `EncryptedTursoDb::new()` resolves the encryption key via `encryption_key::get_or_create_encryption_key()`, enables Turso experimental local encryption, applies strict `aegis256` cipher selection through `turso::EncryptionOpts` during local DB open/connect, and runs embedded migrations after connect. -- `EncryptedTursoDb` also exposes synchronous `execute()`, `query()`, and `query_map()` wrappers plus generic `run_migrations()` with the same `__sce_migrations` metadata flow used by `TursoDb`. +- `EncryptedTursoDb` exposes the same public synchronous `execute()`, `query()`, `query_map()`, and `run_migrations()` methods by delegating to the same `TursoConnectionCore` operation path used by `TursoDb`. +- `TursoConnectionCore` is internal to `cli/src/services/db/mod.rs` and owns the shared blocking Turso operation wrappers plus embedded migration execution with per-database `__sce_migrations` metadata; encryption vs unencrypted behavior remains constructor-only at the public adapter layer. - Shared lifecycle helpers: - `collect_db_path_health()` emits common parent/path health problems for DB-backed services. - `bootstrap_db_parent()` creates the resolved DB parent directory for repair/setup flows. @@ -55,7 +55,7 @@ All three database wrappers (local DB, auth DB, Agent Trace DB) have lifecycle p ## Migration metadata -`TursoDb::run_migrations()` creates a service-local `__sce_migrations` table before applying migrations. Each migration is skipped only when its ID is already recorded in that table; otherwise the SQL is executed and the ID is recorded after success. +The shared `TursoConnectionCore` migration path creates a service-local `__sce_migrations` table before applying migrations. Each migration is skipped only when its ID is already recorded in that table; otherwise the SQL is executed and the ID is recorded after success. Existing databases created before migration metadata are upgraded by re-applying the current idempotent migration list and recording each migration ID. This lets later `sce setup` / lifecycle initialization runs apply migrations added after the database file already existed, including Agent Trace DB schema/index additions. From 95fcc7386937385f3895f6b44181419549da5d20 Mon Sep 17 00:00:00 2001 From: David Abram Date: Sun, 7 Jun 2026 15:17:30 +0200 Subject: [PATCH 3/7] cli/config: Extract shared types into dedicated types submodule Move stable config primitives from cli/src/services/config/mod.rs into a new types.rs submodule: LogLevel, LogFormat, LogFileMode, ConfigSubcommand, ConfigRequest, source metadata types (ValueSource, ConfigPathSource, LoadedConfigPath, ResolvedValue, ResolvedOptionalValue), resolved runtime config types (ResolvedAuthRuntimeConfig, ResolvedObservabilityRuntimeConfig, ResolvedHookRuntimeConfig), the NAME constant, observability env-key constants, and the shared parse_bool_value_from helper. mod.rs now acts as a module facade via pub mod types and pub use types::*, preserving existing services::config::* imports for downstream callers without public API churn. Co-authored-by: SCE --- cli/src/services/config/mod.rs | 241 +--------------------- cli/src/services/config/types.rs | 252 +++++++++++++++++++++++ context/glossary.md | 2 +- context/overview.md | 2 +- context/plans/cli-maintenance-hazards.md | 6 +- 5 files changed, 262 insertions(+), 241 deletions(-) create mode 100644 cli/src/services/config/types.rs diff --git a/cli/src/services/config/mod.rs b/cli/src/services/config/mod.rs index 393add11..ba387af5 100644 --- a/cli/src/services/config/mod.rs +++ b/cli/src/services/config/mod.rs @@ -1,5 +1,8 @@ pub mod command; pub mod lifecycle; +pub mod types; + +pub use types::*; use std::{ path::{Path, PathBuf}, @@ -7,16 +10,13 @@ use std::{ }; use anyhow::{anyhow, bail, Context, Result}; -use clap::ValueEnum; use jsonschema::{validator_for, Validator}; use serde::Deserialize; use serde_json::{json, Value}; use crate::services::default_paths::{resolve_sce_default_locations, schema, RepoPaths}; -use crate::services::output_format::OutputFormat; use crate::services::style::{self}; -pub const NAME: &str = "config"; #[cfg_attr(not(test), allow(dead_code))] pub(crate) const SCE_CONFIG_SCHEMA_JSON: &str = include_str!("../../../assets/generated/config/schema/sce-config.schema.json"); @@ -36,11 +36,6 @@ const TOP_LEVEL_CONFIG_KEYS: &[&str] = &[ ]; const TOP_LEVEL_CONFIG_KEYS_DESCRIPTION: &str = "$schema, log_level, log_format, log_file, log_file_mode, timeout_ms, workos_client_id, policies"; -pub(crate) const ENV_LOG_LEVEL: &str = "SCE_LOG_LEVEL"; -pub(crate) const ENV_LOG_FORMAT: &str = "SCE_LOG_FORMAT"; -pub(crate) const ENV_LOG_FILE: &str = "SCE_LOG_FILE"; -pub(crate) const ENV_LOG_FILE_MODE: &str = "SCE_LOG_FILE_MODE"; -pub(crate) const ENV_ATTRIBUTION_HOOKS_ENABLED: &str = "SCE_ATTRIBUTION_HOOKS_ENABLED"; const WORKOS_CLIENT_ID_ENV: &str = "WORKOS_CLIENT_ID"; const WORKOS_CLIENT_ID_BAKED_DEFAULT: &str = "client_sce_default"; const WORKOS_CLIENT_ID_KEY: AuthConfigKeySpec = AuthConfigKeySpec { @@ -49,202 +44,6 @@ const WORKOS_CLIENT_ID_KEY: AuthConfigKeySpec = AuthConfigKeySpec { baked_default: Some(WORKOS_CLIENT_ID_BAKED_DEFAULT), }; -pub type ReportFormat = OutputFormat; - -#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] -pub enum LogLevel { - Error, - Warn, - Info, - Debug, -} - -impl LogLevel { - pub(crate) fn parse(raw: &str, source: &str) -> Result { - match raw { - "error" => Ok(Self::Error), - "warn" => Ok(Self::Warn), - "info" => Ok(Self::Info), - "debug" => Ok(Self::Debug), - _ => bail!( - "Invalid log level '{raw}' from {source}. Valid values: error, warn, info, debug." - ), - } - } - - pub(crate) fn parse_env(raw: &str, key: &str) -> Result { - match raw { - "error" => Ok(Self::Error), - "warn" => Ok(Self::Warn), - "info" => Ok(Self::Info), - "debug" => Ok(Self::Debug), - _ => bail!("Invalid {key} '{raw}'. Valid values: error, warn, info, debug."), - } - } - - pub(crate) fn as_str(self) -> &'static str { - match self { - Self::Error => "error", - Self::Warn => "warn", - Self::Info => "info", - Self::Debug => "debug", - } - } - - pub(crate) fn severity(self) -> u8 { - match self { - Self::Error => 1, - Self::Warn => 2, - Self::Info => 3, - Self::Debug => 4, - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum LogFormat { - Text, - Json, -} - -impl LogFormat { - pub(crate) fn parse(raw: &str, source: &str) -> Result { - match raw { - "text" => Ok(Self::Text), - "json" => Ok(Self::Json), - _ => bail!("Invalid log format '{raw}' from {source}. Valid values: text, json."), - } - } - - #[allow(dead_code)] - pub(crate) fn parse_env(raw: &str, key: &str) -> Result { - match raw { - "text" => Ok(Self::Text), - "json" => Ok(Self::Json), - _ => bail!("Invalid {key} '{raw}'. Valid values: text, json."), - } - } - - pub(crate) fn as_str(self) -> &'static str { - match self { - Self::Text => "text", - Self::Json => "json", - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum LogFileMode { - Truncate, - Append, -} - -impl LogFileMode { - pub(crate) fn parse(raw: &str, source: &str) -> Result { - match raw { - "truncate" => Ok(Self::Truncate), - "append" => Ok(Self::Append), - _ => bail!( - "Invalid log file mode '{raw}' from {source}. Valid values: truncate, append." - ), - } - } - - #[allow(dead_code)] - pub(crate) fn parse_env(raw: &str, key: &str) -> Result { - match raw { - "truncate" => Ok(Self::Truncate), - "append" => Ok(Self::Append), - _ => bail!("Invalid {key} '{raw}'. Valid values: truncate, append."), - } - } - - pub(crate) fn as_str(self) -> &'static str { - match self { - Self::Truncate => "truncate", - Self::Append => "append", - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum ValueSource { - Flag, - Env, - ConfigFile(ConfigPathSource), - Default, -} - -impl ValueSource { - fn as_str(self) -> &'static str { - match self { - Self::Flag => "flag", - Self::Env => "env", - Self::ConfigFile(_) => "config_file", - Self::Default => "default", - } - } - - fn config_source(self) -> Option { - match self { - Self::ConfigFile(source) => Some(source), - _ => None, - } - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum ConfigSubcommand { - Show(ConfigRequest), - Validate(ConfigRequest), -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct ConfigRequest { - pub report_format: ReportFormat, - pub config_path: Option, - pub log_level: Option, - pub timeout_ms: Option, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum ConfigPathSource { - Flag, - Env, - DefaultDiscoveredGlobal, - DefaultDiscoveredLocal, -} - -impl ConfigPathSource { - pub(crate) fn as_str(self) -> &'static str { - match self { - Self::Flag => "flag", - Self::Env => "env", - Self::DefaultDiscoveredGlobal => "default_discovered_global", - Self::DefaultDiscoveredLocal => "default_discovered_local", - } - } - - const fn is_default_discovered(self) -> bool { - matches!( - self, - Self::DefaultDiscoveredGlobal | Self::DefaultDiscoveredLocal - ) - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -struct ResolvedValue { - value: T, - source: ValueSource, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct LoadedConfigPath { - pub(crate) path: PathBuf, - pub(crate) source: ConfigPathSource, -} - #[derive(Clone, Copy, Debug, Eq, PartialEq)] struct AuthConfigKeySpec { config_key: &'static str, @@ -282,32 +81,6 @@ struct RuntimeConfig { validation_warnings: Vec, } -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct ResolvedOptionalValue { - pub(crate) value: Option, - source: Option, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct ResolvedAuthRuntimeConfig { - pub(crate) workos_client_id: ResolvedOptionalValue, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct ResolvedObservabilityRuntimeConfig { - pub(crate) log_level: LogLevel, - pub(crate) log_format: LogFormat, - pub(crate) log_file: Option, - pub(crate) log_file_mode: LogFileMode, - pub(crate) loaded_config_paths: Vec, - pub(crate) validation_errors: Vec, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct ResolvedHookRuntimeConfig { - pub(crate) attribution_hooks_enabled: bool, -} - #[derive(Clone, Debug, Eq, PartialEq)] struct FileConfig { log_level: Option>, @@ -866,14 +639,6 @@ where }) } -pub(crate) fn parse_bool_value_from(key: &str, raw: &str, source: &str) -> Result { - match raw { - "1" | "true" => Ok(true), - "0" | "false" => Ok(false), - _ => bail!("Invalid {key} '{raw}' from {source}. Valid values: true, false, 1, 0."), - } -} - fn resolve_bash_policy_config( presets: Option<&FileConfigValue>>, custom: Option<&FileConfigValue>>, diff --git a/cli/src/services/config/types.rs b/cli/src/services/config/types.rs new file mode 100644 index 00000000..ccbdb84c --- /dev/null +++ b/cli/src/services/config/types.rs @@ -0,0 +1,252 @@ +//! Shared config types, enums, constants, and source metadata. +//! +//! This submodule owns the stable config primitives that are consumed across +//! multiple services. The parent `mod.rs` re-exports everything here through +//! `pub use types::*` so that existing `services::config::TypeName` imports +//! continue to work unchanged. + +use std::path::PathBuf; + +use clap::ValueEnum; + +use crate::services::output_format::OutputFormat; + +pub const NAME: &str = "config"; + +pub(crate) const ENV_LOG_LEVEL: &str = "SCE_LOG_LEVEL"; +pub(crate) const ENV_LOG_FORMAT: &str = "SCE_LOG_FORMAT"; +pub(crate) const ENV_LOG_FILE: &str = "SCE_LOG_FILE"; +pub(crate) const ENV_LOG_FILE_MODE: &str = "SCE_LOG_FILE_MODE"; +pub(crate) const ENV_ATTRIBUTION_HOOKS_ENABLED: &str = "SCE_ATTRIBUTION_HOOKS_ENABLED"; + +pub type ReportFormat = OutputFormat; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] +pub enum LogLevel { + Error, + Warn, + Info, + Debug, +} + +impl LogLevel { + pub(crate) fn parse(raw: &str, source: &str) -> anyhow::Result { + match raw { + "error" => Ok(Self::Error), + "warn" => Ok(Self::Warn), + "info" => Ok(Self::Info), + "debug" => Ok(Self::Debug), + _ => anyhow::bail!( + "Invalid log level '{raw}' from {source}. Valid values: error, warn, info, debug." + ), + } + } + + pub(crate) fn parse_env(raw: &str, key: &str) -> anyhow::Result { + match raw { + "error" => Ok(Self::Error), + "warn" => Ok(Self::Warn), + "info" => Ok(Self::Info), + "debug" => Ok(Self::Debug), + _ => anyhow::bail!("Invalid {key} '{raw}'. Valid values: error, warn, info, debug."), + } + } + + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::Error => "error", + Self::Warn => "warn", + Self::Info => "info", + Self::Debug => "debug", + } + } + + pub(crate) fn severity(self) -> u8 { + match self { + Self::Error => 1, + Self::Warn => 2, + Self::Info => 3, + Self::Debug => 4, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum LogFormat { + Text, + Json, +} + +impl LogFormat { + pub(crate) fn parse(raw: &str, source: &str) -> anyhow::Result { + match raw { + "text" => Ok(Self::Text), + "json" => Ok(Self::Json), + _ => { + anyhow::bail!("Invalid log format '{raw}' from {source}. Valid values: text, json.") + } + } + } + + #[allow(dead_code)] + pub(crate) fn parse_env(raw: &str, key: &str) -> anyhow::Result { + match raw { + "text" => Ok(Self::Text), + "json" => Ok(Self::Json), + _ => anyhow::bail!("Invalid {key} '{raw}'. Valid values: text, json."), + } + } + + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::Text => "text", + Self::Json => "json", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum LogFileMode { + Truncate, + Append, +} + +impl LogFileMode { + pub(crate) fn parse(raw: &str, source: &str) -> anyhow::Result { + match raw { + "truncate" => Ok(Self::Truncate), + "append" => Ok(Self::Append), + _ => anyhow::bail!( + "Invalid log file mode '{raw}' from {source}. Valid values: truncate, append." + ), + } + } + + #[allow(dead_code)] + pub(crate) fn parse_env(raw: &str, key: &str) -> anyhow::Result { + match raw { + "truncate" => Ok(Self::Truncate), + "append" => Ok(Self::Append), + _ => anyhow::bail!("Invalid {key} '{raw}'. Valid values: truncate, append."), + } + } + + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::Truncate => "truncate", + Self::Append => "append", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ValueSource { + Flag, + Env, + ConfigFile(ConfigPathSource), + Default, +} + +impl ValueSource { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::Flag => "flag", + Self::Env => "env", + Self::ConfigFile(_) => "config_file", + Self::Default => "default", + } + } + + pub(crate) fn config_source(self) -> Option { + match self { + Self::ConfigFile(source) => Some(source), + _ => None, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ConfigPathSource { + Flag, + Env, + DefaultDiscoveredGlobal, + DefaultDiscoveredLocal, +} + +impl ConfigPathSource { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::Flag => "flag", + Self::Env => "env", + Self::DefaultDiscoveredGlobal => "default_discovered_global", + Self::DefaultDiscoveredLocal => "default_discovered_local", + } + } + + pub(crate) const fn is_default_discovered(self) -> bool { + matches!( + self, + Self::DefaultDiscoveredGlobal | Self::DefaultDiscoveredLocal + ) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ConfigSubcommand { + Show(ConfigRequest), + Validate(ConfigRequest), +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ConfigRequest { + pub report_format: ReportFormat, + pub config_path: Option, + pub log_level: Option, + pub timeout_ms: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ResolvedValue { + pub(crate) value: T, + pub(crate) source: ValueSource, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ResolvedOptionalValue { + pub(crate) value: Option, + pub(crate) source: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct LoadedConfigPath { + pub(crate) path: PathBuf, + pub(crate) source: ConfigPathSource, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ResolvedAuthRuntimeConfig { + pub(crate) workos_client_id: ResolvedOptionalValue, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ResolvedObservabilityRuntimeConfig { + pub(crate) log_level: LogLevel, + pub(crate) log_format: LogFormat, + pub(crate) log_file: Option, + pub(crate) log_file_mode: LogFileMode, + pub(crate) loaded_config_paths: Vec, + pub(crate) validation_errors: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ResolvedHookRuntimeConfig { + pub(crate) attribution_hooks_enabled: bool, +} + +pub(crate) fn parse_bool_value_from(key: &str, raw: &str, source: &str) -> anyhow::Result { + match raw { + "1" | "true" => Ok(true), + "0" | "false" => Ok(false), + _ => anyhow::bail!("Invalid {key} '{raw}' from {source}. Valid values: true, false, 1, 0."), + } +} diff --git a/context/glossary.md b/context/glossary.md index aaa2edb0..f28dfe3d 100644 --- a/context/glossary.md +++ b/context/glossary.md @@ -87,7 +87,7 @@ - `GitOps`: Git capability trait in `cli/src/services/capabilities.rs` with `run_command`, `resolve_repository_root`, `resolve_hooks_directory`, and `is_available`, implemented in production by `ProcessGitOps`. - `SCE default path policy seam`: Canonical path resolver in `cli/src/services/default_paths.rs` that owns config/state/cache root resolution through an internal `roots` helper seam, named default paths, and an explicit inventory for the current default persisted artifacts (`global config`, `auth tokens`); named DB paths include `auth DB`, `local DB`, and `Agent Trace DB`. On Linux those defaults resolve to `$XDG_CONFIG_HOME/sce/config.json`, `$XDG_STATE_HOME/sce/auth/tokens.json`, `$XDG_STATE_HOME/sce/auth.db`, `$XDG_STATE_HOME/sce/local.db`, and `$XDG_STATE_HOME/sce/agent-trace.db` with platform-equivalent `dirs` fallbacks elsewhere. The same module is also the canonical owner for broader production CLI path definitions and is protected by a regression test that fails when new non-test production path literals are introduced outside `default_paths.rs`. - `cli config precedence contract`: Deterministic runtime value resolution in `cli/src/services/config/mod.rs` with precedence `flags > env > config file > defaults` for flag-backed keys (`log_level`, `timeout_ms`) plus shared app-runtime observability keys (`log_format`, `log_file`, `log_file_mode`) consumed by `cli/src/app.rs`; config discovery order is `--config`, `SCE_CONFIG_FILE`, then default discovered global+local paths (`${config_root}/sce/config.json` merged before `.sce/config.json`, with local overriding per key, where `config_root` comes from the shared default path policy seam and resolves to `$XDG_CONFIG_HOME` / `dirs::config_dir()` semantics with platform fallback behavior rather than the old state/data-root default). Runtime startup config loading now also permits the canonical top-level `"$schema": "https://sce.crocoder.dev/config.json"` declaration anywhere those config files are parsed. -- `shared runtime/config primitives seam`: Canonical ownership in `cli/src/services/config/mod.rs` for the CLI's shared observability/config enums (`LogLevel`, `LogFormat`, `LogFileMode`), observability env-key constants, and shared bool parsing helpers reused by `cli/src/services/observability.rs`. +- `shared runtime/config primitives seam`: Canonical ownership in `cli/src/services/config/types.rs` for the CLI's shared observability/config enums (`LogLevel`, `LogFormat`, `LogFileMode`), request/response primitives (`ConfigSubcommand`, `ConfigRequest`, `ReportFormat`), source metadata types (`ValueSource`, `ConfigPathSource`, `LoadedConfigPath`, `ResolvedValue`, `ResolvedOptionalValue`), resolved runtime config types (`ResolvedAuthRuntimeConfig`, `ResolvedObservabilityRuntimeConfig`, `ResolvedHookRuntimeConfig`), the `NAME` constant, observability env-key constants, and shared bool parsing helpers; re-exported through `cli/src/services/config/mod.rs` via `pub use types::*` so downstream modules continue importing through `services::config` unchanged. - `sce config schema artifact`: Canonical JSON Schema for global and repo-local `sce/config.json` files, authored in `config/pkl/base/sce-config-schema.pkl`, generated to `config/schema/sce-config.schema.json`, and embedded by `cli/src/services/config/mod.rs` for shared `sce config validate` and doctor config validation. The current schema accepts the canonical `$schema` declaration, flat logging keys (`log_level`, `log_format`, `log_file`, `log_file_mode`), existing auth/config keys, and enforces the schema-level dependency that `log_file_mode` requires `log_file`. - `bash tool policy config surface`: Nested repo config namespace under `.sce/config.json` at `policies.bash`, currently supporting unique built-in `presets` plus repo-owned `custom` argv-prefix rules with deterministic validation, merged global/local resolution, and first-class `sce config show|validate` reporting. - `attribution hooks gate`: Disabled-default local hook runtime gate resolved through shared config precedence in `cli/src/services/config/mod.rs`: env `SCE_ATTRIBUTION_HOOKS_ENABLED` overrides repo/global config key `policies.attribution_hooks.enabled`, and the current enabled path activates commit-msg-only attribution without re-enabling trace persistence. diff --git a/context/overview.md b/context/overview.md index dd85dff8..95c2a94f 100644 --- a/context/overview.md +++ b/context/overview.md @@ -20,7 +20,7 @@ The setup service also provides repository-root install orchestration: it resolv The CLI now also applies baseline security hardening for reliability-driven automation: diagnostics/logging paths use deterministic secret redaction, `sce setup --hooks --repo ` canonicalizes and validates repository paths before execution, and setup write flows run explicit directory write-permission probes before staging/swap operations. The config service now provides deterministic runtime config resolution with explicit precedence (`flags > env > config file > defaults`), strict config-file validation (`$schema`, `log_level`, `log_format`, `log_file`, `log_file_mode`, `timeout_ms`, `workos_client_id`, and nested `policies.bash`), deterministic default discovery/merge of global+local config files (`${config_root}/sce/config.json` then `.sce/config.json` with local override, where `config_root` comes from the shared default-path seam with XDG/`dirs::config_dir()` config-root resolution), defaults for the resolved observability value set (`log_level=error`, `log_format=text`, `log_file_mode=truncate`), shared auth-key resolution with optional baked defaults starting at `workos_client_id`, first-class bash-policy preset/custom parsing with deterministic conflict and duplicate-prefix validation, and a canonical Pkl-authored `sce/config.json` JSON Schema generated to `config/schema/sce-config.schema.json` and embedded by `cli/src/services/config/mod.rs` for both `sce config validate` and doctor-time config checks. Runtime startup config loading now keeps parity with that schema by accepting the canonical `"$schema": "https://sce.crocoder.dev/config.json"` declaration in repo-local and global config files, so startup commands such as `sce version` no longer fail before dispatch on that field. App-runtime observability now consumes flat logging keys through the shared resolver, so env values still override config-file values while config files provide deterministic fallback for file logging; `sce config show` reports resolved observability/auth/policy values with provenance, while `sce config validate` is now a trimmed validation surface that reports only pass/fail plus validation errors or warnings in text and JSON modes. The canonical preset catalog and matching contract live in `config/pkl/data/bash-policy-presets.json` and `context/sce/bash-tool-policy-enforcement-contract.md`. Invalid default-discovered config files now also degrade gracefully at startup: `sce` keeps running with degraded observability defaults, logs `sce.config.invalid_config` warnings, and reserves hard failures for explicit `--config` / `SCE_CONFIG_FILE` targets or other truly invalid runtime observability inputs. -`cli/src/services/config/mod.rs` is now also the canonical owner for the CLI's shared observability/config primitive seam: `LogLevel`, `LogFormat`, `LogFileMode`, the observability env-key constants, and the shared bool parsing helpers consumed by `cli/src/services/observability.rs`. The CLI now has a minimal `AppContext` dependency-injection container in `cli/src/app.rs` holding `Arc`, `Arc`, `Arc`, `Arc`, and an optional `repo_root: Option`; it can derive repo-root-scoped contexts with `with_repo_root(...)` while preserving runtime dependencies. The broad capability seam lives in `cli/src/services/capabilities.rs`, where `FsOps`/`StdFsOps` wrap filesystem operations and `GitOps`/`ProcessGitOps` wrap git process execution plus repository-root/hooks-directory resolution. Current services have not migrated to consume the filesystem/git traits internally yet. +`cli/src/services/config/mod.rs` is now a module facade that declares `pub mod types` and re-exports `pub use types::*`, delegating shared config primitive ownership to `cli/src/services/config/types.rs`: `LogLevel`, `LogFormat`, `LogFileMode`, `ConfigSubcommand`, `ConfigRequest`, `ReportFormat`, source metadata types (`ValueSource`, `ConfigPathSource`, `LoadedConfigPath`, `ResolvedValue`, `ResolvedOptionalValue`), resolved runtime config types (`ResolvedAuthRuntimeConfig`, `ResolvedObservabilityRuntimeConfig`, `ResolvedHookRuntimeConfig`), the `NAME` constant, observability env-key constants, and the shared `parse_bool_value_from` helper. Downstream modules continue importing through `services::config` unchanged. The CLI now has a minimal `AppContext` dependency-injection container in `cli/src/app.rs` holding `Arc`, `Arc`, `Arc`, `Arc`, and an optional `repo_root: Option`; it can derive repo-root-scoped contexts with `with_repo_root(...)` while preserving runtime dependencies. The broad capability seam lives in `cli/src/services/capabilities.rs`, where `FsOps`/`StdFsOps` wrap filesystem operations and `GitOps`/`ProcessGitOps` wrap git process execution plus repository-root/hooks-directory resolution. Current services have not migrated to consume the filesystem/git traits internally yet. The shared default path service in `cli/src/services/default_paths.rs` is now the canonical owner for production CLI path definitions. It resolves per-user config/state/cache roots through a dedicated internal `roots` seam, exposes the current persisted-artifact inventory (global config and auth tokens), and also defines named DB paths (auth DB, local DB, Agent Trace DB) plus the repo-relative, embedded-asset, install/runtime, hook, and context-path accessors consumed across current CLI production code. Non-test production modules should consume this shared catalog instead of hardcoding owned path literals. No default cache-backed persisted artifact currently exists, so cache-root resolution remains available without speculative cache-path features and no legacy default-path fallback is supported. The same config resolver now also owns the attribution-hooks gate used by local hook runtime: `SCE_ATTRIBUTION_HOOKS_ENABLED` overrides `policies.attribution_hooks.enabled`, and the gate defaults to disabled. Generated config now includes repo-local OpenCode plugin assets for both profiles: `sce-bash-policy.ts` plus `sce-agent-trace.ts` are emitted under `config/.opencode/plugins/` and `config/automated/.opencode/plugins/`; the agent-trace plugin extracts `{ sessionID, diff, time, model_id }` from user `message.updated` events with diffs, tracks per-session OpenCode client version from `session.created`/`session.updated`, and sends payloads to `sce hooks diff-trace` with `tool_name="opencode"` plus optional `tool_version`; the Rust hook continues to validate required fields and persists `model_id`, `tool_name`, and nullable `tool_version` into `diff_traces` through AgentTraceDb. Bash-policy also emits shared runtime logic and preset data under `config/.opencode/lib/` (also emitted for `config/automated/.opencode/**`). Claude bash-policy enforcement has been removed from generated outputs. diff --git a/context/plans/cli-maintenance-hazards.md b/context/plans/cli-maintenance-hazards.md index a79c001c..fb931fd8 100644 --- a/context/plans/cli-maintenance-hazards.md +++ b/context/plans/cli-maintenance-hazards.md @@ -51,12 +51,16 @@ Resolve the architectural/maintenance hazards in the Rust CLI without changing u - Evidence: `nix flake check` passed. Initial narrow `nix develop -c sh -c 'cd cli && cargo fmt --check && cargo check'` attempt was blocked by repo policy in favor of flake validation. - Notes: `TursoConnectionCore` now owns the shared synchronous `execute`, `query`, `query_map`, and `run_migrations` implementation; `TursoDb` and `EncryptedTursoDb` keep separate constructors for unencrypted vs encrypted connection initialization and delegate public operation methods to the shared core. -- [ ] T03: `Create config module facade and shared type submodule` (status:todo) +- [x] T03: `Create config module facade and shared type submodule` (status:done) - Task ID: T03 - Goal: Establish the config split by moving stable shared config types/constants into a focused submodule while keeping `services::config` re-exports/source compatibility. - Boundaries (in/out of scope): In - create a `types`-style submodule for config request/response primitives, log/config enums, source metadata, constants that are safe to move first, and facade re-exports from `mod.rs`. Out - moving resolution logic, schema validation, policy validation, or renderers. - Done when: `cli/src/services/config/mod.rs` starts acting as a module facade for shared config primitives; existing callers still import through `services::config::*` as before; behavior is unchanged. - Verification notes (commands or checks): Nix-wrapped compile/check or `nix flake check`; confirm no public API churn outside config-owned imports is needed. + - Completed: 2026-06-07 + - Files changed: `cli/src/services/config/mod.rs`, `cli/src/services/config/types.rs` + - Evidence: `nix flake check` passed (cli-tests, cli-clippy, cli-fmt, pkl-parity all green); `nix run .#pkl-check-generated` passed. No public API changes required outside `config/` module; all existing callers continue importing through `services::config::*`. + - Notes: Created `cli/src/services/config/types.rs` containing `LogLevel`, `LogFormat`, `LogFileMode`, `ReportFormat`, `ConfigSubcommand`, `ConfigRequest`, `ValueSource`, `ConfigPathSource`, `LoadedConfigPath`, `ResolvedValue`, `ResolvedOptionalValue`, `ResolvedAuthRuntimeConfig`, `ResolvedObservabilityRuntimeConfig`, `ResolvedHookRuntimeConfig`, `NAME`, env key constants (`ENV_LOG_LEVEL`, `ENV_LOG_FORMAT`, `ENV_LOG_FILE`, `ENV_LOG_FILE_MODE`, `ENV_ATTRIBUTION_HOOKS_ENABLED`), and `parse_bool_value_from`. `mod.rs` now declares `pub mod types;` and `pub use types::*;` as facade re-exports. Resolution logic, schema validation, policy validation, and renderers remain in `mod.rs`. - [ ] T04: `Extract config schema loading and file parsing concerns` (status:todo) - Task ID: T04 From dc791e29824b13b3b59e3e53b80b375ad0149855 Mon Sep 17 00:00:00 2001 From: David Abram Date: Sun, 7 Jun 2026 16:32:58 +0200 Subject: [PATCH 4/7] cli/config: Extract schema loading and file parsing into schema.rs Move JSON Schema embedding, OnceLock validator setup, top-level allowed-key validation, serde DTO definitions, and config-file load/parse helpers from mod.rs into a focused schema.rs submodule. mod.rs now re-exports validate_config_file and remains a slimmer facade. No public API changes; downstream modules continue importing through services::config::*. Behavior, validation output, and error messages remain unchanged. Co-authored-by: SCE --- cli/src/services/config/mod.rs | 403 ++--------------------- cli/src/services/config/schema.rs | 386 ++++++++++++++++++++++ context/glossary.md | 11 +- context/overview.md | 2 +- context/plans/cli-maintenance-hazards.md | 6 +- 5 files changed, 426 insertions(+), 382 deletions(-) create mode 100644 cli/src/services/config/schema.rs diff --git a/cli/src/services/config/mod.rs b/cli/src/services/config/mod.rs index ba387af5..e3c9ad00 100644 --- a/cli/src/services/config/mod.rs +++ b/cli/src/services/config/mod.rs @@ -1,5 +1,6 @@ pub mod command; pub mod lifecycle; +pub mod schema; pub mod types; pub use types::*; @@ -10,49 +11,32 @@ use std::{ }; use anyhow::{anyhow, bail, Context, Result}; -use jsonschema::{validator_for, Validator}; -use serde::Deserialize; use serde_json::{json, Value}; -use crate::services::default_paths::{resolve_sce_default_locations, schema, RepoPaths}; +use crate::services::default_paths::{resolve_sce_default_locations, RepoPaths}; use crate::services::style::{self}; -#[cfg_attr(not(test), allow(dead_code))] -pub(crate) const SCE_CONFIG_SCHEMA_JSON: &str = - include_str!("../../../assets/generated/config/schema/sce-config.schema.json"); +pub(crate) use schema::validate_config_file; const DEFAULT_TIMEOUT_MS: u64 = 30000; const PRECEDENCE_DESCRIPTION: &str = "flags > env > config file > defaults"; -const CONFIG_SCHEMA_DECLARATION_KEY: &str = "$schema"; -const TOP_LEVEL_CONFIG_KEYS: &[&str] = &[ - CONFIG_SCHEMA_DECLARATION_KEY, - "log_level", - "log_format", - "log_file", - "log_file_mode", - "timeout_ms", - WORKOS_CLIENT_ID_KEY.config_key, - "policies", -]; -const TOP_LEVEL_CONFIG_KEYS_DESCRIPTION: &str = - "$schema, log_level, log_format, log_file, log_file_mode, timeout_ms, workos_client_id, policies"; const WORKOS_CLIENT_ID_ENV: &str = "WORKOS_CLIENT_ID"; const WORKOS_CLIENT_ID_BAKED_DEFAULT: &str = "client_sce_default"; -const WORKOS_CLIENT_ID_KEY: AuthConfigKeySpec = AuthConfigKeySpec { +pub(crate) const WORKOS_CLIENT_ID_KEY: AuthConfigKeySpec = AuthConfigKeySpec { config_key: "workos_client_id", env_key: WORKOS_CLIENT_ID_ENV, baked_default: Some(WORKOS_CLIENT_ID_BAKED_DEFAULT), }; #[derive(Clone, Copy, Debug, Eq, PartialEq)] -struct AuthConfigKeySpec { - config_key: &'static str, - env_key: &'static str, - baked_default: Option<&'static str>, +pub(crate) struct AuthConfigKeySpec { + pub(crate) config_key: &'static str, + pub(crate) env_key: &'static str, + pub(crate) baked_default: Option<&'static str>, } impl AuthConfigKeySpec { - fn precedence_description(self) -> String { + pub(crate) fn precedence_description(self) -> String { let mut layers = vec![ format!("env ({})", self.env_key), format!("config file ({})", self.config_key), @@ -81,79 +65,7 @@ struct RuntimeConfig { validation_warnings: Vec, } -#[derive(Clone, Debug, Eq, PartialEq)] -struct FileConfig { - log_level: Option>, - log_format: Option>, - log_file: Option>, - log_file_mode: Option>, - timeout_ms: Option>, - attribution_hooks_enabled: Option>, - workos_client_id: Option>, - bash_policy_presets: Option>>, - bash_policy_custom: Option>>, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] -struct ParsedFileConfigDocument { - #[serde(rename = "$schema")] - _schema: Option, - log_level: Option, - log_format: Option, - log_file: Option, - log_file_mode: Option, - timeout_ms: Option, - workos_client_id: Option, - policies: Option, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] -struct ParsedPoliciesConfigDocument { - bash: Option, - attribution_hooks: Option, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] -struct ParsedBashPolicyConfigDocument { - presets: Option>, - custom: Option>, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] -struct ParsedAttributionHooksConfigDocument { - enabled: Option, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] -struct ParsedCustomBashPolicyEntryDocument { - id: Option, - #[serde(rename = "match")] - matcher: Option, - message: Option, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] -struct ParsedCustomBashPolicyMatchDocument { - argv_prefix: Option>, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -struct FileConfigValue { - value: T, - source: ConfigPathSource, -} - -type ParsedBashPolicyConfig = ( - Option>>, - Option>>, -); -type ParsedFilePolicies = ( - Option>, - Option>>, - Option>>, -); static BUILTIN_BASH_POLICY_CATALOG: OnceLock = OnceLock::new(); -static CONFIG_SCHEMA_VALIDATOR: OnceLock = OnceLock::new(); const BASH_POLICY_PRESET_CATALOG_JSON: &str = include_str!("../../../assets/generated/config/opencode/lib/bash-policy-presets.json"); @@ -164,14 +76,14 @@ struct BashPolicyConfig { custom: Vec, } -#[derive(Debug, Deserialize)] +#[derive(Debug, serde::Deserialize)] struct BuiltinBashPolicyCatalog { presets: Vec, mutually_exclusive: Vec>, redundancy_warnings: Vec, } -#[derive(Debug, Deserialize)] +#[derive(Debug, serde::Deserialize)] struct BuiltinBashPolicyPreset { id: String, #[serde(rename = "match")] @@ -179,22 +91,22 @@ struct BuiltinBashPolicyPreset { message: String, } -#[derive(Debug, Deserialize)] +#[derive(Debug, serde::Deserialize)] struct BuiltinBashPolicyMatcher { argv_prefixes: Vec>, } -#[derive(Debug, Deserialize)] +#[derive(Debug, serde::Deserialize)] struct BuiltinBashPolicyRedundancyWarning { if_enabled: Vec, warning: String, } #[derive(Clone, Debug, Eq, PartialEq)] -struct CustomBashPolicyEntry { - id: String, - argv_prefix: Vec, - message: String, +pub(crate) struct CustomBashPolicyEntry { + pub(crate) id: String, + pub(crate) argv_prefix: Vec, + pub(crate) message: String, } impl CustomBashPolicyEntry { @@ -238,7 +150,7 @@ fn builtin_bash_policy_preset_ids() -> Vec<&'static str> { .collect() } -fn is_builtin_bash_policy_preset_id(id: &str) -> bool { +pub(crate) fn is_builtin_bash_policy_preset_id(id: &str) -> bool { builtin_bash_policy_catalog() .presets .iter() @@ -435,7 +347,7 @@ where resolve_global_config_path, )?; - let mut file_config = FileConfig { + let mut file_config = schema::FileConfig { log_level: None, log_format: None, log_file: None, @@ -449,7 +361,7 @@ where let mut validation_errors = Vec::new(); for loaded_path in &loaded_config_paths { let raw = read_file(&loaded_path.path)?; - let layer = match parse_file_config(&raw, &loaded_path.path, loaded_path.source) { + let layer = match schema::parse_file_config(&raw, &loaded_path.path, loaded_path.source) { Ok(layer) => layer, Err(error) if loaded_path.source.is_default_discovered() => { validation_errors.push(error.to_string()); @@ -640,8 +552,8 @@ where } fn resolve_bash_policy_config( - presets: Option<&FileConfigValue>>, - custom: Option<&FileConfigValue>>, + presets: Option<&schema::FileConfigValue>>, + custom: Option<&schema::FileConfigValue>>, ) -> ResolvedOptionalValue { let resolved_presets = presets.map(|value| value.value.clone()); let resolved_custom = custom.map(|value| value.value.clone()); @@ -687,7 +599,7 @@ fn build_validation_warnings(value: &ResolvedOptionalValue) -> fn resolve_optional_auth_config_value( key: AuthConfigKeySpec, - file_value: Option>, + file_value: Option>, env_lookup: &FEnv, ) -> ResolvedOptionalValue where @@ -783,266 +695,7 @@ fn resolve_default_global_config_path() -> Result { Ok(resolve_sce_default_locations()?.global_config_file()) } -pub(crate) fn validate_config_file(path: &Path) -> Result<()> { - let raw = std::fs::read_to_string(path) - .with_context(|| format!("Failed to read config file '{}'.", path.display()))?; - parse_file_config(&raw, path, ConfigPathSource::Flag)?; - Ok(()) -} - -fn config_schema_validator() -> &'static Validator { - CONFIG_SCHEMA_VALIDATOR.get_or_init(|| { - let schema: Value = - serde_json::from_str(SCE_CONFIG_SCHEMA_JSON).expect("config schema JSON should parse"); - validator_for(&schema).expect("config schema JSON should compile") - }) -} - -fn generated_config_schema_path() -> String { - format!("{}/{}", schema::SCHEMA_DIR, schema::SCE_CONFIG_SCHEMA) -} - -fn validate_config_value_against_schema(value: &Value, path: &Path) -> Result<()> { - let mut errors = config_schema_validator() - .iter_errors(value) - .map(|error| error.to_string()) - .collect::>(); - - if errors.is_empty() { - return Ok(()); - } - - errors.sort(); - let generated_schema_path = generated_config_schema_path(); - bail!( - "Config file '{}' failed schema validation against generated schema '{}': {}", - path.display(), - generated_schema_path, - errors.join(" | ") - ); -} - -fn validate_object_keys( - object: &serde_json::Map, - path: &Path, - context: Option<&str>, - allowed_keys: &[&str], - allowed_keys_description: &str, -) -> Result<()> { - for key in object.keys() { - if !allowed_keys.contains(&key.as_str()) { - match context { - Some(context) => bail!( - "Config key '{context}' in '{}' contains unknown key '{}'. Allowed keys: {allowed_keys_description}.", - path.display(), - key - ), - None => bail!( - "Config file '{}' contains unknown key '{}'. Allowed keys: {allowed_keys_description}.", - path.display(), - key - ), - } - } - } - - Ok(()) -} - -fn deserialize_typed_config(parsed: Value, path: &Path) -> Result { - serde_json::from_value(parsed).with_context(|| { - format!( - "Config file '{}' could not be mapped into the typed runtime config model.", - path.display() - ) - }) -} - -fn parse_file_config(raw: &str, path: &Path, source: ConfigPathSource) -> Result { - let parsed: Value = serde_json::from_str(raw) - .with_context(|| format!("Config file '{}' must contain valid JSON.", path.display()))?; - - let object = parsed.as_object().with_context(|| { - format!( - "Config file '{}' must contain a top-level JSON object.", - path.display() - ) - })?; - - validate_config_value_against_schema(&parsed, path)?; - validate_object_keys( - object, - path, - None, - TOP_LEVEL_CONFIG_KEYS, - TOP_LEVEL_CONFIG_KEYS_DESCRIPTION, - )?; - - let typed = deserialize_typed_config(parsed.clone(), path)?; - let log_level = typed - .log_level - .map(|raw| -> Result> { - Ok(FileConfigValue { - value: LogLevel::parse(&raw, &format!("config file '{}'", path.display()))?, - source, - }) - }) - .transpose()?; - let log_format = typed - .log_format - .map(|raw| -> Result> { - Ok(FileConfigValue { - value: LogFormat::parse(&raw, &format!("config file '{}'", path.display()))?, - source, - }) - }) - .transpose()?; - let log_file = typed - .log_file - .map(|value| FileConfigValue { value, source }); - let log_file_mode = typed - .log_file_mode - .map(|raw| -> Result> { - Ok(FileConfigValue { - value: LogFileMode::parse(&raw, &format!("config file '{}'", path.display()))?, - source, - }) - }) - .transpose()?; - let timeout_ms = typed - .timeout_ms - .map(|value| FileConfigValue { value, source }); - let workos_client_id = typed - .workos_client_id - .map(|value| FileConfigValue { value, source }); - let (attribution_hooks_enabled, bash_policy_presets, bash_policy_custom) = - map_policies_config(typed.policies.as_ref(), object, path, source)?; - - Ok(FileConfig { - log_level, - log_format, - log_file, - log_file_mode, - timeout_ms, - attribution_hooks_enabled, - workos_client_id, - bash_policy_presets, - bash_policy_custom, - }) -} - -fn map_policies_config( - typed: Option<&ParsedPoliciesConfigDocument>, - object: &serde_json::Map, - path: &Path, - source: ConfigPathSource, -) -> Result { - let Some(policies_value) = object.get("policies") else { - return Ok((None, None, None)); - }; - - let policies_object = policies_value.as_object().with_context(|| { - format!( - "Config key 'policies' in '{}' must be an object.", - path.display() - ) - })?; - - validate_object_keys( - policies_object, - path, - Some("policies"), - &["bash", "attribution_hooks"], - "bash, attribution_hooks", - )?; - - let bash = typed.and_then(|config| config.bash.as_ref()); - let attribution_hooks_enabled = map_attribution_hooks_config( - typed.and_then(|config| config.attribution_hooks.as_ref()), - policies_object, - path, - source, - )?; - let (bash_policy_presets, bash_policy_custom) = - map_bash_policy_config(bash, policies_object, path, source)?; - - Ok(( - attribution_hooks_enabled, - bash_policy_presets, - bash_policy_custom, - )) -} - -fn map_attribution_hooks_config( - typed: Option<&ParsedAttributionHooksConfigDocument>, - policies_object: &serde_json::Map, - path: &Path, - source: ConfigPathSource, -) -> Result>> { - let Some(attribution_hooks_value) = policies_object.get("attribution_hooks") else { - return Ok(None); - }; - - let attribution_hooks_object = attribution_hooks_value.as_object().with_context(|| { - format!( - "Config key 'policies.attribution_hooks' in '{}' must be an object.", - path.display() - ) - })?; - - validate_object_keys( - attribution_hooks_object, - path, - Some("policies.attribution_hooks"), - &["enabled"], - "enabled", - )?; - - Ok(typed - .and_then(|config| config.enabled) - .map(|value| FileConfigValue { value, source })) -} - -fn map_bash_policy_config( - typed: Option<&ParsedBashPolicyConfigDocument>, - policies_object: &serde_json::Map, - path: &Path, - source: ConfigPathSource, -) -> Result { - let Some(bash_value) = policies_object.get("bash") else { - return Ok((None, None)); - }; - - let bash_object = bash_value.as_object().with_context(|| { - format!( - "Config key 'policies.bash' in '{}' must be an object.", - path.display() - ) - })?; - - validate_object_keys( - bash_object, - path, - Some("policies.bash"), - &["presets", "custom"], - "presets, custom", - )?; - - let presets = typed - .and_then(|config| config.presets.as_ref()) - .map(|presets| parse_bash_policy_presets(presets, path)) - .transpose()? - .map(|value| FileConfigValue { value, source }); - let custom = typed - .and_then(|config| config.custom.as_ref()) - .map(|custom| parse_custom_bash_policies(custom, path)) - .transpose()? - .map(|value| FileConfigValue { value, source }); - - Ok((presets, custom)) -} - -fn parse_bash_policy_presets(items: &[String], path: &Path) -> Result> { +pub(crate) fn parse_bash_policy_presets(items: &[String], path: &Path) -> Result> { let mut presets = Vec::with_capacity(items.len()); let builtin_preset_ids = builtin_bash_policy_preset_ids(); for item in items { @@ -1086,8 +739,8 @@ fn parse_bash_policy_presets(items: &[String], path: &Path) -> Result Result> { let mut policies = Vec::with_capacity(items.len()); @@ -1123,7 +776,7 @@ fn parse_custom_bash_policies( } fn parse_custom_bash_policy_entry( - item: &ParsedCustomBashPolicyEntryDocument, + item: &schema::ParsedCustomBashPolicyEntryDocument, path: &Path, ) -> Result { let id = item @@ -1170,7 +823,7 @@ fn parse_custom_bash_policy_entry( fn parse_custom_bash_policy_match( id: &str, - matcher: Option<&ParsedCustomBashPolicyMatchDocument>, + matcher: Option<&schema::ParsedCustomBashPolicyMatchDocument>, path: &Path, ) -> Result> { let matcher = matcher.with_context(|| { diff --git a/cli/src/services/config/schema.rs b/cli/src/services/config/schema.rs new file mode 100644 index 00000000..e507a2bf --- /dev/null +++ b/cli/src/services/config/schema.rs @@ -0,0 +1,386 @@ +//! Config schema embedding, JSON validation, serde DTO definitions, +//! and config-file load/parse helpers. +//! +//! This submodule owns the JSON Schema constant/validator, top-level +//! allowed-key validation, typed config-file deserialization, and the +//! file-parse orchestration that bridges schema validation to the +//! runtime config model. Policy-specific semantic validation (bash-policy +//! preset/custom conflict and redundancy checks) remains in the parent +//! module and is called through `super::` from parse helpers here. + +use std::path::Path; +use std::sync::OnceLock; + +use anyhow::{bail, Context, Result}; +use jsonschema::{validator_for, Validator}; +use serde::Deserialize; +use serde_json::Value; + +use super::types::{ConfigPathSource, LogFileMode, LogFormat, LogLevel}; +use super::{parse_bash_policy_presets, parse_custom_bash_policies, CustomBashPolicyEntry}; + +#[cfg_attr(not(test), allow(dead_code))] +pub(crate) const SCE_CONFIG_SCHEMA_JSON: &str = + include_str!("../../../assets/generated/config/schema/sce-config.schema.json"); + +pub(crate) const CONFIG_SCHEMA_DECLARATION_KEY: &str = "$schema"; + +pub(crate) const TOP_LEVEL_CONFIG_KEYS: &[&str] = &[ + CONFIG_SCHEMA_DECLARATION_KEY, + "log_level", + "log_format", + "log_file", + "log_file_mode", + "timeout_ms", + super::WORKOS_CLIENT_ID_KEY.config_key, + "policies", +]; + +pub(crate) const TOP_LEVEL_CONFIG_KEYS_DESCRIPTION: &str = + "$schema, log_level, log_format, log_file, log_file_mode, timeout_ms, workos_client_id, policies"; + +static CONFIG_SCHEMA_VALIDATOR: OnceLock = OnceLock::new(); + +pub(crate) fn config_schema_validator() -> &'static Validator { + CONFIG_SCHEMA_VALIDATOR.get_or_init(|| { + let schema: Value = + serde_json::from_str(SCE_CONFIG_SCHEMA_JSON).expect("config schema JSON should parse"); + validator_for(&schema).expect("config schema JSON should compile") + }) +} + +pub(crate) fn generated_config_schema_path() -> String { + format!( + "{}/{}", + crate::services::default_paths::schema::SCHEMA_DIR, + crate::services::default_paths::schema::SCE_CONFIG_SCHEMA + ) +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +pub(crate) struct ParsedFileConfigDocument { + #[serde(rename = "$schema")] + pub(crate) _schema: Option, + pub(crate) log_level: Option, + pub(crate) log_format: Option, + pub(crate) log_file: Option, + pub(crate) log_file_mode: Option, + pub(crate) timeout_ms: Option, + pub(crate) workos_client_id: Option, + pub(crate) policies: Option, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +pub(crate) struct ParsedPoliciesConfigDocument { + pub(crate) bash: Option, + pub(crate) attribution_hooks: Option, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +pub(crate) struct ParsedBashPolicyConfigDocument { + pub(crate) presets: Option>, + pub(crate) custom: Option>, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +pub(crate) struct ParsedAttributionHooksConfigDocument { + pub(crate) enabled: Option, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +pub(crate) struct ParsedCustomBashPolicyEntryDocument { + pub(crate) id: Option, + #[serde(rename = "match")] + pub(crate) matcher: Option, + pub(crate) message: Option, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +pub(crate) struct ParsedCustomBashPolicyMatchDocument { + pub(crate) argv_prefix: Option>, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct FileConfigValue { + pub(crate) value: T, + pub(crate) source: ConfigPathSource, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct FileConfig { + pub(crate) log_level: Option>, + pub(crate) log_format: Option>, + pub(crate) log_file: Option>, + pub(crate) log_file_mode: Option>, + pub(crate) timeout_ms: Option>, + pub(crate) attribution_hooks_enabled: Option>, + pub(crate) workos_client_id: Option>, + pub(crate) bash_policy_presets: Option>>, + pub(crate) bash_policy_custom: Option>>, +} + +pub(crate) type ParsedBashPolicyConfig = ( + Option>>, + Option>>, +); + +pub(crate) type ParsedFilePolicies = ( + Option>, + Option>>, + Option>>, +); + +pub(crate) fn validate_config_value_against_schema(value: &Value, path: &Path) -> Result<()> { + let mut errors = config_schema_validator() + .iter_errors(value) + .map(|error| error.to_string()) + .collect::>(); + + if errors.is_empty() { + return Ok(()); + } + + errors.sort(); + let generated_schema_path = generated_config_schema_path(); + bail!( + "Config file '{}' failed schema validation against generated schema '{}': {}", + path.display(), + generated_schema_path, + errors.join(" | ") + ); +} + +pub(crate) fn validate_object_keys( + object: &serde_json::Map, + path: &Path, + context: Option<&str>, + allowed_keys: &[&str], + allowed_keys_description: &str, +) -> Result<()> { + for key in object.keys() { + if !allowed_keys.contains(&key.as_str()) { + match context { + Some(context) => bail!( + "Config key '{context}' in '{}' contains unknown key '{}'. Allowed keys: {allowed_keys_description}.", + path.display(), + key + ), + None => bail!( + "Config file '{}' contains unknown key '{}'. Allowed keys: {allowed_keys_description}.", + path.display(), + key + ), + } + } + } + + Ok(()) +} + +pub(crate) fn validate_config_file(path: &Path) -> Result<()> { + let raw = std::fs::read_to_string(path) + .with_context(|| format!("Failed to read config file '{}'.", path.display()))?; + parse_file_config(&raw, path, ConfigPathSource::Flag)?; + Ok(()) +} + +pub(crate) fn deserialize_typed_config( + parsed: Value, + path: &Path, +) -> Result { + serde_json::from_value(parsed).with_context(|| { + format!( + "Config file '{}' could not be mapped into the typed runtime config model.", + path.display() + ) + }) +} + +#[allow(clippy::too_many_lines)] +pub(crate) fn parse_file_config( + raw: &str, + path: &Path, + source: ConfigPathSource, +) -> Result { + let parsed: Value = serde_json::from_str(raw) + .with_context(|| format!("Config file '{}' must contain valid JSON.", path.display()))?; + + let object = parsed.as_object().with_context(|| { + format!( + "Config file '{}' must contain a top-level JSON object.", + path.display() + ) + })?; + + validate_config_value_against_schema(&parsed, path)?; + validate_object_keys( + object, + path, + None, + TOP_LEVEL_CONFIG_KEYS, + TOP_LEVEL_CONFIG_KEYS_DESCRIPTION, + )?; + + let typed = deserialize_typed_config(parsed.clone(), path)?; + let log_level = typed + .log_level + .map(|raw| -> Result> { + Ok(FileConfigValue { + value: LogLevel::parse(&raw, &format!("config file '{}'", path.display()))?, + source, + }) + }) + .transpose()?; + let log_format = typed + .log_format + .map(|raw| -> Result> { + Ok(FileConfigValue { + value: LogFormat::parse(&raw, &format!("config file '{}'", path.display()))?, + source, + }) + }) + .transpose()?; + let log_file = typed + .log_file + .map(|value| FileConfigValue { value, source }); + let log_file_mode = typed + .log_file_mode + .map(|raw| -> Result> { + Ok(FileConfigValue { + value: LogFileMode::parse(&raw, &format!("config file '{}'", path.display()))?, + source, + }) + }) + .transpose()?; + let timeout_ms = typed + .timeout_ms + .map(|value| FileConfigValue { value, source }); + let workos_client_id = typed + .workos_client_id + .map(|value| FileConfigValue { value, source }); + let (attribution_hooks_enabled, bash_policy_presets, bash_policy_custom) = + map_policies_config(typed.policies.as_ref(), object, path, source)?; + + Ok(FileConfig { + log_level, + log_format, + log_file, + log_file_mode, + timeout_ms, + attribution_hooks_enabled, + workos_client_id, + bash_policy_presets, + bash_policy_custom, + }) +} + +pub(crate) fn map_policies_config( + typed: Option<&ParsedPoliciesConfigDocument>, + object: &serde_json::Map, + path: &Path, + source: ConfigPathSource, +) -> Result { + let Some(policies_value) = object.get("policies") else { + return Ok((None, None, None)); + }; + + let policies_object = policies_value.as_object().with_context(|| { + format!( + "Config key 'policies' in '{}' must be an object.", + path.display() + ) + })?; + + validate_object_keys( + policies_object, + path, + Some("policies"), + &["bash", "attribution_hooks"], + "bash, attribution_hooks", + )?; + + let bash = typed.and_then(|config| config.bash.as_ref()); + let attribution_hooks_enabled = map_attribution_hooks_config( + typed.and_then(|config| config.attribution_hooks.as_ref()), + policies_object, + path, + source, + )?; + let (bash_policy_presets, bash_policy_custom) = + map_bash_policy_config(bash, policies_object, path, source)?; + + Ok(( + attribution_hooks_enabled, + bash_policy_presets, + bash_policy_custom, + )) +} + +pub(crate) fn map_attribution_hooks_config( + typed: Option<&ParsedAttributionHooksConfigDocument>, + policies_object: &serde_json::Map, + path: &Path, + source: ConfigPathSource, +) -> Result>> { + let Some(attribution_hooks_value) = policies_object.get("attribution_hooks") else { + return Ok(None); + }; + + let attribution_hooks_object = attribution_hooks_value.as_object().with_context(|| { + format!( + "Config key 'policies.attribution_hooks' in '{}' must be an object.", + path.display() + ) + })?; + + validate_object_keys( + attribution_hooks_object, + path, + Some("policies.attribution_hooks"), + &["enabled"], + "enabled", + )?; + + Ok(typed + .and_then(|config| config.enabled) + .map(|value| FileConfigValue { value, source })) +} + +pub(crate) fn map_bash_policy_config( + typed: Option<&ParsedBashPolicyConfigDocument>, + policies_object: &serde_json::Map, + path: &Path, + source: ConfigPathSource, +) -> Result { + let Some(bash_value) = policies_object.get("bash") else { + return Ok((None, None)); + }; + + let bash_object = bash_value.as_object().with_context(|| { + format!( + "Config key 'policies.bash' in '{}' must be an object.", + path.display() + ) + })?; + + validate_object_keys( + bash_object, + path, + Some("policies.bash"), + &["presets", "custom"], + "presets, custom", + )?; + + let presets = typed + .and_then(|config| config.presets.as_ref()) + .map(|presets| parse_bash_policy_presets(presets, path)) + .transpose()? + .map(|value| FileConfigValue { value, source }); + let custom = typed + .and_then(|config| config.custom.as_ref()) + .map(|custom| parse_custom_bash_policies(custom, path)) + .transpose()? + .map(|value| FileConfigValue { value, source }); + + Ok((presets, custom)) +} diff --git a/context/glossary.md b/context/glossary.md index f28dfe3d..fe9ccf0f 100644 --- a/context/glossary.md +++ b/context/glossary.md @@ -75,7 +75,7 @@ - `CommandRegistry`: Statically populated command registry in `cli/src/services/command_registry.rs` that maps `&'static str` command names to zero-arg constructor functions (`fn() -> RuntimeCommandHandle`); populated at compile time via `build_default_registry()` and carried by `AppRuntime` during command dispatch. The `RuntimeCommand` trait and `RuntimeCommandHandle` type alias are co-located in the same module. Current registered constructors cover the full top-level command catalog: `help`, `auth`, `config`, `setup`, `doctor`, `hooks`, `version`, and `completion`; stateful parsed requests are constructed by `cli/src/services/parse/command_runtime.rs` when invocation options require per-command data. - `ServiceLifecycle`: Compile-safe lifecycle trait seam in `cli/src/services/lifecycle.rs` with default no-op `diagnose`, `fix`, and `setup` methods that accept `&AppContext`; it exposes lifecycle-owned health, fix, and setup result types, while doctor/setup adapt those records at orchestration boundaries before rendering command-owned output. The hooks service has `HooksLifecycle` for hook rollout diagnosis/fix/setup, the config service has `ConfigLifecycle` for global/repo-local config validation plus repo-local config bootstrap, local_db has `LocalDbLifecycle` for canonical local DB path health/bootstrap/setup, auth_db has `AuthDbLifecycle` for canonical auth DB path health/bootstrap/setup, and agent_trace_db has `AgentTraceDbLifecycle` for canonical Agent Trace DB path health/bootstrap/setup. Doctor runtime aggregates those providers for `diagnose` and `fix`; setup command now aggregates providers for `setup` in order (config → local_db → auth_db → agent_trace_db → hooks when requested). - `lifecycle provider catalog`: Shared factory in `cli/src/services/lifecycle.rs` (`lifecycle_providers(include_hooks)`) that returns boxed `ServiceLifecycle` providers in deterministic config → local_db → auth_db → agent_trace_db → hooks order, used by doctor with hooks included and by setup with hooks included only when requested. -- `sce config command surface`: Implemented top-level CLI command routed by `cli/src/app.rs` to `cli/src/services/config/mod.rs`, exposing `show`, `validate`, and `--help` for deterministic runtime config inspection and validation; `show` reports resolved flat logging observability values with provenance, while `validate` reports pass/fail plus validation issues and warnings only. +- `sce config command surface`: Implemented top-level CLI command routed by `cli/src/app.rs` to `cli/src/services/config/mod.rs` (with schema/file-parsing delegated to `cli/src/services/config/schema.rs`), exposing `show`, `validate`, and `--help` for deterministic runtime config inspection and validation; `show` reports resolved flat logging observability values with provenance, while `validate` reports pass/fail plus validation issues and warnings only. - `sce version command surface`: Implemented top-level CLI command routed by `cli/src/app.rs` to `cli/src/services/version/mod.rs` plus `cli/src/services/version/command.rs`, exposing deterministic runtime identification output in text form by default and JSON form via `--format json`. - `sce version output contract`: `cli/src/services/version/mod.rs` rendering contract where text output is a deterministic single-line ` ()` payload and JSON output includes stable fields `status`, `command`, `binary`, `version`, and `build_profile`. - `sce completion command surface`: Implemented top-level CLI command routed by `cli/src/app.rs` to `cli/src/services/completion/mod.rs` plus `cli/src/services/completion/command.rs`, requiring `--shell ` and returning a deterministic shell completion script on stdout. @@ -86,15 +86,16 @@ - `FsOps`: Filesystem capability trait in `cli/src/services/capabilities.rs` with `read_file`, `write_file`, `metadata`, and `exists`, implemented in production by `StdFsOps`. - `GitOps`: Git capability trait in `cli/src/services/capabilities.rs` with `run_command`, `resolve_repository_root`, `resolve_hooks_directory`, and `is_available`, implemented in production by `ProcessGitOps`. - `SCE default path policy seam`: Canonical path resolver in `cli/src/services/default_paths.rs` that owns config/state/cache root resolution through an internal `roots` helper seam, named default paths, and an explicit inventory for the current default persisted artifacts (`global config`, `auth tokens`); named DB paths include `auth DB`, `local DB`, and `Agent Trace DB`. On Linux those defaults resolve to `$XDG_CONFIG_HOME/sce/config.json`, `$XDG_STATE_HOME/sce/auth/tokens.json`, `$XDG_STATE_HOME/sce/auth.db`, `$XDG_STATE_HOME/sce/local.db`, and `$XDG_STATE_HOME/sce/agent-trace.db` with platform-equivalent `dirs` fallbacks elsewhere. The same module is also the canonical owner for broader production CLI path definitions and is protected by a regression test that fails when new non-test production path literals are introduced outside `default_paths.rs`. -- `cli config precedence contract`: Deterministic runtime value resolution in `cli/src/services/config/mod.rs` with precedence `flags > env > config file > defaults` for flag-backed keys (`log_level`, `timeout_ms`) plus shared app-runtime observability keys (`log_format`, `log_file`, `log_file_mode`) consumed by `cli/src/app.rs`; config discovery order is `--config`, `SCE_CONFIG_FILE`, then default discovered global+local paths (`${config_root}/sce/config.json` merged before `.sce/config.json`, with local overriding per key, where `config_root` comes from the shared default path policy seam and resolves to `$XDG_CONFIG_HOME` / `dirs::config_dir()` semantics with platform fallback behavior rather than the old state/data-root default). Runtime startup config loading now also permits the canonical top-level `"$schema": "https://sce.crocoder.dev/config.json"` declaration anywhere those config files are parsed. +- `cli config precedence contract`: Deterministic runtime value resolution in `cli/src/services/config/mod.rs` with precedence `flags > env > config file > defaults` for flag-backed keys (`log_level`, `timeout_ms`) plus shared app-runtime observability keys (`log_format`, `log_file`, `log_file_mode`) consumed by `cli/src/app.rs`; config discovery order is `--config`, `SCE_CONFIG_FILE`, then default discovered global+local paths (`${config_root}/sce/config.json` merged before `.sce/config.json`, with local overriding per key, where `config_root` comes from the shared default path policy seam and resolves to `$XDG_CONFIG_HOME` / `dirs::config_dir()` semantics with platform fallback behavior rather than the old state/data-root default). Runtime startup config loading now also permits the canonical top-level `"$schema": "https://sce.crocoder.dev/config.json"` declaration anywhere those config files are parsed (parsing delegated to `schema.rs`). - `shared runtime/config primitives seam`: Canonical ownership in `cli/src/services/config/types.rs` for the CLI's shared observability/config enums (`LogLevel`, `LogFormat`, `LogFileMode`), request/response primitives (`ConfigSubcommand`, `ConfigRequest`, `ReportFormat`), source metadata types (`ValueSource`, `ConfigPathSource`, `LoadedConfigPath`, `ResolvedValue`, `ResolvedOptionalValue`), resolved runtime config types (`ResolvedAuthRuntimeConfig`, `ResolvedObservabilityRuntimeConfig`, `ResolvedHookRuntimeConfig`), the `NAME` constant, observability env-key constants, and shared bool parsing helpers; re-exported through `cli/src/services/config/mod.rs` via `pub use types::*` so downstream modules continue importing through `services::config` unchanged. -- `sce config schema artifact`: Canonical JSON Schema for global and repo-local `sce/config.json` files, authored in `config/pkl/base/sce-config-schema.pkl`, generated to `config/schema/sce-config.schema.json`, and embedded by `cli/src/services/config/mod.rs` for shared `sce config validate` and doctor config validation. The current schema accepts the canonical `$schema` declaration, flat logging keys (`log_level`, `log_format`, `log_file`, `log_file_mode`), existing auth/config keys, and enforces the schema-level dependency that `log_file_mode` requires `log_file`. +- `config schema and file parsing seam`: Canonical ownership in `cli/src/services/config/schema.rs` for the CLI's JSON Schema embedding (`SCE_CONFIG_SCHEMA_JSON`), `OnceLock` validator (`CONFIG_SCHEMA_VALIDATOR`, `config_schema_validator()`), top-level allowed-key validation (`TOP_LEVEL_CONFIG_KEYS`, `validate_object_keys`), serde DTO definitions (`ParsedFileConfigDocument`, `ParsedPoliciesConfigDocument`, `ParsedBashPolicyConfigDocument`, `ParsedAttributionHooksConfigDocument`, `ParsedCustomBashPolicyEntryDocument`, `ParsedCustomBashPolicyMatchDocument`), file config value wrapper (`FileConfigValue`) and aggregate (`FileConfig`), type aliases (`ParsedBashPolicyConfig`, `ParsedFilePolicies`), and config-file load/parse helpers (`validate_config_file`, `parse_file_config`, `deserialize_typed_config`, `map_policies_config`, `map_attribution_hooks_config`, `map_bash_policy_config`); `validate_config_file` is re-exported `pub(crate)` through `mod.rs` for `lifecycle.rs` and `doctor` consumers. +- `sce config schema artifact`: Canonical JSON Schema for global and repo-local `sce/config.json` files, authored in `config/pkl/base/sce-config-schema.pkl`, generated to `config/schema/sce-config.schema.json`, and embedded by `cli/src/services/config/schema.rs` for shared `sce config validate` and doctor config validation. The current schema accepts the canonical `$schema` declaration, flat logging keys (`log_level`, `log_format`, `log_file`, `log_file_mode`), existing auth/config keys, and enforces the schema-level dependency that `log_file_mode` requires `log_file`. - `bash tool policy config surface`: Nested repo config namespace under `.sce/config.json` at `policies.bash`, currently supporting unique built-in `presets` plus repo-owned `custom` argv-prefix rules with deterministic validation, merged global/local resolution, and first-class `sce config show|validate` reporting. -- `attribution hooks gate`: Disabled-default local hook runtime gate resolved through shared config precedence in `cli/src/services/config/mod.rs`: env `SCE_ATTRIBUTION_HOOKS_ENABLED` overrides repo/global config key `policies.attribution_hooks.enabled`, and the current enabled path activates commit-msg-only attribution without re-enabling trace persistence. +- `attribution hooks gate`: Disabled-default local hook runtime gate resolved through shared config precedence in `cli/src/services/config/mod.rs` (with parsing in `schema.rs`): env `SCE_ATTRIBUTION_HOOKS_ENABLED` overrides repo/global config key `policies.attribution_hooks.enabled`, and the current enabled path activates commit-msg-only attribution without re-enabling trace persistence. - `bash policy preset catalog`: Canonical authored preset source at `config/pkl/base/bash-policy-presets.pkl`, rendered to JSON by `config/pkl/generate.pkl` and embedded by the CLI from `config/.opencode/lib/bash-policy-presets.json` so CLI validation and OpenCode enforcement share the same preset IDs, argv-prefix matchers, fixed messages, and conflict metadata. - `OpenCode bash policy plugin`: Generated OpenCode pre-execution hook at `config/.opencode/plugins/sce-bash-policy.ts` with runtime logic at `config/.opencode/lib/bash-policy-runtime.ts` (also emitted under `config/automated/.opencode/**`) that intercepts `bash` tool calls, applies the canonical argv normalization and longest-prefix/custom-over-preset precedence rules, merges `${config_root}/sce/config.json` from the shared default path policy seam with repo-local `.sce/config.json`, and throws a stable `Blocked by SCE bash-tool policy '': ` denial before subprocess launch. - `bash policy redundancy warning`: Non-fatal config validation output emitted when `forbid-git-all` and `forbid-git-commit` are enabled together; the config remains valid, but `sce config show|validate` reports the overlap deterministically as a warning instead of an error. -- `auth config baked default`: Optional key-declared fallback in `cli/src/services/config/mod.rs` used only after env and config-file inputs are absent; the first implemented case is `workos_client_id`, which currently falls back to `client_sce_default`. +- `auth config baked default`: Optional key-declared fallback in `cli/src/services/config/mod.rs` (with schema/parsing in `schema.rs`) used only after env and config-file inputs are absent; the first implemented case is `workos_client_id`, which currently falls back to `client_sce_default`. - `setup install engine`: Installer in `cli/src/services/setup/mod.rs` (`install_embedded_setup_assets`) that writes embedded setup assets into per-target staging directories and swaps them into repository-root `.opencode/`/`.claude/` destinations, using a unified remove-and-replace policy that removes existing targets before swapping staged content. - `setup remove-and-replace`: Replacement choreography in `cli/src/services/setup/mod.rs` where existing install targets are removed before staged content is promoted; on swap failure, the engine cleans temporary staging paths and returns deterministic recovery guidance (recover from version control). No backup artifacts are created. - `hooks command routing contract`: Current hook command parser/dispatcher plus runtime wiring in `cli/src/services/hooks/mod.rs` (`HookSubcommand`, `run_hooks_subcommand`) that supports `pre-commit`, `commit-msg `, `post-commit`, `post-rewrite `, and `diff-trace` with deterministic invocation validation/usage errors; `commit-msg` is the only active attribution path behind the attribution hooks gate, `pre-commit`/`post-rewrite` are deterministic no-op entrypoints, `post-commit` requires validated `--remote-url`, threads that value through the Agent Trace flow, prints it to stderr, captures the current commit patch, queries recent `diff_traces` from the past 7 days, combines valid patches via `patch::combine_patches`, intersects with the post-commit patch via `patch::intersect_patches`, persists the intersection result to `post_commit_patch_intersections`, and persists built Agent Trace payloads to AgentTraceDb `agent_traces` (DB-only, no post-commit Agent Trace file artifact), and `diff-trace` performs STDIN JSON intake with required non-empty `sessionID`/`diff`/`model_id`/`tool_name`, required `tool_version` (present and either `null` or non-empty string), required `u64` `time` validation, non-lossy AgentTraceDb `time_ms` conversion, collision-safe per-invocation artifact persistence at `context/tmp/-000000-diff-trace.json`, and AgentTraceDb insertion. diff --git a/context/overview.md b/context/overview.md index 95c2a94f..948c2a9d 100644 --- a/context/overview.md +++ b/context/overview.md @@ -20,7 +20,7 @@ The setup service also provides repository-root install orchestration: it resolv The CLI now also applies baseline security hardening for reliability-driven automation: diagnostics/logging paths use deterministic secret redaction, `sce setup --hooks --repo ` canonicalizes and validates repository paths before execution, and setup write flows run explicit directory write-permission probes before staging/swap operations. The config service now provides deterministic runtime config resolution with explicit precedence (`flags > env > config file > defaults`), strict config-file validation (`$schema`, `log_level`, `log_format`, `log_file`, `log_file_mode`, `timeout_ms`, `workos_client_id`, and nested `policies.bash`), deterministic default discovery/merge of global+local config files (`${config_root}/sce/config.json` then `.sce/config.json` with local override, where `config_root` comes from the shared default-path seam with XDG/`dirs::config_dir()` config-root resolution), defaults for the resolved observability value set (`log_level=error`, `log_format=text`, `log_file_mode=truncate`), shared auth-key resolution with optional baked defaults starting at `workos_client_id`, first-class bash-policy preset/custom parsing with deterministic conflict and duplicate-prefix validation, and a canonical Pkl-authored `sce/config.json` JSON Schema generated to `config/schema/sce-config.schema.json` and embedded by `cli/src/services/config/mod.rs` for both `sce config validate` and doctor-time config checks. Runtime startup config loading now keeps parity with that schema by accepting the canonical `"$schema": "https://sce.crocoder.dev/config.json"` declaration in repo-local and global config files, so startup commands such as `sce version` no longer fail before dispatch on that field. App-runtime observability now consumes flat logging keys through the shared resolver, so env values still override config-file values while config files provide deterministic fallback for file logging; `sce config show` reports resolved observability/auth/policy values with provenance, while `sce config validate` is now a trimmed validation surface that reports only pass/fail plus validation errors or warnings in text and JSON modes. The canonical preset catalog and matching contract live in `config/pkl/data/bash-policy-presets.json` and `context/sce/bash-tool-policy-enforcement-contract.md`. Invalid default-discovered config files now also degrade gracefully at startup: `sce` keeps running with degraded observability defaults, logs `sce.config.invalid_config` warnings, and reserves hard failures for explicit `--config` / `SCE_CONFIG_FILE` targets or other truly invalid runtime observability inputs. -`cli/src/services/config/mod.rs` is now a module facade that declares `pub mod types` and re-exports `pub use types::*`, delegating shared config primitive ownership to `cli/src/services/config/types.rs`: `LogLevel`, `LogFormat`, `LogFileMode`, `ConfigSubcommand`, `ConfigRequest`, `ReportFormat`, source metadata types (`ValueSource`, `ConfigPathSource`, `LoadedConfigPath`, `ResolvedValue`, `ResolvedOptionalValue`), resolved runtime config types (`ResolvedAuthRuntimeConfig`, `ResolvedObservabilityRuntimeConfig`, `ResolvedHookRuntimeConfig`), the `NAME` constant, observability env-key constants, and the shared `parse_bool_value_from` helper. Downstream modules continue importing through `services::config` unchanged. The CLI now has a minimal `AppContext` dependency-injection container in `cli/src/app.rs` holding `Arc`, `Arc`, `Arc`, `Arc`, and an optional `repo_root: Option`; it can derive repo-root-scoped contexts with `with_repo_root(...)` while preserving runtime dependencies. The broad capability seam lives in `cli/src/services/capabilities.rs`, where `FsOps`/`StdFsOps` wrap filesystem operations and `GitOps`/`ProcessGitOps` wrap git process execution plus repository-root/hooks-directory resolution. Current services have not migrated to consume the filesystem/git traits internally yet. +`cli/src/services/config/mod.rs` is now a module facade that declares `pub mod types`, `pub mod schema`, and `pub mod command`/`pub mod lifecycle`, re-exporting `pub use types::*` and `pub(crate) use schema::validate_config_file`. Shared config primitive ownership is delegated to `cli/src/services/config/types.rs` (`LogLevel`, `LogFormat`, `LogFileMode`, `ConfigSubcommand`, `ConfigRequest`, `ReportFormat`, source metadata types, resolved runtime config types, the `NAME` constant, observability env-key constants, and `parse_bool_value_from`). Config schema loading and file parsing ownership is delegated to `cli/src/services/config/schema.rs` (JSON Schema embedding/validator, top-level allowed-key validation, serde DTO definitions, `FileConfig`/`FileConfigValue`, type aliases, `validate_config_file`, `parse_file_config`, `deserialize_typed_config`, `map_policies_config`, `map_attribution_hooks_config`, `map_bash_policy_config`). Downstream modules continue importing through `services::config` unchanged. The CLI now has a minimal `AppContext` dependency-injection container in `cli/src/app.rs` holding `Arc`, `Arc`, `Arc`, `Arc`, and an optional `repo_root: Option`; it can derive repo-root-scoped contexts with `with_repo_root(...)` while preserving runtime dependencies. The broad capability seam lives in `cli/src/services/capabilities.rs`, where `FsOps`/`StdFsOps` wrap filesystem operations and `GitOps`/`ProcessGitOps` wrap git process execution plus repository-root/hooks-directory resolution. Current services have not migrated to consume the filesystem/git traits internally yet. The shared default path service in `cli/src/services/default_paths.rs` is now the canonical owner for production CLI path definitions. It resolves per-user config/state/cache roots through a dedicated internal `roots` seam, exposes the current persisted-artifact inventory (global config and auth tokens), and also defines named DB paths (auth DB, local DB, Agent Trace DB) plus the repo-relative, embedded-asset, install/runtime, hook, and context-path accessors consumed across current CLI production code. Non-test production modules should consume this shared catalog instead of hardcoding owned path literals. No default cache-backed persisted artifact currently exists, so cache-root resolution remains available without speculative cache-path features and no legacy default-path fallback is supported. The same config resolver now also owns the attribution-hooks gate used by local hook runtime: `SCE_ATTRIBUTION_HOOKS_ENABLED` overrides `policies.attribution_hooks.enabled`, and the gate defaults to disabled. Generated config now includes repo-local OpenCode plugin assets for both profiles: `sce-bash-policy.ts` plus `sce-agent-trace.ts` are emitted under `config/.opencode/plugins/` and `config/automated/.opencode/plugins/`; the agent-trace plugin extracts `{ sessionID, diff, time, model_id }` from user `message.updated` events with diffs, tracks per-session OpenCode client version from `session.created`/`session.updated`, and sends payloads to `sce hooks diff-trace` with `tool_name="opencode"` plus optional `tool_version`; the Rust hook continues to validate required fields and persists `model_id`, `tool_name`, and nullable `tool_version` into `diff_traces` through AgentTraceDb. Bash-policy also emits shared runtime logic and preset data under `config/.opencode/lib/` (also emitted for `config/automated/.opencode/**`). Claude bash-policy enforcement has been removed from generated outputs. diff --git a/context/plans/cli-maintenance-hazards.md b/context/plans/cli-maintenance-hazards.md index fb931fd8..2f643137 100644 --- a/context/plans/cli-maintenance-hazards.md +++ b/context/plans/cli-maintenance-hazards.md @@ -62,12 +62,16 @@ Resolve the architectural/maintenance hazards in the Rust CLI without changing u - Evidence: `nix flake check` passed (cli-tests, cli-clippy, cli-fmt, pkl-parity all green); `nix run .#pkl-check-generated` passed. No public API changes required outside `config/` module; all existing callers continue importing through `services::config::*`. - Notes: Created `cli/src/services/config/types.rs` containing `LogLevel`, `LogFormat`, `LogFileMode`, `ReportFormat`, `ConfigSubcommand`, `ConfigRequest`, `ValueSource`, `ConfigPathSource`, `LoadedConfigPath`, `ResolvedValue`, `ResolvedOptionalValue`, `ResolvedAuthRuntimeConfig`, `ResolvedObservabilityRuntimeConfig`, `ResolvedHookRuntimeConfig`, `NAME`, env key constants (`ENV_LOG_LEVEL`, `ENV_LOG_FORMAT`, `ENV_LOG_FILE`, `ENV_LOG_FILE_MODE`, `ENV_ATTRIBUTION_HOOKS_ENABLED`), and `parse_bool_value_from`. `mod.rs` now declares `pub mod types;` and `pub use types::*;` as facade re-exports. Resolution logic, schema validation, policy validation, and renderers remain in `mod.rs`. -- [ ] T04: `Extract config schema loading and file parsing concerns` (status:todo) +- [x] T04: `Extract config schema loading and file parsing concerns` (status:done) - Task ID: T04 - Goal: Move JSON schema embedding/validator setup, top-level allowed-key validation, serde DTO definitions, and config-file load/parse helpers out of `mod.rs` into a focused schema/loading submodule. - Boundaries (in/out of scope): In - schema constants, `OnceLock` validator ownership, JSON top-level validation, file parse/deserialization helpers, tests directly tied to schema/file parsing. Out - precedence resolution, rendering, policy-specific semantic validation unless already isolated as DTO parsing. - Done when: schema and config-file parsing have one focused owner; explicit vs default-discovered invalid-file behavior remains unchanged; `sce config validate` still reports the same issues/warnings for equivalent inputs. - Verification notes (commands or checks): Prefer `nix flake check`; include targeted config validation tests if available/needed. + - Completed: 2026-06-07 + - Files changed: `cli/src/services/config/mod.rs`, `cli/src/services/config/schema.rs` + - Evidence: `nix flake check` passed (cli-tests, cli-clippy, cli-fmt, pkl-parity all green); `nix run .#pkl-check-generated` passed. No public API changes required outside `config/` module; `validate_config_file` re-exported through `mod.rs` for `lifecycle.rs` and `doctor` consumers. + - Notes: Created `cli/src/services/config/schema.rs` containing schema constants (`SCE_CONFIG_SCHEMA_JSON`, `CONFIG_SCHEMA_DECLARATION_KEY`, `TOP_LEVEL_CONFIG_KEYS`, `TOP_LEVEL_CONFIG_KEYS_DESCRIPTION`), `OnceLock` validator (`CONFIG_SCHEMA_VALIDATOR`, `config_schema_validator()`), JSON validation functions (`validate_config_value_against_schema`, `validate_object_keys`), serde DTOs (`ParsedFileConfigDocument`, `ParsedPoliciesConfigDocument`, `ParsedBashPolicyConfigDocument`, `ParsedAttributionHooksConfigDocument`, `ParsedCustomBashPolicyEntryDocument`, `ParsedCustomBashPolicyMatchDocument`), `FileConfigValue`, `FileConfig`, type aliases (`ParsedBashPolicyConfig`, `ParsedFilePolicies`), and file parse/deserialization helpers (`validate_config_file`, `deserialize_typed_config`, `parse_file_config`, `map_policies_config`, `map_attribution_hooks_config`, `map_bash_policy_config`). `mod.rs` now declares `pub mod schema` and re-exports `validate_config_file` as `pub(crate)`. Bash-policy catalog/preset/validation functions (`builtin_bash_policy_catalog`, `parse_bash_policy_presets`, `parse_custom_bash_policies`, etc.) remain in `mod.rs` for T05 extraction. `WORKOS_CLIENT_ID_KEY` and `AuthConfigKeySpec` fields made `pub(crate)` for `schema.rs` access via `super::`. - [ ] T05: `Extract config policy semantic validation` (status:todo) - Task ID: T05 From 12b9054c4440e5aea7b61f7214a7e17cc06c6940 Mon Sep 17 00:00:00 2001 From: David Abram Date: Sun, 7 Jun 2026 16:59:24 +0200 Subject: [PATCH 5/7] cli/config: Extract bash-policy validation into policy submodule Move built-in catalog handling, preset/custom policy parsing, conflict/duplicate/redundancy validation, and policy rendering from cli/src/services/config/mod.rs into a new focused cli/src/services/config/policy.rs submodule. schema.rs now imports policy helpers from super::policy instead of the parent module. mod.rs declares pub mod policy and re-exports items needed by resolution and rendering consumers. Co-authored-by: SCE --- cli/src/services/config/mod.rs | 382 +--------------------- cli/src/services/config/policy.rs | 391 +++++++++++++++++++++++ cli/src/services/config/schema.rs | 2 +- context/glossary.md | 3 +- context/overview.md | 2 +- context/plans/cli-maintenance-hazards.md | 6 +- 6 files changed, 408 insertions(+), 378 deletions(-) create mode 100644 cli/src/services/config/policy.rs diff --git a/cli/src/services/config/mod.rs b/cli/src/services/config/mod.rs index e3c9ad00..1d6976f4 100644 --- a/cli/src/services/config/mod.rs +++ b/cli/src/services/config/mod.rs @@ -1,20 +1,23 @@ pub mod command; pub mod lifecycle; +pub mod policy; pub mod schema; pub mod types; pub use types::*; -use std::{ - path::{Path, PathBuf}, - sync::OnceLock, -}; +use std::path::{Path, PathBuf}; use anyhow::{anyhow, bail, Context, Result}; use serde_json::{json, Value}; use crate::services::default_paths::{resolve_sce_default_locations, RepoPaths}; -use crate::services::style::{self}; +use crate::services::style; + +use policy::{ + build_validation_warnings, format_bash_policies_json, format_bash_policies_text, + resolve_bash_policy_config, BashPolicyConfig, +}; pub(crate) use schema::validate_config_file; @@ -65,98 +68,6 @@ struct RuntimeConfig { validation_warnings: Vec, } -static BUILTIN_BASH_POLICY_CATALOG: OnceLock = OnceLock::new(); - -const BASH_POLICY_PRESET_CATALOG_JSON: &str = - include_str!("../../../assets/generated/config/opencode/lib/bash-policy-presets.json"); - -#[derive(Clone, Debug, Eq, PartialEq)] -struct BashPolicyConfig { - presets: Vec, - custom: Vec, -} - -#[derive(Debug, serde::Deserialize)] -struct BuiltinBashPolicyCatalog { - presets: Vec, - mutually_exclusive: Vec>, - redundancy_warnings: Vec, -} - -#[derive(Debug, serde::Deserialize)] -struct BuiltinBashPolicyPreset { - id: String, - #[serde(rename = "match")] - matcher: BuiltinBashPolicyMatcher, - message: String, -} - -#[derive(Debug, serde::Deserialize)] -struct BuiltinBashPolicyMatcher { - argv_prefixes: Vec>, -} - -#[derive(Debug, serde::Deserialize)] -struct BuiltinBashPolicyRedundancyWarning { - if_enabled: Vec, - warning: String, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct CustomBashPolicyEntry { - pub(crate) id: String, - pub(crate) argv_prefix: Vec, - pub(crate) message: String, -} - -impl CustomBashPolicyEntry { - fn json_value(&self) -> Value { - json!({ - "id": self.id, - "match": { - "argv_prefix": self.argv_prefix, - }, - "message": self.message, - }) - } - - fn text_summary(&self) -> String { - format!( - "{} => [{}] :: {}", - self.id, - self.argv_prefix.join(" "), - self.message - ) - } -} - -fn builtin_bash_policy_catalog() -> &'static BuiltinBashPolicyCatalog { - BUILTIN_BASH_POLICY_CATALOG.get_or_init(|| { - let catalog: BuiltinBashPolicyCatalog = - serde_json::from_str(BASH_POLICY_PRESET_CATALOG_JSON) - .expect("bash policy preset catalog JSON must remain valid"); - debug_assert!(catalog.presets.iter().all(|preset| !preset.id.is_empty() - && !preset.message.is_empty() - && !preset.matcher.argv_prefixes.is_empty())); - catalog - }) -} - -fn builtin_bash_policy_preset_ids() -> Vec<&'static str> { - builtin_bash_policy_catalog() - .presets - .iter() - .map(|preset| preset.id.as_str()) - .collect() -} - -pub(crate) fn is_builtin_bash_policy_preset_id(id: &str) -> bool { - builtin_bash_policy_catalog() - .presets - .iter() - .any(|preset| preset.id == id) -} - pub fn run_config_subcommand(subcommand: ConfigSubcommand) -> Result { match subcommand { ConfigSubcommand::Show(request) => { @@ -551,52 +462,6 @@ where }) } -fn resolve_bash_policy_config( - presets: Option<&schema::FileConfigValue>>, - custom: Option<&schema::FileConfigValue>>, -) -> ResolvedOptionalValue { - let resolved_presets = presets.map(|value| value.value.clone()); - let resolved_custom = custom.map(|value| value.value.clone()); - let source = custom - .map(|value| value.source) - .or_else(|| presets.map(|value| value.source)); - - if resolved_presets.as_ref().is_none_or(Vec::is_empty) - && resolved_custom.as_ref().is_none_or(Vec::is_empty) - { - return ResolvedOptionalValue { - value: None, - source: None, - }; - } - - ResolvedOptionalValue { - value: Some(BashPolicyConfig { - presets: resolved_presets.unwrap_or_default(), - custom: resolved_custom.unwrap_or_default(), - }), - source: source.map(ValueSource::ConfigFile), - } -} - -fn build_validation_warnings(value: &ResolvedOptionalValue) -> Vec { - let Some(config) = value.value.as_ref() else { - return Vec::new(); - }; - - builtin_bash_policy_catalog() - .redundancy_warnings - .iter() - .filter(|warning| { - warning - .if_enabled - .iter() - .all(|preset| config.presets.iter().any(|enabled| enabled == preset)) - }) - .map(|warning| warning.warning.clone()) - .collect() -} - fn resolve_optional_auth_config_value( key: AuthConfigKeySpec, file_value: Option>, @@ -695,182 +560,6 @@ fn resolve_default_global_config_path() -> Result { Ok(resolve_sce_default_locations()?.global_config_file()) } -pub(crate) fn parse_bash_policy_presets(items: &[String], path: &Path) -> Result> { - let mut presets = Vec::with_capacity(items.len()); - let builtin_preset_ids = builtin_bash_policy_preset_ids(); - for item in items { - let preset = item.as_str(); - if !builtin_preset_ids.contains(&preset) { - bail!( - "Config key 'policies.bash.presets' in '{}' contains unknown preset '{}'. Allowed presets: {}.", - path.display(), - preset, - builtin_preset_ids.join(", ") - ); - } - if presets.iter().any(|existing| existing == preset) { - bail!( - "Config key 'policies.bash.presets' in '{}' contains duplicate preset '{}'.", - path.display(), - preset - ); - } - presets.push(preset.to_string()); - } - - for conflict_group in &builtin_bash_policy_catalog().mutually_exclusive { - if conflict_group - .iter() - .all(|preset| presets.iter().any(|enabled| enabled == preset)) - { - let joined = conflict_group - .iter() - .map(|preset| format!("'{preset}'")) - .collect::>() - .join(" and "); - bail!( - "Config key 'policies.bash.presets' in '{}' cannot enable both {}.", - path.display(), - joined - ); - } - } - - Ok(presets) -} - -pub(crate) fn parse_custom_bash_policies( - items: &[schema::ParsedCustomBashPolicyEntryDocument], - path: &Path, -) -> Result> { - let mut policies = Vec::with_capacity(items.len()); - let mut argv_prefixes: Vec> = Vec::new(); - for item in items { - let policy = parse_custom_bash_policy_entry(item, path)?; - if policies - .iter() - .any(|existing: &CustomBashPolicyEntry| existing.id == policy.id) - { - bail!( - "Config key 'policies.bash.custom' in '{}' contains duplicate id '{}'.", - path.display(), - policy.id - ); - } - - if argv_prefixes - .iter() - .any(|existing| existing == &policy.argv_prefix) - { - bail!( - "Config key 'policies.bash.custom' in '{}' contains duplicate argv_prefix [{}].", - path.display(), - policy.argv_prefix.join(" ") - ); - } - argv_prefixes.push(policy.argv_prefix.clone()); - policies.push(policy); - } - - Ok(policies) -} - -fn parse_custom_bash_policy_entry( - item: &schema::ParsedCustomBashPolicyEntryDocument, - path: &Path, -) -> Result { - let id = item - .id - .as_deref() - .with_context(|| { - format!( - "Each 'policies.bash.custom' entry in '{}' must include string field 'id'.", - path.display() - ) - })? - .to_string(); - if is_builtin_bash_policy_preset_id(&id) { - bail!( - "Custom bash policy id '{}' in '{}' collides with a built-in preset id.", - id, - path.display() - ); - } - - let message = item.message.as_deref().with_context(|| { - format!( - "Custom bash policy '{}' in '{}' must include string field 'message'.", - id, - path.display() - ) - })?; - if message.is_empty() { - bail!( - "Custom bash policy '{}' in '{}' must use a non-empty 'message'.", - id, - path.display() - ); - } - - let argv_prefix = parse_custom_bash_policy_match(&id, item.matcher.as_ref(), path)?; - - Ok(CustomBashPolicyEntry { - id, - argv_prefix, - message: message.to_string(), - }) -} - -fn parse_custom_bash_policy_match( - id: &str, - matcher: Option<&schema::ParsedCustomBashPolicyMatchDocument>, - path: &Path, -) -> Result> { - let matcher = matcher.with_context(|| { - format!( - "Custom bash policy '{}' in '{}' must include object field 'match'.", - id, - path.display() - ) - })?; - let argv_prefix_values = matcher.argv_prefix.as_deref().with_context(|| { - format!( - "Custom bash policy '{}' in '{}' must include array field 'match.argv_prefix'.", - id, - path.display() - ) - })?; - if argv_prefix_values.is_empty() { - bail!( - "Custom bash policy '{}' in '{}' must use a non-empty 'match.argv_prefix'.", - id, - path.display() - ); - } - - parse_custom_bash_policy_argv_prefix(id, argv_prefix_values, path) -} - -fn parse_custom_bash_policy_argv_prefix( - id: &str, - argv_prefix_values: &[String], - path: &Path, -) -> Result> { - let mut argv_prefix = Vec::with_capacity(argv_prefix_values.len()); - for token in argv_prefix_values { - if token.is_empty() { - bail!( - "Custom bash policy '{}' in '{}' cannot use empty argv_prefix tokens.", - id, - path.display() - ); - } - argv_prefix.push(token.clone()); - } - - Ok(argv_prefix) -} - fn format_show_output(runtime: &RuntimeConfig, report_format: ReportFormat) -> String { let warnings = build_show_warnings(runtime); match report_format { @@ -1007,61 +696,6 @@ fn format_config_paths_json(runtime: &RuntimeConfig) -> Value { ) } -fn format_bash_policies_text(value: &ResolvedOptionalValue) -> String { - match (value.value.as_ref(), value.source) { - (Some(config), Some(source)) => { - let presets = if config.presets.is_empty() { - String::from("(none)") - } else { - config.presets.join(", ") - }; - let custom = if config.custom.is_empty() { - String::from("(none)") - } else { - config - .custom - .iter() - .map(CustomBashPolicyEntry::text_summary) - .collect::>() - .join(" | ") - }; - match source.config_source() { - Some(config_source) => format!( - "- {}: presets=[{}]; custom=[{}] (source: {}, config_source: {})", - style::label("policies.bash"), - style::value(&presets), - style::value(&custom), - style::label(source.as_str()), - style::label(config_source.as_str()) - ), - None => format!( - "- {}: presets=[{}]; custom=[{}] (source: {})", - style::label("policies.bash"), - style::value(&presets), - style::value(&custom), - style::label(source.as_str()) - ), - } - } - _ => format!( - "- {}: {} (source: {})", - style::label("policies.bash"), - style::value("(unset)"), - style::label("none") - ), - } -} - -fn format_bash_policies_json(value: &ResolvedOptionalValue) -> Value { - let config = value.value.as_ref(); - json!({ - "presets": config.map(|bash| bash.presets.clone()), - "custom": config.map(|bash| bash.custom.iter().map(CustomBashPolicyEntry::json_value).collect::>()), - "source": value.source.map(ValueSource::as_str), - "config_source": value.source.and_then(ValueSource::config_source).map(ConfigPathSource::as_str), - }) -} - fn build_show_warnings(runtime: &RuntimeConfig) -> Vec { let mut warnings = runtime .validation_errors diff --git a/cli/src/services/config/policy.rs b/cli/src/services/config/policy.rs new file mode 100644 index 00000000..4085a96e --- /dev/null +++ b/cli/src/services/config/policy.rs @@ -0,0 +1,391 @@ +//! Bash-policy and attribution-hooks semantic validation, merge helpers, +//! and policy rendering. +//! +//! This submodule owns built-in/custom bash-policy validation, duplicate/conflict/ +//! redundancy checks, attribution-hooks config parsing helpers, policy resolved-data +//! structs, and policy-specific rendering. The parent `mod.rs` re-exports items +//! needed by resolution and rendering consumers. + +use std::path::Path; +use std::sync::OnceLock; + +use anyhow::{bail, Context, Result}; +use serde::Deserialize; +use serde_json::{json, Value}; + +use super::schema::{ + FileConfigValue, ParsedCustomBashPolicyEntryDocument, ParsedCustomBashPolicyMatchDocument, +}; +use super::types::{ConfigPathSource, ResolvedOptionalValue, ValueSource}; +use crate::services::style; + +const BASH_POLICY_PRESET_CATALOG_JSON: &str = + include_str!("../../../assets/generated/config/opencode/lib/bash-policy-presets.json"); + +static BUILTIN_BASH_POLICY_CATALOG: OnceLock = OnceLock::new(); + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(super) struct BashPolicyConfig { + pub(super) presets: Vec, + pub(super) custom: Vec, +} + +#[derive(Debug, Deserialize)] +struct BuiltinBashPolicyCatalog { + presets: Vec, + mutually_exclusive: Vec>, + redundancy_warnings: Vec, +} + +#[derive(Debug, Deserialize)] +struct BuiltinBashPolicyPreset { + id: String, + #[serde(rename = "match")] + matcher: BuiltinBashPolicyMatcher, + message: String, +} + +#[derive(Debug, Deserialize)] +struct BuiltinBashPolicyMatcher { + argv_prefixes: Vec>, +} + +#[derive(Debug, Deserialize)] +struct BuiltinBashPolicyRedundancyWarning { + if_enabled: Vec, + warning: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct CustomBashPolicyEntry { + pub(crate) id: String, + pub(crate) argv_prefix: Vec, + pub(crate) message: String, +} + +impl CustomBashPolicyEntry { + fn json_value(&self) -> Value { + json!({ + "id": self.id, + "match": { + "argv_prefix": self.argv_prefix, + }, + "message": self.message, + }) + } + + fn text_summary(&self) -> String { + format!( + "{} => [{}] :: {}", + self.id, + self.argv_prefix.join(" "), + self.message + ) + } +} + +fn builtin_bash_policy_catalog() -> &'static BuiltinBashPolicyCatalog { + BUILTIN_BASH_POLICY_CATALOG.get_or_init(|| { + let catalog: BuiltinBashPolicyCatalog = + serde_json::from_str(BASH_POLICY_PRESET_CATALOG_JSON) + .expect("bash policy preset catalog JSON must remain valid"); + debug_assert!(catalog.presets.iter().all(|preset| !preset.id.is_empty() + && !preset.message.is_empty() + && !preset.matcher.argv_prefixes.is_empty())); + catalog + }) +} + +fn builtin_bash_policy_preset_ids() -> Vec<&'static str> { + builtin_bash_policy_catalog() + .presets + .iter() + .map(|preset| preset.id.as_str()) + .collect() +} + +pub(crate) fn is_builtin_bash_policy_preset_id(id: &str) -> bool { + builtin_bash_policy_catalog() + .presets + .iter() + .any(|preset| preset.id == id) +} + +pub(super) fn resolve_bash_policy_config( + presets: Option<&FileConfigValue>>, + custom: Option<&FileConfigValue>>, +) -> ResolvedOptionalValue { + let resolved_presets = presets.map(|value| value.value.clone()); + let resolved_custom = custom.map(|value| value.value.clone()); + let source = custom + .map(|value| value.source) + .or_else(|| presets.map(|value| value.source)); + + if resolved_presets.as_ref().is_none_or(Vec::is_empty) + && resolved_custom.as_ref().is_none_or(Vec::is_empty) + { + return ResolvedOptionalValue { + value: None, + source: None, + }; + } + + ResolvedOptionalValue { + value: Some(BashPolicyConfig { + presets: resolved_presets.unwrap_or_default(), + custom: resolved_custom.unwrap_or_default(), + }), + source: source.map(ValueSource::ConfigFile), + } +} + +pub(super) fn build_validation_warnings( + value: &ResolvedOptionalValue, +) -> Vec { + let Some(config) = value.value.as_ref() else { + return Vec::new(); + }; + + builtin_bash_policy_catalog() + .redundancy_warnings + .iter() + .filter(|warning| { + warning + .if_enabled + .iter() + .all(|preset| config.presets.iter().any(|enabled| enabled == preset)) + }) + .map(|warning| warning.warning.clone()) + .collect() +} + +pub(crate) fn parse_bash_policy_presets(items: &[String], path: &Path) -> Result> { + let mut presets = Vec::with_capacity(items.len()); + let builtin_preset_ids = builtin_bash_policy_preset_ids(); + for item in items { + let preset = item.as_str(); + if !builtin_preset_ids.contains(&preset) { + bail!( + "Config key 'policies.bash.presets' in '{}' contains unknown preset '{}'. Allowed presets: {}.", + path.display(), + preset, + builtin_preset_ids.join(", ") + ); + } + if presets.iter().any(|existing| existing == preset) { + bail!( + "Config key 'policies.bash.presets' in '{}' contains duplicate preset '{}'.", + path.display(), + preset + ); + } + presets.push(preset.to_string()); + } + + for conflict_group in &builtin_bash_policy_catalog().mutually_exclusive { + if conflict_group + .iter() + .all(|preset| presets.iter().any(|enabled| enabled == preset)) + { + let joined = conflict_group + .iter() + .map(|preset| format!("'{preset}'")) + .collect::>() + .join(" and "); + bail!( + "Config key 'policies.bash.presets' in '{}' cannot enable both {}.", + path.display(), + joined + ); + } + } + + Ok(presets) +} + +pub(crate) fn parse_custom_bash_policies( + items: &[ParsedCustomBashPolicyEntryDocument], + path: &Path, +) -> Result> { + let mut policies = Vec::with_capacity(items.len()); + let mut argv_prefixes: Vec> = Vec::new(); + for item in items { + let policy = parse_custom_bash_policy_entry(item, path)?; + if policies + .iter() + .any(|existing: &CustomBashPolicyEntry| existing.id == policy.id) + { + bail!( + "Config key 'policies.bash.custom' in '{}' contains duplicate id '{}'.", + path.display(), + policy.id + ); + } + + if argv_prefixes + .iter() + .any(|existing| existing == &policy.argv_prefix) + { + bail!( + "Config key 'policies.bash.custom' in '{}' contains duplicate argv_prefix [{}].", + path.display(), + policy.argv_prefix.join(" ") + ); + } + argv_prefixes.push(policy.argv_prefix.clone()); + policies.push(policy); + } + + Ok(policies) +} + +fn parse_custom_bash_policy_entry( + item: &ParsedCustomBashPolicyEntryDocument, + path: &Path, +) -> Result { + let id = item + .id + .as_deref() + .with_context(|| { + format!( + "Each 'policies.bash.custom' entry in '{}' must include string field 'id'.", + path.display() + ) + })? + .to_string(); + if is_builtin_bash_policy_preset_id(&id) { + bail!( + "Custom bash policy id '{}' in '{}' collides with a built-in preset id.", + id, + path.display() + ); + } + + let message = item.message.as_deref().with_context(|| { + format!( + "Custom bash policy '{}' in '{}' must include string field 'message'.", + id, + path.display() + ) + })?; + if message.is_empty() { + bail!( + "Custom bash policy '{}' in '{}' must use a non-empty 'message'.", + id, + path.display() + ); + } + + let argv_prefix = parse_custom_bash_policy_match(&id, item.matcher.as_ref(), path)?; + + Ok(CustomBashPolicyEntry { + id, + argv_prefix, + message: message.to_string(), + }) +} + +fn parse_custom_bash_policy_match( + id: &str, + matcher: Option<&ParsedCustomBashPolicyMatchDocument>, + path: &Path, +) -> Result> { + let matcher = matcher.with_context(|| { + format!( + "Custom bash policy '{}' in '{}' must include object field 'match'.", + id, + path.display() + ) + })?; + let argv_prefix_values = matcher.argv_prefix.as_deref().with_context(|| { + format!( + "Custom bash policy '{}' in '{}' must include array field 'match.argv_prefix'.", + id, + path.display() + ) + })?; + if argv_prefix_values.is_empty() { + bail!( + "Custom bash policy '{}' in '{}' must use a non-empty 'match.argv_prefix'.", + id, + path.display() + ); + } + + parse_custom_bash_policy_argv_prefix(id, argv_prefix_values, path) +} + +fn parse_custom_bash_policy_argv_prefix( + id: &str, + argv_prefix_values: &[String], + path: &Path, +) -> Result> { + let mut argv_prefix = Vec::with_capacity(argv_prefix_values.len()); + for token in argv_prefix_values { + if token.is_empty() { + bail!( + "Custom bash policy '{}' in '{}' cannot use empty argv_prefix tokens.", + id, + path.display() + ); + } + argv_prefix.push(token.clone()); + } + + Ok(argv_prefix) +} + +pub(super) fn format_bash_policies_text(value: &ResolvedOptionalValue) -> String { + match (value.value.as_ref(), value.source) { + (Some(config), Some(source)) => { + let presets = if config.presets.is_empty() { + String::from("(none)") + } else { + config.presets.join(", ") + }; + let custom = if config.custom.is_empty() { + String::from("(none)") + } else { + config + .custom + .iter() + .map(CustomBashPolicyEntry::text_summary) + .collect::>() + .join(" | ") + }; + match source.config_source() { + Some(config_source) => format!( + "- {}: presets=[{}]; custom=[{}] (source: {}, config_source: {})", + style::label("policies.bash"), + style::value(&presets), + style::value(&custom), + style::label(source.as_str()), + style::label(config_source.as_str()) + ), + None => format!( + "- {}: presets=[{}]; custom=[{}] (source: {})", + style::label("policies.bash"), + style::value(&presets), + style::value(&custom), + style::label(source.as_str()) + ), + } + } + _ => format!( + "- {}: {} (source: {})", + style::label("policies.bash"), + style::value("(unset)"), + style::label("none") + ), + } +} + +pub(super) fn format_bash_policies_json(value: &ResolvedOptionalValue) -> Value { + let config = value.value.as_ref(); + json!({ + "presets": config.map(|bash| bash.presets.clone()), + "custom": config.map(|bash| bash.custom.iter().map(CustomBashPolicyEntry::json_value).collect::>()), + "source": value.source.map(ValueSource::as_str), + "config_source": value.source.and_then(ValueSource::config_source).map(ConfigPathSource::as_str), + }) +} diff --git a/cli/src/services/config/schema.rs b/cli/src/services/config/schema.rs index e507a2bf..0adfa011 100644 --- a/cli/src/services/config/schema.rs +++ b/cli/src/services/config/schema.rs @@ -16,8 +16,8 @@ use jsonschema::{validator_for, Validator}; use serde::Deserialize; use serde_json::Value; +use super::policy::{parse_bash_policy_presets, parse_custom_bash_policies, CustomBashPolicyEntry}; use super::types::{ConfigPathSource, LogFileMode, LogFormat, LogLevel}; -use super::{parse_bash_policy_presets, parse_custom_bash_policies, CustomBashPolicyEntry}; #[cfg_attr(not(test), allow(dead_code))] pub(crate) const SCE_CONFIG_SCHEMA_JSON: &str = diff --git a/context/glossary.md b/context/glossary.md index fe9ccf0f..363fdc7f 100644 --- a/context/glossary.md +++ b/context/glossary.md @@ -88,7 +88,8 @@ - `SCE default path policy seam`: Canonical path resolver in `cli/src/services/default_paths.rs` that owns config/state/cache root resolution through an internal `roots` helper seam, named default paths, and an explicit inventory for the current default persisted artifacts (`global config`, `auth tokens`); named DB paths include `auth DB`, `local DB`, and `Agent Trace DB`. On Linux those defaults resolve to `$XDG_CONFIG_HOME/sce/config.json`, `$XDG_STATE_HOME/sce/auth/tokens.json`, `$XDG_STATE_HOME/sce/auth.db`, `$XDG_STATE_HOME/sce/local.db`, and `$XDG_STATE_HOME/sce/agent-trace.db` with platform-equivalent `dirs` fallbacks elsewhere. The same module is also the canonical owner for broader production CLI path definitions and is protected by a regression test that fails when new non-test production path literals are introduced outside `default_paths.rs`. - `cli config precedence contract`: Deterministic runtime value resolution in `cli/src/services/config/mod.rs` with precedence `flags > env > config file > defaults` for flag-backed keys (`log_level`, `timeout_ms`) plus shared app-runtime observability keys (`log_format`, `log_file`, `log_file_mode`) consumed by `cli/src/app.rs`; config discovery order is `--config`, `SCE_CONFIG_FILE`, then default discovered global+local paths (`${config_root}/sce/config.json` merged before `.sce/config.json`, with local overriding per key, where `config_root` comes from the shared default path policy seam and resolves to `$XDG_CONFIG_HOME` / `dirs::config_dir()` semantics with platform fallback behavior rather than the old state/data-root default). Runtime startup config loading now also permits the canonical top-level `"$schema": "https://sce.crocoder.dev/config.json"` declaration anywhere those config files are parsed (parsing delegated to `schema.rs`). - `shared runtime/config primitives seam`: Canonical ownership in `cli/src/services/config/types.rs` for the CLI's shared observability/config enums (`LogLevel`, `LogFormat`, `LogFileMode`), request/response primitives (`ConfigSubcommand`, `ConfigRequest`, `ReportFormat`), source metadata types (`ValueSource`, `ConfigPathSource`, `LoadedConfigPath`, `ResolvedValue`, `ResolvedOptionalValue`), resolved runtime config types (`ResolvedAuthRuntimeConfig`, `ResolvedObservabilityRuntimeConfig`, `ResolvedHookRuntimeConfig`), the `NAME` constant, observability env-key constants, and shared bool parsing helpers; re-exported through `cli/src/services/config/mod.rs` via `pub use types::*` so downstream modules continue importing through `services::config` unchanged. -- `config schema and file parsing seam`: Canonical ownership in `cli/src/services/config/schema.rs` for the CLI's JSON Schema embedding (`SCE_CONFIG_SCHEMA_JSON`), `OnceLock` validator (`CONFIG_SCHEMA_VALIDATOR`, `config_schema_validator()`), top-level allowed-key validation (`TOP_LEVEL_CONFIG_KEYS`, `validate_object_keys`), serde DTO definitions (`ParsedFileConfigDocument`, `ParsedPoliciesConfigDocument`, `ParsedBashPolicyConfigDocument`, `ParsedAttributionHooksConfigDocument`, `ParsedCustomBashPolicyEntryDocument`, `ParsedCustomBashPolicyMatchDocument`), file config value wrapper (`FileConfigValue`) and aggregate (`FileConfig`), type aliases (`ParsedBashPolicyConfig`, `ParsedFilePolicies`), and config-file load/parse helpers (`validate_config_file`, `parse_file_config`, `deserialize_typed_config`, `map_policies_config`, `map_attribution_hooks_config`, `map_bash_policy_config`); `validate_config_file` is re-exported `pub(crate)` through `mod.rs` for `lifecycle.rs` and `doctor` consumers. +- `config schema and file parsing seam`: Canonical ownership in `cli/src/services/config/schema.rs` for the CLI's JSON Schema embedding (`SCE_CONFIG_SCHEMA_JSON`), `OnceLock` validator (`CONFIG_SCHEMA_VALIDATOR`, `config_schema_validator()`), top-level allowed-key validation (`TOP_LEVEL_CONFIG_KEYS`, `validate_object_keys`), serde DTO definitions (`ParsedFileConfigDocument`, `ParsedPoliciesConfigDocument`, `ParsedBashPolicyConfigDocument`, `ParsedAttributionHooksConfigDocument`, `ParsedCustomBashPolicyEntryDocument`, `ParsedCustomBashPolicyMatchDocument`), file config value wrapper (`FileConfigValue`) and aggregate (`FileConfig`), type aliases (`ParsedBashPolicyConfig`, `ParsedFilePolicies`), and config-file load/parse helpers (`validate_config_file`, `parse_file_config`, `deserialize_typed_config`, `map_policies_config`, `map_attribution_hooks_config`, `map_bash_policy_config`); `validate_config_file` is re-exported `pub(crate)` through `mod.rs` for `lifecycle.rs` and `doctor` consumers. Policy parsing helpers (`parse_bash_policy_presets`, `parse_custom_bash_policies`) and `CustomBashPolicyEntry` are imported from `super::policy` rather than the parent module. +- `config policy semantic validation seam`: Canonical ownership in `cli/src/services/config/policy.rs` for the CLI's bash-policy and attribution-hooks semantic validation, merge helpers, and policy rendering: built-in/custom bash-policy catalog types and OnceLock (`BuiltinBashPolicyCatalog`, `BuiltinBashPolicyPreset`, `BuiltinBashPolicyMatcher`, `BuiltinBashPolicyRedundancyWarning`, `BUILTIN_BASH_POLICY_CATALOG`, `BASH_POLICY_PRESET_CATALOG_JSON`), policy config types (`BashPolicyConfig`, `CustomBashPolicyEntry`), catalog accessors (`builtin_bash_policy_catalog`, `builtin_bash_policy_preset_ids`, `is_builtin_bash_policy_preset_id`), policy parsing and validation (`parse_bash_policy_presets`, `parse_custom_bash_policies`, `parse_custom_bash_policy_entry`, `parse_custom_bash_policy_match`, `parse_custom_bash_policy_argv_prefix`), policy resolution (`resolve_bash_policy_config`, `build_validation_warnings`), and policy rendering (`format_bash_policies_text`, `format_bash_policies_json`); `mod.rs` imports `BashPolicyConfig`, `build_validation_warnings`, `format_bash_policies_json`, `format_bash_policies_text`, and `resolve_bash_policy_config` from `policy` for resolution and rendering consumers. - `sce config schema artifact`: Canonical JSON Schema for global and repo-local `sce/config.json` files, authored in `config/pkl/base/sce-config-schema.pkl`, generated to `config/schema/sce-config.schema.json`, and embedded by `cli/src/services/config/schema.rs` for shared `sce config validate` and doctor config validation. The current schema accepts the canonical `$schema` declaration, flat logging keys (`log_level`, `log_format`, `log_file`, `log_file_mode`), existing auth/config keys, and enforces the schema-level dependency that `log_file_mode` requires `log_file`. - `bash tool policy config surface`: Nested repo config namespace under `.sce/config.json` at `policies.bash`, currently supporting unique built-in `presets` plus repo-owned `custom` argv-prefix rules with deterministic validation, merged global/local resolution, and first-class `sce config show|validate` reporting. - `attribution hooks gate`: Disabled-default local hook runtime gate resolved through shared config precedence in `cli/src/services/config/mod.rs` (with parsing in `schema.rs`): env `SCE_ATTRIBUTION_HOOKS_ENABLED` overrides repo/global config key `policies.attribution_hooks.enabled`, and the current enabled path activates commit-msg-only attribution without re-enabling trace persistence. diff --git a/context/overview.md b/context/overview.md index 948c2a9d..08af6a12 100644 --- a/context/overview.md +++ b/context/overview.md @@ -20,7 +20,7 @@ The setup service also provides repository-root install orchestration: it resolv The CLI now also applies baseline security hardening for reliability-driven automation: diagnostics/logging paths use deterministic secret redaction, `sce setup --hooks --repo ` canonicalizes and validates repository paths before execution, and setup write flows run explicit directory write-permission probes before staging/swap operations. The config service now provides deterministic runtime config resolution with explicit precedence (`flags > env > config file > defaults`), strict config-file validation (`$schema`, `log_level`, `log_format`, `log_file`, `log_file_mode`, `timeout_ms`, `workos_client_id`, and nested `policies.bash`), deterministic default discovery/merge of global+local config files (`${config_root}/sce/config.json` then `.sce/config.json` with local override, where `config_root` comes from the shared default-path seam with XDG/`dirs::config_dir()` config-root resolution), defaults for the resolved observability value set (`log_level=error`, `log_format=text`, `log_file_mode=truncate`), shared auth-key resolution with optional baked defaults starting at `workos_client_id`, first-class bash-policy preset/custom parsing with deterministic conflict and duplicate-prefix validation, and a canonical Pkl-authored `sce/config.json` JSON Schema generated to `config/schema/sce-config.schema.json` and embedded by `cli/src/services/config/mod.rs` for both `sce config validate` and doctor-time config checks. Runtime startup config loading now keeps parity with that schema by accepting the canonical `"$schema": "https://sce.crocoder.dev/config.json"` declaration in repo-local and global config files, so startup commands such as `sce version` no longer fail before dispatch on that field. App-runtime observability now consumes flat logging keys through the shared resolver, so env values still override config-file values while config files provide deterministic fallback for file logging; `sce config show` reports resolved observability/auth/policy values with provenance, while `sce config validate` is now a trimmed validation surface that reports only pass/fail plus validation errors or warnings in text and JSON modes. The canonical preset catalog and matching contract live in `config/pkl/data/bash-policy-presets.json` and `context/sce/bash-tool-policy-enforcement-contract.md`. Invalid default-discovered config files now also degrade gracefully at startup: `sce` keeps running with degraded observability defaults, logs `sce.config.invalid_config` warnings, and reserves hard failures for explicit `--config` / `SCE_CONFIG_FILE` targets or other truly invalid runtime observability inputs. -`cli/src/services/config/mod.rs` is now a module facade that declares `pub mod types`, `pub mod schema`, and `pub mod command`/`pub mod lifecycle`, re-exporting `pub use types::*` and `pub(crate) use schema::validate_config_file`. Shared config primitive ownership is delegated to `cli/src/services/config/types.rs` (`LogLevel`, `LogFormat`, `LogFileMode`, `ConfigSubcommand`, `ConfigRequest`, `ReportFormat`, source metadata types, resolved runtime config types, the `NAME` constant, observability env-key constants, and `parse_bool_value_from`). Config schema loading and file parsing ownership is delegated to `cli/src/services/config/schema.rs` (JSON Schema embedding/validator, top-level allowed-key validation, serde DTO definitions, `FileConfig`/`FileConfigValue`, type aliases, `validate_config_file`, `parse_file_config`, `deserialize_typed_config`, `map_policies_config`, `map_attribution_hooks_config`, `map_bash_policy_config`). Downstream modules continue importing through `services::config` unchanged. The CLI now has a minimal `AppContext` dependency-injection container in `cli/src/app.rs` holding `Arc`, `Arc`, `Arc`, `Arc`, and an optional `repo_root: Option`; it can derive repo-root-scoped contexts with `with_repo_root(...)` while preserving runtime dependencies. The broad capability seam lives in `cli/src/services/capabilities.rs`, where `FsOps`/`StdFsOps` wrap filesystem operations and `GitOps`/`ProcessGitOps` wrap git process execution plus repository-root/hooks-directory resolution. Current services have not migrated to consume the filesystem/git traits internally yet. +`cli/src/services/config/mod.rs` is now a module facade that declares `pub mod types`, `pub mod schema`, `pub mod policy`, and `pub mod command`/`pub mod lifecycle`, re-exporting `pub use types::*` and `pub(crate) use schema::validate_config_file`. Shared config primitive ownership is delegated to `cli/src/services/config/types.rs` (`LogLevel`, `LogFormat`, `LogFileMode`, `ConfigSubcommand`, `ConfigRequest`, `ReportFormat`, source metadata types, resolved runtime config types, the `NAME` constant, observability env-key constants, and `parse_bool_value_from`). Config schema loading and file parsing ownership is delegated to `cli/src/services/config/schema.rs` (JSON Schema embedding/validator, top-level allowed-key validation, serde DTO definitions, `FileConfig`/`FileConfigValue`, type aliases, `validate_config_file`, `parse_file_config`, `deserialize_typed_config`, `map_policies_config`, `map_attribution_hooks_config`, `map_bash_policy_config`). Config policy semantic validation and rendering ownership is delegated to `cli/src/services/config/policy.rs` (`BashPolicyConfig`, `CustomBashPolicyEntry`, built-in catalog types and OnceLock, `is_builtin_bash_policy_preset_id`, `parse_bash_policy_presets`, `parse_custom_bash_policies`, `resolve_bash_policy_config`, `build_validation_warnings`, `format_bash_policies_text`, `format_bash_policies_json`). Downstream modules continue importing through `services::config` unchanged. The CLI now has a minimal `AppContext` dependency-injection container in `cli/src/app.rs` holding `Arc`, `Arc`, `Arc`, `Arc`, and an optional `repo_root: Option`; it can derive repo-root-scoped contexts with `with_repo_root(...)` while preserving runtime dependencies. The broad capability seam lives in `cli/src/services/capabilities.rs`, where `FsOps`/`StdFsOps` wrap filesystem operations and `GitOps`/`ProcessGitOps` wrap git process execution plus repository-root/hooks-directory resolution. Current services have not migrated to consume the filesystem/git traits internally yet. The shared default path service in `cli/src/services/default_paths.rs` is now the canonical owner for production CLI path definitions. It resolves per-user config/state/cache roots through a dedicated internal `roots` seam, exposes the current persisted-artifact inventory (global config and auth tokens), and also defines named DB paths (auth DB, local DB, Agent Trace DB) plus the repo-relative, embedded-asset, install/runtime, hook, and context-path accessors consumed across current CLI production code. Non-test production modules should consume this shared catalog instead of hardcoding owned path literals. No default cache-backed persisted artifact currently exists, so cache-root resolution remains available without speculative cache-path features and no legacy default-path fallback is supported. The same config resolver now also owns the attribution-hooks gate used by local hook runtime: `SCE_ATTRIBUTION_HOOKS_ENABLED` overrides `policies.attribution_hooks.enabled`, and the gate defaults to disabled. Generated config now includes repo-local OpenCode plugin assets for both profiles: `sce-bash-policy.ts` plus `sce-agent-trace.ts` are emitted under `config/.opencode/plugins/` and `config/automated/.opencode/plugins/`; the agent-trace plugin extracts `{ sessionID, diff, time, model_id }` from user `message.updated` events with diffs, tracks per-session OpenCode client version from `session.created`/`session.updated`, and sends payloads to `sce hooks diff-trace` with `tool_name="opencode"` plus optional `tool_version`; the Rust hook continues to validate required fields and persists `model_id`, `tool_name`, and nullable `tool_version` into `diff_traces` through AgentTraceDb. Bash-policy also emits shared runtime logic and preset data under `config/.opencode/lib/` (also emitted for `config/automated/.opencode/**`). Claude bash-policy enforcement has been removed from generated outputs. diff --git a/context/plans/cli-maintenance-hazards.md b/context/plans/cli-maintenance-hazards.md index 2f643137..cd291cff 100644 --- a/context/plans/cli-maintenance-hazards.md +++ b/context/plans/cli-maintenance-hazards.md @@ -73,12 +73,16 @@ Resolve the architectural/maintenance hazards in the Rust CLI without changing u - Evidence: `nix flake check` passed (cli-tests, cli-clippy, cli-fmt, pkl-parity all green); `nix run .#pkl-check-generated` passed. No public API changes required outside `config/` module; `validate_config_file` re-exported through `mod.rs` for `lifecycle.rs` and `doctor` consumers. - Notes: Created `cli/src/services/config/schema.rs` containing schema constants (`SCE_CONFIG_SCHEMA_JSON`, `CONFIG_SCHEMA_DECLARATION_KEY`, `TOP_LEVEL_CONFIG_KEYS`, `TOP_LEVEL_CONFIG_KEYS_DESCRIPTION`), `OnceLock` validator (`CONFIG_SCHEMA_VALIDATOR`, `config_schema_validator()`), JSON validation functions (`validate_config_value_against_schema`, `validate_object_keys`), serde DTOs (`ParsedFileConfigDocument`, `ParsedPoliciesConfigDocument`, `ParsedBashPolicyConfigDocument`, `ParsedAttributionHooksConfigDocument`, `ParsedCustomBashPolicyEntryDocument`, `ParsedCustomBashPolicyMatchDocument`), `FileConfigValue`, `FileConfig`, type aliases (`ParsedBashPolicyConfig`, `ParsedFilePolicies`), and file parse/deserialization helpers (`validate_config_file`, `deserialize_typed_config`, `parse_file_config`, `map_policies_config`, `map_attribution_hooks_config`, `map_bash_policy_config`). `mod.rs` now declares `pub mod schema` and re-exports `validate_config_file` as `pub(crate)`. Bash-policy catalog/preset/validation functions (`builtin_bash_policy_catalog`, `parse_bash_policy_presets`, `parse_custom_bash_policies`, etc.) remain in `mod.rs` for T05 extraction. `WORKOS_CLIENT_ID_KEY` and `AuthConfigKeySpec` fields made `pub(crate)` for `schema.rs` access via `super::`. -- [ ] T05: `Extract config policy semantic validation` (status:todo) +- [x] T05: `Extract config policy semantic validation` (status:done) - Task ID: T05 - Goal: Move bash-policy and attribution-hooks semantic validation/merge helpers into a focused policy submodule consumed by config resolution and rendering. - Boundaries (in/out of scope): In - built-in/custom bash-policy validation, duplicate/conflict/redundancy checks, attribution-hooks config parsing helpers, policy resolved-data structs if needed for cohesion. Out - changing policy schema, changing preset catalog generation, changing OpenCode plugin runtime behavior. - Done when: policy-specific rules are no longer interleaved with generic config resolution/rendering in `mod.rs`; existing warnings/errors for policy conflicts and redundancy remain stable. - Verification notes (commands or checks): Prefer `nix flake check`; run targeted config-policy tests if implementation adds/moves them. + - Completed: 2026-06-07 + - Files changed: `cli/src/services/config/mod.rs`, `cli/src/services/config/policy.rs`, `cli/src/services/config/schema.rs` + - Evidence: `nix flake check` passed (cli-tests, cli-clippy, cli-fmt, pkl-parity all green); `nix run .#pkl-check-generated` passed. No public API changes required outside `config/` module; all existing callers continue importing through `services::config` unchanged. + - Notes: Created `cli/src/services/config/policy.rs` containing `BashPolicyConfig`, `BuiltinBashPolicyCatalog`, `BuiltinBashPolicyPreset`, `BuiltinBashPolicyMatcher`, `BuiltinBashPolicyRedundancyWarning`, `CustomBashPolicyEntry`, `BUILTIN_BASH_POLICY_CATALOG` OnceLock, `BASH_POLICY_PRESET_CATALOG_JSON` include_str, `builtin_bash_policy_catalog()`, `builtin_bash_policy_preset_ids()`, `is_builtin_bash_policy_preset_id()`, `parse_bash_policy_presets()`, `parse_custom_bash_policies()`, `parse_custom_bash_policy_entry()`, `parse_custom_bash_policy_match()`, `parse_custom_bash_policy_argv_prefix()`, `resolve_bash_policy_config()`, `build_validation_warnings()`, `format_bash_policies_text()`, `format_bash_policies_json()`. `mod.rs` now declares `pub mod policy` and imports `BashPolicyConfig`, `build_validation_warnings`, `format_bash_policies_json`, `format_bash_policies_text`, `resolve_bash_policy_config` from `policy`. `schema.rs` now imports `parse_bash_policy_presets`, `parse_custom_bash_policies`, `CustomBashPolicyEntry` from `super::policy` instead of `super`. - [ ] T06: `Extract runtime config resolution and precedence flow` (status:todo) - Task ID: T06 From 6ec3fabaef2dc816a99e3d436ee5f96ce6a30b35 Mon Sep 17 00:00:00 2001 From: David Abram Date: Mon, 8 Jun 2026 17:24:23 +0200 Subject: [PATCH 6/7] config: Extract runtime config resolver flow Move config discovery, file-layer merging, env/flag/default precedence, auth-key resolution, observability resolution, attribution-hooks resolution, and invalid default-discovered config degradation into a resolver module while preserving existing facade imports. Co-authored-by: SCE --- cli/src/services/config/mod.rs | 540 +-------------------- cli/src/services/config/resolver.rs | 545 ++++++++++++++++++++++ cli/src/services/config/schema.rs | 2 +- context/architecture.md | 2 +- context/cli/config-precedence-contract.md | 9 +- context/context-map.md | 2 +- context/glossary.md | 5 +- context/overview.md | 1 + context/plans/cli-maintenance-hazards.md | 6 +- 9 files changed, 572 insertions(+), 540 deletions(-) create mode 100644 cli/src/services/config/resolver.rs diff --git a/cli/src/services/config/mod.rs b/cli/src/services/config/mod.rs index 1d6976f4..ebd8349c 100644 --- a/cli/src/services/config/mod.rs +++ b/cli/src/services/config/mod.rs @@ -1,72 +1,27 @@ pub mod command; pub mod lifecycle; pub mod policy; +pub mod resolver; pub mod schema; pub mod types; pub use types::*; -use std::path::{Path, PathBuf}; - -use anyhow::{anyhow, bail, Context, Result}; +use anyhow::{Context, Result}; use serde_json::{json, Value}; -use crate::services::default_paths::{resolve_sce_default_locations, RepoPaths}; use crate::services::style; -use policy::{ - build_validation_warnings, format_bash_policies_json, format_bash_policies_text, - resolve_bash_policy_config, BashPolicyConfig, +use policy::{format_bash_policies_json, format_bash_policies_text}; +use resolver::{ + resolve_runtime_config, AuthConfigKeySpec, RuntimeConfig, PRECEDENCE_DESCRIPTION, + WORKOS_CLIENT_ID_KEY, }; -pub(crate) use schema::validate_config_file; - -const DEFAULT_TIMEOUT_MS: u64 = 30000; -const PRECEDENCE_DESCRIPTION: &str = "flags > env > config file > defaults"; -const WORKOS_CLIENT_ID_ENV: &str = "WORKOS_CLIENT_ID"; -const WORKOS_CLIENT_ID_BAKED_DEFAULT: &str = "client_sce_default"; -pub(crate) const WORKOS_CLIENT_ID_KEY: AuthConfigKeySpec = AuthConfigKeySpec { - config_key: "workos_client_id", - env_key: WORKOS_CLIENT_ID_ENV, - baked_default: Some(WORKOS_CLIENT_ID_BAKED_DEFAULT), +pub(crate) use resolver::{ + resolve_auth_runtime_config, resolve_hook_runtime_config, resolve_observability_runtime_config, }; - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) struct AuthConfigKeySpec { - pub(crate) config_key: &'static str, - pub(crate) env_key: &'static str, - pub(crate) baked_default: Option<&'static str>, -} - -impl AuthConfigKeySpec { - pub(crate) fn precedence_description(self) -> String { - let mut layers = vec![ - format!("env ({})", self.env_key), - format!("config file ({})", self.config_key), - ]; - - if let Some(default) = self.baked_default { - layers.push(format!("baked default ({default})")); - } - - layers.join(" > ") - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -struct RuntimeConfig { - loaded_config_paths: Vec, - log_level: ResolvedValue, - log_format: ResolvedValue, - log_file: ResolvedOptionalValue, - log_file_mode: ResolvedValue, - timeout_ms: ResolvedValue, - attribution_hooks_enabled: ResolvedValue, - workos_client_id: ResolvedOptionalValue, - bash_policies: ResolvedOptionalValue, - validation_errors: Vec, - validation_warnings: Vec, -} +pub(crate) use schema::validate_config_file; pub fn run_config_subcommand(subcommand: ConfigSubcommand) -> Result { match subcommand { @@ -83,483 +38,6 @@ pub fn run_config_subcommand(subcommand: ConfigSubcommand) -> Result { } } -pub(crate) fn resolve_auth_runtime_config(cwd: &Path) -> Result { - resolve_auth_runtime_config_with( - cwd, - |key| std::env::var(key).ok(), - |path| { - std::fs::read_to_string(path) - .with_context(|| format!("Failed to read config file '{}'.", path.display())) - }, - Path::exists, - resolve_default_global_config_path, - ) -} - -pub(crate) fn resolve_observability_runtime_config( - cwd: &Path, -) -> Result { - resolve_observability_runtime_config_with( - cwd, - |key| std::env::var(key).ok(), - |path| { - std::fs::read_to_string(path) - .with_context(|| format!("Failed to read config file '{}'.", path.display())) - }, - Path::exists, - resolve_default_global_config_path, - ) -} - -pub(crate) fn resolve_hook_runtime_config(cwd: &Path) -> Result { - resolve_hook_runtime_config_with( - cwd, - |key| std::env::var(key).ok(), - |path| { - std::fs::read_to_string(path) - .with_context(|| format!("Failed to read config file '{}'.", path.display())) - }, - Path::exists, - resolve_default_global_config_path, - ) -} - -pub(crate) fn resolve_auth_runtime_config_with( - cwd: &Path, - env_lookup: FEnv, - read_file: FRead, - path_exists: fn(&Path) -> bool, - resolve_global_config_path: FGlobalPath, -) -> Result -where - FEnv: Fn(&str) -> Option, - FRead: Fn(&Path) -> Result, - FGlobalPath: Fn() -> Result, -{ - let runtime = resolve_runtime_config_with( - &ConfigRequest { - report_format: ReportFormat::Text, - config_path: None, - log_level: None, - timeout_ms: None, - }, - cwd, - env_lookup, - read_file, - path_exists, - resolve_global_config_path, - )?; - - Ok(ResolvedAuthRuntimeConfig { - workos_client_id: runtime.workos_client_id, - }) -} - -pub(crate) fn resolve_observability_runtime_config_with( - cwd: &Path, - env_lookup: FEnv, - read_file: FRead, - path_exists: fn(&Path) -> bool, - resolve_global_config_path: FGlobalPath, -) -> Result -where - FEnv: Fn(&str) -> Option, - FRead: Fn(&Path) -> Result, - FGlobalPath: Fn() -> Result, -{ - let runtime = resolve_runtime_config_with( - &ConfigRequest { - report_format: ReportFormat::Text, - config_path: None, - log_level: None, - timeout_ms: None, - }, - cwd, - env_lookup, - read_file, - path_exists, - resolve_global_config_path, - )?; - - Ok(ResolvedObservabilityRuntimeConfig { - log_level: runtime.log_level.value, - log_format: runtime.log_format.value, - log_file: runtime.log_file.value, - log_file_mode: runtime.log_file_mode.value, - loaded_config_paths: runtime.loaded_config_paths, - validation_errors: runtime.validation_errors, - }) -} - -pub(crate) fn resolve_hook_runtime_config_with( - cwd: &Path, - env_lookup: FEnv, - read_file: FRead, - path_exists: fn(&Path) -> bool, - resolve_global_config_path: FGlobalPath, -) -> Result -where - FEnv: Fn(&str) -> Option, - FRead: Fn(&Path) -> Result, - FGlobalPath: Fn() -> Result, -{ - let runtime = resolve_runtime_config_with( - &ConfigRequest { - report_format: ReportFormat::Text, - config_path: None, - log_level: None, - timeout_ms: None, - }, - cwd, - env_lookup, - read_file, - path_exists, - resolve_global_config_path, - )?; - - Ok(ResolvedHookRuntimeConfig { - attribution_hooks_enabled: runtime.attribution_hooks_enabled.value, - }) -} - -fn resolve_runtime_config(request: &ConfigRequest, cwd: &Path) -> Result { - resolve_runtime_config_with( - request, - cwd, - |key| std::env::var(key).ok(), - |path| { - std::fs::read_to_string(path) - .with_context(|| format!("Failed to read config file '{}'.", path.display())) - }, - Path::exists, - resolve_default_global_config_path, - ) -} - -#[allow(clippy::too_many_lines)] -fn resolve_runtime_config_with( - request: &ConfigRequest, - cwd: &Path, - env_lookup: FEnv, - read_file: FRead, - path_exists: fn(&Path) -> bool, - resolve_global_config_path: FGlobalPath, -) -> Result -where - FEnv: Fn(&str) -> Option, - FRead: Fn(&Path) -> Result, - FGlobalPath: Fn() -> Result, -{ - let loaded_config_paths = resolve_config_paths( - request, - cwd, - &env_lookup, - path_exists, - resolve_global_config_path, - )?; - - let mut file_config = schema::FileConfig { - log_level: None, - log_format: None, - log_file: None, - log_file_mode: None, - timeout_ms: None, - attribution_hooks_enabled: None, - workos_client_id: None, - bash_policy_presets: None, - bash_policy_custom: None, - }; - let mut validation_errors = Vec::new(); - for loaded_path in &loaded_config_paths { - let raw = read_file(&loaded_path.path)?; - let layer = match schema::parse_file_config(&raw, &loaded_path.path, loaded_path.source) { - Ok(layer) => layer, - Err(error) if loaded_path.source.is_default_discovered() => { - validation_errors.push(error.to_string()); - continue; - } - Err(error) => return Err(error), - }; - if let Some(log_level) = layer.log_level { - file_config.log_level = Some(log_level); - } - if let Some(log_format) = layer.log_format { - file_config.log_format = Some(log_format); - } - if let Some(log_file) = layer.log_file { - file_config.log_file = Some(log_file); - } - if let Some(log_file_mode) = layer.log_file_mode { - file_config.log_file_mode = Some(log_file_mode); - } - if let Some(timeout_ms) = layer.timeout_ms { - file_config.timeout_ms = Some(timeout_ms); - } - if let Some(attribution_hooks_enabled) = layer.attribution_hooks_enabled { - file_config.attribution_hooks_enabled = Some(attribution_hooks_enabled); - } - if let Some(workos_client_id) = layer.workos_client_id { - file_config.workos_client_id = Some(workos_client_id); - } - if let Some(bash_policy_presets) = layer.bash_policy_presets { - file_config.bash_policy_presets = Some(bash_policy_presets); - } - if let Some(bash_policy_custom) = layer.bash_policy_custom { - file_config.bash_policy_custom = Some(bash_policy_custom); - } - } - - let mut resolved_log_level = ResolvedValue { - value: LogLevel::Error, - source: ValueSource::Default, - }; - if let Some(value) = file_config.log_level { - resolved_log_level = ResolvedValue { - value: value.value, - source: ValueSource::ConfigFile(value.source), - }; - } - if let Some(raw) = env_lookup(ENV_LOG_LEVEL) { - resolved_log_level = ResolvedValue { - value: LogLevel::parse(&raw, ENV_LOG_LEVEL)?, - source: ValueSource::Env, - }; - } - if let Some(value) = request.log_level { - resolved_log_level = ResolvedValue { - value, - source: ValueSource::Flag, - }; - } - - let mut resolved_log_format = ResolvedValue { - value: LogFormat::Text, - source: ValueSource::Default, - }; - if let Some(value) = file_config.log_format { - resolved_log_format = ResolvedValue { - value: value.value, - source: ValueSource::ConfigFile(value.source), - }; - } - if let Some(raw) = env_lookup(ENV_LOG_FORMAT) { - resolved_log_format = ResolvedValue { - value: LogFormat::parse(&raw, ENV_LOG_FORMAT)?, - source: ValueSource::Env, - }; - } - - let mut resolved_log_file = ResolvedOptionalValue { - value: file_config - .log_file - .as_ref() - .map(|value| value.value.clone()), - source: file_config - .log_file - .as_ref() - .map(|value| ValueSource::ConfigFile(value.source)), - }; - if let Some(raw) = env_lookup(ENV_LOG_FILE) { - resolved_log_file = ResolvedOptionalValue { - value: Some(raw), - source: Some(ValueSource::Env), - }; - } - - let mut resolved_log_file_mode = ResolvedValue { - value: LogFileMode::Truncate, - source: ValueSource::Default, - }; - if let Some(value) = file_config.log_file_mode { - resolved_log_file_mode = ResolvedValue { - value: value.value, - source: ValueSource::ConfigFile(value.source), - }; - } - if let Some(raw) = env_lookup(ENV_LOG_FILE_MODE) { - resolved_log_file_mode = ResolvedValue { - value: LogFileMode::parse(&raw, ENV_LOG_FILE_MODE)?, - source: ValueSource::Env, - }; - } - if resolved_log_file.value.is_none() && resolved_log_file_mode.source != ValueSource::Default { - bail!( - "{ENV_LOG_FILE_MODE} requires {ENV_LOG_FILE}. Try: set {ENV_LOG_FILE} to a file path or unset {ENV_LOG_FILE_MODE}." - ); - } - - let mut resolved_timeout_ms = ResolvedValue { - value: DEFAULT_TIMEOUT_MS, - source: ValueSource::Default, - }; - if let Some(value) = file_config.timeout_ms { - resolved_timeout_ms = ResolvedValue { - value: value.value, - source: ValueSource::ConfigFile(value.source), - }; - } - if let Some(raw) = env_lookup("SCE_TIMEOUT_MS") { - let value = raw - .parse::() - .map_err(|_| anyhow!("Invalid timeout '{raw}' from SCE_TIMEOUT_MS."))?; - resolved_timeout_ms = ResolvedValue { - value, - source: ValueSource::Env, - }; - } - if let Some(value) = request.timeout_ms { - resolved_timeout_ms = ResolvedValue { - value, - source: ValueSource::Flag, - }; - } - - let mut resolved_attribution_hooks_enabled = ResolvedValue { - value: false, - source: ValueSource::Default, - }; - if let Some(value) = file_config.attribution_hooks_enabled { - resolved_attribution_hooks_enabled = ResolvedValue { - value: value.value, - source: ValueSource::ConfigFile(value.source), - }; - } - if let Some(raw) = env_lookup(ENV_ATTRIBUTION_HOOKS_ENABLED) { - resolved_attribution_hooks_enabled = ResolvedValue { - value: parse_bool_value_from( - ENV_ATTRIBUTION_HOOKS_ENABLED, - &raw, - ENV_ATTRIBUTION_HOOKS_ENABLED, - )?, - source: ValueSource::Env, - }; - } - - let resolved_workos_client_id = resolve_optional_auth_config_value( - WORKOS_CLIENT_ID_KEY, - file_config.workos_client_id, - &env_lookup, - ); - - let resolved_bash_policies = resolve_bash_policy_config( - file_config.bash_policy_presets.as_ref(), - file_config.bash_policy_custom.as_ref(), - ); - let validation_warnings = build_validation_warnings(&resolved_bash_policies); - - Ok(RuntimeConfig { - loaded_config_paths, - log_level: resolved_log_level, - log_format: resolved_log_format, - log_file: resolved_log_file, - log_file_mode: resolved_log_file_mode, - timeout_ms: resolved_timeout_ms, - attribution_hooks_enabled: resolved_attribution_hooks_enabled, - workos_client_id: resolved_workos_client_id, - bash_policies: resolved_bash_policies, - validation_errors, - validation_warnings, - }) -} - -fn resolve_optional_auth_config_value( - key: AuthConfigKeySpec, - file_value: Option>, - env_lookup: &FEnv, -) -> ResolvedOptionalValue -where - FEnv: Fn(&str) -> Option, -{ - if let Some(raw) = env_lookup(key.env_key) { - return ResolvedOptionalValue { - value: Some(raw), - source: Some(ValueSource::Env), - }; - } - - if let Some(value) = file_value { - return ResolvedOptionalValue { - value: Some(value.value), - source: Some(ValueSource::ConfigFile(value.source)), - }; - } - - if let Some(value) = key.baked_default { - return ResolvedOptionalValue { - value: Some(value.to_string()), - source: Some(ValueSource::Default), - }; - } - - ResolvedOptionalValue { - value: None, - source: None, - } -} - -fn resolve_config_paths( - request: &ConfigRequest, - cwd: &Path, - env_lookup: &FEnv, - path_exists: fn(&Path) -> bool, - resolve_global_config_path: FGlobalPath, -) -> Result> -where - FEnv: Fn(&str) -> Option, - FGlobalPath: Fn() -> Result, -{ - if let Some(path) = request.config_path.as_ref() { - if !path_exists(path) { - bail!( - "Config file '{}' was provided via --config but does not exist.", - path.display() - ); - } - return Ok(vec![LoadedConfigPath { - path: path.clone(), - source: ConfigPathSource::Flag, - }]); - } - - if let Some(raw) = env_lookup("SCE_CONFIG_FILE") { - let path = PathBuf::from(raw); - if !path_exists(&path) { - bail!( - "Config file '{}' was provided via SCE_CONFIG_FILE but does not exist.", - path.display() - ); - } - return Ok(vec![LoadedConfigPath { - path, - source: ConfigPathSource::Env, - }]); - } - - let mut discovered_paths = Vec::new(); - - let global_path = resolve_global_config_path()?; - if path_exists(&global_path) { - discovered_paths.push(LoadedConfigPath { - path: global_path, - source: ConfigPathSource::DefaultDiscoveredGlobal, - }); - } - - let local_path = RepoPaths::new(cwd).sce_config_file(); - if path_exists(&local_path) { - discovered_paths.push(LoadedConfigPath { - path: local_path, - source: ConfigPathSource::DefaultDiscoveredLocal, - }); - } - - Ok(discovered_paths) -} - -fn resolve_default_global_config_path() -> Result { - Ok(resolve_sce_default_locations()?.global_config_file()) -} - fn format_show_output(runtime: &RuntimeConfig, report_format: ReportFormat) -> String { let warnings = build_show_warnings(runtime); match report_format { diff --git a/cli/src/services/config/resolver.rs b/cli/src/services/config/resolver.rs new file mode 100644 index 00000000..45241f76 --- /dev/null +++ b/cli/src/services/config/resolver.rs @@ -0,0 +1,545 @@ +//! Runtime config discovery, merge, and precedence resolution. +//! +//! This submodule owns config-file discovery, file-layer merging, +//! env/flag/default precedence, auth-key resolution, observability resolution, +//! and default-discovered invalid-file degradation. + +use std::path::{Path, PathBuf}; + +use anyhow::{anyhow, bail, Context, Result}; + +use crate::services::default_paths::{resolve_sce_default_locations, RepoPaths}; + +use super::policy::{build_validation_warnings, resolve_bash_policy_config, BashPolicyConfig}; +use super::schema; +use super::types::{ + parse_bool_value_from, ConfigPathSource, ConfigRequest, LoadedConfigPath, LogFileMode, + LogFormat, LogLevel, ReportFormat, ResolvedAuthRuntimeConfig, ResolvedHookRuntimeConfig, + ResolvedObservabilityRuntimeConfig, ResolvedOptionalValue, ResolvedValue, ValueSource, + ENV_ATTRIBUTION_HOOKS_ENABLED, ENV_LOG_FILE, ENV_LOG_FILE_MODE, ENV_LOG_FORMAT, ENV_LOG_LEVEL, +}; + +const DEFAULT_TIMEOUT_MS: u64 = 30000; +pub(crate) const PRECEDENCE_DESCRIPTION: &str = "flags > env > config file > defaults"; +const WORKOS_CLIENT_ID_ENV: &str = "WORKOS_CLIENT_ID"; +const WORKOS_CLIENT_ID_BAKED_DEFAULT: &str = "client_sce_default"; + +pub(crate) const WORKOS_CLIENT_ID_KEY: AuthConfigKeySpec = AuthConfigKeySpec { + config_key: "workos_client_id", + env_key: WORKOS_CLIENT_ID_ENV, + baked_default: Some(WORKOS_CLIENT_ID_BAKED_DEFAULT), +}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct AuthConfigKeySpec { + pub(crate) config_key: &'static str, + pub(crate) env_key: &'static str, + pub(crate) baked_default: Option<&'static str>, +} + +impl AuthConfigKeySpec { + pub(crate) fn precedence_description(self) -> String { + let mut layers = vec![ + format!("env ({})", self.env_key), + format!("config file ({})", self.config_key), + ]; + + if let Some(default) = self.baked_default { + layers.push(format!("baked default ({default})")); + } + + layers.join(" > ") + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(super) struct RuntimeConfig { + pub(super) loaded_config_paths: Vec, + pub(super) log_level: ResolvedValue, + pub(super) log_format: ResolvedValue, + pub(super) log_file: ResolvedOptionalValue, + pub(super) log_file_mode: ResolvedValue, + pub(super) timeout_ms: ResolvedValue, + pub(super) attribution_hooks_enabled: ResolvedValue, + pub(super) workos_client_id: ResolvedOptionalValue, + pub(super) bash_policies: ResolvedOptionalValue, + pub(super) validation_errors: Vec, + pub(super) validation_warnings: Vec, +} + +pub(crate) fn resolve_auth_runtime_config(cwd: &Path) -> Result { + resolve_auth_runtime_config_with( + cwd, + |key| std::env::var(key).ok(), + |path| { + std::fs::read_to_string(path) + .with_context(|| format!("Failed to read config file '{}'.", path.display())) + }, + Path::exists, + resolve_default_global_config_path, + ) +} + +pub(crate) fn resolve_observability_runtime_config( + cwd: &Path, +) -> Result { + resolve_observability_runtime_config_with( + cwd, + |key| std::env::var(key).ok(), + |path| { + std::fs::read_to_string(path) + .with_context(|| format!("Failed to read config file '{}'.", path.display())) + }, + Path::exists, + resolve_default_global_config_path, + ) +} + +pub(crate) fn resolve_hook_runtime_config(cwd: &Path) -> Result { + resolve_hook_runtime_config_with( + cwd, + |key| std::env::var(key).ok(), + |path| { + std::fs::read_to_string(path) + .with_context(|| format!("Failed to read config file '{}'.", path.display())) + }, + Path::exists, + resolve_default_global_config_path, + ) +} + +pub(crate) fn resolve_auth_runtime_config_with( + cwd: &Path, + env_lookup: FEnv, + read_file: FRead, + path_exists: fn(&Path) -> bool, + resolve_global_config_path: FGlobalPath, +) -> Result +where + FEnv: Fn(&str) -> Option, + FRead: Fn(&Path) -> Result, + FGlobalPath: Fn() -> Result, +{ + let runtime = resolve_runtime_config_with( + &ConfigRequest { + report_format: ReportFormat::Text, + config_path: None, + log_level: None, + timeout_ms: None, + }, + cwd, + env_lookup, + read_file, + path_exists, + resolve_global_config_path, + )?; + + Ok(ResolvedAuthRuntimeConfig { + workos_client_id: runtime.workos_client_id, + }) +} + +pub(crate) fn resolve_observability_runtime_config_with( + cwd: &Path, + env_lookup: FEnv, + read_file: FRead, + path_exists: fn(&Path) -> bool, + resolve_global_config_path: FGlobalPath, +) -> Result +where + FEnv: Fn(&str) -> Option, + FRead: Fn(&Path) -> Result, + FGlobalPath: Fn() -> Result, +{ + let runtime = resolve_runtime_config_with( + &ConfigRequest { + report_format: ReportFormat::Text, + config_path: None, + log_level: None, + timeout_ms: None, + }, + cwd, + env_lookup, + read_file, + path_exists, + resolve_global_config_path, + )?; + + Ok(ResolvedObservabilityRuntimeConfig { + log_level: runtime.log_level.value, + log_format: runtime.log_format.value, + log_file: runtime.log_file.value, + log_file_mode: runtime.log_file_mode.value, + loaded_config_paths: runtime.loaded_config_paths, + validation_errors: runtime.validation_errors, + }) +} + +pub(crate) fn resolve_hook_runtime_config_with( + cwd: &Path, + env_lookup: FEnv, + read_file: FRead, + path_exists: fn(&Path) -> bool, + resolve_global_config_path: FGlobalPath, +) -> Result +where + FEnv: Fn(&str) -> Option, + FRead: Fn(&Path) -> Result, + FGlobalPath: Fn() -> Result, +{ + let runtime = resolve_runtime_config_with( + &ConfigRequest { + report_format: ReportFormat::Text, + config_path: None, + log_level: None, + timeout_ms: None, + }, + cwd, + env_lookup, + read_file, + path_exists, + resolve_global_config_path, + )?; + + Ok(ResolvedHookRuntimeConfig { + attribution_hooks_enabled: runtime.attribution_hooks_enabled.value, + }) +} + +pub(super) fn resolve_runtime_config(request: &ConfigRequest, cwd: &Path) -> Result { + resolve_runtime_config_with( + request, + cwd, + |key| std::env::var(key).ok(), + |path| { + std::fs::read_to_string(path) + .with_context(|| format!("Failed to read config file '{}'.", path.display())) + }, + Path::exists, + resolve_default_global_config_path, + ) +} + +#[allow(clippy::too_many_lines)] +fn resolve_runtime_config_with( + request: &ConfigRequest, + cwd: &Path, + env_lookup: FEnv, + read_file: FRead, + path_exists: fn(&Path) -> bool, + resolve_global_config_path: FGlobalPath, +) -> Result +where + FEnv: Fn(&str) -> Option, + FRead: Fn(&Path) -> Result, + FGlobalPath: Fn() -> Result, +{ + let loaded_config_paths = resolve_config_paths( + request, + cwd, + &env_lookup, + path_exists, + resolve_global_config_path, + )?; + + let mut file_config = schema::FileConfig { + log_level: None, + log_format: None, + log_file: None, + log_file_mode: None, + timeout_ms: None, + attribution_hooks_enabled: None, + workos_client_id: None, + bash_policy_presets: None, + bash_policy_custom: None, + }; + let mut validation_errors = Vec::new(); + for loaded_path in &loaded_config_paths { + let raw = read_file(&loaded_path.path)?; + let layer = match schema::parse_file_config(&raw, &loaded_path.path, loaded_path.source) { + Ok(layer) => layer, + Err(error) if loaded_path.source.is_default_discovered() => { + validation_errors.push(error.to_string()); + continue; + } + Err(error) => return Err(error), + }; + if let Some(log_level) = layer.log_level { + file_config.log_level = Some(log_level); + } + if let Some(log_format) = layer.log_format { + file_config.log_format = Some(log_format); + } + if let Some(log_file) = layer.log_file { + file_config.log_file = Some(log_file); + } + if let Some(log_file_mode) = layer.log_file_mode { + file_config.log_file_mode = Some(log_file_mode); + } + if let Some(timeout_ms) = layer.timeout_ms { + file_config.timeout_ms = Some(timeout_ms); + } + if let Some(attribution_hooks_enabled) = layer.attribution_hooks_enabled { + file_config.attribution_hooks_enabled = Some(attribution_hooks_enabled); + } + if let Some(workos_client_id) = layer.workos_client_id { + file_config.workos_client_id = Some(workos_client_id); + } + if let Some(bash_policy_presets) = layer.bash_policy_presets { + file_config.bash_policy_presets = Some(bash_policy_presets); + } + if let Some(bash_policy_custom) = layer.bash_policy_custom { + file_config.bash_policy_custom = Some(bash_policy_custom); + } + } + + let mut resolved_log_level = ResolvedValue { + value: LogLevel::Error, + source: ValueSource::Default, + }; + if let Some(value) = file_config.log_level { + resolved_log_level = ResolvedValue { + value: value.value, + source: ValueSource::ConfigFile(value.source), + }; + } + if let Some(raw) = env_lookup(ENV_LOG_LEVEL) { + resolved_log_level = ResolvedValue { + value: LogLevel::parse(&raw, ENV_LOG_LEVEL)?, + source: ValueSource::Env, + }; + } + if let Some(value) = request.log_level { + resolved_log_level = ResolvedValue { + value, + source: ValueSource::Flag, + }; + } + + let mut resolved_log_format = ResolvedValue { + value: LogFormat::Text, + source: ValueSource::Default, + }; + if let Some(value) = file_config.log_format { + resolved_log_format = ResolvedValue { + value: value.value, + source: ValueSource::ConfigFile(value.source), + }; + } + if let Some(raw) = env_lookup(ENV_LOG_FORMAT) { + resolved_log_format = ResolvedValue { + value: LogFormat::parse(&raw, ENV_LOG_FORMAT)?, + source: ValueSource::Env, + }; + } + + let mut resolved_log_file = ResolvedOptionalValue { + value: file_config + .log_file + .as_ref() + .map(|value| value.value.clone()), + source: file_config + .log_file + .as_ref() + .map(|value| ValueSource::ConfigFile(value.source)), + }; + if let Some(raw) = env_lookup(ENV_LOG_FILE) { + resolved_log_file = ResolvedOptionalValue { + value: Some(raw), + source: Some(ValueSource::Env), + }; + } + + let mut resolved_log_file_mode = ResolvedValue { + value: LogFileMode::Truncate, + source: ValueSource::Default, + }; + if let Some(value) = file_config.log_file_mode { + resolved_log_file_mode = ResolvedValue { + value: value.value, + source: ValueSource::ConfigFile(value.source), + }; + } + if let Some(raw) = env_lookup(ENV_LOG_FILE_MODE) { + resolved_log_file_mode = ResolvedValue { + value: LogFileMode::parse(&raw, ENV_LOG_FILE_MODE)?, + source: ValueSource::Env, + }; + } + if resolved_log_file.value.is_none() && resolved_log_file_mode.source != ValueSource::Default { + bail!( + "{ENV_LOG_FILE_MODE} requires {ENV_LOG_FILE}. Try: set {ENV_LOG_FILE} to a file path or unset {ENV_LOG_FILE_MODE}." + ); + } + + let mut resolved_timeout_ms = ResolvedValue { + value: DEFAULT_TIMEOUT_MS, + source: ValueSource::Default, + }; + if let Some(value) = file_config.timeout_ms { + resolved_timeout_ms = ResolvedValue { + value: value.value, + source: ValueSource::ConfigFile(value.source), + }; + } + if let Some(raw) = env_lookup("SCE_TIMEOUT_MS") { + let value = raw + .parse::() + .map_err(|_| anyhow!("Invalid timeout '{raw}' from SCE_TIMEOUT_MS."))?; + resolved_timeout_ms = ResolvedValue { + value, + source: ValueSource::Env, + }; + } + if let Some(value) = request.timeout_ms { + resolved_timeout_ms = ResolvedValue { + value, + source: ValueSource::Flag, + }; + } + + let mut resolved_attribution_hooks_enabled = ResolvedValue { + value: false, + source: ValueSource::Default, + }; + if let Some(value) = file_config.attribution_hooks_enabled { + resolved_attribution_hooks_enabled = ResolvedValue { + value: value.value, + source: ValueSource::ConfigFile(value.source), + }; + } + if let Some(raw) = env_lookup(ENV_ATTRIBUTION_HOOKS_ENABLED) { + resolved_attribution_hooks_enabled = ResolvedValue { + value: parse_bool_value_from( + ENV_ATTRIBUTION_HOOKS_ENABLED, + &raw, + ENV_ATTRIBUTION_HOOKS_ENABLED, + )?, + source: ValueSource::Env, + }; + } + + let resolved_workos_client_id = resolve_optional_auth_config_value( + WORKOS_CLIENT_ID_KEY, + file_config.workos_client_id, + &env_lookup, + ); + + let resolved_bash_policies = resolve_bash_policy_config( + file_config.bash_policy_presets.as_ref(), + file_config.bash_policy_custom.as_ref(), + ); + let validation_warnings = build_validation_warnings(&resolved_bash_policies); + + Ok(RuntimeConfig { + loaded_config_paths, + log_level: resolved_log_level, + log_format: resolved_log_format, + log_file: resolved_log_file, + log_file_mode: resolved_log_file_mode, + timeout_ms: resolved_timeout_ms, + attribution_hooks_enabled: resolved_attribution_hooks_enabled, + workos_client_id: resolved_workos_client_id, + bash_policies: resolved_bash_policies, + validation_errors, + validation_warnings, + }) +} + +fn resolve_optional_auth_config_value( + key: AuthConfigKeySpec, + file_value: Option>, + env_lookup: &FEnv, +) -> ResolvedOptionalValue +where + FEnv: Fn(&str) -> Option, +{ + if let Some(raw) = env_lookup(key.env_key) { + return ResolvedOptionalValue { + value: Some(raw), + source: Some(ValueSource::Env), + }; + } + + if let Some(value) = file_value { + return ResolvedOptionalValue { + value: Some(value.value), + source: Some(ValueSource::ConfigFile(value.source)), + }; + } + + if let Some(value) = key.baked_default { + return ResolvedOptionalValue { + value: Some(value.to_string()), + source: Some(ValueSource::Default), + }; + } + + ResolvedOptionalValue { + value: None, + source: None, + } +} + +fn resolve_config_paths( + request: &ConfigRequest, + cwd: &Path, + env_lookup: &FEnv, + path_exists: fn(&Path) -> bool, + resolve_global_config_path: FGlobalPath, +) -> Result> +where + FEnv: Fn(&str) -> Option, + FGlobalPath: Fn() -> Result, +{ + if let Some(path) = request.config_path.as_ref() { + if !path_exists(path) { + bail!( + "Config file '{}' was provided via --config but does not exist.", + path.display() + ); + } + return Ok(vec![LoadedConfigPath { + path: path.clone(), + source: ConfigPathSource::Flag, + }]); + } + + if let Some(raw) = env_lookup("SCE_CONFIG_FILE") { + let path = PathBuf::from(raw); + if !path_exists(&path) { + bail!( + "Config file '{}' was provided via SCE_CONFIG_FILE but does not exist.", + path.display() + ); + } + return Ok(vec![LoadedConfigPath { + path, + source: ConfigPathSource::Env, + }]); + } + + let mut discovered_paths = Vec::new(); + + let global_path = resolve_global_config_path()?; + if path_exists(&global_path) { + discovered_paths.push(LoadedConfigPath { + path: global_path, + source: ConfigPathSource::DefaultDiscoveredGlobal, + }); + } + + let local_path = RepoPaths::new(cwd).sce_config_file(); + if path_exists(&local_path) { + discovered_paths.push(LoadedConfigPath { + path: local_path, + source: ConfigPathSource::DefaultDiscoveredLocal, + }); + } + + Ok(discovered_paths) +} + +fn resolve_default_global_config_path() -> Result { + Ok(resolve_sce_default_locations()?.global_config_file()) +} diff --git a/cli/src/services/config/schema.rs b/cli/src/services/config/schema.rs index 0adfa011..fb3f34e4 100644 --- a/cli/src/services/config/schema.rs +++ b/cli/src/services/config/schema.rs @@ -32,7 +32,7 @@ pub(crate) const TOP_LEVEL_CONFIG_KEYS: &[&str] = &[ "log_file", "log_file_mode", "timeout_ms", - super::WORKOS_CLIENT_ID_KEY.config_key, + super::resolver::WORKOS_CLIENT_ID_KEY.config_key, "policies", ]; diff --git a/context/architecture.md b/context/architecture.md index 4df89878..25f8e75a 100644 --- a/context/architecture.md +++ b/context/architecture.md @@ -98,7 +98,7 @@ The repository includes a new placeholder Rust binary crate at `cli/`. - `cli/src/services/observability.rs` no longer owns duplicate log enums or parsing helpers; it consumes the canonical primitive seam from `cli/src/services/config/mod.rs` and stays focused on logger and telemetry runtime behavior. - `cli/src/cli_schema.rs` is now the canonical owner for top-level command metadata for the real clap-backed command set (`auth`, `config`, `setup`, `doctor`, `hooks`, `version`, `completion`), including the slim top-level help purpose text and per-command visibility on `sce`, `sce help`, and `sce --help`; `cli/src/command_surface.rs` remains the custom top-level help renderer and known-command classifier, adding the synthetic `help` row plus the ASCII banner while consuming that shared metadata instead of maintaining a parallel command catalog. - `cli/src/services/default_paths.rs` is the canonical production path catalog for the CLI: it resolves config/state/cache roots with platform-aware XDG or `dirs` fallbacks through an internal `roots` seam, exposes named default paths for current persisted artifacts and database files (global config, auth tokens, auth DB, local DB, agent trace DB), and owns canonical repo-relative, embedded-asset, install/runtime, hook, and context-path accessors so non-test production path definitions have one shared owner. Current production consumers such as config discovery, doctor reporting, setup/install flows, database adapters, and local hook runtime path resolution consume this shared catalog rather than defining owned path literals in their own modules. -- `cli/src/services/config/mod.rs` defines `sce config` parser/runtime contracts (`show`, `validate`, `--help`), with bare `sce config` routed by `cli/src/app.rs` to the same help payload as `sce config --help`, deterministic config-file selection, explicit value precedence (`flags > env > config file > defaults`), strict config-file validation (`$schema`, `log_level`, `log_format`, `log_file`, `log_file_mode`, `timeout_ms`, `workos_client_id`, `policies.bash`, and `policies.attribution_hooks.enabled`), compile-time embedding of the canonical generated schema artifact and bash-policy preset catalog from the ephemeral crate-local `cli/assets/generated/config/**` mirror prepared from canonical `config/` outputs before Cargo packaging/builds, runtime parity between that schema and the Rust-side top-level allowed-key gate so startup config discovery accepts the canonical `"$schema": "https://sce.crocoder.dev/config.json"` declaration, graceful fallback for parse/schema/top-level-object failures in default-discovered config files (collected as `validation_errors` while defaults continue to resolve), explicit-path hard failures for `--config` and `SCE_CONFIG_FILE`, shared auth-key resolution with optional baked defaults starting at `workos_client_id`, repo-configured bash-policy preset/custom validation and merged reporting from discovered config files, a shared attribution-hooks runtime gate resolved from `SCE_ATTRIBUTION_HOOKS_ENABLED` or `policies.attribution_hooks.enabled` with disabled-by-default semantics, shared observability-runtime resolution for logging config keys (`log_level`, `log_format`, `log_file`, `log_file_mode`) with deterministic source-aware env-over-config precedence reused by `cli/src/app.rs`, and deterministic text/JSON output rendering where `show` includes resolved observability/auth/policy values with provenance while `validate` now returns only validation status plus issues/warnings. +- `cli/src/services/config/mod.rs` is the config service facade and `sce config` orchestration surface (`show`, `validate`, `--help`), with bare `sce config` routed by `cli/src/app.rs` to the same help payload as `sce config --help`. Focused submodules own the implementation slices: `types.rs` owns shared config/runtime primitives, `schema.rs` owns generated schema embedding plus typed file parsing, `policy.rs` owns bash-policy semantic validation/format helpers, and `resolver.rs` owns deterministic config-file discovery, file-layer merging, explicit value precedence (`flags > env > config file > defaults` where flag-backed), shared auth-key resolution, observability-runtime resolution, attribution-hooks runtime gate resolution, default-discovered invalid-file degradation, and explicit-path fatal errors for `--config` / `SCE_CONFIG_FILE`. The facade preserves existing `services::config` imports for startup/auth/hooks callers while keeping deterministic text/JSON rendering in `mod.rs` until the render split is completed. - `cli/src/services/output_format.rs` defines the canonical shared CLI output-format contract (`OutputFormat`) for supporting commands, with deterministic `text|json` parsing and command-scoped actionable invalid-value guidance. - `cli/src/services/config/mod.rs` is also the canonical owner for the shared runtime/config primitive seam used by the CLI: `LogLevel`, `LogFormat`, `LogFileMode`, the observability env-key constants, and the shared bool parsing helpers used by both config resolution and observability bootstrap. - `cli/src/services/capabilities.rs` defines the current broad CLI dependency-injection capability traits consumed by `AppContext`: `FsOps` with `StdFsOps` for filesystem operations and `GitOps` with `ProcessGitOps` for git command execution plus repository-root/hooks-directory resolution. Existing services do not consume these traits internally yet; doctor/setup/hooks/config migration is deferred to later lifecycle/AppContext tasks. diff --git a/context/cli/config-precedence-contract.md b/context/cli/config-precedence-contract.md index 48533bd0..58319838 100644 --- a/context/cli/config-precedence-contract.md +++ b/context/cli/config-precedence-contract.md @@ -2,7 +2,7 @@ ## Scope -This contract documents the implemented `sce config` command behavior in `cli/src/services/config/mod.rs`, the canonical Pkl-authored `sce/config.json` schema artifact generated to `config/schema/sce-config.schema.json` and embedded there as `SCE_CONFIG_SCHEMA_JSON`, the typed serde DTO + mapping pipeline used for config-file parsing, and parser/dispatch wiring in `cli/src/app.rs`. +This contract documents the implemented `sce config` command behavior in `cli/src/services/config/mod.rs`, the runtime resolver in `cli/src/services/config/resolver.rs`, the canonical Pkl-authored `sce/config.json` schema artifact generated to `config/schema/sce-config.schema.json` and embedded by `cli/src/services/config/schema.rs` as `SCE_CONFIG_SCHEMA_JSON`, the typed serde DTO + mapping pipeline used for config-file parsing, and parser/dispatch wiring in `cli/src/app.rs`. The current implementation resolves flat logging keys with deterministic env-over-config precedence and source metadata, uses those resolved values in `cli/src/app.rs` / `cli/src/services/observability.rs` for runtime logging, exposes resolved-value inspection through `sce config show`, and keeps `sce config validate` focused on validation status plus errors/warnings. @@ -54,9 +54,9 @@ When a default-discovered global or repo-local config file exists but fails JSON ## Validation contract - The canonical JSON Schema artifact for both global and repo-local `sce/config.json` files is authored in `config/pkl/base/sce-config-schema.pkl` and generated to `config/schema/sce-config.schema.json`. -- `cli/src/services/config/mod.rs` embeds that generated artifact at compile time as `SCE_CONFIG_SCHEMA_JSON` and uses it for runtime schema validation before mapping parsed files into typed serde DTOs. +- `cli/src/services/config/schema.rs` embeds that generated artifact at compile time as `SCE_CONFIG_SCHEMA_JSON` and uses it for runtime schema validation before mapping parsed files into typed serde DTOs. - `sce config validate` and `sce doctor` both validate config-file structure against that shared generated schema before applying Rust-owned semantic checks such as duplicate custom `argv_prefix` detection and redundancy warnings. -- After schema validation, `cli/src/services/config/mod.rs` deserializes top-level and nested config structure (`policies`, `policies.bash`, `policies.attribution_hooks`) into typed serde DTOs and applies focused Rust-owned mapping helpers for enum conversion, source attribution, and policy-specific semantic checks. +- After schema validation, `cli/src/services/config/schema.rs` deserializes top-level and nested config structure (`policies`, `policies.bash`, `policies.attribution_hooks`) into typed serde DTOs and applies focused Rust-owned mapping helpers for enum conversion and source attribution; policy-specific semantic checks are owned by `cli/src/services/config/policy.rs`. - The canonical top-level schema declaration `"$schema": "https://sce.crocoder.dev/config.json"` is a supported config key for both explicit and discovered `sce/config.json` files, including command-startup paths like `sce version` and other config-loading commands that parse config before normal command dispatch. - Startup/runtime config resolution now degrades gracefully only for default-discovered files: invalid discovered files are skipped and reported via collected `validation_errors`, while explicit `--config` / `SCE_CONFIG_FILE` targets still fail immediately on the same parse or validation errors. @@ -117,3 +117,6 @@ When a default-discovered global or repo-local config file exists but fails JSON - `cli/src/app.rs` - `cli/src/command_surface.rs` - `cli/src/services/config/mod.rs` +- `cli/src/services/config/resolver.rs` +- `cli/src/services/config/schema.rs` +- `cli/src/services/config/policy.rs` diff --git a/context/context-map.md b/context/context-map.md index f57ff1ad..a01135e7 100644 --- a/context/context-map.md +++ b/context/context-map.md @@ -14,7 +14,7 @@ Feature/domain context: - `context/cli/default-path-catalog.md` (canonical production CLI path-ownership contract centered on `cli/src/services/default_paths.rs`, including persisted auth/config files, named DB paths for auth/local/Agent Trace databases, repo-relative, embedded-asset, install/runtime, hook, and context-path families plus the regression guard that keeps production path ownership centralized) - `context/cli/patch-service.md` (standalone patch domain model, parser, JSON load helpers, and set operations in `cli/src/services/patch.rs` for in-memory parsed unified-diff representation, capturing only touched lines plus minimal per-file/per-hunk metadata, supporting both `Index:` SVN-style and `diff --git` git-style formats, with `ParseError` for actionable malformed-input diagnostics, `PatchLoadError`/`load_patch_from_json`/`load_patch_from_json_bytes` for storage-agnostic JSON reconstruction, `intersect_patches` for target-shaped overlap with exact-match-first and historical `kind`+`content` fallback semantics plus matched-constructed-hunk `model_id` provenance inheritance, and `combine_patches` for ordered patch combination with later-wins conflict resolution plus winning-hunk `model_id` provenance inheritance; `parse_patch`, `intersect_patches`, and `combine_patches` are consumed by the active post-commit hook runtime) - `context/cli/styling-service.md` (CLI text-mode output styling with `owo-colors` and `comfy-table`, TTY/`NO_COLOR` policy, shared helper API for human-facing surfaces, and per-column right-to-left RGB gradient banner rendering) -- `context/cli/config-precedence-contract.md` (implemented `sce config` show/validate command contract, deterministic `flags > env > config file > defaults` resolution order, canonical `$schema` acceptance for startup-loaded `sce/config.json` files, shared auth-key env/config/optional baked-default support starting with `workos_client_id`, shared runtime resolution for flat logging observability keys, canonical Pkl-generated `sce/config.json` schema ownership plus CLI embedding/reuse contract, config-file selection order, `show` provenance output, trimmed `validate` output contract, and opt-in compiled-binary config-precedence E2E coverage contract) +- `context/cli/config-precedence-contract.md` (implemented `sce config` show/validate command contract, deterministic `flags > env > config file > defaults` resolution order, focused `config/resolver.rs` ownership for config discovery/merge/runtime precedence plus default-discovered invalid-file degradation, canonical `$schema` acceptance for startup-loaded `sce/config.json` files, shared auth-key env/config/optional baked-default support starting with `workos_client_id`, shared runtime resolution for flat logging observability keys, canonical Pkl-generated `sce/config.json` schema ownership plus CLI embedding/reuse contract, config-file selection order, `show` provenance output, and trimmed `validate` output contract) - `context/cli/capability-traits.md` (current broad CLI dependency-injection capability seam in `cli/src/services/capabilities.rs`, including `FsOps`/`StdFsOps`, `GitOps`/`ProcessGitOps`, git root/hooks resolution behavior, AppContext wiring with capability accessors plus repo-root-scoped context derivation, and test-only unimplemented stubs; current service internals do not consume these traits until later lifecycle migration tasks) - `context/cli/service-lifecycle.md` (current compile-safe `ServiceLifecycle` seam in `cli/src/services/lifecycle.rs`, including default no-op diagnose/fix/setup methods against `AppContext`, lifecycle-owned health/fix/setup result types, doctor/setup adapter boundaries, the shared lifecycle provider catalog/factory, hook/config/local_db/auth_db/agent_trace_db lifecycle providers, implemented doctor aggregation over diagnose/fix providers, and implemented setup aggregation over `setup` providers in order config → local_db → auth_db → agent_trace_db → hooks when requested) - `context/sce/cli-observability-contract.md` (implemented config-backed runtime observability contract for the flat logging config-file shape with env-over-config fallback, concrete logger/telemetry runtime behavior plus logger and object-safe telemetry trait boundaries, AppContext observability wiring including runtime-classified repeated telemetry action protection, operator-facing `sce config show` observability reporting, and the trimmed `sce config validate` status-only validation surface) diff --git a/context/glossary.md b/context/glossary.md index 363fdc7f..4b4cf731 100644 --- a/context/glossary.md +++ b/context/glossary.md @@ -75,7 +75,7 @@ - `CommandRegistry`: Statically populated command registry in `cli/src/services/command_registry.rs` that maps `&'static str` command names to zero-arg constructor functions (`fn() -> RuntimeCommandHandle`); populated at compile time via `build_default_registry()` and carried by `AppRuntime` during command dispatch. The `RuntimeCommand` trait and `RuntimeCommandHandle` type alias are co-located in the same module. Current registered constructors cover the full top-level command catalog: `help`, `auth`, `config`, `setup`, `doctor`, `hooks`, `version`, and `completion`; stateful parsed requests are constructed by `cli/src/services/parse/command_runtime.rs` when invocation options require per-command data. - `ServiceLifecycle`: Compile-safe lifecycle trait seam in `cli/src/services/lifecycle.rs` with default no-op `diagnose`, `fix`, and `setup` methods that accept `&AppContext`; it exposes lifecycle-owned health, fix, and setup result types, while doctor/setup adapt those records at orchestration boundaries before rendering command-owned output. The hooks service has `HooksLifecycle` for hook rollout diagnosis/fix/setup, the config service has `ConfigLifecycle` for global/repo-local config validation plus repo-local config bootstrap, local_db has `LocalDbLifecycle` for canonical local DB path health/bootstrap/setup, auth_db has `AuthDbLifecycle` for canonical auth DB path health/bootstrap/setup, and agent_trace_db has `AgentTraceDbLifecycle` for canonical Agent Trace DB path health/bootstrap/setup. Doctor runtime aggregates those providers for `diagnose` and `fix`; setup command now aggregates providers for `setup` in order (config → local_db → auth_db → agent_trace_db → hooks when requested). - `lifecycle provider catalog`: Shared factory in `cli/src/services/lifecycle.rs` (`lifecycle_providers(include_hooks)`) that returns boxed `ServiceLifecycle` providers in deterministic config → local_db → auth_db → agent_trace_db → hooks order, used by doctor with hooks included and by setup with hooks included only when requested. -- `sce config command surface`: Implemented top-level CLI command routed by `cli/src/app.rs` to `cli/src/services/config/mod.rs` (with schema/file-parsing delegated to `cli/src/services/config/schema.rs`), exposing `show`, `validate`, and `--help` for deterministic runtime config inspection and validation; `show` reports resolved flat logging observability values with provenance, while `validate` reports pass/fail plus validation issues and warnings only. +- `sce config command surface`: Implemented top-level CLI command routed by `cli/src/app.rs` to `cli/src/services/config/mod.rs` (with schema/file-parsing delegated to `cli/src/services/config/schema.rs` and runtime precedence resolution delegated to `cli/src/services/config/resolver.rs`), exposing `show`, `validate`, and `--help` for deterministic runtime config inspection and validation; `show` reports resolved flat logging observability values with provenance, while `validate` reports pass/fail plus validation issues and warnings only. - `sce version command surface`: Implemented top-level CLI command routed by `cli/src/app.rs` to `cli/src/services/version/mod.rs` plus `cli/src/services/version/command.rs`, exposing deterministic runtime identification output in text form by default and JSON form via `--format json`. - `sce version output contract`: `cli/src/services/version/mod.rs` rendering contract where text output is a deterministic single-line ` ()` payload and JSON output includes stable fields `status`, `command`, `binary`, `version`, and `build_profile`. - `sce completion command surface`: Implemented top-level CLI command routed by `cli/src/app.rs` to `cli/src/services/completion/mod.rs` plus `cli/src/services/completion/command.rs`, requiring `--shell ` and returning a deterministic shell completion script on stdout. @@ -86,10 +86,11 @@ - `FsOps`: Filesystem capability trait in `cli/src/services/capabilities.rs` with `read_file`, `write_file`, `metadata`, and `exists`, implemented in production by `StdFsOps`. - `GitOps`: Git capability trait in `cli/src/services/capabilities.rs` with `run_command`, `resolve_repository_root`, `resolve_hooks_directory`, and `is_available`, implemented in production by `ProcessGitOps`. - `SCE default path policy seam`: Canonical path resolver in `cli/src/services/default_paths.rs` that owns config/state/cache root resolution through an internal `roots` helper seam, named default paths, and an explicit inventory for the current default persisted artifacts (`global config`, `auth tokens`); named DB paths include `auth DB`, `local DB`, and `Agent Trace DB`. On Linux those defaults resolve to `$XDG_CONFIG_HOME/sce/config.json`, `$XDG_STATE_HOME/sce/auth/tokens.json`, `$XDG_STATE_HOME/sce/auth.db`, `$XDG_STATE_HOME/sce/local.db`, and `$XDG_STATE_HOME/sce/agent-trace.db` with platform-equivalent `dirs` fallbacks elsewhere. The same module is also the canonical owner for broader production CLI path definitions and is protected by a regression test that fails when new non-test production path literals are introduced outside `default_paths.rs`. -- `cli config precedence contract`: Deterministic runtime value resolution in `cli/src/services/config/mod.rs` with precedence `flags > env > config file > defaults` for flag-backed keys (`log_level`, `timeout_ms`) plus shared app-runtime observability keys (`log_format`, `log_file`, `log_file_mode`) consumed by `cli/src/app.rs`; config discovery order is `--config`, `SCE_CONFIG_FILE`, then default discovered global+local paths (`${config_root}/sce/config.json` merged before `.sce/config.json`, with local overriding per key, where `config_root` comes from the shared default path policy seam and resolves to `$XDG_CONFIG_HOME` / `dirs::config_dir()` semantics with platform fallback behavior rather than the old state/data-root default). Runtime startup config loading now also permits the canonical top-level `"$schema": "https://sce.crocoder.dev/config.json"` declaration anywhere those config files are parsed (parsing delegated to `schema.rs`). +- `cli config precedence contract`: Deterministic runtime value resolution in `cli/src/services/config/resolver.rs` with precedence `flags > env > config file > defaults` for flag-backed keys (`log_level`, `timeout_ms`) plus shared app-runtime observability keys (`log_format`, `log_file`, `log_file_mode`) consumed by `cli/src/app.rs`; config discovery order is `--config`, `SCE_CONFIG_FILE`, then default discovered global+local paths (`${config_root}/sce/config.json` merged before `.sce/config.json`, with local overriding per key, where `config_root` comes from the shared default path policy seam and resolves to `$XDG_CONFIG_HOME` / `dirs::config_dir()` semantics with platform fallback behavior rather than the old state/data-root default). Runtime startup config loading permits the canonical top-level `"$schema": "https://sce.crocoder.dev/config.json"` declaration anywhere those config files are parsed (parsing delegated to `schema.rs`). - `shared runtime/config primitives seam`: Canonical ownership in `cli/src/services/config/types.rs` for the CLI's shared observability/config enums (`LogLevel`, `LogFormat`, `LogFileMode`), request/response primitives (`ConfigSubcommand`, `ConfigRequest`, `ReportFormat`), source metadata types (`ValueSource`, `ConfigPathSource`, `LoadedConfigPath`, `ResolvedValue`, `ResolvedOptionalValue`), resolved runtime config types (`ResolvedAuthRuntimeConfig`, `ResolvedObservabilityRuntimeConfig`, `ResolvedHookRuntimeConfig`), the `NAME` constant, observability env-key constants, and shared bool parsing helpers; re-exported through `cli/src/services/config/mod.rs` via `pub use types::*` so downstream modules continue importing through `services::config` unchanged. - `config schema and file parsing seam`: Canonical ownership in `cli/src/services/config/schema.rs` for the CLI's JSON Schema embedding (`SCE_CONFIG_SCHEMA_JSON`), `OnceLock` validator (`CONFIG_SCHEMA_VALIDATOR`, `config_schema_validator()`), top-level allowed-key validation (`TOP_LEVEL_CONFIG_KEYS`, `validate_object_keys`), serde DTO definitions (`ParsedFileConfigDocument`, `ParsedPoliciesConfigDocument`, `ParsedBashPolicyConfigDocument`, `ParsedAttributionHooksConfigDocument`, `ParsedCustomBashPolicyEntryDocument`, `ParsedCustomBashPolicyMatchDocument`), file config value wrapper (`FileConfigValue`) and aggregate (`FileConfig`), type aliases (`ParsedBashPolicyConfig`, `ParsedFilePolicies`), and config-file load/parse helpers (`validate_config_file`, `parse_file_config`, `deserialize_typed_config`, `map_policies_config`, `map_attribution_hooks_config`, `map_bash_policy_config`); `validate_config_file` is re-exported `pub(crate)` through `mod.rs` for `lifecycle.rs` and `doctor` consumers. Policy parsing helpers (`parse_bash_policy_presets`, `parse_custom_bash_policies`) and `CustomBashPolicyEntry` are imported from `super::policy` rather than the parent module. - `config policy semantic validation seam`: Canonical ownership in `cli/src/services/config/policy.rs` for the CLI's bash-policy and attribution-hooks semantic validation, merge helpers, and policy rendering: built-in/custom bash-policy catalog types and OnceLock (`BuiltinBashPolicyCatalog`, `BuiltinBashPolicyPreset`, `BuiltinBashPolicyMatcher`, `BuiltinBashPolicyRedundancyWarning`, `BUILTIN_BASH_POLICY_CATALOG`, `BASH_POLICY_PRESET_CATALOG_JSON`), policy config types (`BashPolicyConfig`, `CustomBashPolicyEntry`), catalog accessors (`builtin_bash_policy_catalog`, `builtin_bash_policy_preset_ids`, `is_builtin_bash_policy_preset_id`), policy parsing and validation (`parse_bash_policy_presets`, `parse_custom_bash_policies`, `parse_custom_bash_policy_entry`, `parse_custom_bash_policy_match`, `parse_custom_bash_policy_argv_prefix`), policy resolution (`resolve_bash_policy_config`, `build_validation_warnings`), and policy rendering (`format_bash_policies_text`, `format_bash_policies_json`); `mod.rs` imports `BashPolicyConfig`, `build_validation_warnings`, `format_bash_policies_json`, `format_bash_policies_text`, and `resolve_bash_policy_config` from `policy` for resolution and rendering consumers. +- `config runtime resolver seam`: Canonical ownership in `cli/src/services/config/resolver.rs` for config-file discovery, file-layer merging, env/flag/default precedence resolution, shared auth-key resolution (`workos_client_id`), observability runtime resolution, attribution-hooks gate resolution, and default-discovered invalid-file degradation; `cli/src/services/config/mod.rs` delegates `sce config show|validate` runtime resolution to this seam while facade re-exports preserve startup/auth/hooks callers through `services::config`. - `sce config schema artifact`: Canonical JSON Schema for global and repo-local `sce/config.json` files, authored in `config/pkl/base/sce-config-schema.pkl`, generated to `config/schema/sce-config.schema.json`, and embedded by `cli/src/services/config/schema.rs` for shared `sce config validate` and doctor config validation. The current schema accepts the canonical `$schema` declaration, flat logging keys (`log_level`, `log_format`, `log_file`, `log_file_mode`), existing auth/config keys, and enforces the schema-level dependency that `log_file_mode` requires `log_file`. - `bash tool policy config surface`: Nested repo config namespace under `.sce/config.json` at `policies.bash`, currently supporting unique built-in `presets` plus repo-owned `custom` argv-prefix rules with deterministic validation, merged global/local resolution, and first-class `sce config show|validate` reporting. - `attribution hooks gate`: Disabled-default local hook runtime gate resolved through shared config precedence in `cli/src/services/config/mod.rs` (with parsing in `schema.rs`): env `SCE_ATTRIBUTION_HOOKS_ENABLED` overrides repo/global config key `policies.attribution_hooks.enabled`, and the current enabled path activates commit-msg-only attribution without re-enabling trace persistence. diff --git a/context/overview.md b/context/overview.md index 08af6a12..380af178 100644 --- a/context/overview.md +++ b/context/overview.md @@ -23,6 +23,7 @@ Invalid default-discovered config files now also degrade gracefully at startup: `cli/src/services/config/mod.rs` is now a module facade that declares `pub mod types`, `pub mod schema`, `pub mod policy`, and `pub mod command`/`pub mod lifecycle`, re-exporting `pub use types::*` and `pub(crate) use schema::validate_config_file`. Shared config primitive ownership is delegated to `cli/src/services/config/types.rs` (`LogLevel`, `LogFormat`, `LogFileMode`, `ConfigSubcommand`, `ConfigRequest`, `ReportFormat`, source metadata types, resolved runtime config types, the `NAME` constant, observability env-key constants, and `parse_bool_value_from`). Config schema loading and file parsing ownership is delegated to `cli/src/services/config/schema.rs` (JSON Schema embedding/validator, top-level allowed-key validation, serde DTO definitions, `FileConfig`/`FileConfigValue`, type aliases, `validate_config_file`, `parse_file_config`, `deserialize_typed_config`, `map_policies_config`, `map_attribution_hooks_config`, `map_bash_policy_config`). Config policy semantic validation and rendering ownership is delegated to `cli/src/services/config/policy.rs` (`BashPolicyConfig`, `CustomBashPolicyEntry`, built-in catalog types and OnceLock, `is_builtin_bash_policy_preset_id`, `parse_bash_policy_presets`, `parse_custom_bash_policies`, `resolve_bash_policy_config`, `build_validation_warnings`, `format_bash_policies_text`, `format_bash_policies_json`). Downstream modules continue importing through `services::config` unchanged. The CLI now has a minimal `AppContext` dependency-injection container in `cli/src/app.rs` holding `Arc`, `Arc`, `Arc`, `Arc`, and an optional `repo_root: Option`; it can derive repo-root-scoped contexts with `with_repo_root(...)` while preserving runtime dependencies. The broad capability seam lives in `cli/src/services/capabilities.rs`, where `FsOps`/`StdFsOps` wrap filesystem operations and `GitOps`/`ProcessGitOps` wrap git process execution plus repository-root/hooks-directory resolution. Current services have not migrated to consume the filesystem/git traits internally yet. The shared default path service in `cli/src/services/default_paths.rs` is now the canonical owner for production CLI path definitions. It resolves per-user config/state/cache roots through a dedicated internal `roots` seam, exposes the current persisted-artifact inventory (global config and auth tokens), and also defines named DB paths (auth DB, local DB, Agent Trace DB) plus the repo-relative, embedded-asset, install/runtime, hook, and context-path accessors consumed across current CLI production code. Non-test production modules should consume this shared catalog instead of hardcoding owned path literals. No default cache-backed persisted artifact currently exists, so cache-root resolution remains available without speculative cache-path features and no legacy default-path fallback is supported. The same config resolver now also owns the attribution-hooks gate used by local hook runtime: `SCE_ATTRIBUTION_HOOKS_ENABLED` overrides `policies.attribution_hooks.enabled`, and the gate defaults to disabled. +The config service split now includes `cli/src/services/config/resolver.rs` as the focused owner for config-file discovery, file-layer merging, env/flag/default precedence, auth-key resolution, observability resolution, attribution-hooks resolution, and default-discovered invalid-file degradation; `cli/src/services/config/mod.rs` remains the facade/rendering orchestration surface while preserving existing `services::config` imports. Generated config now includes repo-local OpenCode plugin assets for both profiles: `sce-bash-policy.ts` plus `sce-agent-trace.ts` are emitted under `config/.opencode/plugins/` and `config/automated/.opencode/plugins/`; the agent-trace plugin extracts `{ sessionID, diff, time, model_id }` from user `message.updated` events with diffs, tracks per-session OpenCode client version from `session.created`/`session.updated`, and sends payloads to `sce hooks diff-trace` with `tool_name="opencode"` plus optional `tool_version`; the Rust hook continues to validate required fields and persists `model_id`, `tool_name`, and nullable `tool_version` into `diff_traces` through AgentTraceDb. Bash-policy also emits shared runtime logic and preset data under `config/.opencode/lib/` (also emitted for `config/automated/.opencode/**`). Claude bash-policy enforcement has been removed from generated outputs. The `doctor` command now exposes explicit inspection mode (`sce doctor`) and repair-intent mode (`sce doctor --fix`) at the CLI/help/schema level while keeping diagnosis mode read-only. It now validates both current global operator health and the current repo/hook-integrity slice: state-root resolution, global config path resolution, global and repo-local `sce/config.json` readability/schema validity, local DB and Agent Trace DB path + health, DB parent-directory readiness, git availability, non-repo vs bare-repo targeting failures, effective git hook-path source (default, per-repo `core.hooksPath`, or global `core.hooksPath`), hooks-directory health, required hook presence/executable permissions/content drift against canonical embedded SCE-managed hook assets, and repo-root OpenCode integration presence across the installed `plugins`, `agents`, `commands`, and `skills` inventories with embedded SHA-256 content verification for OpenCode assets. Text mode now renders the approved human-only layout with ordered `Environment` / `Configuration` / `Repository` / `Git Hooks` / `Integrations` sections, `SCE doctor diagnose` / `SCE doctor fix` headers, bracketed `[PASS]`/`[FAIL]`/`[MISS]` status tokens, shared-style green pass plus red fail/miss coloring when color output is enabled, simplified `label (path)` row formatting, top-level-only hook rows, and integration parent/child rows that reflect missing vs content-mismatch states; JSON output now reports Agent Trace DB health under `agent_trace_db` (as a row within the Configuration section in text mode). Repo-scoped database reporting is empty by default because no repo-owned SCE database currently exists. Fix mode reuses the canonical setup hook install flow to repair missing/stale/non-executable required hooks and can also bootstrap missing canonical DB parent directories while preserving manual-only guidance for unsupported issues. Local database bootstrap is now owned by `LocalDbLifecycle::setup` and `AgentTraceDbLifecycle::setup` aggregated by the setup command, while doctor validates both DB paths/health and can bootstrap missing parent directories. Wiring a user-invocable `sce sync` command is deferred to `0.4.0`. diff --git a/context/plans/cli-maintenance-hazards.md b/context/plans/cli-maintenance-hazards.md index cd291cff..217b8d66 100644 --- a/context/plans/cli-maintenance-hazards.md +++ b/context/plans/cli-maintenance-hazards.md @@ -84,12 +84,16 @@ Resolve the architectural/maintenance hazards in the Rust CLI without changing u - Evidence: `nix flake check` passed (cli-tests, cli-clippy, cli-fmt, pkl-parity all green); `nix run .#pkl-check-generated` passed. No public API changes required outside `config/` module; all existing callers continue importing through `services::config` unchanged. - Notes: Created `cli/src/services/config/policy.rs` containing `BashPolicyConfig`, `BuiltinBashPolicyCatalog`, `BuiltinBashPolicyPreset`, `BuiltinBashPolicyMatcher`, `BuiltinBashPolicyRedundancyWarning`, `CustomBashPolicyEntry`, `BUILTIN_BASH_POLICY_CATALOG` OnceLock, `BASH_POLICY_PRESET_CATALOG_JSON` include_str, `builtin_bash_policy_catalog()`, `builtin_bash_policy_preset_ids()`, `is_builtin_bash_policy_preset_id()`, `parse_bash_policy_presets()`, `parse_custom_bash_policies()`, `parse_custom_bash_policy_entry()`, `parse_custom_bash_policy_match()`, `parse_custom_bash_policy_argv_prefix()`, `resolve_bash_policy_config()`, `build_validation_warnings()`, `format_bash_policies_text()`, `format_bash_policies_json()`. `mod.rs` now declares `pub mod policy` and imports `BashPolicyConfig`, `build_validation_warnings`, `format_bash_policies_json`, `format_bash_policies_text`, `resolve_bash_policy_config` from `policy`. `schema.rs` now imports `parse_bash_policy_presets`, `parse_custom_bash_policies`, `CustomBashPolicyEntry` from `super::policy` instead of `super`. -- [ ] T06: `Extract runtime config resolution and precedence flow` (status:todo) +- [x] T06: `Extract runtime config resolution and precedence flow` (status:done) - Task ID: T06 - Goal: Move config-file discovery, merge order, env/flag/default precedence, auth-key resolution, observability resolution, and invalid-default-discovered fallback flow into a focused resolver submodule. - Boundaries (in/out of scope): In - resolution functions consumed by startup, `sce config show/validate`, auth runtime, observability runtime, and attribution-hooks gate; source/provenance preservation. Out - rendering output shape, schema policy changes, adding new keys. - Done when: precedence behavior remains `flags > env > config file > defaults` where applicable; default-discovered invalid config still degrades gracefully while explicit config remains fatal; `mod.rs` delegates resolution instead of containing it inline. - Verification notes (commands or checks): Prefer `nix flake check`; include focused tests/smoke checks for `sce config show`, `sce config validate`, startup config loading, and attribution-hooks gate if available. + - Completed: 2026-06-08 + - Files changed: `cli/src/services/config/mod.rs`, `cli/src/services/config/resolver.rs`, `cli/src/services/config/schema.rs` + - Evidence: `nix develop -c sh -c 'cd cli && cargo fmt'` passed after adding `cli/src/services/config/resolver.rs` to the git index so Nix source filtering included the new module; `nix develop -c sh -c 'cd cli && cargo fmt --check'` was blocked by repo policy in favor of `nix flake check`; user reported `nix flake check` was run. + - Notes: Created `cli/src/services/config/resolver.rs` to own runtime config discovery, file-layer merging, env/flag/default precedence, auth-key resolution, observability resolution, attribution-hooks resolution, and default-discovered invalid-file degradation. `mod.rs` now delegates runtime resolution through the resolver while preserving facade re-exports for existing startup/auth/hooks callers. `schema.rs` now references the resolver-owned `WORKOS_CLIENT_ID_KEY` for the top-level config-key list. - [ ] T07: `Extract config text and JSON rendering` (status:todo) - Task ID: T07 From ace59048fe2c85224728e009e35a031a73abf311 Mon Sep 17 00:00:00 2001 From: David Abram Date: Mon, 8 Jun 2026 17:40:58 +0200 Subject: [PATCH 7/7] config: Extract config rendering to render.rs and complete plan tasks Move sce config show and sce config validate text/JSON output construction from the monolithic mod.rs into a focused render.rs submodule. This includes rendering-specific config-path formatting, resolved-value formatting, validation issue/warning rendering, and auth display-value redaction/abbreviation helpers. mod.rs is now a thin facade that delegates runtime resolution to resolver.rs and output formatting to render.rs, while preserving the existing services::config import surface for downstream callers. Co-authored-by: SCE --- cli/src/services/config/mod.rs | 368 +--------------------- cli/src/services/config/render.rs | 367 +++++++++++++++++++++ context/architecture.md | 4 +- context/cli/config-precedence-contract.md | 3 +- context/context-map.md | 2 +- context/glossary.md | 3 +- context/overview.md | 2 +- context/plans/cli-maintenance-hazards.md | 45 ++- 8 files changed, 422 insertions(+), 372 deletions(-) create mode 100644 cli/src/services/config/render.rs diff --git a/cli/src/services/config/mod.rs b/cli/src/services/config/mod.rs index ebd8349c..5a6837cb 100644 --- a/cli/src/services/config/mod.rs +++ b/cli/src/services/config/mod.rs @@ -5,18 +5,13 @@ pub mod resolver; pub mod schema; pub mod types; +mod render; + pub use types::*; use anyhow::{Context, Result}; -use serde_json::{json, Value}; - -use crate::services::style; - -use policy::{format_bash_policies_json, format_bash_policies_text}; -use resolver::{ - resolve_runtime_config, AuthConfigKeySpec, RuntimeConfig, PRECEDENCE_DESCRIPTION, - WORKOS_CLIENT_ID_KEY, -}; +use render::{format_show_output, format_validate_output}; +use resolver::resolve_runtime_config; pub(crate) use resolver::{ resolve_auth_runtime_config, resolve_hook_runtime_config, resolve_observability_runtime_config, @@ -37,358 +32,3 @@ pub fn run_config_subcommand(subcommand: ConfigSubcommand) -> Result { } } } - -fn format_show_output(runtime: &RuntimeConfig, report_format: ReportFormat) -> String { - let warnings = build_show_warnings(runtime); - match report_format { - ReportFormat::Text => { - let mut lines = vec![ - format!( - "{}: {}", - style::success("SCE config"), - style::value("resolved") - ), - format!( - "{}: {}", - style::label("Precedence"), - style::value(PRECEDENCE_DESCRIPTION) - ), - format_config_paths_text(runtime), - format_resolved_value_text( - "timeout_ms", - &runtime.timeout_ms.value.to_string(), - runtime.timeout_ms.source, - ), - format_optional_auth_resolved_value_text( - WORKOS_CLIENT_ID_KEY, - &runtime.workos_client_id, - ), - format_bash_policies_text(&runtime.bash_policies), - format_validation_warnings_text(&warnings), - ]; - lines.splice(3..3, format_observability_text_lines(runtime)); - lines.join("\n") - } - ReportFormat::Json => { - let payload = json!({ - "status": "ok", - "result": { - "command": "config_show", - "precedence": PRECEDENCE_DESCRIPTION, - "config_paths": format_config_paths_json(runtime), - "resolved": { - "log_level": format_resolved_value_json( - runtime.log_level.value.as_str(), - runtime.log_level.source, - ), - "log_format": format_resolved_value_json( - runtime.log_format.value.as_str(), - runtime.log_format.source, - ), - "log_file": format_optional_resolved_value_json(&runtime.log_file), - "log_file_mode": format_resolved_value_json( - runtime.log_file_mode.value.as_str(), - runtime.log_file_mode.source, - ), - "timeout_ms": { - "value": runtime.timeout_ms.value, - "source": runtime.timeout_ms.source.as_str(), - "config_source": runtime.timeout_ms.source.config_source().map(ConfigPathSource::as_str), - }, - "workos_client_id": format_optional_auth_resolved_value_json(WORKOS_CLIENT_ID_KEY, &runtime.workos_client_id), - "policies": { - "bash": format_bash_policies_json(&runtime.bash_policies), - } - }, - "warnings": warnings, - } - }); - serde_json::to_string_pretty(&payload).expect("config show payload should serialize") - } - } -} - -fn format_validate_output(runtime: &RuntimeConfig, report_format: ReportFormat) -> String { - let valid = runtime.validation_errors.is_empty(); - match report_format { - ReportFormat::Text => { - let lines = [ - format!( - "{}: {}", - style::success("SCE config validation"), - style::value(if valid { "valid" } else { "invalid" }) - ), - format_validation_issues_text(&runtime.validation_errors), - format_validation_warnings_text(&runtime.validation_warnings), - ]; - lines.join("\n") - } - ReportFormat::Json => { - let payload = json!({ - "status": "ok", - "result": { - "command": "config_validate", - "valid": valid, - "issues": runtime.validation_errors, - "warnings": runtime.validation_warnings, - } - }); - serde_json::to_string_pretty(&payload) - .expect("config validate payload should serialize") - } - } -} - -fn format_config_paths_text(runtime: &RuntimeConfig) -> String { - if runtime.loaded_config_paths.is_empty() { - return format!( - "{}: {}", - style::label("Config files"), - style::value("(none discovered)") - ); - } - - let mut lines = vec![format!("{}:", style::label("Config files"))]; - for path in &runtime.loaded_config_paths { - lines.push(format!( - " - {} (source: {})", - style::value(&path.path.display().to_string()), - style::label(path.source.as_str()) - )); - } - lines.join("\n") -} - -fn format_config_paths_json(runtime: &RuntimeConfig) -> Value { - Value::Array( - runtime - .loaded_config_paths - .iter() - .map(|path| { - json!({ - "path": path.path.display().to_string(), - "source": path.source.as_str(), - }) - }) - .collect(), - ) -} - -fn build_show_warnings(runtime: &RuntimeConfig) -> Vec { - let mut warnings = runtime - .validation_errors - .iter() - .map(|error| format!("Skipped invalid config: {error}")) - .collect::>(); - warnings.extend(runtime.validation_warnings.iter().cloned()); - warnings -} - -fn format_validation_issues_text(issues: &[String]) -> String { - if issues.is_empty() { - return format!( - "{}: {}", - style::label("Validation issues"), - style::value("none") - ); - } - - format!( - "{}: {}", - style::label("Validation issues"), - style::value(&issues.join(" | ")) - ) -} - -fn format_validation_warnings_text(warnings: &[String]) -> String { - if warnings.is_empty() { - return format!( - "{}: {}", - style::label("Validation warnings"), - style::value("none") - ); - } - - format!( - "{}: {}", - style::label("Validation warnings"), - style::value(&warnings.join(" | ")) - ) -} - -fn format_observability_text_lines(runtime: &RuntimeConfig) -> Vec { - vec![ - format_resolved_value_text( - "log_level", - runtime.log_level.value.as_str(), - runtime.log_level.source, - ), - format_resolved_value_text( - "log_format", - runtime.log_format.value.as_str(), - runtime.log_format.source, - ), - format_optional_resolved_value_text("log_file", &runtime.log_file), - format_resolved_value_text( - "log_file_mode", - runtime.log_file_mode.value.as_str(), - runtime.log_file_mode.source, - ), - ] -} - -fn format_resolved_value_json(value: T, source: ValueSource) -> Value -where - T: serde::Serialize, -{ - json!({ - "value": value, - "source": source.as_str(), - "config_source": source.config_source().map(ConfigPathSource::as_str), - }) -} - -fn format_resolved_value_text(key: &str, value_text: &str, source: ValueSource) -> String { - match source.config_source() { - Some(config_source) => format!( - "- {}: {} (source: {}, config_source: {})", - style::label(key), - style::value(value_text), - style::label(source.as_str()), - style::label(config_source.as_str()) - ), - None => format!( - "- {}: {} (source: {})", - style::label(key), - style::value(value_text), - style::label(source.as_str()) - ), - } -} - -fn format_optional_resolved_value_text(key: &str, value: &ResolvedOptionalValue) -> String { - match (value.value.as_deref(), value.source) { - (Some(raw_value), Some(source)) => match source.config_source() { - Some(config_source) => format!( - "- {}: {} (source: {}, config_source: {})", - style::label(key), - style::value(raw_value), - style::label(source.as_str()), - style::label(config_source.as_str()) - ), - None => format!( - "- {}: {} (source: {})", - style::label(key), - style::value(raw_value), - style::label(source.as_str()) - ), - }, - _ => format!( - "- {}: {} (source: {})", - style::label(key), - style::value("(unset)"), - style::label("none") - ), - } -} - -fn format_optional_resolved_value_json(value: &ResolvedOptionalValue) -> Value { - json!({ - "value": value.value, - "source": value.source.map(ValueSource::as_str), - "config_source": value.source.and_then(ValueSource::config_source).map(ConfigPathSource::as_str), - }) -} - -fn format_optional_auth_resolved_value_text( - key: AuthConfigKeySpec, - value: &ResolvedOptionalValue, -) -> String { - match (value.value.as_deref(), value.source) { - (Some(raw_value), Some(source)) => { - let display_value = format_text_display_value(key.config_key, raw_value); - match source.config_source() { - Some(config_source) => format!( - "- {}: {} (source: {}, config_source: {}, auth_precedence: {})", - style::label(key.config_key), - style::value(&display_value), - style::label(source.as_str()), - style::label(config_source.as_str()), - style::value(&key.precedence_description()) - ), - None => format!( - "- {}: {} (source: {}, auth_precedence: {})", - style::label(key.config_key), - style::value(&display_value), - style::label(source.as_str()), - style::value(&key.precedence_description()) - ), - } - } - _ => format!( - "- {}: {} (source: {}, auth_precedence: {})", - style::label(key.config_key), - style::value("(unset)"), - style::label("none"), - style::value(&key.precedence_description()) - ), - } -} - -fn format_optional_auth_resolved_value_json( - key: AuthConfigKeySpec, - value: &ResolvedOptionalValue, -) -> Value { - json!({ - "value": value.value, - "display_value": value.value.as_deref().map(|raw| format_text_display_value(key.config_key, raw)), - "source": value.source.map(ValueSource::as_str), - "config_source": value.source.and_then(ValueSource::config_source).map(ConfigPathSource::as_str), - "precedence": key.precedence_description(), - }) -} - -fn format_text_display_value(key: &str, value: &str) -> String { - if should_fully_redact_text_value(key) { - return String::from("[REDACTED]"); - } - - if looks_credential_like(value) { - return abbreviate_text_value(value); - } - - value.to_string() -} - -fn should_fully_redact_text_value(key: &str) -> bool { - let key = key.to_ascii_lowercase(); - ["password", "passwd", "secret", "token", "api_key", "apikey"] - .iter() - .any(|needle| key.contains(needle)) -} - -fn looks_credential_like(value: &str) -> bool { - let trimmed = value.trim(); - trimmed.len() >= 16 - && trimmed - .chars() - .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '/')) -} - -fn abbreviate_text_value(value: &str) -> String { - let total = value.chars().count(); - if total <= 8 { - return value.to_string(); - } - - let prefix: String = value.chars().take(4).collect(); - let suffix: String = value - .chars() - .rev() - .take(4) - .collect::>() - .into_iter() - .rev() - .collect(); - format!("{prefix}...{suffix}") -} diff --git a/cli/src/services/config/render.rs b/cli/src/services/config/render.rs new file mode 100644 index 00000000..6959ba15 --- /dev/null +++ b/cli/src/services/config/render.rs @@ -0,0 +1,367 @@ +use serde_json::{json, Value}; + +use crate::services::style; + +use super::policy::{format_bash_policies_json, format_bash_policies_text}; +use super::resolver::{ + AuthConfigKeySpec, RuntimeConfig, PRECEDENCE_DESCRIPTION, WORKOS_CLIENT_ID_KEY, +}; +use super::{ConfigPathSource, ReportFormat, ResolvedOptionalValue, ValueSource}; + +pub(super) fn format_show_output(runtime: &RuntimeConfig, report_format: ReportFormat) -> String { + let warnings = build_show_warnings(runtime); + match report_format { + ReportFormat::Text => { + let mut lines = vec![ + format!( + "{}: {}", + style::success("SCE config"), + style::value("resolved") + ), + format!( + "{}: {}", + style::label("Precedence"), + style::value(PRECEDENCE_DESCRIPTION) + ), + format_config_paths_text(runtime), + format_resolved_value_text( + "timeout_ms", + &runtime.timeout_ms.value.to_string(), + runtime.timeout_ms.source, + ), + format_optional_auth_resolved_value_text( + WORKOS_CLIENT_ID_KEY, + &runtime.workos_client_id, + ), + format_bash_policies_text(&runtime.bash_policies), + format_validation_warnings_text(&warnings), + ]; + lines.splice(3..3, format_observability_text_lines(runtime)); + lines.join("\n") + } + ReportFormat::Json => { + let payload = json!({ + "status": "ok", + "result": { + "command": "config_show", + "precedence": PRECEDENCE_DESCRIPTION, + "config_paths": format_config_paths_json(runtime), + "resolved": { + "log_level": format_resolved_value_json( + runtime.log_level.value.as_str(), + runtime.log_level.source, + ), + "log_format": format_resolved_value_json( + runtime.log_format.value.as_str(), + runtime.log_format.source, + ), + "log_file": format_optional_resolved_value_json(&runtime.log_file), + "log_file_mode": format_resolved_value_json( + runtime.log_file_mode.value.as_str(), + runtime.log_file_mode.source, + ), + "timeout_ms": { + "value": runtime.timeout_ms.value, + "source": runtime.timeout_ms.source.as_str(), + "config_source": runtime.timeout_ms.source.config_source().map(ConfigPathSource::as_str), + }, + "workos_client_id": format_optional_auth_resolved_value_json(WORKOS_CLIENT_ID_KEY, &runtime.workos_client_id), + "policies": { + "bash": format_bash_policies_json(&runtime.bash_policies), + } + }, + "warnings": warnings, + } + }); + serde_json::to_string_pretty(&payload).expect("config show payload should serialize") + } + } +} + +pub(super) fn format_validate_output( + runtime: &RuntimeConfig, + report_format: ReportFormat, +) -> String { + let valid = runtime.validation_errors.is_empty(); + match report_format { + ReportFormat::Text => { + let lines = [ + format!( + "{}: {}", + style::success("SCE config validation"), + style::value(if valid { "valid" } else { "invalid" }) + ), + format_validation_issues_text(&runtime.validation_errors), + format_validation_warnings_text(&runtime.validation_warnings), + ]; + lines.join("\n") + } + ReportFormat::Json => { + let payload = json!({ + "status": "ok", + "result": { + "command": "config_validate", + "valid": valid, + "issues": runtime.validation_errors, + "warnings": runtime.validation_warnings, + } + }); + serde_json::to_string_pretty(&payload) + .expect("config validate payload should serialize") + } + } +} + +fn format_config_paths_text(runtime: &RuntimeConfig) -> String { + if runtime.loaded_config_paths.is_empty() { + return format!( + "{}: {}", + style::label("Config files"), + style::value("(none discovered)") + ); + } + + let mut lines = vec![format!("{}:", style::label("Config files"))]; + for path in &runtime.loaded_config_paths { + lines.push(format!( + " - {} (source: {})", + style::value(&path.path.display().to_string()), + style::label(path.source.as_str()) + )); + } + lines.join("\n") +} + +fn format_config_paths_json(runtime: &RuntimeConfig) -> Value { + Value::Array( + runtime + .loaded_config_paths + .iter() + .map(|path| { + json!({ + "path": path.path.display().to_string(), + "source": path.source.as_str(), + }) + }) + .collect(), + ) +} + +fn build_show_warnings(runtime: &RuntimeConfig) -> Vec { + let mut warnings = runtime + .validation_errors + .iter() + .map(|error| format!("Skipped invalid config: {error}")) + .collect::>(); + warnings.extend(runtime.validation_warnings.iter().cloned()); + warnings +} + +fn format_validation_issues_text(issues: &[String]) -> String { + if issues.is_empty() { + return format!( + "{}: {}", + style::label("Validation issues"), + style::value("none") + ); + } + + format!( + "{}: {}", + style::label("Validation issues"), + style::value(&issues.join(" | ")) + ) +} + +fn format_validation_warnings_text(warnings: &[String]) -> String { + if warnings.is_empty() { + return format!( + "{}: {}", + style::label("Validation warnings"), + style::value("none") + ); + } + + format!( + "{}: {}", + style::label("Validation warnings"), + style::value(&warnings.join(" | ")) + ) +} + +fn format_observability_text_lines(runtime: &RuntimeConfig) -> Vec { + vec![ + format_resolved_value_text( + "log_level", + runtime.log_level.value.as_str(), + runtime.log_level.source, + ), + format_resolved_value_text( + "log_format", + runtime.log_format.value.as_str(), + runtime.log_format.source, + ), + format_optional_resolved_value_text("log_file", &runtime.log_file), + format_resolved_value_text( + "log_file_mode", + runtime.log_file_mode.value.as_str(), + runtime.log_file_mode.source, + ), + ] +} + +fn format_resolved_value_json(value: T, source: ValueSource) -> Value +where + T: serde::Serialize, +{ + json!({ + "value": value, + "source": source.as_str(), + "config_source": source.config_source().map(ConfigPathSource::as_str), + }) +} + +fn format_resolved_value_text(key: &str, value_text: &str, source: ValueSource) -> String { + match source.config_source() { + Some(config_source) => format!( + "- {}: {} (source: {}, config_source: {})", + style::label(key), + style::value(value_text), + style::label(source.as_str()), + style::label(config_source.as_str()) + ), + None => format!( + "- {}: {} (source: {})", + style::label(key), + style::value(value_text), + style::label(source.as_str()) + ), + } +} + +fn format_optional_resolved_value_text(key: &str, value: &ResolvedOptionalValue) -> String { + match (value.value.as_deref(), value.source) { + (Some(raw_value), Some(source)) => match source.config_source() { + Some(config_source) => format!( + "- {}: {} (source: {}, config_source: {})", + style::label(key), + style::value(raw_value), + style::label(source.as_str()), + style::label(config_source.as_str()) + ), + None => format!( + "- {}: {} (source: {})", + style::label(key), + style::value(raw_value), + style::label(source.as_str()) + ), + }, + _ => format!( + "- {}: {} (source: {})", + style::label(key), + style::value("(unset)"), + style::label("none") + ), + } +} + +fn format_optional_resolved_value_json(value: &ResolvedOptionalValue) -> Value { + json!({ + "value": value.value, + "source": value.source.map(ValueSource::as_str), + "config_source": value.source.and_then(ValueSource::config_source).map(ConfigPathSource::as_str), + }) +} + +fn format_optional_auth_resolved_value_text( + key: AuthConfigKeySpec, + value: &ResolvedOptionalValue, +) -> String { + match (value.value.as_deref(), value.source) { + (Some(raw_value), Some(source)) => { + let display_value = format_text_display_value(key.config_key, raw_value); + match source.config_source() { + Some(config_source) => format!( + "- {}: {} (source: {}, config_source: {}, auth_precedence: {})", + style::label(key.config_key), + style::value(&display_value), + style::label(source.as_str()), + style::label(config_source.as_str()), + style::value(&key.precedence_description()) + ), + None => format!( + "- {}: {} (source: {}, auth_precedence: {})", + style::label(key.config_key), + style::value(&display_value), + style::label(source.as_str()), + style::value(&key.precedence_description()) + ), + } + } + _ => format!( + "- {}: {} (source: {}, auth_precedence: {})", + style::label(key.config_key), + style::value("(unset)"), + style::label("none"), + style::value(&key.precedence_description()) + ), + } +} + +fn format_optional_auth_resolved_value_json( + key: AuthConfigKeySpec, + value: &ResolvedOptionalValue, +) -> Value { + json!({ + "value": value.value, + "display_value": value.value.as_deref().map(|raw| format_text_display_value(key.config_key, raw)), + "source": value.source.map(ValueSource::as_str), + "config_source": value.source.and_then(ValueSource::config_source).map(ConfigPathSource::as_str), + "precedence": key.precedence_description(), + }) +} + +fn format_text_display_value(key: &str, value: &str) -> String { + if should_fully_redact_text_value(key) { + return String::from("[REDACTED]"); + } + + if looks_credential_like(value) { + return abbreviate_text_value(value); + } + + value.to_string() +} + +fn should_fully_redact_text_value(key: &str) -> bool { + let key = key.to_ascii_lowercase(); + ["password", "passwd", "secret", "token", "api_key", "apikey"] + .iter() + .any(|needle| key.contains(needle)) +} + +fn looks_credential_like(value: &str) -> bool { + let trimmed = value.trim(); + trimmed.len() >= 16 + && trimmed + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '/')) +} + +fn abbreviate_text_value(value: &str) -> String { + let total = value.chars().count(); + if total <= 8 { + return value.to_string(); + } + + let prefix: String = value.chars().take(4).collect(); + let suffix: String = value + .chars() + .rev() + .take(4) + .collect::>() + .into_iter() + .rev() + .collect(); + format!("{prefix}...{suffix}") +} diff --git a/context/architecture.md b/context/architecture.md index 25f8e75a..c7641aaa 100644 --- a/context/architecture.md +++ b/context/architecture.md @@ -98,9 +98,9 @@ The repository includes a new placeholder Rust binary crate at `cli/`. - `cli/src/services/observability.rs` no longer owns duplicate log enums or parsing helpers; it consumes the canonical primitive seam from `cli/src/services/config/mod.rs` and stays focused on logger and telemetry runtime behavior. - `cli/src/cli_schema.rs` is now the canonical owner for top-level command metadata for the real clap-backed command set (`auth`, `config`, `setup`, `doctor`, `hooks`, `version`, `completion`), including the slim top-level help purpose text and per-command visibility on `sce`, `sce help`, and `sce --help`; `cli/src/command_surface.rs` remains the custom top-level help renderer and known-command classifier, adding the synthetic `help` row plus the ASCII banner while consuming that shared metadata instead of maintaining a parallel command catalog. - `cli/src/services/default_paths.rs` is the canonical production path catalog for the CLI: it resolves config/state/cache roots with platform-aware XDG or `dirs` fallbacks through an internal `roots` seam, exposes named default paths for current persisted artifacts and database files (global config, auth tokens, auth DB, local DB, agent trace DB), and owns canonical repo-relative, embedded-asset, install/runtime, hook, and context-path accessors so non-test production path definitions have one shared owner. Current production consumers such as config discovery, doctor reporting, setup/install flows, database adapters, and local hook runtime path resolution consume this shared catalog rather than defining owned path literals in their own modules. -- `cli/src/services/config/mod.rs` is the config service facade and `sce config` orchestration surface (`show`, `validate`, `--help`), with bare `sce config` routed by `cli/src/app.rs` to the same help payload as `sce config --help`. Focused submodules own the implementation slices: `types.rs` owns shared config/runtime primitives, `schema.rs` owns generated schema embedding plus typed file parsing, `policy.rs` owns bash-policy semantic validation/format helpers, and `resolver.rs` owns deterministic config-file discovery, file-layer merging, explicit value precedence (`flags > env > config file > defaults` where flag-backed), shared auth-key resolution, observability-runtime resolution, attribution-hooks runtime gate resolution, default-discovered invalid-file degradation, and explicit-path fatal errors for `--config` / `SCE_CONFIG_FILE`. The facade preserves existing `services::config` imports for startup/auth/hooks callers while keeping deterministic text/JSON rendering in `mod.rs` until the render split is completed. +- `cli/src/services/config/mod.rs` is the config service facade and `sce config` orchestration surface (`show`, `validate`, `--help`), with bare `sce config` routed by `cli/src/app.rs` to the same help payload as `sce config --help`. Focused submodules own the implementation slices: `types.rs` owns shared config/runtime primitives, `schema.rs` owns generated schema embedding plus typed file parsing, `policy.rs` owns bash-policy semantic validation plus policy-specific formatting, `resolver.rs` owns deterministic config-file discovery, file-layer merging, explicit value precedence (`flags > env > config file > defaults` where flag-backed), shared auth-key resolution, observability-runtime resolution, attribution-hooks runtime gate resolution, default-discovered invalid-file degradation, and explicit-path fatal errors for `--config` / `SCE_CONFIG_FILE`, and private `render.rs` owns `sce config show` / `sce config validate` text and JSON output construction plus rendering-specific display-value helpers. The facade preserves existing `services::config` imports for startup/auth/hooks callers while delegating command execution to resolution plus rendering submodules. - `cli/src/services/output_format.rs` defines the canonical shared CLI output-format contract (`OutputFormat`) for supporting commands, with deterministic `text|json` parsing and command-scoped actionable invalid-value guidance. -- `cli/src/services/config/mod.rs` is also the canonical owner for the shared runtime/config primitive seam used by the CLI: `LogLevel`, `LogFormat`, `LogFileMode`, the observability env-key constants, and the shared bool parsing helpers used by both config resolution and observability bootstrap. +- `cli/src/services/config/types.rs` is the canonical owner for the shared runtime/config primitive seam used by the CLI: `LogLevel`, `LogFormat`, `LogFileMode`, the observability env-key constants, and the shared bool parsing helpers used by both config resolution and observability bootstrap; `cli/src/services/config/mod.rs` re-exports those primitives through the facade. - `cli/src/services/capabilities.rs` defines the current broad CLI dependency-injection capability traits consumed by `AppContext`: `FsOps` with `StdFsOps` for filesystem operations and `GitOps` with `ProcessGitOps` for git command execution plus repository-root/hooks-directory resolution. Existing services do not consume these traits internally yet; doctor/setup/hooks/config migration is deferred to later lifecycle/AppContext tasks. - `cli/src/services/lifecycle.rs` defines the current compile-safe `ServiceLifecycle` trait seam. It has default no-op `diagnose(&AppContext)`, `fix(&AppContext, &[HealthProblem])`, and `setup(&AppContext)` methods, with lifecycle-owned health, fix, and setup result types so the trait contract is not publicly anchored to doctor/setup module types. The same module owns the shared lifecycle provider catalog/factory, returning providers in deterministic order (config → local_db → auth_db → agent_trace_db → hooks when requested). Hooks exposes a `HooksLifecycle` provider in `cli/src/services/hooks/lifecycle.rs` for hook rollout diagnosis/fix/setup using lifecycle-owned health records plus the canonical required-hook installer. Config exposes a `ConfigLifecycle` provider in `cli/src/services/config/lifecycle.rs` for global/repo-local config validation and repo-local `.sce/config.json` bootstrap. local_db exposes a `LocalDbLifecycle` provider in `cli/src/services/local_db/lifecycle.rs` for canonical local DB path health, parent-directory readiness/bootstrap, and `LocalDb::new()` setup. auth_db exposes an `AuthDbLifecycle` provider in `cli/src/services/auth_db/lifecycle.rs` for canonical auth DB path health, parent-directory readiness/bootstrap, and `AuthDb::new()` setup. agent_trace_db exposes an `AgentTraceDbLifecycle` provider in `cli/src/services/agent_trace_db/lifecycle.rs` for canonical Agent Trace DB path health, parent-directory readiness/bootstrap, and `AgentTraceDb::new()` setup. Doctor runtime aggregates the full provider catalog for `diagnose` and `fix` and adapts lifecycle records into doctor report/fix records at the orchestration boundary; setup command aggregates the shared catalog for `setup` with hooks included only when requested and adapts hook setup outcomes before rendering setup-owned messages. - `cli/src/services/auth_command/mod.rs` defines the implemented auth command surface for `sce auth login|renew|logout|status`, including device-flow login, stored-token renewal (`--force` supported for renew), logout, and status rendering in text/JSON formats; `cli/src/services/auth_command/command.rs` owns the `AuthCommand` struct and its `RuntimeCommand` impl. diff --git a/context/cli/config-precedence-contract.md b/context/cli/config-precedence-contract.md index 58319838..1e07cc7b 100644 --- a/context/cli/config-precedence-contract.md +++ b/context/cli/config-precedence-contract.md @@ -2,7 +2,7 @@ ## Scope -This contract documents the implemented `sce config` command behavior in `cli/src/services/config/mod.rs`, the runtime resolver in `cli/src/services/config/resolver.rs`, the canonical Pkl-authored `sce/config.json` schema artifact generated to `config/schema/sce-config.schema.json` and embedded by `cli/src/services/config/schema.rs` as `SCE_CONFIG_SCHEMA_JSON`, the typed serde DTO + mapping pipeline used for config-file parsing, and parser/dispatch wiring in `cli/src/app.rs`. +This contract documents the implemented `sce config` command behavior in `cli/src/services/config/mod.rs`, the runtime resolver in `cli/src/services/config/resolver.rs`, the text/JSON output renderer in `cli/src/services/config/render.rs`, the canonical Pkl-authored `sce/config.json` schema artifact generated to `config/schema/sce-config.schema.json` and embedded by `cli/src/services/config/schema.rs` as `SCE_CONFIG_SCHEMA_JSON`, the typed serde DTO + mapping pipeline used for config-file parsing, and parser/dispatch wiring in `cli/src/app.rs`. The current implementation resolves flat logging keys with deterministic env-over-config precedence and source metadata, uses those resolved values in `cli/src/app.rs` / `cli/src/services/observability.rs` for runtime logging, exposes resolved-value inspection through `sce config show`, and keeps `sce config validate` focused on validation status plus errors/warnings. @@ -118,5 +118,6 @@ When a default-discovered global or repo-local config file exists but fails JSON - `cli/src/command_surface.rs` - `cli/src/services/config/mod.rs` - `cli/src/services/config/resolver.rs` +- `cli/src/services/config/render.rs` - `cli/src/services/config/schema.rs` - `cli/src/services/config/policy.rs` diff --git a/context/context-map.md b/context/context-map.md index a01135e7..db2a3ce2 100644 --- a/context/context-map.md +++ b/context/context-map.md @@ -14,7 +14,7 @@ Feature/domain context: - `context/cli/default-path-catalog.md` (canonical production CLI path-ownership contract centered on `cli/src/services/default_paths.rs`, including persisted auth/config files, named DB paths for auth/local/Agent Trace databases, repo-relative, embedded-asset, install/runtime, hook, and context-path families plus the regression guard that keeps production path ownership centralized) - `context/cli/patch-service.md` (standalone patch domain model, parser, JSON load helpers, and set operations in `cli/src/services/patch.rs` for in-memory parsed unified-diff representation, capturing only touched lines plus minimal per-file/per-hunk metadata, supporting both `Index:` SVN-style and `diff --git` git-style formats, with `ParseError` for actionable malformed-input diagnostics, `PatchLoadError`/`load_patch_from_json`/`load_patch_from_json_bytes` for storage-agnostic JSON reconstruction, `intersect_patches` for target-shaped overlap with exact-match-first and historical `kind`+`content` fallback semantics plus matched-constructed-hunk `model_id` provenance inheritance, and `combine_patches` for ordered patch combination with later-wins conflict resolution plus winning-hunk `model_id` provenance inheritance; `parse_patch`, `intersect_patches`, and `combine_patches` are consumed by the active post-commit hook runtime) - `context/cli/styling-service.md` (CLI text-mode output styling with `owo-colors` and `comfy-table`, TTY/`NO_COLOR` policy, shared helper API for human-facing surfaces, and per-column right-to-left RGB gradient banner rendering) -- `context/cli/config-precedence-contract.md` (implemented `sce config` show/validate command contract, deterministic `flags > env > config file > defaults` resolution order, focused `config/resolver.rs` ownership for config discovery/merge/runtime precedence plus default-discovered invalid-file degradation, canonical `$schema` acceptance for startup-loaded `sce/config.json` files, shared auth-key env/config/optional baked-default support starting with `workos_client_id`, shared runtime resolution for flat logging observability keys, canonical Pkl-generated `sce/config.json` schema ownership plus CLI embedding/reuse contract, config-file selection order, `show` provenance output, and trimmed `validate` output contract) +- `context/cli/config-precedence-contract.md` (implemented `sce config` show/validate command contract, deterministic `flags > env > config file > defaults` resolution order, focused `config/resolver.rs` ownership for config discovery/merge/runtime precedence plus default-discovered invalid-file degradation, focused `config/render.rs` ownership for `show`/`validate` text+JSON output construction, canonical `$schema` acceptance for startup-loaded `sce/config.json` files, shared auth-key env/config/optional baked-default support starting with `workos_client_id`, shared runtime resolution for flat logging observability keys, canonical Pkl-generated `sce/config.json` schema ownership plus CLI embedding/reuse contract, config-file selection order, `show` provenance output, and trimmed `validate` output contract) - `context/cli/capability-traits.md` (current broad CLI dependency-injection capability seam in `cli/src/services/capabilities.rs`, including `FsOps`/`StdFsOps`, `GitOps`/`ProcessGitOps`, git root/hooks resolution behavior, AppContext wiring with capability accessors plus repo-root-scoped context derivation, and test-only unimplemented stubs; current service internals do not consume these traits until later lifecycle migration tasks) - `context/cli/service-lifecycle.md` (current compile-safe `ServiceLifecycle` seam in `cli/src/services/lifecycle.rs`, including default no-op diagnose/fix/setup methods against `AppContext`, lifecycle-owned health/fix/setup result types, doctor/setup adapter boundaries, the shared lifecycle provider catalog/factory, hook/config/local_db/auth_db/agent_trace_db lifecycle providers, implemented doctor aggregation over diagnose/fix providers, and implemented setup aggregation over `setup` providers in order config → local_db → auth_db → agent_trace_db → hooks when requested) - `context/sce/cli-observability-contract.md` (implemented config-backed runtime observability contract for the flat logging config-file shape with env-over-config fallback, concrete logger/telemetry runtime behavior plus logger and object-safe telemetry trait boundaries, AppContext observability wiring including runtime-classified repeated telemetry action protection, operator-facing `sce config show` observability reporting, and the trimmed `sce config validate` status-only validation surface) diff --git a/context/glossary.md b/context/glossary.md index 4b4cf731..7d9ef029 100644 --- a/context/glossary.md +++ b/context/glossary.md @@ -75,7 +75,7 @@ - `CommandRegistry`: Statically populated command registry in `cli/src/services/command_registry.rs` that maps `&'static str` command names to zero-arg constructor functions (`fn() -> RuntimeCommandHandle`); populated at compile time via `build_default_registry()` and carried by `AppRuntime` during command dispatch. The `RuntimeCommand` trait and `RuntimeCommandHandle` type alias are co-located in the same module. Current registered constructors cover the full top-level command catalog: `help`, `auth`, `config`, `setup`, `doctor`, `hooks`, `version`, and `completion`; stateful parsed requests are constructed by `cli/src/services/parse/command_runtime.rs` when invocation options require per-command data. - `ServiceLifecycle`: Compile-safe lifecycle trait seam in `cli/src/services/lifecycle.rs` with default no-op `diagnose`, `fix`, and `setup` methods that accept `&AppContext`; it exposes lifecycle-owned health, fix, and setup result types, while doctor/setup adapt those records at orchestration boundaries before rendering command-owned output. The hooks service has `HooksLifecycle` for hook rollout diagnosis/fix/setup, the config service has `ConfigLifecycle` for global/repo-local config validation plus repo-local config bootstrap, local_db has `LocalDbLifecycle` for canonical local DB path health/bootstrap/setup, auth_db has `AuthDbLifecycle` for canonical auth DB path health/bootstrap/setup, and agent_trace_db has `AgentTraceDbLifecycle` for canonical Agent Trace DB path health/bootstrap/setup. Doctor runtime aggregates those providers for `diagnose` and `fix`; setup command now aggregates providers for `setup` in order (config → local_db → auth_db → agent_trace_db → hooks when requested). - `lifecycle provider catalog`: Shared factory in `cli/src/services/lifecycle.rs` (`lifecycle_providers(include_hooks)`) that returns boxed `ServiceLifecycle` providers in deterministic config → local_db → auth_db → agent_trace_db → hooks order, used by doctor with hooks included and by setup with hooks included only when requested. -- `sce config command surface`: Implemented top-level CLI command routed by `cli/src/app.rs` to `cli/src/services/config/mod.rs` (with schema/file-parsing delegated to `cli/src/services/config/schema.rs` and runtime precedence resolution delegated to `cli/src/services/config/resolver.rs`), exposing `show`, `validate`, and `--help` for deterministic runtime config inspection and validation; `show` reports resolved flat logging observability values with provenance, while `validate` reports pass/fail plus validation issues and warnings only. +- `sce config command surface`: Implemented top-level CLI command routed by `cli/src/app.rs` to `cli/src/services/config/mod.rs` (with schema/file-parsing delegated to `cli/src/services/config/schema.rs`, runtime precedence resolution delegated to `cli/src/services/config/resolver.rs`, and text/JSON output construction delegated to `cli/src/services/config/render.rs`), exposing `show`, `validate`, and `--help` for deterministic runtime config inspection and validation; `show` reports resolved flat logging observability values with provenance, while `validate` reports pass/fail plus validation issues and warnings only. - `sce version command surface`: Implemented top-level CLI command routed by `cli/src/app.rs` to `cli/src/services/version/mod.rs` plus `cli/src/services/version/command.rs`, exposing deterministic runtime identification output in text form by default and JSON form via `--format json`. - `sce version output contract`: `cli/src/services/version/mod.rs` rendering contract where text output is a deterministic single-line ` ()` payload and JSON output includes stable fields `status`, `command`, `binary`, `version`, and `build_profile`. - `sce completion command surface`: Implemented top-level CLI command routed by `cli/src/app.rs` to `cli/src/services/completion/mod.rs` plus `cli/src/services/completion/command.rs`, requiring `--shell ` and returning a deterministic shell completion script on stdout. @@ -91,6 +91,7 @@ - `config schema and file parsing seam`: Canonical ownership in `cli/src/services/config/schema.rs` for the CLI's JSON Schema embedding (`SCE_CONFIG_SCHEMA_JSON`), `OnceLock` validator (`CONFIG_SCHEMA_VALIDATOR`, `config_schema_validator()`), top-level allowed-key validation (`TOP_LEVEL_CONFIG_KEYS`, `validate_object_keys`), serde DTO definitions (`ParsedFileConfigDocument`, `ParsedPoliciesConfigDocument`, `ParsedBashPolicyConfigDocument`, `ParsedAttributionHooksConfigDocument`, `ParsedCustomBashPolicyEntryDocument`, `ParsedCustomBashPolicyMatchDocument`), file config value wrapper (`FileConfigValue`) and aggregate (`FileConfig`), type aliases (`ParsedBashPolicyConfig`, `ParsedFilePolicies`), and config-file load/parse helpers (`validate_config_file`, `parse_file_config`, `deserialize_typed_config`, `map_policies_config`, `map_attribution_hooks_config`, `map_bash_policy_config`); `validate_config_file` is re-exported `pub(crate)` through `mod.rs` for `lifecycle.rs` and `doctor` consumers. Policy parsing helpers (`parse_bash_policy_presets`, `parse_custom_bash_policies`) and `CustomBashPolicyEntry` are imported from `super::policy` rather than the parent module. - `config policy semantic validation seam`: Canonical ownership in `cli/src/services/config/policy.rs` for the CLI's bash-policy and attribution-hooks semantic validation, merge helpers, and policy rendering: built-in/custom bash-policy catalog types and OnceLock (`BuiltinBashPolicyCatalog`, `BuiltinBashPolicyPreset`, `BuiltinBashPolicyMatcher`, `BuiltinBashPolicyRedundancyWarning`, `BUILTIN_BASH_POLICY_CATALOG`, `BASH_POLICY_PRESET_CATALOG_JSON`), policy config types (`BashPolicyConfig`, `CustomBashPolicyEntry`), catalog accessors (`builtin_bash_policy_catalog`, `builtin_bash_policy_preset_ids`, `is_builtin_bash_policy_preset_id`), policy parsing and validation (`parse_bash_policy_presets`, `parse_custom_bash_policies`, `parse_custom_bash_policy_entry`, `parse_custom_bash_policy_match`, `parse_custom_bash_policy_argv_prefix`), policy resolution (`resolve_bash_policy_config`, `build_validation_warnings`), and policy rendering (`format_bash_policies_text`, `format_bash_policies_json`); `mod.rs` imports `BashPolicyConfig`, `build_validation_warnings`, `format_bash_policies_json`, `format_bash_policies_text`, and `resolve_bash_policy_config` from `policy` for resolution and rendering consumers. - `config runtime resolver seam`: Canonical ownership in `cli/src/services/config/resolver.rs` for config-file discovery, file-layer merging, env/flag/default precedence resolution, shared auth-key resolution (`workos_client_id`), observability runtime resolution, attribution-hooks gate resolution, and default-discovered invalid-file degradation; `cli/src/services/config/mod.rs` delegates `sce config show|validate` runtime resolution to this seam while facade re-exports preserve startup/auth/hooks callers through `services::config`. +- `config render seam`: Canonical ownership in `cli/src/services/config/render.rs` for `sce config show` and `sce config validate` text/JSON output construction, including rendering-specific config-path formatting, resolved-value formatting, validation issue/warning rendering, and auth display-value redaction/abbreviation helpers; `cli/src/services/config/mod.rs` delegates rendering to this private submodule after resolver-owned runtime config resolution. - `sce config schema artifact`: Canonical JSON Schema for global and repo-local `sce/config.json` files, authored in `config/pkl/base/sce-config-schema.pkl`, generated to `config/schema/sce-config.schema.json`, and embedded by `cli/src/services/config/schema.rs` for shared `sce config validate` and doctor config validation. The current schema accepts the canonical `$schema` declaration, flat logging keys (`log_level`, `log_format`, `log_file`, `log_file_mode`), existing auth/config keys, and enforces the schema-level dependency that `log_file_mode` requires `log_file`. - `bash tool policy config surface`: Nested repo config namespace under `.sce/config.json` at `policies.bash`, currently supporting unique built-in `presets` plus repo-owned `custom` argv-prefix rules with deterministic validation, merged global/local resolution, and first-class `sce config show|validate` reporting. - `attribution hooks gate`: Disabled-default local hook runtime gate resolved through shared config precedence in `cli/src/services/config/mod.rs` (with parsing in `schema.rs`): env `SCE_ATTRIBUTION_HOOKS_ENABLED` overrides repo/global config key `policies.attribution_hooks.enabled`, and the current enabled path activates commit-msg-only attribution without re-enabling trace persistence. diff --git a/context/overview.md b/context/overview.md index 380af178..77b19d25 100644 --- a/context/overview.md +++ b/context/overview.md @@ -20,7 +20,7 @@ The setup service also provides repository-root install orchestration: it resolv The CLI now also applies baseline security hardening for reliability-driven automation: diagnostics/logging paths use deterministic secret redaction, `sce setup --hooks --repo ` canonicalizes and validates repository paths before execution, and setup write flows run explicit directory write-permission probes before staging/swap operations. The config service now provides deterministic runtime config resolution with explicit precedence (`flags > env > config file > defaults`), strict config-file validation (`$schema`, `log_level`, `log_format`, `log_file`, `log_file_mode`, `timeout_ms`, `workos_client_id`, and nested `policies.bash`), deterministic default discovery/merge of global+local config files (`${config_root}/sce/config.json` then `.sce/config.json` with local override, where `config_root` comes from the shared default-path seam with XDG/`dirs::config_dir()` config-root resolution), defaults for the resolved observability value set (`log_level=error`, `log_format=text`, `log_file_mode=truncate`), shared auth-key resolution with optional baked defaults starting at `workos_client_id`, first-class bash-policy preset/custom parsing with deterministic conflict and duplicate-prefix validation, and a canonical Pkl-authored `sce/config.json` JSON Schema generated to `config/schema/sce-config.schema.json` and embedded by `cli/src/services/config/mod.rs` for both `sce config validate` and doctor-time config checks. Runtime startup config loading now keeps parity with that schema by accepting the canonical `"$schema": "https://sce.crocoder.dev/config.json"` declaration in repo-local and global config files, so startup commands such as `sce version` no longer fail before dispatch on that field. App-runtime observability now consumes flat logging keys through the shared resolver, so env values still override config-file values while config files provide deterministic fallback for file logging; `sce config show` reports resolved observability/auth/policy values with provenance, while `sce config validate` is now a trimmed validation surface that reports only pass/fail plus validation errors or warnings in text and JSON modes. The canonical preset catalog and matching contract live in `config/pkl/data/bash-policy-presets.json` and `context/sce/bash-tool-policy-enforcement-contract.md`. Invalid default-discovered config files now also degrade gracefully at startup: `sce` keeps running with degraded observability defaults, logs `sce.config.invalid_config` warnings, and reserves hard failures for explicit `--config` / `SCE_CONFIG_FILE` targets or other truly invalid runtime observability inputs. -`cli/src/services/config/mod.rs` is now a module facade that declares `pub mod types`, `pub mod schema`, `pub mod policy`, and `pub mod command`/`pub mod lifecycle`, re-exporting `pub use types::*` and `pub(crate) use schema::validate_config_file`. Shared config primitive ownership is delegated to `cli/src/services/config/types.rs` (`LogLevel`, `LogFormat`, `LogFileMode`, `ConfigSubcommand`, `ConfigRequest`, `ReportFormat`, source metadata types, resolved runtime config types, the `NAME` constant, observability env-key constants, and `parse_bool_value_from`). Config schema loading and file parsing ownership is delegated to `cli/src/services/config/schema.rs` (JSON Schema embedding/validator, top-level allowed-key validation, serde DTO definitions, `FileConfig`/`FileConfigValue`, type aliases, `validate_config_file`, `parse_file_config`, `deserialize_typed_config`, `map_policies_config`, `map_attribution_hooks_config`, `map_bash_policy_config`). Config policy semantic validation and rendering ownership is delegated to `cli/src/services/config/policy.rs` (`BashPolicyConfig`, `CustomBashPolicyEntry`, built-in catalog types and OnceLock, `is_builtin_bash_policy_preset_id`, `parse_bash_policy_presets`, `parse_custom_bash_policies`, `resolve_bash_policy_config`, `build_validation_warnings`, `format_bash_policies_text`, `format_bash_policies_json`). Downstream modules continue importing through `services::config` unchanged. The CLI now has a minimal `AppContext` dependency-injection container in `cli/src/app.rs` holding `Arc`, `Arc`, `Arc`, `Arc`, and an optional `repo_root: Option`; it can derive repo-root-scoped contexts with `with_repo_root(...)` while preserving runtime dependencies. The broad capability seam lives in `cli/src/services/capabilities.rs`, where `FsOps`/`StdFsOps` wrap filesystem operations and `GitOps`/`ProcessGitOps` wrap git process execution plus repository-root/hooks-directory resolution. Current services have not migrated to consume the filesystem/git traits internally yet. +`cli/src/services/config/mod.rs` is now a module facade that declares focused config submodules (`types`, `schema`, `policy`, `resolver`, private `render`, `command`, and `lifecycle`), re-exporting `pub use types::*` and `pub(crate) use schema::validate_config_file`. Shared config primitive ownership is delegated to `cli/src/services/config/types.rs`; schema loading and file parsing to `cli/src/services/config/schema.rs`; bash-policy semantic validation and policy-specific formatting to `cli/src/services/config/policy.rs`; runtime discovery/precedence to `cli/src/services/config/resolver.rs`; and `sce config show` / `sce config validate` text+JSON output construction to `cli/src/services/config/render.rs`. Downstream modules continue importing through `services::config` unchanged. The CLI now has a minimal `AppContext` dependency-injection container in `cli/src/app.rs` holding `Arc`, `Arc`, `Arc`, `Arc`, and an optional `repo_root: Option`; it can derive repo-root-scoped contexts with `with_repo_root(...)` while preserving runtime dependencies. The broad capability seam lives in `cli/src/services/capabilities.rs`, where `FsOps`/`StdFsOps` wrap filesystem operations and `GitOps`/`ProcessGitOps` wrap git process execution plus repository-root/hooks-directory resolution. The shared default path service in `cli/src/services/default_paths.rs` is now the canonical owner for production CLI path definitions. It resolves per-user config/state/cache roots through a dedicated internal `roots` seam, exposes the current persisted-artifact inventory (global config and auth tokens), and also defines named DB paths (auth DB, local DB, Agent Trace DB) plus the repo-relative, embedded-asset, install/runtime, hook, and context-path accessors consumed across current CLI production code. Non-test production modules should consume this shared catalog instead of hardcoding owned path literals. No default cache-backed persisted artifact currently exists, so cache-root resolution remains available without speculative cache-path features and no legacy default-path fallback is supported. The same config resolver now also owns the attribution-hooks gate used by local hook runtime: `SCE_ATTRIBUTION_HOOKS_ENABLED` overrides `policies.attribution_hooks.enabled`, and the gate defaults to disabled. The config service split now includes `cli/src/services/config/resolver.rs` as the focused owner for config-file discovery, file-layer merging, env/flag/default precedence, auth-key resolution, observability resolution, attribution-hooks resolution, and default-discovered invalid-file degradation; `cli/src/services/config/mod.rs` remains the facade/rendering orchestration surface while preserving existing `services::config` imports. diff --git a/context/plans/cli-maintenance-hazards.md b/context/plans/cli-maintenance-hazards.md index 217b8d66..da3ea937 100644 --- a/context/plans/cli-maintenance-hazards.md +++ b/context/plans/cli-maintenance-hazards.md @@ -95,19 +95,60 @@ Resolve the architectural/maintenance hazards in the Rust CLI without changing u - Evidence: `nix develop -c sh -c 'cd cli && cargo fmt'` passed after adding `cli/src/services/config/resolver.rs` to the git index so Nix source filtering included the new module; `nix develop -c sh -c 'cd cli && cargo fmt --check'` was blocked by repo policy in favor of `nix flake check`; user reported `nix flake check` was run. - Notes: Created `cli/src/services/config/resolver.rs` to own runtime config discovery, file-layer merging, env/flag/default precedence, auth-key resolution, observability resolution, attribution-hooks resolution, and default-discovered invalid-file degradation. `mod.rs` now delegates runtime resolution through the resolver while preserving facade re-exports for existing startup/auth/hooks callers. `schema.rs` now references the resolver-owned `WORKOS_CLIENT_ID_KEY` for the top-level config-key list. -- [ ] T07: `Extract config text and JSON rendering` (status:todo) +- [x] T07: `Extract config text and JSON rendering` (status:done) - Task ID: T07 - Goal: Move `sce config show` and `sce config validate` text/JSON rendering into a focused render submodule without changing output contracts. - Boundaries (in/out of scope): In - text rendering, JSON response construction, display-value/redaction helpers that are rendering-specific, render tests/golden assertions if present. Out - changing resolved data semantics, schema validation, policy validation, or command parsing. - Done when: config renderers have one focused owner; output for representative `show` and `validate` cases remains stable; `mod.rs` is reduced to facade/orchestration-level exports and `run_config_subcommand` delegation. - Verification notes (commands or checks): Prefer `nix flake check`; include targeted config show/validate output tests if available/needed. + - Completed: 2026-06-08 + - Files changed: `cli/src/services/config/mod.rs`, `cli/src/services/config/render.rs` + - Evidence: `nix develop -c sh -c 'cd cli && cargo fmt'` passed; `nix develop -c sh -c 'cd cli && cargo check'` was blocked by repo policy in favor of `nix flake check`; `nix flake check` passed; `nix run .#pkl-check-generated` passed. + - Notes: Created `cli/src/services/config/render.rs` as the focused owner for `sce config show` and `sce config validate` text/JSON rendering, including rendering-specific config-path formatting, resolved-value formatting, validation issue/warning rendering, and auth display-value redaction/abbreviation helpers. `cli/src/services/config/mod.rs` now remains a small facade/orchestrator that resolves runtime config and delegates output formatting to the render module without changing config resolution, schema, policy, or command parsing behavior. Context sync classified this as an important architecture-boundary update and refreshed the config/domain context links. -- [ ] T08: `Final validation and context sync` (status:todo) +- [x] T08: `Final validation and context sync` (status:done) - Task ID: T08 - Goal: Run full validation, remove temporary scaffolding, and sync durable context for the resulting CLI architecture and maintenance boundaries. - Boundaries (in/out of scope): In - full repo validation, generated-output parity, cleanup of task-owned temporary files, context updates for current-state architecture/glossary/domain docs. Out - new refactors or behavior changes beyond documenting the completed plan outcome. - Done when: `nix run .#pkl-check-generated` and `nix flake check` pass; config/DB/CLI enum context reflects the new ownership; this plan records validation evidence and any residual risks. - Verification notes (commands or checks): `nix run .#pkl-check-generated`; `nix flake check`; verify `context/overview.md`, `context/architecture.md`, `context/glossary.md`, `context/cli/config-precedence-contract.md`, and `context/sce/shared-turso-db.md` are current or explicitly verified unchanged. + - Completed: 2026-06-08 + - Files changed: `context/plans/cli-maintenance-hazards.md` + - Evidence: `nix run .#pkl-check-generated` passed with generated outputs up to date; `nix flake check` passed all evaluated checks, including `cli-tests`, `cli-clippy`, `cli-fmt`, `pkl-parity`, npm/config-lib JS checks, and integration install checks. Reviewed task-owned temporary scaffolding and found no T08-owned temp files to remove. + - Notes: Final validation confirmed the completed maintenance refactor without additional code changes. Existing current-state context already reflects the canonical CLI enum ownership, shared Turso operation core, and split config module ownership, including `render.rs`; no residual risks identified. + +## Validation Report + +### Commands run + +- `nix run .#pkl-check-generated` -> exit 0; generated outputs are up to date. +- `nix flake check` -> exit 0; all evaluated flake checks passed, including CLI tests/clippy/fmt, `pkl-parity`, npm/config-lib JS checks, and integration install checks. + +### Cleanup + +- Reviewed `context/tmp/`; no T08-owned temporary scaffolding was identified for removal. + +### Success-criteria verification + +- [x] `cli/src/cli_schema.rs` no longer defines independent duplicate `OutputFormat` / `LogLevel` enums; it imports canonical service-owned types from `services::output_format` and `services::config`. +- [x] Adding an output format or log level now has one canonical enum owner plus parser/rendering tests. +- [x] `TursoDb` and `EncryptedTursoDb` delegate shared synchronous operations and migration execution through `TursoConnectionCore`. +- [x] Database error/migration/local/encrypted initialization behavior remained stable under full flake validation. +- [x] `cli/src/services/config/mod.rs` is a small facade over focused `types`, `schema`, `policy`, `resolver`, private `render`, `command`, and `lifecycle` modules. +- [x] Existing config, startup, doctor, auth, attribution-hooks, and observability behavior remained unchanged under full flake validation. +- [x] Rust formatting, linting, tests, generated-output parity, and repo-level validation passed through `nix flake check` plus `nix run .#pkl-check-generated`. + +### Context verification + +- Verified `context/overview.md`, `context/architecture.md`, `context/glossary.md`, `context/cli/config-precedence-contract.md`, and `context/sce/shared-turso-db.md` against current code ownership. Existing context already reflects canonical CLI enum ownership, shared Turso operation ownership, and the focused config module split including `render.rs`. + +### Failed checks and follow-ups + +- None. + +### Residual risks + +- None identified. ## Open questions