zx API Reference 深度解析:核心 API、配置预设与底层实现原理
本文基于 zx 仓库的官方 API 参考文档 docs/api.md,完整覆盖 $ 模板命令、可链式配置预设、cd()/within() 等上下文 API、fetch()/retry() 等实用工具函数,以及 quote 系列与 shell 切换函数。同时结合 src/core.ts、src/goods.ts、src/util.ts 的源码实现,解释每个 API 的调用链、默认值来源与适用限制,帮助你在编写跨平台脚本时既能“照抄即用”,又能理解每个行为背后的机制。
一、$ 模板字符串与 $.sync:同步/异步两种执行模式
zx 的核心入口是 $ 对象。它既是一个模板标签函数,又承载全部默认配置,因此文档将其称为“the zx”。执行一条命令的两种基本方式是:
const list = await $`ls -la` // 异步,返回 ProcessPromise
const dir = $.sync`pwd` // 同步,返回 ProcessOutput
从源码结构看,这一行为的实现在 src/core.ts 中:$ 是一个由 sync$ 包装的 Proxy(src/core.ts 中 function sync$(fn, makeSync)),当访问 $ 上不存在的属性时,优先从 getStore()(当前 AsyncLocalStorage 上下文或 defaults)中取值;而 sync 是一个特例 getter,访问它时直接返回 () => $({ sync: true }) 生成的同步版 $。也就是说,$.synccmd 等价于 `$({sync: true})`cmd。
两者返回不同类型的对象:
- 异步模式返回
ProcessPromise——一个Promise<ProcessOutput>,在其上可直接链式调用pipe、nothrow()、timeout()等; - 同步模式返回
ProcessOutput——一个继承自Error的结果对象,带有stdout、stderr、stdall、exitCode、ok、duration等字段(见src/core.ts中class ProcessOutput)。
一个容易踩坑的细节:同步模式“不能等待异步解析的命令”。ProcessPromise.build() 中明确抛出 sync mode does not allow async command resolution(src/core.ts 的 build() 方法),因此 $.sync 适合简单命令,复杂脚本建议用异步。
二、$({...}):配置工厂与可链式预设
$ 对象持有所有执行的默认配置(详见 Configuration 文档)。要把一套自定义配置复用到多条命令上,可以把 $ 当工厂调用:
const $$ = $({
verbose: false,
env: {NODE_ENV: 'production'},
})
const env = await $$`node -e 'console.log(process.env.NODE_ENV)'`
const pwd = $$.sync`pwd`
const hello = $({quiet: true})`echo "Hello!"`
实现上,$ 收到非模板参数时,返回一个新的代理函数,其内部执行 within(() => Object.assign($, opts, pieces))(src/core.ts 中 export const $ 的定义)。Object.assign 将新选项叠加在“当前上下文”之上,within 则保证叠加的选项只在回调内的 $ 调用中生效——这就是预设能“链式”的根本原因:
const $1 = $({ nothrow: true })
assert.equal((await $1`exit 1`).exitCode, 1)
const $2 = $1({ sync: true }) // 同时应用 {nothrow: true, sync: true}
assert.equal($2`exit 2`.exitCode, 2)
const $3 = $({ sync: true })({ nothrow: true })
assert.equal($3`exit 3`.exitCode, 3)
每次 $({...}) 都是“在当前上下文快照上再叠一层”,多层预设自然合并。
$({input}):向命令注入 stdin
input 选项把给定数据写入命令的标准输入,支持字符串、Buffer、Readable 流,甚至其他 ProcessOutput/ProcessPromise(把另一个进程的输出作为输入):
const p1 = $({ input: 'foo' })`cat`
const p2 = $({ input: Readable.from('bar') })`cat`
const p3 = $({ input: Buffer.from('baz') })`cat`
const p4 = $({ input: p3 })`cat`
const p5 = $({ input: await p3 })`cat`
在 src/core.ts 的 run() 方法中可以看到,input 若为 ProcessPromise | ProcessOutput 会被解包为 .stdout 后再交给底层 zurk 执行器:input: ($.input as ProcessPromise | ProcessOutput)?.stdout ?? $.input,这解释了为什么“进程传进程”是类型安全且可直接运行的。
$({signal}):可中断的进程
signal 让进程可以随 AbortController 一起取消:
const {signal} = new AbortController()
const p = $({ signal })`sleep 9999`
setTimeout(() => signal.abort('reason'), 1000)
源码中每个命令都会自动拥有一个 AbortController(getSnapshot 里 ac: opts.ac || new AbortController()),ProcessPromise 还暴露 signal getter 与 abort(reason?) 方法。需要注意源码里的约束:若传入的 signal 与内部 ac 不是同一个,abort() 会抛出 The signal is controlled by another process.,即“谁提供 signal,谁负责取消”。
$({timeout}):超时自动杀进程
timeout 选项按 Duration 类型指定自动终止时限:
const p = $({timeout: '1s'})`sleep 999`
Duration 在 src/util.ts 中定义为 number | \{number}m\` | \`{number}s` | `{number}ms\``,并由 `parseDuration()` 统一换算为毫秒(数字直接视为毫秒,`'1s'`→1000,`'2m'`→120000)。在 `ProcessPromise` 中,`timeout()` 方法会注册一个 `setTimeout`,到期后以 `timeoutSignal`(默认 `SIGTERM`,可配 `.timeoutSignal`)杀掉进程,命令结束(无论成败)时定时器被清理。
$({nothrow}):吞掉失败,返回结果对象
默认情况下,非 0 退出码会让 Promise reject。nothrow 选项则抑制抛错,直接返回带完整细节的 ProcessOutput:
const o1 = await $({nothrow: true})`exit 1`
o1.ok // false
o1.exitCode // 1
o1.message // exit code: 1 ...
const o2 = await $({nothrow: true, spawn() { throw new Error('BrokenSpawn') }})`echo foo`
o2.ok // false
o2.exitCode // null
o2.message // BrokenSpawn ...
第二个例子很能说明实现细节:连自定义 spawn 抛出的异常(这里 BrokenSpawn)也会被包进 ProcessOutput(exitCode 为 null,因为进程根本没生出来)。这在 src/core.ts 中对应 finalize() 的逻辑:output.ok || this.isNothrow() 为真时走 resolve 分支,而不是 reject。
完整 Options 接口与默认值
文档给出的完整选项列表如下:
interface Options {
cwd: string
ac: AbortController
signal: AbortSignal
input: string | Buffer | Readable | ProcessOutput | ProcessPromise
timeout: Duration
timeoutSignal: NodeJS.Signals
stdio: StdioOptions
verbose: boolean
sync: boolean
env: NodeJS.ProcessEnv
shell: string | true
nothrow: boolean
prefix: string
postfix: string
quote: typeof quote
quiet: boolean
detached: boolean
preferLocal: boolean | string | string[]
spawn: typeof spawn
spawnSync: typeof spawnSync
store: TSpawnStore
log: typeof log
kill: typeof kill
killSignal: NodeJS.Signals
halt: boolean
delimiter: string | RegExp
}
各选项的默认值可以在 src/core.ts 的 defaults 常量中一一对上:
| 选项 | 默认值 | 说明 |
|---|---|---|
cwd |
process.cwd() |
所有子进程的工作目录 |
env |
process.env |
子进程环境变量 |
shell |
true |
true 表示自动探测(默认 which bash) |
stdio |
'pipe' |
等效于 ['pipe','pipe','pipe'] |
verbose / quiet |
false / false |
控制命令与输出的打印 |
nothrow |
false |
非 0 退出码是否抛错 |
sync |
false |
同步执行 |
detached |
false |
是否独立进程组 |
preferLocal |
false |
是否优先 node_modules/.bin |
spawn / spawnSync |
child_process.spawn / spawnSync |
可整体替换执行器 |
killSignal / timeoutSignal |
SIGTERM |
kill/超时时使用的信号 |
log / kill |
内置实现 | 日志与进程树终止函数,可覆写 |
此外还有一个值得知道的事实:源码中的 resolveDefaults() 会扫描环境变量,凡是以 ZX_ 前缀且命中允许清单(cwd、preferLocal、detached、verbose、quiet、timeout、timeoutSignal、killSignal、prefix、postfix、shell)的变量,都会用来覆盖对应默认值。例如设置 ZX_VERBOSE=1 环境变量,即可在不改代码的情况下打开 verbose 模式——这一机制在 Configuration 文档 的 $.defaults 一节也可对照。
三、cd() 与 syncProcessCwd():目录上下文
cd() 改变当前工作目录:
cd('/tmp')
await $`pwd` // => /tmp
与 shell 的 echo 类似,cd 除了接受 string,还接受 ProcessOutput 并自动 trim 掉尾随换行,从而支持常见惯用法:
cd(await $`mktemp -d`)
源码中 cd() 的实现是 process.chdir(dir) 之后把内部符号 CWD 同步为新的 process.cwd()(src/core.ts 的 export function cd),所以它对全局上下文生效。
⚠️
cd内部调用process.chdir(),会影响整个 Node 进程。若希望process.cwd()始终跟随$内部的目录变化,需要启用syncProcessCwd()钩子:
import {syncProcessCwd} from 'zx'
syncProcessCwd()
syncProcessCwd(false) // 传 false 关闭钩子
从实现看,syncProcessCwd() 基于 node:async_hooks 的 createHook,在 init/before/promiseResolve/after/destroy 各阶段检查 $[CWD] != process.cwd() 并 process.chdir() 跟上。文档也明确说明:该功能默认关闭,因为它有性能开销。
四、fetch():面向管线的网络请求封装
fetch 是 node-fetch-native 的包装(仓库通过 src/vendor.ts 导出为 nodeFetch),基本用法与原生 fetch 一致:
const r1 = await fetch('https://example.com')
const json = await r1.json()
const r2 = await fetch('https://example.com', {
signal: AbortSignal.timeout(5000),
})
zx 为它追加了一个“管线友好”的 pipe 方法,用于规避 text()/json() 在超大响应下可能超过字符串大小限制的问题——流式传输正是为此而生:
const p1 = fetch('https://example.com').pipe($`cat`)
const p2 = fetch('https://example.com').pipe`cat`
src/goods.ts 中 fetch 的实现可以看到:pipe 把 Response.body 通过 getReader() 转成一个 Readable,再 pipe 到目标(模板字符串形式会先构造一个 halt 模式的 $ 子进程),并同步传递了 AbortSignal。也就是说,fetch().pipe 让 HTTP 流与 $ 子进程流走的是同一套管道机制。
五、交互式与轻量工具:question()、sleep()、echo()、stdin()
question()
readline API 的包装,支持 choices 补全:
const bear = await question('What kind of bear is best? ')
const selected = await question('Select an option:', {
choices: ['A', 'B', 'C'],
})
源码中(src/goods.ts 的 question),当传入 choices 时会为 readline 接口挂一个 completer,按键前缀过滤候选项,这就是 choices 带来“补全”体验的原因。
sleep()
setTimeout 的 Promise 化,时长支持 Duration 类型(数字毫秒或 '1s'/'100ms'/'2m' 字符串):
await sleep(1000)
echo()
console.log() 的替代,特别之处是它理解 ProcessOutput:输出时会自动调用 toString() 并去掉尾部换行(src/goods.ts 中 stringify() 对 ProcessOutput 做 trimEnd()),因此拼接日志时不会出现多余空行:
const branch = await $`git branch --show-current`
echo`Current branch is ${branch}.`
// 或
echo('Current branch is', branch)
stdin()
把标准输入整体读成字符串返回,常用来接收上游管道传入的 JSON:
const content = JSON.parse(await stdin())
实现上就是对 process.stdin 按 UTF-8 逐 chunk 累加(src/goods.ts 的 stdin),也允许传入自定义 Readable。
六、within():异步作用域上下文
within(callback) 创建一个“配置作用域”:回调内对 $ 属性(如 $.cwd、$.prefix 或任意自定义字段)的修改只影响该异步上下文,退出后自动恢复:
await $`pwd` // => /home/path
$.foo = 'bar'
within(async () => {
$.cwd = '/tmp'
$.foo = 'baz'
setTimeout(async () => {
await $`pwd` // => /tmp
$.foo // baz
}, 1000)
})
await $`pwd` // => /home/path
$.foo // still 'bar'
典型场景是在一个上下文中临时切换 Node 版本再执行命令:
await $`node --version` // => v20.2.0
const version = await within(async () => {
$.prefix += 'export NVM_DIR=$HOME/.nvm; source $NVM_DIR/nvm.sh; nvm use 16;'
return $`node --version`
})
echo(version) // => v16.20.0
实现非常简洁:within 就是 storage.run({ ...getStore() }, callback)(src/core.ts),底层是 node:async_hooks 的 AsyncLocalStorage。这解释了两点:一是作用域“跟随异步执行链”而非按调用栈严格恢复;二是 spinner() 内部也用 within 在旋转期间临时置 $.verbose = false,从而不干扰正常日志。
七、健壮性工具:retry()、spinner()
retry()
按次数重试一个回调,返回第一次成功结果;全部失败则抛出最后一次的错误。支持固定间隔与指数退避:
const p = await retry(10, () => $`curl https://medv.io`)
// 指定尝试间隔
const p = await retry(20, '1s', () => $`curl https://medv.io`)
// 指数退避
const p = await retry(30, expBackoff(), () => $`curl https://medv.io`)
src/goods.ts 中 retry 的签名支持第二个参数为 Duration(经 parseDuration 换算)或“毫秒间隔生成器”。每次失败都会打一条 kind: 'retry' 的日志(含 attempt/total/delay),verbose 模式下可见 FAIL Attempt: x/y, next: ... 这样的进度信息。配套的 expBackoff(max, delay) 是一个生成器:从 delay(默认 100ms)起步按 delay * 2^n 递增,封顶 max(默认 60s),即 100ms → 200ms → 400ms …… 不超过 60 秒。
spinner()
给长时间命令加一个简单 CLI 旋转器:
await spinner(() => $`long-running command`)
// 带消息
await spinner('working...', () => $`sleep 99`)
实现细节(src/goods.ts 的 spinner):每 100ms 往 $.log.output(默认 process.stderr)写入 Braille 字符 ⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ 与标题;结束时用空格整行清除。注意源码中明确的限制:if ($.quiet || process.env.CI) return callback()——即 CI 环境下(或 $.quiet 时)spinner 会被禁用,直接执行回调,这正是文档所说“And it's disabled for CI by default”的出处。
八、目录与临时文件:tmpdir()、tmpfile()
t1 = tmpdir() // /os/based/tmp/zx-1ra1iofojgg/
t2 = tmpdir('foo') // /os/based/tmp/zx-1ra1iofojgg/foo/
f1 = tmpfile() // /os/based/tmp/zx-1ra1iofojgg
f2 = tmpfile('f2.txt') // /os/based/tmp/zx-1ra1iofojgg/foo.txt
f3 = tmpfile('f3.txt', 'string or buffer')
f4 = tmpfile('f4.sh', 'echo "foo"', 0o744) // 可执行
对应源码为 src/goods.ts 中的 tempdir/tempfile(并以 tmpdir/tmpfile 别名导出):目录创建在 os.tmpdir() 下,默认带 zx- 加随机 id 的前缀,mode 参数透传给 fs.mkdirSync/fs.writeFileSync。典型用途是生成一次性可执行脚本后交给 $ 运行:
const f = tmpfile('run.sh', 'echo "foo"', 0o744)
await $`./${f}`
九、进程管理:ps 与 kill()
ps 是对 @webpod/ps 的再导出,提供跨平台的进程列表能力:
const all = await ps.lookup()
const nodejs = await ps.lookup({ command: 'node' })
const children = await ps.tree({ pid: 123 })
const fulltree = await ps.tree({ pid: 123, recursive: true })
kill() 是进程终结函数,$.kill 的默认实现就是它(可参考 Configuration 文档 的 $.kill 一节将其替换为 tree-kill 等更复杂的逻辑):
await kill(123)
await kill(123, 'SIGKILL')
从 src/core.ts 的实现可以读出它的“半优雅”策略:先校验 pid 为纯数字,否则抛 Invalid pid;在 win32 平台优先尝试 taskkill /pid <pid> /t /f 整树强杀;其他平台则先用 ps.tree({pid, recursive: true}) 逐个 process.kill 子孙进程,再依次尝试 process.kill(-pid, signal)(进程组)与 process.kill(+pid, signal)(单进程),信号默认取 $.killSignal(默认 SIGTERM)。
十、第三方包再导出:一行 import 即可用
zx 把一批常用依赖统一从主入口 src/index.ts 再导出,脚本无需单独安装它们:
glob()
globby 包,异步与同步两种用法:
const packages = await glob(['package.json', 'packages/*/package.json'])
const markdowns = glob.sync('*.md') // 同步 API 快捷方式
which()
node-which 包:
const node = await which('node')
配合 nothrow 选项,未找到时返回 null 而不是抛错:
const pathOrNull = await which('node', { nothrow: true })
minimist 与 argv
minimist 包直接可用:
const argv = minimist(process.argv.slice(2), {})
更推荐的是内置的 argv 常量——它已经是用 minimist 解析过的 process.argv(src/goods.ts 中 export const argv = parseArgv()),脚本里直接:
if (argv.someFlag) {
echo('yes')
}
需要自定义解析规则(布尔标志、别名等)时,用 minimist 的 options 重新解析即可:
const myCustomArgv = minimist(process.argv.slice(2), {
boolean: ['force', 'help'],
alias: { h: 'help' },
})
补充一点:源码中 parseArgv 还额外支持 camelCase 与 parseBoolean 两个选项(ArgvOpts 类型),可通过 updateArgv(args, opts) 重新解析并回填到 argv。
chalk、fs、os、path
分别是对应 chalk、fs-extra、Node.js 内置 os 与 path 包的再导出:
console.log(chalk.blue('Hello world!'))
const {version} = await fs.readJson('./package.json')
await $`cd ${os.homedir()} && mkdir example`
await $`mkdir ${path.join(basedir, 'output')}`
YAML 与 MAML
console.log(YAML.parse('foo: bar').foo)
const maml = `{
project: "MAML"
tags: [ "minimal", "readable" ]
# 支持注释
spec: {
version: 1
author: "Anton Medvedev"
}
notes: """
This is a raw multiline string.
Keeps formatting as‑is.
"""
}`
console.log(MAML.parse(maml).project) // MAML
dotenv
envapi 包,提供 dotenv 格式环境变量的解析/加载/注入 API:
// parse
const raw = 'FOO=BAR\nBAZ=QUX'
const data = dotenv.parse(raw) // {FOO: 'BAR', BAZ: 'QUX'}
await fs.writeFile('.env', raw)
// load
const env = dotenv.load('.env')
await $({ env })`echo $FOO`.stdout // BAR
// config
dotenv.config('.env')
process.env.FOO // BAR
注意 load 与 config 的区别:前者只返回解析结果(可传给 $({env}),不影响当前进程),后者会把变量写入 process.env。
versions
导出 zx 各依赖的版本号(由 src/versions.ts 生成),便于排障时核对环境:
import { versions } from 'zx'
versions.zx // 8.7.2
versions.chalk // 5.4.1
十一、引号与 Shell 切换:quote()、quotePowerShell()、useBash()、usePowerShell()、usePwsh()
模板字符串中内插变量的安全转义由 $.quote 函数负责,zx 内置两种实现,均可在 src/util.ts 中查到源码:
quote("$FOO") // "$'$FOO'"
bash 版 quote() 的规则是:空串返回 $'';纯安全字符(\w/.-+@:=,% 等)原样输出;否则包成 ANSI-C 风格的 $'...',并对反斜杠、单引号、换行、制表符等逐一转义。
quotePowerShell("$FOO") // "'$FOO'"
PowerShell 版 quotePowerShell() 更简单:空串返回 '',安全字符原样输出,其余单引号包裹并把内部 ' 双写为 ''。
三个 shell 切换函数(src/core.ts 的 useBash/usePwsh/usePowerShell,共享内部 setShell())一次性设置 $.shell、$.prefix、$.postfix、$.quote:
useBash() // $.shell = which bash;$.quote = quote;$.prefix = 'set -euo pipefail;'
usePowerShell() // powershell.exe + quotePowerShell;$.postfix = '; exit $LastExitCode'
usePwsh() // pwsh (PowerShell v7+) 同上
这里有个源码层面的细节值得注意:useBash() 会先执行一次 which.sync('bash'),若系统找不到 bash,$.shell 会保持原值。此外 src/core.ts 底部有一段启动逻辑:模块加载时先 useBash(),再把加载前已设置的字符串形式的 $.shell/$.prefix/$.postfix 恢复回去——因此“先赋值 $.shell 再 import 顺序”不会影响用户自定义 shell。
$.prefix 默认值 set -euo pipefail;(bash 下让任何命令失败即中断)与 $.postfix 的 PowerShell 兼容写法 ; exit $LastExitCode,都可在 Configuration 文档 的 $.prefix/$.postfix 两节找到完整说明。
十二、小结:从 API 文档到源码的阅读路径
- 执行核心(
$、$.sync、Options、ProcessPromise、ProcessOutput、cd、kill、within、syncProcessCwd):src/core.ts - 实用工具(
tmpdir/tmpfile、argv、fetch、echo、question、stdin、retry、expBackoff、spinner、versions):src/goods.ts - 引号与时长解析(
quote、quotePowerShell、Duration/parseDuration):src/util.ts - 第三方再导出(
minimist、dotenv、fs、YAML、MAML、glob):src/index.ts 与 src/vendor.ts - 配置项默认值与 CLI 参数对应关系:docs/configuration.md
- 结果对象与 Promise 对象细节:docs/process-output.md、docs/process-promise.md
- 行为验证:test/core.test.js、test/goods.test.ts、test/export.test.js
需要说明的前提:本文以当前仓库(zx 8.9.0,package.json 中 engines.node >= 12.17.0,且支持 Bun、Deno 等运行时,见 README.md)为准;within()/syncProcessCwd() 依赖 node:async_hooks,在 Node.js 环境中最可靠;kill() 的 Windows 分支依赖 taskkill,Linux/macOS 分支依赖进程组与 @webpod/ps 的进程树探测。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00