feat: add onboarding plugin install flow
This commit is contained in:
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