feat: add onboarding plugin install flow
This commit is contained in:
@@ -8,6 +8,7 @@
|
|||||||
- Agents: add optional auth-profile copy prompt on `agents add` and improve auth error messaging.
|
- Agents: add optional auth-profile copy prompt on `agents add` and improve auth error messaging.
|
||||||
- Security: add `clawdbot security audit` (`--deep`, `--fix`) and surface it in `status --all` and `doctor`.
|
- Security: add `clawdbot security audit` (`--deep`, `--fix`) and surface it in `status --all` and `doctor`.
|
||||||
- Security: add `clawdbot security audit` (`--deep`, `--fix`) and surface it in `status --all` and `doctor` (includes browser control exposure checks).
|
- Security: add `clawdbot security audit` (`--deep`, `--fix`) and surface it in `status --all` and `doctor` (includes browser control exposure checks).
|
||||||
|
- Plugins: add Zalo channel plugin with gateway HTTP hooks and onboarding install prompt. (#854) — thanks @longmaba.
|
||||||
- Onboarding: add a security checkpoint prompt (docs link + sandboxing hint); require `--accept-risk` for `--non-interactive`.
|
- Onboarding: add a security checkpoint prompt (docs link + sandboxing hint); require `--accept-risk` for `--non-interactive`.
|
||||||
- Docs: expand gateway security hardening guidance and incident response checklist.
|
- Docs: expand gateway security hardening guidance and incident response checklist.
|
||||||
- Docs: document DM history limits for channel DMs. (#883) — thanks @pkrmf.
|
- Docs: document DM history limits for channel DMs. (#883) — thanks @pkrmf.
|
||||||
|
|||||||
@@ -289,6 +289,9 @@ Typical fields in `~/.clawdbot/clawdbot.json`:
|
|||||||
WhatsApp credentials go under `~/.clawdbot/credentials/whatsapp/<accountId>/`.
|
WhatsApp credentials go under `~/.clawdbot/credentials/whatsapp/<accountId>/`.
|
||||||
Sessions are stored under `~/.clawdbot/agents/<agentId>/sessions/`.
|
Sessions are stored under `~/.clawdbot/agents/<agentId>/sessions/`.
|
||||||
|
|
||||||
|
Some channels are delivered as plugins. When you pick one during onboarding, the wizard
|
||||||
|
will prompt to install it (npm or a local path) before it can be configured.
|
||||||
|
|
||||||
## Related docs
|
## Related docs
|
||||||
|
|
||||||
- macOS app onboarding: [Onboarding](/start/onboarding)
|
- macOS app onboarding: [Onboarding](/start/onboarding)
|
||||||
|
|||||||
43
src/channels/plugins/catalog.ts
Normal file
43
src/channels/plugins/catalog.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import type { ChannelMeta } from "./types.js";
|
||||||
|
|
||||||
|
export type ChannelPluginCatalogEntry = {
|
||||||
|
id: string;
|
||||||
|
meta: ChannelMeta;
|
||||||
|
install: {
|
||||||
|
npmSpec: string;
|
||||||
|
localPath?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const CATALOG: ChannelPluginCatalogEntry[] = [
|
||||||
|
{
|
||||||
|
id: "zalo",
|
||||||
|
meta: {
|
||||||
|
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: "@clawdbot/zalo",
|
||||||
|
localPath: "extensions/zalo",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function listChannelPluginCatalogEntries(): ChannelPluginCatalogEntry[] {
|
||||||
|
return [...CATALOG];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getChannelPluginCatalogEntry(
|
||||||
|
id: string,
|
||||||
|
): ChannelPluginCatalogEntry | undefined {
|
||||||
|
const trimmed = id.trim();
|
||||||
|
if (!trimmed) return undefined;
|
||||||
|
return CATALOG.find((entry) => entry.id === trimmed);
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||||
|
import { listChannelPluginCatalogEntries } from "../channels/plugins/catalog.js";
|
||||||
import { listChannelPlugins, getChannelPlugin } from "../channels/plugins/index.js";
|
import { listChannelPlugins, getChannelPlugin } from "../channels/plugins/index.js";
|
||||||
import { formatChannelPrimerLine, formatChannelSelectionLine } from "../channels/registry.js";
|
import { formatChannelPrimerLine, formatChannelSelectionLine } from "../channels/registry.js";
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
@@ -10,10 +12,25 @@ import {
|
|||||||
getChannelOnboardingAdapter,
|
getChannelOnboardingAdapter,
|
||||||
listChannelOnboardingAdapters,
|
listChannelOnboardingAdapters,
|
||||||
} from "./onboarding/registry.js";
|
} from "./onboarding/registry.js";
|
||||||
|
import {
|
||||||
|
ensureOnboardingPluginInstalled,
|
||||||
|
reloadOnboardingPluginRegistry,
|
||||||
|
} from "./onboarding/plugin-install.js";
|
||||||
import type { ChannelOnboardingDmPolicy, SetupChannelsOptions } from "./onboarding/types.js";
|
import type { ChannelOnboardingDmPolicy, SetupChannelsOptions } from "./onboarding/types.js";
|
||||||
|
|
||||||
async function noteChannelPrimer(prompter: WizardPrompter): Promise<void> {
|
async function noteChannelPrimer(
|
||||||
const channelLines = listChannelPlugins().map((plugin) => formatChannelPrimerLine(plugin.meta));
|
prompter: WizardPrompter,
|
||||||
|
channels: Array<{ id: ChannelChoice; blurb: string; label: string }>,
|
||||||
|
): Promise<void> {
|
||||||
|
const channelLines = channels.map((channel) =>
|
||||||
|
formatChannelPrimerLine({
|
||||||
|
id: channel.id,
|
||||||
|
label: channel.label,
|
||||||
|
selectionLabel: channel.label,
|
||||||
|
docsPath: "/",
|
||||||
|
blurb: channel.blurb,
|
||||||
|
}),
|
||||||
|
);
|
||||||
await prompter.note(
|
await prompter.note(
|
||||||
[
|
[
|
||||||
"DM security: default is pairing; unknown DMs get a pairing code.",
|
"DM security: default is pairing; unknown DMs get a pairing code.",
|
||||||
@@ -97,6 +114,7 @@ export async function setupChannels(
|
|||||||
prompter: WizardPrompter,
|
prompter: WizardPrompter,
|
||||||
options?: SetupChannelsOptions,
|
options?: SetupChannelsOptions,
|
||||||
): Promise<ClawdbotConfig> {
|
): Promise<ClawdbotConfig> {
|
||||||
|
let next = cfg;
|
||||||
const forceAllowFromChannels = new Set(options?.forceAllowFromChannels ?? []);
|
const forceAllowFromChannels = new Set(options?.forceAllowFromChannels ?? []);
|
||||||
const accountOverrides: Partial<Record<ChannelChoice, string>> = {
|
const accountOverrides: Partial<Record<ChannelChoice, string>> = {
|
||||||
...options?.accountIds,
|
...options?.accountIds,
|
||||||
@@ -105,13 +123,27 @@ export async function setupChannels(
|
|||||||
accountOverrides.whatsapp = options.whatsappAccountId.trim();
|
accountOverrides.whatsapp = options.whatsappAccountId.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const installedPlugins = listChannelPlugins();
|
||||||
|
const catalogEntries = listChannelPluginCatalogEntries().filter(
|
||||||
|
(entry) => !installedPlugins.some((plugin) => plugin.id === entry.id),
|
||||||
|
);
|
||||||
const statusEntries = await Promise.all(
|
const statusEntries = await Promise.all(
|
||||||
listChannelOnboardingAdapters().map((adapter) =>
|
listChannelOnboardingAdapters().map((adapter) =>
|
||||||
adapter.getStatus({ cfg, options, accountOverrides }),
|
adapter.getStatus({ cfg, options, accountOverrides }),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const statusByChannel = new Map(statusEntries.map((entry) => [entry.channel, entry]));
|
const catalogStatuses = catalogEntries.map((entry) => ({
|
||||||
const statusLines = statusEntries.flatMap((entry) => entry.statusLines);
|
channel: entry.id,
|
||||||
|
configured: false,
|
||||||
|
statusLines: [`${entry.meta.label}: install plugin to enable`],
|
||||||
|
selectionHint: "plugin · install",
|
||||||
|
quickstartScore: 0,
|
||||||
|
}));
|
||||||
|
const combinedStatuses = [...statusEntries, ...catalogStatuses];
|
||||||
|
const statusByChannel = new Map(
|
||||||
|
combinedStatuses.map((entry) => [entry.channel, entry]),
|
||||||
|
);
|
||||||
|
const statusLines = combinedStatuses.flatMap((entry) => entry.statusLines);
|
||||||
if (statusLines.length > 0) {
|
if (statusLines.length > 0) {
|
||||||
await prompter.note(statusLines.join("\n"), "Channel status");
|
await prompter.note(statusLines.join("\n"), "Channel status");
|
||||||
}
|
}
|
||||||
@@ -124,11 +156,32 @@ export async function setupChannels(
|
|||||||
});
|
});
|
||||||
if (!shouldConfigure) return cfg;
|
if (!shouldConfigure) return cfg;
|
||||||
|
|
||||||
await noteChannelPrimer(prompter);
|
const primerChannels = [
|
||||||
|
...installedPlugins.map((plugin) => ({
|
||||||
|
id: plugin.id as ChannelChoice,
|
||||||
|
label: plugin.meta.label,
|
||||||
|
blurb: plugin.meta.blurb,
|
||||||
|
})),
|
||||||
|
...catalogEntries.map((entry) => ({
|
||||||
|
id: entry.id as ChannelChoice,
|
||||||
|
label: entry.meta.label,
|
||||||
|
blurb: entry.meta.blurb,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
await noteChannelPrimer(prompter, primerChannels);
|
||||||
|
|
||||||
const selectionOptions = listChannelPlugins().map((plugin) => {
|
const selectionOptions = [
|
||||||
const meta = plugin.meta;
|
...installedPlugins.map((plugin) => ({
|
||||||
const status = statusByChannel.get(meta.id as ChannelChoice);
|
id: plugin.id as ChannelChoice,
|
||||||
|
meta: plugin.meta,
|
||||||
|
})),
|
||||||
|
...catalogEntries.map((entry) => ({
|
||||||
|
id: entry.id as ChannelChoice,
|
||||||
|
meta: entry.meta,
|
||||||
|
})),
|
||||||
|
].map((entry) => {
|
||||||
|
const meta = entry.meta;
|
||||||
|
const status = statusByChannel.get(entry.id);
|
||||||
return {
|
return {
|
||||||
value: meta.id,
|
value: meta.id,
|
||||||
label: meta.selectionLabel ?? meta.label,
|
label: meta.selectionLabel ?? meta.label,
|
||||||
@@ -163,12 +216,43 @@ export async function setupChannels(
|
|||||||
})) as ChannelChoice[];
|
})) as ChannelChoice[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const catalogById = new Map(
|
||||||
|
catalogEntries.map((entry) => [entry.id as ChannelChoice, entry]),
|
||||||
|
);
|
||||||
|
if (selection.some((channel) => catalogById.has(channel))) {
|
||||||
|
const workspaceDir = resolveAgentWorkspaceDir(next, resolveDefaultAgentId(next));
|
||||||
|
for (const channel of selection) {
|
||||||
|
const entry = catalogById.get(channel);
|
||||||
|
if (!entry) continue;
|
||||||
|
const result = await ensureOnboardingPluginInstalled({
|
||||||
|
cfg: next,
|
||||||
|
entry,
|
||||||
|
prompter,
|
||||||
|
runtime,
|
||||||
|
workspaceDir,
|
||||||
|
});
|
||||||
|
next = result.cfg;
|
||||||
|
if (!result.installed) {
|
||||||
|
selection = selection.filter((id) => id !== channel);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
reloadOnboardingPluginRegistry({
|
||||||
|
cfg: next,
|
||||||
|
runtime,
|
||||||
|
workspaceDir,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
options?.onSelection?.(selection);
|
options?.onSelection?.(selection);
|
||||||
|
|
||||||
const selectionNotes = new Map(
|
const selectionNotes = new Map(
|
||||||
listChannelPlugins().map((plugin) => [
|
[
|
||||||
plugin.id,
|
...installedPlugins.map((plugin) => [plugin.id, plugin.meta]),
|
||||||
formatChannelSelectionLine(plugin.meta, formatDocsLink),
|
...catalogEntries.map((entry) => [entry.id, entry.meta]),
|
||||||
|
].map(([id, meta]) => [
|
||||||
|
id,
|
||||||
|
formatChannelSelectionLine(meta, formatDocsLink),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
const selectedLines = selection
|
const selectedLines = selection
|
||||||
@@ -185,7 +269,6 @@ export async function setupChannels(
|
|||||||
adapter?.onAccountRecorded?.(accountId, options);
|
adapter?.onAccountRecorded?.(accountId, options);
|
||||||
};
|
};
|
||||||
|
|
||||||
let next = cfg;
|
|
||||||
for (const channel of selection) {
|
for (const channel of selection) {
|
||||||
const adapter = getChannelOnboardingAdapter(channel);
|
const adapter = getChannelOnboardingAdapter(channel);
|
||||||
if (!adapter) continue;
|
if (!adapter) continue;
|
||||||
|
|||||||
25
src/commands/onboarding/__tests__/test-utils.ts
Normal file
25
src/commands/onboarding/__tests__/test-utils.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { vi } from "vitest";
|
||||||
|
|
||||||
|
import type { RuntimeEnv } from "../../../runtime.js";
|
||||||
|
import type { WizardPrompter } from "../../../wizard/prompts.js";
|
||||||
|
|
||||||
|
export const makeRuntime = (overrides: Partial<RuntimeEnv> = {}): RuntimeEnv => ({
|
||||||
|
log: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
exit: vi.fn(),
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const makePrompter = (
|
||||||
|
overrides: Partial<WizardPrompter> = {},
|
||||||
|
): WizardPrompter => ({
|
||||||
|
intro: vi.fn(async () => {}),
|
||||||
|
outro: vi.fn(async () => {}),
|
||||||
|
note: vi.fn(async () => {}),
|
||||||
|
select: vi.fn(async () => "npm") as WizardPrompter["select"],
|
||||||
|
multiselect: vi.fn(async () => []) as WizardPrompter["multiselect"],
|
||||||
|
text: vi.fn(async () => "") as WizardPrompter["text"],
|
||||||
|
confirm: vi.fn(async () => false),
|
||||||
|
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
126
src/commands/onboarding/plugin-install.test.ts
Normal file
126
src/commands/onboarding/plugin-install.test.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("node:fs", () => ({
|
||||||
|
default: {
|
||||||
|
existsSync: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const installPluginFromNpmSpec = vi.fn();
|
||||||
|
vi.mock("../../plugins/install.js", () => ({
|
||||||
|
installPluginFromNpmSpec: (...args: unknown[]) => installPluginFromNpmSpec(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../plugins/loader.js", () => ({
|
||||||
|
loadClawdbotPlugins: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import fs from "node:fs";
|
||||||
|
import type { ChannelPluginCatalogEntry } from "../../channels/plugins/catalog.js";
|
||||||
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
|
import type { WizardPrompter } from "../../wizard/prompts.js";
|
||||||
|
import { makePrompter, makeRuntime } from "./__tests__/test-utils.js";
|
||||||
|
import { ensureOnboardingPluginInstalled } from "./plugin-install.js";
|
||||||
|
|
||||||
|
const baseEntry: ChannelPluginCatalogEntry = {
|
||||||
|
id: "zalo",
|
||||||
|
meta: {
|
||||||
|
id: "zalo",
|
||||||
|
label: "Zalo",
|
||||||
|
selectionLabel: "Zalo (Bot API)",
|
||||||
|
docsPath: "/channels/zalo",
|
||||||
|
docsLabel: "zalo",
|
||||||
|
blurb: "Test",
|
||||||
|
},
|
||||||
|
install: {
|
||||||
|
npmSpec: "@clawdbot/zalo",
|
||||||
|
localPath: "extensions/zalo",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ensureOnboardingPluginInstalled", () => {
|
||||||
|
it("installs from npm and enables the plugin", async () => {
|
||||||
|
const runtime = makeRuntime();
|
||||||
|
const prompter = makePrompter({
|
||||||
|
select: vi.fn(async () => "npm") as WizardPrompter["select"],
|
||||||
|
});
|
||||||
|
const cfg: ClawdbotConfig = { plugins: { allow: ["other"] } };
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||||
|
installPluginFromNpmSpec.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
pluginId: "zalo",
|
||||||
|
targetDir: "/tmp/zalo",
|
||||||
|
extensions: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await ensureOnboardingPluginInstalled({
|
||||||
|
cfg,
|
||||||
|
entry: baseEntry,
|
||||||
|
prompter,
|
||||||
|
runtime,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.installed).toBe(true);
|
||||||
|
expect(result.cfg.plugins?.entries?.zalo?.enabled).toBe(true);
|
||||||
|
expect(result.cfg.plugins?.allow).toContain("zalo");
|
||||||
|
expect(installPluginFromNpmSpec).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ spec: "@clawdbot/zalo" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses local path when selected", async () => {
|
||||||
|
const runtime = makeRuntime();
|
||||||
|
const prompter = makePrompter({
|
||||||
|
select: vi.fn(async () => "local") as WizardPrompter["select"],
|
||||||
|
});
|
||||||
|
const cfg: ClawdbotConfig = {};
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
|
||||||
|
const result = await ensureOnboardingPluginInstalled({
|
||||||
|
cfg,
|
||||||
|
entry: baseEntry,
|
||||||
|
prompter,
|
||||||
|
runtime,
|
||||||
|
});
|
||||||
|
|
||||||
|
const expectedPath = path.resolve(process.cwd(), "extensions/zalo");
|
||||||
|
expect(result.installed).toBe(true);
|
||||||
|
expect(result.cfg.plugins?.load?.paths).toContain(expectedPath);
|
||||||
|
expect(result.cfg.plugins?.entries?.zalo?.enabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to local path after npm install failure", async () => {
|
||||||
|
const runtime = makeRuntime();
|
||||||
|
const note = vi.fn(async () => {});
|
||||||
|
const confirm = vi.fn(async () => true);
|
||||||
|
const prompter = makePrompter({
|
||||||
|
select: vi.fn(async () => "npm") as WizardPrompter["select"],
|
||||||
|
note,
|
||||||
|
confirm,
|
||||||
|
});
|
||||||
|
const cfg: ClawdbotConfig = {};
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
installPluginFromNpmSpec.mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
error: "nope",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await ensureOnboardingPluginInstalled({
|
||||||
|
cfg,
|
||||||
|
entry: baseEntry,
|
||||||
|
prompter,
|
||||||
|
runtime,
|
||||||
|
});
|
||||||
|
|
||||||
|
const expectedPath = path.resolve(process.cwd(), "extensions/zalo");
|
||||||
|
expect(result.installed).toBe(true);
|
||||||
|
expect(result.cfg.plugins?.load?.paths).toContain(expectedPath);
|
||||||
|
expect(note).toHaveBeenCalled();
|
||||||
|
expect(runtime.error).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
185
src/commands/onboarding/plugin-install.ts
Normal file
185
src/commands/onboarding/plugin-install.ts
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
|
||||||
|
import type { ChannelPluginCatalogEntry } from "../../channels/plugins/catalog.js";
|
||||||
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
|
import { createSubsystemLogger } from "../../logging.js";
|
||||||
|
import { loadClawdbotPlugins } from "../../plugins/loader.js";
|
||||||
|
import { installPluginFromNpmSpec } from "../../plugins/install.js";
|
||||||
|
import type { RuntimeEnv } from "../../runtime.js";
|
||||||
|
import type { WizardPrompter } from "../../wizard/prompts.js";
|
||||||
|
|
||||||
|
type InstallChoice = "npm" | "local" | "skip";
|
||||||
|
|
||||||
|
type InstallResult = {
|
||||||
|
cfg: ClawdbotConfig;
|
||||||
|
installed: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveLocalPath(entry: ChannelPluginCatalogEntry, workspaceDir?: string): string | null {
|
||||||
|
const raw = entry.install.localPath?.trim();
|
||||||
|
if (!raw) return null;
|
||||||
|
const candidates = new Set<string>();
|
||||||
|
candidates.add(path.resolve(process.cwd(), raw));
|
||||||
|
if (workspaceDir && workspaceDir !== process.cwd()) {
|
||||||
|
candidates.add(path.resolve(workspaceDir, raw));
|
||||||
|
}
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (fs.existsSync(candidate)) return candidate;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensurePluginEnabled(cfg: ClawdbotConfig, pluginId: string): ClawdbotConfig {
|
||||||
|
const entries = {
|
||||||
|
...cfg.plugins?.entries,
|
||||||
|
[pluginId]: {
|
||||||
|
...(cfg.plugins?.entries?.[pluginId] as Record<string, unknown> | undefined),
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const next: ClawdbotConfig = {
|
||||||
|
...cfg,
|
||||||
|
plugins: {
|
||||||
|
...cfg.plugins,
|
||||||
|
...(cfg.plugins?.enabled === false ? { enabled: true } : {}),
|
||||||
|
entries,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return ensurePluginAllowlist(next, pluginId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensurePluginAllowlist(cfg: ClawdbotConfig, pluginId: string): ClawdbotConfig {
|
||||||
|
const allow = cfg.plugins?.allow;
|
||||||
|
if (!allow || allow.includes(pluginId)) return cfg;
|
||||||
|
return {
|
||||||
|
...cfg,
|
||||||
|
plugins: {
|
||||||
|
...cfg.plugins,
|
||||||
|
allow: [...allow, pluginId],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function addPluginLoadPath(cfg: ClawdbotConfig, pluginPath: string): ClawdbotConfig {
|
||||||
|
const existing = cfg.plugins?.load?.paths ?? [];
|
||||||
|
const merged = Array.from(new Set([...existing, pluginPath]));
|
||||||
|
return {
|
||||||
|
...cfg,
|
||||||
|
plugins: {
|
||||||
|
...cfg.plugins,
|
||||||
|
load: {
|
||||||
|
...cfg.plugins?.load,
|
||||||
|
paths: merged,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function promptInstallChoice(params: {
|
||||||
|
entry: ChannelPluginCatalogEntry;
|
||||||
|
localPath?: string | null;
|
||||||
|
prompter: WizardPrompter;
|
||||||
|
}): Promise<InstallChoice> {
|
||||||
|
const { entry, localPath, prompter } = params;
|
||||||
|
const options: Array<{ value: InstallChoice; label: string; hint?: string }> = [
|
||||||
|
{ value: "npm", label: `Download from npm (${entry.install.npmSpec})` },
|
||||||
|
...(localPath
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
value: "local",
|
||||||
|
label: "Use local plugin path",
|
||||||
|
hint: localPath,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
{ value: "skip", label: "Skip for now" },
|
||||||
|
];
|
||||||
|
return (await prompter.select({
|
||||||
|
message: `Install ${entry.meta.label} plugin?`,
|
||||||
|
options,
|
||||||
|
initialValue: localPath ? "local" : "npm",
|
||||||
|
})) as InstallChoice;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureOnboardingPluginInstalled(params: {
|
||||||
|
cfg: ClawdbotConfig;
|
||||||
|
entry: ChannelPluginCatalogEntry;
|
||||||
|
prompter: WizardPrompter;
|
||||||
|
runtime: RuntimeEnv;
|
||||||
|
workspaceDir?: string;
|
||||||
|
}): Promise<InstallResult> {
|
||||||
|
const { entry, prompter, runtime, workspaceDir } = params;
|
||||||
|
let next = params.cfg;
|
||||||
|
const localPath = resolveLocalPath(entry, workspaceDir);
|
||||||
|
const choice = await promptInstallChoice({
|
||||||
|
entry,
|
||||||
|
localPath,
|
||||||
|
prompter,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (choice === "skip") {
|
||||||
|
return { cfg: next, installed: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (choice === "local" && localPath) {
|
||||||
|
next = addPluginLoadPath(next, localPath);
|
||||||
|
next = ensurePluginEnabled(next, entry.id);
|
||||||
|
return { cfg: next, installed: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await installPluginFromNpmSpec({
|
||||||
|
spec: entry.install.npmSpec,
|
||||||
|
logger: {
|
||||||
|
info: (msg) => runtime.log?.(msg),
|
||||||
|
warn: (msg) => runtime.log?.(msg),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.ok) {
|
||||||
|
next = ensurePluginEnabled(next, result.pluginId);
|
||||||
|
return { cfg: next, installed: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
await prompter.note(
|
||||||
|
`Failed to install ${entry.install.npmSpec}: ${result.error}`,
|
||||||
|
"Plugin install",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (localPath) {
|
||||||
|
const fallback = await prompter.confirm({
|
||||||
|
message: `Use local plugin path instead? (${localPath})`,
|
||||||
|
initialValue: true,
|
||||||
|
});
|
||||||
|
if (fallback) {
|
||||||
|
next = addPluginLoadPath(next, localPath);
|
||||||
|
next = ensurePluginEnabled(next, entry.id);
|
||||||
|
return { cfg: next, installed: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime.error?.(`Plugin install failed: ${result.error}`);
|
||||||
|
return { cfg: next, installed: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reloadOnboardingPluginRegistry(params: {
|
||||||
|
cfg: ClawdbotConfig;
|
||||||
|
runtime: RuntimeEnv;
|
||||||
|
workspaceDir?: string;
|
||||||
|
}): void {
|
||||||
|
const workspaceDir =
|
||||||
|
params.workspaceDir ??
|
||||||
|
resolveAgentWorkspaceDir(params.cfg, resolveDefaultAgentId(params.cfg));
|
||||||
|
const log = createSubsystemLogger("plugins");
|
||||||
|
loadClawdbotPlugins({
|
||||||
|
config: params.cfg,
|
||||||
|
workspaceDir,
|
||||||
|
cache: false,
|
||||||
|
logger: {
|
||||||
|
info: (msg) => log.info(msg),
|
||||||
|
warn: (msg) => log.warn(msg),
|
||||||
|
error: (msg) => log.error(msg),
|
||||||
|
debug: (msg) => log.debug(msg),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user