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