prprompts-flutter-generator
Version:
AI-powered Flutter development with full automation + official extension support - Generate 32 security-audited guides & auto-implement in 2-3 hours. NEW v5.1: Official Claude Code plugin with hooks, Gemini TOML commands, Qwen MCP settings. Features: Comp
586 lines (488 loc) • 15.6 kB
JavaScript
/**
* ArtifactManager - Manages conversion artifacts and run history
*
* Features:
* - Artifact storage and organization
* - Run history tracking
* - Diff preservation
* - Backup management
* - Log aggregation
* - Metadata tracking
*/
const fs = require('fs-extra');
const path = require('path');
const crypto = require('crypto');
const zlib = require('zlib');
class ArtifactManager {
constructor(config = {}) {
this.config = {
basePath: config.basePath || '.prprompts',
compress: config.compress || false,
maxRuns: config.maxRuns || 50, // Maximum number of runs to keep
maxAge: config.maxAge || 30 * 24 * 60 * 60 * 1000, // 30 days
verbose: config.verbose || false,
};
this.currentRun = null;
}
/**
* Initialize artifact storage
* @param {string} projectPath - Project root path
* @returns {Promise<void>}
*/
async initialize(projectPath) {
this.projectPath = projectPath;
this.artifactsPath = path.join(projectPath, this.config.basePath);
// Create directory structure
await fs.ensureDir(this.artifactsPath);
await fs.ensureDir(path.join(this.artifactsPath, 'runs'));
await fs.ensureDir(path.join(this.artifactsPath, 'artifacts'));
await fs.ensureDir(path.join(this.artifactsPath, 'backups'));
await fs.ensureDir(path.join(this.artifactsPath, 'logs'));
await fs.ensureDir(path.join(this.artifactsPath, 'archive'));
// Clean old artifacts
await this.cleanup();
}
/**
* Start a new conversion run
* @param {Object} metadata - Run metadata
* @returns {Promise<Object>} Run information
*/
async startRun(metadata = {}) {
const timestamp = Date.now();
const runId = `run-${timestamp}`;
const run = {
id: runId,
timestamp,
startedAt: new Date().toISOString(),
metadata: {
...metadata,
cwd: process.cwd(),
node: process.version,
platform: process.platform,
},
status: 'in_progress',
features: [],
errors: [],
logs: [],
};
// Create run directory
const runPath = path.join(this.artifactsPath, 'runs', runId);
await fs.ensureDir(runPath);
await fs.ensureDir(path.join(runPath, 'diffs'));
await fs.ensureDir(path.join(runPath, 'files'));
await fs.ensureDir(path.join(runPath, 'logs'));
await fs.ensureDir(path.join(runPath, 'metadata'));
// Save run metadata
await this.saveRunMetadata(runId, run);
this.currentRun = run;
if (this.config.verbose) {
console.log(`Started run: ${runId}`);
}
return run;
}
/**
* End current run
* @param {string} status - Final status (success, failed, cancelled)
* @returns {Promise<void>}
*/
async endRun(status = 'success') {
if (!this.currentRun) {
throw new Error('No active run');
}
this.currentRun.status = status;
this.currentRun.completedAt = new Date().toISOString();
this.currentRun.duration = Date.now() - this.currentRun.timestamp;
await this.saveRunMetadata(this.currentRun.id, this.currentRun);
if (this.config.verbose) {
console.log(`Ended run: ${this.currentRun.id} (${status})`);
}
this.currentRun = null;
}
/**
* Save feature artifacts
* @param {string} featureName - Feature name
* @param {Object} artifacts - Feature artifacts
* @returns {Promise<void>}
*/
async saveFeatureArtifacts(featureName, artifacts) {
if (!this.currentRun) {
throw new Error('No active run');
}
const featurePath = path.join(
this.artifactsPath,
'artifacts',
'features',
featureName
);
await fs.ensureDir(featurePath);
await fs.ensureDir(path.join(featurePath, 'files'));
await fs.ensureDir(path.join(featurePath, 'diffs'));
// Save feature metadata
await fs.writeJson(
path.join(featurePath, 'meta.json'),
{
name: featureName,
runId: this.currentRun.id,
timestamp: Date.now(),
...artifacts.metadata,
},
{ spaces: 2 }
);
// Save generated files
if (artifacts.files) {
for (const file of artifacts.files) {
const filePath = path.join(featurePath, 'files', file.relativePath);
await fs.ensureDir(path.dirname(filePath));
if (this.config.compress) {
const compressed = zlib.gzipSync(file.content);
await fs.writeFile(filePath + '.gz', compressed);
} else {
await fs.writeFile(filePath, file.content, 'utf8');
}
}
}
// Save diffs
if (artifacts.diffs) {
for (const diff of artifacts.diffs) {
const diffPath = path.join(
featurePath,
'diffs',
`${diff.name}.diff`
);
await fs.writeFile(diffPath, diff.content, 'utf8');
}
}
// Update run metadata
this.currentRun.features.push({
name: featureName,
timestamp: Date.now(),
files: artifacts.files?.length || 0,
diffs: artifacts.diffs?.length || 0,
});
await this.saveRunMetadata(this.currentRun.id, this.currentRun);
if (this.config.verbose) {
console.log(`Saved artifacts for feature: ${featureName}`);
}
}
/**
* Save diff for a file
* @param {string} fileName - File name
* @param {string} diff - Diff content
* @returns {Promise<void>}
*/
async saveDiff(fileName, diff) {
if (!this.currentRun) {
throw new Error('No active run');
}
const runPath = path.join(this.artifactsPath, 'runs', this.currentRun.id);
const diffPath = path.join(runPath, 'diffs', `${fileName}.diff`);
await fs.ensureDir(path.dirname(diffPath));
await fs.writeFile(diffPath, diff, 'utf8');
if (this.config.verbose) {
console.log(`Saved diff: ${fileName}`);
}
}
/**
* Save generated file
* @param {string} relativePath - Relative file path
* @param {string} content - File content
* @returns {Promise<void>}
*/
async saveGeneratedFile(relativePath, content) {
if (!this.currentRun) {
throw new Error('No active run');
}
const runPath = path.join(this.artifactsPath, 'runs', this.currentRun.id);
const filePath = path.join(runPath, 'files', relativePath);
await fs.ensureDir(path.dirname(filePath));
if (this.config.compress) {
const compressed = zlib.gzipSync(content);
await fs.writeFile(filePath + '.gz', compressed);
} else {
await fs.writeFile(filePath, content, 'utf8');
}
if (this.config.verbose) {
console.log(`Saved file: ${relativePath}`);
}
}
/**
* Log message to run
* @param {string} level - Log level (info, warn, error)
* @param {string} message - Log message
* @param {Object} data - Additional data
* @returns {Promise<void>}
*/
async log(level, message, data = {}) {
if (!this.currentRun) {
return;
}
const logEntry = {
level,
message,
timestamp: new Date().toISOString(),
data,
};
this.currentRun.logs.push(logEntry);
// Also write to log file
const runPath = path.join(this.artifactsPath, 'runs', this.currentRun.id);
const logPath = path.join(runPath, 'logs', `${level}.log`);
const logLine = `[${logEntry.timestamp}] ${message}\n`;
await fs.appendFile(logPath, logLine, 'utf8');
}
/**
* Log error to run
* @param {Error} error - Error object
* @param {Object} context - Error context
* @returns {Promise<void>}
*/
async logError(error, context = {}) {
if (!this.currentRun) {
return;
}
const errorEntry = {
message: error.message,
stack: error.stack,
timestamp: new Date().toISOString(),
context,
};
this.currentRun.errors.push(errorEntry);
await this.log('error', error.message, {
stack: error.stack,
...context,
});
}
/**
* Save run metadata
* @param {string} runId - Run ID
* @param {Object} metadata - Run metadata
* @returns {Promise<void>}
*/
async saveRunMetadata(runId, metadata) {
const runPath = path.join(this.artifactsPath, 'runs', runId);
const metaPath = path.join(runPath, 'meta.json');
await fs.writeJson(metaPath, metadata, { spaces: 2 });
}
/**
* Load run metadata
* @param {string} runId - Run ID
* @returns {Promise<Object>} Run metadata
*/
async loadRunMetadata(runId) {
const runPath = path.join(this.artifactsPath, 'runs', runId);
const metaPath = path.join(runPath, 'meta.json');
if (!await fs.pathExists(metaPath)) {
throw new Error(`Run not found: ${runId}`);
}
return await fs.readJson(metaPath);
}
/**
* Get all runs
* @returns {Promise<Array>} List of runs
*/
async getAllRuns() {
const runsPath = path.join(this.artifactsPath, 'runs');
if (!await fs.pathExists(runsPath)) {
return [];
}
const runDirs = await fs.readdir(runsPath);
const runs = [];
for (const dir of runDirs) {
try {
const metadata = await this.loadRunMetadata(dir);
runs.push(metadata);
} catch (error) {
// Skip invalid run directories
}
}
// Sort by timestamp (newest first)
runs.sort((a, b) => b.timestamp - a.timestamp);
return runs;
}
/**
* Get recent runs
* @param {number} count - Number of runs to retrieve
* @returns {Promise<Array>} Recent runs
*/
async getRecentRuns(count = 10) {
const allRuns = await this.getAllRuns();
return allRuns.slice(0, count);
}
/**
* Archive old run
* @param {string} runId - Run ID
* @returns {Promise<void>}
*/
async archiveRun(runId) {
const runPath = path.join(this.artifactsPath, 'runs', runId);
const archivePath = path.join(this.artifactsPath, 'archive', `${runId}.tar.gz`);
if (!await fs.pathExists(runPath)) {
throw new Error(`Run not found: ${runId}`);
}
// Create tarball (simplified - using copy for now)
await fs.ensureDir(path.dirname(archivePath));
await fs.copy(runPath, archivePath.replace('.tar.gz', ''));
// Remove original
await fs.remove(runPath);
if (this.config.verbose) {
console.log(`Archived run: ${runId}`);
}
}
/**
* Delete run
* @param {string} runId - Run ID
* @returns {Promise<void>}
*/
async deleteRun(runId) {
const runPath = path.join(this.artifactsPath, 'runs', runId);
if (!await fs.pathExists(runPath)) {
throw new Error(`Run not found: ${runId}`);
}
await fs.remove(runPath);
if (this.config.verbose) {
console.log(`Deleted run: ${runId}`);
}
}
/**
* Cleanup old artifacts
* @returns {Promise<Object>} Cleanup statistics
*/
async cleanup() {
const stats = {
deleted: 0,
archived: 0,
kept: 0,
};
const runs = await this.getAllRuns();
// Delete runs beyond maxRuns
if (runs.length > this.config.maxRuns) {
const toDelete = runs.slice(this.config.maxRuns);
for (const run of toDelete) {
await this.deleteRun(run.id);
stats.deleted++;
}
}
// Archive runs older than maxAge
const now = Date.now();
const remainingRuns = runs.slice(0, this.config.maxRuns);
for (const run of remainingRuns) {
const age = now - run.timestamp;
if (age > this.config.maxAge) {
await this.archiveRun(run.id);
stats.archived++;
} else {
stats.kept++;
}
}
if (this.config.verbose && (stats.deleted > 0 || stats.archived > 0)) {
console.log(`Cleanup: deleted ${stats.deleted}, archived ${stats.archived}, kept ${stats.kept}`);
}
return stats;
}
/**
* Get storage statistics
* @returns {Promise<Object>} Storage statistics
*/
async getStorageStats() {
const stats = {
totalSize: 0,
runs: 0,
artifacts: 0,
backups: 0,
logs: 0,
archived: 0,
};
// Calculate directory sizes
const paths = {
runs: path.join(this.artifactsPath, 'runs'),
artifacts: path.join(this.artifactsPath, 'artifacts'),
backups: path.join(this.artifactsPath, 'backups'),
logs: path.join(this.artifactsPath, 'logs'),
archived: path.join(this.artifactsPath, 'archive'),
};
for (const [key, dirPath] of Object.entries(paths)) {
if (await fs.pathExists(dirPath)) {
stats[key] = await this.getDirectorySize(dirPath);
}
}
stats.totalSize = Object.values(stats).reduce((sum, size) => sum + (typeof size === 'number' ? size : 0), 0);
return stats;
}
/**
* Get directory size recursively
* @param {string} dirPath - Directory path
* @returns {Promise<number>} Size in bytes
*/
async getDirectorySize(dirPath) {
let size = 0;
const items = await fs.readdir(dirPath, { withFileTypes: true });
for (const item of items) {
const fullPath = path.join(dirPath, item.name);
if (item.isDirectory()) {
size += await this.getDirectorySize(fullPath);
} else if (item.isFile()) {
const stat = await fs.stat(fullPath);
size += stat.size;
}
}
return size;
}
/**
* Export run artifacts
* @param {string} runId - Run ID
* @param {string} exportPath - Export directory path
* @returns {Promise<void>}
*/
async exportRun(runId, exportPath) {
const runPath = path.join(this.artifactsPath, 'runs', runId);
if (!await fs.pathExists(runPath)) {
throw new Error(`Run not found: ${runId}`);
}
await fs.copy(runPath, exportPath);
if (this.config.verbose) {
console.log(`Exported run ${runId} to ${exportPath}`);
}
}
/**
* Generate run report
* @param {string} runId - Run ID
* @returns {Promise<Object>} Run report
*/
async generateRunReport(runId) {
const metadata = await this.loadRunMetadata(runId);
const report = {
id: runId,
status: metadata.status,
startedAt: metadata.startedAt,
completedAt: metadata.completedAt,
duration: metadata.duration,
features: metadata.features.length,
errors: metadata.errors.length,
logs: metadata.logs.length,
summary: {
totalFiles: metadata.features.reduce((sum, f) => sum + f.files, 0),
totalDiffs: metadata.features.reduce((sum, f) => sum + f.diffs, 0),
},
};
return report;
}
/**
* Get hash for content
* @param {string} content - Content to hash
* @returns {string} Hash
*/
getHash(content) {
return crypto.createHash('md5').update(content).digest('hex');
}
/**
* Format bytes to human-readable size
* @param {number} bytes - Bytes
* @returns {string} Formatted size
*/
formatBytes(bytes) {
if (bytes === 0) return '0 B';
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(2)} ${sizes[i]}`;
}
}
module.exports = ArtifactManager;