11import * as Command from "@effect/platform/Command"
22import * as CommandExecutor from "@effect/platform/CommandExecutor"
33import type { PlatformError } from "@effect/platform/Error"
4- import { Effect , pipe } from "effect"
4+ import { Duration , Effect , pipe , Schedule } from "effect"
55import * as Deferred from "effect/Deferred"
66import * as Fiber from "effect/Fiber"
77import type * as Scope from "effect/Scope"
88import * as Stream from "effect/Stream"
99import { writeSync } from "node:fs"
10- import * as Readline from "node:readline"
1110
1211import { AuthError , CommandFailedError } from "../shell/errors.js"
12+ import { readVisibleLine } from "./auth-claude-oauth-input.js"
1313
1414type 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-
12067const 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+
14496const pumpDockerOutput = (
14597 source : Stream . Stream < Uint8Array , PlatformError > ,
14698 fd : number ,
@@ -204,9 +156,9 @@ const buildDockerLoginSpec = (
204156} )
205157
206158const 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+
220186const 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+
251252const 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