mirror of
https://github.com/Fission-AI/OpenSpec.git
synced 2026-10-03 22:13:19 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c989198a0d |
+12
-84
@@ -39,10 +39,13 @@ import {
|
||||
getSkillTemplates,
|
||||
getCommandContents,
|
||||
generateSkillContent,
|
||||
removeAllSkillDirs,
|
||||
removeAllCommandFiles,
|
||||
printOnboardingFooter,
|
||||
type ToolSkillStatus,
|
||||
} from './shared/index.js';
|
||||
import { getGlobalConfig, type Delivery, type Profile } from './global-config.js';
|
||||
import { getProfileWorkflows, CORE_WORKFLOWS, ALL_WORKFLOWS } from './profiles.js';
|
||||
import { getProfileWorkflows } from './profiles.js';
|
||||
import { getAvailableTools } from './available-tools.js';
|
||||
import { migrateIfNeeded } from './migration.js';
|
||||
|
||||
@@ -60,20 +63,6 @@ const PROGRESS_SPINNER = {
|
||||
frames: ['░░░', '▒░░', '▒▒░', '▒▒▒', '▓▒▒', '▓▓▒', '▓▓▓', '▒▓▓', '░▒▓'],
|
||||
};
|
||||
|
||||
const WORKFLOW_TO_SKILL_DIR: Record<string, string> = {
|
||||
'explore': 'openspec-explore',
|
||||
'new': 'openspec-new-change',
|
||||
'continue': 'openspec-continue-change',
|
||||
'apply': 'openspec-apply-change',
|
||||
'ff': 'openspec-ff-change',
|
||||
'sync': 'openspec-sync-specs',
|
||||
'archive': 'openspec-archive-change',
|
||||
'bulk-archive': 'openspec-bulk-archive-change',
|
||||
'verify': 'openspec-verify-change',
|
||||
'onboard': 'openspec-onboard',
|
||||
'propose': 'openspec-propose',
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Types
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -547,7 +536,7 @@ export class InitCommand {
|
||||
}
|
||||
if (!shouldGenerateSkills) {
|
||||
const skillsDir = path.join(projectPath, tool.skillsDir, 'skills');
|
||||
removedSkillCount += await this.removeSkillDirs(skillsDir);
|
||||
removedSkillCount += await removeAllSkillDirs(skillsDir);
|
||||
}
|
||||
|
||||
// Generate commands if delivery includes commands
|
||||
@@ -565,7 +554,7 @@ export class InitCommand {
|
||||
}
|
||||
}
|
||||
if (!shouldGenerateCommands) {
|
||||
removedCommandCount += await this.removeCommandFiles(projectPath, tool.value);
|
||||
removedCommandCount += await removeAllCommandFiles(projectPath, tool.value);
|
||||
}
|
||||
|
||||
spinner.succeed(`Setup complete for ${tool.name}`);
|
||||
@@ -696,33 +685,14 @@ export class InitCommand {
|
||||
console.log(chalk.dim(`Config: skipped (non-interactive mode)`));
|
||||
}
|
||||
|
||||
// Getting started (task 7.6: show propose if in profile)
|
||||
// Onboarding footer (getting started + links + IDE restart)
|
||||
const globalCfg = getGlobalConfig();
|
||||
const activeProfile: Profile = (this.profileOverride as Profile) ?? globalCfg.profile ?? 'core';
|
||||
const activeWorkflows = [...getProfileWorkflows(activeProfile, globalCfg.workflows)];
|
||||
console.log();
|
||||
if (activeWorkflows.includes('propose')) {
|
||||
console.log(chalk.bold('Getting started:'));
|
||||
console.log(' Start your first change: /opsx:propose "your idea"');
|
||||
} else if (activeWorkflows.includes('new')) {
|
||||
console.log(chalk.bold('Getting started:'));
|
||||
console.log(' Start your first change: /opsx:new "your idea"');
|
||||
} else {
|
||||
console.log("Done. Run 'openspec config profile' to configure your workflows.");
|
||||
}
|
||||
|
||||
// Links
|
||||
console.log();
|
||||
console.log(`Learn more: ${chalk.cyan('https://github.com/Fission-AI/OpenSpec')}`);
|
||||
console.log(`Feedback: ${chalk.cyan('https://github.com/Fission-AI/OpenSpec/issues')}`);
|
||||
|
||||
// Restart instruction if any tools were configured
|
||||
if (results.createdTools.length > 0 || results.refreshedTools.length > 0) {
|
||||
console.log();
|
||||
console.log(chalk.white('Restart your IDE for slash commands to take effect.'));
|
||||
}
|
||||
|
||||
console.log();
|
||||
printOnboardingFooter({
|
||||
profile: activeProfile,
|
||||
customWorkflows: globalCfg.workflows,
|
||||
hasConfiguredTools: results.createdTools.length > 0 || results.refreshedTools.length > 0,
|
||||
});
|
||||
}
|
||||
|
||||
private startSpinner(text: string) {
|
||||
@@ -734,46 +704,4 @@ export class InitCommand {
|
||||
}).start();
|
||||
}
|
||||
|
||||
private async removeSkillDirs(skillsDir: string): Promise<number> {
|
||||
let removed = 0;
|
||||
|
||||
for (const workflow of ALL_WORKFLOWS) {
|
||||
const dirName = WORKFLOW_TO_SKILL_DIR[workflow];
|
||||
if (!dirName) continue;
|
||||
|
||||
const skillDir = path.join(skillsDir, dirName);
|
||||
try {
|
||||
if (fs.existsSync(skillDir)) {
|
||||
await fs.promises.rm(skillDir, { recursive: true, force: true });
|
||||
removed++;
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
private async removeCommandFiles(projectPath: string, toolId: string): Promise<number> {
|
||||
let removed = 0;
|
||||
const adapter = CommandAdapterRegistry.get(toolId);
|
||||
if (!adapter) return 0;
|
||||
|
||||
for (const workflow of ALL_WORKFLOWS) {
|
||||
const cmdPath = adapter.getFilePath(workflow);
|
||||
const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath);
|
||||
|
||||
try {
|
||||
if (fs.existsSync(fullPath)) {
|
||||
await fs.promises.unlink(fullPath);
|
||||
removed++;
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Artifact Removal Helpers
|
||||
*
|
||||
* Shared functions for removing skill directories and command files.
|
||||
* Used by both init and update commands when delivery mode changes or
|
||||
* workflows are deselected.
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import { ALL_WORKFLOWS } from '../profiles.js';
|
||||
import { WORKFLOW_TO_SKILL_DIR } from '../profile-sync-drift.js';
|
||||
import { CommandAdapterRegistry } from '../command-generation/index.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Skill directory removal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Removes ALL workflow skill directories under the given skillsDir.
|
||||
* Used when delivery changes to commands-only.
|
||||
*/
|
||||
export async function removeAllSkillDirs(skillsDir: string): Promise<number> {
|
||||
let removed = 0;
|
||||
|
||||
for (const workflow of ALL_WORKFLOWS) {
|
||||
const dirName = WORKFLOW_TO_SKILL_DIR[workflow];
|
||||
if (!dirName) continue;
|
||||
|
||||
const skillDir = path.join(skillsDir, dirName);
|
||||
try {
|
||||
if (fs.existsSync(skillDir)) {
|
||||
await fs.promises.rm(skillDir, { recursive: true, force: true });
|
||||
removed++;
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes skill directories for workflows that are NOT in the desired set.
|
||||
* Used during profile-sync to clean up deselected workflows.
|
||||
*/
|
||||
export async function removeUnselectedSkillDirs(
|
||||
skillsDir: string,
|
||||
desiredWorkflows: readonly string[],
|
||||
): Promise<number> {
|
||||
const desiredSet = new Set(desiredWorkflows);
|
||||
let removed = 0;
|
||||
|
||||
for (const workflow of ALL_WORKFLOWS) {
|
||||
if (desiredSet.has(workflow)) continue;
|
||||
const dirName = WORKFLOW_TO_SKILL_DIR[workflow];
|
||||
if (!dirName) continue;
|
||||
|
||||
const skillDir = path.join(skillsDir, dirName);
|
||||
try {
|
||||
if (fs.existsSync(skillDir)) {
|
||||
await fs.promises.rm(skillDir, { recursive: true, force: true });
|
||||
removed++;
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Command file removal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Removes ALL workflow command files for a given tool.
|
||||
* Used when delivery changes to skills-only.
|
||||
*/
|
||||
export async function removeAllCommandFiles(
|
||||
projectPath: string,
|
||||
toolId: string,
|
||||
): Promise<number> {
|
||||
let removed = 0;
|
||||
const adapter = CommandAdapterRegistry.get(toolId);
|
||||
if (!adapter) return 0;
|
||||
|
||||
for (const workflow of ALL_WORKFLOWS) {
|
||||
const cmdPath = adapter.getFilePath(workflow);
|
||||
const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath);
|
||||
|
||||
try {
|
||||
if (fs.existsSync(fullPath)) {
|
||||
await fs.promises.unlink(fullPath);
|
||||
removed++;
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes command files for workflows that are NOT in the desired set.
|
||||
* Used during profile-sync to clean up deselected workflows.
|
||||
*/
|
||||
export async function removeUnselectedCommandFiles(
|
||||
projectPath: string,
|
||||
toolId: string,
|
||||
desiredWorkflows: readonly string[],
|
||||
): Promise<number> {
|
||||
let removed = 0;
|
||||
const adapter = CommandAdapterRegistry.get(toolId);
|
||||
if (!adapter) return 0;
|
||||
|
||||
const desiredSet = new Set(desiredWorkflows);
|
||||
|
||||
for (const workflow of ALL_WORKFLOWS) {
|
||||
if (desiredSet.has(workflow)) continue;
|
||||
const cmdPath = adapter.getFilePath(workflow);
|
||||
const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath);
|
||||
|
||||
try {
|
||||
if (fs.existsSync(fullPath)) {
|
||||
await fs.promises.unlink(fullPath);
|
||||
removed++;
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
@@ -28,3 +28,18 @@ export {
|
||||
getCommandContents,
|
||||
generateSkillContent,
|
||||
} from './skill-generation.js';
|
||||
|
||||
export {
|
||||
removeAllSkillDirs,
|
||||
removeUnselectedSkillDirs,
|
||||
removeAllCommandFiles,
|
||||
removeUnselectedCommandFiles,
|
||||
} from './artifact-removal.js';
|
||||
|
||||
export {
|
||||
type OnboardingContext,
|
||||
formatGettingStarted,
|
||||
formatLinks,
|
||||
formatIdeRestart,
|
||||
printOnboardingFooter,
|
||||
} from './onboarding.js';
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Onboarding Helpers
|
||||
*
|
||||
* Shared output formatting for "getting started" and IDE-restart messages
|
||||
* used by both init and update commands.
|
||||
*/
|
||||
|
||||
import chalk from 'chalk';
|
||||
import type { Profile } from '../global-config.js';
|
||||
import { getProfileWorkflows } from '../profiles.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface OnboardingContext {
|
||||
/** Effective profile ('core' | 'custom'). */
|
||||
profile: Profile;
|
||||
/** Custom workflows list (only relevant when profile === 'custom'). */
|
||||
customWorkflows?: string[];
|
||||
/** Whether any tools were created or refreshed in this run. */
|
||||
hasConfiguredTools: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Getting-started message
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns the "Getting started" lines appropriate for the active profile.
|
||||
*
|
||||
* Both init and update previously had divergent implementations:
|
||||
* - init showed `/opsx:propose` or `/opsx:new` based on profile
|
||||
* - update showed a fixed `/opsx:new`, `/opsx:continue`, `/opsx:apply` set
|
||||
*
|
||||
* This function unifies the logic: show the primary entry-point command for the
|
||||
* active profile, keeping the output concise and consistent.
|
||||
*/
|
||||
export function formatGettingStarted(ctx: OnboardingContext): string[] {
|
||||
const activeWorkflows = [
|
||||
...getProfileWorkflows(ctx.profile, ctx.customWorkflows),
|
||||
];
|
||||
|
||||
const lines: string[] = [];
|
||||
|
||||
if (activeWorkflows.includes('propose')) {
|
||||
lines.push(chalk.bold('Getting started:'));
|
||||
lines.push(' Start your first change: /opsx:propose "your idea"');
|
||||
} else if (activeWorkflows.includes('new')) {
|
||||
lines.push(chalk.bold('Getting started:'));
|
||||
lines.push(' Start your first change: /opsx:new "your idea"');
|
||||
} else {
|
||||
lines.push(
|
||||
"Done. Run 'openspec config profile' to configure your workflows.",
|
||||
);
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Links
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function formatLinks(): string[] {
|
||||
return [
|
||||
`Learn more: ${chalk.cyan('https://github.com/Fission-AI/OpenSpec')}`,
|
||||
`Feedback: ${chalk.cyan('https://github.com/Fission-AI/OpenSpec/issues')}`,
|
||||
];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IDE-restart hint
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function formatIdeRestart(): string {
|
||||
return chalk.dim('Restart your IDE for changes to take effect.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: print the full onboarding footer (getting-started + links + IDE restart).
|
||||
*/
|
||||
export function printOnboardingFooter(ctx: OnboardingContext): void {
|
||||
const started = formatGettingStarted(ctx);
|
||||
console.log();
|
||||
for (const line of started) {
|
||||
console.log(line);
|
||||
}
|
||||
|
||||
console.log();
|
||||
for (const line of formatLinks()) {
|
||||
console.log(line);
|
||||
}
|
||||
|
||||
if (ctx.hasConfiguredTools) {
|
||||
console.log();
|
||||
console.log(formatIdeRestart());
|
||||
}
|
||||
|
||||
console.log();
|
||||
}
|
||||
+16
-132
@@ -23,6 +23,12 @@ import {
|
||||
getCommandContents,
|
||||
generateSkillContent,
|
||||
getToolsWithSkillsDir,
|
||||
removeAllSkillDirs,
|
||||
removeUnselectedSkillDirs,
|
||||
removeAllCommandFiles,
|
||||
removeUnselectedCommandFiles,
|
||||
printOnboardingFooter,
|
||||
formatIdeRestart,
|
||||
type ToolVersionStatus,
|
||||
} from './shared/index.js';
|
||||
import {
|
||||
@@ -38,7 +44,6 @@ import { getGlobalConfig, type Delivery } from './global-config.js';
|
||||
import { getProfileWorkflows, ALL_WORKFLOWS } from './profiles.js';
|
||||
import { getAvailableTools } from './available-tools.js';
|
||||
import {
|
||||
WORKFLOW_TO_SKILL_DIR,
|
||||
getCommandConfiguredTools,
|
||||
getConfiguredToolsForProfileSync,
|
||||
getToolsNeedingProfileSync,
|
||||
@@ -200,12 +205,12 @@ export class UpdateCommand {
|
||||
await FileSystemUtils.writeFile(skillFile, skillContent);
|
||||
}
|
||||
|
||||
removedDeselectedSkillCount += await this.removeUnselectedSkillDirs(skillsDir, desiredWorkflows);
|
||||
removedDeselectedSkillCount += await removeUnselectedSkillDirs(skillsDir, desiredWorkflows);
|
||||
}
|
||||
|
||||
// Delete skill directories if delivery is commands-only
|
||||
if (!shouldGenerateSkills) {
|
||||
removedSkillCount += await this.removeSkillDirs(skillsDir);
|
||||
removedSkillCount += await removeAllSkillDirs(skillsDir);
|
||||
}
|
||||
|
||||
// Generate commands if delivery includes commands
|
||||
@@ -219,7 +224,7 @@ export class UpdateCommand {
|
||||
await FileSystemUtils.writeFile(commandFile, cmd.fileContent);
|
||||
}
|
||||
|
||||
removedDeselectedCommandCount += await this.removeUnselectedCommandFiles(
|
||||
removedDeselectedCommandCount += await removeUnselectedCommandFiles(
|
||||
resolvedProjectPath,
|
||||
toolId,
|
||||
desiredWorkflows
|
||||
@@ -229,7 +234,7 @@ export class UpdateCommand {
|
||||
|
||||
// Delete command files if delivery is skills-only
|
||||
if (!shouldGenerateCommands) {
|
||||
removedCommandCount += await this.removeCommandFiles(resolvedProjectPath, toolId);
|
||||
removedCommandCount += await removeAllCommandFiles(resolvedProjectPath, toolId);
|
||||
}
|
||||
|
||||
spinner.succeed(`Updated ${tool.name}`);
|
||||
@@ -266,13 +271,11 @@ export class UpdateCommand {
|
||||
|
||||
// 12. 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')}`);
|
||||
printOnboardingFooter({
|
||||
profile,
|
||||
customWorkflows: globalConfig.workflows,
|
||||
hasConfiguredTools: false, // IDE restart is shown below for all cases
|
||||
});
|
||||
}
|
||||
|
||||
const configuredAndNewTools = [...new Set([...configuredTools, ...newlyConfiguredTools])];
|
||||
@@ -290,7 +293,7 @@ export class UpdateCommand {
|
||||
}
|
||||
|
||||
console.log();
|
||||
console.log(chalk.dim('Restart your IDE for changes to take effect.'));
|
||||
console.log(formatIdeRestart());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -369,125 +372,6 @@ export class UpdateCommand {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes skill directories for workflows when delivery changed to commands-only.
|
||||
* Returns the number of directories removed.
|
||||
*/
|
||||
private async removeSkillDirs(skillsDir: string): Promise<number> {
|
||||
let removed = 0;
|
||||
|
||||
for (const workflow of ALL_WORKFLOWS) {
|
||||
const dirName = WORKFLOW_TO_SKILL_DIR[workflow];
|
||||
if (!dirName) continue;
|
||||
|
||||
const skillDir = path.join(skillsDir, dirName);
|
||||
try {
|
||||
if (fs.existsSync(skillDir)) {
|
||||
await fs.promises.rm(skillDir, { recursive: true, force: true });
|
||||
removed++;
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes skill directories for workflows that are no longer selected in the active profile.
|
||||
* Returns the number of directories removed.
|
||||
*/
|
||||
private async removeUnselectedSkillDirs(
|
||||
skillsDir: string,
|
||||
desiredWorkflows: readonly (typeof ALL_WORKFLOWS)[number][]
|
||||
): Promise<number> {
|
||||
const desiredSet = new Set(desiredWorkflows);
|
||||
let removed = 0;
|
||||
|
||||
for (const workflow of ALL_WORKFLOWS) {
|
||||
if (desiredSet.has(workflow)) continue;
|
||||
const dirName = WORKFLOW_TO_SKILL_DIR[workflow];
|
||||
if (!dirName) continue;
|
||||
|
||||
const skillDir = path.join(skillsDir, dirName);
|
||||
try {
|
||||
if (fs.existsSync(skillDir)) {
|
||||
await fs.promises.rm(skillDir, { recursive: true, force: true });
|
||||
removed++;
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes command files for workflows when delivery changed to skills-only.
|
||||
* Returns the number of files removed.
|
||||
*/
|
||||
private async removeCommandFiles(
|
||||
projectPath: string,
|
||||
toolId: string,
|
||||
): Promise<number> {
|
||||
let removed = 0;
|
||||
|
||||
const adapter = CommandAdapterRegistry.get(toolId);
|
||||
if (!adapter) return 0;
|
||||
|
||||
for (const workflow of ALL_WORKFLOWS) {
|
||||
const cmdPath = adapter.getFilePath(workflow);
|
||||
const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath);
|
||||
|
||||
try {
|
||||
if (fs.existsSync(fullPath)) {
|
||||
await fs.promises.unlink(fullPath);
|
||||
removed++;
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes command files for workflows that are no longer selected in the active profile.
|
||||
* Returns the number of files removed.
|
||||
*/
|
||||
private async removeUnselectedCommandFiles(
|
||||
projectPath: string,
|
||||
toolId: string,
|
||||
desiredWorkflows: readonly (typeof ALL_WORKFLOWS)[number][]
|
||||
): Promise<number> {
|
||||
let removed = 0;
|
||||
|
||||
const adapter = CommandAdapterRegistry.get(toolId);
|
||||
if (!adapter) return 0;
|
||||
|
||||
const desiredSet = new Set(desiredWorkflows);
|
||||
|
||||
for (const workflow of ALL_WORKFLOWS) {
|
||||
if (desiredSet.has(workflow)) continue;
|
||||
const cmdPath = adapter.getFilePath(workflow);
|
||||
const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath);
|
||||
|
||||
try {
|
||||
if (fs.existsSync(fullPath)) {
|
||||
await fs.promises.unlink(fullPath);
|
||||
removed++;
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect and handle legacy OpenSpec artifacts.
|
||||
* Unlike init, update warns but continues if legacy files found in non-interactive mode.
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Output Consistency Tests
|
||||
*
|
||||
* Verifies that init, update, and migration produce consistent
|
||||
* onboarding output using the shared helpers.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { InitCommand } from '../../src/core/init.js';
|
||||
import { UpdateCommand } from '../../src/core/update.js';
|
||||
import { saveGlobalConfig, getGlobalConfig } from '../../src/core/global-config.js';
|
||||
|
||||
const { confirmMock, showWelcomeScreenMock, searchableMultiSelectMock } = vi.hoisted(() => ({
|
||||
confirmMock: vi.fn(),
|
||||
showWelcomeScreenMock: vi.fn().mockResolvedValue(undefined),
|
||||
searchableMultiSelectMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@inquirer/prompts', () => ({
|
||||
confirm: confirmMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../src/ui/welcome-screen.js', () => ({
|
||||
showWelcomeScreen: showWelcomeScreenMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../src/prompts/searchable-multi-select.js', () => ({
|
||||
searchableMultiSelect: searchableMultiSelectMock,
|
||||
}));
|
||||
|
||||
// Strip ANSI escape codes for content assertions
|
||||
function stripAnsi(str: string): string {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return str.replace(/\u001B\[[\d;]*m/g, '');
|
||||
}
|
||||
|
||||
function collectOutput(spy: ReturnType<typeof vi.spyOn>): string {
|
||||
return spy.mock.calls
|
||||
.map((call) => call.map(String).join(' '))
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
describe('init and update output consistency', () => {
|
||||
let testDir: string;
|
||||
let configTempDir: string;
|
||||
let originalEnv: NodeJS.ProcessEnv;
|
||||
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(async () => {
|
||||
testDir = path.join(os.tmpdir(), `openspec-output-test-${Date.now()}`);
|
||||
await fs.mkdir(testDir, { recursive: true });
|
||||
originalEnv = { ...process.env };
|
||||
configTempDir = path.join(os.tmpdir(), `openspec-config-output-${Date.now()}`);
|
||||
await fs.mkdir(configTempDir, { recursive: true });
|
||||
process.env.XDG_CONFIG_HOME = configTempDir;
|
||||
|
||||
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
confirmMock.mockReset();
|
||||
confirmMock.mockResolvedValue(true);
|
||||
showWelcomeScreenMock.mockClear();
|
||||
searchableMultiSelectMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
process.env = originalEnv;
|
||||
await fs.rm(testDir, { recursive: true, force: true });
|
||||
await fs.rm(configTempDir, { recursive: true, force: true });
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('init should include getting-started with /opsx:propose for core profile', async () => {
|
||||
const initCommand = new InitCommand({ tools: 'claude', force: true });
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const output = stripAnsi(collectOutput(consoleSpy));
|
||||
expect(output).toContain('Getting started');
|
||||
expect(output).toContain('/opsx:propose');
|
||||
});
|
||||
|
||||
it('init should include links in output', async () => {
|
||||
const initCommand = new InitCommand({ tools: 'claude', force: true });
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const output = stripAnsi(collectOutput(consoleSpy));
|
||||
expect(output).toContain('https://github.com/Fission-AI/OpenSpec');
|
||||
expect(output).toContain('https://github.com/Fission-AI/OpenSpec/issues');
|
||||
});
|
||||
|
||||
it('init should include IDE restart message', async () => {
|
||||
const initCommand = new InitCommand({ tools: 'claude', force: true });
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const output = stripAnsi(collectOutput(consoleSpy));
|
||||
expect(output).toContain('Restart your IDE');
|
||||
});
|
||||
|
||||
it('update should include IDE restart message after updating tools', async () => {
|
||||
// First init
|
||||
const initCommand = new InitCommand({ tools: 'claude', force: true });
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
consoleSpy.mockClear();
|
||||
|
||||
// Force update
|
||||
const updateCommand = new UpdateCommand({ force: true });
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
const output = stripAnsi(collectOutput(consoleSpy));
|
||||
expect(output).toContain('Restart your IDE');
|
||||
});
|
||||
|
||||
it('init and update should use the same IDE restart wording', async () => {
|
||||
const initCommand = new InitCommand({ tools: 'claude', force: true });
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const initOutput = stripAnsi(collectOutput(consoleSpy));
|
||||
const initRestartLine = initOutput.split('\n').find((l) => l.includes('Restart your IDE'));
|
||||
|
||||
consoleSpy.mockClear();
|
||||
|
||||
const updateCommand = new UpdateCommand({ force: true });
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
const updateOutput = stripAnsi(collectOutput(consoleSpy));
|
||||
const updateRestartLine = updateOutput.split('\n').find((l) => l.includes('Restart your IDE'));
|
||||
|
||||
expect(initRestartLine).toBe(updateRestartLine);
|
||||
});
|
||||
|
||||
it('init should show /opsx:new for custom profile without propose', async () => {
|
||||
saveGlobalConfig({
|
||||
featureFlags: {},
|
||||
profile: 'custom',
|
||||
delivery: 'both',
|
||||
workflows: ['explore', 'new', 'apply'],
|
||||
});
|
||||
|
||||
const initCommand = new InitCommand({ tools: 'claude', force: true });
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const output = stripAnsi(collectOutput(consoleSpy));
|
||||
expect(output).toContain('Getting started');
|
||||
expect(output).toContain('/opsx:new');
|
||||
expect(output).not.toContain('/opsx:propose');
|
||||
});
|
||||
|
||||
it('migration output should include propose hint', async () => {
|
||||
// Pre-create existing skills to trigger migration
|
||||
const skillsDir = path.join(testDir, '.claude', 'skills', 'openspec-explore');
|
||||
await fs.mkdir(skillsDir, { recursive: true });
|
||||
await fs.writeFile(path.join(skillsDir, 'SKILL.md'), 'old content');
|
||||
|
||||
// Create openspec dir to trigger extend mode
|
||||
await fs.mkdir(path.join(testDir, 'openspec'), { recursive: true });
|
||||
|
||||
// Run init (which triggers migrateIfNeeded in extend mode)
|
||||
const initCommand = new InitCommand({ tools: 'claude', force: true });
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const output = stripAnsi(collectOutput(consoleSpy));
|
||||
// Migration should mention propose
|
||||
expect(output).toContain('/opsx:propose');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
formatGettingStarted,
|
||||
formatLinks,
|
||||
formatIdeRestart,
|
||||
printOnboardingFooter,
|
||||
} from '../../src/core/shared/onboarding.js';
|
||||
|
||||
// Strip ANSI escape codes for content assertions
|
||||
function stripAnsi(str: string): string {
|
||||
return str.replace(/\u001B\[\d+m/g, '');
|
||||
}
|
||||
|
||||
describe('formatGettingStarted', () => {
|
||||
it('should show /opsx:propose when propose is in core profile', () => {
|
||||
const lines = formatGettingStarted({
|
||||
profile: 'core',
|
||||
hasConfiguredTools: true,
|
||||
});
|
||||
const plain = lines.map(stripAnsi);
|
||||
expect(plain).toContain(' Start your first change: /opsx:propose "your idea"');
|
||||
expect(plain.some((l) => l.includes('Getting started'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should show /opsx:new when custom profile has new but not propose', () => {
|
||||
const lines = formatGettingStarted({
|
||||
profile: 'custom',
|
||||
customWorkflows: ['explore', 'new', 'apply'],
|
||||
hasConfiguredTools: true,
|
||||
});
|
||||
const plain = lines.map(stripAnsi);
|
||||
expect(plain.some((l) => l.includes('/opsx:new'))).toBe(true);
|
||||
expect(plain.some((l) => l.includes('/opsx:propose'))).toBe(false);
|
||||
});
|
||||
|
||||
it('should show /opsx:propose when custom profile includes propose', () => {
|
||||
const lines = formatGettingStarted({
|
||||
profile: 'custom',
|
||||
customWorkflows: ['propose', 'explore', 'apply'],
|
||||
hasConfiguredTools: true,
|
||||
});
|
||||
const plain = lines.map(stripAnsi);
|
||||
expect(plain.some((l) => l.includes('/opsx:propose'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should show fallback message when profile has neither propose nor new', () => {
|
||||
const lines = formatGettingStarted({
|
||||
profile: 'custom',
|
||||
customWorkflows: ['explore'],
|
||||
hasConfiguredTools: true,
|
||||
});
|
||||
const plain = lines.map(stripAnsi);
|
||||
expect(plain.some((l) => l.includes('openspec config profile'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should show fallback when custom profile has empty workflows', () => {
|
||||
const lines = formatGettingStarted({
|
||||
profile: 'custom',
|
||||
customWorkflows: [],
|
||||
hasConfiguredTools: false,
|
||||
});
|
||||
const plain = lines.map(stripAnsi);
|
||||
expect(plain.some((l) => l.includes('openspec config profile'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatLinks', () => {
|
||||
it('should include repo and issues URLs', () => {
|
||||
const lines = formatLinks();
|
||||
const plain = lines.map(stripAnsi).join('\n');
|
||||
expect(plain).toContain('https://github.com/Fission-AI/OpenSpec');
|
||||
expect(plain).toContain('https://github.com/Fission-AI/OpenSpec/issues');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatIdeRestart', () => {
|
||||
it('should mention IDE restart', () => {
|
||||
const msg = stripAnsi(formatIdeRestart());
|
||||
expect(msg).toContain('Restart your IDE');
|
||||
});
|
||||
});
|
||||
|
||||
describe('printOnboardingFooter', () => {
|
||||
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should print getting-started, links, and IDE restart when tools were configured', () => {
|
||||
printOnboardingFooter({
|
||||
profile: 'core',
|
||||
hasConfiguredTools: true,
|
||||
});
|
||||
|
||||
const output = consoleSpy.mock.calls
|
||||
.map((call) => call.map(String).join(' '))
|
||||
.join('\n');
|
||||
const plain = stripAnsi(output);
|
||||
|
||||
expect(plain).toContain('Getting started');
|
||||
expect(plain).toContain('/opsx:propose');
|
||||
expect(plain).toContain('https://github.com/Fission-AI/OpenSpec');
|
||||
expect(plain).toContain('Restart your IDE');
|
||||
});
|
||||
|
||||
it('should not print IDE restart when no tools were configured', () => {
|
||||
printOnboardingFooter({
|
||||
profile: 'core',
|
||||
hasConfiguredTools: false,
|
||||
});
|
||||
|
||||
const output = consoleSpy.mock.calls
|
||||
.map((call) => call.map(String).join(' '))
|
||||
.join('\n');
|
||||
const plain = stripAnsi(output);
|
||||
|
||||
expect(plain).toContain('Getting started');
|
||||
expect(plain).not.toContain('Restart your IDE');
|
||||
});
|
||||
|
||||
it('should produce identical output for init and update contexts with same profile', () => {
|
||||
// Simulate init context
|
||||
printOnboardingFooter({
|
||||
profile: 'core',
|
||||
hasConfiguredTools: true,
|
||||
});
|
||||
const initOutput = consoleSpy.mock.calls
|
||||
.map((call) => call.map(String).join(' '))
|
||||
.join('\n');
|
||||
|
||||
consoleSpy.mockClear();
|
||||
|
||||
// Simulate update context (same profile)
|
||||
printOnboardingFooter({
|
||||
profile: 'core',
|
||||
hasConfiguredTools: true,
|
||||
});
|
||||
const updateOutput = consoleSpy.mock.calls
|
||||
.map((call) => call.map(String).join(' '))
|
||||
.join('\n');
|
||||
|
||||
expect(initOutput).toBe(updateOutput);
|
||||
});
|
||||
});
|
||||
@@ -1137,12 +1137,13 @@ More user content after markers.
|
||||
expect.stringContaining('Claude Code')
|
||||
);
|
||||
|
||||
// Should show getting started message for newly configured tools
|
||||
// Should show getting started message for newly configured tools.
|
||||
// The shared onboarding helper shows /opsx:propose for the default core profile.
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Getting started')
|
||||
);
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/opsx:new')
|
||||
expect.stringContaining('/opsx:propose')
|
||||
);
|
||||
|
||||
// Skills should be created
|
||||
|
||||
Reference in New Issue
Block a user