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:
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");