stellar_scaffold_cli/commands/
upgrade.rs

1use crate::arg_parsing::ArgParser;
2use crate::commands::build::env_toml::{Account, Contract, Environment, Network};
3use clap::Parser;
4use degit::degit;
5use indexmap::IndexMap;
6use std::fs;
7use std::fs::{create_dir_all, metadata, read_dir, write};
8use std::io;
9use std::path::{Path, PathBuf};
10use toml_edit::{value, DocumentMut, Item, Table};
11
12use crate::{arg_parsing, commands::build};
13use stellar_cli::print::Print;
14
15const FRONTEND_TEMPLATE: &str = "https://github.com/AhaLabs/scaffold-stellar-frontend";
16
17/// A command to upgrade an existing Soroban workspace to a scaffold project
18#[derive(Parser, Debug, Clone)]
19pub struct Cmd {
20    /// The path to the existing workspace (defaults to current directory)
21    #[arg(default_value = ".")]
22    pub workspace_path: PathBuf,
23    /// Skip the prompt to fill in constructor arguments
24    #[arg(long)]
25    pub skip_prompt: bool,
26}
27
28/// Errors that can occur during upgrade
29#[derive(thiserror::Error, Debug)]
30pub enum Error {
31    #[error("Failed to clone template: {0}")]
32    DegitError(String),
33    #[error(
34        "Workspace path contains invalid UTF-8 characters and cannot be converted to a string"
35    )]
36    InvalidWorkspacePathEncoding,
37    #[error("IO error: {0}")]
38    IoError(#[from] io::Error),
39    #[error("No Cargo.toml found in workspace path")]
40    NoCargoToml,
41    #[error("No contracts/ directory found in workspace path")]
42    NoContractsDirectory,
43    #[error("Invalid package name in Cargo.toml")]
44    InvalidPackageName,
45    #[error("Failed to parse TOML: {0}")]
46    TomlParseError(#[from] toml_edit::TomlError),
47    #[error("Failed to serialize TOML: {0}")]
48    TomlSerializeError(#[from] toml::ser::Error),
49    #[error("Failed to deserialize TOML: {0}")]
50    TomlDeserializeError(#[from] toml::de::Error),
51    #[error(transparent)]
52    BuildError(#[from] build::Error),
53    #[error("Failed to get constructor arguments: {0:?}")]
54    ConstructorArgsError(arg_parsing::Error),
55    #[error("WASM file not found for contract '{0}'. Please build the contract first.")]
56    WasmFileNotFound(String),
57    #[error(transparent)]
58    Clap(#[from] clap::Error),
59    #[error(transparent)]
60    SorobanSpecTools(#[from] soroban_spec_tools::contract::Error),
61    #[error(transparent)]
62    CopyError(#[from] fs_extra::error::Error),
63}
64
65impl Cmd {
66    /// Run the upgrade command
67    ///
68    /// # Example:
69    ///
70    /// ```
71    /// /// From the command line
72    /// stellar scaffold upgrade /path/to/workspace
73    /// ```
74    pub async fn run(
75        &self,
76        global_args: &stellar_cli::commands::global::Args,
77    ) -> Result<(), Error> {
78        let printer = Print::new(global_args.quiet);
79
80        printer.infoln(format!(
81            "Upgrading Soroban workspace to scaffold project in {:?}",
82            self.workspace_path
83        ));
84
85        // Validate workspace
86        self.validate_workspace()?;
87
88        // Create temporary directory for frontend template
89        let temp_dir = tempfile::tempdir().map_err(Error::IoError)?;
90        let temp_path = temp_dir.path();
91
92        printer.infoln("Downloading frontend template...");
93        Self::clone_frontend_template(temp_path)?;
94
95        printer.infoln("Copying frontend files...");
96        self.copy_frontend_files(temp_path)?;
97
98        printer.infoln("Setting up environment file...");
99        self.setup_env_file()?;
100
101        printer.infoln("Creating environments.toml...");
102        self.create_environments_toml(global_args).await?;
103
104        printer.checkln(format!(
105            "Workspace successfully upgraded to scaffold project at {:?}",
106            self.workspace_path
107        ));
108
109        Ok(())
110    }
111
112    fn validate_workspace(&self) -> Result<(), Error> {
113        // Check for Cargo.toml
114        let cargo_toml = self.workspace_path.join("Cargo.toml");
115        if !cargo_toml.exists() {
116            return Err(Error::NoCargoToml);
117        }
118
119        // Check for contracts/ directory
120        let contracts_dir = self.workspace_path.join("contracts");
121        if !contracts_dir.exists() {
122            return Err(Error::NoContractsDirectory);
123        }
124
125        Ok(())
126    }
127
128    fn clone_frontend_template(temp_path: &Path) -> Result<(), Error> {
129        let temp_str = temp_path
130            .to_str()
131            .ok_or(Error::InvalidWorkspacePathEncoding)?;
132
133        degit(FRONTEND_TEMPLATE, temp_str);
134
135        if metadata(temp_path).is_err() || read_dir(temp_path)?.next().is_none() {
136            return Err(Error::DegitError(format!(
137                "Failed to clone template into {temp_str}: directory is empty or missing",
138            )));
139        }
140
141        Ok(())
142    }
143
144    fn copy_frontend_files(&self, temp_path: &Path) -> Result<(), Error> {
145        // Files and directories to skip (don't copy from template)
146        let skip_items = ["contracts", "environments.toml", "Cargo.toml"];
147
148        // Copy all items from template except the ones we want to skip
149        for entry in read_dir(temp_path)? {
150            let entry = entry?;
151            let item_name = entry.file_name();
152
153            // Skip items that shouldn't be copied
154            if let Some(name_str) = item_name.to_str() {
155                if skip_items.contains(&name_str) {
156                    continue;
157                }
158            }
159
160            let src = entry.path();
161            let dest = self.workspace_path.join(&item_name);
162
163            // Don't overwrite existing files/directories
164            if dest.exists() {
165                continue;
166            }
167
168            if src.is_dir() {
169                let copy_options = fs_extra::dir::CopyOptions::new()
170                    .overwrite(false) // Don't overwrite existing files
171                    .skip_exist(true); // Skip files that already exist
172
173                fs_extra::dir::copy(&src, &self.workspace_path, &copy_options)?;
174            } else {
175                let copy_options = fs_extra::file::CopyOptions::new().overwrite(false); // Don't overwrite existing files
176
177                fs_extra::file::copy(&src, &dest, &copy_options)?;
178            }
179        }
180
181        // Create packages directory if it doesn't exist
182        let packages_dir = self.workspace_path.join("packages");
183        if !packages_dir.exists() {
184            create_dir_all(&packages_dir)?;
185        }
186
187        Ok(())
188    }
189
190    async fn create_environments_toml(
191        &self,
192        global_args: &stellar_cli::commands::global::Args,
193    ) -> Result<(), Error> {
194        let env_path = self.workspace_path.join("environments.toml");
195
196        // Don't overwrite existing environments.toml
197        if env_path.exists() {
198            return Ok(());
199        }
200
201        // Discover contracts by looking in contracts/ directory
202        let contracts = self.discover_contracts()?;
203
204        // Build contracts to get WASM files for constructor arg analysis
205        self.build_contracts(global_args).await?;
206
207        // Get constructor args for each contract
208        let contract_configs = contracts
209            .iter()
210            .map(|contract_name| {
211                let constructor_args = self.get_constructor_args(contract_name)?;
212                Ok((
213                    contract_name.clone().into_boxed_str(),
214                    Contract {
215                        constructor_args,
216                        ..Default::default()
217                    },
218                ))
219            })
220            .collect::<Result<IndexMap<_, _>, Error>>()?;
221
222        let env_config = Environment {
223            accounts: Some(vec![Account {
224                name: "default".to_string(),
225                default: true,
226            }]),
227            network: Network {
228                name: None,
229                rpc_url: Some("http://localhost:8000/rpc".to_string()),
230                network_passphrase: Some("Standalone Network ; February 2017".to_string()),
231                rpc_headers: None,
232                run_locally: true,
233            },
234            contracts: (!contract_configs.is_empty()).then_some(contract_configs),
235        };
236
237        let mut doc = DocumentMut::new();
238
239        // Add development environment
240        let mut dev_table = Table::new();
241
242        // Add accounts
243        let mut accounts_array = toml_edit::Array::new();
244        accounts_array.push("default");
245        dev_table["accounts"] = Item::Value(accounts_array.into());
246
247        // Add network
248        let mut network_table = Table::new();
249        network_table["rpc-url"] = value(env_config.network.rpc_url.as_ref().unwrap());
250        network_table["network-passphrase"] =
251            value(env_config.network.network_passphrase.as_ref().unwrap());
252        network_table["run-locally"] = value(env_config.network.run_locally);
253        dev_table["network"] = Item::Table(network_table);
254
255        // Add contracts
256        let contracts_table = env_config
257            .contracts
258            .as_ref()
259            .map(|contracts| {
260                contracts
261                    .iter()
262                    .map(|(name, config)| {
263                        let mut contract_constructor_args = Table::new();
264                        if let Some(args) = &config.constructor_args {
265                            contract_constructor_args["constructor_args"] = value(args);
266                        }
267                        // Convert hyphens to underscores for contract names in TOML
268                        let contract_key = name.replace('-', "_");
269                        (contract_key, Item::Table(contract_constructor_args))
270                    })
271                    .collect::<Table>()
272            })
273            .unwrap_or_default();
274
275        dev_table["contracts"] = Item::Table(contracts_table);
276
277        doc["development"] = Item::Table(dev_table);
278
279        write(&env_path, doc.to_string())?;
280
281        Ok(())
282    }
283
284    fn discover_contracts(&self) -> Result<Vec<String>, Error> {
285        let contracts_dir = self.workspace_path.join("contracts");
286
287        let contracts = std::fs::read_dir(&contracts_dir)?
288            .map(|entry_res| -> Result<Option<String>, Error> {
289                let entry = entry_res?;
290                let path = entry.path();
291
292                // skip non-directories or dirs without Cargo.toml
293                let cargo_toml = path.join("Cargo.toml");
294                if !path.is_dir() || !cargo_toml.exists() {
295                    return Ok(None);
296                }
297
298                let content = std::fs::read_to_string(&cargo_toml)?;
299                if !content.contains("cdylib") {
300                    return Ok(None);
301                }
302
303                // parse and extract package.name, propagating any toml errors
304                let tv = content.parse::<toml::Value>()?;
305                let name = tv
306                    .get("package")
307                    .and_then(|p| p.get("name"))
308                    .and_then(|n| n.as_str())
309                    .ok_or_else(|| Error::InvalidPackageName)?;
310
311                Ok(Some(name.to_string()))
312            })
313            .collect::<Result<Vec<Option<String>>, Error>>()? // bubbles up any Err
314            .into_iter()
315            .flatten()
316            .collect();
317
318        Ok(contracts)
319    }
320
321    async fn build_contracts(
322        &self,
323        global_args: &stellar_cli::commands::global::Args,
324    ) -> Result<(), Error> {
325        // Run scaffold build to generate WASM files
326        let build_cmd = build::Command {
327            build_clients_args: build::clients::Args {
328                env: Some(build::clients::ScaffoldEnv::Development),
329                workspace_root: Some(self.workspace_path.clone()),
330                out_dir: None,
331                global_args: Some(global_args.clone()),
332            },
333            build: stellar_cli::commands::contract::build::Cmd {
334                manifest_path: None,
335                package: None,
336                profile: "release".to_string(),
337                features: None,
338                all_features: false,
339                no_default_features: false,
340                out_dir: None,
341                print_commands_only: false,
342                meta: Vec::new(),
343            },
344            list: false,
345            build_clients: false, // Don't build clients, just contracts
346        };
347
348        build_cmd.run(global_args).await?;
349        Ok(())
350    }
351
352    fn get_constructor_args(&self, contract_name: &str) -> Result<Option<String>, Error> {
353        // Get the WASM file path
354        let target_dir = self.workspace_path.join("target");
355        let wasm_path = stellar_build::stellar_wasm_out_file(&target_dir, contract_name);
356
357        if !wasm_path.exists() {
358            return Err(Error::WasmFileNotFound(contract_name.to_string()));
359        }
360
361        // Read the WASM file and get spec entries
362        let raw_wasm = fs::read(&wasm_path)?;
363        ArgParser::get_constructor_args(self.skip_prompt, contract_name, &raw_wasm)
364            .map_err(Error::ConstructorArgsError)
365    }
366
367    fn setup_env_file(&self) -> Result<(), Error> {
368        let env_example_path = self.workspace_path.join(".env.example");
369        let env_path = self.workspace_path.join(".env");
370
371        // Only copy if .env.example exists and .env doesn't exist
372        if env_example_path.exists() && !env_path.exists() {
373            let copy_options = fs_extra::file::CopyOptions::new();
374            fs_extra::file::copy(&env_example_path, &env_path, &copy_options)?;
375        }
376
377        Ok(())
378    }
379}