cli

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

commit 92386a9c250a47ea1bb2011a70cd829a8a9c6aff
parent ba9243bf329e966c4d55e66e289e2dc7345d7839
Author: triesap <tyson@radroots.org>
Date:   Thu,  9 Apr 2026 03:10:37 +0000

cli: add repo_local path profile support

Diffstat:
Msrc/render/mod.rs | 4++--
Msrc/runtime/config.rs | 173+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mtests/doctor.rs | 2++
Mtests/find.rs | 2++
Mtests/identity_commands.rs | 2++
Mtests/job_rpc.rs | 2++
Mtests/listing.rs | 2++
Mtests/local.rs | 2++
Mtests/myc_status.rs | 2++
Mtests/order.rs | 2++
Mtests/relay_net.rs | 2++
Mtests/runtime_show.rs | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/signer_status.rs | 2++
Mtests/sync.rs | 2++
14 files changed, 248 insertions(+), 10 deletions(-)

diff --git a/src/render/mod.rs b/src/render/mod.rs @@ -2165,7 +2165,7 @@ mod tests { }, paths: PathsConfig { profile: "interactive_user".into(), - allowed_profiles: vec!["interactive_user".into()], + allowed_profiles: vec!["interactive_user".into(), "repo_local".into()], app_namespace: "apps/cli".into(), shared_accounts_namespace: "shared/accounts".into(), shared_identities_namespace: "shared/identities".into(), @@ -2302,7 +2302,7 @@ mod tests { }, paths: PathsConfig { profile: "interactive_user".into(), - allowed_profiles: vec!["interactive_user".into()], + allowed_profiles: vec!["interactive_user".into(), "repo_local".into()], app_namespace: "apps/cli".into(), shared_accounts_namespace: "shared/accounts".into(), shared_identities_namespace: "shared/identities".into(), diff --git a/src/runtime/config.rs b/src/runtime/config.rs @@ -24,14 +24,15 @@ const DEFAULT_LOCAL_EXPORTS_DIR: &str = "exports"; const DEFAULT_SHARED_ACCOUNTS_STORE_FILE: &str = "store.json"; const DEFAULT_HYF_EXECUTABLE: &str = "hyfd"; const DEFAULT_RPC_URL: &str = "http://127.0.0.1:7070"; -const CLI_PROFILE: &str = "interactive_user"; +const CLI_DEFAULT_PROFILE: &str = "interactive_user"; +const CLI_REPO_LOCAL_PROFILE: &str = "repo_local"; const CLI_APP_NAMESPACE_VALUE: &str = "cli"; const SHARED_ACCOUNTS_NAMESPACE_VALUE: &str = "accounts"; const SHARED_IDENTITIES_NAMESPACE_VALUE: &str = "identities"; const CLI_HOST_VAULT_POLICY: &str = "desktop"; const CLI_DEFAULT_SECRET_BACKEND: &str = "host_vault"; const CLI_DEFAULT_SECRET_FALLBACK: &str = "encrypted_file"; -const CLI_ALLOWED_PROFILES: &[&str] = &[CLI_PROFILE]; +const CLI_ALLOWED_PROFILES: &[&str] = &[CLI_DEFAULT_PROFILE, CLI_REPO_LOCAL_PROFILE]; const CLI_ALLOWED_SHARED_SECRET_BACKENDS: &[&str] = &["host_vault", "encrypted_file", "memory", "plaintext_file"]; const CLI_USES_PROTECTED_STORE: bool = true; @@ -40,6 +41,8 @@ const ENV_OUTPUT: &str = "RADROOTS_OUTPUT"; const ENV_CLI_LOG_FILTER: &str = "RADROOTS_CLI_LOGGING_FILTER"; const ENV_CLI_LOG_DIR: &str = "RADROOTS_CLI_LOGGING_OUTPUT_DIR"; const ENV_CLI_LOG_STDOUT: &str = "RADROOTS_CLI_LOGGING_STDOUT"; +const ENV_CLI_PATHS_PROFILE: &str = "RADROOTS_CLI_PATHS_PROFILE"; +const ENV_CLI_PATHS_REPO_LOCAL_ROOT: &str = "RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT"; const ENV_LOG_FILTER: &str = "RADROOTS_LOG_FILTER"; const ENV_LOG_DIR: &str = "RADROOTS_LOG_DIR"; const ENV_LOG_STDOUT: &str = "RADROOTS_LOG_STDOUT"; @@ -59,6 +62,8 @@ const SUPPORTED_ENV_FILE_KEYS: &[&str] = &[ ENV_CLI_LOG_FILTER, ENV_CLI_LOG_DIR, ENV_CLI_LOG_STDOUT, + ENV_CLI_PATHS_PROFILE, + ENV_CLI_PATHS_REPO_LOCAL_ROOT, ENV_LOG_FILTER, ENV_LOG_DIR, ENV_LOG_STDOUT, @@ -331,7 +336,7 @@ impl RuntimeConfig { env: &dyn Environment, env_file: &EnvFileValues, ) -> Result<Self, RuntimeError> { - let paths = resolve_paths(env)?; + let paths = resolve_paths(env, env_file)?; let workspace_config = load_cli_config_file(paths.workspace_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)? @@ -466,11 +471,14 @@ impl RuntimeConfig { } } -fn resolve_paths(env: &dyn Environment) -> Result<PathsConfig, RuntimeError> { +fn resolve_paths( + env: &dyn Environment, + env_file: &EnvFileValues, +) -> Result<PathsConfig, RuntimeError> { let current_dir = env.current_dir()?; let resolver = env.path_resolver(); - let profile = RadrootsPathProfile::InteractiveUser; - let overrides = RadrootsPathOverrides::default(); + let (profile, profile_label) = resolve_cli_path_profile(env, env_file)?; + let overrides = resolve_cli_path_overrides(current_dir.as_path(), env, env_file, profile)?; let resolved = resolver .resolve(profile, &overrides) .map_err(|err| RuntimeError::Config(format!("resolve Radroots path roots: {err}")))?; @@ -490,7 +498,7 @@ fn resolve_paths(env: &dyn Environment) -> Result<PathsConfig, RuntimeError> { .map_err(|err| RuntimeError::Config(format!("resolve shared identity path: {err}")))?; Ok(PathsConfig { - profile: CLI_PROFILE.to_owned(), + profile: profile_label.to_owned(), allowed_profiles: CLI_ALLOWED_PROFILES .iter() .map(|value| (*value).to_owned()) @@ -514,6 +522,68 @@ fn resolve_paths(env: &dyn Environment) -> Result<PathsConfig, RuntimeError> { }) } +fn resolve_cli_path_profile( + env: &dyn Environment, + env_file: &EnvFileValues, +) -> Result<(RadrootsPathProfile, &'static str), RuntimeError> { + match env_value_entry(env, env_file, &[ENV_CLI_PATHS_PROFILE]) { + Some((key, value)) => parse_cli_path_profile(key.as_str(), value.as_str()), + None => Ok((RadrootsPathProfile::InteractiveUser, CLI_DEFAULT_PROFILE)), + } +} + +fn parse_cli_path_profile( + key: &str, + value: &str, +) -> Result<(RadrootsPathProfile, &'static str), RuntimeError> { + match value.trim().to_ascii_lowercase().as_str() { + CLI_DEFAULT_PROFILE => Ok((RadrootsPathProfile::InteractiveUser, CLI_DEFAULT_PROFILE)), + CLI_REPO_LOCAL_PROFILE => Ok((RadrootsPathProfile::RepoLocal, CLI_REPO_LOCAL_PROFILE)), + other => Err(RuntimeError::Config(format!( + "{key} must be `interactive_user` or `repo_local`, got `{other}`" + ))), + } +} + +fn resolve_cli_path_overrides( + current_dir: &Path, + env: &dyn Environment, + env_file: &EnvFileValues, + profile: RadrootsPathProfile, +) -> Result<RadrootsPathOverrides, RuntimeError> { + match profile { + RadrootsPathProfile::InteractiveUser => Ok(RadrootsPathOverrides::default()), + RadrootsPathProfile::RepoLocal => { + let Some((key, value)) = + env_value_entry(env, env_file, &[ENV_CLI_PATHS_REPO_LOCAL_ROOT]) + else { + return Err(RuntimeError::Config(format!( + "{ENV_CLI_PATHS_REPO_LOCAL_ROOT} must be set when {ENV_CLI_PATHS_PROFILE}=repo_local" + ))); + }; + if value.trim().is_empty() { + return Err(RuntimeError::Config(format!( + "{key} must not be empty when {ENV_CLI_PATHS_PROFILE}=repo_local" + ))); + } + let repo_local_root = normalize_explicit_path_root(current_dir, value.as_str()); + Ok(RadrootsPathOverrides::repo_local(repo_local_root)) + } + _ => Err(RuntimeError::Config( + "cli only supports interactive_user and repo_local path profiles".to_owned(), + )), + } +} + +fn normalize_explicit_path_root(current_dir: &Path, value: &str) -> PathBuf { + let root = PathBuf::from(value.trim()); + if root.is_absolute() { + root + } else { + current_dir.join(root) + } +} + fn load_cli_config_file(path: &Path) -> Result<Option<CliConfigFile>, RuntimeError> { if !path.exists() { return Ok(None); @@ -1119,7 +1189,7 @@ mod tests { resolved.paths, PathsConfig { profile: "interactive_user".to_owned(), - allowed_profiles: vec!["interactive_user".to_owned()], + allowed_profiles: vec!["interactive_user".to_owned(), "repo_local".to_owned(),], app_namespace: "apps/cli".to_owned(), shared_accounts_namespace: "shared/accounts".to_owned(), shared_identities_namespace: "shared/identities".to_owned(), @@ -1524,6 +1594,10 @@ RADROOTS_CLI_LOGGING_STDOUT=false resolved.paths.app_data_root, PathBuf::from("/home/tester/.radroots/data/apps/cli") ); + assert_eq!( + resolved.paths.allowed_profiles, + vec!["interactive_user".to_owned(), "repo_local".to_owned(),] + ); } #[test] @@ -1582,6 +1656,89 @@ RADROOTS_CLI_LOGGING_STDOUT=false } #[test] + fn repo_local_profile_uses_explicit_repo_local_root() { + let args = CliArgs::parse_from(["radroots", "config", "show"]); + let env = MapEnvironment::new(BTreeMap::from([ + ( + "RADROOTS_CLI_PATHS_PROFILE".to_owned(), + "repo_local".to_owned(), + ), + ( + "RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT".to_owned(), + ".local/radroots/dev".to_owned(), + ), + ])); + + let resolved = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) + .expect("resolve runtime config"); + + assert_eq!(resolved.paths.profile, "repo_local"); + assert_eq!( + resolved.paths.app_config_path, + PathBuf::from( + "/workspaces/radroots-cli/.local/radroots/dev/config/apps/cli/config.toml" + ) + ); + assert_eq!( + resolved.paths.app_data_root, + PathBuf::from("/workspaces/radroots-cli/.local/radroots/dev/data/apps/cli") + ); + assert_eq!( + resolved.paths.app_logs_root, + PathBuf::from("/workspaces/radroots-cli/.local/radroots/dev/logs/apps/cli") + ); + assert_eq!( + resolved.paths.shared_accounts_data_root, + PathBuf::from("/workspaces/radroots-cli/.local/radroots/dev/data/shared/accounts") + ); + assert_eq!( + resolved.paths.default_identity_path, + PathBuf::from( + "/workspaces/radroots-cli/.local/radroots/dev/secrets/shared/identities/default.json" + ) + ); + } + + #[test] + fn repo_local_profile_requires_explicit_root() { + let args = CliArgs::parse_from(["radroots", "config", "show"]); + let env = MapEnvironment::new(BTreeMap::from([( + "RADROOTS_CLI_PATHS_PROFILE".to_owned(), + "repo_local".to_owned(), + )])); + + let error = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) + .expect_err("repo_local should require an explicit root"); + assert!( + error + .to_string() + .contains("RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT") + ); + } + + #[test] + fn env_file_can_select_repo_local_profile() { + let args = CliArgs::parse_from(["radroots", "config", "show"]); + let env = MapEnvironment::new(BTreeMap::new()); + let env_file = parse_env_file_values( + r#" +RADROOTS_CLI_PATHS_PROFILE=repo_local +RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT=.local/radroots/dev +"#, + Path::new(".env.test"), + ) + .expect("parse env file"); + + let resolved = + RuntimeConfig::resolve_with_env_file(&args, &env, &env_file).expect("resolve config"); + assert_eq!(resolved.paths.profile, "repo_local"); + assert_eq!( + resolved.paths.app_data_root, + PathBuf::from("/workspaces/radroots-cli/.local/radroots/dev/data/apps/cli") + ); + } + + #[test] fn unknown_env_file_variable_fails() { let error = parse_env_file_values( "RADROOTS_CLI_LOGGING_FILTRE=debug\n", diff --git a/tests/doctor.rs b/tests/doctor.rs @@ -16,6 +16,8 @@ fn doctor_command_in(workdir: &Path) -> Command { "RADROOTS_CLI_LOGGING_FILTER", "RADROOTS_CLI_LOGGING_OUTPUT_DIR", "RADROOTS_CLI_LOGGING_STDOUT", + "RADROOTS_CLI_PATHS_PROFILE", + "RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT", "RADROOTS_LOG_FILTER", "RADROOTS_LOG_DIR", "RADROOTS_LOG_STDOUT", diff --git a/tests/find.rs b/tests/find.rs @@ -26,6 +26,8 @@ fn cli_command_in(workdir: &Path) -> Command { "RADROOTS_CLI_LOGGING_FILTER", "RADROOTS_CLI_LOGGING_OUTPUT_DIR", "RADROOTS_CLI_LOGGING_STDOUT", + "RADROOTS_CLI_PATHS_PROFILE", + "RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT", "RADROOTS_LOG_FILTER", "RADROOTS_LOG_DIR", "RADROOTS_LOG_STDOUT", diff --git a/tests/identity_commands.rs b/tests/identity_commands.rs @@ -34,6 +34,8 @@ fn cli_command_in(workdir: &Path) -> Command { "RADROOTS_CLI_LOGGING_FILTER", "RADROOTS_CLI_LOGGING_OUTPUT_DIR", "RADROOTS_CLI_LOGGING_STDOUT", + "RADROOTS_CLI_PATHS_PROFILE", + "RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT", "RADROOTS_LOG_FILTER", "RADROOTS_LOG_DIR", "RADROOTS_LOG_STDOUT", diff --git a/tests/job_rpc.rs b/tests/job_rpc.rs @@ -21,6 +21,8 @@ fn job_rpc_command_in(workdir: &Path) -> Command { "RADROOTS_CLI_LOGGING_FILTER", "RADROOTS_CLI_LOGGING_OUTPUT_DIR", "RADROOTS_CLI_LOGGING_STDOUT", + "RADROOTS_CLI_PATHS_PROFILE", + "RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT", "RADROOTS_LOG_FILTER", "RADROOTS_LOG_DIR", "RADROOTS_LOG_STDOUT", diff --git a/tests/listing.rs b/tests/listing.rs @@ -33,6 +33,8 @@ fn cli_command_in(workdir: &Path) -> Command { "RADROOTS_CLI_LOGGING_FILTER", "RADROOTS_CLI_LOGGING_OUTPUT_DIR", "RADROOTS_CLI_LOGGING_STDOUT", + "RADROOTS_CLI_PATHS_PROFILE", + "RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT", "RADROOTS_LOG_FILTER", "RADROOTS_LOG_DIR", "RADROOTS_LOG_STDOUT", diff --git a/tests/local.rs b/tests/local.rs @@ -26,6 +26,8 @@ fn local_command_in(workdir: &Path) -> Command { "RADROOTS_CLI_LOGGING_FILTER", "RADROOTS_CLI_LOGGING_OUTPUT_DIR", "RADROOTS_CLI_LOGGING_STDOUT", + "RADROOTS_CLI_PATHS_PROFILE", + "RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT", "RADROOTS_LOG_FILTER", "RADROOTS_LOG_DIR", "RADROOTS_LOG_STDOUT", diff --git a/tests/myc_status.rs b/tests/myc_status.rs @@ -19,6 +19,8 @@ fn cli_command_in(workdir: &Path) -> Command { "RADROOTS_CLI_LOGGING_FILTER", "RADROOTS_CLI_LOGGING_OUTPUT_DIR", "RADROOTS_CLI_LOGGING_STDOUT", + "RADROOTS_CLI_PATHS_PROFILE", + "RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT", "RADROOTS_LOG_FILTER", "RADROOTS_LOG_DIR", "RADROOTS_LOG_STDOUT", diff --git a/tests/order.rs b/tests/order.rs @@ -32,6 +32,8 @@ fn order_command_in(workdir: &Path) -> Command { "RADROOTS_CLI_LOGGING_FILTER", "RADROOTS_CLI_LOGGING_OUTPUT_DIR", "RADROOTS_CLI_LOGGING_STDOUT", + "RADROOTS_CLI_PATHS_PROFILE", + "RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT", "RADROOTS_LOG_FILTER", "RADROOTS_LOG_DIR", "RADROOTS_LOG_STDOUT", diff --git a/tests/relay_net.rs b/tests/relay_net.rs @@ -16,6 +16,8 @@ fn cli_command_in(workdir: &Path) -> Command { "RADROOTS_CLI_LOGGING_FILTER", "RADROOTS_CLI_LOGGING_OUTPUT_DIR", "RADROOTS_CLI_LOGGING_STDOUT", + "RADROOTS_CLI_PATHS_PROFILE", + "RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT", "RADROOTS_LOG_FILTER", "RADROOTS_LOG_DIR", "RADROOTS_LOG_STDOUT", diff --git a/tests/runtime_show.rs b/tests/runtime_show.rs @@ -66,6 +66,8 @@ fn runtime_show_command_in(workdir: &Path) -> Command { "RADROOTS_CLI_LOGGING_FILTER", "RADROOTS_CLI_LOGGING_OUTPUT_DIR", "RADROOTS_CLI_LOGGING_STDOUT", + "RADROOTS_CLI_PATHS_PROFILE", + "RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT", "RADROOTS_LOG_FILTER", "RADROOTS_LOG_DIR", "RADROOTS_LOG_STDOUT", @@ -105,6 +107,7 @@ fn config_show_json_reports_default_bootstrap_state() { assert_eq!(json["output"]["dry_run"], false); assert_eq!(json["paths"]["profile"], "interactive_user"); assert_eq!(json["paths"]["allowed_profiles"][0], "interactive_user"); + assert_eq!(json["paths"]["allowed_profiles"][1], "repo_local"); assert_eq!(json["paths"]["app_namespace"], "apps/cli"); assert_eq!( json["paths"]["shared_accounts_namespace"], @@ -249,6 +252,62 @@ fn config_show_json_reports_default_bootstrap_state() { } #[test] +fn config_show_json_reports_repo_local_paths_when_requested() { + let dir = tempdir().expect("tempdir"); + let repo_local_root = dir.path().join(".local/radroots/dev"); + let output = runtime_show_command_in(dir.path()) + .env("RADROOTS_CLI_PATHS_PROFILE", "repo_local") + .env("RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT", &repo_local_root) + .args(["--json", "config", "show"]) + .output() + .expect("run config show"); + + 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["paths"]["profile"], "repo_local"); + assert_eq!(json["paths"]["allowed_profiles"][0], "interactive_user"); + assert_eq!(json["paths"]["allowed_profiles"][1], "repo_local"); + assert_eq!( + json["paths"]["app_config_path"], + repo_local_root + .join("config/apps/cli/config.toml") + .display() + .to_string() + ); + assert_eq!( + json["paths"]["app_data_root"], + repo_local_root.join("data/apps/cli").display().to_string() + ); + assert_eq!( + json["paths"]["app_logs_root"], + repo_local_root.join("logs/apps/cli").display().to_string() + ); + assert_eq!( + json["paths"]["shared_accounts_data_root"], + repo_local_root + .join("data/shared/accounts") + .display() + .to_string() + ); + assert_eq!( + json["paths"]["shared_accounts_secrets_root"], + repo_local_root + .join("secrets/shared/accounts") + .display() + .to_string() + ); + assert_eq!( + json["paths"]["default_identity_path"], + repo_local_root + .join("secrets/shared/identities/default.json") + .display() + .to_string() + ); +} + +#[test] fn config_show_json_reflects_environment_configuration() { let dir = tempdir().expect("tempdir"); let output = runtime_show_command_in(dir.path()) diff --git a/tests/signer_status.rs b/tests/signer_status.rs @@ -26,6 +26,8 @@ fn cli_command_in(workdir: &Path) -> Command { "RADROOTS_CLI_LOGGING_FILTER", "RADROOTS_CLI_LOGGING_OUTPUT_DIR", "RADROOTS_CLI_LOGGING_STDOUT", + "RADROOTS_CLI_PATHS_PROFILE", + "RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT", "RADROOTS_LOG_FILTER", "RADROOTS_LOG_DIR", "RADROOTS_LOG_STDOUT", diff --git a/tests/sync.rs b/tests/sync.rs @@ -16,6 +16,8 @@ fn cli_command_in(workdir: &Path) -> Command { "RADROOTS_CLI_LOGGING_FILTER", "RADROOTS_CLI_LOGGING_OUTPUT_DIR", "RADROOTS_CLI_LOGGING_STDOUT", + "RADROOTS_CLI_PATHS_PROFILE", + "RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT", "RADROOTS_LOG_FILTER", "RADROOTS_LOG_DIR", "RADROOTS_LOG_STDOUT",