pub mod api;
pub mod automations;
pub mod capture;
pub mod custom_stacks;
pub mod deploy;
pub mod env;
pub mod proxy;
pub mod runtime;
pub mod sdk_gen;
pub mod squads;
pub mod tools;
pub mod ui;
pub mod up;
pub mod watch;
use std::path::PathBuf;
use anyhow::{bail, Context, Result};
use arete_mcp::dev::{
parse_replay, DevClient, DevProject, ReplayOptions, StepProgress, StepRequest,
};
use clap::{Args, Subcommand};
use colored::Colorize;
use serde_json::{json, Value};
use crate::api_client::ApiClient;
use env::EnvArgs;
#[derive(Subcommand, Debug)]
pub enum DevCommands {
#[command(name = "__admit-local-programs", hide = true)]
AdmitLocalPrograms,
Up(UpArgs),
Down(DownArgs),
Status(EnvArgs),
List,
Use(UseArgs),
Wake(EnvArgs),
Sleep(EnvArgs),
Delete(EnvArgs),
Checkpoint(CheckpointArgs),
Branch(BranchArgs),
Restore(RestoreArgs),
Rebase(RebaseArgs),
Reset(ResetArgs),
Fund(FundArgs),
Pin(PinArgs),
Follow(FollowArgs),
#[command(alias = "time-travel")]
Warp(WarpArgs),
Pause(EnvArgs),
Resume(EnvArgs),
Scenario(ScenarioArgs),
Preview(PreviewArgs),
Repro(ReproArgs),
Events(EventsArgs),
Token(EnvArgs),
Step(StepArgs),
Wallet(WalletArgs),
Instance(InstanceArgs),
Replay(ReplayArgs),
Capture(capture::CaptureArgs),
Sdk(SdkArgs),
Consent(ConsentArgs),
#[command(subcommand)]
Squads(squads::DevSquadsCommands),
}
#[derive(Args, Debug, Clone)]
pub struct ConsentArgs {
#[command(flatten)]
pub project: ProjectArgs,
pub wallet: String,
#[arg(long, value_delimiter = ',', required_unless_present = "revoke")]
pub steps: Vec<String>,
#[arg(long = "limit", value_name = "ASSET=AMOUNT")]
pub limits: Vec<String>,
#[arg(long = "window", value_name = "ASSET=AMOUNT/WINDOW")]
pub windows: Vec<String>,
#[arg(long = "param", value_name = "KEY=VALUE")]
pub params: Vec<String>,
#[arg(long)]
pub instance: Option<String>,
#[arg(long, default_value_t = 30)]
pub days: u64,
#[arg(long)]
pub revoke: bool,
}
#[derive(Args, Debug, Clone, Default)]
pub struct ProjectArgs {
#[arg(long, value_name = "DIR")]
pub project: Option<PathBuf>,
}
#[derive(Args, Debug, Clone)]
pub struct UpArgs {
#[command(flatten)]
pub project: ProjectArgs,
#[arg(long = "env", env = "ARETE_DEV_ENV", value_name = "NAME|ID")]
pub env: Option<String>,
#[arg(long, value_name = "NAME")]
pub template: Option<String>,
#[arg(long, value_name = "FILE")]
pub fee_policy: Option<PathBuf>,
#[arg(long = "resource", value_name = "NAME=FILE")]
pub resources: Vec<String>,
#[arg(long, default_value_t = 48_700, value_name = "N")]
pub port_base: u16,
#[arg(long, visible_alias = "no-watch")]
pub once: bool,
#[arg(long)]
pub no_deploy: bool,
#[arg(long)]
pub no_sdk: bool,
#[arg(long, conflicts_with = "no_ui")]
pub open: bool,
#[arg(long)]
pub show_token: bool,
#[arg(long)]
pub no_ui: bool,
#[arg(long, value_name = "URL", hide = true)]
pub ui_dev_url: Option<String>,
}
#[derive(Args, Debug, Clone)]
pub struct DownArgs {
#[command(flatten)]
pub project: ProjectArgs,
#[arg(long)]
pub sleep: bool,
#[arg(long)]
pub purge: bool,
}
#[derive(Args, Debug, Clone)]
pub struct UseArgs {
#[command(flatten)]
pub project: ProjectArgs,
pub env: String,
}
#[derive(Args, Debug, Clone)]
pub struct CheckpointArgs {
#[command(flatten)]
pub env: EnvArgs,
#[arg(conflicts_with_all = ["list", "delete"])]
pub name: Option<String>,
#[arg(long, conflicts_with = "delete")]
pub list: bool,
#[arg(long, value_name = "CHECKPOINT")]
pub delete: Option<String>,
}
#[derive(Args, Debug, Clone)]
pub struct BranchArgs {
#[command(flatten)]
pub env: EnvArgs,
pub checkpoint: String,
pub name: String,
#[arg(long = "use")]
pub switch: bool,
}
#[derive(Args, Debug, Clone)]
pub struct RestoreArgs {
#[command(flatten)]
pub env: EnvArgs,
pub checkpoint: String,
}
#[derive(Args, Debug, Clone)]
pub struct RebaseArgs {
#[command(flatten)]
pub env: EnvArgs,
#[arg(long, value_name = "PUBKEY")]
pub keep: Vec<String>,
}
#[derive(Args, Debug, Clone)]
pub struct ResetArgs {
#[command(flatten)]
pub env: EnvArgs,
#[arg(long, value_name = "PUBKEY")]
pub account: Option<String>,
#[arg(long, requires = "account")]
pub include_owned: bool,
}
#[derive(Args, Debug, Clone)]
pub struct FundArgs {
#[command(flatten)]
pub env: EnvArgs,
pub account: String,
#[arg(conflicts_with = "mint")]
pub sol: Option<String>,
#[arg(long, value_name = "MINT", requires = "amount")]
pub mint: Option<String>,
#[arg(long, value_name = "BASE_UNITS")]
pub amount: Option<String>,
#[arg(long, value_name = "PROGRAM", requires = "mint")]
pub token_program: Option<String>,
}
#[derive(Args, Debug, Clone)]
pub struct PinArgs {
#[command(flatten)]
pub env: EnvArgs,
pub account: String,
#[arg(long, conflicts_with = "unpin")]
pub include_owned: bool,
#[arg(long)]
pub unpin: bool,
}
#[derive(Args, Debug, Clone)]
pub struct FollowArgs {
#[command(flatten)]
pub env: EnvArgs,
pub account: String,
#[arg(long)]
pub stop: bool,
}
#[derive(Args, Debug, Clone)]
pub struct WarpArgs {
#[command(flatten)]
pub env: EnvArgs,
#[arg(allow_hyphen_values = true)]
pub to: String,
}
#[derive(Args, Debug, Clone)]
pub struct ScenarioArgs {
#[command(flatten)]
pub env: EnvArgs,
pub file: String,
#[arg(long)]
pub dry_run: bool,
}
#[derive(Args, Debug, Clone)]
pub struct PreviewArgs {
#[command(flatten)]
pub env: EnvArgs,
pub transaction: String,
}
#[derive(Args, Debug, Clone)]
pub struct ReproArgs {
#[command(flatten)]
pub env: EnvArgs,
pub signature: String,
#[arg(short = 'o', long, value_name = "FILE")]
pub output: Option<PathBuf>,
}
#[derive(Args, Debug, Clone)]
pub struct EventsArgs {
#[command(flatten)]
pub env: EnvArgs,
#[arg(long)]
pub after: Option<i64>,
#[arg(long)]
pub limit: Option<u32>,
#[arg(long, short = 'f')]
pub follow: bool,
}
#[derive(Args, Debug, Clone)]
pub struct StepArgs {
#[command(flatten)]
pub project: ProjectArgs,
pub step: String,
#[arg(long)]
pub instance: Option<String>,
#[arg(long, default_value = "{}", value_name = "JSON")]
pub args: String,
#[arg(long, default_value = "default")]
pub wallet: String,
}
#[derive(Args, Debug, Clone)]
pub struct WalletArgs {
#[command(flatten)]
pub project: ProjectArgs,
#[arg(default_value = "default")]
pub name: String,
}
#[derive(Args, Debug, Clone)]
pub struct InstanceArgs {
#[command(flatten)]
pub project: ProjectArgs,
pub id: String,
#[arg(long)]
pub wallet: Option<String>,
}
#[derive(Args, Debug, Clone)]
pub struct SdkArgs {
#[command(flatten)]
pub project: ProjectArgs,
#[arg(long)]
pub ts: bool,
#[arg(long)]
pub rust: bool,
#[arg(long)]
pub python: bool,
}
#[derive(Args, Debug, Clone)]
pub struct ReplayArgs {
#[command(flatten)]
pub project: ProjectArgs,
pub file: PathBuf,
#[arg(long)]
pub include_failed: bool,
}
pub fn run(command: DevCommands, json: bool) -> Result<()> {
match command {
DevCommands::AdmitLocalPrograms => up::admission_worker(),
DevCommands::Up(args) => up(args, json),
DevCommands::Down(args) => down(args, json),
DevCommands::Status(args) => block_on(status(args, json)),
DevCommands::List => env::list(json),
DevCommands::Use(args) => env::use_env(args.project, args.env, json),
DevCommands::Wake(args) => env::wake(args, json),
DevCommands::Sleep(args) => env::sleep(args, json),
DevCommands::Delete(args) => env::delete(args, json),
DevCommands::Checkpoint(args) => {
env::checkpoint(args.env, args.name, args.list, args.delete, json)
}
DevCommands::Branch(args) => {
env::branch(args.env, args.checkpoint, args.name, args.switch, json)
}
DevCommands::Restore(args) => env::restore(args.env, args.checkpoint, json),
DevCommands::Rebase(args) => env::rebase(args.env, args.keep, json),
DevCommands::Reset(args) => env::reset(args.env, args.account, args.include_owned, json),
DevCommands::Fund(args) => env::fund(
args.env,
args.account,
args.sol,
args.mint,
args.amount,
args.token_program,
json,
),
DevCommands::Pin(args) => {
env::pin(args.env, args.account, args.include_owned, args.unpin, json)
}
DevCommands::Follow(args) => env::follow(args.env, args.account, args.stop, json),
DevCommands::Warp(args) => env::warp(args.env, args.to, json),
DevCommands::Pause(args) => env::clock(args, true, json),
DevCommands::Resume(args) => env::clock(args, false, json),
DevCommands::Scenario(args) => env::scenario(args.env, args.file, args.dry_run, json),
DevCommands::Preview(args) => env::preview(args.env, args.transaction, json),
DevCommands::Repro(args) => env::repro(args.env, args.signature, args.output, json),
DevCommands::Events(args) => {
env::events(args.env, args.after, args.limit, args.follow, json)
}
DevCommands::Token(args) => env::token(args, json),
DevCommands::Step(args) => block_on(async move {
let client = client(&args.project)?;
let step_args: Value =
serde_json::from_str(&args.args).context("--args must be a JSON object")?;
if !step_args.is_object() {
bail!("--args must be a JSON object");
}
let request = StepRequest {
step: args.step,
instance: args.instance,
args: step_args,
wallet: args.wallet,
result: None,
};
let work = client.step_with_progress(&request, |phase| {
eprintln!("{}", step_progress_message(phase));
});
let result =
finish_step(work, json, &mut std::io::stdout(), &mut std::io::stderr()).await?;
if result["state"] != "completed" {
std::process::exit(1);
}
Ok(())
}),
DevCommands::Wallet(args) => block_on(async move {
let client = client(&args.project)?;
let wallet = client.wallet(&args.name).await.map_err(env::explain)?;
let balance = client
.rpc(
"getBalance",
json!([wallet.pubkey(), { "commitment": "processed" }]),
)
.await?
.get("value")
.cloned()
.unwrap_or(Value::Null);
let result = json!({
"name": args.name,
"pubkey": wallet.pubkey(),
"lamports": balance,
"keypair": client.project.wallets_dir().join(format!("{}.json", args.name)).display().to_string(),
});
print(json, &result, || {
println!(
"{} {} ({} lamports)",
args.name.bold(),
result["pubkey"].as_str().unwrap_or_default(),
balance
)
});
Ok(())
}),
DevCommands::Instance(args) => block_on(async move {
let result = client(&args.project)?
.instance(&args.id, args.wallet.as_deref())
.await?;
print(json, &result, || {
println!(
"{}",
serde_json::to_string_pretty(&result).unwrap_or_default()
)
});
Ok(())
}),
DevCommands::Capture(args) => capture::capture(args, json),
DevCommands::Sdk(args) => sdk(args, json),
DevCommands::Consent(args) => consent(args, json),
DevCommands::Squads(command) => block_on(squads::run(command, json)),
DevCommands::Replay(args) => block_on(async move {
let text = std::fs::read_to_string(&args.file)
.with_context(|| format!("Failed to read {}", args.file.display()))?;
let steps = parse_replay(&text)?;
if steps.is_empty() {
bail!("{} has no steps", args.file.display());
}
let options = ReplayOptions {
include_failed: args.include_failed,
};
let result = client(&args.project)?.replay(&steps, options).await?;
print(json, &result, || {
for skipped in result["skipped"].as_array().into_iter().flatten() {
println!(
"{} {} (entry {}, recorded {}; --include-failed runs it)",
"skip".dimmed(),
skipped["step"].as_str().unwrap_or_default(),
skipped["entry"],
skipped["recordedState"].as_str().unwrap_or("failed")
);
}
for step in result["results"].as_array().into_iter().flatten() {
print_step(step);
}
});
if result["completed"] != true {
std::process::exit(1);
}
Ok(())
}),
}
}
fn block_on<T, F: std::future::Future<Output = Result<T>>>(future: F) -> Result<T> {
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.context("Failed to start the async runtime")?
.block_on(future)
}
fn print(json: bool, value: &Value, human: impl FnOnce()) {
if json {
println!("{value}");
} else {
human();
}
}
async fn finish_step(
work: impl std::future::Future<Output = Result<Value>>,
json: bool,
stdout: &mut impl std::io::Write,
stderr: &mut impl std::io::Write,
) -> Result<Value> {
let started = tokio::time::Instant::now();
let period = std::time::Duration::from_secs(5);
let mut heartbeat = tokio::time::interval_at(started + period, period);
heartbeat.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
tokio::pin!(work);
let result = loop {
tokio::select! {
biased;
result = &mut work => break result.map_err(env::explain)?,
_ = heartbeat.tick() => writeln!(
stderr,
"Still waiting ({}s). Do not resubmit while this execution is pending.",
started.elapsed().as_secs()
)?,
}
};
if let Some(message) = step_outcome_message(&result) {
writeln!(stderr, "{message}")?;
}
if json {
serde_json::to_writer(&mut *stdout, &result)?;
writeln!(stdout)?;
} else {
print_step(&result);
}
Ok(result)
}
fn step_progress_message(phase: StepProgress<'_>) -> String {
match phase {
StepProgress::Preparing => "Preparing and simulating step…".into(),
StepProgress::Signing => "Signing with dev wallet…".into(),
StepProgress::Submitting { execution_id } => {
format!("Submitting execution {execution_id}…")
}
StepProgress::Confirming { execution_id } => {
format!("Waiting for confirmation of {execution_id}…")
}
}
}
#[cfg(test)]
mod step_progress_tests {
use super::*;
#[derive(Clone, Default)]
struct Output(std::sync::Arc<std::sync::Mutex<Vec<u8>>>);
impl Output {
fn text(&self) -> String {
String::from_utf8(self.0.lock().unwrap().clone()).unwrap()
}
}
impl std::io::Write for Output {
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
self.0.lock().unwrap().extend_from_slice(bytes);
Ok(bytes.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
#[tokio::test(start_paused = true)]
async fn slow_step_heartbeats_go_only_to_stderr_until_one_json_result() {
let stdout = Output::default();
let stderr = Output::default();
let (mut out, mut err) = (stdout.clone(), stderr.clone());
let task = tokio::spawn(async move {
finish_step(
async {
tokio::time::sleep(std::time::Duration::from_secs(12)).await;
Ok(json!({"state": "completed", "executionId": "e-1"}))
},
true,
&mut out,
&mut err,
)
.await
.unwrap()
});
tokio::task::yield_now().await;
tokio::time::advance(std::time::Duration::from_secs(4)).await;
tokio::task::yield_now().await;
assert!(stderr.text().is_empty(), "no immediate or early heartbeat");
assert!(stdout.text().is_empty(), "no result before completion");
for elapsed in [5, 10] {
tokio::time::advance(std::time::Duration::from_secs(if elapsed == 5 {
1
} else {
5
}))
.await;
tokio::task::yield_now().await;
assert!(stderr
.text()
.contains(&format!("Still waiting ({elapsed}s)")));
assert!(
stdout.text().is_empty(),
"progress must not reach JSON stdout"
);
}
tokio::time::advance(std::time::Duration::from_secs(2)).await;
let result = task.await.unwrap();
assert_eq!(
serde_json::from_str::<Value>(&stdout.text()).unwrap(),
result
);
assert_eq!(stdout.text().lines().count(), 1);
let finished = stderr.text();
assert_eq!(finished.lines().count(), 2);
tokio::time::advance(std::time::Duration::from_secs(20)).await;
assert_eq!(stderr.text(), finished, "no heartbeat after completion");
}
#[tokio::test(start_paused = true)]
async fn completion_at_a_tick_wins_and_expiry_stays_out_of_json_stdout() {
let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
let result = finish_step(
async {
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
Ok(json!({"state": "expired"}))
},
true,
&mut stdout,
&mut stderr,
)
.await
.unwrap();
let err = String::from_utf8(stderr).unwrap();
assert!(
!err.contains("Still waiting"),
"completion must win at the tick boundary"
);
assert!(err.contains("Execution expired before confirmation"));
let out = String::from_utf8(stdout).unwrap();
assert_eq!(serde_json::from_str::<Value>(&out).unwrap(), result);
assert_eq!(out.lines().count(), 1);
}
#[test]
fn expiry_is_not_presented_as_a_successful_payment() {
let message = step_outcome_message(&json!({"state": "expired"})).unwrap();
assert!(message.contains("not a completed payment"));
assert!(message.contains("inspect"));
assert!(step_outcome_message(&json!({"state": "completed"})).is_none());
assert!(step_progress_message(StepProgress::Submitting {
execution_id: "public-id"
})
.contains("public-id"));
}
}
fn step_outcome_message(result: &Value) -> Option<&'static str> {
(result["state"] == "expired").then_some(
"Execution expired before confirmation. This is not a completed payment; inspect its transaction status before trying again."
)
}
fn print_step(result: &Value) {
let state = result["state"].as_str().unwrap_or("unknown");
let marker = if state == "completed" {
state.green()
} else {
state.red()
};
println!(
"{} {} by {} -> {}",
"step".bold(),
result["step"].as_str().unwrap_or_default(),
result["wallet"]["name"].as_str().unwrap_or_default(),
marker
);
for (label, key) in [
("instance", "instanceId"),
("execution", "executionId"),
("rejected", "rejected"),
] {
if let Some(value) = result[key].as_str() {
println!(" {label:<10} {value}");
}
}
for transaction in result["transactions"].as_array().into_iter().flatten() {
if let Some(signature) = transaction["signature"].as_str() {
println!(" signature {signature}");
}
if let Some(error) = transaction["error"].as_str() {
println!(" error {error}");
}
}
}
fn down(args: DownArgs, json: bool) -> Result<()> {
let root = project_root(&args.project)?;
let selection = DevProject::new(&root).load_selection().ok().flatten();
let report = up::down(&root, args.purge)?;
let mut slept = None;
if args.sleep {
if let Some(selection) = &selection {
slept = Some(block_on(async {
let api = arete_mcp::dev_env::EnvApi::for_origin(
reqwest::Client::new(),
&selection.api_url,
)?;
api.sleep(&selection.id).await.map_err(env::explain)
})?);
}
}
let mut report = report;
report["slept"] = json!(slept.as_ref().map(arete_mcp::dev_env::public_view));
print(json, &report, || {
let stopped = report["stopped"].as_array().map(Vec::len).unwrap_or(0);
if stopped == 0 {
println!("No a4 dev up was running.");
} else {
println!("Stopped a4 dev up.");
}
if let Some(env) = &slept {
println!("{}", arete_mcp::dev_env::describe_env(env));
}
if args.purge {
println!(
"Deleted .arete/dev (the environment itself stays; `a4 dev delete` deletes it)."
);
}
});
Ok(())
}
fn consent_request(args: &ConsentArgs, now_ms: u128) -> Result<Value> {
fn pair<'a>(text: &'a str, what: &str) -> Result<(&'a str, &'a str)> {
text.split_once('=')
.filter(|(key, value)| !key.is_empty() && !value.is_empty())
.with_context(|| format!("{what} `{text}` is not KEY=VALUE"))
}
let mut per_execution = serde_json::Map::new();
for limit in &args.limits {
let (asset, amount) = pair(limit, "--limit")?;
per_execution.insert(asset.into(), json!(amount));
}
if per_execution.is_empty() && !args.revoke {
per_execution.insert("SOL".into(), json!("10000000"));
}
let mut per_window = serde_json::Map::new();
for window in &args.windows {
let (asset, rest) = pair(window, "--window")?;
let (amount, window) = rest
.split_once('/')
.with_context(|| format!("--window `{window}` is not ASSET=AMOUNT/WINDOW"))?;
per_window.insert(asset.into(), json!({ "amount": amount, "window": window }));
}
let mut params = serde_json::Map::new();
for param in &args.params {
let (key, value) = pair(param, "--param")?;
params.insert(key.into(), json!(value));
}
let mut limits = json!({ "perExecution": per_execution });
if !per_window.is_empty() {
limits["perWindow"] = Value::Object(per_window);
}
Ok(json!({
"wallet": args.wallet,
"steps": args.steps,
"limits": limits,
"params": params,
"instanceId": args.instance,
"expiresAt": now_ms + u128::from(args.days) * 86_400_000,
"revoke": args.revoke,
}))
}
fn consent(args: ConsentArgs, json: bool) -> Result<()> {
let root = project_root(&args.project)?;
let (port, token) = automations::control(&root)?;
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)?
.as_millis();
let request = consent_request(&args, now_ms)?;
let response = reqwest::blocking::Client::new()
.post(format!("http://127.0.0.1:{port}/consent"))
.bearer_auth(token)
.json(&request)
.send()
.context("Could not reach the automations `a4 dev up` runs")?;
let ok = response.status().is_success();
let body: Value = response.json().unwrap_or(Value::Null);
if !ok {
bail!(
"{}",
body["error"]
.as_str()
.unwrap_or("the automations refused the consent")
);
}
if json {
println!("{body}");
} else if args.revoke {
println!(
"Revoked {} consent grant(s) of {}",
body["revoked"],
body["wallet"].as_str().unwrap_or(&args.wallet)
);
} else {
println!(
"{} consented to {} until {} days from now",
body["grant"]["wallet"].as_str().unwrap_or(&args.wallet),
args.steps.join(", "),
args.days
);
for warning in body["warnings"].as_array().into_iter().flatten() {
if let Some(warning) = warning.as_str() {
println!(" note: {warning}");
}
}
}
Ok(())
}
fn sdk(args: SdkArgs, json: bool) -> Result<()> {
use crate::project::manifest::InstallTarget;
let root = project_root(&args.project)?;
let mut targets = Vec::new();
for (chosen, target) in [
(args.ts, InstallTarget::TypeScript),
(args.rust, InstallTarget::Rust),
(args.python, InstallTarget::Python),
] {
if chosen {
targets.push(target);
}
}
if targets.is_empty() {
targets = sdk_gen::configured_targets(&root)?;
}
let protocol = sdk_gen::LocalProtocol::from_project(&root)?;
let api_base = DevProject::new(&root)
.load_selection()
.ok()
.flatten()
.map(|selection| selection.api_url)
.unwrap_or_else(ApiClient::configured_api_url);
let report = sdk_gen::sync_installed_client(&root, &protocol, &targets, &api_base)?;
print(json, &serde_json::to_value(&report)?, || {
if report.added_dependency {
println!(
"{} added [dependencies.protocols.{}] to arete.toml",
"ok".green(),
report.install.alias
);
}
println!("{} {}", "ok".green(), report.summary());
println!(
"{}",
"The app calls the deployment in your dev environment with your Arete key. `a4 install --locked` restores the published client.".dimmed()
);
});
Ok(())
}
fn project_root(args: &ProjectArgs) -> Result<PathBuf> {
let root = match &args.project {
Some(path) => path.clone(),
None => std::env::current_dir()?,
};
std::fs::canonicalize(&root).with_context(|| format!("{} does not exist", root.display()))
}
fn client(args: &ProjectArgs) -> Result<DevClient> {
DevClient::open(DevProject::discover(args.project.as_deref())?)
}
async fn status(args: EnvArgs, json: bool) -> Result<()> {
let mut report = if args.env.is_none() {
match DevProject::discover(args.project.project.as_deref()).and_then(DevClient::open) {
Ok(client) => {
let mut report = client.status().await;
let state = up::load_state(&client.project);
report["up"] = match state {
Some(state) if runtime::pid_alive(state.pid) => {
json!({ "pid": state.pid, "ports": state.ports })
}
_ => Value::Null,
};
let deploy = deploy::DeployState::load(&client.project.deploy_state_path());
report["deployments"] = json!(deploy
.deployment_states()
.into_iter()
.map(|(id, state)| json!({ "id": id, "state": state }))
.collect::<Vec<_>>());
report
}
Err(error) => {
if json {
println!(
"{}",
json!({ "environment": null, "error": format!("{error:#}") })
);
return Ok(());
}
return Err(error);
}
}
} else {
let rpc = args.rpc().await?;
let view = rpc.api().get_env(rpc.env()).await.map_err(env::explain)?;
json!({ "environment": arete_mcp::dev_env::public_view(&view) })
};
if report["environment"]["state"] == "running" && report.get("chain").is_none() {
if let Ok(rpc) = args.rpc().await {
if let Ok((slot, unix)) = arete_mcp::dev_env::read_clock(&rpc).await {
report["chain"] = json!({ "healthy": true, "slot": slot, "unixTimestamp": unix });
}
}
}
arete_mcp::dev::redact_secrets(&mut report);
print(json, &report, || print_status(&report));
Ok(())
}
fn print_status(report: &Value) {
print!("{}", render_status(report));
}
fn render_status(report: &Value) -> String {
use std::fmt::Write as _;
let mut out = String::new();
let env = &report["environment"];
if let Some(error) = env["error"].as_str() {
let _ = writeln!(out, "{} {}", "environment".bold(), error.red());
} else {
let _ = writeln!(
out,
"{} {}",
"environment".bold(),
arete_mcp::dev_env::describe_env(env)
);
if let Some(slept) = env["sleptAt"].as_str() {
if env["state"] == "sleeping" {
let _ = writeln!(
out,
" slept {slept} (any call or `a4 dev up` wakes it)"
);
}
}
if let Some(bytes) = env["storageBytes"].as_i64() {
let _ = writeln!(out, " storage {bytes} bytes");
}
}
let session = &report["session"];
match (&report["up"], session["rpcUrl"].as_str()) {
(up, Some(rpc)) if !up.is_null() && !rpc.is_empty() => {
let _ = writeln!(out, " a4 dev up running (pid {})", up["pid"]);
let _ = writeln!(out, " RPC {rpc}");
let _ = writeln!(
out,
" WebSocket {}",
session["rpcWsUrl"].as_str().unwrap_or_default()
);
if let Some(ui) = session["uiUrl"].as_str() {
let _ = writeln!(out, " UI {ui}");
}
}
_ => {
if !session.is_null() {
let _ = writeln!(out, " a4 dev up not running");
}
}
}
if let Some(url) = session["environmentRpcUrl"]
.as_str()
.filter(|url| !url.is_empty())
{
let _ = writeln!(out, " Env RPC {url} (needs a token: `a4 dev token`)");
}
if let Some(deployment) = session["deploymentId"].as_str() {
let _ = writeln!(out, " deployment {deployment} (active)");
}
for earlier in report["deployments"].as_array().into_iter().flatten() {
let state = earlier["state"].as_str().unwrap_or_default();
if state == "active" {
continue;
}
let state = if state == "disabled" {
state.dimmed()
} else {
format!("{state} (disable failed; retried on the next deploy)").yellow()
};
let _ = writeln!(
out,
" {} {state}",
earlier["id"].as_str().unwrap_or_default()
);
}
if let Some(url) = report["stack"]["streamUrl"].as_str() {
let _ = writeln!(out,
" stream a4 stream --dev (lists the protocol's views; --tui browses them; mints its own tokens)"
);
let _ = writeln!(
out,
" stream URL {url} (token redacted; `a4 stream --dev` mints one)"
);
} else if let Some(error) = report["stack"]["error"].as_str() {
let _ = writeln!(out, " stream {}", error.red());
}
let chain = &report["chain"];
if chain["healthy"] == true {
let _ = writeln!(
out,
" chain slot {} clock {}",
chain["slot"], chain["unixTimestamp"]
);
} else if let Some(error) = chain["error"].as_str() {
let _ = writeln!(out, " chain {}", error.red());
}
out
}
fn up(args: UpArgs, json: bool) -> Result<()> {
let project_root = project_root(&args.project)?;
let has_protocol = project_root.join(deploy::ENTRY).is_file();
let mut checks = Vec::new();
let mut warnings = Vec::new();
if has_protocol && !args.no_deploy {
let node = tools::check_node()?;
let arete_protocol = crate::commands::protocol_bin::resolve(&project_root)?;
checks.push(json!({ "tool": "node", "version": node.detail }));
checks.push(json!({ "tool": "arete-protocol", "path": arete_protocol }));
warnings.extend(node.warning);
}
let settings = if has_protocol {
deploy_settings(&project_root, &args)?
} else {
deploy::DeploySettings::default()
};
let api_url = ApiClient::configured_api_url();
let key = ApiClient::load_optional_api_key_for_url(&api_url)?.ok_or_else(|| {
anyhow::anyhow!(
"Cloud dev environments need an Arete secret key: run `a4 auth login` (or `a4 auth signup`), or set ARETE_API_KEY."
)
})?;
let api = arete_mcp::dev_env::EnvApi::new(reqwest::Client::new(), &api_url, &key)?;
if json {
println!(
"{}",
json!({ "event": "checks", "tools": checks, "warnings": warnings, "api": api.origin() })
);
} else {
for check in &checks {
println!(
"{} {} {}",
"ok".green(),
check["tool"].as_str().unwrap_or_default(),
check["version"]
.as_str()
.or_else(|| check["path"].as_str())
.unwrap_or_default()
);
}
for warning in &warnings {
eprintln!("{} {warning}", "warning:".yellow().bold());
}
}
let config = up::UpConfig {
project_root: project_root.clone(),
protocol: has_protocol,
api,
environment: args.env.clone(),
template: args.template.clone(),
settings,
port_base: args.port_base,
once: args.once,
deploy: !args.no_deploy,
sync_sdk: has_protocol && !args.no_sdk && !args.no_deploy,
sdk_targets: if has_protocol {
sdk_gen::configured_targets(&project_root)?
} else {
Vec::new()
},
json,
ui: !args.no_ui,
open_ui: args.open,
show_token: args.show_token,
ui_dev_url: args.ui_dev_url.clone(),
};
block_on(up::run(config))
}
fn deploy_settings(
project_root: &std::path::Path,
args: &UpArgs,
) -> Result<deploy::DeploySettings> {
let config = crate::commands::protocol::load_protocol_config(project_root)?;
let mut resources: std::collections::BTreeMap<_, _> = config
.resources
.iter()
.map(|(name, source)| (name.clone(), source.relative_to(project_root)))
.collect();
resources.extend(crate::commands::protocol::parse_resource_flags(
&args.resources,
)?);
if let Some(slug) = &config.slug {
crate::commands::protocol::validate_slug(slug)?;
}
Ok(deploy::DeploySettings {
slug: config.slug,
name: config.name,
fee_policy: args
.fee_policy
.clone()
.or_else(|| config.fee_policy.map(|path| project_root.join(path))),
resources,
})
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
use std::path::Path;
#[derive(Parser, Debug)]
struct Cli {
#[command(subcommand)]
command: DevCommands,
}
#[test]
fn dev_status_prints_no_token() {
const STACK_TOKEN: &str = "eyJhbGciOiJFZERTQSJ9.c3RhY2s.c2ln";
const ENV_TOKEN: &str = "denvtok_9f8e7d6c5b4a";
let mut report = json!({
"environment": { "id": "denv_01hzx3k8m9q2r4s5t6v7w8x9yz", "state": "running" },
"session": {
"rpcUrl": "http://127.0.0.1:48700",
"stackSession": { "token": STACK_TOKEN, "expiresAt": 1 },
},
"up": { "pid": 1, "ports": [48700, 48701, 48702] },
"stack": {
"wsUrl": "wss://stack.example.test",
"streamUrl": format!("wss://stack.example.test/?hs_token={STACK_TOKEN}"),
},
"chain": { "healthy": false, "error": format!("401 for Bearer {ENV_TOKEN}") },
});
arete_mcp::dev::redact_secrets(&mut report);
let json_output = report.to_string();
let plain_output = render_status(&report);
for output in [&json_output, &plain_output] {
assert!(!output.contains(STACK_TOKEN), "{output}");
assert!(!output.contains(ENV_TOKEN), "{output}");
}
assert!(json_output.contains("hs_token=<redacted>"), "{json_output}");
assert!(plain_output.contains("stream URL"), "{plain_output}");
}
#[test]
fn dev_up_shows_the_token_only_on_request() {
let args = match parse(&["up"]).unwrap() {
DevCommands::Up(args) => args,
other => panic!("{other:?}"),
};
assert!(!args.show_token);
match parse(&["up", "--show-token"]).unwrap() {
DevCommands::Up(args) => assert!(args.show_token),
other => panic!("{other:?}"),
}
}
fn parse(args: &[&str]) -> Result<DevCommands, clap::Error> {
let mut argv = vec!["dev"];
argv.extend_from_slice(args);
Cli::try_parse_from(argv).map(|cli| cli.command)
}
#[test]
fn builds_a_consent_request() {
let DevCommands::Consent(args) = parse(&[
"consent",
"alice",
"--steps",
"refund,claim",
"--limit",
"SOL=5000",
"--window",
"SOL=20000/30d",
"--param",
"weekday=fri",
"--days",
"2",
])
.unwrap() else {
panic!("not consent");
};
assert_eq!(
consent_request(&args, 1_000).unwrap(),
json!({
"wallet": "alice",
"steps": ["refund", "claim"],
"limits": {
"perExecution": { "SOL": "5000" },
"perWindow": { "SOL": { "amount": "20000", "window": "30d" } },
},
"params": { "weekday": "fri" },
"instanceId": null,
"expiresAt": 1_000 + 2 * 86_400_000u64,
"revoke": false,
})
);
let DevCommands::Consent(args) = parse(&["consent", "alice", "--steps", "refund"]).unwrap()
else {
panic!("not consent");
};
assert_eq!(
consent_request(&args, 0).unwrap()["limits"],
json!({ "perExecution": { "SOL": "10000000" } })
);
assert!(parse(&["consent", "alice"]).is_err());
let DevCommands::Consent(args) = parse(&["consent", "alice", "--revoke"]).unwrap() else {
panic!("not consent");
};
assert_eq!(consent_request(&args, 0).unwrap()["revoke"], true);
let DevCommands::Consent(bad) =
parse(&["consent", "alice", "--steps", "x", "--limit", "SOL"]).unwrap()
else {
panic!("not consent");
};
assert!(consent_request(&bad, 0).is_err());
}
#[test]
fn parses_up_flags() {
let DevCommands::Up(args) = parse(&["up"]).unwrap() else {
panic!("expected up");
};
assert_eq!(args.port_base, 48_700);
assert!(!args.once && !args.no_deploy && !args.no_ui);
for flag in ["--once", "--no-watch"] {
let DevCommands::Up(args) = parse(&["up", flag]).unwrap() else {
panic!("expected up");
};
assert!(args.once, "{flag}");
}
let DevCommands::Up(args) = parse(&[
"up",
"--project",
"/tmp/p",
"--env",
"staging",
"--template",
"dex",
"--no-deploy",
"--port-base",
"0",
])
.unwrap() else {
panic!("expected up");
};
assert_eq!(args.project.project.as_deref(), Some(Path::new("/tmp/p")));
assert_eq!(args.env.as_deref(), Some("staging"));
assert_eq!(args.template.as_deref(), Some("dex"));
assert!(args.no_deploy);
assert_eq!(args.port_base, 0);
for gone in [
"--arete-dev-bin",
"--geyser-plugin",
"--rpc-url",
"--offline",
"--accept-eula",
] {
assert!(parse(&["up", gone, "x"]).is_err(), "{gone}");
}
assert!(parse(&["up", "--open", "--no-ui"]).is_err());
}
#[test]
fn parses_environment_commands() {
assert!(matches!(parse(&["list"]).unwrap(), DevCommands::List));
assert!(matches!(
parse(&["status", "--env", "app"]).unwrap(),
DevCommands::Status(EnvArgs { ref env, .. }) if env.as_deref() == Some("app")
));
for command in ["wake", "sleep", "delete", "pause", "resume", "token"] {
assert!(parse(&[command]).is_ok(), "{command}");
}
assert!(matches!(
parse(&["checkpoint", "funded"]).unwrap(),
DevCommands::Checkpoint(CheckpointArgs { ref name, list: false, .. }) if name.as_deref() == Some("funded")
));
assert!(matches!(
parse(&["checkpoint", "--list"]).unwrap(),
DevCommands::Checkpoint(CheckpointArgs { list: true, .. })
));
assert!(parse(&["checkpoint", "x", "--list"]).is_err());
assert!(matches!(
parse(&["checkpoint", "--delete", "ckpt_ab"]).unwrap(),
DevCommands::Checkpoint(CheckpointArgs { ref delete, .. }) if delete.as_deref() == Some("ckpt_ab")
));
assert!(matches!(
parse(&["branch", "funded", "app-b", "--use"]).unwrap(),
DevCommands::Branch(BranchArgs { switch: true, ref name, .. }) if name == "app-b"
));
assert!(parse(&["branch", "funded"]).is_err());
assert!(matches!(
parse(&["restore", "funded"]).unwrap(),
DevCommands::Restore(_)
));
assert!(matches!(
parse(&["rebase", "--keep", "A", "--keep", "B"]).unwrap(),
DevCommands::Rebase(RebaseArgs { ref keep, .. }) if keep.len() == 2
));
assert!(matches!(
parse(&["reset", "--account", "A", "--include-owned"]).unwrap(),
DevCommands::Reset(ResetArgs {
include_owned: true,
..
})
));
assert!(parse(&["reset", "--include-owned"]).is_err());
assert!(matches!(
parse(&["fund", "default", "5"]).unwrap(),
DevCommands::Fund(FundArgs { ref sol, .. }) if sol.as_deref() == Some("5")
));
assert!(matches!(
parse(&["fund", "default", "--mint", "M", "--amount", "100"]).unwrap(),
DevCommands::Fund(FundArgs { ref mint, .. }) if mint.as_deref() == Some("M")
));
assert!(parse(&["fund", "default", "--mint", "M"]).is_err());
assert!(parse(&["fund", "default", "5", "--mint", "M", "--amount", "1"]).is_err());
assert!(matches!(
parse(&["pin", "A", "--unpin"]).unwrap(),
DevCommands::Pin(PinArgs { unpin: true, .. })
));
assert!(parse(&["pin", "A", "--unpin", "--include-owned"]).is_err());
assert!(matches!(
parse(&["follow", "A", "--stop"]).unwrap(),
DevCommands::Follow(FollowArgs { stop: true, .. })
));
assert!(matches!(
parse(&["warp", "+2h"]).unwrap(),
DevCommands::Warp(WarpArgs { ref to, .. }) if to == "+2h"
));
assert!(matches!(
parse(&["time-travel", "slot:5"]).unwrap(),
DevCommands::Warp(_)
));
assert!(matches!(
parse(&["scenario", "crash.json", "--dry-run"]).unwrap(),
DevCommands::Scenario(ScenarioArgs { dry_run: true, .. })
));
assert!(matches!(
parse(&["repro", "sig", "-o", "r.json"]).unwrap(),
DevCommands::Repro(_)
));
assert!(matches!(
parse(&["events", "-f", "--after", "3"]).unwrap(),
DevCommands::Events(EventsArgs {
follow: true,
after: Some(3),
..
})
));
assert!(matches!(
parse(&["use", "app-b"]).unwrap(),
DevCommands::Use(_)
));
assert!(matches!(
parse(&["down", "--sleep", "--purge"]).unwrap(),
DevCommands::Down(DownArgs {
sleep: true,
purge: true,
..
})
));
for gone in [
"fetch",
"logs",
"set-account",
"inspect",
"state",
"diff",
"stack",
] {
assert!(parse(&[gone]).is_err(), "{gone}");
}
}
#[test]
fn parses_protocol_commands() {
let DevCommands::Step(step) = parse(&[
"step",
"open",
"--args",
"{\"amount\":\"5\"}",
"--wallet",
"bob",
])
.unwrap() else {
panic!("expected step");
};
assert_eq!(step.step, "open");
assert_eq!(step.wallet, "bob");
assert!(step.instance.is_none());
assert!(matches!(
parse(&["replay", "h.jsonl", "--include-failed"]).unwrap(),
DevCommands::Replay(ReplayArgs {
include_failed: true,
..
})
));
assert!(matches!(
parse(&["wallet", "bob"]).unwrap(),
DevCommands::Wallet(WalletArgs { ref name, .. }) if name == "bob"
));
assert!(matches!(
parse(&["instance", "i-1"]).unwrap(),
DevCommands::Instance(_)
));
assert!(matches!(
parse(&["sdk", "--ts", "--python"]).unwrap(),
DevCommands::Sdk(SdkArgs {
ts: true,
rust: false,
python: true,
..
})
));
assert!(matches!(
parse(&[
"capture",
"--program",
"oreV3EG1i9BEgiAJ8b177Z2S2rMarzak4NMv1kULvWv",
"--from",
"-2m",
"-o",
"ore.arcap"
])
.unwrap(),
DevCommands::Capture(ref args) if args.from == "-2m"
));
assert!(parse(&["capture", "--program", "x", "-o", "a.arcap"]).is_err());
}
}