cli

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

commit d27ff693775d5c52e6c3c5c90480869d417e8b1d
parent 3885ce5f284399bafcd63c5ef53a46698c0aa946
Author: triesap <tyson@radroots.org>
Date:   Sat, 23 May 2026 08:15:38 +0000

cli: export app-authored listing records

- adds listing app list and export commands for shared app records
- materializes app listing payloads as canonical CLI listing drafts
- keeps publish routed through existing listing publish files
- covers discovery dry-run export and validation with target CLI tests

Diffstat:
Msrc/domain/runtime.rs | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/main.rs | 6++++++
Msrc/operation_adapter.rs | 11++++++++++-
Msrc/operation_listing.rs | 88+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Msrc/operation_registry.rs | 35++++++++++++++++++++++++++++++++++-
Msrc/runtime/listing.rs | 335+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Msrc/runtime/local_events.rs | 31+++++++++++++++++++++++++++++++
Msrc/runtime_args.rs | 6++++++
Msrc/target_cli.rs | 24++++++++++++++++++++++++
Mtests/target_cli.rs | 244++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
10 files changed, 855 insertions(+), 10 deletions(-)

diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs @@ -2568,6 +2568,91 @@ impl ListingListView { } #[derive(Debug, Clone, Serialize)] +pub struct ListingAppRecordListView { + pub state: String, + pub source: String, + pub count: usize, + pub local_events_db: String, + pub records: Vec<ListingAppRecordSummaryView>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub actions: Vec<String>, +} + +impl ListingAppRecordListView { + pub fn disposition(&self) -> CommandDisposition { + match self.state.as_str() { + "error" => CommandDisposition::InternalError, + _ => CommandDisposition::Success, + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct ListingAppRecordSummaryView { + pub record_id: String, + pub seq: i64, + pub record_kind: String, + pub status: String, + pub source_runtime: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub owner_account_id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub owner_pubkey: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub farm_id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub listing_addr: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub listing_id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option<String>, + pub exportable: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub actions: Vec<String>, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ListingAppRecordExportView { + pub state: String, + pub source: String, + pub record_id: String, + pub dry_run: bool, + pub file: String, + pub valid: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub listing_id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub listing_addr: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub seller_account_id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub seller_pubkey: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub seller_actor_source: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub farm_d_tag: Option<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub issues: Vec<ListingValidationIssueView>, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub actions: Vec<String>, +} + +impl ListingAppRecordExportView { + pub fn disposition(&self) -> CommandDisposition { + match self.state.as_str() { + "missing" => CommandDisposition::NotFound, + "invalid" | "unsupported" => CommandDisposition::ValidationFailed, + "error" => CommandDisposition::InternalError, + _ => CommandDisposition::Success, + } + } +} + +#[derive(Debug, Clone, Serialize)] pub struct ListingSummaryView { pub id: String, pub state: String, diff --git a/src/main.rs b/src/main.rs @@ -229,6 +229,12 @@ fn execute_request( TargetOperationRequest::ListingList(request) => { execute_with(ListingOperationService::new(config), request) } + TargetOperationRequest::ListingAppList(request) => { + execute_with(ListingOperationService::new(config), request) + } + TargetOperationRequest::ListingAppExport(request) => { + execute_with(ListingOperationService::new(config), request) + } TargetOperationRequest::ListingUpdate(request) => { execute_with(ListingOperationService::new(config), request) } diff --git a/src/operation_adapter.rs b/src/operation_adapter.rs @@ -1284,7 +1284,7 @@ fn target_operation_input(command: &crate::target_cli::TargetCommand) -> Operati use crate::target_cli::{ AccountCommand, AccountSelectionCommand, BasketAdjustmentCommand, BasketCommand, BasketItemCommand, BasketQuoteCommand, FarmCommand, FarmFulfillmentCommand, - FarmLocationCommand, FarmProfileCommand, ListingCommand, MarketCommand, + FarmLocationCommand, FarmProfileCommand, ListingAppCommand, ListingCommand, MarketCommand, MarketListingCommand, MarketProductCommand, OrderCommand, OrderEventCommand, OrderFulfillmentCommand, OrderPaymentCommand, OrderReceiptCommand, OrderRevisionCommand, OrderSettlementCommand, OrderStatusCommand, TargetCommand, ValidationCommand, @@ -1378,6 +1378,13 @@ fn target_operation_input(command: &crate::target_cli::TargetCommand) -> Operati insert_string(&mut input, "discount_currency", &args.discount_currency); } ListingCommand::Get(args) => insert_string(&mut input, "key", &args.key), + ListingCommand::App(args) => match &args.command { + ListingAppCommand::Export(args) => { + insert_string(&mut input, "record_id", &args.record_id); + insert_path(&mut input, "output", &args.output); + } + ListingAppCommand::List => {} + }, ListingCommand::Update(args) | ListingCommand::Validate(args) | ListingCommand::Publish(args) @@ -1632,6 +1639,8 @@ target_operation_contracts! { ListingCreate => (ListingCreateRequest, ListingCreateResult, "listing.create"), ListingGet => (ListingGetRequest, ListingGetResult, "listing.get"), ListingList => (ListingListRequest, ListingListResult, "listing.list"), + ListingAppList => (ListingAppListRequest, ListingAppListResult, "listing.app.list"), + ListingAppExport => (ListingAppExportRequest, ListingAppExportResult, "listing.app.export"), ListingUpdate => (ListingUpdateRequest, ListingUpdateResult, "listing.update"), ListingValidate => (ListingValidateRequest, ListingValidateResult, "listing.validate"), ListingRebind => (ListingRebindRequest, ListingRebindResult, "listing.rebind"), diff --git a/src/operation_listing.rs b/src/operation_listing.rs @@ -3,8 +3,9 @@ use std::path::PathBuf; use serde::Serialize; use serde_json::Value; -use crate::domain::runtime::{CommandDisposition, ListingMutationView}; +use crate::domain::runtime::{CommandDisposition, ListingAppRecordExportView, ListingMutationView}; use crate::operation_adapter::{ + ListingAppExportRequest, ListingAppExportResult, ListingAppListRequest, ListingAppListResult, ListingArchiveRequest, ListingArchiveResult, ListingCreateRequest, ListingCreateResult, ListingGetRequest, ListingGetResult, ListingListRequest, ListingListResult, ListingPublishRequest, ListingPublishResult, ListingRebindRequest, ListingRebindResult, @@ -15,7 +16,8 @@ use crate::operation_adapter::{ use crate::runtime::RuntimeError; use crate::runtime::config::RuntimeConfig; use crate::runtime_args::{ - ListingCreateArgs, ListingFileArgs, ListingMutationArgs, ListingRebindArgs, RecordLookupArgs, + ListingAppRecordExportArgs, ListingCreateArgs, ListingFileArgs, ListingMutationArgs, + ListingRebindArgs, RecordLookupArgs, }; pub struct ListingOperationService<'a> { @@ -106,6 +108,44 @@ impl OperationService<ListingListRequest> for ListingOperationService<'_> { } } +impl OperationService<ListingAppListRequest> for ListingOperationService<'_> { + type Result = ListingAppListResult; + + fn execute( + &self, + request: OperationRequest<ListingAppListRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + let view = map_runtime( + request.operation_id(), + crate::runtime::listing::app_record_list(self.config), + )?; + serialized_operation_result::<ListingAppListResult, _>(&view) + } +} + +impl OperationService<ListingAppExportRequest> for ListingOperationService<'_> { + type Result = ListingAppExportResult; + + fn execute( + &self, + request: OperationRequest<ListingAppExportRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + let args = ListingAppRecordExportArgs { + record_id: required_string(&request, "record_id")?, + output: optional_path(&request, "output"), + }; + let mut config = self.config.clone(); + if request.context.dry_run { + config.output.dry_run = true; + } + let view = map_runtime( + request.operation_id(), + crate::runtime::listing::app_record_export(&config, &args), + )?; + listing_app_record_export_result::<ListingAppExportResult>(request.operation_id(), &view) + } +} + impl OperationService<ListingUpdateRequest> for ListingOperationService<'_> { type Result = ListingUpdateResult; @@ -303,6 +343,50 @@ fn listing_relay_unavailable(view: &ListingMutationView) -> bool { || !view.failed_relays.is_empty()) } +fn listing_app_record_export_result<R>( + operation_id: &str, + view: &ListingAppRecordExportView, +) -> Result<OperationResult<R>, OperationAdapterError> +where + R: OperationResultData, +{ + match view.disposition() { + CommandDisposition::Success => serialized_operation_result::<R, _>(view), + CommandDisposition::NotFound => Err(OperationAdapterError::not_found_with_detail( + operation_id, + view.reason.clone().unwrap_or_else(|| { + format!( + "app-authored local record `{}` was not found", + view.record_id + ) + }), + serde_json::to_value(view).unwrap_or(Value::Null), + )), + CommandDisposition::ValidationFailed => { + Err(OperationAdapterError::validation_failed_with_detail( + operation_id, + view.reason.clone().unwrap_or_else(|| { + format!( + "app-authored local record `{}` cannot be exported", + view.record_id + ) + }), + serde_json::to_value(view).unwrap_or(Value::Null), + )) + } + disposition => Err(OperationAdapterError::from_command_disposition( + operation_id, + disposition, + view.reason.clone().unwrap_or_else(|| { + format!( + "app-authored local record export finished with state `{}`", + view.state + ) + }), + )), + } +} + fn map_runtime<T>( operation_id: &str, result: Result<T, RuntimeError>, diff --git a/src/operation_registry.rs b/src/operation_registry.rs @@ -608,6 +608,36 @@ pub const OPERATION_REGISTRY: &[OperationSpec] = &[ false ), operation!( + "listing.app.list", + "radroots listing app list", + "listing", + "listing_app_list", + "ListingAppListRequest", + "ListingAppListResult", + "List app-authored shared local listing records.", + Seller, + false, + None, + Low, + false, + false + ), + operation!( + "listing.app.export", + "radroots listing app export", + "listing", + "listing_app_export", + "ListingAppExportRequest", + "ListingAppExportResult", + "Export an app-authored shared listing record as a CLI draft.", + Seller, + true, + None, + Medium, + false, + true + ), + operation!( "listing.update", "radroots listing update", "listing", @@ -1328,6 +1358,8 @@ mod tests { "listing.create", "listing.get", "listing.list", + "listing.app.list", + "listing.app.export", "listing.update", "listing.validate", "listing.rebind", @@ -1388,6 +1420,7 @@ mod tests { "farm.fulfillment.update", "farm.publish", "listing.create", + "listing.app.export", "listing.update", "listing.rebind", "listing.publish", @@ -1422,7 +1455,7 @@ mod tests { .copied() .collect::<BTreeSet<_>>(); assert_eq!(actual, expected); - assert_eq!(OPERATION_REGISTRY.len(), 74); + assert_eq!(OPERATION_REGISTRY.len(), 76); } #[test] diff --git a/src/runtime/listing.rs b/src/runtime/listing.rs @@ -20,6 +20,7 @@ use radroots_events::trade::RadrootsTradeListingValidationError; use radroots_events_codec::d_tag::is_d_tag_base64url; use radroots_events_codec::listing::encode::to_wire_parts_with_kind; use radroots_events_codec::wire::WireEventParts; +use radroots_local_events::{LocalEventRecord, LocalRecordFamily, SourceRuntime}; use radroots_nostr::prelude::{RadrootsNostrEvent as SignedNostrEvent, radroots_event_from_nostr}; use radroots_replica_db::{ReplicaSql, migrations}; use radroots_replica_sync::{RadrootsReplicaIngestOutcome, radroots_replica_ingest_event}; @@ -27,10 +28,11 @@ use radroots_sql_core::SqliteExecutor; use radroots_trade::listing::publish::validate_listing_for_seller; use radroots_trade::listing::validation::validate_listing_event; use serde::{Deserialize, Serialize}; -use serde_json::json; +use serde_json::{Value, json}; use crate::domain::runtime::{ - FindPriceView, FindQuantityView, FindResultProvenanceView, ListingGetView, ListingListView, + FindPriceView, FindQuantityView, FindResultProvenanceView, ListingAppRecordExportView, + ListingAppRecordListView, ListingAppRecordSummaryView, ListingGetView, ListingListView, ListingMutationEventView, ListingMutationLocalReplicaView, ListingMutationView, ListingNewView, ListingRebindView, ListingSummaryView, ListingValidateView, ListingValidationIssueView, MarketReadinessView, RelayFailureView, @@ -46,26 +48,30 @@ use crate::runtime::direct_relay::{ }; use crate::runtime::farm_config; use crate::runtime::local_events::{ - append_local_work, append_signed_event, mark_signed_event_acknowledged, - mark_signed_event_failed_for_publish_error, + append_local_work, append_signed_event, get_shared_record, list_shared_records, + mark_signed_event_acknowledged, mark_signed_event_failed_for_publish_error, + shared_local_events_db_path, }; use crate::runtime::signer::{ActorWriteBindingError, resolve_actor_write_authority}; use crate::runtime::sync::{ RelayIngestScope, freshness_for_scope_from_executor, market_refresh, missing_freshness, }; use crate::runtime_args::{ - ListingCreateArgs, ListingFileArgs, ListingMutationArgs, ListingRebindArgs, RecordLookupArgs, + ListingAppRecordExportArgs, ListingCreateArgs, ListingFileArgs, ListingMutationArgs, + ListingRebindArgs, RecordLookupArgs, }; const DRAFT_KIND: &str = "listing_draft_v1"; const LISTING_SOURCE: &str = "local draft · local first"; const LISTING_READ_SOURCE: &str = "local replica · local first"; +const LISTING_APP_RECORD_SOURCE: &str = "shared local events · app"; const RELAY_LISTING_WRITE_SOURCE: &str = "direct Nostr relay publish · local key"; const RADROOTSD_LISTING_WRITE_SOURCE: &str = "radrootsd publish transport · deferred"; const LISTING_DRAFTS_DIR: &str = "listings/drafts"; const LISTING_SELLER_ACTOR_SOURCE_FARM_CONFIG: &str = "farm_config"; const LISTING_SELLER_ACTOR_SOURCE_RESOLVED_ACCOUNT: &str = "resolved_account"; const LISTING_SELLER_ACTOR_SOURCE_REBIND: &str = "listing_rebind"; +const APP_RECORD_LIST_LIMIT: u32 = 500; static D_TAG_COUNTER: AtomicU64 = AtomicU64::new(0); @@ -660,6 +666,163 @@ pub fn list(config: &RuntimeConfig) -> Result<ListingListView, RuntimeError> { }) } +pub fn app_record_list(config: &RuntimeConfig) -> Result<ListingAppRecordListView, RuntimeError> { + let database_path = shared_local_events_db_path(config)?; + let records = list_shared_records(config, APP_RECORD_LIST_LIMIT)? + .into_iter() + .filter(|record| { + record.source_runtime == SourceRuntime::App + && record.family == LocalRecordFamily::LocalWork + && matches!( + local_record_kind(record).as_deref(), + Some("farm_config_v1" | DRAFT_KIND) + ) + }) + .map(|record| app_record_summary(&record)) + .collect::<Vec<_>>(); + let state = if records.is_empty() { "empty" } else { "ready" }; + let actions = if records.is_empty() { + vec!["create or save a farm listing in radroots_app".to_owned()] + } else { + Vec::new() + }; + + Ok(ListingAppRecordListView { + state: state.to_owned(), + source: LISTING_APP_RECORD_SOURCE.to_owned(), + count: records.len(), + local_events_db: database_path.display().to_string(), + records, + actions, + }) +} + +pub fn app_record_export( + config: &RuntimeConfig, + args: &ListingAppRecordExportArgs, +) -> Result<ListingAppRecordExportView, RuntimeError> { + let Some(record) = get_shared_record(config, args.record_id.as_str())? else { + return Ok(ListingAppRecordExportView { + state: "missing".to_owned(), + source: LISTING_APP_RECORD_SOURCE.to_owned(), + record_id: args.record_id.clone(), + dry_run: config.output.dry_run, + file: args + .output + .as_ref() + .map(|path| path.display().to_string()) + .unwrap_or_default(), + valid: false, + listing_id: None, + listing_addr: None, + seller_account_id: None, + seller_pubkey: None, + seller_actor_source: None, + farm_d_tag: None, + issues: Vec::new(), + reason: Some(format!( + "app-authored local record `{}` was not found", + args.record_id + )), + actions: vec!["radroots listing app list".to_owned()], + }); + }; + + let draft = match app_listing_draft_from_record(&record) { + Ok(draft) => draft, + Err(reason) => { + return Ok(ListingAppRecordExportView { + state: "unsupported".to_owned(), + source: LISTING_APP_RECORD_SOURCE.to_owned(), + record_id: args.record_id.clone(), + dry_run: config.output.dry_run, + file: args + .output + .as_ref() + .map(|path| path.display().to_string()) + .unwrap_or_default(), + valid: false, + listing_id: None, + listing_addr: record.listing_addr.clone(), + seller_account_id: record.owner_account_id.clone(), + seller_pubkey: record.owner_pubkey.clone(), + seller_actor_source: None, + farm_d_tag: record.farm_id.clone(), + issues: vec![ListingValidationIssueView { + field: "local_work_json".to_owned(), + message: reason.clone(), + line: None, + }], + reason: Some(reason), + actions: vec!["radroots listing app list".to_owned()], + }); + } + }; + let output_path = listing_output_path(config, args.output.as_ref(), &draft.listing.d_tag)?; + validate_listing_output_target(output_path.as_path())?; + let contents = scaffold_contents(&draft)?; + let context = validation_context(config)?; + let issues = app_listing_export_issues(config, &draft, contents.as_str(), &context)?; + let listing_addr_value = app_record_listing_addr(&draft); + + if !issues.is_empty() { + return Ok(ListingAppRecordExportView { + state: "invalid".to_owned(), + source: LISTING_APP_RECORD_SOURCE.to_owned(), + record_id: args.record_id.clone(), + dry_run: config.output.dry_run, + file: output_path.display().to_string(), + valid: false, + listing_id: non_empty(draft.listing.d_tag.clone()), + listing_addr: listing_addr_value, + seller_account_id: non_empty(draft.seller_actor.account_id.clone()), + seller_pubkey: non_empty(draft.seller_actor.pubkey.clone()), + seller_actor_source: non_empty(draft.seller_actor.source.clone()), + farm_d_tag: non_empty(draft.listing.farm_d_tag.clone()), + issues, + reason: Some(format!( + "app-authored local record `{}` does not validate as a CLI listing draft", + args.record_id + )), + actions: vec!["radroots listing app list".to_owned()], + }); + } + + if !config.output.dry_run { + write_listing_draft(output_path.as_path(), &draft, false)?; + } + + Ok(ListingAppRecordExportView { + state: if config.output.dry_run { + "dry_run" + } else { + "exported" + } + .to_owned(), + source: LISTING_APP_RECORD_SOURCE.to_owned(), + record_id: args.record_id.clone(), + dry_run: config.output.dry_run, + file: output_path.display().to_string(), + valid: true, + listing_id: Some(draft.listing.d_tag.clone()), + listing_addr: app_record_listing_addr(&draft), + seller_account_id: Some(draft.seller_actor.account_id.clone()), + seller_pubkey: Some(draft.seller_actor.pubkey.clone()), + seller_actor_source: Some(draft.seller_actor.source.clone()), + farm_d_tag: Some(draft.listing.farm_d_tag.clone()), + issues: Vec::new(), + reason: Some(if config.output.dry_run { + "dry run requested; listing draft was not written".to_owned() + } else { + "app-authored listing record exported as a CLI listing draft".to_owned() + }), + actions: vec![ + format!("radroots listing validate {}", output_path.display()), + format!("radroots listing publish {}", output_path.display()), + ], + }) +} + pub fn rebind( config: &RuntimeConfig, args: &ListingRebindArgs, @@ -957,6 +1120,168 @@ fn summary_for_invalid_file(path: &Path, issue: ListingValidationIssueView) -> L } } +fn app_record_summary(record: &LocalEventRecord) -> ListingAppRecordSummaryView { + let record_kind = local_record_kind(record).unwrap_or_else(|| "unknown".to_owned()); + let parsed_listing = app_listing_draft_from_record(record); + let (listing_id, title, exportable, reason) = match (record_kind.as_str(), parsed_listing) { + (DRAFT_KIND, Ok(draft)) => ( + non_empty(draft.listing.d_tag), + non_empty(draft.product.title), + true, + None, + ), + (DRAFT_KIND, Err(reason)) => (None, None, false, Some(reason)), + ("farm_config_v1", _) => ( + None, + record + .local_work_json + .as_ref() + .and_then(|payload| payload["document"]["farm"]["name"].as_str()) + .map(str::to_owned), + false, + Some("farm records provide defaults; export selects listing records".to_owned()), + ), + _ => ( + None, + None, + false, + Some(format!("unsupported app record kind `{record_kind}`")), + ), + }; + let actions = if exportable { + vec![format!("radroots listing app export {}", record.record_id)] + } else { + Vec::new() + }; + + ListingAppRecordSummaryView { + record_id: record.record_id.clone(), + seq: record.seq, + record_kind, + status: record.status.as_str().to_owned(), + source_runtime: record.source_runtime.as_str().to_owned(), + owner_account_id: record.owner_account_id.clone(), + owner_pubkey: record.owner_pubkey.clone(), + farm_id: record.farm_id.clone(), + listing_addr: record.listing_addr.clone(), + listing_id, + title, + exportable, + reason, + actions, + } +} + +fn app_listing_draft_from_record( + record: &LocalEventRecord, +) -> Result<ListingDraftDocument, String> { + if record.source_runtime != SourceRuntime::App { + return Err(format!( + "record source_runtime `{}` is not app", + record.source_runtime.as_str() + )); + } + if record.family != LocalRecordFamily::LocalWork { + return Err(format!( + "record family `{}` is not local_work", + record.family.as_str() + )); + } + let payload = record + .local_work_json + .as_ref() + .ok_or_else(|| "record has no local_work_json payload".to_owned())?; + let record_kind = local_record_kind(record).unwrap_or_else(|| "unknown".to_owned()); + if record_kind != DRAFT_KIND { + return Err(format!("record kind `{record_kind}` is not {DRAFT_KIND}")); + } + let document = payload + .get("document") + .cloned() + .ok_or_else(|| "record local_work_json.document is missing".to_owned())?; + let mut draft = serde_json::from_value::<ListingDraftDocument>(document) + .map_err(|error| format!("record listing document is invalid: {error}"))?; + if draft.seller_actor.account_id.trim().is_empty() + && let Some(account_id) = record.owner_account_id.as_ref() + { + draft.seller_actor.account_id = account_id.clone(); + } + if draft.seller_actor.pubkey.trim().is_empty() + && let Some(pubkey) = record.owner_pubkey.as_ref() + { + draft.seller_actor.pubkey = pubkey.clone(); + } + if draft.listing.farm_d_tag.trim().is_empty() + && let Some(farm_id) = record.farm_id.as_ref() + { + draft.listing.farm_d_tag = farm_id.clone(); + } + normalize_app_listing_availability(&mut draft)?; + Ok(draft) +} + +fn normalize_app_listing_availability(draft: &mut ListingDraftDocument) -> Result<(), String> { + let kind = draft.availability.kind.trim(); + if kind.is_empty() || kind == "local" { + draft.availability.kind = "status".to_owned(); + } else if !matches!(kind, "status" | "window") { + return Err(format!( + "unsupported app listing availability kind `{kind}`" + )); + } + if draft.availability.kind == "window" { + return Ok(()); + } + + let status = draft.availability.status.trim(); + draft.availability.status = match status { + "" | "active" | "draft" | "published" => "active".to_owned(), + "archived" | "paused" | "sold" => { + return Err(format!( + "app listing status `{status}` is not exportable as a publishable CLI draft" + )); + } + other => return Err(format!("unsupported app listing status `{other}`")), + }; + Ok(()) +} + +fn app_listing_export_issues( + config: &RuntimeConfig, + draft: &ListingDraftDocument, + contents: &str, + context: &ListingValidationContext, +) -> Result<Vec<ListingValidationIssueView>, RuntimeError> { + let canonical = match canonicalize_draft(draft, contents, context) { + Ok(canonical) => canonical, + Err(error) => return Ok(vec![error.into_issue()]), + }; + let mut issues = listing_ready_issues(&canonical, contents); + if let Some(issue) = listing_bound_account_issue(config, &canonical, contents)? { + issues.push(issue); + } + Ok(issues) +} + +fn app_record_listing_addr(draft: &ListingDraftDocument) -> Option<String> { + let seller_pubkey = draft.seller_actor.pubkey.trim(); + let listing_id = draft.listing.d_tag.trim(); + if seller_pubkey.is_empty() || listing_id.is_empty() { + None + } else { + Some(listing_addr(seller_pubkey, listing_id)) + } +} + +fn local_record_kind(record: &LocalEventRecord) -> Option<String> { + record + .local_work_json + .as_ref() + .and_then(|payload| payload.get("record_kind")) + .and_then(Value::as_str) + .map(str::to_owned) +} + pub fn get( config: &RuntimeConfig, args: &RecordLookupArgs, diff --git a/src/runtime/local_events.rs b/src/runtime/local_events.rs @@ -1,4 +1,5 @@ use std::fs; +use std::path::PathBuf; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -160,6 +161,36 @@ pub fn mark_signed_event_failed_for_publish_error( ) } +pub fn shared_local_events_db_path(config: &RuntimeConfig) -> Result<PathBuf, RuntimeError> { + Ok(shared_local_events_root(config)?.join(SHARED_LOCAL_EVENTS_DB_FILE)) +} + +pub fn list_shared_records( + config: &RuntimeConfig, + limit: u32, +) -> Result<Vec<LocalEventRecord>, RuntimeError> { + let database_path = shared_local_events_db_path(config)?; + if !database_path.exists() { + return Ok(Vec::new()); + } + let executor = SqliteExecutor::open(database_path)?; + let store = LocalEventsStore::new(executor); + Ok(store.list_records_after(0, limit)?) +} + +pub fn get_shared_record( + config: &RuntimeConfig, + record_id: &str, +) -> Result<Option<LocalEventRecord>, RuntimeError> { + let database_path = shared_local_events_db_path(config)?; + if !database_path.exists() { + return Ok(None); + } + let executor = SqliteExecutor::open(database_path)?; + let store = LocalEventsStore::new(executor); + Ok(store.get_record(record_id)?) +} + fn update_signed_event_outbox( config: &RuntimeConfig, record_id: &str, diff --git a/src/runtime_args.rs b/src/runtime_args.rs @@ -168,6 +168,12 @@ pub struct ListingFileArgs { } #[derive(Debug, Clone)] +pub struct ListingAppRecordExportArgs { + pub record_id: String, + pub output: Option<PathBuf>, +} + +#[derive(Debug, Clone)] pub struct ListingRebindArgs { pub file: PathBuf, pub selector: String, diff --git a/src/target_cli.rs b/src/target_cli.rs @@ -185,6 +185,10 @@ impl TargetCommand { ListingCommand::Create(_) => "listing.create", ListingCommand::Get(_) => "listing.get", ListingCommand::List => "listing.list", + ListingCommand::App(app) => match &app.command { + ListingAppCommand::List => "listing.app.list", + ListingAppCommand::Export(_) => "listing.app.export", + }, ListingCommand::Update(_) => "listing.update", ListingCommand::Validate(_) => "listing.validate", ListingCommand::Rebind(_) => "listing.rebind", @@ -597,6 +601,7 @@ pub enum ListingCommand { Create(ListingCreateArgs), Get(LookupArgs), List, + App(ListingAppArgs), Update(FileArgs), Validate(FileArgs), Rebind(ListingRebindArgs), @@ -654,6 +659,25 @@ pub struct FileArgs { } #[derive(Debug, Clone, Args)] +pub struct ListingAppArgs { + #[command(subcommand)] + pub command: ListingAppCommand, +} + +#[derive(Debug, Clone, Subcommand)] +pub enum ListingAppCommand { + List, + Export(ListingAppExportArgs), +} + +#[derive(Debug, Clone, Args)] +pub struct ListingAppExportArgs { + pub record_id: Option<String>, + #[arg(long)] + pub output: Option<PathBuf>, +} + +#[derive(Debug, Clone, Args)] pub struct ListingRebindArgs { pub file: Option<PathBuf>, pub selector: Option<String>, diff --git a/tests/target_cli.rs b/tests/target_cli.rs @@ -14,7 +14,8 @@ use radroots_events::trade::{ }; use radroots_events_codec::trade::active_trade_order_request_event_build; use radroots_local_events::{ - LocalRecordFamily, LocalRecordStatus, PublishOutboxStatus, SourceRuntime, + LocalEventRecordInput, LocalEventsStore, LocalRecordFamily, LocalRecordStatus, + PublishOutboxStatus, SourceRuntime, }; use radroots_nostr::prelude::{RadrootsNostrEvent, radroots_nostr_build_event}; use radroots_replica_db::{farm, farm_member_claim, migrations}; @@ -217,6 +218,162 @@ fn sync_push_pending_batch(sandbox: &RadrootsCliSandbox) -> RadrootsReplicaPendi radroots_replica_pending_publish_batch(&executor).expect("sync push pending batch") } +fn seed_app_farm_record( + sandbox: &RadrootsCliSandbox, + account_id: &str, + seller_pubkey: &str, + farm_d_tag: &str, +) { + append_app_local_record( + LocalEventRecordInput { + record_id: format!("app:local_work:farm:{farm_d_tag}:test"), + family: LocalRecordFamily::LocalWork, + status: LocalRecordStatus::LocalSaved, + source_runtime: SourceRuntime::App, + created_at_ms: 1_779_000_001_000, + inserted_at_ms: 1_779_000_001_000, + owner_account_id: Some(account_id.to_owned()), + owner_pubkey: Some(seller_pubkey.to_owned()), + farm_id: Some(farm_d_tag.to_owned()), + listing_addr: None, + local_work_json: Some(json!({ + "record_kind": "farm_config_v1", + "scope": "app", + "document": { + "version": 1, + "selection": { + "scope": "app", + "account": account_id, + "farm_d_tag": farm_d_tag, + }, + "profile": { + "name": "App Farm", + "display_name": "App Farm", + }, + "farm": { + "d_tag": farm_d_tag, + "name": "App Farm", + "location": { + "primary": "farmstand", + }, + }, + "listing_defaults": { + "delivery_method": "pickup", + "location": { + "primary": "farmstand", + }, + }, + }, + })), + event_id: None, + event_kind: None, + event_pubkey: None, + event_created_at: None, + event_tags_json: None, + event_content: None, + event_sig: None, + raw_event_json: None, + outbox_status: PublishOutboxStatus::None, + relay_set_fingerprint: None, + relay_delivery_json: None, + }, + sandbox, + ); +} + +fn seed_app_listing_record( + sandbox: &RadrootsCliSandbox, + account_id: &str, + seller_pubkey: &str, + farm_d_tag: &str, + listing_d_tag: &str, +) -> String { + let record_id = format!("app:local_work:listing:{listing_d_tag}:test"); + append_app_local_record( + LocalEventRecordInput { + record_id: record_id.clone(), + family: LocalRecordFamily::LocalWork, + status: LocalRecordStatus::LocalSaved, + source_runtime: SourceRuntime::App, + created_at_ms: 1_779_000_002_000, + inserted_at_ms: 1_779_000_002_000, + owner_account_id: Some(account_id.to_owned()), + owner_pubkey: Some(seller_pubkey.to_owned()), + farm_id: Some(farm_d_tag.to_owned()), + listing_addr: Some(format!("30402:{seller_pubkey}:{listing_d_tag}")), + local_work_json: Some(json!({ + "record_kind": "listing_draft_v1", + "document": { + "version": 1, + "kind": "listing_draft_v1", + "listing": { + "d_tag": listing_d_tag, + "farm_d_tag": farm_d_tag, + }, + "seller_actor": { + "account_id": account_id, + "pubkey": seller_pubkey, + "source": "farm_config", + }, + "product": { + "key": listing_d_tag, + "title": "App Eggs", + "category": "eggs", + "summary": "Fresh app eggs", + }, + "primary_bin": { + "bin_id": "bin-1", + "quantity_amount": "1", + "quantity_unit": "each", + "price_amount": "7.50", + "price_currency": "USD", + "price_per_amount": "1", + "price_per_unit": "each", + }, + "inventory": { + "available": "12", + }, + "availability": { + "kind": "local", + "status": "draft", + }, + "delivery": { + "method": "pickup", + }, + "location": { + "primary": "farmstand", + }, + }, + })), + event_id: None, + event_kind: None, + event_pubkey: None, + event_created_at: None, + event_tags_json: None, + event_content: None, + event_sig: None, + raw_event_json: None, + outbox_status: PublishOutboxStatus::None, + relay_set_fingerprint: None, + relay_delivery_json: None, + }, + sandbox, + ); + record_id +} + +fn append_app_local_record(input: LocalEventRecordInput, sandbox: &RadrootsCliSandbox) { + let database_path = sandbox.local_events_db_path(); + fs::create_dir_all(database_path.parent().expect("local events parent")) + .expect("local events parent"); + let executor = SqliteExecutor::open(database_path).expect("open local events"); + let store = LocalEventsStore::new(executor); + store.migrate_up().expect("migrate local events"); + store + .append_record(&input) + .expect("append app local event record"); +} + #[test] fn root_help_exposes_only_target_namespaces() { let output = radroots().arg("--help").output().expect("run root help"); @@ -3374,6 +3531,91 @@ fn seller_local_writes_append_shared_local_work_records() { } #[test] +fn listing_app_records_list_and_export_to_valid_cli_draft() { + let sandbox = RadrootsCliSandbox::new(); + let account = sandbox.json_success(&["--format", "json", "account", "create"]); + let account_id = account["result"]["account"]["id"] + .as_str() + .expect("account id"); + let signer = sandbox.json_success(&["--format", "json", "signer", "status", "get"]); + let seller_pubkey = signer["result"]["local"]["public_identity"]["public_key_hex"] + .as_str() + .expect("seller pubkey"); + let farm_d_tag = "AAAAAAAAAAAAAAAAAAAAAw"; + let listing_d_tag = "AAAAAAAAAAAAAAAAAAAAAQ"; + seed_app_farm_record(&sandbox, account_id, seller_pubkey, farm_d_tag); + let listing_record_id = seed_app_listing_record( + &sandbox, + account_id, + seller_pubkey, + farm_d_tag, + listing_d_tag, + ); + + let list = sandbox.json_success(&["--format", "json", "listing", "app", "list"]); + assert_eq!(list["operation_id"], "listing.app.list"); + assert_eq!(list["result"]["state"], "ready"); + assert_eq!(list["result"]["count"], 2); + let listing_row = list["result"]["records"] + .as_array() + .expect("records") + .iter() + .find(|record| record["record_id"] == listing_record_id) + .expect("listing row"); + assert_eq!(listing_row["record_kind"], "listing_draft_v1"); + assert_eq!(listing_row["source_runtime"], "app"); + assert_eq!(listing_row["exportable"], true); + assert_eq!(listing_row["listing_id"], listing_d_tag); + assert_eq!(listing_row["title"], "App Eggs"); + + let export_path = sandbox.root().join("app-eggs.toml"); + let export_path_arg = export_path.to_string_lossy(); + let dry_run = sandbox.json_success(&[ + "--format", + "json", + "--dry-run", + "listing", + "app", + "export", + listing_record_id.as_str(), + "--output", + export_path_arg.as_ref(), + ]); + assert_eq!(dry_run["operation_id"], "listing.app.export"); + assert_eq!(dry_run["result"]["state"], "dry_run"); + assert_eq!(dry_run["result"]["valid"], true); + assert!(!export_path.exists()); + + let export = sandbox.json_success(&[ + "--format", + "json", + "listing", + "app", + "export", + listing_record_id.as_str(), + "--output", + export_path_arg.as_ref(), + ]); + assert_eq!(export["operation_id"], "listing.app.export"); + assert_eq!(export["result"]["state"], "exported"); + assert_eq!(export["result"]["listing_id"], listing_d_tag); + assert_eq!(export["result"]["seller_account_id"], account_id); + assert!(export_path.exists()); + + let validate = sandbox.json_success(&[ + "--format", + "json", + "listing", + "validate", + export_path_arg.as_ref(), + ]); + assert_eq!(validate["operation_id"], "listing.validate"); + assert_eq!(validate["result"]["valid"], true); + assert_eq!(validate["result"]["listing_id"], listing_d_tag); + assert_eq!(validate["result"]["seller_account_id"], account_id); +} + +#[test] fn farm_publish_writes_acknowledged_signed_outbox_records() { let sandbox = RadrootsCliSandbox::new(); sandbox.json_success(&["--format", "json", "account", "create"]);