UNPKG

miridev-cli

Version:

Official CLI tool for deploying static sites to miri.dev - Deploy your websites in seconds

469 lines (409 loc) 14 kB
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 };