From 5832970e652a231cbc4632e852c8b79144c0712b Mon Sep 17 00:00:00 2001 From: rafaeelricco Date: Sat, 16 May 2026 20:09:24 -0300 Subject: [PATCH 1/8] Route Gemini OAuth through SDK with Bearer auth. Use GoogleGenAI httpOptions for timeout and retries instead of raw fetch, removing the REST JSON cast. --- src/infra/llm/gemini.ts | 49 ++++++++++++++--------------------------- 1 file changed, 17 insertions(+), 32 deletions(-) diff --git a/src/infra/llm/gemini.ts b/src/infra/llm/gemini.ts index 177cef4..3849fa1 100644 --- a/src/infra/llm/gemini.ts +++ b/src/infra/llm/gemini.ts @@ -1,6 +1,6 @@ export { type GeminiAuthCredentials, generateContentWithGemini, getAuthCredentials }; -import { GoogleGenAI, ThinkingLevel, type Content, type GenerateContentConfig, type GenerateContentResponse, type GenerationConfig } from "@google/genai"; +import { GoogleGenAI, ThinkingLevel, type GenerateContentConfig, type GenerateContentResponse } from "@google/genai"; import { type Config, type OAuthTokens, type GeminiEffort } from "@/domain/config/config"; import { type GenerateContentParams, type ProviderGeneratedContent, type TokenUsage } from "@/domain/llm/router"; @@ -11,15 +11,12 @@ import { extractResponse } from "@/domain/llm/response-parser"; import { unsupportedAuth } from "@/domain/llm/auth-error"; import { absurd } from "@/libs/types"; +/** Dummy apiKey silences SDK constructor warning; Bearer Authorization wins at request time. */ +const OAUTH_API_KEY_PLACEHOLDER = "oauth-bearer-placeholder"; + type GeminiConfig = Extract; type GeminiAuthCredentials = { readonly method: "api_key"; readonly apiKey: string } | { readonly method: "google_oauth"; readonly tokens: OAuthTokens }; -type OAuthRequestBody = { - contents: Content[]; - systemInstruction?: Content; - generationConfig?: GenerationConfig; -}; - const toTokenUsage = (usage: GenerateContentResponse["usageMetadata"]): Maybe => fromOptional(usage).map((u) => ({ input: fromOptional(u.promptTokenCount), @@ -53,24 +50,10 @@ const buildSDKConfig = (effort: Maybe, params: GenerateContentPara return fromOptional(params.systemInstruction).maybe(core, (s) => ({ ...core, systemInstruction: s })); }; -const buildOAuthBody = (effort: Maybe, params: GenerateContentParams): OAuthRequestBody => { - const core: OAuthRequestBody = { - contents: [{ parts: [{ text: params.prompt }] }], - generationConfig: { thinkingConfig: { thinkingLevel: effort.withDefault(ThinkingLevel.MEDIUM) } } - }; - return fromOptional(params.systemInstruction).maybe(core, (s) => ({ ...core, systemInstruction: { parts: [{ text: s }] } })); -}; - -const parseOAuthResponse = async (response: Response): Promise => { - if (!response.ok) throw new Error(`Gemini API error (${response.status}): ${await response.text()}`); - - // The REST OAuth path receives the same JSON wire shape that @google/genai maps - // to GenerateContentResponse for API-key calls. Response.json() cannot prove that - // shape to TypeScript, so this cast keeps both auth paths on one metadata mapper. - // If this breaks, compare the REST payload with the fields used below: - // response.text, candidates[].content.parts[].text, and usageMetadata token counts. - return toGeneratedContent((await response.json()) as GenerateContentResponse); -}; +const geminiHttpOptions = { + timeout: 120_000, + retryOptions: { attempts: 3 } +} as const; const generateContentWithApiKey = ( apiKey: string, @@ -81,7 +64,7 @@ const generateContentWithApiKey = ( Future.attemptP(async () => { const ai = new GoogleGenAI({ apiKey, - httpOptions: { timeout: 120_000, retryOptions: { attempts: 3 } } + httpOptions: geminiHttpOptions }); const response = await ai.models.generateContent({ model, contents: params.prompt, config: buildSDKConfig(effort, params) }); return toGeneratedContent(response); @@ -97,13 +80,15 @@ const generateContentWithOAuth = ( ): Future => getAccessToken(tokens).chain((accessToken) => Future.attemptP(async () => { - const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`; - const response = await fetch(url, { - method: "POST", - headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" }, - body: JSON.stringify(buildOAuthBody(effort, params)) + const ai = new GoogleGenAI({ + apiKey: OAUTH_API_KEY_PLACEHOLDER, + httpOptions: { + ...geminiHttpOptions, + headers: { Authorization: `Bearer ${accessToken}` } + } }); - return await parseOAuthResponse(response); + const response = await ai.models.generateContent({ model, contents: params.prompt, config: buildSDKConfig(effort, params) }); + return toGeneratedContent(response); }) .mapRej((error) => new Error(`Failed to create Gemini content: ${error instanceof Error ? error.message : String(error)}`)) .chain((content) => extractResponse({ text: fromOptional(content.text) }).map((text) => ({ ...content, text }))) From abbb727206b200cfe9fa3b6c3b32fbb3b0ade2e3 Mon Sep 17 00:00:00 2001 From: rafaeelricco Date: Sat, 16 May 2026 20:29:16 -0300 Subject: [PATCH 2/8] Fix Gemini OAuth so requests do not send x-goog-api-key. Drop the placeholder apiKey (NodeAuth always appends the key header when apiKey is set). Temporarily unset GEMINI/GOOGLE API key env vars during the OAuth SDK call so implicit env keys cannot add the header either. --- src/infra/llm/gemini.ts | 42 +++++++++++++++++++++++++++-------------- 1 file changed, 28 insertions(+), 14 deletions(-) diff --git a/src/infra/llm/gemini.ts b/src/infra/llm/gemini.ts index 3849fa1..ffa750c 100644 --- a/src/infra/llm/gemini.ts +++ b/src/infra/llm/gemini.ts @@ -11,9 +11,6 @@ import { extractResponse } from "@/domain/llm/response-parser"; import { unsupportedAuth } from "@/domain/llm/auth-error"; import { absurd } from "@/libs/types"; -/** Dummy apiKey silences SDK constructor warning; Bearer Authorization wins at request time. */ -const OAUTH_API_KEY_PLACEHOLDER = "oauth-bearer-placeholder"; - type GeminiConfig = Extract; type GeminiAuthCredentials = { readonly method: "api_key"; readonly apiKey: string } | { readonly method: "google_oauth"; readonly tokens: OAuthTokens }; @@ -55,6 +52,22 @@ const geminiHttpOptions = { retryOptions: { attempts: 3 } } as const; +/** Gemini OAuth must not send `x-goog-api-key`; `GoogleGenAI` reads `GEMINI_API_KEY` / `GOOGLE_API_KEY` from the environment as `apiKey`, which would add that header after `Authorization`. */ +const withEnvWithoutGeminiApiKeys = async (run: () => Promise): Promise => { + const savedGoogle = process.env["GOOGLE_API_KEY"]; + const savedGemini = process.env["GEMINI_API_KEY"]; + try { + delete process.env["GOOGLE_API_KEY"]; + delete process.env["GEMINI_API_KEY"]; + return await run(); + } finally { + if (savedGoogle === undefined) delete process.env["GOOGLE_API_KEY"]; + else process.env["GOOGLE_API_KEY"] = savedGoogle; + if (savedGemini === undefined) delete process.env["GEMINI_API_KEY"]; + else process.env["GEMINI_API_KEY"] = savedGemini; + } +}; + const generateContentWithApiKey = ( apiKey: string, model: string, @@ -79,17 +92,18 @@ const generateContentWithOAuth = ( params: GenerateContentParams ): Future => getAccessToken(tokens).chain((accessToken) => - Future.attemptP(async () => { - const ai = new GoogleGenAI({ - apiKey: OAUTH_API_KEY_PLACEHOLDER, - httpOptions: { - ...geminiHttpOptions, - headers: { Authorization: `Bearer ${accessToken}` } - } - }); - const response = await ai.models.generateContent({ model, contents: params.prompt, config: buildSDKConfig(effort, params) }); - return toGeneratedContent(response); - }) + Future.attemptP(async () => + withEnvWithoutGeminiApiKeys(async () => { + const ai = new GoogleGenAI({ + httpOptions: { + ...geminiHttpOptions, + headers: { Authorization: `Bearer ${accessToken}` } + } + }); + const response = await ai.models.generateContent({ model, contents: params.prompt, config: buildSDKConfig(effort, params) }); + return toGeneratedContent(response); + }) + ) .mapRej((error) => new Error(`Failed to create Gemini content: ${error instanceof Error ? error.message : String(error)}`)) .chain((content) => extractResponse({ text: fromOptional(content.text) }).map((text) => ({ ...content, text }))) ); From ad6340c823c13f9d4fa5b096efa1e64e58be70ec Mon Sep 17 00:00:00 2001 From: rafaeelricco Date: Sat, 16 May 2026 20:35:04 -0300 Subject: [PATCH 3/8] Bump version to 0.2.9 Follows the Gemini OAuth SDK follow-up fix. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f1ce9b0..31bee60 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rafaeelricco/commit-tools", - "version": "0.2.8", + "version": "0.2.9", "type": "module", "packageManager": "pnpm@10.33.0", "bin": { From bc2732917b7cfba2374b24093a686b5e25ff43fe Mon Sep 17 00:00:00 2001 From: rafaeelricco Date: Sat, 16 May 2026 20:39:37 -0300 Subject: [PATCH 4/8] Show package version in doctor output. Add a CLI Version row from package.json and build rows with concat. --- src/cli/doctor.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index 6b853a3..7e8bd41 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -10,6 +10,7 @@ import { Just, type Maybe } from "@/libs/maybe"; import { absurd } from "@/libs/types"; import { access } from "node:fs/promises"; import { environment } from "@/infra/env"; +import { name as packageName, version as packageVersion } from "@/package.json"; import color from "picocolors"; import Table from "cli-table3"; @@ -28,7 +29,12 @@ class Doctor { return this.checkOAuthCredentials().chain((oauthRow) => this.checkConfig().chain((configRows) => this.checkGitContext().map((gitRows) => { - const rows: CheckRow[] = [this.checkRuntime(), this.checkPlatform(), oauthRow, ...configRows, ...gitRows]; + const rows: CheckRow[] = [ + ["CLI Version", color.green(packageVersion), packageName], + this.checkRuntime(), + this.checkPlatform(), + oauthRow + ].concat(configRows, gitRows); this.renderTable(rows, performance.now() - start); }) ) From 42967a8a7e31157481234d21f4241ba44d5e75c9 Mon Sep 17 00:00:00 2001 From: rafaeelricco Date: Sat, 16 May 2026 20:48:14 -0300 Subject: [PATCH 5/8] Fix doctor CheckRow typing for concat with config/git rows. Annotate prefix rows as CheckRow[] then concat so tsc accepts renderTable input. --- src/cli/doctor.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index 7e8bd41..e216df7 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -34,8 +34,8 @@ class Doctor { this.checkRuntime(), this.checkPlatform(), oauthRow - ].concat(configRows, gitRows); - this.renderTable(rows, performance.now() - start); + ]; + this.renderTable(rows.concat(configRows, gitRows), performance.now() - start); }) ) ); From 9c23a0ab9d0c1556139ec7be2d41c1f3e882e198 Mon Sep 17 00:00:00 2001 From: rafaeelricco Date: Sat, 16 May 2026 20:50:32 -0300 Subject: [PATCH 6/8] Inline CheckRow array into a single line in doctor --- src/cli/doctor.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index e216df7..befb58e 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -29,12 +29,7 @@ class Doctor { return this.checkOAuthCredentials().chain((oauthRow) => this.checkConfig().chain((configRows) => this.checkGitContext().map((gitRows) => { - const rows: CheckRow[] = [ - ["CLI Version", color.green(packageVersion), packageName], - this.checkRuntime(), - this.checkPlatform(), - oauthRow - ]; + const rows: CheckRow[] = [["CLI Version", color.green(packageVersion), packageName], this.checkRuntime(), this.checkPlatform(), oauthRow]; this.renderTable(rows.concat(configRows, gitRows), performance.now() - start); }) ) From aef11370337da14bfba0d453b74cb66eca7940e1 Mon Sep 17 00:00:00 2001 From: rafaeelricco Date: Sat, 16 May 2026 21:14:44 -0300 Subject: [PATCH 7/8] Improve Google OAuth setup flow and terminal feedback. Bracket OAuth and model fetch under bracketStatus with phased status messages, fix the OAuth race timeout, resolve email via getTokenInfo, drop forced consent on re-auth, and soften the browser success copy. --- src/cli/setup.ts | 46 ++++++++++++++++++++++++++----------- src/infra/auth/google.ts | 38 ++++++++++++++++++++++++++---- src/infra/auth/templates.ts | 3 +-- src/infra/ui/spinner.ts | 35 ++++++++++++++++++++++++++-- 4 files changed, 99 insertions(+), 23 deletions(-) diff --git a/src/cli/setup.ts b/src/cli/setup.ts index d5229e4..c67d974 100644 --- a/src/cli/setup.ts +++ b/src/cli/setup.ts @@ -5,12 +5,12 @@ import * as p from "@clack/prompts"; import { Future } from "@/libs/future"; import { type Option } from "@clack/prompts"; import { saveConfig } from "@/infra/storage/config"; -import { CommitConvention, type Config, type ProviderConfig } from "@/domain/config/config"; -import { performOAuthFlow, validateOAuthTokens } from "@/infra/auth/google"; +import { CommitConvention, type Config, type Model, type ProviderConfig } from "@/domain/config/config"; +import { performOAuthFlow, type GoogleOAuthPhase } from "@/infra/auth/google"; import { performOpenAIOAuthFlow, validateOpenAITokens } from "@/infra/auth/openai"; import { validateAnthropicApiKey, validateAnthropicSetupToken } from "@/infra/auth/anthropic"; import { Just, Nothing } from "@/libs/maybe"; -import { loading } from "@/infra/ui/spinner"; +import { bracketStatus, loading } from "@/infra/ui/spinner"; import { fetchModels } from "@/domain/commit/models"; import { selectModelInteractively } from "@/infra/ui/model-picker"; import { selectEffortForProvider, seedProviderConfig } from "@/domain/llm/effort"; @@ -104,16 +104,29 @@ class Setup { } private setupOAuth(): Future { - p.log.info("Opening browser for Google sign-in..."); + return bracketStatus("Opening browser for Google sign-in...", "Models fetched!", (status) => { + const onPhase = (phase: GoogleOAuthPhase, detail?: string) => { + switch (phase) { + case "opening_browser": + status.message("Opening browser for Google sign-in..."); + break; + case "waiting_browser": + status.message("Waiting for you to complete sign-in in the browser"); + break; + case "exchanging_code": + status.message("Exchanging authorization code..."); + break; + case "signed_in": + status.message(detail ? `Signed in as ${detail}` : "Signed in"); + break; + } + }; - return performOAuthFlow() - .chain((tokens) => - loading("Validating OAuth tokens...", "OAuth tokens validated!", validateOAuthTokens(tokens)).map(() => ({ - type: "google_oauth" as const, - content: tokens - })) - ) - .chain((authMethod) => this.finalizeSetup(authMethod)); + return performOAuthFlow({ onPhase }).chain((tokens) => { + const authMethod = { type: "google_oauth" as const, content: tokens }; + return fetchModels(this.preferences.provider, authMethod).map((models) => ({ authMethod, models })); + }); + }).chain(({ authMethod, models }) => this.finalizeAfterModels(authMethod, models)); } private setupOpenAIOAuth(): Future { @@ -152,8 +165,13 @@ class Setup { } private finalizeSetup(authMethod: ProviderConfig["auth_method"]): Future { - return loading("Fetching available models...", "Models fetched!", fetchModels(this.preferences.provider, authMethod)) - .chain((models) => selectModelInteractively(models)) + return loading("Fetching available models...", "Models fetched!", fetchModels(this.preferences.provider, authMethod)).chain((models) => + this.finalizeAfterModels(authMethod, models) + ); + } + + private finalizeAfterModels(authMethod: ProviderConfig["auth_method"], models: Model[]): Future { + return selectModelInteractively(models) .chain((modelId) => selectEffortForProvider(seedProviderConfig(this.preferences.provider, modelId, authMethod))) .chain((ai) => saveConfig(this.buildConfig(ai))) .map(() => { diff --git a/src/infra/auth/google.ts b/src/infra/auth/google.ts index 03beb4a..5701c45 100644 --- a/src/infra/auth/google.ts +++ b/src/infra/auth/google.ts @@ -20,6 +20,12 @@ const PORT_RANGE_END = 8410; const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000; +export type GoogleOAuthPhase = "opening_browser" | "waiting_browser" | "exchanging_code" | "signed_in"; + +export type GoogleOAuthFlowHooks = { + readonly onPhase?: (phase: GoogleOAuthPhase, detail?: string) => void; +}; + type CallbackServer = { readonly server: Server; readonly port: number; @@ -148,7 +154,13 @@ const exchangeCodeForTokens = (client: OAuth2Client, code: string, codeVerifier: }; }).mapRej((e) => new Error(`Token exchange failed: ${e}`)); -const performOAuthFlow = (): Future => +const getUserEmailFromTokenInfo = (client: OAuth2Client, accessToken: string): Future => + Future.attemptP(async (): Promise => { + const info = await client.getTokenInfo(accessToken); + return info.email; + }).chainRej(() => Future.resolve(undefined)); + +const performOAuthFlow = (hooks?: GoogleOAuthFlowHooks): Future => findAvailablePort().chain((port) => { const redirectUri = `http://127.0.0.1:${port}/callback`; const codeVerifier = generateCodeVerifier(); @@ -163,7 +175,6 @@ const performOAuthFlow = (): Future => const authUrl = client.generateAuthUrl({ access_type: "offline", - prompt: "consent", scope: SCOPES, code_challenge: codeChallenge, code_challenge_method: CodeChallengeMethod.S256, @@ -171,13 +182,30 @@ const performOAuthFlow = (): Future => }); return Future.bracket(startCallbackServer(port, state), stopCallbackServer, (cs) => { - const waitForCode: Future = openBrowser(authUrl).chain(() => Future.attemptP(() => cs.codePromise)); + hooks?.onPhase?.("opening_browser"); + + const waitForCode: Future = openBrowser(authUrl).chain(() => { + hooks?.onPhase?.("waiting_browser"); + return Future.attemptP(async () => { + const code = await cs.codePromise; + hooks?.onPhase?.("exchanging_code"); + return code; + }); + }); const timeout: Future = Future.create((reject) => { - return () => clearTimeout(setTimeout(() => reject(new Error("OAuth flow timed out after 5 minutes. Please try again.")), OAUTH_TIMEOUT_MS)); + const timer = setTimeout(() => reject(new Error("OAuth flow timed out after 5 minutes. Please try again.")), OAUTH_TIMEOUT_MS); + return () => clearTimeout(timer); }); - return Future.race(waitForCode, timeout).chain((code) => exchangeCodeForTokens(client, code, codeVerifier, redirectUri)); + return Future.race(waitForCode, timeout) + .chain((code) => exchangeCodeForTokens(client, code, codeVerifier, redirectUri)) + .chain((tokens) => + getUserEmailFromTokenInfo(client, tokens.access_token).map((email) => { + hooks?.onPhase?.("signed_in", email); + return tokens; + }) + ); }); }); diff --git a/src/infra/auth/templates.ts b/src/infra/auth/templates.ts index a339385..876cef1 100644 --- a/src/infra/auth/templates.ts +++ b/src/infra/auth/templates.ts @@ -103,8 +103,7 @@ const SUCCESS_HTML = successHtml(); const GOOGLE_OAUTH_NOTICE = `
- Google OAuth: It can take 1-2 minutes after this page appears for the terminal to - continue. Keep the terminal open while it finishes. + Next step: You can close this tab — the terminal will continue automatically.
`; const GOOGLE_SUCCESS_HTML = successHtml(GOOGLE_OAUTH_NOTICE); diff --git a/src/infra/ui/spinner.ts b/src/infra/ui/spinner.ts index 49b0560..98cec0c 100644 --- a/src/infra/ui/spinner.ts +++ b/src/infra/ui/spinner.ts @@ -1,9 +1,15 @@ -export { loading }; - import * as p from "@clack/prompts"; import { Future } from "@/libs/future"; +/** Sink for user-visible status text while a bracketed `Future` runs. */ +export type StatusMessageSink = { + readonly message: (msg: string) => void; +}; + +/** Brackets a `Future` with a live status sink and clack spinner teardown. */ +export type BracketStatus = (startLabel: string, stopLabel: string, body: (status: StatusMessageSink) => Future) => Future; + const loading = (label: string, stopLabel: string, f: Future): Future => { const s = p.spinner(); s.start(label); @@ -17,3 +23,28 @@ const loading = (label: string, stopLabel: string, f: Future): Futu return e; }); }; + +/** + * Brackets async work: starts a clack spinner with `startLabel`, passes a `status` + * sink for `message` updates, then stops with `stopLabel` on success or `"Failed."` on rejection. + */ +export const bracketStatus: BracketStatus = ( + startLabel: string, + stopLabel: string, + body: (status: StatusMessageSink) => Future +): Future => { + const s = p.spinner(); + s.start(startLabel); + const status: StatusMessageSink = { message: (msg) => s.message(msg) }; + return body(status) + .map((v) => { + s.stop(stopLabel); + return v; + }) + .mapRej((e) => { + s.stop("Failed."); + return e; + }); +}; + +export { loading }; From 9256966fed243e35d12732a72bb179979c09c5ee Mon Sep 17 00:00:00 2001 From: rafaeelricco Date: Sat, 16 May 2026 21:15:45 -0300 Subject: [PATCH 8/8] Consolidate spinner exports for bracketStatus and loading. --- src/infra/ui/spinner.ts | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/src/infra/ui/spinner.ts b/src/infra/ui/spinner.ts index 98cec0c..96c08ff 100644 --- a/src/infra/ui/spinner.ts +++ b/src/infra/ui/spinner.ts @@ -1,14 +1,14 @@ +export { loading, bracketStatus, type StatusMessageSink, type BracketStatus }; + import * as p from "@clack/prompts"; import { Future } from "@/libs/future"; -/** Sink for user-visible status text while a bracketed `Future` runs. */ -export type StatusMessageSink = { +type StatusMessageSink = { readonly message: (msg: string) => void; }; -/** Brackets a `Future` with a live status sink and clack spinner teardown. */ -export type BracketStatus = (startLabel: string, stopLabel: string, body: (status: StatusMessageSink) => Future) => Future; +type BracketStatus = (startLabel: string, stopLabel: string, body: (status: StatusMessageSink) => Future) => Future; const loading = (label: string, stopLabel: string, f: Future): Future => { const s = p.spinner(); @@ -24,11 +24,7 @@ const loading = (label: string, stopLabel: string, f: Future): Futu }); }; -/** - * Brackets async work: starts a clack spinner with `startLabel`, passes a `status` - * sink for `message` updates, then stops with `stopLabel` on success or `"Failed."` on rejection. - */ -export const bracketStatus: BracketStatus = ( +const bracketStatus: BracketStatus = ( startLabel: string, stopLabel: string, body: (status: StatusMessageSink) => Future @@ -46,5 +42,3 @@ export const bracketStatus: BracketStatus = ( return e; }); }; - -export { loading };