cli

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

commit 3a292865c8260e4b15731c224a25eb754be0ad0d
parent d1cf7fd498936e7e177732dc4afa5dcf8997df2a
Author: triesap <tyson@radroots.org>
Date:   Tue,  7 Apr 2026 23:38:16 +0000

paths: adopt interactive-user defaults in cli

Diffstat:
MCargo.lock | 10++++++++++
MCargo.toml | 1+
Msrc/commands/doctor.rs | 6+++---
Msrc/commands/runtime.rs | 17+++++++++++++----
Msrc/domain/runtime.rs | 11++++++++---
Msrc/render/mod.rs | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
Msrc/runtime/config.rs | 190++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Msrc/runtime/order.rs | 2+-
Mtests/find.rs | 14+++++++++++---
Mtests/identity_commands.rs | 30+++++++++++++++++++++---------
Mtests/listing.rs | 18++++++++++++------
Mtests/local.rs | 25+++++++++++++++----------
Mtests/order.rs | 20+++++++++++++++-----
Mtests/runtime_show.rs | 125++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Mtests/signer_status.rs | 12+++++++++++-
15 files changed, 427 insertions(+), 137 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -1658,6 +1658,7 @@ dependencies = [ "radroots-protected-store", "radroots-replica-db", "radroots-replica-sync", + "radroots-runtime-paths", "radroots-secret-vault", "radroots-sql-core", "radroots-trade", @@ -1708,6 +1709,7 @@ dependencies = [ "nostr", "radroots-events", "radroots-runtime", + "radroots-runtime-paths", "serde", "serde_json", "thiserror 1.0.69", @@ -1829,6 +1831,7 @@ dependencies = [ "getrandom 0.2.17", "radroots-log", "radroots-protected-store", + "radroots-runtime-paths", "radroots-secret-vault", "serde", "serde_json", @@ -1841,6 +1844,13 @@ dependencies = [ ] [[package]] +name = "radroots-runtime-paths" +version = "0.1.0-alpha.1" +dependencies = [ + "thiserror 1.0.69", +] + +[[package]] name = "radroots-secret-vault" version = "0.1.0-alpha.1" dependencies = [ diff --git a/Cargo.toml b/Cargo.toml @@ -31,6 +31,7 @@ radroots-nostr-signer = { path = "../lib/crates/nostr-signer" } radroots-protected-store = { path = "../lib/crates/protected-store", features = ["std"] } radroots-replica-db = { path = "../lib/crates/replica-db" } radroots-replica-sync = { path = "../lib/crates/replica-sync" } +radroots-runtime-paths = { path = "../lib/crates/runtime-paths" } radroots-secret-vault = { path = "../lib/crates/secret-vault", features = ["std", "os-keyring"] } radroots-sql-core = { path = "../lib/crates/sql-core", features = ["native"] } radroots-trade = { path = "../lib/crates/trade" } diff --git a/src/commands/doctor.rs b/src/commands/doctor.rs @@ -90,13 +90,13 @@ pub fn report( fn config_check(config: &RuntimeConfig) -> EvaluatedCheck { let detail = match ( - config.paths.user_config_path.exists(), + config.paths.app_config_path.exists(), config.paths.workspace_config_path.exists(), ) { (false, false) => "defaults active".to_owned(), - (true, false) => "user config root present".to_owned(), + (true, false) => "app config root present".to_owned(), (false, true) => "workspace config root present".to_owned(), - (true, true) => "user and workspace config roots present".to_owned(), + (true, true) => "app and workspace config roots present".to_owned(), }; EvaluatedCheck { diff --git a/src/commands/runtime.rs b/src/commands/runtime.rs @@ -21,13 +21,22 @@ pub fn show( dry_run: config.output.dry_run, }, config_files: ConfigFilesRuntimeView { - user_present: config.paths.user_config_path.exists(), + user_present: config.paths.app_config_path.exists(), workspace_present: config.paths.workspace_config_path.exists(), }, paths: PathsRuntimeView { - user_config_path: config.paths.user_config_path.display().to_string(), + profile: config.paths.profile.clone(), + app_config_path: config.paths.app_config_path.display().to_string(), workspace_config_path: config.paths.workspace_config_path.display().to_string(), - user_state_root: config.paths.user_state_root.display().to_string(), + app_data_root: config.paths.app_data_root.display().to_string(), + app_logs_root: config.paths.app_logs_root.display().to_string(), + shared_accounts_data_root: config.paths.shared_accounts_data_root.display().to_string(), + shared_accounts_secrets_root: config + .paths + .shared_accounts_secrets_root + .display() + .to_string(), + default_identity_path: config.paths.default_identity_path.display().to_string(), }, logging: LoggingRuntimeView { initialized: logging.initialized, @@ -47,7 +56,7 @@ pub fn show( 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(), + identity_path: config.identity.path.display().to_string(), secret_backend: AccountSecretRuntimeView { configured_primary: secret_backend.configured_primary, configured_fallback: secret_backend.configured_fallback, diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs @@ -145,9 +145,14 @@ pub struct LoggingRuntimeView { #[derive(Debug, Clone, Serialize)] pub struct PathsRuntimeView { - pub user_config_path: String, + pub profile: String, + pub app_config_path: String, pub workspace_config_path: String, - pub user_state_root: String, + pub app_data_root: String, + pub app_logs_root: String, + pub shared_accounts_data_root: String, + pub shared_accounts_secrets_root: String, + pub default_identity_path: String, } #[derive(Debug, Clone, Serialize)] @@ -156,7 +161,7 @@ pub struct AccountRuntimeView { pub selector: Option<String>, pub store_path: String, pub secrets_dir: String, - pub legacy_identity_path: String, + pub identity_path: String, pub secret_backend: AccountSecretRuntimeView, } diff --git a/src/render/mod.rs b/src/render/mod.rs @@ -489,7 +489,7 @@ fn render_config_show( let user_config = format!( "{} · {}", present_absent(view.config_files.user_present), - view.paths.user_config_path + view.paths.app_config_path ); let workspace_config = format!( "{} · {}", @@ -500,9 +500,23 @@ fn render_config_show( stdout, "config roots", &[ - ("user config", user_config.as_str()), + ("profile", view.paths.profile.as_str()), + ("app config", user_config.as_str()), ("workspace config", workspace_config.as_str()), - ("user state root", view.paths.user_state_root.as_str()), + ("app data root", view.paths.app_data_root.as_str()), + ("app logs root", view.paths.app_logs_root.as_str()), + ( + "shared accounts data", + view.paths.shared_accounts_data_root.as_str(), + ), + ( + "shared accounts secrets", + view.paths.shared_accounts_secrets_root.as_str(), + ), + ( + "default identity path", + view.paths.default_identity_path.as_str(), + ), ], )?; @@ -525,10 +539,7 @@ fn render_config_show( "secret backend", view.account.secret_backend.configured_primary.as_str(), ), - ( - "legacy import path", - view.account.legacy_identity_path.as_str(), - ), + ("identity path", view.account.identity_path.as_str()), ]; if let Some(fallback) = &view.account.secret_backend.configured_fallback { account_rows.push(("secret fallback", fallback.as_str())); @@ -2066,9 +2077,16 @@ mod tests { dry_run: false, }, paths: PathsConfig { - user_config_path: "/home/tester/.config/radroots/config.toml".into(), + profile: "interactive_user".into(), + app_config_path: "/home/tester/.radroots/config/apps/cli/config.toml".into(), workspace_config_path: "/workspace/.radroots/config.toml".into(), - user_state_root: "/home/tester/.local/share/radroots".into(), + app_data_root: "/home/tester/.radroots/data/apps/cli".into(), + app_logs_root: "/home/tester/.radroots/logs/apps/cli".into(), + shared_accounts_data_root: "/home/tester/.radroots/data/shared/accounts".into(), + shared_accounts_secrets_root: "/home/tester/.radroots/secrets/shared/accounts" + .into(), + default_identity_path: + "/home/tester/.radroots/secrets/shared/identities/default.json".into(), }, logging: LoggingConfig { filter: "info".to_owned(), @@ -2077,13 +2095,13 @@ mod tests { }, 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(), + store_path: "/home/tester/.radroots/data/shared/accounts/store.json".into(), + secrets_dir: "/home/tester/.radroots/secrets/shared/accounts".into(), secret_backend: RadrootsSecretBackend::EncryptedFile, secret_fallback: None, }, identity: IdentityConfig { - path: "identity.json".into(), + path: "/home/tester/.radroots/secrets/shared/identities/default.json".into(), }, signer: SignerConfig { backend: SignerBackend::Local, @@ -2094,11 +2112,11 @@ mod tests { source: RelayConfigSource::WorkspaceConfig, }, local: LocalConfig { - root: "/home/tester/.local/share/radroots/replica".into(), - replica_db_path: "/home/tester/.local/share/radroots/replica/replica.sqlite" + root: "/home/tester/.radroots/data/apps/cli/replica".into(), + replica_db_path: "/home/tester/.radroots/data/apps/cli/replica/replica.sqlite" .into(), - backups_dir: "/home/tester/.local/share/radroots/replica/backups".into(), - exports_dir: "/home/tester/.local/share/radroots/replica/exports".into(), + backups_dir: "/home/tester/.radroots/data/apps/cli/replica/backups".into(), + exports_dir: "/home/tester/.radroots/data/apps/cli/replica/exports".into(), }, myc: MycConfig { executable: "myc".into(), @@ -2115,6 +2133,7 @@ mod tests { ) .expect("runtime show"); assert_eq!(view.output.format, "human"); + assert_eq!(view.paths.profile, "interactive_user"); assert_eq!( view.paths.workspace_config_path, "/workspace/.radroots/config.toml" @@ -2123,14 +2142,14 @@ mod tests { assert!( view.account .store_path - .ends_with(".local/share/radroots/accounts/store.json") + .ends_with(".radroots/data/shared/accounts/store.json") ); assert_eq!(view.relay.count, 2); assert_eq!(view.relay.publish_policy, "any"); assert!( view.local .replica_db_path - .ends_with(".local/share/radroots/replica/replica.sqlite") + .ends_with(".radroots/data/apps/cli/replica/replica.sqlite") ); } @@ -2167,9 +2186,18 @@ mod tests { dry_run: true, }, paths: PathsConfig { - user_config_path: "/home/tester/.config/radroots/config.toml".into(), + profile: "interactive_user".into(), + app_config_path: "/home/tester/.radroots/config/apps/cli/config.toml" + .into(), workspace_config_path: "/workspace/.radroots/config.toml".into(), - user_state_root: "/home/tester/.local/share/radroots".into(), + app_data_root: "/home/tester/.radroots/data/apps/cli".into(), + app_logs_root: "/home/tester/.radroots/logs/apps/cli".into(), + shared_accounts_data_root: "/home/tester/.radroots/data/shared/accounts" + .into(), + shared_accounts_secrets_root: + "/home/tester/.radroots/secrets/shared/accounts".into(), + default_identity_path: + "/home/tester/.radroots/secrets/shared/identities/default.json".into(), }, logging: LoggingConfig { filter: "info".to_owned(), @@ -2178,13 +2206,14 @@ mod tests { }, 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(), + store_path: "/home/tester/.radroots/data/shared/accounts/store.json".into(), + secrets_dir: "/home/tester/.radroots/secrets/shared/accounts".into(), secret_backend: RadrootsSecretBackend::EncryptedFile, secret_fallback: None, }, identity: IdentityConfig { - path: "identity.json".into(), + path: "/home/tester/.radroots/secrets/shared/identities/default.json" + .into(), }, signer: SignerConfig { backend: SignerBackend::Local, @@ -2195,11 +2224,11 @@ mod tests { source: RelayConfigSource::Defaults, }, local: LocalConfig { - root: "/home/tester/.local/share/radroots/replica".into(), + root: "/home/tester/.radroots/data/apps/cli/replica".into(), replica_db_path: - "/home/tester/.local/share/radroots/replica/replica.sqlite".into(), - backups_dir: "/home/tester/.local/share/radroots/replica/backups".into(), - exports_dir: "/home/tester/.local/share/radroots/replica/exports".into(), + "/home/tester/.radroots/data/apps/cli/replica/replica.sqlite".into(), + backups_dir: "/home/tester/.radroots/data/apps/cli/replica/backups".into(), + exports_dir: "/home/tester/.radroots/data/apps/cli/replica/exports".into(), }, myc: MycConfig { executable: "myc".into(), diff --git a/src/runtime/config.rs b/src/runtime/config.rs @@ -3,6 +3,11 @@ use std::fs; use std::path::Path; use std::path::PathBuf; +use radroots_identity::DEFAULT_IDENTITY_PATH; +use radroots_runtime_paths::{ + DEFAULT_CONFIG_FILE_NAME, RadrootsPathOverrides, RadrootsPathProfile, RadrootsPathResolver, + RadrootsRuntimeNamespace, +}; use radroots_secret_vault::{RadrootsHostVaultPolicy, RadrootsSecretBackend}; use serde::Deserialize; use url::Url; @@ -13,12 +18,11 @@ use crate::runtime::RuntimeError; const DEFAULT_LOG_FILTER: &str = "info"; const DEFAULT_ENV_PATH: &str = ".env"; const DEFAULT_WORKSPACE_CONFIG_PATH: &str = ".radroots/config.toml"; -const DEFAULT_USER_CONFIG_PATH: &str = ".config/radroots/config.toml"; -const DEFAULT_USER_STATE_ROOT: &str = ".local/share/radroots"; const DEFAULT_LOCAL_STATE_DIR: &str = "replica"; const DEFAULT_LOCAL_DB_FILE: &str = "replica.sqlite"; const DEFAULT_LOCAL_BACKUPS_DIR: &str = "backups"; const DEFAULT_LOCAL_EXPORTS_DIR: &str = "exports"; +const DEFAULT_SHARED_ACCOUNTS_STORE_FILE: &str = "store.json"; const DEFAULT_RPC_URL: &str = "http://127.0.0.1:7070"; const ENV_FILE_PATH: &str = "RADROOTS_ENV_FILE"; const ENV_OUTPUT: &str = "RADROOTS_OUTPUT"; @@ -217,9 +221,14 @@ pub struct RuntimeConfig { #[derive(Debug, Clone, PartialEq, Eq)] pub struct PathsConfig { - pub user_config_path: PathBuf, + pub profile: String, + pub app_config_path: PathBuf, pub workspace_config_path: PathBuf, - pub user_state_root: PathBuf, + pub app_data_root: PathBuf, + pub app_logs_root: PathBuf, + pub shared_accounts_data_root: PathBuf, + pub shared_accounts_secrets_root: PathBuf, + pub default_identity_path: PathBuf, } #[derive(Debug, Default)] @@ -245,7 +254,7 @@ struct RpcFileConfig { pub trait Environment { fn var(&self, key: &str) -> Option<String>; fn current_dir(&self) -> Result<PathBuf, RuntimeError>; - fn home_dir(&self) -> Option<PathBuf>; + fn path_resolver(&self) -> RadrootsPathResolver; } pub struct SystemEnvironment; @@ -261,8 +270,8 @@ impl Environment for SystemEnvironment { }) } - fn home_dir(&self) -> Option<PathBuf> { - std::env::var_os("HOME").map(PathBuf::from) + fn path_resolver(&self) -> RadrootsPathResolver { + RadrootsPathResolver::current() } } @@ -281,7 +290,7 @@ impl RuntimeConfig { ) -> Result<Self, RuntimeError> { let paths = resolve_paths(env)?; let workspace_config = load_cli_config_file(paths.workspace_config_path.as_path())?; - let user_config = load_cli_config_file(paths.user_config_path.as_path())?; + let app_config = load_cli_config_file(paths.app_config_path.as_path())?; let account_secret_backend = resolve_account_secret_backend(args, env, env_file)? .unwrap_or(RadrootsSecretBackend::HostVault( RadrootsHostVaultPolicy::desktop(), @@ -306,7 +315,9 @@ impl RuntimeConfig { .or_else(|| env_value(env, env_file, &[ENV_CLI_LOG_FILTER, ENV_LOG_FILTER])) .unwrap_or_else(|| DEFAULT_LOG_FILTER.to_owned()), directory: args.log_dir.clone().or_else(|| { - env_value(env, env_file, &[ENV_CLI_LOG_DIR, ENV_LOG_DIR]).map(PathBuf::from) + env_value(env, env_file, &[ENV_CLI_LOG_DIR, ENV_LOG_DIR]) + .map(PathBuf::from) + .or_else(|| Some(paths.app_logs_root.clone())) }), stdout: resolve_bool_pair( args.log_stdout, @@ -324,8 +335,10 @@ impl RuntimeConfig { .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"), + store_path: paths + .shared_accounts_data_root + .join(DEFAULT_SHARED_ACCOUNTS_STORE_FILE), + secrets_dir: paths.shared_accounts_secrets_root.clone(), secret_backend: account_secret_backend, secret_fallback: account_secret_fallback, }, @@ -334,7 +347,7 @@ impl RuntimeConfig { .identity_path .clone() .or_else(|| env_value(env, env_file, &[ENV_IDENTITY_PATH]).map(PathBuf::from)) - .unwrap_or_else(|| PathBuf::from("identity.json")), + .unwrap_or_else(|| paths.default_identity_path.clone()), }, signer: SignerConfig { backend: args @@ -349,21 +362,21 @@ impl RuntimeConfig { args, env, env_file, - user_config.as_ref(), + app_config.as_ref(), workspace_config.as_ref(), )?, local: LocalConfig { - root: paths.user_state_root.join(DEFAULT_LOCAL_STATE_DIR), + root: paths.app_data_root.join(DEFAULT_LOCAL_STATE_DIR), replica_db_path: paths - .user_state_root + .app_data_root .join(DEFAULT_LOCAL_STATE_DIR) .join(DEFAULT_LOCAL_DB_FILE), backups_dir: paths - .user_state_root + .app_data_root .join(DEFAULT_LOCAL_STATE_DIR) .join(DEFAULT_LOCAL_BACKUPS_DIR), exports_dir: paths - .user_state_root + .app_data_root .join(DEFAULT_LOCAL_STATE_DIR) .join(DEFAULT_LOCAL_EXPORTS_DIR), }, @@ -377,7 +390,7 @@ impl RuntimeConfig { rpc: resolve_rpc_config( env, env_file, - user_config.as_ref(), + app_config.as_ref(), workspace_config.as_ref(), )?, }) @@ -386,16 +399,34 @@ impl RuntimeConfig { fn resolve_paths(env: &dyn Environment) -> Result<PathsConfig, RuntimeError> { let current_dir = env.current_dir()?; - let home_dir = env.home_dir().ok_or_else(|| { - RuntimeError::Config( - "failed to resolve home directory for Radroots config roots".to_owned(), + let resolver = env.path_resolver(); + let resolved = resolver + .resolve( + RadrootsPathProfile::InteractiveUser, + &RadrootsPathOverrides::default(), ) - })?; + .map_err(|err| RuntimeError::Config(format!("resolve Radroots path roots: {err}")))?; + let app_namespace = RadrootsRuntimeNamespace::app("cli") + .map_err(|err| RuntimeError::Config(format!("resolve cli namespace: {err}")))?; + let shared_accounts_namespace = RadrootsRuntimeNamespace::shared("accounts") + .map_err(|err| RuntimeError::Config(format!("resolve shared accounts namespace: {err}")))?; + let app_paths = resolved.namespaced(&app_namespace); + let shared_accounts_paths = resolved.namespaced(&shared_accounts_namespace); + let default_identity_path = resolved + .secrets + .join("shared") + .join("identities") + .join(DEFAULT_IDENTITY_PATH); Ok(PathsConfig { - user_config_path: home_dir.join(DEFAULT_USER_CONFIG_PATH), + profile: RadrootsPathProfile::InteractiveUser.to_string(), + app_config_path: app_paths.config.join(DEFAULT_CONFIG_FILE_NAME), workspace_config_path: current_dir.join(DEFAULT_WORKSPACE_CONFIG_PATH), - user_state_root: home_dir.join(DEFAULT_USER_STATE_ROOT), + app_data_root: app_paths.data, + app_logs_root: app_paths.logs, + shared_accounts_data_root: shared_accounts_paths.data, + shared_accounts_secrets_root: shared_accounts_paths.secrets, + default_identity_path, }) } @@ -842,6 +873,7 @@ mod tests { }; use crate::cli::CliArgs; use clap::Parser; + use radroots_runtime_paths::{RadrootsHostEnvironment, RadrootsPathResolver, RadrootsPlatform}; use radroots_secret_vault::{RadrootsHostVaultPolicy, RadrootsSecretBackend}; use std::collections::BTreeMap; use std::fs; @@ -851,7 +883,7 @@ mod tests { struct MapEnvironment { values: BTreeMap<String, String>, current_dir: PathBuf, - home_dir: PathBuf, + path_resolver: RadrootsPathResolver, } impl MapEnvironment { @@ -859,7 +891,13 @@ mod tests { Self { values, current_dir: PathBuf::from("/workspaces/radroots-cli"), - home_dir: PathBuf::from("/home/tester"), + path_resolver: RadrootsPathResolver::new( + RadrootsPlatform::Linux, + RadrootsHostEnvironment { + home_dir: Some(PathBuf::from("/home/tester")), + ..RadrootsHostEnvironment::default() + }, + ), } } } @@ -873,8 +911,8 @@ mod tests { Ok(self.current_dir.clone()) } - fn home_dir(&self) -> Option<PathBuf> { - Some(self.home_dir.clone()) + fn path_resolver(&self) -> RadrootsPathResolver { + self.path_resolver.clone() } } @@ -929,11 +967,24 @@ mod tests { assert_eq!( resolved.paths, PathsConfig { - user_config_path: PathBuf::from("/home/tester/.config/radroots/config.toml"), + profile: "interactive_user".to_owned(), + app_config_path: PathBuf::from( + "/home/tester/.radroots/config/apps/cli/config.toml" + ), workspace_config_path: PathBuf::from( "/workspaces/radroots-cli/.radroots/config.toml" ), - user_state_root: PathBuf::from("/home/tester/.local/share/radroots"), + app_data_root: PathBuf::from("/home/tester/.radroots/data/apps/cli"), + app_logs_root: PathBuf::from("/home/tester/.radroots/logs/apps/cli"), + shared_accounts_data_root: PathBuf::from( + "/home/tester/.radroots/data/shared/accounts" + ), + shared_accounts_secrets_root: PathBuf::from( + "/home/tester/.radroots/secrets/shared/accounts" + ), + default_identity_path: PathBuf::from( + "/home/tester/.radroots/secrets/shared/identities/default.json" + ), } ); assert_eq!(resolved.logging.filter, "debug"); @@ -946,8 +997,8 @@ mod tests { 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"), + store_path: PathBuf::from("/home/tester/.radroots/data/shared/accounts/store.json"), + secrets_dir: PathBuf::from("/home/tester/.radroots/secrets/shared/accounts"), secret_backend: RadrootsSecretBackend::HostVault( RadrootsHostVaultPolicy::desktop(), ), @@ -1146,14 +1197,14 @@ RADROOTS_CLI_LOGGING_STDOUT=false let workspace_root = temp.path().join("workspace"); let user_home = temp.path().join("home"); fs::create_dir_all(workspace_root.join(".radroots")).expect("workspace config dir"); - fs::create_dir_all(user_home.join(".config/radroots")).expect("user config dir"); + fs::create_dir_all(user_home.join(".radroots/config/apps/cli")).expect("app config dir"); fs::write( workspace_root.join(".radroots/config.toml"), "[relay]\nurls = [\"wss://relay.workspace\"]\npublish_policy = \"any\"\n", ) .expect("write workspace config"); fs::write( - user_home.join(".config/radroots/config.toml"), + user_home.join(".radroots/config/apps/cli/config.toml"), "[relay]\nurls = [\"wss://relay.user\", \"wss://relay.workspace\"]\n", ) .expect("write user config"); @@ -1161,7 +1212,13 @@ RADROOTS_CLI_LOGGING_STDOUT=false let env = MapEnvironment { values: BTreeMap::new(), current_dir: workspace_root, - home_dir: user_home, + path_resolver: RadrootsPathResolver::new( + RadrootsPlatform::Linux, + RadrootsHostEnvironment { + home_dir: Some(user_home), + ..RadrootsHostEnvironment::default() + }, + ), }; let args = CliArgs::parse_from(["radroots", "config", "show"]); @@ -1201,16 +1258,71 @@ RADROOTS_CLI_LOGGING_STDOUT=false .expect("resolve runtime config"); assert_eq!( - resolved.paths.user_config_path, - PathBuf::from("/home/tester/.config/radroots/config.toml") + resolved.paths.app_config_path, + PathBuf::from("/home/tester/.radroots/config/apps/cli/config.toml") ); assert_eq!( resolved.paths.workspace_config_path, PathBuf::from("/workspaces/radroots-cli/.radroots/config.toml") ); assert_eq!( - resolved.paths.user_state_root, - PathBuf::from("/home/tester/.local/share/radroots") + resolved.paths.app_data_root, + PathBuf::from("/home/tester/.radroots/data/apps/cli") + ); + } + + #[test] + fn windows_roots_use_native_user_directories() { + let args = CliArgs::parse_from(["radroots", "config", "show"]); + let env = MapEnvironment { + values: BTreeMap::new(), + current_dir: PathBuf::from(r"C:\workspaces\radroots-cli"), + path_resolver: RadrootsPathResolver::new( + RadrootsPlatform::Windows, + RadrootsHostEnvironment { + appdata_dir: Some(PathBuf::from(r"C:\Users\tester\AppData\Roaming")), + localappdata_dir: Some(PathBuf::from(r"C:\Users\tester\AppData\Local")), + ..RadrootsHostEnvironment::default() + }, + ), + }; + + let resolved = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) + .expect("resolve runtime config"); + + assert_eq!( + resolved.paths.app_config_path, + PathBuf::from(r"C:\Users\tester\AppData\Roaming") + .join("Radroots") + .join("config") + .join("apps") + .join("cli") + .join("config.toml") + ); + assert_eq!( + resolved.paths.app_data_root, + PathBuf::from(r"C:\Users\tester\AppData\Local") + .join("Radroots") + .join("data") + .join("apps") + .join("cli") + ); + assert_eq!( + resolved.paths.shared_accounts_data_root, + PathBuf::from(r"C:\Users\tester\AppData\Local") + .join("Radroots") + .join("data") + .join("shared") + .join("accounts") + ); + assert_eq!( + resolved.paths.default_identity_path, + PathBuf::from(r"C:\Users\tester\AppData\Roaming") + .join("Radroots") + .join("secrets") + .join("shared") + .join("identities") + .join("default.json") ); } diff --git a/src/runtime/order.rs b/src/runtime/order.rs @@ -1210,7 +1210,7 @@ fn scaffold_contents(draft: &OrderDraftDocument) -> Result<String, RuntimeError> } fn drafts_dir(config: &RuntimeConfig) -> PathBuf { - config.paths.user_state_root.join(ORDERS_DIR) + config.paths.app_data_root.join(ORDERS_DIR) } fn draft_lookup_path(config: &RuntimeConfig, lookup: &str) -> PathBuf { diff --git a/tests/find.rs b/tests/find.rs @@ -6,10 +6,20 @@ use radroots_sql_core::{SqlExecutor, SqliteExecutor}; use serde_json::{Value, json}; use tempfile::tempdir; +fn data_root(workdir: &Path) -> std::path::PathBuf { + if cfg!(windows) { + workdir.join("local").join("Radroots").join("data") + } else { + workdir.join("home").join(".radroots").join("data") + } +} + 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")); + command.env("APPDATA", workdir.join("roaming")); + command.env("LOCALAPPDATA", workdir.join("local")); for key in [ "RADROOTS_ENV_FILE", "RADROOTS_OUTPUT", @@ -178,9 +188,7 @@ fn seed_trade_product( qty_avail: i64, location_label: Option<&str>, ) { - let replica_db = workdir - .join("home") - .join(".local/share/radroots/replica/replica.sqlite"); + let replica_db = data_root(workdir).join("apps/cli/replica/replica.sqlite"); let executor = SqliteExecutor::open(&replica_db).expect("open replica db"); let now = "2026-04-07T00:00:00.000Z"; executor diff --git a/tests/identity_commands.rs b/tests/identity_commands.rs @@ -6,10 +6,28 @@ use assert_cmd::prelude::*; use serde_json::Value; use tempfile::tempdir; +fn data_root(workdir: &Path) -> std::path::PathBuf { + if cfg!(windows) { + workdir.join("local").join("Radroots").join("data") + } else { + workdir.join("home").join(".radroots").join("data") + } +} + +fn secrets_root(workdir: &Path) -> std::path::PathBuf { + if cfg!(windows) { + workdir.join("roaming").join("Radroots").join("secrets") + } else { + workdir.join("home").join(".radroots").join("secrets") + } +} + 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")); + command.env("APPDATA", workdir.join("roaming")); + command.env("LOCALAPPDATA", workdir.join("local")); for key in [ "RADROOTS_ENV_FILE", "RADROOTS_OUTPUT", @@ -39,9 +57,7 @@ fn cli_command_in(workdir: &Path) -> Command { #[test] fn account_new_json_creates_local_account_store_entry() { let dir = tempdir().expect("tempdir"); - let store_path = dir - .path() - .join("home/.local/share/radroots/accounts/store.json"); + let store_path = data_root(dir.path()).join("shared/accounts/store.json"); let output = cli_command_in(dir.path()) .args(["--json", "account", "new"]) @@ -75,9 +91,7 @@ fn account_new_encrypts_file_backed_secret_fallback_by_default() { assert!(output.status.success()); let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("json output"); let account_id = json["account"]["id"].as_str().expect("account id"); - let secrets_dir = dir - .path() - .join("home/.local/share/radroots/accounts/secrets"); + let secrets_dir = secrets_root(dir.path()).join("shared/accounts"); let envelope_path = secrets_dir.join(format!("{account_id}.secret.json")); assert!(secrets_dir.join(".vault.key").exists()); @@ -98,9 +112,7 @@ fn account_new_encrypts_file_backed_secret_fallback_by_default() { #[test] fn account_new_rejects_dry_run_without_creating_store_state() { let dir = tempdir().expect("tempdir"); - let store_path = dir - .path() - .join("home/.local/share/radroots/accounts/store.json"); + let store_path = data_root(dir.path()).join("shared/accounts/store.json"); let output = cli_command_in(dir.path()) .args(["--dry-run", "account", "new"]) diff --git a/tests/listing.rs b/tests/listing.rs @@ -13,10 +13,20 @@ use radroots_sql_core::{SqlExecutor, SqliteExecutor}; use serde_json::{Value, json}; use tempfile::tempdir; +fn data_root(workdir: &Path) -> std::path::PathBuf { + if cfg!(windows) { + workdir.join("local").join("Radroots").join("data") + } else { + workdir.join("home").join(".radroots").join("data") + } +} + 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")); + command.env("APPDATA", workdir.join("roaming")); + command.env("LOCALAPPDATA", workdir.join("local")); for key in [ "RADROOTS_ENV_FILE", "RADROOTS_OUTPUT", @@ -468,9 +478,7 @@ fn listing_archive_and_dry_run_are_truthful() { } fn seed_farm(workdir: &Path, pubkey: &str, d_tag: &str, name: &str) { - let replica_db = workdir - .join("home") - .join(".local/share/radroots/replica/replica.sqlite"); + let replica_db = data_root(workdir).join("apps/cli/replica/replica.sqlite"); let executor = SqliteExecutor::open(&replica_db).expect("open replica db"); let now = "2026-04-07T00:00:00.000Z"; executor @@ -724,9 +732,7 @@ fn seed_trade_product( qty_avail: i64, location_label: Option<&str>, ) { - let replica_db = workdir - .join("home") - .join(".local/share/radroots/replica/replica.sqlite"); + let replica_db = data_root(workdir).join("apps/cli/replica/replica.sqlite"); let executor = SqliteExecutor::open(&replica_db).expect("open replica db"); let now = "2026-04-07T00:00:00.000Z"; executor diff --git a/tests/local.rs b/tests/local.rs @@ -6,10 +6,20 @@ use assert_cmd::prelude::*; use serde_json::Value; use tempfile::tempdir; +fn data_root(workdir: &Path) -> std::path::PathBuf { + if cfg!(windows) { + workdir.join("local").join("Radroots").join("data") + } else { + workdir.join("home").join(".radroots").join("data") + } +} + fn local_command_in(workdir: &Path) -> Command { let mut command = Command::cargo_bin("radroots").expect("binary"); command.current_dir(workdir); command.env("HOME", workdir.join("home")); + command.env("APPDATA", workdir.join("roaming")); + command.env("LOCALAPPDATA", workdir.join("local")); for key in [ "RADROOTS_ENV_FILE", "RADROOTS_OUTPUT", @@ -49,21 +59,16 @@ fn local_init_json_creates_replica_db_and_roots() { assert_eq!(json["state"], "initialized"); assert_eq!(json["replica_db"], "ready"); - let replica_db = dir - .path() - .join("home") - .join(".local/share/radroots/replica/replica.sqlite"); + let replica_db = data_root(dir.path()).join("apps/cli/replica/replica.sqlite"); assert!(replica_db.exists()); assert!( - dir.path() - .join("home") - .join(".local/share/radroots/replica/backups") + data_root(dir.path()) + .join("apps/cli/replica/backups") .exists() ); assert!( - dir.path() - .join("home") - .join(".local/share/radroots/replica/exports") + data_root(dir.path()) + .join("apps/cli/replica/exports") .exists() ); } diff --git a/tests/order.rs b/tests/order.rs @@ -12,10 +12,20 @@ use assert_cmd::prelude::*; use serde_json::Value; use tempfile::tempdir; +fn data_root(workdir: &Path) -> std::path::PathBuf { + if cfg!(windows) { + workdir.join("local").join("Radroots").join("data") + } else { + workdir.join("home").join(".radroots").join("data") + } +} + fn order_command_in(workdir: &Path) -> Command { let mut command = Command::cargo_bin("radroots").expect("binary"); command.current_dir(workdir); command.env("HOME", workdir.join("home")); + command.env("APPDATA", workdir.join("roaming")); + command.env("LOCALAPPDATA", workdir.join("local")); for key in [ "RADROOTS_ENV_FILE", "RADROOTS_OUTPUT", @@ -310,7 +320,7 @@ fn order_new_creates_a_local_draft_with_selected_account_defaults() { assert_eq!(json["items"][0]["bin_count"], 2); let file = json["file"].as_str().expect("draft file"); - assert!(file.contains(".local/share/radroots/orders/drafts/ord_")); + assert!(file.contains("/data/apps/cli/orders/drafts/ord_")); let contents = fs::read_to_string(file).expect("read order draft"); assert!(contents.contains("kind = \"order_draft_v1\"")); assert!(contents.contains("listing_lookup = \"pasture-eggs\"")); @@ -398,7 +408,7 @@ fn order_get_and_ls_read_local_drafts_and_report_missing() { fn order_get_surfaces_recorded_job_metadata_from_the_local_draft_store() { let _guard = order_test_guard(); let dir = tempdir().expect("tempdir"); - let drafts_dir = dir.path().join("home/.local/share/radroots/orders/drafts"); + let drafts_dir = data_root(dir.path()).join("apps/cli/orders/drafts"); fs::create_dir_all(&drafts_dir).expect("create drafts dir"); let draft_path = drafts_dir.join("ord_AAAAAAAAAAAAAAAAAAAAAg.toml"); fs::write( @@ -544,7 +554,7 @@ fn order_watch_reports_job_frames_for_submitted_order() { other => panic!("unexpected mock rpc method {other}"), }); - let drafts_dir = dir.path().join("home/.local/share/radroots/orders/drafts"); + let drafts_dir = data_root(dir.path()).join("apps/cli/orders/drafts"); fs::create_dir_all(&drafts_dir).expect("create drafts dir"); fs::write( drafts_dir.join("ord_AAAAAAAAAAAAAAAAAAAAAg.toml"), @@ -596,7 +606,7 @@ job_id = "job_watch_01" fn order_history_lists_submitted_order_drafts() { let _guard = order_test_guard(); let dir = tempdir().expect("tempdir"); - let drafts_dir = dir.path().join("home/.local/share/radroots/orders/drafts"); + let drafts_dir = data_root(dir.path()).join("apps/cli/orders/drafts"); fs::create_dir_all(&drafts_dir).expect("create drafts dir"); fs::write( drafts_dir.join("ord_AAAAAAAAAAAAAAAAAAAAAg.toml"), @@ -649,7 +659,7 @@ submitted_at_unix = 1712720000 fn order_cancel_is_truthfully_narrowed_when_trade_chain_state_is_unavailable() { let _guard = order_test_guard(); let dir = tempdir().expect("tempdir"); - let drafts_dir = dir.path().join("home/.local/share/radroots/orders/drafts"); + let drafts_dir = data_root(dir.path()).join("apps/cli/orders/drafts"); fs::create_dir_all(&drafts_dir).expect("create drafts dir"); fs::write( drafts_dir.join("ord_AAAAAAAAAAAAAAAAAAAAAg.toml"), diff --git a/tests/runtime_show.rs b/tests/runtime_show.rs @@ -6,10 +6,60 @@ use assert_cmd::prelude::*; use serde_json::Value; use tempfile::tempdir; +fn appdata_root(workdir: &Path) -> std::path::PathBuf { + workdir.join("roaming").join("Radroots") +} + +fn localappdata_root(workdir: &Path) -> std::path::PathBuf { + workdir.join("local").join("Radroots") +} + +fn interactive_root(workdir: &Path) -> std::path::PathBuf { + if cfg!(windows) { + localappdata_root(workdir) + } else { + workdir.join("home").join(".radroots") + } +} + +fn config_root(workdir: &Path) -> std::path::PathBuf { + if cfg!(windows) { + appdata_root(workdir).join("config") + } else { + interactive_root(workdir).join("config") + } +} + +fn data_root(workdir: &Path) -> std::path::PathBuf { + if cfg!(windows) { + localappdata_root(workdir).join("data") + } else { + interactive_root(workdir).join("data") + } +} + +fn logs_root(workdir: &Path) -> std::path::PathBuf { + if cfg!(windows) { + localappdata_root(workdir).join("logs") + } else { + interactive_root(workdir).join("logs") + } +} + +fn secrets_root(workdir: &Path) -> std::path::PathBuf { + if cfg!(windows) { + appdata_root(workdir).join("secrets") + } else { + interactive_root(workdir).join("secrets") + } +} + fn runtime_show_command_in(workdir: &Path) -> Command { let mut command = Command::cargo_bin("radroots").expect("binary"); command.current_dir(workdir); command.env("HOME", workdir.join("home")); + command.env("APPDATA", workdir.join("roaming")); + command.env("LOCALAPPDATA", workdir.join("local")); for key in [ "RADROOTS_ENV_FILE", "RADROOTS_OUTPUT", @@ -53,11 +103,11 @@ fn config_show_json_reports_default_bootstrap_state() { assert_eq!(json["output"]["verbosity"], "normal"); assert_eq!(json["output"]["color"], true); assert_eq!(json["output"]["dry_run"], false); + assert_eq!(json["paths"]["profile"], "interactive_user"); assert_eq!( - json["paths"]["user_config_path"], - dir.path() - .join("home") - .join(".config/radroots/config.toml") + json["paths"]["app_config_path"], + config_root(dir.path()) + .join("apps/cli/config.toml") .display() .to_string() ); @@ -69,36 +119,64 @@ fn config_show_json_reports_default_bootstrap_state() { .to_string() ); assert_eq!( - json["paths"]["user_state_root"], - dir.path() - .join("home") - .join(".local/share/radroots") + json["paths"]["app_data_root"], + data_root(dir.path()).join("apps/cli").display().to_string() + ); + assert_eq!( + json["paths"]["app_logs_root"], + logs_root(dir.path()).join("apps/cli").display().to_string() + ); + assert_eq!( + json["paths"]["shared_accounts_data_root"], + data_root(dir.path()) + .join("shared/accounts") + .display() + .to_string() + ); + assert_eq!( + json["paths"]["shared_accounts_secrets_root"], + secrets_root(dir.path()) + .join("shared/accounts") + .display() + .to_string() + ); + assert_eq!( + json["paths"]["default_identity_path"], + secrets_root(dir.path()) + .join("shared/identities/default.json") .display() .to_string() ); assert_eq!(json["logging"]["initialized"], true); assert_eq!(json["logging"]["stdout"], false); - assert_eq!(json["logging"]["directory"], Value::Null); + assert_eq!( + json["logging"]["directory"], + logs_root(dir.path()).join("apps/cli").display().to_string() + ); assert_eq!(json["config_files"]["user_present"], false); assert_eq!(json["config_files"]["workspace_present"], false); assert_eq!(json["account"]["selector"], Value::Null); assert_eq!( json["account"]["store_path"], - dir.path() - .join("home") - .join(".local/share/radroots/accounts/store.json") + data_root(dir.path()) + .join("shared/accounts/store.json") .display() .to_string() ); assert_eq!( json["account"]["secrets_dir"], - dir.path() - .join("home") - .join(".local/share/radroots/accounts/secrets") + secrets_root(dir.path()) + .join("shared/accounts") + .display() + .to_string() + ); + assert_eq!( + json["account"]["identity_path"], + secrets_root(dir.path()) + .join("shared/identities/default.json") .display() .to_string() ); - assert_eq!(json["account"]["legacy_identity_path"], "identity.json"); assert_eq!( json["account"]["secret_backend"]["configured_primary"], "host_vault" @@ -119,17 +197,15 @@ fn config_show_json_reports_default_bootstrap_state() { assert_eq!(json["relay"]["source"], "defaults · local first"); assert_eq!( json["local"]["root"], - dir.path() - .join("home") - .join(".local/share/radroots/replica") + data_root(dir.path()) + .join("apps/cli/replica") .display() .to_string() ); assert_eq!( json["local"]["replica_db_path"], - dir.path() - .join("home") - .join(".local/share/radroots/replica/replica.sqlite") + data_root(dir.path()) + .join("apps/cli/replica/replica.sqlite") .display() .to_string() ); @@ -164,10 +240,7 @@ fn config_show_json_reflects_environment_configuration() { assert_eq!(json["logging"]["filter"], "debug"); assert_eq!(json["logging"]["directory"], "logs/runtime"); assert_eq!(json["account"]["selector"], "acct_demo"); - assert_eq!( - json["account"]["legacy_identity_path"], - "state/identity.json" - ); + assert_eq!(json["account"]["identity_path"], "state/identity.json"); assert_eq!( json["account"]["secret_backend"]["active_backend"], "encrypted_file" diff --git a/tests/signer_status.rs b/tests/signer_status.rs @@ -6,10 +6,20 @@ use assert_cmd::prelude::*; use serde_json::Value; use tempfile::tempdir; +fn data_root(workdir: &Path) -> std::path::PathBuf { + if cfg!(windows) { + workdir.join("local").join("Radroots").join("data") + } else { + workdir.join("home").join(".radroots").join("data") + } +} + 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")); + command.env("APPDATA", workdir.join("roaming")); + command.env("LOCALAPPDATA", workdir.join("local")); for key in [ "RADROOTS_ENV_FILE", "RADROOTS_OUTPUT", @@ -90,7 +100,7 @@ fn signer_status_reports_local_unconfigured_when_no_account_is_selected() { #[test] fn signer_status_reports_internal_error_for_invalid_account_store_file() { let dir = tempdir().expect("tempdir"); - let accounts_dir = dir.path().join("home/.local/share/radroots/accounts"); + let accounts_dir = data_root(dir.path()).join("shared/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");