stellar_scaffold_cli/commands/
upgrade.rs

1use crate::commands::build::env_toml::{Account, Contract, Environment, Network};
2use clap::Parser;
3use degit::degit;
4use dialoguer::{Confirm, Input, Select};
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}
24
25/// Errors that can occur during upgrade
26#[derive(thiserror::Error, Debug)]
27pub enum Error {
28    #[error("Failed to clone template: {0}")]
29    DegitError(String),
30    #[error(
31        "Workspace path contains invalid UTF-8 characters and cannot be converted to a string"
32    )]
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(String),
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
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().await?;
100
101        printer.checkln(format!(
102            "Workspace successfully upgraded to scaffold project at {:?}",
103            self.workspace_path
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                if skip_items.contains(&name_str) {
153                    continue;
154                }
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(&self) -> Result<(), Error> {
188        let env_path = self.workspace_path.join("environments.toml");
189
190        // Don't overwrite existing environments.toml
191        if env_path.exists() {
192            return Ok(());
193        }
194
195        // Discover contracts by looking in contracts/ directory
196        let contracts = self.discover_contracts()?;
197
198        // Build contracts to get WASM files for constructor arg analysis
199        self.build_contracts().await?;
200
201        // Get constructor args for each contract
202        let contract_configs = contracts
203            .iter()
204            .map(|contract_name| {
205                let constructor_args = self.get_constructor_args(contract_name)?;
206                Ok((
207                    contract_name.clone().into_boxed_str(),
208                    Contract {
209                        constructor_args,
210                        ..Default::default()
211                    },
212                ))
213            })
214            .collect::<Result<IndexMap<_, _>, Error>>()?;
215
216        let env_config = Environment {
217            accounts: Some(vec![Account {
218                name: "default".to_string(),
219                default: true,
220            }]),
221            network: Network {
222                name: None,
223                rpc_url: Some("http://localhost:8000/rpc".to_string()),
224                network_passphrase: Some("Standalone Network ; February 2017".to_string()),
225                rpc_headers: None,
226                run_locally: true,
227            },
228            contracts: (!contract_configs.is_empty()).then_some(contract_configs),
229        };
230
231        let mut doc = DocumentMut::new();
232
233        // Add development environment
234        let mut dev_table = Table::new();
235
236        // Add accounts
237        let mut accounts_array = toml_edit::Array::new();
238        accounts_array.push("default");
239        dev_table["accounts"] = Item::Value(accounts_array.into());
240
241        // Add network
242        let mut network_table = Table::new();
243        network_table["rpc-url"] = value(env_config.network.rpc_url.as_ref().unwrap());
244        network_table["network-passphrase"] =
245            value(env_config.network.network_passphrase.as_ref().unwrap());
246        network_table["run-locally"] = value(env_config.network.run_locally);
247        dev_table["network"] = Item::Table(network_table);
248
249        // Add contracts
250        let contracts_table = env_config
251            .contracts
252            .as_ref()
253            .map(|contracts| {
254                contracts
255                    .iter()
256                    .map(|(name, config)| {
257                        let mut contract_constructor_args = Table::new();
258                        if let Some(args) = &config.constructor_args {
259                            contract_constructor_args["constructor_args"] = value(args);
260                        }
261                        // Convert hyphens to underscores for contract names in TOML
262                        let contract_key = name.replace('-', "_");
263                        (contract_key, Item::Table(contract_constructor_args))
264                    })
265                    .collect::<Table>()
266            })
267            .unwrap_or_default();
268
269        dev_table["contracts"] = Item::Table(contracts_table);
270
271        doc["development"] = Item::Table(dev_table);
272
273        write(&env_path, doc.to_string())?;
274
275        Ok(())
276    }
277
278    fn discover_contracts(&self) -> Result<Vec<String>, Error> {
279        let contracts_dir = self.workspace_path.join("contracts");
280
281        let contracts = std::fs::read_dir(&contracts_dir)?
282            .map(|entry_res| -> Result<Option<String>, Error> {
283                let entry = entry_res?;
284                let path = entry.path();
285
286                // skip non-directories or dirs without Cargo.toml
287                let cargo_toml = path.join("Cargo.toml");
288                if !path.is_dir() || !cargo_toml.exists() {
289                    return Ok(None);
290                }
291
292                let content = std::fs::read_to_string(&cargo_toml)?;
293                if !content.contains("cdylib") {
294                    return Ok(None);
295                }
296
297                // parse and extract package.name, propagating any toml errors
298                let tv = content.parse::<toml::Value>()?;
299                let name = tv
300                    .get("package")
301                    .and_then(|p| p.get("name"))
302                    .and_then(|n| n.as_str())
303                    .ok_or_else(|| Error::InvalidPackageName)?;
304
305                Ok(Some(name.to_string()))
306            })
307            .collect::<Result<Vec<Option<String>>, Error>>()? // bubbles up any Err
308            .into_iter()
309            .flatten()
310            .collect();
311
312        Ok(contracts)
313    }
314
315    async fn build_contracts(&self) -> Result<(), Error> {
316        // Run scaffold build to generate WASM files
317        let build_cmd = build::Command {
318            build_clients_args: build::clients::Args {
319                env: Some(build::clients::ScaffoldEnv::Development),
320                workspace_root: Some(self.workspace_path.clone()),
321                out_dir: None,
322            },
323            build: stellar_cli::commands::contract::build::Cmd {
324                manifest_path: None,
325                package: None,
326                profile: "release".to_string(),
327                features: None,
328                all_features: false,
329                no_default_features: false,
330                out_dir: None,
331                print_commands_only: false,
332                meta: Vec::new(),
333            },
334            list: false,
335            build_clients: false, // Don't build clients, just contracts
336        };
337
338        build_cmd.run().await?;
339        Ok(())
340    }
341
342    fn get_constructor_args(&self, contract_name: &str) -> Result<Option<String>, Error> {
343        // Get the WASM file path
344        let target_dir = self.workspace_path.join("target");
345        let wasm_path = stellar_build::stellar_wasm_out_file(&target_dir, contract_name);
346
347        if !wasm_path.exists() {
348            return Err(Error::WasmFileNotFound(contract_name.to_string()));
349        }
350
351        // Read the WASM file and get spec entries
352        let raw_wasm = fs::read(&wasm_path)?;
353        let entries = soroban_spec_tools::contract::Spec::new(&raw_wasm)?.spec;
354        let spec = soroban_spec_tools::Spec::new(entries.clone());
355
356        // Check if constructor function exists
357        let Ok(func) = spec.find_function("__constructor") else {
358            return Ok(None);
359        };
360        if func.inputs.is_empty() {
361            return Ok(None);
362        }
363
364        // Build the custom command for the constructor
365        let cmd = arg_parsing::build_custom_cmd("__constructor", &spec).map_err(|e| {
366            Error::ConstructorArgsError(format!("Failed to build constructor command: {e}"))
367        })?;
368
369        println!("\nšŸ“‹ Contract '{contract_name}' requires constructor arguments:");
370
371        let mut args = Vec::new();
372
373        // Loop through the command arguments, skipping file args
374        for arg in cmd.get_arguments() {
375            let arg_name = arg.get_id().as_str();
376
377            // Skip file arguments (they end with -file-path)
378            if arg_name.ends_with("-file-path") {
379                continue;
380            }
381
382            if let Some(arg_value) = Self::handle_constructor_argument(arg)? {
383                args.push(arg_value);
384            }
385        }
386
387        Ok((!args.is_empty()).then(|| args.join(" ")))
388    }
389
390    fn handle_constructor_argument(arg: &clap::Arg) -> Result<Option<String>, Error> {
391        let arg_name = arg.get_id().as_str();
392
393        let help_text = arg.get_long_help().or(arg.get_help()).map_or_else(
394            || "No description available".to_string(),
395            ToString::to_string,
396        );
397
398        let value_name = arg
399            .get_value_names()
400            .map_or_else(|| "VALUE".to_string(), |names| names.join(" "));
401
402        // Display help text before the prompt
403        println!("\n  --{arg_name}");
404        if value_name != "bool" && !help_text.is_empty() {
405            println!("   {help_text}");
406        }
407
408        if value_name == "bool" {
409            Self::handle_bool_argument(arg_name)
410        } else if value_name.contains('|') && Self::is_simple_enum(&value_name) {
411            Self::handle_simple_enum_argument(arg_name, &value_name)
412        } else {
413            // For all other types (complex enums, structs, strings), use string input
414            Self::handle_formatted_input(arg_name)
415        }
416    }
417
418    fn is_simple_enum(value_name: &str) -> bool {
419        value_name.split('|').all(|part| {
420            let trimmed = part.trim();
421            trimmed.parse::<i32>().is_ok() || trimmed.chars().all(|c| c.is_alphabetic() || c == '_')
422        })
423    }
424
425    fn handle_formatted_input(arg_name: &str) -> Result<Option<String>, Error> {
426        let input_result: Result<String, _> = Input::new()
427            .with_prompt(format!("Enter value for --{arg_name}"))
428            .allow_empty(true)
429            .interact();
430
431        let value = input_result
432            .as_deref()
433            .map(str::trim)
434            .map_err(|e| Error::ConstructorArgsError(format!("Input error: {e}")))?;
435
436        let value = if value.is_empty() {
437            "# TODO: Fill in value"
438        } else {
439            // Check if the value is already quoted
440            let is_already_quoted = (value.starts_with('"') && value.ends_with('"'))
441                || (value.starts_with('\'') && value.ends_with('\''));
442
443            // Only wrap in quotes if it's not already quoted and contains special characters or spaces
444            if !is_already_quoted
445                && (value.contains(' ')
446                    || value.contains('{')
447                    || value.contains('[')
448                    || value.contains('"'))
449            {
450                &format!("'{value}'")
451            } else {
452                value
453            }
454        };
455        Ok(Some(format!("--{arg_name} {value}")))
456    }
457
458    fn handle_simple_enum_argument(
459        arg_name: &str,
460        value_name: &str,
461    ) -> Result<Option<String>, Error> {
462        let mut select = Select::new()
463            .with_prompt(format!("Select value for --{arg_name}"))
464            .default(0); // This will show the cursor on the first option initially
465
466        // Add "Skip" option
467        select = select.item("(Skip - leave blank)");
468
469        // Parse the values from "a | b | c" format and add numeric options
470        let values: Vec<_> = value_name.split('|').collect();
471        for value in &values {
472            select = select.item(format!("Value: {value}"));
473        }
474
475        let selection = select
476            .interact()
477            .map_err(|e| Error::ConstructorArgsError(format!("Input error: {e}")))?;
478
479        Ok((selection > 0).then(|| {
480            // User selected an actual value (not skip)
481            let selected_value = values[selection - 1];
482            format!("--{arg_name} {selected_value}")
483        }))
484    }
485
486    fn handle_bool_argument(arg_name: &str) -> Result<Option<String>, Error> {
487        let bool_value = Confirm::new()
488            .with_prompt(format!("Set --{arg_name} to true?"))
489            .default(false)
490            .interact()
491            .map_err(|e| Error::ConstructorArgsError(format!("Input error: {e}")))?;
492        Ok(bool_value.then(|| format!("--{arg_name}")))
493    }
494
495    fn setup_env_file(&self) -> Result<(), Error> {
496        let env_example_path = self.workspace_path.join(".env.example");
497        let env_path = self.workspace_path.join(".env");
498
499        // Only copy if .env.example exists and .env doesn't exist
500        if env_example_path.exists() && !env_path.exists() {
501            let copy_options = fs_extra::file::CopyOptions::new();
502            fs_extra::file::copy(&env_example_path, &env_path, &copy_options)?;
503        }
504
505        Ok(())
506    }
507}