Skip to content
Closed
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
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