From a7b37f26993445e928c1d305891a4f2e3b3a9547 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Mon, 13 Apr 2026 19:23:00 +0000 Subject: [PATCH 1/3] feat(web): require github auth before actions --- packages/app/src/web/actions-shared.ts | 19 ++++++ packages/app/src/web/actions.ts | 6 +- packages/app/src/web/app-ready-actions.ts | 2 + packages/app/src/web/app-ready-controller.ts | 45 +++++++++++--- packages/app/src/web/app-ready-create.ts | 5 ++ packages/app/src/web/app-ready-hooks.ts | 43 +++++++++++++ packages/app/src/web/github-auth-gate.ts | 32 ++++++++++ packages/app/src/web/menu.ts | 5 ++ .../tests/docker-git/github-auth-gate.test.ts | 62 +++++++++++++++++++ 9 files changed, 209 insertions(+), 10 deletions(-) create mode 100644 packages/app/src/web/github-auth-gate.ts create mode 100644 packages/app/tests/docker-git/github-auth-gate.test.ts diff --git a/packages/app/src/web/actions-shared.ts b/packages/app/src/web/actions-shared.ts index bf83609..d87b290 100644 --- a/packages/app/src/web/actions-shared.ts +++ b/packages/app/src/web/actions-shared.ts @@ -2,7 +2,10 @@ import { Effect } from "effect" import type { Dispatch, SetStateAction } from "react" import type { ActionPromptState } from "./action-prompt.js" +import { createAuthActionPrompt } from "./action-prompt.js" import type { AuthSnapshot, GithubAuthStatus, ProjectAuthSnapshot, ProjectDetails } from "./api.js" +import { githubAuthGateMessage, shouldRequireGithubAuth } from "./github-auth-gate.js" +import { browserMenuIndex } from "./menu.js" import type { ActiveTerminalSession } from "./terminal.js" type Setter = Dispatch> @@ -17,6 +20,7 @@ type BusyAction = { } export type BrowserActionContext = { + readonly githubStatus: GithubAuthStatus | null readonly reloadDashboard: () => void readonly selectedProjectId: string | null readonly selectedProjectName: string | null @@ -76,5 +80,20 @@ export const requireSelectedProjectId = ( return null } +export const requireGithubAuthConfigured = (context: BrowserActionContext): boolean => { + if (context.githubStatus === null) { + context.setSelectedMenuIndex(browserMenuIndex("Auth")) + context.setMessage("Проверяю подключение GitHub перед продолжением.") + return false + } + if (!shouldRequireGithubAuth(context.githubStatus)) { + return true + } + context.setSelectedMenuIndex(browserMenuIndex("Auth")) + context.setActionPrompt(createAuthActionPrompt("GithubOauth")) + context.setMessage(githubAuthGateMessage(context.githubStatus)) + return false +} + export const projectActionLabel = (context: BrowserActionContext): string => context.selectedProjectName ?? context.selectedProjectId ?? "selected project" diff --git a/packages/app/src/web/actions.ts b/packages/app/src/web/actions.ts index 3979804..6306205 100644 --- a/packages/app/src/web/actions.ts +++ b/packages/app/src/web/actions.ts @@ -1,6 +1,7 @@ import { refreshAuthPanel, refreshProjectAuthPanel } from "./actions-auth.js" import { runProjectMenuAction } from "./actions-projects.js" -import type { BrowserActionContext } from "./actions-shared.js" +import { type BrowserActionContext, requireGithubAuthConfigured } from "./actions-shared.js" +import { shouldBlockMenuForGithubAuth } from "./github-auth-gate.js" import type { BrowserMenuTag } from "./menu.js" export type { BrowserActionContext } from "./actions-shared.js" @@ -19,6 +20,9 @@ export const runBrowserMenuAction = ( currentMenu: BrowserMenuTag, context: BrowserActionContext ) => { + if (shouldBlockMenuForGithubAuth(context.githubStatus, currentMenu) && !requireGithubAuthConfigured(context)) { + return + } if (currentMenu === "Auth") { refreshAuthPanel(context) return diff --git a/packages/app/src/web/app-ready-actions.ts b/packages/app/src/web/app-ready-actions.ts index 8757cd3..14ed51b 100644 --- a/packages/app/src/web/app-ready-actions.ts +++ b/packages/app/src/web/app-ready-actions.ts @@ -6,6 +6,7 @@ import type { BrowserMenuTag } from "./menu.js" import { browserMenuItems } from "./menu.js" type ActionContextArgs = { + readonly githubStatus: BrowserActionContext["githubStatus"] readonly refreshDashboard: () => void readonly selectedProjectId: string | null readonly selectedProjectName: string | null @@ -26,6 +27,7 @@ export const resolveCurrentMenu = (selectedMenuIndex: number): BrowserMenuTag => browserMenuItems[selectedMenuIndex]?.tag ?? "Select" export const createActionContext = (args: ActionContextArgs): BrowserActionContext => ({ + githubStatus: args.githubStatus, reloadDashboard: args.refreshDashboard, selectedProjectId: args.selectedProjectId, selectedProjectName: args.selectedProjectName, diff --git a/packages/app/src/web/app-ready-controller.ts b/packages/app/src/web/app-ready-controller.ts index 33a2abf..f447631 100644 --- a/packages/app/src/web/app-ready-controller.ts +++ b/packages/app/src/web/app-ready-controller.ts @@ -11,6 +11,7 @@ import { cancelCreate, setCreateBuffer, submitCreateView, useCreateMenuReset } f import { useActionPromptReset, useBrowserShortcuts, + useGithubAuthGate, usePanelAutoload, useProjectAuthReset, useProjectDetailsReset, @@ -25,15 +26,15 @@ type ReadyControllerArgs = { readonly refreshDashboard: () => void } -const useReadySideEffects = ( - args: { - readonly actionContext: ReturnType - readonly currentMenu: ReturnType - readonly dashboard: DashboardData - readonly dashboardRefreshTick: number - readonly state: ReturnType - } -) => { +type ReadySideEffectsArgs = { + readonly actionContext: ReturnType + readonly currentMenu: ReturnType + readonly dashboard: DashboardData + readonly dashboardRefreshTick: number + readonly state: ReturnType +} + +const useProjectSyncEffects = (args: ReadySideEffectsArgs) => { useProjectSelectionSync({ dashboard: args.dashboard, selectedProjectId: args.state.selectedProjectId, @@ -41,22 +42,40 @@ const useReadySideEffects = ( setSelectedProject: args.state.setSelectedProject, setSelectedProjectId: args.state.setSelectedProjectId }) +} + +const useReadyResetEffects = (args: ReadySideEffectsArgs) => { useCreateMenuReset(args.currentMenu, args.state.setCreateView) useActionPromptReset(args.state.actionPrompt, args.currentMenu, args.state.setActionPrompt) + useGithubAuthGate({ + actionPrompt: args.state.actionPrompt, + githubStatus: args.state.githubStatus, + selectedMenuIndex: args.state.selectedMenuIndex, + setActionPrompt: args.state.setActionPrompt, + setMessage: args.state.setMessage, + setSelectedMenuIndex: args.state.setSelectedMenuIndex + }) useProjectNavigationReset(args.currentMenu, args.state.setProjectNavigationArmed) useProjectAuthReset(args.state.selectedProjectId, args.state.setProjectAuthSnapshot) useProjectDetailsReset(args.state.selectedProjectId, args.state.setSelectedProject) +} + +const useReadyAutoloadEffects = (args: ReadySideEffectsArgs) => { usePanelAutoload({ authSnapshot: args.state.authSnapshot, busyLabel: args.state.busyLabel, context: args.actionContext, currentMenu: args.currentMenu, dashboardRefreshTick: args.dashboardRefreshTick, + githubStatus: args.state.githubStatus, project: args.state.project, projectNavigationArmed: args.state.projectNavigationArmed, selectedProjectId: args.state.selectedProjectId, projectAuthSnapshot: args.state.projectAuthSnapshot }) +} + +const useReadyShortcutEffects = (args: ReadySideEffectsArgs) => { useBrowserShortcuts({ actionPrompt: args.state.actionPrompt, context: args.actionContext, @@ -75,6 +94,13 @@ const useReadySideEffects = ( }) } +const useReadySideEffects = (args: ReadySideEffectsArgs) => { + useProjectSyncEffects(args) + useReadyResetEffects(args) + useReadyAutoloadEffects(args) + useReadyShortcutEffects(args) +} + const bindMenuActions = (actionContext: ReturnType) => ({ onRunAuthAction: (index: number) => { runAuthActionByIndex(index, actionContext) @@ -131,6 +157,7 @@ export const useReadyController = ({ dashboard, dashboardRefreshTick, refreshDas const currentMenu = resolveCurrentMenu(state.selectedMenuIndex) const selectedProjectSummary = dashboard.projects.find((project) => project.id === state.selectedProjectId) const actionContext = createActionContext({ + githubStatus: state.githubStatus, refreshDashboard, selectedProjectId: state.selectedProjectId, selectedProjectName: selectedProjectSummary?.displayName ?? null, diff --git a/packages/app/src/web/app-ready-create.ts b/packages/app/src/web/app-ready-create.ts index db4ca9d..7bee6c0 100644 --- a/packages/app/src/web/app-ready-create.ts +++ b/packages/app/src/web/app-ready-create.ts @@ -3,6 +3,7 @@ import { type Dispatch, type SetStateAction, useEffect } from "react" import { nextBufferValue } from "../docker-git/menu-buffer-input.js" import { advanceCreateFlow, type CreateFlowView, createInitialFlowView } from "../docker-git/menu-create-shared.js" import { submitCreateInputs } from "./actions-projects.js" +import { requireGithubAuthConfigured } from "./actions-shared.js" import type { BrowserActionContext } from "./actions.js" import type { BrowserMenuTag } from "./menu.js" @@ -50,6 +51,10 @@ export const submitCreateView = ( setCreateView }: CreateSubmitArgs ): void => { + if (!requireGithubAuthConfigured(context)) { + return + } + const createContext = { cwd: controllerCwd, projectsRoot } const next = forceWizard === true ? advanceCreateFlow(createContext, createView, { forceWizard: true }) diff --git a/packages/app/src/web/app-ready-hooks.ts b/packages/app/src/web/app-ready-hooks.ts index 7810741..cefc6db 100644 --- a/packages/app/src/web/app-ready-hooks.ts +++ b/packages/app/src/web/app-ready-hooks.ts @@ -2,6 +2,7 @@ import { type Dispatch, type SetStateAction, useEffect, useEffectEvent, useState import type { CreateFlowView } from "../docker-git/menu-create-shared.js" import type { ActionPromptState } from "./action-prompt.js" +import { createAuthActionPrompt } from "./action-prompt.js" import { type BrowserActionContext, loadSelectedProjectInfo, @@ -17,7 +18,9 @@ import { shouldRefreshProjectAuthPanel, shouldRefreshProjectDetails } from "./app-ready-shortcuts.js" +import { githubAuthGateMessage, isGithubOauthPrompt, shouldRequireGithubAuth } from "./github-auth-gate.js" import type { BrowserMenuTag } from "./menu.js" +import { browserMenuIndex } from "./menu.js" import type { ActiveTerminalSession } from "./terminal.js" type Setter = Dispatch> @@ -36,12 +39,22 @@ type PanelAutoloadArgs = { readonly context: BrowserActionContext readonly currentMenu: BrowserMenuTag readonly dashboardRefreshTick: number + readonly githubStatus: GithubAuthStatus | null readonly project: ProjectDetails | null readonly projectNavigationArmed: boolean readonly projectAuthSnapshot: ProjectAuthSnapshot | null readonly selectedProjectId: string | null } +type GithubAuthGateArgs = { + readonly actionPrompt: ActionPromptState | null + readonly githubStatus: GithubAuthStatus | null + readonly selectedMenuIndex: number + readonly setActionPrompt: Setter + readonly setMessage: Setter + readonly setSelectedMenuIndex: Setter +} + type ReadyStateSetters = Pick< BrowserActionContext, | "setAuthSnapshot" @@ -173,6 +186,30 @@ export const useActionPromptReset = ( }, [actionPrompt, currentMenu, setActionPrompt]) } +export const useGithubAuthGate = ({ + actionPrompt, + githubStatus, + selectedMenuIndex, + setActionPrompt, + setMessage, + setSelectedMenuIndex +}: GithubAuthGateArgs) => { + useEffect(() => { + if (!shouldRequireGithubAuth(githubStatus)) { + return + } + + const authIndex = browserMenuIndex("Auth") + if (selectedMenuIndex !== authIndex) { + setSelectedMenuIndex(authIndex) + } + if (!isGithubOauthPrompt(actionPrompt)) { + setActionPrompt(createAuthActionPrompt("GithubOauth")) + } + setMessage(githubAuthGateMessage(githubStatus)) + }, [actionPrompt, githubStatus, selectedMenuIndex, setActionPrompt, setMessage, setSelectedMenuIndex]) +} + export const useProjectNavigationReset = ( currentMenu: BrowserMenuTag, setProjectNavigationArmed: Setter @@ -188,6 +225,7 @@ export const usePanelAutoload = ({ context, currentMenu, dashboardRefreshTick, + githubStatus, project, projectAuthSnapshot, projectNavigationArmed, @@ -197,6 +235,10 @@ export const usePanelAutoload = ({ if (busyLabel !== null) { return } + if (githubStatus === null) { + refreshAuthPanel(context) + return + } if (shouldRefreshAuthPanel(currentMenu, authSnapshot)) { refreshAuthPanel(context) } @@ -215,6 +257,7 @@ export const usePanelAutoload = ({ busyLabel, currentMenu, dashboardRefreshTick, + githubStatus, project?.id, projectAuthSnapshot, projectNavigationArmed, diff --git a/packages/app/src/web/github-auth-gate.ts b/packages/app/src/web/github-auth-gate.ts new file mode 100644 index 0000000..1f4065e --- /dev/null +++ b/packages/app/src/web/github-auth-gate.ts @@ -0,0 +1,32 @@ +import { authMenuActionByIndex } from "../docker-git/menu-auth-shared.js" + +import type { ActionPromptState } from "./action-prompt.js" +import type { GithubAuthStatus } from "./api.js" +import type { BrowserMenuTag } from "./menu.js" + +const usableGithubTokenStatuses: ReadonlySet = new Set([ + "valid", + "unknown" +]) + +export const isGithubAuthConfigured = (githubStatus: GithubAuthStatus): boolean => + githubStatus.tokens.some((token) => usableGithubTokenStatuses.has(token.status)) + +export const shouldRequireGithubAuth = (githubStatus: GithubAuthStatus | null): githubStatus is GithubAuthStatus => + githubStatus !== null && !isGithubAuthConfigured(githubStatus) + +export const githubAuthGateMessage = (githubStatus: GithubAuthStatus): string => + githubStatus.tokens.length === 0 + ? "Сначала подключи GitHub: без GitHub token docker-git не сможет клонировать репозитории." + : "GitHub token не прошёл проверку. Подключи GitHub заново перед работой с docker-git." + +export const isGithubOauthPrompt = (actionPrompt: ActionPromptState | null): boolean => + actionPrompt?.kind === "Auth" && actionPrompt.action === "GithubOauth" + +export const shouldBlockMenuForGithubAuth = ( + githubStatus: GithubAuthStatus | null, + currentMenu: BrowserMenuTag +): boolean => + currentMenu !== "Auth" && currentMenu !== "Quit" && (githubStatus === null || shouldRequireGithubAuth(githubStatus)) + +export const isGithubOauthAuthMenuIndex = (index: number): boolean => authMenuActionByIndex(index) === "GithubOauth" diff --git a/packages/app/src/web/menu.ts b/packages/app/src/web/menu.ts index d3efb6a..29d3069 100644 --- a/packages/app/src/web/menu.ts +++ b/packages/app/src/web/menu.ts @@ -31,3 +31,8 @@ export const browserMenuItems = browserMenuOrder.map((tag) => ({ tag, label: menuItems.find((item) => item.id._tag === tag)?.label ?? tag })) + +export const browserMenuIndex = (tag: BrowserMenuTag): number => { + const index = browserMenuItems.findIndex((item) => item.tag === tag) + return index === -1 ? 0 : index +} diff --git a/packages/app/tests/docker-git/github-auth-gate.test.ts b/packages/app/tests/docker-git/github-auth-gate.test.ts new file mode 100644 index 0000000..9a5b738 --- /dev/null +++ b/packages/app/tests/docker-git/github-auth-gate.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest" + +import type { GithubAuthStatus } from "../../src/web/api.js" +import { + githubAuthGateMessage, + isGithubAuthConfigured, + isGithubOauthAuthMenuIndex, + shouldBlockMenuForGithubAuth, + shouldRequireGithubAuth +} from "../../src/web/github-auth-gate.js" + +type GithubTokenStatus = GithubAuthStatus["tokens"][number]["status"] + +const makeStatus = ( + tokens: ReadonlyArray +): GithubAuthStatus => ({ + summary: `tokens: ${tokens.length}`, + tokens +}) + +const makeToken = (status: GithubTokenStatus): GithubAuthStatus["tokens"][number] => ({ + key: `GITHUB_TOKEN_${status}`, + label: "default", + login: status === "valid" ? "octocat" : null, + status +}) + +describe("github-auth-gate", () => { + it("requires GitHub auth when no token is configured", () => { + const status = makeStatus([]) + + expect(isGithubAuthConfigured(status)).toBe(false) + expect(shouldRequireGithubAuth(status)).toBe(true) + expect(githubAuthGateMessage(status)).toContain("Сначала подключи GitHub") + }) + + it("requires reconnect when all configured tokens are invalid", () => { + const status = makeStatus([makeToken("invalid")]) + + expect(isGithubAuthConfigured(status)).toBe(false) + expect(shouldRequireGithubAuth(status)).toBe(true) + expect(githubAuthGateMessage(status)).toContain("не прошёл проверку") + }) + + it("accepts valid or unknown tokens as configured", () => { + expect(isGithubAuthConfigured(makeStatus([makeToken("valid")]))).toBe(true) + expect(isGithubAuthConfigured(makeStatus([makeToken("unknown")]))).toBe(true) + }) + + it("blocks non-auth browser actions while GitHub auth is required", () => { + const status = makeStatus([]) + + expect(shouldBlockMenuForGithubAuth(null, "Create")).toBe(true) + expect(shouldBlockMenuForGithubAuth(status, "Create")).toBe(true) + expect(shouldBlockMenuForGithubAuth(status, "Auth")).toBe(false) + expect(shouldBlockMenuForGithubAuth(status, "Quit")).toBe(false) + }) + + it("keeps the first auth menu action mapped to GitHub OAuth", () => { + expect(isGithubOauthAuthMenuIndex(0)).toBe(true) + }) +}) From 3811e9034eb942ba9dd624054ec5641d191721dc Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Mon, 13 Apr 2026 19:56:49 +0000 Subject: [PATCH 2/3] fix(web): stream github oauth logs --- .../app/src/docker-git/api-client-auth.ts | 104 ++++-------------- packages/app/src/docker-git/api-http.ts | 20 +--- .../app/src/shared/auth-stream-markers.ts | 83 ++++++++++++++ .../app/src/shared/http-response-stream.ts | 16 +++ packages/app/src/web/actions-auth.ts | 49 ++++----- packages/app/src/web/actions-github-oauth.ts | 55 +++++++++ packages/app/src/web/actions-shared.ts | 26 +++++ packages/app/src/web/api-http.ts | 24 ++++ packages/app/src/web/api.ts | 10 +- packages/app/src/web/app-ready-controller.ts | 1 + packages/app/src/web/app-ready-hooks.ts | 7 +- packages/app/src/web/panel-layout.tsx | 3 +- .../docker-git/auth-stream-markers.test.ts | 48 ++++++++ 13 files changed, 318 insertions(+), 128 deletions(-) create mode 100644 packages/app/src/shared/auth-stream-markers.ts create mode 100644 packages/app/src/shared/http-response-stream.ts create mode 100644 packages/app/src/web/actions-github-oauth.ts create mode 100644 packages/app/tests/docker-git/auth-stream-markers.test.ts diff --git a/packages/app/src/docker-git/api-client-auth.ts b/packages/app/src/docker-git/api-client-auth.ts index 5a8d681..c65ebe3 100644 --- a/packages/app/src/docker-git/api-client-auth.ts +++ b/packages/app/src/docker-git/api-client-auth.ts @@ -2,6 +2,16 @@ import * as FsPlatform from "@effect/platform/FileSystem" import * as PathPlatform from "@effect/platform/Path" import { Effect } from "effect" +import { + authStreamMarkerExitCode, + type AuthStreamMarkers, + authStreamSucceeded, + authStreamVisibleLines, + codexLoginStreamMarkers, + githubLoginFailureMessage, + githubLoginStreamMarkers, + makeVisibleAuthStreamWriter +} from "../shared/auth-stream-markers.js" import { request, requestTextStream, requestVoid } from "./api-http.js" import { asObject, type JsonRequest, type JsonValue } from "./api-json.js" import type { ControllerRuntime } from "./controller.js" @@ -17,75 +27,18 @@ import type { import { resolvePathFromCwd } from "./frontend-lib/usecases/path-helpers.js" import type { ApiAuthRequiredError, ApiRequestError } from "./host-errors.js" -type StreamMarkers = { - readonly success: string - readonly errorPrefix: string -} - -const codexLoginMarkers: StreamMarkers = { - success: "__DOCKER_GIT_CODEX_LOGIN_STATUS__:ok", - errorPrefix: "__DOCKER_GIT_CODEX_LOGIN_STATUS__:error:" -} - -const githubLoginMarkers: StreamMarkers = { - success: "__DOCKER_GIT_GITHUB_LOGIN_STATUS__:ok", - errorPrefix: "__DOCKER_GIT_GITHUB_LOGIN_STATUS__:error:" -} - -const isMarkerLine = (line: string, markers: StreamMarkers): boolean => - line.startsWith(markers.success) || line.startsWith(markers.errorPrefix) - -const visibleLines = (output: string, markers: StreamMarkers): ReadonlyArray => - output - .split(/\r?\n/u) - .map((line) => line.trim()) - .filter((line) => line.length > 0 && !isMarkerLine(line, markers)) - -const markerExitCode = (output: string, markers: StreamMarkers): string | null => { - const failureLine = output - .split(/\r?\n/u) - .find((line) => line.startsWith(markers.errorPrefix)) - - return failureLine === undefined - ? null - : failureLine.slice(markers.errorPrefix.length) -} - -const makeVisibleChunkWriter = (markers: StreamMarkers) => { - let pending = "" - const flushVisiblePending = () => { - if (pending.length > 0 && !isMarkerLine(pending, markers)) { - process.stdout.write(pending) - } - } - - const writeVisibleChunk = (chunk: string) => { - pending += chunk - const lines = pending.split("\n") - pending = lines.pop() ?? "" - - for (const line of lines) { - if (!isMarkerLine(line, markers)) { - process.stdout.write(`${line}\n`) - } - } - } - - return { flushVisiblePending, writeVisibleChunk } -} - const codexLoginFailureMessage = (output: string, exitCode: string | null): string => { if (output.includes("429 Too Many Requests")) { return "Codex device auth is rate-limited by OpenAI (429 Too Many Requests). Wait a few minutes and retry." } - const detailedLine = visibleLines(output, codexLoginMarkers) + const detailedLine = authStreamVisibleLines(output, codexLoginStreamMarkers) .findLast((line) => line.toLowerCase().includes("error")) if (detailedLine !== undefined) { return detailedLine } - const lastLine = visibleLines(output, codexLoginMarkers).at(-1) + const lastLine = authStreamVisibleLines(output, codexLoginStreamMarkers).at(-1) if (lastLine !== undefined) { return lastLine } @@ -95,23 +48,6 @@ const codexLoginFailureMessage = (output: string, exitCode: string | null): stri : `Codex login failed (${exitCode}).` } -const githubLoginFailureMessage = (output: string, exitCode: string | null): string => { - const detailedLine = visibleLines(output, githubLoginMarkers) - .findLast((line) => line.toLowerCase().includes("failed") || line.toLowerCase().includes("error")) - if (detailedLine !== undefined) { - return detailedLine - } - - const lastLine = visibleLines(output, githubLoginMarkers).at(-1) - if (lastLine !== undefined) { - return lastLine - } - - return exitCode === null - ? "GitHub login stream ended without a completion marker." - : `GitHub login failed (${exitCode}).` -} - const streamFailure = ( method: "POST", path: string, @@ -127,21 +63,23 @@ const streamFailure = ( const requestMarkedAuthStream = ( path: string, body: JsonRequest, - markers: StreamMarkers, + markers: AuthStreamMarkers, failureMessage: (output: string, exitCode: string | null) => string ) => Effect.gen(function*(_) { - const writer = makeVisibleChunkWriter(markers) - const output = yield* _(requestTextStream("POST", path, body, writer.writeVisibleChunk)) + const writer = makeVisibleAuthStreamWriter(markers, (chunk) => { + process.stdout.write(chunk) + }) + const output = yield* _(requestTextStream("POST", path, body, writer.writeChunk)) writer.flushVisiblePending() - if (output.includes(markers.success)) { + if (authStreamSucceeded(output, markers)) { return output } return yield* _( Effect.fail( - streamFailure("POST", path, failureMessage(output, markerExitCode(output, markers))) + streamFailure("POST", path, failureMessage(output, authStreamMarkerExitCode(output, markers))) ) ) }) @@ -164,7 +102,7 @@ const githubWebLogin = ( token: null, scopes: command.scopes }, - githubLoginMarkers, + githubLoginStreamMarkers, githubLoginFailureMessage ).pipe( Effect.flatMap(() => request("GET", "/auth/github/status")), @@ -193,7 +131,7 @@ export const codexLogin = (command: AuthCodexLoginCommand) => requestMarkedAuthStream( "/auth/codex/login", { label: command.label }, - codexLoginMarkers, + codexLoginStreamMarkers, codexLoginFailureMessage ).pipe(Effect.asVoid) diff --git a/packages/app/src/docker-git/api-http.ts b/packages/app/src/docker-git/api-http.ts index f23674a..deafd9c 100644 --- a/packages/app/src/docker-git/api-http.ts +++ b/packages/app/src/docker-git/api-http.ts @@ -1,8 +1,9 @@ -import { FetchHttpClient, HttpBody, HttpClient, HttpClientResponse } from "@effect/platform" +import type { HttpClientResponse } from "@effect/platform" +import { FetchHttpClient, HttpBody, HttpClient } from "@effect/platform" import type * as HttpClientError from "@effect/platform/HttpClientError" import { Effect } from "effect" -import * as Stream from "effect/Stream" +import { readHttpResponseTextStream } from "../shared/http-response-stream.js" import { asObject, asString, type JsonRequest, type JsonValue, parseResponseBody } from "./api-json.js" import { type ControllerRuntime, ensureControllerReady, resolveApiBaseUrl } from "./controller.js" import type { ApiAuthRequiredError, ApiRequestError } from "./host-errors.js" @@ -202,19 +203,6 @@ export const request = ( export const requestVoid = (method: ApiHttpMethod, path: string, body?: JsonRequest) => request(method, path, body).pipe(Effect.asVoid) -const readResponseTextStream = ( - response: HttpClientResponse.HttpClientResponse, - onChunk: (chunk: string) => void -) => - HttpClientResponse.stream(Effect.succeed(response)).pipe( - Stream.decodeText(), - Stream.runFoldEffect("", (output, chunk) => - Effect.sync(() => { - onChunk(chunk) - return output + chunk - })) - ) - export const requestTextStream = ( method: ApiHttpMethod, path: string, @@ -230,5 +218,5 @@ export const requestTextStream = ( return yield* _(Effect.fail(toRequestError(method, path, response.status, parsed))) } - return yield* _(readResponseTextStream(response, onChunk)) + return yield* _(readHttpResponseTextStream(response, onChunk)) }).pipe(Effect.provide(FetchHttpClient.layer), mapTransportError(method, path)) diff --git a/packages/app/src/shared/auth-stream-markers.ts b/packages/app/src/shared/auth-stream-markers.ts new file mode 100644 index 0000000..c9f0d62 --- /dev/null +++ b/packages/app/src/shared/auth-stream-markers.ts @@ -0,0 +1,83 @@ +export type AuthStreamMarkers = { + readonly success: string + readonly errorPrefix: string +} + +export const codexLoginStreamMarkers: AuthStreamMarkers = { + success: "__DOCKER_GIT_CODEX_LOGIN_STATUS__:ok", + errorPrefix: "__DOCKER_GIT_CODEX_LOGIN_STATUS__:error:" +} + +export const githubLoginStreamMarkers: AuthStreamMarkers = { + success: "__DOCKER_GIT_GITHUB_LOGIN_STATUS__:ok", + errorPrefix: "__DOCKER_GIT_GITHUB_LOGIN_STATUS__:error:" +} + +export const isAuthStreamMarkerLine = (line: string, markers: AuthStreamMarkers): boolean => + line.startsWith(markers.success) || line.startsWith(markers.errorPrefix) + +export const authStreamVisibleLines = ( + output: string, + markers: AuthStreamMarkers +): ReadonlyArray => + output + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !isAuthStreamMarkerLine(line, markers)) + +export const authStreamMarkerExitCode = (output: string, markers: AuthStreamMarkers): string | null => { + const failureLine = output + .split(/\r?\n/u) + .find((line) => line.startsWith(markers.errorPrefix)) + + return failureLine === undefined + ? null + : failureLine.slice(markers.errorPrefix.length) +} + +export const authStreamSucceeded = (output: string, markers: AuthStreamMarkers): boolean => + output.includes(markers.success) + +export const githubLoginFailureMessage = (output: string, exitCode: string | null): string => { + const detailedLine = authStreamVisibleLines(output, githubLoginStreamMarkers) + .findLast((line) => line.toLowerCase().includes("failed") || line.toLowerCase().includes("error")) + if (detailedLine !== undefined) { + return detailedLine + } + + const lastLine = authStreamVisibleLines(output, githubLoginStreamMarkers).at(-1) + if (lastLine !== undefined) { + return lastLine + } + + return exitCode === null + ? "GitHub login stream ended without a completion marker." + : `GitHub login failed (${exitCode}).` +} + +export const makeVisibleAuthStreamWriter = ( + markers: AuthStreamMarkers, + writeVisibleChunk: (chunk: string) => void +) => { + let pending = "" + const flushVisiblePending = () => { + if (pending.length > 0 && !isAuthStreamMarkerLine(pending, markers)) { + writeVisibleChunk(pending) + } + pending = "" + } + + const writeChunk = (chunk: string) => { + pending += chunk + const lines = pending.split("\n") + pending = lines.pop() ?? "" + + for (const line of lines) { + if (!isAuthStreamMarkerLine(line, markers)) { + writeVisibleChunk(`${line}\n`) + } + } + } + + return { flushVisiblePending, writeChunk } +} diff --git a/packages/app/src/shared/http-response-stream.ts b/packages/app/src/shared/http-response-stream.ts new file mode 100644 index 0000000..310e066 --- /dev/null +++ b/packages/app/src/shared/http-response-stream.ts @@ -0,0 +1,16 @@ +import { HttpClientResponse } from "@effect/platform" +import { Effect } from "effect" +import * as Stream from "effect/Stream" + +export const readHttpResponseTextStream = ( + response: HttpClientResponse.HttpClientResponse, + onChunk: (chunk: string) => void +) => + HttpClientResponse.stream(Effect.succeed(response)).pipe( + Stream.decodeText(), + Stream.runFoldEffect("", (output, chunk) => + Effect.sync(() => { + onChunk(chunk) + return output + chunk + })) + ) diff --git a/packages/app/src/web/actions-auth.ts b/packages/app/src/web/actions-auth.ts index a5716bf..ad208f8 100644 --- a/packages/app/src/web/actions-auth.ts +++ b/packages/app/src/web/actions-auth.ts @@ -17,7 +17,9 @@ import { createProjectAuthActionPrompt, validateActionPrompt } from "./action-prompt.js" +import { runGithubOauthMutation } from "./actions-github-oauth.js" import { + applyAuthSuccessState, type BrowserActionContext, defaultLabel, nullableValue, @@ -29,7 +31,6 @@ import { loadAuthSnapshot, loadGithubStatus, loadProjectAuthSnapshot, - loginGithub, runAuthMenuFlow, runProjectAuthFlow } from "./api.js" @@ -89,36 +90,34 @@ const runSupportedAuthMutation = ( values: Readonly>, context: BrowserActionContext ) => { + if (action === "GithubOauth") { + runGithubOauthMutation(values, context) + return + } + const label = defaultLabel(values["label"]) withBusy({ context, - effect: ( - action === "GithubOauth" - ? loginGithub(nullableValue(values["label"])).pipe( - Effect.flatMap((githubStatus) => - loadAuthSnapshot().pipe(Effect.map((snapshot) => ({ githubStatus, snapshot }))) - ) - ) - : runAuthMenuFlow({ - flow: action, - label: nullableValue(values["label"]), - token: nullableValue(values["token"]), - user: nullableValue(values["user"]), - apiKey: nullableValue(values["apiKey"]) - }).pipe( - Effect.flatMap((snapshot) => - loadGithubStatus().pipe(Effect.map((githubStatus) => ({ githubStatus, snapshot }))) - ) + effect: runAuthMenuFlow({ + flow: action, + label: nullableValue(values["label"]), + token: nullableValue(values["token"]), + user: nullableValue(values["user"]), + apiKey: nullableValue(values["apiKey"]) + }).pipe( + Effect.flatMap((snapshot) => + loadGithubStatus().pipe( + Effect.map((githubStatus) => ({ githubStatus, snapshot })) ) + ) ), - label: action === "GithubOauth" ? "Running GitHub OAuth" : action, + label: action, onSuccess: ({ githubStatus, snapshot }) => { - context.setActionPrompt(null) - context.setAuthSnapshot(snapshot) - context.setGithubStatus(githubStatus) - context.setMessage( - action === "GithubOauth" ? `Saved GitHub token (${label}).` : authSuccessMessage(action, label) - ) + applyAuthSuccessState(context, { + githubStatus, + message: authSuccessMessage(action, label), + snapshot + }) } }) } diff --git a/packages/app/src/web/actions-github-oauth.ts b/packages/app/src/web/actions-github-oauth.ts new file mode 100644 index 0000000..41cf106 --- /dev/null +++ b/packages/app/src/web/actions-github-oauth.ts @@ -0,0 +1,55 @@ +import { Effect } from "effect" + +import { + authStreamMarkerExitCode, + authStreamSucceeded, + githubLoginFailureMessage, + githubLoginStreamMarkers, + makeVisibleAuthStreamWriter +} from "../shared/auth-stream-markers.js" +import { + appendOutputChunk, + applyAuthSuccessState, + type BrowserActionContext, + defaultLabel, + nullableValue, + withBusy +} from "./actions-shared.js" +import { loadAuthSnapshot, loadGithubStatus, loginGithubStream } from "./api.js" + +export const runGithubOauthMutation = ( + values: Readonly>, + context: BrowserActionContext +) => { + const label = defaultLabel(values["label"]) + const writer = makeVisibleAuthStreamWriter(githubLoginStreamMarkers, (chunk) => { + appendOutputChunk(context, chunk) + }) + context.setOutput("") + context.setMessage("GitHub OAuth запущен. Следуй инструкциям в Output.") + withBusy({ + context, + effect: loginGithubStream(nullableValue(values["label"]), writer.writeChunk).pipe( + Effect.ensuring(Effect.sync(writer.flushVisiblePending)), + Effect.flatMap((output) => + authStreamSucceeded(output, githubLoginStreamMarkers) + ? Effect.all({ + githubStatus: loadGithubStatus(), + snapshot: loadAuthSnapshot() + }) + : Effect.fail(githubLoginFailureMessage( + output, + authStreamMarkerExitCode(output, githubLoginStreamMarkers) + )) + ) + ), + label: "Running GitHub OAuth", + onSuccess: ({ githubStatus, snapshot }) => { + applyAuthSuccessState(context, { + githubStatus, + message: `Saved GitHub token (${label}).`, + snapshot + }) + } + }) +} diff --git a/packages/app/src/web/actions-shared.ts b/packages/app/src/web/actions-shared.ts index d87b290..ab4c8b2 100644 --- a/packages/app/src/web/actions-shared.ts +++ b/packages/app/src/web/actions-shared.ts @@ -19,6 +19,14 @@ type BusyAction = { readonly onSuccess: (value: A) => void } +type AuthSuccessState = { + readonly githubStatus: GithubAuthStatus + readonly message: string + readonly snapshot: AuthSnapshot +} + +const outputLineLimit = 120 + export type BrowserActionContext = { readonly githubStatus: GithubAuthStatus | null readonly reloadDashboard: () => void @@ -70,6 +78,24 @@ export const withBusy = ({ context, effect, label, onFailure, onFinally, onSu }) } +export const appendOutputChunk = (context: BrowserActionContext, chunk: string) => { + if (chunk.length === 0) { + return + } + context.setOutput((current) => { + const next = current.length === 0 ? chunk : `${current}${chunk}` + const lines = next.split("\n") + return lines.length <= outputLineLimit ? next : lines.slice(-outputLineLimit).join("\n") + }) +} + +export const applyAuthSuccessState = (context: BrowserActionContext, state: AuthSuccessState) => { + context.setActionPrompt(null) + context.setAuthSnapshot(state.snapshot) + context.setGithubStatus(state.githubStatus) + context.setMessage(state.message) +} + export const requireSelectedProjectId = ( context: BrowserActionContext ): string | null => { diff --git a/packages/app/src/web/api-http.ts b/packages/app/src/web/api-http.ts index 462e493..0ae8c4f 100644 --- a/packages/app/src/web/api-http.ts +++ b/packages/app/src/web/api-http.ts @@ -5,11 +5,19 @@ import * as TreeFormatter from "@effect/schema/TreeFormatter" import { Effect, Either } from "effect" import { type JsonRequest, parseResponseBody, renderJsonPayload } from "../docker-git/api-json.js" +import { readHttpResponseTextStream } from "../shared/http-response-stream.js" const defaultApiBaseUrl = "/api" type ApiHttpMethod = "GET" | "POST" | "DELETE" +type TextStreamRequest = { + readonly body: JsonRequest | undefined + readonly method: ApiHttpMethod + readonly onChunk: (chunk: string) => void + readonly path: string +} + const noCacheHeaders: Readonly> = { "cache-control": "no-cache, no-store, max-age=0", pragma: "no-cache" @@ -91,6 +99,22 @@ export const requestText = ( Effect.mapError(String) ) +export const requestTextStream = ( + { body, method, onChunk, path }: TextStreamRequest +): Effect.Effect => + Effect.gen(function*(_) { + const client = yield* _(HttpClient.HttpClient) + const response = yield* _(executeRequest(client, method, `${resolveApiBaseUrl()}${path}`, body)) + if (response.status >= 400) { + const text = yield* _(response.text) + return yield* _(readErrorMessage(response.status, text)) + } + return yield* _(readHttpResponseTextStream(response, onChunk)) + }).pipe( + Effect.provide(FetchHttpClient.layer), + Effect.mapError(String) + ) + export const requestJson = ( method: ApiHttpMethod, path: string, diff --git a/packages/app/src/web/api.ts b/packages/app/src/web/api.ts index 5b9e50b..89ce8f1 100644 --- a/packages/app/src/web/api.ts +++ b/packages/app/src/web/api.ts @@ -1,7 +1,7 @@ import { Effect } from "effect" import type { AuthMenuRequestBody, ProjectAuthMenuRequestBody } from "../shared/auth-menu-request.js" -import { requestJson, requestText, resolveApiBaseUrl } from "./api-http.js" +import { requestJson, requestText, requestTextStream, resolveApiBaseUrl } from "./api-http.js" import { AuthSnapshotResponseSchema, AuthTerminalSessionResponseSchema, @@ -131,6 +131,14 @@ export const loginGithub = (label: string | null) => Effect.map((response) => response.status) ) +export const loginGithubStream = (label: string | null, onChunk: (chunk: string) => void) => + requestTextStream({ + body: { label, token: null }, + method: "POST", + onChunk, + path: "/auth/github/login/stream" + }) + export const loadProjectEvents = ( projectId: string, cursor?: number diff --git a/packages/app/src/web/app-ready-controller.ts b/packages/app/src/web/app-ready-controller.ts index f447631..27a9068 100644 --- a/packages/app/src/web/app-ready-controller.ts +++ b/packages/app/src/web/app-ready-controller.ts @@ -49,6 +49,7 @@ const useReadyResetEffects = (args: ReadySideEffectsArgs) => { useActionPromptReset(args.state.actionPrompt, args.currentMenu, args.state.setActionPrompt) useGithubAuthGate({ actionPrompt: args.state.actionPrompt, + busyLabel: args.state.busyLabel, githubStatus: args.state.githubStatus, selectedMenuIndex: args.state.selectedMenuIndex, setActionPrompt: args.state.setActionPrompt, diff --git a/packages/app/src/web/app-ready-hooks.ts b/packages/app/src/web/app-ready-hooks.ts index cefc6db..ae7b72d 100644 --- a/packages/app/src/web/app-ready-hooks.ts +++ b/packages/app/src/web/app-ready-hooks.ts @@ -48,6 +48,7 @@ type PanelAutoloadArgs = { type GithubAuthGateArgs = { readonly actionPrompt: ActionPromptState | null + readonly busyLabel: string | null readonly githubStatus: GithubAuthStatus | null readonly selectedMenuIndex: number readonly setActionPrompt: Setter @@ -188,6 +189,7 @@ export const useActionPromptReset = ( export const useGithubAuthGate = ({ actionPrompt, + busyLabel, githubStatus, selectedMenuIndex, setActionPrompt, @@ -195,6 +197,9 @@ export const useGithubAuthGate = ({ setSelectedMenuIndex }: GithubAuthGateArgs) => { useEffect(() => { + if (busyLabel !== null) { + return + } if (!shouldRequireGithubAuth(githubStatus)) { return } @@ -207,7 +212,7 @@ export const useGithubAuthGate = ({ setActionPrompt(createAuthActionPrompt("GithubOauth")) } setMessage(githubAuthGateMessage(githubStatus)) - }, [actionPrompt, githubStatus, selectedMenuIndex, setActionPrompt, setMessage, setSelectedMenuIndex]) + }, [actionPrompt, busyLabel, githubStatus, selectedMenuIndex, setActionPrompt, setMessage, setSelectedMenuIndex]) } export const useProjectNavigationReset = ( diff --git a/packages/app/src/web/panel-layout.tsx b/packages/app/src/web/panel-layout.tsx index 02af4b7..bcdfc3d 100644 --- a/packages/app/src/web/panel-layout.tsx +++ b/packages/app/src/web/panel-layout.tsx @@ -201,8 +201,7 @@ export const OutputPanel = ({ output }: { readonly output: string }): JSX.Elemen {output.trim().length === 0 ? Нет вывода. Выполни action через Enter или клик. - : output.split("\n").slice(0, 18).map((line, index) => {line} - )} + : output.split("\n").slice(-18).map((line, index) => {line})} ) diff --git a/packages/app/tests/docker-git/auth-stream-markers.test.ts b/packages/app/tests/docker-git/auth-stream-markers.test.ts new file mode 100644 index 0000000..673a82f --- /dev/null +++ b/packages/app/tests/docker-git/auth-stream-markers.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest" + +import { + authStreamMarkerExitCode, + authStreamSucceeded, + authStreamVisibleLines, + githubLoginFailureMessage, + githubLoginStreamMarkers, + makeVisibleAuthStreamWriter +} from "../../src/shared/auth-stream-markers.js" + +describe("auth stream markers", () => { + it("detects GitHub stream success markers", () => { + const output = [ + "Copy your one-time code: ABCD-1234", + githubLoginStreamMarkers.success + ].join("\n") + + expect(authStreamSucceeded(output, githubLoginStreamMarkers)).toBe(true) + expect(authStreamVisibleLines(output, githubLoginStreamMarkers)).toEqual([ + "Copy your one-time code: ABCD-1234" + ]) + }) + + it("extracts GitHub stream error markers", () => { + const output = [ + "failed to authenticate", + `${githubLoginStreamMarkers.errorPrefix}2` + ].join("\n") + + expect(authStreamMarkerExitCode(output, githubLoginStreamMarkers)).toBe("2") + expect(githubLoginFailureMessage(output, "2")).toBe("failed to authenticate") + }) + + it("filters marker lines from chunked visible output", () => { + const chunks: Array = [] + const writer = makeVisibleAuthStreamWriter(githubLoginStreamMarkers, (chunk) => { + chunks.push(chunk) + }) + + writer.writeChunk("First line\n") + writer.writeChunk(`${githubLoginStreamMarkers.success}\n`) + writer.writeChunk("Last") + writer.flushVisiblePending() + + expect(chunks.join("")).toBe("First line\nLast") + }) +}) From a5345a84d6426a53fd9e71f243142d527a203197 Mon Sep 17 00:00:00 2001 From: Skuli Dropek <66840575+skulidropek@users.noreply.github.com> Date: Tue, 14 Apr 2026 01:31:52 +0400 Subject: [PATCH 3/3] fix(web): refresh projects after github oauth --- .../src/services/auth-github-login-stream.ts | 30 ++++- .../tests/auth-github-login-stream.test.ts | 28 +++++ packages/app/src/web/actions-github-oauth.ts | 1 + .../docker-git/actions-github-oauth.test.ts | 114 ++++++++++++++++++ 4 files changed, 169 insertions(+), 4 deletions(-) create mode 100644 packages/api/tests/auth-github-login-stream.test.ts create mode 100644 packages/app/tests/docker-git/actions-github-oauth.test.ts diff --git a/packages/api/src/services/auth-github-login-stream.ts b/packages/api/src/services/auth-github-login-stream.ts index 1f8cea0..08958ab 100644 --- a/packages/api/src/services/auth-github-login-stream.ts +++ b/packages/api/src/services/auth-github-login-stream.ts @@ -12,7 +12,7 @@ import { resolvePathFromCwd } from "@effect-template/lib/usecases/path-helpers" import { autoSyncState } from "@effect-template/lib/usecases/state-repo" import { ensureStateDotDockerGitRepo } from "@effect-template/lib/usecases/state-repo-github" import { migrateLegacyOrchLayout } from "@effect-template/lib/usecases/auth-sync" -import { Effect, Runtime } from "effect" +import { Effect, Logger, Runtime } from "effect" import * as Stream from "effect/Stream" import { spawn, type ChildProcess } from "node:child_process" @@ -188,6 +188,20 @@ const finalizeMessage = (status: string): string => ? `\nGitHub login completed.\n${githubLoginStreamSuccessMarker}\n` : `\n${githubLoginStreamErrorMarkerPrefix}${status}\n` +const normalizeCapturedLogLines = (lines: ReadonlyArray): ReadonlyArray => + lines + .map((line) => line.trim()) + .filter((line) => line.length > 0) + +export const renderGithubPostLoginOutput = ( + 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 @@ -252,17 +266,25 @@ export const streamGithubAuthLogin = ( return } + const postLoginLogs: Array = [] + const logger = Logger.make(({ message }) => { + postLoginLogs.push(String(message)) + }) + void runPromiseExit( finalizeGithubLogin(prepared).pipe( + Effect.provide(Logger.replace(Logger.defaultLogger, logger)), Effect.matchEffect({ onFailure: (error) => Effect.sync(() => { - enqueue(`\nGitHub login finished in browser, but post-login sync failed: ${error.message}\n`) - enqueue(finalizeMessage("post-login")) + enqueue(renderGithubPostLoginOutput([ + ...postLoginLogs, + `GitHub login finished in browser, but post-login sync failed: ${error.message}` + ], "post-login")) }), onSuccess: () => Effect.sync(() => { - enqueue(finalizeMessage("ok")) + enqueue(renderGithubPostLoginOutput(postLoginLogs, "ok")) }) }) ) diff --git a/packages/api/tests/auth-github-login-stream.test.ts b/packages/api/tests/auth-github-login-stream.test.ts new file mode 100644 index 0000000..015556a --- /dev/null +++ b/packages/api/tests/auth-github-login-stream.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest" + +import { renderGithubPostLoginOutput } from "../src/services/auth-github-login-stream.js" + +describe("GitHub auth login stream", () => { + it("renders post-login state logs before the success marker", () => { + const output = renderGithubPostLoginOutput([ + "Initializing state repository: https://github.com/octocat/.docker-git.git", + "State dir ready: /home/dev/.docker-git" + ], "ok") + + expect(output).toContain("Initializing state repository") + expect(output).toContain("State dir ready") + expect(output).toContain("GitHub login completed.") + expect(output).toContain("__DOCKER_GIT_GITHUB_LOGIN_STATUS__:ok") + expect(output.indexOf("State dir ready")).toBeLessThan(output.indexOf("GitHub login completed.")) + }) + + it("renders post-login failure details before the failure marker", () => { + const output = renderGithubPostLoginOutput([ + "GitHub login finished in browser, but post-login sync failed: git fetch failed" + ], "post-login") + + expect(output).toContain("post-login sync failed") + expect(output).toContain("__DOCKER_GIT_GITHUB_LOGIN_STATUS__:error:post-login") + expect(output.indexOf("post-login sync failed")).toBeLessThan(output.indexOf("__DOCKER_GIT_GITHUB_LOGIN_STATUS__")) + }) +}) diff --git a/packages/app/src/web/actions-github-oauth.ts b/packages/app/src/web/actions-github-oauth.ts index 41cf106..c3bbd81 100644 --- a/packages/app/src/web/actions-github-oauth.ts +++ b/packages/app/src/web/actions-github-oauth.ts @@ -50,6 +50,7 @@ export const runGithubOauthMutation = ( message: `Saved GitHub token (${label}).`, snapshot }) + context.reloadDashboard() } }) } diff --git a/packages/app/tests/docker-git/actions-github-oauth.test.ts b/packages/app/tests/docker-git/actions-github-oauth.test.ts new file mode 100644 index 0000000..95c8db0 --- /dev/null +++ b/packages/app/tests/docker-git/actions-github-oauth.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" +import { vi } from "vitest" + +import { githubLoginStreamMarkers } from "../../src/shared/auth-stream-markers.js" +import { runGithubOauthMutation } from "../../src/web/actions-github-oauth.js" +import type { BrowserActionContext } from "../../src/web/actions-shared.js" +import type { AuthSnapshot, GithubAuthStatus } from "../../src/web/api.js" + +const loginGithubStreamMock = vi.hoisted(() => vi.fn()) +const loadAuthSnapshotMock = vi.hoisted(() => vi.fn()) +const loadGithubStatusMock = vi.hoisted(() => vi.fn()) + +vi.mock("../../src/web/api.js", () => ({ + loadAuthSnapshot: loadAuthSnapshotMock, + loadGithubStatus: loadGithubStatusMock, + loginGithubStream: loginGithubStreamMock +})) + +const githubStatus: GithubAuthStatus = { + summary: "GitHub tokens (1):", + tokens: [ + { + key: "GITHUB_TOKEN", + label: "default", + login: "octocat", + status: "valid" + } + ] +} + +const authSnapshot: AuthSnapshot = { + claudeAuthEntries: 0, + claudeAuthPath: "/home/dev/.docker-git/.orch/auth/claude", + geminiAuthEntries: 0, + geminiAuthPath: "/home/dev/.docker-git/.orch/auth/gemini", + gitTokenEntries: 0, + gitUserEntries: 0, + githubTokenEntries: 1, + globalEnvPath: "/home/dev/.docker-git/.orch/env/global.env", + totalEntries: 1 +} + +const makeContext = () => { + let output = "" + const setOutput: BrowserActionContext["setOutput"] = (next) => { + output = typeof next === "function" ? next(output) : next + } + const setMessage: BrowserActionContext["setMessage"] = vi.fn() + const reloadDashboard = vi.fn() + + return { + context: { + githubStatus: null, + reloadDashboard, + selectedProjectId: null, + selectedProjectName: null, + setActionPrompt: vi.fn(), + setAuthSnapshot: vi.fn(), + setBusyLabel: vi.fn(), + setGithubStatus: vi.fn(), + setMessage, + setOutput, + setProjectAuthSnapshot: vi.fn(), + setSelectedMenuIndex: vi.fn(), + setSelectedProject: vi.fn(), + setSelectedProjectId: vi.fn(), + setTerminalSession: vi.fn() + } satisfies BrowserActionContext, + output: () => output, + reloadDashboard, + setMessage + } +} + +describe("web GitHub OAuth action", () => { + it.effect("refreshes dashboard projects after successful OAuth", () => + Effect.gen(function*(_) { + loginGithubStreamMock.mockImplementation((_label: string | null, onChunk: (chunk: string) => void) => + Effect.sync(() => { + onChunk("Copy your one-time code: ABCD-1234\n") + onChunk("State dir ready: /home/dev/.docker-git\n") + onChunk(`${githubLoginStreamMarkers.success}\n`) + return [ + "Copy your one-time code: ABCD-1234", + "State dir ready: /home/dev/.docker-git", + githubLoginStreamMarkers.success + ].join("\n") + }) + ) + loadAuthSnapshotMock.mockImplementation(() => Effect.succeed(authSnapshot)) + loadGithubStatusMock.mockImplementation(() => Effect.succeed(githubStatus)) + + const { context, output, reloadDashboard, setMessage } = makeContext() + + runGithubOauthMutation({ label: "" }, context) + + yield* _( + Effect.tryPromise({ + catch: (error) => error, + try: () => + vi.waitFor(() => { + expect(reloadDashboard).toHaveBeenCalledTimes(1) + }) + }) + ) + + expect(output()).toBe("Copy your one-time code: ABCD-1234\nState dir ready: /home/dev/.docker-git\n") + expect(context.setActionPrompt).toHaveBeenCalledWith(null) + expect(context.setAuthSnapshot).toHaveBeenCalledWith(authSnapshot) + expect(context.setGithubStatus).toHaveBeenCalledWith(githubStatus) + expect(setMessage).toHaveBeenLastCalledWith("Saved GitHub token (default).") + })) +})