diff --git a/CHANGELOG.md b/CHANGELOG.md index c9f47b6e1b..955cbdb0ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - contract names to call trace +### Cast + +#### Added + +- `script init` command to generate a template file structure for deployment scripts + ## [0.17.1] - 2024-02-12 ### Cast diff --git a/crates/sncast/src/helpers/constants.rs b/crates/sncast/src/helpers/constants.rs index 550f0ff48a..955e5a4e39 100644 --- a/crates/sncast/src/helpers/constants.rs +++ b/crates/sncast/src/helpers/constants.rs @@ -31,3 +31,5 @@ pub const CREATE_KEYSTORE_PASSWORD_ENV_VAR: &str = "CREATE_KEYSTORE_PASSWORD"; pub const SCRIPT_LIB_ARTIFACT_NAME: &str = "__sncast_script_lib"; pub const CONFIG_FILENAME: &str = "snfoundry.toml"; pub const STATE_FILE_VERSION: u8 = 1; + +pub const INIT_SCRIPTS_DIR: &str = "scripts"; diff --git a/crates/sncast/src/helpers/scarb_utils.rs b/crates/sncast/src/helpers/scarb_utils.rs index ddf49e91c2..c406e15f10 100644 --- a/crates/sncast/src/helpers/scarb_utils.rs +++ b/crates/sncast/src/helpers/scarb_utils.rs @@ -64,6 +64,12 @@ pub fn get_scarb_metadata_with_deps(manifest_path: &Utf8PathBuf) -> Result Result { + let scarb_metadata = get_scarb_metadata(manifest_path)?; + + Ok(scarb_metadata.app_version_info.cairo.version.to_string()) +} + pub fn assert_manifest_path_exists( path_to_scarb_toml: &Option, ) -> Result { diff --git a/crates/sncast/src/main.rs b/crates/sncast/src/main.rs index 8a4da9546c..2f6f42a835 100644 --- a/crates/sncast/src/main.rs +++ b/crates/sncast/src/main.rs @@ -4,7 +4,7 @@ use crate::starknet_commands::{ account, call::Call, declare::Declare, deploy::Deploy, invoke::Invoke, multicall::Multicall, script::Script, }; -use anyhow::{Context, Result}; +use anyhow::{anyhow, Context, Result}; use sncast::response::print::{print_command_result, OutputFormat}; use camino::Utf8PathBuf; @@ -108,7 +108,7 @@ enum Commands { /// Show current configuration being used ShowConfig(ShowConfig), - /// Run a deployment script + /// Run or initialize a deployment script Script(Script), } @@ -121,35 +121,7 @@ fn main() -> Result<()> { let runtime = Runtime::new().expect("Failed to instantiate Runtime"); if let Commands::Script(script) = &cli.command { - let manifest_path = assert_manifest_path_exists(&cli.path_to_scarb_toml)?; - let package_metadata = get_package_metadata(&manifest_path, &script.package)?; - - let mut config = load_config(&cli.profile, &Some(package_metadata.root.clone()))?; - update_cast_config(&mut config, &cli); - let provider = get_provider(&config.rpc_url)?; - - let mut artifacts = build_and_load_artifacts( - &package_metadata, - &BuildConfig { - scarb_toml_path: manifest_path.clone(), - json: cli.json, - profile: cli.profile.unwrap_or("dev".to_string()), - }, - ) - .expect("Failed to build script"); - let metadata_with_deps = get_scarb_metadata_with_deps(&manifest_path)?; - let mut result = starknet_commands::script::run( - &script.script_module_name, - &metadata_with_deps, - &package_metadata, - &mut artifacts, - &provider, - runtime, - &config, - ); - - print_command_result("script", &mut result, numbers_format, &output_format)?; - Ok(()) + run_script_command(&cli, runtime, script, numbers_format, &output_format) } else { let mut config = load_config(&cli.profile, &None)?; update_cast_config(&mut config, &cli); @@ -421,6 +393,54 @@ async fn run_async_command( } } +fn run_script_command( + cli: &Cli, + runtime: Runtime, + script: &Script, + numbers_format: NumbersFormat, + output_format: &OutputFormat, +) -> Result<()> { + if let Some(starknet_commands::script::Commands::Init(init)) = &script.command { + let mut result = starknet_commands::script::init::init(init); + print_command_result("script init", &mut result, numbers_format, output_format)?; + } else { + let manifest_path = assert_manifest_path_exists(&cli.path_to_scarb_toml)?; + let package_metadata = get_package_metadata(&manifest_path, &script.package)?; + + let mut config = load_config(&cli.profile, &Some(package_metadata.root.clone()))?; + update_cast_config(&mut config, cli); + let provider = get_provider(&config.rpc_url)?; + + let mut artifacts = build_and_load_artifacts( + &package_metadata, + &BuildConfig { + scarb_toml_path: manifest_path.clone(), + json: cli.json, + profile: cli.profile.clone().unwrap_or("dev".to_string()), + }, + ) + .expect("Failed to build script"); + let metadata_with_deps = get_scarb_metadata_with_deps(&manifest_path)?; + + let script_module_name = script.module_name.as_ref().ok_or_else(|| { + anyhow!("Required positional argument SCRIPT_MODULE_NAME not provided") + })?; + + let mut result = starknet_commands::script::run::run( + script_module_name, + &metadata_with_deps, + &package_metadata, + &mut artifacts, + &provider, + runtime, + &config, + ); + + print_command_result("script", &mut result, numbers_format, output_format)?; + } + Ok(()) +} + fn update_cast_config(config: &mut CastConfig, cli: &Cli) { macro_rules! clone_or_else { ($field:expr, $config_field:expr) => { diff --git a/crates/sncast/src/response/structs.rs b/crates/sncast/src/response/structs.rs index 91bac6ae67..8b8224f584 100644 --- a/crates/sncast/src/response/structs.rs +++ b/crates/sncast/src/response/structs.rs @@ -105,3 +105,10 @@ pub struct ScriptResponse { } impl CommandResponse for ScriptResponse {} + +#[derive(Serialize)] +pub struct ScriptInitResponse { + pub status: String, +} + +impl CommandResponse for ScriptInitResponse {} diff --git a/crates/sncast/src/starknet_commands/script/init.rs b/crates/sncast/src/starknet_commands/script/init.rs new file mode 100644 index 0000000000..4b4f7e5c13 --- /dev/null +++ b/crates/sncast/src/starknet_commands/script/init.rs @@ -0,0 +1,162 @@ +use anyhow::{anyhow, ensure, Context, Ok, Result}; +use camino::Utf8PathBuf; +use std::fs; + +use clap::Args; +use indoc::{formatdoc, indoc}; +use scarb_api::ScarbCommand; +use sncast::helpers::constants::INIT_SCRIPTS_DIR; +use sncast::helpers::scarb_utils::get_cairo_version; +use sncast::response::print::print_as_warning; +use sncast::response::structs::ScriptInitResponse; + +#[derive(Args, Debug)] +pub struct Init { + /// Name of a script to create + pub script_name: String, +} + +pub fn init(init_args: &Init) -> Result { + let script_root_dir_path = get_script_root_dir_path(&init_args.script_name)?; + + init_scarb_project(&init_args.script_name, &script_root_dir_path)?; + + let modify_files_result = add_dependencies(&script_root_dir_path) + .and_then(|()| modify_files_in_src_dir(&init_args.script_name, &script_root_dir_path)); + + print_as_warning(&anyhow!( + "The newly created script isn't auto-added to the workspace. For more details, please see https://foundry-rs.github.io/starknet-foundry/starknet/script.html#initialize-a-script") + ); + + match modify_files_result { + Result::Ok(()) => Ok(ScriptInitResponse { + status: format!( + "Successfully initialized `{}` at {}", + init_args.script_name, script_root_dir_path + ), + }), + Err(err) => { + clean_created_dir_and_files(&script_root_dir_path); + Err(err) + } + } +} + +fn get_script_root_dir_path(script_name: &str) -> Result { + let current_dir = Utf8PathBuf::from_path_buf(std::env::current_dir()?) + .expect("Failed to create Utf8PathBuf for the current directory"); + + let scripts_dir = current_dir.join(INIT_SCRIPTS_DIR); + + ensure!( + !scripts_dir.exists(), + "Scripts directory already exists at `{scripts_dir}`" + ); + + Ok(scripts_dir.join(script_name)) +} + +fn init_scarb_project(script_name: &str, script_root_dir: &Utf8PathBuf) -> Result<()> { + ScarbCommand::new() + .args([ + "new", + "--name", + &script_name, + "--no-vcs", + "--quiet", + script_root_dir.as_str(), + ]) + .run() + .context("Failed to init Scarb project")?; + + Ok(()) +} + +fn add_dependencies(script_root_dir: &Utf8PathBuf) -> Result<()> { + add_sncast_std_dependency(script_root_dir) + .context("Failed to add sncast_std dependency to Scarb.toml")?; + add_starknet_dependency(script_root_dir) + .context("Failed to add starknet dependency to Scarb.toml")?; + + Ok(()) +} + +fn add_sncast_std_dependency(script_root_dir: &Utf8PathBuf) -> Result<()> { + let cast_version = format!("v{}", env!("CARGO_PKG_VERSION")); + + ScarbCommand::new() + .current_dir(script_root_dir) + .args([ + "--offline", + "add", + "sncast_std", + "--git", + "https://github.com/foundry-rs/starknet-foundry.git", + "--tag", + &cast_version, + ]) + .run()?; + + Ok(()) +} + +fn add_starknet_dependency(script_root_dir: &Utf8PathBuf) -> Result<()> { + let scarb_manifest_path = script_root_dir.join("Scarb.toml"); + let cairo_version = + get_cairo_version(&scarb_manifest_path).context("Failed to get cairo version")?; + let starknet_dependency = format!("starknet@>={cairo_version}"); + + ScarbCommand::new() + .current_dir(script_root_dir) + .args(["--offline", "add", &starknet_dependency]) + .run()?; + + Ok(()) +} + +fn modify_files_in_src_dir(script_name: &str, script_root_dir: &Utf8PathBuf) -> Result<()> { + create_script_main_file(script_name, script_root_dir) + .context(format!("Failed to create {script_name}.cairo file"))?; + overwrite_lib_file(script_name, script_root_dir).context("Failed to overwrite lib.cairo file") +} + +fn create_script_main_file(script_name: &str, script_root_dir: &Utf8PathBuf) -> Result<()> { + let script_main_file_name = format!("{script_name}.cairo"); + let script_main_file_path = script_root_dir.join("src").join(script_main_file_name); + + fs::write( + script_main_file_path, + indoc! {r#" + use sncast_std::{call, CallResult}; + + // The example below uses a contract deployed to the Goerli testnet + fn main() { + let contract_address = 0x7ad10abd2cc24c2e066a2fee1e435cd5fa60a37f9268bfbaf2e98ce5ca3c436; + let call_result = call(contract_address.try_into().unwrap(), 'get_greeting', array![]); + assert(*call_result.data[0]=='Hello, Starknet!', *call_result.data[0]); + println!("{:?}", call_result); + } + "#}, + )?; + + Ok(()) +} + +fn overwrite_lib_file(script_name: &str, script_root_dir: &Utf8PathBuf) -> Result<()> { + let lib_file_path = script_root_dir.join("src/lib.cairo"); + + fs::write( + lib_file_path, + formatdoc! {r#" + mod {script_name}; + "#}, + )?; + + Ok(()) +} + +fn clean_created_dir_and_files(script_root_dir: &Utf8PathBuf) { + if fs::remove_dir_all(script_root_dir).is_err() { + eprintln!("Failed to clean created files by init command at {script_root_dir}"); + } +} diff --git a/crates/sncast/src/starknet_commands/script/mod.rs b/crates/sncast/src/starknet_commands/script/mod.rs new file mode 100644 index 0000000000..073eba55b9 --- /dev/null +++ b/crates/sncast/src/starknet_commands/script/mod.rs @@ -0,0 +1,23 @@ +use crate::starknet_commands::script::init::Init; +use clap::{Args, Subcommand}; + +pub mod init; +pub mod run; + +#[derive(Args)] +pub struct Script { + /// Module name that contains the `main` function, which will be executed + pub module_name: Option, + + /// Specifies scarb package to be used + #[clap(long)] + pub package: Option, + + #[clap(subcommand)] + pub command: Option, +} + +#[derive(Debug, Subcommand)] +pub enum Commands { + Init(Init), +} diff --git a/crates/sncast/src/starknet_commands/script.rs b/crates/sncast/src/starknet_commands/script/run.rs similarity index 97% rename from crates/sncast/src/starknet_commands/script.rs rename to crates/sncast/src/starknet_commands/script/run.rs index d999f4638b..efe44f2338 100644 --- a/crates/sncast/src/starknet_commands/script.rs +++ b/crates/sncast/src/starknet_commands/script/run.rs @@ -18,8 +18,6 @@ use cairo_lang_utils::ordered_hash_map::OrderedHashMap; use cairo_vm::types::relocatable::Relocatable; use cairo_vm::vm::errors::hint_errors::HintError; use cairo_vm::vm::vm_core::VirtualMachine; -use clap::command; -use clap::Args; use conversions::{FromConv, IntoConv}; use itertools::chain; use runtime::starknet::context::{build_context, BlockInfo}; @@ -45,17 +43,6 @@ use tokio::runtime::Runtime; type ScriptStarknetContractArtifacts = StarknetContractArtifacts; -#[derive(Args)] -#[command(about = "Execute a deployment script")] -pub struct Script { - /// Module name that contains the `main` function, which will be executed - pub script_module_name: String, - - /// Specifies scarb package to be used - #[clap(long)] - pub package: Option, -} - pub struct CastScriptExtension<'a> { pub hints: &'a HashMap, pub provider: &'a JsonRpcClient, diff --git a/crates/sncast/tests/e2e/script/init.rs b/crates/sncast/tests/e2e/script/init.rs new file mode 100644 index 0000000000..51ce7dfc74 --- /dev/null +++ b/crates/sncast/tests/e2e/script/init.rs @@ -0,0 +1,139 @@ +use camino::Utf8PathBuf; +use indoc::{formatdoc, indoc}; +use scarb_api::ScarbCommand; +use snapbox::cmd::{cargo_bin, Command}; +use sncast::helpers::constants::INIT_SCRIPTS_DIR; +use sncast::helpers::scarb_utils::get_cairo_version; +use tempfile::TempDir; + +#[test] +fn test_script_init_happy_case() { + let script_name = "my_script"; + let temp_dir = TempDir::new().expect("Unable to create a temporary directory"); + + let snapbox = Command::new(cargo_bin!("sncast")) + .current_dir(temp_dir.path()) + .args(["script", "init", script_name]); + + snapbox.assert().stdout_matches(formatdoc! {r" + Warning: [..] + command: script init + status: Successfully initialized `{script_name}` at [..]/scripts/{script_name} + "}); + + let script_dir_path = temp_dir.path().join(INIT_SCRIPTS_DIR).join(script_name); + let scarb_toml_path = script_dir_path.join("Scarb.toml"); + + let scarb_toml_content = std::fs::read_to_string(&scarb_toml_path).unwrap(); + let lib_cairo_content = std::fs::read_to_string(script_dir_path.join("src/lib.cairo")).unwrap(); + let main_file_content = + std::fs::read_to_string(script_dir_path.join(format!("src/{script_name}.cairo"))).unwrap(); + + let cast_version = env!("CARGO_PKG_VERSION"); + + let scarb_toml_path = Utf8PathBuf::from_path_buf(scarb_toml_path).unwrap(); + let cairo_version = get_cairo_version(&scarb_toml_path).unwrap(); + + let expected_scarb_toml = formatdoc!( + r#" + [package] + name = "{script_name}" + version = "0.1.0" + edition = [..] + + # See more keys and their definitions at https://docs.swmansion.com/scarb/docs/reference/manifest.html + + [dependencies] + sncast_std = {{ git = "https://github.com/foundry-rs/starknet-foundry", tag = "v{cast_version}" }} + starknet = ">={cairo_version}" + "# + ); + + snapbox::assert_matches(expected_scarb_toml, scarb_toml_content); + + assert_eq!( + lib_cairo_content, + formatdoc! {r#" + mod {script_name}; + "#} + ); + assert_eq!( + main_file_content, + indoc! {r#" + use sncast_std::{call, CallResult}; + + // The example below uses a contract deployed to the Goerli testnet + fn main() { + let contract_address = 0x7ad10abd2cc24c2e066a2fee1e435cd5fa60a37f9268bfbaf2e98ce5ca3c436; + let call_result = call(contract_address.try_into().unwrap(), 'get_greeting', array![]); + assert(*call_result.data[0]=='Hello, Starknet!', *call_result.data[0]); + println!("{:?}", call_result); + } + "#} + ); +} + +#[test] +fn test_init_fails_when_scripts_dir_exists_in_cwd() { + let script_name = "my_script"; + let temp_dir = TempDir::new().expect("Unable to create a temporary directory"); + + std::fs::create_dir_all(temp_dir.path().join(INIT_SCRIPTS_DIR)) + .expect("Failed to create scripts directory in the current temp directory"); + + let snapbox = Command::new(cargo_bin!("sncast")) + .current_dir(temp_dir.path()) + .args(["script", "init", script_name]); + + snapbox.assert().stderr_matches(formatdoc! {r" + command: script init + error: Scripts directory already exists at [..] + "}); +} + +#[test] +fn test_init_twice_fails() { + let script_name = "my_script"; + let temp_dir = TempDir::new().expect("Unable to create a temporary directory"); + + Command::new(cargo_bin!("sncast")) + .current_dir(temp_dir.path()) + .args(["script", "init", script_name]) + .assert() + .success(); + + assert!(temp_dir.path().join(INIT_SCRIPTS_DIR).exists()); + + let snapbox = Command::new(cargo_bin!("sncast")) + .current_dir(temp_dir.path()) + .args(["script", "init", script_name]); + + snapbox.assert().stderr_matches(formatdoc! {r#" + command: script init + error: Scripts directory already exists at [..] + "#}); +} + +#[test] +fn test_initialized_script_compiles() { + let script_name = "my_script"; + let temp_dir = TempDir::new().expect("Unable to create a temporary directory"); + + let snapbox = Command::new(cargo_bin!("sncast")) + .current_dir(temp_dir.path()) + .args(["script", "init", script_name]); + + snapbox.assert().stdout_matches(formatdoc! {r" + Warning: [..] + command: script init + status: Successfully initialized `{script_name}` at [..]/scripts/{script_name} + "}); + + let script_dir_path = temp_dir.path().join(INIT_SCRIPTS_DIR).join(script_name); + + ScarbCommand::new_with_stdio() + .current_dir(script_dir_path) + .arg("build") + .run() + .expect("Failed to compile the initialized script"); +} diff --git a/crates/sncast/tests/e2e/script/mod.rs b/crates/sncast/tests/e2e/script/mod.rs index 49327ab04d..95ed7aee64 100644 --- a/crates/sncast/tests/e2e/script/mod.rs +++ b/crates/sncast/tests/e2e/script/mod.rs @@ -2,4 +2,5 @@ mod call; mod declare; mod deploy; mod general; +mod init; mod invoke; diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index d85fb8ca84..8db55870a9 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -94,7 +94,7 @@ * [new](appendix/sncast/multicall/new.md) * [run](appendix/sncast/multicall/run.md) * [show-config](appendix/sncast/show_config.md) - * [script](appendix/sncast/script.md) + * [script](appendix/sncast/script/script.md) * [`sncast` Library Functions References](appendix/sncast-library.md) * [declare](appendix/sncast-library/declare.md) * [deploy](appendix/sncast-library/deploy.md) diff --git a/docs/src/appendix/sncast/script/init.md b/docs/src/appendix/sncast/script/init.md new file mode 100644 index 0000000000..739b0dfb8e --- /dev/null +++ b/docs/src/appendix/sncast/script/init.md @@ -0,0 +1,18 @@ +# `init` +Create a deployment script template. + +The command creates the following file and directory structure: +``` +. +└── scripts + └── my_script + ├── Scarb.toml + └── src + ├── lib.cairo + └── my_script.cairo +``` + +## `` +Required. + +Name of a script to create. diff --git a/docs/src/appendix/sncast/script.md b/docs/src/appendix/sncast/script/script.md similarity index 54% rename from docs/src/appendix/sncast/script.md rename to docs/src/appendix/sncast/script/script.md index dd0b0bf1b3..48524f5af6 100644 --- a/docs/src/appendix/sncast/script.md +++ b/docs/src/appendix/sncast/script/script.md @@ -1,10 +1,10 @@ # `script` -Compile and run a cairo deployment script. +Compile and run a cairo deployment script or initialize a script template by using subcommand [`init`](./init.md) ## `` Required. -Module name that contains the 'main' function that will be executed. +Script module name that contains the 'main' function that will be executed. ## `--package ` Optional. diff --git a/docs/src/starknet/script.md b/docs/src/starknet/script.md index acd0b5afe4..1abcc44eff 100644 --- a/docs/src/starknet/script.md +++ b/docs/src/starknet/script.md @@ -35,12 +35,25 @@ Some of the planned features that will be included in future versions are: - account creation/deployment - multicall support - dry running the scripts -- init subcommand and more! ## Examples +### Initialize a script + +To get started, a deployment script with all required elements can be initialized using the following command: + +```shell +$ sncast script init my_script +``` + +For more details, see [init command](../appendix/sncast/script/init.md). + +> 📝 **Note** +> To include a newly created script in an existing workspace, it must be manually added to the members list in the `Scarb.toml` file, under the defined workspace. +> For more detailed information about workspaces, please refer to the [Scarb documentation](https://docs.swmansion.com/scarb/docs/reference/workspaces.html). + ### Minimal Example (Without Contract Deployment) This example shows how to call an already deployed contract. Please find full example with contract deployment [here](#full-example-with-contract-deployment).