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:
Tam Nhu Tran
2026-03-18 07:55:09 -04:00
parent 68a5d17327
commit fc02c4b968
4 changed files with 277 additions and 1 deletions
+1
View File
@@ -65,6 +65,7 @@ class InstanceManager {
return;
}
this.sharedManager.detachSharedDirectories(instancePath);
this.sharedManager.normalizeSharedPluginMetadataPaths();
});
});
+146 -1
View File
@@ -307,6 +307,31 @@ class SharedManager {
this.normalizeSharedPluginMetadataPaths(instancePath);
}
detachSharedDirectories(instancePath: string): void {
this.ensureSharedDirectories();
for (const item of this.sharedItems) {
const managedPath = path.join(instancePath, item.name);
if (!fs.existsSync(managedPath)) {
continue;
}
if (item.name === 'plugins') {
this.detachManagedPluginLayout(instancePath);
continue;
}
const stats = fs.lstatSync(managedPath);
if (!stats.isSymbolicLink()) {
continue;
}
if (this.symlinkPointsTo(managedPath, path.join(this.sharedDir, item.name))) {
this.removeExistingPath(managedPath, item.type);
}
}
}
private ensureSharedPluginLayoutDefaults(): void {
const pluginsDir = path.join(this.claudeDir, 'plugins');
fs.mkdirSync(pluginsDir, { recursive: true, mode: 0o700 });
@@ -906,7 +931,9 @@ class SharedManager {
}
}
for (const [name, value] of Object.entries(this.discoverMarketplaceEntries(targetConfigDir))) {
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] = {
@@ -919,6 +946,12 @@ class SharedManager {
merged[name] = value;
}
for (const name of Object.keys(merged)) {
if (!(name in discoveredEntries)) {
delete merged[name];
}
}
return JSON.stringify(merged, null, 2);
}
@@ -1442,6 +1475,118 @@ class SharedManager {
return candidate;
}
private symlinkPointsTo(linkPath: string, expectedTarget: string): boolean {
try {
const currentTarget = fs.readlinkSync(linkPath);
const resolvedCurrentTarget = path.resolve(path.dirname(linkPath), currentTarget);
return (
this.resolveCanonicalPath(resolvedCurrentTarget) ===
this.resolveCanonicalPath(expectedTarget)
);
} catch {
return false;
}
}
private detachManagedPluginLayout(instancePath: string): void {
const pluginsPath = path.join(instancePath, 'plugins');
if (!fs.existsSync(pluginsPath)) {
return;
}
const stats = fs.lstatSync(pluginsPath);
const sharedPluginsPath = path.join(this.sharedDir, 'plugins');
if (stats.isSymbolicLink()) {
if (this.symlinkPointsTo(pluginsPath, sharedPluginsPath)) {
this.removeExistingPath(pluginsPath, 'directory');
}
return;
}
if (!stats.isDirectory()) {
return;
}
let removedManagedEntries = false;
for (const item of this.getSharedPluginLinkItems()) {
const pluginEntryPath = path.join(pluginsPath, item.name);
if (!fs.existsSync(pluginEntryPath)) {
continue;
}
const entryStats = fs.lstatSync(pluginEntryPath);
if (!entryStats.isSymbolicLink()) {
continue;
}
if (this.symlinkPointsTo(pluginEntryPath, path.join(sharedPluginsPath, item.name))) {
this.removeExistingPath(pluginEntryPath, item.type);
removedManagedEntries = true;
}
}
if (!removedManagedEntries) {
return;
}
this.reconcileLocalMarketplaceRegistry(instancePath);
if (fs.readdirSync(pluginsPath).length === 0) {
fs.rmSync(pluginsPath, { recursive: true, force: true });
}
}
private reconcileLocalMarketplaceRegistry(configDir: string): void {
const registryPath = path.join(configDir, 'plugins', 'known_marketplaces.json');
if (!fs.existsSync(registryPath)) {
return;
}
const discoveredEntries = this.discoverMarketplaceEntries(configDir);
if (Object.keys(discoveredEntries).length === 0) {
this.removeExistingPath(registryPath, 'file');
return;
}
let parsed: Record<string, unknown> = {};
try {
const raw = JSON.parse(fs.readFileSync(registryPath, 'utf8')) as unknown;
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
parsed = raw as Record<string, unknown>;
}
} catch {
parsed = {};
}
const reconciled = Object.fromEntries(
Object.entries(discoveredEntries).map(([name, value]) => {
const existing = parsed[name];
if (existing && typeof existing === 'object' && !Array.isArray(existing)) {
return [
name,
{
...(normalizePluginMetadataValue(existing, configDir).normalized as Record<
string,
unknown
>),
installLocation: value.installLocation,
},
];
}
return [name, value];
})
);
this.writePluginMetadataFile(
registryPath,
JSON.stringify(reconciled, null, 2),
'Synchronized marketplace registry paths'
);
}
private resolveCanonicalPath(targetPath: string): string {
try {
return fs.realpathSync.native(targetPath);
@@ -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'),
+37
View File
@@ -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(