|
| 1 | +import type * as CommandExecutor from "@effect/platform/CommandExecutor" |
| 2 | +import type { PlatformError } from "@effect/platform/Error" |
| 3 | +import type * as FileSystem from "@effect/platform/FileSystem" |
| 4 | +import type * as Path from "@effect/platform/Path" |
| 5 | +import { Effect, pipe } from "effect" |
| 6 | + |
| 7 | +import type { AuthGeminiLoginCommand, AuthGeminiLogoutCommand, AuthGeminiStatusCommand } from "../core/domain.js" |
| 8 | +import { defaultTemplateConfig } from "../core/domain.js" |
| 9 | +import { runCommandExitCode } from "../shell/command-runner.js" |
| 10 | +import type { CommandFailedError } from "../shell/errors.js" |
| 11 | +import { isRegularFile, normalizeAccountLabel } from "./auth-helpers.js" |
| 12 | +import { migrateLegacyOrchLayout } from "./auth-sync.js" |
| 13 | +import { ensureDockerImage } from "./docker-image.js" |
| 14 | +import { resolvePathFromCwd } from "./path-helpers.js" |
| 15 | +import { withFsPathContext } from "./runtime.js" |
| 16 | + |
| 17 | +export type GeminiRuntime = FileSystem.FileSystem | Path.Path | CommandExecutor.CommandExecutor |
| 18 | +export type GeminiAuthMethod = "none" | "api-key" | "oauth" |
| 19 | + |
| 20 | +export const geminiImageName = "docker-git-auth-gemini:latest" |
| 21 | +export const geminiImageDir = ".docker-git/.orch/auth/gemini/.image" |
| 22 | +export const geminiContainerHomeDir = "/gemini-home" |
| 23 | +export const geminiCredentialsDir = ".gemini" |
| 24 | + |
| 25 | +export type GeminiAccountContext = { |
| 26 | + readonly accountLabel: string |
| 27 | + readonly accountPath: string |
| 28 | + readonly cwd: string |
| 29 | + readonly fs: FileSystem.FileSystem |
| 30 | +} |
| 31 | + |
| 32 | +export const geminiAuthRoot = ".docker-git/.orch/auth/gemini" |
| 33 | + |
| 34 | +export const geminiApiKeyFileName = ".api-key" |
| 35 | +export const geminiEnvFileName = ".env" |
| 36 | + |
| 37 | +export const geminiApiKeyPath = (accountPath: string): string => `${accountPath}/${geminiApiKeyFileName}` |
| 38 | +export const geminiEnvFilePath = (accountPath: string): string => `${accountPath}/${geminiEnvFileName}` |
| 39 | +export const geminiCredentialsPath = (accountPath: string): string => `${accountPath}/${geminiCredentialsDir}` |
| 40 | + |
| 41 | +// CHANGE: render Dockerfile for Gemini CLI authentication image |
| 42 | +// WHY: Gemini CLI OAuth requires running in Docker for headless environments |
| 43 | +// QUOTE(ТЗ): "Типо ждал пока мы вставим ссылку" |
| 44 | +// REF: issue-146, PR-147 comment |
| 45 | +// SOURCE: https://github.com/google-gemini/gemini-cli |
| 46 | +// FORMAT THEOREM: renderGeminiDockerfile() -> valid_dockerfile |
| 47 | +// PURITY: CORE |
| 48 | +// INVARIANT: Image includes Node.js and Gemini CLI |
| 49 | +// COMPLEXITY: O(1) |
| 50 | +export const renderGeminiDockerfile = (): string => |
| 51 | + String.raw`FROM ubuntu:24.04 |
| 52 | +ENV DEBIAN_FRONTEND=noninteractive |
| 53 | +RUN apt-get update \ |
| 54 | + && apt-get install -y --no-install-recommends ca-certificates curl bsdutils \ |
| 55 | + && rm -rf /var/lib/apt/lists/* |
| 56 | +RUN curl -fsSL https://deb.nodesource.com/setup_24.x | bash - \ |
| 57 | + && apt-get install -y --no-install-recommends nodejs \ |
| 58 | + && rm -rf /var/lib/apt/lists/* |
| 59 | +RUN npm install -g @google/gemini-cli@0.33.2 |
| 60 | +` |
| 61 | + |
| 62 | +export const ensureGeminiOrchLayout = ( |
| 63 | + cwd: string |
| 64 | +): Effect.Effect<void, PlatformError, FileSystem.FileSystem | Path.Path> => |
| 65 | + migrateLegacyOrchLayout(cwd, { |
| 66 | + envGlobalPath: defaultTemplateConfig.envGlobalPath, |
| 67 | + envProjectPath: defaultTemplateConfig.envProjectPath, |
| 68 | + codexAuthPath: defaultTemplateConfig.codexAuthPath, |
| 69 | + ghAuthPath: ".docker-git/.orch/auth/gh", |
| 70 | + claudeAuthPath: ".docker-git/.orch/auth/claude", |
| 71 | + geminiAuthPath: ".docker-git/.orch/auth/gemini" |
| 72 | + }) |
| 73 | + |
| 74 | +export const resolveGeminiAccountPath = (path: Path.Path, rootPath: string, label: string | null): { |
| 75 | + readonly accountLabel: string |
| 76 | + readonly accountPath: string |
| 77 | +} => { |
| 78 | + const accountLabel = normalizeAccountLabel(label, "default") |
| 79 | + const accountPath = path.join(rootPath, accountLabel) |
| 80 | + return { accountLabel, accountPath } |
| 81 | +} |
| 82 | + |
| 83 | +export const withGeminiAuth = <A, E>( |
| 84 | + command: AuthGeminiLoginCommand | AuthGeminiLogoutCommand | AuthGeminiStatusCommand, |
| 85 | + run: ( |
| 86 | + context: GeminiAccountContext |
| 87 | + ) => Effect.Effect<A, E, CommandExecutor.CommandExecutor>, |
| 88 | + options: { readonly buildImage?: boolean } = {} |
| 89 | +): Effect.Effect<A, E | PlatformError | CommandFailedError, GeminiRuntime> => |
| 90 | + withFsPathContext(({ cwd, fs, path }) => |
| 91 | + Effect.gen(function*(_) { |
| 92 | + yield* _(ensureGeminiOrchLayout(cwd)) |
| 93 | + const rootPath = resolvePathFromCwd(path, cwd, command.geminiAuthPath) |
| 94 | + const { accountLabel, accountPath } = resolveGeminiAccountPath(path, rootPath, command.label) |
| 95 | + yield* _(fs.makeDirectory(accountPath, { recursive: true })) |
| 96 | + if (options.buildImage === true) { |
| 97 | + yield* _( |
| 98 | + ensureDockerImage(fs, path, cwd, { |
| 99 | + imageName: geminiImageName, |
| 100 | + imageDir: geminiImageDir, |
| 101 | + dockerfile: renderGeminiDockerfile(), |
| 102 | + buildLabel: "gemini auth" |
| 103 | + }) |
| 104 | + ) |
| 105 | + } |
| 106 | + return yield* _(run({ accountLabel, accountPath, cwd, fs })) |
| 107 | + }) |
| 108 | + ) |
| 109 | + |
| 110 | +export const readApiKey = ( |
| 111 | + fs: FileSystem.FileSystem, |
| 112 | + accountPath: string |
| 113 | +): Effect.Effect<string | null, PlatformError> => |
| 114 | + Effect.gen(function*(_) { |
| 115 | + const apiKeyFilePath = geminiApiKeyPath(accountPath) |
| 116 | + const hasApiKey = yield* _(isRegularFile(fs, apiKeyFilePath)) |
| 117 | + if (hasApiKey) { |
| 118 | + const apiKey = yield* _(fs.readFileString(apiKeyFilePath), Effect.orElseSucceed(() => "")) |
| 119 | + const trimmed = apiKey.trim() |
| 120 | + if (trimmed.length > 0) { |
| 121 | + return trimmed |
| 122 | + } |
| 123 | + } |
| 124 | + |
| 125 | + const envFilePath = geminiEnvFilePath(accountPath) |
| 126 | + const hasEnvFile = yield* _(isRegularFile(fs, envFilePath)) |
| 127 | + if (hasEnvFile) { |
| 128 | + const envContent = yield* _(fs.readFileString(envFilePath), Effect.orElseSucceed(() => "")) |
| 129 | + const lines = envContent.split("\n") |
| 130 | + for (const line of lines) { |
| 131 | + const trimmed = line.trim() |
| 132 | + if (trimmed.startsWith("GEMINI_API_KEY=")) { |
| 133 | + const value = trimmed.slice("GEMINI_API_KEY=".length).replaceAll(/^['"]|['"]$/g, "").trim() |
| 134 | + if (value.length > 0) { |
| 135 | + return value |
| 136 | + } |
| 137 | + } |
| 138 | + } |
| 139 | + } |
| 140 | + |
| 141 | + return null |
| 142 | + }) |
| 143 | + |
| 144 | +// CHANGE: check for OAuth credentials in .gemini directory |
| 145 | +// WHY: Gemini CLI stores OAuth tokens in ~/.gemini after successful OAuth flow |
| 146 | +// QUOTE(ТЗ): "Типо ждал пока мы вставим ссылку" |
| 147 | +// REF: issue-146, PR-147 comment |
| 148 | +// SOURCE: https://github.com/google-gemini/gemini-cli |
| 149 | +// FORMAT THEOREM: hasOauthCredentials(fs, accountPath) -> boolean |
| 150 | +// PURITY: SHELL |
| 151 | +// INVARIANT: checks for existence of OAuth token file |
| 152 | +// COMPLEXITY: O(1) |
| 153 | +export const hasOauthCredentials = ( |
| 154 | + fs: FileSystem.FileSystem, |
| 155 | + accountPath: string |
| 156 | +): Effect.Effect<boolean, PlatformError> => |
| 157 | + Effect.gen(function*(_) { |
| 158 | + const credentialsDir = geminiCredentialsPath(accountPath) |
| 159 | + const dirExists = yield* _(fs.exists(credentialsDir)) |
| 160 | + if (!dirExists) { |
| 161 | + return false |
| 162 | + } |
| 163 | + // Check for various possible credential files Gemini CLI might create |
| 164 | + const possibleFiles = [ |
| 165 | + `${credentialsDir}/oauth-tokens.json`, |
| 166 | + `${credentialsDir}/credentials.json`, |
| 167 | + `${credentialsDir}/application_default_credentials.json` |
| 168 | + ] |
| 169 | + for (const filePath of possibleFiles) { |
| 170 | + const fileExists = yield* _(isRegularFile(fs, filePath)) |
| 171 | + if (fileExists) { |
| 172 | + return true |
| 173 | + } |
| 174 | + } |
| 175 | + return false |
| 176 | + }) |
| 177 | + |
| 178 | +// CHANGE: resolve Gemini authentication method |
| 179 | +// WHY: need to detect whether user authenticated via API key or OAuth |
| 180 | +// QUOTE(ТЗ): "Добавь поддержку gemini CLI" |
| 181 | +// REF: issue-146 |
| 182 | +// SOURCE: https://geminicli.com/docs/get-started/authentication/ |
| 183 | +// FORMAT THEOREM: resolveGeminiAuthMethod(fs, accountPath) -> GeminiAuthMethod |
| 184 | +// PURITY: SHELL |
| 185 | +// INVARIANT: API key takes precedence over OAuth credentials |
| 186 | +// COMPLEXITY: O(1) |
| 187 | +export const resolveGeminiAuthMethod = ( |
| 188 | + fs: FileSystem.FileSystem, |
| 189 | + accountPath: string |
| 190 | +): Effect.Effect<GeminiAuthMethod, PlatformError> => |
| 191 | + Effect.gen(function*(_) { |
| 192 | + const apiKey = yield* _(readApiKey(fs, accountPath)) |
| 193 | + if (apiKey !== null) { |
| 194 | + return "api-key" |
| 195 | + } |
| 196 | + |
| 197 | + const hasOauth = yield* _(hasOauthCredentials(fs, accountPath)) |
| 198 | + return hasOauth ? "oauth" : "none" |
| 199 | + }) |
| 200 | + |
| 201 | +// CHANGE: login to Gemini CLI via OAuth in Docker container |
| 202 | +// WHY: enable Gemini CLI OAuth authentication in headless/Docker environments |
| 203 | +// QUOTE(ТЗ): "Мне надо что бы он её умел принимать, типо ждал пока мы вставим ссылку" |
| 204 | +// REF: issue-146, PR-147 comment from skulidropek |
| 205 | +// SOURCE: https://github.com/google-gemini/gemini-cli |
| 206 | +export const prepareGeminiCredentialsDir = ( |
| 207 | + cwd: string, |
| 208 | + accountPath: string, |
| 209 | + fs: FileSystem.FileSystem |
| 210 | +) => |
| 211 | + Effect.gen(function*(_) { |
| 212 | + const credentialsDir = geminiCredentialsPath(accountPath) |
| 213 | + const removeFallback = pipe( |
| 214 | + runCommandExitCode({ |
| 215 | + cwd, |
| 216 | + command: "docker", |
| 217 | + args: ["run", "--rm", "-v", `${accountPath}:/target`, "alpine", "rm", "-rf", "/target/.gemini"] |
| 218 | + }), |
| 219 | + Effect.asVoid, |
| 220 | + Effect.orElse(() => Effect.void) |
| 221 | + ) |
| 222 | + |
| 223 | + yield* _( |
| 224 | + fs.remove(credentialsDir, { recursive: true, force: true }).pipe( |
| 225 | + Effect.orElse(() => removeFallback) |
| 226 | + ) |
| 227 | + ) |
| 228 | + yield* _(fs.makeDirectory(credentialsDir, { recursive: true })) |
| 229 | + // Fix permissions before Docker starts, so root in container can write freely |
| 230 | + yield* _( |
| 231 | + runCommandExitCode({ |
| 232 | + cwd, |
| 233 | + command: "chmod", |
| 234 | + args: ["-R", "777", credentialsDir] |
| 235 | + }).pipe(Effect.orElse(() => Effect.succeed(0))) |
| 236 | + ) |
| 237 | + return credentialsDir |
| 238 | + }) |
| 239 | + |
| 240 | +export const writeInitialSettings = (credentialsDir: string, fs: FileSystem.FileSystem) => |
| 241 | + Effect.gen(function*(_) { |
| 242 | + const settingsPath = `${credentialsDir}/settings.json` |
| 243 | + yield* _( |
| 244 | + fs.writeFileString( |
| 245 | + settingsPath, |
| 246 | + JSON.stringify( |
| 247 | + { |
| 248 | + model: { |
| 249 | + name: "gemini-2.0-flash", |
| 250 | + compressionThreshold: 0.9, |
| 251 | + disableLoopDetection: true |
| 252 | + }, |
| 253 | + general: { |
| 254 | + defaultApprovalMode: "auto_edit" |
| 255 | + }, |
| 256 | + yolo: true, |
| 257 | + sandbox: { |
| 258 | + enabled: false |
| 259 | + }, |
| 260 | + security: { |
| 261 | + folderTrust: { enabled: false }, |
| 262 | + auth: { selectedType: "oauth-personal" }, |
| 263 | + approvalPolicy: "never" |
| 264 | + } |
| 265 | + }, |
| 266 | + null, |
| 267 | + 2 |
| 268 | + ) |
| 269 | + ) |
| 270 | + ) |
| 271 | + |
| 272 | + const trustedFoldersPath = `${credentialsDir}/trustedFolders.json` |
| 273 | + yield* _( |
| 274 | + fs.writeFileString( |
| 275 | + trustedFoldersPath, |
| 276 | + JSON.stringify({ "/": "TRUST_FOLDER", [geminiContainerHomeDir]: "TRUST_FOLDER" }) |
| 277 | + ) |
| 278 | + ) |
| 279 | + return settingsPath |
| 280 | + }) |
0 commit comments