Main Chain A · Step 02
loadProfile:profile 发现与补丁层组合
packages/boot/app-boot/src/profile.ts(420 行)是「配置树」的物质来源:找到 profile 目录、解析它的 dsh.profile.bundles、把每个 bundle 的补丁文件读成补丁列表,再加上 profile 自己的用户层。五层补丁中的前两层在这里就位。
示例本次示例:dsh web 在你机器上的 profile
$ 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"
]
}
}
}
# 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: [] ← 用户层是空数组(本机还没写过任何定制)
}
下一站:03 boot()——五层补丁里的前两层已经变成 PatchOptions[]。
§1先看数据形状:Profile 类型
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}
一个 bundle 层 = 包名 + 目录 + 补丁路径 + 已解析的补丁列表。注意补丁在 loadProfile 里就已经解析好了(不是懒加载)——组装阶段是纯数据操作。
Profile = bundle 层们 + 用户层。用户层(patches)与 bundle 层分离存放——因为两者在热更新里的地位不同(04 页:用户层可热重载,bundle 层是基底)。
出厂模板:web 和 headless 两个 profile 各有自己的 bundle 组合。两个 profile 共享同一个 base——这就是「模式差异」的实现:差异只在于多挂了哪个 bundle。
§2loadProfile:主体
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}
profile 目录 = $DSH_HOME/profiles/<name>。注意 resolveProfileDir(104-111 行)里有名字合法性校验:空、含路径分隔符、./..、node_modules 都被拒——防止路径逃逸。
首次使用自动初始化:profile 目录不存在时,若名字有出厂模板(web/headless)就自动 initProfile(写 package.json + 空 cordis.patch.yml + pnpm-workspace.yaml);没模板就报错并提示用 dsh plugin 创建。这就是「dsh web 第一次跑就能用」的原因。
normalizeShippedProfile(297-312 行):如果 bundle 列表恰好等于某个「安装方拥有」的历史元组,就规范化到当前出厂模板并写回。这是发布期兼容——老 profile 的 bundle 组合升级到新模板。
手写的 profile manifest 可以完全没有 dsh 段——此时 bundles 为空数组,等价于一个空 profile。
bundle 层解析循环:每个名字 → 解析目录 → 读它的 package.json → 取 dsh.bundle.patch 字段 → 解析补丁文件。392-394 是 fail-loud:列出的包没有 dsh.bundle 声明就报错——把非 bundle 包当层是配置错误,不是「没有补丁」。
用户层:profile 自己的 cordis.patch.yml。注意 options.userLayer: false 时跳过——这是 --dump-default-config 走的路径(01 页的 defaultOnly),保证 dump 不因坏掉的用户层而失败。
§3resolveBundleDir:双锚点解析
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}
两个锚点、安装优先:先按 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 也改变不了它从安装处来。
packageDirFromAnchor(322-330 行):不用 require.resolve(那需要包导出 package.json),而是拿 createRequire(anchor).resolve.paths() 的搜索路径逐个探测——与 Node 自己的 node_modules 查找顺序一致,保证「Loader 实际会 import 什么,解析到的就是什么」。
两个锚点都找不到 → 报错并给修复指引(用 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:补丁叠加
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}
输入是「补丁列表的列表」——每一层是一个 PatchOptions[],按应用顺序传入。输出是最终的条目表(id → Entry)。
关键:applyEntryPatches 来自 vendored 的 @deepseek-ai/cordis-plugin-include(vendor/include/src/index.ts,377 行)——补丁算法的唯一定义点。它应用在空列表上(第一个参数 [])。
为什么 structuredClone?(这是全库最重要的 gotcha 之一,04 页还会遇到):insert 行按引用 push 进挂载树,后续 id 定向补丁原地 mutate这些对象。不 clone 的话,用户 override 会「烤」进 bundle 的内存 insert 行——之后删除 override 也无法还原。所以每次组合前都 clone。
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() 里会再次出现。