Hotdry.
systems

用 TypeScript 实现交互式脚本化工作流:make.ts 模式

放弃 Up Up Up 终端历史复用模式,用 gitignored 的 TypeScript 脚本捕获交互式命令,结合 Deno 与 tagged template 实现类型安全的进程调度与渐进式脚本演化。

大多数开发者对这样一个场景再熟悉不过:需要反复执行一串命令来调试或基准测试,于是本能地开始在终端里重复按向上箭头,配合回车键来完成。这种被戏称为「Up Up Up」的工作流在简单场景下确实够用,但一旦涉及多进程编排、跨机器同步、参数化基准测试等需求,纯靠终端历史的局限性就会暴露无遗。Make.ts 模式提供了一种截然不同的思路:用 TypeScript 脚本作为交互式命令的持久化载体,配合 Deno 运行时实现类型安全的进程调度与渐进式脚本演化。

放弃终端历史的工程化理由

终端历史的复用方式存在几个根本性的工程缺陷,这些缺陷在简单任务中或许可以被容忍,但在复杂工作流中会累积成显著的生产力损耗。首先,终端的行编辑器本质上是一维的文本操作界面,当单个命令的参数列表变长时,在命令行中修改变得笨拙且容易出错。相比之下,专业的代码编辑器提供了二维的编辑体验、语法高亮、补全提示和重构能力,这些在编写复杂命令时都是质的飞跃。其次,当单个终端命令不足以完成需求时,开发者往往会转向 && 拼接或管道符组合,但这种链式表达的可读性和可维护性随命令数量增加急剧下降,调试时更是灾难。再次,终端历史是会话绑定的,换一个终端窗口或重启电脑,积累的命令历史就丢失了,而版本控制系统对纯终端历史没有任何感知。最关键的一点是,多进程项目的调试几乎必然需要同时观察多个终端窗口,这种「终端分屏」的工作方式与代码编辑器的上下文切换存在根本冲突。

Make.ts 模式的核心洞见在于:不要把交互式命令视为「一次性的临时操作」,而是将其视为「值得被记录但不必一开始就设计好」的脚本草稿。将命令写入文件这个动作本身就完成了从「临时」到「持久」的第一步转变,而后续的演进完全是渐进的,不存在某个需要投入大量时间进行「脚本化重构」的关键节点。

TypeScript 运行时选型的技术权衡

在 Make.ts 模式中,TypeScript 作为脚本语言的选择并非随意之举,而是基于一系列实际考量的结果。传统上,Shell 脚本(bash、zsh)似乎是这类场景的自然选择,但 Shell 作为编程语言存在明显的短板:字符串处理的 ergonomics 极差,条件分支的语法冗长,类型系统几乎不存在,调试全靠打印。Python 和 Ruby 提供了更好的语言特性,但需要独立的运行时环境设置,对于快速脚本而言启动成本偏高。Deno 运行时在这个场景中展现了独特的适配性:原生支持 TypeScript 执行无需编译步骤,单一可执行文件包含了运行时、格式化、 LSP 等完整工具链,权限模型虽然严格但对于本地脚本场景是可接受的。

JavaScript 的 tagged template 语法为进程生成提供了一个优雅的类型安全接口。考虑以下代码模式:使用 $ 函数配合模板字面量,函数接收到的不是拼接后的字符串,而是字面量片段数组和插值变量数组。这意味着可以直接将数组形式的参数传递给 exec 系统调用,完全绕过了 shell 的字符串解析层。传统做法 $(command) 需要 shell 解释变量替换、通配符展开等语义,而 tagged template 方案将插值部分与命令主体分离,使得命令的组成更加透明和可控。

Deno 的 dax 库进一步封装了这个模式,提供了 await $ 语法糖来执行命令,同时支持 .noThrow() 抑制异常、.spawn() 异步启动等实用方法。Bun 运行时也提供了类似的 bun:ffi 或内置的命令执行能力,但 Deno 在格式化(deno fmt)和语言服务器(deno lsp)的集成度上目前仍略胜一筹。

渐进式脚本演化的工程参数

采用 Make.ts 模式时,有几个工程参数值得关注。首先是脚本文件的定位策略:建议在项目根目录放置一个固定命名为 make.ts 的文件,并将其加入 .git/info/exclude(即私有 gitignore)以避免意外提交。这种固定命名策略降低了「是否需要新建文件」「文件叫什么名字」等认知负担,使得每次打开编辑器时都有一个明确的入口点。脚本文件的权限应通过 chmod a+x make.ts 设为可执行,首行添加 shebang:#!/usr/bin/env -S deno run --allow-all,这样可以直接 ./make.ts 运行而无需显式调用解释器。

渐进式演化的典型路径可以划分为四个阶段。第一阶段是命令捕获:将原本需要多次按向上箭头执行的命令直接写入 make.ts,此时脚本可能就是一系列顺序执行的 $ 调用。第二阶段是错误处理:在首次运行时发现问题后,添加 .noThrow() 来处理预期内的失败(如清理残留进程时进程可能已不存在)。第三阶段是并行化:将独立的命令改为 Promise.all([...]) 组合,实现真正的并发执行,取代终端分屏。第四阶段是参数化:引入 Deno.args 读取命令行参数,将硬编码的值替换为变量,使脚本可以针对不同配置重复运行。

对于多进程项目的编排,Make.ts 模式的优势更加明显。假设需要在多台机器上启动分布式服务,传统的做法是打开多个终端窗口,分别 SSH 连接并执行启动命令。使用 Make.ts 后,可以通过循环遍历机器列表,将命令写成数组形式,配合 for 循环和 await Promise.all 实现声明式的进程树管理。这种方式不仅更易于阅读和修改,还能自然地将进程输出重定向到文件以便后续分析。

落地迁移的清单与注意事项

对于考虑采用 Make.ts 模式的团队和个人,以下清单提供了可操作的迁移路径。第一步是环境准备:安装 Deno(curl -fsSL https://deno.land/x/install/install.sh | sh),配置编辑器插件以获得类型提示支持,在项目根目录创建 make.ts 并设置好 shebang 和权限。第二步是初始迁移:选择下一个需要反复执行的复杂命令序列,将其写入 make.ts,用 ./make.ts 验证等价性,此后即可以脚本替代终端历史。第三步是渐进增强:在后续工作中,有意识地使用 make.ts 替代临时命令,逐步积累脚本内容并添加注释和类型标注。第四步是团队同步:如果团队成员也采用类似模式,可以约定 make.ts 的使用规范,如禁止提交到版本控制、鼓励共享常用命令片段等。

需要注意的是,Deno 的权限模型要求对文件系统访问和网络操作显式授权。对于本地开发脚本,使用 --allow-all 是合理的简化,但如果脚本涉及敏感操作,应当细化权限标记。另一个潜在的摩擦点是类型系统的学习曲线:TypeScript 的类型系统提供了强大的安全保障,但同时也增加了入门门槛。对于不熟悉 TypeScript 的开发者,可以先以渐进方式使用,从无类型标注的纯 JavaScript 语法开始,逐步引入类型注解。

Make.ts 模式并非要取代传统的构建系统(如 Make、Ninja、Meson),而是填补了「一次性复杂命令」与「正式构建脚本」之间的空白地带。当某个命令序列的复杂度超过一定阈值时,它自然演化为项目基础设施的一部分;而在达到这个阈值之前,它只是一份被记录下来的交互式草稿。这种无感知的渐进式演化,正是该模式的核心价值所在。


参考资料

  • matklad.github.io/2026/01/27/make-ts.html
  • Hacker News: Make.ts (2026-01-28, 232 points)
查看归档