Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 18 additions & 13 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion packages/atxp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions packages/atxp/src/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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');
Expand Down
4 changes: 3 additions & 1 deletion packages/atxp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ interface CreateOptions {
interface LoginOptions {
force: boolean;
token?: string;
qr?: boolean;
}

// Parse command line arguments
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -112,7 +114,7 @@ function parseArgs(): {
subCommand,
demoOptions: { port, dir, verbose, refresh },
createOptions: { framework, appName, git },
loginOptions: { force, token },
loginOptions: { force, token, qr },
toolArgs,
};
}
Expand Down
210 changes: 170 additions & 40 deletions packages/atxp/src/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@
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');
Expand All @@ -33,9 +35,12 @@
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);
Expand All @@ -54,48 +59,39 @@
}
}

async function loginWithBrowser(): Promise<string> {
/**
* 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<string> {
// 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`);
const connectionString = url.searchParams.get('connection_string');

if (connectionString) {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(`
<!DOCTYPE html>
<html>
<head>
<title>ATXP Login</title>
<style>
body {
font-family: system-ui, -apple-system, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
background: #f5f5f5;
}
.container {
text-align: center;
padding: 40px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
h1 { color: #10b981; margin-bottom: 16px; }
p { color: #666; }
</style>
</head>
<body>
<div class="container">
<h1>Login Successful</h1>
<p>You can close this tab and return to your terminal.</p>
</div>
</body>
</html>
`);
res.end(getSuccessHTML());
server.close();
resolve(decodeURIComponent(connectionString));
} else {
Expand All @@ -109,7 +105,7 @@
});

// 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;

Expand All @@ -124,9 +120,42 @@
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) {

Check warning on line 145 in packages/atxp/src/login.ts

View workflow job for this annotation

GitHub Actions / test

'openError' is defined but never used
// 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(() => {
Expand All @@ -141,6 +170,107 @@
});
}

/**
* QR code based login - shows QR in terminal for mobile scanning
*/
async function loginWithQRCode(): Promise<string> {
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 `
<!DOCTYPE html>
<html>
<head>
<title>ATXP Login</title>
<style>
body {
font-family: system-ui, -apple-system, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
background: #f5f5f5;
}
.container {
text-align: center;
padding: 40px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
h1 { color: #10b981; margin-bottom: 16px; }
p { color: #666; }
</style>
</head>
<body>
<div class="container">
<h1>Login Successful</h1>
<p>You can close this tab and return to your terminal.</p>
</div>
</body>
</html>
`;
}

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