先说结论:把一个 Node 写的开源 Agent 做成「拷到 U 盘、插到一台从没装过 Node 的电脑、解压双击就能跑」的便携包,难点不在改代码,而在你愿不愿意把"在我机器上能跑"这句话,一个字一个字拆掉,再到一台干净机器上真跑一遍。
这篇我把从动手到验收的完整链路写下来,包括我替你踩平的几个坑。技术栈以 Windows x64 为目标,思路通用。
一、为什么"源码能跑"远远不够
开发机上一切顺手:装好包管理器、装好依赖、node app.mjs gateway 起服务。但交付场景完全是另一回事------目标机可能没有 Node,没有包管理器,甚至下载源也不通。你让对方"先装个 Node 22 以上",很多单子就卡在这第一步。
所以我先定死一条验收线,后面所有决定都围着它转:
一台从没装过 Node 的 Windows,解压、双击、能起来;换台机器,配置和会话还在。
达不到这条,做得再花哨都是自嗨。为了过线,我把交付物拆成焊死在一起的四块:
runtime/------ 内置绿色 Node 运行时,目标机零安装;app/------ 程序构建产物 + 裁剪后的生产依赖;ruyi_workspace/------ 自包含状态目录,配置/密钥/会话全在包内;RuyiClaw.cmd------ 一键启动器。
二、一条命令产出整个包
我没做成"点七八个脚本",而是封装成一条 pnpm run pack 背后跑一个 scripts/pack.mjs。交付流程必须能一键复现,否则每次打包靠记忆,迟早出错。
2.1 内置绿色 Node
直接从 nodejs.org 取 win-x64 的 .zip (不是 .msi------msi 是要安装的,违背便携初衷),解压出 node.exe 放进 runtime/。脚本里用 fetch 下载、缓存,再解压:
js
const zipName = `node-v${version}-win-x64.zip`;
await downloadFile(`https://nodejs.org/dist/v${version}/${zipName}`, zipPath);
// 解压见第三节的 tar 坑
我固定到了 LTS 版本(22.22.3),因为入口脚本要求 Node ≥ 22.19,固定版本可复现。
2.2 裁剪生产依赖:npm pack 还是 pnpm deploy?
这是最容易翻车的地方。项目是 pnpm 工作区,还带一堆内置扩展。社区第一反应都是 pnpm deploy --prod。我一开始也想这么干,但停下来想清楚:交付包真正要的性质是「确定性」------同样的输入永远得到同样的产物,而且最好跟用户真实安装时拿到的一模一样。
我的选择是 npm pack,理由很实在:
npm pack严格按package.json的files白名单打包,还会带上npm-shrinkwrap.json;- 这恰恰是用户
npm i -g <pkg>时拿到的那份东西; - 把这个 tgz 解开当作
app/,再在里面npm install --omit=dev,靠 shrinkwrap 把依赖版本钉死------这次装 297 个包,下次还是这 297 个。
js
// 1) 产出与发布一致的 tarball
runStep("npm", ["pack", "--ignore-scripts", "--pack-destination", cacheDir]);
// 2) 解开为 app/,再用 shrinkwrap 钉死生产依赖
runStep("npm", ["install", "--omit=dev", "--no-audit", "--no-fund"], appDir);
pnpm deploy 当然也能用,但它的产物完整性要你自己反复验证,而 shrinkwrap 这条路本身就是发布路径,我更信它。
选型的本质不是比谁更专业,是比谁更贴合你真正要的那个性质。 我要确定性 + 和真实安装一致,npm pack 直接命中。
三、我替你踩过的坑:tar 不是只有一个 tar
第一次跑卡在解压,报错 tar: Cannot connect to D: resolve failed。这是 GNU tar 把 D:\... 里的盘符冒号当成了「远程主机:路径」,想去连一台叫 D 的机器。
换写法后,解压 Node 的 zip 又报 tar: This does not look like a tar archive------因为当前环境里 tar 解析成了 GNU tar,而 GNU tar 根本不处理 zip。
解法分两层:
- 给 tar 的路径一律用「相对文件名 + 设好 cwd」,绝不把带盘符冒号的绝对路径塞给它;
- 在 Windows 上直接固定调用
C:\Windows\System32\tar.exe,它是 bsdtar(libarchive),既能读写 zip 又能读 tgz,还认盘符。
js
const TAR_BIN = process.platform === "win32"
? path.join(process.env.SystemRoot || "C:\\Windows", "System32", "tar.exe")
: "tar";
这个坑的价值在于提醒你:便携包脚本的运行环境本身就是不确定的 ,同一个 tar 在 PowerShell、cmd、不同 shell 里可能是三个不同程序。做交付件,就得把这种隐性假设一个个挖出来钉死。
四、状态自包含,这块我盯得最紧
启动器 RuyiClaw.cmd 每一行都在为"便携"服务:
bat
set "PATH=%~dp0runtime;%PATH%"
set "OPENCLAW_STATE_DIR=%~dp0ruyi_workspace"
%~dp0 是脚本自己所在目录,所以无论拖到 U 盘还是别人桌面,路径都现算,没有任何硬编码绝对路径。首次运行从模板生成配置、复制出一个空的密钥模板,提示你填上自己的 key 再运行。我一个字节的 key 都没往包里塞,留的是空模板让你填------既是合规,也是对客户负责。
我没只靠"设了环境变量应该就行"收尾,而是做了个最硬的验证:把宿主机原本的状态目录整个改名挪走,用包内 Node、状态目录指向包里启动服务。结果服务在 127.0.0.1:18789 正常起来,而宿主机那个目录没有被重新创建,所有 sqlite、身份、会话全写进了包内。验完把目录改回去。承诺变成亲眼看过的事实,这才算数。
五、三层验收,必须真机验
我的验收分三层,每层对应客户会不会骂街:
- 能起来 :把系统 Node 从 PATH 摘干净,只留包内 runtime,
node app/app.mjs --version打出版本,服务绑上端口。证明目标机真不需要预装 Node。 - 状态写在包内:跑完去翻状态目录,配置/数据库/身份都在里面,宿主主目录纹丝不动。
- 换机器会话还在:状态全在包里,整包复制过去会话自然跟着走。
前两层我能在自己机器上验到死,第三层的物理动作(拿 U 盘插另一台机)是交付现场的事------脚本保证机制成立,最后那一插得你自己做。诚实地区分哪步我验了、哪步要你验,比拍胸脯重要。
六、合规这条线:改品牌不抹来源
底子是 MIT 许可的开源项目。我换上自己的交付名可以,但 LICENSE 和第三方声明原样打进包里,一个字不删。靠人家的开源底子吃饭,署名就得留着。
七、你能照搬的步骤
- 把"干净机器解压双击就能跑"写成你自己的验收标准;
- 把打包封成一条命令,别留一堆零散脚本;
- 挨个挖隐性假设:Node 哪来、依赖怎么锁、状态写哪、key 谁填、tar 是哪个 tar;
- 拿一台没装过 Node 的机器真跑一遍。
把这套跑通,你交付的就不再是"源码",而是一个产品。