cli

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

commit 1f7c1d99bb15de81dffb31bb2bd649cdd74104ee
parent e23ea523c84b190abb9b80ad2821e99b801ff3c2
Author: triesap <tyson@radroots.org>
Date:   Mon, 27 Apr 2026 07:27:28 +0000

cli: validate dry run preflight

Diffstat:
Msrc/operation_listing.rs | 58+++++++++++++++++++++++-----------------------------------
Msrc/runtime/listing.rs | 36+++++++++++++++++++++++++++++-------
Msrc/runtime/order.rs | 8++++----
Mtests/signer_runtime_modes.rs | 44++++++++++++++++++++++++++++++++++++++++++++
Mtests/target_cli.rs | 32++++++++++++++++++++++++++++----
5 files changed, 128 insertions(+), 50 deletions(-)

diff --git a/src/operation_listing.rs b/src/operation_listing.rs @@ -105,11 +105,9 @@ impl OperationService<ListingUpdateRequest> for ListingOperationService<'_> { &self, request: OperationRequest<ListingUpdateRequest>, ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - if request.context.dry_run { - return mutation_dry_run::<ListingUpdateResult>(&request, "update"); - } let args = mutation_args(&request)?; - let view = map_runtime(crate::runtime::listing::update(self.config, &args))?; + let config = mutation_config(self.config, &request); + let view = map_runtime(crate::runtime::listing::update(&config, &args))?; serialized_operation_result::<ListingUpdateResult, _>(&view) } } @@ -136,12 +134,12 @@ impl OperationService<ListingPublishRequest> for ListingOperationService<'_> { &self, request: OperationRequest<ListingPublishRequest>, ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - if request.context.dry_run { - return mutation_dry_run::<ListingPublishResult>(&request, "publish"); + if !request.context.dry_run { + require_approval(&request)?; } - require_approval(&request)?; let args = mutation_args(&request)?; - let view = crate::runtime::listing::publish(self.config, &args) + let config = mutation_config(self.config, &request); + let view = crate::runtime::listing::publish(&config, &args) .map_err(|error| publish_runtime_error(request.operation_id(), error))?; mutation_result::<ListingPublishResult>(request.operation_id(), &view) } @@ -154,16 +152,27 @@ impl OperationService<ListingArchiveRequest> for ListingOperationService<'_> { &self, request: OperationRequest<ListingArchiveRequest>, ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - if request.context.dry_run { - return mutation_dry_run::<ListingArchiveResult>(&request, "archive"); + if !request.context.dry_run { + require_approval(&request)?; } - require_approval(&request)?; let args = mutation_args(&request)?; - let view = map_runtime(crate::runtime::listing::archive(self.config, &args))?; + let config = mutation_config(self.config, &request); + let view = map_runtime(crate::runtime::listing::archive(&config, &args))?; mutation_result::<ListingArchiveResult>(request.operation_id(), &view) } } +fn mutation_config<P>(config: &RuntimeConfig, request: &OperationRequest<P>) -> RuntimeConfig +where + P: OperationRequestPayload, +{ + let mut config = config.clone(); + if request.context.dry_run { + config.output.dry_run = true; + } + config +} + fn mutation_args<P>( request: &OperationRequest<P>, ) -> Result<ListingMutationArgs, OperationAdapterError> @@ -183,22 +192,6 @@ where }) } -fn mutation_dry_run<R>( - request: &OperationRequest<impl OperationRequestPayload + OperationRequestData>, - action: &str, -) -> Result<OperationResult<R>, OperationAdapterError> -where - R: OperationResultData, -{ - json_operation_result::<R>(json!({ - "state": "dry_run", - "action": action, - "file": optional_path(request, "file").map(|path| path.display().to_string()), - "idempotency_key": request.context.idempotency_key, - "signer_session_id": string_input(request, "signer_session_id"), - })) -} - fn require_approval<P>(request: &OperationRequest<P>) -> Result<(), OperationAdapterError> where P: OperationRequestPayload + OperationRequestData, @@ -441,13 +434,8 @@ mod tests { ListingArchiveRequest::from_data(data(&[("file", "listing.toml")])), ) .expect("listing archive request"); - let archive_envelope = service - .execute(archive) - .expect("archive dry run") - .to_envelope(context.envelope_context("req_listing_archive")) - .expect("archive envelope"); - assert_eq!(archive_envelope.operation_id, "listing.archive"); - assert_eq!(archive_envelope.result["state"], "dry_run"); + let archive_error = service.execute(archive).expect_err("archive preflight"); + assert!(!format!("{archive_error}").contains("approval_token")); } fn sample_config(root: &Path) -> RuntimeConfig { diff --git a/src/runtime/listing.rs b/src/runtime/listing.rs @@ -1085,6 +1085,13 @@ fn mutate( let (event_preview, listing_addr) = build_listing_event_preview(&canonical)?; + if config.output.dry_run + && matches!(operation, ListingMutationOperation::Publish) + && matches!(config.signer.backend, SignerBackend::Local) + { + validate_local_listing_signer(config, &canonical)?; + } + if config.output.dry_run { return Ok(ListingMutationView { state: "dry_run".to_owned(), @@ -1788,6 +1795,27 @@ fn sign_listing_event( config: &RuntimeConfig, canonical: &CanonicalListingDraft, ) -> Result<radroots_nostr::prelude::RadrootsNostrEvent, RuntimeError> { + let signing = resolve_listing_signing_identity(config, canonical)?; + let parts = to_wire_parts_with_kind(&canonical.listing, KIND_LISTING) + .map_err(|error| RuntimeError::Config(format!("invalid listing contract: {error}")))?; + let event = radroots_nostr_build_event(parts.kind, parts.content, parts.tags) + .map_err(|error| RuntimeError::Config(format!("build local listing event: {error}")))? + .sign_with_keys(signing.identity.keys()) + .map_err(|error| RuntimeError::Config(format!("sign local listing event: {error}")))?; + Ok(event) +} + +fn validate_local_listing_signer( + config: &RuntimeConfig, + canonical: &CanonicalListingDraft, +) -> Result<(), RuntimeError> { + resolve_listing_signing_identity(config, canonical).map(|_| ()) +} + +fn resolve_listing_signing_identity( + config: &RuntimeConfig, + canonical: &CanonicalListingDraft, +) -> Result<accounts::AccountSigningIdentity, RuntimeError> { let signing = accounts::resolve_local_signing_identity(config)?; let account_pubkey = signing .account @@ -1801,13 +1829,7 @@ fn sign_listing_event( canonical.seller_pubkey ))); } - let parts = to_wire_parts_with_kind(&canonical.listing, KIND_LISTING) - .map_err(|error| RuntimeError::Config(format!("invalid listing contract: {error}")))?; - let event = radroots_nostr_build_event(parts.kind, parts.content, parts.tags) - .map_err(|error| RuntimeError::Config(format!("build local listing event: {error}")))? - .sign_with_keys(signing.identity.keys()) - .map_err(|error| RuntimeError::Config(format!("sign local listing event: {error}")))?; - Ok(event) + Ok(signing) } fn signed_listing_event_view( diff --git a/src/runtime/order.rs b/src/runtime/order.rs @@ -350,7 +350,7 @@ pub fn submit( buyer_account_id: None, buyer_pubkey: None, seller_pubkey: None, - dry_run: false, + dry_run: config.output.dry_run, deduplicated: false, idempotency_key: args.idempotency_key.clone(), signer_mode: None, @@ -379,7 +379,7 @@ pub fn submit( buyer_account_id: None, buyer_pubkey: None, seller_pubkey: None, - dry_run: false, + dry_run: config.output.dry_run, deduplicated: false, idempotency_key: args.idempotency_key.clone(), signer_mode: None, @@ -409,7 +409,7 @@ pub fn submit( buyer_account_id: loaded.document.buyer_account_id.clone(), buyer_pubkey: non_empty_string(loaded.document.order.buyer_pubkey.clone()), seller_pubkey: non_empty_string(loaded.document.order.seller_pubkey.clone()), - dry_run: false, + dry_run: config.output.dry_run, deduplicated: false, idempotency_key: args.idempotency_key.clone(), signer_mode: job.signer_mode.clone(), @@ -439,7 +439,7 @@ pub fn submit( buyer_account_id: loaded.document.buyer_account_id.clone(), buyer_pubkey: non_empty_string(loaded.document.order.buyer_pubkey.clone()), seller_pubkey: non_empty_string(loaded.document.order.seller_pubkey.clone()), - dry_run: false, + dry_run: config.output.dry_run, deduplicated: false, idempotency_key: args.idempotency_key.clone(), signer_mode: None, diff --git a/tests/signer_runtime_modes.rs b/tests/signer_runtime_modes.rs @@ -289,6 +289,27 @@ fn local_listing_publish_fails_without_local_account_authority() { } #[test] +fn local_listing_publish_dry_run_validates_local_account_authority() { + let sandbox = RadrootsCliSandbox::new(); + let listing_file = create_listing_draft(&sandbox, "local-dry-run-no-account"); + + let (output, value) = sandbox.json_output(&[ + "--format", + "json", + "--dry-run", + "listing", + "publish", + listing_file.to_string_lossy().as_ref(), + ]); + + assert!(!output.status.success()); + assert_eq!(value["operation_id"], "listing.publish"); + assert_eq!(value["result"], serde_json::Value::Null); + assert_eq!(value["errors"][0]["code"], "account_unresolved"); + assert_eq!(value["errors"][0]["detail"]["class"], "account"); +} + +#[test] fn local_listing_publish_signs_with_selected_account_without_remote_fallback() { let sandbox = RadrootsCliSandbox::new(); sandbox.json_success(&["--format", "json", "account", "create"]); @@ -342,6 +363,29 @@ fn local_listing_publish_signs_with_selected_account_without_remote_fallback() { } #[test] +fn local_listing_publish_dry_run_does_not_sign_matching_listing() { + let sandbox = RadrootsCliSandbox::new(); + sandbox.json_success(&["--format", "json", "account", "create"]); + let listing_file = create_listing_draft(&sandbox, "local-dry-run"); + make_listing_publishable(&listing_file, "AAAAAAAAAAAAAAAAAAAAAw"); + + let value = sandbox.json_success(&[ + "--format", + "json", + "--dry-run", + "listing", + "publish", + listing_file.to_string_lossy().as_ref(), + ]); + + assert_eq!(value["operation_id"], "listing.publish"); + assert_eq!(value["dry_run"], true); + assert_eq!(value["result"]["state"], "dry_run"); + assert_eq!(value["result"]["dry_run"], true); + assert_eq!(value["result"]["event_id"], serde_json::Value::Null); +} + +#[test] fn local_listing_publish_fails_when_selected_account_does_not_match_seller() { let sandbox = RadrootsCliSandbox::new(); let first = sandbox.json_success(&["--format", "json", "account", "create"]); diff --git a/tests/target_cli.rs b/tests/target_cli.rs @@ -2,7 +2,10 @@ mod support; use serde_json::Value; -use support::{RadrootsCliSandbox, assert_no_removed_command_reference, radroots}; +use support::{ + RadrootsCliSandbox, assert_no_removed_command_reference, create_listing_draft, + make_listing_publishable, radroots, +}; const LISTING_ADDR: &str = "30402:1111111111111111111111111111111111111111111111111111111111111111:AAAAAAAAAAAAAAAAAAAAAg"; @@ -189,8 +192,9 @@ fn offline_forbids_external_network_operations() { #[test] fn offline_allows_supported_external_dry_run() { let sandbox = RadrootsCliSandbox::new(); - let listing_file = sandbox.root().join("listing.toml"); - let listing_file = listing_file.to_string_lossy().into_owned(); + sandbox.json_success(&["--format", "json", "account", "create"]); + let listing_file = create_listing_draft(&sandbox, "offline-dry-run"); + make_listing_publishable(&listing_file, "AAAAAAAAAAAAAAAAAAAAAw"); let publish = sandbox.json_success(&[ "--format", @@ -199,7 +203,7 @@ fn offline_allows_supported_external_dry_run() { "--dry-run", "listing", "publish", - listing_file.as_str(), + listing_file.to_string_lossy().as_ref(), ]); assert_eq!(publish["operation_id"], "listing.publish"); @@ -207,6 +211,25 @@ fn offline_allows_supported_external_dry_run() { } #[test] +fn listing_publish_dry_run_validates_missing_file() { + let sandbox = RadrootsCliSandbox::new(); + let missing = sandbox.root().join("missing-listing.toml"); + let (output, value) = sandbox.json_output(&[ + "--format", + "json", + "--dry-run", + "listing", + "publish", + missing.to_string_lossy().as_ref(), + ]); + + assert!(!output.status.success()); + assert_eq!(value["operation_id"], "listing.publish"); + assert_eq!(value["result"], Value::Null); + assert_eq!(value["errors"][0]["code"], "runtime_error"); +} + +#[test] fn online_requires_relay_for_external_network_operations() { let output = radroots() .args(["--format", "json", "--online", "market", "refresh"]) @@ -310,6 +333,7 @@ fn buyer_target_flow_acceptance_uses_target_operations() { assert_eq!(submit["operation_id"], "order.submit"); assert_eq!(submit["dry_run"], true); assert_eq!(submit["result"]["state"], "unconfigured"); + assert_eq!(submit["result"]["dry_run"], true); assert_eq!(submit["result"]["order_id"], order_id); assert!( submit["result"]["reason"]