Skip to content

Commit da3e4fa

Browse files
emilioaccclaude
andauthored
feat: add QR code fallback for login (#20)
- 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 <noreply@anthropic.com>
1 parent ba4eef2 commit da3e4fa

File tree

5 files changed

+196
-55
lines changed

5 files changed

+196
-55
lines changed

package-lock.json

Lines changed: 18 additions & 13 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/atxp/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,14 @@
3939
"fs-extra": "^11.2.0",
4040
"inquirer": "^9.2.12",
4141
"open": "^9.1.0",
42-
"ora": "^7.0.1"
42+
"ora": "^7.0.1",
43+
"qrcode-terminal": "^0.12.0"
4344
},
4445
"devDependencies": {
4546
"@types/fs-extra": "^11.0.4",
4647
"@types/inquirer": "^9.0.7",
4748
"@types/node": "^22.13.0",
49+
"@types/qrcode-terminal": "^0.12.2",
4850
"tsx": "^4.19.2",
4951
"typescript": "^5.7.3",
5052
"vitest": "^1.0.0"

packages/atxp/src/help.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export function showHelp(): void {
3434

3535
console.log(chalk.bold('Login Options:'));
3636
console.log(' ' + chalk.yellow('--token, -t') + ' ' + 'Provide token directly (headless mode)');
37+
console.log(' ' + chalk.yellow('--qr') + ' ' + 'Use QR code login (for terminals without browser)');
3738
console.log(' ' + chalk.yellow('--force') + ' ' + 'Update connection string even if already set');
3839
console.log();
3940

@@ -54,6 +55,7 @@ export function showHelp(): void {
5455

5556
console.log(chalk.bold('Examples:'));
5657
console.log(' npx atxp login # Log in to ATXP (browser)');
58+
console.log(' npx atxp login --qr # Log in with QR code');
5759
console.log(' npx atxp login --token $TOKEN # Log in with token (headless)');
5860
console.log(' npx atxp search "latest AI news" # Search the web');
5961
console.log(' npx atxp image "sunset over mountains" # Generate an image');

packages/atxp/src/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ interface CreateOptions {
2929
interface LoginOptions {
3030
force: boolean;
3131
token?: string;
32+
qr?: boolean;
3233
}
3334

3435
// Parse command line arguments
@@ -78,6 +79,7 @@ function parseArgs(): {
7879
const refresh = process.argv.includes('--refresh');
7980
const force = process.argv.includes('--force');
8081
const token = getArgValue('--token', '-t');
82+
const qr = process.argv.includes('--qr');
8183

8284
// Parse create options
8385
const framework = getArgValue('--framework', '-f') as Framework | undefined;
@@ -112,7 +114,7 @@ function parseArgs(): {
112114
subCommand,
113115
demoOptions: { port, dir, verbose, refresh },
114116
createOptions: { framework, appName, git },
115-
loginOptions: { force, token },
117+
loginOptions: { force, token, qr },
116118
toolArgs,
117119
};
118120
}

packages/atxp/src/login.ts

Lines changed: 170 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@ import path from 'path';
44
import os from 'os';
55
import chalk from 'chalk';
66
import open from 'open';
7+
import qrcode from 'qrcode-terminal';
78

89
interface LoginOptions {
910
force?: boolean;
1011
token?: string;
12+
qr?: boolean;
1113
}
1214

1315
const 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+
144274
function saveConnectionString(connectionString: string): void {
145275
if (!fs.existsSync(CONFIG_DIR)) {
146276
fs.mkdirSync(CONFIG_DIR, { recursive: true });

0 commit comments

Comments
 (0)