stellar_scaffold_cli/commands/
upgrade.rs1use 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};
14use stellar_cli::print::Print;
15
16const FRONTEND_TEMPLATE: &str = "https://github.com/AhaLabs/scaffold-stellar-frontend";
17
18#[derive(Parser, Debug, Clone)]
20pub struct Cmd {
21 #[arg(default_value = ".")]
23 pub workspace_path: PathBuf,
24 #[arg(long)]
26 pub skip_prompt: bool,
27}
28
29#[derive(thiserror::Error, Debug)]
31pub enum Error {
32 #[error("Failed to clone template: {0}")]
33 DegitError(String),
34 #[error(
35 "Workspace path contains invalid UTF-8 characters and cannot be converted to a string"
36 )]
37 InvalidWorkspacePathEncoding,
38 #[error("IO error: {0}")]
39 IoError(#[from] io::Error),
40 #[error("No Cargo.toml found in workspace path")]
41 NoCargoToml,
42 #[error("No contracts/ directory found in workspace path")]
43 NoContractsDirectory,
44 #[error("Invalid package name in Cargo.toml")]
45 InvalidPackageName,
46 #[error("Failed to parse TOML: {0}")]
47 TomlParseError(#[from] toml_edit::TomlError),
48 #[error("Failed to serialize TOML: {0}")]
49 TomlSerializeError(#[from] toml::ser::Error),
50 #[error("Failed to deserialize TOML: {0}")]
51 TomlDeserializeError(#[from] toml::de::Error),
52 #[error(transparent)]
53 BuildError(#[from] build::Error),
54 #[error("Failed to get constructor arguments: {0:?}")]
55 ConstructorArgsError(arg_parsing::Error),
56 #[error("WASM file not found for contract '{0}'. Please build the contract first.")]
57 WasmFileNotFound(String),
58 #[error(transparent)]
59 Clap(#[from] clap::Error),
60 #[error(transparent)]
61 SorobanSpecTools(#[from] soroban_spec_tools::contract::Error),
62 #[error(transparent)]
63 CopyError(#[from] fs_extra::error::Error),
64}
65
66impl Cmd {
67 pub async fn run(
76 &self,
77 global_args: &stellar_cli::commands::global::Args,
78 ) -> Result<(), Error> {
79 let printer = Print::new(global_args.quiet);
80
81 printer.infoln(format!(
82 "Upgrading Soroban workspace to scaffold project in {:?}",
83 self.workspace_path
84 ));
85
86 self.validate_workspace()?;
88
89 let temp_dir = tempfile::tempdir().map_err(Error::IoError)?;
91 let temp_path = temp_dir.path();
92
93 printer.infoln("Downloading frontend template...");
94 Self::clone_frontend_template(temp_path)?;
95
96 printer.infoln("Copying frontend files...");
97 self.copy_frontend_files(temp_path)?;
98
99 printer.infoln("Setting up environment file...");
100 self.setup_env_file()?;
101
102 printer.infoln("Creating environments.toml...");
103 self.create_environments_toml(global_args).await?;
104
105 printer.checkln(format!(
106 "Workspace successfully upgraded to scaffold project at {:?}",
107 self.workspace_path
108 ));
109
110 Ok(())
111 }
112
113 fn validate_workspace(&self) -> Result<(), Error> {
114 let cargo_toml = self.workspace_path.join("Cargo.toml");
116 if !cargo_toml.exists() {
117 return Err(Error::NoCargoToml);
118 }
119
120 let contracts_dir = self.workspace_path.join("contracts");
122 if !contracts_dir.exists() {
123 return Err(Error::NoContractsDirectory);
124 }
125
126 Ok(())
127 }
128
129 fn clone_frontend_template(temp_path: &Path) -> Result<(), Error> {
130 let temp_str = temp_path
131 .to_str()
132 .ok_or(Error::InvalidWorkspacePathEncoding)?;
133
134 degit(FRONTEND_TEMPLATE, temp_str);
135
136 if metadata(temp_path).is_err() || read_dir(temp_path)?.next().is_none() {
137 return Err(Error::DegitError(format!(
138 "Failed to clone template into {temp_str}: directory is empty or missing",
139 )));
140 }
141
142 Ok(())
143 }
144
145 fn copy_frontend_files(&self, temp_path: &Path) -> Result<(), Error> {
146 let skip_items = ["contracts", "environments.toml", "Cargo.toml"];
148
149 for entry in read_dir(temp_path)? {
151 let entry = entry?;
152 let item_name = entry.file_name();
153
154 if let Some(name_str) = item_name.to_str() {
156 if skip_items.contains(&name_str) {
157 continue;
158 }
159 }
160
161 let src = entry.path();
162 let dest = self.workspace_path.join(&item_name);
163
164 if dest.exists() {
166 continue;
167 }
168
169 if src.is_dir() {
170 let copy_options = fs_extra::dir::CopyOptions::new()
171 .overwrite(false) .skip_exist(true); fs_extra::dir::copy(&src, &self.workspace_path, ©_options)?;
175 } else {
176 let copy_options = fs_extra::file::CopyOptions::new().overwrite(false); fs_extra::file::copy(&src, &dest, ©_options)?;
179 }
180 }
181
182 let packages_dir = self.workspace_path.join("packages");
184 if !packages_dir.exists() {
185 create_dir_all(&packages_dir)?;
186 }
187
188 Ok(())
189 }
190
191 async fn create_environments_toml(
192 &self,
193 global_args: &stellar_cli::commands::global::Args,
194 ) -> Result<(), Error> {
195 let env_path = self.workspace_path.join("environments.toml");
196
197 if env_path.exists() {
199 return Ok(());
200 }
201
202 let contracts = self.discover_contracts(global_args)?;
204
205 self.build_contracts(global_args).await?;
207
208 let contract_configs = contracts
210 .iter()
211 .map(|contract_name| {
212 let constructor_args = self.get_constructor_args(contract_name)?;
213 Ok((
214 contract_name.clone().into_boxed_str(),
215 Contract {
216 constructor_args,
217 ..Default::default()
218 },
219 ))
220 })
221 .collect::<Result<IndexMap<_, _>, Error>>()?;
222
223 let env_config = Environment {
224 accounts: Some(vec![Account {
225 name: "default".to_string(),
226 default: true,
227 }]),
228 network: Network {
229 name: None,
230 rpc_url: Some("http://localhost:8000/rpc".to_string()),
231 network_passphrase: Some("Standalone Network ; February 2017".to_string()),
232 rpc_headers: None,
233 run_locally: true,
234 },
235 contracts: (!contract_configs.is_empty()).then_some(contract_configs),
236 };
237
238 let mut doc = DocumentMut::new();
239
240 let mut dev_table = Table::new();
242
243 let mut accounts_array = toml_edit::Array::new();
245 accounts_array.push("default");
246 dev_table["accounts"] = Item::Value(accounts_array.into());
247
248 let mut network_table = Table::new();
250 network_table["rpc-url"] = value(env_config.network.rpc_url.as_ref().unwrap());
251 network_table["network-passphrase"] =
252 value(env_config.network.network_passphrase.as_ref().unwrap());
253 network_table["run-locally"] = value(env_config.network.run_locally);
254 dev_table["network"] = Item::Table(network_table);
255
256 let contracts_table = env_config
258 .contracts
259 .as_ref()
260 .map(|contracts| {
261 contracts
262 .iter()
263 .map(|(name, config)| {
264 let mut contract_constructor_args = Table::new();
265 if let Some(args) = &config.constructor_args {
266 contract_constructor_args["constructor_args"] = value(args);
267 }
268 let contract_key = name.replace('-', "_");
270 (contract_key, Item::Table(contract_constructor_args))
271 })
272 .collect::<Table>()
273 })
274 .unwrap_or_default();
275
276 dev_table["contracts"] = Item::Table(contracts_table);
277
278 doc["development"] = Item::Table(dev_table);
279
280 write(&env_path, doc.to_string())?;
281
282 Ok(())
283 }
284
285 fn discover_contracts(&self, global_args: &Args) -> Result<Vec<String>, Error> {
286 let contracts_dir = self.workspace_path.join("contracts");
287 let printer = Print::new(global_args.quiet);
288
289 let contracts = std::fs::read_dir(&contracts_dir)?
290 .map(|entry_res| -> Result<Option<String>, Error> {
291 let entry = entry_res?;
292 let path = entry.path();
293
294 let cargo_toml = path.join("Cargo.toml");
296 if !path.is_dir() || !cargo_toml.exists() {
297 return Ok(None);
298 }
299
300 let mut content = fs::read_to_string(&cargo_toml)?;
301 if !content.contains("cdylib") {
302 return Ok(None);
303 }
304
305 let tv = content.parse::<toml::Value>()?;
307 let name = tv
308 .get("package")
309 .and_then(|p| p.get("name"))
310 .and_then(|n| n.as_str())
311 .ok_or_else(|| Error::InvalidPackageName)?;
312
313 if content.contains("[package.metadata.stellar]") {
315 printer.infoln("Found metadata section [package.metadata.stellar]");
316 } else {
317 content.push_str("\n[package.metadata.stellar]\ncargo_inherit = true\n");
318
319 let res = write(path.join("Cargo.toml"), content);
320 if let Err(e) = res {
321 printer.errorln(format!("Failed to write Cargo.toml file {e}"));
322 }
323 }
324
325 Ok(Some(name.to_string()))
326 })
327 .collect::<Result<Vec<Option<String>>, Error>>()? .into_iter()
329 .flatten()
330 .collect();
331
332 Ok(contracts)
333 }
334
335 async fn build_contracts(
336 &self,
337 global_args: &stellar_cli::commands::global::Args,
338 ) -> Result<(), Error> {
339 let build_cmd = build::Command {
341 build_clients_args: build::clients::Args {
342 env: Some(build::clients::ScaffoldEnv::Development),
343 workspace_root: Some(self.workspace_path.clone()),
344 out_dir: None,
345 global_args: Some(global_args.clone()),
346 },
347 build: stellar_cli::commands::contract::build::Cmd {
348 manifest_path: None,
349 package: None,
350 profile: "release".to_string(),
351 features: None,
352 all_features: false,
353 no_default_features: false,
354 out_dir: None,
355 print_commands_only: false,
356 meta: Vec::new(),
357 },
358 list: false,
359 build_clients: false, };
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 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 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 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, ©_options)?;
389 }
390
391 Ok(())
392 }
393}