cli

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

commit d49ba4f3d7bf43f4a33d57d6393b520d6f410e2a
parent f2b8a487403e2a020e205400fb637d449a7ea1fe
Author: triesap <tyson@radroots.org>
Date:   Mon, 27 Apr 2026 09:11:59 +0000

cli: preflight local mutation dry-runs

- validate account import remove and selection dry-runs without mutation
- preflight store backup dry-runs against initialized local state
- keep store export dry-run explicitly unsupported
- add process coverage for account and store dry-run contracts

Diffstat:
Msrc/operation_adapter.rs | 1+
Msrc/operation_core.rs | 100+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Msrc/runtime/accounts.rs | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Msrc/runtime/local.rs | 48++++++++++++++++++++++++++++++++++++++----------
Mtests/signer_runtime_modes.rs | 134+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/target_cli.rs | 39+++++++++++++++++++++++++++++++++++++++
6 files changed, 364 insertions(+), 38 deletions(-)

diff --git a/src/operation_adapter.rs b/src/operation_adapter.rs @@ -733,6 +733,7 @@ fn looks_like_validation_failure(value: &str) -> bool { "must not", "must be", "validation", + "failed to import account", ], ) } diff --git a/src/operation_core.rs b/src/operation_core.rs @@ -3,6 +3,7 @@ use std::path::PathBuf; use serde::Serialize; use serde_json::{Value, json}; +use crate::domain::runtime::{CommandDisposition, LocalBackupView}; use crate::operation_adapter::{ AccountCreateRequest, AccountCreateResult, AccountGetRequest, AccountGetResult, AccountImportRequest, AccountImportResult, AccountListRequest, AccountListResult, @@ -19,8 +20,9 @@ use crate::operation_adapter::{ use crate::runtime::RuntimeError; use crate::runtime::accounts::{ account_resolution_view, account_summary_view, clear_default_account, - create_or_migrate_default_account, import_public_identity, remove_account, - resolve_account_resolution, select_account, snapshot, unresolved_account_reason, + create_or_migrate_default_account, import_public_identity, preview_account_removal, + preview_public_identity_import, remove_account, resolve_account_resolution, + resolve_account_selector, select_account, snapshot, unresolved_account_reason, }; use crate::runtime::config::RuntimeConfig; use crate::runtime::logging::LoggingState; @@ -220,10 +222,15 @@ impl OperationService<AccountImportRequest> for CoreOperationService<'_> { let path = required_path(&request, "path")?; let make_default = bool_input(&request, "default").unwrap_or(false); if request.context.dry_run { + let account = map_expected_runtime( + request.operation_id(), + preview_public_identity_import(self.config, path.as_path(), make_default), + )?; return json_operation_result::<AccountImportResult>(json!({ "state": "dry_run", "path": path.display().to_string(), "default": make_default, + "account": account_summary_view(&account), })); } if request.context.approval_token.is_none() { @@ -232,11 +239,10 @@ impl OperationService<AccountImportRequest> for CoreOperationService<'_> { )); } - let account = map_runtime(import_public_identity( - self.config, - path.as_path(), - make_default, - ))?; + let account = map_expected_runtime( + request.operation_id(), + import_public_identity(self.config, path.as_path(), make_default), + )?; json_operation_result::<AccountImportResult>(json!({ "state": "imported", "account": account_summary_view(&account), @@ -304,9 +310,15 @@ impl OperationService<AccountRemoveRequest> for CoreOperationService<'_> { ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { let selector = required_string(&request, "selector")?; if request.context.dry_run { + let preview = + preview_account_removal(self.config, selector.as_str()).map_err(|error| { + OperationAdapterError::unconfigured(request.operation_id(), error.to_string()) + })?; return json_operation_result::<AccountRemoveResult>(json!({ "state": "dry_run", - "selector": selector, + "removed_account": account_summary_view(&preview.account), + "default_would_clear": preview.default_would_clear, + "remaining_account_count": preview.remaining_account_count, })); } if request.context.approval_token.is_none() { @@ -350,9 +362,13 @@ impl OperationService<AccountSelectionUpdateRequest> for CoreOperationService<'_ ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { let selector = required_string(&request, "selector")?; if request.context.dry_run { + let account = + resolve_account_selector(self.config, selector.as_str()).map_err(|error| { + OperationAdapterError::unconfigured(request.operation_id(), error.to_string()) + })?; return json_operation_result::<AccountSelectionUpdateResult>(json!({ "state": "dry_run", - "selector": selector, + "account": account_summary_view(&account), })); } @@ -439,11 +455,10 @@ impl OperationService<StoreExportRequest> for CoreOperationService<'_> { } }; if request.context.dry_run { - return json_operation_result::<StoreExportResult>(json!({ - "state": "dry_run", - "format": format.as_str(), - "file": output.display().to_string(), - })); + return Err(invalid_input( + request.operation_id(), + "`radroots store export` does not support --dry-run".to_owned(), + )); } let view = map_runtime(crate::runtime::local::export( @@ -465,14 +480,18 @@ impl OperationService<StoreBackupCreateRequest> for CoreOperationService<'_> { let output = optional_path(&request, "output") .unwrap_or_else(|| self.config.local.backups_dir.join("store-backup.json")); if request.context.dry_run { - return json_operation_result::<StoreBackupCreateResult>(json!({ - "state": "dry_run", - "file": output.display().to_string(), - })); + let view = map_expected_runtime( + request.operation_id(), + crate::runtime::local::backup_preflight(self.config, output.as_path()), + )?; + return local_backup_result(request.operation_id(), &view); } - let view = map_runtime(crate::runtime::local::backup(self.config, output.as_path()))?; - serialized_operation_result::<StoreBackupCreateResult, _>(&view) + let view = map_expected_runtime( + request.operation_id(), + crate::runtime::local::backup(self.config, output.as_path()), + )?; + local_backup_result(request.operation_id(), &view) } } @@ -495,6 +514,47 @@ fn map_runtime<T>(result: Result<T, RuntimeError>) -> Result<T, OperationAdapter result.map_err(|error| OperationAdapterError::Runtime(error.to_string())) } +fn map_expected_runtime<T>( + operation_id: &str, + result: Result<T, RuntimeError>, +) -> Result<T, OperationAdapterError> { + result.map_err(|error| OperationAdapterError::runtime_failure(operation_id, error)) +} + +fn local_backup_result( + operation_id: &str, + view: &LocalBackupView, +) -> Result<OperationResult<StoreBackupCreateResult>, OperationAdapterError> { + match view.disposition() { + CommandDisposition::Success => { + serialized_operation_result::<StoreBackupCreateResult, _>(view) + } + CommandDisposition::Unconfigured => Err(OperationAdapterError::unconfigured( + operation_id, + view.reason + .clone() + .unwrap_or_else(|| "store backup is unconfigured".to_owned()), + )), + CommandDisposition::ExternalUnavailable => Err(OperationAdapterError::unavailable( + operation_id, + view.reason + .clone() + .unwrap_or_else(|| "store backup is unavailable".to_owned()), + )), + CommandDisposition::Unsupported => Err(invalid_input( + operation_id, + view.reason + .clone() + .unwrap_or_else(|| "store backup is unsupported".to_owned()), + )), + CommandDisposition::InternalError => Err(OperationAdapterError::Runtime( + view.reason + .clone() + .unwrap_or_else(|| "store backup failed".to_owned()), + )), + } +} + fn selected_config(config: &RuntimeConfig, selector: String) -> RuntimeConfig { let mut config = config.clone(); config.account.selector = Some(selector); diff --git a/src/runtime/accounts.rs b/src/runtime/accounts.rs @@ -1,6 +1,8 @@ use std::path::Path; -use radroots_identity::{IdentityError, RadrootsIdentity, load_identity_profile}; +use radroots_identity::{ + IdentityError, RadrootsIdentity, RadrootsIdentityPublic, load_identity_profile, +}; use radroots_nostr_accounts::prelude::{ RadrootsNostrAccountRecord, RadrootsNostrAccountStatus, RadrootsNostrAccountsError, RadrootsNostrAccountsManager, @@ -67,6 +69,13 @@ pub struct AccountRemoveResult { pub remaining_account_count: usize, } +#[derive(Debug, Clone)] +pub struct AccountRemovePreview { + pub account: AccountRecordView, + pub default_would_clear: bool, + pub remaining_account_count: usize, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AccountResolutionSource { InvocationOverride, @@ -130,13 +139,7 @@ pub fn import_public_identity( make_default: bool, ) -> Result<AccountRecordView, RuntimeError> { let manager = account_manager(config)?; - let public_identity = load_identity_profile(path).map_err(|error| { - RuntimeError::Config(format!( - "failed to import account from {}: {}", - path.display(), - format_identity_error(error) - )) - })?; + let public_identity = load_public_identity_for_import(path)?; let imported_account_id = manager.upsert_public_identity(public_identity, None, make_default)?; let snapshot = snapshot_from_manager(&manager)?; @@ -147,6 +150,34 @@ pub fn import_public_identity( ) } +pub fn preview_public_identity_import( + config: &RuntimeConfig, + path: &Path, + make_default: bool, +) -> Result<AccountRecordView, RuntimeError> { + let public_identity = load_public_identity_for_import(path)?; + let manager = account_manager(config)?; + let snapshot = snapshot_from_manager(&manager)?; + if let Some(existing) = snapshot + .accounts + .iter() + .find(|account| account.record.account_id == public_identity.id) + .cloned() + { + let mut account = existing; + if make_default { + account.is_default = true; + } + return Ok(account); + } + + Ok(AccountRecordView { + record: RadrootsNostrAccountRecord::new(public_identity, None, 0), + is_default: make_default, + signer: "watch_only", + }) +} + pub fn snapshot(config: &RuntimeConfig) -> Result<AccountSnapshot, RuntimeError> { let manager = account_manager(config)?; snapshot_from_manager(&manager) @@ -209,6 +240,15 @@ pub fn select_account( }) } +pub fn resolve_account_selector( + config: &RuntimeConfig, + selector: &str, +) -> Result<AccountRecordView, RuntimeError> { + let manager = account_manager(config)?; + let snapshot = snapshot_from_manager(&manager)?; + resolve_selector_account(&manager, &snapshot, selector) +} + pub fn clear_default_account( config: &RuntimeConfig, ) -> Result<AccountClearDefaultResult, RuntimeError> { @@ -244,6 +284,20 @@ pub fn remove_account( }) } +pub fn preview_account_removal( + config: &RuntimeConfig, + selector: &str, +) -> Result<AccountRemovePreview, RuntimeError> { + let manager = account_manager(config)?; + let snapshot = snapshot_from_manager(&manager)?; + let account = resolve_selector_account(&manager, &snapshot, selector)?; + Ok(AccountRemovePreview { + default_would_clear: account.is_default, + remaining_account_count: snapshot.accounts.len().saturating_sub(1), + account, + }) +} + pub fn resolved_account_signing_status( config: &RuntimeConfig, ) -> Result<RadrootsNostrAccountStatus, RuntimeError> { @@ -453,6 +507,16 @@ fn format_identity_error(error: IdentityError) -> String { } } +fn load_public_identity_for_import(path: &Path) -> Result<RadrootsIdentityPublic, RuntimeError> { + load_identity_profile(path).map_err(|error| { + RuntimeError::Config(format!( + "failed to import account from {}: {}", + path.display(), + format_identity_error(error) + )) + }) +} + fn account_manager(config: &RuntimeConfig) -> Result<RadrootsNostrAccountsManager, RuntimeError> { let (manager, _) = RadrootsNostrAccountsManager::new_local_file_backed( config.account.store_path.as_path(), diff --git a/src/runtime/local.rs b/src/runtime/local.rs @@ -92,16 +92,7 @@ pub fn status(config: &RuntimeConfig) -> Result<LocalStatusView, RuntimeError> { pub fn backup(config: &RuntimeConfig, output: &Path) -> Result<LocalBackupView, RuntimeError> { if !config.local.replica_db_path.exists() { - return Ok(LocalBackupView { - state: "unconfigured".to_owned(), - source: LOCAL_SOURCE.to_owned(), - file: output.display().to_string(), - size_bytes: 0, - backup_format_version: String::new(), - replica_db_version: String::new(), - reason: Some("local replica database is not initialized".to_owned()), - actions: vec!["radroots store init".to_owned()], - }); + return Ok(missing_backup_view(output)); } ensure_safe_output_path(config, output)?; @@ -125,6 +116,30 @@ pub fn backup(config: &RuntimeConfig, output: &Path) -> Result<LocalBackupView, }) } +pub fn backup_preflight( + config: &RuntimeConfig, + output: &Path, +) -> Result<LocalBackupView, RuntimeError> { + if !config.local.replica_db_path.exists() { + return Ok(missing_backup_view(output)); + } + + ensure_safe_output_path(config, output)?; + let executor = SqliteExecutor::open(&config.local.replica_db_path)?; + let manifest = export_manifest(&executor)?; + + Ok(LocalBackupView { + state: "dry_run".to_owned(), + source: LOCAL_SOURCE.to_owned(), + file: output.display().to_string(), + size_bytes: 0, + backup_format_version: manifest.backup_format_version, + replica_db_version: manifest.replica_db_version, + reason: Some("dry run requested; backup file was not written".to_owned()), + actions: vec!["radroots store backup create".to_owned()], + }) +} + pub fn export( config: &RuntimeConfig, format: LocalExportFormatArg, @@ -224,6 +239,19 @@ fn ensure_local_roots(config: &RuntimeConfig) -> Result<(), RuntimeError> { Ok(()) } +fn missing_backup_view(output: &Path) -> LocalBackupView { + LocalBackupView { + state: "unconfigured".to_owned(), + source: LOCAL_SOURCE.to_owned(), + file: output.display().to_string(), + size_bytes: 0, + backup_format_version: String::new(), + replica_db_version: String::new(), + reason: Some("local replica database is not initialized".to_owned()), + actions: vec!["radroots store init".to_owned()], + } +} + fn create_parent_dir(path: &Path) -> Result<(), RuntimeError> { if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; diff --git a/tests/signer_runtime_modes.rs b/tests/signer_runtime_modes.rs @@ -143,6 +143,140 @@ fn local_account_selection_and_invocation_override_resolve_signer_actor() { } #[test] +fn account_import_dry_run_validates_profile_without_mutating_store() { + let sandbox = RadrootsCliSandbox::new(); + let public_identity = identity_public(21); + let public_identity_file = + write_public_identity_profile(&sandbox, "dry-run-import", &public_identity); + + let value = sandbox.json_success(&[ + "--format", + "json", + "--dry-run", + "account", + "import", + "--default", + public_identity_file.to_string_lossy().as_ref(), + ]); + + assert_eq!(value["operation_id"], "account.import"); + assert_eq!(value["dry_run"], true); + assert_eq!(value["result"]["state"], "dry_run"); + assert_eq!( + value["result"]["account"]["id"], + public_identity.id.as_str() + ); + assert_eq!(value["result"]["account"]["signer"], "watch_only"); + assert_eq!(value["result"]["account"]["is_default"], true); + + let list = sandbox.json_success(&["--format", "json", "account", "list"]); + assert_eq!(list["result"]["count"], 0); +} + +#[test] +fn account_import_dry_run_validates_missing_profile_file() { + let sandbox = RadrootsCliSandbox::new(); + let missing = sandbox.root().join("missing-account.json"); + + let (output, value) = sandbox.json_output(&[ + "--format", + "json", + "--dry-run", + "account", + "import", + missing.to_string_lossy().as_ref(), + ]); + + assert!(!output.status.success()); + assert_eq!(value["operation_id"], "account.import"); + assert_eq!(value["errors"][0]["code"], "not_found"); + assert_eq!(value["errors"][0]["exit_code"], 4); +} + +#[test] +fn account_remove_dry_run_validates_selector_without_mutating_store() { + let sandbox = RadrootsCliSandbox::new(); + let created = sandbox.json_success(&["--format", "json", "account", "create"]); + let account_id = created["result"]["account"]["id"] + .as_str() + .expect("account id"); + + let value = sandbox.json_success(&[ + "--format", + "json", + "--dry-run", + "account", + "remove", + account_id, + ]); + + assert_eq!(value["operation_id"], "account.remove"); + assert_eq!(value["result"]["state"], "dry_run"); + assert_eq!(value["result"]["removed_account"]["id"], account_id); + assert_eq!(value["result"]["default_would_clear"], true); + assert_eq!(value["result"]["remaining_account_count"], 0); + + let get = sandbox.json_success(&["--format", "json", "account", "get", account_id]); + assert_eq!(get["result"]["state"], "ready"); + assert_eq!( + get["result"]["account_resolution"]["resolved_account"]["id"], + account_id + ); +} + +#[test] +fn account_selection_update_dry_run_validates_selector_without_mutating_selection() { + let sandbox = RadrootsCliSandbox::new(); + let first = sandbox.json_success(&["--format", "json", "account", "create"]); + let second = sandbox.json_success(&["--format", "json", "account", "create"]); + let first_account_id = first["result"]["account"]["id"] + .as_str() + .expect("first account id"); + let second_account_id = second["result"]["account"]["id"] + .as_str() + .expect("second account id"); + + let value = sandbox.json_success(&[ + "--format", + "json", + "--dry-run", + "account", + "selection", + "update", + second_account_id, + ]); + + assert_eq!(value["operation_id"], "account.selection.update"); + assert_eq!(value["result"]["state"], "dry_run"); + assert_eq!(value["result"]["account"]["id"], second_account_id); + + let selected = sandbox.json_success(&["--format", "json", "account", "selection", "get"]); + assert_eq!( + selected["result"]["account_resolution"]["resolved_account"]["id"], + first_account_id + ); +} + +#[test] +fn account_selection_update_dry_run_rejects_missing_selector() { + let sandbox = RadrootsCliSandbox::new(); + let (output, value) = sandbox.json_output(&[ + "--format", + "json", + "--dry-run", + "account", + "selection", + "update", + "missing-account", + ]); + + assert!(!output.status.success()); + assert_eq!(value["operation_id"], "account.selection.update"); + assert_eq!(value["errors"][0]["code"], "account_unresolved"); + assert_eq!(value["errors"][0]["exit_code"], 5); +} + +#[test] fn unresolved_account_override_returns_account_failure() { let sandbox = RadrootsCliSandbox::new(); let (output, value) = sandbox.json_output(&[ diff --git a/tests/target_cli.rs b/tests/target_cli.rs @@ -1,6 +1,7 @@ mod support; use std::fs; +use std::path::Path; use serde_json::Value; @@ -408,6 +409,44 @@ fn online_allows_local_diagnostics() { } #[test] +fn store_export_dry_run_is_structured_unsupported() { + let sandbox = RadrootsCliSandbox::new(); + let (output, value) = + sandbox.json_output(&["--format", "json", "--dry-run", "store", "export"]); + + assert!(!output.status.success()); + assert_eq!(output.status.code(), Some(2)); + assert_eq!(value["operation_id"], "store.export"); + assert_eq!(value["errors"][0]["code"], "invalid_input"); + assert_eq!(value["errors"][0]["exit_code"], 2); +} + +#[test] +fn store_backup_dry_run_preflights_initialized_store_without_writing_file() { + let sandbox = RadrootsCliSandbox::new(); + let (missing_output, missing_value) = + sandbox.json_output(&["--format", "json", "--dry-run", "store", "backup", "create"]); + + assert!(!missing_output.status.success()); + assert_eq!(missing_value["operation_id"], "store.backup.create"); + assert_eq!(missing_value["errors"][0]["code"], "operation_unavailable"); + assert_eq!(missing_value["errors"][0]["exit_code"], 3); + + let init = sandbox.json_success(&["--format", "json", "store", "init"]); + assert_eq!(init["operation_id"], "store.init"); + + let backup = + sandbox.json_success(&["--format", "json", "--dry-run", "store", "backup", "create"]); + let file = backup["result"]["file"].as_str().expect("backup file"); + + assert_eq!(backup["operation_id"], "store.backup.create"); + assert_eq!(backup["dry_run"], true); + assert_eq!(backup["result"]["state"], "dry_run"); + assert_eq!(backup["result"]["size_bytes"], 0); + assert!(!Path::new(file).exists()); +} + +#[test] fn required_approval_missing_token_returns_structured_error() { let output = radroots() .args(["--format", "json", "order", "submit"])