Skip to content
Merged
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@rafaeelricco/commit-tools",
"version": "0.2.8",
"version": "0.2.9",
"type": "module",
"packageManager": "pnpm@10.33.0",
"bin": {
Expand Down
5 changes: 3 additions & 2 deletions src/cli/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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);
})
)
);
Expand Down
46 changes: 32 additions & 14 deletions src/cli/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -104,16 +104,29 @@ class Setup {
}

private setupOAuth(): Future<Error, void> {
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<Error, void> {
Expand Down Expand Up @@ -152,8 +165,13 @@ class Setup {
}

private finalizeSetup(authMethod: ProviderConfig["auth_method"]): Future<Error, void> {
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<Error, void> {
return selectModelInteractively(models)
.chain((modelId) => selectEffortForProvider(seedProviderConfig(this.preferences.provider, modelId, authMethod)))
.chain((ai) => saveConfig(this.buildConfig(ai)))
.map(() => {
Expand Down
38 changes: 33 additions & 5 deletions src/infra/auth/google.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -148,7 +154,13 @@ const exchangeCodeForTokens = (client: OAuth2Client, code: string, codeVerifier:
};
}).mapRej((e) => new Error(`Token exchange failed: ${e}`));

const performOAuthFlow = (): Future<Error, OAuthTokens> =>
const getUserEmailFromTokenInfo = (client: OAuth2Client, accessToken: string): Future<Error, string | undefined> =>
Future.attemptP(async (): Promise<string | undefined> => {
const info = await client.getTokenInfo(accessToken);
return info.email;
}).chainRej(() => Future.resolve<Error, string | undefined>(undefined));

const performOAuthFlow = (hooks?: GoogleOAuthFlowHooks): Future<Error, OAuthTokens> =>
findAvailablePort().chain((port) => {
const redirectUri = `http://127.0.0.1:${port}/callback`;
const codeVerifier = generateCodeVerifier();
Expand All @@ -163,21 +175,37 @@ const performOAuthFlow = (): Future<Error, OAuthTokens> =>

const authUrl = client.generateAuthUrl({
access_type: "offline",
prompt: "consent",
scope: SCOPES,
code_challenge: codeChallenge,
code_challenge_method: CodeChallengeMethod.S256,
state
});

return Future.bracket<Error, CallbackServer, OAuthTokens, void>(startCallbackServer(port, state), stopCallbackServer, (cs) => {
const waitForCode: Future<Error, string> = openBrowser(authUrl).chain(() => Future.attemptP(() => cs.codePromise));
hooks?.onPhase?.("opening_browser");

const waitForCode: Future<Error, string> = 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<Error, string> = Future.create<Error, string>((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;
})
);
});
});

Expand Down
3 changes: 1 addition & 2 deletions src/infra/auth/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,7 @@ const SUCCESS_HTML = successHtml();

const GOOGLE_OAUTH_NOTICE = `
<div class="notice-badge" role="note">
<strong>Google OAuth:</strong> It can take 1-2 minutes after this page appears for the terminal to
continue. Keep the terminal open while it finishes.
<strong>Next step:</strong> You can close this tab — the terminal will continue automatically.
</div>`;

const GOOGLE_SUCCESS_HTML = successHtml(GOOGLE_OAUTH_NOTICE);
Expand Down
65 changes: 32 additions & 33 deletions src/infra/llm/gemini.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -14,12 +14,6 @@ import { absurd } from "@/libs/types";
type GeminiConfig = Extract<Config["ai"], { provider: "gemini" }>;
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<TokenUsage> =>
fromOptional(usage).map((u) => ({
input: fromOptional(u.promptTokenCount),
Expand Down Expand Up @@ -53,23 +47,25 @@ const buildSDKConfig = (effort: Maybe<GeminiEffort>, params: GenerateContentPara
return fromOptional(params.systemInstruction).maybe(core, (s) => ({ ...core, systemInstruction: s }));
};

const buildOAuthBody = (effort: Maybe<GeminiEffort>, 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<ProviderGeneratedContent> => {
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 <T>(run: () => Promise<T>): Promise<T> => {
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 = (
Expand All @@ -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);
Expand All @@ -96,15 +92,18 @@ const generateContentWithOAuth = (
params: GenerateContentParams
): Future<Error, ProviderGeneratedContent> =>
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 })))
);
Expand Down
27 changes: 26 additions & 1 deletion src/infra/ui/spinner.ts
Original file line number Diff line number Diff line change
@@ -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 = <T>(startLabel: string, stopLabel: string, body: (status: StatusMessageSink) => Future<Error, T>) => Future<Error, T>;

const loading = <T>(label: string, stopLabel: string, f: Future<Error, T>): Future<Error, T> => {
const s = p.spinner();
s.start(label);
Expand All @@ -17,3 +23,22 @@ const loading = <T>(label: string, stopLabel: string, f: Future<Error, T>): Futu
return e;
});
};

const bracketStatus: BracketStatus = <T>(
startLabel: string,
stopLabel: string,
body: (status: StatusMessageSink) => Future<Error, T>
): Future<Error, T> => {
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;
});
};