给 lsof 套一层皮:fork 出 @ropean/ports
一个小周末项目:把熟人都用过的
lsof -iTCP -sTCP:LISTEN包成更顺手的 CLI,再加几个真正会天天用的功能,最后发到 npm。
为什么搞这个
每天开发,被 3000 already in use 怼回来的次数比写出 console.log 还多。lsof | grep 老套路够用但啰嗦,并且只能看到 PID、command 头几个字符,看不到这个 node 进程到底是哪个项目、哪个框架、跑了多久。
port-whisperer 把这件事做得很优雅:一张表格列出所有 LISTEN 端口,自动识别项目目录、框架、Docker 容器、内存、运行时间。我用了几天觉得真香,但还差几个开发体验上的小拼图,于是 fork 一份,改名 @ropean/ports,把缺的部分补上。
它的核心原理:三个 shell 调用拼起来的
读完源码才发现实现极简,全靠 child_process.execSync 调系统命令再正则解析输出,没有任何 native binding。按平台分发(src/platform/):
主流程(scanner.js:getListeningPorts):
拿到
{port, pid, processName}列表;去重 PID 后批量查
ps,拿 ppid、rss、启动时间、完整命令行;批量查每个进程的
cwd,沿父目录向上找package.json/Cargo.toml/go.mod等 marker 定位项目根;框架识别三层:Docker 镜像名 → 命令行关键字(
next/vite/uvicorn) →package.json依赖;状态判定:
stat含Z算 zombie,ppid==1且像 dev server 算 orphaned;Docker 容器再调一次
docker ps --format按 host port 做映射补一层。
简单、可读、易扩展。下面加的功能都是顺着这套数据流接进去的。
我加了什么
1. 按进程名批量杀:ports kill node bun
每天最常见的场景:node 进程开了七八个(编辑器内置、vite、playwright、worker、各种 dev server),一个个 ports kill 5173、ports kill 5174 太蠢。
新语法接受进程名作为参数,对 lsof 报上来的 processName 做大小写无关精确匹配,然后批量杀:
ports kill node # 杀所有 listening 的 node 进程
ports kill node bun # 多个名字一起
ports kill node 3000 4321 # 名字、端口、PID 混着传都行
ports kill -f node # SIGKILL 版
ports kill -y node # 跳过确认实现要点:
只匹配正在监听端口的进程,不是
killall— 避免误伤编辑器内嵌的 node、后台 worker;同 PID 多端口(一个进程绑 IPv4 + IPv6)按 PID 去重,列表只显示首端口;
多于一个目标时列出全部 + 确认,跑批前可以最后一眼;
与端口范围
3000-3010、PID、单端口完全兼容,一条命令混传。
$ ports kill node
About to kill 17 processes (SIGTERM):
• :3000 — node (PID 42872)
• :5173 — node (PID 95380)
...
Proceed? [Y/n]2. 确认提示默认 Y
原版三处 prompt 都是 [y/N],回车默认中止。我用 ports 的 95% 时间就是想杀进程,每次回车被告诉 Aborted 反而是噪声。全部改成 [Y/n]:回车 / y / yes → 执行,n / no → 中止。
注:批量杀默认 Y 风险其实不低。但既然已经列出全部目标 + 显式按了 Enter,符合"我看过了,干"的心智模型。
3. ports -h 两列对齐
原版用硬编码空格 pad,几条命令长度差了 6 个字符以后就歪了。改成数据驱动:
const helpRows = [
["ports", "Show dev server ports"],
["ports kill <n>", "Kill by port, PID, range, or name (...)"],
// ...
];
const cmdWidth = Math.max(...helpRows.map((r) => r[0].length));
for (const [cmd, desc] of helpRows) {
const padded = cmd + " ".repeat(cmdWidth - cmd.length);
console.log(` ${chalk.cyan(padded)} ${desc}`);
}注意 pad 必须在上色前算 — chalk.cyan() 包了 ANSI 转义序列,.length 会把那些不可见字符也算进去,对齐就崩了。先 pad 字符串、再上色。
4. 顺手做的清理
移除
whoisonportbin 别名(没人用过);加
link:global/unlink:global脚本:本地开发不用node src/index.js,链一次直接用ports;切到 pnpm lockfile。
关于 pnpm 缓存(顺便记一笔)
试装的时候 pnpx ports 57630 输出 +1 PACKAGE,但我在项目里翻不到任何 node_modules。原因:pnpx = pnpm dlx,装到了独立的临时缓存:
~/Library/Caches/pnpm/dlx/<sha256>/pkg/node_modules/ports/实际文件硬链接到全局内容寻址 store ~/Library/pnpm/store/v10/<hash>/。同 hash 下次直接复用,所以 +1 只出现一次。清理:pnpm store prune 或直接 rm -rf ~/Library/Caches/pnpm/dlx。
发布到 npm
scoped 包(@ropean/ports)默认 private,需要显式声明 public。package.json 里加:
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
},
"scripts": {
"prepublishOnly": "node --check src/index.js",
"publish:dry": "npm publish --access public --dry-run",
"release:patch": "npm version patch && npm publish --access public && git push --follow-tags",
"release:minor": "npm version minor && npm publish --access public && git push --follow-tags",
"release:major": "npm version major && npm publish --access public && git push --follow-tags"
}发布流程:
# 1. 登录
npm login
# 2. 确认账号已加入 ropean org
npm whoami
# 3. 预演 — 看 tarball 内容、文件清单、有无 warning
pnpm publish:dry
# 4. 首发(package.json 已是 0.0.1,不想 bump 就直接发)
npm publish --access public
# 5. 后续迭代
pnpm release:patch # 0.0.1 → 0.0.2,自动 tag + pushprepublishOnly 会在 publish 前先 node --check 跑一遍语法体检,避免把语法错误发上去。
几个坑:
首次发布必须
--access public— 免费账号下,scoped 包默认 private,npm 直接拒;git remote必须指向你自己的 repo。fork 后默认 origin 还是上游,release:patch走完npm publish才去git push,如果 push 失败你就有了一个发出去但没 tag 的版本;files字段限制 tarball 内容。这个项目只发src/,不会把node_modules、测试、.env之类塞进去。
鸣谢 & 链接
感谢原作者 Larsen Cundric 写出 port-whisperer — 简洁、好读、易扩展的代码骨架是这次 fork 顺利的根本。fork 不是为了取代,是因为基础足够好才舍得在上面继续做。
我这版欢迎一试:
npm install -g @ropean/ports
ports
ports kill nodenpm 包页:https://www.npmjs.com/package/@ropean/ports
Bug、想法、PR 都欢迎。下个目标可能是把 ports watch 拿来做 Slack/Linear 通知钩子 — 端口悄悄起来或挂掉时给自己一条消息,免得调试时盯着终端发呆。