mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 10:16:49 +00:00
fix(management): serialize marketplace registry reconciliation
- keep bare and non-bare marketplace normalization inside the plugin-layout lock - warn when malformed registry sources are skipped during reconciliation - add regression coverage for cross-instance refresh metadata and legacy layout upgrade
This commit is contained in:
@@ -59,14 +59,15 @@ class InstanceManager {
|
||||
await this.sharedManager.syncProjectContext(instancePath, contextPolicy);
|
||||
await this.sharedManager.syncAdvancedContinuityArtifacts(instancePath, contextPolicy);
|
||||
|
||||
if (!options.bare) {
|
||||
await this.pluginLayoutLock.withNamedLock('__plugin-layout__', async () => {
|
||||
await this.pluginLayoutLock.withNamedLock('__plugin-layout__', async () => {
|
||||
if (!options.bare) {
|
||||
this.sharedManager.linkSharedDirectories(instancePath);
|
||||
});
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.sharedManager.normalizeSharedPluginMetadataPaths(options.bare ? undefined : instancePath);
|
||||
this.sharedManager.normalizeSharedPluginMetadataPaths();
|
||||
});
|
||||
});
|
||||
|
||||
// Sync MCP servers from global ~/.claude.json (unless bare)
|
||||
if (!options.bare) {
|
||||
|
||||
@@ -899,8 +899,10 @@ class SharedManager {
|
||||
for (const [name, value] of Object.entries(parsed as Record<string, unknown>)) {
|
||||
merged[name] = normalizePluginMetadataValue(value, targetConfigDir).normalized;
|
||||
}
|
||||
} catch (_err) {
|
||||
// Best-effort merge: malformed sources should not block self-heal.
|
||||
} catch (err) {
|
||||
console.log(
|
||||
warn(`Skipping malformed marketplace registry ${registryPath}: ${(err as Error).message}`)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,28 @@ describe('InstanceManager MCP sync', () => {
|
||||
);
|
||||
}
|
||||
|
||||
function writeMarketplaceRegistryWithMetadata(
|
||||
registryPath: string,
|
||||
installLocation: string,
|
||||
metadata: Record<string, unknown>
|
||||
): void {
|
||||
fs.mkdirSync(path.dirname(registryPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
registryPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
'claude-code-plugins': {
|
||||
installLocation,
|
||||
...metadata,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
'utf8'
|
||||
);
|
||||
}
|
||||
|
||||
function expectMarketplaceLocation(registryPath: string, expectedLocation: string): void {
|
||||
const parsed = readJson(registryPath) as Record<string, { installLocation?: string }>;
|
||||
expect(parsed['claude-code-plugins']?.installLocation).toBe(expectedLocation);
|
||||
@@ -226,43 +248,100 @@ describe('InstanceManager MCP sync', () => {
|
||||
expect(syncMcpSpy).toHaveBeenCalledWith(instancePath);
|
||||
});
|
||||
|
||||
it('keeps alternating instances independently valid for Claude Code validation', async () => {
|
||||
it('reconciles marketplace metadata across isolated instances without losing refresh fields', async () => {
|
||||
spyOn(SharedManager.prototype, 'syncProjectContext').mockResolvedValue(undefined);
|
||||
spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined);
|
||||
spyOn(InstanceManager.prototype, 'syncMcpServers').mockImplementation(() => false);
|
||||
|
||||
const globalRegistryPath = path.join(claudeDir(), 'plugins', 'known_marketplaces.json');
|
||||
writeMarketplaceRegistry(
|
||||
globalRegistryPath,
|
||||
path.join(tempRoot, '.claude', 'plugins', 'marketplaces', 'claude-code-plugins')
|
||||
);
|
||||
|
||||
const manager = new InstanceManager();
|
||||
const workPath = await manager.ensureInstance('work', { mode: 'isolated' });
|
||||
const workRegistryPath = path.join(workPath, 'plugins', 'known_marketplaces.json');
|
||||
writeMarketplaceRegistryWithMetadata(workRegistryPath, marketplacePath(workPath), {
|
||||
label: 'Official marketplace',
|
||||
refreshToken: 'refresh-token',
|
||||
metadata: {
|
||||
source: 'refresh-flow',
|
||||
lastSyncedAt: '2026-03-18T00:00:00Z',
|
||||
},
|
||||
});
|
||||
|
||||
const personalPath = await manager.ensureInstance('personal', { mode: 'isolated' });
|
||||
await manager.ensureInstance('work', { mode: 'isolated' });
|
||||
const workRegistry = readJson(workRegistryPath) as Record<
|
||||
string,
|
||||
{
|
||||
installLocation?: string;
|
||||
label?: string;
|
||||
refreshToken?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
>;
|
||||
const personalRegistry = readJson(path.join(personalPath, 'plugins', 'known_marketplaces.json')) as Record<
|
||||
string,
|
||||
{
|
||||
installLocation?: string;
|
||||
label?: string;
|
||||
refreshToken?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
>;
|
||||
|
||||
const workInstallLocation = (
|
||||
readJson(path.join(workPath, 'plugins', 'known_marketplaces.json')) as Record<
|
||||
string,
|
||||
{ installLocation?: string }
|
||||
>
|
||||
)['claude-code-plugins']?.installLocation;
|
||||
const personalInstallLocation = (
|
||||
readJson(path.join(personalPath, 'plugins', 'known_marketplaces.json')) as Record<
|
||||
string,
|
||||
{ installLocation?: string }
|
||||
>
|
||||
)['claude-code-plugins']?.installLocation;
|
||||
expect(workRegistry['claude-code-plugins']).toMatchObject({
|
||||
installLocation: marketplacePath(workPath),
|
||||
label: 'Official marketplace',
|
||||
refreshToken: 'refresh-token',
|
||||
metadata: {
|
||||
source: 'refresh-flow',
|
||||
lastSyncedAt: '2026-03-18T00:00:00Z',
|
||||
},
|
||||
});
|
||||
expect(personalRegistry['claude-code-plugins']).toMatchObject({
|
||||
installLocation: marketplacePath(personalPath),
|
||||
label: 'Official marketplace',
|
||||
refreshToken: 'refresh-token',
|
||||
metadata: {
|
||||
source: 'refresh-flow',
|
||||
lastSyncedAt: '2026-03-18T00:00:00Z',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
expect(workInstallLocation).toBe(marketplacePath(workPath));
|
||||
expect(personalInstallLocation).toBe(marketplacePath(personalPath));
|
||||
expect(path.resolve(workInstallLocation ?? '')).toStartWith(
|
||||
path.resolve(path.join(workPath, 'plugins', 'marketplaces'))
|
||||
it('upgrades a legacy shared plugins symlink to an instance-local layout', async () => {
|
||||
spyOn(SharedManager.prototype, 'syncProjectContext').mockResolvedValue(undefined);
|
||||
spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined);
|
||||
|
||||
const manager = new InstanceManager();
|
||||
const legacyPath = manager.getInstancePath('legacy');
|
||||
const sharedPluginsPath = path.join(tempRoot, '.ccs', 'shared', 'plugins');
|
||||
fs.mkdirSync(sharedPluginsPath, { recursive: true });
|
||||
fs.mkdirSync(legacyPath, { recursive: true });
|
||||
fs.symlinkSync(sharedPluginsPath, path.join(legacyPath, 'plugins'), 'dir');
|
||||
|
||||
writeMarketplaceRegistryWithMetadata(
|
||||
path.join(claudeDir(), 'plugins', 'known_marketplaces.json'),
|
||||
path.join(tempRoot, '.ccs', 'shared', 'plugins', 'marketplaces', 'claude-code-plugins'),
|
||||
{
|
||||
label: 'Legacy marketplace',
|
||||
refreshToken: 'legacy-refresh-token',
|
||||
}
|
||||
);
|
||||
expect(path.resolve(personalInstallLocation ?? '')).toStartWith(
|
||||
path.resolve(path.join(personalPath, 'plugins', 'marketplaces'))
|
||||
|
||||
await manager.ensureInstance('legacy', { mode: 'isolated' });
|
||||
|
||||
expect(fs.lstatSync(path.join(legacyPath, 'plugins')).isSymbolicLink()).toBe(false);
|
||||
expectMarketplaceLocation(
|
||||
path.join(legacyPath, 'plugins', 'known_marketplaces.json'),
|
||||
marketplacePath(legacyPath)
|
||||
);
|
||||
expectMarketplaceLocation(globalRegistryPath, marketplacePath(claudeDir()));
|
||||
|
||||
const legacyRegistry = readJson(
|
||||
path.join(legacyPath, 'plugins', 'known_marketplaces.json')
|
||||
) as Record<
|
||||
string,
|
||||
{ installLocation?: string; label?: string; refreshToken?: string }
|
||||
>;
|
||||
expect(legacyRegistry['claude-code-plugins']).toMatchObject({
|
||||
installLocation: marketplacePath(legacyPath),
|
||||
label: 'Legacy marketplace',
|
||||
refreshToken: 'legacy-refresh-token',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -204,6 +204,44 @@ describe('SharedManager', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('warns and skips malformed marketplace registries while keeping valid sources', () => {
|
||||
const manager = new SharedManager();
|
||||
const instancePath = instanceDir('work');
|
||||
fs.mkdirSync(instancePath, { recursive: true });
|
||||
manager.linkSharedDirectories(instancePath);
|
||||
|
||||
const globalRegistryPath = path.join(claudeDir(), 'plugins', 'known_marketplaces.json');
|
||||
writeJson(globalRegistryPath, {
|
||||
'claude-code-plugins': {
|
||||
installLocation: path.join(
|
||||
tempRoot,
|
||||
'.ccs',
|
||||
'instances',
|
||||
'work',
|
||||
'plugins',
|
||||
'marketplaces',
|
||||
'claude-code-plugins'
|
||||
),
|
||||
label: 'Official marketplace',
|
||||
},
|
||||
});
|
||||
|
||||
const malformedRegistryPath = path.join(instancePath, 'plugins', 'known_marketplaces.json');
|
||||
fs.writeFileSync(malformedRegistryPath, '{invalid-json', 'utf8');
|
||||
const logSpy = spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
manager.normalizeMarketplaceRegistryPaths(instancePath);
|
||||
|
||||
expect(readMarketplaceLocation(malformedRegistryPath)).toBe(marketplacePath(instancePath));
|
||||
expect(
|
||||
logSpy.mock.calls.some(
|
||||
([message]) =>
|
||||
String(message).includes('Skipping malformed marketplace registry') &&
|
||||
String(message).includes(malformedRegistryPath)
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the instance-local registry valid under Windows copy fallback', () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||
spyOn(fs, 'symlinkSync').mockImplementation(() => {
|
||||
|
||||
Reference in New Issue
Block a user