UNPKG

@fission-ai/openspec

Version:

AI-native system for spec-driven development

502 lines 21.9 kB
/** * Legacy cleanup module for detecting and removing OpenSpec artifacts * from previous init versions during the migration to the skill-based workflow. */ import { promises as fs } from 'fs'; import chalk from 'chalk'; import { FileSystemUtils, removeMarkerBlock as removeMarkerBlockUtil } from '../utils/file-system.js'; import { OPENSPEC_MARKERS } from './config.js'; /** * Legacy config file names from the old ToolRegistry. * These were config files created at project root with OpenSpec markers. */ export const LEGACY_CONFIG_FILES = [ 'CLAUDE.md', 'CLINE.md', 'CODEBUDDY.md', 'COSTRICT.md', 'QODER.md', 'IFLOW.md', 'AGENTS.md', // root AGENTS.md (not openspec/AGENTS.md) 'QWEN.md', ]; /** * Legacy slash command patterns from the old SlashCommandRegistry. * These map toolId to the path pattern where legacy commands were created. * Some tools used a directory structure, others used individual files. */ export const LEGACY_SLASH_COMMAND_PATHS = { // Directory-based: .tooldir/commands/openspec/ or .tooldir/commands/openspec/*.md 'claude': { type: 'directory', path: '.claude/commands/openspec' }, 'codebuddy': { type: 'directory', path: '.codebuddy/commands/openspec' }, 'qoder': { type: 'directory', path: '.qoder/commands/openspec' }, 'crush': { type: 'directory', path: '.crush/commands/openspec' }, 'gemini': { type: 'directory', path: '.gemini/commands/openspec' }, 'costrict': { type: 'directory', path: '.cospec/openspec/commands' }, // File-based: individual openspec-*.md files in a commands/workflows/prompts folder 'cursor': { type: 'files', pattern: '.cursor/commands/openspec-*.md' }, 'windsurf': { type: 'files', pattern: '.windsurf/workflows/openspec-*.md' }, 'kilocode': { type: 'files', pattern: '.kilocode/workflows/openspec-*.md' }, 'kiro': { type: 'files', pattern: '.kiro/prompts/openspec-*.prompt.md' }, 'github-copilot': { type: 'files', pattern: '.github/prompts/openspec-*.prompt.md' }, 'amazon-q': { type: 'files', pattern: '.amazonq/prompts/openspec-*.md' }, 'cline': { type: 'files', pattern: '.clinerules/workflows/openspec-*.md' }, 'roocode': { type: 'files', pattern: '.roo/commands/openspec-*.md' }, 'auggie': { type: 'files', pattern: '.augment/commands/openspec-*.md' }, 'factory': { type: 'files', pattern: '.factory/commands/openspec-*.md' }, 'opencode': { type: 'files', pattern: '.opencode/command/openspec-*.md' }, 'continue': { type: 'files', pattern: '.continue/prompts/openspec-*.prompt' }, 'antigravity': { type: 'files', pattern: '.agent/workflows/openspec-*.md' }, 'iflow': { type: 'files', pattern: '.iflow/commands/openspec-*.md' }, 'qwen': { type: 'files', pattern: '.qwen/commands/openspec-*.toml' }, 'codex': { type: 'files', pattern: '.codex/prompts/openspec-*.md' }, }; /** * Detects all legacy OpenSpec artifacts in a project. * * @param projectPath - The root path of the project * @returns Detection result with all found legacy artifacts */ export async function detectLegacyArtifacts(projectPath) { const result = { configFiles: [], configFilesToUpdate: [], slashCommandDirs: [], slashCommandFiles: [], hasOpenspecAgents: false, hasProjectMd: false, hasRootAgentsWithMarkers: false, hasLegacyArtifacts: false, }; // Detect legacy config files const configResult = await detectLegacyConfigFiles(projectPath); result.configFiles = configResult.allFiles; result.configFilesToUpdate = configResult.filesToUpdate; // Detect legacy slash commands const slashResult = await detectLegacySlashCommands(projectPath); result.slashCommandDirs = slashResult.directories; result.slashCommandFiles = slashResult.files; // Detect legacy structure files const structureResult = await detectLegacyStructureFiles(projectPath); result.hasOpenspecAgents = structureResult.hasOpenspecAgents; result.hasProjectMd = structureResult.hasProjectMd; result.hasRootAgentsWithMarkers = structureResult.hasRootAgentsWithMarkers; // Determine if any legacy artifacts exist result.hasLegacyArtifacts = result.configFiles.length > 0 || result.slashCommandDirs.length > 0 || result.slashCommandFiles.length > 0 || result.hasOpenspecAgents || result.hasRootAgentsWithMarkers || result.hasProjectMd; return result; } /** * Detects legacy config files with OpenSpec markers. * All config files with markers are candidates for update (marker removal only). * Config files are NEVER deleted - they belong to the user's project root. * * @param projectPath - The root path of the project * @returns Object with all files found and files to update */ export async function detectLegacyConfigFiles(projectPath) { const allFiles = []; const filesToUpdate = []; for (const fileName of LEGACY_CONFIG_FILES) { const filePath = FileSystemUtils.joinPath(projectPath, fileName); if (await FileSystemUtils.fileExists(filePath)) { const content = await FileSystemUtils.readFile(filePath); if (hasOpenSpecMarkers(content)) { allFiles.push(fileName); filesToUpdate.push(fileName); // Always update, never delete config files } } } return { allFiles, filesToUpdate }; } /** * Detects legacy slash command directories and files. * * @param projectPath - The root path of the project * @returns Object with directories and individual files found */ export async function detectLegacySlashCommands(projectPath) { const directories = []; const files = []; for (const [toolId, pattern] of Object.entries(LEGACY_SLASH_COMMAND_PATHS)) { if (pattern.type === 'directory' && pattern.path) { const dirPath = FileSystemUtils.joinPath(projectPath, pattern.path); if (await FileSystemUtils.directoryExists(dirPath)) { directories.push(pattern.path); } } else if (pattern.type === 'files' && pattern.pattern) { // For file-based patterns, check for individual files const foundFiles = await findLegacySlashCommandFiles(projectPath, pattern.pattern); files.push(...foundFiles); } } return { directories, files }; } /** * Finds legacy slash command files matching a glob pattern. * * @param projectPath - The root path of the project * @param pattern - Glob pattern like '.cursor/commands/openspec-*.md' * @returns Array of matching file paths relative to projectPath */ async function findLegacySlashCommandFiles(projectPath, pattern) { const foundFiles = []; // Extract directory and file pattern from glob // Handle both forward and backward slashes for Windows compatibility const lastForwardSlash = pattern.lastIndexOf('/'); const lastBackSlash = pattern.lastIndexOf('\\'); const lastSeparator = Math.max(lastForwardSlash, lastBackSlash); const dirPart = pattern.substring(0, lastSeparator); const filePart = pattern.substring(lastSeparator + 1); const dirPath = FileSystemUtils.joinPath(projectPath, dirPart); if (!(await FileSystemUtils.directoryExists(dirPath))) { return foundFiles; } try { const entries = await fs.readdir(dirPath); // Convert glob pattern to regex // openspec-*.md -> /^openspec-.*\.md$/ // openspec-*.prompt.md -> /^openspec-.*\.prompt\.md$/ // openspec-*.toml -> /^openspec-.*\.toml$/ const regexPattern = filePart .replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape regex special chars except * .replace(/\*/g, '.*'); // Replace * with .* const regex = new RegExp(`^${regexPattern}$`); for (const entry of entries) { if (regex.test(entry)) { // Use forward slashes for consistency in relative paths (cross-platform) const normalizedDir = dirPart.replace(/\\/g, '/'); foundFiles.push(`${normalizedDir}/${entry}`); } } } catch { // Directory doesn't exist or can't be read } return foundFiles; } /** * Detects legacy OpenSpec structure files (AGENTS.md and project.md). * * @param projectPath - The root path of the project * @returns Object with detection results for structure files */ export async function detectLegacyStructureFiles(projectPath) { let hasOpenspecAgents = false; let hasProjectMd = false; let hasRootAgentsWithMarkers = false; // Check for openspec/AGENTS.md const openspecAgentsPath = FileSystemUtils.joinPath(projectPath, 'openspec', 'AGENTS.md'); hasOpenspecAgents = await FileSystemUtils.fileExists(openspecAgentsPath); // Check for openspec/project.md (for migration messaging, not deleted) const projectMdPath = FileSystemUtils.joinPath(projectPath, 'openspec', 'project.md'); hasProjectMd = await FileSystemUtils.fileExists(projectMdPath); // Check for root AGENTS.md with OpenSpec markers const rootAgentsPath = FileSystemUtils.joinPath(projectPath, 'AGENTS.md'); if (await FileSystemUtils.fileExists(rootAgentsPath)) { const content = await FileSystemUtils.readFile(rootAgentsPath); hasRootAgentsWithMarkers = hasOpenSpecMarkers(content); } return { hasOpenspecAgents, hasProjectMd, hasRootAgentsWithMarkers }; } /** * Checks if content contains OpenSpec markers. * * @param content - File content to check * @returns True if both start and end markers are present */ export function hasOpenSpecMarkers(content) { return (content.includes(OPENSPEC_MARKERS.start) && content.includes(OPENSPEC_MARKERS.end)); } /** * Checks if file content is 100% OpenSpec content (only markers and whitespace outside). * * @param content - File content to check * @returns True if content outside markers is only whitespace */ export function isOnlyOpenSpecContent(content) { const startIndex = content.indexOf(OPENSPEC_MARKERS.start); const endIndex = content.indexOf(OPENSPEC_MARKERS.end); if (startIndex === -1 || endIndex === -1 || endIndex <= startIndex) { return false; } const before = content.substring(0, startIndex); const after = content.substring(endIndex + OPENSPEC_MARKERS.end.length); return before.trim() === '' && after.trim() === ''; } /** * Removes the OpenSpec marker block from file content. * Only removes markers that are on their own lines (ignores inline mentions). * Cleans up double blank lines that may result from removal. * * @param content - File content with OpenSpec markers * @returns Content with marker block removed */ export function removeMarkerBlock(content) { return removeMarkerBlockUtil(content, OPENSPEC_MARKERS.start, OPENSPEC_MARKERS.end); } /** * Cleans up legacy OpenSpec artifacts from a project. * Preserves openspec/project.md (shows migration hint instead of deleting). * * @param projectPath - The root path of the project * @param detection - Detection result from detectLegacyArtifacts * @returns Cleanup result with summary of actions taken */ export async function cleanupLegacyArtifacts(projectPath, detection) { const result = { deletedFiles: [], modifiedFiles: [], deletedDirs: [], projectMdNeedsMigration: detection.hasProjectMd, errors: [], }; // Remove marker blocks from config files (NEVER delete config files) // Config files like CLAUDE.md, AGENTS.md belong to the user's project root for (const fileName of detection.configFilesToUpdate) { const filePath = FileSystemUtils.joinPath(projectPath, fileName); try { const content = await FileSystemUtils.readFile(filePath); const newContent = removeMarkerBlock(content); // Always write the file, even if empty - never delete user config files await FileSystemUtils.writeFile(filePath, newContent); result.modifiedFiles.push(fileName); } catch (error) { result.errors.push(`Failed to modify ${fileName}: ${error.message}`); } } // Delete legacy slash command directories (these are 100% OpenSpec-managed) for (const dirPath of detection.slashCommandDirs) { const fullPath = FileSystemUtils.joinPath(projectPath, dirPath); try { await fs.rm(fullPath, { recursive: true, force: true }); result.deletedDirs.push(dirPath); } catch (error) { result.errors.push(`Failed to delete directory ${dirPath}: ${error.message}`); } } // Delete legacy slash command files (these are 100% OpenSpec-managed) for (const filePath of detection.slashCommandFiles) { const fullPath = FileSystemUtils.joinPath(projectPath, filePath); try { await fs.unlink(fullPath); result.deletedFiles.push(filePath); } catch (error) { result.errors.push(`Failed to delete ${filePath}: ${error.message}`); } } // Delete openspec/AGENTS.md (this is inside openspec/, it's OpenSpec-managed) if (detection.hasOpenspecAgents) { const agentsPath = FileSystemUtils.joinPath(projectPath, 'openspec', 'AGENTS.md'); if (await FileSystemUtils.fileExists(agentsPath)) { try { await fs.unlink(agentsPath); result.deletedFiles.push('openspec/AGENTS.md'); } catch (error) { result.errors.push(`Failed to delete openspec/AGENTS.md: ${error.message}`); } } } // Handle root AGENTS.md with OpenSpec markers - remove markers only, NEVER delete // Note: Root AGENTS.md is handled via configFilesToUpdate above (it's in LEGACY_CONFIG_FILES) // This hasRootAgentsWithMarkers flag is just for detection, cleanup happens via configFilesToUpdate return result; } /** * Generates a cleanup summary message for display. * * @param result - Cleanup result from cleanupLegacyArtifacts * @returns Formatted summary string for console output */ export function formatCleanupSummary(result) { const lines = []; if (result.deletedFiles.length > 0 || result.deletedDirs.length > 0 || result.modifiedFiles.length > 0) { lines.push('Cleaned up legacy files:'); for (const file of result.deletedFiles) { lines.push(` ✓ Removed ${file}`); } for (const dir of result.deletedDirs) { lines.push(` ✓ Removed ${dir}/ (replaced by /opsx:*)`); } for (const file of result.modifiedFiles) { lines.push(` ✓ Removed OpenSpec markers from ${file}`); } } if (result.projectMdNeedsMigration) { if (lines.length > 0) { lines.push(''); } lines.push(formatProjectMdMigrationHint()); } if (result.errors.length > 0) { if (lines.length > 0) { lines.push(''); } lines.push('Errors during cleanup:'); for (const error of result.errors) { lines.push(` ⚠ ${error}`); } } return lines.join('\n'); } /** * Build list of files to be removed with explanations. * Only includes OpenSpec-managed files (slash commands, openspec/AGENTS.md). * Config files like CLAUDE.md, AGENTS.md are NEVER deleted. * * @param detection - Detection result from detectLegacyArtifacts * @returns Array of objects with path and explanation */ function buildRemovalsList(detection) { const removals = []; // Slash command directories (these are 100% OpenSpec-managed) for (const dir of detection.slashCommandDirs) { // Split on both forward and backward slashes for Windows compatibility const toolDir = dir.split(/[\/\\]/)[0]; removals.push({ path: dir + '/', explanation: `replaced by ${toolDir}/skills/` }); } // Slash command files (these are 100% OpenSpec-managed) for (const file of detection.slashCommandFiles) { removals.push({ path: file, explanation: 'replaced by skills/' }); } // openspec/AGENTS.md (inside openspec/, it's OpenSpec-managed) if (detection.hasOpenspecAgents) { removals.push({ path: 'openspec/AGENTS.md', explanation: 'obsolete workflow file' }); } // Note: Config files (CLAUDE.md, AGENTS.md, etc.) are NEVER in the removals list // They always go to the updates list where only markers are removed return removals; } /** * Build list of files to be updated with explanations. * Includes ALL config files with markers - markers are removed, file is never deleted. * * @param detection - Detection result from detectLegacyArtifacts * @returns Array of objects with path and explanation */ function buildUpdatesList(detection) { const updates = []; // All config files with markers get updated (markers removed, file preserved) for (const file of detection.configFilesToUpdate) { updates.push({ path: file, explanation: 'removing OpenSpec markers' }); } return updates; } /** * Generates a detection summary message for display before cleanup. * Groups files by action type: removals, updates, and manual migration. * * @param detection - Detection result from detectLegacyArtifacts * @returns Formatted summary string showing what was found */ export function formatDetectionSummary(detection) { const lines = []; const removals = buildRemovalsList(detection); const updates = buildUpdatesList(detection); // If nothing to show, return empty if (removals.length === 0 && updates.length === 0 && !detection.hasProjectMd) { return ''; } // Header - welcoming upgrade message lines.push(chalk.bold('Upgrading to the new OpenSpec')); lines.push(''); lines.push('OpenSpec now uses agent skills, the emerging standard across coding'); lines.push('agents. This simplifies your setup while keeping everything working'); lines.push('as before.'); lines.push(''); // Section 1: Files to remove (no user content to preserve) if (removals.length > 0) { lines.push(chalk.bold('Files to remove')); lines.push(chalk.dim('No user content to preserve:')); for (const { path } of removals) { lines.push(` • ${path}`); } } // Section 2: Files to update (markers removed, content preserved) if (updates.length > 0) { if (removals.length > 0) lines.push(''); lines.push(chalk.bold('Files to update')); lines.push(chalk.dim('OpenSpec markers will be removed, your content preserved:')); for (const { path } of updates) { lines.push(` • ${path}`); } } // Section 3: Manual migration (project.md) if (detection.hasProjectMd) { if (removals.length > 0 || updates.length > 0) lines.push(''); lines.push(formatProjectMdMigrationHint()); } return lines.join('\n'); } /** * Extract tool IDs from detected legacy artifacts. * Uses LEGACY_SLASH_COMMAND_PATHS to map paths back to tool IDs. * * @param detection - Detection result from detectLegacyArtifacts * @returns Array of tool IDs that had legacy artifacts */ export function getToolsFromLegacyArtifacts(detection) { const tools = new Set(); // Match directories to tool IDs for (const dir of detection.slashCommandDirs) { for (const [toolId, pattern] of Object.entries(LEGACY_SLASH_COMMAND_PATHS)) { if (pattern.type === 'directory' && pattern.path === dir) { tools.add(toolId); break; } } } // Match files to tool IDs using glob patterns for (const file of detection.slashCommandFiles) { // Normalize file path to use forward slashes for consistent matching (Windows compatibility) const normalizedFile = file.replace(/\\/g, '/'); for (const [toolId, pattern] of Object.entries(LEGACY_SLASH_COMMAND_PATHS)) { if (pattern.type === 'files' && pattern.pattern) { // Convert glob pattern to regex for matching // e.g., '.cursor/commands/openspec-*.md' -> /^\.cursor\/commands\/openspec-.*\.md$/ const regexPattern = pattern.pattern .replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape regex special chars except * .replace(/\*/g, '.*'); // Replace * with .* const regex = new RegExp(`^${regexPattern}$`); if (regex.test(normalizedFile)) { tools.add(toolId); break; } } } } return Array.from(tools); } /** * Generates a migration hint message for project.md. * This is shown when project.md exists and needs manual migration to config.yaml. * * @returns Formatted migration hint string for console output */ export function formatProjectMdMigrationHint() { const lines = []; lines.push(chalk.yellow.bold('Needs your attention')); lines.push(' • openspec/project.md'); lines.push(chalk.dim(' We won\'t delete this file. It may contain useful project context.')); lines.push(''); lines.push(chalk.dim(' The new openspec/config.yaml has a "context:" section for planning')); lines.push(chalk.dim(' context. This is included in every OpenSpec request and works more')); lines.push(chalk.dim(' reliably than the old project.md approach.')); lines.push(''); lines.push(chalk.dim(' Review project.md, move any useful content to config.yaml\'s context')); lines.push(chalk.dim(' section, then delete the file when ready.')); return lines.join('\n'); } //# sourceMappingURL=legacy-cleanup.js.map