//! 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 //! mnw-admin reindex-notes [user] Rebuild the git-notes index from the repos //! //! 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 std::sync::Arc; use clap::{Parser, Subcommand}; use sqlx::PgPool; use makenotwork::auth::AdminId; use makenotwork::config::Config; use makenotwork::db::{ self, AppealDecision, SelectionMethod, TransactionStatus, Username, WaitlistStatus, }; use makenotwork::email::{EmailClient, EmailConfig}; use makenotwork::payments::{PaymentProvider, StripeClient}; use makenotwork::routes::admin::moderation_service; /// Truncate `s` to at most `max` bytes on a char boundary (for display only). fn truncate_display(s: &str, max: usize) -> &str { if s.len() <= max { return s; } let mut end = max; while end > 0 && !s.is_char_boundary(end) { end -= 1; } &s[..end] } #[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 the git user's authorized_keys (`$GIT_HOME/.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, /// Rebuild the git-notes index from the repositories on disk ReindexNotes { /// Only this owner's repositories. Omit for every repository. username: Option, }, /// Backfill resource limits (receive.maxInputSize) into all existing bare repos BackfillGitConfig, /// 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()?, Command::ReindexNotes { username } => cmd_reindex_notes(&pool, username.as_deref()).await?, Command::BackfillGitConfig => cmd_backfill_git_config()?, 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!("{}...", truncate_display(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 '{username_str}' not found"))?; if user.can_create_projects { println!("'{username_str}' already has creator access."); return Ok(()); } let entry = db::waitlist::get_waitlist_entry_by_user(pool, user.id) .await? .ok_or_else(|| anyhow::anyhow!("'{username_str}' has no waitlist entry"))?; 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 '{username_str}' and granted creator access."); 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 '{username_str}' not found"))?; let entry = db::waitlist::get_waitlist_entry_by_user(pool, user.id) .await? .ok_or_else(|| anyhow::anyhow!("'{username_str}' has no waitlist entry"))?; db::waitlist::update_waitlist_status(pool, entry.id, WaitlistStatus::Spam, None, None).await?; println!("Marked '{username_str}' as spam."); 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 #{next_wave}: {hand_picked_count} hand-pick(s), drawing {lottery_count} from {eligible} 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?; 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 #{wave_number} complete."); 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 ── /// Build the collaborators the shared moderation service needs, from the same /// server env the CLI already loaded (`/etc/mnw/makenotwork.env`). /// /// Fails if no admin is configured, we refuse to issue a moderation action with /// no admin actor to attribute the audit record to, mirroring the web /// `require_admin` gate for a headless caller. Building the Stripe + email clients /// here (not just the DB pool) is the whole point of routing the CLI through the /// service: a suspension issued from the CLI now pauses fans in Stripe and emails /// the creator, instead of silently diverging from the web path. /// The env-built collaborators the shared moderation service needs: the email /// client, the optional Stripe provider, and the configured admin actor. type ModerationContext = (EmailClient, Option>, AdminId); fn moderation_context(pool: &PgPool) -> anyhow::Result { let config = Config::from_env().map_err(|e| anyhow::anyhow!("failed to load config: {e}"))?; let admin_id = AdminId::from_config(&config).ok_or_else(|| { anyhow::anyhow!( "ADMIN_USER_ID is not set; refusing to issue a moderation action with no admin actor to attribute it to" ) })?; let email = EmailClient::new(EmailConfig::from_env(), Some(pool.clone())); let stripe: Option> = match config.stripe { Some(ref stripe_config) => { Some(Arc::new(StripeClient::new(stripe_config)?) as Arc) } None => None, }; Ok((email, stripe, admin_id)) } async fn cmd_suspend(pool: &PgPool, username_str: &str, reason: &str) -> anyhow::Result<()> { let reason = reason.trim(); if reason.is_empty() { return Err(anyhow::anyhow!("a suspension reason is required")); } 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 '{username_str}' not found"))?; if user.is_suspended() { println!("'{username_str}' is already suspended."); return Ok(()); } let (email, stripe, admin_id) = moderation_context(pool)?; moderation_service::suspend_creator( pool, &email, stripe.as_ref(), moderation_service::FanoutMode::Inline, &user, admin_id, reason, ) .await?; println!("Suspended '{username_str}'. Reason: {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 '{username_str}' not found"))?; if !user.is_suspended() { println!("'{username_str}' is not suspended."); return Ok(()); } let (_email, stripe, _admin_id) = moderation_context(pool)?; moderation_service::unsuspend_creator( pool, stripe.as_ref(), moderation_service::FanoutMode::Inline, &user, ) .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_or_else(|| "-".to_string(), |t| t.format("%Y-%m-%d").to_string()); let appeal_date = user .appeal_submitted_at .map_or_else(|| "-".to_string(), |t| t.format("%Y-%m-%d").to_string()); let appeal = user.appeal_text.as_deref().unwrap_or(""); let appeal_short = if appeal.len() > 50 { format!("{}...", truncate_display(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 '{username_str}' not found"))?; let decision: AppealDecision = decision_str.parse().map_err(|_| { anyhow::anyhow!("invalid decision '{decision_str}': use 'approved' or 'denied'") })?; let response = response.trim(); if response.is_empty() { return Err(anyhow::anyhow!("a response message is required")); } let (email, stripe, _admin_id) = moderation_context(pool)?; moderation_service::decide_appeal( pool, &email, stripe.as_ref(), moderation_service::FanoutMode::Inline, &user, decision, response, ) .await?; match decision { AppealDecision::Approved => { println!("Appeal approved for '{username_str}'. Suspension lifted."); } AppealDecision::Denied => { println!("Appeal denied for '{username_str}'. Suspension remains."); } } 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?; println!("Platform Revenue"); // Platform-wide, so it spans every creator's currency. Rendered per // currency rather than summed: there is no rate that makes one number true. println!( " Total revenue: {}", revenue_cents.display(makenotwork::currency::SettlementCurrency::Usd) ); 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 '{username_str}' not found"))?; 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)); // Accumulated per currency: this listing is not scoped to one creator. let mut totals: Vec<(makenotwork::currency::SettlementCurrency, i64)> = Vec::new(); 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!("{}...", truncate_display(title, 25)) } else { title.to_string() }; let amount = makenotwork::formatting::format_revenue(tx.amount_cents.as_i64(), tx.currency()); println!( "{:<12} {:<30} {:>10} {:<10}", date, title_short, amount, tx.status ); if tx.status == TransactionStatus::Completed { totals.push((tx.currency(), tx.amount_cents.as_i64())); } } println!( "\n{} transaction(s), {} total revenue.", txs.len(), makenotwork::currency::MoneyByCurrency::from_rows(totals) .display(makenotwork::currency::SettlementCurrency::Usd) ); 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 '{username_str}' not found"))?; 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 = makenotwork::formatting::sanitize_csv_cell(row.item_title.as_deref().unwrap_or("")); let email = makenotwork::formatting::sanitize_csv_cell(row.buyer_email.as_deref().unwrap_or("")); println!( "{},{},{},{},{},{}", date, item_id, title, row.amount_cents, row.status, email ); } Ok(()) } // ── 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 '{username_str}' not found"))?; 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!("{}...", truncate_display(&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_count} item file(s), {version_file_count} version file(s)."); Ok(()) } // ── Build hooks command ── 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 {git_root} does not exist"); } 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 std::path::Path::new(n).extension().and_then(|e| e.to_str()) == Some("git") => { n.trim_end_matches(".git") } _ => continue, }; if !repo_path.is_dir() { continue; } makenotwork::git_ssh::install_hooks_for_repo( &repo_path, Some(&token), &owner_name, repo_name, )?; installed += 1; } } println!("Installed hooks on {installed} repo(s)."); Ok(()) } // ── Notes index command ── /// Rebuild the `git_notes` index from what the repositories hold. /// /// The index is a projection and the repositories are truth, so this is always /// safe to run: it walks every namespace of every repository and makes the table /// agree with the refs. Reindexing a namespace the index already holds costs one /// ref read, so a full run over an up-to-date platform is cheap enough to be the /// answer to "is the index right" rather than a thing to be careful about. async fn cmd_reindex_notes(pool: &PgPool, username: Option<&str>) -> anyhow::Result<()> { let config = Config::from_env().map_err(|e| anyhow::anyhow!("failed to load config: {e}"))?; let repos = db::git_repos::all_repos_with_owner(pool).await?; let repos: Vec<_> = repos .into_iter() .filter(|(_, owner, _)| username.is_none_or(|wanted| owner == wanted)) .collect(); if repos.is_empty() { println!("No repositories to reindex."); return Ok(()); } let mut done = 0u32; let mut failed = 0u32; for (repo_id, owner, name) in &repos { match makenotwork::routes::git::notes_index::reindex_repo( pool, &config, *repo_id, owner, name, ) .await { Ok(()) => done += 1, // One unreadable repository must not stop the rest: the whole point // of the command is to make the index agree with the disk, and // stopping at the first problem leaves it disagreeing everywhere // after it. Err(e) => { failed += 1; eprintln!("{owner}/{name}: {e}"); } } } println!("Reindexed notes for {done} repo(s), {failed} failed."); Ok(()) } /// One-time backfill: apply the standard bare-repo resource limits to every /// existing repo on disk. New repos get these at creation via /// `git::init_bare_repo`; this brings repos created before that landed up to par. fn cmd_backfill_git_config() -> anyhow::Result<()> { let git_root = std::env::var("GIT_REPOS_PATH").unwrap_or_else(|_| "/opt/git".to_string()); let root = std::path::Path::new(&git_root); if !root.exists() { anyhow::bail!("git root {git_root} does not exist"); } let mut updated = 0u32; for owner_entry in std::fs::read_dir(root)? { let owner_entry = owner_entry?; if !owner_entry.file_type()?.is_dir() { continue; } for repo_entry in std::fs::read_dir(owner_entry.path())? { let repo_entry = repo_entry?; let repo_path = repo_entry.path(); let is_bare = repo_path .file_name() .and_then(|n| n.to_str()) .is_some_and(|n| { std::path::Path::new(n) .extension() .is_some_and(|e| e == "git") }); if !is_bare || !repo_path.is_dir() { continue; } match gix::open(&repo_path) { Ok(repo) => { makenotwork::git::apply_bare_repo_limits(&repo)?; updated += 1; } Err(e) => { eprintln!("[backfill] skipping {}: {e}", repo_path.display()); } } } } println!("Applied resource limits to {updated} repo(s)."); Ok(()) } fn cmd_setup_git() -> anyhow::Result<()> { use std::fs; use std::os::unix::fs::PermissionsExt; use std::path::Path; let authorized_keys = makenotwork::git_ssh::authorized_keys_path(); let ssh_dir = authorized_keys .parent() .expect("authorized_keys_path always has a .ssh parent"); 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() { println!( "[setup] Sudoers rule already exists: {}", sudoers_file.display() ); } else { 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() ); } } 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_count} key(s)."); Ok(()) } async fn cmd_git_auth(pool: &PgPool, key_id_str: &str) -> anyhow::Result<()> { makenotwork::git_ssh::dispatch(pool, key_id_str).await }