Skip to main content

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