Skip to content
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
/target
.cargo/config.toml
.cache/
*.orig
.claude/plans/
.claude/scheduled_tasks.lock
.claude/settings.local.json
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed
- `ant node start`/`ant node stop` with `--service-name` now resolve the node ID through the daemon API instead of reading `node_registry.json` directly, eliminating a race against concurrent registry mutations by the daemon.
- `ant node add --json` no longer interleaves binary-download progress with the JSON result; progress output now goes to stderr (was stdout) and is suppressed entirely in JSON mode.

### Changed
- Default network binding changed from IPv4-only to IPv6 dual-stack. Hosts without a working IPv6 stack should pass `--ipv4-only` to avoid advertising unreachable v6 addresses to the DHT (which causes slow connects and junk address records).
- `ant file upload` now writes datamaps as `<filename>.<extension>.datamap` instead of stripping the extension. Uploading `photo.jpg` produces `photo.jpg.datamap` (was `photo.datamap`). Existing datamaps remain readable.
Expand All @@ -18,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `ant file download --datamap` now reads both msgpack (canonical) and legacy JSON datamaps, so datamaps produced by older versions of the GUI download cleanly via the CLI.

### Internal
- CLI-audit thinning (V2-189): `PortRange` parsing (`FromStr`), env `KEY=VALUE` parsing (`AddNodeOpts::parse_env_vars`), and bootstrap-peer resolution (`config::resolve_bootstrap_peers`) moved from ant-cli into ant-core; `node add`/`node reset` daemon calls now go through `ant_core::node::daemon::client` (new `add_node`/`reset`/`resolve_node_id_by_name` functions) instead of hand-rolled HTTP; the two CLI `ProgressReporter` impls collapsed into one.
- New `ant_core::datamap_file` module owns the on-disk datamap format (msgpack canonical, JSON legacy auto-detect on read) and naming convention. `ant-cli` and consumers like `ant-gui` route through this single helper instead of reimplementing serialization.

## [0.1.1] - 2026-03-28
Expand Down
114 changes: 18 additions & 96 deletions ant-cli/src/commands/node/add.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use std::path::PathBuf;
use clap::Args;
use colored::Colorize;

use ant_core::node::binary::ProgressReporter;
use ant_core::node::binary::{NoopProgress, ProgressReporter};
use ant_core::node::daemon::client;
use ant_core::node::types::DaemonConfig;
use ant_core::node::types::{
Expand Down Expand Up @@ -103,8 +103,8 @@ impl AddArgs {
// Check if daemon is running; if so, POST to API; otherwise call directly
let config = DaemonConfig::default();
let result = match client::status(&config).await {
Ok(status) if status.running => self.add_via_daemon(&config, &opts).await?,
_ => self.add_directly(&config, &opts).await?,
Ok(status) if status.running => client::add_node(&config, &opts).await?,
_ => self.add_directly(&config, &opts, json_output).await?,
};

if json_output {
Expand Down Expand Up @@ -150,7 +150,11 @@ impl AddArgs {
}

fn to_add_node_opts(&self) -> anyhow::Result<AddNodeOpts> {
let node_port = self.parse_port_range(&self.node_port)?;
let node_port = self
.node_port
.as_deref()
.map(str::parse::<PortRange>)
.transpose()?;

let binary_source = if let Some(ref path) = self.path {
BinarySource::LocalPath(path.clone())
Expand All @@ -162,18 +166,7 @@ impl AddArgs {
BinarySource::Latest
};

let env_variables: Vec<(String, String)> = self
.env
.iter()
.map(|e| {
let parts: Vec<&str> = e.splitn(2, '=').collect();
if parts.len() == 2 {
Ok((parts[0].to_string(), parts[1].to_string()))
} else {
anyhow::bail!("Invalid env variable format: '{e}'. Expected KEY=VALUE")
}
})
.collect::<anyhow::Result<Vec<_>>>()?;
let env_variables = AddNodeOpts::parse_env_vars(&self.env)?;

Ok(AddNodeOpts {
count: self.count,
Expand All @@ -189,92 +182,21 @@ impl AddArgs {
})
}

fn parse_port_range(&self, input: &Option<String>) -> anyhow::Result<Option<PortRange>> {
match input {
None => Ok(None),
Some(s) => {
if let Some((start, end)) = s.split_once('-') {
let start: u16 = start
.parse()
.map_err(|_| anyhow::anyhow!("Invalid port range start: '{start}'"))?;
let end: u16 = end
.parse()
.map_err(|_| anyhow::anyhow!("Invalid port range end: '{end}'"))?;
if end < start {
anyhow::bail!("Port range end ({end}) must be >= start ({start})");
}
Ok(Some(PortRange::Range(start, end)))
} else {
let port: u16 = s
.parse()
.map_err(|_| anyhow::anyhow!("Invalid port: '{s}'"))?;
Ok(Some(PortRange::Single(port)))
}
}
}
}

async fn add_via_daemon(
&self,
config: &DaemonConfig,
opts: &AddNodeOpts,
) -> anyhow::Result<AddNodeResult> {
let info = client::info(config);
let api_base = info
.api_base
.ok_or_else(|| anyhow::anyhow!("Daemon is running but API base URL not available"))?;

let client = reqwest::Client::new();
let resp = client
.post(format!("{api_base}/nodes"))
.json(opts)
.send()
.await?;

if resp.status().is_success() {
Ok(resp.json().await?)
} else {
let body = resp.text().await?;
anyhow::bail!("Daemon returned error: {body}");
}
}

async fn add_directly(
&self,
config: &DaemonConfig,
opts: &AddNodeOpts,
json_output: bool,
) -> anyhow::Result<AddNodeResult> {
let progress = CliProgress;
// Suppress progress in JSON mode so stdout stays parseable.
let progress: Box<dyn ProgressReporter> = if json_output {
Box::new(NoopProgress)
} else {
Box::new(crate::progress::CliProgress)
};
let result =
ant_core::node::add_nodes(opts.clone(), &config.registry_path, &progress).await?;
ant_core::node::add_nodes(opts.clone(), &config.registry_path, progress.as_ref())
.await?;
Ok(result)
}
}

/// CLI progress reporter that prints to the terminal.
struct CliProgress;

impl ProgressReporter for CliProgress {
fn report_started(&self, message: &str) {
println!("{} {message}", "⟳".cyan());
}

fn report_progress(&self, bytes: u64, total: u64) {
if total > 0 {
let pct = (bytes as f64 / total as f64 * 100.0) as u32;
let bar_width = 30;
let filled = (pct as usize * bar_width) / 100;
let empty = bar_width - filled;
let bar = format!(
"{}{}",
"█".repeat(filled).cyan(),
"░".repeat(empty).dimmed()
);
print!("\r {} {bar} {pct:>3}%", "Downloading".dimmed());
}
}

fn report_complete(&self, message: &str) {
println!("\r{} {message}", "✓".green().bold());
}
}
19 changes: 1 addition & 18 deletions ant-cli/src/commands/node/reset.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ impl ResetArgs {
}

let result = if daemon_running {
self.reset_via_daemon(&config).await?
client::reset(&config).await?
} else {
self.reset_directly(&config)?
};
Expand Down Expand Up @@ -85,23 +85,6 @@ impl ResetArgs {
Ok(())
}

async fn reset_via_daemon(&self, config: &DaemonConfig) -> anyhow::Result<ResetResult> {
let info = client::info(config);
let api_base = info
.api_base
.ok_or_else(|| anyhow::anyhow!("Daemon is running but API base URL not available"))?;

let client = reqwest::Client::new();
let resp = client.post(format!("{api_base}/reset")).send().await?;

if resp.status().is_success() {
Ok(resp.json().await?)
} else {
let body = resp.text().await?;
anyhow::bail!("Daemon returned error: {body}");
}
}

fn reset_directly(&self, config: &DaemonConfig) -> anyhow::Result<ResetResult> {
let result = ant_core::node::reset(&config.registry_path)?;
Ok(result)
Expand Down
9 changes: 3 additions & 6 deletions ant-cli/src/commands/node/start.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,9 @@ impl StartArgs {
service_name: &str,
json_output: bool,
) -> anyhow::Result<()> {
// Look up node ID by service name via the registry
let registry = ant_core::node::registry::NodeRegistry::load(&config.registry_path)?;
let node = registry
.find_by_service_name(service_name)
.ok_or_else(|| anyhow::anyhow!("No node found with service name '{service_name}'"))?;
let node_id = node.id;
// Resolve the node ID through the daemon API — the daemon owns the
// registry, so the CLI must not read node_registry.json directly.
let node_id = client::resolve_node_id_by_name(config, service_name).await?;

let result = client::start_node(config, node_id).await?;

Expand Down
8 changes: 3 additions & 5 deletions ant-cli/src/commands/node/stop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,9 @@ impl StopArgs {
service_name: &str,
json_output: bool,
) -> anyhow::Result<()> {
let registry = ant_core::node::registry::NodeRegistry::load(&config.registry_path)?;
let node = registry
.find_by_service_name(service_name)
.ok_or_else(|| anyhow::anyhow!("No node found with service name '{service_name}'"))?;
let node_id = node.id;
// Resolve the node ID through the daemon API — the daemon owns the
// registry, so the CLI must not read node_registry.json directly.
let node_id = client::resolve_node_id_by_name(config, service_name).await?;

let result = client::stop_node(config, node_id).await?;

Expand Down
22 changes: 2 additions & 20 deletions ant-cli/src/commands/update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,7 @@ use colored::Colorize;
use ant_core::node::binary::NoopProgress;
use ant_core::update;

/// Progress reporter that prints to the terminal.
struct CliUpdateProgress;

impl ant_core::node::binary::ProgressReporter for CliUpdateProgress {
fn report_started(&self, message: &str) {
eprintln!("{}", message.dimmed());
}

fn report_progress(&self, bytes: u64, total: u64) {
if total > 0 {
let pct = (bytes as f64 / total as f64 * 100.0) as u64;
eprint!("\r{}", format!(" Downloading... {pct}%").dimmed());
}
}

fn report_complete(&self, message: &str) {
eprintln!("\r{}", message.green());
}
}
use crate::progress::CliProgress;

#[derive(Args)]
pub struct UpdateArgs {
Expand Down Expand Up @@ -72,7 +54,7 @@ impl UpdateArgs {
let progress: Box<dyn ant_core::node::binary::ProgressReporter> = if json_output {
Box::new(NoopProgress)
} else {
Box::new(CliUpdateProgress)
Box::new(CliProgress)
};

let result = update::perform_update(&check, progress.as_ref()).await?;
Expand Down
35 changes: 1 addition & 34 deletions ant-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ async fn build_data_client(
}

let manifest = load_manifest(ctx)?;
let bootstrap = resolve_bootstrap_from(ctx, manifest.as_ref())?;
let bootstrap = ant_core::config::resolve_bootstrap_peers(&ctx.bootstrap, manifest.as_ref())?;
// Explicit network selectors should be isolated from the general client
// peer cache. `--bootstrap` and `--devnet-manifest` both mean "use exactly
// this network entrypoint", so cached public-network peers must not be
Expand Down Expand Up @@ -454,39 +454,6 @@ fn resolve_evm_network(
}
}

/// Resolve bootstrap peers from a pre-loaded manifest.
///
/// Priority: CLI `--bootstrap` > devnet manifest > `bootstrap_peers.toml` config file.
fn resolve_bootstrap_from(
ctx: &DataCliContext,
manifest: Option<&DevnetManifest>,
) -> anyhow::Result<Vec<SocketAddr>> {
if !ctx.bootstrap.is_empty() {
return Ok(ctx.bootstrap.clone());
}

if let Some(m) = manifest {
let bootstrap: Vec<SocketAddr> = m
.bootstrap
.iter()
.filter_map(MultiAddr::socket_addr)
.collect();
return Ok(bootstrap);
}

if let Some(peers) = ant_core::config::load_bootstrap_peers()
.map_err(|e| anyhow::anyhow!("Failed to load bootstrap config: {e}"))?
{
info!("Loaded {} bootstrap peer(s) from config file", peers.len());
return Ok(peers);
}

anyhow::bail!(
"No bootstrap peers provided. Use --bootstrap, --devnet-manifest, \
or install bootstrap_peers.toml to your config directory."
)
}

async fn create_client_node(
bootstrap: &[SocketAddr],
allow_loopback: bool,
Expand Down
33 changes: 33 additions & 0 deletions ant-cli/src/progress.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,12 @@ use std::io::{self, IsTerminal, Write};
use std::sync::OnceLock;
use std::time::Duration;

use colored::Colorize;
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
use tracing_subscriber::fmt::MakeWriter;

use ant_core::node::binary::ProgressReporter;

static MULTI: OnceLock<MultiProgress> = OnceLock::new();

/// The shared `MultiProgress` instance. Created on first access.
Expand Down Expand Up @@ -51,6 +54,36 @@ pub fn attach(pb: ProgressBar) -> ProgressBar {
}
}

/// Terminal implementation of ant-core's `ProgressReporter` (binary
/// downloads during `node add` and self-update). Writes to stderr so
/// stdout stays clean for command output.
pub struct CliProgress;

impl ProgressReporter for CliProgress {
fn report_started(&self, message: &str) {
eprintln!("{} {message}", "⟳".cyan());
}

fn report_progress(&self, bytes: u64, total: u64) {
if total > 0 {
let pct = (bytes as f64 / total as f64 * 100.0) as u32;
let bar_width = 30;
let filled = (pct as usize * bar_width) / 100;
let empty = bar_width - filled;
let bar = format!(
"{}{}",
"█".repeat(filled).cyan(),
"░".repeat(empty).dimmed()
);
eprint!("\r {} {bar} {pct:>3}%", "Downloading".dimmed());
}
}

fn report_complete(&self, message: &str) {
eprintln!("\r{} {message}", "✓".green().bold());
}
}

/// Tracing writer that suspends active progress bars while writing log lines.
#[derive(Clone)]
pub struct ProgressAwareWriter;
Expand Down
Loading
Loading