mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:50:42 +00:00
fix(onboard): recover externalized channel plugin from stale config (#78328)
When a user's config has a stale `channels.<id>` entry (e.g. `appId` or tokens left over from an earlier install) and the plugin is no longer on disk -- for instance because the externalized npm package was uninstalled or pruned during an upgrade -- `handleChannelChoice` used to dead-end with "<channel> plugin not available." and leave onboard stuck until the user manually deleted the config entry and re-ran the CLI. Two discovery paths are affected: 1. The `installedCatalogEntry` branch: when `loadScopedChannelPlugin` returns null but the catalog entry still carries `install.npmSpec`, fall back to `ensureChannelSetupPluginInstalled` with the same entry so onboard can reinstall the plugin from the official catalog. 2. The bundled-enable `else` branch: with a non-empty `channels.<id>` record, `isStaticallyChannelConfigured` drops the channel from `installableCatalogEntries`; if the plugin is also missing on disk (so it never enters `manifestInstalledIds`), both discovery buckets come back empty and the channel falls through to `enableBundledPluginForSetup`. Before delegating to that bundled path, consult the trusted catalog via `getTrustedChannelPluginCatalogEntry` and, if an `install.npmSpec` is available, drive the same catalog install flow used by a fresh pick of the channel. Both new fallbacks re-apply the `resolveConfigDisabledHint` guard that `enableBundledPluginForSetup` has always enforced, so an operator-disabled channel (`plugins.entries.<id>.enabled === false` or explicit `channels.<id>.enabled === false`) with a stale config entry cannot be silently reinstalled or re-enabled through the catalog path. Both branches also keep their previous behavior when no catalog npm spec is available (e.g. purely bundled channels), so this change is a superset of the old flow rather than a replacement. Affects all externalized channel plugins listed in the core package's `files` exclusion (qqbot, bluebubbles, discord, whatsapp, line, msteams, feishu, googlechat, nostr, zalo, zalouser, synology-chat, tlon, twitch, and similar).
This commit is contained in:
@@ -85,6 +85,9 @@ const resolveDefaultAgentId = vi.hoisted(() => vi.fn((_cfg?: unknown) => "defaul
|
||||
const listTrustedChannelPluginCatalogEntries = vi.hoisted(() =>
|
||||
vi.fn((_params?: unknown): unknown[] => []),
|
||||
);
|
||||
const getTrustedChannelPluginCatalogEntry = vi.hoisted(() =>
|
||||
vi.fn((_channelId: string, _params?: unknown): unknown => undefined),
|
||||
);
|
||||
const getChannelSetupPlugin = vi.hoisted(() => vi.fn((_channel?: unknown) => undefined));
|
||||
const listChannelSetupPlugins = vi.hoisted(() => vi.fn((): unknown[] => []));
|
||||
const listActiveChannelSetupPlugins = vi.hoisted(() => vi.fn((): unknown[] => []));
|
||||
@@ -162,6 +165,8 @@ vi.mock("../commands/channel-setup/registry.js", () => ({
|
||||
vi.mock("../commands/channel-setup/trusted-catalog.js", () => ({
|
||||
listTrustedChannelPluginCatalogEntries: (params?: unknown) =>
|
||||
listTrustedChannelPluginCatalogEntries(params),
|
||||
getTrustedChannelPluginCatalogEntry: (channelId: string, params?: unknown) =>
|
||||
getTrustedChannelPluginCatalogEntry(channelId, params),
|
||||
}));
|
||||
|
||||
vi.mock("../config/channel-configured.js", () => ({
|
||||
@@ -661,4 +666,461 @@ describe("setupChannels workspace shadow exclusion", () => {
|
||||
"Channel setup",
|
||||
);
|
||||
});
|
||||
|
||||
it(
|
||||
"reinstalls the external plugin via catalog when a stale channel config " +
|
||||
"declares an already-installed plugin whose runtime cannot be loaded",
|
||||
async () => {
|
||||
// Regression: users who uninstalled an externalized channel plugin
|
||||
// (qqbot / bluebubbles / discord / ...) while a non-empty
|
||||
// `channels.<id>` entry remained in their config got dead-ended with
|
||||
// "<channel> plugin not available" because the installed-catalog
|
||||
// branch did not fall back to the catalog install flow.
|
||||
const configure = vi.fn(async ({ cfg }: { cfg: Record<string, unknown> }) => ({
|
||||
cfg: { ...cfg, channels: { "external-chat": { token: "secret" } } },
|
||||
}));
|
||||
const externalChatPlugin = makeSetupPlugin({
|
||||
id: "external-chat",
|
||||
label: "External Chat",
|
||||
setupWizard: {
|
||||
channel: "external-chat",
|
||||
getStatus: vi.fn(async () => ({
|
||||
channel: "external-chat",
|
||||
configured: false,
|
||||
statusLines: [],
|
||||
})),
|
||||
configure,
|
||||
} as ChannelSetupPlugin["setupWizard"],
|
||||
});
|
||||
const installedCatalogEntry = makeCatalogEntry("external-chat", "External Chat", {
|
||||
pluginId: "@vendor/external-chat-plugin",
|
||||
install: { npmSpec: "@vendor/external-chat-plugin" },
|
||||
});
|
||||
resolveChannelSetupEntries.mockReturnValue(
|
||||
externalChatSetupEntries({
|
||||
installedCatalogEntries: [installedCatalogEntry],
|
||||
installedCatalogById: new Map([["external-chat", installedCatalogEntry]]),
|
||||
}),
|
||||
);
|
||||
// First snapshot (pre-install) is empty — plugin runtime is gone.
|
||||
// After `ensureChannelSetupPluginInstalled` runs, subsequent snapshots
|
||||
// resolve the plugin as expected.
|
||||
loadChannelSetupPluginRegistrySnapshotForChannel
|
||||
.mockReturnValueOnce(makePluginRegistry())
|
||||
.mockReturnValue(
|
||||
makePluginRegistry({
|
||||
channels: [
|
||||
{
|
||||
pluginId: "@vendor/external-chat-plugin",
|
||||
source: "global",
|
||||
plugin: externalChatPlugin,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
ensureChannelSetupPluginInstalled.mockResolvedValueOnce({
|
||||
cfg: {},
|
||||
installed: true,
|
||||
pluginId: "@vendor/external-chat-plugin",
|
||||
status: "installed",
|
||||
});
|
||||
isChannelConfigured.mockReturnValue(false);
|
||||
const note = vi.fn(async () => undefined);
|
||||
const select = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce("external-chat")
|
||||
.mockResolvedValueOnce("__done__");
|
||||
|
||||
await setupChannels(
|
||||
{} as never,
|
||||
{} as never,
|
||||
{
|
||||
confirm: vi.fn(async () => true),
|
||||
note,
|
||||
select,
|
||||
} as never,
|
||||
{
|
||||
deferStatusUntilSelection: true,
|
||||
skipConfirm: true,
|
||||
skipDmPolicyPrompt: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(ensureChannelSetupPluginInstalled).toHaveBeenCalledTimes(1);
|
||||
expect(ensureChannelSetupPluginInstalled).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
entry: expect.objectContaining({
|
||||
id: "external-chat",
|
||||
install: expect.objectContaining({ npmSpec: "@vendor/external-chat-plugin" }),
|
||||
}),
|
||||
autoConfirmSingleSource: true,
|
||||
}),
|
||||
);
|
||||
expect(note).not.toHaveBeenCalledWith("external-chat plugin not available.", "Channel setup");
|
||||
expect(configure).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
|
||||
it(
|
||||
"returns to channel selection when catalog-fallback install is declined " +
|
||||
"from the installed-catalog branch",
|
||||
async () => {
|
||||
const installedCatalogEntry = makeCatalogEntry("external-chat", "External Chat", {
|
||||
pluginId: "@vendor/external-chat-plugin",
|
||||
install: { npmSpec: "@vendor/external-chat-plugin" },
|
||||
});
|
||||
resolveChannelSetupEntries.mockReturnValue(
|
||||
externalChatSetupEntries({
|
||||
installedCatalogEntries: [installedCatalogEntry],
|
||||
installedCatalogById: new Map([["external-chat", installedCatalogEntry]]),
|
||||
}),
|
||||
);
|
||||
loadChannelSetupPluginRegistrySnapshotForChannel.mockReturnValue(makePluginRegistry());
|
||||
ensureChannelSetupPluginInstalled.mockResolvedValueOnce({
|
||||
cfg: {},
|
||||
installed: false,
|
||||
pluginId: "@vendor/external-chat-plugin",
|
||||
status: "skipped",
|
||||
});
|
||||
isChannelConfigured.mockReturnValue(false);
|
||||
let quickstartSelectionCount = 0;
|
||||
const select = vi.fn(async ({ message }: { message: string }) => {
|
||||
if (message === "Select channel (QuickStart)") {
|
||||
quickstartSelectionCount += 1;
|
||||
if (quickstartSelectionCount === 1) {
|
||||
return "external-chat";
|
||||
}
|
||||
}
|
||||
return "__skip__";
|
||||
});
|
||||
const note = vi.fn(async () => undefined);
|
||||
|
||||
await setupChannels(
|
||||
{} as never,
|
||||
{} as never,
|
||||
{
|
||||
confirm: vi.fn(async () => true),
|
||||
note,
|
||||
select,
|
||||
} as never,
|
||||
{
|
||||
quickstartDefaults: true,
|
||||
skipConfirm: true,
|
||||
skipDmPolicyPrompt: true,
|
||||
},
|
||||
);
|
||||
|
||||
// Install prompt ran once, was declined; user returned to channel
|
||||
// selection (quickstartSelectionCount === 2) rather than being
|
||||
// dead-ended with a "plugin not available" note.
|
||||
expect(ensureChannelSetupPluginInstalled).toHaveBeenCalledTimes(1);
|
||||
expect(quickstartSelectionCount).toBe(2);
|
||||
expect(note).not.toHaveBeenCalledWith("external-chat plugin not available.", "Channel setup");
|
||||
},
|
||||
);
|
||||
|
||||
it(
|
||||
"auto-installs external plugin from catalog when both discovery buckets " +
|
||||
"are empty due to a stale `channels.<id>` config entry",
|
||||
async () => {
|
||||
// Regression test for the real-world repro: `channels.qqbot` has stale
|
||||
// fields (appId/secret) from an earlier install, so
|
||||
// `isStaticallyChannelConfigured` drops qqbot from
|
||||
// `installableCatalogEntries`; qqbot isn't on disk either, so
|
||||
// `manifestInstalledIds` doesn't include it. Both discovery buckets
|
||||
// come back empty, but the channel is still selectable (entries list
|
||||
// does not apply the static-config filter). Before the fix, onboard
|
||||
// fell through to `enableBundledPluginForSetup` which just printed
|
||||
// "qqbot plugin not available." and exited the flow. The fix consults
|
||||
// the catalog directly and drives `ensureChannelSetupPluginInstalled`.
|
||||
const configure = vi.fn(async ({ cfg }: { cfg: Record<string, unknown> }) => ({
|
||||
cfg: { ...cfg, channels: { "external-chat": { token: "secret" } } },
|
||||
}));
|
||||
const externalChatPlugin = makeSetupPlugin({
|
||||
id: "external-chat",
|
||||
label: "External Chat",
|
||||
setupWizard: {
|
||||
channel: "external-chat",
|
||||
getStatus: vi.fn(async () => ({
|
||||
channel: "external-chat",
|
||||
configured: false,
|
||||
statusLines: [],
|
||||
})),
|
||||
configure,
|
||||
} as ChannelSetupPlugin["setupWizard"],
|
||||
});
|
||||
// Entries list exposes the channel in the menu, but BOTH discovery
|
||||
// buckets are empty — faithfully reproducing the observed bug.
|
||||
resolveChannelSetupEntries.mockReturnValue(
|
||||
makeChannelSetupEntries({
|
||||
entries: [
|
||||
{
|
||||
id: "external-chat",
|
||||
meta: makeMeta("external-chat", "External Chat"),
|
||||
},
|
||||
],
|
||||
installedCatalogEntries: [],
|
||||
installableCatalogEntries: [],
|
||||
installedCatalogById: new Map(),
|
||||
installableCatalogById: new Map(),
|
||||
}),
|
||||
);
|
||||
const fallbackCatalogEntry = makeCatalogEntry("external-chat", "External Chat", {
|
||||
pluginId: "@vendor/external-chat-plugin",
|
||||
install: { npmSpec: "@vendor/external-chat-plugin" },
|
||||
});
|
||||
getTrustedChannelPluginCatalogEntry.mockReturnValue(fallbackCatalogEntry);
|
||||
ensureChannelSetupPluginInstalled.mockResolvedValueOnce({
|
||||
cfg: {},
|
||||
installed: true,
|
||||
pluginId: "@vendor/external-chat-plugin",
|
||||
status: "installed",
|
||||
});
|
||||
loadChannelSetupPluginRegistrySnapshotForChannel.mockReturnValue(
|
||||
makePluginRegistry({
|
||||
channels: [
|
||||
{
|
||||
pluginId: "@vendor/external-chat-plugin",
|
||||
source: "global",
|
||||
plugin: externalChatPlugin,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
isChannelConfigured.mockReturnValue(false);
|
||||
const note = vi.fn(async () => undefined);
|
||||
const select = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce("external-chat")
|
||||
.mockResolvedValueOnce("__done__");
|
||||
|
||||
await setupChannels(
|
||||
{} as never,
|
||||
{} as never,
|
||||
{
|
||||
confirm: vi.fn(async () => true),
|
||||
note,
|
||||
select,
|
||||
} as never,
|
||||
{
|
||||
deferStatusUntilSelection: true,
|
||||
skipConfirm: true,
|
||||
skipDmPolicyPrompt: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(getTrustedChannelPluginCatalogEntry).toHaveBeenCalledWith(
|
||||
"external-chat",
|
||||
expect.objectContaining({ workspaceDir: "/tmp/openclaw-workspace" }),
|
||||
);
|
||||
expect(ensureChannelSetupPluginInstalled).toHaveBeenCalledTimes(1);
|
||||
expect(ensureChannelSetupPluginInstalled).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
entry: expect.objectContaining({
|
||||
id: "external-chat",
|
||||
install: expect.objectContaining({ npmSpec: "@vendor/external-chat-plugin" }),
|
||||
}),
|
||||
autoConfirmSingleSource: true,
|
||||
}),
|
||||
);
|
||||
expect(note).not.toHaveBeenCalledWith("external-chat plugin not available.", "Channel setup");
|
||||
expect(configure).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
|
||||
it(
|
||||
"returns to channel selection when the catalog-fallback install is " +
|
||||
"declined from the bundled-enable branch",
|
||||
async () => {
|
||||
resolveChannelSetupEntries.mockReturnValue(
|
||||
makeChannelSetupEntries({
|
||||
entries: [
|
||||
{
|
||||
id: "external-chat",
|
||||
meta: makeMeta("external-chat", "External Chat"),
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const fallbackCatalogEntry = makeCatalogEntry("external-chat", "External Chat", {
|
||||
pluginId: "@vendor/external-chat-plugin",
|
||||
install: { npmSpec: "@vendor/external-chat-plugin" },
|
||||
});
|
||||
getTrustedChannelPluginCatalogEntry.mockReturnValue(fallbackCatalogEntry);
|
||||
ensureChannelSetupPluginInstalled.mockResolvedValueOnce({
|
||||
cfg: {},
|
||||
installed: false,
|
||||
pluginId: "@vendor/external-chat-plugin",
|
||||
status: "skipped",
|
||||
});
|
||||
isChannelConfigured.mockReturnValue(false);
|
||||
let quickstartSelectionCount = 0;
|
||||
const select = vi.fn(async ({ message }: { message: string }) => {
|
||||
if (message === "Select channel (QuickStart)") {
|
||||
quickstartSelectionCount += 1;
|
||||
if (quickstartSelectionCount === 1) {
|
||||
return "external-chat";
|
||||
}
|
||||
}
|
||||
return "__skip__";
|
||||
});
|
||||
const note = vi.fn(async () => undefined);
|
||||
|
||||
await setupChannels(
|
||||
{} as never,
|
||||
{} as never,
|
||||
{
|
||||
confirm: vi.fn(async () => true),
|
||||
note,
|
||||
select,
|
||||
} as never,
|
||||
{
|
||||
quickstartDefaults: true,
|
||||
skipConfirm: true,
|
||||
skipDmPolicyPrompt: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(ensureChannelSetupPluginInstalled).toHaveBeenCalledTimes(1);
|
||||
expect(quickstartSelectionCount).toBe(2);
|
||||
expect(note).not.toHaveBeenCalledWith("external-chat plugin not available.", "Channel setup");
|
||||
},
|
||||
);
|
||||
|
||||
it(
|
||||
"refuses catalog-fallback install from empty discovery buckets when the " +
|
||||
"channel is explicitly disabled in config",
|
||||
async () => {
|
||||
// Review-note regression: the bundled-enable `else` branch used to rely
|
||||
// on `enableBundledPluginForSetup`'s own disabled-config guard. The
|
||||
// new catalog fallback runs BEFORE that helper, so it must re-apply
|
||||
// the same `resolveConfigDisabledHint` check — otherwise an operator-
|
||||
// disabled channel with a stale `channels.<id>` entry could be
|
||||
// reinstalled/re-enabled silently.
|
||||
//
|
||||
// We intentionally do NOT pass `deferStatusUntilSelection` here so the
|
||||
// top-level `deferredDisabledHint` guard in `handleChannelChoice` is
|
||||
// bypassed. That isolates the guard newly added inside the catalog
|
||||
// fallback; without it, the test would pass against an unguarded
|
||||
// fallback because the QuickStart path's early guard would catch the
|
||||
// disabled state first.
|
||||
resolveChannelSetupEntries.mockReturnValue(
|
||||
makeChannelSetupEntries({
|
||||
entries: [
|
||||
{
|
||||
id: "external-chat",
|
||||
meta: makeMeta("external-chat", "External Chat"),
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const fallbackCatalogEntry = makeCatalogEntry("external-chat", "External Chat", {
|
||||
pluginId: "@vendor/external-chat-plugin",
|
||||
install: { npmSpec: "@vendor/external-chat-plugin" },
|
||||
});
|
||||
getTrustedChannelPluginCatalogEntry.mockReturnValue(fallbackCatalogEntry);
|
||||
const select = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce("external-chat")
|
||||
.mockResolvedValueOnce("__done__");
|
||||
const note = vi.fn(async () => undefined);
|
||||
// Operator has explicitly disabled the plugin while a stale
|
||||
// `channels.<id>` entry lingers in config.
|
||||
const cfg = {
|
||||
plugins: { entries: { "external-chat": { enabled: false } } },
|
||||
channels: {
|
||||
"external-chat": {
|
||||
enabled: true,
|
||||
appId: "999999",
|
||||
clientSecret: "stale",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await setupChannels(
|
||||
cfg as never,
|
||||
{} as never,
|
||||
{
|
||||
confirm: vi.fn(async () => true),
|
||||
note,
|
||||
select,
|
||||
} as never,
|
||||
{
|
||||
skipConfirm: true,
|
||||
skipDmPolicyPrompt: true,
|
||||
},
|
||||
);
|
||||
|
||||
// The new catalog fallback must NOT drive an install.
|
||||
expect(ensureChannelSetupPluginInstalled).not.toHaveBeenCalled();
|
||||
// Instead, the same "Enable it before setup." note used by
|
||||
// `enableBundledPluginForSetup` should be shown.
|
||||
expect(note).toHaveBeenCalledWith(
|
||||
"external-chat cannot be configured while plugin disabled. Enable it before setup.",
|
||||
"Channel setup",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it(
|
||||
"refuses the installed-catalog install fallback when the channel is " +
|
||||
"explicitly disabled in config",
|
||||
async () => {
|
||||
// Symmetric guard for the `installedCatalogEntry` fallback path. When
|
||||
// `loadScopedChannelPlugin` returns null and the catalog entry carries
|
||||
// `install.npmSpec`, the fix reaches for the catalog install flow —
|
||||
// but must first respect an operator-level disable, matching the
|
||||
// guard inside `enableBundledPluginForSetup`.
|
||||
//
|
||||
// As in the sibling test, we omit `deferStatusUntilSelection` to skip
|
||||
// the top-level guard and isolate the new inline guard.
|
||||
const installedCatalogEntry = makeCatalogEntry("external-chat", "External Chat", {
|
||||
pluginId: "@vendor/external-chat-plugin",
|
||||
install: { npmSpec: "@vendor/external-chat-plugin" },
|
||||
});
|
||||
resolveChannelSetupEntries.mockReturnValue(
|
||||
externalChatSetupEntries({
|
||||
installedCatalogEntries: [installedCatalogEntry],
|
||||
installedCatalogById: new Map([["external-chat", installedCatalogEntry]]),
|
||||
}),
|
||||
);
|
||||
loadChannelSetupPluginRegistrySnapshotForChannel.mockReturnValue(makePluginRegistry());
|
||||
isChannelConfigured.mockReturnValue(false);
|
||||
const select = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce("external-chat")
|
||||
.mockResolvedValueOnce("__done__");
|
||||
const note = vi.fn(async () => undefined);
|
||||
const cfg = {
|
||||
plugins: { entries: { "external-chat": { enabled: false } } },
|
||||
channels: {
|
||||
"external-chat": {
|
||||
enabled: true,
|
||||
appId: "999999",
|
||||
clientSecret: "stale",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await setupChannels(
|
||||
cfg as never,
|
||||
{} as never,
|
||||
{
|
||||
confirm: vi.fn(async () => true),
|
||||
note,
|
||||
select,
|
||||
} as never,
|
||||
{
|
||||
skipConfirm: true,
|
||||
skipDmPolicyPrompt: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(ensureChannelSetupPluginInstalled).not.toHaveBeenCalled();
|
||||
expect(note).toHaveBeenCalledWith(
|
||||
"external-chat cannot be configured while plugin disabled. Enable it before setup.",
|
||||
"Channel setup",
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -16,7 +16,10 @@ import {
|
||||
loadChannelSetupPluginRegistrySnapshotForChannel,
|
||||
} from "../commands/channel-setup/plugin-install.js";
|
||||
import { resolveChannelSetupWizardAdapterForPlugin } from "../commands/channel-setup/registry.js";
|
||||
import { listTrustedChannelPluginCatalogEntries } from "../commands/channel-setup/trusted-catalog.js";
|
||||
import {
|
||||
getTrustedChannelPluginCatalogEntry,
|
||||
listTrustedChannelPluginCatalogEntries,
|
||||
} from "../commands/channel-setup/trusted-catalog.js";
|
||||
import type {
|
||||
ChannelSetupConfiguredResult,
|
||||
ChannelSetupResult,
|
||||
@@ -584,16 +587,99 @@ export async function setupChannels(
|
||||
await loadScopedChannelPlugin(channel, result.pluginId ?? catalogEntry.pluginId);
|
||||
await refreshStatus(channel);
|
||||
} else if (installedCatalogEntry) {
|
||||
const plugin = await loadScopedChannelPlugin(channel, installedCatalogEntry.pluginId);
|
||||
let plugin = await loadScopedChannelPlugin(channel, installedCatalogEntry.pluginId);
|
||||
if (!plugin && installedCatalogEntry.install?.npmSpec) {
|
||||
// The channel is recorded in the user's config (e.g. a stale
|
||||
// `channels.<id>` entry left over from a previous install) but the
|
||||
// plugin runtime cannot be loaded from disk — typically because the
|
||||
// externalized npm package was uninstalled or pruned during an
|
||||
// upgrade. Rather than dead-ending with "plugin not available", fall
|
||||
// back to the catalog-driven install flow so onboard can recover by
|
||||
// reinstalling the official external plugin.
|
||||
//
|
||||
// Preserve the same disabled-config guard used by
|
||||
// `enableBundledPluginForSetup` so an operator-disabled channel
|
||||
// cannot be silently reinstalled/re-enabled through this path.
|
||||
const disabledHint = resolveConfigDisabledHint(channel);
|
||||
if (disabledHint) {
|
||||
await prompter.note(
|
||||
`${channel} cannot be configured while ${disabledHint}. Enable it before setup.`,
|
||||
"Channel setup",
|
||||
);
|
||||
return "done";
|
||||
}
|
||||
const workspaceDir = resolveWorkspaceDir();
|
||||
const result = await ensureChannelSetupPluginInstalled({
|
||||
cfg: next,
|
||||
entry: installedCatalogEntry,
|
||||
prompter,
|
||||
runtime,
|
||||
workspaceDir,
|
||||
autoConfirmSingleSource: true,
|
||||
});
|
||||
next = result.cfg;
|
||||
if (!result.installed) {
|
||||
return "retry_selection";
|
||||
}
|
||||
plugin = await loadScopedChannelPlugin(
|
||||
channel,
|
||||
result.pluginId ?? installedCatalogEntry.pluginId,
|
||||
);
|
||||
}
|
||||
if (!plugin) {
|
||||
await prompter.note(`${channel} plugin not available.`, "Channel setup");
|
||||
return "done";
|
||||
}
|
||||
await refreshStatus(channel);
|
||||
} else {
|
||||
const enabled = await enableBundledPluginForSetup(channel);
|
||||
if (!enabled) {
|
||||
return "done";
|
||||
// Neither discovery bucket yielded an entry for this channel. This can
|
||||
// happen when `channels.<id>` in user config carries stale fields (e.g.
|
||||
// `appId`, tokens) left over from a previous install: `isStatically-
|
||||
// ChannelConfigured` returns true, which removes the channel from the
|
||||
// `installableCatalogEntries` bucket, while a missing/pruned plugin on
|
||||
// disk keeps it out of `installedCatalogEntries`. Before falling back
|
||||
// to the bundled-plugin enable path, consult the catalog directly so
|
||||
// users with a stale config entry for an externalized channel (qqbot,
|
||||
// bluebubbles, discord, whatsapp, ...) still get auto-install instead
|
||||
// of a dead-end "plugin not available" note.
|
||||
const fallbackCatalogEntry = getTrustedChannelPluginCatalogEntry(channel, {
|
||||
cfg: next,
|
||||
workspaceDir: resolveWorkspaceDir(),
|
||||
});
|
||||
if (fallbackCatalogEntry?.install?.npmSpec) {
|
||||
// Preserve the same disabled-config guard used by
|
||||
// `enableBundledPluginForSetup` so an operator-disabled channel
|
||||
// cannot be silently reinstalled/re-enabled through this path. This
|
||||
// mirrors the guard that was previously enforced inside the
|
||||
// bundled-enable fallback.
|
||||
const disabledHint = resolveConfigDisabledHint(channel);
|
||||
if (disabledHint) {
|
||||
await prompter.note(
|
||||
`${channel} cannot be configured while ${disabledHint}. Enable it before setup.`,
|
||||
"Channel setup",
|
||||
);
|
||||
return "done";
|
||||
}
|
||||
const workspaceDir = resolveWorkspaceDir();
|
||||
const result = await ensureChannelSetupPluginInstalled({
|
||||
cfg: next,
|
||||
entry: fallbackCatalogEntry,
|
||||
prompter,
|
||||
runtime,
|
||||
workspaceDir,
|
||||
autoConfirmSingleSource: true,
|
||||
});
|
||||
next = result.cfg;
|
||||
if (!result.installed) {
|
||||
return "retry_selection";
|
||||
}
|
||||
await loadScopedChannelPlugin(channel, result.pluginId ?? fallbackCatalogEntry.pluginId);
|
||||
await refreshStatus(channel);
|
||||
} else {
|
||||
const enabled = await enableBundledPluginForSetup(channel);
|
||||
if (!enabled) {
|
||||
return "done";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user