close

Main Chain A · Step 02

loadProfile:profile 发现与补丁层组合

packages/boot/app-boot/src/profile.ts(420 行)是「配置树」的物质来源:找到 profile 目录、解析它的 dsh.profile.bundles、把每个 bundle 的补丁文件读成补丁列表,再加上 profile 自己的用户层。五层补丁中的前两层在这里就位。

bin.ts:31 runProfile profile-boot.ts:98 prepareProfile profile.ts:371 loadProfile (下一步) profile.ts:413 composeEntries → 03 boot

示例本次示例:dsh web 在你机器上的 profile

示例轨迹 02-1 · 真实 profile 目录的内容(本机 $DSH_HOME = C:\Users\充电宝\.dsh)
$ ls ~/.dsh
profiles/  sessions/  settings.yaml  storages/

$ ls ~/.dsh/profiles
node_modules/  web/

$ cat ~/.dsh/profiles/web/package.json
{
  "name": "dsh-profile-web",
  "private": true,
  "dependencies": {},
  "dsh": {
    "profile": {
      "bundles": [
        "@deepseek-ai/dsh-base",
        "@deepseek-ai/dsh-web-app"
      ]
    }
  }
}
来源:本机真实目录。dsh.profile.bundles 就是 02 页 loadProfile:387 读的那个字段;web 目录是首次运行 dsh web 时 initProfile(383 行)自动创建的(PROFILE_TEMPLATES.web,114-117 行)
示例轨迹 02-2 · 本次 loadProfile 的返回值形状
# loadProfile('dsh', 'web', INSTALL_ANCHOR) 返回(02 页 402 行):
{
  name: 'web',
  dir: 'C:/Users/充电宝/.dsh/profiles/web',
  layers: [
    { packageName: '@deepseek-ai/dsh-base',
      packageDir: 'D:/dev/sourcecode/deepseek-harness/packages/bundle/base',   ← 安装优先!
      patchPath: '.../packages/bundle/base/cordis.patch.yml',
      patches: [78 个 insert/disable 补丁] },
    { packageName: '@deepseek-ai/dsh-web-app',
      packageDir: '.../packages/bundle/web-app',
      patchPath: '.../web-app/cordis.patch.yml',
      patches: [UI 条目补丁] },
  ],
  patchPath: 'C:/Users/充电宝/.dsh/profiles/web/cordis.patch.yml',
  patches: []          ← 用户层是空数组(本机还没写过任何定制)
}
来源:02 页 Profile 类型(profile.ts:84-90)+ 本机真实 profile

下一站:03 boot()——五层补丁里的前两层已经变成 PatchOptions[]。

§1先看数据形状:Profile 类型

packages/boot/app-boot/src/profile.ts核心类型72-96, 114-117
72export interface ProfileLayer {
73  packageName: string       // dsh.profile.bundles 里列的名字
74  packageDir: string        // bundle 包的绝对目录
75  patchPath: string         // bundle 的补丁文件绝对路径
76  patches: PatchOptions[]   // 解析后的补丁列表
77}
84export interface Profile {
85  name: string
86  dir: string
87  layers: ProfileLayer[]   // bundle 层,按 dsh.profile.bundles 顺序
88  patchPath: string        // profile 自己的 cordis.patch.yml 路径
89  patches: PatchOptions[]  // 用户层补丁;文件不存在时为空
90}
114export const PROFILE_TEMPLATES: Record<string, readonly string[]> = {
115  web: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'],
116  headless: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-headless'],
117}
72-77

一个 bundle 层 = 包名 + 目录 + 补丁路径 + 已解析的补丁列表。注意补丁在 loadProfile 里就已经解析好了(不是懒加载)——组装阶段是纯数据操作。

84-90

Profile = bundle 层们 + 用户层。用户层(patches)与 bundle 层分离存放——因为两者在热更新里的地位不同(04 页:用户层可热重载,bundle 层是基底)。

114-117

出厂模板:webheadless 两个 profile 各有自己的 bundle 组合。两个 profile 共享同一个 base——这就是「模式差异」的实现:差异只在于多挂了哪个 bundle。

§2loadProfile:主体

packages/boot/app-boot/src/profile.tsloadProfile 全文371-403
371export function loadProfile(binName, name, installAnchor, home = resolveDshHome(), options = {}): Profile {
375  const dir = resolveProfileDir(name, home)
376  if (!existsSync(join(dir, 'package.json'))) {
377    const template = PROFILE_TEMPLATES[name]
378    if (template === undefined) {
379      throw new Error(`${binName}: profile ${JSON.stringify(name)} does not exist; create it with ...`)
380    }
383    initProfile(dir, template)
384  }
385  const manifest = normalizeShippedProfile(name, dir, readProfileManifest(binName, dir))
387  const bundles = manifest.dsh?.profile?.bundles ?? []
388  const layers = bundles.map((packageName): ProfileLayer => {
389    const packageDir = resolveBundleDir(binName, packageName, installAnchor, dir)
390    const bundleManifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8'))
391    const declared = bundleManifest.dsh?.bundle?.patch
392    if (declared === undefined) {
393      throw new Error(`${binName}: profile bundle ${packageName} declares no dsh.bundle in its package.json`)
394    }
395    const patchPath = join(packageDir, declared)
396    return { packageName, packageDir, patchPath, patches: loadOverlayPatches(binName, patchPath) }
397  })
398  const patchPath = join(dir, PROFILE_PATCH_FILENAME)
399  const patches = options.userLayer !== false && existsSync(patchPath)
400    ? loadOverlayPatches(binName, patchPath)
401    : []
402  return { name, dir, layers, patchPath, patches }
403}
375

profile 目录 = $DSH_HOME/profiles/<name>。注意 resolveProfileDir(104-111 行)里有名字合法性校验:空、含路径分隔符、./..node_modules 都被拒——防止路径逃逸。

376-384

首次使用自动初始化:profile 目录不存在时,若名字有出厂模板(web/headless)就自动 initProfile(写 package.json + 空 cordis.patch.yml + pnpm-workspace.yaml);没模板就报错并提示用 dsh plugin 创建。这就是「dsh web 第一次跑就能用」的原因。

385

normalizeShippedProfile(297-312 行):如果 bundle 列表恰好等于某个「安装方拥有」的历史元组,就规范化到当前出厂模板并写回。这是发布期兼容——老 profile 的 bundle 组合升级到新模板。

387

手写的 profile manifest 可以完全没有 dsh 段——此时 bundles 为空数组,等价于一个空 profile。

388-397

bundle 层解析循环:每个名字 → 解析目录 → 读它的 package.json → 取 dsh.bundle.patch 字段 → 解析补丁文件。392-394 是 fail-loud:列出的包没有 dsh.bundle 声明就报错——把非 bundle 包当层是配置错误,不是「没有补丁」。

398-401

用户层:profile 自己的 cordis.patch.yml。注意 options.userLayer: false 时跳过——这是 --dump-default-config 走的路径(01 页的 defaultOnly),保证 dump 不因坏掉的用户层而失败。

§3resolveBundleDir:双锚点解析

packages/boot/app-boot/src/profile.ts安装优先的解析契约344-355
344export function resolveBundleDir(binName, packageName, installAnchor, profileDir): string {
347  for (const anchor of [installAnchor, join(profileDir, 'package.json')]) {
348    const dir = packageDirFromAnchor(anchor, packageName)
349    if (dir !== undefined) return dir
350  }
351  throw new Error(
352    `${binName}: cannot resolve profile bundle ${packageName} from the dsh installation or ${profileDir}; `
353    + `run 'dsh plugin --profile ${basename(profileDir)} install' ...`
354  )
355}
347

两个锚点、安装优先:先按 dsh 安装位置(launcher 自己的 package.json)解析,再按 profile 目录解析。这是契约——JSDoc 原文:「installation-first order is the contract that @deepseek-ai/dsh-base always comes from the same installation as the running dsh, never from a profile-local copy」。你 clone 一份 bundle 放进 profile 也改变不了它从安装处来。

348

packageDirFromAnchor(322-330 行):不用 require.resolve(那需要包导出 package.json),而是拿 createRequire(anchor).resolve.paths() 的搜索路径逐个探测——与 Node 自己的 node_modules 查找顺序一致,保证「Loader 实际会 import 什么,解析到的就是什么」。

351-354

两个锚点都找不到 → 报错并给修复指引(用 dsh plugin 装)。fail-loud,不静默跳过。

配套的还有 healProfilesModuleFallback(223-255 行):在 $DSH_HOME/profiles/node_modules 里为 dsh 依赖闭包的每个包建一个 symlink(BFS 遍历 dependencies + peerDependencies)。这样 out-of-tree 插件(装在 profile 自己 node_modules 里的)通过 Node 的父目录向上查找,能找到安装方的 cordis 等 peer——所有插件共享安装里的同一个 cordis 实例,不会出现两个 cordis。

§4composeEntries:补丁叠加

packages/boot/app-boot/src/profile.ts组合成条目表413-420
413export function composeEntries(layers, warn = () => {}): EntryOptions[] {
416  return applyEntryPatches([], structuredClone(layers.flat()), (message, ...args) => {
417    let index = 0
418    warn(message.replace(/%C/g, () => JSON.stringify(args[index++])))
419  })
420}
413

输入是「补丁列表的列表」——每一层是一个 PatchOptions[],按应用顺序传入。输出是最终的条目表(id → Entry)。

416

关键:applyEntryPatches 来自 vendored 的 @deepseek-ai/cordis-plugin-includevendor/include/src/index.ts,377 行)——补丁算法的唯一定义点。它应用在空列表上(第一个参数 [])。

416

为什么 structuredClone?(这是全库最重要的 gotcha 之一,04 页还会遇到):insert 行按引用 push 进挂载树,后续 id 定向补丁原地 mutate这些对象。不 clone 的话,用户 override 会「烤」进 bundle 的内存 insert 行——之后删除 override 也无法还原。所以每次组合前都 clone。

417-418

warn 回调:把 include 库的 %C 占位符格式转成 JSON 字符串。用于诊断「被跳过的补丁」(比如 patch 了一个不存在的 id)。

§5五层补丁清单

到这里,五层补丁的前两层(bundle 层、profile 用户层)已经在 loadProfile 里变成 PatchOptions[]。完整的五层在 04 页的 composeProfile 里拼齐:

来源在本页还是 04 页就位
① bundle 层每个 bundle 的 dsh.bundle.patch 文件,按 dsh.profile.bundles 顺序本页 loadProfile:396
② profile 用户层$DSH_HOME/profiles/<name>/cordis.patch.yml本页 loadProfile:400
③ home 用户层$DSH_HOME/cordis.patch.yml(机器级偏好,高于 profile 层)04 页 composeProfile:147
④ --patch overlay命令行 overlay,按 argv 顺序04 页 composeProfile:148
⑤ 遥测开关DSH_TELEMETRY_DISABLED 非空即禁用(含 '0'/'false'——隐私开关宁可误关)04 页 resolveTelemetryPatch:80

patch 的替换语义是「整行替换」,不是深合并。一个 patch 命中某 id 时替换该行的整个 config。因此与模式相关的行不放在 base bundle,而归每个模式 bundle 各自重述完整配置。

cordis.patch.yml 不是 Loader 直接挂载的配置——它只是一份数据。真正的 root 配置是 profile-boot.ts 每次重写的空 cordis.yml(只为给 Loader 一个 include 根锚定 baseUrl)。这个「数据 vs 挂载」的区分在 03 页 boot() 里会再次出现。