From bb3917c53c1f553c628c50627661449088fbcc0c Mon Sep 17 00:00:00 2001 From: Emilio Amaya Date: Mon, 26 Jan 2026 15:30:42 -0500 Subject: [PATCH] feat: add QR code fallback for login - Add --qr flag for explicit QR code login - Detect headless environments (SSH, Docker, CI, no DISPLAY) and auto-fallback to QR - Try browser first, fall back to QR if browser fails to open - Add qrcode-terminal dependency for terminal QR rendering Co-Authored-By: Claude Opus 4.5 --- package-lock.json | 31 +++--- packages/atxp/package.json | 4 +- packages/atxp/src/help.ts | 2 + packages/atxp/src/index.ts | 4 +- packages/atxp/src/login.ts | 210 ++++++++++++++++++++++++++++++------- 5 files changed, 196 insertions(+), 55 deletions(-) diff --git a/package-lock.json b/package-lock.json index d687553..3cbea87 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4834,6 +4834,13 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/qrcode-terminal": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/@types/qrcode-terminal/-/qrcode-terminal-0.12.2.tgz", + "integrity": "sha512-v+RcIEJ+Uhd6ygSQ0u5YYY7ZM+la7GgPbs0V/7l/kFs2uO4S8BcIUEMoP7za4DNIqNnUD5npf0A/7kBhrCKG5Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", @@ -13975,7 +13982,8 @@ "fs-extra": "^11.2.0", "inquirer": "^9.2.12", "open": "^9.1.0", - "ora": "^7.0.1" + "ora": "^7.0.1", + "qrcode-terminal": "^0.12.0" }, "bin": { "atxp": "dist/index.js" @@ -13984,6 +13992,7 @@ "@types/fs-extra": "^11.0.4", "@types/inquirer": "^9.0.7", "@types/node": "^22.13.0", + "@types/qrcode-terminal": "^0.12.2", "tsx": "^4.19.2", "typescript": "^5.7.3", "vitest": "^1.0.0" @@ -14004,6 +14013,14 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "packages/atxp/node_modules/qrcode-terminal": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz", + "integrity": "sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ==", + "bin": { + "qrcode-terminal": "bin/qrcode-terminal.js" + } + }, "packages/create-atxp": { "version": "1.2.2", "license": "MIT", @@ -14018,18 +14035,6 @@ "globals": "^16.3.0", "vitest": "^1.0.0" } - }, - "packages/create-atxp/node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } } } } diff --git a/packages/atxp/package.json b/packages/atxp/package.json index eef182c..aafea13 100644 --- a/packages/atxp/package.json +++ b/packages/atxp/package.json @@ -39,12 +39,14 @@ "fs-extra": "^11.2.0", "inquirer": "^9.2.12", "open": "^9.1.0", - "ora": "^7.0.1" + "ora": "^7.0.1", + "qrcode-terminal": "^0.12.0" }, "devDependencies": { "@types/fs-extra": "^11.0.4", "@types/inquirer": "^9.0.7", "@types/node": "^22.13.0", + "@types/qrcode-terminal": "^0.12.2", "tsx": "^4.19.2", "typescript": "^5.7.3", "vitest": "^1.0.0" diff --git a/packages/atxp/src/help.ts b/packages/atxp/src/help.ts index 3f20902..abf1032 100644 --- a/packages/atxp/src/help.ts +++ b/packages/atxp/src/help.ts @@ -34,6 +34,7 @@ export function showHelp(): void { console.log(chalk.bold('Login Options:')); console.log(' ' + chalk.yellow('--token, -t') + ' ' + 'Provide token directly (headless mode)'); + console.log(' ' + chalk.yellow('--qr') + ' ' + 'Use QR code login (for terminals without browser)'); console.log(' ' + chalk.yellow('--force') + ' ' + 'Update connection string even if already set'); console.log(); @@ -54,6 +55,7 @@ export function showHelp(): void { console.log(chalk.bold('Examples:')); console.log(' npx atxp login # Log in to ATXP (browser)'); + console.log(' npx atxp login --qr # Log in with QR code'); console.log(' npx atxp login --token $TOKEN # Log in with token (headless)'); console.log(' npx atxp search "latest AI news" # Search the web'); console.log(' npx atxp image "sunset over mountains" # Generate an image'); diff --git a/packages/atxp/src/index.ts b/packages/atxp/src/index.ts index e0cd0ac..90667b2 100644 --- a/packages/atxp/src/index.ts +++ b/packages/atxp/src/index.ts @@ -29,6 +29,7 @@ interface CreateOptions { interface LoginOptions { force: boolean; token?: string; + qr?: boolean; } // Parse command line arguments @@ -78,6 +79,7 @@ function parseArgs(): { const refresh = process.argv.includes('--refresh'); const force = process.argv.includes('--force'); const token = getArgValue('--token', '-t'); + const qr = process.argv.includes('--qr'); // Parse create options const framework = getArgValue('--framework', '-f') as Framework | undefined; @@ -112,7 +114,7 @@ function parseArgs(): { subCommand, demoOptions: { port, dir, verbose, refresh }, createOptions: { framework, appName, git }, - loginOptions: { force, token }, + loginOptions: { force, token, qr }, toolArgs, }; } diff --git a/packages/atxp/src/login.ts b/packages/atxp/src/login.ts index 84c785c..2c98a96 100644 --- a/packages/atxp/src/login.ts +++ b/packages/atxp/src/login.ts @@ -4,10 +4,12 @@ import path from 'path'; import os from 'os'; import chalk from 'chalk'; import open from 'open'; +import qrcode from 'qrcode-terminal'; interface LoginOptions { force?: boolean; token?: string; + qr?: boolean; } const CONFIG_DIR = path.join(os.homedir(), '.atxp'); @@ -33,9 +35,12 @@ export async function login(options: LoginOptions = {}): Promise { if (options.token) { connectionString = options.token; console.log('Using provided token for headless authentication...'); + } else if (options.qr) { + // QR code mode explicitly requested + connectionString = await loginWithQRCode(); } else { - // Otherwise, use browser-based login - connectionString = await loginWithBrowser(); + // Try browser first, fall back to QR code on failure + connectionString = await loginWithBrowserOrQR(); } saveConnectionString(connectionString); @@ -54,7 +59,31 @@ export async function login(options: LoginOptions = {}): Promise { } } -async function loginWithBrowser(): Promise { +/** + * Check if we're likely in a headless environment where browser won't work + */ +function isHeadlessEnvironment(): boolean { + // Common indicators of headless/non-GUI environments + const isSSH = !!process.env.SSH_TTY || !!process.env.SSH_CONNECTION; + const noDisplay = process.platform !== 'win32' && process.platform !== 'darwin' && !process.env.DISPLAY; + const isDocker = fs.existsSync('/.dockerenv'); + const isCI = !!process.env.CI || !!process.env.GITHUB_ACTIONS || !!process.env.GITLAB_CI; + + return isSSH || noDisplay || isDocker || isCI; +} + +/** + * Try browser login first, fall back to QR code if it fails + */ +async function loginWithBrowserOrQR(): Promise { + // If we detect a headless environment, go straight to QR + if (isHeadlessEnvironment()) { + console.log(chalk.yellow('Headless environment detected, using QR code login...')); + console.log(); + return loginWithQRCode(); + } + + // Try browser-based login return new Promise((resolve, reject) => { const server = http.createServer((req, res) => { const url = new URL(req.url!, `http://localhost`); @@ -62,40 +91,7 @@ async function loginWithBrowser(): Promise { if (connectionString) { res.writeHead(200, { 'Content-Type': 'text/html' }); - res.end(` - - - - ATXP Login - - - -
-

Login Successful

-

You can close this tab and return to your terminal.

-
- - - `); + res.end(getSuccessHTML()); server.close(); resolve(decodeURIComponent(connectionString)); } else { @@ -109,7 +105,7 @@ async function loginWithBrowser(): Promise { }); // Listen on random available port - server.listen(0, '127.0.0.1', () => { + server.listen(0, '127.0.0.1', async () => { const address = server.address(); const port = typeof address === 'object' ? address?.port : null; @@ -124,9 +120,42 @@ async function loginWithBrowser(): Promise { console.log('Opening browser to complete login...'); console.log(chalk.gray(`(${loginUrl})`)); console.log(); - console.log('Waiting for authentication...'); - open(loginUrl); + try { + // Try to open browser + const browserProcess = await open(loginUrl); + + // Check if the browser process exited with an error + browserProcess.on('error', async () => { + console.log(); + console.log(chalk.yellow('Browser failed to open. Switching to QR code...')); + console.log(); + server.close(); + try { + const result = await loginWithQRCode(); + resolve(result); + } catch (qrError) { + reject(qrError); + } + }); + + console.log('Waiting for authentication...'); + console.log(chalk.gray('(If browser did not open, press Ctrl+C and run: npx atxp login --qr)')); + + } catch (openError) { + // Browser open failed, fall back to QR + console.log(); + console.log(chalk.yellow('Could not open browser. Switching to QR code...')); + console.log(); + server.close(); + try { + const result = await loginWithQRCode(); + resolve(result); + } catch (qrError) { + reject(qrError); + } + return; + } // Timeout after 5 minutes const timeout = setTimeout(() => { @@ -141,6 +170,107 @@ async function loginWithBrowser(): Promise { }); } +/** + * QR code based login - shows QR in terminal for mobile scanning + */ +async function loginWithQRCode(): Promise { + return new Promise((resolve, reject) => { + const server = http.createServer((req, res) => { + const url = new URL(req.url!, `http://localhost`); + const connectionString = url.searchParams.get('connection_string'); + + if (connectionString) { + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(getSuccessHTML()); + server.close(); + resolve(decodeURIComponent(connectionString)); + } else { + res.writeHead(400, { 'Content-Type': 'text/plain' }); + res.end('Missing connection_string parameter'); + } + }); + + server.on('error', (err) => { + reject(new Error(`Failed to start local server: ${err.message}`)); + }); + + // Listen on random available port + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + const port = typeof address === 'object' ? address?.port : null; + + if (!port) { + reject(new Error('Failed to start local server')); + return; + } + + const redirectUri = `http://localhost:${port}/callback`; + const loginUrl = `https://accounts.atxp.ai?cli_redirect=${encodeURIComponent(redirectUri)}`; + + console.log(chalk.bold('Scan this QR code with your phone to login:')); + console.log(); + + // Generate and display QR code + qrcode.generate(loginUrl, { small: true }, (qr) => { + console.log(qr); + }); + + console.log(); + console.log(chalk.gray('Or open this URL manually:')); + console.log(chalk.cyan(loginUrl)); + console.log(); + console.log('Waiting for authentication...'); + + // Timeout after 5 minutes + const timeout = setTimeout(() => { + server.close(); + reject(new Error('Login timed out. Please try again.')); + }, 5 * 60 * 1000); + + server.on('close', () => { + clearTimeout(timeout); + }); + }); + }); +} + +function getSuccessHTML(): string { + return ` + + + + ATXP Login + + + +
+

Login Successful

+

You can close this tab and return to your terminal.

+
+ + + `; +} + function saveConnectionString(connectionString: string): void { if (!fs.existsSync(CONFIG_DIR)) { fs.mkdirSync(CONFIG_DIR, { recursive: true });