From 9335e89c7db8ff507717fa30d2968fa2c3cbe321 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?D=C3=A1vid=20Balatoni?= Date: Sun, 25 Oct 2026 01:13:46 +0200 Subject: [PATCH] feat(cli): target the configured GitHub host in install, gh and doctor --- lib/apps/fabro-cli/src/commands/doctor.rs | 41 ++++++++++- lib/apps/fabro-cli/src/commands/install.rs | 61 ++++++++++++----- lib/apps/fabro-cli/src/gh.rs | 80 ++++++++++++++++++---- 3 files changed, 148 insertions(+), 34 deletions(-) diff --git a/lib/apps/fabro-cli/src/commands/doctor.rs b/lib/apps/fabro-cli/src/commands/doctor.rs index 4765c7da1..0480d9ac9 100644 --- a/lib/apps/fabro-cli/src/commands/doctor.rs +++ b/lib/apps/fabro-cli/src/commands/doctor.rs @@ -3,6 +3,7 @@ use std::path::{Path, PathBuf}; use anyhow::Result; use fabro_api::types as api_types; use fabro_config::user::active_settings_path; +use fabro_github::GitHubHost; use fabro_types::settings::replace_wildcard_host; pub(crate) use fabro_util::check_report::{ CheckDetail, CheckReport, CheckResult, CheckSection, CheckStatus, @@ -125,10 +126,14 @@ fn wildcard_public_url_details(path: &Path) -> Vec { .unwrap_or(&bad_urls[0]) .suggestion .as_str(); + let github_host = toml_string_at(&doc, &["server", "integrations", "github", "web_url"]) + .and_then(|web_url| GitHubHost::new(web_url, None).ok()) + .and_then(|host| fabro_github::with_env_overrides(&host).ok()) + .unwrap_or_default(); let github_settings_url = toml_string_at(&doc, &["server", "integrations", "github", "slug"]) .map_or_else( || "the GitHub App settings page".to_string(), - |slug| format!("https://github.com/settings/apps/{slug}"), + |slug| format!("{}/settings/apps/{slug}", github_host.web_url()), ); details.push(CheckDetail { text: format!( @@ -470,6 +475,40 @@ url = "http://0.0.0.0:32276" assert!(details.contains("https://github.com/settings/apps/octocat-fabro")); } + #[test] + #[expect( + clippy::disallowed_methods, + reason = "unit test stages a temporary settings.toml fixture with sync std::fs" + )] + fn check_config_links_the_github_app_on_the_enterprise_host() { + let dir = tempfile::tempdir().unwrap(); + let settings_path = dir.path().join("settings.toml"); + std::fs::write( + &settings_path, + r#" +_version = 1 + +[server.web] +url = "http://0.0.0.0:32276" + +[server.integrations.github] +slug = "octocat-fabro" +web_url = "https://ghe.example.com" +"#, + ) + .unwrap(); + + let result = check_config(Some(settings_path)); + let details = result + .details + .iter() + .map(|detail| detail.text.as_str()) + .collect::>() + .join("\n"); + + assert!(details.contains("https://ghe.example.com/settings/apps/octocat-fabro")); + } + #[test] fn check_version_parity_warns_on_mismatch() { let result = check_version_parity("0.0.0-test"); diff --git a/lib/apps/fabro-cli/src/commands/install.rs b/lib/apps/fabro-cli/src/commands/install.rs index f5edbcdc0..a5e35da39 100644 --- a/lib/apps/fabro-cli/src/commands/install.rs +++ b/lib/apps/fabro-cli/src/commands/install.rs @@ -27,6 +27,7 @@ use fabro_config::bind::Bind; use fabro_config::daemon::ServerDaemon; use fabro_config::user::{SETTINGS_CONFIG_FILENAME, default_storage_dir}; use fabro_config::{Storage, UserSettingsBuilder, envfile}; +use fabro_github::GitHubHost; use fabro_install::{ GITHUB_APP_VAULT_KEYS, GITHUB_INSTALL_SECRET_KEYS, InstallListenConfig, InstallPersistencePlan, PendingDevTokenWrite, PendingSettingsWrite, SecretStoreWrite, @@ -498,7 +499,8 @@ impl InstallInputSource for InteractiveInstallInputSource { s: &Styles, _printer: Printer, ) -> Result { - let gh_available = GhCli::detect().await.is_some(); + let github_host = crate::user_config::github_host()?; + let gh_available = GhCli::detect(&github_host).await.is_some(); let token_label = if gh_available { "Personal Access Token — use your existing `gh` login" @@ -518,7 +520,7 @@ impl InstallInputSource for InteractiveInstallInputSource { match strategy { 0 => { let token = if gh_available { - fabro_github::gh_auth_token(&fabro_github::GitHubHost::default()) + fabro_github::gh_auth_token(&github_host) .await .context("Run `gh auth login` and rerun `fabro install`.")? } else { @@ -528,7 +530,7 @@ impl InstallInputSource for InteractiveInstallInputSource { Ok(GitHubInstallSelection::Token { token }) } 1 => { - let (owner, username) = prompt_github_app_owner(s).await?; + let (owner, username) = prompt_github_app_owner(s, &github_host).await?; Ok(GitHubInstallSelection::App { owner, username }) } _ => unreachable!("prompt_select returned an out-of-range index"), @@ -694,9 +696,10 @@ impl InstallInputSource for NonInteractiveInstallInputSource { _s: &Styles, _printer: Printer, ) -> Result { + let github_host = crate::user_config::github_host()?; match self.args.github_strategy { Some(InstallGitHubStrategyArg::Token) => { - let token = fabro_github::gh_auth_token(&fabro_github::GitHubHost::default()) + let token = fabro_github::gh_auth_token(&github_host) .await .context("Run `gh auth login` and rerun `fabro install`.")?; fabro_github::validate_static_github_token(&token)?; @@ -708,7 +711,7 @@ impl InstallInputSource for NonInteractiveInstallInputSource { "non-interactive install requires --github-owner for --github-strategy app", )?, )?, - username: best_effort_github_username().await, + username: best_effort_github_username(&github_host).await, }), None => bail!("non-interactive install requires --github-strategy"), } @@ -780,9 +783,10 @@ async fn choose_install_github_selection( return input.choose_github_install(s, printer).await; } + let github_host = crate::user_config::github_host()?; match github_args.strategy { Some(InstallGitHubStrategyArg::Token) => { - let token = fabro_github::gh_auth_token(&fabro_github::GitHubHost::default()) + let token = fabro_github::gh_auth_token(&github_host) .await .context("Run `gh auth login` and rerun `fabro install github`.")?; fabro_github::validate_static_github_token(&token)?; @@ -792,7 +796,7 @@ async fn choose_install_github_selection( owner: GitHubAppOwner::parse_scripted(github_args.owner.as_deref().context( "install github --non-interactive requires --owner for --strategy app", )?)?, - username: best_effort_github_username().await, + username: best_effort_github_username(&github_host).await, }), None => bail!("install github --non-interactive requires --strategy"), } @@ -824,11 +828,12 @@ impl GitHubAppOwner { Ok(Self::Organization(org.to_string())) } - fn manifest_form_action(&self) -> String { + fn manifest_form_action(&self, github_host: &GitHubHost) -> String { + let web_url = github_host.web_url(); match self { - Self::Personal => "https://github.com/settings/apps/new".to_string(), + Self::Personal => format!("{web_url}/settings/apps/new"), Self::Organization(org) => { - format!("https://github.com/organizations/{org}/settings/apps/new") + format!("{web_url}/organizations/{org}/settings/apps/new") } } } @@ -860,8 +865,8 @@ impl GitHubAppOwner { } } -async fn best_effort_github_username() -> Option { - let gh = GhCli::detect().await?; +async fn best_effort_github_username(github_host: &GitHubHost) -> Option { + let gh = GhCli::detect(github_host).await?; gh.authenticated_user().await } @@ -872,10 +877,13 @@ async fn best_effort_github_username() -> Option { /// 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)> { +async fn prompt_github_app_owner( + _s: &Styles, + github_host: &GitHubHost, +) -> Result<(GitHubAppOwner, Option)> { let spinner = cyan_spinner("Checking GitHub CLI..."); - let Some(gh) = GhCli::detect().await else { + let Some(gh) = GhCli::detect(github_host).await else { spinner.finish_and_clear(); return Ok((GitHubAppOwner::Personal, None)); }; @@ -974,6 +982,7 @@ async fn setup_github_app( printer: Printer, ) -> Result { let app_name = owner.app_name(username); + let github_host = crate::user_config::github_host()?; // Bind to random port let listener = TcpListener::bind("127.0.0.1:0") @@ -999,7 +1008,7 @@ 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 form_action = owner.manifest_form_action(&github_host); let index_html = format!( r#" @@ -1117,7 +1126,8 @@ async fn setup_github_app( let client = fabro_http::http_client()?; let resp = client .post(format!( - "https://api.github.com/app-manifests/{code}/conversions" + "{}/app-manifests/{code}/conversions", + github_host.api_url() )) .header("Accept", "application/vnd.github+json") .header("User-Agent", "fabro-cli") @@ -1159,7 +1169,7 @@ async fn setup_github_app( printer, " {}", s.dim - .apply_to(format!("App: https://github.com/apps/{slug}")) + .apply_to(format!("App: {}/apps/{slug}", github_host.web_url())) ); // Return secret pairs @@ -2569,16 +2579,29 @@ client_id = "client-id" fn github_app_owner_personal_url() { let owner = GitHubAppOwner::Personal; assert_eq!( - owner.manifest_form_action(), + owner.manifest_form_action(&GitHubHost::default()), "https://github.com/settings/apps/new" ); } + #[test] + fn github_app_owner_urls_follow_the_enterprise_host() { + let host = GitHubHost::new("https://ghe.example.com", None).unwrap(); + assert_eq!( + GitHubAppOwner::Personal.manifest_form_action(&host), + "https://ghe.example.com/settings/apps/new" + ); + assert_eq!( + GitHubAppOwner::Organization("my-org".to_string()).manifest_form_action(&host), + "https://ghe.example.com/organizations/my-org/settings/apps/new" + ); + } + #[test] fn github_app_owner_org_url() { let owner = GitHubAppOwner::Organization("my-org".to_string()); assert_eq!( - owner.manifest_form_action(), + owner.manifest_form_action(&GitHubHost::default()), "https://github.com/organizations/my-org/settings/apps/new" ); } diff --git a/lib/apps/fabro-cli/src/gh.rs b/lib/apps/fabro-cli/src/gh.rs index 8f9d149ec..62078b03f 100644 --- a/lib/apps/fabro-cli/src/gh.rs +++ b/lib/apps/fabro-cli/src/gh.rs @@ -1,3 +1,4 @@ +use fabro_github::GitHubHost; use tokio::process::Command; use tracing::debug; @@ -6,19 +7,20 @@ use tracing::debug; /// 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: (), + command: String, + host: GitHubHost, } 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::detect_with_command("gh").await + /// against the configured GitHub host. + pub(crate) async fn detect(host: &GitHubHost) -> Option { + Self::detect_with_command("gh", host).await } - async fn detect_with_command(command: &str) -> Option { + async fn detect_with_command(command: &str, host: &GitHubHost) -> Option { let version = Command::new(command).arg("--version").output().await; let Ok(output) = version else { debug!("gh CLI not found on PATH"); @@ -30,16 +32,19 @@ impl GhCli { } let auth = Command::new(command) - .args(["auth", "status", "--hostname", "github.com"]) + .args(["auth", "status", "--hostname", host.host()]) .output() .await; match auth { Ok(o) if o.status.success() => { - debug!("gh CLI available and authenticated for github.com"); - Some(Self { _private: () }) + debug!("gh CLI available and authenticated for {}", host.host()); + Some(Self { + command: command.to_string(), + host: host.clone(), + }) } _ => { - debug!("gh is not authenticated for github.com"); + debug!("gh is not authenticated for {}", host.host()); None } } @@ -47,8 +52,15 @@ impl GhCli { /// 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"]) + let output = Command::new(&self.command) + .args([ + "api", + "--hostname", + self.host.host(), + "/user", + "--jq", + ".login", + ]) .output() .await .ok()?; @@ -64,11 +76,11 @@ impl GhCli { /// /// Returns an empty vec on any failure (network, parse, etc.). pub(crate) async fn list_admin_orgs(&self) -> Vec { - let output = Command::new("gh") + let output = Command::new(&self.command) .args([ "api", "--hostname", - "github.com", + self.host.host(), "--paginate", "/user/memberships/orgs", "--jq", @@ -101,7 +113,47 @@ mod tests { #[tokio::test] async fn detect_returns_none_when_gh_is_missing() { - let result = GhCli::detect_with_command("fabro-test-gh-that-should-not-exist").await; + let result = GhCli::detect_with_command( + "fabro-test-gh-that-should-not-exist", + &GitHubHost::default(), + ) + .await; assert!(result.is_none()); } + + #[cfg(unix)] + #[tokio::test] + async fn gh_invocations_target_the_configured_host() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let log = dir.path().join("args.log"); + let script = dir.path().join("gh"); + std::fs::write( + &script, + format!("#!/bin/sh\necho \"$@\" >> '{}'\n", log.display()), + ) + .unwrap(); + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); + + for (web_url, hostname) in [ + ("https://github.com", "github.com"), + ("https://ghe.example.com:8443", "ghe.example.com:8443"), + ] { + let host = GitHubHost::new(web_url, None).unwrap(); + let gh = GhCli::detect_with_command(script.to_str().unwrap(), &host) + .await + .unwrap(); + gh.authenticated_user().await; + gh.list_admin_orgs().await; + + let logged = std::fs::read_to_string(&log).unwrap(); + std::fs::remove_file(&log).unwrap(); + let lines = logged.lines().collect::>(); + assert_eq!(lines[0], "--version"); + assert_eq!(lines[1], format!("auth status --hostname {hostname}")); + assert!(lines[2].starts_with(&format!("api --hostname {hostname} /user"))); + assert!(lines[3].starts_with(&format!("api --hostname {hostname} --paginate"))); + } + } }