mirror of
https://github.com/Fission-AI/OpenSpec.git
synced 2026-11-02 08:23:13 +08:00
* feat(core): add legacy cleanup detection functions for init migration Implement src/core/legacy-cleanup.ts with detection and cleanup functions for all legacy OpenSpec artifact types: Detection functions: - detectLegacyConfigFiles() - checks for config files with OpenSpec markers (CLAUDE.md, CLINE.md, CODEBUDDY.md, COSTRICT.md, QODER.md, IFLOW.md, AGENTS.md, QWEN.md) - detectLegacySlashCommands() - checks for old /openspec:* command directories and files across all 21 tool integrations - detectLegacyStructureFiles() - checks for openspec/AGENTS.md and openspec/project.md (project.md preserved for migration hint) - detectLegacyArtifacts() - orchestrates all detection Utility functions: - hasOpenSpecMarkers() - checks if content has OpenSpec markers - isOnlyOpenSpecContent() - checks if file is 100% OpenSpec content - removeMarkerBlock() - surgically removes marker blocks from mixed content Cleanup functions: - cleanupLegacyArtifacts() - orchestrates removal with proper edge cases: - Deletes files that are 100% OpenSpec content - Removes marker blocks from files with mixed content - Deletes legacy slash command directories and files - Preserves openspec/project.md (shows migration hint only) Formatting functions: - formatDetectionSummary() - formats what was detected before cleanup - formatCleanupSummary() - formats what was cleaned up after This is task 1.1 for the merge-init-experimental change. * feat(utils): add removeMarkerBlock() for surgically removing marker blocks - Add removeMarkerBlock() function to file-system.ts that properly handles inline marker mentions by using findMarkerIndex/isMarkerOnOwnLine - Refactor legacy-cleanup.ts to use the shared utility - Export removeMarkerBlock from utils/index.ts for reusability - Add comprehensive tests for inline marker mention edge cases - Add tests for shell-style markers and various whitespace scenarios The new implementation correctly ignores markers mentioned inline within text and only removes actual marker blocks that are on their own lines. * feat(core): add formatProjectMdMigrationHint() for migration messaging - Add standalone formatProjectMdMigrationHint() function for reusable migration hint output directing users to migrate project.md content to config.yaml's "context:" field - Update formatDetectionSummary() to include the migration hint when project.md is detected (not just in cleanup summary) - Refactor formatCleanupSummary() to use the new function for consistency - Add unit tests for the new function and updated behavior * test(init): rewrite init tests for experimental workflow approach Rewrites the init command tests to verify the new experimental workflow implementation. The new tests cover: - OpenSpec directory structure creation (specs, changes, archive) - config.yaml generation with default schema - 9 Agent Skills creation for various tools (Claude, Cursor, Windsurf, etc.) - 9 slash commands generation using tool-specific adapters - Multi-tool support (--tools all, --tools none, specific tools) - Extend mode (re-running init) - Tool-specific adapters (Gemini TOML, Continue .prompt, etc.) - Error handling for invalid tools and permissions Removes old tests for legacy config file generation (AGENTS.md, CLAUDE.md, project.md, etc.) as the new init command uses Agent Skills instead. * test(update): rewrite tests for skills/commands refresh behavior Update the update command tests to match the new implementation that refreshes skills and opsx commands instead of config files. Changes: - Remove old ToolRegistry import (deleted module) - Rewrite tests to verify skill file updates - Rewrite tests to verify opsx command generation - Add tests for multi-tool support (Claude, Cursor, Qwen, Windsurf) - Add tests for error handling and tool detection - Fix test assertions to match actual skill template names The update command now: - Detects configured tools by checking skill directories - Updates SKILL.md files with latest skill templates - Generates opsx commands using tool-specific adapters * docs(readme): update documentation for new init behavior - Replace tool list with simplified supported tools section (skills-based) - Update init instructions to document --tools flag, --force, and legacy cleanup - Replace project.md with config.yaml documentation - Update workflow examples to use /opsx:* commands instead of /openspec:* - Add command reference table for slash commands - Update Team Adoption and Updating sections for new workflow - Replace Experimental Features with Workflow Customization section * refactor(cli): remove legacy configurators and merge experimental into workflow - Delete src/core/configurators/ directory (ToolRegistry, all config generators) - Delete legacy templates (agents-template, claude-template, project-template, etc.) - Move experimental commands to src/commands/workflow/ with cleaner structure - Remove experimental setup.ts and index.ts (functionality merged into init) - Update CLI to register workflow commands directly instead of through experimental - Update openspec update command to refresh skills/commands instead of config files - Update tests for new command structure * refactor: extract shared modules and move AGENTS.md to root - Move AGENTS.md from openspec/ to project root - Add shared module with tool-detection and skill-generation utilities - Update legacy-cleanup with improved cleanup logic - Enhance update.ts with additional functionality - Add comprehensive tests for shared modules * fix(ui): update welcome screen tagline Change from experimental reference to reflect the merged workflow. * fix: improve Windows cross-platform compatibility - Handle both forward and backward slashes in path parsing - Normalize paths before regex matching for legacy artifact detection - Use regex split for both path separators in tool directory extraction - Handle CRLF line endings when cleaning up multiple blank lines - Add retry logic for test file cleanup to handle Windows file locking * fix(init): use dynamic counts for skills and commands in success message Replace hard-coded "9 skills and 9 commands" with dynamic values from getSkillTemplates().length and getCommandContents().length to prevent the message from diverging from reality when skills/commands change. * fix: various small improvements across init, cleanup, and file handling - Remove shell prompt characters from README bash examples (MD014) - Show actual config filename (config.yaml vs config.yml) in init output - Include hasProjectMd in hasLegacyArtifacts to show migration hint - Add existence check before AGENTS.md deletion to avoid spurious errors - Preserve leading whitespace and original newline style in file operations - Use dynamic tool list from CommandAdapterRegistry in tests
158 lines
6.6 KiB
TypeScript
158 lines
6.6 KiB
TypeScript
import { afterAll, describe, it, expect } from 'vitest';
|
|
import { promises as fs } from 'fs';
|
|
import path from 'path';
|
|
import { tmpdir } from 'os';
|
|
import { runCLI, cliProjectRoot } from '../helpers/run-cli.js';
|
|
import { AI_TOOLS } from '../../src/core/config.js';
|
|
|
|
async function fileExists(filePath: string): Promise<boolean> {
|
|
try {
|
|
await fs.access(filePath);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
const tempRoots: string[] = [];
|
|
|
|
async function prepareFixture(fixtureName: string): Promise<string> {
|
|
const base = await fs.mkdtemp(path.join(tmpdir(), 'openspec-cli-e2e-'));
|
|
tempRoots.push(base);
|
|
const projectDir = path.join(base, 'project');
|
|
await fs.mkdir(projectDir, { recursive: true });
|
|
const fixtureDir = path.join(cliProjectRoot, 'test', 'fixtures', fixtureName);
|
|
await fs.cp(fixtureDir, projectDir, { recursive: true });
|
|
return projectDir;
|
|
}
|
|
|
|
afterAll(async () => {
|
|
await Promise.all(tempRoots.map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
|
});
|
|
|
|
describe('openspec CLI e2e basics', () => {
|
|
it('shows help output', async () => {
|
|
const result = await runCLI(['--help']);
|
|
expect(result.exitCode).toBe(0);
|
|
expect(result.stdout).toContain('Usage: openspec');
|
|
expect(result.stderr).toBe('');
|
|
|
|
});
|
|
|
|
it('shows dynamic tool ids in init help', async () => {
|
|
const result = await runCLI(['init', '--help']);
|
|
expect(result.exitCode).toBe(0);
|
|
|
|
const expectedTools = AI_TOOLS.filter((tool) => tool.available)
|
|
.map((tool) => tool.value)
|
|
.join(', ');
|
|
const normalizedOutput = result.stdout.replace(/\s+/g, ' ').trim();
|
|
expect(normalizedOutput).toContain(
|
|
`Use "all", "none", or a comma-separated list of: ${expectedTools}`
|
|
);
|
|
});
|
|
|
|
it('reports the package version', async () => {
|
|
const pkgRaw = await fs.readFile(path.join(cliProjectRoot, 'package.json'), 'utf-8');
|
|
const pkg = JSON.parse(pkgRaw);
|
|
const result = await runCLI(['--version']);
|
|
expect(result.exitCode).toBe(0);
|
|
expect(result.stdout.trim()).toBe(pkg.version);
|
|
});
|
|
|
|
it('validates the tmp-init fixture with --all --json', async () => {
|
|
const projectDir = await prepareFixture('tmp-init');
|
|
const result = await runCLI(['validate', '--all', '--json'], { cwd: projectDir });
|
|
expect(result.exitCode).toBe(0);
|
|
const output = result.stdout.trim();
|
|
expect(output).not.toBe('');
|
|
const json = JSON.parse(output);
|
|
expect(json.summary?.totals?.failed).toBe(0);
|
|
expect(json.items.some((item: any) => item.id === 'c1' && item.type === 'change')).toBe(true);
|
|
});
|
|
|
|
it('returns an error for unknown items in the fixture', async () => {
|
|
const projectDir = await prepareFixture('tmp-init');
|
|
const result = await runCLI(['validate', 'does-not-exist'], { cwd: projectDir });
|
|
expect(result.exitCode).toBe(1);
|
|
expect(result.stderr).toContain("Unknown item 'does-not-exist'");
|
|
});
|
|
|
|
describe('init command non-interactive options', () => {
|
|
it('initializes with --tools all option', async () => {
|
|
const projectDir = await prepareFixture('tmp-init');
|
|
const emptyProjectDir = path.join(projectDir, '..', 'empty-project');
|
|
await fs.mkdir(emptyProjectDir, { recursive: true });
|
|
|
|
const codexHome = path.join(emptyProjectDir, '.codex');
|
|
const result = await runCLI(['init', '--tools', 'all'], {
|
|
cwd: emptyProjectDir,
|
|
env: { CODEX_HOME: codexHome },
|
|
});
|
|
expect(result.exitCode).toBe(0);
|
|
expect(result.stdout).toContain('OpenSpec Setup Complete');
|
|
|
|
// Check that skills were created for multiple tools
|
|
const claudeSkillPath = path.join(emptyProjectDir, '.claude/skills/openspec-explore/SKILL.md');
|
|
const cursorSkillPath = path.join(emptyProjectDir, '.cursor/skills/openspec-explore/SKILL.md');
|
|
expect(await fileExists(claudeSkillPath)).toBe(true);
|
|
expect(await fileExists(cursorSkillPath)).toBe(true);
|
|
});
|
|
|
|
it('initializes with --tools list option', async () => {
|
|
const projectDir = await prepareFixture('tmp-init');
|
|
const emptyProjectDir = path.join(projectDir, '..', 'empty-project');
|
|
await fs.mkdir(emptyProjectDir, { recursive: true });
|
|
|
|
const result = await runCLI(['init', '--tools', 'claude'], { cwd: emptyProjectDir });
|
|
expect(result.exitCode).toBe(0);
|
|
expect(result.stdout).toContain('OpenSpec Setup Complete');
|
|
expect(result.stdout).toContain('Claude Code');
|
|
|
|
// New init creates skills, not CLAUDE.md
|
|
const claudeSkillPath = path.join(emptyProjectDir, '.claude/skills/openspec-explore/SKILL.md');
|
|
const cursorSkillPath = path.join(emptyProjectDir, '.cursor/skills/openspec-explore/SKILL.md');
|
|
expect(await fileExists(claudeSkillPath)).toBe(true);
|
|
expect(await fileExists(cursorSkillPath)).toBe(false); // Not selected
|
|
});
|
|
|
|
it('initializes with --tools none option', async () => {
|
|
const projectDir = await prepareFixture('tmp-init');
|
|
const emptyProjectDir = path.join(projectDir, '..', 'empty-project');
|
|
await fs.mkdir(emptyProjectDir, { recursive: true });
|
|
|
|
const result = await runCLI(['init', '--tools', 'none'], { cwd: emptyProjectDir });
|
|
expect(result.exitCode).toBe(0);
|
|
expect(result.stdout).toContain('OpenSpec Setup Complete');
|
|
|
|
// With --tools none, no tool skills should be created
|
|
const claudeSkillPath = path.join(emptyProjectDir, '.claude/skills/openspec-explore/SKILL.md');
|
|
const cursorSkillPath = path.join(emptyProjectDir, '.cursor/skills/openspec-explore/SKILL.md');
|
|
|
|
expect(await fileExists(claudeSkillPath)).toBe(false);
|
|
expect(await fileExists(cursorSkillPath)).toBe(false);
|
|
});
|
|
|
|
it('returns error for invalid tool names', async () => {
|
|
const projectDir = await prepareFixture('tmp-init');
|
|
const emptyProjectDir = path.join(projectDir, '..', 'empty-project');
|
|
await fs.mkdir(emptyProjectDir, { recursive: true });
|
|
|
|
const result = await runCLI(['init', '--tools', 'invalid-tool'], { cwd: emptyProjectDir });
|
|
expect(result.exitCode).toBe(1);
|
|
expect(result.stderr).toContain('Invalid tool(s): invalid-tool');
|
|
expect(result.stderr).toContain('Available values:');
|
|
});
|
|
|
|
it('returns error when combining reserved keywords with explicit ids', async () => {
|
|
const projectDir = await prepareFixture('tmp-init');
|
|
const emptyProjectDir = path.join(projectDir, '..', 'empty-project');
|
|
await fs.mkdir(emptyProjectDir, { recursive: true });
|
|
|
|
const result = await runCLI(['init', '--tools', 'all,claude'], { cwd: emptyProjectDir });
|
|
expect(result.exitCode).toBe(1);
|
|
expect(result.stderr).toContain('Cannot combine reserved values "all" or "none" with specific tool IDs');
|
|
});
|
|
});
|
|
});
|