mod commands;
mod config;
mod context;
mod deadline;
mod error;
mod output;
mod parsers;
mod prelude;
mod secret;
mod target;
use std::path::PathBuf;
use std::process::ExitCode;
use clap::{Parser, Subcommand};
use crate::error::CliError;
use crate::output::OutputFormat;
#[derive(Parser, Debug)]
#[command(
name = "net-mesh",
bin_name = "net-mesh",
version,
about = "Unified command-line interface for the Net mesh.",
// Help is the only documentation an installed binary carries, so it has
// to stand on its own. This used to end with "See NET_CLI_PLAN.md for the
// full surface" — a repository file that ships in no package, so the one
// pointer offered to a user who had just run `pip install net-mesh-cli`
// named something they could not open.
long_about = "net-mesh is the operational counterpart to net-deck — \
a non-interactive command-line tool that wraps \
the Rust SDK for one-shot operator commands, CI \
scripting, daemon authoring, and ad-hoc cluster \
inspection.\n\n\
Run `net-mesh help <command>` for any subcommand, or see \
https://ai2070.net/docs/reference/cli for the full surface."
)]
struct Cli {
#[arg(long, global = true, env = "NET_MESH_CONFIG")]
config: Option<PathBuf>,
#[arg(long, global = true, env = "NET_MESH_PROFILE")]
profile: Option<String>,
#[arg(long, global = true, env = "NET_MESH_INSECURE_CONFIG_PERMISSIONS")]
insecure_config_permissions: bool,
#[arg(long, global = true, value_enum)]
output: Option<OutputFormat>,
#[arg(long, short = 'q', global = true)]
quiet: bool,
#[arg(long, short = 'v', global = true, action = clap::ArgAction::Count)]
verbose: u8,
#[arg(long, global = true)]
no_color: bool,
#[arg(long, global = true, value_parser = humantime::parse_duration)]
timeout: Option<std::time::Duration>,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand, Debug)]
enum Command {
Version,
#[command(subcommand)]
Identity(commands::identity::IdentityCommand),
Up(commands::lifecycle::UpArgs),
Down(commands::lifecycle::DownArgs),
#[command(subcommand)]
Enrollment(commands::enrollment::EnrollmentCommand),
#[command(subcommand)]
Invite(commands::enrollment::InviteCommand),
Join(commands::enrollment::JoinArgs),
Leave(commands::lifecycle::LeaveArgs),
#[command(subcommand)]
Relay(commands::relay::RelayCommand),
#[command(subcommand)]
Admin(commands::admin::AdminCommand),
#[command(subcommand)]
Ice(commands::ice::IceCommand),
#[command(subcommand)]
Snapshot(commands::snapshot::SnapshotCommand),
#[command(subcommand)]
Audit(commands::audit::AuditCommand),
#[command(subcommand)]
Log(LogCommand),
#[command(subcommand)]
Failures(FailuresCommand),
#[command(subcommand)]
Cap(commands::cap::CapCommand),
#[command(subcommand)]
Peer(PeerCommand),
#[command(subcommand)]
Daemon(DaemonCommand),
#[command(subcommand)]
Netdb(commands::netdb::NetdbCommand),
#[command(subcommand)]
Org(commands::org::OrgCommand),
#[command(subcommand)]
Node(commands::node::NodeCommand),
#[command(subcommand)]
Anchor(commands::anchor::AnchorCommand),
#[command(subcommand)]
Subnet(commands::subnet::SubnetCommand),
#[command(subcommand)]
Gateway(commands::gateway::GatewayCommand),
#[command(subcommand)]
Channel(commands::channel::ChannelCommand),
#[command(subcommand)]
Aggregator(commands::aggregator::AggregatorCommand),
#[command(subcommand)]
Transfer(commands::transfer::TransferCommand),
Wrap(commands::wrap::WrapArgs),
#[command(subcommand)]
Mcp(commands::mcp::McpCommand),
#[command(subcommand)]
Forwarding(commands::forwarding::ForwardingCommand),
#[command(subcommand)]
Typegen(commands::typegen::TypegenCommand),
Completion(commands::completion::CompletionArgs),
Man,
}
#[derive(Subcommand, Debug)]
enum LogCommand {
Tail(commands::logs::LogTailArgs),
}
#[derive(Subcommand, Debug)]
enum FailuresCommand {
Tail(commands::logs::FailuresTailArgs),
}
#[derive(Subcommand, Debug)]
enum PeerCommand {
Ls(commands::peer::LsArgs),
}
#[derive(Subcommand, Debug)]
enum DaemonCommand {
Ls(commands::daemon::LsArgs),
}
fn no_color_from_env() -> bool {
std::env::var_os("NO_COLOR").is_some_and(|v| !v.is_empty())
}
#[tokio::main(flavor = "multi_thread")]
async fn main() -> ExitCode {
let mut cli = Cli::parse();
cli.no_color |= no_color_from_env();
config::set_insecure_permissions(cli.insecure_config_permissions);
install_tracing(cli.verbose, cli.quiet);
match dispatch(cli).await {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("net-mesh: {}", e);
ExitCode::from(e.code())
}
}
}
async fn dispatch(cli: Cli) -> Result<(), CliError> {
if let Some(timeout) = cli.timeout {
use commands::aggregator::AggregatorCommand;
use commands::transfer::TransferCommand;
use commands::typegen::TypegenCommand;
let supported = match &cli.command {
Command::Aggregator(AggregatorCommand::Query(a)) => !a.attach.inspect_target,
Command::Aggregator(AggregatorCommand::Spawn(a)) => !a.attach.inspect_target,
Command::Aggregator(AggregatorCommand::Scale(a)) => !a.attach.inspect_target,
Command::Aggregator(AggregatorCommand::Ls(a)) => {
!a.scope.local && !a.attach.inspect_target
}
Command::Transfer(TransferCommand::Ls(a)) => !a.attach.inspect_target,
Command::Transfer(TransferCommand::Status(a)) => !a.attach.inspect_target,
Command::Transfer(TransferCommand::Cancel(a)) => !a.attach.inspect_target,
Command::Transfer(TransferCommand::RecvBlob(a)) => !a.attach.inspect_target,
Command::Typegen(TypegenCommand::Generate(a)) => {
a.from_snapshot.is_none() && !a.attach.inspect_target
}
Command::Typegen(TypegenCommand::Snapshot(a)) => !a.attach.inspect_target,
Command::Mcp(commands::mcp::McpCommand::Serve(a)) => !a.remote.inspect_target,
Command::Wrap(a) => !a.remote.inspect_target,
_ => false,
};
if !supported {
return Err(error::invalid_args("--timeout is not supported for this command/mode; supported for remote aggregator ls/query/spawn/scale, transfer ls/status/cancel/recv-blob, live typegen acquisition and mcp serve/wrap startup. Remove --timeout to use the command's existing limits"));
}
let deadline = deadline::Deadline::after(timeout)?;
if matches!(
&cli.command,
Command::Typegen(_)
| Command::Transfer(TransferCommand::RecvBlob(_))
| Command::Mcp(commands::mcp::McpCommand::Serve(_))
| Command::Wrap(_)
) {
return Box::pin(dispatch_inner(cli, Some(deadline))).await;
}
return deadline.run(Box::pin(dispatch_inner(cli, None))).await;
}
Box::pin(dispatch_inner(cli, None)).await
}
async fn dispatch_inner(cli: Cli, deadline: Option<deadline::Deadline>) -> Result<(), CliError> {
let output = cli.output;
let config_path = cli.config.as_deref();
let profile = cli.profile.as_deref().unwrap_or("default");
if cli.config.is_some() || cli.profile.is_some() {
deadline::run_optional(deadline, context::resolve_profile(config_path, profile)).await?;
}
let quiet = cli.quiet;
match cli.command {
Command::Version => commands::version::run(output).await,
Command::Identity(cmd) => commands::identity::run(cmd, output, config_path, profile).await,
Command::Up(args) => {
Box::pin(commands::lifecycle::run_up(
args,
output,
config_path,
profile,
))
.await
}
Command::Down(args) => commands::lifecycle::run_down(args, output, profile).await,
Command::Enrollment(cmd) => {
commands::enrollment::run_enrollment(cmd, output, profile).await
}
Command::Invite(cmd) => commands::enrollment::run_invite(cmd, output, profile).await,
Command::Relay(cmd) => commands::relay::run(cmd, output).await,
Command::Join(args) => {
Box::pin(commands::enrollment::run_join(args, output, profile)).await
}
Command::Leave(args) => commands::lifecycle::run_leave(args, output, profile).await,
Command::Admin(cmd) => commands::admin::run(cmd, output, config_path, profile).await,
Command::Ice(cmd) => commands::ice::run(cmd, output, config_path, profile).await,
Command::Snapshot(cmd) => commands::snapshot::run(cmd, output, config_path, profile).await,
Command::Audit(cmd) => commands::audit::run(cmd, output, config_path, profile).await,
Command::Log(LogCommand::Tail(args)) => {
commands::logs::run_log_tail(args, output, config_path, profile).await
}
Command::Failures(FailuresCommand::Tail(args)) => {
commands::logs::run_failures_tail(args, output, config_path, profile).await
}
Command::Cap(cmd) => commands::cap::run(cmd, output, config_path, profile).await,
Command::Peer(PeerCommand::Ls(args)) => {
commands::peer::run_ls(args, output, config_path, profile).await
}
Command::Daemon(DaemonCommand::Ls(args)) => {
commands::daemon::run_ls(args, output, config_path, profile).await
}
Command::Netdb(cmd) => commands::netdb::run(cmd, output, config_path, profile).await,
Command::Org(cmd) => commands::org::run(cmd, output, config_path, profile).await,
Command::Node(cmd) => commands::node::run(cmd, output, config_path, profile).await,
Command::Anchor(cmd) => commands::anchor::run(cmd, output, config_path, profile).await,
Command::Subnet(cmd) => commands::subnet::run(cmd, output, config_path, profile).await,
Command::Gateway(cmd) => commands::gateway::run(cmd, output, config_path, profile).await,
Command::Channel(cmd) => commands::channel::run(cmd, output, config_path, profile).await,
Command::Aggregator(cmd) => {
commands::aggregator::run(cmd, output, config_path, profile).await
}
Command::Transfer(cmd) => {
Box::pin(commands::transfer::run(
cmd,
output,
config_path,
profile,
quiet,
deadline,
))
.await
}
Command::Wrap(args) => {
Box::pin(commands::wrap::run(
args,
output,
config_path,
profile,
deadline,
))
.await
}
Command::Mcp(cmd) => {
Box::pin(commands::mcp::run(
cmd,
output,
config_path,
profile,
deadline,
))
.await
}
Command::Forwarding(cmd) => {
commands::forwarding::run(cmd, output, config_path, profile).await
}
Command::Typegen(cmd) => {
Box::pin(commands::typegen::run(
cmd,
output,
config_path,
profile,
deadline,
))
.await
}
Command::Completion(args) => commands::completion::run::<Cli>(args),
Command::Man => commands::man::run::<Cli>(),
}
}
fn install_tracing(verbose: u8, quiet: bool) {
use tracing_subscriber::{fmt, EnvFilter};
let level = if quiet && verbose == 0 {
"error"
} else {
match verbose {
0 => "warn",
1 => "info",
2 => "debug",
_ => "trace",
}
};
let filter = EnvFilter::try_from_env("NET_MESH_LOG")
.unwrap_or_else(|_| EnvFilter::new(format!("net={level},net_sdk={level}")));
let _ = fmt()
.with_env_filter(filter)
.with_writer(std::io::stderr)
.compact()
.try_init();
}
mod humantime {
use std::time::Duration;
pub(crate) fn parse_duration(s: &str) -> Result<Duration, String> {
let s = s.trim();
if s.is_empty() {
return Err("empty duration".into());
}
if s.chars().all(|c| c.is_ascii_digit()) {
let value: u64 = s.parse().map_err(|_| format!("invalid integer {s:?}"))?;
return Ok(Duration::from_secs(value));
}
let mut total = Duration::ZERO;
let mut digits = String::new();
let mut units = String::new();
let mut between_components = false;
for c in s.chars() {
if c.is_ascii_digit() {
if !units.is_empty() {
total = total
.checked_add(apply_unit(&digits, &units)?)
.ok_or_else(|| format!("duration overflow in {s:?}"))?;
digits.clear();
units.clear();
}
digits.push(c);
between_components = false;
} else if c.is_alphabetic() {
if digits.is_empty() {
return Err(format!("unit {c:?} with no preceding number"));
}
units.push(c);
between_components = false;
} else if c.is_whitespace() {
if !units.is_empty() {
total = total
.checked_add(apply_unit(&digits, &units)?)
.ok_or_else(|| format!("duration overflow in {s:?}"))?;
digits.clear();
units.clear();
between_components = true;
} else if !between_components {
return Err(format!("unexpected whitespace inside duration {s:?}"));
}
} else {
return Err(format!("invalid character {c:?} in duration"));
}
}
if units.is_empty() {
return Err(format!("missing unit on trailing number in duration {s:?}"));
}
total
.checked_add(apply_unit(&digits, &units)?)
.ok_or_else(|| format!("duration overflow in {s:?}"))
}
fn apply_unit(digits: &str, unit: &str) -> Result<Duration, String> {
let value: u64 = digits
.parse()
.map_err(|_| format!("invalid numeric value {digits:?}"))?;
let secs = match unit {
"ns" => return Ok(Duration::from_nanos(value)),
"us" | "µs" => return Ok(Duration::from_micros(value)),
"ms" => return Ok(Duration::from_millis(value)),
"s" | "sec" | "secs" => value,
"m" | "min" | "mins" => value
.checked_mul(60)
.ok_or_else(|| format!("duration overflow at {value}{unit}"))?,
"h" | "hr" | "hrs" => value
.checked_mul(3600)
.ok_or_else(|| format!("duration overflow at {value}{unit}"))?,
"d" | "day" | "days" => value
.checked_mul(86_400)
.ok_or_else(|| format!("duration overflow at {value}{unit}"))?,
other => return Err(format!("unknown duration unit {other:?}")),
};
Ok(Duration::from_secs(secs))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bare_integer_is_seconds() {
assert_eq!(parse_duration("30").unwrap(), Duration::from_secs(30));
}
#[test]
fn unit_suffixes() {
assert_eq!(parse_duration("500ms").unwrap(), Duration::from_millis(500));
assert_eq!(parse_duration("2m").unwrap(), Duration::from_secs(120));
assert_eq!(parse_duration("1h").unwrap(), Duration::from_secs(3600));
}
#[test]
fn composite_units() {
assert_eq!(
parse_duration("1h30m").unwrap(),
Duration::from_secs(3600 + 30 * 60)
);
assert_eq!(
parse_duration("1h 30m").unwrap(),
Duration::from_secs(3600 + 30 * 60)
);
}
#[test]
fn rejects_empty_and_garbage() {
assert!(parse_duration("").is_err());
assert!(parse_duration("abc").is_err());
assert!(parse_duration("10x").is_err());
}
#[test]
fn rejects_trailing_unitless_number() {
assert!(parse_duration("1m5").is_err());
}
#[test]
fn rejects_whitespace_inside_a_number() {
assert!(parse_duration("10 5s").is_err());
}
#[test]
fn rejects_overflow_on_unit_multiplication() {
assert!(parse_duration("999999999999999d").is_err());
}
}
}