Skip to content

Commit 4df6b11

Browse files
committed
fix(auth): add Claude OAuth progress logs
1 parent 6038a4d commit 4df6b11

2 files changed

Lines changed: 119 additions & 61 deletions

File tree

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { Effect } from "effect"
2+
import { writeSync } from "node:fs"
3+
import * as Readline from "node:readline"
4+
5+
import { AuthError } from "../shell/errors.js"
6+
7+
export const readVisibleLine = (prompt: string): Effect.Effect<string, AuthError> =>
8+
Effect.async<string, AuthError>((resume) => {
9+
// We intentionally use readline (not raw mode) so paste works reliably in common terminals.
10+
const hasRawMode = process.stdin.isTTY && typeof process.stdin.setRawMode === "function"
11+
const previousRaw = hasRawMode ? process.stdin.isRaw : undefined
12+
if (hasRawMode) {
13+
process.stdin.setRawMode(false)
14+
}
15+
16+
const rl = Readline.createInterface({
17+
input: process.stdin,
18+
output: process.stdout,
19+
terminal: true
20+
})
21+
22+
let settled = false
23+
const cleanup = () => {
24+
rl.removeAllListeners()
25+
rl.close()
26+
if (hasRawMode && previousRaw !== undefined) {
27+
process.stdin.setRawMode(previousRaw)
28+
}
29+
}
30+
31+
rl.on("SIGINT", () => {
32+
if (settled) {
33+
return
34+
}
35+
settled = true
36+
cleanup()
37+
resume(Effect.fail(new AuthError({ message: "Claude auth login cancelled." })))
38+
})
39+
40+
writeSync(1, prompt)
41+
rl.question("", (answer) => {
42+
if (settled) {
43+
return
44+
}
45+
settled = true
46+
cleanup()
47+
resume(Effect.succeed(answer))
48+
})
49+
50+
return Effect.sync(() => {
51+
if (settled) {
52+
return
53+
}
54+
settled = true
55+
cleanup()
56+
})
57+
})

packages/lib/src/usecases/auth-claude-oauth.ts

Lines changed: 62 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
import * as Command from "@effect/platform/Command"
22
import * as CommandExecutor from "@effect/platform/CommandExecutor"
33
import type { PlatformError } from "@effect/platform/Error"
4-
import { Effect, pipe } from "effect"
4+
import { Duration, Effect, pipe, Schedule } from "effect"
55
import * as Deferred from "effect/Deferred"
66
import * as Fiber from "effect/Fiber"
77
import type * as Scope from "effect/Scope"
88
import * as Stream from "effect/Stream"
99
import { writeSync } from "node:fs"
10-
import * as Readline from "node:readline"
1110

1211
import { AuthError, CommandFailedError } from "../shell/errors.js"
12+
import { readVisibleLine } from "./auth-claude-oauth-input.js"
1313

1414
type ClaudeAuthorizeInfo = {
1515
readonly authorizeUrl: string
@@ -64,67 +64,14 @@ const ensureInteractiveStdin = (): Effect.Effect<void, AuthError> =>
6464
})
6565
)
6666

67-
const readLine = (prompt: string): Effect.Effect<string, AuthError> =>
68-
Effect.async<string, AuthError>((resume) => {
69-
// We intentionally use readline (not raw mode) so paste works reliably in common terminals.
70-
const hasRawMode = process.stdin.isTTY && typeof process.stdin.setRawMode === "function"
71-
const previousRaw = hasRawMode ? process.stdin.isRaw : undefined
72-
if (hasRawMode) {
73-
process.stdin.setRawMode(false)
74-
}
75-
76-
const rl = Readline.createInterface({
77-
input: process.stdin,
78-
output: process.stdout,
79-
terminal: true
80-
})
81-
82-
let settled = false
83-
84-
const cleanup = () => {
85-
rl.removeAllListeners()
86-
rl.close()
87-
if (hasRawMode && previousRaw !== undefined) {
88-
process.stdin.setRawMode(previousRaw)
89-
}
90-
}
91-
92-
rl.on("SIGINT", () => {
93-
if (settled) {
94-
return
95-
}
96-
settled = true
97-
cleanup()
98-
resume(Effect.fail(new AuthError({ message: "Claude auth login cancelled." })))
99-
})
100-
101-
writeSync(1, prompt)
102-
rl.question("", (answer) => {
103-
if (settled) {
104-
return
105-
}
106-
settled = true
107-
cleanup()
108-
resume(Effect.succeed(answer))
109-
})
110-
111-
return Effect.sync(() => {
112-
if (settled) {
113-
return
114-
}
115-
settled = true
116-
cleanup()
117-
})
118-
})
119-
12067
const resolveOauthCodeInput = (info: ClaudeAuthorizeInfo): Effect.Effect<string, AuthError> => {
12168
const fromEnv = oauthCodeFromEnv()
12269
if (fromEnv !== null) {
12370
return Effect.succeed(normalizeOauthPaste(fromEnv, info.state))
12471
}
12572
return ensureInteractiveStdin().pipe(
12673
Effect.zipRight(
127-
readLine(
74+
readVisibleLine(
12875
"\n[docker-git] Paste the Authentication Code from the browser and press Enter (Ctrl+C to cancel):\n> "
12976
)
13077
),
@@ -141,6 +88,11 @@ const writeChunk = (fd: number, chunk: Uint8Array): Effect.Effect<void> =>
14188
writeSync(fd, chunk)
14289
}).pipe(Effect.asVoid)
14390

91+
const logLine = (line: string): Effect.Effect<void> =>
92+
Effect.sync(() => {
93+
writeSync(1, line.endsWith("\n") ? line : `${line}\n`)
94+
}).pipe(Effect.asVoid)
95+
14496
const pumpDockerOutput = (
14597
source: Stream.Stream<Uint8Array, PlatformError>,
14698
fd: number,
@@ -204,9 +156,9 @@ const buildDockerLoginSpec = (
204156
})
205157

206158
const buildDockerLoginArgs = (spec: DockerLoginSpec): ReadonlyArray<string> => {
207-
// NOTE: We intentionally avoid `-t` here.
208-
// We need stdin as a pipe (to feed the OAuth code), and `docker run -t` errors when stdin isn't a real TTY.
209-
const base: Array<string> = ["run", "--rm", "-i", "-v", `${spec.hostPath}:${spec.containerPath}`]
159+
// NOTE: Claude Code's `auth login` uses an interactive prompt that behaves poorly without a TTY.
160+
// We still want to programmatically feed the code, so we run `docker` under `script(1)` which allocates a pty.
161+
const base: Array<string> = ["run", "--rm", "-i", "-t", "-v", `${spec.hostPath}:${spec.containerPath}`]
210162
for (const entry of spec.env) {
211163
const trimmed = entry.trim()
212164
if (trimmed.length === 0) {
@@ -217,13 +169,36 @@ const buildDockerLoginArgs = (spec: DockerLoginSpec): ReadonlyArray<string> => {
217169
return [...base, spec.image, ...spec.args]
218170
}
219171

172+
const shellQuote = (value: string): string => {
173+
if (value.length === 0) {
174+
return "''"
175+
}
176+
// POSIX-safe single-quote escaping: ' -> '\'' .
177+
const singleQuoteEscape = String.raw`'\''`
178+
return "'".concat(value.replaceAll("'", singleQuoteEscape), "'")
179+
}
180+
181+
const buildDockerLoginCommandString = (spec: DockerLoginSpec): string =>
182+
["docker", ...buildDockerLoginArgs(spec)]
183+
.map((part) => shellQuote(part))
184+
.join(" ")
185+
220186
const startDockerProcess = (
221187
executor: CommandExecutor.CommandExecutor,
222188
spec: DockerLoginSpec
223189
): Effect.Effect<CommandExecutor.Process, PlatformError, Scope.Scope> =>
224190
executor.start(
225191
pipe(
226-
Command.make("docker", ...buildDockerLoginArgs(spec)),
192+
// We run docker via script(1) to get a pseudo-tty even when we need to pipe input from Node.
193+
Command.make(
194+
"script",
195+
"-q",
196+
"-f",
197+
"-e",
198+
"-c",
199+
buildDockerLoginCommandString(spec),
200+
"/dev/null"
201+
),
227202
Command.workingDirectory(spec.cwd),
228203
Command.stdin("pipe"),
229204
Command.stdout("pipe"),
@@ -248,6 +223,32 @@ const feedOauthCode = (proc: CommandExecutor.Process, code: string): Effect.Effe
248223
return pipe(Stream.make(bytes), Stream.run(proc.stdin), Effect.asVoid)
249224
}
250225

226+
const awaitExitCodeWithHeartbeat = (proc: CommandExecutor.Process): Effect.Effect<number, PlatformError> =>
227+
Effect.gen(function*(_) {
228+
const start = Date.now()
229+
const heartbeat = yield* _(
230+
Effect.fork(
231+
logLine("[docker-git] Waiting for Claude to finish OAuth login (this can be silent for a bit)...").pipe(
232+
Effect.zipRight(
233+
Effect.repeat(
234+
Effect.sync(() => {
235+
const seconds = Math.max(0, Math.floor((Date.now() - start) / 1000))
236+
writeSync(1, `[docker-git] Still waiting for Claude... (${seconds}s)\n`)
237+
}),
238+
Schedule.addDelay(Schedule.forever, () => Duration.seconds(5))
239+
)
240+
)
241+
)
242+
)
243+
)
244+
return yield* _(
245+
proc.exitCode.pipe(
246+
Effect.map(Number),
247+
Effect.ensuring(Fiber.interrupt(heartbeat).pipe(Effect.ignore))
248+
)
249+
)
250+
})
251+
251252
const finishClaudeLogin = (
252253
outcome: FirstOutcome,
253254
proc: CommandExecutor.Process
@@ -265,7 +266,7 @@ const finishClaudeLogin = (
265266
)
266267
)
267268
),
268-
Effect.zipRight(proc.exitCode.pipe(Effect.map(Number), Effect.flatMap((exitCode) => ensureExitOk(exitCode)))),
269+
Effect.zipRight(awaitExitCodeWithHeartbeat(proc).pipe(Effect.flatMap((exitCode) => ensureExitOk(exitCode)))),
269270
Effect.asVoid
270271
)
271272
}

0 commit comments

Comments
 (0)