Main Chain A · Step 00

根:bin.ts 入口

一切从 apps/cli/src/bin.ts 开始——它是 dsh 命令的执行入口。整条启动链从这一页出发。

process.argv → bin.ts:20 parseDshArgs → (下一步) args.ts:145

示例本次示例:pnpm dsh web 的起点

全书用一条真实命令作主线示例——官方文档的启动命令 dsh web。它在本页的形态是:

示例轨迹 00-1 · pnpm 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)
来源:package.json(真实 script 行)· apps/cli/src/bin.ts:18-20
示例轨迹 00-2 · 同一命令在发布版里是 npx @deepseek-ai/dsh web
# README.zh.md 的官方命令。两者只有「入口」不同:
# 源码版走 tsx 钩子跑 TypeScript 源文件;发布版跑打包产物。
# 本页全部行号对两者同样成立——src/ 与 lib/ 都在 apps/cli 下一层。
# 0.1.3-alpha.2 起 bin.ts 把 dispatch 收进导出的 runCli(),
# 末尾用 import.meta.main 守卫自执行——便于测试直接调用。
来源:README.zh.md「运行」节 · bin.ts:14-17 注释

这条命令的下一站: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 行

apps/cli/src/bin.ts全文 68 行1-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行级解读

1

shebang。这是可执行脚本的入口行——npm 发布的 bin 会以这个文件为命令入口。

7

/* v8 ignore file */:告诉覆盖率工具忽略本文件的测试覆盖——因为这个文件是「自执行的 dispatch」,真正的验收靠 built-bin acceptance 测试跑产物。

9-10

两个 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 内置导入也一起消失。

11-12

两个包内相对 import:./args.ts 的 parseDshArgs 与 ./startup-diagnostics.ts 的 reportStartupFailure(本轮新增的模块,68 行),后者负责把启动失败写成日志文件。

9

loadLayeredEnv('dsh'):从 @deepseek-ai/dsh-app-boot 加载分层环境变量快照(后面在 runProfile 里被 provide 进插件树,见 04 页)。「layered」= 进程 env + 根 .env 文件按层合并。

11

parseDshArgs 来自 ./args.ts——注意是 .ts 后缀的相对导入(仓库约定:跨包用包名,包内用 .ts 后缀)。下一站(01 页)讲它。

19

getDshRuntimeVersion():版本号不再由 bin.ts 自己读 package.json,而是委托给 @deepseek-ai/dsh-app-boot(它读的是自己包的 package.json,所以拿到的是运行时版本,而非 CLI 包版本)。原来的 readVersion() 连同它的 new URL('../package.json', import.meta.url) 相对定位一起删掉了。

18-20

入口函数 runCli(0.1.3-alpha.2 起):整个进程的第一行真实逻辑——先取版本号存进局部变量(0.1.6-alpha.2 改:以前是内联调用,现在 version 要供下面的诊断复用),再解析 argv(去掉前两个:node 路径和脚本路径)拿到 invocation。返回类型是判别联合 DshInvocation(01 页详述)。抽出成导出函数是为了让测试能直接调用而不触发进程自执行。

29

0.1.5 新增 fromDefaultProfile:dsh --from-default-profile … 让本次启动的 profile 以默认 profile 为底稿初始化(initializeProfileFromDefault,见 04 页),而不是从空目录起步。它同时透传给 runProfile(29 行)与 runDumpConfig(51 行)——两个入口对同一个开关必须有一致认知,否则 dump-config 打印出来的树会和实际启动的树不一样。

22

switch 按 mode 分发。注意四个 mode 都是动态 import——这是设计决策,见 §4。

23-39

主路径 profile(dsh web / dsh headless "task" 都走这):动态 import profile-boot.ts,调 runProfile。传五个参数:环境快照、profile 名、fromDefaultProfile、--patch overlay 路径、以及「内部参数」(profile 名之后的所有东西,原样交给插件树)。这一调用最终长出一整棵插件树(02→04 页)。

33-37

0.1.6-alpha.2 新增:启动失败诊断。注意它只捕 StartupError(app-boot/src/index.ts:799),其余错误原样 rethrow——因为只有启动审计的错误才携带结构化的插件诊断。reportStartupFailure 把完整报告写进 $DSH_HOME/logs/(见 §5),终端只留摘要,然后 exit(1)。

40-44

plugin 模式:管理 profile 的插件依赖,转发给 pnpm。注意 42 行现在 await runPlugin(...)——该函数在 0.1.6-alpha.2 变成了 async(它要 await 包管理器操作)。与主链无关,略过。

45-54

dump-config 模式:打印组装好的配置树然后退出,不真正 boot。它和主链共用 02 页的组装函数,所以值得知道它的存在——0.1.5 起它多收一个 fromDefaultProfile,正是为了保持这条「预览即所见」的承诺。

55-59

0.1.7-rc.1 新增:dump-config-schema 模式。这是第四个 mode,打印的是配置树的 JSON Schema(而非配置本身),供编辑器补全与外部工具校验用。它收三个参数(profile、--patch 路径、fromDefaultProfile),注意这里用了 await——schema 档的生成要走异步的组装路径。相应地 DshInvocation 联合也多了一个成员(01 页)。

60-62

穷尽性检查:invocation satisfies never 是 TypeScript 的编译期断言——如果将来 DshInvocation 加了新变体,这行会编译失败,逼你处理。

66-68

自执行守卫(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:799StartupError extends Error,携带 entries: StartupEntryDiagnostic[](每个未激活插件的元数据与原始失败值)。boot 在 dispose 之后把 startup: { configurationPath, messages } 挂上去(1018 行)
日志收集app-boot/src/index.ts:980-987boot 里新建一个独立 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 的边界」的关键。