mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:20:43 +00:00
feat(plugins): prefer clawhub in onboarding installs
This commit is contained in:
@@ -38,6 +38,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Plugins/ClawHub: prefer versioned ClawPack artifacts when ClawHub publishes digest metadata, verifying the ClawPack response header and downloaded bytes before installing. Thanks @vincentkoc.
|
||||
- Plugins/ClawHub: persist ClawPack digest metadata on ClawHub plugin install and update records so registry refreshes and download verification can reuse stored artifact facts. Thanks @vincentkoc.
|
||||
- Plugins/ClawHub: allow official bundled-plugin cutovers to prefer ClawHub installs with npm fallback only when the ClawHub package or version is absent. Thanks @vincentkoc.
|
||||
- Plugins/onboarding: allow install-on-demand provider setup entries to prefer ClawHub packages before npm/local fallback and persist ClawHub artifact metadata after install. Thanks @vincentkoc.
|
||||
- Plugins/Crestodian: add ClawHub plugin search plus Crestodian plugin list/search/install/uninstall operations, with approval and audit coverage for install and uninstall.
|
||||
- Channels/thread bindings: replace split subagent/ACP thread-spawn toggles with `threadBindings.spawnSessions`, default thread-bound spawns on, and let `openclaw doctor --fix` migrate the legacy keys. (#75943)
|
||||
- Providers/OpenAI: add `extraBody`/`extra_body` passthrough for OpenAI-compatible TTS endpoints, so custom speech servers can receive fields such as `lang` in `/audio/speech` requests. Fixes #39900. Thanks @R3NK0R.
|
||||
|
||||
@@ -1152,22 +1152,22 @@ Some pre-runtime plugin metadata intentionally lives in `package.json` under the
|
||||
|
||||
Important examples:
|
||||
|
||||
| Field | What it means |
|
||||
| ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `openclaw.extensions` | Declares native plugin entrypoints. Must stay inside the plugin package directory. |
|
||||
| `openclaw.runtimeExtensions` | Declares built JavaScript runtime entrypoints for installed packages. Must stay inside the plugin package directory. |
|
||||
| `openclaw.setupEntry` | Lightweight setup-only entrypoint used during onboarding, deferred channel startup, and read-only channel status/SecretRef discovery. Must stay inside the plugin package directory. |
|
||||
| `openclaw.runtimeSetupEntry` | Declares the built JavaScript setup entrypoint for installed packages. Requires `setupEntry`, must exist, and must stay inside the plugin package directory. |
|
||||
| `openclaw.channel` | Cheap channel catalog metadata like labels, docs paths, aliases, and selection copy. |
|
||||
| `openclaw.channel.commands` | Static native command and native skill auto-default metadata used by config, audit, and command-list surfaces before channel runtime loads. |
|
||||
| `openclaw.channel.configuredState` | Lightweight configured-state checker metadata that can answer "does env-only setup already exist?" without loading the full channel runtime. |
|
||||
| `openclaw.channel.persistedAuthState` | Lightweight persisted-auth checker metadata that can answer "is anything already signed in?" without loading the full channel runtime. |
|
||||
| `openclaw.install.npmSpec` / `openclaw.install.localPath` | Install/update hints for bundled and externally published plugins. |
|
||||
| `openclaw.install.defaultChoice` | Preferred install path when multiple install sources are available. |
|
||||
| `openclaw.install.minHostVersion` | Minimum supported OpenClaw host version, using a semver floor like `>=2026.3.22` or `>=2026.5.1-beta.1`. |
|
||||
| `openclaw.install.expectedIntegrity` | Expected npm dist integrity string such as `sha512-...`; install and update flows verify the fetched artifact against it. |
|
||||
| `openclaw.install.allowInvalidConfigRecovery` | Allows a narrow bundled-plugin reinstall recovery path when config is invalid. |
|
||||
| `openclaw.startup.deferConfiguredChannelFullLoadUntilAfterListen` | Lets setup-only channel surfaces load before the full channel plugin during startup. |
|
||||
| Field | What it means |
|
||||
| ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `openclaw.extensions` | Declares native plugin entrypoints. Must stay inside the plugin package directory. |
|
||||
| `openclaw.runtimeExtensions` | Declares built JavaScript runtime entrypoints for installed packages. Must stay inside the plugin package directory. |
|
||||
| `openclaw.setupEntry` | Lightweight setup-only entrypoint used during onboarding, deferred channel startup, and read-only channel status/SecretRef discovery. Must stay inside the plugin package directory. |
|
||||
| `openclaw.runtimeSetupEntry` | Declares the built JavaScript setup entrypoint for installed packages. Requires `setupEntry`, must exist, and must stay inside the plugin package directory. |
|
||||
| `openclaw.channel` | Cheap channel catalog metadata like labels, docs paths, aliases, and selection copy. |
|
||||
| `openclaw.channel.commands` | Static native command and native skill auto-default metadata used by config, audit, and command-list surfaces before channel runtime loads. |
|
||||
| `openclaw.channel.configuredState` | Lightweight configured-state checker metadata that can answer "does env-only setup already exist?" without loading the full channel runtime. |
|
||||
| `openclaw.channel.persistedAuthState` | Lightweight persisted-auth checker metadata that can answer "is anything already signed in?" without loading the full channel runtime. |
|
||||
| `openclaw.install.clawhubSpec` / `openclaw.install.npmSpec` / `openclaw.install.localPath` | Install/update hints for bundled and externally published plugins. |
|
||||
| `openclaw.install.defaultChoice` | Preferred install path when multiple install sources are available. |
|
||||
| `openclaw.install.minHostVersion` | Minimum supported OpenClaw host version, using a semver floor like `>=2026.3.22` or `>=2026.5.1-beta.1`. |
|
||||
| `openclaw.install.expectedIntegrity` | Expected npm dist integrity string such as `sha512-...`; install and update flows verify the fetched artifact against it. |
|
||||
| `openclaw.install.allowInvalidConfigRecovery` | Allows a narrow bundled-plugin reinstall recovery path when config is invalid. |
|
||||
| `openclaw.startup.deferConfiguredChannelFullLoadUntilAfterListen` | Lets setup-only channel surfaces load before the full channel plugin during startup. |
|
||||
|
||||
Manifest metadata decides which provider/channel/setup choices appear in
|
||||
onboarding before runtime loads. `package.json#openclaw.install` tells
|
||||
@@ -1179,6 +1179,11 @@ registry loading for non-bundled plugin sources. Invalid values are rejected;
|
||||
newer-but-valid values skip external plugins on older hosts. Bundled source
|
||||
plugins are assumed to be co-versioned with the host checkout.
|
||||
|
||||
Official install-on-demand metadata should use `clawhubSpec` when the plugin is
|
||||
published on ClawHub; onboarding treats that as the preferred remote source and
|
||||
records ClawHub artifact facts after install. `npmSpec` remains the compatibility
|
||||
fallback for packages that have not moved to ClawHub yet.
|
||||
|
||||
Exact npm version pinning already lives in `npmSpec`, for example
|
||||
`"npmSpec": "@wecom/wecom-openclaw-plugin@1.2.3"`. Official external catalog
|
||||
entries should pair exact specs with `expectedIntegrity` so update flows fail
|
||||
|
||||
@@ -154,18 +154,19 @@ Example:
|
||||
|
||||
`openclaw.install` is package metadata, not manifest metadata.
|
||||
|
||||
| Field | Type | What it means |
|
||||
| ---------------------------- | -------------------- | --------------------------------------------------------------------------------- |
|
||||
| `npmSpec` | `string` | Canonical npm spec for install/update flows. |
|
||||
| `localPath` | `string` | Local development or bundled install path. |
|
||||
| `defaultChoice` | `"npm"` \| `"local"` | Preferred install source when both are available. |
|
||||
| `minHostVersion` | `string` | Minimum supported OpenClaw version in the form `>=x.y.z` or `>=x.y.z-prerelease`. |
|
||||
| `expectedIntegrity` | `string` | Expected npm dist integrity string, usually `sha512-...`, for pinned installs. |
|
||||
| `allowInvalidConfigRecovery` | `boolean` | Lets bundled-plugin reinstall flows recover from specific stale-config failures. |
|
||||
| Field | Type | What it means |
|
||||
| ---------------------------- | ----------------------------------- | --------------------------------------------------------------------------------- |
|
||||
| `clawhubSpec` | `string` | Canonical ClawHub spec for install/update and onboarding install-on-demand flows. |
|
||||
| `npmSpec` | `string` | Canonical npm spec for install/update fallback flows. |
|
||||
| `localPath` | `string` | Local development or bundled install path. |
|
||||
| `defaultChoice` | `"clawhub"` \| `"npm"` \| `"local"` | Preferred install source when multiple sources are available. |
|
||||
| `minHostVersion` | `string` | Minimum supported OpenClaw version in the form `>=x.y.z` or `>=x.y.z-prerelease`. |
|
||||
| `expectedIntegrity` | `string` | Expected npm dist integrity string, usually `sha512-...`, for pinned installs. |
|
||||
| `allowInvalidConfigRecovery` | `boolean` | Lets bundled-plugin reinstall flows recover from specific stale-config failures. |
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Onboarding behavior">
|
||||
Interactive onboarding also uses `openclaw.install` for install-on-demand surfaces. If your plugin exposes provider auth choices or channel setup/catalog metadata before runtime loads, onboarding can show that choice, prompt for npm vs local install, install or enable the plugin, then continue the selected flow. Npm onboarding choices require trusted catalog metadata with a registry `npmSpec`; exact versions and `expectedIntegrity` are optional pins. If `expectedIntegrity` is present, install/update flows enforce it. Keep the "what to show" metadata in `openclaw.plugin.json` and the "how to install it" metadata in `package.json`.
|
||||
Interactive onboarding also uses `openclaw.install` for install-on-demand surfaces. If your plugin exposes provider auth choices or channel setup/catalog metadata before runtime loads, onboarding can show that choice, prompt for ClawHub, npm, or local install, install or enable the plugin, then continue the selected flow. ClawHub onboarding choices use `clawhubSpec` and are preferred when present; npm choices require trusted catalog metadata with a registry `npmSpec`; exact versions and `expectedIntegrity` are optional npm pins. If `expectedIntegrity` is present, install/update flows enforce it for npm. Keep the "what to show" metadata in `openclaw.plugin.json` and the "how to install it" metadata in `package.json`.
|
||||
</Accordion>
|
||||
<Accordion title="minHostVersion enforcement">
|
||||
If `minHostVersion` is set, install and non-bundled manifest-registry loading both enforce it. Older hosts skip external plugins; invalid version strings are rejected. Bundled source plugins are assumed to be co-versioned with the host checkout.
|
||||
|
||||
@@ -31,6 +31,11 @@ vi.mock("../plugins/install.js", () => ({
|
||||
installPluginFromNpmSpec,
|
||||
}));
|
||||
|
||||
const installPluginFromClawHub = vi.hoisted(() => vi.fn());
|
||||
vi.mock("../plugins/clawhub.js", () => ({
|
||||
installPluginFromClawHub,
|
||||
}));
|
||||
|
||||
const enablePluginInConfig = vi.hoisted(() =>
|
||||
vi.fn<(cfg: OpenClawConfig, pluginId: string) => PluginEnableResult>((cfg, pluginId) => ({
|
||||
config: cfg,
|
||||
@@ -74,6 +79,87 @@ describe("ensureOnboardingPluginInstalled", () => {
|
||||
refreshPluginRegistryAfterConfigMutation.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("installs and records ClawHub provider plugins with source facts", async () => {
|
||||
installPluginFromClawHub.mockImplementation(async (params) => {
|
||||
params.logger?.info?.("Downloading demo-plugin from ClawHub…");
|
||||
return {
|
||||
ok: true,
|
||||
pluginId: "demo-plugin",
|
||||
targetDir: "/tmp/demo-plugin",
|
||||
version: "2026.5.2",
|
||||
packageName: "demo-plugin",
|
||||
clawhub: {
|
||||
source: "clawhub",
|
||||
clawhubUrl: "https://clawhub.ai",
|
||||
clawhubPackage: "demo-plugin",
|
||||
clawhubFamily: "code-plugin",
|
||||
clawhubChannel: "official",
|
||||
version: "2026.5.2",
|
||||
integrity: "sha256-clawpack",
|
||||
resolvedAt: "2026-05-02T00:00:00.000Z",
|
||||
clawpackSha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
clawpackSpecVersion: 1,
|
||||
clawpackManifestSha256:
|
||||
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
clawpackSize: 4096,
|
||||
},
|
||||
};
|
||||
});
|
||||
const stop = vi.fn();
|
||||
const update = vi.fn();
|
||||
|
||||
const result = await ensureOnboardingPluginInstalled({
|
||||
cfg: {},
|
||||
entry: {
|
||||
pluginId: "demo-plugin",
|
||||
label: "Demo Provider",
|
||||
install: {
|
||||
clawhubSpec: "clawhub:demo-plugin@2026.5.2",
|
||||
npmSpec: "@openclaw/demo-plugin@2026.5.2",
|
||||
defaultChoice: "clawhub",
|
||||
},
|
||||
},
|
||||
prompter: {
|
||||
select: vi.fn(async () => "clawhub"),
|
||||
progress: vi.fn(() => ({ update, stop })),
|
||||
} as never,
|
||||
runtime: {} as never,
|
||||
});
|
||||
|
||||
expect(installPluginFromClawHub).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
spec: "clawhub:demo-plugin@2026.5.2",
|
||||
expectedPluginId: "demo-plugin",
|
||||
mode: "install",
|
||||
timeoutMs: 300_000,
|
||||
}),
|
||||
);
|
||||
expect(update).toHaveBeenCalledWith("Downloading");
|
||||
expect(stop).toHaveBeenCalledWith("Installed Demo Provider plugin");
|
||||
expect(recordPluginInstall).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
pluginId: "demo-plugin",
|
||||
source: "clawhub",
|
||||
spec: "clawhub:demo-plugin@2026.5.2",
|
||||
installPath: "/tmp/demo-plugin",
|
||||
version: "2026.5.2",
|
||||
integrity: "sha256-clawpack",
|
||||
clawhubPackage: "demo-plugin",
|
||||
clawpackSize: 4096,
|
||||
}),
|
||||
);
|
||||
expect(result.installed).toBe(true);
|
||||
expect(result.status).toBe("installed");
|
||||
expect(result.cfg.plugins?.installs).toEqual({
|
||||
"demo-plugin": expect.objectContaining({
|
||||
pluginId: "demo-plugin",
|
||||
source: "clawhub",
|
||||
spec: "clawhub:demo-plugin@2026.5.2",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("passes npm specs and optional expected integrity to npm installs with progress", async () => {
|
||||
const npmResolution = {
|
||||
name: "@wecom/wecom-openclaw-plugin",
|
||||
@@ -196,8 +282,12 @@ describe("ensureOnboardingPluginInstalled", () => {
|
||||
it("offers registry npm specs without requiring an exact version or integrity pin", async () => {
|
||||
let captured:
|
||||
| {
|
||||
options: Array<{ value: "npm" | "local" | "skip"; label: string; hint?: string }>;
|
||||
initialValue: "npm" | "local" | "skip";
|
||||
options: Array<{
|
||||
value: "clawhub" | "npm" | "local" | "skip";
|
||||
label: string;
|
||||
hint?: string;
|
||||
}>;
|
||||
initialValue: "clawhub" | "npm" | "local" | "skip";
|
||||
}
|
||||
| undefined;
|
||||
|
||||
@@ -227,6 +317,47 @@ describe("ensureOnboardingPluginInstalled", () => {
|
||||
expect(installPluginFromNpmSpec).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("offers ClawHub as the default remote source when package metadata provides it", async () => {
|
||||
let captured:
|
||||
| {
|
||||
options: Array<{
|
||||
value: "clawhub" | "npm" | "local" | "skip";
|
||||
label: string;
|
||||
hint?: string;
|
||||
}>;
|
||||
initialValue: "clawhub" | "npm" | "local" | "skip";
|
||||
}
|
||||
| undefined;
|
||||
|
||||
await ensureOnboardingPluginInstalled({
|
||||
cfg: {},
|
||||
entry: {
|
||||
pluginId: "demo-plugin",
|
||||
label: "Demo Plugin",
|
||||
install: {
|
||||
clawhubSpec: "clawhub:demo-plugin@2026.5.2",
|
||||
npmSpec: "@openclaw/demo-plugin@2026.5.2",
|
||||
},
|
||||
},
|
||||
prompter: {
|
||||
select: vi.fn(async (input) => {
|
||||
captured = input;
|
||||
return "skip";
|
||||
}),
|
||||
} as never,
|
||||
runtime: {} as never,
|
||||
});
|
||||
|
||||
expect(captured?.options).toEqual([
|
||||
{ value: "clawhub", label: "Download from ClawHub (clawhub:demo-plugin@2026.5.2)" },
|
||||
{ value: "npm", label: "Download from npm (@openclaw/demo-plugin@2026.5.2)" },
|
||||
{ value: "skip", label: "Skip for now" },
|
||||
]);
|
||||
expect(captured?.initialValue).toBe("clawhub");
|
||||
expect(installPluginFromClawHub).not.toHaveBeenCalled();
|
||||
expect(installPluginFromNpmSpec).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not offer local installs when the workspace only has a spoofed .git marker", async () => {
|
||||
await withTempDir({ prefix: "openclaw-onboarding-install-spoofed-git-" }, async (temp) => {
|
||||
const workspaceDir = path.join(temp, "workspace");
|
||||
@@ -239,8 +370,12 @@ describe("ensureOnboardingPluginInstalled", () => {
|
||||
let captured:
|
||||
| {
|
||||
message: string;
|
||||
options: Array<{ value: "npm" | "local" | "skip"; label: string; hint?: string }>;
|
||||
initialValue: "npm" | "local" | "skip";
|
||||
options: Array<{
|
||||
value: "clawhub" | "npm" | "local" | "skip";
|
||||
label: string;
|
||||
hint?: string;
|
||||
}>;
|
||||
initialValue: "clawhub" | "npm" | "local" | "skip";
|
||||
}
|
||||
| undefined;
|
||||
|
||||
@@ -291,8 +426,12 @@ describe("ensureOnboardingPluginInstalled", () => {
|
||||
let captured:
|
||||
| {
|
||||
message: string;
|
||||
options: Array<{ value: "npm" | "local" | "skip"; label: string; hint?: string }>;
|
||||
initialValue: "npm" | "local" | "skip";
|
||||
options: Array<{
|
||||
value: "clawhub" | "npm" | "local" | "skip";
|
||||
label: string;
|
||||
hint?: string;
|
||||
}>;
|
||||
initialValue: "clawhub" | "npm" | "local" | "skip";
|
||||
}
|
||||
| undefined;
|
||||
|
||||
@@ -395,8 +534,12 @@ describe("ensureOnboardingPluginInstalled", () => {
|
||||
let captured:
|
||||
| {
|
||||
message: string;
|
||||
options: Array<{ value: "npm" | "local" | "skip"; label: string; hint?: string }>;
|
||||
initialValue: "npm" | "local" | "skip";
|
||||
options: Array<{
|
||||
value: "clawhub" | "npm" | "local" | "skip";
|
||||
label: string;
|
||||
hint?: string;
|
||||
}>;
|
||||
initialValue: "clawhub" | "npm" | "local" | "skip";
|
||||
}
|
||||
| undefined;
|
||||
|
||||
@@ -502,8 +645,12 @@ describe("ensureOnboardingPluginInstalled", () => {
|
||||
let captured:
|
||||
| {
|
||||
message: string;
|
||||
options: Array<{ value: "npm" | "local" | "skip"; label: string; hint?: string }>;
|
||||
initialValue: "npm" | "local" | "skip";
|
||||
options: Array<{
|
||||
value: "clawhub" | "npm" | "local" | "skip";
|
||||
label: string;
|
||||
hint?: string;
|
||||
}>;
|
||||
initialValue: "clawhub" | "npm" | "local" | "skip";
|
||||
}
|
||||
| undefined;
|
||||
|
||||
@@ -701,7 +848,11 @@ describe("ensureOnboardingPluginInstalled", () => {
|
||||
|
||||
let captured:
|
||||
| {
|
||||
options: Array<{ value: "npm" | "local" | "skip"; label: string; hint?: string }>;
|
||||
options: Array<{
|
||||
value: "clawhub" | "npm" | "local" | "skip";
|
||||
label: string;
|
||||
hint?: string;
|
||||
}>;
|
||||
}
|
||||
| undefined;
|
||||
const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(repoDir);
|
||||
@@ -749,7 +900,11 @@ describe("ensureOnboardingPluginInstalled", () => {
|
||||
|
||||
let captured:
|
||||
| {
|
||||
options: Array<{ value: "npm" | "local" | "skip"; label: string; hint?: string }>;
|
||||
options: Array<{
|
||||
value: "clawhub" | "npm" | "local" | "skip";
|
||||
label: string;
|
||||
hint?: string;
|
||||
}>;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
@@ -807,7 +962,11 @@ describe("ensureOnboardingPluginInstalled", () => {
|
||||
try {
|
||||
let captured:
|
||||
| {
|
||||
options: Array<{ value: "npm" | "local" | "skip"; label: string; hint?: string }>;
|
||||
options: Array<{
|
||||
value: "clawhub" | "npm" | "local" | "skip";
|
||||
label: string;
|
||||
hint?: string;
|
||||
}>;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { resolveBundledInstallPlanForCatalogEntry } from "../cli/plugin-install-plan.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { parseClawHubPluginSpec } from "../infra/clawhub-spec.js";
|
||||
import { parseRegistryNpmSpec } from "../infra/npm-registry-spec.js";
|
||||
import {
|
||||
findBundledPluginSourceInMap,
|
||||
@@ -17,7 +18,10 @@ import { sanitizeTerminalText } from "../terminal/safe-text.js";
|
||||
import { withTimeout } from "../utils/with-timeout.js";
|
||||
import type { WizardPrompter } from "../wizard/prompts.js";
|
||||
|
||||
type InstallChoice = "npm" | "local" | "skip";
|
||||
type InstallChoice = "clawhub" | "npm" | "local" | "skip";
|
||||
type InstallPluginFromClawHubResult = Awaited<
|
||||
ReturnType<(typeof import("../plugins/clawhub.js"))["installPluginFromClawHub"]>
|
||||
>;
|
||||
const ONBOARDING_PLUGIN_INSTALL_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
const ONBOARDING_PLUGIN_INSTALL_WATCHDOG_TIMEOUT_MS = ONBOARDING_PLUGIN_INSTALL_TIMEOUT_MS + 5_000;
|
||||
|
||||
@@ -249,19 +253,30 @@ function resolveNpmSpecForOnboarding(install: PluginPackageInstall): string | nu
|
||||
return parsed ? npmSpec : null;
|
||||
}
|
||||
|
||||
function resolveClawHubSpecForOnboarding(install: PluginPackageInstall): string | null {
|
||||
const clawhubSpec = install.clawhubSpec?.trim();
|
||||
if (!clawhubSpec) {
|
||||
return null;
|
||||
}
|
||||
const parsed = parseClawHubPluginSpec(clawhubSpec);
|
||||
return parsed ? clawhubSpec : null;
|
||||
}
|
||||
|
||||
function resolveInstallDefaultChoice(params: {
|
||||
cfg: OpenClawConfig;
|
||||
entry: OnboardingPluginInstallEntry;
|
||||
localPath?: string | null;
|
||||
bundledLocalPath?: string | null;
|
||||
hasClawHubSpec: boolean;
|
||||
hasNpmSpec: boolean;
|
||||
}): InstallChoice {
|
||||
const { cfg, entry, localPath, bundledLocalPath, hasNpmSpec } = params;
|
||||
if (!hasNpmSpec) {
|
||||
const { cfg, entry, localPath, bundledLocalPath, hasClawHubSpec, hasNpmSpec } = params;
|
||||
const hasRemoteSpec = hasClawHubSpec || hasNpmSpec;
|
||||
if (!hasRemoteSpec) {
|
||||
return localPath ? "local" : "skip";
|
||||
}
|
||||
if (!localPath) {
|
||||
return "npm";
|
||||
return hasClawHubSpec ? "clawhub" : "npm";
|
||||
}
|
||||
if (bundledLocalPath) {
|
||||
return "local";
|
||||
@@ -271,16 +286,19 @@ function resolveInstallDefaultChoice(params: {
|
||||
return "local";
|
||||
}
|
||||
if (updateChannel === "stable" || updateChannel === "beta") {
|
||||
return "npm";
|
||||
return hasClawHubSpec ? "clawhub" : "npm";
|
||||
}
|
||||
const entryDefault = entry.install.defaultChoice;
|
||||
if (entryDefault === "clawhub" && hasClawHubSpec) {
|
||||
return "clawhub";
|
||||
}
|
||||
if (entryDefault === "local") {
|
||||
return "local";
|
||||
}
|
||||
if (entryDefault === "npm") {
|
||||
return "npm";
|
||||
}
|
||||
return "local";
|
||||
return hasClawHubSpec ? "clawhub" : "local";
|
||||
}
|
||||
|
||||
async function promptInstallChoice(params: {
|
||||
@@ -295,22 +313,29 @@ async function promptInstallChoice(params: {
|
||||
* (e.g. they just picked the channel in a previous menu). */
|
||||
autoConfirmSingleSource?: boolean;
|
||||
}): Promise<InstallChoice> {
|
||||
const rawClawHubSpec = resolveClawHubSpecForOnboarding(params.entry.install);
|
||||
const rawNpmSpec = resolveNpmSpecForOnboarding(params.entry.install);
|
||||
// When the plugin already ships bundled with the host (i.e. lives under
|
||||
// `extensions/<id>` and is discovered via `resolveBundledPluginSources`),
|
||||
// the bundled copy is the source of truth: it is version-locked to the
|
||||
// current host build and is what `defaultChoice` will pick anyway (see
|
||||
// `resolveInstallDefaultChoice`). Surfacing a "Download from npm (...)"
|
||||
// option in that case is misleading — it suggests the plugin is missing
|
||||
// and forces the user to reason about an npm catalog channel that, for
|
||||
// bundled channels, only exists as a fallback for non-bundled builds.
|
||||
// Hide the npm option entirely in this scenario so bundled channels like
|
||||
// Tlon look identical to Twitch / Slack in the menu.
|
||||
// `resolveInstallDefaultChoice`). Surfacing remote download options in that
|
||||
// case is misleading; those catalog specs only exist as fallback metadata for
|
||||
// non-bundled builds. Hide them so bundled channels like Tlon look identical
|
||||
// to Twitch / Slack in the menu.
|
||||
const clawhubSpec = params.bundledLocalPath ? null : rawClawHubSpec;
|
||||
const npmSpec = params.bundledLocalPath ? null : rawNpmSpec;
|
||||
const safeLabel = sanitizeTerminalText(params.entry.label);
|
||||
const safeClawHubSpec = clawhubSpec ? sanitizeTerminalText(clawhubSpec) : null;
|
||||
const safeNpmSpec = npmSpec ? sanitizeTerminalText(npmSpec) : null;
|
||||
const safeLocalPath = params.localPath ? sanitizeTerminalText(params.localPath) : null;
|
||||
const options: Array<{ value: InstallChoice; label: string; hint?: string }> = [];
|
||||
if (safeClawHubSpec) {
|
||||
options.push({
|
||||
value: "clawhub",
|
||||
label: `Download from ClawHub (${safeClawHubSpec})`,
|
||||
});
|
||||
}
|
||||
if (safeNpmSpec) {
|
||||
options.push({
|
||||
value: "npm",
|
||||
@@ -327,6 +352,9 @@ async function promptInstallChoice(params: {
|
||||
|
||||
if (params.autoConfirmSingleSource) {
|
||||
const realSources: InstallChoice[] = [];
|
||||
if (safeClawHubSpec) {
|
||||
realSources.push("clawhub");
|
||||
}
|
||||
if (safeNpmSpec) {
|
||||
realSources.push("npm");
|
||||
}
|
||||
@@ -342,10 +370,24 @@ async function promptInstallChoice(params: {
|
||||
|
||||
const initialValue =
|
||||
params.defaultChoice === "local" && !params.localPath
|
||||
? npmSpec
|
||||
? "npm"
|
||||
: "skip"
|
||||
: params.defaultChoice;
|
||||
? clawhubSpec
|
||||
? "clawhub"
|
||||
: npmSpec
|
||||
? "npm"
|
||||
: "skip"
|
||||
: params.defaultChoice === "clawhub" && !clawhubSpec
|
||||
? npmSpec
|
||||
? "npm"
|
||||
: params.localPath
|
||||
? "local"
|
||||
: "skip"
|
||||
: params.defaultChoice === "npm" && !npmSpec
|
||||
? clawhubSpec
|
||||
? "clawhub"
|
||||
: params.localPath
|
||||
? "local"
|
||||
: "skip"
|
||||
: params.defaultChoice;
|
||||
|
||||
return await params.prompter.select<InstallChoice>({
|
||||
message: `Install ${safeLabel} plugin?`,
|
||||
@@ -475,8 +517,7 @@ function createAnimatedInstallProgress(
|
||||
const renderBar = (): string => {
|
||||
const percent = computePercent();
|
||||
const filled = Math.round((percent / 100) * PROGRESS_BAR_WIDTH);
|
||||
const bar =
|
||||
"█".repeat(filled) + "░".repeat(Math.max(0, PROGRESS_BAR_WIDTH - filled));
|
||||
const bar = "█".repeat(filled) + "░".repeat(Math.max(0, PROGRESS_BAR_WIDTH - filled));
|
||||
return `[${bar}] ${percent}%`;
|
||||
};
|
||||
|
||||
@@ -579,6 +620,76 @@ async function installPluginFromNpmSpecWithProgress(params: {
|
||||
}
|
||||
}
|
||||
|
||||
async function installPluginFromClawHubSpecWithProgress(params: {
|
||||
entry: OnboardingPluginInstallEntry;
|
||||
clawhubSpec: string;
|
||||
prompter: WizardPrompter;
|
||||
runtime: RuntimeEnv;
|
||||
}): Promise<
|
||||
| { status: "timed_out" }
|
||||
| {
|
||||
status: "completed";
|
||||
result: InstallPluginFromClawHubResult;
|
||||
}
|
||||
> {
|
||||
const safeLabel = sanitizeTerminalText(params.entry.label);
|
||||
const progress = params.prompter.progress(`Installing ${safeLabel} plugin…`);
|
||||
const animated = createAnimatedInstallProgress(progress);
|
||||
animated.setLabel("Preparing");
|
||||
const updateProgress = (message: string) => {
|
||||
const sanitized = sanitizeTerminalText(message).trim();
|
||||
if (!sanitized) {
|
||||
return;
|
||||
}
|
||||
animated.setLabel(shortenInstallLabel(sanitized));
|
||||
};
|
||||
|
||||
try {
|
||||
const { installPluginFromClawHub } = await import("../plugins/clawhub.js");
|
||||
const result = await withTimeout(
|
||||
installPluginFromClawHub({
|
||||
spec: params.clawhubSpec,
|
||||
timeoutMs: ONBOARDING_PLUGIN_INSTALL_TIMEOUT_MS,
|
||||
extensionsDir: resolveDefaultPluginExtensionsDir(),
|
||||
expectedPluginId: params.entry.pluginId,
|
||||
mode: "install",
|
||||
logger: {
|
||||
info: updateProgress,
|
||||
warn: (message) => {
|
||||
updateProgress(message);
|
||||
params.runtime.log?.(sanitizeTerminalText(message));
|
||||
},
|
||||
},
|
||||
}),
|
||||
ONBOARDING_PLUGIN_INSTALL_WATCHDOG_TIMEOUT_MS,
|
||||
);
|
||||
animated.stop();
|
||||
if (result.ok) {
|
||||
progress.stop(`Installed ${safeLabel} plugin`);
|
||||
} else {
|
||||
progress.stop(`Install failed: ${safeLabel}`);
|
||||
}
|
||||
return {
|
||||
status: "completed",
|
||||
result,
|
||||
};
|
||||
} catch (error) {
|
||||
animated.stop();
|
||||
if (isTimeoutError(error)) {
|
||||
progress.stop(`Install timed out: ${safeLabel}`);
|
||||
return { status: "timed_out" };
|
||||
}
|
||||
progress.stop(`Install failed: ${safeLabel}`);
|
||||
return {
|
||||
status: "completed",
|
||||
result: {
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureOnboardingPluginInstalled(params: {
|
||||
cfg: OpenClawConfig;
|
||||
entry: OnboardingPluginInstallEntry;
|
||||
@@ -599,12 +710,14 @@ export async function ensureOnboardingPluginInstalled(params: {
|
||||
workspaceDir,
|
||||
allowLocal,
|
||||
});
|
||||
const clawhubSpec = resolveClawHubSpecForOnboarding(entry.install);
|
||||
const npmSpec = resolveNpmSpecForOnboarding(entry.install);
|
||||
const defaultChoice = resolveInstallDefaultChoice({
|
||||
cfg: next,
|
||||
entry,
|
||||
localPath,
|
||||
bundledLocalPath,
|
||||
hasClawHubSpec: Boolean(clawhubSpec),
|
||||
hasNpmSpec: Boolean(npmSpec),
|
||||
});
|
||||
const choice =
|
||||
@@ -662,13 +775,117 @@ export async function ensureOnboardingPluginInstalled(params: {
|
||||
};
|
||||
}
|
||||
|
||||
if (!npmSpec) {
|
||||
let shouldTryNpm = choice === "npm";
|
||||
if (choice === "clawhub" && clawhubSpec) {
|
||||
const installOutcome = await installPluginFromClawHubSpecWithProgress({
|
||||
entry,
|
||||
clawhubSpec,
|
||||
prompter,
|
||||
runtime,
|
||||
});
|
||||
|
||||
if (installOutcome.status === "timed_out") {
|
||||
await prompter.note(
|
||||
[
|
||||
`Installing ${sanitizeTerminalText(clawhubSpec)} timed out after ${formatDurationLabel(ONBOARDING_PLUGIN_INSTALL_TIMEOUT_MS)}.`,
|
||||
"Returning to selection.",
|
||||
].join("\n"),
|
||||
"Plugin install",
|
||||
);
|
||||
runtime.error?.(
|
||||
`Plugin install timed out after ${ONBOARDING_PLUGIN_INSTALL_TIMEOUT_MS}ms: ${sanitizeTerminalText(clawhubSpec)}`,
|
||||
);
|
||||
return {
|
||||
cfg: next,
|
||||
installed: false,
|
||||
pluginId: entry.pluginId,
|
||||
status: "timed_out",
|
||||
};
|
||||
}
|
||||
|
||||
const { result } = installOutcome;
|
||||
if (result.ok) {
|
||||
const enableResult = await applyPluginEnablement({
|
||||
cfg: next,
|
||||
pluginId: result.pluginId,
|
||||
label: entry.label,
|
||||
prompter,
|
||||
runtime,
|
||||
});
|
||||
if (!enableResult.enabled) {
|
||||
return {
|
||||
cfg: enableResult.config,
|
||||
installed: false,
|
||||
pluginId: result.pluginId,
|
||||
status: "failed",
|
||||
};
|
||||
}
|
||||
next = enableResult.config;
|
||||
next = recordPluginInstall(next, {
|
||||
pluginId: result.pluginId,
|
||||
source: "clawhub",
|
||||
spec: clawhubSpec,
|
||||
installPath: result.targetDir,
|
||||
version: result.version,
|
||||
integrity: result.clawhub.integrity,
|
||||
resolvedAt: result.clawhub.resolvedAt,
|
||||
clawhubUrl: result.clawhub.clawhubUrl,
|
||||
clawhubPackage: result.clawhub.clawhubPackage,
|
||||
clawhubFamily: result.clawhub.clawhubFamily,
|
||||
clawhubChannel: result.clawhub.clawhubChannel,
|
||||
clawpackSha256: result.clawhub.clawpackSha256,
|
||||
clawpackSpecVersion: result.clawhub.clawpackSpecVersion,
|
||||
clawpackManifestSha256: result.clawhub.clawpackManifestSha256,
|
||||
clawpackSize: result.clawhub.clawpackSize,
|
||||
});
|
||||
return {
|
||||
cfg: next,
|
||||
installed: true,
|
||||
pluginId: result.pluginId,
|
||||
status: "installed",
|
||||
};
|
||||
}
|
||||
|
||||
await prompter.note(
|
||||
`No npm install source is available for ${sanitizeTerminalText(entry.label)}. Returning to selection.`,
|
||||
[
|
||||
`Failed to install ${sanitizeTerminalText(clawhubSpec)}: ${summarizeInstallError(result.error)}`,
|
||||
"Returning to selection.",
|
||||
].join("\n"),
|
||||
"Plugin install",
|
||||
);
|
||||
|
||||
if (!npmSpec) {
|
||||
runtime.error?.(`Plugin install failed: ${sanitizeTerminalText(result.error)}`);
|
||||
return {
|
||||
cfg: next,
|
||||
installed: false,
|
||||
pluginId: entry.pluginId,
|
||||
status: "failed",
|
||||
};
|
||||
}
|
||||
|
||||
shouldTryNpm = await prompter.confirm({
|
||||
message: `Use npm package instead? (${sanitizeTerminalText(npmSpec)})`,
|
||||
initialValue: true,
|
||||
});
|
||||
if (!shouldTryNpm) {
|
||||
runtime.error?.(`Plugin install failed: ${sanitizeTerminalText(result.error)}`);
|
||||
return {
|
||||
cfg: next,
|
||||
installed: false,
|
||||
pluginId: entry.pluginId,
|
||||
status: "failed",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!shouldTryNpm || !npmSpec) {
|
||||
await prompter.note(
|
||||
`No remote install source is available for ${sanitizeTerminalText(entry.label)}. Returning to selection.`,
|
||||
"Plugin install",
|
||||
);
|
||||
runtime.error?.(
|
||||
`Plugin install failed: no npm spec available for ${sanitizeTerminalText(entry.pluginId)}.`,
|
||||
`Plugin install failed: no remote spec available for ${sanitizeTerminalText(entry.pluginId)}.`,
|
||||
);
|
||||
return {
|
||||
cfg: next,
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { parseClawHubPluginSpec } from "../infra/clawhub-spec.js";
|
||||
import { parseRegistryNpmSpec, type ParsedRegistryNpmSpec } from "../infra/npm-registry-spec.js";
|
||||
import { normalizeOptionalString } from "../shared/string-coerce.js";
|
||||
import type { PluginPackageInstall } from "./manifest.js";
|
||||
|
||||
export type PluginInstallSourceWarning =
|
||||
| "invalid-clawhub-spec"
|
||||
| "invalid-npm-spec"
|
||||
| "invalid-default-choice"
|
||||
| "default-choice-missing-source"
|
||||
| "clawhub-spec-floating"
|
||||
| "npm-integrity-without-source"
|
||||
| "npm-spec-floating"
|
||||
| "npm-spec-missing-integrity"
|
||||
@@ -32,8 +35,16 @@ export type PluginInstallLocalSourceInfo = {
|
||||
path: string;
|
||||
};
|
||||
|
||||
export type PluginInstallClawHubSourceInfo = {
|
||||
spec: string;
|
||||
packageName: string;
|
||||
version?: string;
|
||||
exactVersion: boolean;
|
||||
};
|
||||
|
||||
export type PluginInstallSourceInfo = {
|
||||
defaultChoice?: PluginPackageInstall["defaultChoice"];
|
||||
clawhub?: PluginInstallClawHubSourceInfo;
|
||||
npm?: PluginInstallNpmSourceInfo;
|
||||
local?: PluginInstallLocalSourceInfo;
|
||||
warnings: readonly PluginInstallSourceWarning[];
|
||||
@@ -54,7 +65,7 @@ function resolveNpmPinState(params: {
|
||||
}
|
||||
|
||||
function resolveDefaultChoice(value: unknown): PluginPackageInstall["defaultChoice"] | undefined {
|
||||
return value === "npm" || value === "local" ? value : undefined;
|
||||
return value === "clawhub" || value === "npm" || value === "local" ? value : undefined;
|
||||
}
|
||||
|
||||
function normalizeExpectedPackageName(value: string | null | undefined): string | undefined {
|
||||
@@ -69,18 +80,37 @@ export function describePluginInstallSource(
|
||||
install: PluginPackageInstall,
|
||||
options?: DescribePluginInstallSourceOptions,
|
||||
): PluginInstallSourceInfo {
|
||||
const clawhubSpec = normalizeOptionalString(install.clawhubSpec);
|
||||
const npmSpec = normalizeOptionalString(install.npmSpec);
|
||||
const localPath = normalizeOptionalString(install.localPath);
|
||||
const defaultChoice = resolveDefaultChoice(install.defaultChoice);
|
||||
const expectedIntegrity = normalizeOptionalString(install.expectedIntegrity);
|
||||
const expectedPackageName = normalizeExpectedPackageName(options?.expectedPackageName);
|
||||
const warnings: PluginInstallSourceWarning[] = [];
|
||||
let clawhub: PluginInstallClawHubSourceInfo | undefined;
|
||||
let npm: PluginInstallNpmSourceInfo | undefined;
|
||||
|
||||
if (install.defaultChoice !== undefined && !defaultChoice) {
|
||||
warnings.push("invalid-default-choice");
|
||||
}
|
||||
|
||||
if (clawhubSpec) {
|
||||
const parsed = parseClawHubPluginSpec(clawhubSpec);
|
||||
if (parsed) {
|
||||
if (!parsed.version) {
|
||||
warnings.push("clawhub-spec-floating");
|
||||
}
|
||||
clawhub = {
|
||||
spec: clawhubSpec,
|
||||
packageName: parsed.name,
|
||||
...(parsed.version ? { version: parsed.version } : {}),
|
||||
exactVersion: Boolean(parsed.version),
|
||||
};
|
||||
} else {
|
||||
warnings.push("invalid-clawhub-spec");
|
||||
}
|
||||
}
|
||||
|
||||
if (npmSpec) {
|
||||
const parsed = parseRegistryNpmSpec(npmSpec);
|
||||
if (parsed) {
|
||||
@@ -111,6 +141,9 @@ export function describePluginInstallSource(
|
||||
warnings.push("invalid-npm-spec");
|
||||
}
|
||||
}
|
||||
if (defaultChoice === "clawhub" && !clawhub) {
|
||||
warnings.push("default-choice-missing-source");
|
||||
}
|
||||
if (defaultChoice === "npm" && !npm) {
|
||||
warnings.push("default-choice-missing-source");
|
||||
}
|
||||
@@ -123,6 +156,7 @@ export function describePluginInstallSource(
|
||||
|
||||
return {
|
||||
...(defaultChoice ? { defaultChoice } : {}),
|
||||
...(clawhub ? { clawhub } : {}),
|
||||
...(npm ? { npm } : {}),
|
||||
...(localPath ? { local: { path: localPath } } : {}),
|
||||
warnings,
|
||||
|
||||
@@ -1685,9 +1685,10 @@ export type PluginPackageChannelCliOption = {
|
||||
};
|
||||
|
||||
export type PluginPackageInstall = {
|
||||
clawhubSpec?: string;
|
||||
npmSpec?: string;
|
||||
localPath?: string;
|
||||
defaultChoice?: "npm" | "local";
|
||||
defaultChoice?: "clawhub" | "npm" | "local";
|
||||
minHostVersion?: string;
|
||||
expectedIntegrity?: string;
|
||||
allowInvalidConfigRecovery?: boolean;
|
||||
|
||||
@@ -249,6 +249,83 @@ describe("provider install catalog", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves durable ClawHub install records for provider setup reinstall hints", () => {
|
||||
loadPluginRegistrySnapshot.mockReturnValue({
|
||||
version: 1,
|
||||
hostContractVersion: "test",
|
||||
compatRegistryVersion: "test",
|
||||
migrationVersion: 1,
|
||||
policyHash: "test",
|
||||
generatedAtMs: 0,
|
||||
installRecords: {
|
||||
vllm: {
|
||||
source: "clawhub",
|
||||
spec: "clawhub:openclaw/vllm@2026.5.2",
|
||||
integrity: "sha256-clawpack",
|
||||
clawhubPackage: "openclaw/vllm",
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
{
|
||||
pluginId: "vllm",
|
||||
origin: "global",
|
||||
manifestPath: "/Users/test/.openclaw/plugins/vllm/openclaw.plugin.json",
|
||||
manifestHash: "hash",
|
||||
rootDir: "/Users/test/.openclaw/plugins/vllm",
|
||||
enabled: true,
|
||||
startup: {
|
||||
sidecar: false,
|
||||
memory: false,
|
||||
deferConfiguredChannelFullLoadUntilAfterListen: false,
|
||||
agentHarnesses: [],
|
||||
},
|
||||
compat: [],
|
||||
packageName: "@openclaw/vllm",
|
||||
packageInstall: {
|
||||
npm: {
|
||||
spec: "@openclaw/vllm-fork@1.0.0",
|
||||
packageName: "@openclaw/vllm-fork",
|
||||
selector: "1.0.0",
|
||||
selectorKind: "exact-version",
|
||||
exactVersion: true,
|
||||
expectedIntegrity: "sha512-old",
|
||||
pinState: "exact-with-integrity",
|
||||
},
|
||||
warnings: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
diagnostics: [],
|
||||
});
|
||||
resolveManifestProviderAuthChoices.mockReturnValue([
|
||||
{
|
||||
pluginId: "vllm",
|
||||
providerId: "vllm",
|
||||
methodId: "server",
|
||||
choiceId: "vllm",
|
||||
choiceLabel: "vLLM",
|
||||
groupLabel: "vLLM",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(resolveProviderInstallCatalogEntry("vllm")).toMatchObject({
|
||||
install: {
|
||||
clawhubSpec: "clawhub:openclaw/vllm@2026.5.2",
|
||||
defaultChoice: "clawhub",
|
||||
},
|
||||
installSource: {
|
||||
defaultChoice: "clawhub",
|
||||
clawhub: {
|
||||
spec: "clawhub:openclaw/vllm@2026.5.2",
|
||||
packageName: "openclaw/vllm",
|
||||
version: "2026.5.2",
|
||||
exactVersion: true,
|
||||
},
|
||||
warnings: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("does not expose untrusted global package install intent without an install record", () => {
|
||||
loadPluginRegistrySnapshot.mockReturnValue({
|
||||
version: 1,
|
||||
@@ -421,6 +498,74 @@ describe("provider install catalog", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces provider-index ClawHub install metadata as the preferred source", () => {
|
||||
loadOpenClawProviderIndex.mockReturnValue({
|
||||
version: 1,
|
||||
providers: {
|
||||
moonshot: {
|
||||
id: "moonshot",
|
||||
name: "Moonshot AI",
|
||||
plugin: {
|
||||
id: "moonshot",
|
||||
package: "@openclaw/plugin-moonshot",
|
||||
install: {
|
||||
clawhubSpec: "clawhub:openclaw/moonshot@2026.5.2",
|
||||
npmSpec: "@openclaw/plugin-moonshot@2026.5.2",
|
||||
defaultChoice: "clawhub",
|
||||
expectedIntegrity: "sha512-moonshot",
|
||||
},
|
||||
},
|
||||
authChoices: [
|
||||
{
|
||||
method: "api-key",
|
||||
choiceId: "moonshot-api-key",
|
||||
choiceLabel: "Moonshot API key",
|
||||
groupId: "moonshot",
|
||||
groupLabel: "Moonshot AI",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolveProviderInstallCatalogEntry("moonshot-api-key")).toEqual({
|
||||
pluginId: "moonshot",
|
||||
providerId: "moonshot",
|
||||
methodId: "api-key",
|
||||
choiceId: "moonshot-api-key",
|
||||
choiceLabel: "Moonshot API key",
|
||||
groupId: "moonshot",
|
||||
groupLabel: "Moonshot AI",
|
||||
label: "Moonshot AI",
|
||||
origin: "bundled",
|
||||
install: {
|
||||
clawhubSpec: "clawhub:openclaw/moonshot@2026.5.2",
|
||||
npmSpec: "@openclaw/plugin-moonshot@2026.5.2",
|
||||
defaultChoice: "clawhub",
|
||||
expectedIntegrity: "sha512-moonshot",
|
||||
},
|
||||
installSource: {
|
||||
defaultChoice: "clawhub",
|
||||
clawhub: {
|
||||
spec: "clawhub:openclaw/moonshot@2026.5.2",
|
||||
packageName: "openclaw/moonshot",
|
||||
version: "2026.5.2",
|
||||
exactVersion: true,
|
||||
},
|
||||
npm: {
|
||||
spec: "@openclaw/plugin-moonshot@2026.5.2",
|
||||
packageName: "@openclaw/plugin-moonshot",
|
||||
selector: "2026.5.2",
|
||||
selectorKind: "exact-version",
|
||||
exactVersion: true,
|
||||
expectedIntegrity: "sha512-moonshot",
|
||||
pinState: "exact-with-integrity",
|
||||
},
|
||||
warnings: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps provider-index entries hidden when the plugin is already installed", () => {
|
||||
loadPluginRegistrySnapshot.mockReturnValue({
|
||||
version: 1,
|
||||
|
||||
@@ -52,7 +52,7 @@ function isPreferredOrigin(candidate: PluginOrigin, current: PluginOrigin | unde
|
||||
}
|
||||
|
||||
function normalizeDefaultChoice(value: unknown): PluginPackageInstall["defaultChoice"] | undefined {
|
||||
return value === "npm" || value === "local" ? value : undefined;
|
||||
return value === "clawhub" || value === "npm" || value === "local" ? value : undefined;
|
||||
}
|
||||
|
||||
function resolveInstallInfoFromInstallRecord(
|
||||
@@ -63,6 +63,12 @@ function resolveInstallInfoFromInstallRecord(
|
||||
}
|
||||
const npmSpec = (record.resolvedSpec ?? record.spec)?.trim();
|
||||
const localPath = (record.installPath ?? record.sourcePath)?.trim();
|
||||
if (record.source === "clawhub" && record.spec?.trim()) {
|
||||
return {
|
||||
clawhubSpec: record.spec.trim(),
|
||||
defaultChoice: "clawhub",
|
||||
};
|
||||
}
|
||||
if (record.source === "npm" && npmSpec) {
|
||||
return {
|
||||
npmSpec,
|
||||
@@ -87,15 +93,26 @@ function resolveInstallInfoFromPackageSource(params: {
|
||||
params.origin === "bundled" || params.origin === "config"
|
||||
? params.source?.npm?.spec
|
||||
: undefined;
|
||||
const clawhubSpec =
|
||||
params.origin === "bundled" || params.origin === "config"
|
||||
? params.source?.clawhub?.spec
|
||||
: undefined;
|
||||
const localPath = params.source?.local?.path;
|
||||
if (!npmSpec && !localPath) {
|
||||
if (!clawhubSpec && !npmSpec && !localPath) {
|
||||
return null;
|
||||
}
|
||||
const defaultChoice = normalizeDefaultChoice(params.source?.defaultChoice);
|
||||
return {
|
||||
...(clawhubSpec ? { clawhubSpec } : {}),
|
||||
...(npmSpec ? { npmSpec } : {}),
|
||||
...(localPath ? { localPath } : {}),
|
||||
...(defaultChoice ? { defaultChoice } : npmSpec ? { defaultChoice: "npm" as const } : {}),
|
||||
...(defaultChoice
|
||||
? { defaultChoice }
|
||||
: clawhubSpec
|
||||
? { defaultChoice: "clawhub" as const }
|
||||
: npmSpec
|
||||
? { defaultChoice: "npm" as const }
|
||||
: {}),
|
||||
...(npmSpec && params.source?.npm?.expectedIntegrity
|
||||
? { expectedIntegrity: params.source.npm.expectedIntegrity }
|
||||
: {}),
|
||||
@@ -122,13 +139,16 @@ function resolveInstallInfoFromProviderIndex(
|
||||
if (!install) {
|
||||
return null;
|
||||
}
|
||||
const clawhubSpec = install.clawhubSpec?.trim();
|
||||
const npmSpec = install.npmSpec?.trim();
|
||||
if (!npmSpec) {
|
||||
if (!clawhubSpec && !npmSpec) {
|
||||
return null;
|
||||
}
|
||||
const defaultChoice = normalizeDefaultChoice(install.defaultChoice) ?? "npm";
|
||||
const defaultChoice =
|
||||
normalizeDefaultChoice(install.defaultChoice) ?? (clawhubSpec ? "clawhub" : "npm");
|
||||
return {
|
||||
npmSpec,
|
||||
...(clawhubSpec ? { clawhubSpec } : {}),
|
||||
...(npmSpec ? { npmSpec } : {}),
|
||||
defaultChoice,
|
||||
...(install.minHostVersion ? { minHostVersion: install.minHostVersion } : {}),
|
||||
...(install.expectedIntegrity ? { expectedIntegrity: install.expectedIntegrity } : {}),
|
||||
|
||||
Reference in New Issue
Block a user