From 75fce2e16a4b450205f30814fcc425b855120aff Mon Sep 17 00:00:00 2001 From: sharmis-tp Date: Wed, 28 Oct 2026 15:07:25 +0000 Subject: [PATCH] feat(stellar-scaffold-cli): build clients without a Cargo workspace --- .../src/commands/build/clients.rs | 83 ++++++++- .../src/commands/build/env_toml.rs | 12 ++ .../src/commands/build/mod.rs | 159 +++++++++++++++--- crates/stellar-scaffold-cli/src/config/mod.rs | 21 ++- .../tests/it/build_clients/scaffold_v2.rs | 58 +++++++ crates/stellar-scaffold-cli/tests/unit.rs | 87 ++++++++++ docs/site/docs/cli.md | 2 + docs/site/docs/configuration.md | 20 +++ 8 files changed, 404 insertions(+), 38 deletions(-) diff --git a/crates/stellar-scaffold-cli/src/commands/build/clients.rs b/crates/stellar-scaffold-cli/src/commands/build/clients.rs index be52fafd..f2bce1e7 100644 --- a/crates/stellar-scaffold-cli/src/commands/build/clients.rs +++ b/crates/stellar-scaffold-cli/src/commands/build/clients.rs @@ -872,12 +872,14 @@ impl Builder { async fn handle_contracts(&self, package_names: Vec) -> Result<(), Error> { let printer = self.printer(); - if package_names.is_empty() { - return Ok(()); - } + // Version 2 contracts referenced by `id` need nothing compiled, so a + // project without crates still gets their clients. if self.env.from_scaffold_yml { return self.handle_listed_contracts(&package_names).await; } + if package_names.is_empty() { + return Ok(()); + } let contracts = self.env.contracts.as_ref(); let network = &self.network; let env = self.stellar_scaffold_env(); @@ -1382,10 +1384,18 @@ impl ClientsArgs { .map(|account| account.name.clone()) .collect::>(); - let default_account = match (default_account_candidates.as_slice(), accounts.as_slice()) { + let source_account = match (default_account_candidates.as_slice(), accounts.as_slice()) { + // Generating clients for contracts that are already deployed signs + // nothing, so a version 2 network may list no accounts. + ([], []) + if current_env.from_scaffold_yml + && current_env.workspace_contracts().is_empty() => + { + UnresolvedMuxedAccount::default() + } ([], []) => return Err(Error::NeedAtLeastOneAccount), - ([], [env_toml::Account { name, .. }, ..]) => name.clone(), - ([candidate], _) => candidate.clone(), + ([], [env_toml::Account { name, .. }, ..]) => name.parse()?, + ([candidate], _) => candidate.parse()?, _ => return Err(Error::OnlyOneDefaultAccount(default_account_candidates)), }; @@ -1396,7 +1406,7 @@ impl ClientsArgs { let builder = Builder::new( global_args, network, - default_account.parse()?, + source_account, workspace_root.clone(), env, self.out_dir.clone(), @@ -1573,10 +1583,67 @@ async fn fetch_contract_spec( #[cfg(test)] mod tests { use super::{ - alias_is_ours, binding_targets, render_clients_index, to_camel_case, to_pascal_case, + ClientsArgs, Error, alias_is_ours, binding_targets, render_clients_index, to_camel_case, + to_pascal_case, }; + use crate::commands::build::env_toml::{self, Environment, ResolvedDeploy}; + use clap::Parser; use stellar_cli::config::network::passphrase; + /// A version 2 plan for testnet with no accounts and one contract, + /// either built from a crate or referenced by its ID. + fn plan_without_accounts(workspace: bool) -> Environment { + let contract = env_toml::Contract { + id: (!workspace) + .then(|| "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".into()), + resolved: Some(ResolvedDeploy { + crate_name: workspace.then(|| "hello".into()), + ..ResolvedDeploy::default() + }), + ..env_toml::Contract::default() + }; + Environment { + accounts: Some(vec![]), + network: env_toml::Network { + name: Some("testnet".into()), + rpc_url: Some("https://soroban-testnet.stellar.org".into()), + network_passphrase: Some(passphrase::TESTNET.into()), + rpc_headers: None, + run_locally: false, + allow_http: None, + }, + contracts: Some([("hello".into(), contract)].into_iter().collect()), + extensions: vec![], + from_scaffold_yml: true, + } + } + + fn builder_for(plan: Environment) -> Result { + #[derive(Parser)] + struct Wrapper { + #[command(flatten)] + args: ClientsArgs, + } + let dir = tempfile::tempdir().unwrap(); + let mut args = Wrapper::parse_from(["build"]).args; + args.workspace_root = Some(dir.path().to_path_buf()); + args.plan = Some(plan); + args.builder() + } + + #[test] + fn referenced_contracts_need_no_account() { + assert!(builder_for(plan_without_accounts(false)).is_ok()); + } + + #[test] + fn workspace_contracts_need_an_account() { + assert!(matches!( + builder_for(plan_without_accounts(true)), + Err(Error::NeedAtLeastOneAccount) + )); + } + #[test] fn scaffold_only_moves_aliases_it_owns() { // free, or already pointing at the new deployment diff --git a/crates/stellar-scaffold-cli/src/commands/build/env_toml.rs b/crates/stellar-scaffold-cli/src/commands/build/env_toml.rs index 1c8e19f0..a709325b 100644 --- a/crates/stellar-scaffold-cli/src/commands/build/env_toml.rs +++ b/crates/stellar-scaffold-cli/src/commands/build/env_toml.rs @@ -267,6 +267,18 @@ impl Environment { } Ok(current_env) } + + /// Version 2 contracts built from a crate (`type: workspace`), which need + /// cargo to compile and an account to deploy. Contracts referenced by + /// `id` need neither. + pub fn workspace_contracts(&self) -> Vec<&str> { + self.contracts + .iter() + .flatten() + .filter(|(_, contract)| contract.crate_name().is_some()) + .map(|(name, _)| &**name) + .collect() + } } impl From<&Network> for stellar_cli::config::network::Args { diff --git a/crates/stellar-scaffold-cli/src/commands/build/mod.rs b/crates/stellar-scaffold-cli/src/commands/build/mod.rs index 9d832edb..81885335 100644 --- a/crates/stellar-scaffold-cli/src/commands/build/mod.rs +++ b/crates/stellar-scaffold-cli/src/commands/build/mod.rs @@ -76,6 +76,28 @@ pub enum Error { EmptyPackageName(Utf8PathBuf), #[error(transparent)] Config(#[from] v2::Error), + #[error( + "⛔ ️{} `type: workspace`, so building it needs a Cargo workspace and a Rust toolchain, but cargo failed: {source}", + describe_workspace_contracts(.contracts) + )] + WorkspaceNeedsCargo { + contracts: Vec, + source: cargo_metadata::Error, + }, +} + +/// "contract `a` is" or "contracts `a`, `b` are", for [`Error::WorkspaceNeedsCargo`]. +fn describe_workspace_contracts(contracts: &[String]) -> String { + let names = contracts + .iter() + .map(|c| format!("`{c}`")) + .collect::>() + .join(", "); + if contracts.len() == 1 { + format!("contract {names} is") + } else { + format!("contracts {names} are") + } } /// What `build` and `watch` read from the project's config before compiling. @@ -120,7 +142,10 @@ impl Command { pub async fn run(&self, global_args: &global::Args) -> Result<(), Error> { let printer = Print::new(global_args.quiet); - let metadata = self.metadata()?; + let metadata = match self.metadata() { + Ok(metadata) => metadata, + Err(e) => return self.run_without_cargo(global_args, e).await, + }; let packages = self.list_packages(&metadata)?; let workspace_root = metadata.workspace_root.as_std_path(); if !v2::is_v2(workspace_root) { @@ -216,22 +241,81 @@ impl Command { .await; if build_clients { - let mut build_clients_args = self.build_clients_args.clone(); - // Pass through the workspace_root, out_dir, global_args, and printer - build_clients_args.workspace_root = Some(metadata.workspace_root.into_std_path_buf()); - build_clients_args.out_dir.clone_from(&self.build.out_dir); - build_clients_args.global_args = Some(global_args.clone()); - build_clients_args.extensions = extensions; - build_clients_args.compile_ctx = Some(post_compile_ctx); - build_clients_args.plan = plan; - build_clients_args - .run(packages.iter().map(|p| p.name.replace('-', "_")).collect()) - .await?; + self.clients_args( + metadata.workspace_root.into_std_path_buf(), + global_args, + extensions, + Some(post_compile_ctx), + plan, + ) + .run(packages.iter().map(|p| p.name.replace('-', "_")).collect()) + .await?; } Ok(()) } + /// Build a version 2 project that cargo can't find a workspace for, such + /// as a TypeScript-only app or a machine without a Rust toolchain. With no + /// `type: workspace` contract on the selected network there is nothing to + /// compile, so this goes straight to generating clients for the contracts + /// `scaffold.yml` references. Anything else fails with `cargo_error`, as + /// before. + async fn run_without_cargo( + &self, + global_args: &global::Args, + cargo_error: cargo_metadata::Error, + ) -> Result<(), Error> { + let printer = Print::new(global_args.quiet); + let root = crate::config::root_without_cargo(self.build.manifest_path.as_deref()); + // `--list` and `--compile-only` only make sense with crates to compile. + if self.list || !v2::is_v2(&root) || !self.builds_clients(&root, &printer) { + return Err(cargo_error.into()); + } + + let plan = self.v2_plan(&root, None, &[], &printer)?; + let contracts: Vec = plan + .workspace_contracts() + .into_iter() + .map(str::to_string) + .collect(); + if !contracts.is_empty() { + return Err(Error::WorkspaceNeedsCargo { + contracts, + source: cargo_error, + }); + } + printer.infoln("No Cargo workspace found; nothing to compile"); + Self::start_network_if_local(&plan.network, &printer).await?; + + // No compile step, so no compile hooks and no compile context. + let extensions = extension::discover(&plan.extensions, &printer); + self.clients_args(root, global_args, extensions, None, Some(plan)) + .run(vec![]) + .await?; + Ok(()) + } + + /// Arguments for the client pipeline, which deploys and generates clients + /// after compiling. + fn clients_args( + &self, + workspace_root: std::path::PathBuf, + global_args: &global::Args, + extensions: Vec, + compile_ctx: Option, + plan: Option, + ) -> clients::ClientsArgs { + let mut args = self.build_clients_args.clone(); + args.workspace_root = Some(workspace_root); + args.out_dir.clone_from(&self.build.out_dir); + args.global_args = Some(global_args.clone()); + args.extensions = extensions; + args.compile_ctx = compile_ctx; + args.plan = plan; + args + } + /// Whether this build deploys and generates clients after compiling. /// A version 2 `scaffold.yml` declares what to deploy and which clients /// to generate for each network, so it always does; environments.toml @@ -328,10 +412,13 @@ impl Command { ); } let crates = crate::config::cdylib_crates(metadata); - let network_flag = self.build_clients_args.network.as_deref(); if !resolve_network { - let (extensions, env_label) = - v2::hook_inputs(workspace_root, Some(&crates), network_flag, printer)?; + let (extensions, env_label) = v2::hook_inputs( + workspace_root, + Some(&crates), + self.build_clients_args.network.as_deref(), + printer, + )?; return Ok(Inputs { plan: None, extensions, @@ -339,9 +426,29 @@ impl Command { }); } - let (network, source) = v2::select_network(network_flag); + let plan = self.v2_plan(workspace_root, Some(&crates), packages, printer)?; + Self::start_network_if_local(&plan.network, printer).await?; + Ok(Inputs { + extensions: plan.extensions.clone(), + env_label: plan.network.name.clone().unwrap_or_default(), + plan: Some(plan), + }) + } + + /// Resolve `scaffold.yml` for the selected network, naming any built + /// `packages` that no contract lists as not being deployed. `crates` is + /// `None` without a cargo workspace, which skips checking that + /// `workspace` sources name real crates. + fn v2_plan( + &self, + workspace_root: &Path, + crates: Option<&[(String, std::path::PathBuf)]>, + packages: &[Package], + printer: &Print, + ) -> Result { + let (network, source) = v2::select_network(self.build_clients_args.network.as_deref()); printer.infoln(format!("Building for network {network:?} (from {source})")); - let plan = v2::plan(workspace_root, Some(&crates), &network, printer)?; + let plan = v2::plan(workspace_root, crates, &network, printer)?; let unlisted = v2::unlisted_crates(packages.iter().map(|p| p.name.as_str()), &plan); if !unlisted.is_empty() { printer.infoln(format!( @@ -351,16 +458,20 @@ impl Command { if unlisted.len() == 1 { "it" } else { "them" } )); } - if plan.network.run_locally { + Ok(plan) + } + + /// Start a version 2 network's local container, if it runs one. + async fn start_network_if_local( + network: &env_toml::Network, + printer: &Print, + ) -> Result<(), Error> { + if network.run_locally { printer.infoln("Starting local Stellar Docker container..."); - start_container_if_local(&plan.network).await?; + start_container_if_local(network).await?; printer.checkln("Local Stellar network is healthy and running."); } - Ok(Inputs { - extensions: plan.extensions.clone(), - env_label: network, - plan: Some(plan), - }) + Ok(()) } fn packages(&self, metadata: &Metadata) -> Result, Error> { diff --git a/crates/stellar-scaffold-cli/src/config/mod.rs b/crates/stellar-scaffold-cli/src/config/mod.rs index f4b5bf5a..180bf709 100644 --- a/crates/stellar-scaffold-cli/src/config/mod.rs +++ b/crates/stellar-scaffold-cli/src/config/mod.rs @@ -118,12 +118,10 @@ impl Workspace { cmd.manifest_path(path); } let Ok(metadata) = cmd.exec() else { - let root = manifest_path - .and_then(Path::parent) - .map(Path::to_path_buf) - .or_else(|| std::env::current_dir().ok()) - .unwrap_or_default(); - return Self { root, crates: None }; + return Self { + root: root_without_cargo(manifest_path), + crates: None, + }; }; Self { crates: Some(cdylib_crates(&metadata)), @@ -137,6 +135,17 @@ impl Workspace { } } +/// The project root when cargo can't locate a workspace, for example in a +/// TypeScript-only project or without a Rust toolchain: the directory of +/// `manifest_path` if given, otherwise the current directory. +pub fn root_without_cargo(manifest_path: Option<&Path>) -> PathBuf { + manifest_path + .and_then(Path::parent) + .map(Path::to_path_buf) + .or_else(|| std::env::current_dir().ok()) + .unwrap_or_default() +} + /// `(package name, directory)` of every `cdylib` crate in the workspace. pub fn cdylib_crates(metadata: &cargo_metadata::Metadata) -> Vec<(String, PathBuf)> { metadata diff --git a/crates/stellar-scaffold-cli/tests/it/build_clients/scaffold_v2.rs b/crates/stellar-scaffold-cli/tests/it/build_clients/scaffold_v2.rs index 3978e1c6..4ca8d530 100644 --- a/crates/stellar-scaffold-cli/tests/it/build_clients/scaffold_v2.rs +++ b/crates/stellar-scaffold-cli/tests/it/build_clients/scaffold_v2.rs @@ -125,3 +125,61 @@ fn removed_client_is_not_exported() { assert!(!index.contains("export const counter = "), "{index}"); }); } + +/// A TypeScript-only project, with no `Cargo.toml`, gets a client for a +/// contract another project deployed, without compiling anything. +#[test] +fn client_for_a_deployed_contract_builds_without_cargo() { + TestEnv::from("soroban-init-boilerplate", |env| { + env.modify_file( + "scaffold.yml", + &scaffold_yml( + "me", + " hello: + type: workspace + source: soroban-hello-world-contract + networks: { test: } +", + ), + ); + let output = build(env); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let index = index_ts(env); + let id = index + .lines() + .find_map(|l| l.trim().strip_prefix("contractId: \"")) + .and_then(|l| l.strip_suffix("\",")) + .expect("hello's contract ID in index.ts") + .to_string(); + + let app = env.temp_dir.path().join("ts-only"); + std::fs::create_dir(&app).unwrap(); + std::fs::write( + app.join("scaffold.yml"), + scaffold_yml( + "", + &format!( + " hello:\n type: contract\n source: {id}\n networks: {{ test: }}\n" + ), + ), + ) + .unwrap(); + let output = env + .stellar_scaffold_custom_dir("build", &["--network", "test"], &app) + .env("PATH", TestEnv::stellar_path()) + .output() + .expect("Failed to execute command"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(output.status.success(), "{stderr}"); + assert!(stderr.contains("No Cargo workspace found"), "{stderr}"); + assert!(!app.join("Cargo.toml").exists()); + let index = std::fs::read_to_string(app.join("app-lib/clients/index.ts")).unwrap(); + assert!(index.contains("export const hello = "), "{index}"); + assert!(index.contains(&id), "{index}"); + assert!(app.join("app-lib/clients/hello/dist/index.js").exists()); + }); +} diff --git a/crates/stellar-scaffold-cli/tests/unit.rs b/crates/stellar-scaffold-cli/tests/unit.rs index 68230fc0..45c968af 100644 --- a/crates/stellar-scaffold-cli/tests/unit.rs +++ b/crates/stellar-scaffold-cli/tests/unit.rs @@ -120,3 +120,90 @@ fn init_builds_with_the_projects_toolchain() { ); } } + +/// A version 2 `scaffold.yml` for a project with no Cargo workspace: one +/// network, `testnet`, with the given extra settings, and the given +/// `contracts:` section (indented two spaces). +fn no_cargo_scaffold_yml(network: &str, contracts: &str) -> String { + format!("version: 2\nnetworks:\n testnet:\n{network}contracts:\n{contracts}") +} + +/// A contract that is already deployed, with no client so the build makes no +/// network calls. +const DEPLOYED_CONTRACT: &str = " xlm: + type: contract + source: CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC + client: false + networks: { testnet: } +"; + +/// Run `build --network testnet` in `env`, which has no `Cargo.toml`. +fn build_without_cargo(env: &TestEnv, args: &[&str]) -> assert_cmd::Command { + let mut cmd = env.stellar_scaffold_custom_dir("build", &["--network", "testnet"], &env.cwd); + cmd.args(args); + cmd +} + +/// A TypeScript-only project, with no `Cargo.toml` and no `contracts/`, only +/// references deployed contracts, so it builds without cargo and without any +/// accounts to sign with. +#[test] +fn builds_without_cargo_when_no_contract_is_a_workspace_crate() { + let env = TestEnv::new_empty(); + env.modify_file( + "scaffold.yml", + &no_cargo_scaffold_yml("", DEPLOYED_CONTRACT), + ); + let stderr = build_without_cargo(&env, &[]) + .assert() + .success() + .stderr_as_str(); + assert!(stderr.contains("No Cargo workspace found"), "{stderr}"); + assert!(!stderr.contains("cargo metadata"), "{stderr}"); +} + +/// A `workspace` contract still needs cargo; the error names it. +#[test] +fn workspace_contracts_need_cargo() { + let env = TestEnv::new_empty(); + env.modify_file( + "scaffold.yml", + &no_cargo_scaffold_yml( + " allow-deploy: true\n accounts: [me]\n", + " hello: + type: workspace + source: hello-world + networks: { testnet: } +", + ), + ); + let stderr = build_without_cargo(&env, &[]) + .assert() + .failure() + .stderr_as_str(); + assert!( + stderr.contains("contract `hello` is `type: workspace`"), + "{stderr}" + ); + assert!(stderr.contains("Cargo.toml"), "{stderr}"); +} + +/// Without a version 2 `scaffold.yml`, or with `--compile-only`, a missing +/// Cargo workspace is still cargo's error. +#[test] +fn without_cargo_other_builds_report_cargos_error() { + let env = TestEnv::new_empty(); + build_without_cargo(&env, &[]) + .assert() + .failure() + .stderr(predicates::str::contains("could not find `Cargo.toml`")); + + env.modify_file( + "scaffold.yml", + &no_cargo_scaffold_yml("", DEPLOYED_CONTRACT), + ); + build_without_cargo(&env, &["--compile-only"]) + .assert() + .failure() + .stderr(predicates::str::contains("could not find `Cargo.toml`")); +} diff --git a/docs/site/docs/cli.md b/docs/site/docs/cli.md index 94ae7c1f..d1a3c198 100644 --- a/docs/site/docs/cli.md +++ b/docs/site/docs/cli.md @@ -98,6 +98,8 @@ Options: `build` validates `scaffold.yml` before compiling and stops if it has errors. To only compile, use `stellar contract build`. Contracts that do not list the selected network are skipped: they are not deployed and not exported from the generated clients `index.ts`. Clients removed from `scaffold.yml` are dropped from `index.ts` on the next build. +A project with no Cargo workspace, such as a TypeScript-only app, can still run `build` as long as no contract it lists for the selected network is `type: workspace`. Nothing is compiled, so neither `Cargo.toml` nor a Rust toolchain is needed: `build` generates clients for the contracts `scaffold.yml` references. See [Projects without Rust contracts](./configuration.md#projects-without-rust-contracts). + ## Dev Command Start development mode with hot reloading: diff --git a/docs/site/docs/configuration.md b/docs/site/docs/configuration.md index 0822ff32..2ac0c2ad 100644 --- a/docs/site/docs/configuration.md +++ b/docs/site/docs/configuration.md @@ -194,6 +194,26 @@ networks: `allow-deploy: true` on a network with the mainnet passphrase is allowed, with a warning. +### Projects without Rust contracts + +A project that only uses contracts deployed elsewhere, such as a TypeScript-only app, doesn't need a `Cargo.toml`, a `contracts-dir` or a Rust toolchain. If no contract listed for the selected network is `type: workspace`, `build` compiles nothing and generates clients for the contracts `scaffold.yml` references. Such a network also needs no `accounts`, since nothing is deployed. + +```yaml +version: 2 + +networks: + testnet: + +contracts: + xlm: + type: contract + source: CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC + networks: + testnet: +``` + +Run `build` from the directory holding `scaffold.yml`. A `workspace` contract still needs a Cargo workspace to compile, and `build` stops with an error naming it if there isn't one. + ### Constructor arguments `args` maps each `__constructor` parameter, by name, to a value. Values are checked against the contract's spec by stellar-cli when it deploys, so write them as you would on the command line: strings, numbers and booleans as YAML scalars, vectors as lists, and structs or maps as maps.