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";
#[derive(Parser, Debug, Clone)]
pub struct Cmd {
pub project_path: PathBuf,
#[command(flatten)]
vers: Vers,
}
#[derive(Args, Debug, Clone)]
#[group(multiple = false)]
struct Vers {
#[arg(long, default_value_t = false)]
pub tutorial: bool,
#[arg(long)]
pub tag: Option<String>,
}
#[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 {
pub async fn run(&self, global_args: &global::Args) -> Result<(), Error> {
let printer: Print = Print::new(global_args.quiet);
let absolute_project_path = self.project_path.canonicalize().unwrap_or_else(|_| {
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()
)));
}
let example_path = absolute_project_path.join(".env.example");
let env_path = absolute_project_path.join(".env");
copy(example_path, env_path)?;
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?;
}
}
let npm_status = npm_install(&absolute_project_path, &printer);
printer.infoln("Building contracts and generating client code...");
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_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(())
}
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;
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(())
}
}
fn npm_exists() -> bool {
Command::new("npm").arg("--version").output().is_ok()
}
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) => {
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
}
}
}
fn git_exists() -> bool {
Command::new("git").arg("--version").output().is_ok()
}
fn git_init(path: &PathBuf) {
let _ = Command::new("git").arg("init").current_dir(path).output();
}
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();
}
fn git_commit(path: &PathBuf, message: &str) {
let _ = Command::new("git")
.args(["commit", "-m", message])
.current_dir(path)
.output();
}