Skip to content

Commit 1811e4c

Browse files
committed
feat(docker-git): improve auth streams, logs, and menu layout
1 parent 8a6efe1 commit 1811e4c

10 files changed

Lines changed: 725 additions & 29 deletions

File tree

packages/api/src/http.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import {
4242
readGithubAuthStatus,
4343
} from "./services/auth.js"
4444
import { readAuthMenuSnapshot, runAuthMenuFlow } from "./services/auth-menu.js"
45+
import { streamGithubAuthLogin } from "./services/auth-github-login-stream.js"
4546
import { createAuthTerminalSession, deleteAuthTerminalSession } from "./services/auth-terminal-sessions.js"
4647
import { streamCodexAuthLogin } from "./services/auth-codex-login-stream.js"
4748
import { getAgent, getAgentAttachInfo, listAgents, readAgentLogs, startAgent, stopAgent } from "./services/agents.js"
@@ -358,6 +359,20 @@ export const makeRouter = () => {
358359
return yield* _(jsonResponse({ snapshot }, 200))
359360
}).pipe(Effect.catchAll(errorResponse))
360361
),
362+
HttpRouter.post(
363+
"/auth/github/login/stream",
364+
Effect.gen(function*(_) {
365+
const request = yield* _(readGithubAuthLoginRequest())
366+
const outputStream = yield* _(streamGithubAuthLogin(request))
367+
return HttpServerResponse.stream(outputStream, {
368+
status: 200,
369+
headers: {
370+
"content-type": "text/plain; charset=utf-8",
371+
"cache-control": "no-cache"
372+
}
373+
})
374+
}).pipe(Effect.catchAll(errorResponse))
375+
),
361376
HttpRouter.post(
362377
"/auth/github/login",
363378
Effect.gen(function*(_) {
Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
import type * as CommandExecutor from "@effect/platform/CommandExecutor"
2+
import type { PlatformError } from "@effect/platform/Error"
3+
import * as FileSystem from "@effect/platform/FileSystem"
4+
import * as Path from "@effect/platform/Path"
5+
import { defaultTemplateConfig } from "@effect-template/lib/core/template-defaults"
6+
import { buildDockerAuthArgs, resolveDockerVolumeHostPath, runDockerAuthCapture } from "@effect-template/lib/shell/docker-auth"
7+
import { CommandFailedError } from "@effect-template/lib/shell/errors"
8+
import { buildDockerAuthSpec, normalizeAccountLabel } from "@effect-template/lib/usecases/auth-helpers"
9+
import { ensureEnvFile, readEnvText, upsertEnvKey } from "@effect-template/lib/usecases/env-file"
10+
import { ensureGhAuthImage, ghAuthDir, ghAuthRoot, ghImageName } from "@effect-template/lib/usecases/github-auth-image"
11+
import { resolvePathFromCwd } from "@effect-template/lib/usecases/path-helpers"
12+
import { autoSyncState } from "@effect-template/lib/usecases/state-repo"
13+
import { ensureStateDotDockerGitRepo } from "@effect-template/lib/usecases/state-repo-github"
14+
import { migrateLegacyOrchLayout } from "@effect-template/lib/usecases/auth-sync"
15+
import { Effect, Runtime } from "effect"
16+
import * as Stream from "effect/Stream"
17+
import { spawn, type ChildProcess } from "node:child_process"
18+
19+
import type { GithubAuthLoginRequest } from "../api/contracts.js"
20+
import { ApiBadRequestError, ApiInternalError } from "../api/errors.js"
21+
22+
type GithubRuntime = FileSystem.FileSystem | Path.Path | CommandExecutor.CommandExecutor
23+
type GithubSetupError = CommandFailedError | PlatformError
24+
25+
type PreparedGithubLogin = {
26+
readonly cwd: string
27+
readonly args: ReadonlyArray<string>
28+
readonly accountPath: string
29+
readonly envPath: string
30+
readonly key: string
31+
readonly label: string
32+
readonly scopes: ReadonlyArray<string>
33+
}
34+
35+
const githubLoginStreamSuccessMarker = "__DOCKER_GIT_GITHUB_LOGIN_STATUS__:ok"
36+
const githubLoginStreamErrorMarkerPrefix = "__DOCKER_GIT_GITHUB_LOGIN_STATUS__:error:"
37+
const githubTokenKey = "GITHUB_TOKEN"
38+
const githubTokenPrefix = "GITHUB_TOKEN__"
39+
const defaultGithubScopes = "repo,workflow,read:org"
40+
41+
const ensureGithubOrchLayout = (
42+
cwd: string,
43+
envGlobalPath: string
44+
): Effect.Effect<void, PlatformError, FileSystem.FileSystem | Path.Path> =>
45+
migrateLegacyOrchLayout(cwd, {
46+
envGlobalPath,
47+
envProjectPath: defaultTemplateConfig.envProjectPath,
48+
codexAuthPath: defaultTemplateConfig.codexAuthPath,
49+
ghAuthPath: ghAuthRoot,
50+
claudeAuthPath: ".docker-git/.orch/auth/claude"
51+
})
52+
53+
const normalizeGithubLabel = (value: string | null): string => {
54+
const trimmed = value?.trim() ?? ""
55+
if (trimmed.length === 0) {
56+
return ""
57+
}
58+
const normalized = trimmed.toUpperCase().replaceAll(/[^A-Z0-9]+/g, "_")
59+
const withoutLeading = normalized.replace(/^_+/u, "")
60+
const cleaned = withoutLeading.replace(/_+$/u, "")
61+
return cleaned.length > 0 ? cleaned : ""
62+
}
63+
64+
const buildGithubTokenKey = (label: string | null): string => {
65+
const normalized = normalizeGithubLabel(label)
66+
if (normalized === "DEFAULT" || normalized.length === 0) {
67+
return githubTokenKey
68+
}
69+
return `${githubTokenPrefix}${normalized}`
70+
}
71+
72+
const labelFromKey = (key: string): string => key.startsWith(githubTokenPrefix) ? key.slice(githubTokenPrefix.length) : "default"
73+
74+
const normalizeGithubScopes = (value: string | null | undefined): ReadonlyArray<string> => {
75+
const raw = value?.trim() ?? ""
76+
const input = raw.length === 0 ? defaultGithubScopes : raw
77+
const scopes = input
78+
.split(/[,\s]+/g)
79+
.map((scope) => scope.trim())
80+
.filter((scope) => scope.length > 0 && scope !== "delete_repo")
81+
return scopes.length === 0 ? defaultGithubScopes.split(",") : scopes
82+
}
83+
84+
const toApiError = (error: GithubSetupError): ApiBadRequestError | ApiInternalError =>
85+
error._tag === "CommandFailedError"
86+
? new ApiBadRequestError({
87+
message: `${error.command} failed (exit ${error.exitCode}).`
88+
})
89+
: new ApiInternalError({
90+
message: String(error),
91+
cause: error
92+
})
93+
94+
const prepareGithubLogin = (
95+
request: GithubAuthLoginRequest
96+
): Effect.Effect<PreparedGithubLogin, ApiBadRequestError | ApiInternalError, GithubRuntime> =>
97+
Effect.gen(function*(_) {
98+
const fs = yield* _(FileSystem.FileSystem)
99+
const path = yield* _(Path.Path)
100+
const cwd = process.cwd()
101+
102+
yield* _(
103+
ensureGithubOrchLayout(cwd, defaultTemplateConfig.envGlobalPath).pipe(
104+
Effect.mapError(toApiError)
105+
)
106+
)
107+
108+
const envPath = resolvePathFromCwd(path, cwd, defaultTemplateConfig.envGlobalPath)
109+
const rootPath = resolvePathFromCwd(path, cwd, ghAuthRoot)
110+
const label = normalizeAccountLabel(request.label ?? null, "default")
111+
const accountPath = path.join(rootPath, label)
112+
const scopes = normalizeGithubScopes(request.scopes)
113+
114+
yield* _(fs.makeDirectory(accountPath, { recursive: true }).pipe(Effect.mapError(toApiError)))
115+
yield* _(ensureGhAuthImage(fs, path, cwd, "gh auth").pipe(Effect.mapError(toApiError)))
116+
117+
const hostPath = yield* _(resolveDockerVolumeHostPath(cwd, accountPath).pipe(Effect.mapError(toApiError)))
118+
const args = buildDockerAuthArgs(
119+
buildDockerAuthSpec({
120+
cwd,
121+
image: ghImageName,
122+
hostPath,
123+
containerPath: ghAuthDir,
124+
env: ["BROWSER=echo", `GH_CONFIG_DIR=${ghAuthDir}`],
125+
args: [
126+
"auth",
127+
"login",
128+
"--web",
129+
"-h",
130+
"github.com",
131+
"-p",
132+
"https",
133+
...(scopes.length > 0 ? ["--scopes", scopes.join(",")] : [])
134+
],
135+
interactive: false
136+
})
137+
)
138+
139+
return {
140+
cwd,
141+
args,
142+
accountPath,
143+
envPath,
144+
key: buildGithubTokenKey(request.label ?? null),
145+
label: labelFromKey(buildGithubTokenKey(request.label ?? null)),
146+
scopes
147+
}
148+
})
149+
150+
const resolveGithubToken = (
151+
cwd: string,
152+
accountPath: string
153+
): Effect.Effect<string, CommandFailedError | PlatformError, CommandExecutor.CommandExecutor> =>
154+
runDockerAuthCapture(
155+
buildDockerAuthSpec({
156+
cwd,
157+
image: ghImageName,
158+
hostPath: accountPath,
159+
containerPath: ghAuthDir,
160+
env: `GH_CONFIG_DIR=${ghAuthDir}`,
161+
args: ["auth", "token"],
162+
interactive: false
163+
}),
164+
[0],
165+
(exitCode) => new CommandFailedError({ command: "gh auth token", exitCode })
166+
).pipe(
167+
Effect.map((raw) => raw.trim()),
168+
Effect.filterOrFail(
169+
(value) => value.length > 0,
170+
() => new CommandFailedError({ command: "gh auth token", exitCode: 1 })
171+
)
172+
)
173+
174+
const persistGithubToken = (
175+
fs: FileSystem.FileSystem,
176+
envPath: string,
177+
key: string,
178+
token: string
179+
): Effect.Effect<void, PlatformError> =>
180+
Effect.gen(function*(_) {
181+
const current = yield* _(readEnvText(fs, envPath))
182+
const nextText = upsertEnvKey(current, key, token)
183+
yield* _(fs.writeFileString(envPath, nextText))
184+
})
185+
186+
const finalizeMessage = (status: string): string =>
187+
status === "ok"
188+
? `\nGitHub login completed.\n${githubLoginStreamSuccessMarker}\n`
189+
: `\n${githubLoginStreamErrorMarkerPrefix}${status}\n`
190+
191+
const toStreamError = (error: unknown): ApiInternalError | ApiBadRequestError =>
192+
error instanceof ApiBadRequestError || error instanceof ApiInternalError
193+
? error
194+
: new ApiInternalError({
195+
message: String(error),
196+
cause: error
197+
})
198+
199+
const finalizeGithubLogin = (
200+
prepared: PreparedGithubLogin
201+
): Effect.Effect<void, ApiBadRequestError | ApiInternalError, GithubRuntime> =>
202+
Effect.gen(function*(_) {
203+
const fs = yield* _(FileSystem.FileSystem)
204+
const path = yield* _(Path.Path)
205+
const token = yield* _(resolveGithubToken(prepared.cwd, prepared.accountPath).pipe(Effect.mapError(toApiError)))
206+
yield* _(ensureEnvFile(fs, path, prepared.envPath).pipe(Effect.mapError(toApiError)))
207+
yield* _(persistGithubToken(fs, prepared.envPath, prepared.key, token).pipe(Effect.mapError(toApiError)))
208+
yield* _(ensureStateDotDockerGitRepo(token))
209+
yield* _(autoSyncState(`chore(state): auth gh ${prepared.label}`))
210+
})
211+
212+
export const streamGithubAuthLogin = (
213+
request: GithubAuthLoginRequest
214+
): Effect.Effect<Stream.Stream<Uint8Array, ApiBadRequestError | ApiInternalError>, ApiBadRequestError | ApiInternalError, GithubRuntime> =>
215+
Effect.gen(function*(_) {
216+
const prepared = yield* _(prepareGithubLogin(request))
217+
const encoder = new TextEncoder()
218+
const runPromiseExit = Runtime.runPromiseExit(yield* _(Effect.runtime<GithubRuntime>()))
219+
220+
let child: ChildProcess | null = null
221+
const readable = new ReadableStream<Uint8Array>({
222+
start(controller) {
223+
const enqueue = (chunk: Buffer | string) => {
224+
const encoded = typeof chunk === "string" ? encoder.encode(chunk) : new Uint8Array(chunk)
225+
controller.enqueue(encoded)
226+
}
227+
228+
enqueue(`Starting GH auth login in container (scopes: ${prepared.scopes.join(", ")})...\n`)
229+
230+
child = spawn("docker", prepared.args, {
231+
cwd: prepared.cwd,
232+
stdio: ["ignore", "pipe", "pipe"]
233+
})
234+
235+
child.stdout?.on("data", enqueue)
236+
child.stderr?.on("data", enqueue)
237+
238+
child.on("error", (error) => {
239+
controller.error(
240+
new ApiInternalError({
241+
message: String(error),
242+
cause: error
243+
})
244+
)
245+
})
246+
247+
child.on("close", (code) => {
248+
const exitCode = code ?? 1
249+
if (exitCode !== 0) {
250+
enqueue(finalizeMessage(String(exitCode)))
251+
controller.close()
252+
return
253+
}
254+
255+
void runPromiseExit(
256+
finalizeGithubLogin(prepared).pipe(
257+
Effect.matchEffect({
258+
onFailure: (error) =>
259+
Effect.sync(() => {
260+
enqueue(`\nGitHub login finished in browser, but post-login sync failed: ${error.message}\n`)
261+
enqueue(finalizeMessage("post-login"))
262+
}),
263+
onSuccess: () =>
264+
Effect.sync(() => {
265+
enqueue(finalizeMessage("ok"))
266+
})
267+
})
268+
)
269+
).finally(() => {
270+
controller.close()
271+
})
272+
})
273+
},
274+
cancel() {
275+
child?.kill("SIGTERM")
276+
}
277+
})
278+
279+
return Stream.fromReadableStream({
280+
evaluate: () => readable,
281+
onError: toStreamError,
282+
releaseLockOnEnd: true
283+
})
284+
})

packages/api/src/services/projects.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import { deleteDockerGitProject } from "@effect-template/lib/usecases/projects"
2020
import type { RawOptions } from "@effect-template/lib/core/command-options"
2121
import type { CreateCommand as LibCreateCommand } from "@effect-template/lib/core/domain"
2222
import type { ProjectItem } from "@effect-template/lib/usecases/projects"
23-
import { Effect, Either } from "effect"
23+
import { Effect, Either, Logger } from "effect"
2424

2525
import type { CreateProjectRequest, ProjectDetails, ProjectStatus, ProjectSummary } from "../api/contracts.js"
2626
import { ApiAuthRequiredError, ApiConflictError, ApiInternalError, ApiNotFoundError, ApiBadRequestError } from "../api/errors.js"
@@ -77,6 +77,23 @@ const runComposeCapture = (
7777
)
7878
)
7979

80+
const runWithProjectEventLogs = <A, E, R>(
81+
projectId: string,
82+
effect: Effect.Effect<A, E, R>
83+
): Effect.Effect<A, E, R> =>
84+
Effect.gen(function*(_) {
85+
const logger = Logger.make(({ message }) => {
86+
for (const line of String(message).split(/\r?\n/u)) {
87+
const trimmed = line.trimEnd()
88+
if (trimmed.length > 0) {
89+
emitProjectEvent(projectId, "project.deployment.log", { line: trimmed })
90+
}
91+
}
92+
})
93+
94+
return yield* _(effect.pipe(Effect.provide(Logger.replace(Logger.defaultLogger, logger))))
95+
})
96+
8097
const toProjectStatus = (raw: string): ProjectStatus => {
8198
const normalized = raw.toLowerCase()
8299
if (normalized.includes("up") || normalized.includes("running")) {
@@ -380,11 +397,14 @@ export const createProjectFromRequest = (
380397
)
381398

382399
yield* _(
383-
createProject(command).pipe(
400+
runWithProjectEventLogs(
401+
command.outDir,
402+
createProject(command).pipe(
384403
Effect.catchTag("DockerIdentityConflictError", (error) =>
385404
Effect.fail(new ApiConflictError({ message: renderError(error) }))
386405
)
387406
)
407+
)
388408
)
389409

390410
const project = yield* _(

0 commit comments

Comments
 (0)