UNPKG

@fission-ai/openspec

Version:

AI-native system for spec-driven development

318 lines 14.4 kB
import { promises as fs } from 'fs'; import path from 'path'; import { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progress.js'; import { Validator } from './validation/validator.js'; import chalk from 'chalk'; import { findSpecUpdates, buildUpdatedSpec, writeUpdatedSpec, } from './specs-apply.js'; /** * Recursively copy a directory. Used when fs.rename fails (e.g. EPERM on Windows). */ async function copyDirRecursive(src, dest) { await fs.mkdir(dest, { recursive: true }); const entries = await fs.readdir(src, { withFileTypes: true }); for (const entry of entries) { const srcPath = path.join(src, entry.name); const destPath = path.join(dest, entry.name); if (entry.isDirectory()) { await copyDirRecursive(srcPath, destPath); } else { await fs.copyFile(srcPath, destPath); } } } /** * Move a directory from src to dest. On Windows, fs.rename() often fails with * EPERM when the directory is non-empty or another process has it open (IDE, * file watcher, antivirus). Fall back to copy-then-remove when rename fails * with EPERM or EXDEV. */ async function moveDirectory(src, dest) { try { await fs.rename(src, dest); } catch (err) { const code = err?.code; if (code === 'EPERM' || code === 'EXDEV') { await copyDirRecursive(src, dest); await fs.rm(src, { recursive: true, force: true }); } else { throw err; } } } export class ArchiveCommand { async execute(changeName, options = {}) { const targetPath = '.'; const changesDir = path.join(targetPath, 'openspec', 'changes'); const archiveDir = path.join(changesDir, 'archive'); const mainSpecsDir = path.join(targetPath, 'openspec', 'specs'); // Check if changes directory exists try { await fs.access(changesDir); } catch { throw new Error("No OpenSpec changes directory found. Run 'openspec init' first."); } // Get change name interactively if not provided if (!changeName) { const selectedChange = await this.selectChange(changesDir); if (!selectedChange) { console.log('No change selected. Aborting.'); return; } changeName = selectedChange; } const changeDir = path.join(changesDir, changeName); // Verify change exists try { const stat = await fs.stat(changeDir); if (!stat.isDirectory()) { throw new Error(`Change '${changeName}' not found.`); } } catch { throw new Error(`Change '${changeName}' not found.`); } const skipValidation = options.validate === false || options.noValidate === true; // Validate specs and change before archiving if (!skipValidation) { const validator = new Validator(); let hasValidationErrors = false; // Validate proposal.md (non-blocking unless strict mode desired in future) const changeFile = path.join(changeDir, 'proposal.md'); try { await fs.access(changeFile); const changeReport = await validator.validateChange(changeFile); // Proposal validation is informative only (do not block archive) if (!changeReport.valid) { console.log(chalk.yellow(`\nProposal warnings in proposal.md (non-blocking):`)); for (const issue of changeReport.issues) { const symbol = issue.level === 'ERROR' ? '⚠' : (issue.level === 'WARNING' ? '⚠' : 'ℹ'); console.log(chalk.yellow(` ${symbol} ${issue.message}`)); } } } catch { // Change file doesn't exist, skip validation } // Validate delta-formatted spec files under the change directory if present const changeSpecsDir = path.join(changeDir, 'specs'); let hasDeltaSpecs = false; try { const candidates = await fs.readdir(changeSpecsDir, { withFileTypes: true }); for (const c of candidates) { if (c.isDirectory()) { try { const candidatePath = path.join(changeSpecsDir, c.name, 'spec.md'); await fs.access(candidatePath); const content = await fs.readFile(candidatePath, 'utf-8'); if (/^##\s+(ADDED|MODIFIED|REMOVED|RENAMED)\s+Requirements/m.test(content)) { hasDeltaSpecs = true; break; } } catch { } } } } catch { } if (hasDeltaSpecs) { const deltaReport = await validator.validateChangeDeltaSpecs(changeDir); if (!deltaReport.valid) { hasValidationErrors = true; console.log(chalk.red(`\nValidation errors in change delta specs:`)); for (const issue of deltaReport.issues) { if (issue.level === 'ERROR') { console.log(chalk.red(` ✗ ${issue.message}`)); } else if (issue.level === 'WARNING') { console.log(chalk.yellow(` ⚠ ${issue.message}`)); } } } } if (hasValidationErrors) { console.log(chalk.red('\nValidation failed. Please fix the errors before archiving.')); console.log(chalk.yellow('To skip validation (not recommended), use --no-validate flag.')); return; } } else { // Log warning when validation is skipped const timestamp = new Date().toISOString(); if (!options.yes) { const { confirm } = await import('@inquirer/prompts'); const proceed = await confirm({ message: chalk.yellow('⚠️ WARNING: Skipping validation may archive invalid specs. Continue? (y/N)'), default: false }); if (!proceed) { console.log('Archive cancelled.'); return; } } else { console.log(chalk.yellow(`\n⚠️ WARNING: Skipping validation may archive invalid specs.`)); } console.log(chalk.yellow(`[${timestamp}] Validation skipped for change: ${changeName}`)); console.log(chalk.yellow(`Affected files: ${changeDir}`)); } // Show progress and check for incomplete tasks const progress = await getTaskProgressForChange(changesDir, changeName); const status = formatTaskStatus(progress); console.log(`Task status: ${status}`); const incompleteTasks = Math.max(progress.total - progress.completed, 0); if (incompleteTasks > 0) { if (!options.yes) { const { confirm } = await import('@inquirer/prompts'); const proceed = await confirm({ message: `Warning: ${incompleteTasks} incomplete task(s) found. Continue?`, default: false }); if (!proceed) { console.log('Archive cancelled.'); return; } } else { console.log(`Warning: ${incompleteTasks} incomplete task(s) found. Continuing due to --yes flag.`); } } // Handle spec updates unless skipSpecs flag is set if (options.skipSpecs) { console.log('Skipping spec updates (--skip-specs flag provided).'); } else { // Find specs to update const specUpdates = await findSpecUpdates(changeDir, mainSpecsDir); if (specUpdates.length > 0) { console.log('\nSpecs to update:'); for (const update of specUpdates) { const status = update.exists ? 'update' : 'create'; const capability = path.basename(path.dirname(update.target)); console.log(` ${capability}: ${status}`); } let shouldUpdateSpecs = true; if (!options.yes) { const { confirm } = await import('@inquirer/prompts'); shouldUpdateSpecs = await confirm({ message: 'Proceed with spec updates?', default: true }); if (!shouldUpdateSpecs) { console.log('Skipping spec updates. Proceeding with archive.'); } } if (shouldUpdateSpecs) { // Prepare all updates first (validation pass, no writes) const prepared = []; try { for (const update of specUpdates) { const built = await buildUpdatedSpec(update, changeName); prepared.push({ update, rebuilt: built.rebuilt, counts: built.counts }); } } catch (err) { console.log(String(err.message || err)); console.log('Aborted. No files were changed.'); return; } // All validations passed; pre-validate rebuilt full spec and then write files and display counts let totals = { added: 0, modified: 0, removed: 0, renamed: 0 }; for (const p of prepared) { const specName = path.basename(path.dirname(p.update.target)); if (!skipValidation) { const report = await new Validator().validateSpecContent(specName, p.rebuilt); if (!report.valid) { console.log(chalk.red(`\nValidation errors in rebuilt spec for ${specName} (will not write changes):`)); for (const issue of report.issues) { if (issue.level === 'ERROR') console.log(chalk.red(` ✗ ${issue.message}`)); else if (issue.level === 'WARNING') console.log(chalk.yellow(` ⚠ ${issue.message}`)); } console.log('Aborted. No files were changed.'); return; } } await writeUpdatedSpec(p.update, p.rebuilt, p.counts); totals.added += p.counts.added; totals.modified += p.counts.modified; totals.removed += p.counts.removed; totals.renamed += p.counts.renamed; } console.log(`Totals: + ${totals.added}, ~ ${totals.modified}, - ${totals.removed}, → ${totals.renamed}`); console.log('Specs updated successfully.'); } } } // Create archive directory with date prefix const archiveName = `${this.getArchiveDate()}-${changeName}`; const archivePath = path.join(archiveDir, archiveName); // Check if archive already exists try { await fs.access(archivePath); throw new Error(`Archive '${archiveName}' already exists.`); } catch (error) { if (error.code !== 'ENOENT') { throw error; } } // Create archive directory if needed await fs.mkdir(archiveDir, { recursive: true }); // Move change to archive (uses copy+remove on EPERM/EXDEV, e.g. Windows) await moveDirectory(changeDir, archivePath); console.log(`Change '${changeName}' archived as '${archiveName}'.`); } async selectChange(changesDir) { const { select } = await import('@inquirer/prompts'); // Get all directories in changes (excluding archive) const entries = await fs.readdir(changesDir, { withFileTypes: true }); const changeDirs = entries .filter(entry => entry.isDirectory() && entry.name !== 'archive') .map(entry => entry.name) .sort(); if (changeDirs.length === 0) { console.log('No active changes found.'); return null; } // Build choices with progress inline to avoid duplicate lists let choices = changeDirs.map(name => ({ name, value: name })); try { const progressList = []; for (const id of changeDirs) { const progress = await getTaskProgressForChange(changesDir, id); const status = formatTaskStatus(progress); progressList.push({ id, status }); } const nameWidth = Math.max(...progressList.map(p => p.id.length)); choices = progressList.map(p => ({ name: `${p.id.padEnd(nameWidth)} ${p.status}`, value: p.id })); } catch { // If anything fails, fall back to simple names choices = changeDirs.map(name => ({ name, value: name })); } try { const answer = await select({ message: 'Select a change to archive', choices }); return answer; } catch (error) { // User cancelled (Ctrl+C) return null; } } getArchiveDate() { // Returns date in YYYY-MM-DD format return new Date().toISOString().split('T')[0]; } } //# sourceMappingURL=archive.js.map