Skip to main content

max / makenotwork

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