cli

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

commit 5399882fe1e6070c061849730d6a9e233292a6d4
parent 5597fc481c64f1c72f5e77977e1c088768e7d88e
Author: triesap <tyson@radroots.org>
Date:   Thu, 16 Apr 2026 01:31:55 +0000

cli: add farm publish

Diffstat:
Msrc/cli.rs | 47+++++++++++++++++++++++++++++++++++++++++++++++
Msrc/commands/farm.rs | 31+++++++++++++++++++++++++++++--
Msrc/commands/mod.rs | 1+
Msrc/domain/runtime.rs | 89+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/render/mod.rs | 75++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Msrc/runtime/daemon.rs | 125++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/runtime/farm.rs | 731++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
7 files changed, 1086 insertions(+), 13 deletions(-)

diff --git a/src/cli.rs b/src/cli.rs @@ -85,6 +85,7 @@ impl Command { }, Self::Doctor => "doctor", Self::Farm(farm) => match farm.command { + FarmCommand::Publish(_) => "farm publish", FarmCommand::Setup(_) => "farm setup", FarmCommand::Status(_) => "farm status", FarmCommand::Get(_) => "farm get", @@ -271,12 +272,27 @@ pub struct FarmArgs { #[derive(Debug, Clone, Subcommand)] pub enum FarmCommand { + Publish(FarmPublishArgs), Setup(FarmSetupArgs), Status(FarmScopedArgs), Get(FarmScopedArgs), } #[derive(Debug, Clone, Args, Default)] +pub struct FarmPublishArgs { + #[arg(long, value_enum)] + pub scope: Option<FarmScopeArg>, + #[arg(long = "idempotency-key")] + pub idempotency_key: Option<String>, + #[arg(long = "signer-session-id")] + pub signer_session_id: Option<String>, + #[arg(long = "print-job", action = ArgAction::SetTrue)] + pub print_job: bool, + #[arg(long = "print-event", action = ArgAction::SetTrue)] + pub print_event: bool, +} + +#[derive(Debug, Clone, Args, Default)] pub struct FarmScopedArgs { #[arg(long, value_enum)] pub scope: Option<FarmScopeArg>, @@ -796,6 +812,33 @@ mod tests { _ => panic!("unexpected command variant"), } + let farm_publish = CliArgs::parse_from([ + "radroots", + "farm", + "publish", + "--scope", + "workspace", + "--idempotency-key", + "farm-publish-1", + "--signer-session-id", + "session-1", + "--print-job", + "--print-event", + ]); + match farm_publish.command { + Command::Farm(args) => match args.command { + FarmCommand::Publish(publish) => { + assert_eq!(publish.scope, Some(FarmScopeArg::Workspace)); + assert_eq!(publish.idempotency_key.as_deref(), Some("farm-publish-1")); + assert_eq!(publish.signer_session_id.as_deref(), Some("session-1")); + assert!(publish.print_job); + assert!(publish.print_event); + } + _ => panic!("unexpected farm subcommand"), + }, + _ => panic!("unexpected command variant"), + } + let relay = CliArgs::parse_from(["radroots", "relay", "ls"]); match relay.command { Command::Relay(args) => match args.command { @@ -1220,6 +1263,10 @@ mod tests { .supports_output_format(OutputFormat::Ndjson) ); + let farm_publish = CliArgs::parse_from(["radroots", "farm", "publish"]); + assert_eq!(farm_publish.command.display_name(), "farm publish"); + assert!(farm_publish.command.supports_dry_run()); + let find = CliArgs::parse_from(["radroots", "find", "eggs"]); assert!(find.command.supports_output_format(OutputFormat::Ndjson)); diff --git a/src/commands/farm.rs b/src/commands/farm.rs @@ -1,6 +1,7 @@ -use crate::cli::{FarmScopedArgs, FarmSetupArgs}; +use crate::cli::{FarmPublishArgs, FarmScopedArgs, FarmSetupArgs}; use crate::domain::runtime::{ - CommandDisposition, CommandOutput, CommandView, FarmGetView, FarmSetupView, FarmStatusView, + CommandDisposition, CommandOutput, CommandView, FarmGetView, FarmPublishView, FarmSetupView, + FarmStatusView, }; use crate::runtime::RuntimeError; use crate::runtime::config::RuntimeConfig; @@ -10,6 +11,14 @@ pub fn setup(config: &RuntimeConfig, args: &FarmSetupArgs) -> Result<CommandOutp Ok(farm_setup_output(view)) } +pub fn publish( + config: &RuntimeConfig, + args: &FarmPublishArgs, +) -> Result<CommandOutput, RuntimeError> { + let view = crate::runtime::farm::publish(config, args)?; + Ok(farm_publish_output(view)) +} + pub fn status( config: &RuntimeConfig, args: &FarmScopedArgs, @@ -23,6 +32,24 @@ pub fn get(config: &RuntimeConfig, args: &FarmScopedArgs) -> Result<CommandOutpu Ok(farm_get_output(view)) } +fn farm_publish_output(view: FarmPublishView) -> CommandOutput { + match view.disposition() { + CommandDisposition::Success => CommandOutput::success(CommandView::FarmPublish(view)), + CommandDisposition::Unconfigured => { + CommandOutput::unconfigured(CommandView::FarmPublish(view)) + } + CommandDisposition::ExternalUnavailable => { + CommandOutput::external_unavailable(CommandView::FarmPublish(view)) + } + CommandDisposition::Unsupported => { + CommandOutput::unsupported(CommandView::FarmPublish(view)) + } + CommandDisposition::InternalError => { + CommandOutput::internal_error(CommandView::FarmPublish(view)) + } + } +} + fn farm_setup_output(view: FarmSetupView) -> CommandOutput { match view.disposition() { CommandDisposition::Success => CommandOutput::success(CommandView::FarmSetup(view)), diff --git a/src/commands/mod.rs b/src/commands/mod.rs @@ -53,6 +53,7 @@ pub fn dispatch( }, Command::Doctor => doctor::report(config, logging), Command::Farm(farm_command) => match &farm_command.command { + FarmCommand::Publish(args) => farm::publish(config, args), FarmCommand::Setup(args) => farm::setup(config, args), FarmCommand::Status(args) => farm::status(config, args), FarmCommand::Get(args) => farm::get(config, args), diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs @@ -87,6 +87,7 @@ pub enum CommandView { ConfigShow(ConfigShowView), Doctor(DoctorView), FarmGet(FarmGetView), + FarmPublish(FarmPublishView), FarmSetup(FarmSetupView), FarmStatus(FarmStatusView), Find(FindView), @@ -710,6 +711,94 @@ impl FarmGetView { } #[derive(Debug, Clone, Serialize)] +pub struct FarmPublishView { + pub state: String, + pub source: String, + pub scope: String, + pub path: String, + pub config_present: bool, + pub dry_run: bool, + pub selected_account_id: String, + pub selected_account_pubkey: String, + pub farm_d_tag: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub requested_signer_session_id: Option<String>, + pub profile: FarmPublishComponentView, + pub farm: FarmPublishComponentView, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub actions: Vec<String>, +} + +impl FarmPublishView { + pub fn disposition(&self) -> CommandDisposition { + match self.state.as_str() { + "unconfigured" => CommandDisposition::Unconfigured, + "partial" | "unavailable" => CommandDisposition::ExternalUnavailable, + "error" => CommandDisposition::InternalError, + _ => CommandDisposition::Success, + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct FarmPublishComponentView { + pub state: String, + pub rpc_method: String, + pub event_kind: u32, + pub deduplicated: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub job_id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub job_status: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub signer_mode: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub signer_session_id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub event_id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub event_addr: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub idempotency_key: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub job: Option<FarmPublishJobView>, + #[serde(skip_serializing_if = "Option::is_none")] + pub event: Option<FarmPublishEventView>, +} + +#[derive(Debug, Clone, Serialize)] +pub struct FarmPublishJobView { + pub rpc_method: String, + pub state: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub job_id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub idempotency_key: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub requested_signer_session_id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub signer_mode: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub signer_session_id: Option<String>, +} + +#[derive(Debug, Clone, Serialize)] +pub struct FarmPublishEventView { + pub kind: u32, + pub author: String, + pub content: String, + pub tags: Vec<Vec<String>>, + #[serde(skip_serializing_if = "Option::is_none")] + pub event_id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub event_addr: Option<String>, +} + +#[derive(Debug, Clone, Serialize)] pub struct FarmConfigSummaryView { pub scope: String, pub path: String, diff --git a/src/render/mod.rs b/src/render/mod.rs @@ -2,13 +2,14 @@ use std::io::{self, Write}; use crate::domain::runtime::{ AccountListView, AccountSummaryView, CommandOutput, CommandView, DoctorCheckView, DoctorView, - FarmConfigSummaryView, FarmGetView, FarmSetupView, FarmStatusView, FindView, JobGetView, - JobListView, JobWatchView, ListingGetView, ListingMutationView, ListingNewView, - ListingValidateView, LocalBackupView, LocalExportView, LocalInitView, LocalStatusView, - NetStatusView, OrderCancelView, OrderDraftItemView, OrderGetView, OrderHistoryView, - OrderJobView, OrderListView, OrderNewView, OrderSubmitView, OrderWatchView, OrderWorkflowView, - RelayListView, RpcSessionsView, RpcStatusView, RuntimeActionView, RuntimeLogsView, - RuntimeManagedConfigView, RuntimeStatusView, SyncActionView, SyncStatusView, SyncWatchView, + FarmConfigSummaryView, FarmGetView, FarmPublishComponentView, FarmPublishView, FarmSetupView, + FarmStatusView, FindView, JobGetView, JobListView, JobWatchView, ListingGetView, + ListingMutationView, ListingNewView, ListingValidateView, LocalBackupView, LocalExportView, + LocalInitView, LocalStatusView, NetStatusView, OrderCancelView, OrderDraftItemView, + OrderGetView, OrderHistoryView, OrderJobView, OrderListView, OrderNewView, OrderSubmitView, + OrderWatchView, OrderWorkflowView, RelayListView, RpcSessionsView, RpcStatusView, + RuntimeActionView, RuntimeLogsView, RuntimeManagedConfigView, RuntimeStatusView, + SyncActionView, SyncStatusView, SyncWatchView, }; use crate::runtime::RuntimeError; use crate::runtime::config::{OutputConfig, OutputFormat}; @@ -117,6 +118,9 @@ fn render_human_to(stdout: &mut dyn Write, output: &CommandOutput) -> Result<(), CommandView::FarmGet(view) => { render_farm_get(stdout, view)?; } + CommandView::FarmPublish(view) => { + render_farm_publish(stdout, view)?; + } CommandView::FarmSetup(view) => { render_farm_setup(stdout, view)?; } @@ -303,6 +307,10 @@ fn render_json_to(stdout: &mut dyn Write, output: &CommandOutput) -> Result<(), serde_json::to_writer_pretty(&mut *stdout, view)?; writeln!(stdout)?; } + CommandView::FarmPublish(view) => { + serde_json::to_writer_pretty(&mut *stdout, view)?; + writeln!(stdout)?; + } CommandView::FarmSetup(view) => { serde_json::to_writer_pretty(&mut *stdout, view)?; writeln!(stdout)?; @@ -2267,6 +2275,58 @@ fn render_farm_get(stdout: &mut dyn Write, view: &FarmGetView) -> Result<(), Run Ok(()) } +fn render_farm_publish(stdout: &mut dyn Write, view: &FarmPublishView) -> Result<(), RuntimeError> { + write_context(stdout, format!("farm publish · {}", view.state).as_str())?; + render_owned_pairs( + stdout, + "farm", + &[ + ("scope", view.scope.clone()), + ("path", view.path.clone()), + ("account id", view.selected_account_id.clone()), + ("account pubkey", view.selected_account_pubkey.clone()), + ("farm d_tag", view.farm_d_tag.clone()), + ("dry run", yes_no(view.dry_run).to_owned()), + ], + )?; + render_farm_publish_component(stdout, "profile", &view.profile)?; + render_farm_publish_component(stdout, "farm record", &view.farm)?; + if let Some(reason) = &view.reason { + writeln!(stdout, "reason: {reason}")?; + } + writeln!(stdout, "source: {}", view.source)?; + render_actions(stdout, &view.actions)?; + Ok(()) +} + +fn render_farm_publish_component( + stdout: &mut dyn Write, + label: &str, + component: &FarmPublishComponentView, +) -> Result<(), RuntimeError> { + let mut rows = vec![ + ("state", component.state.clone()), + ("rpc method", component.rpc_method.clone()), + ("event kind", component.event_kind.to_string()), + ]; + if let Some(job_id) = &component.job_id { + rows.push(("job id", job_id.clone())); + } + if let Some(job_status) = &component.job_status { + rows.push(("job status", job_status.clone())); + } + if let Some(event_id) = &component.event_id { + rows.push(("event id", event_id.clone())); + } + if let Some(event_addr) = &component.event_addr { + rows.push(("event addr", event_addr.clone())); + } + if let Some(reason) = &component.reason { + rows.push(("reason", reason.clone())); + } + render_owned_pairs(stdout, label, rows.as_slice()) +} + fn render_farm_summary( stdout: &mut dyn Write, config: &FarmConfigSummaryView, @@ -2701,6 +2761,7 @@ fn human_command_name(view: &CommandView) -> &'static str { CommandView::ConfigShow(_) => "config show", CommandView::Doctor(_) => "doctor", CommandView::FarmGet(_) => "farm get", + CommandView::FarmPublish(_) => "farm publish", CommandView::FarmSetup(_) => "farm setup", CommandView::FarmStatus(_) => "farm status", CommandView::Find(_) => "find", diff --git a/src/runtime/daemon.rs b/src/runtime/daemon.rs @@ -1,9 +1,12 @@ use std::time::Duration; +use radroots_events::farm::RadrootsFarm; use radroots_events::listing::RadrootsListing; +use radroots_events::profile::{RadrootsProfile, RadrootsProfileType}; use radroots_events::trade::RadrootsTradeOrder; use radroots_sdk::{ - RadrootsSdkConfig, RadrootsdAuth, SdkPublishError, SdkRadrootsdListingPublishOptions, + RadrootsSdkConfig, RadrootsdAuth, SdkPublishError, SdkRadrootsdFarmPublishOptions, + SdkRadrootsdListingPublishOptions, SdkRadrootsdProfilePublishOptions, SdkRadrootsdSignerAuthority, SdkRadrootsdSignerSessionRef, SdkTransportMode, SignerConfig, }; use reqwest::blocking::Client; @@ -145,6 +148,19 @@ pub struct BridgeListingPublishResult { } #[derive(Debug, Clone)] +pub struct BridgeEventPublishResult { + pub deduplicated: bool, + pub job_id: String, + pub idempotency_key: Option<String>, + pub status: String, + pub signer_mode: String, + pub signer_session_id: Option<String>, + pub event_kind: Option<u32>, + pub event_id: Option<String>, + pub event_addr: Option<String>, +} + +#[derive(Debug, Clone)] pub struct BridgeOrderRequestResult { pub deduplicated: bool, pub job_id: String, @@ -416,6 +432,72 @@ pub fn bridge_listing_publish( map_listing_publish_receipt(receipt, idempotency_key) } +pub fn bridge_profile_publish( + config: &RuntimeConfig, + profile: &RadrootsProfile, + profile_type: Option<RadrootsProfileType>, + idempotency_key: Option<&str>, + signer_session_id: Option<&str>, + signer_authority: Option<&ActorWriteSignerAuthority>, +) -> Result<BridgeEventPublishResult, DaemonRpcError> { + let Some(signer_session_id) = signer_session_id else { + return Err(DaemonRpcError::Unconfigured( + "profile publish requires a signer session id".to_owned(), + )); + }; + + let sdk = actor_write_sdk_client(config)?; + let session = SdkRadrootsdSignerSessionRef::from_session_id(signer_session_id.to_owned()); + let mut options = SdkRadrootsdProfilePublishOptions::from_signer_session_ref(&session); + if let Some(idempotency_key) = idempotency_key { + options = options.with_idempotency_key(idempotency_key.to_owned()); + } + if let Some(signer_authority) = signer_authority { + options = options.with_signer_authority(sdk_signer_authority(signer_authority)); + } + + let receipt = block_on_sdk(sdk.profile().publish_profile_via_radrootsd_with_options( + profile, + profile_type, + &options, + ))? + .map_err(map_sdk_publish_error)?; + + map_event_publish_receipt(receipt, idempotency_key, "profile") +} + +pub fn bridge_farm_publish( + config: &RuntimeConfig, + farm: &RadrootsFarm, + idempotency_key: Option<&str>, + signer_session_id: Option<&str>, + signer_authority: Option<&ActorWriteSignerAuthority>, +) -> Result<BridgeEventPublishResult, DaemonRpcError> { + let Some(signer_session_id) = signer_session_id else { + return Err(DaemonRpcError::Unconfigured( + "farm publish requires a signer session id".to_owned(), + )); + }; + + let sdk = actor_write_sdk_client(config)?; + let session = SdkRadrootsdSignerSessionRef::from_session_id(signer_session_id.to_owned()); + let mut options = SdkRadrootsdFarmPublishOptions::from_signer_session_ref(&session); + if let Some(idempotency_key) = idempotency_key { + options = options.with_idempotency_key(idempotency_key.to_owned()); + } + if let Some(signer_authority) = signer_authority { + options = options.with_signer_authority(sdk_signer_authority(signer_authority)); + } + + let receipt = block_on_sdk( + sdk.farm() + .publish_farm_via_radrootsd_with_options(farm, &options), + )? + .map_err(map_sdk_publish_error)?; + + map_event_publish_receipt(receipt, idempotency_key, "farm") +} + pub fn bridge_order_request( config: &RuntimeConfig, order: &RadrootsTradeOrder, @@ -575,6 +657,45 @@ fn map_listing_publish_receipt( }) } +fn map_event_publish_receipt( + receipt: radroots_sdk::SdkPublishReceipt, + idempotency_key: Option<&str>, + label: &str, +) -> Result<BridgeEventPublishResult, DaemonRpcError> { + let radroots_sdk::SdkTransportReceipt::Radrootsd(transport_receipt) = receipt.transport_receipt + else { + return Err(DaemonRpcError::InvalidResponse(format!( + "sdk {label} publish returned a non-radrootsd transport receipt" + ))); + }; + let Some(job_id) = transport_receipt.job_id else { + return Err(DaemonRpcError::InvalidResponse(format!( + "sdk {label} publish did not return a job id" + ))); + }; + let Some(status) = transport_receipt.status else { + return Err(DaemonRpcError::InvalidResponse(format!( + "sdk {label} publish did not return a job status" + ))); + }; + let Some(signer_mode) = transport_receipt.signer_mode else { + return Err(DaemonRpcError::InvalidResponse(format!( + "sdk {label} publish did not return a signer mode" + ))); + }; + Ok(BridgeEventPublishResult { + deduplicated: transport_receipt.deduplicated, + job_id, + idempotency_key: idempotency_key.map(str::to_owned), + status, + signer_mode, + signer_session_id: transport_receipt.signer_session_id, + event_kind: receipt.event_kind, + event_id: receipt.event_id, + event_addr: transport_receipt.event_addr, + }) +} + fn map_order_request_receipt( receipt: radroots_sdk::SdkPublishReceipt, idempotency_key: Option<&str>, @@ -846,6 +967,8 @@ fn map_rpc_error(method: &str, error: JsonRpcResponseError) -> DaemonRpcError { fn map_job_command(command: String) -> String { match command.as_str() { + "bridge.profile.publish" => "profile.publish".to_owned(), + "bridge.farm.publish" => "farm.publish".to_owned(), "bridge.listing.publish" => "listing.publish".to_owned(), "bridge.order.request" => "order.submit".to_owned(), other => other.to_owned(), diff --git a/src/runtime/farm.rs b/src/runtime/farm.rs @@ -2,22 +2,28 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; use radroots_events::farm::{RadrootsFarm, RadrootsFarmLocation}; +use radroots_events::kinds::{KIND_FARM, KIND_PROFILE}; use radroots_events::listing::RadrootsListingLocation; -use radroots_events::profile::RadrootsProfile; +use radroots_events::profile::{RadrootsProfile, RadrootsProfileType}; use radroots_events_codec::d_tag::is_d_tag_base64url; +use radroots_events_codec::farm::encode::to_wire_parts_with_kind; +use radroots_events_codec::profile::encode::to_wire_parts_with_profile_type; -use crate::cli::{FarmScopeArg, FarmScopedArgs, FarmSetupArgs}; +use crate::cli::{FarmPublishArgs, FarmScopeArg, FarmScopedArgs, FarmSetupArgs}; use crate::domain::runtime::{ FarmConfigDocumentView, FarmConfigSummaryView, FarmGetView, FarmListingDefaultsView, - FarmPublicationView, FarmSelectionView, FarmSetupView, FarmStatusView, + FarmPublicationView, FarmPublishComponentView, FarmPublishEventView, FarmPublishJobView, + FarmPublishView, FarmSelectionView, FarmSetupView, FarmStatusView, }; use crate::runtime::RuntimeError; use crate::runtime::accounts::{self, AccountRecordView}; use crate::runtime::config::RuntimeConfig; +use crate::runtime::daemon::{self, BridgeEventPublishResult, DaemonRpcError}; use crate::runtime::farm_config::{ self, FarmConfigDocument, FarmConfigScope, FarmConfigSelection, FarmListingDefaults, FarmPublicationStatus, ResolvedFarmConfig, SUPPORTED_FARM_CONFIG_VERSION, }; +use crate::runtime::signer::{ActorWriteBindingError, resolve_actor_write_authority}; const FARM_CONFIG_SOURCE: &str = "farm config · local first"; @@ -169,6 +175,718 @@ pub fn get(config: &RuntimeConfig, args: &FarmScopedArgs) -> Result<FarmGetView, }) } +pub fn publish( + config: &RuntimeConfig, + args: &FarmPublishArgs, +) -> Result<FarmPublishView, RuntimeError> { + let scope = scope_from_arg(args.scope); + let resolved_scope = farm_config::resolve_scope(&config.paths, scope)?; + let path = farm_config::config_path(&config.paths, resolved_scope)?; + let Some(resolved) = farm_config::load(config, Some(resolved_scope))? else { + return Ok(missing_publish_view( + resolved_scope, + path.display().to_string(), + args, + format!("no farm config found at {}", path.display()), + )); + }; + + let Some(account) = configured_account(config, &resolved.document.selection.account)? else { + return Ok(missing_publish_view( + resolved.scope, + resolved.path.display().to_string(), + args, + format!( + "farm config account `{}` is not present in the local account store", + resolved.document.selection.account + ), + )); + }; + let account_pubkey = account.record.public_identity.public_key_hex.clone(); + let previews = build_publish_previews(&resolved.document, account_pubkey.as_str())?; + let profile_idempotency_key = component_idempotency_key(args, "profile")?; + let farm_idempotency_key = component_idempotency_key(args, "farm")?; + + if config.output.dry_run { + return Ok(base_publish_view( + "dry_run", + config, + args, + &resolved, + &account_pubkey, + preview_component( + "bridge.profile.publish", + KIND_PROFILE, + profile_idempotency_key, + args, + Some(previews.profile), + ), + preview_component( + "bridge.farm.publish", + KIND_FARM, + farm_idempotency_key, + args, + Some(previews.farm), + ), + Some("dry run requested; daemon farm publish skipped".to_owned()), + vec![format!( + "radroots farm publish --scope {}", + resolved.scope.as_str() + )], + )); + } + + let signer_authority = + match resolve_actor_write_authority(config, "farm", account_pubkey.as_str()) { + Ok(authority) => authority, + Err(error) => { + return Ok(binding_error_publish_view( + config, + args, + &resolved, + &account_pubkey, + previews, + profile_idempotency_key, + farm_idempotency_key, + error, + )); + } + }; + let profile_signer_session_id = match daemon::resolve_signer_session_id( + config, + "farm profile", + account_pubkey.as_str(), + KIND_PROFILE, + args.signer_session_id.as_deref(), + signer_authority.as_ref(), + ) { + Ok(session_id) => session_id, + Err(error) => { + return Ok(daemon_error_publish_view( + config, + args, + &resolved, + &account_pubkey, + previews, + profile_idempotency_key, + farm_idempotency_key, + error, + FarmPublishFailureStage::Profile, + )); + } + }; + let farm_signer_session_id = match daemon::resolve_signer_session_id( + config, + "farm", + account_pubkey.as_str(), + KIND_FARM, + Some(profile_signer_session_id.as_str()), + signer_authority.as_ref(), + ) { + Ok(session_id) => session_id, + Err(error) => { + return Ok(daemon_error_publish_view( + config, + args, + &resolved, + &account_pubkey, + previews, + profile_idempotency_key, + farm_idempotency_key, + error, + FarmPublishFailureStage::Farm, + )); + } + }; + + let profile_result = match daemon::bridge_profile_publish( + config, + &resolved.document.profile, + Some(RadrootsProfileType::Farm), + profile_idempotency_key.as_deref(), + Some(profile_signer_session_id.as_str()), + signer_authority.as_ref(), + ) { + Ok(result) => result_component( + "bridge.profile.publish", + KIND_PROFILE, + result, + args, + Some(previews.profile.clone()), + ), + Err(error) => { + return Ok(daemon_error_publish_view( + config, + args, + &resolved, + &account_pubkey, + previews, + profile_idempotency_key, + farm_idempotency_key, + error, + FarmPublishFailureStage::Profile, + )); + } + }; + + if component_failed(&profile_result) { + return Ok(persist_publication_and_view( + config, + args, + resolved, + &account_pubkey, + profile_result, + preview_component( + "bridge.farm.publish", + KIND_FARM, + farm_idempotency_key, + args, + Some(previews.farm), + ), + )?); + } + + let farm_result = match daemon::bridge_farm_publish( + config, + &resolved.document.farm, + farm_idempotency_key.as_deref(), + Some(farm_signer_session_id.as_str()), + signer_authority.as_ref(), + ) { + Ok(result) => result_component( + "bridge.farm.publish", + KIND_FARM, + result, + args, + Some(previews.farm), + ), + Err(error) => { + let profile_for_error = profile_result.clone(); + let farm_for_error = daemon_error_component( + "bridge.farm.publish", + KIND_FARM, + args, + farm_idempotency_key, + error, + Some(previews.farm), + ); + return Ok(persist_publication_and_view( + config, + args, + resolved, + &account_pubkey, + profile_for_error, + farm_for_error, + )?); + } + }; + + persist_publication_and_view( + config, + args, + resolved, + &account_pubkey, + profile_result, + farm_result, + ) +} + +#[derive(Debug, Clone)] +struct FarmPublishPreviews { + profile: FarmPublishEventView, + farm: FarmPublishEventView, +} + +#[derive(Debug, Clone, Copy)] +enum FarmPublishFailureStage { + Profile, + Farm, +} + +fn missing_publish_view( + scope: FarmConfigScope, + path: String, + args: &FarmPublishArgs, + reason: String, +) -> FarmPublishView { + FarmPublishView { + state: "unconfigured".to_owned(), + source: daemon::bridge_source().to_owned(), + scope: scope.as_str().to_owned(), + path, + config_present: false, + dry_run: false, + selected_account_id: String::new(), + selected_account_pubkey: String::new(), + farm_d_tag: String::new(), + requested_signer_session_id: args.signer_session_id.clone(), + profile: not_submitted_component("bridge.profile.publish", KIND_PROFILE, args, None, None), + farm: not_submitted_component("bridge.farm.publish", KIND_FARM, args, None, None), + reason: Some(reason), + actions: vec![setup_action(scope), "radroots account whoami".to_owned()], + } +} + +fn base_publish_view( + state: &str, + config: &RuntimeConfig, + args: &FarmPublishArgs, + resolved: &ResolvedFarmConfig, + account_pubkey: &str, + profile: FarmPublishComponentView, + farm: FarmPublishComponentView, + reason: Option<String>, + actions: Vec<String>, +) -> FarmPublishView { + FarmPublishView { + state: state.to_owned(), + source: daemon::bridge_source().to_owned(), + scope: resolved.scope.as_str().to_owned(), + path: resolved.path.display().to_string(), + config_present: true, + dry_run: config.output.dry_run, + selected_account_id: resolved.document.selection.account.clone(), + selected_account_pubkey: account_pubkey.to_owned(), + farm_d_tag: resolved.document.selection.farm_d_tag.clone(), + requested_signer_session_id: args.signer_session_id.clone(), + profile, + farm, + reason, + actions, + } +} + +fn build_publish_previews( + document: &FarmConfigDocument, + account_pubkey: &str, +) -> Result<FarmPublishPreviews, RuntimeError> { + let profile_parts = + to_wire_parts_with_profile_type(&document.profile, Some(RadrootsProfileType::Farm)) + .map_err(|error| RuntimeError::Config(format!("invalid farm profile: {error}")))?; + let farm_parts = to_wire_parts_with_kind(&document.farm, KIND_FARM) + .map_err(|error| RuntimeError::Config(format!("invalid farm contract: {error}")))?; + let farm_addr = format!( + "{}:{}:{}", + farm_parts.kind, account_pubkey, document.farm.d_tag + ); + + Ok(FarmPublishPreviews { + profile: FarmPublishEventView { + kind: profile_parts.kind, + author: account_pubkey.to_owned(), + content: profile_parts.content, + tags: profile_parts.tags, + event_id: None, + event_addr: None, + }, + farm: FarmPublishEventView { + kind: farm_parts.kind, + author: account_pubkey.to_owned(), + content: farm_parts.content, + tags: farm_parts.tags, + event_id: None, + event_addr: Some(farm_addr), + }, + }) +} + +fn component_idempotency_key( + args: &FarmPublishArgs, + component: &str, +) -> Result<Option<String>, RuntimeError> { + args.idempotency_key + .as_deref() + .map(|value| { + required_text(value, "idempotency_key").map(|key| format!("{key}:{component}")) + }) + .transpose() +} + +fn preview_component( + rpc_method: &str, + event_kind: u32, + idempotency_key: Option<String>, + args: &FarmPublishArgs, + event: Option<FarmPublishEventView>, +) -> FarmPublishComponentView { + FarmPublishComponentView { + state: if event.is_some() { + "not_submitted".to_owned() + } else { + "unconfigured".to_owned() + }, + rpc_method: rpc_method.to_owned(), + event_kind, + deduplicated: false, + job_id: None, + job_status: None, + signer_mode: None, + signer_session_id: None, + event_id: None, + event_addr: event.as_ref().and_then(|event| event.event_addr.clone()), + idempotency_key: idempotency_key.clone(), + reason: Some("not submitted".to_owned()), + job: args.print_job.then(|| FarmPublishJobView { + rpc_method: rpc_method.to_owned(), + state: "not_submitted".to_owned(), + job_id: None, + idempotency_key, + requested_signer_session_id: args.signer_session_id.clone(), + signer_mode: None, + signer_session_id: None, + }), + event: args.print_event.then_some(event).flatten(), + } +} + +fn not_submitted_component( + rpc_method: &str, + event_kind: u32, + args: &FarmPublishArgs, + idempotency_key: Option<String>, + event: Option<FarmPublishEventView>, +) -> FarmPublishComponentView { + preview_component(rpc_method, event_kind, idempotency_key, args, event) +} + +fn result_component( + rpc_method: &str, + fallback_event_kind: u32, + result: BridgeEventPublishResult, + args: &FarmPublishArgs, + preview: Option<FarmPublishEventView>, +) -> FarmPublishComponentView { + let state = if result.status == "failed" { + "failed".to_owned() + } else if result.deduplicated { + "deduplicated".to_owned() + } else { + result.status.clone() + }; + let event_kind = result.event_kind.unwrap_or(fallback_event_kind); + let event_addr = result + .event_addr + .clone() + .or_else(|| preview.as_ref().and_then(|event| event.event_addr.clone())); + let event = args.print_event.then(|| FarmPublishEventView { + event_id: result.event_id.clone(), + event_addr: event_addr.clone(), + ..preview.unwrap_or_else(|| FarmPublishEventView { + kind: event_kind, + author: String::new(), + content: String::new(), + tags: Vec::new(), + event_id: None, + event_addr: None, + }) + }); + FarmPublishComponentView { + state: state.clone(), + rpc_method: rpc_method.to_owned(), + event_kind, + deduplicated: result.deduplicated, + job_id: Some(result.job_id.clone()), + job_status: Some(result.status.clone()), + signer_mode: Some(result.signer_mode.clone()), + signer_session_id: result.signer_session_id.clone(), + event_id: result.event_id.clone(), + event_addr, + idempotency_key: result.idempotency_key.clone(), + reason: (result.status == "failed") + .then(|| "daemon publish job failed before relay delivery completed".to_owned()), + job: args.print_job.then(|| FarmPublishJobView { + rpc_method: rpc_method.to_owned(), + state, + job_id: Some(result.job_id), + idempotency_key: result.idempotency_key, + requested_signer_session_id: args.signer_session_id.clone(), + signer_mode: Some(result.signer_mode), + signer_session_id: result.signer_session_id, + }), + event, + } +} + +fn daemon_error_component( + rpc_method: &str, + event_kind: u32, + args: &FarmPublishArgs, + idempotency_key: Option<String>, + error: DaemonRpcError, + preview: Option<FarmPublishEventView>, +) -> FarmPublishComponentView { + let (state, reason) = daemon_error_state_reason(error); + FarmPublishComponentView { + state: state.clone(), + rpc_method: rpc_method.to_owned(), + event_kind, + deduplicated: false, + job_id: None, + job_status: None, + signer_mode: None, + signer_session_id: None, + event_id: None, + event_addr: preview.as_ref().and_then(|event| event.event_addr.clone()), + idempotency_key: idempotency_key.clone(), + reason: Some(reason), + job: args.print_job.then(|| FarmPublishJobView { + rpc_method: rpc_method.to_owned(), + state, + job_id: None, + idempotency_key, + requested_signer_session_id: args.signer_session_id.clone(), + signer_mode: None, + signer_session_id: None, + }), + event: args.print_event.then_some(preview).flatten(), + } +} + +fn daemon_error_state_reason(error: DaemonRpcError) -> (String, String) { + match error { + DaemonRpcError::Unconfigured(reason) + | DaemonRpcError::Unauthorized(reason) + | DaemonRpcError::MethodUnavailable(reason) => ("unconfigured".to_owned(), reason), + DaemonRpcError::External(reason) => ("unavailable".to_owned(), reason), + DaemonRpcError::InvalidResponse(reason) + | DaemonRpcError::Remote(reason) + | DaemonRpcError::UnknownJob(reason) => ("error".to_owned(), reason), + } +} + +fn binding_error_publish_view( + config: &RuntimeConfig, + args: &FarmPublishArgs, + resolved: &ResolvedFarmConfig, + account_pubkey: &str, + previews: FarmPublishPreviews, + profile_idempotency_key: Option<String>, + farm_idempotency_key: Option<String>, + error: ActorWriteBindingError, +) -> FarmPublishView { + let (state, reason, actions) = match error { + ActorWriteBindingError::Unconfigured(reason) => ( + "unconfigured".to_owned(), + reason, + vec![ + "radroots --signer myc signer status".to_owned(), + "radroots rpc sessions".to_owned(), + ], + ), + ActorWriteBindingError::Unavailable(reason) => ( + "unavailable".to_owned(), + reason, + vec![ + "radroots myc status".to_owned(), + "verify RADROOTS_MYC_EXECUTABLE and signer.remote_nip46 binding".to_owned(), + ], + ), + }; + base_publish_view( + state.as_str(), + config, + args, + resolved, + account_pubkey, + FarmPublishComponentView { + state: state.clone(), + reason: Some(reason.clone()), + ..preview_component( + "bridge.profile.publish", + KIND_PROFILE, + profile_idempotency_key, + args, + Some(previews.profile), + ) + }, + FarmPublishComponentView { + state: state.clone(), + reason: Some(reason.clone()), + ..preview_component( + "bridge.farm.publish", + KIND_FARM, + farm_idempotency_key, + args, + Some(previews.farm), + ) + }, + Some(reason), + actions, + ) +} + +fn daemon_error_publish_view( + config: &RuntimeConfig, + args: &FarmPublishArgs, + resolved: &ResolvedFarmConfig, + account_pubkey: &str, + previews: FarmPublishPreviews, + profile_idempotency_key: Option<String>, + farm_idempotency_key: Option<String>, + error: DaemonRpcError, + stage: FarmPublishFailureStage, +) -> FarmPublishView { + let (state, reason) = daemon_error_state_reason(error); + let profile = match stage { + FarmPublishFailureStage::Profile => FarmPublishComponentView { + state: state.clone(), + reason: Some(reason.clone()), + ..preview_component( + "bridge.profile.publish", + KIND_PROFILE, + profile_idempotency_key, + args, + Some(previews.profile), + ) + }, + FarmPublishFailureStage::Farm => preview_component( + "bridge.profile.publish", + KIND_PROFILE, + profile_idempotency_key, + args, + Some(previews.profile), + ), + }; + let farm = match stage { + FarmPublishFailureStage::Profile => preview_component( + "bridge.farm.publish", + KIND_FARM, + farm_idempotency_key, + args, + Some(previews.farm), + ), + FarmPublishFailureStage::Farm => FarmPublishComponentView { + state: state.clone(), + reason: Some(reason.clone()), + ..preview_component( + "bridge.farm.publish", + KIND_FARM, + farm_idempotency_key, + args, + Some(previews.farm), + ) + }, + }; + base_publish_view( + state.as_str(), + config, + args, + resolved, + account_pubkey, + profile, + farm, + Some(reason), + daemon_error_actions(state.as_str()), + ) +} + +fn persist_publication_and_view( + config: &RuntimeConfig, + args: &FarmPublishArgs, + mut resolved: ResolvedFarmConfig, + account_pubkey: &str, + profile: FarmPublishComponentView, + farm: FarmPublishComponentView, +) -> Result<FarmPublishView, RuntimeError> { + let now = unix_timestamp_now(); + if component_published(&profile) { + if let Some(event_id) = &profile.event_id { + resolved.document.publication.profile_event_id = Some(event_id.clone()); + } + resolved.document.publication.profile_published_at = Some(now); + } + if component_published(&farm) { + if let Some(event_id) = &farm.event_id { + resolved.document.publication.farm_event_id = Some(event_id.clone()); + } + resolved.document.publication.farm_published_at = Some(now); + } + if component_published(&profile) || component_published(&farm) { + farm_config::write(&config.paths, resolved.scope, &resolved.document)?; + } + + let state = publish_view_state(&profile, &farm); + let mut actions = Vec::new(); + if let Some(job_id) = &profile.job_id { + actions.push(format!("radroots job get {job_id}")); + } + if let Some(job_id) = &farm.job_id { + actions.push(format!("radroots job get {job_id}")); + actions.push(format!("radroots job watch {job_id}")); + } + if actions.is_empty() { + actions.push("radroots rpc status".to_owned()); + } + let reason = (state == "partial" || state == "unavailable" || state == "error") + .then(|| "farm publish did not complete for both profile and farm record".to_owned()); + + Ok(base_publish_view( + state, + config, + args, + &resolved, + account_pubkey, + profile, + farm, + reason, + actions, + )) +} + +fn publish_view_state( + profile: &FarmPublishComponentView, + farm: &FarmPublishComponentView, +) -> &'static str { + if component_error(profile) || component_error(farm) { + return "error"; + } + if component_unconfigured(profile) || component_unconfigured(farm) { + return "unconfigured"; + } + if component_failed(profile) || component_failed(farm) { + return if component_published(profile) || component_published(farm) { + "partial" + } else { + "unavailable" + }; + } + if profile.state == "deduplicated" || farm.state == "deduplicated" { + return "deduplicated"; + } + "published" +} + +fn component_published(component: &FarmPublishComponentView) -> bool { + matches!(component.state.as_str(), "published" | "deduplicated") + || component + .job_status + .as_deref() + .is_some_and(|status| status == "published") +} + +fn component_failed(component: &FarmPublishComponentView) -> bool { + matches!(component.state.as_str(), "failed" | "unavailable") +} + +fn component_unconfigured(component: &FarmPublishComponentView) -> bool { + component.state == "unconfigured" +} + +fn component_error(component: &FarmPublishComponentView) -> bool { + component.state == "error" +} + +fn daemon_error_actions(state: &str) -> Vec<String> { + match state { + "unconfigured" => vec![ + "set RADROOTS_RPC_BEARER_TOKEN in .env or your shell".to_owned(), + "start radrootsd with bridge ingress enabled".to_owned(), + ], + "unavailable" => vec!["start radrootsd and verify the rpc url".to_owned()], + _ => vec!["inspect the daemon rpc response contract".to_owned()], + } +} + fn setup_document( args: &FarmSetupArgs, scope: FarmConfigScope, @@ -413,6 +1131,13 @@ fn non_empty(value: &str) -> Option<String> { } } +fn unix_timestamp_now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or_default() +} + fn generate_d_tag() -> String { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH)