fix: preserve --plugin-dir across cache clears, safe force-reinstall, URL classification

- Store injected --plugin-dir roots in module-level variable; survive
  cache clears by re-merging on next listClaudePluginRoots() call
- Defer old plugin deletion in force-reinstall until new cache succeeds;
  transient failures no longer leave the plugin uninstalled
- Classify HTTP URLs by pathname instead of full string; query params
  no longer cause .json URLs to be misclassified as git
This commit is contained in:
Miroslav Drbal
2026-03-30 13:44:54 +02:00
committed by can1357
parent 87b0e8eab4
commit 7a8e4e021e
3 changed files with 36 additions and 13 deletions
+12 -1
View File
@@ -746,6 +746,14 @@ export async function listClaudePluginRoots(home: string): Promise<{ roots: Clau
}
}
// Merge --plugin-dir roots (highest precedence) on every fresh load
if (injectedPluginDirRoots.length > 0) {
const injectedIds = new Set(injectedPluginDirRoots.map(r => r.id));
const filtered = roots.filter(r => !injectedIds.has(r.id));
roots.length = 0;
roots.push(...injectedPluginDirRoots, ...filtered);
}
const result = { roots, warnings };
pluginRootsCache.set(home, result);
return result;
@@ -756,7 +764,7 @@ export async function listClaudePluginRoots(home: string): Promise<{ roots: Clau
*/
export function clearClaudePluginRootsCache(): void {
pluginRootsCache.clear();
preloadedPluginRoots = [];
preloadedPluginRoots = [...injectedPluginDirRoots];
}
// ── Preloaded plugin roots (for sync consumers like LSP config) ─────────────
@@ -764,6 +772,7 @@ export function clearClaudePluginRootsCache(): void {
// getPreloadedPluginRoots(). Safe degradation: empty array if not warmed.
let preloadedPluginRoots: ClaudePluginRoot[] = [];
let injectedPluginDirRoots: ClaudePluginRoot[] = [];
/**
* Populate the module-level plugin roots cache for sync consumers.
@@ -815,6 +824,8 @@ export async function injectPluginDirRoots(home: string, dirs: string[]): Promis
// --plugin-dir roots have highest precedence: prepend them,
// removing any existing entries with the same plugin ID.
injectedPluginDirRoots = injected;
const injectedIds = new Set(injected.map(r => r.id));
const filtered = roots.filter(r => !injectedIds.has(r.id));
const merged = [...injected, ...filtered];
@@ -50,7 +50,13 @@ const GITHUB_SHORTHAND_RE = /^[a-z0-9-]+\/[a-z0-9._-]+$/i;
export function classifySource(source: string): MarketplaceSourceType {
// Rule 1: HTTP(S) URLs — .json suffix → url, everything else → git
if (source.startsWith("https://") || source.startsWith("http://")) {
return source.endsWith(".json") ? "url" : "git";
try {
const { pathname } = new URL(source);
return pathname.endsWith(".json") ? "url" : "git";
} catch {
// Malformed URL — treat as git
return "git";
}
}
// Rule 2: SCP-style SSH git URLs
@@ -200,17 +200,8 @@ export class MarketplaceManager {
// 3. Check if already installed
const instReg = await readInstalledPluginsRegistry(this.#opts.installedRegistryPath);
const existing = getInstalledPlugin(instReg, pluginId);
if (existing && existing.length > 0) {
if (!force) {
throw new Error(`Plugin "${pluginId}" is already installed. Use force option to reinstall.`);
}
// Remove all cached entries for this plugin (there may be multiple versions)
for (const entry of existing) {
await fs.rm(entry.installPath, { recursive: true, force: true });
}
const prunedReg = removeInstalledPlugin(instReg, pluginId);
await writeInstalledPluginsRegistry(this.#opts.installedRegistryPath, prunedReg);
this.#opts.clearPluginRootsCache?.();
if (existing && existing.length > 0 && !force) {
throw new Error(`Plugin "${pluginId}" is already installed. Use force option to reinstall.`);
}
// 4. Resolve source path.
@@ -245,6 +236,21 @@ export class MarketplaceManager {
}
}
// Only now clean up old entries — new cache succeeded, so it is safe to remove old ones.
if (existing && existing.length > 0) {
for (const entry of existing) {
// Skip if the new cache resolved to the same path (same version reinstall).
if (entry.installPath !== cachePath) {
await fs.rm(entry.installPath, { recursive: true, force: true });
}
}
const prunedReg = removeInstalledPlugin(
await readInstalledPluginsRegistry(this.#opts.installedRegistryPath),
pluginId,
);
await writeInstalledPluginsRegistry(this.#opts.installedRegistryPath, prunedReg);
}
// 6. Build and register the entry
const now = new Date().toISOString();
const installedEntry: InstalledPluginEntry = {