cli

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

commit d7b6a67dc1b5532371760afc2c7da566dd50b35a
parent 9c18ca6145da99a828a29b39fdce53b3a5e3d2f5
Author: triesap <tyson@radroots.org>
Date:   Tue,  7 Apr 2026 03:52:19 +0000

land account store operator surfaces

Diffstat:
MCargo.lock | 14++++++++++++++
MCargo.toml | 1+
Msrc/cli.rs | 5+++++
Msrc/commands/doctor.rs | 70++++++++++++++++++++++++++++++++++++++++++++--------------------------
Msrc/commands/identity.rs | 87+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Msrc/commands/mod.rs | 8+++++---
Msrc/commands/runtime.rs | 5++++-
Msrc/domain/runtime.rs | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Msrc/render/mod.rs | 240++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Asrc/runtime/accounts.rs | 278+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/runtime/config.rs | 37++++++++++++++++++++++++++++++++++---
Dsrc/runtime/identity.rs | 32--------------------------------
Msrc/runtime/mod.rs | 8++++----
Msrc/runtime/signer.rs | 48+++++++++++++++++++++++++++++++-----------------
Mtests/doctor.rs | 17+++--------------
Mtests/identity_commands.rs | 161+++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
Mtests/myc_status.rs | 2++
Mtests/runtime_show.rs | 27+++++++++++++++++++++++++--
Mtests/signer_status.rs | 61++++++++++++++-----------------------------------------------
19 files changed, 892 insertions(+), 270 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -1196,6 +1196,7 @@ dependencies = [ "clap", "radroots-identity", "radroots-log", + "radroots-nostr-accounts", "radroots-nostr-signer", "serde", "serde_json", @@ -1245,6 +1246,19 @@ dependencies = [ ] [[package]] +name = "radroots-nostr-accounts" +version = "0.1.0-alpha.1" +dependencies = [ + "radroots-identity", + "radroots-nostr-signer", + "radroots-runtime", + "serde", + "serde_json", + "thiserror 1.0.69", + "zeroize", +] + +[[package]] name = "radroots-nostr-connect" version = "0.1.0-alpha.1" dependencies = [ diff --git a/Cargo.toml b/Cargo.toml @@ -20,6 +20,7 @@ unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] } clap = { version = "4.5", features = ["derive"] } radroots-identity = { path = "../lib/crates/identity" } radroots-log = { path = "../lib/crates/log" } +radroots-nostr-accounts = { path = "../lib/crates/nostr-accounts" } radroots-nostr-signer = { path = "../lib/crates/nostr-signer" } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/src/cli.rs b/src/cli.rs @@ -32,6 +32,8 @@ pub struct CliArgs { #[arg(long = "no-log-stdout", global = true, action = ArgAction::SetTrue)] pub no_log_stdout: bool, #[arg(long, global = true)] + pub account: Option<String>, + #[arg(long, global = true)] pub identity_path: Option<PathBuf>, #[arg(long, global = true)] pub signer_backend: Option<String>, @@ -370,6 +372,8 @@ mod tests { "--no-color", "--env-file", ".env.local", + "--account", + "acct_demo", "--log-filter", "debug,radroots_cli=trace", "--log-dir", @@ -400,6 +404,7 @@ mod tests { parsed.log_dir.as_deref().and_then(|path| path.to_str()), Some("logs") ); + assert_eq!(parsed.account.as_deref(), Some("acct_demo")); assert!(parsed.log_stdout); assert_eq!( parsed diff --git a/src/commands/doctor.rs b/src/commands/doctor.rs @@ -1,10 +1,10 @@ use crate::domain::runtime::{ CommandDisposition, CommandOutput, CommandView, DoctorCheckView, DoctorView, }; +use crate::runtime::RuntimeError; use crate::runtime::config::{RuntimeConfig, SignerBackend}; use crate::runtime::logging::LoggingState; use crate::runtime::signer::resolve_signer_status; -use radroots_identity::IdentityError; #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] enum DoctorSeverity { @@ -39,10 +39,13 @@ struct EvaluatedCheck { action: Option<&'static str>, } -pub fn report(config: &RuntimeConfig, logging: &LoggingState) -> CommandOutput { +pub fn report( + config: &RuntimeConfig, + logging: &LoggingState, +) -> Result<CommandOutput, RuntimeError> { let mut checks = Vec::new(); checks.push(config_check(config)); - checks.push(account_check(config)); + checks.push(account_check(config)?); let signer = resolve_signer_status(config); checks.push(signer_check(&signer)); @@ -72,7 +75,7 @@ pub fn report(config: &RuntimeConfig, logging: &LoggingState) -> CommandOutput { actions, }; - match severity.command_disposition() { + Ok(match severity.command_disposition() { CommandDisposition::Success => CommandOutput::success(CommandView::Doctor(view)), CommandDisposition::Unconfigured => CommandOutput::unconfigured(CommandView::Doctor(view)), CommandDisposition::ExternalUnavailable => { @@ -81,7 +84,7 @@ pub fn report(config: &RuntimeConfig, logging: &LoggingState) -> CommandOutput { CommandDisposition::InternalError => { CommandOutput::internal_error(CommandView::Doctor(view)) } - } + }) } fn config_check(config: &RuntimeConfig) -> EvaluatedCheck { @@ -106,37 +109,52 @@ fn config_check(config: &RuntimeConfig) -> EvaluatedCheck { } } -fn account_check(config: &RuntimeConfig) -> EvaluatedCheck { - match crate::runtime::identity::load_identity(&config.identity) { - Ok(identity) => EvaluatedCheck { - severity: DoctorSeverity::Ok, +fn account_check(config: &RuntimeConfig) -> Result<EvaluatedCheck, RuntimeError> { + let snapshot = crate::runtime::accounts::snapshot(config)?; + if snapshot.accounts.is_empty() { + return Ok(EvaluatedCheck { + severity: DoctorSeverity::Warn, view: DoctorCheckView { name: "account".to_owned(), - status: "ok".to_owned(), - detail: format!("{} loaded", identity.public_identity.id), + status: "warn".to_owned(), + detail: format!( + "no local accounts found in {}", + config.account.store_path.display() + ), }, - action: None, - }, - Err(crate::runtime::RuntimeError::Identity(IdentityError::NotFound(path))) => { - EvaluatedCheck { - severity: DoctorSeverity::Warn, + action: Some("radroots account new"), + }); + } + + match crate::runtime::accounts::resolve_account(config)? { + Some(account) => { + let detail = if account.selected { + format!("{} selected", account.record.account_id) + } else { + format!("{} resolved by selector", account.record.account_id) + }; + Ok(EvaluatedCheck { + severity: DoctorSeverity::Ok, view: DoctorCheckView { name: "account".to_owned(), - status: "warn".to_owned(), - detail: format!("no local account at {}", path.display()), + status: "ok".to_owned(), + detail, }, - action: Some("radroots account new"), - } + action: None, + }) } - Err(error) => EvaluatedCheck { - severity: DoctorSeverity::InternalFail, + None => Ok(EvaluatedCheck { + severity: DoctorSeverity::Warn, view: DoctorCheckView { name: "account".to_owned(), - status: "fail".to_owned(), - detail: error.to_string(), + status: "warn".to_owned(), + detail: format!( + "accounts exist but no local account is selected in {}", + config.account.store_path.display() + ), }, - action: Some("radroots account whoami --json"), - }, + action: Some("radroots account ls"), + }), } } diff --git a/src/commands/identity.rs b/src/commands/identity.rs @@ -1,41 +1,58 @@ use crate::domain::runtime::{ - AccountNewView, AccountWhoamiView, CommandDisposition, CommandOutput, CommandView, - IdentityPublicView, + AccountListView, AccountNewView, AccountSummaryView, AccountUseView, AccountWhoamiView, + CommandDisposition, CommandOutput, CommandView, IdentityPublicView, }; use crate::runtime::RuntimeError; +use crate::runtime::accounts::{ + AccountCreateMode, AccountRecordView, create_or_migrate_selected_account, resolve_account, + select_account, snapshot, +}; use crate::runtime::config::RuntimeConfig; -use crate::runtime::identity::{initialize_identity, load_identity}; -use radroots_identity::IdentityError; pub fn init(config: &RuntimeConfig) -> Result<AccountNewView, RuntimeError> { - let identity = initialize_identity(&config.identity)?; + let result = create_or_migrate_selected_account(config)?; + let account = account_summary(&result.account); Ok(AccountNewView { - path: identity.path.display().to_string(), - created: identity.created, - public_identity: IdentityPublicView::from_public_identity(&identity.public_identity), + state: match result.mode { + AccountCreateMode::Created => "created".to_owned(), + AccountCreateMode::Migrated => "migrated".to_owned(), + }, + source: match result.mode { + AccountCreateMode::Created => "local account store · local first".to_owned(), + AccountCreateMode::Migrated => "legacy identity import · local first".to_owned(), + }, + public_identity: IdentityPublicView::from_public_identity( + &result.account.record.public_identity, + ), + account, + actions: vec![ + "radroots account whoami".to_owned(), + "radroots account ls".to_owned(), + ], }) } pub fn show(config: &RuntimeConfig) -> Result<CommandOutput, RuntimeError> { - let view = match load_identity(&config.identity) { - Ok(identity) => AccountWhoamiView { - path: identity.path.display().to_string(), + let view = match resolve_account(config)? { + Some(account) => AccountWhoamiView { state: "ready".to_owned(), + source: "local account store · local first".to_owned(), reason: None, public_identity: Some(IdentityPublicView::from_public_identity( - &identity.public_identity, + &account.record.public_identity, )), + account: Some(account_summary(&account)), }, - Err(RuntimeError::Identity(IdentityError::NotFound(path))) => AccountWhoamiView { - path: path.display().to_string(), + None => AccountWhoamiView { state: "unconfigured".to_owned(), + source: "local account store · local first".to_owned(), reason: Some(format!( - "local identity file was not found at {}", - path.display() + "no local account is selected in {}", + config.account.store_path.display() )), + account: None, public_identity: None, }, - Err(error) => return Err(error), }; Ok(match view.disposition() { @@ -51,3 +68,39 @@ pub fn show(config: &RuntimeConfig) -> Result<CommandOutput, RuntimeError> { } }) } + +pub fn list(config: &RuntimeConfig) -> Result<CommandOutput, RuntimeError> { + let snapshot = snapshot(config)?; + let accounts = snapshot + .accounts + .iter() + .map(account_summary) + .collect::<Vec<_>>(); + let actions = if accounts.is_empty() { + vec!["radroots account new".to_owned()] + } else { + Vec::new() + }; + Ok(CommandOutput::success(CommandView::AccountList( + AccountListView { + source: "local account store · local first".to_owned(), + count: accounts.len(), + accounts, + actions, + }, + ))) +} + +pub fn use_account(config: &RuntimeConfig, selector: &str) -> Result<AccountUseView, RuntimeError> { + let account = select_account(config, selector)?; + Ok(AccountUseView { + state: "active".to_owned(), + source: "local account store · local first".to_owned(), + active_account_id: account.record.account_id.to_string(), + account: account_summary(&account), + }) +} + +fn account_summary(account: &AccountRecordView) -> AccountSummaryView { + AccountSummaryView::from_account_record(&account.record, account.signer, account.selected) +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs @@ -24,8 +24,10 @@ pub fn dispatch( identity::init(config)?, ))), AccountCommand::Whoami => identity::show(config), - AccountCommand::Ls => unimplemented_command("account ls"), - AccountCommand::Use(_) => unimplemented_command("account use"), + AccountCommand::Ls => identity::list(config), + AccountCommand::Use(args) => Ok(CommandOutput::success(CommandView::AccountUse( + identity::use_account(config, args.selector.as_str())?, + ))), }, Command::Myc(myc) => match &myc.command { MycCommand::Status => Ok(myc::status(config)), @@ -38,7 +40,7 @@ pub fn dispatch( Command::Signer(signer) => match &signer.command { SignerCommand::Status => Ok(signer::status(config)), }, - Command::Doctor => Ok(doctor::report(config, logging)), + Command::Doctor => doctor::report(config, logging), Command::Find(_) => unimplemented_command("find"), Command::Job(job) => match &job.command { JobCommand::Ls => unimplemented_command("job ls"), diff --git a/src/commands/runtime.rs b/src/commands/runtime.rs @@ -38,7 +38,10 @@ pub fn show(config: &RuntimeConfig, logging: &LoggingState) -> ConfigShowView { .map(|path| path.display().to_string()), }, account: AccountRuntimeView { - identity_path: config.identity.path.display().to_string(), + selector: config.account.selector.clone(), + store_path: config.account.store_path.display().to_string(), + secrets_dir: config.account.secrets_dir.display().to_string(), + legacy_identity_path: config.identity.path.display().to_string(), }, signer: SignerRuntimeView { backend: config.signer.backend.as_str().to_owned(), diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs @@ -1,5 +1,6 @@ use std::process::ExitCode; +use radroots_nostr_accounts::prelude::RadrootsNostrAccountRecord; use serde::Serialize; #[derive(Debug, Clone)] @@ -67,7 +68,9 @@ impl CommandDisposition { #[derive(Debug, Clone)] pub enum CommandView { + AccountList(AccountListView), AccountNew(AccountNewView), + AccountUse(AccountUseView), AccountWhoami(AccountWhoamiView), ConfigShow(ConfigShowView), Doctor(DoctorView), @@ -119,7 +122,11 @@ pub struct PathsRuntimeView { #[derive(Debug, Clone, Serialize)] pub struct AccountRuntimeView { - pub identity_path: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub selector: Option<String>, + pub store_path: String, + pub secrets_dir: String, + pub legacy_identity_path: String, } #[derive(Debug, Clone, Serialize)] @@ -167,12 +174,38 @@ impl IdentityPublicView { } #[derive(Debug, Clone, Serialize)] +pub struct AccountSummaryView { + pub id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub display_name: Option<String>, + pub signer: String, + pub is_default: bool, +} + +impl AccountSummaryView { + pub fn from_account_record( + record: &RadrootsNostrAccountRecord, + signer: &str, + is_default: bool, + ) -> Self { + Self { + id: record.account_id.to_string(), + display_name: record.label.clone(), + signer: signer.to_owned(), + is_default, + } + } +} + +#[derive(Debug, Clone, Serialize)] pub struct AccountWhoamiView { - pub path: String, pub state: String, + pub source: String, #[serde(skip_serializing_if = "Option::is_none")] pub reason: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] + pub account: Option<AccountSummaryView>, + #[serde(skip_serializing_if = "Option::is_none")] pub public_identity: Option<IdentityPublicView>, } @@ -187,9 +220,29 @@ impl AccountWhoamiView { #[derive(Debug, Clone, Serialize)] pub struct AccountNewView { - pub path: String, - pub created: bool, + 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, + pub active_account_id: String, + pub account: AccountSummaryView, +} + +#[derive(Debug, Clone, Serialize)] +pub struct AccountListView { + pub source: String, + pub count: usize, + pub accounts: Vec<AccountSummaryView>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub actions: Vec<String>, } #[derive(Debug, Clone, Serialize)] diff --git a/src/render/mod.rs b/src/render/mod.rs @@ -1,6 +1,8 @@ use std::io::{self, Write}; -use crate::domain::runtime::{CommandOutput, CommandView, DoctorCheckView, DoctorView}; +use crate::domain::runtime::{ + AccountListView, AccountSummaryView, CommandOutput, CommandView, DoctorCheckView, DoctorView, +}; use crate::runtime::RuntimeError; use crate::runtime::config::{OutputConfig, OutputFormat}; @@ -21,42 +23,50 @@ fn render_human(output: &CommandOutput) -> Result<(), RuntimeError> { fn render_human_to(stdout: &mut dyn Write, output: &CommandOutput) -> Result<(), RuntimeError> { match output.view() { + CommandView::AccountList(view) => render_account_list(stdout, view)?, CommandView::AccountNew(view) => { - writeln!(stdout, "account new")?; - writeln!(stdout, " path: {}", view.path)?; - writeln!(stdout, " created: {}", yes_no(view.created))?; - writeln!(stdout, " id: {}", view.public_identity.id)?; - writeln!( + write_context(stdout, format!("account · {}", view.state).as_str())?; + render_owned_pairs( stdout, - " public key hex: {}", - view.public_identity.public_key_hex + "account", + account_pairs(&view.account, Some(&view.public_identity)).as_slice(), )?; - writeln!( + writeln!(stdout, "source: {}", view.source)?; + render_actions(stdout, &view.actions)?; + } + CommandView::AccountUse(view) => { + write_context(stdout, "account · active")?; + render_owned_pairs( stdout, - " public key npub: {}", - view.public_identity.public_key_npub + "account", + account_pairs(&view.account, None).as_slice(), )?; + writeln!(stdout, "active account id: {}", view.active_account_id)?; + writeln!(stdout, "source: {}", view.source)?; } CommandView::AccountWhoami(view) => { - writeln!(stdout, "account")?; - writeln!(stdout, " path: {}", view.path)?; - writeln!(stdout, " state: {}", view.state)?; - if let Some(reason) = &view.reason { - writeln!(stdout, " reason: {reason}")?; - } - if let Some(public_identity) = &view.public_identity { - writeln!(stdout, " id: {}", public_identity.id)?; - writeln!( - stdout, - " public key hex: {}", - public_identity.public_key_hex - )?; - writeln!( + write_context( + stdout, + match view.state.as_str() { + "ready" => "account · active", + "unconfigured" => "account · unconfigured", + _ => "account", + }, + )?; + if let Some(account) = &view.account { + render_owned_pairs( stdout, - " public key npub: {}", - public_identity.public_key_npub + "account", + account_pairs(account, view.public_identity.as_ref()).as_slice(), )?; + } else { + writeln!(stdout, "no local account selected")?; + writeln!(stdout)?; + } + if let Some(reason) = &view.reason { + writeln!(stdout, "reason: {reason}")?; } + writeln!(stdout, "source: {}", view.source)?; } CommandView::MycStatus(view) => { render_myc_status(stdout, view)?; @@ -92,10 +102,18 @@ fn render_json(output: &CommandOutput) -> Result<(), RuntimeError> { fn render_json_to(stdout: &mut dyn Write, output: &CommandOutput) -> Result<(), RuntimeError> { match output.view() { + CommandView::AccountList(view) => { + serde_json::to_writer_pretty(&mut *stdout, view)?; + writeln!(stdout)?; + } CommandView::AccountNew(view) => { serde_json::to_writer_pretty(&mut *stdout, view)?; writeln!(stdout)?; } + CommandView::AccountUse(view) => { + serde_json::to_writer_pretty(&mut *stdout, view)?; + writeln!(stdout)?; + } CommandView::AccountWhoami(view) => { serde_json::to_writer_pretty(&mut *stdout, view)?; writeln!(stdout)?; @@ -125,11 +143,20 @@ fn render_ndjson(output: &CommandOutput) -> Result<(), RuntimeError> { render_ndjson_to(&mut stdout, output) } -fn render_ndjson_to(_stdout: &mut dyn Write, output: &CommandOutput) -> Result<(), RuntimeError> { - Err(RuntimeError::Config(format!( - "`{}` does not support --ndjson", - human_command_name(output.view()) - ))) +fn render_ndjson_to(stdout: &mut dyn Write, output: &CommandOutput) -> Result<(), RuntimeError> { + match output.view() { + CommandView::AccountList(view) => { + for account in &view.accounts { + serde_json::to_writer(&mut *stdout, account)?; + writeln!(stdout)?; + } + Ok(()) + } + _ => Err(RuntimeError::Config(format!( + "`{}` does not support --ndjson", + human_command_name(output.view()) + ))), + } } fn yes_no(value: bool) -> &'static str { @@ -140,6 +167,35 @@ fn present_absent(value: bool) -> &'static str { if value { "present" } else { "absent" } } +fn render_account_list(stdout: &mut dyn Write, view: &AccountListView) -> Result<(), RuntimeError> { + write_context(stdout, format!("accounts · {} local", view.count).as_str())?; + if view.accounts.is_empty() { + writeln!(stdout, "no accounts found")?; + writeln!(stdout)?; + } else { + let table = Table { + headers: &["account", "display name", "signer", "default"], + rows: view + .accounts + .iter() + .map(|account| { + vec![ + account.id.clone(), + account.display_name.clone().unwrap_or_default(), + account.signer.clone(), + yes_no(account.is_default).to_owned(), + ] + }) + .collect(), + }; + render_table(stdout, &table)?; + writeln!(stdout)?; + } + writeln!(stdout, "source: {}", view.source)?; + render_actions(stdout, &view.actions)?; + Ok(()) +} + fn render_config_show( stdout: &mut dyn Write, view: &crate::domain::runtime::ConfigShowView, @@ -186,11 +242,19 @@ fn render_config_show( logging_rows.push(("file", current_file.as_str())); } render_pairs(stdout, "logging", logging_rows.as_slice())?; - render_pairs( - stdout, - "account", - &[("identity path", view.account.identity_path.as_str())], - )?; + + let mut account_rows = vec![ + ("store path", view.account.store_path.as_str()), + ("secrets dir", view.account.secrets_dir.as_str()), + ( + "legacy import path", + view.account.legacy_identity_path.as_str(), + ), + ]; + if let Some(selector) = &view.account.selector { + account_rows.insert(0, ("selector", selector.as_str())); + } + render_pairs(stdout, "account", account_rows.as_slice())?; render_pairs( stdout, "signer", @@ -238,6 +302,18 @@ fn write_context(stdout: &mut dyn Write, line: &str) -> Result<(), RuntimeError> Ok(()) } +fn render_actions(stdout: &mut dyn Write, actions: &[String]) -> Result<(), RuntimeError> { + if actions.is_empty() { + return Ok(()); + } + writeln!(stdout)?; + writeln!(stdout, "actions")?; + for action in actions { + writeln!(stdout, " › {action}")?; + } + Ok(()) +} + fn render_pairs( stdout: &mut dyn Write, heading: &str, @@ -256,6 +332,37 @@ fn render_pairs( Ok(()) } +fn render_owned_pairs( + stdout: &mut dyn Write, + heading: &str, + rows: &[(&str, String)], +) -> Result<(), RuntimeError> { + let borrowed = rows + .iter() + .map(|(label, value)| (*label, value.as_str())) + .collect::<Vec<_>>(); + render_pairs(stdout, heading, borrowed.as_slice()) +} + +fn account_pairs( + account: &AccountSummaryView, + public_identity: Option<&crate::domain::runtime::IdentityPublicView>, +) -> Vec<(&'static str, String)> { + let mut rows = vec![ + ("account id", account.id.clone()), + ("signer", account.signer.clone()), + ("default", yes_no(account.is_default).to_owned()), + ]; + if let Some(display_name) = &account.display_name { + rows.insert(1, ("display name", display_name.clone())); + } + if let Some(public_identity) = public_identity { + rows.push(("public key npub", public_identity.public_key_npub.clone())); + rows.push(("public key hex", public_identity.public_key_hex.clone())); + } + rows +} + fn render_local_signer( stdout: &mut dyn Write, heading: &str, @@ -333,7 +440,6 @@ fn render_myc_custody_identity( Ok(()) } -#[allow(dead_code)] fn render_table(stdout: &mut dyn Write, table: &Table) -> Result<(), RuntimeError> { let mut widths: Vec<usize> = table.headers.iter().map(|header| header.len()).collect(); for row in &table.rows { @@ -365,7 +471,6 @@ fn render_table(stdout: &mut dyn Write, table: &Table) -> Result<(), RuntimeErro Ok(()) } -#[allow(dead_code)] struct Table { headers: &'static [&'static str], rows: Vec<Vec<String>>, @@ -373,7 +478,9 @@ 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::ConfigShow(_) => "config show", CommandView::Doctor(_) => "doctor", @@ -387,11 +494,11 @@ mod tests { use super::{Table, render_human_to, render_ndjson_to, render_table}; use crate::commands::runtime; use crate::domain::runtime::{ - CommandOutput, CommandView, DoctorCheckView, DoctorView, MycStatusView, + AccountListView, CommandOutput, CommandView, DoctorCheckView, DoctorView, MycStatusView, }; use crate::runtime::config::{ - IdentityConfig, LoggingConfig, MycConfig, OutputConfig, OutputFormat, PathsConfig, - RuntimeConfig, SignerBackend, SignerConfig, Verbosity, + AccountConfig, IdentityConfig, LoggingConfig, MycConfig, OutputConfig, OutputFormat, + PathsConfig, RuntimeConfig, SignerBackend, SignerConfig, Verbosity, }; use crate::runtime::logging::LoggingState; @@ -415,6 +522,11 @@ mod tests { directory: None, stdout: false, }, + account: AccountConfig { + selector: Some("acct_demo".into()), + store_path: "/home/tester/.local/share/radroots/accounts/store.json".into(), + secrets_dir: "/home/tester/.local/share/radroots/accounts/secrets".into(), + }, identity: IdentityConfig { path: "identity.json".into(), }, @@ -435,7 +547,12 @@ mod tests { view.paths.workspace_config_path, "/workspace/.radroots/config.toml" ); - assert_eq!(view.account.identity_path, "identity.json"); + assert_eq!(view.account.selector.as_deref(), Some("acct_demo")); + assert!( + view.account + .store_path + .ends_with(".local/share/radroots/accounts/store.json") + ); } #[test] @@ -478,6 +595,11 @@ mod tests { directory: None, stdout: false, }, + account: AccountConfig { + selector: None, + store_path: "/home/tester/.local/share/radroots/accounts/store.json".into(), + secrets_dir: "/home/tester/.local/share/radroots/accounts/secrets".into(), + }, identity: IdentityConfig { path: "identity.json".into(), }, @@ -503,6 +625,36 @@ mod tests { } #[test] + fn account_list_ndjson_emits_one_json_object_per_account() { + let output = CommandOutput::success(CommandView::AccountList(AccountListView { + source: "local account store · local first".to_owned(), + count: 2, + accounts: vec![ + crate::domain::runtime::AccountSummaryView { + id: "acct_a".to_owned(), + display_name: Some("Alpha".to_owned()), + signer: "local".to_owned(), + is_default: true, + }, + crate::domain::runtime::AccountSummaryView { + id: "acct_b".to_owned(), + display_name: None, + signer: "local".to_owned(), + is_default: false, + }, + ], + actions: Vec::new(), + })); + let mut buffer = Vec::new(); + render_ndjson_to(&mut buffer, &output).expect("render ndjson"); + let rendered = String::from_utf8(buffer).expect("utf8"); + let lines = rendered.lines().collect::<Vec<_>>(); + assert_eq!(lines.len(), 2); + assert!(lines[0].contains("\"id\":\"acct_a\"")); + assert!(lines[1].contains("\"id\":\"acct_b\"")); + } + + #[test] fn human_render_doctor_uses_check_table_and_actions() { let output = CommandOutput::unconfigured(CommandView::Doctor(DoctorView { ok: false, @@ -516,7 +668,7 @@ mod tests { DoctorCheckView { name: "account".to_owned(), status: "warn".to_owned(), - detail: "no local account at identity.json".to_owned(), + detail: "no local account in store".to_owned(), }, ], source: "local diagnostics".to_owned(), diff --git a/src/runtime/accounts.rs b/src/runtime/accounts.rs @@ -0,0 +1,278 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use radroots_identity::RadrootsIdentityId; +use radroots_nostr_accounts::prelude::{ + RadrootsNostrAccountRecord, RadrootsNostrAccountsManager, RadrootsNostrFileAccountStore, + RadrootsNostrSecretVault, RadrootsNostrSelectedAccountStatus, +}; + +use crate::runtime::RuntimeError; +use crate::runtime::config::RuntimeConfig; + +#[derive(Debug, Clone)] +pub struct AccountSnapshot { + pub accounts: Vec<AccountRecordView>, +} + +#[derive(Debug, Clone)] +pub struct AccountRecordView { + pub record: RadrootsNostrAccountRecord, + pub selected: bool, + pub signer: &'static str, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AccountCreateMode { + Created, + Migrated, +} + +#[derive(Debug, Clone)] +pub struct AccountCreateResult { + pub mode: AccountCreateMode, + pub account: AccountRecordView, +} + +pub fn create_or_migrate_selected_account( + config: &RuntimeConfig, +) -> Result<AccountCreateResult, RuntimeError> { + let manager = account_manager(config)?; + let existing = manager.list_accounts()?; + let mode = if existing.is_empty() && config.identity.path.exists() { + manager.migrate_legacy_identity_file(&config.identity.path, None, true)?; + AccountCreateMode::Migrated + } else { + manager.generate_identity(None, true)?; + AccountCreateMode::Created + }; + + let snapshot = snapshot(config)?; + let Some(account) = snapshot + .accounts + .into_iter() + .find(|account| account.selected) + else { + return Err(RuntimeError::Accounts( + radroots_nostr_accounts::prelude::RadrootsNostrAccountsError::InvalidState( + "selected account missing after account create".to_owned(), + ), + )); + }; + + Ok(AccountCreateResult { mode, account }) +} + +pub fn snapshot(config: &RuntimeConfig) -> Result<AccountSnapshot, RuntimeError> { + let manager = account_manager(config)?; + snapshot_from_manager(&manager) +} + +pub fn resolve_account(config: &RuntimeConfig) -> Result<Option<AccountRecordView>, RuntimeError> { + let snapshot = snapshot(config)?; + if let Some(selector) = config.account.selector.as_deref() { + let Some(account) = find_by_selector(snapshot.accounts.as_slice(), selector) else { + return Err(RuntimeError::Config(format!( + "account selector `{selector}` did not match any local account" + ))); + }; + return Ok(Some(account.clone())); + } + + Ok(snapshot + .accounts + .into_iter() + .find(|account| account.selected)) +} + +pub fn select_account( + config: &RuntimeConfig, + selector: &str, +) -> Result<AccountRecordView, RuntimeError> { + let manager = account_manager(config)?; + let snapshot = snapshot_from_manager(&manager)?; + let Some(account) = find_by_selector(snapshot.accounts.as_slice(), selector) else { + return Err(RuntimeError::Config(format!( + "account selector `{selector}` did not match any local account" + ))); + }; + + manager.select_account(&account.record.account_id)?; + let snapshot = snapshot_from_manager(&manager)?; + snapshot + .accounts + .into_iter() + .find(|candidate| candidate.record.account_id == account.record.account_id) + .ok_or_else(|| { + RuntimeError::Accounts( + radroots_nostr_accounts::prelude::RadrootsNostrAccountsError::InvalidState( + "selected account missing after account use".to_owned(), + ), + ) + }) +} + +pub fn selected_account_status( + config: &RuntimeConfig, +) -> Result<RadrootsNostrSelectedAccountStatus, RuntimeError> { + let manager = account_manager(config)?; + if let Some(selector) = config.account.selector.as_deref() { + let snapshot = snapshot_from_manager(&manager)?; + let Some(account) = find_by_selector(snapshot.accounts.as_slice(), selector) else { + return Err(RuntimeError::Config(format!( + "account selector `{selector}` did not match any local account" + ))); + }; + + return Ok( + match secret_file_path(&config.account.secrets_dir, &account.record.account_id).exists() + { + true => RadrootsNostrSelectedAccountStatus::Ready { + account: account.record.clone(), + }, + false => RadrootsNostrSelectedAccountStatus::PublicOnly { + account: account.record.clone(), + }, + }, + ); + } + + Ok(manager.selected_account_status()?) +} + +fn snapshot_from_manager( + manager: &RadrootsNostrAccountsManager, +) -> Result<AccountSnapshot, RuntimeError> { + let selected_account_id = manager.selected_account_id()?.map(|id| id.to_string()); + let accounts = manager + .list_accounts()? + .into_iter() + .map(|record| AccountRecordView { + selected: selected_account_id + .as_deref() + .is_some_and(|selected| selected == record.account_id.as_str()), + signer: "local", + record, + }) + .collect(); + + Ok(AccountSnapshot { accounts }) +} + +fn find_by_selector<'a>( + accounts: &'a [AccountRecordView], + selector: &str, +) -> Option<&'a AccountRecordView> { + let normalized = selector.trim(); + if normalized.is_empty() { + return None; + } + + accounts.iter().find(|account| { + account.record.account_id.as_str() == normalized + || account.record.public_identity.public_key_npub == normalized + || account.record.label.as_deref() == Some(normalized) + }) +} + +fn account_manager(config: &RuntimeConfig) -> Result<RadrootsNostrAccountsManager, RuntimeError> { + let store = Arc::new(RadrootsNostrFileAccountStore::new( + config.account.store_path.as_path(), + )); + let vault = Arc::new(CliFileSecretVault::new( + config.account.secrets_dir.as_path(), + )); + Ok(RadrootsNostrAccountsManager::new(store, vault)?) +} + +#[derive(Debug, Clone)] +struct CliFileSecretVault { + secrets_dir: PathBuf, +} + +impl CliFileSecretVault { + fn new(path: impl AsRef<Path>) -> Self { + Self { + secrets_dir: path.as_ref().to_path_buf(), + } + } +} + +impl RadrootsNostrSecretVault for CliFileSecretVault { + fn store_secret_hex( + &self, + account_id: &RadrootsIdentityId, + secret_key_hex: &str, + ) -> Result<(), radroots_nostr_accounts::prelude::RadrootsNostrAccountsError> { + fs::create_dir_all(&self.secrets_dir).map_err(|source| { + radroots_nostr_accounts::prelude::RadrootsNostrAccountsError::Vault(source.to_string()) + })?; + let path = secret_file_path(&self.secrets_dir, account_id); + fs::write(&path, secret_key_hex.as_bytes()).map_err(|source| { + radroots_nostr_accounts::prelude::RadrootsNostrAccountsError::Vault(source.to_string()) + })?; + set_secret_permissions(&path)?; + Ok(()) + } + + fn load_secret_hex( + &self, + account_id: &RadrootsIdentityId, + ) -> Result<Option<String>, radroots_nostr_accounts::prelude::RadrootsNostrAccountsError> { + let path = secret_file_path(&self.secrets_dir, account_id); + match fs::read_to_string(path) { + Ok(contents) => Ok(Some(contents.trim().to_owned())), + Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(source) => Err( + radroots_nostr_accounts::prelude::RadrootsNostrAccountsError::Vault( + source.to_string(), + ), + ), + } + } + + fn remove_secret( + &self, + account_id: &RadrootsIdentityId, + ) -> Result<(), radroots_nostr_accounts::prelude::RadrootsNostrAccountsError> { + let path = secret_file_path(&self.secrets_dir, account_id); + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(source) => Err( + radroots_nostr_accounts::prelude::RadrootsNostrAccountsError::Vault( + source.to_string(), + ), + ), + } + } +} + +fn secret_file_path(secrets_dir: &Path, account_id: &RadrootsIdentityId) -> PathBuf { + secrets_dir.join(format!("{}.secret", account_id)) +} + +#[cfg(unix)] +fn set_secret_permissions( + path: &Path, +) -> Result<(), radroots_nostr_accounts::prelude::RadrootsNostrAccountsError> { + use std::os::unix::fs::PermissionsExt; + + let mut permissions = fs::metadata(path) + .map_err(|source| { + radroots_nostr_accounts::prelude::RadrootsNostrAccountsError::Vault(source.to_string()) + })? + .permissions(); + permissions.set_mode(0o600); + fs::set_permissions(path, permissions).map_err(|source| { + radroots_nostr_accounts::prelude::RadrootsNostrAccountsError::Vault(source.to_string()) + }) +} + +#[cfg(not(unix))] +fn set_secret_permissions( + _path: &Path, +) -> Result<(), radroots_nostr_accounts::prelude::RadrootsNostrAccountsError> { + Ok(()) +} diff --git a/src/runtime/config.rs b/src/runtime/config.rs @@ -19,6 +19,7 @@ const ENV_CLI_LOG_STDOUT: &str = "RADROOTS_CLI_LOGGING_STDOUT"; const ENV_LOG_FILTER: &str = "RADROOTS_LOG_FILTER"; const ENV_LOG_DIR: &str = "RADROOTS_LOG_DIR"; const ENV_LOG_STDOUT: &str = "RADROOTS_LOG_STDOUT"; +const ENV_ACCOUNT: &str = "RADROOTS_ACCOUNT"; const ENV_IDENTITY_PATH: &str = "RADROOTS_IDENTITY_PATH"; const ENV_SIGNER_BACKEND: &str = "RADROOTS_SIGNER_BACKEND"; const ENV_MYC_EXECUTABLE: &str = "RADROOTS_MYC_EXECUTABLE"; @@ -30,6 +31,7 @@ const SUPPORTED_ENV_FILE_KEYS: &[&str] = &[ ENV_LOG_FILTER, ENV_LOG_DIR, ENV_LOG_STDOUT, + ENV_ACCOUNT, ENV_IDENTITY_PATH, ENV_SIGNER_BACKEND, ENV_MYC_EXECUTABLE, @@ -91,6 +93,13 @@ pub struct IdentityConfig { pub path: PathBuf, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AccountConfig { + pub selector: Option<String>, + pub store_path: PathBuf, + pub secrets_dir: PathBuf, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SignerBackend { Local, @@ -121,6 +130,7 @@ pub struct RuntimeConfig { pub output: OutputConfig, pub paths: PathsConfig, pub logging: LoggingConfig, + pub account: AccountConfig, pub identity: IdentityConfig, pub signer: SignerConfig, pub myc: MycConfig, @@ -173,6 +183,7 @@ impl RuntimeConfig { env: &dyn Environment, env_file: &EnvFileValues, ) -> Result<Self, RuntimeError> { + let paths = resolve_paths(env)?; Ok(Self { output: OutputConfig { format: resolve_output_format(args, env, env_file)?, @@ -180,7 +191,7 @@ impl RuntimeConfig { color: !args.no_color, dry_run: args.dry_run, }, - paths: resolve_paths(env)?, + paths: paths.clone(), logging: LoggingConfig { filter: args .log_filter @@ -201,6 +212,14 @@ impl RuntimeConfig { "--no-log-stdout", )?, }, + account: AccountConfig { + selector: args + .account + .clone() + .or_else(|| env_value(env, env_file, &[ENV_ACCOUNT])), + store_path: paths.user_state_root.join("accounts/store.json"), + secrets_dir: paths.user_state_root.join("accounts/secrets"), + }, identity: IdentityConfig { path: args .identity_path @@ -432,8 +451,8 @@ fn parse_bool_env(key: &str, value: &str) -> Result<bool, RuntimeError> { #[cfg(test)] mod tests { use super::{ - EnvFileValues, Environment, OutputConfig, OutputFormat, PathsConfig, RuntimeConfig, - SignerBackend, Verbosity, parse_env_file_values, + AccountConfig, EnvFileValues, Environment, OutputConfig, OutputFormat, PathsConfig, + RuntimeConfig, SignerBackend, Verbosity, parse_env_file_values, }; use crate::cli::CliArgs; use clap::Parser; @@ -529,6 +548,14 @@ mod tests { resolved.identity.path, PathBuf::from("custom-identity.json") ); + assert_eq!( + resolved.account, + AccountConfig { + selector: None, + store_path: PathBuf::from("/home/tester/.local/share/radroots/accounts/store.json"), + secrets_dir: PathBuf::from("/home/tester/.local/share/radroots/accounts/secrets"), + } + ); assert_eq!(resolved.signer.backend, SignerBackend::Local); assert_eq!(resolved.myc.executable, PathBuf::from("bin/myc-cli")); } @@ -544,6 +571,7 @@ mod tests { ), ("RADROOTS_LOG_DIR".to_owned(), "logs/runtime".to_owned()), ("RADROOTS_LOG_STDOUT".to_owned(), "true".to_owned()), + ("RADROOTS_ACCOUNT".to_owned(), "acct_demo".to_owned()), ( "RADROOTS_IDENTITY_PATH".to_owned(), "state/identity.json".to_owned(), @@ -569,6 +597,7 @@ mod tests { Some(PathBuf::from("logs/runtime")) ); assert!(resolved.logging.stdout); + assert_eq!(resolved.account.selector.as_deref(), Some("acct_demo")); assert_eq!(resolved.identity.path, PathBuf::from("state/identity.json")); assert_eq!(resolved.signer.backend, SignerBackend::Myc); assert_eq!(resolved.myc.executable, PathBuf::from("bin/myc")); @@ -640,6 +669,7 @@ RADROOTS_OUTPUT=json RADROOTS_CLI_LOGGING_FILTER="debug,radroots_cli=trace" RADROOTS_CLI_LOGGING_OUTPUT_DIR=/tmp/radroots-cli-logs RADROOTS_CLI_LOGGING_STDOUT=false +RADROOTS_ACCOUNT=acct_env_file RADROOTS_IDENTITY_PATH=state/identity.json RADROOTS_SIGNER_BACKEND=myc RADROOTS_MYC_EXECUTABLE=bin/myc @@ -657,6 +687,7 @@ RADROOTS_MYC_EXECUTABLE=bin/myc Some(PathBuf::from("/tmp/radroots-cli-logs")) ); assert!(!resolved.logging.stdout); + assert_eq!(resolved.account.selector.as_deref(), Some("acct_env_file")); assert_eq!(resolved.identity.path, PathBuf::from("state/identity.json")); assert_eq!(resolved.signer.backend, SignerBackend::Myc); assert_eq!(resolved.myc.executable, PathBuf::from("bin/myc")); diff --git a/src/runtime/identity.rs b/src/runtime/identity.rs @@ -1,32 +0,0 @@ -use std::path::PathBuf; - -use radroots_identity::{RadrootsIdentity, RadrootsIdentityPublic}; - -use crate::runtime::RuntimeError; -use crate::runtime::config::IdentityConfig; - -#[derive(Debug, Clone)] -pub struct IdentityRecord { - pub path: PathBuf, - pub public_identity: RadrootsIdentityPublic, - pub created: bool, -} - -pub fn initialize_identity(config: &IdentityConfig) -> Result<IdentityRecord, RuntimeError> { - let created = !config.path.exists(); - let identity = RadrootsIdentity::load_or_generate(Some(config.path.as_path()), true)?; - Ok(IdentityRecord { - path: config.path.clone(), - public_identity: identity.to_public(), - created, - }) -} - -pub fn load_identity(config: &IdentityConfig) -> Result<IdentityRecord, RuntimeError> { - let identity = RadrootsIdentity::load_from_path_auto(config.path.as_path())?; - Ok(IdentityRecord { - path: config.path.clone(), - public_identity: identity.to_public(), - created: false, - }) -} diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs @@ -1,5 +1,5 @@ +pub mod accounts; pub mod config; -pub mod identity; pub mod logging; pub mod myc; pub mod signer; @@ -12,8 +12,8 @@ pub enum RuntimeError { Config(String), #[error("failed to initialize logging: {0}")] Logging(#[from] radroots_log::Error), - #[error("identity error: {0}")] - Identity(#[from] radroots_identity::IdentityError), + #[error("accounts error: {0}")] + Accounts(#[from] radroots_nostr_accounts::prelude::RadrootsNostrAccountsError), #[error("failed to serialize json output: {0}")] Json(#[from] serde_json::Error), #[error("failed to write output: {0}")] @@ -24,7 +24,7 @@ impl RuntimeError { pub fn exit_code(&self) -> ExitCode { match self { Self::Config(_) => ExitCode::from(2), - Self::Logging(_) | Self::Identity(_) | Self::Json(_) | Self::Io(_) => ExitCode::from(1), + Self::Logging(_) | Self::Accounts(_) | Self::Json(_) | Self::Io(_) => ExitCode::from(1), } } } diff --git a/src/runtime/signer.rs b/src/runtime/signer.rs @@ -1,6 +1,6 @@ use crate::domain::runtime::{IdentityPublicView, LocalSignerStatusView, SignerStatusView}; use crate::runtime::config::{RuntimeConfig, SignerBackend}; -use radroots_identity::IdentityError; +use radroots_nostr_accounts::prelude::RadrootsNostrSelectedAccountStatus; use radroots_nostr_signer::prelude::{ RadrootsNostrLocalSignerAvailability, RadrootsNostrLocalSignerCapability, RadrootsNostrSignerCapability, @@ -14,12 +14,12 @@ pub fn resolve_signer_status(config: &RuntimeConfig) -> SignerStatusView { } fn resolve_local_signer_status(config: &RuntimeConfig) -> SignerStatusView { - match crate::runtime::identity::load_identity(&config.identity) { - Ok(identity) => { + match crate::runtime::accounts::selected_account_status(config) { + Ok(RadrootsNostrSelectedAccountStatus::Ready { account }) => { let capability = RadrootsNostrSignerCapability::LocalAccount( RadrootsNostrLocalSignerCapability::new( - identity.public_identity.id.clone(), - identity.public_identity.clone(), + account.account_id.clone(), + account.public_identity.clone(), RadrootsNostrLocalSignerAvailability::SecretBacked, ), ); @@ -42,18 +42,32 @@ fn resolve_local_signer_status(config: &RuntimeConfig) -> SignerStatusView { myc: None, } } - Err(crate::runtime::RuntimeError::Identity(IdentityError::NotFound(path))) => { - SignerStatusView { - backend: config.signer.backend.as_str().to_owned(), - state: "unconfigured".to_owned(), - reason: Some(format!( - "local identity file was not found at {}", - path.display() - )), - local: None, - myc: None, - } - } + Ok(RadrootsNostrSelectedAccountStatus::PublicOnly { account }) => SignerStatusView { + backend: config.signer.backend.as_str().to_owned(), + state: "unconfigured".to_owned(), + reason: Some(format!( + "local account {} is present but not secret-backed", + account.account_id + )), + local: Some(LocalSignerStatusView { + account_id: account.account_id.to_string(), + public_identity: IdentityPublicView::from_public_identity(&account.public_identity), + availability: local_availability(RadrootsNostrLocalSignerAvailability::PublicOnly) + .to_owned(), + secret_backed: false, + }), + myc: None, + }, + Ok(RadrootsNostrSelectedAccountStatus::NotConfigured) => SignerStatusView { + backend: config.signer.backend.as_str().to_owned(), + state: "unconfigured".to_owned(), + reason: Some(format!( + "no local account is selected in {}", + config.account.store_path.display() + )), + local: None, + myc: None, + }, Err(error) => SignerStatusView { backend: config.signer.backend.as_str().to_owned(), state: "error".to_owned(), diff --git a/tests/doctor.rs b/tests/doctor.rs @@ -19,6 +19,7 @@ fn doctor_command_in(workdir: &Path) -> Command { "RADROOTS_LOG_FILTER", "RADROOTS_LOG_DIR", "RADROOTS_LOG_STDOUT", + "RADROOTS_ACCOUNT", "RADROOTS_IDENTITY_PATH", "RADROOTS_SIGNER_BACKEND", "RADROOTS_MYC_EXECUTABLE", @@ -54,26 +55,14 @@ fn doctor_reports_unconfigured_local_bootstrap_state() { #[test] fn doctor_reports_ready_local_bootstrap_state() { let dir = tempdir().expect("tempdir"); - let identity_path = dir.path().join("identity.json"); let init = doctor_command_in(dir.path()) - .args([ - "--json", - "--identity-path", - identity_path.to_str().expect("identity path"), - "account", - "new", - ]) + .args(["--json", "account", "new"]) .output() .expect("run account new"); assert!(init.status.success()); let output = doctor_command_in(dir.path()) - .args([ - "--json", - "--identity-path", - identity_path.to_str().expect("identity path"), - "doctor", - ]) + .args(["--json", "doctor"]) .output() .expect("run doctor"); diff --git a/tests/identity_commands.rs b/tests/identity_commands.rs @@ -8,6 +8,7 @@ use tempfile::tempdir; fn cli_command_in(workdir: &Path) -> Command { let mut command = Command::cargo_bin("radroots").expect("binary"); command.current_dir(workdir); + command.env("HOME", workdir.join("home")); for key in [ "RADROOTS_ENV_FILE", "RADROOTS_OUTPUT", @@ -17,6 +18,7 @@ fn cli_command_in(workdir: &Path) -> Command { "RADROOTS_LOG_FILTER", "RADROOTS_LOG_DIR", "RADROOTS_LOG_STDOUT", + "RADROOTS_ACCOUNT", "RADROOTS_IDENTITY_PATH", "RADROOTS_SIGNER_BACKEND", "RADROOTS_MYC_EXECUTABLE", @@ -27,123 +29,170 @@ fn cli_command_in(workdir: &Path) -> Command { } #[test] -fn account_new_json_creates_identity_file() { +fn account_new_json_creates_local_account_store_entry() { let dir = tempdir().expect("tempdir"); - let identity_path = dir.path().join("identity.json"); + let store_path = dir + .path() + .join("home/.local/share/radroots/accounts/store.json"); let output = cli_command_in(dir.path()) - .args([ - "--json", - "--identity-path", - identity_path.to_str().expect("identity path"), - "account", - "new", - ]) + .args(["--json", "account", "new"]) .output() .expect("run account new"); assert!(output.status.success()); - assert!(identity_path.exists()); + assert!(store_path.exists()); let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); let json: Value = serde_json::from_str(stdout.as_str()).expect("json output"); - assert_eq!(json["path"], identity_path.display().to_string()); - assert_eq!(json["created"], true); + assert_eq!(json["state"], "created"); + assert_eq!(json["source"], "local account store · local first"); + assert!(json["account"]["id"].is_string()); + assert_eq!(json["account"]["signer"], "local"); + assert_eq!(json["account"]["is_default"], true); assert!(json["public_identity"]["id"].is_string()); assert!(json["public_identity"]["public_key_hex"].is_string()); assert!(json["public_identity"]["public_key_npub"].is_string()); - assert!(json.get("secret_key").is_none()); } #[test] -fn account_new_rejects_dry_run_without_creating_identity() { +fn account_new_rejects_dry_run_without_creating_store_state() { let dir = tempdir().expect("tempdir"); - let identity_path = dir.path().join("identity.json"); + let store_path = dir + .path() + .join("home/.local/share/radroots/accounts/store.json"); let output = cli_command_in(dir.path()) - .args([ - "--dry-run", - "--identity-path", - identity_path.to_str().expect("identity path"), - "account", - "new", - ]) + .args(["--dry-run", "account", "new"]) .output() .expect("run account new"); assert_eq!(output.status.code(), Some(2)); - assert!(!identity_path.exists()); + assert!(!store_path.exists()); assert!(output.stdout.is_empty()); let stderr = String::from_utf8(output.stderr).expect("utf8 stderr"); assert!(stderr.contains("`account new` does not support --dry-run yet")); } #[test] -fn account_whoami_json_reads_existing_public_identity() { +fn account_whoami_json_reads_selected_account() { let dir = tempdir().expect("tempdir"); - let identity_path = dir.path().join("identity.json"); let init = cli_command_in(dir.path()) - .args([ - "--json", - "--identity-path", - identity_path.to_str().expect("identity path"), - "account", - "new", - ]) + .args(["--json", "account", "new"]) .output() .expect("run account new"); assert!(init.status.success()); let output = cli_command_in(dir.path()) - .args([ - "--json", - "--identity-path", - identity_path.to_str().expect("identity path"), - "account", - "whoami", - ]) + .args(["--json", "account", "whoami"]) .output() .expect("run account whoami"); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); let json: Value = serde_json::from_str(stdout.as_str()).expect("json output"); - assert_eq!(json["path"], identity_path.display().to_string()); assert_eq!(json["state"], "ready"); + assert_eq!(json["source"], "local account store · local first"); + assert!(json["account"]["id"].is_string()); + assert_eq!(json["account"]["signer"], "local"); + assert_eq!(json["account"]["is_default"], true); assert!(json["public_identity"]["id"].is_string()); - assert!(json["public_identity"]["public_key_hex"].is_string()); - assert!(json["public_identity"]["public_key_npub"].is_string()); - assert!(json.get("secret_key").is_none()); } #[test] -fn account_whoami_json_reports_unconfigured_without_creating_identity() { +fn account_whoami_json_reports_unconfigured_without_accounts() { let dir = tempdir().expect("tempdir"); - let identity_path = dir.path().join("missing-identity.json"); let output = cli_command_in(dir.path()) - .args([ - "--json", - "--identity-path", - identity_path.to_str().expect("identity path"), - "account", - "whoami", - ]) + .args(["--json", "account", "whoami"]) .output() .expect("run account whoami"); assert_eq!(output.status.code(), Some(3)); - assert!(!identity_path.exists()); let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); let json: Value = serde_json::from_str(stdout.as_str()).expect("json output"); - assert_eq!(json["path"], identity_path.display().to_string()); assert_eq!(json["state"], "unconfigured"); + assert_eq!(json["account"], Value::Null); + assert_eq!(json["public_identity"], Value::Null); assert!( json["reason"] .as_str() - .is_some_and(|value| value.contains("local identity file was not found")) + .is_some_and(|value| value.contains("no local account is selected")) ); - assert_eq!(json.get("public_identity"), None); +} + +#[test] +fn account_ls_ndjson_emits_one_line_per_account() { + let dir = tempdir().expect("tempdir"); + + let first = cli_command_in(dir.path()) + .args(["--json", "account", "new"]) + .output() + .expect("run first account new"); + assert!(first.status.success()); + + 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(["--ndjson", "account", "ls"]) + .output() + .expect("run account ls"); + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); + let lines = stdout.lines().collect::<Vec<_>>(); + assert_eq!(lines.len(), 2); + assert!(lines[0].contains("\"id\":")); + assert!(lines[1].contains("\"id\":")); +} + +#[test] +fn account_use_selects_existing_account() { + let dir = tempdir().expect("tempdir"); + + 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 account 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 use_output = cli_command_in(dir.path()) + .args(["--json", "account", "use", first_id.as_str()]) + .output() + .expect("run account use"); + + assert!(use_output.status.success()); + let use_json: Value = + serde_json::from_slice(use_output.stdout.as_slice()).expect("account use json"); + assert_eq!(use_json["state"], "active"); + assert_eq!(use_json["active_account_id"], first_id); + assert_eq!(use_json["account"]["is_default"], true); + + 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("account whoami json"); + assert_eq!(whoami_json["account"]["id"], first_id); + assert_eq!(whoami_json["account"]["is_default"], true); } diff --git a/tests/myc_status.rs b/tests/myc_status.rs @@ -12,6 +12,7 @@ use tempfile::tempdir; fn cli_command_in(workdir: &Path) -> Command { let mut command = Command::cargo_bin("radroots").expect("binary"); command.current_dir(workdir); + command.env("HOME", workdir.join("home")); for key in [ "RADROOTS_ENV_FILE", "RADROOTS_OUTPUT", @@ -21,6 +22,7 @@ fn cli_command_in(workdir: &Path) -> Command { "RADROOTS_LOG_FILTER", "RADROOTS_LOG_DIR", "RADROOTS_LOG_STDOUT", + "RADROOTS_ACCOUNT", "RADROOTS_IDENTITY_PATH", "RADROOTS_SIGNER_BACKEND", "RADROOTS_MYC_EXECUTABLE", diff --git a/tests/runtime_show.rs b/tests/runtime_show.rs @@ -19,6 +19,7 @@ fn runtime_show_command_in(workdir: &Path) -> Command { "RADROOTS_LOG_FILTER", "RADROOTS_LOG_DIR", "RADROOTS_LOG_STDOUT", + "RADROOTS_ACCOUNT", "RADROOTS_IDENTITY_PATH", "RADROOTS_SIGNER_BACKEND", "RADROOTS_MYC_EXECUTABLE", @@ -73,7 +74,24 @@ fn config_show_json_reports_default_bootstrap_state() { assert_eq!(json["logging"]["directory"], Value::Null); assert_eq!(json["config_files"]["user_present"], false); assert_eq!(json["config_files"]["workspace_present"], false); - assert_eq!(json["account"]["identity_path"], "identity.json"); + assert_eq!(json["account"]["selector"], Value::Null); + assert_eq!( + json["account"]["store_path"], + dir.path() + .join("home") + .join(".local/share/radroots/accounts/store.json") + .display() + .to_string() + ); + assert_eq!( + json["account"]["secrets_dir"], + dir.path() + .join("home") + .join(".local/share/radroots/accounts/secrets") + .display() + .to_string() + ); + assert_eq!(json["account"]["legacy_identity_path"], "identity.json"); assert_eq!(json["signer"]["backend"], "local"); assert_eq!(json["myc"]["executable"], "myc"); } @@ -86,6 +104,7 @@ fn config_show_json_reflects_environment_configuration() { .env("RADROOTS_LOG_FILTER", "debug") .env("RADROOTS_LOG_DIR", "logs/runtime") .env("RADROOTS_LOG_STDOUT", "false") + .env("RADROOTS_ACCOUNT", "acct_demo") .env("RADROOTS_IDENTITY_PATH", "state/identity.json") .env("RADROOTS_SIGNER_BACKEND", "myc") .env("RADROOTS_MYC_EXECUTABLE", "bin/myc") @@ -99,7 +118,11 @@ fn config_show_json_reflects_environment_configuration() { assert_eq!(json["output"]["format"], "json"); assert_eq!(json["logging"]["filter"], "debug"); assert_eq!(json["logging"]["directory"], "logs/runtime"); - assert_eq!(json["account"]["identity_path"], "state/identity.json"); + assert_eq!(json["account"]["selector"], "acct_demo"); + assert_eq!( + json["account"]["legacy_identity_path"], + "state/identity.json" + ); assert_eq!(json["signer"]["backend"], "myc"); assert_eq!(json["myc"]["executable"], "bin/myc"); } diff --git a/tests/signer_status.rs b/tests/signer_status.rs @@ -9,6 +9,7 @@ use tempfile::tempdir; fn cli_command_in(workdir: &Path) -> Command { let mut command = Command::cargo_bin("radroots").expect("binary"); command.current_dir(workdir); + command.env("HOME", workdir.join("home")); for key in [ "RADROOTS_ENV_FILE", "RADROOTS_OUTPUT", @@ -18,6 +19,7 @@ fn cli_command_in(workdir: &Path) -> Command { "RADROOTS_LOG_FILTER", "RADROOTS_LOG_DIR", "RADROOTS_LOG_STDOUT", + "RADROOTS_ACCOUNT", "RADROOTS_IDENTITY_PATH", "RADROOTS_SIGNER_BACKEND", "RADROOTS_MYC_EXECUTABLE", @@ -28,32 +30,17 @@ fn cli_command_in(workdir: &Path) -> Command { } #[test] -fn signer_status_reports_local_ready_when_identity_exists() { +fn signer_status_reports_local_ready_when_account_exists() { let dir = tempdir().expect("tempdir"); - let identity_path = dir.path().join("identity.json"); let init = cli_command_in(dir.path()) - .args([ - "--json", - "--identity-path", - identity_path.to_str().expect("identity path"), - "account", - "new", - ]) + .args(["--json", "account", "new"]) .output() .expect("run account new"); assert!(init.status.success()); let output = cli_command_in(dir.path()) - .args([ - "--json", - "--identity-path", - identity_path.to_str().expect("identity path"), - "--signer-backend", - "local", - "signer", - "status", - ]) + .args(["--json", "--signer-backend", "local", "signer", "status"]) .output() .expect("run signer status"); @@ -68,20 +55,11 @@ fn signer_status_reports_local_ready_when_identity_exists() { } #[test] -fn signer_status_reports_local_unconfigured_when_identity_is_missing() { +fn signer_status_reports_local_unconfigured_when_no_account_is_selected() { let dir = tempdir().expect("tempdir"); - let identity_path = dir.path().join("missing-identity.json"); let output = cli_command_in(dir.path()) - .args([ - "--json", - "--identity-path", - identity_path.to_str().expect("identity path"), - "--signer-backend", - "local", - "signer", - "status", - ]) + .args(["--json", "--signer-backend", "local", "signer", "status"]) .output() .expect("run signer status"); @@ -93,27 +71,20 @@ fn signer_status_reports_local_unconfigured_when_identity_is_missing() { assert!( json["reason"] .as_str() - .is_some_and(|value| value.contains("local identity file was not found")) + .is_some_and(|value| value.contains("no local account is selected")) ); assert_eq!(json["local"], Value::Null); } #[test] -fn signer_status_reports_internal_error_for_invalid_identity_file() { +fn signer_status_reports_internal_error_for_invalid_account_store_file() { let dir = tempdir().expect("tempdir"); - let identity_path = dir.path().join("invalid-identity.json"); - fs::write(&identity_path, "{ not valid json").expect("write invalid identity"); + let accounts_dir = dir.path().join("home/.local/share/radroots/accounts"); + fs::create_dir_all(&accounts_dir).expect("create accounts dir"); + fs::write(accounts_dir.join("store.json"), "{ not valid json").expect("write invalid store"); let output = cli_command_in(dir.path()) - .args([ - "--json", - "--identity-path", - identity_path.to_str().expect("identity path"), - "--signer-backend", - "local", - "signer", - "status", - ]) + .args(["--json", "--signer-backend", "local", "signer", "status"]) .output() .expect("run signer status"); @@ -122,10 +93,6 @@ fn signer_status_reports_internal_error_for_invalid_identity_file() { let json: Value = serde_json::from_str(stdout.as_str()).expect("json output"); assert_eq!(json["backend"], "local"); assert_eq!(json["state"], "error"); - assert!( - json["reason"] - .as_str() - .is_some_and(|value| value.contains("invalid identity JSON")) - ); + assert!(json["reason"].as_str().is_some()); assert_eq!(json["local"], Value::Null); }