@@ -22,11 +22,13 @@ import { CLIProxyProvider } from './types';
2222import {
2323 AccountInfo ,
2424 discoverExistingAccounts ,
25+ generateNickname ,
2526 getDefaultAccount ,
2627 getProviderAccounts ,
2728 registerAccount ,
2829 touchAccount ,
2930} from './account-manager' ;
31+ import { preflightOAuthCheck } from '../management/oauth-port-diagnostics' ;
3032
3133/**
3234 * OAuth callback ports used by CLIProxyAPI (hardcoded in binary)
@@ -90,26 +92,50 @@ function killProcessOnPort(port: number, verbose: boolean): boolean {
9092
9193/**
9294 * Detect if running in a headless environment (no browser available)
95+ *
96+ * IMPROVED: Avoids false positives on Windows desktop environments
97+ * where isTTY may be undefined due to terminal wrapper behavior.
98+ *
99+ * Case study: Vietnamese Windows users reported "command hangs" because
100+ * their terminal (PowerShell via npm) didn't set isTTY correctly.
93101 */
94102function isHeadlessEnvironment ( ) : boolean {
95- // SSH session
103+ // SSH session - always headless
96104 if ( process . env . SSH_TTY || process . env . SSH_CLIENT || process . env . SSH_CONNECTION ) {
97105 return true ;
98106 }
99107
100- // No display ( Linux/ X11)
108+ // No display on Linux ( X11/Wayland) - headless
101109 if ( process . platform === 'linux' && ! process . env . DISPLAY && ! process . env . WAYLAND_DISPLAY ) {
102110 return true ;
103111 }
104112
105- // Non-interactive (piped stdin) - skip on Windows
106- // Windows npm wrappers don't set isTTY correctly (returns undefined, not true)
113+ // Windows desktop - NEVER headless unless SSH (already checked above)
114+ // This fixes false positive where Windows npm wrappers don't set isTTY correctly
107115 // Windows desktop environments always have browser capability
108- if ( process . platform !== 'win32' && ! process . stdin . isTTY ) {
109- return true ;
116+ if ( process . platform === 'win32' ) {
117+ return false ;
118+ }
119+
120+ // macOS - check for proper terminal
121+ if ( process . platform === 'darwin' ) {
122+ // Non-interactive stdin on macOS means likely piped/scripted
123+ if ( ! process . stdin . isTTY ) {
124+ return true ;
125+ }
126+ return false ;
110127 }
111128
112- return false ;
129+ // Linux with display - check TTY
130+ if ( process . platform === 'linux' ) {
131+ if ( ! process . stdin . isTTY ) {
132+ return true ;
133+ }
134+ return false ;
135+ }
136+
137+ // Default fallback for unknown platforms
138+ return ! process . stdin . isTTY ;
113139}
114140
115141/**
@@ -404,14 +430,15 @@ export function clearAuth(provider: CLIProxyProvider): boolean {
404430 * Auto-detects headless environment and uses --no-browser flag accordingly
405431 * @param provider - The CLIProxy provider to authenticate
406432 * @param options - OAuth options
433+ * @param options.add - If true, skip confirm prompt when adding another account
407434 * @returns Account info if successful, null otherwise
408435 */
409436export async function triggerOAuth (
410437 provider : CLIProxyProvider ,
411- options : { verbose ?: boolean ; headless ?: boolean ; account ?: string } = { }
438+ options : { verbose ?: boolean ; headless ?: boolean ; account ?: string ; add ?: boolean } = { }
412439) : Promise < AccountInfo | null > {
413440 const oauthConfig = getOAuthConfig ( provider ) ;
414- const { verbose = false } = options ;
441+ const { verbose = false , add = false } = options ;
415442
416443 // Auto-detect headless if not explicitly set
417444 const headless = options . headless ?? isHeadlessEnvironment ( ) ;
@@ -422,6 +449,47 @@ export async function triggerOAuth(
422449 }
423450 } ;
424451
452+ // Check for existing accounts and prompt if --add not specified
453+ const existingAccounts = getProviderAccounts ( provider ) ;
454+ if ( existingAccounts . length > 0 && ! add ) {
455+ console . log ( '' ) ;
456+ console . log (
457+ `[i] ${ existingAccounts . length } account(s) already authenticated for ${ oauthConfig . displayName } `
458+ ) ;
459+
460+ // Import readline for confirm prompt
461+ const readline = await import ( 'readline' ) ;
462+ const rl = readline . createInterface ( {
463+ input : process . stdin ,
464+ output : process . stdout ,
465+ } ) ;
466+
467+ const confirmed = await new Promise < boolean > ( ( resolve ) => {
468+ rl . question ( '[?] Add another account? (y/N): ' , ( answer ) => {
469+ rl . close ( ) ;
470+ resolve ( answer . toLowerCase ( ) === 'y' || answer . toLowerCase ( ) === 'yes' ) ;
471+ } ) ;
472+ } ) ;
473+
474+ if ( ! confirmed ) {
475+ console . log ( '[i] Cancelled' ) ;
476+ return null ;
477+ }
478+ }
479+
480+ // Pre-flight check: verify OAuth callback port is available
481+ const preflight = await preflightOAuthCheck ( provider ) ;
482+ if ( ! preflight . ready ) {
483+ console . log ( '' ) ;
484+ console . log ( '[!] OAuth pre-flight check failed:' ) ;
485+ for ( const issue of preflight . issues ) {
486+ console . log ( ` ${ issue } ` ) ;
487+ }
488+ console . log ( '' ) ;
489+ console . log ( '[i] Resolve the port conflict and try again.' ) ;
490+ return null ;
491+ }
492+
425493 // Ensure binary exists
426494 let binaryPath : string ;
427495 try {
@@ -624,8 +692,8 @@ function registerAccountFromToken(
624692 const data = JSON . parse ( content ) ;
625693 const email = data . email || undefined ;
626694
627- // Register the account
628- return registerAccount ( provider , newestFile , email ) ;
695+ // Register the account with auto-generated nickname
696+ return registerAccount ( provider , newestFile , email , generateNickname ( email ) ) ;
629697 } catch {
630698 return null ;
631699 }
0 commit comments