Files
cline/apps/cli/src/commands/plugin.test.ts
T

739 lines
22 KiB
TypeScript

import { execFileSync } from "node:child_process";
import {
existsSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { mkdir, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
discoverPluginModulePaths,
resolvePluginConfigSearchPaths,
setClineDir,
setHomeDir,
} from "@cline/shared/storage";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
installPlugin,
isOfficialPluginSlug,
parsePluginSource,
runPluginInstallCommand,
runPluginUninstallCommand,
} from "./plugin";
type FetchCall = (
...args: Parameters<typeof fetch>
) => ReturnType<typeof fetch>;
describe("plugin install command", () => {
let root = "";
let home = "";
let workspace = "";
let originalHome: string | undefined;
let originalClineDir: string | undefined;
let originalClineDataDir: string | undefined;
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), "cli-plugin-install-"));
home = join(root, "home");
workspace = join(root, "workspace");
originalHome = process.env.HOME;
originalClineDir = process.env.CLINE_DIR;
originalClineDataDir = process.env.CLINE_DATA_DIR;
process.env.HOME = home;
process.env.CLINE_DIR = join(home, ".cline");
process.env.CLINE_DATA_DIR = join(home, ".cline", "data");
setHomeDir(home);
setClineDir(process.env.CLINE_DIR);
});
function runGitCommand(cwd: string, args: string[]): void {
execFileSync("git", args, { cwd, stdio: "ignore" });
}
async function createOfficialPluginsRepo(
plugins: Record<string, Record<string, string>>,
): Promise<string> {
const repo = mkdtempSync(join(root, "official-plugins-"));
for (const [slug, files] of Object.entries(plugins)) {
const pluginRoot = join(repo, "plugins", slug);
await mkdir(pluginRoot, { recursive: true });
for (const [filename, content] of Object.entries(files)) {
await writeFile(join(pluginRoot, filename), content, "utf8");
}
}
runGitCommand(repo, ["init"]);
runGitCommand(repo, ["config", "user.email", "test@example.com"]);
runGitCommand(repo, ["config", "user.name", "Cline Test"]);
runGitCommand(repo, ["add", "."]);
runGitCommand(repo, ["commit", "-m", "seed plugins"]);
return repo;
}
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
if (originalHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = originalHome;
}
if (originalClineDir === undefined) {
delete process.env.CLINE_DIR;
} else {
process.env.CLINE_DIR = originalClineDir;
}
if (originalClineDataDir === undefined) {
delete process.env.CLINE_DATA_DIR;
} else {
process.env.CLINE_DATA_DIR = originalClineDataDir;
}
rmSync(root, { recursive: true, force: true });
});
it("parses explicit npm source type without the npm prefix", () => {
expect(parsePluginSource("@scope/plugin@1.2.3", "npm")).toEqual({
type: "npm",
spec: "@scope/plugin@1.2.3",
name: "@scope/plugin",
});
});
it("parses explicit git source type without the git prefix", () => {
expect(parsePluginSource("github.com/acme/plugin", "git")).toMatchObject({
type: "git",
repo: "https://github.com/acme/plugin",
host: "github.com",
path: "acme/plugin",
});
});
it("parses bare official keywords as official plugin slugs", () => {
expect(isOfficialPluginSlug("clickhouse")).toBe(true);
expect(isOfficialPluginSlug("web-search")).toBe(true);
expect(isOfficialPluginSlug("WebSearch")).toBe(false);
expect(parsePluginSource("clickhouse")).toEqual({
type: "official",
slug: "clickhouse",
});
expect(parsePluginSource("web-search")).toEqual({
type: "official",
slug: "web-search",
});
expect(parsePluginSource("web-search", "npm")).toEqual({
type: "npm",
spec: "web-search",
name: "web-search",
});
});
it("rejects hostname-style sources without --git guidance", () => {
expect(() => parsePluginSource("github.com/acme/plugin")).toThrow(
/Use --git/,
);
});
it("parses GitHub plugin file URLs as remote sources", () => {
expect(
parsePluginSource(
"https://github.com/cline/cline/blob/main/sdk/examples/plugins/weather-metrics.ts",
),
).toEqual({
type: "remote",
url: "https://raw.githubusercontent.com/cline/cline/main/sdk/examples/plugins/weather-metrics.ts",
filename: "weather-metrics.ts",
});
});
it("parses raw plugin file URLs as remote sources", () => {
expect(
parsePluginSource(
"https://raw.githubusercontent.com/cline/cline/main/sdk/examples/plugins/weather-metrics.ts",
),
).toEqual({
type: "remote",
url: "https://raw.githubusercontent.com/cline/cline/main/sdk/examples/plugins/weather-metrics.ts",
filename: "weather-metrics.ts",
});
});
it("rejects HTTP plugin file URLs", () => {
expect(() =>
parsePluginSource("http://example.com/plugins/weather-metrics.ts"),
).toThrow(/must use https/);
});
it("installs a local plugin file into the global plugin root", async () => {
const source = join(root, "weather.ts");
writeFileSync(
source,
"export const plugin = { name: 'weather', manifest: { capabilities: ['tools'] } };",
"utf8",
);
const result = await installPlugin({ source });
expect(result.installPath).toContain(join(home, ".cline", "plugins"));
expect(result.entryPaths).toHaveLength(1);
expect(existsSync(result.entryPaths[0] ?? "")).toBe(true);
const discovered = discoverPluginModulePaths(
join(home, ".cline", "plugins"),
);
expect(discovered).toEqual(result.entryPaths);
});
it("installs a remote plugin file into the workspace plugin root", async () => {
const source =
"https://github.com/acme/plugins/blob/main/weather-metrics.ts";
const fetchMock = vi.fn<FetchCall>(async (input) => {
expect(String(input)).toBe(
"https://raw.githubusercontent.com/acme/plugins/main/weather-metrics.ts",
);
return new Response(
"export default { name: 'remote-weather', manifest: { capabilities: ['tools'] } };",
);
});
vi.stubGlobal("fetch", fetchMock);
const result = await installPlugin({ source, cwd: workspace });
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(result.installPath).toContain(
join(workspace, ".cline", "plugins", "_installed", "remote"),
);
expect(result.entryPaths).toHaveLength(1);
expect(existsSync(result.entryPaths[0] ?? "")).toBe(true);
expect(readFileSync(result.entryPaths[0] ?? "", "utf8")).toContain(
"remote-weather",
);
expect(
discoverPluginModulePaths(join(workspace, ".cline", "plugins")),
).toEqual(result.entryPaths);
});
it("installs an official plugin slug from the configured collection repo", async () => {
const officialPluginsRepo = await createOfficialPluginsRepo({
"web-search": {
"index.ts":
"export default { name: 'official-web-search', manifest: { capabilities: ['tools'] } };",
},
"other-plugin": {
"index.ts":
"export default { name: 'other-plugin', manifest: { capabilities: ['tools'] } };",
},
});
const result = await installPlugin({
source: "web-search",
cwd: workspace,
officialPluginsRepo,
});
expect(result.installPath).toContain(
join(workspace, ".cline", "plugins", "_installed", "official"),
);
expect(result.entryPaths).toHaveLength(1);
expect(readFileSync(result.entryPaths[0] ?? "", "utf8")).toContain(
"official-web-search",
);
const wrapperManifest = JSON.parse(
readFileSync(join(result.installPath, "package.json"), "utf8"),
) as { name?: string };
expect(wrapperManifest.name).toBe("web-search");
expect(existsSync(join(result.installPath, "repo"))).toBe(false);
expect(
existsSync(join(result.installPath, "package", "other-plugin")),
).toBe(false);
expect(
discoverPluginModulePaths(join(workspace, ".cline", "plugins")),
).toEqual(result.entryPaths);
});
it("installs an official package plugin and runs package dependency install", async () => {
const officialPluginsRepo = await createOfficialPluginsRepo({
"package-plugin": {
"package.json": JSON.stringify(
{
name: "package-plugin",
type: "module",
cline: {
plugins: [{ paths: ["./index.ts"] }],
},
dependencies: {
yaml: "^2.8.1",
},
},
null,
2,
),
"index.ts":
"export default { name: 'package-plugin', manifest: { capabilities: ['tools'] } };",
},
});
const npmLogPath = join(root, "official-npm-install.log");
const npmCommandPath = join(root, "official-fake-npm.sh");
writeFileSync(
npmCommandPath,
`#!/bin/sh\nprintf '%s\\n' "$PWD $*" >> "${npmLogPath}"\nexit 0\n`,
{ encoding: "utf8", mode: 0o755 },
);
const result = await installPlugin({
source: "package-plugin",
cwd: workspace,
officialPluginsRepo,
npmCommand: npmCommandPath,
});
const npmLog = readFileSync(npmLogPath, "utf8");
expect(npmLog).toContain("package install --omit=dev --omit=peer");
expect(result.entryPaths).toHaveLength(1);
expect(readFileSync(result.entryPaths[0] ?? "", "utf8")).toContain(
"package-plugin",
);
});
it("reports a clear error when an official plugin slug is missing", async () => {
const officialPluginsRepo = await createOfficialPluginsRepo({
"known-plugin": {
"index.ts":
"export default { name: 'known-plugin', manifest: { capabilities: ['tools'] } };",
},
});
await expect(
installPlugin({
source: "missing-plugin",
cwd: workspace,
officialPluginsRepo,
}),
).rejects.toThrow(
/Official Cline plugin "missing-plugin" was not found at plugins\/missing-plugin/,
);
});
it("keeps explicit relative paths as local plugin installs", async () => {
const localPluginRoot = join(workspace, "web-search");
await mkdir(localPluginRoot, { recursive: true });
await writeFile(
join(localPluginRoot, "index.ts"),
"export default { name: 'local-web-search', manifest: { capabilities: ['tools'] } };",
"utf8",
);
const result = await installPlugin({
source: "./web-search",
cwd: workspace,
});
expect(result.installPath).toContain(
join(workspace, ".cline", "plugins", "_installed", "local"),
);
const wrapperManifest = JSON.parse(
readFileSync(join(result.installPath, "package.json"), "utf8"),
) as { name?: string };
expect(wrapperManifest.name).toBe("web-search");
expect(readFileSync(result.entryPaths[0] ?? "", "utf8")).toContain(
"local-web-search",
);
});
it("times out stalled remote plugin downloads", async () => {
vi.useFakeTimers();
const source =
"https://github.com/acme/plugins/blob/main/weather-metrics.ts";
const fetchMock = vi.fn<FetchCall>((_input, init) => {
return new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener("abort", () => {
const error = new Error("Aborted");
error.name = "AbortError";
reject(error);
});
});
});
vi.stubGlobal("fetch", fetchMock);
const install = installPlugin({ source, cwd: workspace });
const rejection = expect(install).rejects.toThrow(/Timed out downloading/);
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
await vi.advanceTimersByTimeAsync(30_000);
await rejection;
});
it("rejects remote plugin files with oversized content length", async () => {
const source =
"https://github.com/acme/plugins/blob/main/weather-metrics.ts";
const fetchMock = vi.fn<FetchCall>(async () => {
return new Response(
"export default { name: 'remote-weather', manifest: { capabilities: ['tools'] } };",
{
headers: {
"content-length": String(10 * 1024 * 1024 + 1),
},
},
);
});
vi.stubGlobal("fetch", fetchMock);
await expect(installPlugin({ source, cwd: workspace })).rejects.toThrow(
/exceeds the 10485760 byte limit/,
);
});
it("rejects remote plugin files that stream past the size limit", async () => {
const source =
"https://github.com/acme/plugins/blob/main/weather-metrics.ts";
const fetchMock = vi.fn<FetchCall>(async () => {
return new Response(
new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new Uint8Array(10 * 1024 * 1024 + 1));
controller.close();
},
}),
);
});
vi.stubGlobal("fetch", fetchMock);
await expect(installPlugin({ source, cwd: workspace })).rejects.toThrow(
/exceeds the 10485760 byte limit/,
);
});
it("installs into cwd plugin root when cwd is provided", async () => {
const source = join(root, "plugin-package");
const npmLogPath = join(root, "npm-install.log");
const npmCommandPath = join(root, "fake-npm.sh");
writeFileSync(
npmCommandPath,
`#!/bin/sh\nprintf '%s\\n' "$PWD $*" >> "${npmLogPath}"\nexit 0\n`,
{ encoding: "utf8", mode: 0o755 },
);
await mkdir(join(source, "node_modules", "dependency"), {
recursive: true,
});
await writeFile(
join(source, "package.json"),
JSON.stringify(
{
name: "plugin-package",
cline: {
plugins: [{ paths: ["./index.ts"], capabilities: ["tools"] }],
},
dependencies: {
"@cline/core": "latest",
yaml: "^2.8.1",
},
peerDependencies: {
"@cline/shared": "*",
bun: ">=1.0.0",
},
peerDependenciesMeta: {
"@cline/shared": {
optional: true,
},
},
},
null,
2,
),
"utf8",
);
await writeFile(
join(source, "index.ts"),
"export default { name: 'plugin-package', manifest: { capabilities: ['tools'] } };",
"utf8",
);
await writeFile(
join(source, "node_modules", "dependency", "noise.ts"),
"export default { name: 'noise', manifest: { capabilities: ['tools'] } };",
"utf8",
);
await mkdir(join(source, ".git", "objects"), { recursive: true });
await writeFile(join(source, ".git", "HEAD"), "ref: refs/heads/main\n");
const result = await installPlugin({
source,
cwd: workspace,
npmCommand: npmCommandPath,
});
const wrapperManifest = JSON.parse(
readFileSync(join(result.installPath, "package.json"), "utf8"),
) as { name?: string; cline?: { plugins?: Array<{ paths?: string[] }> } };
expect(wrapperManifest.name).toBe("plugin-package");
expect(wrapperManifest.cline?.plugins?.[0]?.paths).toHaveLength(1);
expect(wrapperManifest.cline?.plugins?.[0]?.paths?.[0]).toContain(
"package/index.ts",
);
const packageManifest = JSON.parse(
readFileSync(join(result.installPath, "package", "package.json"), "utf8"),
) as {
dependencies?: Record<string, string>;
peerDependencies?: Record<string, string>;
peerDependenciesMeta?: Record<string, unknown>;
};
expect(packageManifest.dependencies).toEqual({ yaml: "^2.8.1" });
expect(packageManifest.peerDependencies).toEqual({ bun: ">=1.0.0" });
expect(packageManifest.peerDependenciesMeta).toBeUndefined();
const npmLog = readFileSync(npmLogPath, "utf8");
expect(npmLog).toContain(`${join(".tmp")}/`);
expect(npmLog).toContain(
"package install --omit=dev --omit=peer --legacy-peer-deps --no-audit --no-fund --package-lock=false",
);
expect(existsSync(join(result.installPath, "package", ".git"))).toBe(false);
expect(
existsSync(join(result.installPath, "package", "node_modules")),
).toBe(false);
const discovered = discoverPluginModulePaths(
join(workspace, ".cline", "plugins"),
);
expect(discovered).toEqual(result.entryPaths);
expect(discovered.some((path) => path.includes("noise.ts"))).toBe(false);
});
it("omits and removes host SDK packages from npm-sourced installs", async () => {
const npmLogPath = join(root, "npm-source-install.log");
const npmCommandPath = join(root, "fake-npm-source.sh");
writeFileSync(
npmCommandPath,
[
"#!/bin/sh",
`printf '%s\\n' "$*" >> "${npmLogPath}"`,
"prefix=''",
"while [ $# -gt 0 ]; do",
" if [ \"$1\" = '--prefix' ]; then",
" shift",
' prefix="$1"',
" fi",
" shift",
"done",
'mkdir -p "$prefix/node_modules/published-plugin"',
'mkdir -p "$prefix/node_modules/@cline/core"',
'printf \'%s\\n\' \'{"name":"published-plugin","type":"module","cline":{"plugins":["index.ts"]}}\' > "$prefix/node_modules/published-plugin/package.json"',
"printf '%s\\n' \"export default { name: 'published-plugin', manifest: { capabilities: ['tools'] } };\" > \"$prefix/node_modules/published-plugin/index.ts\"",
'printf \'%s\\n\' \'{"name":"@cline/core"}\' > "$prefix/node_modules/@cline/core/package.json"',
"exit 0",
].join("\n"),
{ encoding: "utf8", mode: 0o755 },
);
const result = await installPlugin({
source: "npm:published-plugin@1.0.0",
npmCommand: npmCommandPath,
});
const npmLog = readFileSync(npmLogPath, "utf8");
expect(npmLog).toContain("install published-plugin@1.0.0");
expect(npmLog).toContain("--omit=peer");
expect(npmLog).toContain("--legacy-peer-deps");
expect(
existsSync(
join(result.installPath, "package", "node_modules", "@cline", "core"),
),
).toBe(false);
expect(existsSync(result.entryPaths[0] ?? "")).toBe(true);
});
it("requires --force before replacing an existing install", async () => {
const source = join(root, "replace.ts");
writeFileSync(
source,
"export default { name: 'replace', manifest: { capabilities: ['tools'] } };",
"utf8",
);
const first = await installPlugin({ source });
await expect(installPlugin({ source })).rejects.toThrow(/Use --force/);
const second = await installPlugin({ source, force: true });
expect(second.installPath).toBe(first.installPath);
});
it("keeps an existing install when a forced replacement fails during staging", async () => {
const source = join(root, "replace-package");
const npmCommandPath = join(root, "fake-npm.sh");
await mkdir(source, { recursive: true });
await writeFile(
join(source, "package.json"),
JSON.stringify(
{
name: "replace-package",
cline: {
plugins: [{ paths: ["./index.ts"], capabilities: ["tools"] }],
},
},
null,
2,
),
"utf8",
);
await writeFile(
join(source, "index.ts"),
"export default { name: 'installed-v1', manifest: { capabilities: ['tools'] } };",
"utf8",
);
writeFileSync(npmCommandPath, "#!/bin/sh\nexit 0\n", {
encoding: "utf8",
mode: 0o755,
});
const first = await installPlugin({ source, npmCommand: npmCommandPath });
await writeFile(
join(source, "index.ts"),
"export default { name: 'installed-v2', manifest: { capabilities: ['tools'] } };",
"utf8",
);
writeFileSync(npmCommandPath, "#!/bin/sh\nprintf 'offline' >&2\nexit 1\n", {
encoding: "utf8",
mode: 0o755,
});
await expect(
installPlugin({ source, force: true, npmCommand: npmCommandPath }),
).rejects.toThrow(/offline/);
expect(existsSync(first.installPath)).toBe(true);
expect(
readFileSync(join(first.installPath, "package", "index.ts"), "utf8"),
).toContain("installed-v1");
});
it("uninstalls a package plugin by package name", async () => {
const source = join(root, "uninstall-package");
const npmCommandPath = join(root, "fake-npm.sh");
await mkdir(source, { recursive: true });
await writeFile(
join(source, "package.json"),
JSON.stringify(
{
name: "cli-uninstall-plugin",
cline: {
plugins: [{ paths: ["./index.ts"], capabilities: ["tools"] }],
},
},
null,
2,
),
"utf8",
);
await writeFile(
join(source, "index.ts"),
"export default { name: 'cli-uninstall-plugin', manifest: { capabilities: ['tools'] } };",
"utf8",
);
writeFileSync(npmCommandPath, "#!/bin/sh\nexit 0\n", {
encoding: "utf8",
mode: 0o755,
});
const installed = await installPlugin({
source,
npmCommand: npmCommandPath,
});
const output: string[] = [];
const code = await runPluginUninstallCommand({
name: "cli-uninstall-plugin",
io: {
writeln: (text = "") => output.push(text),
writeErr: (text) => output.push(text),
},
});
expect(code).toBe(0);
expect(existsSync(installed.installPath)).toBe(false);
expect(output.join("\n")).toContain(
"Uninstalled plugin cli-uninstall-plugin",
);
});
it("prints JSON output for command callers", async () => {
const source = join(root, "json.ts");
writeFileSync(
source,
"export default { name: 'json', manifest: { capabilities: ['tools'] } };",
"utf8",
);
const stdout: string[] = [];
const originalWrite = process.stdout.write;
process.stdout.write = ((chunk: string | Uint8Array) => {
stdout.push(String(chunk));
return true;
}) as typeof process.stdout.write;
try {
const code = await runPluginInstallCommand({
source,
json: true,
io: {
writeln: () => {},
writeErr: () => {},
},
});
expect(code).toBe(0);
const parsed = JSON.parse(stdout.join("")) as { installPath: string };
expect(parsed.installPath).toContain(join(home, ".cline", "plugins"));
} finally {
process.stdout.write = originalWrite;
}
});
it("prints JSON output for official plugin installs", async () => {
const officialPluginsRepo = await createOfficialPluginsRepo({
"json-plugin": {
"index.ts":
"export default { name: 'json-plugin', manifest: { capabilities: ['tools'] } };",
},
});
const stdout: string[] = [];
const originalWrite = process.stdout.write;
process.stdout.write = ((chunk: string | Uint8Array) => {
stdout.push(String(chunk));
return true;
}) as typeof process.stdout.write;
try {
const code = await runPluginInstallCommand({
source: "json-plugin",
cwd: workspace,
officialPluginsRepo,
json: true,
io: {
writeln: () => {},
writeErr: () => {},
},
});
expect(code).toBe(0);
const parsed = JSON.parse(stdout.join("")) as { installPath: string };
expect(parsed.installPath).toContain(
join(workspace, ".cline", "plugins", "_installed", "official"),
);
} finally {
process.stdout.write = originalWrite;
}
});
it("uses shared search paths for cwd installs", async () => {
const source = join(root, "workspace.ts");
writeFileSync(
source,
"export default { name: 'workspace', manifest: { capabilities: ['tools'] } };",
"utf8",
);
await installPlugin({
source,
cwd: workspace,
});
expect(resolvePluginConfigSearchPaths(workspace)[0]).toBe(
join(workspace, ".cline", "plugins"),
);
expect(
discoverPluginModulePaths(join(workspace, ".cline", "plugins")),
).toHaveLength(1);
});
});