Skip to main content

max / makenotwork

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