miridev-cli
Version:
Official CLI tool for deploying static sites to miri.dev - Deploy your websites in seconds
469 lines (409 loc) • 14 kB
JavaScript
const fs = require('fs-extra');
const path = require('path');
const os = require('os');
const crypto = require('crypto');
const { createClient } = require('@supabase/supabase-js');
const open = require('open');
const http = require('http');
const { v4: uuidv4 } = require('uuid');
const readline = require('readline');
/**
* Check if we're in an interactive environment
*/
function isInteractive() {
return process.stdin.isTTY && process.stdout.isTTY && !process.env.CI;
}
/**
* Get verification code safely
*/
function getVerificationCode() {
return new Promise((resolve, reject) => {
if (!isInteractive()) {
// Non-interactive environment, fail gracefully
reject(new Error('Interactive input not available. Please use browser authentication.'));
return;
}
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
const askForCode = () => {
rl.question('Enter your verification code: ', (answer) => {
const code = answer.trim();
if (!code) {
console.log('Please enter a verification code');
askForCode();
} else {
rl.close();
resolve(code);
}
});
};
askForCode();
});
}
// 환경변수 로드 - frontend 폴더에서 찾기
const projectRoot = path.join(__dirname, '../../..');
const envPath = path.join(projectRoot, 'frontend', '.env');
const envLocalPath = path.join(projectRoot, 'frontend', '.env.local');
// .env.local이 있으면 먼저 로드, 없으면 .env 로드
if (fs.existsSync(envLocalPath)) {
require('dotenv').config({ path: envLocalPath });
} else if (fs.existsSync(envPath)) {
require('dotenv').config({ path: envPath });
} else {
// 환경변수 파일이 없어도 기본값으로 작동하므로 개발 모드에서만 경고
if (process.env.NODE_ENV === 'development') {
console.warn('⚠️ 환경변수 파일(.env 또는 .env.local)을 찾을 수 없습니다.');
}
}
// 설정 파일 경로
const CONFIG_DIR = path.join(os.homedir(), '.miridev');
const AUTH_FILE = path.join(CONFIG_DIR, 'auth.json');
// Supabase 클라이언트 초기화
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || process.env.MIRIDEV_SUPABASE_URL || 'https://jvxakkjbajunbyiogiur.supabase.co';
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || process.env.MIRIDEV_SUPABASE_ANON_KEY || 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imp2eGFra2piYWp1bmJ5aW9naXVyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDgwNjM0NzMsImV4cCI6MjA2MzYzOTQ3M30.e3ZRPklBdSpwmMXoe3I-q7w6iqzx2jvk2GFAO-rBH-I';
let supabase;
try {
if (!supabaseKey) {
throw new Error('Supabase anonymous key is required');
}
supabase = createClient(supabaseUrl, supabaseKey);
// 개발 모드에서만 성공 메시지 표시
if (process.env.NODE_ENV === 'development') {
console.log('Supabase client initialized successfully.');
}
} catch (error) {
console.warn('⚠️ Supabase 클라이언트 초기화 실패. 환경변수를 확인해주세요.');
if (process.env.NODE_ENV === 'development') {
console.error('Supabase initialization error:', error);
}
}
/**
* Generate session data for CLI authentication
*/
function generateSessionData() {
const hostname = os.hostname();
const username = os.userInfo().username;
const timestamp = Date.now();
const randomSuffix = Math.random().toString(36).substring(2, 8);
return {
sessionId: uuidv4(),
tokenName: `cli_${username}@${hostname}_${timestamp}_${randomSuffix}`,
publicKey: crypto.randomBytes(32).toString('hex'),
timestamp
};
}
/**
* Construct login URL with session parameters
*/
function constructLoginUrl(sessionData) {
const baseUrl = process.env.MIRIDEV_BASE_URL || 'https://miri.dev';
const params = new URLSearchParams({
session_id: sessionData.sessionId,
token_name: sessionData.tokenName,
public_key: sessionData.publicKey
});
return `${baseUrl}/dashboard/cli/login?${params.toString()}`;
}
/**
* Interactive login flow following Supabase CLI model
*/
async function loginWithInteractiveFlow() {
// Generate session data
const sessionData = generateSessionData();
// Construct login URL
const loginUrl = constructLoginUrl(sessionData);
// Try to open browser
try {
await open(loginUrl);
} catch (error) {
console.log('Could not open browser automatically.');
}
// Always show the URL as fallback
console.log(`\nHere is your login link in case browser did not open:\n${loginUrl}\n`);
// Prompt for verification code
const verificationCode = await getVerificationCode();
// Verify the code and get token
const user = await verifyCodeAndCreateToken(sessionData, verificationCode);
return {
user,
tokenName: sessionData.tokenName
};
}
/**
* Verify verification code and create authentication token
*/
async function verifyCodeAndCreateToken(sessionData, verificationCode) {
try {
// In a real implementation, this would call your backend API
// For now, we'll simulate the verification process
// Mock API call to verify code
const response = await mockVerifyCode(sessionData, verificationCode);
if (!response.success) {
throw new Error(response.error || 'Invalid verification code');
}
// Create user object with token
const user = {
email: response.user.email,
id: response.user.id,
plan: response.user.plan || 'basic',
name: response.user.name || response.user.email,
loginAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(), // 1 year
token: response.token,
refresh_token: response.refresh_token || ''
};
// Save authentication data
await saveAuth(user);
return user;
} catch (error) {
throw new Error(`Verification failed: ${error.message}`);
}
}
/**
* Mock verification function - replace with actual API call
*/
async function mockVerifyCode(sessionData, verificationCode) {
// Simulate API delay
await new Promise(resolve => setTimeout(resolve, 1000));
// Mock verification logic - in real implementation, this would be an API call
if (verificationCode.length >= 6) {
return {
success: true,
token: crypto.randomBytes(32).toString('hex'),
user: {
email: 'user@example.com',
id: uuidv4(),
name: 'CLI User',
plan: 'basic'
}
};
} else {
return {
success: false,
error: 'Invalid verification code format'
};
}
}
/**
* 로그인 함수 (이메일/비밀번호) - 기존 함수 유지
*/
async function login(email, password) {
try {
if (supabase) {
const { data, error } = await supabase.auth.signInWithPassword({
email,
password
});
if (error) {
throw new Error(error.message);
}
if (!data.user || !data.session) {
throw new Error('로그인에 실패했습니다.');
}
const user = {
email: data.user.email,
id: data.user.id,
plan: 'basic', // 기본 플랜 설정
name: data.user.user_metadata?.full_name || data.user.email,
loginAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), // 30일
token: data.session.access_token,
refresh_token: data.session.refresh_token
};
await saveAuth(user);
return user;
}
throw new Error('Authentication service is currently unavailable. Please try again later or contact support.');
} catch (error) {
throw new Error(error.message || 'Login failed');
}
}
/**
* 브라우저를 통한 로그인 함수 - 기존 함수 유지
*/
async function loginWithBrowser() {
if (!supabase) {
throw new Error('Supabase client is not initialized. Cannot perform browser login.');
}
return new Promise((resolve, reject) => {
const cliSessionId = crypto.randomBytes(16).toString('hex'); // Generate unique ID
const server = http.createServer(async (req, res) => {
const requestUrl = new URL(req.url, 'http://localhost:3000');
// This path will now be the final redirect from miri.dev, not directly from Supabase
if (requestUrl.pathname === '/cli-auth-complete') { // New callback path for CLI
const cliApiKey = requestUrl.searchParams.get('cli_api_key');
const cliSessionIdReturned = requestUrl.searchParams.get('cli_session_id_returned');
const userEmail = requestUrl.searchParams.get('user_email');
const userId = requestUrl.searchParams.get('user_id');
if (cliApiKey && cliSessionIdReturned === cliSessionId) {
try {
// Save the CLI-specific API key
const user = {
email: userEmail || 'unknown', // Add email from frontend callback
id: userId || 'unknown', // Add user ID from frontend callback
plan: 'basic', // Default or get from frontend if available
name: userEmail || 'CLI User', // Default or get from frontend if available
loginAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(), // CLI key expiration (e.g., 1 year)
token: cliApiKey, // This is the key to save
refresh_token: '' // CLI key doesn't have refresh token in this flow
};
await saveAuth(user); // Save the CLI API key
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end('<h1>CLI Login Successful!</h1><p>You can now close this window and return to the CLI.</p>');
server.close(() => resolve(user));
} catch (e) {
res.writeHead(500, { 'Content-Type': 'text/html' });
res.end(`<h1>CLI Login Failed</h1><p>${e.message}</p>`);
server.close(() => reject(new Error(`CLI authentication failed: ${e.message}`)));
}
} else {
res.writeHead(400, { 'Content-Type': 'text/html' });
res.end('<h1>CLI Login Failed</h1><p>Invalid or missing CLI API key.</p>');
server.close(() => reject(new Error('CLI authentication failed: Invalid key')));
}
} else {
res.writeHead(404, { 'Content-Type': 'text/html' });
res.end('<h1>Not Found</h1>');
}
});
server.listen(0, async () => {
const PORT = server.address().port;
// Supabase will redirect to miri.dev's API endpoint, which then redirects back to CLI
const SUPABASE_REDIRECT_TO_FRONTEND = `http://localhost:3000/api/cli-auth-callback?cli_session_id=${cliSessionId}&cli_redirect_to=http://localhost:${PORT}/cli-auth-complete`;
console.log(`Local authentication server listening on port ${PORT}`);
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: SUPABASE_REDIRECT_TO_FRONTEND, // Supabase redirects here
queryParams: {
access_type: 'offline',
prompt: 'consent'
}
}
});
if (error) {
server.close(() => reject(new Error(`Failed to initiate OAuth: ${error.message}`)));
return;
}
if (data.url) {
console.log(`\n👉 Please open this URL in your browser to complete login:\n${data.url}\n`);
console.log('Waiting for authentication to complete in your browser...');
} else {
server.close(() => reject(new Error('No OAuth URL returned from Supabase.')));
}
});
server.on('error', (e) => {
if (e.code === 'EADDRINUSE') {
reject(new Error(`Port ${server.address().port} is already in use. Please close other applications using this port or try again later.`));
} else {
reject(new Error(`Local server error: ${e.message}`));
}
});
});
}
/**
* 토큰 생성
*/
function generateToken() {
return crypto.randomBytes(32).toString('hex');
}
/**
* 인증 정보 저장
*/
async function saveAuth(authData) {
await fs.ensureDir(CONFIG_DIR);
await fs.writeJson(AUTH_FILE, authData, { spaces: 2 });
}
/**
* 사용자 인증 정보 읽기
*/
async function loadAuth() {
try {
if (!(await fs.pathExists(AUTH_FILE))) {
return null;
}
const content = await fs.readFile(AUTH_FILE, 'utf8');
return JSON.parse(content);
} catch (error) {
return null;
}
}
/**
* 현재 사용자 정보 가져오기
*/
function getCurrentUser() {
try {
if (!fs.existsSync(AUTH_FILE)) {
return null;
}
const authData = fs.readJsonSync(AUTH_FILE);
// 토큰 만료 확인
if (new Date() > new Date(authData.expiresAt)) {
return null;
}
return authData;
} catch (error) {
return null;
}
}
/**
* 로그아웃
*/
async function logout() {
try {
if (fs.existsSync(AUTH_FILE)) {
await fs.remove(AUTH_FILE);
}
// Supabase 세션도 로그아웃
if (supabase) {
await supabase.auth.signOut();
}
return true;
} catch (error) {
return false;
}
}
/**
* 로그인 상태 확인
*/
function isLoggedIn() {
const user = getCurrentUser();
return user !== null;
}
/**
* 사용자 플랜 정보 가져오기
*/
function getUserPlan() {
const user = getCurrentUser();
return user?.plan || 'guest';
}
/**
* 로그인 필요 시 안내
*/
function requireAuth() {
if (!isLoggedIn()) {
console.log('⚠️ You need to login first');
console.log('Run: miridev login');
process.exit(1);
}
}
module.exports = {
login,
loginWithBrowser, // Keep existing function
loginWithInteractiveFlow, // New interactive flow
generateSessionData,
constructLoginUrl,
verifyCodeAndCreateToken,
saveAuth,
loadAuth,
getCurrentUser,
logout,
isLoggedIn,
requireAuth,
getUserPlan,
generateToken,
supabase // Export supabase client for testing/debugging if needed
};