diff --git a/Cargo.toml b/Cargo.toml index 9d2b344..a167ca8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ dirs = "5" futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] } jsonwebtoken = { version = "9", default-features = false } libc = "0.2" +maestro-config = { path = "crates/maestro-config" } maestro-protocol = { path = "crates/maestro-protocol" } nix = { version = "0.29", default-features = false, features = ["term", "signal"] } rand = "0.8" diff --git a/crates/maestro-config/Cargo.toml b/crates/maestro-config/Cargo.toml new file mode 100644 index 0000000..f1e4c60 --- /dev/null +++ b/crates/maestro-config/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "maestro-config" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +publish.workspace = true + +[dependencies] +dirs.workspace = true +serde.workspace = true +thiserror.workspace = true +toml.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/maestro-config/src/lib.rs b/crates/maestro-config/src/lib.rs new file mode 100644 index 0000000..84496fc --- /dev/null +++ b/crates/maestro-config/src/lib.rs @@ -0,0 +1,389 @@ +//! Unified Maestro on-disk config. +//! +//! One TOML file at `~/.config/maestro/config.toml` (override via +//! `$MAESTRO_CONFIG`) holds settings for every Maestro binary: +//! +//! ```toml +//! [shared] +//! registry_url = "https://maestro.example.com" +//! token = "" +//! +//! [cli] +//! cookie = "" +//! +//! [daemon] +//! daemon_url = "ws://laptop.lan:7070" +//! bind_addr = "127.0.0.1:7070" +//! +//! [registry] +//! bind_addr = "127.0.0.1:8080" +//! ``` +//! +//! The CLI consumes `[shared].registry_url` as its HTTP base; the daemon +//! derives its WS endpoint with [`derive_daemon_ws_url`] (scheme swap + +//! `/daemon` suffix). +//! +//! This crate owns the file schema, path resolution, atomic save, and the +//! URL derivation helper. Each binary applies its own env-var precedence and +//! defaults on top of [`MaestroConfigFile`]. + +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +/// Directory name under `$XDG_CONFIG_HOME` (default `~/.config`). +pub const DIR_NAME: &str = "maestro"; +/// File name inside [`DIR_NAME`]. +pub const FILE_NAME: &str = "config.toml"; +/// Env var that, when set, overrides the resolved config-file path. +pub const CONFIG_PATH_ENV: &str = "MAESTRO_CONFIG"; + +#[derive(Debug, thiserror::Error)] +pub enum ConfigError { + #[error("io: {0}")] + Io(#[from] std::io::Error), + #[error("toml decode: {0}")] + TomlDecode(#[from] toml::de::Error), + #[error("toml encode: {0}")] + TomlEncode(#[from] toml::ser::Error), + #[error("could not determine config directory (XDG_CONFIG_HOME / $HOME unset?)")] + NoConfigDir, + #[error( + "maestro config not found at {}\n\ + Run `maestrod configure` to create it. \ + (Legacy ~/.config/maestrod/config.toml and ~/.config/maestro/{{cli,registry}}.toml \ + are no longer read.)", + path.display() + )] + MissingFile { path: PathBuf }, + #[error("invalid registry_url {input:?}: {reason}")] + InvalidRegistryUrl { input: String, reason: String }, +} + +/// On-disk representation of the unified config. Every section is optional +/// so a partial file (e.g. just `[shared]` + `[registry]` for a registry +/// host) is valid. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct MaestroConfigFile { + #[serde(default, skip_serializing_if = "SharedSection::is_empty")] + pub shared: SharedSection, + #[serde(default, skip_serializing_if = "CliSection::is_empty")] + pub cli: CliSection, + #[serde(default, skip_serializing_if = "DaemonSection::is_empty")] + pub daemon: DaemonSection, + #[serde(default, skip_serializing_if = "RegistrySection::is_empty")] + pub registry: RegistrySection, +} + +/// Fields consumed by more than one binary (CLI + daemon, daemon + registry). +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct SharedSection { + /// Registry base URL, e.g. `https://maestro.example.com`. CLI uses it + /// as-is for HTTP; the daemon derives its WS endpoint via + /// [`derive_daemon_ws_url`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub registry_url: Option, + /// Shared HS256 secret. Registry signs JWTs with it, daemons verify + /// (and authenticate registry calls) with it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub token: Option, +} + +impl SharedSection { + fn is_empty(&self) -> bool { + self.registry_url.is_none() && self.token.is_none() + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct CliSection { + /// Persisted `maestro_session` cookie value (a JWT). Opaque to the CLI. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cookie: Option, +} + +impl CliSection { + fn is_empty(&self) -> bool { + self.cookie.is_none() + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct DaemonSection { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub daemon_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hostname: Option, + /// Reachable URL clients dial for the daemon's per-session WS endpoint. + /// Distinct from `bind_addr` because the bind may be `0.0.0.0` while + /// the publicly reachable address is a Wireguard / VPN interface. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub daemon_url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bind_addr: Option, + /// Root for everything daemon-local: sidecars, FIFOs, etc. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub state_dir: Option, + /// Root for ephemeral worktrees the daemon creates on `CreateSession` + /// with a `worktree` spec. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub worktree_root: Option, +} + +impl DaemonSection { + fn is_empty(&self) -> bool { + self.daemon_id.is_none() + && self.hostname.is_none() + && self.daemon_url.is_none() + && self.bind_addr.is_none() + && self.state_dir.is_none() + && self.worktree_root.is_none() + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct RegistrySection { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bind_addr: Option, +} + +impl RegistrySection { + fn is_empty(&self) -> bool { + self.bind_addr.is_none() + } +} + +impl MaestroConfigFile { + /// Strict load — error with [`ConfigError::MissingFile`] if absent. + pub fn load(path: &Path) -> Result { + if !path.exists() { + return Err(ConfigError::MissingFile { + path: path.to_path_buf(), + }); + } + let bytes = std::fs::read(path)?; + let s = std::str::from_utf8(&bytes).unwrap_or(""); + Ok(toml::from_str(s)?) + } + + /// Load if present, else return an empty config. Use for callers that + /// want a default-on-missing read (e.g. CLI's `set-registry` bootstrap, + /// registry's first-run token generation). + pub fn load_or_default(path: &Path) -> Result { + if !path.exists() { + return Ok(Self::default()); + } + Self::load(path) + } + + /// Atomic write (tmp + rename). Creates parent dirs on demand. + pub fn save(&self, path: &Path) -> Result<(), ConfigError> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let body = toml::to_string_pretty(self)?; + let tmp = path.with_extension("toml.tmp"); + std::fs::write(&tmp, body)?; + std::fs::rename(&tmp, path)?; + Ok(()) + } +} + +/// `$MAESTRO_CONFIG` if set, else [`default_config_path`]. +pub fn resolve_config_path() -> Result { + if let Some(p) = std::env::var_os(CONFIG_PATH_ENV) { + return Ok(PathBuf::from(p)); + } + default_config_path() +} + +/// `~/.config/maestro/config.toml` (XDG-aware). +pub fn default_config_path() -> Result { + let base = dirs::config_dir().ok_or(ConfigError::NoConfigDir)?; + Ok(base.join(DIR_NAME).join(FILE_NAME)) +} + +/// Derive the daemon's WS endpoint from the shared `registry_url`. +/// +/// | input | output | +/// |--------------------------|---------------------------------| +/// | `https://r.example` | `wss://r.example/daemon` | +/// | `http://r.example:8080` | `ws://r.example:8080/daemon` | +/// | `http://r/some/prefix/` | `ws://r/some/prefix/daemon` | +/// +/// Trailing slashes are normalised so the result never has `//daemon`. +/// Any non-`http(s)` scheme returns [`ConfigError::InvalidRegistryUrl`]. +pub fn derive_daemon_ws_url(registry_url: &str) -> Result { + let trimmed = registry_url.trim_end_matches('/'); + if let Some(rest) = trimmed.strip_prefix("https://") { + Ok(format!("wss://{rest}/daemon")) + } else if let Some(rest) = trimmed.strip_prefix("http://") { + Ok(format!("ws://{rest}/daemon")) + } else { + Err(ConfigError::InvalidRegistryUrl { + input: registry_url.to_string(), + reason: "must start with http:// or https://".into(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn missing_file_strict_load_errors_with_pointer_to_configure() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + let err = MaestroConfigFile::load(&path).unwrap_err(); + let msg = err.to_string(); + assert!(matches!(err, ConfigError::MissingFile { .. })); + assert!(msg.contains("maestrod configure"), "msg was: {msg}"); + assert!(msg.contains("Legacy"), "msg was: {msg}"); + } + + #[test] + fn missing_file_load_or_default_returns_empty() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + let cfg = MaestroConfigFile::load_or_default(&path).unwrap(); + assert_eq!(cfg, MaestroConfigFile::default()); + } + + #[test] + fn full_file_round_trips() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + let cfg = MaestroConfigFile { + shared: SharedSection { + registry_url: Some("https://r.example".into()), + token: Some("secret".into()), + }, + cli: CliSection { + cookie: Some("eyJ...".into()), + }, + daemon: DaemonSection { + daemon_id: Some("laptop".into()), + hostname: Some("laptop.lan".into()), + daemon_url: Some("ws://laptop.lan:7070".into()), + bind_addr: Some("127.0.0.1:7070".parse().unwrap()), + state_dir: Some(PathBuf::from("/var/lib/maestrod")), + worktree_root: Some(PathBuf::from("/var/lib/maestrod/wt")), + }, + registry: RegistrySection { + bind_addr: Some("0.0.0.0:8080".parse().unwrap()), + }, + }; + cfg.save(&path).unwrap(); + assert_eq!(MaestroConfigFile::load(&path).unwrap(), cfg); + } + + #[test] + fn partial_file_loads_with_section_defaults() { + // Only [shared] + [registry] present (typical registry host). + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + std::fs::write( + &path, + r#" +[shared] +token = "abc" + +[registry] +bind_addr = "127.0.0.1:9090" +"#, + ) + .unwrap(); + let cfg = MaestroConfigFile::load(&path).unwrap(); + assert_eq!(cfg.shared.token.as_deref(), Some("abc")); + assert!(cfg.shared.registry_url.is_none()); + assert_eq!(cfg.cli, CliSection::default()); + assert_eq!(cfg.daemon, DaemonSection::default()); + assert_eq!( + cfg.registry.bind_addr, + Some("127.0.0.1:9090".parse().unwrap()) + ); + } + + #[test] + fn save_omits_empty_sections() { + // Avoid littering the file with empty `[cli]` / `[daemon]` tables + // when only one component is configured. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + let cfg = MaestroConfigFile { + shared: SharedSection { + token: Some("t".into()), + ..Default::default() + }, + ..Default::default() + }; + cfg.save(&path).unwrap(); + let body = std::fs::read_to_string(&path).unwrap(); + assert!(body.contains("[shared]"), "body: {body}"); + assert!(!body.contains("[cli]"), "body: {body}"); + assert!(!body.contains("[daemon]"), "body: {body}"); + assert!(!body.contains("[registry]"), "body: {body}"); + } + + #[test] + fn save_atomic_creates_parent_dirs() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("nested/here/config.toml"); + MaestroConfigFile::default().save(&path).unwrap(); + assert!(path.exists()); + } + + #[test] + fn resolve_config_path_respects_env_var() { + // We can't safely tweak the process env in a unit test (it races with + // other tests). Exercise the default path branch instead and rely on + // an integration-level smoke that env-override works. + let p = default_config_path().unwrap(); + assert!(p.ends_with(format!("{DIR_NAME}/{FILE_NAME}"))); + } + + #[test] + fn derive_daemon_ws_url_swaps_http_to_ws() { + assert_eq!( + derive_daemon_ws_url("http://r.example").unwrap(), + "ws://r.example/daemon" + ); + assert_eq!( + derive_daemon_ws_url("http://r.example:8080").unwrap(), + "ws://r.example:8080/daemon" + ); + } + + #[test] + fn derive_daemon_ws_url_swaps_https_to_wss() { + assert_eq!( + derive_daemon_ws_url("https://r.example").unwrap(), + "wss://r.example/daemon" + ); + } + + #[test] + fn derive_daemon_ws_url_normalises_trailing_slash() { + assert_eq!( + derive_daemon_ws_url("https://r.example/").unwrap(), + "wss://r.example/daemon" + ); + assert_eq!( + derive_daemon_ws_url("https://r.example/path/").unwrap(), + "wss://r.example/path/daemon" + ); + } + + #[test] + fn derive_daemon_ws_url_rejects_non_http_schemes() { + let err = derive_daemon_ws_url("ws://r.example/daemon").unwrap_err(); + match err { + ConfigError::InvalidRegistryUrl { input, .. } => { + assert_eq!(input, "ws://r.example/daemon"); + } + other => panic!("expected InvalidRegistryUrl, got {other:?}"), + } + } +}