UNPKG

miridev-cli

Version:

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

458 lines (379 loc) 12 kB
const fs = require('fs-extra'); const path = require('path'); const glob = require('glob'); const mime = require('mime-types'); const chalk = require('chalk'); // const { promisify } = require('util'); // Not used currently const inquirer = require('inquirer'); /** * 기본 무시할 파일/폴더 패턴 */ const DEFAULT_IGNORE_PATTERNS = [ 'node_modules/**', '.git/**', '.github/**', '.gitignore', '.DS_Store', 'Thumbs.db', '*.log', 'npm-debug.log*', 'yarn-debug.log*', 'yarn-error.log*', '.env', '.env.local', '.env.development.local', '.env.test.local', '.env.production.local', 'dist/**', 'build/**', '.next/**', '.nuxt/**', '.vuepress/dist/**', 'coverage/**', '.nyc_output/**', '*.tgz', '*.tar.gz', 'package-lock.json', 'yarn.lock' ]; /** * 파일명이 Storage에 안전한지 확인 */ function isFileNameSafe(fileName) { // 한글, 공백, 특수문자가 있으면 안전하지 않음 const hasKorean = /[가-힣]/.test(fileName); const hasSpace = /\s/.test(fileName); const hasSpecialChars = /[^a-zA-Z0-9\-_./]/.test(fileName); return !hasKorean && !hasSpace && !hasSpecialChars; } /** * 파일명을 스토리지에 안전한 형태로 변환 (폴더 구조 유지) * 한글, 공백, 특수문자를 영어/숫자/하이픈/언더스코어로 변환 */ function sanitizeFileName(fileName) { // 경로를 분리해서 각 세그먼트별로 처리 const pathSegments = fileName.split('/'); const sanitizedSegments = pathSegments.map(segment => { if (!segment) { return segment; } // 빈 세그먼트는 그대로 // 파일명과 확장자 분리 const ext = path.extname(segment); const name = path.basename(segment, ext); let sanitized = name // 한글을 언더스코어로 변환 .replace(/[가-힣]/g, '_') // 공백을 언더스코어로 변환 .replace(/\s+/g, '_') // 특수문자를 언더스코어로 변환 (영어, 숫자, 하이픈, 언더스코어만 허용) .replace(/[^a-zA-Z0-9\-_]/g, '_') // 연속된 언더스코어를 하나로 합치기 .replace(/_+/g, '_') // 앞뒤 언더스코어 제거 .replace(/^_+|_+$/g, ''); // 빈 문자열이 되면 타임스탬프 사용 if (!sanitized) { sanitized = Date.now().toString(); } // 너무 긴 경우 자르기 if (sanitized.length > 95 - ext.length) { sanitized = sanitized.substring(0, 95 - ext.length); } return sanitized + ext.toLowerCase(); }); return sanitizedSegments.join('/'); } // Note: sanitizeFileNameOnly function removed as it was unused /** * 문제가 있는 파일명들을 사용자에게 보여주고 변경할지 확인 */ async function promptFileNameChanges(unsafeFiles) { console.log(chalk.yellow('\n⚠️ 다음 파일들은 한글, 공백, 특수문자가 포함되어 배포 시 문제가 될 수 있습니다:\n')); const changes = []; for (const file of unsafeFiles) { const safeName = sanitizeFileName(file.relativePath); changes.push({ original: file.relativePath, safe: safeName, file }); console.log(` ${chalk.red('원본:')} ${file.relativePath}`); console.log(` ${chalk.green('변경:')} ${safeName}`); console.log(''); } const answer = await inquirer.prompt([ { type: 'confirm', name: 'proceed', message: `${unsafeFiles.length}개 파일의 이름을 위와 같이 변경하고 배포하시겠습니까?`, default: true } ]); if (!answer.proceed) { throw new Error('사용자가 파일명 변경을 거부했습니다. 배포를 중단합니다.'); } return changes; } /** * 실제로 파일명을 변경 */ async function renameFiles(changes, directory) { console.log(chalk.blue('\n📝 파일명 변경 중...\n')); for (const change of changes) { const oldPath = path.join(directory, change.original); const newPath = path.join(directory, change.safe); try { // 새 파일명으로 이미 파일이 존재하는지 확인 if (await fs.pathExists(newPath)) { console.log(chalk.yellow(`⚠️ 건너뜀: ${change.safe} (이미 존재함)`)); continue; } await fs.move(oldPath, newPath); console.log(chalk.green(`✓ ${change.original}${change.safe}`)); // 파일 객체 업데이트 change.file.path = newPath; change.file.relativePath = change.safe; } catch (error) { console.log(chalk.red(`✗ 실패: ${change.original}${error.message}`)); throw new Error(`파일명 변경 실패: ${change.original}`); } } console.log(chalk.green('\n✅ 모든 파일명 변경 완료!\n')); } /** * 프로젝트에서 배포할 파일들을 스캔 */ async function scanFiles(directory, options = {}) { const { ignorePatterns = [], includeHidden = false, maxFileSize = 25 * 1024 * 1024 // 25MB } = options; console.log(chalk.gray(`📁 Scanning files in ${directory}...`)); // 절대 경로로 변환 const absoluteDir = path.resolve(directory); if (!(await fs.pathExists(absoluteDir))) { throw new Error(`Directory does not exist: ${directory}`); } // .gitignore 패턴 로드 const gitignorePatterns = await loadGitignoreFile(absoluteDir); // .miriignore 패턴 로드 const miriignorePatterns = await loadIgnoreFile(absoluteDir); // 모든 무시 패턴 합치기 const allIgnorePatterns = [ ...DEFAULT_IGNORE_PATTERNS, ...gitignorePatterns, ...miriignorePatterns, ...ignorePatterns ]; if (gitignorePatterns.length > 0) { console.log(chalk.gray(`📋 Loaded ${gitignorePatterns.length} patterns from .gitignore`)); } if (miriignorePatterns.length > 0) { console.log(chalk.gray(`📋 Loaded ${miriignorePatterns.length} patterns from .miriignore`)); } // glob 패턴 생성 const globPattern = includeHidden ? '**/*' : '**/!(.)*'; const globOptions = { cwd: absoluteDir, ignore: allIgnorePatterns, nodir: true, // 디렉토리는 제외 dot: includeHidden }; const files = glob.sync(globPattern, globOptions); const fileInfo = []; let totalSize = 0; const unsafeFiles = []; for (const file of files) { const absolutePath = path.join(absoluteDir, file); const stats = await fs.stat(absolutePath); // 파일 크기 체크 if (stats.size > maxFileSize) { console.log(chalk.yellow(`⚠️ Skipping large file: ${file} (${formatFileSize(stats.size)})`)); continue; } // 파일 정보 수집 const contentType = mime.lookup(file) || 'application/octet-stream'; // 원본 경로와 안전한 경로 모두 저장 const safePath = sanitizeFileName(file); const fileObj = { path: absolutePath, relativePath: file.replace(/\\/g, '/'), // Windows 경로 정규화 safePath, // 스토리지에서 사용할 안전한 경로 size: stats.size, contentType, mtime: stats.mtime }; fileInfo.push(fileObj); // 안전하지 않은 파일명 체크 if (!isFileNameSafe(file)) { unsafeFiles.push(fileObj); } totalSize += stats.size; } // index.html 존재 확인 const hasIndexFile = fileInfo.some(file => { const baseName = path.basename(file.relativePath).toLowerCase(); return baseName === 'index.html' || baseName === 'index.htm'; }); if (!hasIndexFile) { throw new Error('index.html file is required for deployment'); } console.log(chalk.green(`✓ Found ${fileInfo.length} files (${formatFileSize(totalSize)})`)); // 파일 크기별로 정렬 (작은 파일부터) fileInfo.sort((a, b) => a.size - b.size); // 안전하지 않은 파일명이 있으면 사용자에게 확인 if (unsafeFiles.length > 0) { const changes = await promptFileNameChanges(unsafeFiles); await renameFiles(changes, directory); } return { files: fileInfo, totalSize, totalFiles: fileInfo.length }; } /** * 설정 파일 읽기 */ async function loadConfig(configPath) { const absolutePath = path.resolve(configPath); if (!(await fs.pathExists(absolutePath))) { return {}; } try { // JavaScript 설정 파일 if (path.extname(absolutePath) === '.js') { delete require.cache[absolutePath]; return require(absolutePath); } // JSON 설정 파일 if (path.extname(absolutePath) === '.json') { const content = await fs.readFile(absolutePath, 'utf8'); return JSON.parse(content); } throw new Error(`Unsupported config file format: ${configPath}`); } catch (error) { throw new Error(`Failed to load config file: ${error.message}`); } } /** * 설정 파일 생성 */ async function createConfig(configPath, config = {}) { const defaultConfig = { // 배포 설정 deploy: { ignore: [], includeHidden: false, maxFileSize: '25MB' }, // 빌드 설정 build: { command: null, // 'npm run build' directory: '.' // 빌드 결과 디렉토리 }, // 사이트 설정 site: { name: null, customDomain: null } }; const finalConfig = { ...defaultConfig, ...config }; const configContent = `module.exports = ${JSON.stringify(finalConfig, null, 2)};`; await fs.writeFile(configPath, configContent, 'utf8'); console.log(chalk.green(`✓ Created config file: ${configPath}`)); } /** * .gitignore 파일 읽기 */ async function loadGitignoreFile(directory) { const gitignorePath = path.join(directory, '.gitignore'); if (!(await fs.pathExists(gitignorePath))) { return []; } try { const content = await fs.readFile(gitignorePath, 'utf8'); return content .split('\n') .map(line => line.trim()) .filter(line => line && !line.startsWith('#')) // 빈 줄과 주석 제외 .map(pattern => { // 디렉토리 패턴 정규화 if (pattern.endsWith('/')) { return pattern + '**'; } return pattern; }); } catch (error) { console.log(chalk.yellow(`⚠️ Failed to read .gitignore: ${error.message}`)); return []; } } /** * .miriignore 파일 읽기 */ async function loadIgnoreFile(directory) { const ignorePath = path.join(directory, '.miriignore'); if (!(await fs.pathExists(ignorePath))) { return []; } try { const content = await fs.readFile(ignorePath, 'utf8'); return content .split('\n') .map(line => line.trim()) .filter(line => line && !line.startsWith('#')); } catch (error) { console.log(chalk.yellow(`⚠️ Failed to read .miriignore: ${error.message}`)); return []; } } /** * 파일 크기를 읽기 쉬운 형태로 변환 */ function formatFileSize(bytes) { if (bytes === 0) { return '0 B'; } const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; } /** * 파일 청크로 나누기 (대용량 파일용) */ async function splitFileIntoChunks(filePath, chunkSize = 3 * 1024 * 1024) { const stats = await fs.stat(filePath); const fileSize = stats.size; if (fileSize <= chunkSize) { return [{ data: await fs.readFile(filePath), index: 0, total: 1 }]; } const chunks = []; const totalChunks = Math.ceil(fileSize / chunkSize); for (let i = 0; i < totalChunks; i++) { const start = i * chunkSize; const end = Math.min(start + chunkSize, fileSize); const chunk = Buffer.alloc(end - start); const fd = await fs.open(filePath, 'r'); await fs.read(fd, chunk, 0, chunk.length, start); await fs.close(fd); chunks.push({ data: chunk, index: i, total: totalChunks }); } return chunks; } module.exports = { scanFiles, loadConfig, createConfig, loadIgnoreFile, loadGitignoreFile, formatFileSize, splitFileIntoChunks, DEFAULT_IGNORE_PATTERNS };