mirror of
https://github.com/Fission-AI/OpenSpec.git
synced 2026-10-02 05:24:34 +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
1350 lines
43 KiB
TypeScript
1350 lines
43 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
import { UpdateCommand } from '../../src/core/update.js';
|
|
import { FileSystemUtils } from '../../src/utils/file-system.js';
|
|
import { OPENSPEC_MARKERS } from '../../src/core/config.js';
|
|
import path from 'path';
|
|
import fs from 'fs/promises';
|
|
import os from 'os';
|
|
import { randomUUID } from 'crypto';
|
|
|
|
describe('UpdateCommand', () => {
|
|
let testDir: string;
|
|
let updateCommand: UpdateCommand;
|
|
|
|
beforeEach(async () => {
|
|
// Create a temporary test directory
|
|
testDir = path.join(os.tmpdir(), `openspec-test-${randomUUID()}`);
|
|
await fs.mkdir(testDir, { recursive: true });
|
|
|
|
// Create openspec directory
|
|
const openspecDir = path.join(testDir, 'openspec');
|
|
await fs.mkdir(openspecDir, { recursive: true });
|
|
|
|
updateCommand = new UpdateCommand();
|
|
|
|
// Clear all mocks before each test
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
// Restore all mocks after each test
|
|
vi.restoreAllMocks();
|
|
|
|
// Clean up test directory
|
|
await fs.rm(testDir, { recursive: true, force: true });
|
|
});
|
|
|
|
describe('basic validation', () => {
|
|
it('should throw error if openspec directory does not exist', async () => {
|
|
// Remove openspec directory
|
|
await fs.rm(path.join(testDir, 'openspec'), {
|
|
recursive: true,
|
|
force: true,
|
|
});
|
|
|
|
await expect(updateCommand.execute(testDir)).rejects.toThrow(
|
|
"No OpenSpec directory found. Run 'openspec init' first."
|
|
);
|
|
});
|
|
|
|
it('should report no configured tools when none exist', async () => {
|
|
const consoleSpy = vi.spyOn(console, 'log');
|
|
|
|
await updateCommand.execute(testDir);
|
|
|
|
expect(consoleSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining('No configured tools found')
|
|
);
|
|
|
|
consoleSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe('skill updates', () => {
|
|
it('should update skill files for configured Claude tool', async () => {
|
|
// Set up a configured Claude tool by creating skill directories
|
|
const skillsDir = path.join(testDir, '.claude', 'skills');
|
|
const exploreSkillDir = path.join(skillsDir, 'openspec-explore');
|
|
await fs.mkdir(exploreSkillDir, { recursive: true });
|
|
|
|
// Create an existing skill file
|
|
const oldSkillContent = `---
|
|
name: openspec-explore (old)
|
|
description: Old description
|
|
license: MIT
|
|
compatibility: Requires openspec CLI.
|
|
metadata:
|
|
author: openspec
|
|
version: "0.9"
|
|
---
|
|
|
|
Old instructions content
|
|
`;
|
|
await fs.writeFile(
|
|
path.join(exploreSkillDir, 'SKILL.md'),
|
|
oldSkillContent
|
|
);
|
|
|
|
const consoleSpy = vi.spyOn(console, 'log');
|
|
|
|
await updateCommand.execute(testDir);
|
|
|
|
// Check skill file was updated
|
|
const updatedSkill = await fs.readFile(
|
|
path.join(exploreSkillDir, 'SKILL.md'),
|
|
'utf-8'
|
|
);
|
|
expect(updatedSkill).toContain('name: openspec-explore');
|
|
expect(updatedSkill).not.toContain('Old instructions content');
|
|
expect(updatedSkill).toContain('license: MIT');
|
|
|
|
// Check console output
|
|
expect(consoleSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining('Updating 1 tool(s): claude')
|
|
);
|
|
|
|
consoleSpy.mockRestore();
|
|
});
|
|
|
|
it('should update all 9 skill files when tool is configured', async () => {
|
|
// Set up a configured tool with all skill directories
|
|
const skillsDir = path.join(testDir, '.claude', 'skills');
|
|
const skillNames = [
|
|
'openspec-explore',
|
|
'openspec-new-change',
|
|
'openspec-continue-change',
|
|
'openspec-apply-change',
|
|
'openspec-ff-change',
|
|
'openspec-sync-specs',
|
|
'openspec-archive-change',
|
|
'openspec-bulk-archive-change',
|
|
'openspec-verify-change',
|
|
];
|
|
|
|
// Create at least one skill to mark tool as configured
|
|
await fs.mkdir(path.join(skillsDir, 'openspec-explore'), {
|
|
recursive: true,
|
|
});
|
|
await fs.writeFile(
|
|
path.join(skillsDir, 'openspec-explore', 'SKILL.md'),
|
|
'old content'
|
|
);
|
|
|
|
await updateCommand.execute(testDir);
|
|
|
|
// Verify all skill files were created/updated
|
|
for (const skillName of skillNames) {
|
|
const skillFile = path.join(skillsDir, skillName, 'SKILL.md');
|
|
const exists = await FileSystemUtils.fileExists(skillFile);
|
|
expect(exists).toBe(true);
|
|
|
|
const content = await fs.readFile(skillFile, 'utf-8');
|
|
expect(content).toContain('---');
|
|
expect(content).toContain('name:');
|
|
expect(content).toContain('description:');
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('command updates', () => {
|
|
it('should update opsx commands for configured Claude tool', async () => {
|
|
// Set up a configured Claude tool
|
|
const skillsDir = path.join(testDir, '.claude', 'skills');
|
|
await fs.mkdir(path.join(skillsDir, 'openspec-explore'), {
|
|
recursive: true,
|
|
});
|
|
await fs.writeFile(
|
|
path.join(skillsDir, 'openspec-explore', 'SKILL.md'),
|
|
'old content'
|
|
);
|
|
|
|
await updateCommand.execute(testDir);
|
|
|
|
// Check opsx command files were created
|
|
const commandsDir = path.join(testDir, '.claude', 'commands', 'opsx');
|
|
const exploreCmd = path.join(commandsDir, 'explore.md');
|
|
const exists = await FileSystemUtils.fileExists(exploreCmd);
|
|
expect(exists).toBe(true);
|
|
|
|
const content = await fs.readFile(exploreCmd, 'utf-8');
|
|
expect(content).toContain('---');
|
|
expect(content).toContain('name:');
|
|
expect(content).toContain('description:');
|
|
expect(content).toContain('category:');
|
|
expect(content).toContain('tags:');
|
|
});
|
|
|
|
it('should update all 9 opsx commands when tool is configured', async () => {
|
|
// Set up a configured tool
|
|
const skillsDir = path.join(testDir, '.claude', 'skills');
|
|
await fs.mkdir(path.join(skillsDir, 'openspec-explore'), {
|
|
recursive: true,
|
|
});
|
|
await fs.writeFile(
|
|
path.join(skillsDir, 'openspec-explore', 'SKILL.md'),
|
|
'old content'
|
|
);
|
|
|
|
await updateCommand.execute(testDir);
|
|
|
|
const commandIds = [
|
|
'explore',
|
|
'new',
|
|
'continue',
|
|
'apply',
|
|
'ff',
|
|
'sync',
|
|
'archive',
|
|
'bulk-archive',
|
|
'verify',
|
|
];
|
|
|
|
const commandsDir = path.join(testDir, '.claude', 'commands', 'opsx');
|
|
for (const cmdId of commandIds) {
|
|
const cmdFile = path.join(commandsDir, `${cmdId}.md`);
|
|
const exists = await FileSystemUtils.fileExists(cmdFile);
|
|
expect(exists).toBe(true);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('multi-tool support', () => {
|
|
it('should update multiple configured tools', async () => {
|
|
// Set up Claude
|
|
const claudeSkillsDir = path.join(testDir, '.claude', 'skills');
|
|
await fs.mkdir(path.join(claudeSkillsDir, 'openspec-explore'), {
|
|
recursive: true,
|
|
});
|
|
await fs.writeFile(
|
|
path.join(claudeSkillsDir, 'openspec-explore', 'SKILL.md'),
|
|
'old'
|
|
);
|
|
|
|
// Set up Cursor
|
|
const cursorSkillsDir = path.join(testDir, '.cursor', 'skills');
|
|
await fs.mkdir(path.join(cursorSkillsDir, 'openspec-explore'), {
|
|
recursive: true,
|
|
});
|
|
await fs.writeFile(
|
|
path.join(cursorSkillsDir, 'openspec-explore', 'SKILL.md'),
|
|
'old'
|
|
);
|
|
|
|
const consoleSpy = vi.spyOn(console, 'log');
|
|
|
|
await updateCommand.execute(testDir);
|
|
|
|
// Both tools should be updated
|
|
expect(consoleSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining('Updating 2 tool(s)')
|
|
);
|
|
|
|
// Verify Claude skills updated
|
|
const claudeSkill = await fs.readFile(
|
|
path.join(claudeSkillsDir, 'openspec-explore', 'SKILL.md'),
|
|
'utf-8'
|
|
);
|
|
expect(claudeSkill).toContain('name: openspec-explore');
|
|
|
|
// Verify Cursor skills updated
|
|
const cursorSkill = await fs.readFile(
|
|
path.join(cursorSkillsDir, 'openspec-explore', 'SKILL.md'),
|
|
'utf-8'
|
|
);
|
|
expect(cursorSkill).toContain('name: openspec-explore');
|
|
|
|
consoleSpy.mockRestore();
|
|
});
|
|
|
|
it('should update Qwen tool with correct command format', async () => {
|
|
// Set up Qwen
|
|
const qwenSkillsDir = path.join(testDir, '.qwen', 'skills');
|
|
await fs.mkdir(path.join(qwenSkillsDir, 'openspec-explore'), {
|
|
recursive: true,
|
|
});
|
|
await fs.writeFile(
|
|
path.join(qwenSkillsDir, 'openspec-explore', 'SKILL.md'),
|
|
'old'
|
|
);
|
|
|
|
await updateCommand.execute(testDir);
|
|
|
|
// Check Qwen command format (TOML) - Qwen uses flat path structure: opsx-<id>.toml
|
|
const qwenCmd = path.join(
|
|
testDir,
|
|
'.qwen',
|
|
'commands',
|
|
'opsx-explore.toml'
|
|
);
|
|
const exists = await FileSystemUtils.fileExists(qwenCmd);
|
|
expect(exists).toBe(true);
|
|
|
|
const content = await fs.readFile(qwenCmd, 'utf-8');
|
|
expect(content).toContain('description =');
|
|
expect(content).toContain('prompt =');
|
|
});
|
|
|
|
it('should update Windsurf tool with correct command format', async () => {
|
|
// Set up Windsurf
|
|
const windsurfSkillsDir = path.join(testDir, '.windsurf', 'skills');
|
|
await fs.mkdir(path.join(windsurfSkillsDir, 'openspec-explore'), {
|
|
recursive: true,
|
|
});
|
|
await fs.writeFile(
|
|
path.join(windsurfSkillsDir, 'openspec-explore', 'SKILL.md'),
|
|
'old'
|
|
);
|
|
|
|
await updateCommand.execute(testDir);
|
|
|
|
// Check Windsurf command format
|
|
const windsurfCmd = path.join(
|
|
testDir,
|
|
'.windsurf',
|
|
'commands',
|
|
'opsx',
|
|
'explore.md'
|
|
);
|
|
const exists = await FileSystemUtils.fileExists(windsurfCmd);
|
|
expect(exists).toBe(true);
|
|
|
|
const content = await fs.readFile(windsurfCmd, 'utf-8');
|
|
expect(content).toContain('---');
|
|
expect(content).toContain('name:');
|
|
});
|
|
});
|
|
|
|
describe('error handling', () => {
|
|
it('should handle tool update failures gracefully', async () => {
|
|
// Set up a configured tool
|
|
const skillsDir = path.join(testDir, '.claude', 'skills');
|
|
await fs.mkdir(path.join(skillsDir, 'openspec-explore'), {
|
|
recursive: true,
|
|
});
|
|
await fs.writeFile(
|
|
path.join(skillsDir, 'openspec-explore', 'SKILL.md'),
|
|
'old'
|
|
);
|
|
|
|
// Mock writeFile to fail for skills
|
|
const originalWriteFile = FileSystemUtils.writeFile.bind(FileSystemUtils);
|
|
const writeSpy = vi
|
|
.spyOn(FileSystemUtils, 'writeFile')
|
|
.mockImplementation(async (filePath, content) => {
|
|
if (filePath.includes('SKILL.md')) {
|
|
throw new Error('EACCES: permission denied');
|
|
}
|
|
return originalWriteFile(filePath, content);
|
|
});
|
|
|
|
const consoleSpy = vi.spyOn(console, 'log');
|
|
|
|
// Should not throw
|
|
await updateCommand.execute(testDir);
|
|
|
|
// Should report failure
|
|
expect(consoleSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining('Failed')
|
|
);
|
|
|
|
writeSpy.mockRestore();
|
|
consoleSpy.mockRestore();
|
|
});
|
|
|
|
it('should continue updating other tools when one fails', async () => {
|
|
// Set up Claude and Cursor
|
|
const claudeSkillsDir = path.join(testDir, '.claude', 'skills');
|
|
await fs.mkdir(path.join(claudeSkillsDir, 'openspec-explore'), {
|
|
recursive: true,
|
|
});
|
|
await fs.writeFile(
|
|
path.join(claudeSkillsDir, 'openspec-explore', 'SKILL.md'),
|
|
'old'
|
|
);
|
|
|
|
const cursorSkillsDir = path.join(testDir, '.cursor', 'skills');
|
|
await fs.mkdir(path.join(cursorSkillsDir, 'openspec-explore'), {
|
|
recursive: true,
|
|
});
|
|
await fs.writeFile(
|
|
path.join(cursorSkillsDir, 'openspec-explore', 'SKILL.md'),
|
|
'old'
|
|
);
|
|
|
|
// Mock writeFile to fail only for Claude
|
|
const originalWriteFile = FileSystemUtils.writeFile.bind(FileSystemUtils);
|
|
const writeSpy = vi
|
|
.spyOn(FileSystemUtils, 'writeFile')
|
|
.mockImplementation(async (filePath, content) => {
|
|
if (filePath.includes('.claude') && filePath.includes('SKILL.md')) {
|
|
throw new Error('EACCES: permission denied');
|
|
}
|
|
return originalWriteFile(filePath, content);
|
|
});
|
|
|
|
const consoleSpy = vi.spyOn(console, 'log');
|
|
|
|
await updateCommand.execute(testDir);
|
|
|
|
// Cursor should still be updated - check the actual format from ora spinner
|
|
expect(consoleSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining('Updated: Cursor')
|
|
);
|
|
|
|
// Claude should be reported as failed
|
|
expect(consoleSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining('Failed')
|
|
);
|
|
|
|
writeSpy.mockRestore();
|
|
consoleSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe('tool detection', () => {
|
|
it('should detect tool as configured only when skill file exists', async () => {
|
|
// Create skills directory but no skill files
|
|
const skillsDir = path.join(testDir, '.claude', 'skills');
|
|
await fs.mkdir(skillsDir, { recursive: true });
|
|
|
|
const consoleSpy = vi.spyOn(console, 'log');
|
|
|
|
await updateCommand.execute(testDir);
|
|
|
|
// Should report no configured tools
|
|
expect(consoleSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining('No configured tools found')
|
|
);
|
|
|
|
consoleSpy.mockRestore();
|
|
});
|
|
|
|
it('should detect tool when any single skill exists', async () => {
|
|
// Create only one skill file
|
|
const skillDir = path.join(
|
|
testDir,
|
|
'.claude',
|
|
'skills',
|
|
'openspec-archive-change'
|
|
);
|
|
await fs.mkdir(skillDir, { recursive: true });
|
|
await fs.writeFile(path.join(skillDir, 'SKILL.md'), 'old');
|
|
|
|
const consoleSpy = vi.spyOn(console, 'log');
|
|
|
|
await updateCommand.execute(testDir);
|
|
|
|
// Should detect and update Claude
|
|
expect(consoleSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining('Updating 1 tool(s): claude')
|
|
);
|
|
|
|
consoleSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe('skill content validation', () => {
|
|
it('should generate valid YAML frontmatter in skill files', async () => {
|
|
// Set up a configured tool
|
|
const skillsDir = path.join(testDir, '.claude', 'skills');
|
|
await fs.mkdir(path.join(skillsDir, 'openspec-explore'), {
|
|
recursive: true,
|
|
});
|
|
await fs.writeFile(
|
|
path.join(skillsDir, 'openspec-explore', 'SKILL.md'),
|
|
'old'
|
|
);
|
|
|
|
await updateCommand.execute(testDir);
|
|
|
|
const skillContent = await fs.readFile(
|
|
path.join(skillsDir, 'openspec-explore', 'SKILL.md'),
|
|
'utf-8'
|
|
);
|
|
|
|
// Validate frontmatter structure
|
|
expect(skillContent).toMatch(/^---\n/);
|
|
expect(skillContent).toContain('name:');
|
|
expect(skillContent).toContain('description:');
|
|
expect(skillContent).toContain('license:');
|
|
expect(skillContent).toContain('compatibility:');
|
|
expect(skillContent).toContain('metadata:');
|
|
expect(skillContent).toContain('author:');
|
|
expect(skillContent).toContain('version:');
|
|
expect(skillContent).toMatch(/---\n\n/);
|
|
});
|
|
|
|
it('should include proper instructions in skill files', async () => {
|
|
// Set up a configured tool
|
|
const skillsDir = path.join(testDir, '.claude', 'skills');
|
|
await fs.mkdir(path.join(skillsDir, 'openspec-apply-change'), {
|
|
recursive: true,
|
|
});
|
|
await fs.writeFile(
|
|
path.join(skillsDir, 'openspec-apply-change', 'SKILL.md'),
|
|
'old'
|
|
);
|
|
|
|
await updateCommand.execute(testDir);
|
|
|
|
const skillContent = await fs.readFile(
|
|
path.join(skillsDir, 'openspec-apply-change', 'SKILL.md'),
|
|
'utf-8'
|
|
);
|
|
|
|
// Apply skill should contain implementation instructions
|
|
expect(skillContent.toLowerCase()).toContain('task');
|
|
});
|
|
});
|
|
|
|
describe('success output', () => {
|
|
it('should display success message with tool name', async () => {
|
|
// Set up a configured tool
|
|
const skillsDir = path.join(testDir, '.claude', 'skills');
|
|
await fs.mkdir(path.join(skillsDir, 'openspec-explore'), {
|
|
recursive: true,
|
|
});
|
|
await fs.writeFile(
|
|
path.join(skillsDir, 'openspec-explore', 'SKILL.md'),
|
|
'old'
|
|
);
|
|
|
|
const consoleSpy = vi.spyOn(console, 'log');
|
|
|
|
await updateCommand.execute(testDir);
|
|
|
|
// The success output uses "✓ Updated: <name>"
|
|
expect(consoleSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining('Updated: Claude Code')
|
|
);
|
|
|
|
consoleSpy.mockRestore();
|
|
});
|
|
|
|
it('should suggest IDE restart after update', async () => {
|
|
// Set up a configured tool
|
|
const skillsDir = path.join(testDir, '.claude', 'skills');
|
|
await fs.mkdir(path.join(skillsDir, 'openspec-explore'), {
|
|
recursive: true,
|
|
});
|
|
await fs.writeFile(
|
|
path.join(skillsDir, 'openspec-explore', 'SKILL.md'),
|
|
'old'
|
|
);
|
|
|
|
const consoleSpy = vi.spyOn(console, 'log');
|
|
|
|
await updateCommand.execute(testDir);
|
|
|
|
expect(consoleSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining('Restart your IDE')
|
|
);
|
|
|
|
consoleSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe('smart update detection', () => {
|
|
it('should show "up to date" message when skills have current version', async () => {
|
|
// Set up a configured tool with current version
|
|
const skillsDir = path.join(testDir, '.claude', 'skills');
|
|
await fs.mkdir(path.join(skillsDir, 'openspec-explore'), {
|
|
recursive: true,
|
|
});
|
|
|
|
// Use the current package version in generatedBy
|
|
const { version } = await import('../../package.json');
|
|
await fs.writeFile(
|
|
path.join(skillsDir, 'openspec-explore', 'SKILL.md'),
|
|
`---
|
|
name: openspec-explore
|
|
metadata:
|
|
author: openspec
|
|
version: "1.0"
|
|
generatedBy: "${version}"
|
|
---
|
|
|
|
Content here
|
|
`
|
|
);
|
|
|
|
const consoleSpy = vi.spyOn(console, 'log');
|
|
|
|
await updateCommand.execute(testDir);
|
|
|
|
expect(consoleSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining('up to date')
|
|
);
|
|
expect(consoleSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining('--force')
|
|
);
|
|
|
|
consoleSpy.mockRestore();
|
|
});
|
|
|
|
it('should detect update needed when generatedBy is missing', async () => {
|
|
// Set up a configured tool without generatedBy
|
|
const skillsDir = path.join(testDir, '.claude', 'skills');
|
|
await fs.mkdir(path.join(skillsDir, 'openspec-explore'), {
|
|
recursive: true,
|
|
});
|
|
await fs.writeFile(
|
|
path.join(skillsDir, 'openspec-explore', 'SKILL.md'),
|
|
`---
|
|
name: openspec-explore
|
|
metadata:
|
|
author: openspec
|
|
version: "1.0"
|
|
---
|
|
|
|
Legacy content without generatedBy
|
|
`
|
|
);
|
|
|
|
const consoleSpy = vi.spyOn(console, 'log');
|
|
|
|
await updateCommand.execute(testDir);
|
|
|
|
// Should show "unknown → version" in the update message
|
|
expect(consoleSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining('unknown')
|
|
);
|
|
|
|
consoleSpy.mockRestore();
|
|
});
|
|
|
|
it('should detect update needed when version differs', async () => {
|
|
// Set up a configured tool with old version
|
|
const skillsDir = path.join(testDir, '.claude', 'skills');
|
|
await fs.mkdir(path.join(skillsDir, 'openspec-explore'), {
|
|
recursive: true,
|
|
});
|
|
await fs.writeFile(
|
|
path.join(skillsDir, 'openspec-explore', 'SKILL.md'),
|
|
`---
|
|
name: openspec-explore
|
|
metadata:
|
|
generatedBy: "0.1.0"
|
|
---
|
|
|
|
Old version content
|
|
`
|
|
);
|
|
|
|
const consoleSpy = vi.spyOn(console, 'log');
|
|
|
|
await updateCommand.execute(testDir);
|
|
|
|
// Should show version transition
|
|
expect(consoleSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining('0.1.0')
|
|
);
|
|
|
|
consoleSpy.mockRestore();
|
|
});
|
|
|
|
it('should embed generatedBy in updated skill files', async () => {
|
|
// Set up a configured tool without generatedBy
|
|
const skillsDir = path.join(testDir, '.claude', 'skills');
|
|
await fs.mkdir(path.join(skillsDir, 'openspec-explore'), {
|
|
recursive: true,
|
|
});
|
|
await fs.writeFile(
|
|
path.join(skillsDir, 'openspec-explore', 'SKILL.md'),
|
|
'old content without version'
|
|
);
|
|
|
|
await updateCommand.execute(testDir);
|
|
|
|
const updatedContent = await fs.readFile(
|
|
path.join(skillsDir, 'openspec-explore', 'SKILL.md'),
|
|
'utf-8'
|
|
);
|
|
|
|
// Should contain generatedBy field
|
|
expect(updatedContent).toMatch(/generatedBy:\s*["']\d+\.\d+\.\d+["']/);
|
|
});
|
|
});
|
|
|
|
describe('--force flag', () => {
|
|
it('should update when force is true even if up to date', async () => {
|
|
// Set up a configured tool with current version
|
|
const skillsDir = path.join(testDir, '.claude', 'skills');
|
|
await fs.mkdir(path.join(skillsDir, 'openspec-explore'), {
|
|
recursive: true,
|
|
});
|
|
|
|
const { version } = await import('../../package.json');
|
|
await fs.writeFile(
|
|
path.join(skillsDir, 'openspec-explore', 'SKILL.md'),
|
|
`---
|
|
metadata:
|
|
generatedBy: "${version}"
|
|
---
|
|
Content
|
|
`
|
|
);
|
|
|
|
const consoleSpy = vi.spyOn(console, 'log');
|
|
|
|
// Create update command with force option
|
|
const forceUpdateCommand = new UpdateCommand({ force: true });
|
|
await forceUpdateCommand.execute(testDir);
|
|
|
|
// Should show "Force updating" message
|
|
expect(consoleSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining('Force updating')
|
|
);
|
|
|
|
// Should show updated message
|
|
expect(consoleSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining('Updated: Claude Code')
|
|
);
|
|
|
|
consoleSpy.mockRestore();
|
|
});
|
|
|
|
it('should not show --force hint when force is used', async () => {
|
|
// Set up a configured tool
|
|
const skillsDir = path.join(testDir, '.claude', 'skills');
|
|
await fs.mkdir(path.join(skillsDir, 'openspec-explore'), {
|
|
recursive: true,
|
|
});
|
|
await fs.writeFile(
|
|
path.join(skillsDir, 'openspec-explore', 'SKILL.md'),
|
|
'old content'
|
|
);
|
|
|
|
const consoleSpy = vi.spyOn(console, 'log');
|
|
|
|
const forceUpdateCommand = new UpdateCommand({ force: true });
|
|
await forceUpdateCommand.execute(testDir);
|
|
|
|
// Get all console.log calls as strings
|
|
const allCalls = consoleSpy.mock.calls.map(call =>
|
|
call.map(arg => String(arg)).join(' ')
|
|
);
|
|
|
|
// Should not show "Use --force" since force was used
|
|
const hasForceHint = allCalls.some(call => call.includes('Use --force'));
|
|
expect(hasForceHint).toBe(false);
|
|
|
|
consoleSpy.mockRestore();
|
|
});
|
|
|
|
it('should update all tools when force is used with mixed versions', async () => {
|
|
// Set up Claude with current version
|
|
const { version } = await import('../../package.json');
|
|
const claudeSkillDir = path.join(testDir, '.claude', 'skills', 'openspec-explore');
|
|
await fs.mkdir(claudeSkillDir, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(claudeSkillDir, 'SKILL.md'),
|
|
`---
|
|
metadata:
|
|
generatedBy: "${version}"
|
|
---
|
|
`
|
|
);
|
|
|
|
// Set up Cursor with old version
|
|
const cursorSkillDir = path.join(testDir, '.cursor', 'skills', 'openspec-explore');
|
|
await fs.mkdir(cursorSkillDir, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(cursorSkillDir, 'SKILL.md'),
|
|
`---
|
|
metadata:
|
|
generatedBy: "0.1.0"
|
|
---
|
|
`
|
|
);
|
|
|
|
const consoleSpy = vi.spyOn(console, 'log');
|
|
|
|
const forceUpdateCommand = new UpdateCommand({ force: true });
|
|
await forceUpdateCommand.execute(testDir);
|
|
|
|
// Should show both tools being force updated
|
|
expect(consoleSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining('Force updating 2 tool(s)')
|
|
);
|
|
|
|
consoleSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe('version tracking', () => {
|
|
it('should show version in success message', async () => {
|
|
// Set up a configured tool
|
|
const skillsDir = path.join(testDir, '.claude', 'skills');
|
|
await fs.mkdir(path.join(skillsDir, 'openspec-explore'), {
|
|
recursive: true,
|
|
});
|
|
await fs.writeFile(
|
|
path.join(skillsDir, 'openspec-explore', 'SKILL.md'),
|
|
'old'
|
|
);
|
|
|
|
const consoleSpy = vi.spyOn(console, 'log');
|
|
|
|
await updateCommand.execute(testDir);
|
|
|
|
// Should show version in success message
|
|
const { version } = await import('../../package.json');
|
|
expect(consoleSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining(`(v${version})`)
|
|
);
|
|
|
|
consoleSpy.mockRestore();
|
|
});
|
|
|
|
it('should only update tools that need updating', async () => {
|
|
// Set up Claude with old version (needs update)
|
|
const claudeSkillDir = path.join(testDir, '.claude', 'skills', 'openspec-explore');
|
|
await fs.mkdir(claudeSkillDir, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(claudeSkillDir, 'SKILL.md'),
|
|
`---
|
|
metadata:
|
|
generatedBy: "0.1.0"
|
|
---
|
|
`
|
|
);
|
|
|
|
// Set up Cursor with current version (up to date)
|
|
const { version } = await import('../../package.json');
|
|
const cursorSkillDir = path.join(testDir, '.cursor', 'skills', 'openspec-explore');
|
|
await fs.mkdir(cursorSkillDir, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(cursorSkillDir, 'SKILL.md'),
|
|
`---
|
|
metadata:
|
|
generatedBy: "${version}"
|
|
---
|
|
`
|
|
);
|
|
|
|
const consoleSpy = vi.spyOn(console, 'log');
|
|
|
|
await updateCommand.execute(testDir);
|
|
|
|
// Should show only Claude being updated
|
|
expect(consoleSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining('Updating 1 tool(s)')
|
|
);
|
|
|
|
// Should mention Cursor is already up to date
|
|
expect(consoleSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining('Already up to date: cursor')
|
|
);
|
|
|
|
consoleSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe('legacy cleanup', () => {
|
|
it('should detect and auto-cleanup legacy files with --force flag', async () => {
|
|
// Set up a configured tool
|
|
const skillsDir = path.join(testDir, '.claude', 'skills');
|
|
await fs.mkdir(path.join(skillsDir, 'openspec-explore'), {
|
|
recursive: true,
|
|
});
|
|
await fs.writeFile(
|
|
path.join(skillsDir, 'openspec-explore', 'SKILL.md'),
|
|
'old'
|
|
);
|
|
|
|
// Create legacy CLAUDE.md with OpenSpec markers
|
|
const legacyContent = `${OPENSPEC_MARKERS.start}
|
|
# OpenSpec Instructions
|
|
|
|
These instructions are for AI assistants.
|
|
${OPENSPEC_MARKERS.end}
|
|
`;
|
|
await fs.writeFile(path.join(testDir, 'CLAUDE.md'), legacyContent);
|
|
|
|
const consoleSpy = vi.spyOn(console, 'log');
|
|
|
|
// Create update command with force option
|
|
const forceUpdateCommand = new UpdateCommand({ force: true });
|
|
await forceUpdateCommand.execute(testDir);
|
|
|
|
// Should show v1 upgrade message
|
|
expect(consoleSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining('Upgrading to the new OpenSpec')
|
|
);
|
|
|
|
// Should show marker removal message (config files are never deleted, only have markers removed)
|
|
expect(consoleSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining('Removed OpenSpec markers from CLAUDE.md')
|
|
);
|
|
|
|
// Config file should still exist (never deleted)
|
|
const legacyExists = await FileSystemUtils.fileExists(
|
|
path.join(testDir, 'CLAUDE.md')
|
|
);
|
|
expect(legacyExists).toBe(true);
|
|
|
|
// File should have markers removed
|
|
const content = await fs.readFile(path.join(testDir, 'CLAUDE.md'), 'utf-8');
|
|
expect(content).not.toContain(OPENSPEC_MARKERS.start);
|
|
expect(content).not.toContain(OPENSPEC_MARKERS.end);
|
|
|
|
consoleSpy.mockRestore();
|
|
});
|
|
|
|
it('should warn but continue with update when legacy files found in non-interactive mode', async () => {
|
|
// Set up a configured tool
|
|
const skillsDir = path.join(testDir, '.claude', 'skills');
|
|
await fs.mkdir(path.join(skillsDir, 'openspec-explore'), {
|
|
recursive: true,
|
|
});
|
|
await fs.writeFile(
|
|
path.join(skillsDir, 'openspec-explore', 'SKILL.md'),
|
|
'old'
|
|
);
|
|
|
|
// Create legacy CLAUDE.md with OpenSpec markers
|
|
const legacyContent = `${OPENSPEC_MARKERS.start}
|
|
# OpenSpec Instructions
|
|
${OPENSPEC_MARKERS.end}
|
|
`;
|
|
await fs.writeFile(path.join(testDir, 'CLAUDE.md'), legacyContent);
|
|
|
|
const consoleSpy = vi.spyOn(console, 'log');
|
|
|
|
// Run without --force in non-interactive mode (CI environment)
|
|
await updateCommand.execute(testDir);
|
|
|
|
// Should show v1 upgrade message
|
|
expect(consoleSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining('Upgrading to the new OpenSpec')
|
|
);
|
|
|
|
// Should show warning about --force
|
|
expect(consoleSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining('Run with --force to auto-cleanup')
|
|
);
|
|
|
|
// Should continue with update
|
|
expect(consoleSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining('Updated: Claude Code')
|
|
);
|
|
|
|
// Legacy file should still exist (not cleaned up)
|
|
const legacyExists = await FileSystemUtils.fileExists(
|
|
path.join(testDir, 'CLAUDE.md')
|
|
);
|
|
expect(legacyExists).toBe(true);
|
|
|
|
consoleSpy.mockRestore();
|
|
});
|
|
|
|
it('should cleanup legacy slash command directories with --force', async () => {
|
|
// Set up a configured tool
|
|
const skillsDir = path.join(testDir, '.claude', 'skills');
|
|
await fs.mkdir(path.join(skillsDir, 'openspec-explore'), {
|
|
recursive: true,
|
|
});
|
|
await fs.writeFile(
|
|
path.join(skillsDir, 'openspec-explore', 'SKILL.md'),
|
|
'old'
|
|
);
|
|
|
|
// Create legacy slash command directory
|
|
const legacyCommandDir = path.join(testDir, '.claude', 'commands', 'openspec');
|
|
await fs.mkdir(legacyCommandDir, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(legacyCommandDir, 'old-command.md'),
|
|
'old command'
|
|
);
|
|
|
|
const consoleSpy = vi.spyOn(console, 'log');
|
|
|
|
// Create update command with force option
|
|
const forceUpdateCommand = new UpdateCommand({ force: true });
|
|
await forceUpdateCommand.execute(testDir);
|
|
|
|
// Should show cleanup message for directory
|
|
expect(consoleSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining('Removed .claude/commands/openspec/')
|
|
);
|
|
|
|
// Legacy directory should be deleted
|
|
const legacyDirExists = await FileSystemUtils.directoryExists(legacyCommandDir);
|
|
expect(legacyDirExists).toBe(false);
|
|
|
|
consoleSpy.mockRestore();
|
|
});
|
|
|
|
it('should cleanup legacy openspec/AGENTS.md with --force', async () => {
|
|
// Set up a configured tool
|
|
const skillsDir = path.join(testDir, '.claude', 'skills');
|
|
await fs.mkdir(path.join(skillsDir, 'openspec-explore'), {
|
|
recursive: true,
|
|
});
|
|
await fs.writeFile(
|
|
path.join(skillsDir, 'openspec-explore', 'SKILL.md'),
|
|
'old'
|
|
);
|
|
|
|
// Create legacy openspec/AGENTS.md
|
|
await fs.writeFile(
|
|
path.join(testDir, 'openspec', 'AGENTS.md'),
|
|
'# Old AGENTS.md content'
|
|
);
|
|
|
|
const consoleSpy = vi.spyOn(console, 'log');
|
|
|
|
// Create update command with force option
|
|
const forceUpdateCommand = new UpdateCommand({ force: true });
|
|
await forceUpdateCommand.execute(testDir);
|
|
|
|
// Should show cleanup message
|
|
expect(consoleSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining('Removed openspec/AGENTS.md')
|
|
);
|
|
|
|
// Legacy file should be deleted
|
|
const legacyExists = await FileSystemUtils.fileExists(
|
|
path.join(testDir, 'openspec', 'AGENTS.md')
|
|
);
|
|
expect(legacyExists).toBe(false);
|
|
|
|
consoleSpy.mockRestore();
|
|
});
|
|
|
|
it('should not show legacy cleanup messages when no legacy files exist', async () => {
|
|
// Set up a configured tool with no legacy files
|
|
const skillsDir = path.join(testDir, '.claude', 'skills');
|
|
await fs.mkdir(path.join(skillsDir, 'openspec-explore'), {
|
|
recursive: true,
|
|
});
|
|
await fs.writeFile(
|
|
path.join(skillsDir, 'openspec-explore', 'SKILL.md'),
|
|
'old'
|
|
);
|
|
|
|
const consoleSpy = vi.spyOn(console, 'log');
|
|
|
|
await updateCommand.execute(testDir);
|
|
|
|
// Should not show v1 upgrade message (no legacy files)
|
|
const calls = consoleSpy.mock.calls.map(call =>
|
|
call.map(arg => String(arg)).join(' ')
|
|
);
|
|
const hasLegacyMessage = calls.some(call =>
|
|
call.includes('Upgrading to the new OpenSpec')
|
|
);
|
|
expect(hasLegacyMessage).toBe(false);
|
|
|
|
consoleSpy.mockRestore();
|
|
});
|
|
|
|
it('should remove OpenSpec marker block from mixed content files', async () => {
|
|
// Set up a configured tool
|
|
const skillsDir = path.join(testDir, '.claude', 'skills');
|
|
await fs.mkdir(path.join(skillsDir, 'openspec-explore'), {
|
|
recursive: true,
|
|
});
|
|
await fs.writeFile(
|
|
path.join(skillsDir, 'openspec-explore', 'SKILL.md'),
|
|
'old'
|
|
);
|
|
|
|
// Create CLAUDE.md with mixed content (user content + OpenSpec markers)
|
|
const mixedContent = `# My Project
|
|
|
|
Some user-defined instructions here.
|
|
|
|
${OPENSPEC_MARKERS.start}
|
|
# OpenSpec Instructions
|
|
|
|
These instructions are for AI assistants.
|
|
${OPENSPEC_MARKERS.end}
|
|
|
|
More user content after markers.
|
|
`;
|
|
await fs.writeFile(path.join(testDir, 'CLAUDE.md'), mixedContent);
|
|
|
|
const consoleSpy = vi.spyOn(console, 'log');
|
|
|
|
// Create update command with force option
|
|
const forceUpdateCommand = new UpdateCommand({ force: true });
|
|
await forceUpdateCommand.execute(testDir);
|
|
|
|
// Should show marker removal message
|
|
expect(consoleSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining('Removed OpenSpec markers from CLAUDE.md')
|
|
);
|
|
|
|
// File should still exist
|
|
const fileExists = await FileSystemUtils.fileExists(
|
|
path.join(testDir, 'CLAUDE.md')
|
|
);
|
|
expect(fileExists).toBe(true);
|
|
|
|
// File should have markers removed but preserve user content
|
|
const updatedContent = await fs.readFile(
|
|
path.join(testDir, 'CLAUDE.md'),
|
|
'utf-8'
|
|
);
|
|
expect(updatedContent).toContain('# My Project');
|
|
expect(updatedContent).toContain('Some user-defined instructions here');
|
|
expect(updatedContent).toContain('More user content after markers');
|
|
expect(updatedContent).not.toContain(OPENSPEC_MARKERS.start);
|
|
expect(updatedContent).not.toContain(OPENSPEC_MARKERS.end);
|
|
|
|
consoleSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe('legacy tool upgrade', () => {
|
|
it('should upgrade legacy tools to new skills with --force', async () => {
|
|
// Create legacy slash command directory (no skills exist yet)
|
|
const legacyCommandDir = path.join(testDir, '.claude', 'commands', 'openspec');
|
|
await fs.mkdir(legacyCommandDir, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(legacyCommandDir, 'proposal.md'),
|
|
'old command content'
|
|
);
|
|
|
|
const consoleSpy = vi.spyOn(console, 'log');
|
|
|
|
// Create update command with force option
|
|
const forceUpdateCommand = new UpdateCommand({ force: true });
|
|
await forceUpdateCommand.execute(testDir);
|
|
|
|
// Should show detected tools message
|
|
expect(consoleSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining('Tools detected from legacy artifacts')
|
|
);
|
|
|
|
// Should show Claude Code being set up
|
|
expect(consoleSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining('Claude Code')
|
|
);
|
|
|
|
// Should show getting started message for newly configured tools
|
|
expect(consoleSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining('Getting started')
|
|
);
|
|
expect(consoleSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining('/opsx:new')
|
|
);
|
|
|
|
// Skills should be created
|
|
const skillFile = path.join(testDir, '.claude', 'skills', 'openspec-explore', 'SKILL.md');
|
|
const skillExists = await FileSystemUtils.fileExists(skillFile);
|
|
expect(skillExists).toBe(true);
|
|
|
|
// Legacy directory should be deleted
|
|
const legacyDirExists = await FileSystemUtils.directoryExists(legacyCommandDir);
|
|
expect(legacyDirExists).toBe(false);
|
|
|
|
consoleSpy.mockRestore();
|
|
});
|
|
|
|
it('should upgrade multiple legacy tools with --force', async () => {
|
|
// Create legacy command directories for Claude and Cursor
|
|
await fs.mkdir(path.join(testDir, '.claude', 'commands', 'openspec'), { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(testDir, '.claude', 'commands', 'openspec', 'proposal.md'),
|
|
'content'
|
|
);
|
|
|
|
await fs.mkdir(path.join(testDir, '.cursor', 'commands'), { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(testDir, '.cursor', 'commands', 'openspec-proposal.md'),
|
|
'content'
|
|
);
|
|
|
|
const consoleSpy = vi.spyOn(console, 'log');
|
|
|
|
// Create update command with force option
|
|
const forceUpdateCommand = new UpdateCommand({ force: true });
|
|
await forceUpdateCommand.execute(testDir);
|
|
|
|
// Should detect both tools
|
|
expect(consoleSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining('Tools detected from legacy artifacts')
|
|
);
|
|
|
|
// Both tools should have skills created
|
|
const claudeSkillFile = path.join(testDir, '.claude', 'skills', 'openspec-explore', 'SKILL.md');
|
|
const cursorSkillFile = path.join(testDir, '.cursor', 'skills', 'openspec-explore', 'SKILL.md');
|
|
|
|
expect(await FileSystemUtils.fileExists(claudeSkillFile)).toBe(true);
|
|
expect(await FileSystemUtils.fileExists(cursorSkillFile)).toBe(true);
|
|
|
|
consoleSpy.mockRestore();
|
|
});
|
|
|
|
it('should not upgrade legacy tools already configured', async () => {
|
|
// Set up a configured Claude tool with skills
|
|
const skillsDir = path.join(testDir, '.claude', 'skills');
|
|
await fs.mkdir(path.join(skillsDir, 'openspec-explore'), { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(skillsDir, 'openspec-explore', 'SKILL.md'),
|
|
'existing skill'
|
|
);
|
|
|
|
// Also create legacy directory (simulating partial upgrade)
|
|
const legacyCommandDir = path.join(testDir, '.claude', 'commands', 'openspec');
|
|
await fs.mkdir(legacyCommandDir, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(legacyCommandDir, 'proposal.md'),
|
|
'old command'
|
|
);
|
|
|
|
const consoleSpy = vi.spyOn(console, 'log');
|
|
|
|
// Create update command with force option
|
|
const forceUpdateCommand = new UpdateCommand({ force: true });
|
|
await forceUpdateCommand.execute(testDir);
|
|
|
|
// Legacy cleanup should happen
|
|
expect(consoleSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining('Removed .claude/commands/openspec/')
|
|
);
|
|
|
|
// Should NOT show "Tools detected from legacy artifacts" because claude is already configured
|
|
const calls = consoleSpy.mock.calls.map(call =>
|
|
call.map(arg => String(arg)).join(' ')
|
|
);
|
|
const hasDetectedMessage = calls.some(call =>
|
|
call.includes('Tools detected from legacy artifacts')
|
|
);
|
|
expect(hasDetectedMessage).toBe(false);
|
|
|
|
// Should update existing skills (not "Getting started" for newly configured)
|
|
expect(consoleSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining('Updated: Claude Code')
|
|
);
|
|
|
|
consoleSpy.mockRestore();
|
|
});
|
|
|
|
it('should upgrade only unconfigured legacy tools when mixed', async () => {
|
|
// Set up configured Claude tool with skills
|
|
const claudeSkillsDir = path.join(testDir, '.claude', 'skills');
|
|
await fs.mkdir(path.join(claudeSkillsDir, 'openspec-explore'), { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(claudeSkillsDir, 'openspec-explore', 'SKILL.md'),
|
|
'existing skill'
|
|
);
|
|
|
|
// Create legacy commands for both Claude (configured) and Cursor (not configured)
|
|
await fs.mkdir(path.join(testDir, '.claude', 'commands', 'openspec'), { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(testDir, '.claude', 'commands', 'openspec', 'proposal.md'),
|
|
'content'
|
|
);
|
|
|
|
await fs.mkdir(path.join(testDir, '.cursor', 'commands'), { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(testDir, '.cursor', 'commands', 'openspec-proposal.md'),
|
|
'content'
|
|
);
|
|
|
|
const consoleSpy = vi.spyOn(console, 'log');
|
|
|
|
// Create update command with force option
|
|
const forceUpdateCommand = new UpdateCommand({ force: true });
|
|
await forceUpdateCommand.execute(testDir);
|
|
|
|
// Should detect Cursor as a legacy tool to upgrade (but not Claude)
|
|
expect(consoleSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining('Tools detected from legacy artifacts')
|
|
);
|
|
|
|
// Cursor skills should be created
|
|
const cursorSkillFile = path.join(testDir, '.cursor', 'skills', 'openspec-explore', 'SKILL.md');
|
|
expect(await FileSystemUtils.fileExists(cursorSkillFile)).toBe(true);
|
|
|
|
// Should show "Getting started" for newly configured Cursor
|
|
expect(consoleSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining('Getting started')
|
|
);
|
|
|
|
consoleSpy.mockRestore();
|
|
});
|
|
|
|
it('should not show getting started message when no new tools configured', async () => {
|
|
// Set up a configured tool (no legacy artifacts)
|
|
const skillsDir = path.join(testDir, '.claude', 'skills');
|
|
await fs.mkdir(path.join(skillsDir, 'openspec-explore'), { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(skillsDir, 'openspec-explore', 'SKILL.md'),
|
|
'old skill'
|
|
);
|
|
|
|
const consoleSpy = vi.spyOn(console, 'log');
|
|
|
|
await updateCommand.execute(testDir);
|
|
|
|
// Should NOT show "Getting started" message
|
|
const calls = consoleSpy.mock.calls.map(call =>
|
|
call.map(arg => String(arg)).join(' ')
|
|
);
|
|
const hasGettingStarted = calls.some(call =>
|
|
call.includes('Getting started')
|
|
);
|
|
expect(hasGettingStarted).toBe(false);
|
|
|
|
consoleSpy.mockRestore();
|
|
});
|
|
|
|
it('should create all 9 skills when upgrading legacy tools', async () => {
|
|
// Create legacy command directory
|
|
await fs.mkdir(path.join(testDir, '.claude', 'commands', 'openspec'), { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(testDir, '.claude', 'commands', 'openspec', 'proposal.md'),
|
|
'content'
|
|
);
|
|
|
|
// Create update command with force option
|
|
const forceUpdateCommand = new UpdateCommand({ force: true });
|
|
await forceUpdateCommand.execute(testDir);
|
|
|
|
// Verify all 9 skill directories were created
|
|
const skillNames = [
|
|
'openspec-explore',
|
|
'openspec-new-change',
|
|
'openspec-continue-change',
|
|
'openspec-apply-change',
|
|
'openspec-ff-change',
|
|
'openspec-sync-specs',
|
|
'openspec-archive-change',
|
|
'openspec-bulk-archive-change',
|
|
'openspec-verify-change',
|
|
];
|
|
|
|
const skillsDir = path.join(testDir, '.claude', 'skills');
|
|
for (const skillName of skillNames) {
|
|
const skillFile = path.join(skillsDir, skillName, 'SKILL.md');
|
|
const exists = await FileSystemUtils.fileExists(skillFile);
|
|
expect(exists).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('should create commands when upgrading legacy tools', async () => {
|
|
// Create legacy command directory
|
|
await fs.mkdir(path.join(testDir, '.claude', 'commands', 'openspec'), { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(testDir, '.claude', 'commands', 'openspec', 'proposal.md'),
|
|
'content'
|
|
);
|
|
|
|
// Create update command with force option
|
|
const forceUpdateCommand = new UpdateCommand({ force: true });
|
|
await forceUpdateCommand.execute(testDir);
|
|
|
|
// New opsx commands should be created
|
|
const commandsDir = path.join(testDir, '.claude', 'commands', 'opsx');
|
|
const exploreCmd = path.join(commandsDir, 'explore.md');
|
|
const exists = await FileSystemUtils.fileExists(exploreCmd);
|
|
expect(exists).toBe(true);
|
|
});
|
|
});
|
|
});
|