use std::path::PathBuf;
use std::process::ExitCode;
use cairn_mod::cli::{
error::{CliError, code},
login::{self, post_login_warning},
logout::{self, LogoutOutcome},
operator_login,
publish_service_record::{self, PublishOutcome},
report::{self, ReportCreateInput},
session,
};
use cairn_mod::config::Config;
use cairn_mod::{serve, storage};
use clap::{ArgAction, Args, Parser, Subcommand, ValueEnum};
use tracing_subscriber::{EnvFilter, fmt};
#[derive(Debug, Parser)]
#[command(name = "cairn", version, about = "Cairn labeler CLI")]
struct Cli {
#[arg(short = 'v', action = ArgAction::Count, global = true)]
verbose: u8,
#[command(subcommand)]
command: Command,
}
#[derive(Debug, Subcommand)]
enum Command {
Login(LoginArgs),
Logout,
Report {
#[command(subcommand)]
sub: ReportSub,
},
Serve(ServeArgs),
OperatorLogin(OperatorLoginArgs),
PublishServiceRecord(ServeArgs),
}
#[derive(Debug, Args)]
struct ServeArgs {
#[arg(long)]
config: Option<PathBuf>,
}
#[derive(Debug, Args)]
struct OperatorLoginArgs {
#[arg(long)]
pds: Option<String>,
#[arg(long)]
handle: String,
#[arg(long)]
config: Option<PathBuf>,
}
#[derive(Debug, Args)]
struct LoginArgs {
#[arg(long)]
cairn_server: String,
#[arg(long)]
pds: String,
#[arg(long)]
handle: String,
#[arg(long)]
cairn_did: Option<String>,
}
#[derive(Debug, Subcommand)]
enum ReportSub {
Create(ReportCreateArgs),
}
#[derive(Debug, Args)]
struct ReportCreateArgs {
#[arg(long)]
subject: String,
#[arg(long)]
cid: Option<String>,
#[arg(long = "reason-type", value_enum)]
reason_type: ReasonTypeArg,
#[arg(long)]
reason: Option<String>,
#[arg(long = "cairn-server")]
cairn_server: Option<String>,
#[arg(long)]
json: bool,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
enum ReasonTypeArg {
Spam,
Violation,
Misleading,
Sexual,
Rude,
Other,
}
impl ReasonTypeArg {
fn as_lexicon(self) -> &'static str {
match self {
ReasonTypeArg::Spam => "com.atproto.moderation.defs#reasonSpam",
ReasonTypeArg::Violation => "com.atproto.moderation.defs#reasonViolation",
ReasonTypeArg::Misleading => "com.atproto.moderation.defs#reasonMisleading",
ReasonTypeArg::Sexual => "com.atproto.moderation.defs#reasonSexual",
ReasonTypeArg::Rude => "com.atproto.moderation.defs#reasonRude",
ReasonTypeArg::Other => "com.atproto.moderation.defs#reasonOther",
}
}
}
fn main() -> ExitCode {
let cli = Cli::parse();
init_tracing(cli.verbose);
let runtime = tokio::runtime::Runtime::new().expect("tokio runtime");
let result = runtime.block_on(dispatch(cli.command));
match result {
Ok(()) => ExitCode::from(code::SUCCESS as u8),
Err(e) => {
eprintln!("error: {e}");
ExitCode::from(e.exit_code() as u8)
}
}
}
fn init_tracing(verbosity: u8) {
let level = match verbosity {
0 => "warn",
1 => "info",
_ => "debug",
};
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(level));
fmt()
.with_env_filter(filter)
.with_writer(std::io::stderr)
.init();
}
async fn dispatch(cmd: Command) -> Result<(), CliError> {
match cmd {
Command::Login(args) => run_login(args).await,
Command::Logout => run_logout().await,
Command::Report {
sub: ReportSub::Create(args),
} => run_report_create(args).await,
Command::Serve(args) => run_serve(args).await,
Command::OperatorLogin(args) => run_operator_login(args).await,
Command::PublishServiceRecord(args) => run_publish_service_record(args).await,
}
}
async fn run_login(args: LoginArgs) -> Result<(), CliError> {
let password = rpassword::prompt_password(format!(
"App password for {} at {}: ",
args.handle, args.pds
))
.map_err(|e| CliError::Config(format!("could not read app password (no TTY?): {e}")))?;
if password.is_empty() {
return Err(CliError::Config("app password was empty".into()));
}
let path = session_path()?;
let session = login::login(
&args.cairn_server,
&args.pds,
&args.handle,
&password,
args.cairn_did.as_deref(),
&path,
)
.await?;
println!("{}", post_login_warning(&session, &path));
Ok(())
}
async fn run_logout() -> Result<(), CliError> {
let path = session_path()?;
match logout::logout(&path).await? {
LogoutOutcome::NotLoggedIn => println!("not logged in"),
LogoutOutcome::RevokedAndRemoved => println!("Logged out. Session file removed."),
LogoutOutcome::RemovedLocalOnlyPdsFailed => println!(
"Session file removed. PDS deleteSession failed — re-check with your PDS; rerun logout is harmless.",
),
}
Ok(())
}
async fn run_report_create(args: ReportCreateArgs) -> Result<(), CliError> {
let path = session_path()?;
let mut session = session::SessionFile::load(&path)?.ok_or(CliError::NotLoggedIn)?;
let input = ReportCreateInput {
subject: args.subject,
cid: args.cid,
reason_type: args.reason_type.as_lexicon().to_string(),
reason: args.reason,
cairn_server_override: args.cairn_server,
};
let resp = report::create(&mut session, &path, input).await?;
if args.json {
println!("{}", report::format_json(&resp));
} else {
println!("{}", report::format_human(&resp));
}
Ok(())
}
fn session_path() -> Result<PathBuf, CliError> {
Ok(session::default_path()?)
}
async fn run_operator_login(args: OperatorLoginArgs) -> Result<(), CliError> {
let config = load_config(args.config.as_deref())?;
let operator_cfg = config
.operator
.as_ref()
.ok_or_else(|| CliError::Config("missing [operator] section in config".into()))?;
let pds_url = args.pds.as_deref().unwrap_or(&operator_cfg.pds_url);
let password = rpassword::prompt_password(format!(
"Operator app password for {} at {}: ",
args.handle, pds_url
))
.map_err(|e| CliError::Config(format!("could not read app password (no TTY?): {e}")))?;
if password.is_empty() {
return Err(CliError::Config("app password was empty".into()));
}
let session =
operator_login::login(pds_url, &args.handle, &password, &operator_cfg.session_path).await?;
println!(
"{}",
operator_login::post_login_warning(&session, &operator_cfg.session_path)
);
Ok(())
}
async fn run_publish_service_record(args: ServeArgs) -> Result<(), CliError> {
let config = load_config(args.config.as_deref())?;
let operator_cfg = config
.operator
.as_ref()
.ok_or_else(|| CliError::Config("missing [operator] section in config".into()))?
.clone();
let pool = storage::open(&config.db_path)
.await
.map_err(|e| CliError::MigrationFailed(e.to_string()))?;
let outcome =
publish_service_record::publish(&pool, &config, &operator_cfg.session_path).await?;
match outcome {
PublishOutcome::NoChange => {
println!("service record already up to date; no publish needed");
}
PublishOutcome::Published { cid, created_at } => {
println!("published service record: cid={cid}, createdAt={created_at}");
}
}
Ok(())
}
fn load_config(explicit_path: Option<&std::path::Path>) -> Result<Config, CliError> {
match explicit_path {
Some(p) => Config::load_from(Some(p)),
None => Config::load(),
}
.map_err(|e| CliError::Config(e.to_string()))
}
async fn run_serve(args: ServeArgs) -> Result<(), CliError> {
let config = load_config(args.config.as_deref())?;
serve::run(config, shutdown_signal()).await
}
async fn shutdown_signal() {
tracing::debug!("shutdown_signal: entered, awaiting SIGINT or SIGTERM");
#[cfg(unix)]
{
use tokio::signal::unix::{SignalKind, signal};
let mut term = signal(SignalKind::terminate()).expect("install SIGTERM handler");
tokio::select! {
_ = tokio::signal::ctrl_c() => {
tracing::debug!("shutdown_signal: SIGINT (ctrl_c) fired");
}
_ = term.recv() => {
tracing::debug!("shutdown_signal: SIGTERM (term.recv()) fired");
}
}
}
#[cfg(not(unix))]
{
let _ = tokio::signal::ctrl_c().await;
tracing::debug!("shutdown_signal: ctrl_c fired (non-unix path)");
}
}