Merge pull request #1025 from LightYear512/fix/marketplace-staging-dir-pollution

fix(shared-manager): exclude .staging dirs from marketplace registry sync
This commit is contained in:
Kai (Tam Nhu) Tran
2026-04-15 16:38:13 -04:00
committed by GitHub
2 changed files with 117 additions and 13 deletions
+28 -13
View File
@@ -936,6 +936,10 @@ class SharedManager {
}
for (const [name, value] of Object.entries(parsed as Record<string, unknown>)) {
if (!this.isMarketplaceRegistryEntry(value)) {
continue;
}
merged[name] = normalizePluginMetadataValue(value, targetConfigDir).normalized;
}
} catch (err) {
@@ -947,22 +951,20 @@ class SharedManager {
const discoveredEntries = this.discoverMarketplaceEntries(targetConfigDir);
for (const [name, value] of Object.entries(discoveredEntries)) {
const existing = merged[name];
if (existing && typeof existing === 'object' && !Array.isArray(existing)) {
merged[name] = {
...(existing as Record<string, unknown>),
installLocation: value.installLocation,
};
continue;
}
merged[name] = value;
}
// Keep only registry entries that have a physical directory, and update their
// installLocation. Entries only on disk (no registry record) are excluded —
// they lack required schema fields that Claude Code enforces.
for (const name of Object.keys(merged)) {
const entry = merged[name];
if (!(name in discoveredEntries)) {
delete merged[name];
} else if (this.isMarketplaceRegistryEntry(entry)) {
merged[name] = {
...entry,
installLocation: discoveredEntries[name].installLocation,
};
} else {
delete merged[name];
}
}
@@ -984,6 +986,11 @@ class SharedManager {
continue;
}
// Skip hidden dirs and Claude Code rename-dance leftovers (.staging/.bak).
if (this.isTransientMarketplaceDirectory(entry.name)) {
continue;
}
discovered[entry.name] = {
installLocation: path.join(targetConfigDir, 'plugins', 'marketplaces', entry.name),
};
@@ -992,6 +999,14 @@ class SharedManager {
return discovered;
}
private isTransientMarketplaceDirectory(name: string): boolean {
return name.startsWith('.') || name.endsWith('.staging') || name.endsWith('.bak');
}
private isMarketplaceRegistryEntry(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
private writePluginMetadataFile(
registryPath: string,
content: string,
+89
View File
@@ -360,6 +360,95 @@ describe('SharedManager', () => {
expect(reconciled.stale).toBeUndefined();
});
it('does not register transient marketplace directories left behind by interrupted auto-updates', () => {
// Regression: CCS used to write bare { installLocation } entries for marketplace
// directories with no registry record. Claude Code requires source + lastUpdated,
// so those entries corrupted known_marketplaces.json and broke /plugin.
const manager = new SharedManager();
const instancePath = instanceDir('work');
fs.mkdirSync(instancePath, { recursive: true });
manager.linkSharedDirectories(instancePath);
// Simulate Claude Code leaving rename-dance temp dirs behind in both the
// global claude dir and the instance dir (discoverMarketplaceEntries scans
// each independently).
for (const suffix of ['.staging', '.bak']) {
fs.mkdirSync(marketplacePath(claudeDir(), `claude-plugins-official${suffix}`), {
recursive: true,
});
fs.mkdirSync(marketplacePath(instancePath, `claude-plugins-official${suffix}`), {
recursive: true,
});
}
manager.normalizeMarketplaceRegistryPaths(instancePath);
const globalRegistryPath = path.join(claudeDir(), 'plugins', 'known_marketplaces.json');
const global = readJson(globalRegistryPath) as Record<string, unknown>;
expect(global['claude-plugins-official.staging']).toBeUndefined();
expect(global['claude-plugins-official.bak']).toBeUndefined();
const instanceRegistryPath = path.join(instancePath, 'plugins', 'known_marketplaces.json');
const instance = readJson(instanceRegistryPath) as Record<string, unknown>;
expect(instance['claude-plugins-official.staging']).toBeUndefined();
expect(instance['claude-plugins-official.bak']).toBeUndefined();
});
it('removes registry entries whose physical marketplace directory no longer exists', () => {
// Regression guard: buildMarketplaceRegistryContent merges JSON sources then
// cross-checks against discoveredEntries. Any name in the merged registry that
// has no matching directory on disk must be pruned so stale entries don't
// accumulate across marketplace uninstalls or renames.
const manager = new SharedManager();
const instancePath = instanceDir('work');
fs.mkdirSync(instancePath, { recursive: true });
manager.linkSharedDirectories(instancePath);
// Write a registry entry for a marketplace that has no physical directory.
const globalRegistryPath = path.join(claudeDir(), 'plugins', 'known_marketplaces.json');
writeJson(globalRegistryPath, {
'vanished-marketplace': {
source: { type: 'github', repo: 'example/vanished' },
lastUpdated: '2024-01-01T00:00:00.000Z',
installLocation: marketplacePath(claudeDir(), 'vanished-marketplace'),
},
});
// Intentionally do NOT create the physical directory — simulate an uninstalled
// marketplace whose registry entry was not cleaned up.
manager.normalizeMarketplaceRegistryPaths(instancePath);
const global = readJson(globalRegistryPath) as Record<string, unknown>;
expect(global['vanished-marketplace']).toBeUndefined();
const instanceRegistryPath = path.join(instancePath, 'plugins', 'known_marketplaces.json');
const instance = readJson(instanceRegistryPath) as Record<string, unknown>;
expect(instance['vanished-marketplace']).toBeUndefined();
});
it('drops malformed marketplace entries even when the payload directory still exists', () => {
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 });
const globalRegistryPath = path.join(claudeDir(), 'plugins', 'known_marketplaces.json');
writeJson(globalRegistryPath, {
'claude-code-plugins': 'bad-entry',
});
manager.normalizeMarketplaceRegistryPaths(instancePath);
const global = readJson(globalRegistryPath) as Record<string, unknown>;
expect(global['claude-code-plugins']).toBeUndefined();
const instanceRegistryPath = path.join(instancePath, 'plugins', 'known_marketplaces.json');
const instance = readJson(instanceRegistryPath) as Record<string, unknown>;
expect(instance['claude-code-plugins']).toBeUndefined();
});
it('warns and skips malformed marketplace registries while keeping valid sources', () => {
const manager = new SharedManager();
const instancePath = instanceDir('work');