Skip to main content

max / makenotwork

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