cli

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

commit 26deb5b552da91da235c5dc42971c7e4295e993a
parent 9f8d72269b7ac7b27d801b2997edb52d03bf46b3
Author: triesap <tyson@radroots.org>
Date:   Thu, 16 Apr 2026 20:26:50 +0000

implement workflow setup and status summaries

Diffstat:
Msrc/cli.rs | 106+++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
Msrc/commands/listing.rs | 2+-
Msrc/commands/mod.rs | 9+++------
Asrc/commands/workflow.rs | 44++++++++++++++++++++++++++++++++++++++++++++
Msrc/domain/runtime.rs | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/render/mod.rs | 141++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Msrc/runtime/config.rs | 25+++++++++----------------
Msrc/runtime/listing.rs | 8++++----
Msrc/runtime/mod.rs | 1+
Asrc/runtime/workflow.rs | 245+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/help.rs | 12++++++++++--
Mtests/listing.rs | 31++++++++++++++++++-------------
Atests/workflow.rs | 200+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
13 files changed, 776 insertions(+), 106 deletions(-)

diff --git a/src/cli.rs b/src/cli.rs @@ -1,6 +1,6 @@ use clap::{ - error::ErrorKind, ArgAction, Args, CommandFactory, FromArgMatches, Parser, Subcommand, - ValueEnum, + ArgAction, Args, CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum, + error::ErrorKind, }; use std::ffi::{OsStr, OsString}; use std::path::PathBuf; @@ -339,10 +339,9 @@ fn parse_output_format_value(value: &OsStr) -> Result<OutputFormatArg, clap::Err fn long_option_name(arg: &OsStr) -> Option<&str> { let token = arg.to_str()?; - token.strip_prefix("--").map(|rest| { - rest.split_once('=') - .map_or(rest, |(flag, _value)| flag) - }) + token + .strip_prefix("--") + .map(|rest| rest.split_once('=').map_or(rest, |(flag, _value)| flag)) } fn split_long_option(arg: &OsStr) -> Option<(&str, &OsStr)> { @@ -615,7 +614,11 @@ pub struct AccountArgs { #[derive(Debug, Clone, Subcommand)] pub enum AccountCommand { - #[command(name = "create", visible_alias = "new", about = "Create a local account")] + #[command( + name = "create", + visible_alias = "new", + about = "Create a local account" + )] New, #[command( name = "view", @@ -625,7 +628,11 @@ pub enum AccountCommand { Whoami, #[command(name = "list", visible_alias = "ls", about = "List local accounts")] Ls, - #[command(name = "select", visible_alias = "use", about = "Select a local account")] + #[command( + name = "select", + visible_alias = "use", + about = "Select a local account" + )] Use(AccountUseArgs), } @@ -680,7 +687,11 @@ pub enum FarmCommand { Publish(FarmPublishArgs), #[command(about = "Create or update a farm draft in one command")] Setup(FarmSetupArgs), - #[command(name = "check", visible_alias = "status", about = "Check farm readiness")] + #[command( + name = "check", + visible_alias = "status", + about = "Check farm readiness" + )] Status(FarmScopedArgs), #[command(name = "show", visible_alias = "get", about = "Show the farm draft")] Get(FarmScopedArgs), @@ -996,7 +1007,11 @@ pub struct OrderArgs { #[derive(Debug, Clone, Subcommand)] pub enum OrderCommand { - #[command(name = "create", visible_alias = "new", about = "Create a local order draft")] + #[command( + name = "create", + visible_alias = "new", + about = "Create a local order draft" + )] New(OrderNewArgs), #[command(name = "view", visible_alias = "get", about = "Show one order")] Get(RecordKeyArgs), @@ -1096,8 +1111,8 @@ mod tests { AccountCommand, CliArgs, Command, ConfigCommand, FarmCommand, FarmScopeArg, JobCommand, JobWatchArgs, ListingCommand, LocalCommand, LocalExportFormatArg, MarketCommand, MycCommand, NetCommand, OrderCommand, OrderWatchArgs, OutputFormatArg, RelayCommand, - RpcCommand, RuntimeCommand, RuntimeConfigCommand, SellCommand, SetupRoleArg, - SignerCommand, SyncCommand, SyncWatchArgs, + RpcCommand, RuntimeCommand, RuntimeConfigCommand, SellCommand, SetupRoleArg, SignerCommand, + SyncCommand, SyncWatchArgs, }; use crate::runtime::config::OutputFormat; #[test] @@ -1358,7 +1373,8 @@ mod tests { _ => panic!("unexpected command variant"), } - let order_create = CliArgs::parse_from(["radroots", "order", "create", "--listing", "eggs"]); + let order_create = + CliArgs::parse_from(["radroots", "order", "create", "--listing", "eggs"]); match order_create.command { Command::Order(order) => match order.command { OrderCommand::New(args) => assert_eq!(args.listing.as_deref(), Some("eggs")), @@ -1953,15 +1969,21 @@ mod tests { #[test] fn command_contract_helpers_report_supported_modes() { let config_show = CliArgs::parse_from(["radroots", "config", "show"]); - assert!(config_show - .command - .supports_output_format(OutputFormat::Human)); - assert!(config_show - .command - .supports_output_format(OutputFormat::Json)); - assert!(!config_show - .command - .supports_output_format(OutputFormat::Ndjson)); + assert!( + config_show + .command + .supports_output_format(OutputFormat::Human) + ); + assert!( + config_show + .command + .supports_output_format(OutputFormat::Json) + ); + assert!( + !config_show + .command + .supports_output_format(OutputFormat::Ndjson) + ); assert!(config_show.command.supports_dry_run()); let account_create = CliArgs::parse_from(["radroots", "account", "create"]); @@ -1983,9 +2005,11 @@ mod tests { let farm_check = CliArgs::parse_from(["radroots", "farm", "check"]); assert_eq!(farm_check.command.display_name(), "farm check"); assert!(farm_check.command.supports_dry_run()); - assert!(!farm_check - .command - .supports_output_format(OutputFormat::Ndjson)); + assert!( + !farm_check + .command + .supports_output_format(OutputFormat::Ndjson) + ); let farm_publish = CliArgs::parse_from(["radroots", "farm", "publish"]); assert_eq!(farm_publish.command.display_name(), "farm publish"); @@ -1996,23 +2020,29 @@ mod tests { let market_search = CliArgs::parse_from(["radroots", "market", "search", "eggs"]); assert_eq!(market_search.command.display_name(), "market search"); - assert!(market_search - .command - .supports_output_format(OutputFormat::Ndjson)); + assert!( + market_search + .command + .supports_output_format(OutputFormat::Ndjson) + ); let sync_watch = CliArgs::parse_from(["radroots", "sync", "watch", "--frames", "1"]); - assert!(sync_watch - .command - .supports_output_format(OutputFormat::Ndjson)); + assert!( + sync_watch + .command + .supports_output_format(OutputFormat::Ndjson) + ); let sell_add = CliArgs::parse_from(["radroots", "sell", "add"]); assert_eq!(sell_add.command.display_name(), "sell add"); assert!(!sell_add.command.supports_dry_run()); let order_watch = CliArgs::parse_from(["radroots", "order", "watch", "ord_demo"]); - assert!(order_watch - .command - .supports_output_format(OutputFormat::Ndjson)); + assert!( + order_watch + .command + .supports_output_format(OutputFormat::Ndjson) + ); let order_submit = CliArgs::parse_from(["radroots", "order", "submit", "ord_demo"]); assert_eq!(order_submit.command.display_name(), "order submit"); @@ -2029,8 +2059,10 @@ mod tests { let runtime_status = CliArgs::parse_from(["radroots", "runtime", "status", "radrootsd"]); assert_eq!(runtime_status.command.display_name(), "runtime status"); assert!(runtime_status.command.supports_dry_run()); - assert!(!runtime_status - .command - .supports_output_format(OutputFormat::Ndjson)); + assert!( + !runtime_status + .command + .supports_output_format(OutputFormat::Ndjson) + ); } } diff --git a/src/commands/listing.rs b/src/commands/listing.rs @@ -1,7 +1,7 @@ use crate::cli::{ListingFileArgs, ListingMutationArgs, ListingNewArgs, RecordKeyArgs}; use crate::domain::runtime::{CommandOutput, CommandView}; -use crate::runtime::config::RuntimeConfig; use crate::runtime::RuntimeError; +use crate::runtime::config::RuntimeConfig; pub fn new(config: &RuntimeConfig, args: &ListingNewArgs) -> Result<CommandOutput, RuntimeError> { let view = crate::runtime::listing::scaffold(config, args)?; diff --git a/src/commands/mod.rs b/src/commands/mod.rs @@ -13,6 +13,7 @@ pub mod rpc; pub mod runtime; pub mod signer; pub mod sync; +pub mod workflow; use crate::cli::{ AccountCommand, Command, ConfigCommand, FarmCommand, JobCommand, ListingCommand, LocalCommand, @@ -118,9 +119,7 @@ pub fn dispatch( "`sell restock` will land in the draft-mutation slice; edit the draft file directly for now", ), }, - Command::Setup(_setup) => planned_command( - "`setup` will land in the workflow slice; use `account`, `local`, and `farm` directly for now", - ), + Command::Setup(setup) => workflow::setup(config, setup), Command::Runtime(runtime_command) => match &runtime_command.command { RuntimeCommand::Install(args) => runtime::install(config, args), RuntimeCommand::Uninstall(args) => runtime::uninstall(config, args), @@ -134,9 +133,7 @@ pub fn dispatch( RuntimeConfigCommand::Set(args) => runtime::config_set(config, args), }, }, - Command::Status => planned_command( - "`status` will land in the workflow slice; use `doctor` for readiness details right now", - ), + Command::Status => workflow::status(config), Command::Sync(sync) => match &sync.command { SyncCommand::Status => sync::status(config), SyncCommand::Pull => sync::pull(config), diff --git a/src/commands/workflow.rs b/src/commands/workflow.rs @@ -0,0 +1,44 @@ +use crate::cli::SetupArgs; +use crate::domain::runtime::{ + CommandDisposition, CommandOutput, CommandView, SetupView, StatusView, +}; +use crate::runtime::RuntimeError; +use crate::runtime::config::RuntimeConfig; + +pub fn setup(config: &RuntimeConfig, args: &SetupArgs) -> Result<CommandOutput, RuntimeError> { + let view = crate::runtime::workflow::setup(config, args.role)?; + Ok(setup_output(view)) +} + +pub fn status(config: &RuntimeConfig) -> Result<CommandOutput, RuntimeError> { + let view = crate::runtime::workflow::status(config)?; + Ok(status_output(view)) +} + +fn setup_output(view: SetupView) -> CommandOutput { + match view.disposition() { + CommandDisposition::Success => CommandOutput::success(CommandView::Setup(view)), + CommandDisposition::Unconfigured => CommandOutput::unconfigured(CommandView::Setup(view)), + CommandDisposition::ExternalUnavailable => { + CommandOutput::external_unavailable(CommandView::Setup(view)) + } + CommandDisposition::Unsupported => CommandOutput::unsupported(CommandView::Setup(view)), + CommandDisposition::InternalError => { + CommandOutput::internal_error(CommandView::Setup(view)) + } + } +} + +fn status_output(view: StatusView) -> CommandOutput { + match view.disposition() { + CommandDisposition::Success => CommandOutput::success(CommandView::Status(view)), + CommandDisposition::Unconfigured => CommandOutput::unconfigured(CommandView::Status(view)), + CommandDisposition::ExternalUnavailable => { + CommandOutput::external_unavailable(CommandView::Status(view)) + } + CommandDisposition::Unsupported => CommandOutput::unsupported(CommandView::Status(view)), + CommandDisposition::InternalError => { + CommandOutput::internal_error(CommandView::Status(view)) + } + } +} diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs @@ -118,7 +118,9 @@ pub enum CommandView { RuntimeConfigShow(RuntimeManagedConfigView), RuntimeLogs(RuntimeLogsView), RuntimeStatus(RuntimeStatusView), + Setup(SetupView), SignerStatus(SignerStatusView), + Status(StatusView), SyncPull(SyncActionView), SyncPush(SyncActionView), SyncStatus(SyncStatusView), @@ -650,6 +652,62 @@ pub struct LocalReplicaSyncView { } #[derive(Debug, Clone, Serialize)] +pub struct SetupView { + pub state: String, + pub source: String, + pub role: String, + pub selected_account_id: String, + pub local_state: String, + pub local_root: String, + pub relay_state: String, + pub relay_count: usize, + pub farm_state: String, + #[serde(default)] + pub ready: Vec<String>, + #[serde(default)] + pub needs_attention: Vec<String>, + #[serde(default)] + pub next: Vec<String>, +} + +impl SetupView { + pub fn disposition(&self) -> CommandDisposition { + match self.state.as_str() { + "unconfigured" => CommandDisposition::Unconfigured, + _ => CommandDisposition::Success, + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct StatusView { + pub state: String, + pub source: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub selected_account_id: Option<String>, + pub local_state: String, + pub local_root: String, + pub relay_state: String, + pub relay_count: usize, + pub farm_state: String, + #[serde(default)] + pub ready: Vec<String>, + #[serde(default)] + pub needs_attention: Vec<String>, + #[serde(default)] + pub next: Vec<String>, +} + +impl StatusView { + pub fn disposition(&self) -> CommandDisposition { + match self.state.as_str() { + "unconfigured" => CommandDisposition::Unconfigured, + _ => CommandDisposition::Success, + } + } +} + +#[derive(Debug, Clone, Serialize)] pub struct FarmSetupView { pub state: String, pub source: String, diff --git a/src/render/mod.rs b/src/render/mod.rs @@ -8,11 +8,11 @@ use crate::domain::runtime::{ LocalInitView, LocalStatusView, NetStatusView, OrderCancelView, OrderDraftItemView, OrderGetView, OrderHistoryView, OrderJobView, OrderListView, OrderNewView, OrderSubmitView, OrderWatchView, OrderWorkflowView, RelayListView, RpcSessionsView, RpcStatusView, - RuntimeActionView, RuntimeLogsView, RuntimeManagedConfigView, RuntimeStatusView, - SyncActionView, SyncStatusView, SyncWatchView, + RuntimeActionView, RuntimeLogsView, RuntimeManagedConfigView, RuntimeStatusView, SetupView, + StatusView, SyncActionView, SyncStatusView, SyncWatchView, }; -use crate::runtime::config::{OutputConfig, OutputFormat}; use crate::runtime::RuntimeError; +use crate::runtime::config::{OutputConfig, OutputFormat}; const THIN_RULE: &str = "────────────────────────────────────────────────────"; @@ -178,6 +178,9 @@ fn render_human_to(stdout: &mut dyn Write, output: &CommandOutput) -> Result<(), CommandView::RuntimeStatus(view) => { render_runtime_status(stdout, view)?; } + CommandView::Setup(view) => { + render_setup(stdout, view)?; + } CommandView::SignerStatus(view) => { write_context( stdout, @@ -212,6 +215,9 @@ fn render_human_to(stdout: &mut dyn Write, output: &CommandOutput) -> Result<(), render_myc_status(stdout, myc, false)?; } } + CommandView::Status(view) => { + render_status_summary(stdout, view)?; + } CommandView::SyncPull(view) => { render_sync_action(stdout, view)?; } @@ -387,10 +393,18 @@ fn render_json_to(stdout: &mut dyn Write, output: &CommandOutput) -> Result<(), serde_json::to_writer_pretty(&mut *stdout, view)?; writeln!(stdout)?; } + CommandView::Setup(view) => { + serde_json::to_writer_pretty(&mut *stdout, view)?; + writeln!(stdout)?; + } CommandView::SignerStatus(view) => { serde_json::to_writer_pretty(&mut *stdout, view)?; writeln!(stdout)?; } + CommandView::Status(view) => { + serde_json::to_writer_pretty(&mut *stdout, view)?; + writeln!(stdout)?; + } CommandView::SyncPull(view) => { serde_json::to_writer_pretty(&mut *stdout, view)?; writeln!(stdout)?; @@ -496,19 +510,11 @@ fn render_ndjson_to(stdout: &mut dyn Write, output: &CommandOutput) -> Result<() } fn yes_no(value: bool) -> &'static str { - if value { - "yes" - } else { - "no" - } + if value { "yes" } else { "no" } } fn present_absent(value: bool) -> &'static str { - if value { - "present" - } else { - "absent" - } + if value { "present" } else { "absent" } } fn render_account_list(stdout: &mut dyn Write, view: &AccountListView) -> Result<(), RuntimeError> { @@ -2454,6 +2460,81 @@ fn render_local_status(stdout: &mut dyn Write, view: &LocalStatusView) -> Result Ok(()) } +fn render_setup(stdout: &mut dyn Write, view: &SetupView) -> Result<(), RuntimeError> { + render_checklist_summary( + stdout, + match view.state.as_str() { + "unconfigured" => "Not ready yet", + _ => "Setup saved", + }, + &view.ready, + &view.needs_attention, + &view.next, + ) +} + +fn render_status_summary(stdout: &mut dyn Write, view: &StatusView) -> Result<(), RuntimeError> { + render_checklist_summary( + stdout, + match view.state.as_str() { + "unconfigured" => "Not ready yet", + _ => "Status", + }, + &view.ready, + &view.needs_attention, + &view.next, + ) +} + +fn render_checklist_summary( + stdout: &mut dyn Write, + headline: &str, + ready: &[String], + needs_attention: &[String], + next: &[String], +) -> Result<(), RuntimeError> { + writeln!(stdout, "{headline}")?; + + let mut wrote_section = false; + if !ready.is_empty() || !needs_attention.is_empty() || !next.is_empty() { + writeln!(stdout)?; + } + + if !ready.is_empty() { + render_item_section(stdout, "Ready", ready)?; + wrote_section = true; + } + + if !needs_attention.is_empty() { + if wrote_section { + writeln!(stdout)?; + } + render_item_section(stdout, "Needs attention", needs_attention)?; + wrote_section = true; + } + + if !next.is_empty() { + if wrote_section { + writeln!(stdout)?; + } + render_item_section(stdout, "Next", next)?; + } + + Ok(()) +} + +fn render_item_section( + stdout: &mut dyn Write, + title: &str, + items: &[String], +) -> Result<(), RuntimeError> { + writeln!(stdout, "{title}")?; + for item in items { + writeln!(stdout, " {item}")?; + } + Ok(()) +} + fn render_local_backup(stdout: &mut dyn Write, view: &LocalBackupView) -> Result<(), RuntimeError> { write_context(stdout, format!("local · {}", view.state).as_str())?; let size_bytes = view.size_bytes.to_string(); @@ -2837,7 +2918,9 @@ fn human_command_name(view: &CommandView) -> &'static str { CommandView::RuntimeConfigShow(_) => "runtime config show", CommandView::RuntimeLogs(_) => "runtime logs", CommandView::RuntimeStatus(_) => "runtime status", + CommandView::Setup(_) => "setup", CommandView::SignerStatus(_) => "signer status", + CommandView::Status(_) => "status", CommandView::SyncPull(_) => "sync pull", CommandView::SyncPush(_) => "sync push", CommandView::SyncStatus(_) => "sync status", @@ -2847,15 +2930,15 @@ fn human_command_name(view: &CommandView) -> &'static str { #[cfg(test)] mod tests { - use super::{render_human_to, render_ndjson_to, render_table, Table}; + use super::{Table, render_human_to, render_ndjson_to, render_table}; use crate::commands::runtime; use crate::domain::runtime::{ AccountListView, CommandOutput, CommandView, DoctorCheckView, DoctorView, MycStatusView, RelayEntryView, RelayListView, }; use crate::runtime::config::{ - AccountConfig, AccountSecretContractConfig, HyfConfig, IdentityConfig, LocalConfig, - InteractionConfig, LoggingConfig, MigrationConfig, MycConfig, OutputConfig, OutputFormat, + AccountConfig, AccountSecretContractConfig, HyfConfig, IdentityConfig, InteractionConfig, + LocalConfig, LoggingConfig, MigrationConfig, MycConfig, OutputConfig, OutputFormat, PathsConfig, RelayConfig, RelayConfigSource, RelayPublishPolicy, RpcConfig, RuntimeConfig, SignerBackend, SignerConfig, Verbosity, }; @@ -2976,10 +3059,11 @@ mod tests { "/workspace/.radroots/config.toml" ); assert_eq!(view.account.selector.as_deref(), Some("acct_demo")); - assert!(view - .account - .store_path - .ends_with(".radroots/data/shared/accounts/store.json")); + assert!( + view.account + .store_path + .ends_with(".radroots/data/shared/accounts/store.json") + ); assert_eq!(view.relay.count, 2); assert_eq!(view.relay.publish_policy, "any"); assert!(!view.hyf.enabled); @@ -2989,10 +3073,11 @@ mod tests { view.account.secret_backend.contract_default_backend, "host_vault" ); - assert!(view - .local - .replica_db_path - .ends_with(".radroots/data/apps/cli/replica/replica.sqlite")); + assert!( + view.local + .replica_db_path + .ends_with(".radroots/data/apps/cli/replica/replica.sqlite") + ); } #[test] @@ -3127,9 +3212,11 @@ mod tests { )); let mut buffer = Vec::new(); let error = render_ndjson_to(&mut buffer, &output).expect_err("unsupported ndjson"); - assert!(error - .to_string() - .contains("`config show` does not support --ndjson")); + assert!( + error + .to_string() + .contains("`config show` does not support --ndjson") + ); } #[test] diff --git a/src/runtime/config.rs b/src/runtime/config.rs @@ -1699,22 +1699,15 @@ mod tests { .contains("--quiet, --verbose, and --trace") ); - let conflicting_aliases = CliArgs::parse_from([ - "radroots", - "--output", - "json", - "--json", - "config", - "show", - ]); - let error = - RuntimeConfig::resolve_with_env_file(&conflicting_aliases, &env, &EnvFileValues::default()) - .expect_err("conflicting output aliases"); - assert!( - error - .to_string() - .contains("--output, --json, and --ndjson") - ); + let conflicting_aliases = + CliArgs::parse_from(["radroots", "--output", "json", "--json", "config", "show"]); + let error = RuntimeConfig::resolve_with_env_file( + &conflicting_aliases, + &env, + &EnvFileValues::default(), + ) + .expect_err("conflicting output aliases"); + assert!(error.to_string().contains("--output, --json, and --ndjson")); } #[test] diff --git a/src/runtime/listing.rs b/src/runtime/listing.rs @@ -7,6 +7,7 @@ use radroots_core::{ RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, RadrootsCoreQuantityPrice, RadrootsCoreUnit, }; +use radroots_events::RadrootsNostrEvent; use radroots_events::farm::RadrootsFarmRef; use radroots_events::kinds::{KIND_LISTING, KIND_LISTING_DRAFT}; use radroots_events::listing::{ @@ -15,7 +16,6 @@ use radroots_events::listing::{ RadrootsListingStatus, }; use radroots_events::trade::RadrootsTradeListingValidationError; -use radroots_events::RadrootsNostrEvent; use radroots_events_codec::d_tag::is_d_tag_base64url; use radroots_events_codec::listing::encode::to_wire_parts_with_kind; use radroots_replica_db::ReplicaSql; @@ -30,14 +30,14 @@ use crate::domain::runtime::{ ListingMutationEventView, ListingMutationJobView, ListingMutationView, ListingNewView, ListingValidateView, ListingValidationIssueView, SyncFreshnessView, }; +use crate::runtime::RuntimeError; use crate::runtime::accounts; use crate::runtime::config::RuntimeConfig; use crate::runtime::daemon; use crate::runtime::daemon::DaemonRpcError; use crate::runtime::farm_config; -use crate::runtime::signer::{resolve_actor_write_authority, ActorWriteBindingError}; +use crate::runtime::signer::{ActorWriteBindingError, resolve_actor_write_authority}; use crate::runtime::sync::freshness_from_executor; -use crate::runtime::RuntimeError; const DRAFT_KIND: &str = "listing_draft_v1"; const LISTING_SOURCE: &str = "local draft · local first"; @@ -1451,7 +1451,7 @@ fn encode_base64url_no_pad(bytes: [u8; 16]) -> String { #[cfg(test)] mod tests { - use super::{encode_base64url_no_pad, generate_d_tag, ListingDraftDocument, DRAFT_KIND}; + use super::{DRAFT_KIND, ListingDraftDocument, encode_base64url_no_pad, generate_d_tag}; use radroots_events_codec::d_tag::is_d_tag_base64url; #[test] diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs @@ -18,6 +18,7 @@ pub mod paths; pub mod provider; pub mod signer; pub mod sync; +pub mod workflow; use std::process::ExitCode; diff --git a/src/runtime/workflow.rs b/src/runtime/workflow.rs @@ -0,0 +1,245 @@ +use crate::cli::{FarmScopedArgs, SetupRoleArg}; +use crate::domain::runtime::{SetupView, StatusView}; +use crate::runtime::RuntimeError; +use crate::runtime::accounts::{self, AccountRecordView}; +use crate::runtime::config::RuntimeConfig; +use crate::runtime::{farm, local}; + +const WORKFLOW_SOURCE: &str = "workflow summary · local first"; +const RELAY_SETUP_ACTION: &str = "radroots relay list --relay wss://relay.example.com"; + +pub fn setup(config: &RuntimeConfig, role: SetupRoleArg) -> Result<SetupView, RuntimeError> { + let account = ensure_selected_account(config)?; + let local_status = ensure_local_status(config)?; + let farm = inspect_farm(config)?; + let relay_configured = relay_configured(config); + let relay_count = config.relay.urls.len(); + + let mut ready = vec![ + "Selected account".to_owned(), + "Local market data".to_owned(), + ]; + let mut needs_attention = Vec::new(); + let mut next = Vec::new(); + + if relay_configured { + ready.push("Relay configuration".to_owned()); + } else { + needs_attention.push("Relay configuration".to_owned()); + } + + match role { + SetupRoleArg::Seller | SetupRoleArg::Both => { + apply_farm_attention(&mut ready, &mut needs_attention, &mut next, &farm); + push_next(&mut next, farm.primary_next_action.as_deref()); + } + SetupRoleArg::Buyer => {} + } + + match role { + SetupRoleArg::Buyer | SetupRoleArg::Both if relay_configured => { + push_next(&mut next, Some("radroots market search tomatoes")); + } + _ => {} + } + + if !relay_configured { + push_next(&mut next, Some(RELAY_SETUP_ACTION)); + } + + push_next(&mut next, Some("radroots status")); + + Ok(SetupView { + state: "saved".to_owned(), + source: WORKFLOW_SOURCE.to_owned(), + role: role_name(role).to_owned(), + selected_account_id: account.record.account_id.to_string(), + local_state: local_status.state, + local_root: local_status.local_root, + relay_state: relay_state(config).to_owned(), + relay_count, + farm_state: farm.state.to_owned(), + ready, + needs_attention, + next, + }) +} + +pub fn status(config: &RuntimeConfig) -> Result<StatusView, RuntimeError> { + let account = accounts::resolve_account(config)?; + let local_status = local::status(config)?; + let farm = inspect_farm(config)?; + let relay_configured = relay_configured(config); + let relay_count = config.relay.urls.len(); + + let mut ready = Vec::new(); + let mut needs_attention = Vec::new(); + let mut next = Vec::new(); + let mut state = "ready"; + + if account.is_some() { + ready.push("Selected account".to_owned()); + } else { + state = "unconfigured"; + needs_attention.push("Selected account".to_owned()); + } + + if local_status.state == "ready" { + ready.push("Local market data".to_owned()); + } else { + state = "unconfigured"; + needs_attention.push("Local market data".to_owned()); + } + + if relay_configured { + ready.push("Relay configuration".to_owned()); + } else { + state = "unconfigured"; + needs_attention.push("Relay configuration".to_owned()); + } + + if state == "ready" { + apply_farm_attention(&mut ready, &mut needs_attention, &mut next, &farm); + + if relay_configured { + match farm.state { + "draft" | "published" => push_next(&mut next, Some("radroots sell add tomatoes")), + "missing" => push_next(&mut next, Some("radroots market search tomatoes")), + _ => {} + } + } + } else { + push_next(&mut next, Some("radroots setup buyer")); + push_next(&mut next, Some("radroots setup seller")); + if account.is_some() && local_status.state == "ready" && !relay_configured { + next.clear(); + push_next(&mut next, Some(RELAY_SETUP_ACTION)); + push_next(&mut next, Some("radroots status")); + } + } + + Ok(StatusView { + state: state.to_owned(), + source: WORKFLOW_SOURCE.to_owned(), + selected_account_id: account.map(|account| account.record.account_id.to_string()), + local_state: local_status.state, + local_root: local_status.local_root, + relay_state: relay_state(config).to_owned(), + relay_count, + farm_state: farm.state.to_owned(), + ready, + needs_attention, + next, + }) +} + +fn ensure_selected_account(config: &RuntimeConfig) -> Result<AccountRecordView, RuntimeError> { + if let Some(account) = accounts::resolve_account(config)? { + return Ok(account); + } + + let snapshot = accounts::snapshot(config)?; + if let Some(account) = snapshot.accounts.first() { + return accounts::select_account(config, account.record.account_id.as_str()); + } + + Ok(accounts::create_or_migrate_selected_account(config)?.account) +} + +fn ensure_local_status( + config: &RuntimeConfig, +) -> Result<crate::domain::runtime::LocalStatusView, RuntimeError> { + let _ = local::init(config)?; + local::status(config) +} + +#[derive(Debug, Clone)] +struct FarmWorkflowState { + state: &'static str, + primary_next_action: Option<String>, +} + +fn inspect_farm(config: &RuntimeConfig) -> Result<FarmWorkflowState, RuntimeError> { + let view = farm::status(config, &FarmScopedArgs::default())?; + if !view.config_present { + return Ok(FarmWorkflowState { + state: "missing", + primary_next_action: view.actions.into_iter().next(), + }); + } + + if view.account_state != "ready" { + return Ok(FarmWorkflowState { + state: "account_missing", + primary_next_action: view.actions.into_iter().next(), + }); + } + + let Some(config_summary) = view.config else { + return Ok(FarmWorkflowState { + state: "missing", + primary_next_action: view.actions.into_iter().next(), + }); + }; + + let published = config_summary.publication.profile_state == "published" + && config_summary.publication.farm_state == "published"; + + Ok(FarmWorkflowState { + state: if published { "published" } else { "draft" }, + primary_next_action: (!published).then(|| "radroots farm publish".to_owned()), + }) +} + +fn apply_farm_attention( + ready: &mut Vec<String>, + needs_attention: &mut Vec<String>, + next: &mut Vec<String>, + farm: &FarmWorkflowState, +) { + match farm.state { + "missing" => { + needs_attention.push("Farm draft".to_owned()); + } + "draft" => { + needs_attention.push("Farm not yet published".to_owned()); + push_next(next, Some("radroots farm publish")); + } + "published" => { + ready.push("Farm published".to_owned()); + } + "account_missing" => { + needs_attention.push("Farm draft account not available locally".to_owned()); + } + _ => {} + } +} + +fn relay_configured(config: &RuntimeConfig) -> bool { + !config.relay.urls.is_empty() +} + +fn relay_state(config: &RuntimeConfig) -> &'static str { + if relay_configured(config) { + "configured" + } else { + "unconfigured" + } +} + +fn role_name(role: SetupRoleArg) -> &'static str { + match role { + SetupRoleArg::Seller => "seller", + SetupRoleArg::Buyer => "buyer", + SetupRoleArg::Both => "both", + } +} + +fn push_next(next: &mut Vec<String>, command: Option<&str>) { + let Some(command) = command else { + return; + }; + if !next.iter().any(|existing| existing == command) { + next.push(command.to_owned()); + } +} diff --git a/tests/help.rs b/tests/help.rs @@ -71,7 +71,11 @@ fn market_help_is_example_first() { assert!(stdout.contains("search")); assert!(stdout.contains("view")); assert!(stdout.contains("radroots market search tomatoes")); - assert!(stdout.contains("Compatibility paths: `sync pull`, `find`, and `listing get` remain available.")); + assert!( + stdout.contains( + "Compatibility paths: `sync pull`, `find`, and `listing get` remain available." + ) + ); } #[test] @@ -91,7 +95,11 @@ fn sell_help_mentions_listing_compatibility() { assert!(stdout.contains("pause")); assert!(stdout.contains("reprice")); assert!(stdout.contains("restock")); - assert!(stdout.contains("Compatibility path: the advanced `listing` command family remains available.")); + assert!( + stdout.contains( + "Compatibility path: the advanced `listing` command family remains available." + ) + ); } #[test] diff --git a/tests/listing.rs b/tests/listing.rs @@ -11,7 +11,7 @@ use std::time::Duration; use assert_cmd::prelude::*; use radroots_sql_core::{SqlExecutor, SqliteExecutor}; -use serde_json::{json, Value}; +use serde_json::{Value, json}; use tempfile::tempdir; fn data_root(workdir: &Path) -> std::path::PathBuf { @@ -1227,10 +1227,12 @@ fn listing_publish_without_matching_signer_session_exits_unconfigured() { let publish_json: Value = serde_json::from_slice(publish_output.stdout.as_slice()).expect("publish json"); assert_eq!(publish_json["state"], "unconfigured"); - assert!(publish_json["reason"] - .as_str() - .expect("reason") - .contains("no authorized signer session matched seller pubkey")); + assert!( + publish_json["reason"] + .as_str() + .expect("reason") + .contains("no authorized signer session matched seller pubkey") + ); let recorded = requests.lock().expect("requests"); assert_eq!(recorded.len(), 1); @@ -1324,10 +1326,12 @@ fn listing_publish_rejects_requested_session_that_mismatches_seller_pubkey() { let publish_json: Value = serde_json::from_slice(publish_output.stdout.as_slice()).expect("publish json"); assert_eq!(publish_json["state"], "unconfigured"); - assert!(publish_json["reason"] - .as_str() - .expect("reason") - .contains("does not match seller pubkey")); + assert!( + publish_json["reason"] + .as_str() + .expect("reason") + .contains("does not match seller pubkey") + ); let recorded = requests.lock().expect("requests"); assert_eq!(recorded.len(), 1); @@ -1408,10 +1412,11 @@ fn listing_publish_requires_authoritative_write_plane_binding() { let publish_json: Value = serde_json::from_slice(publish_output.stdout.as_slice()).expect("publish json"); assert_eq!(publish_json["state"], "unconfigured"); - assert!(publish_json["reason"] - .as_str() - .expect("reason") - .contains("explicit write-plane capability binding or managed radrootsd instance `local`")); + assert!( + publish_json["reason"].as_str().expect("reason").contains( + "explicit write-plane capability binding or managed radrootsd instance `local`" + ) + ); assert!(requests.lock().expect("requests").is_empty()); } diff --git a/tests/workflow.rs b/tests/workflow.rs @@ -0,0 +1,200 @@ +use std::fs; +use std::path::Path; +use std::process::Command; + +use assert_cmd::prelude::*; +use serde_json::Value; +use tempfile::tempdir; + +fn cli_command_in(workdir: &Path) -> Command { + let mut command = Command::cargo_bin("radroots").expect("binary"); + command.current_dir(workdir); + command.env("HOME", workdir.join("home")); + command.env("APPDATA", workdir.join("roaming")); + command.env("LOCALAPPDATA", workdir.join("local")); + for key in [ + "RADROOTS_ENV_FILE", + "RADROOTS_OUTPUT", + "RADROOTS_CLI_LOGGING_FILTER", + "RADROOTS_CLI_LOGGING_OUTPUT_DIR", + "RADROOTS_CLI_LOGGING_STDOUT", + "RADROOTS_CLI_PATHS_PROFILE", + "RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT", + "RADROOTS_LOG_FILTER", + "RADROOTS_LOG_DIR", + "RADROOTS_LOG_STDOUT", + "RADROOTS_ACCOUNT", + "RADROOTS_ACCOUNT_SECRET_BACKEND", + "RADROOTS_ACCOUNT_SECRET_FALLBACK", + "RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE", + "RADROOTS_IDENTITY_PATH", + "RADROOTS_SIGNER", + "RADROOTS_RELAYS", + "RADROOTS_MYC_EXECUTABLE", + "RADROOTS_RPC_URL", + "RADROOTS_RPC_BEARER_TOKEN", + ] { + command.env_remove(key); + } + command.env("RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE", "false"); + command +} + +fn write_workspace_config(workdir: &Path, contents: &str) { + let config_dir = workdir.join(".radroots"); + fs::create_dir_all(&config_dir).expect("workspace config dir"); + fs::write(config_dir.join("config.toml"), contents).expect("write workspace config"); +} + +#[test] +fn setup_seller_creates_local_state_and_reports_farm_attention() { + let dir = tempdir().expect("tempdir"); + + let output = cli_command_in(dir.path()) + .args(["setup", "seller"]) + .output() + .expect("run setup seller"); + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); + assert!(stdout.contains("Setup saved")); + assert!(stdout.contains("Ready")); + assert!(stdout.contains("Selected account")); + assert!(stdout.contains("Local market data")); + assert!(stdout.contains("Needs attention")); + assert!(stdout.contains("Relay configuration")); + assert!(stdout.contains("Farm draft")); + assert!(stdout.contains("radroots farm setup")); + assert!(stdout.contains("radroots status")); + + let account_output = cli_command_in(dir.path()) + .args(["--json", "account", "view"]) + .output() + .expect("run account view"); + assert!(account_output.status.success()); + let account_json: Value = + serde_json::from_slice(account_output.stdout.as_slice()).expect("account json"); + assert_eq!(account_json["state"], "ready"); + + let local_output = cli_command_in(dir.path()) + .args(["--json", "local", "status"]) + .output() + .expect("run local status"); + assert!(local_output.status.success()); + let local_json: Value = + serde_json::from_slice(local_output.stdout.as_slice()).expect("local json"); + assert_eq!(local_json["state"], "ready"); +} + +#[test] +fn status_is_unconfigured_before_setup() { + let dir = tempdir().expect("tempdir"); + + let output = cli_command_in(dir.path()) + .args(["--json", "status"]) + .output() + .expect("run status"); + + assert_eq!(output.status.code(), Some(3)); + let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("status json"); + assert_eq!(json["state"], "unconfigured"); + assert_eq!(json["ready"], Value::Array(Vec::new())); + assert_eq!( + json["needs_attention"], + serde_json::json!([ + "Selected account", + "Local market data", + "Relay configuration" + ]) + ); + assert_eq!( + json["next"], + serde_json::json!(["radroots setup buyer", "radroots setup seller"]) + ); +} + +#[test] +fn status_calls_out_missing_relay_after_buyer_setup() { + let dir = tempdir().expect("tempdir"); + let setup = cli_command_in(dir.path()) + .args(["setup", "buyer"]) + .output() + .expect("run setup buyer"); + assert!(setup.status.success()); + + let output = cli_command_in(dir.path()) + .args(["--json", "status"]) + .output() + .expect("run status"); + + assert_eq!(output.status.code(), Some(3)); + let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("status json"); + assert_eq!(json["state"], "unconfigured"); + assert_eq!( + json["ready"], + serde_json::json!(["Selected account", "Local market data"]) + ); + assert_eq!( + json["needs_attention"], + serde_json::json!(["Relay configuration"]) + ); + assert_eq!( + json["next"], + serde_json::json!([ + "radroots relay list --relay wss://relay.example.com", + "radroots status" + ]) + ); +} + +#[test] +fn status_reports_farm_publish_need_when_core_state_is_ready() { + let dir = tempdir().expect("tempdir"); + + let account = cli_command_in(dir.path()) + .args(["account", "new"]) + .output() + .expect("run account new"); + assert!(account.status.success()); + + let local = cli_command_in(dir.path()) + .args(["local", "init"]) + .output() + .expect("run local init"); + assert!(local.status.success()); + + write_workspace_config( + dir.path(), + "[relay]\nurls = [\"wss://relay.one\"]\npublish_policy = \"any\"\n", + ); + + let farm = cli_command_in(dir.path()) + .args([ + "farm", + "setup", + "--name", + "La Huerta", + "--location", + "San Francisco, CA", + ]) + .output() + .expect("run farm setup"); + assert!(farm.status.success()); + + let output = cli_command_in(dir.path()) + .args(["status"]) + .output() + .expect("run status"); + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); + assert!(stdout.contains("Status")); + assert!(stdout.contains("Ready")); + assert!(stdout.contains("Selected account")); + assert!(stdout.contains("Local market data")); + assert!(stdout.contains("Relay configuration")); + assert!(stdout.contains("Needs attention")); + assert!(stdout.contains("Farm not yet published")); + assert!(stdout.contains("radroots farm publish")); + assert!(stdout.contains("radroots sell add tomatoes")); +}