cli

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

commit fa544de1a2a50aa0d6084f6eb42c71d2e1475891
parent f59fbea5a1faa094bf0ed646a1883890de18f3fa
Author: triesap <tyson@radroots.org>
Date:   Mon, 20 Apr 2026 18:58:11 +0000

cli: expand account lifecycle commands

- add account import, remove, and clear-default parser and dispatch support
- reuse the shared account manager for watch-only import and default-clearing flows
- render truthful default-account and watch-only output across help and account views
- update mounted cli docs and tests for the expanded account lifecycle surface

Diffstat:
Msrc/cli.rs | 87++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Msrc/commands/identity.rs | 100++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Msrc/commands/mod.rs | 9+++++++++
Msrc/domain/runtime.rs | 37+++++++++++++++++++++++++++++++++++++
Msrc/render/mod.rs | 144++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Msrc/runtime/accounts.rs | 145+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Mtests/help.rs | 4++++
Mtests/identity_commands.rs | 164+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
8 files changed, 647 insertions(+), 43 deletions(-)

diff --git a/src/cli.rs b/src/cli.rs @@ -40,7 +40,7 @@ Buy from the market order Create and manage order requests Accounts and settings - account Create and select local accounts + account Create, import, and manage local accounts config Show effective configuration Advanced and troubleshooting @@ -102,9 +102,14 @@ When no actor is resolved, it points to explicit account commands instead of mut const ACCOUNT_HELP: &str = "\ Examples: radroots account create + radroots account import ./identity.json radroots account view radroots account list radroots account select market-main + radroots account clear-default + radroots account remove market-main + +Select stores the default account. Clear-default removes the stored default without deleting accounts. Compatibility aliases: new, whoami, ls, use. "; @@ -388,7 +393,7 @@ fn matches_local_output_context(command_tokens: &[String]) -> bool { #[derive(Debug, Clone, Subcommand)] pub enum Command { - #[command(about = "Create and select local accounts")] + #[command(about = "Create, import, and manage local accounts")] Account(AccountArgs), #[command(about = "Show effective configuration")] Config(ConfigArgs), @@ -434,9 +439,12 @@ impl Command { match self { Self::Account(account) => match account.command { AccountCommand::New => "account create", + AccountCommand::Import(_) => "account import", AccountCommand::Whoami => "account view", AccountCommand::Ls => "account list", AccountCommand::Use(_) => "account select", + AccountCommand::ClearDefault => "account clear-default", + AccountCommand::Remove(_) => "account remove", }, Self::Config(config) => match config.command { ConfigCommand::Show => "config show", @@ -569,7 +577,11 @@ impl Command { !matches!( self, Self::Account(AccountArgs { - command: AccountCommand::New | AccountCommand::Use(_), + command: AccountCommand::New + | AccountCommand::Import(_) + | AccountCommand::Use(_) + | AccountCommand::ClearDefault + | AccountCommand::Remove(_), }) | Self::Farm(FarmArgs { command: FarmCommand::Init(_) | FarmCommand::Set(_) | FarmCommand::Setup(_), }) | Self::Local(LocalArgs { @@ -627,6 +639,8 @@ pub enum AccountCommand { about = "Create a local account" )] New, + #[command(name = "import", about = "Import a watch-only local account")] + Import(AccountImportArgs), #[command( name = "view", visible_alias = "whoami", @@ -641,6 +655,17 @@ pub enum AccountCommand { about = "Select a local account" )] Use(AccountUseArgs), + #[command(name = "clear-default", about = "Clear the stored default account")] + ClearDefault, + #[command(name = "remove", about = "Remove a local account")] + Remove(AccountRemoveArgs), +} + +#[derive(Debug, Clone, Args)] +pub struct AccountImportArgs { + pub path: PathBuf, + #[arg(long, action = ArgAction::SetTrue)] + pub default: bool, } #[derive(Debug, Clone, Args)] @@ -649,6 +674,11 @@ pub struct AccountUseArgs { } #[derive(Debug, Clone, Args)] +pub struct AccountRemoveArgs { + pub selector: String, +} + +#[derive(Debug, Clone, Args)] pub struct MycArgs { #[command(subcommand)] pub command: MycCommand, @@ -1195,6 +1225,8 @@ pub struct SellRestockArgs { #[cfg(test)] mod tests { + use std::path::PathBuf; + use super::{ AccountCommand, CliArgs, Command, ConfigCommand, FarmCommand, FarmFieldArg, FarmScopeArg, JobCommand, JobWatchArgs, ListingCommand, LocalCommand, LocalExportFormatArg, @@ -1532,6 +1564,19 @@ mod tests { _ => panic!("unexpected command variant"), } + let import = + CliArgs::parse_from(["radroots", "account", "import", "./identity.json", "--default"]); + match import.command { + Command::Account(account) => match account.command { + AccountCommand::Import(args) => { + assert_eq!(args.path, PathBuf::from("./identity.json")); + assert!(args.default); + } + _ => panic!("unexpected account subcommand"), + }, + _ => panic!("unexpected command variant"), + } + let use_account = CliArgs::parse_from(["radroots", "account", "use", "market-main"]); match use_account.command { Command::Account(account) => match account.command { @@ -1540,6 +1585,24 @@ mod tests { }, _ => panic!("unexpected command variant"), } + + let clear_default = CliArgs::parse_from(["radroots", "account", "clear-default"]); + match clear_default.command { + Command::Account(account) => match account.command { + AccountCommand::ClearDefault => {} + _ => panic!("unexpected account subcommand"), + }, + _ => panic!("unexpected command variant"), + } + + let remove = CliArgs::parse_from(["radroots", "account", "remove", "market-main"]); + match remove.command { + Command::Account(account) => match account.command { + AccountCommand::Remove(args) => assert_eq!(args.selector, "market-main"), + _ => panic!("unexpected account subcommand"), + }, + _ => panic!("unexpected command variant"), + } } #[test] @@ -2172,6 +2235,24 @@ mod tests { assert_eq!(account_create.command.display_name(), "account create"); assert!(!account_create.command.supports_dry_run()); + let account_import = + CliArgs::parse_from(["radroots", "account", "import", "./identity.json"]); + assert_eq!(account_import.command.display_name(), "account import"); + assert!(!account_import.command.supports_dry_run()); + + let account_clear_default = + CliArgs::parse_from(["radroots", "account", "clear-default"]); + assert_eq!( + account_clear_default.command.display_name(), + "account clear-default" + ); + assert!(!account_clear_default.command.supports_dry_run()); + + let account_remove = + CliArgs::parse_from(["radroots", "account", "remove", "market-main"]); + assert_eq!(account_remove.command.display_name(), "account remove"); + assert!(!account_remove.command.supports_dry_run()); + let farm_init = CliArgs::parse_from(["radroots", "farm", "init"]); assert_eq!(farm_init.command.display_name(), "farm init"); assert!(!farm_init.command.supports_dry_run()); diff --git a/src/commands/identity.rs b/src/commands/identity.rs @@ -1,13 +1,16 @@ use crate::domain::runtime::{ - AccountListView, AccountNewView, AccountSummaryView, AccountUseView, AccountWhoamiView, + AccountClearDefaultView, AccountImportView, AccountListView, AccountNewView, + AccountRemoveView, AccountSummaryView, AccountUseView, AccountWhoamiView, CommandDisposition, CommandOutput, CommandView, IdentityPublicView, }; use crate::runtime::RuntimeError; use crate::runtime::accounts::{ AccountCreateMode, AccountRecordView, SHARED_ACCOUNT_STORE_SOURCE, account_resolution_view, - account_summary_view, create_or_migrate_default_account, resolve_account_resolution, + account_summary_view, clear_default_account, create_or_migrate_default_account, + import_public_identity, remove_account as remove_stored_account, resolve_account_resolution, select_account, snapshot, unresolved_account_reason, }; +use crate::cli::AccountImportArgs; use crate::runtime::config::RuntimeConfig; pub fn init(config: &RuntimeConfig) -> Result<AccountNewView, RuntimeError> { @@ -35,6 +38,7 @@ pub fn init(config: &RuntimeConfig) -> Result<AccountNewView, RuntimeError> { pub fn show(config: &RuntimeConfig) -> Result<CommandOutput, RuntimeError> { let resolution = resolve_account_resolution(config)?; + let snapshot = snapshot(config)?; let view = match resolution.resolved_account.as_ref() { Some(account) => AccountWhoamiView { state: "ready".to_owned(), @@ -44,6 +48,7 @@ pub fn show(config: &RuntimeConfig) -> Result<CommandOutput, RuntimeError> { public_identity: Some(IdentityPublicView::from_public_identity( &account.record.public_identity, )), + actions: Vec::new(), }, None => AccountWhoamiView { state: "unconfigured".to_owned(), @@ -51,6 +56,7 @@ pub fn show(config: &RuntimeConfig) -> Result<CommandOutput, RuntimeError> { reason: Some(unresolved_account_reason(config)?), account_resolution: account_resolution_view(&resolution), public_identity: None, + actions: unresolved_account_actions(snapshot.accounts.is_empty()), }, }; @@ -71,6 +77,31 @@ pub fn show(config: &RuntimeConfig) -> Result<CommandOutput, RuntimeError> { }) } +pub fn import( + config: &RuntimeConfig, + args: &AccountImportArgs, +) -> Result<AccountImportView, RuntimeError> { + let account = import_public_identity(config, args.path.as_path(), args.default)?; + let account_view = account_summary(&account); + Ok(AccountImportView { + state: "imported".to_owned(), + source: "shared account store · watch-only import".to_owned(), + public_identity: IdentityPublicView::from_public_identity(&account.record.public_identity), + actions: if account.is_default { + vec![ + "radroots account view".to_owned(), + "radroots account list".to_owned(), + ] + } else { + vec![ + "radroots account list".to_owned(), + "radroots account select <selector>".to_owned(), + ] + }, + account: account_view, + }) +} + pub fn list(config: &RuntimeConfig) -> Result<CommandOutput, RuntimeError> { let snapshot = snapshot(config)?; let accounts = snapshot @@ -79,7 +110,10 @@ pub fn list(config: &RuntimeConfig) -> Result<CommandOutput, RuntimeError> { .map(account_summary) .collect::<Vec<_>>(); let actions = if accounts.is_empty() { - vec!["radroots account new".to_owned()] + vec![ + "radroots account create".to_owned(), + "radroots account import <path>".to_owned(), + ] } else { Vec::new() }; @@ -103,6 +137,66 @@ pub fn use_account(config: &RuntimeConfig, selector: &str) -> Result<AccountUseV }) } +pub fn clear_default(config: &RuntimeConfig) -> Result<AccountClearDefaultView, RuntimeError> { + let result = clear_default_account(config)?; + let cleared_account = result.cleared_account.as_ref().map(account_summary); + Ok(AccountClearDefaultView { + state: if cleared_account.is_some() { + "cleared".to_owned() + } else { + "already_clear".to_owned() + }, + source: SHARED_ACCOUNT_STORE_SOURCE.to_owned(), + actions: follow_up_account_actions(result.remaining_account_count), + cleared_account, + remaining_account_count: result.remaining_account_count, + }) +} + +pub fn remove(config: &RuntimeConfig, selector: &str) -> Result<AccountRemoveView, RuntimeError> { + let result = remove_stored_account(config, selector)?; + Ok(AccountRemoveView { + state: "removed".to_owned(), + source: SHARED_ACCOUNT_STORE_SOURCE.to_owned(), + removed_account: account_summary(&result.removed_account), + default_cleared: result.default_cleared, + remaining_account_count: result.remaining_account_count, + actions: if result.default_cleared { + follow_up_account_actions(result.remaining_account_count) + } else { + Vec::new() + }, + }) +} + fn account_summary(account: &AccountRecordView) -> AccountSummaryView { account_summary_view(account) } + +fn unresolved_account_actions(has_accounts: bool) -> Vec<String> { + if has_accounts { + vec![ + "radroots account list".to_owned(), + "radroots account select <selector>".to_owned(), + ] + } else { + vec![ + "radroots account create".to_owned(), + "radroots account import <path>".to_owned(), + ] + } +} + +fn follow_up_account_actions(remaining_account_count: usize) -> Vec<String> { + if remaining_account_count == 0 { + vec![ + "radroots account create".to_owned(), + "radroots account import <path>".to_owned(), + ] + } else { + vec![ + "radroots account list".to_owned(), + "radroots account select <selector>".to_owned(), + ] + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs @@ -37,11 +37,20 @@ pub fn dispatch( AccountCommand::New => Ok(CommandOutput::success(CommandView::AccountNew( identity::init(config)?, ))), + AccountCommand::Import(args) => Ok(CommandOutput::success(CommandView::AccountImport( + identity::import(config, args)?, + ))), AccountCommand::Whoami => identity::show(config), AccountCommand::Ls => identity::list(config), AccountCommand::Use(args) => Ok(CommandOutput::success(CommandView::AccountUse( identity::use_account(config, args.selector.as_str())?, ))), + AccountCommand::ClearDefault => Ok(CommandOutput::success( + CommandView::AccountClearDefault(identity::clear_default(config)?), + )), + AccountCommand::Remove(args) => Ok(CommandOutput::success(CommandView::AccountRemove( + identity::remove(config, args.selector.as_str())?, + ))), }, Command::Myc(myc) => match &myc.command { MycCommand::Status => Ok(myc::status(config)), diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs @@ -80,8 +80,11 @@ impl CommandDisposition { #[derive(Debug, Clone)] pub enum CommandView { + AccountClearDefault(AccountClearDefaultView), + AccountImport(AccountImportView), AccountList(AccountListView), AccountNew(AccountNewView), + AccountRemove(AccountRemoveView), AccountUse(AccountUseView), AccountWhoami(AccountWhoamiView), ConfigShow(ConfigShowView), @@ -579,6 +582,8 @@ pub struct AccountWhoamiView { pub account_resolution: AccountResolutionView, #[serde(skip_serializing_if = "Option::is_none")] pub public_identity: Option<IdentityPublicView>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub actions: Vec<String>, } impl AccountWhoamiView { @@ -601,6 +606,16 @@ pub struct AccountNewView { } #[derive(Debug, Clone, Serialize)] +pub struct AccountImportView { + pub state: String, + pub source: String, + pub account: AccountSummaryView, + pub public_identity: IdentityPublicView, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub actions: Vec<String>, +} + +#[derive(Debug, Clone, Serialize)] pub struct AccountUseView { pub state: String, pub source: String, @@ -609,6 +624,28 @@ pub struct AccountUseView { } #[derive(Debug, Clone, Serialize)] +pub struct AccountClearDefaultView { + pub state: String, + pub source: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub cleared_account: Option<AccountSummaryView>, + pub remaining_account_count: usize, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub actions: Vec<String>, +} + +#[derive(Debug, Clone, Serialize)] +pub struct AccountRemoveView { + pub state: String, + pub source: String, + pub removed_account: AccountSummaryView, + pub default_cleared: bool, + pub remaining_account_count: usize, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub actions: Vec<String>, +} + +#[derive(Debug, Clone, Serialize)] pub struct AccountListView { pub source: String, pub count: usize, diff --git a/src/render/mod.rs b/src/render/mod.rs @@ -1,16 +1,17 @@ use std::io::{self, Write}; use crate::domain::runtime::{ - AccountListView, AccountSummaryView, CommandOutput, CommandView, DoctorCheckView, DoctorView, + AccountClearDefaultView, AccountImportView, AccountListView, AccountRemoveView, + AccountSummaryView, CommandOutput, CommandView, DoctorCheckView, DoctorView, FarmConfigSummaryView, FarmGetView, FarmPublishComponentView, FarmPublishView, FarmSetView, - FarmSetupView, FarmStatusView, FindView, JobGetView, JobListView, JobWatchView, ListingGetView, - ListingMutationView, ListingNewView, ListingValidateView, LocalBackupView, LocalExportView, - LocalInitView, LocalStatusView, NetStatusView, OrderCancelView, OrderDraftItemView, - OrderGetView, OrderHistoryView, OrderJobView, OrderListView, OrderNewView, OrderSubmitView, - OrderSubmitWatchView, OrderWatchView, OrderWorkflowView, RelayListView, RpcSessionsView, - RpcStatusView, RuntimeActionView, RuntimeLogsView, RuntimeManagedConfigView, RuntimeStatusView, - SellAddView, SellCheckView, SellDraftMutationView, SellMutationView, SellShowView, SetupView, - StatusView, SyncActionView, SyncStatusView, SyncWatchView, + FarmSetupView, FarmStatusView, FindView, JobGetView, JobListView, JobWatchView, + ListingGetView, ListingMutationView, ListingNewView, ListingValidateView, LocalBackupView, + LocalExportView, LocalInitView, LocalStatusView, NetStatusView, OrderCancelView, + OrderDraftItemView, OrderGetView, OrderHistoryView, OrderJobView, OrderListView, OrderNewView, + OrderSubmitView, OrderSubmitWatchView, OrderWatchView, OrderWorkflowView, RelayListView, + RpcSessionsView, RpcStatusView, RuntimeActionView, RuntimeLogsView, RuntimeManagedConfigView, + RuntimeStatusView, SellAddView, SellCheckView, SellDraftMutationView, SellMutationView, + SellShowView, SetupView, StatusView, SyncActionView, SyncStatusView, SyncWatchView, }; use crate::runtime::RuntimeError; use crate::runtime::config::{OutputConfig, OutputFormat, Verbosity}; @@ -72,8 +73,11 @@ fn render_human_view_to( output: &CommandOutput, ) -> Result<(), RuntimeError> { match output.view() { + CommandView::AccountClearDefault(view) => render_account_clear_default(stdout, view)?, + CommandView::AccountImport(view) => render_account_import(stdout, view)?, CommandView::AccountList(view) => render_account_list(stdout, view)?, CommandView::AccountNew(view) => render_account_new(stdout, view)?, + CommandView::AccountRemove(view) => render_account_remove(stdout, view)?, CommandView::AccountUse(view) => render_account_use(stdout, view)?, CommandView::AccountWhoami(view) => render_account_whoami(stdout, view)?, CommandView::MycStatus(view) => { @@ -273,6 +277,14 @@ fn render_json(output: &CommandOutput) -> Result<(), RuntimeError> { fn render_json_to(stdout: &mut dyn Write, output: &CommandOutput) -> Result<(), RuntimeError> { match output.view() { + CommandView::AccountClearDefault(view) => { + serde_json::to_writer_pretty(&mut *stdout, view)?; + writeln!(stdout)?; + } + CommandView::AccountImport(view) => { + serde_json::to_writer_pretty(&mut *stdout, view)?; + writeln!(stdout)?; + } CommandView::AccountList(view) => { serde_json::to_writer_pretty(&mut *stdout, view)?; writeln!(stdout)?; @@ -281,6 +293,10 @@ fn render_json_to(stdout: &mut dyn Write, output: &CommandOutput) -> Result<(), serde_json::to_writer_pretty(&mut *stdout, view)?; writeln!(stdout)?; } + CommandView::AccountRemove(view) => { + serde_json::to_writer_pretty(&mut *stdout, view)?; + writeln!(stdout)?; + } CommandView::AccountUse(view) => { serde_json::to_writer_pretty(&mut *stdout, view)?; writeln!(stdout)?; @@ -594,6 +610,11 @@ fn yes_no(value: bool) -> &'static str { fn render_quiet_output(output: &CommandOutput) -> Option<String> { match output.view() { + CommandView::AccountClearDefault(view) => Some(match &view.cleared_account { + Some(account) => format!("Default account cleared: {}", account.id), + None => "No default account configured".to_owned(), + }), + CommandView::AccountImport(view) => Some(format!("Account imported: {}", view.account.id)), CommandView::AccountNew(view) => Some(format!( "{}: {}", match view.state.as_str() { @@ -602,6 +623,9 @@ fn render_quiet_output(output: &CommandOutput) -> Option<String> { }, view.account.id )), + CommandView::AccountRemove(view) => { + Some(format!("Account removed: {}", view.removed_account.id)) + } CommandView::Find(view) | CommandView::MarketSearch(view) => match view.state.as_str() { "ready" if !view.results.is_empty() => Some( view.results @@ -747,8 +771,17 @@ fn render_field_rows_string(rows: &[(&str, String)]) -> String { fn verbose_details(output: &CommandOutput) -> Vec<(&'static str, String)> { match output.view() { + CommandView::AccountClearDefault(view) => vec![ + ("Source", view.source.clone()), + ("Remaining accounts", view.remaining_account_count.to_string()), + ], + CommandView::AccountImport(view) => vec![("Source", view.source.clone())], CommandView::AccountList(view) => vec![("Source", view.source.clone())], CommandView::AccountNew(view) => vec![("Source", view.source.clone())], + CommandView::AccountRemove(view) => vec![ + ("Source", view.source.clone()), + ("Remaining accounts", view.remaining_account_count.to_string()), + ], CommandView::AccountUse(view) => vec![("Source", view.source.clone())], CommandView::AccountWhoami(view) => vec![("Source", view.source.clone())], CommandView::Doctor(view) => vec![("Source", view.source.clone())], @@ -864,6 +897,25 @@ fn render_account_list(stdout: &mut dyn Write, view: &AccountListView) -> Result Ok(()) } +fn render_account_import( + stdout: &mut dyn Write, + view: &AccountImportView, +) -> Result<(), RuntimeError> { + writeln!(stdout, "Watch-only account imported")?; + writeln!(stdout)?; + render_account_section(stdout, &view.account)?; + writeln!(stdout)?; + writeln!(stdout, "Identity")?; + render_field_rows( + stdout, + &[("npub", view.public_identity.public_key_npub.clone())], + )?; + if !view.actions.is_empty() { + render_item_section(stdout, "Next", &view.actions)?; + } + Ok(()) +} + fn render_account_new( stdout: &mut dyn Write, view: &crate::domain::runtime::AccountNewView, @@ -894,11 +946,66 @@ fn render_account_use( stdout: &mut dyn Write, view: &crate::domain::runtime::AccountUseView, ) -> Result<(), RuntimeError> { - writeln!(stdout, "Default account updated")?; + writeln!(stdout, "Default account selected")?; writeln!(stdout)?; render_account_section(stdout, &view.account) } +fn render_account_clear_default( + stdout: &mut dyn Write, + view: &AccountClearDefaultView, +) -> Result<(), RuntimeError> { + writeln!( + stdout, + "{}", + match view.state.as_str() { + "cleared" => "Default account cleared", + _ => "No default account configured", + } + )?; + if let Some(account) = &view.cleared_account { + writeln!(stdout)?; + render_account_section(stdout, account)?; + } + writeln!(stdout)?; + render_field_rows( + stdout, + &[("Remaining accounts", view.remaining_account_count.to_string())], + )?; + if !view.actions.is_empty() { + writeln!(stdout)?; + render_item_section(stdout, "Next", &view.actions)?; + } + Ok(()) +} + +fn render_account_remove( + stdout: &mut dyn Write, + view: &AccountRemoveView, +) -> Result<(), RuntimeError> { + writeln!( + stdout, + "{}", + if view.default_cleared { + "Default account removed" + } else { + "Account removed" + } + )?; + writeln!(stdout)?; + render_account_section(stdout, &view.removed_account)?; + writeln!(stdout)?; + render_field_rows( + stdout, + &[("Remaining accounts", view.remaining_account_count.to_string())], + )?; + if !view.actions.is_empty() { + writeln!(stdout)?; + render_item_section(stdout, "Next", &view.actions)?; + } + Ok(()) +} + fn render_account_whoami( stdout: &mut dyn Write, view: &crate::domain::runtime::AccountWhoamiView, @@ -928,8 +1035,10 @@ fn render_account_whoami( render_account_resolution(stdout, &view.account_resolution)?; writeln!(stdout)?; render_item_section(stdout, "Missing", &["Resolved account".to_owned()])?; - writeln!(stdout)?; - render_item_section(stdout, "Next", &["radroots account create".to_owned()])?; + if !view.actions.is_empty() { + writeln!(stdout)?; + render_item_section(stdout, "Next", &view.actions)?; + } } } Ok(()) @@ -4057,10 +4166,13 @@ struct Table { fn human_command_name(view: &CommandView) -> &'static str { match view { - CommandView::AccountList(_) => "account ls", - CommandView::AccountNew(_) => "account new", - CommandView::AccountUse(_) => "account use", - CommandView::AccountWhoami(_) => "account whoami", + CommandView::AccountClearDefault(_) => "account clear-default", + CommandView::AccountImport(_) => "account import", + CommandView::AccountList(_) => "account list", + CommandView::AccountNew(_) => "account create", + CommandView::AccountRemove(_) => "account remove", + CommandView::AccountUse(_) => "account select", + CommandView::AccountWhoami(_) => "account view", CommandView::ConfigShow(_) => "config show", CommandView::Doctor(_) => "doctor", CommandView::FarmGet(_) => "farm show", diff --git a/src/runtime/accounts.rs b/src/runtime/accounts.rs @@ -1,3 +1,6 @@ +use std::path::Path; + +use radroots_identity::{IdentityError, load_identity_profile}; use radroots_nostr_accounts::prelude::{ RadrootsNostrAccountRecord, RadrootsNostrAccountStatus, RadrootsNostrAccountsError, RadrootsNostrAccountsManager, @@ -51,6 +54,19 @@ pub struct AccountCreateResult { pub account: AccountRecordView, } +#[derive(Debug, Clone)] +pub struct AccountClearDefaultResult { + pub cleared_account: Option<AccountRecordView>, + pub remaining_account_count: usize, +} + +#[derive(Debug, Clone)] +pub struct AccountRemoveResult { + pub removed_account: AccountRecordView, + pub default_cleared: bool, + pub remaining_account_count: usize, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AccountResolutionSource { InvocationOverride, @@ -93,21 +109,37 @@ pub fn create_or_migrate_default_account( }; let snapshot = snapshot(config)?; - let Some(account) = snapshot - .accounts - .into_iter() - .find(|account| account.record.account_id == created_account_id) - else { - return Err(RuntimeError::Accounts( - radroots_nostr_accounts::prelude::RadrootsNostrAccountsError::InvalidState( - "created account missing after account create".to_owned(), - ), - )); - }; + let account = snapshot_account( + &snapshot, + &created_account_id, + "created account missing after account create", + )?; Ok(AccountCreateResult { mode, account }) } +pub fn import_public_identity( + config: &RuntimeConfig, + path: &Path, + 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 imported_account_id = manager.upsert_public_identity(public_identity, None, make_default)?; + let snapshot = snapshot_from_manager(&manager)?; + snapshot_account( + &snapshot, + &imported_account_id, + "imported account missing after account import", + ) +} + pub fn snapshot(config: &RuntimeConfig) -> Result<AccountSnapshot, RuntimeError> { let manager = account_manager(config)?; snapshot_from_manager(&manager) @@ -170,6 +202,37 @@ pub fn select_account( }) } +pub fn clear_default_account( + config: &RuntimeConfig, +) -> Result<AccountClearDefaultResult, RuntimeError> { + let manager = account_manager(config)?; + let snapshot = snapshot_from_manager(&manager)?; + let cleared_account = snapshot.accounts.iter().find(|account| account.is_default).cloned(); + manager.clear_default_account()?; + let remaining_account_count = snapshot_from_manager(&manager)?.accounts.len(); + Ok(AccountClearDefaultResult { + cleared_account, + remaining_account_count, + }) +} + +pub fn remove_account( + config: &RuntimeConfig, + selector: &str, +) -> Result<AccountRemoveResult, RuntimeError> { + let manager = account_manager(config)?; + let snapshot = snapshot_from_manager(&manager)?; + let removed_account = resolve_selector_account(&manager, &snapshot, selector)?; + let default_cleared = removed_account.is_default; + manager.remove_account(&removed_account.record.account_id)?; + let remaining_account_count = snapshot_from_manager(&manager)?.accounts.len(); + Ok(AccountRemoveResult { + removed_account, + default_cleared, + remaining_account_count, + }) +} + pub fn resolved_account_signing_status( config: &RuntimeConfig, ) -> Result<RadrootsNostrAccountStatus, RuntimeError> { @@ -271,21 +334,41 @@ fn snapshot_from_manager( manager: &RadrootsNostrAccountsManager, ) -> Result<AccountSnapshot, RuntimeError> { let default_account_id = manager.default_account_id()?.map(|id| id.to_string()); - let accounts = manager - .list_accounts()? - .into_iter() - .map(|record| AccountRecordView { - is_default: default_account_id - .as_deref() - .is_some_and(|default| default == record.account_id.as_str()), - signer: "local", + let mut accounts = Vec::new(); + for record in manager.list_accounts()? { + let is_default = default_account_id + .as_deref() + .is_some_and(|default| default == record.account_id.as_str()); + let signer = account_signer(manager, &record)?; + accounts.push(AccountRecordView { record, - }) - .collect(); + is_default, + signer, + }); + } Ok(AccountSnapshot { accounts }) } +fn snapshot_account( + snapshot: &AccountSnapshot, + account_id: &radroots_identity::RadrootsIdentityId, + missing_message: &str, +) -> Result<AccountRecordView, RuntimeError> { + snapshot + .accounts + .iter() + .find(|account| account.record.account_id == *account_id) + .cloned() + .ok_or_else(|| { + RuntimeError::Accounts( + radroots_nostr_accounts::prelude::RadrootsNostrAccountsError::InvalidState( + missing_message.to_owned(), + ), + ) + }) +} + fn resolve_selector_account( manager: &RadrootsNostrAccountsManager, snapshot: &AccountSnapshot, @@ -320,6 +403,26 @@ fn selector_runtime_error(selector: &str, error: RadrootsNostrAccountsError) -> } } +fn account_signer( + manager: &RadrootsNostrAccountsManager, + record: &RadrootsNostrAccountRecord, +) -> Result<&'static str, RuntimeError> { + Ok( + if manager.get_signing_identity(&record.account_id)?.is_some() { + "local" + } else { + "watch_only" + }, + ) +} + +fn format_identity_error(error: IdentityError) -> String { + match error { + IdentityError::NotFound(path) => format!("path not found: {}", path.display()), + other => other.to_string(), + } +} + fn account_manager(config: &RuntimeConfig) -> Result<RadrootsNostrAccountsManager, RuntimeError> { let (manager, _) = RadrootsNostrAccountsManager::new_local_file_backed( config.account.store_path.as_path(), diff --git a/tests/help.rs b/tests/help.rs @@ -73,9 +73,13 @@ fn account_help_prefers_human_first_aliases() { assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); assert!(stdout.contains("create")); + assert!(stdout.contains("import")); assert!(stdout.contains("view")); assert!(stdout.contains("list")); assert!(stdout.contains("select")); + assert!(stdout.contains("clear-default")); + assert!(stdout.contains("remove")); + assert!(stdout.contains("Select stores the default account.")); assert!(stdout.contains("Compatibility aliases: new, whoami, ls, use.")); } diff --git a/tests/identity_commands.rs b/tests/identity_commands.rs @@ -3,6 +3,7 @@ use std::path::Path; use std::process::Command; use assert_cmd::prelude::*; +use radroots_identity::RadrootsIdentity; use serde_json::Value; use tempfile::tempdir; @@ -128,6 +129,56 @@ fn account_new_encrypts_file_backed_secret_fallback_by_default() { } #[test] +fn account_import_json_creates_watch_only_account_without_secret_material() { + let dir = tempdir().expect("tempdir"); + let identity = RadrootsIdentity::generate(); + let import_path = dir.path().join("watch-only-identity.json"); + fs::write( + &import_path, + serde_json::to_vec_pretty(&identity.to_public()).expect("serialize public identity"), + ) + .expect("write import file"); + + let output = cli_command_in(dir.path()) + .args([ + "--json", + "account", + "import", + import_path.to_str().expect("utf8 path"), + ]) + .output() + .expect("run account import"); + + assert!(output.status.success()); + let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("json output"); + let account_id = json["account"]["id"].as_str().expect("account id"); + assert_eq!(json["state"], "imported"); + assert_eq!(json["source"], "shared account store · watch-only import"); + assert_eq!(json["account"]["signer"], "watch_only"); + assert_eq!(json["account"]["is_default"], true); + + let secrets_dir = secrets_root(dir.path()).join("shared/accounts"); + assert!(!secrets_dir.join(format!("{account_id}.secret")).exists()); + assert!(!secrets_dir.join(format!("{account_id}.secret.json")).exists()); + + let whoami = cli_command_in(dir.path()) + .args(["--json", "account", "whoami"]) + .output() + .expect("run account whoami"); + assert!(whoami.status.success()); + let whoami_json: Value = + serde_json::from_slice(whoami.stdout.as_slice()).expect("whoami json"); + assert_eq!( + whoami_json["account_resolution"]["resolved_account"]["id"], + account_id + ); + assert_eq!( + whoami_json["account_resolution"]["resolved_account"]["signer"], + "watch_only" + ); +} + +#[test] fn account_new_rejects_dry_run_without_creating_store_state() { let dir = tempdir().expect("tempdir"); let store_path = data_root(dir.path()).join("shared/accounts/store.json"); @@ -242,6 +293,62 @@ fn account_new_does_not_replace_existing_default_account() { } #[test] +fn account_clear_default_json_clears_stored_default_without_removing_accounts() { + let dir = tempdir().expect("tempdir"); + let store_path = data_root(dir.path()).join("shared/accounts/store.json"); + + let first = cli_command_in(dir.path()) + .args(["--json", "account", "new"]) + .output() + .expect("run first account new"); + assert!(first.status.success()); + let first_json: Value = serde_json::from_slice(first.stdout.as_slice()).expect("first json"); + let first_id = first_json["account"]["id"] + .as_str() + .expect("first account id") + .to_owned(); + + let second = cli_command_in(dir.path()) + .args(["--json", "account", "new"]) + .output() + .expect("run second account new"); + assert!(second.status.success()); + + let output = cli_command_in(dir.path()) + .args(["--json", "account", "clear-default"]) + .output() + .expect("run clear-default"); + + assert!(output.status.success()); + let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("clear-default json"); + assert_eq!(json["state"], "cleared"); + assert_eq!(json["cleared_account"]["id"], first_id); + assert_eq!(json["remaining_account_count"], 2); + + let store_json: Value = + serde_json::from_slice(fs::read(&store_path).expect("read store").as_slice()) + .expect("parse store"); + assert_eq!(store_json["default_account_id"], Value::Null); + assert_eq!( + store_json["accounts"] + .as_array() + .expect("accounts array") + .len(), + 2 + ); + + let whoami = cli_command_in(dir.path()) + .args(["--json", "account", "whoami"]) + .output() + .expect("run account whoami"); + assert_eq!(whoami.status.code(), Some(3)); + let whoami_json: Value = + serde_json::from_slice(whoami.stdout.as_slice()).expect("whoami json"); + assert_eq!(whoami_json["account_resolution"]["source"], "none"); + assert_eq!(whoami_json["account_resolution"]["default_account"], Value::Null); +} + +#[test] fn account_whoami_json_reports_unconfigured_without_accounts() { let dir = tempdir().expect("tempdir"); @@ -351,6 +458,63 @@ fn account_use_selects_existing_account() { } #[test] +fn account_remove_json_clears_default_when_removing_default_account() { + let dir = tempdir().expect("tempdir"); + let store_path = data_root(dir.path()).join("shared/accounts/store.json"); + + let first = cli_command_in(dir.path()) + .args(["--json", "account", "new"]) + .output() + .expect("run first account new"); + assert!(first.status.success()); + let first_json: Value = serde_json::from_slice(first.stdout.as_slice()).expect("first json"); + let first_id = first_json["account"]["id"] + .as_str() + .expect("first account id") + .to_owned(); + + let second = cli_command_in(dir.path()) + .args(["--json", "account", "new"]) + .output() + .expect("run second account new"); + assert!(second.status.success()); + + let output = cli_command_in(dir.path()) + .args(["--json", "account", "remove", first_id.as_str()]) + .output() + .expect("run account remove"); + + assert!(output.status.success()); + let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("remove json"); + assert_eq!(json["state"], "removed"); + assert_eq!(json["removed_account"]["id"], first_id); + assert_eq!(json["default_cleared"], true); + assert_eq!(json["remaining_account_count"], 1); + + let store_json: Value = + serde_json::from_slice(fs::read(&store_path).expect("read store").as_slice()) + .expect("parse store"); + assert_eq!(store_json["default_account_id"], Value::Null); + assert_eq!( + store_json["accounts"] + .as_array() + .expect("accounts array") + .len(), + 1 + ); + + let whoami = cli_command_in(dir.path()) + .args(["--json", "account", "whoami"]) + .output() + .expect("run account whoami"); + assert_eq!(whoami.status.code(), Some(3)); + let whoami_json: Value = + serde_json::from_slice(whoami.stdout.as_slice()).expect("whoami json"); + assert_eq!(whoami_json["account_resolution"]["source"], "none"); + assert_eq!(whoami_json["account_resolution"]["default_account"], Value::Null); +} + +#[test] fn account_use_rejects_ambiguous_label_selector() { let dir = tempdir().expect("tempdir"); let store_path = data_root(dir.path()).join("shared/accounts/store.json");