@fission-ai/openspec
Version:
AI-native system for spec-driven development
311 lines • 14.9 kB
JavaScript
/**
* Update Command
*
* Refreshes OpenSpec skills and commands for configured tools.
* Supports smart update detection to skip updates when already current.
*/
import path from 'path';
import chalk from 'chalk';
import ora from 'ora';
import { createRequire } from 'module';
import { FileSystemUtils } from '../utils/file-system.js';
import { transformToHyphenCommands } from '../utils/command-references.js';
import { AI_TOOLS, OPENSPEC_DIR_NAME } from './config.js';
import { generateCommands, CommandAdapterRegistry, } from './command-generation/index.js';
import { getConfiguredTools, getAllToolVersionStatus, getSkillTemplates, getCommandContents, generateSkillContent, getToolsWithSkillsDir, } from './shared/index.js';
import { detectLegacyArtifacts, cleanupLegacyArtifacts, formatCleanupSummary, formatDetectionSummary, getToolsFromLegacyArtifacts, } from './legacy-cleanup.js';
import { isInteractive } from '../utils/interactive.js';
const require = createRequire(import.meta.url);
const { version: OPENSPEC_VERSION } = require('../../package.json');
export class UpdateCommand {
force;
constructor(options = {}) {
this.force = options.force ?? false;
}
async execute(projectPath) {
const resolvedProjectPath = path.resolve(projectPath);
const openspecPath = path.join(resolvedProjectPath, OPENSPEC_DIR_NAME);
// 1. Check openspec directory exists
if (!await FileSystemUtils.directoryExists(openspecPath)) {
throw new Error(`No OpenSpec directory found. Run 'openspec init' first.`);
}
// 2. Detect and handle legacy artifacts + upgrade legacy tools to new skills
const newlyConfiguredTools = await this.handleLegacyCleanup(resolvedProjectPath);
// 3. Find configured tools
const configuredTools = getConfiguredTools(resolvedProjectPath);
if (configuredTools.length === 0 && newlyConfiguredTools.length === 0) {
console.log(chalk.yellow('No configured tools found.'));
console.log(chalk.dim('Run "openspec init" to set up tools.'));
return;
}
// 4. Check version status for all configured tools
const toolStatuses = getAllToolVersionStatus(resolvedProjectPath, OPENSPEC_VERSION);
// 5. Smart update detection
const toolsNeedingUpdate = toolStatuses.filter((s) => s.needsUpdate);
const toolsUpToDate = toolStatuses.filter((s) => !s.needsUpdate);
if (!this.force && toolsNeedingUpdate.length === 0) {
// All tools are up to date
this.displayUpToDateMessage(toolStatuses);
return;
}
// 6. Display update plan
if (this.force) {
console.log(`Force updating ${configuredTools.length} tool(s): ${configuredTools.join(', ')}`);
}
else {
this.displayUpdatePlan(toolsNeedingUpdate, toolsUpToDate);
}
console.log();
// 7. Prepare templates
const skillTemplates = getSkillTemplates();
const commandContents = getCommandContents();
// 8. Update tools (all if force, otherwise only those needing update)
const toolsToUpdate = this.force ? configuredTools : toolsNeedingUpdate.map((s) => s.toolId);
const updatedTools = [];
const failedTools = [];
for (const toolId of toolsToUpdate) {
const tool = AI_TOOLS.find((t) => t.value === toolId);
if (!tool?.skillsDir)
continue;
const spinner = ora(`Updating ${tool.name}...`).start();
try {
const skillsDir = path.join(resolvedProjectPath, tool.skillsDir, 'skills');
// Update skill files
for (const { template, dirName } of skillTemplates) {
const skillDir = path.join(skillsDir, dirName);
const skillFile = path.join(skillDir, 'SKILL.md');
// Use hyphen-based command references for OpenCode
const transformer = tool.value === 'opencode' ? transformToHyphenCommands : undefined;
const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer);
await FileSystemUtils.writeFile(skillFile, skillContent);
}
// Update commands
const adapter = CommandAdapterRegistry.get(tool.value);
if (adapter) {
const generatedCommands = generateCommands(commandContents, adapter);
for (const cmd of generatedCommands) {
const commandFile = path.isAbsolute(cmd.path) ? cmd.path : path.join(resolvedProjectPath, cmd.path);
await FileSystemUtils.writeFile(commandFile, cmd.fileContent);
}
}
spinner.succeed(`Updated ${tool.name}`);
updatedTools.push(tool.name);
}
catch (error) {
spinner.fail(`Failed to update ${tool.name}`);
failedTools.push({
name: tool.name,
error: error instanceof Error ? error.message : String(error)
});
}
}
// 9. Summary
console.log();
if (updatedTools.length > 0) {
console.log(chalk.green(`✓ Updated: ${updatedTools.join(', ')} (v${OPENSPEC_VERSION})`));
}
if (failedTools.length > 0) {
console.log(chalk.red(`✗ Failed: ${failedTools.map(f => `${f.name} (${f.error})`).join(', ')}`));
}
// 10. Show onboarding message for newly configured tools from legacy upgrade
if (newlyConfiguredTools.length > 0) {
console.log();
console.log(chalk.bold('Getting started:'));
console.log(' /opsx:new Start a new change');
console.log(' /opsx:continue Create the next artifact');
console.log(' /opsx:apply Implement tasks');
console.log();
console.log(`Learn more: ${chalk.cyan('https://github.com/Fission-AI/OpenSpec')}`);
}
console.log();
console.log(chalk.dim('Restart your IDE for changes to take effect.'));
}
/**
* Display message when all tools are up to date.
*/
displayUpToDateMessage(toolStatuses) {
const toolNames = toolStatuses.map((s) => s.toolId);
console.log(chalk.green(`✓ All ${toolStatuses.length} tool(s) up to date (v${OPENSPEC_VERSION})`));
console.log(chalk.dim(` Tools: ${toolNames.join(', ')}`));
console.log();
console.log(chalk.dim('Use --force to refresh skills anyway.'));
}
/**
* Display the update plan showing which tools need updating.
*/
displayUpdatePlan(needingUpdate, upToDate) {
const updates = needingUpdate.map((s) => {
const fromVersion = s.generatedByVersion ?? 'unknown';
return `${s.toolId} (${fromVersion} → ${OPENSPEC_VERSION})`;
});
console.log(`Updating ${needingUpdate.length} tool(s): ${updates.join(', ')}`);
if (upToDate.length > 0) {
const upToDateNames = upToDate.map((s) => s.toolId);
console.log(chalk.dim(`Already up to date: ${upToDateNames.join(', ')}`));
}
}
/**
* Detect and handle legacy OpenSpec artifacts.
* Unlike init, update warns but continues if legacy files found in non-interactive mode.
* Returns array of tool IDs that were newly configured during legacy upgrade.
*/
async handleLegacyCleanup(projectPath) {
// Detect legacy artifacts
const detection = await detectLegacyArtifacts(projectPath);
if (!detection.hasLegacyArtifacts) {
return []; // No legacy artifacts found
}
// Show what was detected
console.log();
console.log(formatDetectionSummary(detection));
console.log();
const canPrompt = isInteractive();
if (this.force) {
// --force flag: proceed with cleanup automatically
await this.performLegacyCleanup(projectPath, detection);
// Then upgrade legacy tools to new skills
return this.upgradeLegacyTools(projectPath, detection, canPrompt);
}
if (!canPrompt) {
// Non-interactive mode without --force: warn and continue
// (Unlike init, update doesn't abort - user may just want to update skills)
console.log(chalk.yellow('⚠ Run with --force to auto-cleanup legacy files, or run interactively.'));
console.log();
return [];
}
// Interactive mode: prompt for confirmation
const { confirm } = await import('@inquirer/prompts');
const shouldCleanup = await confirm({
message: 'Upgrade and clean up legacy files?',
default: true,
});
if (shouldCleanup) {
await this.performLegacyCleanup(projectPath, detection);
// Then upgrade legacy tools to new skills
return this.upgradeLegacyTools(projectPath, detection, canPrompt);
}
else {
console.log(chalk.dim('Skipping legacy cleanup. Continuing with skill update...'));
console.log();
return [];
}
}
/**
* Perform cleanup of legacy artifacts.
*/
async performLegacyCleanup(projectPath, detection) {
const spinner = ora('Cleaning up legacy files...').start();
const result = await cleanupLegacyArtifacts(projectPath, detection);
spinner.succeed('Legacy files cleaned up');
const summary = formatCleanupSummary(result);
if (summary) {
console.log();
console.log(summary);
}
console.log();
}
/**
* Upgrade legacy tools to new skills system.
* Returns array of tool IDs that were newly configured.
*/
async upgradeLegacyTools(projectPath, detection, canPrompt) {
// Get tools that had legacy artifacts
const legacyTools = getToolsFromLegacyArtifacts(detection);
if (legacyTools.length === 0) {
return [];
}
// Get currently configured tools
const configuredTools = getConfiguredTools(projectPath);
const configuredSet = new Set(configuredTools);
// Filter to tools that aren't already configured
const unconfiguredLegacyTools = legacyTools.filter((t) => !configuredSet.has(t));
if (unconfiguredLegacyTools.length === 0) {
return [];
}
// Get valid tools (those with skillsDir)
const validToolIds = new Set(getToolsWithSkillsDir());
const validUnconfiguredTools = unconfiguredLegacyTools.filter((t) => validToolIds.has(t));
if (validUnconfiguredTools.length === 0) {
return [];
}
// Show what tools were detected from legacy artifacts
console.log(chalk.bold('Tools detected from legacy artifacts:'));
for (const toolId of validUnconfiguredTools) {
const tool = AI_TOOLS.find((t) => t.value === toolId);
console.log(` • ${tool?.name || toolId}`);
}
console.log();
let selectedTools;
if (this.force || !canPrompt) {
// Non-interactive with --force: auto-select detected tools
selectedTools = validUnconfiguredTools;
console.log(`Setting up skills for: ${selectedTools.join(', ')}`);
}
else {
// Interactive mode: prompt for tool selection with detected tools pre-selected
const { searchableMultiSelect } = await import('../prompts/searchable-multi-select.js');
const sortedChoices = validUnconfiguredTools.map((toolId) => {
const tool = AI_TOOLS.find((t) => t.value === toolId);
return {
name: tool?.name || toolId,
value: toolId,
configured: false,
preSelected: true, // Pre-select all detected legacy tools
};
});
selectedTools = await searchableMultiSelect({
message: 'Select tools to set up with the new skill system:',
pageSize: 15,
choices: sortedChoices,
validate: (_selected) => true, // Allow empty selection (user can skip)
});
if (selectedTools.length === 0) {
console.log(chalk.dim('Skipping tool setup.'));
console.log();
return [];
}
}
// Create skills for selected tools
const newlyConfigured = [];
const skillTemplates = getSkillTemplates();
const commandContents = getCommandContents();
for (const toolId of selectedTools) {
const tool = AI_TOOLS.find((t) => t.value === toolId);
if (!tool?.skillsDir)
continue;
const spinner = ora(`Setting up ${tool.name}...`).start();
try {
const skillsDir = path.join(projectPath, tool.skillsDir, 'skills');
// Create skill files
for (const { template, dirName } of skillTemplates) {
const skillDir = path.join(skillsDir, dirName);
const skillFile = path.join(skillDir, 'SKILL.md');
// Use hyphen-based command references for OpenCode
const transformer = tool.value === 'opencode' ? transformToHyphenCommands : undefined;
const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer);
await FileSystemUtils.writeFile(skillFile, skillContent);
}
// Create commands
const adapter = CommandAdapterRegistry.get(tool.value);
if (adapter) {
const generatedCommands = generateCommands(commandContents, adapter);
for (const cmd of generatedCommands) {
const commandFile = path.isAbsolute(cmd.path) ? cmd.path : path.join(projectPath, cmd.path);
await FileSystemUtils.writeFile(commandFile, cmd.fileContent);
}
}
spinner.succeed(`Setup complete for ${tool.name}`);
newlyConfigured.push(toolId);
}
catch (error) {
spinner.fail(`Failed to set up ${tool.name}`);
console.log(chalk.red(` ${error instanceof Error ? error.message : String(error)}`));
}
}
if (newlyConfigured.length > 0) {
console.log();
}
return newlyConfigured;
}
}
//# sourceMappingURL=update.js.map