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):

  1. 拿到 {port, pid, processName} 列表;

  2. 去重 PID 后批量查 ps,拿 ppid、rss、启动时间、完整命令行;

  3. 批量查每个进程的 cwd,沿父目录向上找 package.json / Cargo.toml / go.mod 等 marker 定位项目根;

  4. 框架识别三层:Docker 镜像名 → 命令行关键字(next / vite / uvicorn) → package.json 依赖;

  5. 状态判定:statZ 算 zombie,ppid==1 且像 dev server 算 orphaned;

  6. Docker 容器再调一次 docker ps --format 按 host port 做映射补一层。

简单、可读、易扩展。下面加的功能都是顺着这套数据流接进去的。

我加了什么

1. 按进程名批量杀:ports kill node bun

每天最常见的场景:node 进程开了七八个(编辑器内置、vite、playwright、worker、各种 dev server),一个个 ports kill 5173ports 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. 顺手做的清理

  • 移除 whoisonport bin 别名(没人用过);

  • 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 + push

prepublishOnly 会在 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 node

npm 包页:https://www.npmjs.com/package/@ropean/ports

Bug、想法、PR 都欢迎。下个目标可能是把 ports watch 拿来做 Slack/Linear 通知钩子 — 端口悄悄起来或挂掉时给自己一条消息,免得调试时盯着终端发呆。