config.rs (127283B)
1 use std::collections::BTreeMap; 2 use std::fs; 3 use std::io::IsTerminal; 4 use std::path::Path; 5 use std::path::PathBuf; 6 7 use radroots_local_events::{RelayUrlValidationError, normalize_relay_url}; 8 use radroots_runtime::{parse_bool_value, parse_strict_env_file, parse_u64_value}; 9 use radroots_runtime_paths::{ 10 RadrootsLegacyPathCandidate, RadrootsMigrationReport, RadrootsPathResolver, 11 inspect_legacy_paths, 12 }; 13 use radroots_secret_vault::{RadrootsHostVaultPolicy, RadrootsSecretBackend}; 14 use serde::Deserialize; 15 use url::Url; 16 17 use crate::cli::global::RuntimeInvocationArgs; 18 use crate::runtime::RuntimeError; 19 pub use crate::runtime::paths::PathsConfig; 20 use crate::runtime::paths::{ENV_CLI_PATHS_PROFILE, ENV_CLI_PATHS_REPO_LOCAL_ROOT, resolve_paths}; 21 22 const DEFAULT_LOG_FILTER: &str = "info"; 23 const DEFAULT_ENV_PATH: &str = ".env"; 24 const DEFAULT_LOCAL_STATE_DIR: &str = "replica"; 25 const DEFAULT_LOCAL_DB_FILE: &str = "replica.sqlite"; 26 const DEFAULT_LOCAL_BACKUPS_DIR: &str = "backups"; 27 const DEFAULT_LOCAL_EXPORTS_DIR: &str = "exports"; 28 const DEFAULT_SHARED_ACCOUNTS_STORE_FILE: &str = "store.json"; 29 const DEFAULT_MYC_STATUS_TIMEOUT_MS: u64 = 2_000; 30 const DEFAULT_HYF_EXECUTABLE: &str = "hyfd"; 31 const DEFAULT_RPC_URL: &str = "http://127.0.0.1:7070"; 32 const CLI_HOST_VAULT_POLICY: &str = "desktop"; 33 const CLI_DEFAULT_SECRET_BACKEND: &str = "host_vault"; 34 const CLI_DEFAULT_SECRET_FALLBACK: &str = "encrypted_file"; 35 const CLI_ALLOWED_SHARED_SECRET_BACKENDS: &[&str] = &["host_vault", "encrypted_file"]; 36 const CLI_USES_PROTECTED_STORE: bool = true; 37 const ENV_CLI_FILE_PATH: &str = "RADROOTS_CLI_ENV_FILE"; 38 const ENV_CLI_OUTPUT_FORMAT: &str = "RADROOTS_CLI_OUTPUT_FORMAT"; 39 const ENV_CLI_LOG_FILTER: &str = "RADROOTS_CLI_LOGGING_FILTER"; 40 const ENV_CLI_LOG_DIR: &str = "RADROOTS_CLI_LOGGING_OUTPUT_DIR"; 41 const ENV_CLI_LOG_STDOUT: &str = "RADROOTS_CLI_LOGGING_STDOUT"; 42 const ENV_CLI_ACCOUNT_SELECTOR: &str = "RADROOTS_CLI_ACCOUNT_SELECTOR"; 43 const ENV_CLI_ACCOUNT_SECRET_BACKEND: &str = "RADROOTS_CLI_ACCOUNT_SECRET_BACKEND"; 44 const ENV_CLI_ACCOUNT_SECRET_FALLBACK: &str = "RADROOTS_CLI_ACCOUNT_SECRET_FALLBACK"; 45 const ENV_CLI_IDENTITY_PATH: &str = "RADROOTS_CLI_IDENTITY_PATH"; 46 const ENV_CLI_SIGNER_BACKEND: &str = "RADROOTS_CLI_SIGNER_BACKEND"; 47 const ENV_CLI_PUBLISH_TRANSPORT: &str = "RADROOTS_CLI_PUBLISH_TRANSPORT"; 48 const ENV_CLI_RELAYS_URLS: &str = "RADROOTS_CLI_RELAYS_URLS"; 49 const ENV_CLI_RADROOTSD_PROXY_URL: &str = "RADROOTS_CLI_RADROOTSD_PROXY_URL"; 50 const ENV_CLI_RADROOTSD_PROXY_TOKEN_FILE: &str = "RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_FILE"; 51 const ENV_CLI_RADROOTSD_PROXY_TOKEN_SECRET_ID: &str = 52 "RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_SECRET_ID"; 53 const ENV_CLI_MYC_EXECUTABLE: &str = "RADROOTS_CLI_MYC_EXECUTABLE"; 54 const ENV_CLI_MYC_STATUS_TIMEOUT_MS: &str = "RADROOTS_CLI_MYC_STATUS_TIMEOUT_MS"; 55 const ENV_CLI_HYF_ENABLED: &str = "RADROOTS_CLI_HYF_ENABLED"; 56 const ENV_CLI_HYF_EXECUTABLE: &str = "RADROOTS_CLI_HYF_EXECUTABLE"; 57 const ENV_CLI_RHI_TRUSTED_WORKER_PUBKEYS: &str = "RADROOTS_CLI_RHI_TRUSTED_WORKER_PUBKEYS"; 58 const SUPPORTED_ENV_FILE_KEYS: &[&str] = &[ 59 ENV_CLI_OUTPUT_FORMAT, 60 ENV_CLI_LOG_FILTER, 61 ENV_CLI_LOG_DIR, 62 ENV_CLI_LOG_STDOUT, 63 ENV_CLI_PATHS_PROFILE, 64 ENV_CLI_PATHS_REPO_LOCAL_ROOT, 65 ENV_CLI_ACCOUNT_SELECTOR, 66 ENV_CLI_ACCOUNT_SECRET_BACKEND, 67 ENV_CLI_ACCOUNT_SECRET_FALLBACK, 68 ENV_CLI_IDENTITY_PATH, 69 ENV_CLI_SIGNER_BACKEND, 70 ENV_CLI_PUBLISH_TRANSPORT, 71 ENV_CLI_RELAYS_URLS, 72 ENV_CLI_RADROOTSD_PROXY_URL, 73 ENV_CLI_RADROOTSD_PROXY_TOKEN_FILE, 74 ENV_CLI_RADROOTSD_PROXY_TOKEN_SECRET_ID, 75 ENV_CLI_MYC_EXECUTABLE, 76 ENV_CLI_MYC_STATUS_TIMEOUT_MS, 77 ENV_CLI_HYF_ENABLED, 78 ENV_CLI_HYF_EXECUTABLE, 79 ENV_CLI_RHI_TRUSTED_WORKER_PUBKEYS, 80 ]; 81 82 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 83 pub enum OutputFormat { 84 Human, 85 Json, 86 Ndjson, 87 } 88 89 impl OutputFormat { 90 pub fn as_str(self) -> &'static str { 91 match self { 92 Self::Human => "human", 93 Self::Json => "json", 94 Self::Ndjson => "ndjson", 95 } 96 } 97 } 98 99 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 100 pub enum Verbosity { 101 Quiet, 102 Normal, 103 Verbose, 104 Trace, 105 } 106 107 impl Verbosity { 108 pub fn as_str(self) -> &'static str { 109 match self { 110 Self::Quiet => "quiet", 111 Self::Normal => "normal", 112 Self::Verbose => "verbose", 113 Self::Trace => "trace", 114 } 115 } 116 } 117 118 #[derive(Debug, Clone, PartialEq, Eq)] 119 pub struct OutputConfig { 120 pub format: OutputFormat, 121 pub verbosity: Verbosity, 122 pub color: bool, 123 pub dry_run: bool, 124 } 125 126 #[derive(Debug, Clone, PartialEq, Eq)] 127 pub struct InteractionConfig { 128 pub input_enabled: bool, 129 pub assume_yes: bool, 130 pub stdin_tty: bool, 131 pub stdout_tty: bool, 132 pub prompts_allowed: bool, 133 pub confirmations_allowed: bool, 134 } 135 136 #[derive(Debug, Clone, PartialEq, Eq)] 137 pub struct LoggingConfig { 138 pub filter: String, 139 pub directory: Option<PathBuf>, 140 pub stdout: bool, 141 } 142 143 #[derive(Debug, Clone, PartialEq, Eq)] 144 pub struct IdentityConfig { 145 pub path: PathBuf, 146 } 147 148 #[derive(Debug, Clone, PartialEq, Eq)] 149 pub struct AccountConfig { 150 pub selector: Option<String>, 151 pub store_path: PathBuf, 152 pub secrets_dir: PathBuf, 153 pub secret_backend: RadrootsSecretBackend, 154 pub secret_fallback: Option<RadrootsSecretBackend>, 155 } 156 157 #[derive(Debug, Clone, PartialEq, Eq)] 158 pub struct AccountSecretContractConfig { 159 pub default_backend: String, 160 pub default_fallback: Option<String>, 161 pub allowed_backends: Vec<String>, 162 pub host_vault_policy: Option<String>, 163 pub uses_protected_store: bool, 164 } 165 166 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 167 pub enum SignerBackend { 168 Local, 169 Myc, 170 } 171 172 impl SignerBackend { 173 pub fn as_str(self) -> &'static str { 174 match self { 175 Self::Local => "local", 176 Self::Myc => "myc", 177 } 178 } 179 } 180 181 #[derive(Debug, Clone, PartialEq, Eq)] 182 pub struct SignerConfig { 183 pub backend: SignerBackend, 184 } 185 186 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 187 pub enum PublishTransport { 188 DirectNostrRelay, 189 RadrootsdProxy, 190 } 191 192 impl PublishTransport { 193 pub fn as_str(self) -> &'static str { 194 match self { 195 Self::DirectNostrRelay => "direct_nostr_relay", 196 Self::RadrootsdProxy => "radrootsd_proxy", 197 } 198 } 199 200 pub fn transport_family(self) -> &'static str { 201 match self { 202 Self::DirectNostrRelay => "direct_nostr_relay", 203 Self::RadrootsdProxy => "radrootsd_proxy", 204 } 205 } 206 } 207 208 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 209 pub enum PublishTransportSource { 210 Flags, 211 Environment, 212 UserConfig, 213 WorkspaceConfig, 214 Defaults, 215 } 216 217 impl PublishTransportSource { 218 pub fn as_str(self) -> &'static str { 219 match self { 220 Self::Flags => "cli flags · local first", 221 Self::Environment => "environment · local first", 222 Self::UserConfig => "user config · local first", 223 Self::WorkspaceConfig => "workspace config · local first", 224 Self::Defaults => "defaults · local first", 225 } 226 } 227 } 228 229 #[derive(Debug, Clone, PartialEq, Eq)] 230 pub struct PublishConfig { 231 pub transport: PublishTransport, 232 pub source: PublishTransportSource, 233 pub radrootsd_proxy: RadrootsdProxyConfig, 234 } 235 236 #[derive(Debug, Clone, PartialEq, Eq)] 237 pub struct RadrootsdProxyConfig { 238 pub url: String, 239 pub token_file: Option<PathBuf>, 240 pub token_secret_id: Option<String>, 241 } 242 243 impl Default for RadrootsdProxyConfig { 244 fn default() -> Self { 245 Self { 246 url: DEFAULT_RPC_URL.to_owned(), 247 token_file: None, 248 token_secret_id: None, 249 } 250 } 251 } 252 253 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 254 pub enum RelayPublishPolicy { 255 Any, 256 } 257 258 impl RelayPublishPolicy { 259 pub fn as_str(self) -> &'static str { 260 match self { 261 Self::Any => "any", 262 } 263 } 264 } 265 266 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 267 pub enum RelayConfigSource { 268 Flags, 269 Environment, 270 UserConfig, 271 WorkspaceConfig, 272 Defaults, 273 } 274 275 impl RelayConfigSource { 276 pub fn as_str(self) -> &'static str { 277 match self { 278 Self::Flags => "cli flags · local first", 279 Self::Environment => "environment · local first", 280 Self::UserConfig => "user config · local first", 281 Self::WorkspaceConfig => "workspace config · local first", 282 Self::Defaults => "defaults · local first", 283 } 284 } 285 } 286 287 #[derive(Debug, Clone, PartialEq, Eq)] 288 pub struct RelayConfig { 289 pub urls: Vec<String>, 290 pub publish_policy: RelayPublishPolicy, 291 pub source: RelayConfigSource, 292 } 293 294 #[derive(Debug, Clone, PartialEq, Eq)] 295 pub struct LocalConfig { 296 pub root: PathBuf, 297 pub replica_db_path: PathBuf, 298 pub backups_dir: PathBuf, 299 pub exports_dir: PathBuf, 300 } 301 302 #[derive(Debug, Clone, PartialEq, Eq)] 303 pub struct MycConfig { 304 pub executable: PathBuf, 305 pub status_timeout_ms: u64, 306 } 307 308 #[derive(Debug, Clone, PartialEq, Eq)] 309 pub struct HyfConfig { 310 pub enabled: bool, 311 pub executable: PathBuf, 312 } 313 314 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 315 pub enum CapabilityBindingTargetKind { 316 ManagedInstance, 317 ExplicitEndpoint, 318 } 319 320 impl CapabilityBindingTargetKind { 321 pub fn as_str(self) -> &'static str { 322 match self { 323 Self::ManagedInstance => "managed_instance", 324 Self::ExplicitEndpoint => "explicit_endpoint", 325 } 326 } 327 } 328 329 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 330 pub enum CapabilityBindingSource { 331 UserConfig, 332 WorkspaceConfig, 333 } 334 335 impl CapabilityBindingSource { 336 pub fn as_str(self) -> &'static str { 337 match self { 338 Self::UserConfig => "user config [[capability_binding]]", 339 Self::WorkspaceConfig => "workspace config [[capability_binding]]", 340 } 341 } 342 } 343 344 #[derive(Debug, Clone, PartialEq, Eq)] 345 pub struct CapabilityBindingConfig { 346 pub capability_id: String, 347 pub provider_runtime_id: String, 348 pub binding_model: String, 349 pub target_kind: CapabilityBindingTargetKind, 350 pub target: String, 351 pub managed_account_ref: Option<String>, 352 pub signer_session_ref: Option<String>, 353 pub source: CapabilityBindingSource, 354 } 355 356 #[cfg(test)] 357 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 358 pub enum CapabilityBindingInspectionState { 359 Configured, 360 NotConfigured, 361 Disabled, 362 } 363 364 #[cfg(test)] 365 #[derive(Debug, Clone, PartialEq, Eq)] 366 pub struct CapabilityBindingInspection { 367 pub capability_id: String, 368 pub provider_runtime_id: String, 369 pub binding_model: String, 370 pub state: CapabilityBindingInspectionState, 371 pub source: String, 372 pub target_kind: Option<String>, 373 pub target: Option<String>, 374 pub managed_account_ref: Option<String>, 375 pub signer_session_ref: Option<String>, 376 } 377 378 #[derive(Debug, Clone, PartialEq, Eq)] 379 pub struct RpcConfig { 380 pub url: String, 381 } 382 383 #[derive(Debug, Clone, PartialEq, Eq)] 384 pub struct RhiConfig { 385 pub trusted_worker_pubkeys: Vec<String>, 386 } 387 388 #[derive(Debug, Clone, PartialEq, Eq)] 389 pub struct RuntimeConfig { 390 pub output: OutputConfig, 391 pub interaction: InteractionConfig, 392 pub paths: PathsConfig, 393 pub migration: MigrationConfig, 394 pub logging: LoggingConfig, 395 pub account: AccountConfig, 396 pub account_secret_contract: AccountSecretContractConfig, 397 pub identity: IdentityConfig, 398 pub signer: SignerConfig, 399 pub publish: PublishConfig, 400 pub relay: RelayConfig, 401 pub local: LocalConfig, 402 pub myc: MycConfig, 403 pub hyf: HyfConfig, 404 pub rpc: RpcConfig, 405 pub rhi: RhiConfig, 406 pub capability_bindings: Vec<CapabilityBindingConfig>, 407 } 408 409 #[derive(Debug, Clone, PartialEq, Eq)] 410 pub struct MigrationConfig { 411 pub report: RadrootsMigrationReport, 412 } 413 414 #[derive(Debug, Default)] 415 pub(crate) struct EnvFileValues(BTreeMap<String, String>); 416 417 impl EnvFileValues { 418 pub(crate) fn get(&self, key: &str) -> Option<&str> { 419 self.0.get(key).map(String::as_str) 420 } 421 } 422 423 #[derive(Debug, Default, Deserialize)] 424 #[serde(default, deny_unknown_fields)] 425 struct CliConfigFile { 426 output: Option<OutputFileConfig>, 427 logging: Option<LoggingFileConfig>, 428 account: Option<AccountFileConfig>, 429 identity: Option<IdentityFileConfig>, 430 relays: Option<RelayFileConfig>, 431 publish: Option<PublishFileConfig>, 432 signer: Option<SignerFileConfig>, 433 myc: Option<MycFileConfig>, 434 hyf: Option<HyfFileConfig>, 435 rpc: Option<RpcFileConfig>, 436 rhi: Option<RhiFileConfig>, 437 capability_binding: Option<Vec<CapabilityBindingFileConfig>>, 438 } 439 440 #[derive(Debug, Default, Deserialize)] 441 #[serde(default, deny_unknown_fields)] 442 struct OutputFileConfig { 443 format: Option<String>, 444 } 445 446 #[derive(Debug, Default, Deserialize)] 447 #[serde(default, deny_unknown_fields)] 448 struct LoggingFileConfig { 449 filter: Option<String>, 450 output_dir: Option<PathBuf>, 451 stdout: Option<bool>, 452 } 453 454 #[derive(Debug, Default, Deserialize)] 455 #[serde(default, deny_unknown_fields)] 456 struct AccountFileConfig { 457 selector: Option<String>, 458 secret: Option<AccountSecretFileConfig>, 459 } 460 461 #[derive(Debug, Default, Deserialize)] 462 #[serde(default, deny_unknown_fields)] 463 struct AccountSecretFileConfig { 464 backend: Option<String>, 465 fallback: Option<String>, 466 } 467 468 #[derive(Debug, Default, Deserialize)] 469 #[serde(default, deny_unknown_fields)] 470 struct IdentityFileConfig { 471 path: Option<PathBuf>, 472 } 473 474 #[derive(Debug, Default, Deserialize)] 475 #[serde(default, deny_unknown_fields)] 476 struct RelayFileConfig { 477 urls: Option<Vec<String>>, 478 publish_policy: Option<String>, 479 } 480 481 #[derive(Debug, Default, Deserialize)] 482 #[serde(default, deny_unknown_fields)] 483 struct PublishFileConfig { 484 transport: Option<String>, 485 radrootsd_proxy: Option<RadrootsdProxyFileConfig>, 486 } 487 488 #[derive(Debug, Default, Deserialize)] 489 #[serde(default, deny_unknown_fields)] 490 struct RadrootsdProxyFileConfig { 491 url: Option<String>, 492 token_file: Option<PathBuf>, 493 token_secret_id: Option<String>, 494 } 495 496 #[derive(Debug, Default, Deserialize)] 497 #[serde(default, deny_unknown_fields)] 498 struct RpcFileConfig { 499 url: Option<String>, 500 } 501 502 #[derive(Debug, Default, Deserialize)] 503 #[serde(default, deny_unknown_fields)] 504 struct RhiFileConfig { 505 trusted_worker_pubkeys: Option<Vec<String>>, 506 } 507 508 #[derive(Debug, Default, Deserialize)] 509 #[serde(default, deny_unknown_fields)] 510 struct MycFileConfig { 511 executable: Option<PathBuf>, 512 status_timeout_ms: Option<u64>, 513 } 514 515 #[derive(Debug, Default, Deserialize)] 516 #[serde(default, deny_unknown_fields)] 517 struct SignerFileConfig { 518 backend: Option<String>, 519 } 520 521 #[derive(Debug, Default, Deserialize)] 522 #[serde(default, deny_unknown_fields)] 523 struct HyfFileConfig { 524 enabled: Option<bool>, 525 executable: Option<PathBuf>, 526 } 527 528 #[derive(Debug, Clone, Deserialize)] 529 #[serde(deny_unknown_fields)] 530 struct CapabilityBindingFileConfig { 531 capability: String, 532 provider: String, 533 target_kind: String, 534 target: String, 535 managed_account_ref: Option<String>, 536 signer_session_ref: Option<String>, 537 } 538 539 #[derive(Debug, Clone, Copy)] 540 struct CapabilityBindingSpec { 541 capability_id: &'static str, 542 provider_runtime_id: &'static str, 543 binding_model: &'static str, 544 } 545 546 pub(crate) const SIGNER_REMOTE_NIP46_CAPABILITY: &str = "signer.remote_nip46"; 547 pub(crate) const INFERENCE_HYF_STDIO_CAPABILITY: &str = "inference.hyf_stdio"; 548 const CAPABILITY_BINDING_SPECS: &[CapabilityBindingSpec] = &[ 549 CapabilityBindingSpec { 550 capability_id: SIGNER_REMOTE_NIP46_CAPABILITY, 551 provider_runtime_id: "myc", 552 binding_model: "session_authorized_remote_signer", 553 }, 554 CapabilityBindingSpec { 555 capability_id: INFERENCE_HYF_STDIO_CAPABILITY, 556 provider_runtime_id: "hyf", 557 binding_model: "stdio_service", 558 }, 559 ]; 560 561 pub(crate) trait Environment { 562 fn var(&self, key: &str) -> Option<String>; 563 fn current_dir(&self) -> Result<PathBuf, RuntimeError>; 564 fn path_resolver(&self) -> RadrootsPathResolver; 565 fn stdin_is_tty(&self) -> bool; 566 fn stdout_is_tty(&self) -> bool; 567 } 568 569 pub struct SystemEnvironment; 570 571 impl Environment for SystemEnvironment { 572 fn var(&self, key: &str) -> Option<String> { 573 std::env::var(key).ok() 574 } 575 576 fn current_dir(&self) -> Result<PathBuf, RuntimeError> { 577 std::env::current_dir().map_err(|err| { 578 RuntimeError::Config(format!("failed to resolve current directory: {err}")) 579 }) 580 } 581 582 fn path_resolver(&self) -> RadrootsPathResolver { 583 RadrootsPathResolver::current() 584 } 585 586 fn stdin_is_tty(&self) -> bool { 587 std::io::stdin().is_terminal() 588 } 589 590 fn stdout_is_tty(&self) -> bool { 591 std::io::stdout().is_terminal() 592 } 593 } 594 595 impl RuntimeConfig { 596 pub fn from_system(args: &RuntimeInvocationArgs) -> Result<Self, RuntimeError> { 597 let system = SystemEnvironment; 598 let env_file_path = resolve_env_file_path(args, &system); 599 let env_file = load_env_file_values(env_file_path.as_deref())?; 600 Self::resolve_with_env_file(args, &system, &env_file) 601 } 602 603 fn resolve_with_env_file( 604 args: &RuntimeInvocationArgs, 605 env: &dyn Environment, 606 env_file: &EnvFileValues, 607 ) -> Result<Self, RuntimeError> { 608 let paths = resolve_paths(env, env_file)?; 609 let migration = resolve_migration(paths.clone(), env); 610 let workspace_config = paths 611 .workspace_config_path 612 .as_deref() 613 .map(load_cli_config_file) 614 .transpose()? 615 .flatten(); 616 let app_config = load_cli_config_file(paths.app_config_path.as_path())?; 617 let account_secret_backend = resolve_account_secret_backend( 618 env, 619 env_file, 620 app_config.as_ref(), 621 workspace_config.as_ref(), 622 )? 623 .unwrap_or(RadrootsSecretBackend::HostVault( 624 RadrootsHostVaultPolicy::desktop(), 625 )); 626 let account_secret_fallback = resolve_account_secret_fallback( 627 env, 628 env_file, 629 app_config.as_ref(), 630 workspace_config.as_ref(), 631 )? 632 .unwrap_or(match account_secret_backend { 633 RadrootsSecretBackend::HostVault(_) => Some(RadrootsSecretBackend::EncryptedFile), 634 _ => None, 635 }); 636 let output = OutputConfig { 637 format: resolve_output_format( 638 args, 639 env, 640 env_file, 641 app_config.as_ref(), 642 workspace_config.as_ref(), 643 )?, 644 verbosity: resolve_verbosity(args)?, 645 color: !args.no_color, 646 dry_run: args.dry_run, 647 }; 648 let logging = LoggingConfig { 649 filter: resolve_logging_filter( 650 args, 651 env, 652 env_file, 653 app_config.as_ref(), 654 workspace_config.as_ref(), 655 ), 656 directory: resolve_logging_directory( 657 args, 658 env, 659 env_file, 660 app_config.as_ref(), 661 workspace_config.as_ref(), 662 paths.app_logs_root.as_path(), 663 ), 664 stdout: resolve_logging_stdout( 665 args, 666 env, 667 env_file, 668 app_config.as_ref(), 669 workspace_config.as_ref(), 670 )?, 671 }; 672 validate_logging_output_contract(&output, &logging)?; 673 Ok(Self { 674 capability_bindings: resolve_capability_bindings( 675 app_config.as_ref(), 676 workspace_config.as_ref(), 677 )?, 678 output, 679 interaction: resolve_interaction_config(args, env), 680 paths: paths.clone(), 681 migration, 682 logging, 683 account: AccountConfig { 684 selector: args 685 .account 686 .clone() 687 .or_else(|| env_value(env, env_file, &[ENV_CLI_ACCOUNT_SELECTOR])) 688 .or_else(|| { 689 app_config 690 .as_ref() 691 .and_then(|config| config.account.as_ref()) 692 .and_then(|account| account.selector.clone()) 693 }) 694 .or_else(|| { 695 workspace_config 696 .as_ref() 697 .and_then(|config| config.account.as_ref()) 698 .and_then(|account| account.selector.clone()) 699 }), 700 store_path: paths 701 .shared_accounts_data_root 702 .join(DEFAULT_SHARED_ACCOUNTS_STORE_FILE), 703 secrets_dir: paths.shared_accounts_secrets_root.clone(), 704 secret_backend: account_secret_backend, 705 secret_fallback: account_secret_fallback, 706 }, 707 account_secret_contract: AccountSecretContractConfig { 708 default_backend: CLI_DEFAULT_SECRET_BACKEND.to_owned(), 709 default_fallback: Some(CLI_DEFAULT_SECRET_FALLBACK.to_owned()), 710 allowed_backends: CLI_ALLOWED_SHARED_SECRET_BACKENDS 711 .iter() 712 .map(|value| (*value).to_owned()) 713 .collect(), 714 host_vault_policy: Some(CLI_HOST_VAULT_POLICY.to_owned()), 715 uses_protected_store: CLI_USES_PROTECTED_STORE, 716 }, 717 identity: IdentityConfig { 718 path: args 719 .identity_path 720 .clone() 721 .or_else(|| { 722 env_value(env, env_file, &[ENV_CLI_IDENTITY_PATH]).map(PathBuf::from) 723 }) 724 .or_else(|| { 725 app_config 726 .as_ref() 727 .and_then(|config| config.identity.as_ref()) 728 .and_then(|identity| identity.path.clone()) 729 }) 730 .or_else(|| { 731 workspace_config 732 .as_ref() 733 .and_then(|config| config.identity.as_ref()) 734 .and_then(|identity| identity.path.clone()) 735 }) 736 .unwrap_or_else(|| paths.default_identity_path.clone()), 737 }, 738 signer: resolve_signer_config( 739 args, 740 env, 741 env_file, 742 app_config.as_ref(), 743 workspace_config.as_ref(), 744 )?, 745 publish: resolve_publish_config( 746 args, 747 env, 748 env_file, 749 app_config.as_ref(), 750 workspace_config.as_ref(), 751 )?, 752 relay: resolve_relay_config( 753 args, 754 env, 755 env_file, 756 app_config.as_ref(), 757 workspace_config.as_ref(), 758 )?, 759 local: LocalConfig { 760 root: paths.app_data_root.join(DEFAULT_LOCAL_STATE_DIR), 761 replica_db_path: paths 762 .app_data_root 763 .join(DEFAULT_LOCAL_STATE_DIR) 764 .join(DEFAULT_LOCAL_DB_FILE), 765 backups_dir: paths 766 .app_data_root 767 .join(DEFAULT_LOCAL_STATE_DIR) 768 .join(DEFAULT_LOCAL_BACKUPS_DIR), 769 exports_dir: paths 770 .app_data_root 771 .join(DEFAULT_LOCAL_STATE_DIR) 772 .join(DEFAULT_LOCAL_EXPORTS_DIR), 773 }, 774 myc: resolve_myc_config( 775 args, 776 env, 777 env_file, 778 app_config.as_ref(), 779 workspace_config.as_ref(), 780 )?, 781 hyf: HyfConfig { 782 enabled: resolve_hyf_enabled( 783 args, 784 env, 785 env_file, 786 app_config.as_ref(), 787 workspace_config.as_ref(), 788 )?, 789 executable: resolve_hyf_executable( 790 args, 791 env, 792 env_file, 793 app_config.as_ref(), 794 workspace_config.as_ref(), 795 ), 796 }, 797 rpc: resolve_rpc_config( 798 env, 799 env_file, 800 app_config.as_ref(), 801 workspace_config.as_ref(), 802 )?, 803 rhi: resolve_rhi_config( 804 env, 805 env_file, 806 app_config.as_ref(), 807 workspace_config.as_ref(), 808 )?, 809 }) 810 } 811 812 #[cfg(test)] 813 pub fn inspect_capability_bindings(&self) -> Vec<CapabilityBindingInspection> { 814 CAPABILITY_BINDING_SPECS 815 .iter() 816 .map(|spec| { 817 if let Some(binding) = self 818 .capability_bindings 819 .iter() 820 .find(|binding| binding.capability_id == spec.capability_id) 821 { 822 return CapabilityBindingInspection { 823 capability_id: binding.capability_id.clone(), 824 provider_runtime_id: binding.provider_runtime_id.clone(), 825 binding_model: binding.binding_model.clone(), 826 state: CapabilityBindingInspectionState::Configured, 827 source: binding.source.as_str().to_owned(), 828 target_kind: Some(binding.target_kind.as_str().to_owned()), 829 target: Some(binding.target.clone()), 830 managed_account_ref: binding.managed_account_ref.clone(), 831 signer_session_ref: binding.signer_session_ref.clone(), 832 }; 833 } 834 835 let (state, source) = match spec.capability_id { 836 SIGNER_REMOTE_NIP46_CAPABILITY 837 if matches!(self.signer.backend, SignerBackend::Local) => 838 { 839 ( 840 CapabilityBindingInspectionState::Disabled, 841 "independent local signer mode".to_owned(), 842 ) 843 } 844 INFERENCE_HYF_STDIO_CAPABILITY if !self.hyf.enabled => ( 845 CapabilityBindingInspectionState::Disabled, 846 "hyf disabled by config".to_owned(), 847 ), 848 _ => ( 849 CapabilityBindingInspectionState::NotConfigured, 850 "no explicit capability binding".to_owned(), 851 ), 852 }; 853 854 CapabilityBindingInspection { 855 capability_id: spec.capability_id.to_owned(), 856 provider_runtime_id: spec.provider_runtime_id.to_owned(), 857 binding_model: spec.binding_model.to_owned(), 858 state, 859 source, 860 target_kind: None, 861 target: None, 862 managed_account_ref: None, 863 signer_session_ref: None, 864 } 865 }) 866 .collect() 867 } 868 869 pub fn capability_binding(&self, capability_id: &str) -> Option<&CapabilityBindingConfig> { 870 self.capability_bindings 871 .iter() 872 .find(|binding| binding.capability_id == capability_id) 873 } 874 } 875 876 fn resolve_migration(paths: PathsConfig, env: &dyn Environment) -> MigrationConfig { 877 MigrationConfig { 878 report: inspect_legacy_paths(legacy_path_candidates(&paths, env)), 879 } 880 } 881 882 fn legacy_path_candidates( 883 paths: &PathsConfig, 884 env: &dyn Environment, 885 ) -> Vec<RadrootsLegacyPathCandidate> { 886 let Some(home_dir) = env.var("HOME").map(PathBuf::from) else { 887 return Vec::new(); 888 }; 889 let old_user_config = home_dir.join(".config/radroots/config.toml"); 890 let old_user_state_root = home_dir.join(".local/share/radroots"); 891 892 vec![ 893 RadrootsLegacyPathCandidate::new( 894 "cli_user_config_v0", 895 "legacy cli user config", 896 old_user_config, 897 Some(paths.app_config_path.clone()), 898 "merge this config into the canonical app config path; the cli will not copy it on startup", 899 ), 900 RadrootsLegacyPathCandidate::new( 901 "cli_user_state_root_v0", 902 "legacy cli user state root", 903 old_user_state_root, 904 Some(paths.app_data_root.clone()), 905 "export/import the old local state into the canonical app and shared namespaces; the cli will not move it on startup", 906 ), 907 ] 908 } 909 910 fn load_cli_config_file(path: &Path) -> Result<Option<CliConfigFile>, RuntimeError> { 911 if !path.exists() { 912 return Ok(None); 913 } 914 915 let raw = fs::read_to_string(path).map_err(|err| { 916 RuntimeError::Config(format!( 917 "failed to read config file {}: {err}", 918 path.display() 919 )) 920 })?; 921 922 if raw.trim().is_empty() { 923 return Ok(Some(CliConfigFile::default())); 924 } 925 926 toml::from_str::<CliConfigFile>(&raw) 927 .map(Some) 928 .map_err(|err| { 929 RuntimeError::Config(format!( 930 "failed to parse config file {}: {err}", 931 path.display() 932 )) 933 }) 934 } 935 936 fn resolve_rpc_config( 937 _env: &dyn Environment, 938 _env_file: &EnvFileValues, 939 user_config: Option<&CliConfigFile>, 940 workspace_config: Option<&CliConfigFile>, 941 ) -> Result<RpcConfig, RuntimeError> { 942 let url = user_config 943 .and_then(|config| config.rpc.as_ref()) 944 .and_then(|rpc| rpc.url.clone()) 945 .or_else(|| { 946 workspace_config 947 .and_then(|config| config.rpc.as_ref()) 948 .and_then(|rpc| rpc.url.clone()) 949 }) 950 .unwrap_or_else(|| DEFAULT_RPC_URL.to_owned()); 951 952 Ok(RpcConfig { 953 url: validate_rpc_url(url.as_str())?, 954 }) 955 } 956 957 fn resolve_radrootsd_proxy_config( 958 env: &dyn Environment, 959 env_file: &EnvFileValues, 960 user_config: Option<&CliConfigFile>, 961 workspace_config: Option<&CliConfigFile>, 962 ) -> Result<RadrootsdProxyConfig, RuntimeError> { 963 let user_proxy = user_config 964 .and_then(|config| config.publish.as_ref()) 965 .and_then(|publish| publish.radrootsd_proxy.as_ref()); 966 let workspace_proxy = workspace_config 967 .and_then(|config| config.publish.as_ref()) 968 .and_then(|publish| publish.radrootsd_proxy.as_ref()); 969 let url = env_value(env, env_file, &[ENV_CLI_RADROOTSD_PROXY_URL]) 970 .or_else(|| user_proxy.and_then(|proxy| proxy.url.clone())) 971 .or_else(|| workspace_proxy.and_then(|proxy| proxy.url.clone())) 972 .unwrap_or_else(|| DEFAULT_RPC_URL.to_owned()); 973 let token_file = env_value(env, env_file, &[ENV_CLI_RADROOTSD_PROXY_TOKEN_FILE]) 974 .map(PathBuf::from) 975 .or_else(|| user_proxy.and_then(|proxy| proxy.token_file.clone())) 976 .or_else(|| workspace_proxy.and_then(|proxy| proxy.token_file.clone())); 977 let token_secret_id = env_value(env, env_file, &[ENV_CLI_RADROOTSD_PROXY_TOKEN_SECRET_ID]) 978 .or_else(|| user_proxy.and_then(|proxy| proxy.token_secret_id.clone())) 979 .or_else(|| workspace_proxy.and_then(|proxy| proxy.token_secret_id.clone())); 980 981 Ok(RadrootsdProxyConfig { 982 url: validate_rpc_url(url.as_str())?, 983 token_file, 984 token_secret_id, 985 }) 986 } 987 988 fn resolve_rhi_config( 989 env: &dyn Environment, 990 env_file: &EnvFileValues, 991 user_config: Option<&CliConfigFile>, 992 workspace_config: Option<&CliConfigFile>, 993 ) -> Result<RhiConfig, RuntimeError> { 994 let trusted_worker_pubkeys = 995 if let Some(value) = env_value(env, env_file, &[ENV_CLI_RHI_TRUSTED_WORKER_PUBKEYS]) { 996 parse_pubkey_env_value(value.as_str(), ENV_CLI_RHI_TRUSTED_WORKER_PUBKEYS)? 997 } else if let Some(values) = user_config 998 .and_then(|config| config.rhi.as_ref()) 999 .and_then(|rhi| rhi.trusted_worker_pubkeys.clone()) 1000 { 1001 normalize_pubkeys(values, "user config [rhi].trusted_worker_pubkeys")? 1002 } else if let Some(values) = workspace_config 1003 .and_then(|config| config.rhi.as_ref()) 1004 .and_then(|rhi| rhi.trusted_worker_pubkeys.clone()) 1005 { 1006 normalize_pubkeys(values, "workspace config [rhi].trusted_worker_pubkeys")? 1007 } else { 1008 Vec::new() 1009 }; 1010 1011 Ok(RhiConfig { 1012 trusted_worker_pubkeys, 1013 }) 1014 } 1015 1016 fn parse_pubkey_env_value(value: &str, key: &str) -> Result<Vec<String>, RuntimeError> { 1017 let entries = value 1018 .split(',') 1019 .map(str::trim) 1020 .filter(|entry| !entry.is_empty()) 1021 .map(ToOwned::to_owned) 1022 .collect::<Vec<_>>(); 1023 normalize_pubkeys(entries, key) 1024 } 1025 1026 fn normalize_pubkeys(values: Vec<String>, source: &str) -> Result<Vec<String>, RuntimeError> { 1027 let mut normalized = Vec::new(); 1028 for value in values { 1029 let pubkey = validate_pubkey(value.as_str(), source)?; 1030 if !normalized.iter().any(|existing| existing == &pubkey) { 1031 normalized.push(pubkey); 1032 } 1033 } 1034 Ok(normalized) 1035 } 1036 1037 fn validate_pubkey(value: &str, source: &str) -> Result<String, RuntimeError> { 1038 let trimmed = value.trim(); 1039 if trimmed.len() != 64 || !trimmed.chars().all(|char| char.is_ascii_hexdigit()) { 1040 return Err(RuntimeError::Config(format!( 1041 "{source} must contain 64-character hex Nostr public keys" 1042 ))); 1043 } 1044 Ok(trimmed.to_ascii_lowercase()) 1045 } 1046 1047 fn resolve_capability_bindings( 1048 user_config: Option<&CliConfigFile>, 1049 workspace_config: Option<&CliConfigFile>, 1050 ) -> Result<Vec<CapabilityBindingConfig>, RuntimeError> { 1051 let workspace = resolve_file_capability_bindings( 1052 workspace_config.and_then(|config| config.capability_binding.as_deref()), 1053 CapabilityBindingSource::WorkspaceConfig, 1054 )?; 1055 let user = resolve_file_capability_bindings( 1056 user_config.and_then(|config| config.capability_binding.as_deref()), 1057 CapabilityBindingSource::UserConfig, 1058 )?; 1059 1060 let mut merged = BTreeMap::new(); 1061 for binding in workspace.into_iter().chain(user) { 1062 merged.insert(binding.capability_id.clone(), binding); 1063 } 1064 1065 Ok(CAPABILITY_BINDING_SPECS 1066 .iter() 1067 .filter_map(|spec| merged.remove(spec.capability_id)) 1068 .collect()) 1069 } 1070 1071 fn resolve_file_capability_bindings( 1072 bindings: Option<&[CapabilityBindingFileConfig]>, 1073 source: CapabilityBindingSource, 1074 ) -> Result<Vec<CapabilityBindingConfig>, RuntimeError> { 1075 let Some(bindings) = bindings else { 1076 return Ok(Vec::new()); 1077 }; 1078 1079 let mut seen = BTreeMap::new(); 1080 let mut resolved = Vec::with_capacity(bindings.len()); 1081 1082 for binding in bindings { 1083 let capability = binding.capability.trim(); 1084 let provider = binding.provider.trim(); 1085 let Some(spec) = capability_binding_spec(capability) else { 1086 return Err(RuntimeError::Config(format!( 1087 "unknown capability_binding capability `{capability}`" 1088 ))); 1089 }; 1090 if provider != spec.provider_runtime_id { 1091 return Err(RuntimeError::Config(format!( 1092 "capability_binding `{capability}` must use provider `{}`, got `{provider}`", 1093 spec.provider_runtime_id 1094 ))); 1095 } 1096 if seen.insert(spec.capability_id.to_owned(), ()).is_some() { 1097 return Err(RuntimeError::Config(format!( 1098 "capability_binding `{capability}` is duplicated in one config file" 1099 ))); 1100 } 1101 1102 let target = binding.target.trim(); 1103 if target.is_empty() { 1104 return Err(RuntimeError::Config(format!( 1105 "capability_binding `{capability}` target must not be empty" 1106 ))); 1107 } 1108 1109 let managed_account_ref = normalize_binding_ref( 1110 binding 1111 .managed_account_ref 1112 .as_deref() 1113 .map(str::trim) 1114 .filter(|value| !value.is_empty()), 1115 ); 1116 let signer_session_ref = normalize_binding_ref( 1117 binding 1118 .signer_session_ref 1119 .as_deref() 1120 .map(str::trim) 1121 .filter(|value| !value.is_empty()), 1122 ); 1123 if spec.capability_id != SIGNER_REMOTE_NIP46_CAPABILITY 1124 && (managed_account_ref.is_some() || signer_session_ref.is_some()) 1125 { 1126 return Err(RuntimeError::Config(format!( 1127 "capability_binding `{capability}` may not set managed_account_ref or signer_session_ref" 1128 ))); 1129 } 1130 1131 resolved.push(CapabilityBindingConfig { 1132 capability_id: spec.capability_id.to_owned(), 1133 provider_runtime_id: spec.provider_runtime_id.to_owned(), 1134 binding_model: spec.binding_model.to_owned(), 1135 target_kind: parse_capability_binding_target_kind( 1136 binding.target_kind.as_str(), 1137 spec.capability_id, 1138 )?, 1139 target: target.to_owned(), 1140 managed_account_ref, 1141 signer_session_ref, 1142 source, 1143 }); 1144 } 1145 1146 Ok(resolved) 1147 } 1148 1149 fn capability_binding_spec(capability_id: &str) -> Option<CapabilityBindingSpec> { 1150 CAPABILITY_BINDING_SPECS 1151 .iter() 1152 .copied() 1153 .find(|spec| spec.capability_id == capability_id) 1154 } 1155 1156 fn parse_capability_binding_target_kind( 1157 value: &str, 1158 capability_id: &str, 1159 ) -> Result<CapabilityBindingTargetKind, RuntimeError> { 1160 match value.trim().to_ascii_lowercase().as_str() { 1161 "managed_instance" => Ok(CapabilityBindingTargetKind::ManagedInstance), 1162 "explicit_endpoint" => Ok(CapabilityBindingTargetKind::ExplicitEndpoint), 1163 other => Err(RuntimeError::Config(format!( 1164 "capability_binding `{capability_id}` target_kind must be `managed_instance` or `explicit_endpoint`, got `{other}`" 1165 ))), 1166 } 1167 } 1168 1169 fn normalize_binding_ref(value: Option<&str>) -> Option<String> { 1170 value.map(ToOwned::to_owned) 1171 } 1172 1173 fn resolve_relay_config( 1174 args: &RuntimeInvocationArgs, 1175 env: &dyn Environment, 1176 env_file: &EnvFileValues, 1177 user_config: Option<&CliConfigFile>, 1178 workspace_config: Option<&CliConfigFile>, 1179 ) -> Result<RelayConfig, RuntimeError> { 1180 let publish_policy = resolve_relay_publish_policy(user_config, workspace_config)? 1181 .unwrap_or(RelayPublishPolicy::Any); 1182 1183 if !args.relay.is_empty() { 1184 return Ok(RelayConfig { 1185 urls: normalize_relay_urls(args.relay.clone(), "--relay")?, 1186 publish_policy, 1187 source: RelayConfigSource::Flags, 1188 }); 1189 } 1190 1191 if let Some(value) = env_value(env, env_file, &[ENV_CLI_RELAYS_URLS]) { 1192 return Ok(RelayConfig { 1193 urls: parse_relay_env_value(value.as_str(), ENV_CLI_RELAYS_URLS)?, 1194 publish_policy, 1195 source: RelayConfigSource::Environment, 1196 }); 1197 } 1198 1199 if let Some(relay) = user_config.and_then(|config| config.relays.as_ref()) { 1200 if let Some(urls) = relay.urls.clone() { 1201 return Ok(RelayConfig { 1202 urls: normalize_relay_urls(urls, "user config [relays].urls")?, 1203 publish_policy, 1204 source: RelayConfigSource::UserConfig, 1205 }); 1206 } 1207 } 1208 1209 if let Some(relay) = workspace_config.and_then(|config| config.relays.as_ref()) { 1210 if let Some(urls) = relay.urls.clone() { 1211 return Ok(RelayConfig { 1212 urls: normalize_relay_urls(urls, "workspace config [relays].urls")?, 1213 publish_policy, 1214 source: RelayConfigSource::WorkspaceConfig, 1215 }); 1216 } 1217 } 1218 1219 Ok(RelayConfig { 1220 urls: Vec::new(), 1221 publish_policy, 1222 source: RelayConfigSource::Defaults, 1223 }) 1224 } 1225 1226 fn resolve_signer_config( 1227 args: &RuntimeInvocationArgs, 1228 env: &dyn Environment, 1229 env_file: &EnvFileValues, 1230 user_config: Option<&CliConfigFile>, 1231 workspace_config: Option<&CliConfigFile>, 1232 ) -> Result<SignerConfig, RuntimeError> { 1233 let backend = if let Some(value) = args.signer.clone() { 1234 parse_signer_mode("internal invocation signer mode", value)? 1235 } else if let Some((key, value)) = env_value_entry(env, env_file, &[ENV_CLI_SIGNER_BACKEND]) { 1236 parse_signer_mode(key.as_str(), value)? 1237 } else if let Some(value) = user_config 1238 .and_then(|config| config.signer.as_ref()) 1239 .and_then(|signer| signer.backend.clone()) 1240 { 1241 parse_signer_mode("user config [signer].backend", value)? 1242 } else if let Some(value) = workspace_config 1243 .and_then(|config| config.signer.as_ref()) 1244 .and_then(|signer| signer.backend.clone()) 1245 { 1246 parse_signer_mode("workspace config [signer].backend", value)? 1247 } else { 1248 SignerBackend::Local 1249 }; 1250 1251 Ok(SignerConfig { backend }) 1252 } 1253 1254 fn resolve_publish_config( 1255 args: &RuntimeInvocationArgs, 1256 env: &dyn Environment, 1257 env_file: &EnvFileValues, 1258 user_config: Option<&CliConfigFile>, 1259 workspace_config: Option<&CliConfigFile>, 1260 ) -> Result<PublishConfig, RuntimeError> { 1261 let radrootsd_proxy = 1262 resolve_radrootsd_proxy_config(env, env_file, user_config, workspace_config)?; 1263 if let Some(value) = args.publish_transport.clone() { 1264 return Ok(PublishConfig { 1265 transport: parse_publish_transport("--publish-transport", value)?, 1266 source: PublishTransportSource::Flags, 1267 radrootsd_proxy, 1268 }); 1269 } 1270 1271 if let Some((key, value)) = env_value_entry(env, env_file, &[ENV_CLI_PUBLISH_TRANSPORT]) { 1272 return Ok(PublishConfig { 1273 transport: parse_publish_transport(key.as_str(), value)?, 1274 source: PublishTransportSource::Environment, 1275 radrootsd_proxy, 1276 }); 1277 } 1278 1279 if let Some(value) = user_config 1280 .and_then(|config| config.publish.as_ref()) 1281 .and_then(|publish| publish.transport.clone()) 1282 { 1283 return Ok(PublishConfig { 1284 transport: parse_publish_transport("user config [publish].transport", value)?, 1285 source: PublishTransportSource::UserConfig, 1286 radrootsd_proxy, 1287 }); 1288 } 1289 1290 if let Some(value) = workspace_config 1291 .and_then(|config| config.publish.as_ref()) 1292 .and_then(|publish| publish.transport.clone()) 1293 { 1294 return Ok(PublishConfig { 1295 transport: parse_publish_transport("workspace config [publish].transport", value)?, 1296 source: PublishTransportSource::WorkspaceConfig, 1297 radrootsd_proxy, 1298 }); 1299 } 1300 1301 Ok(PublishConfig { 1302 transport: PublishTransport::DirectNostrRelay, 1303 source: PublishTransportSource::Defaults, 1304 radrootsd_proxy, 1305 }) 1306 } 1307 1308 fn resolve_myc_config( 1309 args: &RuntimeInvocationArgs, 1310 env: &dyn Environment, 1311 env_file: &EnvFileValues, 1312 user_config: Option<&CliConfigFile>, 1313 workspace_config: Option<&CliConfigFile>, 1314 ) -> Result<MycConfig, RuntimeError> { 1315 let executable = args 1316 .myc_executable 1317 .clone() 1318 .or_else(|| env_value(env, env_file, &[ENV_CLI_MYC_EXECUTABLE]).map(PathBuf::from)) 1319 .or_else(|| { 1320 user_config 1321 .and_then(|config| config.myc.as_ref()) 1322 .and_then(|myc| myc.executable.clone()) 1323 }) 1324 .or_else(|| { 1325 workspace_config 1326 .and_then(|config| config.myc.as_ref()) 1327 .and_then(|myc| myc.executable.clone()) 1328 }) 1329 .unwrap_or_else(|| PathBuf::from("myc")); 1330 1331 Ok(MycConfig { 1332 executable, 1333 status_timeout_ms: resolve_myc_status_timeout_ms( 1334 args, 1335 env, 1336 env_file, 1337 user_config, 1338 workspace_config, 1339 )?, 1340 }) 1341 } 1342 1343 fn resolve_myc_status_timeout_ms( 1344 args: &RuntimeInvocationArgs, 1345 env: &dyn Environment, 1346 env_file: &EnvFileValues, 1347 user_config: Option<&CliConfigFile>, 1348 workspace_config: Option<&CliConfigFile>, 1349 ) -> Result<u64, RuntimeError> { 1350 if let Some(value) = args.myc_status_timeout_ms { 1351 return validate_myc_status_timeout_ms("--myc-status-timeout-ms", value); 1352 } 1353 1354 if let Some((key, value)) = env_value_entry(env, env_file, &[ENV_CLI_MYC_STATUS_TIMEOUT_MS]) { 1355 let parsed = parse_u64_value(key.as_str(), value.as_str()) 1356 .map_err(|err| RuntimeError::Config(err.to_string()))?; 1357 return validate_myc_status_timeout_ms(key.as_str(), parsed); 1358 } 1359 1360 if let Some(value) = user_config 1361 .and_then(|config| config.myc.as_ref()) 1362 .and_then(|myc| myc.status_timeout_ms) 1363 { 1364 return validate_myc_status_timeout_ms("user config [myc].status_timeout_ms", value); 1365 } 1366 1367 if let Some(value) = workspace_config 1368 .and_then(|config| config.myc.as_ref()) 1369 .and_then(|myc| myc.status_timeout_ms) 1370 { 1371 return validate_myc_status_timeout_ms("workspace config [myc].status_timeout_ms", value); 1372 } 1373 1374 Ok(DEFAULT_MYC_STATUS_TIMEOUT_MS) 1375 } 1376 1377 fn validate_myc_status_timeout_ms(source: &str, value: u64) -> Result<u64, RuntimeError> { 1378 if value == 0 { 1379 return Err(RuntimeError::Config(format!( 1380 "{source} must be greater than zero" 1381 ))); 1382 } 1383 Ok(value) 1384 } 1385 1386 fn resolve_hyf_enabled( 1387 args: &RuntimeInvocationArgs, 1388 env: &dyn Environment, 1389 env_file: &EnvFileValues, 1390 user_config: Option<&CliConfigFile>, 1391 workspace_config: Option<&CliConfigFile>, 1392 ) -> Result<bool, RuntimeError> { 1393 match (args.hyf_enabled, args.no_hyf_enabled) { 1394 (true, true) => { 1395 return Err(RuntimeError::Config( 1396 "flags --hyf-enabled and --no-hyf-enabled cannot be used together".to_owned(), 1397 )); 1398 } 1399 (true, false) => return Ok(true), 1400 (false, true) => return Ok(false), 1401 (false, false) => {} 1402 } 1403 1404 if let Some((key, value)) = env_value_entry(env, env_file, &[ENV_CLI_HYF_ENABLED]) { 1405 return parse_bool_env(key.as_str(), value.as_str()); 1406 } 1407 1408 if let Some(enabled) = user_config 1409 .and_then(|config| config.hyf.as_ref()) 1410 .and_then(|hyf| hyf.enabled) 1411 { 1412 return Ok(enabled); 1413 } 1414 1415 if let Some(enabled) = workspace_config 1416 .and_then(|config| config.hyf.as_ref()) 1417 .and_then(|hyf| hyf.enabled) 1418 { 1419 return Ok(enabled); 1420 } 1421 1422 Ok(false) 1423 } 1424 1425 fn resolve_hyf_executable( 1426 args: &RuntimeInvocationArgs, 1427 env: &dyn Environment, 1428 env_file: &EnvFileValues, 1429 user_config: Option<&CliConfigFile>, 1430 workspace_config: Option<&CliConfigFile>, 1431 ) -> PathBuf { 1432 args.hyf_executable 1433 .clone() 1434 .or_else(|| env_value(env, env_file, &[ENV_CLI_HYF_EXECUTABLE]).map(PathBuf::from)) 1435 .or_else(|| { 1436 user_config 1437 .and_then(|config| config.hyf.as_ref()) 1438 .and_then(|hyf| hyf.executable.clone()) 1439 }) 1440 .or_else(|| { 1441 workspace_config 1442 .and_then(|config| config.hyf.as_ref()) 1443 .and_then(|hyf| hyf.executable.clone()) 1444 }) 1445 .unwrap_or_else(|| PathBuf::from(DEFAULT_HYF_EXECUTABLE)) 1446 } 1447 1448 fn resolve_relay_publish_policy( 1449 user_config: Option<&CliConfigFile>, 1450 workspace_config: Option<&CliConfigFile>, 1451 ) -> Result<Option<RelayPublishPolicy>, RuntimeError> { 1452 if let Some(value) = user_config 1453 .and_then(|config| config.relays.as_ref()) 1454 .and_then(|relay| relay.publish_policy.as_deref()) 1455 { 1456 return parse_relay_publish_policy(value).map(Some); 1457 } 1458 1459 if let Some(value) = workspace_config 1460 .and_then(|config| config.relays.as_ref()) 1461 .and_then(|relay| relay.publish_policy.as_deref()) 1462 { 1463 return parse_relay_publish_policy(value).map(Some); 1464 } 1465 1466 Ok(None) 1467 } 1468 1469 fn parse_relay_publish_policy(value: &str) -> Result<RelayPublishPolicy, RuntimeError> { 1470 match value.trim().to_ascii_lowercase().as_str() { 1471 "any" => Ok(RelayPublishPolicy::Any), 1472 other => Err(RuntimeError::Config(format!( 1473 "[relays].publish_policy must be `any`, got `{other}`" 1474 ))), 1475 } 1476 } 1477 1478 fn validate_rpc_url(value: &str) -> Result<String, RuntimeError> { 1479 let trimmed = value.trim(); 1480 if trimmed.is_empty() { 1481 return Err(RuntimeError::Config("rpc url must not be empty".to_owned())); 1482 } 1483 let parsed = Url::parse(trimmed) 1484 .map_err(|err| RuntimeError::Config(format!("rpc url `{trimmed}` is invalid: {err}")))?; 1485 if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() { 1486 return Err(RuntimeError::Config(format!( 1487 "rpc url must use http or https, got `{trimmed}`" 1488 ))); 1489 } 1490 Ok(trimmed.to_owned()) 1491 } 1492 1493 fn parse_relay_env_value(value: &str, key: &str) -> Result<Vec<String>, RuntimeError> { 1494 let entries = value 1495 .split(',') 1496 .map(str::trim) 1497 .map(ToOwned::to_owned) 1498 .collect::<Vec<_>>(); 1499 1500 if entries.is_empty() { 1501 return Err(RuntimeError::Config(format!( 1502 "{key} must contain at least one websocket relay url" 1503 ))); 1504 } 1505 1506 normalize_relay_urls(entries, key) 1507 } 1508 1509 fn normalize_relay_urls(values: Vec<String>, source: &str) -> Result<Vec<String>, RuntimeError> { 1510 let mut normalized = Vec::new(); 1511 for value in values { 1512 let relay = validate_relay_url(value.as_str(), source)?; 1513 if !normalized.iter().any(|existing| existing == &relay) { 1514 normalized.push(relay); 1515 } 1516 } 1517 Ok(normalized) 1518 } 1519 1520 fn validate_relay_url(value: &str, source: &str) -> Result<String, RuntimeError> { 1521 let trimmed = value.trim(); 1522 if trimmed.is_empty() { 1523 return Err(RuntimeError::Config(format!( 1524 "{source} contains an empty relay url" 1525 ))); 1526 } 1527 normalize_relay_url(trimmed).map_err(|error| match error { 1528 RelayUrlValidationError::UnsupportedScheme(_) => RuntimeError::Config(format!( 1529 "{source} must use websocket relay urls, got `{trimmed}`" 1530 )), 1531 _ => RuntimeError::Config(format!( 1532 "{source} contains invalid relay url `{trimmed}`: {error}" 1533 )), 1534 }) 1535 } 1536 1537 fn resolve_env_file_path(args: &RuntimeInvocationArgs, env: &dyn Environment) -> Option<PathBuf> { 1538 args.env_file 1539 .clone() 1540 .or_else(|| env.var(ENV_CLI_FILE_PATH).map(PathBuf::from)) 1541 .or_else(|| { 1542 let default_path = PathBuf::from(DEFAULT_ENV_PATH); 1543 default_path.exists().then_some(default_path) 1544 }) 1545 } 1546 1547 fn resolve_output_format( 1548 args: &RuntimeInvocationArgs, 1549 env: &dyn Environment, 1550 env_file: &EnvFileValues, 1551 user_config: Option<&CliConfigFile>, 1552 workspace_config: Option<&CliConfigFile>, 1553 ) -> Result<OutputFormat, RuntimeError> { 1554 if args.output_format.is_some() && (args.json || args.ndjson) { 1555 return Err(RuntimeError::Config( 1556 "flags --output, --json, and --ndjson cannot be used together".to_owned(), 1557 )); 1558 } 1559 1560 match (args.output_format, args.json, args.ndjson) { 1561 (_, true, true) => { 1562 return Err(RuntimeError::Config( 1563 "flags --json and --ndjson cannot be used together".to_owned(), 1564 )); 1565 } 1566 (Some(format), false, false) => return Ok(format.as_output_format()), 1567 (None, true, false) => return Ok(OutputFormat::Json), 1568 (None, false, true) => return Ok(OutputFormat::Ndjson), 1569 (None, false, false) => {} 1570 (Some(_), true, false) | (Some(_), false, true) => unreachable!(), 1571 } 1572 if let Some(value) = env_value(env, env_file, &[ENV_CLI_OUTPUT_FORMAT]) { 1573 return parse_output_format(value.as_str()); 1574 } 1575 1576 if let Some(value) = user_config 1577 .and_then(|config| config.output.as_ref()) 1578 .and_then(|output| output.format.as_deref()) 1579 { 1580 return parse_output_format(value); 1581 } 1582 1583 match workspace_config 1584 .and_then(|config| config.output.as_ref()) 1585 .and_then(|output| output.format.as_deref()) 1586 { 1587 Some(value) => parse_output_format(value), 1588 None => Ok(OutputFormat::Human), 1589 } 1590 } 1591 1592 fn resolve_verbosity(args: &RuntimeInvocationArgs) -> Result<Verbosity, RuntimeError> { 1593 let selected = [args.quiet, args.verbose, args.trace] 1594 .into_iter() 1595 .filter(|selected| *selected) 1596 .count(); 1597 if selected > 1 { 1598 return Err(RuntimeError::Config( 1599 "flags --quiet, --verbose, and --trace are mutually exclusive".to_owned(), 1600 )); 1601 } 1602 1603 if args.quiet { 1604 Ok(Verbosity::Quiet) 1605 } else if args.trace { 1606 Ok(Verbosity::Trace) 1607 } else if args.verbose { 1608 Ok(Verbosity::Verbose) 1609 } else { 1610 Ok(Verbosity::Normal) 1611 } 1612 } 1613 1614 fn resolve_interaction_config( 1615 args: &RuntimeInvocationArgs, 1616 env: &dyn Environment, 1617 ) -> InteractionConfig { 1618 let stdin_tty = env.stdin_is_tty(); 1619 let stdout_tty = env.stdout_is_tty(); 1620 let input_enabled = !args.no_input; 1621 let prompts_allowed = input_enabled && stdin_tty && stdout_tty; 1622 let confirmations_allowed = prompts_allowed && !args.yes; 1623 InteractionConfig { 1624 input_enabled, 1625 assume_yes: args.yes, 1626 stdin_tty, 1627 stdout_tty, 1628 prompts_allowed, 1629 confirmations_allowed, 1630 } 1631 } 1632 1633 fn resolve_logging_filter( 1634 args: &RuntimeInvocationArgs, 1635 env: &dyn Environment, 1636 env_file: &EnvFileValues, 1637 user_config: Option<&CliConfigFile>, 1638 workspace_config: Option<&CliConfigFile>, 1639 ) -> String { 1640 args.log_filter 1641 .clone() 1642 .or_else(|| env_value(env, env_file, &[ENV_CLI_LOG_FILTER])) 1643 .or_else(|| { 1644 user_config 1645 .and_then(|config| config.logging.as_ref()) 1646 .and_then(|logging| logging.filter.clone()) 1647 }) 1648 .or_else(|| { 1649 workspace_config 1650 .and_then(|config| config.logging.as_ref()) 1651 .and_then(|logging| logging.filter.clone()) 1652 }) 1653 .unwrap_or_else(|| DEFAULT_LOG_FILTER.to_owned()) 1654 } 1655 1656 fn resolve_logging_directory( 1657 args: &RuntimeInvocationArgs, 1658 env: &dyn Environment, 1659 env_file: &EnvFileValues, 1660 user_config: Option<&CliConfigFile>, 1661 workspace_config: Option<&CliConfigFile>, 1662 default_logs_root: &Path, 1663 ) -> Option<PathBuf> { 1664 args.log_dir 1665 .clone() 1666 .or_else(|| env_value(env, env_file, &[ENV_CLI_LOG_DIR]).map(PathBuf::from)) 1667 .or_else(|| { 1668 user_config 1669 .and_then(|config| config.logging.as_ref()) 1670 .and_then(|logging| logging.output_dir.clone()) 1671 }) 1672 .or_else(|| { 1673 workspace_config 1674 .and_then(|config| config.logging.as_ref()) 1675 .and_then(|logging| logging.output_dir.clone()) 1676 }) 1677 .or_else(|| Some(default_logs_root.to_path_buf())) 1678 } 1679 1680 fn resolve_logging_stdout( 1681 args: &RuntimeInvocationArgs, 1682 env: &dyn Environment, 1683 env_file: &EnvFileValues, 1684 user_config: Option<&CliConfigFile>, 1685 workspace_config: Option<&CliConfigFile>, 1686 ) -> Result<bool, RuntimeError> { 1687 resolve_bool_pair( 1688 args.log_stdout, 1689 args.no_log_stdout, 1690 &[ENV_CLI_LOG_STDOUT], 1691 user_config 1692 .and_then(|config| config.logging.as_ref()) 1693 .and_then(|logging| logging.stdout), 1694 workspace_config 1695 .and_then(|config| config.logging.as_ref()) 1696 .and_then(|logging| logging.stdout), 1697 false, 1698 env, 1699 env_file, 1700 "--log-stdout", 1701 "--no-log-stdout", 1702 ) 1703 } 1704 1705 fn validate_logging_output_contract( 1706 output: &OutputConfig, 1707 logging: &LoggingConfig, 1708 ) -> Result<(), RuntimeError> { 1709 if logging.stdout && matches!(output.format, OutputFormat::Json | OutputFormat::Ndjson) { 1710 return Err(RuntimeError::Config(format!( 1711 "stdout logging cannot be used with {} output; unset {ENV_CLI_LOG_STDOUT} or use --no-log-stdout", 1712 output.format.as_str() 1713 ))); 1714 } 1715 1716 Ok(()) 1717 } 1718 1719 fn resolve_bool_pair( 1720 positive_flag: bool, 1721 negative_flag: bool, 1722 env_keys: &[&str], 1723 user_value: Option<bool>, 1724 workspace_value: Option<bool>, 1725 default: bool, 1726 env: &dyn Environment, 1727 env_file: &EnvFileValues, 1728 positive_label: &str, 1729 negative_label: &str, 1730 ) -> Result<bool, RuntimeError> { 1731 match (positive_flag, negative_flag) { 1732 (true, true) => Err(RuntimeError::Config(format!( 1733 "flags {positive_label} and {negative_label} cannot be used together" 1734 ))), 1735 (true, false) => Ok(true), 1736 (false, true) => Ok(false), 1737 (false, false) => match env_value_entry(env, env_file, env_keys) { 1738 Some((key, value)) => parse_bool_env(key.as_str(), value.as_str()), 1739 None => Ok(user_value.or(workspace_value).unwrap_or(default)), 1740 }, 1741 } 1742 } 1743 1744 fn env_value(env: &dyn Environment, env_file: &EnvFileValues, keys: &[&str]) -> Option<String> { 1745 env_value_entry(env, env_file, keys).map(|(_, value)| value) 1746 } 1747 1748 fn env_value_entry( 1749 env: &dyn Environment, 1750 env_file: &EnvFileValues, 1751 keys: &[&str], 1752 ) -> Option<(String, String)> { 1753 keys.iter() 1754 .find_map(|key| env.var(key).map(|value| ((*key).to_owned(), value))) 1755 .or_else(|| { 1756 keys.iter().find_map(|key| { 1757 env_file 1758 .0 1759 .get(*key) 1760 .cloned() 1761 .map(|value| ((*key).to_owned(), value)) 1762 }) 1763 }) 1764 } 1765 1766 fn load_env_file_values(path: Option<&Path>) -> Result<EnvFileValues, RuntimeError> { 1767 let Some(path) = path else { 1768 return Ok(EnvFileValues::default()); 1769 }; 1770 let raw = fs::read_to_string(path).map_err(|err| { 1771 RuntimeError::Config(format!("failed to read env file {}: {err}", path.display())) 1772 })?; 1773 parse_env_file_values(&raw, path) 1774 } 1775 1776 fn parse_env_file_values(raw: &str, path: &Path) -> Result<EnvFileValues, RuntimeError> { 1777 parse_strict_env_file(raw, path, SUPPORTED_ENV_FILE_KEYS) 1778 .map(|values| EnvFileValues(values.into_inner())) 1779 .map_err(|err| RuntimeError::Config(err.to_string())) 1780 } 1781 1782 fn parse_output_format(value: &str) -> Result<OutputFormat, RuntimeError> { 1783 match value.trim().to_ascii_lowercase().as_str() { 1784 "human" => Ok(OutputFormat::Human), 1785 "json" => Ok(OutputFormat::Json), 1786 "ndjson" => Ok(OutputFormat::Ndjson), 1787 other => Err(RuntimeError::Config(format!( 1788 "{ENV_CLI_OUTPUT_FORMAT} must be `human`, `json`, or `ndjson`, got `{other}`" 1789 ))), 1790 } 1791 } 1792 1793 fn parse_signer_mode(source: &str, value: String) -> Result<SignerBackend, RuntimeError> { 1794 match value.trim().to_ascii_lowercase().as_str() { 1795 "local" => Ok(SignerBackend::Local), 1796 "myc" => Ok(SignerBackend::Myc), 1797 other => Err(RuntimeError::Config(format!( 1798 "{source} must be `local` or `myc`, got `{other}`" 1799 ))), 1800 } 1801 } 1802 1803 fn parse_publish_transport(source: &str, value: String) -> Result<PublishTransport, RuntimeError> { 1804 match value.trim().to_ascii_lowercase().as_str() { 1805 "direct_nostr_relay" => Ok(PublishTransport::DirectNostrRelay), 1806 "radrootsd_proxy" => Ok(PublishTransport::RadrootsdProxy), 1807 other => Err(RuntimeError::Config(format!( 1808 "{source} must be `{}` or `{}`, got `{other}`", 1809 PublishTransport::DirectNostrRelay.as_str(), 1810 PublishTransport::RadrootsdProxy.as_str() 1811 ))), 1812 } 1813 } 1814 1815 fn resolve_account_secret_backend( 1816 env: &dyn Environment, 1817 env_file: &EnvFileValues, 1818 user_config: Option<&CliConfigFile>, 1819 workspace_config: Option<&CliConfigFile>, 1820 ) -> Result<Option<RadrootsSecretBackend>, RuntimeError> { 1821 if let Some((key, value)) = env_value_entry(env, env_file, &[ENV_CLI_ACCOUNT_SECRET_BACKEND]) { 1822 return parse_account_secret_backend(key.as_str(), value.as_str()).map(Some); 1823 } 1824 1825 if let Some(value) = user_config 1826 .and_then(|config| config.account.as_ref()) 1827 .and_then(|account| account.secret.as_ref()) 1828 .and_then(|secret| secret.backend.as_deref()) 1829 { 1830 return parse_account_secret_backend("user config [account.secret].backend", value) 1831 .map(Some); 1832 } 1833 1834 workspace_config 1835 .and_then(|config| config.account.as_ref()) 1836 .and_then(|account| account.secret.as_ref()) 1837 .and_then(|secret| secret.backend.as_deref()) 1838 .map(|value| { 1839 parse_account_secret_backend("workspace config [account.secret].backend", value) 1840 }) 1841 .transpose() 1842 } 1843 1844 fn resolve_account_secret_fallback( 1845 env: &dyn Environment, 1846 env_file: &EnvFileValues, 1847 user_config: Option<&CliConfigFile>, 1848 workspace_config: Option<&CliConfigFile>, 1849 ) -> Result<Option<Option<RadrootsSecretBackend>>, RuntimeError> { 1850 if let Some((key, value)) = env_value_entry(env, env_file, &[ENV_CLI_ACCOUNT_SECRET_FALLBACK]) { 1851 return parse_account_secret_fallback(key.as_str(), value.as_str()).map(Some); 1852 } 1853 1854 if let Some(value) = user_config 1855 .and_then(|config| config.account.as_ref()) 1856 .and_then(|account| account.secret.as_ref()) 1857 .and_then(|secret| secret.fallback.as_deref()) 1858 { 1859 return parse_account_secret_fallback("user config [account.secret].fallback", value) 1860 .map(Some); 1861 } 1862 1863 workspace_config 1864 .and_then(|config| config.account.as_ref()) 1865 .and_then(|account| account.secret.as_ref()) 1866 .and_then(|secret| secret.fallback.as_deref()) 1867 .map(|value| { 1868 parse_account_secret_fallback("workspace config [account.secret].fallback", value) 1869 }) 1870 .transpose() 1871 } 1872 1873 fn parse_account_secret_fallback( 1874 key: &str, 1875 value: &str, 1876 ) -> Result<Option<RadrootsSecretBackend>, RuntimeError> { 1877 match value.trim().to_ascii_lowercase().as_str() { 1878 "none" => Ok(None), 1879 "host_vault" => Ok(Some(RadrootsSecretBackend::HostVault( 1880 RadrootsHostVaultPolicy::desktop(), 1881 ))), 1882 "encrypted_file" => Ok(Some(RadrootsSecretBackend::EncryptedFile)), 1883 other => Err(RuntimeError::Config(format!( 1884 "{key} must be `host_vault`, `encrypted_file`, or `none`, got `{other}`" 1885 ))), 1886 } 1887 } 1888 1889 fn parse_account_secret_backend( 1890 key: &str, 1891 value: &str, 1892 ) -> Result<RadrootsSecretBackend, RuntimeError> { 1893 match value.trim().to_ascii_lowercase().as_str() { 1894 "host_vault" => Ok(RadrootsSecretBackend::HostVault( 1895 RadrootsHostVaultPolicy::desktop(), 1896 )), 1897 "encrypted_file" => Ok(RadrootsSecretBackend::EncryptedFile), 1898 other => Err(RuntimeError::Config(format!( 1899 "{key} must be `host_vault` or `encrypted_file`, got `{other}`" 1900 ))), 1901 } 1902 } 1903 1904 fn parse_bool_env(key: &str, value: &str) -> Result<bool, RuntimeError> { 1905 parse_bool_value(key, value).map_err(|err| RuntimeError::Config(err.to_string())) 1906 } 1907 1908 #[cfg(test)] 1909 mod tests { 1910 use super::{ 1911 AccountConfig, AccountSecretContractConfig, CapabilityBindingConfig, 1912 CapabilityBindingSource, CapabilityBindingTargetKind, DEFAULT_HYF_EXECUTABLE, 1913 DEFAULT_LOG_FILTER, DEFAULT_MYC_STATUS_TIMEOUT_MS, DEFAULT_RPC_URL, EnvFileValues, 1914 Environment, HyfConfig, INFERENCE_HYF_STDIO_CAPABILITY, InteractionConfig, OutputConfig, 1915 OutputFormat, PathsConfig, PublishConfig, PublishTransport, PublishTransportSource, 1916 RelayConfigSource, RelayPublishPolicy, RuntimeConfig, SignerBackend, Verbosity, 1917 parse_env_file_values, 1918 }; 1919 use crate::cli::global::{RuntimeInvocationArgs, RuntimeOutputFormatArg}; 1920 use radroots_runtime_paths::{RadrootsHostEnvironment, RadrootsPathResolver, RadrootsPlatform}; 1921 use radroots_secret_vault::{RadrootsHostVaultPolicy, RadrootsSecretBackend}; 1922 use std::collections::BTreeMap; 1923 use std::fs; 1924 use std::path::{Path, PathBuf}; 1925 use tempfile::tempdir; 1926 1927 struct MapEnvironment { 1928 values: BTreeMap<String, String>, 1929 current_dir: PathBuf, 1930 path_resolver: RadrootsPathResolver, 1931 stdin_tty: bool, 1932 stdout_tty: bool, 1933 } 1934 1935 impl MapEnvironment { 1936 fn new(values: BTreeMap<String, String>) -> Self { 1937 Self { 1938 values, 1939 current_dir: PathBuf::from("/workspaces/radroots-cli"), 1940 path_resolver: RadrootsPathResolver::new( 1941 RadrootsPlatform::Linux, 1942 RadrootsHostEnvironment { 1943 home_dir: Some(PathBuf::from("/home/tester")), 1944 ..RadrootsHostEnvironment::default() 1945 }, 1946 ), 1947 stdin_tty: false, 1948 stdout_tty: false, 1949 } 1950 } 1951 1952 fn with_tty(mut self, stdin_tty: bool, stdout_tty: bool) -> Self { 1953 self.stdin_tty = stdin_tty; 1954 self.stdout_tty = stdout_tty; 1955 self 1956 } 1957 } 1958 1959 impl Environment for MapEnvironment { 1960 fn var(&self, key: &str) -> Option<String> { 1961 self.values.get(key).cloned() 1962 } 1963 1964 fn current_dir(&self) -> Result<PathBuf, crate::runtime::RuntimeError> { 1965 Ok(self.current_dir.clone()) 1966 } 1967 1968 fn path_resolver(&self) -> RadrootsPathResolver { 1969 self.path_resolver.clone() 1970 } 1971 1972 fn stdin_is_tty(&self) -> bool { 1973 self.stdin_tty 1974 } 1975 1976 fn stdout_is_tty(&self) -> bool { 1977 self.stdout_tty 1978 } 1979 } 1980 1981 fn repo_local_env( 1982 workspace_root: PathBuf, 1983 repo_local_root: PathBuf, 1984 user_home: PathBuf, 1985 mut values: BTreeMap<String, String>, 1986 ) -> MapEnvironment { 1987 values.insert( 1988 "RADROOTS_CLI_PATHS_PROFILE".to_owned(), 1989 "repo_local".to_owned(), 1990 ); 1991 values.insert( 1992 "RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT".to_owned(), 1993 repo_local_root.display().to_string(), 1994 ); 1995 1996 MapEnvironment { 1997 values, 1998 current_dir: workspace_root, 1999 path_resolver: RadrootsPathResolver::new( 2000 RadrootsPlatform::Linux, 2001 RadrootsHostEnvironment { 2002 home_dir: Some(user_home), 2003 ..RadrootsHostEnvironment::default() 2004 }, 2005 ), 2006 stdin_tty: false, 2007 stdout_tty: false, 2008 } 2009 } 2010 2011 fn runtime_args() -> RuntimeInvocationArgs { 2012 RuntimeInvocationArgs::default() 2013 } 2014 2015 #[test] 2016 fn flags_override_environment_values() { 2017 let args = RuntimeInvocationArgs { 2018 output_format: Some(RuntimeOutputFormatArg::Human), 2019 verbose: true, 2020 dry_run: true, 2021 no_color: true, 2022 log_filter: Some("debug".to_owned()), 2023 log_stdout: true, 2024 identity_path: Some(PathBuf::from("custom-identity.json")), 2025 signer: Some("local".to_owned()), 2026 publish_transport: Some("direct_nostr_relay".to_owned()), 2027 relay: vec!["wss://relay.one".to_owned(), "wss://relay.two".to_owned()], 2028 myc_executable: Some(PathBuf::from("bin/myc-cli")), 2029 myc_status_timeout_ms: Some(2500), 2030 hyf_enabled: true, 2031 hyf_executable: Some(PathBuf::from("bin/hyfd-cli")), 2032 ..runtime_args() 2033 }; 2034 let env = MapEnvironment::new(BTreeMap::from([ 2035 ("RADROOTS_CLI_OUTPUT_FORMAT".to_owned(), "human".to_owned()), 2036 ("RADROOTS_CLI_LOGGING_FILTER".to_owned(), "trace".to_owned()), 2037 ("RADROOTS_CLI_LOGGING_STDOUT".to_owned(), "false".to_owned()), 2038 ( 2039 "RADROOTS_CLI_IDENTITY_PATH".to_owned(), 2040 "env-identity.json".to_owned(), 2041 ), 2042 ("RADROOTS_CLI_SIGNER_BACKEND".to_owned(), "myc".to_owned()), 2043 ( 2044 "RADROOTS_CLI_PUBLISH_TRANSPORT".to_owned(), 2045 "radrootsd_proxy".to_owned(), 2046 ), 2047 ( 2048 "RADROOTS_CLI_RELAYS_URLS".to_owned(), 2049 "wss://relay.env".to_owned(), 2050 ), 2051 ( 2052 "RADROOTS_CLI_MYC_EXECUTABLE".to_owned(), 2053 "env-myc".to_owned(), 2054 ), 2055 ( 2056 "RADROOTS_CLI_MYC_STATUS_TIMEOUT_MS".to_owned(), 2057 "9000".to_owned(), 2058 ), 2059 ("RADROOTS_CLI_HYF_ENABLED".to_owned(), "false".to_owned()), 2060 ( 2061 "RADROOTS_CLI_HYF_EXECUTABLE".to_owned(), 2062 "env-hyfd".to_owned(), 2063 ), 2064 ])); 2065 2066 let resolved = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) 2067 .expect("resolve runtime config"); 2068 assert_eq!( 2069 resolved.output, 2070 OutputConfig { 2071 format: OutputFormat::Human, 2072 verbosity: Verbosity::Verbose, 2073 color: false, 2074 dry_run: true, 2075 } 2076 ); 2077 assert_eq!( 2078 resolved.interaction, 2079 InteractionConfig { 2080 input_enabled: true, 2081 assume_yes: false, 2082 stdin_tty: false, 2083 stdout_tty: false, 2084 prompts_allowed: false, 2085 confirmations_allowed: false, 2086 } 2087 ); 2088 assert_eq!( 2089 resolved.paths, 2090 PathsConfig { 2091 profile: "interactive_user".to_owned(), 2092 profile_source: "default".to_owned(), 2093 allowed_profiles: vec!["interactive_user".to_owned(), "repo_local".to_owned(),], 2094 root_source: "host_defaults".to_owned(), 2095 repo_local_root: None, 2096 repo_local_root_source: None, 2097 subordinate_path_override_source: "runtime_config".to_owned(), 2098 app_namespace: "apps/cli".to_owned(), 2099 shared_accounts_namespace: "shared/accounts".to_owned(), 2100 shared_identities_namespace: "shared/identities".to_owned(), 2101 app_config_path: PathBuf::from( 2102 "/home/tester/.radroots/config/apps/cli/config.toml" 2103 ), 2104 workspace_config_path: None, 2105 app_data_root: PathBuf::from("/home/tester/.radroots/data/apps/cli"), 2106 app_logs_root: PathBuf::from("/home/tester/.radroots/logs/apps/cli"), 2107 shared_accounts_data_root: PathBuf::from( 2108 "/home/tester/.radroots/data/shared/accounts" 2109 ), 2110 shared_accounts_secrets_root: PathBuf::from( 2111 "/home/tester/.radroots/secrets/shared/accounts" 2112 ), 2113 default_identity_path: PathBuf::from( 2114 "/home/tester/.radroots/secrets/shared/identities/default.json" 2115 ), 2116 } 2117 ); 2118 assert_eq!(resolved.logging.filter, "debug"); 2119 assert!(resolved.logging.stdout); 2120 assert_eq!( 2121 resolved.identity.path, 2122 PathBuf::from("custom-identity.json") 2123 ); 2124 assert_eq!( 2125 resolved.account, 2126 AccountConfig { 2127 selector: None, 2128 store_path: PathBuf::from("/home/tester/.radroots/data/shared/accounts/store.json"), 2129 secrets_dir: PathBuf::from("/home/tester/.radroots/secrets/shared/accounts"), 2130 secret_backend: RadrootsSecretBackend::HostVault( 2131 RadrootsHostVaultPolicy::desktop(), 2132 ), 2133 secret_fallback: Some(RadrootsSecretBackend::EncryptedFile), 2134 } 2135 ); 2136 assert_eq!( 2137 resolved.account_secret_contract, 2138 AccountSecretContractConfig { 2139 default_backend: "host_vault".to_owned(), 2140 default_fallback: Some("encrypted_file".to_owned()), 2141 allowed_backends: vec!["host_vault".to_owned(), "encrypted_file".to_owned(),], 2142 host_vault_policy: Some("desktop".to_owned()), 2143 uses_protected_store: true, 2144 } 2145 ); 2146 assert_eq!(resolved.signer.backend, SignerBackend::Local); 2147 assert_eq!( 2148 resolved.publish, 2149 PublishConfig { 2150 transport: PublishTransport::DirectNostrRelay, 2151 source: PublishTransportSource::Flags, 2152 radrootsd_proxy: crate::runtime::config::RadrootsdProxyConfig::default(), 2153 } 2154 ); 2155 assert_eq!( 2156 resolved.relay.urls, 2157 vec!["wss://relay.one".to_owned(), "wss://relay.two".to_owned()] 2158 ); 2159 assert_eq!(resolved.relay.source, RelayConfigSource::Flags); 2160 assert_eq!(resolved.relay.publish_policy, RelayPublishPolicy::Any); 2161 assert_eq!(resolved.myc.executable, PathBuf::from("bin/myc-cli")); 2162 assert_eq!(resolved.myc.status_timeout_ms, 2500); 2163 assert_eq!( 2164 resolved.hyf, 2165 HyfConfig { 2166 enabled: true, 2167 executable: PathBuf::from("bin/hyfd-cli"), 2168 } 2169 ); 2170 } 2171 2172 #[test] 2173 fn environment_values_fill_missing_flags() { 2174 let args = runtime_args(); 2175 let env = MapEnvironment::new(BTreeMap::from([ 2176 ("RADROOTS_CLI_OUTPUT_FORMAT".to_owned(), "json".to_owned()), 2177 ( 2178 "RADROOTS_CLI_LOGGING_FILTER".to_owned(), 2179 "debug,cli=trace".to_owned(), 2180 ), 2181 ( 2182 "RADROOTS_CLI_LOGGING_OUTPUT_DIR".to_owned(), 2183 "logs/runtime".to_owned(), 2184 ), 2185 ("RADROOTS_CLI_LOGGING_STDOUT".to_owned(), "false".to_owned()), 2186 ( 2187 "RADROOTS_CLI_ACCOUNT_SELECTOR".to_owned(), 2188 "acct_demo".to_owned(), 2189 ), 2190 ( 2191 "RADROOTS_CLI_IDENTITY_PATH".to_owned(), 2192 "state/identity.json".to_owned(), 2193 ), 2194 ("RADROOTS_CLI_SIGNER_BACKEND".to_owned(), "myc".to_owned()), 2195 ( 2196 "RADROOTS_CLI_PUBLISH_TRANSPORT".to_owned(), 2197 "radrootsd_proxy".to_owned(), 2198 ), 2199 ( 2200 "RADROOTS_CLI_RELAYS_URLS".to_owned(), 2201 "wss://relay.one,wss://relay.two".to_owned(), 2202 ), 2203 ( 2204 "RADROOTS_CLI_MYC_EXECUTABLE".to_owned(), 2205 "bin/myc".to_owned(), 2206 ), 2207 ( 2208 "RADROOTS_CLI_MYC_STATUS_TIMEOUT_MS".to_owned(), 2209 "3500".to_owned(), 2210 ), 2211 ("RADROOTS_CLI_HYF_ENABLED".to_owned(), "true".to_owned()), 2212 ( 2213 "RADROOTS_CLI_HYF_EXECUTABLE".to_owned(), 2214 "bin/hyfd".to_owned(), 2215 ), 2216 ])); 2217 2218 let resolved = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) 2219 .expect("resolve runtime config"); 2220 assert_eq!( 2221 resolved.output, 2222 OutputConfig { 2223 format: OutputFormat::Json, 2224 verbosity: Verbosity::Normal, 2225 color: true, 2226 dry_run: false, 2227 } 2228 ); 2229 assert_eq!( 2230 resolved.interaction, 2231 InteractionConfig { 2232 input_enabled: true, 2233 assume_yes: false, 2234 stdin_tty: false, 2235 stdout_tty: false, 2236 prompts_allowed: false, 2237 confirmations_allowed: false, 2238 } 2239 ); 2240 assert_eq!(resolved.logging.filter, "debug,cli=trace"); 2241 assert_eq!( 2242 resolved.logging.directory, 2243 Some(PathBuf::from("logs/runtime")) 2244 ); 2245 assert!(!resolved.logging.stdout); 2246 assert_eq!(resolved.account.selector.as_deref(), Some("acct_demo")); 2247 assert_eq!( 2248 resolved.account.secret_backend, 2249 RadrootsSecretBackend::HostVault(RadrootsHostVaultPolicy::desktop()) 2250 ); 2251 assert_eq!( 2252 resolved.account.secret_fallback, 2253 Some(RadrootsSecretBackend::EncryptedFile) 2254 ); 2255 assert_eq!(resolved.identity.path, PathBuf::from("state/identity.json")); 2256 assert_eq!(resolved.signer.backend, SignerBackend::Myc); 2257 assert_eq!( 2258 resolved.publish, 2259 PublishConfig { 2260 transport: PublishTransport::RadrootsdProxy, 2261 source: PublishTransportSource::Environment, 2262 radrootsd_proxy: crate::runtime::config::RadrootsdProxyConfig::default(), 2263 } 2264 ); 2265 assert_eq!( 2266 resolved.relay.urls, 2267 vec!["wss://relay.one".to_owned(), "wss://relay.two".to_owned()] 2268 ); 2269 assert_eq!(resolved.relay.source, RelayConfigSource::Environment); 2270 assert_eq!(resolved.myc.executable, PathBuf::from("bin/myc")); 2271 assert_eq!(resolved.myc.status_timeout_ms, 3500); 2272 assert_eq!( 2273 resolved.hyf, 2274 HyfConfig { 2275 enabled: true, 2276 executable: PathBuf::from("bin/hyfd"), 2277 } 2278 ); 2279 } 2280 2281 #[test] 2282 fn old_process_environment_names_have_no_effect() { 2283 let args = runtime_args(); 2284 let env = MapEnvironment::new(BTreeMap::from([ 2285 ("RADROOTS_OUTPUT".to_owned(), "json".to_owned()), 2286 ("RADROOTS_LOG_FILTER".to_owned(), "trace".to_owned()), 2287 ("RADROOTS_LOG_DIR".to_owned(), "logs/old".to_owned()), 2288 ("RADROOTS_LOG_STDOUT".to_owned(), "true".to_owned()), 2289 ("RADROOTS_ACCOUNT".to_owned(), "old_account".to_owned()), 2290 ( 2291 "RADROOTS_ACCOUNT_SECRET_BACKEND".to_owned(), 2292 "encrypted_file".to_owned(), 2293 ), 2294 ( 2295 "RADROOTS_ACCOUNT_SECRET_FALLBACK".to_owned(), 2296 "none".to_owned(), 2297 ), 2298 ( 2299 "RADROOTS_IDENTITY_PATH".to_owned(), 2300 "old-identity.json".to_owned(), 2301 ), 2302 ("RADROOTS_SIGNER".to_owned(), "myc".to_owned()), 2303 ("RADROOTS_PUBLISH_MODE".to_owned(), "radrootsd".to_owned()), 2304 ( 2305 "RADROOTS_RELAYS".to_owned(), 2306 "wss://old-relay.example".to_owned(), 2307 ), 2308 ("RADROOTS_MYC_EXECUTABLE".to_owned(), "old-myc".to_owned()), 2309 ( 2310 "RADROOTS_MYC_STATUS_TIMEOUT_MS".to_owned(), 2311 "9999".to_owned(), 2312 ), 2313 ("RADROOTS_HYF_ENABLED".to_owned(), "true".to_owned()), 2314 ("RADROOTS_HYF_EXECUTABLE".to_owned(), "old-hyfd".to_owned()), 2315 ( 2316 "RADROOTS_RPC_URL".to_owned(), 2317 "http://127.0.0.1:9".to_owned(), 2318 ), 2319 ( 2320 "RADROOTS_RPC_BEARER_TOKEN".to_owned(), 2321 "old-token".to_owned(), 2322 ), 2323 ( 2324 "RADROOTS_TRUSTED_RHI_WORKER_PUBKEYS".to_owned(), 2325 "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".to_owned(), 2326 ), 2327 ])); 2328 2329 let resolved = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) 2330 .expect("resolve runtime config"); 2331 2332 assert_eq!(resolved.output.format, OutputFormat::Human); 2333 assert_eq!(resolved.logging.filter, DEFAULT_LOG_FILTER); 2334 assert!(!resolved.logging.stdout); 2335 assert_eq!(resolved.account.selector, None); 2336 assert_eq!( 2337 resolved.account.secret_backend, 2338 RadrootsSecretBackend::HostVault(RadrootsHostVaultPolicy::desktop()) 2339 ); 2340 assert_eq!( 2341 resolved.account.secret_fallback, 2342 Some(RadrootsSecretBackend::EncryptedFile) 2343 ); 2344 assert_eq!(resolved.signer.backend, SignerBackend::Local); 2345 assert_eq!( 2346 resolved.publish.transport, 2347 PublishTransport::DirectNostrRelay 2348 ); 2349 assert_eq!(resolved.relay.urls, Vec::<String>::new()); 2350 assert_eq!(resolved.myc.executable, PathBuf::from("myc")); 2351 assert_eq!( 2352 resolved.myc.status_timeout_ms, 2353 DEFAULT_MYC_STATUS_TIMEOUT_MS 2354 ); 2355 assert!(!resolved.hyf.enabled); 2356 assert_eq!( 2357 resolved.hyf.executable, 2358 PathBuf::from(DEFAULT_HYF_EXECUTABLE) 2359 ); 2360 assert_eq!(resolved.rpc.url, DEFAULT_RPC_URL); 2361 assert_eq!(resolved.rhi.trusted_worker_pubkeys, Vec::<String>::new()); 2362 } 2363 2364 #[test] 2365 fn toml_output_logging_account_and_identity_config_resolve() { 2366 let temp = tempdir().expect("tempdir"); 2367 let workspace_root = temp.path().join("workspace"); 2368 let repo_local_root = workspace_root.join("infra/local/runtime/radroots"); 2369 let app_config_dir = repo_local_root.join("config/apps/cli"); 2370 let user_home = temp.path().join("home"); 2371 fs::create_dir_all(&app_config_dir).expect("app config dir"); 2372 fs::write( 2373 app_config_dir.join("config.toml"), 2374 r#" 2375 [output] 2376 format = "json" 2377 2378 [logging] 2379 filter = "debug,cli=trace" 2380 output_dir = "logs/from-toml" 2381 stdout = false 2382 2383 [account] 2384 selector = "acct_from_toml" 2385 2386 [account.secret] 2387 backend = "encrypted_file" 2388 fallback = "none" 2389 2390 [identity] 2391 path = "identity/from-toml.json" 2392 "#, 2393 ) 2394 .expect("write user config"); 2395 2396 let env = repo_local_env(workspace_root, repo_local_root, user_home, BTreeMap::new()); 2397 let resolved = 2398 RuntimeConfig::resolve_with_env_file(&runtime_args(), &env, &EnvFileValues::default()) 2399 .expect("resolve toml config"); 2400 2401 assert_eq!(resolved.output.format, OutputFormat::Json); 2402 assert_eq!(resolved.logging.filter, "debug,cli=trace"); 2403 assert_eq!( 2404 resolved.logging.directory, 2405 Some(PathBuf::from("logs/from-toml")) 2406 ); 2407 assert!(!resolved.logging.stdout); 2408 assert_eq!(resolved.account.selector.as_deref(), Some("acct_from_toml")); 2409 assert_eq!( 2410 resolved.account.secret_backend, 2411 RadrootsSecretBackend::EncryptedFile 2412 ); 2413 assert_eq!(resolved.account.secret_fallback, None); 2414 assert_eq!( 2415 resolved.identity.path, 2416 PathBuf::from("identity/from-toml.json") 2417 ); 2418 } 2419 2420 #[test] 2421 fn conflicting_boolean_flags_fail() { 2422 let args = RuntimeInvocationArgs { 2423 log_stdout: true, 2424 no_log_stdout: true, 2425 ..runtime_args() 2426 }; 2427 let env = MapEnvironment::new(BTreeMap::new()); 2428 let error = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) 2429 .expect_err("conflicting flags"); 2430 assert!(error.to_string().contains("cannot be used together")); 2431 2432 let hyf_args = RuntimeInvocationArgs { 2433 hyf_enabled: true, 2434 no_hyf_enabled: true, 2435 ..runtime_args() 2436 }; 2437 let error = 2438 RuntimeConfig::resolve_with_env_file(&hyf_args, &env, &EnvFileValues::default()) 2439 .expect_err("conflicting hyf flags"); 2440 assert!(error.to_string().contains("--hyf-enabled")); 2441 } 2442 2443 #[test] 2444 fn conflicting_output_and_verbosity_flags_fail() { 2445 let env = MapEnvironment::new(BTreeMap::new()); 2446 2447 let conflicting_output = RuntimeInvocationArgs { 2448 json: true, 2449 ndjson: true, 2450 ..runtime_args() 2451 }; 2452 let error = RuntimeConfig::resolve_with_env_file( 2453 &conflicting_output, 2454 &env, 2455 &EnvFileValues::default(), 2456 ) 2457 .expect_err("conflicting output flags"); 2458 assert!(error.to_string().contains("--json and --ndjson")); 2459 2460 let conflicting_verbosity = RuntimeInvocationArgs { 2461 quiet: true, 2462 trace: true, 2463 ..runtime_args() 2464 }; 2465 let error = RuntimeConfig::resolve_with_env_file( 2466 &conflicting_verbosity, 2467 &env, 2468 &EnvFileValues::default(), 2469 ) 2470 .expect_err("conflicting verbosity flags"); 2471 assert!( 2472 error 2473 .to_string() 2474 .contains("--quiet, --verbose, and --trace") 2475 ); 2476 2477 let conflicting_aliases = RuntimeInvocationArgs { 2478 output_format: Some(RuntimeOutputFormatArg::Json), 2479 json: true, 2480 ..runtime_args() 2481 }; 2482 let error = RuntimeConfig::resolve_with_env_file( 2483 &conflicting_aliases, 2484 &env, 2485 &EnvFileValues::default(), 2486 ) 2487 .expect_err("conflicting output aliases"); 2488 assert!(error.to_string().contains("--output, --json, and --ndjson")); 2489 } 2490 2491 #[test] 2492 fn machine_output_rejects_stdout_logging_flags() { 2493 let env = MapEnvironment::new(BTreeMap::new()); 2494 2495 let json_args = RuntimeInvocationArgs { 2496 json: true, 2497 log_stdout: true, 2498 ..runtime_args() 2499 }; 2500 let error = 2501 RuntimeConfig::resolve_with_env_file(&json_args, &env, &EnvFileValues::default()) 2502 .expect_err("json stdout logging should fail"); 2503 let message = error.to_string(); 2504 assert!(message.contains("stdout logging")); 2505 assert!(message.contains("json output")); 2506 assert!(message.contains("--no-log-stdout")); 2507 2508 let ndjson_args = RuntimeInvocationArgs { 2509 ndjson: true, 2510 log_stdout: true, 2511 ..runtime_args() 2512 }; 2513 let error = 2514 RuntimeConfig::resolve_with_env_file(&ndjson_args, &env, &EnvFileValues::default()) 2515 .expect_err("ndjson stdout logging should fail"); 2516 let message = error.to_string(); 2517 assert!(message.contains("stdout logging")); 2518 assert!(message.contains("ndjson output")); 2519 } 2520 2521 #[test] 2522 fn machine_output_rejects_stdout_logging_environment() { 2523 let json_args = RuntimeInvocationArgs { 2524 json: true, 2525 ..runtime_args() 2526 }; 2527 let env = MapEnvironment::new(BTreeMap::from([( 2528 "RADROOTS_CLI_LOGGING_STDOUT".to_owned(), 2529 "true".to_owned(), 2530 )])); 2531 let error = 2532 RuntimeConfig::resolve_with_env_file(&json_args, &env, &EnvFileValues::default()) 2533 .expect_err("json stdout logging from env should fail"); 2534 let message = error.to_string(); 2535 assert!(message.contains("RADROOTS_CLI_LOGGING_STDOUT")); 2536 assert!(message.contains("RADROOTS_CLI_LOGGING_STDOUT")); 2537 2538 let ndjson_env_args = runtime_args(); 2539 let env = MapEnvironment::new(BTreeMap::from([ 2540 ("RADROOTS_CLI_OUTPUT_FORMAT".to_owned(), "ndjson".to_owned()), 2541 ("RADROOTS_CLI_LOGGING_STDOUT".to_owned(), "true".to_owned()), 2542 ])); 2543 let error = 2544 RuntimeConfig::resolve_with_env_file(&ndjson_env_args, &env, &EnvFileValues::default()) 2545 .expect_err("ndjson stdout logging from env should fail"); 2546 assert!(error.to_string().contains("ndjson output")); 2547 } 2548 2549 #[test] 2550 fn no_log_stdout_overrides_environment_for_machine_output() { 2551 let args = RuntimeInvocationArgs { 2552 json: true, 2553 no_log_stdout: true, 2554 ..runtime_args() 2555 }; 2556 let env = MapEnvironment::new(BTreeMap::from([( 2557 "RADROOTS_CLI_LOGGING_STDOUT".to_owned(), 2558 "true".to_owned(), 2559 )])); 2560 2561 let resolved = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) 2562 .expect("resolve machine output with stdout logging disabled"); 2563 assert_eq!(resolved.output.format, OutputFormat::Json); 2564 assert!(!resolved.logging.stdout); 2565 } 2566 2567 #[test] 2568 fn invalid_environment_value_fails() { 2569 let args = runtime_args(); 2570 let env = MapEnvironment::new(BTreeMap::from([( 2571 "RADROOTS_CLI_LOGGING_STDOUT".to_owned(), 2572 "maybe".to_owned(), 2573 )])); 2574 let error = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) 2575 .expect_err("invalid bool"); 2576 assert!(error.to_string().contains("RADROOTS_CLI_LOGGING_STDOUT")); 2577 2578 let env = MapEnvironment::new(BTreeMap::from([( 2579 "RADROOTS_CLI_MYC_STATUS_TIMEOUT_MS".to_owned(), 2580 "slow".to_owned(), 2581 )])); 2582 let error = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) 2583 .expect_err("invalid myc timeout"); 2584 assert!( 2585 error 2586 .to_string() 2587 .contains("RADROOTS_CLI_MYC_STATUS_TIMEOUT_MS") 2588 ); 2589 2590 let env = MapEnvironment::new(BTreeMap::from([( 2591 "RADROOTS_CLI_PUBLISH_TRANSPORT".to_owned(), 2592 "relay".to_owned(), 2593 )])); 2594 let error = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) 2595 .expect_err("invalid publish transport"); 2596 assert!(error.to_string().contains("RADROOTS_CLI_PUBLISH_TRANSPORT")); 2597 assert!(error.to_string().contains("direct_nostr_relay")); 2598 assert!(error.to_string().contains("radrootsd_proxy")); 2599 2600 let args = RuntimeInvocationArgs { 2601 myc_status_timeout_ms: Some(0), 2602 ..runtime_args() 2603 }; 2604 let env = MapEnvironment::new(BTreeMap::new()); 2605 let error = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) 2606 .expect_err("zero myc timeout"); 2607 assert!(error.to_string().contains("greater than zero")); 2608 } 2609 2610 #[test] 2611 fn env_file_values_fill_missing_flags() { 2612 let args = runtime_args(); 2613 let env = MapEnvironment::new(BTreeMap::new()); 2614 let env_file = parse_env_file_values( 2615 r#" 2616 RADROOTS_CLI_OUTPUT_FORMAT=json 2617 RADROOTS_CLI_LOGGING_FILTER="debug,radroots_cli=trace" 2618 RADROOTS_CLI_LOGGING_OUTPUT_DIR=/tmp/radroots-cli-logs 2619 RADROOTS_CLI_LOGGING_STDOUT=false 2620 RADROOTS_CLI_ACCOUNT_SELECTOR=acct_env_file 2621 RADROOTS_CLI_IDENTITY_PATH=state/identity.json 2622 RADROOTS_CLI_SIGNER_BACKEND=myc 2623 RADROOTS_CLI_PUBLISH_TRANSPORT=radrootsd_proxy 2624 RADROOTS_CLI_RELAYS_URLS=wss://relay.env-file 2625 RADROOTS_CLI_MYC_EXECUTABLE=bin/myc 2626 RADROOTS_CLI_MYC_STATUS_TIMEOUT_MS=4500 2627 RADROOTS_CLI_HYF_ENABLED=true 2628 RADROOTS_CLI_HYF_EXECUTABLE=bin/hyfd 2629 "#, 2630 Path::new(".env.test"), 2631 ) 2632 .expect("parse env file"); 2633 2634 let resolved = 2635 RuntimeConfig::resolve_with_env_file(&args, &env, &env_file).expect("resolve config"); 2636 assert_eq!(resolved.output.format, OutputFormat::Json); 2637 assert_eq!(resolved.logging.filter, "debug,radroots_cli=trace"); 2638 assert_eq!( 2639 resolved.logging.directory, 2640 Some(PathBuf::from("/tmp/radroots-cli-logs")) 2641 ); 2642 assert!(!resolved.logging.stdout); 2643 assert_eq!(resolved.account.selector.as_deref(), Some("acct_env_file")); 2644 assert_eq!(resolved.identity.path, PathBuf::from("state/identity.json")); 2645 assert_eq!(resolved.signer.backend, SignerBackend::Myc); 2646 assert_eq!( 2647 resolved.publish, 2648 PublishConfig { 2649 transport: PublishTransport::RadrootsdProxy, 2650 source: PublishTransportSource::Environment, 2651 radrootsd_proxy: crate::runtime::config::RadrootsdProxyConfig::default(), 2652 } 2653 ); 2654 assert_eq!(resolved.relay.urls, vec!["wss://relay.env-file".to_owned()]); 2655 assert_eq!(resolved.relay.source, RelayConfigSource::Environment); 2656 assert_eq!(resolved.myc.executable, PathBuf::from("bin/myc")); 2657 assert_eq!(resolved.myc.status_timeout_ms, 4500); 2658 assert_eq!( 2659 resolved.hyf, 2660 HyfConfig { 2661 enabled: true, 2662 executable: PathBuf::from("bin/hyfd"), 2663 } 2664 ); 2665 } 2666 2667 #[test] 2668 fn explicit_output_flag_overrides_environment_output() { 2669 let args = RuntimeInvocationArgs { 2670 output_format: Some(RuntimeOutputFormatArg::Ndjson), 2671 ..runtime_args() 2672 }; 2673 let env = MapEnvironment::new(BTreeMap::from([( 2674 "RADROOTS_CLI_OUTPUT_FORMAT".to_owned(), 2675 "json".to_owned(), 2676 )])); 2677 2678 let resolved = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) 2679 .expect("resolve runtime config"); 2680 assert_eq!(resolved.output.format, OutputFormat::Ndjson); 2681 } 2682 2683 #[test] 2684 fn interaction_config_reflects_tty_and_flags() { 2685 let args = RuntimeInvocationArgs { 2686 no_input: true, 2687 yes: true, 2688 ..runtime_args() 2689 }; 2690 let env = MapEnvironment::new(BTreeMap::new()).with_tty(true, true); 2691 2692 let resolved = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) 2693 .expect("resolve runtime config"); 2694 assert_eq!( 2695 resolved.interaction, 2696 InteractionConfig { 2697 input_enabled: false, 2698 assume_yes: true, 2699 stdin_tty: true, 2700 stdout_tty: true, 2701 prompts_allowed: false, 2702 confirmations_allowed: false, 2703 } 2704 ); 2705 2706 let interactive_args = runtime_args(); 2707 let interactive = RuntimeConfig::resolve_with_env_file( 2708 &interactive_args, 2709 &env, 2710 &EnvFileValues::default(), 2711 ) 2712 .expect("resolve interactive runtime config"); 2713 assert_eq!( 2714 interactive.interaction, 2715 InteractionConfig { 2716 input_enabled: true, 2717 assume_yes: false, 2718 stdin_tty: true, 2719 stdout_tty: true, 2720 prompts_allowed: true, 2721 confirmations_allowed: true, 2722 } 2723 ); 2724 } 2725 2726 #[test] 2727 fn process_environment_overrides_env_file_values() { 2728 let args = runtime_args(); 2729 let env = MapEnvironment::new(BTreeMap::from([ 2730 ("RADROOTS_CLI_LOGGING_FILTER".to_owned(), "info".to_owned()), 2731 ("RADROOTS_CLI_LOGGING_STDOUT".to_owned(), "true".to_owned()), 2732 ])); 2733 let env_file = parse_env_file_values( 2734 r#" 2735 RADROOTS_CLI_LOGGING_FILTER=debug 2736 RADROOTS_CLI_LOGGING_STDOUT=false 2737 "#, 2738 Path::new(".env.test"), 2739 ) 2740 .expect("parse env file"); 2741 2742 let resolved = 2743 RuntimeConfig::resolve_with_env_file(&args, &env, &env_file).expect("resolve config"); 2744 assert_eq!(resolved.output.format, OutputFormat::Human); 2745 assert_eq!(resolved.logging.filter, "info"); 2746 assert!(resolved.logging.stdout); 2747 } 2748 2749 #[test] 2750 fn user_relay_config_overrides_workspace_relay_config() { 2751 let temp = tempdir().expect("tempdir"); 2752 let workspace_root = temp.path().join("workspace"); 2753 let repo_local_root = workspace_root.join("infra/local/runtime/radroots"); 2754 let app_config_dir = repo_local_root.join("config/apps/cli"); 2755 let user_home = temp.path().join("home"); 2756 fs::create_dir_all(&repo_local_root).expect("workspace config dir"); 2757 fs::create_dir_all(&app_config_dir).expect("app config dir"); 2758 fs::write( 2759 repo_local_root.join("config.toml"), 2760 "[relays]\nurls = [\"wss://relay.workspace\"]\npublish_policy = \"any\"\n", 2761 ) 2762 .expect("write workspace config"); 2763 fs::write( 2764 app_config_dir.join("config.toml"), 2765 "[relays]\nurls = [\"wss://relay.user\", \"wss://relay.workspace\"]\n", 2766 ) 2767 .expect("write user config"); 2768 2769 let env = MapEnvironment { 2770 values: BTreeMap::from([ 2771 ( 2772 "RADROOTS_CLI_PATHS_PROFILE".to_owned(), 2773 "repo_local".to_owned(), 2774 ), 2775 ( 2776 "RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT".to_owned(), 2777 repo_local_root.display().to_string(), 2778 ), 2779 ]), 2780 current_dir: workspace_root, 2781 path_resolver: RadrootsPathResolver::new( 2782 RadrootsPlatform::Linux, 2783 RadrootsHostEnvironment { 2784 home_dir: Some(user_home), 2785 ..RadrootsHostEnvironment::default() 2786 }, 2787 ), 2788 stdin_tty: false, 2789 stdout_tty: false, 2790 }; 2791 let args = runtime_args(); 2792 2793 let resolved = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) 2794 .expect("resolve config"); 2795 assert_eq!( 2796 resolved.relay.urls, 2797 vec![ 2798 "wss://relay.user".to_owned(), 2799 "wss://relay.workspace".to_owned() 2800 ] 2801 ); 2802 assert_eq!(resolved.relay.source, RelayConfigSource::UserConfig); 2803 assert_eq!(resolved.relay.publish_policy, RelayPublishPolicy::Any); 2804 } 2805 2806 #[test] 2807 fn publish_transport_precedence_tracks_source() { 2808 let temp = tempdir().expect("tempdir"); 2809 let workspace_root = temp.path().join("workspace"); 2810 let repo_local_root = workspace_root.join("infra/local/runtime/radroots"); 2811 let app_config_dir = repo_local_root.join("config/apps/cli"); 2812 let user_home = temp.path().join("home"); 2813 fs::create_dir_all(&repo_local_root).expect("workspace config dir"); 2814 fs::create_dir_all(&app_config_dir).expect("app config dir"); 2815 fs::write( 2816 repo_local_root.join("config.toml"), 2817 "[publish]\ntransport = \"radrootsd_proxy\"\n", 2818 ) 2819 .expect("write workspace config"); 2820 fs::write( 2821 app_config_dir.join("config.toml"), 2822 "[publish]\ntransport = \"direct_nostr_relay\"\n", 2823 ) 2824 .expect("write user config"); 2825 2826 let env = repo_local_env( 2827 workspace_root.clone(), 2828 repo_local_root.clone(), 2829 user_home.clone(), 2830 BTreeMap::from([( 2831 "RADROOTS_CLI_PUBLISH_TRANSPORT".to_owned(), 2832 "radrootsd_proxy".to_owned(), 2833 )]), 2834 ); 2835 let args = RuntimeInvocationArgs { 2836 publish_transport: Some("direct_nostr_relay".to_owned()), 2837 ..runtime_args() 2838 }; 2839 let resolved = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) 2840 .expect("resolve flag publish transport"); 2841 assert_eq!( 2842 resolved.publish, 2843 PublishConfig { 2844 transport: PublishTransport::DirectNostrRelay, 2845 source: PublishTransportSource::Flags, 2846 radrootsd_proxy: crate::runtime::config::RadrootsdProxyConfig::default(), 2847 } 2848 ); 2849 2850 let env = repo_local_env( 2851 workspace_root.clone(), 2852 repo_local_root.clone(), 2853 user_home.clone(), 2854 BTreeMap::from([( 2855 "RADROOTS_CLI_PUBLISH_TRANSPORT".to_owned(), 2856 "radrootsd_proxy".to_owned(), 2857 )]), 2858 ); 2859 let resolved = 2860 RuntimeConfig::resolve_with_env_file(&runtime_args(), &env, &EnvFileValues::default()) 2861 .expect("resolve environment publish transport"); 2862 assert_eq!( 2863 resolved.publish, 2864 PublishConfig { 2865 transport: PublishTransport::RadrootsdProxy, 2866 source: PublishTransportSource::Environment, 2867 radrootsd_proxy: crate::runtime::config::RadrootsdProxyConfig::default(), 2868 } 2869 ); 2870 2871 let env = repo_local_env( 2872 workspace_root.clone(), 2873 repo_local_root.clone(), 2874 user_home.clone(), 2875 BTreeMap::new(), 2876 ); 2877 let resolved = 2878 RuntimeConfig::resolve_with_env_file(&runtime_args(), &env, &EnvFileValues::default()) 2879 .expect("resolve user publish transport"); 2880 assert_eq!( 2881 resolved.publish, 2882 PublishConfig { 2883 transport: PublishTransport::DirectNostrRelay, 2884 source: PublishTransportSource::UserConfig, 2885 radrootsd_proxy: crate::runtime::config::RadrootsdProxyConfig::default(), 2886 } 2887 ); 2888 2889 fs::remove_file(app_config_dir.join("config.toml")).expect("remove user config"); 2890 let env = repo_local_env( 2891 workspace_root.clone(), 2892 repo_local_root.clone(), 2893 user_home.clone(), 2894 BTreeMap::new(), 2895 ); 2896 let resolved = 2897 RuntimeConfig::resolve_with_env_file(&runtime_args(), &env, &EnvFileValues::default()) 2898 .expect("resolve workspace publish transport"); 2899 assert_eq!( 2900 resolved.publish, 2901 PublishConfig { 2902 transport: PublishTransport::RadrootsdProxy, 2903 source: PublishTransportSource::WorkspaceConfig, 2904 radrootsd_proxy: crate::runtime::config::RadrootsdProxyConfig::default(), 2905 } 2906 ); 2907 2908 fs::remove_file(repo_local_root.join("config.toml")).expect("remove workspace config"); 2909 let env = repo_local_env(workspace_root, repo_local_root, user_home, BTreeMap::new()); 2910 let resolved = 2911 RuntimeConfig::resolve_with_env_file(&runtime_args(), &env, &EnvFileValues::default()) 2912 .expect("resolve default publish transport"); 2913 assert_eq!( 2914 resolved.publish, 2915 PublishConfig { 2916 transport: PublishTransport::DirectNostrRelay, 2917 source: PublishTransportSource::Defaults, 2918 radrootsd_proxy: crate::runtime::config::RadrootsdProxyConfig::default(), 2919 } 2920 ); 2921 } 2922 2923 #[test] 2924 fn user_hyf_config_overrides_workspace_hyf_config() { 2925 let temp = tempdir().expect("tempdir"); 2926 let workspace_root = temp.path().join("workspace"); 2927 let repo_local_root = workspace_root.join("infra/local/runtime/radroots"); 2928 let app_config_dir = repo_local_root.join("config/apps/cli"); 2929 let user_home = temp.path().join("home"); 2930 fs::create_dir_all(&repo_local_root).expect("workspace config dir"); 2931 fs::create_dir_all(&app_config_dir).expect("app config dir"); 2932 fs::write( 2933 repo_local_root.join("config.toml"), 2934 "[hyf]\nenabled = false\nexecutable = \"workspace-hyfd\"\n", 2935 ) 2936 .expect("write workspace config"); 2937 fs::write( 2938 app_config_dir.join("config.toml"), 2939 "[hyf]\nenabled = true\nexecutable = \"user-hyfd\"\n", 2940 ) 2941 .expect("write user config"); 2942 2943 let env = MapEnvironment { 2944 values: BTreeMap::from([ 2945 ( 2946 "RADROOTS_CLI_PATHS_PROFILE".to_owned(), 2947 "repo_local".to_owned(), 2948 ), 2949 ( 2950 "RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT".to_owned(), 2951 repo_local_root.display().to_string(), 2952 ), 2953 ]), 2954 current_dir: workspace_root, 2955 path_resolver: RadrootsPathResolver::new( 2956 RadrootsPlatform::Linux, 2957 RadrootsHostEnvironment { 2958 home_dir: Some(user_home), 2959 ..RadrootsHostEnvironment::default() 2960 }, 2961 ), 2962 stdin_tty: false, 2963 stdout_tty: false, 2964 }; 2965 let args = runtime_args(); 2966 2967 let resolved = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) 2968 .expect("resolve config"); 2969 assert_eq!( 2970 resolved.hyf, 2971 HyfConfig { 2972 enabled: true, 2973 executable: PathBuf::from("user-hyfd"), 2974 } 2975 ); 2976 } 2977 2978 #[test] 2979 fn user_myc_config_overrides_workspace_myc_config() { 2980 let temp = tempdir().expect("tempdir"); 2981 let workspace_root = temp.path().join("workspace"); 2982 let repo_local_root = workspace_root.join("infra/local/runtime/radroots"); 2983 let app_config_dir = repo_local_root.join("config/apps/cli"); 2984 let user_home = temp.path().join("home"); 2985 fs::create_dir_all(&repo_local_root).expect("workspace config dir"); 2986 fs::create_dir_all(&app_config_dir).expect("app config dir"); 2987 fs::write( 2988 repo_local_root.join("config.toml"), 2989 "[myc]\nexecutable = \"workspace-myc\"\nstatus_timeout_ms = 9000\n", 2990 ) 2991 .expect("write workspace config"); 2992 fs::write( 2993 app_config_dir.join("config.toml"), 2994 "[myc]\nexecutable = \"user-myc\"\nstatus_timeout_ms = 3000\n", 2995 ) 2996 .expect("write user config"); 2997 2998 let env = MapEnvironment { 2999 values: BTreeMap::from([ 3000 ( 3001 "RADROOTS_CLI_PATHS_PROFILE".to_owned(), 3002 "repo_local".to_owned(), 3003 ), 3004 ( 3005 "RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT".to_owned(), 3006 repo_local_root.display().to_string(), 3007 ), 3008 ]), 3009 current_dir: workspace_root, 3010 path_resolver: RadrootsPathResolver::new( 3011 RadrootsPlatform::Linux, 3012 RadrootsHostEnvironment { 3013 home_dir: Some(user_home), 3014 ..RadrootsHostEnvironment::default() 3015 }, 3016 ), 3017 stdin_tty: false, 3018 stdout_tty: false, 3019 }; 3020 let args = runtime_args(); 3021 3022 let resolved = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) 3023 .expect("resolve config"); 3024 assert_eq!(resolved.myc.executable, PathBuf::from("user-myc")); 3025 assert_eq!(resolved.myc.status_timeout_ms, 3000); 3026 } 3027 3028 #[test] 3029 fn user_signer_config_overrides_workspace_signer_config() { 3030 let temp = tempdir().expect("tempdir"); 3031 let workspace_root = temp.path().join("workspace"); 3032 let repo_local_root = workspace_root.join("infra/local/runtime/radroots"); 3033 let app_config_dir = repo_local_root.join("config/apps/cli"); 3034 let user_home = temp.path().join("home"); 3035 fs::create_dir_all(&app_config_dir).expect("app config dir"); 3036 fs::write( 3037 repo_local_root.join("config.toml"), 3038 "[signer]\nbackend = \"myc\"\n", 3039 ) 3040 .expect("write workspace config"); 3041 fs::write( 3042 app_config_dir.join("config.toml"), 3043 "[signer]\nbackend = \"local\"\n", 3044 ) 3045 .expect("write user config"); 3046 3047 let env = repo_local_env(workspace_root, repo_local_root, user_home, BTreeMap::new()); 3048 let args = runtime_args(); 3049 3050 let resolved = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) 3051 .expect("resolve config"); 3052 assert_eq!(resolved.signer.backend, SignerBackend::Local); 3053 } 3054 3055 #[test] 3056 fn environment_signer_overrides_user_signer_config() { 3057 let temp = tempdir().expect("tempdir"); 3058 let workspace_root = temp.path().join("workspace"); 3059 let repo_local_root = workspace_root.join("infra/local/runtime/radroots"); 3060 let app_config_dir = repo_local_root.join("config/apps/cli"); 3061 let user_home = temp.path().join("home"); 3062 fs::create_dir_all(&app_config_dir).expect("app config dir"); 3063 fs::write( 3064 app_config_dir.join("config.toml"), 3065 "[signer]\nbackend = \"local\"\n", 3066 ) 3067 .expect("write user config"); 3068 3069 let env = repo_local_env( 3070 workspace_root, 3071 repo_local_root, 3072 user_home, 3073 BTreeMap::from([("RADROOTS_CLI_SIGNER_BACKEND".to_owned(), "myc".to_owned())]), 3074 ); 3075 let args = runtime_args(); 3076 3077 let resolved = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) 3078 .expect("resolve config"); 3079 assert_eq!(resolved.signer.backend, SignerBackend::Myc); 3080 } 3081 3082 #[test] 3083 fn invalid_signer_config_reports_config_source() { 3084 let temp = tempdir().expect("tempdir"); 3085 let workspace_root = temp.path().join("workspace"); 3086 let repo_local_root = workspace_root.join("infra/local/runtime/radroots"); 3087 let user_home = temp.path().join("home"); 3088 fs::create_dir_all(&repo_local_root).expect("workspace config dir"); 3089 fs::write( 3090 repo_local_root.join("config.toml"), 3091 "[signer]\nbackend = \"remote\"\n", 3092 ) 3093 .expect("write workspace config"); 3094 3095 let env = repo_local_env(workspace_root, repo_local_root, user_home, BTreeMap::new()); 3096 let args = runtime_args(); 3097 3098 let error = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) 3099 .expect_err("invalid signer mode"); 3100 let message = error.to_string(); 3101 assert!(message.contains("workspace config [signer].backend")); 3102 assert!(!message.contains("--signer")); 3103 } 3104 3105 #[test] 3106 fn invalid_publish_config_reports_config_source() { 3107 let temp = tempdir().expect("tempdir"); 3108 let workspace_root = temp.path().join("workspace"); 3109 let repo_local_root = workspace_root.join("infra/local/runtime/radroots"); 3110 let user_home = temp.path().join("home"); 3111 fs::create_dir_all(&repo_local_root).expect("workspace config dir"); 3112 fs::write( 3113 repo_local_root.join("config.toml"), 3114 "[publish]\ntransport = \"nostr\"\n", 3115 ) 3116 .expect("write workspace config"); 3117 3118 let env = repo_local_env(workspace_root, repo_local_root, user_home, BTreeMap::new()); 3119 let error = 3120 RuntimeConfig::resolve_with_env_file(&runtime_args(), &env, &EnvFileValues::default()) 3121 .expect_err("invalid publish transport"); 3122 let message = error.to_string(); 3123 assert!(message.contains("workspace config [publish].transport")); 3124 assert!(message.contains("direct_nostr_relay")); 3125 assert!(message.contains("radrootsd_proxy")); 3126 } 3127 3128 #[test] 3129 fn user_capability_binding_overrides_workspace_binding() { 3130 let temp = tempdir().expect("tempdir"); 3131 let workspace_root = temp.path().join("workspace"); 3132 let repo_local_root = workspace_root.join("infra/local/runtime/radroots"); 3133 let app_config_dir = repo_local_root.join("config/apps/cli"); 3134 let user_home = temp.path().join("home"); 3135 fs::create_dir_all(&repo_local_root).expect("workspace config dir"); 3136 fs::create_dir_all(&app_config_dir).expect("app config dir"); 3137 fs::write( 3138 repo_local_root.join("config.toml"), 3139 r#" 3140 [[capability_binding]] 3141 capability = "inference.hyf_stdio" 3142 provider = "hyf" 3143 target_kind = "managed_instance" 3144 target = "workspace-hyf" 3145 "#, 3146 ) 3147 .expect("write workspace config"); 3148 fs::write( 3149 app_config_dir.join("config.toml"), 3150 r#" 3151 [[capability_binding]] 3152 capability = "inference.hyf_stdio" 3153 provider = "hyf" 3154 target_kind = "explicit_endpoint" 3155 target = "bin/user-hyfd" 3156 "#, 3157 ) 3158 .expect("write user config"); 3159 3160 let env = MapEnvironment { 3161 values: BTreeMap::from([ 3162 ( 3163 "RADROOTS_CLI_PATHS_PROFILE".to_owned(), 3164 "repo_local".to_owned(), 3165 ), 3166 ( 3167 "RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT".to_owned(), 3168 repo_local_root.display().to_string(), 3169 ), 3170 ]), 3171 current_dir: workspace_root, 3172 path_resolver: RadrootsPathResolver::new( 3173 RadrootsPlatform::Linux, 3174 RadrootsHostEnvironment { 3175 home_dir: Some(user_home), 3176 ..RadrootsHostEnvironment::default() 3177 }, 3178 ), 3179 stdin_tty: false, 3180 stdout_tty: false, 3181 }; 3182 let args = runtime_args(); 3183 3184 let resolved = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) 3185 .expect("resolve config"); 3186 assert_eq!(resolved.capability_bindings.len(), 1); 3187 assert_eq!( 3188 resolved.capability_bindings[0], 3189 CapabilityBindingConfig { 3190 capability_id: INFERENCE_HYF_STDIO_CAPABILITY.to_owned(), 3191 provider_runtime_id: "hyf".to_owned(), 3192 binding_model: "stdio_service".to_owned(), 3193 target_kind: CapabilityBindingTargetKind::ExplicitEndpoint, 3194 target: "bin/user-hyfd".to_owned(), 3195 managed_account_ref: None, 3196 signer_session_ref: None, 3197 source: CapabilityBindingSource::UserConfig, 3198 } 3199 ); 3200 } 3201 3202 #[test] 3203 fn daemon_and_workflow_capability_bindings_are_rejected() { 3204 let temp = tempdir().expect("tempdir"); 3205 let workspace_root = temp.path().join("workspace"); 3206 let repo_local_root = workspace_root.join("infra/local/runtime/radroots"); 3207 let user_home = temp.path().join("home"); 3208 fs::create_dir_all(&repo_local_root).expect("workspace config dir"); 3209 fs::create_dir_all(user_home.join(".radroots/config/apps/cli")).expect("app config dir"); 3210 fs::write( 3211 repo_local_root.join("config.toml"), 3212 r#" 3213 [[capability_binding]] 3214 capability = "write_plane.trade_jsonrpc" 3215 provider = "radrootsd" 3216 target_kind = "explicit_endpoint" 3217 target = "https://rpc.workspace.test/jsonrpc" 3218 "#, 3219 ) 3220 .expect("write workspace config"); 3221 3222 let env = MapEnvironment { 3223 values: BTreeMap::from([ 3224 ( 3225 "RADROOTS_CLI_PATHS_PROFILE".to_owned(), 3226 "repo_local".to_owned(), 3227 ), 3228 ( 3229 "RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT".to_owned(), 3230 repo_local_root.display().to_string(), 3231 ), 3232 ]), 3233 current_dir: workspace_root, 3234 path_resolver: RadrootsPathResolver::new( 3235 RadrootsPlatform::Linux, 3236 RadrootsHostEnvironment { 3237 home_dir: Some(user_home), 3238 ..RadrootsHostEnvironment::default() 3239 }, 3240 ), 3241 stdin_tty: false, 3242 stdout_tty: false, 3243 }; 3244 let args = runtime_args(); 3245 3246 let error = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) 3247 .expect_err("rejected daemon capability binding"); 3248 assert!( 3249 error 3250 .to_string() 3251 .contains("unknown capability_binding capability `write_plane.trade_jsonrpc`") 3252 ); 3253 3254 fs::write( 3255 repo_local_root.join("config.toml"), 3256 r#" 3257 [[capability_binding]] 3258 capability = "workflow.trade" 3259 provider = "rhi" 3260 target_kind = "managed_instance" 3261 target = "workflow-default" 3262 "#, 3263 ) 3264 .expect("write workflow config"); 3265 3266 let error = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) 3267 .expect_err("rejected workflow capability binding"); 3268 assert!( 3269 error 3270 .to_string() 3271 .contains("unknown capability_binding capability `workflow.trade`") 3272 ); 3273 } 3274 3275 #[test] 3276 fn invalid_relay_url_fails() { 3277 for relay in [ 3278 "https://not-a-websocket.example.com", 3279 "wss://", 3280 "wss://user@relay.example", 3281 "wss://relay.example:abc", 3282 " ", 3283 ] { 3284 let args = RuntimeInvocationArgs { 3285 relay: vec![relay.to_owned()], 3286 ..runtime_args() 3287 }; 3288 let env = MapEnvironment::new(BTreeMap::new()); 3289 let error = 3290 RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) 3291 .expect_err("invalid relay url"); 3292 assert!( 3293 error.to_string().contains("relay url") 3294 || error.to_string().contains("websocket relay urls"), 3295 "unexpected error for {relay}: {error}" 3296 ); 3297 } 3298 } 3299 3300 #[test] 3301 fn relay_env_value_rejects_empty_entries() { 3302 let env = MapEnvironment::new(BTreeMap::from([( 3303 super::ENV_CLI_RELAYS_URLS.to_owned(), 3304 "wss://relay.example,,wss://relay-two.example".to_owned(), 3305 )])); 3306 let error = 3307 RuntimeConfig::resolve_with_env_file(&runtime_args(), &env, &EnvFileValues::default()) 3308 .expect_err("empty relay entry"); 3309 3310 assert!(error.to_string().contains("empty relay url")); 3311 } 3312 3313 #[test] 3314 fn valid_ipv6_relay_url_resolves() { 3315 let args = RuntimeInvocationArgs { 3316 relay: vec![" wss://[2001:db8::1]:443/relay ".to_owned()], 3317 ..runtime_args() 3318 }; 3319 let env = MapEnvironment::new(BTreeMap::new()); 3320 let config = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) 3321 .expect("valid relay url"); 3322 3323 assert_eq!(config.relay.urls, vec!["wss://[2001:db8::1]:443/relay"]); 3324 } 3325 3326 #[test] 3327 fn state_roots_are_resolved_from_home_and_workspace() { 3328 let args = runtime_args(); 3329 let env = MapEnvironment::new(BTreeMap::new()); 3330 let resolved = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) 3331 .expect("resolve runtime config"); 3332 3333 assert_eq!( 3334 resolved.paths.app_config_path, 3335 PathBuf::from("/home/tester/.radroots/config/apps/cli/config.toml") 3336 ); 3337 assert_eq!(resolved.paths.profile_source, "default"); 3338 assert_eq!(resolved.paths.root_source, "host_defaults"); 3339 assert_eq!(resolved.paths.repo_local_root, None); 3340 assert_eq!(resolved.paths.repo_local_root_source, None); 3341 assert_eq!( 3342 resolved.paths.subordinate_path_override_source, 3343 "runtime_config" 3344 ); 3345 assert_eq!(resolved.paths.app_namespace, "apps/cli"); 3346 assert_eq!(resolved.paths.shared_accounts_namespace, "shared/accounts"); 3347 assert_eq!( 3348 resolved.paths.shared_identities_namespace, 3349 "shared/identities" 3350 ); 3351 assert_eq!(resolved.paths.workspace_config_path, None); 3352 assert_eq!( 3353 resolved.paths.app_data_root, 3354 PathBuf::from("/home/tester/.radroots/data/apps/cli") 3355 ); 3356 assert_eq!( 3357 resolved.paths.allowed_profiles, 3358 vec!["interactive_user".to_owned(), "repo_local".to_owned(),] 3359 ); 3360 } 3361 3362 #[test] 3363 fn windows_roots_use_native_user_directories() { 3364 let args = runtime_args(); 3365 let env = MapEnvironment { 3366 values: BTreeMap::new(), 3367 current_dir: PathBuf::from(r"C:\workspaces\radroots-cli"), 3368 path_resolver: RadrootsPathResolver::new( 3369 RadrootsPlatform::Windows, 3370 RadrootsHostEnvironment { 3371 appdata_dir: Some(PathBuf::from(r"C:\Users\tester\AppData\Roaming")), 3372 localappdata_dir: Some(PathBuf::from(r"C:\Users\tester\AppData\Local")), 3373 ..RadrootsHostEnvironment::default() 3374 }, 3375 ), 3376 stdin_tty: false, 3377 stdout_tty: false, 3378 }; 3379 3380 let resolved = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) 3381 .expect("resolve runtime config"); 3382 3383 assert_eq!( 3384 resolved.paths.app_config_path, 3385 PathBuf::from(r"C:\Users\tester\AppData\Roaming") 3386 .join("Radroots") 3387 .join("config") 3388 .join("apps") 3389 .join("cli") 3390 .join("config.toml") 3391 ); 3392 assert_eq!( 3393 resolved.paths.app_data_root, 3394 PathBuf::from(r"C:\Users\tester\AppData\Local") 3395 .join("Radroots") 3396 .join("data") 3397 .join("apps") 3398 .join("cli") 3399 ); 3400 assert_eq!( 3401 resolved.paths.shared_accounts_data_root, 3402 PathBuf::from(r"C:\Users\tester\AppData\Local") 3403 .join("Radroots") 3404 .join("data") 3405 .join("shared") 3406 .join("accounts") 3407 ); 3408 assert_eq!( 3409 resolved.paths.default_identity_path, 3410 PathBuf::from(r"C:\Users\tester\AppData\Roaming") 3411 .join("Radroots") 3412 .join("secrets") 3413 .join("shared") 3414 .join("identities") 3415 .join("default.json") 3416 ); 3417 } 3418 3419 #[test] 3420 fn repo_local_profile_uses_explicit_repo_local_root() { 3421 let args = runtime_args(); 3422 let env = MapEnvironment::new(BTreeMap::from([ 3423 ( 3424 "RADROOTS_CLI_PATHS_PROFILE".to_owned(), 3425 "repo_local".to_owned(), 3426 ), 3427 ( 3428 "RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT".to_owned(), 3429 ".local/radroots/dev".to_owned(), 3430 ), 3431 ])); 3432 3433 let resolved = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) 3434 .expect("resolve runtime config"); 3435 3436 assert_eq!(resolved.paths.profile, "repo_local"); 3437 assert_eq!( 3438 resolved.paths.profile_source, 3439 "process_env:RADROOTS_CLI_PATHS_PROFILE" 3440 ); 3441 assert_eq!(resolved.paths.root_source, "repo_local_root"); 3442 assert_eq!( 3443 resolved.paths.repo_local_root, 3444 Some(PathBuf::from( 3445 "/workspaces/radroots-cli/.local/radroots/dev" 3446 )) 3447 ); 3448 assert_eq!( 3449 resolved.paths.repo_local_root_source, 3450 Some("process_env:RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT".to_owned()) 3451 ); 3452 assert_eq!( 3453 resolved.paths.app_config_path, 3454 PathBuf::from( 3455 "/workspaces/radroots-cli/.local/radroots/dev/config/apps/cli/config.toml" 3456 ) 3457 ); 3458 assert_eq!( 3459 resolved.paths.workspace_config_path, 3460 Some(PathBuf::from( 3461 "/workspaces/radroots-cli/.local/radroots/dev/config.toml" 3462 )) 3463 ); 3464 assert_eq!( 3465 resolved.paths.app_data_root, 3466 PathBuf::from("/workspaces/radroots-cli/.local/radroots/dev/data/apps/cli") 3467 ); 3468 assert_eq!( 3469 resolved.paths.app_logs_root, 3470 PathBuf::from("/workspaces/radroots-cli/.local/radroots/dev/logs/apps/cli") 3471 ); 3472 assert_eq!( 3473 resolved.paths.shared_accounts_data_root, 3474 PathBuf::from("/workspaces/radroots-cli/.local/radroots/dev/data/shared/accounts") 3475 ); 3476 assert_eq!( 3477 resolved.paths.default_identity_path, 3478 PathBuf::from( 3479 "/workspaces/radroots-cli/.local/radroots/dev/secrets/shared/identities/default.json" 3480 ) 3481 ); 3482 } 3483 3484 #[test] 3485 fn repo_local_profile_requires_explicit_root() { 3486 let args = runtime_args(); 3487 let env = MapEnvironment::new(BTreeMap::from([( 3488 "RADROOTS_CLI_PATHS_PROFILE".to_owned(), 3489 "repo_local".to_owned(), 3490 )])); 3491 3492 let error = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) 3493 .expect_err("repo_local should require an explicit root"); 3494 assert!( 3495 error 3496 .to_string() 3497 .contains("RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT") 3498 ); 3499 } 3500 3501 #[test] 3502 fn env_file_can_select_repo_local_profile() { 3503 let args = runtime_args(); 3504 let env = MapEnvironment::new(BTreeMap::new()); 3505 let env_file = parse_env_file_values( 3506 r#" 3507 RADROOTS_CLI_PATHS_PROFILE=repo_local 3508 RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT=.local/radroots/dev 3509 "#, 3510 Path::new(".env.test"), 3511 ) 3512 .expect("parse env file"); 3513 3514 let resolved = 3515 RuntimeConfig::resolve_with_env_file(&args, &env, &env_file).expect("resolve config"); 3516 assert_eq!(resolved.paths.profile, "repo_local"); 3517 assert_eq!( 3518 resolved.paths.app_data_root, 3519 PathBuf::from("/workspaces/radroots-cli/.local/radroots/dev/data/apps/cli") 3520 ); 3521 assert_eq!( 3522 resolved.paths.workspace_config_path, 3523 Some(PathBuf::from( 3524 "/workspaces/radroots-cli/.local/radroots/dev/config.toml" 3525 )) 3526 ); 3527 } 3528 3529 #[test] 3530 fn unknown_env_file_variable_fails() { 3531 let error = parse_env_file_values( 3532 "RADROOTS_CLI_LOGGING_FILTRE=debug\n", 3533 Path::new(".env.test"), 3534 ) 3535 .expect_err("unknown env variable"); 3536 assert!( 3537 error 3538 .to_string() 3539 .contains("unknown environment variable `RADROOTS_CLI_LOGGING_FILTRE`") 3540 ); 3541 } 3542 3543 #[test] 3544 fn old_env_file_variable_fails() { 3545 let error = parse_env_file_values("RADROOTS_OUTPUT=json\n", Path::new(".env.test")) 3546 .expect_err("old env variable should fail"); 3547 assert!( 3548 error 3549 .to_string() 3550 .contains("unknown environment variable `RADROOTS_OUTPUT`") 3551 ); 3552 } 3553 3554 #[test] 3555 fn duplicate_env_file_variable_fails() { 3556 let error = parse_env_file_values( 3557 "RADROOTS_CLI_OUTPUT_FORMAT=json\nRADROOTS_CLI_OUTPUT_FORMAT=human\n", 3558 Path::new(".env.test"), 3559 ) 3560 .expect_err("duplicate env variable should fail"); 3561 assert!( 3562 error 3563 .to_string() 3564 .contains("duplicate environment variable `RADROOTS_CLI_OUTPUT_FORMAT`") 3565 ); 3566 } 3567 3568 #[test] 3569 fn old_toml_groups_and_fields_fail() { 3570 let temp = tempdir().expect("tempdir"); 3571 let workspace_root = temp.path().join("workspace"); 3572 let repo_local_root = workspace_root.join("infra/local/runtime/radroots"); 3573 let user_home = temp.path().join("home"); 3574 fs::create_dir_all(&repo_local_root).expect("workspace config dir"); 3575 3576 for (raw, expected) in [ 3577 ( 3578 "[relay]\nurls = [\"wss://relay.old\"]\n", 3579 "unknown field `relay`", 3580 ), 3581 ("[signer]\nmode = \"local\"\n", "unknown field `mode`"), 3582 ( 3583 "[relays]\nurls = [\"wss://relay.example\"]\nextra = true\n", 3584 "unknown field `extra`", 3585 ), 3586 ] { 3587 fs::write(repo_local_root.join("config.toml"), raw).expect("write config"); 3588 let env = repo_local_env( 3589 workspace_root.clone(), 3590 repo_local_root.clone(), 3591 user_home.clone(), 3592 BTreeMap::new(), 3593 ); 3594 let error = RuntimeConfig::resolve_with_env_file( 3595 &runtime_args(), 3596 &env, 3597 &EnvFileValues::default(), 3598 ) 3599 .expect_err("old toml shape should fail"); 3600 assert!( 3601 error.to_string().contains(expected), 3602 "expected {expected}, got {error}" 3603 ); 3604 } 3605 } 3606 3607 #[test] 3608 fn env_output_accepts_ndjson() { 3609 let args = runtime_args(); 3610 let env = MapEnvironment::new(BTreeMap::from([( 3611 "RADROOTS_CLI_OUTPUT_FORMAT".to_owned(), 3612 "ndjson".to_owned(), 3613 )])); 3614 3615 let resolved = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) 3616 .expect("resolve runtime config"); 3617 assert_eq!(resolved.output.format, OutputFormat::Ndjson); 3618 } 3619 }