stellar-scaffold-cli 0.0.23

Stellar CLI plugin for building smart contracts with frontend support
Documentation
use clap::{Args, Parser};
use degit::degit;
use std::fs::{copy, metadata, read_dir, remove_dir_all};
use std::path::PathBuf;
use std::process::Command;
use std::{env, io};

use super::{build, generate};
use stellar_cli::{commands::global, print::Print};

pub const FRONTEND_TEMPLATE: &str = "theahaco/scaffold-stellar-frontend";
const TUTORIAL_BRANCH: &str = "tutorial";

/// A command to initialize a new project
#[derive(Parser, Debug, Clone)]
pub struct Cmd {
    /// The path to the project must be provided
    pub project_path: PathBuf,

    #[command(flatten)]
    vers: Vers,
}

#[derive(Args, Debug, Clone)]
#[group(multiple = false)]
struct Vers {
    /// Initialize the tutorial project instead of the default project
    #[arg(long, default_value_t = false)]
    pub tutorial: bool,

    /// Optional argument to specify a tagged version
    #[arg(long)]
    pub tag: Option<String>,
}

/// Errors that can occur during initialization
#[derive(thiserror::Error, Debug)]
pub enum Error {
    #[error("Failed to clone template: {0}")]
    DegitError(String),
    #[error("Project path contains invalid UTF-8 characters and cannot be converted to a string")]
    InvalidProjectPathEncoding,
    #[error("IO error: {0}")]
    IoError(#[from] io::Error),
    #[error(transparent)]
    GenerateError(#[from] generate::contract::Error),
}

impl Cmd {
    /// Run the initialization command
    ///
    /// # Example:
    ///
    /// ```
    /// /// From the command line
    /// stellar scaffold init /path/to/project
    /// ```
    pub async fn run(&self, global_args: &global::Args) -> Result<(), Error> {
        let printer: Print = Print::new(global_args.quiet);

        // Convert to absolute path to avoid issues when changing directories
        let absolute_project_path = self.project_path.canonicalize().unwrap_or_else(|_| {
            // If canonicalize fails (path doesn't exist yet), manually create absolute path
            if self.project_path.is_absolute() {
                self.project_path.clone()
            } else {
                env::current_dir()
                    .unwrap_or_default()
                    .join(&self.project_path)
            }
        });

        printer.infoln(format!(
            "Creating new Stellar project in {}",
            absolute_project_path.display()
        ));

        let project_str = absolute_project_path
            .to_str()
            .ok_or(Error::InvalidProjectPathEncoding)?
            .to_owned();

        let mut repo = FRONTEND_TEMPLATE.to_string();
        if let Some(tag) = self.vers.tag.as_deref() {
            repo = format!("{repo}#{tag}");
        } else if self.vers.tutorial {
            repo = format!("{repo}#{TUTORIAL_BRANCH}");
        }
        tokio::task::spawn_blocking(move || {
            degit(repo.as_str(), &project_str);
        })
        .await
        .expect("Blocking task panicked");

        if metadata(&absolute_project_path).is_err()
            || read_dir(&absolute_project_path)?.next().is_none()
        {
            return Err(Error::DegitError(format!(
                "Failed to clone template into {}: directory is empty or missing",
                absolute_project_path.display()
            )));
        }

        // Copy .env.example to .env
        let example_path = absolute_project_path.join(".env.example");
        let env_path = absolute_project_path.join(".env");
        copy(example_path, env_path)?;

        // Update the project's OpenZeppelin examples with the latest editions
        if !self.vers.tutorial {
            let example_contracts = ["oz/nft-enumerable", "oz/fungible-allowlist"];

            for contract in example_contracts {
                self.update_oz_example(&absolute_project_path, contract, global_args)
                    .await?;
            }
        }

        // Install npm dependencies
        let npm_status = npm_install(&absolute_project_path, &printer);

        // Build contracts and create contract clients
        printer.infoln("Building contracts and generating client code...");
        // Use clap to parse build command with defaults, then configure programmatically
        let mut build_command = build::Command::parse_from(["build", "--build-clients"]);
        build_command.build.manifest_path = Some(absolute_project_path.join("Cargo.toml"));
        build_command.build_clients_args.env = Some(build::clients::ScaffoldEnv::Development);
        build_command.build_clients_args.workspace_root = Some(absolute_project_path.clone());
        let mut build_args = global_args.clone();
        if !(global_args.verbose && global_args.very_verbose) {
            build_args.quiet = true;
        }

        if let Err(e) = build_command.run(&build_args).await {
            printer.warnln(format!("Failed to build contract clients: {e}"));
        }

        // If git is installed, run init and make initial commit
        if git_exists() {
            git_init(&absolute_project_path);
            git_add(&absolute_project_path, &["-A"]);
            git_commit(&absolute_project_path, "initial commit");
        }

        printer.blankln("\n\n");
        printer.checkln(format!(
            "Project successfully created at {}!",
            absolute_project_path.display()
        ));
        printer.blankln(" You can now run the application with:\n");
        printer.blankln(format!("\tcd {}", self.project_path.display()));
        if !npm_status {
            printer.blankln("\tnpm install");
        }
        printer.blankln("\tnpm start\n");
        printer.blankln(" Happy hacking! 🚀");
        Ok(())
    }

    /// Updates the project with an Open Zeppelin example contract
    ///
    /// This method attempts to generate a contract from Open Zeppelin
    /// and prints a warning if it can't be found or generated.
    async fn update_oz_example(
        &self,
        absolute_project_path: &PathBuf,
        example_name: &str,
        global_args: &global::Args,
    ) -> Result<(), Error> {
        let mut example_path = example_name;
        if example_name.starts_with("oz/") {
            (_, example_path) = example_name.split_at(3);
        }

        let printer = Print::new(global_args.quiet);
        let original_dir = env::current_dir()?;
        env::set_current_dir(absolute_project_path)?;

        let all_contracts_path = absolute_project_path.join("contracts");
        let existing_contract_path = all_contracts_path.join(example_path);

        if existing_contract_path.exists() {
            remove_dir_all(&existing_contract_path)?;
        }

        let mut quiet_global_args = global_args.clone();
        quiet_global_args.quiet = false;

        let result = generate::contract::Cmd {
            from: Some(example_name.to_owned()),
            ls: false,
            from_wizard: false,
            output: Some(all_contracts_path.join(example_path)),
            force: false,
        }
        .run(&quiet_global_args)
        .await;

        // Restore directory before handling result
        let _ = env::set_current_dir(original_dir);

        match result {
            Ok(()) => {
                printer.infoln(format!(
                    "Successfully added OpenZeppelin example contract: {example_path}"
                ));
            }
            Err(generate::contract::Error::OzExampleNotFound(_)) => {
                printer.infoln(format!(
                    "Skipped missing OpenZeppelin example contract: {example_path}"
                ));
            }
            Err(e) => {
                printer.warnln(format!(
                    "Failed to generate example contract: {example_path}\n{e}"
                ));
            }
        }

        Ok(())
    }
}

// Check if npm is installed and exists in PATH
fn npm_exists() -> bool {
    Command::new("npm").arg("--version").output().is_ok()
}

// Install npm dependencies
fn npm_install(path: &PathBuf, printer: &Print) -> bool {
    if !npm_exists() {
        printer.warnln("Failed to install dependencies, npm is not installed");
        return false;
    }

    printer.infoln("Installing npm dependencies...");
    match Command::new("npm")
        .arg("install")
        .current_dir(path)
        .output()
    {
        Ok(output) if output.status.success() => true,
        Ok(output) => {
            // Command ran without panic, but failed for some other reason
            // like network issue or missing dependency, etc.
            printer.warnln("Failed to install dependencies: Please run 'npm install' manually");
            if !output.stderr.is_empty()
                && let Ok(stderr) = String::from_utf8(output.stderr)
            {
                printer.warnln(format!("Error: {}", stderr.trim()));
            }
            false
        }
        Err(e) => {
            printer.warnln(format!("Failed to run npm install: {e}"));
            false
        }
    }
}

// Check if git is installed and exists in PATH
fn git_exists() -> bool {
    Command::new("git").arg("--version").output().is_ok()
}

// Initialize a new git repository
fn git_init(path: &PathBuf) {
    let _ = Command::new("git").arg("init").current_dir(path).output();
}

// Stage files for commit
fn git_add(path: &PathBuf, rest: &[&str]) {
    let mut args = vec!["add"];
    args.extend_from_slice(rest);
    let _ = Command::new("git").args(args).current_dir(path).output();
}

// Commit with message
fn git_commit(path: &PathBuf, message: &str) {
    let _ = Command::new("git")
        .args(["commit", "-m", message])
        .current_dir(path)
        .output();
}