mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-12 22:14:22 +00:00
fix: normalize plugin metadata across shared launch flows
This commit is contained in:
1 parent
2cb5b4ebec
commit
1043a433f6
8 files changed
+431
-40
No files matched your search
@@ -58,9 +58,10 @@ class InstanceManager {
|
|||||||
await this.sharedManager.syncAdvancedContinuityArtifacts(instancePath, contextPolicy);
|
await this.sharedManager.syncAdvancedContinuityArtifacts(instancePath, contextPolicy);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.sharedManager.normalizeSharedPluginMetadataPaths(instancePath);
|
||||||
|
|
||||||
// Sync MCP servers from global ~/.claude.json (unless bare)
|
// Sync MCP servers from global ~/.claude.json (unless bare)
|
||||||
if (!options.bare) {
|
if (!options.bare) {
|
||||||
this.sharedManager.normalizeSharedPluginMetadataPaths();
|
|
||||||
this.syncMcpServers(instancePath);
|
this.syncMcpServers(instancePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,50 @@ interface SharedItem {
|
|||||||
type: 'directory' | 'file';
|
type: 'directory' | 'file';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function normalizePluginMetadataPathString(input: string): string {
|
||||||
|
return input.replace(
|
||||||
|
/([\\/])\.ccs\1instances\1[^\\/]+\1/g,
|
||||||
|
(_match, separator: string) => `${separator}.claude${separator}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePluginMetadataValue(value: unknown): { normalized: unknown; changed: boolean } {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const normalized = normalizePluginMetadataPathString(value);
|
||||||
|
return { normalized, changed: normalized !== value };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
let changed = false;
|
||||||
|
const normalized = value.map((item) => {
|
||||||
|
const result = normalizePluginMetadataValue(item);
|
||||||
|
changed = changed || result.changed;
|
||||||
|
return result.normalized;
|
||||||
|
});
|
||||||
|
return { normalized, changed };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
let changed = false;
|
||||||
|
const normalized = Object.fromEntries(
|
||||||
|
Object.entries(value as Record<string, unknown>).map(([key, item]) => {
|
||||||
|
const result = normalizePluginMetadataValue(item);
|
||||||
|
changed = changed || result.changed;
|
||||||
|
return [key, result.normalized];
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return { normalized, changed };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { normalized: value, changed: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizePluginMetadataContent(original: string): string {
|
||||||
|
const parsed = JSON.parse(original) as unknown;
|
||||||
|
const result = normalizePluginMetadataValue(parsed);
|
||||||
|
return result.changed ? JSON.stringify(result.normalized, null, 2) : original;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SharedManager Class
|
* SharedManager Class
|
||||||
*/
|
*/
|
||||||
@@ -210,7 +254,7 @@ class SharedManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.normalizeSharedPluginMetadataPaths();
|
this.normalizeSharedPluginMetadataPaths(instancePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -541,9 +585,9 @@ class SharedManager {
|
|||||||
/**
|
/**
|
||||||
* Normalize shared plugin metadata files to canonical ~/.claude/ paths.
|
* Normalize shared plugin metadata files to canonical ~/.claude/ paths.
|
||||||
*/
|
*/
|
||||||
normalizeSharedPluginMetadataPaths(): void {
|
normalizeSharedPluginMetadataPaths(configDir?: string): void {
|
||||||
this.normalizePluginRegistryPaths();
|
this.normalizePluginRegistryPaths(configDir);
|
||||||
this.normalizeMarketplaceRegistryPaths();
|
this.normalizeMarketplaceRegistryPaths(configDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -553,9 +597,10 @@ class SharedManager {
|
|||||||
* This ensures installed_plugins.json is consistent regardless of
|
* This ensures installed_plugins.json is consistent regardless of
|
||||||
* which CCS instance installed the plugin.
|
* which CCS instance installed the plugin.
|
||||||
*/
|
*/
|
||||||
normalizePluginRegistryPaths(): void {
|
normalizePluginRegistryPaths(configDir?: string): void {
|
||||||
this.normalizePluginMetadataFile(
|
this.normalizePluginMetadataFiles(
|
||||||
path.join(this.claudeDir, 'plugins', 'installed_plugins.json'),
|
'installed_plugins.json',
|
||||||
|
configDir,
|
||||||
'Normalized plugin registry paths',
|
'Normalized plugin registry paths',
|
||||||
'plugin registry'
|
'plugin registry'
|
||||||
);
|
);
|
||||||
@@ -568,14 +613,47 @@ class SharedManager {
|
|||||||
* This ensures known_marketplaces.json is consistent regardless of
|
* This ensures known_marketplaces.json is consistent regardless of
|
||||||
* which CCS instance added the marketplace.
|
* which CCS instance added the marketplace.
|
||||||
*/
|
*/
|
||||||
normalizeMarketplaceRegistryPaths(): void {
|
normalizeMarketplaceRegistryPaths(configDir?: string): void {
|
||||||
this.normalizePluginMetadataFile(
|
this.normalizePluginMetadataFiles(
|
||||||
path.join(this.claudeDir, 'plugins', 'known_marketplaces.json'),
|
'known_marketplaces.json',
|
||||||
|
configDir,
|
||||||
'Normalized marketplace registry paths',
|
'Normalized marketplace registry paths',
|
||||||
'marketplace registry'
|
'marketplace registry'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private normalizePluginMetadataFiles(
|
||||||
|
fileName: string,
|
||||||
|
configDir: string | undefined,
|
||||||
|
successMessage: string,
|
||||||
|
warningLabel: string
|
||||||
|
): void {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
for (const registryPath of this.getPluginMetadataFilePaths(fileName, configDir)) {
|
||||||
|
const dedupeKey = this.resolveCanonicalPath(registryPath);
|
||||||
|
if (seen.has(dedupeKey)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
seen.add(dedupeKey);
|
||||||
|
this.normalizePluginMetadataFile(registryPath, successMessage, warningLabel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getPluginMetadataFilePaths(fileName: string, configDir?: string): string[] {
|
||||||
|
const pluginDirs = new Set<string>([
|
||||||
|
path.join(this.claudeDir, 'plugins'),
|
||||||
|
path.join(this.sharedDir, 'plugins'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (configDir && path.resolve(configDir) !== path.resolve(this.claudeDir)) {
|
||||||
|
pluginDirs.add(path.join(configDir, 'plugins'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...pluginDirs].map((pluginDir) => path.join(pluginDir, fileName));
|
||||||
|
}
|
||||||
|
|
||||||
private normalizePluginMetadataFile(
|
private normalizePluginMetadataFile(
|
||||||
registryPath: string,
|
registryPath: string,
|
||||||
successMessage: string,
|
successMessage: string,
|
||||||
@@ -587,12 +665,9 @@ class SharedManager {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const original = fs.readFileSync(registryPath, 'utf8');
|
const original = fs.readFileSync(registryPath, 'utf8');
|
||||||
|
const normalized = normalizePluginMetadataContent(original);
|
||||||
// Pattern: /.ccs/instances/<instance-name>/ -> /.claude/
|
|
||||||
const normalized = original.replace(/\/\.ccs\/instances\/[^/]+\//g, '/.claude/');
|
|
||||||
|
|
||||||
if (normalized !== original) {
|
if (normalized !== original) {
|
||||||
JSON.parse(normalized);
|
|
||||||
fs.writeFileSync(registryPath, normalized, 'utf8');
|
fs.writeFileSync(registryPath, normalized, 'utf8');
|
||||||
console.log(ok(successMessage));
|
console.log(ok(successMessage));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager';
|
|||||||
import { getProxyTarget } from '../cliproxy/proxy-target-resolver';
|
import { getProxyTarget } from '../cliproxy/proxy-target-resolver';
|
||||||
import { generateCopilotEnv } from '../copilot/copilot-executor';
|
import { generateCopilotEnv } from '../copilot/copilot-executor';
|
||||||
import InstanceManager from '../management/instance-manager';
|
import InstanceManager from '../management/instance-manager';
|
||||||
|
import SharedManager from '../management/shared-manager';
|
||||||
import { expandPath } from '../utils/helpers';
|
import { expandPath } from '../utils/helpers';
|
||||||
import { getClaudeSettingsPath } from '../utils/claude-config-path';
|
import { getClaudeSettingsPath } from '../utils/claude-config-path';
|
||||||
import {
|
import {
|
||||||
@@ -161,6 +162,7 @@ async function resolveExtensionEnv(
|
|||||||
profileType: result.type,
|
profileType: result.type,
|
||||||
target: 'claude',
|
target: 'claude',
|
||||||
});
|
});
|
||||||
|
new SharedManager().normalizeSharedPluginMetadataPaths(continuity.claudeConfigDir);
|
||||||
if (continuity.claudeConfigDir) {
|
if (continuity.claudeConfigDir) {
|
||||||
notes.push(`Default profile inherits continuity from account "${continuity.sourceAccount}".`);
|
notes.push(`Default profile inherits continuity from account "${continuity.sourceAccount}".`);
|
||||||
return {
|
return {
|
||||||
@@ -234,6 +236,9 @@ async function resolveExtensionEnv(
|
|||||||
`Continuity inheritance adds CLAUDE_CONFIG_DIR from account "${continuity.sourceAccount}".`
|
`Continuity inheritance adds CLAUDE_CONFIG_DIR from account "${continuity.sourceAccount}".`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
new SharedManager().normalizeSharedPluginMetadataPaths(env.CLAUDE_CONFIG_DIR);
|
||||||
|
|
||||||
if (result.type === 'copilot') {
|
if (result.type === 'copilot') {
|
||||||
warnings.push(
|
warnings.push(
|
||||||
'copilot-api must stay reachable for this profile to work inside the IDE extension.'
|
'copilot-api must stay reachable for this profile to work inside the IDE extension.'
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { ErrorManager } from './error-manager';
|
|||||||
import { getWebSearchHookEnv } from './websearch-manager';
|
import { getWebSearchHookEnv } from './websearch-manager';
|
||||||
import { wireChildProcessSignals } from './signal-forwarder';
|
import { wireChildProcessSignals } from './signal-forwarder';
|
||||||
import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader';
|
import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader';
|
||||||
|
import SharedManager from '../management/shared-manager';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Strip ANTHROPIC_* env vars from an environment object.
|
* Strip ANTHROPIC_* env vars from an environment object.
|
||||||
@@ -122,6 +123,14 @@ export function execClaude(
|
|||||||
// (Claude Code v2.1.39+ sets CLAUDECODE to detect nested sessions)
|
// (Claude Code v2.1.39+ sets CLAUDECODE to detect nested sessions)
|
||||||
const env = stripClaudeCodeEnv(mergedEnv);
|
const env = stripClaudeCodeEnv(mergedEnv);
|
||||||
|
|
||||||
|
if (profileType !== 'account') {
|
||||||
|
try {
|
||||||
|
new SharedManager().normalizeSharedPluginMetadataPaths(env.CLAUDE_CONFIG_DIR);
|
||||||
|
} catch {
|
||||||
|
// Best-effort normalization should never block Claude launch.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// propagate key env vars to tmux session so agent team teammates
|
// propagate key env vars to tmux session so agent team teammates
|
||||||
// (spawned via tmux split-window) inherit the correct config dir
|
// (spawned via tmux split-window) inherit the correct config dir
|
||||||
if (process.env.TMUX && envVars) {
|
if (process.env.TMUX && envVars) {
|
||||||
|
|||||||
@@ -7,14 +7,35 @@ import SharedManager from '../../src/management/shared-manager';
|
|||||||
|
|
||||||
describe('InstanceManager MCP sync', () => {
|
describe('InstanceManager MCP sync', () => {
|
||||||
let tempRoot = '';
|
let tempRoot = '';
|
||||||
|
let originalHome: string | undefined;
|
||||||
let originalCcsHome: string | undefined;
|
let originalCcsHome: string | undefined;
|
||||||
let originalCcsDir: string | undefined;
|
let originalCcsDir: string | undefined;
|
||||||
|
|
||||||
|
function writeMarketplaceRegistry(registryPath: string, installLocation: string): void {
|
||||||
|
fs.mkdirSync(path.dirname(registryPath), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
registryPath,
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
'claude-code-plugins': {
|
||||||
|
installLocation,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-instance-mcp-test-'));
|
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-instance-mcp-test-'));
|
||||||
|
originalHome = process.env.HOME;
|
||||||
originalCcsHome = process.env.CCS_HOME;
|
originalCcsHome = process.env.CCS_HOME;
|
||||||
originalCcsDir = process.env.CCS_DIR;
|
originalCcsDir = process.env.CCS_DIR;
|
||||||
|
|
||||||
|
spyOn(os, 'homedir').mockReturnValue(tempRoot);
|
||||||
|
process.env.HOME = tempRoot;
|
||||||
process.env.CCS_HOME = tempRoot;
|
process.env.CCS_HOME = tempRoot;
|
||||||
delete process.env.CCS_DIR;
|
delete process.env.CCS_DIR;
|
||||||
});
|
});
|
||||||
@@ -22,6 +43,9 @@ describe('InstanceManager MCP sync', () => {
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
mock.restore();
|
mock.restore();
|
||||||
|
|
||||||
|
if (originalHome !== undefined) process.env.HOME = originalHome;
|
||||||
|
else delete process.env.HOME;
|
||||||
|
|
||||||
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
|
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
|
||||||
else delete process.env.CCS_HOME;
|
else delete process.env.CCS_HOME;
|
||||||
|
|
||||||
@@ -71,7 +95,9 @@ describe('InstanceManager MCP sync', () => {
|
|||||||
const synced = manager.syncMcpServers(instancePath);
|
const synced = manager.syncMcpServers(instancePath);
|
||||||
expect(synced).toBe(true);
|
expect(synced).toBe(true);
|
||||||
|
|
||||||
const instanceContent = JSON.parse(fs.readFileSync(path.join(instancePath, '.claude.json'), 'utf8'));
|
const instanceContent = JSON.parse(
|
||||||
|
fs.readFileSync(path.join(instancePath, '.claude.json'), 'utf8')
|
||||||
|
);
|
||||||
expect(instanceContent.otherKey).toBe('keep-me');
|
expect(instanceContent.otherKey).toBe('keep-me');
|
||||||
expect(instanceContent.mcpServers).toEqual({
|
expect(instanceContent.mcpServers).toEqual({
|
||||||
globalOnly: { command: 'global-cmd' },
|
globalOnly: { command: 'global-cmd' },
|
||||||
@@ -96,45 +122,118 @@ describe('InstanceManager MCP sync', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('skips shared symlinks and MCP sync for bare instance creation', async () => {
|
it('skips shared symlinks and MCP sync for bare instance creation', async () => {
|
||||||
const linkSharedSpy = spyOn(SharedManager.prototype, 'linkSharedDirectories').mockImplementation(
|
const linkSharedSpy = spyOn(
|
||||||
() => {}
|
SharedManager.prototype,
|
||||||
);
|
'linkSharedDirectories'
|
||||||
|
).mockImplementation(() => {});
|
||||||
spyOn(SharedManager.prototype, 'syncProjectContext').mockResolvedValue(undefined);
|
spyOn(SharedManager.prototype, 'syncProjectContext').mockResolvedValue(undefined);
|
||||||
spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined);
|
spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined);
|
||||||
const normalizeSharedPluginMetadataSpy = spyOn(
|
|
||||||
SharedManager.prototype,
|
|
||||||
'normalizeSharedPluginMetadataPaths'
|
|
||||||
).mockImplementation(() => {});
|
|
||||||
const syncMcpSpy = spyOn(InstanceManager.prototype, 'syncMcpServers').mockImplementation(
|
const syncMcpSpy = spyOn(InstanceManager.prototype, 'syncMcpServers').mockImplementation(
|
||||||
() => false
|
() => false
|
||||||
);
|
);
|
||||||
|
|
||||||
const manager = new InstanceManager();
|
const manager = new InstanceManager();
|
||||||
|
const instancePath = manager.getInstancePath('sandbox');
|
||||||
|
const sharedRegistryPath = path.join(tempRoot, '.claude', 'plugins', 'known_marketplaces.json');
|
||||||
|
writeMarketplaceRegistry(
|
||||||
|
sharedRegistryPath,
|
||||||
|
path.join(
|
||||||
|
tempRoot,
|
||||||
|
'.ccs',
|
||||||
|
'instances',
|
||||||
|
'work',
|
||||||
|
'plugins',
|
||||||
|
'marketplaces',
|
||||||
|
'claude-code-plugins'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
await manager.ensureInstance('sandbox', { mode: 'isolated' }, { bare: true });
|
await manager.ensureInstance('sandbox', { mode: 'isolated' }, { bare: true });
|
||||||
|
|
||||||
|
const normalized = JSON.parse(fs.readFileSync(sharedRegistryPath, 'utf8'));
|
||||||
|
|
||||||
expect(linkSharedSpy).not.toHaveBeenCalled();
|
expect(linkSharedSpy).not.toHaveBeenCalled();
|
||||||
expect(normalizeSharedPluginMetadataSpy).not.toHaveBeenCalled();
|
expect(fs.existsSync(instancePath)).toBe(true);
|
||||||
|
expect(normalized['claude-code-plugins'].installLocation).toBe(
|
||||||
|
path.join(tempRoot, '.claude', 'plugins', 'marketplaces', 'claude-code-plugins')
|
||||||
|
);
|
||||||
|
expect(fs.existsSync(path.join(instancePath, 'plugins', 'known_marketplaces.json'))).toBe(
|
||||||
|
false
|
||||||
|
);
|
||||||
expect(syncMcpSpy).not.toHaveBeenCalled();
|
expect(syncMcpSpy).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('normalizes shared plugin metadata for existing non-bare instances', async () => {
|
it('normalizes shared plugin metadata for existing non-bare instances', async () => {
|
||||||
spyOn(SharedManager.prototype, 'syncProjectContext').mockResolvedValue(undefined);
|
spyOn(SharedManager.prototype, 'syncProjectContext').mockResolvedValue(undefined);
|
||||||
spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined);
|
spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined);
|
||||||
const normalizeSharedPluginMetadataSpy = spyOn(
|
|
||||||
SharedManager.prototype,
|
|
||||||
'normalizeSharedPluginMetadataPaths'
|
|
||||||
).mockImplementation(() => {});
|
|
||||||
const syncMcpSpy = spyOn(InstanceManager.prototype, 'syncMcpServers').mockImplementation(
|
const syncMcpSpy = spyOn(InstanceManager.prototype, 'syncMcpServers').mockImplementation(
|
||||||
() => false
|
() => false
|
||||||
);
|
);
|
||||||
|
|
||||||
const manager = new InstanceManager();
|
const manager = new InstanceManager();
|
||||||
const instancePath = manager.getInstancePath('work');
|
const instancePath = manager.getInstancePath('work');
|
||||||
fs.mkdirSync(instancePath, { recursive: true });
|
writeMarketplaceRegistry(
|
||||||
|
path.join(instancePath, 'plugins', 'known_marketplaces.json'),
|
||||||
|
path.join(
|
||||||
|
tempRoot,
|
||||||
|
'.ccs',
|
||||||
|
'instances',
|
||||||
|
'work',
|
||||||
|
'plugins',
|
||||||
|
'marketplaces',
|
||||||
|
'claude-code-plugins'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
await manager.ensureInstance('work', { mode: 'isolated' });
|
await manager.ensureInstance('work', { mode: 'isolated' });
|
||||||
|
|
||||||
expect(normalizeSharedPluginMetadataSpy).toHaveBeenCalledTimes(1);
|
const normalized = JSON.parse(
|
||||||
|
fs.readFileSync(path.join(instancePath, 'plugins', 'known_marketplaces.json'), 'utf8')
|
||||||
|
);
|
||||||
|
expect(normalized['claude-code-plugins'].installLocation).toBe(
|
||||||
|
path.join(tempRoot, '.claude', 'plugins', 'marketplaces', 'claude-code-plugins')
|
||||||
|
);
|
||||||
|
expect(syncMcpSpy).toHaveBeenCalledWith(instancePath);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes shared plugin metadata during new non-bare instance creation', async () => {
|
||||||
|
spyOn(SharedManager.prototype, 'syncProjectContext').mockResolvedValue(undefined);
|
||||||
|
spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined);
|
||||||
|
const syncMcpSpy = spyOn(InstanceManager.prototype, 'syncMcpServers').mockImplementation(
|
||||||
|
() => false
|
||||||
|
);
|
||||||
|
|
||||||
|
const registryPath = path.join(tempRoot, '.claude', 'plugins', 'known_marketplaces.json');
|
||||||
|
writeMarketplaceRegistry(
|
||||||
|
registryPath,
|
||||||
|
path.join(
|
||||||
|
tempRoot,
|
||||||
|
'.ccs',
|
||||||
|
'instances',
|
||||||
|
'work',
|
||||||
|
'plugins',
|
||||||
|
'marketplaces',
|
||||||
|
'claude-code-plugins'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const manager = new InstanceManager();
|
||||||
|
const instancePath = await manager.ensureInstance('work', { mode: 'isolated' });
|
||||||
|
|
||||||
|
const expected = path.join(
|
||||||
|
tempRoot,
|
||||||
|
'.claude',
|
||||||
|
'plugins',
|
||||||
|
'marketplaces',
|
||||||
|
'claude-code-plugins'
|
||||||
|
);
|
||||||
|
const normalizedShared = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
|
||||||
|
const normalizedInstance = JSON.parse(
|
||||||
|
fs.readFileSync(path.join(instancePath, 'plugins', 'known_marketplaces.json'), 'utf8')
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(normalizedShared['claude-code-plugins'].installLocation).toBe(expected);
|
||||||
|
expect(normalizedInstance['claude-code-plugins'].installLocation).toBe(expected);
|
||||||
expect(syncMcpSpy).toHaveBeenCalledWith(instancePath);
|
expect(syncMcpSpy).toHaveBeenCalledWith(instancePath);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -5,11 +5,13 @@ import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from 'bun:te
|
|||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import SharedManager from '../../src/management/shared-manager';
|
import SharedManager, {
|
||||||
|
normalizePluginMetadataPathString,
|
||||||
|
} from '../../src/management/shared-manager';
|
||||||
|
|
||||||
// Test the normalization regex pattern directly
|
// Test the normalization regex pattern directly
|
||||||
const normalizePluginPaths = (content: string): string => {
|
const normalizePluginPaths = (content: string): string => {
|
||||||
return content.replace(/\/\.ccs\/instances\/[^/]+\//g, '/.claude/');
|
return normalizePluginMetadataPathString(content);
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('SharedManager', () => {
|
describe('SharedManager', () => {
|
||||||
@@ -17,12 +19,14 @@ describe('SharedManager', () => {
|
|||||||
let originalHome: string | undefined;
|
let originalHome: string | undefined;
|
||||||
let originalCcsHome: string | undefined;
|
let originalCcsHome: string | undefined;
|
||||||
let originalCcsDir: string | undefined;
|
let originalCcsDir: string | undefined;
|
||||||
|
let originalPlatform: PropertyDescriptor | undefined;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-shared-manager-test-'));
|
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-shared-manager-test-'));
|
||||||
originalHome = process.env.HOME;
|
originalHome = process.env.HOME;
|
||||||
originalCcsHome = process.env.CCS_HOME;
|
originalCcsHome = process.env.CCS_HOME;
|
||||||
originalCcsDir = process.env.CCS_DIR;
|
originalCcsDir = process.env.CCS_DIR;
|
||||||
|
originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform');
|
||||||
|
|
||||||
spyOn(os, 'homedir').mockReturnValue(tempRoot);
|
spyOn(os, 'homedir').mockReturnValue(tempRoot);
|
||||||
process.env.HOME = tempRoot;
|
process.env.HOME = tempRoot;
|
||||||
@@ -42,6 +46,10 @@ describe('SharedManager', () => {
|
|||||||
if (originalCcsDir !== undefined) process.env.CCS_DIR = originalCcsDir;
|
if (originalCcsDir !== undefined) process.env.CCS_DIR = originalCcsDir;
|
||||||
else delete process.env.CCS_DIR;
|
else delete process.env.CCS_DIR;
|
||||||
|
|
||||||
|
if (originalPlatform) {
|
||||||
|
Object.defineProperty(process, 'platform', originalPlatform);
|
||||||
|
}
|
||||||
|
|
||||||
if (tempRoot && fs.existsSync(tempRoot)) {
|
if (tempRoot && fs.existsSync(tempRoot)) {
|
||||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
@@ -149,9 +157,8 @@ describe('SharedManager', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should handle Windows-style paths (backslash)', () => {
|
it('should handle Windows-style paths (backslash)', () => {
|
||||||
// Windows paths use backslashes, regex should not match
|
|
||||||
const input = 'C:\\Users\\user\\.ccs\\instances\\ck\\plugins\\cache';
|
const input = 'C:\\Users\\user\\.ccs\\instances\\ck\\plugins\\cache';
|
||||||
expect(normalizePluginPaths(input)).toBe(input);
|
expect(normalizePluginPaths(input)).toBe('C:\\Users\\user\\.claude\\plugins\\cache');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -185,5 +192,81 @@ describe('SharedManager', () => {
|
|||||||
'/home/kai/.claude/plugins/marketplaces/claude-code-plugins'
|
'/home/kai/.claude/plugins/marketplaces/claude-code-plugins'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rewrites Windows-style known_marketplaces.json paths on disk', () => {
|
||||||
|
const pluginsDir = path.join(tempRoot, '.claude', 'plugins');
|
||||||
|
fs.mkdirSync(pluginsDir, { recursive: true });
|
||||||
|
|
||||||
|
const registryPath = path.join(pluginsDir, 'known_marketplaces.json');
|
||||||
|
fs.writeFileSync(
|
||||||
|
registryPath,
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
'claude-code-plugins': {
|
||||||
|
installLocation:
|
||||||
|
'C:\\Users\\kai\\.ccs\\instances\\work\\plugins\\marketplaces\\claude-code-plugins',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
const manager = new SharedManager();
|
||||||
|
manager.normalizeMarketplaceRegistryPaths();
|
||||||
|
|
||||||
|
const normalized = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
|
||||||
|
expect(normalized['claude-code-plugins'].installLocation).toBe(
|
||||||
|
'C:\\Users\\kai\\.claude\\plugins\\marketplaces\\claude-code-plugins'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes copied shared and instance metadata under Windows fallback', () => {
|
||||||
|
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||||
|
spyOn(fs, 'symlinkSync').mockImplementation(() => {
|
||||||
|
throw Object.assign(new Error('simulated symlink failure'), { code: 'EPERM' });
|
||||||
|
});
|
||||||
|
|
||||||
|
const pluginsDir = path.join(tempRoot, '.claude', 'plugins');
|
||||||
|
fs.mkdirSync(pluginsDir, { recursive: true });
|
||||||
|
|
||||||
|
const registryPath = path.join(pluginsDir, 'known_marketplaces.json');
|
||||||
|
fs.writeFileSync(
|
||||||
|
registryPath,
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
'claude-code-plugins': {
|
||||||
|
installLocation:
|
||||||
|
'/home/kai/.ccs/instances/work/plugins/marketplaces/claude-code-plugins',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
const manager = new SharedManager();
|
||||||
|
const instancePath = path.join(tempRoot, '.ccs', 'instances', 'personal');
|
||||||
|
fs.mkdirSync(instancePath, { recursive: true });
|
||||||
|
manager.linkSharedDirectories(instancePath);
|
||||||
|
|
||||||
|
const expected = '/home/kai/.claude/plugins/marketplaces/claude-code-plugins';
|
||||||
|
const claudeRegistry = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
|
||||||
|
const sharedRegistry = JSON.parse(
|
||||||
|
fs.readFileSync(
|
||||||
|
path.join(tempRoot, '.ccs', 'shared', 'plugins', 'known_marketplaces.json'),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const instanceRegistry = JSON.parse(
|
||||||
|
fs.readFileSync(path.join(instancePath, 'plugins', 'known_marketplaces.json'), 'utf8')
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(claudeRegistry['claude-code-plugins'].installLocation).toBe(expected);
|
||||||
|
expect(sharedRegistry['claude-code-plugins'].installLocation).toBe(expected);
|
||||||
|
expect(instanceRegistry['claude-code-plugins'].installLocation).toBe(expected);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,4 +1,14 @@
|
|||||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock } from 'bun:test';
|
import {
|
||||||
|
afterAll,
|
||||||
|
afterEach,
|
||||||
|
beforeAll,
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
mock,
|
||||||
|
spyOn,
|
||||||
|
} from 'bun:test';
|
||||||
import { EventEmitter } from 'events';
|
import { EventEmitter } from 'events';
|
||||||
import * as childProcess from 'child_process';
|
import * as childProcess from 'child_process';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
@@ -115,6 +125,7 @@ preferences:
|
|||||||
let execClaude: typeof import('../../../src/utils/shell-executor').execClaude;
|
let execClaude: typeof import('../../../src/utils/shell-executor').execClaude;
|
||||||
let stripClaudeCodeEnv: typeof import('../../../src/utils/shell-executor').stripClaudeCodeEnv;
|
let stripClaudeCodeEnv: typeof import('../../../src/utils/shell-executor').stripClaudeCodeEnv;
|
||||||
let HeadlessExecutor: typeof import('../../../src/delegation/headless-executor').HeadlessExecutor;
|
let HeadlessExecutor: typeof import('../../../src/delegation/headless-executor').HeadlessExecutor;
|
||||||
|
let SharedManager: typeof import('../../../src/management/shared-manager').default;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
registerChildProcessMock();
|
registerChildProcessMock();
|
||||||
@@ -123,6 +134,9 @@ beforeAll(async () => {
|
|||||||
execClaude = shellExecutor.execClaude;
|
execClaude = shellExecutor.execClaude;
|
||||||
stripClaudeCodeEnv = shellExecutor.stripClaudeCodeEnv;
|
stripClaudeCodeEnv = shellExecutor.stripClaudeCodeEnv;
|
||||||
|
|
||||||
|
const sharedManagerModule = await import('../../../src/management/shared-manager');
|
||||||
|
SharedManager = sharedManagerModule.default;
|
||||||
|
|
||||||
const headless = await import('../../../src/delegation/headless-executor');
|
const headless = await import('../../../src/delegation/headless-executor');
|
||||||
HeadlessExecutor = headless.HeadlessExecutor;
|
HeadlessExecutor = headless.HeadlessExecutor;
|
||||||
});
|
});
|
||||||
@@ -242,6 +256,32 @@ describe('CLAUDECODE environment stripping', () => {
|
|||||||
expect(env.DISABLE_AUTOUPDATER).toBeUndefined();
|
expect(env.DISABLE_AUTOUPDATER).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('execClaude normalizes shared plugin metadata before default-profile launch', () => {
|
||||||
|
const normalizeSpy = spyOn(
|
||||||
|
SharedManager.prototype,
|
||||||
|
'normalizeSharedPluginMetadataPaths'
|
||||||
|
).mockImplementation(() => {});
|
||||||
|
|
||||||
|
execClaude('claude', ['--help'], { CCS_PROFILE_TYPE: 'default' });
|
||||||
|
|
||||||
|
expect(normalizeSpy).toHaveBeenCalledWith(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('execClaude normalizes shared plugin metadata using CLAUDE_CONFIG_DIR when provided', () => {
|
||||||
|
const normalizeSpy = spyOn(
|
||||||
|
SharedManager.prototype,
|
||||||
|
'normalizeSharedPluginMetadataPaths'
|
||||||
|
).mockImplementation(() => {});
|
||||||
|
const instancePath = path.join(os.tmpdir(), 'ccs-shell-executor-instance');
|
||||||
|
|
||||||
|
execClaude('claude', ['--help'], {
|
||||||
|
CCS_PROFILE_TYPE: 'settings',
|
||||||
|
CLAUDE_CONFIG_DIR: instancePath,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(normalizeSpy).toHaveBeenCalledWith(instancePath);
|
||||||
|
});
|
||||||
|
|
||||||
it('headless executor spawn path strips CLAUDECODE before spawn', async () => {
|
it('headless executor spawn path strips CLAUDECODE before spawn', async () => {
|
||||||
writeConfigWithAutoUpdatePreference(false);
|
writeConfigWithAutoUpdatePreference(false);
|
||||||
process.env.CLAUDECODE = 'nested';
|
process.env.CLAUDECODE = 'nested';
|
||||||
|
|||||||
@@ -1,10 +1,21 @@
|
|||||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test';
|
import {
|
||||||
|
afterAll,
|
||||||
|
afterEach,
|
||||||
|
beforeAll,
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
mock,
|
||||||
|
spyOn,
|
||||||
|
} from 'bun:test';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
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 type { Server } from 'http';
|
import type { Server } from 'http';
|
||||||
import claudeExtensionRoutes from '../../../src/web-server/routes/claude-extension-routes';
|
import claudeExtensionRoutes from '../../../src/web-server/routes/claude-extension-routes';
|
||||||
|
import SharedManager from '../../../src/management/shared-manager';
|
||||||
import { createEmptyUnifiedConfig } from '../../../src/config/unified-config-types';
|
import { createEmptyUnifiedConfig } from '../../../src/config/unified-config-types';
|
||||||
import { saveUnifiedConfig } from '../../../src/config/unified-config-loader';
|
import { saveUnifiedConfig } from '../../../src/config/unified-config-loader';
|
||||||
|
|
||||||
@@ -98,6 +109,8 @@ describe('web-server claude-extension-routes', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
mock.restore();
|
||||||
|
|
||||||
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
|
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
|
||||||
else delete process.env.CCS_HOME;
|
else delete process.env.CCS_HOME;
|
||||||
|
|
||||||
@@ -143,6 +156,61 @@ describe('web-server claude-extension-routes', () => {
|
|||||||
expect(payload.sharedSettings.json).toContain('"env"');
|
expect(payload.sharedSettings.json).toContain('"env"');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('normalizes the effective profile CLAUDE_CONFIG_DIR for extension setup', async () => {
|
||||||
|
const explicitConfigDir = path.join(tempHome, '.claude-profiles', 'glm');
|
||||||
|
const glmSettingsPath = path.join(tempHome, '.ccs', 'glm.settings.json');
|
||||||
|
const normalizeSpy = spyOn(
|
||||||
|
SharedManager.prototype,
|
||||||
|
'normalizeSharedPluginMetadataPaths'
|
||||||
|
).mockImplementation(() => {});
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
glmSettingsPath,
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
env: {
|
||||||
|
ANTHROPIC_BASE_URL: 'https://api.example.test',
|
||||||
|
ANTHROPIC_API_KEY: 'sk-ant-test-123456',
|
||||||
|
ANTHROPIC_MODEL: 'claude-sonnet-4-5',
|
||||||
|
CLAUDE_CONFIG_DIR: explicitConfigDir,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
) + '\n'
|
||||||
|
);
|
||||||
|
|
||||||
|
const config = createEmptyUnifiedConfig();
|
||||||
|
config.profiles.glm = {
|
||||||
|
type: 'api',
|
||||||
|
settings: glmSettingsPath,
|
||||||
|
};
|
||||||
|
config.accounts.work = {
|
||||||
|
created: '2026-03-15T00:00:00.000Z',
|
||||||
|
last_used: null,
|
||||||
|
context_mode: 'isolated',
|
||||||
|
};
|
||||||
|
config.default = 'work';
|
||||||
|
config.continuity = {
|
||||||
|
inherit_from_account: {
|
||||||
|
glm: 'work',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
saveUnifiedConfig(config);
|
||||||
|
|
||||||
|
const response = await fetch(`${baseUrl}/api/claude-extension/setup?profile=glm&host=vscode`);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
|
||||||
|
const payload = (await response.json()) as {
|
||||||
|
ideSettings: { json: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(payload.ideSettings.json).toContain(explicitConfigDir);
|
||||||
|
expect(normalizeSpy.mock.calls.some(([configDir]) => configDir === explicitConfigDir)).toBe(
|
||||||
|
true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('renders Windsurf setup for default account resolution via CLAUDE_CONFIG_DIR', async () => {
|
it('renders Windsurf setup for default account resolution via CLAUDE_CONFIG_DIR', async () => {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${baseUrl}/api/claude-extension/setup?profile=default&host=windsurf`
|
`${baseUrl}/api/claude-extension/setup?profile=default&host=windsurf`
|
||||||
@@ -179,7 +247,11 @@ describe('web-server claude-extension-routes', () => {
|
|||||||
expect(createResponse.status).toBe(201);
|
expect(createResponse.status).toBe(201);
|
||||||
|
|
||||||
const created = (await createResponse.json()) as {
|
const created = (await createResponse.json()) as {
|
||||||
binding: { id: string; effectiveIdeSettingsPath: string; usesDefaultIdeSettingsPath: boolean };
|
binding: {
|
||||||
|
id: string;
|
||||||
|
effectiveIdeSettingsPath: string;
|
||||||
|
usesDefaultIdeSettingsPath: boolean;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
expect(created.binding.effectiveIdeSettingsPath).toBe(ideSettingsPath);
|
expect(created.binding.effectiveIdeSettingsPath).toBe(ideSettingsPath);
|
||||||
expect(created.binding.usesDefaultIdeSettingsPath).toBe(false);
|
expect(created.binding.usesDefaultIdeSettingsPath).toBe(false);
|
||||||
@@ -418,7 +490,10 @@ describe('web-server claude-extension-routes', () => {
|
|||||||
);
|
);
|
||||||
expect(applyResponse.status).toBe(200);
|
expect(applyResponse.status).toBe(200);
|
||||||
|
|
||||||
let ideSettings = JSON.parse(fs.readFileSync(ideSettingsPath, 'utf8')) as Record<string, unknown>;
|
let ideSettings = JSON.parse(fs.readFileSync(ideSettingsPath, 'utf8')) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
const appliedEnv = ideSettings['claudeCode.environmentVariables'] as Array<{
|
const appliedEnv = ideSettings['claudeCode.environmentVariables'] as Array<{
|
||||||
name: string;
|
name: string;
|
||||||
value: string;
|
value: string;
|
||||||
@@ -426,7 +501,9 @@ describe('web-server claude-extension-routes', () => {
|
|||||||
|
|
||||||
expect(appliedEnv.some((entry) => entry.name === 'KEEP_ME' && entry.value === '1')).toBe(true);
|
expect(appliedEnv.some((entry) => entry.name === 'KEEP_ME' && entry.value === '1')).toBe(true);
|
||||||
expect(
|
expect(
|
||||||
appliedEnv.some((entry) => entry.name === 'ANTHROPIC_API_KEY' && entry.value === 'sk-ant-test-123456')
|
appliedEnv.some(
|
||||||
|
(entry) => entry.name === 'ANTHROPIC_API_KEY' && entry.value === 'sk-ant-test-123456'
|
||||||
|
)
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
|
|
||||||
const verifyAppliedResponse = await fetch(
|
const verifyAppliedResponse = await fetch(
|
||||||
@@ -452,7 +529,9 @@ describe('web-server claude-extension-routes', () => {
|
|||||||
ideSettings = JSON.parse(fs.readFileSync(ideSettingsPath, 'utf8')) as Record<string, unknown>;
|
ideSettings = JSON.parse(fs.readFileSync(ideSettingsPath, 'utf8')) as Record<string, unknown>;
|
||||||
expect(ideSettings['editor.tabSize']).toBe(2);
|
expect(ideSettings['editor.tabSize']).toBe(2);
|
||||||
expect(ideSettings['claudeCode.disableLoginPrompt']).toBeUndefined();
|
expect(ideSettings['claudeCode.disableLoginPrompt']).toBeUndefined();
|
||||||
expect(ideSettings['claudeCode.environmentVariables']).toEqual([{ name: 'KEEP_ME', value: '1' }]);
|
expect(ideSettings['claudeCode.environmentVariables']).toEqual([
|
||||||
|
{ name: 'KEEP_ME', value: '1' },
|
||||||
|
]);
|
||||||
|
|
||||||
const verifyResetResponse = await fetch(
|
const verifyResetResponse = await fetch(
|
||||||
`${baseUrl}/api/claude-extension/bindings/${created.binding.id}/verify`
|
`${baseUrl}/api/claude-extension/bindings/${created.binding.id}/verify`
|
||||||
|
|||||||
Reference in new issue
Block a user