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:
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",