Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Monorepo 工程化:根目录配置与构建链路

TL;DR:Pi 是一个 npm workspaces monorepo,packages/* 下 12 个包同版本(lockstep)发布,根目录不发布,只负责编排。npm workspaces 不按依赖拓扑排序,而子包编译时又依赖兄弟包 dist/ 里的类型声明,所以根 build 脚本手写了一条从 telemetry 到 coding-agent 的固定顺序。pi-ai 的内置模型目录在构建时从上游拉取(build),也可以用已就绪的数据离线构建(build:offline),Nix 打包走的就是后者,用一个带 sha256 的目录快照。npm run check 是唯一的提交门禁:格式、依赖钉死、运行时依赖声明、入口模块图预算、安装锁、类型检查、浏览器冒烟。整个仓库的供应链立场是:依赖精确钉版本、新版本至少发布 2 天才装、安装时一律 --ignore-scripts、锁文件当作需要评审的代码。

仓库长什么样

根 package.json 名为 pi-monorepo,private: true、type: module,要求 Node >=22.19.0。它本身不是一个可运行的程序,只是 workspace 的入口和脚本集合。workspaces 声明了三类目录:

  • packages/*:真正发布的包。1.5 里有 agent、ai、chord、codemode、coding-agent、durable、env、mcp、schema、telemetry、tui,外加私有的 evals(private: true,用于评测,不发布)。
  • apps/*:只在私有仓库 pi-private 里存在(pre-commit 钩子里的注释写明了),公开仓库里这一项是空的。
  • 四个带独立依赖的示例扩展:with-deps、custom-provider-anthropic、custom-provider-gitlab-duo、gondolin。它们有自己的第三方依赖,挂进 workspace 才能让一次 npm ci 装好、让类型检查能解析。旧版里的 sandbox 示例已删除,换成了 gondolin(见 沙箱与容器化)。

所有公开包版本一致(1.5.0),内部互相依赖也写精确版本号,比如 coding-agent 依赖 "@earendil-works/pi-agent-core": "1.5.0"。这是 lockstep 发布:任何一个包改了,全部一起升版本,用户不会遇到 "pi-ai 1.5 配 agent-core 1.4" 这种组合。

一句话:根目录只编排不发布,12 个包同版本前进,示例扩展也是 workspace 成员。

为什么 build 顺序要手写

根 build 是一长串 cd packages/x && npm run build,顺序是:

telemetry → schema → chord → tui → codemode → mcp → ai → durable → env → agent → coding-agent

为什么不直接 npm run build --workspaces?因为 npm workspaces 只按声明(目录 glob)顺序执行,不做依赖拓扑排序;而子包的 tsconfig.build.json 是对着兄弟包的构建产物编译的。比如 coding-agent 的构建配置把 @earendil-works/pi-agent-core 映射到 ../agent/dist/index.d.ts,把 pi-ai 映射到 ../ai/dist/index.d.ts。如果 agent 还没构建,coding-agent 就找不到类型。于是依赖关系决定了顺序:

  • chord、ai 依赖 schema 和 telemetry,所以这两个最先;
  • durable 依赖 chord、ai、codemode;env 依赖 chord、durable;
  • agent 依赖 ai、schema;
  • coding-agent 依赖 agent、ai、codemode、mcp、tui,放最后。

clean 和 test 则可以用 --workspaces 并列跑,因为它们之间没有先后依赖。

coding-agent 自己的 build 也分两步:先 tsc -p tsconfig.build.json 产出普通 dist/,再用 scripts/build-coding-agent-bundle.mjs(esbuild)打一个 dist/bundle/cli.js。bundle 只把少数可选原生模块(如 bufferutil、photon、jiti)留为外部依赖,Nix 包装出来的 pi 命令运行的就是这个 bundle。

模型数据:在线构建与离线构建

pi-ai 的内置模型目录不是手写的,由 packages/ai/catalog/generate.ts 从上游拉取:models.dev、OpenRouter、Vercel AI Gateway、NVIDIA NIM 等来源先全部拉下来,再按 catalog/providers/*.ts 里每个 provider 的定义做映射和修正。生成物分两种:

  • src/providers/<provider>.models.ts 和 src/models.generated.ts 是类型包装,提交进 git;
  • src/providers/data/*.json 是真正的模型数值,被 .gitignore 忽略,每次构建重新生成。

于是 pi-ai 的脚本拆成两层:build = generate-models(联网拉取)+ build:offline;build:offline = check:model-data(校验数据存在且不过期)+ tsc + 把 data 目录拷进 dist。根目录对应有 build 与 build:offline 两条链,唯一区别就是 ai 那一步。新克隆的仓库要离线构建,先跑 npm run hydrate:model-data(只写数据、不写类型文件)。这样设计的原因是模型价格、上下文长度变化频繁,把 JSON 提交进 git 会制造大量噪音 diff;而 CI、Nix 这类需要可复现的场景又不能每次联网,于是用"联网生成"和"给定数据离线构建"两条路径分开。

一句话:手写顺序是因为 npm workspaces 不排拓扑、而子包又对着兄弟包的 dist 编译;模型数据可以联网刷新,也可以用快照离线构建。

check:唯一的提交门禁

npm run check 是 AGENTS.md 规定的首选检查(改完代码必须跑、不跑测试),pre-commit 钩子也调用它。1.5 的完整链路:

biome-check.mjs --write          格式化 + lint,warning 也算失败
check:pinned-deps                所有 package.json 的直接依赖必须是精确版本
check:runtime-deps               公开包源码里的运行时 import 必须在自己的 dependencies 里声明
check:ts-imports                 相对导入写法规范(源码里写 .ts 后缀)
check:entry-graphs               每个导出入口的模块图不超过预算
check:install-lock:coding-agent  coding-agent 安装锁与根锁文件一致
tsc --noEmit                     全仓类型检查
tsc --noEmit -p examples/...     示例扩展按"扩展视角"再查一遍
check:browser-smoke              esbuild 打包浏览器入口,抓误引入的 Node-only 模块

几个值得说的点:

  • biome 包了一层脚本。biome-check.mjs 本质是 biome check --error-on-warnings,但 Biome 没有 Android(Termux)原生二进制,脚本会回退到 @biomejs/wasm-nodejs 逐文件检查,所以 devDependencies 里同时有 biome 和它的 wasm 版本。
  • 类型检查换成了 TypeScript 7。旧版用 @typescript/native-preview 提供的 tsgo,1.5 的 devDependency 是 typescript: 7.0.2,原生实现的 tsc 直接就是快的那个,check-runtime-deps 也是基于 TS 7 的 AST API 写的。
  • 入口是成本契约。check-entry-graphs.mjs 的注释说得很直白:一个多余的 export * 就能让窄入口拖进整个 barrel,引一个单文件纯函数可能要求值约 37 MB 的模块图,而且什么都不会报错,直到有人测进程内存。所以它沿着值导入(import type 不算)遍历每个声明的入口,超预算就在提交时失败。
  • 示例扩展单独查一遍。扩展运行时看到的 @earendil-works/pi-ai 是一个"扩展视角"(pi-ai-extension-view.ts,额外暴露 TypeBox 的 Type 等),和普通包导入不一样,所以 packages/coding-agent/examples/tsconfig.json 把这条路径改指向扩展视角,单独做一次 tsc。根 tsconfig 则把 examples/extensions/** 排除掉。gondolin 示例因为依赖 Node 23.6+ 的 Gondolin 包,两边都排除。
  • shrinkwrap 没了。旧版 check 里有 check:shrinkwrap(npm-shrinkwrap.json 随包发布);1.5 改成 packages/coding-agent/install-lock/,见下文供应链一节。

pre-commit(.husky/pre-commit)在 check 之外还做三件事:先跑 check-lockfile-commit.mjs 拦截锁文件提交;暂存文件涉及 packages/ai、package.json 或锁文件时再单独跑一次浏览器冒烟;最后把被格式化改过的暂存文件重新 git add。

一句话:check 把"代码风格、依赖卫生、包边界、类型、浏览器兼容"全部收进一条命令,提交时强制执行。

TypeScript、Vitest 与 Biome 配置

TypeScript 配置分三层,各管一件事:

  • tsconfig.base.json 是所有包共享的编译选项:ES2024 + Node16 模块解析、strict、declaration + declarationMap + sourceMap。两个选项体现了仓库风格:erasableSyntaxOnly 禁止 enum、namespace、参数属性这类需要生成 JS 的语法,verbatimModuleSyntax 要求类型导入显式写 import type,两者合起来保证 TS 能被"擦掉类型"直接运行。allowImportingTsExtensions + rewriteRelativeImportExtensions 让源码里写 ./foo.ts,编译产物自动改成 ./foo.js。旧版里的 experimentalDecorators、emitDecoratorMetadata 在 1.5 已经移除。
  • 根 tsconfig.json 只用于类型检查(noEmit,NodeNext),核心是 paths:把所有 @earendil-works/* 包名及其子路径映射到 packages/*/src,编辑器和 tsc --noEmit 因此不需要先构建就能跨包跳转、检查。
  • 各包的 tsconfig.build.json 负责真正产出 dist/,paths 反过来指向兄弟包的 dist/*.d.ts。这正是 build 顺序必须手写的原因。

vitest.base.ts 做的是和根 tsconfig 同样的事,只是给测试用:用 resolve.alias 把包名精确映射到源码文件,测试跑的是 src 而不是可能过期的 dist。各包的 vitest.config.ts 用 mergeConfig 继承它;coding-agent 还额外设置 PI_OFFLINE=1,测试默认离线,需要网络的测试显式调用 allowNetwork()。

biome.json 规则从旧版延续:推荐规则集,useConst 设为 error,关掉 noNonNullAssertion、noExplicitAny、useNodejsImportProtocol;格式化用 Tab、宽 3、行宽 120。1.5 新增的是:范围扩到 packages/ai/catalog、packages/agent/examples 和模型目录协议脚本,排除 models.generated.ts、*.models.ts 这些生成文件,以及一个自定义 GritQL 插件 scripts/biome/model-type-comparison.grit 做仓库特有的 lint。

一句话:根 tsconfig 和 vitest.base 都把包名指向源码,构建配置则指向 dist;前者让开发和测试免构建,后者决定了构建顺序。

本地运行与测试:pi-test.sh 与 test.sh

pi-test.sh(以及 Windows 的 .ps1、.bat)用来直接从源码运行 CLI。旧版用 tsx 跑,1.5 改成 node --import <source-resolver.ts> packages/coding-agent/src/cli.ts。原因写在 source-resolver.ts 注释里:Node 已经能原生剥离 TypeScript 类型,但不认 tsconfig 里的 paths,于是用 module.registerHooks 预加载一个解析器,把包名导入解析到源码,避免悄悄落到过期的 dist 上。--no-env 参数会 unset 一长串 provider 的 API key 环境变量(和 pi-ai 的 env key 表对应),用来模拟"什么凭证都没有"的启动。

test.sh 是 AGENTS.md 指定的跑全部非 e2e 测试的方式(禁止直接跑全量 vitest,因为 e2e 测试会在检测到 key 时自动启用、花真钱)。1.5 的做法比旧版"挪走 auth.json、unset 几个 key"彻底得多:

  • 用 mktemp 建一个临时根目录,把 HOME、TMPDIR、XDG_CONFIG_HOME、npm 的 userconfig/globalconfig/cache 全部重定向进去;
  • 用 env -i 从空环境启动 npm test,只放行 PATH、locale、时区、PI_NO_LOCAL_LLM=1、AWS_EC2_METADATA_DISABLED 等白名单变量,以及 CI 标识和 NODE_EXTRA_CA_CERTS;
  • Git 也被隔离:不读系统和全局配置,禁止终端提示,GIT_ASKPASS 指向 false;
  • 退出时清理临时目录前先校验它确实是自己创建的(路径前缀 + 标记文件 .pi-test-owned + 不是符号链接),否则拒绝删除。

思路从"黑名单删掉已知的密钥"变成"白名单只给必要的环境",新加的 provider 不用记得更新脚本。根 npm test 本身则是 test:scripts(node --test 跑 scripts/*.test.*)加各 workspace 的测试。

一句话:pi-test.sh 免构建从源码跑,test.sh 用空环境 + 临时 HOME 保证测试碰不到真实凭证。

Nix flake

1.x 新增了 flake.nix,对外提供 packages.default/pi、apps.default(运行 bin/pi)和 overlays.default,覆盖 aarch64/x86_64 的 darwin 与 linux(Intel Mac 单独钉在 nixpkgs 26.05-darwin 分支,因为 unstable 已不支持)。具体构建在 nix/package.nix,分两个 derivation:

  1. workspacePackages:以根 package-lock.json 为依赖源(importNpmLock,--ignore-scripts),用 nix/model-catalog.json 里钉住的目录版本从 pi.dev 下载模型目录,sha256 就是版本号本身;hydrate-model-catalog.ts 写入数据后跑 npm run build:offline,再 npm pack 出各包 tarball。
  2. pi:以 packages/coding-agent/install-lock/ 为依赖源,把锁文件里指向 registry 的内部包替换成第一步本地构建的 tarball,安装后用 makeWrapper 生成 pi 命令,运行 bundle 版 CLI,并把 fd、ripgrep(Linux 上还有剪贴板工具)放到 PATH 前面。installCheckPhase 校验 pi --version,并实际加载 TUI 原生模块和 photon,缺依赖就让构建失败,而不是运行时悄悄丢掉剪贴板功能。

这里能看出离线构建和安装锁的价值:Nix 沙箱构建不能联网,模型目录靠"钉版本 + 哈希"变成确定输入;运行时依赖树靠 install-lock 固定,与 pi.dev 安装器装出来的是同一棵树。

供应链立场

Pi 的依赖安全不是靠某一个工具,而是一组互相配合的约定:

  • 精确版本:.npmrc 设 save-exact=true,check-pinned-deps 在 check 里强制所有 dependencies、devDependencies、optionalDependencies 都是精确 semver,内部 workspace 依赖也一样。
  • 冷却期:.npmrc 的 min-release-age=2 让 npm 拒绝安装发布不足 2 天的版本,给恶意版本被发现、撤回留出窗口。
  • 不跑生命周期脚本:AGENTS.md 规定本地用 npm install --ignore-scripts、CI 风格用 npm ci --ignore-scripts,CI workflow、Nix、官方 Docker 示例都照此执行;版本脚本里的 npm install --package-lock-only 也带 --ignore-scripts。
  • 安装锁:packages/coding-agent/install-lock/ 是给 pi.dev 安装器(@earendil-works/pi-coding-agent-install)用的锁文件根,由 generate-coding-agent-install-lock.mjs 从根锁文件派生,check 保证两者同步。脚本里有一张允许执行安装脚本的白名单,每项写明理由(例如 protobufjs 的 postinstall 只是打印警告),新增有安装脚本的依赖必须评审后显式加入。
  • 锁文件即代码:pre-commit 默认拒绝提交锁文件变更,除非设置 PI_ALLOW_LOCKFILE_CHANGE=1;npm-audit.mjs 跑 npm audit --omit=dev,被接受的已知漏洞必须写明包名和不适用的理由。根 overrides 只钉了一个 protobufjs@7.6.6。

一句话:钉死版本、延迟采纳、禁止安装脚本、锁文件需评审,四件事把"装依赖"从默认信任变成默认怀疑。

常见追问

为什么不用 Turborepo、Nx 这类工具来排构建顺序? 包只有十来个、依赖关系稳定,一行手写顺序足够且零依赖;引入编排工具意味着又多一个需要钉版本、审计的依赖,与仓库的供应链立场相悖。代价是新增包时要记得改这一行。

为什么开发时指向 src、构建时指向 dist? 开发和测试要的是改完立刻生效,不想每次先构建;构建和发布要的是验证包之间的公开接口(.d.ts)真的够用,指向 dist 能提前暴露"依赖了对方未导出的内部类型"这种问题。

为什么从 shrinkwrap 换成 install-lock? 从 Nix 和安装器的用法看,install-lock 是一个独立的锁文件根,既能让安装器装出确定的依赖树,也能让 Nix 把内部包替换成本地构建,而 shrinkwrap 只能随 npm 包发布、对这些场景不够灵活。(这是对设计的推断,仓库没有写明迁移理由。)

源码入口(v1.5):package.json、tsconfig.json、packages/coding-agent/tsconfig.build.json、vitest.base.ts、test.sh、nix/package.nix