Skip to main content

max / makenotwork

31.3 KB · 925 lines History Blame Raw
1 //! CLI tool for MNW admin operations (waitlist, waves, creator management).
2 //!
3 //! Connects directly to the database, no HTTP server needed.
4 //!
5 //! Usage:
6 //! mnw-admin waitlist List pending applications
7 //! mnw-admin approve <username> Hand-pick a user
8 //! mnw-admin spam <username> Mark application as spam
9 //! mnw-admin wave <lottery_count> Run a wave (hand-picks + lottery)
10 //! mnw-admin stats Show waitlist/creator counts
11 //! mnw-admin suspend <user> <why> Suspend a user account
12 //! mnw-admin unsuspend <user> Lift a suspension
13 //! mnw-admin appeals List pending appeals
14 //! mnw-admin decide <user> <d> <r> Approve or deny an appeal
15 //! mnw-admin revenue Platform-wide revenue report
16 //! mnw-admin transactions <user> Recent sales for a user
17 //! mnw-admin export <user> CSV export of a user's sales
18 //! mnw-admin storage <user> S3 storage audit for a user
19 //! mnw-admin rebuild-keys Rebuild authorized_keys from DB
20 //! mnw-admin git-auth <key_id> Authenticate an SSH git operation
21 //! mnw-admin install-hooks Install post-receive hooks on all repos
22 //! mnw-admin backfill-git-config Backfill resource limits into bare repos
23 //! mnw-admin setup-git Set up SSH directories, permissions, sudoers
24 //! mnw-admin reindex-notes [user] Rebuild the git-notes index from the repos
25 //!
26 //! The SSH management verbs (`repo list`, `key rm`, ...) are not here: they
27 //! moved to mnw-cli on 2026-07-31 and are reached through cli.makenot.work.
28 //! `git-auth` serves git transport only and answers anything else with a
29 //! pointer there (see `git_ssh.rs`).
30
31 use std::sync::Arc;
32
33 use clap::{Parser, Subcommand};
34 use sqlx::PgPool;
35
36 use makenotwork::auth::AdminId;
37 use makenotwork::config::Config;
38 use makenotwork::db::{
39 self, AppealDecision, SelectionMethod, TransactionStatus, Username, WaitlistStatus,
40 };
41 use makenotwork::email::{EmailClient, EmailConfig};
42 use makenotwork::payments::{PaymentProvider, StripeClient};
43 use makenotwork::routes::admin::moderation_service;
44
45 /// Truncate `s` to at most `max` bytes on a char boundary (for display only).
46 fn truncate_display(s: &str, max: usize) -> &str {
47 if s.len() <= max {
48 return s;
49 }
50 let mut end = max;
51 while end > 0 && !s.is_char_boundary(end) {
52 end -= 1;
53 }
54 &s[..end]
55 }
56
57 #[derive(Parser)]
58 #[command(name = "mnw-admin", about = "MNW admin CLI")]
59 struct Cli {
60 #[command(subcommand)]
61 command: Command,
62 }
63
64 #[derive(Subcommand)]
65 enum Command {
66 /// List pending waitlist applications
67 Waitlist,
68 /// Hand-pick a user: approve + grant creator access
69 Approve {
70 /// Username to approve
71 username: String,
72 },
73 /// Mark a waitlist application as spam
74 Spam {
75 /// Username to mark as spam
76 username: String,
77 },
78 /// Run a wave: assign hand-picks + draw lottery winners
79 Wave {
80 /// Number of lottery winners to draw
81 lottery_count: i32,
82 },
83 /// Show waitlist and creator statistics
84 Stats,
85 /// Suspend a user account
86 Suspend {
87 /// Username to suspend
88 username: String,
89 /// Reason for suspension
90 reason: String,
91 },
92 /// Lift a user's suspension
93 Unsuspend {
94 /// Username to unsuspend
95 username: String,
96 },
97 /// List pending suspension appeals
98 Appeals,
99 /// Decide a suspension appeal (approve or deny)
100 Decide {
101 /// Username whose appeal to decide
102 username: String,
103 /// Decision: "approved" or "denied"
104 decision: String,
105 /// Response message to the user
106 response: String,
107 },
108 /// Show platform-wide revenue report
109 Revenue,
110 /// Show recent transactions for a seller
111 Transactions {
112 /// Username to look up
113 username: String,
114 },
115 /// Export a seller's transactions as CSV to stdout
116 Export {
117 /// Username to export
118 username: String,
119 },
120 /// Audit S3 storage usage for a user
121 Storage {
122 /// Username to audit
123 username: String,
124 },
125 /// Rebuild the git user's authorized_keys (`$GIT_HOME/.ssh/authorized_keys`)
126 /// from the database
127 RebuildKeys,
128 /// Authenticate an SSH git operation (called by sshd command= prefix)
129 GitAuth {
130 /// SSH key ID from the authorized_keys command= prefix
131 key_id: String,
132 },
133 /// Install post-receive hooks on all git repos for build triggers
134 InstallHooks,
135 /// Rebuild the git-notes index from the repositories on disk
136 ReindexNotes {
137 /// Only this owner's repositories. Omit for every repository.
138 username: Option<String>,
139 },
140 /// Backfill resource limits (receive.maxInputSize) into all existing bare repos
141 BackfillGitConfig,
142 /// Set up SSH infrastructure for git access (directories, permissions, sudoers)
143 SetupGit,
144 }
145
146 #[tokio::main]
147 async fn main() -> anyhow::Result<()> {
148 // Try the production env first (SSH invocations have CWD=/opt/git or
149 // /var/lib/mnw/git), then fall back to the local directory for dev usage.
150 dotenvy::from_path("/etc/mnw/makenotwork.env").ok();
151 dotenvy::dotenv().ok();
152
153 let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
154
155 let pool = PgPool::connect(&database_url).await?;
156 let cli = Cli::parse();
157
158 match cli.command {
159 Command::Waitlist => cmd_waitlist(&pool).await?,
160 Command::Approve { username } => cmd_approve(&pool, &username).await?,
161 Command::Spam { username } => cmd_spam(&pool, &username).await?,
162 Command::Wave { lottery_count } => cmd_wave(&pool, lottery_count).await?,
163 Command::Stats => cmd_stats(&pool).await?,
164 Command::Suspend { username, reason } => cmd_suspend(&pool, &username, &reason).await?,
165 Command::Unsuspend { username } => cmd_unsuspend(&pool, &username).await?,
166 Command::Appeals => cmd_appeals(&pool).await?,
167 Command::Decide {
168 username,
169 decision,
170 response,
171 } => cmd_decide(&pool, &username, &decision, &response).await?,
172 Command::Revenue => cmd_revenue(&pool).await?,
173 Command::Transactions { username } => cmd_transactions(&pool, &username).await?,
174 Command::Export { username } => cmd_export(&pool, &username).await?,
175 Command::Storage { username } => cmd_storage(&pool, &username).await?,
176 Command::RebuildKeys => cmd_rebuild_keys(&pool).await?,
177 Command::GitAuth { key_id } => cmd_git_auth(&pool, &key_id).await?,
178 Command::InstallHooks => cmd_install_hooks()?,
179 Command::ReindexNotes { username } => cmd_reindex_notes(&pool, username.as_deref()).await?,
180 Command::BackfillGitConfig => cmd_backfill_git_config()?,
181 Command::SetupGit => cmd_setup_git()?,
182 }
183
184 Ok(())
185 }
186
187 // ── Waitlist commands (existing) ──
188
189 async fn cmd_waitlist(pool: &PgPool) -> anyhow::Result<()> {
190 let entries = db::waitlist::get_admin_waitlist(pool, Some("pending")).await?;
191
192 if entries.is_empty() {
193 println!("No pending applications.");
194 return Ok(());
195 }
196
197 println!("{:<20} {:<30} {:<12} Pitch", "Username", "Email", "Date");
198 println!("{}", "-".repeat(90));
199
200 for entry in &entries {
201 let pitch = entry.pitch.as_deref().unwrap_or("(invited)");
202 let pitch_short = if pitch.len() > 40 {
203 format!("{}...", truncate_display(pitch, 40))
204 } else {
205 pitch.to_string()
206 };
207 let date = entry.created_at.format("%Y-%m-%d");
208 println!(
209 "{:<20} {:<30} {:<12} {}",
210 entry.username, entry.email, date, pitch_short
211 );
212 }
213
214 println!("\n{} pending application(s).", entries.len());
215 Ok(())
216 }
217
218 async fn cmd_approve(pool: &PgPool, username_str: &str) -> anyhow::Result<()> {
219 let username =
220 Username::new(username_str).map_err(|e| anyhow::anyhow!("invalid username: {e}"))?;
221
222 let user = db::users::get_user_by_username(pool, &username)
223 .await?
224 .ok_or_else(|| anyhow::anyhow!("user '{username_str}' not found"))?;
225
226 if user.can_create_projects {
227 println!("'{username_str}' already has creator access.");
228 return Ok(());
229 }
230
231 let entry = db::waitlist::get_waitlist_entry_by_user(pool, user.id)
232 .await?
233 .ok_or_else(|| anyhow::anyhow!("'{username_str}' has no waitlist entry"))?;
234
235 db::waitlist::update_waitlist_status(
236 pool,
237 entry.id,
238 WaitlistStatus::Approved,
239 Some(SelectionMethod::HandPicked),
240 None,
241 )
242 .await?;
243
244 db::waitlist::grant_creator_access(pool, user.id).await?;
245
246 println!("Approved '{username_str}' and granted creator access.");
247 Ok(())
248 }
249
250 async fn cmd_spam(pool: &PgPool, username_str: &str) -> anyhow::Result<()> {
251 let username =
252 Username::new(username_str).map_err(|e| anyhow::anyhow!("invalid username: {e}"))?;
253
254 let user = db::users::get_user_by_username(pool, &username)
255 .await?
256 .ok_or_else(|| anyhow::anyhow!("user '{username_str}' not found"))?;
257
258 let entry = db::waitlist::get_waitlist_entry_by_user(pool, user.id)
259 .await?
260 .ok_or_else(|| anyhow::anyhow!("'{username_str}' has no waitlist entry"))?;
261
262 db::waitlist::update_waitlist_status(pool, entry.id, WaitlistStatus::Spam, None, None).await?;
263
264 println!("Marked '{username_str}' as spam.");
265 Ok(())
266 }
267
268 async fn cmd_wave(pool: &PgPool, lottery_count: i32) -> anyhow::Result<()> {
269 if lottery_count < 1 {
270 anyhow::bail!("lottery count must be at least 1");
271 }
272
273 // Gather stats before starting transaction
274 let hand_picked_count = db::waitlist::count_unassigned_handpicks(pool).await?;
275 let next_wave = db::waitlist::get_next_wave_number(pool).await?;
276 let eligible = db::waitlist::get_lottery_eligible_count(pool).await?;
277
278 println!(
279 "Wave #{next_wave}: {hand_picked_count} hand-pick(s), drawing {lottery_count} from {eligible} eligible."
280 );
281 print!("Proceed? [y/N] ");
282
283 // Flush and read confirmation
284 use std::io::Write;
285 std::io::stdout().flush()?;
286 let mut input = String::new();
287 std::io::stdin().read_line(&mut input)?;
288
289 if !matches!(input.trim(), "y" | "Y" | "yes") {
290 println!("Aborted.");
291 return Ok(());
292 }
293
294 let mut tx = pool.begin().await?;
295
296 // Re-read inside transaction for consistency
297 let hand_picked_count = db::waitlist::count_unassigned_handpicks(&mut *tx).await?;
298 let wave_number = db::waitlist::get_next_wave_number(&mut *tx).await?;
299 let eligible = db::waitlist::get_lottery_eligible_count(&mut *tx).await?;
300
301 let wave = db::waitlist::create_wave(
302 &mut *tx,
303 wave_number,
304 hand_picked_count as i32,
305 lottery_count,
306 eligible as i32,
307 None,
308 )
309 .await?;
310
311 // Assign wave to unassigned hand-picks
312 let assigned = db::waitlist::assign_wave_to_handpicks(&mut *tx, wave.id).await?;
313
314 let winners = db::waitlist::run_lottery(&mut *tx, wave.id, lottery_count).await?;
315
316 // Grant creator access to lottery winners
317 let winner_ids: Vec<_> = winners.iter().map(|w| w.user_id).collect();
318 if !winner_ids.is_empty() {
319 db::waitlist::grant_creator_access_batch(&mut *tx, &winner_ids).await?;
320 }
321
322 tx.commit().await?;
323
324 println!("\nWave #{wave_number} complete.");
325 println!(" Hand-picks assigned: {assigned}");
326 println!(" Lottery winners: {}", winners.len());
327
328 if !winners.is_empty() {
329 // Look up usernames for the winners
330 for w in &winners {
331 if let Ok(Some(u)) = db::users::get_user_by_id(pool, w.user_id).await {
332 println!(" - {}", u.username);
333 }
334 }
335 }
336
337 Ok(())
338 }
339
340 async fn cmd_stats(pool: &PgPool) -> anyhow::Result<()> {
341 let stats = db::waitlist::get_waitlist_stats(pool).await?;
342 let total_creators = db::waitlist::count_active_creators(pool).await?;
343 let waves = db::waitlist::get_all_waves(pool).await?;
344
345 println!("Waitlist");
346 println!(" Pending: {}", stats.pending);
347 println!(" Approved: {}", stats.approved);
348 println!(" Spam: {}", stats.spam);
349 println!();
350 println!("Creators: {total_creators}");
351 println!("Waves: {}", waves.len());
352
353 Ok(())
354 }
355
356 // ── Suspension commands ──
357
358 /// The env-built collaborators the shared moderation service needs: the email
359 /// client, the optional Stripe provider, and the configured admin actor.
360 type ModerationContext = (EmailClient, Option<Arc<dyn PaymentProvider>>, AdminId);
361
362 /// Build the collaborators the shared moderation service needs, from the same
363 /// server env the CLI already loaded (`/etc/mnw/makenotwork.env`).
364 ///
365 /// Fails if no admin is configured: we refuse to issue a moderation action with
366 /// no admin actor to attribute the audit record to, mirroring the web
367 /// `require_admin` gate for a headless caller. Building the Stripe + email clients
368 /// here, and not only the DB pool, is the whole point of routing the CLI through
369 /// the service: a suspension issued from the CLI pauses fans in Stripe and emails
370 /// the creator, instead of silently diverging from the web path.
371 fn moderation_context(pool: &PgPool) -> anyhow::Result<ModerationContext> {
372 let config = Config::from_env().map_err(|e| anyhow::anyhow!("failed to load config: {e}"))?;
373 let admin_id = AdminId::from_config(&config).ok_or_else(|| {
374 anyhow::anyhow!(
375 "ADMIN_USER_ID is not set; refusing to issue a moderation action with no admin actor to attribute it to"
376 )
377 })?;
378 let email = EmailClient::new(EmailConfig::from_env(), Some(pool.clone()));
379 let stripe: Option<Arc<dyn PaymentProvider>> = match config.stripe {
380 Some(ref stripe_config) => {
381 Some(Arc::new(StripeClient::new(stripe_config)?) as Arc<dyn PaymentProvider>)
382 }
383 None => None,
384 };
385 Ok((email, stripe, admin_id))
386 }
387
388 async fn cmd_suspend(pool: &PgPool, username_str: &str, reason: &str) -> anyhow::Result<()> {
389 let reason = reason.trim();
390 if reason.is_empty() {
391 return Err(anyhow::anyhow!("a suspension reason is required"));
392 }
393
394 let username =
395 Username::new(username_str).map_err(|e| anyhow::anyhow!("invalid username: {e}"))?;
396
397 let user = db::users::get_user_by_username(pool, &username)
398 .await?
399 .ok_or_else(|| anyhow::anyhow!("user '{username_str}' not found"))?;
400
401 if user.is_suspended() {
402 println!("'{username_str}' is already suspended.");
403 return Ok(());
404 }
405
406 let (email, stripe, admin_id) = moderation_context(pool)?;
407 moderation_service::suspend_creator(
408 pool,
409 &email,
410 stripe.as_ref(),
411 moderation_service::FanoutMode::Inline,
412 &user,
413 admin_id,
414 reason,
415 )
416 .await?;
417
418 println!("Suspended '{username_str}'. Reason: {reason}");
419 Ok(())
420 }
421
422 async fn cmd_unsuspend(pool: &PgPool, username_str: &str) -> anyhow::Result<()> {
423 let username =
424 Username::new(username_str).map_err(|e| anyhow::anyhow!("invalid username: {e}"))?;
425
426 let user = db::users::get_user_by_username(pool, &username)
427 .await?
428 .ok_or_else(|| anyhow::anyhow!("user '{username_str}' not found"))?;
429
430 if !user.is_suspended() {
431 println!("'{username_str}' is not suspended.");
432 return Ok(());
433 }
434
435 let (_email, stripe, _admin_id) = moderation_context(pool)?;
436 moderation_service::unsuspend_creator(
437 pool,
438 stripe.as_ref(),
439 moderation_service::FanoutMode::Inline,
440 &user,
441 )
442 .await?;
443
444 println!("Unsuspended '{username_str}'.");
445 Ok(())
446 }
447
448 // ── Appeal commands ──
449
450 async fn cmd_appeals(pool: &PgPool) -> anyhow::Result<()> {
451 let users = db::users::get_pending_appeals(pool).await?;
452
453 if users.is_empty() {
454 println!("No pending appeals.");
455 return Ok(());
456 }
457
458 println!(
459 "{:<20} {:<30} {:<12} {:<12} Appeal Text",
460 "Username", "Email", "Suspended", "Appeal Date"
461 );
462 println!("{}", "-".repeat(110));
463
464 for user in &users {
465 let suspended = user
466 .suspended_at
467 .map_or_else(|| "-".to_string(), |t| t.format("%Y-%m-%d").to_string());
468 let appeal_date = user
469 .appeal_submitted_at
470 .map_or_else(|| "-".to_string(), |t| t.format("%Y-%m-%d").to_string());
471 let appeal = user.appeal_text.as_deref().unwrap_or("");
472 let appeal_short = if appeal.len() > 50 {
473 format!("{}...", truncate_display(appeal, 50))
474 } else {
475 appeal.to_string()
476 };
477 println!(
478 "{:<20} {:<30} {:<12} {:<12} {}",
479 user.username, user.email, suspended, appeal_date, appeal_short
480 );
481 }
482
483 println!("\n{} pending appeal(s).", users.len());
484 Ok(())
485 }
486
487 async fn cmd_decide(
488 pool: &PgPool,
489 username_str: &str,
490 decision_str: &str,
491 response: &str,
492 ) -> anyhow::Result<()> {
493 let username =
494 Username::new(username_str).map_err(|e| anyhow::anyhow!("invalid username: {e}"))?;
495
496 let user = db::users::get_user_by_username(pool, &username)
497 .await?
498 .ok_or_else(|| anyhow::anyhow!("user '{username_str}' not found"))?;
499
500 let decision: AppealDecision = decision_str.parse().map_err(|_| {
501 anyhow::anyhow!("invalid decision '{decision_str}': use 'approved' or 'denied'")
502 })?;
503
504 let response = response.trim();
505 if response.is_empty() {
506 return Err(anyhow::anyhow!("a response message is required"));
507 }
508
509 let (email, stripe, _admin_id) = moderation_context(pool)?;
510 moderation_service::decide_appeal(
511 pool,
512 &email,
513 stripe.as_ref(),
514 moderation_service::FanoutMode::Inline,
515 &user,
516 decision,
517 response,
518 )
519 .await?;
520
521 match decision {
522 AppealDecision::Approved => {
523 println!("Appeal approved for '{username_str}'. Suspension lifted.");
524 }
525 AppealDecision::Denied => {
526 println!("Appeal denied for '{username_str}'. Suspension remains.");
527 }
528 }
529 Ok(())
530 }
531
532 // ── Revenue & transaction commands ──
533
534 async fn cmd_revenue(pool: &PgPool) -> anyhow::Result<()> {
535 let (revenue_cents, completed, refunded) =
536 db::transactions::get_platform_revenue_stats(pool).await?;
537
538 println!("Platform Revenue");
539 // Platform-wide, so it spans every creator's currency. Rendered per
540 // currency rather than summed: there is no rate that makes one number true.
541 println!(
542 " Total revenue: {}",
543 revenue_cents.display(makenotwork::currency::SettlementCurrency::Usd)
544 );
545 println!(" Total sales: {completed}");
546 println!(" Total refunds: {refunded}");
547
548 Ok(())
549 }
550
551 async fn cmd_transactions(pool: &PgPool, username_str: &str) -> anyhow::Result<()> {
552 let username =
553 Username::new(username_str).map_err(|e| anyhow::anyhow!("invalid username: {e}"))?;
554
555 let user = db::users::get_user_by_username(pool, &username)
556 .await?
557 .ok_or_else(|| anyhow::anyhow!("user '{username_str}' not found"))?;
558
559 let txs = db::transactions::get_transactions_by_seller(pool, user.id, Some(50)).await?;
560
561 if txs.is_empty() {
562 println!("No transactions for '{username_str}'.");
563 return Ok(());
564 }
565
566 println!(
567 "{:<12} {:<30} {:>10} {:<10}",
568 "Date", "Item", "Amount", "Status"
569 );
570 println!("{}", "-".repeat(65));
571
572 // Accumulated per currency: this listing is not scoped to one creator.
573 let mut totals: Vec<(makenotwork::currency::SettlementCurrency, i64)> = Vec::new();
574 for tx in &txs {
575 let date = tx.created_at.format("%Y-%m-%d");
576 let title = tx.item_title.as_deref().unwrap_or("(deleted)");
577 let title_short = if title.len() > 28 {
578 format!("{}...", truncate_display(title, 25))
579 } else {
580 title.to_string()
581 };
582 let amount =
583 makenotwork::formatting::format_revenue(tx.amount_cents.as_i64(), tx.currency());
584 println!(
585 "{:<12} {:<30} {:>10} {:<10}",
586 date, title_short, amount, tx.status
587 );
588 if tx.status == TransactionStatus::Completed {
589 totals.push((tx.currency(), tx.amount_cents.as_i64()));
590 }
591 }
592
593 println!(
594 "\n{} transaction(s), {} total revenue.",
595 txs.len(),
596 makenotwork::currency::MoneyByCurrency::from_rows(totals)
597 .display(makenotwork::currency::SettlementCurrency::Usd)
598 );
599 Ok(())
600 }
601
602 // ── Export command ──
603
604 async fn cmd_export(pool: &PgPool, username_str: &str) -> anyhow::Result<()> {
605 let username =
606 Username::new(username_str).map_err(|e| anyhow::anyhow!("invalid username: {e}"))?;
607
608 let user = db::users::get_user_by_username(pool, &username)
609 .await?
610 .ok_or_else(|| anyhow::anyhow!("user '{username_str}' not found"))?;
611
612 let rows = db::transactions::get_seller_transactions_for_export(pool, user.id).await?;
613
614 // CSV header
615 println!("date,item_id,item_title,amount_cents,status,buyer_email");
616
617 for row in &rows {
618 let date = row.created_at.format("%Y-%m-%dT%H:%M:%SZ");
619 let item_id = row.item_id.map(|id| id.to_string()).unwrap_or_default();
620 let title =
621 makenotwork::formatting::sanitize_csv_cell(row.item_title.as_deref().unwrap_or(""));
622 let email =
623 makenotwork::formatting::sanitize_csv_cell(row.buyer_email.as_deref().unwrap_or(""));
624 println!(
625 "{},{},{},{},{},{}",
626 date, item_id, title, row.amount_cents, row.status, email
627 );
628 }
629
630 Ok(())
631 }
632
633 // ── Storage audit command ──
634
635 async fn cmd_storage(pool: &PgPool, username_str: &str) -> anyhow::Result<()> {
636 let username =
637 Username::new(username_str).map_err(|e| anyhow::anyhow!("invalid username: {e}"))?;
638
639 let user = db::users::get_user_by_username(pool, &username)
640 .await?
641 .ok_or_else(|| anyhow::anyhow!("user '{username_str}' not found"))?;
642
643 let item_keys = db::items::get_user_s3_keys(pool, user.id).await?;
644 let version_keys = db::versions::get_user_version_s3_keys(pool, user.id).await?;
645
646 if item_keys.is_empty() && version_keys.is_empty() {
647 println!("No S3 files for '{username_str}'.");
648 return Ok(());
649 }
650
651 println!("{:<10} {:<20} {:<25} S3 Key", "Type", "Project", "Item");
652 println!("{}", "-".repeat(100));
653
654 let mut item_file_count = 0u32;
655 for row in &item_keys {
656 if let Some(key) = &row.audio_s3_key {
657 println!(
658 "{:<10} {:<20} {:<25} {}",
659 "audio", row.project_slug, row.title, key
660 );
661 item_file_count += 1;
662 }
663 if let Some(key) = &row.cover_s3_key {
664 println!(
665 "{:<10} {:<20} {:<25} {}",
666 "cover", row.project_slug, row.title, key
667 );
668 item_file_count += 1;
669 }
670 }
671
672 for row in &version_keys {
673 if let Some(key) = &row.s3_key {
674 let label = format!("{} v{}", row.item_title, row.version_number);
675 let label_short = if label.len() > 23 {
676 format!("{}...", truncate_display(&label, 20))
677 } else {
678 label
679 };
680 println!(
681 "{:<10} {:<20} {:<25} {}",
682 "version", row.project_slug, label_short, key
683 );
684 }
685 }
686
687 let version_file_count = version_keys.iter().filter(|r| r.s3_key.is_some()).count();
688 println!("\n{item_file_count} item file(s), {version_file_count} version file(s).");
689 Ok(())
690 }
691
692 // ── Build hooks command ──
693
694 fn cmd_install_hooks() -> anyhow::Result<()> {
695 let token = std::env::var("BUILD_TRIGGER_TOKEN")
696 .map_err(|_| anyhow::anyhow!("BUILD_TRIGGER_TOKEN must be set"))?;
697
698 let git_root = std::env::var("GIT_REPOS_PATH").unwrap_or_else(|_| "/opt/git".to_string());
699
700 let mut installed = 0u32;
701
702 let root = std::path::Path::new(&git_root);
703 if !root.exists() {
704 anyhow::bail!("git root {git_root} does not exist");
705 }
706
707 for owner_entry in std::fs::read_dir(root)? {
708 let owner_entry = owner_entry?;
709 if !owner_entry.file_type()?.is_dir() {
710 continue;
711 }
712 let owner_name = owner_entry.file_name().to_string_lossy().to_string();
713 for repo_entry in std::fs::read_dir(owner_entry.path())? {
714 let repo_entry = repo_entry?;
715 let repo_path = repo_entry.path();
716 let repo_name = match repo_path.file_name().and_then(|n| n.to_str()) {
717 Some(n)
718 if std::path::Path::new(n).extension().and_then(|e| e.to_str())
719 == Some("git") =>
720 {
721 n.trim_end_matches(".git")
722 }
723 _ => continue,
724 };
725 if !repo_path.is_dir() {
726 continue;
727 }
728
729 makenotwork::git_ssh::install_hooks_for_repo(
730 &repo_path,
731 Some(&token),
732 &owner_name,
733 repo_name,
734 )?;
735 installed += 1;
736 }
737 }
738
739 println!("Installed hooks on {installed} repo(s).");
740 Ok(())
741 }
742
743 // ── Notes index command ──
744
745 /// Rebuild the `git_notes` index from what the repositories hold.
746 ///
747 /// The index is a projection and the repositories are truth, so this is always
748 /// safe to run: it walks every namespace of every repository and makes the table
749 /// agree with the refs. Reindexing a namespace the index already holds costs one
750 /// ref read, so a full run over an up-to-date platform is cheap enough to be the
751 /// answer to "is the index right" rather than a thing to be careful about.
752 async fn cmd_reindex_notes(pool: &PgPool, username: Option<&str>) -> anyhow::Result<()> {
753 let config = Config::from_env().map_err(|e| anyhow::anyhow!("failed to load config: {e}"))?;
754
755 let repos = db::git_repos::all_repos_with_owner(pool).await?;
756 let repos: Vec<_> = repos
757 .into_iter()
758 .filter(|(_, owner, _)| username.is_none_or(|wanted| owner == wanted))
759 .collect();
760 if repos.is_empty() {
761 println!("No repositories to reindex.");
762 return Ok(());
763 }
764
765 let mut done = 0u32;
766 let mut failed = 0u32;
767 for (repo_id, owner, name) in &repos {
768 match makenotwork::routes::git::notes_index::reindex_repo(
769 pool, &config, *repo_id, owner, name,
770 )
771 .await
772 {
773 Ok(()) => done += 1,
774 // One unreadable repository must not stop the rest: the whole point
775 // of the command is to make the index agree with the disk, and
776 // stopping at the first problem leaves it disagreeing everywhere
777 // after it.
778 Err(e) => {
779 failed += 1;
780 eprintln!("{owner}/{name}: {e}");
781 }
782 }
783 }
784
785 println!("Reindexed notes for {done} repo(s), {failed} failed.");
786 Ok(())
787 }
788
789 /// One-time backfill: apply the standard bare-repo resource limits to every
790 /// existing repo on disk. New repos get these at creation via
791 /// `git::init_bare_repo`; this brings repos created before that landed up to par.
792 fn cmd_backfill_git_config() -> anyhow::Result<()> {
793 let git_root = std::env::var("GIT_REPOS_PATH").unwrap_or_else(|_| "/opt/git".to_string());
794 let root = std::path::Path::new(&git_root);
795 if !root.exists() {
796 anyhow::bail!("git root {git_root} does not exist");
797 }
798
799 let mut updated = 0u32;
800 for owner_entry in std::fs::read_dir(root)? {
801 let owner_entry = owner_entry?;
802 if !owner_entry.file_type()?.is_dir() {
803 continue;
804 }
805 for repo_entry in std::fs::read_dir(owner_entry.path())? {
806 let repo_entry = repo_entry?;
807 let repo_path = repo_entry.path();
808 let is_bare = repo_path
809 .file_name()
810 .and_then(|n| n.to_str())
811 .is_some_and(|n| {
812 std::path::Path::new(n)
813 .extension()
814 .is_some_and(|e| e == "git")
815 });
816 if !is_bare || !repo_path.is_dir() {
817 continue;
818 }
819 match gix::open(&repo_path) {
820 Ok(repo) => {
821 makenotwork::git::apply_bare_repo_limits(&repo)?;
822 updated += 1;
823 }
824 Err(e) => {
825 eprintln!("[backfill] skipping {}: {e}", repo_path.display());
826 }
827 }
828 }
829 }
830
831 println!("Applied resource limits to {updated} repo(s).");
832 Ok(())
833 }
834
835 fn cmd_setup_git() -> anyhow::Result<()> {
836 use std::fs;
837 use std::os::unix::fs::PermissionsExt;
838 use std::path::Path;
839
840 let authorized_keys = makenotwork::git_ssh::authorized_keys_path();
841 let ssh_dir = authorized_keys
842 .parent()
843 .expect("authorized_keys_path always has a .ssh parent");
844 let sudoers_file = Path::new("/etc/sudoers.d/mnw-git-ssh");
845 let mnw_admin = Path::new(makenotwork::git_ssh::MNW_ADMIN_PATH);
846
847 // 1. Create git user's .ssh directory
848 if !ssh_dir.exists() {
849 fs::create_dir_all(ssh_dir)?;
850 println!("[setup] Created {}", ssh_dir.display());
851 }
852 fs::set_permissions(ssh_dir, fs::Permissions::from_mode(0o700))?;
853 chown("git:git", ssh_dir)?;
854
855 // 2. Create authorized_keys
856 if !authorized_keys.exists() {
857 fs::write(&authorized_keys, "")?;
858 println!("[setup] Created {}", authorized_keys.display());
859 }
860 fs::set_permissions(&authorized_keys, fs::Permissions::from_mode(0o600))?;
861 chown("git:git", &authorized_keys)?;
862
863 // 3. Check mnw-admin binary
864 if !mnw_admin.exists() {
865 println!(
866 "[setup] WARNING: {} not found. Deploy the binary first.",
867 mnw_admin.display()
868 );
869 }
870
871 // 4. Install sudoers rule
872 if sudoers_file.exists() {
873 println!(
874 "[setup] Sudoers rule already exists: {}",
875 sudoers_file.display()
876 );
877 } else {
878 let rule = format!(
879 "makenotwork ALL=(git) NOPASSWD: {} rebuild-keys\n",
880 mnw_admin.display(),
881 );
882 fs::write(sudoers_file, &rule)?;
883 fs::set_permissions(sudoers_file, fs::Permissions::from_mode(0o440))?;
884 println!("[setup] Created sudoers rule: {}", sudoers_file.display());
885
886 // Verify syntax
887 let status = std::process::Command::new("visudo")
888 .args(["-cf", &sudoers_file.to_string_lossy()])
889 .status()?;
890 if !status.success() {
891 anyhow::bail!(
892 "sudoers syntax check failed, fix {} manually",
893 sudoers_file.display()
894 );
895 }
896 }
897
898 println!("[setup] Git SSH infrastructure ready.");
899 println!(" Users add SSH keys via the dashboard.");
900 println!(" Clone: git clone git@makenot.work:{{username}}/{{repo}}.git");
901 Ok(())
902 }
903
904 /// Run `chown <spec> <path>`.
905 fn chown(spec: &str, path: &std::path::Path) -> anyhow::Result<()> {
906 let status = std::process::Command::new("chown")
907 .args([spec, &path.to_string_lossy()])
908 .status()?;
909 if !status.success() {
910 anyhow::bail!("chown {} {} failed", spec, path.display());
911 }
912 Ok(())
913 }
914
915 async fn cmd_rebuild_keys(pool: &PgPool) -> anyhow::Result<()> {
916 let key_count = db::ssh_keys::get_all_keys_with_username(pool).await?.len();
917 makenotwork::git_ssh::write_authorized_keys(pool, true).await?;
918 println!("Rebuilt authorized_keys with {key_count} key(s).");
919 Ok(())
920 }
921
922 async fn cmd_git_auth(pool: &PgPool, key_id_str: &str) -> anyhow::Result<()> {
923 makenotwork::git_ssh::dispatch(pool, key_id_str).await
924 }
925