feat(cli): target the configured GitHub host in install, gh and doctor

This commit is contained in:
Dávid Balatoni 2026-10-25 01:13:46 +02:00
parent 8eaaa1f2c2
commit 9335e89c7d
No known key found for this signature in database
3 changed files with 148 additions and 34 deletions

View file

@ -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<CheckDetail> {
.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::<Vec<_>>()
.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");

View file

@ -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<GitHubInstallSelection> {
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<GitHubInstallSelection> {
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<String> {
let gh = GhCli::detect().await?;
async fn best_effort_github_username(github_host: &GitHubHost) -> Option<String> {
let gh = GhCli::detect(github_host).await?;
gh.authenticated_user().await
}
@ -872,10 +877,13 @@ async fn best_effort_github_username() -> Option<String> {
/// 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>)> {
async fn prompt_github_app_owner(
_s: &Styles,
github_host: &GitHubHost,
) -> Result<(GitHubAppOwner, Option<String>)> {
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<GitHubAppRegistration> {
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#"<!DOCTYPE html>
<html>
@ -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"
);
}

View file

@ -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> {
Self::detect_with_command("gh").await
/// against the configured GitHub host.
pub(crate) async fn detect(host: &GitHubHost) -> Option<Self> {
Self::detect_with_command("gh", host).await
}
async fn detect_with_command(command: &str) -> Option<Self> {
async fn detect_with_command(command: &str, host: &GitHubHost) -> Option<Self> {
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<String> {
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<String> {
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::<Vec<_>>();
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")));
}
}
}