refactor: install heavy plugins on demand
This commit is contained in:
parent
83c5bc946d
commit
b7ca56f662
@ -9,6 +9,21 @@ title: "WhatsApp"
|
||||
|
||||
Status: production-ready via WhatsApp Web (Baileys). Gateway owns linked session(s).
|
||||
|
||||
## Install (on demand)
|
||||
|
||||
- Onboarding (`openclaw onboard`) and `openclaw channels add --channel whatsapp`
|
||||
prompt to install the WhatsApp plugin the first time you select it.
|
||||
- `openclaw channels login --channel whatsapp` also offers the install flow when
|
||||
the plugin is not present yet.
|
||||
- Dev channel + git checkout: defaults to the local plugin path.
|
||||
- Stable/Beta: defaults to the npm package `@openclaw/whatsapp`.
|
||||
|
||||
Manual install stays available:
|
||||
|
||||
```bash
|
||||
openclaw plugins install @openclaw/whatsapp
|
||||
```
|
||||
|
||||
<CardGroup cols={3}>
|
||||
<Card title="Pairing" icon="link" href="/channels/pairing">
|
||||
Default DM policy is pairing for unknown senders.
|
||||
|
||||
@ -76,6 +76,12 @@ These are published to npm and installed with `openclaw plugins install`:
|
||||
|
||||
Microsoft Teams is plugin-only as of 2026.1.15.
|
||||
|
||||
Packaged installs also ship install-on-demand metadata for heavyweight official
|
||||
plugins. Today that includes WhatsApp and `memory-lancedb`: onboarding,
|
||||
`openclaw channels add`, `openclaw channels login --channel whatsapp`, and
|
||||
other channel setup flows prompt to install them when first used instead of
|
||||
shipping their full runtime trees inside the main npm tarball.
|
||||
|
||||
### Bundled plugins
|
||||
|
||||
These ship with OpenClaw and are enabled by default unless noted.
|
||||
@ -83,7 +89,7 @@ These ship with OpenClaw and are enabled by default unless noted.
|
||||
**Memory:**
|
||||
|
||||
- `memory-core` -- bundled memory search (default via `plugins.slots.memory`)
|
||||
- `memory-lancedb` -- long-term memory with auto-recall/capture (set `plugins.slots.memory = "memory-lancedb"`)
|
||||
- `memory-lancedb` -- install-on-demand long-term memory with auto-recall/capture (set `plugins.slots.memory = "memory-lancedb"`)
|
||||
|
||||
**Model providers** (all enabled by default):
|
||||
|
||||
@ -193,8 +199,10 @@ enablement via `plugins.entries.<id>.enabled` or
|
||||
Bundled plugin runtime dependencies are owned by each plugin package. Packaged
|
||||
builds stage opted-in bundled dependencies under
|
||||
`dist/extensions/<id>/node_modules` instead of requiring mirrored copies in the
|
||||
root package. npm artifacts ship the built `dist/extensions/*` tree; source
|
||||
`extensions/*` directories stay in source checkouts only.
|
||||
root package. Very large official plugins can ship as metadata-only bundled
|
||||
entries and install their runtime package on demand. npm artifacts ship the
|
||||
built `dist/extensions/*` tree; source `extensions/*` directories stay in source
|
||||
checkouts only.
|
||||
|
||||
Installed plugins are enabled by default, but can be disabled the same way.
|
||||
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/memory-lancedb",
|
||||
"version": "2026.3.14",
|
||||
"private": true,
|
||||
"description": "OpenClaw LanceDB-backed long-term memory plugin with auto-recall/capture",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
@ -12,6 +11,14 @@
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
],
|
||||
"install": {
|
||||
"npmSpec": "@openclaw/memory-lancedb",
|
||||
"localPath": "extensions/memory-lancedb",
|
||||
"defaultChoice": "npm"
|
||||
},
|
||||
"release": {
|
||||
"publishToNpm": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -21,6 +21,14 @@
|
||||
"docsLabel": "whatsapp",
|
||||
"blurb": "works with your own number; recommend a separate phone + eSIM.",
|
||||
"systemImage": "message"
|
||||
},
|
||||
"install": {
|
||||
"npmSpec": "@openclaw/whatsapp",
|
||||
"localPath": "extensions/whatsapp",
|
||||
"defaultChoice": "npm"
|
||||
},
|
||||
"release": {
|
||||
"publishToNpm": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -690,7 +690,6 @@
|
||||
"@aws-sdk/client-bedrock": "^3.1011.0",
|
||||
"@clack/prompts": "^1.1.0",
|
||||
"@homebridge/ciao": "^1.3.5",
|
||||
"@lancedb/lancedb": "^0.27.0",
|
||||
"@line/bot-sdk": "^10.6.0",
|
||||
"@lydell/node-pty": "1.2.0-beta.3",
|
||||
"@mariozechner/pi-agent-core": "0.58.0",
|
||||
@ -700,7 +699,6 @@
|
||||
"@modelcontextprotocol/sdk": "1.27.1",
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
"@sinclair/typebox": "0.34.48",
|
||||
"@whiskeysockets/baileys": "7.0.0-rc.9",
|
||||
"ajv": "^8.18.0",
|
||||
"chalk": "^5.6.2",
|
||||
"chokidar": "^5.0.0",
|
||||
|
||||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
@ -40,9 +40,6 @@ importers:
|
||||
'@homebridge/ciao':
|
||||
specifier: ^1.3.5
|
||||
version: 1.3.5
|
||||
'@lancedb/lancedb':
|
||||
specifier: ^0.27.0
|
||||
version: 0.27.0(apache-arrow@18.1.0)
|
||||
'@line/bot-sdk':
|
||||
specifier: ^10.6.0
|
||||
version: 10.6.0
|
||||
@ -73,9 +70,6 @@ importers:
|
||||
'@sinclair/typebox':
|
||||
specifier: 0.34.48
|
||||
version: 0.34.48
|
||||
'@whiskeysockets/baileys':
|
||||
specifier: 7.0.0-rc.9
|
||||
version: 7.0.0-rc.9(audio-decode@2.2.3)(jimp@1.6.0)(sharp@0.34.5)
|
||||
ajv:
|
||||
specifier: ^8.18.0
|
||||
version: 8.18.0
|
||||
|
||||
@ -10,6 +10,7 @@ export const optionalBundledClusters = [
|
||||
"tlon",
|
||||
"twitch",
|
||||
"ui",
|
||||
"whatsapp",
|
||||
"zalouser",
|
||||
];
|
||||
|
||||
|
||||
@ -16,8 +16,6 @@ import { slackSetupPlugin } from "../../../extensions/slack/setup-entry.js";
|
||||
import { synologyChatPlugin } from "../../../extensions/synology-chat/index.js";
|
||||
import { telegramPlugin, setTelegramRuntime } from "../../../extensions/telegram/index.js";
|
||||
import { telegramSetupPlugin } from "../../../extensions/telegram/setup-entry.js";
|
||||
import { whatsappPlugin } from "../../../extensions/whatsapp/index.js";
|
||||
import { whatsappSetupPlugin } from "../../../extensions/whatsapp/setup-entry.js";
|
||||
import { zaloPlugin } from "../../../extensions/zalo/index.js";
|
||||
import type { ChannelId, ChannelPlugin } from "./types.js";
|
||||
|
||||
@ -34,13 +32,11 @@ export const bundledChannelPlugins = [
|
||||
slackPlugin,
|
||||
synologyChatPlugin,
|
||||
telegramPlugin,
|
||||
whatsappPlugin,
|
||||
zaloPlugin,
|
||||
] as ChannelPlugin[];
|
||||
|
||||
export const bundledChannelSetupPlugins = [
|
||||
telegramSetupPlugin,
|
||||
whatsappSetupPlugin,
|
||||
discordSetupPlugin,
|
||||
ircPlugin,
|
||||
slackSetupPlugin,
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { MANIFEST_KEY } from "../../compat/legacy-names.js";
|
||||
import { resolveBundledPluginsDir } from "../../plugins/bundled-dir.js";
|
||||
import { discoverOpenClawPlugins } from "../../plugins/discovery.js";
|
||||
import { loadPluginManifest } from "../../plugins/manifest.js";
|
||||
import type { OpenClawPackageManifest } from "../../plugins/manifest.js";
|
||||
import type { PackageManifest as PluginPackageManifest } from "../../plugins/manifest.js";
|
||||
import type { PluginOrigin } from "../../plugins/types.js";
|
||||
import { isRecord, resolveConfigDir, resolveUserPath } from "../../utils.js";
|
||||
import type { ChannelMeta } from "./types.js";
|
||||
@ -263,6 +265,46 @@ function buildExternalCatalogEntry(entry: ExternalCatalogEntry): ChannelPluginCa
|
||||
});
|
||||
}
|
||||
|
||||
function loadBundledMetadataCatalogEntries(options: CatalogOptions): ChannelPluginCatalogEntry[] {
|
||||
const bundledDir = resolveBundledPluginsDir(options.env ?? process.env);
|
||||
if (!bundledDir || !fs.existsSync(bundledDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const entries: ChannelPluginCatalogEntry[] = [];
|
||||
for (const dirent of fs.readdirSync(bundledDir, { withFileTypes: true })) {
|
||||
if (!dirent.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
const pluginDir = path.join(bundledDir, dirent.name);
|
||||
const packageJsonPath = path.join(pluginDir, "package.json");
|
||||
if (!fs.existsSync(packageJsonPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let packageJson: PluginPackageManifest;
|
||||
try {
|
||||
packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as PluginPackageManifest;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const entry = buildCatalogEntry({
|
||||
packageName: packageJson.name,
|
||||
packageDir: pluginDir,
|
||||
rootDir: pluginDir,
|
||||
origin: "bundled",
|
||||
workspaceDir: options.workspaceDir,
|
||||
packageManifest: packageJson.openclaw,
|
||||
});
|
||||
if (entry) {
|
||||
entries.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function buildChannelUiCatalog(
|
||||
plugins: Array<{ id: string; meta: ChannelMeta }>,
|
||||
): ChannelUiCatalog {
|
||||
@ -312,6 +354,14 @@ export function listChannelPluginCatalogEntries(
|
||||
}
|
||||
}
|
||||
|
||||
for (const entry of loadBundledMetadataCatalogEntries(options)) {
|
||||
const priority = ORIGIN_PRIORITY.bundled ?? 99;
|
||||
const existing = resolved.get(entry.id);
|
||||
if (!existing || priority < existing.priority) {
|
||||
resolved.set(entry.id, { entry, priority });
|
||||
}
|
||||
}
|
||||
|
||||
const externalEntries = loadExternalCatalogEntries(options)
|
||||
.map((entry) => buildExternalCatalogEntry(entry))
|
||||
.filter((entry): entry is ChannelPluginCatalogEntry => Boolean(entry));
|
||||
|
||||
@ -279,6 +279,54 @@ describe("channel plugin catalog", () => {
|
||||
|
||||
expect(ids).toContain("default-env-demo");
|
||||
});
|
||||
|
||||
it("includes bundled metadata-only channel entries even when the runtime entrypoint is omitted", () => {
|
||||
const packageRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bundled-catalog-"));
|
||||
const bundledDir = path.join(packageRoot, "dist", "extensions", "whatsapp");
|
||||
fs.mkdirSync(bundledDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(packageRoot, "package.json"),
|
||||
JSON.stringify({ name: "openclaw" }),
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(bundledDir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "@openclaw/whatsapp",
|
||||
openclaw: {
|
||||
extensions: ["./index.js"],
|
||||
channel: {
|
||||
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: {
|
||||
npmSpec: "@openclaw/whatsapp",
|
||||
defaultChoice: "npm",
|
||||
},
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(bundledDir, "openclaw.plugin.json"),
|
||||
JSON.stringify({ id: "whatsapp", channels: ["whatsapp"], configSchema: {} }),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const entry = listChannelPluginCatalogEntries({
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(packageRoot, "dist", "extensions"),
|
||||
},
|
||||
}).find((item) => item.id === "whatsapp");
|
||||
|
||||
expect(entry?.install.npmSpec).toBe("@openclaw/whatsapp");
|
||||
expect(entry?.pluginId).toBe("whatsapp");
|
||||
});
|
||||
});
|
||||
|
||||
const emptyRegistry = createTestRegistry([]);
|
||||
|
||||
@ -2,17 +2,33 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { runChannelLogin, runChannelLogout } from "./channel-auth.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
resolveAgentWorkspaceDir: vi.fn(),
|
||||
resolveDefaultAgentId: vi.fn(),
|
||||
getChannelPluginCatalogEntry: vi.fn(),
|
||||
resolveChannelDefaultAccountId: vi.fn(),
|
||||
getChannelPlugin: vi.fn(),
|
||||
normalizeChannelId: vi.fn(),
|
||||
loadConfig: vi.fn(),
|
||||
writeConfigFile: vi.fn(),
|
||||
resolveMessageChannelSelection: vi.fn(),
|
||||
setVerbose: vi.fn(),
|
||||
createClackPrompter: vi.fn(),
|
||||
ensureChannelSetupPluginInstalled: vi.fn(),
|
||||
loadChannelSetupPluginRegistrySnapshotForChannel: vi.fn(),
|
||||
login: vi.fn(),
|
||||
logoutAccount: vi.fn(),
|
||||
resolveAccount: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../agents/agent-scope.js", () => ({
|
||||
resolveAgentWorkspaceDir: mocks.resolveAgentWorkspaceDir,
|
||||
resolveDefaultAgentId: mocks.resolveDefaultAgentId,
|
||||
}));
|
||||
|
||||
vi.mock("../channels/plugins/catalog.js", () => ({
|
||||
getChannelPluginCatalogEntry: mocks.getChannelPluginCatalogEntry,
|
||||
}));
|
||||
|
||||
vi.mock("../channels/plugins/helpers.js", () => ({
|
||||
resolveChannelDefaultAccountId: mocks.resolveChannelDefaultAccountId,
|
||||
}));
|
||||
@ -24,6 +40,7 @@ vi.mock("../channels/plugins/index.js", () => ({
|
||||
|
||||
vi.mock("../config/config.js", () => ({
|
||||
loadConfig: mocks.loadConfig,
|
||||
writeConfigFile: mocks.writeConfigFile,
|
||||
}));
|
||||
|
||||
vi.mock("../infra/outbound/channel-selection.js", () => ({
|
||||
@ -34,9 +51,20 @@ vi.mock("../globals.js", () => ({
|
||||
setVerbose: mocks.setVerbose,
|
||||
}));
|
||||
|
||||
vi.mock("../wizard/clack-prompter.js", () => ({
|
||||
createClackPrompter: mocks.createClackPrompter,
|
||||
}));
|
||||
|
||||
vi.mock("../commands/channel-setup/plugin-install.js", () => ({
|
||||
ensureChannelSetupPluginInstalled: mocks.ensureChannelSetupPluginInstalled,
|
||||
loadChannelSetupPluginRegistrySnapshotForChannel:
|
||||
mocks.loadChannelSetupPluginRegistrySnapshotForChannel,
|
||||
}));
|
||||
|
||||
describe("channel-auth", () => {
|
||||
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
|
||||
const plugin = {
|
||||
id: "whatsapp",
|
||||
auth: { login: mocks.login },
|
||||
gateway: { logoutAccount: mocks.logoutAccount },
|
||||
config: { resolveAccount: mocks.resolveAccount },
|
||||
@ -46,12 +74,26 @@ describe("channel-auth", () => {
|
||||
vi.clearAllMocks();
|
||||
mocks.normalizeChannelId.mockReturnValue("whatsapp");
|
||||
mocks.getChannelPlugin.mockReturnValue(plugin);
|
||||
mocks.getChannelPluginCatalogEntry.mockReturnValue(undefined);
|
||||
mocks.loadConfig.mockReturnValue({ channels: {} });
|
||||
mocks.writeConfigFile.mockResolvedValue(undefined);
|
||||
mocks.resolveMessageChannelSelection.mockResolvedValue({
|
||||
channel: "whatsapp",
|
||||
configured: ["whatsapp"],
|
||||
});
|
||||
mocks.resolveDefaultAgentId.mockReturnValue("main");
|
||||
mocks.resolveAgentWorkspaceDir.mockReturnValue("/tmp/workspace");
|
||||
mocks.resolveChannelDefaultAccountId.mockReturnValue("default-account");
|
||||
mocks.createClackPrompter.mockReturnValue({} as object);
|
||||
mocks.ensureChannelSetupPluginInstalled.mockResolvedValue({
|
||||
cfg: { channels: {} },
|
||||
installed: true,
|
||||
pluginId: "whatsapp",
|
||||
});
|
||||
mocks.loadChannelSetupPluginRegistrySnapshotForChannel.mockReturnValue({
|
||||
channels: [{ plugin }],
|
||||
channelSetups: [],
|
||||
});
|
||||
mocks.resolveAccount.mockReturnValue({ id: "resolved-account" });
|
||||
mocks.login.mockResolvedValue(undefined);
|
||||
mocks.logoutAccount.mockResolvedValue(undefined);
|
||||
@ -115,6 +157,52 @@ describe("channel-auth", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("installs a catalog-backed channel plugin on demand for login", async () => {
|
||||
mocks.getChannelPlugin.mockReturnValueOnce(undefined);
|
||||
mocks.getChannelPluginCatalogEntry.mockReturnValueOnce({
|
||||
id: "whatsapp",
|
||||
pluginId: "@openclaw/whatsapp",
|
||||
meta: {
|
||||
id: "whatsapp",
|
||||
label: "WhatsApp",
|
||||
selectionLabel: "WhatsApp",
|
||||
docsPath: "/channels/whatsapp",
|
||||
blurb: "wa",
|
||||
},
|
||||
install: {
|
||||
npmSpec: "@openclaw/whatsapp",
|
||||
},
|
||||
});
|
||||
mocks.loadChannelSetupPluginRegistrySnapshotForChannel
|
||||
.mockReturnValueOnce({
|
||||
channels: [],
|
||||
channelSetups: [],
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
channels: [{ plugin }],
|
||||
channelSetups: [],
|
||||
});
|
||||
|
||||
await runChannelLogin({ channel: "whatsapp" }, runtime);
|
||||
|
||||
expect(mocks.ensureChannelSetupPluginInstalled).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
entry: expect.objectContaining({ id: "whatsapp" }),
|
||||
runtime,
|
||||
workspaceDir: "/tmp/workspace",
|
||||
}),
|
||||
);
|
||||
expect(mocks.loadChannelSetupPluginRegistrySnapshotForChannel).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channel: "whatsapp",
|
||||
pluginId: "whatsapp",
|
||||
workspaceDir: "/tmp/workspace",
|
||||
}),
|
||||
);
|
||||
expect(mocks.writeConfigFile).toHaveBeenCalledWith({ channels: {} });
|
||||
expect(mocks.login).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs logout with resolved account and explicit account id", async () => {
|
||||
await runChannelLogout({ channel: "whatsapp", account: " acct-2 " }, runtime);
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
|
||||
import { getChannelPlugin, normalizeChannelId } from "../channels/plugins/index.js";
|
||||
import { loadConfig, type OpenClawConfig } from "../config/config.js";
|
||||
import { resolveInstallableChannelPlugin } from "../commands/channel-setup/channel-plugin-resolution.js";
|
||||
import { loadConfig, writeConfigFile, type OpenClawConfig } from "../config/config.js";
|
||||
import { setVerbose } from "../globals.js";
|
||||
import { resolveMessageChannelSelection } from "../infra/outbound/channel-selection.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||
@ -18,7 +19,14 @@ async function resolveChannelPluginForMode(
|
||||
opts: ChannelAuthOptions,
|
||||
mode: ChannelAuthMode,
|
||||
cfg: OpenClawConfig,
|
||||
): Promise<{ channelInput: string; channelId: string; plugin: ChannelPlugin }> {
|
||||
runtime: RuntimeEnv,
|
||||
): Promise<{
|
||||
cfg: OpenClawConfig;
|
||||
configChanged: boolean;
|
||||
channelInput: string;
|
||||
channelId: string;
|
||||
plugin: ChannelPlugin;
|
||||
}> {
|
||||
const explicitChannel = opts.channel?.trim();
|
||||
const channelInput = explicitChannel
|
||||
? explicitChannel
|
||||
@ -27,13 +35,28 @@ async function resolveChannelPluginForMode(
|
||||
if (!channelId) {
|
||||
throw new Error(`Unsupported channel: ${channelInput}`);
|
||||
}
|
||||
const plugin = getChannelPlugin(channelId);
|
||||
|
||||
const resolved = await resolveInstallableChannelPlugin({
|
||||
cfg,
|
||||
runtime,
|
||||
channelId,
|
||||
allowInstall: true,
|
||||
supports: (candidate) =>
|
||||
mode === "login" ? Boolean(candidate.auth?.login) : Boolean(candidate.gateway?.logoutAccount),
|
||||
});
|
||||
const plugin = resolved.plugin;
|
||||
const supportsMode =
|
||||
mode === "login" ? Boolean(plugin?.auth?.login) : Boolean(plugin?.gateway?.logoutAccount);
|
||||
if (!supportsMode) {
|
||||
throw new Error(`Channel ${channelId} does not support ${mode}`);
|
||||
}
|
||||
return { channelInput, channelId, plugin: plugin as ChannelPlugin };
|
||||
return {
|
||||
cfg: resolved.cfg,
|
||||
configChanged: resolved.configChanged,
|
||||
channelInput,
|
||||
channelId,
|
||||
plugin: plugin as ChannelPlugin,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveAccountContext(
|
||||
@ -49,8 +72,16 @@ export async function runChannelLogin(
|
||||
opts: ChannelAuthOptions,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
) {
|
||||
const cfg = loadConfig();
|
||||
const { channelInput, plugin } = await resolveChannelPluginForMode(opts, "login", cfg);
|
||||
const loadedCfg = loadConfig();
|
||||
const { cfg, configChanged, channelInput, plugin } = await resolveChannelPluginForMode(
|
||||
opts,
|
||||
"login",
|
||||
loadedCfg,
|
||||
runtime,
|
||||
);
|
||||
if (configChanged) {
|
||||
await writeConfigFile(cfg);
|
||||
}
|
||||
const login = plugin.auth?.login;
|
||||
if (!login) {
|
||||
throw new Error(`Channel ${channelInput} does not support login`);
|
||||
@ -71,8 +102,16 @@ export async function runChannelLogout(
|
||||
opts: ChannelAuthOptions,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
) {
|
||||
const cfg = loadConfig();
|
||||
const { channelInput, plugin } = await resolveChannelPluginForMode(opts, "logout", cfg);
|
||||
const loadedCfg = loadConfig();
|
||||
const { cfg, configChanged, channelInput, plugin } = await resolveChannelPluginForMode(
|
||||
opts,
|
||||
"logout",
|
||||
loadedCfg,
|
||||
runtime,
|
||||
);
|
||||
if (configChanged) {
|
||||
await writeConfigFile(cfg);
|
||||
}
|
||||
const logoutAccount = plugin.gateway?.logoutAccount;
|
||||
if (!logoutAccount) {
|
||||
throw new Error(`Channel ${channelInput} does not support logout`);
|
||||
|
||||
192
src/commands/channel-setup/channel-plugin-resolution.ts
Normal file
192
src/commands/channel-setup/channel-plugin-resolution.ts
Normal file
@ -0,0 +1,192 @@
|
||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
|
||||
import {
|
||||
getChannelPluginCatalogEntry,
|
||||
listChannelPluginCatalogEntries,
|
||||
type ChannelPluginCatalogEntry,
|
||||
} from "../../channels/plugins/catalog.js";
|
||||
import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js";
|
||||
import type { ChannelId, ChannelPlugin } from "../../channels/plugins/types.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type { RuntimeEnv } from "../../runtime.js";
|
||||
import { createClackPrompter } from "../../wizard/clack-prompter.js";
|
||||
import type { WizardPrompter } from "../../wizard/prompts.js";
|
||||
import {
|
||||
ensureChannelSetupPluginInstalled,
|
||||
loadChannelSetupPluginRegistrySnapshotForChannel,
|
||||
} from "./plugin-install.js";
|
||||
|
||||
type ChannelPluginSnapshot = {
|
||||
channels: Array<{ plugin: ChannelPlugin }>;
|
||||
channelSetups: Array<{ plugin: ChannelPlugin }>;
|
||||
};
|
||||
|
||||
type ResolveInstallableChannelPluginResult = {
|
||||
cfg: OpenClawConfig;
|
||||
channelId?: ChannelId;
|
||||
plugin?: ChannelPlugin;
|
||||
catalogEntry?: ChannelPluginCatalogEntry;
|
||||
configChanged: boolean;
|
||||
};
|
||||
|
||||
function resolveWorkspaceDir(cfg: OpenClawConfig) {
|
||||
return resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg));
|
||||
}
|
||||
|
||||
function resolveResolvedChannelId(params: {
|
||||
rawChannel?: string | null;
|
||||
catalogEntry?: ChannelPluginCatalogEntry;
|
||||
}): ChannelId | undefined {
|
||||
const normalized = normalizeChannelId(params.rawChannel);
|
||||
if (normalized) {
|
||||
return normalized;
|
||||
}
|
||||
if (!params.catalogEntry) {
|
||||
return undefined;
|
||||
}
|
||||
return normalizeChannelId(params.catalogEntry.id) ?? (params.catalogEntry.id as ChannelId);
|
||||
}
|
||||
|
||||
export function resolveCatalogChannelEntry(raw: string, cfg: OpenClawConfig | null) {
|
||||
const trimmed = raw.trim().toLowerCase();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
const workspaceDir = cfg ? resolveWorkspaceDir(cfg) : undefined;
|
||||
return listChannelPluginCatalogEntries({ workspaceDir }).find((entry) => {
|
||||
if (entry.id.toLowerCase() === trimmed) {
|
||||
return true;
|
||||
}
|
||||
return (entry.meta.aliases ?? []).some((alias) => alias.trim().toLowerCase() === trimmed);
|
||||
});
|
||||
}
|
||||
|
||||
function findScopedChannelPlugin(
|
||||
snapshot: ChannelPluginSnapshot,
|
||||
channelId: ChannelId,
|
||||
): ChannelPlugin | undefined {
|
||||
return (
|
||||
snapshot.channels.find((entry) => entry.plugin.id === channelId)?.plugin ??
|
||||
snapshot.channelSetups.find((entry) => entry.plugin.id === channelId)?.plugin
|
||||
);
|
||||
}
|
||||
|
||||
function loadScopedChannelPlugin(params: {
|
||||
cfg: OpenClawConfig;
|
||||
runtime: RuntimeEnv;
|
||||
channelId: ChannelId;
|
||||
pluginId?: string;
|
||||
workspaceDir?: string;
|
||||
}): ChannelPlugin | undefined {
|
||||
const snapshot = loadChannelSetupPluginRegistrySnapshotForChannel({
|
||||
cfg: params.cfg,
|
||||
runtime: params.runtime,
|
||||
channel: params.channelId,
|
||||
...(params.pluginId ? { pluginId: params.pluginId } : {}),
|
||||
workspaceDir: params.workspaceDir,
|
||||
});
|
||||
return findScopedChannelPlugin(snapshot, params.channelId);
|
||||
}
|
||||
|
||||
export async function resolveInstallableChannelPlugin(params: {
|
||||
cfg: OpenClawConfig;
|
||||
runtime: RuntimeEnv;
|
||||
rawChannel?: string | null;
|
||||
channelId?: ChannelId;
|
||||
allowInstall?: boolean;
|
||||
prompter?: WizardPrompter;
|
||||
supports?: (plugin: ChannelPlugin) => boolean;
|
||||
}): Promise<ResolveInstallableChannelPluginResult> {
|
||||
const supports = params.supports ?? (() => true);
|
||||
let nextCfg = params.cfg;
|
||||
const workspaceDir = resolveWorkspaceDir(nextCfg);
|
||||
const catalogEntry =
|
||||
(params.rawChannel ? resolveCatalogChannelEntry(params.rawChannel, nextCfg) : undefined) ??
|
||||
(params.channelId
|
||||
? getChannelPluginCatalogEntry(params.channelId, {
|
||||
workspaceDir,
|
||||
})
|
||||
: undefined);
|
||||
const channelId =
|
||||
params.channelId ??
|
||||
resolveResolvedChannelId({
|
||||
rawChannel: params.rawChannel,
|
||||
catalogEntry,
|
||||
});
|
||||
if (!channelId) {
|
||||
return {
|
||||
cfg: nextCfg,
|
||||
catalogEntry,
|
||||
configChanged: false,
|
||||
};
|
||||
}
|
||||
|
||||
const existing = getChannelPlugin(channelId);
|
||||
if (existing && supports(existing)) {
|
||||
return {
|
||||
cfg: nextCfg,
|
||||
channelId,
|
||||
plugin: existing,
|
||||
catalogEntry,
|
||||
configChanged: false,
|
||||
};
|
||||
}
|
||||
|
||||
const resolvedPluginId = catalogEntry?.pluginId;
|
||||
if (catalogEntry) {
|
||||
const scoped = loadScopedChannelPlugin({
|
||||
cfg: nextCfg,
|
||||
runtime: params.runtime,
|
||||
channelId,
|
||||
pluginId: resolvedPluginId,
|
||||
workspaceDir,
|
||||
});
|
||||
if (scoped && supports(scoped)) {
|
||||
return {
|
||||
cfg: nextCfg,
|
||||
channelId,
|
||||
plugin: scoped,
|
||||
catalogEntry,
|
||||
configChanged: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (params.allowInstall !== false) {
|
||||
const installResult = await ensureChannelSetupPluginInstalled({
|
||||
cfg: nextCfg,
|
||||
entry: catalogEntry,
|
||||
prompter: params.prompter ?? createClackPrompter(),
|
||||
runtime: params.runtime,
|
||||
workspaceDir,
|
||||
});
|
||||
nextCfg = installResult.cfg;
|
||||
const installedPluginId = installResult.pluginId ?? resolvedPluginId;
|
||||
const installedPlugin = installResult.installed
|
||||
? loadScopedChannelPlugin({
|
||||
cfg: nextCfg,
|
||||
runtime: params.runtime,
|
||||
channelId,
|
||||
pluginId: installedPluginId,
|
||||
workspaceDir: resolveWorkspaceDir(nextCfg),
|
||||
})
|
||||
: undefined;
|
||||
return {
|
||||
cfg: nextCfg,
|
||||
channelId,
|
||||
plugin: installedPlugin ?? existing,
|
||||
catalogEntry:
|
||||
installedPluginId && catalogEntry.pluginId !== installedPluginId
|
||||
? { ...catalogEntry, pluginId: installedPluginId }
|
||||
: catalogEntry,
|
||||
configChanged: nextCfg !== params.cfg,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
cfg: nextCfg,
|
||||
channelId,
|
||||
plugin: existing,
|
||||
catalogEntry,
|
||||
configChanged: false,
|
||||
};
|
||||
}
|
||||
@ -153,7 +153,9 @@ describe("channelsAddCommand", () => {
|
||||
})),
|
||||
},
|
||||
};
|
||||
vi.mocked(loadChannelSetupPluginRegistrySnapshotForChannel).mockReturnValue(
|
||||
vi.mocked(loadChannelSetupPluginRegistrySnapshotForChannel)
|
||||
.mockReturnValueOnce(createTestRegistry())
|
||||
.mockReturnValueOnce(
|
||||
createTestRegistry([{ pluginId: "msteams", plugin: scopedMSTeamsPlugin, source: "test" }]),
|
||||
);
|
||||
|
||||
@ -292,7 +294,9 @@ describe("channelsAddCommand", () => {
|
||||
installed: true,
|
||||
pluginId: "@vendor/teams-runtime",
|
||||
}));
|
||||
vi.mocked(loadChannelSetupPluginRegistrySnapshotForChannel).mockReturnValue(
|
||||
vi.mocked(loadChannelSetupPluginRegistrySnapshotForChannel)
|
||||
.mockReturnValueOnce(createTestRegistry())
|
||||
.mockReturnValueOnce(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "@vendor/teams-runtime",
|
||||
|
||||
@ -24,8 +24,9 @@ vi.mock("../config/config.js", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../extensions/telegram/api.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../extensions/telegram/api.js")>();
|
||||
vi.mock("../../extensions/telegram/src/update-offset-store.js", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("../../extensions/telegram/src/update-offset-store.js")>();
|
||||
return {
|
||||
...actual,
|
||||
deleteTelegramUpdateOffset: offsetMocks.deleteTelegramUpdateOffset,
|
||||
|
||||
113
src/commands/channels.resolve.test.ts
Normal file
113
src/commands/channels.resolve.test.ts
Normal file
@ -0,0 +1,113 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
resolveCommandSecretRefsViaGateway: vi.fn(),
|
||||
getChannelsCommandSecretTargetIds: vi.fn(() => []),
|
||||
loadConfig: vi.fn(),
|
||||
writeConfigFile: vi.fn(),
|
||||
resolveMessageChannelSelection: vi.fn(),
|
||||
resolveInstallableChannelPlugin: vi.fn(),
|
||||
getChannelPlugin: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../cli/command-secret-gateway.js", () => ({
|
||||
resolveCommandSecretRefsViaGateway: mocks.resolveCommandSecretRefsViaGateway,
|
||||
}));
|
||||
|
||||
vi.mock("../cli/command-secret-targets.js", () => ({
|
||||
getChannelsCommandSecretTargetIds: mocks.getChannelsCommandSecretTargetIds,
|
||||
}));
|
||||
|
||||
vi.mock("../config/config.js", () => ({
|
||||
loadConfig: mocks.loadConfig,
|
||||
writeConfigFile: mocks.writeConfigFile,
|
||||
}));
|
||||
|
||||
vi.mock("../infra/outbound/channel-selection.js", () => ({
|
||||
resolveMessageChannelSelection: mocks.resolveMessageChannelSelection,
|
||||
}));
|
||||
|
||||
vi.mock("./channel-setup/channel-plugin-resolution.js", () => ({
|
||||
resolveInstallableChannelPlugin: mocks.resolveInstallableChannelPlugin,
|
||||
}));
|
||||
|
||||
vi.mock("../channels/plugins/index.js", () => ({
|
||||
getChannelPlugin: mocks.getChannelPlugin,
|
||||
}));
|
||||
|
||||
const { channelsResolveCommand } = await import("./channels/resolve.js");
|
||||
|
||||
describe("channelsResolveCommand", () => {
|
||||
const runtime = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.loadConfig.mockReturnValue({ channels: {} });
|
||||
mocks.writeConfigFile.mockResolvedValue(undefined);
|
||||
mocks.resolveCommandSecretRefsViaGateway.mockResolvedValue({
|
||||
resolvedConfig: { channels: {} },
|
||||
diagnostics: [],
|
||||
});
|
||||
mocks.resolveMessageChannelSelection.mockResolvedValue({
|
||||
channel: "telegram",
|
||||
configured: ["telegram"],
|
||||
source: "explicit",
|
||||
});
|
||||
});
|
||||
|
||||
it("persists install-on-demand channel setup before resolving explicit targets", async () => {
|
||||
const resolveTargets = vi.fn().mockResolvedValue([
|
||||
{
|
||||
input: "friends",
|
||||
resolved: true,
|
||||
id: "120363000000@g.us",
|
||||
name: "Friends",
|
||||
},
|
||||
]);
|
||||
const installedCfg = {
|
||||
channels: {},
|
||||
plugins: {
|
||||
entries: {
|
||||
whatsapp: { enabled: true },
|
||||
},
|
||||
},
|
||||
};
|
||||
mocks.resolveInstallableChannelPlugin.mockResolvedValue({
|
||||
cfg: installedCfg,
|
||||
channelId: "whatsapp",
|
||||
configChanged: true,
|
||||
plugin: {
|
||||
id: "whatsapp",
|
||||
resolver: { resolveTargets },
|
||||
},
|
||||
});
|
||||
|
||||
await channelsResolveCommand(
|
||||
{
|
||||
channel: "whatsapp",
|
||||
entries: ["friends"],
|
||||
},
|
||||
runtime,
|
||||
);
|
||||
|
||||
expect(mocks.resolveInstallableChannelPlugin).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
rawChannel: "whatsapp",
|
||||
allowInstall: true,
|
||||
}),
|
||||
);
|
||||
expect(mocks.writeConfigFile).toHaveBeenCalledWith(installedCfg);
|
||||
expect(resolveTargets).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cfg: installedCfg,
|
||||
inputs: ["friends"],
|
||||
kind: "group",
|
||||
}),
|
||||
);
|
||||
expect(runtime.log).toHaveBeenCalledWith("friends -> 120363000000@g.us (Friends)");
|
||||
});
|
||||
});
|
||||
@ -1,16 +1,18 @@
|
||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
|
||||
import { listChannelPluginCatalogEntries } from "../../channels/plugins/catalog.js";
|
||||
import { resolveDefaultAgentId } from "../../agents/agent-scope.js";
|
||||
import { parseOptionalDelimitedEntries } from "../../channels/plugins/helpers.js";
|
||||
import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js";
|
||||
import { moveSingleAccountChannelSectionToDefaultAccount } from "../../channels/plugins/setup-helpers.js";
|
||||
import type { ChannelSetupPlugin } from "../../channels/plugins/setup-wizard-types.js";
|
||||
import type { ChannelId, ChannelPlugin, ChannelSetupInput } from "../../channels/plugins/types.js";
|
||||
import { writeConfigFile, type OpenClawConfig } from "../../config/config.js";
|
||||
import type { ChannelSetupInput } from "../../channels/plugins/types.js";
|
||||
import { writeConfigFile } from "../../config/config.js";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
|
||||
import { createClackPrompter } from "../../wizard/clack-prompter.js";
|
||||
import { applyAgentBindings, describeBinding } from "../agents.bindings.js";
|
||||
import { isCatalogChannelInstalled } from "../channel-setup/discovery.js";
|
||||
import {
|
||||
resolveCatalogChannelEntry,
|
||||
resolveInstallableChannelPlugin,
|
||||
} from "../channel-setup/channel-plugin-resolution.js";
|
||||
import type { ChannelChoice } from "../onboard-types.js";
|
||||
import { applyAccountName, applyChannelAccountConfig } from "./add-mutators.js";
|
||||
import { channelLabel, requireValidConfig, shouldUseWizard } from "./shared.js";
|
||||
@ -22,21 +24,6 @@ export type ChannelsAddOptions = {
|
||||
groupChannels?: string;
|
||||
dmAllowlist?: string;
|
||||
} & Omit<ChannelSetupInput, "groupChannels" | "dmAllowlist" | "initialSyncLimit">;
|
||||
|
||||
function resolveCatalogChannelEntry(raw: string, cfg: OpenClawConfig | null) {
|
||||
const trimmed = raw.trim().toLowerCase();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
const workspaceDir = cfg ? resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)) : undefined;
|
||||
return listChannelPluginCatalogEntries({ workspaceDir }).find((entry) => {
|
||||
if (entry.id.toLowerCase() === trimmed) {
|
||||
return true;
|
||||
}
|
||||
return (entry.meta.aliases ?? []).some((alias) => alias.trim().toLowerCase() === trimmed);
|
||||
});
|
||||
}
|
||||
|
||||
export async function channelsAddCommand(
|
||||
opts: ChannelsAddOptions,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
@ -177,62 +164,17 @@ export async function channelsAddCommand(
|
||||
const rawChannel = String(opts.channel ?? "");
|
||||
let channel = normalizeChannelId(rawChannel);
|
||||
let catalogEntry = channel ? undefined : resolveCatalogChannelEntry(rawChannel, nextConfig);
|
||||
const resolveWorkspaceDir = () =>
|
||||
resolveAgentWorkspaceDir(nextConfig, resolveDefaultAgentId(nextConfig));
|
||||
// May trigger loadOpenClawPlugins on cache miss (disk scan + jiti import)
|
||||
const loadScopedPlugin = async (
|
||||
channelId: ChannelId,
|
||||
pluginId?: string,
|
||||
): Promise<ChannelPlugin | undefined> => {
|
||||
const existing = getChannelPlugin(channelId);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const { loadChannelSetupPluginRegistrySnapshotForChannel } =
|
||||
await import("../channel-setup/plugin-install.js");
|
||||
const snapshot = loadChannelSetupPluginRegistrySnapshotForChannel({
|
||||
const resolvedPluginState = await resolveInstallableChannelPlugin({
|
||||
cfg: nextConfig,
|
||||
runtime,
|
||||
channel: channelId,
|
||||
...(pluginId ? { pluginId } : {}),
|
||||
workspaceDir: resolveWorkspaceDir(),
|
||||
rawChannel,
|
||||
allowInstall: true,
|
||||
prompter: createClackPrompter(),
|
||||
supports: (plugin) => Boolean(plugin.setup?.applyAccountConfig),
|
||||
});
|
||||
return (
|
||||
snapshot.channels.find((entry) => entry.plugin.id === channelId)?.plugin ??
|
||||
snapshot.channelSetups.find((entry) => entry.plugin.id === channelId)?.plugin
|
||||
);
|
||||
};
|
||||
|
||||
if (!channel && catalogEntry) {
|
||||
const workspaceDir = resolveWorkspaceDir();
|
||||
if (
|
||||
!isCatalogChannelInstalled({
|
||||
cfg: nextConfig,
|
||||
entry: catalogEntry,
|
||||
workspaceDir,
|
||||
})
|
||||
) {
|
||||
const { ensureChannelSetupPluginInstalled } =
|
||||
await import("../channel-setup/plugin-install.js");
|
||||
const prompter = createClackPrompter();
|
||||
const result = await ensureChannelSetupPluginInstalled({
|
||||
cfg: nextConfig,
|
||||
entry: catalogEntry,
|
||||
prompter,
|
||||
runtime,
|
||||
workspaceDir,
|
||||
});
|
||||
nextConfig = result.cfg;
|
||||
if (!result.installed) {
|
||||
return;
|
||||
}
|
||||
catalogEntry = {
|
||||
...catalogEntry,
|
||||
...(result.pluginId ? { pluginId: result.pluginId } : {}),
|
||||
};
|
||||
}
|
||||
channel = normalizeChannelId(catalogEntry.id) ?? (catalogEntry.id as ChannelId);
|
||||
}
|
||||
nextConfig = resolvedPluginState.cfg;
|
||||
channel = resolvedPluginState.channelId ?? channel;
|
||||
catalogEntry = resolvedPluginState.catalogEntry ?? catalogEntry;
|
||||
|
||||
if (!channel) {
|
||||
const hint = catalogEntry
|
||||
@ -243,7 +185,7 @@ export async function channelsAddCommand(
|
||||
return;
|
||||
}
|
||||
|
||||
const plugin = await loadScopedPlugin(channel, catalogEntry?.pluginId);
|
||||
const plugin = resolvedPluginState.plugin ?? (channel ? getChannelPlugin(channel) : undefined);
|
||||
if (!plugin?.setup?.applyAccountConfig) {
|
||||
runtime.error(`Channel ${channel} does not support add.`);
|
||||
runtime.exit(1);
|
||||
|
||||
@ -2,10 +2,11 @@ import { getChannelPlugin } from "../../channels/plugins/index.js";
|
||||
import type { ChannelResolveKind, ChannelResolveResult } from "../../channels/plugins/types.js";
|
||||
import { resolveCommandSecretRefsViaGateway } from "../../cli/command-secret-gateway.js";
|
||||
import { getChannelsCommandSecretTargetIds } from "../../cli/command-secret-targets.js";
|
||||
import { loadConfig } from "../../config/config.js";
|
||||
import { loadConfig, writeConfigFile } from "../../config/config.js";
|
||||
import { danger } from "../../globals.js";
|
||||
import { resolveMessageChannelSelection } from "../../infra/outbound/channel-selection.js";
|
||||
import type { RuntimeEnv } from "../../runtime.js";
|
||||
import { resolveInstallableChannelPlugin } from "../channel-setup/channel-plugin-resolution.js";
|
||||
|
||||
export type ChannelsResolveOptions = {
|
||||
channel?: string;
|
||||
@ -71,12 +72,13 @@ function formatResolveResult(result: ResolveResult): string {
|
||||
|
||||
export async function channelsResolveCommand(opts: ChannelsResolveOptions, runtime: RuntimeEnv) {
|
||||
const loadedRaw = loadConfig();
|
||||
const { resolvedConfig: cfg, diagnostics } = await resolveCommandSecretRefsViaGateway({
|
||||
const { resolvedConfig, diagnostics } = await resolveCommandSecretRefsViaGateway({
|
||||
config: loadedRaw,
|
||||
commandName: "channels resolve",
|
||||
targetIds: getChannelsCommandSecretTargetIds(),
|
||||
mode: "read_only_operational",
|
||||
});
|
||||
let cfg = resolvedConfig;
|
||||
for (const entry of diagnostics) {
|
||||
runtime.log(`[secrets] ${entry}`);
|
||||
}
|
||||
@ -85,13 +87,35 @@ export async function channelsResolveCommand(opts: ChannelsResolveOptions, runti
|
||||
throw new Error("At least one entry is required.");
|
||||
}
|
||||
|
||||
const selection = await resolveMessageChannelSelection({
|
||||
const explicitChannel = opts.channel?.trim();
|
||||
const resolvedExplicit = explicitChannel
|
||||
? await resolveInstallableChannelPlugin({
|
||||
cfg,
|
||||
runtime,
|
||||
rawChannel: explicitChannel,
|
||||
allowInstall: true,
|
||||
supports: (plugin) => Boolean(plugin.resolver?.resolveTargets),
|
||||
})
|
||||
: null;
|
||||
if (resolvedExplicit?.configChanged) {
|
||||
cfg = resolvedExplicit.cfg;
|
||||
await writeConfigFile(cfg);
|
||||
}
|
||||
|
||||
const selection = explicitChannel
|
||||
? {
|
||||
channel: resolvedExplicit?.channelId,
|
||||
}
|
||||
: await resolveMessageChannelSelection({
|
||||
cfg,
|
||||
channel: opts.channel ?? null,
|
||||
});
|
||||
const plugin = getChannelPlugin(selection.channel);
|
||||
const plugin =
|
||||
(explicitChannel ? resolvedExplicit?.plugin : undefined) ??
|
||||
(selection.channel ? getChannelPlugin(selection.channel) : undefined);
|
||||
if (!plugin?.resolver?.resolveTargets) {
|
||||
throw new Error(`Channel ${selection.channel} does not support resolve.`);
|
||||
const channelText = selection.channel ?? explicitChannel ?? "";
|
||||
throw new Error(`Channel ${channelText} does not support resolve.`);
|
||||
}
|
||||
const preferredKind = resolvePreferredKind(opts.kind);
|
||||
|
||||
|
||||
@ -22,22 +22,12 @@ describe("bundled plugin runtime dependencies", () => {
|
||||
expect(rootSpec).toBeUndefined();
|
||||
}
|
||||
|
||||
function expectRootMirrorsPluginRuntimeDep(pluginPath: string, dependencyName: string) {
|
||||
const rootManifest = readJson<PackageManifest>("package.json");
|
||||
const pluginManifest = readJson<PackageManifest>(pluginPath);
|
||||
const pluginSpec = pluginManifest.dependencies?.[dependencyName];
|
||||
const rootSpec = rootManifest.dependencies?.[dependencyName];
|
||||
|
||||
expect(pluginSpec).toBeTruthy();
|
||||
expect(rootSpec).toBe(pluginSpec);
|
||||
}
|
||||
|
||||
it("keeps bundled Feishu runtime deps plugin-local instead of mirroring them into the root package", () => {
|
||||
expectPluginOwnsRuntimeDep("extensions/feishu/package.json", "@larksuiteoapi/node-sdk");
|
||||
});
|
||||
|
||||
it("keeps bundled memory-lancedb runtime deps mirrored in the root package while its native runtime is still packaged that way", () => {
|
||||
expectRootMirrorsPluginRuntimeDep("extensions/memory-lancedb/package.json", "@lancedb/lancedb");
|
||||
it("keeps memory-lancedb runtime deps plugin-local so packaged installs fetch them on demand", () => {
|
||||
expectPluginOwnsRuntimeDep("extensions/memory-lancedb/package.json", "@lancedb/lancedb");
|
||||
});
|
||||
|
||||
it("keeps bundled Discord runtime deps plugin-local instead of mirroring them into the root package", () => {
|
||||
@ -52,11 +42,8 @@ describe("bundled plugin runtime dependencies", () => {
|
||||
expectPluginOwnsRuntimeDep("extensions/telegram/package.json", "grammy");
|
||||
});
|
||||
|
||||
it("keeps bundled WhatsApp runtime deps mirrored in the root package while its heavy runtime still uses the legacy bundle path", () => {
|
||||
expectRootMirrorsPluginRuntimeDep(
|
||||
"extensions/whatsapp/package.json",
|
||||
"@whiskeysockets/baileys",
|
||||
);
|
||||
it("keeps WhatsApp runtime deps plugin-local so packaged installs fetch them on demand", () => {
|
||||
expectPluginOwnsRuntimeDep("extensions/whatsapp/package.json", "@whiskeysockets/baileys");
|
||||
});
|
||||
|
||||
it("keeps bundled proxy-agent deps plugin-local instead of mirroring them into the root package", () => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user