diff --git a/CHANGELOG.md b/CHANGELOG.md index 3315d3e02..5fe1b5984 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ * Fixed a race condition in `pruneIrrelevantBlocks` that could delete the current block header when multiple tabs share IndexedDB, causing sync to panic ([#1650](https://github.com/0xMiden/miden-client/pull/1650)). * Fixed a race condition where concurrent sync operations could cause sync height to go backwards, leading to block header deletion and subsequent panics ([#1650](https://github.com/0xMiden/miden-client/pull/1650)). * Changed `get_current_partial_mmr` to return a `StoreError::BlockHeaderNotFound` error instead of panicking when the block header is missing ([#1650](https://github.com/0xMiden/miden-client/pull/1650)). +* Added `CliClient` wrapper and `CliConfig::from_system()` to allow creating a CLI-configured client programmatically ([#1642](https://github.com/0xMiden/miden-client/pull/1642)). ## 0.12.6 (2026-01-08) diff --git a/bin/miden-cli/src/commands/account.rs b/bin/miden-cli/src/commands/account.rs index b20ce0022..ee0ff432d 100644 --- a/bin/miden-cli/src/commands/account.rs +++ b/bin/miden-cli/src/commands/account.rs @@ -15,7 +15,7 @@ use miden_client::{Client, PrettyPrint, ZERO}; use crate::config::CliConfig; use crate::errors::CliError; -use crate::utils::{load_config_file, load_faucet_details_map, parse_account_id}; +use crate::utils::{load_faucet_details_map, parse_account_id}; use crate::{client_binary_name, create_dynamic_table}; pub const DEFAULT_ACCOUNT_ID_KEY: &str = "default_account_id"; @@ -47,7 +47,7 @@ pub struct AccountCmd { impl AccountCmd { pub async fn execute(&self, mut client: Client) -> Result<(), CliError> { - let (cli_config, _) = load_config_file()?; + let cli_config = CliConfig::from_system()?; match self { AccountCmd { list: false, diff --git a/bin/miden-cli/src/commands/address.rs b/bin/miden-cli/src/commands/address.rs index 87d55c446..072626b71 100644 --- a/bin/miden-cli/src/commands/address.rs +++ b/bin/miden-cli/src/commands/address.rs @@ -4,9 +4,10 @@ use miden_client::Client; use miden_client::address::{Address, AddressInterface, NetworkId, RoutingParameters}; use miden_client::note::{NoteExecutionMode, NoteTag}; +use crate::config::CliConfig; use crate::errors::CliError; use crate::utils::parse_account_id; -use crate::{Parser, Subcommand, create_dynamic_table, load_config_file}; +use crate::{Parser, Subcommand, create_dynamic_table}; #[derive(Debug, Clone)] pub enum CliAddressInterface { @@ -67,7 +68,7 @@ impl AddressCmd { pub async fn execute(&self, client: Client) -> Result<(), CliError> { match &self.command { Some(AddressSubCommand::List { account_id: Some(account_id) }) => { - let (cli_config, _) = load_config_file()?; + let cli_config = CliConfig::from_system()?; let network_id = cli_config.rpc.endpoint.0.to_network_id(); list_account_addresses(client, account_id, network_id).await?; }, @@ -79,7 +80,7 @@ impl AddressCmd { }, _ => { // List all addresses as default - let (cli_config, _) = load_config_file()?; + let cli_config = CliConfig::from_system()?; let network_id = cli_config.rpc.endpoint.0.to_network_id(); list_all_addresses(client, network_id).await?; }, diff --git a/bin/miden-cli/src/commands/new_account.rs b/bin/miden-cli/src/commands/new_account.rs index 1214c2208..c9abd0da9 100644 --- a/bin/miden-cli/src/commands/new_account.rs +++ b/bin/miden-cli/src/commands/new_account.rs @@ -16,7 +16,6 @@ use miden_client::account::component::{ }; use miden_client::account::{Account, AccountBuilder, AccountStorageMode, AccountType}; use miden_client::auth::{AuthRpoFalcon512, AuthSecretKey, TransactionAuthenticator}; -use miden_client::keystore::FilesystemKeyStore; use miden_client::transaction::TransactionRequestBuilder; use miden_client::utils::Deserializable; use miden_client::vm::{Package, SectionId}; @@ -26,7 +25,7 @@ use tracing::{debug, warn}; use crate::commands::account::set_default_account_if_unset; use crate::config::CliConfig; use crate::errors::CliError; -use crate::{client_binary_name, load_config_file}; +use crate::{CliKeyStore, client_binary_name}; // CLI TYPES // ================================================================================================ @@ -103,7 +102,7 @@ impl NewWalletCmd { pub async fn execute( &self, mut client: Client, - keystore: FilesystemKeyStore, + keystore: CliKeyStore, ) -> Result<(), CliError> { let package_paths: Vec = [PathBuf::from("basic-wallet")] .into_iter() @@ -198,7 +197,7 @@ impl NewAccountCmd { pub async fn execute( &self, mut client: Client, - keystore: FilesystemKeyStore, + keystore: CliKeyStore, ) -> Result<(), CliError> { let new_account = create_client_account( &mut client, @@ -349,7 +348,7 @@ fn separate_auth_components( /// If no auth component is detected in the packages, a Falcon-based auth component will be added. async fn create_client_account( client: &mut Client, - keystore: &FilesystemKeyStore, + keystore: &CliKeyStore, account_type: AccountType, storage_mode: AccountStorageMode, package_paths: &[PathBuf], @@ -365,7 +364,7 @@ async fn create_client_account( // Load the component templates and initialization storage data. - let (cli_config, _) = load_config_file()?; + let cli_config = CliConfig::from_system()?; debug!("Loading packages..."); let packages = load_packages(&cli_config, package_paths)?; debug!("Loaded {} packages", packages.len()); diff --git a/bin/miden-cli/src/commands/new_transactions.rs b/bin/miden-cli/src/commands/new_transactions.rs index b886cd1db..cf873eddf 100644 --- a/bin/miden-cli/src/commands/new_transactions.rs +++ b/bin/miden-cli/src/commands/new_transactions.rs @@ -24,12 +24,12 @@ use miden_client::transaction::{ use miden_client::{Client, RemoteTransactionProver}; use tracing::info; +use crate::config::CliConfig; use crate::create_dynamic_table; use crate::errors::CliError; use crate::utils::{ SHARED_TOKEN_DOCUMENTATION, get_input_acc_id_by_prefix_or_default, - load_config_file, load_faucet_details_map, parse_account_id, }; @@ -412,7 +412,7 @@ async fn execute_transaction( println!("Proving transaction..."); let prover = if delegated_proving { - let (cli_config, _) = load_config_file()?; + let cli_config = CliConfig::from_system()?; let remote_prover_endpoint = cli_config.remote_prover_endpoint.as_ref().ok_or(CliError::Config( "Remote prover endpoint".to_string().into(), diff --git a/bin/miden-cli/src/config.rs b/bin/miden-cli/src/config.rs index 1a36783c3..93f2c322e 100644 --- a/bin/miden-cli/src/config.rs +++ b/bin/miden-cli/src/config.rs @@ -1,11 +1,12 @@ use core::fmt::Debug; use std::fmt::Display; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::str::FromStr; use std::time::Duration; +use figment::providers::{Format, Toml}; use figment::value::{Dict, Map}; -use figment::{Metadata, Profile, Provider}; +use figment::{Figment, Metadata, Profile, Provider}; use miden_client::note_transport::NOTE_TRANSPORT_DEFAULT_ENDPOINT; use miden_client::rpc::Endpoint; use serde::{Deserialize, Serialize}; @@ -13,6 +14,7 @@ use serde::{Deserialize, Serialize}; use crate::errors::CliError; pub const MIDEN_DIR: &str = ".miden"; +pub const CLIENT_CONFIG_FILE_NAME: &str = "miden-client.toml"; pub const TOKEN_SYMBOL_MAP_FILENAME: &str = "token_symbol_map.toml"; pub const DEFAULT_PACKAGES_DIR: &str = "packages"; pub const STORE_FILENAME: &str = "store.sqlite3"; @@ -75,6 +77,13 @@ impl Provider for CliConfig { } } +/// Default implementation for `CliConfig`. +/// +/// **Note**: This implementation is primarily used by the [`figment`] `Provider` trait +/// (see [`CliConfig::data()`]) to provide default values during configuration merging. +/// The paths returned are relative and intended to be resolved against a `.miden` directory. +/// +/// For loading configuration from the filesystem, use [`CliConfig::from_system()`] instead. impl Default for CliConfig { fn default() -> Self { // Create paths relative to the config file location (which is in .miden directory) @@ -93,6 +102,243 @@ impl Default for CliConfig { } } +impl CliConfig { + /// Loads configuration from a specific `.miden` directory. + /// + /// # ⚠️ WARNING: Advanced Use Only + /// + /// **This method bypasses the standard CLI configuration discovery logic.** + /// + /// This method loads config from an explicitly specified directory, which means: + /// - It does NOT check for local `.miden` directory first + /// - It does NOT fall back to global `~/.miden` directory + /// - It does NOT follow CLI priority logic + /// + /// ## Recommended Alternative + /// + /// For standard CLI-like configuration loading, use: + /// ```ignore + /// CliConfig::from_system() // Respects local → global priority + /// ``` + /// + /// Or for client initialization: + /// ```ignore + /// CliClient::from_system_user_config(debug_mode).await? + /// ``` + /// + /// ## When to use this method + /// + /// - **Testing**: When you need to test with config from a specific directory + /// - **Explicit Control**: When you must load from a non-standard location + /// + /// # Arguments + /// + /// * `miden_dir` - Path to the `.miden` directory containing `miden-client.toml` + /// + /// # Returns + /// + /// A configured [`CliConfig`] instance with resolved paths. + /// + /// # Errors + /// + /// Returns a [`CliError`](crate::errors::CliError): + /// - [`CliError::ConfigNotFound`](crate::errors::CliError::ConfigNotFound) if the config file + /// doesn't exist in the specified directory + /// - [`CliError::Config`](crate::errors::CliError::Config) if configuration file parsing fails + /// + /// # Examples + /// + /// ```no_run + /// use std::path::PathBuf; + /// + /// use miden_client_cli::config::CliConfig; + /// + /// # fn example() -> Result<(), Box> { + /// // ⚠️ This bypasses standard config discovery! + /// let config = CliConfig::from_dir(&PathBuf::from("/path/to/.miden"))?; + /// + /// // ✅ Prefer this for CLI-like behavior: + /// let config = CliConfig::from_system()?; + /// # Ok(()) + /// # } + /// ``` + pub fn from_dir(miden_dir: &Path) -> Result { + let config_path = miden_dir.join(CLIENT_CONFIG_FILE_NAME); + + if !config_path.exists() { + return Err(CliError::ConfigNotFound(format!( + "Config file does not exist at {}", + config_path.display() + ))); + } + + let mut cli_config = Self::load_from_file(&config_path)?; + + // Resolve all relative paths relative to the .miden directory + Self::resolve_relative_path(&mut cli_config.store_filepath, miden_dir); + Self::resolve_relative_path(&mut cli_config.secret_keys_directory, miden_dir); + Self::resolve_relative_path(&mut cli_config.token_symbol_map_filepath, miden_dir); + Self::resolve_relative_path(&mut cli_config.package_directory, miden_dir); + + Ok(cli_config) + } + + /// Loads configuration from the local `.miden` directory (current working directory). + /// + /// # ⚠️ WARNING: Advanced Use Only + /// + /// **This method bypasses the standard CLI configuration discovery logic.** + /// + /// This method ONLY checks the local directory and does NOT fall back to the global + /// configuration if the local config doesn't exist. This differs from CLI behavior. + /// + /// ## Recommended Alternative + /// + /// For standard CLI-like behavior: + /// ```ignore + /// CliConfig::from_system() // Respects local → global fallback + /// CliClient::from_system_user_config(debug_mode).await? + /// ``` + /// + /// ## When to use this method + /// + /// - **Testing**: When you need to ensure only local config is used + /// - **Explicit Control**: When you must avoid global config + /// + /// # Returns + /// + /// A configured [`CliConfig`] instance. + /// + /// # Errors + /// + /// Returns a [`CliError`](crate::errors::CliError) if: + /// - Cannot determine current working directory + /// - The config file doesn't exist locally + /// - Configuration file parsing fails + pub fn from_local_dir() -> Result { + let local_miden_dir = get_local_miden_dir()?; + Self::from_dir(&local_miden_dir) + } + + /// Loads configuration from the global `.miden` directory (user's home directory). + /// + /// # ⚠️ WARNING: Advanced Use Only + /// + /// **This method bypasses the standard CLI configuration discovery logic.** + /// + /// This method ONLY checks the global directory and does NOT check for local config first. + /// This differs from CLI behavior which prioritizes local config over global. + /// + /// ## Recommended Alternative + /// + /// For standard CLI-like behavior: + /// ```ignore + /// CliConfig::from_system() // Respects local → global priority + /// CliClient::from_system_user_config(debug_mode).await? + /// ``` + /// + /// ## When to use this method + /// + /// - **Testing**: When you need to ensure only global config is used + /// - **Explicit Control**: When you must bypass local config + /// + /// # Returns + /// + /// A configured [`CliConfig`] instance. + /// + /// # Errors + /// + /// Returns a [`CliError`](crate::errors::CliError) if: + /// - Cannot determine home directory + /// - The config file doesn't exist globally + /// - Configuration file parsing fails + pub fn from_global_dir() -> Result { + let global_miden_dir = get_global_miden_dir().map_err(|e| { + CliError::Config(Box::new(e), "Failed to determine global config directory".to_string()) + })?; + Self::from_dir(&global_miden_dir) + } + + /// Loads configuration from system directories with priority: local first, then global + /// fallback. + /// + /// # ✅ Recommended Method + /// + /// **This is the recommended method for loading CLI configuration as it follows the same + /// discovery logic as the CLI tool itself.** + /// + /// This method searches for configuration files in the following order: + /// 1. Local `.miden/miden-client.toml` in the current working directory + /// 2. Global `.miden/miden-client.toml` in the home directory (fallback) + /// + /// This matches the CLI's configuration priority logic. For most use cases, you should + /// use [`CliClient::from_system_user_config()`](crate::CliClient::from_system_user_config) + /// instead, which uses this method internally. + /// + /// # Returns + /// + /// A configured [`CliConfig`] instance. + /// + /// # Errors + /// + /// Returns a [`CliError`](crate::errors::CliError): + /// - [`CliError::ConfigNotFound`](crate::errors::CliError::ConfigNotFound) if neither local nor + /// global config file exists + /// - [`CliError::Config`](crate::errors::CliError::Config) if configuration file parsing fails + /// + /// Note: If a local config file exists but has parse errors, the error is returned + /// immediately without falling back to global config. + /// + /// # Examples + /// + /// ```no_run + /// use miden_client_cli::config::CliConfig; + /// + /// # fn example() -> Result<(), Box> { + /// // ✅ Recommended: Loads from local .miden dir if it exists, otherwise from global + /// let config = CliConfig::from_system()?; + /// + /// // Or even better, use CliClient directly: + /// // let client = CliClient::from_system_user_config(DebugMode::Disabled).await?; + /// # Ok(()) + /// # } + /// ``` + pub fn from_system() -> Result { + // Try local first + match Self::from_local_dir() { + Ok(config) => Ok(config), + // Only fall back to global if the local config file was not found + // (not for parse errors or other issues) + Err(CliError::ConfigNotFound(_)) => { + // Fall back to global + Self::from_global_dir().map_err(|e| match e { + CliError::ConfigNotFound(_) => CliError::ConfigNotFound( + "Neither local nor global config file exists".to_string(), + ), + other => other, + }) + }, + // For other errors (like parse errors), propagate them immediately + Err(e) => Err(e), + } + } + + /// Loads the client configuration from a TOML file. + fn load_from_file(config_file: &Path) -> Result { + Figment::from(Toml::file(config_file)).extract().map_err(|err| { + CliError::Config("failed to load config file".to_string().into(), err.to_string()) + }) + } + + /// Resolves a relative path against a base directory. + /// If the path is already absolute, it remains unchanged. + fn resolve_relative_path(path: &mut PathBuf, base_dir: &Path) { + if path.is_relative() { + *path = base_dir.join(&*path); + } + } +} + // RPC CONFIG // ================================================================================================ diff --git a/bin/miden-cli/src/errors.rs b/bin/miden-cli/src/errors.rs index e75807cd1..d4bbc8aab 100644 --- a/bin/miden-cli/src/errors.rs +++ b/bin/miden-cli/src/errors.rs @@ -53,6 +53,15 @@ pub enum CliError { ) )] Config(#[source] SourceError, String), + #[error("configuration file not found: {0}")] + #[diagnostic( + code(cli::config_not_found), + help( + "Run `{} init` command to create a configuration file.", + client_binary_name().display() + ) + )] + ConfigNotFound(String), #[error("execute program error: {1}")] #[diagnostic(code(cli::execute_program_error))] Exec(#[source] SourceError, String), diff --git a/bin/miden-cli/src/info.rs b/bin/miden-cli/src/info.rs index 916f50bc8..7fbb2b521 100644 --- a/bin/miden-cli/src/info.rs +++ b/bin/miden-cli/src/info.rs @@ -8,12 +8,11 @@ use miden_client::store::NoteFilter; use super::config::CliConfig; use crate::commands::account::DEFAULT_ACCOUNT_ID_KEY; use crate::errors::CliError; -use crate::load_config_file; pub async fn print_client_info( client: &Client, ) -> Result<(), CliError> { - let (config, _) = load_config_file()?; + let config = CliConfig::from_system()?; println!("Client version: {}", env!("CARGO_PKG_VERSION")); print_config_stats(&config)?; diff --git a/bin/miden-cli/src/lib.rs b/bin/miden-cli/src/lib.rs index edeac016c..2284cd4ff 100644 --- a/bin/miden-cli/src/lib.rs +++ b/bin/miden-cli/src/lib.rs @@ -1,5 +1,6 @@ use std::env; use std::ffi::OsString; +use std::ops::{Deref, DerefMut}; use std::sync::Arc; use clap::{Parser, Subcommand}; @@ -11,8 +12,8 @@ use miden_client::builder::ClientBuilder; use miden_client::keystore::FilesystemKeyStore; use miden_client::note_transport::grpc::GrpcNoteTransportClient; use miden_client::store::{NoteFilter as ClientNoteFilter, OutputNoteRecord}; -use miden_client::{Client, ClientError, DebugMode, IdPrefixFetchError}; use miden_client_sqlite_store::ClientBuilderSqliteExt; + mod commands; use commands::account::AccountCmd; use commands::clear_config::ClearConfigCmd; @@ -27,20 +28,269 @@ use commands::sync::SyncCmd; use commands::tags::TagsCmd; use commands::transactions::TransactionCmd; -use self::utils::{config_file_exists, load_config_file}; +use self::utils::config_file_exists; use crate::commands::address::AddressCmd; -mod config; +pub type CliKeyStore = FilesystemKeyStore; + +/// A Client configured using the CLI's system user configuration. +/// +/// This is a wrapper around `Client` that provides convenient +/// initialization methods while maintaining full compatibility with the +/// underlying Client API through `Deref`. +/// +/// # Examples +/// +/// ```no_run +/// use miden_client_cli::transaction::TransactionRequestBuilder; +/// use miden_client_cli::{CliClient, DebugMode}; +/// +/// # async fn example() -> Result<(), Box> { +/// // Create a CLI-configured client +/// let mut client = CliClient::from_system_user_config(DebugMode::Disabled).await?; +/// +/// // All Client methods work automatically via Deref +/// client.sync_state().await?; +/// +/// // Build and submit transactions +/// let req = TransactionRequestBuilder::new() +/// // ... configure transaction +/// .build()?; +/// +/// // client.submit_new_transaction(req, target_account_id)?; +/// # Ok(()) +/// # } +/// ``` +pub struct CliClient(miden_client::Client); + +impl CliClient { + /// Creates a new `CliClient` instance from an existing `CliConfig`. + /// + /// + /// **⚠️ WARNING: This method bypasses the standard CLI configuration discovery logic and should + /// only be used in specific scenarios such as testing or when you have explicit control + /// requirements.** + /// + /// ## When NOT to use this method + /// + /// - **DO NOT** use this method if you want your application to behave like the CLI tool + /// - **DO NOT** use this for general-purpose client initialization + /// - **DO NOT** use this if you expect automatic local/global config resolution + /// + /// ## When to use this method + /// + /// - **Testing**: When you need to test with a specific configuration + /// - **Explicit Control**: When you must load config from a non-standard location + /// - **Programmatic Config**: When you're constructing configuration programmatically + /// + /// ## Recommended Alternative + /// + /// For standard client initialization that matches CLI behavior, use: + /// ```ignore + /// CliClient::from_system_user_config(debug_mode).await? + /// ``` + /// + /// This method **does not** follow the CLI's configuration priority logic (local → global). + /// Instead, it uses exactly the configuration provided, which may not be what you expect. + /// + /// # Arguments + /// + /// * `config` - The CLI configuration to use (bypasses standard config discovery) + /// * `debug_mode` - The debug mode setting ([`DebugMode::Enabled`] or [`DebugMode::Disabled`]) + /// + /// # Returns + /// + /// A configured [`CliClient`] instance. + /// + /// # Errors + /// + /// Returns a [`CliError`] if: + /// - Keystore initialization fails + /// - Client builder fails to construct the client + /// - Note transport connection fails (if configured) + /// + /// # Examples + /// + /// ```no_run + /// use std::path::PathBuf; + /// + /// use miden_client_cli::{CliClient, CliConfig, DebugMode}; + /// + /// # async fn example() -> Result<(), Box> { + /// // BEWARE: This bypasses standard config discovery! + /// // Only use if you know what you're doing. + /// let config = CliConfig::from_dir(&PathBuf::from("/path/to/.miden"))?; + /// let client = CliClient::from_config(config, DebugMode::Disabled).await?; + /// + /// // Prefer this for standard CLI-like behavior: + /// let client = CliClient::from_system_user_config(DebugMode::Disabled).await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn from_config( + config: CliConfig, + debug_mode: miden_client::DebugMode, + ) -> Result { + // Create keystore + let keystore = + CliKeyStore::new(config.secret_keys_directory.clone()).map_err(CliError::KeyStore)?; + + // Build client with the provided configuration + let mut builder = ClientBuilder::new() + .sqlite_store(config.store_filepath.clone()) + .grpc_client(&config.rpc.endpoint.clone().into(), Some(config.rpc.timeout_ms)) + .authenticator(Arc::new(keystore)) + .in_debug_mode(debug_mode) + .tx_graceful_blocks(Some(TX_GRACEFUL_BLOCK_DELTA)); + + // Add optional max_block_number_delta + if let Some(delta) = config.max_block_number_delta { + builder = builder.max_block_number_delta(delta); + } + + // Add optional note transport client + if let Some(tl_config) = config.note_transport { + let note_transport_client = + GrpcNoteTransportClient::connect(tl_config.endpoint.clone(), tl_config.timeout_ms) + .await + .map_err(|e| CliError::from(miden_client::ClientError::from(e)))?; + builder = builder.note_transport(Arc::new(note_transport_client)); + } + + // Build and return the wrapped client + let client = builder.build().await.map_err(CliError::from)?; + Ok(CliClient(client)) + } + + /// Creates a new `CliClient` instance configured using the system user configuration. + /// + /// This method implements the configuration logic used by the CLI tool, allowing external + /// projects to create a Client instance with the same configuration. It searches for + /// configuration files in the following order: + /// + /// 1. Local `.miden/miden-client.toml` in the current working directory + /// 2. Global `.miden/miden-client.toml` in the home directory + /// + /// The client is initialized with: + /// - `SQLite` store from the configured path + /// - `gRPC` client connection to the configured RPC endpoint + /// - Filesystem-based keystore authenticator + /// - Optional note transport client (if configured) + /// - Transaction graceful blocks delta + /// - Optional max block number delta + /// + /// # Arguments + /// + /// * `debug_mode` - The debug mode setting ([`DebugMode::Enabled`] or [`DebugMode::Disabled`]). + /// + /// # Returns + /// + /// A configured [`CliClient`] instance. + /// + /// # Errors + /// + /// Returns a [`CliError`] if: + /// - No configuration file is found (local or global) + /// - Configuration file parsing fails + /// - Keystore initialization fails + /// - Client builder fails to construct the client + /// - Note transport connection fails (if configured) + /// + /// # Examples + /// + /// ```no_run + /// use miden_client_cli::transaction::TransactionRequestBuilder; + /// use miden_client_cli::{CliClient, DebugMode}; + /// + /// # async fn example() -> Result<(), Box> { + /// // Create a client with default settings (debug disabled) + /// let mut client = CliClient::from_system_user_config(DebugMode::Disabled).await?; + /// + /// // Or with debug mode enabled + /// let mut client = CliClient::from_system_user_config(DebugMode::Enabled).await?; + /// + /// // Use it like a regular Client + /// client.sync_state().await?; + /// + /// // Build and submit transactions + /// let req = TransactionRequestBuilder::new() + /// // ... configure transaction + /// .build()?; + /// + /// // client.submit_new_transaction(req, target_account_id)?; + /// # Ok(()) + /// # } + /// ``` + pub async fn from_system_user_config( + debug_mode: miden_client::DebugMode, + ) -> Result { + // Check if client is not yet initialized => silently initialize the client + if !config_file_exists()? { + let init_cmd = InitCmd::default(); + init_cmd.execute()?; + } + + // Load configuration from system + let config = CliConfig::from_system()?; + + // Create client using the loaded configuration + Self::from_config(config, debug_mode).await + } + + /// Unwraps the `CliClient` to get the inner `Client`. + /// + /// This consumes the `CliClient` and returns the underlying client. + /// + /// # Examples + /// + /// ```no_run + /// use miden_client_cli::{CliClient, DebugMode}; + /// + /// # async fn example() -> Result<(), Box> { + /// let cli_client = CliClient::from_system_user_config(DebugMode::Disabled).await?; + /// let inner_client = cli_client.into_inner(); + /// # Ok(()) + /// # } + /// ``` + pub fn into_inner(self) -> miden_client::Client { + self.0 + } +} + +/// Allows using `CliClient` like `Client` through deref coercion. +/// +/// This enables calling all `Client` methods on `CliClient` directly. +impl Deref for CliClient { + type Target = miden_client::Client; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +/// Allows mutable access to `Client` methods. +impl DerefMut for CliClient { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +pub mod config; +// These modules intentionally shadow the miden_client re-exports - CLI has its own errors/utils +#[allow(hidden_glob_reexports)] mod errors; mod faucet_details_map; mod info; +#[allow(hidden_glob_reexports)] mod utils; /// Re-export `MIDEN_DIR` for use in tests pub use config::MIDEN_DIR; - -/// Config file name. -const CLIENT_CONFIG_FILE_NAME: &str = "miden-client.toml"; +/// Re-export common types for external projects +pub use config::{CLIENT_CONFIG_FILE_NAME, CliConfig}; +pub use errors::CliError as Error; +/// Re-export the entire `miden_client` crate so external projects can use a single dependency. +pub use miden_client::*; /// Client binary name. /// @@ -176,36 +426,22 @@ impl Cli { // Define whether we want to use the executor's debug mode based on the env var and // the flag override let in_debug_mode = match env::var("MIDEN_DEBUG") { - Ok(value) if value.to_lowercase() == "true" => DebugMode::Enabled, - _ => DebugMode::Disabled, + Ok(value) if value.to_lowercase() == "true" => miden_client::DebugMode::Enabled, + _ => miden_client::DebugMode::Disabled, }; - // Create the client - let (cli_config, _config_path) = load_config_file()?; + // Load configuration + let cli_config = CliConfig::from_system()?; - let keystore = FilesystemKeyStore::new(cli_config.secret_keys_directory.clone()) + // Create keystore for commands that need it + let keystore = CliKeyStore::new(cli_config.secret_keys_directory.clone()) .map_err(CliError::KeyStore)?; - let mut builder = ClientBuilder::new() - .sqlite_store(cli_config.store_filepath.clone()) - .grpc_client(&cli_config.rpc.endpoint.clone().into(), Some(cli_config.rpc.timeout_ms)) - .authenticator(Arc::new(keystore.clone())) - .in_debug_mode(in_debug_mode) - .tx_graceful_blocks(Some(TX_GRACEFUL_BLOCK_DELTA)); - - if let Some(delta) = cli_config.max_block_number_delta { - builder = builder.max_block_number_delta(delta); - } - - if let Some(tl_config) = cli_config.note_transport { - let client = - GrpcNoteTransportClient::connect(tl_config.endpoint.clone(), tl_config.timeout_ms) - .await - .map_err(|e| CliError::from(ClientError::from(e)))?; - builder = builder.note_transport(Arc::new(client)); - } + // Create the client + let cli_client = CliClient::from_config(cli_config, in_debug_mode).await?; - let client = builder.build().await?; + // Extract the inner client for command execution + let client = cli_client.into_inner(); // Execute CLI command match &self.action { @@ -256,27 +492,29 @@ pub fn create_dynamic_table(headers: &[&str]) -> Table { /// /// # Errors /// -/// - Returns [`IdPrefixFetchError::NoMatch`] if we were unable to find any note where -/// `note_id_prefix` is a prefix of its ID. -/// - Returns [`IdPrefixFetchError::MultipleMatches`] if there were more than one note found where -/// `note_id_prefix` is a prefix of its ID. +/// - Returns [`IdPrefixFetchError::NoMatch`](miden_client::IdPrefixFetchError::NoMatch) if we were +/// unable to find any note where `note_id_prefix` is a prefix of its ID. +/// - Returns [`IdPrefixFetchError::MultipleMatches`](miden_client::IdPrefixFetchError::MultipleMatches) +/// if there were more than one note found where `note_id_prefix` is a prefix of its ID. pub(crate) async fn get_output_note_with_id_prefix( - client: &Client, + client: &miden_client::Client, note_id_prefix: &str, -) -> Result { +) -> Result { let mut output_note_records = client .get_output_notes(ClientNoteFilter::All) .await .map_err(|err| { tracing::error!("Error when fetching all notes from the store: {err}"); - IdPrefixFetchError::NoMatch(format!("note ID prefix {note_id_prefix}").to_string()) + miden_client::IdPrefixFetchError::NoMatch( + format!("note ID prefix {note_id_prefix}").to_string(), + ) })? .into_iter() .filter(|note_record| note_record.id().to_hex().starts_with(note_id_prefix)) .collect::>(); if output_note_records.is_empty() { - return Err(IdPrefixFetchError::NoMatch( + return Err(miden_client::IdPrefixFetchError::NoMatch( format!("note ID prefix {note_id_prefix}").to_string(), )); } @@ -288,7 +526,7 @@ pub(crate) async fn get_output_note_with_id_prefix( - client: &Client, + client: &miden_client::Client, account_id_prefix: &str, -) -> Result { +) -> Result { let mut accounts = client .get_account_headers() .await .map_err(|err| { tracing::error!("Error when fetching all accounts from the store: {err}"); - IdPrefixFetchError::NoMatch( + miden_client::IdPrefixFetchError::NoMatch( format!("account ID prefix {account_id_prefix}").to_string(), ) })? @@ -325,7 +563,7 @@ async fn get_account_with_id_prefix( .collect::>(); if accounts.is_empty() { - return Err(IdPrefixFetchError::NoMatch( + return Err(miden_client::IdPrefixFetchError::NoMatch( format!("account ID prefix {account_id_prefix}").to_string(), )); } @@ -336,7 +574,7 @@ async fn get_account_with_id_prefix( account_id_prefix, account_ids ); - return Err(IdPrefixFetchError::MultipleMatches( + return Err(miden_client::IdPrefixFetchError::MultipleMatches( format!("account ID prefix {account_id_prefix}").to_string(), )); } diff --git a/bin/miden-cli/src/utils.rs b/bin/miden-cli/src/utils.rs index 94ea3cab4..1f4aa5820 100644 --- a/bin/miden-cli/src/utils.rs +++ b/bin/miden-cli/src/utils.rs @@ -1,7 +1,3 @@ -use std::path::{Path, PathBuf}; - -use figment::Figment; -use figment::providers::{Format, Toml}; use miden_client::Client; use miden_client::account::AccountId; use miden_client::address::{Address, AddressId}; @@ -77,43 +73,6 @@ pub(crate) async fn parse_account_id( } } -/// Loads config file from .miden directory with priority: local miden directory first, then global -/// fallback. -/// -/// This function will look for the configuration file at the .miden/miden-client.toml path in the -/// following order: -/// - Local miden directory in current working directory -/// - Global miden directory in home directory -/// -/// Note: Relative paths in the config are resolved relative to the .miden directory. -pub(super) fn load_config_file() -> Result<(CliConfig, PathBuf), CliError> { - let local_miden_dir = get_local_miden_dir()?; - let mut config_path = local_miden_dir.join(CLIENT_CONFIG_FILE_NAME); - - if !config_path.exists() { - let global_miden_dir = get_global_miden_dir().map_err(|e| { - CliError::Config(Box::new(e), "Failed to determine global config directory".to_string()) - })?; - config_path = global_miden_dir.join(CLIENT_CONFIG_FILE_NAME); - - if !config_path.exists() { - return Err(CliError::Config( - "No configuration file found".to_string().into(), - "Neither local nor global config file exists. Run 'miden-client init' to create one.".to_string() - )); - } - } - let mut cli_config = load_config(config_path.as_path())?; - let config_dir = config_path.parent().unwrap(); - - resolve_relative_path(&mut cli_config.store_filepath, config_dir); - resolve_relative_path(&mut cli_config.secret_keys_directory, config_dir); - resolve_relative_path(&mut cli_config.token_symbol_map_filepath, config_dir); - resolve_relative_path(&mut cli_config.package_directory, config_dir); - - Ok((cli_config, config_path)) -} - /// Checks if either local or global configuration file exists. pub(super) fn config_file_exists() -> Result { let local_miden_dir = get_local_miden_dir()?; @@ -128,23 +87,8 @@ pub(super) fn config_file_exists() -> Result { Ok(global_miden_dir.join(CLIENT_CONFIG_FILE_NAME).exists()) } -/// Loads the client configuration. -fn load_config(config_file: &Path) -> Result { - Figment::from(Toml::file(config_file)).extract().map_err(|err| { - CliError::Config("failed to load config file".to_string().into(), err.to_string()) - }) -} - /// Returns the faucet details map using the config file. pub fn load_faucet_details_map() -> Result { - let (config, _) = load_config_file()?; + let config = CliConfig::from_system()?; FaucetDetailsMap::new(config.token_symbol_map_filepath) } - -/// Resolves a relative path against a base directory. -/// If the path is already absolute, it remains unchanged. -fn resolve_relative_path(path: &mut PathBuf, base_dir: &Path) { - if path.is_relative() { - *path = base_dir.join(&*path); - } -} diff --git a/bin/miden-cli/tests/cli.rs b/bin/miden-cli/tests/cli.rs index ad0f3d948..70db7c102 100644 --- a/bin/miden-cli/tests/cli.rs +++ b/bin/miden-cli/tests/cli.rs @@ -37,6 +37,7 @@ use miden_client::utils::Serializable; use miden_client::{ self, Client, + DebugMode, ExecutionOptions, Felt, MAX_TX_EXECUTION_CYCLES, @@ -1311,3 +1312,154 @@ fn create_account_with_ecdsa_auth() { create_account_cmd.current_dir(&temp_dir).assert().success(); } + +// FROM_SYSTEM_USER_CONFIG TESTS +// ================================================================================================ +/// Tests that `CliClient::from_system_user_config()` successfully creates a client with the same +/// configuration as the CLI tool when a local config exists. +#[tokio::test] +#[serial_test::serial(global_config)] +async fn test_from_system_user_config_with_local_config() -> Result<()> { + // Initialize a local CLI configuration + let (store_path, temp_dir, _endpoint) = init_cli(); + + // Ensure no global config exists to verify local config takes priority + cleanup_global_config(); + + // Change to the temp directory where local .miden config exists + let original_dir = env::current_dir().unwrap(); + env::set_current_dir(&temp_dir)?; + + // Create a client using from_system_user_config - should pick up local config + let client_result = + miden_client_cli::CliClient::from_system_user_config(DebugMode::Disabled).await; + + // Restore original directory + env::set_current_dir(original_dir)?; + + // Assert the client was created successfully + assert!( + client_result.is_ok(), + "Failed to create client from local config: {:?}", + client_result.err() + ); + + // Verify that the local config was actually used by checking which store file was created. + // The local store should exist, indicating the local config was used. + assert!( + store_path.exists(), + "Local store file should exist at {store_path:?}, indicating local config was used" + ); + + Ok(()) +} + +/// Tests that `CliClient::from_system_user_config()` silently initializes with default config +/// when no configuration exists. +#[tokio::test] +#[serial_test::serial(global_config)] +async fn test_from_system_user_config_silent_init() -> Result<()> { + // Create a temporary directory with no .miden configuration + let temp_dir = temp_dir().join(format!("cli-test-silent-init-{}", rand::rng().random::())); + std::fs::create_dir_all(&temp_dir)?; + + // Ensure no global config exists + cleanup_global_config(); + + // Verify no config exists before we start + let global_miden_dir = dirs::home_dir().unwrap().join(MIDEN_DIR); + let global_config_path = global_miden_dir.join("miden-client.toml"); + assert!(!global_config_path.exists(), "Global config should not exist before test"); + + // Change to the temp directory + let original_dir = env::current_dir().unwrap(); + env::set_current_dir(&temp_dir)?; + + // Create a client - should succeed via silent initialization + let client_result = + miden_client_cli::CliClient::from_system_user_config(DebugMode::Disabled).await; + + // Restore original directory + env::set_current_dir(original_dir)?; + + // Assert the client was created successfully + assert!( + client_result.is_ok(), + "Expected client to be created via silent initialization, but got error: {:?}", + client_result.err() + ); + + // Verify that a global config was created by the silent initialization + assert!( + global_config_path.exists(), + "Expected global config to be created at {global_config_path:?} by silent initialization" + ); + + // Clean up temp directory and global config + let _ = std::fs::remove_dir_all(&temp_dir); + cleanup_global_config(); + + Ok(()) +} + +/// Tests that `CliConfig::from_system()` prioritizes local config over global config. +#[tokio::test] +#[serial_test::serial(global_config)] +async fn test_from_system_user_config_local_priority() -> Result<()> { + // Clean up any existing global config + cleanup_global_config(); + + // Create a global config with testnet endpoint + let global_store_path = create_test_store_path(); + let global_endpoint = Endpoint::testnet(); + + let temp_dir_for_global = + temp_dir().join(format!("cli-test-global-init-{}", rand::rng().random::())); + std::fs::create_dir_all(&temp_dir_for_global)?; + + let mut init_global_cmd = cargo_bin_cmd!("miden-client"); + init_global_cmd.args([ + "init", + "--network", + global_endpoint.to_string().as_str(), + "--store-path", + global_store_path.to_str().unwrap(), + ]); + init_global_cmd.current_dir(&temp_dir_for_global).assert().success(); + + // Create a local config with localhost endpoint + let local_store_path = create_test_store_path(); + let local_endpoint = Endpoint::localhost(); + let local_temp_dir = init_cli_with_store_path(&local_store_path, &local_endpoint); + + // Load config from the specific local directory (no need to change working directory!) + let local_miden_dir = local_temp_dir.join(MIDEN_DIR); + let config = miden_client_cli::CliConfig::from_dir(&local_miden_dir)?; + + // Create client with local config + let client = miden_client_cli::CliClient::from_config(config, DebugMode::Disabled).await; + + // Clean up + let _ = std::fs::remove_dir_all(&temp_dir_for_global); + let _ = std::fs::remove_dir_all(&local_temp_dir); + cleanup_global_config(); + + // Assert client was created with local config + assert!(client.is_ok(), "Failed to create client with local config: {:?}", client.err()); + + // Verify that the local config was actually used by checking which store file was created + + // The local store should exist + assert!( + local_store_path.exists(), + "Local store file should exist at {local_store_path:?}, indicating local config was used" + ); + + // The global store should NOT exist + assert!( + !global_store_path.exists(), + "Global store file should NOT exist at {global_store_path:?}, as global config should not have been used" + ); + + Ok(()) +}