Files
OpenSpec/test/core/update.test.ts
T
Tabish Bidiwale 39bebefcc4 feat(cli): merge init and experimental commands (#565)
* 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
2026-01-23 19:51:31 -08:00

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);
});
});
});