Merge remote-tracking branch 'origin/main'

This commit is contained in:
Bryan Helmkamp 2026-04-10 12:02:05 -04:00
commit 9b0eeb3814
No known key found for this signature in database
3 changed files with 248 additions and 10 deletions

View file

@ -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<Vec<usize>> {
.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::<u8>() % 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<String>)> {
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<Vec<(String, String)>> {
// 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::<u8>() % 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#"<!DOCTYPE html>
<html>
<body>
<p>Redirecting to GitHub...</p>
<form id="f" method="post" action="https://github.com/settings/apps/new">
<form id="f" method="post" action="{form_action}">
<input type="hidden" name="manifest" value="{escaped_manifest}">
</form>
<script>document.getElementById('f').submit();</script>
@ -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]

View file

@ -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<Self> {
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<String> {
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<String> {
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;
}
}

View file

@ -3,6 +3,7 @@
mod args;
mod command_context;
mod commands;
mod gh;
mod logging;
mod manifest_builder;
mod server_client;