--- name: migrate-zhin-plugin-runtime description: Migrate legacy Zhin.js plugins and projects to the convention-based Plugin Runtime — from usePlugin/getPlugin/addCommand/addMiddleware/addComponent/addTool/addCron/declareConfig/useContext and mutable registries to definePlugin + capability directories + Scope/Token resources. Use this whenever a Zhin plugin fails to load under `zhin runtime start`, errors with "does not default-export a Plugin definition", still imports zhin.js/@zhin.js/core/@zhin.js/kernel, or when asked to upgrade, port, modernize, or migrate a Zhin plugin, command, middleware, component, tool, cron job, config schema, or package manifest — even if the user does not say the word "migrate". 适用于旧 Zhin 插件向约定式目录与快照运行时的破坏性迁移。 --- # 迁移 Zhin Plugin Runtime 目标是产出**纯新架构代码**:`plugin.ts` 只做装配,能力按目录发现,共享状态走 Resource/Token。 不保留 compat runtime,不双写。 ## 先建立事实,再动手 迁移最容易翻车的地方是凭印象改代码。`zhin runtime migrate status` 会静态分析整个项目并 返回一个**状态机**,它比任何猜测都准 —— 每一步都以它的输出为准: ```bash zhin runtime migrate status # 输出 JSON;state 为 ready 时退出码 0,否则 1 ``` | state | 含义 | 下一步 | |---|---|---| | `blocked` | 有 `error` 或 `manual` 诊断,自动迁移无法证明语义等价 | 人工清掉诊断,见 [人工诊断处理](./references/manual-diagnostics.md) | | `extraction-required` | 还有能自动搬运的注册(`automatic > 0`) | `zhin runtime migrate extract --write` | | `cutover-required` | 能力已就位,但 `package.json#zhin` / `plugin.ts` 还没生成 | `zhin runtime migrate cutover --write` | | `dual-run` | 仍在从 `zhin.js` 导入经典 API(`usePlugin` / `MessageCommand` 等),或直接 import `@zhin.js/core` / `@zhin.js/kernel` | 改用门面约定 API(`definePlugin` / `defineCommand` 等),删掉旧入口 | | `compat` | 仍在 import `@zhin.js/next-compat` | 移除 compat 依赖 | | `ready` | 完成 | 跑构建与测试 | 状态是**从上往下**判定的:只要还有 manual/error 诊断就一直是 `blocked`,先清诊断再谈其它。 ## 工作流 1. **盘点**:读目标包的 README、最近的测试、旧入口,弄清用户可见行为(命令、消息、定时、 持久化)。迁移的验收标准是行为不变,不是编译通过。 2. **看计划**:`zhin runtime migrate extract --check`,逐条读 `changes` 与 `diagnostics`。 `--check` 与 `--write` 必须二选一,同时给或都不给会直接报错。 3. **搬能力**:`zhin runtime migrate extract --write`。它只搬**模块顶层、且闭包干净**的注册, 已存在的目标文件不会被覆盖。 4. **清诊断**:每条 `manual` 都要人工处理,见 [人工诊断处理](./references/manual-diagnostics.md)。 最常见的是 action 捕获了模块级变量 —— 把它提升为 owner Resource,能力文件再从执行上下文读。 5. **装配**:`zhin runtime migrate cutover --write` 生成 `package.json#zhin` 与 `plugin.ts`, 并补齐 `zhin.js`、`@zhin.js/runtime`;Stable Features 可由 Root 继承,不必再装 `@zhin.js/command|middleware|component`(cutover 仍可能按约定目录写入 features 挂载)。启动脚本统一是 `zhin runtime start`,不要再写失效的 `zhin dev` / `zhin start` / `zhin build`。 - `package.json#private: true` 的本地 TS root 使用 `entry: "./plugin.ts"`,直接执行 `pnpm dev` 或 `zhin runtime start`。 - 非 private 的发布包使用 `entry: "./plugin.js"`;cutover 生成独立的 `tsconfig.zhin.json`、`zhin:build` 与 `prepack` / `prepublishOnly`,以便 `pnpm pack` 和 npm publish 前把 `plugin.ts`、约定目录和 `src/` 编译成可发布 JS。不要把 `plugin.ts` 作为发布 manifest 的入口。 已有合法 manifest 会被补齐到相应模式;其它 `zhin` 字段形态仍需人工处理。 6. **迁移剩余配置**:`schema.json`(只声明本包字段)、Feature mounts、child plugin mounts。 7. **删旧**:删掉旧注册代码、旧入口、compat 依赖。 8. **验证**:构建 + 测试 + 行为验证(命令路由、消息发送、配置默认值、热更新)。 第 2–5 步之间反复跑 `status` 是最省事的做法 —— 它会告诉你还差什么。 ## 目标写法 `plugin.ts` 只装配;能力一个文件一个,default export。完整对照见 [迁移映射](./references/migration-map.md)。 ```ts // plugin.ts import { createToken, definePlugin, databaseHostToken } from 'zhin.js'; export const storeToken = createToken('my-plugin.store'); export default definePlugin({ name: 'my-plugin', // /^[a-z][a-z0-9-]*$/ setup(context) { // Host 资源都是可选的:先 has 再 use,否则精简安装会装配失败 if (!context.resources.has(databaseHostToken)) return; const db = context.resources.use(databaseHostToken); context.resources.provide(storeToken, createStore(db)); return () => { /* disposer;HMR 回滚时调用 */ }; }, }); ``` ```ts // commands/profile/index.ts —— 目录路径即路由;参数写在目录名里 import { defineCommand } from 'zhin.js/command'; import { storeToken } from '../../plugin.js'; export default defineCommand({ description: 'Show current user profile', async execute(context) { const store = context.use(storeToken); // 能力上下文直接 use,不 import 单例 return store.describe(context.input.sender.id); }, }); ``` > **两个 context 不是同一个东西**,别混用: > `setup(context)` 里 `context.resources` 是 Scope —— `provide` / `use` / `has` 都挂在它上面。 > 能力的 `execute(context)` 拿到的是 CapabilityContext —— 直接 `context.use(token)` / > `context.config`,**没有** `context.resources`,写成 `context.resources.use(...)` 会在运行期 > 报 `Cannot read properties of undefined`。 ## 硬性规则 这些不是风格偏好,违反会让迁移在运行期而不是编译期爆炸: - **`usePlugin()` / `getPlugin()` 不得出现在能力执行路径。** 新运行时不建立 AsyncLocalStorage 上下文,调用它只会拿到一个挂空的孤儿 Plugin,注册的东西永远不生效。 - **不双写。** 同一能力不要既留旧 registry 又建新目录,`status` 会一直停在 `dual-run`。 - **不引入 `@zhin.js/next-*` 或 legacy callback adapter。** - **Command 参数只由文件名表达**,不要在 metadata 里维护第二套路由。 - **消息发送必须走统一 render/send 链路**,不要在能力里直连 endpoint。 - **自动迁移无法证明语义等价时,保留 diagnostic 人工改写**,不要做猜测性替换。 - **每个 `register` / 订阅都要有 disposer** 交给 `context.lifecycle`,否则热更新会重复注册。 ## 完成标准 ```bash zhin runtime migrate status # state 必须是 ready(退出码 0) rg -n "usePlugin\(|getPlugin\(|add(Command|Middleware|Component|Tool|Cron)\(|@zhin.js/next-" . pnpm --filter build pnpm --filter test pnpm check:plugin-runtime-migration-readiness pnpm check:plugin-runtime-migration-verify ``` 公开插件还必须验证 tarball,而不是只验证源码类型检查: ```bash pnpm --filter run build pnpm --filter pack # 解开 tarball,确认 package/plugin.js、package/plugin.d.ts 和已编译的能力目录存在 ``` `rg` 只允许命中文档与迁移测试。`ready` 意味着静态检查通过,**不等于行为等价** —— 平台相关 行为(真实适配器收发、定时触发)要么实测,要么在交付说明里写清未验证项,不要用"编译通过" 替代运行时验证。 ## 仓库门禁 仓库内声明 `zhin.type: "plugin"` 的包会由 `check:plugin-runtime-migration-readiness` 做确定性检查。 它只扫描当前 checkout,不读取 cwd 之外的用户项目;`tests/`、`test/`、`fixtures/`、`__fixtures__/` 和带 `zhin-migration-gate: legacy-fixture` 标记的源码会被排除。因此迁移示例可以保留旧 API, 但 native Plugin Runtime 的生产源码不能在函数体内调用 `usePlugin()` 或 `getPlugin()`。 ## 离线 Verify `pnpm check:plugin-runtime-migration-verify` 是 migration 的离线 E2E verify:先确认 cutover 已经无变更,再用不触发 install 的 `pnpm run build` 验证构建。私有 development root 额外要求 `scripts.dev` 与 `scripts.start` 都是 `zhin runtime start`;公开 publish package 会执行 `pnpm pack`,解读 tarball 后确认 `package.json#zhin.entry`、 `plugin.js`、`plugin.d.ts` 和每个已发现能力目录的 JS 产物一致。它不执行 install,也不会访问网络。