diff --git a/apps/fabro-web/app/components/automation-form.test.tsx b/apps/fabro-web/app/components/automation-form.test.tsx index d2e89406d..c738536b4 100644 --- a/apps/fabro-web/app/components/automation-form.test.tsx +++ b/apps/fabro-web/app/components/automation-form.test.tsx @@ -25,6 +25,31 @@ describe("automation workflow source form values", () => { expect(workflowSourceFromFormValues(values)).toBeUndefined(); }); + test("create-from-run reads the repository from origins on the configured GitHub host", () => { + const fromOrigin = (originUrl: string, githubWebUrl?: string) => + automationFormValuesFromRun( + { + title: "Release", + workflow: { name: "Release", graph_name: "release", slug: "release" }, + repository: { name: "", origin_url: originUrl }, + sandbox: null, + } as any, + null, + null, + undefined, + githubWebUrl, + ).targetRepository; + + expect(fromOrigin("https://github.com/acme/widgets.git")).toBe("acme/widgets"); + expect(fromOrigin("git@github.com:acme/widgets.git")).toBe("acme/widgets"); + expect(fromOrigin("https://ghe.example.com/acme/widgets.git")).toBe(""); + + const ghes = "https://ghe.example.com"; + expect(fromOrigin("https://ghe.example.com/acme/widgets.git", ghes)).toBe("acme/widgets"); + expect(fromOrigin("git@ghe.example.com:acme/widgets.git", ghes)).toBe("acme/widgets"); + expect(fromOrigin("https://github.com/acme/widgets.git", ghes)).toBe(""); + }); + test("branch, tag, and SHA selectors serialize with target precedence", () => { const base = { ...EMPTY_AUTOMATION_FORM, diff --git a/apps/fabro-web/app/components/automation-form.tsx b/apps/fabro-web/app/components/automation-form.tsx index bd9d3a8ce..6e8db8811 100644 --- a/apps/fabro-web/app/components/automation-form.tsx +++ b/apps/fabro-web/app/components/automation-form.tsx @@ -100,6 +100,7 @@ export function automationFormValuesFromRun( runState?: RunProjection | null, settings?: WorkflowSettings | null, environments?: Environment[], + githubWebUrl: string = DEFAULT_GITHUB_WEB_URL, ): AutomationFormValues { const name = firstPresentString( run.title, @@ -117,7 +118,7 @@ export function automationFormValuesFromRun( const targetRepository = canonicalTarget?.repo ?? githubRepositoryFromSettings(settings) ?? githubRepositoryName(run.repository?.name) - ?? githubRepositoryFromOriginUrl(run.repository?.origin_url) + ?? githubRepositoryFromOriginUrl(run.repository?.origin_url, githubWebUrl) ?? ""; const cloneBranch = sandboxRuntime(run.sandbox)?.clone_branch; const sourceEnvironment = settings?.run?.environment; @@ -245,20 +246,30 @@ function githubRepositoryName(value: string | null | undefined): string | null { return `${match[1]}/${match[2]}`; } -function githubRepositoryFromOriginUrl(value: string | null | undefined): string | null { +const DEFAULT_GITHUB_WEB_URL = "https://github.com"; + +function githubRepositoryFromOriginUrl( + value: string | null | undefined, + githubWebUrl: string, +): string | null { const trimmed = value?.trim(); if (!trimmed) return null; - const scpLikeMatch = trimmed.match( - /^git@github\.com:([A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)\/([A-Za-z0-9._-]+?)(?:\.git)?$/i, - ); - if (scpLikeMatch) { - return githubRepositoryName(`${scpLikeMatch[1]}/${scpLikeMatch[2]}`); + let githubHostname: string; + try { + githubHostname = new URL(githubWebUrl).hostname.toLowerCase(); + } catch { + return null; + } + + const scpPrefix = `git@${githubHostname}:`; + if (trimmed.toLowerCase().startsWith(scpPrefix)) { + return githubRepositoryName(trimmed.slice(scpPrefix.length)); } try { const url = new URL(trimmed); - if (url.hostname.toLowerCase() !== "github.com") return null; + if (url.hostname.toLowerCase() !== githubHostname) return null; const parts = url.pathname.split("/").filter(Boolean); if (parts.length !== 2) return null; return githubRepositoryName(`${parts[0]}/${parts[1]}`); diff --git a/apps/fabro-web/app/install-app.test.tsx b/apps/fabro-web/app/install-app.test.tsx index 99356a25c..5e8b30237 100644 --- a/apps/fabro-web/app/install-app.test.tsx +++ b/apps/fabro-web/app/install-app.test.tsx @@ -13,6 +13,7 @@ const INSTALL_ERROR_MESSAGE = const INSTALL_PREFILL = { canonical_url: "https://fabro.example.com", object_store_local_root: "/home/test/.fabro/storage/objects", + github_web_url: "https://github.com", }; const SESSION_RESPONSE = { @@ -243,6 +244,67 @@ describe("InstallApp", () => { } }); + test.each([ + ["https://github.com", "github.com/settings/tokens"], + ["https://ghe.example.com", "ghe.example.com/settings/tokens"], + ])("links the GitHub token page on %s", async (githubWebUrl, label) => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + const originalConsoleError = console.error; + console.error = ((...args: unknown[]) => { + if ( + typeof args[0] === "string" && + args[0].startsWith("react-test-renderer is deprecated") + ) { + return; + } + originalConsoleError(...args); + }) as typeof console.error; + try { + const fetchMock = mock(() => + Promise.resolve( + new Response( + JSON.stringify({ + ...SESSION_RESPONSE, + prefill: { ...INSTALL_PREFILL, github_web_url: githubWebUrl }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ), + ); + useInstallFetchMock(fetchMock as unknown as typeof fetch); + + const testWindow = createTestWindow("https://fabro.example.com/install/github"); + testWindow.sessionStorage.setItem("fabro-install-token", "test-install-token"); + (globalThis as { window?: unknown }).window = testWindow; + + let renderer: TestRenderer.ReactTestRenderer | null = null; + await act(async () => { + renderer = createInstallRenderer( + + + } /> + + , + ); + }); + + await waitFor(() => { + expect(renderTreeText(renderer!.toJSON())).toContain("Connect GitHub"); + }); + const link = renderer!.root.findAll( + (node) => node.type === "a" && node.props.href === `${githubWebUrl}/settings/tokens`, + ); + expect(link.length).toBeGreaterThan(0); + expect(renderTreeText(renderer!.toJSON())).toContain(label); + + await act(async () => { + renderer?.unmount(); + }); + } finally { + console.error = originalConsoleError; + } + }); + test("renders the GitHub App done screen after the manifest callback resolves", async () => { (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; const originalConsoleError = console.error; diff --git a/apps/fabro-web/app/install-app.tsx b/apps/fabro-web/app/install-app.tsx index c8e6c8032..93b5a84bd 100644 --- a/apps/fabro-web/app/install-app.tsx +++ b/apps/fabro-web/app/install-app.tsx @@ -1148,6 +1148,7 @@ function GithubStep({ runStepSubmit: RunStepSubmit; dispatchInstall: (action: InstallAction) => void; }) { + const tokensUrl = `${session.prefill.github_web_url}/settings/tokens`; return ( repo scope.

- - github.com/settings/tokens + + {tokensUrl.replace(/^https?:\/\//, "")} diff --git a/apps/fabro-web/app/routes/automations-new.test.tsx b/apps/fabro-web/app/routes/automations-new.test.tsx index 8a5059a28..cc3044af3 100644 --- a/apps/fabro-web/app/routes/automations-new.test.tsx +++ b/apps/fabro-web/app/routes/automations-new.test.tsx @@ -15,6 +15,7 @@ let currentRunStateLoading = false; let currentRunSettings: any = null; let currentEnvironments: any[] = []; let currentEnvironmentsError: unknown = null; +let currentGithubWebUrl = "https://github.com"; const queryCalls: Array<{ hook: string; id: string | undefined }> = []; const mountedRenderers: TestRenderer.ReactTestRenderer[] = []; let teardownReactEnv: (() => void) | undefined; @@ -67,6 +68,11 @@ mock.module("../lib/queries", () => ({ isLoading: false, }; }, + useServerSettings: () => ({ + data: { server: { integrations: { github: { web_url: currentGithubWebUrl } } } }, + error: null, + isLoading: false, + }), useRunState: (id: string | undefined) => { queryCalls.push({ hook: "useRunState", id }); return { @@ -277,6 +283,7 @@ beforeEach(() => { currentRunState = null; currentRunStateLoading = false; currentRunSettings = null; + currentGithubWebUrl = "https://github.com"; currentEnvironments = [ { id: "default", provider: "docker" }, { id: "daytona-smoke", provider: "daytona" }, @@ -416,6 +423,21 @@ describe("AutomationsNew", () => { ); }); + test("run origin on the configured GitHub Enterprise host prefills the target repository", async () => { + currentGithubWebUrl = "https://ghe.example.com"; + currentRun = makeRun({ + repository: { + name: "", + origin_url: "https://ghe.example.com/acme/widgets.git", + provider: "github", + }, + }); + + const { renderer } = await renderAutomationsNew("/automations/new?from_run=run_1"); + + expect(fieldValue(renderer, "Run target repository")).toBe("acme/widgets"); + }); + test("automationFormValuesFromRun kebab-cases the workflow name fallback", () => { const run = makeRun({ workflow: { diff --git a/apps/fabro-web/app/routes/automations-new.tsx b/apps/fabro-web/app/routes/automations-new.tsx index ecc1b465c..a0751cbf0 100644 --- a/apps/fabro-web/app/routes/automations-new.tsx +++ b/apps/fabro-web/app/routes/automations-new.tsx @@ -6,7 +6,13 @@ import type { Environment } from "@qltysh/fabro-api-client"; import { ApiError, apiData, automationsApi } from "../lib/api-client"; import { queryKeys } from "../lib/query-keys"; -import { useEnvironments, useRun, useRunSettings, useRunState } from "../lib/queries"; +import { + useEnvironments, + useRun, + useRunSettings, + useRunState, + useServerSettings, +} from "../lib/queries"; import { AutomationFormFields, EMPTY_AUTOMATION_FORM, @@ -37,6 +43,7 @@ export default function AutomationsNew() { const runStateQuery = useRunState(fromRunId); const settingsQuery = useRunSettings(fromRunId); const environmentsQuery = useEnvironments(); + const serverSettingsQuery = useServerSettings(); const environments = environmentsQuery.data?.data; const environmentsPending = environmentsQuery.isLoading && !environmentsQuery.data; const environmentsError = Boolean(environmentsQuery.error); @@ -86,6 +93,7 @@ export default function AutomationsNew() { runStateQuery.data ?? null, settingsQuery.data ?? null, environments, + serverSettingsQuery.data?.server.integrations.github.web_url, ); return ( diff --git a/docs/public/administration/server-configuration.mdx b/docs/public/administration/server-configuration.mdx index 215f279c9..a31d52ba3 100644 --- a/docs/public/administration/server-configuration.mdx +++ b/docs/public/administration/server-configuration.mdx @@ -303,8 +303,8 @@ When a field is not set, Fabro derives it from the run's GitHub credential: | Credential | Name | Email | |---|---|---| -| GitHub App (`strategy = "app"`) | `[bot]` | `+[bot]@users.noreply.github.com` | -| Token (`strategy = "token"`) | the token's user login | `+@users.noreply.github.com` | +| GitHub App (`strategy = "app"`) | `[bot]` | `+[bot]@users.noreply.github.com` (`users.noreply.` on an enterprise host) | +| Token (`strategy = "token"`) | the token's user login | `+@users.noreply.github.com` (`users.noreply.` on an enterprise host) | | None | `Fabro` | `noreply@fabro.sh` | Setting both `name` and `email` skips the credential lookup. Setting one field overlays it on the derived identity. A lookup failure for the selected credential fails the run at setup; Fabro never silently switches to another author. The resolved identity is recorded in the run's event stream as `git.identity.resolved` and in the run state as `git_identity`. @@ -323,6 +323,15 @@ Configure GitHub integration auth. `strategy = "token"` is the default and uses strategy = "token" ``` +| Key | Description | Default | +|---|---|---| +| `strategy` | `token` or `app` | `token` | +| `app_id`, `client_id`, `slug` | GitHub App identity (App mode) | none | +| `web_url` | Web origin of the GitHub deployment, such as `https://ghe.example.com`; must be `https` (`http` only for loopback). Override with `GITHUB_WEB_URL`. Read at server startup: changing it needs a restart | `https://github.com` | +| `api_url` | REST API base URL; may be plain `http` (for a proxy or mock). Override with `GITHUB_BASE_URL` | derived from `web_url` (`https://api.github.com`, `https://api.` for `*.ghe.com`, `/api/v3` otherwise) | + +For GitHub Enterprise Server or GitHub Enterprise Cloud, set `web_url` (see [GitHub Enterprise Server and GitHub Enterprise Cloud](/integrations/github#github-enterprise-server-and-github-enterprise-cloud)). The CLI and the run worker read the host from the local `settings.toml` or `GITHUB_WEB_URL`. Put `web_url` in `settings.toml` before `fabro install`; the installer does not persist it from the environment. + For GitHub App mode, set `strategy = "app"` and include `app_id`, `client_id`, and `slug`. Webhook delivery is configured under `[server.integrations.github.webhooks]`: ```toml title="settings.toml" diff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml index a734c4ddc..0092eb082 100644 --- a/docs/public/api-reference/fabro-api.yaml +++ b/docs/public/api-reference/fabro-api.yaml @@ -6638,12 +6638,17 @@ components: required: - canonical_url - object_store_local_root + - github_web_url properties: canonical_url: type: string format: uri object_store_local_root: type: string + github_web_url: + type: string + description: Web origin of the GitHub deployment the install targets, without a trailing slash. + example: https://github.com InstallLlmValidationResponse: description: Successful response from install-time LLM credential validation. @@ -15672,7 +15677,17 @@ components: - client_id - slug - webhooks + - web_url + - api_url properties: + web_url: + type: string + description: Web origin of the GitHub deployment, without a trailing slash. + example: https://github.com + api_url: + type: string + description: REST API base URL of the GitHub deployment, without a trailing slash. + example: https://api.github.com enabled: type: boolean strategy: diff --git a/docs/public/changelog/2026-10-25.mdx b/docs/public/changelog/2026-10-25.mdx new file mode 100644 index 000000000..3558d4eb0 --- /dev/null +++ b/docs/public/changelog/2026-10-25.mdx @@ -0,0 +1,22 @@ +--- +title: "GitHub Enterprise Server and Enterprise Cloud hosts" +date: "2026-10-25" +--- + +Fabro can now target a GitHub Enterprise Server instance or a GitHub +Enterprise Cloud tenant with data residency (`https://.ghe.com`) +instead of github.com. Set the deployment's web origin once and Fabro derives +the REST, GraphQL and OAuth URLs, clone URLs, pull request links, Git +credential scoping and the sandbox `gh` configuration from it: + +```toml +[server.integrations.github] +web_url = "https://ghe.example.com" +``` + +`GITHUB_WEB_URL` overrides the setting, and the existing `GITHUB_BASE_URL` +still overrides the REST API base. Without the new setting, github.com +behavior is unchanged. + +See [GitHub Enterprise Server and GitHub Enterprise Cloud](/integrations/github#github-enterprise-server-and-github-enterprise-cloud) +for setup, custom certificate authorities and known limitations. diff --git a/docs/public/docs.json b/docs/public/docs.json index 214d4ffea..67e28ceba 100644 --- a/docs/public/docs.json +++ b/docs/public/docs.json @@ -303,6 +303,13 @@ "tab": "Changelog", "icon": "clock-rotate-left", "groups": [ + { + "group": "October 2026", + "icon": "clock-rotate-left", + "pages": [ + "changelog/2026-10-25" + ] + }, { "group": "September 2026", "icon": "clock-rotate-left", diff --git a/docs/public/integrations/github.mdx b/docs/public/integrations/github.mdx index f11876231..4bf8561d0 100644 --- a/docs/public/integrations/github.mdx +++ b/docs/public/integrations/github.mdx @@ -121,6 +121,7 @@ slug = "fabro-a3f2" | `app_id` | Numeric GitHub App ID | | `client_id` | OAuth Client ID for the app | | `slug` | App slug, used for linking to the GitHub App settings page | +| `web_url`, `api_url` | Optional. Target GitHub Enterprise Server or Enterprise Cloud instead of github.com; see [GitHub Enterprise Server and GitHub Enterprise Cloud](#github-enterprise-server-and-github-enterprise-cloud) | ### Server vault @@ -174,6 +175,75 @@ fabro install github This walks through just the GitHub setup steps — choosing a strategy, registering an app, or storing a token — and restarts the server when done. Stale settings and secrets from the previous strategy are cleaned up automatically. +## GitHub Enterprise Server and GitHub Enterprise Cloud + +Fabro talks to github.com by default. To use a GitHub Enterprise Server (GHES) instance, or a GitHub Enterprise Cloud tenant with data residency (`https://.ghe.com`), set the web origin of that deployment. Fabro derives every other URL from it. + +```toml title="settings.toml" +[server.integrations.github] +web_url = "https://ghe.example.com" +# api_url = "https://ghe.example.com/api/v3" # optional override +``` + +| Setting | Environment override | Description | +|---|---|---| +| `server.integrations.github.web_url` | `GITHUB_WEB_URL` | Web origin of the GitHub deployment, for example `https://ghe.example.com`. Defaults to `https://github.com`. Must be `https` and must not contain a path, query or fragment (plain `http` is accepted only for loopback hosts such as `localhost` and `127.0.0.1`). | +| `server.integrations.github.api_url` | `GITHUB_BASE_URL` | REST API base URL. Optional: when unset it is derived from `web_url` as shown below. May be plain `http` on any host, for example to reach GitHub through a proxy or a mock; `web_url` must still be `https`. | + +The environment variables win over `settings.toml`. `GITHUB_WEB_URL` alone re-derives the API URL from the new web origin; `GITHUB_BASE_URL` alone replaces only the API URL. + +The server reads the host once at startup, so changing `web_url` or `api_url` takes effect only after a server restart. + +### Derived URLs + +| | github.com | GitHub Enterprise Cloud (`https://octocorp.ghe.com`) | GitHub Enterprise Server (`https://ghe.example.com`) | +|---|---|---|---| +| REST API | `https://api.github.com` | `https://api.octocorp.ghe.com` | `https://ghe.example.com/api/v3` | +| GraphQL | `https://api.github.com/graphql` | `https://api.octocorp.ghe.com/graphql` | `https://ghe.example.com/api/graphql` | +| OAuth | `https://github.com/login/oauth/...` | `https://octocorp.ghe.com/login/oauth/...` | `https://ghe.example.com/login/oauth/...` | +| Repository clone URL | `https://github.com/owner/repo` | `https://octocorp.ghe.com/owner/repo` | `https://ghe.example.com/owner/repo` | +| Pull request link | `https://github.com/owner/repo/pull/7` | `https://octocorp.ghe.com/owner/repo/pull/7` | `https://ghe.example.com/owner/repo/pull/7` | +| Commit email domain | `users.noreply.github.com` | `users.noreply.octocorp.ghe.com` | `users.noreply.ghe.example.com` | + +A host named exactly `github.com` is treated as github.com, `ghe.com` or `*.ghe.com` as GitHub Enterprise Cloud, and any other host as GitHub Enterprise Server. + +### Where the host is read + +The server, the run worker and the CLI all need to agree on the host: + +- The **server** reads `web_url` and `api_url` from its `settings.toml` and the environment overrides above. The server passes `GITHUB_WEB_URL` and `GITHUB_BASE_URL` on to run workers it starts. +- The **run worker** reads the same `settings.toml` (through `FABRO_HOME`) and the same environment variables. +- The **CLI** reads the host from the local `settings.toml` or from `GITHUB_WEB_URL`; it does not ask a remote server. If you run `fabro` against a remote server, set the same `web_url` in your local `settings.toml` (or export `GITHUB_WEB_URL`) so repository URLs, `fabro install` and `fabro doctor` target the enterprise host. + +Put `web_url` in `settings.toml` before running `fabro install`. The installer does not persist the host, so `GITHUB_WEB_URL` alone is not enough: a server restarted without that variable falls back to github.com. The installer uses it for `gh auth token --hostname `, for the GitHub App manifest registration and for the links it prints. The `App:` link it prints points at `/github-apps/` on GitHub Enterprise Server and at `/apps/` on github.com and GitHub Enterprise Cloud. + +### Authentication + +- **Tokens.** Personal access tokens, fine-grained tokens and OAuth tokens use the same `ghp_`, `github_pat_`, `gho_`, `ghu_` and `ghs_` formats on GHES and GHEC. `fabro install` runs `gh auth token --hostname `, so run `gh auth login --hostname ghe.example.com` first. +- **GitHub App.** `fabro install` registers the App through the manifest flow against `/settings/apps/new` (or the organization equivalent) and exchanges the code on the host's API. This needs a GHES version that supports GitHub App manifests. If your instance does not, register the App by hand and set `app_id`, `client_id` and `slug` under `[server.integrations.github]`, then store the private key and client secret as described in [Server vault](#server-vault). +- **Browser sign-in.** OAuth login redirects to `/login/oauth/authorize`, and sessions record the enterprise host as the identity issuer. + +### Custom certificate authorities + +Enterprise hosts often use a certificate from a private CA. Fabro always verifies TLS certificates and has no setting to disable verification. + +Fabro's HTTP client uses rustls with the platform certificate verifier: + +- **Linux:** `SSL_CERT_FILE` and `SSL_CERT_DIR` are honoured, in addition to the system trust store. The server passes both on to the run workers it starts. +- **macOS:** the system Keychain is used. **Windows:** the Windows certificate store is used. The variables are not read on either platform, so install the CA in the system store. +- **Sandboxes:** containers do not inherit the host's trust store. Bake the CA into the sandbox image, or set `SSL_CERT_FILE` and `GIT_SSL_CAINFO` in the sandbox environment so Git and the `gh` CLI trust it. + +### The `gh` CLI in sandboxes + +The `gh` CLI reads `GITHUB_TOKEN` only for github.com. For any other host Fabro also injects `GH_HOST=` and `GH_ENTERPRISE_TOKEN=` next to `GITHUB_TOKEN`, so `gh pr list` and similar commands in command stages work without `gh auth login`. Git's credential helper and the `git@:` and `ssh://git@/` rewrites are scoped to the configured host. + +### Known limitations + +- One GitHub host per server. Repositories on any other host (including github.com when `web_url` points at an enterprise host) are rejected. +- Features that an older GHES release does not provide, such as newer App permissions or auto-merge, surface as the error GitHub returns. +- Avatars and the Content Security Policy follow the configured host: for a non-github.com host, `img-src` allows the web origin and `https://avatars.` instead of `avatars.githubusercontent.com`. A GHES instance that serves avatars from another origin shows broken avatar images. +- The Fabro CLI release channel and documentation links stay on github.com. + ## `token` mode Choose **GitHub CLI** in `fabro install` to use the default local-user flow. The installer: 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/graph.rs b/lib/apps/fabro-cli/src/commands/graph.rs index 285c4add3..a2bda5e08 100644 --- a/lib/apps/fabro-cli/src/commands/graph.rs +++ b/lib/apps/fabro-cli/src/commands/graph.rs @@ -22,6 +22,7 @@ use crate::args::{GraphArgs, GraphDirection, GraphOutputFormat}; use crate::command_context::CommandContext; use crate::commands::run::output::api_diagnostics_to_local; use crate::shared::{absolute_or_current, print_diagnostics, print_json_pretty, relative_path}; +use crate::user_config; pub(crate) async fn run( args: &GraphArgs, @@ -39,6 +40,7 @@ pub(crate) async fn run( cwd: ctx.cwd().to_path_buf(), environment_defaults: fabro_environment::seeded_catalog_layer(), user_settings_path: Some(active_settings_path(None)), + github_host: user_config::github_host()?, ..Default::default() })?; let client = ctx.server().await?; diff --git a/lib/apps/fabro-cli/src/commands/install.rs b/lib/apps/fabro-cli/src/commands/install.rs index 1c61c032c..adf30e7d1 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 = 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::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 = user_config::github_host()?; match self.args.github_strategy { Some(InstallGitHubStrategyArg::Token) => { - let token = fabro_github::gh_auth_token() + 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 = user_config::github_host()?; match github_args.strategy { Some(InstallGitHubStrategyArg::Token) => { - let token = fabro_github::gh_auth_token() + 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 = 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: {}", github_host.app_page_url(&slug))) ); // 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/commands/preflight.rs b/lib/apps/fabro-cli/src/commands/preflight.rs index 2bf94cb08..2f3426e9f 100644 --- a/lib/apps/fabro-cli/src/commands/preflight.rs +++ b/lib/apps/fabro-cli/src/commands/preflight.rs @@ -12,6 +12,7 @@ use crate::commands::run::output::{ use crate::commands::run::overrides::preflight_args_overrides; use crate::manifest_args::preflight_manifest_args; use crate::shared::{cyan_spinner, print_json_pretty}; +use crate::user_config; pub(crate) async fn execute( mut args: PreflightArgs, @@ -32,6 +33,7 @@ pub(crate) async fn execute( args: preflight_manifest_args(&args), environment_defaults: fabro_environment::seeded_catalog_layer(), user_settings_path: Some(active_settings_path(None)), + github_host: user_config::github_host()?, })?; let spinner = (!ctx.json_output()).then(|| cyan_spinner("Running checks...")); diff --git a/lib/apps/fabro-cli/src/commands/repo/init.rs b/lib/apps/fabro-cli/src/commands/repo/init.rs index 71c644b72..e537d3760 100644 --- a/lib/apps/fabro-cli/src/commands/repo/init.rs +++ b/lib/apps/fabro-cli/src/commands/repo/init.rs @@ -11,6 +11,7 @@ use tokio::task::spawn_blocking; use crate::args::{RepoInitArgs, ServerTargetArgs}; use crate::command_context::CommandContext; +use crate::user_config; #[expect( clippy::disallowed_methods, @@ -193,8 +194,11 @@ async fn check_github_app_installation(target: &ServerTargetArgs, base_ctx: &Com }; // Convert SSH URL to HTTPS and parse owner/repo + let Ok(host) = user_config::github_host() else { + return; // Unreadable server settings — skip silently + }; let https_url = fabro_github::ssh_url_to_https(&remote_url); - let Ok((owner, repo)) = fabro_github::parse_github_owner_repo(&https_url) else { + let Ok((owner, repo)) = fabro_github::parse_github_owner_repo(&host, &https_url) else { return; // Not a GitHub repo — skip silently }; diff --git a/lib/apps/fabro-cli/src/commands/run/create.rs b/lib/apps/fabro-cli/src/commands/run/create.rs index 6e07c0a65..9b5fc50c3 100644 --- a/lib/apps/fabro-cli/src/commands/run/create.rs +++ b/lib/apps/fabro-cli/src/commands/run/create.rs @@ -14,7 +14,7 @@ use super::{resolution, selection}; use crate::args::RunArgs; use crate::command_context::CommandContext; use crate::commands::resolve_run_id; -use crate::user_config::{RunSettingsKeyPresence, read_project_run_settings_key_presence}; +use crate::user_config::{self, RunSettingsKeyPresence, read_project_run_settings_key_presence}; pub(crate) struct CreatedRun { pub(crate) run_id: RunId, @@ -104,7 +104,10 @@ pub(crate) async fn create_run( && args.target_repo.is_none() && args.target_repo_selector.is_none() => { - fabro_manifest::configured_repo_origin_url_for_location(package.workflow_location())? + fabro_manifest::configured_repo_origin_url_for_location( + &user_config::github_host()?, + package.workflow_location(), + )? } _ => None, }; diff --git a/lib/apps/fabro-cli/src/commands/run/petri_worker.rs b/lib/apps/fabro-cli/src/commands/run/petri_worker.rs index 267c18070..4af4fe9c5 100644 --- a/lib/apps/fabro-cli/src/commands/run/petri_worker.rs +++ b/lib/apps/fabro-cli/src/commands/run/petri_worker.rs @@ -109,7 +109,7 @@ use tracing::{info, warn}; use super::publish; use super::runner::{self, WorkerTitlePhase}; use crate::args::RunWorkerMode; -use crate::command_context; +use crate::{command_context, user_config}; /// What the worker holds when it hands a run to Petri. pub(super) struct PetriWorker<'a> { @@ -221,10 +221,17 @@ pub(super) async fn execute(worker: PetriWorker<'_>) -> Result<()> { None } }; - let read_tokens = publish::read_token_source(&worker.run_state.spec, github.as_ref()); - runtime.stage_credentials = - StageCredentials::for_run(&worker.run_state.spec, github.as_ref(), read_tokens.clone())?; + let github_host = user_config::github_host()?; + let read_tokens = + publish::read_token_source(&worker.run_state.spec, github.as_ref(), &github_host); + runtime.stage_credentials = StageCredentials::for_run( + &worker.run_state.spec, + github.as_ref(), + &github_host, + read_tokens.clone(), + )?; let mut source = RunSource::for_run( + &github_host, worker.run_state.spec.target.as_ref(), &worker.run_state.spec.settings.run, None, @@ -236,6 +243,7 @@ pub(super) async fn execute(worker: PetriWorker<'_>) -> Result<()> { run_id, &worker.run_state.spec, github, + github_host, Arc::new(VaultCredentialSource::new(Arc::clone(&vault))), Arc::new(catalog), Arc::clone(&records), diff --git a/lib/apps/fabro-cli/src/commands/run/publish.rs b/lib/apps/fabro-cli/src/commands/run/publish.rs index 8555f3161..8ea028c7c 100644 --- a/lib/apps/fabro-cli/src/commands/run/publish.rs +++ b/lib/apps/fabro-cli/src/commands/run/publish.rs @@ -39,7 +39,7 @@ use fabro_static::EnvVars; use fabro_store::platform_records::{PlatformRecord, PullRequestCreatedRecord}; use fabro_types::settings::run::{PullRequestSettings, RunMode}; use fabro_types::settings::server::GithubIntegrationStrategy; -use fabro_types::{GitHubRepositorySlug, RunId, RunSpec, RunTarget}; +use fabro_types::{GitHubHost, GitHubRepositorySlug, RunId, RunSpec, RunTarget}; use fabro_vault::Vault; use fabro_workflow::pull_request::{self, AutoMergeOptions, OpenPullRequestRequest}; use tokio::time; @@ -77,11 +77,11 @@ pub(super) fn github_credentials(vault: &Vault) -> Result Option { +fn repository(spec: &RunSpec, host: &GitHubHost) -> Option { let Some(RunTarget::Git(target)) = spec.target.as_ref() else { return None; }; - Some(target.clone().validate().ok()?.repository().clone()) + Some(target.clone().validate(host).ok()?.repository().clone()) } /// A cached token source for the run's repository with `permissions`, or @@ -89,12 +89,14 @@ fn repository(spec: &RunSpec) -> Option { fn token_source( spec: &RunSpec, credentials: Option<&GitHubCredentials>, + host: &GitHubHost, permissions: serde_json::Value, ) -> Option> { - let repository = repository(spec)?; + let repository = repository(spec, host)?; let credentials = credentials?; match InstallationTokenSource::for_repository( credentials, + host, repository.owner().to_string(), repository.repo().to_string(), permissions, @@ -114,8 +116,14 @@ fn token_source( pub(super) fn read_token_source( spec: &RunSpec, credentials: Option<&GitHubCredentials>, + host: &GitHubHost, ) -> Option> { - token_source(spec, credentials, serde_json::json!({ "contents": "read" })) + token_source( + spec, + credentials, + host, + serde_json::json!({ "contents": "read" }), + ) } /// Fetch credentials over the run's read-only token source. @@ -160,6 +168,7 @@ pub(super) struct GitHubPublisher { /// The run's model, when its settings name one. model: Option, credentials: Option, + host: GitHubHost, /// The run's `contents: write` token source; `None` without credentials. push_tokens: Option>, pull_request: Option, @@ -176,6 +185,7 @@ impl GitHubPublisher { run_id: RunId, spec: &RunSpec, credentials: Option, + host: GitHubHost, llm_source: Arc, catalog: Arc, records: Arc, @@ -192,10 +202,11 @@ impl GitHubPublisher { let Some(RunTarget::Git(target)) = spec.target.as_ref() else { return None; }; - let repository = repository(spec)?; + let repository = repository(spec, &host)?; let push_tokens = token_source( spec, credentials.as_ref(), + &host, serde_json::json!({ "contents": "write" }), ); Some(Self { @@ -205,6 +216,7 @@ impl GitHubPublisher { goal: spec.graph.goal.clone(), model: settings.model.name.clone(), credentials, + host, push_tokens, pull_request: settings .pull_request @@ -243,7 +255,7 @@ impl GitHubPublisher { .ok_or_else(|| "no LLM model is available to write the pull request".to_string())?; // The run so far, for the pull request's details: best effort. let run_state = self.client.get_run_state(&self.run_id).await.ok(); - let origin_url = self.repository.https_url(); + let origin_url = self.repository.https_url(&self.host); let created = pull_request::open_pull_request(OpenPullRequestRequest { run_id: self.run_id, github: context, @@ -293,7 +305,15 @@ impl RunPublisher for GitHubPublisher { .resolve() .await .map_err(|err| format!("no push credential for {}: {err:#}", self.repository))?; - push(&self.repository, &resolved.token, site, branch, sha).await + push( + &self.repository, + &self.host, + &resolved.token, + site, + branch, + sha, + ) + .await } async fn publish(&self, publication: &Publication) -> Result<(), String> { @@ -313,8 +333,7 @@ impl RunPublisher for GitHubPublisher { .credentials .as_ref() .ok_or_else(|| "GitHub credentials are unavailable".to_string())?; - let base_url = fabro_github::github_api_base_url(); - let context = GitHubContext::new(credentials, &base_url); + let context = GitHubContext::new(credentials, &self.host); Box::pin(self.open_pull_request(context, settings, publication)).await } } @@ -325,12 +344,13 @@ impl RunPublisher for GitHubPublisher { /// repository alone and never appears in the error. async fn push( repository: &GitHubRepositorySlug, + host: &GitHubHost, token: &SecretString, site: &Site, branch: &str, sha: &str, ) -> Result<(), String> { - let mut url = repository.https_url(); + let mut url = repository.https_url(host); url.push_str(".git"); let credential = basic(token); let env = credential @@ -399,6 +419,7 @@ mod tests { RunId::new(), spec, None, + GitHubHost::default(), Arc::new(NoCredentials), Arc::new(test_support::test_catalog()), Arc::new(MemoryPlatformRecords::new()), diff --git a/lib/apps/fabro-cli/src/commands/run/remote_workflow.rs b/lib/apps/fabro-cli/src/commands/run/remote_workflow.rs index 8bec413d7..61369fd7a 100644 --- a/lib/apps/fabro-cli/src/commands/run/remote_workflow.rs +++ b/lib/apps/fabro-cli/src/commands/run/remote_workflow.rs @@ -7,7 +7,7 @@ use std::time::Duration; use anyhow::{Context as _, bail}; use fabro_manifest::CollectedWorkflowClosure; use fabro_proc::ProcessError; -use fabro_types::{GitHubRepositorySlug, GitRunTarget, repository}; +use fabro_types::{GitHubHost, GitHubRepositorySlug, GitRunTarget, repository}; use tokio::process::Command; use tokio::{fs, signal as tokio_signal, task}; use tokio_util::sync::CancellationToken; @@ -69,17 +69,19 @@ pub(super) enum RemoteWorkflowError { /// the same Git configuration: the user's global and system config applies, /// repository-local config from wherever the CLI was invoked does not. pub(super) struct NativeGit { + host: GitHubHost, timeout: Duration, #[cfg(test)] pub(super) environment: Vec<(String, String)>, } impl NativeGit { - pub(super) fn new() -> Self { + pub(super) fn new(host: GitHubHost) -> Self { Self { - timeout: Duration::from_mins(2), + host, + timeout: Duration::from_mins(2), #[cfg(test)] - environment: Vec::new(), + environment: Vec::new(), } } @@ -157,7 +159,7 @@ impl NativeGit { patterns: &[String], cancel: &CancellationToken, ) -> anyhow::Result { - let url = repository.https_url(); + let url = repository.https_url(&self.host); let mut args = vec!["ls-remote", "--symref", &url]; args.extend(patterns.iter().map(String::as_str)); let bytes = self.command("metadata lookup", root, &args, cancel).await?; @@ -193,7 +195,7 @@ impl NativeGit { tag: None, sha: Some(sha), }; - Ok(target.validate()?.into_target()) + Ok(target.validate(&self.host)?.into_target()) } /// Resolve `revision` to a commit SHA using metadata lookups run from @@ -298,7 +300,7 @@ impl NativeGit { "--depth=1", "--no-tags", "--no-recurse-submodules", - &repository.https_url(), + &repository.https_url(&self.host), sha, ], cancel, @@ -570,7 +572,7 @@ mod tests { ), ) .unwrap(); - let mut git = NativeGit::new(); + let mut git = NativeGit::new(GitHubHost::default()); git.environment = vec![ ("GIT_CONFIG_GLOBAL".into(), config.to_str().unwrap().into()), ("GIT_CONFIG_NOSYSTEM".into(), "1".into()), @@ -1081,7 +1083,7 @@ mod tests { let path = root.path().join("git"); std::fs::write(&path, format!("#!/bin/sh\n{script}\n")).unwrap(); std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); - let mut git = NativeGit::new(); + let mut git = NativeGit::new(GitHubHost::default()); git.environment .push(("PATH".into(), root.path().to_str().unwrap().into())); (root, git) diff --git a/lib/apps/fabro-cli/src/commands/run/resolution.rs b/lib/apps/fabro-cli/src/commands/run/resolution.rs index 085030c2c..5e2ca3920 100644 --- a/lib/apps/fabro-cli/src/commands/run/resolution.rs +++ b/lib/apps/fabro-cli/src/commands/run/resolution.rs @@ -7,6 +7,7 @@ use tokio::task; use super::remote_workflow::{Interruption, NativeGit}; use super::selection::{TargetSelection, WorkflowSelection}; +use crate::user_config; /// Owns the canonical collector result without copying its contents. Local /// location metadata remains available for settings warnings and target @@ -55,7 +56,7 @@ pub(super) async fn workflow( selector, revision, } => { - let git = NativeGit::new(); + let git = NativeGit::new(user_config::github_host()?); let (repository, selector, revision) = (repository.clone(), selector.clone(), revision.clone()); let closure = interruption @@ -84,7 +85,7 @@ pub(super) async fn target( if !provider.clones_workspace() { bail!("Git targets require a clone-enabled environment"); } - let git = NativeGit::new(); + let git = NativeGit::new(user_config::github_host()?); let (repository, branch) = (repository.clone(), branch.clone()); let target = interruption .owned(move |cancel| async move { @@ -101,9 +102,11 @@ pub(super) async fn target( // The existing observer can push/query Git synchronously. Preserve its // behavior without blocking a Tokio worker or promising a new timeout. let provider = provider.clone(); + let host = user_config::github_host()?; let configured_repo_origin_url = configured_repo_origin_url.map(str::to_owned); let derived = task::spawn_blocking(move || { fabro_manifest::derive_run_target_for_provider( + &host, &provider, &path, configured_repo_origin_url.as_deref(), diff --git a/lib/apps/fabro-cli/src/commands/server/mod.rs b/lib/apps/fabro-cli/src/commands/server/mod.rs index 2f3f3f8dc..5b8f58f6b 100644 --- a/lib/apps/fabro-cli/src/commands/server/mod.rs +++ b/lib/apps/fabro-cli/src/commands/server/mod.rs @@ -224,6 +224,7 @@ async fn run_install_mode(bootstrap: InstallBootstrap, printer: Printer) -> Resu &bootstrap.storage_dir, &bootstrap.config_path, ) + .with_github_host(user_config::github_host()?) .with_finish_hook(persist_install_dev_token_hook()); install::serve_install_command(bootstrap.bind_request, state, move |bind| { announce_install_mode(bind, &token, styles, printer); diff --git a/lib/apps/fabro-cli/src/commands/validate.rs b/lib/apps/fabro-cli/src/commands/validate.rs index cec9f7672..e763b6451 100644 --- a/lib/apps/fabro-cli/src/commands/validate.rs +++ b/lib/apps/fabro-cli/src/commands/validate.rs @@ -10,6 +10,7 @@ use crate::args::ValidateArgs; use crate::command_context::CommandContext; use crate::commands::run::output::api_diagnostics_to_local; use crate::shared::{print_diagnostics, print_json_pretty, relative_path}; +use crate::user_config; pub(crate) fn run( args: &ValidateArgs, @@ -22,6 +23,7 @@ pub(crate) fn run( cwd: base_ctx.cwd().to_path_buf(), environment_defaults: fabro_environment::seeded_catalog_layer(), user_settings_path: Some(active_settings_path(None)), + github_host: user_config::github_host()?, ..Default::default() })?; let response = manifest_validation::validate_manifest(&RunLayer::default(), &built.manifest)?; diff --git a/lib/apps/fabro-cli/src/gh.rs b/lib/apps/fabro-cli/src/gh.rs index 8f9d149ec..20c204d50 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.hostname()]) .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.hostname()); + Some(Self { + command: command.to_string(), + host: host.clone(), + }) } _ => { - debug!("gh is not authenticated for github.com"); + debug!("gh is not authenticated for {}", host.hostname()); 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.hostname(), + "/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.hostname(), "--paginate", "/user/memberships/orgs", "--jq", @@ -101,7 +113,51 @@ 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] + #[expect( + clippy::disallowed_methods, + reason = "unit test stages a fake gh script and reads its argument log with sync std::fs" + )] + 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"), + ] { + 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"))); + } + } } diff --git a/lib/apps/fabro-cli/src/user_config.rs b/lib/apps/fabro-cli/src/user_config.rs index e842a389e..ca90712f3 100644 --- a/lib/apps/fabro-cli/src/user_config.rs +++ b/lib/apps/fabro-cli/src/user_config.rs @@ -14,7 +14,7 @@ use fabro_static::EnvVars; use fabro_types::settings::RunNamespace; use fabro_types::settings::cli::CliTargetSettings; use fabro_types::settings::server::LogDestination; -use fabro_types::{ServerSettings, UserSettings}; +use fabro_types::{GitHubHost, ServerSettings, UserSettings}; use fabro_util::error::SharedError; use fabro_util::version::FABRO_VERSION; use tokio::fs; @@ -23,6 +23,14 @@ use tracing::debug; use crate::args::ServerTargetArgs; +/// The GitHub deployment configured in the local server settings, with the +/// `GITHUB_WEB_URL` / `GITHUB_BASE_URL` overrides applied. +pub(crate) fn github_host() -> Result { + let configured = ServerSettingsBuilder::load_default_github_host() + .context("loading the GitHub host from the server settings")?; + fabro_github::with_env_overrides(&configured) +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub(crate) struct RunSettingsKeyPresence { pub(crate) run: bool, diff --git a/lib/apps/fabro-server/src/auth/github_endpoints.rs b/lib/apps/fabro-server/src/auth/github_endpoints.rs index 951c3af03..a7d59e680 100644 --- a/lib/apps/fabro-server/src/auth/github_endpoints.rs +++ b/lib/apps/fabro-server/src/auth/github_endpoints.rs @@ -4,6 +4,7 @@ )] use fabro_http::Url; +use fabro_types::GitHubHost; #[doc(hidden)] #[derive(Debug, Clone)] @@ -13,11 +14,12 @@ pub struct GithubEndpoints { } impl GithubEndpoints { - pub fn production_defaults() -> Self { + pub fn from_host(host: &GitHubHost) -> Self { Self { - oauth_base: Url::parse("https://github.com/").expect("github oauth base should parse"), - api_base: Url::parse("https://api.github.com/") - .expect("github api base should parse"), + oauth_base: Url::parse(&format!("{}/", host.web_url())) + .expect("GitHubHost web_url should parse as a URL"), + api_base: Url::parse(&format!("{}/", host.api_url())) + .expect("GitHubHost api_url should parse as a URL"), } } @@ -32,16 +34,42 @@ impl GithubEndpoints { #[cfg(test)] mod tests { + use fabro_types::GitHubHost; + use super::GithubEndpoints; #[test] - fn production_defaults_match_current_github_urls() { - let endpoints = GithubEndpoints::production_defaults(); + fn default_host_matches_current_github_urls() { + let endpoints = GithubEndpoints::from_host(&GitHubHost::default()); assert_eq!(endpoints.oauth_base.as_str(), "https://github.com/"); assert_eq!(endpoints.api_base.as_str(), "https://api.github.com/"); } + #[test] + fn enterprise_host_uses_its_web_origin_and_api_url() { + let host = GitHubHost::new("https://ghe.example.com", None).unwrap(); + let endpoints = GithubEndpoints::from_host(&host); + + assert_eq!(endpoints.oauth_base.as_str(), "https://ghe.example.com/"); + assert_eq!( + endpoints.api_base.as_str(), + "https://ghe.example.com/api/v3/" + ); + assert_eq!( + endpoints + .oauth_base + .join("login/oauth/authorize") + .unwrap() + .as_str(), + "https://ghe.example.com/login/oauth/authorize" + ); + assert_eq!( + endpoints.api_base.join("user").unwrap().as_str(), + "https://ghe.example.com/api/v3/user" + ); + } + #[test] fn with_bases_uses_custom_urls() { let endpoints = GithubEndpoints::with_bases( diff --git a/lib/apps/fabro-server/src/automation_materializer.rs b/lib/apps/fabro-server/src/automation_materializer.rs index c3feace37..e5d8e8f79 100644 --- a/lib/apps/fabro-server/src/automation_materializer.rs +++ b/lib/apps/fabro-server/src/automation_materializer.rs @@ -128,6 +128,7 @@ pub(crate) struct ProductionAutomationRunMaterializer { remote_resolver: Arc, repo_cache: Arc, version_store: WorkflowVersionStore, + host: fabro_types::GitHubHost, } /// Where to fetch a repository from and how to authenticate. @@ -143,9 +144,9 @@ trait AutomationGitRemoteResolver: Send + Sync { } struct ServerGitHubRemoteResolver { - credentials: Option, - api_base_url: String, - http_client: Option, + credentials: Option, + host: fabro_types::GitHubHost, + http_client: Option, } #[async_trait] @@ -154,12 +155,12 @@ impl AutomationGitRemoteResolver for ServerGitHubRemoteResolver { let auth = git_checkout::resolve_git_read_auth_config( self.credentials.as_ref(), repo, - &self.api_base_url, + &self.host, self.http_client.clone(), ) .await?; Ok(GitRemote { - clone_url: git_checkout::github_clone_url(repo), + clone_url: git_checkout::github_clone_url(repo, &self.host), auth, }) } @@ -168,7 +169,7 @@ impl AutomationGitRemoteResolver for ServerGitHubRemoteResolver { impl ProductionAutomationRunMaterializer { pub(crate) fn new( github_credentials: Option, - github_api_base_url: String, + github_host: fabro_types::GitHubHost, http_client: Option, repo_cache: Arc, version_store: WorkflowVersionStore, @@ -176,11 +177,12 @@ impl ProductionAutomationRunMaterializer { Self { remote_resolver: Arc::new(ServerGitHubRemoteResolver { credentials: github_credentials, - api_base_url: github_api_base_url, + host: github_host.clone(), http_client, }), repo_cache, version_store, + host: github_host, } } @@ -232,13 +234,13 @@ impl AutomationRunMaterializer for ProductionAutomationRunMaterializer { ) -> Result { let validated_target = input .target - .validate() + .validate(&self.host) .map_err(|source| RunMaterializeError::InvalidTarget { source })?; let target_repo = validated_target.repository().clone(); let mut exact_target = validated_target.into_target(); let workflow_source = input .workflow_source - .map(GitRunTarget::validate) + .map(|source| source.validate(&self.host)) .transpose() .map_err(|source| RunMaterializeError::InvalidWorkflowSource { source })?; // A workflow source naming the target's exact coordinate shares its @@ -772,7 +774,7 @@ mod tests { ) -> ProductionAutomationRunMaterializer { ProductionAutomationRunMaterializer::new( None, - "https://api.github.com".to_string(), + fabro_types::GitHubHost::default(), None, Arc::new(GitRepoCache::new(root.join("cache"))), store, diff --git a/lib/apps/fabro-server/src/csp.rs b/lib/apps/fabro-server/src/csp.rs index efb16545c..6ba455045 100644 --- a/lib/apps/fabro-server/src/csp.rs +++ b/lib/apps/fabro-server/src/csp.rs @@ -4,24 +4,17 @@ //! `index.html` so any inline `