查看 Markdown

把插件迁移到运行时入口

本指南用于把为 Paseo 0.7 或更早版本编写的插件,更新为 0.8 引入的当前客户端/服务端入口格式。

把本页交给一个编程 Agent,并以插件目录作为它的工作目录。按顺序执行各个步骤。不要保留兼容入口。

1. 对现有代码分类

从旧的结构开始:

my-plugin/
  paseo-plugin.json
  package.json
  tsconfig.json
  index.ts
  greeting.client.tsx
  greeting.server.ts
  greeting.shared.ts

完成后的结构是:

my-plugin/
  paseo-plugin.json
  package.json
  tsconfig.json
  index.client.tsx
  index.server.ts
  client/greeting.tsx
  server/greeting.ts
  shared/greeting.ts

只创建插件需要的入口,至少需要一个。组件和客户端回调需要客户端入口。RPC 处理函数和 Node API 需要服务端入口。

2. 重命名文件和目录

严格按以下规则操作:

  1. 用 index.client.tsx、index.server.ts 或两者替换混合的根入口。
  2. 把每个 name.client.ts 或 name.client.tsx 移动到 client/name.ts 或 client/name.tsx。
  3. 把每个 name.server.ts 或 name.server.tsx 移动到 server/name.ts 或 server/name.tsx。
  4. 把每个 name.shared.ts 或 name.shared.tsx 移动到 shared/name.ts 或 shared/name.tsx。
  5. 在对应的运行时目录下保留嵌套的功能目录。
  6. 每次移动后更新相对导入路径。
  7. 把 paseo-plugin.json、package.json 和 tsconfig.json 保留在根目录。
  8. 删除旧的根入口。Paseo 不会加载它。

这些目录就是编译器的边界。client/ 下的文件只编译进应用的包,server/ 下的文件只编译进 daemon 的包,shared/ 则编译进两者。*.client.tsx 之类的文件名后缀不再有任何含义,留在插件根目录的代码模块会导致编译错误。

3. 迁移每一处注册

把下表作为完整的注册检查清单。

旧的注册及位置新的注册及位置
旧根入口中的 plugin.handle(contract, handler)index.server.ts 中的 server.handle(contract, handler)
旧根入口中的 plugin.addSurface(id, Component)index.client.tsx 中的 client.addScreen({ id, title, Component })
旧根入口中的 plugin.addSidebarItem({ id, title, icon, surface })index.client.tsx 中的 client.addSidebarHeaderItem({ id, title, Component });见侧边栏项
新的侧边栏底部贡献index.client.tsx 中的 client.addSidebarFooterItem({ id, title, Component })
新的文件面板贡献index.client.tsx 中的 client.addFilePanel(panel)
旧根入口中的 plugin.addWorkspacePanel(panel)index.client.tsx 中的 client.addWorkspacePanel(panel)
旧根入口中的 plugin.addCommandCenterItem(item)index.client.tsx 中的 client.addCommandCenterItem(item)
旧根入口中的 plugin.addClientSlashCommand(command)index.client.tsx 中的 client.addSlashCommand(command)
旧根入口中的 plugin.addClientSide(fn)删除这层包装,把 fn 的函数体移入默认的客户端入口函数
旧客户端回调中的 client.addComposerPill(pill)index.client.tsx 或被导入的 client/ 函数中的 client.addComposerPill(pill)
新的标题栏贡献client.addHeaderButton({ id, workspaceId, button })
旧根入口中的 plugin.addAttachmentSource(source)index.client.tsx 中的 client.addAttachmentSource(source)
新的设置界面贡献index.client.tsx 中的 client.addSettingsScreen(screen);见设置界面
旧根入口中的 plugin.addTheme(theme)index.client.tsx 中的 client.addTheme(theme)
旧根入口中的 plugin.addTimelineTransformer(transformer)index.client.tsx 中的 client.addTimelineTransformer(transformer)
旧根入口中的 plugin.addTimelineRenderer(renderer)index.client.tsx 中的 client.addTimelineRenderer(renderer)
shared 文件中的 import { defineRpc, defineAttachmentSource } from "@getpaseo/plugin/server"import { defineRpc, defineAttachmentSource } from "@getpaseo/plugin"
处理函数参数类型 ZodOutput<typeof contract.input>来自 @getpaseo/plugin 的 RpcInput<typeof contract>;返回类型使用 RpcOutput

用原 addSidebarItem 项的 id 注册替代它的界面,这样已保存的链接仍然有效。/plugin/<plugin>/sidebar/<id> 链接以及更早的 /plugin/<plugin>/<id> 链接,会打开具有该 id 的 addSidebarItem,否则打开具有该 id 的界面。

从 @getpaseo/plugin/client 导入 PluginClientContext,从 @getpaseo/plugin/server 导入 PluginServerContext。删除对旧 context 类型的导入。客户端注册返回幂等的移除函数,但标题栏按钮和 Composer 标签除外,它们返回 { update, remove } 句柄。保留插件在销毁之前调用的所有移除函数;入口的清理函数运行后,Paseo 会移除尚未移除的注册。

Composer 标签

更新插件项目的 @getpaseo/plugin 依赖,然后运行 npm run typecheck。旧的贡献缺少必需的 button 字段,PluginComposerPillProps 不再导出,并且把新的注册当作函数调用会产生 TypeScript 错误。固定在旧 SDK 上的项目仍然按旧的契约检查;安装或重新加载插件并不会运行 TypeScript 的类型检查器。

把标签的 Component 替换为 button.icon 和 button.label,把 title 移入 button,把 onPress 移入 button.behavior。清理方式从调用返回的函数改为调用它的 .remove() 方法。

const pill = client.addComposerPill({
  id: "review",
  workspaceId,
  agentId,
  button: {
    title: "Open review",
    icon: "Scan",
    label: "Review",
    behavior: { kind: "action", onPress: openReview },
  },
});

pill.update({ label: "Review · 3", visible: true });
// Client entry cleanup:
pill.remove();

把原组件中的动态文本改为由你的模型或 SDK 订阅发出更新。自定义图标组件仍然可以使用 hook。Composer 标签总是显示图标和文字,并且从不显示展开箭头。菜单、弹出框和可见性见按钮。

4. 拆分导入

把 hook(usePaseo、useRpc、useSettings、useAgent、useWorkspace)和客户端贡献类型从 @getpaseo/plugin 移到 @getpaseo/plugin/client。把 Icon 移到 @getpaseo/plugin/client/ui。从 @getpaseo/plugin/server 导入服务端 context 和生命周期契约。共享的辅助函数(defineRpc、defineSettings、defineAttachmentSource)、schema 和普通数据类型保留在根入口。这些规则同样适用于类型导入。完整的契约见运行时模块。

把其余的 SDK 子路径移到它们所属的运行时下:

旧入口当前入口
@getpaseo/plugin/react-native@getpaseo/plugin/client/ui
@getpaseo/plugin/client/react-native@getpaseo/plugin/client/ui
@getpaseo/plugin/ui@getpaseo/plugin/client/ui
@getpaseo/plugin/provider@getpaseo/plugin/server/provider
@getpaseo/plugin/acp@getpaseo/plugin/server/acp

0.8 之前的入口和旧的 @paseo/plugin scope 已被移除。/client/host 是 Paseo 应用集成的私有模块,插件作者从不导入它。

客户端入口只导入 client/、shared/ 和可在客户端安全使用的包。服务端入口只导入 server/、shared/ 和可在服务端安全使用的包。在客户端入口或从它可达的任何代码中导入 node: 模块都会导致编译错误。不要仅仅为了接线注册而把组件导入服务端入口;那项注册属于客户端入口。

v0.13 中的 UI 导入

所有 Paseo UI 组件和 UI hook 都从 /client/ui 导入。之前的入口作为未写入文档的兼容别名保留到 2027-04-20。

对于逐步显示的时间线文本,把 useRevealedText(text, phase) 及其外层的 Text 换成 /client/ui 中的 <StreamedText text={text} phase={phase} style={style} />。插件设置的标题使用 SettingsSection;SettingsGroup 已弃用。

5. 识别只迁移了一半的错误

编译器或加载错误含义与修复方法
This plugin has no requirements.paseo完成迁移,并按第 7 步声明版本范围。
This plugin was made for an older version of Paseo目录中仍然只有旧的根入口。创建一个运行时入口,迁移注册,然后删除旧文件。
Plugin entry points are missing: expected index.client.ts or index.client.tsx and/or index.server.ts or index.server.tsx不存在受支持的入口。至少添加一个文件名完全匹配的入口。
server-only module cannot be imported into the plugin client bundle: <file>某个客户端导入触及了 server/。把这个调用移到 RPC 之后,并从 shared/ 导入它的契约。
client-only module cannot be imported into the plugin server bundle: <file>某个服务端导入触及了 client/。把那项注册及其导入移到客户端入口。
Plugin modules belong in client/, server/, or shared/: <file>某个代码模块仍在插件根目录。把它移到对应的目录并修复它的导入。
Node module cannot be imported into the plugin client bundle: node:<name> imported by <file>客户端代码导入了 Node API。把该操作移到 server/,在 shared/ 中暴露一个 RPC,并从客户端调用它。
TypeScript 报告 PluginContext、addClientSide 或 addClientSlashCommand 不存在按照上面的表格替换旧的 context 类型和注册。

6. 完整示例:plugin-examples/local-plugin

只有入口文件和导入路径发生变化。组件和处理函数的函数体原样移动,无需编辑。

迁移前:

local-plugin/
  index.ts
  main.client.tsx
  increment.server.ts
  increment.shared.ts
// index.ts
import type { PluginContext } from "@getpaseo/plugin";
import { contributeClient, ExamplePanel } from "./main.client";
import { increment } from "./increment.server";
import { incrementRpc } from "./increment.shared";

export default function contribute(plugin: PluginContext) {
  plugin.handle(incrementRpc, increment);
  plugin.addWorkspacePanel({
    id: "counter",
    title: "Plugin counter",
    icon: "Blocks",
    context: "workspace",
    locations: ["workspace", "explorer"],
    Component: ExamplePanel,
  });
  plugin.addCommandCenterItem({
    id: "open-counter",
    title: "Open plugin counter",
    icon: "Blocks",
    context: "workspace",
    onSelect({ openPanel }) {
      openPanel("counter");
    },
  });
  plugin.addClientSide(contributeClient);
  return () => {};
}

迁移后:

local-plugin/
  index.client.tsx
  index.server.ts
  client/main.tsx        # was main.client.tsx
  server/increment.ts    # was increment.server.ts
  shared/increment.ts    # was increment.shared.ts
// index.client.tsx
import type { PluginClientContext } from "@getpaseo/plugin/client";
import { contributeClient, ExamplePanel } from "./client/main";

export default function contribute(client: PluginClientContext) {
  client.addWorkspacePanel({
    id: "counter",
    title: "Plugin counter",
    icon: "Blocks",
    context: "workspace",
    locations: ["workspace", "explorer"],
    Component: ExamplePanel,
  });
  client.addCommandCenterItem({
    id: "open-counter",
    title: "Open plugin counter",
    icon: "Blocks",
    context: "workspace",
    onSelect({ openPanel }) {
      openPanel("counter");
    },
  });
  return contributeClient(client);
}
// index.server.ts
import type { PluginServerContext } from "@getpaseo/plugin/server";
import { increment } from "./server/increment";
import { incrementRpc } from "./shared/increment";

export default function contribute(server: PluginServerContext) {
  server.handle(incrementRpc, increment);
  return () => {};
}

被移动文件中的导入路径变化:

 // client/main.tsx
-import { incrementRpc } from "./increment.shared";
+import { incrementRpc } from "../shared/increment";

 // server/increment.ts
-import { incrementRpc } from "./increment.shared";
+import { incrementRpc } from "../shared/increment";

contributeClient 原本就接收 PluginClientContext 并返回清理函数,因此客户端入口直接调用它并返回它的清理函数。如果插件的 addClientSide 回调还注册了标签或订阅,请保留那些代码;只去掉外层包装。

7. 声明 Paseo 版本要求

迁移完入口和导入后,在 paseo-plugin.json 中添加最低运行时版本:

{
  "id": "my-plugin",
  "requirements": { "paseo": ">=0.8.0" }
}

保留你现有的 ID 和构建命令。缺少 requirements.paseo 表示 <0.8.0,因此即使文件已经移动,Paseo 0.8 及更高版本也会拒绝该插件。仅仅添加这个字段并不会迁移代码。请把本地的 @getpaseo/plugin 开发依赖更新到你的目标版本,并在类型检查之前重新安装依赖。

版本范围和预发布版本的语义见版本要求。

8. 验证迁移

运行:

npm run typecheck
paseo plugin reload <plugin-id>
paseo plugin ls

确认状态为 running 且没有错误。试用每一项贡献。对于带 RPC 的插件,调用客户端操作并验证服务端结果。对于只有客户端的插件,确认贡献在没有服务端进程的情况下也能加载。对任何保存下来的注册移除函数调用两次,验证第二次调用不产生任何效果。

赞助商