mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-11-10 08:28:25 +00:00
feat(cli): target the configured GitHub host in install, gh and doctor
This commit is contained in:
parent
8eaaa1f2c2
commit
9335e89c7d
3 changed files with 148 additions and 34 deletions
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue