fix(shared-manager): exclude .staging dirs from marketplace registry sync

Claude Code's marketplace auto-update uses a three-step atomic rename:
clone to <name>.staging → rename old dir to <name>.bak → rename staging
to the final name. On Windows, EPERM errors can interrupt the rename-dance
and leave a <name>.staging directory permanently on disk.

CCS scanned physical directories to discover marketplaces, so a lingering
.staging directory was registered as a bare { installLocation } entry in
known_marketplaces.json. Claude Code's Zod schema requires each entry to
also have `source` and `lastUpdated` fields, so the corrupt entry caused
the /plugin command to throw a validation error.

Fix (two layers of defence):

- discoverMarketplaceEntries: skip any directory whose name starts with
  '.' or ends with '.staging'. This filters all hidden dirs including
  .staging and .bak left behind by interrupted rename operations.

- buildMarketplaceRegistryContent: invert the loop direction. Instead of
  iterating discoveredEntries and adding to the registry (which could
  introduce bare entries for disk-only directories with no registry
  record), iterate the merged registry and only keep entries that also
  exist on disk. Disk-only directories are now silently ignored.

Adds two regression tests: one for .staging pollution, one for orphan
registry entries whose physical directory has been removed.
This commit is contained in:
xuhaodong committed 2026-04-15 15:02:54 +08:00
1 parent 2589c02937
commit 5b262f0139
2 files changed
+76 -13

No files matched your search

+15 -13
View File
@@ -947,23 +947,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 (entry && typeof entry === 'object' && !Array.isArray(entry)) {
merged[name] = {
...(entry as Record<string, unknown>),
installLocation: discoveredEntries[name].installLocation,
};
}
// else: entry is in discoveredEntries but has a malformed value — preserve as-is
}
return JSON.stringify(merged, null, 2);
@@ -984,6 +981,11 @@ class SharedManager {
continue;
}
// Skip hidden dirs and Claude Code's .staging rename-dance work trees.
if (entry.name.startsWith('.') || entry.name.endsWith('.staging')) {
continue;
}
discovered[entry.name] = {
installLocation: path.join(targetConfigDir, 'plugins', 'marketplaces', entry.name),
};