From 2b1d0d0f37e2de333ce112e63d7a663dae314516 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Mon, 21 Sep 2026 10:52:41 -0500 Subject: [PATCH 1/3] fix(update): report legacy Codex bootstrap failures --- .changeset/tidy-codex-bootstrap.md | 5 +++ src/core/update.ts | 30 ++++++++++++++---- test/core/update.test.ts | 49 ++++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 6 deletions(-) create mode 100644 .changeset/tidy-codex-bootstrap.md diff --git a/.changeset/tidy-codex-bootstrap.md b/.changeset/tidy-codex-bootstrap.md new file mode 100644 index 0000000000..10c19576f5 --- /dev/null +++ b/.changeset/tidy-codex-bootstrap.md @@ -0,0 +1,5 @@ +--- +"@fission-ai/openspec": patch +--- + +Return a nonzero exit status when `openspec update --force` cannot replace a legacy-only Codex installation. diff --git a/src/core/update.ts b/src/core/update.ts index 772950fbab..851adfec5e 100644 --- a/src/core/update.ts +++ b/src/core/update.ts @@ -89,6 +89,7 @@ const { version: OPENSPEC_VERSION } = require('../../package.json'); type LegacyUpgradeResult = { newlyConfiguredTools: string[]; workflowOverrides: Partial>; + failedTools?: ToolFailure[]; deferredGlobalCleanup?: LegacyDetectionResult; /** * Tools whose skill generation was skipped because another tool already owns @@ -98,6 +99,14 @@ type LegacyUpgradeResult = { skippedSharedSkillTools?: string[]; }; +type ToolFailure = { name: string; error: string }; + +function throwIfUpdateFailed(failedTools: readonly ToolFailure[]): void { + if (failedTools.length > 0) { + throw new Error(`OpenSpec update failed for: ${failedTools.map((tool) => tool.name).join(', ')}`); + } +} + /** * Checkout artifacts that are not real content drift: a UTF-8 BOM and the CRLF * line endings a Windows clone with `core.autocrlf` reintroduces on every @@ -185,6 +194,7 @@ export class UpdateCommand { newlyConfiguredTools, workflowOverrides: legacyWorkflowOverrides, deferredGlobalCleanup, + failedTools: legacyUpgradeFailures = [], } = legacyUpgrade; // 5. Find configured tools @@ -212,6 +222,7 @@ export class UpdateCommand { return; } await this.syncCopilotCloudFiles(resolvedProjectPath, configuredAndNewTools); + throwIfUpdateFailed(legacyUpgradeFailures); console.log(chalk.yellow('No configured tools found.')); console.log(chalk.dim('Run "openspec init" to set up tools.')); return; @@ -269,6 +280,7 @@ export class UpdateCommand { this.detectNewTools(resolvedProjectPath, configuredTools); this.displayProfileNotes(resolvedProjectPath, configuredTools, desiredWorkflows, profile, delivery); this.displaySetupNotes(configuredTools); + throwIfUpdateFailed(legacyUpgradeFailures); return; } @@ -299,7 +311,7 @@ export class UpdateCommand { ); const updatedTools: string[] = []; const updatedToolIds: string[] = []; - const failedTools: Array<{ name: string; error: string }> = []; + const failedTools: ToolFailure[] = [...legacyUpgradeFailures]; const skillsInvocableCommandSkips: string[] = []; const zeroArtifactTools: string[] = []; let removedCommandCount = 0; @@ -526,9 +538,7 @@ export class UpdateCommand { if (restartHint) { console.log(chalk.dim(restartHint)); } - if (failedTools.length > 0) { - throw new Error(`OpenSpec update failed for: ${failedTools.map((tool) => tool.name).join(', ')}`); - } + throwIfUpdateFailed(failedTools); } private async syncCopilotCloudFiles(projectPath: string, configuredTools: string[]): Promise { @@ -1277,6 +1287,7 @@ export class UpdateCommand { // Create skills/commands for selected tools using effective profile+delivery. const newlyConfigured: string[] = []; const skippedSharedSkillTools: string[] = []; + const failedTools: ToolFailure[] = []; const workflowOverrides: LegacyUpgradeResult['workflowOverrides'] = {}; const arbitrationTools = [...new Set([...configuredTools, ...selectedTools])] .map((toolId) => AI_TOOLS.find((tool) => tool.value === toolId)) @@ -1377,7 +1388,9 @@ export class UpdateCommand { } } catch (error) { spinner.fail(`Failed to set up ${tool.name}`); - console.log(chalk.red(` ${error instanceof Error ? error.message : String(error)}`)); + const message = error instanceof Error ? error.message : String(error); + console.log(chalk.red(` ${message}`)); + failedTools.push({ name: tool.name, error: message }); } } @@ -1385,6 +1398,11 @@ export class UpdateCommand { console.log(); } - return { newlyConfiguredTools: newlyConfigured, workflowOverrides, skippedSharedSkillTools }; + return { + newlyConfiguredTools: newlyConfigured, + workflowOverrides, + skippedSharedSkillTools, + failedTools, + }; } } diff --git a/test/core/update.test.ts b/test/core/update.test.ts index 8541d43d46..43b2faa1b8 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -1688,6 +1688,55 @@ metadata: }); describe('error handling', () => { + it('should report a failed legacy-only Codex bootstrap to automation', async () => { + setMockConfig({ featureFlags: {}, profile: 'core', delivery: 'commands' }); + + const prompt = path.join(process.env.CODEX_HOME!, 'prompts', 'opsx-explore.md'); + await fs.mkdir(path.dirname(prompt), { recursive: true }); + await fs.writeFile(prompt, 'legacy prompt'); + + const originalWriteFile = FileSystemUtils.writeFile.bind(FileSystemUtils); + vi.spyOn(FileSystemUtils, 'writeFile').mockImplementation(async (filePath, content) => { + if (filePath.includes(`${path.sep}.agents${path.sep}`) && filePath.endsWith('SKILL.md')) { + throw new Error('EACCES: permission denied'); + } + return originalWriteFile(filePath, content); + }); + + await expect(new UpdateCommand({ force: true }).execute(testDir)).rejects.toThrow( + 'OpenSpec update failed for: Codex' + ); + await expect(fs.access(prompt)).resolves.toBeUndefined(); + await expect( + fs.access(path.join(testDir, '.agents', 'skills', 'openspec-explore', 'SKILL.md')) + ).rejects.toThrow(); + }); + + it('should refresh configured tools after a legacy Codex bootstrap fails', async () => { + setMockConfig({ featureFlags: {}, profile: 'core', delivery: 'commands' }); + + const prompt = path.join(process.env.CODEX_HOME!, 'prompts', 'opsx-explore.md'); + await fs.mkdir(path.dirname(prompt), { recursive: true }); + await fs.writeFile(prompt, 'legacy prompt'); + + const cursorCommand = path.join(testDir, '.cursor', 'commands', 'opsx-explore.md'); + await fs.mkdir(path.dirname(cursorCommand), { recursive: true }); + await fs.writeFile(cursorCommand, 'old'); + + const originalWriteFile = FileSystemUtils.writeFile.bind(FileSystemUtils); + vi.spyOn(FileSystemUtils, 'writeFile').mockImplementation(async (filePath, content) => { + if (filePath.includes(`${path.sep}.agents${path.sep}`) && filePath.endsWith('SKILL.md')) { + throw new Error('EACCES: permission denied'); + } + return originalWriteFile(filePath, content); + }); + + await expect(new UpdateCommand({ force: true }).execute(testDir)).rejects.toThrow( + 'OpenSpec update failed for: Codex' + ); + expect(await fs.readFile(cursorCommand, 'utf-8')).not.toBe('old'); + }); + it('should preserve legacy Codex skills and prompts when canonical generation fails', async () => { const legacySkill = path.join( testDir, From f4b6e549f461c7633ff6b616a3493e20054b924e Mon Sep 17 00:00:00 2001 From: Clay Good Date: Mon, 21 Sep 2026 11:02:15 -0500 Subject: [PATCH 2/3] fix(update): preserve failures after declined migrations --- src/core/update.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/core/update.ts b/src/core/update.ts index 851adfec5e..2bf9f22b72 100644 --- a/src/core/update.ts +++ b/src/core/update.ts @@ -219,6 +219,7 @@ export class UpdateCommand { chalk.dim(`Re-run "openspec update" and accept the move to ${migration.to}/ to resume updates.`) ); } + throwIfUpdateFailed(legacyUpgradeFailures); return; } await this.syncCopilotCloudFiles(resolvedProjectPath, configuredAndNewTools); From b4fdfd700b5e60c46638579c60e23ff745a77415 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Mon, 21 Sep 2026 13:15:15 -0500 Subject: [PATCH 3/3] test(update): cover every bootstrap failure exit --- src/core/update.ts | 3 +- test/core/update.test.ts | 108 +++++++++++++++++++++++++++++++-------- 2 files changed, 88 insertions(+), 23 deletions(-) diff --git a/src/core/update.ts b/src/core/update.ts index 2bf9f22b72..1f624a345d 100644 --- a/src/core/update.ts +++ b/src/core/update.ts @@ -205,6 +205,7 @@ export class UpdateCommand { if (deferredGlobalCleanup) { await this.performDeferredGlobalPromptCleanup(resolvedProjectPath, deferredGlobalCleanup); } + throwIfUpdateFailed(legacyUpgradeFailures); if (declinedMigrations.length > 0) { // Not an unconfigured project — a configured one the user chose to // leave in its former directory. Saying "run init" would be wrong. @@ -219,11 +220,9 @@ export class UpdateCommand { chalk.dim(`Re-run "openspec update" and accept the move to ${migration.to}/ to resume updates.`) ); } - throwIfUpdateFailed(legacyUpgradeFailures); return; } await this.syncCopilotCloudFiles(resolvedProjectPath, configuredAndNewTools); - throwIfUpdateFailed(legacyUpgradeFailures); console.log(chalk.yellow('No configured tools found.')); console.log(chalk.dim('Run "openspec init" to set up tools.')); return; diff --git a/test/core/update.test.ts b/test/core/update.test.ts index 43b2faa1b8..ae0efd7d84 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -11,6 +11,23 @@ import path from 'path'; import fs from 'fs/promises'; import os from 'os'; +const { confirmMock, searchableMultiSelectMock, interactiveState } = vi.hoisted(() => ({ + confirmMock: vi.fn(), + searchableMultiSelectMock: vi.fn(), + interactiveState: { value: false }, +})); + +vi.mock('@inquirer/prompts', () => ({ confirm: confirmMock })); + +vi.mock('../../src/prompts/searchable-multi-select.js', () => ({ + searchableMultiSelect: searchableMultiSelectMock, +})); + +vi.mock('../../src/utils/interactive.js', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, isInteractive: () => interactiveState.value }; +}); + // Shared mutable mock config state const mockState = { config: { @@ -45,6 +62,23 @@ async function markCodexTarget(skillsDir: string): Promise { await fs.writeFile(path.join(skillsDir, '.openspec-target'), 'codex\n'); } +async function createLegacyCodexPrompt(): Promise { + const prompt = path.join(process.env.CODEX_HOME!, 'prompts', 'opsx-explore.md'); + await fs.mkdir(path.dirname(prompt), { recursive: true }); + await fs.writeFile(prompt, 'legacy prompt'); + return prompt; +} + +function failCodexSkillWrites(): void { + const originalWriteFile = FileSystemUtils.writeFile.bind(FileSystemUtils); + vi.spyOn(FileSystemUtils, 'writeFile').mockImplementation(async (filePath, content) => { + if (filePath.includes(`${path.sep}.agents${path.sep}`) && filePath.endsWith('SKILL.md')) { + throw new Error('EACCES: permission denied'); + } + return originalWriteFile(filePath, content); + }); +} + describe('UpdateCommand', () => { let testDir: string; let updateCommand: UpdateCommand; @@ -66,6 +100,9 @@ describe('UpdateCommand', () => { // Reset mock config to defaults resetMockConfig(); + interactiveState.value = false; + confirmMock.mockReset(); + searchableMultiSelectMock.mockReset(); // Clear all mocks before each test vi.restoreAllMocks(); @@ -1691,17 +1728,8 @@ metadata: it('should report a failed legacy-only Codex bootstrap to automation', async () => { setMockConfig({ featureFlags: {}, profile: 'core', delivery: 'commands' }); - const prompt = path.join(process.env.CODEX_HOME!, 'prompts', 'opsx-explore.md'); - await fs.mkdir(path.dirname(prompt), { recursive: true }); - await fs.writeFile(prompt, 'legacy prompt'); - - const originalWriteFile = FileSystemUtils.writeFile.bind(FileSystemUtils); - vi.spyOn(FileSystemUtils, 'writeFile').mockImplementation(async (filePath, content) => { - if (filePath.includes(`${path.sep}.agents${path.sep}`) && filePath.endsWith('SKILL.md')) { - throw new Error('EACCES: permission denied'); - } - return originalWriteFile(filePath, content); - }); + const prompt = await createLegacyCodexPrompt(); + failCodexSkillWrites(); await expect(new UpdateCommand({ force: true }).execute(testDir)).rejects.toThrow( 'OpenSpec update failed for: Codex' @@ -1715,21 +1743,13 @@ metadata: it('should refresh configured tools after a legacy Codex bootstrap fails', async () => { setMockConfig({ featureFlags: {}, profile: 'core', delivery: 'commands' }); - const prompt = path.join(process.env.CODEX_HOME!, 'prompts', 'opsx-explore.md'); - await fs.mkdir(path.dirname(prompt), { recursive: true }); - await fs.writeFile(prompt, 'legacy prompt'); + await createLegacyCodexPrompt(); const cursorCommand = path.join(testDir, '.cursor', 'commands', 'opsx-explore.md'); await fs.mkdir(path.dirname(cursorCommand), { recursive: true }); await fs.writeFile(cursorCommand, 'old'); - const originalWriteFile = FileSystemUtils.writeFile.bind(FileSystemUtils); - vi.spyOn(FileSystemUtils, 'writeFile').mockImplementation(async (filePath, content) => { - if (filePath.includes(`${path.sep}.agents${path.sep}`) && filePath.endsWith('SKILL.md')) { - throw new Error('EACCES: permission denied'); - } - return originalWriteFile(filePath, content); - }); + failCodexSkillWrites(); await expect(new UpdateCommand({ force: true }).execute(testDir)).rejects.toThrow( 'OpenSpec update failed for: Codex' @@ -1737,6 +1757,52 @@ metadata: expect(await fs.readFile(cursorCommand, 'utf-8')).not.toBe('old'); }); + it('should report a failed bootstrap when configured tools are already current', async () => { + setMockConfig({ featureFlags: {}, profile: 'core', delivery: 'commands' }); + await new InitCommand({ tools: 'cursor', force: true }).execute(testDir); + + await createLegacyCodexPrompt(); + + interactiveState.value = true; + confirmMock.mockResolvedValue(true); + searchableMultiSelectMock.mockResolvedValue(['codex']); + + failCodexSkillWrites(); + const consoleSpy = vi.spyOn(console, 'log'); + + await expect(new UpdateCommand().execute(testDir)).rejects.toThrow( + 'OpenSpec update failed for: Codex' + ); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('All 1 tool(s) up to date')); + }); + + it('should report a failed bootstrap after declining an unrelated migration', async () => { + setMockConfig({ featureFlags: {}, profile: 'core', delivery: 'commands' }); + + const legacySkill = path.join( + testDir, + '.windsurf', + 'skills', + 'openspec-explore', + 'SKILL.md' + ); + await fs.mkdir(path.dirname(legacySkill), { recursive: true }); + await fs.writeFile(legacySkill, 'legacy skill'); + + await createLegacyCodexPrompt(); + + interactiveState.value = true; + confirmMock.mockResolvedValueOnce(false).mockResolvedValueOnce(true); + searchableMultiSelectMock.mockResolvedValue(['codex']); + + failCodexSkillWrites(); + + await expect(new UpdateCommand().execute(testDir)).rejects.toThrow( + 'OpenSpec update failed for: Codex' + ); + expect(await fs.readFile(legacySkill, 'utf-8')).toBe('legacy skill'); + }); + it('should preserve legacy Codex skills and prompts when canonical generation fails', async () => { const legacySkill = path.join( testDir,