cli

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

commit 0ccd8f54b2faed1e1ba3bff575540051de0b5adb
parent f5b849afcc287bb09544cf7aecc180ff36efac97
Author: triesap <tyson@radroots.org>
Date:   Wed, 17 Jun 2026 14:30:09 -0700

runtime: add CLI SDK adapter boundary

- map CLI runtime config to SDK storage, relay policy, and configured relays
- materialize local account custody as an SDK-ready local event signer
- map SDK errors to CLI envelopes through typed code, class, retry, and recovery metadata
- validate with focused adapter/error tests and CLI nix check/test lanes

Diffstat:
MCargo.lock | 2++
MCargo.toml | 1+
Msrc/ops/error.rs | 146+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/runtime/mod.rs | 1+
Asrc/runtime/sdk.rs | 369+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 519 insertions(+), 0 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -3617,6 +3617,7 @@ dependencies = [ "clap", "flate2", "getrandom 0.2.17", + "radroots_authority", "radroots_core", "radroots_events", "radroots_events_codec", @@ -3917,6 +3918,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "sqlx", "uuid", ] diff --git a/Cargo.toml b/Cargo.toml @@ -26,6 +26,7 @@ chacha20poly1305 = "0.10" chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } clap = { version = "4.5", features = ["derive"] } getrandom = "0.2" +radroots_authority = { path = "../lib/crates/authority", default-features = false, features = ["std", "local_signer"] } radroots_core = { path = "../lib/crates/core", features = ["std", "serde"] } radroots_events = { path = "../lib/crates/events" } radroots_events_codec = { path = "../lib/crates/events_codec", features = ["nostr", "serde_json"] } diff --git a/src/ops/error.rs b/src/ops/error.rs @@ -1,10 +1,12 @@ use std::io::ErrorKind; +use radroots_sdk::{RadrootsSdkError, RadrootsSdkErrorClass, RadrootsSdkRecoveryAction}; use serde_json::{Map, Value, json}; use crate::out::envelope::{CliExitCode, OutputError}; use crate::runtime::RuntimeError; use crate::runtime::account::AccountRuntimeFailure; +use crate::runtime::sdk::CliSdkAdapterError; use crate::view::runtime::CommandDisposition; #[derive(Debug, thiserror::Error, PartialEq, Eq)] @@ -319,6 +321,38 @@ impl OperationAdapterError { } } + pub fn sdk_adapter_failure(operation_id: &str, error: CliSdkAdapterError) -> Self { + match error { + CliSdkAdapterError::Runtime(error) => Self::runtime_failure(operation_id, error), + CliSdkAdapterError::Sdk(error) => Self::sdk_failure(operation_id, error), + } + } + + pub fn sdk_failure(operation_id: &str, error: RadrootsSdkError) -> Self { + let code = error.code().to_owned(); + let class = sdk_error_class_name(error.class()).to_owned(); + let message = error.to_string(); + let exit_code = sdk_error_exit_code(error.class()); + let mut detail = error.detail_json(); + let actions = sdk_recovery_next_actions(operation_id, &error.recovery_actions()); + if !actions.is_empty() + && let Some(detail) = detail.as_object_mut() + { + detail.insert( + "actions".to_owned(), + Value::Array(actions.into_iter().map(Value::String).collect()), + ); + } + Self::DetailedFailure { + operation_id: operation_id.to_owned(), + code, + class, + message, + exit_code, + detail_json: detail.to_string(), + } + } + pub fn to_output_error(&self) -> OutputError { match self { Self::ApprovalRequired { message, .. } => OutputError::new( @@ -506,6 +540,73 @@ impl OperationAdapterError { } } +fn sdk_error_exit_code(class: RadrootsSdkErrorClass) -> CliExitCode { + match class { + RadrootsSdkErrorClass::Authorization => CliExitCode::AuthorizationFailed, + RadrootsSdkErrorClass::Clock + | RadrootsSdkErrorClass::Configuration + | RadrootsSdkErrorClass::Request => CliExitCode::InvalidInput, + RadrootsSdkErrorClass::LocalMutation => CliExitCode::Conflict, + RadrootsSdkErrorClass::Storage => CliExitCode::RuntimeUnavailable, + RadrootsSdkErrorClass::Transport => CliExitCode::SyncOrNetworkFailure, + RadrootsSdkErrorClass::Unsupported => CliExitCode::RuntimeUnavailable, + _ => CliExitCode::InternalError, + } +} + +fn sdk_error_class_name(class: RadrootsSdkErrorClass) -> &'static str { + match class { + RadrootsSdkErrorClass::Authorization => "authorization", + RadrootsSdkErrorClass::Clock => "clock", + RadrootsSdkErrorClass::Configuration => "configuration", + RadrootsSdkErrorClass::LocalMutation => "local_mutation", + RadrootsSdkErrorClass::Request => "request", + RadrootsSdkErrorClass::Storage => "storage", + RadrootsSdkErrorClass::Transport => "transport", + RadrootsSdkErrorClass::Unsupported => "unsupported", + _ => "internal", + } +} + +fn sdk_recovery_next_actions( + operation_id: &str, + recovery_actions: &[RadrootsSdkRecoveryAction], +) -> Vec<String> { + recovery_actions + .iter() + .filter_map(|action| match action { + RadrootsSdkRecoveryAction::RetryOutboxEnqueue + | RadrootsSdkRecoveryAction::RetryOperationWithSameIdempotencyKey + | RadrootsSdkRecoveryAction::FixRequest => Some(operation_retry_action(operation_id)), + RadrootsSdkRecoveryAction::InspectLocalStores => { + Some("radroots store status get".to_owned()) + } + RadrootsSdkRecoveryAction::ConfigureRelayTargets => { + Some("radroots relay list".to_owned()) + } + RadrootsSdkRecoveryAction::SelectAuthorizedActor => { + Some("radroots account list".to_owned()) + } + RadrootsSdkRecoveryAction::RetryAfterTransportFailure => { + Some(operation_retry_action(operation_id)) + } + RadrootsSdkRecoveryAction::EnableRequiredFeature => { + Some("radroots health status get".to_owned()) + } + _ => None, + }) + .fold(Vec::new(), |mut actions, action| { + if !actions.contains(&action) { + actions.push(action); + } + actions + }) +} + +fn operation_retry_action(operation_id: &str) -> String { + format!("radroots {}", operation_id.replace('.', " ")) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum RuntimeFailureAvailability { Unconfigured, @@ -829,3 +930,48 @@ fn runtime_output_error_with_detail( error.detail = Some(Value::Object(detail)); error } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sdk_storage_error_maps_to_typed_output_without_string_classification() { + let error = OperationAdapterError::sdk_failure( + "store.status.get", + RadrootsSdkError::EventStore { + message: "database is locked".to_owned(), + }, + ); + + let output = error.to_output_error(); + + assert_eq!(output.code, "event_store"); + assert_eq!(output.exit_code, CliExitCode::RuntimeUnavailable.code()); + let detail = output.detail.expect("detail"); + assert_eq!(detail["operation_id"], "store.status.get"); + assert_eq!(detail["class"], "storage"); + assert_eq!(detail["retryable"], true); + assert_eq!(detail["detail"]["message"], "database is locked"); + assert_eq!(detail["actions"], json!(["radroots store status get"])); + } + + #[test] + fn sdk_request_error_maps_recovery_to_operation_retry_action() { + let error = OperationAdapterError::sdk_failure( + "listing.publish", + RadrootsSdkError::InvalidRequest { + message: "idempotency key must not contain boundary whitespace".to_owned(), + }, + ); + + let output = error.to_output_error(); + + assert_eq!(output.code, "invalid_request"); + assert_eq!(output.exit_code, CliExitCode::InvalidInput.code()); + let detail = output.detail.expect("detail"); + assert_eq!(detail["class"], "request"); + assert_eq!(detail["retryable"], false); + assert_eq!(detail["actions"], json!(["radroots listing publish"])); + } +} diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs @@ -12,6 +12,7 @@ pub mod network; pub mod order; pub mod paths; pub mod provider; +pub mod sdk; pub mod signer; pub mod store; pub mod sync; diff --git a/src/runtime/sdk.rs b/src/runtime/sdk.rs @@ -0,0 +1,369 @@ +#![allow(dead_code)] + +use std::future::Future; +use std::path::PathBuf; + +use radroots_authority::RadrootsLocalEventSigner; +use radroots_nostr::prelude::RadrootsNostrKeys; +use radroots_sdk::{ + RadrootsSdk, RadrootsSdkBuilder, RadrootsSdkError, RadrootsSdkStorageConfig, SdkRelayUrlPolicy, +}; +use tokio::runtime::{Builder as TokioRuntimeBuilder, Runtime}; + +use crate::runtime::RuntimeError; +use crate::runtime::account; +use crate::runtime::config::RuntimeConfig; + +const SDK_STORAGE_DIR_NAME: &str = "sdk"; + +#[derive(Debug, thiserror::Error)] +pub enum CliSdkAdapterError { + #[error("{0}")] + Runtime(#[from] RuntimeError), + #[error("{0}")] + Sdk(#[from] RadrootsSdkError), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CliSdkConfig { + pub storage_root: PathBuf, + pub relay_url_policy: SdkRelayUrlPolicy, + pub relay_urls: Vec<String>, +} + +impl CliSdkConfig { + pub fn from_runtime_config(config: &RuntimeConfig) -> Self { + Self { + storage_root: sdk_storage_root(config), + relay_url_policy: sdk_relay_url_policy(config), + relay_urls: config.relay.urls.clone(), + } + } + + pub fn builder(&self) -> RadrootsSdkBuilder { + self.relay_urls.iter().fold( + RadrootsSdk::builder() + .storage(RadrootsSdkStorageConfig::Directory( + self.storage_root.clone(), + )) + .relay_url_policy(self.relay_url_policy), + |builder, relay_url| builder.relay_url(relay_url.clone()), + ) + } +} + +pub struct CliSdkSession { + runtime: Runtime, + sdk: RadrootsSdk, + config: CliSdkConfig, +} + +impl CliSdkSession { + pub fn connect(config: &RuntimeConfig) -> Result<Self, CliSdkAdapterError> { + let sdk_config = CliSdkConfig::from_runtime_config(config); + let runtime = TokioRuntimeBuilder::new_multi_thread() + .enable_all() + .build() + .map_err(|error| { + RuntimeError::Config(format!("failed to initialize SDK async runtime: {error}")) + })?; + let sdk = runtime.block_on(sdk_config.builder().build())?; + Ok(Self { + runtime, + sdk, + config: sdk_config, + }) + } + + pub fn sdk(&self) -> &RadrootsSdk { + &self.sdk + } + + pub fn config(&self) -> &CliSdkConfig { + &self.config + } + + pub fn block_on<F>(&self, future: F) -> F::Output + where + F: Future, + { + self.runtime.block_on(future) + } +} + +pub struct CliSdkLocalSigner { + account_id: String, + public_key_hex: String, + signer: RadrootsLocalEventSigner, +} + +impl CliSdkLocalSigner { + pub fn from_runtime_config(config: &RuntimeConfig) -> Result<Self, RuntimeError> { + let signing = account::resolve_local_signing_identity(config)?; + let account_id = signing.account.record.account_id.to_string(); + let public_key_hex = signing + .account + .record + .public_identity + .public_key_hex + .clone(); + let keys: RadrootsNostrKeys = signing.identity.into_keys(); + let signer = RadrootsLocalEventSigner::new(keys) + .map_err(|error| RuntimeError::Config(error.to_string()))?; + Ok(Self { + account_id, + public_key_hex, + signer, + }) + } + + pub fn account_id(&self) -> &str { + self.account_id.as_str() + } + + pub fn public_key_hex(&self) -> &str { + self.public_key_hex.as_str() + } + + pub fn signer(&self) -> &RadrootsLocalEventSigner { + &self.signer + } +} + +pub fn sdk_storage_root(config: &RuntimeConfig) -> PathBuf { + config.local.root.join(SDK_STORAGE_DIR_NAME) +} + +pub fn sdk_relay_url_policy(config: &RuntimeConfig) -> SdkRelayUrlPolicy { + if config + .relay + .urls + .iter() + .any(|relay_url| relay_url.starts_with("ws://")) + { + SdkRelayUrlPolicy::Localhost + } else { + SdkRelayUrlPolicy::Public + } +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::path::{Path, PathBuf}; + + use radroots_authority::RadrootsEventSigner; + use radroots_runtime_paths::RadrootsMigrationReport; + use radroots_sdk::{SdkStorageKind, StorageStatusRequest}; + use radroots_secret_vault::RadrootsSecretBackend; + use tempfile::tempdir; + + use super::*; + use crate::runtime::config::{ + AccountConfig, AccountSecretContractConfig, HyfConfig, IdentityConfig, InteractionConfig, + LocalConfig, LoggingConfig, MigrationConfig, MycConfig, OutputConfig, OutputFormat, + PathsConfig, PublishConfig, PublishMode, PublishModeSource, RelayConfig, RelayConfigSource, + RelayPublishPolicy, RhiConfig, RpcConfig, SignerBackend, SignerConfig, Verbosity, + }; + + #[test] + fn maps_runtime_config_to_sdk_builder_inputs() { + let root = tempdir().expect("tempdir"); + let config = sample_config( + root.path(), + vec!["wss://relay.one".to_owned(), "wss://relay.two".to_owned()], + ); + + let sdk_config = CliSdkConfig::from_runtime_config(&config); + + assert_eq!(sdk_config.storage_root, config.local.root.join("sdk")); + assert_eq!(sdk_config.relay_url_policy, SdkRelayUrlPolicy::Public); + assert_eq!( + sdk_config.relay_urls, + vec!["wss://relay.one".to_owned(), "wss://relay.two".to_owned()] + ); + } + + #[test] + fn maps_localhost_ws_relays_to_localhost_sdk_policy() { + let root = tempdir().expect("tempdir"); + let config = sample_config(root.path(), vec!["ws://127.0.0.1:8080".to_owned()]); + + assert_eq!(sdk_relay_url_policy(&config), SdkRelayUrlPolicy::Localhost); + } + + #[test] + fn materializes_local_account_signer_for_sdk_workflows() { + let root = tempdir().expect("tempdir"); + let config = sample_config(root.path(), Vec::new()); + let account = account::create_or_migrate_default_account(&config).expect("create account"); + + let signer = CliSdkLocalSigner::from_runtime_config(&config).expect("sdk signer"); + + assert_eq!( + signer.account_id(), + account.account.record.account_id.as_str() + ); + assert_eq!( + signer.public_key_hex(), + account.account.record.public_identity.public_key_hex + ); + assert_eq!( + signer.signer().pubkey().as_str(), + account.account.record.public_identity.public_key_hex + ); + } + + #[test] + fn sdk_session_builds_once_and_runs_async_storage_smoke() { + let root = tempdir().expect("tempdir"); + let config = sample_config(root.path(), Vec::new()); + let session = CliSdkSession::connect(&config).expect("sdk session"); + + let status = session + .block_on( + session + .sdk() + .storage_status(StorageStatusRequest::default()), + ) + .expect("storage status"); + + assert_eq!(session.config().storage_root, config.local.root.join("sdk")); + assert_eq!(status.storage, SdkStorageKind::Directory); + assert_eq!(status.event_store.total_events, 0); + assert_eq!(status.outbox.total_events, 0); + } + + #[test] + fn sdk_sources_do_not_import_cli_types() { + let sdk_src = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../../../domains/radroots/sdk/crates/sdk/src"); + let mut files = Vec::new(); + collect_rs_files(sdk_src.as_path(), &mut files); + + for file in files { + let source = fs::read_to_string(&file).expect("read sdk source"); + assert!( + !source.contains("radroots_cli"), + "SDK source imports CLI crate identity in {}", + file.display() + ); + assert!( + !source.contains("domains/radroots/cli"), + "SDK source references CLI path in {}", + file.display() + ); + } + } + + fn collect_rs_files(dir: &Path, files: &mut Vec<PathBuf>) { + for entry in fs::read_dir(dir).expect("read dir") { + let path = entry.expect("entry").path(); + if path.is_dir() { + collect_rs_files(path.as_path(), files); + } else if path.extension().and_then(|extension| extension.to_str()) == Some("rs") { + files.push(path); + } + } + } + + fn sample_config(root: &Path, relays: Vec<String>) -> RuntimeConfig { + let data = root.join("data"); + let logs = root.join("logs"); + let secrets = root.join("secrets"); + RuntimeConfig { + output: OutputConfig { + format: OutputFormat::Json, + verbosity: Verbosity::Normal, + color: false, + dry_run: false, + }, + interaction: InteractionConfig { + input_enabled: false, + assume_yes: false, + stdin_tty: false, + stdout_tty: false, + prompts_allowed: false, + confirmations_allowed: false, + }, + paths: PathsConfig { + profile: "interactive_user".to_owned(), + profile_source: "test".to_owned(), + allowed_profiles: vec!["interactive_user".to_owned(), "repo_local".to_owned()], + root_source: "test".to_owned(), + repo_local_root: None, + repo_local_root_source: None, + subordinate_path_override_source: "runtime_config".to_owned(), + app_namespace: "apps/cli".to_owned(), + shared_accounts_namespace: "shared/accounts".to_owned(), + shared_identities_namespace: "shared/identities".to_owned(), + app_config_path: root.join("config/apps/cli/config.toml"), + workspace_config_path: None, + app_data_root: data.join("apps/cli"), + app_logs_root: logs.join("apps/cli"), + shared_accounts_data_root: data.join("shared/accounts"), + shared_accounts_secrets_root: secrets.join("shared/accounts"), + default_identity_path: secrets.join("shared/identities/default.json"), + }, + migration: MigrationConfig { + report: RadrootsMigrationReport::empty(), + }, + logging: LoggingConfig { + filter: "info".to_owned(), + directory: None, + stdout: false, + }, + account: AccountConfig { + selector: None, + store_path: data.join("shared/accounts/store.json"), + secrets_dir: secrets.join("shared/accounts"), + secret_backend: RadrootsSecretBackend::EncryptedFile, + secret_fallback: None, + }, + account_secret_contract: AccountSecretContractConfig { + default_backend: "host_vault".to_owned(), + default_fallback: Some("encrypted_file".to_owned()), + allowed_backends: vec!["host_vault".to_owned(), "encrypted_file".to_owned()], + host_vault_policy: Some("desktop".to_owned()), + uses_protected_store: true, + }, + identity: IdentityConfig { + path: secrets.join("shared/identities/default.json"), + }, + signer: SignerConfig { + backend: SignerBackend::Local, + }, + publish: PublishConfig { + mode: PublishMode::NostrRelay, + source: PublishModeSource::Defaults, + }, + relay: RelayConfig { + urls: relays, + publish_policy: RelayPublishPolicy::Any, + source: RelayConfigSource::Flags, + }, + local: LocalConfig { + root: data.join("apps/cli/replica"), + replica_db_path: data.join("apps/cli/replica/replica.sqlite"), + backups_dir: data.join("apps/cli/replica/backups"), + exports_dir: data.join("apps/cli/replica/exports"), + }, + myc: MycConfig { + executable: PathBuf::from("myc"), + status_timeout_ms: 2_000, + }, + hyf: HyfConfig { + enabled: false, + executable: PathBuf::from("hyfd"), + }, + rpc: RpcConfig { + url: "http://127.0.0.1:7070".to_owned(), + bridge_bearer_token: None, + }, + rhi: RhiConfig { + trusted_worker_pubkeys: Vec::new(), + }, + capability_bindings: Vec::new(), + } + } +}