mod agents;
mod clipboard;
mod cloudflared;
mod os;
mod proxy;
mod share;
mod state;
mod tui;
mod tunnel;
mod widget;
use std::io::{IsTerminal, Write};
use std::sync::mpsc;
use std::time::Duration;
use clap::{Parser, Subcommand};
use tokio::sync::watch;
use share::Event;
const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(3);
const EXIT_USAGE: i32 = 2;
const AFTER_HELP: &str = r#"Examples:
bunflared 5173 share one app
bunflared 5173 3000 app at /, its API at /_port/3000
bunflared 5173 3000 --detach print the ready line, keep sharing in the background
bunflared ls [--json] list live shares
bunflared down <id> | --all stop shares
bunflared agents teach your coding agents to use bunflared
In a terminal you get the animated dashboard. Otherwise, or with --json, the
ready line is one JSON object on stdout:
{"id":"4242","pid":4242,"tunnel_pid":4243,"url":"https://....trycloudflare.com",
"routes":{"/":5173,"/_port/3000":3000},"started_at":1790000000}
and a failure is one JSON object on stderr: {"error":"...","code":N}.
cloudflared is fetched from Cloudflare's releases on the first run when it is
not installed.
Exit codes: 0 ok, 1 tunnel closed, 2 bad arguments, 3 cloudflared missing and
not downloadable, 4 a port is not answering, 5 ~/.cloudflared/config.yaml blocks quick tunnels,
6 the tunnel failed to start.
Limits: anyone with the link reaches your app. Quick tunnels allow 200
requests in flight and do not carry Server-Sent Events."#;
#[derive(Parser)]
#[command(
version,
about = "Share local ports on a temporary public https link.",
after_help = AFTER_HELP,
args_conflicts_with_subcommands = true,
subcommand_negates_reqs = true
)]
struct Cli {
#[command(subcommand)]
command: Option<Command>,
#[arg(required = true, value_parser = clap::value_parser!(u16).range(1..))]
ports: Vec<u16>,
#[arg(long)]
json: bool,
#[arg(long)]
detach: bool,
#[arg(long)]
calm: bool,
#[arg(long)]
no_widget: bool,
#[arg(long, value_enum, default_value_t = ThemeChoice::Auto)]
theme: ThemeChoice,
}
#[derive(Clone, Copy, clap::ValueEnum)]
enum ThemeChoice {
Auto,
Light,
Dark,
}
#[derive(Subcommand)]
enum Command {
Ls {
#[arg(long)]
json: bool,
},
Agents {
#[arg(long)]
print: bool,
#[arg(long, conflicts_with = "print")]
remove: bool,
},
Down {
#[arg(required_unless_present = "all")]
id: Option<String>,
#[arg(long)]
all: bool,
},
}
fn main() {
let default_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
if std::thread::current().name() == Some("main") {
tunnel::kill_orphan();
}
default_hook(info);
}));
let cli = match Cli::try_parse() {
Ok(cli) => cli,
Err(err) if err.use_stderr() && machine_output() => {
let message = err.to_string();
let summary = message.split("\n\n").next().unwrap_or("bad arguments");
let summary = summary.split_whitespace().collect::<Vec<_>>().join(" ");
let json = serde_json::json!({ "error": summary.trim_start_matches("error: "), "code": EXIT_USAGE });
eprintln!("{json}");
std::process::exit(EXIT_USAGE);
}
Err(err) => err.exit(),
};
let code = match cli.command {
Some(Command::Ls { json }) => state::list(json),
Some(Command::Down { id, all }) => state::down(id, all),
Some(Command::Agents { print, remove }) => agents::run(print, remove),
None if cli.detach => state::detach(&cli.ports, cli.no_widget),
None => {
let no_color = std::env::var_os("NO_COLOR").is_some_and(|v| !v.is_empty());
let interactive = !cli.json && std::io::stdout().is_terminal();
let theme = interactive.then(|| tui::Theme {
calm: cli.calm || no_color,
color: !no_color,
light: match cli.theme {
ThemeChoice::Light => true,
ThemeChoice::Dark => false,
ThemeChoice::Auto => tui::light_terminal(),
},
});
let feedback = (!cli.no_widget)
.then(|| std::env::current_dir().ok())
.flatten()
.map(|dir| dir.join(widget::FOLDER));
share(cli.ports, theme, feedback)
}
};
std::process::exit(code);
}
fn machine_output() -> bool {
std::env::args().any(|arg| arg == "--json" || arg == "--detach")
|| !std::io::stdout().is_terminal()
}
fn share(ports: Vec<u16>, theme: Option<tui::Theme>, feedback: Option<std::path::PathBuf>) -> i32 {
let runtime = tokio::runtime::Runtime::new().expect("tokio runtime");
let (tx, rx) = mpsc::channel();
let (stop, stop_rx) = watch::channel(false);
let shared = ports.clone();
let backend = runtime.spawn(async move {
let result = share::run(&shared, &tx, stop_rx, feedback).await;
let _ = tx.send(Event::Done(result));
});
let code = match theme {
Some(theme) => tui::run(rx, &stop, &ports, theme),
None => print_json(rx),
};
let _ = stop.send(true);
runtime.block_on(async {
let _ = tokio::time::timeout(SHUTDOWN_TIMEOUT, backend).await;
});
code
}
fn print_json(rx: mpsc::Receiver<Event>) -> i32 {
for event in rx {
match event {
Event::Ready { record, .. } => {
let mut out = std::io::stdout();
let _ = writeln!(
out,
"{}",
serde_json::to_string(&record).unwrap_or_default()
);
let _ = out.flush();
}
Event::Done(Ok(())) => return 0,
Event::Done(Err(failure)) => {
let json = serde_json::json!({ "error": failure.message, "code": failure.code });
let _ = writeln!(std::io::stderr(), "{json}");
return failure.code;
}
_ => {}
}
}
0
}