1#![deny(clippy::pedantic)]
2#![allow(clippy::doc_markdown)]
3#![allow(clippy::missing_errors_doc)]
4#![allow(clippy::missing_panics_doc)]
5#![allow(clippy::module_name_repetitions)]
6#![allow(clippy::must_use_candidate)]
7#![allow(clippy::ref_option)]
8#![allow(clippy::return_self_not_must_use)]
9#![allow(clippy::too_many_lines)]
10#![allow(clippy::large_futures)]
11
12mod cli;
13mod client;
14mod db;
15pub mod envs;
16mod utils;
17
18use core::fmt;
19use std::collections::BTreeMap;
20use std::fmt::Debug;
21use std::io::{IsTerminal, Read, Write};
22use std::path::{Path, PathBuf};
23use std::process::exit;
24use std::str::FromStr;
25use std::sync::Arc;
26use std::time::Duration;
27use std::{fs, result};
28
29use anyhow::{Context, format_err};
30use clap::{CommandFactory, Parser};
31use cli::{
32 AdminCmd, Command, DatabaseBackend, DecodeType, DevCmd, EncodeType, OOBNotesJson, Opts,
33 SetupAdminArgs, SetupAdminCmd,
34};
35use envs::SALT_FILE;
36use fedimint_aead::{encrypted_read, encrypted_write, get_encryption_key};
37use fedimint_api_client::api::{DynGlobalApi, FederationApiExt, FederationError};
38use fedimint_bip39::{Bip39RootSecretStrategy, Mnemonic};
39use fedimint_client::db::ApiSecretKey;
40use fedimint_client::module::meta::{FetchKind, LegacyMetaSource, MetaSource};
41use fedimint_client::module::module::init::ClientModuleInit;
42use fedimint_client::module_init::ClientModuleInitRegistry;
43use fedimint_client::secret::RootSecretStrategy;
44use fedimint_client::{AdminCreds, Client, ClientBuilder, ClientHandleArc, RootSecret};
45use fedimint_connectors::ConnectorRegistry;
46use fedimint_core::base32::FEDIMINT_PREFIX;
47use fedimint_core::config::{FederationId, FederationIdPrefix};
48use fedimint_core::core::ModuleInstanceId;
49use fedimint_core::db::{Database, DatabaseValue, IDatabaseTransactionOpsCoreTyped as _};
50use fedimint_core::encoding::Decodable;
51use fedimint_core::invite_code::InviteCode;
52use fedimint_core::module::registry::ModuleRegistry;
53use fedimint_core::module::{ApiAuth, ApiRequestErased};
54use fedimint_core::setup_code::PeerSetupCode;
55use fedimint_core::transaction::Transaction;
56use fedimint_core::util::{SafeUrl, backoff_util, handle_version_hash_command, retry};
57use fedimint_core::{PeerId, base32, fedimint_build_code_version_env, runtime};
58use fedimint_derive_secret::DerivableSecret;
59use fedimint_eventlog::EventLogTrimableId;
60use fedimint_ln_client::LightningClientInit;
61use fedimint_logging::{LOG_CLIENT, TracingSetup};
62use fedimint_meta_client::{MetaClientInit, MetaModuleMetaSourceWithFallback};
63use fedimint_mint_client::{MintClientInit, MintClientModule, OOBNotes};
64use fedimint_wallet_client::api::WalletFederationApi;
65use fedimint_wallet_client::{WalletClientInit, WalletClientModule};
66use futures::future::pending;
67use itertools::Itertools;
68use rand::thread_rng;
69use serde::Serialize;
70use serde_json::{Value, json};
71use thiserror::Error;
72use tracing::{debug, info, warn};
73
74use crate::client::ClientCmd;
75use crate::db::{StoredAdminCreds, load_admin_creds, store_admin_creds};
76
77#[derive(Serialize)]
79#[serde(rename_all = "snake_case")]
80#[serde(untagged)]
81enum CliOutput {
82 VersionHash {
83 hash: String,
84 },
85
86 UntypedApiOutput {
87 value: Value,
88 },
89
90 WaitBlockCount {
91 reached: u64,
92 },
93
94 InviteCode {
95 invite_code: InviteCode,
96 },
97
98 DecodeInviteCode {
99 url: SafeUrl,
100 federation_id: FederationId,
101 },
102
103 Join {
104 joined: String,
105 },
106
107 DecodeTransaction {
108 transaction: String,
109 },
110
111 EpochCount {
112 count: u64,
113 },
114
115 ConfigDecrypt,
116
117 ConfigEncrypt,
118
119 SetupCode {
120 setup_code: PeerSetupCode,
121 },
122
123 Raw(serde_json::Value),
124}
125
126impl fmt::Display for CliOutput {
127 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
128 write!(f, "{}", serde_json::to_string_pretty(self).unwrap())
129 }
130}
131
132type CliResult<E> = Result<E, CliError>;
134
135type CliOutputResult = Result<CliOutput, CliError>;
137
138#[derive(Serialize, Error)]
140#[serde(tag = "error", rename_all(serialize = "snake_case"))]
141struct CliError {
142 error: String,
143}
144
145trait CliResultExt<O, E> {
148 fn map_err_cli(self) -> Result<O, CliError>;
150 fn map_err_cli_msg(self, msg: impl fmt::Display + Send + Sync + 'static)
152 -> Result<O, CliError>;
153}
154
155impl<O, E> CliResultExt<O, E> for result::Result<O, E>
156where
157 E: Into<anyhow::Error>,
158{
159 fn map_err_cli(self) -> Result<O, CliError> {
160 self.map_err(|e| {
161 let e = e.into();
162 CliError {
163 error: format!("{e:#}"),
164 }
165 })
166 }
167
168 fn map_err_cli_msg(
169 self,
170 msg: impl fmt::Display + Send + Sync + 'static,
171 ) -> Result<O, CliError> {
172 self.map_err(|e| Into::<anyhow::Error>::into(e))
173 .context(msg)
174 .map_err(|e| CliError {
175 error: format!("{e:#}"),
176 })
177 }
178}
179
180trait CliOptionExt<O> {
183 fn ok_or_cli_msg(self, msg: impl Into<String>) -> Result<O, CliError>;
184}
185
186impl<O> CliOptionExt<O> for Option<O> {
187 fn ok_or_cli_msg(self, msg: impl Into<String>) -> Result<O, CliError> {
188 self.ok_or_else(|| CliError { error: msg.into() })
189 }
190}
191
192impl From<FederationError> for CliError {
194 fn from(e: FederationError) -> Self {
195 CliError {
196 error: e.to_string(),
197 }
198 }
199}
200
201impl Debug for CliError {
202 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
203 f.debug_struct("CliError")
204 .field("error", &self.error)
205 .finish()
206 }
207}
208
209impl fmt::Display for CliError {
210 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
211 let json = serde_json::to_value(self).expect("CliError is valid json");
212 let json_as_string =
213 serde_json::to_string_pretty(&json).expect("valid json is serializable");
214 write!(f, "{json_as_string}")
215 }
216}
217
218impl Opts {
219 fn data_dir(&self) -> CliResult<&PathBuf> {
220 self.data_dir
221 .as_ref()
222 .ok_or_cli_msg("`--data-dir=` argument not set.")
223 }
224
225 async fn data_dir_create(&self) -> CliResult<&PathBuf> {
227 let dir = self.data_dir()?;
228
229 tokio::fs::create_dir_all(&dir).await.map_err_cli()?;
230
231 Ok(dir)
232 }
233 fn iroh_enable_dht(&self) -> bool {
234 self.iroh_enable_dht.unwrap_or(true)
235 }
236
237 fn iroh_enable_next(&self) -> bool {
238 self.iroh_enable_next.unwrap_or(true)
239 }
240
241 fn use_tor(&self) -> bool {
242 #[cfg(feature = "tor")]
243 return self.use_tor;
244 #[cfg(not(feature = "tor"))]
245 false
246 }
247
248 async fn admin_client(
249 &self,
250 peer_urls: &BTreeMap<PeerId, SafeUrl>,
251 api_secret: Option<&str>,
252 ) -> CliResult<DynGlobalApi> {
253 self.admin_client_with_db(peer_urls, api_secret, None).await
254 }
255
256 async fn admin_client_with_db(
257 &self,
258 peer_urls: &BTreeMap<PeerId, SafeUrl>,
259 api_secret: Option<&str>,
260 db: Option<&Database>,
261 ) -> CliResult<DynGlobalApi> {
262 let our_id = if let Some(id) = self.our_id {
264 id
265 } else if let Some(db) = db {
266 if let Some(stored_creds) = load_admin_creds(db).await {
267 stored_creds.peer_id
268 } else {
269 return Err(CliError {
270 error: "Admin client needs our-id set (no stored credentials found)"
271 .to_string(),
272 });
273 }
274 } else {
275 return Err(CliError {
276 error: "Admin client needs our-id set".to_string(),
277 });
278 };
279
280 DynGlobalApi::new_admin(
281 self.make_endpoints().await.map_err(|e| CliError {
282 error: e.to_string(),
283 })?,
284 our_id,
285 peer_urls
286 .get(&our_id)
287 .cloned()
288 .context("Our peer URL not found in config")
289 .map_err_cli()?,
290 api_secret,
291 )
292 .map_err_cli()
293 }
294
295 async fn make_endpoints(&self) -> Result<ConnectorRegistry, anyhow::Error> {
296 ConnectorRegistry::build_from_client_defaults()
297 .iroh_next(self.iroh_enable_next())
298 .iroh_pkarr_dht(self.iroh_enable_dht())
299 .ws_force_tor(self.use_tor())
300 .bind()
301 .await
302 }
303
304 fn auth(&self) -> CliResult<ApiAuth> {
305 let password = self
306 .password
307 .clone()
308 .ok_or_cli_msg("CLI needs password set")?;
309 Ok(ApiAuth::new(password))
310 }
311
312 async fn load_database(&self) -> CliResult<Database> {
313 debug!(target: LOG_CLIENT, "Loading client database");
314 let db_path = self.data_dir_create().await?.join("client.db");
315 match self.db_backend {
316 DatabaseBackend::RocksDb => {
317 debug!(target: LOG_CLIENT, "Using RocksDB database backend");
318 Ok(fedimint_rocksdb::RocksDb::build(db_path)
319 .open()
320 .await
321 .map_err_cli_msg("could not open rocksdb database")?
322 .into())
323 }
324 DatabaseBackend::CursedRedb => {
325 debug!(target: LOG_CLIENT, "Using CursedRedb database backend");
326 Ok(fedimint_cursed_redb::MemAndRedb::new(db_path)
327 .await
328 .map_err_cli_msg("could not open cursed redb database")?
329 .into())
330 }
331 }
332 }
333}
334
335fn decode_federation_secret_hex(federation_secret_hex: &str) -> CliResult<DerivableSecret> {
336 <DerivableSecret as Decodable>::consensus_decode_hex(
337 federation_secret_hex,
338 &ModuleRegistry::default(),
339 )
340 .map_err_cli_msg("invalid federation secret hex")
341}
342
343enum RecoverySecret {
344 Mnemonic(Mnemonic),
345 FederationSecret(DerivableSecret),
346}
347
348fn root_secret_from_mnemonic(mnemonic: &Mnemonic) -> RootSecret {
349 RootSecret::StandardDoubleDerive(Bip39RootSecretStrategy::<12>::to_root_secret(mnemonic))
350}
351
352async fn load_or_generate_mnemonic(db: &Database) -> Result<Mnemonic, CliError> {
353 Ok(
354 if let Ok(entropy) = Client::load_decodable_client_secret::<Vec<u8>>(db).await {
355 Mnemonic::from_entropy(&entropy).map_err_cli()?
356 } else {
357 debug!(
358 target: LOG_CLIENT,
359 "Generating mnemonic and writing entropy to client storage"
360 );
361 let mnemonic = Bip39RootSecretStrategy::<12>::random(&mut thread_rng());
362 Client::store_encodable_client_secret(db, mnemonic.to_entropy())
363 .await
364 .map_err_cli()?;
365 mnemonic
366 },
367 )
368}
369
370pub struct FedimintCli {
371 module_inits: ClientModuleInitRegistry,
372 cli_args: Opts,
373}
374
375impl FedimintCli {
376 pub fn new(version_hash: &str) -> anyhow::Result<FedimintCli> {
378 assert_eq!(
379 fedimint_build_code_version_env!().len(),
380 version_hash.len(),
381 "version_hash must have an expected length"
382 );
383
384 handle_version_hash_command(version_hash);
385
386 let cli_args = Opts::parse();
387 let base_level = if cli_args.verbose { "debug" } else { "info" };
388 TracingSetup::default()
389 .with_base_level(base_level)
390 .init()
391 .expect("tracing initializes");
392
393 let version = env!("CARGO_PKG_VERSION");
394 debug!(target: LOG_CLIENT, "Starting fedimint-cli (version: {version} version_hash: {version_hash})");
395
396 Ok(Self {
397 module_inits: ClientModuleInitRegistry::new(),
398 cli_args,
399 })
400 }
401
402 pub fn with_module<T>(mut self, r#gen: T) -> Self
403 where
404 T: ClientModuleInit + 'static + Send + Sync,
405 {
406 self.module_inits.attach(r#gen);
407 self
408 }
409
410 pub fn with_default_modules(self) -> Self {
411 self.with_module(LightningClientInit::default())
412 .with_module(MintClientInit)
413 .with_module(fedimint_mintv2_client::MintClientInit)
414 .with_module(WalletClientInit::default())
415 .with_module(MetaClientInit)
416 .with_module(fedimint_lnv2_client::LightningClientInit::default())
417 .with_module(fedimint_walletv2_client::WalletClientInit)
418 }
419
420 pub async fn run(&mut self) {
421 match self.handle_command(self.cli_args.clone()).await {
422 Ok(output) => {
423 let _ = writeln!(std::io::stdout(), "{output}");
425 }
426 Err(err) => {
427 debug!(target: LOG_CLIENT, err = %err.error.as_str(), "Command failed");
428 let _ = writeln!(std::io::stdout(), "{err}");
429 exit(1);
430 }
431 }
432 }
433
434 async fn make_client_builder(&self, cli: &Opts) -> CliResult<(ClientBuilder, Database)> {
435 let mut client_builder = Client::builder()
436 .await
437 .map_err_cli()?
438 .with_iroh_enable_dht(cli.iroh_enable_dht())
439 .with_iroh_enable_next(cli.iroh_enable_next());
440 client_builder.with_module_inits(self.module_inits.clone());
441
442 let db = cli.load_database().await?;
443 Ok((client_builder, db))
444 }
445
446 async fn client_join(
447 &mut self,
448 cli: &Opts,
449 invite_code: InviteCode,
450 ) -> CliResult<ClientHandleArc> {
451 let (client_builder, db) = self.make_client_builder(cli).await?;
452
453 let mnemonic = load_or_generate_mnemonic(&db).await?;
454
455 let client = client_builder
456 .preview(cli.make_endpoints().await.map_err_cli()?, &invite_code)
457 .await
458 .map_err_cli()?
459 .join(db, root_secret_from_mnemonic(&mnemonic))
460 .await
461 .map(Arc::new)
462 .map_err_cli()?;
463
464 print_welcome_message(&client).await;
465 log_expiration_notice(&client).await;
466
467 Ok(client)
468 }
469
470 async fn client_open(&self, cli: &Opts) -> CliResult<ClientHandleArc> {
471 let (mut client_builder, db) = self.make_client_builder(cli).await?;
472
473 if let Some(our_id) = cli.our_id {
475 client_builder.set_admin_creds(AdminCreds {
476 peer_id: our_id,
477 auth: cli.auth()?,
478 });
479 } else if let Some(stored_creds) = load_admin_creds(&db).await {
480 debug!(target: LOG_CLIENT, "Using stored admin credentials");
481 client_builder.set_admin_creds(AdminCreds {
482 peer_id: stored_creds.peer_id,
483 auth: ApiAuth::new(stored_creds.auth),
484 });
485 }
486
487 let existing_mnemonic = Client::load_decodable_client_secret_opt::<Vec<u8>>(&db)
488 .await
489 .map_err_cli()?;
490
491 let root_secret = match (cli.federation_secret_hex.as_deref(), existing_mnemonic) {
492 (Some(_), Some(_)) => {
493 return Err(CliError {
494 error: "client secret is already set in DB; --federation-secret-hex open requires a client DB without any stored secret".to_owned(),
495 });
496 }
497 (Some(federation_secret_hex), None) => {
498 RootSecret::Custom(decode_federation_secret_hex(federation_secret_hex)?)
499 }
500 (None, Some(entropy)) => {
501 let mnemonic = Mnemonic::from_entropy(&entropy).map_err_cli()?;
502 root_secret_from_mnemonic(&mnemonic)
503 }
504 (None, None) => {
505 return Err(CliError {
506 error: "Encoded client secret not present in DB".to_owned(),
507 });
508 }
509 };
510
511 let client = client_builder
512 .open(cli.make_endpoints().await.map_err_cli()?, db, root_secret)
513 .await
514 .map(Arc::new)
515 .map_err_cli()?;
516
517 log_expiration_notice(&client).await;
518
519 Ok(client)
520 }
521
522 async fn client_recover(
523 &mut self,
524 cli: &Opts,
525 recovery_secret: RecoverySecret,
526 invite_code: InviteCode,
527 ) -> CliResult<ClientHandleArc> {
528 let (builder, db) = self.make_client_builder(cli).await?;
529 let existing_mnemonic = Client::load_decodable_client_secret_opt::<Vec<u8>>(&db)
530 .await
531 .map_err_cli()?;
532
533 let root_secret = match (recovery_secret, existing_mnemonic) {
534 (RecoverySecret::Mnemonic(mnemonic), Some(existing)) => {
535 if existing != mnemonic.to_entropy() {
536 Err(anyhow::anyhow!("Previously set mnemonic does not match")).map_err_cli()?;
537 }
538
539 root_secret_from_mnemonic(&mnemonic)
540 }
541 (RecoverySecret::Mnemonic(mnemonic), None) => {
542 Client::store_encodable_client_secret(&db, mnemonic.to_entropy())
543 .await
544 .map_err_cli()?;
545 root_secret_from_mnemonic(&mnemonic)
546 }
547 (RecoverySecret::FederationSecret(federation_secret), None) => {
548 RootSecret::Custom(federation_secret)
549 }
550 (RecoverySecret::FederationSecret(_), Some(_)) => {
551 return Err(CliError {
552 error: "client secret is already set in DB; --federation-secret-hex restore requires a client DB without any stored secret".to_owned(),
553 });
554 }
555 };
556
557 let preview = builder
558 .preview(cli.make_endpoints().await.map_err_cli()?, &invite_code)
559 .await
560 .map_err_cli()?;
561
562 #[allow(deprecated)]
563 let backup = preview
564 .download_backup_from_federation(root_secret.clone())
565 .await
566 .map_err_cli()?;
567
568 let client = preview
569 .recover(db, root_secret, backup)
570 .await
571 .map(Arc::new)
572 .map_err_cli()?;
573
574 print_welcome_message(&client).await;
575 log_expiration_notice(&client).await;
576
577 Ok(client)
578 }
579
580 async fn handle_command(&mut self, cli: Opts) -> CliOutputResult {
581 if cli.federation_secret_hex.is_some() && matches!(&cli.command, Command::Join { .. }) {
582 return Err(CliError {
583 error: "--federation-secret-hex cannot be used with join".to_owned(),
584 });
585 }
586
587 match cli.command.clone() {
588 Command::InviteCode { peer } => {
589 let client = self.client_open(&cli).await?;
590
591 let invite_code = client
592 .invite_code(peer)
593 .await
594 .ok_or_cli_msg("peer not found")?;
595
596 Ok(CliOutput::InviteCode { invite_code })
597 }
598 Command::Join { invite_code } => {
599 {
600 let invite_code: InviteCode = InviteCode::from_str(&invite_code)
601 .map_err_cli_msg("invalid invite code")?;
602
603 let _client = self.client_join(&cli, invite_code).await?;
605 }
606
607 Ok(CliOutput::Join {
608 joined: invite_code,
609 })
610 }
611 Command::VersionHash => Ok(CliOutput::VersionHash {
612 hash: fedimint_build_code_version_env!().to_string(),
613 }),
614 Command::Client(ClientCmd::Restore {
615 mnemonic,
616 invite_code,
617 }) => {
618 let invite_code: InviteCode =
619 InviteCode::from_str(&invite_code).map_err_cli_msg("invalid invite code")?;
620 let recovery_secret = match (
621 mnemonic.as_deref(),
622 cli.federation_secret_hex.as_deref(),
623 ) {
624 (Some(_), Some(_)) => {
625 return Err(CliError {
626 error: "restore accepts either --mnemonic or --federation-secret-hex, not both".to_owned(),
627 });
628 }
629 (Some(mnemonic), None) => {
630 let mnemonic = Mnemonic::from_str(mnemonic).map_err_cli()?;
631 RecoverySecret::Mnemonic(mnemonic)
632 }
633 (None, Some(federation_secret_hex)) => {
634 let federation_secret =
635 decode_federation_secret_hex(federation_secret_hex)?;
636 RecoverySecret::FederationSecret(federation_secret)
637 }
638 (None, None) => {
639 return Err(CliError {
640 error: "restore requires either --mnemonic or --federation-secret-hex"
641 .to_owned(),
642 });
643 }
644 };
645 let client = self
646 .client_recover(&cli, recovery_secret, invite_code)
647 .await?;
648
649 debug!(target: LOG_CLIENT, "Waiting for mint module recovery to finish");
652 client.wait_for_all_recoveries().await.map_err_cli()?;
653
654 debug!(target: LOG_CLIENT, "Recovery complete");
655
656 Ok(CliOutput::Raw(serde_json::to_value(()).unwrap()))
657 }
658 Command::Client(command) => {
659 let client = self.client_open(&cli).await?;
660 Ok(CliOutput::Raw(
661 client::handle_command(command, client)
662 .await
663 .map_err_cli()?,
664 ))
665 }
666 Command::Admin(AdminCmd::Auth {
667 peer_id,
668 password,
669 no_verify,
670 force,
671 }) => {
672 let db = cli.load_database().await?;
673 let peer_id = PeerId::from(peer_id);
674 let auth = ApiAuth::new(password);
675
676 if !force {
678 let existing = load_admin_creds(&db).await;
679 if existing.is_some() {
680 return Err(CliError {
681 error: "Admin credentials already stored. Use --force to overwrite."
682 .to_string(),
683 });
684 }
685 }
686
687 let config = Client::get_config_from_db(&db)
689 .await
690 .ok_or_cli_msg("Client not initialized. Please join a federation first.")?;
691
692 let peer_url =
694 config
695 .global
696 .api_endpoints
697 .get(&peer_id)
698 .ok_or_else(|| CliError {
699 error: format!(
700 "Peer ID {} not found in federation. Valid peer IDs are: {:?}",
701 peer_id,
702 config.global.api_endpoints.keys().collect::<Vec<_>>()
703 ),
704 })?;
705
706 if !no_verify {
708 if !std::io::stdin().is_terminal() {
710 return Err(CliError {
711 error: "Interactive verification requires a terminal. Use --no-verify to skip.".to_string(),
712 });
713 }
714
715 eprintln!("Guardian endpoint for peer {}: {}", peer_id, peer_url.url);
716 eprint!("Does this look correct? (y/N): ");
717 std::io::stderr().flush().map_err_cli()?;
718
719 let mut input = String::new();
720 std::io::stdin().read_line(&mut input).map_err_cli()?;
721 let input = input.trim().to_lowercase();
722
723 if input != "y" && input != "yes" {
724 return Err(CliError {
725 error: "Endpoint verification cancelled by user.".to_string(),
726 });
727 }
728 }
729
730 eprintln!("Verifying credentials...");
732 let admin_api = DynGlobalApi::new_admin(
733 cli.make_endpoints().await.map_err_cli()?,
734 peer_id,
735 peer_url.url.clone(),
736 db.begin_transaction_nc()
737 .await
738 .get_value(&ApiSecretKey)
739 .await
740 .as_deref(),
741 )
742 .map_err_cli()?;
743
744 admin_api.auth(auth.clone()).await.map_err(|e| CliError {
746 error: format!(
747 "Failed to verify credentials: {e}. Please check your peer ID and password."
748 ),
749 })?;
750
751 store_admin_creds(
753 &db,
754 &StoredAdminCreds {
755 peer_id,
756 auth: auth.as_str().to_string(),
757 },
758 )
759 .await;
760
761 eprintln!("Admin credentials verified and saved successfully.");
762 Ok(CliOutput::Raw(json!({
763 "peer_id": peer_id,
764 "endpoint": peer_url.url.to_string(),
765 "status": "saved"
766 })))
767 }
768 Command::Admin(AdminCmd::Audit) => {
769 let client = self.client_open(&cli).await?;
770
771 let audit = cli
772 .admin_client(
773 &client.get_peer_urls().await,
774 client.api_secret().as_deref(),
775 )
776 .await?
777 .audit(cli.auth()?)
778 .await?;
779 Ok(CliOutput::Raw(
780 serde_json::to_value(audit).map_err_cli_msg("invalid response")?,
781 ))
782 }
783 Command::Admin(AdminCmd::Status) => {
784 let client = self.client_open(&cli).await?;
785
786 let status = cli
787 .admin_client_with_db(
788 &client.get_peer_urls().await,
789 client.api_secret().as_deref(),
790 Some(client.db()),
791 )
792 .await?
793 .status()
794 .await?;
795 Ok(CliOutput::Raw(
796 serde_json::to_value(status).map_err_cli_msg("invalid response")?,
797 ))
798 }
799 Command::Admin(AdminCmd::GuardianConfigBackup) => {
800 let client = self.client_open(&cli).await?;
801
802 let guardian_config_backup = cli
803 .admin_client(
804 &client.get_peer_urls().await,
805 client.api_secret().as_deref(),
806 )
807 .await?
808 .guardian_config_backup(cli.auth()?)
809 .await?;
810 Ok(CliOutput::Raw(
811 serde_json::to_value(guardian_config_backup)
812 .map_err_cli_msg("invalid response")?,
813 ))
814 }
815 Command::Admin(AdminCmd::Setup(dkg_args)) => self
816 .handle_admin_setup_command(cli, dkg_args)
817 .await
818 .map(CliOutput::Raw)
819 .map_err_cli_msg("Config Gen Error"),
820 Command::Admin(AdminCmd::SignApiAnnouncement {
821 api_url,
822 override_url,
823 }) => {
824 let client = self.client_open(&cli).await?;
825
826 if !["ws", "wss"].contains(&api_url.scheme()) {
827 return Err(CliError {
828 error: format!(
829 "Unsupported URL scheme {}, use ws:// or wss://",
830 api_url.scheme()
831 ),
832 });
833 }
834
835 let announcement = cli
836 .admin_client(
837 &override_url
838 .and_then(|url| Some(vec![(cli.our_id?, url)].into_iter().collect()))
839 .unwrap_or(client.get_peer_urls().await),
840 client.api_secret().as_deref(),
841 )
842 .await?
843 .sign_api_announcement(api_url, cli.auth()?)
844 .await?;
845
846 Ok(CliOutput::Raw(
847 serde_json::to_value(announcement).map_err_cli_msg("invalid response")?,
848 ))
849 }
850 Command::Admin(AdminCmd::SignGuardianMetadata { api_urls, pkarr_id }) => {
851 let client = self.client_open(&cli).await?;
852
853 let metadata = fedimint_core::net::guardian_metadata::GuardianMetadata {
854 api_urls,
855 pkarr_id_z32: pkarr_id,
856 timestamp_secs: fedimint_core::time::duration_since_epoch().as_secs(),
857 };
858
859 let signed_metadata = cli
860 .admin_client(
861 &client.get_peer_urls().await,
862 client.api_secret().as_deref(),
863 )
864 .await?
865 .sign_guardian_metadata(metadata, cli.auth()?)
866 .await?;
867
868 Ok(CliOutput::Raw(
869 serde_json::to_value(signed_metadata).map_err_cli_msg("invalid response")?,
870 ))
871 }
872 Command::Admin(AdminCmd::Shutdown { session_idx }) => {
873 let client = self.client_open(&cli).await?;
874
875 cli.admin_client(
876 &client.get_peer_urls().await,
877 client.api_secret().as_deref(),
878 )
879 .await?
880 .shutdown(Some(session_idx), cli.auth()?)
881 .await?;
882
883 Ok(CliOutput::Raw(json!(null)))
884 }
885 Command::Admin(AdminCmd::BackupStatistics) => {
886 let client = self.client_open(&cli).await?;
887
888 let backup_statistics = cli
889 .admin_client(
890 &client.get_peer_urls().await,
891 client.api_secret().as_deref(),
892 )
893 .await?
894 .backup_statistics(cli.auth()?)
895 .await?;
896
897 Ok(CliOutput::Raw(
898 serde_json::to_value(backup_statistics).expect("Can be encoded"),
899 ))
900 }
901 Command::Admin(AdminCmd::ChangePassword { new_password }) => {
902 let client = self.client_open(&cli).await?;
903
904 cli.admin_client(
905 &client.get_peer_urls().await,
906 client.api_secret().as_deref(),
907 )
908 .await?
909 .change_password(cli.auth()?, &new_password)
910 .await?;
911
912 warn!(target: LOG_CLIENT, "Password changed, please restart fedimintd manually");
913
914 Ok(CliOutput::Raw(json!(null)))
915 }
916 Command::Dev(DevCmd::Api {
917 method,
918 params,
919 peer_id,
920 password: auth,
921 module,
922 }) => {
923 let params = serde_json::from_str::<Value>(¶ms).unwrap_or_else(|err| {
926 debug!(
927 target: LOG_CLIENT,
928 "Failed to serialize params:{}. Converting it to JSON string",
929 err
930 );
931
932 serde_json::Value::String(params)
933 });
934
935 let mut params = ApiRequestErased::new(params);
936 if let Some(auth) = auth {
937 params = params.with_auth(ApiAuth::new(auth));
938 }
939 let client = self.client_open(&cli).await?;
940
941 let api = client.api_clone();
942
943 let module_api = match module {
944 Some(selector) => {
945 Some(api.with_module(selector.resolve(&client).map_err_cli()?))
946 }
947 None => None,
948 };
949
950 let response: Value = match (peer_id, module_api) {
951 (Some(peer_id), Some(module_api)) => module_api
952 .request_raw(peer_id.into(), &method, ¶ms)
953 .await
954 .map_err_cli()?,
955 (Some(peer_id), None) => api
956 .request_raw(peer_id.into(), &method, ¶ms)
957 .await
958 .map_err_cli()?,
959 (None, Some(module_api)) => module_api
960 .request_current_consensus(method, params)
961 .await
962 .map_err_cli()?,
963 (None, None) => api
964 .request_current_consensus(method, params)
965 .await
966 .map_err_cli()?,
967 };
968
969 Ok(CliOutput::UntypedApiOutput { value: response })
970 }
971 Command::Dev(DevCmd::AdvanceNoteIdx { count, amount }) => {
972 let client = self.client_open(&cli).await?;
973
974 let mint = client
975 .get_first_module::<MintClientModule>()
976 .map_err_cli_msg("can't get mint module")?;
977
978 for _ in 0..count {
979 mint.advance_note_idx(amount)
980 .await
981 .map_err_cli_msg("failed to advance the note_idx")?;
982 }
983
984 Ok(CliOutput::Raw(serde_json::Value::Null))
985 }
986 Command::Dev(DevCmd::ApiAnnouncements) => {
987 let client = self.client_open(&cli).await?;
988 let announcements = client.get_peer_url_announcements().await;
989 Ok(CliOutput::Raw(
990 serde_json::to_value(announcements).expect("Can be encoded"),
991 ))
992 }
993 Command::Dev(DevCmd::GuardianMetadata) => {
994 let client = self.client_open(&cli).await?;
995 let metadata = client.get_guardian_metadata().await;
996 Ok(CliOutput::Raw(
997 serde_json::to_value(metadata).expect("Can be encoded"),
998 ))
999 }
1000 Command::Dev(DevCmd::WaitBlockCount { count: target }) => retry(
1001 "wait_block_count",
1002 backoff_util::custom_backoff(
1003 Duration::from_millis(100),
1004 Duration::from_secs(5),
1005 None,
1006 ),
1007 || async {
1008 let client = self.client_open(&cli).await?;
1009 let wallet = client.get_first_module::<WalletClientModule>()?;
1010 let count = client
1011 .api()
1012 .with_module(wallet.id)
1013 .fetch_consensus_block_count()
1014 .await?;
1015 if count >= target {
1016 Ok(CliOutput::WaitBlockCount { reached: count })
1017 } else {
1018 info!(target: LOG_CLIENT, current=count, target, "Block count not reached");
1019 Err(format_err!("target not reached"))
1020 }
1021 },
1022 )
1023 .await
1024 .map_err_cli(),
1025
1026 Command::Dev(DevCmd::WaitComplete) => {
1027 let client = self.client_open(&cli).await?;
1028 client
1029 .wait_for_all_active_state_machines()
1030 .await
1031 .map_err_cli_msg("failed to wait for all active state machines")?;
1032 Ok(CliOutput::Raw(serde_json::Value::Null))
1033 }
1034 Command::Dev(DevCmd::Wait { seconds }) => {
1035 let client = self.client_open(&cli).await?;
1036 client
1040 .task_group()
1041 .spawn_cancellable("fedimint-cli dev wait: init networking", {
1042 let client = client.clone();
1043 async move {
1044 let _ = client.api().session_count().await;
1045 }
1046 });
1047
1048 if let Some(secs) = seconds {
1049 runtime::sleep(Duration::from_secs_f32(secs)).await;
1050 } else {
1051 pending::<()>().await;
1052 }
1053 Ok(CliOutput::Raw(serde_json::Value::Null))
1054 }
1055 Command::Dev(DevCmd::Decode { decode_type }) => match decode_type {
1056 DecodeType::InviteCode { invite_code } => Ok(CliOutput::DecodeInviteCode {
1057 url: invite_code.url(),
1058 federation_id: invite_code.federation_id(),
1059 }),
1060 DecodeType::Notes { notes, file } => {
1061 let notes = if let Some(notes) = notes {
1062 notes
1063 } else if let Some(file) = file {
1064 let notes_str =
1065 fs::read_to_string(file).map_err_cli_msg("failed to read file")?;
1066 OOBNotes::from_str(¬es_str).map_err_cli_msg("failed to decode notes")?
1067 } else {
1068 unreachable!("Clap enforces either notes or file being set");
1069 };
1070
1071 let notes_json = notes
1072 .notes_json()
1073 .map_err_cli_msg("failed to decode notes")?;
1074 Ok(CliOutput::Raw(notes_json))
1075 }
1076 DecodeType::Transaction { hex_string } => {
1077 let bytes: Vec<u8> = hex::FromHex::from_hex(&hex_string)
1078 .map_err_cli_msg("failed to decode transaction")?;
1079
1080 let client = self.client_open(&cli).await?;
1081 let tx = fedimint_core::transaction::Transaction::from_bytes(
1082 &bytes,
1083 client.decoders(),
1084 )
1085 .map_err_cli_msg("failed to decode transaction")?;
1086
1087 Ok(CliOutput::DecodeTransaction {
1088 transaction: (format!("{tx:?}")),
1089 })
1090 }
1091 DecodeType::SetupCode { setup_code } => {
1092 let setup_code = base32::decode_prefixed(FEDIMINT_PREFIX, &setup_code)
1093 .map_err_cli_msg("failed to decode setup code")?;
1094
1095 Ok(CliOutput::SetupCode { setup_code })
1096 }
1097 },
1098 Command::Dev(DevCmd::Encode { encode_type }) => match encode_type {
1099 EncodeType::InviteCode {
1100 url,
1101 federation_id,
1102 peer,
1103 api_secret,
1104 } => Ok(CliOutput::InviteCode {
1105 invite_code: InviteCode::new(url, peer, federation_id, api_secret),
1106 }),
1107 EncodeType::Notes { notes_json } => {
1108 let notes = serde_json::from_str::<OOBNotesJson>(¬es_json)
1109 .map_err_cli_msg("invalid JSON for notes")?;
1110 let prefix =
1111 FederationIdPrefix::from_str(¬es.federation_id_prefix).map_err_cli()?;
1112 let notes = OOBNotes::new(prefix, notes.notes);
1113 Ok(CliOutput::Raw(notes.to_string().into()))
1114 }
1115 },
1116 Command::Dev(DevCmd::SessionCount) => {
1117 let client = self.client_open(&cli).await?;
1118 let count = client.api().session_count().await?;
1119 Ok(CliOutput::EpochCount { count })
1120 }
1121 Command::Dev(DevCmd::Config) => {
1122 let client = self.client_open(&cli).await?;
1123 let config = client.get_config_json().await;
1124 Ok(CliOutput::Raw(
1125 serde_json::to_value(config).expect("Client config is serializable"),
1126 ))
1127 }
1128 Command::Dev(DevCmd::ConfigDecrypt {
1129 in_file,
1130 out_file,
1131 salt_file,
1132 password,
1133 }) => {
1134 let salt_file = salt_file.unwrap_or_else(|| salt_from_file_path(&in_file));
1135 let salt = fs::read_to_string(salt_file).map_err_cli()?;
1136 let key = get_encryption_key(&password, &salt).map_err_cli()?;
1137 let decrypted_bytes = encrypted_read(&key, in_file).map_err_cli()?;
1138
1139 let mut out_file_handle = fs::File::options()
1140 .create_new(true)
1141 .write(true)
1142 .open(out_file)
1143 .expect("Could not create output cfg file");
1144 out_file_handle.write_all(&decrypted_bytes).map_err_cli()?;
1145 Ok(CliOutput::ConfigDecrypt)
1146 }
1147 Command::Dev(DevCmd::ConfigEncrypt {
1148 in_file,
1149 out_file,
1150 salt_file,
1151 password,
1152 }) => {
1153 let mut in_file_handle =
1154 fs::File::open(in_file).expect("Could not create output cfg file");
1155 let mut plaintext_bytes = vec![];
1156 in_file_handle.read_to_end(&mut plaintext_bytes).unwrap();
1157
1158 let salt_file = salt_file.unwrap_or_else(|| salt_from_file_path(&out_file));
1159 let salt = fs::read_to_string(salt_file).map_err_cli()?;
1160 let key = get_encryption_key(&password, &salt).map_err_cli()?;
1161 encrypted_write(plaintext_bytes, &key, out_file).map_err_cli()?;
1162 Ok(CliOutput::ConfigEncrypt)
1163 }
1164 Command::Dev(DevCmd::ListOperationStates { operation_id }) => {
1165 #[derive(Serialize)]
1166 struct ReactorLogState {
1167 active: bool,
1168 module_instance: ModuleInstanceId,
1169 creation_time: String,
1170 #[serde(skip_serializing_if = "Option::is_none")]
1171 end_time: Option<String>,
1172 state: String,
1173 }
1174
1175 let client = self.client_open(&cli).await?;
1176
1177 let (active_states, inactive_states) =
1178 client.executor().get_operation_states(operation_id).await;
1179 let all_states =
1180 active_states
1181 .into_iter()
1182 .map(|(active_state, active_meta)| ReactorLogState {
1183 active: true,
1184 module_instance: active_state.module_instance_id(),
1185 creation_time: crate::client::time_to_iso8601(&active_meta.created_at),
1186 end_time: None,
1187 state: format!("{active_state:?}",),
1188 })
1189 .chain(inactive_states.into_iter().map(
1190 |(inactive_state, inactive_meta)| ReactorLogState {
1191 active: false,
1192 module_instance: inactive_state.module_instance_id(),
1193 creation_time: crate::client::time_to_iso8601(
1194 &inactive_meta.created_at,
1195 ),
1196 end_time: Some(crate::client::time_to_iso8601(
1197 &inactive_meta.exited_at,
1198 )),
1199 state: format!("{inactive_state:?}",),
1200 },
1201 ))
1202 .sorted_by(|a, b| a.creation_time.cmp(&b.creation_time))
1203 .collect::<Vec<_>>();
1204
1205 Ok(CliOutput::Raw(json!({
1206 "states": all_states
1207 })))
1208 }
1209 Command::Dev(DevCmd::MetaFields) => {
1210 let client = self.client_open(&cli).await?;
1211 let source = MetaModuleMetaSourceWithFallback::<LegacyMetaSource>::default();
1212
1213 let meta_fields = source
1214 .fetch(
1215 &client.config().await,
1216 &client.api_clone(),
1217 FetchKind::Initial,
1218 None,
1219 )
1220 .await
1221 .map_err_cli()?;
1222
1223 Ok(CliOutput::Raw(
1224 serde_json::to_value(meta_fields).expect("Can be encoded"),
1225 ))
1226 }
1227 Command::Dev(DevCmd::PeerVersion { peer_id }) => {
1228 let client = self.client_open(&cli).await?;
1229 let version = client
1230 .api()
1231 .fedimintd_version(peer_id.into())
1232 .await
1233 .map_err_cli()?;
1234
1235 Ok(CliOutput::Raw(json!({ "version": version })))
1236 }
1237 Command::Dev(DevCmd::ShowEventLog { pos, limit }) => {
1238 let client = self.client_open(&cli).await?;
1239
1240 let events: Vec<_> = client
1241 .get_event_log(pos, limit)
1242 .await
1243 .into_iter()
1244 .map(|v| {
1245 let id = v.id();
1246 let v = v.as_raw();
1247 let module_id = v.module.as_ref().map(|m| m.1);
1248 let module_kind = v.module.as_ref().map(|m| m.0.clone());
1249 serde_json::json!({
1250 "id": id,
1251 "kind": v.kind,
1252 "module_kind": module_kind,
1253 "module_id": module_id,
1254 "ts": v.ts_usecs,
1255 "payload": serde_json::from_slice(&v.payload).unwrap_or_else(|_| hex::encode(&v.payload)),
1256 })
1257 })
1258 .collect();
1259
1260 Ok(CliOutput::Raw(
1261 serde_json::to_value(events).expect("Can be encoded"),
1262 ))
1263 }
1264 Command::Dev(DevCmd::ShowEventLogTrimable { pos, limit }) => {
1265 let client = self.client_open(&cli).await?;
1266
1267 let events: Vec<_> = client
1268 .get_event_log_trimable(
1269 pos.map(|id| EventLogTrimableId::from(u64::from(id))),
1270 limit,
1271 )
1272 .await
1273 .into_iter()
1274 .map(|v| {
1275 let id = v.id();
1276 let v = v.as_raw();
1277 let module_id = v.module.as_ref().map(|m| m.1);
1278 let module_kind = v.module.as_ref().map(|m| m.0.clone());
1279 serde_json::json!({
1280 "id": id,
1281 "kind": v.kind,
1282 "module_kind": module_kind,
1283 "module_id": module_id,
1284 "ts": v.ts_usecs,
1285 "payload": serde_json::from_slice(&v.payload).unwrap_or_else(|_| hex::encode(&v.payload)),
1286 })
1287 })
1288 .collect();
1289
1290 Ok(CliOutput::Raw(
1291 serde_json::to_value(events).expect("Can be encoded"),
1292 ))
1293 }
1294 Command::Dev(DevCmd::SubmitTransaction { transaction }) => {
1295 let client = self.client_open(&cli).await?;
1296 let tx = Transaction::consensus_decode_hex(&transaction, client.decoders())
1297 .map_err_cli()?;
1298 let tx_outcome = client
1299 .api()
1300 .submit_transaction(tx)
1301 .await
1302 .try_into_inner(client.decoders())
1303 .map_err_cli()?;
1304
1305 Ok(CliOutput::Raw(
1306 serde_json::to_value(tx_outcome.0.map_err_cli()?).expect("Can be encoded"),
1307 ))
1308 }
1309 Command::Dev(DevCmd::TestEventLogHandling) => {
1310 let client = self.client_open(&cli).await?;
1311
1312 client
1313 .handle_events(
1314 client.built_in_application_event_log_tracker(),
1315 move |_dbtx, event| {
1316 Box::pin(async move {
1317 info!(target: LOG_CLIENT, "{event:?}");
1318
1319 Ok(())
1320 })
1321 },
1322 )
1323 .await
1324 .map_err_cli()?;
1325 unreachable!(
1326 "handle_events exits only if client shuts down, which we don't do here"
1327 )
1328 }
1329 Command::Dev(DevCmd::ChainId) => {
1330 let client = self.client_open(&cli).await?;
1331 let chain_id = client
1332 .db()
1333 .begin_transaction_nc()
1334 .await
1335 .get_value(&fedimint_client::db::ChainIdKey)
1336 .await
1337 .ok_or_cli_msg("Chain ID not cached in client database")?;
1338
1339 Ok(CliOutput::Raw(serde_json::json!({
1340 "chain_id": chain_id.to_string()
1341 })))
1342 }
1343 Command::Completion { shell } => {
1344 let bin_path = PathBuf::from(
1345 std::env::args_os()
1346 .next()
1347 .expect("Binary name is always provided if we get this far"),
1348 );
1349 let bin_name = bin_path
1350 .file_name()
1351 .expect("path has file name")
1352 .to_string_lossy();
1353 clap_complete::generate(
1354 shell,
1355 &mut Opts::command(),
1356 bin_name.as_ref(),
1357 &mut std::io::stdout(),
1358 );
1359 Ok(CliOutput::Raw(serde_json::Value::Bool(true)))
1361 }
1362 }
1363 }
1364
1365 async fn handle_admin_setup_command(
1366 &self,
1367 cli: Opts,
1368 args: SetupAdminArgs,
1369 ) -> anyhow::Result<Value> {
1370 let client =
1371 DynGlobalApi::new_admin_setup(cli.make_endpoints().await?, args.endpoint.clone())?;
1372
1373 match &args.subcommand {
1374 SetupAdminCmd::Status => {
1375 let status = client.setup_status(cli.auth()?).await?;
1376
1377 Ok(serde_json::to_value(status).expect("JSON serialization failed"))
1378 }
1379 SetupAdminCmd::SetLocalParams {
1380 name,
1381 federation_name,
1382 federation_size,
1383 } => {
1384 let info = client
1385 .set_local_params(
1386 name.clone(),
1387 federation_name.clone(),
1388 None,
1389 None,
1390 *federation_size,
1391 cli.auth()?,
1392 )
1393 .await?;
1394
1395 Ok(serde_json::to_value(info).expect("JSON serialization failed"))
1396 }
1397 SetupAdminCmd::AddPeer { info } => {
1398 let name = client
1399 .add_peer_connection_info(info.clone(), cli.auth()?)
1400 .await?;
1401
1402 Ok(serde_json::to_value(name).expect("JSON serialization failed"))
1403 }
1404 SetupAdminCmd::StartDkg => {
1405 client.start_dkg(cli.auth()?).await?;
1406
1407 Ok(Value::Null)
1408 }
1409 }
1410 }
1411}
1412
1413async fn log_expiration_notice(client: &Client) {
1414 client.get_meta_expiration_timestamp().await;
1415 if let Some(expiration_time) = client.get_meta_expiration_timestamp().await {
1416 match expiration_time.duration_since(fedimint_core::time::now()) {
1417 Ok(until_expiration) => {
1418 let days = until_expiration.as_secs() / (60 * 60 * 24);
1419
1420 if 90 < days {
1421 debug!(target: LOG_CLIENT, %days, "This federation will expire");
1422 } else if 30 < days {
1423 info!(target: LOG_CLIENT, %days, "This federation will expire");
1424 } else {
1425 warn!(target: LOG_CLIENT, %days, "This federation will expire soon");
1426 }
1427 }
1428 Err(_) => {
1429 tracing::error!(target: LOG_CLIENT, "This federation has expired and might not be safe to use");
1430 }
1431 }
1432 }
1433}
1434async fn print_welcome_message(client: &Client) {
1435 if let Some(welcome_message) = client
1436 .meta_service()
1437 .get_field::<String>(client.db(), "welcome_message")
1438 .await
1439 .and_then(|v| v.value)
1440 {
1441 eprintln!("{welcome_message}");
1442 }
1443}
1444
1445fn salt_from_file_path(file_path: &Path) -> PathBuf {
1446 file_path
1447 .parent()
1448 .expect("File has no parent?!")
1449 .join(SALT_FILE)
1450}
1451
1452fn metadata_from_clap_cli(metadata: Vec<String>) -> Result<BTreeMap<String, String>, CliError> {
1454 let metadata: BTreeMap<String, String> = metadata
1455 .into_iter()
1456 .map(|item| {
1457 match &item
1458 .splitn(2, '=')
1459 .map(ToString::to_string)
1460 .collect::<Vec<String>>()[..]
1461 {
1462 [] => Err(format_err!("Empty metadata argument not allowed")),
1463 [key] => Err(format_err!("Metadata {key} is missing a value")),
1464 [key, val] => Ok((key.clone(), val.clone())),
1465 [..] => unreachable!(),
1466 }
1467 })
1468 .collect::<anyhow::Result<_>>()
1469 .map_err_cli_msg("invalid metadata")?;
1470 Ok(metadata)
1471}
1472
1473#[test]
1474fn metadata_from_clap_cli_test() {
1475 for (args, expected) in [
1476 (
1477 vec!["a=b".to_string()],
1478 BTreeMap::from([("a".into(), "b".into())]),
1479 ),
1480 (
1481 vec!["a=b".to_string(), "c=d".to_string()],
1482 BTreeMap::from([("a".into(), "b".into()), ("c".into(), "d".into())]),
1483 ),
1484 ] {
1485 assert_eq!(metadata_from_clap_cli(args).unwrap(), expected);
1486 }
1487}