miridev-cli
Version:
Official CLI tool for deploying static sites to miri.dev - Deploy your websites in seconds
458 lines (379 loc) • 12 kB
JavaScript
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
};