diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 2e70c2bd95d..b59ab1e3f9f 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed marketplace plugin installs registering only in `installed_plugins.json` and never in the runtime plugin tree, leaving slash commands and extensions unavailable after `omp plugin install name@marketplace`. The runtime loader now also enumerates the project-scope plugins root (`/.omp/plugins`) so `--scope project` installs surface alongside user-scope installs, with project entries shadowing same-named user entries ([#3244](https://github.com/can1357/oh-my-pi/issues/3244)). + ## [16.1.14] - 2026-06-22 ### Added diff --git a/packages/coding-agent/src/discovery/omp-extension-roots.ts b/packages/coding-agent/src/discovery/omp-extension-roots.ts index a16bb8055d5..fb60f3c2fa6 100644 --- a/packages/coding-agent/src/discovery/omp-extension-roots.ts +++ b/packages/coding-agent/src/discovery/omp-extension-roots.ts @@ -180,9 +180,7 @@ export async function listOmpExtensionRoots(ctx: LoadContext): Promise { try { const plugins = await getEnabledPlugins(ctx.cwd, { home: ctx.home }); - // Installed plugins are always user-scope; project disablement is already - // honored by `getEnabledPlugins` via `loadProjectOverrides`. - return plugins.map(({ path: p }) => ({ path: p, level: "user" })); + return plugins.map(({ path: p, scope }) => ({ path: p, level: scope })); } catch (err) { logger.debug("listInstalledPluginRoots: enumeration failed", { error: String(err) }); return []; diff --git a/packages/coding-agent/src/extensibility/plugins/loader.ts b/packages/coding-agent/src/extensibility/plugins/loader.ts index 9b859008cc6..17855205c5b 100644 --- a/packages/coding-agent/src/extensibility/plugins/loader.ts +++ b/packages/coding-agent/src/extensibility/plugins/loader.ts @@ -6,12 +6,18 @@ */ import * as fs from "node:fs"; import * as path from "node:path"; -import { getPluginsLockfile, getPluginsNodeModules, getPluginsPackageJson, isEnoent } from "@oh-my-pi/pi-utils"; +import { getPluginsDir, getPluginsLockfile, isEnoent } from "@oh-my-pi/pi-utils"; import { getConfigDirPaths } from "../../config"; +import { resolveActiveProjectRegistryPath } from "../../discovery/helpers"; import { installLegacyPiSpecifierShim } from "./legacy-pi-compat"; import { normalizePluginRuntimeConfig } from "./runtime-config"; import type { InstalledPlugin, PluginManifest, PluginRuntimeConfig, ProjectPluginOverrides } from "./types"; +/** Installed plugin plus the root scope that supplied its runtime metadata. */ +export interface ScopedInstalledPlugin extends InstalledPlugin { + scope: "user" | "project"; +} + installLegacyPiSpecifierShim(); // ============================================================================= @@ -51,38 +57,38 @@ async function loadProjectOverrides(cwd: string): Promise/package.json#dependencies` (`bun install`-installed - * packages) and `/omp-plugins.lock.json#plugins` (so locally - * `plugin link`-symlinked extensions, which never get a dependency entry, - * are still discovered). The optional `home` parameter pins the plugins - * root for callers that need to enumerate plugins relative to a non-default - * home (tests with a tempdir, discovery loaders threaded with - * `LoadContext.home`). + * Per-root enumeration of plugins from `/node_modules`, + * `/package.json#dependencies`, and `/omp-plugins.lock.json#plugins`. + * Honors `projectOverrides.disabled` and `projectOverrides.features`. Returns an + * empty array when the root has no `node_modules` yet. */ -export async function getEnabledPlugins(cwd: string, opts: { home?: string } = {}): Promise { - const { home } = opts; - - const nodeModulesPath = getPluginsNodeModules(home); - if (!fs.existsSync(nodeModulesPath)) { - return []; - } +async function collectPluginsAtRoot( + root: string, + projectOverrides: ProjectPluginOverrides, + scope: ScopedInstalledPlugin["scope"], +): Promise { + const nodeModulesPath = path.join(root, "node_modules"); + if (!fs.existsSync(nodeModulesPath)) return []; let depsKeys: string[] = []; - const pkgJsonPath = getPluginsPackageJson(home); + const pkgJsonPath = path.join(root, "package.json"); try { const pkg: { dependencies?: Record } = await Bun.file(pkgJsonPath).json(); depsKeys = Object.keys(pkg.dependencies ?? {}); } catch (err) { - // Linked-only setups may have no `/package.json` yet — that's + // Linked-only setups may have no `/package.json` yet — that's // fine, the lockfile still records the link. if (!isEnoent(err)) throw err; } - const runtimeConfig = await loadRuntimeConfig(home); - const projectOverrides = await loadProjectOverrides(cwd); + const lockPath = path.join(root, "omp-plugins.lock.json"); + let runtimeConfig: PluginRuntimeConfig; + try { + runtimeConfig = normalizePluginRuntimeConfig(await Bun.file(lockPath).json()); + } catch (err) { + if (!isEnoent(err)) throw err; + runtimeConfig = normalizePluginRuntimeConfig({}); + } // Union: dependencies (npm/marketplace installs) ∪ runtime-config plugins // (links + already-recorded installs). Set preserves first-seen order, @@ -92,7 +98,7 @@ export async function getEnabledPlugins(cwd: string, opts: { home?: string } = { names.add(name); } - const plugins: InstalledPlugin[] = []; + const plugins: ScopedInstalledPlugin[] = []; for (const name of names) { const pluginPkgPath = path.join(nodeModulesPath, name, "package.json"); let pluginPkg: { version: string; omp?: PluginManifest; pi?: PluginManifest }; @@ -130,6 +136,7 @@ export async function getEnabledPlugins(cwd: string, opts: { home?: string } = { name, version: pluginPkg.version, path: path.join(nodeModulesPath, name), + scope, manifest, enabledFeatures, enabled: true, @@ -139,6 +146,47 @@ export async function getEnabledPlugins(cwd: string, opts: { home?: string } = { return plugins; } +/** + * Get list of enabled plugins with their resolved configurations. + * + * Enumerates two plugin roots in order: the user root + * (`getPluginsDir(home)`) and, when a project anchor (`.omp/` or `.git/`) + * exists at or above `cwd`, the project root + * (`/.omp/plugins`). Each root contributes the union of its + * `package.json#dependencies` and `omp-plugins.lock.json#plugins`. Project + * entries shadow user entries with the same package name, matching the + * shadow semantics of `MarketplaceManager.listInstalledPlugins`. + * + * The optional `home` parameter pins the user plugins root for callers that + * need to enumerate plugins relative to a non-default home (tests with a + * tempdir, discovery loaders threaded with `LoadContext.home`). + */ +export async function getEnabledPlugins(cwd: string, opts: { home?: string } = {}): Promise { + const { home } = opts; + const projectOverrides = await loadProjectOverrides(cwd); + + const userRoot = getPluginsDir(home); + const userPlugins = await collectPluginsAtRoot(userRoot, projectOverrides, "user"); + + let projectPlugins: ScopedInstalledPlugin[] = []; + const projectRegistryPath = await resolveActiveProjectRegistryPath(cwd); + if (projectRegistryPath) { + const projectRoot = path.dirname(projectRegistryPath); + if (projectRoot !== userRoot) { + projectPlugins = await collectPluginsAtRoot(projectRoot, projectOverrides, "project"); + } + } + + if (projectPlugins.length === 0) return userPlugins; + if (userPlugins.length === 0) return projectPlugins; + + // Project entries shadow user entries with the same package name. + const merged = new Map(); + for (const plugin of userPlugins) merged.set(plugin.name, plugin); + for (const plugin of projectPlugins) merged.set(plugin.name, plugin); + return Array.from(merged.values()); +} + // ============================================================================= // Path Resolution // ============================================================================= diff --git a/packages/coding-agent/src/extensibility/plugins/marketplace/manager.ts b/packages/coding-agent/src/extensibility/plugins/marketplace/manager.ts index 049583cbf37..98afc3a3fcc 100644 --- a/packages/coding-agent/src/extensibility/plugins/marketplace/manager.ts +++ b/packages/coding-agent/src/extensibility/plugins/marketplace/manager.ts @@ -11,6 +11,8 @@ import * as os from "node:os"; import * as path from "node:path"; import { isEnoent, logger, pathIsWithin } from "@oh-my-pi/pi-utils"; +import { normalizePluginRuntimeConfig } from "../runtime-config"; +import type { PluginRuntimeConfig } from "../types"; import { cachePlugin } from "./cache"; import { classifySource, fetchMarketplace, parseMarketplaceCatalog, promoteCloneToCache } from "./fetcher"; @@ -38,6 +40,16 @@ import type { } from "./types"; import { buildPluginId, parsePluginId } from "./types"; +const RUNTIME_PACKAGE_NAME_RE = /^(?:@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/; +const MAX_RUNTIME_PACKAGE_NAME_LENGTH = 214; + +function assertRuntimePackageName(name: string): string { + if (name.length > MAX_RUNTIME_PACKAGE_NAME_LENGTH || !RUNTIME_PACKAGE_NAME_RE.test(name)) { + throw new Error(`Invalid marketplace plugin package name: ${JSON.stringify(name)}`); + } + return name; +} + // ── Options ────────────────────────────────────────────────────────────────── export interface MarketplaceManagerOptions { @@ -298,6 +310,9 @@ export class MarketplaceManager { } } + const packageName = await this.#resolvePluginPackageName(cachePath, name); + const previousPackageNames = await this.#resolveInstalledPackageNames(existing ?? [], name); + // Only now clean up old entries — new cache succeeded, so it is safe to remove old ones. if (existing && existing.length > 0) { // Remove from scope-appropriate registry first, then cross-check refs before disk deletion. @@ -337,6 +352,13 @@ export class MarketplaceManager { const newInstReg = addInstalledPlugin(freshInstReg, pluginId, installedEntry); await writeInstalledPluginsRegistry(registryPath, newInstReg); + for (const previousPackageName of previousPackageNames) { + if (previousPackageName !== packageName) { + await this.#removeRuntimePlugin(scope, previousPackageName); + } + } + await this.#registerRuntimePlugin(scope, packageName, cachePath, version, wasDisabled ? false : undefined); + this.#clearCache(); logger.debug("Plugin installed", { pluginId, version, cachePath }); @@ -434,6 +456,7 @@ export class MarketplaceManager { const targetEntries = targetScope === "project" ? projectEntries! : userEntries!; const targetReg = targetScope === "project" ? projectReg : userReg; const registryPath = this.#registryPath(targetScope); + const packageNames = await this.#resolveInstalledPackageNames(targetEntries, parsed.name); const updatedReg = removeInstalledPlugin(targetReg, pluginId); await writeInstalledPluginsRegistry(registryPath, updatedReg); @@ -453,6 +476,10 @@ export class MarketplaceManager { } } + for (const packageName of packageNames) { + await this.#removeRuntimePlugin(targetScope, packageName); + } + this.#clearCache(); logger.debug("Plugin uninstalled", { pluginId, scope: targetScope }); @@ -539,6 +566,12 @@ export class MarketplaceManager { }; await writeInstalledPluginsRegistry(registryPath, updated); + const fallbackName = parsePluginId(pluginId)?.name ?? pluginId; + const packageNames = await this.#resolveInstalledPackageNames(entries, fallbackName); + for (const packageName of packageNames) { + await this.#setRuntimePluginEnabled(targetScope, packageName, enabled); + } + this.#clearCache(); logger.debug("Plugin enabled state changed", { pluginId, enabled, scope: targetScope }); @@ -701,6 +734,107 @@ export class MarketplaceManager { // ── Private helpers ─────────────────────────────────────────────────────── + #runtimeRoot(scope: "user" | "project"): string { + return path.dirname(this.#registryPath(scope)); + } + + #nodeModulesPath(scope: "user" | "project"): string { + return path.join(this.#runtimeRoot(scope), "node_modules"); + } + + #runtimeLockPath(scope: "user" | "project"): string { + return path.join(this.#runtimeRoot(scope), "omp-plugins.lock.json"); + } + + async #loadRuntimeConfig(scope: "user" | "project"): Promise { + try { + return normalizePluginRuntimeConfig(await Bun.file(this.#runtimeLockPath(scope)).json()); + } catch (err) { + if (isEnoent(err)) return normalizePluginRuntimeConfig({}); + logger.warn("Failed to load marketplace plugin runtime config", { + path: this.#runtimeLockPath(scope), + error: String(err), + }); + return normalizePluginRuntimeConfig({}); + } + } + + async #writeRuntimeConfig(scope: "user" | "project", config: PluginRuntimeConfig): Promise { + await Bun.write(this.#runtimeLockPath(scope), JSON.stringify(config, null, 2)); + } + + async #resolvePluginPackageName(installPath: string, fallbackName: string): Promise { + try { + const pkg: { name?: unknown } = await Bun.file(path.join(installPath, "package.json")).json(); + const name = typeof pkg.name === "string" && pkg.name.length > 0 ? pkg.name : fallbackName; + return assertRuntimePackageName(name); + } catch (err) { + if (isEnoent(err)) return assertRuntimePackageName(fallbackName); + throw err; + } + } + + #runtimePackagePath(scope: "user" | "project", packageName: string): string { + const nodeModules = path.resolve(this.#nodeModulesPath(scope)); + const linkPath = path.resolve(nodeModules, assertRuntimePackageName(packageName)); + const relative = path.relative(nodeModules, linkPath); + if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) { + throw new Error(`Marketplace plugin package path escapes node_modules: ${JSON.stringify(packageName)}`); + } + return linkPath; + } + + async #resolveInstalledPackageNames( + entries: readonly InstalledPluginEntry[], + fallbackName: string, + ): Promise> { + const packageNames = new Set(); + for (const entry of entries) { + packageNames.add(await this.#resolvePluginPackageName(entry.installPath, fallbackName)); + } + return packageNames; + } + + async #registerRuntimePlugin( + scope: "user" | "project", + packageName: string, + cachePath: string, + version: string, + enabled: boolean | undefined, + ): Promise { + const linkPath = this.#runtimePackagePath(scope, packageName); + await fs.mkdir(path.dirname(linkPath), { recursive: true }); + await fs.rm(linkPath, { recursive: true, force: true }); + await fs.symlink(cachePath, linkPath, process.platform === "win32" ? "junction" : "dir"); + + const config = await this.#loadRuntimeConfig(scope); + const previous = config.plugins[packageName]; + config.plugins[packageName] = { + version, + enabledFeatures: previous?.enabledFeatures ?? null, + enabled: enabled ?? previous?.enabled ?? true, + }; + await this.#writeRuntimeConfig(scope, config); + } + + async #removeRuntimePlugin(scope: "user" | "project", packageName: string): Promise { + await fs.rm(this.#runtimePackagePath(scope, packageName), { recursive: true, force: true }); + + const config = await this.#loadRuntimeConfig(scope); + delete config.plugins[packageName]; + delete config.settings[packageName]; + await this.#writeRuntimeConfig(scope, config); + } + + async #setRuntimePluginEnabled(scope: "user" | "project", packageName: string, enabled: boolean): Promise { + const config = await this.#loadRuntimeConfig(scope); + const previous = config.plugins[packageName]; + if (!previous) return; + + config.plugins[packageName] = { ...previous, enabled }; + await this.#writeRuntimeConfig(scope, config); + } + #registryPath(scope: "user" | "project"): string { if (scope === "project") { if (!this.#opts.projectInstalledRegistryPath) { diff --git a/packages/coding-agent/test/discovery/omp-plugins.test.ts b/packages/coding-agent/test/discovery/omp-plugins.test.ts index f2711d9cec4..9c6f7e6cc68 100644 --- a/packages/coding-agent/test/discovery/omp-plugins.test.ts +++ b/packages/coding-agent/test/discovery/omp-plugins.test.ts @@ -209,6 +209,27 @@ test("installed plugins under `/node_modules/` are surfaced (e.g. via ` expect(found).toBeDefined(); }); +test("project-scoped installed plugins surface project-level sub-discovery", async () => { + const pluginsDir = path.join(project, ".omp", "plugins"); + const installed = path.join(pluginsDir, "node_modules", "my-project-ext"); + fs.mkdirSync(installed, { recursive: true }); + fs.cpSync(ext, installed, { recursive: true }); + writeFile( + path.join(pluginsDir, "omp-plugins.lock.json"), + JSON.stringify({ + plugins: { "my-project-ext": { version: "1.0.0", enabled: true, enabledFeatures: null } }, + settings: {}, + }), + ); + + const skills = await loadFromPlugin<{ name: string; path: string; level: "user" | "project" }>( + skillCapability.id, + ctx(), + ); + const found = skills.find(s => s.name === "my-skill" && s.path.includes("my-project-ext")); + expect(found?.level).toBe("project"); +}); + test("disabled installed plugins do not contribute sub-discovery", async () => { const pluginsDir = path.join(home, ".omp", "plugins"); const installed = path.join(pluginsDir, "node_modules", "my-disabled-ext"); diff --git a/packages/coding-agent/test/marketplace/manager.test.ts b/packages/coding-agent/test/marketplace/manager.test.ts index 2d5c43e978d..cf87ffd205c 100644 --- a/packages/coding-agent/test/marketplace/manager.test.ts +++ b/packages/coding-agent/test/marketplace/manager.test.ts @@ -3,6 +3,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; +import { getEnabledPlugins } from "@oh-my-pi/pi-coding-agent/extensibility/plugins/loader"; import { MarketplaceManager, readInstalledPluginsRegistry, @@ -18,6 +19,7 @@ function buildMinimalFixture(): string { const pluginDir = path.join(root, "plugins", "hello-plugin"); fs.mkdirSync(path.join(pluginDir, ".claude-plugin"), { recursive: true }); fs.mkdirSync(path.join(root, ".claude-plugin"), { recursive: true }); + fs.mkdirSync(path.join(pluginDir, "extensions"), { recursive: true }); fs.writeFileSync( path.join(root, ".claude-plugin", "marketplace.json"), JSON.stringify({ @@ -39,6 +41,15 @@ function buildMinimalFixture(): string { path.join(pluginDir, ".claude-plugin", "plugin.json"), JSON.stringify({ name: "hello-plugin", version: "1.0.0" }), ); + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: "hello-plugin", + version: "1.0.0", + omp: { extensions: ["./extensions"] }, + }), + ); + fs.writeFileSync(path.join(pluginDir, "extensions", "index.ts"), "export default {};\n"); return root; } @@ -178,12 +189,118 @@ describe("MarketplaceManager", () => { expect(instEntry.scope).toBe("user"); expect(instEntry.version).toBe("1.0.0"); expect(fs.existsSync(instEntry.installPath)).toBe(true); + const linkPath = path.join(ctx.tmpDir, "node_modules", "hello-plugin"); + expect(fs.realpathSync(linkPath)).toBe(fs.realpathSync(instEntry.installPath)); + + const runtimeConfig = await Bun.file(path.join(ctx.tmpDir, "omp-plugins.lock.json")).json(); + expect(runtimeConfig.plugins["hello-plugin"]).toEqual({ + version: "1.0.0", + enabledFeatures: null, + enabled: true, + }); const installed = await ctx.manager.listInstalledPlugins(); expect(installed).toHaveLength(1); expect(installed[0].id).toBe("hello-plugin@test-marketplace"); }); + it("installPlugin rejects package names that escape node_modules", async () => { + const marketplaceDir = path.join(ctx.tmpDir, "bad-package-marketplace"); + const pluginDir = path.join(marketplaceDir, "plugins", "bad-package"); + fs.mkdirSync(path.join(marketplaceDir, ".claude-plugin"), { recursive: true }); + fs.mkdirSync(pluginDir, { recursive: true }); + await Bun.write( + path.join(marketplaceDir, ".claude-plugin", "marketplace.json"), + `${JSON.stringify( + { + name: "bad-package-marketplace", + owner: { name: "Test Author" }, + plugins: [{ name: "bad-package", source: "./plugins/bad-package", version: "1.0.0" }], + }, + null, + 2, + )}\n`, + ); + await Bun.write(path.join(pluginDir, "package.json"), `${JSON.stringify({ name: "../outside" })}\n`); + await Bun.write(path.join(ctx.tmpDir, "outside"), "sentinel\n"); + + await ctx.manager.addMarketplace(marketplaceDir); + await expect(ctx.manager.installPlugin("bad-package", "bad-package-marketplace")).rejects.toThrow( + /Invalid marketplace plugin package name/, + ); + + expect(await Bun.file(path.join(ctx.tmpDir, "outside")).text()).toBe("sentinel\n"); + expect(fs.existsSync(path.join(ctx.tmpDir, "node_modules"))).toBe(false); + const installed = await ctx.manager.listInstalledPlugins(); + expect(installed).toHaveLength(0); + }); + + it("installPlugin exposes marketplace package to the runtime loader", async () => { + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "omp-mgr-home-")); + try { + const pluginsDir = path.join(tmpHome, ".omp", "plugins"); + const manager = new MarketplaceManager({ + marketplacesRegistryPath: path.join(tmpHome, ".omp", "marketplaces.json"), + installedRegistryPath: path.join(pluginsDir, "installed_plugins.json"), + marketplacesCacheDir: path.join(pluginsDir, "cache", "marketplaces"), + pluginsCacheDir: path.join(pluginsDir, "cache", "plugins"), + }); + + await manager.addMarketplace(FIXTURE_DIR); + await manager.installPlugin("hello-plugin", "test-marketplace"); + + const enabled = await getEnabledPlugins(ctx.tmpDir, { home: tmpHome }); + expect(enabled.map(plugin => plugin.name)).toEqual(["hello-plugin"]); + expect(enabled[0].path).toBe(path.join(pluginsDir, "node_modules", "hello-plugin")); + } finally { + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }); + + it("installPlugin with scope:project exposes the marketplace package to the runtime loader", async () => { + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "omp-mgr-home-")); + const projectAnchor = fs.mkdtempSync(path.join(os.tmpdir(), "omp-mgr-project-")); + try { + const userPluginsDir = path.join(tmpHome, ".omp", "plugins"); + const projectPluginsDir = path.join(projectAnchor, ".omp", "plugins"); + fs.mkdirSync(projectPluginsDir, { recursive: true }); + const manager = new MarketplaceManager({ + marketplacesRegistryPath: path.join(tmpHome, ".omp", "marketplaces.json"), + installedRegistryPath: path.join(userPluginsDir, "installed_plugins.json"), + projectInstalledRegistryPath: path.join(projectPluginsDir, "installed_plugins.json"), + marketplacesCacheDir: path.join(userPluginsDir, "cache", "marketplaces"), + pluginsCacheDir: path.join(userPluginsDir, "cache", "plugins"), + }); + + await manager.addMarketplace(FIXTURE_DIR); + await manager.installPlugin("hello-plugin", "test-marketplace", { scope: "project" }); + + // Project-scope install must surface via the runtime loader when cwd is inside the anchor. + const enabled = await getEnabledPlugins(projectAnchor, { home: tmpHome }); + expect(enabled.map(plugin => plugin.name)).toEqual(["hello-plugin"]); + expect(enabled[0].path).toBe(path.join(projectPluginsDir, "node_modules", "hello-plugin")); + + // The runtime symlink and lockfile must live under the project plugins root. + const projectLink = path.join(projectPluginsDir, "node_modules", "hello-plugin"); + expect(fs.realpathSync(projectLink)).toBe( + fs.realpathSync(path.join(userPluginsDir, "cache", "plugins", "test-marketplace___hello-plugin___1.0.0")), + ); + const projectLock = await Bun.file(path.join(projectPluginsDir, "omp-plugins.lock.json")).json(); + expect(projectLock.plugins["hello-plugin"]).toEqual({ + version: "1.0.0", + enabledFeatures: null, + enabled: true, + }); + + // User-scope tree stays untouched. + expect(fs.existsSync(path.join(userPluginsDir, "node_modules", "hello-plugin"))).toBe(false); + expect(fs.existsSync(path.join(userPluginsDir, "omp-plugins.lock.json"))).toBe(false); + } finally { + fs.rmSync(tmpHome, { recursive: true, force: true }); + fs.rmSync(projectAnchor, { recursive: true, force: true }); + } + }); + it("installPlugin embeds config-only marketplace LSP metadata", async () => { const marketplaceDir = path.join(ctx.tmpDir, "config-only-marketplace"); const pluginDir = path.join(marketplaceDir, "plugins", "csharp-lsp"); @@ -254,6 +371,7 @@ describe("MarketplaceManager", () => { it("installPlugin with force:true → replaces existing", async () => { await ctx.manager.addMarketplace(FIXTURE_DIR); const first = await ctx.manager.installPlugin("hello-plugin", "test-marketplace"); + await ctx.manager.setPluginEnabled("hello-plugin@test-marketplace", false); const second = await ctx.manager.installPlugin("hello-plugin", "test-marketplace", { force: true, }); @@ -261,6 +379,9 @@ describe("MarketplaceManager", () => { expect(second.installPath).toBe(first.installPath); expect(fs.existsSync(second.installPath)).toBe(true); + const runtimeConfig = await Bun.file(path.join(ctx.tmpDir, "omp-plugins.lock.json")).json(); + expect(runtimeConfig.plugins["hello-plugin"].enabled).toBe(false); + const installed = await ctx.manager.listInstalledPlugins(); expect(installed).toHaveLength(1); }); @@ -287,6 +408,10 @@ describe("MarketplaceManager", () => { await ctx.manager.uninstallPlugin("hello-plugin@test-marketplace"); expect(fs.existsSync(instEntry.installPath)).toBe(false); + expect(fs.existsSync(path.join(ctx.tmpDir, "node_modules", "hello-plugin"))).toBe(false); + + const runtimeConfig = await Bun.file(path.join(ctx.tmpDir, "omp-plugins.lock.json")).json(); + expect(runtimeConfig.plugins["hello-plugin"]).toBeUndefined(); const installed = await ctx.manager.listInstalledPlugins(); expect(installed).toHaveLength(0); @@ -310,9 +435,13 @@ describe("MarketplaceManager", () => { const installed = await ctx.manager.listInstalledPlugins(); expect(installed[0].entries[0].enabled).toBe(false); + let runtimeConfig = await Bun.file(path.join(ctx.tmpDir, "omp-plugins.lock.json")).json(); + expect(runtimeConfig.plugins["hello-plugin"].enabled).toBe(false); await ctx.manager.setPluginEnabled("hello-plugin@test-marketplace", true); const updated = await ctx.manager.listInstalledPlugins(); + runtimeConfig = await Bun.file(path.join(ctx.tmpDir, "omp-plugins.lock.json")).json(); + expect(runtimeConfig.plugins["hello-plugin"].enabled).toBe(true); expect(updated[0].entries[0].enabled).toBe(true); });