diff --git a/src/commands/config.ts b/src/commands/config.ts index 711ec5f9c3..2e78ce5767 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -1,5 +1,5 @@ import { Command } from 'commander'; -import { spawn, execSync } from 'node:child_process'; +import { spawn } from 'node:child_process'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { @@ -22,7 +22,8 @@ import { import { CORE_WORKFLOWS, ALL_WORKFLOWS, getProfileWorkflows } from '../core/profiles.js'; import { OPENSPEC_DIR_NAME } from '../core/config.js'; import { hasProjectConfigDrift } from '../core/profile-sync-drift.js'; -import { isPromptCancellationError } from './shared-output.js'; +import { UpdateCommand } from '../core/update.js'; +import { asErrorMessage, isPromptCancellationError } from './shared-output.js'; type ProfileAction = 'both' | 'delivery' | 'workflows' | 'keep'; @@ -621,10 +622,11 @@ export function registerConfigCommand(program: Command): void { if (applyNow) { try { - execSync('npx openspec update', { stdio: 'inherit', cwd: projectDir }); + await new UpdateCommand().execute(projectDir); console.log('Run `openspec update` in your other projects to apply.'); - } catch { - console.error('`openspec update` failed. Please run it manually to apply the profile changes.'); + } catch (error) { + console.error(`\`openspec update\` failed: ${asErrorMessage(error)}`); + console.error('Please run it manually to apply the profile changes.'); process.exitCode = 1; } return; diff --git a/test/commands/config-profile.test.ts b/test/commands/config-profile.test.ts index 679e89a547..ab18e4e6b7 100644 --- a/test/commands/config-profile.test.ts +++ b/test/commands/config-profile.test.ts @@ -3,15 +3,6 @@ import { Command } from 'commander'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; -import { execSync } from 'node:child_process'; - -vi.mock('node:child_process', async () => { - const actual = await vi.importActual('node:child_process'); - return { - ...actual, - execSync: vi.fn(), - }; -}); vi.mock('@inquirer/prompts', () => ({ select: vi.fn(), @@ -150,10 +141,10 @@ describe('config profile interactive flow', () => { consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - vi.mocked(execSync).mockReset(); }); afterEach(() => { + vi.unstubAllEnvs(); process.env = originalEnv; process.chdir(originalCwd); (process.stdout as NodeJS.WriteStream & { isTTY?: boolean }).isTTY = originalTTY; @@ -377,12 +368,15 @@ describe('config profile interactive flow', () => { }); }); - it('confirmed project apply should run openspec update in the project', async () => { + it('confirmed project apply should update in process without resolving openspec from PATH', async () => { const { saveGlobalConfig, getGlobalConfig } = await import('../../src/core/global-config.js'); const { select, confirm } = await getPromptMocks(); saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'update', 'sync', 'archive'] }); fs.mkdirSync(path.join(tempDir, 'openspec'), { recursive: true }); + const emptyBinDir = path.join(tempDir, 'empty-bin'); + fs.mkdirSync(emptyBinDir); + vi.stubEnv('PATH', emptyBinDir); select.mockResolvedValueOnce('delivery'); select.mockResolvedValueOnce('skills'); @@ -391,10 +385,35 @@ describe('config profile interactive flow', () => { await runConfigCommand(['profile']); expect(getGlobalConfig().delivery).toBe('skills'); - expect(execSync).toHaveBeenCalledWith('npx openspec update', { - stdio: 'inherit', - cwd: fs.realpathSync(tempDir), - }); + expect(process.exitCode).toBeUndefined(); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + expect(consoleLogSpy).toHaveBeenCalledWith('No configured tools found.'); + expect(consoleLogSpy).toHaveBeenCalledWith('Run `openspec update` in your other projects to apply.'); + }); + + it('confirmed project apply should report the update failure reason', async () => { + const { saveGlobalConfig } = await import('../../src/core/global-config.js'); + const { UpdateCommand } = await import('../../src/core/update.js'); + const { select, confirm } = await getPromptMocks(); + + saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'update', 'sync', 'archive'] }); + fs.mkdirSync(path.join(tempDir, 'openspec'), { recursive: true }); + const executeSpy = vi.spyOn(UpdateCommand.prototype, 'execute') + .mockRejectedValueOnce(new Error('permission denied')); + + select.mockResolvedValueOnce('delivery'); + select.mockResolvedValueOnce('skills'); + confirm.mockResolvedValueOnce(true); + + try { + await runConfigCommand(['profile']); + } finally { + executeSpy.mockRestore(); + } + + expect(consoleErrorSpy).toHaveBeenCalledWith('`openspec update` failed: permission denied'); + expect(consoleErrorSpy).toHaveBeenCalledWith('Please run it manually to apply the profile changes.'); + expect(process.exitCode).toBe(1); }); it('core preset should preserve delivery setting', async () => {