spec-flow
Version:
Spec-Driven Development workflow toolkit for Claude Code - Build high-quality features faster with repeatable AI workflows
218 lines (189 loc) • 6.87 kB
JavaScript
const fs = require('fs-extra');
const path = require('path');
const { getISOTimestamp } = require('./utils');
/**
* Conflict resolution strategies
*/
const STRATEGIES = {
MERGE: 'merge', // Smart merge for CLAUDE.md, rename others
BACKUP: 'backup', // Backup existing files, then overwrite
SKIP: 'skip', // Skip existing files, only install new
FORCE: 'force' // Overwrite everything (current behavior)
};
/**
* Detect file conflicts in target directory
* @param {string} targetDir - Target installation directory
* @param {string[]} files - Files to check for conflicts
* @returns {Promise<Object>} { conflicts: Array, hasConflicts: boolean }
*/
async function detectConflicts(targetDir, files) {
const conflicts = [];
for (const file of files) {
const fullPath = path.join(targetDir, file);
if (await fs.pathExists(fullPath)) {
const stats = await fs.stat(fullPath);
conflicts.push({
file,
path: fullPath,
isDirectory: stats.isDirectory(),
size: stats.size,
modified: stats.mtime
});
}
}
return {
conflicts,
hasConflicts: conflicts.length > 0
};
}
/**
* Smart merge CLAUDE.md files
* Appends spec-flow content under "## Spec-Flow Workflow" section
* @param {string} existingPath - Path to existing CLAUDE.md
* @param {string} newContent - New spec-flow CLAUDE.md content
* @returns {Promise<string>} Merged content
*/
async function mergeCLAUDEmd(existingPath, newContent) {
const existing = await fs.readFile(existingPath, 'utf8');
// Check if spec-flow section already exists
if (existing.includes('## Spec-Flow Workflow') || existing.includes('spec-flow')) {
// Already has spec-flow content, don't duplicate
return existing;
}
// Append spec-flow content as new section
const separator = '\n\n---\n\n';
const sectionHeader = '## Spec-Flow Workflow\n\n';
const merged = existing.trimEnd() + separator + sectionHeader + newContent.trim() + '\n';
return merged;
}
/**
* Create timestamped backup of file
* @param {string} filePath - Path to file to backup
* @returns {Promise<string>} Path to backup file
*/
async function backupFile(filePath) {
if (!await fs.pathExists(filePath)) {
return null;
}
const dir = path.dirname(filePath);
const ext = path.extname(filePath);
const basename = path.basename(filePath, ext);
const timestamp = getISOTimestamp();
const backupPath = path.join(dir, `${basename}.backup-${timestamp}${ext}`);
await fs.copy(filePath, backupPath);
return backupPath;
}
/**
* Rename file with suffix to avoid conflict
* @param {string} sourcePath - Source file path
* @param {string} targetPath - Original target path
* @param {string} suffix - Suffix to add (e.g., '-spec-flow')
* @returns {Promise<string>} New target path
*/
async function renameWithSuffix(sourcePath, targetPath, suffix = '-spec-flow') {
const dir = path.dirname(targetPath);
const ext = path.extname(targetPath);
const basename = path.basename(targetPath, ext);
const newPath = path.join(dir, `${basename}${suffix}${ext}`);
await fs.copy(sourcePath, newPath);
return newPath;
}
/**
* Resolve file conflict based on strategy
* @param {Object} options - Resolution options
* @param {string} options.sourcePath - Source file to install
* @param {string} options.targetPath - Target installation path
* @param {string} options.strategy - Resolution strategy
* @param {string} options.fileName - File name for special handling
* @returns {Promise<Object>} { action: string, path: string, backupPath?: string }
*/
async function resolveConflict(options) {
const { sourcePath, targetPath, strategy, fileName } = options;
const exists = await fs.pathExists(targetPath);
// No conflict, just copy
if (!exists) {
await fs.copy(sourcePath, targetPath, { overwrite: false });
return { action: 'installed', path: targetPath };
}
switch (strategy) {
case STRATEGIES.MERGE:
// Special handling for CLAUDE.md
if (fileName === 'CLAUDE.md') {
const newContent = await fs.readFile(sourcePath, 'utf8');
const merged = await mergeCLAUDEmd(targetPath, newContent);
await fs.writeFile(targetPath, merged, 'utf8');
return { action: 'merged', path: targetPath };
}
// For other files, rename to avoid conflict
const renamedPath = await renameWithSuffix(sourcePath, targetPath);
return { action: 'renamed', path: renamedPath, originalName: fileName };
case STRATEGIES.BACKUP:
const backupPath = await backupFile(targetPath);
await fs.copy(sourcePath, targetPath, { overwrite: true });
return { action: 'backed-up', path: targetPath, backupPath };
case STRATEGIES.SKIP:
return { action: 'skipped', path: targetPath };
case STRATEGIES.FORCE:
await fs.copy(sourcePath, targetPath, { overwrite: true });
return { action: 'overwritten', path: targetPath };
default:
throw new Error(`Unknown conflict strategy: ${strategy}`);
}
}
/**
* Format conflict list for display
* @param {Array} conflicts - Array of conflict objects
* @returns {string} Formatted string
*/
function formatConflicts(conflicts) {
return conflicts.map(c => {
const type = c.isDirectory ? 'directory' : 'file';
let desc = c.file;
// Add context for known files
if (c.file === 'CLAUDE.md') desc += ' (project instructions)';
if (c.file === 'LICENSE') desc += ' (project license)';
if (c.file === '.claude') desc += ' (existing configuration)';
return ` • ${desc} (${type})`;
}).join('\n');
}
/**
* Format resolution actions for display
* @param {Array} actions - Array of resolution action objects
* @returns {string} Formatted string
*/
function formatActions(actions) {
const lines = [];
for (const action of actions) {
switch (action.action) {
case 'merged':
lines.push(` • ${path.basename(action.path)} - Appended spec-flow section`);
break;
case 'renamed':
lines.push(` • ${action.originalName} - Installed as ${path.basename(action.path)}`);
break;
case 'backed-up':
lines.push(` • ${path.basename(action.path)} - Backed up to ${path.basename(action.backupPath)}`);
break;
case 'skipped':
lines.push(` • ${path.basename(action.path)} - Skipped (already exists)`);
break;
case 'overwritten':
lines.push(` • ${path.basename(action.path)} - Overwritten`);
break;
case 'installed':
lines.push(` • ${path.basename(action.path)} - Installed`);
break;
}
}
return lines.join('\n');
}
module.exports = {
STRATEGIES,
detectConflicts,
mergeCLAUDEmd,
backupFile,
renameWithSuffix,
resolveConflict,
formatConflicts,
formatActions
};