diff --git a/lib/crates/fabro-cli/src/commands/install.rs b/lib/crates/fabro-cli/src/commands/install.rs index 9d8633547..88a8be5c9 100644 --- a/lib/crates/fabro-cli/src/commands/install.rs +++ b/lib/crates/fabro-cli/src/commands/install.rs @@ -27,6 +27,7 @@ use tokio::task::spawn_blocking; use super::doctor; use crate::args::{DoctorArgs, GlobalArgs, InstallArgs, ServerTargetArgs}; use crate::commands::server::record; +use crate::gh::GhCli; use crate::server_client; use crate::shared::provider_auth::{ prompt_and_validate_key, prompt_confirm, provider_display_name, run_openai_oauth_or_api_key, @@ -300,6 +301,100 @@ fn prompt_multiselect(prompt: &str, items: &[String]) -> Result> { .interact_on(&Term::stderr())?) } +// --------------------------------------------------------------------------- +// GitHub App owner selection +// --------------------------------------------------------------------------- + +enum GitHubAppOwner { + Personal, + Organization(String), +} + +impl GitHubAppOwner { + fn manifest_form_action(&self) -> String { + match self { + Self::Personal => "https://github.com/settings/apps/new".to_string(), + Self::Organization(org) => { + format!("https://github.com/organizations/{org}/settings/apps/new") + } + } + } + + fn app_name(&self, username: Option<&str>) -> String { + match self { + Self::Organization(org) => format!("{org}-fabro"), + Self::Personal => { + if let Some(user) = username { + format!("{user}-fabro") + } else { + let mut rng = rand::thread_rng(); + let suffix: String = (0..6).fold(String::with_capacity(6), |mut s, _| { + use std::fmt::Write; + let _ = write!(s, "{:x}", rng.gen::() % 16); + s + }); + format!("Fabro-{suffix}") + } + } + } + } +} + +/// Ask the user where to create the GitHub App. +/// +/// Uses the `gh` CLI to discover the username and admin orgs. If `gh` is +/// unavailable or the user has no admin orgs, falls back gracefully. +/// Always offers a manual "Other" option so org app managers can enter a slug. +/// +/// Returns `(owner, username)`. +async fn prompt_github_app_owner(_s: &Styles) -> Result<(GitHubAppOwner, Option)> { + let spinner = indicatif::ProgressBar::new_spinner(); + spinner.set_style( + indicatif::ProgressStyle::with_template("{spinner:.cyan} {msg}") + .expect("valid template") + .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏", ""]), + ); + spinner.set_message("Checking GitHub CLI..."); + spinner.enable_steady_tick(std::time::Duration::from_millis(80)); + + let Some(gh) = GhCli::detect().await else { + spinner.finish_and_clear(); + return Ok((GitHubAppOwner::Personal, None)); + }; + + let (username, orgs) = tokio::join!(gh.authenticated_user(), gh.list_admin_orgs()); + spinner.finish_and_clear(); + + // Build the selection menu + let personal_label = match &username { + Some(user) => format!("Personal account ({user})"), + None => "Personal account".to_string(), + }; + let mut items = vec![personal_label]; + for org in &orgs { + items.push(format!("Organization: {org}")); + } + items.push("Other (enter organization name)".to_string()); + + let selected: usize = spawn_blocking({ + let items = items.clone(); + move || prompt_select("Where should the GitHub App be created?", &items) + }) + .await??; + + let other_index = 1 + orgs.len(); + let owner = if selected == 0 { + GitHubAppOwner::Personal + } else if selected == other_index { + let org_slug: String = spawn_blocking(|| prompt_input("Organization name")).await??; + GitHubAppOwner::Organization(org_slug) + } else { + GitHubAppOwner::Organization(orgs[selected - 1].clone()) + }; + + Ok((owner, username)) +} + // --------------------------------------------------------------------------- // GitHub App manifest flow // --------------------------------------------------------------------------- @@ -335,15 +430,10 @@ async fn setup_github_app( fabro_dir: &Path, s: &Styles, web_url: &str, + owner: &GitHubAppOwner, + username: Option<&str>, ) -> Result> { - // Random suffix so app names don't collide - let mut rng = rand::thread_rng(); - let suffix: String = (0..6).fold(String::with_capacity(6), |mut s, _| { - use std::fmt::Write; - let _ = write!(s, "{:x}", rng.gen::() % 16); - s - }); - let app_name = format!("Fabro-{suffix}"); + let app_name = owner.app_name(username); // Bind to random port let listener = TcpListener::bind("127.0.0.1:0") @@ -369,12 +459,13 @@ async fn setup_github_app( let code_tx = std::sync::Arc::new(std::sync::Mutex::new(Some(code_tx))); let shutdown_tx = std::sync::Arc::new(std::sync::Mutex::new(Some(shutdown_tx))); + let form_action = owner.manifest_form_action(); let index_html = format!( r#"

Redirecting to GitHub...

-
+
@@ -741,7 +832,9 @@ pub(crate) async fn run_install(args: &InstallArgs, globals: &GlobalArgs) -> Res spawn_blocking(|| prompt_confirm("Set up a GitHub App? (Recommended)", true)).await??; if setup_github { - let github_env_pairs = setup_github_app(&fabro_dir, &s, web_url).await?; + let (owner, username) = prompt_github_app_owner(&s).await?; + let github_env_pairs = + setup_github_app(&fabro_dir, &s, web_url, &owner, username.as_deref()).await?; let slug = { let user_toml_path = fabro_dir.join(SETTINGS_CONFIG_FILENAME); let toml_content = std::fs::read_to_string(&user_toml_path).unwrap_or_default(); @@ -1137,6 +1230,46 @@ name = "custom" ); } + // -- GitHub App owner -- + + #[test] + fn github_app_owner_personal_url() { + let owner = GitHubAppOwner::Personal; + assert_eq!( + owner.manifest_form_action(), + "https://github.com/settings/apps/new" + ); + } + + #[test] + fn github_app_owner_org_url() { + let owner = GitHubAppOwner::Organization("my-org".to_string()); + assert_eq!( + owner.manifest_form_action(), + "https://github.com/organizations/my-org/settings/apps/new" + ); + } + + #[test] + fn github_app_owner_app_name_with_org() { + let owner = GitHubAppOwner::Organization("acme-corp".to_string()); + assert_eq!(owner.app_name(Some("alice")), "acme-corp-fabro"); + } + + #[test] + fn github_app_owner_app_name_personal_with_username() { + let owner = GitHubAppOwner::Personal; + assert_eq!(owner.app_name(Some("brynary")), "brynary-fabro"); + } + + #[test] + fn github_app_owner_app_name_personal_without_username() { + let owner = GitHubAppOwner::Personal; + let name = owner.app_name(None); + assert!(name.starts_with("Fabro-"), "expected Fabro- prefix: {name}"); + assert_eq!(name.len(), 12); // "Fabro-" (6) + 6 hex chars + } + // -- GitHub App manifest -- #[test] diff --git a/lib/crates/fabro-cli/src/gh.rs b/lib/crates/fabro-cli/src/gh.rs new file mode 100644 index 000000000..cfd7fc275 --- /dev/null +++ b/lib/crates/fabro-cli/src/gh.rs @@ -0,0 +1,104 @@ +use tokio::process::Command; +use tracing::debug; + +/// Best-effort wrapper around the `gh` CLI. +/// +/// All methods degrade gracefully: if `gh` is missing or not authenticated +/// the caller receives `None` / empty results rather than errors. +pub(crate) struct GhCli { + _private: (), +} + +impl GhCli { + /// Attempt to find an authenticated `gh` CLI on PATH. + /// + /// Returns `None` if `gh` is not installed or not authenticated + /// against github.com. + pub(crate) async fn detect() -> Option { + let version = Command::new("gh").arg("--version").output().await; + let Ok(output) = version else { + debug!("gh CLI not found on PATH"); + return None; + }; + if !output.status.success() { + debug!("gh --version failed"); + return None; + } + + let auth = Command::new("gh") + .args(["auth", "status", "--hostname", "github.com"]) + .output() + .await; + match auth { + Ok(o) if o.status.success() => { + debug!("gh CLI available and authenticated for github.com"); + Some(Self { _private: () }) + } + _ => { + debug!("gh is not authenticated for github.com"); + None + } + } + } + + /// Return the login of the authenticated GitHub user, or `None` on failure. + pub(crate) async fn authenticated_user(&self) -> Option { + let output = Command::new("gh") + .args(["api", "--hostname", "github.com", "/user", "--jq", ".login"]) + .output() + .await + .ok()?; + if !output.status.success() { + debug!("gh api /user failed"); + return None; + } + let login = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if login.is_empty() { None } else { Some(login) } + } + + /// List GitHub organizations the authenticated user is an admin of. + /// + /// Returns an empty vec on any failure (network, parse, etc.). + pub(crate) async fn list_admin_orgs(&self) -> Vec { + let output = Command::new("gh") + .args([ + "api", + "--hostname", + "github.com", + "--paginate", + "/user/memberships/orgs", + "--jq", + r#".[] | select(.role == "admin" and .state == "active") | .organization.login"#, + ]) + .output() + .await; + let Ok(output) = output else { + debug!("gh api /user/memberships/orgs failed to execute"); + return Vec::new(); + }; + if !output.status.success() { + debug!( + "gh api /user/memberships/orgs exited with {}", + output.status + ); + return Vec::new(); + } + String::from_utf8_lossy(&output.stdout) + .lines() + .filter(|line| !line.is_empty()) + .map(String::from) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn detect_does_not_panic() { + // Validates graceful degradation — in CI where gh may not be installed + // this returns None without panicking. + let _result = GhCli::detect().await; + } +} diff --git a/lib/crates/fabro-cli/src/main.rs b/lib/crates/fabro-cli/src/main.rs index 1a2416d2e..012d757fe 100644 --- a/lib/crates/fabro-cli/src/main.rs +++ b/lib/crates/fabro-cli/src/main.rs @@ -3,6 +3,7 @@ mod args; mod command_context; mod commands; +mod gh; mod logging; mod manifest_builder; mod server_client;