从源码构建 dsh:构建流水线与 Host/Client 双面

专栏:DeepSeek Harness · 第 3 / 14 篇
DeepSeek HarnessAgent工程化

上篇我们用 npx 把 dsh 跑了起来。这篇进入源码:从 git clonepnpm dsh web 能跑,中间发生了什么。

最短路径

git clone https://github.com/deepseek-ai/deepseek-harness.git
cd deepseek-harness
pnpm install        # 包管理器锁定 pnpm@11.7.0,Node 要求 ^22.19.0 || >=24.0.0
pnpm run build      # 全量构建,产物落在各包自己的 lib/
pnpm dsh web        # 源码形态启动,直接消费构建产物

一个容易困惑的点:pnpm dsh 走的是 node --import tsx/esm apps/cli/src/bin.ts——TypeScript 入口直跑,不经过构建。但它要求构建产物已经存在:启动过程会加载 Typert 反射产物和前端 dist,缺了会在 profile 启动时报错并提示 pnpm run build。所以「源码直跑」不等于「不需要构建」。

构建流水线:四步

pnpm run build = tsx scripts/build.ts,主体很薄:解析 --profile(或环境变量)算出客户端公共构建环境(内联根包版本、7 位 commit、dirty 标记)→ 依次跑 build:libbuild:web → 把环境与产物摘要写进 gitignore 的记录文件。核心在 build:lib,它是固定顺序的四步:

tsc -b tsconfig.host.json          # ① Host 聚合 tsc:全部 TS 源发射为 lib/types/**/*.js
tsdown --env.DSH_BUILD_FACE host   # ② Host 打包:消费 ① 的产物,并运行 Typert 类型反射
tsc -b tsconfig.client.json        # ③ Client 聚合 tsc
tsdown --env.DSH_BUILD_FACE client # ④ Client 打包:各 Client 包产出 Node loader 入口 + browser bundle

build:web 再用 vite 把 apps/web 打到 apps/web/dist。构建入口脚本本身值得一提:

// scripts/build.ts:32-52
function main(): void {
  const { values } = parseArgs({
    options: { profile: { type: 'string' } },
    allowPositionals: false,
  })
  const root = resolve(import.meta.dirname, '..')
  const repositoryEnvironment = repositoryClientBuildEnvironment(root, process.env)
  const profile = values.profile ?? process.env[CLIENT_BUILD_PROFILE_SELECTOR]
  const clientEnvironment = resolveClientBuildEnvironment(repositoryEnvironment, profile)
  const buildEnvironment = clientBuildProcessEnvironment(process.env, clientEnvironment)

  rmSync(resolve(root, CLIENT_BUILD_RECORD_PATH), { force: true })
  runScript('build:lib', buildEnvironment)
  runScript('build:web', buildEnvironment)
  const record = writeClientBuildRecord(root, clientEnvironment)
  console.log(
    `build: recorded ${String(record.artifacts.fileCount)} client artifact(s) with ${String(Object.keys(record.environment).length)} public value(s)`,
  )
}

版本号和 commit 在这里被内联进客户端构建环境——浏览器端显示的版本信息来自构建时的注入,而不是运行时探测。

为什么要两个 tsconfig「面」

这是这个仓库最特别的工程决定。dsh 的前后端(宿主进程与浏览器)共享大量类型:都通过 Cordis 的 declare module同一批 Context上做接口合并。问题在于:一个 ts.Program 如果同时看到两侧合并进来的同名服务声明,就会类型冲突。

dsh 的解法是一个 solution、两个互斥的聚合 program

图表(build-from-source.md)
  • tsconfig.base.json:共享 compilerOptions + 全部 @deepseek-ai/* 源码 paths 别名(同时是 vitest 的解析门面,因此永远不许加 include/files)。
  • tsconfig.host.json / tsconfig.client.json:两个聚合入口,互斥地纳入各自源码与测试(*.host.spec.ts 归 Host,*.client.spec.ts 归 Client)。
  • 只有 6 个「拆分包」需要两侧各一份 leaf tsconfig(如 api/gatewayclient/connection)。

tsdown 的配置能看出两个面共享同一份 workspace 列表,只靠环境变量切换:

// tsdown.config.ts:16-30
export default defineConfig(({ env }) => {
  const client = isBuildFaceClient(env?.DSH_BUILD_FACE)
  return {
    workspace: ['vendor/*', 'packages/*/*', 'apps/cli'],
    entry: client ? '' : ['lib/types/{index,invariant,startup}.js'],
    outDir: 'lib',
    format: ['esm'],
    platform: 'node',
    target: 'es2024',
    fixedExtension: false,
    dts: false,
    clean: false,
    plugins: client ? [] : [typertPlugin({ mode: 'workspace', faces: ['host'] })],
  }
})

注意 Host 面的 entrylib/types/*.js——它只消费前置 tsc 发射的 JS,不直接碰 TS 源码。这也是「四步必须按序」的原因:① 的产物是 ② 的输入。

产物地图与验证

全部产物落在每个包自己的 lib/(不在根目录 dist),几个关键位置:

产物地图与验证

全部产物落在每个包自己的 lib/(不在根目录 dist),几个关键位置:

位置内容
apps/cli/lib/bin.jsnpm 安装形态的 CLI 入口(npx 跑的就是它)
apps/web/dist前端静态资源
.dsh-build/client-build-environment.json构建记录(版本/commit/产物摘要),release 打包会拒绝缺失或陈旧的记录
packages/*/lib/types/**Host tsc 发射物(tsdown 的输入)

日常循环建议:

pnpm run typecheck   # = 完整 Host lib 构建 + tsc -b client
pnpm run lint        # oxlint
pnpm run test        # vitest(还有 e2e/snapshot/web 等多套 config)
pnpm run clean       # 白名单清理:lib/、.dsh-build、*.tsbuildinfo 等

改了源码之后 pnpm dsh web 不会自动重建——它消费的是上次构建的产物。改完记得重跑对应步骤;全量 pnpm run build 在这个体量的仓库里并不快。

从构建产物回看启动

现在可以把「源码形态启动」画成时序图——重点看它在哪里消费构建产物:

图表(build-from-source.md)

可以说:构建负责把「源码 + 配置树」变成「可挂载的插件集合」,启动负责把插件集合变成运行中的进程

下一篇开始补地基:这一切的运行时底座——Cordis 插件框架。

← 返回文章列表