cli

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

commit 2b38ca7345e0e59e5d29ce3e37f27cf01a8fa256
parent a5eec5bd9ecbffcc462ac0fa9c395d380cb00b15
Author: triesap <tyson@radroots.org>
Date:   Mon, 27 Apr 2026 10:37:18 +0000

cli: harden seller dry run preflight

- preflight farm create and update dry runs through farm runtime validation
- preflight listing create dry runs with output collision checks
- expose farm update values through the target cli contract
- verify target cli and signer runtime mode integration tests

Diffstat:
Msrc/operation_adapter.rs | 27++++++++++++++++++++-------
Msrc/operation_farm.rs | 49++++++-------------------------------------------
Msrc/operation_listing.rs | 20++++++--------------
Msrc/runtime/farm.rs | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/runtime/listing.rs | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++----
Msrc/target_cli.rs | 40+++++++++++++++++++++++++++++++---------
Mtests/target_cli.rs | 157+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 348 insertions(+), 77 deletions(-)

diff --git a/src/operation_adapter.rs b/src/operation_adapter.rs @@ -956,7 +956,8 @@ fn value_to_data(value: Value) -> OperationData { fn target_operation_input(command: &crate::target_cli::TargetCommand) -> OperationData { use crate::target_cli::{ AccountCommand, AccountSelectionCommand, BasketCommand, BasketItemCommand, - BasketQuoteCommand, FarmCommand, ListingCommand, MarketCommand, MarketListingCommand, + BasketQuoteCommand, FarmCommand, FarmFulfillmentCommand, FarmLocationCommand, + FarmProfileCommand, ListingCommand, MarketCommand, MarketListingCommand, MarketProductCommand, OrderCommand, OrderEventCommand, TargetCommand, }; @@ -994,12 +995,24 @@ fn target_operation_input(command: &crate::target_cli::TargetCommand) -> Operati insert_string(&mut input, "country", &args.country); insert_string(&mut input, "delivery_method", &args.delivery_method); } - FarmCommand::Get - | FarmCommand::Profile(_) - | FarmCommand::Location(_) - | FarmCommand::Fulfillment(_) - | FarmCommand::Readiness(_) - | FarmCommand::Publish => {} + FarmCommand::Profile(args) => match &args.command { + FarmProfileCommand::Update(args) => { + insert_string(&mut input, "field", &args.field); + insert_string(&mut input, "value", &args.value); + } + }, + FarmCommand::Location(args) => match &args.command { + FarmLocationCommand::Update(args) => { + insert_string(&mut input, "field", &args.field); + insert_string(&mut input, "value", &args.value); + } + }, + FarmCommand::Fulfillment(args) => match &args.command { + FarmFulfillmentCommand::Update(args) => { + insert_string(&mut input, "value", &args.value); + } + }, + FarmCommand::Get | FarmCommand::Readiness(_) | FarmCommand::Publish => {} }, TargetCommand::Listing(args) => match &args.command { ListingCommand::Create(args) => { diff --git a/src/operation_farm.rs b/src/operation_farm.rs @@ -1,5 +1,5 @@ use serde::Serialize; -use serde_json::{Value, json}; +use serde_json::Value; use crate::domain::runtime::{CommandDisposition, FarmPublishView}; use crate::operation_adapter::{ @@ -49,12 +49,8 @@ impl OperationService<FarmCreateRequest> for FarmOperationService<'_> { delivery_method: string_input(&request, "delivery_method"), }; if request.context.dry_run { - return json_operation_result::<FarmCreateResult>(json!({ - "state": "dry_run", - "scope": args.scope.map(scope_name), - "name": args.name, - "location": args.location, - })); + let view = map_runtime(crate::runtime::farm::init_preflight(self.config, &args))?; + return serialized_operation_result::<FarmCreateResult, _>(&view); } let view = map_runtime(crate::runtime::farm::init(self.config, &args))?; @@ -171,11 +167,8 @@ where value: vec![value.clone()], }; if request.context.dry_run { - return json_operation_result::<R>(json!({ - "state": "dry_run", - "field": field_name(field), - "value": value, - })); + let view = map_runtime(crate::runtime::farm::set_preflight(config, &args))?; + return serialized_operation_result::<R, _>(&view); } let view = map_runtime(crate::runtime::farm::set(config, &args))?; @@ -243,13 +236,6 @@ fn farm_publish_result( } } -fn json_operation_result<R>(value: Value) -> Result<OperationResult<R>, OperationAdapterError> -where - R: OperationResultData, -{ - OperationResult::new(R::from_value(value)) -} - fn map_runtime<T>(result: Result<T, RuntimeError>) -> Result<T, OperationAdapterError> { result.map_err(|error| OperationAdapterError::Runtime(error.to_string())) } @@ -312,29 +298,6 @@ fn invalid_input(operation_id: &str, message: String) -> OperationAdapterError { } } -fn scope_name(scope: FarmScopeArg) -> &'static str { - match scope { - FarmScopeArg::User => "user", - FarmScopeArg::Workspace => "workspace", - } -} - -fn field_name(field: FarmFieldArg) -> &'static str { - match field { - FarmFieldArg::Name => "name", - FarmFieldArg::DisplayName => "display_name", - FarmFieldArg::About => "about", - FarmFieldArg::Website => "website", - FarmFieldArg::Picture => "picture", - FarmFieldArg::Banner => "banner", - FarmFieldArg::Location => "location", - FarmFieldArg::City => "city", - FarmFieldArg::Region => "region", - FarmFieldArg::Country => "country", - FarmFieldArg::Delivery => "delivery", - } -} - #[cfg(test)] mod tests { use std::path::{Path, PathBuf}; @@ -394,7 +357,7 @@ mod tests { assert_eq!(envelope.operation_id, "farm.create"); assert_eq!(envelope.dry_run, true); - assert_eq!(envelope.result["state"], "dry_run"); + assert_eq!(envelope.result["state"], "unconfigured"); let readiness = OperationRequest::new( OperationContext::default(), diff --git a/src/operation_listing.rs b/src/operation_listing.rs @@ -1,7 +1,7 @@ use std::path::PathBuf; use serde::Serialize; -use serde_json::{Value, json}; +use serde_json::Value; use crate::domain::runtime::{CommandDisposition, ListingMutationView}; use crate::operation_adapter::{ @@ -52,12 +52,11 @@ impl OperationService<ListingCreateRequest> for ListingOperationService<'_> { label: string_input(&request, "label"), }; if request.context.dry_run { - return json_operation_result::<ListingCreateResult>(json!({ - "state": "dry_run", - "output": args.output.as_ref().map(|path| path.display().to_string()), - "key": args.key, - "title": args.title, - })); + let view = map_runtime( + request.operation_id(), + crate::runtime::listing::scaffold_preflight(self.config, &args), + )?; + return serialized_operation_result::<ListingCreateResult, _>(&view); } let view = map_runtime( @@ -246,13 +245,6 @@ where } } -fn json_operation_result<R>(value: Value) -> Result<OperationResult<R>, OperationAdapterError> -where - R: OperationResultData, -{ - OperationResult::new(R::from_value(value)) -} - fn map_runtime<T>( operation_id: &str, result: Result<T, RuntimeError>, diff --git a/src/runtime/farm.rs b/src/runtime/farm.rs @@ -50,6 +50,38 @@ pub fn init(config: &RuntimeConfig, args: &FarmCreateArgs) -> Result<FarmSetupVi ) } +pub fn init_preflight( + config: &RuntimeConfig, + args: &FarmCreateArgs, +) -> Result<FarmSetupView, RuntimeError> { + let scope = scope_from_arg(args.scope); + let resolved_scope = farm_config::resolve_scope(&config.paths, scope)?; + let Some(selected_account) = selected_account_for_draft(config)? else { + return Ok(missing_selected_account_setup_view()); + }; + let existing = farm_config::load(config, Some(resolved_scope))?; + let document = init_document(resolved_scope, &selected_account, existing.as_ref(), args)?; + let path = farm_config::config_path(&config.paths, resolved_scope)?; + Ok(FarmSetupView { + state: "dry_run".to_owned(), + source: FARM_CONFIG_SOURCE.to_owned(), + config: Some(summary_view( + resolved_scope, + path.display().to_string(), + &document, + Some( + selected_account + .record + .public_identity + .public_key_hex + .as_str(), + ), + )), + reason: Some("dry run requested; farm draft was not written".to_owned()), + actions: farm_setup_actions(&document), + }) +} + pub fn set(config: &RuntimeConfig, args: &FarmUpdateArgs) -> Result<FarmSetView, RuntimeError> { let scope = scope_from_arg(args.scope); let resolved_scope = farm_config::resolve_scope(&config.paths, scope)?; @@ -91,6 +123,49 @@ pub fn set(config: &RuntimeConfig, args: &FarmUpdateArgs) -> Result<FarmSetView, }) } +pub fn set_preflight( + config: &RuntimeConfig, + args: &FarmUpdateArgs, +) -> Result<FarmSetView, 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(mut resolved) = farm_config::load(config, Some(resolved_scope))? else { + return Ok(FarmSetView { + state: "unconfigured".to_owned(), + source: FARM_CONFIG_SOURCE.to_owned(), + field: human_field_name(args.field).to_owned(), + value: human_field_value(args.field, args.value.join(" ").trim()).to_owned(), + config: None, + reason: Some(format!("no farm draft found at {}", path.display())), + actions: vec!["radroots farm create".to_owned()], + }); + }; + + let raw_value = args.value.join(" "); + let field_value = required_text(raw_value.as_str(), "farm set value")?; + apply_field_update(&mut resolved.document, args.field, field_value.as_str())?; + let configured_account = configured_account(config, &resolved.document.selection.account)?; + let account_pubkey = configured_account + .as_ref() + .map(|account| account.record.public_identity.public_key_hex.as_str()); + + Ok(FarmSetView { + state: "dry_run".to_owned(), + source: FARM_CONFIG_SOURCE.to_owned(), + field: human_field_name(args.field).to_owned(), + value: human_field_value(args.field, field_value.as_str()).to_owned(), + config: Some(summary_view( + resolved.scope, + path.display().to_string(), + &resolved.document, + account_pubkey, + )), + reason: Some("dry run requested; farm draft was not written".to_owned()), + actions: vec!["radroots farm readiness check".to_owned()], + }) +} + pub fn status( config: &RuntimeConfig, args: &FarmScopedArgs, diff --git a/src/runtime/listing.rs b/src/runtime/listing.rs @@ -222,6 +222,40 @@ pub fn scaffold( }) } +pub fn scaffold_preflight( + config: &RuntimeConfig, + args: &ListingCreateArgs, +) -> Result<ListingNewView, RuntimeError> { + let (draft, defaults) = build_listing_draft(config, args)?; + let output_path = listing_output_path(config, args.output.as_ref(), &draft.listing.d_tag)?; + validate_listing_output_target(&output_path)?; + + let mut actions = vec![format!( + "radroots listing validate {}", + output_path.display() + )]; + if defaults.selected_account_pubkey.is_none() { + actions.push("radroots account create".to_owned()); + } + if let Some(action) = &defaults.farm_next_action { + actions.push(action.clone()); + } + + Ok(ListingNewView { + state: "dry_run".to_owned(), + source: LISTING_SOURCE.to_owned(), + file: output_path.display().to_string(), + listing_id: draft.listing.d_tag, + selected_account_id: defaults.selected_account_id, + seller_pubkey: defaults.selected_account_pubkey, + farm_d_tag: defaults.selected_farm_d_tag, + delivery_method: non_empty(draft.delivery.method.clone()), + location_primary: non_empty(draft.location.primary.clone()), + reason: Some("dry run requested; listing draft was not written".to_owned()), + actions, + }) +} + fn build_listing_draft( config: &RuntimeConfig, args: &ListingCreateArgs, @@ -305,16 +339,31 @@ fn write_listing_draft( draft: &ListingDraftDocument, overwrite: bool, ) -> Result<(), RuntimeError> { - if output_path.exists() && !overwrite { + if !overwrite { + validate_listing_output_target(output_path)?; + } + if let Some(parent) = output_path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(output_path, scaffold_contents(draft)?)?; + Ok(()) +} + +fn validate_listing_output_target(output_path: &Path) -> Result<(), RuntimeError> { + if output_path.exists() { return Err(RuntimeError::Config(format!( - "listing draft output {} already exists", + "listing draft output {} must not already exist", output_path.display() ))); } if let Some(parent) = output_path.parent() { - fs::create_dir_all(parent)?; + if parent.exists() && !parent.is_dir() { + return Err(RuntimeError::Config(format!( + "listing draft parent {} is not a directory", + parent.display() + ))); + } } - fs::write(output_path, scaffold_contents(draft)?)?; Ok(()) } diff --git a/src/target_cli.rs b/src/target_cli.rs @@ -144,13 +144,13 @@ impl TargetCommand { FarmCommand::Create(_) => "farm.create", FarmCommand::Get => "farm.get", FarmCommand::Profile(profile) => match profile.command { - FarmProfileCommand::Update => "farm.profile.update", + FarmProfileCommand::Update(_) => "farm.profile.update", }, FarmCommand::Location(location) => match location.command { - FarmLocationCommand::Update => "farm.location.update", + FarmLocationCommand::Update(_) => "farm.location.update", }, FarmCommand::Fulfillment(fulfillment) => match fulfillment.command { - FarmFulfillmentCommand::Update => "farm.fulfillment.update", + FarmFulfillmentCommand::Update(_) => "farm.fulfillment.update", }, FarmCommand::Readiness(readiness) => match readiness.command { FarmReadinessCommand::Check => "farm.readiness.check", @@ -512,9 +512,17 @@ pub struct FarmProfileArgs { pub command: FarmProfileCommand, } -#[derive(Debug, Clone, Copy, Subcommand)] +#[derive(Debug, Clone, Subcommand)] pub enum FarmProfileCommand { - Update, + Update(FarmProfileUpdateArgs), +} + +#[derive(Debug, Clone, Args)] +pub struct FarmProfileUpdateArgs { + #[arg(long)] + pub field: Option<String>, + #[arg(long)] + pub value: Option<String>, } #[derive(Debug, Clone, Args)] @@ -523,9 +531,17 @@ pub struct FarmLocationArgs { pub command: FarmLocationCommand, } -#[derive(Debug, Clone, Copy, Subcommand)] +#[derive(Debug, Clone, Subcommand)] pub enum FarmLocationCommand { - Update, + Update(FarmLocationUpdateArgs), +} + +#[derive(Debug, Clone, Args)] +pub struct FarmLocationUpdateArgs { + #[arg(long)] + pub field: Option<String>, + #[arg(long)] + pub value: Option<String>, } #[derive(Debug, Clone, Args)] @@ -534,9 +550,15 @@ pub struct FarmFulfillmentArgs { pub command: FarmFulfillmentCommand, } -#[derive(Debug, Clone, Copy, Subcommand)] +#[derive(Debug, Clone, Subcommand)] pub enum FarmFulfillmentCommand { - Update, + Update(FarmFulfillmentUpdateArgs), +} + +#[derive(Debug, Clone, Args)] +pub struct FarmFulfillmentUpdateArgs { + #[arg(long)] + pub value: Option<String>, } #[derive(Debug, Clone, Args)] diff --git a/tests/target_cli.rs b/tests/target_cli.rs @@ -619,6 +619,163 @@ fn runtime_lifecycle_dry_runs_inspect_without_changing_runtime_status() { } #[test] +fn seller_dry_runs_preflight_without_mutating_farm_or_listing_files() { + let sandbox = RadrootsCliSandbox::new(); + sandbox.json_success(&["--format", "json", "account", "create"]); + + let farm_dry_run = sandbox.json_success(&[ + "--format", + "json", + "--dry-run", + "farm", + "create", + "--name", + "Green Farm", + "--location", + "farmstand", + "--country", + "US", + "--delivery-method", + "pickup", + ]); + let farm_path = farm_dry_run["result"]["config"]["path"] + .as_str() + .expect("farm path"); + assert_eq!(farm_dry_run["operation_id"], "farm.create"); + assert_eq!(farm_dry_run["result"]["state"], "dry_run"); + assert!(!Path::new(farm_path).exists()); + + let missing_update = sandbox.json_success(&[ + "--format", + "json", + "--dry-run", + "farm", + "profile", + "update", + "--value", + "Dry Name", + ]); + assert_eq!(missing_update["operation_id"], "farm.profile.update"); + assert_eq!(missing_update["result"]["state"], "unconfigured"); + assert!(!Path::new(farm_path).exists()); + + let farm = sandbox.json_success(&[ + "--format", + "json", + "farm", + "create", + "--name", + "Green Farm", + "--location", + "farmstand", + "--country", + "US", + "--delivery-method", + "pickup", + ]); + let farm_path = farm["result"]["config"]["path"] + .as_str() + .expect("farm path"); + let farm_before = fs::read_to_string(farm_path).expect("farm before"); + let farm_update = sandbox.json_success(&[ + "--format", + "json", + "--dry-run", + "farm", + "profile", + "update", + "--value", + "Dry Name", + ]); + assert_eq!(farm_update["operation_id"], "farm.profile.update"); + assert_eq!(farm_update["result"]["state"], "dry_run"); + assert_eq!(farm_update["result"]["config"]["name"], "Dry Name"); + assert_eq!( + fs::read_to_string(farm_path).expect("farm after dry-run"), + farm_before + ); + + let listing_path = sandbox.root().join("dry-listing.toml"); + let listing_path_arg = listing_path.to_string_lossy(); + let listing_dry_run = sandbox.json_success(&[ + "--format", + "json", + "--dry-run", + "listing", + "create", + "--output", + listing_path_arg.as_ref(), + "--key", + "eggs", + "--title", + "Eggs", + "--category", + "eggs", + "--summary", + "Fresh eggs", + "--bin-id", + "bin-1", + "--quantity-amount", + "1", + "--quantity-unit", + "each", + "--price-amount", + "6", + "--price-currency", + "USD", + "--price-per-amount", + "1", + "--price-per-unit", + "each", + "--available", + "10", + ]); + assert_eq!(listing_dry_run["operation_id"], "listing.create"); + assert_eq!(listing_dry_run["result"]["state"], "dry_run"); + assert_eq!(listing_dry_run["result"]["file"], listing_path_arg.as_ref()); + assert!(!listing_path.exists()); + + fs::write(&listing_path, "existing").expect("existing listing path"); + let (collision_output, collision) = sandbox.json_output(&[ + "--format", + "json", + "--dry-run", + "listing", + "create", + "--output", + listing_path_arg.as_ref(), + "--key", + "eggs", + ]); + assert!(!collision_output.status.success()); + assert_eq!(collision["operation_id"], "listing.create"); + assert_eq!(collision["errors"][0]["code"], "validation_failed"); + + let listing_file = create_listing_draft(&sandbox, "seller-dry-run"); + make_listing_publishable( + &listing_file, + farm["result"]["config"]["farm_d_tag"] + .as_str() + .expect("farm d tag"), + ); + let listing_before = fs::read_to_string(&listing_file).expect("listing before"); + let listing_update = sandbox.json_success(&[ + "--format", + "json", + "--dry-run", + "listing", + "update", + listing_file.to_string_lossy().as_ref(), + ]); + assert_eq!(listing_update["operation_id"], "listing.update"); + assert_eq!(listing_update["result"]["state"], "dry_run"); + assert_eq!( + fs::read_to_string(&listing_file).expect("listing after dry-run"), + listing_before + ); +} + +#[test] fn required_approval_token_rejects_absent_empty_and_whitespace_values() { let sandbox = RadrootsCliSandbox::new(); let public_identity = identity_public(61);