mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 12:15:57 +00:00
Merge pull request #739 from kaitranntt/kai/fix/738-marketplace-install-location
fix: normalize shared marketplace install paths
This commit is contained in:
@@ -58,6 +58,8 @@ class InstanceManager {
|
||||
await this.sharedManager.syncAdvancedContinuityArtifacts(instancePath, contextPolicy);
|
||||
});
|
||||
|
||||
this.sharedManager.normalizeSharedPluginMetadataPaths(instancePath);
|
||||
|
||||
// Sync MCP servers from global ~/.claude.json (unless bare)
|
||||
if (!options.bare) {
|
||||
this.syncMcpServers(instancePath);
|
||||
|
||||
@@ -18,6 +18,50 @@ interface SharedItem {
|
||||
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
|
||||
*/
|
||||
@@ -210,8 +254,7 @@ class SharedManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize plugin registry paths after linking
|
||||
this.normalizePluginRegistryPaths();
|
||||
this.normalizeSharedPluginMetadataPaths(instancePath);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -539,6 +582,14 @@ class SharedManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize shared plugin metadata files to canonical ~/.claude/ paths.
|
||||
*/
|
||||
normalizeSharedPluginMetadataPaths(configDir?: string): void {
|
||||
this.normalizePluginRegistryPaths(configDir);
|
||||
this.normalizeMarketplaceRegistryPaths(configDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize plugin registry paths to use canonical ~/.claude/ paths
|
||||
* instead of instance-specific ~/.ccs/instances/<name>/ paths.
|
||||
@@ -546,31 +597,82 @@ class SharedManager {
|
||||
* This ensures installed_plugins.json is consistent regardless of
|
||||
* which CCS instance installed the plugin.
|
||||
*/
|
||||
normalizePluginRegistryPaths(): void {
|
||||
const registryPath = path.join(this.claudeDir, 'plugins', 'installed_plugins.json');
|
||||
normalizePluginRegistryPaths(configDir?: string): void {
|
||||
this.normalizePluginMetadataFiles(
|
||||
'installed_plugins.json',
|
||||
configDir,
|
||||
'Normalized plugin registry paths',
|
||||
'plugin registry'
|
||||
);
|
||||
}
|
||||
|
||||
// Skip if registry doesn't exist
|
||||
/**
|
||||
* Normalize marketplace registry paths to use canonical ~/.claude/ paths
|
||||
* instead of instance-specific ~/.ccs/instances/<name>/ paths.
|
||||
*
|
||||
* This ensures known_marketplaces.json is consistent regardless of
|
||||
* which CCS instance added the marketplace.
|
||||
*/
|
||||
normalizeMarketplaceRegistryPaths(configDir?: string): void {
|
||||
this.normalizePluginMetadataFiles(
|
||||
'known_marketplaces.json',
|
||||
configDir,
|
||||
'Normalized marketplace registry paths',
|
||||
'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(
|
||||
registryPath: string,
|
||||
successMessage: string,
|
||||
warningLabel: string
|
||||
): void {
|
||||
if (!fs.existsSync(registryPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const original = fs.readFileSync(registryPath, 'utf8');
|
||||
const normalized = normalizePluginMetadataContent(original);
|
||||
|
||||
// Replace instance paths with canonical claude path
|
||||
// Pattern: /.ccs/instances/<instance-name>/ -> /.claude/
|
||||
const normalized = original.replace(/\/\.ccs\/instances\/[^/]+\//g, '/.claude/');
|
||||
|
||||
// Only write if changes were made
|
||||
if (normalized !== original) {
|
||||
// Validate JSON before writing
|
||||
JSON.parse(normalized);
|
||||
fs.writeFileSync(registryPath, normalized, 'utf8');
|
||||
console.log(ok('Normalized plugin registry paths'));
|
||||
console.log(ok(successMessage));
|
||||
}
|
||||
} catch (err) {
|
||||
// Log warning but don't fail - registry may be malformed
|
||||
console.log(warn(`Could not normalize plugin registry: ${(err as Error).message}`));
|
||||
console.log(warn(`Could not normalize ${warningLabel}: ${(err as Error).message}`));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager';
|
||||
import { getProxyTarget } from '../cliproxy/proxy-target-resolver';
|
||||
import { generateCopilotEnv } from '../copilot/copilot-executor';
|
||||
import InstanceManager from '../management/instance-manager';
|
||||
import SharedManager from '../management/shared-manager';
|
||||
import { expandPath } from '../utils/helpers';
|
||||
import { getClaudeSettingsPath } from '../utils/claude-config-path';
|
||||
import {
|
||||
@@ -161,6 +162,7 @@ async function resolveExtensionEnv(
|
||||
profileType: result.type,
|
||||
target: 'claude',
|
||||
});
|
||||
new SharedManager().normalizeSharedPluginMetadataPaths(continuity.claudeConfigDir);
|
||||
if (continuity.claudeConfigDir) {
|
||||
notes.push(`Default profile inherits continuity from account "${continuity.sourceAccount}".`);
|
||||
return {
|
||||
@@ -234,6 +236,9 @@ async function resolveExtensionEnv(
|
||||
`Continuity inheritance adds CLAUDE_CONFIG_DIR from account "${continuity.sourceAccount}".`
|
||||
);
|
||||
}
|
||||
|
||||
new SharedManager().normalizeSharedPluginMetadataPaths(env.CLAUDE_CONFIG_DIR);
|
||||
|
||||
if (result.type === 'copilot') {
|
||||
warnings.push(
|
||||
'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 { wireChildProcessSignals } from './signal-forwarder';
|
||||
import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader';
|
||||
import SharedManager from '../management/shared-manager';
|
||||
|
||||
/**
|
||||
* 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)
|
||||
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
|
||||
// (spawned via tmux split-window) inherit the correct config dir
|
||||
if (process.env.TMUX && envVars) {
|
||||
|
||||
@@ -7,14 +7,35 @@ import SharedManager from '../../src/management/shared-manager';
|
||||
|
||||
describe('InstanceManager MCP sync', () => {
|
||||
let tempRoot = '';
|
||||
let originalHome: string | undefined;
|
||||
let originalCcsHome: 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(() => {
|
||||
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-instance-mcp-test-'));
|
||||
originalHome = process.env.HOME;
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
originalCcsDir = process.env.CCS_DIR;
|
||||
|
||||
spyOn(os, 'homedir').mockReturnValue(tempRoot);
|
||||
process.env.HOME = tempRoot;
|
||||
process.env.CCS_HOME = tempRoot;
|
||||
delete process.env.CCS_DIR;
|
||||
});
|
||||
@@ -22,6 +43,9 @@ describe('InstanceManager MCP sync', () => {
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
|
||||
if (originalHome !== undefined) process.env.HOME = originalHome;
|
||||
else delete process.env.HOME;
|
||||
|
||||
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
|
||||
else delete process.env.CCS_HOME;
|
||||
|
||||
@@ -71,7 +95,9 @@ describe('InstanceManager MCP sync', () => {
|
||||
const synced = manager.syncMcpServers(instancePath);
|
||||
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.mcpServers).toEqual({
|
||||
globalOnly: { command: 'global-cmd' },
|
||||
@@ -96,9 +122,10 @@ describe('InstanceManager MCP sync', () => {
|
||||
});
|
||||
|
||||
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, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined);
|
||||
const syncMcpSpy = spyOn(InstanceManager.prototype, 'syncMcpServers').mockImplementation(
|
||||
@@ -106,9 +133,107 @@ describe('InstanceManager MCP sync', () => {
|
||||
);
|
||||
|
||||
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 });
|
||||
|
||||
const normalized = JSON.parse(fs.readFileSync(sharedRegistryPath, 'utf8'));
|
||||
|
||||
expect(linkSharedSpy).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();
|
||||
});
|
||||
|
||||
it('normalizes shared plugin metadata for existing non-bare instances', async () => {
|
||||
spyOn(SharedManager.prototype, 'syncProjectContext').mockResolvedValue(undefined);
|
||||
spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined);
|
||||
const syncMcpSpy = spyOn(InstanceManager.prototype, 'syncMcpServers').mockImplementation(
|
||||
() => false
|
||||
);
|
||||
|
||||
const manager = new InstanceManager();
|
||||
const instancePath = manager.getInstancePath('work');
|
||||
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' });
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,17 +1,60 @@
|
||||
/**
|
||||
* Unit tests for SharedManager - plugin registry path normalization
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import SharedManager, {
|
||||
normalizePluginMetadataPathString,
|
||||
} from '../../src/management/shared-manager';
|
||||
|
||||
// Test the normalization regex pattern directly
|
||||
const normalizePluginPaths = (content: string): string => {
|
||||
return content.replace(/\/\.ccs\/instances\/[^/]+\//g, '/.claude/');
|
||||
return normalizePluginMetadataPathString(content);
|
||||
};
|
||||
|
||||
describe('SharedManager', () => {
|
||||
let tempRoot = '';
|
||||
let originalHome: string | undefined;
|
||||
let originalCcsHome: string | undefined;
|
||||
let originalCcsDir: string | undefined;
|
||||
let originalPlatform: PropertyDescriptor | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-shared-manager-test-'));
|
||||
originalHome = process.env.HOME;
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
originalCcsDir = process.env.CCS_DIR;
|
||||
originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform');
|
||||
|
||||
spyOn(os, 'homedir').mockReturnValue(tempRoot);
|
||||
process.env.HOME = tempRoot;
|
||||
process.env.CCS_HOME = tempRoot;
|
||||
delete process.env.CCS_DIR;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
|
||||
if (originalHome !== undefined) process.env.HOME = originalHome;
|
||||
else delete process.env.HOME;
|
||||
|
||||
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 (originalPlatform) {
|
||||
Object.defineProperty(process, 'platform', originalPlatform);
|
||||
}
|
||||
|
||||
if (tempRoot && fs.existsSync(tempRoot)) {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe('normalizePluginRegistryPaths', () => {
|
||||
describe('regex pattern', () => {
|
||||
it('should replace instance paths with canonical claude path', () => {
|
||||
@@ -82,6 +125,24 @@ describe('SharedManager', () => {
|
||||
'/home/kai/.claude/plugins/cache/claude-hud/claude-hud/0.0.2'
|
||||
);
|
||||
});
|
||||
|
||||
it('should normalize marketplace installLocation values', () => {
|
||||
const original = {
|
||||
'claude-code-plugins': {
|
||||
installLocation:
|
||||
'/home/kai/.ccs/instances/work/plugins/marketplaces/claude-code-plugins',
|
||||
},
|
||||
};
|
||||
const input = JSON.stringify(original, null, 2);
|
||||
const result = normalizePluginPaths(input);
|
||||
|
||||
expect(() => JSON.parse(result)).not.toThrow();
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed['claude-code-plugins'].installLocation).toBe(
|
||||
'/home/kai/.claude/plugins/marketplaces/claude-code-plugins'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
@@ -96,10 +157,116 @@ describe('SharedManager', () => {
|
||||
});
|
||||
|
||||
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';
|
||||
expect(normalizePluginPaths(input)).toBe(input);
|
||||
expect(normalizePluginPaths(input)).toBe('C:\\Users\\user\\.claude\\plugins\\cache');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeMarketplaceRegistryPaths', () => {
|
||||
it('rewrites known_marketplaces.json 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:
|
||||
'/home/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(
|
||||
'/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 * as childProcess from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
@@ -115,6 +125,7 @@ preferences:
|
||||
let execClaude: typeof import('../../../src/utils/shell-executor').execClaude;
|
||||
let stripClaudeCodeEnv: typeof import('../../../src/utils/shell-executor').stripClaudeCodeEnv;
|
||||
let HeadlessExecutor: typeof import('../../../src/delegation/headless-executor').HeadlessExecutor;
|
||||
let SharedManager: typeof import('../../../src/management/shared-manager').default;
|
||||
|
||||
beforeAll(async () => {
|
||||
registerChildProcessMock();
|
||||
@@ -123,6 +134,9 @@ beforeAll(async () => {
|
||||
execClaude = shellExecutor.execClaude;
|
||||
stripClaudeCodeEnv = shellExecutor.stripClaudeCodeEnv;
|
||||
|
||||
const sharedManagerModule = await import('../../../src/management/shared-manager');
|
||||
SharedManager = sharedManagerModule.default;
|
||||
|
||||
const headless = await import('../../../src/delegation/headless-executor');
|
||||
HeadlessExecutor = headless.HeadlessExecutor;
|
||||
});
|
||||
@@ -242,6 +256,32 @@ describe('CLAUDECODE environment stripping', () => {
|
||||
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 () => {
|
||||
writeConfigWithAutoUpdatePreference(false);
|
||||
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 * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import type { Server } from 'http';
|
||||
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 { saveUnifiedConfig } from '../../../src/config/unified-config-loader';
|
||||
|
||||
@@ -98,6 +109,8 @@ describe('web-server claude-extension-routes', () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
|
||||
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
|
||||
else delete process.env.CCS_HOME;
|
||||
|
||||
@@ -143,6 +156,61 @@ describe('web-server claude-extension-routes', () => {
|
||||
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 () => {
|
||||
const response = await fetch(
|
||||
`${baseUrl}/api/claude-extension/setup?profile=default&host=windsurf`
|
||||
@@ -179,7 +247,11 @@ describe('web-server claude-extension-routes', () => {
|
||||
expect(createResponse.status).toBe(201);
|
||||
|
||||
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.usesDefaultIdeSettingsPath).toBe(false);
|
||||
@@ -418,7 +490,10 @@ describe('web-server claude-extension-routes', () => {
|
||||
);
|
||||
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<{
|
||||
name: 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 === '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);
|
||||
|
||||
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>;
|
||||
expect(ideSettings['editor.tabSize']).toBe(2);
|
||||
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(
|
||||
`${baseUrl}/api/claude-extension/bindings/${created.binding.id}/verify`
|
||||
|
||||
Reference in New Issue
Block a user