Skip to content

Commit 021f857

Browse files
authored
feat(auth): add generic per-host git connections via token (#368) (#393)
* Initial commit with task details Adding .gitkeep for PR creation (default mode). This file will be removed when the task is complete. Issue: #368 * feat(auth): add generic per-host git connections via token Implements issue #368: connect to git hosts other than github.com/ gitlab.com (Gitea, Bitbucket, self-hosted, ...) by supplying a token, and lets github/gitlab be configured non-interactively with --token. - CLI: auth git login/status/logout --host/--token/--user - API: GET/POST /auth/git/status|login|logout (token never returned) - Container: HTTPS credential helper resolves per-host GIT_AUTH_TOKEN__<HOST> first, then github/gitlab defaults; host-scoped creds exported to login/SSH - Split entrypoint git template renderers under the 50-line limit - Tests: lib usecase, parser, api service, template render Tokens are stored in the shared env file and never logged. * chore(pr): remove task placeholder
1 parent 5effa47 commit 021f857

29 files changed

Lines changed: 1331 additions & 32 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
"@prover-coder-ai/docker-git": minor
3+
"@effect-template/api": minor
4+
"@effect-template/lib": minor
5+
---
6+
7+
feat(auth): add generic per-host git connections via token
8+
9+
Adds a new `git` auth provider so connections to git hosts other than
10+
github.com/gitlab.com (Gitea, Bitbucket, self-hosted, ...) can be configured
11+
by simply supplying a token, addressing issue #368.
12+
13+
- CLI: `docker-git auth git login --host <host> --token <token> [--user <user>]`,
14+
`docker-git auth git status`, and `docker-git auth git logout --host <host>`.
15+
Tokens are persisted to the shared env file as host-scoped
16+
`GIT_AUTH_TOKEN__<HOST_KEY>` / `GIT_AUTH_USER__<HOST_KEY>` keys.
17+
- API: `GET /auth/git/status`, `POST /auth/git/login`, and `POST /auth/git/logout`.
18+
The status payload reports only the host and HTTPS username — token values
19+
are never returned.
20+
- Container: the in-container HTTPS credential helper now resolves per-host
21+
generic tokens first (matching the CLI/web host normalization: uppercase,
22+
non-alphanumeric → `_`, trimmed), then falls back to the github/gitlab
23+
defaults and the global `GIT_AUTH_TOKEN`. Host-scoped credentials are also
24+
exported to login and SSH shells so clone/push work outside the entrypoint.
25+
26+
This also lets GitHub/GitLab connections be set up non-interactively by
27+
providing a token (`--token`) instead of running an OAuth web flow.

README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,29 @@ bun run docker-git auth claude login --web
2828
bun run docker-git auth grok login --web
2929
```
3030

31+
GitHub и GitLab можно подключить и без OAuth — просто передав токен:
32+
33+
```bash
34+
bun run docker-git auth github login --token <token>
35+
bun run docker-git auth gitlab login --token <token>
36+
```
37+
38+
Для любых других git-хостов (Gitea, Bitbucket, self-hosted и т.д.) есть
39+
универсальный провайдер `git` — подключение задаётся хостом и токеном:
40+
41+
```bash
42+
bun run docker-git auth git login --host git.example.com --token <token>
43+
bun run docker-git auth git login --host git.example.com --token <token> --user deploy-bot
44+
bun run docker-git auth git status
45+
bun run docker-git auth git logout --host git.example.com
46+
```
47+
48+
Токены сохраняются в общий env-файл как `GIT_AUTH_TOKEN__<HOST>` /
49+
`GIT_AUTH_USER__<HOST>`, а внутри контейнера git credential helper сам
50+
подбирает нужный токен по хосту при `clone`/`push` по HTTPS. Команда
51+
`status` показывает только хост и имя пользователя — значения токенов
52+
никогда не выводятся.
53+
3154
Для запуска WEB версии:
3255
```bash
3356
bun run docker-git -- browser

packages/api/src/api/contracts.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,26 @@ export type GitlabAuthStatus = {
192192
readonly tokens: ReadonlyArray<GitlabAuthTokenStatus>
193193
}
194194

195+
export type GitAuthConnectionStatus = {
196+
readonly host: string
197+
readonly user: string
198+
}
199+
200+
export type GitAuthStatus = {
201+
readonly summary: string
202+
readonly connections: ReadonlyArray<GitAuthConnectionStatus>
203+
}
204+
205+
export type GitAuthLoginRequest = {
206+
readonly host: string
207+
readonly token?: string | null | undefined
208+
readonly user?: string | null | undefined
209+
}
210+
211+
export type GitAuthLogoutRequest = {
212+
readonly host: string
213+
}
214+
195215
export type GithubAuthLoginRequest = {
196216
readonly label?: string | null | undefined
197217
readonly token?: string | null | undefined

packages/api/src/api/schema.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,16 @@ export const GitlabAuthLoginRequestSchema = Schema.Struct({
6565
token: OptionalNullableString
6666
})
6767

68+
export const GitAuthLoginRequestSchema = Schema.Struct({
69+
host: Schema.String,
70+
token: OptionalNullableString,
71+
user: OptionalNullableString
72+
})
73+
74+
export const GitAuthLogoutRequestSchema = Schema.Struct({
75+
host: Schema.String
76+
})
77+
6878
export const AuthMenuFlowSchema = Schema.Literal(
6979
"GithubRemove",
7080
"GitSet",
@@ -352,6 +362,8 @@ export const TerminalSessionSchema = Schema.Struct({
352362
export type CreateProjectRequestInput = Schema.Schema.Type<typeof CreateProjectRequestSchema>
353363
export type GithubAuthLoginRequestInput = Schema.Schema.Type<typeof GithubAuthLoginRequestSchema>
354364
export type GitlabAuthLoginRequestInput = Schema.Schema.Type<typeof GitlabAuthLoginRequestSchema>
365+
export type GitAuthLoginRequestInput = Schema.Schema.Type<typeof GitAuthLoginRequestSchema>
366+
export type GitAuthLogoutRequestInput = Schema.Schema.Type<typeof GitAuthLogoutRequestSchema>
355367
export type AuthMenuRequestInput = Schema.Schema.Type<typeof AuthMenuRequestSchema>
356368
export type AuthTerminalSessionRequestInput = Schema.Schema.Type<typeof AuthTerminalSessionRequestSchema>
357369
export type GithubAuthLogoutRequestInput = Schema.Schema.Type<typeof GithubAuthLogoutRequestSchema>

packages/api/src/http.ts

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ import {
2626
CreateProjectRequestSchema,
2727
ExchangePollRequestSchema,
2828
ExchangeSubscribeRequestSchema,
29+
GitAuthLoginRequestSchema,
30+
GitAuthLogoutRequestSchema,
2931
GitlabAuthLoginRequestSchema,
3032
GitlabAuthLogoutRequestSchema,
3133
GrokAuthLogoutRequestSchema,
@@ -48,14 +50,17 @@ import { defaultProjectsRoot } from "@effect-template/lib/usecases/menu-helpers"
4850
import { resolveWorkspaceRoot } from "@effect-template/lib/shell/workspace-root"
4951
import {
5052
importCodexAuth,
53+
loginGitAuth,
5154
loginGitlabAuth,
5255
loginGithubAuth,
5356
logoutCodexAuth,
5457
logoutGrokAuth,
58+
logoutGitAuth,
5559
logoutGitlabAuth,
5660
logoutGithubAuth,
5761
readCodexAuthStatus,
5862
readGrokAuthStatus,
63+
readGitAuthStatus,
5964
readGitlabAuthStatus,
6065
readGithubAuthStatus,
6166
} from "./services/auth.js"
@@ -450,6 +455,8 @@ const readGithubAuthLoginRequest = () => HttpServerRequest.schemaBodyJson(Github
450455
const readGithubAuthLogoutRequest = () => HttpServerRequest.schemaBodyJson(GithubAuthLogoutRequestSchema)
451456
const readGitlabAuthLoginRequest = () => HttpServerRequest.schemaBodyJson(GitlabAuthLoginRequestSchema)
452457
const readGitlabAuthLogoutRequest = () => HttpServerRequest.schemaBodyJson(GitlabAuthLogoutRequestSchema)
458+
const readGitAuthLoginRequest = () => HttpServerRequest.schemaBodyJson(GitAuthLoginRequestSchema)
459+
const readGitAuthLogoutRequest = () => HttpServerRequest.schemaBodyJson(GitAuthLogoutRequestSchema)
453460
const readAuthMenuRequest = () => HttpServerRequest.schemaBodyJson(AuthMenuRequestSchema)
454461
const readAuthTerminalSessionRequest = () => HttpServerRequest.schemaBodyJson(AuthTerminalSessionRequestSchema)
455462
const readCodexAuthImportRequest = () => HttpServerRequest.schemaBodyJson(CodexAuthImportRequestSchema)
@@ -1046,7 +1053,7 @@ export const makeRouter = () => {
10461053
)
10471054
)
10481055

1049-
const withAuth = withCoreRoutes.pipe(
1056+
const withAuthHead = withCoreRoutes.pipe(
10501057
HttpRouter.get(
10511058
"/auth/github/status",
10521059
Effect.gen(function*(_) {
@@ -1061,6 +1068,13 @@ export const makeRouter = () => {
10611068
return yield* _(jsonResponse({ status }, 200))
10621069
}).pipe(Effect.catchAll(errorResponse))
10631070
),
1071+
HttpRouter.get(
1072+
"/auth/git/status",
1073+
Effect.gen(function*(_) {
1074+
const status = yield* _(readGitAuthStatus())
1075+
return yield* _(jsonResponse({ status }, 200))
1076+
}).pipe(Effect.catchAll(errorResponse))
1077+
),
10641078
HttpRouter.get(
10651079
"/auth/grok/status",
10661080
Effect.gen(function*(_) {
@@ -1121,14 +1135,35 @@ export const makeRouter = () => {
11211135
return yield* _(jsonResponse({ ok: true, status }, 201))
11221136
}).pipe(Effect.catchAll(errorResponse))
11231137
),
1138+
HttpRouter.post(
1139+
"/auth/git/login",
1140+
Effect.gen(function*(_) {
1141+
const request = yield* _(readGitAuthLoginRequest())
1142+
const status = yield* _(loginGitAuth(request))
1143+
return yield* _(jsonResponse({ ok: true, status }, 201))
1144+
}).pipe(Effect.catchAll(errorResponse))
1145+
),
11241146
HttpRouter.post(
11251147
"/auth/menu",
11261148
Effect.gen(function*(_) {
11271149
const request = yield* _(readAuthMenuRequest())
11281150
const snapshot = yield* _(runAuthMenuFlow(request))
11291151
return yield* _(jsonResponse({ ok: true, snapshot }, 200))
11301152
}).pipe(Effect.catchAll(errorResponse))
1131-
),
1153+
)
1154+
)
1155+
1156+
// CHANGE: split the auth router pipe into two chains
1157+
// WHY: Effect's `.pipe` overloads accept at most 20 arguments; adding the generic git routes exceeded that limit
1158+
// QUOTE(ТЗ): "реализовать возможность добавлять git подключения отличных от gitlab, github"
1159+
// REF: issue-368
1160+
// SOURCE: n/a
1161+
// FORMAT THEOREM: forall r in routes: route(withAuth) ⊇ route(withAuthHead) ∪ route(tail)
1162+
// PURITY: SHELL
1163+
// EFFECT: HttpRouter composition only
1164+
// INVARIANT: route set is preserved; only the pipe arity is reduced
1165+
// COMPLEXITY: O(1)
1166+
const withAuth = withAuthHead.pipe(
11321167
HttpRouter.post(
11331168
"/auth/terminal-sessions",
11341169
Effect.gen(function*(_) {
@@ -1173,6 +1208,14 @@ export const makeRouter = () => {
11731208
return yield* _(jsonResponse({ ok: true, status }, 200))
11741209
}).pipe(Effect.catchAll(errorResponse))
11751210
),
1211+
HttpRouter.post(
1212+
"/auth/git/logout",
1213+
Effect.gen(function*(_) {
1214+
const request = yield* _(readGitAuthLogoutRequest())
1215+
const status = yield* _(logoutGitAuth(request))
1216+
return yield* _(jsonResponse({ ok: true, status }, 200))
1217+
}).pipe(Effect.catchAll(errorResponse))
1218+
),
11761219
HttpRouter.post(
11771220
"/auth/grok/logout",
11781221
Effect.gen(function*(_) {

packages/api/src/services/auth.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { parseGithubRepoUrl, parseGitlabRepoUrl } from "@effect-template/lib/cor
77
import { CommandFailedError } from "@effect-template/lib/shell/errors"
88
import { authCodexLogin as runCodexLogin } from "@effect-template/lib/usecases/auth-codex"
99
import { authGrokLogout as runGrokLogout } from "@effect-template/lib/usecases/auth-grok-logout"
10+
import { authGitLogin as runGitLogin, authGitLogout as runGitLogout, listGitConnections } from "@effect-template/lib/usecases/auth-git"
1011
import { authGitlabLogin as runGitlabLogin, authGitlabLogout as runGitlabLogout, listGitlabTokens } from "@effect-template/lib/usecases/auth-gitlab"
1112
import { authGithubLogin as runGithubLogin, authGithubLogout as runGithubLogout } from "@effect-template/lib/usecases/auth-github"
1213
import { readEnvText } from "@effect-template/lib/usecases/env-file"
@@ -38,6 +39,9 @@ import type {
3839
CodexAuthStatus,
3940
GrokAuthLogoutRequest,
4041
GrokAuthStatus,
42+
GitAuthLoginRequest,
43+
GitAuthLogoutRequest,
44+
GitAuthStatus,
4145
GitlabAuthLoginRequest,
4246
GitlabAuthLogoutRequest,
4347
GitlabAuthStatus,
@@ -405,6 +409,83 @@ export const logoutGitlabAuth = (request: GitlabAuthLogoutRequest) =>
405409
return yield* _(readGitlabAuthTokens(githubAuthEnvGlobalPath))
406410
})
407411

412+
// CHANGE: read generic per-host git connections for the controller status endpoint
413+
// WHY: issue #368 wants to add git connections to providers other than github/gitlab
414+
// QUOTE(ТЗ): "реализовать возможность добавлять git подключения отличных от gitlab, github"
415+
// REF: issue-368
416+
// SOURCE: https://git-scm.com/docs/gitcredentials
417+
// FORMAT THEOREM: forall env: readGitAuthStatus(env).connections = listGitConnections(env) without secrets
418+
// PURITY: SHELL
419+
// EFFECT: Effect<GitAuthStatus, PlatformError, FileSystem | Path>
420+
// INVARIANT: token values are never included in the returned status
421+
// COMPLEXITY: O(n) where n = |env entries|
422+
const buildGitStatusSummary = (connections: GitAuthStatus["connections"]): string =>
423+
connections.length === 0
424+
? "No generic git connections."
425+
: `Git connections (${connections.length}):`
426+
427+
const readGitAuthConnections = (
428+
envGlobalPath: string
429+
): Effect.Effect<GitAuthStatus, PlatformError, FileSystem.FileSystem | Path.Path> =>
430+
Effect.gen(function*(_) {
431+
const fs = yield* _(FileSystem.FileSystem)
432+
const path = yield* _(Path.Path)
433+
const resolvedEnvPath = resolveControllerEnvPath(path, envGlobalPath)
434+
const envText = yield* _(readEnvText(fs, resolvedEnvPath))
435+
const connections = listGitConnections(envText).map((entry) => ({
436+
host: entry.host,
437+
user: entry.user
438+
}))
439+
return {
440+
summary: buildGitStatusSummary(connections),
441+
connections
442+
} satisfies GitAuthStatus
443+
})
444+
445+
export const readGitAuthStatus = (): Effect.Effect<
446+
GitAuthStatus,
447+
PlatformError,
448+
FileSystem.FileSystem | Path.Path
449+
> => readGitAuthConnections(githubAuthEnvGlobalPath)
450+
451+
export const loginGitAuth = (request: GitAuthLoginRequest) =>
452+
Effect.gen(function*(_) {
453+
const host = (request.host ?? "").trim()
454+
if (host.length === 0) {
455+
return yield* _(Effect.fail(new ApiBadRequestError({ message: "Git host is required." })))
456+
}
457+
const token = request.token?.trim() ?? ""
458+
if (token.length === 0) {
459+
return yield* _(Effect.fail(new ApiBadRequestError({ message: "Git token is required." })))
460+
}
461+
yield* _(
462+
runGitLogin({
463+
_tag: "AuthGitLogin",
464+
host,
465+
token,
466+
user: request.user ?? null,
467+
envGlobalPath: githubAuthEnvGlobalPath
468+
})
469+
)
470+
return yield* _(readGitAuthConnections(githubAuthEnvGlobalPath))
471+
})
472+
473+
export const logoutGitAuth = (request: GitAuthLogoutRequest) =>
474+
Effect.gen(function*(_) {
475+
const host = (request.host ?? "").trim()
476+
if (host.length === 0) {
477+
return yield* _(Effect.fail(new ApiBadRequestError({ message: "Git host is required." })))
478+
}
479+
yield* _(
480+
runGitLogout({
481+
_tag: "AuthGitLogout",
482+
host,
483+
envGlobalPath: githubAuthEnvGlobalPath
484+
})
485+
)
486+
return yield* _(readGitAuthConnections(githubAuthEnvGlobalPath))
487+
})
488+
408489
const codexAuthStatus = (
409490
present: boolean,
410491
label: string,

0 commit comments

Comments
 (0)