diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts index 9e8a6829566..6df81a1cdcc 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -7,7 +7,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as GitHubCli from "./GitHubCli.ts"; -import { parseGitHubAuthStatus } from "./gitHubAuthStatus.ts"; +import { parseGitHubAuthStatus, parseGitHubAuthStatusText } from "./gitHubAuthStatus.ts"; import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; const processResult = ( @@ -381,3 +381,66 @@ it("reports unauthenticated when GitHub JSON has accounts but none are valid", ( }, ); }); + +it("parses the plain-text auth status emitted by CLIs without --json support", () => { + const auth = GitHubSourceControlProvider.discovery.parseAuth( + processResult( + [ + "github.com", + " \u2713 Logged in to github.com account octocat (/home/user/.config/gh/hosts.yml)", + " - Active account: true", + " - Token: gho_************", + ].join("\n"), + ), + ); + + assert.deepStrictEqual(auth, { + status: "authenticated", + account: Option.some("octocat"), + host: Option.some("github.com"), + detail: Option.none(), + }); +}); + +it("parses the legacy `Logged in to as ` phrasing", () => { + const status = parseGitHubAuthStatusText( + [ + "github.com", + " \u2713 Logged in to github.com as octocat (/home/user/.config/gh/hosts.yml)", + " \u2713 Token scopes: gist, read:org, repo", + ].join("\n"), + ); + + assert.deepStrictEqual(status, { + parsed: true, + accounts: [ + { + host: "github.com", + account: "octocat", + authenticated: true, + active: true, + error: null, + }, + ], + }); +}); + +it("keeps failed plain-text logins unauthenticated", () => { + const status = parseGitHubAuthStatusText( + [ + "github.com", + " X Failed to log in to github.com account octocat (keyring)", + " - Active account: true", + ].join("\n"), + ); + + assert.deepStrictEqual(status.accounts, [ + { + host: "github.com", + account: "octocat", + authenticated: false, + active: true, + error: null, + }, + ]); +}); diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index b5d5d3a55f8..33af223ab7f 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -9,7 +9,11 @@ import { } from "@t3tools/contracts"; import * as GitHubCli from "./GitHubCli.ts"; -import { findAuthenticatedGitHubAccount, parseGitHubAuthStatus } from "./gitHubAuthStatus.ts"; +import { + findAuthenticatedGitHubAccount, + parseGitHubAuthStatus, + parseGitHubAuthStatusText, +} from "./gitHubAuthStatus.ts"; import { decodeGitHubPullRequestListJson } from "./gitHubPullRequests.ts"; import * as SourceControlProvider from "./SourceControlProvider.ts"; import { @@ -44,7 +48,10 @@ function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeReq function parseGitHubAuth(input: SourceControlAuthProbeInput) { const output = combinedAuthOutput(input); - const authStatus = parseGitHubAuthStatus(input.stdout); + const jsonStatus = parseGitHubAuthStatus(input.stdout); + // Older CLIs (< 2.81) lack `auth status --json`; the discovery fallback re-runs + // the plain command, whose human-readable output is parsed here. + const authStatus = jsonStatus.parsed ? jsonStatus : parseGitHubAuthStatusText(output); const authenticatedAccount = findAuthenticatedGitHubAccount(authStatus.accounts); const host = authenticatedAccount?.host; @@ -89,6 +96,7 @@ export const discovery = { executable: "gh", versionArgs: ["--version"], authArgs: ["auth", "status", "--json", "hosts"], + authFallbackArgs: ["auth", "status"], parseAuth: parseGitHubAuth, installHint: "Install the GitHub command-line tool (`gh`) via https://cli.github.com/ or your package manager (for example `brew install gh`).", diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index 9e4702af04c..40c43f0ce93 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -281,3 +281,146 @@ Logged in to gitlab.com as gitlab-user ); }).pipe(Effect.provide(testLayer)); }); + +it.effect("falls back to plain `gh auth status` when the CLI predates --json", () => { + const calls: string[] = []; + const processMock = { + run: (input: VcsProcess.VcsProcessInput) => { + calls.push([input.command, ...input.args].join(" ")); + if (input.args[0] === "--version") { + return Effect.succeed(processOutput(`${input.command} version 2.45.0\n`)); + } + if (input.command === "gh" && input.args.join(" ") === "auth status --json hosts") { + return Effect.succeed( + processOutput("", { + stderr: "unknown flag: --json\n", + exitCode: ChildProcessSpawner.ExitCode(1), + }), + ); + } + if (input.command === "gh" && input.args.join(" ") === "auth status") { + return Effect.succeed( + processOutput( + [ + "github.com", + " ✓ Logged in to github.com as octocat (/home/user/.config/gh/hosts.yml)", + " ✓ Token: gho_************", + ].join("\n"), + ), + ); + } + return Effect.fail( + new VcsProcessSpawnError({ + operation: "source-control.discovery.probe", + command: input.command, + cwd: input.cwd, + cause: new Error("not found"), + }), + ); + }, + } satisfies Partial; + + const testLayer = SourceControlDiscovery.layer.pipe( + Layer.provide( + Layer.mergeAll( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-source-control-discovery-fallback-test-", + }).pipe(Layer.provide(NodeServices.layer)), + Layer.mock(VcsProcess.VcsProcess)(processMock), + Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({}), + sourceControlProviderRegistryTestLayer({ + process: processMock, + bitbucket: { + probeAuth: Effect.succeed({ + status: "unauthenticated" as const, + account: Option.none(), + host: Option.none(), + detail: Option.none(), + }), + }, + }), + ), + ), + Layer.provideMerge(NodeServices.layer), + ); + + return Effect.gen(function* () { + const discovery = yield* SourceControlDiscovery.SourceControlDiscovery; + const result = yield* discovery.discover; + + const github = result.sourceControlProviders.find((item) => item.kind === "github"); + assert.ok(github); + assert.strictEqual(github.auth.status, "authenticated"); + assert.deepStrictEqual(github.auth.account, Option.some("octocat")); + assert.ok(calls.includes("gh auth status")); + }).pipe(Effect.provide(testLayer)); +}); + +it.effect("does NOT fall back when the primary probe reports a genuine sign-out", () => { + const calls: string[] = []; + const processMock = { + run: (input: VcsProcess.VcsProcessInput) => { + calls.push([input.command, ...input.args].join(" ")); + if (input.args[0] === "--version") { + return Effect.succeed(processOutput(`${input.command} version 2.95.0\n`)); + } + if (input.command === "gh" && input.args.join(" ") === "auth status --json hosts") { + // Modern CLI, simply not logged in: non-zero exit, specific message, no --json flag error. + return Effect.succeed( + processOutput("", { + stderr: "You are not logged into any GitHub hosts. To log in, run: gh auth login\n", + exitCode: ChildProcessSpawner.ExitCode(1), + }), + ); + } + return Effect.fail( + new VcsProcessSpawnError({ + operation: "source-control.discovery.probe", + command: input.command, + cwd: input.cwd, + cause: new Error("not found"), + }), + ); + }, + } satisfies Partial; + + const testLayer = SourceControlDiscovery.layer.pipe( + Layer.provide( + Layer.mergeAll( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-source-control-discovery-no-fallback-test-", + }).pipe(Layer.provide(NodeServices.layer)), + Layer.mock(VcsProcess.VcsProcess)(processMock), + Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({}), + sourceControlProviderRegistryTestLayer({ + process: processMock, + bitbucket: { + probeAuth: Effect.succeed({ + status: "unauthenticated" as const, + account: Option.none(), + host: Option.none(), + detail: Option.none(), + }), + }, + }), + ), + ), + Layer.provideMerge(NodeServices.layer), + ); + + return Effect.gen(function* () { + const discovery = yield* SourceControlDiscovery.SourceControlDiscovery; + const result = yield* discovery.discover; + + const github = result.sourceControlProviders.find((item) => item.kind === "github"); + assert.ok(github); + assert.strictEqual(github.auth.status, "unauthenticated"); + // The fallback `gh auth status` must NOT have been invoked, and the specific + // sign-out detail from the primary probe must be preserved. + assert.strictEqual(calls.filter((call) => call === "gh auth status").length, 0); + assert.deepStrictEqual( + github.auth.detail, + Option.some("You are not logged into any GitHub hosts. To log in, run: gh auth login"), + ); + }).pipe(Effect.provide(testLayer)); +}); diff --git a/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts b/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts index e3a6bd1fb20..e29d151fd27 100644 --- a/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts +++ b/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts @@ -33,6 +33,11 @@ export type SourceControlCliDiscoverySpec = SourceControlDiscoverySpecBase & { readonly executable: string; readonly versionArgs: ReadonlyArray; readonly authArgs: ReadonlyArray; + /** + * Args retried when `authArgs` exits non-zero without resolving an account — + * typically an older CLI that does not support the flags in `authArgs`. + */ + readonly authFallbackArgs?: ReadonlyArray; readonly parseAuth: (input: SourceControlAuthProbeInput) => SourceControlProviderAuth; readonly refineUnknownRemote?: ( input: SourceControlUnknownRemoteRefinementInput, @@ -120,6 +125,15 @@ export function combinedAuthOutput(input: SourceControlAuthProbeInput): string { return parts.join("\n"); } +// Cobra-based CLIs (gh, glab, …) print these when handed a flag/subcommand they +// don't support — the signal that a probe failed because the CLI is too old for +// the primary args, rather than because the user is signed out. +const UNSUPPORTED_INVOCATION_PATTERN = /unknown (?:flag|shorthand flag|command)/iu; + +export function looksLikeUnsupportedInvocation(input: SourceControlAuthProbeInput): boolean { + return input.exitCode !== 0 && UNSUPPORTED_INVOCATION_PATTERN.test(combinedAuthOutput(input)); +} + function sanitizedAuthLines(text: string): ReadonlyArray { const lines: string[] = []; for (const entry of text.split(/\r?\n/)) { @@ -237,32 +251,54 @@ export function probeSourceControlProvider(input: { } satisfies SourceControlProviderDiscoveryItem); } - return input.process - .run({ + const runAuth = (args: ReadonlyArray) => + input.process.run({ operation: "source-control.discovery.auth", command: spec.executable, - args: spec.authArgs, + args, cwd: input.cwd, allowNonZeroExit: true, timeoutMs: 5_000, maxOutputBytes: 8_000, appendTruncationMarker: true, - }) - .pipe( - Effect.map( - (result) => - ({ + }); + + return runAuth(spec.authArgs).pipe( + Effect.flatMap((result) => { + const auth = spec.parseAuth(result); + const fallbackArgs = spec.authFallbackArgs; + // Only retry with the fallback args when the CLI actually rejected the + // primary invocation (an older CLI that predates those flags). A genuine + // "not signed in" result already carries specific detail, so it must not + // trigger the fallback or have its detail replaced by a generic hint. + if ( + fallbackArgs === undefined || + auth.status === "authenticated" || + !looksLikeUnsupportedInvocation(result) + ) { + return Effect.succeed({ ...item, auth } satisfies SourceControlProviderDiscoveryItem); + } + + return runAuth(fallbackArgs).pipe( + Effect.map((fallbackResult) => { + const fallbackAuth = spec.parseAuth(fallbackResult); + return { ...item, - auth: spec.parseAuth(result), - }) satisfies SourceControlProviderDiscoveryItem, - ), - Effect.catch((cause) => - Effect.succeed({ - ...item, - auth: unknownAuth(Option.getOrUndefined(detailFromCause(cause))), - } satisfies SourceControlProviderDiscoveryItem), - ), - ); + auth: fallbackAuth.status === "unknown" ? auth : fallbackAuth, + } satisfies SourceControlProviderDiscoveryItem; + }), + Effect.orElseSucceed( + () => ({ ...item, auth }) satisfies SourceControlProviderDiscoveryItem, + ), + ); + }), + Effect.catch((cause) => + Effect.succeed({ + ...item, + auth: unknownAuth(Option.getOrUndefined(detailFromCause(cause))), + } satisfies SourceControlProviderDiscoveryItem), + ), + ); }), ); } diff --git a/apps/server/src/sourceControl/gitHubAuthStatus.ts b/apps/server/src/sourceControl/gitHubAuthStatus.ts index d58909c560c..4cf38b67a67 100644 --- a/apps/server/src/sourceControl/gitHubAuthStatus.ts +++ b/apps/server/src/sourceControl/gitHubAuthStatus.ts @@ -62,6 +62,65 @@ export function parseGitHubAuthStatus(text: string): GitHubAuthStatus { }); } +const HOST_LINE_PATTERN = /^(?:[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?|\[[a-f0-9:.]+\])(?::\d+)?$/iu; +const LOGGED_IN_PATTERN = /Logged in to\s+(\S+)\s+(?:account\s+|as\s+)([^\s(]+)/iu; +const LOGIN_FAILED_PATTERN = /Failed to log in to\s+(\S+)\s+(?:account\s+|as\s+)([^\s(]+)/iu; +const ACTIVE_ACCOUNT_PATTERN = /Active account:\s*(true|false)/iu; + +/** + * Parse the human-readable `gh auth status` output. + * + * Used as a fallback for CLI versions older than 2.81, which do not support + * `--json` and would otherwise be reported as unauthenticated. Handles both the + * current `Logged in to account ` phrasing and the older + * `Logged in to as ` form. + */ +type PendingGitHubAuthStatusAccount = Omit & { + active: boolean | null; +}; + +export function parseGitHubAuthStatusText(text: string): GitHubAuthStatus { + const accounts: Array = []; + let currentHost: string | null = null; + + for (const rawLine of text.split(/\r?\n/)) { + const line = rawLine.trim(); + if (line.length === 0) continue; + + if (rawLine.length === rawLine.trimStart().length && HOST_LINE_PATTERN.test(line)) { + currentHost = line.toLowerCase(); + continue; + } + + const active = ACTIVE_ACCOUNT_PATTERN.exec(line)?.[1]; + if (active !== undefined && accounts.length > 0) { + accounts[accounts.length - 1]!.active = active.toLowerCase() === "true"; + continue; + } + + const match = LOGGED_IN_PATTERN.exec(line) ?? LOGIN_FAILED_PATTERN.exec(line); + if (match === undefined || match === null) continue; + + const host = nonEmptyString(match[1] ?? "") ?? currentHost; + const login = nonEmptyString(match[2] ?? ""); + if (host === null || login === null) continue; + + accounts.push({ + host: host.toLowerCase(), + account: login, + authenticated: LOGGED_IN_PATTERN.test(line), + // Older CLIs omit the `Active account:` line entirely; treat those as active. + active: null, + error: null, + }); + } + + return { + parsed: accounts.length > 0, + accounts: accounts.map((entry) => ({ ...entry, active: entry.active ?? true })), + }; +} + export function findAuthenticatedGitHubAccount( accounts: ReadonlyArray, ): GitHubAuthStatusAccount | undefined {