//! CLI tool for MNW admin operations (waitlist, waves, creator management). //! //! Connects directly to the database — no HTTP server needed. //! //! Usage: //! mnw-admin waitlist List pending applications //! mnw-admin approve Hand-pick a user //! mnw-admin spam Mark application as spam //! mnw-admin wave Run a wave (hand-picks + lottery) //! mnw-admin stats Show waitlist/creator counts //! mnw-admin suspend Suspend a user account //! mnw-admin unsuspend Lift a suspension //! mnw-admin appeals List pending appeals //! mnw-admin decide Approve or deny an appeal //! mnw-admin revenue Platform-wide revenue report //! mnw-admin transactions Recent sales for a user //! mnw-admin export CSV export of a user's sales //! mnw-admin storage S3 storage audit for a user //! mnw-admin rebuild-keys Rebuild authorized_keys from DB //! mnw-admin git-auth Authenticate SSH git/management operations //! mnw-admin setup-git Set up SSH directories, permissions, sudoers //! //! SSH management commands (via git-auth dispatcher): //! repo list List your repositories //! repo info Show repo details + issue counts //! repo delete --confirm Delete a repo (DB + disk) //! repo set-visibility Set public/private/unlisted //! repo set-description "d" Set description (quote for spaces) //! key list List your SSH keys //! key rm Remove an SSH key by fingerprint use clap::{Parser, Subcommand}; use sqlx::PgPool; use makenotwork::db::{self, AppealDecision, SelectionMethod, TransactionStatus, Username, WaitlistStatus}; #[derive(Parser)] #[command(name = "mnw-admin", about = "MNW admin CLI")] struct Cli { #[command(subcommand)] command: Command, } #[derive(Subcommand)] enum Command { /// List pending waitlist applications Waitlist, /// Hand-pick a user: approve + grant creator access Approve { /// Username to approve username: String, }, /// Mark a waitlist application as spam Spam { /// Username to mark as spam username: String, }, /// Run a wave: assign hand-picks + draw lottery winners Wave { /// Number of lottery winners to draw lottery_count: i32, }, /// Show waitlist and creator statistics Stats, /// Suspend a user account Suspend { /// Username to suspend username: String, /// Reason for suspension reason: String, }, /// Lift a user's suspension Unsuspend { /// Username to unsuspend username: String, }, /// List pending suspension appeals Appeals, /// Decide a suspension appeal (approve or deny) Decide { /// Username whose appeal to decide username: String, /// Decision: "approved" or "denied" decision: String, /// Response message to the user response: String, }, /// Show platform-wide revenue report Revenue, /// Show recent transactions for a seller Transactions { /// Username to look up username: String, }, /// Export a seller's transactions as CSV to stdout Export { /// Username to export username: String, }, /// Audit S3 storage usage for a user Storage { /// Username to audit username: String, }, /// Rebuild /opt/git/.ssh/authorized_keys from the database RebuildKeys, /// Authenticate an SSH git operation (called by sshd command= prefix) GitAuth { /// SSH key ID from the authorized_keys command= prefix key_id: String, }, /// Install post-receive hooks on all git repos for build triggers InstallHooks, /// Set up SSH infrastructure for git access (directories, permissions, sudoers) SetupGit, } #[tokio::main] async fn main() -> anyhow::Result<()> { // Try the production env first (SSH invocations have CWD=/opt/git or // /var/lib/mnw/git), then fall back to the local directory for dev usage. dotenvy::from_path("/etc/mnw/makenotwork.env").ok(); dotenvy::dotenv().ok(); let database_url = std::env::var("DATABASE_URL") .expect("DATABASE_URL must be set"); let pool = PgPool::connect(&database_url).await?; let cli = Cli::parse(); match cli.command { Command::Waitlist => cmd_waitlist(&pool).await?, Command::Approve { username } => cmd_approve(&pool, &username).await?, Command::Spam { username } => cmd_spam(&pool, &username).await?, Command::Wave { lottery_count } => cmd_wave(&pool, lottery_count).await?, Command::Stats => cmd_stats(&pool).await?, Command::Suspend { username, reason } => cmd_suspend(&pool, &username, &reason).await?, Command::Unsuspend { username } => cmd_unsuspend(&pool, &username).await?, Command::Appeals => cmd_appeals(&pool).await?, Command::Decide { username, decision, response } => { cmd_decide(&pool, &username, &decision, &response).await? } Command::Revenue => cmd_revenue(&pool).await?, Command::Transactions { username } => cmd_transactions(&pool, &username).await?, Command::Export { username } => cmd_export(&pool, &username).await?, Command::Storage { username } => cmd_storage(&pool, &username).await?, Command::RebuildKeys => cmd_rebuild_keys(&pool).await?, Command::GitAuth { key_id } => cmd_git_auth(&pool, &key_id).await?, Command::InstallHooks => cmd_install_hooks().await?, Command::SetupGit => cmd_setup_git()?, } Ok(()) } // ── Waitlist commands (existing) ── async fn cmd_waitlist(pool: &PgPool) -> anyhow::Result<()> { let entries = db::waitlist::get_admin_waitlist(pool, Some("pending")).await?; if entries.is_empty() { println!("No pending applications."); return Ok(()); } println!("{:<20} {:<30} {:<12} Pitch", "Username", "Email", "Date"); println!("{}", "-".repeat(90)); for entry in &entries { let pitch = entry.pitch.as_deref().unwrap_or("(invited)"); let pitch_short = if pitch.len() > 40 { format!("{}...", &pitch[..40]) } else { pitch.to_string() }; let date = entry.created_at.format("%Y-%m-%d"); println!( "{:<20} {:<30} {:<12} {}", entry.username, entry.email, date, pitch_short ); } println!("\n{} pending application(s).", entries.len()); Ok(()) } async fn cmd_approve(pool: &PgPool, username_str: &str) -> anyhow::Result<()> { let username = Username::new(username_str) .map_err(|e| anyhow::anyhow!("invalid username: {e}"))?; let user = db::users::get_user_by_username(pool, &username) .await? .ok_or_else(|| anyhow::anyhow!("user '{}' not found", username_str))?; if user.can_create_projects { println!("'{}' already has creator access.", username_str); return Ok(()); } let entry = db::waitlist::get_waitlist_entry_by_user(pool, user.id) .await? .ok_or_else(|| anyhow::anyhow!("'{}' has no waitlist entry", username_str))?; db::waitlist::update_waitlist_status( pool, entry.id, WaitlistStatus::Approved, Some(SelectionMethod::HandPicked), None, ) .await?; db::waitlist::grant_creator_access(pool, user.id).await?; println!("Approved '{}' and granted creator access.", username_str); Ok(()) } async fn cmd_spam(pool: &PgPool, username_str: &str) -> anyhow::Result<()> { let username = Username::new(username_str) .map_err(|e| anyhow::anyhow!("invalid username: {e}"))?; let user = db::users::get_user_by_username(pool, &username) .await? .ok_or_else(|| anyhow::anyhow!("user '{}' not found", username_str))?; let entry = db::waitlist::get_waitlist_entry_by_user(pool, user.id) .await? .ok_or_else(|| anyhow::anyhow!("'{}' has no waitlist entry", username_str))?; db::waitlist::update_waitlist_status( pool, entry.id, WaitlistStatus::Spam, None, None, ) .await?; println!("Marked '{}' as spam.", username_str); Ok(()) } async fn cmd_wave(pool: &PgPool, lottery_count: i32) -> anyhow::Result<()> { if lottery_count < 1 { anyhow::bail!("lottery count must be at least 1"); } // Gather stats before starting transaction let hand_picked_count = db::waitlist::count_unassigned_handpicks(pool).await?; let next_wave = db::waitlist::get_next_wave_number(pool).await?; let eligible = db::waitlist::get_lottery_eligible_count(pool).await?; println!( "Wave #{}: {} hand-pick(s), drawing {} from {} eligible.", next_wave, hand_picked_count, lottery_count, eligible ); print!("Proceed? [y/N] "); // Flush and read confirmation use std::io::Write; std::io::stdout().flush()?; let mut input = String::new(); std::io::stdin().read_line(&mut input)?; if !matches!(input.trim(), "y" | "Y" | "yes") { println!("Aborted."); return Ok(()); } let mut tx = pool.begin().await?; // Re-read inside transaction for consistency let hand_picked_count = db::waitlist::count_unassigned_handpicks(&mut *tx).await?; let wave_number = db::waitlist::get_next_wave_number(&mut *tx).await?; let eligible = db::waitlist::get_lottery_eligible_count(&mut *tx).await?; let wave = db::waitlist::create_wave( &mut *tx, wave_number, hand_picked_count as i32, lottery_count, eligible as i32, None, ) .await?; // Assign wave to unassigned hand-picks let assigned = db::waitlist::assign_wave_to_handpicks(&mut *tx, wave.id).await?; // Run lottery let winners = db::waitlist::run_lottery(&mut *tx, wave.id, lottery_count).await?; // Grant creator access to lottery winners let winner_ids: Vec<_> = winners.iter().map(|w| w.user_id).collect(); if !winner_ids.is_empty() { db::waitlist::grant_creator_access_batch(&mut *tx, &winner_ids).await?; } tx.commit().await?; println!("\nWave #{} complete.", wave_number); println!(" Hand-picks assigned: {}", assigned); println!(" Lottery winners: {}", winners.len()); if !winners.is_empty() { // Look up usernames for the winners for w in &winners { if let Ok(Some(u)) = db::users::get_user_by_id(pool, w.user_id).await { println!(" - {}", u.username); } } } Ok(()) } async fn cmd_stats(pool: &PgPool) -> anyhow::Result<()> { let stats = db::waitlist::get_waitlist_stats(pool).await?; let total_creators = db::waitlist::count_active_creators(pool).await?; let waves = db::waitlist::get_all_waves(pool).await?; println!("Waitlist"); println!(" Pending: {}", stats.pending); println!(" Approved: {}", stats.approved); println!(" Spam: {}", stats.spam); println!(); println!("Creators: {}", total_creators); println!("Waves: {}", waves.len()); Ok(()) } // ── Suspension commands ── async fn cmd_suspend(pool: &PgPool, username_str: &str, reason: &str) -> anyhow::Result<()> { let username = Username::new(username_str) .map_err(|e| anyhow::anyhow!("invalid username: {e}"))?; let user = db::users::get_user_by_username(pool, &username) .await? .ok_or_else(|| anyhow::anyhow!("user '{}' not found", username_str))?; if user.is_suspended() { println!("'{}' is already suspended.", username_str); return Ok(()); } db::users::suspend_user(pool, user.id, reason).await?; println!("Suspended '{}'. Reason: {}", username_str, reason); Ok(()) } async fn cmd_unsuspend(pool: &PgPool, username_str: &str) -> anyhow::Result<()> { let username = Username::new(username_str) .map_err(|e| anyhow::anyhow!("invalid username: {e}"))?; let user = db::users::get_user_by_username(pool, &username) .await? .ok_or_else(|| anyhow::anyhow!("user '{}' not found", username_str))?; if !user.is_suspended() { println!("'{}' is not suspended.", username_str); return Ok(()); } db::users::unsuspend_user(pool, user.id).await?; println!("Unsuspended '{}'.", username_str); Ok(()) } // ── Appeal commands ── async fn cmd_appeals(pool: &PgPool) -> anyhow::Result<()> { let users = db::users::get_pending_appeals(pool).await?; if users.is_empty() { println!("No pending appeals."); return Ok(()); } println!( "{:<20} {:<30} {:<12} {:<12} Appeal Text", "Username", "Email", "Suspended", "Appeal Date" ); println!("{}", "-".repeat(110)); for user in &users { let suspended = user .suspended_at .map(|t| t.format("%Y-%m-%d").to_string()) .unwrap_or_else(|| "-".to_string()); let appeal_date = user .appeal_submitted_at .map(|t| t.format("%Y-%m-%d").to_string()) .unwrap_or_else(|| "-".to_string()); let appeal = user.appeal_text.as_deref().unwrap_or(""); let appeal_short = if appeal.len() > 50 { format!("{}...", &appeal[..50]) } else { appeal.to_string() }; println!( "{:<20} {:<30} {:<12} {:<12} {}", user.username, user.email, suspended, appeal_date, appeal_short ); } println!("\n{} pending appeal(s).", users.len()); Ok(()) } async fn cmd_decide( pool: &PgPool, username_str: &str, decision_str: &str, response: &str, ) -> anyhow::Result<()> { let username = Username::new(username_str) .map_err(|e| anyhow::anyhow!("invalid username: {e}"))?; let user = db::users::get_user_by_username(pool, &username) .await? .ok_or_else(|| anyhow::anyhow!("user '{}' not found", username_str))?; let decision: AppealDecision = decision_str .parse() .map_err(|_| anyhow::anyhow!("invalid decision '{}': use 'approved' or 'denied'", decision_str))?; db::users::resolve_appeal(pool, user.id, decision, response).await?; match decision { AppealDecision::Approved => { println!("Appeal approved for '{}'. Suspension lifted.", username_str); } AppealDecision::Denied => { println!("Appeal denied for '{}'. Suspension remains.", username_str); } } Ok(()) } // ── Revenue & transaction commands ── async fn cmd_revenue(pool: &PgPool) -> anyhow::Result<()> { let (revenue_cents, completed, refunded) = db::transactions::get_platform_revenue_stats(pool).await?; let dollars = revenue_cents as f64 / 100.0; println!("Platform Revenue"); println!(" Total revenue: ${:.2}", dollars); println!(" Total sales: {}", completed); println!(" Total refunds: {}", refunded); Ok(()) } async fn cmd_transactions(pool: &PgPool, username_str: &str) -> anyhow::Result<()> { let username = Username::new(username_str) .map_err(|e| anyhow::anyhow!("invalid username: {e}"))?; let user = db::users::get_user_by_username(pool, &username) .await? .ok_or_else(|| anyhow::anyhow!("user '{}' not found", username_str))?; let txs = db::transactions::get_transactions_by_seller(pool, user.id, Some(50)).await?; if txs.is_empty() { println!("No transactions for '{}'.", username_str); return Ok(()); } println!( "{:<12} {:<30} {:>10} {:<10}", "Date", "Item", "Amount", "Status" ); println!("{}", "-".repeat(65)); let mut total_cents: i64 = 0; for tx in &txs { let date = tx.created_at.format("%Y-%m-%d"); let title = tx.item_title.as_deref().unwrap_or("(deleted)"); let title_short = if title.len() > 28 { format!("{}...", &title[..25]) } else { title.to_string() }; let amount = format!("${:.2}", tx.amount_cents.as_f64() / 100.0); println!( "{:<12} {:<30} {:>10} {:<10}", date, title_short, amount, tx.status ); if tx.status == TransactionStatus::Completed { total_cents += tx.amount_cents.as_i64(); } } println!( "\n{} transaction(s), ${:.2} total revenue.", txs.len(), total_cents as f64 / 100.0 ); Ok(()) } // ── Export command ── async fn cmd_export(pool: &PgPool, username_str: &str) -> anyhow::Result<()> { let username = Username::new(username_str) .map_err(|e| anyhow::anyhow!("invalid username: {e}"))?; let user = db::users::get_user_by_username(pool, &username) .await? .ok_or_else(|| anyhow::anyhow!("user '{}' not found", username_str))?; let rows = db::transactions::get_seller_transactions_for_export(pool, user.id).await?; // CSV header println!("date,item_id,item_title,amount_cents,status,buyer_email"); for row in &rows { let date = row.created_at.format("%Y-%m-%dT%H:%M:%SZ"); let item_id = row .item_id .map(|id| id.to_string()) .unwrap_or_default(); let title = csv_escape(row.item_title.as_deref().unwrap_or("")); let email = csv_escape(row.buyer_email.as_deref().unwrap_or("")); println!( "{},{},{},{},{},{}", date, item_id, title, row.amount_cents, row.status, email ); } Ok(()) } /// Escape a value for CSV: wrap in quotes if it contains comma, quote, or newline. fn csv_escape(s: &str) -> String { if s.contains(',') || s.contains('"') || s.contains('\n') { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_string() } } // ── Storage audit command ── async fn cmd_storage(pool: &PgPool, username_str: &str) -> anyhow::Result<()> { let username = Username::new(username_str) .map_err(|e| anyhow::anyhow!("invalid username: {e}"))?; let user = db::users::get_user_by_username(pool, &username) .await? .ok_or_else(|| anyhow::anyhow!("user '{}' not found", username_str))?; let item_keys = db::items::get_user_s3_keys(pool, user.id).await?; let version_keys = db::versions::get_user_version_s3_keys(pool, user.id).await?; if item_keys.is_empty() && version_keys.is_empty() { println!("No S3 files for '{}'.", username_str); return Ok(()); } println!( "{:<10} {:<20} {:<25} S3 Key", "Type", "Project", "Item" ); println!("{}", "-".repeat(100)); let mut item_file_count = 0u32; for row in &item_keys { if let Some(key) = &row.audio_s3_key { println!( "{:<10} {:<20} {:<25} {}", "audio", row.project_slug, row.title, key ); item_file_count += 1; } if let Some(key) = &row.cover_s3_key { println!( "{:<10} {:<20} {:<25} {}", "cover", row.project_slug, row.title, key ); item_file_count += 1; } } for row in &version_keys { if let Some(key) = &row.s3_key { let label = format!("{} v{}", row.item_title, row.version_number); let label_short = if label.len() > 23 { format!("{}...", &label[..20]) } else { label }; println!( "{:<10} {:<20} {:<25} {}", "version", row.project_slug, label_short, key ); } } let version_file_count = version_keys.iter().filter(|r| r.s3_key.is_some()).count(); println!( "\n{} item file(s), {} version file(s).", item_file_count, version_file_count ); Ok(()) } // ── Build hooks command ── async fn cmd_install_hooks() -> anyhow::Result<()> { let token = std::env::var("BUILD_TRIGGER_TOKEN") .map_err(|_| anyhow::anyhow!("BUILD_TRIGGER_TOKEN must be set"))?; let git_root = std::env::var("GIT_REPOS_PATH") .unwrap_or_else(|_| "/opt/git".to_string()); let mut installed = 0u32; let root = std::path::Path::new(&git_root); if !root.exists() { anyhow::bail!("git root {} does not exist", git_root); } for owner_entry in std::fs::read_dir(root)? { let owner_entry = owner_entry?; if !owner_entry.file_type()?.is_dir() { continue; } let owner_name = owner_entry.file_name().to_string_lossy().to_string(); for repo_entry in std::fs::read_dir(owner_entry.path())? { let repo_entry = repo_entry?; let repo_path = repo_entry.path(); let repo_name = match repo_path.file_name().and_then(|n| n.to_str()) { Some(n) if n.ends_with(".git") => n.trim_end_matches(".git"), _ => continue, }; if !repo_path.is_dir() { continue; } let hook_content = makenotwork::build_runner::post_receive_hook(&token, &owner_name, repo_name); makenotwork::git_ssh::install_hook_for_repo(&repo_path, &hook_content)?; installed += 1; } } println!("Installed post-receive hooks on {} repo(s).", installed); Ok(()) } fn cmd_setup_git() -> anyhow::Result<()> { use std::fs; use std::os::unix::fs::PermissionsExt; use std::path::Path; let ssh_dir = Path::new("/opt/git/.ssh"); let authorized_keys = ssh_dir.join("authorized_keys"); let sudoers_file = Path::new("/etc/sudoers.d/mnw-git-ssh"); let mnw_admin = Path::new(makenotwork::git_ssh::MNW_ADMIN_PATH); // 1. Create git user's .ssh directory if !ssh_dir.exists() { fs::create_dir_all(ssh_dir)?; println!("[setup] Created {}", ssh_dir.display()); } fs::set_permissions(ssh_dir, fs::Permissions::from_mode(0o700))?; chown("git:git", ssh_dir)?; // 2. Create authorized_keys if !authorized_keys.exists() { fs::write(&authorized_keys, "")?; println!("[setup] Created {}", authorized_keys.display()); } fs::set_permissions(&authorized_keys, fs::Permissions::from_mode(0o600))?; chown("git:git", &authorized_keys)?; // 3. Check mnw-admin binary if !mnw_admin.exists() { println!("[setup] WARNING: {} not found. Deploy the binary first.", mnw_admin.display()); } // 4. Install sudoers rule if !sudoers_file.exists() { let rule = format!( "makenotwork ALL=(git) NOPASSWD: {} rebuild-keys\n", mnw_admin.display(), ); fs::write(sudoers_file, &rule)?; fs::set_permissions(sudoers_file, fs::Permissions::from_mode(0o440))?; println!("[setup] Created sudoers rule: {}", sudoers_file.display()); // Verify syntax let status = std::process::Command::new("visudo") .args(["-cf", &sudoers_file.to_string_lossy()]) .status()?; if !status.success() { anyhow::bail!("sudoers syntax check failed — fix {} manually", sudoers_file.display()); } } else { println!("[setup] Sudoers rule already exists: {}", sudoers_file.display()); } println!("[setup] Git SSH infrastructure ready."); println!(" Users add SSH keys via the dashboard."); println!(" Clone: git clone git@makenot.work:{{username}}/{{repo}}.git"); Ok(()) } /// Run `chown `. fn chown(spec: &str, path: &std::path::Path) -> anyhow::Result<()> { let status = std::process::Command::new("chown") .args([spec, &path.to_string_lossy()]) .status()?; if !status.success() { anyhow::bail!("chown {} {} failed", spec, path.display()); } Ok(()) } async fn cmd_rebuild_keys(pool: &PgPool) -> anyhow::Result<()> { let key_count = db::ssh_keys::get_all_keys_with_username(pool).await?.len(); makenotwork::git_ssh::write_authorized_keys(pool, true).await?; println!("Rebuilt authorized_keys with {} key(s).", key_count); Ok(()) } async fn cmd_git_auth(pool: &PgPool, key_id_str: &str) -> anyhow::Result<()> { makenotwork::git_ssh::dispatch(pool, key_id_str).await }