From 327477d27bf51733040e05be672752f282cb9b50 Mon Sep 17 00:00:00 2001 From: kkawula <57270771+kkawula@users.noreply.github.com> Date: Fri, 20 Dec 2024 16:00:18 +0100 Subject: [PATCH] Default account mechanism for sncast (#2773) Closes #2695 ## Introduced changes - Added interactive prompt when creating or importing new account - Added `silent` flag, to disable interactive selection dialog ## Checklist - [x] Linked relevant issue - [x] Updated relevant documentation - [x] Added relevant tests - [x] Performed self-review of the code - [x] Added changes to `CHANGELOG.md` --- CHANGELOG.md | 7 + Cargo.lock | 15 + Cargo.toml | 1 + crates/sncast/Cargo.toml | 2 + crates/sncast/src/helpers/interactive.rs | 266 ++++++++++++++++++ crates/sncast/src/helpers/mod.rs | 1 + crates/sncast/src/main.rs | 19 ++ .../src/starknet_commands/account/create.rs | 4 + .../src/starknet_commands/account/import.rs | 9 +- crates/sncast/tests/e2e/account/import.rs | 4 +- docs/src/appendix/sncast/account/create.md | 5 + docs/src/appendix/sncast/account/import.md | 5 + 12 files changed, 332 insertions(+), 6 deletions(-) create mode 100644 crates/sncast/src/helpers/interactive.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 6dcddb0ec7..b613a343c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Cast + +#### Added + +- interactive interface that allows setting created or imported account as the default + + ## [0.35.1] - 2024-12-16 ### Forge diff --git a/Cargo.lock b/Cargo.lock index a76ea7d959..f664d43d02 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1788,6 +1788,19 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "dialoguer" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de" +dependencies = [ + "console", + "shell-words", + "tempfile", + "thiserror", + "zeroize", +] + [[package]] name = "diff" version = "0.1.13" @@ -4870,6 +4883,7 @@ dependencies = [ "conversions", "ctor", "data-transformer", + "dialoguer", "dirs", "docs", "fs_extra", @@ -4901,6 +4915,7 @@ dependencies = [ "thiserror", "tokio", "toml", + "toml_edit", "url", "walkdir", "wiremock", diff --git a/Cargo.toml b/Cargo.toml index 0e024c21cf..834409326d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,6 +45,7 @@ cairo-lang-macro = "0.1.0" cairo-vm = "1.0.0-rc3" cairo-annotations = "0.1.0" dirs = "5.0.1" +dialoguer = "0.11.0" starknet-types-core = { version = "0.1.7", features = ["hash", "prime-bigint"] } anyhow = "1.0.93" assert_fs = "1.1.2" diff --git a/crates/sncast/Cargo.toml b/crates/sncast/Cargo.toml index c33d7fa084..9a0b992c99 100644 --- a/crates/sncast/Cargo.toml +++ b/crates/sncast/Cargo.toml @@ -48,6 +48,8 @@ walkdir.workspace = true const-hex.workspace = true regex.workspace = true dirs.workspace = true +dialoguer.workspace = true +toml_edit.workspace = true [dev-dependencies] ctor.workspace = true diff --git a/crates/sncast/src/helpers/interactive.rs b/crates/sncast/src/helpers/interactive.rs new file mode 100644 index 0000000000..051f6e6c2f --- /dev/null +++ b/crates/sncast/src/helpers/interactive.rs @@ -0,0 +1,266 @@ +use crate::helpers::config::get_global_config_path; +use anyhow::{Context, Result}; +use camino::Utf8PathBuf; +use configuration::search_config_upwards_relative_to; +use dialoguer::theme::ColorfulTheme; +use dialoguer::Select; +use std::fmt::Display; +use std::{env, fs}; +use toml_edit::{DocumentMut, Item, Table, Value}; + +enum PromptSelection { + GlobalDefault(Utf8PathBuf), + LocalDefault(Utf8PathBuf), + No, +} + +impl Display for PromptSelection { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + PromptSelection::LocalDefault(path) => { + write!(f, "Yes, local default ({})", to_tilde_path(path)) + } + PromptSelection::GlobalDefault(path) => { + write!(f, "Yes, global default ({})", to_tilde_path(path)) + } + PromptSelection::No => write!(f, "No"), + } + } +} + +pub fn prompt_to_add_account_as_default(account: &str) -> Result<()> { + let mut options = Vec::new(); + + if let Ok(global_path) = get_global_config_path() { + options.push(PromptSelection::GlobalDefault(global_path)); + } + + if let Some(local_path) = env::current_dir() + .ok() + .and_then(|current_path| Utf8PathBuf::from_path_buf(current_path.clone()).ok()) + .and_then(|current_path_utf8| search_config_upwards_relative_to(¤t_path_utf8).ok()) + { + options.push(PromptSelection::LocalDefault(local_path)); + } + + options.push(PromptSelection::No); + + let selection = Select::with_theme(&ColorfulTheme::default()) + .with_prompt("Do you want to make this account default?") + .items(&options) + .default(0) + .interact() + .context("Failed to display selection dialog")?; + + match &options[selection] { + PromptSelection::GlobalDefault(path) | PromptSelection::LocalDefault(path) => { + edit_config(path, "default", "account", account)?; + } + PromptSelection::No => {} + } + + Ok(()) +} + +fn edit_config(config_path: &Utf8PathBuf, profile: &str, key: &str, value: &str) -> Result<()> { + let file_content = fs::read_to_string(config_path).context("Failed to read config file")?; + + let mut toml_doc = file_content + .parse::() + .context("Failed to parse TOML")?; + update_config(&mut toml_doc, profile, key, value); + + fs::write(config_path, toml_doc.to_string()).context("Failed to write to config file")?; + + Ok(()) +} + +fn update_config(toml_doc: &mut DocumentMut, profile: &str, key: &str, value: &str) { + if !toml_doc.contains_key("sncast") { + toml_doc["sncast"] = Item::Table(Table::new()); + } + + let sncast_table = toml_doc + .get_mut("sncast") + .and_then(|item| item.as_table_mut()) + .expect("Failed to create or access 'sncast' table"); + + if !sncast_table.contains_key(profile) { + sncast_table[profile] = Item::Table(Table::new()); + } + + let profile_table = sncast_table + .get_mut(profile) + .and_then(|item| item.as_table_mut()) + .expect("Failed to create or access profile table"); + + profile_table[key] = Value::from(value).into(); +} + +fn to_tilde_path(path: &Utf8PathBuf) -> String { + if cfg!(not(target_os = "windows")) { + if let Some(home_dir) = dirs::home_dir() { + if let Ok(canonical_path) = path.canonicalize() { + if let Ok(stripped_path) = canonical_path.strip_prefix(&home_dir) { + return format!("~/{}", stripped_path.to_string_lossy()); + } + } + } + } + + path.to_string() +} + +#[cfg(test)] +mod tests { + + use super::update_config; + use indoc::formatdoc; + use toml_edit::DocumentMut; + #[test] + fn test_update_value() { + let original = formatdoc! {r#" + [snfoundry] + key = 2137 + + [sncast.default] + account = "mainnet" + url = "https://localhost:5050" + + # comment + + [sncast.testnet] + account = "testnet-account" # comment + url = "https://swmansion.com/" + "#}; + + let expected = formatdoc! {r#" + [snfoundry] + key = 2137 + + [sncast.default] + account = "testnet" + url = "https://localhost:5050" + + # comment + + [sncast.testnet] + account = "testnet-account" # comment + url = "https://swmansion.com/" + "#}; + + let mut toml_doc = original.parse::().unwrap(); + + update_config(&mut toml_doc, "default", "account", "testnet"); + + assert_eq!(toml_doc.to_string(), expected); + } + + #[test] + fn test_create_key() { + let original = formatdoc! {r#" + [snfoundry] + key = 2137 + + [sncast.default] + url = "https://localhost:5050" + + [sncast.testnet] + account = "testnet-account" # comment + url = "https://swmansion.com/" + "#}; + + let expected = formatdoc! {r#" + [snfoundry] + key = 2137 + + [sncast.default] + url = "https://localhost:5050" + account = "testnet" + + [sncast.testnet] + account = "testnet-account" # comment + url = "https://swmansion.com/" + "#}; + + let mut toml_doc = original.parse::().unwrap(); + + update_config(&mut toml_doc, "default", "account", "testnet"); + + assert_eq!(toml_doc.to_string(), expected); + } + + #[test] + fn test_create_table_key() { + let original = formatdoc! {r#" + [snfoundry] + key = 2137 + + [sncast.testnet] + account = "testnet-account" # comment + url = "https://swmansion.com/" + "#}; + + let expected = formatdoc! {r#" + [snfoundry] + key = 2137 + + [sncast.testnet] + account = "testnet-account" # comment + url = "https://swmansion.com/" + + [sncast.default] + account = "testnet" + "#}; + + let mut toml_doc = original.parse::().unwrap(); + + update_config(&mut toml_doc, "default", "account", "testnet"); + + assert_eq!(toml_doc.to_string(), expected); + } + + #[test] + fn test_create_table() { + let original = formatdoc! {r#" + [snfoundry] + key = 2137 + "#}; + + let expected = formatdoc! { + r#" + [snfoundry] + key = 2137 + + [sncast] + + [sncast.default] + account = "testnet" + "#}; + + let mut toml_doc = original.parse::().unwrap(); + + update_config(&mut toml_doc, "default", "account", "testnet"); + + assert_eq!(toml_doc.to_string(), expected); + } + + #[test] + fn test_create_table_empty_file() { + let original = ""; + + let expected = formatdoc! { + r#" + [sncast] + + [sncast.default] + account = "testnet" + "#}; + + let mut toml_doc = original.parse::().unwrap(); + + update_config(&mut toml_doc, "default", "account", "testnet"); + + assert_eq!(toml_doc.to_string(), expected); + } +} diff --git a/crates/sncast/src/helpers/mod.rs b/crates/sncast/src/helpers/mod.rs index c88c105765..4dd3519e36 100644 --- a/crates/sncast/src/helpers/mod.rs +++ b/crates/sncast/src/helpers/mod.rs @@ -5,5 +5,6 @@ pub mod configuration; pub mod constants; pub mod error; pub mod fee; +pub mod interactive; pub mod rpc; pub mod scarb_utils; diff --git a/crates/sncast/src/main.rs b/crates/sncast/src/main.rs index 4f483bd7f3..2db1f8e788 100644 --- a/crates/sncast/src/main.rs +++ b/crates/sncast/src/main.rs @@ -6,6 +6,8 @@ use anyhow::{Context, Result}; use data_transformer::Calldata; use sncast::response::explorer_link::print_block_explorer_link_if_allowed; use sncast::response::print::{print_command_result, OutputFormat}; +use std::io; +use std::io::IsTerminal; use crate::starknet_commands::deploy::DeployArguments; use camino::Utf8PathBuf; @@ -15,6 +17,7 @@ use sncast::helpers::config::{combine_cast_configs, get_global_config_path}; use sncast::helpers::configuration::CastConfig; use sncast::helpers::constants::{DEFAULT_ACCOUNTS_FILE, DEFAULT_MULTICALL_CONTENTS}; use sncast::helpers::fee::PayableTransaction; +use sncast::helpers::interactive::prompt_to_add_account_as_default; use sncast::helpers::scarb_utils::{ assert_manifest_path_exists, build, build_and_load_artifacts, get_package_metadata, get_scarb_metadata_with_deps, BuildConfig, @@ -489,6 +492,16 @@ async fn run_async_command( ) .await; + if !import.silent && result.is_ok() && io::stdout().is_terminal() { + if let Some(account_name) = + result.as_ref().ok().and_then(|r| r.account_name.clone()) + { + if let Err(err) = prompt_to_add_account_as_default(account_name.as_str()) { + eprintln!("Error: Failed to launch interactive prompt: {err}"); + } + } + } + print_command_result("account import", &result, numbers_format, output_format)?; Ok(()) } @@ -516,6 +529,12 @@ async fn run_async_command( ) .await; + if !create.silent && result.is_ok() && io::stdout().is_terminal() { + if let Err(err) = prompt_to_add_account_as_default(&account) { + eprintln!("Error: Failed to launch interactive prompt: {err}"); + } + } + print_command_result("account create", &result, numbers_format, output_format)?; print_block_explorer_link_if_allowed( &result, diff --git a/crates/sncast/src/starknet_commands/account/create.rs b/crates/sncast/src/starknet_commands/account/create.rs index b177af2bb7..df7c2cd78c 100644 --- a/crates/sncast/src/starknet_commands/account/create.rs +++ b/crates/sncast/src/starknet_commands/account/create.rs @@ -53,6 +53,10 @@ pub struct Create { #[clap(flatten)] pub rpc: RpcArgs, + + /// If passed, the command will not trigger an interactive prompt to add an account as a default + #[clap(long)] + pub silent: bool, } #[allow(clippy::too_many_arguments)] diff --git a/crates/sncast/src/starknet_commands/account/import.rs b/crates/sncast/src/starknet_commands/account/import.rs index bd756b72d6..2a95e29418 100644 --- a/crates/sncast/src/starknet_commands/account/import.rs +++ b/crates/sncast/src/starknet_commands/account/import.rs @@ -62,6 +62,10 @@ pub struct Import { #[clap(flatten)] pub rpc: RpcArgs, + + /// If passed, the command will not trigger an interactive prompt to add an account as a default + #[clap(long)] + pub silent: bool, } pub async fn import( @@ -166,10 +170,7 @@ pub async fn import( || "--add-profile flag was not set. No profile added to snfoundry.toml".to_string(), |profile_name| format!("Profile {profile_name} successfully added to snfoundry.toml"), ), - account_name: account.map_or_else( - || Some(format!("Account imported with name: {account_name}")), - |_| None, - ), + account_name: account.map_or_else(|| Some(account_name), |_| None), }) } diff --git a/crates/sncast/tests/e2e/account/import.rs b/crates/sncast/tests/e2e/account/import.rs index cfbe7daa2c..bbc3328f67 100644 --- a/crates/sncast/tests/e2e/account/import.rs +++ b/crates/sncast/tests/e2e/account/import.rs @@ -740,7 +740,7 @@ pub async fn test_happy_case_default_name_generation() { let snapbox = runner(&import_args).current_dir(tempdir.path()); snapbox.assert().stdout_matches(formatdoc! {r" command: account import - account_name: Account imported with name: account-{id} + account_name: account-{id} add_profile: --add-profile flag was not set. No profile added to snfoundry.toml ", id = i + 1}); } @@ -766,7 +766,7 @@ pub async fn test_happy_case_default_name_generation() { let snapbox = runner(&import_args).current_dir(tempdir.path()); snapbox.assert().stdout_matches(indoc! {r" command: account import - account_name: Account imported with name: account-2 + account_name: account-2 add_profile: --add-profile flag was not set. No profile added to snfoundry.toml "}); diff --git a/docs/src/appendix/sncast/account/create.md b/docs/src/appendix/sncast/account/create.md index f102c2424a..75a094103d 100644 --- a/docs/src/appendix/sncast/account/create.md +++ b/docs/src/appendix/sncast/account/create.md @@ -43,3 +43,8 @@ If passed, a profile with corresponding name will be added to the local snfoundr Optional. Class hash of a custom openzeppelin account contract declared to the network. + +## `--silent` +Optional. + +If passed, the command will not trigger an interactive prompt to add an account as a default diff --git a/docs/src/appendix/sncast/account/import.md b/docs/src/appendix/sncast/account/import.md index e1ad3c0715..141604fd42 100644 --- a/docs/src/appendix/sncast/account/import.md +++ b/docs/src/appendix/sncast/account/import.md @@ -50,3 +50,8 @@ Salt for the account address. Optional. If passed, a profile with corresponding name will be added to the local snfoundry.toml. + +## `--silent` +Optional. + +If passed, the command will not trigger an interactive prompt to add an account as a default