From c5295322e16d8c6b455f3d27e754e87ad1d93cff Mon Sep 17 00:00:00 2001 From: Noisemaker111 Date: Wed, 8 Jul 2026 11:39:49 -0400 Subject: [PATCH 1/4] feat(source-control): detect outdated GitHub CLI instead of "Not authenticated" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `gh` is installed but predates `gh auth status --json` (added in gh v2.81.0, cli/cli#11544), the server's auth probe can't be parsed, so the Source Control settings row shows a misleading "Not authenticated" — even though the user is signed in. Running `gh auth login` never clears it. Add an "outdated" source-control auth status: - contracts: new `outdated` literal on SourceControlProviderAuthStatus - server: thread the CLI `--version` output and host platform into parseAuth; the GitHub provider classifies the unknown-`--json`-flag / sub-2.81.0 case as outdated and puts an OS-appropriate upgrade command in `detail` - web: red version number + "Outdated CLI" badge + a copyable upgrade command Co-Authored-By: Claude Opus 4.8 --- .../GitHubSourceControlProvider.test.ts | 30 +++++++ .../GitHubSourceControlProvider.ts | 49 ++++++++++++ .../SourceControlProviderDiscovery.ts | 23 ++++-- .../settings/SourceControlSettings.tsx | 78 ++++++++++++++++++- packages/contracts/src/sourceControl.ts | 5 ++ 5 files changed, 178 insertions(+), 7 deletions(-) diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts index 9e8a6829566..9b2468427df 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -381,3 +381,33 @@ it("reports unauthenticated when GitHub JSON has accounts but none are valid", ( }, ); }); + +it("reports an outdated CLI when gh rejects the --json flag", () => { + const auth = GitHubSourceControlProvider.discovery.parseAuth({ + stdout: "", + stderr: "unknown flag: --json\n\nUsage: gh auth status [flags]", + exitCode: ChildProcessSpawner.ExitCode(1), + version: Option.some("gh version 2.74.2 (2025-06-18)"), + platform: "darwin", + }); + + assert.deepStrictEqual( + { status: auth.status, detail: auth.detail }, + { status: "outdated", detail: Option.some("brew upgrade gh") }, + ); +}); + +it("detects an outdated gh by version and suggests the platform upgrade command", () => { + const auth = GitHubSourceControlProvider.discovery.parseAuth({ + stdout: "", + stderr: "", + exitCode: ChildProcessSpawner.ExitCode(1), + version: Option.some("gh version 2.40.0 (2023-11-01)"), + platform: "win32", + }); + + assert.deepStrictEqual( + { status: auth.status, detail: auth.detail }, + { status: "outdated", detail: Option.some("winget upgrade --id GitHub.cli") }, + ); +}); diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index b5d5d3a55f8..52122ddf607 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -2,6 +2,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Result from "effect/Result"; +import { compareSemverVersions } from "@t3tools/shared/semver"; import { SourceControlProviderError, type ChangeRequest, @@ -42,6 +43,44 @@ function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeReq }; } +/** + * Minimum `gh` version that supports `gh auth status --json`, which the server + * relies on to read authentication state. Shipped in gh v2.81.0 (cli/cli#11544). + */ +const GITHUB_CLI_MIN_VERSION = "2.81.0"; + +function parseGitHubCliVersion(version: Option.Option | undefined): string | null { + const raw = version === undefined ? undefined : Option.getOrUndefined(version); + if (raw === undefined) return null; + const match = raw.match(/\bversion\s+(\d+\.\d+\.\d+)/iu) ?? raw.match(/(\d+\.\d+\.\d+)/u); + return match?.[1] ?? null; +} + +/** + * True when the failed auth probe is explained by an outdated `gh` rather than a + * missing login. The definitive signal is gh rejecting `--json`; the parsed + * version is a fallback should the flag surface differently in future releases. + */ +function isOutdatedGitHubCli(input: SourceControlAuthProbeInput): boolean { + if (/unknown flag:\s*--json/iu.test(`${input.stdout}\n${input.stderr}`)) { + return true; + } + const version = parseGitHubCliVersion(input.version); + return version !== null && compareSemverVersions(version, GITHUB_CLI_MIN_VERSION) < 0; +} + +function gitHubCliUpgradeCommand(platform: NodeJS.Platform | undefined): string { + switch (platform) { + case "darwin": + return "brew upgrade gh"; + case "win32": + return "winget upgrade --id GitHub.cli"; + default: + // Debian/Ubuntu default. Detecting the actual package manager is a follow-up. + return "sudo apt update && sudo apt install gh"; + } +} + function parseGitHubAuth(input: SourceControlAuthProbeInput) { const output = combinedAuthOutput(input); const authStatus = parseGitHubAuthStatus(input.stdout); @@ -56,6 +95,16 @@ function parseGitHubAuth(input: SourceControlAuthProbeInput) { }); } + // A `gh` too old for `auth status --json` can't be parsed here, so the probe + // looks like a sign-out even when the user is authenticated. Surface it as an + // outdated CLI, with the platform-appropriate upgrade command in `detail`. + if (!authStatus.parsed && isOutdatedGitHubCli(input)) { + return providerAuth({ + status: "outdated", + detail: gitHubCliUpgradeCommand(input.platform), + }); + } + const failedAccount = authStatus.accounts.find((entry) => entry.active) ?? authStatus.accounts[0]; if (authStatus.parsed) { return providerAuth({ diff --git a/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts b/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts index e3a6bd1fb20..2b9125a999b 100644 --- a/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts +++ b/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts @@ -4,6 +4,7 @@ import type { SourceControlProviderInfo, SourceControlProviderKind, } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; @@ -14,6 +15,10 @@ export interface SourceControlAuthProbeInput { readonly stdout: string; readonly stderr: string; readonly exitCode: VcsProcess.VcsProcessOutput["exitCode"]; + /** First line of the CLI `--version` probe, when it was available. */ + readonly version?: Option.Option; + /** Host platform, so `parseAuth` can suggest an OS-appropriate upgrade command. */ + readonly platform?: NodeJS.Platform; } export interface SourceControlUnknownRemoteRefinementInput { @@ -249,12 +254,20 @@ export function probeSourceControlProvider(input: { appendTruncationMarker: true, }) .pipe( - Effect.map( - (result) => - ({ + Effect.flatMap((result) => + Effect.gen(function* () { + const platform = yield* HostProcessPlatform; + return { ...item, - auth: spec.parseAuth(result), - }) satisfies SourceControlProviderDiscoveryItem, + auth: spec.parseAuth({ + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.exitCode, + version: item.version, + platform, + }), + } satisfies SourceControlProviderDiscoveryItem; + }), ), Effect.catch((cause) => Effect.succeed({ diff --git a/apps/web/src/components/settings/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx index b6d23de4f79..7fea51b5fa9 100644 --- a/apps/web/src/components/settings/SourceControlSettings.tsx +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -1,4 +1,10 @@ -import { ChevronDownIcon, GitPullRequestIcon, RefreshCwIcon } from "lucide-react"; +import { + CheckIcon, + ChevronDownIcon, + CopyIcon, + GitPullRequestIcon, + RefreshCwIcon, +} from "lucide-react"; import * as Duration from "effect/Duration"; import * as Option from "effect/Option"; import { useState, type ReactNode } from "react"; @@ -105,6 +111,9 @@ function authPresentation(auth: SourceControlProviderAuth): { if (auth.status === "unauthenticated") { return { label: "Not authenticated", badge: "warning" }; } + if (auth.status === "outdated") { + return { label: "Outdated CLI", badge: "warning" }; + } return { label: "Status unknown", badge: null }; } @@ -190,6 +199,16 @@ function itemSummary({ return Available. {item.installHint}; } + if (auth.status === "outdated") { + return ( + + Your {item.executable} CLI + is too old for {item.label} sign-in to be detected here — you may already be authenticated. + Update it on the server host, then Rescan. + + ); + } + if (auth.status === "unauthenticated") { return ( @@ -209,6 +228,56 @@ function itemSummary({ return Available; } +function CliVersion({ + version, + outdated, +}: { + readonly version: string; + readonly outdated: boolean; +}) { + if (!outdated) { + return {version}; + } + const match = version.match(/\d+\.\d+\.\d+/); + if (!match) { + return {version}; + } + const number = match[0]; + const start = version.indexOf(number); + return ( + + {version.slice(0, start)} + {number} + {version.slice(start + number.length)} + + ); +} + +function UpgradeCommandLine({ command }: { readonly command: string }) { + const [copied, setCopied] = useState(false); + return ( +
+ $ + {command} + +
+ ); +} + function DiscoveryItemRow({ item, children, @@ -241,7 +310,9 @@ function DiscoveryItemRow({ {item.label} - {version ? {version} : null} + {version ? ( + + ) : null} {isVcsNotReady(item) ? ( Coming Soon @@ -256,6 +327,9 @@ function DiscoveryItemRow({

{itemSummary({ item, auth, authAccount })}

+ {auth?.status === "outdated" && optionLabel(auth.detail) ? ( + + ) : null}
{hasDetails ? ( diff --git a/packages/contracts/src/sourceControl.ts b/packages/contracts/src/sourceControl.ts index 104aadd9161..b8f30786357 100644 --- a/packages/contracts/src/sourceControl.ts +++ b/packages/contracts/src/sourceControl.ts @@ -109,6 +109,11 @@ export type SourceControlDiscoveryStatus = typeof SourceControlDiscoveryStatus.T export const SourceControlProviderAuthStatus = Schema.Literals([ "authenticated", "unauthenticated", + // The provider CLI is installed but too old to report auth status in the + // format the server needs (e.g. `gh` predating `auth status --json`). The + // user may well be signed in — we just can't read it. `detail` carries the + // platform-appropriate upgrade command. + "outdated", "unknown", ]); export type SourceControlProviderAuthStatus = typeof SourceControlProviderAuthStatus.Type; From a4051a138b2c41f147628ddc34a6bf009cae93be Mon Sep 17 00:00:00 2001 From: Noisemaker111 Date: Wed, 8 Jul 2026 12:31:58 -0400 Subject: [PATCH 2/4] fix(source-control): guard clipboard API and avoid wrong OS upgrade command - UpgradeCommandLine: `navigator.clipboard` is undefined in non-secure contexts and some embedded webviews, so the copy handler threw. Guard it and fall back to a hidden-textarea `execCommand("copy")`, only showing "Copied" on success. - gitHubCliUpgradeCommand: return null for platforms without a known package manager (freebsd, openbsd, sunos, aix) instead of an `apt` command that can't run there; the row then shows the generic "update it" guidance with no command line. Adds a regression test for the freebsd case. Co-Authored-By: Claude Opus 4.8 --- .../GitHubSourceControlProvider.test.ts | 15 +++++++ .../GitHubSourceControlProvider.ts | 10 +++-- .../settings/SourceControlSettings.tsx | 41 +++++++++++++++++-- 3 files changed, 59 insertions(+), 7 deletions(-) diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts index 9b2468427df..db0c7e1c374 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -411,3 +411,18 @@ it("detects an outdated gh by version and suggests the platform upgrade command" { status: "outdated", detail: Option.some("winget upgrade --id GitHub.cli") }, ); }); + +it("omits the upgrade command on platforms without a known package manager", () => { + const auth = GitHubSourceControlProvider.discovery.parseAuth({ + stdout: "", + stderr: "unknown flag: --json", + exitCode: ChildProcessSpawner.ExitCode(1), + version: Option.some("gh version 2.74.2 (2025-06-18)"), + platform: "freebsd", + }); + + assert.deepStrictEqual( + { status: auth.status, detail: auth.detail }, + { status: "outdated", detail: Option.none() }, + ); +}); diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index 52122ddf607..9a45c293437 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -69,15 +69,19 @@ function isOutdatedGitHubCli(input: SourceControlAuthProbeInput): boolean { return version !== null && compareSemverVersions(version, GITHUB_CLI_MIN_VERSION) < 0; } -function gitHubCliUpgradeCommand(platform: NodeJS.Platform | undefined): string { +function gitHubCliUpgradeCommand(platform: NodeJS.Platform | undefined): string | null { switch (platform) { case "darwin": return "brew upgrade gh"; case "win32": return "winget upgrade --id GitHub.cli"; - default: + case "linux": // Debian/Ubuntu default. Detecting the actual package manager is a follow-up. return "sudo apt update && sudo apt install gh"; + default: + // BSDs, illumos, AIX, etc.: no safe single command. Return null so we show + // the generic "update it" guidance instead of a command that can't run. + return null; } } @@ -101,7 +105,7 @@ function parseGitHubAuth(input: SourceControlAuthProbeInput) { if (!authStatus.parsed && isOutdatedGitHubCli(input)) { return providerAuth({ status: "outdated", - detail: gitHubCliUpgradeCommand(input.platform), + detail: gitHubCliUpgradeCommand(input.platform) ?? undefined, }); } diff --git a/apps/web/src/components/settings/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx index 7fea51b5fa9..c281d65d778 100644 --- a/apps/web/src/components/settings/SourceControlSettings.tsx +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -202,9 +202,9 @@ function itemSummary({ if (auth.status === "outdated") { return ( - Your {item.executable} CLI - is too old for {item.label} sign-in to be detected here — you may already be authenticated. - Update it on the server host, then Rescan. + Your {item.executable}{" "} + CLI is too old for {item.label} sign-in to be detected here — you may already be + authenticated. Update it on the server host, then Rescan. ); } @@ -253,6 +253,38 @@ function CliVersion({ ); } +/** + * Copy helper that works outside secure contexts / embedded webviews, where + * `navigator.clipboard` is undefined. Falls back to a hidden-textarea + + * `execCommand("copy")`. Returns whether the copy succeeded. + */ +async function copyTextToClipboard(text: string): Promise { + if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + // Fall through to the execCommand fallback below. + } + } + if (typeof document === "undefined") return false; + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.setAttribute("readonly", ""); + textarea.style.position = "fixed"; + textarea.style.left = "-9999px"; + document.body.appendChild(textarea); + textarea.select(); + let ok = false; + try { + ok = document.execCommand("copy"); + } catch { + ok = false; + } + document.body.removeChild(textarea); + return ok; +} + function UpgradeCommandLine({ command }: { readonly command: string }) { const [copied, setCopied] = useState(false); return ( @@ -264,7 +296,8 @@ function UpgradeCommandLine({ command }: { readonly command: string }) { variant="ghost" className="h-6 shrink-0 gap-1 px-1.5 text-xs text-primary hover:text-primary" onClick={() => { - void navigator.clipboard.writeText(command).then(() => { + void copyTextToClipboard(command).then((ok) => { + if (!ok) return; setCopied(true); window.setTimeout(() => setCopied(false), 1500); }); From 3fe8ad01fa7360528ac08969fb845745fbc917f1 Mon Sep 17 00:00:00 2001 From: Noisemaker111 Date: Wed, 8 Jul 2026 12:39:13 -0400 Subject: [PATCH 3/4] fix(source-control): block PR/publish actions when the gh CLI is outdated An `outdated` CLI can't confirm sign-in, but the readiness checks only gated on `unauthenticated`, so GitHub was marked usable and pull-request / publish / add-project actions were enabled and then failed at runtime. Treat `outdated` as not-ready (with an "update it" hint) in all three readiness paths: - CommandPalette add-project readiness - GitActionsControl publish readiness - client-runtime add-project remote readiness Co-Authored-By: Claude Opus 4.8 --- apps/web/src/components/CommandPalette.tsx | 7 +++++++ apps/web/src/components/GitActionsControl.tsx | 6 ++++++ packages/client-runtime/src/operations/projects.ts | 7 +++++++ 3 files changed, 20 insertions(+) diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 8dccf984457..7e7e049dc93 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -312,6 +312,13 @@ function buildAddProjectRemoteSourceReadiness( readiness[source] = { ready: false, hint: provider.installHint }; continue; } + if (provider.auth.status === "outdated") { + readiness[source] = { + ready: false, + hint: `${provider.label} CLI is outdated and can't confirm sign-in. Open Settings -> Source Control to update it.`, + }; + continue; + } if (provider.auth.status === "unauthenticated") { readiness[source] = { ready: false, diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index c9816719452..5a3010b7291 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -225,6 +225,12 @@ function getPublishProviderReadiness(input: { if (discovered.status !== "available") { return { ready: false, hint: discovered.installHint }; } + if (discovered.auth.status === "outdated") { + return { + ready: false, + hint: `${discovered.label} CLI is outdated and can't confirm sign-in. Open Settings -> Source Control to update it.`, + }; + } if (discovered.auth.status === "unauthenticated") { return { ready: false, diff --git a/packages/client-runtime/src/operations/projects.ts b/packages/client-runtime/src/operations/projects.ts index ec58418a94f..8fe654cdab2 100644 --- a/packages/client-runtime/src/operations/projects.ts +++ b/packages/client-runtime/src/operations/projects.ts @@ -151,6 +151,13 @@ export function buildAddProjectRemoteSourceReadiness( readiness[source] = { ready: false, hint: provider.installHint }; continue; } + if (provider.auth.status === "outdated") { + readiness[source] = { + ready: false, + hint: `${provider.label} CLI is outdated and can't confirm sign-in. Open Source Control settings to update it.`, + }; + continue; + } if (provider.auth.status === "unauthenticated") { readiness[source] = { ready: false, From ce83182e3480c66e2772b4f9f2c9edbb06941713 Mon Sep 17 00:00:00 2001 From: Noisemaker111 Date: Wed, 8 Jul 2026 12:02:16 -0400 Subject: [PATCH 4/4] feat(source-control): detect the package manager for the gh upgrade command When GitHub is flagged as an outdated CLI, probe the host for how `gh` was actually installed and surface the exact upgrade command instead of a single per-OS default: - winget / scoop / choco on Windows - brew / macports on macOS - apt (dpkg) / dnf (rpm) / pacman on Linux Adds an optional `resolveUpgradeCommand` hook to the CLI discovery spec, run from `probeSourceControlProvider` only when `parseAuth` reports `outdated`. Any probe failure falls back to the platform default from the base change, so this can never fail discovery. Co-Authored-By: Claude Opus 4.8 --- .../GitHubSourceControlProvider.ts | 83 ++++++++++++++++++- .../SourceControlProviderDiscovery.ts | 45 +++++++--- 2 files changed, 117 insertions(+), 11 deletions(-) diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index 9a45c293437..b35fef3c792 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -10,6 +10,7 @@ import { } from "@t3tools/contracts"; import * as GitHubCli from "./GitHubCli.ts"; +import type * as VcsProcess from "../vcs/VcsProcess.ts"; import { findAuthenticatedGitHubAccount, parseGitHubAuthStatus } from "./gitHubAuthStatus.ts"; import { decodeGitHubPullRequestListJson } from "./gitHubPullRequests.ts"; import * as SourceControlProvider from "./SourceControlProvider.ts"; @@ -76,7 +77,8 @@ function gitHubCliUpgradeCommand(platform: NodeJS.Platform | undefined): string case "win32": return "winget upgrade --id GitHub.cli"; case "linux": - // Debian/Ubuntu default. Detecting the actual package manager is a follow-up. + // Debian/Ubuntu default, used when `resolveGitHubUpgradeCommand` can't + // identify the actual package manager. return "sudo apt update && sudo apt install gh"; default: // BSDs, illumos, AIX, etc.: no safe single command. Return null so we show @@ -85,6 +87,84 @@ function gitHubCliUpgradeCommand(platform: NodeJS.Platform | undefined): string } } +function ghUpgradeProbe( + process: VcsProcess.VcsProcess["Service"], + cwd: string, + command: string, + args: ReadonlyArray, +): Effect.Effect { + return process + .run({ + operation: "source-control.discovery.upgrade-probe", + command, + args, + cwd, + allowNonZeroExit: true, + timeoutMs: 5_000, + maxOutputBytes: 8_000, + appendTruncationMarker: true, + }) + .pipe( + Effect.map((result) => Number(result.exitCode) === 0), + Effect.orElseSucceed(() => false), + ); +} + +/** + * When `gh` is outdated, figure out which package manager installed it so we can + * suggest the exact upgrade command. Returns `null` when nothing matches, so the + * caller keeps the platform default from `gitHubCliUpgradeCommand`. + * + * These are best-effort heuristics; some managers (scoop, choco) are shims whose + * spawnability varies by host, so a miss simply falls back to the default. + */ +function resolveGitHubUpgradeCommand(input: { + readonly process: VcsProcess.VcsProcess["Service"]; + readonly cwd: string; + readonly platform: NodeJS.Platform; +}): Effect.Effect { + const probe = (command: string, args: ReadonlyArray) => + ghUpgradeProbe(input.process, input.cwd, command, args); + + return Effect.gen(function* () { + switch (input.platform) { + case "win32": { + if (yield* probe("winget", ["list", "--id", "GitHub.cli", "-e"])) { + return "winget upgrade --id GitHub.cli -e"; + } + if (yield* probe("scoop", ["list", "gh"])) { + return "scoop update gh"; + } + if (yield* probe("choco", ["list", "--local-only", "gh"])) { + return "choco upgrade gh"; + } + return null; + } + case "darwin": { + if (yield* probe("brew", ["list", "--versions", "gh"])) { + return "brew upgrade gh"; + } + if (yield* probe("port", ["installed", "gh"])) { + return "sudo port upgrade gh"; + } + return null; + } + default: { + if (yield* probe("dpkg", ["-s", "gh"])) { + return "sudo apt update && sudo apt install gh"; + } + if (yield* probe("rpm", ["-q", "gh"])) { + return "sudo dnf upgrade gh"; + } + if (yield* probe("pacman", ["-Q", "github-cli"])) { + return "sudo pacman -S github-cli"; + } + return null; + } + } + }); +} + function parseGitHubAuth(input: SourceControlAuthProbeInput) { const output = combinedAuthOutput(input); const authStatus = parseGitHubAuthStatus(input.stdout); @@ -143,6 +223,7 @@ export const discovery = { versionArgs: ["--version"], authArgs: ["auth", "status", "--json", "hosts"], parseAuth: parseGitHubAuth, + resolveUpgradeCommand: resolveGitHubUpgradeCommand, installHint: "Install the GitHub command-line tool (`gh`) via https://cli.github.com/ or your package manager (for example `brew install gh`).", } satisfies SourceControlCliDiscoverySpec; diff --git a/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts b/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts index 2b9125a999b..f9badc1c7da 100644 --- a/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts +++ b/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts @@ -39,6 +39,17 @@ export type SourceControlCliDiscoverySpec = SourceControlDiscoverySpecBase & { readonly versionArgs: ReadonlyArray; readonly authArgs: ReadonlyArray; readonly parseAuth: (input: SourceControlAuthProbeInput) => SourceControlProviderAuth; + /** + * Only consulted when `parseAuth` returns `outdated`: probe the host for how + * the CLI was actually installed and return the exact upgrade command (or + * `null` to keep `parseAuth`'s platform default). Any process error must + * resolve to `null` — this can never fail discovery. + */ + readonly resolveUpgradeCommand?: (input: { + readonly process: VcsProcess.VcsProcess["Service"]; + readonly cwd: string; + readonly platform: NodeJS.Platform; + }) => Effect.Effect; readonly refineUnknownRemote?: ( input: SourceControlUnknownRemoteRefinementInput, ) => SourceControlProviderInfo | null; @@ -257,16 +268,30 @@ export function probeSourceControlProvider(input: { Effect.flatMap((result) => Effect.gen(function* () { const platform = yield* HostProcessPlatform; - return { - ...item, - auth: spec.parseAuth({ - stdout: result.stdout, - stderr: result.stderr, - exitCode: result.exitCode, - version: item.version, - platform, - }), - } satisfies SourceControlProviderDiscoveryItem; + const auth = spec.parseAuth({ + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.exitCode, + version: item.version, + platform, + }); + + // When the CLI is outdated, let the provider probe how it was + // installed and refine the upgrade command. Failures fall back to + // the platform default already carried on `auth.detail`. + if (auth.status === "outdated" && spec.resolveUpgradeCommand !== undefined) { + const resolved = yield* spec + .resolveUpgradeCommand({ process: input.process, cwd: input.cwd, platform }) + .pipe(Effect.orElseSucceed(() => null)); + if (resolved !== null && resolved.trim().length > 0) { + return { + ...item, + auth: providerAuth({ status: "outdated", detail: resolved }), + } satisfies SourceControlProviderDiscoveryItem; + } + } + + return { ...item, auth } satisfies SourceControlProviderDiscoveryItem; }), ), Effect.catch((cause) =>