From aeadaf484473d5146e8513e776c668ac8ad38e74 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Sat, 9 May 2026 09:37:54 +0000 Subject: [PATCH 1/7] feat(gitlab): add GitLab auth and repo support --- packages/api/src/api/contracts.ts | 21 ++ packages/api/src/api/errors.ts | 2 +- packages/api/src/api/schema.ts | 11 + packages/api/src/http.ts | 45 +++ .../src/services/auth-gitlab-login-stream.ts | 269 ++++++++++++++++ packages/api/src/services/auth.ts | 144 ++++++++- packages/api/src/services/projects.ts | 3 +- .../tests/auth-gitlab-login-stream.test.ts | 26 ++ packages/api/tests/auth.test.ts | 136 ++++++++ packages/api/tests/schema.test.ts | 36 +++ .../app/src/docker-git/api-client-auth.ts | 44 ++- packages/app/src/docker-git/api-client.ts | 5 +- .../app/src/docker-git/cli/parser-auth.ts | 42 ++- packages/app/src/docker-git/cli/usage.ts | 9 +- .../frontend-lib/core/auth-domain.ts | 21 ++ .../docker-git/frontend-lib/core/domain.ts | 5 +- .../src/docker-git/frontend-lib/core/repo.ts | 107 ++++++- packages/app/src/docker-git/program-auth.ts | 121 ++++++++ .../app/src/docker-git/program-unsupported.ts | 12 +- packages/app/src/docker-git/program.ts | 66 +--- packages/app/src/lib/core/auth-domain.ts | 21 ++ packages/app/src/lib/core/domain.ts | 5 +- packages/app/src/lib/core/repo.ts | 109 ++++++- .../src/lib/core/templates-entrypoint/base.ts | 11 +- .../core/templates-entrypoint/git-hooks.ts | 169 ++++++++++ .../src/lib/core/templates-entrypoint/git.ts | 263 +++++----------- .../lib/core/templates-entrypoint/tasks.ts | 96 +++--- .../app/src/lib/core/templates/dockerfile.ts | 58 +--- packages/app/src/lib/core/templates/glab.ts | 21 ++ packages/app/src/lib/core/templates/tools.ts | 52 ++++ .../lib/usecases/actions/create-project.ts | 2 + packages/app/src/lib/usecases/auth-gitlab.ts | 293 ++++++++++++++++++ .../app/src/lib/usecases/auth-sync-helpers.ts | 7 +- .../app/src/lib/usecases/gitlab-auth-image.ts | 55 ++++ .../lib/usecases/gitlab-token-preflight.ts | 169 ++++++++++ .../lib/usecases/gitlab-token-validation.ts | 84 +++++ .../app/src/shared/auth-stream-markers.ts | 26 +- .../docker-git/auth-stream-markers.test.ts | 14 + .../app/tests/docker-git/parser-auth.test.ts | 39 ++- .../docker-git/parser-gitlab-repo.test.ts | 115 +++++++ packages/app/tests/docker-git/program.test.ts | 22 ++ packages/lib/src/core/auth-domain.ts | 21 ++ packages/lib/src/core/domain.ts | 5 +- packages/lib/src/core/repo.ts | 107 ++++++- .../lib/src/core/templates-entrypoint/base.ts | 9 +- .../core/templates-entrypoint/git-hooks.ts | 169 ++++++++++ .../lib/src/core/templates-entrypoint/git.ts | 261 +++++----------- .../src/core/templates-entrypoint/tasks.ts | 94 +++--- packages/lib/src/core/templates/dockerfile.ts | 56 +--- packages/lib/src/core/templates/glab.ts | 21 ++ packages/lib/src/core/templates/tools.ts | 52 ++++ .../src/usecases/actions/create-project.ts | 2 + packages/lib/src/usecases/auth-gitlab.ts | 293 ++++++++++++++++++ .../lib/src/usecases/auth-sync-helpers.ts | 7 +- .../lib/src/usecases/gitlab-auth-image.ts | 55 ++++ .../src/usecases/gitlab-token-preflight.ts | 169 ++++++++++ .../src/usecases/gitlab-token-validation.ts | 84 +++++ packages/lib/tests/core/templates.test.ts | 18 +- .../tests/usecases/auth-gitlab-status.test.ts | 128 ++++++++ packages/lib/tests/usecases/auth-sync.test.ts | 4 + .../usecases/gitlab-token-preflight.test.ts | 168 ++++++++++ 61 files changed, 3785 insertions(+), 694 deletions(-) create mode 100644 packages/api/src/services/auth-gitlab-login-stream.ts create mode 100644 packages/api/tests/auth-gitlab-login-stream.test.ts create mode 100644 packages/app/src/docker-git/program-auth.ts create mode 100644 packages/app/src/lib/core/templates-entrypoint/git-hooks.ts create mode 100644 packages/app/src/lib/core/templates/glab.ts create mode 100644 packages/app/src/lib/core/templates/tools.ts create mode 100644 packages/app/src/lib/usecases/auth-gitlab.ts create mode 100644 packages/app/src/lib/usecases/gitlab-auth-image.ts create mode 100644 packages/app/src/lib/usecases/gitlab-token-preflight.ts create mode 100644 packages/app/src/lib/usecases/gitlab-token-validation.ts create mode 100644 packages/app/tests/docker-git/parser-gitlab-repo.test.ts create mode 100644 packages/lib/src/core/templates-entrypoint/git-hooks.ts create mode 100644 packages/lib/src/core/templates/glab.ts create mode 100644 packages/lib/src/core/templates/tools.ts create mode 100644 packages/lib/src/usecases/auth-gitlab.ts create mode 100644 packages/lib/src/usecases/gitlab-auth-image.ts create mode 100644 packages/lib/src/usecases/gitlab-token-preflight.ts create mode 100644 packages/lib/src/usecases/gitlab-token-validation.ts create mode 100644 packages/lib/tests/usecases/auth-gitlab-status.test.ts create mode 100644 packages/lib/tests/usecases/gitlab-token-preflight.test.ts diff --git a/packages/api/src/api/contracts.ts b/packages/api/src/api/contracts.ts index e326c9ba..2474919c 100644 --- a/packages/api/src/api/contracts.ts +++ b/packages/api/src/api/contracts.ts @@ -152,12 +152,29 @@ export type GithubAuthStatus = { readonly tokens: ReadonlyArray } +export type GitlabAuthTokenStatus = { + readonly key: string + readonly label: string + readonly status: "valid" | "invalid" | "unknown" + readonly login: string | null +} + +export type GitlabAuthStatus = { + readonly summary: string + readonly tokens: ReadonlyArray +} + export type GithubAuthLoginRequest = { readonly label?: string | null | undefined readonly token?: string | null | undefined readonly scopes?: string | null | undefined } +export type GitlabAuthLoginRequest = { + readonly label?: string | null | undefined + readonly token?: string | null | undefined +} + export type AuthMenuFlow = | "GithubRemove" | "GitSet" @@ -197,6 +214,10 @@ export type GithubAuthLogoutRequest = { readonly label?: string | null | undefined } +export type GitlabAuthLogoutRequest = { + readonly label?: string | null | undefined +} + export type CodexAuthImportRequest = { readonly label?: string | null | undefined readonly authText: string diff --git a/packages/api/src/api/errors.ts b/packages/api/src/api/errors.ts index a4013e11..4c35eb4e 100644 --- a/packages/api/src/api/errors.ts +++ b/packages/api/src/api/errors.ts @@ -1,7 +1,7 @@ import { Data } from "effect" export class ApiAuthRequiredError extends Data.TaggedError("ApiAuthRequiredError")<{ - readonly provider: "github" + readonly provider: "github" | "gitlab" readonly message: string readonly command: string }> {} diff --git a/packages/api/src/api/schema.ts b/packages/api/src/api/schema.ts index 5fa180f1..a8386db6 100644 --- a/packages/api/src/api/schema.ts +++ b/packages/api/src/api/schema.ts @@ -46,6 +46,11 @@ export const GithubAuthLoginRequestSchema = Schema.Struct({ scopes: OptionalNullableString }) +export const GitlabAuthLoginRequestSchema = Schema.Struct({ + label: OptionalNullableString, + token: OptionalNullableString +}) + export const AuthMenuFlowSchema = Schema.Literal( "GithubRemove", "GitSet", @@ -74,6 +79,10 @@ export const GithubAuthLogoutRequestSchema = Schema.Struct({ label: OptionalNullableString }) +export const GitlabAuthLogoutRequestSchema = Schema.Struct({ + label: OptionalNullableString +}) + export const CodexAuthImportRequestSchema = Schema.Struct({ label: OptionalNullableString, authText: Schema.String @@ -282,9 +291,11 @@ 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 AuthMenuRequestInput = Schema.Schema.Type export type AuthTerminalSessionRequestInput = Schema.Schema.Type export type GithubAuthLogoutRequestInput = Schema.Schema.Type +export type GitlabAuthLogoutRequestInput = Schema.Schema.Type export type CodexAuthImportRequestInput = Schema.Schema.Type export type CodexAuthLoginRequestInput = Schema.Schema.Type export type CodexAuthLogoutRequestInput = Schema.Schema.Type diff --git a/packages/api/src/http.ts b/packages/api/src/http.ts index 107aa25e..47b06361 100644 --- a/packages/api/src/http.ts +++ b/packages/api/src/http.ts @@ -23,6 +23,8 @@ import { CreateProjectRequestSchema, ExchangePollRequestSchema, ExchangeSubscribeRequestSchema, + GitlabAuthLoginRequestSchema, + GitlabAuthLogoutRequestSchema, GithubAuthLoginRequestSchema, GithubAuthLogoutRequestSchema, ProjectDatabaseProfileRequestSchema, @@ -40,13 +42,17 @@ import { defaultProjectsRoot } from "@effect-template/lib/usecases/menu-helpers" import { resolveWorkspaceRoot } from "@effect-template/lib/shell/workspace-root" import { importCodexAuth, + loginGitlabAuth, loginGithubAuth, logoutCodexAuth, + logoutGitlabAuth, logoutGithubAuth, readCodexAuthStatus, + readGitlabAuthStatus, readGithubAuthStatus, } from "./services/auth.js" import { readAuthMenuSnapshot, runAuthMenuFlow } from "./services/auth-menu.js" +import { streamGitlabAuthLogin } from "./services/auth-gitlab-login-stream.js" import { streamGithubAuthLogin } from "./services/auth-github-login-stream.js" import { createAuthTerminalSession, deleteAuthTerminalSession } from "./services/auth-terminal-sessions.js" import { streamCodexAuthLogin } from "./services/auth-codex-login-stream.js" @@ -352,6 +358,8 @@ const readCreateProjectRequest = () => HttpServerRequest.schemaBodyJson(CreatePr const readCreateFollowRequest = () => HttpServerRequest.schemaBodyJson(CreateFollowRequestSchema) const readGithubAuthLoginRequest = () => HttpServerRequest.schemaBodyJson(GithubAuthLoginRequestSchema) const readGithubAuthLogoutRequest = () => HttpServerRequest.schemaBodyJson(GithubAuthLogoutRequestSchema) +const readGitlabAuthLoginRequest = () => HttpServerRequest.schemaBodyJson(GitlabAuthLoginRequestSchema) +const readGitlabAuthLogoutRequest = () => HttpServerRequest.schemaBodyJson(GitlabAuthLogoutRequestSchema) const readAuthMenuRequest = () => HttpServerRequest.schemaBodyJson(AuthMenuRequestSchema) const readAuthTerminalSessionRequest = () => HttpServerRequest.schemaBodyJson(AuthTerminalSessionRequestSchema) const readCodexAuthImportRequest = () => HttpServerRequest.schemaBodyJson(CodexAuthImportRequestSchema) @@ -502,6 +510,13 @@ export const makeRouter = () => { return yield* _(jsonResponse({ status }, 200)) }).pipe(Effect.catchAll(errorResponse)) ), + HttpRouter.get( + "/auth/gitlab/status", + Effect.gen(function*(_) { + const status = yield* _(readGitlabAuthStatus()) + return yield* _(jsonResponse({ status }, 200)) + }).pipe(Effect.catchAll(errorResponse)) + ), HttpRouter.get( "/auth/menu", Effect.gen(function*(_) { @@ -531,6 +546,28 @@ export const makeRouter = () => { return yield* _(jsonResponse({ ok: true, status }, 201)) }).pipe(Effect.catchAll(errorResponse)) ), + HttpRouter.post( + "/auth/gitlab/login/stream", + Effect.gen(function*(_) { + const request = yield* _(readGitlabAuthLoginRequest()) + const outputStream = yield* _(streamGitlabAuthLogin(request)) + return HttpServerResponse.stream(outputStream, { + status: 200, + headers: { + "content-type": "text/plain; charset=utf-8", + "cache-control": "no-cache" + } + }) + }).pipe(Effect.catchAll(errorResponse)) + ), + HttpRouter.post( + "/auth/gitlab/login", + Effect.gen(function*(_) { + const request = yield* _(readGitlabAuthLoginRequest()) + const status = yield* _(loginGitlabAuth(request)) + return yield* _(jsonResponse({ ok: true, status }, 201)) + }).pipe(Effect.catchAll(errorResponse)) + ), HttpRouter.post( "/auth/menu", Effect.gen(function*(_) { @@ -575,6 +612,14 @@ export const makeRouter = () => { return yield* _(jsonResponse({ ok: true, status }, 200)) }).pipe(Effect.catchAll(errorResponse)) ), + HttpRouter.post( + "/auth/gitlab/logout", + Effect.gen(function*(_) { + const request = yield* _(readGitlabAuthLogoutRequest()) + const status = yield* _(logoutGitlabAuth(request)) + return yield* _(jsonResponse({ ok: true, status }, 200)) + }).pipe(Effect.catchAll(errorResponse)) + ), HttpRouter.get( "/auth/codex/status", Effect.gen(function*(_) { diff --git a/packages/api/src/services/auth-gitlab-login-stream.ts b/packages/api/src/services/auth-gitlab-login-stream.ts new file mode 100644 index 00000000..7886f15c --- /dev/null +++ b/packages/api/src/services/auth-gitlab-login-stream.ts @@ -0,0 +1,269 @@ +import type * as CommandExecutor from "@effect/platform/CommandExecutor" +import type { PlatformError } from "@effect/platform/Error" +import * as FileSystem from "@effect/platform/FileSystem" +import * as Path from "@effect/platform/Path" +import { defaultTemplateConfig } from "@effect-template/lib/core/template-defaults" +import { buildDockerAuthArgs, resolveDockerVolumeHostPath, runDockerAuthCapture } from "@effect-template/lib/shell/docker-auth" +import { CommandFailedError } from "@effect-template/lib/shell/errors" +import { buildDockerAuthSpec, normalizeAccountLabel } from "@effect-template/lib/usecases/auth-helpers" +import { migrateLegacyOrchLayout } from "@effect-template/lib/usecases/auth-sync" +import { buildGitlabTokenKey, extractGitlabTokenFromStatusOutput, gitlabLabelFromKey } from "@effect-template/lib/usecases/auth-gitlab" +import { ensureEnvFile, readEnvText, upsertEnvKey } from "@effect-template/lib/usecases/env-file" +import { ensureGlabAuthImage, gitlabAuthDir, gitlabAuthRoot, gitlabImageName } from "@effect-template/lib/usecases/gitlab-auth-image" +import { resolvePathFromCwd } from "@effect-template/lib/usecases/path-helpers" +import { autoSyncState } from "@effect-template/lib/usecases/state-repo" +import { Effect, Logger, Runtime } from "effect" +import * as Stream from "effect/Stream" +import { spawn, type ChildProcess } from "node:child_process" + +import type { GitlabAuthLoginRequest } from "../api/contracts.js" +import { ApiBadRequestError, ApiInternalError } from "../api/errors.js" + +type GitlabRuntime = FileSystem.FileSystem | Path.Path | CommandExecutor.CommandExecutor +type GitlabSetupError = CommandFailedError | PlatformError + +type PreparedGitlabLogin = { + readonly cwd: string + readonly args: ReadonlyArray + readonly accountPath: string + readonly envPath: string + readonly key: string + readonly label: string +} + +const gitlabLoginStreamSuccessMarker = "__DOCKER_GIT_GITLAB_LOGIN_STATUS__:ok" +const gitlabLoginStreamErrorMarkerPrefix = "__DOCKER_GIT_GITLAB_LOGIN_STATUS__:error:" + +const ensureGitlabOrchLayout = ( + cwd: string, + envGlobalPath: string +): Effect.Effect => + migrateLegacyOrchLayout(cwd, { + envGlobalPath, + envProjectPath: defaultTemplateConfig.envProjectPath, + codexAuthPath: defaultTemplateConfig.codexAuthPath, + ghAuthPath: ".docker-git/.orch/auth/gh", + gitlabAuthPath: gitlabAuthRoot, + claudeAuthPath: ".docker-git/.orch/auth/claude" + }) + +const toApiError = (error: GitlabSetupError): ApiBadRequestError | ApiInternalError => + error._tag === "CommandFailedError" + ? new ApiBadRequestError({ + message: `${error.command} failed (exit ${error.exitCode}).` + }) + : new ApiInternalError({ + message: String(error), + cause: error + }) + +const prepareGitlabLogin = ( + request: GitlabAuthLoginRequest +): Effect.Effect => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const cwd = process.cwd() + + yield* _( + ensureGitlabOrchLayout(cwd, defaultTemplateConfig.envGlobalPath).pipe( + Effect.mapError(toApiError) + ) + ) + + const envPath = resolvePathFromCwd(path, cwd, defaultTemplateConfig.envGlobalPath) + const rootPath = resolvePathFromCwd(path, cwd, gitlabAuthRoot) + const label = normalizeAccountLabel(request.label ?? null, "default") + const accountPath = path.join(rootPath, label) + const key = buildGitlabTokenKey(request.label ?? null) + + yield* _(fs.makeDirectory(accountPath, { recursive: true }).pipe(Effect.mapError(toApiError))) + yield* _(ensureGlabAuthImage(fs, path, cwd, "glab auth").pipe(Effect.mapError(toApiError))) + + const hostPath = yield* _(resolveDockerVolumeHostPath(cwd, accountPath).pipe(Effect.mapError(toApiError))) + const args = buildDockerAuthArgs( + buildDockerAuthSpec({ + cwd, + image: gitlabImageName, + hostPath, + containerPath: gitlabAuthDir, + env: ["BROWSER=echo", `GLAB_CONFIG_DIR=${gitlabAuthDir}`], + args: [ + "auth", + "login", + "--hostname", + "gitlab.com", + "--web", + "--git-protocol", + "https" + ], + interactive: false + }) + ) + + return { + cwd, + args, + accountPath, + envPath, + key, + label: gitlabLabelFromKey(key) + } + }) + +const resolveGitlabToken = ( + cwd: string, + accountPath: string +): Effect.Effect => + runDockerAuthCapture( + buildDockerAuthSpec({ + cwd, + image: gitlabImageName, + hostPath: accountPath, + containerPath: gitlabAuthDir, + env: `GLAB_CONFIG_DIR=${gitlabAuthDir}`, + args: ["auth", "status", "--hostname", "gitlab.com", "--show-token"], + interactive: false + }), + [0], + (exitCode) => new CommandFailedError({ command: "glab auth status --show-token", exitCode }) + ).pipe( + Effect.map((raw) => extractGitlabTokenFromStatusOutput(raw)), + Effect.filterOrFail( + (value): value is string => value !== null && value.length > 0, + () => new CommandFailedError({ command: "glab auth status --show-token", exitCode: 1 }) + ) + ) + +const persistGitlabToken = ( + fs: FileSystem.FileSystem, + envPath: string, + key: string, + token: string +): Effect.Effect => + Effect.gen(function*(_) { + const current = yield* _(readEnvText(fs, envPath)) + const nextText = upsertEnvKey(current, key, token) + yield* _(fs.writeFileString(envPath, nextText)) + }) + +const finalizeMessage = (status: string): string => + status === "ok" + ? `\nGitLab login completed.\n${gitlabLoginStreamSuccessMarker}\n` + : `\n${gitlabLoginStreamErrorMarkerPrefix}${status}\n` + +const normalizeCapturedLogLines = (lines: ReadonlyArray): ReadonlyArray => + lines + .map((line) => line.trim()) + .filter((line) => line.length > 0) + +export const renderGitlabPostLoginOutput = ( + lines: ReadonlyArray, + status: string +): string => { + const output = normalizeCapturedLogLines(lines).join("\n") + const logBlock = output.length === 0 ? "" : `\n${output}\n` + return `${logBlock}${finalizeMessage(status)}` +} + +const toStreamError = (error: unknown): ApiInternalError | ApiBadRequestError => + error instanceof ApiBadRequestError || error instanceof ApiInternalError + ? error + : new ApiInternalError({ + message: String(error), + cause: error + }) + +const finalizeGitlabLogin = ( + prepared: PreparedGitlabLogin +): Effect.Effect => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const token = yield* _(resolveGitlabToken(prepared.cwd, prepared.accountPath).pipe(Effect.mapError(toApiError))) + yield* _(ensureEnvFile(fs, path, prepared.envPath).pipe(Effect.mapError(toApiError))) + yield* _(persistGitlabToken(fs, prepared.envPath, prepared.key, token).pipe(Effect.mapError(toApiError))) + yield* _(autoSyncState(`chore(state): auth gitlab ${prepared.label}`)) + }) + +export const streamGitlabAuthLogin = ( + request: GitlabAuthLoginRequest +): Effect.Effect, ApiBadRequestError | ApiInternalError, GitlabRuntime> => + Effect.gen(function*(_) { + const prepared = yield* _(prepareGitlabLogin(request)) + const encoder = new TextEncoder() + const runPromiseExit = Runtime.runPromiseExit(yield* _(Effect.runtime())) + + let child: ChildProcess | null = null + const readable = new ReadableStream({ + start(controller) { + const enqueue = (chunk: Buffer | string) => { + const encoded = typeof chunk === "string" ? encoder.encode(chunk) : new Uint8Array(chunk) + controller.enqueue(encoded) + } + + enqueue("Starting GitLab auth login in container...\n") + + child = spawn("docker", prepared.args, { + cwd: prepared.cwd, + stdio: ["ignore", "pipe", "pipe"] + }) + + child.stdout?.on("data", enqueue) + child.stderr?.on("data", enqueue) + + child.on("error", (error) => { + controller.error( + new ApiInternalError({ + message: String(error), + cause: error + }) + ) + }) + + child.on("close", (code) => { + const exitCode = code ?? 1 + if (exitCode !== 0) { + enqueue(finalizeMessage(String(exitCode))) + controller.close() + return + } + + const postLoginLogs: Array = [] + const logger = Logger.make(({ message }) => { + postLoginLogs.push(String(message)) + }) + + void runPromiseExit( + finalizeGitlabLogin(prepared).pipe( + Effect.provide(Logger.replace(Logger.defaultLogger, logger)), + Effect.matchEffect({ + onFailure: (error) => + Effect.sync(() => { + enqueue(renderGitlabPostLoginOutput([ + ...postLoginLogs, + `GitLab login finished in browser, but post-login sync failed: ${error.message}` + ], "post-login")) + }), + onSuccess: () => + Effect.sync(() => { + enqueue(renderGitlabPostLoginOutput(postLoginLogs, "ok")) + }) + }) + ) + ).finally(() => { + controller.close() + }) + }) + }, + cancel() { + child?.kill("SIGTERM") + } + }) + + return Stream.fromReadableStream({ + evaluate: () => readable, + onError: toStreamError, + releaseLockOnEnd: true + }) + }) diff --git a/packages/api/src/services/auth.ts b/packages/api/src/services/auth.ts index ead98612..a61da8b8 100644 --- a/packages/api/src/services/auth.ts +++ b/packages/api/src/services/auth.ts @@ -3,11 +3,20 @@ import type * as CommandExecutor from "@effect/platform/CommandExecutor" import type { PlatformError } from "@effect/platform/Error" import * as Path from "@effect/platform/Path" import { defaultTemplateConfig } from "@effect-template/lib/core/template-defaults" -import { parseGithubRepoUrl } from "@effect-template/lib/core/repo" +import { parseGithubRepoUrl, parseGitlabRepoUrl } from "@effect-template/lib/core/repo" import { CommandFailedError } from "@effect-template/lib/shell/errors" import { authCodexLogin as runCodexLogin } from "@effect-template/lib/usecases/auth-codex" +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" +import { + gitlabRepoAccessMessage, + gitlabRepoAccessWarning, + gitlabInvalidTokenMessage, + probeGitlabRepoAccess, + resolveGitlabCloneAuthToken +} from "@effect-template/lib/usecases/gitlab-token-preflight" +import { validateGitlabToken, type GitlabTokenValidationResult } from "@effect-template/lib/usecases/gitlab-token-validation" import { githubRepoAccessMessage, githubRepoAccessWarning, @@ -25,6 +34,10 @@ import type { CodexAuthLoginRequest, CodexAuthLogoutRequest, CodexAuthStatus, + GitlabAuthLoginRequest, + GitlabAuthLogoutRequest, + GitlabAuthStatus, + GitlabAuthTokenStatus, GithubAuthLoginRequest, GithubAuthLogoutRequest, GithubAuthStatus, @@ -37,6 +50,11 @@ export const githubAuthRequiredMessage = [ "GitHub auth is missing: no GitHub token/key was found for this repository.", "If the repository requires access, run: docker-git auth github login --web" ].join("\n") +export const gitlabAuthRequiredCommand = "docker-git auth gitlab login --web" +export const gitlabAuthRequiredMessage = [ + "GitLab auth is missing: no GitLab token/key was found for this repository.", + "If the repository requires access, run: docker-git auth gitlab login --web" +].join("\n") export const githubAuthEnvGlobalPath = defaultTemplateConfig.envGlobalPath export const codexAuthPath = defaultTemplateConfig.codexAuthPath @@ -103,6 +121,13 @@ const githubAuthError = (message: string): ApiAuthRequiredError => command: githubAuthRequiredCommand }) +const gitlabAuthError = (message: string): ApiAuthRequiredError => + new ApiAuthRequiredError({ + provider: "gitlab", + message, + command: gitlabAuthRequiredCommand + }) + const toCodexApiError = (error: CodexCommandError): ApiBadRequestError | ApiInternalError => error._tag === "CommandFailedError" ? new ApiBadRequestError({ @@ -251,6 +276,49 @@ const readGithubAuthTokens = ( export const readGithubAuthStatus = (): Effect.Effect => readGithubAuthTokens(githubAuthEnvGlobalPath) +const toGitlabTokenStatus = ( + entry: ReturnType[number], + validation: GitlabTokenValidationResult +): GitlabAuthTokenStatus => ({ + key: entry.key, + label: entry.label, + status: validation.status, + login: validation.login +}) + +const buildGitlabStatusSummary = (tokens: ReadonlyArray): string => + tokens.length === 0 + ? "GitLab not connected (no tokens)." + : `GitLab tokens (${tokens.length}):` + +const readGitlabAuthTokens = ( + 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 entries = listGitlabTokens(envText) + const tokens: ReadonlyArray = yield* _( + Effect.forEach( + entries, + (entry) => + validateGitlabToken(entry.token).pipe( + Effect.map((validation: GitlabTokenValidationResult) => toGitlabTokenStatus(entry, validation)) + ), + { concurrency: "unbounded" } + ) + ) + return { + summary: buildGitlabStatusSummary(tokens), + tokens + } satisfies GitlabAuthStatus + }) + +export const readGitlabAuthStatus = (): Effect.Effect => + readGitlabAuthTokens(githubAuthEnvGlobalPath) + export const loginGithubAuth = (request: GithubAuthLoginRequest) => Effect.gen(function*(_) { yield* _( @@ -265,6 +333,19 @@ export const loginGithubAuth = (request: GithubAuthLoginRequest) => return yield* _(readGithubAuthTokens(githubAuthEnvGlobalPath)) }) +export const loginGitlabAuth = (request: GitlabAuthLoginRequest) => + Effect.gen(function*(_) { + yield* _( + runGitlabLogin({ + _tag: "AuthGitlabLogin", + label: request.label ?? null, + token: request.token ?? null, + envGlobalPath: githubAuthEnvGlobalPath + }) + ) + return yield* _(readGitlabAuthTokens(githubAuthEnvGlobalPath)) + }) + export const loginCodexAuth = ( request: CodexAuthLoginRequest ): Effect.Effect => @@ -296,6 +377,18 @@ export const logoutGithubAuth = (request: GithubAuthLogoutRequest) => return yield* _(readGithubAuthTokens(githubAuthEnvGlobalPath)) }) +export const logoutGitlabAuth = (request: GitlabAuthLogoutRequest) => + Effect.gen(function*(_) { + yield* _( + runGitlabLogout({ + _tag: "AuthGitlabLogout", + label: request.label ?? null, + envGlobalPath: githubAuthEnvGlobalPath + }) + ) + return yield* _(readGitlabAuthTokens(githubAuthEnvGlobalPath)) + }) + const codexAuthStatus = ( present: boolean, label: string, @@ -449,3 +542,52 @@ export const ensureGithubAuthForCreate = (config: { ) ) }) + +export const ensureGitlabAuthForCreate = (config: { + readonly repoUrl: string + readonly gitTokenLabel?: string | undefined + readonly envGlobalPath: string +}): Effect.Effect => + Effect.gen(function*(_) { + if (parseGitlabRepoUrl(config.repoUrl) === null) { + return + } + + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const resolvedEnvPath = resolveControllerEnvPath(path, config.envGlobalPath) + const envText = yield* _(readEnvText(fs, resolvedEnvPath)) + const token = resolveGitlabCloneAuthToken(envText, { + repoUrl: config.repoUrl, + gitTokenLabel: config.gitTokenLabel + }) + + if (token === null) { + return yield* _(Effect.fail(gitlabAuthError(gitlabAuthRequiredMessage))) + } + + const validation: GitlabTokenValidationResult = yield* _(validateGitlabToken(token)) + yield* _( + Match.value(validation.status).pipe( + Match.when("valid", () => Effect.void), + Match.when("invalid", () => Effect.fail(gitlabAuthError(gitlabInvalidTokenMessage))), + Match.when("unknown", () => Effect.logWarning("Unable to validate GitLab token before create; continuing.")), + Match.exhaustive + ) + ) + + const access = yield* _(probeGitlabRepoAccess(config.repoUrl, token)) + return yield* _( + Match.value(access).pipe( + Match.when("accessible", () => Effect.void), + Match.when("notAccessible", () => + Effect.fail( + new ApiBadRequestError({ + message: gitlabRepoAccessMessage(config.repoUrl, true) + }) + )), + Match.when("unknown", () => Effect.logWarning(gitlabRepoAccessWarning)), + Match.exhaustive + ) + ) + }) diff --git a/packages/api/src/services/projects.ts b/packages/api/src/services/projects.ts index 3e843507..81ec2d7f 100644 --- a/packages/api/src/services/projects.ts +++ b/packages/api/src/services/projects.ts @@ -33,7 +33,7 @@ import type { ProjectSummary } from "../api/contracts.js" import { ApiAuthRequiredError, ApiConflictError, ApiInternalError, ApiNotFoundError, ApiBadRequestError } from "../api/errors.js" -import { ensureGithubAuthForCreate } from "./auth.js" +import { ensureGithubAuthForCreate, ensureGitlabAuthForCreate } from "./auth.js" import { clearProjectEvents, emitProjectEvent, latestProjectCursor } from "./events.js" import { resolveCreateAuthorizedKeysContents, resolveManagedAuthorizedKeysContents } from "./project-authorized-keys.js" import { projectShortKey } from "./project-port-proxy-core.js" @@ -477,6 +477,7 @@ const prepareCreateProjectRequest = ( yield* _(seedAuthorizedKeysForCreate(command.outDir, resolvedAuthorizedKeysContents)) yield* _(ensureGithubAuthForCreate(command.config)) + yield* _(ensureGitlabAuthForCreate(command.config)) return { command, diff --git a/packages/api/tests/auth-gitlab-login-stream.test.ts b/packages/api/tests/auth-gitlab-login-stream.test.ts new file mode 100644 index 00000000..dbe5a89e --- /dev/null +++ b/packages/api/tests/auth-gitlab-login-stream.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest" + +import { renderGitlabPostLoginOutput } from "../src/services/auth-gitlab-login-stream.js" + +describe("GitLab auth login stream", () => { + it("renders post-login state logs before the success marker", () => { + const output = renderGitlabPostLoginOutput([ + "GitLab token stored (default) in /home/dev/.docker-git/.orch/env/global.env" + ], "ok") + + expect(output).toContain("GitLab token stored") + expect(output).toContain("GitLab login completed.") + expect(output).toContain("__DOCKER_GIT_GITLAB_LOGIN_STATUS__:ok") + expect(output.indexOf("GitLab token stored")).toBeLessThan(output.indexOf("GitLab login completed.")) + }) + + it("renders post-login failure details before the failure marker", () => { + const output = renderGitlabPostLoginOutput([ + "GitLab login finished in browser, but post-login sync failed: glab auth status failed" + ], "post-login") + + expect(output).toContain("post-login sync failed") + expect(output).toContain("__DOCKER_GIT_GITLAB_LOGIN_STATUS__:error:post-login") + expect(output.indexOf("post-login sync failed")).toBeLessThan(output.indexOf("__DOCKER_GIT_GITLAB_LOGIN_STATUS__")) + }) +}) diff --git a/packages/api/tests/auth.test.ts b/packages/api/tests/auth.test.ts index edbfca1c..59891787 100644 --- a/packages/api/tests/auth.test.ts +++ b/packages/api/tests/auth.test.ts @@ -8,13 +8,16 @@ import * as Scope from "effect/Scope" import { vi } from "vitest" import { githubRepoAccessMessage } from "@effect-template/lib/usecases/github-token-preflight" +import { gitlabRepoAccessMessage } from "@effect-template/lib/usecases/gitlab-token-preflight" import { ApiAuthRequiredError } from "../src/api/errors.js" import { ensureGithubAuthForCreate, + ensureGitlabAuthForCreate, importCodexAuth, logoutCodexAuth, readCodexAuthStatus, + readGitlabAuthStatus, readGithubAuthStatus } from "../src/services/auth.js" import { createProjectFromRequest } from "../src/services/projects.js" @@ -177,6 +180,49 @@ describe("api auth", () => { }) ).pipe(Effect.provide(NodeContext.layer))) + it.effect("reads GitLab auth status from the controller env file", () => + 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, "GITLAB_TOKEN=live-token\n")) + + const fetchMock = vi.fn(() => + Effect.runPromise( + Effect.succeed( + new Response(JSON.stringify({ username: "gitlab-user" }), { + status: 200, + headers: { + "content-type": "application/json" + } + }) + ) + ) + ) + + const status = yield* _( + withProjectsRoot( + projectsRoot, + withWorkingDirectory( + root, + withPatchedFetch(fetchMock, readGitlabAuthStatus()) + ) + ) + ) + + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(status.summary).toBe("GitLab tokens (1):") + expect(status.tokens).toHaveLength(1) + expect(status.tokens[0]?.status).toBe("valid") + expect(status.tokens[0]?.login).toBe("gitlab-user") + }) + ).pipe(Effect.provide(NodeContext.layer))) + it.effect("skips API GitHub auth gate when anonymous clone override is enabled", () => withTempDir((root) => Effect.gen(function*(_) { @@ -263,6 +309,96 @@ describe("api auth", () => { }) ).pipe(Effect.provide(NodeContext.layer))) + it.effect("returns auth required for GitLab create when no token is stored", () => + 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 failure = yield* _( + withProjectsRoot( + projectsRoot, + withWorkingDirectory( + root, + ensureGitlabAuthForCreate({ + repoUrl: "https://gitlab.com/group/repo.git", + gitTokenLabel: undefined, + envGlobalPath: ".docker-git/.orch/env/global.env" + }).pipe(Effect.flip) + ) + ) + ) + + expect(failure).toBeInstanceOf(ApiAuthRequiredError) + if (failure instanceof ApiAuthRequiredError) { + expect(failure.provider).toBe("gitlab") + expect(failure.command).toBe("docker-git auth gitlab login --web") + } + }) + ).pipe(Effect.provide(NodeContext.layer))) + + it.effect("returns bad request when the selected GitLab token cannot access the repository", () => + 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") + const repoUrl = "https://gitlab.com/group/private-repo.git" + const fetchMock = vi.fn((input) => { + const url = resolveFetchUrl(input) + + if (url === "https://gitlab.com/api/v4/user") { + return Effect.runPromise( + Effect.succeed( + new Response(JSON.stringify({ username: "gitlab-user" }), { + status: 200, + headers: { + "content-type": "application/json" + } + }) + ) + ) + } + + return Effect.runPromise(Effect.succeed(new Response(null, { status: 404 }))) + }) + + yield* _(fs.makeDirectory(envDir, { recursive: true })) + yield* _(fs.writeFileString(envPath, "GITLAB_TOKEN=live-token\n")) + + const failure = yield* _( + withProjectsRoot( + projectsRoot, + withWorkingDirectory( + root, + withPatchedFetch( + fetchMock, + ensureGitlabAuthForCreate({ + repoUrl, + gitTokenLabel: undefined, + envGlobalPath: ".docker-git/.orch/env/global.env" + }).pipe(Effect.flip) + ) + ) + ) + ) + + expect(failure._tag).toBe("ApiBadRequestError") + if (failure._tag === "ApiBadRequestError") { + expect(failure.message).toBe(gitlabRepoAccessMessage(repoUrl, true)) + } + expect(fetchMock).toHaveBeenCalledTimes(2) + }) + ).pipe(Effect.provide(NodeContext.layer))) + it.effect("imports Codex auth into the controller-owned auth directory", () => withTempDir((root) => Effect.gen(function*(_) { diff --git a/packages/api/tests/schema.test.ts b/packages/api/tests/schema.test.ts index fae8b4fa..b4fb4005 100644 --- a/packages/api/tests/schema.test.ts +++ b/packages/api/tests/schema.test.ts @@ -11,6 +11,8 @@ import { ExchangeSubscribeRequestSchema, CreateFollowRequestSchema, CreateProjectRequestSchema, + GitlabAuthLoginRequestSchema, + GitlabAuthLogoutRequestSchema, GithubAuthLoginRequestSchema, GithubAuthLogoutRequestSchema, ProjectBrowserSessionSchema, @@ -149,6 +151,24 @@ describe("api schemas", () => { }) })) + it.effect("decodes gitlab auth login payload", () => + Effect.sync(() => { + const result = Schema.decodeUnknownEither(GitlabAuthLoginRequestSchema)({ + label: "default", + token: "glpat-token" + }) + + Either.match(result, { + onLeft: (error) => { + throw new Error(ParseResult.TreeFormatter.formatIssueSync(error.issue)) + }, + onRight: (value) => { + expect(value.label).toBe("default") + expect(value.token).toBe("glpat-token") + } + }) + })) + it.effect("decodes codex auth import payload", () => Effect.sync(() => { const result = Schema.decodeUnknownEither(CodexAuthImportRequestSchema)({ @@ -215,6 +235,22 @@ describe("api schemas", () => { }) })) + it.effect("decodes gitlab auth logout payload", () => + Effect.sync(() => { + const result = Schema.decodeUnknownEither(GitlabAuthLogoutRequestSchema)({ + label: "default" + }) + + Either.match(result, { + onLeft: (error) => { + throw new Error(ParseResult.TreeFormatter.formatIssueSync(error.issue)) + }, + onRight: (value) => { + expect(value.label).toBe("default") + } + }) + })) + it.effect("decodes apply-all payload", () => Effect.sync(() => { const result = Schema.decodeUnknownEither(ApplyAllRequestSchema)({ diff --git a/packages/app/src/docker-git/api-client-auth.ts b/packages/app/src/docker-git/api-client-auth.ts index c65ebe36..4cef2733 100644 --- a/packages/app/src/docker-git/api-client-auth.ts +++ b/packages/app/src/docker-git/api-client-auth.ts @@ -10,6 +10,8 @@ import { codexLoginStreamMarkers, githubLoginFailureMessage, githubLoginStreamMarkers, + gitlabLoginFailureMessage, + gitlabLoginStreamMarkers, makeVisibleAuthStreamWriter } from "../shared/auth-stream-markers.js" import { request, requestTextStream, requestVoid } from "./api-http.js" @@ -22,7 +24,10 @@ import type { AuthCodexStatusCommand, AuthGithubLoginCommand, AuthGithubLogoutCommand, - AuthGithubStatusCommand + AuthGithubStatusCommand, + AuthGitlabLoginCommand, + AuthGitlabLogoutCommand, + AuthGitlabStatusCommand } from "./frontend-lib/core/domain.js" import { resolvePathFromCwd } from "./frontend-lib/usecases/path-helpers.js" import type { ApiAuthRequiredError, ApiRequestError } from "./host-errors.js" @@ -84,7 +89,7 @@ const requestMarkedAuthStream = ( ) }) -const githubLoginSuccessPayload = (statusPayload: JsonValue): JsonValue => { +const authLoginSuccessPayload = (statusPayload: JsonValue): JsonValue => { const object = asObject(statusPayload) return { ok: true, @@ -106,7 +111,7 @@ const githubWebLogin = ( githubLoginFailureMessage ).pipe( Effect.flatMap(() => request("GET", "/auth/github/status")), - Effect.map((statusPayload) => githubLoginSuccessPayload(statusPayload)) + Effect.map((statusPayload) => authLoginSuccessPayload(statusPayload)) ) export const githubLogin = ( @@ -127,6 +132,39 @@ export const githubLogout = (command: AuthGithubLogoutCommand) => label: command.label }) +const gitlabWebLogin = ( + command: AuthGitlabLoginCommand +): Effect.Effect => + requestMarkedAuthStream( + "/auth/gitlab/login/stream", + { + label: command.label, + token: null + }, + gitlabLoginStreamMarkers, + gitlabLoginFailureMessage + ).pipe( + Effect.flatMap(() => request("GET", "/auth/gitlab/status")), + Effect.map((statusPayload) => authLoginSuccessPayload(statusPayload)) + ) + +export const gitlabLogin = ( + command: AuthGitlabLoginCommand +): Effect.Effect => + command.token !== null && command.token.trim().length > 0 + ? request("POST", "/auth/gitlab/login", { + label: command.label, + token: command.token + }) + : gitlabWebLogin(command) + +export const gitlabStatus = (_command: AuthGitlabStatusCommand) => request("GET", "/auth/gitlab/status") + +export const gitlabLogout = (command: AuthGitlabLogoutCommand) => + requestVoid("POST", "/auth/gitlab/logout", { + label: command.label + }) + 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 6ca6f277..4f5f1495 100644 --- a/packages/app/src/docker-git/api-client.ts +++ b/packages/app/src/docker-git/api-client.ts @@ -29,7 +29,10 @@ export { codexStatus, githubLogin, githubLogout, - githubStatus + githubStatus, + gitlabLogin, + gitlabLogout, + gitlabStatus } from "./api-client-auth.js" export { type ApiContainerTask, diff --git a/packages/app/src/docker-git/cli/parser-auth.ts b/packages/app/src/docker-git/cli/parser-auth.ts index abf1a049..1babd3fd 100644 --- a/packages/app/src/docker-git/cli/parser-auth.ts +++ b/packages/app/src/docker-git/cli/parser-auth.ts @@ -28,6 +28,17 @@ const invalidArgument = (name: string, reason: string): ParseError => ({ reason }) +type ProviderLogoutCommand = Extract + +const providerLogoutCommand = ( + tag: ProviderLogoutCommand["_tag"], + options: AuthOptions +): ProviderLogoutCommand => ({ + _tag: tag, + label: options.label, + envGlobalPath: options.envGlobalPath +}) + const defaultEnvGlobalPath = ".docker-git/.orch/env/global.env" const defaultCodexAuthPath = ".docker-git/.orch/auth/codex" const defaultClaudeAuthPath = ".docker-git/.orch/auth/claude" @@ -61,12 +72,36 @@ const buildGithubCommand = (action: string, options: AuthOptions): Either.Either _tag: "AuthGithubStatus", envGlobalPath: options.envGlobalPath })), - Match.when("logout", () => + Match.when("logout", () => Either.right(providerLogoutCommand("AuthGithubLogout", options))), + Match.orElse(() => Either.left(invalidArgument("auth action", `unknown action '${action}'`))) + ) + +const buildGitlabLoginCommand = (options: AuthOptions): Either.Either => { + if (options.scopes !== null) { + return Either.left(invalidArgument("--scopes", "GitLab auth does not support --scopes")) + } + + if (options.authWeb && options.token !== null) { + return Either.left(invalidArgument("--token", "cannot be combined with --web")) + } + + return Either.right({ + _tag: "AuthGitlabLogin", + label: options.label, + token: options.authWeb ? null : options.token, + envGlobalPath: options.envGlobalPath + }) +} + +const buildGitlabCommand = (action: string, options: AuthOptions): Either.Either => + Match.value(action).pipe( + Match.when("login", () => buildGitlabLoginCommand(options)), + Match.when("status", () => Either.right({ - _tag: "AuthGithubLogout", - label: options.label, + _tag: "AuthGitlabStatus", envGlobalPath: options.envGlobalPath })), + Match.when("logout", () => Either.right(providerLogoutCommand("AuthGitlabLogout", options))), Match.orElse(() => Either.left(invalidArgument("auth action", `unknown action '${action}'`))) ) @@ -164,6 +199,7 @@ const buildAuthCommand = ( Match.value(provider).pipe( Match.when("github", () => buildGithubCommand(action, options)), Match.when("gh", () => buildGithubCommand(action, options)), + Match.when("gitlab", () => buildGitlabCommand(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/usage.ts b/packages/app/src/docker-git/cli/usage.ts index c672cdda..0239ff12 100644 --- a/packages/app/src/docker-git/cli/usage.ts +++ b/packages/app/src/docker-git/cli/usage.ts @@ -34,7 +34,7 @@ Commands: ps, status Show docker compose status for all docker-git projects apply-all Apply docker-git config and refresh all containers (docker compose up); use --active to restrict to running containers only down-all Stop all docker-git containers (docker compose down) - auth Manage GitHub/Codex/Claude Code auth for docker-git + auth Manage GitHub/GitLab/Codex/Claude Code auth for docker-git state Manage docker-git state directory via git (sync across machines) Options: @@ -60,7 +60,7 @@ Options: --project-dir Project directory for open/attach (default: .) --archive Scrap snapshot directory (default: .orch/scrap/session) --mode Scrap mode (default: session) - --git-token