1. TypeScript CLI 循环依赖排查:从构建报错到模块拆分
TypeScript CLI 项目写到一定规模,最容易撞上的不是类型体操,而是模块组织与依赖关系失控。你可能会遇到这样的场景:本地tsc突然报Cannot access 'X' before initialization,或者bun build打包后运行时报undefined is not a function,再或者madge一跑满屏红色箭头——循环依赖。这类问题在 CLI 项目里尤其隐蔽,因为 CLI 通常有一个聚合入口(比如tools.ts、commands.ts),所有子模块都往这里注册,子模块又反过来引用入口里的类型或工具函数,环就形成了。
我试过在一个 200+ 文件的 TypeScript CLI 里排查循环依赖,最初靠肉眼翻 import,效率极低。后来固定了一套流程:先用依赖图工具把环可视化,再用类型集中化 + 延迟 require 拆环,最后用构建产物和运行时双重验证。这套流程对 Claude Code 这类大型 CLI 的模块组织思路是通用的——它的Tool.ts里明确注释了「从集中位置导入权限类型以打破 import cycles」,tools.ts里用require()包裹TeamCreateTool来延迟解析,都是同一套打法。
这篇文章面向正在维护 TypeScript CLI 的开发者,尤其是遇到循环依赖导致构建失败、启动报错、打包产物异常的人。你会拿到可复制的依赖图生成配置、循环检测命令、拆环前后的对照代码,以及一套从定位到验证的完整动作。核心检索词就是「TypeScript CLI 循环依赖排查」和「模块组织与依赖关系治理」,下面所有步骤都围绕这两个点展开。
先说清楚循环依赖为什么在 CLI 里特别容易发生。CLI 的典型结构是:入口cli.ts导入main.ts,main.ts导入聚合层tools.ts/commands.ts,聚合层导入各个具体实现tools/BashTool/BashTool.ts,而具体实现又需要引用核心抽象Tool.ts里的类型,甚至需要调用tools.ts里的注册函数。只要有一条反向边,环就闭合了。更麻烦的是 TypeScript 的import type在编译后会被擦除,但如果你写成了普通import,运行时就真的会去加载那个模块,环在运行时才暴露。
所以排查循环依赖要分两层看:编译期(类型层)和运行期(值层)。import type造成的环通常不影响运行,但会让tsc的类型推断变慢甚至报错;普通import造成的环才是运行时undefined的元凶。下面的步骤会同时覆盖这两层。
2. TaoToken 前置:拿到 Base URL、API Key 与 Model ID
在开始改代码之前,先把模型调用这条链路打通,因为后面验证拆环效果时,你需要一个能实际跑起来的 CLI 命令来确认运行时没有回归。TaoToken 在这里的角色是提供兼容 OpenAI 风格的接口,让你在 CLI 里用统一的 Base URL + API Key + Model ID 三件套调用模型,不用为每个供应商写一套适配。
你需要准备三样东西:
第一,API Key。到控制台的 API Keys 页面创建一个,复制出来。地址是 https://taotoken.net/api-keys ,创建后只显示一次,记得存到环境变量里,别硬编码进代码。
第二,Base URL。TaoToken 的 API 入口是 https://taotoken.net/api ,注意这个地址不带任何查询参数,直接作为 OpenAI SDK 的baseURL使用。如果你用的是 Anthropic 风格的 SDK,走的是另一套路径,但本文的 CLI 示例统一用 OpenAI 兼容风格,方便你直接套。
第三,Model ID。在模型对话页面可以看到当前可用的模型列表,选一个你常用的,比如claude-sonnet-4-5这类标识。Model ID 要和你 CLI 里配置的字段完全一致,大小写敏感。
把这三个值写进.env文件,或者直接 export 到 shell:
export TAOTOKEN_API_KEY="sk-你的key" export TAOTOKEN_BASE_URL="https://taotoken.net/api" export TAOTOKEN_MODEL="claude-sonnet-4-5"这里有个坑要提前说:很多 CLI 项目会在启动时读取配置,如果你的配置模块本身就在循环依赖环里,那么process.env可能还没被加载就被引用了,导致读到undefined。所以配置读取要放在依赖图的最底层(基础设施层),不要让它依赖任何上层模块。这也是后面拆环时的一个原则。
如果你还没决定用哪个模型,可以先到模型对话页面发一条测试消息,确认 Key 和 Base URL 能通,再回到 CLI 里配置。这样能把「模型调用失败」和「循环依赖导致运行异常」两类问题分开,避免排查时互相干扰。
对于长期做 CLI 编码和 Agent 开发的场景,可以考虑 Coding Plan,它更适合高频调用和长会话,不用每次手动管额度。但本文的重点是依赖治理,模型调用只是验证手段,你按自己的用量选就行。
3. 可复制配置:依赖图生成与循环检测
这一节给你可以直接落地的配置。目标是把 TypeScript CLI 的模块依赖关系画出来,并自动检测循环。我用的是madge+dependency-cruiser组合,前者出图快,后者规则细。
先装依赖:
npm i -D madge dependency-cruiser3.1 madge 配置与循环检测命令
madge可以直接对src目录跑循环检测,输出环的路径。最常用的命令:
npx madge --circular --extensions ts,tsx src/如果只想看依赖图(不检测环),生成 SVG:
npx madge --image deps.svg --extensions ts,tsx src/但 CLI 项目往往有路径别名(@/之类),madge默认不认tsconfig的paths,需要显式指定:
npx madge --circular --extensions ts,tsx --ts-config tsconfig.json src/实测下来,madge对import type也会算进依赖,所以它报的环里有一部分是类型层的,不影响运行。这时候要配合dependency-cruiser做更细的判定。
3.2 dependency-cruiser 配置片段
在项目根目录建.dependency-cruiser.js,下面这份配置可以直接用,重点是no-circular规则和tsConfig路径解析:
/** @type {import('dependency-cruiser').IConfiguration} */ module.exports = { forbidden: [ { name: 'no-circular', severity: 'error', comment: '禁止循环依赖,CLI 项目里环会导致运行时 undefined', from: {}, to: { circular: true, }, }, { name: 'no-orphans', severity: 'warn', comment: '孤立模块,可能是没被引用的死代码', from: { orphan: true, pathNot: ['\\.d\\.ts$', '(^|/)tsconfig\\.json$'], }, to: {}, }, ], options: { doNotFollow: { path: 'node_modules', }, tsConfig: { fileName: 'tsconfig.json', }, enhancedResolveOptions: { exportsFields: ['exports'], conditionNames: ['import', 'require', 'node', 'default'], extensions: ['.js', '.jsx', '.ts', '.tsx'], }, reporterOptions: { dot: { collapsePattern: 'node_modules/[^/]+', }, archi: { collapsePattern: '^(packages|src|lib|app|bin|test)/[^/]+/[^/]+', }, }, }, };跑检测:
npx depcruise --config .dependency-cruiser.js src/输出会直接告诉你哪条边构成了环,比如tools.ts -> TeamCreateTool.ts -> tools.ts。这就是你要拆的目标。
3.3 tsconfig 路径别名配置
如果你的 CLI 用了路径别名,tsconfig.json里要有对应的paths,否则依赖工具解析不到真实文件,环会漏报:
{ "compilerOptions": { "target": "ES2022", "module": "ESNext", "moduleResolution": "Bundler", "strict": true, "baseUrl": ".", "paths": { "@/*": ["src/*"], "@tools/*": ["src/tools/*"], "@types/*": ["src/types/*"] } }, "include": ["src/**/*.ts", "src/**/*.tsx"] }注意moduleResolution用Bundler还是NodeNext会影响解析行为。CLI 项目如果最终用bun build或esbuild打包,用Bundler更贴近实际;如果用tsc直接产出,用NodeNext。这个字段配错,依赖图会失真。
3.4 把检测接进 CI
在package.json里加脚本,提交前自动跑:
{ "scripts": { "dep:check": "depcruise --config .dependency-cruiser.js src/", "dep:graph": "madge --image deps.svg --extensions ts,tsx --ts-config tsconfig.json src/" } }这样每次 PR 都会拦下新增的环,避免依赖关系继续恶化。
4. 验证请求:从定位到拆分的完整动作
配置就绪后,走一遍完整的定位到拆分流程。假设depcruise报了一个环:src/tools.ts -> src/tools/TeamCreateTool/TeamCreateTool.ts -> src/tools.ts。
4.1 定位环的具体边
先看tools.ts里怎么引用TeamCreateTool:
// src/tools.ts import { TeamCreateTool } from './tools/TeamCreateTool/TeamCreateTool.js'; export function getTools() { return [TeamCreateTool, /* ...其他工具 */]; }再看TeamCreateTool.ts里怎么反向引用tools.ts:
// src/tools/TeamCreateTool/TeamCreateTool.ts import { getTools } from '../../tools.js'; export const TeamCreateTool = { name: 'TeamCreate', async run() { const all = getTools(); // ... }, };环就在这里:tools.ts导入TeamCreateTool,TeamCreateTool又导入tools.ts的getTools。运行时,tools.ts开始执行,遇到import TeamCreateTool,去加载TeamCreateTool.ts,后者又去加载tools.ts,此时tools.ts还没执行完,getTools是undefined,于是报getTools is not a function。
4.2 拆环方案一:延迟 require
最直接的改法是把TeamCreateTool.ts里的静态导入改成函数内延迟 require:
// src/tools/TeamCreateTool/TeamCreateTool.ts export const TeamCreateTool = { name: 'TeamCreate', async run() { // 延迟到运行时才解析,打破静态环 const { getTools } = require('../../tools.js') as typeof import('../../tools.js'); const all = getTools(); // ... }, };这样静态依赖图里TeamCreateTool.ts不再指向tools.ts,环断开。代价是失去了静态类型检查的即时性,但as typeof import(...)把类型补回来了。
4.3 拆环方案二:类型集中化
如果环是因为共享类型造成的,把类型抽到src/types/下。比如Tool.ts里原本从tools.ts导入PermissionResult,改成从types/permissions.ts导入:
// src/Tool.ts // 从集中位置导入权限类型,打破 import cycle import type { AdditionalWorkingDirectory, PermissionMode, PermissionResult, } from './types/permissions.js';types/permissions.ts只依赖其他类型文件,不依赖任何实现,所以它是依赖图的叶子,不会成环。这是 Claude Code 里明确采用的做法,注释里写得很清楚。
4.4 拆环方案三:接口隔离
如果TeamCreateTool只是需要「获取所有工具」这个能力,可以把它抽成一个接口,由上层注入,而不是直接导入tools.ts:
// src/types/toolRegistry.ts export interface ToolRegistry { getAll(): unknown[]; }TeamCreateTool依赖ToolRegistry接口,tools.ts实现它并在注册时注入。这样依赖方向变成TeamCreateTool -> types/toolRegistry,不再指向tools.ts。
4.5 验证拆环效果
改完后重新跑检测:
npx depcruise --config .dependency-cruiser.js src/应该看到no-circular规则通过,没有 error。再跑madge确认:
npx madge --circular --extensions ts,tsx --ts-config tsconfig.json src/输出No circular dependency found!就对了。
然后做运行时验证。写一个最小 CLI 命令,实际调用TeamCreateTool:
// src/entrypoints/cli.ts import { getTools } from '../tools.js'; async function main() { const tools = getTools(); const teamCreate = tools.find((t: any) => t.name === 'TeamCreate'); if (!teamCreate) { throw new Error('TeamCreate tool not registered'); } const result = await teamCreate.run(); console.log('TeamCreate result:', result); } main().catch((err) => { console.error('CLI failed:', err); process.exit(1); });跑起来:
npx tsx src/entrypoints/cli.ts如果之前是getTools is not a function,现在应该能正常输出结果。这一步很关键,因为depcruise通过不代表运行时一定没问题——有些环是动态require造成的,静态工具看不到。
4.6 用模型调用做端到端验证
如果你的 CLI 里有调用模型的功能,用 TaoToken 的三件套跑一次真实请求,确认拆环没有破坏配置加载链路:
// src/services/api/client.ts import OpenAI from 'openai'; const client = new OpenAI({ apiKey: process.env.TAOTOKEN_API_KEY, baseURL: process.env.TAOTOKEN_BASE_URL, }); export async function chat(prompt: string) { const res = await client.chat.completions.create({ model: process.env.TAOTOKEN_MODEL!, messages: [{ role: 'user', content: prompt }], }); return res.choices[0]?.message?.content; }跑:
npx tsx -e "import('./src/services/api/client.js').then(m => m.chat('ping').then(console.log))"能打印出模型回复,说明配置模块、服务模块、入口模块的依赖链都是通的,拆环没有引入新的加载顺序问题。
5. 本篇常见错排查:401、local proxy failed、reading choices、OAuth
拆环过程中和模型调用链路上,有几类报错特别常见,逐个对照。
5.1 401 Unauthorized
报错长这样:
Error: 401 Unauthorized { "error": { "message": "Invalid API key", "type": "invalid_request_error" } }原因通常是 API Key 没读到或读错了。检查顺序:第一,process.env.TAOTOKEN_API_KEY是否真的被加载,CLI 启动时有没有加载.env;第二,Key 有没有多余空格或换行;第三,Base URL 是不是写成了https://taotoken.net/api/(末尾多斜杠有时会导致路径拼接错误)。如果配置模块本身在环里,process.env可能还没初始化就被引用,这时候要先把配置读取移到依赖图最底层。
5.2 local proxy failed
报错:
Error: local proxy failed: connect ECONNREFUSED 127.0.0.1:7890这是本地网络配置残留导致的。检查你的 shell 里有没有HTTP_PROXY/HTTPS_PROXY指向一个已经不存在的本地端口。CLI 项目里如果用了一些网络库,它们会自动读这些环境变量。清掉:
unset HTTP_PROXY HTTPS_PROXY ALL_PROXY然后重跑。注意不要在任何配置里写死代理地址,这类问题在 CI 环境里尤其容易复现。
5.3 reading 'choices' of undefined
报错:
TypeError: Cannot read properties of undefined (reading 'choices')这是模型调用返回结构不符合预期。常见原因:第一,baseURL配错,请求打到了非兼容端点,返回的不是 OpenAI 格式;第二,model字段填了一个不存在的 Model ID,服务端返回错误对象而不是正常响应;第三,SDK 版本和接口不匹配。排查时先把原始响应打出来:
const res = await client.chat.completions.create({ /* ... */ }); console.log(JSON.stringify(res, null, 2));确认res.choices存在再往下走。如果res本身是undefined,说明 SDK 调用抛异常被吞了,检查有没有 try/catch 把错误吃掉了。
5.4 OAuth 相关报错
报错:
Error: OAuth token expired Error: invalid_grant如果你的 CLI 集成了 OAuth 登录,拆环时如果把 token 刷新逻辑和入口模块耦合在一起,可能出现刷新失败。检查 token 存储模块是否依赖了上层模块,导致刷新时读不到配置。OAuth 的 token 管理应该放在服务层,只依赖基础设施层的存储和 HTTP 客户端,不要反向依赖入口。
5.5 拆环后仍然报 undefined
有时候depcruise显示无环,但运行时还是undefined。这通常是动态require的时机问题。比如你把require放在了模块顶层而不是函数内:
// 错误:还是在模块加载时执行 const { getTools } = require('../../tools.js');这样环只是从静态图里消失了,运行时依然在加载阶段触发。正确做法是放进函数体,确保调用时才解析:
// 正确:调用时才解析 function run() { const { getTools } = require('../../tools.js'); }5.6 三件套配置对照表
如果你在 CLI 里同时用了多个模型供应商,用下面这张表核对字段,避免混用:
| 配置项 | 环境变量 | 示例值 | 常见错误 |
|---|---|---|---|
| Base URL | TAOTOKEN_BASE_URL | https://taotoken.net/api | 末尾多斜杠、写成网页地址 |
| API Key | TAOTOKEN_API_KEY | sk-xxxx | 含空格、未加载 .env |
| Model ID | TAOTOKEN_MODEL | claude-sonnet-4-5 | 大小写错误、用了不存在的模型 |
这三项在 CLI 里出现时,必须成对出现:Base URL + Key + Model ID。少任何一个都会导致 401 或reading 'choices'。如果你用的是 Claude Code 风格的配置,检查settings.json里的字段名是否和代码里读取的一致。
6. 把依赖治理变成日常动作
拆完一个环不代表结束。CLI 项目会持续加功能,新的环随时可能长出来。我的做法是把depcruise接进 pre-commit 和 CI,任何新增的no-circular违规直接拦下。同时在types/目录里维护一份共享类型清单,新类型优先往这里放,而不是从实现模块里导出。
另外,延迟require虽然好用,但不要滥用。它牺牲了静态分析能力,用多了会让依赖图变得不可信。优先用类型集中化和接口隔离,实在拆不开再用延迟require,并且在代码里写清楚注释说明为什么这里必须延迟。
最后,每次重构模块组织后,跑一遍端到端的模型调用验证。用 TaoToken 的三件套发一条真实请求,确认配置加载、服务调用、入口执行这条链路没有因为拆环而断裂。这一步花不了几分钟,但能挡住大部分「静态检查通过、运行时崩溃」的回归。
如果你在拆环过程中遇到depcruise报的环和实际运行时报错对不上,多半是动态require或import type造成的差异。把madge和depcruise两个工具的输出对照着看,再结合运行时的堆栈,基本能定位到具体那条边。依赖治理没有一劳永逸,但有了这套检测和拆分流程,至少不会让环悄悄堆积到无法收拾。