diff --git a/src/commands/config.ts b/src/commands/config.ts index 4a3382b95..90537f00a 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -539,6 +539,7 @@ export function registerConfigCommand(program: Command): void { delivery: currentState.delivery, workflows: [...currentState.workflows], }; + let workflowSelectionChanged = false; if (action === 'both' || action === 'delivery') { const deliveryChoices: { value: Delivery; name: string; description: string }[] = [ @@ -599,7 +600,12 @@ export function registerConfigCommand(program: Command): void { choices: ALL_WORKFLOWS.map(formatWorkflowChoice), }); nextState.workflows = selectedWorkflows; - nextState.profile = deriveProfileFromWorkflowSelection(selectedWorkflows); + workflowSelectionChanged = + selectedWorkflows.length !== currentState.workflows.length || + selectedWorkflows.some((workflow) => !currentState.workflows.includes(workflow)); + nextState.profile = workflowSelectionChanged + ? deriveProfileFromWorkflowSelection(selectedWorkflows) + : currentState.profile; } const diff = diffProfileState(currentState, nextState); @@ -617,7 +623,9 @@ export function registerConfigCommand(program: Command): void { config.profile = nextState.profile; config.delivery = nextState.delivery; - config.workflows = nextState.workflows; + if (currentState.profile !== 'custom' || workflowSelectionChanged) { + config.workflows = nextState.workflows; + } saveGlobalConfig(config); // Check if inside an OpenSpec project diff --git a/src/core/profiles.ts b/src/core/profiles.ts index acdc3ec95..64351cb8a 100644 --- a/src/core/profiles.ts +++ b/src/core/profiles.ts @@ -38,14 +38,27 @@ export type CoreWorkflowId = (typeof CORE_WORKFLOWS)[number]; * Resolves which workflows should be active for a given profile configuration. * * - 'core' profile always returns CORE_WORKFLOWS - * - 'custom' profile returns the provided customWorkflows, or empty array if not provided + * - 'custom' profile returns the provided customWorkflows and required dependencies */ export function getProfileWorkflows( profile: Profile, customWorkflows?: string[] ): readonly string[] { if (profile === 'custom') { - return customWorkflows ?? []; + const workflows = customWorkflows ?? []; + const syncDependentIndex = workflows.findIndex( + (workflow) => workflow === 'archive' || workflow === 'bulk-archive' + ); + + if (syncDependentIndex !== -1 && !workflows.includes('sync')) { + return [ + ...workflows.slice(0, syncDependentIndex), + 'sync', + ...workflows.slice(syncDependentIndex), + ]; + } + + return workflows; } return CORE_WORKFLOWS; } diff --git a/src/core/update.ts b/src/core/update.ts index 69fa4bafe..4c74d0851 100644 --- a/src/core/update.ts +++ b/src/core/update.ts @@ -247,7 +247,7 @@ export class UpdateCommand { // Still check for new tool directories and extra workflows this.detectNewTools(resolvedProjectPath, configuredTools); this.displayExtraWorkflowsNote(resolvedProjectPath, configuredTools, desiredWorkflows); - this.displayMissingCoreWorkflowsNote(profile, globalConfig.workflows); + this.displayMissingCoreWorkflowsNote(profile, desiredWorkflows); this.displaySetupNotes(configuredTools); return; } @@ -474,7 +474,7 @@ export class UpdateCommand { // 14. Display note about extra workflows not in profile this.displayExtraWorkflowsNote(resolvedProjectPath, configuredAndNewTools, desiredWorkflows); - this.displayMissingCoreWorkflowsNote(profile, globalConfig.workflows); + this.displayMissingCoreWorkflowsNote(profile, desiredWorkflows); this.displaySetupNotes(configuredAndNewTools); // 15. List affected tools @@ -1100,7 +1100,12 @@ export class UpdateCommand { } } - const inferredCodexWorkflows = getLegacyWorkflowIdsForTool(detection, 'codex'); + const inferredCodexWorkflows = getProfileWorkflows( + 'custom', + getLegacyWorkflowIdsForTool(detection, 'codex') + ).filter((workflow): workflow is (typeof ALL_WORKFLOWS)[number] => + (ALL_WORKFLOWS as readonly string[]).includes(workflow) + ); // Create skills/commands for selected tools using effective profile+delivery. const newlyConfigured: string[] = []; diff --git a/test/commands/config-profile.test.ts b/test/commands/config-profile.test.ts index bb130a8f6..3137814e6 100644 --- a/test/commands/config-profile.test.ts +++ b/test/commands/config-profile.test.ts @@ -288,6 +288,65 @@ describe('config profile interactive flow', () => { expect(consoleLogSpy).toHaveBeenCalledWith('No config changes.'); }); + it('should preserve a custom profile when dependency expansion matches the core set', async () => { + const { saveGlobalConfig, getGlobalConfig, getGlobalConfigPath } = await import('../../src/core/global-config.js'); + const { select, checkbox, confirm } = await getPromptMocks(); + + saveGlobalConfig({ + featureFlags: {}, + profile: 'custom', + delivery: 'both', + workflows: ['propose', 'explore', 'apply', 'update', 'archive'], + }); + const configPath = getGlobalConfigPath(); + const beforeContent = fs.readFileSync(configPath, 'utf-8'); + + select.mockResolvedValueOnce('workflows'); + checkbox.mockResolvedValueOnce(['propose', 'explore', 'apply', 'update', 'sync', 'archive']); + + await runConfigCommand(['profile']); + + expect(getGlobalConfig().profile).toBe('custom'); + expect(fs.readFileSync(configPath, 'utf-8')).toBe(beforeContent); + expect(confirm).not.toHaveBeenCalled(); + expect(consoleLogSpy).toHaveBeenCalledWith('No config changes.'); + }); + + it.each(['delivery', 'both'] as const)( + 'should preserve raw custom workflows during a %s change', + async (action) => { + const { saveGlobalConfig, getGlobalConfig } = await import('../../src/core/global-config.js'); + const { select, checkbox } = await getPromptMocks(); + + saveGlobalConfig({ + featureFlags: {}, + profile: 'custom', + delivery: 'both', + workflows: ['propose', 'explore', 'apply', 'update', 'archive'], + }); + select.mockResolvedValueOnce(action); + select.mockResolvedValueOnce('skills'); + if (action === 'both') { + checkbox.mockResolvedValueOnce([ + 'propose', + 'explore', + 'apply', + 'update', + 'sync', + 'archive', + ]); + } + + await runConfigCommand(['profile']); + + expect(getGlobalConfig()).toMatchObject({ + profile: 'custom', + delivery: 'skills', + workflows: ['propose', 'explore', 'apply', 'update', 'archive'], + }); + } + ); + it('keep action should warn when project files drift from global config', async () => { const { saveGlobalConfig } = await import('../../src/core/global-config.js'); const { select } = await getPromptMocks(); diff --git a/test/core/init.test.ts b/test/core/init.test.ts index 42a60a79f..388c19917 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -118,6 +118,34 @@ describe('InitCommand', () => { } }); + it.each([ + ['archive', 'openspec-archive-change'], + ['bulk-archive', 'openspec-bulk-archive-change'], + ] as const)( + 'should install the sync workflow required by %s in a custom profile', + async (archiveWorkflow, archiveSkill) => { + saveGlobalConfig({ + featureFlags: {}, + profile: 'custom', + delivery: 'both', + workflows: ['propose', 'explore', 'apply', archiveWorkflow], + }); + + const initCommand = new InitCommand({ tools: 'claude', force: true }); + await initCommand.execute(testDir); + + await expect( + fs.access(path.join(testDir, '.claude', 'skills', archiveSkill, 'SKILL.md')) + ).resolves.toBeUndefined(); + await expect( + fs.access(path.join(testDir, '.claude', 'skills', 'openspec-sync-specs', 'SKILL.md')) + ).resolves.toBeUndefined(); + await expect( + fs.access(path.join(testDir, '.claude', 'commands', 'opsx', 'sync.md')) + ).resolves.toBeUndefined(); + } + ); + it('should create core profile commands for Claude Code by default', async () => { const initCommand = new InitCommand({ tools: 'claude', force: true }); diff --git a/test/core/profiles.test.ts b/test/core/profiles.test.ts index b06456e01..79ff45a1c 100644 --- a/test/core/profiles.test.ts +++ b/test/core/profiles.test.ts @@ -54,6 +54,32 @@ describe('profiles', () => { expect(result).toEqual(customWorkflows); }); + it('should include sync when a custom profile selects archive', () => { + const result = getProfileWorkflows('custom', ['propose', 'explore', 'apply', 'archive']); + expect(result).toEqual(['propose', 'explore', 'apply', 'sync', 'archive']); + }); + + it('should include sync when a custom profile selects bulk archive', () => { + const result = getProfileWorkflows('custom', ['explore', 'bulk-archive']); + expect(result).toEqual(['explore', 'sync', 'bulk-archive']); + }); + + it('should not duplicate or reorder an existing sync dependency', () => { + const workflows = ['sync', 'archive', 'bulk-archive']; + const result = getProfileWorkflows('custom', workflows); + + expect(result).toEqual(workflows); + expect(result).toBe(workflows); + }); + + it('should not mutate the custom workflow selection when adding sync', () => { + const workflows = ['archive', 'bulk-archive']; + const result = getProfileWorkflows('custom', workflows); + + expect(result).toEqual(['sync', 'archive', 'bulk-archive']); + expect(workflows).toEqual(['archive', 'bulk-archive']); + }); + it('should return empty array for custom profile with no customWorkflows', () => { const result = getProfileWorkflows('custom'); expect(result).toEqual([]); diff --git a/test/core/update.test.ts b/test/core/update.test.ts index b541bdadc..c3085e878 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -2333,6 +2333,33 @@ ${OPENSPEC_MARKERS.end} )).toBe(false); }); + it.each([ + ['opsx-archive.md', 'openspec-archive-change'], + ['opsx-bulk-archive.md', 'openspec-bulk-archive-change'], + ])('should include sync when replacing legacy Codex %s', async (promptName, archiveSkill) => { + setMockConfig({ + featureFlags: {}, + profile: 'core', + delivery: 'skills', + }); + + const promptDir = path.join(process.env.CODEX_HOME!, 'prompts'); + const managedPrompt = path.join(promptDir, promptName); + await fs.mkdir(promptDir, { recursive: true }); + await fs.writeFile(managedPrompt, 'legacy archive prompt'); + + const forceUpdateCommand = new UpdateCommand({ force: true }); + await forceUpdateCommand.execute(testDir); + + expect(await FileSystemUtils.fileExists(managedPrompt)).toBe(false); + expect(await FileSystemUtils.fileExists( + path.join(testDir, '.agents', 'skills', archiveSkill, 'SKILL.md') + )).toBe(true); + expect(await FileSystemUtils.fileExists( + path.join(testDir, '.agents', 'skills', 'openspec-sync-specs', 'SKILL.md') + )).toBe(true); + }); + it('should print a skill-based getting-started menu when a legacy upgrade newly configures codex', async () => { setMockConfig({ featureFlags: {}, @@ -3003,40 +3030,63 @@ More user content after markers. )).toBe(false); }); - it('should list missing core workflows when custom profile preserves the old core workflow set', async () => { - setMockConfig({ - featureFlags: {}, - profile: 'custom', - delivery: 'both', - workflows: ['propose', 'explore', 'apply', 'archive'], - }); - - const initCommand = new InitCommand({ tools: 'claude', force: true }); - await initCommand.execute(testDir); - - const consoleSpy = vi.spyOn(console, 'log'); + it.each(['skills', 'commands', 'both'] as const)( + 'should repair an archive profile missing sync with %s delivery', + async (delivery) => { + setMockConfig({ + featureFlags: {}, + profile: 'custom', + delivery, + workflows: ['propose', 'explore', 'apply', 'archive'], + }); - await updateCommand.execute(testDir); + const archiveSkill = path.join( + testDir, + '.claude', + 'skills', + 'openspec-archive-change', + 'SKILL.md' + ); + const archiveCommand = path.join( + testDir, + '.claude', + 'commands', + 'opsx', + 'archive.md' + ); + if (delivery !== 'commands') { + await fs.mkdir(path.dirname(archiveSkill), { recursive: true }); + await fs.writeFile(archiveSkill, 'old archive skill'); + } + if (delivery !== 'skills') { + await fs.mkdir(path.dirname(archiveCommand), { recursive: true }); + await fs.writeFile(archiveCommand, 'old archive command'); + } - const calls = consoleSpy.mock.calls.map(call => - call.map(arg => String(arg)).join(' ') - ); - expect(calls.some(call => - call.includes('Your custom profile is missing 2 core workflows: update, sync') - )).toBe(true); - expect(calls.some(call => - call.includes('openspec config profile core') - )).toBe(true); + const consoleSpy = vi.spyOn(console, 'log'); - expect(await FileSystemUtils.fileExists( - path.join(testDir, '.claude', 'skills', 'openspec-sync-specs', 'SKILL.md') - )).toBe(false); - expect(await FileSystemUtils.fileExists( - path.join(testDir, '.claude', 'commands', 'opsx', 'sync.md') - )).toBe(false); + await updateCommand.execute(testDir); - consoleSpy.mockRestore(); - }); + const calls = consoleSpy.mock.calls.map(call => + call.map(arg => String(arg)).join(' ') + ); + expect(calls.some(call => + call.includes('Your custom profile is missing 1 core workflow: update') + )).toBe(true); + expect(calls.some(call => + call.includes('openspec config profile core') + )).toBe(true); + + expect(await FileSystemUtils.fileExists( + path.join(testDir, '.claude', 'skills', 'openspec-sync-specs', 'SKILL.md') + )).toBe(delivery !== 'commands'); + expect(await FileSystemUtils.fileExists( + path.join(testDir, '.claude', 'commands', 'opsx', 'sync.md') + )).toBe(delivery !== 'skills'); + + consoleSpy.mockRestore(); + } + ); it('should list a single missing core workflow when custom profile lacks only update', async () => { setMockConfig({