diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4bf2cdcb..5945044b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -105,8 +105,15 @@ jobs: echo "Could not derive protocol version from stellar-cli pin in Cargo.toml" >&2 exit 1 fi - echo "Starting local network at protocol $PROTOCOL_VERSION" - stellar container start local --protocol-version "$PROTOCOL_VERSION" + # Same pinned image the CLI starts; the floating `testing` tag can move + # to an RPC that no longer simulates this protocol. + IMAGE_TAG=$(sed -n 's/^pub const LOCAL_IMAGE_TAG: &str = "\(.*\)";/\1/p' crates/stellar-scaffold-cli/src/commands/build/docker.rs) + if [ -z "$IMAGE_TAG" ]; then + echo "Could not read LOCAL_IMAGE_TAG from docker.rs" >&2 + exit 1 + fi + echo "Starting local network at protocol $PROTOCOL_VERSION with quickstart:$IMAGE_TAG" + stellar container start local --protocol-version "$PROTOCOL_VERSION" --image-tag-override "$IMAGE_TAG" echo "Waiting for RPC + friendbot to become healthy..." for _ in $(seq 1 50); do if curl --no-progress-meter --fail-with-body -X POST "http://localhost:8000/soroban/rpc" \ diff --git a/Cargo.lock b/Cargo.lock index 0eb29a7b..2b904896 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3284,6 +3284,12 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c" +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + [[package]] name = "normalize-line-endings" version = "0.3.0" @@ -4717,11 +4723,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8050abb251097357e24aff63ba2c52a6309ecb7d23a5474023df960a02694d8" dependencies = [ "annotate-snippets", + "base64 0.21.7", "encoding_rs_io", "granit-parser", + "nohash-hasher", "num-traits", "serde_core", "smallvec", + "zmij", ] [[package]] @@ -4853,19 +4862,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "serde_yaml" -version = "0.9.34+deprecated" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" -dependencies = [ - "indexmap 2.14.0", - "itoa", - "ryu", - "serde", - "unsafe-libyaml", -] - [[package]] name = "sha1" version = "0.10.6" @@ -5520,7 +5516,6 @@ dependencies = [ "serde", "serde-saphyr", "serde_json", - "serde_yaml", "sha2 0.10.9", "shlex", "soroban-cli", @@ -6372,12 +6367,6 @@ dependencies = [ "subtle", ] -[[package]] -name = "unsafe-libyaml" -version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" - [[package]] name = "untrusted" version = "0.9.0" diff --git a/crates/stellar-scaffold-cli/Cargo.toml b/crates/stellar-scaffold-cli/Cargo.toml index a1ef7def..81d0087a 100644 --- a/crates/stellar-scaffold-cli/Cargo.toml +++ b/crates/stellar-scaffold-cli/Cargo.toml @@ -53,7 +53,7 @@ reqwest = { workspace = true, default-features = false, features = [ async-trait = "0.1" thiserror = "2.0.17" serde = "1.0.228" -serde_json = "1.0.149" +serde_json = { version = "1.0.149", features = ["preserve_order"] } tokio = { version = "1", features = ["full"] } tokio-stream = { version = "0.1", features = ["io-util"] } @@ -63,7 +63,6 @@ heck = "0.5.0" hex = "0.4.3" shlex = "1.1.0" toml = { version = "0.9", features = ["parse", "preserve_order"] } -serde_yaml = "0.9" semver = { version = "1", features = ["serde"] } rand = "0.8.5" notify = "8.2.0" @@ -76,7 +75,7 @@ tempfile = "3.27" fs_extra = "1.3" webbrowser = "1.2.1" strsim = "0.11" -serde-saphyr = { version = "1.3", default-features = false, features = ["deserialize"] } +serde-saphyr = { version = "1.3", default-features = false, features = ["deserialize", "serialize"] } # Cross-compiled release targets (musl via `cross`, freebsd via a foreign # sysroot) have no system OpenSSL or libdbus available to link against. The diff --git a/crates/stellar-scaffold-cli/src/commands/build/clients.rs b/crates/stellar-scaffold-cli/src/commands/build/clients.rs index 15363347..30efe181 100644 --- a/crates/stellar-scaffold-cli/src/commands/build/clients.rs +++ b/crates/stellar-scaffold-cli/src/commands/build/clients.rs @@ -4,8 +4,8 @@ use crate::arg_parsing; use crate::arg_parsing::ArgParser; use crate::commands::build::clients::Error::UpgradeArgsError; use crate::commands::build::env_toml::{self, Environment}; -use crate::commands::build::scaffold_yml::ScaffoldConfig; use crate::commands::{PackageManager, PackageManagerSpec}; +use crate::config::schema::Project; use crate::config::source::same_crate; use crate::extension::{self, ResolvedExtension}; use indexmap::IndexMap; @@ -185,7 +185,7 @@ pub struct Builder { pkg_manager: PackageManager, extensions: Vec, compile_ctx: Option, - scaffold_config: ScaffoldConfig, + project: Project, /// Name reported to extensions as the environment: the `ScaffoldEnv` /// for environments.toml projects, the network name for version 2. env_label: String, @@ -204,7 +204,7 @@ impl Builder { pkg_manager: PackageManager, extensions: Vec, compile_ctx: Option, - scaffold_config: ScaffoldConfig, + project: Project, ) -> Self { let env_label = if env.from_scaffold_yml { env.network.name.clone().unwrap_or_default() @@ -224,7 +224,7 @@ impl Builder { pkg_manager, extensions, compile_ctx, - scaffold_config, + project, } } @@ -403,13 +403,12 @@ impl Builder { /// Regenerate the single flattened Clients file `clients_dir/index.ts` from /// every Binding package currently present in `clients_dir`. Each Client is - /// exported as a camelCase const built from one shared `options` object; - /// only `contractId` is baked per contract. `allowHttp` and the - /// network passphrase/rpcUrl are resolved at runtime from the app's `network` - /// reader rather than baked. Idempotent — safe to call after every build, + /// exported as a camelCase const built from one shared `options` object, with + /// the network passphrase, rpcUrl, and `allowHttp` written at build time and + /// a `contractId` per contract. Idempotent, safe to call after every build, /// including incremental `watch` rebuilds of a single contract. fn regenerate_clients_index(&self) -> Result<(), Error> { - let clients_dir = self.workspace_root.join(&self.scaffold_config.clients_dir); + let clients_dir = self.workspace_root.join(&self.project.clients_dir); if !clients_dir.exists() { return Ok(()); } @@ -422,8 +421,7 @@ impl Builder { .collect(); let names = Self::maintain_user_ordering(&present, self.env.contracts.as_ref()); - let mut imports = String::new(); - let mut exports = String::new(); + let mut clients = Vec::new(); for name in &names { // Honor `.client = false` (per-contract, per-environment): // never export a Client for a contract whose client generation is @@ -449,23 +447,21 @@ impl Builder { let Some(contract_id) = self.resolve_contract_id(name)? else { continue; }; - let pascal = to_pascal_case(name); - let camel = to_camel_case(name); - // Writing to a String is infallible. - let _ = writeln!(imports, "import {{ Client as {pascal} }} from \"{name}\""); - let _ = write!( - exports, - "\nexport const {camel} = new {pascal}({{\n\t...options,\n\tcontractId: \"{contract_id}\",\n}})\n" - ); + clients.push((name.clone(), contract_id)); } - let contents = format!( - "// AUTO-GENERATED by `stellar scaffold` — do not edit.\n\ - // Regenerated on every build/redeploy from your deployed contracts.\n\ - // To customize a client, import it into your own code under app/ instead.\n\n\ - import {{ network }} from \"@stellar-scaffold/app-lib\"\n{imports}\n\ - const {{ passphrase: networkPassphrase, rpcUrl, id }} = network\n\n\ - const options = {{\n\tnetworkPassphrase,\n\trpcUrl,\n\tallowHttp: id === \"local\",\n\tpublicKey: undefined,\n}}\n{exports}" + // Plain HTTP default only for the local network, or if the network opts in + let allow_http = self + .env + .network + .allow_http + .unwrap_or(self.network.network_passphrase == network::passphrase::LOCAL); + + let contents = render_clients_index( + &clients, + &self.network.network_passphrase, + &self.network.rpc_url, + allow_http, ); std::fs::write(clients_dir.join("index.ts"), contents)?; @@ -483,13 +479,11 @@ impl Builder { let network = &self.network; let printer = self.printer(); let workspace_root = &self.workspace_root; - let final_output_dir = workspace_root - .join(&self.scaffold_config.clients_dir) - .join(name); + let final_output_dir = workspace_root.join(&self.project.clients_dir).join(name); // The generated Client now lives in the shared flattened index, not a // per-contract file; hooks see the index path as the codegen target. let src_template_path = workspace_root - .join(&self.scaffold_config.clients_dir) + .join(&self.project.clients_dir) .join("index.ts"); extension::run_hook( @@ -516,7 +510,7 @@ impl Builder { if rebuild { let temp_dir = workspace_root .join("target") - .join(&self.scaffold_config.clients_dir) + .join(&self.project.clients_dir) .join(name); let temp_dir_display = temp_dir.display(); let config_dir = self.get_config_dir()?; @@ -744,17 +738,25 @@ impl Builder { { continue; } - if let Some(id) = &settings.id - && self.client_has_contract_id(name, id) - { - printer.checkln(format!("Client {name:?} is up to date")); - continue; + if let Some(id) = &settings.id { + // A referenced contract without a client has nothing to build. + if !settings.client { + continue; + } + if self.client_has_contract_id(name, id) { + printer.checkln(format!("Client {name:?} is up to date")); + continue; + } } match self .process_single_contract(name, settings.clone(), &self.network, self.scaffold_env) .await { - Ok(()) => printer.checkln(format!("Successfully generated client for: {name}")), + Ok(()) if settings.client => { + printer.checkln(format!("Successfully generated client for: {name}")); + } + // Deployed for other contracts to use; the deploy step reported it. + Ok(()) => {} Err(e) => { printer.errorln(format!("Failed to generate client for: {name}: {e}")); failed.push(name.to_string()); @@ -774,7 +776,7 @@ impl Builder { fn client_has_contract_id(&self, name: &str, contract_id: &str) -> bool { let index = self .workspace_root - .join(&self.scaffold_config.clients_dir) + .join(&self.project.clients_dir) .join(name) .join("src/index.ts"); std::fs::read_to_string(index).is_ok_and(|s| binding_targets(&s, contract_id)) @@ -983,19 +985,22 @@ impl Builder { let needs_rebuild = deploy_kind != DeployKind::Unchanged || !self .workspace_root - .join(&self.scaffold_config.clients_dir) + .join(&self.project.clients_dir) .join(name) .exists(); (contract_id, Some(new_hash), needs_rebuild) }; - self.generate_contract_bindings( - name, - &contract_id.to_string(), - wasm_hash.as_deref(), - needs_rebuild, - ) - .await?; + // Without a client, the contract is only deployed for other contracts to depend on. + if settings.client { + self.generate_contract_bindings( + name, + &contract_id.to_string(), + wasm_hash.as_deref(), + needs_rebuild, + ) + .await?; + } Ok(()) } @@ -1315,13 +1320,9 @@ impl Args { _ => return Err(Error::OnlyOneDefaultAccount(default_account_candidates)), }; - let pkg_manager_spec = - PackageManagerSpec::from_package_json(workspace_root).unwrap_or(PackageManagerSpec { - kind: PackageManager::Npm, - version: None, - }); + let pkg_manager_spec = PackageManagerSpec::for_project(workspace_root); - let scaffold_config = ScaffoldConfig::get(workspace_root); + let project = crate::config::project(workspace_root); let builder = Builder::new( global_args, @@ -1334,7 +1335,7 @@ impl Args { pkg_manager_spec.kind, self.extensions.clone(), self.compile_ctx.clone(), - scaffold_config, + project, ); Ok(builder) } @@ -1366,6 +1367,46 @@ fn binding_targets(source: &str, contract_id: &str) -> bool { source.contains(&format!("contractId: \"{contract_id}\"")) } +/// Render the flattened Clients `index.ts` from `(binding name, contract ID)` +/// pairs in export order. Each Binding package is imported by relative paths so +/// the file works from any `clients_dir`, and network values are written as +/// JSON string literals so any passphrase or URL is escaped. +fn render_clients_index( + clients: &[(String, String)], + passphrase: &str, + rpc_url: &str, + allow_http: bool, +) -> String { + let mut imports = String::new(); + let mut exports = String::new(); + for (name, contract_id) in clients { + let pascal = to_pascal_case(name); + let camel = to_camel_case(name); + // Writing to String is infallible. + let _ = writeln!( + imports, + "import {{ Client as {pascal} }} from \"./{name}/dist/index.js\"" + ); + let _ = write!( + exports, + "\nexport const {camel} = new {pascal}({{\n\t...options,\n\tcontractId: \"{contract_id}\",\n}})\n" + ); + } + + // Serializing a string to JSON can't fail. + let passphrase = serde_json::to_string(passphrase).expect("string serializes"); + let rpc_url = serde_json::to_string(rpc_url).expect("string serializes"); + + format!( + "// AUTO-GENERATED by `stellar scaffold` - DO NOT EDIT.\n\ + // Regenerated on every build/redeploy from your deployed contracts.\n\ + // To customize a client, import it into your own code instead.\n\n\ + {imports}\n\ + export const options = {{\n\tnetworkPassphrase: {passphrase},\n\trpcUrl: {rpc_url},\n\tallowHttp: {allow_http},\n\tpublicKey: undefined,\n}}\n\ + {exports}" + ) +} + /// `snake_case` or `kebab-case` contract/package name → `PascalCase`, used /// as the imported binding `Client` alias (e.g. `guess_the_number` → /// `GuessTheNumber`). @@ -1453,51 +1494,10 @@ async fn fetch_contract_spec( } } -// #[cfg(test)] -// mod tests { -// use super::*; -// use tempfile::TempDir; - -// #[test] -// fn test_get_package_dir() { -// let temp_dir = TempDir::new().unwrap(); -// let package_path = temp_dir.path().join("packages/existing_package"); -// std::fs::create_dir_all(&package_path).unwrap(); -// let args = Args { -// env: Some(ScaffoldEnv::Development), -// workspace_root: Some(temp_dir.path().to_path_buf()), -// out_dir: None, -// global_args: None, -// }; -// let args = args.builder().unwrap(); -// let result = args.get_package_dir("existing_package"); -// assert!(result.is_ok()); -// let path = result.unwrap(); -// assert_eq!(path.file_name().unwrap(), "existing_package"); -// } - -// #[test] -// fn test_get_package_dir_nonexistent() { -// let args = Args { -// env: Some(ScaffoldEnv::Development), -// workspace_root: Some(std::path::PathBuf::from("tests/nonexistent_workspace")), -// out_dir: None, -// global_args: None, -// }; -// let args = args.builder().unwrap(); -// let result = args.get_package_dir("nonexistent_package"); -// assert!(result.is_err()); -// if let Err(Error::BadContractName(name)) = result { -// assert_eq!(name, "nonexistent_package"); -// } else { -// panic!("Expected BadContractName error"); -// } -// } -// } - #[cfg(test)] mod tests { - use super::{binding_targets, to_camel_case, to_pascal_case}; + use super::{binding_targets, render_clients_index, to_camel_case, to_pascal_case}; + use stellar_cli::config::network::passphrase; #[test] fn binding_target_matches_the_contract_id_field() { @@ -1521,4 +1521,25 @@ mod tests { "nftEnumerableExample" ); } + + #[test] + fn clients_index_imports_bindings_relatively() { + let clients = [("guess_the_number".to_string(), "CABC".to_string())]; + let index = + render_clients_index(&clients, passphrase::TESTNET, "https://rpc.example", false); + assert!(index.contains( + "import { Client as GuessTheNumber } from \"./guess_the_number/dist/index.js\"" + )); + assert!(index.contains("export const guessTheNumber = new GuessTheNumber({")); + assert!(index.contains("contractId: \"CABC\"")); + assert!(!index.contains("@stellar-scaffold/app-lib")); + } + + #[test] + fn clients_index_writes_escaped_network_values() { + let index = render_clients_index(&[], "Say \"hi\"", "http://localhost:8000/rpc", true); + assert!(index.contains(r#"networkPassphrase: "Say \"hi\"","#)); + assert!(index.contains(r#"rpcUrl: "http://localhost:8000/rpc","#)); + assert!(index.contains("allowHttp: true,")); + } } diff --git a/crates/stellar-scaffold-cli/src/commands/build/docker.rs b/crates/stellar-scaffold-cli/src/commands/build/docker.rs index 67e6d0ce..4769eefb 100644 --- a/crates/stellar-scaffold-cli/src/commands/build/docker.rs +++ b/crates/stellar-scaffold-cli/src/commands/build/docker.rs @@ -8,11 +8,20 @@ use stellar_cli::{CommandParser, commands as cli}; /// so it tracks the protocol automatically when that dependency is bumped. const LOCAL_PROTOCOL_VERSION: &str = env!("LOCAL_PROTOCOL_VERSION"); +/// Quickstart image tag the local network runs. The floating `testing` tag +/// moves to new RPC releases, which only simulate the current and previous +/// protocol, so it must be pinned to an image that still simulates +/// [`LOCAL_PROTOCOL_VERSION`]. Bump it alongside the protocol. CI reads this +/// line to start the same image. +pub const LOCAL_IMAGE_TAG: &str = "v672-b1475.1-testing"; + pub async fn start_local_stellar(rpc_url: Option<&str>) -> Result<(), Box> { let result = cli::container::StartCmd::parse_arg_vec(&[ "local", "--protocol-version", LOCAL_PROTOCOL_VERSION, + "--image-tag-override", + LOCAL_IMAGE_TAG, ])? .run(&stellar_cli::commands::global::Args::default()) .await; 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 c1c6fee9..1c8e19f0 100644 --- a/crates/stellar-scaffold-cli/src/commands/build/env_toml.rs +++ b/crates/stellar-scaffold-cli/src/commands/build/env_toml.rs @@ -152,6 +152,10 @@ pub struct Network { pub rpc_headers: Option>, #[serde(skip_serializing_if = "is_false", default)] pub run_locally: bool, + /// Whether generated clients may use a plain `http://` RPC URL. `None` + /// allows it only for the local (standalone) passphrase. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub allow_http: Option, } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] diff --git a/crates/stellar-scaffold-cli/src/commands/build/mod.rs b/crates/stellar-scaffold-cli/src/commands/build/mod.rs index 64e6fc92..7a94b8eb 100644 --- a/crates/stellar-scaffold-cli/src/commands/build/mod.rs +++ b/crates/stellar-scaffold-cli/src/commands/build/mod.rs @@ -17,7 +17,6 @@ use stellar_scaffold_ext_types::{CompileContext, HookName}; pub mod clients; pub mod docker; pub mod env_toml; -pub mod scaffold_yml; pub mod v2; /// Build a contract from source diff --git a/crates/stellar-scaffold-cli/src/commands/build/scaffold_yml.rs b/crates/stellar-scaffold-cli/src/commands/build/scaffold_yml.rs deleted file mode 100644 index 7a893d12..00000000 --- a/crates/stellar-scaffold-cli/src/commands/build/scaffold_yml.rs +++ /dev/null @@ -1,236 +0,0 @@ -use std::path::Path; - -/// Name of the scaffold configuration file. -pub const CONFIG_FILE: &str = "scaffold.yml"; - -/// The `version:` of the original `config:`-only format. Version 2 is -/// `crate::config::SCHEMA_VERSION`; both are accepted. -pub const CURRENT_SCHEMA_VERSION: u32 = 1; - -#[derive(thiserror::Error, Debug)] -pub enum Error { - #[error( - "scaffold.yml is missing or has no 'version' field. \ - If migrating from environments.toml, rename it to scaffold.yml and add 'version: {CURRENT_SCHEMA_VERSION}' at the top. \ - See https://github.com/stellar-scaffold/cli/blob/main/CHANGELOG.md for details." - )] - MissingVersion, - #[error( - "scaffold.yml uses schema version {found}, but this CLI supports versions {CURRENT_SCHEMA_VERSION} and {v2}. \ - See https://github.com/stellar-scaffold/cli/blob/main/CHANGELOG.md for migration instructions.", - v2 = crate::config::SCHEMA_VERSION - )] - UnsupportedVersion { found: u32 }, -} - -/// Configurable directory paths read from the `config:` section of `scaffold.yml`. -/// -/// All fields default to the conventional locations used by the scaffold template. -/// -/// Example `scaffold.yml`: -/// ```yaml -/// config: -/// contracts_dir: contracts -/// clients_dir: app-lib/clients -/// ``` -#[derive(Debug, Clone, serde::Deserialize)] -#[serde(default)] -pub struct ScaffoldConfig { - /// Directory containing Rust/Soroban contract source (default: `"contracts"`). - pub contracts_dir: std::path::PathBuf, - /// Directory for the generated TypeScript contract layer the app imports - /// (default: `"app-lib/clients"`). Holds both the generated Contract Binding - /// npm packages (one per contract, in `clients_dir//`) and the single - /// flattened Contract Clients `index.ts` (`clients_dir/index.ts`), imported - /// by app code as `@stellar-scaffold/app-lib/clients`. CLI-owned. (The - /// former separate `bindings_dir` was folded into this one directory.) - pub clients_dir: std::path::PathBuf, -} - -impl Default for ScaffoldConfig { - fn default() -> Self { - Self { - contracts_dir: "contracts".into(), - clients_dir: "app-lib/clients".into(), - } - } -} - -/// Top-level structure of `scaffold.yml`. Version 1 keeps directories under -/// `config:`; version 2 keeps them under `project:`. -#[derive(Debug, serde::Deserialize, Default)] -struct ScaffoldFile { - version: Option, - #[serde(default)] - config: ScaffoldConfig, - #[serde(default)] - project: Option, -} - -/// The directory keys of a version 2 `project:` section. -#[derive(Debug, serde::Deserialize, Default)] -#[serde(rename_all = "kebab-case")] -struct ProjectDirs { - contracts_dir: Option, - clients_dir: Option, -} - -impl ScaffoldConfig { - /// Read `scaffold.yml` from `workspace_root` and return the `config:` section. - /// - /// Returns defaults if the file is absent, unreadable, or has no `config:` key. - /// This function reads the file fresh on every call — it is not cached. - pub fn get(workspace_root: &Path) -> ScaffoldConfig { - let path = workspace_root.join(CONFIG_FILE); - let Ok(contents) = std::fs::read_to_string(path) else { - return ScaffoldConfig::default(); - }; - let Ok(file) = serde_yaml::from_str::(&contents) else { - return ScaffoldConfig::default(); - }; - match file.project { - Some(project) if file.version == Some(crate::config::SCHEMA_VERSION) => { - let defaults = ScaffoldConfig::default(); - ScaffoldConfig { - contracts_dir: project.contracts_dir.unwrap_or(defaults.contracts_dir), - clients_dir: project.clients_dir.unwrap_or(defaults.clients_dir), - } - } - _ => file.config, - } - } -} - -/// Validate the `version:` field in `scaffold.yml`. -/// -/// Returns `Ok(())` only if the file exists and its version is -/// `CURRENT_SCHEMA_VERSION` or version 2. A missing file is treated as an outdated project -/// (no `scaffold.yml` means pre-versioning). A missing or unsupported version -/// field is also an error. -pub fn check_version(workspace_root: &Path) -> Result<(), Error> { - let path = workspace_root.join(CONFIG_FILE); - let Ok(contents) = std::fs::read_to_string(path) else { - return Err(Error::MissingVersion); - }; - let Ok(file) = serde_yaml::from_str::(&contents) else { - return Err(Error::MissingVersion); - }; - match file.version { - None => Err(Error::MissingVersion), - Some(v) if v != CURRENT_SCHEMA_VERSION && v != crate::config::SCHEMA_VERSION => { - Err(Error::UnsupportedVersion { found: v }) - } - Some(_) => Ok(()), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::path::PathBuf; - - #[test] - fn defaults_when_file_absent() { - let dir = tempfile::TempDir::new().unwrap(); - let config = ScaffoldConfig::get(dir.path()); - assert_eq!(config.contracts_dir, PathBuf::from("contracts")); - assert_eq!(config.clients_dir, PathBuf::from("app-lib/clients")); - } - - #[test] - fn reads_custom_config() { - let dir = tempfile::TempDir::new().unwrap(); - std::fs::write( - dir.path().join(CONFIG_FILE), - "config:\n contracts_dir: my_contracts\n clients_dir: frontend/contracts\n", - ) - .unwrap(); - let config = ScaffoldConfig::get(dir.path()); - assert_eq!(config.contracts_dir, PathBuf::from("my_contracts")); - assert_eq!(config.clients_dir, PathBuf::from("frontend/contracts")); - } - - #[test] - fn defaults_when_no_config_section() { - let dir = tempfile::TempDir::new().unwrap(); - std::fs::write( - dir.path().join(CONFIG_FILE), - "# just a comment, no config section\n", - ) - .unwrap(); - let config = ScaffoldConfig::get(dir.path()); - assert_eq!(config.contracts_dir, PathBuf::from("contracts")); - assert_eq!(config.clients_dir, PathBuf::from("app-lib/clients")); - } - - #[test] - fn check_version_err_when_file_absent() { - let dir = tempfile::TempDir::new().unwrap(); - assert!(matches!( - check_version(dir.path()), - Err(Error::MissingVersion) - )); - } - - #[test] - fn check_version_ok_for_current_version() { - let dir = tempfile::TempDir::new().unwrap(); - std::fs::write( - dir.path().join(CONFIG_FILE), - format!("version: {CURRENT_SCHEMA_VERSION}\n"), - ) - .unwrap(); - assert!(check_version(dir.path()).is_ok()); - } - - #[test] - fn check_version_err_when_version_missing() { - let dir = tempfile::TempDir::new().unwrap(); - std::fs::write( - dir.path().join(CONFIG_FILE), - "config:\n contracts_dir: contracts\n", - ) - .unwrap(); - assert!(matches!( - check_version(dir.path()), - Err(Error::MissingVersion) - )); - } - - #[test] - fn check_version_err_for_unsupported_version() { - let dir = tempfile::TempDir::new().unwrap(); - std::fs::write(dir.path().join(CONFIG_FILE), "version: 99\n").unwrap(); - assert!(matches!( - check_version(dir.path()), - Err(Error::UnsupportedVersion { found: 99 }) - )); - } - - #[test] - fn reads_version_2_project_dirs() { - let dir = tempfile::TempDir::new().unwrap(); - std::fs::write( - dir.path().join(CONFIG_FILE), - "version: 2\nproject:\n clients-dir: src/contracts\nnetworks: {}\n", - ) - .unwrap(); - let config = ScaffoldConfig::get(dir.path()); - assert_eq!(config.contracts_dir, PathBuf::from("contracts")); - assert_eq!(config.clients_dir, PathBuf::from("src/contracts")); - assert!(check_version(dir.path()).is_ok()); - } - - #[test] - fn partial_config_uses_defaults_for_missing_fields() { - let dir = tempfile::TempDir::new().unwrap(); - std::fs::write( - dir.path().join(CONFIG_FILE), - "config:\n clients_dir: my_clients\n", - ) - .unwrap(); - let config = ScaffoldConfig::get(dir.path()); - assert_eq!(config.contracts_dir, PathBuf::from("contracts")); - assert_eq!(config.clients_dir, PathBuf::from("my_clients")); - } -} diff --git a/crates/stellar-scaffold-cli/src/commands/build/v2.rs b/crates/stellar-scaffold-cli/src/commands/build/v2.rs index 7a9bb37c..171f03df 100644 --- a/crates/stellar-scaffold-cli/src/commands/build/v2.rs +++ b/crates/stellar-scaffold-cli/src/commands/build/v2.rs @@ -145,6 +145,7 @@ fn translate( network_passphrase: Some(network_passphrase), rpc_headers: Some(rpc_headers), run_locally: net.start_container(), + allow_http: Some(net.allow_http()), }, contracts: Some(contracts), extensions: extension_entries(config), @@ -211,7 +212,10 @@ fn pipeline_entry( if c.after_deploy_script.is_some() { return Err(unsupported("`after-deploy-script`".to_string())); } - let mut entry = env_toml::Contract::default(); + let mut entry = env_toml::Contract { + client: c.client, + ..env_toml::Contract::default() + }; match c.ty { SourceType::Workspace => { entry.resolved = Some(ResolvedDeploy { @@ -322,6 +326,18 @@ contracts: assert_eq!(contracts.get("dex").unwrap().id.as_deref(), Some(CONTRACT)); } + #[test] + fn translates_client_and_allow_http() { + let yaml = format!( + "version: 2\nnetworks:\n local: {{}}\ncontracts:\n a:\n type: contract\n source: {CONTRACT}\n client: false\n networks: {{ local: }}\n b:\n type: contract\n source: {CONTRACT}\n networks: {{ local: }}\n" + ); + let env = translate_yaml(&yaml, "local").unwrap(); + assert_eq!(env.network.allow_http, Some(true)); + let contracts = env.contracts.unwrap(); + assert!(!contracts["a"].client); + assert!(contracts["b"].client); + } + #[test] fn contracts_not_listing_the_selected_network_are_skipped() { let yaml = format!( diff --git a/crates/stellar-scaffold-cli/src/commands/clean.rs b/crates/stellar-scaffold-cli/src/commands/clean.rs index 2db58e9a..a11514bc 100644 --- a/crates/stellar-scaffold-cli/src/commands/clean.rs +++ b/crates/stellar-scaffold-cli/src/commands/clean.rs @@ -1,6 +1,6 @@ use crate::commands::build::clients::ScaffoldEnv; use crate::commands::build::env_toml::{self, Account, Environment}; -use crate::commands::build::scaffold_yml::ScaffoldConfig; +use crate::config::{self, schema::Project}; use cargo_metadata::Metadata; use clap::Parser; use std::{ @@ -60,9 +60,9 @@ impl Cmd { Self::clean_target_stellar(&cargo_meta, &printer)?; let workspace_root: PathBuf = cargo_meta.workspace_root.into(); - let scaffold_config = ScaffoldConfig::get(&workspace_root); + let project = config::project(&workspace_root); - Self::clean_clients(&workspace_root, &scaffold_config, &printer)?; + Self::clean_clients(&workspace_root, &project, &printer)?; Self::clean_contract_aliases(&workspace_root, &printer)?; @@ -89,11 +89,11 @@ impl Cmd { /// entries and `.gitkeep` are preserved. fn clean_clients( workspace_root: &Path, - scaffold_config: &ScaffoldConfig, + project: &Project, printer: &Print, ) -> Result<(), Error> { - let clients_path = workspace_root.join(&scaffold_config.clients_dir); - let clients_dir_str = scaffold_config + let clients_path = workspace_root.join(&project.clients_dir); + let clients_dir_str = project .clients_dir .to_str() .unwrap_or("app-lib/clients") diff --git a/crates/stellar-scaffold-cli/src/commands/config/show.rs b/crates/stellar-scaffold-cli/src/commands/config/show.rs index 9fb02305..9009f06a 100644 --- a/crates/stellar-scaffold-cli/src/commands/config/show.rs +++ b/crates/stellar-scaffold-cli/src/commands/config/show.rs @@ -26,7 +26,7 @@ pub enum Error { #[error("{file} has errors; run `stellar scaffold config check` for details", file = config::CONFIG_FILE)] Invalid, #[error(transparent)] - Yaml(#[from] serde_yaml::Error), + Yaml(#[from] serde_saphyr::ser::Error), #[error(transparent)] Json(#[from] serde_json::Error), } @@ -45,7 +45,7 @@ impl Cmd { let env = |name: &str| std::env::var(name).ok(); match config::resolved_view(config, &self.network, &env) { Ok(view) if self.json => println!("{}", serde_json::to_string_pretty(&view)?), - Ok(view) => print!("{}", serde_yaml::to_string(&view)?), + Ok(view) => print!("{}", serde_saphyr::to_string(&view)?), Err(diags) => { loaded.diagnostics = diags; eprint!("{}", loaded.render()); diff --git a/crates/stellar-scaffold-cli/src/commands/doctor/checks/project.rs b/crates/stellar-scaffold-cli/src/commands/doctor/checks/project.rs index e81c3776..9c742b80 100644 --- a/crates/stellar-scaffold-cli/src/commands/doctor/checks/project.rs +++ b/crates/stellar-scaffold-cli/src/commands/doctor/checks/project.rs @@ -1,18 +1,17 @@ use std::path::Path; use crate::commands::build::env_toml::{self, ENV_FILE}; -use crate::commands::build::scaffold_yml::{self, CONFIG_FILE, ScaffoldConfig}; use crate::commands::check_engine_constraint; use crate::commands::doctor::diagnosis::{Category, Check, Context, Diagnosis}; use crate::commands::{EngineConstraintError, version}; -use crate::config; +use crate::config::{self, CONFIG_FILE}; use crate::extension::{ExtensionListStatus, list as list_extensions}; /// Where the schema-version fixes point for migration instructions. const MIGRATION_URL: &str = "https://github.com/stellar-scaffold/cli/blob/main/CHANGELOG.md"; -/// Validates `scaffold.yml`: schema version first, then the directories its -/// `config:` section points at. +/// Validates `scaffold.yml`: the full config for version 2, otherwise whether +/// the project falls back to `environments.toml`, and the default directories. pub struct ScaffoldYml; #[async_trait::async_trait] @@ -37,25 +36,30 @@ impl Check for ScaffoldYml { if let Some(loaded) = &ctx.config { return config_diagnoses(self.name(), root, loaded); } + // Version 2 files are loaded and reported above, so passing here means + // the project is configured by environments.toml. // Matched per variant rather than reusing the error text, which is too // long for one report line. The fix carries the migration link instead. - let current = scaffold_yml::CURRENT_SCHEMA_VERSION; - let version = match scaffold_yml::check_version(root) { - Ok(()) => Diagnosis::ok( + let current = config::SCHEMA_VERSION; + let version = match config::check_version(root) { + Ok(()) => Diagnosis::warn( self.name(), Category::Project, - format!("{CONFIG_FILE} schema version {current}"), - ), - Err(scaffold_yml::Error::MissingVersion) => Diagnosis::error( + format!("configured by the deprecated {ENV_FILE}"), + ) + .with_fix(format!( + "migrate to schema version {current}; see {MIGRATION_URL}" + )), + Err(config::VersionError::MissingVersion) => Diagnosis::error( self.name(), Category::Project, format!("{CONFIG_FILE} is missing or has no 'version' field"), ) .with_fix(format!( - "add 'version: {current}' to {CONFIG_FILE} (renaming environments.toml if migrating); see {MIGRATION_URL}" + "add 'version: {current}' to {CONFIG_FILE}; see {MIGRATION_URL}" )), - Err(scaffold_yml::Error::UnsupportedVersion { found }) => Diagnosis::error( + Err(config::VersionError::UnsupportedVersion { found }) => Diagnosis::error( self.name(), Category::Project, format!("{CONFIG_FILE} uses schema version {found}, this CLI supports {current}"), @@ -63,19 +67,19 @@ impl Check for ScaffoldYml { .with_fix(format!("see {MIGRATION_URL}")), }; - let config = ScaffoldConfig::get(root); + let project = config::project(root); vec![ version, dir_exists( "contracts-dir", root, - &config.contracts_dir, - "create it, or point config.contracts_dir at the right path", + &project.contracts_dir, + "create it, or migrate to schema version 2 and set project.contracts-dir", ), dir_exists( "clients-dir", root, - &config.clients_dir, + &project.clients_dir, "stellar scaffold build --build-clients", ), ] @@ -453,20 +457,38 @@ mod tests { } #[tokio::test] - async fn scaffold_yml_ok_with_current_version() { + async fn scaffold_yml_warns_on_environments_toml() { let dir = tempfile::TempDir::new().unwrap(); - std::fs::write(dir.path().join(CONFIG_FILE), "version: 1\n").unwrap(); + std::fs::write(dir.path().join(ENV_FILE), "").unwrap(); std::fs::create_dir_all(dir.path().join("contracts")).unwrap(); std::fs::create_dir_all(dir.path().join("app-lib/clients")).unwrap(); let printer = Print::new(true); let findings = ScaffoldYml.run(&context(Some(dir.path()), &printer)).await; - assert_eq!(severity_of(&findings, "scaffold-yml"), Severity::Ok); + assert_eq!(severity_of(&findings, "scaffold-yml"), Severity::Warn); assert_eq!(severity_of(&findings, "contracts-dir"), Severity::Ok); assert_eq!(severity_of(&findings, "clients-dir"), Severity::Ok); } + #[tokio::test] + async fn scaffold_yml_checks_version_1_dirs_beside_environments_toml() { + let dir = tempfile::TempDir::new().unwrap(); + std::fs::write(dir.path().join(ENV_FILE), "").unwrap(); + std::fs::write( + dir.path().join(CONFIG_FILE), + "version: 1\nconfig:\n contracts_dir: rust\n", + ) + .unwrap(); + // Only the custom directory exists, not the default `contracts/`. + std::fs::create_dir_all(dir.path().join("rust")).unwrap(); + + let printer = Print::new(true); + let findings = ScaffoldYml.run(&context(Some(dir.path()), &printer)).await; + + assert_eq!(severity_of(&findings, "contracts-dir"), Severity::Ok); + } + /// A context carrying a version 2 `scaffold.yml` loaded from `root`. fn context_with_config<'a>(root: &'a Path, yaml: &str, printer: &'a Print) -> Context<'a> { std::fs::write(root.join(CONFIG_FILE), yaml).unwrap(); diff --git a/crates/stellar-scaffold-cli/src/commands/doctor/checks/toolchain.rs b/crates/stellar-scaffold-cli/src/commands/doctor/checks/toolchain.rs index 5b80cfe1..aaa99570 100644 --- a/crates/stellar-scaffold-cli/src/commands/doctor/checks/toolchain.rs +++ b/crates/stellar-scaffold-cli/src/commands/doctor/checks/toolchain.rs @@ -210,11 +210,8 @@ impl Check for NodeToolchain { } async fn run(&self, ctx: &Context<'_>) -> Vec { - // A contracts-only project has no package.json and needs no JS tooling. - let Some(spec) = ctx - .workspace_root - .and_then(PackageManagerSpec::from_package_json) - else { + // A project that declares no package manager needs no JS tooling. + let Some(spec) = ctx.workspace_root.and_then(PackageManagerSpec::declared) else { return vec![Diagnosis::skipped( self.name(), Category::Toolchain, @@ -233,7 +230,8 @@ impl Check for NodeToolchain { } } -/// Compares the installed package manager against the `packageManager` field. +/// Compares the installed package manager against the version the project +/// pins, in `scaffold.yml` or a legacy `package.json` `packageManager` field. fn package_manager_diagnosis(ctx: &Context<'_>, spec: &PackageManagerSpec) -> Diagnosis { const NAME: &str = "package-manager"; @@ -248,7 +246,7 @@ fn package_manager_diagnosis(ctx: &Context<'_>, spec: &PackageManagerSpec) -> Di Some(pinned) if pinned != installed => Diagnosis::warn( NAME, Category::Toolchain, - format!("package.json pins {command}@{pinned}, found {installed}"), + format!("project pins {command}@{pinned}, found {installed}"), ) .with_fix(format!("corepack use {command}@{pinned}")), _ => Diagnosis::ok(NAME, Category::Toolchain, format!("{command} {installed}")), @@ -466,6 +464,28 @@ mod tests { assert_eq!(pm.fix.as_deref(), Some("corepack use pnpm@9.6.0")); } + #[tokio::test] + async fn node_warns_when_scaffold_yml_pin_differs() { + let dir = tempfile::TempDir::new().unwrap(); + std::fs::write( + dir.path().join("scaffold.yml"), + "version: 2\nproject:\n package-manager: pnpm@9.6.0\n", + ) + .unwrap(); + + let commands = FakeCommands::new() + .succeeding("node", "v24.16.0") + .succeeding("pnpm", "8.15.1"); + let printer = Print::new(true); + let findings = NodeToolchain + .run(&context(&commands, Some(dir.path()), &printer)) + .await; + + let pm = named(&findings, "package-manager"); + assert_eq!(pm.severity, Severity::Warn); + assert!(pm.message.contains("pnpm@9.6.0")); + } + #[tokio::test] async fn node_errors_when_package_manager_missing() { let dir = tempfile::TempDir::new().unwrap(); diff --git a/crates/stellar-scaffold-cli/src/commands/init/instantiate.rs b/crates/stellar-scaffold-cli/src/commands/init/instantiate.rs index 67f46c6b..0976e268 100644 --- a/crates/stellar-scaffold-cli/src/commands/init/instantiate.rs +++ b/crates/stellar-scaffold-cli/src/commands/init/instantiate.rs @@ -10,6 +10,7 @@ use std::fs; use std::path::Path; use crate::commands::{PackageManager, PackageManagerSpec}; +use crate::config::CONFIG_FILE; /// The directory inside the UI monorepo that holds per-framework templates pub const TEMPLATES_DIR: &str = "templates"; @@ -59,6 +60,8 @@ pub enum Error { PackageJson(#[from] serde_json::Error), #[error("malformed environments.toml: {0}")] EnvToml(#[from] toml_edit::TomlError), + #[error("couldn't update scaffold.yml automatically; set {0} by hand")] + ScaffoldYmlEdit(&'static str), } /// How `--template` (or the interactive prompt) selected a source. @@ -132,8 +135,9 @@ pub fn instantiate(root: &Path, framework: &str) -> Result<(), Error> { /// Assemble the no-frontend layout: strip every JS workspace and node config /// from the acquired monorepo, leaving only the Cargo/contracts side, and set -/// `client = false` on every contract in `environments.toml` so `build`/`watch` -/// never generate (or deploy for) client packages there is no app to consume. +/// `client: false` on every contract in `scaffold.yml` (or `client = false` in +/// a legacy `environments.toml`) so `build`/`watch` never generate client +/// packages there is no app to consume. pub fn instantiate_no_frontend(root: &Path) -> Result<(), Error> { if !root.join(TEMPLATES_DIR).is_dir() { return Err(Error::NoTemplatesDir); @@ -146,9 +150,38 @@ pub fn instantiate_no_frontend(root: &Path) -> Result<(), Error> { fs::remove_file(&path)?; } } + disable_clients_in_scaffold_yml(root)?; disable_clients_in_env_toml(root) } +/// Set `client: false` on every contract in `scaffold.yml`, preserving its +/// comments and layout. A missing file is left for `prepare` to report. +fn disable_clients_in_scaffold_yml(root: &Path) -> Result<(), Error> { + let path = root.join(CONFIG_FILE); + let Ok(contents) = fs::read_to_string(&path) else { + return Ok(()); + }; + let updated = crate::config::edit::disable_clients(&contents) + .ok_or(Error::ScaffoldYmlEdit("`client: false` on each contract"))?; + fs::write(&path, updated)?; + Ok(()) +} + +/// Record `spec` as `project.package-manager` in `scaffold.yml`, with its +/// version when known, preserving comments and layout. A missing file is left +/// for `prepare` to report. +pub fn write_package_manager(root: &Path, spec: &PackageManagerSpec) -> Result<(), Error> { + let path = root.join(CONFIG_FILE); + let Ok(contents) = fs::read_to_string(&path) else { + return Ok(()); + }; + let updated = + crate::config::edit::set_project_key(&contents, "package-manager", &spec.to_string()) + .ok_or(Error::ScaffoldYmlEdit("`project.package-manager`"))?; + fs::write(&path, updated)?; + Ok(()) +} + /// Set `client = false` on every contract entry in every environment of /// `environments.toml`, preserving formatting and comments. Contracts absent /// from the file default to `client = true` at build time, so only listed @@ -220,11 +253,11 @@ fn pnpm_workspace_yaml() -> String { out } -/// Apply the selected package manager to the instantiated project: write the -/// `packageManager` field, emit any manager-specific workspace/config file, and +/// Apply the selected package manager to the instantiated project: record it +/// in `scaffold.yml`, emit any manager-specific workspace/config file, and /// drop the npm lockfile when switching away from npm. Pure filesystem work. pub fn apply_package_manager(root: &Path, spec: &PackageManagerSpec) -> Result<(), Error> { - spec.write_to_package_json(root)?; + write_package_manager(root, spec)?; match spec.kind { PackageManager::Pnpm => { @@ -272,6 +305,10 @@ mod tests { "{\"name\":\"@stellar-scaffold/app-lib\"}", ); write(&root.join("contracts/.gitkeep"), ""); + write( + &root.join("scaffold.yml"), + "version: 2\n\n# Comment kept\nproject:\n contracts-dir: contracts\n\ncontracts:\n guess:\n type: workspace\n source: guess\n networks:\n local:\n", + ); // e2e is kept post-init; its per-framework snapshot baselines are not. write(&root.join("e2e/tests/smoke.spec.ts"), "// smoke"); write( @@ -472,6 +509,17 @@ mod tests { ); } + #[test] + fn no_frontend_disables_clients_in_scaffold_yml() { + let dir = fixture(); + let root = dir.path(); + instantiate_no_frontend(root).unwrap(); + + let contents = fs::read_to_string(root.join("scaffold.yml")).unwrap(); + assert!(contents.contains("# Comment kept"), "comments preserved"); + assert!(contents.contains(" guess:\n client: false\n type: workspace\n")); + } + #[test] fn no_frontend_errors_without_templates_dir() { let dir = tempfile::tempdir().unwrap(); @@ -506,8 +554,12 @@ mod tests { assert!(yaml.contains("- \"app-lib\"")); assert!(yaml.contains("- \"app-lib/clients/*\"")); + let config = fs::read_to_string(root.join("scaffold.yml")).unwrap(); + assert!(config.contains(" contracts-dir: contracts\n package-manager: pnpm@1.2.3\n")); + assert!(config.contains("# Comment kept")); + let pkg = fs::read_to_string(root.join("package.json")).unwrap(); - assert!(pkg.contains("\"packageManager\": \"pnpm@1.2.3\"")); + assert!(!pkg.contains("packageManager")); } #[test] diff --git a/crates/stellar-scaffold-cli/src/commands/init/mod.rs b/crates/stellar-scaffold-cli/src/commands/init/mod.rs index 128343c5..d805e641 100644 --- a/crates/stellar-scaffold-cli/src/commands/init/mod.rs +++ b/crates/stellar-scaffold-cli/src/commands/init/mod.rs @@ -8,7 +8,7 @@ use std::path::{Path, PathBuf}; use std::{env, io}; use super::setup; -use crate::commands::PackageManager; +use crate::commands::{PackageManager, PackageManagerSpec}; use stellar_cli::{commands::global, print::Print}; pub mod instantiate; @@ -162,16 +162,24 @@ impl Cmd { return Ok(()); } - // Gather the package-manager choice up front (both paths need it). - // `--template` signals non-interactive intent (the framework is already - // chosen), so default the package manager to npm rather than prompting; - // `init` is only fully interactive when no template is given. - let pkg_manager = setup::resolve_pkg_manager( - self.package_manager.as_ref(), - &printer, - self.yes || self.template.is_some(), - ) - .ok_or(Error::NoPackageManager)?; + // Gather the package-manager choice up front (both paths need it). A + // community repo's declared manager wins unless `-p` overrides it. + // Otherwise `--template` signals non-interactive intent (the framework + // is already chosen), so default to npm rather than prompting; `init` + // is only fully interactive when no template is given. + let declared = match (&source, &self.package_manager) { + (Source::Community(_), None) => PackageManagerSpec::declared(&absolute_project_path), + _ => None, + }; + let pkg_manager = declared + .or_else(|| { + setup::resolve_pkg_manager( + self.package_manager.as_ref(), + &printer, + self.yes || self.template.is_some(), + ) + }) + .ok_or(Error::NoPackageManager)?; // instantiate: official templates promote one framework + apply pkg-mgr fs. match &source { @@ -180,8 +188,10 @@ impl Cmd { instantiate::apply_package_manager(&absolute_project_path, &pkg_manager)?; } Source::Community(_) => { - // Community repos own their own layout; just record the manager. - let _ = pkg_manager.write_to_package_json(&absolute_project_path); + // Community repos own their own layout; only record an explicit `-p`. + if self.package_manager.is_some() { + instantiate::write_package_manager(&absolute_project_path, &pkg_manager)?; + } } // Both settled above. Source::Official(None) | Source::NoFrontend => unreachable!(), diff --git a/crates/stellar-scaffold-cli/src/commands/mod.rs b/crates/stellar-scaffold-cli/src/commands/mod.rs index 62eeebbd..671973cd 100644 --- a/crates/stellar-scaffold-cli/src/commands/mod.rs +++ b/crates/stellar-scaffold-cli/src/commands/mod.rs @@ -7,7 +7,6 @@ use std::{ }; use clap::{CommandFactory, FromArgMatches, Parser}; -use regex::Regex; use stellar_cli; pub mod build; @@ -160,21 +159,21 @@ pub struct PackageManagerSpec { } impl PackageManagerSpec { - pub fn write_to_package_json(&self, workspace_root: &Path) -> io::Result<()> { - let pkg_path = workspace_root.join("package.json"); - let contents = - read_to_string(&pkg_path).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; - - let pm_field_value = match &self.version { - Some(version) => format!("{}@{}", self.kind.as_str(), version), - None => self.kind.as_str().to_string(), - }; - - let updated = set_package_manager_field(&contents, &pm_field_value) - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "malformed package.json"))?; + /// The package manager a project declares: `project.package-manager` in + /// `scaffold.yml`, else the legacy `packageManager` field in + /// `package.json`. `None` when the project names neither. + pub fn declared(workspace_root: &Path) -> Option { + crate::config::project(workspace_root) + .package_manager + .or_else(|| Self::from_package_json(workspace_root)) + } - std::fs::write(&pkg_path, updated)?; - Ok(()) + /// The package manager to run for a project: its declared one, else npm. + pub fn for_project(workspace_root: &Path) -> Self { + Self::declared(workspace_root).unwrap_or(Self { + kind: PackageManager::Npm, + version: None, + }) } pub fn from_package_json(workspace_root: &Path) -> Option { @@ -205,26 +204,39 @@ impl PackageManagerSpec { } } -/// Replace or insert the `packageManager` field in a package.json string, -/// preserving the original formatting of the rest of the file. -fn set_package_manager_field(json: &str, value: &str) -> Option { - let re = Regex::new(r#""packageManager"\s*:\s*"[^"]*""#).ok()?; - let replacement = format!(r#""packageManager": "{value}""#); - - if re.is_match(json) { - Some(re.replace(json, replacement.as_str()).into_owned()) - } else { - // Field absent — insert before the final closing brace - let insert_pos = json.rfind('}')?; - let before = &json[..insert_pos]; - let after = &json[insert_pos..]; - let before_trimmed = before.trim_end(); - let comma = if before_trimmed.ends_with(',') { - "" - } else { - "," +/// Parses `project.package-manager`: a name, optionally pinned to a version, +/// e.g. `pnpm` or `pnpm@9.6.0`. Unlike `packageManager` in `package.json`, an +/// unknown name is an error rather than falling back to npm. +impl FromStr for PackageManagerSpec { + type Err = String; + + fn from_str(value: &str) -> Result { + let (name, version) = match value.split_once('@') { + Some((name, version)) if !version.is_empty() => (name, Some(version.to_string())), + Some(_) => return Err(format!("`{value}` has no version after `@`")), + None => (value, None), }; - Some(format!("{before_trimmed}{comma}\n {replacement}\n{after}")) + let kind = ::from_str(name, false).map_err(|_| { + format!("unknown package manager `{name}`; expected npm, pnpm, yarn, bun or deno") + })?; + Ok(Self { kind, version }) + } +} + +impl std::fmt::Display for PackageManagerSpec { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match &self.version { + Some(version) => write!(f, "{}@{version}", self.kind.as_str()), + None => f.write_str(self.kind.as_str()), + } + } +} + +impl<'de> serde::Deserialize<'de> for PackageManagerSpec { + fn deserialize>(deserializer: D) -> Result { + String::deserialize(deserializer)? + .parse() + .map_err(serde::de::Error::custom) } } @@ -412,38 +424,51 @@ mod tests { } #[test] - fn set_package_manager_field_replaces_existing() { - let json = r#"{"name": "foo", "packageManager": "npm@10.0.0", "version": "1.0.0"}"#; - let result = set_package_manager_field(json, "pnpm@9.6.0").unwrap(); - assert!(result.contains(r#""packageManager": "pnpm@9.6.0""#)); - assert!(result.contains(r#""name": "foo""#)); - assert!(result.contains(r#""version": "1.0.0""#)); - } - - #[test] - fn set_package_manager_field_inserts_when_absent() { - let json = "{\n \"name\": \"foo\"\n}"; - let result = set_package_manager_field(json, "npm@11.0.0").unwrap(); - assert!(result.contains(r#""packageManager": "npm@11.0.0""#)); - assert!(result.contains(r#""name": "foo""#)); - assert!(serde_json::from_str::(&result).is_ok()); + fn declared_prefers_scaffold_yml_over_package_json() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("package.json"), + r#"{"packageManager": "yarn@1.22.19"}"#, + ) + .unwrap(); + let legacy = PackageManagerSpec::declared(dir.path()).unwrap(); + assert_eq!(legacy.kind, PackageManager::Yarn); + assert_eq!(legacy.version, Some("1.22.19".to_string())); + + std::fs::write( + dir.path().join("scaffold.yml"), + "version: 2\nproject:\n package-manager: pnpm@9.6.0\n", + ) + .unwrap(); + let declared = PackageManagerSpec::declared(dir.path()).unwrap(); + assert_eq!(declared.kind, PackageManager::Pnpm); + assert_eq!(declared.version, Some("9.6.0".to_string())); } #[test] - fn set_package_manager_field_inserts_produces_valid_json() { - // real-world multi-line package.json with trailing newline before closing brace - let json = "{\n \"name\": \"my-app\",\n \"version\": \"1.0.0\"\n}"; - let result = set_package_manager_field(json, "pnpm@9.6.0").unwrap(); - assert!(serde_json::from_str::(&result).is_ok()); - assert!(result.contains("\"packageManager\": \"pnpm@9.6.0\"")); + fn package_manager_spec_parses_name_and_optional_version() { + let bare: PackageManagerSpec = "deno".parse().unwrap(); + assert_eq!(bare.kind, PackageManager::Deno); + assert_eq!(bare.version, None); + assert_eq!(bare.to_string(), "deno"); + + let pinned: PackageManagerSpec = "pnpm@9.6.0".parse().unwrap(); + assert_eq!(pinned.kind, PackageManager::Pnpm); + assert_eq!(pinned.version, Some("9.6.0".to_string())); + assert_eq!(pinned.to_string(), "pnpm@9.6.0"); + + assert!("cargo".parse::().is_err()); + assert!("pnpm@".parse::().is_err()); } #[test] - fn set_package_manager_field_preserves_surrounding_content() { - let json = r#"{"scripts": {"start": "vite"}, "packageManager": "npm@10.0.0"}"#; - let result = set_package_manager_field(json, "bun@1.0.0").unwrap(); - assert!(result.contains(r#""scripts""#)); - assert!(result.contains(r#""packageManager": "bun@1.0.0""#)); + fn for_project_defaults_to_npm() { + let dir = tempfile::tempdir().unwrap(); + assert!(PackageManagerSpec::declared(dir.path()).is_none()); + assert_eq!( + PackageManagerSpec::for_project(dir.path()).kind, + PackageManager::Npm + ); } mod engine_constraint { @@ -535,26 +560,5 @@ mod tests { fn command_contains_name(#[case] pm: PackageManager) { assert!(pm.command().contains(pm.as_str())); } - - #[rstest] - #[case(PackageManager::Npm, Some("11.0.0".to_string()))] - #[case(PackageManager::Pnpm, Some("9.6.0".to_string()))] - #[case(PackageManager::Yarn, None)] - #[case(PackageManager::Bun, Some("1.1.0".to_string()))] - fn package_json_round_trip(#[case] pm: PackageManager, #[case] version: Option) { - let dir = tempfile::tempdir().unwrap(); - let pkg_path = dir.path().join("package.json"); - std::fs::write(&pkg_path, "{\n \"name\": \"test-app\"\n}").unwrap(); - - let spec = PackageManagerSpec { kind: pm, version }; - spec.write_to_package_json(dir.path()).unwrap(); - - let contents = std::fs::read_to_string(&pkg_path).unwrap(); - assert!(serde_json::from_str::(&contents).is_ok()); - - let recovered = PackageManagerSpec::from_package_json(dir.path()).unwrap(); - assert_eq!(recovered.kind, spec.kind); - assert_eq!(recovered.version, spec.version); - } } } diff --git a/crates/stellar-scaffold-cli/src/commands/setup.rs b/crates/stellar-scaffold-cli/src/commands/setup.rs index 3c2b62d1..7b2ea5f2 100644 --- a/crates/stellar-scaffold-cli/src/commands/setup.rs +++ b/crates/stellar-scaffold-cli/src/commands/setup.rs @@ -20,7 +20,7 @@ pub enum Error { #[error(transparent)] BuildError(Box), #[error(transparent)] - SchemaVersion(#[from] build::scaffold_yml::Error), + SchemaVersion(#[from] crate::config::VersionError), #[error(transparent)] EngineConstraint(#[from] EngineConstraintError), } @@ -39,7 +39,7 @@ pub async fn prepare( ) -> Result<(), Error> { let printer = Print::new(global_args.quiet); - build::scaffold_yml::check_version(project_path)?; + crate::config::check_version(project_path)?; check_engine_constraint(project_path)?; let env_path = project_path.join(".env"); @@ -64,7 +64,10 @@ pub async fn prepare( ensure_extensions_installed(project_path, &printer, yes); if let Some(pkg_manager) = pkg_manager { - run_install(pkg_manager.kind.command(), project_path, &printer); + // A frontend without a JS manifest has no dependencies to install. + if has_js_manifest(project_path) { + run_install(pkg_manager.kind.command(), project_path, &printer); + } printer.infoln("Compiling contracts and generating client packages..."); } else { printer.infoln("Compiling contracts..."); @@ -306,6 +309,13 @@ fn ensure_extensions_installed(project_path: &Path, printer: &Print, yes: bool) } } +/// Whether `root` has a manifest a JS package manager installs from. +fn has_js_manifest(root: &Path) -> bool { + ["package.json", "deno.json", "deno.jsonc"] + .iter() + .any(|m| root.join(m).is_file()) +} + fn git_exists() -> bool { Command::new("git").arg("--version").output().is_ok() } diff --git a/crates/stellar-scaffold-cli/src/commands/upgrade.rs b/crates/stellar-scaffold-cli/src/commands/upgrade.rs index 51ee1fdf..da1d3e78 100644 --- a/crates/stellar-scaffold-cli/src/commands/upgrade.rs +++ b/crates/stellar-scaffold-cli/src/commands/upgrade.rs @@ -254,6 +254,7 @@ impl Cmd { network_passphrase: Some("Standalone Network ; February 2017".to_string()), rpc_headers: None, run_locally: true, + allow_http: None, }, contracts: (!contract_configs.is_empty()).then_some(contract_configs), extensions: vec![], diff --git a/crates/stellar-scaffold-cli/src/commands/watch/mod.rs b/crates/stellar-scaffold-cli/src/commands/watch/mod.rs index 76303202..db3e5d22 100644 --- a/crates/stellar-scaffold-cli/src/commands/watch/mod.rs +++ b/crates/stellar-scaffold-cli/src/commands/watch/mod.rs @@ -44,7 +44,7 @@ pub enum Error { #[error(transparent)] Manifest(#[from] cargo_metadata::Error), #[error(transparent)] - SchemaVersion(#[from] build::scaffold_yml::Error), + SchemaVersion(#[from] crate::config::VersionError), #[error(transparent)] EngineConstraint(#[from] super::EngineConstraintError), } @@ -154,7 +154,7 @@ impl Cmd { let metadata = &self.build_cmd.metadata()?; let workspace_root = metadata.workspace_root.as_std_path(); - build::scaffold_yml::check_version(workspace_root)?; + crate::config::check_version(workspace_root)?; super::check_engine_constraint(workspace_root)?; // Extensions for pre/post-dev hooks, and the label they see as `env`. diff --git a/crates/stellar-scaffold-cli/src/config/diagnostic.rs b/crates/stellar-scaffold-cli/src/config/diagnostic.rs index 9928d528..55c1ea64 100644 --- a/crates/stellar-scaffold-cli/src/config/diagnostic.rs +++ b/crates/stellar-scaffold-cli/src/config/diagnostic.rs @@ -87,6 +87,8 @@ pub enum Code { UnpinnedVersion, /// `start-container: true` on a non-local passphrase. StartContainerNonLocal, + /// `allow-http: true` on a public passphrase. + AllowHttpOnPublic, /// A contract has no network entries, so it is never built. ContractWithoutNetworks, /// A `wasm-file` source does not exist yet. @@ -121,6 +123,7 @@ impl Code { Self::DeployToMainnet => "deploy-to-mainnet", Self::UnpinnedVersion => "unpinned-version", Self::StartContainerNonLocal => "start-container-non-local", + Self::AllowHttpOnPublic => "allow-http-public", Self::ContractWithoutNetworks => "contract-without-networks", Self::WasmFileMissing => "wasm-file-missing", Self::WorkspaceUnavailable => "workspace-unavailable", diff --git a/crates/stellar-scaffold-cli/src/config/edit.rs b/crates/stellar-scaffold-cli/src/config/edit.rs new file mode 100644 index 00000000..87a52909 --- /dev/null +++ b/crates/stellar-scaffold-cli/src/config/edit.rs @@ -0,0 +1,295 @@ +//! Line-based edits to `scaffold.yml` that keep its comments and layout, +//! which a parse-and-reserialize round trip would drop. Every edit is checked +//! by parsing the result, so a layout these can't handle yields `None` rather +//! than a broken file. + +use std::collections::BTreeMap; + +use serde::Deserialize; + +/// The parts of `scaffold.yml` edits are checked against. Other keys are +/// skipped, so values serde can't represent, like huge constructor integers, +/// don't fail the check. +#[derive(Deserialize, Default)] +#[serde(default)] +struct Probe { + project: BTreeMap, + contracts: BTreeMap>, +} + +#[derive(Deserialize, Default)] +#[serde(default)] +struct ContractProbe { + client: Option, +} + +fn probe(contents: &str) -> Option { + serde_saphyr::from_str(contents).ok() +} + +/// Set `project.: `, replacing an existing entry or appending one +/// to the `project:` block, which is created after `version:` if absent. +/// `None` if the file's layout can't be edited safely. +pub fn set_project_key(contents: &str, key: &str, value: &str) -> Option { + let mut lines: Vec = contents.lines().map(str::to_string).collect(); + if let Some(block) = top_level_block(&lines, "project") { + let (start, end) = block.ok()?; + let indent = child_indent(&lines[start + 1..end]).unwrap_or_else(|| " ".to_string()); + let entry = format!("{indent}{key}: {value}"); + if let Some(i) = (start + 1..end).find(|&i| is_key(&lines[i], &indent, key)) { + lines[i] = entry; + } else { + lines.insert(last_content_line(&lines, start, end) + 1, entry); + } + } else { + let at = lines + .iter() + .position(|l| l.starts_with("version:")) + .map_or(0, |i| i + 1); + let block = [ + String::new(), + "project:".into(), + format!(" {key}: {value}"), + ]; + lines.splice(at..at, block); + } + + let out = join(&lines, contents); + let parsed = probe(&out)?; + (parsed.project.get(key).map(String::as_str) == Some(value)).then_some(out) +} + +/// Set `client: false` on every contract under `contracts:`. Returns the file +/// unchanged when it lists no contracts, and `None` if the layout can't be +/// edited safely, e.g. a contract written inline as `name: {…}`. +pub fn disable_clients(contents: &str) -> Option { + let mut lines: Vec = contents.lines().map(str::to_string).collect(); + let Some(block) = top_level_block(&lines, "contracts") else { + return Some(contents.to_string()); + }; + let (start, end) = block.ok()?; + let Some(contract_indent) = child_indent(&lines[start + 1..end]) else { + return Some(contents.to_string()); + }; + + let headers: Vec = (start + 1..end) + .filter(|&i| is_entry(&lines[i], &contract_indent)) + .collect(); + // Walk backwards so inserted lines don't shift headers not yet visited. + for (n, &header) in headers.iter().enumerate().rev() { + let body_end = headers.get(n + 1).copied().unwrap_or(end); + let field_indent = child_indent(&lines[header + 1..body_end]) + .filter(|i| i.len() > contract_indent.len()) + .unwrap_or_else(|| format!("{contract_indent} ")); + let entry = format!("{field_indent}client: false"); + if let Some(i) = + (header + 1..body_end).find(|&i| is_key(&lines[i], &field_indent, "client")) + { + lines[i] = entry; + } else { + lines.insert(header + 1, entry); + } + } + + let out = join(&lines, contents); + let parsed = probe(&out)?; + parsed + .contracts + .values() + .all(|c| c.as_ref().and_then(|c| c.client) == Some(false)) + .then_some(out) +} + +/// Line range of a top-level `name:` block: its header line and the exclusive +/// end, where the next top-level key starts. Comments at column 0 don't end +/// the block. `Err` when the value is written inline (`name: {…}`). +fn top_level_block(lines: &[String], name: &str) -> Option> { + let header = format!("{name}:"); + let start = lines.iter().position(|l| l.starts_with(&header))?; + let rest = lines[start][header.len()..].trim(); + if !(rest.is_empty() || rest.starts_with('#')) { + return Some(Err(())); + } + let end = (start + 1..lines.len()) + .find(|&i| { + let l = &lines[i]; + !l.is_empty() && !l.starts_with([' ', '\t']) && !l.starts_with('#') + }) + .unwrap_or(lines.len()); + Some(Ok((start, end))) +} + +/// Leading whitespace of the first entry in `lines`, skipping blanks and +/// comments. +fn child_indent(lines: &[String]) -> Option { + lines.iter().find(|l| is_content(l)).map(|l| { + let trimmed = l.trim_start(); + l[..l.len() - trimmed.len()].to_string() + }) +} + +/// Whether `line` is the `key:` entry at exactly `indent`. +fn is_key(line: &str, indent: &str, key: &str) -> bool { + line.strip_prefix(indent) + .and_then(|rest| rest.strip_prefix(key)) + .is_some_and(|rest| rest.starts_with(':')) +} + +/// Whether `line` is a mapping entry at exactly `indent`. +fn is_entry(line: &str, indent: &str) -> bool { + line.strip_prefix(indent) + .is_some_and(|rest| !rest.starts_with([' ', '\t', '#']) && rest.contains(':')) +} + +fn is_content(line: &str) -> bool { + let trimmed = line.trim(); + !trimmed.is_empty() && !trimmed.starts_with('#') +} + +/// Index of the last entry line in `start..end`, or `start` if there is none, +/// so appended entries land before any trailing comments. +fn last_content_line(lines: &[String], start: usize, end: usize) -> usize { + (start + 1..end) + .rev() + .find(|&i| is_content(&lines[i])) + .unwrap_or(start) +} + +/// Rejoin `lines`, keeping the original file's line endings and trailing newline. +fn join(lines: &[String], original: &str) -> String { + let eol = if original.contains("\r\n") { + "\r\n" + } else { + "\n" + }; + let mut out = lines.join(eol); + if original.ends_with('\n') { + out.push_str(eol); + } + out +} + +#[cfg(test)] +mod tests { + use super::{disable_clients, set_project_key}; + + const TEMPLATE: &str = "version: 2 + +# Scaffold CLI Configuration +project: + # Your Rust/Soroban contract source. + contracts-dir: contracts + + # Generated clients + clients-dir: app-lib/clients + +# Stellar Networks +networks: + local: + accounts: [me] + +# Contracts +contracts: + guess-the-number: + type: workspace + source: guess-the-number # the crate's package name + networks: + local: + + nft: + # An NFT + type: workspace + source: nft + networks: + local: +"; + + #[test] + fn set_project_key_appends_to_existing_block() { + let out = set_project_key(TEMPLATE, "package-manager", "pnpm").unwrap(); + assert!( + out.contains(" clients-dir: app-lib/clients\n package-manager: pnpm\n\n# Stellar") + ); + assert!(out.contains("# Your Rust/Soroban contract source.")); + } + + #[test] + fn set_project_key_replaces_existing_entry() { + let once = set_project_key(TEMPLATE, "package-manager", "pnpm").unwrap(); + let twice = set_project_key(&once, "package-manager", "bun").unwrap(); + assert!(twice.contains(" package-manager: bun\n")); + assert!(!twice.contains("pnpm")); + } + + #[test] + fn set_project_key_creates_block_after_version() { + let out = set_project_key( + "version: 2\nnetworks:\n local: {}\n", + "package-manager", + "deno", + ) + .unwrap(); + assert_eq!( + out, + "version: 2\n\nproject:\n package-manager: deno\nnetworks:\n local: {}\n" + ); + } + + #[test] + fn set_project_key_rejects_inline_project() { + let yaml = "version: 2\nproject: { contracts-dir: contracts }\n"; + assert!(set_project_key(yaml, "package-manager", "npm").is_none()); + } + + #[test] + fn disable_clients_marks_every_contract() { + let out = disable_clients(TEMPLATE).unwrap(); + assert!(out.contains(" guess-the-number:\n client: false\n type: workspace\n")); + assert!(out.contains(" nft:\n client: false\n # An NFT\n")); + assert!(out.contains("# the crate's package name")); + } + + #[test] + fn disable_clients_replaces_existing_value() { + let yaml = "version: 2\ncontracts:\n a:\n client: true\n type: workspace\n"; + let out = disable_clients(yaml).unwrap(); + assert_eq!( + out, + "version: 2\ncontracts:\n a:\n client: false\n type: workspace\n" + ); + } + + #[test] + fn disable_clients_rejects_inline_contracts() { + let yaml = "version: 2\ncontracts:\n a: { type: workspace }\n"; + assert!(disable_clients(yaml).is_none()); + } + + #[test] + fn edits_tolerate_values_serde_cannot_represent() { + let yaml = "version: 2\ncontracts:\n token:\n args:\n initial_supply: 1000000000000000000000000\n"; + assert!(set_project_key(yaml, "package-manager", "npm").is_some()); + assert!(disable_clients(yaml).is_some()); + } + + #[test] + fn edits_keep_crlf_line_endings() { + let yaml = "version: 2\r\nproject:\r\n contracts-dir: contracts\r\n"; + assert_eq!( + set_project_key(yaml, "package-manager", "npm").unwrap(), + "version: 2\r\nproject:\r\n contracts-dir: contracts\r\n package-manager: npm\r\n" + ); + + let yaml = "version: 2\r\ncontracts:\r\n a:\r\n type: workspace\r\n"; + assert_eq!( + disable_clients(yaml).unwrap(), + "version: 2\r\ncontracts:\r\n a:\r\n client: false\r\n type: workspace\r\n" + ); + } + + #[test] + fn disable_clients_without_contracts_is_unchanged() { + let yaml = "version: 2\nnetworks:\n local: {}\n"; + assert_eq!(disable_clients(yaml).unwrap(), yaml); + } +} diff --git a/crates/stellar-scaffold-cli/src/config/lint.rs b/crates/stellar-scaffold-cli/src/config/lint.rs index c7905bbd..9d7e4ffd 100644 --- a/crates/stellar-scaffold-cli/src/config/lint.rs +++ b/crates/stellar-scaffold-cli/src/config/lint.rs @@ -57,6 +57,16 @@ pub fn lint(config: &Config, env: &Env) -> Vec { .at(span(nested)), ); } + if let Some(client) = &settings.client { + l.diags.push( + Diagnostic::error( + Code::UnknownKey, + format!("{what} cannot set `client`"), + ) + .at(span(client)) + .help("set it on the contract, so its client is the same on every network"), + ); + } l.settings(&what, settings); } } diff --git a/crates/stellar-scaffold-cli/src/config/mod.rs b/crates/stellar-scaffold-cli/src/config/mod.rs index 3af0522d..c38b3e05 100644 --- a/crates/stellar-scaffold-cli/src/config/mod.rs +++ b/crates/stellar-scaffold-cli/src/config/mod.rs @@ -7,15 +7,18 @@ //! - `interpolate.rs` :: `${namespace.path}` grammar //! - `cli_args.rs` :: constructor `args` as stellar-cli arguments //! - `resolve.rs` :: `extends` and per-network merging, cross-reference rules +//! - `edit.rs` :: line-based edits that keep comments and layout //! //! [`load`] runs the whole pipeline and never fails: every problem is a //! [`Diagnostic`]. Callers decide what to do with errors. //! -//! Version 1 files (the `config:` section only) are still read by -//! `commands::build::scaffold_yml` until `build` moves to this module. +//! Projects still configured by the deprecated `environments.toml` are +//! recognized by that file alone; from their `scaffold.yml`, only a version 1 +//! `config:` section's directories are read. pub mod cli_args; pub mod diagnostic; +pub mod edit; pub mod interpolate; pub mod lint; pub mod resolve; @@ -35,6 +38,21 @@ pub const CONFIG_FILE: &str = "scaffold.yml"; /// Schema version this module reads. pub const SCHEMA_VERSION: u32 = 2; +#[derive(thiserror::Error, Debug)] +pub enum VersionError { + #[error( + "scaffold.yml is missing or has no 'version' field. \ + Add 'version: {SCHEMA_VERSION}' at the top. \ + See https://github.com/stellar-scaffold/cli/blob/main/CHANGELOG.md for details." + )] + MissingVersion, + #[error( + "scaffold.yml uses schema version {found}, but this CLI supports version {SCHEMA_VERSION}. \ + See https://github.com/stellar-scaffold/cli/blob/main/CHANGELOG.md for migration instructions." + )] + UnsupportedVersion { found: u64 }, +} + /// Environment variable selecting the network when `--network` is absent. /// Shared with stellar-cli, which also sets it from `stellar network use`. pub const NETWORK_ENV: &str = "STELLAR_NETWORK"; @@ -191,6 +209,7 @@ pub fn load(root: &Path, crates: Option<&[(String, PathBuf)]>, selected: Option< } /// The fully resolved config for one network, as printed by `config show`. +/// Keys keep their insertion order, which `config show` prints as YAML or JSON. /// /// `env` and `network` references are substituted; `account` references stay /// symbolic because addresses may not exist until `build` creates the keys. @@ -198,8 +217,8 @@ pub fn resolved_view( config: &Config, network: &str, env: &dyn Fn(&str) -> Option, -) -> Result> { - use serde_yaml::{Mapping, Value}; +) -> Result> { + use serde_json::{Map, Value}; if !config.networks.contains_key(network) { let mut d = Diagnostic::error( @@ -241,7 +260,7 @@ pub fn resolved_view( .map(|t| interp(t, &net_ctx)) .unwrap_or_default(); - let mut n = Mapping::new(); + let mut n = Map::new(); n.insert("name".into(), net.name.clone().into()); if !net.extends.is_empty() { n.insert("extends".into(), net.extends.clone().into()); @@ -249,10 +268,10 @@ pub fn resolved_view( n.insert("rpc-url".into(), rpc_url.clone().into()); n.insert("network-passphrase".into(), passphrase.clone().into()); if !net.rpc_headers.is_empty() { - let headers: Mapping = net + let headers: Map = net .rpc_headers .iter() - .map(|(k, t)| (k.clone().into(), interp(t, &net_ctx).into())) + .map(|(k, t)| (k.clone(), interp(t, &net_ctx).into())) .collect(); n.insert("rpc-headers".into(), headers.into()); } @@ -262,13 +281,14 @@ pub fn resolved_view( } n.insert("start-container".into(), net.start_container().into()); n.insert("allow-deploy".into(), net.allow_deploy().into()); + n.insert("allow-http".into(), net.allow_http().into()); let args_ctx = interpolate::Context { mode: interpolate::Mode::Display, env, network: Some((&net.name, &rpc_url, &passphrase)), }; - let mut contracts = Mapping::new(); + let mut contracts = Map::new(); for name in config.contracts.keys() { let Some(c) = resolver.contract(name, network) else { continue; @@ -286,32 +306,33 @@ pub fn resolved_view( return Err(errors_all); } - let mut out = Mapping::new(); + let mut out = Map::new(); out.insert("network".into(), n.into()); out.insert("contracts".into(), contracts.into()); - Ok(Value::Mapping(out)) + Ok(Value::Object(out)) } fn contract_view( c: &resolve::Contract, resolve: &mut dyn FnMut(&str) -> String, -) -> serde_yaml::Value { - use serde_yaml::Mapping; - let mut m = Mapping::new(); +) -> serde_json::Value { + use serde_json::{Map, Value}; + let mut m = Map::new(); m.insert("type".into(), c.ty.as_str().into()); m.insert("source".into(), c.source.clone().into()); if let Some(f) = &c.from_network { m.insert("from-network".into(), f.clone().into()); } m.insert("deploy".into(), c.deploys.into()); + m.insert("client".into(), c.client.into()); if let Some(s) = &c.signer { m.insert("signer".into(), s.clone().into()); } if !c.args.is_empty() { - let args: Mapping = c + let args: Map = c .args .iter() - .map(|(k, v)| (k.value.clone().into(), value_view(&v.value, resolve))) + .map(|(k, v)| (k.value.clone(), value_view(&v.value, resolve))) .collect(); m.insert("args".into(), args.into()); } @@ -326,9 +347,9 @@ fn contract_view( /// Convert a value for display, interpolating strings. Integers that don't /// fit 64 bits are shown as strings rather than lossy floats. -fn value_view(value: &schema::Value, resolve: &mut dyn FnMut(&str) -> String) -> serde_yaml::Value { +fn value_view(value: &schema::Value, resolve: &mut dyn FnMut(&str) -> String) -> serde_json::Value { use schema::Value as V; - use serde_yaml::Value; + use serde_json::Value; match value { V::Null => Value::Null, V::Bool(b) => (*b).into(), @@ -344,9 +365,9 @@ fn value_view(value: &schema::Value, resolve: &mut dyn FnMut(&str) -> String) -> .map(|i| value_view(&i.value, resolve)) .collect::>() .into(), - V::Map(map) => Value::Mapping( + V::Map(map) => Value::Object( map.iter() - .map(|(k, v)| (k.value.clone().into(), value_view(&v.value, resolve))) + .map(|(k, v)| (k.value.clone(), value_view(&v.value, resolve))) .collect(), ), } @@ -357,3 +378,65 @@ pub fn declared_version(root: &Path) -> Option { let source = std::fs::read_to_string(root.join(CONFIG_FILE)).ok()?; schema::declared_version(&source) } + +/// Check that `scaffold.yml` in `root` declares [`SCHEMA_VERSION`]. A project +/// configured by the deprecated `environments.toml` passes whatever its +/// `scaffold.yml` says, or without one. +pub fn check_version(root: &Path) -> Result<(), VersionError> { + match declared_version(root) { + Some(v) if v == u64::from(SCHEMA_VERSION) => Ok(()), + _ if uses_env_toml(root) => Ok(()), + None => Err(VersionError::MissingVersion), + Some(found) => Err(VersionError::UnsupportedVersion { found }), + } +} + +/// Whether `root` has the deprecated `environments.toml`. A version 2 +/// `scaffold.yml` takes precedence when both exist. +pub fn uses_env_toml(root: &Path) -> bool { + root.join(crate::commands::build::env_toml::ENV_FILE) + .is_file() +} + +/// The `project:` section of `scaffold.yml` in `root`. Beside the deprecated +/// `environments.toml`, a version 1 file's `config:` directories are read +/// instead. Defaults when the file is absent or doesn't parse; commands that +/// need a valid file report that through [`load`]. +pub fn project(root: &Path) -> schema::Project { + let Ok(source) = std::fs::read_to_string(root.join(CONFIG_FILE)) else { + return schema::Project::default(); + }; + if let Ok(config) = schema::parse(&source) { + return config.project; + } + // A version 1 file only counts beside environments.toml, matching check_version + if uses_env_toml(root) { + let dirs = serde_saphyr::from_str::(&source) + .map(|file| file.config) + .unwrap_or_default(); + let defaults = schema::Project::default(); + return schema::Project { + contracts_dir: dirs.contracts_dir.unwrap_or(defaults.contracts_dir), + clients_dir: dirs.clients_dir.unwrap_or(defaults.clients_dir), + package_manager: None, + }; + } + schema::Project::default() +} + +/// A version 1 `scaffold.yml`, read only beside the deprecated +/// `environments.toml` so its custom directories keep working. Other keys, +/// including `version`, are ignored. +#[derive(serde::Deserialize, Default)] +#[serde(default)] +struct LegacyFile { + config: LegacyDirs, +} + +/// The directories a version 1 `config:` section may override. +#[derive(serde::Deserialize, Default)] +#[serde(default)] +struct LegacyDirs { + contracts_dir: Option, + clients_dir: Option, +} diff --git a/crates/stellar-scaffold-cli/src/config/resolve.rs b/crates/stellar-scaffold-cli/src/config/resolve.rs index 6075a0fa..737b766d 100644 --- a/crates/stellar-scaffold-cli/src/config/resolve.rs +++ b/crates/stellar-scaffold-cli/src/config/resolve.rs @@ -53,6 +53,7 @@ pub struct Network { pub explicit_default_account: Option, pub explicit_start_container: Option, pub explicit_allow_deploy: Option, + pub explicit_allow_http: Option, } impl Network { @@ -67,6 +68,7 @@ impl Network { explicit_default_account: None, explicit_start_container: None, explicit_allow_deploy: None, + explicit_allow_http: None, } } @@ -130,6 +132,12 @@ impl Network { self.explicit_allow_deploy .unwrap_or_else(|| !self.is_public()) } + + /// Explicit value, else true only for the local (standalone) passphrase, + /// so generated clients never silently accept plain HTTP elsewhere. + pub fn allow_http(&self) -> bool { + self.explicit_allow_http.unwrap_or_else(|| self.is_local()) + } } /// A contract's effective settings on one network. @@ -146,6 +154,8 @@ pub struct Contract { pub after_deploy_script: Option, /// Whether Scaffold deploys this contract on this network. pub deploys: bool, + /// Whether a TypeScript client is generated and exported. + pub client: bool, } /// Resolves networks once each, so a broken parent is reported once rather @@ -338,6 +348,16 @@ impl<'c> Resolver<'c> { .help("make sure this is intended; deploys cost real funds and cannot be undone"), ); } + if net.explicit_allow_http == Some(true) && net.is_public() { + self.diagnostics.push( + Diagnostic::warning( + Code::AllowHttpOnPublic, + format!("network `{}` allows plain HTTP on a public network", net.name), + ) + .at(def.allow_http.as_ref().map_or(key, span)) + .help("make sure this is intended; requests and signed transactions are sent unencrypted"), + ); + } } fn unknown_network(&mut self, name: &str, at: Option) { @@ -360,7 +380,13 @@ impl<'c> Resolver<'c> { fn check_export_collisions(&mut self) { use heck::ToLowerCamelCase; let mut seen: HashMap = HashMap::new(); - for (key, _) in self.config.contracts.iter() { + // Contracts without a client aren't exported, so can't collide. + let exported = self + .config + .contracts + .iter() + .filter(|(_, def)| def.client.as_ref().is_none_or(|c| c.value)); + for (key, _) in exported { let export = key.value.to_lower_camel_case(); if let Some(first) = seen.get(&export) { self.diagnostics.push( @@ -434,6 +460,7 @@ impl<'c> Resolver<'c> { after_deploy: Vec::new(), after_deploy_script: None, deploys, + client: def.client.as_ref().is_none_or(|c| c.value), }; // Client-level deploy settings are inherited only where Scaffold // actually deploys; a network that references an existing contract @@ -577,6 +604,9 @@ fn apply(name: &str, def: &NetworkDef, mut net: Network) -> Network { if let Some(b) = &def.allow_deploy { net.explicit_allow_deploy = Some(b.value); } + if let Some(b) = &def.allow_http { + net.explicit_allow_http = Some(b.value); + } net } @@ -624,6 +654,7 @@ fn merge(defaults: &ContractSettings, over: &ContractSettings) -> ContractSettin .after_deploy_script .clone() .or_else(|| defaults.after_deploy_script.clone()), + client: None, networks: None, } } diff --git a/crates/stellar-scaffold-cli/src/config/schema.rs b/crates/stellar-scaffold-cli/src/config/schema.rs index 7bab7fc4..e5fcd112 100644 --- a/crates/stellar-scaffold-cli/src/config/schema.rs +++ b/crates/stellar-scaffold-cli/src/config/schema.rs @@ -38,6 +38,9 @@ pub struct Config { pub struct Project { pub contracts_dir: PathBuf, pub clients_dir: PathBuf, + /// Installs and builds the generated clients, optionally pinned to a + /// version (`pnpm@9.6.0`). Defaults to npm. + pub package_manager: Option, } impl Default for Project { @@ -45,6 +48,7 @@ impl Default for Project { Self { contracts_dir: "contracts".into(), clients_dir: "app-lib/clients".into(), + package_manager: None, } } } @@ -78,6 +82,11 @@ pub struct NetworkDef { /// value is inherited through `extends`; an unset one is recomputed from /// each network's own passphrase. pub allow_deploy: Option>, + /// Whether generated clients may talk to a plain `http://` RPC URL. When + /// unset, true only if the resolved passphrase is the standalone (local) + /// one. An explicit value is inherited through `extends`; an unset one is + /// recomputed from each network's own passphrase. + pub allow_http: Option>, } /// A contract entry. At the top level its keys are defaults for every @@ -94,6 +103,10 @@ pub struct ContractSettings { pub args: Option>>>, pub after_deploy: Option>>>, pub after_deploy_script: Option>, + /// Whether to generate a TypeScript client. Defaults to true. Only valid + /// at the top level of a contract, so a client's types match on every + /// network. + pub client: Option>, /// Per-network overrides. Only valid at the top level of a contract. pub networks: Option>>, } diff --git a/crates/stellar-scaffold-cli/src/config/tests.rs b/crates/stellar-scaffold-cli/src/config/tests.rs index cd69ec01..cac8acd3 100644 --- a/crates/stellar-scaffold-cli/src/config/tests.rs +++ b/crates/stellar-scaffold-cli/src/config/tests.rs @@ -2,7 +2,7 @@ use std::path::PathBuf; -use super::{Code, Loaded, load, resolved_view}; +use super::{Code, Loaded, VersionError, check_version, load, project, resolved_view}; const CONTRACT: &str = "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"; @@ -21,7 +21,7 @@ fn codes(loaded: &Loaded) -> Vec { loaded.diagnostics.iter().map(|d| d.code).collect() } -fn show(yaml: &str, network: &str, env: &dyn Fn(&str) -> Option) -> serde_yaml::Value { +fn show(yaml: &str, network: &str, env: &dyn Fn(&str) -> Option) -> serde_json::Value { let (_dir, loaded) = load_str(yaml, None); assert!(!loaded.has_errors(), "{}", loaded.render()); resolved_view(loaded.config.as_ref().unwrap(), network, env).unwrap() @@ -95,6 +95,68 @@ fn extends_inherits_builtin_defaults_and_policy() { assert_eq!(net["extends"][0], "testnet"); } +#[test] +fn client_defaults_to_true_and_can_be_disabled() { + let yaml = "version: 2\nnetworks:\n local: {}\ncontracts:\n on:\n type: contract\n source: CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC\n networks: { local: }\n off:\n type: contract\n source: CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC\n client: false\n networks: { local: }\n"; + let view = show(yaml, "local", &no_env); + assert_eq!(view["contracts"]["on"]["client"], true); + assert_eq!(view["contracts"]["off"]["client"], false); +} + +#[test] +fn client_per_network_is_an_error() { + let yaml = "version: 2\nnetworks:\n local: {}\ncontracts:\n c:\n type: contract\n source: CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC\n networks:\n local:\n client: false\n"; + let (_dir, loaded) = load_str(yaml, None); + assert!(loaded.has_errors()); + assert!(codes(&loaded).contains(&Code::UnknownKey)); +} + +#[test] +fn package_manager_must_be_known() { + let (_dir, ok) = load_str("version: 2\nproject:\n package-manager: pnpm\n", None); + assert!(!ok.has_errors(), "{}", ok.render()); + let (_dir, bad) = load_str("version: 2\nproject:\n package-manager: cargo\n", None); + assert!(bad.has_errors()); +} + +#[test] +fn package_manager_may_pin_a_version() { + let (_dir, ok) = load_str( + "version: 2\nproject:\n package-manager: pnpm@9.6.0\n", + None, + ); + assert!(!ok.has_errors(), "{}", ok.render()); + let (_dir, bad) = load_str("version: 2\nproject:\n package-manager: pnpm@\n", None); + assert!(bad.has_errors()); +} + +#[test] +fn allow_http_defaults_to_local_only() { + let yaml = "version: 2\nnetworks:\n local: {}\n testnet: {}\n"; + assert_eq!(show(yaml, "local", &no_env)["network"]["allow-http"], true); + assert_eq!( + show(yaml, "testnet", &no_env)["network"]["allow-http"], + false + ); +} + +#[test] +fn allow_http_is_recomputed_for_a_public_child() { + let yaml = "version: 2\nnetworks:\n local: {}\n remote:\n extends: local\n rpc-url: https://rpc.example\n network-passphrase: Test SDF Network ; September 2015\n"; + assert_eq!( + show(yaml, "remote", &no_env)["network"]["allow-http"], + false + ); +} + +#[test] +fn allow_http_on_public_network_warns() { + let yaml = "version: 2\nnetworks:\n testnet:\n allow-http: true\n"; + let (_dir, loaded) = load_str(yaml, None); + assert_eq!(codes(&loaded), vec![Code::AllowHttpOnPublic]); + assert!(!loaded.has_errors()); +} + #[test] fn derived_defaults_follow_the_childs_passphrase() { // Inheriting `local`'s derived start-container/allow-deploy would be @@ -224,3 +286,79 @@ fn missing_file_is_a_single_error() { let loaded = load(dir.path(), Some(&Vec::<(String, PathBuf)>::new()), None); assert_eq!(codes(&loaded), vec![Code::SchemaVersion]); } + +fn write_config(yaml: &str) -> tempfile::TempDir { + let dir = tempfile::TempDir::new().unwrap(); + std::fs::write(dir.path().join(super::CONFIG_FILE), yaml).unwrap(); + dir +} + +#[test] +fn check_version_accepts_version_2() { + assert!(check_version(write_config("version: 2\n").path()).is_ok()); + assert!(matches!( + check_version(write_config("version: 1\n").path()), + Err(VersionError::UnsupportedVersion { found: 1 }) + )); + assert!(matches!( + check_version(write_config("networks: {}\n").path()), + Err(VersionError::MissingVersion) + )); + let empty = tempfile::TempDir::new().unwrap(); + assert!(matches!( + check_version(empty.path()), + Err(VersionError::MissingVersion) + )); +} + +#[test] +fn check_version_accepts_any_environments_toml_project() { + let with_v1 = write_config("version: 1\n"); + std::fs::write(with_v1.path().join("environments.toml"), "").unwrap(); + assert!(check_version(with_v1.path()).is_ok()); + + let without_yml = tempfile::TempDir::new().unwrap(); + std::fs::write(without_yml.path().join("environments.toml"), "").unwrap(); + assert!(check_version(without_yml.path()).is_ok()); +} + +#[test] +fn project_reads_version_2_and_defaults_otherwise() { + let v2 = + write_config("version: 2\nproject:\n contracts-dir: rust\n clients-dir: web/clients\n"); + let p = project(v2.path()); + assert_eq!(p.contracts_dir, PathBuf::from("rust")); + assert_eq!(p.clients_dir, PathBuf::from("web/clients")); + + let legacy = write_config("version: 1\nconfig:\n contracts_dir: rust\n"); + assert_eq!( + project(legacy.path()).contracts_dir, + PathBuf::from("contracts") + ); + + let empty = tempfile::TempDir::new().unwrap(); + assert_eq!( + project(empty.path()).clients_dir, + PathBuf::from("app-lib/clients") + ); +} + +#[test] +fn project_reads_version_1_dirs_beside_environments_toml() { + let legacy = + write_config("version: 1\nconfig:\n contracts_dir: rust\n clients_dir: web/clients\n"); + std::fs::write(legacy.path().join("environments.toml"), "").unwrap(); + let p = project(legacy.path()); + assert_eq!(p.contracts_dir, PathBuf::from("rust")); + assert_eq!(p.clients_dir, PathBuf::from("web/clients")); + + let partial = write_config("version: 1\nconfig:\n clients_dir: web/clients\n"); + std::fs::write(partial.path().join("environments.toml"), "").unwrap(); + let p = project(partial.path()); + assert_eq!(p.contracts_dir, PathBuf::from("contracts")); + assert_eq!(p.clients_dir, PathBuf::from("web/clients")); + + let v2 = write_config("version: 2\nproject:\n clients-dir: v2/clients\n"); + std::fs::write(v2.path().join("environments.toml"), "").unwrap(); + assert_eq!(project(v2.path()).clients_dir, PathBuf::from("v2/clients")); +} diff --git a/crates/stellar-scaffold-cli/tests/unit.rs b/crates/stellar-scaffold-cli/tests/unit.rs index 43159b2a..a6330ec1 100644 --- a/crates/stellar-scaffold-cli/tests/unit.rs +++ b/crates/stellar-scaffold-cli/tests/unit.rs @@ -77,4 +77,7 @@ fn init_copies_frontend_template() { assert!(project_path.join("app").exists()); assert!(project_path.join("app/src").exists()); assert!(project_path.join("app/tsconfig.json").exists()); + // The chosen package manager is recorded in scaffold.yml, not package.json. + let config = std::fs::read_to_string(project_path.join("scaffold.yml")).unwrap(); + assert!(config.contains("\n package-manager: npm@")); }