Add managed local plugin lifecycle (#3222)

* feat(plugins): add local plugin surfaces and RPCs

Keep plugin execution scoped to its daemon while allowing one local TypeScript entry point to contribute validated RPCs and native client surfaces. Paseo owns host selection, navigation chrome, and per-installation query caches.

* feat(plugins): add Linear attachment source

* fix(nix): refresh npm dependency hash

* test(server): exclude internal sessions from Hub counts

* test(server): distinguish Hub execution sessions

* feat(plugins): make local plugins explicitly opt-in

Keep plugin loading dormant until configuration enables it, and collect unsupported examples under one directory.

* refactor(plugins): establish host boundaries

* fix(nix): refresh plugin dependency hash

* fix(plugins): complete packaging and startup rollback

* feat(plugins): add managed local lifecycle

Make trusted local plugins recoverable and authorable through daemon-owned lifecycle operations, host settings, and a capability-gated CLI. Reloads fully stop the previous plugin before starting new code, while generated projects provide strict TSX checking without changing runtime dependency ownership.

* fix(nix): refresh plugin dependency hash
This commit is contained in:
Mohamed Boudra
2026-08-14 17:12:49 +02:00
committed by GitHub
parent f0bd2c8483
commit 23853a1515
115 changed files with 7481 additions and 17 deletions
@@ -0,0 +1,25 @@
import { describe, expect, it, vi } from "vitest";
const listPlugins = vi.fn(async () => []);
const close = vi.fn(async () => undefined);
vi.mock("../../utils/client.js", () => ({
connectToDaemon: vi.fn(async () => ({
getLastServerInfoMessage: () => ({ features: { pluginManagement: false } }),
listPlugins,
close,
})),
}));
import { runPluginListCommand } from "./index.js";
describe("plugin management commands", () => {
it("requires host support before attempting a management RPC", async () => {
await expect(runPluginListCommand({}, {} as never)).rejects.toMatchObject({
code: "DAEMON_UPDATE_REQUIRED",
message: "Update the host to use plugin management.",
});
expect(listPlugins).not.toHaveBeenCalled();
expect(close).toHaveBeenCalledTimes(1);
});
});
+121
View File
@@ -0,0 +1,121 @@
import { Command } from "commander";
import type { PluginListItem } from "@getpaseo/protocol/messages";
import type { CommandOptions, ListResult, OutputSchema, SingleResult } from "../../output/index.js";
import { withOutput } from "../../output/index.js";
import { addJsonAndDaemonHostOptions, addJsonOption } from "../../utils/command-options.js";
import { scaffoldPluginDirectory, type PluginScaffold } from "./scaffold.js";
import { withPluginManagementClient } from "./shared.js";
interface PluginOptions extends CommandOptions {
host?: string;
id?: string;
}
const pluginSchema: OutputSchema<PluginListItem> = {
idField: "id",
columns: [
{ header: "PLUGIN", field: "id", width: 20 },
{ header: "STATUS", field: "status", width: 10 },
{ header: "ENABLED", field: (plugin) => (plugin.enabled ? "yes" : "no"), width: 8 },
{ header: "DIRECTORY", field: "path", width: 40 },
{ header: "ERROR", field: (plugin) => plugin.error ?? "", width: 40 },
],
};
const scaffoldSchema: OutputSchema<PluginScaffold> = {
idField: "id",
columns: [
{ header: "PLUGIN", field: "id", width: 20 },
{ header: "DIRECTORY", field: "directory", width: 60 },
],
};
export async function runPluginInitCommand(
directory: string,
options: PluginOptions,
_command: Command,
): Promise<SingleResult<PluginScaffold>> {
return {
type: "single",
data: await scaffoldPluginDirectory(directory, options.id),
schema: scaffoldSchema,
};
}
export async function runPluginListCommand(
options: PluginOptions,
_command: Command,
): Promise<ListResult<PluginListItem>> {
const data = await withPluginManagementClient(options.host, (client) => client.listPlugins());
return { type: "list", data, schema: pluginSchema };
}
async function install(
directory: string,
options: PluginOptions,
_command: Command,
): Promise<SingleResult<PluginListItem>> {
const data = await withPluginManagementClient(options.host, (client) =>
client.installDirectoryPlugin(directory, options.id),
);
return { type: "single", data, schema: pluginSchema };
}
async function act(
action: "reload" | "enable" | "disable",
pluginId: string,
options: PluginOptions,
): Promise<SingleResult<PluginListItem>> {
const data = await withPluginManagementClient(options.host, (client) =>
client[`${action}Plugin`](pluginId),
);
return { type: "single", data, schema: pluginSchema };
}
async function remove(
pluginId: string,
options: PluginOptions,
_command: Command,
): Promise<SingleResult<PluginListItem>> {
const data = await withPluginManagementClient(options.host, async (client) => {
const current = (await client.listPlugins()).find((plugin) => plugin.id === pluginId);
if (!current) throw new Error(`Plugin is not configured: ${pluginId}`);
await client.removePlugin(pluginId);
return { ...current, enabled: false, status: "disabled" as const };
});
return { type: "single", data, schema: pluginSchema };
}
export function createPluginCommand(): Command {
const plugin = new Command("plugin").description("Manage trusted local plugins");
addJsonOption(
plugin
.command("init")
.description("Create a typecheckable local plugin")
.argument("<directory>")
.option("--id <id>", "Manifest plugin ID (defaults to the directory name)"),
).action(withOutput(runPluginInitCommand));
addJsonAndDaemonHostOptions(plugin.command("ls").description("List configured plugins")).action(
withOutput(runPluginListCommand),
);
addJsonAndDaemonHostOptions(
plugin
.command("install")
.description("Install a local plugin directory")
.argument("<directory>", "Host filesystem directory")
.option("--id <id>", "Runtime plugin ID (defaults to paseo-plugin.json id)"),
).action(withOutput(install));
for (const action of ["reload", "enable", "disable"] as const) {
addJsonAndDaemonHostOptions(
plugin.command(action).description(`${action} a local plugin`).argument("<id>"),
).action(
withOutput((id: string, options: PluginOptions, _command: Command) =>
act(action, id, options),
),
);
}
addJsonAndDaemonHostOptions(
plugin.command("remove").description("Remove plugin configuration").argument("<id>"),
).action(withOutput(remove));
return plugin;
}
@@ -0,0 +1,49 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { tmpdir } from "node:os";
import ts from "typescript";
import { afterEach, describe, expect, it } from "vitest";
import { scaffoldPluginDirectory } from "./scaffold.js";
const directories: string[] = [];
afterEach(async () => {
await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true })));
});
describe("plugin scaffold", () => {
it("creates a standalone strict TSX project that typechecks", 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 loaded = ts.readConfigFile(configPath, ts.sys.readFile);
expect(loaded.error).toBeUndefined();
const parsed = ts.parseJsonConfigFileContent(loaded.config, ts.sys, directory);
const diagnostics = ts.getPreEmitDiagnostics(
ts.createProgram(parsed.fileNames, parsed.options),
);
expect(
diagnostics.map((diagnostic) =>
ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"),
),
).toEqual([]);
expect(JSON.parse(await readFile(path.join(directory, "paseo-plugin.json"), "utf8"))).toEqual({
id: "hello-plugin",
});
});
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");
});
});
@@ -0,0 +1,167 @@
import { mkdir, readdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { PluginIdSchema } from "@getpaseo/protocol/messages";
const SDK_DECLARATIONS = `declare module "@paseo/plugin" {
import type { ComponentType } from "react";
import type { ZodType, input as ZodInput, output as ZodOutput } from "zod";
export interface PluginSurfaceProps {
theme: Record<string, unknown>;
host: { id: string; label: string };
layout: { compact: boolean; platform: "ios" | "android" | "web" };
}
export interface PluginRpcContract<
InputSchema extends ZodType = ZodType,
OutputSchema extends ZodType = ZodType,
> {
name: string;
input: InputSchema;
output: OutputSchema;
}
export interface PluginAttachmentItem {
id: string;
identifier: string;
title: string;
subtitle?: string;
url: string;
text: string;
resourceType: string;
}
export interface PluginAttachmentSearchPayload {
items: PluginAttachmentItem[];
}
export interface PluginAttachmentSourceContribution {
id: string;
title: string;
icon: string;
pickerTitle: string;
searchPlaceholder: string;
search: PluginRpcContract;
}
export interface PluginSidebarContribution {
id: string;
title: string;
icon: string;
surface: string;
}
export interface PluginSurfaceContribution {
id: string;
Component: ComponentType<PluginSurfaceProps>;
}
export interface PluginContext {
handle<InputSchema extends ZodType, OutputSchema extends ZodType>(
contract: PluginRpcContract<InputSchema, OutputSchema>,
handler: (
input: ZodOutput<InputSchema>,
) => ZodInput<OutputSchema> | Promise<ZodInput<OutputSchema>>,
): void;
addSurface(id: string, Component: ComponentType<PluginSurfaceProps>): void;
addSidebarItem(contribution: PluginSidebarContribution): void;
addAttachmentSource(contribution: PluginAttachmentSourceContribution): void;
}
export type PluginCleanup = () => void;
export type PluginContribution = (plugin: PluginContext) => PluginCleanup;
export function defineRpc<InputSchema extends ZodType, OutputSchema extends ZodType>(definition: {
name: string;
input: InputSchema;
output: OutputSchema;
}): PluginRpcContract<InputSchema, OutputSchema>;
export function useRpc<InputSchema extends ZodType, OutputSchema extends ZodType>(
contract: PluginRpcContract<InputSchema, OutputSchema>,
): (input: ZodInput<InputSchema>) => Promise<ZodOutput<OutputSchema>>;
export function defineAttachmentSource<Definition extends PluginAttachmentSourceContribution>(
definition: Definition,
): Definition;
export const PluginAttachmentItemSchema: import("zod").ZodType<PluginAttachmentItem>;
export const PluginAttachmentSearchPayloadSchema: import("zod").ZodType<PluginAttachmentSearchPayload>;
}
`;
const TSCONFIG = {
compilerOptions: {
target: "ES2020",
module: "ESNext",
moduleResolution: "Bundler",
lib: ["ES2023", "DOM"],
jsx: "react-jsx",
strict: true,
skipLibCheck: true,
noEmit: true,
esModuleInterop: true,
allowSyntheticDefaultImports: true,
},
include: ["index.tsx", "paseo-plugin.d.ts"],
};
const ENTRY = `import type { PluginContext } from "@paseo/plugin";
import React from "react";
import { Text } from "react-native";
function MainSurface() {
return <Text>Hello from my plugin</Text>;
}
export default function contribute(plugin: PluginContext) {
plugin.addSurface("main", MainSurface);
return () => undefined;
}
`;
export interface PluginScaffold {
id: string;
directory: string;
}
export async function scaffoldPluginDirectory(
targetDirectory: string,
requestedId?: string,
): Promise<PluginScaffold> {
const directory = path.resolve(targetDirectory);
const id = PluginIdSchema.parse(requestedId ?? path.basename(directory));
await mkdir(directory, { recursive: true });
const existing = await readdir(directory);
if (existing.length > 0) {
throw new Error(`Plugin directory must be empty: ${directory}`);
}
const packageJson = {
name: id,
private: true,
version: "0.0.0",
scripts: { typecheck: "tsc --noEmit" },
devDependencies: {
"@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",
},
};
const files = new Map<string, string>([
["paseo-plugin.json", `${JSON.stringify({ id }, null, 2)}\n`],
["package.json", `${JSON.stringify(packageJson, null, 2)}\n`],
["tsconfig.json", `${JSON.stringify(TSCONFIG, null, 2)}\n`],
["paseo-plugin.d.ts", SDK_DECLARATIONS],
["index.tsx", ENTRY],
]);
await Promise.all(
[...files].map(([filename, contents]) =>
writeFile(path.join(directory, filename), contents, { flag: "wx" }),
),
);
return { id, directory };
}
@@ -0,0 +1,23 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { CommandError } from "../../output/index.js";
import { connectToDaemon } from "../../utils/client.js";
export async function withPluginManagementClient<T>(
host: string | undefined,
run: (client: DaemonClient) => Promise<T>,
): Promise<T> {
const client = await connectToDaemon({ host });
// COMPAT(pluginManagement): added in v0.3.1, remove gate after 2027-08-14.
if (client.getLastServerInfoMessage()?.features?.pluginManagement !== true) {
await client.close().catch(() => undefined);
throw {
code: "DAEMON_UPDATE_REQUIRED",
message: "Update the host to use plugin management.",
} satisfies CommandError;
}
try {
return await run(client);
} finally {
await client.close().catch(() => undefined);
}
}