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:
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");