返回博客2026年9月6日1 分钟阅读

新版 DSH 为什么启动不了 dsh-lark

摘要

一次插件兼容性升级的完整复盘:从干净环境复现启动崩溃,到识别 npm 产物与源码的差异,再用真实 tarball 完成端到端验收。

最近我在升级 dsh-lark,让它适配新版 DSH 0.1.2-rc.1。表面上看,这只是一次常规的依赖升级:改版本号、跑测试、修几个类型错误,然后重新发布。

实际情况却更有意思。旧版 dsh-lark 安装到新版 DSH 后,不是在某个飞书消息到达时出错,而是让整个 DSH 在启动阶段直接退出。更麻烦的是,在开发仓库里直接运行,问题一度无法稳定复现。

这次排查让我再次确认了一件事:开发插件时,仓库源码不是最终事实,真正被用户安装的包才是。

先把“无法启动”变成稳定复现

一开始,我没有继续在已有开发环境里反复启动。这个环境装过多个版本的 DSH 依赖,Node.js 的模块解析很可能从仓库上层找到某个旧包,让本来应该失败的代码意外运行起来。

我换成了一个全新的 DSH 配置目录,只安装公开发布的 @sugarforever/dsh-lark@0.2.2,再用 DSH 0.1.2-rc.1 启动 Web profile。错误马上出现:

SyntaxError: The requested module '@deepseek-ai/dsh-settings'
does not provide an export named 'settingsNamespace'

这个复现很重要。它把问题从“新版好像不兼容”缩小成了一个非常具体的事实:dsh-lark 在运行时导入了 settingsNamespace,但新版实际安装的 @deepseek-ai/dsh-settings 没有导出它。

干净环境还排除了另一个干扰项。问题跟飞书凭证、Webhook 配置、网络请求都无关,因为插件甚至还没有走到这些逻辑,ES Module 在加载入口文件时就已经失败了。

源码有导出,npm 包却没有

接下来出现了这次排查里最容易误导人的地方。

查看 DSH 相关仓库的源码,能够找到 settingsNamespace。如果只以源码为依据,很容易得出“这个导出明明存在”的结论,继而怀疑版本没有装对,或者构建缓存出了问题。

但我真正需要回答的是:用户安装到机器上的 npm 包里有什么?

检查 @deepseek-ai/dsh-settings@0.1.2-rc.1 的发布产物后,答案很清楚:它生成的 lib/index.js 并没有导出 settingsNamespace。源码仓库与发布产物之间存在差异,而插件依赖的是后者。

这也解释了为什么原有开发环境可能正常:本地依赖树可以把缺失的运行时导出遮住,干净安装则忠实暴露了发布包的真实边界。

把运行时依赖缩到最小

settingsNamespace 本质上只是给字符串增加类型约束。dsh-lark 使用的 namespace 是稳定的 lark-channel,没有必要为了构造这个值,在运行时依赖一个辅助函数。

修复方式因此很小:只导入类型,并把常量收窄为 DSH 所需的类型。

import type { SettingsNamespace } from '@deepseek-ai/dsh-settings'

const LARK_SETTINGS_NAMESPACE = 'lark-channel'
const namespace = LARK_SETTINGS_NAMESPACE as SettingsNamespace

settings.register(namespace, provider)

TypeScript 的类型导入在构建后会被擦除。这样既保留了编译期约束,也不再要求新版发布包提供那个运行时函数。

这不是给错误导出打补丁,而是重新审视依赖:如果一个值在协议层就是固定字符串,就不该为了创建它而引入不必要的运行时耦合。

新旧 API 同时存在时,兼容层应该放在哪里

解决启动崩溃后,升级里还有两处 API 演进需要处理。

第一处是 Session 事件读取。旧版通过 session.events 访问事件,新版提供 session.snapshotEvents()。如果业务代码到处判断版本,后续维护会迅速变乱。我把差异收进了 harness 边界里的一个小函数:

function eventsFrom(session: Session, firstSeq: number) {
  return 'snapshotEvents' in session
    ? session.snapshotEvents(firstSeq)
    : session.events
}

上层逻辑只处理统一的事件集合,不需要知道当前运行的是哪个 DSH 版本。

第二处是凭证更新事件。旧事件名是 credentials/updated,新版改为 credentials/reference-updated。这里采用双事件监听,让升级后的插件能够响应新版宿主,同时不立即切断旧版兼容性。

这类兼容层最好靠近外部系统边界。它的任务不是把版本差异传播到整个项目,而是尽早把不同输入归一化。

预发布依赖还有另一种陷阱

修完代码后,npm ci 又暴露了一个不同层面的问题。

DSH 仍处在预发布阶段,各个子包的发布节奏并不完全同步。npm 尝试解析 peer dependencies 时,可能把 rc.7rc.8rc.1 组合到同一棵依赖树里,最终得到一个看似严格、实际上无法安装的冲突。

dsh-lark 来说,部分 DSH 服务本来就由宿主 profile 提供,插件不应该在独立安装时替宿主重新组装整套运行环境。因此我做了两件事:

  • 在项目的 npm 配置中启用 legacy-peer-deps,避免独立 CI 被预发布 peer 解析卡住。
  • 把测试和类型检查确实会直接使用的包加入开发依赖,例如 dsh-scopedsh-timeout@testing-library/dom

前者明确了“宿主负责运行时依赖”的边界,后者保证插件仓库自身的开发工具链完整。两者解决的是不同问题,不能用跳过安装错误来替代缺失的开发依赖。

测试通过,不等于用户能启动

这次升级最后采用了四层验证。

第一层是单元测试和回归测试,共 61 个测试全部通过。回归测试专门保证入口文件不会再次产生对 settingsNamespace 的运行时导入。

第二层是类型检查和正式构建,确认新旧 API 的兼容分支都满足类型约束,并能生成发布文件。

第三层是 npm pack。我检查的是即将发布的 tarball,而不是工作区里的 TypeScript 源码。只有这个产物才接近用户真正拿到的内容。

第四层是把 tarball 安装进全新的 DSH profile,再启动 DSH 0.1.2-rc.1

DSH_TEST_HOME=$(mktemp -d)

DSH_HOME="$DSH_TEST_HOME" \
  npx --yes @deepseek-ai/dsh@0.1.2-rc.1 \
  plugin --profile web add ./sugarforever-dsh-lark-0.2.3.tgz

DSH_HOME="$DSH_TEST_HOME" \
  npx --yes @deepseek-ai/dsh@0.1.2-rc.1 \
  web --no-open --port 0

最终,真实 tarball 能够在干净环境中完成安装,DSH Web 服务也正常启动。这一步才真正对应“用户升级后能不能用”。

包边界才是交付边界

这次问题的代码改动不大,排查价值却远大于改动本身。

当一个插件运行在快速演进的宿主系统中,兼容性问题通常不只来自 API 改名。源码、构建结果、npm 导出、peer dependency 解析和宿主注入的服务,都可能形成不同的现实。只在开发仓库里运行测试,很容易验证到一套用户根本不会安装的依赖组合。

以后再处理类似问题,我会更早做三件事:用空目录复现、直接检查发布产物、把最终 tarball 装回真实宿主。它们并不复杂,却能把“在我机器上可以”转换成一个更可靠的判断:交付出去的东西,确实可以启动。

dsh-lark 的代码在 GitHub 上公开。对这次升级来说,最关键的修复不是多写了一层抽象,而是删掉了一个不该存在的运行时依赖。

相关文章

最近一封 · Sample

我给 DeepSeek DSH 做了个 zvec-grep 插件

从一次代码语义搜索实验,到一个自动索引、可见状态、后台增量更新的 DeepSeek DSH 插件,我在 dsh-zvec-grep 开发中做了哪些取舍。

—— william

Letters

来信

里面装的是

  • 新文章 — 写完一篇就寄一封,不攒货
  • 这周读到的、看到的、好用的工具
  • 正在折腾的实验,附带翻车记录

约莫 1–2 周一封 · 随时退订

合作伙伴

CompeteMap — 英国及爱尔兰学生竞赛一站式搜索

数学、编程、科学、写作等各类竞赛信息汇总,支持按年龄和科目筛选,再也不错过报名截止日。

准备开始了吗?

先简单说明目标,我会给出最合适的沟通方式。