feat: complete CLI milestone
Finish the Milestone 4 CLI surface with config management, daemon install and uninstall helpers, config reload handling, and final polish for secret redaction and running-socket tracking.
This commit is contained in:
+98
-1
@@ -1,8 +1,10 @@
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
env,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use anyhow::bail;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::fs;
|
||||
|
||||
@@ -55,6 +57,58 @@ impl TvctlConfig {
|
||||
fs::write(path, contents).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Return the config as stable flattened key/value pairs.
|
||||
pub fn entries(&self) -> BTreeMap<&'static str, String> {
|
||||
BTreeMap::from([
|
||||
("daemon.socket", self.daemon.socket.clone()),
|
||||
("daemon.http_enabled", self.daemon.http_enabled.to_string()),
|
||||
("daemon.http_port", self.daemon.http_port.to_string()),
|
||||
("daemon.http_host", self.daemon.http_host.clone()),
|
||||
("daemon.log_level", self.daemon.log_level.clone()),
|
||||
(
|
||||
"discovery.auto_discover",
|
||||
self.discovery.auto_discover.to_string(),
|
||||
),
|
||||
(
|
||||
"discovery.interval_secs",
|
||||
self.discovery.interval_secs.to_string(),
|
||||
),
|
||||
(
|
||||
"discovery.timeout_secs",
|
||||
self.discovery.timeout_secs.to_string(),
|
||||
),
|
||||
("devices.default", self.devices.default.clone()),
|
||||
("dev.enabled", self.dev.enabled.to_string()),
|
||||
("dev.roku_username", self.dev.roku_username.clone()),
|
||||
("dev.roku_password", self.dev.roku_password.clone()),
|
||||
])
|
||||
}
|
||||
|
||||
/// Return one flattened config value by stable key.
|
||||
pub fn get_value(&self, key: &str) -> Option<String> {
|
||||
self.entries().remove(key)
|
||||
}
|
||||
|
||||
/// Set one flattened config value by stable key.
|
||||
pub fn set_value(&mut self, key: &str, value: &str) -> anyhow::Result<()> {
|
||||
match key {
|
||||
"daemon.socket" => self.daemon.socket = value.to_string(),
|
||||
"daemon.http_enabled" => self.daemon.http_enabled = parse_bool(key, value)?,
|
||||
"daemon.http_port" => self.daemon.http_port = parse_value(key, value)?,
|
||||
"daemon.http_host" => self.daemon.http_host = value.to_string(),
|
||||
"daemon.log_level" => self.daemon.log_level = value.to_string(),
|
||||
"discovery.auto_discover" => self.discovery.auto_discover = parse_bool(key, value)?,
|
||||
"discovery.interval_secs" => self.discovery.interval_secs = parse_value(key, value)?,
|
||||
"discovery.timeout_secs" => self.discovery.timeout_secs = parse_value(key, value)?,
|
||||
"devices.default" => self.devices.default = value.to_string(),
|
||||
"dev.enabled" => self.dev.enabled = parse_bool(key, value)?,
|
||||
"dev.roku_username" => self.dev.roku_username = value.to_string(),
|
||||
"dev.roku_password" => self.dev.roku_password = value.to_string(),
|
||||
other => bail!("unknown config key '{other}'"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Runtime daemon settings.
|
||||
@@ -121,11 +175,19 @@ pub struct DeviceConfig {
|
||||
pub struct DevConfig {
|
||||
/// Whether developer tooling is enabled.
|
||||
pub enabled: bool,
|
||||
/// Optional Roku developer username override.
|
||||
pub roku_username: String,
|
||||
/// Optional Roku developer password override.
|
||||
pub roku_password: String,
|
||||
}
|
||||
|
||||
impl Default for DevConfig {
|
||||
fn default() -> Self {
|
||||
Self { enabled: true }
|
||||
Self {
|
||||
enabled: true,
|
||||
roku_username: String::new(),
|
||||
roku_password: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,6 +202,8 @@ pub struct RuntimePaths {
|
||||
pub devices_file: PathBuf,
|
||||
/// The platform app cache directory.
|
||||
pub cache_dir: PathBuf,
|
||||
/// The file containing the currently active daemon socket path.
|
||||
pub active_socket_file: PathBuf,
|
||||
/// The runtime socket path.
|
||||
pub socket_file: PathBuf,
|
||||
}
|
||||
@@ -153,6 +217,7 @@ impl RuntimePaths {
|
||||
Self {
|
||||
config_file,
|
||||
devices_file: data_dir.join("devices.json"),
|
||||
active_socket_file: data_dir.join("active_socket"),
|
||||
socket_file: default_socket_path(),
|
||||
data_dir,
|
||||
cache_dir,
|
||||
@@ -173,6 +238,19 @@ pub fn default_config_dir() -> PathBuf {
|
||||
home_dir().join(".config/tvctl")
|
||||
}
|
||||
|
||||
/// Return the user-level systemd unit directory path.
|
||||
pub fn systemd_user_dir() -> PathBuf {
|
||||
if let Ok(path) = env::var("XDG_CONFIG_HOME") {
|
||||
return PathBuf::from(path).join("systemd/user");
|
||||
}
|
||||
home_dir().join(".config/systemd/user")
|
||||
}
|
||||
|
||||
/// Return the canonical tvctld user service unit path.
|
||||
pub fn systemd_unit_path() -> PathBuf {
|
||||
systemd_user_dir().join("tvctld.service")
|
||||
}
|
||||
|
||||
/// Return the default data directory path.
|
||||
pub fn default_data_dir() -> PathBuf {
|
||||
if let Ok(path) = env::var("XDG_DATA_HOME") {
|
||||
@@ -200,6 +278,20 @@ fn current_uid() -> u32 {
|
||||
unsafe { libc::geteuid() }
|
||||
}
|
||||
|
||||
fn parse_bool(key: &str, value: &str) -> anyhow::Result<bool> {
|
||||
parse_value(key, value)
|
||||
}
|
||||
|
||||
fn parse_value<T>(key: &str, value: &str) -> anyhow::Result<T>
|
||||
where
|
||||
T: std::str::FromStr,
|
||||
T::Err: std::fmt::Display,
|
||||
{
|
||||
value
|
||||
.parse::<T>()
|
||||
.map_err(|error| anyhow::anyhow!("invalid value '{value}' for {key}: {error}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -221,6 +313,11 @@ mod tests {
|
||||
devices: DeviceConfig {
|
||||
default: "living-room".to_string(),
|
||||
},
|
||||
dev: DevConfig {
|
||||
enabled: true,
|
||||
roku_username: "rokudev".to_string(),
|
||||
roku_password: "secret".to_string(),
|
||||
},
|
||||
..TvctlConfig::default()
|
||||
};
|
||||
config
|
||||
|
||||
Reference in New Issue
Block a user