Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -381,3 +381,48 @@ 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") },
);
});

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() },
);
});
134 changes: 134 additions & 0 deletions apps/server/src/sourceControl/GitHubSourceControlProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ 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,
type ChangeRequestState,
} 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";
Expand Down Expand Up @@ -42,6 +44,127 @@ 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<string> | 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 | null {
switch (platform) {
case "darwin":
return "brew upgrade gh";
case "win32":
return "winget upgrade --id GitHub.cli";
case "linux":
// 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
// the generic "update it" guidance instead of a command that can't run.
return null;
}
}

function ghUpgradeProbe(
process: VcsProcess.VcsProcess["Service"],
cwd: string,
command: string,
args: ReadonlyArray<string>,
): Effect.Effect<boolean> {
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<string | null> {
const probe = (command: string, args: ReadonlyArray<string>) =>
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);
Expand All @@ -56,6 +179,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) ?? undefined,
});
}

const failedAccount = authStatus.accounts.find((entry) => entry.active) ?? authStatus.accounts[0];
if (authStatus.parsed) {
return providerAuth({
Expand Down Expand Up @@ -90,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;
Expand Down
50 changes: 44 additions & 6 deletions apps/server/src/sourceControl/SourceControlProviderDiscovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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<string>;
/** Host platform, so `parseAuth` can suggest an OS-appropriate upgrade command. */
readonly platform?: NodeJS.Platform;
}

export interface SourceControlUnknownRemoteRefinementInput {
Expand All @@ -34,6 +39,17 @@ export type SourceControlCliDiscoverySpec = SourceControlDiscoverySpecBase & {
readonly versionArgs: ReadonlyArray<string>;
readonly authArgs: ReadonlyArray<string>;
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<string | null>;
readonly refineUnknownRemote?: (
input: SourceControlUnknownRemoteRefinementInput,
) => SourceControlProviderInfo | null;
Expand Down Expand Up @@ -249,12 +265,34 @@ export function probeSourceControlProvider(input: {
appendTruncationMarker: true,
})
.pipe(
Effect.map(
(result) =>
({
...item,
auth: spec.parseAuth(result),
}) satisfies SourceControlProviderDiscoveryItem,
Effect.flatMap((result) =>
Effect.gen(function* () {
const platform = yield* HostProcessPlatform;
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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium sourceControl/SourceControlProviderDiscovery.ts:282

When auth.status === "outdated" and spec.resolveUpgradeCommand is defined, the code always runs the resolver and, if it returns a non-empty string, overwrites the auth detail — including for unsupported platforms like freebsd. parseAuth intentionally returns outdated with generic guidance (no upgrade command) on such platforms, but resolveUpgradeCommand treats every non-darwin/non-win32 host as Linux, so it can return a bogus dpkg/rpm/pacman command whenever one of those tools happens to be installed. Users on unsupported Unix hosts would be told to run the wrong upgrade command instead of the previous generic guidance. Consider only invoking resolveUpgradeCommand on platforms it actually supports, or having the resolver return null for unsupported platforms.

Also found in 1 other location(s)

apps/server/src/sourceControl/GitHubSourceControlProvider.ts:156

resolveGitHubUpgradeCommand treats any successful rpm -q gh probe as proof that dnf is the correct package manager and returns sudo dnf upgrade gh. On RPM-based systems that do not use dnf (for example openSUSE/SLES, which use zypper but still answer rpm -q gh), the discovery flow will show a non-working upgrade command. That breaks this PR's package-manager-aware guidance for those hosts instead of falling back to a usable generic hint.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/sourceControl/SourceControlProviderDiscovery.ts around line 282:

When `auth.status === "outdated"` and `spec.resolveUpgradeCommand` is defined, the code always runs the resolver and, if it returns a non-empty string, overwrites the `auth` detail — including for unsupported platforms like `freebsd`. `parseAuth` intentionally returns `outdated` with generic guidance (no upgrade command) on such platforms, but `resolveUpgradeCommand` treats every non-`darwin`/non-`win32` host as Linux, so it can return a bogus `dpkg`/`rpm`/`pacman` command whenever one of those tools happens to be installed. Users on unsupported Unix hosts would be told to run the wrong upgrade command instead of the previous generic guidance. Consider only invoking `resolveUpgradeCommand` on platforms it actually supports, or having the resolver return `null` for unsupported platforms.

Also found in 1 other location(s):
- apps/server/src/sourceControl/GitHubSourceControlProvider.ts:156 -- `resolveGitHubUpgradeCommand` treats any successful `rpm -q gh` probe as proof that `dnf` is the correct package manager and returns `sudo dnf upgrade gh`. On RPM-based systems that do not use `dnf` (for example openSUSE/SLES, which use `zypper` but still answer `rpm -q gh`), the discovery flow will show a non-working upgrade command. That breaks this PR's package-manager-aware guidance for those hosts instead of falling back to a usable generic hint.

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) =>
Effect.succeed({
Expand Down
7 changes: 7 additions & 0 deletions apps/web/src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/components/GitActionsControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading