diff --git a/packages/lib/src/core/templates-entrypoint/gemini.ts b/packages/lib/src/core/templates-entrypoint/gemini.ts index 3a3bc50f..842e4f68 100644 --- a/packages/lib/src/core/templates-entrypoint/gemini.ts +++ b/packages/lib/src/core/templates-entrypoint/gemini.ts @@ -99,6 +99,63 @@ const renderGeminiAuthConfig = (config: TemplateConfig): string => .replaceAll("__GEMINI_AUTH_ROOT__", geminiAuthRootContainerPath(config.sshUser)) .replaceAll("__GEMINI_HOME_DIR__", config.geminiHome) +const geminiSettingsJsonTemplate = `{ + "model": { + "name": "gemini-3.1-pro-preview-yolo", + "compressionThreshold": 0.9, + "disableLoopDetection": true + }, + "modelConfigs": { + "customAliases": { + "yolo-ultra": { + "modelConfig": { + "model": "gemini-3.1-pro-preview-yolo", + "generateContentConfig": { + "tools": [ + { + "googleSearch": {} + }, + { + "urlContext": {} + } + ] + } + } + } + } + }, + "general": { + "defaultApprovalMode": "auto_edit" + }, + "tools": { + "allowed": [ + "run_shell_command", + "write_file", + "googleSearch", + "urlContext" + ] + }, + "sandbox": { + "enabled": false + }, + "security": { + "folderTrust": { + "enabled": false + }, + "auth": { + "selectedType": "oauth-personal" + }, + "disableYoloMode": false + }, + "mcpServers": { + "playwright": { + "command": "docker-git-playwright-mcp", + "args": [], + "trust": true + } + } +}` + const renderGeminiPermissionSettingsConfig = (config: TemplateConfig): string => String.raw`# Gemini CLI: keep trust settings in sync with docker-git defaults GEMINI_SETTINGS_DIR="${config.geminiHome}" @@ -111,14 +168,7 @@ mkdir -p "$GEMINI_SETTINGS_DIR" || true # Disable folder trust prompt and enable auto-approval in settings.json if [[ ! -f "$GEMINI_CONFIG_SETTINGS_FILE" ]]; then cat <<'EOF' > "$GEMINI_CONFIG_SETTINGS_FILE" -{ - "security": { - "folderTrust": { - "enabled": false - }, - "approvalPolicy": "never" - } -} +${geminiSettingsJsonTemplate} EOF fi diff --git a/packages/lib/src/usecases/actions/create-project.ts b/packages/lib/src/usecases/actions/create-project.ts index dc61b555..249aff98 100644 --- a/packages/lib/src/usecases/actions/create-project.ts +++ b/packages/lib/src/usecases/actions/create-project.ts @@ -23,7 +23,7 @@ import { renderError } from "../errors.js" import { applyGithubForkConfig } from "../github-fork.js" import { defaultProjectsRoot } from "../menu-helpers.js" import { findSshPrivateKey } from "../path-helpers.js" -import { buildSshCommand } from "../projects-core.js" +import { buildSshCommand, getContainerIpIfInsideContainer } from "../projects-core.js" import { resolveTemplateResourceLimits } from "../resource-limits.js" import { autoSyncState } from "../state-repo.js" import { ensureTerminalCursorVisible } from "../terminal-cursor.js" @@ -97,8 +97,11 @@ const isInteractiveTty = (): boolean => process.stdin.isTTY && process.stdout.is const buildSshArgs = ( config: CreateCommand["config"], sshKeyPath: string | null, - remoteCommand?: string + remoteCommand?: string, + ipAddress?: string ): ReadonlyArray => { + const host = ipAddress ?? "localhost" + const port = ipAddress ? 22 : config.sshPort const args: Array = [] if (sshKeyPath !== null) { args.push("-i", sshKeyPath) @@ -113,8 +116,8 @@ const buildSshArgs = ( "-o", "UserKnownHostsFile=/dev/null", "-p", - String(config.sshPort), - `${config.sshUser}@localhost` + String(port), + `${config.sshUser}@${host}` ) if (remoteCommand !== undefined) { args.push(remoteCommand) @@ -140,8 +143,14 @@ const openSshBestEffort = ( const fs = yield* _(FileSystem.FileSystem) const path = yield* _(Path.Path) + const ipAddress = yield* _( + getContainerIpIfInsideContainer(fs, process.cwd(), template.containerName).pipe( + Effect.orElse(() => Effect.succeed("")) + ) + ) + const sshKey = yield* _(findSshPrivateKey(fs, path, process.cwd())) - const sshCommand = buildSshCommand(template, sshKey) + const sshCommand = buildSshCommand(template, sshKey, ipAddress) const remoteCommandLabel = remoteCommand === undefined ? "" : ` (${remoteCommand})` @@ -152,7 +161,7 @@ const openSshBestEffort = ( { cwd: process.cwd(), command: "ssh", - args: buildSshArgs(template, sshKey, remoteCommand) + args: buildSshArgs(template, sshKey, remoteCommand, ipAddress) }, [0, 130], (exitCode) => new CommandFailedError({ command: "ssh", exitCode }) diff --git a/packages/lib/src/usecases/actions/docker-up.ts b/packages/lib/src/usecases/actions/docker-up.ts index 79d76053..aadebeb3 100644 --- a/packages/lib/src/usecases/actions/docker-up.ts +++ b/packages/lib/src/usecases/actions/docker-up.ts @@ -12,6 +12,7 @@ import { runDockerComposeUpRecreate, runDockerExecExitCode, runDockerInspectContainerBridgeIp, + runDockerInspectContainerIp, runDockerNetworkConnectBridge } from "../../shell/docker.js" import type { DockerCommandError } from "../../shell/errors.js" @@ -31,14 +32,29 @@ const agentFailPath = "/run/docker-git/agent.failed" const logSshAccess = ( baseDir: string, config: CreateCommand["config"] -): Effect.Effect => +): Effect.Effect => Effect.gen(function*(_) { const fs = yield* _(FileSystem.FileSystem) const path = yield* _(Path.Path) + + const isInsideContainer = yield* _(fs.exists("/.dockerenv")) + let ipAddress: string | undefined + + if (isInsideContainer) { + const containerIp = yield* _( + runDockerInspectContainerIp(baseDir, config.containerName).pipe( + Effect.orElse(() => Effect.succeed("")) + ) + ) + if (containerIp.length > 0) { + ipAddress = containerIp + } + } + const resolvedAuthorizedKeys = resolveAuthorizedKeysPath(path, baseDir, config.authorizedKeysPath) const authExists = yield* _(fs.exists(resolvedAuthorizedKeys)) const sshKey = yield* _(findSshPrivateKey(fs, path, process.cwd())) - const sshCommand = buildSshCommand(config, sshKey) + const sshCommand = buildSshCommand(config, sshKey, ipAddress) yield* _(Effect.log(`SSH access: ${sshCommand}`)) if (!authExists) { diff --git a/packages/lib/src/usecases/auth-gemini-helpers.ts b/packages/lib/src/usecases/auth-gemini-helpers.ts index 8a0cc3e3..c6e3152d 100644 --- a/packages/lib/src/usecases/auth-gemini-helpers.ts +++ b/packages/lib/src/usecases/auth-gemini-helpers.ts @@ -237,35 +237,70 @@ export const prepareGeminiCredentialsDir = ( return credentialsDir }) +export const defaultGeminiSettings = { + model: { + name: "gemini-3.1-pro-preview-yolo", + compressionThreshold: 0.9, + disableLoopDetection: true + }, + modelConfigs: { + customAliases: { + "yolo-ultra": { + "modelConfig": { + "model": "gemini-3.1-pro-preview-yolo", + "generateContentConfig": { + "tools": [ + { + "googleSearch": {} + }, + { + "urlContext": {} + } + ] + } + } + } + } + }, + general: { + defaultApprovalMode: "auto_edit" + }, + tools: { + allowed: [ + "run_shell_command", + "write_file", + "googleSearch", + "urlContext" + ] + }, + sandbox: { + enabled: false + }, + security: { + folderTrust: { + enabled: false + }, + auth: { + selectedType: "oauth-personal" + }, + disableYoloMode: false + }, + mcpServers: { + playwright: { + command: "docker-git-playwright-mcp", + args: [], + trust: true + } + } +} + export const writeInitialSettings = (credentialsDir: string, fs: FileSystem.FileSystem) => Effect.gen(function*(_) { const settingsPath = `${credentialsDir}/settings.json` yield* _( fs.writeFileString( settingsPath, - JSON.stringify( - { - model: { - name: "gemini-2.0-flash", - compressionThreshold: 0.9, - disableLoopDetection: true - }, - general: { - defaultApprovalMode: "auto_edit" - }, - yolo: true, - sandbox: { - enabled: false - }, - security: { - folderTrust: { enabled: false }, - auth: { selectedType: "oauth-personal" }, - approvalPolicy: "never" - } - }, - null, - 2 - ) + JSON.stringify(defaultGeminiSettings, null, 2) + "\n" ) ) diff --git a/packages/lib/src/usecases/auth-gemini-logout.ts b/packages/lib/src/usecases/auth-gemini-logout.ts new file mode 100644 index 00000000..963a0b10 --- /dev/null +++ b/packages/lib/src/usecases/auth-gemini-logout.ts @@ -0,0 +1,37 @@ +import type { PlatformError } from "@effect/platform/Error" +import { Effect } from "effect" + +import type { AuthGeminiLogoutCommand } from "../core/domain.js" +import type { CommandFailedError } from "../shell/errors.js" +import { geminiApiKeyPath, geminiCredentialsPath, geminiEnvFilePath, withGeminiAuth } from "./auth-gemini-helpers.js" +import type { GeminiRuntime } from "./auth-gemini-helpers.js" +import { normalizeAccountLabel } from "./auth-helpers.js" +import { autoSyncState } from "./state-repo.js" + +// 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-gemini-status.ts b/packages/lib/src/usecases/auth-gemini-status.ts new file mode 100644 index 00000000..8997ea29 --- /dev/null +++ b/packages/lib/src/usecases/auth-gemini-status.ts @@ -0,0 +1,30 @@ +import type { PlatformError } from "@effect/platform/Error" +import { Effect } from "effect" + +import type { AuthGeminiStatusCommand } from "../core/domain.js" +import type { CommandFailedError } from "../shell/errors.js" +import { resolveGeminiAuthMethod, withGeminiAuth } from "./auth-gemini-helpers.js" +import type { GeminiRuntime } from "./auth-gemini-helpers.js" + +// 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}).`)) + })) diff --git a/packages/lib/src/usecases/auth-gemini.ts b/packages/lib/src/usecases/auth-gemini.ts index 9ac2e46b..9ed0f004 100644 --- a/packages/lib/src/usecases/auth-gemini.ts +++ b/packages/lib/src/usecases/auth-gemini.ts @@ -1,16 +1,15 @@ import type { PlatformError } from "@effect/platform/Error" import { Effect } from "effect" -import type { AuthGeminiLoginCommand, AuthGeminiLogoutCommand, AuthGeminiStatusCommand } from "../core/domain.js" +import type { AuthGeminiLoginCommand } from "../core/domain.js" import type { AuthError, CommandFailedError } from "../shell/errors.js" import { + defaultGeminiSettings, geminiApiKeyPath, geminiContainerHomeDir, geminiCredentialsPath, - geminiEnvFilePath, geminiImageName, type GeminiRuntime, prepareGeminiCredentialsDir, - resolveGeminiAuthMethod, withGeminiAuth, writeInitialSettings } from "./auth-gemini-helpers.js" @@ -38,6 +37,16 @@ export const authGeminiLogin = ( const apiKeyFilePath = geminiApiKeyPath(accountPath) yield* _(fs.writeFileString(apiKeyFilePath, `${apiKey.trim()}\n`)) yield* _(fs.chmod(apiKeyFilePath, 0o600), Effect.orElseSucceed(() => void 0)) + + const credentialsDir = geminiCredentialsPath(accountPath) + yield* _(fs.makeDirectory(credentialsDir, { recursive: true })) + const settingsPath = `${credentialsDir}/settings.json` + yield* _( + fs.writeFileString( + settingsPath, + JSON.stringify(defaultGeminiSettings, null, 2) + "\n" + ) + ) })).pipe( Effect.zipRight(autoSyncState(`chore(state): auth gemini ${accountLabel}`)) ) @@ -96,17 +105,7 @@ export const authGeminiLoginOauth = ( yield* _( fs.writeFileString( settingsPath, - JSON.stringify( - { - security: { - folderTrust: { enabled: false }, - auth: { selectedType: "oauth-personal" }, - approvalPolicy: "never" - } - }, - null, - 2 - ) + "\n" + JSON.stringify(defaultGeminiSettings, null, 2) + "\n" ) ) }), @@ -116,53 +115,5 @@ export const authGeminiLoginOauth = ( ) } -// 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) +export { authGeminiLogout } from "./auth-gemini-logout.js" +export { authGeminiStatus } from "./auth-gemini-status.js" diff --git a/packages/lib/src/usecases/projects-core.ts b/packages/lib/src/usecases/projects-core.ts index ff422de4..420141fe 100644 --- a/packages/lib/src/usecases/projects-core.ts +++ b/packages/lib/src/usecases/projects-core.ts @@ -1,3 +1,4 @@ +import type * as CommandExecutor from "@effect/platform/CommandExecutor" import type { PlatformError } from "@effect/platform/Error" import * as FileSystem from "@effect/platform/FileSystem" import * as Path from "@effect/platform/Path" @@ -6,6 +7,7 @@ import { Effect, pipe } from "effect" import type { ProjectConfig, TemplateConfig } from "../core/domain.js" import { deriveRepoPathParts } from "../core/domain.js" import { readProjectConfig } from "../shell/config.js" +import { runDockerInspectContainerIp } from "../shell/docker.js" import type { ConfigDecodeError, ConfigNotFoundError } from "../shell/errors.js" import { resolveBaseDir } from "../shell/paths.js" import { findDockerGitConfigPaths } from "./docker-git-config-search.js" @@ -20,16 +22,21 @@ export type ProjectLoadError = PlatformError | ConfigNotFoundError | ConfigDecod export const buildSshCommand = ( config: TemplateConfig, - sshKey: string | null -): string => - sshKey === null - ? `ssh ${sshOptions} -p ${config.sshPort} ${config.sshUser}@localhost` - : `ssh -i ${sshKey} ${sshOptions} -p ${config.sshPort} ${config.sshUser}@localhost` + sshKey: string | null, + ipAddress?: string +): string => { + const host = ipAddress ?? "localhost" + const port = ipAddress ? 22 : config.sshPort + return sshKey === null + ? `ssh ${sshOptions} -p ${port} ${config.sshUser}@${host}` + : `ssh -i ${sshKey} ${sshOptions} -p ${port} ${config.sshUser}@${host}` +} export type ProjectSummary = { readonly projectDir: string readonly config: ProjectConfig readonly sshCommand: string + readonly ipAddress?: string | undefined readonly authorizedKeysPath: string readonly authorizedKeysExists: boolean } @@ -45,6 +52,7 @@ export type ProjectItem = { readonly sshPort: number readonly targetDir: string readonly sshCommand: string + readonly ipAddress?: string | undefined readonly sshKeyPath: string | null readonly authorizedKeysPath: string readonly authorizedKeysExists: boolean @@ -73,6 +81,24 @@ type ProjectBase = { readonly config: ProjectConfig } +export const getContainerIpIfInsideContainer = ( + fs: FileSystem.FileSystem, + projectDir: string, + containerName: string +): Effect.Effect => + Effect.gen(function*(_) { + const isInsideContainer = yield* _(fs.exists("/.dockerenv")) + if (!isInsideContainer) { + return + } + return yield* _( + runDockerInspectContainerIp(projectDir, containerName).pipe( + Effect.orElse(() => Effect.succeed("")), + Effect.map((ip) => (ip.length > 0 ? ip : undefined)) + ) + ) + }) + const loadProjectBase = ( configPath: string ): Effect.Effect => @@ -91,21 +117,29 @@ const findProjectConfigPaths = ( export const loadProjectSummary = ( configPath: string, sshKey: string | null -): Effect.Effect => +): Effect.Effect< + ProjectSummary, + ProjectLoadError, + FileSystem.FileSystem | Path.Path | CommandExecutor.CommandExecutor +> => Effect.gen(function*(_) { const { config, fs, path, projectDir } = yield* _(loadProjectBase(configPath)) + + const ipAddress = yield* _(getContainerIpIfInsideContainer(fs, projectDir, config.template.containerName)) + const resolvedAuthorizedKeys = resolveAuthorizedKeysPath( path, projectDir, config.template.authorizedKeysPath ) const authExists = yield* _(fs.exists(resolvedAuthorizedKeys)) - const sshCommand = buildSshCommand(config.template, sshKey) + const sshCommand = buildSshCommand(config.template, sshKey, ipAddress) return { projectDir, config, sshCommand, + ipAddress, authorizedKeysPath: resolvedAuthorizedKeys, authorizedKeysExists: authExists } @@ -139,13 +173,16 @@ const formatDisplayName = (repoUrl: string): string => { export const loadProjectItem = ( configPath: string, sshKey: string | null -): Effect.Effect => +): Effect.Effect => Effect.gen(function*(_) { const { config, fs, path, projectDir } = yield* _(loadProjectBase(configPath)) const template = config.template + + const ipAddress = yield* _(getContainerIpIfInsideContainer(fs, projectDir, template.containerName)) + const resolvedAuthorizedKeys = resolveAuthorizedKeysPath(path, projectDir, template.authorizedKeysPath) const authExists = yield* _(fs.exists(resolvedAuthorizedKeys)) - const sshCommand = buildSshCommand(template, sshKey) + const sshCommand = buildSshCommand(template, sshKey, ipAddress) const displayName = formatDisplayName(template.repoUrl) return { @@ -159,6 +196,7 @@ export const loadProjectItem = ( sshPort: template.sshPort, targetDir: template.targetDir, sshCommand, + ipAddress, sshKeyPath: sshKey, authorizedKeysPath: resolvedAuthorizedKeys, authorizedKeysExists: authExists, diff --git a/packages/lib/src/usecases/projects-list.ts b/packages/lib/src/usecases/projects-list.ts index ce8837e4..cb4b7bb5 100644 --- a/packages/lib/src/usecases/projects-list.ts +++ b/packages/lib/src/usecases/projects-list.ts @@ -26,10 +26,12 @@ import { // EFFECT: Effect // INVARIANT: output is deterministic for a stable filesystem // COMPLEXITY: O(n) where n = |projects| +export type ListProjectsContext = FileSystem.FileSystem | Path.Path | CommandExecutor.CommandExecutor + export const listProjects: Effect.Effect< void, PlatformError, - FileSystem.FileSystem | Path.Path + ListProjectsContext > = pipe( withProjectIndexAndSsh((index, sshKey) => Effect.gen(function*(_) { @@ -78,9 +80,12 @@ const emptyItems = (): ReadonlyArray => [] const collectProjectValues = ( configPaths: ReadonlyArray, sshKey: string | null, - load: (configPath: string, sshKey: string | null) => Effect.Effect, + load: ( + configPath: string, + sshKey: string | null + ) => Effect.Effect, toValue: (value: A) => B -): Effect.Effect, never, FileSystem.FileSystem | Path.Path> => +): Effect.Effect, never, ListProjectsContext> => Effect.gen(function*(_) { const available: Array = [] @@ -102,10 +107,17 @@ const collectProjectValues = ( }) const listProjectValues = ( - load: (configPath: string, sshKey: string | null) => Effect.Effect, + load: ( + configPath: string, + sshKey: string | null + ) => Effect.Effect, toValue: (value: A) => B, empty: () => ReadonlyArray -): Effect.Effect, PlatformError, FileSystem.FileSystem | Path.Path> => +): Effect.Effect< + ReadonlyArray, + PlatformError, + ListProjectsContext +> => pipe( withProjectIndexAndSsh((index, sshKey) => collectProjectValues(index.configPaths, sshKey, load, toValue)), Effect.map((values) => values ?? empty()) @@ -114,7 +126,7 @@ const listProjectValues = ( export const listProjectSummaries: Effect.Effect< ReadonlyArray, PlatformError, - FileSystem.FileSystem | Path.Path + ListProjectsContext > = listProjectValues(loadProjectSummary, renderProjectSummary, emptySummaries) // CHANGE: load docker-git projects for TUI selection @@ -130,7 +142,7 @@ export const listProjectSummaries: Effect.Effect< export const listProjectItems: Effect.Effect< ReadonlyArray, PlatformError, - FileSystem.FileSystem | Path.Path + ListProjectsContext > = listProjectValues(loadProjectItem, (value) => value, emptyItems) // CHANGE: list only running docker-git projects (for "Stop container" UI) @@ -146,7 +158,7 @@ export const listProjectItems: Effect.Effect< export const listRunningProjectItems: Effect.Effect< ReadonlyArray, PlatformError | CommandFailedError, - FileSystem.FileSystem | Path.Path | CommandExecutor.CommandExecutor + ListProjectsContext > = pipe( Effect.all([listProjectItems, runDockerPsNames(process.cwd())]), Effect.map(([items, runningNames]) => items.filter((item) => runningNames.includes(item.containerName))) diff --git a/packages/lib/src/usecases/projects-ssh.ts b/packages/lib/src/usecases/projects-ssh.ts index 67165d88..7f102104 100644 --- a/packages/lib/src/usecases/projects-ssh.ts +++ b/packages/lib/src/usecases/projects-ssh.ts @@ -1,11 +1,11 @@ import type * as CommandExecutor from "@effect/platform/CommandExecutor" import type { PlatformError } from "@effect/platform/Error" -import type { FileSystem as Fs } from "@effect/platform/FileSystem" -import type { Path as PathService } from "@effect/platform/Path" +import * as FileSystem from "@effect/platform/FileSystem" +import type * as Path from "@effect/platform/Path" import { Duration, Effect, pipe, Schedule } from "effect" import { runCommandExitCode, runCommandWithExitCodes } from "../shell/command-runner.js" -import { runDockerComposePsFormatted } from "../shell/docker.js" +import { runDockerComposePsFormatted, runDockerInspectContainerIp } from "../shell/docker.js" import { CommandFailedError, type ConfigDecodeError, @@ -19,6 +19,7 @@ import { buildSshCommand, forEachProjectStatus, formatComposeRows, + getContainerIpIfInsideContainer, parseComposePsOutput, type ProjectItem, renderProjectStatusHeader, @@ -28,6 +29,8 @@ import { runDockerComposeUpWithPortCheck } from "./projects-up.js" import { ensureTerminalCursorVisible } from "./terminal-cursor.js" const buildSshArgs = (item: ProjectItem): ReadonlyArray => { + const host = item.ipAddress ?? "localhost" + const port = item.ipAddress ? 22 : item.sshPort const args: Array = [] if (item.sshKeyPath !== null) { args.push("-i", item.sshKeyPath) @@ -42,13 +45,15 @@ const buildSshArgs = (item: ProjectItem): ReadonlyArray => { "-o", "UserKnownHostsFile=/dev/null", "-p", - String(item.sshPort), - `${item.sshUser}@localhost` + String(port), + `${item.sshUser}@${host}` ) return args } const buildSshProbeArgs = (item: ProjectItem): ReadonlyArray => { + const host = item.ipAddress ?? "localhost" + const port = item.ipAddress ? 22 : item.sshPort const args: Array = [] if (item.sshKeyPath !== null) { args.push("-i", item.sshKeyPath) @@ -68,8 +73,8 @@ const buildSshProbeArgs = (item: ProjectItem): ReadonlyArray => { "-o", "UserKnownHostsFile=/dev/null", "-p", - String(item.sshPort), - `${item.sshUser}@localhost`, + String(port), + `${item.sshUser}@${host}`, "true" ) return args @@ -78,6 +83,8 @@ const buildSshProbeArgs = (item: ProjectItem): ReadonlyArray => { const waitForSshReady = ( item: ProjectItem ): Effect.Effect => { + const host = item.ipAddress ?? "localhost" + const port = item.ipAddress ? 22 : item.sshPort const probe = Effect.gen(function*(_) { const exitCode = yield* _( runCommandExitCode({ @@ -92,7 +99,7 @@ const waitForSshReady = ( }) return pipe( - Effect.log(`Waiting for SSH on localhost:${item.sshPort} ...`), + Effect.log(`Waiting for SSH on ${host}:${port} ...`), Effect.zipRight( Effect.retry( probe, @@ -143,8 +150,6 @@ export const connectProjectSsh = ( // FORMAT THEOREM: forall p: up(p) -> ssh(p) // PURITY: SHELL // EFFECT: Effect -// INVARIANT: docker compose up runs before ssh -// COMPLEXITY: O(1) export const connectProjectSshWithUp = ( item: ProjectItem ): Effect.Effect< @@ -156,15 +161,35 @@ export const connectProjectSshWithUp = ( | PortProbeError | DockerCommandError | PlatformError, - CommandExecutor.CommandExecutor | Fs | PathService + CommandExecutor.CommandExecutor | FileSystem.FileSystem | Path.Path > => - pipe( - Effect.log(`Starting docker compose for ${item.displayName} ...`), - Effect.zipRight(runDockerComposeUpWithPortCheck(item.projectDir)), - Effect.map((template) => ({ ...item, sshPort: template.sshPort })), - Effect.tap((updated) => waitForSshReady(updated)), - Effect.flatMap((updated) => connectProjectSsh(updated)) - ) + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + yield* _(Effect.log(`Starting docker compose for ${item.displayName} ...`)) + const template = yield* _(runDockerComposeUpWithPortCheck(item.projectDir)) + + const isInsideContainer = yield* _(fs.exists("/.dockerenv")) + let ipAddress: string | undefined + if (isInsideContainer) { + const containerIp = yield* _( + runDockerInspectContainerIp(item.projectDir, template.containerName).pipe( + Effect.orElse(() => Effect.succeed("")) + ) + ) + if (containerIp.length > 0) { + ipAddress = containerIp + } + } + + const updated: ProjectItem = { + ...item, + sshPort: template.sshPort, + ipAddress + } + + yield* _(waitForSshReady(updated)) + yield* _(connectProjectSsh(updated)) + }) // CHANGE: show docker compose status for all known docker-git projects // WHY: allow checking active containers without switching directories @@ -179,29 +204,29 @@ export const connectProjectSshWithUp = ( export const listProjectStatus: Effect.Effect< void, PlatformError, - Fs | PathService | CommandExecutor.CommandExecutor -> = Effect.asVoid( - withProjectIndexAndSsh((index, sshKey) => - forEachProjectStatus(index.configPaths, (status) => - pipe( - Effect.log(renderProjectStatusHeader(status)), - Effect.zipRight( - Effect.log(`SSH access: ${buildSshCommand(status.config.template, sshKey)}`) - ), - Effect.zipRight( - runDockerComposePsFormatted(status.projectDir).pipe( - Effect.map((raw) => parseComposePsOutput(raw)), - Effect.map((rows) => formatComposeRows(rows)), - Effect.flatMap((text) => Effect.log(text)), - Effect.matchEffect({ - onFailure: (error: DockerCommandError | PlatformError) => - Effect.logWarning( - `docker compose ps failed for ${status.projectDir}: ${renderError(error)}` - ), - onSuccess: () => Effect.void - }) - ) - ) - )) - ) -) + FileSystem.FileSystem | Path.Path | CommandExecutor.CommandExecutor +> = withProjectIndexAndSsh((index, sshKey) => + forEachProjectStatus(index.configPaths, (status) => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const ipAddress = yield* _( + getContainerIpIfInsideContainer(fs, status.projectDir, status.config.template.containerName) + ) + + yield* _(Effect.log(renderProjectStatusHeader(status))) + yield* _(Effect.log(`SSH access: ${buildSshCommand(status.config.template, sshKey, ipAddress)}`)) + + const raw = yield* _(runDockerComposePsFormatted(status.projectDir)) + const rows = parseComposePsOutput(raw) + const text = formatComposeRows(rows) + yield* _(Effect.log(text)) + }).pipe( + Effect.matchEffect({ + onFailure: (error: DockerCommandError | PlatformError) => + Effect.logWarning( + `docker compose ps failed for ${status.projectDir}: ${renderError(error)}` + ), + onSuccess: () => Effect.void + }) + )) +).pipe(Effect.asVoid) diff --git a/packages/lib/tests/usecases/auth-gemini.test.ts b/packages/lib/tests/usecases/auth-gemini.test.ts new file mode 100644 index 00000000..ec88442f --- /dev/null +++ b/packages/lib/tests/usecases/auth-gemini.test.ts @@ -0,0 +1,69 @@ +import * as FileSystem from "@effect/platform/FileSystem" +import * as Path from "@effect/platform/Path" +import { NodeContext } from "@effect/platform-node" +import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" + +import { authGeminiLogin, geminiAuthRoot } from "../../src/usecases/auth-gemini.js" + +const withTempDir = ( + use: (tempDir: string) => Effect.Effect +): Effect.Effect => + Effect.scoped( + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const tempDir = yield* _( + fs.makeTempDirectoryScoped({ + prefix: "docker-git-auth-gemini-" + }) + ) + return yield* _(use(tempDir)) + }) + ) + +describe("authGeminiLogin", () => { + it.effect("generates settings.json with correct 1:1 configuration", () => + withTempDir((root) => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + + // Mock the environment by setting the auth path to our temp root + const geminiAuthPath = ".docker-git/.orch/auth/gemini" + const accountLabel = "test-account" + // In the real app, resolvePathFromCwd is used. + // For the test, we'll bypass the complex resolution and check if we can call the core logic. + // However, authGeminiLogin calls withGeminiAuth which calls ensureGeminiOrchLayout. + // We need to be careful with where it writes. + + // Let's mock the command to use our temp root as the 'geminiAuthPath' + const relativeGeminiAuthPath = path.join(root, geminiAuthPath) + + yield* _( + authGeminiLogin( + { + _tag: "AuthGeminiLogin", + label: accountLabel, + geminiAuthPath: relativeGeminiAuthPath, + isWeb: false + }, + "test-api-key" + ).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path) + ) + ) + + const settingsPath = path.join(relativeGeminiAuthPath, accountLabel, ".gemini", "settings.json") + const settingsContent = yield* _(fs.readFileString(settingsPath)) + const settings = JSON.parse(settingsContent) + + expect(settings.model.name).toBe("gemini-3.1-pro-preview-yolo") + expect(settings.modelConfigs.customAliases["yolo-ultra"]).toBeDefined() + expect(settings.general.defaultApprovalMode).toBe("auto_edit") + expect(settings.mcpServers.playwright.command).toBe("docker-git-playwright-mcp") + expect(settings.security.folderTrust.enabled).toBe(false) + expect(settings.tools.allowed).toContain("googleSearch") + }) + ).pipe(Effect.provide(NodeContext.layer))) +}) diff --git a/run_gemini.sh b/run_gemini.sh new file mode 100644 index 00000000..f2ee5cc5 --- /dev/null +++ b/run_gemini.sh @@ -0,0 +1,4 @@ +export NO_BROWSER=true +export GEMINI_API_KEY="test" +gemini --prompt "hello" +cat ~/.gemini/settings.json