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": { diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index 6b853a3..befb58e 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,8 +29,8 @@ 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]; - this.renderTable(rows, performance.now() - start); + const rows: CheckRow[] = [["CLI Version", color.green(packageVersion), packageName], this.checkRuntime(), this.checkPlatform(), oauthRow]; + this.renderTable(rows.concat(configRows, gitRows), performance.now() - start); }) ) ); 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/llm/gemini.ts b/src/infra/llm/gemini.ts index 177cef4..ffa750c 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"; @@ -14,12 +14,6 @@ import { absurd } from "@/libs/types"; 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,23 +47,25 @@ 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()}`); +const geminiHttpOptions = { + timeout: 120_000, + retryOptions: { attempts: 3 } +} as const; - // 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); +/** 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 = ( @@ -81,7 +77,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); @@ -96,15 +92,18 @@ const generateContentWithOAuth = ( params: GenerateContentParams ): 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)) - }); - return await parseOAuthResponse(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 }))) ); diff --git a/src/infra/ui/spinner.ts b/src/infra/ui/spinner.ts index 49b0560..96c08ff 100644 --- a/src/infra/ui/spinner.ts +++ b/src/infra/ui/spinner.ts @@ -1,9 +1,15 @@ -export { loading }; +export { loading, bracketStatus, type StatusMessageSink, type BracketStatus }; import * as p from "@clack/prompts"; import { Future } from "@/libs/future"; +type StatusMessageSink = { + readonly message: (msg: string) => void; +}; + +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,22 @@ const loading = (label: string, stopLabel: string, f: Future): Futu return e; }); }; + +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; + }); +};