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, moderator_action, moderator_events, moderator_pending, operator_login,
pds_admin as cli_pds_admin,
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),
#[command(name = "pds-admin")]
PdsAdmin {
#[command(subcommand)]
sub: PdsAdminSub,
},
#[command(name = "xrpc-callers")]
XrpcCallers {
#[command(subcommand)]
sub: XrpcMembershipSub,
},
#[command(name = "xrpc-pdses")]
XrpcPdses {
#[command(subcommand)]
sub: XrpcMembershipSub,
},
}
#[derive(Debug, Subcommand)]
enum PdsAdminSub {
Takedown(PdsAdminTakedownArgs),
Suspend(PdsAdminSuspendArgs),
Restore(PdsAdminRestoreArgs),
}
#[derive(Debug, Args)]
struct PdsAdminTakedownArgs {
did: String,
#[arg(long)]
reason: Option<String>,
#[arg(long)]
notes: Option<String>,
#[arg(long)]
config: Option<PathBuf>,
#[arg(long = "cairn-server")]
cairn_server: Option<String>,
#[arg(long)]
json: bool,
}
#[derive(Debug, Args)]
struct PdsAdminSuspendArgs {
did: String,
#[arg(long)]
reason: Option<String>,
#[arg(long)]
duration: Option<String>,
#[arg(long)]
notes: Option<String>,
#[arg(long)]
config: Option<PathBuf>,
#[arg(long = "cairn-server")]
cairn_server: Option<String>,
#[arg(long)]
json: bool,
}
#[derive(Debug, Args)]
struct PdsAdminRestoreArgs {
did: String,
#[arg(long)]
reason: Option<String>,
#[arg(long)]
config: Option<PathBuf>,
#[arg(long = "cairn-server")]
cairn_server: Option<String>,
#[arg(long)]
json: bool,
}
#[derive(Debug, Subcommand)]
enum XrpcMembershipSub {
Add(XrpcMembershipAddArgs),
Revoke(XrpcMembershipRevokeArgs),
List(XrpcMembershipListArgs),
}
#[derive(Debug, Args)]
struct XrpcMembershipAddArgs {
did: String,
#[arg(long)]
note: Option<String>,
#[arg(long)]
by: String,
#[arg(long)]
config: Option<PathBuf>,
}
#[derive(Debug, Args)]
struct XrpcMembershipRevokeArgs {
did: String,
#[arg(long)]
by: String,
#[arg(long)]
config: Option<PathBuf>,
}
#[derive(Debug, Args)]
struct XrpcMembershipListArgs {
#[arg(long)]
include_revoked: bool,
#[arg(long)]
config: Option<PathBuf>,
}
#[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),
Action(ModeratorActionArgs),
Warn(ModeratorWarnArgs),
Note(ModeratorNoteArgs),
Revoke(ModeratorRevokeArgs),
History(ModeratorHistoryArgs),
Strikes(ModeratorStrikesArgs),
Labels(ModeratorLabelsArgs),
Pending(ModeratorPendingArgs),
Events(ModeratorEventsArgs),
}
#[derive(Debug, Args)]
struct ModeratorEventsArgs {
#[arg(long)]
subject: Option<String>,
#[arg(long)]
actor: Option<String>,
#[arg(long = "type")]
action_type: Option<String>,
#[arg(long)]
from: Option<String>,
#[arg(long)]
to: Option<String>,
#[arg(long)]
limit: Option<u32>,
#[arg(long)]
cursor: Option<String>,
#[arg(long = "ozone-only")]
ozone_only: bool,
#[arg(long)]
config: Option<PathBuf>,
#[arg(long)]
json: bool,
}
#[derive(Debug, Args)]
struct ModeratorPendingArgs {
#[command(subcommand)]
sub: ModeratorPendingSub,
}
#[derive(Debug, Subcommand)]
enum ModeratorPendingSub {
List(ModeratorPendingListArgs),
View(ModeratorPendingViewArgs),
Confirm(ModeratorPendingConfirmArgs),
Dismiss(ModeratorPendingDismissArgs),
}
#[derive(Debug, Args)]
struct ModeratorPendingListArgs {
#[arg(long)]
subject: 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 ModeratorPendingViewArgs {
pending_id: i64,
#[arg(long = "cairn-server")]
cairn_server: Option<String>,
#[arg(long)]
json: bool,
}
#[derive(Debug, Args)]
struct ModeratorPendingConfirmArgs {
pending_id: i64,
#[arg(long)]
reason: Option<String>,
#[arg(long = "cairn-server")]
cairn_server: Option<String>,
#[arg(long)]
json: bool,
}
#[derive(Debug, Args)]
struct ModeratorPendingDismissArgs {
pending_id: i64,
#[arg(long)]
reason: Option<String>,
#[arg(long = "cairn-server")]
cairn_server: Option<String>,
#[arg(long)]
json: bool,
}
#[derive(Debug, Args)]
struct ModeratorHistoryArgs {
subject: String,
#[arg(long = "subject-uri")]
subject_uri: Option<String>,
#[arg(long = "no-include-revoked")]
no_include_revoked: bool,
#[arg(long)]
since: 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 ModeratorStrikesArgs {
subject: String,
#[arg(long = "cairn-server")]
cairn_server: Option<String>,
#[arg(long)]
json: bool,
}
#[derive(Debug, Args)]
struct ModeratorLabelsArgs {
subject: String,
#[arg(long = "cairn-server")]
cairn_server: Option<String>,
#[arg(long)]
json: bool,
}
#[derive(Debug, Args)]
struct ModeratorActionArgs {
subject: String,
#[arg(long = "type", value_enum)]
action_type: ActionTypeArg,
#[arg(long = "reason", action = ArgAction::Append, num_args = 1)]
reason: Vec<String>,
#[arg(long)]
duration: Option<String>,
#[arg(long)]
note: Option<String>,
#[arg(long = "report", action = ArgAction::Append, num_args = 1)]
report: Vec<i64>,
#[arg(long = "cairn-server")]
cairn_server: Option<String>,
#[arg(long)]
json: bool,
}
#[derive(Debug, Args)]
struct ModeratorWarnArgs {
subject: String,
#[arg(long = "reason", action = ArgAction::Append, num_args = 1)]
reason: Vec<String>,
#[arg(long)]
note: Option<String>,
#[arg(long = "cairn-server")]
cairn_server: Option<String>,
#[arg(long)]
json: bool,
}
#[derive(Debug, Args)]
struct ModeratorNoteArgs {
subject: String,
text: String,
#[arg(long = "cairn-server")]
cairn_server: Option<String>,
#[arg(long)]
json: bool,
}
#[derive(Debug, Args)]
struct ModeratorRevokeArgs {
action_id: i64,
#[arg(long)]
reason: Option<String>,
#[arg(long = "cairn-server")]
cairn_server: Option<String>,
#[arg(long)]
json: bool,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
enum ActionTypeArg {
Warning,
Note,
TempSuspension,
IndefSuspension,
Takedown,
}
impl ActionTypeArg {
fn as_db_str(self) -> &'static str {
match self {
ActionTypeArg::Warning => "warning",
ActionTypeArg::Note => "note",
ActionTypeArg::TempSuspension => "temp_suspension",
ActionTypeArg::IndefSuspension => "indef_suspension",
ActionTypeArg::Takedown => "takedown",
}
}
}
#[derive(Debug, Args)]
struct ModeratorAddArgs {
did: String,
#[arg(long, value_enum)]
role: RoleArg,
#[arg(long)]
update_role: bool,
#[arg(long, requires = "by")]
with_xrpc_callers: bool,
#[arg(long)]
by: Option<String>,
#[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::Moderator {
sub: ModeratorSub::Action(args),
} => run_moderator_action(args).await,
Command::Moderator {
sub: ModeratorSub::Warn(args),
} => run_moderator_warn(args).await,
Command::Moderator {
sub: ModeratorSub::Note(args),
} => run_moderator_note(args).await,
Command::Moderator {
sub: ModeratorSub::Revoke(args),
} => run_moderator_revoke(args).await,
Command::Moderator {
sub: ModeratorSub::History(args),
} => run_moderator_history(args).await,
Command::Moderator {
sub: ModeratorSub::Strikes(args),
} => run_moderator_strikes(args).await,
Command::Moderator {
sub: ModeratorSub::Labels(args),
} => run_moderator_labels(args).await,
Command::Moderator {
sub:
ModeratorSub::Pending(ModeratorPendingArgs {
sub: ModeratorPendingSub::List(args),
}),
} => run_moderator_pending_list(args).await,
Command::Moderator {
sub:
ModeratorSub::Pending(ModeratorPendingArgs {
sub: ModeratorPendingSub::View(args),
}),
} => run_moderator_pending_view(args).await,
Command::Moderator {
sub:
ModeratorSub::Pending(ModeratorPendingArgs {
sub: ModeratorPendingSub::Confirm(args),
}),
} => run_moderator_pending_confirm(args).await,
Command::Moderator {
sub:
ModeratorSub::Pending(ModeratorPendingArgs {
sub: ModeratorPendingSub::Dismiss(args),
}),
} => run_moderator_pending_dismiss(args).await,
Command::Moderator {
sub: ModeratorSub::Events(args),
} => run_moderator_events(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,
Command::PdsAdmin {
sub: PdsAdminSub::Takedown(args),
} => run_pds_admin_takedown(args).await,
Command::PdsAdmin {
sub: PdsAdminSub::Suspend(args),
} => run_pds_admin_suspend(args).await,
Command::PdsAdmin {
sub: PdsAdminSub::Restore(args),
} => run_pds_admin_restore(args).await,
Command::XrpcCallers {
sub: XrpcMembershipSub::Add(args),
} => run_xrpc_callers_add(args).await,
Command::XrpcCallers {
sub: XrpcMembershipSub::Revoke(args),
} => run_xrpc_callers_revoke(args).await,
Command::XrpcCallers {
sub: XrpcMembershipSub::List(args),
} => run_xrpc_callers_list(args).await,
Command::XrpcPdses {
sub: XrpcMembershipSub::Add(args),
} => run_xrpc_pdses_add(args).await,
Command::XrpcPdses {
sub: XrpcMembershipSub::Revoke(args),
} => run_xrpc_pdses_revoke(args).await,
Command::XrpcPdses {
sub: XrpcMembershipSub::List(args),
} => run_xrpc_pdses_list(args).await,
}
}
async fn run_pds_admin_takedown(args: PdsAdminTakedownArgs) -> Result<(), CliError> {
let config = load_config(args.config.as_deref())?;
cli_pds_admin::verify_pds_admin_enabled(&config)?;
let pool = storage::open(&config.db_path)
.await
.map_err(|e| CliError::MigrationFailed(e.to_string()))?;
let session_path = session_path()?;
let mut session = session::SessionFile::load(&session_path)?.ok_or(CliError::NotLoggedIn)?;
let reason = args
.reason
.unwrap_or_else(|| cli_pds_admin::PDS_ADMIN_DEFAULT_REASON_CODE.to_string());
let outcome = cli_pds_admin::takedown(
&pool,
&mut session,
&session_path,
&args.did,
&reason,
args.notes,
args.cairn_server,
)
.await?;
if args.json {
println!("{}", cli_pds_admin::format_takedown_json(&outcome));
} else {
println!("{}", cli_pds_admin::format_takedown_human(&outcome));
}
Ok(())
}
async fn run_pds_admin_suspend(args: PdsAdminSuspendArgs) -> Result<(), CliError> {
let config = load_config(args.config.as_deref())?;
cli_pds_admin::verify_pds_admin_enabled(&config)?;
let pool = storage::open(&config.db_path)
.await
.map_err(|e| CliError::MigrationFailed(e.to_string()))?;
let session_path = session_path()?;
let mut session = session::SessionFile::load(&session_path)?.ok_or(CliError::NotLoggedIn)?;
let reason = args
.reason
.unwrap_or_else(|| cli_pds_admin::PDS_ADMIN_DEFAULT_REASON_CODE.to_string());
let outcome = cli_pds_admin::suspend(
&pool,
&mut session,
&session_path,
&args.did,
&reason,
args.duration,
args.notes,
args.cairn_server,
)
.await?;
if args.json {
println!("{}", cli_pds_admin::format_takedown_json(&outcome));
} else {
println!("{}", cli_pds_admin::format_takedown_human(&outcome));
}
Ok(())
}
async fn run_pds_admin_restore(args: PdsAdminRestoreArgs) -> Result<(), CliError> {
let config = load_config(args.config.as_deref())?;
cli_pds_admin::verify_pds_admin_enabled(&config)?;
let pool = storage::open(&config.db_path)
.await
.map_err(|e| CliError::MigrationFailed(e.to_string()))?;
let session_path = session_path()?;
let mut session = session::SessionFile::load(&session_path)?.ok_or(CliError::NotLoggedIn)?;
let outcome = cli_pds_admin::restore(
&pool,
&mut session,
&session_path,
&args.did,
args.reason,
args.cairn_server,
)
.await?;
if args.json {
println!("{}", cli_pds_admin::format_restore_json(&outcome));
} else {
println!("{}", cli_pds_admin::format_restore_human(&outcome));
}
Ok(())
}
async fn run_moderator_events(args: ModeratorEventsArgs) -> Result<(), CliError> {
let pool = open_pool_from_config(args.config.as_ref()).await?;
let resp = moderator_events::list(
&pool,
moderator_events::EventsInput {
subject: args.subject,
actor: args.actor,
action_type: args.action_type,
from: args.from,
to: args.to,
limit: args.limit,
cursor: args.cursor,
ozone_only: args.ozone_only,
},
)
.await?;
if args.json {
println!("{}", moderator_events::format_json(&resp));
} else {
println!("{}", moderator_events::format_human(&resp));
}
Ok(())
}
async fn run_xrpc_callers_add(args: XrpcMembershipAddArgs) -> Result<(), CliError> {
let pool = open_pool_from_config(args.config.as_ref()).await?;
cairn_mod::xrpc_gateway::add_known_caller(&pool, &args.did, args.note.as_deref(), &args.by)
.await
.map_err(|e| CliError::Startup(e.to_string()))?;
println!("added xrpc_known_caller: {} (by {})", args.did, args.by);
Ok(())
}
async fn run_xrpc_callers_revoke(args: XrpcMembershipRevokeArgs) -> Result<(), CliError> {
let pool = open_pool_from_config(args.config.as_ref()).await?;
cairn_mod::xrpc_gateway::revoke_known_caller(&pool, &args.did, &args.by)
.await
.map_err(|e| CliError::Startup(e.to_string()))?;
println!("revoked xrpc_known_caller: {} (by {})", args.did, args.by);
Ok(())
}
async fn run_xrpc_callers_list(args: XrpcMembershipListArgs) -> Result<(), CliError> {
let pool = open_pool_from_config(args.config.as_ref()).await?;
let rows = cairn_mod::xrpc_gateway::list_known_callers(&pool, args.include_revoked)
.await
.map_err(|e| CliError::Startup(e.to_string()))?;
print_membership_rows(&rows);
Ok(())
}
async fn run_xrpc_pdses_add(args: XrpcMembershipAddArgs) -> Result<(), CliError> {
let pool = open_pool_from_config(args.config.as_ref()).await?;
cairn_mod::xrpc_gateway::add_trusted_pds(&pool, &args.did, args.note.as_deref(), &args.by)
.await
.map_err(|e| CliError::Startup(e.to_string()))?;
println!("added xrpc_trusted_pds: {} (by {})", args.did, args.by);
Ok(())
}
async fn run_xrpc_pdses_revoke(args: XrpcMembershipRevokeArgs) -> Result<(), CliError> {
let pool = open_pool_from_config(args.config.as_ref()).await?;
cairn_mod::xrpc_gateway::revoke_trusted_pds(&pool, &args.did, &args.by)
.await
.map_err(|e| CliError::Startup(e.to_string()))?;
println!("revoked xrpc_trusted_pds: {} (by {})", args.did, args.by);
Ok(())
}
async fn run_xrpc_pdses_list(args: XrpcMembershipListArgs) -> Result<(), CliError> {
let pool = open_pool_from_config(args.config.as_ref()).await?;
let rows = cairn_mod::xrpc_gateway::list_trusted_pdses(&pool, args.include_revoked)
.await
.map_err(|e| CliError::Startup(e.to_string()))?;
print_membership_rows(&rows);
Ok(())
}
fn print_membership_rows(rows: &[cairn_mod::xrpc_gateway::MembershipRow]) {
if rows.is_empty() {
println!("(no rows)");
return;
}
for r in rows {
let status = match r.revoked_at {
None => "active".to_string(),
Some(t) => format!("revoked@{t}"),
};
let note = r.note.as_deref().unwrap_or("-");
println!(
"{} {status} added@{} by {} note={note}",
r.did, r.added_at, r.added_by_moderator
);
}
}
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 {
table,
row_id,
expected_hash,
actual_hash,
attested_rows_before_divergence,
} => Err(CliError::AuditDivergence {
table: table.as_str(),
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 did = args.did.clone();
let result = moderator::add(
&pool,
moderator::AddInput {
did: args.did,
role: args.role.into(),
update_role: args.update_role,
},
)
.await?;
let xrpc_caller_added = if args.with_xrpc_callers {
let by = args
.by
.as_deref()
.expect("clap requires --by when --with-xrpc-callers is set");
let already = cairn_mod::xrpc_gateway::is_known_caller(&pool, &did)
.await
.map_err(|e| CliError::Startup(format!("xrpc_known_callers lookup: {e}")))?;
if !already {
cairn_mod::xrpc_gateway::add_known_caller(&pool, &did, None, by)
.await
.map_err(|e| CliError::Startup(format!("xrpc_known_callers add: {e}")))?;
true
} else {
false
}
} else {
false
};
if json {
println!(
"{}",
moderator::format_add_json_with_xrpc(&result, xrpc_caller_added)
);
} else {
println!("{}", moderator::format_add_human(&result));
if xrpc_caller_added {
println!("also added to xrpc_known_callers");
}
}
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(())
}
async fn run_moderator_action(args: ModeratorActionArgs) -> Result<(), CliError> {
let path = session_path()?;
let mut session = session::SessionFile::load(&path)?.ok_or(CliError::NotLoggedIn)?;
let json = args.json;
let resp = moderator_action::record(
&mut session,
&path,
moderator_action::RecordActionInput {
subject: args.subject,
action_type: args.action_type.as_db_str().to_string(),
reasons: args.reason,
duration: args.duration,
note: args.note,
report_ids: args.report,
cairn_server_override: args.cairn_server,
},
)
.await?;
if json {
println!("{}", moderator_action::format_record_json(&resp));
} else {
println!("{}", moderator_action::format_record_human(&resp));
}
Ok(())
}
async fn run_moderator_warn(args: ModeratorWarnArgs) -> Result<(), CliError> {
let path = session_path()?;
let mut session = session::SessionFile::load(&path)?.ok_or(CliError::NotLoggedIn)?;
let json = args.json;
let resp = moderator_action::record(
&mut session,
&path,
moderator_action::RecordActionInput {
subject: args.subject,
action_type: "warning".to_string(),
reasons: args.reason,
duration: None,
note: args.note,
report_ids: vec![],
cairn_server_override: args.cairn_server,
},
)
.await?;
if json {
println!("{}", moderator_action::format_record_json(&resp));
} else {
println!("{}", moderator_action::format_record_human(&resp));
}
Ok(())
}
async fn run_moderator_note(args: ModeratorNoteArgs) -> Result<(), CliError> {
let path = session_path()?;
let mut session = session::SessionFile::load(&path)?.ok_or(CliError::NotLoggedIn)?;
let json = args.json;
let resp = moderator_action::record(
&mut session,
&path,
moderator_action::RecordActionInput {
subject: args.subject,
action_type: "note".to_string(),
reasons: vec!["other".to_string()],
duration: None,
note: Some(args.text),
report_ids: vec![],
cairn_server_override: args.cairn_server,
},
)
.await?;
if json {
println!("{}", moderator_action::format_record_json(&resp));
} else {
println!("{}", moderator_action::format_record_human(&resp));
}
Ok(())
}
async fn run_moderator_revoke(args: ModeratorRevokeArgs) -> Result<(), CliError> {
let path = session_path()?;
let mut session = session::SessionFile::load(&path)?.ok_or(CliError::NotLoggedIn)?;
let json = args.json;
let resp = moderator_action::revoke(
&mut session,
&path,
moderator_action::RevokeActionInput {
action_id: args.action_id,
reason: args.reason,
cairn_server_override: args.cairn_server,
},
)
.await?;
if json {
println!("{}", moderator_action::format_revoke_json(&resp));
} else {
println!("{}", moderator_action::format_revoke_human(&resp));
}
Ok(())
}
async fn run_moderator_history(args: ModeratorHistoryArgs) -> Result<(), CliError> {
let path = session_path()?;
let mut session = session::SessionFile::load(&path)?.ok_or(CliError::NotLoggedIn)?;
let json = args.json;
let subject_for_msg = args.subject.clone();
let resp = moderator_action::history(
&mut session,
&path,
moderator_action::HistoryInput {
subject: args.subject,
subject_uri: args.subject_uri,
include_revoked: !args.no_include_revoked,
since: args.since,
limit: args.limit,
cursor: args.cursor,
cairn_server_override: args.cairn_server,
},
)
.await?;
if json {
println!("{}", moderator_action::format_history_json(&resp));
} else {
println!(
"{}",
moderator_action::format_history_human(&resp, &subject_for_msg)
);
}
Ok(())
}
async fn run_moderator_strikes(args: ModeratorStrikesArgs) -> Result<(), CliError> {
let path = session_path()?;
let mut session = session::SessionFile::load(&path)?.ok_or(CliError::NotLoggedIn)?;
let json = args.json;
let subject_for_msg = args.subject.clone();
let resp = moderator_action::strikes(
&mut session,
&path,
moderator_action::StrikesInput {
subject: args.subject,
cairn_server_override: args.cairn_server,
},
)
.await?;
if json {
println!("{}", moderator_action::format_strikes_json(&resp));
} else {
println!(
"{}",
moderator_action::format_strikes_human(&resp, &subject_for_msg)
);
}
Ok(())
}
async fn run_moderator_labels(args: ModeratorLabelsArgs) -> Result<(), CliError> {
let path = session_path()?;
let mut session = session::SessionFile::load(&path)?.ok_or(CliError::NotLoggedIn)?;
let json = args.json;
let subject_for_msg = args.subject.clone();
let resp = moderator_action::labels(
&mut session,
&path,
moderator_action::LabelsInput {
subject: args.subject,
cairn_server_override: args.cairn_server,
},
)
.await?;
if json {
println!("{}", moderator_action::format_labels_json(&resp));
} else {
println!(
"{}",
moderator_action::format_labels_human(&resp, &subject_for_msg)
);
}
Ok(())
}
async fn run_moderator_pending_list(args: ModeratorPendingListArgs) -> Result<(), CliError> {
let path = session_path()?;
let mut session = session::SessionFile::load(&path)?.ok_or(CliError::NotLoggedIn)?;
let json = args.json;
let resp = moderator_pending::list(
&mut session,
&path,
moderator_pending::ListPendingInput {
subject: args.subject,
limit: args.limit,
cursor: args.cursor,
cairn_server_override: args.cairn_server,
},
)
.await?;
if json {
println!("{}", moderator_pending::format_list_json(&resp));
} else {
let now = time::OffsetDateTime::now_utc()
.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_default();
println!("{}", moderator_pending::format_list_human(&resp, &now));
}
Ok(())
}
async fn run_moderator_pending_view(args: ModeratorPendingViewArgs) -> Result<(), CliError> {
let path = session_path()?;
let mut session = session::SessionFile::load(&path)?.ok_or(CliError::NotLoggedIn)?;
let json = args.json;
let resp = moderator_pending::view(
&mut session,
&path,
moderator_pending::ViewPendingInput {
pending_id: args.pending_id,
cairn_server_override: args.cairn_server,
},
)
.await?;
if json {
println!("{}", moderator_pending::format_view_json(&resp));
} else {
println!("{}", moderator_pending::format_view_human(&resp));
}
Ok(())
}
async fn run_moderator_pending_confirm(args: ModeratorPendingConfirmArgs) -> Result<(), CliError> {
let path = session_path()?;
let mut session = session::SessionFile::load(&path)?.ok_or(CliError::NotLoggedIn)?;
let json = args.json;
let resp = moderator_pending::confirm(
&mut session,
&path,
moderator_pending::ConfirmPendingInput {
pending_id: args.pending_id,
reason: args.reason,
cairn_server_override: args.cairn_server,
},
)
.await?;
if json {
println!("{}", moderator_pending::format_confirm_json(&resp));
} else {
println!("{}", moderator_pending::format_confirm_human(&resp));
}
Ok(())
}
async fn run_moderator_pending_dismiss(args: ModeratorPendingDismissArgs) -> Result<(), CliError> {
let path = session_path()?;
let mut session = session::SessionFile::load(&path)?.ok_or(CliError::NotLoggedIn)?;
let json = args.json;
let resp = moderator_pending::dismiss(
&mut session,
&path,
moderator_pending::DismissPendingInput {
pending_id: args.pending_id,
reason: args.reason,
cairn_server_override: args.cairn_server,
},
)
.await?;
if json {
println!("{}", moderator_pending::format_dismiss_json(&resp));
} else {
println!("{}", moderator_pending::format_dismiss_human(&resp));
}
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)");
}
}