Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .changeset/generic-git-auth-connections.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
"@prover-coder-ai/docker-git": minor
"@effect-template/api": minor
"@effect-template/lib": minor
---

feat(auth): add generic per-host git connections via token

Adds a new `git` auth provider so connections to git hosts other than
github.com/gitlab.com (Gitea, Bitbucket, self-hosted, ...) can be configured
by simply supplying a token, addressing issue #368.

- CLI: `docker-git auth git login --host <host> --token <token> [--user <user>]`,
`docker-git auth git status`, and `docker-git auth git logout --host <host>`.
Tokens are persisted to the shared env file as host-scoped
`GIT_AUTH_TOKEN__<HOST_KEY>` / `GIT_AUTH_USER__<HOST_KEY>` keys.
- API: `GET /auth/git/status`, `POST /auth/git/login`, and `POST /auth/git/logout`.
The status payload reports only the host and HTTPS username — token values
are never returned.
- Container: the in-container HTTPS credential helper now resolves per-host
generic tokens first (matching the CLI/web host normalization: uppercase,
non-alphanumeric → `_`, trimmed), then falls back to the github/gitlab
defaults and the global `GIT_AUTH_TOKEN`. Host-scoped credentials are also
exported to login and SSH shells so clone/push work outside the entrypoint.

This also lets GitHub/GitLab connections be set up non-interactively by
providing a token (`--token`) instead of running an OAuth web flow.
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,29 @@ bun run docker-git auth claude login --web
bun run docker-git auth grok login --web
```

GitHub и GitLab можно подключить и без OAuth — просто передав токен:

```bash
bun run docker-git auth github login --token <token>
bun run docker-git auth gitlab login --token <token>
```

Для любых других git-хостов (Gitea, Bitbucket, self-hosted и т.д.) есть
универсальный провайдер `git` — подключение задаётся хостом и токеном:

```bash
bun run docker-git auth git login --host git.example.com --token <token>
bun run docker-git auth git login --host git.example.com --token <token> --user deploy-bot
bun run docker-git auth git status
bun run docker-git auth git logout --host git.example.com
```

Токены сохраняются в общий env-файл как `GIT_AUTH_TOKEN__<HOST>` /
`GIT_AUTH_USER__<HOST>`, а внутри контейнера git credential helper сам
подбирает нужный токен по хосту при `clone`/`push` по HTTPS. Команда
`status` показывает только хост и имя пользователя — значения токенов
никогда не выводятся.

Для запуска WEB версии:
```bash
bun run docker-git -- browser
Expand Down
20 changes: 20 additions & 0 deletions packages/api/src/api/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,26 @@ export type GitlabAuthStatus = {
readonly tokens: ReadonlyArray<GitlabAuthTokenStatus>
}

export type GitAuthConnectionStatus = {
readonly host: string
readonly user: string
}

export type GitAuthStatus = {
readonly summary: string
readonly connections: ReadonlyArray<GitAuthConnectionStatus>
}

export type GitAuthLoginRequest = {
readonly host: string
readonly token?: string | null | undefined
readonly user?: string | null | undefined
}

export type GitAuthLogoutRequest = {
readonly host: string
}

export type GithubAuthLoginRequest = {
readonly label?: string | null | undefined
readonly token?: string | null | undefined
Expand Down
12 changes: 12 additions & 0 deletions packages/api/src/api/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,16 @@ export const GitlabAuthLoginRequestSchema = Schema.Struct({
token: OptionalNullableString
})

export const GitAuthLoginRequestSchema = Schema.Struct({
host: Schema.String,
token: OptionalNullableString,
user: OptionalNullableString
})

export const GitAuthLogoutRequestSchema = Schema.Struct({
host: Schema.String
})

export const AuthMenuFlowSchema = Schema.Literal(
"GithubRemove",
"GitSet",
Expand Down Expand Up @@ -352,6 +362,8 @@ export const TerminalSessionSchema = Schema.Struct({
export type CreateProjectRequestInput = Schema.Schema.Type<typeof CreateProjectRequestSchema>
export type GithubAuthLoginRequestInput = Schema.Schema.Type<typeof GithubAuthLoginRequestSchema>
export type GitlabAuthLoginRequestInput = Schema.Schema.Type<typeof GitlabAuthLoginRequestSchema>
export type GitAuthLoginRequestInput = Schema.Schema.Type<typeof GitAuthLoginRequestSchema>
export type GitAuthLogoutRequestInput = Schema.Schema.Type<typeof GitAuthLogoutRequestSchema>
export type AuthMenuRequestInput = Schema.Schema.Type<typeof AuthMenuRequestSchema>
export type AuthTerminalSessionRequestInput = Schema.Schema.Type<typeof AuthTerminalSessionRequestSchema>
export type GithubAuthLogoutRequestInput = Schema.Schema.Type<typeof GithubAuthLogoutRequestSchema>
Expand Down
47 changes: 45 additions & 2 deletions packages/api/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import {
CreateProjectRequestSchema,
ExchangePollRequestSchema,
ExchangeSubscribeRequestSchema,
GitAuthLoginRequestSchema,
GitAuthLogoutRequestSchema,
GitlabAuthLoginRequestSchema,
GitlabAuthLogoutRequestSchema,
GrokAuthLogoutRequestSchema,
Expand All @@ -48,14 +50,17 @@ import { defaultProjectsRoot } from "@effect-template/lib/usecases/menu-helpers"
import { resolveWorkspaceRoot } from "@effect-template/lib/shell/workspace-root"
import {
importCodexAuth,
loginGitAuth,
loginGitlabAuth,
loginGithubAuth,
logoutCodexAuth,
logoutGrokAuth,
logoutGitAuth,
logoutGitlabAuth,
logoutGithubAuth,
readCodexAuthStatus,
readGrokAuthStatus,
readGitAuthStatus,
readGitlabAuthStatus,
readGithubAuthStatus,
} from "./services/auth.js"
Expand Down Expand Up @@ -450,6 +455,8 @@ const readGithubAuthLoginRequest = () => HttpServerRequest.schemaBodyJson(Github
const readGithubAuthLogoutRequest = () => HttpServerRequest.schemaBodyJson(GithubAuthLogoutRequestSchema)
const readGitlabAuthLoginRequest = () => HttpServerRequest.schemaBodyJson(GitlabAuthLoginRequestSchema)
const readGitlabAuthLogoutRequest = () => HttpServerRequest.schemaBodyJson(GitlabAuthLogoutRequestSchema)
const readGitAuthLoginRequest = () => HttpServerRequest.schemaBodyJson(GitAuthLoginRequestSchema)
const readGitAuthLogoutRequest = () => HttpServerRequest.schemaBodyJson(GitAuthLogoutRequestSchema)
const readAuthMenuRequest = () => HttpServerRequest.schemaBodyJson(AuthMenuRequestSchema)
const readAuthTerminalSessionRequest = () => HttpServerRequest.schemaBodyJson(AuthTerminalSessionRequestSchema)
const readCodexAuthImportRequest = () => HttpServerRequest.schemaBodyJson(CodexAuthImportRequestSchema)
Expand Down Expand Up @@ -1046,7 +1053,7 @@ export const makeRouter = () => {
)
)

const withAuth = withCoreRoutes.pipe(
const withAuthHead = withCoreRoutes.pipe(
HttpRouter.get(
"/auth/github/status",
Effect.gen(function*(_) {
Expand All @@ -1061,6 +1068,13 @@ export const makeRouter = () => {
return yield* _(jsonResponse({ status }, 200))
}).pipe(Effect.catchAll(errorResponse))
),
HttpRouter.get(
"/auth/git/status",
Effect.gen(function*(_) {
const status = yield* _(readGitAuthStatus())
return yield* _(jsonResponse({ status }, 200))
}).pipe(Effect.catchAll(errorResponse))
),
HttpRouter.get(
"/auth/grok/status",
Effect.gen(function*(_) {
Expand Down Expand Up @@ -1121,14 +1135,35 @@ export const makeRouter = () => {
return yield* _(jsonResponse({ ok: true, status }, 201))
}).pipe(Effect.catchAll(errorResponse))
),
HttpRouter.post(
"/auth/git/login",
Effect.gen(function*(_) {
const request = yield* _(readGitAuthLoginRequest())
const status = yield* _(loginGitAuth(request))
return yield* _(jsonResponse({ ok: true, status }, 201))
}).pipe(Effect.catchAll(errorResponse))
),
HttpRouter.post(
"/auth/menu",
Effect.gen(function*(_) {
const request = yield* _(readAuthMenuRequest())
const snapshot = yield* _(runAuthMenuFlow(request))
return yield* _(jsonResponse({ ok: true, snapshot }, 200))
}).pipe(Effect.catchAll(errorResponse))
),
)
)

// CHANGE: split the auth router pipe into two chains
// WHY: Effect's `.pipe` overloads accept at most 20 arguments; adding the generic git routes exceeded that limit
// QUOTE(ТЗ): "реализовать возможность добавлять git подключения отличных от gitlab, github"
// REF: issue-368
// SOURCE: n/a
// FORMAT THEOREM: forall r in routes: route(withAuth) ⊇ route(withAuthHead) ∪ route(tail)
// PURITY: SHELL
// EFFECT: HttpRouter composition only
// INVARIANT: route set is preserved; only the pipe arity is reduced
// COMPLEXITY: O(1)
const withAuth = withAuthHead.pipe(
HttpRouter.post(
"/auth/terminal-sessions",
Effect.gen(function*(_) {
Expand Down Expand Up @@ -1173,6 +1208,14 @@ export const makeRouter = () => {
return yield* _(jsonResponse({ ok: true, status }, 200))
}).pipe(Effect.catchAll(errorResponse))
),
HttpRouter.post(
"/auth/git/logout",
Effect.gen(function*(_) {
const request = yield* _(readGitAuthLogoutRequest())
const status = yield* _(logoutGitAuth(request))
return yield* _(jsonResponse({ ok: true, status }, 200))
}).pipe(Effect.catchAll(errorResponse))
),
HttpRouter.post(
"/auth/grok/logout",
Effect.gen(function*(_) {
Expand Down
81 changes: 81 additions & 0 deletions packages/api/src/services/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { parseGithubRepoUrl, parseGitlabRepoUrl } from "@effect-template/lib/cor
import { CommandFailedError } from "@effect-template/lib/shell/errors"
import { authCodexLogin as runCodexLogin } from "@effect-template/lib/usecases/auth-codex"
import { authGrokLogout as runGrokLogout } from "@effect-template/lib/usecases/auth-grok-logout"
import { authGitLogin as runGitLogin, authGitLogout as runGitLogout, listGitConnections } from "@effect-template/lib/usecases/auth-git"
import { authGitlabLogin as runGitlabLogin, authGitlabLogout as runGitlabLogout, listGitlabTokens } from "@effect-template/lib/usecases/auth-gitlab"
import { authGithubLogin as runGithubLogin, authGithubLogout as runGithubLogout } from "@effect-template/lib/usecases/auth-github"
import { readEnvText } from "@effect-template/lib/usecases/env-file"
Expand Down Expand Up @@ -38,6 +39,9 @@ import type {
CodexAuthStatus,
GrokAuthLogoutRequest,
GrokAuthStatus,
GitAuthLoginRequest,
GitAuthLogoutRequest,
GitAuthStatus,
GitlabAuthLoginRequest,
GitlabAuthLogoutRequest,
GitlabAuthStatus,
Expand Down Expand Up @@ -405,6 +409,83 @@ export const logoutGitlabAuth = (request: GitlabAuthLogoutRequest) =>
return yield* _(readGitlabAuthTokens(githubAuthEnvGlobalPath))
})

// CHANGE: read generic per-host git connections for the controller status endpoint
// WHY: issue #368 wants to add git connections to providers other than github/gitlab
// QUOTE(ТЗ): "реализовать возможность добавлять git подключения отличных от gitlab, github"
// REF: issue-368
// SOURCE: https://git-scm.com/docs/gitcredentials
// FORMAT THEOREM: forall env: readGitAuthStatus(env).connections = listGitConnections(env) without secrets
// PURITY: SHELL
// EFFECT: Effect<GitAuthStatus, PlatformError, FileSystem | Path>
// INVARIANT: token values are never included in the returned status
// COMPLEXITY: O(n) where n = |env entries|
const buildGitStatusSummary = (connections: GitAuthStatus["connections"]): string =>
connections.length === 0
? "No generic git connections."
: `Git connections (${connections.length}):`

const readGitAuthConnections = (
envGlobalPath: string
): Effect.Effect<GitAuthStatus, PlatformError, FileSystem.FileSystem | Path.Path> =>
Effect.gen(function*(_) {
const fs = yield* _(FileSystem.FileSystem)
const path = yield* _(Path.Path)
const resolvedEnvPath = resolveControllerEnvPath(path, envGlobalPath)
const envText = yield* _(readEnvText(fs, resolvedEnvPath))
const connections = listGitConnections(envText).map((entry) => ({
host: entry.host,
user: entry.user
}))
return {
summary: buildGitStatusSummary(connections),
connections
} satisfies GitAuthStatus
})

export const readGitAuthStatus = (): Effect.Effect<
GitAuthStatus,
PlatformError,
FileSystem.FileSystem | Path.Path
> => readGitAuthConnections(githubAuthEnvGlobalPath)

export const loginGitAuth = (request: GitAuthLoginRequest) =>
Effect.gen(function*(_) {
const host = (request.host ?? "").trim()
if (host.length === 0) {
return yield* _(Effect.fail(new ApiBadRequestError({ message: "Git host is required." })))
}
const token = request.token?.trim() ?? ""
if (token.length === 0) {
return yield* _(Effect.fail(new ApiBadRequestError({ message: "Git token is required." })))
}
yield* _(
runGitLogin({
_tag: "AuthGitLogin",
host,
token,
user: request.user ?? null,
envGlobalPath: githubAuthEnvGlobalPath
})
)
return yield* _(readGitAuthConnections(githubAuthEnvGlobalPath))
})

export const logoutGitAuth = (request: GitAuthLogoutRequest) =>
Effect.gen(function*(_) {
const host = (request.host ?? "").trim()
if (host.length === 0) {
return yield* _(Effect.fail(new ApiBadRequestError({ message: "Git host is required." })))
}
yield* _(
runGitLogout({
_tag: "AuthGitLogout",
host,
envGlobalPath: githubAuthEnvGlobalPath
})
)
return yield* _(readGitAuthConnections(githubAuthEnvGlobalPath))
})

const codexAuthStatus = (
present: boolean,
label: string,
Expand Down
Loading
Loading