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::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 with the latest OpenZeppelin examples
89        let example_contracts = ["nft-enumerable", "fungible-allowlist-example"];
90
91        for contract in example_contracts {
92            self.update_oz_example(&absolute_project_path, contract, global_args)
93                .await?;
94        }
95
96        printer.checkln(format!("Project successfully created at {project_str}"));
97        Ok(())
98    }
99
100    /// Updates the project with an Open Zeppelin example contract
101    ///
102    /// This method attempts to generate a contract from Open Zeppelin
103    /// and prints a warning if it can't be found or generated.
104    async fn update_oz_example(
105        &self,
106        absolute_project_path: &PathBuf,
107        contract_path: &str,
108        global_args: &global::Args,
109    ) -> Result<(), Error> {
110        let printer = Print::new(global_args.quiet);
111        let original_dir = env::current_dir()?;
112        env::set_current_dir(absolute_project_path)?;
113
114        let contracts_path = absolute_project_path.join("contracts");
115        let existing_contract_path = contracts_path.join(contract_path);
116
117        if existing_contract_path.exists() {
118            remove_dir_all(&existing_contract_path)?;
119        }
120
121        let mut quiet_global_args = global_args.clone();
122        quiet_global_args.quiet = true;
123
124        let result = generate::contract::Cmd {
125            from: Some(contract_path.to_owned()),
126            ls: false,
127            from_wizard: false,
128            output: Some(
129                contracts_path
130                    .join(contract_path)
131                    .to_string_lossy()
132                    .into_owned(),
133            ),
134        }
135        .run(&quiet_global_args)
136        .await;
137
138        // Restore directory before handling result
139        let _ = env::set_current_dir(original_dir);
140
141        match result {
142            Ok(()) => {
143                printer.infoln(format!(
144                    "Successfully added OpenZeppelin example contract: {contract_path}"
145                ));
146            }
147            Err(generate::contract::Error::ExampleNotFound(_)) => {
148                printer.infoln(format!(
149                    "Skipped missing OpenZeppelin example contract: {contract_path}"
150                ));
151            }
152            Err(e) => {
153                printer.warnln(format!(
154                    "Failed to generate example contract: {contract_path}\n{e}"
155                ));
156            }
157        }
158
159        Ok(())
160    }
161}
162
163// Check if git is installed and exists in PATH
164fn git_exists() -> bool {
165    Command::new("git").arg("--version").output().is_err()
166}
167
168// Initialize a new git repository
169fn git_init(path: &PathBuf) {
170    let _ = Command::new("git").arg("init").current_dir(path).output();
171}
172
173// Stage files for commit
174fn git_add(path: &PathBuf, rest: &[&str]) {
175    let mut args = vec!["add"];
176    args.extend_from_slice(rest);
177    let _ = Command::new("git").args(args).current_dir(path).output();
178}
179
180// Commit with message
181fn git_commit(path: &PathBuf, message: &str) {
182    let _ = Command::new("git")
183        .args(["commit", "-m", message])
184        .current_dir(path)
185        .output();
186}