diff --git a/packages/app/src/docker-git/cli/parser-auth.ts b/packages/app/src/docker-git/cli/parser-auth.ts index c046e17f..b7210e95 100644 --- a/packages/app/src/docker-git/cli/parser-auth.ts +++ b/packages/app/src/docker-git/cli/parser-auth.ts @@ -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 @@ -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), @@ -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 => + Match.value(action).pipe( + Match.when("login", () => + Either.right({ + _tag: "AuthGeminiLogin", + label: options.label, + geminiAuthPath: options.geminiAuthPath + })), + Match.when("status", () => + Either.right({ + _tag: "AuthGeminiStatus", + label: options.label, + geminiAuthPath: options.geminiAuthPath + })), + Match.when("logout", () => + Either.right({ + _tag: "AuthGeminiLogout", + label: options.label, + geminiAuthPath: options.geminiAuthPath + })), + Match.orElse(() => Either.left(invalidArgument("auth action", `unknown action '${action}'`))) + ) + const buildAuthCommand = ( provider: string, action: string, @@ -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}'`))) ) diff --git a/packages/app/src/docker-git/menu-auth-data.ts b/packages/app/src/docker-git/menu-auth-data.ts index 5cc4952e..17e436ab 100644 --- a/packages/app/src/docker-git/menu-auth-data.ts +++ b/packages/app/src/docker-git/menu-auth-data.ts @@ -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" @@ -21,7 +21,7 @@ type AuthMenuItem = { export type AuthEnvFlow = Extract export type AuthPromptStep = { - readonly key: "label" | "token" | "user" + readonly key: "label" | "token" | "user" | "apiKey" readonly label: string readonly required: boolean readonly secret: boolean @@ -34,6 +34,9 @@ const authMenuItems: ReadonlyArray = [ { 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" } ] @@ -58,6 +61,16 @@ const flowSteps: Readonly>> = { ], 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 } ] } @@ -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 ) @@ -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 } @@ -102,9 +123,10 @@ 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 = ( @@ -112,17 +134,18 @@ export const readAuthSnapshot = ( ): Effect.Effect => 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 })) ) ) diff --git a/packages/app/src/docker-git/menu-auth-effects.ts b/packages/app/src/docker-git/menu-auth-effects.ts new file mode 100644 index 00000000..81a4d826 --- /dev/null +++ b/packages/app/src/docker-git/menu-auth-effects.ts @@ -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 + +type AuthEffectContext = MenuViewContext & { + readonly runner: { readonly runEffect: (effect: Effect.Effect) => void } + readonly setSshActive: (active: boolean) => void + readonly setSkipInputs: (update: (value: number) => number) => void + readonly cwd: string +} + +const resolveLabelOption = (values: Readonly>): 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> +): Effect.Effect => { + 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 +): void => { + context.setView({ _tag: "AuthMenu", selected: 0, snapshot }) + context.setMessage(null) +} + +export const runAuthPromptEffect = ( + effect: Effect.Effect, + 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 + ) + ) +} diff --git a/packages/app/src/docker-git/menu-auth-snapshot-builder.ts b/packages/app/src/docker-git/menu-auth-snapshot-builder.ts new file mode 100644 index 00000000..e3b14fc2 --- /dev/null +++ b/packages/app/src/docker-git/menu-auth-snapshot-builder.ts @@ -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 => + pipe( + Effect.all({ + claudeAuthEntries: countAuthAccountDirectories(fs, path, claudeAuthPath), + geminiAuthEntries: countAuthAccountDirectories(fs, path, geminiAuthPath) + }) + ) diff --git a/packages/app/src/docker-git/menu-auth.ts b/packages/app/src/docker-git/menu-auth.ts index f626f085..7089e6ff 100644 --- a/packages/app/src/docker-git/menu-auth.ts +++ b/packages/app/src/docker-git/menu-auth.ts @@ -1,21 +1,18 @@ -import { Effect, Match, pipe } from "effect" +import { Effect, pipe } from "effect" -import { authClaudeLogin, authClaudeLogout, authGithubLogin, claudeAuthRoot } from "@effect-template/lib/usecases/auth" import type { AppError } from "@effect-template/lib/usecases/errors" -import { renderError } from "@effect-template/lib/usecases/errors" import { type AuthMenuAction, authMenuActionByIndex, authMenuSize, authViewSteps, - readAuthSnapshot, - successMessage, - writeAuthFlow + readAuthSnapshot } from "./menu-auth-data.js" +import { resolveAuthPromptEffect, runAuthPromptEffect, startAuthMenuWithSnapshot } from "./menu-auth-effects.js" import { nextBufferValue } from "./menu-buffer-input.js" import { handleMenuNumberInput, submitPromptStep } from "./menu-input-utils.js" -import { pauseOnError, resetToMenu, resumeSshWithSkipInputs, withSuspendedTui } from "./menu-shared.js" +import { resetToMenu } from "./menu-shared.js" import type { AuthFlow, AuthSnapshot, @@ -44,14 +41,6 @@ const defaultLabel = (value: string): string => { return trimmed.length > 0 ? trimmed : "default" } -const startAuthMenuWithSnapshot = ( - snapshot: AuthSnapshot, - context: Pick -) => { - context.setView({ _tag: "AuthMenu", selected: 0, snapshot }) - context.setMessage(null) -} - const startAuthPrompt = ( snapshot: AuthSnapshot, flow: AuthFlow, @@ -68,75 +57,6 @@ const startAuthPrompt = ( context.setMessage(null) } -const resolveLabelOption = (values: Readonly>): string | null => { - const labelValue = (values["label"] ?? "").trim() - return labelValue.length > 0 ? labelValue : null -} - -const resolveAuthPromptEffect = ( - view: AuthPromptView, - cwd: string, - values: Readonly> -): Effect.Effect => { - const labelOption = resolveLabelOption(values) - return Match.value(view.flow).pipe( - Match.when("GithubOauth", () => - authGithubLogin({ - _tag: "AuthGithubLogin", - label: labelOption, - token: null, - scopes: null, - envGlobalPath: view.snapshot.globalEnvPath - })), - Match.when("ClaudeOauth", () => - authClaudeLogin({ - _tag: "AuthClaudeLogin", - label: labelOption, - claudeAuthPath: claudeAuthRoot - })), - Match.when("ClaudeLogout", () => - authClaudeLogout({ - _tag: "AuthClaudeLogout", - label: labelOption, - claudeAuthPath: claudeAuthRoot - })), - 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 - ) -} - -const runAuthPromptEffect = ( - effect: Effect.Effect, - view: AuthPromptView, - label: string, - context: AuthInputContext, - options: { readonly suspendTui: boolean } -) => { - 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.state.cwd)), - Effect.tap((snapshot) => - Effect.sync(() => { - startAuthMenuWithSnapshot(snapshot, context) - context.setMessage(successMessage(view.flow, label)) - }) - ), - Effect.asVoid - ) - ) -} - const loadAuthMenuView = ( cwd: string, context: Pick @@ -167,10 +87,7 @@ const runAuthAction = ( startAuthPrompt(view.snapshot, action, context) } -const submitAuthPrompt = ( - view: AuthPromptView, - context: AuthInputContext -) => { +const submitAuthPrompt = (view: AuthPromptView, context: AuthInputContext) => { const steps = authViewSteps(view.flow) submitPromptStep( view, @@ -182,8 +99,9 @@ const submitAuthPrompt = ( (nextValues) => { const label = defaultLabel(nextValues["label"] ?? "") const effect = resolveAuthPromptEffect(view, context.state.cwd, nextValues) - runAuthPromptEffect(effect, view, label, context, { - suspendTui: view.flow === "GithubOauth" || view.flow === "ClaudeOauth" || view.flow === "ClaudeLogout" + runAuthPromptEffect(effect, view, label, { ...context, cwd: context.state.cwd }, { + suspendTui: view.flow === "GithubOauth" || view.flow === "ClaudeOauth" || view.flow === "ClaudeLogout" || + view.flow === "GeminiOauth" }) } ) @@ -257,6 +175,22 @@ const handleAuthMenuInput = ( handleAuthMenuNumberInput(input, view, context) } +type SetAuthPromptBufferArgs = { + readonly input: string + readonly key: MenuKeyInput + readonly view: Extract + readonly context: Pick +} + +const setAuthPromptBuffer = (args: SetAuthPromptBufferArgs) => { + const { context, input, key, view } = args + const nextBuffer = nextBufferValue(input, key, view.buffer) + if (nextBuffer === null) { + return + } + context.setView({ ...view, buffer: nextBuffer }) +} + const handleAuthPromptInput = ( input: string, key: MenuKeyInput, @@ -274,24 +208,6 @@ const handleAuthPromptInput = ( setAuthPromptBuffer({ input, key, view, context }) } -type SetAuthPromptBufferArgs = { - readonly input: string - readonly key: MenuKeyInput - readonly view: Extract - readonly context: Pick -} - -const setAuthPromptBuffer = ( - args: SetAuthPromptBufferArgs -) => { - const { context, input, key, view } = args - const nextBuffer = nextBufferValue(input, key, view.buffer) - if (nextBuffer === null) { - return - } - context.setView({ ...view, buffer: nextBuffer }) -} - export const openAuthMenu = (context: AuthContext): void => { context.setMessage("Loading auth profiles...") context.runner.runEffect(loadAuthMenuView(context.state.cwd, context)) diff --git a/packages/app/src/docker-git/menu-project-auth-claude.ts b/packages/app/src/docker-git/menu-project-auth-claude.ts index ac4020e3..c25e26c0 100644 --- a/packages/app/src/docker-git/menu-project-auth-claude.ts +++ b/packages/app/src/docker-git/menu-project-auth-claude.ts @@ -2,24 +2,13 @@ import type { PlatformError } from "@effect/platform/Error" import type * as FileSystem from "@effect/platform/FileSystem" import { Effect } from "effect" +import { hasFileAtPath } from "./menu-project-auth-helpers.js" + const oauthTokenFileName = ".oauth-token" const legacyConfigFileName = ".config.json" const credentialsFileName = ".credentials.json" const nestedCredentialsFileName = ".claude/.credentials.json" -const hasFileAtPath = ( - fs: FileSystem.FileSystem, - filePath: string -): Effect.Effect => - Effect.gen(function*(_) { - const exists = yield* _(fs.exists(filePath)) - if (!exists) { - return false - } - const info = yield* _(fs.stat(filePath)) - return info.type === "File" - }) - const hasNonEmptyOauthToken = ( fs: FileSystem.FileSystem, tokenPath: string diff --git a/packages/app/src/docker-git/menu-project-auth-data.ts b/packages/app/src/docker-git/menu-project-auth-data.ts index bce631ed..778f2eea 100644 --- a/packages/app/src/docker-git/menu-project-auth-data.ts +++ b/packages/app/src/docker-git/menu-project-auth-data.ts @@ -2,17 +2,15 @@ import * as FileSystem from "@effect/platform/FileSystem" import * as Path from "@effect/platform/Path" import { Effect, Match, pipe } from "effect" -import { AuthError } from "@effect-template/lib/shell/errors" -import { normalizeAccountLabel } from "@effect-template/lib/usecases/auth-helpers" -import { ensureEnvFile, findEnvValue, readEnvText, upsertEnvKey } from "@effect-template/lib/usecases/env-file" +import { ensureEnvFile, findEnvValue, readEnvText } from "@effect-template/lib/usecases/env-file" import type { AppError } from "@effect-template/lib/usecases/errors" import { defaultProjectsRoot } from "@effect-template/lib/usecases/menu-helpers" import type { ProjectItem } from "@effect-template/lib/usecases/projects" import { autoSyncState } from "@effect-template/lib/usecases/state-repo" -import { countAuthAccountDirectories } from "./menu-auth-helpers.js" -import { buildLabeledEnvKey, countKeyEntries, normalizeLabel } from "./menu-labeled-env.js" -import { hasClaudeAccountCredentials } from "./menu-project-auth-claude.js" +import { countAuthAccountEntries } from "./menu-auth-snapshot-builder.js" +import { countKeyEntries, normalizeLabel } from "./menu-labeled-env.js" +import { type ProjectEnvUpdateSpec, resolveProjectEnvUpdate } from "./menu-project-auth-flows.js" import type { MenuEnv, ProjectAuthFlow, ProjectAuthSnapshot } from "./menu-types.js" export type ProjectAuthMenuAction = ProjectAuthFlow | "Refresh" | "Back" @@ -36,6 +34,8 @@ const projectAuthMenuItems: ReadonlyArray = [ { action: "ProjectGitDisconnect", label: "Project: Git disconnect" }, { action: "ProjectClaudeConnect", label: "Project: Claude connect label" }, { action: "ProjectClaudeDisconnect", label: "Project: Claude disconnect" }, + { action: "ProjectGeminiConnect", label: "Project: Gemini connect label" }, + { action: "ProjectGeminiDisconnect", label: "Project: Gemini disconnect" }, { action: "Refresh", label: "Refresh snapshot" }, { action: "Back", label: "Back to main menu" } ] @@ -52,7 +52,11 @@ const flowSteps: Readonly { @@ -62,13 +66,10 @@ const resolveCanonicalLabel = (value: string): string => { const githubTokenBaseKey = "GITHUB_TOKEN" const gitTokenBaseKey = "GIT_AUTH_TOKEN" -const gitUserBaseKey = "GIT_AUTH_USER" - const projectGithubLabelKey = "GITHUB_AUTH_LABEL" const projectGitLabelKey = "GIT_AUTH_LABEL" const projectClaudeLabelKey = "CLAUDE_AUTH_LABEL" - -const defaultGitUser = "x-access-token" +const projectGeminiLabelKey = "GEMINI_AUTH_LABEL" type ProjectAuthEnvText = { readonly fs: FileSystem.FileSystem @@ -76,12 +77,14 @@ type ProjectAuthEnvText = { readonly globalEnvPath: string readonly projectEnvPath: string readonly claudeAuthPath: string + readonly geminiAuthPath: string readonly globalEnvText: string readonly projectEnvText: string } 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` const loadProjectAuthEnvText = ( project: ProjectItem @@ -91,6 +94,7 @@ const loadProjectAuthEnvText = ( const path = yield* _(Path.Path) const globalEnvPath = buildGlobalEnvPath(process.cwd()) const claudeAuthPath = buildClaudeAuthPath(process.cwd()) + const geminiAuthPath = buildGeminiAuthPath(process.cwd()) yield* _(ensureEnvFile(fs, path, globalEnvPath)) yield* _(ensureEnvFile(fs, path, project.envProjectPath)) const globalEnvText = yield* _(readEnvText(fs, globalEnvPath)) @@ -101,6 +105,7 @@ const loadProjectAuthEnvText = ( globalEnvPath, projectEnvPath: project.envProjectPath, claudeAuthPath, + geminiAuthPath, globalEnvText, projectEnvText } @@ -111,136 +116,47 @@ export const readProjectAuthSnapshot = ( ): Effect.Effect => pipe( loadProjectAuthEnvText(project), - Effect.flatMap(({ claudeAuthPath, fs, globalEnvPath, globalEnvText, path, projectEnvPath, projectEnvText }) => - pipe( - countAuthAccountDirectories(fs, path, claudeAuthPath), - Effect.map((claudeAuthEntries) => ({ + Effect.flatMap(({ + claudeAuthPath, + fs, + geminiAuthPath, + globalEnvPath, + globalEnvText, + path, + projectEnvPath, + projectEnvText + }) => + countAuthAccountEntries(fs, path, claudeAuthPath, geminiAuthPath).pipe( + Effect.map(({ claudeAuthEntries, geminiAuthEntries }) => ({ projectDir: project.projectDir, projectName: project.displayName, envGlobalPath: globalEnvPath, envProjectPath: projectEnvPath, claudeAuthPath, + geminiAuthPath, githubTokenEntries: countKeyEntries(globalEnvText, githubTokenBaseKey), gitTokenEntries: countKeyEntries(globalEnvText, gitTokenBaseKey), claudeAuthEntries, + geminiAuthEntries, activeGithubLabel: findEnvValue(projectEnvText, projectGithubLabelKey), activeGitLabel: findEnvValue(projectEnvText, projectGitLabelKey), - activeClaudeLabel: findEnvValue(projectEnvText, projectClaudeLabelKey) + activeClaudeLabel: findEnvValue(projectEnvText, projectClaudeLabelKey), + activeGeminiLabel: findEnvValue(projectEnvText, projectGeminiLabelKey) })) ) ) ) -const missingSecret = ( - provider: string, - label: string, - envPath: string -): AuthError => - new AuthError({ - message: `${provider} not connected: label '${label}' not found in ${envPath}` - }) - -type ProjectEnvUpdateSpec = { - readonly fs: FileSystem.FileSystem - readonly rawLabel: string - readonly canonicalLabel: string - readonly globalEnvPath: string - readonly globalEnvText: string - readonly projectEnvText: string - readonly claudeAuthPath: string -} - -const updateProjectGithubConnect = (spec: ProjectEnvUpdateSpec): Effect.Effect => { - const key = buildLabeledEnvKey(githubTokenBaseKey, spec.rawLabel) - const token = findEnvValue(spec.globalEnvText, key) - if (token === null) { - return Effect.fail(missingSecret("GitHub token", spec.canonicalLabel, spec.globalEnvPath)) - } - const withGitToken = upsertEnvKey(spec.projectEnvText, "GIT_AUTH_TOKEN", token) - const withGhToken = upsertEnvKey(withGitToken, "GH_TOKEN", token) - const withoutGitLabel = upsertEnvKey(withGhToken, projectGitLabelKey, "") - return Effect.succeed(upsertEnvKey(withoutGitLabel, projectGithubLabelKey, spec.canonicalLabel)) -} - -const clearProjectGitLabels = (envText: string): string => { - const withoutGhToken = upsertEnvKey(envText, "GH_TOKEN", "") - const withoutGitLabel = upsertEnvKey(withoutGhToken, projectGitLabelKey, "") - return upsertEnvKey(withoutGitLabel, projectGithubLabelKey, "") -} - -const updateProjectGithubDisconnect = (spec: ProjectEnvUpdateSpec): Effect.Effect => { - const withoutGitToken = upsertEnvKey(spec.projectEnvText, "GIT_AUTH_TOKEN", "") - return Effect.succeed(clearProjectGitLabels(withoutGitToken)) -} - -const updateProjectGitConnect = (spec: ProjectEnvUpdateSpec): Effect.Effect => { - const tokenKey = buildLabeledEnvKey(gitTokenBaseKey, spec.rawLabel) - const userKey = buildLabeledEnvKey(gitUserBaseKey, spec.rawLabel) - const token = findEnvValue(spec.globalEnvText, tokenKey) - if (token === null) { - return Effect.fail(missingSecret("Git credentials", spec.canonicalLabel, spec.globalEnvPath)) - } - const defaultUser = findEnvValue(spec.globalEnvText, gitUserBaseKey) ?? defaultGitUser - const user = findEnvValue(spec.globalEnvText, userKey) ?? defaultUser - const withToken = upsertEnvKey(spec.projectEnvText, "GIT_AUTH_TOKEN", token) - const withUser = upsertEnvKey(withToken, "GIT_AUTH_USER", user) - const withGhToken = upsertEnvKey(withUser, "GH_TOKEN", token) - const withGitLabel = upsertEnvKey(withGhToken, projectGitLabelKey, spec.canonicalLabel) - return Effect.succeed(upsertEnvKey(withGitLabel, projectGithubLabelKey, spec.canonicalLabel)) -} - -const updateProjectGitDisconnect = (spec: ProjectEnvUpdateSpec): Effect.Effect => { - const withoutToken = upsertEnvKey(spec.projectEnvText, "GIT_AUTH_TOKEN", "") - const withoutUser = upsertEnvKey(withoutToken, "GIT_AUTH_USER", "") - return Effect.succeed(clearProjectGitLabels(withoutUser)) -} - -const resolveClaudeAccountCandidates = ( - claudeAuthPath: string, - accountLabel: string -): ReadonlyArray => - accountLabel === "default" - ? [`${claudeAuthPath}/default`, claudeAuthPath] - : [`${claudeAuthPath}/${accountLabel}`] - -const updateProjectClaudeConnect = (spec: ProjectEnvUpdateSpec): Effect.Effect => { - const accountLabel = normalizeAccountLabel(spec.rawLabel, "default") - const accountCandidates = resolveClaudeAccountCandidates(spec.claudeAuthPath, accountLabel) - return Effect.gen(function*(_) { - for (const accountPath of accountCandidates) { - const exists = yield* _(spec.fs.exists(accountPath)) - if (!exists) { - continue - } - - const hasCredentials = yield* _( - hasClaudeAccountCredentials(spec.fs, accountPath), - Effect.orElseSucceed(() => false) - ) - if (hasCredentials) { - return upsertEnvKey(spec.projectEnvText, projectClaudeLabelKey, spec.canonicalLabel) - } - } - - return yield* _(Effect.fail(missingSecret("Claude Code login", spec.canonicalLabel, spec.claudeAuthPath))) - }) -} - -const updateProjectClaudeDisconnect = (spec: ProjectEnvUpdateSpec): Effect.Effect => { - return Effect.succeed(upsertEnvKey(spec.projectEnvText, projectClaudeLabelKey, "")) -} - -const resolveProjectEnvUpdate = ( - flow: ProjectAuthFlow, - spec: ProjectEnvUpdateSpec -): Effect.Effect => +const resolveSyncMessage = (flow: ProjectAuthFlow, canonicalLabel: string, displayName: string): string => Match.value(flow).pipe( - Match.when("ProjectGithubConnect", () => updateProjectGithubConnect(spec)), - Match.when("ProjectGithubDisconnect", () => updateProjectGithubDisconnect(spec)), - Match.when("ProjectGitConnect", () => updateProjectGitConnect(spec)), - Match.when("ProjectGitDisconnect", () => updateProjectGitDisconnect(spec)), - Match.when("ProjectClaudeConnect", () => updateProjectClaudeConnect(spec)), - Match.when("ProjectClaudeDisconnect", () => updateProjectClaudeDisconnect(spec)), + Match.when("ProjectGithubConnect", () => `chore(state): project auth gh ${canonicalLabel} ${displayName}`), + Match.when("ProjectGithubDisconnect", () => `chore(state): project auth gh logout ${displayName}`), + Match.when("ProjectGitConnect", () => `chore(state): project auth git ${canonicalLabel} ${displayName}`), + Match.when("ProjectGitDisconnect", () => `chore(state): project auth git logout ${displayName}`), + Match.when("ProjectClaudeConnect", () => `chore(state): project auth claude ${canonicalLabel} ${displayName}`), + Match.when("ProjectClaudeDisconnect", () => `chore(state): project auth claude logout ${displayName}`), + Match.when("ProjectGeminiConnect", () => `chore(state): project auth gemini ${canonicalLabel} ${displayName}`), + Match.when("ProjectGeminiDisconnect", () => `chore(state): project auth gemini logout ${displayName}`), Match.exhaustive ) @@ -251,42 +167,29 @@ export const writeProjectAuthFlow = ( ): Effect.Effect => pipe( loadProjectAuthEnvText(project), - Effect.flatMap(({ claudeAuthPath, fs, globalEnvPath, globalEnvText, projectEnvPath, projectEnvText }) => { - const rawLabel = values["label"] ?? "" - const canonicalLabel = resolveCanonicalLabel(rawLabel) - const spec: ProjectEnvUpdateSpec = { - fs, - rawLabel, - canonicalLabel, - globalEnvPath, - globalEnvText, - projectEnvText, - claudeAuthPath + Effect.flatMap( + ({ claudeAuthPath, fs, geminiAuthPath, globalEnvPath, globalEnvText, projectEnvPath, projectEnvText }) => { + const rawLabel = values["label"] ?? "" + const canonicalLabel = resolveCanonicalLabel(rawLabel) + const spec: ProjectEnvUpdateSpec = { + fs, + rawLabel, + canonicalLabel, + globalEnvPath, + globalEnvText, + projectEnvText, + claudeAuthPath, + geminiAuthPath + } + const nextProjectEnv = resolveProjectEnvUpdate(flow, spec) + const syncMessage = resolveSyncMessage(flow, canonicalLabel, project.displayName) + return pipe( + nextProjectEnv, + Effect.flatMap((nextText) => fs.writeFileString(projectEnvPath, nextText)), + Effect.zipRight(autoSyncState(syncMessage)) + ) } - const nextProjectEnv = resolveProjectEnvUpdate(flow, spec) - const syncMessage = Match.value(flow).pipe( - Match.when("ProjectGithubConnect", () => - `chore(state): project auth gh ${canonicalLabel} ${project.displayName}`), - Match.when("ProjectGithubDisconnect", () => - `chore(state): project auth gh logout ${project.displayName}`), - Match.when( - "ProjectGitConnect", - () => `chore(state): project auth git ${canonicalLabel} ${project.displayName}` - ), - Match.when("ProjectGitDisconnect", () => `chore(state): project auth git logout ${project.displayName}`), - Match.when( - "ProjectClaudeConnect", - () => `chore(state): project auth claude ${canonicalLabel} ${project.displayName}` - ), - Match.when("ProjectClaudeDisconnect", () => `chore(state): project auth claude logout ${project.displayName}`), - Match.exhaustive - ) - return pipe( - nextProjectEnv, - Effect.flatMap((nextText) => fs.writeFileString(projectEnvPath, nextText)), - Effect.zipRight(autoSyncState(syncMessage)) - ) - }), + ), Effect.asVoid ) diff --git a/packages/app/src/docker-git/menu-project-auth-flows.ts b/packages/app/src/docker-git/menu-project-auth-flows.ts new file mode 100644 index 00000000..2e52120c --- /dev/null +++ b/packages/app/src/docker-git/menu-project-auth-flows.ts @@ -0,0 +1,150 @@ +import type { PlatformError } from "@effect/platform/Error" +import type * as FileSystem from "@effect/platform/FileSystem" +import { Effect, Match } from "effect" + +import { AuthError } from "@effect-template/lib/shell/errors" +import { normalizeAccountLabel } from "@effect-template/lib/usecases/auth-helpers" +import { findEnvValue, upsertEnvKey } from "@effect-template/lib/usecases/env-file" +import type { AppError } from "@effect-template/lib/usecases/errors" + +import { buildLabeledEnvKey } from "./menu-labeled-env.js" +import { hasClaudeAccountCredentials } from "./menu-project-auth-claude.js" +import { hasGeminiAccountCredentials } from "./menu-project-auth-gemini.js" +import type { ProjectAuthFlow } from "./menu-types.js" + +export type ProjectEnvUpdateSpec = { + readonly fs: FileSystem.FileSystem + readonly rawLabel: string + readonly canonicalLabel: string + readonly globalEnvPath: string + readonly globalEnvText: string + readonly projectEnvText: string + readonly claudeAuthPath: string + readonly geminiAuthPath: string +} + +const githubTokenBaseKey = "GITHUB_TOKEN" +const gitTokenBaseKey = "GIT_AUTH_TOKEN" +const gitUserBaseKey = "GIT_AUTH_USER" +const projectGithubLabelKey = "GITHUB_AUTH_LABEL" +const projectGitLabelKey = "GIT_AUTH_LABEL" +const projectClaudeLabelKey = "CLAUDE_AUTH_LABEL" +const projectGeminiLabelKey = "GEMINI_AUTH_LABEL" +const defaultGitUser = "x-access-token" + +const missingSecret = (provider: string, label: string, envPath: string): AuthError => + new AuthError({ message: `${provider} not connected: label '${label}' not found in ${envPath}` }) + +const clearProjectGitLabels = (envText: string): string => { + const withoutGhToken = upsertEnvKey(envText, "GH_TOKEN", "") + const withoutGitLabel = upsertEnvKey(withoutGhToken, projectGitLabelKey, "") + return upsertEnvKey(withoutGitLabel, projectGithubLabelKey, "") +} + +const updateProjectGithubConnect = (spec: ProjectEnvUpdateSpec): Effect.Effect => { + const key = buildLabeledEnvKey(githubTokenBaseKey, spec.rawLabel) + const token = findEnvValue(spec.globalEnvText, key) + if (token === null) { + return Effect.fail(missingSecret("GitHub token", spec.canonicalLabel, spec.globalEnvPath)) + } + const withGitToken = upsertEnvKey(spec.projectEnvText, "GIT_AUTH_TOKEN", token) + const withGhToken = upsertEnvKey(withGitToken, "GH_TOKEN", token) + const withoutGitLabel = upsertEnvKey(withGhToken, projectGitLabelKey, "") + return Effect.succeed(upsertEnvKey(withoutGitLabel, projectGithubLabelKey, spec.canonicalLabel)) +} + +const updateProjectGithubDisconnect = (spec: ProjectEnvUpdateSpec): Effect.Effect => { + const withoutGitToken = upsertEnvKey(spec.projectEnvText, "GIT_AUTH_TOKEN", "") + return Effect.succeed(clearProjectGitLabels(withoutGitToken)) +} + +const updateProjectGitConnect = (spec: ProjectEnvUpdateSpec): Effect.Effect => { + const tokenKey = buildLabeledEnvKey(gitTokenBaseKey, spec.rawLabel) + const userKey = buildLabeledEnvKey(gitUserBaseKey, spec.rawLabel) + const token = findEnvValue(spec.globalEnvText, tokenKey) + if (token === null) { + return Effect.fail(missingSecret("Git credentials", spec.canonicalLabel, spec.globalEnvPath)) + } + const defaultUser = findEnvValue(spec.globalEnvText, gitUserBaseKey) ?? defaultGitUser + const user = findEnvValue(spec.globalEnvText, userKey) ?? defaultUser + const withToken = upsertEnvKey(spec.projectEnvText, "GIT_AUTH_TOKEN", token) + const withUser = upsertEnvKey(withToken, "GIT_AUTH_USER", user) + const withGhToken = upsertEnvKey(withUser, "GH_TOKEN", token) + const withGitLabel = upsertEnvKey(withGhToken, projectGitLabelKey, spec.canonicalLabel) + return Effect.succeed(upsertEnvKey(withGitLabel, projectGithubLabelKey, spec.canonicalLabel)) +} + +const updateProjectGitDisconnect = (spec: ProjectEnvUpdateSpec): Effect.Effect => { + const withoutToken = upsertEnvKey(spec.projectEnvText, "GIT_AUTH_TOKEN", "") + const withoutUser = upsertEnvKey(withoutToken, "GIT_AUTH_USER", "") + return Effect.succeed(clearProjectGitLabels(withoutUser)) +} + +type CredentialsChecker = ( + fs: FileSystem.FileSystem, + accountPath: string +) => Effect.Effect + +const resolveAccountCandidates = (authPath: string, accountLabel: string): ReadonlyArray => + accountLabel === "default" ? [`${authPath}/default`, authPath] : [`${authPath}/${accountLabel}`] + +const findFirstCredentialsMatch = ( + fs: FileSystem.FileSystem, + candidates: ReadonlyArray, + hasCredentials: CredentialsChecker +): Effect.Effect => + Effect.gen(function*(_) { + for (const accountPath of candidates) { + const exists = yield* _(fs.exists(accountPath)) + if (!exists) continue + const valid = yield* _(hasCredentials(fs, accountPath), Effect.orElseSucceed(() => false)) + if (valid) return accountPath + } + return null + }) + +const updateProjectClaudeConnect = (spec: ProjectEnvUpdateSpec): Effect.Effect => { + const accountLabel = normalizeAccountLabel(spec.rawLabel, "default") + const accountCandidates = resolveAccountCandidates(spec.claudeAuthPath, accountLabel) + return findFirstCredentialsMatch(spec.fs, accountCandidates, hasClaudeAccountCredentials).pipe( + Effect.flatMap((matched) => + matched === null + ? Effect.fail(missingSecret("Claude Code login", spec.canonicalLabel, spec.claudeAuthPath)) + : Effect.succeed(upsertEnvKey(spec.projectEnvText, projectClaudeLabelKey, spec.canonicalLabel)) + ) + ) +} + +const updateProjectClaudeDisconnect = (spec: ProjectEnvUpdateSpec): Effect.Effect => + Effect.succeed(upsertEnvKey(spec.projectEnvText, projectClaudeLabelKey, "")) + +const updateProjectGeminiConnect = (spec: ProjectEnvUpdateSpec): Effect.Effect => { + const accountLabel = normalizeAccountLabel(spec.rawLabel, "default") + const accountCandidates = resolveAccountCandidates(spec.geminiAuthPath, accountLabel) + return findFirstCredentialsMatch(spec.fs, accountCandidates, hasGeminiAccountCredentials).pipe( + Effect.flatMap((matched) => + matched === null + ? Effect.fail(missingSecret("Gemini CLI API key", spec.canonicalLabel, spec.geminiAuthPath)) + : Effect.succeed(upsertEnvKey(spec.projectEnvText, projectGeminiLabelKey, spec.canonicalLabel)) + ) + ) +} + +const updateProjectGeminiDisconnect = (spec: ProjectEnvUpdateSpec): Effect.Effect => + Effect.succeed(upsertEnvKey(spec.projectEnvText, projectGeminiLabelKey, "")) + +export const resolveProjectEnvUpdate = ( + flow: ProjectAuthFlow, + spec: ProjectEnvUpdateSpec +): Effect.Effect => + Match.value(flow).pipe( + Match.when("ProjectGithubConnect", () => updateProjectGithubConnect(spec)), + Match.when("ProjectGithubDisconnect", () => updateProjectGithubDisconnect(spec)), + Match.when("ProjectGitConnect", () => updateProjectGitConnect(spec)), + Match.when("ProjectGitDisconnect", () => updateProjectGitDisconnect(spec)), + Match.when("ProjectClaudeConnect", () => updateProjectClaudeConnect(spec)), + Match.when("ProjectClaudeDisconnect", () => updateProjectClaudeDisconnect(spec)), + Match.when("ProjectGeminiConnect", () => updateProjectGeminiConnect(spec)), + Match.when("ProjectGeminiDisconnect", () => updateProjectGeminiDisconnect(spec)), + Match.exhaustive + ) diff --git a/packages/app/src/docker-git/menu-project-auth-gemini.ts b/packages/app/src/docker-git/menu-project-auth-gemini.ts new file mode 100644 index 00000000..c16d93f2 --- /dev/null +++ b/packages/app/src/docker-git/menu-project-auth-gemini.ts @@ -0,0 +1,116 @@ +import type { PlatformError } from "@effect/platform/Error" +import type * as FileSystem from "@effect/platform/FileSystem" +import { Effect } from "effect" + +import { hasFileAtPath } from "./menu-project-auth-helpers.js" + +// CHANGE: add Gemini CLI account credentials check for project auth +// WHY: enable Gemini CLI authentication verification at project level (API key or OAuth) +// QUOTE(ТЗ): "Добавь поддержку gemini CLI", "Типо ждал пока мы вставим ссылку" +// REF: issue-146, PR-147 comment from skulidropek +// SOURCE: https://geminicli.com/docs/get-started/authentication/ +// FORMAT THEOREM: forall accountPath: hasGeminiAccountCredentials(fs, accountPath) = boolean | PlatformError +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: returns true only if valid API key or OAuth credentials exist +// COMPLEXITY: O(1) + +const apiKeyFileName = ".api-key" +const envFileName = ".env" +const geminiCredentialsDir = ".gemini" + +const hasNonEmptyApiKey = ( + fs: FileSystem.FileSystem, + apiKeyPath: string +): Effect.Effect => + Effect.gen(function*(_) { + const hasFile = yield* _(hasFileAtPath(fs, apiKeyPath)) + if (!hasFile) { + return false + } + const keyValue = yield* _(fs.readFileString(apiKeyPath), Effect.orElseSucceed(() => "")) + return keyValue.trim().length > 0 + }) + +const hasApiKeyInEnvFile = ( + fs: FileSystem.FileSystem, + envFilePath: string +): Effect.Effect => + Effect.gen(function*(_) { + const hasFile = yield* _(hasFileAtPath(fs, envFilePath)) + if (!hasFile) { + return false + } + const envContent = yield* _(fs.readFileString(envFilePath), Effect.orElseSucceed(() => "")) + const lines = envContent.split("\n") + for (const line of lines) { + const trimmed = line.trim() + if (trimmed.startsWith("GEMINI_API_KEY=")) { + const value = trimmed.slice("GEMINI_API_KEY=".length).replaceAll(/^['"]|['"]$/g, "").trim() + if (value.length > 0) { + return true + } + } + } + return false + }) + +// CHANGE: check for OAuth credentials in .gemini directory +// WHY: Gemini CLI stores OAuth tokens in ~/.gemini after successful OAuth flow +// QUOTE(ТЗ): "Типо ждал пока мы вставим ссылку" +// REF: issue-146, PR-147 comment +// FORMAT THEOREM: hasOauthCredentials(fs, credentialsDir) -> boolean +// PURITY: SHELL +// INVARIANT: checks for existence of OAuth credential files +// COMPLEXITY: O(n) where n = number of possible credential files +const geminiOauthCredentialFiles: ReadonlyArray = [ + "oauth-tokens.json", + "credentials.json", + "application_default_credentials.json" +] + +const checkAnyFileExists = ( + fs: FileSystem.FileSystem, + basePath: string, + fileNames: ReadonlyArray +): Effect.Effect => { + const [first, ...rest] = fileNames + if (first === undefined) { + return Effect.succeed(false) + } + return hasFileAtPath(fs, `${basePath}/${first}`).pipe( + Effect.flatMap((exists) => exists ? Effect.succeed(true) : checkAnyFileExists(fs, basePath, rest)) + ) +} + +const hasOauthCredentials = ( + fs: FileSystem.FileSystem, + accountPath: string +): Effect.Effect => { + const credentialsDir = `${accountPath}/${geminiCredentialsDir}` + return hasFileAtPath(fs, credentialsDir).pipe( + Effect.flatMap((dirExists) => + dirExists ? checkAnyFileExists(fs, credentialsDir, geminiOauthCredentialFiles) : Effect.succeed(false) + ) + ) +} + +export const hasGeminiAccountCredentials = ( + fs: FileSystem.FileSystem, + accountPath: string +): Effect.Effect => + hasNonEmptyApiKey(fs, `${accountPath}/${apiKeyFileName}`).pipe( + Effect.flatMap((hasApiKey) => { + if (hasApiKey) { + return Effect.succeed(true) + } + return hasApiKeyInEnvFile(fs, `${accountPath}/${envFileName}`).pipe( + Effect.flatMap((hasEnvApiKey) => { + if (hasEnvApiKey) { + return Effect.succeed(true) + } + return hasOauthCredentials(fs, accountPath) + }) + ) + }) + ) diff --git a/packages/app/src/docker-git/menu-project-auth-helpers.ts b/packages/app/src/docker-git/menu-project-auth-helpers.ts new file mode 100644 index 00000000..97ae6a97 --- /dev/null +++ b/packages/app/src/docker-git/menu-project-auth-helpers.ts @@ -0,0 +1,16 @@ +import type { PlatformError } from "@effect/platform/Error" +import type * as FileSystem from "@effect/platform/FileSystem" +import { Effect } from "effect" + +export const hasFileAtPath = ( + fs: FileSystem.FileSystem, + filePath: string +): Effect.Effect => + Effect.gen(function*(_) { + const exists = yield* _(fs.exists(filePath)) + if (!exists) { + return false + } + const info = yield* _(fs.stat(filePath)) + return info.type === "File" + }) diff --git a/packages/app/src/docker-git/menu-project-auth.ts b/packages/app/src/docker-git/menu-project-auth.ts index dd9c0d03..c8dc1ee1 100644 --- a/packages/app/src/docker-git/menu-project-auth.ts +++ b/packages/app/src/docker-git/menu-project-auth.ts @@ -81,6 +81,8 @@ const successMessage = (flow: ProjectAuthFlow, label: string): string => Match.when("ProjectGitDisconnect", () => "Disconnected Git from project."), Match.when("ProjectClaudeConnect", () => `Connected Claude label (${label}) to project.`), Match.when("ProjectClaudeDisconnect", () => "Disconnected Claude from project."), + Match.when("ProjectGeminiConnect", () => `Connected Gemini label (${label}) to project.`), + Match.when("ProjectGeminiDisconnect", () => "Disconnected Gemini from project."), Match.exhaustive ) @@ -141,7 +143,10 @@ const runProjectAuthAction = ( } if ( - action === "ProjectGithubDisconnect" || action === "ProjectGitDisconnect" || action === "ProjectClaudeDisconnect" + action === "ProjectGithubDisconnect" || + action === "ProjectGitDisconnect" || + action === "ProjectClaudeDisconnect" || + action === "ProjectGeminiDisconnect" ) { runProjectAuthEffect(view.project, action, {}, "default", context) return diff --git a/packages/app/src/docker-git/menu-types.ts b/packages/app/src/docker-git/menu-types.ts index b80fa389..16eb3885 100644 --- a/packages/app/src/docker-git/menu-types.ts +++ b/packages/app/src/docker-git/menu-types.ts @@ -84,15 +84,20 @@ export type AuthFlow = | "GitRemove" | "ClaudeOauth" | "ClaudeLogout" + | "GeminiOauth" + | "GeminiApiKey" + | "GeminiLogout" export interface AuthSnapshot { readonly globalEnvPath: string readonly claudeAuthPath: string + readonly geminiAuthPath: string readonly totalEntries: number readonly githubTokenEntries: number readonly gitTokenEntries: number readonly gitUserEntries: number readonly claudeAuthEntries: number + readonly geminiAuthEntries: number } export type ProjectAuthFlow = @@ -102,6 +107,8 @@ export type ProjectAuthFlow = | "ProjectGitDisconnect" | "ProjectClaudeConnect" | "ProjectClaudeDisconnect" + | "ProjectGeminiConnect" + | "ProjectGeminiDisconnect" export interface ProjectAuthSnapshot { readonly projectDir: string @@ -109,12 +116,15 @@ export interface ProjectAuthSnapshot { readonly envGlobalPath: string readonly envProjectPath: string readonly claudeAuthPath: string + readonly geminiAuthPath: string readonly githubTokenEntries: number readonly gitTokenEntries: number readonly claudeAuthEntries: number + readonly geminiAuthEntries: number readonly activeGithubLabel: string | null readonly activeGitLabel: string | null readonly activeClaudeLabel: string | null + readonly activeGeminiLabel: string | null } export type ViewState = diff --git a/packages/app/src/docker-git/program.ts b/packages/app/src/docker-git/program.ts index dd97d1bc..f28d8dee 100644 --- a/packages/app/src/docker-git/program.ts +++ b/packages/app/src/docker-git/program.ts @@ -8,6 +8,9 @@ import { authCodexLogin, authCodexLogout, authCodexStatus, + authGeminiLoginCli, + authGeminiLogout, + authGeminiStatus, authGithubLogin, authGithubLogout, authGithubStatus @@ -94,10 +97,13 @@ const handleNonBaseCommand = (command: NonBaseCommand) => Match.when({ _tag: "AuthClaudeLogout" }, (cmd) => authClaudeLogout(cmd)), Match.when({ _tag: "Attach" }, (cmd) => attachTmux(cmd)), Match.when({ _tag: "Panes" }, (cmd) => listTmuxPanes(cmd)), - Match.when({ _tag: "SessionsList" }, (cmd) => listTerminalSessions(cmd)), - Match.when({ _tag: "SessionsKill" }, (cmd) => killTerminalProcess(cmd)) + Match.when({ _tag: "SessionsList" }, (cmd) => listTerminalSessions(cmd)) ) .pipe( + Match.when({ _tag: "AuthGeminiLogin" }, (cmd) => authGeminiLoginCli(cmd)), + Match.when({ _tag: "AuthGeminiStatus" }, (cmd) => authGeminiStatus(cmd)), + Match.when({ _tag: "AuthGeminiLogout" }, (cmd) => authGeminiLogout(cmd)), + Match.when({ _tag: "SessionsKill" }, (cmd) => killTerminalProcess(cmd)), Match.when({ _tag: "Apply" }, (cmd) => applyProjectConfig(cmd)), Match.when({ _tag: "SessionsLogs" }, (cmd) => tailTerminalLogs(cmd)), Match.when({ _tag: "ScrapExport" }, (cmd) => exportScrap(cmd)), diff --git a/packages/lib/src/core/command-builders.ts b/packages/lib/src/core/command-builders.ts index 4435cc89..71a83d17 100644 --- a/packages/lib/src/core/command-builders.ts +++ b/packages/lib/src/core/command-builders.ts @@ -88,6 +88,8 @@ type PathConfig = { readonly codexAuthPath: string readonly codexSharedAuthPath: string readonly codexHome: string + readonly geminiAuthPath: string + readonly geminiHome: string readonly outDir: string } @@ -97,6 +99,7 @@ type DefaultPathConfig = { readonly envGlobalPath: string readonly envProjectPath: string readonly codexAuthPath: string + readonly geminiAuthPath: string } const resolveNormalizedSecretsRoot = (value: string | undefined): string | undefined => { @@ -113,7 +116,8 @@ const buildDefaultPathConfig = ( authorizedKeysPath: defaultTemplateConfig.authorizedKeysPath, envGlobalPath: defaultTemplateConfig.envGlobalPath, envProjectPath: defaultTemplateConfig.envProjectPath, - codexAuthPath: defaultTemplateConfig.codexAuthPath + codexAuthPath: defaultTemplateConfig.codexAuthPath, + geminiAuthPath: defaultTemplateConfig.geminiAuthPath } : { // NOTE: Keep docker-git root mount stable (projects root) so caches like @@ -122,7 +126,8 @@ const buildDefaultPathConfig = ( authorizedKeysPath: defaultTemplateConfig.authorizedKeysPath, envGlobalPath: `${normalizedSecretsRoot}/global.env`, envProjectPath: defaultTemplateConfig.envProjectPath, - codexAuthPath: `${normalizedSecretsRoot}/codex` + codexAuthPath: `${normalizedSecretsRoot}/codex`, + geminiAuthPath: `${normalizedSecretsRoot}/gemini` } const resolvePaths = ( @@ -145,6 +150,8 @@ const resolvePaths = ( ) const codexSharedAuthPath = codexAuthPath const codexHome = yield* _(nonEmpty("--codex-home", raw.codexHome, defaultTemplateConfig.codexHome)) + const geminiAuthPath = defaults.geminiAuthPath + const geminiHome = defaultTemplateConfig.geminiHome const outDir = yield* _(nonEmpty("--out-dir", raw.outDir, `.docker-git/${repoPath}`)) return { @@ -155,6 +162,8 @@ const resolvePaths = ( codexAuthPath, codexSharedAuthPath, codexHome, + geminiAuthPath, + geminiHome, outDir } }) @@ -224,6 +233,8 @@ const buildTemplateConfig = ({ codexAuthPath: paths.codexAuthPath, codexSharedAuthPath: paths.codexSharedAuthPath, codexHome: paths.codexHome, + geminiAuthPath: paths.geminiAuthPath, + geminiHome: paths.geminiHome, cpuLimit, ramLimit, dockerNetworkMode, diff --git a/packages/lib/src/core/domain.ts b/packages/lib/src/core/domain.ts index 5026fcf5..bc3f0eda 100644 --- a/packages/lib/src/core/domain.ts +++ b/packages/lib/src/core/domain.ts @@ -2,7 +2,7 @@ export type { MenuAction, ParseError } from "./menu.js" export { parseMenuSelection } from "./menu.js" export { deriveRepoPathParts, deriveRepoSlug, resolveRepoInput } from "./repo.js" -export type AgentMode = "claude" | "codex" +export type AgentMode = "claude" | "codex" | "gemini" export type DockerNetworkMode = "shared" | "project" @@ -34,6 +34,9 @@ export interface TemplateConfig { readonly codexAuthPath: string readonly codexSharedAuthPath: string readonly codexHome: string + readonly geminiAuthLabel?: string | undefined + readonly geminiAuthPath: string + readonly geminiHome: string readonly cpuLimit?: string | undefined readonly ramLimit?: string | undefined readonly dockerNetworkMode: DockerNetworkMode @@ -133,6 +136,7 @@ export interface ApplyCommand { readonly gitTokenLabel?: string | undefined readonly codexTokenLabel?: string | undefined readonly claudeTokenLabel?: string | undefined + readonly geminiTokenLabel?: string | undefined readonly cpuLimit?: string | undefined readonly ramLimit?: string | undefined readonly enableMcpPlaywright?: boolean | undefined @@ -238,6 +242,34 @@ export interface AuthClaudeLogoutCommand { readonly claudeAuthPath: string } +// CHANGE: add Gemini CLI auth commands +// WHY: enable Gemini CLI authentication management similar to Claude/Codex +// QUOTE(ТЗ): "Добавь поддержку gemini CLI" +// REF: issue-146 +// SOURCE: https://geminicli.com/docs/get-started/authentication/ +// FORMAT THEOREM: forall cmd ∈ AuthGeminiCommand: cmd.geminiAuthPath is valid path +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: authentication state is isolated by label +// COMPLEXITY: O(1) +export interface AuthGeminiLoginCommand { + readonly _tag: "AuthGeminiLogin" + readonly label: string | null + readonly geminiAuthPath: string +} + +export interface AuthGeminiStatusCommand { + readonly _tag: "AuthGeminiStatus" + readonly label: string | null + readonly geminiAuthPath: string +} + +export interface AuthGeminiLogoutCommand { + readonly _tag: "AuthGeminiLogout" + readonly label: string | null + readonly geminiAuthPath: string +} + export type SessionsCommand = | SessionsListCommand | SessionsKillCommand @@ -257,6 +289,9 @@ export type AuthCommand = | AuthClaudeLoginCommand | AuthClaudeStatusCommand | AuthClaudeLogoutCommand + | AuthGeminiLoginCommand + | AuthGeminiStatusCommand + | AuthGeminiLogoutCommand export type StateCommand = | StatePathCommand @@ -327,6 +362,8 @@ export const defaultTemplateConfig = { codexAuthPath: "./.docker-git/.orch/auth/codex", codexSharedAuthPath: "./.docker-git/.orch/auth/codex", codexHome: "/home/dev/.codex", + geminiAuthPath: "./.docker-git/.orch/auth/gemini", + geminiHome: "/home/dev/.gemini", cpuLimit: defaultCpuLimit, ramLimit: defaultRamLimit, dockerNetworkMode: defaultDockerNetworkMode, diff --git a/packages/lib/src/core/templates-entrypoint.ts b/packages/lib/src/core/templates-entrypoint.ts index 2cddd820..4825da0a 100644 --- a/packages/lib/src/core/templates-entrypoint.ts +++ b/packages/lib/src/core/templates-entrypoint.ts @@ -19,6 +19,7 @@ import { renderEntrypointCodexSharedAuth, renderEntrypointMcpPlaywright } from "./templates-entrypoint/codex.js" +import { renderEntrypointGeminiConfig } from "./templates-entrypoint/gemini.js" import { renderEntrypointGitConfig, renderEntrypointGitHooks } from "./templates-entrypoint/git.js" import { renderEntrypointDockerGitBootstrap } from "./templates-entrypoint/nested-docker-git.js" import { renderEntrypointOpenCodeConfig } from "./templates-entrypoint/opencode.js" @@ -52,6 +53,7 @@ export const renderEntrypoint = (config: TemplateConfig): string => renderEntrypointDockerSocket(config), renderEntrypointGitConfig(config), renderEntrypointClaudeConfig(config), + renderEntrypointGeminiConfig(config), renderEntrypointGitHooks(), renderEntrypointBackgroundTasks(config), renderEntrypointBaseline(), diff --git a/packages/lib/src/core/templates-entrypoint/gemini.ts b/packages/lib/src/core/templates-entrypoint/gemini.ts new file mode 100644 index 00000000..50e73d2c --- /dev/null +++ b/packages/lib/src/core/templates-entrypoint/gemini.ts @@ -0,0 +1,160 @@ +import type { TemplateConfig } from "../domain.js" + +// CHANGE: add Gemini CLI entrypoint configuration +// WHY: enable Gemini CLI authentication and configuration management similar to Claude/Codex +// QUOTE(ТЗ): "Добавь поддержку gemini CLI" +// REF: issue-146 +// SOURCE: https://geminicli.com/docs/get-started/authentication/ +// FORMAT THEOREM: forall config: renderEntrypointGeminiConfig(config) -> valid_bash_script +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: GEMINI_API_KEY is loaded from shared auth volume +// COMPLEXITY: O(1) + +const geminiAuthRootContainerPath = (sshUser: string): string => `/home/${sshUser}/.docker-git/.orch/auth/gemini` + +const geminiAuthConfigTemplate = String + .raw`# Gemini CLI: expose GEMINI_API_KEY for SSH sessions (API key stored under ~/.docker-git/.orch/auth/gemini) +GEMINI_LABEL_RAW="${"$"}{GEMINI_AUTH_LABEL:-}" +if [[ -z "$GEMINI_LABEL_RAW" ]]; then + GEMINI_LABEL_RAW="default" +fi + +GEMINI_LABEL_NORM="$(printf "%s" "$GEMINI_LABEL_RAW" \ + | tr '[:upper:]' '[:lower:]' \ + | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//')" +if [[ -z "$GEMINI_LABEL_NORM" ]]; then + GEMINI_LABEL_NORM="default" +fi + +GEMINI_AUTH_ROOT="__GEMINI_AUTH_ROOT__" +GEMINI_AUTH_DIR="$GEMINI_AUTH_ROOT/$GEMINI_LABEL_NORM" + +# Backward compatibility: if default auth is stored directly under gemini root, reuse it. +if [[ "$GEMINI_LABEL_NORM" == "default" ]]; then + GEMINI_ROOT_ENV_FILE="$GEMINI_AUTH_ROOT/.env" + if [[ -f "$GEMINI_ROOT_ENV_FILE" ]]; then + GEMINI_AUTH_DIR="$GEMINI_AUTH_ROOT" + fi +fi + +mkdir -p "$GEMINI_AUTH_DIR" || true +GEMINI_HOME_DIR="__GEMINI_HOME_DIR__" +mkdir -p "$GEMINI_HOME_DIR" || true + +GEMINI_API_KEY_FILE="$GEMINI_AUTH_DIR/.api-key" +GEMINI_ENV_FILE="$GEMINI_AUTH_DIR/.env" +GEMINI_HOME_ENV_FILE="$GEMINI_HOME_DIR/.env" + +docker_git_link_gemini_file() { + local source_path="$1" + local link_path="$2" + + # Preserve user-created regular files and seed config dir once. + if [[ -e "$link_path" && ! -L "$link_path" ]]; then + if [[ -f "$link_path" && ! -e "$source_path" ]]; then + cp "$link_path" "$source_path" || true + chmod 0600 "$source_path" || true + fi + return 0 + fi + + ln -sfn "$source_path" "$link_path" || true +} + +# Link Gemini .env file from auth dir to home dir +docker_git_link_gemini_file "$GEMINI_ENV_FILE" "$GEMINI_HOME_ENV_FILE" + +docker_git_refresh_gemini_api_key() { + local api_key="" + # Try to read from dedicated API key file first + if [[ -f "$GEMINI_API_KEY_FILE" ]]; then + api_key="$(tr -d '\r\n' < "$GEMINI_API_KEY_FILE")" + fi + # Fall back to .env file + if [[ -z "$api_key" && -f "$GEMINI_ENV_FILE" ]]; then + api_key="$(grep -E '^GEMINI_API_KEY=' "$GEMINI_ENV_FILE" 2>/dev/null | head -1 | cut -d'=' -f2- | tr -d '\r\n' | sed "s/^['\"]//;s/['\"]$//")" + fi + if [[ -n "$api_key" ]]; then + export GEMINI_API_KEY="$api_key" + else + unset GEMINI_API_KEY || true + fi +} + +docker_git_refresh_gemini_api_key` + +const renderGeminiAuthConfig = (config: TemplateConfig): string => + geminiAuthConfigTemplate + .replaceAll("__GEMINI_AUTH_ROOT__", geminiAuthRootContainerPath(config.sshUser)) + .replaceAll("__GEMINI_HOME_DIR__", `/home/${config.sshUser}/.gemini`) + +const renderGeminiCliInstall = (): string => + String.raw`# Gemini CLI: ensure CLI command exists (non-blocking startup self-heal) +docker_git_ensure_gemini_cli() { + if command -v gemini >/dev/null 2>&1; then + return 0 + fi + + if ! command -v npm >/dev/null 2>&1; then + return 0 + fi + + NPM_ROOT="$(npm root -g 2>/dev/null || true)" + GEMINI_CLI_JS="$NPM_ROOT/@google/gemini-cli/build/cli.js" + if [[ -z "$NPM_ROOT" || ! -f "$GEMINI_CLI_JS" ]]; then + echo "docker-git: gemini cli.js not found under npm global root; skip shim restore" >&2 + return 0 + fi + + # Rebuild a minimal shim when npm package exists but binary link is missing. + cat <<'EOF' > /usr/local/bin/gemini +#!/usr/bin/env bash +set -euo pipefail + +if ! command -v npm >/dev/null 2>&1; then + echo "gemini: npm is required but missing" >&2 + exit 127 +fi + +NPM_ROOT="$(npm root -g 2>/dev/null || true)" +GEMINI_CLI_JS="$NPM_ROOT/@google/gemini-cli/build/cli.js" +if [[ -z "$NPM_ROOT" || ! -f "$GEMINI_CLI_JS" ]]; then + echo "gemini: cli.js not found under npm global root" >&2 + exit 127 +fi + +exec node "$GEMINI_CLI_JS" "$@" +EOF + chmod 0755 /usr/local/bin/gemini || true + ln -sf /usr/local/bin/gemini /usr/bin/gemini || true +} + +docker_git_ensure_gemini_cli` + +const renderGeminiProfileSetup = (): string => + String.raw`GEMINI_PROFILE="/etc/profile.d/gemini-config.sh" +printf "export GEMINI_AUTH_LABEL=%q\n" "${"$"}{GEMINI_AUTH_LABEL:-default}" > "$GEMINI_PROFILE" +cat <<'EOF' >> "$GEMINI_PROFILE" +GEMINI_API_KEY_FILE="${"$"}{GEMINI_AUTH_DIR:-$HOME/.gemini}/.api-key" +GEMINI_ENV_FILE="${"$"}{GEMINI_AUTH_DIR:-$HOME/.gemini}/.env" +if [[ -f "$GEMINI_API_KEY_FILE" ]]; then + export GEMINI_API_KEY="$(tr -d '\r\n' < "$GEMINI_API_KEY_FILE")" +elif [[ -f "$GEMINI_ENV_FILE" ]]; then + GEMINI_KEY="$(grep -E '^GEMINI_API_KEY=' "$GEMINI_ENV_FILE" 2>/dev/null | head -1 | cut -d'=' -f2- | tr -d '\r\n' | sed "s/^['\"]//;s/['\"]$//")" + if [[ -n "$GEMINI_KEY" ]]; then + export GEMINI_API_KEY="$GEMINI_KEY" + fi +fi +EOF +chmod 0644 "$GEMINI_PROFILE" || true + +docker_git_upsert_ssh_env "GEMINI_AUTH_LABEL" "${"$"}{GEMINI_AUTH_LABEL:-default}" +docker_git_upsert_ssh_env "GEMINI_API_KEY" "${"$"}{GEMINI_API_KEY:-}"` + +export const renderEntrypointGeminiConfig = (config: TemplateConfig): string => + [ + renderGeminiAuthConfig(config), + renderGeminiCliInstall(), + renderGeminiProfileSetup() + ].join("\n\n") diff --git a/packages/lib/src/shell/ansi-strip.ts b/packages/lib/src/shell/ansi-strip.ts new file mode 100644 index 00000000..6f344f5c --- /dev/null +++ b/packages/lib/src/shell/ansi-strip.ts @@ -0,0 +1,81 @@ +// CHANGE: extract ANSI escape sequence stripping to shared module +// WHY: avoid code duplication between auth-claude-oauth.ts and auth-gemini-oauth.ts +// REF: issue-146, lint error +// PURITY: CORE +// COMPLEXITY: O(n) where n = string length + +const ansiEscape = "\u001B" +const ansiBell = "\u0007" + +const isAnsiFinalByte = (codePoint: number | undefined): boolean => + codePoint !== undefined && codePoint >= 0x40 && codePoint <= 0x7E + +const skipCsiSequence = (raw: string, start: number): number => { + const length = raw.length + let index = start + 2 + while (index < length) { + const codePoint = raw.codePointAt(index) + if (isAnsiFinalByte(codePoint)) { + return index + 1 + } + index += 1 + } + return index +} + +const skipOscSequence = (raw: string, start: number): number => { + const length = raw.length + let index = start + 2 + while (index < length) { + const char = raw[index] ?? "" + if (char === ansiBell) { + return index + 1 + } + if (char === ansiEscape && raw[index + 1] === "\\") { + return index + 2 + } + index += 1 + } + return index +} + +const skipEscapeSequence = (raw: string, start: number): number => { + const next = raw[start + 1] ?? "" + if (next === "[") { + return skipCsiSequence(raw, start) + } + if (next === "]") { + return skipOscSequence(raw, start) + } + return Math.min(raw.length, start + 2) +} + +export const stripAnsi = (raw: string): string => { + const cleaned: Array = [] + let index = 0 + + while (index < raw.length) { + const current = raw[index] ?? "" + if (current !== ansiEscape) { + cleaned.push(current) + index += 1 + continue + } + index = skipEscapeSequence(raw, index) + } + + return cleaned.join("") +} + +// CHANGE: extract writeChunkToFd to shared module +// WHY: avoid code duplication between auth-claude-oauth.ts and auth-gemini-oauth.ts +// REF: issue-146, lint error +// PURITY: SHELL (I/O side effect) +// COMPLEXITY: O(1) +export const writeChunkToFd = (fd: number, chunk: Uint8Array): void => { + if (fd === 2) { + process.stderr.write(chunk) + return + } + process.stdout.write(chunk) +} diff --git a/packages/lib/src/shell/config.ts b/packages/lib/src/shell/config.ts index ff2a0e48..dccd173b 100644 --- a/packages/lib/src/shell/config.ts +++ b/packages/lib/src/shell/config.ts @@ -37,6 +37,13 @@ const TemplateConfigSchema = Schema.Struct({ default: () => defaultTemplateConfig.codexSharedAuthPath }), codexHome: Schema.String, + geminiAuthLabel: Schema.optional(Schema.String), + geminiAuthPath: Schema.optionalWith(Schema.String, { + default: () => defaultTemplateConfig.geminiAuthPath + }), + geminiHome: Schema.optionalWith(Schema.String, { + default: () => defaultTemplateConfig.geminiHome + }), cpuLimit: Schema.optionalWith(Schema.String, { default: () => defaultTemplateConfig.cpuLimit }), diff --git a/packages/lib/src/usecases/auth-claude-oauth.ts b/packages/lib/src/usecases/auth-claude-oauth.ts index 7ecee5cd..3189ef11 100644 --- a/packages/lib/src/usecases/auth-claude-oauth.ts +++ b/packages/lib/src/usecases/auth-claude-oauth.ts @@ -6,6 +6,7 @@ import * as Fiber from "effect/Fiber" import type * as Scope from "effect/Scope" import * as Stream from "effect/Stream" +import { stripAnsi, writeChunkToFd } from "../shell/ansi-strip.js" import { resolveDefaultDockerUser, resolveDockerVolumeHostPath } from "../shell/docker-auth.js" import { AuthError, CommandFailedError } from "../shell/errors.js" @@ -16,69 +17,6 @@ const outputWindowSize = 262_144 const oauthTokenRegex = /([A-Za-z0-9][A-Za-z0-9._-]{20,})/u -const ansiEscape = "\u001B" -const ansiBell = "\u0007" - -const isAnsiFinalByte = (codePoint: number | undefined): boolean => - codePoint !== undefined && codePoint >= 0x40 && codePoint <= 0x7E - -const skipCsiSequence = (raw: string, start: number): number => { - const length = raw.length - let index = start + 2 - while (index < length) { - const codePoint = raw.codePointAt(index) - if (isAnsiFinalByte(codePoint)) { - return index + 1 - } - index += 1 - } - return index -} - -const skipOscSequence = (raw: string, start: number): number => { - const length = raw.length - let index = start + 2 - while (index < length) { - const char = raw[index] ?? "" - if (char === ansiBell) { - return index + 1 - } - if (char === ansiEscape && raw[index + 1] === "\\") { - return index + 2 - } - index += 1 - } - return index -} - -const skipEscapeSequence = (raw: string, start: number): number => { - const next = raw[start + 1] ?? "" - if (next === "[") { - return skipCsiSequence(raw, start) - } - if (next === "]") { - return skipOscSequence(raw, start) - } - return Math.min(raw.length, start + 2) -} - -const stripAnsi = (raw: string): string => { - const cleaned: Array = [] - let index = 0 - - while (index < raw.length) { - const current = raw[index] ?? "" - if (current !== ansiEscape) { - cleaned.push(current) - index += 1 - continue - } - index = skipEscapeSequence(raw, index) - } - - return cleaned.join("") -} - const extractOauthToken = (rawOutput: string): string | null => { const normalized = stripAnsi(rawOutput).replaceAll("\r", "\n") const markerIndex = normalized.lastIndexOf(tokenMarker) @@ -171,14 +109,6 @@ const startDockerProcess = ( ) ) -const writeChunkToFd = (fd: number, chunk: Uint8Array): void => { - if (fd === 2) { - process.stderr.write(chunk) - return - } - process.stdout.write(chunk) -} - const pumpDockerOutput = ( source: Stream.Stream, fd: number, diff --git a/packages/lib/src/usecases/auth-claude.ts b/packages/lib/src/usecases/auth-claude.ts index d6b9b707..d840104c 100644 --- a/packages/lib/src/usecases/auth-claude.ts +++ b/packages/lib/src/usecases/auth-claude.ts @@ -10,7 +10,7 @@ import { runDockerAuth, runDockerAuthExitCode } from "../shell/docker-auth.js" import type { AuthError } from "../shell/errors.js" import { CommandFailedError } from "../shell/errors.js" import { runClaudeOauthLoginWithPrompt } from "./auth-claude-oauth.js" -import { buildDockerAuthSpec, normalizeAccountLabel } from "./auth-helpers.js" +import { buildDockerAuthSpec, isRegularFile, normalizeAccountLabel } from "./auth-helpers.js" import { migrateLegacyOrchLayout } from "./auth-sync.js" import { ensureDockerImage } from "./docker-image.js" import { resolvePathFromCwd } from "./path-helpers.js" @@ -43,19 +43,6 @@ const claudeCredentialsPath = (accountPath: string): string => `${accountPath}/$ const claudeNestedCredentialsPath = (accountPath: string): string => `${accountPath}/${claudeCredentialsDirName}/${claudeCredentialsFileName}` -const isRegularFile = ( - fs: FileSystem.FileSystem, - filePath: string -): Effect.Effect => - Effect.gen(function*(_) { - const exists = yield* _(fs.exists(filePath)) - if (!exists) { - return false - } - const info = yield* _(fs.stat(filePath)) - return info.type === "File" - }) - const syncClaudeCredentialsFile = ( fs: FileSystem.FileSystem, accountPath: string diff --git a/packages/lib/src/usecases/auth-gemini-oauth.ts b/packages/lib/src/usecases/auth-gemini-oauth.ts new file mode 100644 index 00000000..37de40e6 --- /dev/null +++ b/packages/lib/src/usecases/auth-gemini-oauth.ts @@ -0,0 +1,260 @@ +import * as Command from "@effect/platform/Command" +import * as CommandExecutor from "@effect/platform/CommandExecutor" +import type { PlatformError } from "@effect/platform/Error" +import { Effect, pipe } from "effect" +import * as Fiber from "effect/Fiber" +import type * as Scope from "effect/Scope" +import * as Stream from "effect/Stream" + +import { stripAnsi, writeChunkToFd } from "../shell/ansi-strip.js" +import { resolveDefaultDockerUser, resolveDockerVolumeHostPath } from "../shell/docker-auth.js" +import { AuthError, CommandFailedError } from "../shell/errors.js" + +// CHANGE: add Gemini CLI OAuth authentication flow +// WHY: enable Gemini CLI OAuth login in headless/Docker environments +// QUOTE(ТЗ): "Мне надо что бы он её умел принимать, типо ждал пока мы вставим ссылку" +// REF: issue-146, PR-147 comment from skulidropek +// SOURCE: https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/code_assist/oauth2.ts +// FORMAT THEOREM: forall cmd: runGeminiOauthLogin(cmd) -> oauth_credentials_stored | error +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: OAuth credentials are stored in ~/.gemini directory within account path +// COMPLEXITY: O(command) + +type GeminiAuthResult = "success" | "failure" | "pending" + +const outputWindowSize = 262_144 + +// Detect successful authentication in Gemini CLI output +const authSuccessPatterns = [ + "Authentication succeeded", + "Authentication successful", + "Successfully authenticated", + "Logged in as", + "You are now logged in" +] + +const authFailurePatterns = [ + "Authentication failed", + "Failed to authenticate", + "Authorization failed", + "Authentication timed out", + "Authentication cancelled" +] + +const detectAuthResult = (output: string): GeminiAuthResult => { + const normalized = stripAnsi(output).toLowerCase() + + for (const pattern of authSuccessPatterns) { + if (normalized.includes(pattern.toLowerCase())) { + return "success" + } + } + + for (const pattern of authFailurePatterns) { + if (normalized.includes(pattern.toLowerCase())) { + return "failure" + } + } + + return "pending" +} + +// Fixed port for Gemini CLI OAuth callback server +// WHY: Using a fixed port allows Docker port forwarding to work +// SOURCE: https://github.com/google-gemini/gemini-cli/issues/2040 +const geminiOauthCallbackPort = 38_751 + +type DockerGeminiAuthSpec = { + readonly cwd: string + readonly image: string + readonly hostPath: string + readonly containerPath: string + readonly env: ReadonlyArray + readonly callbackPort: number +} + +const buildDockerGeminiAuthSpec = ( + cwd: string, + accountPath: string, + image: string, + containerPath: string +): DockerGeminiAuthSpec => ({ + cwd, + image, + hostPath: accountPath, + containerPath, + callbackPort: geminiOauthCallbackPort, + env: [ + `HOME=${containerPath}`, + "NO_BROWSER=true", + "GEMINI_CLI_NONINTERACTIVE=false", + `OAUTH_CALLBACK_PORT=${geminiOauthCallbackPort}`, + "OAUTH_CALLBACK_HOST=0.0.0.0" + ] +}) + +const buildDockerGeminiAuthArgs = (spec: DockerGeminiAuthSpec): ReadonlyArray => { + const base: Array = [ + "run", + "--rm", + "-i", + "-t", + "-v", + `${spec.hostPath}:${spec.containerPath}`, + "-p", + `${spec.callbackPort}:${spec.callbackPort}` + ] + const dockerUser = resolveDefaultDockerUser() + if (dockerUser !== null) { + base.push("--user", dockerUser) + } + for (const entry of spec.env) { + const trimmed = entry.trim() + if (trimmed.length === 0) { + continue + } + base.push("-e", trimmed) + } + // Run gemini CLI with --debug flag to ensure auth URL is shown + // WHY: In some Gemini CLI versions, auth URL is only shown with --debug flag + // SOURCE: https://github.com/google-gemini/gemini-cli/issues/13853 + return [...base, spec.image, "gemini", "--debug"] +} + +const startDockerProcess = ( + executor: CommandExecutor.CommandExecutor, + spec: DockerGeminiAuthSpec +): Effect.Effect => + executor.start( + pipe( + Command.make("docker", ...buildDockerGeminiAuthArgs(spec)), + Command.workingDirectory(spec.cwd), + Command.stdin("inherit"), + Command.stdout("pipe"), + Command.stderr("pipe") + ) + ) + +const pumpDockerOutput = ( + source: Stream.Stream, + fd: number, + resultBox: { value: GeminiAuthResult } +): Effect.Effect => { + const decoder = new TextDecoder("utf-8") + let outputWindow = "" + + return pipe( + source, + Stream.runForEach((chunk) => + Effect.sync(() => { + writeChunkToFd(fd, chunk) + outputWindow += decoder.decode(chunk) + if (outputWindow.length > outputWindowSize) { + outputWindow = outputWindow.slice(-outputWindowSize) + } + if (resultBox.value !== "pending") { + return + } + const result = detectAuthResult(outputWindow) + if (result !== "pending") { + resultBox.value = result + } + }).pipe(Effect.asVoid) + ) + ).pipe(Effect.asVoid) +} + +const resolveGeminiLoginResult = ( + result: GeminiAuthResult, + exitCode: number +): Effect.Effect => + Effect.gen(function*(_) { + if (result === "success") { + if (exitCode !== 0) { + yield* _( + Effect.logWarning( + `Gemini CLI returned exit=${exitCode}, but authentication appears successful; continuing.` + ) + ) + } + return + } + + if (result === "failure") { + yield* _( + Effect.fail( + new AuthError({ + message: "Gemini CLI OAuth authentication failed. Please try again." + }) + ) + ) + } + + if (exitCode !== 0) { + yield* _(Effect.fail(new CommandFailedError({ command: "gemini", exitCode }))) + } + + // If we get here with pending result and exit code 0, assume success + // (user may have completed auth flow successfully) + }) + +// CHANGE: print OAuth instructions before starting the flow +// WHY: help users understand how to complete OAuth in Docker environment +// QUOTE(ТЗ): "Мне надо что бы он её умел принимать, типо ждал пока мы вставим ссылку" +// REF: issue-146, PR-147 comment from skulidropek +// SOURCE: https://github.com/google-gemini/gemini-cli +// PURITY: SHELL +// COMPLEXITY: O(1) +const printOauthInstructions = (): Effect.Effect => + Effect.sync(() => { + const port = geminiOauthCallbackPort + process.stderr.write("\n") + process.stderr.write("╔═══════════════════════════════════════════════════════════════════════════╗\n") + process.stderr.write("║ Gemini CLI OAuth Authentication ║\n") + process.stderr.write("╠═══════════════════════════════════════════════════════════════════════════╣\n") + process.stderr.write("║ 1. Copy the auth URL shown below and open it in your browser ║\n") + process.stderr.write("║ 2. Sign in with your Google account ║\n") + process.stderr.write(`║ 3. After authentication, the browser will redirect to localhost:${port} ║\n`) + process.stderr.write("║ 4. The callback will be captured automatically (port is forwarded) ║\n") + process.stderr.write("╚═══════════════════════════════════════════════════════════════════════════╝\n") + process.stderr.write("\n") + }) + +// CHANGE: run Gemini CLI OAuth login with interactive prompt and port forwarding +// WHY: Gemini CLI OAuth callback now works in Docker via fixed port forwarding +// QUOTE(ТЗ): "Типо ждал пока мы вставим ссылку" +// REF: issue-146, PR-147 comment +// SOURCE: https://github.com/google-gemini/gemini-cli +// FORMAT THEOREM: forall (cwd, accountPath): runGeminiOauthLogin(cwd, accountPath) -> auth_completed | error +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: OAuth credentials are stored by Gemini CLI in ~/.gemini within containerPath +// COMPLEXITY: O(user_interaction) +export const runGeminiOauthLoginWithPrompt = ( + cwd: string, + accountPath: string, + options: { + readonly image: string + readonly containerPath: string + } +): Effect.Effect => + Effect.scoped( + Effect.gen(function*(_) { + yield* _(printOauthInstructions()) + + const executor = yield* _(CommandExecutor.CommandExecutor) + const hostPath = yield* _(resolveDockerVolumeHostPath(cwd, accountPath)) + const spec = buildDockerGeminiAuthSpec(cwd, hostPath, options.image, options.containerPath) + const proc = yield* _(startDockerProcess(executor, spec)) + + const resultBox: { value: GeminiAuthResult } = { value: "pending" } + const stdoutFiber = yield* _(Effect.forkScoped(pumpDockerOutput(proc.stdout, 1, resultBox))) + const stderrFiber = yield* _(Effect.forkScoped(pumpDockerOutput(proc.stderr, 2, resultBox))) + + const exitCode = yield* _(proc.exitCode.pipe(Effect.map(Number))) + yield* _(Fiber.join(stdoutFiber)) + yield* _(Fiber.join(stderrFiber)) + return yield* _(resolveGeminiLoginResult(resultBox.value, exitCode)) + }) + ) diff --git a/packages/lib/src/usecases/auth-gemini.ts b/packages/lib/src/usecases/auth-gemini.ts new file mode 100644 index 00000000..17453b82 --- /dev/null +++ b/packages/lib/src/usecases/auth-gemini.ts @@ -0,0 +1,351 @@ +import type * as CommandExecutor from "@effect/platform/CommandExecutor" +import type { PlatformError } from "@effect/platform/Error" +import type * as FileSystem from "@effect/platform/FileSystem" +import type * as Path from "@effect/platform/Path" +import { Effect } from "effect" + +import type { AuthGeminiLoginCommand, AuthGeminiLogoutCommand, AuthGeminiStatusCommand } from "../core/domain.js" +import { defaultTemplateConfig } from "../core/domain.js" +import type { AuthError, CommandFailedError } from "../shell/errors.js" +import { runGeminiOauthLoginWithPrompt } from "./auth-gemini-oauth.js" +import { isRegularFile, normalizeAccountLabel } from "./auth-helpers.js" +import { migrateLegacyOrchLayout } from "./auth-sync.js" +import { ensureDockerImage } from "./docker-image.js" +import { resolvePathFromCwd } from "./path-helpers.js" +import { withFsPathContext } from "./runtime.js" +import { autoSyncState } from "./state-repo.js" + +// CHANGE: add Gemini CLI authentication management with OAuth and API key support +// WHY: enable Gemini CLI authentication via API key or OAuth (for headless/Docker environments) +// QUOTE(ТЗ): "Добавь поддержку gemini CLI", "Типо ждал пока мы вставим ссылку" +// REF: issue-146, PR-147 comment from skulidropek +// SOURCE: https://geminicli.com/docs/get-started/authentication/ +// FORMAT THEOREM: forall cmd: authGeminiLogin(cmd) -> (api_key_persisted | oauth_completed) | error +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: Credentials are stored in isolated account directory +// COMPLEXITY: O(1) for API key, O(user_interaction) for OAuth + +type GeminiRuntime = FileSystem.FileSystem | Path.Path | CommandExecutor.CommandExecutor +type GeminiAuthMethod = "none" | "api-key" | "oauth" + +const geminiImageName = "docker-git-auth-gemini:latest" +const geminiImageDir = ".docker-git/.orch/auth/gemini/.image" +const geminiContainerHomeDir = "/gemini-home" +const geminiCredentialsDir = ".gemini" + +type GeminiAccountContext = { + readonly accountLabel: string + readonly accountPath: string + readonly cwd: string + readonly fs: FileSystem.FileSystem +} + +export const geminiAuthRoot = ".docker-git/.orch/auth/gemini" + +const geminiApiKeyFileName = ".api-key" +const geminiEnvFileName = ".env" + +const geminiApiKeyPath = (accountPath: string): string => `${accountPath}/${geminiApiKeyFileName}` +const geminiEnvFilePath = (accountPath: string): string => `${accountPath}/${geminiEnvFileName}` +const geminiCredentialsPath = (accountPath: string): string => `${accountPath}/${geminiCredentialsDir}` + +// CHANGE: render Dockerfile for Gemini CLI authentication image +// WHY: Gemini CLI OAuth requires running in Docker for headless environments +// QUOTE(ТЗ): "Типо ждал пока мы вставим ссылку" +// REF: issue-146, PR-147 comment +// SOURCE: https://github.com/google-gemini/gemini-cli +// FORMAT THEOREM: renderGeminiDockerfile() -> valid_dockerfile +// PURITY: CORE +// INVARIANT: Image includes Node.js and Gemini CLI +// COMPLEXITY: O(1) +const renderGeminiDockerfile = (): string => + String.raw`FROM ubuntu:24.04 +ENV DEBIAN_FRONTEND=noninteractive +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl bsdutils \ + && rm -rf /var/lib/apt/lists/* +RUN curl -fsSL https://deb.nodesource.com/setup_24.x | bash - \ + && apt-get install -y --no-install-recommends nodejs \ + && node -v \ + && npm -v \ + && rm -rf /var/lib/apt/lists/* +RUN npm install -g @google/gemini-cli@latest +ENTRYPOINT ["/bin/bash", "-c"] +` + +const ensureGeminiOrchLayout = ( + cwd: string +): Effect.Effect => + migrateLegacyOrchLayout(cwd, { + envGlobalPath: defaultTemplateConfig.envGlobalPath, + envProjectPath: defaultTemplateConfig.envProjectPath, + codexAuthPath: defaultTemplateConfig.codexAuthPath, + ghAuthPath: ".docker-git/.orch/auth/gh", + claudeAuthPath: ".docker-git/.orch/auth/claude", + geminiAuthPath: ".docker-git/.orch/auth/gemini" + }) + +const resolveGeminiAccountPath = (path: Path.Path, rootPath: string, label: string | null): { + readonly accountLabel: string + readonly accountPath: string +} => { + const accountLabel = normalizeAccountLabel(label, "default") + const accountPath = path.join(rootPath, accountLabel) + return { accountLabel, accountPath } +} + +const withGeminiAuth = ( + command: AuthGeminiLoginCommand | AuthGeminiLogoutCommand | AuthGeminiStatusCommand, + run: ( + context: GeminiAccountContext + ) => Effect.Effect, + options: { readonly buildImage?: boolean } = {} +): Effect.Effect => + withFsPathContext(({ cwd, fs, path }) => + Effect.gen(function*(_) { + yield* _(ensureGeminiOrchLayout(cwd)) + const rootPath = resolvePathFromCwd(path, cwd, command.geminiAuthPath) + const { accountLabel, accountPath } = resolveGeminiAccountPath(path, rootPath, command.label) + yield* _(fs.makeDirectory(accountPath, { recursive: true })) + if (options.buildImage === true) { + yield* _( + ensureDockerImage(fs, path, cwd, { + imageName: geminiImageName, + imageDir: geminiImageDir, + dockerfile: renderGeminiDockerfile(), + buildLabel: "gemini auth" + }) + ) + } + return yield* _(run({ accountLabel, accountPath, cwd, fs })) + }) + ) + +const readApiKey = ( + fs: FileSystem.FileSystem, + accountPath: string +): Effect.Effect => + Effect.gen(function*(_) { + const apiKeyFilePath = geminiApiKeyPath(accountPath) + const hasApiKey = yield* _(isRegularFile(fs, apiKeyFilePath)) + if (hasApiKey) { + const apiKey = yield* _(fs.readFileString(apiKeyFilePath), Effect.orElseSucceed(() => "")) + const trimmed = apiKey.trim() + if (trimmed.length > 0) { + return trimmed + } + } + + const envFilePath = geminiEnvFilePath(accountPath) + const hasEnvFile = yield* _(isRegularFile(fs, envFilePath)) + if (hasEnvFile) { + const envContent = yield* _(fs.readFileString(envFilePath), Effect.orElseSucceed(() => "")) + const lines = envContent.split("\n") + for (const line of lines) { + const trimmed = line.trim() + if (trimmed.startsWith("GEMINI_API_KEY=")) { + const value = trimmed.slice("GEMINI_API_KEY=".length).replaceAll(/^['"]|['"]$/g, "").trim() + if (value.length > 0) { + return value + } + } + } + } + + return null + }) + +// CHANGE: check for OAuth credentials in .gemini directory +// WHY: Gemini CLI stores OAuth tokens in ~/.gemini after successful OAuth flow +// QUOTE(ТЗ): "Типо ждал пока мы вставим ссылку" +// REF: issue-146, PR-147 comment +// SOURCE: https://github.com/google-gemini/gemini-cli +// FORMAT THEOREM: hasOauthCredentials(fs, accountPath) -> boolean +// PURITY: SHELL +// INVARIANT: checks for existence of OAuth token file +// COMPLEXITY: O(1) +const hasOauthCredentials = ( + fs: FileSystem.FileSystem, + accountPath: string +): Effect.Effect => + Effect.gen(function*(_) { + const credentialsDir = geminiCredentialsPath(accountPath) + const dirExists = yield* _(fs.exists(credentialsDir)) + if (!dirExists) { + return false + } + // Check for various possible credential files Gemini CLI might create + const possibleFiles = [ + `${credentialsDir}/oauth-tokens.json`, + `${credentialsDir}/credentials.json`, + `${credentialsDir}/application_default_credentials.json` + ] + for (const filePath of possibleFiles) { + const fileExists = yield* _(isRegularFile(fs, filePath)) + if (fileExists) { + return true + } + } + return false + }) + +// CHANGE: resolve Gemini authentication method +// WHY: need to detect whether user authenticated via API key or OAuth +// QUOTE(ТЗ): "Добавь поддержку gemini CLI" +// REF: issue-146 +// SOURCE: https://geminicli.com/docs/get-started/authentication/ +// FORMAT THEOREM: resolveGeminiAuthMethod(fs, accountPath) -> GeminiAuthMethod +// PURITY: SHELL +// INVARIANT: API key takes precedence over OAuth credentials +// COMPLEXITY: O(1) +const resolveGeminiAuthMethod = ( + fs: FileSystem.FileSystem, + accountPath: string +): Effect.Effect => + Effect.gen(function*(_) { + const apiKey = yield* _(readApiKey(fs, accountPath)) + if (apiKey !== null) { + return "api-key" + } + + const hasOauth = yield* _(hasOauthCredentials(fs, accountPath)) + return hasOauth ? "oauth" : "none" + }) + +// CHANGE: login to Gemini CLI by storing API key (menu version with direct key) +// WHY: Gemini CLI uses GEMINI_API_KEY environment variable for authentication +// QUOTE(ТЗ): "Добавь поддержку gemini CLI" +// REF: issue-146 +// SOURCE: https://geminicli.com/docs/get-started/authentication/ +// FORMAT THEOREM: forall cmd: authGeminiLogin(cmd) -> api_key_file_exists(accountPath) +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: API key is stored in .api-key file with 0600 permissions +// COMPLEXITY: O(1) +export const authGeminiLogin = ( + command: AuthGeminiLoginCommand, + apiKey: string +): Effect.Effect => { + const accountLabel = normalizeAccountLabel(command.label, "default") + return withGeminiAuth(command, ({ accountPath, fs }) => + Effect.gen(function*(_) { + const apiKeyFilePath = geminiApiKeyPath(accountPath) + yield* _(fs.writeFileString(apiKeyFilePath, `${apiKey.trim()}\n`)) + yield* _(fs.chmod(apiKeyFilePath, 0o600), Effect.orElseSucceed(() => void 0)) + })).pipe( + Effect.zipRight(autoSyncState(`chore(state): auth gemini ${accountLabel}`)) + ) +} + +// CHANGE: login to Gemini CLI via CLI (prompts user to run web-based setup) +// WHY: CLI-based login requires interactive API key entry +// QUOTE(ТЗ): "Добавь поддержку gemini CLI" +// REF: issue-146 +// SOURCE: https://geminicli.com/docs/get-started/authentication/ +// FORMAT THEOREM: forall cmd: authGeminiLoginCli(cmd) -> instruction_shown +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: only shows instructions, does not store credentials +// COMPLEXITY: O(1) +export const authGeminiLoginCli = ( + _command: AuthGeminiLoginCommand +): Effect.Effect => + Effect.gen(function*(_) { + yield* _(Effect.log("Gemini CLI supports two authentication methods:")) + yield* _(Effect.log("")) + yield* _(Effect.log("1. API Key (recommended for simplicity):")) + yield* _(Effect.log(" - Go to https://ai.google.dev/aistudio")) + yield* _(Effect.log(" - Create or retrieve your API key")) + yield* _(Effect.log(" - Use: docker-git menu -> Auth profiles -> Gemini CLI: set API key")) + yield* _(Effect.log("")) + yield* _(Effect.log("2. OAuth (Sign in with Google):")) + yield* _(Effect.log(" - Use: docker-git menu -> Auth profiles -> Gemini CLI: login via OAuth")) + yield* _(Effect.log(" - Follow the prompts to authenticate with your Google account")) + }) + +// CHANGE: login to Gemini CLI via OAuth in Docker container +// WHY: enable Gemini CLI OAuth authentication in headless/Docker environments +// QUOTE(ТЗ): "Мне надо что бы он её умел принимать, типо ждал пока мы вставим ссылку" +// REF: issue-146, PR-147 comment from skulidropek +// SOURCE: https://github.com/google-gemini/gemini-cli +// FORMAT THEOREM: forall cmd: authGeminiLoginOauth(cmd) -> oauth_credentials_stored | error +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: OAuth credentials are stored in account directory after successful auth +// COMPLEXITY: O(user_interaction) +export const authGeminiLoginOauth = ( + command: AuthGeminiLoginCommand +): Effect.Effect => { + const accountLabel = normalizeAccountLabel(command.label, "default") + return withGeminiAuth( + command, + ({ accountPath, cwd, fs }) => + Effect.gen(function*(_) { + // Ensure .gemini directory exists for OAuth credentials storage + const credentialsDir = geminiCredentialsPath(accountPath) + yield* _(fs.makeDirectory(credentialsDir, { recursive: true })) + + yield* _( + runGeminiOauthLoginWithPrompt(cwd, accountPath, { + image: geminiImageName, + containerPath: geminiContainerHomeDir + }) + ) + }), + { buildImage: true } + ).pipe( + Effect.zipRight(autoSyncState(`chore(state): auth gemini oauth ${accountLabel}`)) + ) +} + +// CHANGE: show Gemini CLI auth status for a given label +// WHY: allow verifying API key/OAuth presence without exposing credentials +// QUOTE(ТЗ): "Добавь поддержку gemini CLI" +// REF: issue-146 +// SOURCE: https://geminicli.com/docs/get-started/authentication/ +// FORMAT THEOREM: forall cmd: authGeminiStatus(cmd) -> connected(cmd, method) | disconnected(cmd) +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: never logs API keys or OAuth tokens +// COMPLEXITY: O(1) +export const authGeminiStatus = ( + command: AuthGeminiStatusCommand +): Effect.Effect => + withGeminiAuth(command, ({ accountLabel, accountPath, fs }) => + Effect.gen(function*(_) { + const authMethod = yield* _(resolveGeminiAuthMethod(fs, accountPath)) + if (authMethod === "none") { + yield* _(Effect.log(`Gemini not connected (${accountLabel}).`)) + return + } + yield* _(Effect.log(`Gemini connected (${accountLabel}, ${authMethod}).`)) + })) + +// CHANGE: logout Gemini CLI by clearing API key and OAuth credentials for a label +// WHY: allow revoking Gemini CLI access deterministically +// QUOTE(ТЗ): "Добавь поддержку gemini CLI" +// REF: issue-146 +// SOURCE: https://geminicli.com/docs/get-started/authentication/ +// FORMAT THEOREM: forall cmd: authGeminiLogout(cmd) -> credentials_cleared(cmd) +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: all credential files (API key and OAuth) are removed from account directory +// COMPLEXITY: O(1) +export const authGeminiLogout = ( + command: AuthGeminiLogoutCommand +): Effect.Effect => + Effect.gen(function*(_) { + const accountLabel = normalizeAccountLabel(command.label, "default") + yield* _( + withGeminiAuth(command, ({ accountPath, fs }) => + Effect.gen(function*(_) { + // Clear API key + yield* _(fs.remove(geminiApiKeyPath(accountPath), { force: true })) + yield* _(fs.remove(geminiEnvFilePath(accountPath), { force: true })) + // Clear OAuth credentials (entire .gemini directory) + yield* _(fs.remove(geminiCredentialsPath(accountPath), { recursive: true, force: true })) + })) + ) + yield* _(autoSyncState(`chore(state): auth gemini logout ${accountLabel}`)) + }).pipe(Effect.asVoid) diff --git a/packages/lib/src/usecases/auth-helpers.ts b/packages/lib/src/usecases/auth-helpers.ts index 02e59987..14d2e049 100644 --- a/packages/lib/src/usecases/auth-helpers.ts +++ b/packages/lib/src/usecases/auth-helpers.ts @@ -1,3 +1,7 @@ +import type { PlatformError } from "@effect/platform/Error" +import type * as FileSystem from "@effect/platform/FileSystem" +import { Effect } from "effect" + import { trimLeftChar, trimRightChar } from "../core/strings.js" import type { DockerAuthSpec } from "../shell/docker-auth.js" @@ -50,3 +54,26 @@ export const buildDockerAuthSpec = (input: DockerAuthSpecInput): DockerAuthSpec args: input.args, interactive: input.interactive }) + +// CHANGE: add isRegularFile helper for auth modules +// WHY: deduplicate file existence checks across Claude and Gemini auth +// QUOTE(ТЗ): "система авторизации" +// REF: issue-146 +// SOURCE: n/a +// FORMAT THEOREM: forall p: isRegularFile(fs, p) = true iff exists(p) ∧ type(p) = "File" +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: returns false for directories, symlinks, and non-existent paths +// COMPLEXITY: O(1) +export const isRegularFile = ( + fs: FileSystem.FileSystem, + filePath: string +): Effect.Effect => + Effect.gen(function*(_) { + const exists = yield* _(fs.exists(filePath)) + if (!exists) { + return false + } + const info = yield* _(fs.stat(filePath)) + return info.type === "File" + }) diff --git a/packages/lib/src/usecases/auth-sync-helpers.ts b/packages/lib/src/usecases/auth-sync-helpers.ts index a55aef1a..b3cc994a 100644 --- a/packages/lib/src/usecases/auth-sync-helpers.ts +++ b/packages/lib/src/usecases/auth-sync-helpers.ts @@ -160,4 +160,5 @@ export type AuthSyncSpec = { export type LegacyOrchPaths = AuthPaths & { readonly ghAuthPath: string readonly claudeAuthPath: string + readonly geminiAuthPath?: string } diff --git a/packages/lib/src/usecases/auth.ts b/packages/lib/src/usecases/auth.ts index 4d9b7a6d..5f73c11c 100644 --- a/packages/lib/src/usecases/auth.ts +++ b/packages/lib/src/usecases/auth.ts @@ -1,3 +1,4 @@ export * from "./auth-claude.js" export * from "./auth-codex.js" +export * from "./auth-gemini.js" export * from "./auth-github.js"