# 命令参考

`bm2` 的全部命令、参数、行为与退出码。

## 总表

| 命令 | 参数 | 守护进程 | 作用 |
| --- | --- | --- | --- |
| `bm2 serve` | — | 自动拉起 | 生产模式登记并启动，读 `.env.production` |
| `bm2 dev` | `--no-follow` | 自动拉起 | 开发模式登记并启动，单实例，前台跟随日志 |
| `bm2 stop` | `[name]` | 自动拉起 | 停下实例，保留登记与停止标记 |
| `bm2 remove` | `[name]`、`-f` / `--force` | 自动拉起 | 退役：停下、注销、交还名字，日志默认保留 |
| `bm2 kill` | `-y` / `--all` | 自动拉起 | 停掉全部项目、注销它们、退出守护进程 |
| `bm2 list` | `[name]`、`--json` | 自动拉起 | 实例表，或机器可读 JSON |
| `bm2 check` | — | 不需要 | 校验当前目录的 `bm2.toml`，含端口探测 |
| `bm2 reload` | — | 自动拉起 | 换入新的 `bm2d`，应用继续运行 |
| `bm2 notify` | `[report]` | 自动拉起 | 给本项目的通知目标发一条测试消息 |
| `bm2 clean` | `--dry-run` | 不需要 | 清掉状态目录里无主的项目目录 |
| `bm2 upgrade` | — | 视情况 | 查最新版本、`moon install`、换班 |
| `bm2 version` | — | 不需要 | 打印版本 |

「自动拉起」的意思是端点文件不存在时，CLI 会按 `PATH` 启动 `bm2d`。所以只有 `check`、`clean`、`version` 是纯本地命令。

在工作目录上分两类：

- **读当前目录的 `bm2.toml`**：`serve`、`dev`、`check`、`notify`。
- **不读当前目录**：`stop`、`remove`、`kill`、`list`、`clean`、`reload`、`upgrade`、`version`。它们按名字或按状态目录寻址，在任何目录都能执行。

## 参数解析与退出码

参数由 `moonbitlang/core/argparse` 解析，子命令、位置参数与标志都在那里定义。

没有全局选项：`--json`、`--dry-run`、`-f`/`--force`、`--no-follow`、`-y`/`--all` 都挂在各自的子命令上。

也没有 `bm2 help` 子命令。不带参数执行 `bm2` 会打印帮助，并以 `2` 退出。

| 码 | 含义 |
| --- | --- |
| `0` | 成功 |
| `1` | 失败：配置不合法、守护进程拒绝、目标不存在、运行时版本不够、通知部分失败 |
| `2` | 用法错误，在询问守护进程之前就被参数解析拦下 |

会被参数解析拦下的两类：不带子命令（打印帮助后退出 `2`），以及多余或非法的参数。

```bash
bm2                     # 打印帮助，退出 2
bm2 reload extra        # 多余参数，退出 2
bm2 kill -y slow        # -y 只用于「全部」,带名字即用法错误，退出 2
```

另外两类必须分清：

- `bm2 kill slow` 是**语义**错误，不是用法错误。请求发到守护进程，被拒绝，退出 `1`。
- `bm2 stop` 不带项目名同理：守护进程回 `stop requires a project name`，退出 `1`。

## `serve`

在放着 `bm2.toml` 的目录执行，生产模式。

```bash
bm2 serve
```

它做四步：

1. 读当前目录的 `bm2.toml`，按 `production` 扫描 `.env` 文件。
2. 校验字段与占位符，并确认 `script` 指向的文件存在。
3. 让守护进程重新读一遍同一份配置，探测运行时（`bun --version` 或 `node --version`），不合格就拒绝，不登记任何东西。
4. 登记项目并启动实例。

登记时会做两件排他检查：

- 名字是否已被**另一份**配置文件占用。占用者路径会写进错误里。
- 端口范围是否与已在册项目重叠。冲突时点名两个范围。

每次 `serve` 都是一次完整重启：旧实例先按 `stop_timeout_ms` 停下，再从当前配置重建实例数组并逐个拉起。实例数、端口、脚本、执行模式的改动都在这一步生效。

对已停止的项目再执行一次，会清掉停止标记并恢复它。

输出是「进度行 + 一句结果 + 实例表」。

```text
api: starting
api: online
已启动
```

第一行与最后一句跟着项目语言，实例表的列也跟着项目语言。

## `dev`

同一套监督，换成开发模式。

```bash
bm2 dev                 # 启动后跟随日志，直到 Ctrl+C
bm2 dev --no-follow     # 发完请求就返回，脚本与 CI 用这个
```

三处差别：

- 读 `.env` 与 `.env.development`，`NODE_ENV` 是 `development`。
- **只跑一个实例**，配置里的 `instances` 不生效。开发会话就是一个进程监听一个端口。
- 启动后前台跟随该实例的日志。

跟随的行为值得说清：

- 先把 stdout 与 stderr 的末尾 10 行补打出来，所以「启动就崩」不会漏看。
- 之后每 `200ms` 追加新增内容，每 5 秒确认项目还在不在。
- **Ctrl+C 只停止跟随，应用继续运行**。第一行就会告诉你用 `bm2 stop <name>` 停它。
- 项目被注销（`bm2 remove`）后，跟随自动结束并说明原因。

`dev` 与 `serve` 的监督行为完全相同：崩溃重启、内存上限、重启预算、通知上报一视同仁。调试期的行为与线上不一致，调试就没有意义。

模式记在项目登记里，`bm2 list` 的 MODE 列看得到，`bm2 reload` 也不会把 dev 项目改回 production。

## `stop`

```bash
bm2 stop api
```

先 `SIGTERM` 给进程组，超过 `stop_timeout_ms` 仍未退出就 `SIGKILL`。

项目**保留在册**，只是不再运行：

- `bm2 list` 仍然列它，状态是 `stopped`。
- 停止标记持久化在 `project.json` 里，守护进程重启、`bm2 reload`、机器重启都不会把它带回来。
- 只有 `bm2 serve` 或 `bm2 dev` 能恢复它。

## `remove`

```bash
bm2 remove api        # 优雅停止，然后注销，日志保留
bm2 remove api -f     # 立即 SIGKILL，注销后连状态目录一起删
```

必须给项目名，未知名字是错误而不是静默成功。

不带 `-f`：每个实例 `SIGTERM`，超过 `stop_timeout_ms` 升级 `SIGKILL`，全部停下后删掉 `project.json`，项目从名册消失。`logs/` 目录原样保留。

带 `-f`：直接 `SIGKILL`，注销后由 CLI 删除 `<state_dir>/<name>/`，日志一起删。删除由 CLI 执行，并且只在项目从名册消失之后——否则就是从还握着这些文件的进程脚下把它们抽走。CLI 最多等 30 秒，超时则保留文件并报错退出 `1`。

退役之后名字立刻可用。新一代不继承任何东西：统计、重启历史、启动探测、通知的队列与去重状态，全部清空。

## `kill`

```bash
bm2 kill -y
```

停掉全部项目（强制 `SIGKILL`）、注销它们、退出守护进程。响应是 `bm2d stopping`。

三条边界：

- 裸 `bm2 kill` 拒绝执行，退出 `1`，并提示用 `-y`。
- **不接受项目名**。带名字退出 `1`，提示改用 `stop <name>` 或 `remove <name>`。生命周期与守护进程是两个层次，一个动词只做一件事。
- `-y` 与非空项目名同时出现，属于用法错误，退出 `2`。

对 `bm2d` 直接发 `SIGTERM` 或 `SIGINT`（例如 `systemctl restart`）走的是优雅路径：逐个项目 `stop`，等全部停下，再 `SIGKILL` 剩下的，最后注销全部项目并退出。区别只在于 `kill -y` 不等宽限期。

## `list`

```bash
bm2 list                # 全部项目 + 守护进程摘要
bm2 list api            # 只看一个项目
bm2 list --json         # 机器可读
bm2 list api --json     # 一个项目的机器可读输出
```

不带项目名时，先打印一行守护进程摘要，再打印实例表。

摘要的八列：`pid`、`fds`、`rss`、`uptime`、`instances`、`commands`、`restarts`、`removals`。

- `fds` 与 `rss` 来自 `/proc/<bm2d pid>/`。
- `commands` 是启动以来已应用的命令数，单调递增。
- `restarts` 是等待执行的整项目重启数，`removals` 是等待注销的项目数。

实例表的十一列：名称、实例、PID、端口、模式、执行、状态、次数、内存、运行时长、目录。

- 模式（MODE）是 `production` / `development`，即项目怎么被启动的。
- 执行（EXEC）是 `cluster` / `fork`，即实例怎么分端口。两件事都答「它是怎么跑的」，所以分两列。
- 次数（TIMES）是该实例累计被拉起的次数。
- 内存是最近一次采样值，未采到时显示 `-`。
- 未运行的实例 PID 与运行时长显示 `-`。

哪些行会隐藏：重启间隙的 `stopped` 实例不显示，`restarting` 与 `errored` 保留可见。被 `bm2 stop` 停下的项目相反——它的行留着，这正是 `stop` 与「消失」的区别。退役中的项目不列。

`--json` 输出一个数组，每个实例一个对象：

| 字段 | 含义 |
| --- | --- |
| `app_name` | 项目名 |
| `mode` | `production` / `development` |
| `cwd` | 工作目录 |
| `instance_id` | 实例编号 |
| `pid` | 进程号，未运行是 `0` |
| `port` | 该实例的端口 |
| `exec_mode` | `cluster` / `fork` |
| `status` | `starting` / `online` / `stopping` / `restarting` / `errored` / `stopped` |
| `times` | 累计启动次数 |
| `restart_count` | 当前连续异常重启次数 |
| `rss_kb` | 最近一次内存采样，未采到是 `-1` |
| `uptime_ms` | 本次运行时长（毫秒） |
| `last_reason` | 最后一次异常或停止的原因 |

`--json` 不带进度行，也不带守护进程摘要。没有实例时输出 `[]`。

查询未知项目退出 `1`。

```text
unknown app: nosuch
```

`list` 会按需拉起守护进程：端点文件不存在时会启动 `bm2d`。所以「只想看看」也会留一个空闲守护进程在跑，这是有意的——下一次命令就不必再等它起来。

## `check`

```bash
bm2 check
```

只读当前目录的 `bm2.toml`，不碰守护进程，不登记任何东西。部署前跑一次最划算。

它做的检查比「能不能解析」多三件：

1. 字段校验与 `__NAME__` 占位符展开（与 `serve` 同一套）。
2. 按 **production** 与 **development** 各读一遍。开发模式读的是另一组 `.env` 文件，只在生产视角通过并不等于两种模式都能跑。
3. `script` 指向的文件必须存在，并在整段端口范围内做一次 `bind` 探测。

输出三段。

```text
config ok: project "api", 2 x cluster on port 3000, runtime bun
notify: 2 report(s), 3 target(s), 1 periodic
ports 3000~3000: free
```

端口被占时按端口逐条说明，并区分共享监听者与独占监听者。

```text
port 3000: already held by a shared listener
```

这里也会看到本项目自己正在运行的实例，所以对一个在跑的项目执行 `check`，端口当然不空闲。

失败退出 `1`，错误定位到具体字段、脚本路径或端口。成功退出 `0`。

## `reload`

```bash
bm2 reload
```

在**不停止**被托管应用的前提下换入新的 `bm2d`。

1. 向当前守护进程发 detach 请求。
2. 旧守护进程删掉端点文件并退出，应用继续运行。
3. CLI 拉起新守护进程，新守护进程读注册表、用 pidfd 认出仍在运行的实例、校验环境后原地接管，PID 不变。

输出 `reloaded`。

换班的边界很清楚：

- **监督期的能力立刻生效**：重启预算、日志轮转、通知策略、内存采样。
- **启动期的参数要等应用下次拉起**：注入的环境变量、端口、运行时的 `PATH`。已经跑着的进程不会因为换班而换环境。

装了新 Bun 或 Node.js 之后必须 `reload`：守护进程的 `PATH` 在它启动那一刻定格，不换班就看不到新版本。

## `notify`

```bash
bm2 notify              # 发给本项目所有上报条目的全部目标
bm2 notify 故障告警      # 只发某一条上报的端点
```

在项目目录执行，目标来自那份 `bm2.toml`。

参数是 `[[notify.report]]` 的 `name`，不是平台名。写了一个不存在的名字，退出 `1` 并点名它。

几条行为要记住：

- 配置按 **production** 模式加载，所以占位符解析用 `.env.production` 里的值。
- 必须 `enabled = true` 且至少有一个 `[[notify.report.target]]`，否则直接拒绝并告诉你缺什么。
- 测试消息**绕过**事件筛选、去重与限流，直接投递。它证明的是端点和凭据可用，不是策略配对了。
- 多个目标并发投递，所以整条请求的等待上限取决于最慢的一个。
- 逐目标打印结果，平台名来自 `platform` 字段。

```text
feishu: 已发送
email: 失败 - connect timed out
```

任一目标失败，整体退出 `1`。

## `clean`

```bash
bm2 clean --dry-run     # 只列出会被删除的目录
bm2 clean               # 真删
```

纯本地执行，不需要守护进程。扫描状态目录的第一层，把**没有 `project.json` 的目录**视为无主：

- `bm2 remove` 之后的残留（退役保留日志，目录还在）。
- 项目目录被直接删掉、没走退役流程的残骸。
- 从另一台机器拷过来的状态目录。

判据刻意保守：登记一定会先写 `project.json`，所以最坏结果是漏删某个目录，而不可能是删掉在册项目的日志。

删除时只动 bm2 自己写的东西：`project.json`、`<name>-<id>.json`、`logs/` 下的文件。目录里若有别的文件，目录会留下并说明原因。

```text
bm2: 保留 /home/u/.bm2/api:里面有不是 bm2 写的文件
```

最后一行是汇总。

```text
已删除 3 个无主项目目录
```

## `upgrade`

```bash
bm2 upgrade
```

三步，顺序固定。

1. 查 mooncakes 上的最新版本（15 秒超时），与本机版本比较。已是最新则直接说明，不做任何安装。
2. `moon install chensuiyi/bm2/...`，把 `bm2` 与 `bm2d` 一起换进 `~/.moon/bin`。
3. 若守护进程在跑，先请它 detach，再拉起新版本接管。

失败时的行为：

| 情况 | 结果 |
| --- | --- |
| 查不到 registry | 退出 `1`，说明无法访问 mooncakes |
| 没有 `moon` 命令 | 退出 `1`，提示先安装 `moon` 再执行 `moon install` |
| `moon install` 失败 | 退出 `1`，旧版本原样保留 |
| 换班失败 | 退出 `0`，提示执行 `bm2 reload` |

应用全程继续运行。

同时只允许一个升级：`~/.bm2/upgrade.lock` 被另一个进程持有时立即退出 `1`，避免两个升级把同一批文件换来换去。

装了新版本但不想让它接管，或手工替换了二进制时，用 `bm2 reload`，效果相同。

## `version`

```bash
bm2 version
bm2 0.7.1
```

报问题时先带上它。CLI 与守护进程的协议版本必须配套，版本不一致时 `reload` 是第一建议。

## `bm2d`

守护进程可以被直接执行，但它**不接受任何参数**。

```bash
bm2d                    # 前台运行
bm2d anything           # 用法错误，退出 2
```

日常不需要手工启动它：`bm2` 在端点文件不存在时会按 `PATH` 找到 `bm2d` 并拉起。

同一时刻只允许一个守护进程。第二个会因 `bm2d.lock` 被占用而拒绝。

```text
bm2d: another daemon is already running
```

它自己接管 `SIGTERM` 与 `SIGINT`，收到后走优雅停机：逐个项目 `stop`、等实例退出、注销全部项目、删除端点文件与 pid 文件。所以 `systemctl restart bm2` 不会留下半截状态。

## 异步与进度行

`serve`、`dev`、`stop`、`remove` 都是异步的：守护进程立即应答，CLI 每 `200ms` 轮询一次状态，直到操作真正结束。

这样设计的原因很直接：一个 `stop_timeout_ms = 60000` 的应用不能把守护进程卡住一分钟，期间别的命令还得能用。

等待期间会打印状态变化，一行一次。

```text
api: online
api: stopping
api: 已停止
```

- 每行是一次观测到的状态变化。
- 实例离开列表时打印移除行。
- 超过等待上限时，结果那一句后面追加 `(等待超时)`，并照样打印当前状态的表。
- 按 Ctrl+C 只停止等待，不会取消已经在进行的操作。

`--json` 下不打印进度行，只输出结果。

## 超时与守护进程连接

CLI 对守护进程的请求超时不是固定值：

| 命令 | 等待上限 |
| --- | --- |
| 一般命令 | `max(3000, stop_timeout_ms + 3000)` 毫秒 |
| `notify` | 再与 `notify.timeout_ms + 3000` 取较大者 |
| 无配置可读时 | `60000` 毫秒 |

守护进程侧的边界同样固定：

- 命令应答最长等 `15s`，超时回一条「请稍后再试」。
- 并发控制连接上限 `64`，超出的连接被拒并记 `connection_refused`。
- 单条消息上限 `4 MiB`，握手超时 `5s`。

连接不上时的恢复路径：

1. 端点文件不存在 → 按 `PATH` 拉起 `bm2d`，最多等 3 秒。
2. 端点存在但无人监听（过期文件）→ 删掉端点、拉起新守护进程、重试一次。
3. 有人监听但不应答 → 直接报错，不重启守护进程。**忙不等于死**，把一个正在等停止超时的健康守护进程杀掉只会更糟。

```text
bm2d 正在运行,但在 13000ms 内没有响应,请稍后再试
```

## 语言

三条来源，优先级从高到低：`BM2_LANG`、`LC_ALL`、`LANG`。只读语言部分，所以 `zh`、`zh_CN.UTF-8`、`zh-Hans` 都是中文。`C`、`POSIX` 与不认识的语言都是英文。

关于某个项目的消息跟该项目的 `lang` 走：`bm2 list api` 的表头、针对它的状态提示、它自己的通知文案。

全量表、帮助、更新提示这类跟操作者环境走，因为没有任何一个项目能拥有它。

机器读的输出不参与翻译：事件日志与状态文件用固定键与英文值，脚本可以长期依赖。

```bash
BM2_LANG=en bm2 list    # 一次性覆盖
```
