|
| 1 | +import * as Command from "@effect/platform/Command" |
| 2 | +import * as CommandExecutor from "@effect/platform/CommandExecutor" |
| 3 | +import type { PlatformError } from "@effect/platform/Error" |
| 4 | +import { Effect, pipe } from "effect" |
| 5 | +import * as Deferred from "effect/Deferred" |
| 6 | +import * as Fiber from "effect/Fiber" |
| 7 | +import type * as Scope from "effect/Scope" |
| 8 | +import * as Stream from "effect/Stream" |
| 9 | +import { writeSync } from "node:fs" |
| 10 | + |
| 11 | +import { AuthError, CommandFailedError } from "../shell/errors.js" |
| 12 | + |
| 13 | +type ClaudeAuthorizeInfo = { |
| 14 | + readonly authorizeUrl: string |
| 15 | + readonly state: string | null |
| 16 | +} |
| 17 | + |
| 18 | +type FirstOutcome = |
| 19 | + | { readonly _tag: "Exit"; readonly exitCode: number } |
| 20 | + | { readonly _tag: "AuthorizeUrl"; readonly info: ClaudeAuthorizeInfo } |
| 21 | + |
| 22 | +const oauthCodeEnvKey = "DOCKER_GIT_CLAUDE_OAUTH_CODE" |
| 23 | +const authorizeUrlRegex = /https:\/\/claude\.ai\/oauth\/authorize\S*/u |
| 24 | + |
| 25 | +const extractAuthorizeUrl = (line: string): string | null => { |
| 26 | + const match = authorizeUrlRegex.exec(line) |
| 27 | + return match?.[0] ?? null |
| 28 | +} |
| 29 | + |
| 30 | +const readQueryParam = (raw: string, key: string): string | null => { |
| 31 | + const match = new RegExp(String.raw`[?&]${key}=([^&#\s]+)`, "u").exec(raw) |
| 32 | + return match?.[1] ?? null |
| 33 | +} |
| 34 | + |
| 35 | +const normalizeOauthPaste = (raw: string, authorizeState: string | null): string => { |
| 36 | + const trimmed = raw.trim() |
| 37 | + if (trimmed.length === 0) { |
| 38 | + return "" |
| 39 | + } |
| 40 | + if (trimmed.includes("#")) { |
| 41 | + return trimmed |
| 42 | + } |
| 43 | + const callbackCode = readQueryParam(trimmed, "code") |
| 44 | + const callbackState = readQueryParam(trimmed, "state") |
| 45 | + if (callbackCode !== null) { |
| 46 | + return callbackState === null ? callbackCode : `${callbackCode}#${callbackState}` |
| 47 | + } |
| 48 | + return authorizeState === null ? trimmed : `${trimmed}#${authorizeState}` |
| 49 | +} |
| 50 | + |
| 51 | +const oauthCodeFromEnv = (): string | null => { |
| 52 | + const value = (process.env[oauthCodeEnvKey] ?? "").trim() |
| 53 | + return value.length > 0 ? value : null |
| 54 | +} |
| 55 | + |
| 56 | +const ensureInteractiveStdin = (): Effect.Effect<void, AuthError> => |
| 57 | + process.stdin.isTTY && process.stdout.isTTY && typeof process.stdin.setRawMode === "function" |
| 58 | + ? Effect.void |
| 59 | + : Effect.fail( |
| 60 | + new AuthError({ |
| 61 | + message: |
| 62 | + `Claude auth login needs an interactive TTY, or set ${oauthCodeEnvKey} to the OAuth Authentication Code.` |
| 63 | + }) |
| 64 | + ) |
| 65 | + |
| 66 | +const readHiddenLine = (prompt: string): Effect.Effect<string, AuthError> => |
| 67 | + Effect.async<string, AuthError>((resume) => { |
| 68 | + const previousRaw = process.stdin.isRaw |
| 69 | + let buffer = "" |
| 70 | + |
| 71 | + const cleanup = () => { |
| 72 | + process.stdin.off("data", onData) |
| 73 | + process.stdin.setRawMode(previousRaw) |
| 74 | + } |
| 75 | + |
| 76 | + const done = (value: string) => { |
| 77 | + cleanup() |
| 78 | + writeSync(1, "\n") |
| 79 | + resume(Effect.succeed(value)) |
| 80 | + } |
| 81 | + |
| 82 | + const fail = (message: string) => { |
| 83 | + cleanup() |
| 84 | + resume(Effect.fail(new AuthError({ message }))) |
| 85 | + } |
| 86 | + |
| 87 | + const onData = (chunk: Buffer | string) => { |
| 88 | + const text = typeof chunk === "string" ? chunk : chunk.toString("utf8") |
| 89 | + for (const ch of text) { |
| 90 | + if (ch === "\u0003") { |
| 91 | + fail("Claude auth login cancelled.") |
| 92 | + return |
| 93 | + } |
| 94 | + if (ch === "\r" || ch === "\n") { |
| 95 | + done(buffer) |
| 96 | + return |
| 97 | + } |
| 98 | + if (ch === "\u007F") { |
| 99 | + buffer = buffer.slice(0, Math.max(0, buffer.length - 1)) |
| 100 | + continue |
| 101 | + } |
| 102 | + buffer += ch |
| 103 | + } |
| 104 | + } |
| 105 | + |
| 106 | + writeSync(1, prompt) |
| 107 | + process.stdin.setRawMode(true) |
| 108 | + process.stdin.resume() |
| 109 | + process.stdin.on("data", onData) |
| 110 | + |
| 111 | + return Effect.sync(() => { |
| 112 | + cleanup() |
| 113 | + }) |
| 114 | + }) |
| 115 | + |
| 116 | +const resolveOauthCodeInput = (info: ClaudeAuthorizeInfo): Effect.Effect<string, AuthError> => { |
| 117 | + const fromEnv = oauthCodeFromEnv() |
| 118 | + if (fromEnv !== null) { |
| 119 | + return Effect.succeed(normalizeOauthPaste(fromEnv, info.state)) |
| 120 | + } |
| 121 | + return ensureInteractiveStdin().pipe( |
| 122 | + Effect.zipRight( |
| 123 | + readHiddenLine( |
| 124 | + "\n[docker-git] Paste the Authentication Code from the browser and press Enter (input hidden; Ctrl+C to cancel):\n> " |
| 125 | + ) |
| 126 | + ), |
| 127 | + Effect.map((value) => normalizeOauthPaste(value, info.state)), |
| 128 | + Effect.filterOrFail( |
| 129 | + (value) => value.trim().length > 0, |
| 130 | + () => new AuthError({ message: "Claude auth login requires a non-empty Authentication Code." }) |
| 131 | + ) |
| 132 | + ) |
| 133 | +} |
| 134 | + |
| 135 | +const writeChunk = (fd: number, chunk: Uint8Array): Effect.Effect<void> => |
| 136 | + Effect.sync(() => { |
| 137 | + writeSync(fd, chunk) |
| 138 | + }).pipe(Effect.asVoid) |
| 139 | + |
| 140 | +const pumpDockerOutput = ( |
| 141 | + source: Stream.Stream<Uint8Array, PlatformError>, |
| 142 | + fd: number, |
| 143 | + oauth: Deferred.Deferred<ClaudeAuthorizeInfo> |
| 144 | +): Effect.Effect<void, PlatformError> => { |
| 145 | + const decoder = new TextDecoder("utf-8") |
| 146 | + let remainder = "" |
| 147 | + |
| 148 | + return pipe( |
| 149 | + source, |
| 150 | + Stream.runForEach((chunk) => |
| 151 | + pipe( |
| 152 | + writeChunk(fd, chunk), |
| 153 | + Effect.zipRight( |
| 154 | + Effect.sync((): ClaudeAuthorizeInfo | null => { |
| 155 | + remainder += decoder.decode(chunk) |
| 156 | + // Keep only a sliding window so repeated scans stay cheap. |
| 157 | + if (remainder.length > 8192) { |
| 158 | + remainder = remainder.slice(-8192) |
| 159 | + } |
| 160 | + const authorizeUrl = extractAuthorizeUrl(remainder) |
| 161 | + if (authorizeUrl === null) { |
| 162 | + return null |
| 163 | + } |
| 164 | + const state = readQueryParam(authorizeUrl, "state") |
| 165 | + return { authorizeUrl, state } |
| 166 | + }) |
| 167 | + ), |
| 168 | + Effect.flatMap((info) => |
| 169 | + info === null |
| 170 | + ? Effect.void |
| 171 | + : Deferred.succeed(oauth, info).pipe(Effect.asVoid) |
| 172 | + ), |
| 173 | + Effect.asVoid |
| 174 | + ) |
| 175 | + ) |
| 176 | + ).pipe(Effect.asVoid) |
| 177 | +} |
| 178 | + |
| 179 | +type DockerLoginSpec = { |
| 180 | + readonly cwd: string |
| 181 | + readonly image: string |
| 182 | + readonly hostPath: string |
| 183 | + readonly containerPath: string |
| 184 | + readonly env: ReadonlyArray<string> |
| 185 | + readonly args: ReadonlyArray<string> |
| 186 | + readonly tty: boolean |
| 187 | +} |
| 188 | + |
| 189 | +const buildDockerLoginSpec = ( |
| 190 | + cwd: string, |
| 191 | + accountPath: string, |
| 192 | + image: string, |
| 193 | + containerPath: string |
| 194 | +): DockerLoginSpec => ({ |
| 195 | + cwd, |
| 196 | + image, |
| 197 | + hostPath: accountPath, |
| 198 | + containerPath, |
| 199 | + env: [`CLAUDE_CONFIG_DIR=${containerPath}`, "BROWSER=echo"], |
| 200 | + args: ["auth", "login"], |
| 201 | + tty: process.stdin.isTTY && process.stdout.isTTY |
| 202 | +}) |
| 203 | + |
| 204 | +const buildDockerLoginArgs = (spec: DockerLoginSpec): ReadonlyArray<string> => { |
| 205 | + const base: Array<string> = ["run", "--rm", "-i"] |
| 206 | + if (spec.tty) { |
| 207 | + base.push("-t") |
| 208 | + } |
| 209 | + base.push("-v", `${spec.hostPath}:${spec.containerPath}`) |
| 210 | + for (const entry of spec.env) { |
| 211 | + const trimmed = entry.trim() |
| 212 | + if (trimmed.length === 0) { |
| 213 | + continue |
| 214 | + } |
| 215 | + base.push("-e", trimmed) |
| 216 | + } |
| 217 | + return [...base, spec.image, ...spec.args] |
| 218 | +} |
| 219 | + |
| 220 | +const startDockerProcess = ( |
| 221 | + executor: CommandExecutor.CommandExecutor, |
| 222 | + spec: DockerLoginSpec |
| 223 | +): Effect.Effect<CommandExecutor.Process, PlatformError, Scope.Scope> => |
| 224 | + executor.start( |
| 225 | + pipe( |
| 226 | + Command.make("docker", ...buildDockerLoginArgs(spec)), |
| 227 | + Command.workingDirectory(spec.cwd), |
| 228 | + Command.stdin("pipe"), |
| 229 | + Command.stdout("pipe"), |
| 230 | + Command.stderr("pipe") |
| 231 | + ) |
| 232 | + ) |
| 233 | + |
| 234 | +const awaitFirstOutcome = ( |
| 235 | + proc: CommandExecutor.Process, |
| 236 | + oauth: Deferred.Deferred<ClaudeAuthorizeInfo> |
| 237 | +): Effect.Effect<FirstOutcome, PlatformError> => |
| 238 | + Effect.race( |
| 239 | + Deferred.await(oauth).pipe(Effect.map((info) => ({ _tag: "AuthorizeUrl" as const, info }))), |
| 240 | + proc.exitCode.pipe(Effect.map((exitCode) => ({ _tag: "Exit" as const, exitCode: Number(exitCode) }))) |
| 241 | + ) |
| 242 | + |
| 243 | +const ensureExitOk = (exitCode: number): Effect.Effect<void, CommandFailedError> => |
| 244 | + exitCode === 0 ? Effect.void : Effect.fail(new CommandFailedError({ command: "claude auth login", exitCode })) |
| 245 | + |
| 246 | +const feedOauthCode = (proc: CommandExecutor.Process, code: string): Effect.Effect<void, PlatformError> => { |
| 247 | + const bytes = new TextEncoder().encode(`${code}\n`) |
| 248 | + return pipe(Stream.make(bytes), Stream.run(proc.stdin), Effect.asVoid) |
| 249 | +} |
| 250 | + |
| 251 | +const finishClaudeLogin = ( |
| 252 | + outcome: FirstOutcome, |
| 253 | + proc: CommandExecutor.Process |
| 254 | +): Effect.Effect<void, AuthError | CommandFailedError | PlatformError> => { |
| 255 | + if (outcome._tag === "Exit") { |
| 256 | + return ensureExitOk(outcome.exitCode) |
| 257 | + } |
| 258 | + return resolveOauthCodeInput(outcome.info).pipe( |
| 259 | + Effect.flatMap((code) => feedOauthCode(proc, code)), |
| 260 | + Effect.zipRight(proc.exitCode.pipe(Effect.map(Number), Effect.flatMap((exitCode) => ensureExitOk(exitCode)))), |
| 261 | + Effect.asVoid |
| 262 | + ) |
| 263 | +} |
| 264 | + |
| 265 | +export const runClaudeOauthLoginWithPrompt = ( |
| 266 | + cwd: string, |
| 267 | + accountPath: string, |
| 268 | + options: { |
| 269 | + readonly image: string |
| 270 | + readonly containerPath: string |
| 271 | + } |
| 272 | +): Effect.Effect<void, AuthError | CommandFailedError | PlatformError, CommandExecutor.CommandExecutor> => |
| 273 | + Effect.scoped( |
| 274 | + Effect.gen(function*(_) { |
| 275 | + const executor = yield* _(CommandExecutor.CommandExecutor) |
| 276 | + const spec = buildDockerLoginSpec(cwd, accountPath, options.image, options.containerPath) |
| 277 | + const proc = yield* _(startDockerProcess(executor, spec)) |
| 278 | + |
| 279 | + const oauth = yield* _(Deferred.make<ClaudeAuthorizeInfo>()) |
| 280 | + const stdoutFiber = yield* _(Effect.forkScoped(pumpDockerOutput(proc.stdout, 1, oauth))) |
| 281 | + const stderrFiber = yield* _(Effect.forkScoped(pumpDockerOutput(proc.stderr, 2, oauth))) |
| 282 | + |
| 283 | + const first = yield* _(awaitFirstOutcome(proc, oauth)) |
| 284 | + yield* _(finishClaudeLogin(first, proc)) |
| 285 | + |
| 286 | + yield* _(Fiber.join(stdoutFiber)) |
| 287 | + yield* _(Fiber.join(stderrFiber)) |
| 288 | + }).pipe(Effect.asVoid) |
| 289 | + ) |
0 commit comments