mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 14:16:43 +00:00
fix(auth): preserve bare profile behavior across runtime and sync
This commit is contained in:
@@ -111,6 +111,9 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
|
||||
const previousUnifiedProfile = existsUnified
|
||||
? ctx.registry.getAllAccountsUnified()[profileName]
|
||||
: undefined;
|
||||
const previousBare =
|
||||
previousLegacyProfile?.bare === true || previousUnifiedProfile?.bare === true;
|
||||
const effectiveBare = bare === true || (profileExistedBeforeCreate && previousBare);
|
||||
const previousContextPolicy =
|
||||
profileExistedBeforeCreate && (previousUnifiedProfile || previousLegacyProfile)
|
||||
? resolveAccountContextPolicy(previousUnifiedProfile || previousLegacyProfile)
|
||||
@@ -169,7 +172,9 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
|
||||
|
||||
if (previousContextPolicy) {
|
||||
try {
|
||||
await ctx.instanceMgr.ensureInstance(profileName, previousContextPolicy);
|
||||
await ctx.instanceMgr.ensureInstance(profileName, previousContextPolicy, {
|
||||
bare: previousBare,
|
||||
});
|
||||
} catch {
|
||||
// Best-effort rollback for context mode/group.
|
||||
}
|
||||
@@ -180,7 +185,7 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
|
||||
// Create instance directory
|
||||
console.log(info(`Creating profile: ${profileName}`));
|
||||
const instancePath = await ctx.instanceMgr.ensureInstance(profileName, contextPolicy, {
|
||||
bare: !!bare,
|
||||
bare: effectiveBare,
|
||||
});
|
||||
|
||||
// Create/update profile entry based on config mode
|
||||
@@ -190,13 +195,13 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
|
||||
ctx.registry.updateAccountUnified(profileName, {
|
||||
context_mode: contextMetadata.context_mode,
|
||||
context_group: contextMetadata.context_group,
|
||||
...(bare ? { bare: true } : {}),
|
||||
...(effectiveBare ? { bare: true } : {}),
|
||||
});
|
||||
ctx.registry.touchAccountUnified(profileName);
|
||||
} else {
|
||||
ctx.registry.createAccountUnified(profileName, {
|
||||
...contextMetadata,
|
||||
...(bare ? { bare: true } : {}),
|
||||
...(effectiveBare ? { bare: true } : {}),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
@@ -206,14 +211,14 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
|
||||
type: 'account',
|
||||
context_mode: contextMetadata.context_mode,
|
||||
context_group: contextMetadata.context_group,
|
||||
...(bare ? { bare: true } : {}),
|
||||
...(effectiveBare ? { bare: true } : {}),
|
||||
});
|
||||
} else {
|
||||
ctx.registry.createProfile(profileName, {
|
||||
type: 'account',
|
||||
context_mode: contextMetadata.context_mode,
|
||||
context_group: contextMetadata.context_group,
|
||||
...(bare ? { bare: true } : {}),
|
||||
...(effectiveBare ? { bare: true } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -271,7 +276,7 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
|
||||
`Instance: ${instancePath}\n` +
|
||||
`Type: account\n` +
|
||||
`Context: ${formatAccountContextPolicy(contextPolicy)}` +
|
||||
(bare ? '\nMode: bare (no shared symlinks)' : ''),
|
||||
(effectiveBare ? '\nMode: bare (no shared symlinks)' : ''),
|
||||
'Profile Created'
|
||||
)
|
||||
);
|
||||
|
||||
@@ -146,7 +146,9 @@ export async function resolveProfileContinuityInheritance(
|
||||
isAccountContextMetadata(mappedProfile) ? mappedProfile : undefined
|
||||
);
|
||||
const instanceMgr = new InstanceManager();
|
||||
const instancePath = await instanceMgr.ensureInstance(sourceAccount, contextPolicy);
|
||||
const instancePath = await instanceMgr.ensureInstance(sourceAccount, contextPolicy, {
|
||||
bare: mappedProfile.bare === true,
|
||||
});
|
||||
|
||||
return {
|
||||
sourceAccount,
|
||||
|
||||
@@ -203,6 +203,7 @@ class ProfileDetector {
|
||||
context_mode: account.context_mode,
|
||||
context_group: account.context_group,
|
||||
continuity_mode: account.continuity_mode,
|
||||
bare: account.bare,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ interface CreateMetadata {
|
||||
context_mode?: 'isolated' | 'shared';
|
||||
context_group?: string;
|
||||
continuity_mode?: 'standard' | 'deeper';
|
||||
bare?: boolean;
|
||||
}
|
||||
|
||||
export class ProfileRegistry {
|
||||
@@ -173,6 +174,7 @@ export class ProfileRegistry {
|
||||
context_mode: metadata.context_mode,
|
||||
context_group: metadata.context_group,
|
||||
continuity_mode: metadata.continuity_mode,
|
||||
bare: metadata.bare,
|
||||
});
|
||||
|
||||
// Note: No longer auto-set as default
|
||||
@@ -321,6 +323,7 @@ export class ProfileRegistry {
|
||||
context_mode: metadata.context_mode,
|
||||
context_group: metadata.context_group,
|
||||
continuity_mode: metadata.continuity_mode,
|
||||
bare: metadata.bare,
|
||||
});
|
||||
saveUnifiedConfig(config);
|
||||
}
|
||||
@@ -449,6 +452,7 @@ export class ProfileRegistry {
|
||||
context_mode: account.context_mode,
|
||||
context_group: account.context_group,
|
||||
continuity_mode: account.continuity_mode,
|
||||
bare: account.bare,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+7
-1
@@ -1132,10 +1132,16 @@ async function main(): Promise<void> {
|
||||
const accountMetadata = isAccountContextMetadata(profileInfo.profile)
|
||||
? profileInfo.profile
|
||||
: undefined;
|
||||
const isBareProfile =
|
||||
typeof profileInfo.profile === 'object' &&
|
||||
profileInfo.profile !== null &&
|
||||
(profileInfo.profile as { bare?: unknown }).bare === true;
|
||||
const contextPolicy = resolveAccountContextPolicy(accountMetadata);
|
||||
|
||||
// Ensure instance exists (lazy init if needed)
|
||||
const instancePath = await instanceMgr.ensureInstance(profileInfo.name, contextPolicy);
|
||||
const instancePath = await instanceMgr.ensureInstance(profileInfo.name, contextPolicy, {
|
||||
bare: isBareProfile,
|
||||
});
|
||||
|
||||
// Update last_used timestamp (check unified config first, fallback to legacy)
|
||||
if (registry.hasAccountUnified(profileInfo.name)) {
|
||||
|
||||
@@ -53,9 +53,13 @@ export async function handleSyncCommand(): Promise<void> {
|
||||
if (profile.bare) {
|
||||
continue; // Skip bare profiles
|
||||
}
|
||||
|
||||
if (!instanceMgr.hasInstance(name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const instancePath = instanceMgr.getInstancePath(name);
|
||||
if (instancePath) {
|
||||
instanceMgr.syncMcpServers(instancePath);
|
||||
if (instanceMgr.syncMcpServers(instancePath)) {
|
||||
mcpSynced++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import { AccountContextPolicy, DEFAULT_ACCOUNT_CONTEXT_MODE } from '../auth/acco
|
||||
import { getCcsDir, getCcsHome } from '../utils/config-manager';
|
||||
|
||||
/** Options for instance creation */
|
||||
interface InstanceOptions {
|
||||
export interface InstanceOptions {
|
||||
/** Skip shared symlinks (commands, skills, agents, settings.json) */
|
||||
bare?: boolean;
|
||||
}
|
||||
@@ -108,9 +108,6 @@ class InstanceManager {
|
||||
if (!options.bare) {
|
||||
this.sharedManager.linkSharedDirectories(instancePath);
|
||||
}
|
||||
|
||||
// Copy global configs if exist (settings.json only)
|
||||
this.copyGlobalConfigs(instancePath);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to initialize instance for ${profileName}: ${(error as Error).message}`
|
||||
@@ -180,33 +177,31 @@ class InstanceManager {
|
||||
return fs.existsSync(instancePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy global configs to instance (optional)
|
||||
*/
|
||||
private copyGlobalConfigs(_instancePath: string): void {
|
||||
// No longer needed - settings.json now symlinked via SharedManager
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync MCP servers from global ~/.claude.json to instance .claude.json.
|
||||
* Selectively copies only mcpServers key (not OAuth sessions or caches).
|
||||
*/
|
||||
syncMcpServers(instancePath: string): void {
|
||||
syncMcpServers(instancePath: string): boolean {
|
||||
const homeDir = getCcsHome();
|
||||
const globalClaudeJson = path.join(homeDir, '.claude.json');
|
||||
|
||||
if (!fs.existsSync(globalClaudeJson)) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const globalContent = JSON.parse(fs.readFileSync(globalClaudeJson, 'utf8'));
|
||||
const mcpServers = globalContent.mcpServers;
|
||||
|
||||
if (!mcpServers || Object.keys(mcpServers).length === 0) {
|
||||
return;
|
||||
const rawMcpServers = globalContent.mcpServers;
|
||||
if (
|
||||
!rawMcpServers ||
|
||||
typeof rawMcpServers !== 'object' ||
|
||||
Array.isArray(rawMcpServers) ||
|
||||
Object.keys(rawMcpServers).length === 0
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const mcpServers = rawMcpServers as Record<string, unknown>;
|
||||
const instanceClaudeJson = path.join(instancePath, '.claude.json');
|
||||
let instanceContent: Record<string, unknown> = {};
|
||||
|
||||
@@ -220,12 +215,22 @@ class InstanceManager {
|
||||
}
|
||||
|
||||
// Merge: global MCP servers as base, instance-specific overrides on top
|
||||
const existingMcp = (instanceContent.mcpServers as Record<string, unknown> | undefined) || {};
|
||||
const rawExistingMcp = instanceContent.mcpServers;
|
||||
const existingMcp =
|
||||
rawExistingMcp && typeof rawExistingMcp === 'object' && !Array.isArray(rawExistingMcp)
|
||||
? (rawExistingMcp as Record<string, unknown>)
|
||||
: {};
|
||||
instanceContent.mcpServers = { ...mcpServers, ...existingMcp };
|
||||
|
||||
fs.writeFileSync(instanceClaudeJson, JSON.stringify(instanceContent, null, 2), 'utf8');
|
||||
} catch {
|
||||
fs.writeFileSync(instanceClaudeJson, JSON.stringify(instanceContent, null, 2), {
|
||||
encoding: 'utf8',
|
||||
mode: 0o600,
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
// Best-effort: don't fail instance creation if MCP sync fails
|
||||
console.warn(`[!] MCP sync skipped: ${(error as Error).message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -268,6 +268,7 @@ router.put('/:name/context', async (req: Request, res: Response): Promise<void>
|
||||
|
||||
const previousUnified = existsUnified ? registry.getAllAccountsUnified()[name] : undefined;
|
||||
const previousLegacy = existsLegacy ? registry.getProfile(name) : undefined;
|
||||
const isBare = previousUnified?.bare === true || previousLegacy?.bare === true;
|
||||
|
||||
try {
|
||||
if (existsUnified) {
|
||||
@@ -277,7 +278,7 @@ router.put('/:name/context', async (req: Request, res: Response): Promise<void>
|
||||
registry.updateProfile(name, metadata);
|
||||
}
|
||||
|
||||
await instanceMgr.ensureInstance(name, policy);
|
||||
await instanceMgr.ensureInstance(name, policy, { bare: isBare });
|
||||
} catch (error) {
|
||||
if (existsUnified && previousUnified) {
|
||||
registry.updateAccountUnified(name, previousUnified);
|
||||
|
||||
@@ -47,6 +47,22 @@ describe('auth command args parsing', () => {
|
||||
expect(parsed.deeperContinuity).toBe(true);
|
||||
});
|
||||
|
||||
it('parses bare flag for create command', () => {
|
||||
const parsed = parseArgs(['work', '--bare']);
|
||||
|
||||
expect(parsed.profileName).toBe('work');
|
||||
expect(parsed.bare).toBe(true);
|
||||
});
|
||||
|
||||
it('parses bare flag with shared context flags', () => {
|
||||
const parsed = parseArgs(['work', '--bare', '--share-context', '--context-group', 'sprint-a']);
|
||||
|
||||
expect(parsed.profileName).toBe('work');
|
||||
expect(parsed.bare).toBe(true);
|
||||
expect(parsed.shareContext).toBe(true);
|
||||
expect(parsed.contextGroup).toBe('sprint-a');
|
||||
});
|
||||
|
||||
it('tracks unknown flags and keeps positional profile intact', () => {
|
||||
const parsed = parseArgs(['--foo', 'bar', 'work']);
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ describe('resolveProfileContinuityInheritance', () => {
|
||||
mode: 'shared',
|
||||
group: 'team-alpha',
|
||||
continuityMode: 'deeper',
|
||||
});
|
||||
}, { bare: false });
|
||||
});
|
||||
|
||||
it('supports legacy continuity_inherit_from_account fallback', async () => {
|
||||
@@ -255,7 +255,46 @@ describe('resolveProfileContinuityInheritance', () => {
|
||||
});
|
||||
expect(ensureInstanceSpy).toHaveBeenCalledWith('pro', {
|
||||
mode: 'isolated',
|
||||
}, { bare: false });
|
||||
});
|
||||
|
||||
it('propagates bare source-account mode when inheriting continuity', async () => {
|
||||
spyOn(configLoader, 'loadOrCreateUnifiedConfig').mockReturnValue({
|
||||
version: 8,
|
||||
continuity: {
|
||||
inherit_from_account: {
|
||||
glm: 'pro',
|
||||
},
|
||||
},
|
||||
} as ReturnType<typeof configLoader.loadOrCreateUnifiedConfig>);
|
||||
|
||||
const ensureInstanceSpy = spyOn(InstanceManager.prototype, 'ensureInstance').mockResolvedValue(
|
||||
'/tmp/.ccs/instances/pro'
|
||||
);
|
||||
spyOn(ProfileRegistry.prototype, 'getAllProfilesMerged').mockReturnValue({
|
||||
pro: {
|
||||
type: 'account',
|
||||
created: '2026-03-01T00:00:00.000Z',
|
||||
last_used: null,
|
||||
bare: true,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await resolveProfileContinuityInheritance({
|
||||
profileName: 'glm',
|
||||
profileType: 'settings',
|
||||
target: 'claude',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
sourceAccount: 'pro',
|
||||
claudeConfigDir: '/tmp/.ccs/instances/pro',
|
||||
});
|
||||
expect(ensureInstanceSpy).toHaveBeenCalledWith(
|
||||
'pro',
|
||||
{ mode: 'isolated' },
|
||||
{ bare: true }
|
||||
);
|
||||
});
|
||||
|
||||
it('does not apply km settings alias mapping to kimi cliproxy profile', async () => {
|
||||
|
||||
@@ -161,7 +161,7 @@ describe('ProfileDetector', () => {
|
||||
const mockUnifiedConfig = {
|
||||
version: 2,
|
||||
accounts: {
|
||||
work: { created: '2025-01-01', last_used: '2025-01-02' },
|
||||
work: { created: '2025-01-01', last_used: '2025-01-02', bare: true },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -176,6 +176,7 @@ describe('ProfileDetector', () => {
|
||||
expect(result.name).toBe('work');
|
||||
expect(result.profile).toBeDefined();
|
||||
expect((result.profile as any).type).toBe('account');
|
||||
expect((result.profile as any).bare).toBe(true);
|
||||
} finally {
|
||||
isUnifiedModeSpy.mockRestore();
|
||||
loadUnifiedConfigSpy.mockRestore();
|
||||
|
||||
@@ -91,4 +91,43 @@ describe('profile-registry context normalization', () => {
|
||||
expect(accounts.work.context_group).toBeUndefined();
|
||||
expect(accounts.work.continuity_mode).toBe('standard');
|
||||
});
|
||||
|
||||
it('persists bare flag for legacy profiles', () => {
|
||||
const registry = new ProfileRegistry();
|
||||
registry.createProfile('work', { type: 'account', bare: true });
|
||||
|
||||
const profile = registry.getProfile('work');
|
||||
expect(profile.bare).toBe(true);
|
||||
});
|
||||
|
||||
it('persists bare flag for unified accounts and merged projection', () => {
|
||||
process.env.CCS_UNIFIED_CONFIG = '1';
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'config.yaml'),
|
||||
[
|
||||
'version: 8',
|
||||
'accounts:',
|
||||
' work:',
|
||||
' created: "2026-03-05T00:00:00.000Z"',
|
||||
' last_used: null',
|
||||
' bare: true',
|
||||
'profiles: {}',
|
||||
'cliproxy:',
|
||||
' oauth_accounts: {}',
|
||||
' providers: {}',
|
||||
' variants: {}',
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const registry = new ProfileRegistry();
|
||||
|
||||
const accounts = registry.getAllAccountsUnified();
|
||||
expect(accounts.work.bare).toBe(true);
|
||||
|
||||
const merged = registry.getAllProfilesMerged();
|
||||
expect(merged.work.bare).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
|
||||
import { handleSyncCommand } from '../../../src/commands/sync-command';
|
||||
import { ClaudeDirInstaller } from '../../../src/utils/claude-dir-installer';
|
||||
import { ClaudeSymlinkManager } from '../../../src/utils/claude-symlink-manager';
|
||||
import SharedManager from '../../../src/management/shared-manager';
|
||||
import { InstanceManager } from '../../../src/management/instance-manager';
|
||||
import ProfileRegistry from '../../../src/auth/profile-registry';
|
||||
import type { ProfileMetadata } from '../../../src/types';
|
||||
|
||||
function profile(metadata: Partial<ProfileMetadata> = {}): ProfileMetadata {
|
||||
return {
|
||||
type: 'account',
|
||||
created: '2026-03-05T00:00:00.000Z',
|
||||
last_used: null,
|
||||
...metadata,
|
||||
};
|
||||
}
|
||||
|
||||
describe('sync command MCP sync behavior', () => {
|
||||
let originalProcessExit: typeof process.exit;
|
||||
|
||||
beforeEach(() => {
|
||||
originalProcessExit = process.exit;
|
||||
process.exit = ((code?: number) => {
|
||||
throw new Error(`process.exit(${code ?? 0})`);
|
||||
}) as typeof process.exit;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.exit = originalProcessExit;
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
it('syncs MCP servers only to non-bare profiles', async () => {
|
||||
spyOn(ClaudeDirInstaller.prototype, 'install').mockReturnValue(true);
|
||||
spyOn(ClaudeDirInstaller.prototype, 'cleanupDeprecated').mockReturnValue({
|
||||
success: true,
|
||||
cleanedFiles: [],
|
||||
});
|
||||
spyOn(ClaudeSymlinkManager.prototype, 'install').mockImplementation(() => {});
|
||||
spyOn(SharedManager.prototype, 'ensureSharedDirectories').mockImplementation(() => {});
|
||||
spyOn(ProfileRegistry.prototype, 'getAllProfilesMerged').mockReturnValue({
|
||||
work: profile(),
|
||||
sandbox: profile({ bare: true }),
|
||||
personal: profile(),
|
||||
});
|
||||
spyOn(InstanceManager.prototype, 'hasInstance').mockReturnValue(true);
|
||||
|
||||
const getInstancePathSpy = spyOn(InstanceManager.prototype, 'getInstancePath').mockImplementation(
|
||||
(name: string) => `/tmp/${name}`
|
||||
);
|
||||
const syncMcpSpy = spyOn(InstanceManager.prototype, 'syncMcpServers').mockImplementation(
|
||||
() => true
|
||||
);
|
||||
|
||||
await expect(handleSyncCommand()).rejects.toThrow('process.exit(0)');
|
||||
|
||||
expect(getInstancePathSpy.mock.calls.map((call) => call[0])).toEqual(['work', 'personal']);
|
||||
expect(syncMcpSpy.mock.calls.map((call) => call[0])).toEqual(['/tmp/work', '/tmp/personal']);
|
||||
});
|
||||
|
||||
it('skips MCP sync when all profiles are bare', async () => {
|
||||
spyOn(ClaudeDirInstaller.prototype, 'install').mockReturnValue(true);
|
||||
spyOn(ClaudeDirInstaller.prototype, 'cleanupDeprecated').mockReturnValue({
|
||||
success: true,
|
||||
cleanedFiles: [],
|
||||
});
|
||||
spyOn(ClaudeSymlinkManager.prototype, 'install').mockImplementation(() => {});
|
||||
spyOn(SharedManager.prototype, 'ensureSharedDirectories').mockImplementation(() => {});
|
||||
spyOn(ProfileRegistry.prototype, 'getAllProfilesMerged').mockReturnValue({
|
||||
sandbox: profile({ bare: true }),
|
||||
experiment: profile({ bare: true }),
|
||||
});
|
||||
spyOn(InstanceManager.prototype, 'hasInstance').mockReturnValue(true);
|
||||
|
||||
const syncMcpSpy = spyOn(InstanceManager.prototype, 'syncMcpServers').mockImplementation(
|
||||
() => true
|
||||
);
|
||||
|
||||
await expect(handleSyncCommand()).rejects.toThrow('process.exit(0)');
|
||||
|
||||
expect(syncMcpSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { InstanceManager } from '../../src/management/instance-manager';
|
||||
import SharedManager from '../../src/management/shared-manager';
|
||||
|
||||
describe('InstanceManager MCP sync', () => {
|
||||
let tempRoot = '';
|
||||
let originalCcsHome: string | undefined;
|
||||
let originalCcsDir: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-instance-mcp-test-'));
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
originalCcsDir = process.env.CCS_DIR;
|
||||
|
||||
process.env.CCS_HOME = tempRoot;
|
||||
delete process.env.CCS_DIR;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
|
||||
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
|
||||
else delete process.env.CCS_HOME;
|
||||
|
||||
if (originalCcsDir !== undefined) process.env.CCS_DIR = originalCcsDir;
|
||||
else delete process.env.CCS_DIR;
|
||||
|
||||
if (tempRoot && fs.existsSync(tempRoot)) {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('merges global MCP servers and preserves instance-specific overrides', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tempRoot, '.claude.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
globalOnly: { command: 'global-cmd' },
|
||||
shared: { command: 'global-shared' },
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const manager = new InstanceManager();
|
||||
const instancePath = manager.getInstancePath('work');
|
||||
fs.mkdirSync(instancePath, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(instancePath, '.claude.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
shared: { command: 'instance-shared' },
|
||||
instanceOnly: { command: 'instance-only' },
|
||||
},
|
||||
otherKey: 'keep-me',
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const synced = manager.syncMcpServers(instancePath);
|
||||
expect(synced).toBe(true);
|
||||
|
||||
const instanceContent = JSON.parse(fs.readFileSync(path.join(instancePath, '.claude.json'), 'utf8'));
|
||||
expect(instanceContent.otherKey).toBe('keep-me');
|
||||
expect(instanceContent.mcpServers).toEqual({
|
||||
globalOnly: { command: 'global-cmd' },
|
||||
shared: { command: 'instance-shared' },
|
||||
instanceOnly: { command: 'instance-only' },
|
||||
});
|
||||
});
|
||||
|
||||
it('logs warning when global MCP sync fails', () => {
|
||||
fs.writeFileSync(path.join(tempRoot, '.claude.json'), '{invalid-json', 'utf8');
|
||||
const warnSpy = spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
const manager = new InstanceManager();
|
||||
const instancePath = manager.getInstancePath('work');
|
||||
fs.mkdirSync(instancePath, { recursive: true });
|
||||
|
||||
const synced = manager.syncMcpServers(instancePath);
|
||||
|
||||
expect(synced).toBe(false);
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||
expect(String(warnSpy.mock.calls[0]?.[0] || '')).toContain('MCP sync skipped');
|
||||
});
|
||||
|
||||
it('skips shared symlinks and MCP sync for bare instance creation', async () => {
|
||||
const linkSharedSpy = spyOn(SharedManager.prototype, 'linkSharedDirectories').mockImplementation(
|
||||
() => {}
|
||||
);
|
||||
spyOn(SharedManager.prototype, 'syncProjectContext').mockResolvedValue(undefined);
|
||||
spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined);
|
||||
const syncMcpSpy = spyOn(InstanceManager.prototype, 'syncMcpServers').mockImplementation(
|
||||
() => false
|
||||
);
|
||||
|
||||
const manager = new InstanceManager();
|
||||
await manager.ensureInstance('sandbox', { mode: 'isolated' }, { bare: true });
|
||||
|
||||
expect(linkSharedSpy).not.toHaveBeenCalled();
|
||||
expect(syncMcpSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user