From 2625ca7d671251e3df35446ae934a8a7ba07ce92 Mon Sep 17 00:00:00 2001 From: showms Date: Sun, 12 Jul 2026 16:46:52 +0800 Subject: [PATCH 1/3] fix: avoid npx when applying profile changes --- src/commands/config.ts | 2 +- test/commands/config-profile.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/commands/config.ts b/src/commands/config.ts index 711ec5f9c3..8b731c4594 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -621,7 +621,7 @@ export function registerConfigCommand(program: Command): void { if (applyNow) { try { - execSync('npx openspec update', { stdio: 'inherit', cwd: projectDir }); + execSync('openspec update', { stdio: 'inherit', cwd: 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.'); diff --git a/test/commands/config-profile.test.ts b/test/commands/config-profile.test.ts index 679e89a547..59a6149da3 100644 --- a/test/commands/config-profile.test.ts +++ b/test/commands/config-profile.test.ts @@ -391,7 +391,7 @@ describe('config profile interactive flow', () => { await runConfigCommand(['profile']); expect(getGlobalConfig().delivery).toBe('skills'); - expect(execSync).toHaveBeenCalledWith('npx openspec update', { + expect(execSync).toHaveBeenCalledWith('openspec update', { stdio: 'inherit', cwd: fs.realpathSync(tempDir), }); From 6c5eb37ea921eeca602659af31d19173338c7f9c Mon Sep 17 00:00:00 2001 From: TabishB Date: Sat, 18 Jul 2026 22:48:57 +1000 Subject: [PATCH 2/3] fix(config): apply profile updates in process --- src/commands/config.ts | 5 +++-- test/commands/config-profile.test.ts | 23 ++++++++--------------- 2 files changed, 11 insertions(+), 17 deletions(-) diff --git a/src/commands/config.ts b/src/commands/config.ts index 8b731c4594..bf7196d651 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,6 +22,7 @@ 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 { UpdateCommand } from '../core/update.js'; import { isPromptCancellationError } from './shared-output.js'; type ProfileAction = 'both' | 'delivery' | 'workflows' | 'keep'; @@ -621,7 +622,7 @@ export function registerConfigCommand(program: Command): void { if (applyNow) { try { - execSync('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.'); diff --git a/test/commands/config-profile.test.ts b/test/commands/config-profile.test.ts index 59a6149da3..b99a1bc075 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,7 +141,6 @@ describe('config profile interactive flow', () => { consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - vi.mocked(execSync).mockReset(); }); afterEach(() => { @@ -377,12 +367,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); + process.env.PATH = emptyBinDir; select.mockResolvedValueOnce('delivery'); select.mockResolvedValueOnce('skills'); @@ -391,10 +384,10 @@ describe('config profile interactive flow', () => { await runConfigCommand(['profile']); expect(getGlobalConfig().delivery).toBe('skills'); - expect(execSync).toHaveBeenCalledWith('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('core preset should preserve delivery setting', async () => { From 8fba2e1357fb8423b6c0b0218ad4092757c87a3e Mon Sep 17 00:00:00 2001 From: TabishB Date: Sun, 19 Jul 2026 00:12:49 +1000 Subject: [PATCH 3/3] fix(config): address profile apply review feedback --- src/commands/config.ts | 7 ++++--- test/commands/config-profile.test.ts | 28 +++++++++++++++++++++++++++- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/commands/config.ts b/src/commands/config.ts index bf7196d651..2e78ce5767 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -23,7 +23,7 @@ import { CORE_WORKFLOWS, ALL_WORKFLOWS, getProfileWorkflows } from '../core/prof import { OPENSPEC_DIR_NAME } from '../core/config.js'; import { hasProjectConfigDrift } from '../core/profile-sync-drift.js'; import { UpdateCommand } from '../core/update.js'; -import { isPromptCancellationError } from './shared-output.js'; +import { asErrorMessage, isPromptCancellationError } from './shared-output.js'; type ProfileAction = 'both' | 'delivery' | 'workflows' | 'keep'; @@ -624,8 +624,9 @@ export function registerConfigCommand(program: Command): void { try { 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 b99a1bc075..ab18e4e6b7 100644 --- a/test/commands/config-profile.test.ts +++ b/test/commands/config-profile.test.ts @@ -144,6 +144,7 @@ describe('config profile interactive flow', () => { }); afterEach(() => { + vi.unstubAllEnvs(); process.env = originalEnv; process.chdir(originalCwd); (process.stdout as NodeJS.WriteStream & { isTTY?: boolean }).isTTY = originalTTY; @@ -375,7 +376,7 @@ describe('config profile interactive flow', () => { fs.mkdirSync(path.join(tempDir, 'openspec'), { recursive: true }); const emptyBinDir = path.join(tempDir, 'empty-bin'); fs.mkdirSync(emptyBinDir); - process.env.PATH = emptyBinDir; + vi.stubEnv('PATH', emptyBinDir); select.mockResolvedValueOnce('delivery'); select.mockResolvedValueOnce('skills'); @@ -390,6 +391,31 @@ describe('config profile interactive flow', () => { 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 () => { const { saveGlobalConfig, getGlobalConfig } = await import('../../src/core/global-config.js'); const { select, checkbox, confirm } = await getPromptMocks();