2.8 Node.js 文件、路径与进程 API
本章解决什么问题:前面七章把 TypeScript 的「骨架」搭好了——类型、异步、事件、取消。但 Agent 之所以有用,是因为它能动手:读一个文件、改一行代码、跑一条命令。这些能力不来自语言本身,而来自 Node.js 的标准库。本章讲清三组最基础的 API——
node:fs/promises(读写文件)、node:path(计算路径)、process(进程与外界的接口)——它们正是 Pi 的 read、write、bash 三个内置工具的原材料。前置知识:1.4 模块系统:import 与 export(import 语法)、2.4 泛型、class 与类型收窄(
unknown与类型守卫)、2.5 异步编程:Promise 与 async/await(本章几乎每个调用都要await)、2.7 事件、回调与取消(AbortController)(signal惯例)。学习目标:读完本章后你能
- 说出
node:前缀的作用,并按惯例书写内置模块的 import;- 用
node:fs/promises的readFile/writeFile/mkdir/readdir/stat完成常见文件任务,并说清「读成字符串」和「读成字节」的区别;- 说出同步 API(
readFileSync)和异步 API 各自该用在什么时候,以及在 Agent 里用错会付出什么代价;- 用
node:path的join/resolve/dirname处理路径,并举出三个「手拼字符串」会翻车的例子;- 用
process.argv解析命令行参数、用process.env读环境变量、用process.cwd()理解相对路径的起点、用退出码报告成败;- 指出 Pi 的 read 工具从模型的一次请求走到
node:fs/promises的完整路径,并说明它为什么不直接调用fs。
建立直觉:程序与外部世界之间只有几扇门
到这一章为止,我们写的代码都关在自己的小世界里:定义类型、算数、等待 Promise。可一个 Agent 要做的事——「把 src/main.ts 读出来给我看」「把这段改动写回去」「运行一下测试」——全都要越过程序的边界,去碰外部世界。
外部世界看起来无边无际,但 Node.js 开的门其实只有几扇,本章讲最重要的三扇:
- 文件系统(File System,简称 fs):磁盘上的文件和目录。读、写、列目录、看元信息。
- 路径(path):文件的「地址」。这扇门有点特别——
node:path里的函数一次磁盘都不碰,它们只是一组处理字符串的工具函数。 - 进程(process):程序自己。启动时被传了什么参数、环境里有哪些变量、当前在哪个目录、退出时告诉外界成功还是失败。
npm install 就能用的一批模块。1.3 讲过 npm 是「外面的包」,标准库则是「本来就在屋里的工具」。Node.js 的标准库覆盖文件、网络、加密、子进程等等,本章只用其中三个。浏览器里的 JavaScript 没有这些模块——它不许网页随便读你的硬盘。这正是「同一门语言、两套运行环境」的具体表现(见 1.1)。 node: 前缀是什么,为什么要写
Node.js 内置模块的现代写法是加 node: 前缀:
import { readFile } from "node:fs/promises";
import { join } from "node:path";不写前缀(from "fs/promises")在今天也能跑。那为什么还要写?因为不写就有歧义:看到 import x from "fs",Node.js 得先判断你要的是内置模块,还是 node_modules 里某个恰好叫 fs 的包(npm 上这类同名包真实存在)。加上 node: 就只剩一种解释——这是内置模块,不必去 node_modules 里翻找,也不可能被同名包顶替。副作用是读代码的人一眼就能分清哪些 import 来自标准库、哪些来自第三方。
Pi 全仓库统一使用 node: 前缀(本章末尾会看到源码),本书的示例和实验也一律照此书写。
node:fs/promises:读写文件的五个动词
历史包袱使 Node.js 同时存在三套风格的 fs API,先分清再动手:
| 模块 | 风格 | 例子 | 什么时候用 |
|---|---|---|---|
node:fs | 回调(Callback) | readFile(p, (err, data) => …) | 老代码;本书不用 |
node:fs | 同步(Sync) | const s = readFileSync(p, "utf8") | 极少数场合,见下文 |
node:fs/promises | Promise | const s = await readFile(p, "utf8") | 默认选这个 |
node:fs/promises 里每个函数都返回 Promise,配 await 使用,写法和 2.5 学的完全一样。日常用得最多的是五个动词:
import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
import { join } from "node:path";
const outDir = join("out", "notes");
await mkdir(outDir, { recursive: true }); // 建目录
const filePath = join(outDir, "today.md");
await writeFile(filePath, "# 今日笔记\n- 读完 2.8\n", "utf8"); // 写文件(覆盖)
console.log("readdir:", await readdir(outDir)); // 列目录
const info = await stat(filePath); // 看元信息
console.log("stat:是文件吗", info.isFile(), "| 大小", info.size, "字节");
console.log("readFile:", JSON.stringify(await readFile(filePath, "utf8")));本章实验里这段的真实输出:
readdir: [ 'today.md' ]
stat:是文件吗 true | 大小 28 字节
readFile: "# 今日笔记\n- 读完 2.8\n"几个必须记住的细节:
mkdir的{ recursive: true }:一次把中间缺的层级全部建出来,而且目录已存在时不报错。不加这个选项,父目录不存在会抛错、目录已存在也会抛错——通常都不是你想要的。writeFile是覆盖:文件不存在则创建,存在则内容被整个替换(想追加用appendFile)。- 编码参数决定你拿到什么:
readFile(path, "utf8")返回字符串;不写编码则返回一段字节(Node.js 里是Buffer,可以当成Uint8Array用)。为什么要有后者?因为不是所有文件都是文本——图片、压缩包按 UTF-8 解码只会得到乱码。Pi 的 read 工具就是先读字节,再判断是不是图片,最后才决定要不要解码成文本。 stat不读内容:它只回答「是文件还是目录、多大、什么时候改的」,所以对 1GB 的文件也是瞬间返回。info.isFile()/info.isDirectory()是两个方法而不是属性,别忘了括号。- 它们大多接受
signal:这正是 2.7 讲的取消惯例,例如readFile(path, { signal })。
readFile 会把整个文件装进内存。读一个 2GB 的日志文件,要么进程内存暴涨,要么直接失败。正确做法有两条:用流式读取(node:fs 的 createReadStream)逐块处理;或者先 stat 看大小,超过阈值就截断或拒绝。Pi 走的是第二条路——read 工具读完后按行数与字节数截断,并在返回给模型的文本里附上「还剩多少行,用 offset 继续」。 同步 API:能用,但几乎总是不该用
readFileSync 直接返回结果,不需要 await,写起来确实更短。代价 2.5 讲过:JavaScript 只有一个线程。同步读文件期间,这唯一的线程原地停住——不处理事件、不推进任何其他 Promise、不响应用户按键。对一个正在流式接收模型输出、同时还要监听 ESC 的 Agent 来说,这就是画面卡死。
判断标准很简单:程序还没开始「同时做多件事」的那一小段时间,可以用同步。典型场合是进程启动时读一个配置文件——那时反正也没有别的事在跑,用同步能让启动代码少一层 async。除此之外,一律用 node:fs/promises。
node:path:为什么不能手拼路径
node:path 是一组纯字符串函数:给它字符串,还你字符串,不检查文件是否存在,也不碰磁盘。它存在的唯一理由是,路径的拼接和拆解比看上去麻烦得多。
最常用的四类:
join(a, b, …):把片段拼成一条路径,自动补上分隔符,并把多余的分隔符与.、..化简掉。resolve(a, b, …):从右往左拼,遇到绝对路径就停止,结果一定是绝对路径;如果拼到头还是相对的,就以当前工作目录为起点补全。dirname(p)/basename(p)/extname(p):拆出「所在目录 / 文件名 / 扩展名」。relative(from, to):算出从一个位置到另一个位置的相对写法,常用于把绝对路径缩短了显示给人看。
本章实验的真实输出,一眼看清 join 与手拼的差别:
join: out/notes/today.md
join 会化简: out/notes/today.md
手拼(错误示范): out//notes
dirname: samples | basename: poem.txt | extname: .txt第二行的输入是 join("out/", "/notes", "..", "notes", "./today.md")——两侧都带分隔符、中间还有 .. 和 ./,join 全部化简成了干净的结果。第三行的 dir + "/" + name(其中 dir 的值是 "out/")则拼出了 out//notes 这种带双斜杠的路径。
手拼字符串会在这些地方翻车:
- 分隔符重复或缺失:变量里带不带结尾的
/,你不总能保证; - 用户给的可能已经是绝对路径:这时再拼一个前缀就彻底错了(
resolve会自动识别并直接采用它); ..与.不会自己消失:"a/b" + "/../c"得到的字符串里一直留着..,比较两条路径是否相同时就会误判;- Windows 的分隔符是反斜杠:
join在 Windows 上会输出out\notes,硬编码/的代码换台机器就出问题。
join、resolve 对着一条根本不存在的路径也会正常返回结果,因为它们只做字符串计算。「这个文件在不在」是 fs 的问题,要用 stat(不存在会抛错)或 access 去问操作系统。把这两件事分开,是理解两个模块分工的关键。 图 2.8-1 path 负责算地址,fs 负责去现场
阅读顺序:从左到右。关键是中间那道分界:左半边全是内存里的字符串计算,路径存不存在都会有结果;右半边才真正与操作系统打交道,因此才会失败、才需要 await、才需要按 error.code 分类处理。本章实验 src/fs-tour.ts 的两个部分正好对应图的左右两半。
process:程序与外界的四个接口
process 是一个全局对象(不用 import 就能使用),代表当前这个进程自己。写命令行工具时最常用四样东西。
process.argv——命令行参数。它是一个字符串数组,形如 [node 可执行文件的路径, 脚本文件的路径, 用户参数...]。所以用户传的第一个参数在下标 2,这个 2 是新手最常踩的坑:
const target = process.argv[2]; // node script.ts a.txt → "a.txt"
if (target === undefined) {
console.error("用法:lines <文件路径>");
process.exitCode = 2;
return;
}process.env——环境变量。一张「变量名 → 字符串」的表,由启动进程时的外部环境决定。它的用处是:不改代码就能调整程序行为。API Key、日志开关、代理地址都走这条路。类型是 string | undefined(没设置就是 undefined),所以要用严格比较,而不是直接当布尔值用:
if (process.env.LINES_DEBUG === "1") { /* 打印调试信息 */ }process.cwd()——当前工作目录。这是相对路径的起点:进程在哪个目录被启动,"samples/poem.txt" 就相对哪里解析。注意它不是「脚本文件所在的目录」——同一个脚本换个目录运行,相对路径的含义就变了。想要「相对脚本自己」的路径,用 import.meta.dirname(Node.js 20.11 起提供)。
退出码(Exit Code)。进程结束时会给外界留一个整数:0 表示成功,非零表示失败。这是 Unix 世界的通用约定,shell 脚本、持续集成流水线、以及「负责运行命令的 Agent 工具」都靠它判断一条命令成没成功。两种写法:
process.exitCode = 1:留个字条,程序自然结束时用这个码退出——推荐;process.exit(1):立刻掐断,还没写完的输出可能丢失。
顺带说清另一件事:console.log 写到标准输出(stdout),console.error 写到标准错误(stderr)。用法提示、进度、警告应该走 stderr,只有「程序真正的产出」走 stdout——这样别人把你的输出重定向到文件时,拿到的才是干净的结果。
出错时:认 error.code,别认错误文案
fs 的调用失败时抛出的是一个普通的 Error,但上面多挂了一个 code 属性,值是一个稳定的短字符串:
| code | 含义 |
|---|---|
ENOENT | 文件或目录不存在(Error NO ENTry) |
EACCES / EPERM | 权限不足 |
EISDIR | 期望文件,拿到的是目录 |
ENOTDIR | 路径中间某一段不是目录 |
2.4 讲过,catch 拿到的值类型是 unknown,必须先收窄。标准写法是一个类型守卫(Type Guard):
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
return error instanceof Error && "code" in error;
}
try {
text = await readFile(absolutePath, "utf8");
} catch (error) {
if (isNodeError(error) && error.code === "ENOENT") {
console.error(`错误:文件不存在:${displayPath}`);
process.exitCode = 1;
return;
}
throw error; // 不认识的错误原样抛出,别静默吞掉
}为什么不去匹配 error.message 里的英文?因为错误文案是给人看的,会随 Node.js 版本和系统语言变化;code 才是给程序看的稳定标识。错误处理的完整方法论在 2.9 展开,这里先记住「先收窄、再看 code、不认识就抛回去」这条路径。
Pi 中哪里用到了它
Pi 的 read 工具是本章三组 API 的集大成者,但它自己的源码里一行 fs 都没有——这个细节值得看清楚:
async execute(_toolCallId, { path, offset, limit }, signal, _onUpdate, { env }) {
const absolutePath = await resolveReadToolPath(env, path, signal);
const bytes = getOrThrow(await env.readBinaryFile(absolutePath, signal));
const mimeType = detectSupportedImageMimeType(bytes);executeenv 的类型是 ExecutionEnv——一个 interface,把「读文件、写文件、列目录、跑命令」抽象成一组方法。工具只认这个 interface,不认 Node.js。从源码结构看,这样做至少买到两样东西:工具可以在没有 node:fs 的环境里运行(换一个远程执行的实现即可),以及测试时可以塞一个假的 env 而不必真的读写磁盘。
真正调用 Node.js 的是这个 interface 的 Node 实现,文件开头那串 import 就是本章的全部主角:
import { type ChildProcess, spawn } from "node:child_process";
// …(省略:randomUUID、createReadStream 两行)
import {
access,
appendFile,
lstat,
mkdir,
mkdtemp,
readdir,
readFile,
realpath,
rm,
writeFile,
} from "node:fs/promises";
import { homedir, tmpdir } from "node:os";
import { isAbsolute, join, resolve } from "node:path";node:fs/promises再看两个方法的实现,本章讲过的规矩都能在里面找到对应:
async readBinaryFile(path: string, abortSignal?: AbortSignal): Promise<Result<Uint8Array, FileError>> {
const resolved = resolvePath(this.cwd, path);
const aborted = abortResult<Uint8Array>(abortSignal, resolved);
if (aborted) return aborted;
try {
return ok(await readFile(resolved, { signal: abortSignal }));
} catch (error) {
return err(toFileError(error, resolved));
}
}
// …(省略:writeFile 的签名与两处 abort 检查)
await mkdir(resolve(resolved, ".."), { recursive: true });
await writeFile(resolved, content, { signal: abortSignal });readBinaryFile路径解析同样没有手拼:resolvePath(同文件第 50–64 行)先把 ~ 换成 homedir()、把 file:// 开头的换成真实路径,再用 isAbsolute 判断——已经是绝对路径就 resolve(normalized) 规范化,否则 resolve(cwd, normalized) 以工具的工作目录为起点。错误分类用的正是 switch (error.code):同文件的 toFileError(第 96–118 行)把 ENOENT 翻成 not_found、EACCES 翻成 permission_denied、ABORT_ERR 翻成 aborted,上层就再也不必认识 Node.js 的错误码了。
图解
图 2.8-2 一次 read 工具调用如何落到 node:fs/promises
阅读顺序:从上到下。请重点看第三层到第四层那道跳跃:工具只调用 env 上的方法(接口),具体用不用 Node.js 由实现决定——这是 Pi 把「工具逻辑」和「运行环境」分开的做法。最下面两个出口对应本章最后两节:成功路径要处理编码与截断,失败路径要按 error.code 分类。图中行号与本页 SourceRef 一致。
实践任务
labs/typescript-basics/11-files目标:把本章三组 API 一次用全——process.argv 取参数、node:path 算路径、node:fs/promises 读文件与元信息、error.code 分类错误、退出码报告成败。实验目录:labs/typescript-basics/11-files。
步骤:
进入实验目录,安装依赖并运行主程序:
shcd labs/typescript-basics/11-files npm install npm start -- samples/poem.txt对照
expected-output.txt核对输出;再跑npm run tour看node:path与 fs 五个动词的巡礼,并打开新生成的out/notes/today.md确认文件真的写出来了;跑三个失败场景,每次用
echo $?(fish 用echo $status)看退出码:不传参数(应为 2)、传samples/nope.txt(应为 1)、传samples(应为 1);打开调试旋钮看清 cwd 的作用:
LINES_DEBUG=1 npm start -- samples/poem.txt,再切到上一级目录用同样的相对路径运行,观察报错;加练:给程序加一个
--non-empty-only参数,只统计非空行——你需要自己决定怎么从process.argv里认出它。
预期现象:第 1 步输出与 expected-output.txt 逐字一致、退出码 0;第 3 步三次分别得到 2、1、1;第 4 步在上一级目录运行时报「文件不存在」,因为相对路径以 process.cwd() 为起点。
如何判断成功:三个退出码都对得上,并且你能口头回答——join("out/", "/notes") 为什么不会拼出双斜杠,而 "out/" + "/" + "notes" 会?
常见错误:忘了写 --,参数被 npm 自己吃掉,程序以为你没传文件;tsx: command not found 说明还没在实验目录里 npm install;调用 readFile 时忘了写 "utf8",于是打印出一堆字节而不是文本。
对应源码位置:packages/agent/src/harness/tools/read.ts 第 53–56 行(read 工具入口)、packages/agent/src/harness/env/nodejs.ts 第 1–19 行(node: 前缀的 import)与第 541–569 行(readFile / mkdir / writeFile 的真实调用)。
本章小结
- Node.js 标准库是程序通往外部世界的门;内置模块统一用
node:前缀导入,避免与node_modules里的同名包歧义,Pi 全仓库都这么写。 - 文件操作默认用
node:fs/promises:readFile/writeFile/mkdir/readdir/stat五个动词覆盖绝大多数需求;mkdir记得{ recursive: true },writeFile是覆盖不是追加,编码参数决定你拿到字符串还是字节。 - 同步 API(
readFileSync)会让唯一的线程原地停住,只适合进程启动那一小段时间;Agent 运行期一律用异步。 node:path只做字符串计算,不碰磁盘:join拼接并化简,resolve保证得到绝对路径,dirname/basename/extname拆解。手拼路径会在重复分隔符、绝对路径、..化简、Windows 分隔符四处翻车。process提供四个接口:argv(用户参数从下标 2 开始)、env(不改代码就能调行为)、cwd()(相对路径的起点,不是脚本所在目录)、退出码(0 成功、非零失败,优先用process.exitCode);提示信息走 stderr,产出走 stdout。- fs 的错误按
error.code分类(ENOENT、EACCES、EISDIR……),不要匹配错误文案;catch到的unknown先用类型守卫收窄。 - Pi 的 read 工具本身不调用
fs,而是调用ExecutionEnv接口的方法;Node.js 的实现NodeExecutionEnv才落到node:fs/promises,并在那里完成路径解析、signal传递与错误码翻译。
关键术语:标准库(Standard Library)、node: 前缀、文件系统(File System,fs)、node:fs/promises、编码(UTF-8)、Buffer / Uint8Array、元信息(stat)、同步 API(Sync)、路径(path)、join / resolve / dirname / relative、当前工作目录(process.cwd())、命令行参数(process.argv)、环境变量(process.env)、退出码(Exit Code)、标准输出 / 标准错误(stdout / stderr)、error.code / ENOENT、ExecutionEnv
关键源码索引:packages/agent/src/harness/tools/read.ts 第 53–56 行(read 工具 execute 的前三步);packages/agent/src/harness/env/nodejs.ts 第 1–19 行(node: 前缀 import)、第 50–64 行(resolvePath)、第 92–118 行(isNodeError 与 toFileError 的 switch (error.code))、第 541–569 行(readBinaryFile 与 writeFile)
自测问题:
join("/home/me", "../you", "notes.md")与resolve("/home/me", "../you", "notes.md")分别得到什么?如果把第二个参数换成"/tmp",两者的结果又有什么不同?- 一个程序在
/a/b目录下被启动,脚本文件位于/a/b/c/src/main.ts,代码里写了readFile("data.txt")。它实际会去读哪个文件?想读脚本旁边那个data.txt该怎么改? - 为什么判断「文件不存在」要用
error.code === "ENOENT",而不是检查错误信息里有没有no such file? - Pi 的 read 工具为什么不直接
import { readFile } from "node:fs/promises",而要绕一层env.readBinaryFile?这样换来了什么?
下一章预告:2.9 错误处理与测试基础——本章反复出现「出错了怎么办」:throw、catch、error.code、退出码。下一章把它们系统化:怎样设计自己的错误类型,怎样用 try/finally 保证收尾,以及怎样用 Node.js 内置的测试运行器给这些行为写上测试。这是进入 Agent 篇之前的最后一块基础。