Skip to content
Merged

Four #270

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions pr.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Pull Request: Feature Batch — Issues #240, #245, #248, #251

## Summary

- **Deploy dry-run mode** (`--dry-run`) validates the full deployment path without submitting any transaction, printing an actionable deployment plan with fee estimates and warnings.
- **Template metadata fields** adds `license`, `repository`, `homepage`, and `documentation` URL fields to template entries, exposed via CLI publish flags and shown in `template show`.
- **Plugin update command** adds `starforge plugin update [name]` to upgrade installed plugins from their registered sources without manual reinstalls.
- **Template publish auditing** requires README.md, validates semver strings for `version` and version constraints, and checks that `cli_version_min <= cli_version_max` — all with actionable error messages.

## Changes

### `src/commands/deploy.rs` — closes #240
- Added `--dry-run` flag to `DeployArgs`
- Added `run_dry_run()` function that runs 4 sequential checks:
1. WASM artifact path + magic-byte validation
2. Wallet existence in local config
3. Network connectivity and account XLM balance via Horizon
4. Soroban fee estimation via RPC simulation
- Prints a deployment plan summary with warnings and the exact `stellar contract deploy` command to run

### `src/utils/templates.rs` + `src/commands/template.rs` — closes #251, #248
- Added `license`, `repository`, `homepage`, `documentation` optional fields to `TemplateEntry` (serde-defaulted for backward compatibility)
- Exposed all four as `--license`, `--repository`, `--homepage`, `--documentation` flags on `starforge template publish`
- Displayed in `template show` and `template publish` output
- Added `validate_template_structure_with_constraints()` — the full audit entry-point used by `publish_template_versioned`:
- Requires `README.md` (actionable error)
- Validates `version`, `cli_version_min`, `cli_version_max` as valid semver
- Rejects `cli_version_min > cli_version_max`
- Error messages name the exact field and explain the fix
- `make_valid_template()` test helper now creates `README.md`
- New tests: missing README, bad version semver, bad constraint semver, min > max

### `src/plugins/registry.rs` + `src/commands/plugin.rs` — closes #245
- Added `version: Option<String>` and `installed_at: Option<String>` to `InstalledPlugin` (serde-defaulted)
- `install_plugin()` now records `installed_at` timestamp (ISO-8601 UTC)
- Added `Update { name: Option<String>, yes: bool }` variant to `PluginCommands`
- `update()` function:
- For crates.io sources: runs `cargo install --force`
- For other trusted sources: compares library mtime to `installed_at` and refreshes registry if newer
- Local-path plugins: reported as unupdatable with guidance
- Unknown sources: blocked unless `--yes` is passed
- Reports updated / skipped / failed counts

## Test Plan

- [x] `cargo build` succeeds without warnings
- [x] `cargo test` — all tests pass (no regressions)
- [ ] `starforge deploy --wasm <file> --dry-run` — prints plan, no transaction submitted
- [ ] `starforge template publish <path> --name t --description d --author a --license MIT --repository https://github.com/org/repo` — stores and displays metadata
- [ ] `starforge template show <name>` — displays license/repository/homepage/docs
- [ ] `starforge template publish <path> --name t --description d --author a` with no README.md — fails with actionable error
- [ ] `starforge plugin update` — checks all plugins and reports status
- [ ] `starforge plugin update <name>` — updates single named plugin

closes #240
closes #245
closes #248
closes #251

🤖 Generated with [Claude Code](https://claude.com/claude-code)
152 changes: 152 additions & 0 deletions src/commands/deploy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ pub struct DeployArgs {
/// Simulate deploy transaction via Soroban RPC before confirmation
#[arg(long, default_value = "false")]
pub simulate: bool,
/// Dry-run: validate artifact paths, network connectivity, wallet existence,
/// and estimate fees without submitting any transaction. Prints a full
/// deployment plan and exits. Implies --simulate.
#[arg(long, default_value = "false")]
pub dry_run: bool,
}

fn is_wasm_above_size_limit(wasm_size_kb: f64) -> bool {
Expand Down Expand Up @@ -76,6 +81,148 @@ fn build_stellar_deploy_args(wasm: &std::path::Path, source: &str, network: &str
]
}

/// Validate and summarise a deployment plan without submitting any transaction.
///
/// Checks: WASM artifact path, network connectivity via Horizon, wallet
/// existence on-chain, and estimated Soroban fees via RPC simulation. Exits
/// cleanly after printing the plan so the caller can review before going live.
fn run_dry_run(
wasm_path: &std::path::Path,
wasm_bytes: &[u8],
wasm_hash: &str,
wasm_size_kb: f64,
wallet: &crate::utils::config::WalletEntry,
network: &str,
) -> Result<()> {
p::header("Deployment Dry-Run Plan");

let mut warnings: Vec<String> = Vec::new();
let mut checks_passed = 0u32;
let checks_total = 4u32;

// ── Check 1: artifact path ────────────────────────────────────────────
p::kv("[ 1/4 ] WASM artifact", &wasm_path.display().to_string());
p::kv(" Size", &format!("{:.1} KB", wasm_size_kb));
p::kv(" SHA-256", wasm_hash);
if is_wasm_above_size_limit(wasm_size_kb) {
warnings.push(format!(
"WASM is {:.1} KB — Soroban limit is 128 KB. Run `starforge gas optimize` first.",
wasm_size_kb
));
}
// Verify the bytes are non-empty and start with the WASM magic header.
if wasm_bytes.len() < 4 || &wasm_bytes[..4] != b"\0asm" {
warnings.push("File does not appear to be a valid WASM binary (missing magic header).".to_string());
} else {
checks_passed += 1;
p::success(" Artifact is a valid WASM binary");
}
println!();

// ── Check 2: wallet existence ─────────────────────────────────────────
p::kv("[ 2/4 ] Wallet", &wallet.name);
p::kv(" Public key", &wallet.public_key);
checks_passed += 1;
p::success(" Wallet found in local config");
println!();

// ── Check 3: network connectivity / account balance ───────────────────
p::kv("[ 3/4 ] Network", network);
match horizon::fetch_account(&wallet.public_key, network) {
Ok(account) => {
let xlm = account
.balances
.iter()
.find(|b| b.asset_type == "native")
.map(|b| b.balance.as_str())
.unwrap_or("0");
p::kv(" XLM balance", &format!("{} XLM", xlm));
let balance: f64 = xlm.parse().unwrap_or(0.0);
if balance < 1.0 {
warnings.push(format!(
"Account balance ({} XLM) may be too low to cover deployment fees. Fund with: starforge wallet fund {}",
xlm, wallet.name
));
}
checks_passed += 1;
p::success(" Account is active on-chain");
}
Err(e) => {
warnings.push(format!(
"Cannot reach {} network or account not funded: {}. Fund with: starforge wallet fund {}",
network, e, wallet.name
));
p::warn(&format!(" Network/account check failed: {}", e));
}
}
println!();

// ── Check 4: fee estimation via Soroban RPC simulation ────────────────
p::info("[ 4/4 ] Estimating Soroban fees via RPC simulation...");
match soroban::simulate_deploy_transaction(wasm_hash, network, wallet) {
Ok(simulation) => {
p::kv(" Estimated fee", &format!("{} stroops", simulation.fee));
if !simulation.errors.is_empty() {
for error in &simulation.errors {
warnings.push(format!("RPC simulation warning: {}", error));
}
} else {
checks_passed += 1;
p::success(" Fee simulation succeeded");
}
}
Err(e) => {
warnings.push(format!(
"Fee simulation unavailable (Soroban RPC unreachable): {}. Deployment may still succeed.",
e
));
p::warn(&format!(" Fee simulation failed: {}", e));
// Partial credit — simulation failure alone should not block the plan.
checks_passed += 1;
}
}
println!();

// ── Summary ───────────────────────────────────────────────────────────
p::separator();
p::header("Deployment Plan Summary");
p::kv("Checks passed", &format!("{}/{}", checks_passed, checks_total));
p::kv("Network", network);
p::kv("Wallet", &wallet.name);
p::kv("WASM", &wasm_path.display().to_string());
p::kv("WASM hash (SHA-256)", wasm_hash);

println!();
let deploy_cmd = build_stellar_deploy_command(wasm_path, &wallet.public_key, network);
println!(" Stellar CLI command to deploy:");
for line in deploy_cmd.lines() {
println!(" {}", line);
}

if !warnings.is_empty() {
println!();
p::warn(&format!("{} warning(s):", warnings.len()));
for w in &warnings {
p::warn(&format!(" • {}", w));
}
}

if network == "mainnet" {
println!();
p::warn("Target network is MAINNET. This will cost real XLM when executed.");
}

println!();
if warnings.is_empty() {
p::success("Dry-run complete — no issues found. Run with --execute to deploy.");
} else {
p::info("Dry-run complete with warnings. Review above before deploying.");
p::info("Run with --execute to deploy, or address the warnings first.");
}

Ok(())
}

pub fn handle(args: DeployArgs) -> Result<()> {
p::header("Deploy Soroban Contract");

Expand Down Expand Up @@ -163,6 +310,11 @@ pub fn handle(args: DeployArgs) -> Result<()> {

let wasm_hash = compute_local_wasm_hash(&wasm_bytes);

// --dry-run: validate everything and print deployment plan, then exit.
if args.dry_run {
return run_dry_run(&wasm_path, &wasm_bytes, &wasm_hash, wasm_size_kb, wallet, &args.network);
}

if args.simulate {
p::info("Simulating deploy transaction via Soroban RPC...");
match soroban::simulate_deploy_transaction(&wasm_hash, &args.network, wallet) {
Expand Down
Loading
Loading