diff --git a/src/cli.ts b/src/cli.ts index d4f59cb44..ac25bb18c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -470,8 +470,8 @@ if (isPrintMode) { if (extensionsResult.errors.length > 0) { for (const err of extensionsResult.errors) { // Downgrade conflicts with built-in tools to warnings (#1347) - const isSuperseded = err.error.includes("supersedes"); - const prefix = isSuperseded ? "Extension conflict" : "Extension load error"; + const isConflict = err.error.includes("supersedes") || err.error.includes("conflicts with"); + const prefix = isConflict ? "Extension conflict" : "Extension load error"; process.stderr.write(`[gsd] ${prefix}: ${err.error}\n`) } } @@ -622,8 +622,8 @@ validateConfiguredModel(modelRegistry, settingsManager) if (extensionsResult.errors.length > 0) { for (const err of extensionsResult.errors) { - const isSuperseded = err.error.includes("supersedes"); - const prefix = isSuperseded ? "Extension conflict" : "Extension load error"; + const isConflict = err.error.includes("supersedes") || err.error.includes("conflicts with"); + const prefix = isConflict ? "Extension conflict" : "Extension load error"; process.stderr.write(`[gsd] ${prefix}: ${err.error}\n`) } } diff --git a/src/resource-loader.ts b/src/resource-loader.ts index 041d53143..30ad9c21d 100644 --- a/src/resource-loader.ts +++ b/src/resource-loader.ts @@ -373,6 +373,16 @@ function pruneRemovedBundledExtensions( } } + // Sweep-based: also remove any installed extension subdirectory not in the current bundle, + // even if it was never tracked in the manifest (e.g. installed by a pre-manifest version). + try { + if (existsSync(extensionsDir)) { + for (const e of readdirSync(extensionsDir, { withFileTypes: true })) { + if (e.isDirectory()) removeDirIfStale(e.name) + } + } + } catch { /* non-fatal */ } + // Always remove known stale files regardless of manifest state. // These were installed by pre-manifest versions so they may not appear in // installedExtensionRootFiles even when a manifest exists. diff --git a/src/tests/resource-loader.test.ts b/src/tests/resource-loader.test.ts index 637b9088a..03bbd5db2 100644 --- a/src/tests/resource-loader.test.ts +++ b/src/tests/resource-loader.test.ts @@ -160,3 +160,49 @@ test("initResources prunes stale top-level extension siblings next to bundled co assert.equal(existsSync(staleSiblingPath), false, "stale top-level sibling should be removed during sync"); assert.equal(existsSync(bundledPath), true, "bundled extension should remain after cleanup"); }); + +test("pruneRemovedBundledExtensions removes stale subdirectory extensions not in current bundle", async () => { + const { initResources } = await import("../resource-loader.ts"); + const tmp = mkdtempSync(join(tmpdir(), "gsd-resource-loader-prune-dirs-")); + const fakeAgentDir = join(tmp, "agent"); + + try { + // First sync — seeds the agent dir and writes the manifest. + initResources(fakeAgentDir); + + // Simulate a stale subdirectory extension left from a previous GSD version. + // This mirrors the mcporter scenario: it was bundled before, synced to + // ~/.gsd/agent/extensions/, then removed from the bundle in a newer version. + const staleExtDir = join(fakeAgentDir, "extensions", "mcporter"); + mkdirSync(staleExtDir, { recursive: true }); + writeFileSync(join(staleExtDir, "index.ts"), 'export default { name: "mcporter" };\n'); + assert.equal(existsSync(staleExtDir), true, "stale subdir extension should exist before prune"); + + // Read the manifest to verify subdirectory extensions are tracked. + const manifestPath = join(fakeAgentDir, "managed-resources.json"); + const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")); + + // The manifest must record installed extension directories so the pruner + // can detect when one has been removed from the bundle. + assert.ok( + Array.isArray(manifest.installedExtensionDirs), + "manifest should contain installedExtensionDirs array", + ); + + // Bump the manifest version to force a re-sync (simulates upgrading GSD). + manifest.gsdVersion = "0.0.0-force-resync"; + manifest.contentHash = "0000000000000000"; + writeFileSync(manifestPath, JSON.stringify(manifest)); + + // Second sync — the bundle no longer contains mcporter/, so it must be pruned. + initResources(fakeAgentDir); + + assert.equal( + existsSync(staleExtDir), + false, + "stale subdirectory extension (mcporter/) should be pruned after upgrade", + ); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +});