From 9e0f9b322e71997b042c035fe515b44625a0777a Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Mon, 16 Mar 2026 22:00:20 +0000 Subject: [PATCH 1/2] fix(ci): resolve lint errors and typecheck failures in app and lib --- .../app/src/docker-git/menu-auth-effects.ts | 4 +- packages/app/src/docker-git/program.ts | 4 +- .../src/core/templates-entrypoint/agent.ts | 24 ++-- .../src/core/templates-entrypoint/gemini.ts | 3 +- .../lib/src/usecases/auth-gemini-oauth.ts | 93 +++++++-------- packages/lib/src/usecases/auth-gemini.ts | 110 +++++++++--------- 6 files changed, 115 insertions(+), 123 deletions(-) diff --git a/packages/app/src/docker-git/menu-auth-effects.ts b/packages/app/src/docker-git/menu-auth-effects.ts index 81a4d826..04affc82 100644 --- a/packages/app/src/docker-git/menu-auth-effects.ts +++ b/packages/app/src/docker-git/menu-auth-effects.ts @@ -47,10 +47,10 @@ const resolveClaudeLogoutEffect = (labelOption: string | null) => authClaudeLogout({ _tag: "AuthClaudeLogout", label: labelOption, claudeAuthPath: claudeAuthRoot }) const resolveGeminiOauthEffect = (labelOption: string | null) => - authGeminiLoginOauth({ _tag: "AuthGeminiLogin", label: labelOption, geminiAuthPath: geminiAuthRoot }) + authGeminiLoginOauth({ _tag: "AuthGeminiLogin", label: labelOption, geminiAuthPath: geminiAuthRoot, isWeb: false }) const resolveGeminiApiKeyEffect = (labelOption: string | null, apiKey: string) => - authGeminiLogin({ _tag: "AuthGeminiLogin", label: labelOption, geminiAuthPath: geminiAuthRoot }, apiKey) + authGeminiLogin({ _tag: "AuthGeminiLogin", label: labelOption, geminiAuthPath: geminiAuthRoot, isWeb: false }, apiKey) const resolveGeminiLogoutEffect = (labelOption: string | null) => authGeminiLogout({ _tag: "AuthGeminiLogout", label: labelOption, geminiAuthPath: geminiAuthRoot }) diff --git a/packages/app/src/docker-git/program.ts b/packages/app/src/docker-git/program.ts index 5822e629..7e401955 100644 --- a/packages/app/src/docker-git/program.ts +++ b/packages/app/src/docker-git/program.ts @@ -101,9 +101,7 @@ const handleNonBaseCommand = (command: NonBaseCommand) => Match.when({ _tag: "SessionsList" }, (cmd) => listTerminalSessions(cmd)) ) .pipe( - Match.when({ _tag: "AuthGeminiLogin" }, (cmd) => - cmd.isWeb ? authGeminiLoginOauth(cmd) : authGeminiLoginCli(cmd) - ), + Match.when({ _tag: "AuthGeminiLogin" }, (cmd) => cmd.isWeb ? authGeminiLoginOauth(cmd) : authGeminiLoginCli(cmd)), Match.when({ _tag: "AuthGeminiStatus" }, (cmd) => authGeminiStatus(cmd)), Match.when({ _tag: "AuthGeminiLogout" }, (cmd) => authGeminiLogout(cmd)), Match.when({ _tag: "SessionsKill" }, (cmd) => killTerminalProcess(cmd)), diff --git a/packages/lib/src/core/templates-entrypoint/agent.ts b/packages/lib/src/core/templates-entrypoint/agent.ts index f2ec84be..aa748ce8 100644 --- a/packages/lib/src/core/templates-entrypoint/agent.ts +++ b/packages/lib/src/core/templates-entrypoint/agent.ts @@ -1,5 +1,8 @@ +import { Match } from "effect" import type { TemplateConfig } from "../domain.js" +type AgentMode = "claude" | "codex" | "gemini" + const indentBlock = (block: string, size = 2): string => { const prefix = " ".repeat(size) @@ -46,20 +49,17 @@ if [[ -n "$AGENT_PROMPT" ]]; then fi` ].join("\n\n") -const renderAgentPromptCommand = (mode: "claude" | "codex" | "gemini"): string => { - switch (mode) { - case "claude": - return String.raw`claude --dangerously-skip-permissions -p \"\$(cat \"$AGENT_PROMPT_FILE\")\"` - case "codex": - return String.raw`codex exec \"\$(cat \"$AGENT_PROMPT_FILE\")\"` - case "gemini": - return String.raw`gemini --approval-mode=yolo \"\$(cat \"$AGENT_PROMPT_FILE\")\"` - } -} +const renderAgentPromptCommand = (mode: AgentMode): string => + Match.value(mode).pipe( + Match.when("claude", () => String.raw`claude --dangerously-skip-permissions -p \"\$(cat \"$AGENT_PROMPT_FILE\")\"`), + Match.when("codex", () => String.raw`codex exec \"\$(cat \"$AGENT_PROMPT_FILE\")\"`), + Match.when("gemini", () => String.raw`gemini --approval-mode=yolo \"\$(cat \"$AGENT_PROMPT_FILE\")\"`), + Match.exhaustive + ) const renderAgentAutoLaunchCommand = ( config: TemplateConfig, - mode: "claude" | "codex" | "gemini" + mode: AgentMode ): string => String .raw`su - ${config.sshUser} -s /bin/bash -c "bash -lc '. /etc/profile 2>/dev/null || true; . \"$AGENT_ENV_FILE\" 2>/dev/null || true; cd \"$TARGET_DIR\" && ${ @@ -68,7 +68,7 @@ const renderAgentAutoLaunchCommand = ( const renderAgentModeBlock = ( config: TemplateConfig, - mode: "claude" | "codex" | "gemini" + mode: AgentMode ): string => { const startMessage = `[agent] starting ${mode}...` const interactiveMessage = `[agent] ${mode} started in interactive mode (use SSH to connect)` diff --git a/packages/lib/src/core/templates-entrypoint/gemini.ts b/packages/lib/src/core/templates-entrypoint/gemini.ts index 42104b4b..3a3bc50f 100644 --- a/packages/lib/src/core/templates-entrypoint/gemini.ts +++ b/packages/lib/src/core/templates-entrypoint/gemini.ts @@ -11,7 +11,8 @@ import type { TemplateConfig } from "../domain.js" const geminiAuthRootContainerPath = (sshUser: string): string => `/home/${sshUser}/.docker-git/.orch/auth/gemini` -const geminiAuthConfigTemplate = String.raw`# Gemini CLI: expose GEMINI_HOME for sessions (OAuth cache lives under ~/.docker-git/.orch/auth/gemini) +const geminiAuthConfigTemplate = String + .raw`# Gemini CLI: expose GEMINI_HOME for sessions (OAuth cache lives under ~/.docker-git/.orch/auth/gemini) GEMINI_LABEL_RAW="$GEMINI_AUTH_LABEL" if [[ -z "$GEMINI_LABEL_RAW" ]]; then GEMINI_LABEL_RAW="default" diff --git a/packages/lib/src/usecases/auth-gemini-oauth.ts b/packages/lib/src/usecases/auth-gemini-oauth.ts index 864bd3cb..46e584af 100644 --- a/packages/lib/src/usecases/auth-gemini-oauth.ts +++ b/packages/lib/src/usecases/auth-gemini-oauth.ts @@ -7,9 +7,9 @@ import type * as Scope from "effect/Scope" import * as Stream from "effect/Stream" import { stripAnsi, writeChunkToFd } from "../shell/ansi-strip.js" +import { runCommandCapture, runCommandExitCode } from "../shell/command-runner.js" import { resolveDockerVolumeHostPath } from "../shell/docker-auth.js" import { AuthError, CommandFailedError } from "../shell/errors.js" -import { runCommandCapture, runCommandExitCode } from "../shell/command-runner.js" // CHANGE: add Gemini CLI OAuth authentication flow // WHY: enable Gemini CLI OAuth login in headless/Docker environments @@ -48,31 +48,23 @@ const detectAuthResult = (output: string): GeminiAuthResult => { const normalized = stripAnsi(output).toLowerCase() // Markers that indicate we are in the middle of or after an auth flow - const authInitiated = - normalized.includes("please visit the following url") || - normalized.includes("enter the authorization code") || - normalized.includes("authorized the application") - - for (const pattern of authSuccessPatterns) { - if (normalized.includes(pattern.toLowerCase())) { - // If we saw auth initiation, any success pattern is a real success - if (authInitiated) { - return "success" - } - // If we didn't see initiation but see success, it might be the banner - // BUT if it's "Logged in with Google" and we're NOT in initiation, - // it means we're ALREADY logged in, so we can also stop. - if (normalized.includes("logged in with google")) { - return "success" - } - } - } + const authInitiated = [ + "please visit the following url", + "enter the authorization code", + "authorized the application" + ].some((m) => normalized.includes(m)) + + const isSuccess = authSuccessPatterns.some( + (pattern) => + normalized.includes(pattern.toLowerCase()) && + (authInitiated || normalized.includes("logged in with google")) + ) - for (const pattern of authFailurePatterns) { - if (normalized.includes(pattern.toLowerCase())) { - return "failure" - } - } + if (isSuccess) return "success" + + const isFailure = authFailurePatterns.some((pattern) => normalized.includes(pattern.toLowerCase())) + + if (isFailure) return "failure" return "pending" } @@ -189,7 +181,7 @@ const pumpDockerOutput = ( source: Stream.Stream, fd: number, resultBox: { value: GeminiAuthResult }, - authDeferred: Deferred.Deferred + authDeferred: Deferred.Deferred ): Effect.Effect => { const decoder = new TextDecoder("utf-8") let outputWindow = "" @@ -198,7 +190,9 @@ const pumpDockerOutput = ( source, Stream.runForEach((chunk) => Effect.gen(function*(_) { - yield* _(Effect.sync(() => writeChunkToFd(fd, chunk))) + yield* _(Effect.sync(() => { + writeChunkToFd(fd, chunk) + })) outputWindow += decoder.decode(chunk) if (outputWindow.length > outputWindowSize) { outputWindow = outputWindow.slice(-outputWindowSize) @@ -276,6 +270,26 @@ const printOauthInstructions = (): Effect.Effect => // 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 +const fixGeminiAuthPermissions = (hostPath: string, containerPath: string) => + runCommandExitCode({ + cwd: process.cwd(), + command: "docker", + args: [ + "run", + "--rm", + "-v", + `${hostPath}:${containerPath}`, + "alpine", + "chmod", + "-R", + "777", + containerPath + ] + }).pipe( + Effect.tapError((err) => Effect.logWarning(`Failed to fix Gemini auth permissions: ${String(err)}`)), + Effect.catchAll(() => Effect.succeed(0)) + ) + // QUOTE(ТЗ): "Типо ждал пока мы вставим ссылку" // REF: issue-146, PR-147 comment // SOURCE: https://github.com/google-gemini/gemini-cli @@ -304,7 +318,7 @@ export const runGeminiOauthLoginWithPrompt = ( const spec = buildDockerGeminiAuthSpec(cwd, hostPath, options.image, options.containerPath, port) const proc = yield* _(startDockerProcess(executor, spec)) - const authDeferred = yield* _(Deferred.make()) + const authDeferred = yield* _(Deferred.make()) const resultBox: { value: GeminiAuthResult } = { value: "pending" } const stdoutFiber = yield* _(Effect.forkScoped(pumpDockerOutput(proc.stdout, 1, resultBox, authDeferred))) const stderrFiber = yield* _(Effect.forkScoped(pumpDockerOutput(proc.stderr, 2, resultBox, authDeferred))) @@ -318,32 +332,13 @@ export const runGeminiOauthLoginWithPrompt = ( Effect.map(() => 0) ) ) - ) as Effect.Effect + ) yield* _(Fiber.join(stdoutFiber)) yield* _(Fiber.join(stderrFiber)) // Fix permissions for all files created by root in the volume - yield* _( - runCommandExitCode({ - cwd: process.cwd(), - command: "docker", - args: [ - "run", - "--rm", - "-v", - `${hostPath}:${spec.containerPath}`, - "alpine", - "chmod", - "-R", - "777", - spec.containerPath - ] - }).pipe( - Effect.tapError((err) => Effect.logWarning(`Failed to fix Gemini auth permissions: ${err}`)), - Effect.catchAll(() => Effect.succeed(0)) - ) - ) + yield* _(fixGeminiAuthPermissions(hostPath, spec.containerPath)) return yield* _(resolveGeminiLoginResult(resultBox.value, exitCode)) }) diff --git a/packages/lib/src/usecases/auth-gemini.ts b/packages/lib/src/usecases/auth-gemini.ts index e8750ad5..858512e2 100644 --- a/packages/lib/src/usecases/auth-gemini.ts +++ b/packages/lib/src/usecases/auth-gemini.ts @@ -270,6 +270,47 @@ export const authGeminiLoginCli = ( // QUOTE(ТЗ): "Мне надо что бы он её умел принимать, типо ждал пока мы вставим ссылку" // REF: issue-146, PR-147 comment from skulidropek // SOURCE: https://github.com/google-gemini/gemini-cli +const prepareGeminiCredentialsDir = ( + cwd: string, + accountPath: string, + fs: FileSystem.FileSystem +) => + Effect.gen(function*(_) { + const credentialsDir = geminiCredentialsPath(accountPath) + const removeFallback = pipe( + runCommandExitCode({ + cwd, + command: "docker", + args: ["run", "--rm", "-v", `${accountPath}:/target`, "alpine", "rm", "-rf", "/target/.gemini"] + }), + Effect.asVoid, + Effect.catchAll(() => Effect.void) + ) + + yield* _( + fs.remove(credentialsDir, { recursive: true, force: true }).pipe( + Effect.catchAll(() => removeFallback) + ) + ) + yield* _(fs.makeDirectory(credentialsDir, { recursive: true })) + return credentialsDir + }) + +const writeInitialSettings = (credentialsDir: string, fs: FileSystem.FileSystem) => + Effect.gen(function*(_) { + const settingsPath = `${credentialsDir}/settings.json` + yield* _(fs.writeFileString(settingsPath, JSON.stringify({ security: { folderTrust: { enabled: false } } }))) + + const trustedFoldersPath = `${credentialsDir}/trustedFolders.json` + yield* _( + fs.writeFileString( + trustedFoldersPath, + JSON.stringify({ "/": "TRUST_FOLDER", [geminiContainerHomeDir]: "TRUST_FOLDER" }) + ) + ) + return settingsPath + }) + // FORMAT THEOREM: forall cmd: authGeminiLoginOauth(cmd) -> oauth_credentials_stored | error // PURITY: SHELL // EFFECT: Effect @@ -283,51 +324,8 @@ export const authGeminiLoginOauth = ( command, ({ accountPath, cwd, fs }) => Effect.gen(function*(_) { - // Ensure .gemini directory exists and is empty for fresh OAuth credentials - const credentialsDir = geminiCredentialsPath(accountPath) - yield* _( - fs.remove(credentialsDir, { recursive: true, force: true }).pipe( - Effect.catchAll(() => - pipe( - runCommandExitCode({ - cwd, - command: "docker", - args: ["run", "--rm", "-v", `${accountPath}:/target`, "alpine", "rm", "-rf", "/target/.gemini"] - }), - Effect.asVoid, - Effect.catchAll(() => Effect.void) - ) - ) - ) as Effect.Effect - ) - yield* _(fs.makeDirectory(credentialsDir, { recursive: true })) - - // Pre-create settings.json to disable folder trust prompt - const settingsPath = `${credentialsDir}/settings.json` - yield* _( - fs.writeFileString( - settingsPath, - JSON.stringify({ - security: { - folderTrust: { - enabled: false - } - } - }) - ) - ) - - // Pre-trust the container's home directory to skip interactive prompt - const trustedFoldersPath = `${credentialsDir}/trustedFolders.json` - yield* _( - fs.writeFileString( - trustedFoldersPath, - JSON.stringify({ - "/": "TRUST_FOLDER", - [geminiContainerHomeDir]: "TRUST_FOLDER" - }) - ) - ) + const credentialsDir = yield* _(prepareGeminiCredentialsDir(cwd, accountPath, fs)) + const settingsPath = yield* _(writeInitialSettings(credentialsDir, fs)) yield* _( runGeminiOauthLoginWithPrompt(cwd, accountPath, { @@ -340,17 +338,17 @@ export const authGeminiLoginOauth = ( yield* _( fs.writeFileString( settingsPath, - JSON.stringify({ - security: { - folderTrust: { - enabled: false - }, - auth: { - selectedType: "oauth-personal" - }, - approvalPolicy: "never" - } - }, null, 2) + "\n" + JSON.stringify( + { + security: { + folderTrust: { enabled: false }, + auth: { selectedType: "oauth-personal" }, + approvalPolicy: "never" + } + }, + null, + 2 + ) + "\n" ) ) }), From 017a412f2c163cfa43d0cec0250c14d860fb42d4 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Mon, 16 Mar 2026 22:08:07 +0000 Subject: [PATCH 2/2] fix(ci): replace Effect.catchAll with Effect.orElse to satisfy effect lint rules --- packages/lib/src/usecases/auth-gemini-oauth.ts | 4 ++-- packages/lib/src/usecases/auth-gemini.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/lib/src/usecases/auth-gemini-oauth.ts b/packages/lib/src/usecases/auth-gemini-oauth.ts index 46e584af..06e825f2 100644 --- a/packages/lib/src/usecases/auth-gemini-oauth.ts +++ b/packages/lib/src/usecases/auth-gemini-oauth.ts @@ -158,7 +158,7 @@ const cleanupExistingContainers = ( cwd: process.cwd(), command: "docker", args: ["rm", "-f", ...ids] - }).pipe(Effect.catchAll(() => Effect.succeed(0))) + }).pipe(Effect.orElse(() => Effect.succeed(0))) ) } }) @@ -287,7 +287,7 @@ const fixGeminiAuthPermissions = (hostPath: string, containerPath: string) => ] }).pipe( Effect.tapError((err) => Effect.logWarning(`Failed to fix Gemini auth permissions: ${String(err)}`)), - Effect.catchAll(() => Effect.succeed(0)) + Effect.orElse(() => Effect.succeed(0)) ) // QUOTE(ТЗ): "Типо ждал пока мы вставим ссылку" diff --git a/packages/lib/src/usecases/auth-gemini.ts b/packages/lib/src/usecases/auth-gemini.ts index 858512e2..c0f81f7c 100644 --- a/packages/lib/src/usecases/auth-gemini.ts +++ b/packages/lib/src/usecases/auth-gemini.ts @@ -284,12 +284,12 @@ const prepareGeminiCredentialsDir = ( args: ["run", "--rm", "-v", `${accountPath}:/target`, "alpine", "rm", "-rf", "/target/.gemini"] }), Effect.asVoid, - Effect.catchAll(() => Effect.void) + Effect.orElse(() => Effect.void) ) yield* _( fs.remove(credentialsDir, { recursive: true, force: true }).pipe( - Effect.catchAll(() => removeFallback) + Effect.orElse(() => removeFallback) ) ) yield* _(fs.makeDirectory(credentialsDir, { recursive: true }))