commit 35329b336ccff3ee4b7e31552cfdad7ee6c5c174
parent 3d7b6666b5b8bcc6e8384d4efe3a88e99aa4b850
Author: triesap <tyson@radroots.org>
Date: Mon, 6 Apr 2026 21:33:22 +0000
cli: add myc signer status integration
Diffstat:
9 files changed, 665 insertions(+), 26 deletions(-)
diff --git a/src/cli.rs b/src/cli.rs
@@ -32,6 +32,7 @@ pub struct CliArgs {
#[derive(Debug, Clone, Subcommand)]
pub enum Command {
Identity(IdentityArgs),
+ Myc(MycArgs),
Runtime(RuntimeArgs),
Signer(SignerArgs),
}
@@ -60,6 +61,17 @@ pub enum IdentityCommand {
}
#[derive(Debug, Clone, Args)]
+pub struct MycArgs {
+ #[command(subcommand)]
+ pub command: MycCommand,
+}
+
+#[derive(Debug, Clone, Subcommand)]
+pub enum MycCommand {
+ Status,
+}
+
+#[derive(Debug, Clone, Args)]
pub struct SignerArgs {
#[command(subcommand)]
pub command: SignerCommand,
@@ -72,14 +84,16 @@ pub enum SignerCommand {
#[cfg(test)]
mod tests {
- use super::{CliArgs, Command, IdentityCommand, RuntimeCommand, SignerCommand};
+ use super::{CliArgs, Command, IdentityCommand, MycCommand, RuntimeCommand, SignerCommand};
use clap::Parser;
#[test]
fn parses_runtime_show_command() {
let parsed = CliArgs::parse_from(["radroots", "runtime", "show"]);
match parsed.command {
- Command::Identity(_) | Command::Signer(_) => panic!("unexpected command variant"),
+ Command::Identity(_) | Command::Myc(_) | Command::Signer(_) => {
+ panic!("unexpected command variant")
+ }
Command::Runtime(runtime) => match runtime.command {
RuntimeCommand::Show => {}
},
@@ -142,7 +156,9 @@ mod tests {
IdentityCommand::Init => {}
IdentityCommand::Show => panic!("unexpected identity subcommand"),
},
- Command::Runtime(_) | Command::Signer(_) => panic!("unexpected command variant"),
+ Command::Myc(_) | Command::Runtime(_) | Command::Signer(_) => {
+ panic!("unexpected command variant")
+ }
}
let show = CliArgs::parse_from(["radroots", "identity", "show"]);
@@ -151,7 +167,9 @@ mod tests {
IdentityCommand::Show => {}
IdentityCommand::Init => panic!("unexpected identity subcommand"),
},
- Command::Runtime(_) | Command::Signer(_) => panic!("unexpected command variant"),
+ Command::Myc(_) | Command::Runtime(_) | Command::Signer(_) => {
+ panic!("unexpected command variant")
+ }
}
}
@@ -162,7 +180,22 @@ mod tests {
Command::Signer(signer) => match signer.command {
SignerCommand::Status => {}
},
- Command::Identity(_) | Command::Runtime(_) => panic!("unexpected command variant"),
+ Command::Identity(_) | Command::Myc(_) | Command::Runtime(_) => {
+ panic!("unexpected command variant")
+ }
+ }
+ }
+
+ #[test]
+ fn parses_myc_status() {
+ let parsed = CliArgs::parse_from(["radroots", "myc", "status"]);
+ match parsed.command {
+ Command::Myc(myc) => match myc.command {
+ MycCommand::Status => {}
+ },
+ Command::Identity(_) | Command::Runtime(_) | Command::Signer(_) => {
+ panic!("unexpected command variant")
+ }
}
}
}
diff --git a/src/commands/mod.rs b/src/commands/mod.rs
@@ -1,8 +1,9 @@
pub mod identity;
+pub mod myc;
pub mod runtime;
pub mod signer;
-use crate::cli::{Command, IdentityCommand, RuntimeCommand, SignerCommand};
+use crate::cli::{Command, IdentityCommand, MycCommand, RuntimeCommand, SignerCommand};
use crate::domain::CommandOutput;
use crate::runtime::RuntimeError;
use crate::runtime::config::RuntimeConfig;
@@ -18,6 +19,9 @@ pub fn dispatch(
IdentityCommand::Init => Ok(CommandOutput::IdentityInit(identity::init(config)?)),
IdentityCommand::Show => Ok(CommandOutput::IdentityShow(identity::show(config)?)),
},
+ Command::Myc(myc) => match myc.command {
+ MycCommand::Status => Ok(CommandOutput::MycStatus(myc::status(config))),
+ },
Command::Runtime(runtime) => match runtime.command {
RuntimeCommand::Show => Ok(CommandOutput::RuntimeShow(runtime::show(config, logging))),
},
diff --git a/src/commands/myc.rs b/src/commands/myc.rs
@@ -0,0 +1,6 @@
+use crate::domain::runtime::MycStatusView;
+use crate::runtime::config::RuntimeConfig;
+
+pub fn status(config: &RuntimeConfig) -> MycStatusView {
+ crate::runtime::myc::resolve_status(&config.myc)
+}
diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs
@@ -4,6 +4,7 @@ use serde::Serialize;
pub enum CommandOutput {
IdentityInit(IdentityInitView),
IdentityShow(IdentityShowView),
+ MycStatus(MycStatusView),
RuntimeShow(RuntimeShowView),
SignerStatus(SignerStatusView),
}
@@ -78,6 +79,7 @@ pub struct SignerStatusView {
pub state: String,
pub reason: Option<String>,
pub local: Option<LocalSignerStatusView>,
+ pub myc: Option<MycStatusView>,
}
#[derive(Debug, Clone, Serialize)]
@@ -87,3 +89,36 @@ pub struct LocalSignerStatusView {
pub availability: String,
pub secret_backed: bool,
}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct MycStatusView {
+ pub executable: String,
+ pub state: String,
+ pub service_status: Option<String>,
+ pub ready: bool,
+ pub reason: Option<String>,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub reasons: Vec<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub local_signer: Option<LocalSignerStatusView>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub custody: Option<MycCustodyView>,
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct MycCustodyView {
+ pub signer: MycCustodyIdentityView,
+ pub user: MycCustodyIdentityView,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub discovery_app: Option<MycCustodyIdentityView>,
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct MycCustodyIdentityView {
+ pub resolved: bool,
+ pub selected_account_id: Option<String>,
+ pub selected_account_state: Option<String>,
+ pub identity_id: Option<String>,
+ pub public_key_hex: Option<String>,
+ pub error: Option<String>,
+}
diff --git a/src/render/mod.rs b/src/render/mod.rs
@@ -45,6 +45,9 @@ fn render_human(output: &CommandOutput) -> Result<(), RuntimeError> {
view.public_identity.public_key_npub
)?;
}
+ CommandOutput::MycStatus(view) => {
+ render_myc_status(&mut stdout, view)?;
+ }
CommandOutput::RuntimeShow(view) => {
writeln!(stdout, "runtime")?;
writeln!(stdout, " output format: {}", view.output_format)?;
@@ -88,20 +91,10 @@ fn render_human(output: &CommandOutput) -> Result<(), RuntimeError> {
view.reason.as_deref().unwrap_or("<none>")
)?;
if let Some(local) = &view.local {
- writeln!(stdout, "local signer")?;
- writeln!(stdout, " account id: {}", local.account_id)?;
- writeln!(
- stdout,
- " public key hex: {}",
- local.public_identity.public_key_hex
- )?;
- writeln!(
- stdout,
- " public key npub: {}",
- local.public_identity.public_key_npub
- )?;
- writeln!(stdout, " availability: {}", local.availability)?;
- writeln!(stdout, " secret backed: {}", yes_no(local.secret_backed))?;
+ render_local_signer(&mut stdout, "local signer", local)?;
+ }
+ if let Some(myc) = &view.myc {
+ render_myc_status(&mut stdout, myc)?;
}
}
}
@@ -119,6 +112,10 @@ fn render_json(output: &CommandOutput) -> Result<(), RuntimeError> {
serde_json::to_writer_pretty(&mut stdout, view)?;
writeln!(stdout)?;
}
+ CommandOutput::MycStatus(view) => {
+ serde_json::to_writer_pretty(&mut stdout, view)?;
+ writeln!(stdout)?;
+ }
CommandOutput::RuntimeShow(view) => {
serde_json::to_writer_pretty(&mut stdout, view)?;
writeln!(stdout)?;
@@ -135,6 +132,100 @@ fn yes_no(value: bool) -> &'static str {
if value { "yes" } else { "no" }
}
+fn render_local_signer(
+ stdout: &mut dyn Write,
+ heading: &str,
+ local: &crate::domain::runtime::LocalSignerStatusView,
+) -> Result<(), RuntimeError> {
+ writeln!(stdout, "{heading}")?;
+ writeln!(stdout, " account id: {}", local.account_id)?;
+ writeln!(
+ stdout,
+ " public key hex: {}",
+ local.public_identity.public_key_hex
+ )?;
+ writeln!(
+ stdout,
+ " public key npub: {}",
+ local.public_identity.public_key_npub
+ )?;
+ writeln!(stdout, " availability: {}", local.availability)?;
+ writeln!(stdout, " secret backed: {}", yes_no(local.secret_backed))?;
+ Ok(())
+}
+
+fn render_myc_status(
+ stdout: &mut dyn Write,
+ view: &crate::domain::runtime::MycStatusView,
+) -> Result<(), RuntimeError> {
+ writeln!(stdout, "myc")?;
+ writeln!(stdout, " executable: {}", view.executable)?;
+ writeln!(stdout, " state: {}", view.state)?;
+ writeln!(stdout, " ready: {}", yes_no(view.ready))?;
+ writeln!(
+ stdout,
+ " service status: {}",
+ view.service_status.as_deref().unwrap_or("<unknown>")
+ )?;
+ writeln!(
+ stdout,
+ " reason: {}",
+ view.reason.as_deref().unwrap_or("<none>")
+ )?;
+ if !view.reasons.is_empty() {
+ writeln!(stdout, " reasons: {}", view.reasons.join(" | "))?;
+ }
+ if let Some(local_signer) = &view.local_signer {
+ render_local_signer(stdout, "myc local signer", local_signer)?;
+ }
+ if let Some(custody) = &view.custody {
+ render_myc_custody_identity(stdout, "myc custody signer", &custody.signer)?;
+ render_myc_custody_identity(stdout, "myc custody user", &custody.user)?;
+ if let Some(discovery_app) = &custody.discovery_app {
+ render_myc_custody_identity(stdout, "myc custody discovery app", discovery_app)?;
+ }
+ }
+ Ok(())
+}
+
+fn render_myc_custody_identity(
+ stdout: &mut dyn Write,
+ heading: &str,
+ identity: &crate::domain::runtime::MycCustodyIdentityView,
+) -> Result<(), RuntimeError> {
+ writeln!(stdout, "{heading}")?;
+ writeln!(stdout, " resolved: {}", yes_no(identity.resolved))?;
+ writeln!(
+ stdout,
+ " selected account id: {}",
+ identity.selected_account_id.as_deref().unwrap_or("<none>")
+ )?;
+ writeln!(
+ stdout,
+ " selected account state: {}",
+ identity
+ .selected_account_state
+ .as_deref()
+ .unwrap_or("<none>")
+ )?;
+ writeln!(
+ stdout,
+ " identity id: {}",
+ identity.identity_id.as_deref().unwrap_or("<none>")
+ )?;
+ writeln!(
+ stdout,
+ " public key hex: {}",
+ identity.public_key_hex.as_deref().unwrap_or("<none>")
+ )?;
+ writeln!(
+ stdout,
+ " error: {}",
+ identity.error.as_deref().unwrap_or("<none>")
+ )?;
+ Ok(())
+}
+
#[cfg(test)]
mod tests {
use crate::commands::runtime;
diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs
@@ -1,6 +1,7 @@
pub mod config;
pub mod identity;
pub mod logging;
+pub mod myc;
pub mod signer;
use std::process::ExitCode;
diff --git a/src/runtime/myc.rs b/src/runtime/myc.rs
@@ -0,0 +1,270 @@
+use std::process::Command;
+
+use radroots_nostr_signer::prelude::RadrootsNostrLocalSignerCapability;
+use serde::Deserialize;
+
+use crate::domain::runtime::{
+ IdentityPublicView, LocalSignerStatusView, MycCustodyIdentityView, MycCustodyView,
+ MycStatusView,
+};
+use crate::runtime::config::MycConfig;
+
+pub fn resolve_status(config: &MycConfig) -> MycStatusView {
+ let executable = config.executable.display().to_string();
+ if config.executable.as_os_str().is_empty() {
+ return unavailable_status(
+ executable,
+ "unconfigured",
+ "myc executable path is not configured".to_owned(),
+ );
+ }
+
+ let output = match Command::new(&config.executable)
+ .args(["status", "--view", "full"])
+ .output()
+ {
+ Ok(output) => output,
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
+ return unavailable_status(
+ executable,
+ "unavailable",
+ format!(
+ "myc executable was not found at {}",
+ config.executable.display()
+ ),
+ );
+ }
+ Err(error) => {
+ return unavailable_status(
+ executable,
+ "unavailable",
+ format!(
+ "failed to start myc status command at {}: {error}",
+ config.executable.display()
+ ),
+ );
+ }
+ };
+
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
+ let reason = match output.status.code() {
+ Some(code) if stderr.is_empty() => {
+ format!("myc status command exited with status code {code}")
+ }
+ Some(code) => format!("myc status command exited with status code {code}: {stderr}"),
+ None if stderr.is_empty() => "myc status command terminated by signal".to_owned(),
+ None => format!("myc status command terminated by signal: {stderr}"),
+ };
+ return unavailable_status(executable, "unavailable", reason);
+ }
+
+ let stdout = match String::from_utf8(output.stdout) {
+ Ok(stdout) => stdout,
+ Err(error) => {
+ return unavailable_status(
+ executable,
+ "unavailable",
+ format!("myc status output was not valid UTF-8: {error}"),
+ );
+ }
+ };
+
+ let payload = match serde_json::from_str::<MycStatusPayload>(stdout.as_str()) {
+ Ok(payload) => payload,
+ Err(error) => {
+ return unavailable_status(
+ executable,
+ "unavailable",
+ format!("myc status output was not valid JSON: {error}"),
+ );
+ }
+ };
+
+ let local_signer = payload
+ .signer_backend
+ .local_signer
+ .map(local_signer_status_view);
+ let custody = payload.custody.into_view();
+ let state = if payload.ready {
+ "ready"
+ } else {
+ match payload.status.as_str() {
+ "degraded" => "degraded",
+ _ => "unavailable",
+ }
+ };
+ let reason = primary_reason(
+ payload.ready,
+ payload.status.as_str(),
+ payload.reasons.as_slice(),
+ );
+
+ MycStatusView {
+ executable,
+ state: state.to_owned(),
+ service_status: Some(payload.status),
+ ready: payload.ready,
+ reason,
+ reasons: payload.reasons,
+ local_signer,
+ custody,
+ }
+}
+
+fn local_signer_status_view(
+ capability: RadrootsNostrLocalSignerCapability,
+) -> LocalSignerStatusView {
+ LocalSignerStatusView {
+ account_id: capability.account_id.to_string(),
+ public_identity: IdentityPublicView::from_public_identity(&capability.public_identity),
+ availability: match capability.availability {
+ radroots_nostr_signer::prelude::RadrootsNostrLocalSignerAvailability::PublicOnly => {
+ "public_only".to_owned()
+ }
+ radroots_nostr_signer::prelude::RadrootsNostrLocalSignerAvailability::SecretBacked => {
+ "secret_backed".to_owned()
+ }
+ },
+ secret_backed: capability.is_secret_backed(),
+ }
+}
+
+fn primary_reason(ready: bool, service_status: &str, reasons: &[String]) -> Option<String> {
+ if ready {
+ return None;
+ }
+
+ reasons
+ .first()
+ .cloned()
+ .or_else(|| Some(format!("myc reported service status `{service_status}`")))
+}
+
+fn unavailable_status(executable: String, state: &str, reason: String) -> MycStatusView {
+ MycStatusView {
+ executable,
+ state: state.to_owned(),
+ service_status: None,
+ ready: false,
+ reason: Some(reason),
+ reasons: Vec::new(),
+ local_signer: None,
+ custody: None,
+ }
+}
+
+#[derive(Debug, Deserialize)]
+struct MycStatusPayload {
+ status: String,
+ ready: bool,
+ #[serde(default)]
+ reasons: Vec<String>,
+ #[serde(default)]
+ signer_backend: MycSignerBackendPayload,
+ #[serde(default)]
+ custody: MycCustodyPayload,
+}
+
+#[derive(Debug, Default, Deserialize)]
+struct MycSignerBackendPayload {
+ #[serde(default)]
+ local_signer: Option<RadrootsNostrLocalSignerCapability>,
+}
+
+#[derive(Debug, Default, Deserialize)]
+struct MycCustodyPayload {
+ #[serde(default)]
+ signer: MycCustodyIdentityPayload,
+ #[serde(default)]
+ user: MycCustodyIdentityPayload,
+ #[serde(default)]
+ discovery_app: Option<MycCustodyIdentityPayload>,
+}
+
+impl MycCustodyPayload {
+ fn into_view(self) -> Option<MycCustodyView> {
+ if !self.signer.has_data()
+ && !self.user.has_data()
+ && self
+ .discovery_app
+ .as_ref()
+ .is_none_or(|identity| !identity.has_data())
+ {
+ return None;
+ }
+
+ Some(MycCustodyView {
+ signer: self.signer.into_view(),
+ user: self.user.into_view(),
+ discovery_app: self.discovery_app.and_then(|identity| {
+ if identity.has_data() {
+ Some(identity.into_view())
+ } else {
+ None
+ }
+ }),
+ })
+ }
+}
+
+#[derive(Debug, Default, Deserialize)]
+struct MycCustodyIdentityPayload {
+ #[serde(default)]
+ resolved: bool,
+ #[serde(default)]
+ selected_account_id: Option<String>,
+ #[serde(default)]
+ selected_account_state: Option<String>,
+ #[serde(default)]
+ identity_id: Option<String>,
+ #[serde(default)]
+ public_key_hex: Option<String>,
+ #[serde(default)]
+ error: Option<String>,
+}
+
+impl MycCustodyIdentityPayload {
+ fn has_data(&self) -> bool {
+ self.resolved
+ || self.selected_account_id.is_some()
+ || self.selected_account_state.is_some()
+ || self.identity_id.is_some()
+ || self.public_key_hex.is_some()
+ || self.error.is_some()
+ }
+
+ fn into_view(self) -> MycCustodyIdentityView {
+ MycCustodyIdentityView {
+ resolved: self.resolved,
+ selected_account_id: self.selected_account_id,
+ selected_account_state: self.selected_account_state,
+ identity_id: self.identity_id,
+ public_key_hex: self.public_key_hex,
+ error: self.error,
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use std::path::PathBuf;
+
+ use super::resolve_status;
+ use crate::runtime::config::MycConfig;
+
+ #[test]
+ fn empty_executable_path_reports_unconfigured_status() {
+ let view = resolve_status(&MycConfig {
+ executable: PathBuf::new(),
+ });
+
+ assert_eq!(view.state, "unconfigured");
+ assert_eq!(view.ready, false);
+ assert!(
+ view.reason
+ .as_deref()
+ .is_some_and(|value| value.contains("not configured"))
+ );
+ }
+}
diff --git a/src/runtime/signer.rs b/src/runtime/signer.rs
@@ -9,12 +9,7 @@ use radroots_nostr_signer::prelude::{
pub fn resolve_signer_status(config: &RuntimeConfig) -> SignerStatusView {
match config.signer.backend {
SignerBackend::Local => resolve_local_signer_status(config),
- SignerBackend::Myc => SignerStatusView {
- backend: config.signer.backend.as_str().to_owned(),
- state: "unconfigured".to_owned(),
- reason: Some("myc backend is not bootstrapped in this slice".to_owned()),
- local: None,
- },
+ SignerBackend::Myc => resolve_myc_signer_status(config),
}
}
@@ -44,6 +39,7 @@ fn resolve_local_signer_status(config: &RuntimeConfig) -> SignerStatusView {
availability: local_availability(local.availability).to_owned(),
secret_backed: local.is_secret_backed(),
}),
+ myc: None,
}
}
Err(crate::runtime::RuntimeError::Identity(IdentityError::NotFound(path))) => {
@@ -55,6 +51,7 @@ fn resolve_local_signer_status(config: &RuntimeConfig) -> SignerStatusView {
path.display()
)),
local: None,
+ myc: None,
}
}
Err(error) => SignerStatusView {
@@ -62,10 +59,22 @@ fn resolve_local_signer_status(config: &RuntimeConfig) -> SignerStatusView {
state: "error".to_owned(),
reason: Some(error.to_string()),
local: None,
+ myc: None,
},
}
}
+fn resolve_myc_signer_status(config: &RuntimeConfig) -> SignerStatusView {
+ let myc = crate::runtime::myc::resolve_status(&config.myc);
+ SignerStatusView {
+ backend: config.signer.backend.as_str().to_owned(),
+ state: myc.state.clone(),
+ reason: myc.reason.clone(),
+ local: None,
+ myc: Some(myc),
+ }
+}
+
fn local_availability(value: RadrootsNostrLocalSignerAvailability) -> &'static str {
match value {
RadrootsNostrLocalSignerAvailability::PublicOnly => "public_only",
diff --git a/tests/myc_status.rs b/tests/myc_status.rs
@@ -0,0 +1,190 @@
+use std::fs;
+use std::os::unix::fs::PermissionsExt;
+use std::process::Command;
+
+use assert_cmd::prelude::*;
+use radroots_identity::RadrootsIdentity;
+use serde_json::{Value, json};
+use tempfile::tempdir;
+
+#[test]
+fn myc_status_reports_ready_for_valid_full_status_payload() {
+ let dir = tempdir().expect("tempdir");
+ let executable = write_fake_myc(
+ dir.path(),
+ successful_status_script(sample_status_payload(true).to_string()).as_str(),
+ );
+
+ let output = Command::cargo_bin("radroots")
+ .expect("binary")
+ .args([
+ "--json",
+ "--myc-executable",
+ executable.to_str().expect("executable path"),
+ "myc",
+ "status",
+ ])
+ .output()
+ .expect("run myc status");
+
+ assert!(output.status.success());
+ let stdout = String::from_utf8(output.stdout).expect("utf8 stdout");
+ let json: Value = serde_json::from_str(stdout.as_str()).expect("json output");
+ assert_eq!(json["state"], "ready");
+ assert_eq!(json["ready"], true);
+ assert_eq!(json["service_status"], "healthy");
+ assert_eq!(json["local_signer"]["availability"], "secret_backed");
+ assert_eq!(json["custody"]["signer"]["resolved"], true);
+ assert_eq!(
+ json["custody"]["user"]["selected_account_state"],
+ "public_only"
+ );
+}
+
+#[test]
+fn myc_status_reports_unavailable_for_invalid_status_payload() {
+ let dir = tempdir().expect("tempdir");
+ let executable = write_fake_myc(dir.path(), "#!/bin/sh\nprintf '%s\\n' 'this is not json'\n");
+
+ let output = Command::cargo_bin("radroots")
+ .expect("binary")
+ .args([
+ "--json",
+ "--myc-executable",
+ executable.to_str().expect("executable path"),
+ "myc",
+ "status",
+ ])
+ .output()
+ .expect("run myc status");
+
+ assert!(output.status.success());
+ let stdout = String::from_utf8(output.stdout).expect("utf8 stdout");
+ let json: Value = serde_json::from_str(stdout.as_str()).expect("json output");
+ assert_eq!(json["state"], "unavailable");
+ assert_eq!(json["ready"], false);
+ assert!(
+ json["reason"]
+ .as_str()
+ .is_some_and(|value| value.contains("not valid JSON"))
+ );
+}
+
+#[test]
+fn signer_status_reports_myc_backend_details_when_configured() {
+ let dir = tempdir().expect("tempdir");
+ let executable = write_fake_myc(
+ dir.path(),
+ successful_status_script(sample_status_payload(false).to_string()).as_str(),
+ );
+
+ let output = Command::cargo_bin("radroots")
+ .expect("binary")
+ .args([
+ "--json",
+ "--signer-backend",
+ "myc",
+ "--myc-executable",
+ executable.to_str().expect("executable path"),
+ "signer",
+ "status",
+ ])
+ .output()
+ .expect("run signer status");
+
+ assert!(output.status.success());
+ let stdout = String::from_utf8(output.stdout).expect("utf8 stdout");
+ let json: Value = serde_json::from_str(stdout.as_str()).expect("json output");
+ assert_eq!(json["backend"], "myc");
+ assert_eq!(json["state"], "degraded");
+ assert_eq!(json["myc"]["state"], "degraded");
+ assert_eq!(json["myc"]["service_status"], "degraded");
+ assert!(
+ json["reason"]
+ .as_str()
+ .is_some_and(|value| value.contains("transport quorum is below target"))
+ );
+}
+
+#[test]
+fn myc_status_reports_unavailable_when_executable_is_missing() {
+ let dir = tempdir().expect("tempdir");
+ let missing = dir.path().join("missing-myc");
+
+ let output = Command::cargo_bin("radroots")
+ .expect("binary")
+ .args([
+ "--json",
+ "--myc-executable",
+ missing.to_str().expect("missing path"),
+ "myc",
+ "status",
+ ])
+ .output()
+ .expect("run myc status");
+
+ assert!(output.status.success());
+ let stdout = String::from_utf8(output.stdout).expect("utf8 stdout");
+ let json: Value = serde_json::from_str(stdout.as_str()).expect("json output");
+ assert_eq!(json["state"], "unavailable");
+ assert!(
+ json["reason"]
+ .as_str()
+ .is_some_and(|value| value.contains("was not found"))
+ );
+}
+
+fn write_fake_myc(dir: &std::path::Path, script: &str) -> std::path::PathBuf {
+ let path = dir.join("fake-myc");
+ fs::write(&path, script).expect("write fake myc");
+ let mut permissions = fs::metadata(&path).expect("metadata").permissions();
+ permissions.set_mode(0o755);
+ fs::set_permissions(&path, permissions).expect("chmod fake myc");
+ path
+}
+
+fn successful_status_script(payload_json: String) -> String {
+ format!(
+ "#!/bin/sh\nif [ \"$1\" != \"status\" ] || [ \"$2\" != \"--view\" ] || [ \"$3\" != \"full\" ]; then\n echo \"unexpected args: $*\" >&2\n exit 64\nfi\ncat <<'JSON'\n{payload_json}\nJSON\n"
+ )
+}
+
+fn sample_status_payload(ready: bool) -> Value {
+ let signer_identity = RadrootsIdentity::generate().to_public();
+ let user_identity = RadrootsIdentity::generate().to_public();
+ let service_status = if ready { "healthy" } else { "degraded" };
+ let reasons = if ready {
+ Vec::<String>::new()
+ } else {
+ vec!["transport quorum is below target".to_owned()]
+ };
+
+ json!({
+ "status": service_status,
+ "ready": ready,
+ "reasons": reasons,
+ "signer_backend": {
+ "local_signer": {
+ "account_id": signer_identity.id,
+ "public_identity": signer_identity,
+ "availability": "SecretBacked"
+ }
+ },
+ "custody": {
+ "signer": {
+ "resolved": true,
+ "selected_account_id": "signer-account",
+ "selected_account_state": "ready",
+ "identity_id": signer_identity.id,
+ "public_key_hex": signer_identity.public_key_hex
+ },
+ "user": {
+ "resolved": true,
+ "selected_account_id": "user-account",
+ "selected_account_state": "public_only",
+ "identity_id": user_identity.id,
+ "public_key_hex": user_identity.public_key_hex
+ }
+ }
+ })
+}