Main Chain A · Step 00
根:bin.ts 入口
一切从 apps/cli/src/bin.ts 开始——它是 dsh 命令的执行入口。整条启动链从这一页出发。
示例本次示例:pnpm dsh web 的起点
全书用一条真实命令作主线示例——官方文档的启动命令 dsh web。它在本页的形态是:
# package.json:198 的 "dsh" script: "dsh": "node --import tsx/esm apps/cli/src/bin.ts" # 所以 pnpm dsh web 实际执行: node --import tsx/esm apps/cli/src/bin.ts web # bin.ts 里 argv = ['web'](slice(2) 去掉了 node 与脚本路径) # 本页 20 行:invocation = parseDshArgs(['web'], version) # version 来自 getDshRuntimeVersion()(本页 19 行, # 由 @deepseek-ai/dsh-app-boot 读取它自己的 package.json)
# README.zh.md 的官方命令。两者只有「入口」不同: # 源码版走 tsx 钩子跑 TypeScript 源文件;发布版跑打包产物。 # 本页全部行号对两者同样成立——src/ 与 lib/ 都在 apps/cli 下一层。 # 0.1.3-alpha.2 起 bin.ts 把 dispatch 收进导出的 runCli(), # 末尾用 import.meta.main 守卫自执行——便于测试直接调用。
这条命令的下一站:01 args 解析。
§1这个文件在哪、怎么被调用
根 package.json 的 scripts 里有这样一行(package.json:198):
"dsh": "node --import tsx/esm apps/cli/src/bin.ts"
所以 pnpm dsh web 实际执行的是 node --import tsx/esm apps/cli/src/bin.ts web。--import tsx/esm 让 Node 直接跑 TypeScript 源码(ESM-only hook)。这就是仓库里说的「dsh CLI source launch runs through tsx's ESM-only hook」——源码启动模式。
§2完整代码68 行
1#!/usr/bin/env node
7/* v8 ignore file -- built-bin acceptance exercises this self-executing dispatch. */
9import { getDshRuntimeVersion, loadLayeredEnv, StartupError } from '@deepseek-ai/dsh-app-boot'0.1.7-rc.1:版本号改由这里提供
10import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
11import { parseDshArgs } from './args.ts'
12import { reportStartupFailure } from './startup-diagnostics.ts'0.1.6-alpha.2 新增模块
18export async function runCli(): Promise<void> {
19 const version = getDshRuntimeVersion() // readVersion() 已删除版本号来自 app-boot
20 const invocation = parseDshArgs(process.argv.slice(2), version)
22 switch (invocation.mode) {
23 case 'profile': {
24 const { runProfile } = await import('./profile-boot.ts')
25 try {
26 await runProfile({
27 environment: loadLayeredEnv('dsh'),
28 profile: invocation.profile,
29 fromDefaultProfile: invocation.fromDefaultProfile,
30 patchFiles: invocation.patches,
31 args: invocation.args,
32 })
33 } catch (error) { // 0.1.6-alpha.2 新增启动失败诊断
34 if (!(error instanceof StartupError)) throw error
35 await reportStartupFailure(error, { home: resolveDshHome(), version, profile: invocation.profile })
36 process.exit(1)
37 }
38 break
39 }
40 case 'plugin': {
41 const { runPlugin } = await import('./plugin.ts')
42 process.exit(await runPlugin(invocation.profile, invocation.args)) // 现在 awaitrunPlugin 变 async
43 break
44 }
45 case 'dump-config': {
46 const { runDumpConfig } = await import('./dump-config.ts')
47 runDumpConfig(
48 invocation.profile,
49 invocation.defaultOnly,
50 invocation.patches,
51 invocation.fromDefaultProfile,
52 )
53 break
54 }
55 case 'dump-config-schema': { // 0.1.7-rc.1 新增第四个 mode
56 const { runDumpConfigSchema } = await import('./dump-config-schema.ts')
57 await runDumpConfigSchema(invocation.profile, invocation.patches, invocation.fromDefaultProfile)
58 break
59 }
60 default:
61 invocation satisfies never
62 throw new Error(`dsh: unhandled invocation mode ${JSON.stringify(invocation)}`)
63 }
64}
66if (import.meta.main) { // 仅在直接执行时跑
67 await runCli()
68}
§3行级解读
shebang。这是可执行脚本的入口行——npm 发布的 bin 会以这个文件为命令入口。
/* v8 ignore file */:告诉覆盖率工具忽略本文件的测试覆盖——因为这个文件是「自执行的 dispatch」,真正的验收靠 built-bin acceptance 测试跑产物。
两个 import。@deepseek-ai/dsh-app-boot 提供 getDshRuntimeVersion、loadLayeredEnv 与 StartupError;@deepseek-ai/dsh-home-paths 提供 resolveDshHome。0.1.7-rc.1 起 readVersion() 被删除——版本号改由 app-boot 的 getDshRuntimeVersion() 提供,于是原先的 readFileSync/fileURLToPath 两个 node 内置导入也一起消失。
两个包内相对 import:./args.ts 的 parseDshArgs 与 ./startup-diagnostics.ts 的 reportStartupFailure(本轮新增的模块,68 行),后者负责把启动失败写成日志文件。
loadLayeredEnv('dsh'):从 @deepseek-ai/dsh-app-boot 加载分层环境变量快照(后面在 runProfile 里被 provide 进插件树,见 04 页)。「layered」= 进程 env + 根 .env 文件按层合并。
parseDshArgs 来自 ./args.ts——注意是 .ts 后缀的相对导入(仓库约定:跨包用包名,包内用 .ts 后缀)。下一站(01 页)讲它。
getDshRuntimeVersion():版本号不再由 bin.ts 自己读 package.json,而是委托给 @deepseek-ai/dsh-app-boot(它读的是自己包的 package.json,所以拿到的是运行时版本,而非 CLI 包版本)。原来的 readVersion() 连同它的 new URL('../package.json', import.meta.url) 相对定位一起删掉了。
入口函数 runCli(0.1.3-alpha.2 起):整个进程的第一行真实逻辑——先取版本号存进局部变量(0.1.6-alpha.2 改:以前是内联调用,现在 version 要供下面的诊断复用),再解析 argv(去掉前两个:node 路径和脚本路径)拿到 invocation。返回类型是判别联合 DshInvocation(01 页详述)。抽出成导出函数是为了让测试能直接调用而不触发进程自执行。
0.1.5 新增 fromDefaultProfile:dsh --from-default-profile … 让本次启动的 profile 以默认 profile 为底稿初始化(initializeProfileFromDefault,见 04 页),而不是从空目录起步。它同时透传给 runProfile(29 行)与 runDumpConfig(51 行)——两个入口对同一个开关必须有一致认知,否则 dump-config 打印出来的树会和实际启动的树不一样。
switch 按 mode 分发。注意四个 mode 都是动态 import——这是设计决策,见 §4。
主路径 profile(dsh web / dsh headless "task" 都走这):动态 import profile-boot.ts,调 runProfile。传五个参数:环境快照、profile 名、fromDefaultProfile、--patch overlay 路径、以及「内部参数」(profile 名之后的所有东西,原样交给插件树)。这一调用最终长出一整棵插件树(02→04 页)。
0.1.6-alpha.2 新增:启动失败诊断。注意它只捕 StartupError(app-boot/src/index.ts:799),其余错误原样 rethrow——因为只有启动审计的错误才携带结构化的插件诊断。reportStartupFailure 把完整报告写进 $DSH_HOME/logs/(见 §5),终端只留摘要,然后 exit(1)。
plugin 模式:管理 profile 的插件依赖,转发给 pnpm。注意 42 行现在 await runPlugin(...)——该函数在 0.1.6-alpha.2 变成了 async(它要 await 包管理器操作)。与主链无关,略过。
dump-config 模式:打印组装好的配置树然后退出,不真正 boot。它和主链共用 02 页的组装函数,所以值得知道它的存在——0.1.5 起它多收一个 fromDefaultProfile,正是为了保持这条「预览即所见」的承诺。
0.1.7-rc.1 新增:dump-config-schema 模式。这是第四个 mode,打印的是配置树的 JSON Schema(而非配置本身),供编辑器补全与外部工具校验用。它收三个参数(profile、--patch 路径、fromDefaultProfile),注意这里用了 await——schema 档的生成要走异步的组装路径。相应地 DshInvocation 联合也多了一个成员(01 页)。
穷尽性检查:invocation satisfies never 是 TypeScript 的编译期断言——如果将来 DshInvocation 加了新变体,这行会编译失败,逼你处理。
自执行守卫(0.1.3-alpha.2 新增):import.meta.main 只在文件被直接执行时为真——测试 import 本模块时不会启动进程。
§50.1.6-alpha.2 新增:启动失败诊断
0.1.6-alpha.1 给启动审计做了分级(必需 vs 可选,见 03 页)。alpha.2 补上了另一半:失败时把完整诊断留下。
新模块 apps/cli/src/startup-diagnostics.ts(68 行)的文件头一句话说清定位:「Save original startup diagnostics while keeping the terminal report concise.」——完整报告存盘,终端只留摘要。
| 环节 | 位置 | 做什么 |
|---|---|---|
| 错误类型 | app-boot/src/index.ts:799 | StartupError extends Error,携带 entries: StartupEntryDiagnostic[](每个未激活插件的元数据与原始失败值)。boot 在 dispose 之后把 startup: { configurationPath, messages } 挂上去(1018 行) |
| 日志收集 | app-boot/src/index.ts:980-987 | boot 里新建一个独立 Context 当 logger exporter,只收 warn/error 级别的日志。它必须活得比 root disposal 长,才能留住异步清理阶段的错误(979 行注释) |
| 落盘 | apps/cli/src/startup-diagnostics.ts | 把摘要打到 stderr,完整报告写到 $DSH_HOME/logs/ 下的唯一命名文件(randomUUID)。写失败时会把完整报告也打到 stderr——不谎称「已保存到某路径」 |
这是个漂亮的职责切分:app-boot 负责收集(谁失败了、为什么、启动日志),apps/cli 负责呈现(终端摘要 + 文件路径)。前者是库,不知道终端长什么样;后者是应用,知道用户的耐心有限。
隐私边界:诊断上下文里刻意不含环境变量值与插件配置(startup-diagnostics.ts:9 的注释原文:「no environment values or plugin configurations are collected」)。日志文件是私有的($DSH_HOME/logs),但即便如此也不收集这些——因为报告可能被贴进 issue。
§4一个设计问题:为什么动态 import
为什么 runProfile/runPlugin/runDumpConfig 不静态 import,而要在 switch 里 await import()?文件头的 JSDoc 说了原因:
Dynamic imports per mode keep unrelated modes out of each dispatch path——每个 mode 的无关模块不进对方的派发路径。
如果静态 import 三个文件,那么无论你跑哪个 mode,Node 都会加载全部三个模块(以及它们的依赖树)。动态 import 让 dsh plugin 不需要加载 profile 启动链(含 Cordis Loader 等重依赖),也让 --help/参数错误的路径(在 parseDshArgs 里直接 process.exit)不碰任何重模块——帮助文本和报错要快。
从这里往下看主链:dsh web 的下一步是 profile 分支 → runProfile(04 页)。但在 runProfile 之前,先看 parseDshArgs 怎么把 argv 变成结构化的 invocation(01 页)——这是理解「launcher flags 与 app flags 的边界」的关键。