Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 75 additions & 8 deletions crates/stellar-scaffold-cli/src/commands/build/clients.rs
Original file line number Diff line number Diff line change
Expand Up @@ -872,12 +872,14 @@ impl Builder {

async fn handle_contracts(&self, package_names: Vec<String>) -> 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();
Expand Down Expand Up @@ -1382,10 +1384,18 @@ impl ClientsArgs {
.map(|account| account.name.clone())
.collect::<Vec<_>>();

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)),
};

Expand All @@ -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(),
Expand Down Expand Up @@ -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<super::Builder, Error> {
#[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
Expand Down
12 changes: 12 additions & 0 deletions crates/stellar-scaffold-cli/src/commands/build/env_toml.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
159 changes: 135 additions & 24 deletions crates/stellar-scaffold-cli/src/commands/build/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
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::<Vec<_>>()
.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.
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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<String> = 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<extension::ResolvedExtension>,
compile_ctx: Option<CompileContext>,
plan: Option<env_toml::Environment>,
) -> 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
Expand Down Expand Up @@ -328,20 +412,43 @@ 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,
env_label,
});
}

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<env_toml::Environment, Error> {
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!(
Expand All @@ -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<Vec<Package>, Error> {
Expand Down
21 changes: 15 additions & 6 deletions crates/stellar-scaffold-cli/src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand All @@ -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
Expand Down
Loading
Loading