use std::path::PathBuf;
use std::process::ExitCode;
use cairn_mod::cli::{
audit, audit_rebuild, audit_verify,
error::{CliError, code},
login::{self, post_login_warning},
logout::{self, LogoutOutcome},
moderator, operator_login,
publish_service_record::{self, PublishOutcome},
report::{self, ReportCreateInput},
retention, session, trust_chain,
unpublish_service_record::{self, UnpublishOutcome},
};
use cairn_mod::config::Config;
use cairn_mod::moderators::Role;
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),
UnpublishServiceRecord(ServeArgs),
Moderator {
#[command(subcommand)]
sub: ModeratorSub,
},
Audit {
#[command(subcommand)]
sub: AuditSub,
},
Retention {
#[command(subcommand)]
sub: RetentionSub,
},
#[command(name = "trust-chain")]
TrustChain {
#[command(subcommand)]
sub: TrustChainSub,
},
#[command(name = "audit-rebuild")]
AuditRebuild(AuditRebuildArgs),
}
#[derive(Debug, Args)]
struct AuditRebuildArgs {
#[arg(long)]
config: Option<PathBuf>,
#[arg(long)]
json: bool,
}
#[derive(Debug, Subcommand)]
enum TrustChainSub {
Show(TrustChainShowArgs),
}
#[derive(Debug, Args)]
struct TrustChainShowArgs {
#[arg(long = "cairn-server")]
cairn_server: Option<String>,
#[arg(long)]
json: bool,
}
#[derive(Debug, Subcommand)]
enum RetentionSub {
Sweep(RetentionSweepArgs),
}
#[derive(Debug, Args)]
struct RetentionSweepArgs {
#[arg(long)]
cairn_server: Option<String>,
#[arg(long)]
json: bool,
}
#[derive(Debug, Subcommand)]
enum AuditSub {
List(AuditListArgs),
Show(AuditShowArgs),
Verify(AuditVerifyArgs),
}
#[derive(Debug, Args)]
struct AuditShowArgs {
id: i64,
#[arg(long = "cairn-server")]
cairn_server: Option<String>,
#[arg(long)]
json: bool,
}
#[derive(Debug, Args)]
struct AuditVerifyArgs {
#[arg(long)]
config: Option<PathBuf>,
#[arg(long)]
json: bool,
}
#[derive(Debug, Args)]
struct AuditListArgs {
#[arg(long)]
actor: Option<String>,
#[arg(long)]
action: Option<String>,
#[arg(long)]
outcome: Option<String>,
#[arg(long)]
since: Option<String>,
#[arg(long)]
until: Option<String>,
#[arg(long)]
limit: Option<i64>,
#[arg(long)]
cursor: Option<String>,
#[arg(long = "cairn-server")]
cairn_server: Option<String>,
#[arg(long)]
json: bool,
}
#[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),
List(ReportListArgs),
View(ReportViewArgs),
Resolve(ReportResolveArgs),
Flag(ReportFlagArgs),
Unflag(ReportFlagArgs),
}
#[derive(Debug, Args)]
struct ReportListArgs {
#[arg(long)]
status: Option<String>,
#[arg(long = "reported-by")]
reported_by: Option<String>,
#[arg(long)]
limit: Option<i64>,
#[arg(long)]
cursor: Option<String>,
#[arg(long = "cairn-server")]
cairn_server: Option<String>,
#[arg(long)]
json: bool,
}
#[derive(Debug, Args)]
struct ReportViewArgs {
id: i64,
#[arg(long = "cairn-server")]
cairn_server: Option<String>,
#[arg(long)]
json: bool,
}
#[derive(Debug, Args)]
struct ReportFlagArgs {
did: String,
#[arg(long)]
reason: Option<String>,
#[arg(long = "cairn-server")]
cairn_server: Option<String>,
#[arg(long)]
json: bool,
}
#[derive(Debug, Args)]
struct ReportResolveArgs {
id: i64,
#[arg(long)]
reason: Option<String>,
#[arg(long = "apply-label-val", requires = "apply_label_uri")]
apply_label_val: Option<String>,
#[arg(long = "apply-label-uri", requires = "apply_label_val")]
apply_label_uri: Option<String>,
#[arg(long = "apply-label-cid", requires = "apply_label_val")]
apply_label_cid: Option<String>,
#[arg(long = "apply-label-exp", requires = "apply_label_val")]
apply_label_exp: Option<String>,
#[arg(long = "cairn-server")]
cairn_server: Option<String>,
#[arg(long)]
json: bool,
}
#[derive(Debug, Subcommand)]
enum ModeratorSub {
Add(ModeratorAddArgs),
Remove(ModeratorRemoveArgs),
List(ModeratorListArgs),
}
#[derive(Debug, Args)]
struct ModeratorAddArgs {
did: String,
#[arg(long, value_enum)]
role: RoleArg,
#[arg(long)]
update_role: bool,
#[arg(long)]
json: bool,
#[arg(long)]
config: Option<PathBuf>,
}
#[derive(Debug, Args)]
struct ModeratorRemoveArgs {
did: String,
#[arg(long)]
force: bool,
#[arg(long)]
json: bool,
#[arg(long)]
config: Option<PathBuf>,
}
#[derive(Debug, Args)]
struct ModeratorListArgs {
#[arg(long, value_enum)]
role: Option<RoleArg>,
#[arg(long)]
json: bool,
#[arg(long)]
config: Option<PathBuf>,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
enum RoleArg {
Mod,
Admin,
}
impl From<RoleArg> for Role {
fn from(r: RoleArg) -> Role {
match r {
RoleArg::Mod => Role::Mod,
RoleArg::Admin => Role::Admin,
}
}
}
#[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::Report {
sub: ReportSub::List(args),
} => run_report_list(args).await,
Command::Report {
sub: ReportSub::View(args),
} => run_report_view(args).await,
Command::Report {
sub: ReportSub::Resolve(args),
} => run_report_resolve(args).await,
Command::Report {
sub: ReportSub::Flag(args),
} => run_report_flag(args, true).await,
Command::Report {
sub: ReportSub::Unflag(args),
} => run_report_flag(args, false).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,
Command::UnpublishServiceRecord(args) => run_unpublish_service_record(args).await,
Command::Moderator {
sub: ModeratorSub::Add(args),
} => run_moderator_add(args).await,
Command::Moderator {
sub: ModeratorSub::Remove(args),
} => run_moderator_remove(args).await,
Command::Moderator {
sub: ModeratorSub::List(args),
} => run_moderator_list(args).await,
Command::Audit {
sub: AuditSub::List(args),
} => run_audit_list(args).await,
Command::Audit {
sub: AuditSub::Show(args),
} => run_audit_show(args).await,
Command::Audit {
sub: AuditSub::Verify(args),
} => run_audit_verify(args).await,
Command::Retention {
sub: RetentionSub::Sweep(args),
} => run_retention_sweep(args).await,
Command::TrustChain {
sub: TrustChainSub::Show(args),
} => run_trust_chain_show(args).await,
Command::AuditRebuild(args) => run_audit_rebuild(args).await,
}
}
async fn run_audit_rebuild(args: AuditRebuildArgs) -> Result<(), CliError> {
let pool = open_pool_from_config(args.config.as_ref()).await?;
let outcome = audit_rebuild::rebuild(&pool).await?;
if args.json {
println!("{}", audit_rebuild::format_json(&outcome));
} else {
println!("{}", audit_rebuild::format_human(&outcome));
}
Ok(())
}
async fn run_audit_verify(args: AuditVerifyArgs) -> Result<(), CliError> {
let pool = open_pool_from_config(args.config.as_ref()).await?;
let outcome = audit_verify::verify(&pool).await?;
if args.json {
println!("{}", audit_verify::format_json(&outcome));
} else {
println!("{}", audit_verify::format_human(&outcome));
}
match outcome {
audit_verify::VerifyOutcome::Divergence {
row_id,
expected_hash,
actual_hash,
attested_rows_before_divergence,
} => Err(CliError::AuditDivergence {
row_id,
expected_hash,
actual_hash,
attested_rows_before_divergence,
}),
_ => Ok(()),
}
}
async fn run_retention_sweep(args: RetentionSweepArgs) -> Result<(), CliError> {
let path = session_path()?;
let mut session = session::SessionFile::load(&path)?.ok_or(CliError::NotLoggedIn)?;
let input = retention::SweepInput {
cairn_server_override: args.cairn_server,
};
let resp = retention::sweep(&mut session, &path, input).await?;
if args.json {
println!("{}", retention::format_sweep_json(&resp));
} else {
println!("{}", retention::format_sweep_human(&resp));
}
Ok(())
}
async fn run_trust_chain_show(args: TrustChainShowArgs) -> Result<(), CliError> {
let path = session_path()?;
let mut session = session::SessionFile::load(&path)?.ok_or(CliError::NotLoggedIn)?;
let input = trust_chain::TrustChainShowInput {
cairn_server_override: args.cairn_server,
};
let resp = trust_chain::show(&mut session, &path, input).await?;
if args.json {
println!("{}", trust_chain::format_show_json(&resp));
} else {
println!("{}", trust_chain::format_show_human(&resp));
}
Ok(())
}
async fn run_audit_show(args: AuditShowArgs) -> Result<(), CliError> {
let path = session_path()?;
let mut session = session::SessionFile::load(&path)?.ok_or(CliError::NotLoggedIn)?;
let input = audit::AuditShowInput {
id: args.id,
cairn_server_override: args.cairn_server,
};
let entry = audit::show(&mut session, &path, input).await?;
if args.json {
println!("{}", audit::format_show_json(&entry));
} else {
println!("{}", audit::format_show_human(&entry));
}
Ok(())
}
async fn run_audit_list(args: AuditListArgs) -> Result<(), CliError> {
let path = session_path()?;
let mut session = session::SessionFile::load(&path)?.ok_or(CliError::NotLoggedIn)?;
let input = audit::AuditListInput {
actor: args.actor,
action: args.action,
outcome: args.outcome,
since: args.since,
until: args.until,
limit: args.limit,
cursor: args.cursor,
cairn_server_override: args.cairn_server,
};
let resp = audit::list(&mut session, &path, input).await?;
if args.json {
println!("{}", audit::format_list_json(&resp));
} else {
println!("{}", audit::format_list_human(&resp));
}
Ok(())
}
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_create_json(&resp));
} else {
println!("{}", report::format_create_human(&resp));
}
Ok(())
}
async fn run_report_list(args: ReportListArgs) -> Result<(), CliError> {
let path = session_path()?;
let mut session = session::SessionFile::load(&path)?.ok_or(CliError::NotLoggedIn)?;
let input = report::ReportListInput {
status: args.status,
reported_by: args.reported_by,
limit: args.limit,
cursor: args.cursor,
cairn_server_override: args.cairn_server,
};
let resp = report::list(&mut session, &path, input).await?;
if args.json {
println!("{}", report::format_list_json(&resp));
} else {
println!("{}", report::format_list_human(&resp));
}
Ok(())
}
async fn run_report_view(args: ReportViewArgs) -> Result<(), CliError> {
let path = session_path()?;
let mut session = session::SessionFile::load(&path)?.ok_or(CliError::NotLoggedIn)?;
let input = report::ReportViewInput {
id: args.id,
cairn_server_override: args.cairn_server,
};
let resp = report::view(&mut session, &path, input).await?;
if args.json {
println!("{}", report::format_view_json(&resp));
} else {
println!("{}", report::format_view_human(&resp));
}
Ok(())
}
async fn run_report_flag(args: ReportFlagArgs, suppressed: bool) -> Result<(), CliError> {
let path = session_path()?;
let mut session = session::SessionFile::load(&path)?.ok_or(CliError::NotLoggedIn)?;
let input = report::ReportFlagInput {
did: args.did,
suppressed,
reason: args.reason,
cairn_server_override: args.cairn_server,
};
let resp = report::flag(&mut session, &path, input).await?;
if args.json {
println!("{}", report::format_flag_json(&resp));
} else {
println!("{}", report::format_flag_human(&resp));
}
Ok(())
}
async fn run_report_resolve(args: ReportResolveArgs) -> Result<(), CliError> {
let path = session_path()?;
let mut session = session::SessionFile::load(&path)?.ok_or(CliError::NotLoggedIn)?;
let apply_label = match (args.apply_label_val, args.apply_label_uri) {
(Some(val), Some(uri)) => Some(report::ApplyLabelArg {
uri,
cid: args.apply_label_cid,
val,
exp: args.apply_label_exp,
}),
(None, None) => None,
_ => unreachable!("clap requires should pair --apply-label-val and --apply-label-uri"),
};
let input = report::ReportResolveInput {
id: args.id,
apply_label,
reason: args.reason,
cairn_server_override: args.cairn_server,
};
let resp = report::resolve(&mut session, &path, input).await?;
if args.json {
println!("{}", report::format_resolve_json(&resp));
} else {
println!("{}", report::format_resolve_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(())
}
async fn run_unpublish_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 =
unpublish_service_record::unpublish(&pool, &config, &operator_cfg.session_path).await?;
match outcome {
UnpublishOutcome::NoChange => {
println!("no service record published; nothing to unpublish");
}
UnpublishOutcome::Unpublished { cid } => {
println!("unpublished service record: cid={cid}");
}
}
Ok(())
}
async fn open_pool_from_config(
config: Option<&PathBuf>,
) -> Result<sqlx::Pool<sqlx::Sqlite>, CliError> {
let cfg = load_config(config.map(PathBuf::as_path))?;
storage::open(&cfg.db_path)
.await
.map_err(|e| CliError::MigrationFailed(e.to_string()))
}
async fn run_moderator_add(args: ModeratorAddArgs) -> Result<(), CliError> {
let pool = open_pool_from_config(args.config.as_ref()).await?;
let json = args.json;
let result = moderator::add(
&pool,
moderator::AddInput {
did: args.did,
role: args.role.into(),
update_role: args.update_role,
},
)
.await?;
if json {
println!("{}", moderator::format_add_json(&result));
} else {
println!("{}", moderator::format_add_human(&result));
}
Ok(())
}
async fn run_moderator_remove(args: ModeratorRemoveArgs) -> Result<(), CliError> {
let pool = open_pool_from_config(args.config.as_ref()).await?;
let json = args.json;
let result = moderator::remove(
&pool,
moderator::RemoveInput {
did: args.did,
force: args.force,
},
)
.await?;
if json {
println!("{}", moderator::format_remove_json(&result));
} else {
println!("{}", moderator::format_remove_human(&result));
}
Ok(())
}
async fn run_moderator_list(args: ModeratorListArgs) -> Result<(), CliError> {
let pool = open_pool_from_config(args.config.as_ref()).await?;
let mods = moderator::list(
&pool,
moderator::ListInput {
role: args.role.map(Into::into),
},
)
.await?;
if args.json {
println!("{}", moderator::format_list_json(&mods));
} else {
println!("{}", moderator::format_list_human(&mods));
}
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)");
}
}