cli

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

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:
Msrc/cli.rs | 43++++++++++++++++++++++++++++++++++++++-----
Msrc/commands/mod.rs | 6+++++-
Asrc/commands/myc.rs | 6++++++
Msrc/domain/runtime.rs | 35+++++++++++++++++++++++++++++++++++
Msrc/render/mod.rs | 119+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Msrc/runtime/mod.rs | 1+
Asrc/runtime/myc.rs | 270+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/runtime/signer.rs | 21+++++++++++++++------
Atests/myc_status.rs | 190+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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 + } + } + }) +}