stellar_scaffold_cli/commands/
upgrade.rs1use 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#[derive(Parser, Debug, Clone)]
19pub struct Cmd {
20 #[arg(default_value = ".")]
22 pub workspace_path: PathBuf,
23}
24
25#[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 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 self.validate_workspace()?;
84
85 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 let cargo_toml = self.workspace_path.join("Cargo.toml");
112 if !cargo_toml.exists() {
113 return Err(Error::NoCargoToml);
114 }
115
116 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 let skip_items = ["contracts", "environments.toml", "Cargo.toml"];
144
145 for entry in read_dir(temp_path)? {
147 let entry = entry?;
148 let item_name = entry.file_name();
149
150 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 if dest.exists() {
162 continue;
163 }
164
165 if src.is_dir() {
166 let copy_options = fs_extra::dir::CopyOptions::new()
167 .overwrite(false) .skip_exist(true); fs_extra::dir::copy(&src, &self.workspace_path, ©_options)?;
171 } else {
172 let copy_options = fs_extra::file::CopyOptions::new().overwrite(false); fs_extra::file::copy(&src, &dest, ©_options)?;
175 }
176 }
177
178 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 if env_path.exists() {
192 return Ok(());
193 }
194
195 let contracts = self.discover_contracts()?;
197
198 self.build_contracts().await?;
200
201 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 let mut dev_table = Table::new();
235
236 let mut accounts_array = toml_edit::Array::new();
238 accounts_array.push("default");
239 dev_table["accounts"] = Item::Value(accounts_array.into());
240
241 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 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 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 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 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>>()? .into_iter()
309 .flatten()
310 .collect();
311
312 Ok(contracts)
313 }
314
315 async fn build_contracts(&self) -> Result<(), Error> {
316 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, };
337
338 build_cmd.run().await?;
339 Ok(())
340 }
341
342 fn get_constructor_args(&self, contract_name: &str) -> Result<Option<String>, Error> {
343 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 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 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 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 for arg in cmd.get_arguments() {
375 let arg_name = arg.get_id().as_str();
376
377 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 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 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 let is_already_quoted = (value.starts_with('"') && value.ends_with('"'))
441 || (value.starts_with('\'') && value.ends_with('\''));
442
443 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); select = select.item("(Skip - leave blank)");
468
469 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 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 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, ©_options)?;
503 }
504
505 Ok(())
506 }
507}