Compare commits

...
Author SHA1 Message Date
Claude c989198a0d refactor: unify onboarding output and artifact removal across init/update
The init and update commands had divergent "getting started" messages,
duplicated WORKFLOW_TO_SKILL_DIR constants, and copy-pasted skill/command
removal methods. This caused update to hardcode /opsx:new regardless of
the active profile, while init correctly showed /opsx:propose for core.

Extract shared helpers so both commands produce consistent, profile-aware
onboarding output and share a single implementation for artifact removal.

- Add src/core/shared/onboarding.ts (formatGettingStarted, printOnboardingFooter)
- Add src/core/shared/artifact-removal.ts (removeAll/Unselected SkillDirs/CommandFiles)
- Remove duplicate WORKFLOW_TO_SKILL_DIR from init.ts (canonical source: profile-sync-drift.ts)
- Remove 4 private removal methods from UpdateCommand, 2 from InitCommand
- Fix update legacy-upgrade onboarding to respect active profile
- Add shared-onboarding.test.ts and output-consistency.test.ts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 05:00:03 +00:00
8 changed files with 601 additions and 218 deletions
+12 -84
View File
@@ -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;
}
}
+138
View File
@@ -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;
}
+15
View File
@@ -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';
+101
View File
@@ -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
View File
@@ -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.
+167
View File
@@ -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');
});
});
+149
View File
@@ -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);
});
});
+3 -2
View File
@@ -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