bolt_cli/
lib.rs

1mod commands;
2mod ephemeral_validator;
3
4mod component;
5mod instructions;
6mod rust_template;
7mod system;
8mod templates;
9mod workspace;
10
11pub use ephemeral_validator::EphemeralValidator;
12
13use crate::component::new_component;
14use crate::instructions::{
15    approve_system, authorize, create_registry, create_world, deauthorize, remove_system,
16};
17use crate::rust_template::{create_component, create_system};
18use crate::system::new_system;
19use anchor_cli::config;
20use anchor_cli::config::{
21    BootstrapMode, Config, ConfigOverride, GenesisEntry, ProgramArch, ProgramDeployment,
22    TestValidator, Validator, WithPath,
23};
24use anchor_client::Cluster;
25use anyhow::{anyhow, Result};
26use clap::{Parser, Subcommand};
27use component::{append_component_to_lib_rs, extract_component_id, generate_component_type_file};
28use heck::{ToKebabCase, ToSnakeCase};
29use std::collections::BTreeMap;
30use std::fs::{self, create_dir_all, File};
31use std::io::Write;
32use std::io::{self, BufRead};
33use std::path::{Path, PathBuf};
34use std::process::Stdio;
35use std::string::ToString;
36pub const VERSION: &str = env!("CARGO_PKG_VERSION");
37pub const ANCHOR_VERSION: &str = anchor_cli::VERSION;
38#[derive(Subcommand)]
39pub enum BoltCommand {
40    #[clap(about = "Create a new component")]
41    Component(ComponentCommand),
42    #[clap(about = "Create a new system")]
43    System(SystemCommand),
44    // Include all existing commands from anchor_cli::Command
45    #[clap(flatten)]
46    Anchor(anchor_cli::Command),
47    #[clap(about = "Add a new registry instance")]
48    Registry(RegistryCommand),
49    #[clap(about = "Add a new world instance")]
50    World(WorldCommand),
51    #[clap(about = "Add a new authority for a world instance")]
52    Authorize(AuthorizeCommand),
53    #[clap(about = "Remove an authority from a world instance")]
54    Deauthorize(DeauthorizeCommand),
55    #[clap(about = "Approve a system for a world instance")]
56    ApproveSystem(ApproveSystemCommand),
57    #[clap(about = "Remove a system from a world instance")]
58    RemoveSystem(RemoveSystemCommand),
59}
60
61#[derive(Debug, Parser)]
62pub struct InitCommand {
63    #[clap(short, long, help = "Workspace name")]
64    pub workspace_name: String,
65}
66
67#[derive(Debug, Parser)]
68pub struct ComponentCommand {
69    pub name: String,
70}
71
72#[derive(Debug, Parser)]
73pub struct SystemCommand {
74    pub name: String,
75}
76
77#[derive(Debug, Parser)]
78pub struct RegistryCommand {}
79
80#[derive(Debug, Parser)]
81pub struct WorldCommand {}
82
83#[derive(Debug, Parser)]
84pub struct AuthorizeCommand {
85    pub world: String,
86    pub new_authority: String,
87}
88
89#[derive(Debug, Parser)]
90pub struct DeauthorizeCommand {
91    pub world: String,
92    pub authority_to_remove: String,
93}
94
95#[derive(Debug, Parser)]
96pub struct ApproveSystemCommand {
97    pub world: String,
98    pub system_to_approve: String,
99}
100
101#[derive(Debug, Parser)]
102pub struct RemoveSystemCommand {
103    pub world: String,
104    pub system_to_remove: String,
105}
106
107#[derive(Parser)]
108#[clap(version = VERSION)]
109pub struct Opts {
110    /// Rebuild the auto-generated types
111    #[clap(global = true, long, action)]
112    pub rebuild_types: bool,
113    #[clap(flatten)]
114    pub cfg_override: ConfigOverride,
115    #[clap(subcommand)]
116    pub command: BoltCommand,
117}
118
119pub async fn entry(opts: Opts) -> Result<()> {
120    match opts.command {
121        BoltCommand::Anchor(command) => match command {
122            anchor_cli::Command::Init {
123                name,
124                javascript,
125                solidity,
126                no_install,
127                no_git,
128                template,
129                test_template,
130                force,
131                ..
132            } => init(
133                &opts.cfg_override,
134                name,
135                javascript,
136                solidity,
137                no_install,
138                no_git,
139                template,
140                test_template,
141                force,
142            ),
143            anchor_cli::Command::Test {
144                skip_local_validator,
145                ..
146            } => commands::test(opts.cfg_override, command, skip_local_validator).await,
147            anchor_cli::Command::Build {
148                idl,
149                no_idl,
150                idl_ts,
151                verifiable,
152                program_name,
153                solana_version,
154                docker_image,
155                bootstrap,
156                cargo_args,
157                env,
158                skip_lint,
159                no_docs,
160                arch,
161            } => build(
162                &opts.cfg_override,
163                no_idl,
164                idl,
165                idl_ts,
166                verifiable,
167                skip_lint,
168                program_name,
169                solana_version,
170                docker_image,
171                bootstrap,
172                None,
173                None,
174                env,
175                cargo_args,
176                no_docs,
177                arch,
178                opts.rebuild_types,
179            ),
180            _ => {
181                let opts = anchor_cli::Opts {
182                    cfg_override: opts.cfg_override,
183                    command,
184                };
185                anchor_cli::entry(opts)
186            }
187        },
188        BoltCommand::Component(command) => new_component(&opts.cfg_override, command.name),
189        BoltCommand::System(command) => new_system(&opts.cfg_override, command.name),
190        BoltCommand::Registry(_command) => create_registry(&opts.cfg_override).await,
191        BoltCommand::World(_command) => create_world(&opts.cfg_override).await,
192        BoltCommand::Authorize(command) => {
193            authorize(&opts.cfg_override, command.world, command.new_authority).await
194        }
195        BoltCommand::Deauthorize(command) => {
196            deauthorize(
197                &opts.cfg_override,
198                command.world,
199                command.authority_to_remove,
200            )
201            .await
202        }
203        BoltCommand::ApproveSystem(command) => {
204            approve_system(&opts.cfg_override, command.world, command.system_to_approve).await
205        }
206        BoltCommand::RemoveSystem(command) => {
207            remove_system(&opts.cfg_override, command.world, command.system_to_remove).await
208        }
209    }
210}
211// Bolt Init
212#[allow(clippy::too_many_arguments)]
213fn init(
214    cfg_override: &ConfigOverride,
215    name: String,
216    javascript: bool,
217    solidity: bool,
218    no_install: bool,
219    no_git: bool,
220    template: anchor_cli::rust_template::ProgramTemplate,
221    test_template: anchor_cli::rust_template::TestTemplate,
222    force: bool,
223) -> Result<()> {
224    if !force && Config::discover(cfg_override)?.is_some() {
225        return Err(anyhow!("Workspace already initialized"));
226    }
227
228    // We need to format different cases for the dir and the name
229    let rust_name = name.to_snake_case();
230    let project_name = if name == rust_name {
231        rust_name.clone()
232    } else {
233        name.to_kebab_case()
234    };
235
236    // Additional keywords that have not been added to the `syn` crate as reserved words
237    // https://github.com/dtolnay/syn/pull/1098
238    let extra_keywords = ["async", "await", "try"];
239    let component_name = "position";
240    let system_name = "movement";
241    // Anchor converts to snake case before writing the program name
242    if syn::parse_str::<syn::Ident>(&rust_name).is_err()
243        || extra_keywords.contains(&rust_name.as_str())
244    {
245        return Err(anyhow!(
246            "Anchor workspace name must be a valid Rust identifier. It may not be a Rust reserved word, start with a digit, or include certain disallowed characters. See https://doc.rust-lang.org/reference/identifiers.html for more detail.",
247        ));
248    }
249
250    if force {
251        fs::create_dir_all(&project_name)?;
252    } else {
253        fs::create_dir(&project_name)?;
254    }
255    std::env::set_current_dir(&project_name)?;
256    fs::create_dir_all("app")?;
257
258    let mut cfg = Config::default();
259    let jest = test_template == anchor_cli::rust_template::TestTemplate::Jest;
260    if jest {
261        cfg.scripts.insert(
262            "test".to_owned(),
263            if javascript {
264                "yarn run jest"
265            } else {
266                "yarn run jest --preset ts-jest"
267            }
268            .to_owned(),
269        );
270    } else {
271        cfg.scripts.insert(
272            "test".to_owned(),
273            if javascript {
274                "yarn run mocha -t 1000000 tests/"
275            } else {
276                "yarn run ts-mocha -p ./tsconfig.json -t 1000000 tests/**/*.ts"
277            }
278            .to_owned(),
279        );
280    }
281
282    let mut localnet = BTreeMap::new();
283    let program_id = anchor_cli::rust_template::get_or_create_program_id(&rust_name);
284    localnet.insert(
285        rust_name,
286        ProgramDeployment {
287            address: program_id,
288            path: None,
289            idl: None,
290        },
291    );
292    if !solidity {
293        let component_id = anchor_cli::rust_template::get_or_create_program_id(component_name);
294        let system_id = anchor_cli::rust_template::get_or_create_program_id(system_name);
295        localnet.insert(
296            component_name.to_owned(),
297            ProgramDeployment {
298                address: component_id,
299                path: None,
300                idl: None,
301            },
302        );
303        localnet.insert(
304            system_name.to_owned(),
305            ProgramDeployment {
306                address: system_id,
307                path: None,
308                idl: None,
309            },
310        );
311        cfg.workspace.members.push("programs/*".to_owned());
312        cfg.workspace
313            .members
314            .push("programs-ecs/components/*".to_owned());
315        cfg.workspace
316            .members
317            .push("programs-ecs/systems/*".to_owned());
318    }
319
320    // Setup the test validator to clone Bolt programs from devnet
321    let validator = Validator {
322        url: Some("https://rpc.magicblock.app/devnet/".to_owned()),
323        rpc_port: 8899,
324        bind_address: "0.0.0.0".to_owned(),
325        ledger: ".bolt/test-ledger".to_owned(),
326        account: Some(vec![
327            // Registry account
328            anchor_cli::config::AccountEntry {
329                address: "EHLkWwAT9oebVv9ht3mtqrvHhRVMKrt54tF3MfHTey2K".to_owned(),
330                filename: "tests/fixtures/registry.json".to_owned(),
331            },
332        ]),
333        ..Default::default()
334    };
335
336    let test_validator = TestValidator {
337        startup_wait: 5000,
338        shutdown_wait: 2000,
339        validator: Some(validator),
340        genesis: Some(vec![GenesisEntry {
341            address: world::id().to_string(),
342            program: "tests/fixtures/world.so".to_owned(),
343            upgradeable: Some(false),
344        }]),
345        ..Default::default()
346    };
347
348    cfg.test_validator = Some(test_validator);
349    cfg.programs.insert(Cluster::Localnet, localnet);
350    let toml = cfg.to_string();
351    fs::write("Anchor.toml", toml)?;
352
353    // Initialize .gitignore file
354    fs::write(".gitignore", templates::workspace::git_ignore())?;
355
356    // Initialize .prettierignore file
357    fs::write(".prettierignore", templates::workspace::prettier_ignore())?;
358
359    // Remove the default programs if `--force` is passed
360    if force {
361        let programs_path = std::env::current_dir()?
362            .join(if solidity { "solidity" } else { "programs" })
363            .join(&project_name);
364        fs::create_dir_all(&programs_path)?;
365        fs::remove_dir_all(&programs_path)?;
366        let programs_ecs_path = std::env::current_dir()?
367            .join("programs-ecs")
368            .join(&project_name);
369        fs::create_dir_all(&programs_ecs_path)?;
370        fs::remove_dir_all(&programs_ecs_path)?;
371    }
372
373    // Build the program.
374    if solidity {
375        anchor_cli::solidity_template::create_program(&project_name)?;
376    } else {
377        create_system(system_name)?;
378        create_component(component_name)?;
379        rust_template::create_program(&project_name, template)?;
380
381        // Add the component as a dependency to the system
382        std::process::Command::new("cargo")
383            .arg("add")
384            .arg("--package")
385            .arg(system_name)
386            .arg("--path")
387            .arg(format!("programs-ecs/components/{}", component_name))
388            .arg("--features")
389            .arg("cpi")
390            .stdout(std::process::Stdio::null())
391            .stderr(std::process::Stdio::null())
392            .spawn()
393            .map_err(|e| {
394                anyhow::format_err!(
395                    "error adding component as dependency to the system: {}",
396                    e.to_string()
397                )
398            })?;
399    }
400
401    // Build the test suite.
402    fs::create_dir_all("tests/fixtures")?;
403    // Build the migrations directory.
404    fs::create_dir_all("migrations")?;
405
406    // Create the registry account
407    fs::write(
408        "tests/fixtures/registry.json",
409        rust_template::registry_account(),
410    )?;
411
412    // Dump the World program into tests/fixtures/world.so
413    std::process::Command::new("solana")
414        .arg("program")
415        .arg("dump")
416        .arg("-u")
417        .arg("d")
418        .arg(world::id().to_string())
419        .arg("tests/fixtures/world.so")
420        .stdout(Stdio::inherit())
421        .stderr(Stdio::inherit())
422        .spawn()
423        .map_err(|e| anyhow::format_err!("solana program dump failed: {}", e.to_string()))?;
424
425    if javascript {
426        // Build javascript config
427        let mut package_json = File::create("package.json")?;
428        package_json.write_all(templates::workspace::package_json(jest).as_bytes())?;
429
430        if jest {
431            let mut test = File::create(format!("tests/{}.test.js", &project_name))?;
432            if solidity {
433                test.write_all(anchor_cli::solidity_template::jest(&project_name).as_bytes())?;
434            } else {
435                test.write_all(templates::workspace::jest(&project_name).as_bytes())?;
436            }
437        } else {
438            let mut test = File::create(format!("tests/{}.js", &project_name))?;
439            if solidity {
440                test.write_all(anchor_cli::solidity_template::mocha(&project_name).as_bytes())?;
441            } else {
442                test.write_all(templates::workspace::mocha(&project_name).as_bytes())?;
443            }
444        }
445
446        let mut deploy = File::create("migrations/deploy.js")?;
447
448        deploy.write_all(anchor_cli::rust_template::deploy_script().as_bytes())?;
449    } else {
450        // Build typescript config
451        let mut ts_config = File::create("tsconfig.json")?;
452        ts_config.write_all(anchor_cli::rust_template::ts_config(jest).as_bytes())?;
453
454        let mut ts_package_json = File::create("package.json")?;
455        ts_package_json.write_all(templates::workspace::ts_package_json(jest).as_bytes())?;
456
457        let mut deploy = File::create("migrations/deploy.ts")?;
458        deploy.write_all(anchor_cli::rust_template::ts_deploy_script().as_bytes())?;
459
460        let mut mocha = File::create(format!("tests/{}.ts", &project_name))?;
461        if solidity {
462            mocha.write_all(anchor_cli::solidity_template::ts_mocha(&project_name).as_bytes())?;
463        } else {
464            mocha.write_all(templates::workspace::ts_mocha(&project_name).as_bytes())?;
465        }
466    }
467
468    if !no_install {
469        let yarn_result = install_node_modules("yarn")?;
470        if !yarn_result.status.success() {
471            println!("Failed yarn install will attempt to npm install");
472            install_node_modules("npm")?;
473        }
474    }
475
476    if !no_git {
477        let git_result = std::process::Command::new("git")
478            .arg("init")
479            .stdout(Stdio::inherit())
480            .stderr(Stdio::inherit())
481            .output()
482            .map_err(|e| anyhow::format_err!("git init failed: {}", e.to_string()))?;
483        if !git_result.status.success() {
484            eprintln!("Failed to automatically initialize a new git repository");
485        }
486    }
487
488    println!("{project_name} initialized");
489
490    Ok(())
491}
492
493#[allow(clippy::too_many_arguments)]
494pub fn build(
495    cfg_override: &ConfigOverride,
496    no_idl: bool,
497    idl: Option<String>,
498    idl_ts: Option<String>,
499    verifiable: bool,
500    skip_lint: bool,
501    program_name: Option<String>,
502    solana_version: Option<String>,
503    docker_image: Option<String>,
504    bootstrap: BootstrapMode,
505    stdout: Option<File>,
506    stderr: Option<File>,
507    env_vars: Vec<String>,
508    cargo_args: Vec<String>,
509    no_docs: bool,
510    arch: ProgramArch,
511    rebuild_types: bool,
512) -> Result<()> {
513    let cfg = Config::discover(cfg_override)?.expect("Not in workspace.");
514    let types_path = "crates/types/src";
515
516    // If rebuild_types is true and the types directory exists, remove it
517    if rebuild_types && Path::new(types_path).exists() {
518        fs::remove_dir_all(
519            PathBuf::from(types_path)
520                .parent()
521                .ok_or_else(|| anyhow::format_err!("Failed to remove types directory"))?,
522        )?;
523    }
524    create_dir_all(types_path)?;
525    build_dynamic_types(cfg, cfg_override, types_path)?;
526
527    // Build the programs
528    anchor_cli::build(
529        cfg_override,
530        no_idl,
531        idl,
532        idl_ts,
533        verifiable,
534        skip_lint,
535        program_name,
536        solana_version,
537        docker_image,
538        bootstrap,
539        stdout,
540        stderr,
541        env_vars,
542        cargo_args,
543        no_docs,
544        arch,
545    )
546}
547
548// Install node modules
549fn install_node_modules(cmd: &str) -> Result<std::process::Output> {
550    let mut command = std::process::Command::new(if cfg!(target_os = "windows") {
551        "cmd"
552    } else {
553        cmd
554    });
555    if cfg!(target_os = "windows") {
556        command.arg(format!("/C {} install", cmd));
557    } else {
558        command.arg("install");
559    }
560    command
561        .stdout(Stdio::inherit())
562        .stderr(Stdio::inherit())
563        .output()
564        .map_err(|e| anyhow::format_err!("{} install failed: {}", cmd, e.to_string()))
565}
566
567fn discover_cluster_url(cfg_override: &ConfigOverride) -> Result<String> {
568    let url = match Config::discover(cfg_override)? {
569        Some(cfg) => cluster_url(&cfg, &cfg.test_validator),
570        None => {
571            if let Some(cluster) = cfg_override.cluster.clone() {
572                cluster.url().to_string()
573            } else {
574                config::get_solana_cfg_url()?
575            }
576        }
577    };
578    Ok(url)
579}
580
581fn cluster_url(cfg: &Config, test_validator: &Option<TestValidator>) -> String {
582    let is_localnet = cfg.provider.cluster == Cluster::Localnet;
583    match is_localnet {
584        // Cluster is Localnet, assume the intent is to use the configuration
585        // for solana-test-validator
586        true => test_validator_rpc_url(test_validator),
587        false => cfg.provider.cluster.url().to_string(),
588    }
589}
590
591// Return the URL that solana-test-validator should be running on given the
592// configuration
593fn test_validator_rpc_url(test_validator: &Option<TestValidator>) -> String {
594    match test_validator {
595        Some(TestValidator {
596            validator: Some(validator),
597            ..
598        }) => format!("http://{}:{}", validator.bind_address, validator.rpc_port),
599        _ => "http://127.0.0.1:8899".to_string(),
600    }
601}
602
603fn build_dynamic_types(
604    cfg: WithPath<Config>,
605    cfg_override: &ConfigOverride,
606    types_path: &str,
607) -> Result<()> {
608    let cur_dir = std::env::current_dir()?;
609    for p in cfg.get_rust_program_list()? {
610        process_program_path(&p, cfg_override, types_path)?;
611    }
612    let types_path = PathBuf::from(types_path);
613    let cargo_path = types_path
614        .parent()
615        .unwrap_or(&types_path)
616        .join("Cargo.toml");
617    if !cargo_path.exists() {
618        let mut file = File::create(cargo_path)?;
619        file.write_all(templates::workspace::types_cargo_toml().as_bytes())?;
620    }
621    std::env::set_current_dir(cur_dir)?;
622    Ok(())
623}
624
625fn process_program_path(
626    program_path: &Path,
627    cfg_override: &ConfigOverride,
628    types_path: &str,
629) -> Result<()> {
630    let lib_rs_path = Path::new(types_path).join("lib.rs");
631    let file = File::open(program_path.join("src").join("lib.rs"))?;
632    let lines = io::BufReader::new(file).lines();
633    let mut contains_dynamic_components = false;
634    for line in lines.map_while(Result::ok) {
635        if let Some(component_id) = extract_component_id(&line) {
636            let file_path = PathBuf::from(format!("{}/component_{}.rs", types_path, component_id));
637            if !file_path.exists() {
638                println!("Generating type for Component: {}", component_id);
639                generate_component_type_file(&file_path, cfg_override, component_id)?;
640                append_component_to_lib_rs(&lib_rs_path, component_id)?;
641            }
642            contains_dynamic_components = true;
643        }
644    }
645    if contains_dynamic_components {
646        let program_name = program_path.file_name().unwrap().to_str().unwrap();
647        add_types_crate_dependency(program_name, &types_path.replace("/src", ""))?;
648    }
649
650    Ok(())
651}
652
653fn add_types_crate_dependency(program_name: &str, types_path: &str) -> Result<()> {
654    std::process::Command::new("cargo")
655        .arg("add")
656        .arg("--package")
657        .arg(program_name)
658        .arg("--path")
659        .arg(types_path)
660        .stdout(Stdio::null())
661        .stderr(Stdio::null())
662        .spawn()
663        .map_err(|e| {
664            anyhow::format_err!(
665                "error adding types as dependency to the program: {}",
666                e.to_string()
667            )
668        })?;
669    Ok(())
670}