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