mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-17 08:40:46 +00:00
fix: support npm-only plugin installs
This commit is contained in:
@@ -4,7 +4,7 @@ import type { Command } from "commander";
|
||||
import { findBundledPluginSource } from "../plugins/bundled-sources.js";
|
||||
import { loadPluginManifest } from "../plugins/manifest.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { resolveFileNpmSpecToLocalPath } from "./plugins-command-helpers.js";
|
||||
import { parseNpmPrefixSpec, resolveFileNpmSpecToLocalPath } from "./plugins-command-helpers.js";
|
||||
|
||||
export type PluginInstallInvalidConfigPolicy = "deny" | "allow-bundled-recovery";
|
||||
|
||||
@@ -73,7 +73,14 @@ function resolveBundledInstallRecoveryMetadata(
|
||||
return direct;
|
||||
}
|
||||
}
|
||||
for (const value of [request.rawSpec.trim(), request.normalizedSpec.trim()]) {
|
||||
const rawNpmPrefixSpec = parseNpmPrefixSpec(request.rawSpec);
|
||||
const normalizedNpmPrefixSpec = parseNpmPrefixSpec(request.normalizedSpec);
|
||||
for (const value of [
|
||||
request.rawSpec.trim(),
|
||||
request.normalizedSpec.trim(),
|
||||
rawNpmPrefixSpec ?? "",
|
||||
normalizedNpmPrefixSpec ?? "",
|
||||
]) {
|
||||
if (!value) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -549,6 +549,120 @@ describe("plugins cli install", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("installs directly from npm when npm: prefix is used", async () => {
|
||||
const cfg = createEmptyPluginConfig();
|
||||
const enabledCfg = createEnabledPluginConfig("demo");
|
||||
|
||||
loadConfig.mockReturnValue(cfg);
|
||||
installPluginFromNpmSpec.mockResolvedValue(createNpmPluginInstallResult("demo"));
|
||||
enablePluginInConfig.mockReturnValue({ config: enabledCfg });
|
||||
recordPluginInstall.mockReturnValue(enabledCfg);
|
||||
applyExclusiveSlotSelection.mockReturnValue({
|
||||
config: enabledCfg,
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
await runPluginsCommand(["plugins", "install", "npm:demo"]);
|
||||
|
||||
expect(installPluginFromNpmSpec).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
spec: "demo",
|
||||
mode: "install",
|
||||
}),
|
||||
);
|
||||
expect(installPluginFromClawHub).not.toHaveBeenCalled();
|
||||
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith({
|
||||
demo: expect.objectContaining({
|
||||
source: "npm",
|
||||
spec: "demo",
|
||||
installPath: cliInstallPath("demo"),
|
||||
}),
|
||||
});
|
||||
expect(writeConfigFile).toHaveBeenCalledWith(enabledCfg);
|
||||
});
|
||||
|
||||
it("passes npm: prefix installs through npm options without ClawHub lookup", async () => {
|
||||
const cfg = createEmptyPluginConfig();
|
||||
const enabledCfg = createEnabledPluginConfig("demo");
|
||||
|
||||
loadConfig.mockReturnValue(cfg);
|
||||
installPluginFromNpmSpec.mockResolvedValue(createNpmPluginInstallResult("demo"));
|
||||
enablePluginInConfig.mockReturnValue({ config: enabledCfg });
|
||||
recordPluginInstall.mockReturnValue(enabledCfg);
|
||||
|
||||
await runPluginsCommand([
|
||||
"plugins",
|
||||
"install",
|
||||
"npm:demo",
|
||||
"--force",
|
||||
"--dangerously-force-unsafe-install",
|
||||
]);
|
||||
|
||||
expect(installPluginFromNpmSpec).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
spec: "demo",
|
||||
mode: "update",
|
||||
dangerouslyForceUnsafeInstall: true,
|
||||
}),
|
||||
);
|
||||
expect(installPluginFromClawHub).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports npm install failures without trying ClawHub when npm: prefix is used", async () => {
|
||||
loadConfig.mockReturnValue({} as OpenClawConfig);
|
||||
installPluginFromNpmSpec.mockResolvedValue({
|
||||
ok: false,
|
||||
error: "npm install failed",
|
||||
});
|
||||
installHooksFromNpmSpec.mockResolvedValue({
|
||||
ok: false,
|
||||
error: "package.json missing openclaw.hooks",
|
||||
});
|
||||
|
||||
await expect(runPluginsCommand(["plugins", "install", "npm:demo"])).rejects.toThrow(
|
||||
"__exit__:1",
|
||||
);
|
||||
|
||||
expect(installPluginFromClawHub).not.toHaveBeenCalled();
|
||||
expect(runtimeErrors.at(-1)).toContain("npm install failed");
|
||||
});
|
||||
|
||||
it("does not resolve npm: prefixed bundled plugin ids through bundled installs", async () => {
|
||||
loadConfig.mockReturnValue({ plugins: { load: { paths: [] } } } as OpenClawConfig);
|
||||
installPluginFromNpmSpec.mockResolvedValue({
|
||||
ok: false,
|
||||
error: "Package not found on npm: memory-lancedb.",
|
||||
code: "npm_package_not_found",
|
||||
});
|
||||
installHooksFromNpmSpec.mockResolvedValue({
|
||||
ok: false,
|
||||
error: "package.json missing openclaw.hooks",
|
||||
});
|
||||
|
||||
await expect(runPluginsCommand(["plugins", "install", "npm:memory-lancedb"])).rejects.toThrow(
|
||||
"__exit__:1",
|
||||
);
|
||||
|
||||
expect(installPluginFromNpmSpec).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
spec: "memory-lancedb",
|
||||
}),
|
||||
);
|
||||
expect(installPluginFromClawHub).not.toHaveBeenCalled();
|
||||
expect(writeConfigFile).not.toHaveBeenCalled();
|
||||
expect(runtimeErrors.at(-1)).toContain("Package not found on npm: memory-lancedb.");
|
||||
});
|
||||
|
||||
it("rejects empty npm: prefix installs before resolver lookup", async () => {
|
||||
loadConfig.mockReturnValue({} as OpenClawConfig);
|
||||
|
||||
await expect(runPluginsCommand(["plugins", "install", "npm:"])).rejects.toThrow("__exit__:1");
|
||||
|
||||
expect(installPluginFromNpmSpec).not.toHaveBeenCalled();
|
||||
expect(installPluginFromClawHub).not.toHaveBeenCalled();
|
||||
expect(runtimeErrors.at(-1)).toContain("unsupported npm: spec: missing package");
|
||||
});
|
||||
|
||||
it("passes dangerous force unsafe install to marketplace installs", async () => {
|
||||
await expect(
|
||||
runPluginsCommand([
|
||||
|
||||
@@ -130,6 +130,14 @@ export function buildPreferredClawHubSpec(raw: string): string | null {
|
||||
return `clawhub:${parsed.name}${parsed.selector ? `@${parsed.selector}` : ""}`;
|
||||
}
|
||||
|
||||
export function parseNpmPrefixSpec(raw: string): string | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!normalizeLowercaseStringOrEmpty(trimmed).startsWith("npm:")) {
|
||||
return null;
|
||||
}
|
||||
return trimmed.slice("npm:".length).trim();
|
||||
}
|
||||
|
||||
export const PREFERRED_CLAWHUB_FALLBACK_DECISION = {
|
||||
FALLBACK_TO_NPM: "fallback_to_npm",
|
||||
STOP: "stop",
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
createPluginInstallLogger,
|
||||
decidePreferredClawHubFallback,
|
||||
formatPluginInstallWithHookFallbackError,
|
||||
parseNpmPrefixSpec,
|
||||
} from "./plugins-command-helpers.js";
|
||||
import { persistHookPackInstall, persistPluginInstall } from "./plugins-install-persist.js";
|
||||
import type { ConfigSnapshotForInstallPersist } from "./plugins-install-persist.js";
|
||||
@@ -263,6 +264,74 @@ async function tryInstallHookPackFromNpmSpec(params: {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async function tryInstallPluginOrHookPackFromNpmSpec(params: {
|
||||
snapshot: ConfigSnapshotForInstallPersist;
|
||||
installMode: "install" | "update";
|
||||
spec: string;
|
||||
pin?: boolean;
|
||||
safetyOverrides: InstallSafetyOverrides;
|
||||
allowBundledFallback: boolean;
|
||||
}): Promise<{ ok: true } | { ok: false }> {
|
||||
const result = await installPluginFromNpmSpec({
|
||||
...params.safetyOverrides,
|
||||
mode: params.installMode,
|
||||
spec: params.spec,
|
||||
logger: createPluginInstallLogger(),
|
||||
});
|
||||
if (!result.ok) {
|
||||
if (isTerminalPluginInstallSecurityFailure(result.code)) {
|
||||
defaultRuntime.error(result.error);
|
||||
return { ok: false };
|
||||
}
|
||||
if (params.allowBundledFallback) {
|
||||
const bundledFallbackPlan = resolveBundledInstallPlanForNpmFailure({
|
||||
rawSpec: params.spec,
|
||||
code: result.code,
|
||||
findBundledSource: (lookup) => findBundledPluginSource({ lookup }),
|
||||
});
|
||||
if (bundledFallbackPlan) {
|
||||
await installBundledPluginSource({
|
||||
snapshot: params.snapshot,
|
||||
rawSpec: params.spec,
|
||||
bundledSource: bundledFallbackPlan.bundledSource,
|
||||
warning: bundledFallbackPlan.warning,
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
const hookFallback = await tryInstallHookPackFromNpmSpec({
|
||||
snapshot: params.snapshot,
|
||||
installMode: params.installMode,
|
||||
spec: params.spec,
|
||||
pin: params.pin,
|
||||
});
|
||||
if (hookFallback.ok) {
|
||||
return { ok: true };
|
||||
}
|
||||
defaultRuntime.error(
|
||||
formatPluginInstallWithHookFallbackError(result.error, hookFallback.error),
|
||||
);
|
||||
return { ok: false };
|
||||
}
|
||||
|
||||
clearPluginManifestRegistryCache();
|
||||
const installRecord = resolvePinnedNpmInstallRecordForCli(
|
||||
params.spec,
|
||||
Boolean(params.pin),
|
||||
result.targetDir,
|
||||
result.version,
|
||||
result.npmResolution,
|
||||
defaultRuntime.log,
|
||||
theme.warn,
|
||||
);
|
||||
await persistPluginInstall({
|
||||
snapshot: params.snapshot,
|
||||
pluginId: result.pluginId,
|
||||
install: installRecord,
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function isTerminalPluginInstallSecurityFailure(code?: string): boolean {
|
||||
return (
|
||||
code === PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED ||
|
||||
@@ -534,6 +603,26 @@ export async function runPluginInstallCommand(params: {
|
||||
return defaultRuntime.exit(1);
|
||||
}
|
||||
|
||||
const npmPrefixSpec = parseNpmPrefixSpec(raw);
|
||||
if (npmPrefixSpec !== null) {
|
||||
if (!npmPrefixSpec) {
|
||||
defaultRuntime.error("unsupported npm: spec: missing package");
|
||||
return defaultRuntime.exit(1);
|
||||
}
|
||||
const npmPrefixResult = await tryInstallPluginOrHookPackFromNpmSpec({
|
||||
snapshot,
|
||||
installMode,
|
||||
spec: npmPrefixSpec,
|
||||
pin: opts.pin,
|
||||
safetyOverrides,
|
||||
allowBundledFallback: false,
|
||||
});
|
||||
if (!npmPrefixResult.ok) {
|
||||
return defaultRuntime.exit(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
looksLikeLocalInstallSpec(raw, [
|
||||
".ts",
|
||||
@@ -637,60 +726,15 @@ export async function runPluginInstallCommand(params: {
|
||||
}
|
||||
}
|
||||
|
||||
const result = await installPluginFromNpmSpec({
|
||||
...safetyOverrides,
|
||||
mode: installMode,
|
||||
spec: raw,
|
||||
logger: createPluginInstallLogger(),
|
||||
});
|
||||
if (!result.ok) {
|
||||
if (isTerminalPluginInstallSecurityFailure(result.code)) {
|
||||
defaultRuntime.error(result.error);
|
||||
return defaultRuntime.exit(1);
|
||||
}
|
||||
const bundledFallbackPlan = resolveBundledInstallPlanForNpmFailure({
|
||||
rawSpec: raw,
|
||||
code: result.code,
|
||||
findBundledSource: (lookup) => findBundledPluginSource({ lookup }),
|
||||
});
|
||||
if (!bundledFallbackPlan) {
|
||||
const hookFallback = await tryInstallHookPackFromNpmSpec({
|
||||
snapshot,
|
||||
installMode,
|
||||
spec: raw,
|
||||
pin: opts.pin,
|
||||
});
|
||||
if (hookFallback.ok) {
|
||||
return;
|
||||
}
|
||||
defaultRuntime.error(
|
||||
formatPluginInstallWithHookFallbackError(result.error, hookFallback.error),
|
||||
);
|
||||
return defaultRuntime.exit(1);
|
||||
}
|
||||
|
||||
await installBundledPluginSource({
|
||||
snapshot,
|
||||
rawSpec: raw,
|
||||
bundledSource: bundledFallbackPlan.bundledSource,
|
||||
warning: bundledFallbackPlan.warning,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
clearPluginManifestRegistryCache();
|
||||
const installRecord = resolvePinnedNpmInstallRecordForCli(
|
||||
raw,
|
||||
Boolean(opts.pin),
|
||||
result.targetDir,
|
||||
result.version,
|
||||
result.npmResolution,
|
||||
defaultRuntime.log,
|
||||
theme.warn,
|
||||
);
|
||||
await persistPluginInstall({
|
||||
const npmResult = await tryInstallPluginOrHookPackFromNpmSpec({
|
||||
snapshot,
|
||||
pluginId: result.pluginId,
|
||||
install: installRecord,
|
||||
installMode,
|
||||
spec: raw,
|
||||
pin: opts.pin,
|
||||
safetyOverrides,
|
||||
allowBundledFallback: true,
|
||||
});
|
||||
if (!npmResult.ok) {
|
||||
return defaultRuntime.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,6 +120,37 @@ describe("loadConfigForInstall", () => {
|
||||
expect(result).toEqual({ config: snapshotCfg, baseHash: "abc" });
|
||||
});
|
||||
|
||||
it("allows npm:-prefixed bundled-plugin reinstall recovery", async () => {
|
||||
const snapshotCfg = {
|
||||
plugins: { installs: { matrix: { source: "path", installPath: "/gone" } } },
|
||||
} as unknown as OpenClawConfig;
|
||||
readConfigFileSnapshotMock.mockResolvedValue(
|
||||
makeSnapshot({
|
||||
parsed: { plugins: { installs: { matrix: {} } } },
|
||||
config: snapshotCfg,
|
||||
issues: [
|
||||
{ path: "channels.matrix", message: "unknown channel id: matrix" },
|
||||
{ path: "plugins.load.paths", message: "plugin: plugin path not found: /gone" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const request = resolvePluginInstallRequestContext({
|
||||
rawSpec: "npm:@openclaw/matrix",
|
||||
});
|
||||
if (!request.ok) {
|
||||
throw new Error(request.error);
|
||||
}
|
||||
|
||||
expect(request.request).toMatchObject({
|
||||
bundledPluginId: "matrix",
|
||||
allowInvalidConfigRecovery: true,
|
||||
});
|
||||
const result = await loadConfigForInstall(request.request);
|
||||
expect(collectChannelDoctorStaleConfigMutationsMock).toHaveBeenCalledWith(snapshotCfg);
|
||||
expect(result).toEqual({ config: snapshotCfg, baseHash: "abc" });
|
||||
});
|
||||
|
||||
it("allows explicit repo-checkout bundled-plugin reinstall recovery", async () => {
|
||||
const snapshotCfg = { plugins: {} } as OpenClawConfig;
|
||||
readConfigFileSnapshotMock.mockResolvedValue(
|
||||
|
||||
Reference in New Issue
Block a user