cli

Command-line interface for Radroots
git clone https://radroots.dev/git/cli.git
Log | Files | Refs | README | LICENSE

commit 0c42bc124066d5f728c5408bc51113dc9676f2f5
parent 173153061595c8349a1a7091f47f93201b441f5b
Author: triesap <tyson@radroots.org>
Date:   Mon, 27 Apr 2026 05:05:24 +0000

cli: align signer runtime mode

- add config based signer mode resolution
- replace stale signer recovery actions
- describe command and signer features directly
- cover signer config precedence and rejected signer flag

Diffstat:
Msrc/commands/doctor.rs | 14+++++---------
Msrc/runtime/config.rs | 161+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Msrc/runtime/farm.rs | 6+++---
Msrc/runtime/listing.rs | 6+++---
Msrc/runtime/order.rs | 6+++---
Msrc/runtime/signer.rs | 2+-
Msrc/target_cli.rs | 1+
Mtests/target_cli.rs | 1+
8 files changed, 167 insertions(+), 30 deletions(-)

diff --git a/src/commands/doctor.rs b/src/commands/doctor.rs @@ -200,7 +200,7 @@ fn signer_check(signer: &crate::domain::runtime::SignerStatusView) -> EvaluatedC .reason .clone() .unwrap_or_else(|| format!("{} signer is not configured", signer.mode)), - Some("radroots signer status"), + Some("radroots signer status get"), ), "degraded" | "unavailable" => ( DoctorSeverity::ExternalFail, @@ -208,11 +208,7 @@ fn signer_check(signer: &crate::domain::runtime::SignerStatusView) -> EvaluatedC .reason .clone() .unwrap_or_else(|| format!("{} signer is unavailable", signer.mode)), - Some(if signer.mode == "myc" { - "radroots myc status" - } else { - "radroots signer status" - }), + Some("radroots signer status get"), ), _ => ( DoctorSeverity::InternalFail, @@ -220,7 +216,7 @@ fn signer_check(signer: &crate::domain::runtime::SignerStatusView) -> EvaluatedC .reason .clone() .unwrap_or_else(|| format!("{} signer reported an internal error", signer.mode)), - Some("radroots signer status --json"), + Some("radroots --format json signer status get"), ), }; @@ -277,14 +273,14 @@ fn myc_check(myc: &crate::domain::runtime::MycStatusView) -> EvaluatedCheck { myc.reason .clone() .unwrap_or_else(|| "myc is not configured".to_owned()), - Some("radroots myc status"), + Some("radroots signer status get"), ), _ => ( DoctorSeverity::ExternalFail, myc.reason .clone() .unwrap_or_else(|| "myc is unavailable".to_owned()), - Some("radroots myc status"), + Some("radroots signer status get"), ), }; diff --git a/src/runtime/config.rs b/src/runtime/config.rs @@ -355,6 +355,7 @@ impl EnvFileValues { #[derive(Debug, Default, Deserialize)] struct CliConfigFile { relay: Option<RelayFileConfig>, + signer: Option<SignerFileConfig>, myc: Option<MycFileConfig>, hyf: Option<HyfFileConfig>, rpc: Option<RpcFileConfig>, @@ -379,6 +380,11 @@ struct MycFileConfig { } #[derive(Debug, Default, Deserialize)] +struct SignerFileConfig { + mode: Option<String>, +} + +#[derive(Debug, Default, Deserialize)] struct HyfFileConfig { enabled: Option<bool>, executable: Option<PathBuf>, @@ -562,15 +568,13 @@ impl RuntimeConfig { .or_else(|| env_value(env, env_file, &[ENV_IDENTITY_PATH]).map(PathBuf::from)) .unwrap_or_else(|| paths.default_identity_path.clone()), }, - signer: SignerConfig { - backend: args - .signer - .clone() - .or_else(|| env_value(env, env_file, &[ENV_SIGNER])) - .map(parse_signer_mode) - .transpose()? - .unwrap_or(SignerBackend::Local), - }, + signer: resolve_signer_config( + args, + env, + env_file, + app_config.as_ref(), + workspace_config.as_ref(), + )?, relay: resolve_relay_config( args, env, @@ -952,6 +956,34 @@ fn resolve_relay_config( }) } +fn resolve_signer_config( + args: &CliArgs, + env: &dyn Environment, + env_file: &EnvFileValues, + user_config: Option<&CliConfigFile>, + workspace_config: Option<&CliConfigFile>, +) -> Result<SignerConfig, RuntimeError> { + let backend = if let Some(value) = args.signer.clone() { + parse_signer_mode("internal invocation signer mode", value)? + } else if let Some((key, value)) = env_value_entry(env, env_file, &[ENV_SIGNER]) { + parse_signer_mode(key.as_str(), value)? + } else if let Some(value) = user_config + .and_then(|config| config.signer.as_ref()) + .and_then(|signer| signer.mode.clone()) + { + parse_signer_mode("user config [signer].mode", value)? + } else if let Some(value) = workspace_config + .and_then(|config| config.signer.as_ref()) + .and_then(|signer| signer.mode.clone()) + { + parse_signer_mode("workspace config [signer].mode", value)? + } else { + SignerBackend::Local + }; + + Ok(SignerConfig { backend }) +} + fn resolve_myc_config( args: &CliArgs, env: &dyn Environment, @@ -1393,12 +1425,12 @@ fn parse_output_format(value: &str) -> Result<OutputFormat, RuntimeError> { } } -fn parse_signer_mode(value: String) -> Result<SignerBackend, RuntimeError> { +fn parse_signer_mode(source: &str, value: String) -> Result<SignerBackend, RuntimeError> { match value.trim().to_ascii_lowercase().as_str() { "local" => Ok(SignerBackend::Local), "myc" => Ok(SignerBackend::Myc), other => Err(RuntimeError::Config(format!( - "{ENV_SIGNER} or --signer must be `local` or `myc`, got `{other}`" + "{source} must be `local` or `myc`, got `{other}`" ))), } } @@ -1535,6 +1567,36 @@ mod tests { } } + fn repo_local_env( + workspace_root: PathBuf, + repo_local_root: PathBuf, + user_home: PathBuf, + mut values: BTreeMap<String, String>, + ) -> MapEnvironment { + values.insert( + "RADROOTS_CLI_PATHS_PROFILE".to_owned(), + "repo_local".to_owned(), + ); + values.insert( + "RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT".to_owned(), + repo_local_root.display().to_string(), + ); + + MapEnvironment { + values, + current_dir: workspace_root, + path_resolver: RadrootsPathResolver::new( + RadrootsPlatform::Linux, + RadrootsHostEnvironment { + home_dir: Some(user_home), + ..RadrootsHostEnvironment::default() + }, + ), + stdin_tty: false, + stdout_tty: false, + } + } + #[test] fn flags_override_environment_values() { let args = CliArgs::parse_from([ @@ -2209,6 +2271,83 @@ RADROOTS_CLI_LOGGING_STDOUT=false } #[test] + fn user_signer_config_overrides_workspace_signer_config() { + let temp = tempdir().expect("tempdir"); + let workspace_root = temp.path().join("workspace"); + let repo_local_root = workspace_root.join("infra/local/runtime/radroots"); + let app_config_dir = repo_local_root.join("config/apps/cli"); + let user_home = temp.path().join("home"); + fs::create_dir_all(&app_config_dir).expect("app config dir"); + fs::write( + repo_local_root.join("config.toml"), + "[signer]\nmode = \"myc\"\n", + ) + .expect("write workspace config"); + fs::write( + app_config_dir.join("config.toml"), + "[signer]\nmode = \"local\"\n", + ) + .expect("write user config"); + + let env = repo_local_env(workspace_root, repo_local_root, user_home, BTreeMap::new()); + let args = CliArgs::parse_from(["radroots", "config", "show"]); + + let resolved = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) + .expect("resolve config"); + assert_eq!(resolved.signer.backend, SignerBackend::Local); + } + + #[test] + fn environment_signer_overrides_user_signer_config() { + let temp = tempdir().expect("tempdir"); + let workspace_root = temp.path().join("workspace"); + let repo_local_root = workspace_root.join("infra/local/runtime/radroots"); + let app_config_dir = repo_local_root.join("config/apps/cli"); + let user_home = temp.path().join("home"); + fs::create_dir_all(&app_config_dir).expect("app config dir"); + fs::write( + app_config_dir.join("config.toml"), + "[signer]\nmode = \"local\"\n", + ) + .expect("write user config"); + + let env = repo_local_env( + workspace_root, + repo_local_root, + user_home, + BTreeMap::from([("RADROOTS_SIGNER".to_owned(), "myc".to_owned())]), + ); + let args = CliArgs::parse_from(["radroots", "config", "show"]); + + let resolved = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) + .expect("resolve config"); + assert_eq!(resolved.signer.backend, SignerBackend::Myc); + } + + #[test] + fn invalid_signer_config_reports_config_source() { + let temp = tempdir().expect("tempdir"); + let workspace_root = temp.path().join("workspace"); + let repo_local_root = workspace_root.join("infra/local/runtime/radroots"); + let user_home = temp.path().join("home"); + fs::create_dir_all(&repo_local_root).expect("workspace config dir"); + fs::write( + repo_local_root.join("config.toml"), + "[signer]\nmode = \"remote\"\n", + ) + .expect("write workspace config"); + + let env = repo_local_env(workspace_root, repo_local_root, user_home, BTreeMap::new()); + let args = CliArgs::parse_from(["radroots", "config", "show"]); + + let error = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) + .expect_err("invalid signer mode"); + let message = error.to_string(); + assert!(message.contains("workspace config [signer].mode")); + assert!(!message.contains("--signer")); + } + + #[test] fn user_capability_binding_overrides_workspace_binding() { let temp = tempdir().expect("tempdir"); let workspace_root = temp.path().join("workspace"); diff --git a/src/runtime/farm.rs b/src/runtime/farm.rs @@ -757,15 +757,15 @@ fn binding_error_publish_view( "unconfigured".to_owned(), reason, vec![ - "radroots --signer myc signer status".to_owned(), - "radroots rpc sessions".to_owned(), + "set RADROOTS_SIGNER=myc and run radroots signer status get".to_owned(), + "configure signer.remote_nip46 capability binding".to_owned(), ], ), ActorWriteBindingError::Unavailable(reason) => ( "unavailable".to_owned(), reason, vec![ - "radroots myc status".to_owned(), + "RADROOTS_SIGNER=myc radroots signer status get".to_owned(), "verify RADROOTS_MYC_EXECUTABLE and signer.remote_nip46 binding".to_owned(), ], ), diff --git a/src/runtime/listing.rs b/src/runtime/listing.rs @@ -1701,15 +1701,15 @@ fn binding_error_view( "unconfigured".to_owned(), reason, vec![ - "radroots --signer myc signer status".to_owned(), - "radroots rpc sessions".to_owned(), + "set RADROOTS_SIGNER=myc and run radroots signer status get".to_owned(), + "configure signer.remote_nip46 capability binding".to_owned(), ], ), ActorWriteBindingError::Unavailable(reason) => ( "unavailable".to_owned(), reason, vec![ - "radroots myc status".to_owned(), + "RADROOTS_SIGNER=myc radroots signer status get".to_owned(), "verify RADROOTS_MYC_EXECUTABLE and signer.remote_nip46 binding".to_owned(), ], ), diff --git a/src/runtime/order.rs b/src/runtime/order.rs @@ -1390,15 +1390,15 @@ fn order_binding_error_view( "unconfigured".to_owned(), reason, vec![ - "radroots --signer myc signer status".to_owned(), - "radroots rpc sessions".to_owned(), + "set RADROOTS_SIGNER=myc and run radroots signer status get".to_owned(), + "configure signer.remote_nip46 capability binding".to_owned(), ], ), ActorWriteBindingError::Unavailable(reason) => ( "unavailable".to_owned(), reason, vec![ - "radroots myc status".to_owned(), + "RADROOTS_SIGNER=myc radroots signer status get".to_owned(), "verify RADROOTS_MYC_EXECUTABLE and signer.remote_nip46 binding".to_owned(), ], ), diff --git a/src/runtime/signer.rs b/src/runtime/signer.rs @@ -368,7 +368,7 @@ fn resolve_myc_binding(config: &RuntimeConfig, myc: &MycStatusView) -> MycBindin resolved_signer_session_id: None, matched_session_count: None, reason: Some( - "configure [[capability_binding]] for `signer.remote_nip46` before using `--signer myc`" + "configure [[capability_binding]] for `signer.remote_nip46` before using myc signer mode" .to_owned(), ), }, diff --git a/src/target_cli.rs b/src/target_cli.rs @@ -845,6 +845,7 @@ mod tests { vec!["radroots", "--ndjson", "config", "get"], vec!["radroots", "--yes", "config", "get"], vec!["radroots", "--non-interactive", "config", "get"], + vec!["radroots", "--signer", "myc", "config", "get"], ]; for args in rejected { diff --git a/tests/target_cli.rs b/tests/target_cli.rs @@ -85,6 +85,7 @@ fn removed_global_flags_are_rejected_publicly() { ["--ndjson", "workspace", "get"].as_slice(), ["--yes", "workspace", "get"].as_slice(), ["--non-interactive", "workspace", "get"].as_slice(), + ["--signer", "myc", "workspace", "get"].as_slice(), ] { let output = radroots().args(args).output().expect("run removed flag");