Skip to content
37 changes: 37 additions & 0 deletions packages/app/src/docker-git/cli/parser-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ type AuthOptions = {
readonly envGlobalPath: string
readonly codexAuthPath: string
readonly claudeAuthPath: string
readonly geminiAuthPath: string
readonly label: string | null
readonly token: string | null
readonly scopes: string | null
Expand All @@ -34,11 +35,13 @@ const normalizeLabel = (value: string | undefined): string | null => {
const defaultEnvGlobalPath = ".docker-git/.orch/env/global.env"
const defaultCodexAuthPath = ".docker-git/.orch/auth/codex"
const defaultClaudeAuthPath = ".docker-git/.orch/auth/claude"
const defaultGeminiAuthPath = ".docker-git/.orch/auth/gemini"

const resolveAuthOptions = (raw: RawOptions): AuthOptions => ({
envGlobalPath: raw.envGlobalPath ?? defaultEnvGlobalPath,
codexAuthPath: raw.codexAuthPath ?? defaultCodexAuthPath,
claudeAuthPath: defaultClaudeAuthPath,
geminiAuthPath: defaultGeminiAuthPath,
label: normalizeLabel(raw.label),
token: normalizeLabel(raw.token),
scopes: normalizeLabel(raw.scopes),
Expand Down Expand Up @@ -117,6 +120,39 @@ const buildClaudeCommand = (action: string, options: AuthOptions): Either.Either
Match.orElse(() => Either.left(invalidArgument("auth action", `unknown action '${action}'`)))
)

// CHANGE: add Gemini CLI auth command parsing
// WHY: enable Gemini CLI authentication management via docker-git CLI
// QUOTE(ТЗ): "Добавь поддержку gemini CLI"
// REF: issue-146
// SOURCE: https://geminicli.com/docs/get-started/authentication/
// FORMAT THEOREM: forall action: buildGeminiCommand(action, opts) = AuthCommand | ParseError
// PURITY: CORE
// EFFECT: n/a
// INVARIANT: geminiAuthPath is always set from defaults or options
// COMPLEXITY: O(1)
const buildGeminiCommand = (action: string, options: AuthOptions): Either.Either<AuthCommand, ParseError> =>
Match.value(action).pipe(
Match.when("login", () =>
Either.right<AuthCommand>({
_tag: "AuthGeminiLogin",
label: options.label,
geminiAuthPath: options.geminiAuthPath
})),
Match.when("status", () =>
Either.right<AuthCommand>({
_tag: "AuthGeminiStatus",
label: options.label,
geminiAuthPath: options.geminiAuthPath
})),
Match.when("logout", () =>
Either.right<AuthCommand>({
_tag: "AuthGeminiLogout",
label: options.label,
geminiAuthPath: options.geminiAuthPath
})),
Match.orElse(() => Either.left(invalidArgument("auth action", `unknown action '${action}'`)))
)

const buildAuthCommand = (
provider: string,
action: string,
Expand All @@ -128,6 +164,7 @@ const buildAuthCommand = (
Match.when("codex", () => buildCodexCommand(action, options)),
Match.when("claude", () => buildClaudeCommand(action, options)),
Match.when("cc", () => buildClaudeCommand(action, options)),
Match.when("gemini", () => buildGeminiCommand(action, options)),
Match.orElse(() => Either.left(invalidArgument("auth provider", `unknown provider '${provider}'`)))
)

Expand Down
39 changes: 31 additions & 8 deletions packages/app/src/docker-git/menu-auth-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { type AppError } from "@effect-template/lib/usecases/errors"
import { defaultProjectsRoot } from "@effect-template/lib/usecases/menu-helpers"
import { autoSyncState } from "@effect-template/lib/usecases/state-repo"

import { countAuthAccountDirectories } from "./menu-auth-helpers.js"
import { countAuthAccountEntries } from "./menu-auth-snapshot-builder.js"
import { buildLabeledEnvKey, countKeyEntries, normalizeLabel } from "./menu-labeled-env.js"
import type { AuthFlow, AuthSnapshot, MenuEnv } from "./menu-types.js"

Expand All @@ -21,7 +21,7 @@ type AuthMenuItem = {
export type AuthEnvFlow = Extract<AuthFlow, "GithubRemove" | "GitSet" | "GitRemove">

export type AuthPromptStep = {
readonly key: "label" | "token" | "user"
readonly key: "label" | "token" | "user" | "apiKey"
readonly label: string
readonly required: boolean
readonly secret: boolean
Expand All @@ -34,6 +34,9 @@ const authMenuItems: ReadonlyArray<AuthMenuItem> = [
{ action: "GitRemove", label: "Git: remove credentials" },
{ action: "ClaudeOauth", label: "Claude Code: login via OAuth (web)" },
{ action: "ClaudeLogout", label: "Claude Code: logout (clear cache)" },
{ action: "GeminiOauth", label: "Gemini CLI: login via OAuth (Google account)" },
{ action: "GeminiApiKey", label: "Gemini CLI: set API key" },
{ action: "GeminiLogout", label: "Gemini CLI: logout (clear credentials)" },
{ action: "Refresh", label: "Refresh snapshot" },
{ action: "Back", label: "Back to main menu" }
]
Expand All @@ -58,6 +61,16 @@ const flowSteps: Readonly<Record<AuthFlow, ReadonlyArray<AuthPromptStep>>> = {
],
ClaudeLogout: [
{ key: "label", label: "Label to logout (empty = default)", required: false, secret: false }
],
GeminiOauth: [
{ key: "label", label: "Label (empty = default)", required: false, secret: false }
],
GeminiApiKey: [
{ key: "label", label: "Label (empty = default)", required: false, secret: false },
{ key: "apiKey", label: "Gemini API key (from ai.google.dev)", required: true, secret: true }
],
GeminiLogout: [
{ key: "label", label: "Label to logout (empty = default)", required: false, secret: false }
]
}

Expand All @@ -69,6 +82,9 @@ const flowTitle = (flow: AuthFlow): string =>
Match.when("GitRemove", () => "Git remove"),
Match.when("ClaudeOauth", () => "Claude Code OAuth"),
Match.when("ClaudeLogout", () => "Claude Code logout"),
Match.when("GeminiOauth", () => "Gemini CLI OAuth"),
Match.when("GeminiApiKey", () => "Gemini CLI API key"),
Match.when("GeminiLogout", () => "Gemini CLI logout"),
Match.exhaustive
)

Expand All @@ -80,17 +96,22 @@ export const successMessage = (flow: AuthFlow, label: string): string =>
Match.when("GitRemove", () => `Removed Git credentials (${label}).`),
Match.when("ClaudeOauth", () => `Saved Claude Code login (${label}).`),
Match.when("ClaudeLogout", () => `Logged out Claude Code (${label}).`),
Match.when("GeminiOauth", () => `Saved Gemini CLI OAuth login (${label}).`),
Match.when("GeminiApiKey", () => `Saved Gemini API key (${label}).`),
Match.when("GeminiLogout", () => `Logged out Gemini CLI (${label}).`),
Match.exhaustive
)

const buildGlobalEnvPath = (cwd: string): string => `${defaultProjectsRoot(cwd)}/.orch/env/global.env`
const buildClaudeAuthPath = (cwd: string): string => `${defaultProjectsRoot(cwd)}/.orch/auth/claude`
const buildGeminiAuthPath = (cwd: string): string => `${defaultProjectsRoot(cwd)}/.orch/auth/gemini`

type AuthEnvText = {
readonly fs: FileSystem.FileSystem
readonly path: Path.Path
readonly globalEnvPath: string
readonly claudeAuthPath: string
readonly geminiAuthPath: string
readonly envText: string
}

Expand All @@ -102,27 +123,29 @@ const loadAuthEnvText = (
const path = yield* _(Path.Path)
const globalEnvPath = buildGlobalEnvPath(cwd)
const claudeAuthPath = buildClaudeAuthPath(cwd)
const geminiAuthPath = buildGeminiAuthPath(cwd)
yield* _(ensureEnvFile(fs, path, globalEnvPath))
const envText = yield* _(readEnvText(fs, globalEnvPath))
return { fs, path, globalEnvPath, claudeAuthPath, envText }
return { fs, path, globalEnvPath, claudeAuthPath, geminiAuthPath, envText }
})

export const readAuthSnapshot = (
cwd: string
): Effect.Effect<AuthSnapshot, AppError, MenuEnv> =>
pipe(
loadAuthEnvText(cwd),
Effect.flatMap(({ claudeAuthPath, envText, fs, globalEnvPath, path }) =>
pipe(
countAuthAccountDirectories(fs, path, claudeAuthPath),
Effect.map((claudeAuthEntries) => ({
Effect.flatMap(({ claudeAuthPath, envText, fs, geminiAuthPath, globalEnvPath, path }) =>
countAuthAccountEntries(fs, path, claudeAuthPath, geminiAuthPath).pipe(
Effect.map(({ claudeAuthEntries, geminiAuthEntries }) => ({
globalEnvPath,
claudeAuthPath,
geminiAuthPath,
totalEntries: parseEnvEntries(envText).filter((entry) => entry.value.trim().length > 0).length,
githubTokenEntries: countKeyEntries(envText, "GITHUB_TOKEN"),
gitTokenEntries: countKeyEntries(envText, "GIT_AUTH_TOKEN"),
gitUserEntries: countKeyEntries(envText, "GIT_AUTH_USER"),
claudeAuthEntries
claudeAuthEntries,
geminiAuthEntries
}))
)
)
Expand Down
114 changes: 114 additions & 0 deletions packages/app/src/docker-git/menu-auth-effects.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { Effect, Match, pipe } from "effect"

import {
authClaudeLogin,
authClaudeLogout,
authGeminiLogin,
authGeminiLoginOauth,
authGeminiLogout,
authGithubLogin,
claudeAuthRoot,
geminiAuthRoot
} from "@effect-template/lib/usecases/auth"
import type { AppError } from "@effect-template/lib/usecases/errors"
import { renderError } from "@effect-template/lib/usecases/errors"

import { readAuthSnapshot, successMessage, writeAuthFlow } from "./menu-auth-data.js"
import { pauseOnError, resumeSshWithSkipInputs, withSuspendedTui } from "./menu-shared.js"
import type { AuthSnapshot, MenuEnv, MenuViewContext, ViewState } from "./menu-types.js"

type AuthPromptView = Extract<ViewState, { readonly _tag: "AuthPrompt" }>

type AuthEffectContext = MenuViewContext & {
readonly runner: { readonly runEffect: (effect: Effect.Effect<void, AppError, MenuEnv>) => void }
readonly setSshActive: (active: boolean) => void
readonly setSkipInputs: (update: (value: number) => number) => void
readonly cwd: string
}

const resolveLabelOption = (values: Readonly<Record<string, string>>): string | null => {
const labelValue = (values["label"] ?? "").trim()
return labelValue.length > 0 ? labelValue : null
}

const resolveGithubOauthEffect = (labelOption: string | null, globalEnvPath: string) =>
authGithubLogin({
_tag: "AuthGithubLogin",
label: labelOption,
token: null,
scopes: null,
envGlobalPath: globalEnvPath
})

const resolveClaudeOauthEffect = (labelOption: string | null) =>
authClaudeLogin({ _tag: "AuthClaudeLogin", label: labelOption, claudeAuthPath: claudeAuthRoot })

const resolveClaudeLogoutEffect = (labelOption: string | null) =>
authClaudeLogout({ _tag: "AuthClaudeLogout", label: labelOption, claudeAuthPath: claudeAuthRoot })

const resolveGeminiOauthEffect = (labelOption: string | null) =>
authGeminiLoginOauth({ _tag: "AuthGeminiLogin", label: labelOption, geminiAuthPath: geminiAuthRoot })

const resolveGeminiApiKeyEffect = (labelOption: string | null, apiKey: string) =>
authGeminiLogin({ _tag: "AuthGeminiLogin", label: labelOption, geminiAuthPath: geminiAuthRoot }, apiKey)

const resolveGeminiLogoutEffect = (labelOption: string | null) =>
authGeminiLogout({ _tag: "AuthGeminiLogout", label: labelOption, geminiAuthPath: geminiAuthRoot })

export const resolveAuthPromptEffect = (
view: AuthPromptView,
cwd: string,
values: Readonly<Record<string, string>>
): Effect.Effect<void, AppError, MenuEnv> => {
const labelOption = resolveLabelOption(values)
return Match.value(view.flow).pipe(
Match.when("GithubOauth", () => resolveGithubOauthEffect(labelOption, view.snapshot.globalEnvPath)),
Match.when("ClaudeOauth", () => resolveClaudeOauthEffect(labelOption)),
Match.when("ClaudeLogout", () => resolveClaudeLogoutEffect(labelOption)),
Match.when("GeminiOauth", () => resolveGeminiOauthEffect(labelOption)),
Match.when("GeminiApiKey", () => resolveGeminiApiKeyEffect(labelOption, (values["apiKey"] ?? "").trim())),
Match.when("GeminiLogout", () => resolveGeminiLogoutEffect(labelOption)),
Match.when("GithubRemove", (flow) => writeAuthFlow(cwd, flow, values)),
Match.when("GitSet", (flow) => writeAuthFlow(cwd, flow, values)),
Match.when("GitRemove", (flow) => writeAuthFlow(cwd, flow, values)),
Match.exhaustive
)
}

export const startAuthMenuWithSnapshot = (
snapshot: AuthSnapshot,
context: Pick<MenuViewContext, "setView" | "setMessage">
): void => {
context.setView({ _tag: "AuthMenu", selected: 0, snapshot })
context.setMessage(null)
}

export const runAuthPromptEffect = (
effect: Effect.Effect<void, AppError, MenuEnv>,
view: AuthPromptView,
label: string,
context: AuthEffectContext,
options: { readonly suspendTui: boolean }
): void => {
const withOptionalSuspension = options.suspendTui
? withSuspendedTui(effect, {
onError: pauseOnError(renderError),
onResume: resumeSshWithSkipInputs(context)
})
: effect

context.setSshActive(options.suspendTui)
context.runner.runEffect(
pipe(
withOptionalSuspension,
Effect.zipRight(readAuthSnapshot(context.cwd)),
Effect.tap((snapshot) =>
Effect.sync(() => {
startAuthMenuWithSnapshot(snapshot, context)
context.setMessage(successMessage(view.flow, label))
})
),
Effect.asVoid
)
)
}
24 changes: 24 additions & 0 deletions packages/app/src/docker-git/menu-auth-snapshot-builder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type * as FileSystem from "@effect/platform/FileSystem"
import type * as Path from "@effect/platform/Path"
import { Effect, pipe } from "effect"

import type { AppError } from "@effect-template/lib/usecases/errors"
import { countAuthAccountDirectories } from "./menu-auth-helpers.js"

export type AuthAccountCounts = {
readonly claudeAuthEntries: number
readonly geminiAuthEntries: number
}

export const countAuthAccountEntries = (
fs: FileSystem.FileSystem,
path: Path.Path,
claudeAuthPath: string,
geminiAuthPath: string
): Effect.Effect<AuthAccountCounts, AppError> =>
pipe(
Effect.all({
claudeAuthEntries: countAuthAccountDirectories(fs, path, claudeAuthPath),
geminiAuthEntries: countAuthAccountDirectories(fs, path, geminiAuthPath)
})
)
Loading
Loading