mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 02:14:32 +00:00
fix(management): harden marketplace state transitions
- detach verified shared symlinks when a profile is reopened as bare - prune stale marketplace entries whose payload directories no longer exist - add regressions for bare/non-bare transitions and stale registry recovery
This commit is contained in:
1 parent
68a5d17327
commit
fc02c4b968
4 files changed
+277
-1
No files matched your search
@@ -17,6 +17,10 @@ describe('InstanceManager MCP sync', () => {
|
||||
const readJson = (filePath: string) =>
|
||||
JSON.parse(fs.readFileSync(filePath, 'utf8')) as Record<string, unknown>;
|
||||
|
||||
function ensureMarketplacePayload(configDir: string, name = 'claude-code-plugins'): void {
|
||||
fs.mkdirSync(marketplacePath(configDir, name), { recursive: true });
|
||||
}
|
||||
|
||||
function writeMarketplaceRegistry(registryPath: string, installLocation: string): void {
|
||||
fs.mkdirSync(path.dirname(registryPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
@@ -169,6 +173,7 @@ describe('InstanceManager MCP sync', () => {
|
||||
const manager = new InstanceManager();
|
||||
const instancePath = manager.getInstancePath('sandbox');
|
||||
const globalRegistryPath = path.join(claudeDir(), 'plugins', 'known_marketplaces.json');
|
||||
ensureMarketplacePayload(claudeDir());
|
||||
writeMarketplaceRegistry(
|
||||
globalRegistryPath,
|
||||
path.join(
|
||||
@@ -193,6 +198,90 @@ describe('InstanceManager MCP sync', () => {
|
||||
expect(syncMcpSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('detaches existing shared layout when an instance is reopened as bare', 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 = await manager.ensureInstance('work', { mode: 'isolated' });
|
||||
syncMcpSpy.mockClear();
|
||||
|
||||
await manager.ensureInstance('work', { mode: 'isolated' }, { bare: true });
|
||||
|
||||
expect(fs.existsSync(path.join(instancePath, 'settings.json'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(instancePath, 'commands'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(instancePath, 'skills'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(instancePath, 'agents'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(instancePath, 'plugins'))).toBe(false);
|
||||
expect(syncMcpSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('restores the shared layout when a bare-reopened instance is switched back to non-bare', async () => {
|
||||
spyOn(SharedManager.prototype, 'syncProjectContext').mockResolvedValue(undefined);
|
||||
spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined);
|
||||
const syncMcpSpy = spyOn(InstanceManager.prototype, 'syncMcpServers').mockImplementation(
|
||||
() => false
|
||||
);
|
||||
|
||||
const globalRegistryPath = path.join(claudeDir(), 'plugins', 'known_marketplaces.json');
|
||||
ensureMarketplacePayload(claudeDir());
|
||||
writeMarketplaceRegistry(globalRegistryPath, marketplacePath(claudeDir()));
|
||||
|
||||
const manager = new InstanceManager();
|
||||
const instancePath = await manager.ensureInstance('work', { mode: 'isolated' });
|
||||
await manager.ensureInstance('work', { mode: 'isolated' }, { bare: true });
|
||||
syncMcpSpy.mockClear();
|
||||
|
||||
await manager.ensureInstance('work', { mode: 'isolated' });
|
||||
|
||||
expect(fs.lstatSync(path.join(instancePath, 'settings.json')).isSymbolicLink()).toBe(true);
|
||||
expectMarketplaceLocation(
|
||||
path.join(instancePath, 'plugins', 'known_marketplaces.json'),
|
||||
marketplacePath(instancePath)
|
||||
);
|
||||
expect(syncMcpSpy).toHaveBeenCalledWith(instancePath);
|
||||
});
|
||||
|
||||
it('preserves genuine bare-local content when re-ensuring a bare instance', 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('sandbox');
|
||||
fs.mkdirSync(path.join(instancePath, 'plugins', 'marketplaces', 'custom-market'), {
|
||||
recursive: true,
|
||||
});
|
||||
fs.mkdirSync(path.join(instancePath, 'commands'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(instancePath, 'settings.json'),
|
||||
JSON.stringify({ local: true }, null, 2),
|
||||
'utf8'
|
||||
);
|
||||
fs.writeFileSync(path.join(instancePath, 'commands', 'local.md'), '# local', 'utf8');
|
||||
writeMarketplaceRegistry(
|
||||
path.join(instancePath, 'plugins', 'known_marketplaces.json'),
|
||||
marketplacePath(instancePath, 'custom-market')
|
||||
);
|
||||
|
||||
await manager.ensureInstance('sandbox', { mode: 'isolated' }, { bare: true });
|
||||
|
||||
expect(readJson(path.join(instancePath, 'settings.json'))).toEqual({ local: true });
|
||||
expect(fs.readFileSync(path.join(instancePath, 'commands', 'local.md'), 'utf8')).toBe(
|
||||
'# local'
|
||||
);
|
||||
expectMarketplaceLocation(
|
||||
path.join(instancePath, 'plugins', 'known_marketplaces.json'),
|
||||
marketplacePath(instancePath, 'custom-market')
|
||||
);
|
||||
expect(syncMcpSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rewrites existing non-bare instance marketplace metadata to the instance-local plugin dir', async () => {
|
||||
spyOn(SharedManager.prototype, 'syncProjectContext').mockResolvedValue(undefined);
|
||||
spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined);
|
||||
@@ -202,6 +291,7 @@ describe('InstanceManager MCP sync', () => {
|
||||
|
||||
const manager = new InstanceManager();
|
||||
const instancePath = manager.getInstancePath('work');
|
||||
ensureMarketplacePayload(claudeDir());
|
||||
writeMarketplaceRegistry(
|
||||
path.join(instancePath, 'plugins', 'known_marketplaces.json'),
|
||||
path.join(tempRoot, '.claude', 'plugins', 'marketplaces', 'claude-code-plugins')
|
||||
@@ -224,6 +314,7 @@ describe('InstanceManager MCP sync', () => {
|
||||
);
|
||||
|
||||
const globalRegistryPath = path.join(claudeDir(), 'plugins', 'known_marketplaces.json');
|
||||
ensureMarketplacePayload(claudeDir());
|
||||
writeMarketplaceRegistry(
|
||||
globalRegistryPath,
|
||||
path.join(
|
||||
@@ -253,6 +344,7 @@ describe('InstanceManager MCP sync', () => {
|
||||
spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined);
|
||||
|
||||
const manager = new InstanceManager();
|
||||
ensureMarketplacePayload(claudeDir());
|
||||
const workPath = await manager.ensureInstance('work', { mode: 'isolated' });
|
||||
const workRegistryPath = path.join(workPath, 'plugins', 'known_marketplaces.json');
|
||||
writeMarketplaceRegistryWithMetadata(workRegistryPath, marketplacePath(workPath), {
|
||||
@@ -315,6 +407,7 @@ describe('InstanceManager MCP sync', () => {
|
||||
fs.mkdirSync(legacyPath, { recursive: true });
|
||||
fs.symlinkSync(sharedPluginsPath, path.join(legacyPath, 'plugins'), 'dir');
|
||||
|
||||
ensureMarketplacePayload(claudeDir());
|
||||
writeMarketplaceRegistryWithMetadata(
|
||||
path.join(claudeDir(), 'plugins', 'known_marketplaces.json'),
|
||||
path.join(tempRoot, '.ccs', 'shared', 'plugins', 'marketplaces', 'claude-code-plugins'),
|
||||
|
||||
@@ -22,6 +22,10 @@ describe('SharedManager', () => {
|
||||
const readJson = (filePath: string) =>
|
||||
JSON.parse(fs.readFileSync(filePath, 'utf8')) as Record<string, unknown>;
|
||||
|
||||
function ensureMarketplacePayload(configDir: string, name = 'claude-code-plugins'): void {
|
||||
fs.mkdirSync(marketplacePath(configDir, name), { recursive: true });
|
||||
}
|
||||
|
||||
function writeJson(filePath: string, value: unknown): void {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify(value, null, 2), 'utf8');
|
||||
@@ -153,6 +157,7 @@ describe('SharedManager', () => {
|
||||
describe('marketplace registry ownership', () => {
|
||||
it('writes global and instance registries with different authoritative install locations', () => {
|
||||
const globalRegistryPath = path.join(claudeDir(), 'plugins', 'known_marketplaces.json');
|
||||
ensureMarketplacePayload(claudeDir());
|
||||
writeJson(globalRegistryPath, {
|
||||
'claude-code-plugins': {
|
||||
installLocation: path.join(
|
||||
@@ -204,6 +209,36 @@ describe('SharedManager', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('prunes stale marketplace entries whose payload directories no longer exist', () => {
|
||||
const manager = new SharedManager();
|
||||
const instancePath = instanceDir('work');
|
||||
fs.mkdirSync(instancePath, { recursive: true });
|
||||
manager.linkSharedDirectories(instancePath);
|
||||
|
||||
fs.mkdirSync(marketplacePath(claudeDir(), 'claude-code-plugins'), { recursive: true });
|
||||
writeJson(path.join(instancePath, 'plugins', 'known_marketplaces.json'), {
|
||||
'claude-code-plugins': {
|
||||
installLocation: marketplacePath(instancePath, 'claude-code-plugins'),
|
||||
label: 'Official marketplace',
|
||||
},
|
||||
stale: {
|
||||
installLocation: marketplacePath(instancePath, 'stale'),
|
||||
label: 'Stale marketplace',
|
||||
},
|
||||
});
|
||||
|
||||
manager.normalizeMarketplaceRegistryPaths(instancePath);
|
||||
|
||||
const reconciled = readJson(
|
||||
path.join(instancePath, 'plugins', 'known_marketplaces.json')
|
||||
) as Record<string, { label?: string; installLocation?: string }>;
|
||||
expect(reconciled['claude-code-plugins']).toEqual({
|
||||
installLocation: marketplacePath(instancePath, 'claude-code-plugins'),
|
||||
label: 'Official marketplace',
|
||||
});
|
||||
expect(reconciled.stale).toBeUndefined();
|
||||
});
|
||||
|
||||
it('warns and skips malformed marketplace registries while keeping valid sources', () => {
|
||||
const manager = new SharedManager();
|
||||
const instancePath = instanceDir('work');
|
||||
@@ -211,6 +246,7 @@ describe('SharedManager', () => {
|
||||
manager.linkSharedDirectories(instancePath);
|
||||
|
||||
const globalRegistryPath = path.join(claudeDir(), 'plugins', 'known_marketplaces.json');
|
||||
ensureMarketplacePayload(claudeDir());
|
||||
writeJson(globalRegistryPath, {
|
||||
'claude-code-plugins': {
|
||||
installLocation: path.join(
|
||||
@@ -249,6 +285,7 @@ describe('SharedManager', () => {
|
||||
});
|
||||
|
||||
const globalRegistryPath = path.join(claudeDir(), 'plugins', 'known_marketplaces.json');
|
||||
ensureMarketplacePayload(claudeDir());
|
||||
writeJson(globalRegistryPath, {
|
||||
'claude-code-plugins': {
|
||||
installLocation: path.join(
|
||||
|
||||
Reference in new issue
Block a user