use std::path::{Path, PathBuf};
use std::time::Duration;
use async_trait::async_trait;
use serde_json::{json, Value};
use tokio::process::Command;
use super::paths::resolve_in_workspace;
use super::Tool;
const CAATINGA_VERSION: &str = "@caatinga/cli@3.9.2";
const COMMAND_TIMEOUT: Duration = Duration::from_secs(600);
pub fn check_npx_available() -> Result<(), String> {
which::which("npx")
.map(|_| ())
.map_err(|_| "npx was not found on PATH. Is Node.js installed?".to_string())
}
pub async fn require_caatinga_project() -> Result<String, String> {
for candidate in ["caatinga.config.ts", "caatinga.config.js"] {
let path = resolve_in_workspace(candidate)?;
if tokio::fs::try_exists(&path).await.unwrap_or(false) {
return Ok(candidate.to_string());
}
}
Err(missing_config_message().await)
}
async fn missing_config_message() -> String {
let base = "This is not a Caatinga project: no caatinga.config.ts in the workspace. \
The caatinga_* tools act on contracts declared in that config, so there is \
nothing here for them to do.";
let Ok(root) = std::env::current_dir() else {
return format!("{} Run `npx @caatinga/cli init` to set one up.", base);
};
if let Some(child) = config_in_child(&root).await {
return format!(
"{} There is one at {}, in a subdirectory. Procyon's workspace is the directory it was \
launched from and it does not follow paths out of it, so relaunch Procyon there — or \
run the caatinga_* tools against a workspace that has the config at its root.",
base,
child.display()
);
}
if let Some(parent) = config_in_ancestor(&root).await {
return format!(
"{} There is one at {}, above the workspace. Procyon will not reach outside the \
directory it was launched from; relaunch it there.",
base,
parent.display()
);
}
format!(
"{} Run `npx @caatinga/cli init` to set one up, or use the stellar CLI directly for a \
one-off contract.",
base
)
}
const CONFIG_NAMES: [&str; 2] = ["caatinga.config.ts", "caatinga.config.js"];
async fn has_config(dir: &Path) -> bool {
for name in CONFIG_NAMES {
if tokio::fs::try_exists(dir.join(name)).await.unwrap_or(false) {
return true;
}
}
false
}
async fn config_in_child(root: &Path) -> Option<PathBuf> {
let mut entries = tokio::fs::read_dir(root).await.ok()?;
let mut found = Vec::new();
while let Ok(Some(entry)) = entries.next_entry().await {
let dir = entry.path();
if !dir.is_dir() || dir.file_name().is_some_and(|n| n == "node_modules") {
continue;
}
if has_config(&dir).await {
found.push(dir);
}
}
found.sort();
found.into_iter().next()
}
async fn config_in_ancestor(root: &Path) -> Option<PathBuf> {
let mut current = root.parent()?.to_path_buf();
loop {
if has_config(¤t).await {
return Some(current);
}
if !current.pop() {
return None;
}
}
}
pub async fn dependencies_installed() -> Option<bool> {
let root = resolve_in_workspace(".").ok()?;
if !tokio::fs::try_exists(root.join("package.json"))
.await
.unwrap_or(false)
{
return None;
}
Some(
tokio::fs::try_exists(root.join("node_modules"))
.await
.unwrap_or(false),
)
}
pub const DEPENDENCIES_BLOCKED: &str =
"This project's npm dependencies are not installed (no node_modules next to package.json), so \
the project's own Caatinga CLI, its build scripts and its type-check cannot run — and the \
committed bindings are still placeholders that throw PLACEHOLDER_BINDING at runtime. Run the \
`install_dependencies` tool (it needs approval, or --allow-changes on a headless run), then \
caatinga_build, caatinga_deploy and generate_bindings in that order.";
pub async fn placeholder_bindings() -> Vec<String> {
let Ok(root) = resolve_in_workspace("src/contracts/generated") else {
return Vec::new();
};
let mut found = Vec::new();
let Ok(mut entries) = tokio::fs::read_dir(&root).await else {
return Vec::new();
};
while let Ok(Some(entry)) = entries.next_entry().await {
let index = entry.path().join("src").join("index.ts");
if let Ok(body) = tokio::fs::read_to_string(&index).await {
if body.contains("__caatingaPlaceholder") {
found.push(entry.file_name().to_string_lossy().into_owned());
}
}
}
found.sort();
found
}
pub fn validate_source(source: &str) -> Result<(), String> {
let source = source.trim();
if source.is_empty() {
return Err("'source' is empty; give a Stellar CLI identity alias, e.g. alice".to_string());
}
if source.split_whitespace().count() > 1 {
return Err(
"'source' looks like a seed phrase. Pass a Stellar CLI identity alias instead \
(e.g. alice); Procyon never handles key material."
.to_string(),
);
}
let looks_like_strkey = source.len() >= 56
&& source
.chars()
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit());
if looks_like_strkey {
return Err(match source.as_bytes().first() {
Some(b'S') => "'source' is a secret key. Pass a Stellar CLI identity alias instead \
(e.g. alice) — a secret on a command line reaches the process list, \
the error text and the session log."
.to_string(),
_ => "'source' is a raw address. Caatinga signs through a Stellar CLI identity \
alias (e.g. alice), which is also what keeps the key out of Procyon."
.to_string(),
});
}
Ok(())
}
pub fn validate_target(target: &str) -> Result<(), String> {
let Some((contract, method)) = target.split_once('.') else {
return Err(format!(
"'{}' is not a valid target. Use <contract>.<method>, where the contract is a name \
from caatinga.config.ts — e.g. token.transfer. Caatinga resolves the contract id \
from its artifacts.",
target
));
};
if contract.is_empty() || method.is_empty() {
return Err(format!(
"'{}' is missing the contract or the method. Use <contract>.<method>, e.g. \
token.transfer.",
target
));
}
if super::is_contract_id(contract) {
return Err(format!(
"'{}' is a contract id, not a contract name. Use the name from \
caatinga.config.ts — Caatinga looks the id up in its artifacts, which is what keeps \
the two from drifting after a redeploy.",
contract
));
}
Ok(())
}
pub async fn run_caatinga(args: &[String]) -> Result<String, String> {
check_npx_available()?;
let mut argv = vec!["-y".to_string(), CAATINGA_VERSION.to_string()];
argv.extend_from_slice(args);
let subcommand = args.first().cloned().unwrap_or_default();
let child = Command::new("npx").args(&argv).output();
let output = match tokio::time::timeout(COMMAND_TIMEOUT, child).await {
Ok(Ok(output)) => output,
Ok(Err(e)) => return Err(format!("Failed to execute caatinga {}: {}", subcommand, e)),
Err(_) => {
return Err(format!(
"caatinga {} did not finish within {}s and was abandoned. It may still be \
running; check `npx {} status` before retrying.",
subcommand,
COMMAND_TIMEOUT.as_secs(),
CAATINGA_VERSION
))
}
};
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
if output.status.success() {
Ok(stdout)
} else {
Err(format!(
"caatinga {} failed (exit code: {})\n\nstdout:\n{}\n\nstderr:\n{}",
subcommand,
output.status.code().unwrap_or(-1),
stdout,
stderr
))
}
}
async fn deployed_contracts(network: &str) -> Vec<(String, String)> {
let Ok(path) = resolve_in_workspace("caatinga.artifacts.json") else {
return Vec::new();
};
let Ok(raw) = tokio::fs::read_to_string(&path).await else {
return Vec::new();
};
let Ok(artifacts) = serde_json::from_str::<Value>(&raw) else {
crate::diag::warn("caatinga.artifacts.json is not valid JSON; contract ids not reported");
return Vec::new();
};
let contracts = artifacts
.get("networks")
.and_then(|n| n.get(network))
.and_then(|n| n.get("contracts"))
.and_then(|c| c.as_object());
let Some(contracts) = contracts else {
return Vec::new();
};
contracts
.iter()
.filter_map(|(name, entry)| {
let id = entry.get("contractId").and_then(|v| v.as_str())?;
Some((name.clone(), id.to_string()))
})
.collect()
}
pub struct CaatingaBuildTool;
#[async_trait]
impl Tool for CaatingaBuildTool {
fn name(&self) -> &str {
"caatinga_build"
}
fn capability(&self) -> crate::risk::Capability {
crate::risk::Capability::Build
}
fn description(&self) -> &str {
"Build the Soroban contracts declared in caatinga.config.ts. Builds every contract unless \
one is named. Only works in a Caatinga project."
}
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"contract": {
"type": "string",
"description": "Name of a contract from caatinga.config.ts. Omit to build all."
}
},
"required": []
})
}
async fn execute(&self, input: Value) -> Result<String, String> {
require_caatinga_project().await?;
let mut args = vec!["build".to_string()];
if let Some(contract) = input.get("contract").and_then(|v| v.as_str()) {
args.push(contract.to_string());
}
let stdout = run_caatinga(&args).await?;
Ok(format!("Build successful.\n\n{}", stdout))
}
}
pub struct CaatingaDeployTool;
#[async_trait]
impl Tool for CaatingaDeployTool {
fn name(&self) -> &str {
"caatinga_deploy"
}
fn capability(&self) -> crate::risk::Capability {
crate::risk::Capability::Signing
}
fn description(&self) -> &str {
"Deploy Soroban contracts declared in caatinga.config.ts. Deploys every contract in \
dependency order unless one is named, and afterwards Caatinga regenerates bindings, runs \
wiring hooks and syncs frontend env by itself. Use dry_run to estimate cost without \
submitting. Only works in a Caatinga project."
}
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"contract": {
"type": "string",
"description": "Name of a contract from caatinga.config.ts. Omit to deploy all, in dependency order."
},
"network": {
"type": "string",
"description": "Network name as configured in caatinga.config.ts (e.g. testnet). Required: this signs and submits, so it must not rely on a default. Mainnet is refused unless the operator enabled it."
},
"source": {
"type": "string",
"description": "Stellar CLI identity alias that signs, e.g. 'alice'. Never a secret key, seed phrase or raw address."
},
"dry_run": {
"type": "boolean",
"description": "Estimate the deploy cost without submitting anything"
},
"if_changed": {
"type": "boolean",
"description": "Skip contracts whose local WASM already matches the artifacts"
}
},
"required": ["network"]
})
}
async fn execute(&self, input: Value) -> Result<String, String> {
require_caatinga_project().await?;
crate::verify::session().guard_deploy()?;
let mut args = vec!["deploy".to_string()];
if let Some(contract) = input.get("contract").and_then(|v| v.as_str()) {
args.push(contract.to_string());
}
let network =
super::mainnet::resolve_signing_network(input.get("network").and_then(|v| v.as_str()))?;
args.push("--network".to_string());
args.push(network.clone());
if let Some(source) = input.get("source").and_then(|v| v.as_str()) {
validate_source(source)?;
args.push("--source".to_string());
args.push(source.to_string());
}
if input.get("dry_run").and_then(|v| v.as_bool()) == Some(true) {
args.push("--dry-run".to_string());
}
if input.get("if_changed").and_then(|v| v.as_bool()) == Some(true) {
args.push("--if-changed".to_string());
}
let stdout = run_caatinga(&args).await?;
let mut result = "Deploy successful.\n".to_string();
let deployed = deployed_contracts(&network).await;
if !deployed.is_empty() {
result.push_str(&format!(
"\nRecorded in caatinga.artifacts.json for {}:\n",
network
));
for (name, id) in deployed {
result.push_str(&format!(" {} = {}\n", name, id));
}
}
result.push_str(&format!("\n{}", stdout));
Ok(result)
}
}
pub struct CaatingaDoctorTool;
#[async_trait]
impl Tool for CaatingaDoctorTool {
fn name(&self) -> &str {
"caatinga_doctor"
}
fn capability(&self) -> crate::risk::Capability {
crate::risk::Capability::ReadOnly
}
fn description(&self) -> &str {
"Check the local Caatinga setup: CLI, Stellar CLI, Rust, config, network reachability and \
signing identity. Run this first when a build, deploy or invoke fails for a reason that \
is not in the contract — most such failures are environment drift, and this names them \
instead of guessing. Only works in a Caatinga project."
}
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"network": {
"type": "string",
"description": "Network name from caatinga.config.ts to validate"
},
"source": {
"type": "string",
"description": "Stellar CLI identity alias to validate, e.g. 'alice'. Never a secret key, seed phrase or raw address."
},
"all_networks": {
"type": "boolean",
"description": "Report deploy and bindings coverage for every configured network"
},
"strict": {
"type": "boolean",
"description": "Also fail when the frontend env file drifts from the artifacts, or bindings are stale"
}
},
"required": []
})
}
async fn execute(&self, input: Value) -> Result<String, String> {
require_caatinga_project().await?;
let mut args = vec!["doctor".to_string()];
if let Some(network) = input.get("network").and_then(|v| v.as_str()) {
args.push("--network".to_string());
args.push(network.to_string());
}
if let Some(source) = input.get("source").and_then(|v| v.as_str()) {
validate_source(source)?;
args.push("--source".to_string());
args.push(source.to_string());
}
if input.get("all_networks").and_then(|v| v.as_bool()) == Some(true) {
args.push("--all-networks".to_string());
}
if input.get("strict").and_then(|v| v.as_bool()) == Some(true) {
args.push("--strict".to_string());
}
let mut out = match run_caatinga(&args).await {
Ok(stdout) => format!("Setup looks healthy.\n\n{}", stdout),
Err(report) => format!(
"Doctor reported problems. This is the diagnosis, not a tool failure:\n\n{}",
report
),
};
if dependencies_installed().await == Some(false) {
out.push_str("\n\n");
out.push_str(DEPENDENCIES_BLOCKED);
} else {
let stale = placeholder_bindings().await;
if !stale.is_empty() {
out.push_str(&format!(
"\n\nStill on placeholder bindings: {}. Every method in these throws \
PLACEHOLDER_BINDING at runtime — the frontend will fail in the browser, not \
here. Run generate_bindings once the contracts are deployed.",
stale.join(", ")
));
}
}
Ok(out)
}
}
pub struct InstallDependenciesTool;
#[async_trait]
impl Tool for InstallDependenciesTool {
fn name(&self) -> &str {
"install_dependencies"
}
fn capability(&self) -> crate::risk::Capability {
crate::risk::Capability::Write
}
fn description(&self) -> &str {
"Install the workspace's npm dependencies (npm install, or pnpm install where the project \
uses pnpm). Run this when caatinga_doctor reports that dependencies are not installed: \
until they are, the project's own Caatinga CLI, its build scripts and its type-check \
cannot run."
}
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {},
"required": []
})
}
async fn execute(&self, _input: Value) -> Result<String, String> {
let root = resolve_in_workspace(".")?;
if !tokio::fs::try_exists(root.join("package.json"))
.await
.unwrap_or(false)
{
return Err(
"There is no package.json in the workspace, so there are no npm dependencies to \
install. A Rust-only contract workspace needs cargo, not npm."
.to_string(),
);
}
let pnpm = tokio::fs::try_exists(root.join("pnpm-lock.yaml"))
.await
.unwrap_or(false)
|| tokio::fs::try_exists(root.join("pnpm-workspace.yaml"))
.await
.unwrap_or(false);
let manager = if pnpm { "pnpm" } else { "npm" };
which::which(manager).map_err(|_| {
format!(
"This project is a {0} workspace, but {0} was not found on PATH.",
manager
)
})?;
let run = Command::new(manager)
.arg("install")
.current_dir(&root)
.output();
let output = match tokio::time::timeout(COMMAND_TIMEOUT, run).await {
Ok(Ok(output)) => output,
Ok(Err(e)) => return Err(format!("Failed to execute {} install: {}", manager, e)),
Err(_) => {
return Err(format!(
"{} install did not finish within {}s and was abandoned.",
manager,
COMMAND_TIMEOUT.as_secs()
))
}
};
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
if !output.status.success() {
return Err(format!(
"{} install failed (exit code: {})\n\nstdout:\n{}\n\nstderr:\n{}",
manager,
output.status.code().unwrap_or(-1),
stdout,
stderr
));
}
Ok(format!(
"Dependencies installed with {}. The project's CLI and scripts can run now; the \
bindings are still whatever is committed until generate_bindings replaces them.\n\n{}",
manager, stdout
))
}
}
#[cfg(test)]
mod tests {
use super::*;
const SECRET: &str = "SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEXAMPLE";
const ADDRESS: &str = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEXAMPLE";
const ID: &str = "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC";
#[test]
fn an_identity_alias_is_accepted() {
for alias in ["alice", "bob", "deployer-ci", "s", "Steve"] {
assert!(validate_source(alias).is_ok(), "rejected {}", alias);
}
}
#[test]
fn a_secret_key_is_refused_and_the_message_says_why() {
let err = validate_source(SECRET).expect_err("a secret key must be refused");
assert!(err.contains("secret key"), "{}", err);
assert!(err.contains("alias"), "{}", err);
assert!(
!err.contains(SECRET),
"the error must not echo the secret: {}",
err
);
}
#[test]
fn a_raw_address_is_refused() {
let err = validate_source(ADDRESS).expect_err("an address is not an alias");
assert!(err.contains("alias"), "{}", err);
}
#[test]
fn a_seed_phrase_is_refused() {
let err = validate_source("abandon abandon abandon about")
.expect_err("a seed phrase must be refused");
assert!(err.contains("seed phrase"), "{}", err);
}
#[test]
fn an_empty_source_is_refused() {
assert!(validate_source(" ").is_err());
}
#[test]
fn installing_dependencies_asks_before_it_runs() {
assert_eq!(
InstallDependenciesTool.capability(),
crate::risk::Capability::Write
);
}
#[tokio::test]
async fn installing_dependencies_refuses_a_workspace_with_no_package_json() {
let err = InstallDependenciesTool
.execute(json!({}))
.await
.expect_err("a Rust-only workspace has nothing to install");
assert!(err.contains("no package.json"), "{}", err);
assert!(err.contains("cargo"), "{}", err);
}
#[tokio::test]
async fn a_workspace_with_no_package_json_is_not_reported_as_blocked() {
assert_eq!(dependencies_installed().await, None);
}
#[tokio::test]
async fn a_config_in_a_subdirectory_is_named_in_the_refusal() {
let temp = tempfile::tempdir().unwrap();
let nested = temp.path().join("my-app");
tokio::fs::create_dir(&nested).await.unwrap();
tokio::fs::write(nested.join("caatinga.config.ts"), "export default {}")
.await
.unwrap();
let msg = super::config_in_child(temp.path()).await;
assert_eq!(msg.as_deref(), Some(nested.as_path()));
}
#[tokio::test]
async fn node_modules_is_not_offered_as_the_project() {
let temp = tempfile::tempdir().unwrap();
let inside = temp.path().join("node_modules").join("@caatinga");
tokio::fs::create_dir_all(&inside).await.unwrap();
tokio::fs::write(
temp.path().join("node_modules").join("caatinga.config.ts"),
"export default {}",
)
.await
.unwrap();
assert!(super::config_in_child(temp.path()).await.is_none());
}
#[tokio::test]
async fn a_config_above_the_workspace_is_named_too() {
let temp = tempfile::tempdir().unwrap();
let inner = temp.path().join("inner");
tokio::fs::create_dir(&inner).await.unwrap();
tokio::fs::write(temp.path().join("caatinga.config.ts"), "export default {}")
.await
.unwrap();
let found = super::config_in_ancestor(&inner).await.unwrap();
assert_eq!(
found.canonicalize().unwrap(),
temp.path().canonicalize().unwrap()
);
}
#[tokio::test]
async fn the_tools_refuse_a_project_without_a_caatinga_config() {
let err = CaatingaDeployTool
.execute(json!({"network": "testnet"}))
.await
.expect_err("deploy must refuse a non-Caatinga project");
assert!(err.contains("not a Caatinga project"), "{}", err);
let err = CaatingaBuildTool
.execute(json!({}))
.await
.expect_err("build must refuse a non-Caatinga project");
assert!(err.contains("not a Caatinga project"), "{}", err);
}
#[tokio::test]
async fn deploy_refuses_a_secret_before_spawning_anything() {
let err = CaatingaDeployTool
.execute(json!({"source": SECRET}))
.await
.expect_err("deploy must refuse a secret key");
assert!(
err.contains("secret key") || err.contains("not a Caatinga project"),
"got {}",
err
);
}
#[tokio::test]
async fn no_contract_ids_are_reported_when_there_are_no_artifacts() {
assert!(deployed_contracts("testnet").await.is_empty());
}
#[test]
fn a_contract_and_method_is_a_valid_target() {
for target in ["token.transfer", "my-contract.balance_of", "a.b"] {
assert!(validate_target(target).is_ok(), "rejected {}", target);
}
}
#[test]
fn a_contract_id_as_the_target_is_refused_by_name() {
let target = format!("{}.transfer", ID);
let err = validate_target(&target).expect_err("an id is not a contract name");
assert!(err.contains("contract id"), "{}", err);
assert!(err.contains("artifacts"), "{}", err);
}
#[test]
fn a_target_without_a_method_is_refused() {
for target in ["token", "token.", ".transfer", ""] {
assert!(
validate_target(target).is_err(),
"accepted an incomplete target: {:?}",
target
);
}
}
#[tokio::test]
async fn doctor_refuses_a_project_without_a_caatinga_config() {
let err = CaatingaDoctorTool
.execute(json!({}))
.await
.expect_err("doctor must refuse a non-Caatinga project");
assert!(err.contains("not a Caatinga project"), "{}", err);
}
#[test]
fn deploy_requires_its_network() {
let schema = CaatingaDeployTool.input_schema();
let required = schema["required"].as_array().unwrap();
assert!(
required.contains(&json!("network")),
"a deploy must state its network: {}",
schema
);
}
#[tokio::test]
async fn doctor_refuses_a_secret_source() {
let err = CaatingaDoctorTool
.execute(json!({"source": SECRET}))
.await
.expect_err("doctor must refuse a secret key");
assert!(
err.contains("secret key") || err.contains("not a Caatinga project"),
"got {}",
err
);
}
}