Compare commits

...
Author SHA1 Message Date
Tabish Bidiwale 0755994eaa refactor: use @inquirer/prompts for consistent UX in archive command
- Replace readline with @inquirer/prompts for all user interactions
- Add arrow key navigation for change selection (consistent with diff command)
- Use confirm() for yes/no prompts with better UX
- Remove manual readline interface management (no longer needed)
- Update tests to mock @inquirer/prompts instead of readline
- Add new test cases for interactive mode behavior

This change provides a consistent user experience across all OpenSpec commands,
where users can navigate options with arrow keys rather than typing numbers.
2025-08-13 18:37:18 +10:00
Tabish Bidiwale dcabd6de31 fix: address PR review feedback for archive command
- Add try-finally block to ensure readline interface always closes
- Extract date formatting to dedicated getArchiveDate() method
- Add comprehensive unit tests for ArchiveCommand covering:
  - Successful archiving flow
  - Incomplete tasks warning
  - Spec updates during archiving
  - Edge cases (missing tasks.md, no specs)
  - Error scenarios (missing change, duplicate archive)
  - No OpenSpec directory error
2025-08-13 18:27:24 +10:00
Tabish Bidiwale aef6ce01ff feat: implement archive command for OpenSpec changes
Adds a new `openspec archive` command that moves completed changes to an archive
directory with date-based naming. The command includes:
- Interactive change selection when no name provided
- Incomplete task warnings before archiving
- Automatic spec updates to main specs directory
- Confirmation prompts (skippable with --yes flag)
- Duplicate archive prevention

Also fixes TypeScript compilation errors in diff.ts and init.ts.
2025-08-13 18:19:26 +10:00
Tabish Bidiwale 441f9f444b Merge pull request #20 from Fission-AI/fix-archive-spec-updates
Fix: Add spec update functionality to archive command
2025-08-13 18:03:09 +10:00
Tabish Bidiwale b322829091 fix: add spec update functionality to archive command
The archive command was missing critical functionality to update main specs
from the change's future state specs when archiving. This fix adds:
- Spec update process that copies future state specs to main specs directory
- Confirmation prompt showing which specs will be created vs updated
- --yes flag for automation scenarios to skip confirmations
- Safety by default with clear visibility into spec changes
2025-08-13 18:00:00 +10:00
Tabish Bidiwale 5167e65a5c chore: archive add-list-command change after deployment 2025-08-13 17:36:12 +10:00
Tabish Bidiwale 5c6b4113a7 Merge pull request #19 from Fission-AI/feat/add-archive-command
feat: add archive command for completed changes
2025-08-13 17:25:34 +10:00
Tabish Bidiwale a8b76c3e69 Merge pull request #18 from Fission-AI/feat/implement-list-command
feat: add list command to show active changes with task status
2025-08-13 17:23:21 +10:00
Tabish Bidiwale e395eb4eeb feat: add list command to show active changes with task status 2025-08-13 17:17:40 +10:00
15 changed files with 950 additions and 34 deletions
@@ -6,6 +6,9 @@ Need a command to archive completed changes to the archive folder with proper da
- Check for incomplete tasks before archiving and warn user
- Allow interactive selection of change to archive
- Prevent archiving if target directory already exists
- Update main specs from the change's future state specs (copy from `changes/[name]/specs/` to `openspec/specs/`)
- Show confirmation prompt before updating specs, displaying which specs will be created/updated
- Support `--yes` flag to skip confirmations for automation
## Impact
- Affected specs: cli-archive (new)
@@ -5,9 +5,12 @@ The archive command moves completed changes from the active changes directory to
## Command Syntax
```bash
openspec archive [change-name]
openspec archive [change-name] [--yes|-y]
```
Options:
- `--yes`, `-y`: Skip confirmation prompts (for automation)
## Behavior
### Change Selection
@@ -35,14 +38,59 @@ The archive operation SHALL:
1. Create archive/ directory if it doesn't exist
2. Generate target name as `YYYY-MM-DD-[change-name]` using current date
3. Check if target directory already exists
4. Move the entire change directory to the archive location
4. Update main specs from the change's future state specs (see Spec Update Process below)
5. Move the entire change directory to the archive location
WHEN target archive already exists
THEN fail with error message
AND do not overwrite existing archive
WHEN move succeeds
THEN display success message with archived name
THEN display success message with archived name and list of updated specs
### Spec Update Process
Before moving the change to archive, the command SHALL update main specs to reflect the deployed reality:
WHEN the change contains specs in `changes/[name]/specs/`
THEN:
1. Analyze which specs will be affected by comparing with existing specs
2. Display a summary of spec updates to the user (see Confirmation Behavior below)
3. Prompt for confirmation unless `--yes` flag is provided
4. If confirmed, for each capability spec in the change directory:
- Copy the spec from `changes/[name]/specs/[capability]/spec.md` to `openspec/specs/[capability]/spec.md`
- Create the target directory structure if it doesn't exist
- Overwrite existing spec files (specs represent current reality, change specs are the new reality)
- Track which specs were updated for the success message
WHEN no specs exist in the change
THEN skip the spec update step
AND proceed with archiving
### Confirmation Behavior
The spec update confirmation SHALL:
- Display a clear summary showing:
- Which specs will be created (new capabilities)
- Which specs will be updated (existing capabilities)
- The source path for each spec
- Format the confirmation prompt as:
```
The following specs will be updated:
NEW specs to be created:
- cli-archive (from changes/add-archive-command/specs/cli-archive/spec.md)
EXISTING specs to be updated:
- cli-init (from changes/update-init-command/specs/cli-init/spec.md)
Update 2 specs and archive 'add-archive-command'? [y/N]:
```
- Default to "No" for safety (require explicit "y" or "yes")
- Skip confirmation when `--yes` or `-y` flag is provided
WHEN user declines the confirmation
THEN abort the entire archive operation
AND display message: "Archive cancelled. No changes were made."
AND exit with non-zero status code
## Error Handling
@@ -57,4 +105,7 @@ SHALL handle the following error conditions:
**Interactive selection**: Reduces typing and helps users see available changes
**Task checking**: Prevents accidental archiving of incomplete work
**Date prefixing**: Maintains chronological order and prevents naming conflicts
**No overwrite**: Preserves historical archives and prevents data loss
**No overwrite**: Preserves historical archives and prevents data loss
**Spec updates before archiving**: Specs in the main directory represent current reality; when a change is deployed and archived, its future state specs become the new reality and must replace the main specs
**Confirmation for spec updates**: Provides visibility into what will change, prevents accidental overwrites, and ensures users understand the impact before specs are modified
**--yes flag for automation**: Allows CI/CD pipelines to archive without interactive prompts while maintaining safety by default for manual use
+16 -2
View File
@@ -5,13 +5,21 @@
- [ ] 1.1.1 Implement change selection (interactive if not provided)
- [ ] 1.1.2 Implement incomplete task checking from tasks.md
- [ ] 1.1.3 Implement confirmation prompt for incomplete tasks
- [ ] 1.1.4 Implement archive move with date prefixing
- [ ] 1.1.4 Implement spec update functionality
- [ ] 1.1.4.1 Detect specs in change directory
- [ ] 1.1.4.2 Compare with existing main specs
- [ ] 1.1.4.3 Display summary of new vs updated specs
- [ ] 1.1.4.4 Show confirmation prompt for spec updates
- [ ] 1.1.4.5 Copy specs to main spec directory
- [ ] 1.1.5 Implement archive move with date prefixing
- [ ] 1.1.6 Support --yes flag to skip confirmations
## 2. CLI Integration
- [ ] 2.1 Add archive command to `src/cli/index.ts`
- [ ] 2.1.1 Import ArchiveCommand
- [ ] 2.1.2 Register command with commander
- [ ] 2.1.3 Add proper error handling
- [ ] 2.1.3 Add --yes/-y flag option
- [ ] 2.1.4 Add proper error handling
## 3. Error Handling
- [ ] 3.1 Handle missing openspec/changes/ directory
@@ -24,6 +32,12 @@
- [ ] 4.2 Test with incomplete tasks (warning shown)
- [ ] 4.3 Test interactive selection mode
- [ ] 4.4 Test duplicate archive prevention
- [ ] 4.5 Test spec update functionality
- [ ] 4.5.1 Test creating new specs
- [ ] 4.5.2 Test updating existing specs
- [ ] 4.5.3 Test confirmation prompt display
- [ ] 4.5.4 Test declining confirmation (no changes made)
- [ ] 4.5.5 Test --yes flag skips confirmation
## 5. Build and Validation
- [ ] 5.1 Ensure TypeScript compilation succeeds
@@ -1,26 +0,0 @@
# Implementation Tasks
## 1. Core Implementation
- [ ] 1.1 Create `src/core/list.ts` with list logic
- [ ] 1.1.1 Implement directory scanning (exclude archive/)
- [ ] 1.1.2 Implement task counting from tasks.md files
- [ ] 1.1.3 Format output as simple table
- [ ] 1.2 Add list command to CLI in `src/cli/index.ts`
- [ ] 1.2.1 Register `openspec list` command
- [ ] 1.2.2 Connect to list.ts implementation
## 2. Error Handling
- [ ] 2.1 Handle missing openspec/changes/ directory
- [ ] 2.2 Handle changes without tasks.md files
- [ ] 2.3 Handle empty changes directory
## 3. Testing
- [ ] 3.1 Add tests for list functionality
- [ ] 3.1.1 Test with multiple changes
- [ ] 3.1.2 Test with completed changes
- [ ] 3.1.3 Test with no changes
- [ ] 3.1.4 Test error conditions
## 4. Documentation
- [ ] 4.1 Update CLI help text with list command
- [ ] 4.2 Add list command to README if applicable
@@ -0,0 +1,26 @@
# Implementation Tasks
## 1. Core Implementation
- [x] 1.1 Create `src/core/list.ts` with list logic
- [x] 1.1.1 Implement directory scanning (exclude archive/)
- [x] 1.1.2 Implement task counting from tasks.md files
- [x] 1.1.3 Format output as simple table
- [x] 1.2 Add list command to CLI in `src/cli/index.ts`
- [x] 1.2.1 Register `openspec list` command
- [x] 1.2.2 Connect to list.ts implementation
## 2. Error Handling
- [x] 2.1 Handle missing openspec/changes/ directory
- [x] 2.2 Handle changes without tasks.md files
- [x] 2.3 Handle empty changes directory
## 3. Testing
- [x] 3.1 Add tests for list functionality
- [x] 3.1.1 Test with multiple changes
- [x] 3.1.2 Test with completed changes
- [x] 3.1.3 Test with no changes
- [x] 3.1.4 Test error conditions
## 4. Documentation
- [x] 4.1 Update CLI help text with list command
- [x] 4.2 Add list command to README if applicable
+69
View File
@@ -0,0 +1,69 @@
# List Command Specification
## Purpose
The `openspec list` command SHALL provide developers with a quick overview of all active changes in the project, showing their names and task completion status.
## Behavior
### Command Execution
WHEN `openspec list` is executed
THEN scan the `openspec/changes/` directory for change directories
AND exclude the `archive/` subdirectory from results
AND parse each change's `tasks.md` file to count task completion
### Task Counting
WHEN parsing a `tasks.md` file
THEN count tasks matching these patterns:
- Completed: Lines containing `- [x]`
- Incomplete: Lines containing `- [ ]`
AND calculate total tasks as the sum of completed and incomplete
### Output Format
WHEN displaying the list
THEN show a table with columns:
- Change name (directory name)
- Task progress (e.g., "3/5 tasks" or "✓ Complete")
- Status indicator:
- `✓` for fully completed changes (all tasks done)
- Progress fraction for partial completion
Example output:
```
Changes:
add-auth-feature 3/5 tasks
update-api-docs ✓ Complete
fix-validation 0/2 tasks
add-list-command 1/4 tasks
```
### Empty State
WHEN no active changes exist (only archive/ or empty changes/)
THEN display: "No active changes found."
### Error Handling
IF a change directory has no `tasks.md` file
THEN display the change with "No tasks" status
IF `openspec/changes/` directory doesn't exist
THEN display error: "No OpenSpec changes directory found. Run 'openspec init' first."
AND exit with code 1
### Sorting
Changes SHALL be displayed in alphabetical order by change name for consistency.
## Why
Developers need a quick way to:
- See what changes are in progress
- Identify which changes are ready to archive
- Understand the overall project evolution status
- Get a bird's-eye view without opening multiple files
This command provides that visibility with minimal effort, following OpenSpec's philosophy of simplicity and clarity.
+31
View File
@@ -5,6 +5,8 @@ import { promises as fs } from 'fs';
import { InitCommand } from '../core/init.js';
import { UpdateCommand } from '../core/update.js';
import { DiffCommand } from '../core/diff.js';
import { ListCommand } from '../core/list.js';
import { ArchiveCommand } from '../core/archive.js';
const program = new Command();
@@ -75,4 +77,33 @@ program
}
});
program
.command('list')
.description('List all active changes with their task status')
.action(async () => {
try {
const listCommand = new ListCommand();
await listCommand.execute();
} catch (error) {
console.log(); // Empty line for spacing
ora().fail(`Error: ${(error as Error).message}`);
process.exit(1);
}
});
program
.command('archive [change-name]')
.description('Archive a completed change and update main specs')
.option('-y, --yes', 'Skip confirmation prompts')
.action(async (changeName?: string, options?: { yes?: boolean }) => {
try {
const archiveCommand = new ArchiveCommand();
await archiveCommand.execute(changeName, options);
} catch (error) {
console.log(); // Empty line for spacing
ora().fail(`Error: ${(error as Error).message}`);
process.exit(1);
}
});
program.parse();
+224
View File
@@ -0,0 +1,224 @@
import { promises as fs } from 'fs';
import path from 'path';
import { select, confirm } from '@inquirer/prompts';
import { FileSystemUtils } from '../utils/file-system.js';
interface SpecUpdate {
source: string;
target: string;
exists: boolean;
}
export class ArchiveCommand {
async execute(changeName?: string, options: { yes?: boolean } = {}): Promise<void> {
const targetPath = '.';
const changesDir = path.join(targetPath, 'openspec', 'changes');
const archiveDir = path.join(changesDir, 'archive');
const mainSpecsDir = path.join(targetPath, 'openspec', 'specs');
// Check if changes directory exists
try {
await fs.access(changesDir);
} catch {
throw new Error("No OpenSpec changes directory found. Run 'openspec init' first.");
}
// Get change name interactively if not provided
if (!changeName) {
const selectedChange = await this.selectChange(changesDir);
if (!selectedChange) {
console.log('No change selected. Aborting.');
return;
}
changeName = selectedChange;
}
const changeDir = path.join(changesDir, changeName);
// Verify change exists
try {
const stat = await fs.stat(changeDir);
if (!stat.isDirectory()) {
throw new Error(`Change '${changeName}' not found.`);
}
} catch {
throw new Error(`Change '${changeName}' not found.`);
}
// Check for incomplete tasks
const tasksPath = path.join(changeDir, 'tasks.md');
const incompleteTasks = await this.checkIncompleteTasks(tasksPath);
if (incompleteTasks > 0) {
if (!options.yes) {
const proceed = await confirm({
message: `Warning: ${incompleteTasks} incomplete task(s) found. Continue?`,
default: false
});
if (!proceed) {
console.log('Archive cancelled.');
return;
}
} else {
console.log(`Warning: ${incompleteTasks} incomplete task(s) found. Continuing due to --yes flag.`);
}
}
// Find specs to update
const specUpdates = await this.findSpecUpdates(changeDir, mainSpecsDir);
if (specUpdates.length > 0) {
console.log('\nSpecs to update:');
for (const update of specUpdates) {
const status = update.exists ? 'update' : 'create';
const capability = path.basename(path.dirname(update.target));
console.log(` ${capability}: ${status}`);
}
if (!options.yes) {
const proceed = await confirm({
message: 'Proceed with spec updates?',
default: true
});
if (!proceed) {
console.log('Archive cancelled.');
return;
}
}
// Update specs
for (const update of specUpdates) {
await this.updateSpec(update);
}
console.log('Specs updated successfully.');
}
// Create archive directory with date prefix
const archiveName = `${this.getArchiveDate()}-${changeName}`;
const archivePath = path.join(archiveDir, archiveName);
// Check if archive already exists
try {
await fs.access(archivePath);
throw new Error(`Archive '${archiveName}' already exists.`);
} catch (error: any) {
if (error.code !== 'ENOENT') {
throw error;
}
}
// Create archive directory if needed
await fs.mkdir(archiveDir, { recursive: true });
// Move change to archive
await fs.rename(changeDir, archivePath);
console.log(`Change '${changeName}' archived as '${archiveName}'.`);
}
private async selectChange(changesDir: string): Promise<string | null> {
// Get all directories in changes (excluding archive)
const entries = await fs.readdir(changesDir, { withFileTypes: true });
const changeDirs = entries
.filter(entry => entry.isDirectory() && entry.name !== 'archive')
.map(entry => entry.name)
.sort();
if (changeDirs.length === 0) {
console.log('No active changes found.');
return null;
}
console.log('Available changes:');
const choices = changeDirs.map(name => ({
name: name,
value: name
}));
try {
const answer = await select({
message: 'Select a change to archive',
choices
});
return answer;
} catch (error) {
// User cancelled (Ctrl+C)
return null;
}
}
private async checkIncompleteTasks(tasksPath: string): Promise<number> {
try {
const content = await fs.readFile(tasksPath, 'utf-8');
const lines = content.split('\n');
let incompleteTasks = 0;
for (const line of lines) {
if (line.includes('- [ ]')) {
incompleteTasks++;
}
}
return incompleteTasks;
} catch {
// No tasks.md file or error reading it
return 0;
}
}
private async findSpecUpdates(changeDir: string, mainSpecsDir: string): Promise<SpecUpdate[]> {
const updates: SpecUpdate[] = [];
const changeSpecsDir = path.join(changeDir, 'specs');
try {
const entries = await fs.readdir(changeSpecsDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
const specFile = path.join(changeSpecsDir, entry.name, 'spec.md');
const targetFile = path.join(mainSpecsDir, entry.name, 'spec.md');
try {
await fs.access(specFile);
// Check if target exists
let exists = false;
try {
await fs.access(targetFile);
exists = true;
} catch {
exists = false;
}
updates.push({
source: specFile,
target: targetFile,
exists
});
} catch {
// Source spec doesn't exist, skip
}
}
}
} catch {
// No specs directory in change
}
return updates;
}
private async updateSpec(update: SpecUpdate): Promise<void> {
// Create target directory if needed
const targetDir = path.dirname(update.target);
await fs.mkdir(targetDir, { recursive: true });
// Copy spec file
const content = await fs.readFile(update.source, 'utf-8');
await fs.writeFile(update.target, content);
}
private getArchiveDate(): string {
// Returns date in YYYY-MM-DD format
return new Date().toISOString().split('T')[0];
}
}
+1 -1
View File
@@ -82,7 +82,7 @@ export class DiffCommand {
choices
});
return answer;
return answer as string;
}
private async showDiffs(changeSpecsDir: string): Promise<void> {
+1 -1
View File
@@ -64,7 +64,7 @@ export class InitCommand {
}))
});
config.aiTools = [selectedTool];
config.aiTools = [selectedTool as string];
return config;
}
+90
View File
@@ -0,0 +1,90 @@
import { promises as fs } from 'fs';
import path from 'path';
interface ChangeInfo {
name: string;
completedTasks: number;
totalTasks: number;
}
export class ListCommand {
async execute(targetPath: string = '.'): Promise<void> {
const changesDir = path.join(targetPath, 'openspec', 'changes');
// Check if changes directory exists
try {
await fs.access(changesDir);
} catch {
throw new Error("No OpenSpec changes directory found. Run 'openspec init' first.");
}
// Get all directories in changes (excluding archive)
const entries = await fs.readdir(changesDir, { withFileTypes: true });
const changeDirs = entries
.filter(entry => entry.isDirectory() && entry.name !== 'archive')
.map(entry => entry.name);
if (changeDirs.length === 0) {
console.log('No active changes found.');
return;
}
// Collect information about each change
const changes: ChangeInfo[] = [];
for (const changeDir of changeDirs) {
const tasksPath = path.join(changesDir, changeDir, 'tasks.md');
let completedTasks = 0;
let incompleteTasks = 0;
try {
const content = await fs.readFile(tasksPath, 'utf-8');
const lines = content.split('\n');
for (const line of lines) {
if (line.includes('- [x]')) {
completedTasks++;
} else if (line.includes('- [ ]')) {
incompleteTasks++;
}
}
} catch {
// No tasks.md file
changes.push({
name: changeDir,
completedTasks: 0,
totalTasks: 0
});
continue;
}
changes.push({
name: changeDir,
completedTasks,
totalTasks: completedTasks + incompleteTasks
});
}
// Sort alphabetically by name
changes.sort((a, b) => a.name.localeCompare(b.name));
// Display results
console.log('Changes:');
for (const change of changes) {
const padding = ' ';
const nameWidth = Math.max(...changes.map(c => c.name.length));
const paddedName = change.name.padEnd(nameWidth);
let status: string;
if (change.totalTasks === 0) {
status = 'No tasks';
} else if (change.completedTasks === change.totalTasks) {
status = '✓ Complete';
} else {
status = `${change.completedTasks}/${change.totalTasks} tasks`;
}
console.log(`${padding}${paddedName} ${status}`);
}
}
}
+269
View File
@@ -0,0 +1,269 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { ArchiveCommand } from '../../src/core/archive.js';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
// Mock @inquirer/prompts
vi.mock('@inquirer/prompts', () => ({
select: vi.fn(),
confirm: vi.fn()
}));
describe('ArchiveCommand', () => {
let tempDir: string;
let archiveCommand: ArchiveCommand;
const originalConsoleLog = console.log;
beforeEach(async () => {
// Create temp directory
tempDir = path.join(os.tmpdir(), `openspec-archive-test-${Date.now()}`);
await fs.mkdir(tempDir, { recursive: true });
// Change to temp directory
process.chdir(tempDir);
// Create OpenSpec structure
const openspecDir = path.join(tempDir, 'openspec');
await fs.mkdir(path.join(openspecDir, 'changes'), { recursive: true });
await fs.mkdir(path.join(openspecDir, 'specs'), { recursive: true });
await fs.mkdir(path.join(openspecDir, 'changes', 'archive'), { recursive: true });
// Suppress console.log during tests
console.log = vi.fn();
archiveCommand = new ArchiveCommand();
});
afterEach(async () => {
// Restore console.log
console.log = originalConsoleLog;
// Clear mocks
vi.clearAllMocks();
// Clean up temp directory
try {
await fs.rm(tempDir, { recursive: true, force: true });
} catch (error) {
// Ignore cleanup errors
}
});
describe('execute', () => {
it('should archive a change successfully', async () => {
// Create a test change
const changeName = 'test-feature';
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
await fs.mkdir(changeDir, { recursive: true });
// Create tasks.md with completed tasks
const tasksContent = '- [x] Task 1\n- [x] Task 2';
await fs.writeFile(path.join(changeDir, 'tasks.md'), tasksContent);
// Execute archive with --yes flag
await archiveCommand.execute(changeName, { yes: true });
// Check that change was moved to archive
const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive');
const archives = await fs.readdir(archiveDir);
expect(archives.length).toBe(1);
expect(archives[0]).toMatch(new RegExp(`\\d{4}-\\d{2}-\\d{2}-${changeName}`));
// Verify original change directory no longer exists
await expect(fs.access(changeDir)).rejects.toThrow();
});
it('should warn about incomplete tasks', async () => {
const changeName = 'incomplete-feature';
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
await fs.mkdir(changeDir, { recursive: true });
// Create tasks.md with incomplete tasks
const tasksContent = '- [x] Task 1\n- [ ] Task 2\n- [ ] Task 3';
await fs.writeFile(path.join(changeDir, 'tasks.md'), tasksContent);
// Execute archive with --yes flag
await archiveCommand.execute(changeName, { yes: true });
// Verify warning was logged
expect(console.log).toHaveBeenCalledWith(
expect.stringContaining('Warning: 2 incomplete task(s) found')
);
});
it('should update specs when archiving', async () => {
const changeName = 'spec-feature';
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
const changeSpecDir = path.join(changeDir, 'specs', 'test-capability');
await fs.mkdir(changeSpecDir, { recursive: true });
// Create spec in change
const specContent = '# Test Capability Spec\n\nTest content';
await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent);
// Execute archive with --yes flag
await archiveCommand.execute(changeName, { yes: true });
// Verify spec was copied to main specs
const mainSpecPath = path.join(tempDir, 'openspec', 'specs', 'test-capability', 'spec.md');
const copiedContent = await fs.readFile(mainSpecPath, 'utf-8');
expect(copiedContent).toBe(specContent);
});
it('should throw error if change does not exist', async () => {
await expect(
archiveCommand.execute('non-existent-change', { yes: true })
).rejects.toThrow("Change 'non-existent-change' not found.");
});
it('should throw error if archive already exists', async () => {
const changeName = 'duplicate-feature';
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
await fs.mkdir(changeDir, { recursive: true });
// Create existing archive with same date
const date = new Date().toISOString().split('T')[0];
const archivePath = path.join(tempDir, 'openspec', 'changes', 'archive', `${date}-${changeName}`);
await fs.mkdir(archivePath, { recursive: true });
// Try to archive
await expect(
archiveCommand.execute(changeName, { yes: true })
).rejects.toThrow(`Archive '${date}-${changeName}' already exists.`);
});
it('should handle changes without tasks.md', async () => {
const changeName = 'no-tasks-feature';
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
await fs.mkdir(changeDir, { recursive: true });
// Execute archive without tasks.md
await archiveCommand.execute(changeName, { yes: true });
// Should complete without warnings
expect(console.log).not.toHaveBeenCalledWith(
expect.stringContaining('incomplete task(s)')
);
// Verify change was archived
const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive');
const archives = await fs.readdir(archiveDir);
expect(archives.length).toBe(1);
});
it('should handle changes without specs', async () => {
const changeName = 'no-specs-feature';
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
await fs.mkdir(changeDir, { recursive: true });
// Execute archive without specs
await archiveCommand.execute(changeName, { yes: true });
// Should complete without spec updates
expect(console.log).not.toHaveBeenCalledWith(
expect.stringContaining('Specs to update')
);
// Verify change was archived
const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive');
const archives = await fs.readdir(archiveDir);
expect(archives.length).toBe(1);
});
});
describe('error handling', () => {
it('should throw error when openspec directory does not exist', async () => {
// Remove openspec directory
await fs.rm(path.join(tempDir, 'openspec'), { recursive: true });
await expect(
archiveCommand.execute('any-change', { yes: true })
).rejects.toThrow("No OpenSpec changes directory found. Run 'openspec init' first.");
});
});
describe('interactive mode', () => {
it('should use select prompt for change selection', async () => {
const { select } = await import('@inquirer/prompts');
const mockSelect = select as unknown as ReturnType<typeof vi.fn>;
// Create test changes
const change1 = 'feature-a';
const change2 = 'feature-b';
await fs.mkdir(path.join(tempDir, 'openspec', 'changes', change1), { recursive: true });
await fs.mkdir(path.join(tempDir, 'openspec', 'changes', change2), { recursive: true });
// Mock select to return first change
mockSelect.mockResolvedValueOnce(change1);
// Execute without change name
await archiveCommand.execute(undefined, { yes: true });
// Verify select was called with correct options
expect(mockSelect).toHaveBeenCalledWith({
message: 'Select a change to archive',
choices: [
{ name: change1, value: change1 },
{ name: change2, value: change2 }
]
});
// Verify the selected change was archived
const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive');
const archives = await fs.readdir(archiveDir);
expect(archives[0]).toContain(change1);
});
it('should use confirm prompt for task warnings', async () => {
const { confirm } = await import('@inquirer/prompts');
const mockConfirm = confirm as unknown as ReturnType<typeof vi.fn>;
const changeName = 'incomplete-interactive';
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
await fs.mkdir(changeDir, { recursive: true });
// Create tasks.md with incomplete tasks
const tasksContent = '- [ ] Task 1';
await fs.writeFile(path.join(changeDir, 'tasks.md'), tasksContent);
// Mock confirm to return true (proceed)
mockConfirm.mockResolvedValueOnce(true);
// Execute without --yes flag
await archiveCommand.execute(changeName);
// Verify confirm was called
expect(mockConfirm).toHaveBeenCalledWith({
message: 'Warning: 1 incomplete task(s) found. Continue?',
default: false
});
});
it('should cancel when user declines task warning', async () => {
const { confirm } = await import('@inquirer/prompts');
const mockConfirm = confirm as unknown as ReturnType<typeof vi.fn>;
const changeName = 'cancel-test';
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
await fs.mkdir(changeDir, { recursive: true });
// Create tasks.md with incomplete tasks
const tasksContent = '- [ ] Task 1';
await fs.writeFile(path.join(changeDir, 'tasks.md'), tasksContent);
// Mock confirm to return false (cancel)
mockConfirm.mockResolvedValueOnce(false);
// Execute without --yes flag
await archiveCommand.execute(changeName);
// Verify archive was cancelled
expect(console.log).toHaveBeenCalledWith('Archive cancelled.');
// Verify change was not archived
await expect(fs.access(changeDir)).resolves.not.toThrow();
});
});
});
+165
View File
@@ -0,0 +1,165 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
import { ListCommand } from '../../src/core/list.js';
describe('ListCommand', () => {
let tempDir: string;
let originalLog: typeof console.log;
let logOutput: string[] = [];
beforeEach(async () => {
// Create temp directory
tempDir = path.join(os.tmpdir(), `openspec-list-test-${Date.now()}`);
await fs.mkdir(tempDir, { recursive: true });
// Mock console.log to capture output
originalLog = console.log;
console.log = (...args: any[]) => {
logOutput.push(args.join(' '));
};
logOutput = [];
});
afterEach(async () => {
// Restore console.log
console.log = originalLog;
// Clean up temp directory
await fs.rm(tempDir, { recursive: true, force: true });
});
describe('execute', () => {
it('should handle missing openspec/changes directory', async () => {
const listCommand = new ListCommand();
await expect(listCommand.execute(tempDir)).rejects.toThrow(
"No OpenSpec changes directory found. Run 'openspec init' first."
);
});
it('should handle empty changes directory', async () => {
const changesDir = path.join(tempDir, 'openspec', 'changes');
await fs.mkdir(changesDir, { recursive: true });
const listCommand = new ListCommand();
await listCommand.execute(tempDir);
expect(logOutput).toEqual(['No active changes found.']);
});
it('should exclude archive directory', async () => {
const changesDir = path.join(tempDir, 'openspec', 'changes');
await fs.mkdir(path.join(changesDir, 'archive'), { recursive: true });
await fs.mkdir(path.join(changesDir, 'my-change'), { recursive: true });
// Create tasks.md with some tasks
await fs.writeFile(
path.join(changesDir, 'my-change', 'tasks.md'),
'- [x] Task 1\n- [ ] Task 2\n'
);
const listCommand = new ListCommand();
await listCommand.execute(tempDir);
expect(logOutput).toContain('Changes:');
expect(logOutput.some(line => line.includes('my-change'))).toBe(true);
expect(logOutput.some(line => line.includes('archive'))).toBe(false);
});
it('should count tasks correctly', async () => {
const changesDir = path.join(tempDir, 'openspec', 'changes');
await fs.mkdir(path.join(changesDir, 'test-change'), { recursive: true });
await fs.writeFile(
path.join(changesDir, 'test-change', 'tasks.md'),
`# Tasks
- [x] Completed task 1
- [x] Completed task 2
- [ ] Incomplete task 1
- [ ] Incomplete task 2
- [ ] Incomplete task 3
Regular text that should be ignored
`
);
const listCommand = new ListCommand();
await listCommand.execute(tempDir);
expect(logOutput.some(line => line.includes('2/5 tasks'))).toBe(true);
});
it('should show complete status for fully completed changes', async () => {
const changesDir = path.join(tempDir, 'openspec', 'changes');
await fs.mkdir(path.join(changesDir, 'completed-change'), { recursive: true });
await fs.writeFile(
path.join(changesDir, 'completed-change', 'tasks.md'),
'- [x] Task 1\n- [x] Task 2\n- [x] Task 3\n'
);
const listCommand = new ListCommand();
await listCommand.execute(tempDir);
expect(logOutput.some(line => line.includes('✓ Complete'))).toBe(true);
});
it('should handle changes without tasks.md', async () => {
const changesDir = path.join(tempDir, 'openspec', 'changes');
await fs.mkdir(path.join(changesDir, 'no-tasks'), { recursive: true });
const listCommand = new ListCommand();
await listCommand.execute(tempDir);
expect(logOutput.some(line => line.includes('no-tasks') && line.includes('No tasks'))).toBe(true);
});
it('should sort changes alphabetically', async () => {
const changesDir = path.join(tempDir, 'openspec', 'changes');
await fs.mkdir(path.join(changesDir, 'zebra'), { recursive: true });
await fs.mkdir(path.join(changesDir, 'alpha'), { recursive: true });
await fs.mkdir(path.join(changesDir, 'middle'), { recursive: true });
const listCommand = new ListCommand();
await listCommand.execute(tempDir);
const changeLines = logOutput.filter(line =>
line.includes('alpha') || line.includes('middle') || line.includes('zebra')
);
expect(changeLines[0]).toContain('alpha');
expect(changeLines[1]).toContain('middle');
expect(changeLines[2]).toContain('zebra');
});
it('should handle multiple changes with various states', async () => {
const changesDir = path.join(tempDir, 'openspec', 'changes');
// Complete change
await fs.mkdir(path.join(changesDir, 'completed'), { recursive: true });
await fs.writeFile(
path.join(changesDir, 'completed', 'tasks.md'),
'- [x] Task 1\n- [x] Task 2\n'
);
// Partial change
await fs.mkdir(path.join(changesDir, 'partial'), { recursive: true });
await fs.writeFile(
path.join(changesDir, 'partial', 'tasks.md'),
'- [x] Done\n- [ ] Not done\n- [ ] Also not done\n'
);
// No tasks
await fs.mkdir(path.join(changesDir, 'no-tasks'), { recursive: true });
const listCommand = new ListCommand();
await listCommand.execute(tempDir);
expect(logOutput).toContain('Changes:');
expect(logOutput.some(line => line.includes('completed') && line.includes('✓ Complete'))).toBe(true);
expect(logOutput.some(line => line.includes('partial') && line.includes('1/3 tasks'))).toBe(true);
expect(logOutput.some(line => line.includes('no-tasks') && line.includes('No tasks'))).toBe(true);
});
});
});