From 9004ef65df618a7477ade7f032ff2fe095ed8177 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Fri, 3 Apr 2026 18:09:14 -0400 Subject: [PATCH] Plugins: add install --force overwrite flag (#60544) Merged via squash. Prepared head SHA: 28ae50b615e96706766459698689a0d247ec13a2 Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Reviewed-by: @gumadeiras --- CHANGELOG.md | 1 + docs/cli/index.md | 2 +- docs/cli/plugins.md | 8 ++ docs/tools/plugin.md | 5 + src/cli/plugins-cli.install.test.ts | 165 ++++++++++++++++++++++++++++ src/cli/plugins-cli.ts | 2 + src/cli/plugins-install-command.ts | 22 ++++ src/plugins/install.test.ts | 71 ++++++++++++ src/plugins/install.ts | 103 +++++++++++++---- 9 files changed, 359 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73a6310d46f..9b32e0f5cf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai - Tests/runtime: trim local unit-test import/runtime fan-out across browser, WhatsApp, cron, task, and reply flows so owner suites start faster with lower shared-worker overhead while preserving the same focused behavior coverage. (#60249) Thanks @shakkernerd. - Tests/secrets runtime: restore split secrets suite cache and env isolation cleanup so broader runs do not leak stale plugin or provider snapshot state. (#60395) Thanks @shakkernerd. - Providers/Ollama: add bundled Ollama Web Search provider for key-free web_search via your configured Ollama host and `ollama signin`. (#59318) Thanks @BruceMacD. +- Plugins/install: add `openclaw plugins install --force` to overwrite existing plugin and hook-pack install targets without using the dangerous-code override flag. (#60544) Thanks @gumadeiras. ### Fixes diff --git a/docs/cli/index.md b/docs/cli/index.md index 64eedf9ea33..a5526d45bda 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -308,7 +308,7 @@ Manage extensions and their config: - `openclaw plugins list` — discover plugins (use `--json` for machine output). - `openclaw plugins inspect ` — show details for a plugin (`info` is an alias). -- `openclaw plugins install ` — install a plugin (or add a plugin path to `plugins.load.paths`). +- `openclaw plugins install ` — install a plugin (or add a plugin path to `plugins.load.paths`; use `--force` to overwrite an existing install target). - `openclaw plugins marketplace list ` — list marketplace entries before install. - `openclaw plugins enable ` / `disable ` — toggle `plugins.entries..enabled`. - `openclaw plugins doctor` — report plugin load errors. diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index 9765a8e1353..0583de90b5b 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -48,6 +48,7 @@ capabilities. ```bash openclaw plugins install # ClawHub first, then npm openclaw plugins install clawhub: # ClawHub only +openclaw plugins install --force # overwrite existing install openclaw plugins install --pin # pin version openclaw plugins install --dangerously-force-unsafe-install openclaw plugins install # local path @@ -58,6 +59,10 @@ openclaw plugins install --marketplace # marketplace (explicit) Bare package names are checked against ClawHub first, then npm. Security note: treat plugin installs like running code. Prefer pinned versions. +`--force` reuses the existing install target and overwrites an already-installed +plugin or hook pack in place. Use it when you are intentionally reinstalling +the same id from a new local path, archive, ClawHub package, or npm artifact. + `--dangerously-force-unsafe-install` is a break-glass option for false positives in the built-in dangerous-code scanner. It allows the install to continue even when the built-in scanner reports `critical` findings, but it does **not** @@ -157,6 +162,9 @@ Use `--link` to avoid copying a local directory (adds to `plugins.load.paths`): openclaw plugins install -l ./my-plugin ``` +`--force` is not supported with `--link` because linked installs reuse the +source path instead of copying over a managed install target. + Use `--pin` on npm installs to save the resolved exact spec (`name@version`) in `plugins.installs` while keeping the default behavior unpinned. diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 762917756ed..a4ecc95e237 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -209,6 +209,7 @@ openclaw plugins doctor # diagnostics openclaw plugins install # install (ClawHub first, then npm) openclaw plugins install clawhub: # install from ClawHub only +openclaw plugins install --force # overwrite existing install openclaw plugins install # install from local path openclaw plugins install -l # link (no copy) for dev openclaw plugins install --dangerously-force-unsafe-install @@ -220,6 +221,10 @@ openclaw plugins enable openclaw plugins disable ``` +`--force` overwrites an existing installed plugin or hook pack in place. +It is not supported with `--link`, which reuses the source path instead of +copying over a managed install target. + `--dangerously-force-unsafe-install` is a break-glass override for false positives from the built-in dangerous-code scanner. It allows plugin installs and plugin updates to continue past built-in `critical` findings, but it still diff --git a/src/cli/plugins-cli.install.test.ts b/src/cli/plugins-cli.install.test.ts index 8de96a91d3a..108d285b525 100644 --- a/src/cli/plugins-cli.install.test.ts +++ b/src/cli/plugins-cli.install.test.ts @@ -86,6 +86,21 @@ describe("plugins cli install", () => { resetPluginsCliTestState(); }); + it("shows the force overwrite option in install help", async () => { + const { Command } = await import("commander"); + const { registerPluginsCli } = await import("./plugins-cli.js"); + const program = new Command(); + registerPluginsCli(program); + + const pluginsCommand = program.commands.find((command) => command.name() === "plugins"); + const installCommand = pluginsCommand?.commands.find((command) => command.name() === "install"); + const helpText = installCommand?.helpInformation() ?? ""; + + expect(helpText).toContain("--force"); + expect(helpText).toContain("Overwrite an existing installed plugin or"); + expect(helpText).toContain("hook pack"); + }); + it("exits when --marketplace is combined with --link", async () => { await expect( runPluginsCommand(["plugins", "install", "alpha", "--marketplace", "local/repo", "--link"]), @@ -95,6 +110,16 @@ describe("plugins cli install", () => { expect(installPluginFromMarketplace).not.toHaveBeenCalled(); }); + it("exits when --force is combined with --link", async () => { + await expect( + runPluginsCommand(["plugins", "install", "./plugin", "--link", "--force"]), + ).rejects.toThrow("__exit__:1"); + + expect(runtimeErrors.at(-1)).toContain("`--force` is not supported with `--link`."); + expect(installPluginFromMarketplace).not.toHaveBeenCalled(); + expect(installPluginFromNpmSpec).not.toHaveBeenCalled(); + }); + it("exits when marketplace install fails", async () => { await expect( runPluginsCommand(["plugins", "install", "alpha", "--marketplace", "local/repo"]), @@ -197,6 +222,20 @@ describe("plugins cli install", () => { expect(runtimeLogs.some((line) => line.includes("Installed plugin: alpha"))).toBe(true); }); + it("passes force through as overwrite mode for marketplace installs", async () => { + await expect( + runPluginsCommand(["plugins", "install", "alpha", "--marketplace", "local/repo", "--force"]), + ).rejects.toThrow("__exit__:1"); + + expect(installPluginFromMarketplace).toHaveBeenCalledWith( + expect.objectContaining({ + marketplace: "local/repo", + plugin: "alpha", + mode: "update", + }), + ); + }); + it("installs ClawHub plugins and persists source metadata", async () => { const cfg = { plugins: { @@ -256,6 +295,41 @@ describe("plugins cli install", () => { expect(installPluginFromNpmSpec).not.toHaveBeenCalled(); }); + it("passes force through as overwrite mode for ClawHub installs", async () => { + const cfg = { + plugins: { + entries: {}, + }, + } as OpenClawConfig; + const enabledCfg = createEnabledPluginConfig("demo"); + + loadConfig.mockReturnValue(cfg); + parseClawHubPluginSpec.mockReturnValue({ name: "demo" }); + installPluginFromClawHub.mockResolvedValue( + createClawHubInstallResult({ + pluginId: "demo", + packageName: "demo", + version: "1.2.3", + channel: "official", + }), + ); + enablePluginInConfig.mockReturnValue({ config: enabledCfg }); + recordPluginInstall.mockReturnValue(enabledCfg); + applyExclusiveSlotSelection.mockReturnValue({ + config: enabledCfg, + warnings: [], + }); + + await runPluginsCommand(["plugins", "install", "clawhub:demo", "--force"]); + + expect(installPluginFromClawHub).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "clawhub:demo", + mode: "update", + }), + ); + }); + it("prefers ClawHub before npm for bare plugin specs", async () => { const cfg = { plugins: { @@ -417,6 +491,48 @@ describe("plugins cli install", () => { ); }); + it("passes force through as overwrite mode for npm installs", async () => { + const cfg = { + plugins: { + entries: {}, + }, + } as OpenClawConfig; + const enabledCfg = createEnabledPluginConfig("demo"); + + loadConfig.mockReturnValue(cfg); + installPluginFromClawHub.mockResolvedValue({ + ok: false, + error: "ClawHub /api/v1/packages/demo failed (404): Package not found", + code: "package_not_found", + }); + installPluginFromNpmSpec.mockResolvedValue({ + ok: true, + pluginId: "demo", + targetDir: cliInstallPath("demo"), + version: "1.2.3", + npmResolution: { + packageName: "demo", + resolvedVersion: "1.2.3", + tarballUrl: "https://registry.npmjs.org/demo/-/demo-1.2.3.tgz", + }, + }); + enablePluginInConfig.mockReturnValue({ config: enabledCfg }); + recordPluginInstall.mockReturnValue(enabledCfg); + applyExclusiveSlotSelection.mockReturnValue({ + config: enabledCfg, + warnings: [], + }); + + await runPluginsCommand(["plugins", "install", "demo", "--force"]); + + expect(installPluginFromNpmSpec).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "demo", + mode: "update", + }), + ); + }); + it("does not fall back to npm when ClawHub rejects a real package", async () => { installPluginFromClawHub.mockResolvedValue({ ok: false, @@ -486,4 +602,53 @@ describe("plugins cli install", () => { expect(writeConfigFile).toHaveBeenCalledWith(installedCfg); expect(runtimeLogs.some((line) => line.includes("Installed hook pack: demo-hooks"))).toBe(true); }); + + it("passes force through as overwrite mode for hook-pack npm fallback installs", async () => { + const cfg = {} as OpenClawConfig; + const installedCfg = { + hooks: { + internal: { + installs: { + "demo-hooks": { + source: "npm", + spec: "@acme/demo-hooks@1.2.3", + }, + }, + }, + }, + } as OpenClawConfig; + + loadConfig.mockReturnValue(cfg); + installPluginFromClawHub.mockResolvedValue({ + ok: false, + error: "ClawHub /api/v1/packages/@acme/demo-hooks failed (404): Package not found", + code: "package_not_found", + }); + installPluginFromNpmSpec.mockResolvedValue({ + ok: false, + error: "package.json missing openclaw.plugin.json", + }); + installHooksFromNpmSpec.mockResolvedValue({ + ok: true, + hookPackId: "demo-hooks", + hooks: ["command-audit"], + targetDir: "/tmp/hooks/demo-hooks", + version: "1.2.3", + npmResolution: { + name: "@acme/demo-hooks", + spec: "@acme/demo-hooks@1.2.3", + integrity: "sha256-demo", + }, + }); + recordHookInstall.mockReturnValue(installedCfg); + + await runPluginsCommand(["plugins", "install", "@acme/demo-hooks", "--force"]); + + expect(installHooksFromNpmSpec).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "@acme/demo-hooks", + mode: "update", + }), + ); + }); }); diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index 65c2fd7b087..48ef9724d9d 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -770,6 +770,7 @@ export function registerPluginsCli(program: Command) { "Path (.ts/.js/.zip/.tgz/.tar.gz), npm package spec, or marketplace plugin name", ) .option("-l, --link", "Link a local path instead of copying", false) + .option("--force", "Overwrite an existing installed plugin or hook pack", false) .option("--pin", "Record npm installs as exact resolved @", false) .option( "--dangerously-force-unsafe-install", @@ -785,6 +786,7 @@ export function registerPluginsCli(program: Command) { raw: string, opts: { dangerouslyForceUnsafeInstall?: boolean; + force?: boolean; link?: boolean; pin?: boolean; marketplace?: string; diff --git a/src/cli/plugins-install-command.ts b/src/cli/plugins-install-command.ts index d9c97e9bef4..ae252e239d5 100644 --- a/src/cli/plugins-install-command.ts +++ b/src/cli/plugins-install-command.ts @@ -38,6 +38,10 @@ import { } from "./plugins-command-helpers.js"; import { persistHookPackInstall, persistPluginInstall } from "./plugins-install-persist.js"; +function resolveInstallMode(force?: boolean): "install" | "update" { + return force ? "update" : "install"; +} + async function installBundledPluginSource(params: { config: OpenClawConfig; rawSpec: string; @@ -71,6 +75,7 @@ async function installBundledPluginSource(params: { async function tryInstallHookPackFromLocalPath(params: { config: OpenClawConfig; resolvedPath: string; + installMode: "install" | "update"; link?: boolean; }): Promise<{ ok: true } | { ok: false; error: string }> { if (params.link) { @@ -122,6 +127,7 @@ async function tryInstallHookPackFromLocalPath(params: { const result = await installHooksFromPath({ path: params.resolvedPath, + mode: params.installMode, logger: createHookPackInstallLogger(), }); if (!result.ok) { @@ -145,11 +151,13 @@ async function tryInstallHookPackFromLocalPath(params: { async function tryInstallHookPackFromNpmSpec(params: { config: OpenClawConfig; + installMode: "install" | "update"; spec: string; pin?: boolean; }): Promise<{ ok: true } | { ok: false; error: string }> { const result = await installHooksFromNpmSpec({ spec: params.spec, + mode: params.installMode, logger: createHookPackInstallLogger(), }); if (!result.ok) { @@ -245,6 +253,7 @@ export async function loadConfigForInstall( export async function runPluginInstallCommand(params: { raw: string; opts: InstallSafetyOverrides & { + force?: boolean; link?: boolean; pin?: boolean; marketplace?: string; @@ -274,6 +283,10 @@ export async function runPluginInstallCommand(params: { return defaultRuntime.exit(1); } } + if (opts.link && opts.force) { + defaultRuntime.error("`--force` is not supported with `--link`."); + return defaultRuntime.exit(1); + } const requestResolution = resolvePluginInstallRequestContext({ rawSpec: raw, marketplace: opts.marketplace, @@ -290,11 +303,13 @@ export async function runPluginInstallCommand(params: { if (!cfg) { return defaultRuntime.exit(1); } + const installMode = resolveInstallMode(opts.force); if (opts.marketplace) { const result = await installPluginFromMarketplace({ dangerouslyForceUnsafeInstall: opts.dangerouslyForceUnsafeInstall, marketplace: opts.marketplace, + mode: installMode, plugin: raw, logger: createPluginInstallLogger(), }); @@ -329,6 +344,7 @@ export async function runPluginInstallCommand(params: { if (!probe.ok) { const hookFallback = await tryInstallHookPackFromLocalPath({ config: cfg, + installMode, resolvedPath: resolved, link: true, }); @@ -366,12 +382,14 @@ export async function runPluginInstallCommand(params: { const result = await installPluginFromPath({ dangerouslyForceUnsafeInstall: opts.dangerouslyForceUnsafeInstall, + mode: installMode, path: resolved, logger: createPluginInstallLogger(), }); if (!result.ok) { const hookFallback = await tryInstallHookPackFromLocalPath({ config: cfg, + installMode, resolvedPath: resolved, }); if (hookFallback.ok) { @@ -437,6 +455,7 @@ export async function runPluginInstallCommand(params: { if (clawhubSpec) { const result = await installPluginFromClawHub({ dangerouslyForceUnsafeInstall: opts.dangerouslyForceUnsafeInstall, + mode: installMode, spec: raw, logger: createPluginInstallLogger(), }); @@ -472,6 +491,7 @@ export async function runPluginInstallCommand(params: { if (preferredClawHubSpec) { const clawhubResult = await installPluginFromClawHub({ dangerouslyForceUnsafeInstall: opts.dangerouslyForceUnsafeInstall, + mode: installMode, spec: preferredClawHubSpec, logger: createPluginInstallLogger(), }); @@ -506,6 +526,7 @@ export async function runPluginInstallCommand(params: { const result = await installPluginFromNpmSpec({ dangerouslyForceUnsafeInstall: opts.dangerouslyForceUnsafeInstall, + mode: installMode, spec: raw, logger: createPluginInstallLogger(), }); @@ -518,6 +539,7 @@ export async function runPluginInstallCommand(params: { if (!bundledFallbackPlan) { const hookFallback = await tryInstallHookPackFromNpmSpec({ config: cfg, + installMode, spec: raw, pin: opts.pin, }); diff --git a/src/plugins/install.test.ts b/src/plugins/install.test.ts index 0078f9e7857..1b9df3fce79 100644 --- a/src/plugins/install.test.ts +++ b/src/plugins/install.test.ts @@ -207,12 +207,14 @@ async function installFromDirWithWarnings(params: { pluginDir: string; extensionsDir: string; dangerouslyForceUnsafeInstall?: boolean; + mode?: "install" | "update"; }) { const warnings: string[] = []; const result = await installPluginFromDir({ dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, dirPath: params.pluginDir, extensionsDir: params.extensionsDir, + mode: params.mode, logger: { info: () => {}, warn: (msg: string) => warnings.push(msg), @@ -1005,6 +1007,75 @@ describe("installPluginFromArchive", () => { ).toBe(true); }); + it("reports install mode to before_install when force-style update runs against a missing target", async () => { + const handler = vi.fn().mockReturnValue({}); + initializeGlobalHookRunner(createMockPluginRegistry([{ hookName: "before_install", handler }])); + + const { pluginDir, extensionsDir } = setupPluginInstallDirs(); + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: "fresh-force-plugin", + version: "1.0.0", + openclaw: { extensions: ["index.js"] }, + }), + ); + fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n"); + + const { result } = await installFromDirWithWarnings({ + pluginDir, + extensionsDir, + mode: "update", + }); + + expect(result.ok).toBe(true); + expect(handler).toHaveBeenCalledTimes(1); + expect(handler.mock.calls[0]?.[0]).toMatchObject({ + request: { + kind: "plugin-dir", + mode: "install", + }, + }); + }); + + it("reports update mode to before_install when replacing an existing target", async () => { + const handler = vi.fn().mockReturnValue({}); + initializeGlobalHookRunner(createMockPluginRegistry([{ hookName: "before_install", handler }])); + + const { pluginDir, extensionsDir } = setupPluginInstallDirs(); + const existingTargetDir = resolvePluginInstallDir("replace-force-plugin", extensionsDir); + fs.mkdirSync(existingTargetDir, { recursive: true }); + fs.writeFileSync( + path.join(existingTargetDir, "package.json"), + JSON.stringify({ version: "0.9.0" }), + ); + + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: "replace-force-plugin", + version: "1.0.0", + openclaw: { extensions: ["index.js"] }, + }), + ); + fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n"); + + const { result } = await installFromDirWithWarnings({ + pluginDir, + extensionsDir, + mode: "update", + }); + + expect(result.ok).toBe(true); + expect(handler).toHaveBeenCalledTimes(1); + expect(handler.mock.calls[0]?.[0]).toMatchObject({ + request: { + kind: "plugin-dir", + mode: "update", + }, + }); + }); + it("scans extension entry files in hidden directories", async () => { const { pluginDir, extensionsDir } = setupPluginInstallDirs(); fs.mkdirSync(path.join(pluginDir, ".hidden"), { recursive: true }); diff --git a/src/plugins/install.ts b/src/plugins/install.ts index 4f50a10b513..67f9caf9a8f 100644 --- a/src/plugins/install.ts +++ b/src/plugins/install.ts @@ -283,6 +283,7 @@ async function installPluginDirectoryIntoExtensions(params: { manifestName?: string; version?: string; extensions: string[]; + targetDir?: string; extensionsDir?: string; logger: PluginInstallLogger; timeoutMs: number; @@ -295,20 +296,19 @@ async function installPluginDirectoryIntoExtensions(params: { nameEncoder?: (pluginId: string) => string; }): Promise { const runtime = await loadPluginInstallRuntime(); - const extensionsDir = params.extensionsDir - ? resolveUserPath(params.extensionsDir) - : path.join(CONFIG_DIR, "extensions"); - const targetDirResult = await runtime.resolveCanonicalInstallTarget({ - baseDir: extensionsDir, - id: params.pluginId, - invalidNameMessage: "invalid plugin name: path traversal detected", - boundaryLabel: "extensions directory", - nameEncoder: params.nameEncoder, - }); - if (!targetDirResult.ok) { - return { ok: false, error: targetDirResult.error }; + let targetDir = params.targetDir; + if (!targetDir) { + const targetDirResult = await resolvePluginInstallTarget({ + runtime, + pluginId: params.pluginId, + extensionsDir: params.extensionsDir, + nameEncoder: params.nameEncoder, + }); + if (!targetDirResult.ok) { + return { ok: false, error: targetDirResult.error }; + } + targetDir = targetDirResult.targetDir; } - const targetDir = targetDirResult.targetDir; const availability = await runtime.ensureInstallTargetAvailable({ mode: params.mode, targetDir, @@ -372,6 +372,35 @@ export function resolvePluginInstallDir(pluginId: string, extensionsDir?: string return targetDirResult.path; } +async function resolvePluginInstallTarget(params: { + runtime: Awaited>; + pluginId: string; + extensionsDir?: string; + nameEncoder?: (pluginId: string) => string; +}): Promise<{ ok: true; targetDir: string } | { ok: false; error: string }> { + const extensionsDir = params.extensionsDir + ? resolveUserPath(params.extensionsDir) + : path.join(CONFIG_DIR, "extensions"); + return await params.runtime.resolveCanonicalInstallTarget({ + baseDir: extensionsDir, + id: params.pluginId, + invalidNameMessage: "invalid plugin name: path traversal detected", + boundaryLabel: "extensions directory", + nameEncoder: params.nameEncoder, + }); +} + +async function resolveEffectiveInstallMode(params: { + runtime: Awaited>; + requestedMode: "install" | "update"; + targetPath: string; +}): Promise<"install" | "update"> { + if (params.requestedMode !== "update") { + return "install"; + } + return (await params.runtime.fileExists(params.targetPath)) ? "update" : "install"; +} + async function installBundleFromSourceDir( params: { sourceDir: string; @@ -409,6 +438,20 @@ async function installBundleFromSourceDir( }; } + const targetDirResult = await resolvePluginInstallTarget({ + runtime, + pluginId, + extensionsDir: params.extensionsDir, + }); + if (!targetDirResult.ok) { + return { ok: false, error: targetDirResult.error }; + } + const effectiveMode = await resolveEffectiveInstallMode({ + runtime, + requestedMode: mode, + targetPath: targetDirResult.targetDir, + }); + try { const scanResult = await runtime.scanBundleInstallSource({ dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, @@ -417,7 +460,7 @@ async function installBundleFromSourceDir( logger, requestKind: params.installPolicyRequest?.kind, requestedSpecifier: params.installPolicyRequest?.requestedSpecifier, - mode, + mode: effectiveMode, version: manifestRes.manifest.version, }); if (scanResult?.blocked) { @@ -437,10 +480,11 @@ async function installBundleFromSourceDir( manifestName: manifestRes.manifest.name, version: manifestRes.manifest.version, extensions: [], + targetDir: targetDirResult.targetDir, extensionsDir: params.extensionsDir, logger, timeoutMs, - mode, + mode: effectiveMode, dryRun, copyErrorPrefix: "failed to copy plugin bundle", hasDeps: false, @@ -588,6 +632,21 @@ async function installPluginFromPackageDir( code: PLUGIN_INSTALL_ERROR_CODE.INCOMPATIBLE_HOST_VERSION, }; } + + const targetDirResult = await resolvePluginInstallTarget({ + runtime, + pluginId, + extensionsDir: params.extensionsDir, + nameEncoder: encodePluginInstallDirName, + }); + if (!targetDirResult.ok) { + return { ok: false, error: targetDirResult.error }; + } + const effectiveMode = await resolveEffectiveInstallMode({ + runtime, + requestedMode: mode, + targetPath: targetDirResult.targetDir, + }); try { const scanResult = await runtime.scanPackageInstallSource({ dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, @@ -597,7 +656,7 @@ async function installPluginFromPackageDir( extensions, requestKind: params.installPolicyRequest?.kind, requestedSpecifier: params.installPolicyRequest?.requestedSpecifier, - mode, + mode: effectiveMode, packageName: pkgName || undefined, manifestId: manifestPluginId, version: typeof manifest.version === "string" ? manifest.version : undefined, @@ -620,10 +679,11 @@ async function installPluginFromPackageDir( manifestName: pkgName || undefined, version: typeof manifest.version === "string" ? manifest.version : undefined, extensions, + targetDir: targetDirResult.targetDir, extensionsDir: params.extensionsDir, logger, timeoutMs, - mode, + mode: effectiveMode, dryRun, copyErrorPrefix: "failed to copy plugin", hasDeps: Object.keys(deps).length > 0, @@ -747,9 +807,14 @@ export async function installPluginFromFile(params: { return { ok: false, error: pluginIdError }; } const targetFile = path.join(extensionsDir, `${safeFileName(pluginId)}${path.extname(filePath)}`); + const effectiveMode = await resolveEffectiveInstallMode({ + runtime, + requestedMode: mode, + targetPath: targetFile, + }); const availability = await runtime.ensureInstallTargetAvailable({ - mode, + mode: effectiveMode, targetDir: targetFile, alreadyExistsError: `plugin already exists: ${targetFile} (delete it first)`, }); @@ -766,7 +831,7 @@ export async function installPluginFromFile(params: { dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, filePath, logger, - mode, + mode: effectiveMode, pluginId, requestedSpecifier: installPolicyRequest.requestedSpecifier, });