mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-14 10:18:14 +00:00
fix(mcp): retry Claude user config locks
This commit is contained in:
1 parent
f59536a81f
commit
cf90f48240
6 files changed
+220
-83
No files matched your search
@@ -4,10 +4,13 @@
|
|||||||
|
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as lockfile from 'proper-lockfile';
|
|
||||||
import { InstanceManager } from '../../management/instance-manager';
|
import { InstanceManager } from '../../management/instance-manager';
|
||||||
import { getClaudeUserConfigPath } from '../claude-config-path';
|
import { getClaudeUserConfigPath } from '../claude-config-path';
|
||||||
import { getCcsDir } from '../config-manager';
|
import { getCcsDir } from '../config-manager';
|
||||||
|
import {
|
||||||
|
isClaudeUserConfigLockUnavailableError as isLockUnavailableError,
|
||||||
|
withClaudeUserConfigLock,
|
||||||
|
} from '../claude-user-config-lock';
|
||||||
|
|
||||||
const BROWSER_MCP_SERVER = 'ccs-browser-server.cjs';
|
const BROWSER_MCP_SERVER = 'ccs-browser-server.cjs';
|
||||||
const BROWSER_MCP_SERVER_NAME = 'ccs-browser';
|
const BROWSER_MCP_SERVER_NAME = 'ccs-browser';
|
||||||
@@ -133,38 +136,6 @@ function writeClaudeUserConfig(configPath: string, config: ClaudeUserConfig): bo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function withClaudeUserConfigLock<T>(configPath: string, callback: () => T): T {
|
|
||||||
const configDir = path.dirname(configPath);
|
|
||||||
const lockTarget = path.join(configDir, `${path.basename(configPath)}.ccs-lock`);
|
|
||||||
let release: (() => void) | undefined;
|
|
||||||
|
|
||||||
if (!fs.existsSync(configDir)) {
|
|
||||||
fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!fs.existsSync(lockTarget)) {
|
|
||||||
fs.writeFileSync(lockTarget, '', { encoding: 'utf8', mode: 0o600 });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
release = lockfile.lockSync(lockTarget, { stale: 10000 }) as () => void;
|
|
||||||
return callback();
|
|
||||||
} finally {
|
|
||||||
if (release) {
|
|
||||||
try {
|
|
||||||
release();
|
|
||||||
} catch {
|
|
||||||
// Best-effort release.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isLockUnavailableError(error: unknown): boolean {
|
|
||||||
const code = (error as NodeJS.ErrnoException | undefined)?.code;
|
|
||||||
return code === 'ELOCKED' || code === 'ENOTACQUIRED';
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeManagedServerConfig(configPath: string): boolean {
|
function removeManagedServerConfig(configPath: string): boolean {
|
||||||
if (!fs.existsSync(configPath)) {
|
if (!fs.existsSync(configPath)) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as lockfile from 'proper-lockfile';
|
||||||
|
|
||||||
|
const DEFAULT_LOCK_STALE_MS = 10000;
|
||||||
|
const DEFAULT_LOCK_RETRY_DELAY_MS = 200;
|
||||||
|
const DEFAULT_LOCK_RETRY_TIMEOUT_MS = 10000;
|
||||||
|
|
||||||
|
interface LockOptions {
|
||||||
|
staleMs?: number;
|
||||||
|
retryDelayMs?: number;
|
||||||
|
retryTimeoutMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function withClaudeUserConfigLock<T>(
|
||||||
|
configPath: string,
|
||||||
|
callback: () => T,
|
||||||
|
options: LockOptions = {}
|
||||||
|
): T {
|
||||||
|
const configDir = path.dirname(configPath);
|
||||||
|
const lockTarget = path.join(configDir, `${path.basename(configPath)}.ccs-lock`);
|
||||||
|
const staleMs = options.staleMs ?? DEFAULT_LOCK_STALE_MS;
|
||||||
|
const retryDelayMs = options.retryDelayMs ?? DEFAULT_LOCK_RETRY_DELAY_MS;
|
||||||
|
const retryTimeoutMs = options.retryTimeoutMs ?? DEFAULT_LOCK_RETRY_TIMEOUT_MS;
|
||||||
|
let release: (() => void) | undefined;
|
||||||
|
|
||||||
|
if (!fs.existsSync(configDir)) {
|
||||||
|
fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(lockTarget)) {
|
||||||
|
fs.writeFileSync(lockTarget, '', { encoding: 'utf8', mode: 0o600 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
release = acquireClaudeUserConfigLock(
|
||||||
|
lockTarget,
|
||||||
|
configPath,
|
||||||
|
staleMs,
|
||||||
|
retryDelayMs,
|
||||||
|
retryTimeoutMs
|
||||||
|
);
|
||||||
|
return callback();
|
||||||
|
} finally {
|
||||||
|
if (release) {
|
||||||
|
try {
|
||||||
|
release();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isClaudeUserConfigLockUnavailableError(error: unknown): boolean {
|
||||||
|
const code = (error as NodeJS.ErrnoException | undefined)?.code;
|
||||||
|
return code === 'ELOCKED' || code === 'ENOTACQUIRED';
|
||||||
|
}
|
||||||
|
|
||||||
|
function acquireClaudeUserConfigLock(
|
||||||
|
lockTarget: string,
|
||||||
|
configPath: string,
|
||||||
|
staleMs: number,
|
||||||
|
retryDelayMs: number,
|
||||||
|
retryTimeoutMs: number
|
||||||
|
): () => void {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
let lastError: unknown;
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
try {
|
||||||
|
return lockfile.lockSync(lockTarget, { stale: staleMs }) as () => void;
|
||||||
|
} catch (error) {
|
||||||
|
if (!isClaudeUserConfigLockUnavailableError(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
lastError = error;
|
||||||
|
if (Date.now() - startedAt >= retryTimeoutMs) {
|
||||||
|
throw buildLockTimeoutError(configPath, retryTimeoutMs, lastError);
|
||||||
|
}
|
||||||
|
sleepSync(retryDelayMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleepSync(ms: number): void {
|
||||||
|
if (ms <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLockTimeoutError(
|
||||||
|
configPath: string,
|
||||||
|
timeoutMs: number,
|
||||||
|
cause: unknown
|
||||||
|
): NodeJS.ErrnoException {
|
||||||
|
const causeCode = (cause as NodeJS.ErrnoException | undefined)?.code;
|
||||||
|
const error = new Error(
|
||||||
|
`Failed to acquire Claude user config lock for ${configPath} after ${timeoutMs}ms; another CCS process may be starting CLIProxy`
|
||||||
|
) as NodeJS.ErrnoException;
|
||||||
|
error.code = causeCode === 'ENOTACQUIRED' ? 'ENOTACQUIRED' : 'ELOCKED';
|
||||||
|
return error;
|
||||||
|
}
|
||||||
@@ -4,7 +4,6 @@
|
|||||||
|
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as lockfile from 'proper-lockfile';
|
|
||||||
|
|
||||||
import { getCcsDir } from '../config-manager';
|
import { getCcsDir } from '../config-manager';
|
||||||
import { getClaudeUserConfigPath } from '../claude-config-path';
|
import { getClaudeUserConfigPath } from '../claude-config-path';
|
||||||
@@ -12,6 +11,10 @@ import { info, warn } from '../ui';
|
|||||||
import { InstanceManager } from '../../management/instance-manager';
|
import { InstanceManager } from '../../management/instance-manager';
|
||||||
import { installImageAnalysisPrompts } from './hook-installer';
|
import { installImageAnalysisPrompts } from './hook-installer';
|
||||||
import { getImageAnalysisConfig } from '../../config/config-loader-facade';
|
import { getImageAnalysisConfig } from '../../config/config-loader-facade';
|
||||||
|
import {
|
||||||
|
isClaudeUserConfigLockUnavailableError as isLockUnavailableError,
|
||||||
|
withClaudeUserConfigLock,
|
||||||
|
} from '../claude-user-config-lock';
|
||||||
|
|
||||||
const IMAGE_ANALYSIS_MCP_SERVER = 'ccs-image-analysis-server.cjs';
|
const IMAGE_ANALYSIS_MCP_SERVER = 'ccs-image-analysis-server.cjs';
|
||||||
const IMAGE_ANALYSIS_MCP_RUNTIME = 'image-analysis-runtime.cjs';
|
const IMAGE_ANALYSIS_MCP_RUNTIME = 'image-analysis-runtime.cjs';
|
||||||
@@ -121,38 +124,6 @@ function writeClaudeUserConfig(configPath: string, config: ClaudeUserConfig): bo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function withClaudeUserConfigLock<T>(configPath: string, callback: () => T): T {
|
|
||||||
const configDir = path.dirname(configPath);
|
|
||||||
const lockTarget = path.join(configDir, `${path.basename(configPath)}.ccs-lock`);
|
|
||||||
let release: (() => void) | undefined;
|
|
||||||
|
|
||||||
if (!fs.existsSync(configDir)) {
|
|
||||||
fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!fs.existsSync(lockTarget)) {
|
|
||||||
fs.writeFileSync(lockTarget, '', { encoding: 'utf8', mode: 0o600 });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
release = lockfile.lockSync(lockTarget, { stale: 10000 }) as () => void;
|
|
||||||
return callback();
|
|
||||||
} finally {
|
|
||||||
if (release) {
|
|
||||||
try {
|
|
||||||
release();
|
|
||||||
} catch {
|
|
||||||
// Best-effort release.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isLockUnavailableError(error: unknown): boolean {
|
|
||||||
const code = (error as NodeJS.ErrnoException | undefined)?.code;
|
|
||||||
return code === 'ELOCKED' || code === 'ENOTACQUIRED';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hasImageAnalysisMcpServerInstalled(): boolean {
|
export function hasImageAnalysisMcpServerInstalled(): boolean {
|
||||||
return (
|
return (
|
||||||
fs.existsSync(getImageAnalysisMcpServerPath()) &&
|
fs.existsSync(getImageAnalysisMcpServerPath()) &&
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ import { InstanceManager } from '../../management/instance-manager';
|
|||||||
import { installWebSearchHook } from './hook-installer';
|
import { installWebSearchHook } from './hook-installer';
|
||||||
import { appendWebSearchTrace } from './trace';
|
import { appendWebSearchTrace } from './trace';
|
||||||
import { getWebSearchConfig } from '../../config/config-loader-facade';
|
import { getWebSearchConfig } from '../../config/config-loader-facade';
|
||||||
|
import {
|
||||||
|
isClaudeUserConfigLockUnavailableError as isLockUnavailableError,
|
||||||
|
withClaudeUserConfigLock,
|
||||||
|
} from '../claude-user-config-lock';
|
||||||
|
|
||||||
const WEBSEARCH_MCP_SERVER = 'ccs-websearch-server.cjs';
|
const WEBSEARCH_MCP_SERVER = 'ccs-websearch-server.cjs';
|
||||||
const WEBSEARCH_MCP_SERVER_NAME = 'ccs-websearch';
|
const WEBSEARCH_MCP_SERVER_NAME = 'ccs-websearch';
|
||||||
@@ -118,6 +122,8 @@ function removeManagedServerConfig(configPath: string): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return withClaudeUserConfigLock(configPath, () => {
|
||||||
const config = readClaudeUserConfig(configPath);
|
const config = readClaudeUserConfig(configPath);
|
||||||
if (config === null) {
|
if (config === null) {
|
||||||
if (process.env.CCS_DEBUG) {
|
if (process.env.CCS_DEBUG) {
|
||||||
@@ -127,7 +133,9 @@ function removeManagedServerConfig(configPath: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const existingServers =
|
const existingServers =
|
||||||
config.mcpServers && typeof config.mcpServers === 'object' && !Array.isArray(config.mcpServers)
|
config.mcpServers &&
|
||||||
|
typeof config.mcpServers === 'object' &&
|
||||||
|
!Array.isArray(config.mcpServers)
|
||||||
? { ...(config.mcpServers as Record<string, unknown>) }
|
? { ...(config.mcpServers as Record<string, unknown>) }
|
||||||
: {};
|
: {};
|
||||||
|
|
||||||
@@ -160,6 +168,18 @@ function removeManagedServerConfig(configPath: string): boolean {
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (isLockUnavailableError(error)) {
|
||||||
|
appendWebSearchTrace('websearch_mcp_config_remove_skipped', {
|
||||||
|
reason: 'user_config_locked',
|
||||||
|
configPath,
|
||||||
|
error: (error as Error).message,
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function installWebSearchMcpServer(): boolean {
|
export function installWebSearchMcpServer(): boolean {
|
||||||
@@ -243,7 +263,9 @@ export function ensureWebSearchMcpConfig(): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const claudeUserConfigPath = getClaudeUserConfigPath();
|
const claudeUserConfigPath = getClaudeUserConfigPath();
|
||||||
const claudeUserConfigDir = path.dirname(claudeUserConfigPath);
|
|
||||||
|
try {
|
||||||
|
return withClaudeUserConfigLock(claudeUserConfigPath, () => {
|
||||||
const config = readClaudeUserConfig(claudeUserConfigPath);
|
const config = readClaudeUserConfig(claudeUserConfigPath);
|
||||||
|
|
||||||
if (config === null) {
|
if (config === null) {
|
||||||
@@ -254,12 +276,10 @@ export function ensureWebSearchMcpConfig(): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!fs.existsSync(claudeUserConfigDir)) {
|
|
||||||
fs.mkdirSync(claudeUserConfigDir, { recursive: true, mode: 0o700 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingServers =
|
const existingServers =
|
||||||
config.mcpServers && typeof config.mcpServers === 'object' && !Array.isArray(config.mcpServers)
|
config.mcpServers &&
|
||||||
|
typeof config.mcpServers === 'object' &&
|
||||||
|
!Array.isArray(config.mcpServers)
|
||||||
? (config.mcpServers as Record<string, unknown>)
|
? (config.mcpServers as Record<string, unknown>)
|
||||||
: {};
|
: {};
|
||||||
const desiredServerConfig: ManagedWebSearchMcpConfig = {
|
const desiredServerConfig: ManagedWebSearchMcpConfig = {
|
||||||
@@ -305,6 +325,25 @@ export function ensureWebSearchMcpConfig(): boolean {
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (isLockUnavailableError(error)) {
|
||||||
|
appendWebSearchTrace('websearch_mcp_config_failed', {
|
||||||
|
reason: 'user_config_locked',
|
||||||
|
configPath: claudeUserConfigPath,
|
||||||
|
error: (error as Error).message,
|
||||||
|
});
|
||||||
|
if (process.env.CCS_DEBUG) {
|
||||||
|
console.error(
|
||||||
|
warn(
|
||||||
|
`WebSearch MCP config skipped because ${claudeUserConfigPath} is locked by another process`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ensureWebSearchMcp(): boolean {
|
export function ensureWebSearchMcp(): boolean {
|
||||||
|
|||||||
@@ -194,26 +194,54 @@ describe('ensureImageAnalysisMcp', () => {
|
|||||||
expect(lockSpy.mock.calls[0]?.[0]).toBe(path.join(tempHome as string, '.claude.json.ccs-lock'));
|
expect(lockSpy.mock.calls[0]?.[0]).toBe(path.join(tempHome as string, '.claude.json.ccs-lock'));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns false instead of throwing when ~/.claude.json is already locked', () => {
|
it('retries ~/.claude.json lock acquisition before provisioning', () => {
|
||||||
|
setupTempHome();
|
||||||
|
writeEnabledConfig();
|
||||||
|
const claudeUserConfigPath = path.join(tempHome as string, '.claude.json');
|
||||||
|
fs.writeFileSync(claudeUserConfigPath, '{}\n', 'utf8');
|
||||||
|
|
||||||
|
let calls = 0;
|
||||||
|
spyOn(lockfile, 'lockSync').mockImplementation(() => {
|
||||||
|
calls++;
|
||||||
|
if (calls === 1) {
|
||||||
|
const error = new Error('Lock file is already being held') as NodeJS.ErrnoException;
|
||||||
|
error.code = 'ELOCKED';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return (() => undefined) as ReturnType<typeof lockfile.lockSync>;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(ensureImageAnalysisMcp()).toBe(true);
|
||||||
|
expect(calls).toBe(2);
|
||||||
|
|
||||||
|
const config = JSON.parse(fs.readFileSync(claudeUserConfigPath, 'utf8')) as {
|
||||||
|
mcpServers: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
expect(config.mcpServers[getImageAnalysisMcpServerName()]).toEqual(getManagedConfig());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false instead of throwing when ~/.claude.json stays locked', () => {
|
||||||
setupTempHome();
|
setupTempHome();
|
||||||
writeEnabledConfig();
|
writeEnabledConfig();
|
||||||
|
|
||||||
const claudeUserConfigPath = path.join(tempHome as string, '.claude.json');
|
const claudeUserConfigPath = path.join(tempHome as string, '.claude.json');
|
||||||
fs.writeFileSync(claudeUserConfigPath, '{}\n', 'utf8');
|
fs.writeFileSync(claudeUserConfigPath, '{}\n', 'utf8');
|
||||||
fs.writeFileSync(path.join(tempHome as string, '.claude.json.ccs-lock'), '', 'utf8');
|
|
||||||
|
|
||||||
const release = lockfile.lockSync(path.join(tempHome as string, '.claude.json.ccs-lock'), {
|
let now = 0;
|
||||||
stale: 10000,
|
spyOn(Date, 'now').mockImplementation(() => {
|
||||||
}) as () => void;
|
now += 10001;
|
||||||
|
return now;
|
||||||
|
});
|
||||||
|
spyOn(lockfile, 'lockSync').mockImplementation(() => {
|
||||||
|
const error = new Error('Lock file is already being held') as NodeJS.ErrnoException;
|
||||||
|
error.code = 'ELOCKED';
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
|
||||||
let result: boolean | undefined;
|
let result: boolean | undefined;
|
||||||
expect(() => {
|
expect(() => {
|
||||||
result = ensureImageAnalysisMcp();
|
result = ensureImageAnalysisMcp();
|
||||||
}).not.toThrow();
|
}).not.toThrow();
|
||||||
expect(result).toBe(false);
|
expect(result).toBe(false);
|
||||||
} finally {
|
|
||||||
release();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test';
|
|||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
import * as lockfile from 'proper-lockfile';
|
||||||
import { getHookPath } from '../../../../src/utils/websearch/hook-config';
|
import { getHookPath } from '../../../../src/utils/websearch/hook-config';
|
||||||
import {
|
import {
|
||||||
ensureWebSearchMcp,
|
ensureWebSearchMcp,
|
||||||
@@ -33,7 +34,15 @@ describe('ensureWebSearchMcp', () => {
|
|||||||
fs.mkdirSync(ccsDir, { recursive: true });
|
fs.mkdirSync(ccsDir, { recursive: true });
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(ccsDir, 'config.yaml'),
|
path.join(ccsDir, 'config.yaml'),
|
||||||
['version: 12', 'websearch:', ' enabled: true', ' providers:', ' duckduckgo:', ' enabled: true', ''].join('\n'),
|
[
|
||||||
|
'version: 12',
|
||||||
|
'websearch:',
|
||||||
|
' enabled: true',
|
||||||
|
' providers:',
|
||||||
|
' duckduckgo:',
|
||||||
|
' enabled: true',
|
||||||
|
'',
|
||||||
|
].join('\n'),
|
||||||
'utf8'
|
'utf8'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -95,6 +104,19 @@ describe('ensureWebSearchMcp', () => {
|
|||||||
expect(config.mcpServers[getWebSearchMcpServerName()]).toEqual(getManagedConfig());
|
expect(config.mcpServers[getWebSearchMcpServerName()]).toEqual(getManagedConfig());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('serializes ~/.claude.json updates with a file lock', () => {
|
||||||
|
setupTempHome();
|
||||||
|
writeEnabledConfig();
|
||||||
|
const claudeUserConfigPath = path.join(tempHome as string, '.claude.json');
|
||||||
|
fs.writeFileSync(claudeUserConfigPath, '{}\n', 'utf8');
|
||||||
|
|
||||||
|
const lockSpy = spyOn(lockfile, 'lockSync');
|
||||||
|
|
||||||
|
expect(ensureWebSearchMcp()).toBe(true);
|
||||||
|
expect(lockSpy).toHaveBeenCalled();
|
||||||
|
expect(lockSpy.mock.calls[0]?.[0]).toBe(path.join(tempHome as string, '.claude.json.ccs-lock'));
|
||||||
|
});
|
||||||
|
|
||||||
it('preserves the existing ~/.claude.json permissions when provisioning WebSearch MCP', () => {
|
it('preserves the existing ~/.claude.json permissions when provisioning WebSearch MCP', () => {
|
||||||
setupTempHome();
|
setupTempHome();
|
||||||
writeEnabledConfig();
|
writeEnabledConfig();
|
||||||
@@ -170,7 +192,11 @@ describe('ensureWebSearchMcp', () => {
|
|||||||
'utf8'
|
'utf8'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const lockSpy = spyOn(lockfile, 'lockSync');
|
||||||
|
|
||||||
expect(uninstallWebSearchMcp()).toBe(true);
|
expect(uninstallWebSearchMcp()).toBe(true);
|
||||||
|
expect(lockSpy).toHaveBeenCalled();
|
||||||
|
expect(lockSpy.mock.calls[0]?.[0]).toBe(path.join(tempHome as string, '.claude.json.ccs-lock'));
|
||||||
expect(fs.existsSync(getWebSearchMcpServerPath())).toBe(false);
|
expect(fs.existsSync(getWebSearchMcpServerPath())).toBe(false);
|
||||||
|
|
||||||
const globalConfig = JSON.parse(fs.readFileSync(claudeUserConfigPath, 'utf8')) as {
|
const globalConfig = JSON.parse(fs.readFileSync(claudeUserConfigPath, 'utf8')) as {
|
||||||
|
|||||||
Reference in new issue
Block a user