commit d7b6a67dc1b5532371760afc2c7da566dd50b35a
parent 9c18ca6145da99a828a29b39fdce53b3a5e3d2f5
Author: triesap <tyson@radroots.org>
Date: Tue, 7 Apr 2026 03:52:19 +0000
land account store operator surfaces
Diffstat:
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);
}