# Cell 4 扩展模型

状态：设计记录（2026-11-07 起草；4.0.0 已按本文实现，未实现的条目标注"未实现"）。不是用户文档，用法见 [CLI 参考](../../dev-packages/cli/README.md)。配套 [architecture.md](./architecture.md)，受第一原则 [agent-first.md](./agent-first.md) 约束。全新设计，不考虑 3.x。

## 1. 三种扩展物

| 扩展物 | 回答的问题 | 由谁实现 | 是否进入产物 |
|---|---|---|---|
| **组件（Component）** | 应用能做什么：DI 绑定、默认配置 | 运行时包 | 是 |
| **模块（Module）** | 工具链怎么处理：Vite 插件、入口、命令、模板、产物处理 | 组件或项目本地 | 否 |
| **Preset / Deployer** | 产物长什么样、怎么上云 | 内置 preset + 部署器包 | preset 选定的入口与适配器进入产物；部署器不进入 |

和 pi 的 package 一样，一个 npm 包可以同时带多种扩展物。例如一个包可以同时是组件和模块（例如 `@celljs/react` 同时提供运行时模块和 Vite 插件）。

## 2. 组件：声明式，全部写在 package.json

```jsonc
{
  "name": "@celljs/web",
  "cell": {
    "staticModules": { "backend": ["lib/common/static-module"] },          // 同步优先加载
    "modules": {                                                          // 按顺序异步加载
      "frontend": ["lib/common/module", "lib/browser/module"],
      "backend": ["lib/common/module", "lib/node/module"]
    },
    "optionalDependencies": ["pg-native"],                                // 构建时解析不到就 external
    "targets": ["frontend"],                                              // 装上它就要构建的目标
    "entries": { "http": "lib/node/http-application-entry" },             // 具名后端入口，由 preset 按名字选用
    "toolchain": "lib/cell-module"                                        // 工具链模块（见 §3）
  }
}
```

`cell` 字段里 `modules` / `staticModules` 指 **DI 运行时模块**；工具链扩展一律叫 **toolchain 模块**（字段 `toolchain`、过滤键 `toolchain`），两者不混用一个词。

- **发现**：从项目的 `dependencies` 开始深度优先递归，package.json 带 `cell` 字段的包就是组件；不看关键字，也不在依赖包里按文件名猜测。
- **顺序**：被依赖者在前、按声明顺序，项目自身最后。运行时模块装配、配置合并、模块 setup 用同一个顺序。
- **模块路径**是包内子路径（`lib/...`），构建时按目标环境的导出条件解析（`import` → `esm/`），所以产物里只有一份框架。
- **项目自身**不需要 `cell` 字段，按约定查找 `src/{common,browser|node}/…module`（与旧版约定顺序一致）；也可以在自己的 `cell` 字段里显式列出。
- **配置**：组件目录下的 `cell.yml` 与 `cell.<mode>.yml`，只放应用配置；配置文件不能再引出 mode（mode 只来自命令行与 `cell.config.ts`）。
- **构建目标**：`backend` 加上各组件声明的 `targets`（`@celljs/widget` 声明 `frontend`，`@celljs/react` 依赖它，装上就构建前端）；项目在 `cell.config.ts` 的 `targets` 里显式指定时完全以它为准（纯前端项目写 `['frontend']`）。组件只声明自己"额外需要"的目标，`@celljs/core` 不声明：它同时带前后端 DI 模块，只是哪个目标在构建就装配哪边。
- **入口**：组件在 `entries` 里按名字提供后端入口（`http`、`event`、`timer`……），preset 指定用哪个名字；同名时按组件顺序后者生效，所以项目自己可以覆盖。没有组件提供时用 `@celljs/core` 的默认入口（不监听端口），`cell build` 会给出提示。
- **不执行代码**：发现组件只读 package.json，`cell info`、编辑器插件可以很快拿到组件图。
- 框架自身包的 `modules` / `staticModules` 由 `bun run exports`（`scripts/sync-exports.ts`）根据 `src/` 生成；其余键（如 `optionalDependencies`）手写并被保留。

## 3. toolchain 模块：一个工厂函数

参考 pi 的扩展写法：模块就是默认导出的工厂函数，拿到 `cell` 后命令式地注册能力、订阅事件；可以是 async。

```ts
import type { CellAPI } from '@celljs/kit';
import react from '@vitejs/plugin-react';

export default function (cell: CellAPI) {
  const options = cell.options('react', { fastRefresh: true });   // 读 cell.config.ts 的 react 字段并合并默认值
  cell.addVitePlugin(react(options), { env: 'client' });
  cell.on('build:done', (event) => cell.logger.success(`frontend → ${event.output.public}`));
}
```

需要元信息时用 `defineCellModule` 包一层，只是加上 `meta`（名字、兼容版本），没有第二种写法。加载顺序固定（§3.2），不提供排序选项：

```ts
export default defineCellModule({ name: '@celljs/react', compatibility: '^4.0.0' }, (cell) => { /* 同上 */ });
```

规则：
- 工厂只做注册，不启动常驻资源（进程、端口、文件监听、定时器）。常驻资源放到 `dev:ready` 之后启动，在 `close` 里关闭，且关闭要幂等（pi 同样的约束，因为工厂也会在 `cell info` 这类不启动服务的命令里执行）。
- 工厂只在 CLI 进程里执行，永远不会打进产物。
- 项目本地模块（`cell.config.ts` 的 `modules` 里写路径）用 Vite 的 `runnerImport` 加载，TypeScript 不用预编译；发布到 npm 的模块提供 JS。不能用 `loadConfigFromFile`：它发现默认导出是函数时会直接以 `{ command, mode }` 调用，工厂会被错误执行。
- `cell.options(key)` 只能在工厂里同步调用。`modules:done` 之后，`cell.config.ts` 中没有被任何模块 `options` / `addConfigSchema` 或 preset 选项认领的顶层字段报 `CELL_CONFIG_UNKNOWN_OPTION`，拼错的模块选项不会被静默忽略。

### 3.1 `cell` 提供的能力

| 类别 | API | 用途 |
|---|---|---|
| 信息 | `command`、`mode`、`dev`、`ci`、`root`、`preset`、`components`、`paths`、`logger` | 只读 |
| 选项与配置 | `options(key, defaults)`、`updateConfig(partial)` | 读模块选项；追加 `build.external`、合并 Vite 配置（项目优先）。应用配置不经模块修改，组件把默认值写在自己的 `cell.yml`（R1） |
| 构建 | `addVitePlugin(plugin, { env })` | 构建期改动都落到 Vite 插件上 |
| 运行时 | `addRuntimeModule(path, { target, static })`、`addVirtualModule(id, code)`、`setEntry(target, path)` | 按条件追加 DI 模块或生成代码 |
| 开发 | `addDevHandler(path, handler)`、`addWatchFile(path)` | 开发期附加路由、监听额外文件 |
| 产物与平台 | `addPreset(preset)`、`addDeployer(deployer)`、`addOutputFile(path, content)` | 平台支持、写额外文件 |
| CLI | `addCommand(name, def)`、`addFlag(name, def)`、`addTemplate(def)` | 扩展命令、给已有命令加参数、`cell init` 模板 |
| Agent | `addConfigSchema(key, schema)`、`addCheck(def)`、`addMcpTool(def)`、`addDocs(dir)` | 配置 schema、`cell check` 检查项、MCP 工具、随包文档与 `AGENTS.md` 片段（agent-first R3/R4/R7/R8） |
| 事件 | `on(event, handler)`（返回取消订阅函数）、`events`（模块之间的事件总线） | 见 3.2 |
| 工具 | `exec(cmd, args, { signal, timeout })`、`spinner(text, task)`、`resolve(spec)` | 不经过 shell 执行命令等 |

### 3.2 事件与组合规则

所有扩展点只有 `cell.on(event, handler)` 一种订阅方式。参考 pi，**每个事件固定一种组合规则**，写在类型和文档里，作者不用猜返回值有没有用：

| 规则 | 行为 | 事件 |
|---|---|---|
| 通知 | 返回值忽略 | `modules:done`、`config:resolved`、`dev:ready`、`dev:reload`、`output:done`、`deploy:done`、`close` |
| 原地修改 | 后面的 handler 看到前面的修改 | `build:done`（`event.files` 可增删）、`deploy:plan`（`event.plan`） |
| 闸门 | 任一 handler 返回 `{ cancel: true, reason }` 就中止，后面的不再执行 | `build:before`、`deploy:before` |

- 执行顺序：模块加载顺序（内置 → 组件按依赖拓扑序 → 项目本地），同一模块内按注册顺序。dispatch 进行中增减 handler 不影响本次分发。
- 改 Vite 配置只有两种入口，不提供 `vite:config` 事件：模块用 `addVitePlugin`（插件自己的 `config` / `configEnvironment` 钩子），项目用 `cell.config.ts` 的 `vite` 字段。
- `cell.config.ts` 的 `hooks` 只接受**命令字符串**，只能订阅通知和闸门事件；需要函数或要修改事件对象（`build:done`、`deploy:plan`）时写本地模块。命令经 shell 执行，cwd 为项目根目录，继承环境变量并附加 `CELL_EVENT`、`CELL_PRESET`、`CELL_MODE`（逗号分隔），不读标准输入；`--json` 时命令输出转到 stderr。闸门事件里非零退出即取消（`CELL_BUILD_CANCELLED`），通知事件里非零退出报 `CELL_HOOK_FAILED`。
- 事件对象都带 `reason` 之类的上下文字段，例如 `close` 的 `reason: 'exit' | 'restart'`，`dev:reload` 的 `files`。

### 3.3 生命周期与重载

```
启动 → 读 cell.config.ts → 发现组件 → 加载模块（执行工厂）→ modules:done → 确定 preset、合并配置 → config:resolved
  ├─ dev：创建 Vite → dev:ready ⇄ dev:reload（后端代码变化）
  │        cell.yml / cell.config.ts / 组件 package.json 变化 → close(reason: 'restart') → 重新加载模块 → … → dev:ready
  ├─ build：build:before（闸门）→ Vite 构建 → build:done → 单文件/可执行文件/preset 输出 → output:done
  └─ deploy：build → deploy:before（闸门）→ deploy:plan → apply → deploy:done
退出 → close(reason: 'exit')
```

重载参考 pi 的 `/reload`：旧的模块运行时先收到 `close`，再用新代码重新执行全部工厂，模块里的内存状态不保留。

preset 可以由模块 `addPreset` 注册，所以要等 `modules:done` 之后才能确定。工厂里 `cell.preset` 只是名字（来自 `--preset` / `cell.config.ts` / `CELL_PRESET`，可能为空）；解析后的完整 preset（含 CI 识别结果）在 `config:resolved` 事件里提供。

### 3.4 错误处理

| 场景 | 处理 |
|---|---|
| `cell dev` 中通知类 handler 抛错 | 记录错误（带模块名），继续运行 |
| `cell build` / `cell deploy` / CI 中任何 handler 抛错 | 失败退出。构建结果必须确定，这点和 pi"记录后继续"不同 |
| 闸门类 handler 抛错 | 视为取消（fail closed） |
| 工厂抛错 | 报错退出，指出模块和来源 |

### 3.5 可内省与错误

- 模块注册的事件 handler、命令、Vite 插件、运行时模块、MCP 工具都会出现在 `cell info --json` 里，并标明来自哪个模块（agent-first R2）。
- 模块抛出的错误应是 `CellError`（带 `code` 与 `hint`）；未包装的异常由宿主补上模块名，编码为 `CELL_MODULE_ERROR`。
- 模块用 `addConfigSchema` 为自己的配置项声明 schema（Standard Schema，兼容 zod / valibot，导出 JSON Schema），选项不合法时报出配置文件位置。

### 3.6 宿主提供的包

（P3 随部署器缓存一起实现；P2 没有缓存里的包，且 `CellError.is` 按 `name` 判断而不是 `instanceof`，多一份 kit 也能识别。）

借鉴 pi 处理"一个类出现两份"的办法：`@celljs/kit`、`@celljs/vite`、`vite` 由宿主提供。模块和部署器把它们写在 `peerDependencies`（`"*"`），不打包、不放进 `dependencies`；CLI 加载模块时把这些导入映射到自己的那一份。这样部署器缓存（`~/.cell/cache`）里的包也不会带进第二份 kit。发现某个模块的 `dependencies` 里写了宿主包时，给出警告。

## 4. Preset 与 Deployer：产物形态和上云分开

借鉴 Nitro preset 和 Astro adapter：preset 只负责"产物长什么样"，部署交给 deployer。这样 `cell build --preset fc` 的产物也可以用阿里云官方工具部署，deployer 只是 Cell 提供的一键方式。

```ts
interface Preset {
  name: string;
  extends?: string;                         // 继承另一个 preset
  entry?: string;                           // 后端入口的名字，从组件的 `entries` 里选（如 node 用 `http`）
  format?: 'esm' | 'cjs';
  runtime?: 'node' | 'workerd';             // 服务端产物的运行时，workerd 即 Cloudflare Workers
  output?(ctx: PresetOutputContext): void | Promise<void>;     // 构建后在产物旁写文件（入口包装、平台配置），写出的文件记入 manifest.files
  preview?(ctx: PresetPreviewContext): PreviewProcess;         // cell preview 怎么运行产物，默认 Node.js 运行 manifest.server
  dev?: { event?: boolean };                // 开发期把 HTTP 请求转成事件（未实现）
  detect?(env: NodeJS.ProcessEnv): boolean; // CI 环境自动识别
  deployer?: string;                        // 部署器名
}

interface Deployer {
  name: string;
  credentials?: { fields: Field[]; load(): Promise<Profile | undefined>; save(p: Profile): Promise<void> };   // 未实现
  plan(ctx: DeployContext): Promise<DeployPlan>;          // 计算要创建/更新的云资源，--dry-run 只到这一步
  apply(ctx: DeployContext, plan: DeployPlan): Promise<Deployment>;   // 执行
  info?(ctx): Promise<InfoSection[]>;       // 未实现
  remove?(ctx): Promise<void>;              // 未实现
}
```

已实现（2026-11-08，`@celljs/kit` 的 `preset.ts` / `deployer.ts`）：`runtime`、`output`、`preview`、`deployer`，以及 `Deployer` 的 `plan` / `apply`。`DeployPlan` 是 `{ deployer, resources: { type, name, action, details? }[], details? }`，`Deployment` 是 `{ url?, details? }`；`DeployContext` 带产物目录、manifest、环境变量和 `relay`（转发部署工具的输出，`--json` 时走 stderr）。内置部署器由 `DeployerRegistry` 注册：`cloudflare`、`fc`、`scf`、`lambda`、`vercel`（见 architecture.md §7）。它们不依赖云 SDK（自己实现请求签名），所以内置而不是按需安装；按包名安装第三方部署器（`addDeployer`）尚未实现。preset 的选项是 cell.config.ts 中与 preset 同名的字段，由 `Preset.options`（Standard Schema）校验，部署器从 `DeployContext.options` 读取。

- `plan` / `apply` 两段：借鉴 Terraform，`cell deploy --dry-run` 打印将要变更的函数、触发器、网关、域名，确认后再执行。
- （未实现，目前只读环境变量）凭据统一由 `cell login <provider>` 写入 `~/.cell/credentials`，环境变量（如 `ALIBABA_CLOUD_ACCESS_KEY_ID`）优先，CI 不需要登录。
- 原来 fc / fc3 合并为一个 `fc` deployer（只支持 FC 3.0 API）；cloud-plugin 里的 profile、code-loader 等成为 `@celljs/kit/deploy` 的共享工具。

## 5. 来源、开关与过滤

参考 pi 的 package 来源与过滤规则，但只在项目范围内生效：构建结果必须能由项目本身复现，所以**没有全局模块目录**（pi 的 `~/.pi/agent/extensions` 这类全局扩展不借鉴）。

| 来源 | 写法 | 说明 |
|---|---|---|
| 依赖里的组件 | 自动发现 | 来自 `dependencies`，版本由锁文件固定 |
| 本地路径 | `'./modules/audit'` | 文件是一个模块；目录按包的规则发现 |
| 临时试用（未实现） | `cell build -e npm:@foo/cell-x@1.2.0` | 只对本次命令生效，缓存到 `~/.cell/cache`，不写进项目 |

```ts
export default defineConfig({
  react: { fastRefresh: false },                 // 模块选项
  modules: [
    './modules/audit',
    '!@celljs/serve-static',                      // 禁用整个组件
    { source: '@celljs/react', runtime: false },  // 只禁用它的 DI 运行时模块，保留 toolchain 模块
    { source: '@x/ui', toolchain: false },        // 只禁用它的 toolchain 模块
  ],
});
```

- 过滤只能收窄组件声明过的内容，不能凭空打开组件没声明的东西（与 pi 相同）。
- 包身份按包名判断，同一个包不会因为不同写法被加载两次。
- 依赖里的组件必须在 `cell` 字段里声明 DI 模块和 toolchain 模块（框架包由 `sync-exports` 生成），只有 `cell.yml` 按文件名约定发现；按约定目录查找只用于项目自身（`src/...module`）。显式声明让 `cell info` 不用扫描文件系统（agent-first R2）。
- （未实现）部署器按需安装失败时报错退出（部署不能"跳过后继续"）；临时试用的包安装失败时同样报错。
- 升级组件后，`cell dev` 首次启动时显示新版本的 CHANGELOG 摘要（借鉴 pi 的扩展更新提示），只对带模块的组件生效。

## 6. 仓库内组件的去向

| 现在 | 4.0 |
|---|---|
| react、vue | module：`addVitePlugin`（plugin-react、plugin-vue + vue-jsx） |
| serve-static | 组件 + module：`--single` 时 `addRuntimeModule` 换成内存静态资源服务 |
| web 的 `devEntry` | 取消，开发与生产用同一入口，开发期由 `@celljs/vite` 挂载 |
| node-plugin | 并入内置 `node` preset |
| static-plugin | 并入内置 `static` preset |
| next-plugin | module：`build:before` 跑 `next build`（standalone 输出），`setEntry` 指向 Next 的 server |
| fc / fc3 / scf / lambda / vercel 插件 | preset（内置于 cli）+ deployer 包 |
| fc / scf / lambda / vercel adapter | 组件，提供各 trigger 的入口；mode 文件改为 preset 的 `entry` 选项 |
| faas-adapter 的开发期事件模拟 | preset 的 `dev.event` |
| cloud-plugin、code-loader-plugin | `@celljs/kit/deploy` 共享工具 |
| cli-runtime 的 runtimes/default | 取消；平台差异全部进 preset |
| 示例里的 `webpack-hook.ts` | 删除；需要时写 `cell.config.ts` 的 `hooks` 或本地模块 |

## 7. 测试

`@celljs/kit/testing`：
- `createTestCell({ root, config, modules })`：只跑模块 setup，断言注册了哪些 Vite 插件、运行时模块、命令。
- `buildFixture(dir, { preset })`：在临时目录真实构建，返回 manifest，可以接着 `preview()` 发请求。
- `mockDeployer`：记录 `plan` 结果，用于部署器的快照测试。

## 8. 从 pi 借鉴了什么

| pi 的做法 | Cell 4 |
|---|---|
| 扩展是默认导出的工厂函数 `(pi) => void`，命令式注册 | 采用（§3），去掉了原来的 `meta + defaults + setup` 对象写法 |
| 一个 package 用 package.json 的 `pi` 字段声明多种资源，没有声明时按约定目录发现 | 采用：`cell` 字段声明运行时模块、配置、工具链模块、模板、preset，缺省按约定路径 |
| 每个事件固定一种组合规则（通知、原地修改、链式变换、闸门、最后覆盖） | 采用通知、原地修改、闸门三种（§3.2） |
| 工厂不启动常驻资源，用启动/关闭事件管理；`/reload` = 关闭旧运行时 + 重新执行工厂 | 采用（§3.3） |
| 宿主包写成 peer，宿主把导入映射到自己的副本 | 采用（§3.5），解决部署器缓存里出现第二份 kit 的问题 |
| npm / git / 本地多种来源，过滤支持 `!` 等前缀，按包名去重 | 采用 npm 和本地，以及 `-e` 临时试用；git 来源暂不支持 |
| `pi.exec` 不经过 shell，支持 signal、timeout | 采用 |
| 扩展之间的事件总线 `pi.events` | 采用 |
| 更新后展示扩展的 CHANGELOG | 采用 |
| `pi-package` 关键字进入官方插件市场 | 保留为后续：`cell-module` 关键字 + 文档站模块列表 |

不借鉴：
- **全局扩展目录和用户级 settings**：会让同一个项目在不同机器上构建出不同结果。凭据（`~/.cell/credentials`）和部署器缓存是唯一的全局状态。
- **项目信任（project trust）**：构建本来就要执行项目代码，信任提示没有意义。
- **扩展出错只记录、继续运行**：只在 `cell dev` 中沿用；构建和部署必须失败退出（§3.4）。
- **会话状态重建、TUI 组件、工具注册**：属于 agent 领域，和构建工具无关。