@agentled/cli
Version:
CLI for Agentled — manage workflows, apps, and knowledge from the command line. Zero context-window cost for AI agents.
176 lines • 7.75 kB
JavaScript
/**
* Browser-based authentication for `agentled auth login`.
*
* 1. Starts a temporary HTTP server on localhost
* 2. Opens the browser to /cli/authorize?port=...&state=...
* 3. Waits for the callback with a signed JWT code
* 4. Exchanges the code for the raw API key via /api/cli/exchange
*
* Uses only Node.js built-ins (http, crypto, child_process).
*/
/* eslint-disable no-console */
import * as http from 'node:http';
import * as crypto from 'node:crypto';
import { spawn } from 'node:child_process';
import * as net from 'node:net';
const TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
const PORT_RANGE_START = 9876;
const PORT_RANGE_END = 9999;
/**
* Find an available port in the range.
*/
function findFreePort(start, end) {
return new Promise((resolve, reject) => {
const tryPort = (port) => {
if (port > end) {
reject(new Error(`No free port found in range ${start}-${end}`));
return;
}
const server = net.createServer();
server.once('error', () => tryPort(port + 1));
server.once('listening', () => {
server.close(() => resolve(port));
});
server.listen(port, '127.0.0.1');
};
tryPort(start);
});
}
/**
* Open a URL in the user's default browser.
* Uses spawn with argument arrays to avoid shell injection.
*/
function openBrowser(targetUrl) {
const platform = process.platform;
let cmd;
let args;
if (platform === 'darwin') {
cmd = 'open';
args = [targetUrl];
}
else if (platform === 'win32') {
cmd = 'cmd';
args = ['/c', 'start', '', targetUrl];
}
else {
cmd = 'xdg-open';
args = [targetUrl];
}
const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
child.unref();
child.on('error', () => {
// Silently fail — the URL is printed to the terminal as fallback
});
}
export const SUCCESS_HTML = `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>Agentled CLI</title></head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; display: flex; justify-content: center; align-items: center; min-height: 100vh; margin: 0; background: #f9fafb;">
<div style="text-align: center; padding: 2rem;">
<div style="width: 48px; height: 48px; margin: 0 auto 1rem; background: #d1fae5; border-radius: 50%; display: flex; align-items: center; justify-content: center;">
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" stroke="#059669" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
</div>
<h1 style="font-size: 1.25rem; font-weight: 600; color: #111827; margin: 0 0 0.5rem;">Connected</h1>
<p style="color: #6b7280; font-size: 0.875rem; margin: 0 0 1rem;">You can close this tab and go back to your terminal — the CLI is connected and ready to use.</p>
<div style="background: #f3f4f6; border: 1px solid #e5e7eb; border-radius: 6px; padding: 0.75rem; text-align: left; max-width: 360px; margin: 0 auto; font-size: 0.8125rem; color: #374151;">
<strong style="color: #111827; display: block; margin-bottom: 0.25rem;">Next step</strong>
Return to your command line — your CLI session is now authenticated. Run <code style="background:#e5e7eb;padding:1px 4px;border-radius:3px;font-family:ui-monospace,Menlo,monospace;">agentled auth status</code> to confirm.<br><br><strong style="color:#111827;">Then restart your MCP client</strong> (Claude Code / Codex / Cursor) so it picks up this workspace — until you do, MCP tools stay on the previous workspace.
</div>
</div>
</body>
</html>`;
function escapeHtml(s) {
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
}
const ERROR_HTML = (msg) => `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>Agentled CLI</title></head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; display: flex; justify-content: center; align-items: center; min-height: 100vh; margin: 0; background: #f9fafb;">
<div style="text-align: center; padding: 2rem;">
<h1 style="font-size: 1.25rem; font-weight: 600; color: #991b1b;">Authentication Failed</h1>
<p style="color: #6b7280; font-size: 0.875rem;">${escapeHtml(msg)}</p>
</div>
</body>
</html>`;
/**
* Run the browser-based login flow.
*/
export async function browserLogin(baseUrl) {
const state = crypto.randomBytes(16).toString('hex');
const port = await findFreePort(PORT_RANGE_START, PORT_RANGE_END);
return new Promise((resolve, reject) => {
let settled = false;
const server = http.createServer(async (req, res) => {
if (settled)
return;
const parsed = new URL(req.url || '', `http://127.0.0.1:${port}`);
if (parsed.pathname !== '/callback') {
res.writeHead(404);
res.end('Not found');
return;
}
const callbackState = parsed.searchParams.get('state') || '';
const code = parsed.searchParams.get('code') || '';
// Verify state (CSRF protection)
if (callbackState !== state) {
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(ERROR_HTML('State mismatch. Possible CSRF attack.'));
return;
}
if (!code) {
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(ERROR_HTML('No authorization code received.'));
return;
}
// Serve success page immediately
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(SUCCESS_HTML);
// Exchange code for API key
try {
const exchangeRes = await fetch(`${baseUrl}/api/cli/exchange`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code }),
});
if (!exchangeRes.ok) {
const data = await exchangeRes.json().catch(() => ({}));
throw new Error(data.error || `HTTP ${exchangeRes.status}`);
}
const result = await exchangeRes.json();
settled = true;
cleanup();
resolve(result);
}
catch (err) {
settled = true;
cleanup();
reject(new Error(`Code exchange failed: ${err.message}`));
}
});
// Timeout handler
const timer = setTimeout(() => {
if (!settled) {
settled = true;
cleanup();
reject(new Error('Login timed out. No response from browser within 5 minutes.'));
}
}, TIMEOUT_MS);
function cleanup() {
clearTimeout(timer);
server.close();
}
server.listen(port, '127.0.0.1', () => {
const authorizeUrl = `${baseUrl}/en/cli/authorize?port=${port}&state=${encodeURIComponent(state)}`;
console.error(`Opening browser to authenticate...`);
console.error(`If the browser doesn't open, visit:\n ${authorizeUrl}\n`);
openBrowser(authorizeUrl);
});
server.on('error', (err) => {
if (!settled) {
settled = true;
reject(new Error(`Failed to start local server: ${err.message}`));
}
});
});
}
//# sourceMappingURL=browser-auth.js.map