@@ -4,10 +4,12 @@ import path from 'path';
44import os from 'os' ;
55import chalk from 'chalk' ;
66import open from 'open' ;
7+ import qrcode from 'qrcode-terminal' ;
78
89interface LoginOptions {
910 force ?: boolean ;
1011 token ?: string ;
12+ qr ?: boolean ;
1113}
1214
1315const CONFIG_DIR = path . join ( os . homedir ( ) , '.atxp' ) ;
@@ -33,9 +35,12 @@ export async function login(options: LoginOptions = {}): Promise<void> {
3335 if ( options . token ) {
3436 connectionString = options . token ;
3537 console . log ( 'Using provided token for headless authentication...' ) ;
38+ } else if ( options . qr ) {
39+ // QR code mode explicitly requested
40+ connectionString = await loginWithQRCode ( ) ;
3641 } else {
37- // Otherwise, use browser-based login
38- connectionString = await loginWithBrowser ( ) ;
42+ // Try browser first, fall back to QR code on failure
43+ connectionString = await loginWithBrowserOrQR ( ) ;
3944 }
4045
4146 saveConnectionString ( connectionString ) ;
@@ -54,48 +59,39 @@ export async function login(options: LoginOptions = {}): Promise<void> {
5459 }
5560}
5661
57- async function loginWithBrowser ( ) : Promise < string > {
62+ /**
63+ * Check if we're likely in a headless environment where browser won't work
64+ */
65+ function isHeadlessEnvironment ( ) : boolean {
66+ // Common indicators of headless/non-GUI environments
67+ const isSSH = ! ! process . env . SSH_TTY || ! ! process . env . SSH_CONNECTION ;
68+ const noDisplay = process . platform !== 'win32' && process . platform !== 'darwin' && ! process . env . DISPLAY ;
69+ const isDocker = fs . existsSync ( '/.dockerenv' ) ;
70+ const isCI = ! ! process . env . CI || ! ! process . env . GITHUB_ACTIONS || ! ! process . env . GITLAB_CI ;
71+
72+ return isSSH || noDisplay || isDocker || isCI ;
73+ }
74+
75+ /**
76+ * Try browser login first, fall back to QR code if it fails
77+ */
78+ async function loginWithBrowserOrQR ( ) : Promise < string > {
79+ // If we detect a headless environment, go straight to QR
80+ if ( isHeadlessEnvironment ( ) ) {
81+ console . log ( chalk . yellow ( 'Headless environment detected, using QR code login...' ) ) ;
82+ console . log ( ) ;
83+ return loginWithQRCode ( ) ;
84+ }
85+
86+ // Try browser-based login
5887 return new Promise ( ( resolve , reject ) => {
5988 const server = http . createServer ( ( req , res ) => {
6089 const url = new URL ( req . url ! , `http://localhost` ) ;
6190 const connectionString = url . searchParams . get ( 'connection_string' ) ;
6291
6392 if ( connectionString ) {
6493 res . writeHead ( 200 , { 'Content-Type' : 'text/html' } ) ;
65- res . end ( `
66- <!DOCTYPE html>
67- <html>
68- <head>
69- <title>ATXP Login</title>
70- <style>
71- body {
72- font-family: system-ui, -apple-system, sans-serif;
73- display: flex;
74- justify-content: center;
75- align-items: center;
76- min-height: 100vh;
77- margin: 0;
78- background: #f5f5f5;
79- }
80- .container {
81- text-align: center;
82- padding: 40px;
83- background: white;
84- border-radius: 8px;
85- box-shadow: 0 2px 10px rgba(0,0,0,0.1);
86- }
87- h1 { color: #10b981; margin-bottom: 16px; }
88- p { color: #666; }
89- </style>
90- </head>
91- <body>
92- <div class="container">
93- <h1>Login Successful</h1>
94- <p>You can close this tab and return to your terminal.</p>
95- </div>
96- </body>
97- </html>
98- ` ) ;
94+ res . end ( getSuccessHTML ( ) ) ;
9995 server . close ( ) ;
10096 resolve ( decodeURIComponent ( connectionString ) ) ;
10197 } else {
@@ -109,7 +105,7 @@ async function loginWithBrowser(): Promise<string> {
109105 } ) ;
110106
111107 // Listen on random available port
112- server . listen ( 0 , '127.0.0.1' , ( ) => {
108+ server . listen ( 0 , '127.0.0.1' , async ( ) => {
113109 const address = server . address ( ) ;
114110 const port = typeof address === 'object' ? address ?. port : null ;
115111
@@ -124,9 +120,42 @@ async function loginWithBrowser(): Promise<string> {
124120 console . log ( 'Opening browser to complete login...' ) ;
125121 console . log ( chalk . gray ( `(${ loginUrl } )` ) ) ;
126122 console . log ( ) ;
127- console . log ( 'Waiting for authentication...' ) ;
128123
129- open ( loginUrl ) ;
124+ try {
125+ // Try to open browser
126+ const browserProcess = await open ( loginUrl ) ;
127+
128+ // Check if the browser process exited with an error
129+ browserProcess . on ( 'error' , async ( ) => {
130+ console . log ( ) ;
131+ console . log ( chalk . yellow ( 'Browser failed to open. Switching to QR code...' ) ) ;
132+ console . log ( ) ;
133+ server . close ( ) ;
134+ try {
135+ const result = await loginWithQRCode ( ) ;
136+ resolve ( result ) ;
137+ } catch ( qrError ) {
138+ reject ( qrError ) ;
139+ }
140+ } ) ;
141+
142+ console . log ( 'Waiting for authentication...' ) ;
143+ console . log ( chalk . gray ( '(If browser did not open, press Ctrl+C and run: npx atxp login --qr)' ) ) ;
144+
145+ } catch ( openError ) {
146+ // Browser open failed, fall back to QR
147+ console . log ( ) ;
148+ console . log ( chalk . yellow ( 'Could not open browser. Switching to QR code...' ) ) ;
149+ console . log ( ) ;
150+ server . close ( ) ;
151+ try {
152+ const result = await loginWithQRCode ( ) ;
153+ resolve ( result ) ;
154+ } catch ( qrError ) {
155+ reject ( qrError ) ;
156+ }
157+ return ;
158+ }
130159
131160 // Timeout after 5 minutes
132161 const timeout = setTimeout ( ( ) => {
@@ -141,6 +170,107 @@ async function loginWithBrowser(): Promise<string> {
141170 } ) ;
142171}
143172
173+ /**
174+ * QR code based login - shows QR in terminal for mobile scanning
175+ */
176+ async function loginWithQRCode ( ) : Promise < string > {
177+ return new Promise ( ( resolve , reject ) => {
178+ const server = http . createServer ( ( req , res ) => {
179+ const url = new URL ( req . url ! , `http://localhost` ) ;
180+ const connectionString = url . searchParams . get ( 'connection_string' ) ;
181+
182+ if ( connectionString ) {
183+ res . writeHead ( 200 , { 'Content-Type' : 'text/html' } ) ;
184+ res . end ( getSuccessHTML ( ) ) ;
185+ server . close ( ) ;
186+ resolve ( decodeURIComponent ( connectionString ) ) ;
187+ } else {
188+ res . writeHead ( 400 , { 'Content-Type' : 'text/plain' } ) ;
189+ res . end ( 'Missing connection_string parameter' ) ;
190+ }
191+ } ) ;
192+
193+ server . on ( 'error' , ( err ) => {
194+ reject ( new Error ( `Failed to start local server: ${ err . message } ` ) ) ;
195+ } ) ;
196+
197+ // Listen on random available port
198+ server . listen ( 0 , '127.0.0.1' , ( ) => {
199+ const address = server . address ( ) ;
200+ const port = typeof address === 'object' ? address ?. port : null ;
201+
202+ if ( ! port ) {
203+ reject ( new Error ( 'Failed to start local server' ) ) ;
204+ return ;
205+ }
206+
207+ const redirectUri = `http://localhost:${ port } /callback` ;
208+ const loginUrl = `https://accounts.atxp.ai?cli_redirect=${ encodeURIComponent ( redirectUri ) } ` ;
209+
210+ console . log ( chalk . bold ( 'Scan this QR code with your phone to login:' ) ) ;
211+ console . log ( ) ;
212+
213+ // Generate and display QR code
214+ qrcode . generate ( loginUrl , { small : true } , ( qr ) => {
215+ console . log ( qr ) ;
216+ } ) ;
217+
218+ console . log ( ) ;
219+ console . log ( chalk . gray ( 'Or open this URL manually:' ) ) ;
220+ console . log ( chalk . cyan ( loginUrl ) ) ;
221+ console . log ( ) ;
222+ console . log ( 'Waiting for authentication...' ) ;
223+
224+ // Timeout after 5 minutes
225+ const timeout = setTimeout ( ( ) => {
226+ server . close ( ) ;
227+ reject ( new Error ( 'Login timed out. Please try again.' ) ) ;
228+ } , 5 * 60 * 1000 ) ;
229+
230+ server . on ( 'close' , ( ) => {
231+ clearTimeout ( timeout ) ;
232+ } ) ;
233+ } ) ;
234+ } ) ;
235+ }
236+
237+ function getSuccessHTML ( ) : string {
238+ return `
239+ <!DOCTYPE html>
240+ <html>
241+ <head>
242+ <title>ATXP Login</title>
243+ <style>
244+ body {
245+ font-family: system-ui, -apple-system, sans-serif;
246+ display: flex;
247+ justify-content: center;
248+ align-items: center;
249+ min-height: 100vh;
250+ margin: 0;
251+ background: #f5f5f5;
252+ }
253+ .container {
254+ text-align: center;
255+ padding: 40px;
256+ background: white;
257+ border-radius: 8px;
258+ box-shadow: 0 2px 10px rgba(0,0,0,0.1);
259+ }
260+ h1 { color: #10b981; margin-bottom: 16px; }
261+ p { color: #666; }
262+ </style>
263+ </head>
264+ <body>
265+ <div class="container">
266+ <h1>Login Successful</h1>
267+ <p>You can close this tab and return to your terminal.</p>
268+ </div>
269+ </body>
270+ </html>
271+ ` ;
272+ }
273+
144274function saveConnectionString ( connectionString : string ) : void {
145275 if ( ! fs . existsSync ( CONFIG_DIR ) ) {
146276 fs . mkdirSync ( CONFIG_DIR , { recursive : true } ) ;
0 commit comments