mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 18:18:43 +00:00
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:
@@ -947,23 +947,20 @@ class SharedManager {
|
|||||||
|
|
||||||
const discoveredEntries = this.discoverMarketplaceEntries(targetConfigDir);
|
const discoveredEntries = this.discoverMarketplaceEntries(targetConfigDir);
|
||||||
|
|
||||||
for (const [name, value] of Object.entries(discoveredEntries)) {
|
// Keep only registry entries that have a physical directory, and update their
|
||||||
const existing = merged[name];
|
// installLocation. Entries only on disk (no registry record) are excluded —
|
||||||
if (existing && typeof existing === 'object' && !Array.isArray(existing)) {
|
// they lack required schema fields that Claude Code enforces.
|
||||||
merged[name] = {
|
|
||||||
...(existing as Record<string, unknown>),
|
|
||||||
installLocation: value.installLocation,
|
|
||||||
};
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
merged[name] = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const name of Object.keys(merged)) {
|
for (const name of Object.keys(merged)) {
|
||||||
|
const entry = merged[name];
|
||||||
if (!(name in discoveredEntries)) {
|
if (!(name in discoveredEntries)) {
|
||||||
delete merged[name];
|
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);
|
return JSON.stringify(merged, null, 2);
|
||||||
@@ -984,6 +981,11 @@ class SharedManager {
|
|||||||
continue;
|
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] = {
|
discovered[entry.name] = {
|
||||||
installLocation: path.join(targetConfigDir, 'plugins', 'marketplaces', entry.name),
|
installLocation: path.join(targetConfigDir, 'plugins', 'marketplaces', entry.name),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -360,6 +360,67 @@ describe('SharedManager', () => {
|
|||||||
expect(reconciled.stale).toBeUndefined();
|
expect(reconciled.stale).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not register .staging directories left behind by interrupted marketplace 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 a .staging dir behind in both the global claude dir
|
||||||
|
// and the instance dir (discoverMarketplaceEntries scans each independently).
|
||||||
|
fs.mkdirSync(marketplacePath(claudeDir(), 'claude-plugins-official.staging'), {
|
||||||
|
recursive: true,
|
||||||
|
});
|
||||||
|
fs.mkdirSync(marketplacePath(instancePath, 'claude-plugins-official.staging'), {
|
||||||
|
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();
|
||||||
|
|
||||||
|
const instanceRegistryPath = path.join(instancePath, 'plugins', 'known_marketplaces.json');
|
||||||
|
const instance = readJson(instanceRegistryPath) as Record<string, unknown>;
|
||||||
|
expect(instance['claude-plugins-official.staging']).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('warns and skips malformed marketplace registries while keeping valid sources', () => {
|
it('warns and skips malformed marketplace registries while keeping valid sources', () => {
|
||||||
const manager = new SharedManager();
|
const manager = new SharedManager();
|
||||||
const instancePath = instanceDir('work');
|
const instancePath = instanceDir('work');
|
||||||
|
|||||||
Reference in New Issue
Block a user