From 9312bb9fc75d37b8b53e5077ad49157b35efdc7e Mon Sep 17 00:00:00 2001 From: Zach Fedor Date: Thu, 15 Oct 2026 23:11:26 -0400 Subject: [PATCH 1/8] fix(build): report when the network came from `stellar network use` Run as `stellar scaffold`, stellar-cli copies its `network use` default into STELLAR_NETWORK, so builds labelled it as an explicit choice. `config show` now resolves the network the same way `build` does. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QhmaWLv4yExX8LahP5UtUb --- .../src/commands/build/v2.rs | 44 ++++++++++++++++--- .../src/commands/config/show.rs | 13 +++--- 2 files changed, 47 insertions(+), 10 deletions(-) diff --git a/crates/stellar-scaffold-cli/src/commands/build/v2.rs b/crates/stellar-scaffold-cli/src/commands/build/v2.rs index 171f03df..9a6085f2 100644 --- a/crates/stellar-scaffold-cli/src/commands/build/v2.rs +++ b/crates/stellar-scaffold-cli/src/commands/build/v2.rs @@ -28,6 +28,10 @@ pub fn is_v2(root: &Path) -> bool { config::declared_version(root) == Some(u64::from(config::SCHEMA_VERSION)) } +/// Set by stellar-cli to `use` when it filled [`config::NETWORK_ENV`] from +/// its `stellar network use` default before running this plugin. +const NETWORK_SOURCE_ENV: &str = "STELLAR_NETWORK_SOURCE"; + /// The network to build for, and where the choice came from: `--network` or /// `STELLAR_NETWORK` (passed in as `flag`), else stellar-cli's default from /// `stellar network use`, else `local`. @@ -35,11 +39,23 @@ pub fn select_network(flag: Option<&str>) -> (String, &'static str) { let stellar_default = stellar_cli::config::Config::new() .ok() .and_then(|c| c.defaults.network); - pick_network(flag, stellar_default) + let injected = std::env::var(NETWORK_SOURCE_ENV) + .is_ok_and(|source| source == "use") + .then(|| std::env::var(config::NETWORK_ENV).ok()) + .flatten(); + pick_network(flag, stellar_default, injected.as_deref()) } -fn pick_network(flag: Option<&str>, stellar_default: Option) -> (String, &'static str) { +/// `injected` is the `STELLAR_NETWORK` value stellar-cli copied from +/// `stellar network use`. It only affects the reported source: run as +/// `stellar scaffold`, the default arrives through `flag` like a user's value. +fn pick_network( + flag: Option<&str>, + stellar_default: Option, + injected: Option<&str>, +) -> (String, &'static str) { match (flag, stellar_default) { + (Some(name), _) if Some(name) == injected => (name.to_string(), "`stellar network use`"), (Some(name), _) => (name.to_string(), "--network or STELLAR_NETWORK"), (None, Some(name)) => (name, "`stellar network use`"), (None, None) => (config::DEFAULT_NETWORK.to_string(), "default"), @@ -250,11 +266,29 @@ mod tests { #[test] fn network_precedence() { assert_eq!( - pick_network(Some("preview"), Some("testnet".into())).0, + pick_network(Some("preview"), Some("testnet".into()), None).0, "preview" ); - assert_eq!(pick_network(None, Some("testnet".into())).0, "testnet"); - assert_eq!(pick_network(None, None).0, "local"); + assert_eq!( + pick_network(None, Some("testnet".into()), None).0, + "testnet" + ); + assert_eq!(pick_network(None, None, None).0, "local"); + } + + #[test] + fn network_injected_by_stellar_cli_reports_network_use() { + let default = || Some("testnet".to_string()); + // `stellar scaffold build` after `stellar network use testnet` + assert_eq!( + pick_network(Some("testnet"), default(), Some("testnet")), + ("testnet".into(), "`stellar network use`") + ); + // `--network local` wins over the injected default + assert_eq!( + pick_network(Some("local"), default(), Some("testnet")), + ("local".into(), "--network or STELLAR_NETWORK") + ); } #[test] diff --git a/crates/stellar-scaffold-cli/src/commands/config/show.rs b/crates/stellar-scaffold-cli/src/commands/config/show.rs index 9009f06a..afcac8f1 100644 --- a/crates/stellar-scaffold-cli/src/commands/config/show.rs +++ b/crates/stellar-scaffold-cli/src/commands/config/show.rs @@ -4,13 +4,15 @@ use std::path::PathBuf; use clap::Parser; -use crate::config::{self, DEFAULT_NETWORK, NETWORK_ENV, Workspace}; +use crate::commands::build::v2; +use crate::config::{self, NETWORK_ENV, Workspace}; #[derive(Parser, Debug, Clone)] pub struct Cmd { - /// Network to resolve - #[arg(long, env = NETWORK_ENV, default_value = DEFAULT_NETWORK)] - pub network: String, + /// Network to resolve. Defaults to `STELLAR_NETWORK`, then `stellar + /// network use`, then `local`, the same as `build` + #[arg(long, env = NETWORK_ENV)] + pub network: Option, /// Path to Cargo.toml #[arg(long)] @@ -43,7 +45,8 @@ impl Cmd { return Err(Error::Invalid); }; let env = |name: &str| std::env::var(name).ok(); - match config::resolved_view(config, &self.network, &env) { + let (network, _) = v2::select_network(self.network.as_deref()); + match config::resolved_view(config, &network, &env) { Ok(view) if self.json => println!("{}", serde_json::to_string_pretty(&view)?), Ok(view) => print!("{}", serde_saphyr::to_string(&view)?), Err(diags) => { From 8a79a421269aadc498878d8e3e6f632ef1880781 Mon Sep 17 00:00:00 2001 From: Zach Fedor Date: Thu, 15 Oct 2026 23:12:00 -0400 Subject: [PATCH 2/8] feat(build): deploy and generate clients from a version 2 scaffold.yml A version 2 scaffold.yml declares what each network deploys and binds, so `build` and `watch` always follow it; `--build-clients` is noted as no longer needed. environments.toml projects still opt in with the flag. `init` without a frontend and `upgrade` only compile, and "Not deploying" now names the network. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QhmaWLv4yExX8LahP5UtUb --- .../src/commands/build/mod.rs | 63 +++++++++++++++++-- .../src/commands/doctor/checks/project.rs | 2 +- .../src/commands/setup.rs | 1 + .../src/commands/upgrade.rs | 4 +- crates/stellar-scaffold-ext-types/README.md | 2 +- crates/stellar-scaffold-ext-types/src/lib.rs | 15 ++--- 6 files changed, 72 insertions(+), 15 deletions(-) diff --git a/crates/stellar-scaffold-cli/src/commands/build/mod.rs b/crates/stellar-scaffold-cli/src/commands/build/mod.rs index bde28309..0e7e849f 100644 --- a/crates/stellar-scaffold-cli/src/commands/build/mod.rs +++ b/crates/stellar-scaffold-cli/src/commands/build/mod.rs @@ -35,11 +35,18 @@ pub struct Command { pub list: bool, #[command(flatten)] pub build: build::Cmd, - /// Build client code in addition to building the contract + /// Also deploy contracts and generate their clients. Only needed for + /// projects on environments.toml; with a version 2 scaffold.yml this + /// always happens, as configured there. #[arg(long)] pub build_clients: bool, #[command(flatten)] pub build_clients_args: clients::ClientsArgs, + /// Only compile, skipping deploys and client generation even for a + /// version 2 project. Set by commands that build as one step of their own + /// work; users get compile-only from `stellar contract build`. + #[arg(skip)] + pub compile_only: bool, } #[derive(thiserror::Error, Debug)] @@ -132,6 +139,7 @@ impl Command { } let target_dir = &metadata.target_directory; + let build_clients = self.builds_clients(workspace_root, &printer); // Configuration for this build: a resolved plan for version 2 // projects, otherwise the selected environments.toml environment. @@ -145,7 +153,7 @@ impl Command { &metadata, &packages, &printer, - self.build_clients, + build_clients, ) .await? .unwrap_or_else(|| self.empty_inputs()); @@ -210,7 +218,7 @@ impl Command { ) .await; - if self.build_clients { + 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()); @@ -227,6 +235,21 @@ impl Command { Ok(()) } + /// 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 + /// projects still opt in with `--build-clients`, which a version 2 + /// project notes as redundant. + fn builds_clients(&self, workspace_root: &Path, printer: &Print) -> bool { + let is_v2 = v2::is_v2(workspace_root); + if self.build_clients && is_v2 { + printer.infoln( + "--build-clients is no longer needed: with a version 2 scaffold.yml, build always deploys and generates clients as configured there", + ); + } + builds_clients(self.compile_only, self.build_clients, is_v2) + } + /// For environments.toml projects building `development`, start the local /// container if that environment runs locally. Version 2 projects start /// theirs while resolving the network. @@ -325,9 +348,10 @@ impl Command { let unlisted = v2::unlisted_crates(packages.iter().map(|p| p.name.as_str()), &plan); if !unlisted.is_empty() { printer.infoln(format!( - "Not deploying {}: not listed under `contracts:` in {}", + "Not deploying {} to {network:?}: no contract in {} lists that network for {}", unlisted.join(", "), - crate::config::CONFIG_FILE + crate::config::CONFIG_FILE, + if unlisted.len() == 1 { "it" } else { "them" } )); } if plan.network.run_locally { @@ -490,3 +514,32 @@ impl Command { !matches!(val, Value::Array(_) | Value::Object(_)) } } + +/// See [`Command::builds_clients`]; split out so the rule is testable without +/// a project on disk. +fn builds_clients(compile_only: bool, flag: bool, is_v2: bool) -> bool { + !compile_only && (flag || is_v2) +} + +#[cfg(test)] +mod tests { + use super::builds_clients; + + #[test] + fn v2_builds_clients_without_the_flag() { + assert!(builds_clients(false, false, true)); + assert!(builds_clients(false, true, true)); + } + + #[test] + fn environments_toml_still_needs_the_flag() { + assert!(!builds_clients(false, false, false)); + assert!(builds_clients(false, true, false)); + } + + #[test] + fn compile_only_wins() { + assert!(!builds_clients(true, true, true)); + assert!(!builds_clients(true, true, false)); + } +} 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 bb786ca0..30119b2b 100644 --- a/crates/stellar-scaffold-cli/src/commands/doctor/checks/project.rs +++ b/crates/stellar-scaffold-cli/src/commands/doctor/checks/project.rs @@ -131,7 +131,7 @@ fn config_diagnoses(name: &'static str, root: &Path, loaded: &config::Loaded) -> "clients-dir", root, &config.project.clients_dir, - "stellar scaffold build --build-clients", + "stellar scaffold build", )); } findings diff --git a/crates/stellar-scaffold-cli/src/commands/setup.rs b/crates/stellar-scaffold-cli/src/commands/setup.rs index 7b2ea5f2..870c2925 100644 --- a/crates/stellar-scaffold-cli/src/commands/setup.rs +++ b/crates/stellar-scaffold-cli/src/commands/setup.rs @@ -78,6 +78,7 @@ pub async fn prepare( // every contract opts out with `client = false`; a no-frontend project has // no clients to generate, so skip it and just compile. build_command.build_clients = pkg_manager.is_some(); + build_command.compile_only = pkg_manager.is_none(); let mut build_args = global_args.clone(); if !(global_args.verbose && global_args.very_verbose) { build_args.quiet = true; diff --git a/crates/stellar-scaffold-cli/src/commands/upgrade.rs b/crates/stellar-scaffold-cli/src/commands/upgrade.rs index 36680bd1..d1fe0486 100644 --- a/crates/stellar-scaffold-cli/src/commands/upgrade.rs +++ b/crates/stellar-scaffold-cli/src/commands/upgrade.rs @@ -396,7 +396,9 @@ impl Cmd { build_args: stellar_cli::commands::contract::build::BuildArgs::default(), }, list: false, - build_clients: false, // Don't build clients, just contracts + build_clients: false, + // Don't deploy or build clients, just contracts + compile_only: true, }; build_cmd.run(global_args).await?; diff --git a/crates/stellar-scaffold-ext-types/README.md b/crates/stellar-scaffold-ext-types/README.md index 43417333..b41c0bdc 100644 --- a/crates/stellar-scaffold-ext-types/README.md +++ b/crates/stellar-scaffold-ext-types/README.md @@ -97,7 +97,7 @@ All hooks receive a **flat** JSON object on stdin regardless of how the Rust str | `wasm_out_dir` | ✓ | ✓ | ✓ | ✓ | | `source_dirs` | ✓ | ✓ | ✓ | ✓ | | `wasm_paths` | ✓ (empty at pre) | ✓ | ✓ | — | -| `network` | — | ✓ | ✓ | ✓ (if `--build-clients`) | +| `network` | — | ✓ | ✓ | ✓ (when deploying) | | `contract_name` | — | ✓ | ✓ | — | | `wasm_path` | — | ✓ | ✓ | — | | `wasm_hash` | — | ✓ | ✓ | — | diff --git a/crates/stellar-scaffold-ext-types/src/lib.rs b/crates/stellar-scaffold-ext-types/src/lib.rs index d2c4e384..ff242a7c 100644 --- a/crates/stellar-scaffold-ext-types/src/lib.rs +++ b/crates/stellar-scaffold-ext-types/src/lib.rs @@ -348,10 +348,11 @@ pub struct ProjectContractInfo { /// Context passed to `pre-dev` and `post-dev` hooks. /// -/// Fired once per watch cycle (or once for a non-watch build with -/// `--build-clients`). Aggregates all per-contract information into a single -/// flat list rather than nesting `CodegenContext` directly, since the -/// per-contract hooks fire sequentially inside a single build cycle. +/// Fired once per watch cycle (or once for a non-watch build that deploys: +/// always with a version 2 `scaffold.yml`, or with `--build-clients`). +/// Aggregates all per-contract information into a single flat list rather +/// than nesting `CodegenContext` directly, since the per-contract hooks fire +/// sequentially inside a single build cycle. /// /// ## Field availability by hook /// @@ -359,7 +360,7 @@ pub struct ProjectContractInfo { /// |---|---|---| /// | `project_root`, `env`, `wasm_out_dir` | ✓ | ✓ | /// | `source_dirs`, `watch_paths` | ✓ | ✓ | -/// | `network` | ✓ if `--build-clients` | ✓ if `--build-clients` | +/// | `network` | ✓ when deploying | ✓ when deploying | /// | `contracts[*].wasm_path` etc. | `None` | populated | #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProjectContext { @@ -383,8 +384,8 @@ pub struct ProjectContext { /// Resolved network configuration. /// - /// `None` when the build was invoked without `--build-clients` (i.e. no - /// network interaction occurs). + /// `None` when the build doesn't deploy (an environments.toml project + /// built without `--build-clients`), so no network interaction occurs. pub network: Option, /// Per-contract summary for every cdylib package in the workspace. From 6d5bc792af7767a8989f18c6a4e2c20c5c5c5dc2 Mon Sep 17 00:00:00 2001 From: Zach Fedor Date: Thu, 15 Oct 2026 23:12:03 -0400 Subject: [PATCH 3/8] docs: describe config-driven builds and network selection Drop `--build-clients` from version 2 examples, explain the network precedence, and stop pairing STELLAR_NETWORK with PUBLIC_* values now that the frontend reads its network from the generated clients. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QhmaWLv4yExX8LahP5UtUb --- crates/stellar-scaffold-cli/README.md | 2 +- docs/site/docs/cli.md | 7 +++--- docs/site/docs/community-templates.md | 2 +- docs/site/docs/configuration.md | 4 ++-- docs/site/docs/extensions.md | 2 +- docs/site/docs/tutorial/01-getting-started.md | 4 ++-- .../docs/tutorial/02-making-improvements.md | 4 ++-- skills/stellar-scaffold/SKILL.md | 23 ++++++++----------- skills/stellar-scaffold/references/cli.md | 14 +++++------ skills/stellar-scaffold/references/config.md | 15 +++++------- .../stellar-scaffold/references/frontend.md | 10 ++++---- 11 files changed, 40 insertions(+), 47 deletions(-) diff --git a/crates/stellar-scaffold-cli/README.md b/crates/stellar-scaffold-cli/README.md index 083c87c8..37930f6c 100644 --- a/crates/stellar-scaffold-cli/README.md +++ b/crates/stellar-scaffold-cli/README.md @@ -63,7 +63,7 @@ cp .env.example .env 4. Start development: ```bash -stellar scaffold watch --build-clients +stellar scaffold watch ``` ## Configuration diff --git a/docs/site/docs/cli.md b/docs/site/docs/cli.md index d5844025..e2c7e33d 100644 --- a/docs/site/docs/cli.md +++ b/docs/site/docs/cli.md @@ -30,7 +30,7 @@ The init command creates: Official templates come from the [Stellar Scaffold templates repo](https://github.com/stellar-scaffold/ui). To build your own, see [Community Templates](./community-templates.md). -With `--no-template` (or `--template none`), the frontend layer is omitted entirely: no `app/`, no `app-lib/`, no JS workspaces, no dependency install. Every contract in [`scaffold.yml`](./configuration.md) is written with `client: false`, so `stellar scaffold build --build-clients` and `watch` deploy contracts without generating TypeScript clients. Remove it from a contract if you later need one. +With `--no-template` (or `--template none`), the frontend layer is omitted entirely: no `app/`, no `app-lib/`, no JS workspaces, no dependency install. Every contract in [`scaffold.yml`](./configuration.md) is written with `client: false`, so `stellar scaffold build` and `watch` deploy contracts without generating TypeScript clients. Remove it from a contract if you later need one. ## Generate Command @@ -91,12 +91,12 @@ stellar scaffold build [options] Options: -- `--build-clients`: Deploy contracts and generate TypeScript client packages for them, as configured in [`scaffold.yml`](./configuration.md) - `--network `: Network from `scaffold.yml` to build for. Defaults to `STELLAR_NETWORK`, then the network set with `stellar network use`, then `local` +- `--build-clients`: Only needed for projects still on `environments.toml`. With a version 2 [`scaffold.yml`](./configuration.md), `build` always deploys contracts and generates their clients as configured there - `--list` or `--ls`: List package names in order of build - [Standard Soroban contract build options also supported] -`build` validates `scaffold.yml` before compiling and stops if it has errors. Contracts that do not list the selected network are skipped: they are not deployed and not exported from the generated clients `index.ts`. Clients removed from `scaffold.yml` are dropped from `index.ts` on the next build. +`build` validates `scaffold.yml` before compiling and stops if it has errors. To only compile, use `stellar contract build`. Contracts that do not list the selected network are skipped: they are not deployed and not exported from the generated clients `index.ts`. Clients removed from `scaffold.yml` are dropped from `index.ts` on the next build. ## Dev Command @@ -108,7 +108,6 @@ stellar scaffold watch [options] Options: -- `--build-clients`: Generate TypeScript client packages while watching - All options from the build command are also supported, including `--network` `watch` also rebuilds when `scaffold.yml` changes. diff --git a/docs/site/docs/community-templates.md b/docs/site/docs/community-templates.md index 0dba0341..1fb577dc 100644 --- a/docs/site/docs/community-templates.md +++ b/docs/site/docs/community-templates.md @@ -39,7 +39,7 @@ All three are optional. Directories default to the values above and the package ### Generated clients -During `init` and `stellar scaffold build --build-clients`, the CLI writes one TypeScript package per contract into `clients-dir`, plus an `index.ts` exporting a ready-to-use client for each. Import clients from that file wherever your frontend lives. +During `init` and `stellar scaffold build`, the CLI writes one TypeScript package per contract into `clients-dir`, plus an `index.ts` exporting a ready-to-use client for each. Import clients from that file wherever your frontend lives. `index.ts` only imports the packages beside it, so it works from any `clients-dir`. Network settings are written in at build time, so rebuild to target a different network. diff --git a/docs/site/docs/configuration.md b/docs/site/docs/configuration.md index 2f0031a1..934d66d5 100644 --- a/docs/site/docs/configuration.md +++ b/docs/site/docs/configuration.md @@ -42,9 +42,9 @@ Machine-local settings and secrets stay out of this file. They go in `.env`, whi 2. The default set with `stellar network use ` 3. `local` -The name must be declared under `networks:`. New projects set `STELLAR_NETWORK=local` in `.env`; switch it to `testnet` to build against testnet. Keep the `PUBLIC_STELLAR_*` values in `.env` pointed at the same network, since those are what your frontend connects to. +The first line of build output names the network and which of these picked it. The name must be declared under `networks:`. To build against testnet, run `STELLAR_NETWORK=testnet npm start` (or `stellar scaffold build --network testnet`), or set `STELLAR_NETWORK` in the project's `.env`, which the `stellar` CLI loads; a value in your shell wins over `.env`. -Generated clients are bound to the network they were built for: its passphrase and RPC URL are written into `/index.ts`. Rebuild after switching networks. +Generated clients are bound to the network they were built for: its passphrase and RPC URL are written into `/index.ts`, and the frontend templates read the network from there. Rebuild after switching networks. `STELLAR_SCAFFOLD_ENV` is ignored with a version 2 `scaffold.yml`. The CLI warns if it is set. diff --git a/docs/site/docs/extensions.md b/docs/site/docs/extensions.md index 46a5448b..25f80f5c 100644 --- a/docs/site/docs/extensions.md +++ b/docs/site/docs/extensions.md @@ -95,7 +95,7 @@ At each hook invocation, Scaffold writes a flat JSON object to the extension's s | `wasm_out_dir` | ✓ | ✓ | ✓ | ✓ | | `source_dirs` | ✓ | ✓ | ✓ | ✓ | | `wasm_paths` | ✓ (empty at pre) | ✓ | ✓ | — | -| `network` | — | ✓ | ✓ | ✓ (if `--build-clients`) | +| `network` | — | ✓ | ✓ | ✓ (when deploying) | | `contract_name` | — | ✓ | ✓ | — | | `wasm_path` | — | ✓ | ✓ | — | | `wasm_hash` | — | ✓ | ✓ | — | diff --git a/docs/site/docs/tutorial/01-getting-started.md b/docs/site/docs/tutorial/01-getting-started.md index 72c7aaf7..0dd2d599 100644 --- a/docs/site/docs/tutorial/01-getting-started.md +++ b/docs/site/docs/tutorial/01-getting-started.md @@ -187,8 +187,8 @@ There are a few more files than the ones listed here, but let's highlight some i So how do all these pieces work together? Here's what Stellar Scaffold handles for you: -1. Our `npm run dev` command runs `stellar scaffold watch --build-clients` -2. Our `.env` file set an environment variable to say we're building for the _local_ network (`STELLAR_NETWORK=local`) +1. Our `npm run dev` command runs `stellar scaffold watch` +2. With no `STELLAR_NETWORK` set, Stellar Scaffold builds for the _local_ network 3. Stellar Scaffold looked to `scaffold.yml` for the local network's configuration, which told it to: - Start up a local Stellar network - Create an account on the network diff --git a/docs/site/docs/tutorial/02-making-improvements.md b/docs/site/docs/tutorial/02-making-improvements.md index c9e2aa3e..bfa2af3c 100644 --- a/docs/site/docs/tutorial/02-making-improvements.md +++ b/docs/site/docs/tutorial/02-making-improvements.md @@ -315,7 +315,7 @@ You could also try this out in the CLI. Create a non-admin identity to see how i ```bash $ stellar keys generate bob --network local --fund -✅ Key saved with alias bob in ".config/stellar/identity/bob.toml" +✅ Key saved with alias bob in "~/.config/stellar/identity/bob.toml" ✅ Account bob funded on "Standalone Network ; February 2017" $ stellar contract invoke --id guess-the-number --source bob --network local -- reset @@ -446,7 +446,7 @@ mod test; Let's test that our improvements work. You should still have the `npm run dev` process running from earlier. If not, run it again and we can look a little closer at what it's doing. There's two concurrent processes: -1. `stellar scaffold watch --build-clients`: watches for any changes in your `contracts/` folders, then rebuilds and redeploys them +1. `stellar scaffold watch`: watches for any changes in your `contracts/` folders, then rebuilds and redeploys them 2. `vite`: watches for any changes in your `src/` folder and hot-reloads the UI That means any time you add a method, tweak arguments, or even add documentation, everything is immediately reflected on the local network, your application in the browser, and in the Debugger. Let's add some info to the `guess` method's documentation: diff --git a/skills/stellar-scaffold/SKILL.md b/skills/stellar-scaffold/SKILL.md index f98eabf6..a0eac011 100644 --- a/skills/stellar-scaffold/SKILL.md +++ b/skills/stellar-scaffold/SKILL.md @@ -1,6 +1,6 @@ --- name: stellar-scaffold -description: Build full-stack Stellar dApps with Stellar Scaffold, the `stellar scaffold` CLI plugin. Use when creating a new Soroban project with a frontend (`stellar scaffold init`), adding a frontend to an existing Soroban workspace (`upgrade`), configuring networks, accounts, and contracts in `scaffold.yml`, running the local dev loop (`npm run dev` / `stellar scaffold watch --build-clients`), calling contracts from a React or Svelte app through the generated clients in `@stellar-scaffold/app-lib/clients`, adding example contracts (`generate contract`), building for testnet or mainnet, or debugging a Scaffold build with `config check` and `doctor`. +description: Build full-stack Stellar dApps with Stellar Scaffold, the `stellar scaffold` CLI plugin. Use when creating a new Soroban project with a frontend (`stellar scaffold init`), adding a frontend to an existing Soroban workspace (`upgrade`), configuring networks, accounts, and contracts in `scaffold.yml`, running the local dev loop (`npm run dev` / `stellar scaffold watch`), calling contracts from a React or Svelte app through the generated clients in `@stellar-scaffold/app-lib/clients`, adding example contracts (`generate contract`), building for testnet or mainnet, or debugging a Scaffold build with `config check` and `doctor`. --- # Stellar Scaffold @@ -51,7 +51,7 @@ my-app/ ├── app-lib/ # @stellar-scaffold/app-lib: wallet, network, format helpers │ └── clients/ # GENERATED: one package per contract + index.ts. Never edit. ├── scaffold.yml # networks, accounts, contracts -├── .env # STELLAR_NETWORK + PUBLIC_STELLAR_* for the frontend (gitignored) +├── .env # optional STELLAR_NETWORK, secrets for ${env.…} (gitignored) ├── Cargo.toml # Rust workspace └── package.json # npm workspaces root ``` @@ -60,7 +60,7 @@ my-app/ ## The dev loop -`npm run dev` runs two processes: `stellar scaffold watch --build-clients` and Vite. On every change to a contract or to `scaffold.yml`, `watch`: +`npm run dev` runs two processes: `stellar scaffold watch` and Vite. On every change to a contract or to `scaffold.yml`, `watch`: 1. Builds the contracts (Wasm in `target/stellar//.wasm`, with `-` replaced by `_`). 2. For the selected network: starts the local container if needed, creates and funds the accounts, deploys or upgrades contracts whose Wasm changed, and runs their `after-deploy` calls. @@ -76,9 +76,8 @@ version: 2 networks: local: accounts: [me] # created and funded; the first one signs deploys - testnet: + testnet: # staging: bind to deployments you made deliberately, never deploy from the dev loop accounts: [testnet-user] - allow-deploy: true # public networks don't deploy unless you opt in contracts: guess-the-number: # client name: exported as `guessTheNumber` @@ -149,16 +148,14 @@ Then `stellar scaffold config check`. The client appears as `myToken` in `@stell ## Testnet and mainnet -The network comes from `--network `, else `STELLAR_NETWORK` (set in `.env`), else `stellar network use`, else `local`. Switching networks takes two changes in `.env`: +The network comes from `--network `, else `STELLAR_NETWORK` (from the shell or `.env`), else `stellar network use`, else `local`. The frontend reads its network from the generated clients, so switching is one setting: ```bash -STELLAR_NETWORK=testnet # what the CLI builds and deploys to -PUBLIC_STELLAR_NETWORK="TESTNET" # what the frontend connects to -PUBLIC_STELLAR_NETWORK_PASSPHRASE="Test SDF Network ; September 2015" -PUBLIC_STELLAR_RPC_URL="https://soroban-testnet.stellar.org" -PUBLIC_STELLAR_HORIZON_URL="https://horizon-testnet.stellar.org" +STELLAR_NETWORK=testnet npm run dev ``` +Local is for thrashing: deploy and redeploy freely. Testnet is staging, a dry run for mainnet. + `testnet`, `futurenet` and `mainnet` refuse Scaffold deploys until the network sets `allow-deploy: true`. For production, deploy once yourself and point Scaffold at the result instead of deploying from the dev loop: ```yaml @@ -193,8 +190,8 @@ Full command list: [references/cli.md](references/cli.md). - **Edited `app-lib/clients/`**: it is regenerated on every build. Wrap a client in your own module under `app/` instead. - **`Module has no exported member 'myContract'`**: the contract has no entry for the network you built (`networks:` under the contract), the build failed before codegen, or the client name doesn't match. Client `my-contract` exports as `myContract`. -- **Frontend talks to the wrong network**: `STELLAR_NETWORK` and the `PUBLIC_STELLAR_*` values in `.env` disagree. The CLI bakes contract IDs for `STELLAR_NETWORK`; the app connects with `PUBLIC_STELLAR_*`. -- **`would be deployed to testnet, which has deploys disabled`**: add `allow-deploy: true` to that network, or point the contract at an existing deployment with `type: contract`. +- **Built for an unexpected network**: the first line of build output names the network and where it came from. Check `STELLAR_NETWORK` in your shell and `.env`. +- **`would be deployed to testnet, which has deploys disabled`**: point the contract at an existing deployment with `type: contract`. To redeploy freely on testnet, add a separate network (e.g. `testnet-dev: { extends: testnet, allow-deploy: true }`) rather than opting in `testnet` itself. - **`account is not listed in network 's accounts`**: each network has its own accounts. Add it to `networks..accounts`, or override `args` for that network. - **`uses type: registry, which build does not support yet`**: see the supported types above. Use `type: contract` with the address for now. - **Changed constructor `args`, nothing happened**: only Wasm changes redeploy. Stop the dev server, `stellar scaffold clean`, restart. diff --git a/skills/stellar-scaffold/references/cli.md b/skills/stellar-scaffold/references/cli.md index d110c9e2..30fa2740 100644 --- a/skills/stellar-scaffold/references/cli.md +++ b/skills/stellar-scaffold/references/cli.md @@ -2,7 +2,7 @@ Run every command as `stellar scaffold `. All commands accept the Stellar CLI global flags: `--config-dir`, `-q/--quiet`, `-v/--verbose`, `--very-verbose`, `-f/--filter-logs`, `--no-cache`. -Network selection, wherever a command takes `--network`: the flag, else `STELLAR_NETWORK`, else the `stellar network use` default, else `local`. The name must be declared under `networks:` in `scaffold.yml`. +Network selection, wherever a command takes `--network`: the flag, else `STELLAR_NETWORK` (from the shell or `.env`), else the `stellar network use` default, else `local`. The name must be declared under `networks:` in `scaffold.yml`. ## Projects @@ -24,15 +24,15 @@ Network selection, wherever a command takes `--network`: the flag, else `STELLAR | Command | Purpose | Key arguments | |---|---|---| -| `build` | Compile every `cdylib` crate in dependency order | `--build-clients` (also deploy and generate clients for the selected network), `--network `, `--package `, `--manifest-path `, `--profile ` (default `release`), `--out-dir `, `--features`, `--all-features`, `--no-default-features`, `--locked`, `--optimize[=false]`, `--meta `, `--list/--ls`, `--print-commands-only` | -| `watch` | `build`, then rebuild on any change to a contract or `scaffold.yml` | Same as `build`. The project's `npm run dev` runs `stellar scaffold watch --build-clients` | +| `build` | Compile every `cdylib` crate in dependency order | `--network `, `--package `, `--manifest-path `, `--profile ` (default `release`), `--out-dir `, `--features`, `--all-features`, `--no-default-features`, `--locked`, `--optimize[=false]`, `--meta `, `--list/--ls`, `--print-commands-only` | +| `watch` | `build`, then rebuild on any change to a contract or `scaffold.yml` | Same as `build`. The project's `npm run dev` runs `stellar scaffold watch` | | `clean` | Remove Scaffold-generated artifacts | `--manifest-path ` | -Without `--build-clients`, `build` only compiles. With it, for the selected network it also: starts the local container (when the network's passphrase is the standalone one, or `start-container: true`), creates and funds the network's `accounts`, deploys or upgrades `workspace` contracts whose Wasm changed, runs their `after-deploy` methods, and regenerates `//` and `/index.ts`. If one contract fails, the rest still deploy and export, then the build exits non-zero. +After compiling, for the selected network `build` also: starts the local container (when the network's passphrase is the standalone one, or `start-container: true`), creates and funds the network's `accounts`, deploys or upgrades `workspace` contracts whose Wasm changed, runs their `after-deploy` methods, and regenerates `//` and `/index.ts`. If one contract fails, the rest still deploy and export, then the build exits non-zero. To only compile, use `stellar contract build`. `--build-clients` is only needed by projects still on `environments.toml`. `build` and `watch` also accept a positional `[ENV]` (`development`, `testing`, `staging`, `production`, or `STELLAR_SCAFFOLD_ENV`). It only applies to legacy projects with `environments.toml`, and their `--help` text still describes that legacy flow. -`clean` deletes `target/stellar/`, everything generated in the clients directory (keeping checked-in files like `.gitkeep`), and the contract and identity aliases Scaffold created for the local and test networks. Use it to force fresh deployments, then rerun `build --build-clients` or `npm run dev`. +`clean` deletes `target/stellar/`, everything generated in the clients directory (keeping checked-in files like `.gitkeep`), and the contract and identity aliases Scaffold created for the local and test networks. Use it to force fresh deployments, then rerun `build` or `npm run dev`. ## Config and diagnostics @@ -58,8 +58,8 @@ stellar scaffold init my-app --template react -p pnpm -y # Contracts-only project stellar scaffold init my-contracts --template none -# Build and deploy for testnet once, without the dev server -stellar scaffold build --build-clients --network testnet +# Build clients for testnet (bound to existing deployments), without the dev server +stellar scaffold build --network testnet # Validate in CI stellar scaffold config check --strict --json diff --git a/skills/stellar-scaffold/references/config.md b/skills/stellar-scaffold/references/config.md index ecdc046a..09913158 100644 --- a/skills/stellar-scaffold/references/config.md +++ b/skills/stellar-scaffold/references/config.md @@ -5,7 +5,7 @@ A Scaffold project has three config surfaces: | File | Holds | Committed | |---|---|---| | `scaffold.yml` | Networks, accounts, contracts, extensions, project directories | Yes | -| `.env` | `STELLAR_NETWORK`, `PUBLIC_STELLAR_*` for the frontend, secrets referenced by `${env.…}` | No (copy from `.env.example`) | +| `.env` | Optional `STELLAR_NETWORK`, secrets referenced by `${env.…}` | No (copy from `.env.example`) | | `Cargo.toml` | Rust workspace (`members = ["contracts/*"]`), per-crate `[package.metadata.stellar]` contract metadata | Yes | ## `scaffold.yml` (schema version 2) @@ -24,6 +24,8 @@ networks: default-account: me testnet: accounts: [testnet-user] + testnet-dev: # redeploy freely on testnet, keeping `testnet` itself bind-only + extends: testnet allow-deploy: true preview: # a second setup on testnet, with its own accounts extends: testnet @@ -149,16 +151,11 @@ Map of extension name to its config, run in order on every network. The value is ## `.env` ```bash -STELLAR_NETWORK=local # network the CLI builds and deploys for -XDG_CONFIG_HOME=".config" # keeps keys and aliases inside the project - -PUBLIC_STELLAR_NETWORK="LOCAL" # LOCAL | TESTNET | FUTURENET | PUBLIC (mainnet) -PUBLIC_STELLAR_NETWORK_PASSPHRASE="Standalone Network ; February 2017" -PUBLIC_STELLAR_RPC_URL="http://localhost:8000/rpc" -PUBLIC_STELLAR_HORIZON_URL="http://localhost:8000" +# STELLAR_NETWORK=testnet # network the CLI builds for +# MAINNET_RPC_TOKEN= # secrets referenced as ${env.NAME} ``` -`PUBLIC_*` variables are exposed to the frontend by Vite. Keep them on the same network as `STELLAR_NETWORK`. `.env.example` in the project lists the testnet and mainnet values. +A `STELLAR_NETWORK` set in the shell wins over `.env`. The frontend needs no network variables: `app-lib` reads the network from the generated clients. `PUBLIC_*` variables are still exposed to the frontend by Vite for your own values. The `stellar` CLI loads `.env` from the current directory before running a plugin, so `stellar scaffold …` (and `npm run dev`) see `STELLAR_NETWORK`. Running the `stellar-scaffold` binary directly does not load `.env`. diff --git a/skills/stellar-scaffold/references/frontend.md b/skills/stellar-scaffold/references/frontend.md index 709ba901..0b2762dc 100644 --- a/skills/stellar-scaffold/references/frontend.md +++ b/skills/stellar-scaffold/references/frontend.md @@ -4,7 +4,7 @@ Every official template (React and Svelte) shares one framework-agnostic package ## Generated clients -`stellar scaffold build --build-clients` (run by `npm run dev`) writes, for the selected network: +`stellar scaffold build` (and `watch`, run by `npm run dev`) writes, for the selected network: - `app-lib/clients//`: a TypeScript **binding** package per contract, generated by `stellar contract bindings typescript`. It holds the contract's types and a `Client` class. - `app-lib/clients/index.ts`: one configured **client** per contract, exported under the camelCase contract name from `scaffold.yml` and bound to that network's RPC URL, passphrase, and deployed contract ID. @@ -60,10 +60,10 @@ To get a client's contract ID (for event subscriptions or explorer links), read | `signTransaction` | Pass to `signAndSend` | | `onWalletChange(cb)` | Subscribe to `{ address, networkPassphrase }`; fires immediately and on connect, disconnect, or wallet network switch. Returns an unsubscribe function | | `getWalletNetwork()` | The network the wallet reports, if it supports reporting one | -| `fetchBalances(address)`, `MappedBalances` | Horizon balances keyed by asset | +| `fetchBalances(address)`, `MappedBalances` | The account's native XLM balance over RPC, as `{ xlm?: { balance } }` (`{}` when unfunded) | | `fundAccount(address)`, `getFriendbotUrl(address)` | Friendbot funding on local, testnet and futurenet | | `subscribeToEvents(contractId, topic, onEvent, pollInterval?)` | Poll RPC for a contract's events; returns a stop function | -| `network`, `stellarNetwork`, `networkPassphrase`, `rpcUrl`, `horizonUrl` | The app's network, from `PUBLIC_STELLAR_*` in `.env` | +| `network`, `stellarNetwork`, `networkPassphrase`, `rpcUrl`, `allowHttp` | The app's network, read from the generated `clients/index.ts` | | `networkStatus(...)`, `NetworkState` | Compare the wallet's network with the app's (`ok`, `mismatch`, `disconnected`, `unverified`) | | `shortAddress(addr)`, `formatNetworkName(name)`, `labPrefix()` | Formatting, and Stellar Lab link prefix | | `@stellar-scaffold/app-lib/styles.css` | Shared base styles | @@ -72,7 +72,7 @@ The templates wrap the wallet state for their framework: React has `useWallet()` ## Network consistency -The CLI bakes the network's passphrase, RPC URL, and contract IDs into `index.ts` for the network it built (`STELLAR_NETWORK`). The app connects using `PUBLIC_STELLAR_NETWORK`, `PUBLIC_STELLAR_NETWORK_PASSPHRASE`, `PUBLIC_STELLAR_RPC_URL`, and `PUBLIC_STELLAR_HORIZON_URL`. If they point at different networks, every call fails with a missing contract or a passphrase mismatch. Change both together, then restart `npm run dev` so Vite picks up the new `.env`. +The CLI bakes the network's passphrase, RPC URL, and contract IDs into `index.ts` for the network it built (`STELLAR_NETWORK`), and `app-lib` reads the app's network from that same file, so the two can't disagree. An unsupported passphrase (anything but local, testnet, futurenet, or mainnet) throws at startup rather than falling back to local. ## Building the app @@ -80,4 +80,4 @@ The CLI bakes the network's passphrase, RPC URL, and contract IDs into `index.ts npm run build --workspace app # typecheck + Vite production build; there is no root `build` script ``` -Build the clients for the production network first (`stellar scaffold build --build-clients --network mainnet`, with the `.env` `PUBLIC_*` values set for mainnet), so `index.ts` holds the production contract IDs. +Build the clients for the production network first (`stellar scaffold build --network mainnet`), so `index.ts` holds the production contract IDs. From 6a8f0b925eeb47cdb8f436602e5e55d2f22c67b9 Mon Sep 17 00:00:00 2001 From: Zach Fedor Date: Thu, 15 Oct 2026 23:13:58 -0400 Subject: [PATCH 4/8] feat(state): add .scaffold/state.json for this checkout's deployments Records the contract ID and alias Scaffold saved for each contract, per network, with the network's passphrase so a name that now points at another chain is treated as empty. The directory writes its own .gitignore. Aliases are prefixed with the workspace directory's name so separate checkouts, like git worktrees, don't share them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QhmaWLv4yExX8LahP5UtUb --- crates/stellar-scaffold-cli/src/lib.rs | 1 + crates/stellar-scaffold-cli/src/state.rs | 331 +++++++++++++++++++++++ 2 files changed, 332 insertions(+) create mode 100644 crates/stellar-scaffold-cli/src/state.rs diff --git a/crates/stellar-scaffold-cli/src/lib.rs b/crates/stellar-scaffold-cli/src/lib.rs index 0812371e..e9e0bb43 100644 --- a/crates/stellar-scaffold-cli/src/lib.rs +++ b/crates/stellar-scaffold-cli/src/lib.rs @@ -7,6 +7,7 @@ mod arg_parsing; pub mod commands; pub mod config; pub mod extension; +pub mod state; use std::path::Path; diff --git a/crates/stellar-scaffold-cli/src/state.rs b/crates/stellar-scaffold-cli/src/state.rs new file mode 100644 index 00000000..d2b80cea --- /dev/null +++ b/crates/stellar-scaffold-cli/src/state.rs @@ -0,0 +1,331 @@ +//! `.scaffold/state.json`: the contracts `build` deployed from this checkout +//! of a project, so later builds upgrade or reuse them instead of guessing +//! from stellar-cli's machine-wide contract aliases, and `clean` knows what +//! to remove. Machine-local and regenerable: the directory ignores itself. + +use std::{ + collections::BTreeMap, + fs, io, + path::{Path, PathBuf}, +}; + +use serde::{Deserialize, Serialize}; + +/// Directory at the workspace root holding Scaffold's machine-local files. +pub const DIR: &str = ".scaffold"; + +/// File within [`DIR`] recording deployments. +pub const FILE: &str = "state.json"; + +/// Format version of [`FILE`]. Independent of `scaffold.yml`'s version: the +/// file is generated, so a version this Scaffold can't read is discarded +/// rather than migrated. +pub const VERSION: u32 = 1; + +/// Written into [`DIR`] so its contents stay out of git even in projects +/// whose `.gitignore` doesn't list it. +const GITIGNORE: &str = "# Machine-local Scaffold state, created by `stellar scaffold build`\n*\n"; + +/// Longest name stellar-cli accepts for an alias. +const MAX_ALIAS_LEN: usize = 250; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + Io(#[from] io::Error), + #[error(transparent)] + Json(#[from] serde_json::Error), + #[error("version {0} is not supported; this Scaffold reads version {VERSION}")] + UnsupportedVersion(u32), +} + +/// Everything recorded for a checkout, keyed by network. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub struct State { + pub version: u32, + #[serde(default)] + pub networks: BTreeMap, +} + +/// Deployments on one network, keyed by its name in `scaffold.yml` (or the +/// environment name for environments.toml projects). The passphrase detects +/// a name that now points at a different chain. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub struct NetworkState { + pub network_passphrase: String, + #[serde(default)] + pub contracts: BTreeMap, +} + +/// One deployed contract, keyed by its name under `contracts:`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub struct ContractState { + pub contract_id: String, + /// The stellar-cli alias Scaffold saved for this contract, if any, so + /// `clean` removes exactly that alias even after the directory is renamed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub alias: Option, +} + +/// Read before the full parse, so an unsupported version is reported as +/// such instead of as whatever field it happens to break. +#[derive(Deserialize)] +struct Header { + version: u32, +} + +impl Default for State { + fn default() -> Self { + Self { + version: VERSION, + networks: BTreeMap::new(), + } + } +} + +impl State { + /// Where the state file lives for the workspace at `root`. + pub fn path(root: &Path) -> PathBuf { + root.join(DIR).join(FILE) + } + + /// The recorded state for `root`; empty when there is no file yet. + pub fn load(root: &Path) -> Result { + let contents = match fs::read_to_string(Self::path(root)) { + Ok(contents) => contents, + Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Self::default()), + Err(e) => return Err(e.into()), + }; + Self::parse(&contents) + } + + fn parse(contents: &str) -> Result { + let Header { version } = serde_json::from_str(contents)?; + if version != VERSION { + return Err(Error::UnsupportedVersion(version)); + } + Ok(serde_json::from_str(contents)?) + } + + /// Write the state for `root`, creating [`DIR`] and its `.gitignore` + /// first. Written to a temporary file and renamed, so an interrupted + /// build never leaves a half-written file behind. + pub fn save(&self, root: &Path) -> Result<(), Error> { + let dir = root.join(DIR); + fs::create_dir_all(&dir)?; + let gitignore = dir.join(".gitignore"); + if !gitignore.exists() { + fs::write(gitignore, GITIGNORE)?; + } + let path = Self::path(root); + let tmp = path.with_extension("json.tmp"); + fs::write(&tmp, format!("{}\n", serde_json::to_string_pretty(self)?))?; + fs::rename(tmp, path)?; + Ok(()) + } + + /// Delete the state file for `root`, returning whether there was one. + pub fn remove(root: &Path) -> io::Result { + match fs::remove_file(Self::path(root)) { + Ok(()) => Ok(true), + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(false), + Err(e) => Err(e), + } + } + + /// The contract recorded as `name` on `network`, unless that network + /// was recorded with a different passphrase. + pub fn contract(&self, network: &str, passphrase: &str, name: &str) -> Option<&ContractState> { + self.networks + .get(network) + .filter(|n| n.network_passphrase == passphrase)? + .contracts + .get(name) + } + + /// Record `contract` as `name` on `network`. Entries recorded under a + /// different passphrase belong to another chain and are dropped. + pub fn record(&mut self, network: &str, passphrase: &str, name: &str, contract: ContractState) { + let entry = self + .networks + .entry(network.to_string()) + .or_insert_with(|| NetworkState { + network_passphrase: passphrase.to_string(), + contracts: BTreeMap::new(), + }); + if entry.network_passphrase != passphrase { + entry.network_passphrase = passphrase.to_string(); + entry.contracts.clear(); + } + entry.contracts.insert(name.to_string(), contract); + } + + /// Every recorded alias, as `(passphrase, alias, contract_id)`. + pub fn aliases(&self) -> impl Iterator { + self.networks.values().flat_map(|network| { + network.contracts.values().filter_map(|contract| { + Some(( + network.network_passphrase.as_str(), + contract.alias.as_deref()?, + contract.contract_id.as_str(), + )) + }) + }) + } +} + +/// The stellar-cli alias for contract `name` in the workspace at `root`: +/// the workspace directory's name, then `name`, e.g. `my-app-guess_the_number`. +/// Separate checkouts, such as git worktrees, get separate aliases. Characters +/// stellar-cli doesn't allow in names become `-`. `None` when there's no +/// usable directory name or the result is too long. +pub fn alias_name(root: &Path, name: &str) -> Option { + let dir = root.file_name()?.to_string_lossy(); + let prefix: String = dir + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '_' || c == '-' { + c + } else { + '-' + } + }) + .collect(); + let prefix = prefix.trim_matches('-'); + if prefix.is_empty() { + return None; + } + let alias = format!("{prefix}-{name}"); + (alias.len() <= MAX_ALIAS_LEN).then_some(alias) +} + +#[cfg(test)] +mod tests { + use super::*; + + const LOCAL: &str = "Standalone Network ; February 2017"; + const TESTNET: &str = "Test SDF Network ; September 2015"; + + fn deployed(id: &str) -> ContractState { + ContractState { + contract_id: id.to_string(), + alias: Some(format!("app-{id}")), + } + } + + #[test] + fn missing_file_is_empty_state() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!(State::load(dir.path()).unwrap(), State::default()); + } + + #[test] + fn save_then_load_round_trips() { + let dir = tempfile::tempdir().unwrap(); + let mut state = State::default(); + state.record("local", LOCAL, "guess_the_number", deployed("C1")); + state.save(dir.path()).unwrap(); + assert_eq!(State::load(dir.path()).unwrap(), state); + } + + #[test] + fn saved_file_uses_the_documented_shape() { + let mut state = State::default(); + state.record("local", LOCAL, "guess_the_number", deployed("C1")); + let json: serde_json::Value = serde_json::to_value(&state).unwrap(); + assert_eq!( + json, + serde_json::json!({ + "version": 1, + "networks": { + "local": { + "network-passphrase": LOCAL, + "contracts": { + "guess_the_number": { "contract-id": "C1", "alias": "app-C1" } + } + } + } + }) + ); + } + + #[test] + fn save_ignores_its_own_directory() { + let dir = tempfile::tempdir().unwrap(); + State::default().save(dir.path()).unwrap(); + let gitignore = fs::read_to_string(dir.path().join(DIR).join(".gitignore")).unwrap(); + assert!(gitignore.lines().any(|line| line == "*")); + } + + #[test] + fn unsupported_version_is_an_error() { + let err = State::parse(r#"{ "version": 2, "networks": { "local": 7 } }"#).unwrap_err(); + assert!(matches!(err, Error::UnsupportedVersion(2))); + } + + #[test] + fn contract_on_a_redefined_network_is_not_found() { + let mut state = State::default(); + state.record("local", LOCAL, "counter", deployed("C1")); + assert!(state.contract("local", LOCAL, "counter").is_some()); + // `local` now names a different chain + assert!(state.contract("local", TESTNET, "counter").is_none()); + } + + #[test] + fn recording_on_a_redefined_network_drops_its_old_entries() { + let mut state = State::default(); + state.record("dev", LOCAL, "counter", deployed("C1")); + state.record("dev", TESTNET, "token", deployed("C2")); + let dev = &state.networks["dev"]; + assert_eq!(dev.network_passphrase, TESTNET); + assert_eq!(dev.contracts.keys().collect::>(), ["token"]); + } + + #[test] + fn aliases_skip_contracts_without_one() { + let mut state = State::default(); + state.record("local", LOCAL, "counter", deployed("C1")); + state.record( + "local", + LOCAL, + "token", + ContractState { + contract_id: "C2".into(), + alias: None, + }, + ); + assert_eq!( + state.aliases().collect::>(), + [(LOCAL, "app-C1", "C1")] + ); + } + + #[test] + fn remove_reports_whether_there_was_a_file() { + let dir = tempfile::tempdir().unwrap(); + assert!(!State::remove(dir.path()).unwrap()); + State::default().save(dir.path()).unwrap(); + assert!(State::remove(dir.path()).unwrap()); + assert!(!State::path(dir.path()).exists()); + } + + #[test] + fn alias_is_prefixed_with_the_directory_name() { + assert_eq!( + alias_name(Path::new("/code/my-app"), "guess_the_number").as_deref(), + Some("my-app-guess_the_number") + ); + // characters stellar-cli rejects in names + assert_eq!( + alias_name(Path::new("/code/My App.v2"), "token").as_deref(), + Some("My-App-v2-token") + ); + assert_eq!(alias_name(Path::new("/"), "token"), None); + assert_eq!(alias_name(Path::new("/code/..."), "token"), None); + assert_eq!(alias_name(Path::new(&"a".repeat(250)), "token"), None); + } +} From b63525147492503a76d89b7b1501dfc3b1dda201 Mon Sep 17 00:00:00 2001 From: Zach Fedor Date: Thu, 15 Oct 2026 23:23:59 -0400 Subject: [PATCH 5/8] fix(build): decide deploys from this checkout's state, not global aliases stellar-cli's contract aliases are machine-wide, so two projects with a contract of the same name shared one: the second build adopted, or upgraded, the first project's contract. `build` now looks up what this checkout deployed in .scaffold/state.json and records each deploy there. It still saves a stellar-cli alias for hand-run commands, prefixed with the directory name, and only when the alias is free or already Scaffold's, so one made by hand is never overwritten. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QhmaWLv4yExX8LahP5UtUb --- .../src/commands/build/clients.rs | 148 ++++++++++++++---- .../tests/it/build_clients/contracts.rs | 61 ++++++++ 2 files changed, 180 insertions(+), 29 deletions(-) diff --git a/crates/stellar-scaffold-cli/src/commands/build/clients.rs b/crates/stellar-scaffold-cli/src/commands/build/clients.rs index 839c04ec..be52fafd 100644 --- a/crates/stellar-scaffold-cli/src/commands/build/clients.rs +++ b/crates/stellar-scaffold-cli/src/commands/build/clients.rs @@ -8,6 +8,7 @@ use crate::commands::{PackageManager, PackageManagerSpec}; use crate::config::schema::Project; use crate::config::source::same_crate; use crate::extension::{self, ResolvedExtension}; +use crate::state::{self, ContractState, State}; use indexmap::IndexMap; use regex::Regex; use serde_json; @@ -39,7 +40,8 @@ use stellar_xdr::{Error as xdrError, ScSpecEntry, ScSpecTypeBytesN, ScSpecTypeDe /// Internal decision about what deploy action to take for a contract. /// Resolved before `pre-deploy` fires so the hook always has a clean execution context. enum DeployDecision { - /// No existing alias, or existing contract is not upgradeable — create a new instance. + /// Nothing recorded for this checkout, or the recorded contract is gone or + /// not upgradeable — create a new instance. Fresh, /// Existing contract upgraded in-place; the returned ID is unchanged. Upgraded(Contract), @@ -147,6 +149,8 @@ pub enum Error { WasmHash(#[from] xdrError), #[error(transparent)] Io(#[from] std::io::Error), + #[error("⛔ ️failed to record the deployment in {}: {source}", path.display())] + State { path: PathBuf, source: state::Error }, #[error(transparent)] Json(#[from] serde_json::Error), #[error("⛔ ️Failed to run package manager command in {0:?}: {1}")] @@ -335,13 +339,26 @@ impl Builder { } } - fn get_contract_alias( - &self, - name: &str, - network: &network::Network, - ) -> Result, stellar_cli::config::locator::Error> { - self.get_config_locator() - .get_contract_id(name, &network.network_passphrase) + /// This checkout's `.scaffold/state.json`. An unreadable file is reported + /// and treated as empty, so its contracts are deployed fresh and the file + /// is rewritten. + fn load_state(&self) -> State { + State::load(&self.workspace_root).unwrap_or_else(|e| { + self.printer.warnln(format!( + "Ignoring {}: {e}", + State::path(&self.workspace_root).display() + )); + State::default() + }) + } + + /// The contract this checkout last deployed as `name` on the current + /// network. stellar-cli's aliases are machine-wide, so another project's + /// contract of the same name is never mistaken for this one. + fn recorded_contract(&self, name: &str) -> Option { + let state = self.load_state(); + let recorded = state.contract(&self.env_label, &self.network.network_passphrase, name)?; + Contract::from_string(&recorded.contract_id).ok() } async fn get_contract_hash( @@ -375,30 +392,83 @@ impl Builder { } } - fn save_contract_alias( + /// Record `contract_id` as this checkout's deployment of `name`, and point + /// a stellar-cli alias (see [`state::alias_name`]) at it for running + /// `stellar` by hand. The alias is only written when it's free or already + /// Scaffold's, so an alias someone made by hand is never overwritten. + fn record_deployment(&self, name: &str, contract_id: &Contract) -> Result<(), Error> { + let root = &self.workspace_root; + let passphrase = &self.network.network_passphrase; + let mut state = self.load_state(); + let previous = state.contract(&self.env_label, passphrase, name).cloned(); + let previous_id = previous.as_ref().map(|p| p.contract_id.as_str()); + + let alias = state::alias_name(root, name) + .filter(|alias| self.may_write_alias(alias, contract_id, previous_id)); + if let Some(alias) = &alias { + self.get_config_locator() + .save_contract_id(passphrase, contract_id, alias)?; + self.printer + .infoln(format!("Saved alias {alias:?} for {name:?}")); + } + // An alias saved under an earlier directory name is no longer ours to keep. + if let Some(old) = previous.as_ref().and_then(|p| p.alias.as_deref()) + && alias.as_deref() != Some(old) + && self.alias_target(old).as_deref() == previous_id + { + let _ = self + .get_config_locator() + .remove_contract_id(passphrase, old); + } + + let recorded = ContractState { + contract_id: format!("{contract_id}"), + alias, + }; + state.record(&self.env_label, passphrase, name, recorded); + state.save(root).map_err(|source| Error::State { + path: State::path(root), + source, + }) + } + + /// The contract `alias` points at on the current network, if any. + fn alias_target(&self, alias: &str) -> Option { + self.get_config_locator() + .get_contract_id(alias, &self.network.network_passphrase) + .ok() + .flatten() + .map(|c| format!("{c}")) + } + + /// Whether Scaffold may point `alias` at `contract_id`, warning if not. + fn may_write_alias( &self, - name: &str, + alias: &str, contract_id: &Contract, - network: &network::Network, - ) -> Result<(), stellar_cli::config::locator::Error> { - let config_dir = self.get_config_locator(); - let passphrase = &network.network_passphrase; - config_dir.save_contract_id(passphrase, contract_id, name) + previous_id: Option<&str>, + ) -> bool { + let existing = self.alias_target(alias); + let ours = alias_is_ours(existing.as_deref(), &format!("{contract_id}"), previous_id); + if !ours { + self.printer.warnln(format!( + "Not saving alias {alias:?}: it points at a contract this checkout didn't deploy. Run `stellar contract alias remove {alias}` to let Scaffold manage it" + )); + } + ours } /// Resolve the on-chain contract id baked into a Client: a pinned `id` from - /// the environment config takes precedence, otherwise the saved alias for the - /// current network. - fn resolve_contract_id(&self, name: &str) -> Result, Error> { + /// the environment config takes precedence, otherwise the contract this + /// checkout deployed on the current network. + fn resolve_contract_id(&self, name: &str) -> Option { if let Some(contracts) = self.env.contracts.as_ref() && let Some(contract) = contracts.get(name) && let Some(id) = &contract.id { - return Ok(Some(id.clone())); + return Some(id.clone()); } - Ok(self - .get_contract_alias(name, &self.network)? - .map(|c| format!("{c}"))) + self.recorded_contract(name).map(|c| format!("{c}")) } /// Regenerate the single flattened Clients file `clients_dir/index.ts` from @@ -444,7 +514,7 @@ impl Builder { { continue; } - let Some(contract_id) = self.resolve_contract_id(name)? else { + let Some(contract_id) = self.resolve_contract_id(name) else { continue; }; clients.push((name.clone(), contract_id)); @@ -907,7 +977,7 @@ impl Builder { let new_hash = self.upload_contract_wasm(name, &wasm_path).await?; // Determine what deploy action is needed before firing any hooks. - let decision = if let Some(existing_id) = self.get_contract_alias(name, network)? { + let decision = if let Some(existing_id) = self.recorded_contract(name) { let hash = self.get_contract_hash(&existing_id, network).await?; if let Some(current_hash) = hash { if current_hash == new_hash { @@ -953,7 +1023,7 @@ impl Builder { } }; - // Run after_deploy script and save alias only when something changed on-chain. + // Run after_deploy and record the deployment only when something changed on-chain. if deploy_kind != DeployKind::Unchanged { if let Some(resolved) = &settings.resolved { self.run_after_deploy_methods(name, &contract_id, resolved) @@ -965,7 +1035,7 @@ impl Builder { self.run_after_deploy_script(name, &contract_id, after_deploy) .await?; } - self.save_contract_alias(name, &contract_id, network)?; + self.record_deployment(name, &contract_id)?; } extension::run_hook( @@ -1061,12 +1131,11 @@ impl Builder { async fn deploy_contract( &self, - name: &str, + _name: &str, hash: &str, settings: &env_toml::Contract, ) -> Result { let mut deploy_args = vec![ - format!("--alias={name}"), format!("--wasm-hash={hash}"), "--config-dir".to_string(), self.get_config_dir()? @@ -1360,6 +1429,13 @@ impl ClientsArgs { } } +/// Whether an alias currently pointing at `existing` belongs to Scaffold and +/// may be pointed at `new`: it's unset, already `new`, or still the contract +/// this checkout recorded before (`previous`). +fn alias_is_ours(existing: Option<&str>, new: &str, previous: Option<&str>) -> bool { + existing.is_none_or(|existing| existing == new || Some(existing) == previous) +} + /// Whether generated binding source is bound to `contract_id`. Matches the /// `contractId: "C…"` field the bindings generator writes, not the address /// appearing anywhere in the file. @@ -1496,9 +1572,23 @@ async fn fetch_contract_spec( #[cfg(test)] mod tests { - use super::{binding_targets, render_clients_index, to_camel_case, to_pascal_case}; + use super::{ + alias_is_ours, binding_targets, render_clients_index, to_camel_case, to_pascal_case, + }; use stellar_cli::config::network::passphrase; + #[test] + fn scaffold_only_moves_aliases_it_owns() { + // free, or already pointing at the new deployment + assert!(alias_is_ours(None, "C2", None)); + assert!(alias_is_ours(Some("C2"), "C2", None)); + // still this checkout's previous deployment, e.g. after a redeploy + assert!(alias_is_ours(Some("C1"), "C2", Some("C1"))); + // made by hand, or by another checkout with the same directory name + assert!(!alias_is_ours(Some("C9"), "C2", None)); + assert!(!alias_is_ours(Some("C9"), "C2", Some("C1"))); + } + #[test] fn binding_target_matches_the_contract_id_field() { let id = "CBXH2G4SOWFSN5HUUJHKG34MZKMPQNL3QGZEBJLMEDINSZYJ5V3P64LH"; diff --git a/crates/stellar-scaffold-cli/tests/it/build_clients/contracts.rs b/crates/stellar-scaffold-cli/tests/it/build_clients/contracts.rs index 03c8cfc0..0afdc860 100644 --- a/crates/stellar-scaffold-cli/tests/it/build_clients/contracts.rs +++ b/crates/stellar-scaffold-cli/tests/it/build_clients/contracts.rs @@ -274,6 +274,67 @@ soroban_token_contract.client = false }); } +/// Two checkouts with the same directory name, sharing one stellar-cli +/// config the way projects on one machine do. Each deploys and keeps its own +/// contract; the second doesn't adopt or upgrade the first's through the +/// machine-wide alias, and doesn't overwrite that alias either. +#[test] +fn checkouts_sharing_a_stellar_config_keep_their_own_contracts() { + let shared_config = tempfile::TempDir::new().unwrap(); + let first = TestEnv::new("soroban-init-boilerplate"); + let second = TestEnv::new("soroban-init-boilerplate"); + let toml = format!( + r#" +development.accounts = [ + {{ name = "alice" }}, +] + +[development.network] +rpc-url = "{}" +network-passphrase = "Standalone Network ; February 2017" + +[development.contracts] +soroban_hello_world_contract.client = true +soroban_custom_types_contract.client = false +soroban_auth_contract.client = false +soroban_token_contract.client = false +"#, + rpc_url() + ); + // Identical Wasm in both, so only the recorded state tells them apart + let build = |env: &TestEnv| { + env.set_environments_toml(&toml); + env.scaffold_build("development", false) + .env("XDG_CONFIG_HOME", shared_config.path()) + .assert() + .success() + .stderr_as_str() + }; + let contract_id = |env: &TestEnv| { + let index = std::fs::read_to_string(env.cwd.join("app-lib/clients/index.ts")).unwrap(); + extract_contract_id_from_ts(&index).unwrap() + }; + + let first_build = build(&first); + assert!( + first_build + .contains("Saved alias \"soroban-init-boilerplate-soroban_hello_world_contract\"") + ); + let second_build = build(&second); + assert!(!second_build.contains("is up to date"), "{second_build}"); + assert!(second_build.contains("Not saving alias"), "{second_build}"); + assert_ne!(contract_id(&first), contract_id(&second)); + + // The first checkout's contract is untouched and still recognized + let rebuild = build(&first); + assert!( + rebuild.contains("Contract \"soroban_hello_world_contract\" is up to date"), + "{rebuild}" + ); + assert!(first.cwd.join(".scaffold/state.json").exists()); + assert!(first.cwd.join(".scaffold/.gitignore").exists()); +} + /// Extracts the first contract ID from a `contractId: "..."` line in the /// generated flattened Clients `index.ts` (`regenerate_clients_index`). fn extract_contract_id_from_ts(content: &str) -> Option { From e20f04104372024387a8bdba14e45ea36ebd6786 Mon Sep 17 00:00:00 2001 From: Zach Fedor Date: Thu, 15 Oct 2026 23:24:03 -0400 Subject: [PATCH 6/8] fix(clean): clear this checkout's state and keep shared keys `clean` removes the aliases recorded in .scaffold/state.json, while they still point at the recorded contracts, then the file itself. It no longer deletes identities: keys are machine-wide, so another project may depend on them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QhmaWLv4yExX8LahP5UtUb --- .../src/commands/clean.rs | 224 ++++++------------ .../tests/it/features/clean.rs | 59 +---- 2 files changed, 82 insertions(+), 201 deletions(-) diff --git a/crates/stellar-scaffold-cli/src/commands/clean.rs b/crates/stellar-scaffold-cli/src/commands/clean.rs index a11514bc..7d1984b7 100644 --- a/crates/stellar-scaffold-cli/src/commands/clean.rs +++ b/crates/stellar-scaffold-cli/src/commands/clean.rs @@ -1,6 +1,5 @@ -use crate::commands::build::clients::ScaffoldEnv; -use crate::commands::build::env_toml::{self, Account, Environment}; use crate::config::{self, schema::Project}; +use crate::state::{self, State}; use cargo_metadata::Metadata; use clap::Parser; use std::{ @@ -8,7 +7,7 @@ use std::{ path::{Path, PathBuf}, process::Command, }; -use stellar_cli::{commands::global, print::Print}; +use stellar_cli::{commands::global, config::locator, print::Print}; /// A command to clean the scaffold-generated artifacts from a project #[derive(Parser, Debug, Clone)] pub struct Cmd { @@ -21,20 +20,14 @@ pub struct Cmd { pub enum Error { #[error(transparent)] IO(#[from] io::Error), - - #[error("network config is not sufficient: need name or url and passphrase")] - NetworkConfig, - - #[error(transparent)] - EnvToml(#[from] env_toml::Error), } // cleans up scaffold artifacts // - target/stellar ✅ // - clients_dir/* — Binding packages + flattened Clients index.ts (but not // checked-into-git files like .gitkeep) ✅ -// - contract aliases (for local and test) ✅ -// - identity aliases (for local and test) ✅ +// - contract aliases recorded in .scaffold/state.json, and the file itself ✅ +// - identities are kept: they're machine-wide, shared with other projects // - should this be deleting target/stellar/local and target/stellar/testnet specifically to avoid deleting mainnet? // - what about target/packages? @@ -64,9 +57,7 @@ impl Cmd { Self::clean_clients(&workspace_root, &project, &printer)?; - Self::clean_contract_aliases(&workspace_root, &printer)?; - - Self::clean_identities(&workspace_root, &printer); + Self::clean_state(&workspace_root, &global_args.locator, &printer)?; Ok(()) } @@ -102,151 +93,36 @@ impl Cmd { Self::clean_dir(workspace_root, &clients_path, &git_tracked_entries, printer) } - fn clean_contract_aliases(workspace_root: &Path, printer: &Print) -> Result<(), Error> { - match Environment::get(workspace_root, &ScaffoldEnv::Development) { - Ok(Some(env)) => { - let network_args = Self::get_network_args(&env)?; - if let Some(contracts) = &env.contracts { - for (contract_name, _) in contracts { - let result = std::process::Command::new("stellar") - .args(["contract", "alias", "remove", contract_name]) - .args(&network_args) - .output(); - - match result { - Ok(output) if output.status.success() => { - printer.infoln(format!("Removed contract alias: {contract_name}")); - } - Ok(output) => { - let stderr = String::from_utf8_lossy(&output.stderr); - if !stderr.contains("not found") && !stderr.contains("No alias") { - printer.warnln(format!( - "Failed to remove contract alias {contract_name}: {stderr}" - )); - } - } - Err(e) => { - printer.warnln(format!( - "Failed to execute stellar contract alias remove: {e}" - )); - } - } - } - } - } - Ok(None) => { - printer.infoln("No development environment found in environments.toml"); - } - Err(e) => { - printer.warnln(format!("Failed to read environments.toml: {e}")); - } - } - - Ok(()) - } - - fn clean_identities(workspace_root: &Path, printer: &Print) { - match Environment::get(workspace_root, &ScaffoldEnv::Development) { - Ok(Some(env)) => { - // only clean the alias if it is only configured for Development, otherwise warn - if let Some(accounts) = &env.accounts { - for account in accounts { - let other_envs = Self::account_in_other_envs(workspace_root, account); - if !other_envs.is_empty() { - printer.warnln(format!("Skipping cleaning identity {:?}. It is being used in other environments: {:?}.", account.name, other_envs)); - return; - } - - let result = std::process::Command::new("stellar") - .args(["keys", "rm", "--force", &account.name]) - .output(); - - match result { - Ok(output) if output.status.success() => { - printer.infoln(format!("Removed account: {}", &account.name)); - } - Ok(output) => { - let stderr = String::from_utf8_lossy(&output.stderr); - if !stderr.contains("not found") && !stderr.contains("No alias") { - printer.warnln(format!( - "Warning: Failed to remove account {}: {stderr}", - &account.name - )); - } - } - Err(e) => { - printer.warnln(format!(" Warning: Failed to execute stellar contract alias remove: {e}")); - } - } - } - } - } - Ok(None) => { - printer.infoln("No development environment found in environments.toml"); - } - Err(e) => { - printer.warnln(format!("Warning: Failed to read environments.toml: {e}")); - } - } - } - - fn account_in_other_envs(workspace_root: &Path, current_account: &Account) -> Vec { - let mut other_envs: Vec = vec![]; - - if let Some(testing) = Environment::get(workspace_root, &ScaffoldEnv::Testing) - .ok() - .flatten() - { - let found = testing - .accounts - .as_ref() - .is_some_and(|accts| accts.iter().any(|acct| acct.name == current_account.name)); - if found { - other_envs.push(ScaffoldEnv::Testing); - } - } - - if let Some(staging) = Environment::get(workspace_root, &ScaffoldEnv::Staging) - .ok() - .flatten() - { - let found = staging - .accounts - .as_ref() - .is_some_and(|accts| accts.iter().any(|acct| acct.name == current_account.name)); - if found { - other_envs.push(ScaffoldEnv::Staging); + /// Remove the stellar-cli aliases this checkout's builds saved, then its + /// `.scaffold/state.json`, so the next build deploys fresh. An alias that + /// has since been pointed elsewhere isn't Scaffold's any more and is kept. + /// Keys are kept too: they're shared by every project on this machine. + fn clean_state( + workspace_root: &Path, + locator: &locator::Args, + printer: &Print, + ) -> Result<(), Error> { + let state = State::load(workspace_root).unwrap_or_else(|e| { + printer.warnln(format!( + "Can't read {}, so its aliases are kept: {e}", + State::path(workspace_root).display() + )); + State::default() + }); + for (passphrase, alias, contract_id) in state.aliases() { + let target = locator.get_contract_id(alias, passphrase).ok().flatten(); + if target.is_none_or(|c| format!("{c}") != contract_id) { + continue; } - } - - if let Some(production) = Environment::get(workspace_root, &ScaffoldEnv::Production) - .ok() - .flatten() - { - let found = production - .accounts - .as_ref() - .is_some_and(|accts| accts.iter().any(|acct| acct.name == current_account.name)); - if found { - other_envs.push(ScaffoldEnv::Production); + match locator.remove_contract_id(passphrase, alias) { + Ok(()) => printer.infoln(format!("Removed contract alias: {alias}")), + Err(e) => printer.warnln(format!("Failed to remove contract alias {alias}: {e}")), } } - - other_envs - } - - fn get_network_args(env: &Environment) -> Result, Error> { - match ( - &env.network.name, - &env.network.rpc_url, - &env.network.network_passphrase, - ) { - (Some(name), _, _) => Ok(vec!["--network", name]), - (None, Some(url), Some(passphrase)) => { - Ok(vec!["--rpc-url", url, "--network-passphrase", passphrase]) - } - _ => Err(Error::NetworkConfig), + if State::remove(workspace_root)? { + printer.infoln(format!("Removed {}/{}", state::DIR, state::FILE)); } + Ok(()) } fn git_tracked_entries(workspace_root: &Path, subdir: &str) -> Vec { @@ -345,6 +221,44 @@ crate-type = ["cdylib"] manifest_path } + #[test] + fn clean_state_removes_only_aliases_it_still_owns() { + const LOCAL: &str = "Standalone Network ; February 2017"; + let workspace = TempDir::new().unwrap(); + let config_dir = TempDir::new().unwrap(); + let locator = locator::Args { + config_dir: Some(config_dir.path().to_path_buf()), + }; + let ours = stellar_strkey::Contract([1; 32]); + let theirs = stellar_strkey::Contract([2; 32]); + + let mut state = State::default(); + for (name, alias) in [("counter", "app-counter"), ("token", "app-token")] { + let contract = state::ContractState { + contract_id: format!("{ours}"), + alias: Some(alias.to_string()), + }; + state.record("local", LOCAL, name, contract); + } + state.save(workspace.path()).unwrap(); + locator + .save_contract_id(LOCAL, &ours, "app-counter") + .unwrap(); + // pointed elsewhere since the build recorded it + locator + .save_contract_id(LOCAL, &theirs, "app-token") + .unwrap(); + + Cmd::clean_state(workspace.path(), &locator, &Print::new(true)).unwrap(); + + assert_eq!(locator.get_contract_id("app-counter", LOCAL).unwrap(), None); + assert_eq!( + locator.get_contract_id("app-token", LOCAL).unwrap(), + Some(theirs) + ); + assert!(!State::path(workspace.path()).exists()); + } + #[test] fn test_clean_target_stellar() { let global_args = global::Args::default(); diff --git a/crates/stellar-scaffold-cli/tests/it/features/clean.rs b/crates/stellar-scaffold-cli/tests/it/features/clean.rs index ae1425e8..91f8cc48 100644 --- a/crates/stellar-scaffold-cli/tests/it/features/clean.rs +++ b/crates/stellar-scaffold-cli/tests/it/features/clean.rs @@ -26,8 +26,16 @@ soroban_token_contract.client = false assert!(cmd.contains("Starting workspace cleanup")); assert!(cmd.contains("Removed app-lib/clients/soroban_hello_world_contract")); assert!(cmd.contains("Removed app-lib/clients/index.ts")); - assert!(cmd.contains("Removed contract alias: soroban_hello_world_contract")); - assert!(cmd.contains("Removed account: alice")); + assert!(cmd.contains( + "Removed contract alias: soroban-init-boilerplate-soroban_hello_world_contract" + )); + assert!(cmd.contains("Removed .scaffold/state.json")); + // Keys are machine-wide, shared with other projects + assert!(!cmd.contains("Removed account")); + env.stellar("keys") + .args(["address", "alice"]) + .assert() + .success(); }) .await; } @@ -60,50 +68,9 @@ soroban_token_contract.client = false let calling_clean_again = env.scaffold("clean").assert().success().stderr_as_str(); assert!(calling_clean_again.contains("Starting workspace cleanup")); assert!(calling_clean_again.contains("Skipping target clean")); - assert!( - calling_clean_again - .contains("Failed to remove contract alias soroban_hello_world_contract") - ); // it has already been removed - assert!(calling_clean_again.contains("Failed to remove account alice")); // it has already been removed + // Nothing left to remove, and nothing to complain about + assert!(!calling_clean_again.contains("contract alias")); + assert!(!calling_clean_again.contains("state.json")); }) .await; } - -#[tokio::test] -async fn when_identity_is_used_in_multiple_envs() { - TestEnv::from_async("soroban-init-boilerplate", async |env| { - env.set_environments_toml(format!( - r#" -[development] -network = {{ rpc-url = "{}", network-passphrase = "Standalone Network ; February 2017" }} - -accounts = [ - "alice", -] -[development.contracts] -soroban_hello_world_contract.client = true -soroban_increment_contract.client = false -soroban_custom_types_contract.client = false -soroban_auth_contract.client = false -soroban_token_contract.client = false - - -[staging] -network = {{ rpc-url = "{}", network-passphrase = "Standalone Network ; February 2017" }} - -accounts = [ - "alice", -] -"#, - rpc_url(), - rpc_url(), - )); - - env.scaffold_build("development", true) - .assert() - .success(); - - let cmd = env.scaffold("clean").assert().success().stderr_as_str(); - assert!(cmd.contains("Skipping cleaning identity \"alice\". It is being used in other environments: [Staging].")); - }).await; -} From 02682382244ada64c5292a162c15bcc1c5de3c40 Mon Sep 17 00:00:00 2001 From: Zach Fedor Date: Thu, 15 Oct 2026 23:26:18 -0400 Subject: [PATCH 7/8] docs: explain .scaffold/state.json and project-named aliases Describe how build records deployments per checkout, the `-` alias it saves for hand-run commands, and that `clean` keeps keys. The tutorial's scaffold.yml now matches the template: testnet points at a deliberate deployment instead of deploying from the dev loop. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QhmaWLv4yExX8LahP5UtUb --- docs/site/docs/cli.md | 8 +++++--- docs/site/docs/configuration.md | 4 ++++ .../docs/tutorial/02-making-improvements.md | 20 +++++++++---------- skills/stellar-scaffold/SKILL.md | 1 + skills/stellar-scaffold/references/cli.md | 2 +- skills/stellar-scaffold/references/config.md | 2 +- 6 files changed, 22 insertions(+), 15 deletions(-) diff --git a/docs/site/docs/cli.md b/docs/site/docs/cli.md index e2c7e33d..7c0618d8 100644 --- a/docs/site/docs/cli.md +++ b/docs/site/docs/cli.md @@ -126,11 +126,13 @@ Options: Clean removes: -- `target/stellar/`, the deployment state for the local and test networks +- `target/stellar/`, the built Wasm - Everything generated into your clients directory, while leaving checked-in files such as `.gitkeep` alone -- The contract and identity aliases the CLI created for the local and test networks +- `.scaffold/state.json`, the record of what this checkout deployed, and the contract aliases it lists, if they still point at those deployments -Reach for it when you want a genuinely fresh deployment. Because Stellar Scaffold only redeploys a contract when the contract itself changes, editing something else — constructor `args` or `after-deploy` in `scaffold.yml`, for instance — will not produce a new instance on its own. Clearing the aliases makes the next `stellar scaffold watch` deploy from scratch. +Keys are kept. stellar-cli stores them for the whole machine, so other projects may be using them; remove one yourself with `stellar keys rm `. + +Reach for it when you want a genuinely fresh deployment. Because Stellar Scaffold only redeploys a contract when the contract itself changes, editing something else — constructor `args` or `after-deploy` in `scaffold.yml`, for instance — will not produce a new instance on its own. Clearing the record makes the next `stellar scaffold watch` deploy from scratch. ## Doctor Command diff --git a/docs/site/docs/configuration.md b/docs/site/docs/configuration.md index 934d66d5..38b98a74 100644 --- a/docs/site/docs/configuration.md +++ b/docs/site/docs/configuration.md @@ -213,6 +213,10 @@ after-deploy: [reset, unpause] When a `workspace` contract's Wasm changes, the next build upgrades the existing deployment if the contract supports it, or redeploys it otherwise. Changing only `args` or `after-deploy` does not trigger a redeploy. Run `stellar scaffold clean` to start fresh. +Scaffold records what it deployed in `.scaffold/state.json`, per network, and decides between upgrading and redeploying from that record. The file belongs to one checkout of your project and isn't committed: the `.scaffold/` directory ignores itself. Separate checkouts, such as git worktrees, keep separate records, so they never upgrade each other's contracts. If the network was reset and a recorded contract is gone, the next build deploys it again. + +For running `stellar` commands yourself, Scaffold also saves a contract alias named after the project's directory and the contract, such as `my-app-guess-the-number`. stellar-cli's aliases are shared by every project on the machine, so Scaffold only writes one that is unused or already its own, and warns instead of overwriting an alias someone else made. + If one contract fails to deploy, the build carries on with the rest, exports what it can, and then exits with an error. ## `extensions` diff --git a/docs/site/docs/tutorial/02-making-improvements.md b/docs/site/docs/tutorial/02-making-improvements.md index bfa2af3c..35377255 100644 --- a/docs/site/docs/tutorial/02-making-improvements.md +++ b/docs/site/docs/tutorial/02-making-improvements.md @@ -95,9 +95,9 @@ contracts: after-deploy: [reset] networks: local: - testnet: - args: - admin: ${account.testnet-user} + # testnet: + # type: contract + # source: C... # the deployed contract's ID ``` Let's walk through this line by line: @@ -152,7 +152,7 @@ Let's walk through this line by line: ```bash stellar contract invoke \ - --id guess-the-number \ + --id CCMMU6UYIPGSBR7ZP4DTQEEOQDHL3PJ52ZD7FJIFG4O46Q3QPVGVHAAV \ --source me \ -- \ reset @@ -165,7 +165,7 @@ Let's walk through this line by line: [0] ✅ After deploy calls for "guess-the-number" completed ``` -- `networks`: the networks this contract exists on. A contract only exists on the networks it lists, so if you built for a network that isn't here, Scaffold would skip it and your frontend wouldn't get a `guessTheNumber` client at all. Entries here can also override the settings above for one network: on `testnet`, the `admin` is a testnet account instead, because accounts belong to a network. +- `networks`: the networks this contract exists on. A contract only exists on the networks it lists, so if you built for a network that isn't here, Scaffold would skip it and your frontend wouldn't get a `guessTheNumber` client at all. Entries here can also override the settings above for one network. The commented `testnet` entry shows the usual shape for a public network: rather than deploying from your dev loop, point at a contract you deployed there deliberately. Want to see exactly what Scaffold will use for a network, with all the defaults filled in? Run `stellar scaffold config show --network local`. @@ -188,7 +188,7 @@ Nothing. Nothing happens. At least not yet. The contract didn't change, so Scaffold CLI didn't re-deploy the contract. You're still using the instance that had the `reset` method called right after deploy. -To get a fresh deployment, clear the artifacts Stellar Scaffold is tracking — the generated clients, the build output, and the contract and identity aliases it uses to remember what is already deployed. Stop the `npm run dev` process, then run: +To get a fresh deployment, clear the artifacts Stellar Scaffold is tracking — the generated clients, the build output, and its record of what it already deployed, in `.scaffold/state.json`. Stop the `npm run dev` process, then run: ```bash stellar scaffold clean @@ -287,10 +287,10 @@ $ npm run dev Click over to the Debugger if you're not there already and select the `guess-the-number` contract. You'll see that `reset` is listed here, but `set_random_number` is not. -Our `reset` method is available to be called by code _outside_ our contract because we opted in to it being a public method with the `pub` keyword. Our `set_random_number` is private by default, it's not visible to the outside world. It's not listed in the Contract Explorer. It's not listed in the CLI help either: +Our `reset` method is available to be called by code _outside_ our contract because we opted in to it being a public method with the `pub` keyword. Our `set_random_number` is private by default, it's not visible to the outside world. It's not listed in the Contract Explorer. It's not listed in the CLI help either. Scaffold saved a contract alias for it, named after your project's directory and the client, so you can refer to it by name: ```bash -$ stellar contract invoke --id guess-the-number --source me --network local -- help +$ stellar contract invoke --id guessing-game-tutorial-guess-the-number --source me --network local -- help Commands: reset Update the number. Only callable by admin. guess Guess a number between 1 and 10 @@ -301,7 +301,7 @@ Commands: It would error if you tried to invoke it: ```bash -$ stellar contract invoke --id guess-the-number --source me --network local -- set_random_number +$ stellar contract invoke --id guessing-game-tutorial-guess-the-number --source me --network local -- set_random_number error: unrecognized subcommand 'set_random_number' ``` @@ -318,7 +318,7 @@ $ stellar keys generate bob --network local --fund ✅ Key saved with alias bob in "~/.config/stellar/identity/bob.toml" ✅ Account bob funded on "Standalone Network ; February 2017" -$ stellar contract invoke --id guess-the-number --source bob --network local -- reset +$ stellar contract invoke --id guessing-game-tutorial-guess-the-number --source bob --network local -- reset ❌ error: Missing signing key for account GDAQWVA6REGN47BBCFY6SGQ4YTIGMDZZFHDOVUZXMVRAAT6OEZGCACGH ``` diff --git a/skills/stellar-scaffold/SKILL.md b/skills/stellar-scaffold/SKILL.md index a0eac011..b3ef4367 100644 --- a/skills/stellar-scaffold/SKILL.md +++ b/skills/stellar-scaffold/SKILL.md @@ -52,6 +52,7 @@ my-app/ │ └── clients/ # GENERATED: one package per contract + index.ts. Never edit. ├── scaffold.yml # networks, accounts, contracts ├── .env # optional STELLAR_NETWORK, secrets for ${env.…} (gitignored) +├── .scaffold/ # GENERATED: what this checkout deployed (state.json). Ignores itself. ├── Cargo.toml # Rust workspace └── package.json # npm workspaces root ``` diff --git a/skills/stellar-scaffold/references/cli.md b/skills/stellar-scaffold/references/cli.md index 30fa2740..b7a4214f 100644 --- a/skills/stellar-scaffold/references/cli.md +++ b/skills/stellar-scaffold/references/cli.md @@ -32,7 +32,7 @@ After compiling, for the selected network `build` also: starts the local contain `build` and `watch` also accept a positional `[ENV]` (`development`, `testing`, `staging`, `production`, or `STELLAR_SCAFFOLD_ENV`). It only applies to legacy projects with `environments.toml`, and their `--help` text still describes that legacy flow. -`clean` deletes `target/stellar/`, everything generated in the clients directory (keeping checked-in files like `.gitkeep`), and the contract and identity aliases Scaffold created for the local and test networks. Use it to force fresh deployments, then rerun `build` or `npm run dev`. +`clean` deletes `target/stellar/`, everything generated in the clients directory (keeping checked-in files like `.gitkeep`), and `.scaffold/state.json` along with the contract aliases it recorded. Keys are kept, since stellar-cli shares them across projects. Use it to force fresh deployments, then rerun `build` or `npm run dev`. ## Config and diagnostics diff --git a/skills/stellar-scaffold/references/config.md b/skills/stellar-scaffold/references/config.md index 09913158..be214acc 100644 --- a/skills/stellar-scaffold/references/config.md +++ b/skills/stellar-scaffold/references/config.md @@ -84,7 +84,7 @@ Network fields may use `${env.…}` only. ### `contracts` -The key is the **client name**: the package directory `//` and, camelCased, the export from `index.ts` (`my-token` → `myToken`). It is independent of the crate name. It is also the contract alias Scaffold saves, so `stellar contract invoke --id my-token --network local -- …` works after a deploy. +The key is the **client name**: the package directory `//` and, camelCased, the export from `index.ts` (`my-token` → `myToken`). It is independent of the crate name. Scaffold also saves a contract alias of `-`, so in a project directory `my-app`, `stellar contract invoke --id my-app-my-token --network local -- …` works after a deploy. What Scaffold deployed is recorded in `.scaffold/state.json`, which isn't committed. | Key | Meaning | |---|---| From e7217d8d3ce42c139648edc7a347f33e6c8ed5b2 Mon Sep 17 00:00:00 2001 From: Zach Fedor Date: Fri, 16 Oct 2026 13:25:08 -0400 Subject: [PATCH 8/8] docs: note the one-time redeploy after upgrading Builds now find earlier deployments only through .scaffold/state.json, so the first build after upgrading deploys every contract again and the old contract-name aliases are left in the stellar-cli config. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QhmaWLv4yExX8LahP5UtUb --- docs/site/docs/configuration.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/site/docs/configuration.md b/docs/site/docs/configuration.md index 38b98a74..180edd82 100644 --- a/docs/site/docs/configuration.md +++ b/docs/site/docs/configuration.md @@ -293,7 +293,11 @@ after_deploy = "reset" id = "C..." ``` -`build` and `watch` still read this format, and `--network` is ignored for it. To move to version 2: +`build` and `watch` still read this format, and `--network` is ignored for it. + +Scaffold used to find earlier deployments by contract-name aliases such as `guess_the_number`. It now reads only `.scaffold/state.json`, so the first build after upgrading Scaffold deploys every contract again, whether or not you have moved to version 2. Later builds upgrade them as before. The old aliases stay in your stellar-cli config; remove them with `stellar contract alias remove guess_the_number`. + +To move to version 2: - Set `version: 2`. If your old `scaffold.yml` has a `config:` section, replace it with `project:`, renaming `contracts_dir`/`clients_dir` to `contracts-dir`/`clients-dir`. - Turn each environment into an entry under `networks:`. `run_locally` becomes `start-container`; an explicit `default = true` account becomes `default-account`.