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        // If git is installed, run init and make initial commit
110        if git_exists() {
111            git_init(&absolute_project_path);
112            git_add(&absolute_project_path, &["-A"]);
113            git_commit(&absolute_project_path, "initial commit");
114        }
115
116        // Update the project's OpenZeppelin examples with the latest editions
117        if !self.vers.tutorial {
118            let example_contracts = ["nft-enumerable", "fungible-allowlist"];
119
120            for contract in example_contracts {
121                self.update_oz_example(&absolute_project_path, contract, global_args)
122                    .await?;
123            }
124        }
125
126        // Install npm dependencies
127        printer.infoln("Installing npm dependencies...");
128        let npm_install_command = Command::new("npm")
129            .arg("install")
130            .current_dir(&absolute_project_path)
131            .output()?;
132        if !npm_install_command.status.success() {
133            printer.warnln(
134                "Failed to install dependencies, run 'npm install' in the project directory",
135            );
136        }
137
138        // Build contracts and create contract clients
139        printer.infoln("Building contracts and generating client code...");
140        // Use clap to parse build command with defaults, then configure programmatically
141        let mut build_command = build::Command::parse_from(["build", "--build-clients"]);
142        build_command.build.manifest_path = Some(absolute_project_path.join("Cargo.toml"));
143        build_command.build_clients_args.env = Some(build::clients::ScaffoldEnv::Development);
144        build_command.build_clients_args.workspace_root = Some(absolute_project_path.clone());
145        let mut build_args = global_args.clone();
146        if !(global_args.verbose && global_args.very_verbose) {
147            build_args.quiet = true;
148        }
149
150        if let Err(e) = build_command.run(&build_args).await {
151            printer.warnln(format!("Failed to build contract clients: {e}"));
152        }
153
154        printer.blankln("\n\n");
155        printer.checkln(format!(
156            "Project successfully created at {}!",
157            absolute_project_path.display()
158        ));
159        printer.blankln(" You can now run the application with:\n");
160        printer.blankln(format!("\tcd {}", self.project_path.display()));
161        if !npm_install_command.status.success() {
162            printer.blankln("\tnpm install");
163        }
164        printer.blankln("\tnpm start\n");
165        printer.blankln(" Happy hacking! 🚀");
166        Ok(())
167    }
168
169    /// Updates the project with an Open Zeppelin example contract
170    ///
171    /// This method attempts to generate a contract from Open Zeppelin
172    /// and prints a warning if it can't be found or generated.
173    async fn update_oz_example(
174        &self,
175        absolute_project_path: &PathBuf,
176        contract_path: &str,
177        global_args: &global::Args,
178    ) -> Result<(), Error> {
179        let printer = Print::new(global_args.quiet);
180        let original_dir = env::current_dir()?;
181        env::set_current_dir(absolute_project_path)?;
182
183        let contracts_path = absolute_project_path.join("contracts");
184        let existing_contract_path = contracts_path.join(contract_path);
185
186        if existing_contract_path.exists() {
187            remove_dir_all(&existing_contract_path)?;
188        }
189
190        let mut quiet_global_args = global_args.clone();
191        quiet_global_args.quiet = true;
192
193        let result = generate::contract::Cmd {
194            from: Some(contract_path.to_owned()),
195            ls: false,
196            from_wizard: false,
197            output: Some(
198                contracts_path
199                    .join(contract_path)
200                    .to_string_lossy()
201                    .into_owned(),
202            ),
203        }
204        .run(&quiet_global_args)
205        .await;
206
207        // Restore directory before handling result
208        let _ = env::set_current_dir(original_dir);
209
210        match result {
211            Ok(()) => {
212                printer.infoln(format!(
213                    "Successfully added OpenZeppelin example contract: {contract_path}"
214                ));
215            }
216            Err(generate::contract::Error::ExampleNotFound(_)) => {
217                printer.infoln(format!(
218                    "Skipped missing OpenZeppelin example contract: {contract_path}"
219                ));
220            }
221            Err(e) => {
222                printer.warnln(format!(
223                    "Failed to generate example contract: {contract_path}\n{e}"
224                ));
225            }
226        }
227
228        Ok(())
229    }
230}
231
232// Check if git is installed and exists in PATH
233fn git_exists() -> bool {
234    Command::new("git").arg("--version").output().is_err()
235}
236
237// Initialize a new git repository
238fn git_init(path: &PathBuf) {
239    let _ = Command::new("git").arg("init").current_dir(path).output();
240}
241
242// Stage files for commit
243fn git_add(path: &PathBuf, rest: &[&str]) {
244    let mut args = vec!["add"];
245    args.extend_from_slice(rest);
246    let _ = Command::new("git").args(args).current_dir(path).output();
247}
248
249// Commit with message
250fn git_commit(path: &PathBuf, message: &str) {
251    let _ = Command::new("git")
252        .args(["commit", "-m", message])
253        .current_dir(path)
254        .output();
255}