@autifyhq/muon
Version:
Muon - AI-Powered Playwright Test Coding Agent with Advanced Test Fixing Capabilities
226 lines (225 loc) âĸ 8.51 kB
JavaScript
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import open from 'open';
const CONFIG_DIR = path.join(os.homedir(), '.muon');
const TOKEN_FILE = path.join(CONFIG_DIR, 'auth.json');
export class MuonAuth {
constructor(serverUrl) {
this.serverUrl = serverUrl;
}
ensureConfigDir() {
if (!fs.existsSync(CONFIG_DIR)) {
fs.mkdirSync(CONFIG_DIR, { recursive: true });
}
}
async login() {
try {
console.log('đ Starting Muon CLI authentication...\n');
const deviceAuth = await this.initializeDeviceAuth();
console.log('đą Please complete authentication in your browser:');
console.log(` Visit: ${deviceAuth.verificationUri}`);
console.log(` Enter code: ${deviceAuth.userCode}\n`);
console.log(` Or visit: ${deviceAuth.verificationUriComplete}\n`);
console.log('đ Opening browser...');
await open(deviceAuth.verificationUriComplete);
console.log('âŗ Waiting for authentication...');
const tokens = await this.pollForTokens(deviceAuth.deviceCode, deviceAuth.interval);
this.saveTokens(tokens);
console.log('â
Authentication successful!');
console.log(` Logged in as: ${tokens.user.email}`);
console.log(` Organization: ${tokens.user.organizationId}`);
}
catch (error) {
console.error('â Authentication failed:', error.message);
process.exit(1);
}
}
async logout() {
try {
if (fs.existsSync(TOKEN_FILE)) {
fs.unlinkSync(TOKEN_FILE);
console.log('â
Logged out successfully');
}
else {
console.log('âšī¸ You are not currently logged in');
}
}
catch (error) {
console.error('â Logout failed:', error.message);
process.exit(1);
}
}
async status() {
try {
const tokens = this.loadTokens();
if (tokens) {
console.log('â
You are logged in');
console.log(` Email: ${tokens.user.email}`);
console.log(` Name: ${tokens.user.name}`);
console.log(` Organization: ${tokens.user.organizationId}`);
console.log(` Role: ${tokens.user.role}`);
}
else {
console.log('â You are not logged in');
console.log(' Run "muon login" to authenticate');
}
}
catch (error) {
console.error('â Failed to check status:', error.message);
process.exit(1);
}
}
getTokens() {
return this.loadTokens();
}
// Check if access token is expired or near expiration (within 2 minutes)
isTokenExpiredOrNearExpiration(token) {
try {
// Decode JWT payload (without verification, just to check expiration)
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString());
const exp = payload.exp * 1000; // Convert to milliseconds
const now = Date.now();
const twoMinutes = 2 * 60 * 1000;
// Return true if token is already expired OR will expire within 2 minutes
return exp - now < twoMinutes;
}
catch {
// If we can't decode, assume it's expired
return true;
}
}
async getValidTokens() {
const tokens = this.loadTokens();
if (!tokens) {
return null;
}
if (tokens.accessToken && this.isTokenExpiredOrNearExpiration(tokens.accessToken)) {
const refreshedTokens = await this.refreshTokens();
if (!refreshedTokens) {
return null;
}
return refreshedTokens;
}
return tokens;
}
async refreshTokens() {
try {
const tokens = this.loadTokens();
if (!tokens || !tokens.refreshToken) {
console.log('â No refresh token available');
return null;
}
console.log('đ Attempting to refresh tokens...');
const response = await fetch(`${this.serverUrl}/api/auth/refresh-token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
refreshToken: tokens.refreshToken,
}),
});
if (!response.ok) {
const errorData = await response.json();
if (response.status === 401 && errorData.error === 'invalid_refresh_token') {
this.clearTokens();
return null;
}
throw new Error(`Failed to refresh token: ${errorData.error}`);
}
const newTokens = await response.json();
this.saveTokens(newTokens);
return newTokens;
}
catch (error) {
if (error instanceof Error && error.message.includes('invalid_refresh_token')) {
console.log('đī¸ Clearing invalid tokens');
this.clearTokens();
}
return null;
}
}
clearTokens() {
if (fs.existsSync(TOKEN_FILE)) {
fs.unlinkSync(TOKEN_FILE);
}
}
async initializeDeviceAuth() {
const response = await fetch(`${this.serverUrl}/api/auth/device-authorization`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
clientName: 'Muon CLI',
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Failed to initialize device auth: ${error}`);
}
return response.json();
}
async pollForTokens(deviceCode, interval) {
const maxAttempts = 60; // 5 minutes max
let attempts = 0;
while (attempts < maxAttempts) {
try {
const response = await fetch(`${this.serverUrl}/api/auth/device-token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
deviceCode,
}),
});
if (response.ok) {
return response.json();
}
const errorData = await response.json();
if (response.status === 428 && errorData.error === 'authorization_pending') {
await new Promise((resolve) => setTimeout(resolve, interval * 1000));
attempts++;
continue;
}
if (response.status === 429 && errorData.error === 'slow_down') {
await new Promise((resolve) => setTimeout(resolve, (interval + 5) * 1000));
attempts++;
continue;
}
if (response.status === 410 && errorData.error === 'expired_token') {
throw new Error('Authentication session expired. Please try again.');
}
if (response.status === 403 && errorData.error === 'access_denied') {
throw new Error('Authentication was denied. Please try again.');
}
throw new Error(`Authentication failed: ${errorData.error}`);
}
catch (error) {
if (error instanceof Error) {
throw error;
}
throw new Error('Network error during authentication');
}
}
throw new Error('Authentication timeout. Please try again.');
}
saveTokens(tokens) {
this.ensureConfigDir();
fs.writeFileSync(TOKEN_FILE, JSON.stringify(tokens, null, 2));
}
loadTokens() {
try {
if (!fs.existsSync(TOKEN_FILE)) {
return null;
}
const data = fs.readFileSync(TOKEN_FILE, 'utf8');
return JSON.parse(data);
}
catch (_error) {
return null;
}
}
}