diff --git a/.changeset/generic-git-auth-connections.md b/.changeset/generic-git-auth-connections.md new file mode 100644 index 00000000..aed4fa7b --- /dev/null +++ b/.changeset/generic-git-auth-connections.md @@ -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 --token [--user ]`, + `docker-git auth git status`, and `docker-git auth git logout --host `. + Tokens are persisted to the shared env file as host-scoped + `GIT_AUTH_TOKEN__` / `GIT_AUTH_USER__` 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. diff --git a/README.md b/README.md index e7b057c1..c5a948a4 100644 --- a/README.md +++ b/README.md @@ -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 +bun run docker-git auth gitlab login --token +``` + +Для любых других git-хостов (Gitea, Bitbucket, self-hosted и т.д.) есть +универсальный провайдер `git` — подключение задаётся хостом и токеном: + +```bash +bun run docker-git auth git login --host git.example.com --token +bun run docker-git auth git login --host git.example.com --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__` / +`GIT_AUTH_USER__`, а внутри контейнера git credential helper сам +подбирает нужный токен по хосту при `clone`/`push` по HTTPS. Команда +`status` показывает только хост и имя пользователя — значения токенов +никогда не выводятся. + Для запуска WEB версии: ```bash bun run docker-git -- browser diff --git a/packages/api/src/api/contracts.ts b/packages/api/src/api/contracts.ts index 4fbcf4ee..21b652ea 100644 --- a/packages/api/src/api/contracts.ts +++ b/packages/api/src/api/contracts.ts @@ -192,6 +192,26 @@ export type GitlabAuthStatus = { readonly tokens: ReadonlyArray } +export type GitAuthConnectionStatus = { + readonly host: string + readonly user: string +} + +export type GitAuthStatus = { + readonly summary: string + readonly connections: ReadonlyArray +} + +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 diff --git a/packages/api/src/api/schema.ts b/packages/api/src/api/schema.ts index 1d5a2c94..bc629ab2 100644 --- a/packages/api/src/api/schema.ts +++ b/packages/api/src/api/schema.ts @@ -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", @@ -352,6 +362,8 @@ export const TerminalSessionSchema = Schema.Struct({ export type CreateProjectRequestInput = Schema.Schema.Type export type GithubAuthLoginRequestInput = Schema.Schema.Type export type GitlabAuthLoginRequestInput = Schema.Schema.Type +export type GitAuthLoginRequestInput = Schema.Schema.Type +export type GitAuthLogoutRequestInput = Schema.Schema.Type export type AuthMenuRequestInput = Schema.Schema.Type export type AuthTerminalSessionRequestInput = Schema.Schema.Type export type GithubAuthLogoutRequestInput = Schema.Schema.Type diff --git a/packages/api/src/http.ts b/packages/api/src/http.ts index 91b5e2c6..2d5d9100 100644 --- a/packages/api/src/http.ts +++ b/packages/api/src/http.ts @@ -26,6 +26,8 @@ import { CreateProjectRequestSchema, ExchangePollRequestSchema, ExchangeSubscribeRequestSchema, + GitAuthLoginRequestSchema, + GitAuthLogoutRequestSchema, GitlabAuthLoginRequestSchema, GitlabAuthLogoutRequestSchema, GrokAuthLogoutRequestSchema, @@ -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" @@ -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) @@ -1046,7 +1053,7 @@ export const makeRouter = () => { ) ) - const withAuth = withCoreRoutes.pipe( + const withAuthHead = withCoreRoutes.pipe( HttpRouter.get( "/auth/github/status", Effect.gen(function*(_) { @@ -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*(_) { @@ -1121,6 +1135,14 @@ 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*(_) { @@ -1128,7 +1150,20 @@ export const makeRouter = () => { 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*(_) { @@ -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*(_) { diff --git a/packages/api/src/services/auth.ts b/packages/api/src/services/auth.ts index bc219786..593e4a15 100644 --- a/packages/api/src/services/auth.ts +++ b/packages/api/src/services/auth.ts @@ -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" @@ -38,6 +39,9 @@ import type { CodexAuthStatus, GrokAuthLogoutRequest, GrokAuthStatus, + GitAuthLoginRequest, + GitAuthLogoutRequest, + GitAuthStatus, GitlabAuthLoginRequest, GitlabAuthLogoutRequest, GitlabAuthStatus, @@ -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 +// 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 => + 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, diff --git a/packages/api/tests/auth.test.ts b/packages/api/tests/auth.test.ts index 9b81e833..12330b1b 100644 --- a/packages/api/tests/auth.test.ts +++ b/packages/api/tests/auth.test.ts @@ -15,9 +15,12 @@ import { ensureGithubAuthForCreate, ensureGitlabAuthForCreate, importCodexAuth, + loginGitAuth, logoutCodexAuth, + logoutGitAuth, logoutGrokAuth, readCodexAuthStatus, + readGitAuthStatus, readGrokAuthStatus, readGitlabAuthStatus, readGithubAuthStatus @@ -537,4 +540,107 @@ describe("api auth", () => { expect(status.message).toBe("Grok not connected (team-a).") }) ).pipe(Effect.provide(NodeContext.layer))) + + // CHANGE: cover the generic per-host git auth login/status/logout endpoints + // WHY: issue #368 wants git connections to providers other than github/gitlab via token + // QUOTE(ТЗ): "реализовать возможность добавлять git подключения отличных от gitlab, github ... просто здавая токен" + // REF: issue-368 + // SOURCE: https://git-scm.com/docs/gitcredentials + // FORMAT THEOREM: login(host, token) then status() reports {host_key, user} and NEVER the token + // PURITY: SHELL (filesystem-backed Effects) + // EFFECT: Effect + // INVARIANT: token values are never serialized into the status payload + // COMPLEXITY: O(n) where n = |env entries| + it.effect("logs in, lists and logs out a generic per-host git connection without leaking the token", () => + withTempDir((root) => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const projectsRoot = path.join(root, ".docker-git") + const envDir = path.join(projectsRoot, ".orch", "env") + const envPath = path.join(envDir, "global.env") + + yield* _(fs.makeDirectory(envDir, { recursive: true })) + yield* _(fs.writeFileString(envPath, "# docker-git env\n")) + + const afterLogin = yield* _( + withProjectsRoot( + projectsRoot, + withWorkingDirectory( + root, + loginGitAuth({ host: "git.example.com", token: "secret-token", user: "deploy-bot" }) + ) + ) + ) + + expect(afterLogin.summary).toBe("Git connections (1):") + expect(afterLogin.connections).toEqual([{ host: "GIT_EXAMPLE_COM", user: "deploy-bot" }]) + // SECURITY: the status payload must never carry token values + expect(JSON.stringify(afterLogin)).not.toContain("secret-token") + + const envText = yield* _(fs.readFileString(envPath)) + expect(envText).toContain("GIT_AUTH_TOKEN__GIT_EXAMPLE_COM=secret-token") + expect(envText).toContain("GIT_AUTH_USER__GIT_EXAMPLE_COM=deploy-bot") + + const status = yield* _( + withProjectsRoot( + projectsRoot, + withWorkingDirectory(root, readGitAuthStatus()) + ) + ) + expect(status.connections).toEqual([{ host: "GIT_EXAMPLE_COM", user: "deploy-bot" }]) + + const afterLogout = yield* _( + withProjectsRoot( + projectsRoot, + withWorkingDirectory( + root, + logoutGitAuth({ host: "git.example.com" }) + ) + ) + ) + expect(afterLogout.summary).toBe("No generic git connections.") + expect(afterLogout.connections).toEqual([]) + + const finalEnv = yield* _(fs.readFileString(envPath)) + expect(finalEnv).not.toContain("GIT_AUTH_TOKEN__GIT_EXAMPLE_COM") + expect(finalEnv).not.toContain("GIT_AUTH_USER__GIT_EXAMPLE_COM") + }) + ).pipe(Effect.provide(NodeContext.layer))) + + it.effect("rejects generic git login when host or token is missing", () => + withTempDir((root) => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const projectsRoot = path.join(root, ".docker-git") + const envDir = path.join(projectsRoot, ".orch", "env") + const envPath = path.join(envDir, "global.env") + + yield* _(fs.makeDirectory(envDir, { recursive: true })) + yield* _(fs.writeFileString(envPath, "# docker-git env\n")) + + const missingHost = yield* _( + withProjectsRoot( + projectsRoot, + withWorkingDirectory( + root, + loginGitAuth({ host: " ", token: "t", user: null }).pipe(Effect.flip) + ) + ) + ) + expect(missingHost._tag).toBe("ApiBadRequestError") + + const missingToken = yield* _( + withProjectsRoot( + projectsRoot, + withWorkingDirectory( + root, + loginGitAuth({ host: "git.example.com", token: " ", user: null }).pipe(Effect.flip) + ) + ) + ) + expect(missingToken._tag).toBe("ApiBadRequestError") + }) + ).pipe(Effect.provide(NodeContext.layer))) }) diff --git a/packages/app/src/docker-git/api-client-auth.ts b/packages/app/src/docker-git/api-client-auth.ts index 4503b7cd..2719cd88 100644 --- a/packages/app/src/docker-git/api-client-auth.ts +++ b/packages/app/src/docker-git/api-client-auth.ts @@ -28,6 +28,9 @@ import type { AuthGitlabLoginCommand, AuthGitlabLogoutCommand, AuthGitlabStatusCommand, + AuthGitLoginCommand, + AuthGitLogoutCommand, + AuthGitStatusCommand, AuthGrokLogoutCommand, AuthGrokStatusCommand } from "./frontend-lib/core/domain.js" @@ -146,6 +149,32 @@ export const gitlabLogout = (command: AuthGitlabLogoutCommand) => label: command.label }) +// CHANGE: route generic per-host git auth through the controller HTTP API +// WHY: issue #368 enables connecting git providers other than github/gitlab via token +// QUOTE(ТЗ): "реализовать возможность добавлять git подключения отличных от gitlab, github ... просто здавая токен" +// REF: issue-368 +// SOURCE: n/a +// FORMAT THEOREM: forall cmd: gitLogin(cmd) -> POST /auth/git/login {host, token, user} +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: token-only flow; host is always forwarded +// COMPLEXITY: O(1) +export const gitLogin = ( + command: AuthGitLoginCommand +): Effect.Effect => + request("POST", "/auth/git/login", { + host: command.host, + token: command.token, + user: command.user + }) + +export const gitStatus = (_command: AuthGitStatusCommand) => request("GET", "/auth/git/status") + +export const gitLogout = (command: AuthGitLogoutCommand) => + requestVoid("POST", "/auth/git/logout", { + host: command.host + }) + export const codexLogin = (command: AuthCodexLoginCommand) => requestMarkedAuthStream( "/auth/codex/login", diff --git a/packages/app/src/docker-git/api-client.ts b/packages/app/src/docker-git/api-client.ts index 1ec2b796..4996455f 100644 --- a/packages/app/src/docker-git/api-client.ts +++ b/packages/app/src/docker-git/api-client.ts @@ -34,6 +34,9 @@ export { gitlabLogin, gitlabLogout, gitlabStatus, + gitLogin, + gitLogout, + gitStatus, grokLogout, grokStatus } from "./api-client-auth.js" diff --git a/packages/app/src/docker-git/cli/parser-auth.ts b/packages/app/src/docker-git/cli/parser-auth.ts index 539713a4..e8f07a56 100644 --- a/packages/app/src/docker-git/cli/parser-auth.ts +++ b/packages/app/src/docker-git/cli/parser-auth.ts @@ -15,6 +15,8 @@ type AuthOptions = { readonly label: string | null readonly token: string | null readonly scopes: string | null + readonly host: string | null + readonly user: string | null readonly authWeb: boolean } @@ -55,6 +57,8 @@ const resolveAuthOptions = (raw: RawOptions): AuthOptions => ({ label: normalizeOptionalText(raw.label), token: normalizeOptionalText(raw.token), scopes: normalizeOptionalText(raw.scopes), + host: normalizeOptionalText(raw.host), + user: normalizeOptionalText(raw.user), authWeb: raw.authWeb === true }) @@ -108,6 +112,52 @@ const buildGitlabCommand = (action: string, options: AuthOptions): Either.Either Match.orElse(() => Either.left(invalidArgument("auth action", `unknown action '${action}'`))) ) +// CHANGE: parse `docker-git auth git ` for generic per-host git connections +// WHY: issue #368 wants git connections to providers other than github/gitlab via a token +// QUOTE(ТЗ): "реализовать возможность добавлять git подключения отличных от gitlab, github. ... просто здавая токен" +// REF: issue-368 +// SOURCE: https://git-scm.com/docs/gitcredentials +// FORMAT THEOREM: forall action: buildGitCommand(action, opts) = AuthCommand | ParseError +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: login/logout require --host; status lists all hosts +// COMPLEXITY: O(1) +const buildGitCommand = (action: string, options: AuthOptions): Either.Either => + Match.value(action).pipe( + Match.when("login", () => { + if (options.scopes !== null) { + return Either.left(invalidArgument("--scopes", "git auth does not support --scopes")) + } + if (options.host === null) { + return Either.left(missingArgument("--host")) + } + if (options.token === null) { + return Either.left(missingArgument("--token")) + } + return Either.right({ + _tag: "AuthGitLogin", + host: options.host, + token: options.token, + user: options.user, + envGlobalPath: options.envGlobalPath + }) + }), + Match.when("status", () => + Either.right({ + _tag: "AuthGitStatus", + envGlobalPath: options.envGlobalPath + })), + Match.when("logout", () => + options.host === null + ? Either.left(missingArgument("--host")) + : Either.right({ + _tag: "AuthGitLogout", + host: options.host, + envGlobalPath: options.envGlobalPath + })), + Match.orElse(() => Either.left(invalidArgument("auth action", `unknown action '${action}'`))) + ) + const buildCodexCommand = (action: string, options: AuthOptions): Either.Either => Match.value(action).pipe( Match.when("login", () => @@ -237,6 +287,7 @@ const buildAuthCommand = ( Match.when("github", () => buildGithubCommand(action, options)), Match.when("gh", () => buildGithubCommand(action, options)), Match.when("gitlab", () => buildGitlabCommand(action, options)), + Match.when("git", () => buildGitCommand(action, options)), Match.when("codex", () => buildCodexCommand(action, options)), Match.when("claude", () => buildClaudeCommand(action, options)), Match.when("cc", () => buildClaudeCommand(action, options)), diff --git a/packages/app/src/docker-git/cli/parser-options.ts b/packages/app/src/docker-git/cli/parser-options.ts index bf69872d..915e7c09 100644 --- a/packages/app/src/docker-git/cli/parser-options.ts +++ b/packages/app/src/docker-git/cli/parser-options.ts @@ -37,6 +37,8 @@ interface ValueOptionSpec { | "grokTokenLabel" | "token" | "scopes" + | "host" + | "user" | "message" | "outDir" | "projectDir" @@ -82,6 +84,8 @@ const valueOptionSpecs: ReadonlyArray = [ { flag: "--grok-token", key: "grokTokenLabel" }, { flag: "--token", key: "token" }, { flag: "--scopes", key: "scopes" }, + { flag: "--host", key: "host" }, + { flag: "--user", key: "user" }, { flag: "--message", key: "message" }, { flag: "-m", key: "message" }, { flag: "--out-dir", key: "outDir" }, @@ -145,6 +149,8 @@ const valueFlagUpdaters: { readonly [K in ValueKey]: (raw: RawOptions, value: st grokTokenLabel: (raw, value) => ({ ...raw, grokTokenLabel: value }), token: (raw, value) => ({ ...raw, token: value }), scopes: (raw, value) => ({ ...raw, scopes: value }), + host: (raw, value) => ({ ...raw, host: value }), + user: (raw, value) => ({ ...raw, user: value }), message: (raw, value) => ({ ...raw, message: value }), outDir: (raw, value) => ({ ...raw, outDir: value }), projectDir: (raw, value) => ({ ...raw, projectDir: value }), diff --git a/packages/app/src/docker-git/cli/usage.ts b/packages/app/src/docker-git/cli/usage.ts index ce55f0fc..4f8a8a45 100644 --- a/packages/app/src/docker-git/cli/usage.ts +++ b/packages/app/src/docker-git/cli/usage.ts @@ -104,6 +104,7 @@ Container runtime env (set via .orch/env/project.env): Auth providers: github, gh GitHub CLI auth (tokens saved to env file) gitlab GitLab CLI auth (tokens saved to env file) + git Generic per-host git auth via token (any HTTPS git host; saved to env file) codex Codex CLI auth (stored under .orch/auth/codex) claude, cc Claude Code CLI auth (OAuth cache stored under .orch/auth/claude) gemini Gemini CLI auth (stored under .orch/auth/gemini) @@ -120,6 +121,8 @@ Auth options: --token GitHub/GitLab token override (login only; useful for non-interactive/CI) --web Force OAuth web flow where supported (login only; ignores --token) --scopes GitHub scopes (login only, default: repo,workflow,read:org) + --host Git host for the generic git provider (e.g. git.example.com; login/logout only) + --user HTTPS username for the generic git provider (login only, default: x-access-token) --env-global Env file path for GitHub/GitLab tokens (default: /.orch/env/global.env) --codex-auth Codex auth root path (default: /.orch/auth/codex) --gemini-auth Gemini auth root path (default: /.orch/auth/gemini) diff --git a/packages/app/src/docker-git/frontend-lib/core/auth-domain.ts b/packages/app/src/docker-git/frontend-lib/core/auth-domain.ts index 71cd7a50..4c81e8b7 100644 --- a/packages/app/src/docker-git/frontend-lib/core/auth-domain.ts +++ b/packages/app/src/docker-git/frontend-lib/core/auth-domain.ts @@ -36,6 +36,25 @@ export interface AuthGitlabLogoutCommand { readonly envGlobalPath: string } +export interface AuthGitLoginCommand { + readonly _tag: "AuthGitLogin" + readonly host: string + readonly token: string | null + readonly user: string | null + readonly envGlobalPath: string +} + +export interface AuthGitStatusCommand { + readonly _tag: "AuthGitStatus" + readonly envGlobalPath: string +} + +export interface AuthGitLogoutCommand { + readonly _tag: "AuthGitLogout" + readonly host: string + readonly envGlobalPath: string +} + export interface AuthCodexLoginCommand { readonly _tag: "AuthCodexLogin" readonly label: string | null @@ -143,6 +162,9 @@ export type AuthCommand = | AuthGitlabLoginCommand | AuthGitlabStatusCommand | AuthGitlabLogoutCommand + | AuthGitLoginCommand + | AuthGitStatusCommand + | AuthGitLogoutCommand | AuthCodexLoginCommand | AuthCodexImportCommand | AuthCodexStatusCommand diff --git a/packages/app/src/docker-git/frontend-lib/core/command-options.ts b/packages/app/src/docker-git/frontend-lib/core/command-options.ts index 5c4b02da..d0d1ec3a 100644 --- a/packages/app/src/docker-git/frontend-lib/core/command-options.ts +++ b/packages/app/src/docker-git/frontend-lib/core/command-options.ts @@ -45,6 +45,8 @@ export interface RawOptions { readonly grokTokenLabel?: string readonly token?: string readonly scopes?: string + readonly host?: string + readonly user?: string readonly message?: string readonly authWeb?: boolean readonly authOauth?: boolean diff --git a/packages/app/src/docker-git/frontend-lib/core/domain.ts b/packages/app/src/docker-git/frontend-lib/core/domain.ts index 2398984d..1e188ef0 100644 --- a/packages/app/src/docker-git/frontend-lib/core/domain.ts +++ b/packages/app/src/docker-git/frontend-lib/core/domain.ts @@ -21,6 +21,9 @@ export type { AuthGitlabLoginCommand, AuthGitlabLogoutCommand, AuthGitlabStatusCommand, + AuthGitLoginCommand, + AuthGitLogoutCommand, + AuthGitStatusCommand, AuthGrokLoginCommand, AuthGrokLogoutCommand, AuthGrokStatusCommand diff --git a/packages/app/src/docker-git/program-auth.ts b/packages/app/src/docker-git/program-auth.ts index 636c9f80..ee3a3d19 100644 --- a/packages/app/src/docker-git/program-auth.ts +++ b/packages/app/src/docker-git/program-auth.ts @@ -13,6 +13,9 @@ import { gitlabLogin, gitlabLogout, gitlabStatus, + gitLogin, + gitLogout, + gitStatus, grokLogout, grokStatus, type JsonValue, @@ -37,6 +40,9 @@ export type RoutedAuthCommand = Extract< | "AuthGitlabLogin" | "AuthGitlabStatus" | "AuthGitlabLogout" + | "AuthGitLogin" + | "AuthGitStatus" + | "AuthGitLogout" | "AuthClaudeLogin" | "AuthGeminiLogin" | "AuthGrokLogin" @@ -77,6 +83,9 @@ const routedAuthTags: Readonly> = { AuthGitlabLogin: true, AuthGitlabLogout: true, AuthGitlabStatus: true, + AuthGitLogin: true, + AuthGitLogout: true, + AuthGitStatus: true, AuthGrokStatus: true } @@ -109,6 +118,19 @@ const handleGitlabLogoutCommand = ( pipe(gitlabLogout(command), Effect.zipRight(Effect.log("GitLab auth removed from controller state."))) ) +const handleGitLoginCommand = (command: Extract) => + withControllerReady(pipe(gitLogin(command), Effect.flatMap((payload) => renderAuthPayload(payload)))) + +const handleGitStatusCommand = (command: Extract) => + withControllerReady(pipe(gitStatus(command), Effect.flatMap((payload) => renderAuthPayload(payload)))) + +const handleGitLogoutCommand = ( + command: Extract +) => + withControllerReady( + pipe(gitLogout(command), Effect.zipRight(Effect.log(`Git auth removed from controller state (${command.host}).`))) + ) + const handleCodexLoginCommand = ( command: Extract ) => withControllerReady(codexLogin(command)) @@ -188,6 +210,9 @@ export const dispatchRoutedAuthCommand = ( Match.when({ _tag: "AuthGitlabLogin" }, handleGitlabLoginCommand), Match.when({ _tag: "AuthGitlabStatus" }, handleGitlabStatusCommand), Match.when({ _tag: "AuthGitlabLogout" }, handleGitlabLogoutCommand), + Match.when({ _tag: "AuthGitLogin" }, handleGitLoginCommand), + Match.when({ _tag: "AuthGitStatus" }, handleGitStatusCommand), + Match.when({ _tag: "AuthGitLogout" }, handleGitLogoutCommand), Match.when({ _tag: "AuthClaudeLogin" }, handleClaudeLoginCommand), Match.when({ _tag: "AuthGeminiLogin" }, handleGeminiLoginCommand), Match.when({ _tag: "AuthGrokLogin" }, handleGrokLoginCommand), diff --git a/packages/app/src/lib/core/auth-domain.ts b/packages/app/src/lib/core/auth-domain.ts index 71cd7a50..977afa50 100644 --- a/packages/app/src/lib/core/auth-domain.ts +++ b/packages/app/src/lib/core/auth-domain.ts @@ -36,6 +36,35 @@ export interface AuthGitlabLogoutCommand { readonly envGlobalPath: string } +// CHANGE: add generic git host auth commands +// WHY: issue #368 requires connecting git providers other than github/gitlab via token +// QUOTE(ТЗ): "реализовать возможность добавлять git подключения отличных от gitlab, github" +// REF: issue-368 +// SOURCE: https://git-scm.com/docs/gitcredentials +// FORMAT THEOREM: forall cmd ∈ AuthGitCommand: cmd.host normalizes to a token env key suffix +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: credentials are isolated per normalized host key +// COMPLEXITY: O(1) +export interface AuthGitLoginCommand { + readonly _tag: "AuthGitLogin" + readonly host: string + readonly token: string | null + readonly user: string | null + readonly envGlobalPath: string +} + +export interface AuthGitStatusCommand { + readonly _tag: "AuthGitStatus" + readonly envGlobalPath: string +} + +export interface AuthGitLogoutCommand { + readonly _tag: "AuthGitLogout" + readonly host: string + readonly envGlobalPath: string +} + export interface AuthCodexLoginCommand { readonly _tag: "AuthCodexLogin" readonly label: string | null @@ -143,6 +172,9 @@ export type AuthCommand = | AuthGitlabLoginCommand | AuthGitlabStatusCommand | AuthGitlabLogoutCommand + | AuthGitLoginCommand + | AuthGitStatusCommand + | AuthGitLogoutCommand | AuthCodexLoginCommand | AuthCodexImportCommand | AuthCodexStatusCommand diff --git a/packages/app/src/lib/core/command-options.ts b/packages/app/src/lib/core/command-options.ts index 5c4b02da..d0d1ec3a 100644 --- a/packages/app/src/lib/core/command-options.ts +++ b/packages/app/src/lib/core/command-options.ts @@ -45,6 +45,8 @@ export interface RawOptions { readonly grokTokenLabel?: string readonly token?: string readonly scopes?: string + readonly host?: string + readonly user?: string readonly message?: string readonly authWeb?: boolean readonly authOauth?: boolean diff --git a/packages/app/src/lib/core/domain.ts b/packages/app/src/lib/core/domain.ts index f411e7a6..9041ae89 100644 --- a/packages/app/src/lib/core/domain.ts +++ b/packages/app/src/lib/core/domain.ts @@ -21,6 +21,9 @@ export type { AuthGitlabLoginCommand, AuthGitlabLogoutCommand, AuthGitlabStatusCommand, + AuthGitLoginCommand, + AuthGitLogoutCommand, + AuthGitStatusCommand, AuthGrokLoginCommand, AuthGrokLogoutCommand, AuthGrokStatusCommand diff --git a/packages/app/src/lib/core/templates-entrypoint/git.ts b/packages/app/src/lib/core/templates-entrypoint/git.ts index 1b09728c..68a65d32 100644 --- a/packages/app/src/lib/core/templates-entrypoint/git.ts +++ b/packages/app/src/lib/core/templates-entrypoint/git.ts @@ -67,7 +67,7 @@ if [[ -n "$RESOLVED_AUTH_LABEL" ]]; then fi fi` -const renderAuthBridgeFinalize = (config: TemplateConfig): string => +const renderGithubTokenBridge = (config: TemplateConfig): string => String.raw`EFFECTIVE_GH_TOKEN="$EFFECTIVE_GITHUB_TOKEN" EFFECTIVE_GIT_AUTH_TOKEN="$EFFECTIVE_GITHUB_TOKEN" if [[ "$REPO_URL" == https://gitlab.com/* ]]; then @@ -96,9 +96,10 @@ if [[ -n "$EFFECTIVE_GH_TOKEN" ]]; then if [[ -z "$GIT_USER_EMAIL" && -n "$GH_LOGIN" && -n "$GH_ID" ]]; then GIT_USER_EMAIL="${"${"}GH_ID}+${"${"}GH_LOGIN}@users.noreply.github.com" fi -fi +fi` -if [[ -n "$EFFECTIVE_GITLAB_TOKEN" ]]; then +const renderGitlabTokenBridge = (): string => + String.raw`if [[ -n "$EFFECTIVE_GITLAB_TOKEN" ]]; then printf "export GITLAB_TOKEN=%q\n" "$EFFECTIVE_GITLAB_TOKEN" > /etc/profile.d/gitlab-token.sh chmod 0644 /etc/profile.d/gitlab-token.sh docker_git_upsert_ssh_env "GITLAB_TOKEN" "$EFFECTIVE_GITLAB_TOKEN" @@ -118,13 +119,35 @@ if [[ -n "$EFFECTIVE_GITLAB_TOKEN" ]]; then docker_git_upsert_ssh_env "GLAB_IS_OAUTH2" "$EFFECTIVE_GLAB_IS_OAUTH2" export GLAB_IS_OAUTH2="$EFFECTIVE_GLAB_IS_OAUTH2" fi -fi +fi` -if [[ -n "$EFFECTIVE_GIT_AUTH_TOKEN" ]]; then +const renderGenericTokenBridge = (): string => + String.raw`if [[ -n "$EFFECTIVE_GIT_AUTH_TOKEN" ]]; then printf "export GIT_AUTH_TOKEN=%q\n" "$EFFECTIVE_GIT_AUTH_TOKEN" > /etc/profile.d/git-auth-token.sh chmod 0644 /etc/profile.d/git-auth-token.sh docker_git_upsert_ssh_env "GIT_AUTH_TOKEN" "$EFFECTIVE_GIT_AUTH_TOKEN" -fi` +fi + +# Export host-scoped generic git credentials (GIT_AUTH_TOKEN__, +# GIT_AUTH_USER__) to login + SSH shells so the credential helper can +# resolve per-host tokens during clone/push, not only inside this entrypoint. +GIT_AUTH_HOSTS_ENV_FILE="/etc/profile.d/git-auth-hosts.sh" +: > "$GIT_AUTH_HOSTS_ENV_FILE" +for GIT_AUTH_HOST_VAR in $(compgen -v | grep -E '^GIT_AUTH_(TOKEN|USER)__' || true); do + GIT_AUTH_HOST_VAL="${"${"}!GIT_AUTH_HOST_VAR-}" + if [[ -n "$GIT_AUTH_HOST_VAL" ]]; then + printf "export %s=%q\n" "$GIT_AUTH_HOST_VAR" "$GIT_AUTH_HOST_VAL" >> "$GIT_AUTH_HOSTS_ENV_FILE" + docker_git_upsert_ssh_env "$GIT_AUTH_HOST_VAR" "$GIT_AUTH_HOST_VAL" + fi +done +chmod 0644 "$GIT_AUTH_HOSTS_ENV_FILE"` + +const renderAuthBridgeFinalize = (config: TemplateConfig): string => + [ + renderGithubTokenBridge(config), + renderGitlabTokenBridge(), + renderGenericTokenBridge() + ].join("\n\n") const renderEntrypointAuthEnvBridge = (config: TemplateConfig): string => [ @@ -133,11 +156,18 @@ const renderEntrypointAuthEnvBridge = (config: TemplateConfig): string => renderAuthBridgeFinalize(config) ].join("\n\n") -const renderEntrypointGitCredentialHelper = (config: TemplateConfig): string => - String.raw`# 3) Configure git credential helper for HTTPS remotes -GIT_CREDENTIAL_HELPER_PATH="/usr/local/bin/docker-git-credential-helper" -cat <<'EOF' > "$GIT_CREDENTIAL_HELPER_PATH" -#!/usr/bin/env bash +// CHANGE: make the HTTPS credential helper resolve per-host generic git tokens +// WHY: support git connections to providers other than github.com/gitlab.com +// QUOTE(ТЗ): "реализовать возможность добавлять git подключения отличных от gitlab, github" +// REF: issue-368 +// SOURCE: https://git-scm.com/docs/gitcredentials +// FORMAT THEOREM: forall host: helper(host) prefers GIT_AUTH_TOKEN__ when set +// PURITY: CORE +// EFFECT: renders shell text only; no IO at render time +// INVARIANT: github.com/gitlab.com defaults remain backward compatible +// COMPLEXITY: O(1) +const renderCredentialHelperScriptHead = (): string => + String.raw`#!/usr/bin/env bash set -euo pipefail if [[ "$#" -lt 1 || "$1" != "get" ]]; then @@ -156,13 +186,36 @@ done token="" username="${"${"}GIT_AUTH_USER:-}" -if [[ "$protocol" == "https" && "$host" == "gitlab.com" ]]; then - token="${"${"}GITLAB_TOKEN:-}" + +# Resolve per-host generic git credentials first so connections to providers +# other than github.com/gitlab.com (Gitea, Bitbucket, self-hosted, ...) route to +# their own token. HOST_KEY mirrors the label normalization used by the CLI/web +# auth flows: uppercase, non-alphanumeric -> "_", trimmed of leading/trailing "_". +host_key="" +if [[ -n "$host" ]]; then + host_key="$(printf "%s" "$host" | tr '[:lower:]' '[:upper:]' | sed -E 's/[^A-Z0-9]+/_/g; s/^_+//; s/_+$//')" +fi +if [[ "$protocol" == "https" && -n "$host_key" ]]; then + host_token_key="GIT_AUTH_TOKEN__$host_key" + host_user_key="GIT_AUTH_USER__$host_key" + token="${"${"}!host_token_key:-}" + if [[ -z "$username" ]]; then + username="${"${"}!host_user_key:-}" + fi +fi` + +const renderCredentialHelperScriptTail = (): string => + String.raw`if [[ "$protocol" == "https" && "$host" == "gitlab.com" ]]; then + if [[ -z "$token" ]]; then + token="${"${"}GITLAB_TOKEN:-}" + fi if [[ -z "$username" ]]; then username="oauth2" fi elif [[ "$protocol" == "https" && "$host" == "github.com" ]]; then - token="${"${"}GITHUB_TOKEN:-}" + if [[ -z "$token" ]]; then + token="${"${"}GITHUB_TOKEN:-}" + fi if [[ -z "$token" ]]; then token="${"${"}GH_TOKEN:-}" fi @@ -183,7 +236,15 @@ if [[ -z "$token" ]]; then fi printf "%s\n" "username=$username" -printf "%s\n" "password=$token" +printf "%s\n" "password=$token"` + +const renderEntrypointGitCredentialHelper = (config: TemplateConfig): string => + String.raw`# 3) Configure git credential helper for HTTPS remotes +GIT_CREDENTIAL_HELPER_PATH="/usr/local/bin/docker-git-credential-helper" +cat <<'EOF' > "$GIT_CREDENTIAL_HELPER_PATH" +${renderCredentialHelperScriptHead()} + +${renderCredentialHelperScriptTail()} EOF chmod 0755 "$GIT_CREDENTIAL_HELPER_PATH" su - ${config.sshUser} -c "git config --global credential.helper '$GIT_CREDENTIAL_HELPER_PATH'"` diff --git a/packages/app/src/lib/usecases/auth-git.ts b/packages/app/src/lib/usecases/auth-git.ts new file mode 100644 index 00000000..1e2bba5b --- /dev/null +++ b/packages/app/src/lib/usecases/auth-git.ts @@ -0,0 +1,196 @@ +import type { CommandExecutor } from "@effect/platform/CommandExecutor" +import type { PlatformError } from "@effect/platform/Error" +import type { FileSystem } from "@effect/platform/FileSystem" +import type { Path } from "@effect/platform/Path" +import { Effect } from "effect" + +import type { AuthGitLoginCommand, AuthGitLogoutCommand, AuthGitStatusCommand } from "../core/domain.js" +import { trimLeftChar, trimRightChar } from "../core/strings.js" +import { AuthError } from "../shell/errors.js" +import { ensureEnvFile, parseEnvEntries, readEnvText, removeEnvKey, upsertEnvKey } from "./env-file.js" +import { resolvePathFromCwd } from "./path-helpers.js" +import { withFsPathContext } from "./runtime.js" +import { autoSyncState } from "./state-repo.js" + +// CHANGE: add generic per-host git auth usecase +// WHY: issue #368 wants 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 host: persist(host, token) -> GIT_AUTH_TOKEN__ set +// PURITY: CORE (helpers) / SHELL (Effects) +// EFFECT: Effect +// INVARIANT: env keys mirror the in-container credential helper's host normalization +// COMPLEXITY: O(n) where n = |env entries| + +type GitFsRuntime = FileSystem | Path +type GitRuntime = FileSystem | Path | CommandExecutor + +const tokenKey = "GIT_AUTH_TOKEN" +const tokenPrefix = "GIT_AUTH_TOKEN__" +const userKey = "GIT_AUTH_USER" +const userPrefix = "GIT_AUTH_USER__" + +const defaultGitUser = "x-access-token" + +export type GitConnectionEntry = { + readonly host: string + readonly token: string + readonly user: string +} + +// CHANGE: reduce a host (or full URL) to its bare host[:port] segment +// WHY: only the host portion participates in the credential-helper env key +// QUOTE(ТЗ): "git подключения отличных от gitlab, github" +// REF: issue-368 +// SOURCE: https://git-scm.com/docs/gitcredentials (host includes port when specified) +// FORMAT THEOREM: stripGitHostPath("https://user@h/x") = "h" +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: scheme, credentials and path are removed; the rest is preserved verbatim +// COMPLEXITY: O(n) where n = |value| +const stripGitHostPath = (value: string): string => { + const withoutScheme = value.replace(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//u, "") + const withoutCredentials = withoutScheme.replace(/^[^@/]+@/u, "") + const slashIndex = withoutCredentials.indexOf("/") + return slashIndex === -1 ? withoutCredentials : withoutCredentials.slice(0, slashIndex) +} + +// CHANGE: normalize a host (or full URL) into the credential-helper env key suffix +// WHY: CLI/web persistence must match the in-container helper resolution exactly +// QUOTE(ТЗ): "git подключения отличных от gitlab, github" +// REF: issue-368 +// SOURCE: https://git-scm.com/docs/gitcredentials (host includes port when specified) +// FORMAT THEOREM: normalizeGitHost("https://git.example.com/x") = "GIT_EXAMPLE_COM" +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: uppercase, non-alphanumeric -> "_", trimmed of leading/trailing "_" +// COMPLEXITY: O(n) where n = |value| +export const normalizeGitHost = (value: string | null): string => { + const hostOnly = stripGitHostPath((value ?? "").trim()) + const normalized = hostOnly.toUpperCase().replaceAll(/[^A-Z0-9]+/gu, "_") + return trimRightChar(trimLeftChar(normalized, "_"), "_") +} + +export const buildGitTokenKey = (host: string): string => { + const normalized = normalizeGitHost(host) + return normalized.length === 0 ? tokenKey : `${tokenPrefix}${normalized}` +} + +export const buildGitUserKey = (host: string): string => { + const normalized = normalizeGitHost(host) + return normalized.length === 0 ? userKey : `${userPrefix}${normalized}` +} + +export const gitHostFromKey = (key: string): string => { + if (key.startsWith(tokenPrefix)) { + return key.slice(tokenPrefix.length) + } + if (key.startsWith(userPrefix)) { + return key.slice(userPrefix.length) + } + return "default" +} + +export const listGitConnections = (envText: string): ReadonlyArray => { + const entries = parseEnvEntries(envText) + const userByHost = new Map() + for (const entry of entries) { + if (entry.key === userKey || entry.key.startsWith(userPrefix)) { + userByHost.set(gitHostFromKey(entry.key), entry.value) + } + } + return entries + .filter((entry) => entry.key === tokenKey || entry.key.startsWith(tokenPrefix)) + .filter((entry) => entry.value.trim().length > 0) + .map((entry) => { + const host = gitHostFromKey(entry.key) + return { host, token: entry.value, user: userByHost.get(host) ?? "" } + }) +} + +type GitEnvContext = { + readonly host: string + readonly fs: FileSystem + readonly envPath: string + readonly current: string +} + +// CHANGE: share the host-validated env prologue between login and logout +// WHY: both mutators normalize the host, resolve and read the env file identically +// QUOTE(ТЗ): "реализовать возможность добавлять git подключения отличных от gitlab, github" +// REF: issue-368 +// SOURCE: n/a +// FORMAT THEOREM: withGitHostEnv(cmd, use) fails when host is empty, else runs use over the read env +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: an empty host always yields a typed AuthError before any write +// COMPLEXITY: O(n) where n = |env entries| +const withGitHostEnv = ( + command: { readonly host: string; readonly envGlobalPath: string }, + use: (context: GitEnvContext) => Effect.Effect +): Effect.Effect => + withFsPathContext(({ cwd, fs, path }) => + Effect.gen(function*(_) { + const host = normalizeGitHost(command.host) + if (host.length === 0) { + return yield* _(Effect.fail(new AuthError({ message: "Git host is required (use --host )." }))) + } + const envPath = resolvePathFromCwd(path, cwd, command.envGlobalPath) + yield* _(ensureEnvFile(fs, path, envPath)) + const current = yield* _(readEnvText(fs, envPath)) + return yield* _(use({ host, fs, envPath, current })) + }) + ) + +export const authGitLogin = ( + command: AuthGitLoginCommand +): Effect.Effect => + withGitHostEnv(command, ({ current, envPath, fs, host }) => + Effect.gen(function*(_) { + const token = command.token?.trim() ?? "" + if (token.length === 0) { + return yield* _(Effect.fail(new AuthError({ message: "Git token is required (use --token )." }))) + } + const user = command.user?.trim() ?? "" + const nextText = upsertEnvKey( + upsertEnvKey(current, buildGitTokenKey(command.host), token), + buildGitUserKey(command.host), + user.length > 0 ? user : defaultGitUser + ) + yield* _(fs.writeFileString(envPath, nextText)) + yield* _(Effect.log(`Git token stored (${host}) in ${envPath}`)) + yield* _(autoSyncState(`chore(state): auth git ${host}`)) + })) + +export const authGitStatus = ( + command: AuthGitStatusCommand +): Effect.Effect, PlatformError, GitFsRuntime> => + withFsPathContext(({ cwd, fs, path }) => + Effect.gen(function*(_) { + const envPath = resolvePathFromCwd(path, cwd, command.envGlobalPath) + const current = yield* _(readEnvText(fs, envPath)) + const connections = listGitConnections(current) + if (connections.length === 0) { + yield* _(Effect.log(`No generic git connections in ${envPath}.`)) + } else { + const lines = connections.map((entry) => `- ${entry.host} (user: ${entry.user || defaultGitUser})`) + yield* _(Effect.log([`Git connections (${connections.length}):`, ...lines].join("\n"))) + } + return connections + }) + ) + +export const authGitLogout = ( + command: AuthGitLogoutCommand +): Effect.Effect => + withGitHostEnv(command, ({ current, envPath, fs, host }) => + Effect.gen(function*(_) { + const nextText = removeEnvKey( + removeEnvKey(current, buildGitTokenKey(command.host)), + buildGitUserKey(command.host) + ) + yield* _(fs.writeFileString(envPath, nextText)) + yield* _(Effect.log(`Git token removed (${host}) from ${envPath}`)) + yield* _(autoSyncState(`chore(state): auth git logout ${host}`)) + })) diff --git a/packages/app/tests/docker-git/parser-auth.test.ts b/packages/app/tests/docker-git/parser-auth.test.ts index 6659e907..b501e2ef 100644 --- a/packages/app/tests/docker-git/parser-auth.test.ts +++ b/packages/app/tests/docker-git/parser-auth.test.ts @@ -76,4 +76,64 @@ describe("parse auth commands", () => { it.effect("rejects gitlab login scopes", () => expectParseErrorTag(["auth", "gitlab", "login", "--scopes", "api"], "InvalidOption")) + + // CHANGE: parse `auth git login|status|logout` for generic per-host git providers + // WHY: issue #368 wants git connections to providers other than github/gitlab via a token + // QUOTE(ТЗ): "реализовать возможность добавлять git подключения отличных от gitlab, github ... просто здавая токен" + // REF: issue-368 + // SOURCE: n/a + // FORMAT THEOREM: parse(["auth","git","login","--host",h,"--token",t]) = AuthGitLogin{host:h,token:t} + // PURITY: CORE + // EFFECT: n/a + // INVARIANT: login/logout require --host; login requires --token and forbids --scopes + // COMPLEXITY: O(1) + it.effect("parses generic git token login with host and optional user", () => + Effect.sync(() => { + const command = parseOrThrow([ + "auth", + "git", + "login", + "--host", + "git.example.com", + "--token", + "glpat-generic", + "--user", + "deploy-bot" + ]) + expect(command._tag).toBe("AuthGitLogin") + if (command._tag !== "AuthGitLogin") { + throw new Error("expected AuthGitLogin command") + } + expect(command.host).toBe("git.example.com") + expect(command.token).toBe("glpat-generic") + expect(command.user).toBe("deploy-bot") + expect(command.envGlobalPath).toBe(".docker-git/.orch/env/global.env") + })) + + it.effect("parses generic git status and logout", () => + Effect.sync(() => { + const status = parseOrThrow(["auth", "git", "status"]) + const logout = parseOrThrow(["auth", "git", "logout", "--host", "git.example.com"]) + expect(status._tag).toBe("AuthGitStatus") + expect(logout._tag).toBe("AuthGitLogout") + if (logout._tag !== "AuthGitLogout") { + throw new Error("expected AuthGitLogout command") + } + expect(logout.host).toBe("git.example.com") + })) + + it.effect("rejects generic git login without --host", () => + expectParseErrorTag(["auth", "git", "login", "--token", "t"], "MissingRequiredOption")) + + it.effect("rejects generic git login without --token", () => + expectParseErrorTag(["auth", "git", "login", "--host", "git.example.com"], "MissingRequiredOption")) + + it.effect("rejects generic git login scopes", () => + expectParseErrorTag( + ["auth", "git", "login", "--host", "git.example.com", "--token", "t", "--scopes", "api"], + "InvalidOption" + )) + + it.effect("rejects generic git logout without --host", () => + expectParseErrorTag(["auth", "git", "logout"], "MissingRequiredOption")) }) diff --git a/packages/lib/src/core/auth-domain.ts b/packages/lib/src/core/auth-domain.ts index 2cf27170..b502c6b9 100644 --- a/packages/lib/src/core/auth-domain.ts +++ b/packages/lib/src/core/auth-domain.ts @@ -35,6 +35,35 @@ export interface AuthGitlabLogoutCommand { readonly envGlobalPath: string } +// CHANGE: add generic git host auth commands +// WHY: issue #368 requires connecting git providers other than github/gitlab via token +// QUOTE(ТЗ): "реализовать возможность добавлять git подключения отличных от gitlab, github" +// REF: issue-368 +// SOURCE: https://git-scm.com/docs/gitcredentials +// FORMAT THEOREM: forall cmd ∈ AuthGitCommand: cmd.host normalizes to a token env key suffix +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: credentials are isolated per normalized host key +// COMPLEXITY: O(1) +export interface AuthGitLoginCommand { + readonly _tag: "AuthGitLogin" + readonly host: string + readonly token: string | null + readonly user: string | null + readonly envGlobalPath: string +} + +export interface AuthGitStatusCommand { + readonly _tag: "AuthGitStatus" + readonly envGlobalPath: string +} + +export interface AuthGitLogoutCommand { + readonly _tag: "AuthGitLogout" + readonly host: string + readonly envGlobalPath: string +} + export interface AuthCodexLoginCommand { readonly _tag: "AuthCodexLogin" readonly label: string | null @@ -142,6 +171,9 @@ export type AuthCommand = | AuthGitlabLoginCommand | AuthGitlabStatusCommand | AuthGitlabLogoutCommand + | AuthGitLoginCommand + | AuthGitStatusCommand + | AuthGitLogoutCommand | AuthCodexLoginCommand | AuthCodexStatusCommand | AuthCodexLogoutCommand diff --git a/packages/lib/src/core/command-options.ts b/packages/lib/src/core/command-options.ts index 55db8135..8e445d10 100644 --- a/packages/lib/src/core/command-options.ts +++ b/packages/lib/src/core/command-options.ts @@ -44,6 +44,8 @@ export interface RawOptions { readonly grokTokenLabel?: string readonly token?: string readonly scopes?: string + readonly host?: string + readonly user?: string readonly message?: string readonly authWeb?: boolean readonly authOauth?: boolean diff --git a/packages/lib/src/core/domain.ts b/packages/lib/src/core/domain.ts index 484da3d0..fa101e1f 100644 --- a/packages/lib/src/core/domain.ts +++ b/packages/lib/src/core/domain.ts @@ -20,6 +20,9 @@ export type { AuthGitlabLoginCommand, AuthGitlabLogoutCommand, AuthGitlabStatusCommand, + AuthGitLoginCommand, + AuthGitLogoutCommand, + AuthGitStatusCommand, AuthGrokLoginCommand, AuthGrokLogoutCommand, AuthGrokStatusCommand diff --git a/packages/lib/src/core/templates-entrypoint/git.ts b/packages/lib/src/core/templates-entrypoint/git.ts index 1b09728c..68a65d32 100644 --- a/packages/lib/src/core/templates-entrypoint/git.ts +++ b/packages/lib/src/core/templates-entrypoint/git.ts @@ -67,7 +67,7 @@ if [[ -n "$RESOLVED_AUTH_LABEL" ]]; then fi fi` -const renderAuthBridgeFinalize = (config: TemplateConfig): string => +const renderGithubTokenBridge = (config: TemplateConfig): string => String.raw`EFFECTIVE_GH_TOKEN="$EFFECTIVE_GITHUB_TOKEN" EFFECTIVE_GIT_AUTH_TOKEN="$EFFECTIVE_GITHUB_TOKEN" if [[ "$REPO_URL" == https://gitlab.com/* ]]; then @@ -96,9 +96,10 @@ if [[ -n "$EFFECTIVE_GH_TOKEN" ]]; then if [[ -z "$GIT_USER_EMAIL" && -n "$GH_LOGIN" && -n "$GH_ID" ]]; then GIT_USER_EMAIL="${"${"}GH_ID}+${"${"}GH_LOGIN}@users.noreply.github.com" fi -fi +fi` -if [[ -n "$EFFECTIVE_GITLAB_TOKEN" ]]; then +const renderGitlabTokenBridge = (): string => + String.raw`if [[ -n "$EFFECTIVE_GITLAB_TOKEN" ]]; then printf "export GITLAB_TOKEN=%q\n" "$EFFECTIVE_GITLAB_TOKEN" > /etc/profile.d/gitlab-token.sh chmod 0644 /etc/profile.d/gitlab-token.sh docker_git_upsert_ssh_env "GITLAB_TOKEN" "$EFFECTIVE_GITLAB_TOKEN" @@ -118,13 +119,35 @@ if [[ -n "$EFFECTIVE_GITLAB_TOKEN" ]]; then docker_git_upsert_ssh_env "GLAB_IS_OAUTH2" "$EFFECTIVE_GLAB_IS_OAUTH2" export GLAB_IS_OAUTH2="$EFFECTIVE_GLAB_IS_OAUTH2" fi -fi +fi` -if [[ -n "$EFFECTIVE_GIT_AUTH_TOKEN" ]]; then +const renderGenericTokenBridge = (): string => + String.raw`if [[ -n "$EFFECTIVE_GIT_AUTH_TOKEN" ]]; then printf "export GIT_AUTH_TOKEN=%q\n" "$EFFECTIVE_GIT_AUTH_TOKEN" > /etc/profile.d/git-auth-token.sh chmod 0644 /etc/profile.d/git-auth-token.sh docker_git_upsert_ssh_env "GIT_AUTH_TOKEN" "$EFFECTIVE_GIT_AUTH_TOKEN" -fi` +fi + +# Export host-scoped generic git credentials (GIT_AUTH_TOKEN__, +# GIT_AUTH_USER__) to login + SSH shells so the credential helper can +# resolve per-host tokens during clone/push, not only inside this entrypoint. +GIT_AUTH_HOSTS_ENV_FILE="/etc/profile.d/git-auth-hosts.sh" +: > "$GIT_AUTH_HOSTS_ENV_FILE" +for GIT_AUTH_HOST_VAR in $(compgen -v | grep -E '^GIT_AUTH_(TOKEN|USER)__' || true); do + GIT_AUTH_HOST_VAL="${"${"}!GIT_AUTH_HOST_VAR-}" + if [[ -n "$GIT_AUTH_HOST_VAL" ]]; then + printf "export %s=%q\n" "$GIT_AUTH_HOST_VAR" "$GIT_AUTH_HOST_VAL" >> "$GIT_AUTH_HOSTS_ENV_FILE" + docker_git_upsert_ssh_env "$GIT_AUTH_HOST_VAR" "$GIT_AUTH_HOST_VAL" + fi +done +chmod 0644 "$GIT_AUTH_HOSTS_ENV_FILE"` + +const renderAuthBridgeFinalize = (config: TemplateConfig): string => + [ + renderGithubTokenBridge(config), + renderGitlabTokenBridge(), + renderGenericTokenBridge() + ].join("\n\n") const renderEntrypointAuthEnvBridge = (config: TemplateConfig): string => [ @@ -133,11 +156,18 @@ const renderEntrypointAuthEnvBridge = (config: TemplateConfig): string => renderAuthBridgeFinalize(config) ].join("\n\n") -const renderEntrypointGitCredentialHelper = (config: TemplateConfig): string => - String.raw`# 3) Configure git credential helper for HTTPS remotes -GIT_CREDENTIAL_HELPER_PATH="/usr/local/bin/docker-git-credential-helper" -cat <<'EOF' > "$GIT_CREDENTIAL_HELPER_PATH" -#!/usr/bin/env bash +// CHANGE: make the HTTPS credential helper resolve per-host generic git tokens +// WHY: support git connections to providers other than github.com/gitlab.com +// QUOTE(ТЗ): "реализовать возможность добавлять git подключения отличных от gitlab, github" +// REF: issue-368 +// SOURCE: https://git-scm.com/docs/gitcredentials +// FORMAT THEOREM: forall host: helper(host) prefers GIT_AUTH_TOKEN__ when set +// PURITY: CORE +// EFFECT: renders shell text only; no IO at render time +// INVARIANT: github.com/gitlab.com defaults remain backward compatible +// COMPLEXITY: O(1) +const renderCredentialHelperScriptHead = (): string => + String.raw`#!/usr/bin/env bash set -euo pipefail if [[ "$#" -lt 1 || "$1" != "get" ]]; then @@ -156,13 +186,36 @@ done token="" username="${"${"}GIT_AUTH_USER:-}" -if [[ "$protocol" == "https" && "$host" == "gitlab.com" ]]; then - token="${"${"}GITLAB_TOKEN:-}" + +# Resolve per-host generic git credentials first so connections to providers +# other than github.com/gitlab.com (Gitea, Bitbucket, self-hosted, ...) route to +# their own token. HOST_KEY mirrors the label normalization used by the CLI/web +# auth flows: uppercase, non-alphanumeric -> "_", trimmed of leading/trailing "_". +host_key="" +if [[ -n "$host" ]]; then + host_key="$(printf "%s" "$host" | tr '[:lower:]' '[:upper:]' | sed -E 's/[^A-Z0-9]+/_/g; s/^_+//; s/_+$//')" +fi +if [[ "$protocol" == "https" && -n "$host_key" ]]; then + host_token_key="GIT_AUTH_TOKEN__$host_key" + host_user_key="GIT_AUTH_USER__$host_key" + token="${"${"}!host_token_key:-}" + if [[ -z "$username" ]]; then + username="${"${"}!host_user_key:-}" + fi +fi` + +const renderCredentialHelperScriptTail = (): string => + String.raw`if [[ "$protocol" == "https" && "$host" == "gitlab.com" ]]; then + if [[ -z "$token" ]]; then + token="${"${"}GITLAB_TOKEN:-}" + fi if [[ -z "$username" ]]; then username="oauth2" fi elif [[ "$protocol" == "https" && "$host" == "github.com" ]]; then - token="${"${"}GITHUB_TOKEN:-}" + if [[ -z "$token" ]]; then + token="${"${"}GITHUB_TOKEN:-}" + fi if [[ -z "$token" ]]; then token="${"${"}GH_TOKEN:-}" fi @@ -183,7 +236,15 @@ if [[ -z "$token" ]]; then fi printf "%s\n" "username=$username" -printf "%s\n" "password=$token" +printf "%s\n" "password=$token"` + +const renderEntrypointGitCredentialHelper = (config: TemplateConfig): string => + String.raw`# 3) Configure git credential helper for HTTPS remotes +GIT_CREDENTIAL_HELPER_PATH="/usr/local/bin/docker-git-credential-helper" +cat <<'EOF' > "$GIT_CREDENTIAL_HELPER_PATH" +${renderCredentialHelperScriptHead()} + +${renderCredentialHelperScriptTail()} EOF chmod 0755 "$GIT_CREDENTIAL_HELPER_PATH" su - ${config.sshUser} -c "git config --global credential.helper '$GIT_CREDENTIAL_HELPER_PATH'"` diff --git a/packages/lib/src/usecases/auth-git.ts b/packages/lib/src/usecases/auth-git.ts new file mode 100644 index 00000000..1e2bba5b --- /dev/null +++ b/packages/lib/src/usecases/auth-git.ts @@ -0,0 +1,196 @@ +import type { CommandExecutor } from "@effect/platform/CommandExecutor" +import type { PlatformError } from "@effect/platform/Error" +import type { FileSystem } from "@effect/platform/FileSystem" +import type { Path } from "@effect/platform/Path" +import { Effect } from "effect" + +import type { AuthGitLoginCommand, AuthGitLogoutCommand, AuthGitStatusCommand } from "../core/domain.js" +import { trimLeftChar, trimRightChar } from "../core/strings.js" +import { AuthError } from "../shell/errors.js" +import { ensureEnvFile, parseEnvEntries, readEnvText, removeEnvKey, upsertEnvKey } from "./env-file.js" +import { resolvePathFromCwd } from "./path-helpers.js" +import { withFsPathContext } from "./runtime.js" +import { autoSyncState } from "./state-repo.js" + +// CHANGE: add generic per-host git auth usecase +// WHY: issue #368 wants 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 host: persist(host, token) -> GIT_AUTH_TOKEN__ set +// PURITY: CORE (helpers) / SHELL (Effects) +// EFFECT: Effect +// INVARIANT: env keys mirror the in-container credential helper's host normalization +// COMPLEXITY: O(n) where n = |env entries| + +type GitFsRuntime = FileSystem | Path +type GitRuntime = FileSystem | Path | CommandExecutor + +const tokenKey = "GIT_AUTH_TOKEN" +const tokenPrefix = "GIT_AUTH_TOKEN__" +const userKey = "GIT_AUTH_USER" +const userPrefix = "GIT_AUTH_USER__" + +const defaultGitUser = "x-access-token" + +export type GitConnectionEntry = { + readonly host: string + readonly token: string + readonly user: string +} + +// CHANGE: reduce a host (or full URL) to its bare host[:port] segment +// WHY: only the host portion participates in the credential-helper env key +// QUOTE(ТЗ): "git подключения отличных от gitlab, github" +// REF: issue-368 +// SOURCE: https://git-scm.com/docs/gitcredentials (host includes port when specified) +// FORMAT THEOREM: stripGitHostPath("https://user@h/x") = "h" +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: scheme, credentials and path are removed; the rest is preserved verbatim +// COMPLEXITY: O(n) where n = |value| +const stripGitHostPath = (value: string): string => { + const withoutScheme = value.replace(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//u, "") + const withoutCredentials = withoutScheme.replace(/^[^@/]+@/u, "") + const slashIndex = withoutCredentials.indexOf("/") + return slashIndex === -1 ? withoutCredentials : withoutCredentials.slice(0, slashIndex) +} + +// CHANGE: normalize a host (or full URL) into the credential-helper env key suffix +// WHY: CLI/web persistence must match the in-container helper resolution exactly +// QUOTE(ТЗ): "git подключения отличных от gitlab, github" +// REF: issue-368 +// SOURCE: https://git-scm.com/docs/gitcredentials (host includes port when specified) +// FORMAT THEOREM: normalizeGitHost("https://git.example.com/x") = "GIT_EXAMPLE_COM" +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: uppercase, non-alphanumeric -> "_", trimmed of leading/trailing "_" +// COMPLEXITY: O(n) where n = |value| +export const normalizeGitHost = (value: string | null): string => { + const hostOnly = stripGitHostPath((value ?? "").trim()) + const normalized = hostOnly.toUpperCase().replaceAll(/[^A-Z0-9]+/gu, "_") + return trimRightChar(trimLeftChar(normalized, "_"), "_") +} + +export const buildGitTokenKey = (host: string): string => { + const normalized = normalizeGitHost(host) + return normalized.length === 0 ? tokenKey : `${tokenPrefix}${normalized}` +} + +export const buildGitUserKey = (host: string): string => { + const normalized = normalizeGitHost(host) + return normalized.length === 0 ? userKey : `${userPrefix}${normalized}` +} + +export const gitHostFromKey = (key: string): string => { + if (key.startsWith(tokenPrefix)) { + return key.slice(tokenPrefix.length) + } + if (key.startsWith(userPrefix)) { + return key.slice(userPrefix.length) + } + return "default" +} + +export const listGitConnections = (envText: string): ReadonlyArray => { + const entries = parseEnvEntries(envText) + const userByHost = new Map() + for (const entry of entries) { + if (entry.key === userKey || entry.key.startsWith(userPrefix)) { + userByHost.set(gitHostFromKey(entry.key), entry.value) + } + } + return entries + .filter((entry) => entry.key === tokenKey || entry.key.startsWith(tokenPrefix)) + .filter((entry) => entry.value.trim().length > 0) + .map((entry) => { + const host = gitHostFromKey(entry.key) + return { host, token: entry.value, user: userByHost.get(host) ?? "" } + }) +} + +type GitEnvContext = { + readonly host: string + readonly fs: FileSystem + readonly envPath: string + readonly current: string +} + +// CHANGE: share the host-validated env prologue between login and logout +// WHY: both mutators normalize the host, resolve and read the env file identically +// QUOTE(ТЗ): "реализовать возможность добавлять git подключения отличных от gitlab, github" +// REF: issue-368 +// SOURCE: n/a +// FORMAT THEOREM: withGitHostEnv(cmd, use) fails when host is empty, else runs use over the read env +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: an empty host always yields a typed AuthError before any write +// COMPLEXITY: O(n) where n = |env entries| +const withGitHostEnv = ( + command: { readonly host: string; readonly envGlobalPath: string }, + use: (context: GitEnvContext) => Effect.Effect +): Effect.Effect => + withFsPathContext(({ cwd, fs, path }) => + Effect.gen(function*(_) { + const host = normalizeGitHost(command.host) + if (host.length === 0) { + return yield* _(Effect.fail(new AuthError({ message: "Git host is required (use --host )." }))) + } + const envPath = resolvePathFromCwd(path, cwd, command.envGlobalPath) + yield* _(ensureEnvFile(fs, path, envPath)) + const current = yield* _(readEnvText(fs, envPath)) + return yield* _(use({ host, fs, envPath, current })) + }) + ) + +export const authGitLogin = ( + command: AuthGitLoginCommand +): Effect.Effect => + withGitHostEnv(command, ({ current, envPath, fs, host }) => + Effect.gen(function*(_) { + const token = command.token?.trim() ?? "" + if (token.length === 0) { + return yield* _(Effect.fail(new AuthError({ message: "Git token is required (use --token )." }))) + } + const user = command.user?.trim() ?? "" + const nextText = upsertEnvKey( + upsertEnvKey(current, buildGitTokenKey(command.host), token), + buildGitUserKey(command.host), + user.length > 0 ? user : defaultGitUser + ) + yield* _(fs.writeFileString(envPath, nextText)) + yield* _(Effect.log(`Git token stored (${host}) in ${envPath}`)) + yield* _(autoSyncState(`chore(state): auth git ${host}`)) + })) + +export const authGitStatus = ( + command: AuthGitStatusCommand +): Effect.Effect, PlatformError, GitFsRuntime> => + withFsPathContext(({ cwd, fs, path }) => + Effect.gen(function*(_) { + const envPath = resolvePathFromCwd(path, cwd, command.envGlobalPath) + const current = yield* _(readEnvText(fs, envPath)) + const connections = listGitConnections(current) + if (connections.length === 0) { + yield* _(Effect.log(`No generic git connections in ${envPath}.`)) + } else { + const lines = connections.map((entry) => `- ${entry.host} (user: ${entry.user || defaultGitUser})`) + yield* _(Effect.log([`Git connections (${connections.length}):`, ...lines].join("\n"))) + } + return connections + }) + ) + +export const authGitLogout = ( + command: AuthGitLogoutCommand +): Effect.Effect => + withGitHostEnv(command, ({ current, envPath, fs, host }) => + Effect.gen(function*(_) { + const nextText = removeEnvKey( + removeEnvKey(current, buildGitTokenKey(command.host)), + buildGitUserKey(command.host) + ) + yield* _(fs.writeFileString(envPath, nextText)) + yield* _(Effect.log(`Git token removed (${host}) from ${envPath}`)) + yield* _(autoSyncState(`chore(state): auth git logout ${host}`)) + })) diff --git a/packages/lib/tests/core/templates.test.ts b/packages/lib/tests/core/templates.test.ts index ce980124..a0ec3e08 100644 --- a/packages/lib/tests/core/templates.test.ts +++ b/packages/lib/tests/core/templates.test.ts @@ -602,6 +602,30 @@ describe("renderEntrypoint auth bridge", () => { expect(entrypoint).not.toContain('if [[ "$GITHUB_AUTH_SKIP" != "1" && -n "$AUTH_LABEL_RAW" ]]; then') }) + // CHANGE: assert the per-host generic git credential resolution + env export + // WHY: issue #368 wants git connections to providers other than github/gitlab via token + // QUOTE(ТЗ): "реализовать возможность добавлять git подключения отличных от gitlab, github" + // REF: issue-368 + // SOURCE: https://git-scm.com/docs/gitcredentials + // FORMAT THEOREM: helper resolves GIT_AUTH_TOKEN__ before github/gitlab defaults + // PURITY: CORE (string assertions over rendered shell) + // EFFECT: n/a + // INVARIANT: HOST_KEY normalization mirrors the CLI/web auth flows + // COMPLEXITY: O(n) where n = |entrypoint| + it("renders host-scoped generic git credential resolution and env export", () => { + const entrypoint = renderAuthEntrypoint() + + expectContainsAll(entrypoint, [ + "GIT_AUTH_HOSTS_ENV_FILE=\"/etc/profile.d/git-auth-hosts.sh\"", + "for GIT_AUTH_HOST_VAR in $(compgen -v | grep -E '^GIT_AUTH_(TOKEN|USER)__' || true); do", + "docker_git_upsert_ssh_env \"$GIT_AUTH_HOST_VAR\" \"$GIT_AUTH_HOST_VAL\"", + "host_key=\"$(printf \"%s\" \"$host\" | tr '[:lower:]' '[:upper:]' | sed -E 's/[^A-Z0-9]+/_/g; s/^_+//; s/_+$//')\"", + "if [[ \"$protocol\" == \"https\" && -n \"$host_key\" ]]; then", + "host_token_key=\"GIT_AUTH_TOKEN__$host_key\"", + "host_user_key=\"GIT_AUTH_USER__$host_key\"" + ]) + }) + it("renders Claude auth and wrapper bootstrap wiring", () => { const entrypoint = renderAuthEntrypoint() diff --git a/packages/lib/tests/usecases/auth-git.test.ts b/packages/lib/tests/usecases/auth-git.test.ts new file mode 100644 index 00000000..499d305c --- /dev/null +++ b/packages/lib/tests/usecases/auth-git.test.ts @@ -0,0 +1,171 @@ +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 { + authGitLogin, + authGitLogout, + authGitStatus, + buildGitTokenKey, + buildGitUserKey, + gitHostFromKey, + listGitConnections, + normalizeGitHost +} from "../../src/usecases/auth-git.js" + +// CHANGE: cover the generic per-host git auth usecase end to end +// WHY: issue #368 adds git connections to providers other than github/gitlab via token +// QUOTE(ТЗ): "реализовать возможность добавлять git подключения отличных от gitlab, github ... просто здавая токен" +// REF: issue-368 +// SOURCE: n/a +// FORMAT THEOREM: forall host: login(host, token) then status() contains {host_key, token} +// PURITY: SHELL (filesystem-backed Effects) / CORE (pure helper assertions) +// EFFECT: Effect<..., AuthError | PlatformError, FileSystem | Path | CommandExecutor> +// INVARIANT: env keys mirror the in-container credential helper host normalization; tokens never logged +// COMPLEXITY: O(n) where n = |env entries| + +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-git-" + }) + ) + return yield* _(use(tempDir)) + }) + ) + +describe("normalizeGitHost", () => { + it("strips scheme, credentials and path, then upper-snake-cases the host", () => { + expect(normalizeGitHost("https://git.example.com/group/repo.git")).toBe("GIT_EXAMPLE_COM") + expect(normalizeGitHost("ssh://user@gitea.internal:2222/x")).toBe("GITEA_INTERNAL_2222") + expect(normalizeGitHost("bitbucket.example.org")).toBe("BITBUCKET_EXAMPLE_ORG") + expect(normalizeGitHost(" ")).toBe("") + expect(normalizeGitHost(null)).toBe("") + }) +}) + +describe("buildGitTokenKey / buildGitUserKey", () => { + it("derives the host-scoped env key, falling back to the global key for an empty host", () => { + expect(buildGitTokenKey("git.example.com")).toBe("GIT_AUTH_TOKEN__GIT_EXAMPLE_COM") + expect(buildGitUserKey("git.example.com")).toBe("GIT_AUTH_USER__GIT_EXAMPLE_COM") + expect(buildGitTokenKey("")).toBe("GIT_AUTH_TOKEN") + expect(buildGitUserKey("")).toBe("GIT_AUTH_USER") + }) +}) + +describe("gitHostFromKey", () => { + it("recovers the host suffix from token/user keys and defaults otherwise", () => { + expect(gitHostFromKey("GIT_AUTH_TOKEN__GIT_EXAMPLE_COM")).toBe("GIT_EXAMPLE_COM") + expect(gitHostFromKey("GIT_AUTH_USER__GIT_EXAMPLE_COM")).toBe("GIT_EXAMPLE_COM") + expect(gitHostFromKey("GIT_AUTH_TOKEN")).toBe("default") + }) +}) + +describe("listGitConnections", () => { + it("pairs host tokens with their users and ignores blank tokens", () => { + const envText = [ + "GIT_AUTH_TOKEN__GIT_EXAMPLE_COM=token-a", + "GIT_AUTH_USER__GIT_EXAMPLE_COM=ci-user", + "GIT_AUTH_TOKEN__EMPTY_HOST=", + "GITHUB_TOKEN=should-be-ignored", + "" + ].join("\n") + + expect(listGitConnections(envText)).toEqual([ + { host: "GIT_EXAMPLE_COM", token: "token-a", user: "ci-user" } + ]) + }) +}) + +describe("auth git usecase", () => { + it.effect("login persists a host-scoped token + user, status reads it, logout removes it", () => + withTempDir((root) => + Effect.gen(function*(_) { + const path = yield* _(Path.Path) + const fs = yield* _(FileSystem.FileSystem) + const envGlobalPath = path.join(root, ".orch", "env", "global.env") + + yield* _( + authGitLogin({ + _tag: "AuthGitLogin", + host: "git.example.com", + token: "secret-token", + user: "deploy-bot", + envGlobalPath + }) + ) + + const afterLogin = yield* _(fs.readFileString(envGlobalPath)) + expect(afterLogin).toContain("GIT_AUTH_TOKEN__GIT_EXAMPLE_COM=secret-token") + expect(afterLogin).toContain("GIT_AUTH_USER__GIT_EXAMPLE_COM=deploy-bot") + + const connections = yield* _( + authGitStatus({ _tag: "AuthGitStatus", envGlobalPath }) + ) + expect(connections).toEqual([ + { host: "GIT_EXAMPLE_COM", token: "secret-token", user: "deploy-bot" } + ]) + + yield* _( + authGitLogout({ _tag: "AuthGitLogout", host: "git.example.com", envGlobalPath }) + ) + + const afterLogout = yield* _(fs.readFileString(envGlobalPath)) + expect(afterLogout).not.toContain("GIT_AUTH_TOKEN__GIT_EXAMPLE_COM") + expect(afterLogout).not.toContain("GIT_AUTH_USER__GIT_EXAMPLE_COM") + + const empty = yield* _(authGitStatus({ _tag: "AuthGitStatus", envGlobalPath })) + expect(empty).toEqual([]) + }) + ).pipe(Effect.provide(NodeContext.layer))) + + it.effect("login defaults the HTTPS user to x-access-token when none is given", () => + withTempDir((root) => + Effect.gen(function*(_) { + const path = yield* _(Path.Path) + const fs = yield* _(FileSystem.FileSystem) + const envGlobalPath = path.join(root, ".orch", "env", "global.env") + + yield* _( + authGitLogin({ + _tag: "AuthGitLogin", + host: "gitea.internal", + token: "t", + user: null, + envGlobalPath + }) + ) + + const text = yield* _(fs.readFileString(envGlobalPath)) + expect(text).toContain("GIT_AUTH_USER__GITEA_INTERNAL=x-access-token") + }) + ).pipe(Effect.provide(NodeContext.layer))) + + it.effect("login fails with a typed AuthError when the host is empty", () => + withTempDir((root) => + Effect.gen(function*(_) { + const path = yield* _(Path.Path) + const envGlobalPath = path.join(root, ".orch", "env", "global.env") + const result = yield* _( + authGitLogin({ + _tag: "AuthGitLogin", + host: "", + token: "t", + user: null, + envGlobalPath + }).pipe(Effect.either) + ) + expect(result._tag).toBe("Left") + if (result._tag === "Left") { + expect(result.left._tag).toBe("AuthError") + } + }) + ).pipe(Effect.provide(NodeContext.layer))) +})