cli

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

commit 45d7d5e64d14697b144dce99ba5719b6968878cd
parent 67da82d341870391792bcc700a34049edd7aaace
Author: triesap <tyson@radroots.org>
Date:   Mon, 20 Apr 2026 17:34:05 +0000

remove workflow account mutation

Diffstat:
Msrc/cli.rs | 2++
Msrc/domain/runtime.rs | 2+-
Msrc/render/mod.rs | 4+++-
Msrc/runtime/workflow.rs | 124+++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------
Mtests/help.rs | 6++++++
Mtests/workflow.rs | 108++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
6 files changed, 189 insertions(+), 57 deletions(-)

diff --git a/src/cli.rs b/src/cli.rs @@ -86,6 +86,7 @@ Examples: radroots setup both This workflow layer sits on top of the existing account, local, and farm commands. +Use `radroots account create` or `radroots account select` explicitly when no actor is resolved. "; const STATUS_HELP: &str = "\ @@ -95,6 +96,7 @@ Examples: radroots config show This workflow summary reflects the current readiness and configuration surfaces. +When no actor is resolved, it points to explicit account commands instead of mutating account state. "; const ACCOUNT_HELP: &str = "\ diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs @@ -675,7 +675,7 @@ pub struct SetupView { pub state: String, pub source: String, pub role: String, - pub selected_account_id: String, + pub account_resolution: AccountResolutionView, pub local_state: String, pub local_root: String, pub relay_state: String, diff --git a/src/render/mod.rs b/src/render/mod.rs @@ -3597,7 +3597,9 @@ fn render_setup(stdout: &mut dyn Write, view: &SetupView) -> Result<(), RuntimeE &view.ready, &view.needs_attention, &view.next, - ) + )?; + writeln!(stdout)?; + render_account_resolution(stdout, &view.account_resolution) } fn render_status_summary(stdout: &mut dyn Write, view: &StatusView) -> Result<(), RuntimeError> { diff --git a/src/runtime/workflow.rs b/src/runtime/workflow.rs @@ -1,7 +1,7 @@ use crate::cli::{FarmScopedArgs, SetupRoleArg}; use crate::domain::runtime::{SetupView, StatusView}; use crate::runtime::RuntimeError; -use crate::runtime::accounts::{self, AccountRecordView}; +use crate::runtime::accounts::{self}; use crate::runtime::config::RuntimeConfig; use crate::runtime::{farm, local}; @@ -9,51 +9,66 @@ 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 account_resolution = accounts::resolve_account_resolution(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 state = "saved"; + let mut ready = Vec::new(); let mut needs_attention = Vec::new(); let mut next = Vec::new(); + if account_resolution.resolved_account.is_some() { + ready.push("Resolved account".to_owned()); + } else { + state = "unconfigured"; + needs_attention.push("Resolved account".to_owned()); + push_unresolved_account_actions(config, &mut next, Some(role))?; + } + + 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 { 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()); + if account_resolution.resolved_account.is_some() { + 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 => {} } - SetupRoleArg::Buyer => {} - } - match role { - SetupRoleArg::Buyer | SetupRoleArg::Both if relay_configured => { - push_next(&mut next, Some("radroots market search tomatoes")); + 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)); - } + if !relay_configured { + push_next(&mut next, Some(RELAY_SETUP_ACTION)); + } - push_next(&mut next, Some("radroots status")); + push_next(&mut next, Some("radroots status")); + } Ok(SetupView { - state: "saved".to_owned(), + state: state.to_owned(), source: WORKFLOW_SOURCE.to_owned(), role: role_name(role).to_owned(), - selected_account_id: account.record.account_id.to_string(), + account_resolution: accounts::account_resolution_view(&account_resolution), local_state: local_status.state, local_root: local_status.local_root, relay_state: relay_state(config).to_owned(), @@ -82,6 +97,7 @@ pub fn status(config: &RuntimeConfig) -> Result<StatusView, RuntimeError> { } else { state = "unconfigured"; needs_attention.push("Resolved account".to_owned()); + push_unresolved_account_actions(config, &mut next, None)?; } if local_status.state == "ready" { @@ -108,7 +124,7 @@ pub fn status(config: &RuntimeConfig) -> Result<StatusView, RuntimeError> { _ => {} } } - } else { + } else if account_resolution.resolved_account.is_some() { push_next(&mut next, Some("radroots setup buyer")); push_next(&mut next, Some("radroots setup seller")); if account_resolution.resolved_account.is_some() @@ -136,19 +152,6 @@ pub fn status(config: &RuntimeConfig) -> Result<StatusView, RuntimeError> { }) } -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> { @@ -238,6 +241,47 @@ fn role_name(role: SetupRoleArg) -> &'static str { } } +fn push_unresolved_account_actions( + config: &RuntimeConfig, + next: &mut Vec<String>, + setup_role: Option<SetupRoleArg>, +) -> Result<(), RuntimeError> { + match unresolved_account_resolution_state(config)? { + UnresolvedAccountResolutionState::NoAccounts => { + push_next(next, Some("radroots account create")); + } + UnresolvedAccountResolutionState::AccountsExistWithoutDefault => { + push_next(next, Some("radroots account list")); + push_next(next, Some("radroots account select <selector>")); + } + } + + if let Some(role) = setup_role { + push_next(next, Some(setup_command(role))); + } + + Ok(()) +} + +fn unresolved_account_resolution_state( + config: &RuntimeConfig, +) -> Result<UnresolvedAccountResolutionState, RuntimeError> { + let snapshot = accounts::snapshot(config)?; + Ok(if snapshot.accounts.is_empty() { + UnresolvedAccountResolutionState::NoAccounts + } else { + UnresolvedAccountResolutionState::AccountsExistWithoutDefault + }) +} + +fn setup_command(role: SetupRoleArg) -> &'static str { + match role { + SetupRoleArg::Seller => "radroots setup seller", + SetupRoleArg::Buyer => "radroots setup buyer", + SetupRoleArg::Both => "radroots setup both", + } +} + fn push_next(next: &mut Vec<String>, command: Option<&str>) { let Some(command) = command else { return; @@ -246,3 +290,9 @@ fn push_next(next: &mut Vec<String>, command: Option<&str>) { next.push(command.to_owned()); } } + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum UnresolvedAccountResolutionState { + NoAccounts, + AccountsExistWithoutDefault, +} diff --git a/tests/help.rs b/tests/help.rs @@ -39,6 +39,9 @@ fn setup_help_describes_the_landed_workflow_layer() { assert!(stdout.contains( "This workflow layer sits on top of the existing account, local, and farm commands." )); + assert!(stdout.contains( + "Use `radroots account create` or `radroots account select` explicitly when no actor is resolved." + )); assert!(!stdout.contains("being added")); } @@ -54,6 +57,9 @@ fn status_help_describes_current_readiness_surfaces() { assert!(stdout.contains( "This workflow summary reflects the current readiness and configuration surfaces." )); + assert!(stdout.contains( + "When no actor is resolved, it points to explicit account commands instead of mutating account state." + )); assert!(!stdout.contains("being added")); } diff --git a/tests/workflow.rs b/tests/workflow.rs @@ -6,6 +6,14 @@ use assert_cmd::prelude::*; use serde_json::Value; use tempfile::tempdir; +fn data_root(workdir: &Path) -> std::path::PathBuf { + if cfg!(windows) { + workdir.join("local").join("Radroots").join("data") + } else { + workdir.join("home").join(".radroots").join("data") + } +} + fn cli_command_in(workdir: &Path) -> Command { let mut command = Command::cargo_bin("radroots").expect("binary"); command.current_dir(workdir); @@ -47,34 +55,27 @@ fn write_workspace_config(workdir: &Path, contents: &str) { } #[test] -fn setup_seller_creates_local_state_and_reports_farm_attention() { +fn setup_seller_without_account_is_unconfigured_and_does_not_create_account() { let dir = tempdir().expect("tempdir"); + let store_path = data_root(dir.path()).join("shared/accounts/store.json"); let output = cli_command_in(dir.path()) .args(["setup", "seller"]) .output() .expect("run setup seller"); - assert!(output.status.success()); + assert_eq!(output.status.code(), Some(3)); let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); - assert!(stdout.contains("Setup saved")); + assert!(stdout.contains("Not ready yet")); assert!(stdout.contains("Ready")); - assert!(stdout.contains("Selected account")); + assert!(stdout.contains("Resolved 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 init")); - 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"); + assert!(stdout.contains("Account resolution")); + assert!(stdout.contains("radroots account create")); + assert!(stdout.contains("radroots setup seller")); + assert!(!store_path.exists()); let local_output = cli_command_in(dir.path()) .args(["--json", "local", "status"]) @@ -87,7 +88,38 @@ fn setup_seller_creates_local_state_and_reports_farm_attention() { } #[test] -fn status_is_unconfigured_before_setup() { +fn setup_seller_with_default_account_reports_farm_attention() { + let dir = tempdir().expect("tempdir"); + let store_path = data_root(dir.path()).join("shared/accounts/store.json"); + + let account_output = cli_command_in(dir.path()) + .args(["account", "new"]) + .output() + .expect("run account new"); + assert!(account_output.status.success()); + assert!(store_path.exists()); + + 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("Resolved 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("Account resolution")); + assert!(stdout.contains("radroots farm init")); + assert!(stdout.contains("radroots status")); +} + +#[test] +fn status_is_unconfigured_before_account_setup() { let dir = tempdir().expect("tempdir"); let output = cli_command_in(dir.path()) @@ -107,15 +139,55 @@ fn status_is_unconfigured_before_setup() { "Relay configuration" ]) ); + assert_eq!(json["next"], serde_json::json!(["radroots account create"])); +} + +#[test] +fn status_points_to_account_selection_when_accounts_exist_without_default() { + let dir = tempdir().expect("tempdir"); + let store_path = data_root(dir.path()).join("shared/accounts/store.json"); + + let account_output = cli_command_in(dir.path()) + .args(["account", "new"]) + .output() + .expect("run account new"); + assert!(account_output.status.success()); + let mut store_json: Value = + serde_json::from_slice(fs::read(&store_path).expect("read store").as_slice()) + .expect("parse store"); + store_json["selected_account_id"] = Value::Null; + fs::write( + &store_path, + serde_json::to_vec_pretty(&store_json).expect("serialize store"), + ) + .expect("write store"); + + 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["account_resolution"]["source"], "none"); assert_eq!( json["next"], - serde_json::json!(["radroots setup buyer", "radroots setup seller"]) + serde_json::json!([ + "radroots account list", + "radroots account select <selector>" + ]) ); } #[test] fn status_calls_out_missing_relay_after_buyer_setup() { 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 setup = cli_command_in(dir.path()) .args(["setup", "buyer"]) .output()