import { exec, execFile } from "node:child_process"; import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; import { createRequire } from "node:module"; import path from "node:path"; import { tmpdir } from "node:os"; import { promisify } from "node:util"; import { afterEach, describe, expect, it } from "vitest"; import { scaffoldPluginDirectory } from "./scaffold.js"; const directories: string[] = []; const execFileAsync = promisify(execFile); const execAsync = promisify(exec); const tscPath = createRequire(import.meta.url).resolve("typescript/bin/tsc"); async function typecheckPlugin(directory: string): Promise { const configPath = path.join(directory, "tsconfig.json"); try { await execFileAsync(process.execPath, [tscPath, "--noEmit", "-p", configPath], { cwd: directory, }); } catch (error) { const failure = error as { stderr?: string; stdout?: string }; throw new Error(`${failure.stdout ?? ""}${failure.stderr ?? ""}`, { cause: error }); } } afterEach(async () => { await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true }))); }); describe("plugin scaffold", () => { it("creates an author overview beside the manifest", async () => { const parent = await mkdtemp(path.join(tmpdir(), "paseo-plugin-scaffold-")); directories.push(parent); const directory = path.join(parent, "hello-plugin"); await scaffoldPluginDirectory(directory); const overview = await readFile(path.join(directory, "OVERVIEW.md"), "utf8"); const comments = overview.match(//g) ?? []; expect(comments).toHaveLength(1); expect(overview.replace(//g, "").trim()).toBe(""); expect(overview).toContain("required to list"); expect(overview).toContain("README"); expect(overview).toContain("choosing a plugin in Paseo"); expect(overview).toContain("Length follows complexity"); expect(overview).toContain("1. Describe what your plugin is and does"); expect(overview).toContain("2. Explain how it works only when it is not obvious"); expect(overview).toContain("3. Explain setup when needed"); expect(overview).toContain("4. Explain capabilities and settings"); expect(overview).toContain("permissions"); expect(overview).toContain("known limits"); expect(overview).not.toContain("This plugin entry was imported"); const packageJson = JSON.parse(await readFile(path.join(directory, "package.json"), "utf8")); expect(packageJson.files).toContain("OVERVIEW.md"); expect(await readdir(directory)).toContain("paseo-plugin.json"); }); it("includes the author overview in the packed npm artifact", async () => { const parent = await mkdtemp(path.join(tmpdir(), "paseo-plugin-scaffold-")); directories.push(parent); const directory = path.join(parent, "packed-plugin"); await scaffoldPluginDirectory(directory); const { stdout } = await execAsync("npm pack --dry-run --ignore-scripts --json", { cwd: directory, }); const [artifact] = JSON.parse(stdout) as { files: { path: string }[] }[]; expect(artifact.files.map((file) => file.path)).toEqual( expect.arrayContaining(["OVERVIEW.md", "paseo-plugin.json"]), ); }); it( "creates a standalone split-runtime project that typechecks", { timeout: 15_000 }, async () => { const parent = await mkdtemp(path.join(process.cwd(), ".plugin-scaffold-")); directories.push(parent); const directory = path.join(parent, "hello-plugin"); await scaffoldPluginDirectory(directory); const configPath = path.join(directory, "tsconfig.json"); const config = JSON.parse(await readFile(configPath, "utf8")) as { compilerOptions: { lib: string[]; types: string[] }; }; expect(config.compilerOptions.lib).toEqual(["ES2023"]); expect(config.compilerOptions.types).toEqual(["react"]); await expect(typecheckPlugin(directory)).resolves.toBeUndefined(); const cliPackageJson = JSON.parse( await readFile(new URL("../../../package.json", import.meta.url), "utf8"), ) as { version: string }; expect(JSON.parse(await readFile(path.join(directory, "paseo-plugin.json"), "utf8"))).toEqual( { id: "hello-plugin", $comment: expect.stringContaining('"icon": "assets/icon.png"'), media: [], requirements: { paseo: `>=${cliPackageJson.version}` }, }, ); expect(JSON.parse(await readFile(path.join(directory, "package.json"), "utf8"))).toEqual({ name: "hello-plugin", private: true, version: "0.0.0", files: [ "paseo-plugin.json", "OVERVIEW.md", "index.client.ts", "index.client.tsx", "index.server.ts", "index.server.tsx", "client/", "server/", "shared/", ], scripts: { typecheck: "tsc --noEmit" }, devDependencies: { "@getpaseo/plugin": cliPackageJson.version, "@tanstack/react-query": "^5.90.11", "@types/react": "~19.2.0", react: "19.1.0", "react-native": "0.81.5", typescript: "^5.9.3", zod: "^4.4.3", }, }); expect(await readdir(directory)).not.toContain("paseo-plugin.d.ts"); await expect(readFile(path.join(directory, "index.client.tsx"), "utf8")).resolves.toContain( 'from "./client/greeting"', ); await expect(readFile(path.join(directory, "index.server.ts"), "utf8")).resolves.toContain( 'from "./server/greeting"', ); await expect( readFile(path.join(directory, "client/greeting.tsx"), "utf8"), ).resolves.toContain("useRpc(greetingRpc)"); await expect(readFile(path.join(directory, "client/web.ts"), "utf8")).resolves.not.toContain( `/// `, ); await expect(readFile(path.join(directory, "client/web.ts"), "utf8")).resolves.toContain( `declare const window: { open(url: string, target: string, features: string): unknown };`, ); await expect(readFile(path.join(directory, "client/web.ts"), "utf8")).resolves.toContain( `Platform.OS === "web"`, ); await expect( readFile(path.join(directory, "client/greeting.tsx"), "utf8"), ).resolves.toContain(`openExternal("https://paseo.sh")`); await expect(readFile(path.join(directory, "server/greeting.ts"), "utf8")).resolves.toContain( '"Hello, " + name + "!"', ); await expect(readFile(path.join(directory, "shared/greeting.ts"), "utf8")).resolves.toContain( 'name: "greeting.create"', ); await writeFile(path.join(directory, "client/greeting.tsx"), '\ndocument.title = "x";\n', { flag: "a", }); await expect(typecheckPlugin(directory)).rejects.toThrow("Cannot find name 'document'."); }, ); it("typechecks client and server Paseo API access", async () => { const parent = await mkdtemp(path.join(process.cwd(), ".plugin-scaffold-")); directories.push(parent); const directory = path.join(parent, "paseo-api-plugin"); await scaffoldPluginDirectory(directory); await Promise.all([ writeFile( path.join(directory, "shared", "inspect.ts"), `import { defineRpc } from "@getpaseo/plugin"; import { z } from "zod"; export const inspect = defineRpc({ name: "inspect", input: z.object({}), output: z.object({ configured: z.boolean() }), }); `, ), writeFile( path.join(directory, "server", "inspect.ts"), `import type { PluginHandlerContext } from "@getpaseo/plugin/server"; import type { RpcInput } from "@getpaseo/plugin"; import { inspect } from "../shared/inspect"; export async function inspectConfig( _input: RpcInput, { paseo }: PluginHandlerContext, ) { return { configured: Boolean((await paseo.config.get()).config) }; } `, ), writeFile( path.join(directory, "client", "main.tsx"), `import React from "react"; import { Text } from "react-native"; import { Icon, Modal, useToast } from "@getpaseo/plugin/client/react-native"; import { type PluginAgentPanelProps, type PluginClientContext, type PluginSurfaceProps, useAgent, usePaseo, useWorkspace } from "@getpaseo/plugin/client"; import { inspect } from "../shared/inspect"; export function Surface({ navigation }: PluginSurfaceProps) { const paseo = usePaseo(); const toast = useToast(); const createWorkspace = () => paseo.workspaces.create({ source: { kind: "directory", path: "/repo" }, }); navigation?.openAgent({ agentId: "agent-1" }); navigation?.openWorkspace({ workspaceId: "workspace-1" }); navigation?.openAgent({ serverId: "server-2", agentId: "agent-2" }); navigation?.openWorkspace({ serverId: "server-2", workspaceId: "workspace-2" }); void createWorkspace; return <> toast.show("Ready")}>Paseo API} open={false} onOpenChange={() => {}}>Modal; } export function AgentPanel({ workspaceId, agentId }: PluginAgentPanelProps) { const workspaceName = useWorkspace(workspaceId, (workspace) => { // @ts-expect-error Plugin snapshots are readonly. workspace.name = "mutated"; return workspace.name; }); const agentTitle = useAgent(agentId, (agent) => { // @ts-expect-error Nested plugin snapshot values are readonly. agent.labels.phase = "mutated"; return agent.title; }); return {workspaceName}: {agentTitle}; } export function contributeClient(client: PluginClientContext) { const header = client.addHeaderButton({ id: "review-tools", workspaceId: "workspace-a", button: { title: "Review tools", icon: "Scan", label: "Review", behavior: { kind: "menu", items: [{ kind: "item", id: "refresh", title: "Refresh review", behavior: { kind: "action", async onPress() { await client.rpc(inspect, {}); }, } }], }, }, }); const pill = client.addComposerPill({ id: "open-review", workspaceId: "workspace-a", agentId: "agent-a", button: { title: "Open review", icon: "Scan", behavior: { kind: "action", async onPress() { await client.rpc(inspect, {}); client.openPanel("review", { workspaceId: "workspace-a", agentId: "agent-a" }); }, }, }, }); header.update({ visible: false }); pill.update({ disabled: true }); return () => { header.remove(); pill.remove(); }; } `, ), writeFile( path.join(directory, "index.client.tsx"), `import type { PluginClientContext } from "@getpaseo/plugin/client"; import { AgentPanel, contributeClient, Surface } from "./client/main"; import { inspect } from "./shared/inspect"; export default function contribute(client: PluginClientContext) { client.addScreen({ id: "main", title: "Review", Component: Surface }); client.addWorkspacePanel({ id: "review", title: "Review", icon: "Scan", context: "agent", Component: AgentPanel, }); client.addCommandCenterItem({ id: "open-review", title: "Open review", icon: "Scan", context: "agent", async onSelect({ paseo, rpc, workspace, openPanel }) { await paseo.workspaces.ref(workspace.id).setTitle("Review"); await rpc(inspect, {}); openPanel("review"); }, }); return contributeClient(client); } `, ), writeFile( path.join(directory, "index.server.ts"), `import type { PluginServerContext } from "@getpaseo/plugin/server"; import { inspectConfig } from "./server/inspect"; import { inspect } from "./shared/inspect"; export default function contribute(server: PluginServerContext) { server.handle(inspect, inspectConfig); return () => {}; } `, ), ]); await expect(typecheckPlugin(directory)).resolves.toBeUndefined(); }, 20_000); it("reports TypeScript errors for the old composer pill shape and cleanup", async () => { const parent = await mkdtemp(path.join(process.cwd(), ".plugin-scaffold-")); directories.push(parent); const directory = path.join(parent, "old-composer-pill"); await scaffoldPluginDirectory(directory); await writeFile( path.join(directory, "index.client.tsx"), ` import type { PluginClientContext, PluginComposerPillProps } from "@getpaseo/plugin/client"; export default function contribute(client: PluginClientContext) { const oldPill = { id: "review", workspaceId: "workspace-a", agentId: "agent-a", title: "Review", Component: () => null, onPress() {}, }; client.addComposerPill(oldPill); const pill = client.addComposerPill({ id: "review", workspaceId: "workspace-a", agentId: "agent-a", button: { title: "Review", icon: "Scan", behavior: { kind: "action", onPress() {} } }, }); pill(); return () => pill.remove(); } `, ); const check = typecheckPlugin(directory); await expect(check).rejects.toThrow( /has no exported member (named )?'PluginComposerPillProps'/, ); await expect(check).rejects.toThrow("Property 'button' is missing"); await expect(check).rejects.toThrow("has no call signatures"); }, 20_000); it("refuses to write into a non-empty directory", async () => { const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-scaffold-")); directories.push(directory); await writeFile(path.join(directory, "notes.txt"), "keep me"); await expect(scaffoldPluginDirectory(directory, "hello-plugin")).rejects.toThrow( "Plugin directory must be empty", ); expect(await readFile(path.join(directory, "notes.txt"), "utf8")).toBe("keep me"); }); });