cli

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

commit 7347747acd0e764d98f4fb58aa63dfb51a50a93b
parent 9ef72a6ecac979ff381de92c5b29685124e1f4c4
Author: triesap <tyson@radroots.org>
Date:   Thu,  9 Apr 2026 17:18:30 +0000

runtime: report legacy cli paths

Diffstat:
Msrc/commands/runtime.rs | 41+++++++++++++++++++++++++++++++++++++++--
Msrc/domain/runtime.rs | 21+++++++++++++++++++++
Msrc/render/mod.rs | 13++++++++++---
Msrc/runtime/config.rs | 47++++++++++++++++++++++++++++++++++++++++++++++-
Mtests/runtime_show.rs | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 179 insertions(+), 6 deletions(-)

diff --git a/src/commands/runtime.rs b/src/commands/runtime.rs @@ -1,7 +1,8 @@ use crate::domain::runtime::{ AccountRuntimeView, AccountSecretRuntimeView, ConfigFilesRuntimeView, ConfigShowView, - HyfRuntimeView, LocalRuntimeView, LoggingRuntimeView, MycRuntimeView, OutputRuntimeView, - PathsRuntimeView, RelayRuntimeView, RpcRuntimeView, SignerRuntimeView, + HyfRuntimeView, LegacyPathRuntimeView, LocalRuntimeView, LoggingRuntimeView, + MigrationRuntimeView, MycRuntimeView, OutputRuntimeView, PathsRuntimeView, RelayRuntimeView, + RpcRuntimeView, SignerRuntimeView, }; use crate::runtime::RuntimeError; use crate::runtime::config::RuntimeConfig; @@ -51,6 +52,7 @@ pub fn show( .to_string(), default_identity_path: config.paths.default_identity_path.display().to_string(), }, + migration: migration_runtime_view(config), logging: LoggingRuntimeView { initialized: logging.initialized, filter: config.logging.filter.clone(), @@ -112,3 +114,38 @@ pub fn show( }, }) } + +fn migration_runtime_view(config: &RuntimeConfig) -> MigrationRuntimeView { + let report = &config.migration.report; + let detected_legacy_paths = report + .detected_legacy_paths + .iter() + .map(|path| LegacyPathRuntimeView { + id: path.id.clone(), + description: path.description.clone(), + path: path.path.display().to_string(), + destination: path + .destination + .as_ref() + .map(|destination| destination.display().to_string()), + import_hint: path.import_hint.clone(), + }) + .collect::<Vec<_>>(); + let actions = if detected_legacy_paths.is_empty() { + Vec::new() + } else { + vec![ + "inspect detected_legacy_paths before writing new local state".to_owned(), + "perform an explicit export/import or manual copy; startup did not move legacy data" + .to_owned(), + ] + }; + MigrationRuntimeView { + posture: report.posture.to_owned(), + state: report.state.to_owned(), + silent_startup_relocation: report.silent_startup_relocation, + compatibility_window: report.compatibility_window.to_owned(), + detected_legacy_paths, + actions, + } +} diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs @@ -111,6 +111,7 @@ pub struct ConfigShowView { pub output: OutputRuntimeView, pub config_files: ConfigFilesRuntimeView, pub paths: PathsRuntimeView, + pub migration: MigrationRuntimeView, pub logging: LoggingRuntimeView, pub account: AccountRuntimeView, pub signer: SignerRuntimeView, @@ -122,6 +123,26 @@ pub struct ConfigShowView { } #[derive(Debug, Clone, Serialize)] +pub struct MigrationRuntimeView { + pub posture: String, + pub state: String, + pub silent_startup_relocation: bool, + pub compatibility_window: String, + pub detected_legacy_paths: Vec<LegacyPathRuntimeView>, + pub actions: Vec<String>, +} + +#[derive(Debug, Clone, Serialize)] +pub struct LegacyPathRuntimeView { + pub id: String, + pub description: String, + pub path: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub destination: Option<String>, + pub import_hint: String, +} + +#[derive(Debug, Clone, Serialize)] pub struct OutputRuntimeView { pub format: String, pub verbosity: String, diff --git a/src/render/mod.rs b/src/render/mod.rs @@ -2146,11 +2146,12 @@ mod tests { }; use crate::runtime::config::{ AccountConfig, AccountSecretContractConfig, HyfConfig, IdentityConfig, LocalConfig, - LoggingConfig, MycConfig, OutputConfig, OutputFormat, PathsConfig, RelayConfig, - RelayConfigSource, RelayPublishPolicy, RpcConfig, RuntimeConfig, SignerBackend, - SignerConfig, Verbosity, + LoggingConfig, MigrationConfig, MycConfig, OutputConfig, OutputFormat, PathsConfig, + RelayConfig, RelayConfigSource, RelayPublishPolicy, RpcConfig, RuntimeConfig, + SignerBackend, SignerConfig, Verbosity, }; use crate::runtime::logging::LoggingState; + use radroots_runtime_paths::RadrootsMigrationReport; use radroots_secret_vault::RadrootsSecretBackend; #[test] @@ -2184,6 +2185,9 @@ mod tests { default_identity_path: "/home/tester/.radroots/secrets/shared/identities/default.json".into(), }, + migration: MigrationConfig { + report: RadrootsMigrationReport::empty(), + }, logging: LoggingConfig { filter: "info".to_owned(), directory: None, @@ -2328,6 +2332,9 @@ mod tests { default_identity_path: "/home/tester/.radroots/secrets/shared/identities/default.json".into(), }, + migration: MigrationConfig { + report: RadrootsMigrationReport::empty(), + }, logging: LoggingConfig { filter: "info".to_owned(), directory: None, diff --git a/src/runtime/config.rs b/src/runtime/config.rs @@ -3,7 +3,10 @@ use std::fs; use std::path::Path; use std::path::PathBuf; -use radroots_runtime_paths::RadrootsPathResolver; +use radroots_runtime_paths::{ + RadrootsLegacyPathCandidate, RadrootsMigrationReport, RadrootsPathResolver, + inspect_legacy_paths, +}; use radroots_secret_vault::{RadrootsHostVaultPolicy, RadrootsSecretBackend}; use serde::Deserialize; use url::Url; @@ -234,6 +237,7 @@ pub struct RpcConfig { pub struct RuntimeConfig { pub output: OutputConfig, pub paths: PathsConfig, + pub migration: MigrationConfig, pub logging: LoggingConfig, pub account: AccountConfig, pub account_secret_contract: AccountSecretContractConfig, @@ -246,6 +250,11 @@ pub struct RuntimeConfig { pub rpc: RpcConfig, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MigrationConfig { + pub report: RadrootsMigrationReport, +} + #[derive(Debug, Default)] pub(crate) struct EnvFileValues(BTreeMap<String, String>); @@ -317,6 +326,7 @@ impl RuntimeConfig { env_file: &EnvFileValues, ) -> Result<Self, RuntimeError> { let paths = resolve_paths(env, env_file)?; + let migration = resolve_migration(paths.clone(), env); 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)? @@ -336,6 +346,7 @@ impl RuntimeConfig { dry_run: args.dry_run, }, paths: paths.clone(), + migration, logging: LoggingConfig { filter: args .log_filter @@ -451,6 +462,40 @@ impl RuntimeConfig { } } +fn resolve_migration(paths: PathsConfig, env: &dyn Environment) -> MigrationConfig { + MigrationConfig { + report: inspect_legacy_paths(legacy_path_candidates(&paths, env)), + } +} + +fn legacy_path_candidates( + paths: &PathsConfig, + env: &dyn Environment, +) -> Vec<RadrootsLegacyPathCandidate> { + let Some(home_dir) = env.var("HOME").map(PathBuf::from) else { + return Vec::new(); + }; + let old_user_config = home_dir.join(".config/radroots/config.toml"); + let old_user_state_root = home_dir.join(".local/share/radroots"); + + vec![ + RadrootsLegacyPathCandidate::new( + "cli_user_config_v0", + "legacy cli user config", + old_user_config, + Some(paths.app_config_path.clone()), + "merge this config into the canonical app config path; the cli will not copy it on startup", + ), + RadrootsLegacyPathCandidate::new( + "cli_user_state_root_v0", + "legacy cli user state root", + old_user_state_root, + Some(paths.app_data_root.clone()), + "export/import the old local state into the canonical app and shared namespaces; the cli will not move it on startup", + ), + ] +} + fn load_cli_config_file(path: &Path) -> Result<Option<CliConfigFile>, RuntimeError> { if !path.exists() { return Ok(None); diff --git a/tests/runtime_show.rs b/tests/runtime_show.rs @@ -168,6 +168,21 @@ fn config_show_json_reports_default_bootstrap_state() { .display() .to_string() ); + assert_eq!( + json["migration"]["posture"], + "explicit_operator_import_required" + ); + assert_eq!(json["migration"]["state"], "ready"); + assert_eq!(json["migration"]["silent_startup_relocation"], false); + assert_eq!( + json["migration"]["compatibility_window"], + "detect_and_report_only" + ); + assert_eq!( + json["migration"]["detected_legacy_paths"], + Value::Array(vec![]) + ); + assert_eq!(json["migration"]["actions"], Value::Array(vec![])); assert_eq!(json["logging"]["initialized"], true); assert_eq!(json["logging"]["stdout"], false); assert_eq!( @@ -260,6 +275,54 @@ fn config_show_json_reports_default_bootstrap_state() { } #[test] +fn config_show_json_reports_detected_legacy_cli_paths_without_moving_them() { + let dir = tempdir().expect("tempdir"); + let home = dir.path().join("home"); + let old_config = home.join(".config/radroots/config.toml"); + let old_state_root = home.join(".local/share/radroots"); + fs::create_dir_all(old_config.parent().expect("old config parent")).expect("old config dir"); + fs::create_dir_all(old_state_root.join("accounts")).expect("old state dir"); + fs::write(&old_config, "[relay]\nurls = []\n").expect("old config"); + fs::write(old_state_root.join("accounts/store.json"), "{}").expect("old store"); + + let output = runtime_show_command_in(dir.path()) + .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["migration"]["state"], "legacy_state_detected"); + assert_eq!(json["migration"]["silent_startup_relocation"], false); + let detected = json["migration"]["detected_legacy_paths"] + .as_array() + .expect("detected legacy paths array"); + assert_eq!(detected.len(), 2); + assert_eq!(detected[0]["id"], "cli_user_config_v0"); + assert_eq!(detected[0]["path"], old_config.display().to_string()); + assert_eq!( + detected[0]["destination"], + config_root(dir.path()) + .join("apps/cli/config.toml") + .display() + .to_string() + ); + assert_eq!(detected[1]["id"], "cli_user_state_root_v0"); + assert_eq!(detected[1]["path"], old_state_root.display().to_string()); + assert!( + json["migration"]["actions"] + .as_array() + .expect("actions") + .iter() + .any(|action| action + .as_str() + .is_some_and(|value| value.contains("startup did not move legacy data"))) + ); +} + +#[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");