fix(plugins): catalog externalized npm installs

This commit is contained in:
Vincent Koc
2026-05-02 13:20:26 -07:00
parent 0fad53a192
commit d4268b1b2b
17 changed files with 1100 additions and 42 deletions

View File

@@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Plugins/externalization: add official npm-first catalogs for externalized channel, provider, and generic plugins, keep unpublished ACPX/Google Chat/LINE bundled, and make missing-plugin repair honor npm-first metadata while ClawHub pack files roll out. Thanks @vincentkoc.
- Agents/sessions: preserve terminal lifecycle state when final run metadata persists from a stale in-memory snapshot, preventing `main` sessions from staying stuck as running after completed or timed-out turns.
- Gateway/CLI: make `openclaw gateway start` repair stale managed service definitions that point at old OpenClaw versions, missing binaries, or temporary installer paths before starting.
- Status: show the `openai-codex` OAuth profile for `openai/gpt-*` sessions running through the native Codex runtime instead of reporting auth as unknown. (#76197) Thanks @mbelinky.

View File

@@ -31,7 +31,6 @@
"!dist/.runtime-postbuildstamp",
"!dist/**/*.map",
"!dist/plugin-sdk/.tsbuildinfo",
"!dist/extensions/acpx/**",
"!dist/extensions/node_modules/**",
"!dist/extensions/*/node_modules/**",
"!dist/extensions/bluebubbles/**",
@@ -43,8 +42,6 @@
"!dist/extensions/discord/**",
"!dist/extensions/feishu/**",
"!dist/extensions/google-meet/**",
"!dist/extensions/googlechat/**",
"!dist/extensions/line/**",
"!dist/extensions/lobster/**",
"!dist/extensions/matrix/**",
"!dist/extensions/mattermost/**",
@@ -82,6 +79,9 @@
"skills/",
"scripts/npm-runner.mjs",
"scripts/preinstall-package-manager-warning.mjs",
"scripts/lib/official-external-channel-catalog.json",
"scripts/lib/official-external-plugin-catalog.json",
"scripts/lib/official-external-provider-catalog.json",
"scripts/lib/package-dist-imports.mjs",
"scripts/postinstall-bundled-plugins.mjs",
"scripts/windows-cmd-helpers.mjs"

View File

@@ -47,6 +47,357 @@
"expectedIntegrity": "sha512-lYmBrU71ox3v7dzRqaltvzTXPcMjjgYrNqpBj5HIBkXgEFkXRRG8wplXg9Fub41/FjsSPn3WAbYpdTc+k+jsHg=="
}
}
},
{
"name": "@openclaw/bluebubbles",
"description": "OpenClaw BlueBubbles channel plugin",
"source": "official",
"kind": "channel",
"openclaw": {
"channel": {
"id": "bluebubbles",
"label": "BlueBubbles",
"selectionLabel": "BlueBubbles (macOS app)",
"detailLabel": "BlueBubbles",
"docsPath": "/channels/bluebubbles",
"docsLabel": "bluebubbles",
"blurb": "iMessage via the BlueBubbles mac app + REST API.",
"aliases": ["bb"],
"preferOver": ["imessage"],
"systemImage": "bubble.left.and.text.bubble.right",
"order": 75
},
"install": {
"npmSpec": "@openclaw/bluebubbles",
"defaultChoice": "npm",
"minHostVersion": ">=2026.4.10"
}
}
},
{
"name": "@openclaw/discord",
"description": "OpenClaw Discord channel plugin",
"source": "official",
"kind": "channel",
"openclaw": {
"channel": {
"id": "discord",
"label": "Discord",
"selectionLabel": "Discord (Bot API)",
"detailLabel": "Discord Bot",
"docsPath": "/channels/discord",
"docsLabel": "discord",
"blurb": "very well supported right now.",
"systemImage": "bubble.left.and.bubble.right",
"markdownCapable": true,
"preferSessionLookupForAnnounceTarget": true
},
"install": {
"npmSpec": "@openclaw/discord",
"defaultChoice": "npm",
"minHostVersion": ">=2026.4.10"
}
}
},
{
"name": "@openclaw/feishu",
"description": "OpenClaw Feishu/Lark channel plugin",
"source": "official",
"kind": "channel",
"openclaw": {
"channel": {
"id": "feishu",
"label": "Feishu",
"selectionLabel": "Feishu/Lark (飞书)",
"docsPath": "/channels/feishu",
"docsLabel": "feishu",
"blurb": "飞书/Lark enterprise messaging with doc/wiki/drive tools.",
"aliases": ["lark"],
"order": 35,
"quickstartAllowFrom": true
},
"install": {
"npmSpec": "@openclaw/feishu",
"defaultChoice": "npm",
"minHostVersion": ">=2026.4.25"
}
}
},
{
"name": "@openclaw/matrix",
"description": "OpenClaw Matrix channel plugin",
"source": "official",
"kind": "channel",
"openclaw": {
"channel": {
"id": "matrix",
"label": "Matrix",
"selectionLabel": "Matrix (plugin)",
"docsPath": "/channels/matrix",
"docsLabel": "matrix",
"blurb": "open protocol; install the plugin to enable.",
"order": 70,
"quickstartAllowFrom": true
},
"install": {
"npmSpec": "@openclaw/matrix",
"defaultChoice": "npm",
"minHostVersion": ">=2026.4.10",
"allowInvalidConfigRecovery": true
}
}
},
{
"name": "@openclaw/mattermost",
"description": "OpenClaw Mattermost channel plugin",
"source": "official",
"kind": "channel",
"openclaw": {
"channel": {
"id": "mattermost",
"label": "Mattermost",
"selectionLabel": "Mattermost (plugin)",
"docsPath": "/channels/mattermost",
"docsLabel": "mattermost",
"blurb": "self-hosted Slack-style chat; install the plugin to enable.",
"order": 65
},
"install": {
"npmSpec": "@openclaw/mattermost",
"defaultChoice": "npm",
"minHostVersion": ">=2026.4.10"
}
}
},
{
"name": "@openclaw/msteams",
"description": "OpenClaw Microsoft Teams channel plugin",
"source": "official",
"kind": "channel",
"openclaw": {
"channel": {
"id": "msteams",
"label": "Microsoft Teams",
"selectionLabel": "Microsoft Teams (Teams SDK)",
"docsPath": "/channels/msteams",
"docsLabel": "msteams",
"blurb": "Teams SDK; enterprise support.",
"aliases": ["teams"],
"order": 60
},
"install": {
"npmSpec": "@openclaw/msteams",
"defaultChoice": "npm",
"minHostVersion": ">=2026.4.10"
}
}
},
{
"name": "@openclaw/nextcloud-talk",
"description": "OpenClaw Nextcloud Talk channel plugin",
"source": "official",
"kind": "channel",
"openclaw": {
"channel": {
"id": "nextcloud-talk",
"label": "Nextcloud Talk",
"selectionLabel": "Nextcloud Talk (self-hosted)",
"docsPath": "/channels/nextcloud-talk",
"docsLabel": "nextcloud-talk",
"blurb": "Self-hosted chat via Nextcloud Talk webhook bots.",
"aliases": ["nc-talk", "nc"],
"order": 65,
"quickstartAllowFrom": true
},
"install": {
"npmSpec": "@openclaw/nextcloud-talk",
"defaultChoice": "npm",
"minHostVersion": ">=2026.4.10"
}
}
},
{
"name": "@openclaw/nostr",
"description": "OpenClaw Nostr channel plugin for NIP-04 encrypted DMs",
"source": "official",
"kind": "channel",
"openclaw": {
"channel": {
"id": "nostr",
"label": "Nostr",
"selectionLabel": "Nostr (NIP-04 DMs)",
"docsPath": "/channels/nostr",
"docsLabel": "nostr",
"blurb": "Decentralized protocol; encrypted DMs via NIP-04.",
"order": 55,
"quickstartAllowFrom": true
},
"install": {
"npmSpec": "@openclaw/nostr",
"defaultChoice": "npm",
"minHostVersion": ">=2026.4.10"
}
}
},
{
"name": "@openclaw/qqbot",
"description": "OpenClaw QQ Bot channel plugin",
"source": "official",
"kind": "channel",
"openclaw": {
"channel": {
"id": "qqbot",
"label": "QQ Bot",
"selectionLabel": "QQ Bot (Official API)",
"detailLabel": "QQ Bot",
"docsPath": "/channels/qqbot",
"docsLabel": "qqbot",
"blurb": "connect to QQ via official QQ Bot API with group chat and direct message support.",
"systemImage": "bubble.left.and.bubble.right"
},
"install": {
"npmSpec": "@openclaw/qqbot",
"defaultChoice": "npm",
"minHostVersion": ">=2026.4.10"
}
}
},
{
"name": "@openclaw/synology-chat",
"description": "Synology Chat channel plugin for OpenClaw",
"source": "official",
"kind": "channel",
"openclaw": {
"channel": {
"id": "synology-chat",
"label": "Synology Chat",
"selectionLabel": "Synology Chat (Webhook)",
"docsPath": "/channels/synology-chat",
"docsLabel": "synology-chat",
"blurb": "Connect your Synology NAS Chat to OpenClaw with full agent capabilities.",
"order": 90
},
"install": {
"npmSpec": "@openclaw/synology-chat",
"defaultChoice": "npm",
"minHostVersion": ">=2026.4.10"
}
}
},
{
"name": "@openclaw/tlon",
"description": "OpenClaw Tlon/Urbit channel plugin",
"source": "official",
"kind": "channel",
"openclaw": {
"channel": {
"id": "tlon",
"label": "Tlon",
"selectionLabel": "Tlon (Urbit)",
"docsPath": "/channels/tlon",
"docsLabel": "tlon",
"blurb": "decentralized messaging on Urbit; install the plugin to enable.",
"order": 90,
"quickstartAllowFrom": true
},
"install": {
"npmSpec": "@openclaw/tlon",
"defaultChoice": "npm",
"minHostVersion": ">=2026.4.10"
}
}
},
{
"name": "@openclaw/twitch",
"description": "OpenClaw Twitch channel plugin",
"source": "official",
"kind": "channel",
"openclaw": {
"channel": {
"id": "twitch",
"label": "Twitch",
"selectionLabel": "Twitch (Chat)",
"docsPath": "/channels/twitch",
"blurb": "Twitch chat integration",
"aliases": ["twitch-chat"]
},
"install": {
"npmSpec": "@openclaw/twitch",
"defaultChoice": "npm",
"minHostVersion": ">=2026.4.10"
}
}
},
{
"name": "@openclaw/whatsapp",
"description": "OpenClaw WhatsApp channel plugin",
"source": "official",
"kind": "channel",
"openclaw": {
"channel": {
"id": "whatsapp",
"label": "WhatsApp",
"selectionLabel": "WhatsApp (QR link)",
"detailLabel": "WhatsApp Web",
"docsPath": "/channels/whatsapp",
"docsLabel": "whatsapp",
"blurb": "works with your own number; recommend a separate phone + eSIM.",
"systemImage": "message"
},
"install": {
"npmSpec": "@openclaw/whatsapp",
"defaultChoice": "npm",
"minHostVersion": ">=2026.4.25"
}
}
},
{
"name": "@openclaw/zalo",
"description": "OpenClaw Zalo channel plugin",
"source": "official",
"kind": "channel",
"openclaw": {
"channel": {
"id": "zalo",
"label": "Zalo",
"selectionLabel": "Zalo (Bot API)",
"docsPath": "/channels/zalo",
"docsLabel": "zalo",
"blurb": "Vietnam-focused messaging platform with Bot API.",
"aliases": ["zl"],
"order": 80,
"quickstartAllowFrom": true
},
"install": {
"npmSpec": "@openclaw/zalo",
"defaultChoice": "npm",
"minHostVersion": ">=2026.4.10"
}
}
},
{
"name": "@openclaw/zalouser",
"description": "OpenClaw Zalo Personal Account plugin via native zca-js integration",
"source": "official",
"kind": "channel",
"openclaw": {
"channel": {
"id": "zalouser",
"label": "Zalo Personal",
"selectionLabel": "Zalo (Personal Account)",
"docsPath": "/channels/zalouser",
"docsLabel": "zalouser",
"blurb": "Zalo personal account via QR code login.",
"aliases": ["zlu"],
"order": 85,
"quickstartAllowFrom": false
},
"install": {
"npmSpec": "@openclaw/zalouser",
"defaultChoice": "npm",
"minHostVersion": ">=2026.4.10"
}
}
}
]
}

View File

@@ -0,0 +1,142 @@
{
"entries": [
{
"name": "@openclaw/brave-plugin",
"description": "OpenClaw Brave plugin",
"source": "official",
"kind": "plugin",
"openclaw": {
"plugin": {
"id": "brave",
"label": "Brave"
},
"install": {
"npmSpec": "@openclaw/brave-plugin",
"defaultChoice": "npm",
"minHostVersion": ">=2026.4.10"
}
}
},
{
"name": "@openclaw/diagnostics-otel",
"description": "OpenClaw diagnostics OpenTelemetry exporter",
"source": "official",
"kind": "plugin",
"openclaw": {
"plugin": {
"id": "diagnostics-otel",
"label": "Diagnostics OpenTelemetry"
},
"install": {
"clawhubSpec": "clawhub:@openclaw/diagnostics-otel",
"npmSpec": "@openclaw/diagnostics-otel",
"defaultChoice": "npm",
"minHostVersion": ">=2026.4.25"
}
}
},
{
"name": "@openclaw/diagnostics-prometheus",
"description": "OpenClaw diagnostics Prometheus exporter",
"source": "official",
"kind": "plugin",
"openclaw": {
"plugin": {
"id": "diagnostics-prometheus",
"label": "Diagnostics Prometheus"
},
"install": {
"clawhubSpec": "clawhub:@openclaw/diagnostics-prometheus",
"npmSpec": "@openclaw/diagnostics-prometheus",
"defaultChoice": "npm",
"minHostVersion": ">=2026.4.25"
}
}
},
{
"name": "@openclaw/diffs",
"description": "OpenClaw diff viewer plugin",
"source": "official",
"kind": "plugin",
"openclaw": {
"plugin": {
"id": "diffs",
"label": "Diffs"
},
"install": {
"npmSpec": "@openclaw/diffs",
"defaultChoice": "npm",
"minHostVersion": ">=2026.4.30"
}
}
},
{
"name": "@openclaw/google-meet",
"description": "OpenClaw Google Meet participant plugin",
"source": "official",
"kind": "plugin",
"openclaw": {
"plugin": {
"id": "google-meet",
"label": "Google Meet"
},
"install": {
"npmSpec": "@openclaw/google-meet",
"defaultChoice": "npm",
"minHostVersion": ">=2026.4.20"
}
}
},
{
"name": "@openclaw/lobster",
"description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)",
"source": "official",
"kind": "plugin",
"openclaw": {
"plugin": {
"id": "lobster",
"label": "Lobster"
},
"install": {
"npmSpec": "@openclaw/lobster",
"defaultChoice": "npm",
"minHostVersion": ">=2026.4.25"
}
}
},
{
"name": "@openclaw/memory-lancedb",
"description": "OpenClaw LanceDB-backed long-term memory plugin with auto-recall/capture",
"source": "official",
"kind": "plugin",
"openclaw": {
"plugin": {
"id": "memory-lancedb",
"label": "Memory LanceDB"
},
"install": {
"npmSpec": "@openclaw/memory-lancedb",
"defaultChoice": "npm",
"minHostVersion": ">=2026.4.10"
}
}
},
{
"name": "@openclaw/voice-call",
"description": "OpenClaw voice-call plugin",
"source": "official",
"kind": "plugin",
"openclaw": {
"plugin": {
"id": "voice-call",
"label": "Voice Call"
},
"install": {
"npmSpec": "@openclaw/voice-call",
"defaultChoice": "npm",
"minHostVersion": ">=2026.4.10"
}
}
}
]
}

View File

@@ -0,0 +1,30 @@
{
"entries": [
{
"name": "@openclaw/codex",
"description": "OpenClaw Codex harness and model provider plugin",
"source": "official",
"kind": "provider",
"openclaw": {
"plugin": {
"id": "codex",
"label": "Codex"
},
"providers": [
{
"id": "codex",
"name": "Codex",
"docs": "/providers/models",
"categories": ["cloud", "llm"],
"authChoices": []
}
],
"install": {
"npmSpec": "@openclaw/codex",
"defaultChoice": "npm",
"minHostVersion": ">=2026.5.1-beta.1"
}
}
}
]
}

View File

@@ -26,6 +26,14 @@ const CORE_PROD_REQUIRED_PATHS = [
path: "scripts/lib/official-external-channel-catalog.json",
whenPresent: "src/channels/plugins/catalog.ts",
},
{
path: "scripts/lib/official-external-plugin-catalog.json",
whenPresent: "src/plugins/official-external-plugin-catalog.ts",
},
{
path: "scripts/lib/official-external-provider-catalog.json",
whenPresent: "src/plugins/official-external-plugin-catalog.ts",
},
{
path: "scripts/lib/plugin-sdk-entrypoints.json",
whenPresent: "src/plugin-sdk/entrypoints.ts",

View File

@@ -55,6 +55,9 @@ const requiredPathGroups = [
...WORKSPACE_TEMPLATE_PACK_PATHS,
"scripts/npm-runner.mjs",
"scripts/preinstall-package-manager-warning.mjs",
"scripts/lib/official-external-channel-catalog.json",
"scripts/lib/official-external-plugin-catalog.json",
"scripts/lib/official-external-provider-catalog.json",
"scripts/lib/package-dist-imports.mjs",
"scripts/postinstall-bundled-plugins.mjs",
"dist/plugin-sdk/compat.js",

View File

@@ -53,6 +53,10 @@ function buildCatalogEntry(packageJson) {
};
}
function getCatalogChannelId(entry) {
return trimString(entry?.openclaw?.channel?.id) || trimString(entry?.name);
}
export function buildOfficialChannelCatalog(params = {}) {
const repoRoot = params.cwd ?? params.repoRoot ?? process.cwd();
const extensionsRoot = path.join(repoRoot, "extensions");
@@ -74,7 +78,11 @@ export function buildOfficialChannelCatalog(params = {}) {
try {
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
const entry = buildCatalogEntry(packageJson);
if (entry) {
const channelId = entry ? getCatalogChannelId(entry) : "";
const alreadyPresent = channelId
? entries.some((existing) => getCatalogChannelId(existing) === channelId)
: false;
if (entry && !alreadyPresent) {
entries.push(entry);
}
} catch {

View File

@@ -1,6 +1,5 @@
import fs from "node:fs";
import path from "node:path";
import officialExternalChannelCatalog from "../../../scripts/lib/official-external-channel-catalog.json" with { type: "json" };
import { MANIFEST_KEY } from "../../compat/legacy-names.js";
import { resolveOpenClawPackageRootSync } from "../../infra/openclaw-root.js";
import { listChannelCatalogEntries } from "../../plugins/channel-catalog-registry.js";
@@ -10,6 +9,7 @@ import {
} from "../../plugins/install-source-info.js";
import type { OpenClawPackageManifest } from "../../plugins/manifest.js";
import type { PluginPackageChannel, PluginPackageInstall } from "../../plugins/manifest.js";
import { listOfficialExternalChannelCatalogEntries } from "../../plugins/official-external-plugin-catalog.js";
import type { PluginOrigin } from "../../plugins/plugin-origin.types.js";
import { normalizeOptionalString } from "../../shared/string-coerce.js";
import { isRecord, resolveConfigDir, resolveUserPath } from "../../utils.js";
@@ -196,7 +196,7 @@ function resolveOfficialCatalogPaths(options: CatalogOptions): string[] {
}
function loadOfficialCatalogEntries(options: CatalogOptions): ChannelPluginCatalogEntry[] {
const builtInEntries = parseCatalogEntries(officialExternalChannelCatalog);
const builtInEntries = listOfficialExternalChannelCatalogEntries();
const officialPaths = resolveOfficialCatalogPaths(options);
const fileEntries =
options.officialCatalogPaths && options.officialCatalogPaths.length > 0

View File

@@ -117,12 +117,13 @@ describe("listPersistedBundledPluginLocationBridges", () => {
pluginId: "diagnostics-otel",
preferredSource: "npm",
npmSpec: "@openclaw/diagnostics-otel",
clawhubSpec: "clawhub:@openclaw/diagnostics-otel",
channelIds: ["diagnostics-otel"],
},
]);
});
it("does not create a relocation bridge without npm metadata", async () => {
it("uses official external catalog metadata when the persisted bundled row lacks npm metadata", async () => {
readPersistedInstalledPluginIndexMock.mockResolvedValue(
makeIndex({
pluginId: "diagnostics-otel",
@@ -149,6 +150,37 @@ describe("listPersistedBundledPluginLocationBridges", () => {
makeRegistry("diagnostics-otel"),
);
await expect(listPersistedBundledPluginLocationBridges({})).resolves.toEqual([
{
bundledPluginId: "diagnostics-otel",
pluginId: "diagnostics-otel",
preferredSource: "npm",
npmSpec: "@openclaw/diagnostics-otel",
clawhubSpec: "clawhub:@openclaw/diagnostics-otel",
channelIds: ["diagnostics-otel"],
},
]);
});
it("does not create a relocation bridge without persisted or official install metadata", async () => {
readPersistedInstalledPluginIndexMock.mockResolvedValue(
makeIndex({
pluginId: "local-only",
manifestPath: "/app/dist/extensions/local-only/openclaw.plugin.json",
manifestHash: "hash",
source: "/app/dist/extensions/local-only/index.js",
rootDir: "/app/dist/extensions/local-only",
origin: "bundled",
enabled: true,
startup: startupInfo,
compat: [],
packageInstall: {
warnings: [],
},
}),
);
loadPluginManifestRegistryForInstalledIndexMock.mockReturnValue(makeRegistry("local-only"));
await expect(listPersistedBundledPluginLocationBridges({})).resolves.toEqual([]);
});
});

View File

@@ -3,6 +3,11 @@ import { readPersistedInstalledPluginIndex } from "../plugins/installed-plugin-i
import type { InstalledPluginIndexRecord } from "../plugins/installed-plugin-index.js";
import { loadPluginManifestRegistryForInstalledIndex } from "../plugins/manifest-registry-installed.js";
import type { PluginManifestRecord } from "../plugins/manifest-registry.js";
import {
getOfficialExternalPluginCatalogEntry,
getOfficialExternalPluginCatalogManifest,
resolveOfficialExternalPluginInstall,
} from "../plugins/official-external-plugin-catalog.js";
function buildBridgeFromPersistedBundledRecord(
record: InstalledPluginIndexRecord,
@@ -14,17 +19,32 @@ function buildBridgeFromPersistedBundledRecord(
if (record.origin !== "bundled" || !record.enabled) {
return null;
}
const npmSpec = record.packageInstall?.npm?.spec;
if (!npmSpec) {
const officialEntry = getOfficialExternalPluginCatalogEntry(record.pluginId);
const officialInstall = officialEntry
? resolveOfficialExternalPluginInstall(officialEntry)
: null;
const npmSpec = officialInstall?.npmSpec?.trim() ?? record.packageInstall?.npm?.spec;
const clawhubSpec = officialInstall?.clawhubSpec?.trim();
if (!npmSpec && !clawhubSpec) {
return null;
}
const officialChannelId = officialEntry
? getOfficialExternalPluginCatalogManifest(officialEntry)?.channel?.id?.trim()
: undefined;
const channelIds = manifest?.channels.length
? manifest.channels
: officialChannelId
? [officialChannelId]
: [];
return {
bundledPluginId: record.pluginId,
pluginId: record.pluginId,
preferredSource: "npm",
npmSpec,
preferredSource:
officialInstall?.defaultChoice === "clawhub" && clawhubSpec ? "clawhub" : "npm",
...(npmSpec ? { npmSpec } : {}),
...(clawhubSpec ? { clawhubSpec } : {}),
...(record.enabledByDefault ? { enabledByDefault: true } : {}),
...(manifest?.channels.length ? { channelIds: manifest.channels } : {}),
...(channelIds.length ? { channelIds } : {}),
};
}

View File

@@ -4,8 +4,16 @@ const mocks = vi.hoisted(() => ({
installPluginFromClawHub: vi.fn(),
installPluginFromNpmSpec: vi.fn(),
listChannelPluginCatalogEntries: vi.fn(),
listOfficialExternalPluginCatalogEntries: vi.fn(),
loadInstalledPluginIndexInstallRecords: vi.fn(),
loadPluginMetadataSnapshot: vi.fn(),
resolveOfficialExternalPluginId: vi.fn((entry: { id?: string }) => entry.id),
resolveOfficialExternalPluginInstall: vi.fn(
(entry: { install?: unknown }) => entry.install ?? null,
),
resolveOfficialExternalPluginLabel: vi.fn(
(entry: { label?: string; id?: string }) => entry.label ?? entry.id ?? "plugin",
),
resolveDefaultPluginExtensionsDir: vi.fn(() => "/tmp/openclaw-plugins"),
resolveProviderInstallCatalogEntries: vi.fn(),
updateNpmInstalledPlugins: vi.fn(),
@@ -42,6 +50,13 @@ vi.mock("../../../plugins/plugin-metadata-snapshot.js", () => ({
loadPluginMetadataSnapshot: mocks.loadPluginMetadataSnapshot,
}));
vi.mock("../../../plugins/official-external-plugin-catalog.js", () => ({
listOfficialExternalPluginCatalogEntries: mocks.listOfficialExternalPluginCatalogEntries,
resolveOfficialExternalPluginId: mocks.resolveOfficialExternalPluginId,
resolveOfficialExternalPluginInstall: mocks.resolveOfficialExternalPluginInstall,
resolveOfficialExternalPluginLabel: mocks.resolveOfficialExternalPluginLabel,
}));
vi.mock("../../../plugins/provider-install-catalog.js", () => ({
resolveProviderInstallCatalogEntries: mocks.resolveProviderInstallCatalogEntries,
}));
@@ -59,6 +74,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
});
mocks.loadInstalledPluginIndexInstallRecords.mockResolvedValue({});
mocks.listChannelPluginCatalogEntries.mockReturnValue([]);
mocks.listOfficialExternalPluginCatalogEntries.mockReturnValue([]);
mocks.resolveProviderInstallCatalogEntries.mockReturnValue([]);
mocks.installPluginFromClawHub.mockResolvedValue({
ok: true,
@@ -221,6 +237,104 @@ describe("repairMissingConfiguredPluginInstalls", () => {
expect(result.warnings).toEqual([]);
});
it("honors npm-first catalog metadata for missing OpenClaw channel plugins", async () => {
mocks.installPluginFromNpmSpec.mockResolvedValueOnce({
ok: true,
pluginId: "twitch",
targetDir: "/tmp/openclaw-plugins/twitch",
version: "2026.5.2",
npmResolution: {
name: "@openclaw/twitch",
version: "2026.5.2",
resolvedSpec: "@openclaw/twitch@2026.5.2",
integrity: "sha512-twitch",
resolvedAt: "2026-05-01T00:00:00.000Z",
},
});
mocks.listChannelPluginCatalogEntries.mockReturnValue([
{
id: "twitch",
pluginId: "twitch",
meta: { label: "Twitch" },
install: {
npmSpec: "@openclaw/twitch",
defaultChoice: "npm",
},
},
]);
const { repairMissingPluginInstallsForIds } =
await import("./missing-configured-plugin-install.js");
const result = await repairMissingPluginInstallsForIds({
cfg: {},
pluginIds: [],
channelIds: ["twitch"],
env: {},
});
expect(mocks.installPluginFromClawHub).not.toHaveBeenCalled();
expect(mocks.installPluginFromNpmSpec).toHaveBeenCalledWith(
expect.objectContaining({
spec: "@openclaw/twitch",
expectedPluginId: "twitch",
}),
);
expect(result.changes).toEqual([
'Installed missing configured plugin "twitch" from @openclaw/twitch.',
]);
});
it("installs missing configured non-channel plugins from the official external catalog", async () => {
mocks.installPluginFromNpmSpec.mockResolvedValueOnce({
ok: true,
pluginId: "diagnostics-otel",
targetDir: "/tmp/openclaw-plugins/diagnostics-otel",
version: "2026.5.2",
npmResolution: {
name: "@openclaw/diagnostics-otel",
version: "2026.5.2",
resolvedSpec: "@openclaw/diagnostics-otel@2026.5.2",
integrity: "sha512-otel",
resolvedAt: "2026-05-01T00:00:00.000Z",
},
});
mocks.listOfficialExternalPluginCatalogEntries.mockReturnValue([
{
id: "diagnostics-otel",
label: "Diagnostics OpenTelemetry",
install: {
clawhubSpec: "clawhub:@openclaw/diagnostics-otel",
npmSpec: "@openclaw/diagnostics-otel",
defaultChoice: "npm",
},
},
]);
const { repairMissingConfiguredPluginInstalls } =
await import("./missing-configured-plugin-install.js");
const result = await repairMissingConfiguredPluginInstalls({
cfg: {
plugins: {
entries: {
"diagnostics-otel": { enabled: true },
},
},
},
env: {},
});
expect(mocks.installPluginFromClawHub).not.toHaveBeenCalled();
expect(mocks.installPluginFromNpmSpec).toHaveBeenCalledWith(
expect.objectContaining({
spec: "@openclaw/diagnostics-otel",
expectedPluginId: "diagnostics-otel",
}),
);
expect(result.changes).toEqual([
'Installed missing configured plugin "diagnostics-otel" from @openclaw/diagnostics-otel.',
]);
});
it("installs a missing third-party downloadable plugin from npm only", async () => {
mocks.installPluginFromNpmSpec.mockResolvedValueOnce({
ok: true,

View File

@@ -10,6 +10,13 @@ import { loadInstalledPluginIndexInstallRecords } from "../../../plugins/install
import { writePersistedInstalledPluginIndexInstallRecords } from "../../../plugins/installed-plugin-index-records.js";
import { buildNpmResolutionInstallFields } from "../../../plugins/installs.js";
import { loadManifestMetadataSnapshot } from "../../../plugins/manifest-contract-eligibility.js";
import type { PluginPackageInstall } from "../../../plugins/manifest.js";
import {
listOfficialExternalPluginCatalogEntries,
resolveOfficialExternalPluginId,
resolveOfficialExternalPluginInstall,
resolveOfficialExternalPluginLabel,
} from "../../../plugins/official-external-plugin-catalog.js";
import { resolveProviderInstallCatalogEntries } from "../../../plugins/provider-install-catalog.js";
import { updateNpmInstalledPlugins } from "../../../plugins/update.js";
import { asObjectRecord } from "./object.js";
@@ -20,6 +27,7 @@ type DownloadableInstallCandidate = {
npmSpec?: string;
clawhubSpec?: string;
expectedIntegrity?: string;
defaultChoice?: PluginPackageInstall["defaultChoice"];
};
function buildOpenClawClawHubSpec(npmSpec: string): string | undefined {
@@ -37,6 +45,24 @@ function shouldFallbackClawHubToNpm(result: { ok: false; code?: string }): boole
);
}
function normalizeInstallDefaultChoice(
value: PluginPackageInstall["defaultChoice"] | undefined,
): PluginPackageInstall["defaultChoice"] | undefined {
return value === "clawhub" || value === "npm" || value === "local" ? value : undefined;
}
function resolveCandidateClawHubSpec(install: PluginPackageInstall): string | undefined {
const explicit = install.clawhubSpec?.trim();
if (explicit) {
return explicit;
}
const npmSpec = install.npmSpec?.trim();
if (!npmSpec || normalizeInstallDefaultChoice(install.defaultChoice) === "npm") {
return undefined;
}
return buildOpenClawClawHubSpec(npmSpec);
}
function collectConfiguredPluginIds(cfg: OpenClawConfig): Set<string> {
const ids = new Set<string>();
const plugins = asObjectRecord(cfg.plugins);
@@ -95,9 +121,7 @@ function collectDownloadableInstallCandidates(params: {
continue;
}
const npmSpec = entry.install.npmSpec?.trim();
const clawhubSpec =
entry.install.clawhubSpec?.trim() ??
(npmSpec ? buildOpenClawClawHubSpec(npmSpec) : undefined);
const clawhubSpec = resolveCandidateClawHubSpec(entry.install);
if (!npmSpec && !clawhubSpec) {
continue;
}
@@ -109,6 +133,7 @@ function collectDownloadableInstallCandidates(params: {
...(entry.install.expectedIntegrity
? { expectedIntegrity: entry.install.expectedIntegrity }
: {}),
...(entry.install.defaultChoice ? { defaultChoice: entry.install.defaultChoice } : {}),
});
}
@@ -124,9 +149,7 @@ function collectDownloadableInstallCandidates(params: {
continue;
}
const npmSpec = entry.install.npmSpec?.trim();
const clawhubSpec =
entry.install.clawhubSpec?.trim() ??
(npmSpec ? buildOpenClawClawHubSpec(npmSpec) : undefined);
const clawhubSpec = resolveCandidateClawHubSpec(entry.install);
if (!npmSpec && !clawhubSpec) {
continue;
}
@@ -138,6 +161,34 @@ function collectDownloadableInstallCandidates(params: {
...(entry.install.expectedIntegrity
? { expectedIntegrity: entry.install.expectedIntegrity }
: {}),
...(entry.install.defaultChoice ? { defaultChoice: entry.install.defaultChoice } : {}),
});
}
for (const entry of listOfficialExternalPluginCatalogEntries()) {
const pluginId = resolveOfficialExternalPluginId(entry);
if (!pluginId || candidates.has(pluginId) || params.blockedPluginIds?.has(pluginId)) {
continue;
}
if (!configuredPluginIds.has(pluginId) && !params.missingPluginIds.has(pluginId)) {
continue;
}
const install = resolveOfficialExternalPluginInstall(entry);
if (!install) {
continue;
}
const npmSpec = install.npmSpec?.trim();
const clawhubSpec = resolveCandidateClawHubSpec(install);
if (!npmSpec && !clawhubSpec) {
continue;
}
candidates.set(pluginId, {
pluginId,
label: resolveOfficialExternalPluginLabel(entry),
...(npmSpec ? { npmSpec } : {}),
...(clawhubSpec ? { clawhubSpec } : {}),
...(install.expectedIntegrity ? { expectedIntegrity: install.expectedIntegrity } : {}),
...(install.defaultChoice ? { defaultChoice: install.defaultChoice } : {}),
});
}
@@ -157,7 +208,7 @@ async function installCandidate(params: {
const { candidate } = params;
const extensionsDir = resolveDefaultPluginExtensionsDir();
const changes: string[] = [];
if (candidate.clawhubSpec) {
if (candidate.clawhubSpec && candidate.defaultChoice !== "npm") {
const clawhubResult = await installPluginFromClawHub({
spec: candidate.clawhubSpec,
extensionsDir,

View File

@@ -0,0 +1,173 @@
import officialExternalChannelCatalog from "../../scripts/lib/official-external-channel-catalog.json" with { type: "json" };
import officialExternalPluginCatalog from "../../scripts/lib/official-external-plugin-catalog.json" with { type: "json" };
import officialExternalProviderCatalog from "../../scripts/lib/official-external-provider-catalog.json" with { type: "json" };
import { MANIFEST_KEY } from "../compat/legacy-names.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
import { isRecord } from "../utils.js";
import type { PluginPackageInstall } from "./manifest.js";
type ManifestKey = typeof MANIFEST_KEY;
export type OfficialExternalProviderAuthChoice = {
method?: string;
choiceId?: string;
choiceLabel?: string;
choiceHint?: string;
assistantPriority?: number;
assistantVisibility?: "visible" | "manual-only";
groupId?: string;
groupLabel?: string;
groupHint?: string;
optionKey?: string;
cliFlag?: string;
cliOption?: string;
cliDescription?: string;
onboardingScopes?: readonly ("text-inference" | "image-generation")[];
};
export type OfficialExternalProviderCatalogProvider = {
id?: string;
name?: string;
docs?: string;
categories?: readonly string[];
authChoices?: readonly OfficialExternalProviderAuthChoice[];
};
export type OfficialExternalPluginCatalogManifest = {
plugin?: {
id?: string;
label?: string;
};
channel?: {
id?: string;
label?: string;
};
providers?: readonly OfficialExternalProviderCatalogProvider[];
install?: PluginPackageInstall;
};
export type OfficialExternalPluginCatalogEntry = {
name?: string;
version?: string;
description?: string;
source?: string;
kind?: string;
} & Partial<Record<ManifestKey, OfficialExternalPluginCatalogManifest>>;
const OFFICIAL_CATALOG_SOURCES = [
officialExternalChannelCatalog,
officialExternalProviderCatalog,
officialExternalPluginCatalog,
] as const;
function parseCatalogEntries(raw: unknown): OfficialExternalPluginCatalogEntry[] {
if (Array.isArray(raw)) {
return raw.filter((entry): entry is OfficialExternalPluginCatalogEntry => isRecord(entry));
}
if (!isRecord(raw)) {
return [];
}
const list = raw.entries ?? raw.packages ?? raw.plugins;
if (!Array.isArray(list)) {
return [];
}
return list.filter((entry): entry is OfficialExternalPluginCatalogEntry => isRecord(entry));
}
function normalizeDefaultChoice(value: unknown): PluginPackageInstall["defaultChoice"] | undefined {
return value === "clawhub" || value === "npm" || value === "local" ? value : undefined;
}
export function getOfficialExternalPluginCatalogManifest(
entry: OfficialExternalPluginCatalogEntry,
): OfficialExternalPluginCatalogManifest | undefined {
const manifest = entry[MANIFEST_KEY];
return isRecord(manifest) ? (manifest as OfficialExternalPluginCatalogManifest) : undefined;
}
export function resolveOfficialExternalPluginId(
entry: OfficialExternalPluginCatalogEntry,
): string | undefined {
const manifest = getOfficialExternalPluginCatalogManifest(entry);
return (
normalizeOptionalString(manifest?.plugin?.id) ??
normalizeOptionalString(manifest?.channel?.id) ??
normalizeOptionalString(manifest?.providers?.[0]?.id)
);
}
export function resolveOfficialExternalPluginLabel(
entry: OfficialExternalPluginCatalogEntry,
): string {
const manifest = getOfficialExternalPluginCatalogManifest(entry);
return (
normalizeOptionalString(manifest?.plugin?.label) ??
normalizeOptionalString(manifest?.channel?.label) ??
normalizeOptionalString(manifest?.providers?.[0]?.name) ??
normalizeOptionalString(entry.name) ??
resolveOfficialExternalPluginId(entry) ??
"plugin"
);
}
export function resolveOfficialExternalPluginInstall(
entry: OfficialExternalPluginCatalogEntry,
): PluginPackageInstall | null {
const manifest = getOfficialExternalPluginCatalogManifest(entry);
const install = manifest?.install;
const clawhubSpec = normalizeOptionalString(install?.clawhubSpec);
const npmSpec = normalizeOptionalString(install?.npmSpec) ?? normalizeOptionalString(entry.name);
const localPath = normalizeOptionalString(install?.localPath);
if (!clawhubSpec && !npmSpec && !localPath) {
return null;
}
const defaultChoice =
normalizeDefaultChoice(install?.defaultChoice) ??
(npmSpec ? "npm" : clawhubSpec ? "clawhub" : localPath ? "local" : undefined);
return {
...(clawhubSpec ? { clawhubSpec } : {}),
...(npmSpec ? { npmSpec } : {}),
...(localPath ? { localPath } : {}),
...(defaultChoice ? { defaultChoice } : {}),
...(install?.minHostVersion ? { minHostVersion: install.minHostVersion } : {}),
...(install?.expectedIntegrity ? { expectedIntegrity: install.expectedIntegrity } : {}),
...(install?.allowInvalidConfigRecovery === true ? { allowInvalidConfigRecovery: true } : {}),
};
}
export function listOfficialExternalPluginCatalogEntries(): OfficialExternalPluginCatalogEntry[] {
const entries = OFFICIAL_CATALOG_SOURCES.flatMap((source) => parseCatalogEntries(source));
const resolved = new Map<string, OfficialExternalPluginCatalogEntry>();
for (const entry of entries) {
const pluginId = resolveOfficialExternalPluginId(entry);
const key = pluginId ? `${entry.kind ?? "plugin"}:${pluginId}` : `${entry.name ?? ""}`;
if (key && !resolved.has(key)) {
resolved.set(key, entry);
}
}
return [...resolved.values()];
}
export function listOfficialExternalChannelCatalogEntries(): OfficialExternalPluginCatalogEntry[] {
return listOfficialExternalPluginCatalogEntries().filter((entry) =>
Boolean(getOfficialExternalPluginCatalogManifest(entry)?.channel),
);
}
export function listOfficialExternalProviderCatalogEntries(): OfficialExternalPluginCatalogEntry[] {
return listOfficialExternalPluginCatalogEntries().filter(
(entry) => (getOfficialExternalPluginCatalogManifest(entry)?.providers?.length ?? 0) > 0,
);
}
export function getOfficialExternalPluginCatalogEntry(
pluginId: string,
): OfficialExternalPluginCatalogEntry | undefined {
const normalized = pluginId.trim();
if (!normalized) {
return undefined;
}
return listOfficialExternalPluginCatalogEntries().find(
(entry) => resolveOfficialExternalPluginId(entry) === normalized,
);
}

View File

@@ -9,6 +9,12 @@ import {
} from "./install-source-info.js";
import type { InstalledPluginInstallRecordInfo } from "./installed-plugin-index.js";
import type { PluginPackageInstall } from "./manifest.js";
import {
getOfficialExternalPluginCatalogManifest,
listOfficialExternalProviderCatalogEntries,
resolveOfficialExternalPluginInstall,
type OfficialExternalProviderAuthChoice,
} from "./official-external-plugin-catalog.js";
import type { PluginOrigin } from "./plugin-origin.types.js";
import { loadPluginRegistrySnapshot, type PluginRegistryRecord } from "./plugin-registry.js";
import {
@@ -248,6 +254,84 @@ function resolveProviderIndexInstallCatalogEntries(params: {
return entries;
}
function isProviderFlowScope(value: unknown): value is "text-inference" | "image-generation" {
return value === "text-inference" || value === "image-generation";
}
function normalizeProviderAuthChoiceScopes(
scopes: OfficialExternalProviderAuthChoice["onboardingScopes"],
): ("text-inference" | "image-generation")[] | undefined {
if (!Array.isArray(scopes)) {
return undefined;
}
const normalized = scopes.filter(isProviderFlowScope);
return normalized.length > 0 ? normalized : undefined;
}
function resolveOfficialExternalProviderInstallCatalogEntries(params: {
installedPluginIds: ReadonlySet<string>;
seenChoiceIds: ReadonlySet<string>;
}): ProviderInstallCatalogEntry[] {
const entries: ProviderInstallCatalogEntry[] = [];
for (const entry of listOfficialExternalProviderCatalogEntries()) {
const manifest = getOfficialExternalPluginCatalogManifest(entry);
const pluginId = manifest?.plugin?.id?.trim();
if (!manifest || !pluginId || params.installedPluginIds.has(pluginId)) {
continue;
}
const install = resolveOfficialExternalPluginInstall(entry);
if (!install) {
continue;
}
for (const provider of manifest?.providers ?? []) {
const providerId = provider.id?.trim();
const label = provider.name?.trim() || manifest.plugin?.label?.trim() || entry.name?.trim();
if (!providerId || !label) {
continue;
}
for (const choice of provider.authChoices ?? []) {
const methodId = choice.method?.trim();
const choiceId = choice.choiceId?.trim();
const choiceLabel = choice.choiceLabel?.trim();
if (!methodId || !choiceId || !choiceLabel || params.seenChoiceIds.has(choiceId)) {
continue;
}
entries.push({
pluginId,
providerId,
methodId,
choiceId,
choiceLabel,
...(choice.choiceHint ? { choiceHint: choice.choiceHint } : {}),
...(choice.assistantPriority !== undefined
? { assistantPriority: choice.assistantPriority }
: {}),
...(choice.assistantVisibility
? { assistantVisibility: choice.assistantVisibility }
: {}),
...(choice.groupId ? { groupId: choice.groupId } : {}),
...(choice.groupLabel ? { groupLabel: choice.groupLabel } : {}),
...(choice.groupHint ? { groupHint: choice.groupHint } : {}),
...(choice.optionKey ? { optionKey: choice.optionKey } : {}),
...(choice.cliFlag ? { cliFlag: choice.cliFlag } : {}),
...(choice.cliOption ? { cliOption: choice.cliOption } : {}),
...(choice.cliDescription ? { cliDescription: choice.cliDescription } : {}),
...(normalizeProviderAuthChoiceScopes(choice.onboardingScopes)
? { onboardingScopes: normalizeProviderAuthChoiceScopes(choice.onboardingScopes) }
: {}),
label,
origin: "bundled",
install,
installSource: describePluginInstallSource(install, {
expectedPackageName: entry.name,
}),
});
}
}
}
return entries;
}
export function resolveProviderInstallCatalogEntries(
params?: ProviderInstallCatalogParams,
): ProviderInstallCatalogEntry[] {
@@ -274,11 +358,18 @@ export function resolveProviderInstallCatalogEntries(
})
.toSorted((left, right) => left.choiceLabel.localeCompare(right.choiceLabel));
const seenChoiceIds = new Set(manifestEntries.map((entry) => entry.choiceId));
const officialEntries = resolveOfficialExternalProviderInstallCatalogEntries({
installedPluginIds,
seenChoiceIds,
});
for (const entry of officialEntries) {
seenChoiceIds.add(entry.choiceId);
}
const indexEntries = resolveProviderIndexInstallCatalogEntries({
installedPluginIds,
seenChoiceIds,
});
return [...manifestEntries, ...indexEntries].toSorted((left, right) =>
return [...manifestEntries, ...officialEntries, ...indexEntries].toSorted((left, right) =>
left.choiceLabel.localeCompare(right.choiceLabel),
);
}

View File

@@ -101,33 +101,32 @@ describe("buildOfficialChannelCatalog", () => {
},
}),
}),
{
expect.objectContaining({
name: "@openclaw/whatsapp",
version: "2026.3.23",
description: "OpenClaw WhatsApp channel plugin",
openclaw: {
channel: {
source: "official",
openclaw: expect.objectContaining({
channel: expect.objectContaining({
id: "whatsapp",
label: "WhatsApp",
selectionLabel: "WhatsApp (QR link)",
detailLabel: "WhatsApp Web",
docsPath: "/channels/whatsapp",
blurb: "works with your own number; recommend a separate phone + eSIM.",
},
install: {
}),
install: expect.objectContaining({
npmSpec: "@openclaw/whatsapp",
defaultChoice: "npm",
},
},
},
}),
}),
}),
]),
);
});
it("keeps official external catalog npm sources exactly pinned", () => {
it("keeps third-party official external catalog npm sources exactly pinned", () => {
const repoRoot = makeRepoRoot("openclaw-official-channel-catalog-policy-");
const entries = buildOfficialChannelCatalog({ repoRoot }).entries.filter(
(entry) => entry.source === "external",
(entry) => entry.source === "external" && !entry.name?.startsWith("@openclaw/"),
);
expect(entries.length).toBeGreaterThan(0);
@@ -138,6 +137,29 @@ describe("buildOfficialChannelCatalog", () => {
}
});
it("allows official OpenClaw channel npm specs without integrity during launch", () => {
const repoRoot = makeRepoRoot("openclaw-official-channel-catalog-openclaw-policy-");
const twitch = buildOfficialChannelCatalog({ repoRoot }).entries.find(
(entry) => entry.openclaw?.channel?.id === "twitch",
);
expect(twitch).toEqual(
expect.objectContaining({
name: "@openclaw/twitch",
openclaw: expect.objectContaining({
install: {
npmSpec: "@openclaw/twitch",
defaultChoice: "npm",
minHostVersion: ">=2026.4.10",
},
}),
}),
);
const installSource = describePluginInstallSource(twitch?.openclaw?.install ?? {});
expect(installSource.npm?.pinState).toBe("floating-without-integrity");
expect(installSource.warnings).toEqual(["npm-spec-floating", "npm-spec-missing-integrity"]);
});
it("writes the official catalog under dist", () => {
const repoRoot = makeRepoRoot("openclaw-official-channel-catalog-write-");
writeJson(path.join(repoRoot, "extensions", "whatsapp", "package.json"), {
@@ -171,22 +193,28 @@ describe("buildOfficialChannelCatalog", () => {
expect.objectContaining({
name: "openclaw-plugin-yuanbao",
}),
{
expect.objectContaining({
name: "@openclaw/whatsapp",
openclaw: {
channel: {
source: "official",
openclaw: expect.objectContaining({
channel: expect.objectContaining({
id: "whatsapp",
label: "WhatsApp",
selectionLabel: "WhatsApp",
selectionLabel: "WhatsApp (QR link)",
docsPath: "/channels/whatsapp",
blurb: "wa",
},
install: {
}),
install: expect.objectContaining({
npmSpec: "@openclaw/whatsapp",
},
},
},
defaultChoice: "npm",
}),
}),
}),
]),
);
const whatsappEntries = JSON.parse(fs.readFileSync(outputPath, "utf8")).entries.filter(
(entry: { openclaw?: { channel?: { id?: string } } }) =>
entry.openclaw?.channel?.id === "whatsapp",
);
expect(whatsappEntries).toHaveLength(1);
});
});

View File

@@ -485,6 +485,9 @@ describe("collectMissingPackPaths", () => {
"dist/control-ui/index.html",
"scripts/npm-runner.mjs",
"scripts/preinstall-package-manager-warning.mjs",
"scripts/lib/official-external-channel-catalog.json",
"scripts/lib/official-external-plugin-catalog.json",
"scripts/lib/official-external-provider-catalog.json",
"scripts/lib/package-dist-imports.mjs",
"scripts/postinstall-bundled-plugins.mjs",
"dist/task-registry-control.runtime.js",
@@ -517,6 +520,9 @@ describe("collectMissingPackPaths", () => {
...WORKSPACE_TEMPLATE_PACK_PATHS,
"scripts/npm-runner.mjs",
"scripts/preinstall-package-manager-warning.mjs",
"scripts/lib/official-external-channel-catalog.json",
"scripts/lib/official-external-plugin-catalog.json",
"scripts/lib/official-external-provider-catalog.json",
"scripts/lib/package-dist-imports.mjs",
"scripts/postinstall-bundled-plugins.mjs",
"dist/plugin-sdk/root-alias.cjs",