mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-11 03:13:12 +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:
1 parent
242a095edb
commit
68a5d17327
4 files changed
+156
-36
No files matched your search
@@ -59,14 +59,15 @@ class InstanceManager {
|
|||||||
await this.sharedManager.syncProjectContext(instancePath, contextPolicy);
|
await this.sharedManager.syncProjectContext(instancePath, contextPolicy);
|
||||||
await this.sharedManager.syncAdvancedContinuityArtifacts(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);
|
this.sharedManager.linkSharedDirectories(instancePath);
|
||||||
});
|
return;
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
this.sharedManager.normalizeSharedPluginMetadataPaths(options.bare ? undefined : instancePath);
|
this.sharedManager.normalizeSharedPluginMetadataPaths();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Sync MCP servers from global ~/.claude.json (unless bare)
|
// Sync MCP servers from global ~/.claude.json (unless bare)
|
||||||
if (!options.bare) {
|
if (!options.bare) {
|
||||||
|
|||||||
@@ -899,8 +899,10 @@ class SharedManager {
|
|||||||
for (const [name, value] of Object.entries(parsed as Record<string, unknown>)) {
|
for (const [name, value] of Object.entries(parsed as Record<string, unknown>)) {
|
||||||
merged[name] = normalizePluginMetadataValue(value, targetConfigDir).normalized;
|
merged[name] = normalizePluginMetadataValue(value, targetConfigDir).normalized;
|
||||||
}
|
}
|
||||||
} catch (_err) {
|
} catch (err) {
|
||||||
// Best-effort merge: malformed sources should not block self-heal.
|
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 {
|
function expectMarketplaceLocation(registryPath: string, expectedLocation: string): void {
|
||||||
const parsed = readJson(registryPath) as Record<string, { installLocation?: string }>;
|
const parsed = readJson(registryPath) as Record<string, { installLocation?: string }>;
|
||||||
expect(parsed['claude-code-plugins']?.installLocation).toBe(expectedLocation);
|
expect(parsed['claude-code-plugins']?.installLocation).toBe(expectedLocation);
|
||||||
@@ -226,43 +248,100 @@ describe('InstanceManager MCP sync', () => {
|
|||||||
expect(syncMcpSpy).toHaveBeenCalledWith(instancePath);
|
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, 'syncProjectContext').mockResolvedValue(undefined);
|
||||||
spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').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 manager = new InstanceManager();
|
||||||
const workPath = await manager.ensureInstance('work', { mode: 'isolated' });
|
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' });
|
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 = (
|
expect(workRegistry['claude-code-plugins']).toMatchObject({
|
||||||
readJson(path.join(workPath, 'plugins', 'known_marketplaces.json')) as Record<
|
installLocation: marketplacePath(workPath),
|
||||||
string,
|
label: 'Official marketplace',
|
||||||
{ installLocation?: string }
|
refreshToken: 'refresh-token',
|
||||||
>
|
metadata: {
|
||||||
)['claude-code-plugins']?.installLocation;
|
source: 'refresh-flow',
|
||||||
const personalInstallLocation = (
|
lastSyncedAt: '2026-03-18T00:00:00Z',
|
||||||
readJson(path.join(personalPath, 'plugins', 'known_marketplaces.json')) as Record<
|
},
|
||||||
string,
|
});
|
||||||
{ installLocation?: string }
|
expect(personalRegistry['claude-code-plugins']).toMatchObject({
|
||||||
>
|
installLocation: marketplacePath(personalPath),
|
||||||
)['claude-code-plugins']?.installLocation;
|
label: 'Official marketplace',
|
||||||
|
refreshToken: 'refresh-token',
|
||||||
|
metadata: {
|
||||||
|
source: 'refresh-flow',
|
||||||
|
lastSyncedAt: '2026-03-18T00:00:00Z',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
expect(workInstallLocation).toBe(marketplacePath(workPath));
|
it('upgrades a legacy shared plugins symlink to an instance-local layout', async () => {
|
||||||
expect(personalInstallLocation).toBe(marketplacePath(personalPath));
|
spyOn(SharedManager.prototype, 'syncProjectContext').mockResolvedValue(undefined);
|
||||||
expect(path.resolve(workInstallLocation ?? '')).toStartWith(
|
spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined);
|
||||||
path.resolve(path.join(workPath, 'plugins', 'marketplaces'))
|
|
||||||
|
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', () => {
|
it('keeps the instance-local registry valid under Windows copy fallback', () => {
|
||||||
Object.defineProperty(process, 'platform', { value: 'win32' });
|
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||||
spyOn(fs, 'symlinkSync').mockImplementation(() => {
|
spyOn(fs, 'symlinkSync').mockImplementation(() => {
|
||||||
|
|||||||
Reference in new issue
Block a user