aiwf
Version:
AI Workflow Framework for Claude Code with multi-language support (Korean/English)
559 lines (486 loc) • 18 kB
JavaScript
import fs from 'fs/promises';
import path from 'path';
import { createHash } from 'crypto';
import { createReadStream, createWriteStream } from 'fs';
import { pipeline } from 'stream/promises';
import { createGzip, createGunzip } from 'zlib';
import { promisify } from 'util';
/**
* 오프라인 템플릿 캐시 시스템
* - 모든 AI 도구 템플릿과 프로젝트 템플릿 캐시 관리
* - 압축 저장으로 디스크 사용량 최적화
* - 버전 관리 및 자동 업데이트
* - 네트워크 상태 감지 및 오프라인 폴백
*/
class TemplateCache {
constructor(options = {}) {
this.cacheDir = options.cacheDir || path.join(process.env.HOME || process.env.USERPROFILE, '.aiwf', 'cache');
this.templatesDir = path.join(this.cacheDir, 'templates');
this.aiToolsDir = path.join(this.templatesDir, 'ai-tools');
this.projectsDir = path.join(this.templatesDir, 'projects');
this.manifestPath = path.join(this.cacheDir, 'manifest.json');
this.lockPath = path.join(this.cacheDir, 'lock.json');
// 기본 설정
this.maxCacheSize = options.maxCacheSize || 100 * 1024 * 1024; // 100MB
this.compressionLevel = options.compressionLevel || 6;
this.updateCheckInterval = options.updateCheckInterval || 24 * 60 * 60 * 1000; // 24시간
// 내부 상태
this.manifest = null;
this.lock = null;
this.stats = {
totalSize: 0,
templatesCount: 0,
lastUpdate: null,
hitCount: 0,
missCount: 0
};
}
/**
* 캐시 시스템 초기화
*/
async init() {
try {
// 캐시 디렉토리 생성
await fs.mkdir(this.cacheDir, { recursive: true });
await fs.mkdir(this.templatesDir, { recursive: true });
await fs.mkdir(this.aiToolsDir, { recursive: true });
await fs.mkdir(this.projectsDir, { recursive: true });
// 매니페스트 파일 로드 또는 생성
await this.loadManifest();
await this.loadLock();
// 캐시 상태 계산
await this.calculateStats();
console.log('Template cache system initialized');
} catch (error) {
console.error('Failed to initialize template cache:', error.message);
throw error;
}
}
/**
* 매니페스트 파일 로드
*/
async loadManifest() {
try {
const manifestData = await fs.readFile(this.manifestPath, 'utf8');
this.manifest = JSON.parse(manifestData);
} catch (error) {
// 매니페스트 파일이 없으면 새로 생성
this.manifest = {
version: '1.0.0',
templates: {
'ai-tools': {},
'projects': {}
},
last_update_check: null,
created_at: new Date().toISOString()
};
await this.saveManifest();
}
}
/**
* 락 파일 로드
*/
async loadLock() {
try {
const lockData = await fs.readFile(this.lockPath, 'utf8');
this.lock = JSON.parse(lockData);
} catch (error) {
this.lock = {
locked: false,
process_id: null,
operation: null,
timestamp: null
};
await this.saveLock();
}
}
/**
* 매니페스트 저장
*/
async saveManifest() {
await fs.writeFile(this.manifestPath, JSON.stringify(this.manifest, null, 2));
}
/**
* 락 파일 저장
*/
async saveLock() {
await fs.writeFile(this.lockPath, JSON.stringify(this.lock, null, 2));
}
/**
* 캐시 통계 계산
*/
async calculateStats() {
let totalSize = 0;
let templatesCount = 0;
const calculateDirectorySize = async (dir) => {
try {
const files = await fs.readdir(dir);
let dirSize = 0;
for (const file of files) {
const filePath = path.join(dir, file);
const stat = await fs.stat(filePath);
if (stat.isDirectory()) {
dirSize += await calculateDirectorySize(filePath);
} else {
dirSize += stat.size;
if (file.endsWith('.tar.gz')) {
templatesCount++;
}
}
}
return dirSize;
} catch (error) {
return 0;
}
};
totalSize = await calculateDirectorySize(this.templatesDir);
this.stats = {
...this.stats,
totalSize,
templatesCount,
lastUpdate: this.manifest.last_update_check
};
}
/**
* 템플릿 캐시 키 생성
*/
generateCacheKey(type, name, version = '1.0.0') {
return `${type}/${name}@${version}`;
}
/**
* 템플릿 압축 및 캐시 저장
*/
async cacheTemplate(type, name, sourcePath, version = '1.0.0') {
const cacheKey = this.generateCacheKey(type, name, version);
const targetDir = type === 'ai-tools' ? this.aiToolsDir : this.projectsDir;
const cacheFile = path.join(targetDir, `${name}@${version}.tar.gz`);
try {
// 디렉토리 압축
await this.compressDirectory(sourcePath, cacheFile);
// 체크섬 계산
const checksum = await this.calculateChecksum(cacheFile);
const stat = await fs.stat(cacheFile);
// 매니페스트 업데이트
this.manifest.templates[type][name] = {
version,
cached_at: new Date().toISOString(),
size: stat.size,
checksum,
cache_file: cacheFile
};
await this.saveManifest();
console.log(`Template cached: ${cacheKey}`);
return {
success: true,
cacheKey,
size: stat.size,
checksum
};
} catch (error) {
console.error(`Failed to cache template ${cacheKey}:`, error.message);
throw error;
}
}
/**
* 템플릿 압축 (간단한 방식)
*/
async compressDirectory(sourcePath, targetFile) {
// Node.js 내장 zlib와 fs를 사용한 간단한 압축
const { createGzip } = await import('zlib');
const { createReadStream, createWriteStream } = await import('fs');
// 디렉토리 내용을 JSON으로 직렬화
const dirContents = await this.readDirectoryRecursive(sourcePath);
const jsonData = JSON.stringify(dirContents, null, 2);
// 압축하여 저장
const gzip = createGzip();
const input = Buffer.from(jsonData);
const output = createWriteStream(targetFile);
return new Promise((resolve, reject) => {
gzip.pipe(output);
gzip.on('error', reject);
output.on('error', reject);
output.on('finish', resolve);
gzip.end(input);
});
}
/**
* 디렉토리 내용을 재귀적으로 읽기
*/
async readDirectoryRecursive(dirPath) {
const result = {};
const items = await fs.readdir(dirPath);
for (const item of items) {
const itemPath = path.join(dirPath, item);
const stat = await fs.stat(itemPath);
if (stat.isDirectory()) {
// node_modules와 .git 제외
if (!item.includes('node_modules') && !item.includes('.git')) {
result[item] = await this.readDirectoryRecursive(itemPath);
}
} else {
// 파일 내용 읽기
try {
const content = await fs.readFile(itemPath, 'utf8');
result[item] = {
type: 'file',
content: content,
size: stat.size
};
} catch (error) {
// 바이너리 파일은 base64로 저장
const content = await fs.readFile(itemPath);
result[item] = {
type: 'binary',
content: content.toString('base64'),
size: stat.size
};
}
}
}
return result;
}
/**
* 템플릿 압축 해제
*/
async extractTemplate(cacheFile, targetPath) {
const { createGunzip } = await import('zlib');
const { createReadStream } = await import('fs');
// 압축 해제
const gunzip = createGunzip();
const input = createReadStream(cacheFile);
return new Promise((resolve, reject) => {
let data = '';
input.pipe(gunzip);
gunzip.on('data', chunk => data += chunk);
gunzip.on('end', async () => {
try {
const dirContents = JSON.parse(data);
await this.writeDirectoryRecursive(targetPath, dirContents);
resolve();
} catch (error) {
reject(error);
}
});
gunzip.on('error', reject);
input.on('error', reject);
});
}
/**
* 디렉토리 내용을 재귀적으로 쓰기
*/
async writeDirectoryRecursive(dirPath, contents) {
await fs.mkdir(dirPath, { recursive: true });
for (const [name, item] of Object.entries(contents)) {
const itemPath = path.join(dirPath, name);
if (item.type === 'file') {
await fs.writeFile(itemPath, item.content, 'utf8');
} else if (item.type === 'binary') {
const buffer = Buffer.from(item.content, 'base64');
await fs.writeFile(itemPath, buffer);
} else {
// 디렉토리
await this.writeDirectoryRecursive(itemPath, item);
}
}
}
/**
* 파일 체크섬 계산
*/
async calculateChecksum(filePath) {
return new Promise((resolve, reject) => {
const hash = createHash('sha256');
const stream = createReadStream(filePath);
stream.on('error', reject);
stream.on('data', chunk => hash.update(chunk));
stream.on('end', () => resolve(hash.digest('hex')));
});
}
/**
* 캐시된 템플릿 가져오기
*/
async getTemplate(type, name, version = '1.0.0') {
const cacheKey = this.generateCacheKey(type, name, version);
try {
const templateInfo = this.manifest.templates[type][name];
if (!templateInfo) {
this.stats.missCount++;
return null;
}
const cacheFile = templateInfo.cache_file;
if (!await this.fileExists(cacheFile)) {
this.stats.missCount++;
return null;
}
// 체크섬 검증
const currentChecksum = await this.calculateChecksum(cacheFile);
if (currentChecksum !== templateInfo.checksum) {
console.warn(`Checksum mismatch for ${cacheKey}, cache may be corrupted`);
this.stats.missCount++;
return null;
}
this.stats.hitCount++;
return {
cacheKey,
cacheFile,
templateInfo
};
} catch (error) {
console.error(`Failed to get template ${cacheKey}:`, error.message);
this.stats.missCount++;
return null;
}
}
/**
* 템플릿을 대상 디렉토리로 추출
*/
async extractToDirectory(type, name, targetPath, version = '1.0.0') {
const template = await this.getTemplate(type, name, version);
if (!template) {
throw new Error(`Template ${type}/${name}@${version} not found in cache`);
}
await fs.mkdir(targetPath, { recursive: true });
await this.extractTemplate(template.cacheFile, targetPath);
return {
success: true,
extracted: true,
targetPath,
templateInfo: template.templateInfo
};
}
/**
* 파일 존재 확인
*/
async fileExists(filePath) {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
/**
* 사용 가능한 템플릿 목록 조회
*/
async listTemplates(type = null) {
const result = {};
if (type) {
result[type] = this.manifest.templates[type] || {};
} else {
result['ai-tools'] = this.manifest.templates['ai-tools'] || {};
result['projects'] = this.manifest.templates['projects'] || {};
}
return result;
}
/**
* 캐시 정리
*/
async cleanup(options = {}) {
const maxAge = options.maxAge || 7 * 24 * 60 * 60 * 1000; // 7일
const now = Date.now();
let removedCount = 0;
let freedSpace = 0;
for (const type of ['ai-tools', 'projects']) {
const templates = this.manifest.templates[type];
for (const [name, templateInfo] of Object.entries(templates)) {
const cachedAt = new Date(templateInfo.cached_at).getTime();
if (now - cachedAt > maxAge) {
try {
await fs.unlink(templateInfo.cache_file);
freedSpace += templateInfo.size;
removedCount++;
delete templates[name];
} catch (error) {
console.warn(`Failed to remove expired cache: ${name}`, error.message);
}
}
}
}
await this.saveManifest();
await this.calculateStats();
return {
removedCount,
freedSpace,
totalSize: this.stats.totalSize
};
}
/**
* 전체 캐시 초기화
*/
async clear() {
try {
await fs.rm(this.templatesDir, { recursive: true, force: true });
await fs.mkdir(this.templatesDir, { recursive: true });
await fs.mkdir(this.aiToolsDir, { recursive: true });
await fs.mkdir(this.projectsDir, { recursive: true });
this.manifest.templates = {
'ai-tools': {},
'projects': {}
};
await this.saveManifest();
await this.calculateStats();
return {
success: true,
message: 'Cache cleared successfully'
};
} catch (error) {
console.error('Failed to clear cache:', error.message);
throw error;
}
}
/**
* 캐시 통계 정보
*/
getStats() {
return {
...this.stats,
maxCacheSize: this.maxCacheSize,
usagePercentage: ((this.stats.totalSize / this.maxCacheSize) * 100).toFixed(2) + '%',
hitRate: this.stats.hitCount + this.stats.missCount > 0
? ((this.stats.hitCount / (this.stats.hitCount + this.stats.missCount)) * 100).toFixed(2) + '%'
: '0%'
};
}
/**
* 캐시 크기 제한 확인
*/
async checkSizeLimit() {
if (this.stats.totalSize > this.maxCacheSize) {
// 가장 오래된 캐시부터 정리
const templates = [];
for (const type of ['ai-tools', 'projects']) {
for (const [name, templateInfo] of Object.entries(this.manifest.templates[type])) {
templates.push({
type,
name,
...templateInfo,
cachedAt: new Date(templateInfo.cached_at).getTime()
});
}
}
templates.sort((a, b) => a.cachedAt - b.cachedAt);
let currentSize = this.stats.totalSize;
let removedCount = 0;
for (const template of templates) {
if (currentSize <= this.maxCacheSize * 0.8) break;
try {
await fs.unlink(template.cache_file);
currentSize -= template.size;
removedCount++;
delete this.manifest.templates[template.type][template.name];
} catch (error) {
console.warn(`Failed to remove cache: ${template.name}`, error.message);
}
}
await this.saveManifest();
await this.calculateStats();
return {
cleanedUp: true,
removedCount,
currentSize: this.stats.totalSize
};
}
return { cleanedUp: false };
}
}
export { TemplateCache };