mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-10 02:17:11 +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
|
const previousUnifiedProfile = existsUnified
|
||||||
? ctx.registry.getAllAccountsUnified()[profileName]
|
? ctx.registry.getAllAccountsUnified()[profileName]
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const previousBare =
|
||||||
|
previousLegacyProfile?.bare === true || previousUnifiedProfile?.bare === true;
|
||||||
|
const effectiveBare = bare === true || (profileExistedBeforeCreate && previousBare);
|
||||||
const previousContextPolicy =
|
const previousContextPolicy =
|
||||||
profileExistedBeforeCreate && (previousUnifiedProfile || previousLegacyProfile)
|
profileExistedBeforeCreate && (previousUnifiedProfile || previousLegacyProfile)
|
||||||
? resolveAccountContextPolicy(previousUnifiedProfile || previousLegacyProfile)
|
? resolveAccountContextPolicy(previousUnifiedProfile || previousLegacyProfile)
|
||||||
@@ -169,7 +172,9 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
|
|||||||
|
|
||||||
if (previousContextPolicy) {
|
if (previousContextPolicy) {
|
||||||
try {
|
try {
|
||||||
await ctx.instanceMgr.ensureInstance(profileName, previousContextPolicy);
|
await ctx.instanceMgr.ensureInstance(profileName, previousContextPolicy, {
|
||||||
|
bare: previousBare,
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// Best-effort rollback for context mode/group.
|
// Best-effort rollback for context mode/group.
|
||||||
}
|
}
|
||||||
@@ -180,7 +185,7 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
|
|||||||
// Create instance directory
|
// Create instance directory
|
||||||
console.log(info(`Creating profile: ${profileName}`));
|
console.log(info(`Creating profile: ${profileName}`));
|
||||||
const instancePath = await ctx.instanceMgr.ensureInstance(profileName, contextPolicy, {
|
const instancePath = await ctx.instanceMgr.ensureInstance(profileName, contextPolicy, {
|
||||||
bare: !!bare,
|
bare: effectiveBare,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create/update profile entry based on config mode
|
// 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, {
|
ctx.registry.updateAccountUnified(profileName, {
|
||||||
context_mode: contextMetadata.context_mode,
|
context_mode: contextMetadata.context_mode,
|
||||||
context_group: contextMetadata.context_group,
|
context_group: contextMetadata.context_group,
|
||||||
...(bare ? { bare: true } : {}),
|
...(effectiveBare ? { bare: true } : {}),
|
||||||
});
|
});
|
||||||
ctx.registry.touchAccountUnified(profileName);
|
ctx.registry.touchAccountUnified(profileName);
|
||||||
} else {
|
} else {
|
||||||
ctx.registry.createAccountUnified(profileName, {
|
ctx.registry.createAccountUnified(profileName, {
|
||||||
...contextMetadata,
|
...contextMetadata,
|
||||||
...(bare ? { bare: true } : {}),
|
...(effectiveBare ? { bare: true } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -206,14 +211,14 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
|
|||||||
type: 'account',
|
type: 'account',
|
||||||
context_mode: contextMetadata.context_mode,
|
context_mode: contextMetadata.context_mode,
|
||||||
context_group: contextMetadata.context_group,
|
context_group: contextMetadata.context_group,
|
||||||
...(bare ? { bare: true } : {}),
|
...(effectiveBare ? { bare: true } : {}),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
ctx.registry.createProfile(profileName, {
|
ctx.registry.createProfile(profileName, {
|
||||||
type: 'account',
|
type: 'account',
|
||||||
context_mode: contextMetadata.context_mode,
|
context_mode: contextMetadata.context_mode,
|
||||||
context_group: contextMetadata.context_group,
|
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` +
|
`Instance: ${instancePath}\n` +
|
||||||
`Type: account\n` +
|
`Type: account\n` +
|
||||||
`Context: ${formatAccountContextPolicy(contextPolicy)}` +
|
`Context: ${formatAccountContextPolicy(contextPolicy)}` +
|
||||||
(bare ? '\nMode: bare (no shared symlinks)' : ''),
|
(effectiveBare ? '\nMode: bare (no shared symlinks)' : ''),
|
||||||
'Profile Created'
|
'Profile Created'
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -146,7 +146,9 @@ export async function resolveProfileContinuityInheritance(
|
|||||||
isAccountContextMetadata(mappedProfile) ? mappedProfile : undefined
|
isAccountContextMetadata(mappedProfile) ? mappedProfile : undefined
|
||||||
);
|
);
|
||||||
const instanceMgr = new InstanceManager();
|
const instanceMgr = new InstanceManager();
|
||||||
const instancePath = await instanceMgr.ensureInstance(sourceAccount, contextPolicy);
|
const instancePath = await instanceMgr.ensureInstance(sourceAccount, contextPolicy, {
|
||||||
|
bare: mappedProfile.bare === true,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sourceAccount,
|
sourceAccount,
|
||||||
|
|||||||
@@ -203,6 +203,7 @@ class ProfileDetector {
|
|||||||
context_mode: account.context_mode,
|
context_mode: account.context_mode,
|
||||||
context_group: account.context_group,
|
context_group: account.context_group,
|
||||||
continuity_mode: account.continuity_mode,
|
continuity_mode: account.continuity_mode,
|
||||||
|
bare: account.bare,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ interface CreateMetadata {
|
|||||||
context_mode?: 'isolated' | 'shared';
|
context_mode?: 'isolated' | 'shared';
|
||||||
context_group?: string;
|
context_group?: string;
|
||||||
continuity_mode?: 'standard' | 'deeper';
|
continuity_mode?: 'standard' | 'deeper';
|
||||||
|
bare?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ProfileRegistry {
|
export class ProfileRegistry {
|
||||||
@@ -173,6 +174,7 @@ export class ProfileRegistry {
|
|||||||
context_mode: metadata.context_mode,
|
context_mode: metadata.context_mode,
|
||||||
context_group: metadata.context_group,
|
context_group: metadata.context_group,
|
||||||
continuity_mode: metadata.continuity_mode,
|
continuity_mode: metadata.continuity_mode,
|
||||||
|
bare: metadata.bare,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Note: No longer auto-set as default
|
// Note: No longer auto-set as default
|
||||||
@@ -321,6 +323,7 @@ export class ProfileRegistry {
|
|||||||
context_mode: metadata.context_mode,
|
context_mode: metadata.context_mode,
|
||||||
context_group: metadata.context_group,
|
context_group: metadata.context_group,
|
||||||
continuity_mode: metadata.continuity_mode,
|
continuity_mode: metadata.continuity_mode,
|
||||||
|
bare: metadata.bare,
|
||||||
});
|
});
|
||||||
saveUnifiedConfig(config);
|
saveUnifiedConfig(config);
|
||||||
}
|
}
|
||||||
@@ -449,6 +452,7 @@ export class ProfileRegistry {
|
|||||||
context_mode: account.context_mode,
|
context_mode: account.context_mode,
|
||||||
context_group: account.context_group,
|
context_group: account.context_group,
|
||||||
continuity_mode: account.continuity_mode,
|
continuity_mode: account.continuity_mode,
|
||||||
|
bare: account.bare,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+7
-1
@@ -1132,10 +1132,16 @@ async function main(): Promise<void> {
|
|||||||
const accountMetadata = isAccountContextMetadata(profileInfo.profile)
|
const accountMetadata = isAccountContextMetadata(profileInfo.profile)
|
||||||
? profileInfo.profile
|
? profileInfo.profile
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const isBareProfile =
|
||||||
|
typeof profileInfo.profile === 'object' &&
|
||||||
|
profileInfo.profile !== null &&
|
||||||
|
(profileInfo.profile as { bare?: unknown }).bare === true;
|
||||||
const contextPolicy = resolveAccountContextPolicy(accountMetadata);
|
const contextPolicy = resolveAccountContextPolicy(accountMetadata);
|
||||||
|
|
||||||
// Ensure instance exists (lazy init if needed)
|
// 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)
|
// Update last_used timestamp (check unified config first, fallback to legacy)
|
||||||
if (registry.hasAccountUnified(profileInfo.name)) {
|
if (registry.hasAccountUnified(profileInfo.name)) {
|
||||||
|
|||||||
@@ -53,9 +53,13 @@ export async function handleSyncCommand(): Promise<void> {
|
|||||||
if (profile.bare) {
|
if (profile.bare) {
|
||||||
continue; // Skip bare profiles
|
continue; // Skip bare profiles
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!instanceMgr.hasInstance(name)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const instancePath = instanceMgr.getInstancePath(name);
|
const instancePath = instanceMgr.getInstancePath(name);
|
||||||
if (instancePath) {
|
if (instanceMgr.syncMcpServers(instancePath)) {
|
||||||
instanceMgr.syncMcpServers(instancePath);
|
|
||||||
mcpSynced++;
|
mcpSynced++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { AccountContextPolicy, DEFAULT_ACCOUNT_CONTEXT_MODE } from '../auth/acco
|
|||||||
import { getCcsDir, getCcsHome } from '../utils/config-manager';
|
import { getCcsDir, getCcsHome } from '../utils/config-manager';
|
||||||
|
|
||||||
/** Options for instance creation */
|
/** Options for instance creation */
|
||||||
interface InstanceOptions {
|
export interface InstanceOptions {
|
||||||
/** Skip shared symlinks (commands, skills, agents, settings.json) */
|
/** Skip shared symlinks (commands, skills, agents, settings.json) */
|
||||||
bare?: boolean;
|
bare?: boolean;
|
||||||
}
|
}
|
||||||
@@ -108,9 +108,6 @@ class InstanceManager {
|
|||||||
if (!options.bare) {
|
if (!options.bare) {
|
||||||
this.sharedManager.linkSharedDirectories(instancePath);
|
this.sharedManager.linkSharedDirectories(instancePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy global configs if exist (settings.json only)
|
|
||||||
this.copyGlobalConfigs(instancePath);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to initialize instance for ${profileName}: ${(error as Error).message}`
|
`Failed to initialize instance for ${profileName}: ${(error as Error).message}`
|
||||||
@@ -180,33 +177,31 @@ class InstanceManager {
|
|||||||
return fs.existsSync(instancePath);
|
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.
|
* Sync MCP servers from global ~/.claude.json to instance .claude.json.
|
||||||
* Selectively copies only mcpServers key (not OAuth sessions or caches).
|
* Selectively copies only mcpServers key (not OAuth sessions or caches).
|
||||||
*/
|
*/
|
||||||
syncMcpServers(instancePath: string): void {
|
syncMcpServers(instancePath: string): boolean {
|
||||||
const homeDir = getCcsHome();
|
const homeDir = getCcsHome();
|
||||||
const globalClaudeJson = path.join(homeDir, '.claude.json');
|
const globalClaudeJson = path.join(homeDir, '.claude.json');
|
||||||
|
|
||||||
if (!fs.existsSync(globalClaudeJson)) {
|
if (!fs.existsSync(globalClaudeJson)) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const globalContent = JSON.parse(fs.readFileSync(globalClaudeJson, 'utf8'));
|
const globalContent = JSON.parse(fs.readFileSync(globalClaudeJson, 'utf8'));
|
||||||
const mcpServers = globalContent.mcpServers;
|
const rawMcpServers = globalContent.mcpServers;
|
||||||
|
if (
|
||||||
if (!mcpServers || Object.keys(mcpServers).length === 0) {
|
!rawMcpServers ||
|
||||||
return;
|
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');
|
const instanceClaudeJson = path.join(instancePath, '.claude.json');
|
||||||
let instanceContent: Record<string, unknown> = {};
|
let instanceContent: Record<string, unknown> = {};
|
||||||
|
|
||||||
@@ -220,12 +215,22 @@ class InstanceManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Merge: global MCP servers as base, instance-specific overrides on top
|
// 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 };
|
instanceContent.mcpServers = { ...mcpServers, ...existingMcp };
|
||||||
|
|
||||||
fs.writeFileSync(instanceClaudeJson, JSON.stringify(instanceContent, null, 2), 'utf8');
|
fs.writeFileSync(instanceClaudeJson, JSON.stringify(instanceContent, null, 2), {
|
||||||
} catch {
|
encoding: 'utf8',
|
||||||
|
mode: 0o600,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
// Best-effort: don't fail instance creation if MCP sync fails
|
// 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 previousUnified = existsUnified ? registry.getAllAccountsUnified()[name] : undefined;
|
||||||
const previousLegacy = existsLegacy ? registry.getProfile(name) : undefined;
|
const previousLegacy = existsLegacy ? registry.getProfile(name) : undefined;
|
||||||
|
const isBare = previousUnified?.bare === true || previousLegacy?.bare === true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (existsUnified) {
|
if (existsUnified) {
|
||||||
@@ -277,7 +278,7 @@ router.put('/:name/context', async (req: Request, res: Response): Promise<void>
|
|||||||
registry.updateProfile(name, metadata);
|
registry.updateProfile(name, metadata);
|
||||||
}
|
}
|
||||||
|
|
||||||
await instanceMgr.ensureInstance(name, policy);
|
await instanceMgr.ensureInstance(name, policy, { bare: isBare });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (existsUnified && previousUnified) {
|
if (existsUnified && previousUnified) {
|
||||||
registry.updateAccountUnified(name, previousUnified);
|
registry.updateAccountUnified(name, previousUnified);
|
||||||
|
|||||||
@@ -47,6 +47,22 @@ describe('auth command args parsing', () => {
|
|||||||
expect(parsed.deeperContinuity).toBe(true);
|
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', () => {
|
it('tracks unknown flags and keeps positional profile intact', () => {
|
||||||
const parsed = parseArgs(['--foo', 'bar', 'work']);
|
const parsed = parseArgs(['--foo', 'bar', 'work']);
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ describe('resolveProfileContinuityInheritance', () => {
|
|||||||
mode: 'shared',
|
mode: 'shared',
|
||||||
group: 'team-alpha',
|
group: 'team-alpha',
|
||||||
continuityMode: 'deeper',
|
continuityMode: 'deeper',
|
||||||
});
|
}, { bare: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('supports legacy continuity_inherit_from_account fallback', async () => {
|
it('supports legacy continuity_inherit_from_account fallback', async () => {
|
||||||
@@ -255,7 +255,46 @@ describe('resolveProfileContinuityInheritance', () => {
|
|||||||
});
|
});
|
||||||
expect(ensureInstanceSpy).toHaveBeenCalledWith('pro', {
|
expect(ensureInstanceSpy).toHaveBeenCalledWith('pro', {
|
||||||
mode: 'isolated',
|
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 () => {
|
it('does not apply km settings alias mapping to kimi cliproxy profile', async () => {
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ describe('ProfileDetector', () => {
|
|||||||
const mockUnifiedConfig = {
|
const mockUnifiedConfig = {
|
||||||
version: 2,
|
version: 2,
|
||||||
accounts: {
|
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.name).toBe('work');
|
||||||
expect(result.profile).toBeDefined();
|
expect(result.profile).toBeDefined();
|
||||||
expect((result.profile as any).type).toBe('account');
|
expect((result.profile as any).type).toBe('account');
|
||||||
|
expect((result.profile as any).bare).toBe(true);
|
||||||
} finally {
|
} finally {
|
||||||
isUnifiedModeSpy.mockRestore();
|
isUnifiedModeSpy.mockRestore();
|
||||||
loadUnifiedConfigSpy.mockRestore();
|
loadUnifiedConfigSpy.mockRestore();
|
||||||
|
|||||||
@@ -91,4 +91,43 @@ describe('profile-registry context normalization', () => {
|
|||||||
expect(accounts.work.context_group).toBeUndefined();
|
expect(accounts.work.context_group).toBeUndefined();
|
||||||
expect(accounts.work.continuity_mode).toBe('standard');
|
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