Skip to main content

stellar_scaffold_cli/commands/
init.rs

1use clap::{Args, Parser};
2use degit::degit;
3use std::fs::{copy, metadata, read_dir, remove_dir_all};
4use std::path::PathBuf;
5use std::process::Command;
6use std::{env, io};
7
8use super::{build, generate};
9use stellar_cli::{commands::global, print::Print};
10
11pub const FRONTEND_TEMPLATE: &str = "theahaco/scaffold-stellar-frontend";
12const TUTORIAL_BRANCH: &str = "tutorial";
13
14/// A command to initialize a new project
15#[derive(Parser, Debug, Clone)]
16pub struct Cmd {
17    /// The path to the project must be provided
18    pub project_path: PathBuf,
19
20    #[command(flatten)]
21    vers: Vers,
22}
23
24#[derive(Args, Debug, Clone)]
25#[group(multiple = false)]
26struct Vers {
27    /// Initialize the tutorial project instead of the default project
28    #[arg(long, default_value_t = false)]
29    pub tutorial: bool,
30
31    /// Optional argument to specify a tagged version
32    #[arg(long)]
33    pub tag: Option<String>,
34}
35
36/// Errors that can occur during initialization
37#[derive(thiserror::Error, Debug)]
38pub enum Error {
39    #[error("Failed to clone template: {0}")]
40    DegitError(String),
41    #[error("Project path contains invalid UTF-8 characters and cannot be converted to a string")]
42    InvalidProjectPathEncoding,
43    #[error("IO error: {0}")]
44    IoError(#[from] io::Error),
45    #[error(transparent)]
46    GenerateError(#[from] generate::contract::Error),
47}
48
49impl Cmd {
50    /// Run the initialization command
51    ///
52    /// # Example:
53    ///
54    /// ```
55    /// /// From the command line
56    /// stellar scaffold init /path/to/project
57    /// ```
58    pub async fn run(&self, global_args: &global::Args) -> Result<(), Error> {
59        let printer: Print = Print::new(global_args.quiet);
60
61        // Convert to absolute path to avoid issues when changing directories
62        let absolute_project_path = self.project_path.canonicalize().unwrap_or_else(|_| {
63            // If canonicalize fails (path doesn't exist yet), manually create absolute path
64            if self.project_path.is_absolute() {
65                self.project_path.clone()
66            } else {
67                env::current_dir()
68                    .unwrap_or_default()
69                    .join(&self.project_path)
70            }
71        });
72
73        printer.infoln(format!(
74            "Creating new Stellar project in {}",
75            absolute_project_path.display()
76        ));
77
78        let project_str = absolute_project_path
79            .to_str()
80            .ok_or(Error::InvalidProjectPathEncoding)?
81            .to_owned();
82
83        let mut repo = FRONTEND_TEMPLATE.to_string();
84        if let Some(tag) = self.vers.tag.as_deref() {
85            repo = format!("{repo}#{tag}");
86        } else if self.vers.tutorial {
87            repo = format!("{repo}#{TUTORIAL_BRANCH}");
88        }
89        tokio::task::spawn_blocking(move || {
90            degit(repo.as_str(), &project_str);
91        })
92        .await
93        .expect("Blocking task panicked");
94
95        if metadata(&absolute_project_path).is_err()
96            || read_dir(&absolute_project_path)?.next().is_none()
97        {
98            return Err(Error::DegitError(format!(
99                "Failed to clone template into {}: directory is empty or missing",
100                absolute_project_path.display()
101            )));
102        }
103
104        // Copy .env.example to .env
105        let example_path = absolute_project_path.join(".env.example");
106        let env_path = absolute_project_path.join(".env");
107        copy(example_path, env_path)?;
108
109        // Update the project's OpenZeppelin examples with the latest editions
110        if !self.vers.tutorial {
111            let example_contracts = ["oz/nft-enumerable", "oz/fungible-allowlist"];
112
113            for contract in example_contracts {
114                self.update_oz_example(&absolute_project_path, contract, global_args)
115                    .await?;
116            }
117        }
118
119        // Install npm dependencies
120        let npm_status = npm_install(&absolute_project_path, &printer);
121
122        // Build contracts and create contract clients
123        printer.infoln("Building contracts and generating client code...");
124        // Use clap to parse build command with defaults, then configure programmatically
125        let mut build_command = build::Command::parse_from(["build", "--build-clients"]);
126        build_command.build.manifest_path = Some(absolute_project_path.join("Cargo.toml"));
127        build_command.build_clients_args.env = Some(build::clients::ScaffoldEnv::Development);
128        build_command.build_clients_args.workspace_root = Some(absolute_project_path.clone());
129        let mut build_args = global_args.clone();
130        if !(global_args.verbose && global_args.very_verbose) {
131            build_args.quiet = true;
132        }
133
134        if let Err(e) = build_command.run(&build_args).await {
135            printer.warnln(format!("Failed to build contract clients: {e}"));
136        }
137
138        // If git is installed, run init and make initial commit
139        if git_exists() {
140            git_init(&absolute_project_path);
141            git_add(&absolute_project_path, &["-A"]);
142            git_commit(&absolute_project_path, "initial commit");
143        }
144
145        printer.blankln("\n\n");
146        printer.checkln(format!(
147            "Project successfully created at {}!",
148            absolute_project_path.display()
149        ));
150        printer.blankln(" You can now run the application with:\n");
151        printer.blankln(format!("\tcd {}", self.project_path.display()));
152        if !npm_status {
153            printer.blankln("\tnpm install");
154        }
155        printer.blankln("\tnpm start\n");
156        printer.blankln(" Happy hacking! 🚀");
157        Ok(())
158    }
159
160    /// Updates the project with an Open Zeppelin example contract
161    ///
162    /// This method attempts to generate a contract from Open Zeppelin
163    /// and prints a warning if it can't be found or generated.
164    async fn update_oz_example(
165        &self,
166        absolute_project_path: &PathBuf,
167        example_name: &str,
168        global_args: &global::Args,
169    ) -> Result<(), Error> {
170        let mut example_path = example_name;
171        if example_name.starts_with("oz/") {
172            (_, example_path) = example_name.split_at(3);
173        }
174
175        let printer = Print::new(global_args.quiet);
176        let original_dir = env::current_dir()?;
177        env::set_current_dir(absolute_project_path)?;
178
179        let all_contracts_path = absolute_project_path.join("contracts");
180        let existing_contract_path = all_contracts_path.join(example_path);
181
182        if existing_contract_path.exists() {
183            remove_dir_all(&existing_contract_path)?;
184        }
185
186        let mut quiet_global_args = global_args.clone();
187        quiet_global_args.quiet = false;
188
189        let result = generate::contract::Cmd {
190            from: Some(example_name.to_owned()),
191            ls: false,
192            from_wizard: false,
193            output: Some(all_contracts_path.join(example_path)),
194            force: false,
195        }
196        .run(&quiet_global_args)
197        .await;
198
199        // Restore directory before handling result
200        let _ = env::set_current_dir(original_dir);
201
202        match result {
203            Ok(()) => {
204                printer.infoln(format!(
205                    "Successfully added OpenZeppelin example contract: {example_path}"
206                ));
207            }
208            Err(generate::contract::Error::OzExampleNotFound(_)) => {
209                printer.infoln(format!(
210                    "Skipped missing OpenZeppelin example contract: {example_path}"
211                ));
212            }
213            Err(e) => {
214                printer.warnln(format!(
215                    "Failed to generate example contract: {example_path}\n{e}"
216                ));
217            }
218        }
219
220        Ok(())
221    }
222}
223
224// Check if npm is installed and exists in PATH
225fn npm_exists() -> bool {
226    Command::new("npm").arg("--version").output().is_ok()
227}
228
229// Install npm dependencies
230fn npm_install(path: &PathBuf, printer: &Print) -> bool {
231    if !npm_exists() {
232        printer.warnln("Failed to install dependencies, npm is not installed");
233        return false;
234    }
235
236    printer.infoln("Installing npm dependencies...");
237    match Command::new("npm")
238        .arg("install")
239        .current_dir(path)
240        .output()
241    {
242        Ok(output) if output.status.success() => true,
243        Ok(output) => {
244            // Command ran without panic, but failed for some other reason
245            // like network issue or missing dependency, etc.
246            printer.warnln("Failed to install dependencies: Please run 'npm install' manually");
247            if !output.stderr.is_empty()
248                && let Ok(stderr) = String::from_utf8(output.stderr)
249            {
250                printer.warnln(format!("Error: {}", stderr.trim()));
251            }
252            false
253        }
254        Err(e) => {
255            printer.warnln(format!("Failed to run npm install: {e}"));
256            false
257        }
258    }
259}
260
261// Check if git is installed and exists in PATH
262fn git_exists() -> bool {
263    Command::new("git").arg("--version").output().is_ok()
264}
265
266// Initialize a new git repository
267fn git_init(path: &PathBuf) {
268    let _ = Command::new("git").arg("init").current_dir(path).output();
269}
270
271// Stage files for commit
272fn git_add(path: &PathBuf, rest: &[&str]) {
273    let mut args = vec!["add"];
274    args.extend_from_slice(rest);
275    let _ = Command::new("git").args(args).current_dir(path).output();
276}
277
278// Commit with message
279fn git_commit(path: &PathBuf, message: &str) {
280    let _ = Command::new("git")
281        .args(["commit", "-m", message])
282        .current_dir(path)
283        .output();
284}