stellar_scaffold_cli/commands/
init.rs

1use clap::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
11const FRONTEND_TEMPLATE: &str = "https://github.com/theahaco/scaffold-stellar-frontend";
12
13/// A command to initialize a new project
14#[derive(Parser, Debug, Clone)]
15pub struct Cmd {
16    /// The path to the project must be provided
17    pub project_path: PathBuf,
18}
19
20/// Errors that can occur during initialization
21#[derive(thiserror::Error, Debug)]
22pub enum Error {
23    #[error("Failed to clone template: {0}")]
24    DegitError(String),
25    #[error("Project path contains invalid UTF-8 characters and cannot be converted to a string")]
26    InvalidProjectPathEncoding,
27    #[error("IO error: {0}")]
28    IoError(#[from] io::Error),
29    #[error(transparent)]
30    GenerateError(#[from] generate::contract::Error),
31}
32
33impl Cmd {
34    /// Run the initialization command
35    ///
36    /// # Example:
37    ///
38    /// ```
39    /// /// From the command line
40    /// stellar scaffold init /path/to/project
41    /// ```
42    pub async fn run(&self, global_args: &global::Args) -> Result<(), Error> {
43        let printer: Print = Print::new(global_args.quiet);
44
45        // Convert to absolute path to avoid issues when changing directories
46        let absolute_project_path = self.project_path.canonicalize().unwrap_or_else(|_| {
47            // If canonicalize fails (path doesn't exist yet), manually create absolute path
48            if self.project_path.is_absolute() {
49                self.project_path.clone()
50            } else {
51                env::current_dir()
52                    .unwrap_or_default()
53                    .join(&self.project_path)
54            }
55        });
56
57        printer.infoln(format!(
58            "Creating new Stellar project in {}",
59            absolute_project_path.display()
60        ));
61
62        let project_str = absolute_project_path
63            .to_str()
64            .ok_or(Error::InvalidProjectPathEncoding)?;
65
66        degit(FRONTEND_TEMPLATE, project_str);
67
68        if metadata(&absolute_project_path).is_err()
69            || read_dir(&absolute_project_path)?.next().is_none()
70        {
71            return Err(Error::DegitError(format!(
72                "Failed to clone template into {project_str}: directory is empty or missing",
73            )));
74        }
75
76        // Copy .env.example to .env
77        let example_path = absolute_project_path.join(".env.example");
78        let env_path = absolute_project_path.join(".env");
79        copy(example_path, env_path)?;
80
81        // If git is installed, run init and make initial commit
82        if git_exists() {
83            git_init(&absolute_project_path);
84            git_add(&absolute_project_path, &["-A"]);
85            git_commit(&absolute_project_path, "initial commit");
86        }
87
88        // Update the project's OpenZeppelin examples with the latest editions
89        let example_contracts = ["nft-enumerable", "fungible-allowlist"];
90
91        for contract in example_contracts {
92            self.update_oz_example(&absolute_project_path, contract, global_args)
93                .await?;
94        }
95
96        // Install npm dependencies
97        printer.infoln("Installing npm dependencies...");
98        let npm_install_command = Command::new("npm")
99            .arg("install")
100            .current_dir(&absolute_project_path)
101            .output()?;
102        if !npm_install_command.status.success() {
103            printer.warnln(
104                "Failed to install dependencies, run 'npm install' in the project directory",
105            );
106        }
107
108        // Build contracts and create contract clients
109        printer.infoln("Building contracts and generating client code...");
110        // Use clap to parse build command with defaults, then configure programmatically
111        let mut build_command = build::Command::parse_from(["build", "--build-clients"]);
112        build_command.build.manifest_path = Some(absolute_project_path.join("Cargo.toml"));
113        build_command.build_clients_args.env = Some(build::clients::ScaffoldEnv::Development);
114        build_command.build_clients_args.workspace_root = Some(absolute_project_path.clone());
115        let mut build_args = global_args.clone();
116        if !(global_args.verbose && global_args.very_verbose) {
117            build_args.quiet = true;
118        }
119
120        if let Err(e) = build_command.run(&build_args).await {
121            printer.warnln(format!("Failed to build contract clients: {e}"));
122        }
123
124        printer.blankln("\n\n");
125        printer.checkln(format!("Project successfully created at {project_str}"));
126        printer.blankln(" You can now run the application with:\n");
127        printer.blankln(format!("\tcd {}", self.project_path.display()));
128        if !npm_install_command.status.success() {
129            printer.blankln("\tnpm install");
130        }
131        printer.blankln("\tnpm start\n");
132        printer.blankln(" Happy hacking! 🚀");
133        Ok(())
134    }
135
136    /// Updates the project with an Open Zeppelin example contract
137    ///
138    /// This method attempts to generate a contract from Open Zeppelin
139    /// and prints a warning if it can't be found or generated.
140    async fn update_oz_example(
141        &self,
142        absolute_project_path: &PathBuf,
143        contract_path: &str,
144        global_args: &global::Args,
145    ) -> Result<(), Error> {
146        let printer = Print::new(global_args.quiet);
147        let original_dir = env::current_dir()?;
148        env::set_current_dir(absolute_project_path)?;
149
150        let contracts_path = absolute_project_path.join("contracts");
151        let existing_contract_path = contracts_path.join(contract_path);
152
153        if existing_contract_path.exists() {
154            remove_dir_all(&existing_contract_path)?;
155        }
156
157        let mut quiet_global_args = global_args.clone();
158        quiet_global_args.quiet = true;
159
160        let result = generate::contract::Cmd {
161            from: Some(contract_path.to_owned()),
162            ls: false,
163            from_wizard: false,
164            output: Some(
165                contracts_path
166                    .join(contract_path)
167                    .to_string_lossy()
168                    .into_owned(),
169            ),
170        }
171        .run(&quiet_global_args)
172        .await;
173
174        // Restore directory before handling result
175        let _ = env::set_current_dir(original_dir);
176
177        match result {
178            Ok(()) => {
179                printer.infoln(format!(
180                    "Successfully added OpenZeppelin example contract: {contract_path}"
181                ));
182            }
183            Err(generate::contract::Error::ExampleNotFound(_)) => {
184                printer.infoln(format!(
185                    "Skipped missing OpenZeppelin example contract: {contract_path}"
186                ));
187            }
188            Err(e) => {
189                printer.warnln(format!(
190                    "Failed to generate example contract: {contract_path}\n{e}"
191                ));
192            }
193        }
194
195        Ok(())
196    }
197}
198
199// Check if git is installed and exists in PATH
200fn git_exists() -> bool {
201    Command::new("git").arg("--version").output().is_err()
202}
203
204// Initialize a new git repository
205fn git_init(path: &PathBuf) {
206    let _ = Command::new("git").arg("init").current_dir(path).output();
207}
208
209// Stage files for commit
210fn git_add(path: &PathBuf, rest: &[&str]) {
211    let mut args = vec!["add"];
212    args.extend_from_slice(rest);
213    let _ = Command::new("git").args(args).current_dir(path).output();
214}
215
216// Commit with message
217fn git_commit(path: &PathBuf, message: &str) {
218    let _ = Command::new("git")
219        .args(["commit", "-m", message])
220        .current_dir(path)
221        .output();
222}