mirror of https://github.com/openclaw/openclaw.git
Plugins: add install --force overwrite flag (#60544)
Merged via squash.
Prepared head SHA: 28ae50b615
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
parent
3fd29e549d
commit
9004ef65df
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -308,7 +308,7 @@ Manage extensions and their config:
|
|||
|
||||
- `openclaw plugins list` — discover plugins (use `--json` for machine output).
|
||||
- `openclaw plugins inspect <id>` — show details for a plugin (`info` is an alias).
|
||||
- `openclaw plugins install <path|.tgz|npm-spec|plugin@marketplace>` — install a plugin (or add a plugin path to `plugins.load.paths`).
|
||||
- `openclaw plugins install <path|.tgz|npm-spec|plugin@marketplace>` — install a plugin (or add a plugin path to `plugins.load.paths`; use `--force` to overwrite an existing install target).
|
||||
- `openclaw plugins marketplace list <marketplace>` — list marketplace entries before install.
|
||||
- `openclaw plugins enable <id>` / `disable <id>` — toggle `plugins.entries.<id>.enabled`.
|
||||
- `openclaw plugins doctor` — report plugin load errors.
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ capabilities.
|
|||
```bash
|
||||
openclaw plugins install <package> # ClawHub first, then npm
|
||||
openclaw plugins install clawhub:<package> # ClawHub only
|
||||
openclaw plugins install <package> --force # overwrite existing install
|
||||
openclaw plugins install <package> --pin # pin version
|
||||
openclaw plugins install <package> --dangerously-force-unsafe-install
|
||||
openclaw plugins install <path> # local path
|
||||
|
|
@ -58,6 +59,10 @@ openclaw plugins install <plugin> --marketplace <name> # 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.
|
||||
|
||||
|
|
|
|||
|
|
@ -209,6 +209,7 @@ openclaw plugins doctor # diagnostics
|
|||
|
||||
openclaw plugins install <package> # install (ClawHub first, then npm)
|
||||
openclaw plugins install clawhub:<pkg> # install from ClawHub only
|
||||
openclaw plugins install <spec> --force # overwrite existing install
|
||||
openclaw plugins install <path> # install from local path
|
||||
openclaw plugins install -l <path> # link (no copy) for dev
|
||||
openclaw plugins install <spec> --dangerously-force-unsafe-install
|
||||
|
|
@ -220,6 +221,10 @@ openclaw plugins enable <id>
|
|||
openclaw plugins disable <id>
|
||||
```
|
||||
|
||||
`--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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 <name>@<version>", 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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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<InstallPluginResult> {
|
||||
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<ReturnType<typeof loadPluginInstallRuntime>>;
|
||||
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<ReturnType<typeof loadPluginInstallRuntime>>;
|
||||
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,
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue