diff --git a/src/products/breeze/engine/daemon/identity.ts b/src/products/breeze/engine/daemon/identity.ts index 43d3cd4..f49ebde 100644 --- a/src/products/breeze/engine/daemon/identity.ts +++ b/src/products/breeze/engine/daemon/identity.ts @@ -6,7 +6,7 @@ * The daemon needs a richer identity than the one-shot commands: * - `host` — GitHub host (default `github.com`) * - `login` — the authenticated user's GH login - * - `gitProtocol` — `https` | `ssh` (from `gh auth status --json hosts`) + * - `gitProtocol` — `https` | `ssh` (from `gh auth status --active`) * - `scopes` — OAuth scope list; used by `hasRequiredScope` to warn if * the token lacks `repo`/`notifications` * - `lockKey(profile)` — `host__login__profile`, used by the broker @@ -16,7 +16,7 @@ * Caching: delegates to `runtime/identity-cache.ts`'s 24h-TTL JSON file at * `~/.breeze/identity.json`. The core cache only stores `{login, host, * fetched_at_ms}` because one-shot callers don't need scopes. The - * daemon fetches the richer payload via `gh auth status --json hosts` + * daemon fetches the richer payload via `gh auth status --active` * and keeps it in memory for the lifetime of the daemon process. */ @@ -47,72 +47,85 @@ export interface ResolveDaemonIdentityDeps { host?: string; } -interface AuthStatusHostEntry { - active?: boolean; - user?: string; - login?: string; - gitProtocol?: string; - git_protocol?: string; - scopes?: string | string[]; -} - -interface AuthStatusPayload { - hosts?: Record; -} - /** * Parse the scope field as `gh` returns it. Shape varies: * - Comma-separated string (`"repo,workflow"`) — older output - * - Array of strings (`["repo", "workflow"]`) — newer output + * - Shell-quoted string from `gh auth status` + * (`"'repo', 'workflow'"`) */ function parseScopes(raw: unknown): string[] { - if (Array.isArray(raw)) { - return raw - .filter((s): s is string => typeof s === "string") - .map((s) => s.trim()) - .filter((s) => s.length > 0); - } if (typeof raw === "string") { return raw .split(",") - .map((s) => s.trim()) + .map((s) => s.trim().replace(/^['"]+|['"]+$/gu, "")) + .filter((s) => s.length > 0) + .filter((s) => s !== "(none)" && s.toLowerCase() !== "none"); + } + if (Array.isArray(raw)) { + return raw + .filter((s): s is string => typeof s === "string") + .map((s) => s.trim().replace(/^['"]+|['"]+$/gu, "")) .filter((s) => s.length > 0); } return []; } /** - * Pick the active account from a `gh auth status --json hosts` payload. - * Handles both shapes seen in the wild (single object vs array per host). + * Parse the active account from `gh auth status --active --hostname `. */ -export function pickActiveIdentityFromAuthStatus( - payload: AuthStatusPayload, +export function pickIdentityFromAuthStatusText( + statusText: string, targetHost: string, ): DaemonIdentity | null { - const hosts = payload.hosts ?? {}; - for (const [host, bucket] of Object.entries(hosts)) { - if (host !== targetHost) continue; - const candidates: AuthStatusHostEntry[] = Array.isArray(bucket) - ? bucket - : [bucket]; - const active = candidates.find((c) => c?.active === true) ?? candidates[0]; - if (!active) continue; - const login = active.user ?? active.login; - if (typeof login !== "string" || login.length === 0) continue; - const gitProtocol = active.gitProtocol ?? active.git_protocol ?? "https"; - return { - host, - login, - gitProtocol, - scopes: parseScopes(active.scopes), - }; + let login: string | null = null; + let gitProtocol = "https"; + let scopes: string[] = []; + + for (const line of statusText.split(/\r?\n/u)) { + const trimmed = line.trim(); + if (trimmed.length === 0) continue; + + if (!login) { + const marker = `Logged in to ${targetHost} account `; + const start = trimmed.indexOf(marker); + if (start >= 0) { + const rest = trimmed.slice(start + marker.length).trim(); + const parsedLogin = rest.match(/^([^\s(]+)/u)?.[1]; + if (parsedLogin) { + login = parsedLogin; + continue; + } + } + } + + const protocolMatch = trimmed.match( + /^-\s*Git operations protocol:\s*(\S+)/u, + ); + if (protocolMatch) { + gitProtocol = protocolMatch[1]; + continue; + } + + const scopesMatch = trimmed.match(/^-\s*Token scopes:\s*(.+)$/u); + if (scopesMatch) { + scopes = parseScopes(scopesMatch[1]); + } } - return null; + + if (!login) return null; + + return { + host: targetHost, + login, + gitProtocol, + scopes, + }; } /** * Resolve the active gh identity for the daemon. Uses - * `gh auth status --json hosts` with a jq-free JSON parse in Node. + * `gh auth status --active --hostname ` to stay compatible with + * older gh releases that don't support `gh auth status --json`. */ export function resolveDaemonIdentity( deps: ResolveDaemonIdentityDeps = {}, @@ -128,8 +141,6 @@ export function resolveDaemonIdentity( "--active", "--hostname", host, - "--json", - "hosts", ]); } catch (err) { if (err instanceof GhExecError) { @@ -140,18 +151,11 @@ export function resolveDaemonIdentity( throw err; } - let parsed: AuthStatusPayload; - try { - parsed = JSON.parse(stdout) as AuthStatusPayload; - } catch (err) { + const identity = pickIdentityFromAuthStatusText(stdout, host); + if (!identity) { throw new Error( - `gh auth status returned non-JSON output: ${err instanceof Error ? err.message : String(err)}`, + `could not parse an active gh identity from \`gh auth status\` for host \`${host}\``, ); } - - const identity = pickActiveIdentityFromAuthStatus(parsed, host); - if (!identity) { - throw new Error(`no active gh identity found for host \`${host}\``); - } return identity; } diff --git a/tests/breeze/breeze-daemon-identity.test.ts b/tests/breeze/breeze-daemon-identity.test.ts index 492411b..a0932d6 100644 --- a/tests/breeze/breeze-daemon-identity.test.ts +++ b/tests/breeze/breeze-daemon-identity.test.ts @@ -14,20 +14,24 @@ import { GhClient } from "../../src/products/breeze/engine/runtime/gh.js"; import { identityHasRequiredScope, identityLockKey, - pickActiveIdentityFromAuthStatus, + pickIdentityFromAuthStatusText, resolveDaemonIdentity, } from "../../src/products/breeze/engine/daemon/identity.js"; -function makeGhReturning(stdout: string, status = 0): GhClient { +function makeGhReturning( + stdout: string, + status = 0, + stderr = "", +): { gh: GhClient; spawn: ReturnType } { const spawn = vi.fn().mockReturnValue({ pid: 1, status, signal: null, stdout: Buffer.from(stdout), - stderr: Buffer.alloc(0), + stderr: Buffer.from(stderr), output: [], }); - return new GhClient({ spawn }); + return { gh: new GhClient({ spawn }), spawn }; } describe("identityLockKey", () => { @@ -76,78 +80,76 @@ describe("identityHasRequiredScope", () => { }); }); -describe("pickActiveIdentityFromAuthStatus", () => { - it("picks active=true when multiple entries per host", () => { - const payload = { - hosts: { - "github.com": [ - { - user: "old-login", - active: false, - gitProtocol: "ssh", - scopes: "repo", - }, - { - user: "active-login", - active: true, - gitProtocol: "https", - scopes: "repo,workflow", - }, - ], - }, - }; - const id = pickActiveIdentityFromAuthStatus(payload, "github.com"); +describe("pickIdentityFromAuthStatusText", () => { + it("parses the active account, git protocol, and token scopes", () => { + const statusText = [ + "github.com", + " ✓ Logged in to github.com account active-login (keyring)", + " - Active account: true", + " - Git operations protocol: ssh", + " - Token scopes: 'repo', 'workflow'", + "", + ].join("\n"); + + const id = pickIdentityFromAuthStatusText(statusText, "github.com"); expect(id?.login).toBe("active-login"); expect(id?.scopes).toEqual(["repo", "workflow"]); - expect(id?.gitProtocol).toBe("https"); + expect(id?.gitProtocol).toBe("ssh"); }); - it("accepts scope arrays as well as comma strings", () => { - const payload = { - hosts: { - "github.com": { - user: "x", - active: true, - gitProtocol: "https", - scopes: ["repo", "notifications"], - }, - }, - }; - const id = pickActiveIdentityFromAuthStatus(payload, "github.com"); + it("accepts unquoted scope strings as well as quoted ones", () => { + const statusText = [ + "github.com", + " ✓ Logged in to github.com account x (keyring)", + " - Active account: true", + " - Git operations protocol: https", + " - Token scopes: repo,notifications", + "", + ].join("\n"); + + const id = pickIdentityFromAuthStatusText(statusText, "github.com"); expect(id?.scopes).toEqual(["repo", "notifications"]); }); - it("returns null when target host is missing", () => { - const payload = { - hosts: { "ghe.other": { user: "x", active: true, scopes: "repo" } }, - }; - expect(pickActiveIdentityFromAuthStatus(payload, "github.com")).toBeNull(); + it("returns null when the login line is missing for the target host", () => { + const statusText = [ + "github.com", + " - Active account: true", + " - Token scopes: repo", + "", + ].join("\n"); + expect(pickIdentityFromAuthStatusText(statusText, "github.com")).toBeNull(); }); }); describe("resolveDaemonIdentity", () => { it("surfaces gh auth failure with an actionable error", () => { - const gh = makeGhReturning("", 1); + const { gh } = makeGhReturning("", 1, "not logged in"); expect(() => resolveDaemonIdentity({ gh })).toThrow(/gh auth login/u); }); - it("round-trips a realistic payload", () => { - const payload = JSON.stringify({ - hosts: { - "github.com": { - user: "bingran-you", - active: true, - gitProtocol: "https", - scopes: "repo,workflow,notifications", - }, - }, - }); - const gh = makeGhReturning(payload, 0); + it("parses a realistic auth status response without using --json", () => { + const statusText = [ + "github.com", + " ✓ Logged in to github.com account bingran-you (keyring)", + " - Active account: true", + " - Git operations protocol: https", + " - Token scopes: 'repo', 'workflow', 'notifications'", + "", + ].join("\n"); + const { gh, spawn } = makeGhReturning(statusText, 0); const id = resolveDaemonIdentity({ gh }); expect(id.host).toBe("github.com"); expect(id.login).toBe("bingran-you"); expect(id.gitProtocol).toBe("https"); expect(id.scopes).toContain("repo"); expect(identityHasRequiredScope(id)).toBe(true); + expect(spawn.mock.calls[0]?.[1]).toEqual([ + "auth", + "status", + "--active", + "--hostname", + "github.com", + ]); }); });